7fe85a11da
- Try both targetIndex and targetIndex-1 to handle system message offset - Remove 'last assistant message' skip logic (API error means it's not final) 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
278 lines
7.2 KiB
TypeScript
278 lines
7.2 KiB
TypeScript
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
|
import { join } from "node:path"
|
|
import { MESSAGE_STORAGE, PART_STORAGE, THINKING_TYPES, META_TYPES } from "./constants"
|
|
import type { StoredMessageMeta, StoredPart, StoredTextPart } from "./types"
|
|
|
|
export function generatePartId(): string {
|
|
const timestamp = Date.now().toString(16)
|
|
const random = Math.random().toString(36).substring(2, 10)
|
|
return `prt_${timestamp}${random}`
|
|
}
|
|
|
|
export function getMessageDir(sessionID: string): string {
|
|
if (!existsSync(MESSAGE_STORAGE)) return ""
|
|
|
|
const directPath = join(MESSAGE_STORAGE, sessionID)
|
|
if (existsSync(directPath)) {
|
|
return directPath
|
|
}
|
|
|
|
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
|
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
|
if (existsSync(sessionPath)) {
|
|
return sessionPath
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
export function readMessages(sessionID: string): StoredMessageMeta[] {
|
|
const messageDir = getMessageDir(sessionID)
|
|
if (!messageDir || !existsSync(messageDir)) return []
|
|
|
|
const messages: StoredMessageMeta[] = []
|
|
for (const file of readdirSync(messageDir)) {
|
|
if (!file.endsWith(".json")) continue
|
|
try {
|
|
const content = readFileSync(join(messageDir, file), "utf-8")
|
|
messages.push(JSON.parse(content))
|
|
} catch {
|
|
continue
|
|
}
|
|
}
|
|
|
|
return messages.sort((a, b) => {
|
|
const aTime = a.time?.created ?? 0
|
|
const bTime = b.time?.created ?? 0
|
|
if (aTime !== bTime) return aTime - bTime
|
|
return a.id.localeCompare(b.id)
|
|
})
|
|
}
|
|
|
|
export function readParts(messageID: string): StoredPart[] {
|
|
const partDir = join(PART_STORAGE, messageID)
|
|
if (!existsSync(partDir)) return []
|
|
|
|
const parts: StoredPart[] = []
|
|
for (const file of readdirSync(partDir)) {
|
|
if (!file.endsWith(".json")) continue
|
|
try {
|
|
const content = readFileSync(join(partDir, file), "utf-8")
|
|
parts.push(JSON.parse(content))
|
|
} catch {
|
|
continue
|
|
}
|
|
}
|
|
|
|
return parts
|
|
}
|
|
|
|
export function hasContent(part: StoredPart): boolean {
|
|
if (THINKING_TYPES.has(part.type)) return false
|
|
if (META_TYPES.has(part.type)) return false
|
|
|
|
if (part.type === "text") {
|
|
const textPart = part as StoredTextPart
|
|
return !!(textPart.text?.trim())
|
|
}
|
|
|
|
if (part.type === "tool" || part.type === "tool_use") {
|
|
return true
|
|
}
|
|
|
|
if (part.type === "tool_result") {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
export function messageHasContent(messageID: string): boolean {
|
|
const parts = readParts(messageID)
|
|
return parts.some(hasContent)
|
|
}
|
|
|
|
export function injectTextPart(sessionID: string, messageID: string, text: string): boolean {
|
|
const partDir = join(PART_STORAGE, messageID)
|
|
|
|
if (!existsSync(partDir)) {
|
|
mkdirSync(partDir, { recursive: true })
|
|
}
|
|
|
|
const partId = generatePartId()
|
|
const part: StoredTextPart = {
|
|
id: partId,
|
|
sessionID,
|
|
messageID,
|
|
type: "text",
|
|
text,
|
|
synthetic: true,
|
|
}
|
|
|
|
try {
|
|
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export function findEmptyMessages(sessionID: string): string[] {
|
|
const messages = readMessages(sessionID)
|
|
const emptyIds: string[] = []
|
|
|
|
for (const msg of messages) {
|
|
if (!messageHasContent(msg.id)) {
|
|
emptyIds.push(msg.id)
|
|
}
|
|
}
|
|
|
|
return emptyIds
|
|
}
|
|
|
|
export function findEmptyMessageByIndex(sessionID: string, targetIndex: number): string | null {
|
|
const messages = readMessages(sessionID)
|
|
|
|
// Try multiple indices to handle system message offset
|
|
// API includes system message at index 0, storage may not
|
|
const indicesToTry = [targetIndex, targetIndex - 1]
|
|
|
|
for (const idx of indicesToTry) {
|
|
if (idx < 0 || idx >= messages.length) continue
|
|
|
|
const targetMsg = messages[idx]
|
|
|
|
// NOTE: Do NOT skip last assistant message here
|
|
// If API returned an error, this message is NOT the final assistant message
|
|
// (the API only allows empty content for the ACTUAL final assistant message)
|
|
|
|
if (!messageHasContent(targetMsg.id)) {
|
|
return targetMsg.id
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
export function findFirstEmptyMessage(sessionID: string): string | null {
|
|
const emptyIds = findEmptyMessages(sessionID)
|
|
return emptyIds.length > 0 ? emptyIds[0] : null
|
|
}
|
|
|
|
export function findMessagesWithThinkingBlocks(sessionID: string): string[] {
|
|
const messages = readMessages(sessionID)
|
|
const result: string[] = []
|
|
|
|
for (const msg of messages) {
|
|
if (msg.role !== "assistant") continue
|
|
|
|
const parts = readParts(msg.id)
|
|
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
|
|
if (hasThinking) {
|
|
result.push(msg.id)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
export function findMessagesWithOrphanThinking(sessionID: string): string[] {
|
|
const messages = readMessages(sessionID)
|
|
const result: string[] = []
|
|
|
|
for (let i = 0; i < messages.length; i++) {
|
|
const msg = messages[i]
|
|
if (msg.role !== "assistant") continue
|
|
|
|
// NOTE: Removed isLastMessage skip - recovery needs to fix last message too
|
|
// when "thinking must start with" errors occur on final assistant message
|
|
|
|
const parts = readParts(msg.id)
|
|
if (parts.length === 0) continue
|
|
|
|
const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id))
|
|
const firstPart = sortedParts[0]
|
|
|
|
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
|
|
|
|
// NOTE: Changed condition - if first part is not thinking, it's orphan
|
|
// regardless of whether thinking blocks exist elsewhere in the message
|
|
if (!firstIsThinking) {
|
|
result.push(msg.id)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
|
|
const partDir = join(PART_STORAGE, messageID)
|
|
|
|
if (!existsSync(partDir)) {
|
|
mkdirSync(partDir, { recursive: true })
|
|
}
|
|
|
|
const partId = `prt_0000000000_thinking`
|
|
const part = {
|
|
id: partId,
|
|
sessionID,
|
|
messageID,
|
|
type: "thinking",
|
|
thinking: "",
|
|
synthetic: true,
|
|
}
|
|
|
|
try {
|
|
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export function stripThinkingParts(messageID: string): boolean {
|
|
const partDir = join(PART_STORAGE, messageID)
|
|
if (!existsSync(partDir)) return false
|
|
|
|
let anyRemoved = false
|
|
for (const file of readdirSync(partDir)) {
|
|
if (!file.endsWith(".json")) continue
|
|
try {
|
|
const filePath = join(partDir, file)
|
|
const content = readFileSync(filePath, "utf-8")
|
|
const part = JSON.parse(content) as StoredPart
|
|
if (THINKING_TYPES.has(part.type)) {
|
|
unlinkSync(filePath)
|
|
anyRemoved = true
|
|
}
|
|
} catch {
|
|
continue
|
|
}
|
|
}
|
|
|
|
return anyRemoved
|
|
}
|
|
|
|
export function findMessageByIndexNeedingThinking(sessionID: string, targetIndex: number): string | null {
|
|
const messages = readMessages(sessionID)
|
|
|
|
if (targetIndex < 0 || targetIndex >= messages.length) return null
|
|
|
|
const targetMsg = messages[targetIndex]
|
|
if (targetMsg.role !== "assistant") return null
|
|
|
|
const parts = readParts(targetMsg.id)
|
|
if (parts.length === 0) return null
|
|
|
|
const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id))
|
|
const firstPart = sortedParts[0]
|
|
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
|
|
|
|
if (!firstIsThinking) {
|
|
return targetMsg.id
|
|
}
|
|
|
|
return null
|
|
}
|