Merge PR #4445: fix(mnemopi): let agents read stored memories in full (@roboomp)

# Conflicts:
#	packages/mnemopi/src/core/beam/recall.ts
This commit is contained in:
can1357
2026-07-05 13:12:31 +02:00
11 changed files with 421 additions and 8 deletions
+7
View File
@@ -70,6 +70,13 @@
- Fixed large legacy snapcompact archives being rehydrated into active resumed-session context, avoiding Bun Worker crashes on oversized archived frame payloads ([#4470](https://github.com/can1357/oh-my-pi/issues/4470)).
- Fixed LSP diagnostics staleness after harness-authored file writes by sending watched-file change notifications to running language servers before edit-time diagnostics are read ([#4459](https://github.com/can1357/oh-my-pi/issues/4459)).
- Documented the bash tool timeout clamp in the model-facing schema and prompt so callers know `async` jobs remain capped at 3600 seconds ([#4408](https://github.com/can1357/oh-my-pi/issues/4408)).
### Added
- `read memory://<id>` 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://<memory-id>` for any mnemopi id in scope. Errors are also clearer — "Mnemopi memory `<id>` 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://<id>` before any `memory_edit update` on a truncated preview.
## [16.3.4] - 2026-07-03
@@ -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, type MnemopiSessionState } 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<I
};
}
/**
* Snapshot of live mnemopi session states, deduplicated. A mnemopi backend
* always keeps its state on the {@link AgentSession} it was initialised for;
* subagents alias their parent's state, so different `session` objects can
* point at the same underlying banks. The dedupe below picks the
* canonical (non-aliased) state per bank set so `memory://<id>` resolves in
* one pass regardless of how many subagents are alive.
*/
function mnemopiSessionStatesFromRegistry(): MnemopiSessionState[] {
const seen = new Set<unknown>();
const states: MnemopiSessionState[] = [];
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<InternalResource> {
const roots = memoryRootsFromRegistry();
const namespace = url.rawHost || url.hostname;
if (!namespace) {
throw new Error("memory:// URL requires a namespace: memory://root or memory://<memory-id>");
}
// 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<UrlCompletion[]> {
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: "<memory-id>",
description: "Full mnemopi memory by id (from recall)",
});
}
return completions;
}
}
@@ -114,8 +114,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;
};
}
type MnemopiRetentionMessage = { role: string; content: string };
@@ -201,6 +234,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://<id>` 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,
@@ -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://<id>`, then pass the merged content in `content`.
@@ -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://<id>` — required before any `memory_edit update`.
@@ -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<void>,
options: { bank?: string } = {},
): Promise<void> {
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<typeof MnemopiSessionState>[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://<id> 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.",
);
});
});
});
+8
View File
@@ -10,6 +10,14 @@
### Fixed
- Fixed background LLM fact extraction preserving extractor categories so `instructions`, `preferences`, `timelines`, and `kg` triples populate their MEMORIA tables and graph triples instead of being flattened into generic `fact/entity` rows. ([#4389](https://github.com/can1357/oh-my-pi/issues/4389))
### 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
+33 -2
View File
@@ -69,6 +69,34 @@ const VERACITY_WEIGHTS: Record<string, number> = {
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://<id>`). 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",
@@ -737,10 +765,11 @@ function scoreCandidate(
score *= tierWeight;
}
score *= veracityWeight * currentContentAdjustment(searchableContent, 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,
@@ -766,6 +795,8 @@ function scoreCandidate(
recency_decay: round4(decay),
temporal: round4(temporalScore),
},
truncated: preview.truncated,
full_length: preview.fullLength,
};
return result;
}
@@ -1058,7 +1089,7 @@ function factSearchableText(subject: string, predicate: string, object: string):
}
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;
+18
View File
@@ -172,6 +172,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 {
@@ -238,6 +247,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://<id>` (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;
+1
View File
@@ -319,6 +319,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
@@ -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);
});
});