From b98aea50430708094fb05dd99aeda3a372016158 Mon Sep 17 00:00:00 2001 From: DarkPhilosophy <19309990+DarkPhilosophy@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:12:44 +0300 Subject: [PATCH] fix(memory): tighten runtime and backend coverage --- .../src/memory-backend/runtime.ts | 10 +-- .../coding-agent/src/memory-backend/types.ts | 4 +- packages/coding-agent/src/mnemopi/backend.ts | 14 +++- .../coding-agent/test/memory-tools.test.ts | 78 +++++++++++++++++++ packages/mnemopi/CHANGELOG.md | 2 +- packages/mnemopi/src/core/vector-index.ts | 3 + 6 files changed, 99 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/src/memory-backend/runtime.ts b/packages/coding-agent/src/memory-backend/runtime.ts index f4f132f23..3f9ab1757 100644 --- a/packages/coding-agent/src/memory-backend/runtime.ts +++ b/packages/coding-agent/src/memory-backend/runtime.ts @@ -1,12 +1,12 @@ import type { AgentSession } from "../session/agent-session"; import { resolveMemoryBackend } from "./resolve"; import type { + MemoryBackendId, MemoryBackendOperationContext, MemoryBackendSaveInput, MemoryBackendSearchOptions, MemoryRuntimeContext, } from "./types"; - export function createMemoryRuntimeContext(context: MemoryBackendOperationContext): MemoryRuntimeContext { const settings = context.session?.settings; return { @@ -57,10 +57,10 @@ export function createSessionMemoryRuntimeContext( return createMemoryRuntimeContext({ agentDir, cwd, session }); } -function unavailableSearch(backend: string, query: string, message: string) { - return { backend: backend as never, query, count: 0, items: [], message }; +function unavailableSearch(backend: MemoryBackendId, query: string, message: string) { + return { backend, query, count: 0, items: [], message }; } -function unavailableSave(backend: string, message: string) { - return { backend: backend as never, stored: 0, message }; +function unavailableSave(backend: MemoryBackendId, message: string) { + return { backend, stored: 0, message }; } diff --git a/packages/coding-agent/src/memory-backend/types.ts b/packages/coding-agent/src/memory-backend/types.ts index a7dcb1cb3..a8b722e9b 100644 --- a/packages/coding-agent/src/memory-backend/types.ts +++ b/packages/coding-agent/src/memory-backend/types.ts @@ -1,7 +1,7 @@ /** * Memory backend abstraction. * - * Backends are mutually exclusive — `resolveMemoryBackend(settings)` returns + * Backends are mutually exclusive — `await resolveMemoryBackend(settings)` returns * exactly one. Implementations MUST be self-contained: they own the per-session * state they create in `start()` and tear it down on `clear()`. */ @@ -35,13 +35,13 @@ export interface MemoryBackendStatus { export interface MemoryBackendSearchOptions { limit?: number; + /** Best-effort abort signal. Backends may only observe it before/after an underlying recall call. */ signal?: AbortSignal; } export interface MemoryBackendSearchItem { id?: string; content: string; - bank?: string; source?: string; timestamp?: string; score?: number; diff --git a/packages/coding-agent/src/mnemopi/backend.ts b/packages/coding-agent/src/mnemopi/backend.ts index a5c4b6f90..8c1976e78 100644 --- a/packages/coding-agent/src/mnemopi/backend.ts +++ b/packages/coding-agent/src/mnemopi/backend.ts @@ -206,12 +206,15 @@ export const mnemopiBackend: MemoryBackend = { } const limit = clampLimit(options?.limit); const results = (await primary.recallResultsScoped(query)).slice(0, limit); + if (options?.signal?.aborted) { + return { backend: "mnemopi", query, count: 0, items: [], message: "Search aborted." }; + } const items: MemoryBackendSearchItem[] = results.map(result => ({ id: result.id, content: result.content, source: result.source ?? undefined, timestamp: result.timestamp ?? undefined, - score: result.score ?? result.importance, + score: result.score, })); return { backend: "mnemopi", query, count: items.length, items }; }, @@ -474,8 +477,8 @@ async function resolveMnemopiProviderOptions( return { ...base, llm: async (prompt, opts) => { - const apiKey = await modelRegistry.getApiKey(model, sessionId); - if (!apiKey) { + const hasApiKey = await modelRegistry.getApiKey(model, sessionId); + if (!hasApiKey) { logger.warn("Mnemopi: smol completion requested but no current API key is available.", { provider: model.provider, model: model.id, @@ -488,7 +491,10 @@ async function resolveMnemopiProviderOptions( messages: [{ role: "user", content: prompt, timestamp: Date.now() }], }, { - apiKey, + apiKey: modelRegistry.resolver(model.provider, { + sessionId, + baseUrl: model.baseUrl, + }), maxTokens: opts?.maxTokens, temperature: opts?.temperature, }, diff --git a/packages/coding-agent/test/memory-tools.test.ts b/packages/coding-agent/test/memory-tools.test.ts index d67fe1952..d17523fc7 100644 --- a/packages/coding-agent/test/memory-tools.test.ts +++ b/packages/coding-agent/test/memory-tools.test.ts @@ -525,6 +525,84 @@ describe("Mnemopi backend lifecycle", () => { registeredMnemopiState = undefined; }); + it("exposes direct mnemopi runtime status and search/save results", async () => { + const config = makeMnemopiConfig({ + scoping: "per-project-tagged", + bank: "project-alpha", + globalBank: "default", + retainBank: "project-alpha", + recallBanks: ["project-alpha", "default"], + }); + const state = registerMnemopiState(config, { cwd: "/work/project-alpha" }); + const session = state.session; + setMnemopiSessionState(session, state); + + const save = await mnemopiBackend.save!( + { agentDir: path.dirname(config.dbPath), cwd: "/work/project-alpha", session }, + { + content: "the user prefers dark mode in their editor", + source: "test-source", + context: "editor preferences", + importance: 0.8, + }, + ); + expect(save).toMatchObject({ backend: "mnemopi", stored: 1, ids: [expect.any(String)] }); + + const status = await mnemopiBackend.status!({ + agentDir: path.dirname(config.dbPath), + cwd: "/work/project-alpha", + session, + }); + expect(status).toMatchObject({ + backend: "mnemopi", + active: true, + writable: true, + searchable: true, + retainBank: "project-alpha", + }); + expect(status.recallBanks).toEqual(expect.arrayContaining(["project-alpha", "default"])); + + const search = await mnemopiBackend.search!( + { agentDir: path.dirname(config.dbPath), cwd: "/work/project-alpha", session }, + "dark mode", + ); + expect(search.backend).toBe("mnemopi"); + expect(search.count).toBeGreaterThan(0); + expect(search.items[0]).toMatchObject({ + content: expect.stringContaining("dark mode"), + source: "test-source", + score: expect.any(Number), + }); + }); + + it("reports aborted searches and save-without-id failures", async () => { + const state = registerMnemopiState(); + const session = state.session; + setMnemopiSessionState(session, state); + + const controller = new AbortController(); + controller.abort(); + await expect( + mnemopiBackend.search!({ agentDir: "/tmp/agent", cwd: "/tmp", session }, "anything", { + signal: controller.signal, + }), + ).resolves.toMatchObject({ + backend: "mnemopi", + count: 0, + message: "Search aborted.", + }); + + const rememberSpy = vi.spyOn(state, "rememberScoped").mockReturnValue(undefined); + await expect( + mnemopiBackend.save!({ agentDir: "/tmp/agent", cwd: "/tmp", session }, { content: "memory without id" }), + ).resolves.toMatchObject({ + backend: "mnemopi", + stored: 0, + message: "Mnemopi did not return a stored memory id.", + }); + rememberSpy.mockRestore(); + }); + it("derives valid project banks from the absolute project root", async () => { const root = path.join(tmpdir(), `mnemopi-bank-${Date.now()}`); const alphaCwd = path.join(root, "a", "api"); diff --git a/packages/mnemopi/CHANGELOG.md b/packages/mnemopi/CHANGELOG.md index 51a7e3ce1..90a062ac6 100644 --- a/packages/mnemopi/CHANGELOG.md +++ b/packages/mnemopi/CHANGELOG.md @@ -17,7 +17,7 @@ ### Changed -- Reworked the in-memory fallback vector search to build a normalized exact vector index per query, reducing repeated cosine-normalization work and matching the shape needed for future quantized index backends. +- Reworked the in-memory fallback vector search to build a normalized exact vector index per query, matching the shape needed for future quantized or TurboVec-style backends without adding a new dependency yet. ## [15.9.1] - 2026-06-04 diff --git a/packages/mnemopi/src/core/vector-index.ts b/packages/mnemopi/src/core/vector-index.ts index fcfaade8c..3092cba52 100644 --- a/packages/mnemopi/src/core/vector-index.ts +++ b/packages/mnemopi/src/core/vector-index.ts @@ -35,6 +35,9 @@ export function buildExactVectorIndex(rows: readonly VectorIndexRow[]) if (vector.length > dimensions) dimensions = vector.length; } + // Float32Array keeps a compact contiguous matrix that matches the shape we'd + // feed into future ANN/quantized backends. Exact cosine ranking remains sound + // here because we store normalized vectors and only compare normalized dots. const matrix = new Float32Array(valid.length * dimensions); const ids: TId[] = []; for (let row = 0; row < valid.length; row += 1) {