From 536ecc725a8eb64dac67a9024c47aa53c9f1da28 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 3 Jul 2026 15:05:43 +0000 Subject: [PATCH 1/2] fix(mnemopi): let agents read stored memories in full Recall silently clipped every result to 500 chars mid-word with no marker, and memory_edit update replaces content wholesale by id. There was no way for an agent to inspect the full row before overwriting it: Mnemopi.get existed but nothing surfaced it, and the advertised URI only served the file-backed memory summary. The natural recall/inspect/update loop had no inspect step. Three fixes across mnemopi and coding-agent: - recall now appends an ellipsis marker when it clips content and reports truncated=true plus full_length. The cap is exposed as RecallOptions.contentPreviewChars (default 500, 0 disables). The factLine 200-char clip used by the enhanced-context sandwich gets the same marker. - Under the mnemopi backend the read-tool URI scheme now routes an id host to Mnemopi.get() across every session's scoped banks, returning the row as text/markdown with a YAML-frontmatter header (bank, store, source, timestamp, importance, veracity). The root namespace remains for the file-backed summary. Miss errors now name the backend explicitly. - Updated the recall and memory_edit tool prompts to document the truncation marker and require reading the full memory before any wholesale content update. Fixes #4443 --- packages/coding-agent/CHANGELOG.md | 8 ++ .../src/internal-urls/memory-protocol.ts | 108 ++++++++++++++- packages/coding-agent/src/mnemopi/state.ts | 76 +++++++++++ .../src/prompts/tools/memory-edit.md | 2 + .../coding-agent/src/prompts/tools/recall.md | 2 + .../internal-urls/memory-protocol.test.ts | 124 +++++++++++++++++- packages/mnemopi/CHANGELOG.md | 9 ++ packages/mnemopi/src/core/beam/recall.ts | 35 ++++- packages/mnemopi/src/core/beam/types.ts | 18 +++ packages/mnemopi/src/core/memory.ts | 1 + .../mnemopi/test/beam-recall-unit.test.ts | 48 +++++++ 11 files changed, 423 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 860266c53..d88ec99e6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Added + +- `read memory://` now resolves under the mnemopi backend to the full memory row (working or episodic), wrapped in a YAML-frontmatter header carrying bank, store, source, timestamp, importance, and veracity. Bridges the read gap that made `memory_edit update` a blind overwrite: recall previews are clipped (see the mnemopi changelog), so an agent could not inspect the tail it was about to replace. The URI grammar is now `memory://root[/…]` for the file-backed summary and `memory://` for any mnemopi id in scope. Errors are also clearer — "Mnemopi memory `` not found in any scoped bank" replaces the "memories not enabled" message when a lookup misses under an active mnemopi session ([#4443](https://github.com/can1357/oh-my-pi/issues/4443)). + +### Changed + +- Updated the `recall` and `memory_edit` tool prompts to document the truncation marker (`…`, `truncated: true`, `full_length`) and to require `read memory://` before any `memory_edit update` on a truncated preview. + ## [16.3.4] - 2026-07-03 ### Fixed diff --git a/packages/coding-agent/src/internal-urls/memory-protocol.ts b/packages/coding-agent/src/internal-urls/memory-protocol.ts index 8e6b6e49f..52f342302 100644 --- a/packages/coding-agent/src/internal-urls/memory-protocol.ts +++ b/packages/coding-agent/src/internal-urls/memory-protocol.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { getAgentDir, isEnoent } from "@oh-my-pi/pi-utils"; import { getMemoryRoot } from "../memories"; +import { getMnemopiSessionState, type MnemopiScopedMemoryHit } from "../mnemopi/state"; import { AgentRegistry } from "../registry/agent-registry"; import { buildDirectoryResource } from "./filesystem-resource"; import { validateRelativePath } from "./skill-protocol"; @@ -124,6 +125,75 @@ async function tryResolveInRoot(url: InternalUrl, memoryRoot: string): Promise` resolves in + * one pass regardless of how many subagents are alive. + */ +function mnemopiSessionStatesFromRegistry(): ReturnType[] { + const seen = new Set(); + const states: ReturnType[] = []; + for (const ref of AgentRegistry.global().list()) { + const session = ref.session; + if (!session) continue; + const state = getMnemopiSessionState(session); + if (!state) continue; + const primary = state.aliasOf ?? state; + if (seen.has(primary)) continue; + seen.add(primary); + states.push(primary); + } + return states; +} + +/** + * Look up a mnemopi memory row by id across every live session's scoped banks. + * First hit wins; returns `null` when the id is not stored anywhere in scope. + */ +function tryResolveMnemopiMemory(id: string): MnemopiScopedMemoryHit | null { + for (const state of mnemopiSessionStatesFromRegistry()) { + const hit = state?.getScopedMemory(id); + if (hit) return hit; + } + return null; +} + +/** + * Render a mnemopi memory row as text/markdown with a small YAML-front-matter + * header. The frontmatter carries the metadata an agent needs to reason about + * a working vs episodic memory (bank, store, timestamps, importance) without + * having to reconstruct it from the recall preview. + */ +function renderMnemopiMemory(url: InternalUrl, hit: MnemopiScopedMemoryHit): InternalResource { + const { row, bank, store } = hit; + const meta = row.metadata == null ? "" : `metadata: ${JSON.stringify(row.metadata)}\n`; + const header = + "---\n" + + `id: ${row.id}\n` + + `bank: ${bank}\n` + + `store: ${store}\n` + + (row.memory_type ? `memory_type: ${row.memory_type}\n` : "") + + (row.source ? `source: ${row.source}\n` : "") + + (row.timestamp ? `timestamp: ${row.timestamp}\n` : "") + + (row.created_at ? `created_at: ${row.created_at}\n` : "") + + (row.importance != null ? `importance: ${row.importance}\n` : "") + + (row.veracity ? `veracity: ${row.veracity}\n` : "") + + (row.session_id ? `session_id: ${row.session_id}\n` : "") + + meta + + "---\n\n"; + const content = `${header}${row.content}`; + return { + url: url.href, + content, + contentType: "text/markdown", + size: Buffer.byteLength(content, "utf-8"), + notes: [], + }; +} + /** * Protocol handler for memory:// URLs. * @@ -136,8 +206,31 @@ export class MemoryProtocolHandler implements ProtocolHandler { readonly immutable = true; async resolve(url: InternalUrl): Promise { - const roots = memoryRootsFromRegistry(); + const namespace = url.rawHost || url.hostname; + if (!namespace) { + throw new Error("memory:// URL requires a namespace: memory://root or memory://"); + } + // Mnemopi rows live in SQLite banks per session, keyed by memory id. + // Any host other than the file-backed `root` namespace is treated as a + // mnemopi memory id lookup. This is the read counterpart to + // `memory_edit update` and lets agents inspect the full content of a + // clipped recall preview before overwriting it (issue #4443). + if (namespace !== MEMORY_NAMESPACE) { + const mnemopiStates = mnemopiSessionStatesFromRegistry(); + if (mnemopiStates.length === 0) { + throw new Error( + `Unknown memory namespace: ${namespace}. Supported: ${MEMORY_NAMESPACE} (file-backed memory summary), or a mnemopi memory id when memory.backend=mnemopi is active.`, + ); + } + const hit = tryResolveMnemopiMemory(namespace); + if (hit) return renderMnemopiMemory(url, hit); + throw new Error( + `Mnemopi memory ${namespace} not found in any scoped bank. Use \`recall\` to list available ids.`, + ); + } + + const roots = memoryRootsFromRegistry(); if (roots.length === 0) { throw new Error( "Memory artifacts are not available for this project yet. Run a session with memories enabled first.", @@ -167,7 +260,16 @@ export class MemoryProtocolHandler implements ProtocolHandler { } async complete(): Promise { - if (memoryRootsFromRegistry().length === 0) return []; - return [{ value: MEMORY_NAMESPACE, description: "Project memory summary" }]; + const completions: UrlCompletion[] = []; + if (memoryRootsFromRegistry().length > 0) { + completions.push({ value: MEMORY_NAMESPACE, description: "Project memory summary" }); + } + if (mnemopiSessionStatesFromRegistry().length > 0) { + completions.push({ + value: "", + description: "Full mnemopi memory by id (from recall)", + }); + } + return completions; } } diff --git a/packages/coding-agent/src/mnemopi/state.ts b/packages/coding-agent/src/mnemopi/state.ts index da3c384da..8ff233c91 100644 --- a/packages/coding-agent/src/mnemopi/state.ts +++ b/packages/coding-agent/src/mnemopi/state.ts @@ -112,8 +112,41 @@ export interface MnemopiMemoryEditResult { } interface MnemopiStoredMemoryRow { + id?: unknown; + content?: unknown; + source?: unknown; + timestamp?: unknown; + importance?: unknown; + veracity?: unknown; + created_at?: unknown; memory_store?: unknown; + memory_type?: unknown; session_id?: unknown; + metadata?: unknown; + metadata_json?: unknown; +} + +/** + * Full-row lookup result produced by {@link MnemopiSessionState.getScopedMemory}. + * Mirrors the shape stored in mnemopi's working/episodic tables, tagged with + * the scoped bank that actually held the row so callers can render it with + * meaningful context. + */ +export interface MnemopiScopedMemoryHit { + bank: string; + store: "working" | "episodic"; + row: { + id: string; + content: string; + source: string | null; + timestamp: string | null; + importance: number | null; + veracity: string | null; + created_at: string | null; + session_id: string | null; + memory_type: string | null; + metadata: unknown; + }; } export function getMnemopiSessionState(session: AgentSession | undefined): MnemopiSessionState | undefined { @@ -183,6 +216,49 @@ export class MnemopiSessionState { return this.scoped.retain; } + /** + * Read counterpart to {@link editScopedMemory}: fetch a memory row by id + * from any bank this session recalls from (retain, recall, global). First + * hit wins in the same order {@link editScopedMemory} would touch, so the + * shape matches what an `update`/`forget`/`invalidate` on the same id will + * see. Returns `null` when the id is not found anywhere in scope. + * + * Backs the coding-agent `memory://` URL so agents can inspect the + * FULL content of a recall preview (recall clips content — see + * {@link RecallResult.truncated}) before issuing a wholesale + * `memory_edit update` that would otherwise overwrite unseen bytes + * (issue #4443). + */ + getScopedMemory(id: string): MnemopiScopedMemoryHit | null { + const targets = dedupeScopedTargets([ + this.scoped.retain, + ...this.scoped.recall, + ...(this.scoped.global ? [this.scoped.global] : []), + ]); + for (const target of targets) { + const raw = target.memory.get(id) as MnemopiStoredMemoryRow | null; + if (!raw) continue; + const store: MnemopiScopedMemoryHit["store"] = raw.memory_store === "episodic" ? "episodic" : "working"; + return { + bank: target.bank, + store, + row: { + id: typeof raw.id === "string" ? raw.id : id, + content: typeof raw.content === "string" ? raw.content : "", + source: typeof raw.source === "string" ? raw.source : null, + timestamp: typeof raw.timestamp === "string" ? raw.timestamp : null, + importance: typeof raw.importance === "number" ? raw.importance : null, + veracity: typeof raw.veracity === "string" ? raw.veracity : null, + created_at: typeof raw.created_at === "string" ? raw.created_at : null, + session_id: typeof raw.session_id === "string" ? raw.session_id : null, + memory_type: typeof raw.memory_type === "string" ? raw.memory_type : null, + metadata: raw.metadata ?? raw.metadata_json ?? null, + }, + }; + } + return null; + } + editScopedMemory( op: MnemopiMemoryEditOperation, id: string, diff --git a/packages/coding-agent/src/prompts/tools/memory-edit.md b/packages/coding-agent/src/prompts/tools/memory-edit.md index fc5b05888..641c482c2 100644 --- a/packages/coding-agent/src/prompts/tools/memory-edit.md +++ b/packages/coding-agent/src/prompts/tools/memory-edit.md @@ -6,3 +6,5 @@ Use only with ids returned by the `recall` tool. Operations: - `invalidate`: softly supersede a working or episodic memory, optionally pointing at `replacement_id`. Prefer `invalidate` when a memory became stale but its history may still be useful. Use `forget` only for content that should be hard-deleted. + +**Always read the full memory before `update`.** Recall results are clipped previews (the trailing `…` marks a truncation and `full_length` reports the original size); `update` replaces content wholesale, so overwriting the preview would delete the unseen tail. Fetch the row first with `read memory://`, then pass the merged content in `content`. diff --git a/packages/coding-agent/src/prompts/tools/recall.md b/packages/coding-agent/src/prompts/tools/recall.md index e43dc65e9..28c2ea1dc 100644 --- a/packages/coding-agent/src/prompts/tools/recall.md +++ b/packages/coding-agent/src/prompts/tools/recall.md @@ -3,3 +3,5 @@ Search long-term memory for relevant information. Returns raw matching entries r Use proactively — before answering questions about past conversations, user preferences, project decisions, or any topic where prior context would help accuracy. When in doubt, recall first. Prefer `recall` when you need specific facts or entries. Use `reflect` instead when you need a synthesized answer across many memories. + +Content in each result is a preview. A trailing `…` marks a truncation (`truncated: true`, `full_length` gives the original size). Fetch the full row with `read memory://` — required before any `memory_edit update`. diff --git a/packages/coding-agent/test/internal-urls/memory-protocol.test.ts b/packages/coding-agent/test/internal-urls/memory-protocol.test.ts index d3bcf0619..b88eee52d 100644 --- a/packages/coding-agent/test/internal-urls/memory-protocol.test.ts +++ b/packages/coding-agent/test/internal-urls/memory-protocol.test.ts @@ -4,10 +4,19 @@ import * as os from "node:os"; import * as path from "node:path"; import { InternalUrlRouter } from "@oh-my-pi/pi-coding-agent/internal-urls"; import { getMemoryRoot } from "@oh-my-pi/pi-coding-agent/memories"; +import { + loadMnemopi, + loadMnemopiCore, + MnemopiSessionState, + setMnemopiSessionState, +} from "@oh-my-pi/pi-coding-agent/mnemopi/state"; import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { getAgentDir, removeWithRetries, setAgentDir } from "@oh-my-pi/pi-utils"; +import { getAgentDir, removeWithRetries, setAgentDir, TempDir } from "@oh-my-pi/pi-utils"; +// Mnemopi state is loaded lazily; preload so `new MnemopiSessionState(...)` can +// resolve the module synchronously in the fixtures below. +await Promise.all([loadMnemopi(), loadMnemopiCore()]); interface MemoryFixture { cwd: string; memoryRoot: string; @@ -83,11 +92,11 @@ describe("MemoryProtocolHandler", () => { }); }); - it("throws for unknown memory namespace", async () => { + it("throws for unknown memory namespace when no mnemopi backend is active", async () => { await withMemoryFixture(async () => { const router = InternalUrlRouter.instance(); await expect(router.resolve("memory://other/memory_summary.md")).rejects.toThrow( - "Unknown memory namespace: other. Supported: root", + /Unknown memory namespace: other\. Supported: root/, ); }); }); @@ -129,3 +138,112 @@ describe("MemoryProtocolHandler", () => { }); }); }); + +interface MnemopiFixture { + state: MnemopiSessionState; + dbDir: TempDir; +} + +async function withMnemopiSession( + fn: (fixture: MnemopiFixture) => Promise, + options: { bank?: string } = {}, +): Promise { + const dbDir = TempDir.createSync(`memory-protocol-mnemopi-${Date.now()}-`); + const bank = options.bank ?? "test-bank"; + const config = { + dbPath: dbDir.join("mnemopi.db"), + bank, + autoRecall: false, + autoRetain: false, + polyphonicRecall: false, + enhancedRecall: false, + proactiveLinking: false, + retainEveryNTurns: 3, + recallLimit: 10, + recallContextTurns: 1, + recallMaxQueryChars: 800, + injectionTokenLimit: 1024, + debug: false, + providerOptions: { + noEmbeddings: true, + llm: false, + }, + llmMode: "none" as const, + } as unknown as ConstructorParameters[0]["config"]; + const session = { + sessionId: "test-mnemopi", + sessionManager: { + getEntries: () => [], + getCwd: () => dbDir.path(), + getArtifactsDir: () => null, + getSessionId: () => "test-mnemopi", + }, + emitNotice: () => {}, + getHindsightSessionState: () => undefined, + } as unknown as AgentSession; + const state = new MnemopiSessionState({ sessionId: "test-mnemopi", config, session }); + setMnemopiSessionState(session, state); + AgentRegistry.global().register({ + id: "test-mnemopi", + displayName: "test-mnemopi", + kind: "main", + session, + sessionFile: null, + }); + try { + await fn({ state, dbDir }); + } finally { + await state.dispose({ consolidate: false }); + await dbDir.remove(); + } +} + +describe("MemoryProtocolHandler — mnemopi bridge (issue #4443)", () => { + beforeEach(() => { + AgentRegistry.resetGlobalForTests(); + InternalUrlRouter.resetForTests(); + }); + + afterEach(() => { + AgentRegistry.resetGlobalForTests(); + InternalUrlRouter.resetForTests(); + }); + + it("resolves memory:// to the full mnemopi memory row", async () => { + await withMnemopiSession(async ({ state }) => { + const head = "Decision record: the deploy pipeline uses blue-green cutover. "; + const body = "Detail sentence about rollout invariants. ".repeat(20); + const tail = "CRITICAL-TAIL: rollback requires restoring the previous DNS weight map first."; + const full = `${head}${body}${tail}`; + const id = state.rememberInScope(full, { importance: 0.9 }); + expect(id).toBeTruthy(); + + const router = InternalUrlRouter.instance(); + const resource = await router.resolve(`memory://${id}`); + + expect(resource.contentType).toBe("text/markdown"); + expect(resource.content).toContain("CRITICAL-TAIL"); + expect(resource.content).toContain(`id: ${id}`); + expect(resource.content).toContain("bank: test-bank"); + expect(resource.content).toContain("store: working"); + }); + }); + + it("throws a clear error when the mnemopi id is not stored in any scoped bank", async () => { + await withMnemopiSession(async () => { + const router = InternalUrlRouter.instance(); + await expect(router.resolve("memory://deadbeefdeadbeef")).rejects.toThrow( + /Mnemopi memory deadbeefdeadbeef not found/, + ); + }); + }); + + it("routes memory://root to the file-backed summary even when mnemopi is active", async () => { + await withMnemopiSession(async () => { + const router = InternalUrlRouter.instance(); + await expect(router.resolve("memory://root")).rejects.toThrow( + "Memory artifacts are not available for this project yet. Run a session with memories enabled first.", + ); + }); + }); +}); diff --git a/packages/mnemopi/CHANGELOG.md b/packages/mnemopi/CHANGELOG.md index 20c8183f6..31ed92b3c 100644 --- a/packages/mnemopi/CHANGELOG.md +++ b/packages/mnemopi/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### Added + +- Exposed `RecallOptions.contentPreviewChars` so hosts can raise or disable the per-result content preview cap that `recall()` enforces. Default remains 500; pass `0` to return full content. +- Added `RecallResult.truncated` and `RecallResult.full_length` so callers can tell a clipped preview apart from a short row without inspecting the trailing marker. ([#4443](https://github.com/can1357/oh-my-pi/issues/4443)) + +### Fixed + +- Recall previews now mark clipped content with a trailing `…` character instead of slicing at exactly 500 characters mid-word with no marker. The `factLine` used by the enhanced-context sandwich (200-char cap) gets the same treatment. Agents can now tell a full memory apart from a preview and know when to fetch the full row via `Mnemopi.get(id)` before overwriting content ([#4443](https://github.com/can1357/oh-my-pi/issues/4443)). + ## [16.2.2] - 2026-06-27 ### Fixed diff --git a/packages/mnemopi/src/core/beam/recall.ts b/packages/mnemopi/src/core/beam/recall.ts index 9401588f6..be35a61b9 100644 --- a/packages/mnemopi/src/core/beam/recall.ts +++ b/packages/mnemopi/src/core/beam/recall.ts @@ -69,6 +69,34 @@ const VERACITY_WEIGHTS: Record = { false: 0, }; +/** + * Default per-result content preview cap enforced by {@link recall}. Content + * longer than this is clipped and the last character replaced with `…` so + * callers see the truncation; the full row remains reachable via + * `Mnemopi.get()` (and, in the coding-agent, `memory://`). Overridable per + * call via {@link RecallOptions.contentPreviewChars}. + */ +export const RECALL_CONTENT_PREVIEW_CHARS = 500; + +/** + * Clip `content` to at most `limit` characters, replacing the tail with `…` + * when truncated so agents can distinguish a preview from a full row. Returns + * the original string (and `truncated: false`) when the limit is 0/negative or + * the content already fits. The single `…` occupies one character of the cap, + * so a 500-char cap yields at most 499 real characters plus the marker. + */ +export function clipRecallContent( + content: string, + limit: number = RECALL_CONTENT_PREVIEW_CHARS, +): { content: string; truncated: boolean; fullLength: number } { + const fullLength = content.length; + if (limit <= 0 || fullLength <= limit) { + return { content, truncated: false, fullLength }; + } + const head = content.slice(0, Math.max(0, limit - 1)); + return { content: `${head}…`, truncated: true, fullLength }; +} + const DEFAULT_LIMIT = 500; const STOP_WORDS = new Set([ "a", @@ -733,10 +761,11 @@ function scoreCandidate( score *= tierWeight; } score *= veracityWeight * currentContentAdjustment(content, options.currentSensitive === true); + const preview = clipRecallContent(content, options.contentPreviewChars ?? RECALL_CONTENT_PREVIEW_CHARS); const result: RecallResult = { ...candidate.row, id: asString(candidate.row.id), - content: content.slice(0, 500), + content: preview.content, source: asNullableString(candidate.row.source), timestamp: asNullableString(candidate.row.timestamp), importance, @@ -762,6 +791,8 @@ function scoreCandidate( recency_decay: round4(decay), temporal: round4(temporalScore), }, + truncated: preview.truncated, + full_length: preview.fullLength, }; return result; } @@ -1038,7 +1069,7 @@ function sandwichOrder(results: readonly RecallResult[]): { } function factLine(result: RecallResult): string { - const content = result.content.slice(0, 200).trim(); + const content = clipRecallContent(result.content.trim(), 200).content; const ts = typeof result.timestamp === "string" && result.timestamp.length > 0 ? result.timestamp.slice(0, 10) : "?"; const source = result.source ?? "unknown"; const score = result.score ?? result.importance ?? 0; diff --git a/packages/mnemopi/src/core/beam/types.ts b/packages/mnemopi/src/core/beam/types.ts index 2753a15d9..d33d0fce0 100644 --- a/packages/mnemopi/src/core/beam/types.ts +++ b/packages/mnemopi/src/core/beam/types.ts @@ -167,6 +167,15 @@ export interface RecallOptions { useIntent?: boolean; useMmr?: boolean; mmrLambda?: number; + /** + * Maximum characters of `content` returned per {@link RecallResult}. When the + * stored content exceeds this, the preview is clipped and the trailing + * character is replaced with `…` so callers can see it was truncated. The + * full row is always reachable via {@link BeamMemoryState.get}. `0` or a + * negative value disables clipping. Defaults to + * {@link RECALL_CONTENT_PREVIEW_CHARS} (500). + */ + contentPreviewChars?: number; } export interface RecallEnhancedOptions extends RecallOptions { @@ -233,6 +242,15 @@ export type RecallResult = RecallRowFields & { [key: string]: unknown; id: string; content: string; + /** + * True when {@link content} is a clipped preview of the stored row. The + * clip is capped at {@link RecallOptions.contentPreviewChars} (default 500) + * and the last character is replaced with `…`. Fetch the full row via + * `memory://` (mnemopi backend) or the `Mnemopi.get(id)` API. + */ + truncated?: boolean; + /** Original character count of `content` before {@link truncated} clipping. */ + full_length?: number; score?: number; distance?: number; rank?: number; diff --git a/packages/mnemopi/src/core/memory.ts b/packages/mnemopi/src/core/memory.ts index 7218b3a55..161994693 100644 --- a/packages/mnemopi/src/core/memory.ts +++ b/packages/mnemopi/src/core/memory.ts @@ -308,6 +308,7 @@ function toRecallOptions(options: RecallFacadeOptions): BeamRecallFacadeOptions vecWeight: options.vecWeight ?? options.vec_weight ?? undefined, ftsWeight: options.ftsWeight ?? options.fts_weight ?? undefined, importanceWeight: options.importanceWeight ?? options.importance_weight ?? undefined, + contentPreviewChars: options.contentPreviewChars, }; // Preserve the three-state semantics (`undefined` = auto-derive, `null` = explicitly // FTS-only, `number[]` = caller-supplied) so callers can opt out of `recall()`'s diff --git a/packages/mnemopi/test/beam-recall-unit.test.ts b/packages/mnemopi/test/beam-recall-unit.test.ts index 1d33b91e7..1bdda7d34 100644 --- a/packages/mnemopi/test/beam-recall-unit.test.ts +++ b/packages/mnemopi/test/beam-recall-unit.test.ts @@ -458,4 +458,52 @@ describe("beam recall free functions", () => { expect(typeof results[0]?.score).toBe("number"); expect(results[0]?.explanation).toBeTruthy(); }); + + it("clips long content with a trailing ellipsis and reports the original length (issue #4443)", async () => { + const beam = makeBeam(); + const head = "Decision record: the deploy pipeline uses blue-green cutover. "; + const body = "Detail sentence about rollout invariants. ".repeat(20); + const tail = "CRITICAL-TAIL: rollback requires restoring the previous DNS weight map first."; + const full = `${head}${body}${tail}`; + insertWorking(beam, "wm-long", full, { importance: 0.9 }); + + const results = await recall(beam, "deploy pipeline blue-green cutover", 5); + const hit = results.find(row => row.id === "wm-long"); + expect(hit).toBeDefined(); + expect(hit?.truncated).toBe(true); + expect(hit?.full_length).toBe(full.length); + expect(hit?.content.length).toBe(500); + expect(hit?.content.endsWith("…")).toBe(true); + expect(hit?.content.includes("CRITICAL-TAIL")).toBe(false); + }); + + it("returns short content untouched with truncated=false", async () => { + const beam = makeBeam(); + const short = "quick working note that fits well under the preview cap"; + insertWorking(beam, "wm-short", short); + + const results = await recall(beam, "quick working note preview cap", 5); + const hit = results.find(row => row.id === "wm-short"); + expect(hit).toBeDefined(); + expect(hit?.truncated).toBe(false); + expect(hit?.full_length).toBe(short.length); + expect(hit?.content).toBe(short); + }); + + it("honours a caller-supplied contentPreviewChars cap and disables clipping when 0", async () => { + const beam = makeBeam(); + const long = "long ".repeat(400).trim(); + insertWorking(beam, "wm-cap", long); + + const capped = await recall(beam, "long", 3, { contentPreviewChars: 40 }); + const cappedHit = capped.find(row => row.id === "wm-cap"); + expect(cappedHit?.content.length).toBe(40); + expect(cappedHit?.content.endsWith("…")).toBe(true); + expect(cappedHit?.full_length).toBe(long.length); + + const full = await recall(beam, "long", 3, { contentPreviewChars: 0 }); + const fullHit = full.find(row => row.id === "wm-cap"); + expect(fullHit?.content).toBe(long); + expect(fullHit?.truncated).toBe(false); + }); }); From 6976ccca8027c83b671a1fbe4b50fdd2ceb2e28d Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 3 Jul 2026 15:14:49 +0000 Subject: [PATCH 2/2] fix(mnemopi): used explicit session state type --- packages/coding-agent/src/internal-urls/memory-protocol.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/internal-urls/memory-protocol.ts b/packages/coding-agent/src/internal-urls/memory-protocol.ts index 52f342302..c6c83ffd5 100644 --- a/packages/coding-agent/src/internal-urls/memory-protocol.ts +++ b/packages/coding-agent/src/internal-urls/memory-protocol.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { getAgentDir, isEnoent } from "@oh-my-pi/pi-utils"; import { getMemoryRoot } from "../memories"; -import { getMnemopiSessionState, type MnemopiScopedMemoryHit } from "../mnemopi/state"; +import { getMnemopiSessionState, type MnemopiScopedMemoryHit, type MnemopiSessionState } from "../mnemopi/state"; import { AgentRegistry } from "../registry/agent-registry"; import { buildDirectoryResource } from "./filesystem-resource"; import { validateRelativePath } from "./skill-protocol"; @@ -133,9 +133,9 @@ async function tryResolveInRoot(url: InternalUrl, memoryRoot: string): Promise` resolves in * one pass regardless of how many subagents are alive. */ -function mnemopiSessionStatesFromRegistry(): ReturnType[] { +function mnemopiSessionStatesFromRegistry(): MnemopiSessionState[] { const seen = new Set(); - const states: ReturnType[] = []; + const states: MnemopiSessionState[] = []; for (const ref of AgentRegistry.global().list()) { const session = ref.session; if (!session) continue;