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:
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user