diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cea68b95f..693d551b2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -14,6 +14,7 @@ - Fixed selector dialogs (the `ask` tool, hook prompts) collapsing to a single visible option on shorter terminals when options carried long descriptions: the highlighted option's wrapped description consumed the entire row budget, hiding every other option and making the menu feel unnavigable (down moved the lone visible entry, left/right did nothing). When the fully-expanded list overflows, `HookSelectorComponent` now renders a compact list — every option label stays on screen and only the highlighted option expands its description, truncated to the remaining rows — so the whole menu is always visible and the detail pane follows the cursor. - Fixed `read` failing with "Path not found" on web URLs whose scheme `//` collapsed to a single `/` (e.g. `https:/github.com/...`), which happens when a URL is routed through Node's `path.normalize`/`path.resolve`. The fetch URL recognizer now accepts a single-slash scheme and repairs it back to `//` before fetching, so collapsed URLs resolve instead of falling through to filesystem lookup. +- Fixed Hindsight retain (and the shared mnemopi/Hindsight recall paths) framing assistant turns whose only content was punctuation/whitespace — most commonly the lone `.` some providers emit for tool-call-only or thinking-only turns — into `[role: assistant]\n.\n[assistant:end]` blocks that polluted the bank, wasted retain tokens, and degraded recall. `prepareRetentionTranscript`, `extractMessages`, and `flattenMessagesForRecall` now require at least one letter or digit per message via a shared `hasSubstantiveContent` predicate ([#1806](https://github.com/can1357/oh-my-pi/issues/1806)). ## [15.8.3] - 2026-06-03 ### Fixed diff --git a/packages/coding-agent/src/hindsight/backend.ts b/packages/coding-agent/src/hindsight/backend.ts index 2d00dee27..e57490995 100644 --- a/packages/coding-agent/src/hindsight/backend.ts +++ b/packages/coding-agent/src/hindsight/backend.ts @@ -15,7 +15,7 @@ import type { AgentSession } from "../session/agent-session"; import { computeBankScope } from "./bank"; import { createHindsightClient } from "./client"; import { isHindsightConfigured, loadHindsightConfig } from "./config"; -import type { HindsightMessage } from "./content"; +import { hasSubstantiveContent, type HindsightMessage } from "./content"; import { HindsightSessionState } from "./state"; const STATIC_INSTRUCTIONS = [ @@ -181,7 +181,7 @@ function flattenMessagesForRecall(messages: AgentMessage[]): HindsightMessage[] if (msg.role === "user") { const content = msg.content; if (typeof content === "string") { - if (content.trim()) out.push({ role: "user", content }); + if (hasSubstantiveContent(content)) out.push({ role: "user", content }); continue; } if (Array.isArray(content)) { @@ -189,7 +189,7 @@ function flattenMessagesForRecall(messages: AgentMessage[]): HindsightMessage[] .filter((b): b is { type: "text"; text: string } => !!b && (b as { type?: unknown }).type === "text") .map(b => b.text) .join("\n"); - if (text.trim()) out.push({ role: "user", content: text }); + if (hasSubstantiveContent(text)) out.push({ role: "user", content: text }); } continue; } @@ -198,7 +198,7 @@ function flattenMessagesForRecall(messages: AgentMessage[]): HindsightMessage[] .filter((b): b is { type: "text"; text: string } => b.type === "text") .map(b => b.text) .join("\n"); - if (text.trim()) out.push({ role: "assistant", content: text }); + if (hasSubstantiveContent(text)) out.push({ role: "assistant", content: text }); } } return out; diff --git a/packages/coding-agent/src/hindsight/content.ts b/packages/coding-agent/src/hindsight/content.ts index 38e86ef58..e2fe661f3 100644 --- a/packages/coding-agent/src/hindsight/content.ts +++ b/packages/coding-agent/src/hindsight/content.ts @@ -43,6 +43,22 @@ export function stripMemoryTags(content: string): string { .replace(LEGACY_RELEVANT_MEMORIES_REGEX, ""); } +// At least one letter or digit means the message carries a token a retriever +// can actually match on. Punctuation/whitespace-only strings (e.g. the lone +// `.` some providers emit for tool-call-only or thinking-only assistant turns) +// are dropped before retain/recall touches them — see issue #1806. +const SUBSTANTIVE_CHAR_RE = /[\p{L}\p{N}]/u; + +/** + * True when `content` carries at least one letter or digit. Used by retain + * and recall paths to drop placeholder assistant turns ("." / "..." / pure + * whitespace) that would otherwise pollute the bank and waste tokens on + * embeddings with no semantic content. + */ +export function hasSubstantiveContent(content: string): boolean { + return SUBSTANTIVE_CHAR_RE.test(content); +} + /** Format recall results into a bullet list for context injection. */ export function formatMemories(results: RecallResultLike[]): string { if (results.length === 0) return ""; @@ -197,7 +213,7 @@ export function prepareRetentionTranscript( const parts: string[] = []; for (const msg of targetMessages) { const content = stripMemoryTags(msg.content).trim(); - if (!content) continue; + if (!hasSubstantiveContent(content)) continue; parts.push(`[role: ${msg.role}]\n${content}\n[${msg.role}:end]`); } diff --git a/packages/coding-agent/src/hindsight/transcript.ts b/packages/coding-agent/src/hindsight/transcript.ts index df4c7f4b1..7fcf0ffa3 100644 --- a/packages/coding-agent/src/hindsight/transcript.ts +++ b/packages/coding-agent/src/hindsight/transcript.ts @@ -9,7 +9,7 @@ import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import type { SessionEntry } from "../session/session-manager"; -import type { HindsightMessage } from "./content"; +import { type HindsightMessage, hasSubstantiveContent } from "./content"; export interface ReadonlySessionManagerLike { getEntries(): SessionEntry[]; @@ -39,7 +39,7 @@ export function extractMessages(sessionManager: ReadonlySessionManagerLike): Hin if (role !== "user" && role !== "assistant") continue; const text = role === "user" ? extractUserText(msg) : extractAssistantText(msg as AssistantMessage); - if (text.length === 0) continue; + if (!hasSubstantiveContent(text)) continue; messages.push({ role, content: text }); } diff --git a/packages/coding-agent/test/hindsight-content.test.ts b/packages/coding-agent/test/hindsight-content.test.ts index c51bd9afe..d82a0a4dc 100644 --- a/packages/coding-agent/test/hindsight-content.test.ts +++ b/packages/coding-agent/test/hindsight-content.test.ts @@ -3,6 +3,7 @@ import { composeRecallQuery, formatCurrentTime, formatMemories, + hasSubstantiveContent, type HindsightMessage, prepareRetentionTranscript, sliceLastTurnsByUserBoundary, @@ -182,6 +183,41 @@ describe("prepareRetentionTranscript", () => { const empty = prepareRetentionTranscript([{ role: "user", content: "x" }], true); expect(empty.transcript).toBeNull(); }); + + it("skips punctuation-only assistant turns so retain never stores `.` noise (#1806)", () => { + const messages: HindsightMessage[] = [ + { role: "user", content: "explain how transformers work" }, + { role: "assistant", content: "." }, + { role: "user", content: "now ssh into the server" }, + { role: "assistant", content: "..." }, + { role: "user", content: "any more updates?" }, + { role: "assistant", content: " \n\t" }, + { role: "user", content: "ok keep going" }, + { role: "assistant", content: "done — here are the results" }, + ]; + const { transcript, messageCount } = prepareRetentionTranscript(messages, true); + expect(messageCount).toBe(5); + expect(transcript).not.toContain("[role: assistant]\n.\n[assistant:end]"); + expect(transcript).not.toContain("[role: assistant]\n...\n[assistant:end]"); + expect(transcript).toContain("done — here are the results"); + }); +}); + +describe("hasSubstantiveContent", () => { + it("treats letter/digit-bearing strings as substantive", () => { + expect(hasSubstantiveContent("ok")).toBe(true); + expect(hasSubstantiveContent("y")).toBe(true); + expect(hasSubstantiveContent("4")).toBe(true); + expect(hasSubstantiveContent("こんにちは")).toBe(true); + }); + + it("rejects whitespace and punctuation-only strings", () => { + expect(hasSubstantiveContent("")).toBe(false); + expect(hasSubstantiveContent(".")).toBe(false); + expect(hasSubstantiveContent("...")).toBe(false); + expect(hasSubstantiveContent(" \t\n")).toBe(false); + expect(hasSubstantiveContent("— ! ?")).toBe(false); + }); }); describe("formatMemories", () => {