feat(memory): add runtime API and exact vector index

This commit is contained in:
DarkPhilosophy
2026-06-08 15:08:46 +03:00
committed by can1357
parent 96defff9a5
commit f04b7a1a33
12 changed files with 456 additions and 29 deletions
+4
View File
@@ -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
@@ -14,4 +14,5 @@ export type {
export * from "./local-backend";
export * from "./off-backend";
export * from "./resolve";
export * from "./runtime";
export * from "./types";
@@ -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.",
};
},
};
@@ -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.",
};
},
};
@@ -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 };
}
@@ -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<MemoryBackendStatus>;
search(query: string, options?: MemoryBackendSearchOptions): Promise<MemoryBackendSearchResult>;
save(input: string | MemoryBackendSaveInput): Promise<MemoryBackendSaveResult>;
}
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<void>;
/** Structured state for UI, slash commands, and extensions. */
status?(context: MemoryBackendOperationContext): Promise<MemoryBackendStatus>;
/** Explicit user-facing semantic/lexical search. */
search?(
context: MemoryBackendOperationContext,
query: string,
options?: MemoryBackendSearchOptions,
): Promise<MemoryBackendSearchResult>;
/** Explicit user-facing save operation. */
save?(context: MemoryBackendOperationContext, input: MemoryBackendSaveInput): Promise<MemoryBackendSaveResult>;
/** Render backend-specific memory statistics as markdown (`/memory stats`). */
stats?(agentDir: string, cwd: string, session?: AgentSession): Promise<string | undefined>;
+134 -6
View File
@@ -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<MemoryBackendStatus> {
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<string | undefined> {
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,
},
@@ -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,
});
});
});
+4
View File
@@ -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
+9 -21
View File
@@ -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<string, unknown>[];
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<string, unknown>[];
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 [];
}
+81
View File
@@ -0,0 +1,81 @@
export interface ExactVectorSearchHit<TId> {
id: TId;
score: number;
}
export interface ExactVectorIndex<TId> {
readonly ids: readonly TId[];
readonly matrix: Float32Array;
readonly dimensions: number;
readonly count: number;
}
export interface VectorIndexRow<TId> {
id: TId;
vector: readonly number[] | null | undefined;
}
export function buildExactVectorIndex<TId>(rows: readonly VectorIndexRow<TId>[]): ExactVectorIndex<TId> {
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<TId>(
index: ExactVectorIndex<TId>,
query: readonly number[],
limit: number,
): ExactVectorSearchHit<TId>[] {
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<TId>[] = [];
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));
}
@@ -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([]);
});
});