diff --git a/package.json b/package.json index 3c7cb5375..3f7c9e761 100644 --- a/package.json +++ b/package.json @@ -61,10 +61,10 @@ }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.142", - "@anthropic-ai/sdk": "^0.100.1", + "@anthropic-ai/sdk": "^0.102.0", "@clack/prompts": "^1.2.0", "dotenv": "^17.4.2", - "iii-sdk": "0.11.2", + "iii-sdk": "0.19.0", "zod": "^4.0.0" }, "optionalDependencies": { @@ -76,7 +76,7 @@ }, "devDependencies": { "@types/node": "^25.9.1", - "tsdown": "^0.21.10", + "tsdown": "^0.22.2", "tsx": "^4.19.0", "typescript": "^6.0.3", "vitest": "^4.1.6" diff --git a/src/functions/compress-synthetic.ts b/src/functions/compress-synthetic.ts index 28d17e979..30605bca1 100644 --- a/src/functions/compress-synthetic.ts +++ b/src/functions/compress-synthetic.ts @@ -26,6 +26,21 @@ function inferType( .replace(/([a-z])([A-Z])/g, "$1_$2") .replace(/[-\s]+/g, "_") .toLowerCase(); + // Recognize conversation-shaped tool names. Several integrations + // (notably the Hermes plugin) use tool_name: "conversation" as a + // generic catch-all for user/assistant turns. Without this branch the + // synthetic compressor stores the observation as type "other" with a + // title that is the literal string "conversation", which then renders in + // the memory-context block as `- [other] conversation: `. + if ( + n === "conversation" || + n === "chat" || + n === "prompt" || + n === "message" || + n === "turn" + ) { + return "conversation"; + } const hasWord = (word: string) => new RegExp(`(^|_)${word}(_|$)`).test(n) || n === word || @@ -34,13 +49,13 @@ function inferType( if (["fetch", "http", "web"].some(hasWord)) return "web_fetch"; if (["grep", "search", "glob", "find"].some(hasWord)) return "search"; if (["bash", "shell", "exec", "run"].some(hasWord)) return "command_run"; - if (["edit", "update", "patch", "replace"].some(hasWord)) return "file_edit"; + if (["edit", "update", "patch", "replace"].some(hasWord)) + return "file_edit"; if (["write", "create"].some(hasWord)) return "file_write"; if (["read", "view"].some(hasWord)) return "file_read"; if (["task", "agent"].some(hasWord)) return "subagent"; return "other"; } - function extractFiles(input: unknown): string[] { if (!input || typeof input !== "object") return []; const o = input as Record; @@ -90,7 +105,21 @@ export function buildSyntheticCompression( sessionId: raw.sessionId, timestamp: raw.timestamp, type: inferType(toolName, raw.hookType), - title: truncate(toolName || "observation", 80), + // For generic tool names ("conversation", "other", "observation", + // "tool") fall back to deriving a descriptive title from the user + // prompt or the tool input. Without this, a tool_name of + // "conversation" produces a title that is the literal string + // "conversation" — useless in the rendered memory-context block. + title: + toolName && + !["conversation", "other", "observation", "tool"].includes( + toolName.toLowerCase(), + ) + ? truncate(toolName, 80) + : truncate( + promptStr || inputStr || "observation", + 80, + ), subtitle: inputStr ? truncate(inputStr, 120) : undefined, facts: [], narrative: truncate(narrativeParts.join(" | "), 400), diff --git a/src/functions/context.ts b/src/functions/context.ts index 1e6102cf3..2f744b498 100644 --- a/src/functions/context.ts +++ b/src/functions/context.ts @@ -174,16 +174,67 @@ export function registerContextFunction( for (let j = 0; j < sessionsNeedingObs.length; j++) { const i = sessionsNeedingObs[j]; const observations = obsResults[j]; + // Tighten the filter: drop observations with empty narrative so + // they never reach the renderer and produce dangling-colon lines + // like `- [other] conversation: `. const important = observations.filter( - (o) => o.title && o.importance >= 5, + (o) => + o.title && + o.importance >= 5 && + (o.narrative || "").trim().length > 0, ); if (important.length > 0) { const top = important .sort((a, b) => b.importance - a.importance) .slice(0, 5); + // Defensive renderer: when the stored title is empty, equals the + // type, or is a generic tool name like "conversation", fall back + // to the subtitle (user message) as the title and use the + // narrative as the body. This handles observations stored before + // the inferType fix landed, where the synthetic compressor + // used the raw tool name as the title. Without this fallback + // those observations render as `- [other] conversation: ` with + // a dangling colon and no content. + const GENERIC_TITLES = new Set([ + "conversation", + "other", + "observation", + "tool", + ]); const items = top - .map((o) => `- [${o.type}] ${o.title}: ${o.narrative}`) + .map((o) => { + const titleIsGeneric = + !o.title || + o.title === o.type || + GENERIC_TITLES.has(o.title); + const title = titleIsGeneric + ? o.subtitle + ? o.subtitle.slice(0, 80) + : o.narrative + ? o.narrative + .slice(0, 80) + .replace(/\|/g, " ") + .trim() + : o.type + : o.title; + // Avoid title/content duplication when the title was + // derived from the start of the narrative. + let narrative = o.narrative || o.subtitle || ""; + if ( + title && + narrative + .toLowerCase() + .startsWith(title.toLowerCase().slice(0, 40)) + ) { + narrative = narrative + .slice(title.length) + .replace(/^[\s|:,\-]+/, "") + .trim(); + if (!narrative) narrative = o.narrative || ""; + } + return `- [${o.type}] ${title}: ${narrative}`; + }) .join("\n"); const content = `## Session ${sessions[i].id.slice(0, 8)} (${sessions[i].startedAt})\n${items}`; blocks.push({