e7f5ab4c45
Expand the search range when finding empty messages by index to better handle API index vs storage index mismatches. This increases robustness when searching for messages to sanitize with more fallback indices. 🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
391 lines
10 KiB
TypeScript
391 lines
10 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)
|
|
|
|
// API index may differ from storage index due to system messages
|
|
const indicesToTry = [
|
|
targetIndex,
|
|
targetIndex - 1,
|
|
targetIndex + 1,
|
|
targetIndex - 2,
|
|
targetIndex + 2,
|
|
targetIndex - 3,
|
|
targetIndex - 4,
|
|
targetIndex - 5,
|
|
]
|
|
|
|
for (const idx of indicesToTry) {
|
|
if (idx < 0 || idx >= messages.length) continue
|
|
|
|
const targetMsg = messages[idx]
|
|
|
|
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 findMessagesWithThinkingOnly(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)
|
|
if (parts.length === 0) continue
|
|
|
|
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
|
|
const hasTextContent = parts.some(hasContent)
|
|
|
|
// Has thinking but no text content = orphan thinking
|
|
if (hasThinking && !hasTextContent) {
|
|
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
|
|
}
|
|
|
|
/**
|
|
* Find the most recent thinking content from previous assistant messages
|
|
* Following Anthropic's recommendation to include thinking blocks from previous turns
|
|
*/
|
|
function findLastThinkingContent(sessionID: string, beforeMessageID: string): string {
|
|
const messages = readMessages(sessionID)
|
|
|
|
// Find the index of the current message
|
|
const currentIndex = messages.findIndex(m => m.id === beforeMessageID)
|
|
if (currentIndex === -1) return ""
|
|
|
|
// Search backwards through previous assistant messages
|
|
for (let i = currentIndex - 1; i >= 0; i--) {
|
|
const msg = messages[i]
|
|
if (msg.role !== "assistant") continue
|
|
|
|
// Look for thinking parts in this message
|
|
const parts = readParts(msg.id)
|
|
for (const part of parts) {
|
|
if (THINKING_TYPES.has(part.type)) {
|
|
// Found thinking content - return it
|
|
// Note: 'thinking' type uses 'thinking' property, 'reasoning' type uses 'text' property
|
|
const thinking = (part as { thinking?: string; text?: string }).thinking
|
|
const reasoning = (part as { thinking?: string; text?: string }).text
|
|
const content = thinking || reasoning
|
|
if (content && content.trim().length > 0) {
|
|
return content
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
|
|
const partDir = join(PART_STORAGE, messageID)
|
|
|
|
if (!existsSync(partDir)) {
|
|
mkdirSync(partDir, { recursive: true })
|
|
}
|
|
|
|
// Try to get thinking content from previous turns (Anthropic's recommendation)
|
|
const previousThinking = findLastThinkingContent(sessionID, messageID)
|
|
|
|
const partId = `prt_0000000000_thinking`
|
|
const part = {
|
|
id: partId,
|
|
sessionID,
|
|
messageID,
|
|
type: "thinking",
|
|
thinking: previousThinking || "[Continuing from previous reasoning]",
|
|
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 replaceEmptyTextParts(messageID: string, replacementText: string): boolean {
|
|
const partDir = join(PART_STORAGE, messageID)
|
|
if (!existsSync(partDir)) return false
|
|
|
|
let anyReplaced = 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 (part.type === "text") {
|
|
const textPart = part as StoredTextPart
|
|
if (!textPart.text?.trim()) {
|
|
textPart.text = replacementText
|
|
textPart.synthetic = true
|
|
writeFileSync(filePath, JSON.stringify(textPart, null, 2))
|
|
anyReplaced = true
|
|
}
|
|
}
|
|
} catch {
|
|
continue
|
|
}
|
|
}
|
|
|
|
return anyReplaced
|
|
}
|
|
|
|
export function findMessagesWithEmptyTextParts(sessionID: string): string[] {
|
|
const messages = readMessages(sessionID)
|
|
const result: string[] = []
|
|
|
|
for (const msg of messages) {
|
|
const parts = readParts(msg.id)
|
|
const hasEmptyTextPart = parts.some((p) => {
|
|
if (p.type !== "text") return false
|
|
const textPart = p as StoredTextPart
|
|
return !textPart.text?.trim()
|
|
})
|
|
|
|
if (hasEmptyTextPart) {
|
|
result.push(msg.id)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
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
|
|
}
|