diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ee70e90fe..bff20e25e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -227,6 +227,10 @@ - Removed the special Anthropic `claude-opus-4-8` tool-call batch cap; sessions no longer abort an in-flight provider stream after a fixed number of completed tool calls. +### Added + +- Added a structured memory runtime surface for extensions and UI integrations to query backend status, search memories, and save explicit memories across the configured memory backend. + ## [15.10.4] - 2026-06-08 ### Added diff --git a/packages/coding-agent/src/memory-backend/index.ts b/packages/coding-agent/src/memory-backend/index.ts index 62f504ee4..6c4a75570 100644 --- a/packages/coding-agent/src/memory-backend/index.ts +++ b/packages/coding-agent/src/memory-backend/index.ts @@ -14,4 +14,5 @@ export type { export * from "./local-backend"; export * from "./off-backend"; export * from "./resolve"; +export * from "./runtime"; export * from "./types"; diff --git a/packages/coding-agent/src/memory-backend/local-backend.ts b/packages/coding-agent/src/memory-backend/local-backend.ts index 5a76a341e..e36c7145d 100644 --- a/packages/coding-agent/src/memory-backend/local-backend.ts +++ b/packages/coding-agent/src/memory-backend/local-backend.ts @@ -27,4 +27,13 @@ export const localBackend: MemoryBackend = { async enqueue(agentDir, cwd) { enqueueMemoryConsolidation(agentDir, cwd); }, + async status() { + return { + backend: "local" as const, + active: true, + writable: false, + searchable: false, + message: "Local rollout-summary memory is active; structured search/save is not available.", + }; + }, }; diff --git a/packages/coding-agent/src/memory-backend/off-backend.ts b/packages/coding-agent/src/memory-backend/off-backend.ts index 28eb14053..f354947d9 100644 --- a/packages/coding-agent/src/memory-backend/off-backend.ts +++ b/packages/coding-agent/src/memory-backend/off-backend.ts @@ -13,4 +13,13 @@ export const offBackend: MemoryBackend = { }, async clear() {}, async enqueue() {}, + async status() { + return { + backend: "off" as const, + active: false, + writable: false, + searchable: false, + message: "Memory backend is off.", + }; + }, }; diff --git a/packages/coding-agent/src/memory-backend/runtime.ts b/packages/coding-agent/src/memory-backend/runtime.ts new file mode 100644 index 000000000..f4f132f23 --- /dev/null +++ b/packages/coding-agent/src/memory-backend/runtime.ts @@ -0,0 +1,66 @@ +import type { AgentSession } from "../session/agent-session"; +import { resolveMemoryBackend } from "./resolve"; +import type { + MemoryBackendOperationContext, + MemoryBackendSaveInput, + MemoryBackendSearchOptions, + MemoryRuntimeContext, +} from "./types"; + +export function createMemoryRuntimeContext(context: MemoryBackendOperationContext): MemoryRuntimeContext { + const settings = context.session?.settings; + return { + async status() { + if (!settings) { + return { + backend: "off" as const, + active: false, + writable: false, + searchable: false, + message: "No active agent session.", + }; + } + const backend = await resolveMemoryBackend(settings); + return backend.status + ? await backend.status(context) + : { + backend: backend.id, + active: backend.id !== "off", + writable: false, + searchable: false, + message: "This memory backend does not expose structured status.", + }; + }, + async search(query: string, options?: MemoryBackendSearchOptions) { + if (!settings) return unavailableSearch("off", query, "No active agent session."); + const backend = await resolveMemoryBackend(settings); + return backend.search + ? await backend.search(context, query, options) + : unavailableSearch(backend.id, query, `Memory search is not available for the ${backend.id} backend.`); + }, + async save(input: string | MemoryBackendSaveInput) { + if (!settings) return unavailableSave("off", "No active agent session."); + const backend = await resolveMemoryBackend(settings); + const normalized = typeof input === "string" ? { content: input } : input; + return backend.save + ? await backend.save(context, normalized) + : unavailableSave(backend.id, `Memory save is not available for the ${backend.id} backend.`); + }, + }; +} + +export function createSessionMemoryRuntimeContext( + session: AgentSession, + agentDir: string, + cwd: string, +): MemoryRuntimeContext { + return createMemoryRuntimeContext({ agentDir, cwd, session }); +} + +function unavailableSearch(backend: string, query: string, message: string) { + return { backend: backend as never, query, count: 0, items: [], message }; +} + +function unavailableSave(backend: string, message: string) { + return { backend: backend as never, stored: 0, message }; +} diff --git a/packages/coding-agent/src/memory-backend/types.ts b/packages/coding-agent/src/memory-backend/types.ts index 8b2e5cd15..a7dcb1cb3 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 — `await resolveMemoryBackend(settings)` resolves + * Backends are mutually exclusive — `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()`. */ @@ -15,6 +15,73 @@ import type { AgentSession } from "../session/agent-session"; export type MemoryBackendId = "off" | "local" | "hindsight" | "mnemopi"; +export interface MemoryBackendStatus { + backend: MemoryBackendId; + active: boolean; + writable: boolean; + searchable: boolean; + scope?: string; + retainBank?: string; + recallBanks?: string[]; + workingCount?: number; + episodicCount?: number; + tripleCount?: number; + lastMemory?: string; + lastRecall?: boolean; + database?: string; + message?: string; + error?: string; +} + +export interface MemoryBackendSearchOptions { + limit?: number; + signal?: AbortSignal; +} + +export interface MemoryBackendSearchItem { + id?: string; + content: string; + bank?: string; + source?: string; + timestamp?: string; + score?: number; +} + +export interface MemoryBackendSearchResult { + backend: MemoryBackendId; + query: string; + count: number; + items: MemoryBackendSearchItem[]; + message?: string; +} + +export interface MemoryBackendSaveInput { + content: string; + context?: string; + source?: string; + importance?: number; +} + +export interface MemoryBackendSaveResult { + backend: MemoryBackendId; + stored: number; + ids?: string[]; + queued?: boolean; + message?: string; +} + +export interface MemoryBackendOperationContext { + agentDir: string; + cwd: string; + session?: AgentSession; +} + +export interface MemoryRuntimeContext { + status(): Promise; + search(query: string, options?: MemoryBackendSearchOptions): Promise; + save(input: string | MemoryBackendSaveInput): Promise; +} + export interface MemoryBackendStartOptions { session: AgentSession; settings: Settings; @@ -53,6 +120,19 @@ export interface MemoryBackend { /** Force consolidation/retain to happen now (slash `/memory enqueue`). */ enqueue(agentDir: string, cwd: string, session?: AgentSession): Promise; + /** Structured state for UI, slash commands, and extensions. */ + status?(context: MemoryBackendOperationContext): Promise; + + /** Explicit user-facing semantic/lexical search. */ + search?( + context: MemoryBackendOperationContext, + query: string, + options?: MemoryBackendSearchOptions, + ): Promise; + + /** Explicit user-facing save operation. */ + save?(context: MemoryBackendOperationContext, input: MemoryBackendSaveInput): Promise; + /** Render backend-specific memory statistics as markdown (`/memory stats`). */ stats?(agentDir: string, cwd: string, session?: AgentSession): Promise; diff --git a/packages/coding-agent/src/mnemopi/backend.ts b/packages/coding-agent/src/mnemopi/backend.ts index 061a93a44..a5c4b6f90 100644 --- a/packages/coding-agent/src/mnemopi/backend.ts +++ b/packages/coding-agent/src/mnemopi/backend.ts @@ -5,10 +5,15 @@ import type { Mnemopi } from "@oh-my-pi/pi-mnemopi"; import type * as MnemopiDiagnoseNs from "@oh-my-pi/pi-mnemopi/diagnose"; import type { DiagnosticSummary } from "@oh-my-pi/pi-mnemopi/diagnose"; import { logger } from "@oh-my-pi/pi-utils"; - import type { ModelRegistry } from "../config/model-registry"; import { resolveRoleSelection } from "../config/model-resolver"; -import type { MemoryBackend, MemoryBackendStartOptions } from "../memory-backend/types"; +import type { + MemoryBackend, + MemoryBackendSaveInput, + MemoryBackendSearchItem, + MemoryBackendStartOptions, + MemoryBackendStatus, +} from "../memory-backend/types"; import memoryConsolidationPrompt from "../prompts/system/memory-consolidation-system.md" with { type: "text" }; import memoryExtractionPrompt from "../prompts/system/memory-extraction-system.md" with { type: "text" }; import type { AgentSession } from "../session/agent-session"; @@ -166,6 +171,86 @@ export const mnemopiBackend: MemoryBackend = { return renderMnemopiDiagnostics(summaries); }, + async status({ agentDir, session }): Promise { + const { targets, owned } = createStatsTargets(agentDir, session); + try { + if (targets.length === 0) { + return { + backend: "mnemopi", + active: false, + writable: false, + searchable: false, + message: "Mnemopi backend is configured but not initialised for this session.", + }; + } + return summarizeMnemopiStatus(targets, session); + } finally { + for (const memory of owned) memory.close(); + } + }, + + async search({ session }, query, options) { + const state = getMnemopiSessionState(session); + const primary = state?.aliasOf ?? state; + if (!primary) { + return { + backend: "mnemopi", + query, + count: 0, + items: [], + message: "Mnemopi backend is not initialised for this session.", + }; + } + if (options?.signal?.aborted) { + return { backend: "mnemopi", query, count: 0, items: [], message: "Search aborted." }; + } + const limit = clampLimit(options?.limit); + const results = (await primary.recallResultsScoped(query)).slice(0, limit); + 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, + })); + return { backend: "mnemopi", query, count: items.length, items }; + }, + + async save({ cwd, session }, input: MemoryBackendSaveInput) { + const state = getMnemopiSessionState(session); + const primary = state?.aliasOf ?? state; + if (!primary) { + return { + backend: "mnemopi", + stored: 0, + message: "Mnemopi backend is not initialised for this session.", + }; + } + const content = input.content.trim(); + if (!content) return { backend: "mnemopi", stored: 0, message: "Memory content is empty." }; + const id = primary.rememberScoped(content, { + source: input.source || "coding-agent-memory-command", + importance: normalizeImportance(input.importance), + metadata: { + session_id: primary.sessionId, + cwd, + context: input.context ?? null, + operation: "memory.save", + }, + scope: "bank", + extract: true, + extractEntities: true, + veracity: "user", + memoryType: "fact", + }); + return { + backend: "mnemopi", + stored: id ? 1 : 0, + ids: id ? [id] : [], + message: id ? undefined : "Mnemopi did not return a stored memory id.", + }; + }, + async preCompactionContext(messages, _settings, session): Promise { const state = getMnemopiSessionState(session); return await state?.recallForCompaction(messages); @@ -247,6 +332,52 @@ function renderMnemopiStats(targets: readonly MnemopiStatsTarget[]): string { return lines.join("\n"); } +function summarizeMnemopiStatus( + targets: readonly MnemopiStatsTarget[], + session: AgentSession | undefined, +): MemoryBackendStatus { + let workingCount = 0; + let episodicCount = 0; + let tripleCount = 0; + let lastMemory: string | undefined; + let database: string | undefined; + for (const target of targets) { + const stats = target.memory.getStats(); + workingCount += statCount(stats.beam.working_memory); + episodicCount += statCount(stats.beam.episodic_memory); + tripleCount += stats.beam.triples.total; + lastMemory ??= stats.last_memory ?? undefined; + database ??= stats.database ? shortenPath(stats.database) : undefined; + } + const state = getMnemopiSessionState(session); + const primary = state?.aliasOf ?? state; + return { + backend: "mnemopi", + active: true, + writable: true, + searchable: true, + scope: primary?.config.scoping, + retainBank: primary?.getScopedRetainTarget().bank ?? targets[0]?.bank, + recallBanks: primary?.getScopedRecallTargets().map(target => target.bank) ?? targets.map(target => target.bank), + workingCount, + episodicCount, + tripleCount, + lastMemory, + lastRecall: Boolean(primary?.lastRecallSnippet), + database, + }; +} + +function clampLimit(limit: number | undefined): number { + if (!Number.isFinite(limit)) return 10; + return Math.max(1, Math.min(50, Math.trunc(limit ?? 10))); +} + +function normalizeImportance(value: number | undefined): number { + if (!Number.isFinite(value)) return 0.75; + return Math.max(0, Math.min(1, value ?? 0.75)); +} + function renderMnemopiDiagnostics(entries: readonly { bank: string; summary: DiagnosticSummary }[]): string { const lines = [ "# Mnemopi Memory Diagnostics", @@ -357,10 +488,7 @@ async function resolveMnemopiProviderOptions( messages: [{ role: "user", content: prompt, timestamp: Date.now() }], }, { - apiKey: modelRegistry.resolver(model.provider, { - sessionId, - baseUrl: model.baseUrl, - }), + apiKey, maxTokens: opts?.maxTokens, temperature: opts?.temperature, }, diff --git a/packages/coding-agent/test/memory-backend-resolve.test.ts b/packages/coding-agent/test/memory-backend-resolve.test.ts index 46845e103..a46fe005c 100644 --- a/packages/coding-agent/test/memory-backend-resolve.test.ts +++ b/packages/coding-agent/test/memory-backend-resolve.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { resolveMemoryBackend } from "@oh-my-pi/pi-coding-agent/memory-backend"; +import { createMemoryRuntimeContext, resolveMemoryBackend } from "@oh-my-pi/pi-coding-agent/memory-backend"; describe("resolveMemoryBackend", () => { beforeEach(() => { @@ -17,4 +17,35 @@ describe("resolveMemoryBackend", () => { expect((await resolveMemoryBackend(a)).id).toBe("hindsight"); expect((await resolveMemoryBackend(b)).id).toBe("hindsight"); }); + + it("exposes inactive status when no session is available", async () => { + const memory = createMemoryRuntimeContext({ agentDir: "/tmp/agent", cwd: "/tmp/project" }); + + await expect(memory.status()).resolves.toMatchObject({ + backend: "off", + active: false, + writable: false, + searchable: false, + }); + }); + + it("reports local backend runtime status without structured search/save support", async () => { + const settings = Settings.isolated({ "memory.backend": "local" }); + const memory = createMemoryRuntimeContext({ + agentDir: "/tmp/agent", + cwd: "/tmp/project", + session: { settings } as never, + }); + + await expect(memory.status()).resolves.toMatchObject({ + backend: "local", + active: true, + writable: false, + searchable: false, + }); + await expect(memory.search("project preference")).resolves.toMatchObject({ + backend: "local", + count: 0, + }); + }); }); diff --git a/packages/mnemopi/CHANGELOG.md b/packages/mnemopi/CHANGELOG.md index 0ae551ffb..51a7e3ce1 100644 --- a/packages/mnemopi/CHANGELOG.md +++ b/packages/mnemopi/CHANGELOG.md @@ -15,6 +15,10 @@ - Added an optional `fetch` option to `extractFacts` to control the transport used for remote extraction calls - Added support for passing a custom `fetch` implementation through `complete` and `summarizeMemories` via remote LLM options +### 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. + ## [15.9.1] - 2026-06-04 ### Breaking Changes diff --git a/packages/mnemopi/src/core/beam/helpers.ts b/packages/mnemopi/src/core/beam/helpers.ts index 91dfd6f68..6e89c2244 100644 --- a/packages/mnemopi/src/core/beam/helpers.ts +++ b/packages/mnemopi/src/core/beam/helpers.ts @@ -2,7 +2,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 { buildExactVectorIndex, searchExactVectorIndex } from "../vector-index"; import type { BeamMemoryState, JsonValue, Metadata } from "./types"; export type Vector = number[]; @@ -551,16 +551,10 @@ export function inMemoryVecSearch(db: Database, queryEmbedding: readonly number[ LIMIT 10000 `) .all() as Record[]; - const results: VectorDistanceResult[] = []; - for (const row of rows) { - const vec = decodeVector(String(row.embedding_json ?? "")); - if (vec === null) continue; - const sim = vectorCosineSimilarity(queryEmbedding, vec); - if (sim === 0 && (queryEmbedding.every(n => n === 0) || vec.every(n => n === 0))) continue; - results.push({ rowid: Number(row.rowid), distance: 1 - sim }); - } - results.sort((a, b) => a.distance - b.distance || a.rowid - b.rowid); - return results.slice(0, Math.max(0, Math.trunc(k))); + const index = buildExactVectorIndex( + rows.map(row => ({ id: Number(row.rowid), vector: decodeVector(String(row.embedding_json ?? "")) })), + ); + return searchExactVectorIndex(index, queryEmbedding, k).map(hit => ({ rowid: hit.id, distance: 1 - hit.score })); } catch { return []; } @@ -585,16 +579,10 @@ export function workingMemoryVecSearch( LIMIT ? `) .all(now.toISOString(), limit) as Record[]; - const results: WorkingVectorResult[] = []; - for (const row of rows) { - const vec = decodeVector(String(row.embedding_json ?? "")); - if (vec === null) continue; - const sim = vectorCosineSimilarity(queryEmbedding, vec); - if (sim === 0 && (queryEmbedding.every(n => n === 0) || vec.every(n => n === 0))) continue; - results.push({ id: String(row.id), sim }); - } - results.sort((a, b) => b.sim - a.sim || a.id.localeCompare(b.id)); - return results.slice(0, Math.max(0, Math.trunc(k))); + const index = buildExactVectorIndex( + rows.map(row => ({ id: String(row.id), vector: decodeVector(String(row.embedding_json ?? "")) })), + ); + return searchExactVectorIndex(index, queryEmbedding, k).map(hit => ({ id: hit.id, sim: hit.score })); } catch { return []; } diff --git a/packages/mnemopi/src/core/vector-index.ts b/packages/mnemopi/src/core/vector-index.ts new file mode 100644 index 000000000..fcfaade8c --- /dev/null +++ b/packages/mnemopi/src/core/vector-index.ts @@ -0,0 +1,81 @@ +export interface ExactVectorSearchHit { + id: TId; + score: number; +} + +export interface ExactVectorIndex { + readonly ids: readonly TId[]; + readonly matrix: Float32Array; + readonly dimensions: number; + readonly count: number; +} + +export interface VectorIndexRow { + id: TId; + vector: readonly number[] | null | undefined; +} + +export function buildExactVectorIndex(rows: readonly VectorIndexRow[]): ExactVectorIndex { + const valid: Array<{ id: TId; vector: readonly number[]; norm: number }> = []; + let dimensions = 0; + for (const row of rows) { + const vector = row.vector; + if (!vector || vector.length === 0) continue; + let normSq = 0; + for (let i = 0; i < vector.length; i += 1) { + const value = vector[i] ?? 0; + if (!Number.isFinite(value)) { + normSq = 0; + break; + } + normSq += value * value; + } + if (normSq <= 0) continue; + valid.push({ id: row.id, vector, norm: Math.sqrt(normSq) }); + if (vector.length > dimensions) dimensions = vector.length; + } + + const matrix = new Float32Array(valid.length * dimensions); + const ids: TId[] = []; + for (let row = 0; row < valid.length; row += 1) { + const item = valid[row]; + ids.push(item.id); + const offset = row * dimensions; + for (let col = 0; col < item.vector.length; col += 1) { + matrix[offset + col] = (item.vector[col] ?? 0) / item.norm; + } + } + + return { ids, matrix, dimensions, count: ids.length }; +} + +export function searchExactVectorIndex( + index: ExactVectorIndex, + query: readonly number[], + limit: number, +): ExactVectorSearchHit[] { + const k = Math.max(0, Math.trunc(limit)); + if (k === 0 || index.count === 0 || index.dimensions === 0 || query.length === 0) return []; + + let queryNormSq = 0; + for (const value of query) { + if (!Number.isFinite(value)) return []; + queryNormSq += value * value; + } + if (queryNormSq <= 0) return []; + const queryNorm = Math.sqrt(queryNormSq); + const queryDimensions = Math.min(query.length, index.dimensions); + const hits: ExactVectorSearchHit[] = []; + + for (let row = 0; row < index.count; row += 1) { + const offset = row * index.dimensions; + let score = 0; + for (let col = 0; col < queryDimensions; col += 1) { + score += index.matrix[offset + col] * ((query[col] ?? 0) / queryNorm); + } + hits.push({ id: index.ids[row] as TId, score }); + } + + hits.sort((a, b) => b.score - a.score); + return hits.slice(0, Math.min(k, hits.length)); +} diff --git a/packages/mnemopi/test/vector-index.test.ts b/packages/mnemopi/test/vector-index.test.ts new file mode 100644 index 000000000..66175b09c --- /dev/null +++ b/packages/mnemopi/test/vector-index.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "bun:test"; +import { buildExactVectorIndex, searchExactVectorIndex } from "../src/core/vector-index"; + +describe("exact vector index", () => { + it("normalizes vectors and returns nearest ids by cosine score", () => { + const index = buildExactVectorIndex([ + { id: "x", vector: [1, 0] }, + { id: "y", vector: [0, 2] }, + { id: "z", vector: [0, 0] }, + ]); + + expect(index.count).toBe(2); + expect(searchExactVectorIndex(index, [0, 3], 2)).toEqual([ + { id: "y", score: 1 }, + { id: "x", score: 0 }, + ]); + }); + + it("returns no hits for invalid or empty queries", () => { + const index = buildExactVectorIndex([{ id: 1, vector: [1, 0] }]); + + expect(searchExactVectorIndex(index, [], 10)).toEqual([]); + expect(searchExactVectorIndex(index, [Number.NaN], 10)).toEqual([]); + expect(searchExactVectorIndex(index, [1, 0], 0)).toEqual([]); + }); +});