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
101 lines
3.5 KiB
TypeScript
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));
|
|
}
|
|
});
|
|
}
|
|
}
|