From 66b65978298f1079891e1daedf0080862bdbc692 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 4 Jun 2026 09:22:07 +0000 Subject: [PATCH] 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 --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/mnemopi/state.ts | 8 +- .../coding-agent/src/tools/memory-recall.ts | 2 +- .../coding-agent/src/tools/memory-reflect.ts | 2 +- .../coding-agent/test/memory-tools.test.ts | 8 +- packages/mnemopi/CHANGELOG.md | 12 ++ packages/mnemopi/src/cli.ts | 11 +- packages/mnemopi/src/core/beam/consolidate.ts | 2 + packages/mnemopi/src/core/beam/helpers.ts | 57 ++++++ packages/mnemopi/src/core/beam/index.ts | 4 +- packages/mnemopi/src/core/beam/recall.ts | 19 +- packages/mnemopi/src/core/beam/store.ts | 15 +- packages/mnemopi/src/core/embeddings.ts | 12 ++ packages/mnemopi/src/core/memory.ts | 31 +++- packages/mnemopi/src/core/orchestrator.ts | 27 ++- packages/mnemopi/src/mcp-server.ts | 10 +- packages/mnemopi/src/mcp-tools.ts | 55 ++++-- packages/mnemopi/test/beam-e3-e4-e6.test.ts | 10 +- packages/mnemopi/test/beam-index.test.ts | 6 +- packages/mnemopi/test/beam-parity.test.ts | 8 +- .../mnemopi/test/beam-recall-unit.test.ts | 32 ++-- packages/mnemopi/test/cli.test.ts | 4 +- .../mnemopi/test/configurable-scoring.test.ts | 18 +- .../mnemopi/test/extraction-wiring.test.ts | 2 +- .../test/identity-memory-parity.test.ts | 12 +- .../issue-1832-embedding-population.test.ts | 174 ++++++++++++++++++ packages/mnemopi/test/mcp-server.test.ts | 50 ++--- packages/mnemopi/test/memory-facade.test.ts | 24 +-- packages/mnemopi/test/orchestrator.test.ts | 24 +-- .../test/pre-experiment-fidelity.test.ts | 8 +- .../test/provider-all-15-tools-parity.test.ts | 32 ++-- .../test/provider-all-15-tools.test.ts | 50 ++--- .../test/recall-precision-regressions.test.ts | 31 ++-- .../test/telemetry-env-followups.test.ts | 12 +- packages/mnemopi/test/temporal-recall.test.ts | 22 +-- 35 files changed, 565 insertions(+), 233 deletions(-) create mode 100644 packages/mnemopi/test/issue-1832-embedding-population.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index dcbd68a54..2163ae325 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed scoped mnemopi recall in `MnemopiSessionState.collectScopedRecallResults`/`recallResultsScoped` to await the async `Mnemopi.recallEnhanced` so the new auto-derived `queryEmbedding` flows through. Without this, the embedding-enabled mnemopi backend silently kept running FTS-only on every recall. ([#1832](https://github.com/can1357/oh-my-pi/issues/1832)) + ## [15.9.0] - 2026-06-04 ### Breaking Changes diff --git a/packages/coding-agent/src/mnemopi/state.ts b/packages/coding-agent/src/mnemopi/state.ts index b70f1eeb2..99aa12e83 100644 --- a/packages/coding-agent/src/mnemopi/state.ts +++ b/packages/coding-agent/src/mnemopi/state.ts @@ -173,7 +173,7 @@ export class MnemopiSessionState { return lines.join("\n\n"); } - collectScopedRecallResults(query: string): RecallResult[] { + async collectScopedRecallResults(query: string): Promise { const merged: RecallResult[] = []; const byId = new Map(); const byContent = new Map(); @@ -187,7 +187,7 @@ export class MnemopiSessionState { target.bank === this.scoped.global?.bank && sharedFallbackQuery ? [query, sharedFallbackQuery] : [query]; try { for (const recallQuery of queries) { - const results = target.memory.recallEnhanced(recallQuery, this.config.recallLimit, { + const results = await target.memory.recallEnhanced(recallQuery, this.config.recallLimit, { includeFacts: true, channelId: target.bank, }); @@ -209,7 +209,7 @@ export class MnemopiSessionState { return merged; } - recallResultsScoped(query: string): RecallResult[] { + recallResultsScoped(query: string): Promise { return this.collectScopedRecallResults(query); } @@ -242,7 +242,7 @@ export class MnemopiSessionState { } async recallForContext(query: string): Promise { - const results = this.collectScopedRecallResults(query); + const results = await this.collectScopedRecallResults(query); if (results.length === 0) return undefined; return formatRecallBlock(results); } diff --git a/packages/coding-agent/src/tools/memory-recall.ts b/packages/coding-agent/src/tools/memory-recall.ts index fab2b955b..d2a323457 100644 --- a/packages/coding-agent/src/tools/memory-recall.ts +++ b/packages/coding-agent/src/tools/memory-recall.ts @@ -38,7 +38,7 @@ export class MemoryRecallTool implements AgentTool { throw new Error("Mnemopi backend is not initialised for this session."); } try { - const results = state.recallResultsScoped(params.query); + const results = await state.recallResultsScoped(params.query); if (results.length === 0) { return { content: [{ type: "text", text: "No relevant memories found." }], diff --git a/packages/coding-agent/src/tools/memory-reflect.ts b/packages/coding-agent/src/tools/memory-reflect.ts index e9be589ae..19e5fd2d3 100644 --- a/packages/coding-agent/src/tools/memory-reflect.ts +++ b/packages/coding-agent/src/tools/memory-reflect.ts @@ -43,7 +43,7 @@ export class MemoryReflectTool implements AgentTool const query = params.context?.trim() ? `${params.query.trim()}\n\nAdditional context:\n${params.context.trim()}` : params.query; - const results = state.recallResultsScoped(query); + const results = await state.recallResultsScoped(query); if (results.length === 0) { return { content: [{ type: "text", text: "No relevant information found to reflect on." }], diff --git a/packages/coding-agent/test/memory-tools.test.ts b/packages/coding-agent/test/memory-tools.test.ts index 5f78cbb7a..bd9da3a22 100644 --- a/packages/coding-agent/test/memory-tools.test.ts +++ b/packages/coding-agent/test/memory-tools.test.ts @@ -761,7 +761,7 @@ describe("memory_edit.execute (Mnemopi backend)", () => { await MemoryRetainTool.createIf(makeSession(settings))!.execute("call-memory-edit-store", { items: [{ content }], }); - const id = registeredMnemopiState?.recallResultsScoped(query)[0]?.id; + const id = (await registeredMnemopiState?.recallResultsScoped(query))?.[0]?.id; expect(id).toBeString(); return id!; } @@ -779,7 +779,7 @@ describe("memory_edit.execute (Mnemopi backend)", () => { }); expect((result.content[0] as { text: string }).text).toContain("updated"); - const recalled = registeredMnemopiState!.recallResultsScoped("accent color"); + const recalled = await registeredMnemopiState!.recallResultsScoped("accent color"); expect(recalled.map(memory => memory.content)).toContain("editor accent color is green"); }); @@ -794,7 +794,7 @@ describe("memory_edit.execute (Mnemopi backend)", () => { }); expect((result.content[0] as { text: string }).text).toContain("deleted"); - const recalled = registeredMnemopiState!.recallResultsScoped("deployment note"); + const recalled = await registeredMnemopiState!.recallResultsScoped("deployment note"); expect(recalled.map(memory => memory.content)).not.toContain("temporary deployment note can be deleted"); }); @@ -809,7 +809,7 @@ describe("memory_edit.execute (Mnemopi backend)", () => { }); expect((result.content[0] as { text: string }).text).toContain("invalidated"); - const recalled = registeredMnemopiState!.recallResultsScoped("api key rotation"); + const recalled = await registeredMnemopiState!.recallResultsScoped("api key rotation"); expect(recalled.map(memory => memory.content)).not.toContain("stale api key rotation policy"); }); diff --git a/packages/mnemopi/CHANGELOG.md b/packages/mnemopi/CHANGELOG.md index 06b0cfd07..e7e5fd32a 100644 --- a/packages/mnemopi/CHANGELOG.md +++ b/packages/mnemopi/CHANGELOG.md @@ -2,6 +2,18 @@ ## [Unreleased] +### Breaking Changes + +- Changed `Mnemopi.recall()`, `Mnemopi.recallEnhanced()`, `Mnemopi.search()`, `Mnemopi.query()`, the module-level `recall`/`recallEnhanced`/`search`/`query` exports, the `BeamMemory.recall`/`recallEnhanced` methods, the free `recall`/`recallEnhanced` functions in `core/beam/recall`, and `orchestrateRecall` to return `Promise` so the recall pipeline can auto-derive `queryEmbedding` from the query text via `embedQuery`. Callers must `await` recall calls; pass `queryEmbedding: null` to opt out of auto-embedding and stay on FTS-only. +- Changed the MCP entrypoints `handleToolCall`, `callToolJson`, and `handleJsonRpc` in `mcp-server`/`mcp-tools` to async so the recall/shared-recall handlers can await the new `Promise` shape; external MCP transports must `await` these. + +### Fixed + +- Fixed `memory_embeddings` never being populated by the production `remember`/`rememberBatch`/`updateWorking`/`consolidateToEpisodic` paths; embedding generation is now scheduled as a background task on `beam.pendingExtractions` (mirroring `scheduleFactExtraction`), so configured providers (fastembed, OpenAI-compatible API, custom) actually run and rows land in `memory_embeddings(memory_id, embedding_json, model)`. ([#1832](https://github.com/can1357/oh-my-pi/issues/1832)) +- Fixed `recall()`/`recallEnhanced()` never deriving a query embedding from the query text, which silently degraded every deployment to FTS-only regardless of provider configuration. The recall pipeline now auto-calls `embedQuery(query)` when `options.queryEmbedding` is undefined; pass `null` to keep the old FTS-only behaviour. ([#1832](https://github.com/can1357/oh-my-pi/issues/1832)) +- Fixed `toRecallOptions` dropping `queryEmbedding` between the `Mnemopi` facade and the beam layer, so callers can now explicitly pin or disable the query vector through the public API. +- Fixed `withBeam`/`withSharedBeam` closing the underlying SQLite handle before async handlers finish, which would otherwise surface as `RangeError: Cannot use a closed database` from MCP recall/shared-recall after the move to async. + ## [15.7.4] - 2026-05-31 ### Fixed diff --git a/packages/mnemopi/src/cli.ts b/packages/mnemopi/src/cli.ts index 73a56acc0..1332ffd93 100755 --- a/packages/mnemopi/src/cli.ts +++ b/packages/mnemopi/src/cli.ts @@ -180,12 +180,13 @@ export const cmdRemember: CommandHandler = (args, context) => { }); }; -export const cmdRecall: CommandHandler = (args, context) => { +export const cmdRecall: CommandHandler = async (args, context) => { if (args.length === 0) usage("Usage: mnemopi recall [top_k]"); const query = args[0] ?? ""; const topK = args[1] === undefined ? 5 : parseIntArg(args[1], "top_k"); - return withMemory(context, memory => { - const results = memory.recall(query, topK); + const { memory, owned } = getMemory(context); + try { + const results = await memory.recall(query, topK); out(context, `\nResults for: ${query}\n`); for (const result of results) { const content = result.content ?? ""; @@ -197,7 +198,9 @@ export const cmdRecall: CommandHandler = (args, context) => { out(context); } return 0; - }); + } finally { + if (owned) memory.close(); + } }; export const cmdUpdate: CommandHandler = (args, context) => { diff --git a/packages/mnemopi/src/core/beam/consolidate.ts b/packages/mnemopi/src/core/beam/consolidate.ts index 5e19c4135..4d35221ae 100644 --- a/packages/mnemopi/src/core/beam/consolidate.ts +++ b/packages/mnemopi/src/core/beam/consolidate.ts @@ -3,6 +3,7 @@ import { generateId, stableMemoryId } from "../../util/ids"; import { aaakEncode } from "../aaak"; import { heuristicExtractFacts } from "../extraction"; import { clampVeracity } from "../veracity-consolidation"; +import { scheduleEmbedding } from "./helpers"; import type { BeamMemoryState, BeamStats, JsonValue, MemoriaRetrieveResult, Metadata, SleepResult } from "./types"; type Row = Record; @@ -298,6 +299,7 @@ export function consolidateToEpisodic( ], ); extractAndStoreFacts(beam, summary, 0, memoryId); + scheduleEmbedding(beam, [{ memoryId, content: summary }]); emitEvent(beam, "MEMORY_CONSOLIDATED", memoryId, summary, source, importance, { summary_of: [...sourceWmIds], ...metadata, diff --git a/packages/mnemopi/src/core/beam/helpers.ts b/packages/mnemopi/src/core/beam/helpers.ts index cfed566c1..91dfd6f68 100644 --- a/packages/mnemopi/src/core/beam/helpers.ts +++ b/packages/mnemopi/src/core/beam/helpers.ts @@ -1,5 +1,7 @@ import type { Database } from "bun:sqlite"; import { generateId as generateTimedId, sha256Hex16, stableMemoryId } from "../../util/ids"; +import { currentEmbeddingModel, embed } from "../embeddings"; +import { getMnemopiRuntimeOptions, withMnemopiRuntimeOptions } from "../runtime-options"; import { cosineSimilarity as vectorCosineSimilarity } from "../vector-math"; import type { BeamMemoryState, JsonValue, Metadata } from "./types"; @@ -918,3 +920,58 @@ export { quantizeInt8, } from "../binary-vectors"; export { sha256Hex16 }; + +/** Identifies one freshly stored memory whose embedding still needs to be derived. */ +export interface EmbedItem { + readonly memoryId: string; + readonly content: string; +} + +async function runEmbedding(beam: BeamMemoryState, items: readonly EmbedItem[]): Promise { + try { + const matrix = await embed(items.map(item => item.content)); + if (matrix === null) return; + const model = currentEmbeddingModel(); + const insertEmbedding = beam.db.prepare( + "INSERT OR REPLACE INTO memory_embeddings(memory_id, embedding_json, model) VALUES (?, ?, ?)", + ); + const insertMany = beam.db.transaction((rows: readonly EmbedItem[]) => { + for (let i = 0; i < rows.length; i += 1) { + const vector = matrix[i]; + const item = rows[i]; + if (vector === undefined || item === undefined) continue; + insertEmbedding.run(item.memoryId, JSON.stringify(Array.from(vector)), model); + } + }); + insertMany(items); + } catch { + // Background embedding generation is best-effort: a failing provider, a closed DB + // during shutdown, or a transient API error must never disrupt the synchronous + // remember()/consolidate() that scheduled it. Production recall silently degrades + // to FTS-only for the affected rows, which is the same shape as a misconfigured + // provider. + } +} + +/** + * Schedule background embedding generation for one or more freshly stored memories. + * + * Mirrors the `scheduleFactExtraction` pattern in `beam/store.ts`: `remember()`, + * `rememberBatch()`, and `consolidateToEpisodic()` are synchronous, but `embed()` is + * async (it may hit an HTTP provider), so the task is fired-and-forgotten and tracked + * on `beam.pendingExtractions` so tests and graceful shutdown can drain it via + * `flushExtractions()`. The active runtime options (provider, model, API URL/key) are + * captured here and re-entered inside the task because the `AsyncLocalStorage` scope + * set by `Mnemopi.#withRuntimeOptions` has already exited by the time the task runs. + */ +export function scheduleEmbedding(beam: BeamMemoryState, items: readonly EmbedItem[]): void { + const cleaned = items.filter(item => item.content.trim() !== ""); + if (cleaned.length === 0) return; + const runtimeOptions = getMnemopiRuntimeOptions(); + const task = withMnemopiRuntimeOptions(runtimeOptions, () => runEmbedding(beam, cleaned)); + const pending = beam.pendingExtractions; + if (pending !== undefined) { + pending.add(task); + void task.finally(() => pending.delete(task)); + } +} diff --git a/packages/mnemopi/src/core/beam/index.ts b/packages/mnemopi/src/core/beam/index.ts index 36076bb68..cacbfd85f 100644 --- a/packages/mnemopi/src/core/beam/index.ts +++ b/packages/mnemopi/src/core/beam/index.ts @@ -265,11 +265,11 @@ export class BeamMemory implements BeamMemoryState { return memoriaRetrieve(this, query, ability, topK); } - recall(query: string, topK = 40, options: RecallOptions = {}): RecallResult[] { + recall(query: string, topK = 40, options: RecallOptions = {}): Promise { return recall(this, query, topK, options); } - recallEnhanced(query: string, topK = 40, options: RecallEnhancedOptions = {}): RecallResult[] { + recallEnhanced(query: string, topK = 40, options: RecallEnhancedOptions = {}): Promise { return recallEnhanced(this, query, topK, options); } diff --git a/packages/mnemopi/src/core/beam/recall.ts b/packages/mnemopi/src/core/beam/recall.ts index 4f068fb60..03dc04b4b 100644 --- a/packages/mnemopi/src/core/beam/recall.ts +++ b/packages/mnemopi/src/core/beam/recall.ts @@ -1,4 +1,5 @@ import { normalizedRecallWeights, temporalHalflifeHours } from "../../config"; +import { embedQuery } from "../embeddings"; import { mmrRerank } from "../mmr"; import { adjustWeights, classifyIntent } from "../query-intent"; import { getSynonyms, normalizeQuery } from "../synonyms"; @@ -857,12 +858,12 @@ function collectMemoryCandidates( return candidates; } -export function recall( +export async function recall( beam: BeamMemoryState, query: string, topK = 40, options: RecallOptionsInternal = {}, -): RecallResult[] { +): Promise { if (topK <= 0) return []; const temporalOptions = inferTemporalOptions(query, options); if (queryAsksCurrent(query)) { @@ -870,6 +871,14 @@ export function recall( temporalOptions.temporalWeight ??= 0.45; temporalOptions.currentSensitive = true; } + if (temporalOptions.queryEmbedding === undefined) { + // Honour `null` (explicit "no embedding"); `undefined` means "derive from query text". + // `embedQuery()` returns null when embeddings are disabled or no provider is configured, + // so this is a no-op when the user has not wired one up. Float32Array → number[] + // because RecallOptions exposes the narrower public shape. + const derived = query.length > 0 ? await embedQuery(query) : null; + temporalOptions.queryEmbedding = derived === null ? null : Array.from(derived); + } let weights = normalizedRecallWeights( options.vecWeight ?? beam.config.vecWeight, options.ftsWeight ?? beam.config.ftsWeight, @@ -935,12 +944,12 @@ function diversifyByCoverage( return selected; } -export function recallEnhanced( +export async function recallEnhanced( beam: BeamMemoryState, query: string, topK = 40, options: RecallEnhancedOptions & RecallOptionsInternal = {}, -): RecallResult[] { +): Promise { const useSynonyms = options.useSynonyms !== false; const enhancedOptions: RecallOptionsInternal = { ...options, @@ -948,7 +957,7 @@ export function recallEnhanced( useIntent: options.useIntent !== false, useMmr: options.useMmr !== false, }; - const results = recall(beam, query, Math.max(topK * 2, topK), { + const results = await recall(beam, query, Math.max(topK * 2, topK), { ...enhancedOptions, updateRecallCounts: false, }); diff --git a/packages/mnemopi/src/core/beam/store.ts b/packages/mnemopi/src/core/beam/store.ts index 65146c677..0a89b9da1 100644 --- a/packages/mnemopi/src/core/beam/store.ts +++ b/packages/mnemopi/src/core/beam/store.ts @@ -6,7 +6,7 @@ import { EpisodicGraph } from "../episodic-graph"; import { extractFactsSafe } from "../extraction"; import { getMnemopiRuntimeOptions, withMnemopiRuntimeOptions } from "../runtime-options"; import { storeFactStrings } from "./consolidate"; -import { vecAvailable, vecInsert } from "./helpers"; +import { scheduleEmbedding, vecAvailable, vecInsert } from "./helpers"; import type { BeamEvent, BeamMemoryState, @@ -341,6 +341,7 @@ export function remember(beam: BeamMemoryState, content: string, options: StoreR importance, metadata: metadata ?? undefined, }); + scheduleEmbedding(beam, [{ memoryId, content }]); if (options.extract === true) scheduleFactExtraction(beam, memoryId, content); invalidateCaches(beam); return memoryId; @@ -404,6 +405,13 @@ export function rememberBatch( trimWorkingMemory(beam); }); invalidateCaches(beam); + const embeddingItems: { memoryId: string; content: string }[] = []; + items.forEach((item, index) => { + const id = ids[index]; + if (id === undefined) return; + embeddingItems.push({ memoryId: id, content: item.content }); + }); + scheduleEmbedding(beam, embeddingItems); items.forEach((item, index) => { const id = ids[index]; if (id !== undefined && (item.extract === true || options.extract === true)) { @@ -508,7 +516,10 @@ export function updateWorking( const result = beam.db .prepare(`UPDATE working_memory SET ${assignments.join(", ")} WHERE id = ? AND session_id = ?`) .run(...params); - if (result.changes > 0) invalidateCaches(beam); + if (result.changes > 0) { + invalidateCaches(beam); + if (content !== null) scheduleEmbedding(beam, [{ memoryId, content }]); + } return result.changes > 0; } diff --git a/packages/mnemopi/src/core/embeddings.ts b/packages/mnemopi/src/core/embeddings.ts index e64f27ba5..28dace5a1 100644 --- a/packages/mnemopi/src/core/embeddings.ts +++ b/packages/mnemopi/src/core/embeddings.ts @@ -100,6 +100,18 @@ function defaultModel(): string { return $env.MNEMOPI_EMBEDDING_MODEL || "BAAI/bge-small-en-v1.5"; } +/** + * Resolve the embedding model name for the currently active runtime scope. + * + * Reads (in order): the active provider's `model` from `withMnemopiRuntimeOptions`, + * the `MNEMOPI_EMBEDDING_MODEL` env var, then the bundled fastembed default. Stored + * alongside each row in `memory_embeddings.model` so migrations can re-embed when + * the active model changes. + */ +export function currentEmbeddingModel(): string { + return defaultModel(); +} + export function isApiModel(modelName: string): boolean { if ( modelName.startsWith("openai/") || diff --git a/packages/mnemopi/src/core/memory.ts b/packages/mnemopi/src/core/memory.ts index 429ff4f41..d1e9911ba 100644 --- a/packages/mnemopi/src/core/memory.ts +++ b/packages/mnemopi/src/core/memory.ts @@ -266,7 +266,7 @@ function toRememberOptions(input: string | RememberInput, options: RememberFacad } function toRecallOptions(options: RecallFacadeOptions): BeamRecallFacadeOptions { - return { + const beamOptions: BeamRecallFacadeOptions = { fromDate: options.fromDate ?? options.from_date ?? null, toDate: options.toDate ?? options.to_date ?? null, authorId: options.authorId ?? null, @@ -282,6 +282,11 @@ function toRecallOptions(options: RecallFacadeOptions): BeamRecallFacadeOptions ftsWeight: options.ftsWeight ?? options.fts_weight ?? undefined, importanceWeight: options.importanceWeight ?? options.importance_weight ?? undefined, }; + // Preserve the three-state semantics (`undefined` = auto-derive, `null` = explicitly + // FTS-only, `number[]` = caller-supplied) so callers can opt out of `recall()`'s + // auto-embed without being forced into it. + if ("queryEmbedding" in options) beamOptions.queryEmbedding = options.queryEmbedding; + return beamOptions; } function countRows(db: Database, sql: string, ...params: (string | number | null)[]): number { @@ -397,11 +402,15 @@ export class Mnemopi { return this.#withRuntimeOptions(() => this.beam.remember(content, toRememberOptions(memory, options))); } - recall(query: string, topK = 5, options: RecallFacadeOptions = {}): RecallResult[] { + recall(query: string, topK = 5, options: RecallFacadeOptions = {}): Promise { return this.#withRuntimeOptions(() => this.beam.recall(query, topK, toRecallOptions(options))); } - recallEnhanced(query: string, topK = 5, options: RecallFacadeOptions & RecallEnhancedOptions = {}): RecallResult[] { + recallEnhanced( + query: string, + topK = 5, + options: RecallFacadeOptions & RecallEnhancedOptions = {}, + ): Promise { return this.#withRuntimeOptions(() => this.beam.recallEnhanced(query, topK, { ...toRecallOptions(options), @@ -489,11 +498,11 @@ export class Mnemopi { return this.remember(memory, options); } - search(query: string, topK = 5, options: RecallFacadeOptions = {}): RecallResult[] { + search(query: string, topK = 5, options: RecallFacadeOptions = {}): Promise { return this.recall(query, topK, options); } - query(query: string, topK = 5, options: RecallFacadeOptions = {}): RecallResult[] { + query(query: string, topK = 5, options: RecallFacadeOptions = {}): Promise { return this.recall(query, topK, options); } @@ -523,11 +532,15 @@ export function remember(content: string | RememberInput, options: ModuleRemembe return defaultFor(options.bank).remember(content, options); } -export function recall(query: string, topK = 5, options: ModuleRecallOptions = {}): RecallResult[] { +export function recall(query: string, topK = 5, options: ModuleRecallOptions = {}): Promise { return defaultFor(options.bank).recall(query, topK, options); } -export function recallEnhanced(query: string, topK = 5, options: ModuleRecallEnhancedOptions = {}): RecallResult[] { +export function recallEnhanced( + query: string, + topK = 5, + options: ModuleRecallEnhancedOptions = {}, +): Promise { return defaultFor(options.bank).recallEnhanced(query, topK, options); } @@ -591,11 +604,11 @@ export function storeMemory(memory: string | RememberInput, options: ModuleRemem return remember(memory, options); } -export function search(query: string, topK = 5, options: ModuleRecallOptions = {}): RecallResult[] { +export function search(query: string, topK = 5, options: ModuleRecallOptions = {}): Promise { return recall(query, topK, options); } -export function query(query: string, topK = 5, options: ModuleRecallOptions = {}): RecallResult[] { +export function query(query: string, topK = 5, options: ModuleRecallOptions = {}): Promise { return recall(query, topK, options); } diff --git a/packages/mnemopi/src/core/orchestrator.ts b/packages/mnemopi/src/core/orchestrator.ts index f2d0fb41d..f63fa273d 100644 --- a/packages/mnemopi/src/core/orchestrator.ts +++ b/packages/mnemopi/src/core/orchestrator.ts @@ -1,4 +1,5 @@ import type { BeamMemoryState, RecallOptions, RecallResult } from "./beam/types"; +import { embedQuery } from "./embeddings"; import { type PolyphonicMemoryResult, type PolyphonicRecallOptions, @@ -7,8 +8,8 @@ import { } from "./polyphonic-recall"; export interface OrchestratorBeam extends BeamMemoryState { - recall?: (query: string, topK?: number, options?: RecallOptions) => RecallResult[]; - recallEnhanced?: (query: string, topK?: number, options?: RecallOptions) => RecallResult[]; + recall?: (query: string, topK?: number, options?: RecallOptions) => Promise; + recallEnhanced?: (query: string, topK?: number, options?: RecallOptions) => Promise; } export interface OrchestrateRecallOptions @@ -35,18 +36,26 @@ function toLinearRecallOptions(options: OrchestrateRecallOptions): RecallOptions return options as RecallOptions; } -export function orchestrateRecall( +export async function orchestrateRecall( beam: OrchestratorBeam, query: string, topK = 20, options: OrchestrateRecallOptions = {}, -): OrchestratedRecallResult[] { - if (!options.forceLinear && (options.forcePolyphonic === true || polyphonicRecallIsEnabled())) { - return polyphonicRecall(beam, query, topK, options); +): Promise { + const polyphonic = !options.forceLinear && (options.forcePolyphonic === true || polyphonicRecallIsEnabled()); + let queryEmbedding: readonly number[] | Float32Array | null | undefined = options.queryEmbedding; + if (queryEmbedding === undefined && query.length > 0) { + // Auto-derive when the caller did not pass one. `embedQuery()` returns null when + // embeddings are disabled or no provider is configured, so this is a no-op for + // FTS-only deployments. `null` (explicit "no embedding") is preserved untouched. + queryEmbedding = await embedQuery(query); } - const linearOptions = toLinearRecallOptions(options); - if (options.enhanced === true) { - if (typeof beam.recallEnhanced === "function") return beam.recallEnhanced(query, topK, linearOptions); + if (polyphonic) { + return polyphonicRecall(beam, query, topK, { ...options, queryEmbedding }); + } + const linearOptions = toLinearRecallOptions({ ...options, queryEmbedding }); + if (options.enhanced === true && typeof beam.recallEnhanced === "function") { + return beam.recallEnhanced(query, topK, linearOptions); } if (typeof beam.recall === "function") return beam.recall(query, topK, linearOptions); return []; diff --git a/packages/mnemopi/src/mcp-server.ts b/packages/mnemopi/src/mcp-server.ts index 6f5a81329..08873b58f 100644 --- a/packages/mnemopi/src/mcp-server.ts +++ b/packages/mnemopi/src/mcp-server.ts @@ -52,9 +52,9 @@ export function listToolsJson(): ListToolsResponse { return { tools: getToolDefinitions() }; } -export function callToolJson(name: string, args: ToolArguments = {}): CallToolResponse { +export async function callToolJson(name: string, args: ToolArguments = {}): Promise { try { - const result = handleToolCall(name, args); + const result = await handleToolCall(name, args); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -65,7 +65,7 @@ export function callToolJson(name: string, args: ToolArguments = {}): CallToolRe } } -export function handleJsonRpc(request: JsonRpcRequest): JsonRpcResponse | null { +export async function handleJsonRpc(request: JsonRpcRequest): Promise { const method = request.method ?? ""; if (method.startsWith("notifications/") || !hasRequestId(request)) return null; const id = requestId(request); @@ -85,7 +85,7 @@ export function handleJsonRpc(request: JsonRpcRequest): JsonRpcResponse | null { ? (params.arguments as ToolArguments) : {}; if (name.length === 0) return err(id, -32602, "tools/call requires params.name"); - return ok(id, callToolJson(name, args)); + return ok(id, await callToolJson(name, args)); } return err(id, -32601, `Unknown method: ${method}`); } @@ -115,7 +115,7 @@ export async function runStdio( newline = buffer.indexOf("\n"); continue; } - const response = handleJsonRpc(parsed as JsonRpcRequest); + const response = await handleJsonRpc(parsed as JsonRpcRequest); if (response !== null) output.write(`${JSON.stringify(response)}\n`); } newline = buffer.indexOf("\n"); diff --git a/packages/mnemopi/src/mcp-tools.ts b/packages/mnemopi/src/mcp-tools.ts index ded272a99..815478bb5 100644 --- a/packages/mnemopi/src/mcp-tools.ts +++ b/packages/mnemopi/src/mcp-tools.ts @@ -447,13 +447,25 @@ function sharedBeam(): BeamMemory { return new BeamMemory({ sessionId: "mcp_shared_surface", dbPath }); } -function withBeam(args: ToolArguments, fn: (beam: BeamMemory, bank: string) => T): T { +function withBeam(args: ToolArguments, fn: (beam: BeamMemory, bank: string) => Promise): Promise; +function withBeam(args: ToolArguments, fn: (beam: BeamMemory, bank: string) => T): T; +function withBeam(args: ToolArguments, fn: (beam: BeamMemory, bank: string) => T | Promise): T | Promise { const bank = resolveBank(args); const beam = createBeam(args, bank); + let settled = false; try { - return fn(beam, bank); + const result = fn(beam, bank); + if (result instanceof Promise) { + // Defer close until the async handler resolves; otherwise the beam closes + // out from under in-flight `recall`/`embed` work and downstream queries see + // "Cannot use a closed database". + settled = true; + return result.finally(() => beam.close()); + } + settled = true; + return result; } finally { - beam.close(); + if (!settled) beam.close(); } } @@ -518,10 +530,10 @@ function handleRemember(args: ToolArguments): ToolResult { }); } -function handleRecall(args: ToolArguments): ToolResult { +async function handleRecall(args: ToolArguments): Promise { const query = required(args, "query"); if (typeof query !== "string") return query; - return withBeam(args, (beam, bank) => { + return withBeam(args, async (beam, bank) => { const topK = Math.trunc(numberArg(args, "top_k", numberArg(args, "limit", 5))); const options: RecallOptions & Record = { temporalWeight: numberArg(args, "temporal_weight", 0.0), @@ -534,7 +546,7 @@ function handleRecall(args: ToolArguments): ToolResult { for (const key of ["vec_weight", "fts_weight", "importance_weight"] as const) { if (key in args) options[key.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())] = args[key]; } - const results = beam.recall(query, topK, options).map(row => ({ ...row, bank })); + const results = (await beam.recall(query, topK, options)).map(row => ({ ...row, bank })); return { status: "ok", query, count: results.length, results: serialize(results), bank }; }); } @@ -732,12 +744,21 @@ function surfaceLabel(content: string, kind: string): string { return `${label}: ${content}`; } -function withSharedBeam(fn: (beam: BeamMemory) => T): T { +function withSharedBeam(fn: (beam: BeamMemory) => Promise): Promise; +function withSharedBeam(fn: (beam: BeamMemory) => T): T; +function withSharedBeam(fn: (beam: BeamMemory) => T | Promise): T | Promise { const beam = sharedBeam(); + let settled = false; try { - return fn(beam); + const result = fn(beam); + if (result instanceof Promise) { + settled = true; + return result.finally(() => beam.close()); + } + settled = true; + return result; } finally { - beam.close(); + if (!settled) beam.close(); } } @@ -765,13 +786,15 @@ function handleSharedRemember(args: ToolArguments): ToolResult { }); } -function handleSharedRecall(args: ToolArguments): ToolResult { +async function handleSharedRecall(args: ToolArguments): Promise { const query = required(args, "query"); if (typeof query !== "string") return query; - return withSharedBeam(beam => { - const results = beam - .recall(query, Math.trunc(numberArg(args, "limit", 5))) - .map(row => ({ ...row, bank: "surface", shared_surface: true })); + return withSharedBeam(async beam => { + const results = (await beam.recall(query, Math.trunc(numberArg(args, "limit", 5)))).map(row => ({ + ...row, + bank: "surface", + shared_surface: true, + })); return { query, count: results.length, results: serialize(results) }; }); } @@ -922,7 +945,7 @@ function handleGraphLink(args: ToolArguments): ToolResult { }); } -type Handler = (args: ToolArguments) => ToolResult; +type Handler = (args: ToolArguments) => ToolResult | Promise; const TOOL_HANDLERS: Record = { mnemopi_remember: handleRemember, @@ -951,7 +974,7 @@ const TOOL_HANDLERS: Record = { mnemopi_graph_link: handleGraphLink, }; -export function handleToolCall(name: string, args: ToolArguments = {}): ToolResult { +export async function handleToolCall(name: string, args: ToolArguments = {}): Promise { const handler = TOOL_HANDLERS[name]; if (handler === undefined) throw new Error(`Unknown tool: ${name}`); return handler(args); diff --git a/packages/mnemopi/test/beam-e3-e4-e6.test.ts b/packages/mnemopi/test/beam-e3-e4-e6.test.ts index 589cac405..613330e13 100644 --- a/packages/mnemopi/test/beam-e3-e4-e6.test.ts +++ b/packages/mnemopi/test/beam-e3-e4-e6.test.ts @@ -84,7 +84,7 @@ afterEach(() => { }); describe("Beam E3/E4/E6 parity integration", () => { - it("sleep is additive, marks consolidated_at, preserves recallability, and is idempotent", () => { + it("sleep is additive, marks consolidated_at, preserves recallability, and is idempotent", async () => { const db = tempDb(); const beam = new BeamMemory({ sessionId: "s1", dbPath: db.path }); try { @@ -101,7 +101,9 @@ describe("Beam E3/E4/E6 parity integration", () => { }[]; expect(marked.every(row => row.consolidated_at !== null)).toBe(true); for (const row of marked) expect(() => new Date(row.consolidated_at ?? "bad").toISOString()).not.toThrow(); - expect(beam.recall("token1", 10).some(row => row.id === "wm-old-2" && row.tier === "working")).toBe(true); + expect((await beam.recall("token1", 10)).some(row => row.id === "wm-old-2" && row.tier === "working")).toBe( + true, + ); expect(beam.sleep(false).status).toBe("no_op"); expect(beam.db.query("SELECT COUNT(*) AS count FROM episodic_memory").get()).toEqual({ count: 1, @@ -155,7 +157,7 @@ describe("Beam E3/E4/E6 parity integration", () => { } }); - it("cross-tier recall deduplicates summary/source pairs before recall_count attribution", () => { + it("cross-tier recall deduplicates summary/source pairs before recall_count attribution", async () => { const db = tempDb(); const beam = new BeamMemory({ sessionId: "s1", dbPath: db.path }); try { @@ -188,7 +190,7 @@ describe("Beam E3/E4/E6 parity integration", () => { "INSERT INTO working_memory (id, content, source, timestamp, session_id, importance, veracity) VALUES (?, ?, ?, ?, ?, ?, ?)", ["wm-2", "deployment notes for staging", "conversation", new Date().toISOString(), "s1", 0.5, "stated"], ); - const results = beam.recall("deployment", 2); + const results = await beam.recall("deployment", 2); const ids = results.map(row => row.id); expect(new Set(ids).size).toBe(ids.length); expect(ids.includes("wm-1") && ids.includes("ep-1")).toBe(false); diff --git a/packages/mnemopi/test/beam-index.test.ts b/packages/mnemopi/test/beam-index.test.ts index 83585ffdf..f999e72e0 100644 --- a/packages/mnemopi/test/beam-index.test.ts +++ b/packages/mnemopi/test/beam-index.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"; import { BeamMemory } from "../src/core/beam"; describe("BeamMemory hub", () => { - it("wires index methods to beam module implementations", () => { + it("wires index methods to beam module implementations", async () => { const beam = new BeamMemory({ dbPath: ":memory:" }); try { const memoryId = beam.remember("Beam hub remembers project Alpha preferences", { @@ -11,8 +11,8 @@ describe("BeamMemory hub", () => { }); expect(memoryId).toHaveLength(16); - expect(beam.recall("Alpha", 5).some(row => row.id === memoryId)).toBe(true); - expect(beam.recallEnhanced("Alpha", 5).some(row => row.id === memoryId)).toBe(true); + expect((await beam.recall("Alpha", 5)).some(row => row.id === memoryId)).toBe(true); + expect((await beam.recallEnhanced("Alpha", 5)).some(row => row.id === memoryId)).toBe(true); expect(beam.getContext(10).some(row => (row as { id?: string }).id === memoryId)).toBe(true); expect(beam.getWorkingStats()).toMatchObject({ count: 1 }); diff --git a/packages/mnemopi/test/beam-parity.test.ts b/packages/mnemopi/test/beam-parity.test.ts index 332f8fc61..4f1cf258c 100644 --- a/packages/mnemopi/test/beam-parity.test.ts +++ b/packages/mnemopi/test/beam-parity.test.ts @@ -24,7 +24,7 @@ function closeAndRemoveAll(): void { afterEach(closeAndRemoveAll); describe("Beam TS parity integration", () => { - it("constructs on string DB paths, creates parents, remembers, and recalls from an isolated file DB", () => { + it("constructs on string DB paths, creates parents, remembers, and recalls from an isolated file DB", async () => { const db = tempDb(join("nested", "beam.db")); const beam = new BeamMemory({ sessionId: "path-coercion", dbPath: db.path }); try { @@ -38,7 +38,7 @@ describe("Beam TS parity integration", () => { expect(beam.getContext(5)).toMatchObject([ { id, content: "Prefers Neovim for editing", source: "preference" }, ]); - const recalled = beam.recall("Neovim editing", 5); + const recalled = await beam.recall("Neovim editing", 5); expect(recalled.some(row => row.id === id && row.tier === "working")).toBe(true); } finally { beam.close(); @@ -80,7 +80,7 @@ describe("Beam TS parity integration", () => { } }); - it("rememberBatch threads veracity into storage and recall scoring", () => { + it("rememberBatch threads veracity into storage and recall scoring", async () => { const db = tempDb(); const beam = new BeamMemory({ sessionId: "veracity", dbPath: db.path }); try { @@ -90,7 +90,7 @@ describe("Beam TS parity integration", () => { veracity: label, }); } - const results = beam.recall(token, 20); + const results = await beam.recall(token, 20); const scores = new Map(results.map(row => [row.veracity, row.score ?? 0])); expect([...scores.keys()].sort()).toEqual(["imported", "inferred", "stated", "tool", "unknown"]); expect(scores.get("stated") ?? 0).toBeGreaterThan(scores.get("unknown") ?? 0); diff --git a/packages/mnemopi/test/beam-recall-unit.test.ts b/packages/mnemopi/test/beam-recall-unit.test.ts index bb088c6e5..d78e43a2d 100644 --- a/packages/mnemopi/test/beam-recall-unit.test.ts +++ b/packages/mnemopi/test/beam-recall-unit.test.ts @@ -85,12 +85,12 @@ function insertEpisodic( } describe("beam recall free functions", () => { - it("orders deterministic FTS-only working-memory hits by lexical strength", () => { + it("orders deterministic FTS-only working-memory hits by lexical strength", async () => { const beam = makeBeam(); insertWorking(beam, "wm-weak", "banana appears once beside unrelated notes"); insertWorking(beam, "wm-strong", "banana banana banana release checklist"); - const results = recall(beam, "banana", 2, { queryTime: "2026-05-30T12:00:00.000Z" }); + const results = await recall(beam, "banana", 2, { queryTime: "2026-05-30T12:00:00.000Z" }); const top = results[0]; expect(results.map(result => result.id)).toEqual(["wm-strong", "wm-weak"]); @@ -101,12 +101,12 @@ describe("beam recall free functions", () => { expect(top.fts_score).toBeGreaterThan(0); }); - it("fuses working and episodic memory candidates", () => { + it("fuses working and episodic memory candidates", async () => { const beam = makeBeam(); insertWorking(beam, "wm-deploy", "deploy runbook says use the blue pipeline"); insertEpisodic(beam, "em-deploy", "deploy retrospective: blue pipeline avoided downtime"); - const results = recall(beam, "deploy blue pipeline", 5, { + const results = await recall(beam, "deploy blue pipeline", 5, { queryTime: "2026-05-30T12:00:00.000Z", }); @@ -115,7 +115,7 @@ describe("beam recall free functions", () => { expect(new Set(results.map(result => result.tier_label))).toEqual(new Set(["working", "episodic"])); }); - it("boosts memories near the requested temporal target", () => { + it("boosts memories near the requested temporal target", async () => { const beam = makeBeam(); insertEpisodic(beam, "em-old", "incident alpha resolved by rotating credentials", { timestamp: "2026-05-10T09:00:00.000Z", @@ -126,7 +126,7 @@ describe("beam recall free functions", () => { eventDate: "2026-05-29", }); - const results = recall(beam, "incident alpha", 2, { + const results = await recall(beam, "incident alpha", 2, { queryTime: "2026-05-29T12:00:00.000Z", temporalWeight: 1.0, temporalHalflife: 12, @@ -147,7 +147,7 @@ describe("beam recall free functions", () => { expect(target.temporal_score).toBeGreaterThan(old.temporal_score); }); - it("accounts for importance and recency in deterministic fallback scoring", () => { + it("accounts for importance and recency in deterministic fallback scoring", async () => { const beam = makeBeam(); insertWorking(beam, "wm-low", "phoenix migration requires operator approval", { timestamp: new Date().toISOString(), @@ -158,7 +158,7 @@ describe("beam recall free functions", () => { importance: 1.0, }); - const results = recall(beam, "phoenix migration", 2, { + const results = await recall(beam, "phoenix migration", 2, { importanceWeight: 0.8, ftsWeight: 0.1, vecWeight: 0.1, @@ -168,25 +168,25 @@ describe("beam recall free functions", () => { expect(results[0]?.score).toBeGreaterThan(results[1]?.score ?? 0); }); - it("handles CJK token queries without embeddings", () => { + it("handles CJK token queries without embeddings", async () => { const beam = makeBeam(); insertWorking(beam, "wm-cjk", "数据库 密码 已轮换"); insertWorking(beam, "wm-other", "unrelated english note"); - const results = recall(beam, "数据库", 3); + const results = await recall(beam, "数据库", 3); expect(results[0]?.id).toBe("wm-cjk"); expect(results.map(result => result.id)).not.toContain("wm-other"); }); - it("does not retry scoped recall without the session filter when only another session matches", () => { + it("does not retry scoped recall without the session filter when only another session matches", async () => { const beam = makeBeam(); insertWorking(beam, "wm-private-other", "orion marker lives only in the other private session", { sessionId: "s2", scope: "session", }); - const results = recall(beam, "orion marker", 5); + const results = await recall(beam, "orion marker", 5); expect(results).toHaveLength(0); }); @@ -273,12 +273,12 @@ describe("beam recall free functions", () => { expect(results[0]?.content).toBe("postgres global database"); }); - it("increments enhanced recall counts only for the final returned MMR results", () => { + it("increments enhanced recall counts only for the final returned MMR results", async () => { const beam = makeBeam(); insertWorking(beam, "wm-enhanced-keep", "calypso migration plan keeps postgres online", { importance: 1.0 }); insertWorking(beam, "wm-enhanced-drop", "calypso migration plan keeps redis online", { importance: 0.9 }); - const results = recallEnhanced(beam, "calypso migration plan keeps online", 1, { useCache: false }); + const results = await recallEnhanced(beam, "calypso migration plan keeps online", 1, { useCache: false }); const returned = results[0]?.id; if (returned === undefined) throw new Error("expected enhanced recall to return one result"); @@ -291,12 +291,12 @@ describe("beam recall free functions", () => { expect(counts.get(returned === "wm-enhanced-keep" ? "wm-enhanced-drop" : "wm-enhanced-keep")).toBe(0); }); - it("enhanced recall applies intent/synonym/MMR path without dropping required fields", () => { + it("enhanced recall applies intent/synonym/MMR path without dropping required fields", async () => { const beam = makeBeam(); insertWorking(beam, "wm-db", "database migration notes mention postgres"); insertWorking(beam, "wm-cache", "cache migration notes mention redis"); - const results = recallEnhanced(beam, "db migration", 2, { useCache: false }); + const results = await recallEnhanced(beam, "db migration", 2, { useCache: false }); expect(results).toHaveLength(2); expect(results[0]?.id).toBeTruthy(); diff --git a/packages/mnemopi/test/cli.test.ts b/packages/mnemopi/test/cli.test.ts index dc37bd60c..294e83c2f 100644 --- a/packages/mnemopi/test/cli.test.ts +++ b/packages/mnemopi/test/cli.test.ts @@ -30,7 +30,7 @@ function capture() { } describe("CLI command handlers", () => { - it("remember stores through BeamMemory and recall prints real results", () => { + it("remember stores through BeamMemory and recall prints real results", async () => { const root = tempRoot(); try { const io = capture(); @@ -39,7 +39,7 @@ describe("CLI command handlers", () => { expect(io.stdout).toContain("Stored:"); const recallIo = capture(); - expect(cmdRecall(["Alpha", "5"], recallIo.context(root))).toBe(0); + expect(await cmdRecall(["Alpha", "5"], recallIo.context(root))).toBe(0); expect(recallIo.stdout).toContain("Results for: Alpha"); expect(recallIo.stdout).toContain("Project Alpha prefers terse answers"); expect(recallIo.stderr).toBe(""); diff --git a/packages/mnemopi/test/configurable-scoring.test.ts b/packages/mnemopi/test/configurable-scoring.test.ts index ab0a85895..82cbe8597 100644 --- a/packages/mnemopi/test/configurable-scoring.test.ts +++ b/packages/mnemopi/test/configurable-scoring.test.ts @@ -52,17 +52,17 @@ describe("configurable recall scoring", () => { expect(normalizedRecallWeights()).toEqual([0.7, 0.2, 0.1]); }); - it("uses explicit per-call weights for recall scoring", () => { + it("uses explicit per-call weights for recall scoring", async () => { const beam = makeBeam(); beam.remember("alpha exact text match low priority", { importance: 0.1, source: "test" }); beam.remember("alpha exact text match critical priority", { importance: 0.9, source: "test" }); - const highImportance = beam.recall("alpha exact text match", 2, { + const highImportance = await beam.recall("alpha exact text match", 2, { vecWeight: 0, ftsWeight: 0.1, importanceWeight: 0.9, }); - const textDominant = beam.recall("critical priority", 2, { + const textDominant = await beam.recall("critical priority", 2, { vecWeight: 0, ftsWeight: 1, importanceWeight: 0, @@ -73,7 +73,7 @@ describe("configurable recall scoring", () => { expect(highImportance[0]?.score ?? 0).toBeGreaterThan(highImportance[1]?.score ?? 0); }); - it("lets environment weights affect BeamMemory defaults", () => { + it("lets environment weights affect BeamMemory defaults", async () => { process.env.MNEMOPI_VEC_WEIGHT = "0.1"; process.env.MNEMOPI_FTS_WEIGHT = "0.1"; process.env.MNEMOPI_IMPORTANCE_WEIGHT = "0.8"; @@ -81,7 +81,7 @@ describe("configurable recall scoring", () => { beam.remember("Content A shared lexical anchor", { importance: 0.2, source: "test" }); beam.remember("Content B shared lexical anchor", { importance: 0.9, source: "test" }); - const results = beam.recall("shared lexical anchor", 2, { + const results = await beam.recall("shared lexical anchor", 2, { queryTime: "2026-05-30T12:00:00.000Z", }); @@ -90,7 +90,7 @@ describe("configurable recall scoring", () => { expect(results[0]?.importance ?? 0).toBeGreaterThan(results[1]?.importance ?? 0); }); - it("explicit BeamMemory config overrides environment weights", () => { + it("explicit BeamMemory config overrides environment weights", async () => { process.env.MNEMOPI_VEC_WEIGHT = "0.1"; process.env.MNEMOPI_FTS_WEIGHT = "0.1"; process.env.MNEMOPI_IMPORTANCE_WEIGHT = "0.8"; @@ -103,17 +103,17 @@ describe("configurable recall scoring", () => { beam.remember("Exact text match phrase low", { importance: 0.1, source: "test" }); beam.remember("Exact text distraction high", { importance: 0.9, source: "test" }); - const results = beam.recall("exact text match phrase", 2, { + const results = await beam.recall("exact text match phrase", 2, { queryTime: "2026-05-30T12:00:00.000Z", }); expect(results[0]?.content).toContain("match phrase"); }); - it("includes score breakdown fields and coexists with temporal scoring", () => { + it("includes score breakdown fields and coexists with temporal scoring", async () => { const beam = makeBeam(); beam.remember("Recent event happened today", { importance: 0.5, source: "test" }); - const results = beam.recall("event", 1, { + const results = await beam.recall("event", 1, { vecWeight: 0.4, ftsWeight: 0.3, importanceWeight: 0.3, diff --git a/packages/mnemopi/test/extraction-wiring.test.ts b/packages/mnemopi/test/extraction-wiring.test.ts index 0fb3ae120..b25aa5438 100644 --- a/packages/mnemopi/test/extraction-wiring.test.ts +++ b/packages/mnemopi/test/extraction-wiring.test.ts @@ -76,7 +76,7 @@ describe("remember(extract) wires the LLM fact extractor", () => { await expect(memory.flushExtractions()).resolves.toBeUndefined(); // The memory itself is still durably stored and recallable. - const recalled = memory.recall("opaque payload", 5); + const recalled = await memory.recall("opaque payload", 5); expect(recalled.some(row => row.id === id)).toBe(true); }); }); diff --git a/packages/mnemopi/test/identity-memory-parity.test.ts b/packages/mnemopi/test/identity-memory-parity.test.ts index 284fb0839..76b88e75e 100644 --- a/packages/mnemopi/test/identity-memory-parity.test.ts +++ b/packages/mnemopi/test/identity-memory-parity.test.ts @@ -89,7 +89,7 @@ describe("identity memory parity", () => { } }); - it("isolates recall by author, author type, and channel while preserving same-channel cross-session recall", () => { + it("isolates recall by author, author type, and channel while preserving same-channel cross-session recall", async () => { const dbPath = tempDb(); const abdias = new Mnemopi({ dbPath, @@ -117,13 +117,13 @@ describe("identity memory parity", () => { sarah.remember("Launch is Friday", { scope: "channel" }); ci.remember("Deploy succeeded", { scope: "channel" }); - expect(abdias.recall("dark", 5, { authorId: "abdias" })[0]?.author_id).toBe("abdias"); - expect(abdias.recall("dark", 5, { authorId: "sarah" })).toHaveLength(0); - expect(ci.recall("deploy", 5, { authorType: "agent" })[0]?.author_type).toBe("agent"); + expect((await abdias.recall("dark", 5, { authorId: "abdias" }))[0]?.author_id).toBe("abdias"); + expect(await abdias.recall("dark", 5, { authorId: "sarah" })).toHaveLength(0); + expect((await ci.recall("deploy", 5, { authorType: "agent" }))[0]?.author_type).toBe("agent"); - const launch = abdias.recall("launch", 5, { channelId: "team-a" }); + const launch = await abdias.recall("launch", 5, { channelId: "team-a" }); expect(launch.some(row => row.author_id === "sarah" && row.channel_id === "team-a")).toBe(true); - const teamASecrets = abdias.recall("deploy", 5, { channelId: "team-a" }); + const teamASecrets = await abdias.recall("deploy", 5, { channelId: "team-a" }); expect(teamASecrets.some(row => row.channel_id === "team-b")).toBe(false); } finally { abdias.close(); diff --git a/packages/mnemopi/test/issue-1832-embedding-population.test.ts b/packages/mnemopi/test/issue-1832-embedding-population.test.ts new file mode 100644 index 000000000..5209c5a4d --- /dev/null +++ b/packages/mnemopi/test/issue-1832-embedding-population.test.ts @@ -0,0 +1,174 @@ +/** + * Regression for https://github.com/can1357/oh-my-pi/issues/1832 + * + * Before the fix: + * - `remember()`/`rememberBatch()` never invoked `embed()`, so the + * `memory_embeddings` table was always empty in production. + * - `recall()` never derived a query embedding from the query text, + * so the `dense_score` channel always read zero. + * + * This file pins both contracts using a deterministic in-process embedding + * provider so the fix cannot silently regress in either direction. + */ +import { Database } from "bun:sqlite"; +import { describe, expect, it } from "bun:test"; +import "./setup"; +import { Mnemopi } from "../src/core/memory"; +import { withMnemopiRuntimeOptions } from "../src/core/runtime-options"; + +interface EmbeddingRow { + readonly memory_id: string; + readonly embedding_json: string; + readonly model: string | null; +} + +/** + * Deterministic fake provider: each text yields a 4-D vector based on the + * presence of marker words. Different markers project onto orthogonal axes + * so cosine similarity gives the expected nearest-neighbour ordering. + */ +function fakeProvider() { + let callCount = 0; + const provider = { + // fastembed shape: async generator yielding batches of rows. + async *embed(texts: readonly string[]) { + callCount += 1; + yield texts.map(text => { + const lower = text.toLowerCase(); + if (lower.includes("alpha")) return [1, 0, 0, 0]; + if (lower.includes("beta")) return [0, 1, 0, 0]; + if (lower.includes("gamma")) return [0, 0, 1, 0]; + return [0, 0, 0, 1]; + }); + }, + }; + return { provider, calls: () => callCount }; +} + +function withFakeMemory(fn: (memory: Mnemopi, calls: () => number) => Promise): Promise { + const { provider, calls } = fakeProvider(); + const memory = new Mnemopi({ + db: new Database(":memory:"), + embeddings: { provider: provider.embed.bind(provider) }, + }); + return fn(memory, calls).finally(() => memory.close()); +} + +/** + * Re-enter the per-Mnemopi runtime-options scope when reaching into `memory.beam` + * directly (only `Mnemopi.remember`/`recall`/etc. enter it automatically). + */ +function inScope(memory: Mnemopi, fn: () => T): T { + return withMnemopiRuntimeOptions(memory.runtimeOptions, fn); +} + +function readEmbeddings(memory: Mnemopi): EmbeddingRow[] { + return memory.conn + .query("SELECT memory_id, embedding_json, model FROM memory_embeddings ORDER BY memory_id") + .all() as EmbeddingRow[]; +} + +describe("issue #1832 — embedding write/read coverage", () => { + it("remember() writes a row to memory_embeddings after flushExtractions()", async () => { + await withFakeMemory(async (memory, calls) => { + const memId = memory.remember("alpha facts about migration", { source: "test", importance: 0.5 }); + await memory.flushExtractions(); + + const rows = readEmbeddings(memory); + expect(rows).toHaveLength(1); + expect(rows[0]?.memory_id).toBe(memId); + // Body matches the alpha-bucket projection from the fake provider. + expect(JSON.parse(rows[0]?.embedding_json ?? "[]")).toEqual([1, 0, 0, 0]); + // Provider was actually invoked — not the silent no-op of the pre-fix world. + expect(calls()).toBeGreaterThanOrEqual(1); + }); + }); + + it("rememberBatch() writes one embedding row per item in a single provider call", async () => { + await withFakeMemory(async (memory, calls) => { + const ids = inScope(memory, () => + memory.beam.rememberBatch([ + { content: "alpha launch checklist" }, + { content: "beta migration plan" }, + { content: "gamma postmortem" }, + ]), + ); + await memory.flushExtractions(); + + const rows = readEmbeddings(memory); + expect(rows.map(row => row.memory_id).sort()).toEqual([...ids].sort()); + expect(calls()).toBe(1); + const byId = new Map(rows.map(row => [row.memory_id, JSON.parse(row.embedding_json) as number[]])); + expect(byId.get(ids[0] ?? "")).toEqual([1, 0, 0, 0]); + expect(byId.get(ids[1] ?? "")).toEqual([0, 1, 0, 0]); + expect(byId.get(ids[2] ?? "")).toEqual([0, 0, 1, 0]); + }); + }); + + it("recall() auto-derives queryEmbedding and surfaces a non-zero dense_score", async () => { + await withFakeMemory(async (memory, calls) => { + memory.remember("alpha launch checklist", { source: "test" }); + memory.remember("beta migration plan", { source: "test" }); + memory.remember("gamma postmortem", { source: "test" }); + await memory.flushExtractions(); + const callsAfterEmbedding = calls(); + + const results = await memory.recall("alpha", 3); + const alphaHit = results.find(row => row.content === "alpha launch checklist"); + + expect(alphaHit).toBeDefined(); + expect(typeof alphaHit?.dense_score).toBe("number"); + expect(alphaHit?.dense_score ?? 0).toBeGreaterThan(0); + // recall() must have invoked the provider for the query text (a single + // embedQuery for "alpha") — proving auto-derive ran. + expect(calls()).toBeGreaterThan(callsAfterEmbedding); + }); + }); + + it("recall() honours an explicit queryEmbedding: null (FTS-only) without auto-derive", async () => { + await withFakeMemory(async (memory, calls) => { + memory.remember("alpha launch checklist", { source: "test" }); + await memory.flushExtractions(); + const callsAfterEmbedding = calls(); + + const results = await memory.recall("alpha", 3, { queryEmbedding: null }); + expect(results.length).toBeGreaterThan(0); + // dense_score collapses to 0 when no query vector is computed. + expect(results[0]?.dense_score ?? 0).toBe(0); + // And the provider is never invoked for the query side. + expect(calls()).toBe(callsAfterEmbedding); + }); + }); + + it("updateWorking() re-embeds when content changes", async () => { + await withFakeMemory(async memory => { + const id = memory.remember("alpha facts about migration", { source: "test" }); + await memory.flushExtractions(); + + expect(memory.update(id, "gamma postmortem")).toBe(true); + await memory.flushExtractions(); + + const rows = readEmbeddings(memory); + expect(rows).toHaveLength(1); + // New content lands in the gamma bucket, replacing the alpha projection. + expect(JSON.parse(rows[0]?.embedding_json ?? "[]")).toEqual([0, 0, 1, 0]); + }); + }); + + it("consolidateToEpisodic() writes an embedding for the new episodic id", async () => { + await withFakeMemory(async memory => { + const wmId = memory.remember("alpha launch checklist", { source: "test" }); + await memory.flushExtractions(); + + const episodicId = inScope(memory, () => + memory.beam.consolidateToEpisodic("gamma postmortem summary", [wmId]), + ); + await memory.flushExtractions(); + + const rows = readEmbeddings(memory); + const episodicRow = rows.find(row => row.memory_id === episodicId); + expect(episodicRow).toBeDefined(); + expect(JSON.parse(episodicRow?.embedding_json ?? "[]")).toEqual([0, 0, 1, 0]); + }); + }); +}); diff --git a/packages/mnemopi/test/mcp-server.test.ts b/packages/mnemopi/test/mcp-server.test.ts index a96a8e16e..d9c546628 100644 --- a/packages/mnemopi/test/mcp-server.test.ts +++ b/packages/mnemopi/test/mcp-server.test.ts @@ -88,8 +88,8 @@ describe("MCP tool definitions", () => { }); describe("MCP JSON handlers", () => { - it("lists tools through JSON-RPC", () => { - const response = handleJsonRpc({ jsonrpc: "2.0", id: 1, method: "tools/list" }); + it("lists tools through JSON-RPC", async () => { + const response = await handleJsonRpc({ jsonrpc: "2.0", id: 1, method: "tools/list" }); if (response === null) throw new Error("expected tools/list response"); expect(response.error).toBeUndefined(); expect((response.result as { tools: unknown[] }).tools).toHaveLength(23); @@ -103,8 +103,8 @@ describe("MCP JSON handlers", () => { method: "tools/list", })}\n`, ); - expect(handleJsonRpc({ jsonrpc: "2.0", method: "tools/list" })).toBeNull(); - expect(handleJsonRpc({ jsonrpc: "2.0", method: "notifications/initialized" })).toBeNull(); + expect(await handleJsonRpc({ jsonrpc: "2.0", method: "tools/list" })).toBeNull(); + expect(await handleJsonRpc({ jsonrpc: "2.0", method: "notifications/initialized" })).toBeNull(); expect(responses).toHaveLength(1); const response = responses[0] as { id?: unknown; result?: { tools?: unknown[] } }; expect(response.id).toBe(7); @@ -124,8 +124,8 @@ describe("MCP JSON handlers", () => { expect(validResponse.result?.tools).toHaveLength(23); }); - it("wraps tool results in MCP text content", () => { - const response = callToolJson("mnemopi_stats", { bank: "server" }); + it("wraps tool results in MCP text content", async () => { + const response = await callToolJson("mnemopi_stats", { bank: "server" }); expect(response.isError).toBeUndefined(); const payload = JSON.parse(response.content[0]?.text ?? "{}") as { status: string; @@ -135,8 +135,8 @@ describe("MCP JSON handlers", () => { expect(payload.bank).toBe("server"); }); - it("dispatches remember, recall, stats, sleep, scratchpad, and bank operations", () => { - const remembered = handleToolCall("mnemopi_remember", { + it("dispatches remember, recall, stats, sleep, scratchpad, and bank operations", async () => { + const remembered = await handleToolCall("mnemopi_remember", { content: "MCP server test remembers kombucha preference", importance: 0.8, bank: "work", @@ -145,7 +145,7 @@ describe("MCP JSON handlers", () => { expect(remembered.bank).toBe("work"); expect(typeof remembered.memory_id).toBe("string"); - const recalled = handleToolCall("mnemopi_recall", { + const recalled = await handleToolCall("mnemopi_recall", { query: "kombucha preference", top_k: 3, bank: "work", @@ -154,49 +154,49 @@ describe("MCP JSON handlers", () => { expect(recalled.bank).toBe("work"); expect(recalled.count as number).toBeGreaterThanOrEqual(1); - const scratchWrite = handleToolCall("mnemopi_scratchpad_write", { + const scratchWrite = await handleToolCall("mnemopi_scratchpad_write", { content: "scratch note", bank: "work", }); expect(scratchWrite.status).toBe("written"); expect(scratchWrite.bank).toBe("work"); - const scratchRead = handleToolCall("mnemopi_scratchpad_read", { bank: "work" }); + const scratchRead = await handleToolCall("mnemopi_scratchpad_read", { bank: "work" }); expect(scratchRead.entries_count as number).toBeGreaterThanOrEqual(1); - const stats = handleToolCall("mnemopi_stats", { bank: "work" }); + const stats = await handleToolCall("mnemopi_stats", { bank: "work" }); expect(stats.status).toBe("ok"); expect(stats.bank).toBe("work"); expect(stats.working).toBeDefined(); - const sleep = handleToolCall("mnemopi_sleep", { dry_run: true, bank: "work" }); + const sleep = await handleToolCall("mnemopi_sleep", { dry_run: true, bank: "work" }); expect(sleep.status).toBe("ok"); expect(sleep.dry_run).toBe(true); expect(sleep.bank).toBe("work"); }); - it("uses MNEMOPI_MCP_BANK when a call omits bank", () => { + it("uses MNEMOPI_MCP_BANK when a call omits bank", async () => { process.env.MNEMOPI_MCP_BANK = "env-bank"; - const remembered = handleToolCall("mnemopi_remember", { content: "env bank memory" }); + const remembered = await handleToolCall("mnemopi_remember", { content: "env bank memory" }); expect(remembered.bank).toBe("env-bank"); - const stats = handleToolCall("mnemopi_stats", {}); + const stats = await handleToolCall("mnemopi_stats", {}); expect(stats.bank).toBe("env-bank"); }); - it("routes bank paths through BankManager validation and canonical layout", () => { - const defaultStats = handleToolCall("mnemopi_diagnose", {}); + it("routes bank paths through BankManager validation and canonical layout", async () => { + const defaultStats = await handleToolCall("mnemopi_diagnose", {}); expect(defaultStats.db_path).toBe(join(dataDir, "mnemopi.db")); - const workStats = handleToolCall("mnemopi_diagnose", { bank: "work" }); + const workStats = await handleToolCall("mnemopi_diagnose", { bank: "work" }); expect(workStats.db_path).toBe(join(dataDir, "banks", "work", "mnemopi.db")); - expect(() => handleToolCall("mnemopi_diagnose", { bank: "../escape" })).toThrow(); + await expect(handleToolCall("mnemopi_diagnose", { bank: "../escape" })).rejects.toThrow(); }); - it("links graph edges and queries related memories through a real BeamMemory", () => { - const first = handleToolCall("mnemopi_remember", { + it("links graph edges and queries related memories through a real BeamMemory", async () => { + const first = await handleToolCall("mnemopi_remember", { content: "Graph source memory about Ada and deterministic tests", bank: "graph", }); - const second = handleToolCall("mnemopi_remember", { + const second = await handleToolCall("mnemopi_remember", { content: "Graph target memory about Ada and reliable tests", bank: "graph", }); @@ -204,7 +204,7 @@ describe("MCP JSON handlers", () => { const targetId = second.memory_id; if (typeof sourceId !== "string" || typeof targetId !== "string") throw new Error("expected memory ids"); - const link = handleToolCall("mnemopi_graph_link", { + const link = await handleToolCall("mnemopi_graph_link", { source_id: sourceId, target_id: targetId, relationship: "supports", @@ -214,7 +214,7 @@ describe("MCP JSON handlers", () => { expect(link.status).toBe("linked"); expect(link.bank).toBe("graph"); - const query = handleToolCall("mnemopi_graph_query", { + const query = await handleToolCall("mnemopi_graph_query", { seed_memory_id: sourceId, edge_type: "supports", min_weight: 0.7, diff --git a/packages/mnemopi/test/memory-facade.test.ts b/packages/mnemopi/test/memory-facade.test.ts index c490f5b83..71d303c76 100644 --- a/packages/mnemopi/test/memory-facade.test.ts +++ b/packages/mnemopi/test/memory-facade.test.ts @@ -55,7 +55,7 @@ afterEach(() => { }); describe("Mnemopi facade", () => { - it("wraps BeamMemory for instance remember, recall, get, update, forget, stats, and context", () => { + it("wraps BeamMemory for instance remember, recall, get, update, forget, stats, and context", async () => { const dbPath = join(tempRoot(), "mnemopi.db"); const memory = new Mnemopi({ dbPath, @@ -70,7 +70,7 @@ describe("Mnemopi facade", () => { metadata: { topic: "ui" }, }); - expect(memory.recall("dark", 5, { authorId: "abdias" })[0]).toMatchObject({ + expect((await memory.recall("dark", 5, { authorId: "abdias" }))[0]).toMatchObject({ id, author_id: "abdias", author_type: "human", @@ -137,7 +137,7 @@ describe("Mnemopi facade", () => { } }); - it("preserves legacy and Python-compatible aliases", () => { + it("preserves legacy and Python-compatible aliases", async () => { const memory = new Mnemopi({ dbPath: join(tempRoot(), "mnemopi.db"), session_id: "aliases", @@ -146,11 +146,11 @@ describe("Mnemopi facade", () => { const id = memory.addMemory("Alias memory", { source: "test" }); expect(memory.saveMemory("Saved alias")).toHaveLength(16); expect(memory.storeMemory("Stored alias")).toHaveLength(16); - expect(memory.search("alias").some(row => row.id === id)).toBe(true); - expect(memory.query("alias").some(row => row.id === id)).toBe(true); + expect((await memory.search("alias")).some(row => row.id === id)).toBe(true); + expect((await memory.query("alias")).some(row => row.id === id)).toBe(true); expect(memory.getContext(2).length).toBeGreaterThanOrEqual(1); expect(memory.getStats().beam).toBeDefined(); - expect(Array.isArray(memory.recallEnhanced("alias"))).toBe(true); + expect(Array.isArray(await memory.recallEnhanced("alias"))).toBe(true); const scratchId = memory.scratchpadWrite("scratch alias"); expect(scratchId).toHaveLength(16); expect(memory.scratchpadRead().map(row => (row as { content: string }).content)).toEqual(["scratch alias"]); @@ -163,16 +163,16 @@ describe("Mnemopi facade", () => { } }); - it("exposes module-level singleton functions and resets cleanly for tests", () => { + it("exposes module-level singleton functions and resets cleanly for tests", async () => { useTempDataDir(); const id = remember("Module-level memory", { importance: 0.8 }); - expect(recall("module", 5).some(row => row.id === id)).toBe(true); + expect((await recall("module", 5)).some(row => row.id === id)).toBe(true); expect(get(id)).toMatchObject({ content: "Module-level memory" }); expect(getContext(1)[0]).toMatchObject({ id }); expect(getStats()).toMatchObject({ total_memories: 1 }); expect(update(id, "Module-level memory updated", 0.9)).toBe(true); - expect(Array.isArray(recallEnhanced("updated", 5))).toBe(true); + expect(Array.isArray(await recallEnhanced("updated", 5))).toBe(true); const padId = scratchpadWrite("module scratch"); expect(padId).toHaveLength(16); expect(scratchpadRead().map(row => (row as { content: string }).content)).toEqual(["module scratch"]); @@ -185,7 +185,7 @@ describe("Mnemopi facade", () => { expect(getBank()).toBe("default"); }); - it("switches singleton banks and supports per-call bank selection", () => { + it("switches singleton banks and supports per-call bank selection", async () => { useTempDataDir(); setBank("work"); expect(getBank()).toBe("work"); @@ -193,8 +193,8 @@ describe("Mnemopi facade", () => { const personalId = remember("Personal bank memory", { bank: "personal" }); expect(getBank()).toBe("personal"); - expect(recall("personal", 5).map(row => row.id)).toContain(personalId); - expect(recall("work", 5, { bank: "work" }).map(row => row.id)).toContain(workId); + expect((await recall("personal", 5)).map(row => row.id)).toContain(personalId); + expect((await recall("work", 5, { bank: "work" })).map(row => row.id)).toContain(workId); expect(get(workId, "personal")).toBeNull(); expect(get(personalId, "personal")).toMatchObject({ content: "Personal bank memory" }); }); diff --git a/packages/mnemopi/test/orchestrator.test.ts b/packages/mnemopi/test/orchestrator.test.ts index 7ab101fc4..de17295cd 100644 --- a/packages/mnemopi/test/orchestrator.test.ts +++ b/packages/mnemopi/test/orchestrator.test.ts @@ -7,8 +7,8 @@ import { closeQuietly, openDatabase } from "../src/db"; interface FakeBeam extends BeamMemoryState { linearCalls: number; enhancedCalls: number; - recall: (query: string, topK?: number) => RecallResult[]; - recallEnhanced: (query: string, topK?: number) => RecallResult[]; + recall: (query: string, topK?: number) => Promise; + recallEnhanced: (query: string, topK?: number) => Promise; } function fakeBeam(): FakeBeam { @@ -39,11 +39,11 @@ function fakeBeam(): FakeBeam { }, linearCalls: 0, enhancedCalls: 0, - recall(query: string, topK = 20): RecallResult[] { + async recall(query: string, topK = 20): Promise { this.linearCalls += 1; return [{ id: "linear", content: `${query}:${topK}`, score: 1 }]; }, - recallEnhanced(query: string, topK = 20): RecallResult[] { + async recallEnhanced(query: string, topK = 20): Promise { this.enhancedCalls += 1; return [{ id: "enhanced", content: `${query}:${topK}`, score: 2 }]; }, @@ -69,11 +69,11 @@ afterEach(() => { }); describe("orchestrateRecall", () => { - it("delegates to the Beam linear recall surface when the polyphonic gate is off", () => { + it("delegates to the Beam linear recall surface when the polyphonic gate is off", async () => { const beam = fakeBeam(); try { process.env.MNEMOPI_POLYPHONIC_RECALL = "0"; - const results = orchestrateRecall(beam, "needle", 7); + const results = await orchestrateRecall(beam, "needle", 7); expect(results).toEqual([{ id: "linear", content: "needle:7", score: 1 }]); expect(beam.linearCalls).toBe(1); expect(beam.enhancedCalls).toBe(0); @@ -82,11 +82,11 @@ describe("orchestrateRecall", () => { } }); - it("delegates to enhanced recall when requested on the non-polyphonic path", () => { + it("delegates to enhanced recall when requested on the non-polyphonic path", async () => { const beam = fakeBeam(); try { delete process.env.MNEMOPI_POLYPHONIC_RECALL; - const results = orchestrateRecall(beam, "needle", 3, { enhanced: true }); + const results = await orchestrateRecall(beam, "needle", 3, { enhanced: true }); expect(results).toEqual([{ id: "enhanced", content: "needle:3", score: 2 }]); expect(beam.linearCalls).toBe(0); expect(beam.enhancedCalls).toBe(1); @@ -95,7 +95,7 @@ describe("orchestrateRecall", () => { } }); - it("uses polyphonic recall instead of fake Beam recall when the gate is on", () => { + it("uses polyphonic recall instead of fake Beam recall when the gate is on", async () => { const beam = fakeBeam(); try { const engine = new PolyphonicRecallEngine({ db: beam.db }); @@ -107,7 +107,7 @@ describe("orchestrateRecall", () => { ); beam.caches.polyphonicEngine = engine; process.env.MNEMOPI_POLYPHONIC_RECALL = "1"; - const results = orchestrateRecall(beam, "Alice", 5); + const results = await orchestrateRecall(beam, "Alice", 5); expect(beam.linearCalls).toBe(0); expect(beam.enhancedCalls).toBe(0); expect(results[0]?.id).toBe("m-poly"); @@ -117,11 +117,11 @@ describe("orchestrateRecall", () => { } }); - it("forceLinear bypasses the env gate for A/B callers", () => { + it("forceLinear bypasses the env gate for A/B callers", async () => { const beam = fakeBeam(); try { process.env.MNEMOPI_POLYPHONIC_RECALL = "1"; - const results = orchestrateRecall(beam, "needle", 2, { forceLinear: true }); + const results = await orchestrateRecall(beam, "needle", 2, { forceLinear: true }); expect(results[0]?.id).toBe("linear"); expect(beam.linearCalls).toBe(1); } finally { diff --git a/packages/mnemopi/test/pre-experiment-fidelity.test.ts b/packages/mnemopi/test/pre-experiment-fidelity.test.ts index a8cfcf258..6929e9bd7 100644 --- a/packages/mnemopi/test/pre-experiment-fidelity.test.ts +++ b/packages/mnemopi/test/pre-experiment-fidelity.test.ts @@ -18,7 +18,7 @@ afterEach(() => { }); describe("pre-experiment no-LLM fidelity", () => { - it("recalls deterministic FTS-only memories without extraction or embeddings", () => { + it("recalls deterministic FTS-only memories without extraction or embeddings", async () => { const beam = makeBeam(); beam.remember("The Nimbus launch checklist lives in the release binder.", { source: "fixture", @@ -33,7 +33,7 @@ describe("pre-experiment no-LLM fidelity", () => { extractEntities: false, }); - const results = beam.recall("Nimbus launch checklist", 2, { + const results = await beam.recall("Nimbus launch checklist", 2, { queryTime: "2026-05-30T12:00:00.000Z", }); @@ -43,7 +43,7 @@ describe("pre-experiment no-LLM fidelity", () => { expect(results[0]?.fts_score ?? 0).toBeGreaterThan(0); }); - it("does not let high importance override an exact FTS-only match when configured for lexical fidelity", () => { + it("does not let high importance override an exact FTS-only match when configured for lexical fidelity", async () => { const beam = makeBeam(); beam.remember("low priority: cedar backup target is vault-seven", { source: "fixture", @@ -54,7 +54,7 @@ describe("pre-experiment no-LLM fidelity", () => { importance: 1.0, }); - const results = beam.recall("cedar backup target vault-seven", 2, { + const results = await beam.recall("cedar backup target vault-seven", 2, { queryTime: "2026-05-30T12:00:00.000Z", }); diff --git a/packages/mnemopi/test/provider-all-15-tools-parity.test.ts b/packages/mnemopi/test/provider-all-15-tools-parity.test.ts index c3d30f7d2..2d80cc715 100644 --- a/packages/mnemopi/test/provider-all-15-tools-parity.test.ts +++ b/packages/mnemopi/test/provider-all-15-tools-parity.test.ts @@ -75,7 +75,7 @@ describe("provider all-tools parity", () => { expect(schemaFor("mnemopi_import").required).toContain("input_path"); }); - it("returns user-facing argument errors instead of mutating on missing arguments", () => { + it("returns user-facing argument errors instead of mutating on missing arguments", async () => { for (const [name, args, expected] of [ ["mnemopi_remember", {}, "content is required"], ["mnemopi_recall", {}, "query is required"], @@ -85,25 +85,25 @@ describe("provider all-tools parity", () => { ["mnemopi_export", {}, "output_path is required"], ["mnemopi_import", {}, "Either input_path (for file import) is required"], ] as const) { - const result = handleToolCall(name, args); + const result = await handleToolCall(name, args); expect(result.error).toBe(expected); } }); - it("exports provider data to a file and imports it into a fresh isolated bank", () => { - const remembered = handleToolCall("mnemopi_remember", { + it("exports provider data to a file and imports it into a fresh isolated bank", async () => { + const remembered = await handleToolCall("mnemopi_remember", { content: "source provider memory for import parity", importance: 0.7, bank: "source", }); expect(remembered.status).toBe("stored"); - handleToolCall("mnemopi_scratchpad_write", { + await handleToolCall("mnemopi_scratchpad_write", { content: "portable provider scratch", bank: "source", }); const exportPath = join(dataDir, "provider-export.json"); - const exported = handleToolCall("mnemopi_export", { + const exported = await handleToolCall("mnemopi_export", { output_path: exportPath, bank: "source", }); @@ -112,10 +112,10 @@ describe("provider all-tools parity", () => { const payload = JSON.parse(readFileSync(exportPath, "utf8")) as { working_memory?: unknown[] }; expect(payload.working_memory?.length).toBe(1); - const imported = handleToolCall("mnemopi_import", { input_path: exportPath, bank: "dest" }); + const imported = await handleToolCall("mnemopi_import", { input_path: exportPath, bank: "dest" }); expect(imported.status).toBe("imported"); expect(JSON.stringify(imported.stats)).toContain("inserted"); - const recalled = handleToolCall("mnemopi_recall", { + const recalled = await handleToolCall("mnemopi_recall", { query: "import parity", bank: "dest", limit: 5, @@ -123,23 +123,23 @@ describe("provider all-tools parity", () => { expect(recalled.count as number).toBeGreaterThanOrEqual(1); }); - it("diagnose, validate, graph, and shared handlers return structured provider results", () => { - const remembered = handleToolCall("mnemopi_remember", { + it("diagnose, validate, graph, and shared handlers return structured provider results", async () => { + const remembered = await handleToolCall("mnemopi_remember", { content: "validate me through provider parity", bank: "ops", }); const memoryId = remembered.memory_id as string; - const validate = handleToolCall("mnemopi_validate", { + const validate = await handleToolCall("mnemopi_validate", { memory_id: memoryId, action: "attest", validator: "test", bank: "ops", }); expect(validate.status).toBe("validation_attest"); - const diagnose = handleToolCall("mnemopi_diagnose", { bank: "ops" }); + const diagnose = await handleToolCall("mnemopi_diagnose", { bank: "ops" }); expect(diagnose.status).toBe("ok"); expect(diagnose.db_path).toContain("banks/ops/mnemopi.db"); - const graphQuery = handleToolCall("mnemopi_graph_query", { seed_memory_id: memoryId, bank: "ops" }); + const graphQuery = await handleToolCall("mnemopi_graph_query", { seed_memory_id: memoryId, bank: "ops" }); expect(graphQuery).toMatchObject({ status: "ok", seed_memory_id: memoryId, @@ -150,7 +150,7 @@ describe("provider all-tools parity", () => { bank: "ops", }); expect( - handleToolCall("mnemopi_graph_link", { + await handleToolCall("mnemopi_graph_link", { source_id: memoryId, target_id: "other", relationship: "related", @@ -166,11 +166,11 @@ describe("provider all-tools parity", () => { bank: "ops", }); - const shared = handleToolCall("mnemopi_shared_remember", { + const shared = await handleToolCall("mnemopi_shared_remember", { content: "Prefer concise parity notes", kind: "preference", }); expect(shared.status).toBe("stored_shared"); - expect(handleToolCall("mnemopi_shared_forget", { memory_id: shared.memory_id }).status).toBe("deleted"); + expect((await handleToolCall("mnemopi_shared_forget", { memory_id: shared.memory_id })).status).toBe("deleted"); }); }); diff --git a/packages/mnemopi/test/provider-all-15-tools.test.ts b/packages/mnemopi/test/provider-all-15-tools.test.ts index de9551de5..4a5fe2107 100644 --- a/packages/mnemopi/test/provider-all-15-tools.test.ts +++ b/packages/mnemopi/test/provider-all-15-tools.test.ts @@ -57,14 +57,14 @@ describe("all provider-compatible MCP tools", () => { } }); - it("rejects unknown tools", () => { - expect(() => handleToolCall("mnemopi_nonexistent", {})).toThrow("Unknown tool"); + it("rejects unknown tools", async () => { + await expect(handleToolCall("mnemopi_nonexistent", {})).rejects.toThrow("Unknown tool"); }); }); describe("representative provider-compatible handlers", () => { - it("stores, recalls, reads stats, updates, gets, invalidates, and forgets", () => { - const remembered = handleToolCall("mnemopi_remember", { + it("stores, recalls, reads stats, updates, gets, invalidates, and forgets", async () => { + const remembered = await handleToolCall("mnemopi_remember", { content: "Provider handler stores durable espresso preference", importance: 0.7, bank: "provider", @@ -73,7 +73,7 @@ describe("representative provider-compatible handlers", () => { expect(remembered.status).toBe("stored"); expect(memoryId).toHaveLength(16); - const recalled = handleToolCall("mnemopi_recall", { + const recalled = await handleToolCall("mnemopi_recall", { query: "espresso preference", limit: 5, bank: "provider", @@ -81,78 +81,78 @@ describe("representative provider-compatible handlers", () => { expect(recalled.status).toBe("ok"); expect(recalled.count as number).toBeGreaterThanOrEqual(1); - const updated = handleToolCall("mnemopi_update", { + const updated = await handleToolCall("mnemopi_update", { memory_id: memoryId, content: "Provider handler stores durable tea preference", bank: "provider", }); expect(updated.status).toBe("updated"); - const got = handleToolCall("mnemopi_get", { memory_id: memoryId, bank: "provider" }); + const got = await handleToolCall("mnemopi_get", { memory_id: memoryId, bank: "provider" }); expect(got.status).toBe("ok"); expect(JSON.stringify(got.memory)).toContain("tea preference"); - const stats = handleToolCall("mnemopi_stats", { bank: "provider" }); + const stats = await handleToolCall("mnemopi_stats", { bank: "provider" }); expect(stats.status).toBe("ok"); expect(stats.working).toBeDefined(); - const invalidated = handleToolCall("mnemopi_invalidate", { + const invalidated = await handleToolCall("mnemopi_invalidate", { memory_id: memoryId, bank: "provider", }); expect(invalidated.status).toBe("invalidated"); - const forgotten = handleToolCall("mnemopi_forget", { memory_id: memoryId, bank: "provider" }); + const forgotten = await handleToolCall("mnemopi_forget", { memory_id: memoryId, bank: "provider" }); expect(forgotten.status).toBe("deleted"); }); - it("handles sleep and scratchpad operations", () => { - const write = handleToolCall("mnemopi_scratchpad_write", { + it("handles sleep and scratchpad operations", async () => { + const write = await handleToolCall("mnemopi_scratchpad_write", { content: "provider scratch", bank: "provider", }); expect(write.status).toBe("written"); - const read = handleToolCall("mnemopi_scratchpad_read", { bank: "provider" }); + const read = await handleToolCall("mnemopi_scratchpad_read", { bank: "provider" }); expect(read.entries_count as number).toBe(1); - const clear = handleToolCall("mnemopi_scratchpad_clear", { bank: "provider" }); + const clear = await handleToolCall("mnemopi_scratchpad_clear", { bank: "provider" }); expect(clear.status).toBe("cleared"); - const sleep = handleToolCall("mnemopi_sleep", { dry_run: true, bank: "provider" }); + const sleep = await handleToolCall("mnemopi_sleep", { dry_run: true, bank: "provider" }); expect(sleep.status).toBe("ok"); expect(sleep.dry_run).toBe(true); }); - it("handles bank-isolated operations", () => { - handleToolCall("mnemopi_remember", { + it("handles bank-isolated operations", async () => { + await handleToolCall("mnemopi_remember", { content: "only alpha bank contains apricot", bank: "alpha", }); - const alpha = handleToolCall("mnemopi_recall", { query: "apricot", bank: "alpha" }); - const beta = handleToolCall("mnemopi_recall", { query: "apricot", bank: "beta" }); + const alpha = await handleToolCall("mnemopi_recall", { query: "apricot", bank: "alpha" }); + const beta = await handleToolCall("mnemopi_recall", { query: "apricot", bank: "beta" }); expect(alpha.count as number).toBeGreaterThanOrEqual(1); expect(beta.count).toBe(0); }); - it("handles triple and shared-surface tools", () => { - const triple = handleToolCall("mnemopi_triple_add", { + it("handles triple and shared-surface tools", async () => { + const triple = await handleToolCall("mnemopi_triple_add", { subject: "user", predicate: "prefers", object: "oolong", bank: "provider", }); expect(triple.status).toBe("stored"); - const triples = handleToolCall("mnemopi_triple_query", { + const triples = await handleToolCall("mnemopi_triple_query", { subject: "user", predicate: "prefers", bank: "provider", }); expect(triples.results_count as number).toBeGreaterThanOrEqual(1); - const shared = handleToolCall("mnemopi_shared_remember", { + const shared = await handleToolCall("mnemopi_shared_remember", { content: "User prefers concise answers", kind: "preference", }); expect(shared.status).toBe("stored_shared"); - const sharedRecall = handleToolCall("mnemopi_shared_recall", { query: "concise answers" }); + const sharedRecall = await handleToolCall("mnemopi_shared_recall", { query: "concise answers" }); expect(sharedRecall.count as number).toBeGreaterThanOrEqual(1); - const sharedStats = handleToolCall("mnemopi_shared_stats", {}); + const sharedStats = await handleToolCall("mnemopi_shared_stats", {}); expect(sharedStats.provider).toBe("mnemopi_shared"); }); }); diff --git a/packages/mnemopi/test/recall-precision-regressions.test.ts b/packages/mnemopi/test/recall-precision-regressions.test.ts index 8c2bb3ea4..a027ae1cf 100644 --- a/packages/mnemopi/test/recall-precision-regressions.test.ts +++ b/packages/mnemopi/test/recall-precision-regressions.test.ts @@ -33,21 +33,21 @@ function seedPrecisionFixture(beam: TestBeam): void { } } -function expectTopContains(beam: TestBeam, query: string, expected: string): void { - const results = beam.recall(query, 5, { queryTime: "2026-05-30T12:00:00.000Z" }); +async function expectTopContains(beam: TestBeam, query: string, expected: string): Promise { + const results = await beam.recall(query, 5, { queryTime: "2026-05-30T12:00:00.000Z" }); expect(results.length).toBeGreaterThan(0); expect(results[0]?.content.toLowerCase()).toContain(expected.toLowerCase()); } describe("recall precision regressions", () => { - it("prefers the artifact memory for a natural deployment question", () => { + it("prefers the artifact memory for a natural deployment question", async () => { const beam = makeBeam(); seedPrecisionFixture(beam); - expectTopContains(beam, "Where is the Orion runner jar and how should it bind?", "orion-runner-2026.4.jar"); + await expectTopContains(beam, "Where is the Orion runner jar and how should it bind?", "orion-runner-2026.4.jar"); }); - it("ranks the correct fact first for specific memory probes", () => { + it("ranks the correct fact first for specific memory probes", async () => { const beam = makeBeam(); seedPrecisionFixture(beam); @@ -57,11 +57,11 @@ describe("recall precision regressions", () => { ["What Hotel Meridian running route plan should be used?", "Central Park Loop"], ["What inference routing rule says avoid BudgetCloud?", "avoid BudgetCloud"], ] as const) { - expectTopContains(beam, query, expected); + await expectTopContains(beam, query, expected); } }); - it("abstains on nonsense and single-token overlap noise", () => { + it("abstains on nonsense and single-token overlap noise", async () => { const beam = makeBeam(); seedPrecisionFixture(beam); beam.remember("Quantum field theory research notes are stored in the physics archive.", { @@ -75,12 +75,12 @@ describe("recall precision regressions", () => { scope: "global", }); - expect(beam.recall("zxqvplm norf greeble snargle twompset", 5)).toEqual([]); - expect(beam.recall("purple bicycle quantum oatmeal unrelated", 5)).toEqual([]); - expect(beam.recall("customer invoices quantum", 5)).toEqual([]); + expect(await beam.recall("zxqvplm norf greeble snargle twompset", 5)).toEqual([]); + expect(await beam.recall("purple bicycle quantum oatmeal unrelated", 5)).toEqual([]); + expect(await beam.recall("customer invoices quantum", 5)).toEqual([]); }); - it("keeps separate aspects of a multi-fact query in top results", () => { + it("keeps separate aspects of a multi-fact query in top results", async () => { const beam = makeBeam(); seedPrecisionFixture(beam); beam.remember("Ava profile URL is https://example.test/ava for her professional page.", { @@ -100,10 +100,11 @@ describe("recall precision regressions", () => { ); } - const joined = beam - .recall("What is Ava profile URL and professional branding preference?", 5, { + const joined = ( + await beam.recall("What is Ava profile URL and professional branding preference?", 5, { queryTime: "2026-05-30T12:00:00.000Z", }) + ) .map(result => result.content.toLowerCase()) .join("\n"); @@ -111,7 +112,7 @@ describe("recall precision regressions", () => { expect(joined).toContain("grounded software builder"); }); - it("prefers a current correction over stale history", () => { + it("prefers a current correction over stale history", async () => { const beam = makeBeam(); const oldId = beam.remember( "Project Atlas deployment target was legacy-cluster and should use Model-Old for background work.", @@ -124,7 +125,7 @@ describe("recall precision regressions", () => { beam.db.prepare("UPDATE working_memory SET timestamp = ? WHERE id = ?").run("2025-01-01T00:00:00.000Z", oldId); beam.db.prepare("UPDATE working_memory SET timestamp = ? WHERE id = ?").run("2026-05-24T00:00:00.000Z", newId); - const results = beam.recall("What should Project Atlas deployment use now?", 3, { + const results = await beam.recall("What should Project Atlas deployment use now?", 3, { queryTime: "2026-05-30T12:00:00.000Z", }); diff --git a/packages/mnemopi/test/telemetry-env-followups.test.ts b/packages/mnemopi/test/telemetry-env-followups.test.ts index 7cff03c5a..0c3508fad 100644 --- a/packages/mnemopi/test/telemetry-env-followups.test.ts +++ b/packages/mnemopi/test/telemetry-env-followups.test.ts @@ -21,7 +21,7 @@ afterEach(() => { }); describe("telemetry and env follow-up parity", () => { - it("fallback episodic rows expose explicit zero dense_score and linear voice_scores", () => { + it("fallback episodic rows expose explicit zero dense_score and linear voice_scores", async () => { const beam = new BeamMemory({ sessionId: "s1", dbPath: tempDb() }); try { beam.db.run( @@ -36,7 +36,7 @@ describe("telemetry and env follow-up parity", () => { ], ); - const hit = beam.recall("zorblax", 10).find(row => row.id === "ep-no-emb"); + const hit = (await beam.recall("zorblax", 10)).find(row => row.id === "ep-no-emb"); expect(hit).toBeDefined(); expect(hit?.tier).toBe("episodic"); expect(hit?.dense_score).toBe(0); @@ -52,14 +52,14 @@ describe("telemetry and env follow-up parity", () => { } }); - it("main recall path preserves numeric dense_score and voice_scores on working memory", () => { + it("main recall path preserves numeric dense_score and voice_scores on working memory", async () => { const beam = new BeamMemory({ sessionId: "s1", dbPath: tempDb() }); try { const id = beam.remember("The user wants dark mode for the editor", { source: "conversation", importance: 0.8, }); - const hit = beam.recall("dark mode", 10).find(row => row.id === id); + const hit = (await beam.recall("dark mode", 10)).find(row => row.id === id); expect(hit).toBeDefined(); expect(typeof hit?.dense_score).toBe("number"); const scores = hit?.voice_scores; @@ -73,7 +73,7 @@ describe("telemetry and env follow-up parity", () => { } }); - it("all linear recall results have numeric voice score entries", () => { + it("all linear recall results have numeric voice score entries", async () => { const beam = new BeamMemory({ sessionId: "s1", dbPath: tempDb() }); try { beam.remember("The deployment plan is approved", { importance: 0.7 }); @@ -89,7 +89,7 @@ describe("telemetry and env follow-up parity", () => { ], ); - const results = beam.recall("deployment", 10); + const results = await beam.recall("deployment", 10); expect(results.length).toBeGreaterThan(0); for (const row of results) { expect(row.voice_scores).toBeDefined(); diff --git a/packages/mnemopi/test/temporal-recall.test.ts b/packages/mnemopi/test/temporal-recall.test.ts index ff369bae2..18f54e432 100644 --- a/packages/mnemopi/test/temporal-recall.test.ts +++ b/packages/mnemopi/test/temporal-recall.test.ts @@ -39,7 +39,7 @@ describe("temporal recall scoring", () => { expect(() => parseQueryTime(12345 as never)).toThrow(); }); - it("boosts recent memories over older matches when temporal scoring is enabled", () => { + it("boosts recent memories over older matches when temporal scoring is enabled", async () => { const beam = makeBeam(); beam.remember("Meeting about project alpha", { source: "test", importance: 0.5 }); beam.remember("Meeting about project beta", { source: "test", importance: 0.5 }); @@ -50,11 +50,11 @@ describe("temporal recall scoring", () => { .prepare("UPDATE working_memory SET timestamp = ? WHERE content LIKE ?") .run(iso("2026-05-30T10:00:00.000Z"), "%beta%"); - const noTemporal = beam.recall("meeting", 5, { + const noTemporal = await beam.recall("meeting", 5, { temporalWeight: 0, queryTime: "2026-05-30T12:00:00.000Z", }); - const temporal = beam.recall("meeting", 5, { + const temporal = await beam.recall("meeting", 5, { temporalWeight: 0.5, temporalHalflife: 24, queryTime: "2026-05-30T12:00:00.000Z", @@ -67,13 +67,13 @@ describe("temporal recall scoring", () => { expect(beta?.temporal_score ?? 0).toBeGreaterThan(alpha?.temporal_score ?? 0); }); - it("leaves ordering stable when temporal weight is zero", () => { + it("leaves ordering stable when temporal weight is zero", async () => { const beam = makeBeam(); beam.remember("Test content A", { source: "test", importance: 0.5 }); beam.remember("Test content B", { source: "test", importance: 0.5 }); - const implicit = beam.recall("test content", 5, { queryTime: "2026-05-30T12:00:00.000Z" }); - const explicit = beam.recall("test content", 5, { + const implicit = await beam.recall("test content", 5, { queryTime: "2026-05-30T12:00:00.000Z" }); + const explicit = await beam.recall("test content", 5, { temporalWeight: 0, queryTime: "2026-05-30T12:00:00.000Z", }); @@ -82,19 +82,19 @@ describe("temporal recall scoring", () => { expect(explicit.map(result => result.score)).toEqual(implicit.map(result => result.score)); }); - it("uses per-call temporal halflife overrides", () => { + it("uses per-call temporal halflife overrides", async () => { const beam = makeBeam(); beam.remember("Memory from two days ago", { source: "test", importance: 0.5 }); beam.db .prepare("UPDATE working_memory SET timestamp = ? WHERE content LIKE ?") .run("2026-05-28T12:00:00.000Z", "%two days ago%"); - const short = beam.recall("memory", 1, { + const short = await beam.recall("memory", 1, { temporalWeight: 0.5, temporalHalflife: 6, queryTime: "2026-05-30T12:00:00.000Z", }); - const long = beam.recall("memory", 1, { + const long = await beam.recall("memory", 1, { temporalWeight: 0.5, temporalHalflife: 168, queryTime: "2026-05-30T12:00:00.000Z", @@ -103,7 +103,7 @@ describe("temporal recall scoring", () => { expect(long[0]?.score ?? 0).toBeGreaterThan(short[0]?.score ?? 0); }); - it("infers temporal query targets from natural language", () => { + it("infers temporal query targets from natural language", async () => { const beam = makeBeam(); beam.db .prepare( @@ -128,7 +128,7 @@ describe("temporal recall scoring", () => { "2026-05-29", ); - const results = beam.recall("incident alpha on 2026-05-29", 2, { + const results = await beam.recall("incident alpha on 2026-05-29", 2, { includeWorking: false, temporalHalflife: 12, });