Files
oh-my-pi/packages/coding-agent/src/tools/memory-recall.ts
T
roboomp 66b6597829 fix(mnemopi): populated memory_embeddings on remember and auto-derived queryEmbedding on recall
The beam backend never invoked the embedding pipeline during normal
operation: `remember()`/`rememberBatch()`/`updateWorking()` skipped `embed()`
entirely and `recall()`/`recallEnhanced()` never called `embedQuery()` on
the query text. As a result `memory_embeddings` stayed empty in every
deployment and recall silently degraded to FTS-only regardless of the
configured provider (fastembed, OpenAI-compatible API, custom).

- Added `scheduleEmbedding` on `beam.pendingExtractions` (mirroring
  `scheduleFactExtraction`) and wired it from `remember`, `rememberBatch`,
  `updateWorking`, and `consolidateToEpisodic`. Writes
  `INSERT OR REPLACE INTO memory_embeddings(memory_id, embedding_json, model)`
  with the active runtime-options model, captured before the AsyncLocalStorage
  scope exits and re-entered inside the task.
- Auto-derived `queryEmbedding` inside `recall()` via `embedQuery(query)` when
  the caller did not pass one. `queryEmbedding: null` is preserved as the
  explicit FTS-only opt-out; `undefined` triggers auto-derive.
- Propagated `queryEmbedding` through `Mnemopi`'s `toRecallOptions` so the
  facade no longer strips the override on the way to the beam layer.
- Made `Mnemopi.recall`/`recallEnhanced`/`search`/`query`, the module-level
  exports, `BeamMemory.recall`/`recallEnhanced`, the free `recall`/`recallEnhanced`,
  and `orchestrateRecall` async. MCP `handleToolCall`/`callToolJson`/`handleJsonRpc`
  follow suit so the recall handler can await.
- Fixed `withBeam`/`withSharedBeam` to defer `beam.close()` until the async
  handler resolves; otherwise the new async recall hit
  `RangeError: Cannot use a closed database`.
- Updated CLI, MCP entrypoints, coding-agent `MnemopiSessionState`, and every
  affected test to await the new shapes.

Verified with a new regression suite (`test/issue-1832-embedding-population.test.ts`)
exercising both ends of the bug: empty `memory_embeddings` and zero
`dense_score`.

Fixes #1832
2026-06-04 09:22:07 +00:00

101 lines
3.5 KiB
TypeScript

import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
import { logger, untilAborted } from "@oh-my-pi/pi-utils";
import * as z from "zod/v4";
import { formatCurrentTime, formatMemories } from "../hindsight/content";
import recallDescription from "../prompts/tools/recall.md" with { type: "text" };
import type { ToolSession } from ".";
const memoryRecallSchema = z.object({
query: z.string().describe("natural language search query"),
});
export type MemoryRecallParams = z.infer<typeof memoryRecallSchema>;
export class MemoryRecallTool implements AgentTool<typeof memoryRecallSchema> {
readonly name = "recall";
readonly approval = "read" as const;
readonly label = "Recall";
readonly description = recallDescription;
readonly parameters = memoryRecallSchema;
readonly strict = true;
readonly loadMode = "discoverable";
readonly summary = "Search memory for relevant prior context";
constructor(private readonly session: ToolSession) {}
static createIf(session: ToolSession): MemoryRecallTool | null {
const backend = session.settings.get("memory.backend");
if (backend !== "hindsight" && backend !== "mnemopi") return null;
return new MemoryRecallTool(session);
}
async execute(_id: string, params: MemoryRecallParams, signal?: AbortSignal): Promise<AgentToolResult> {
return untilAborted(signal, async () => {
const backend = this.session.settings.get("memory.backend");
if (backend === "mnemopi") {
const state = this.session.getMnemopiSessionState?.();
if (!state) {
throw new Error("Mnemopi backend is not initialised for this session.");
}
try {
const results = await state.recallResultsScoped(params.query);
if (results.length === 0) {
return {
content: [{ type: "text", text: "No relevant memories found." }],
details: {},
};
}
const formatted = state.formatScopedRecallWithIds(results);
return {
content: [
{
type: "text",
text: `Found ${results.length} relevant ${results.length === 1 ? "memory" : "memories"} (as of ${formatCurrentTime()} UTC):\n\n${formatted}`,
},
],
details: {},
};
} catch (err) {
logger.warn("recall failed", { backend: "mnemopi", bank: state.config.bank, error: String(err) });
throw err instanceof Error ? err : new Error(String(err));
}
}
const state = this.session.getHindsightSessionState?.();
if (!state) {
throw new Error("Hindsight backend is not initialised for this session.");
}
try {
const response = await state.client.recall(state.bankId, params.query, {
budget: state.config.recallBudget,
maxTokens: state.config.recallMaxTokens,
types: state.config.recallTypes.length > 0 ? state.config.recallTypes : undefined,
tags: state.recallTags,
tagsMatch: state.recallTagsMatch,
});
const results = response.results ?? [];
if (results.length === 0) {
return {
content: [{ type: "text", text: "No relevant memories found." }],
details: {},
};
}
const formatted = formatMemories(results);
return {
content: [
{
type: "text",
text: `Found ${results.length} relevant ${results.length === 1 ? "memory" : "memories"} (as of ${formatCurrentTime()} UTC):\n\n${formatted}`,
},
],
details: {},
};
} catch (err) {
logger.warn("recall failed", { bankId: state.bankId, error: String(err) });
throw err instanceof Error ? err : new Error(String(err));
}
});
}
}