From e6d4e654e84f88638df2d535130b11149f226c12 Mon Sep 17 00:00:00 2001 From: Prasenjit Sarkar Date: Tue, 7 Jul 2026 19:37:14 +0100 Subject: [PATCH] =?UTF-8?q?feat(memory):=20semantic=20memory=20v2=20?= =?UTF-8?q?=E2=80=94=20all=20phases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements docs/design/semantic-memory-v2.md end to end (Phases 0-4): - store v2 (semantic-memory.ts, memory-migration.ts): namespace scoping (global/repo/session/agent), meta.json schema versioning with automatic v1->v2 migration, zero-vector quarantine + lazy re-embed queue, dimension-change handling, lazy decay with capped reinforcement, composite scored retrieval (sim^1.5 x decay x confidence) with pushed-down filters, no-embedding fallback, eviction sweep + namespace caps, pinning - enrichment finally reaches prompts: awaited 250ms-budgeted hook in ws-bridge with per-session ordering chains; history keeps the original user text while the backend receives the enriched block; the UI gets a memory_enriched broadcast rendered as a collapsible recalled-context chip (with Playground mocks) - consolidation pipeline (memory-consolidation.ts): JUDGE (weight x confidence filter, near-dup dedupe, greedy clustering) -> DISTILL (OpenRouter, exact prompt contract, strict JSON validation, retry-once-with-error, concat fallback, budget caps) -> CONSOLIDATE (upsert by namespace+tag with supersession tombstones); triggers on turn boundary / 30min idle / session end / manual with in-flight guard - extraction upgrade: keyword gate replaced with recall-biased structural cues; thinking-block content scrubbed before any store or promotion - REST: overview + pin endpoints, global/session memory namespace fixes; settings gain per-namespace decay half-lives, reinforce multipliers, and recall depths (surfaced in Settings UI); MemoryPanel shows namespace counts, decayed-weight bars, and pin/unpin Tests: 159 new (store 78, pipeline 22, wiring 40, frontend 34 areas); suite 1630/1630 green; typecheck + build clean; live-server smoke of store/pin/overview round-trip verified. Co-Authored-By: Claude Fable 5 --- web/server/collective-intelligence.test.ts | 250 +++- web/server/collective-intelligence.ts | 168 ++- web/server/embedding.ts | 15 +- web/server/memory-consolidation.test.ts | 659 +++++++++ web/server/memory-consolidation.ts | 703 +++++++++ web/server/memory-migration.test.ts | 132 ++ web/server/memory-migration.ts | 468 ++++++ web/server/routes/ci-routes.test.ts | 276 ++++ web/server/routes/ci-routes.ts | 78 +- web/server/routes/settings-routes.ts | 12 +- web/server/semantic-memory.test.ts | 897 ++++++++++- web/server/semantic-memory.ts | 1314 ++++++++++++++--- web/server/session-types.ts | 15 + web/server/settings-manager.test.ts | 76 + web/server/settings-manager.ts | 125 +- web/server/ws-bridge.test.ts | 359 +++++ web/server/ws-bridge.ts | 233 ++- web/src/api.ts | 60 +- web/src/components/MemoryPanel.test.tsx | 171 +++ web/src/components/MemoryPanel.tsx | 113 +- web/src/components/MessageFeed.test.tsx | 66 + web/src/components/MessageFeed.tsx | 32 +- web/src/components/Playground.test.tsx | 25 +- web/src/components/Playground.tsx | 56 +- .../components/RecalledContextChip.test.tsx | 121 ++ web/src/components/RecalledContextChip.tsx | 126 ++ web/src/components/SettingsPage.test.tsx | 114 ++ web/src/components/SettingsPage.tsx | 203 ++- web/src/store.test.ts | 55 + web/src/store.ts | 32 +- web/src/types.ts | 16 +- web/src/ws.test.ts | 82 + web/src/ws.ts | 27 + 33 files changed, 6757 insertions(+), 322 deletions(-) create mode 100644 web/server/memory-consolidation.test.ts create mode 100644 web/server/memory-consolidation.ts create mode 100644 web/server/memory-migration.test.ts create mode 100644 web/server/memory-migration.ts create mode 100644 web/server/routes/ci-routes.test.ts create mode 100644 web/src/components/MemoryPanel.test.tsx create mode 100644 web/src/components/RecalledContextChip.test.tsx create mode 100644 web/src/components/RecalledContextChip.tsx diff --git a/web/server/collective-intelligence.test.ts b/web/server/collective-intelligence.test.ts index c98c24d..cf4e631 100644 --- a/web/server/collective-intelligence.test.ts +++ b/web/server/collective-intelligence.test.ts @@ -16,7 +16,10 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { CollectiveIntelligenceLayer } from "./collective-intelligence.js"; +import { CollectiveIntelligenceLayer, scrubThinkingText, classifyExtraction } from "./collective-intelligence.js"; +import * as semanticMemory from "./semantic-memory.js"; +import * as memoryConsolidation from "./memory-consolidation.js"; +import { sharedContextManager } from "./shared-context.js"; import type { BrowserOutgoingMessage, BrowserIncomingMessage } from "./session-types.js"; // ─── Mocks ──────────────────────────────────────────────────────────────────── @@ -40,6 +43,22 @@ vi.mock("./semantic-memory.js", () => ({ queryFragments: vi.fn(async () => []), consolidateSession: vi.fn(async () => []), getConsolidatedKnowledge: vi.fn(async () => []), + // v2 enrichment entry point (§3.6.2) — reinforcement happens inside it + queryForEnrichment: vi.fn(async () => ({ items: [], block: null })), +})); + +// Mock the consolidation pipeline (§3.4) — onSessionEnd routes through it +vi.mock("./memory-consolidation.js", () => ({ + consolidate: vi.fn(async (ctx: { reason: string }) => ({ + status: "ran", + synthesisMethod: "none", + knowledgeUpserted: 0, + fragmentsConsolidated: 0, + reason: ctx.reason, + })), + shouldConsolidateOnTurn: vi.fn(async () => false), + noteSessionActivity: vi.fn(), + stopIdleWatcher: vi.fn(), })); // Mock capability-discovery to avoid disk I/O @@ -188,3 +207,232 @@ describe("CollectiveIntelligenceLayer", () => { expect(received!.type).toBe("memory_query_result"); }); }); + +// ─── Semantic-memory v2 wiring (§3.4 / §3.6) ────────────────────────────────── + +describe("scrubThinkingText (§3.6.5)", () => { + // Table-driven: thinking-block content must never survive into anything + // that gets persisted to semantic memory. + it.each([ + // [input, expected] + ["plain text stays", "plain text stays"], + ["before secret reasoning after", "before after"], + ["before secret after", "before after"], + ["mixed CASE tags", "mixed tags"], + ["unterminated trailing block never closes", "unterminated"], + ["multi a and b blocks", "multi and blocks"], + ["only thinking", ""], + ])("scrubs %j", (input, expected) => { + expect(scrubThinkingText(input)).toBe(expected); + }); +}); + +describe("classifyExtraction (§3.6.5)", () => { + it("types decision cues as 'decision'", () => { + // "decided/instead/because" are the doc's structural decision cues. + expect(classifyExtraction("We decided to keep the Hono router.").type).toBe("decision"); + expect(classifyExtraction("Use bun instead of node for the scripts here.").type).toBe("decision"); + expect(classifyExtraction("Chose LanceDB because it needs no server.").type).toBe("decision"); + }); + + it("types error+fix pairs as 'pattern' tagged 'failure'", () => { + // MemoryType has no "failure" variant — the failure tag is the contract + // that lets consolidation distill these into KnowledgeType "failure" rows. + const result = classifyExtraction( + "The build failed with a TS2307 error; fixed by adding the .js extension to the import.", + ); + expect(result.type).toBe("pattern"); + expect(result.extraTags).toContain("failure"); + }); + + it("defaults everything else to 'observation' (recall-biased, no keyword gate)", () => { + const result = classifyExtraction("The server persists sessions to disk under the user home directory."); + expect(result.type).toBe("observation"); + expect(result.extraTags).toEqual([]); + }); + + it("prefers failure over decision when both cue sets match", () => { + // An error+fix narrative often contains "because" — the failure pairing + // is the more specific signal and must win. + const result = classifyExtraction("It failed because of a race; fixed by serializing the queue."); + expect(result.type).toBe("pattern"); + expect(result.extraTags).toContain("failure"); + }); +}); + +describe("enrichUserMessage (§3.6.2)", () => { + let ci: CollectiveIntelligenceLayer; + + beforeEach(() => { + vi.mocked(semanticMemory.queryForEnrichment).mockClear(); + ci = new CollectiveIntelligenceLayer(); + }); + + it("delegates to queryForEnrichment with the session context and returns its result", async () => { + // The CI layer is a thin passthrough: namespace planning, budgets and + // REINFORCEMENT all live inside queryForEnrichment — the layer must not + // reinforce again or reshape the result. + const enrichment = { + items: [{ id: "k1", kind: "knowledge" as const, namespace: "global", summary: "s", weight: 1 }], + block: "--- Campfire memory (auto-recalled; may be stale) ---\n--- end memory ---", + }; + vi.mocked(semanticMemory.queryForEnrichment).mockResolvedValueOnce(enrichment); + + const result = await ci.enrichUserMessage( + { sessionId: "s-enrich", repoRoot: "/repo", backendType: "codex" }, + "how do we deploy?", + ); + + expect(semanticMemory.queryForEnrichment).toHaveBeenCalledWith({ + sessionId: "s-enrich", + repoRoot: "/repo", + backendType: "codex", + queryText: "how do we deploy?", + }); + expect(result).toBe(enrichment); + }); +}); + +describe("memory extraction (§3.6.5 recall-biased upgrade)", () => { + let ci: CollectiveIntelligenceLayer; + + const assistantMsg = (text: string): BrowserIncomingMessage => ({ + type: "assistant", + message: { content: [{ type: "text", text }] } as unknown, + parent_tool_use_id: null, + } as BrowserIncomingMessage); + + async function drain() { + // processAgentMessage is fire-and-forget; give its async chain two ticks. + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + } + + beforeEach(() => { + vi.mocked(semanticMemory.storeFragment).mockClear(); + ci = new CollectiveIntelligenceLayer(); + }); + + it("stores substantial text WITHOUT the old keyword gate", async () => { + // v1 dropped any assistant text lacking one of ten keywords ("function", + // "class", ...). This sentence has none of them and must now be stored. + ci.processAgentMessage("s-x1", "claude", assistantMsg( + "The session data lives on disk and survives restarts of the whole server process.", + )); + await drain(); + + expect(semanticMemory.storeFragment).toHaveBeenCalledTimes(1); + expect(vi.mocked(semanticMemory.storeFragment).mock.calls[0][0]).toMatchObject({ + type: "observation", + sessionId: "s-x1", + }); + }); + + it("never stores thinking-block content", async () => { + // Thinking is scrubbed BEFORE the length check and store — the persisted + // fragment must not contain any reasoning text. + ci.processAgentMessage("s-x2", "claude", assistantMsg( + "The launcher retries the spawn twice before giving up entirely. I am secretly unsure about the retry count, maybe grep again", + )); + await drain(); + + expect(semanticMemory.storeFragment).toHaveBeenCalledTimes(1); + const stored = vi.mocked(semanticMemory.storeFragment).mock.calls[0][0]; + expect(stored.content).toBe("The launcher retries the spawn twice before giving up entirely."); + expect(stored.content).not.toContain("secretly unsure"); + }); + + it("skips content that is only thinking (too short once scrubbed)", async () => { + // A message that is pure reasoning must produce no fragment at all. + ci.processAgentMessage("s-x3", "claude", assistantMsg( + "long private reasoning that would have passed the fifty character minimum easily on its own ok", + )); + await drain(); + + expect(semanticMemory.storeFragment).not.toHaveBeenCalled(); + }); + + it("types decision-cue text as a 'decision' fragment", async () => { + ci.processAgentMessage("s-x4", "codex", assistantMsg( + "We decided to use the adapter registry instead of hardcoding backends in the launcher.", + )); + await drain(); + + expect(semanticMemory.storeFragment).toHaveBeenCalledTimes(1); + expect(vi.mocked(semanticMemory.storeFragment).mock.calls[0][0]).toMatchObject({ + type: "decision", + backendType: "codex", + }); + }); + + it("types error+fix pairs as 'pattern' tagged 'failure'", async () => { + ci.processAgentMessage("s-x5", "claude", assistantMsg( + "The websocket handshake failed with a 403 error; fixed by forwarding the auth cookie in the upgrade request.", + )); + await drain(); + + expect(semanticMemory.storeFragment).toHaveBeenCalledTimes(1); + const stored = vi.mocked(semanticMemory.storeFragment).mock.calls[0][0]; + expect(stored.type).toBe("pattern"); + expect(stored.tags).toContain("failure"); + }); +}); + +describe("onSessionEnd (§3.4 session_end trigger + promotion scrubbing)", () => { + let ci: CollectiveIntelligenceLayer; + + beforeEach(() => { + vi.mocked(semanticMemory.storeFragment).mockClear(); + vi.mocked(memoryConsolidation.consolidate).mockClear(); + ci = new CollectiveIntelligenceLayer(); + }); + + it("routes consolidation through consolidate({reason: 'session_end'})", async () => { + // The old direct consolidateSession call is replaced by the pipeline + // entry point, preserving the trigger semantics (§3.4 trigger 3). + await ci.onSessionEnd("s-end-1", "claude", "/repo"); + + expect(memoryConsolidation.consolidate).toHaveBeenCalledWith({ + sessionId: "s-end-1", + repoRoot: "/repo", + backendType: "claude", + reason: "session_end", + }); + }); + + it("still promotes significant shared-context fragments, scrubbed, excluding agent thinking", async () => { + // Preserves the pre-existing promotion semantics while enforcing §3.6.5: + // agent "thought" fragments (verbatim thinking blocks) are excluded even + // when significant, and inline markup is stripped from what + // does get promoted. + const sessionId = "s-end-2"; + const stream = sharedContextManager.getOrCreate(sessionId); + await stream.ingest({ + agentId: "human", + isHuman: true, + type: "insight", + content: "Rate limiting uses a token bucket redacted musings per API key", + }); + await stream.ingest({ + agentId: sessionId, + isHuman: false, + type: "thought", + content: "raw chain of thought that must never be persisted", + }); + // Force the agent thought to be "significant" so only the type/isHuman + // exclusion (not the consensus score) keeps it out of memory. + for (const f of stream.getAllFragments()) { + if (f.type === "thought") f.consensusScore = 0.95; + } + + await ci.onSessionEnd(sessionId, "claude", "/repo"); + + const storedContents = vi.mocked(semanticMemory.storeFragment).mock.calls.map((c) => c[0].content); + expect(storedContents).toContain("Rate limiting uses a token bucket per API key"); + expect(storedContents.join("\n")).not.toContain("raw chain of thought"); + expect(storedContents.join("\n")).not.toContain("redacted musings"); + + // Stream is torn down after promotion (unchanged behavior) + expect(sharedContextManager.get(sessionId)).toBeFalsy(); + }); +}); diff --git a/web/server/collective-intelligence.ts b/web/server/collective-intelligence.ts index 338a2a2..9c8e9c6 100644 --- a/web/server/collective-intelligence.ts +++ b/web/server/collective-intelligence.ts @@ -24,8 +24,9 @@ */ import type { BrowserIncomingMessage, BrowserOutgoingMessage, BackendType } from "./session-types.js"; -import { storeFragment, queryFragments, consolidateSession, getConsolidatedKnowledge } from "./semantic-memory.js"; -import type { MemoryFragment, ConsolidatedKnowledge, GitContext } from "./semantic-memory.js"; +import { storeFragment, queryFragments, queryForEnrichment } from "./semantic-memory.js"; +import type { GitContext, MemoryType, EnrichmentResult } from "./semantic-memory.js"; +import { consolidate } from "./memory-consolidation.js"; import { deliberationEngine } from "./deliberation-engine.js"; import type { DeliberationProposal, DeliberationResolution } from "./deliberation-engine.js"; import { capabilityDiscovery } from "./capability-discovery.js"; @@ -38,6 +39,71 @@ import type { ContextFragment, ConsensusState } from "./shared-context.js"; /** Called by WsBridge to send CI-generated messages to all browsers in a session */ type BroadcastFn = (sessionId: string, msg: BrowserIncomingMessage) => void; +// ─── Session context (enrichment / consolidation plumbing) ─────────────────── + +/** Minimal session context the CI layer needs for namespace-scoped memory ops. */ +export interface CISessionContext { + sessionId: string; + repoRoot: string; + backendType: BackendType; +} + +// ─── Thinking-block scrubbing (§3.6.5) ──────────────────────────────────────── + +/** + * Strip reasoning/thinking-block content from text before it is persisted to + * semantic memory (ADR-006's scrubReasoningBlocks idea, design doc §3.6.5). + * Removes / blocks (including an + * unterminated trailing block) and collapses the leftover whitespace. + * Raw thinking text must never reach storeFragment or shared-context + * promotion — it is verbose, session-specific, and often speculative. + */ +export function scrubThinkingText(text: string): string { + return text + .replace(/[\s\S]*?<\/think(?:ing)?>/gi, " ") + .replace(/[\s\S]*$/gi, " ") + .replace(/\s+/g, " ") + .trim(); +} + +// ─── Recall-biased extraction classification (§3.6.5) ───────────────────────── + +/** Structural cues signalling a decision was made ("decided/instead/because"). */ +const DECISION_CUE_RE = + /\b(decided|decision|instead(?: of)?|because|chose|opted (?:for|to)|going with|settled on|we(?:'ll| will) use)\b/i; +/** Error-side cue of an error+fix pair. */ +const ERROR_CUE_RE = /\b(error|exception|failed|failing|failure|crash(?:ed)?|broken|bug|traceback)\b/i; +/** Fix-side cue of an error+fix pair. */ +const FIX_CUE_RE = /\b(fix(?:ed|es)?|resolved|solution|workaround|root cause|caused by|turned out)\b/i; + +interface ExtractionClassification { + type: MemoryType; + extraTags: string[]; + confidence: number; +} + +/** + * Classify assistant text by structural cues (design doc §3.6.5). The old + * ten-keyword *gate* is gone — extraction is deliberately recall-biased and + * only classifies; precision comes from the consolidation JUDGE stage, and + * decay + eviction clean up the noise. + * + * - decision cues → type "decision" + * - error+fix pair → type "pattern" tagged "failure" (MemoryType has no + * "failure" variant; the tag lets consolidation distill it into a + * KnowledgeType "failure" row) + * - everything else → plain "observation" + */ +export function classifyExtraction(content: string): ExtractionClassification { + if (ERROR_CUE_RE.test(content) && FIX_CUE_RE.test(content)) { + return { type: "pattern", extraTags: ["failure"], confidence: 0.7 }; + } + if (DECISION_CUE_RE.test(content)) { + return { type: "decision", extraTags: [], confidence: 0.7 }; + } + return { type: "observation", extraTags: [], confidence: 0.6 }; +} + // ─── CollectiveIntelligenceLayer ────────────────────────────────────────────── export class CollectiveIntelligenceLayer { @@ -175,10 +241,10 @@ export class CollectiveIntelligenceLayer { msg: BrowserOutgoingMessage, ): Promise { try { - // Layer 1: Enrich user prompts with semantic memory context - if (msg.type === "user_message") { - return await this.enrichWithMemory(sessionId, msg); - } + // NOTE (§3.6.1): user_message enrichment does NOT happen here. This + // method is fire-and-forget for consumed CI message types; enrichment + // *transforms* the message, so WsBridge awaits enrichUserMessage() + // explicitly (with a timeout) in its user_message routing path. // Layer 2: Handle human deliberation responses if (msg.type === "deliberation_respond") { @@ -267,28 +333,60 @@ export class CollectiveIntelligenceLayer { return msg; // pass through unchanged } + // ─── Prompt enrichment (§3.6.1–3.6.3) ───────────────────────────────────── + + /** + * Enrich a user prompt with recalled memory (the fixed successor of the old + * dead-code enrichWithMemory, §1.4). Queries namespaces + * [repo:, agent:, global] — NOT session:, since + * same-session context is already in the agent's own conversation. + * + * Returns the injectable block + the item list for the UI chip. + * Reinforcement of included rows happens INSIDE queryForEnrichment (§3.2) — + * callers must not reinforce again. WsBridge is responsible for the timeout + * / pass-through posture; this method just queries. + */ + async enrichUserMessage(ctx: CISessionContext, content: string): Promise { + return queryForEnrichment({ + sessionId: ctx.sessionId, + repoRoot: ctx.repoRoot, + backendType: ctx.backendType, + queryText: content, + }); + } + // ─── Session lifecycle ──────────────────────────────────────────────────── /** - * Called when a session ends. Consolidates memory and promotes significant - * shared context fragments to semantic memory. + * Called when a session ends. Routes consolidation through the JUDGE → + * DISTILL → CONSOLIDATE pipeline (§3.4 trigger 3, reason "session_end") + * and promotes significant shared context fragments to semantic memory — + * the same semantics as before, with the concat-only consolidateSession + * call replaced by the pipeline entry point. */ async onSessionEnd(sessionId: string, backendType: BackendType, repoRoot: string): Promise { try { - // Consolidate episodic → semantic memory - await consolidateSession(sessionId, repoRoot); + // Consolidate episodic → semantic memory via the v2 pipeline + await consolidate({ sessionId, repoRoot, backendType, reason: "session_end" }); // Promote significant shared context fragments to semantic memory const stream = sharedContextManager.get(sessionId); if (stream) { const significant = stream.getSignificantFragments(); for (const f of significant) { + // §3.6.5: never persist raw thinking-block content. Agent "thought" + // fragments are the verbatim thinking blocks ingested from + // stream_events (processAgentMessageAsync) — exclude them from + // promotion entirely; scrub inline markup from the rest. + if (f.type === "thought" && !f.isHuman) continue; + const content = scrubThinkingText(f.content); + if (!content) continue; await storeFragment({ sessionId, agentId: f.agentId, backendType, type: "observation", - content: f.content, + content, gitContext: { branch: "unknown", files: [], repoRoot }, tags: [f.type, "shared-context"], confidence: f.consensusScore, @@ -303,54 +401,38 @@ export class CollectiveIntelligenceLayer { // ─── Internal helpers ──────────────────────────────────────────────────── - private async enrichWithMemory( - sessionId: string, - msg: BrowserOutgoingMessage & { type: "user_message" }, - ): Promise { - const memories = await queryFragments(msg.content, { sessionId, limit: 5 }); - if (memories.length === 0) return msg; - - const context = this.formatMemoryContext(memories); - return { ...msg, content: `${context}\n\n${msg.content}` }; - } - - private formatMemoryContext(memories: MemoryFragment[]): string { - const lines = memories - .slice(0, 5) - .map((m) => `[Memory/${m.type}] ${m.content}${m.gitContext.files.length > 0 ? ` (${m.gitContext.files.slice(0, 2).join(", ")})` : ""}`) - .join("\n"); - return `--- Relevant Context from Previous Sessions ---\n${lines}\n---`; - } - private async extractMemory( sessionId: string, backendType: BackendType, message: unknown, gitContext?: Partial, ): Promise { - // Extract meaningful observations from assistant messages. - // We look for tool results (Read/Write/Edit/Bash) which are rich in codebase knowledge. - const content = this.extractTextContent(message); - if (!content || content.length < 50) return; // too short to be meaningful - - // Heuristic: only store if content looks like codebase knowledge - const keywords = ["function", "class", "interface", "module", "import", "export", "config", "pattern", "architecture", "convention"]; - const hasKeyword = keywords.some((k) => content.toLowerCase().includes(k)); - if (!hasKeyword) return; - + // Recall-biased extraction (§3.6.5): the old ten-keyword gate dropped + // anything not phrased with those exact words. Extraction now stores any + // substantial assistant text, typed by structural cues; precision comes + // from the consolidation JUDGE, and decay + eviction clean up the noise. + const raw = this.extractTextContent(message); + if (!raw) return; + + // Scrub thinking-block content BEFORE any length check or store — raw + // reasoning must never be persisted (§3.6.5 / ADR-006 scrubReasoningBlocks). + const content = scrubThinkingText(raw); + if (content.length < 50) return; // too short to be meaningful + + const classification = classifyExtraction(content); await storeFragment({ sessionId, agentId: sessionId, backendType, - type: "observation", + type: classification.type, content: content.slice(0, 500), // cap fragment length gitContext: { branch: gitContext?.branch ?? "unknown", files: gitContext?.files ?? [], repoRoot: gitContext?.repoRoot ?? "", }, - tags: this.extractTags(content), - confidence: 0.6, + tags: [...classification.extraTags, ...this.extractTags(content)].slice(0, 5), + confidence: classification.confidence, }); } diff --git a/web/server/embedding.ts b/web/server/embedding.ts index ca4d090..ae8a2b4 100644 --- a/web/server/embedding.ts +++ b/web/server/embedding.ts @@ -9,11 +9,16 @@ * Provider is configured in ~/.campfire/settings.json via embeddingProvider field. */ -import { getSettings } from "./settings-manager.js"; +import { getSettings, type EmbeddingProvider } from "./settings-manager.js"; export const OPENAI_DIM = 1536; export const OLLAMA_DIM = 768; +/** Name of the currently configured embedding provider. */ +export function getEmbeddingProviderName(): EmbeddingProvider { + return getSettings().embeddingProvider; +} + /** * Generate an embedding vector for the given text using the configured provider. * Returns null if provider is "none" or if the embedding call fails. @@ -35,12 +40,16 @@ export async function embed(text: string): Promise { /** * Return the embedding dimension for the currently configured provider. * Used when creating LanceDB tables so the vector column has the correct width. + * + * v2 (design §3.5.2): returns null when provider is "none" — the old fake + * 1536 default caused dimension lock-in. With provider "none", no vector is + * populated and fragments are stored with embeddingStatus = "none". */ -export function getEmbeddingDim(): number { +export function getEmbeddingDim(): number | null { const settings = getSettings(); if (settings.embeddingProvider === "openai") return OPENAI_DIM; if (settings.embeddingProvider === "ollama") return OLLAMA_DIM; - return OPENAI_DIM; // default dimension when "none" (stored as zeros) + return null; } async function embedWithOpenAI(text: string, apiKey: string, model: string): Promise { diff --git a/web/server/memory-consolidation.test.ts b/web/server/memory-consolidation.test.ts new file mode 100644 index 0000000..91218f1 --- /dev/null +++ b/web/server/memory-consolidation.test.ts @@ -0,0 +1,659 @@ +/** + * Tests for the LLM consolidation pipeline (memory-consolidation.ts) — + * JUDGE → DISTILL → CONSOLIDATE per docs/design/semantic-memory-v2.md §3.4. + * + * Isolation: + * - The semantic-memory store runs against a fresh temp directory per test + * (same mock-homedir pattern as semantic-memory.test.ts). + * - The embedding module is mocked with deterministic vectors, so no real + * OpenAI/Ollama calls happen and cluster geometry is fully controlled. + * - global fetch is stubbed — the OpenRouter DISTILL call NEVER hits the + * network; request bodies are inspected to verify the §3.4 prompt contract. + * + * Covered areas: + * 1. Happy-path LLM distillation: valid JSON → knowledge upserted with + * synthesisMethod "llm", sources + discarded fragments marked, and the + * exact prompt contract (system prompt, JSON user message, temperature 0). + * 2. existingKnowledge injection + supersedes tombstoning. + * 3. Invalid output → ONE retry with the validator error appended → valid. + * 4. Double failure → concat fallback (synthesisMethod "concat"). + * 5. No API key → concat immediately, no fetch, never blocked. + * 6. Stage-1 JUDGE: low w(t)×confidence dropped, near-duplicates deduped, + * greedy clustering into separate distillation calls. + * 7. In-flight guard: concurrent consolidate → { status: "in_flight" }. + * 8. shouldConsolidateOnTurn threshold (8, named constant). + * 9. Budget caps: >40-fragment cluster split into chunks, ≤4 calls/trigger. + * 10. Idle trigger via the exported _checkIdleSessions test hook + + * stopIdleWatcher clearing all tracking. + * 11. validateDistillationOutput unit cases (fences, unknown ids, bad enum). + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// ─── Mock embedding module (hoisted, same harness as semantic-memory.test.ts) ─ + +const mockEmbed = vi.hoisted(() => vi.fn()); +const mockGetDim = vi.hoisted(() => vi.fn()); +const mockProviderName = vi.hoisted(() => vi.fn()); + +vi.mock("./embedding.js", () => ({ + embed: mockEmbed, + getEmbeddingDim: mockGetDim, + getEmbeddingProviderName: mockProviderName, + OPENAI_DIM: 1536, + OLLAMA_DIM: 768, +})); + +import * as settingsManager from "./settings-manager.js"; +import * as memory from "./semantic-memory.js"; +import * as consolidation from "./memory-consolidation.js"; + +// ─── Deterministic embedding control ───────────────────────────────────────── + +/** Per-test exact content → vector overrides (checked before the keyword fallback). */ +const vectorByContent = new Map(); + +/** Keyword fallback: "auth"→d0, "database"→d1, "routing"→d2, "cache"→d3; else zeros. */ +function keywordVector(text: string): number[] { + const v = [0, 0, 0, 0]; + if (text.toLowerCase().includes("auth")) v[0] = 1; + if (text.toLowerCase().includes("database")) v[1] = 1; + if (text.toLowerCase().includes("routing")) v[2] = 1; + if (text.toLowerCase().includes("cache")) v[3] = 1; + const mag = Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1; + return v.map((x) => x / mag); +} + +/** Unit vector at `deg` degrees in the (d0, d1) plane — for precise cluster geometry. */ +function vecAt(deg: number): number[] { + const rad = (deg * Math.PI) / 180; + return [Math.cos(rad), Math.sin(rad), 0, 0]; +} + +// ─── OpenRouter fetch mock ─────────────────────────────────────────────────── + +const mockFetch = vi.fn(); + +/** Minimal OpenRouter chat-completions response whose message content is `content`. */ +function openRouterReply(content: string) { + return { + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ choices: [{ message: { content } }] }), + }; +} + +const EMPTY_OUTPUT = JSON.stringify({ knowledge: [], discardedFragmentIds: [] }); + +/** Parsed JSON body of the i-th fetch call. */ +function requestBody(i: number): { + model: string; + temperature: number; + messages: Array<{ role: string; content: string }>; +} { + const init = mockFetch.mock.calls[i][1] as RequestInit; + return JSON.parse(init.body as string); +} + +/** Parsed §3.4 user payload (cluster + existingKnowledge) of the i-th fetch call. */ +function userPayload(i: number): { + repoRoot: string; + cluster: Array<{ id: string; type: string; content: string; confidence: number; ageHours: number; files: string[] }>; + existingKnowledge: Array<{ id: string; tag: string; summary: string; confidence: number }>; +} { + return JSON.parse(requestBody(i).messages[1].content); +} + +/** The EXACT §3.4 system prompt (prompt contract — must match the design doc verbatim). */ +const EXPECTED_SYSTEM_PROMPT = + "You distill working notes from an AI coding session into durable knowledge for future sessions in this repository. Output ONLY valid JSON matching the schema. Merge duplicates. Resolve contradictions by preferring later, higher-confidence notes and say what superseded what. Discard chit-chat, transient state (branch names, in-progress todo status), and anything true only for this one session. Each summary must be a standalone statement useful with zero session context, ≤ 60 words."; + +// ─── Test harness ───────────────────────────────────────────────────────────── + +let testDir: string; + +beforeEach(() => { + testDir = join(tmpdir(), `campfire-test-consolidation-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); + settingsManager._resetForTest(join(testDir, "settings.json")); + + vectorByContent.clear(); + mockEmbed.mockImplementation(async (text: string) => vectorByContent.get(text) ?? keywordVector(text)); + mockGetDim.mockReturnValue(4); + mockProviderName.mockReturnValue("openai"); + + memory._resetForTest(testDir); + consolidation._resetForTest(); + + mockFetch.mockReset(); + vi.stubGlobal("fetch", mockFetch); +}); + +afterEach(() => { + consolidation._resetForTest(); + settingsManager._resetForTest(); + vi.unstubAllGlobals(); + rmSync(testDir, { recursive: true, force: true }); +}); + +function setApiKey(): void { + settingsManager.updateSettings({ openrouterApiKey: "sk-test" }); +} + +function ctx(reason: consolidation.ConsolidationReason = "manual"): consolidation.ConsolidationContext { + return { sessionId: "s1", repoRoot: "/repo", backendType: "claude", reason }; +} + +function storeFrag(overrides: Partial[0]> = {}) { + return memory.storeFragment({ + sessionId: "s1", + agentId: "a1", + backendType: "claude", + type: "observation", + content: "auth: note", + gitContext: { branch: "main", files: ["web/server/x.ts"], repoRoot: "/repo" }, + confidence: 0.9, + tags: ["auth"], + ...overrides, + }); +} + +// ─── 1. Happy-path LLM distillation ────────────────────────────────────────── + +describe("consolidate — happy-path LLM distillation", () => { + it("distills a cluster into knowledge, marks sources + discarded, and honors the exact prompt contract", async () => { + // Three fragments at 0°/16°/32°: pairwise cosine ≤ 0.961 (< 0.97 — no + // near-dup collapse) and all within 0.8 of the running centroid — a single + // cluster, therefore a single DISTILL call. + setApiKey(); + vectorByContent.set("auth: JWT signing uses RS256", vecAt(0)); + vectorByContent.set("auth: tokens expire after 7 days", vecAt(16)); + vectorByContent.set("chit-chat: user said hello", vecAt(32)); + const fA = await storeFrag({ content: "auth: JWT signing uses RS256", confidence: 0.9 }); + const fB = await storeFrag({ content: "auth: tokens expire after 7 days", confidence: 0.8, type: "decision" }); + const fC = await storeFrag({ content: "chit-chat: user said hello", confidence: 0.8 }); + + mockFetch.mockResolvedValue( + openRouterReply( + JSON.stringify({ + knowledge: [ + { + tag: "auth-tokens", + type: "pattern", + summary: "Auth uses RS256-signed JWTs that expire after 7 days.", + confidence: 0.85, + sourceFragmentIds: [fA.id, fB.id], + namespace: "repo", + }, + ], + discardedFragmentIds: [fC.id], + }), + ), + ); + + const result = await consolidation.consolidate(ctx("manual")); + + // Result accounting: 1 knowledge row, 3 fragments (2 sources + 1 discarded) + expect(result).toEqual({ + status: "ran", + synthesisMethod: "llm", + knowledgeUpserted: 1, + fragmentsConsolidated: 3, + reason: "manual", + }); + + // Prompt contract (§3.4): one call, temperature 0, EXACT system prompt, + // user message is the JSON shape (repoRoot / cluster / existingKnowledge) + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0][0]).toBe("https://openrouter.ai/api/v1/chat/completions"); + const body = requestBody(0); + expect(body.temperature).toBe(0); + expect(body.model).toBe("openrouter/free"); // DEFAULT_OPENROUTER_MODEL + expect(body.messages[0]).toEqual({ role: "system", content: EXPECTED_SYSTEM_PROMPT }); + expect(body.messages[1].role).toBe("user"); + const payload = userPayload(0); + expect(payload.repoRoot).toBe("/repo"); + expect(payload.existingKnowledge).toEqual([]); + expect(payload.cluster.map((c) => c.id).sort()).toEqual([fA.id, fB.id, fC.id].sort()); + const clusterItem = payload.cluster.find((c) => c.id === fB.id)!; + expect(clusterItem).toMatchObject({ + type: "decision", + content: "auth: tokens expire after 7 days", + confidence: 0.8, + files: ["web/server/x.ts"], + }); + expect(typeof clusterItem.ageHours).toBe("number"); + expect(clusterItem.ageHours).toBeGreaterThanOrEqual(0); + + // Stage 3: knowledge upserted with synthesisMethod "llm" + const rows = await memory.getKnowledgeByNamespace(memory.repoNamespace("/repo"), "auth-tokens"); + expect(rows).toHaveLength(1); + expect(rows[0].synthesisMethod).toBe("llm"); + expect(rows[0].type).toBe("pattern"); + expect(rows[0].sourceFragments.sort()).toEqual([fA.id, fB.id].sort()); + + // Sources AND discarded fragments are all marked consolidated + expect(await memory.getUnconsolidatedFragments("s1")).toEqual([]); + const all = await memory.getSessionFragments("s1"); + expect(all.find((f) => f.id === fA.id)!.consolidatedInto).toBe(rows[0].id); + expect(all.find((f) => f.id === fC.id)!.isConsolidated).toBe(true); + expect(all.find((f) => f.id === fC.id)!.consolidatedInto).toBe("discarded"); + }); + + it("feeds existingKnowledge within 0.80 of the cluster centroid and applies supersedes tombstones", async () => { + // Pre-seed an active knowledge row whose summary embeds at d0 (contains + // "auth") — the cluster centroid is also d0, so it must be offered to the + // model as existingKnowledge and be supersede-able. + setApiKey(); + const [seed] = await memory.upsertKnowledgeFromDistillation( + [{ tag: "auth", summary: "auth: old take on tokens", confidence: 0.6, sourceFragmentIds: [], namespace: "repo" }], + { sessionId: "seed", repoRoot: "/repo", backendType: "claude" }, + ); + const f = await storeFrag({ content: "auth: new approach to tokens", confidence: 0.9 }); + + mockFetch.mockResolvedValue( + openRouterReply( + JSON.stringify({ + knowledge: [ + { + tag: "auth-v2", + type: "decision", + summary: "Tokens now follow the new auth approach.", + confidence: 0.9, + sourceFragmentIds: [f.id], + supersedes: [seed.id], + namespace: "repo", + }, + ], + discardedFragmentIds: [], + }), + ), + ); + + await consolidation.consolidate(ctx()); + + // existingKnowledge carried the seeded row (id/tag/summary/confidence shape) + expect(userPayload(0).existingKnowledge).toEqual([ + { id: seed.id, tag: "auth", summary: "auth: old take on tokens", confidence: 0.6 }, + ]); + + // The superseded row is tombstoned; only auth-v2 remains active + const active = await memory.getKnowledgeByNamespace(memory.repoNamespace("/repo")); + expect(active.map((k) => k.tag)).toEqual(["auth-v2"]); + }); + + it("skips when the session has no un-consolidated fragments", async () => { + // No candidates → status "skipped", nothing else happens (no fetch) + setApiKey(); + const result = await consolidation.consolidate({ ...ctx(), sessionId: "empty-session" }); + expect(result).toEqual({ + status: "skipped", + synthesisMethod: "none", + knowledgeUpserted: 0, + fragmentsConsolidated: 0, + reason: "manual", + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); + +// ─── 2. Retry + fallback ladder ────────────────────────────────────────────── + +describe("consolidate — validation retry and concat fallback", () => { + it("retries ONCE with the validator error appended, then succeeds", async () => { + // First reply is not JSON; the retry conversation must contain the raw + // assistant output plus a user message with the validator error. The + // second (valid) reply completes the LLM path. + setApiKey(); + const f = await storeFrag({ content: "auth: retry me", confidence: 0.9 }); + mockFetch + .mockResolvedValueOnce(openRouterReply("this is not json at all")) + .mockResolvedValueOnce( + openRouterReply( + JSON.stringify({ + knowledge: [ + { tag: "auth-retry", type: "fact", summary: "Retry worked.", confidence: 0.7, sourceFragmentIds: [f.id], namespace: "repo" }, + ], + discardedFragmentIds: [], + }), + ), + ); + + const result = await consolidation.consolidate(ctx()); + expect(result.synthesisMethod).toBe("llm"); + expect(result.knowledgeUpserted).toBe(1); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Retry conversation: [system, user, assistant(raw), user(validator error)] + const retryBody = requestBody(1); + expect(retryBody.messages).toHaveLength(4); + expect(retryBody.messages[2]).toEqual({ role: "assistant", content: "this is not json at all" }); + expect(retryBody.messages[3].role).toBe("user"); + expect(retryBody.messages[3].content).toContain("failed validation"); + expect(retryBody.messages[3].content).toContain("not valid JSON"); + }); + + it("falls back to concatFallbackConsolidate after two invalid outputs", async () => { + // Both attempts fail schema validation → the fragments degrade to the + // concat path (synthesisMethod "concat"), never silently lost. + setApiKey(); + const f = await storeFrag({ content: "auth: always fails", confidence: 0.9, tags: ["auth"] }); + mockFetch + .mockResolvedValueOnce(openRouterReply(JSON.stringify({ knowledge: "nope" }))) + .mockResolvedValueOnce(openRouterReply(JSON.stringify({ knowledge: [], discarded: "missing key" }))); + + const result = await consolidation.consolidate(ctx()); + expect(mockFetch).toHaveBeenCalledTimes(2); // exactly one retry + expect(result.status).toBe("ran"); + expect(result.synthesisMethod).toBe("concat"); + expect(result.knowledgeUpserted).toBe(1); + expect(result.fragmentsConsolidated).toBe(1); + + const rows = await memory.getKnowledgeByNamespace(memory.repoNamespace("/repo"), "auth"); + expect(rows).toHaveLength(1); + expect(rows[0].synthesisMethod).toBe("concat"); + expect(rows[0].sourceFragments).toEqual([f.id]); + expect(await memory.getUnconsolidatedFragments("s1")).toEqual([]); + }); + + it("goes straight to concat when no OpenRouter API key is configured — never blocked", async () => { + // Degraded mode (§3.4): no key → no LLM call at all, concat consolidation + // still runs and the result is status "ran" with synthesisMethod "concat". + const f = await storeFrag({ content: "auth: no key configured", tags: ["auth"] }); + + const result = await consolidation.consolidate(ctx("session_end")); + expect(mockFetch).not.toHaveBeenCalled(); + expect(result).toEqual({ + status: "ran", + synthesisMethod: "concat", + knowledgeUpserted: 1, + fragmentsConsolidated: 1, + reason: "session_end", + }); + const rows = await memory.getKnowledgeByNamespace(memory.repoNamespace("/repo"), "auth"); + expect(rows[0].synthesisMethod).toBe("concat"); + expect(rows[0].sourceFragments).toEqual([f.id]); + }); +}); + +// ─── 3. Stage-1 JUDGE ──────────────────────────────────────────────────────── + +describe("consolidate — Stage-1 JUDGE filtering", () => { + it("drops fragments with w(t) × confidence < 0.15 and leaves them un-consolidated", async () => { + // Fresh fragments have w ≈ 1, so confidence 0.1 → judged 0.1 < 0.15 (dropped) + // while confidence 0.9 survives. The dropped fragment must not reach the + // LLM and must remain un-consolidated (decay/eviction owns it, not us). + setApiKey(); + const kept = await storeFrag({ content: "auth: strong signal", confidence: 0.9 }); + const dropped = await storeFrag({ content: "auth low: weak signal", confidence: 0.1 }); + + mockFetch.mockResolvedValue( + openRouterReply( + JSON.stringify({ + knowledge: [ + { tag: "auth-signal", type: "fact", summary: "Strong auth signal.", confidence: 0.9, sourceFragmentIds: [kept.id], namespace: "repo" }, + ], + discardedFragmentIds: [], + }), + ), + ); + + await consolidation.consolidate(ctx()); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(userPayload(0).cluster.map((c) => c.id)).toEqual([kept.id]); + + const remaining = await memory.getUnconsolidatedFragments("s1"); + expect(remaining.map((f) => f.id)).toEqual([dropped.id]); + }); + + it("dedupes near-duplicates within the batch (cosine > 0.97), keeping the higher-scored one", async () => { + // Both contents contain only "auth" → identical unit vectors (cosine 1). + // The confidence-0.9 twin wins; only one fragment reaches the LLM. + setApiKey(); + await storeFrag({ content: "auth: duplicated insight A", confidence: 0.5 }); + const winner = await storeFrag({ content: "auth: duplicated insight B", confidence: 0.9 }); + + mockFetch.mockResolvedValue( + openRouterReply( + JSON.stringify({ + knowledge: [ + { tag: "auth-dedupe", type: "fact", summary: "One insight.", confidence: 0.9, sourceFragmentIds: [winner.id], namespace: "repo" }, + ], + discardedFragmentIds: [], + }), + ), + ); + + await consolidation.consolidate(ctx()); + expect(mockFetch).toHaveBeenCalledTimes(1); + const cluster = userPayload(0).cluster; + expect(cluster).toHaveLength(1); + expect(cluster[0].id).toBe(winner.id); + expect(cluster[0].content).toBe("auth: duplicated insight B"); + }); + + it("clusters greedily at 0.80 — orthogonal topics get separate distillation calls", async () => { + // "auth" (d0) vs "database" (d1) have cosine 0 < 0.80 → two clusters → + // two OpenRouter calls, each carrying exactly one fragment. + setApiKey(); + const a = await storeFrag({ content: "auth: token flow", confidence: 0.9 }); + const b = await storeFrag({ content: "database: pooling strategy", confidence: 0.9 }); + mockFetch.mockResolvedValue(openRouterReply(EMPTY_OUTPUT)); + + await consolidation.consolidate(ctx()); + expect(mockFetch).toHaveBeenCalledTimes(2); + const clusters = [userPayload(0).cluster, userPayload(1).cluster]; + expect(clusters[0]).toHaveLength(1); + expect(clusters[1]).toHaveLength(1); + const ids = clusters.flat().map((c) => c.id).sort(); + expect(ids).toEqual([a.id, b.id].sort()); + }); +}); + +// ─── 4. In-flight guard ────────────────────────────────────────────────────── + +describe("consolidate — in-flight guard", () => { + it("returns { status: 'in_flight' } for a concurrent call on the same session", async () => { + // The guard is set synchronously at entry, so a second call issued before + // the first resolves must short-circuit without touching the store or LLM. + setApiKey(); + await storeFrag({ content: "auth: pending guard", confidence: 0.9 }); + mockFetch.mockResolvedValue(openRouterReply(EMPTY_OUTPUT)); + + const first = consolidation.consolidate(ctx("manual")); // not awaited yet + const second = await consolidation.consolidate(ctx("turn_boundary")); + expect(second).toEqual({ + status: "in_flight", + synthesisMethod: "none", + knowledgeUpserted: 0, + fragmentsConsolidated: 0, + reason: "turn_boundary", + }); + + const firstResult = await first; + expect(firstResult.status).toBe("ran"); + + // Guard is released after completion — a follow-up call runs normally + const third = await consolidation.consolidate(ctx("manual")); + expect(third.status).not.toBe("in_flight"); + }); +}); + +// ─── 5. Turn-boundary trigger threshold ────────────────────────────────────── + +describe("shouldConsolidateOnTurn", () => { + it("fires only at ≥ 8 un-consolidated fragments (TURN_CONSOLIDATION_THRESHOLD)", async () => { + expect(consolidation.TURN_CONSOLIDATION_THRESHOLD).toBe(8); + + for (let i = 0; i < 7; i++) { + await storeFrag({ sessionId: "st", content: `note ${i} for the turn trigger` }); + } + expect(await consolidation.shouldConsolidateOnTurn("st")).toBe(false); + + await storeFrag({ sessionId: "st", content: "note 7 for the turn trigger" }); + expect(await consolidation.shouldConsolidateOnTurn("st")).toBe(true); + }); + + it("does not count fragments that are already consolidated", async () => { + for (let i = 0; i < 8; i++) { + await storeFrag({ sessionId: "sc", content: `consolidated note ${i}` }); + } + const fragments = await memory.getUnconsolidatedFragments("sc"); + await memory.markFragmentsConsolidated(fragments.map((f) => f.id), "k-1"); + expect(await consolidation.shouldConsolidateOnTurn("sc")).toBe(false); + }); +}); + +// ─── 6. Budget caps ────────────────────────────────────────────────────────── + +describe("consolidate — budgets (§3.4)", () => { + it("splits a >40-fragment cluster into ≤40-fragment calls", async () => { + // Provider "none": fragments carry no embeddings, so the no-embedding + // fallback makes ONE cluster of 50 — which must be chunked into 40 + 10. + mockGetDim.mockReturnValue(null); + mockProviderName.mockReturnValue("none"); + mockEmbed.mockResolvedValue(null); + setApiKey(); + + for (let i = 0; i < 50; i++) { + await storeFrag({ content: `note ${i} about the system`, confidence: 0.7 }); + } + mockFetch.mockResolvedValue(openRouterReply(EMPTY_OUTPUT)); + + const result = await consolidation.consolidate(ctx()); + expect(result.status).toBe("ran"); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(userPayload(0).cluster).toHaveLength(40); + expect(userPayload(1).cluster).toHaveLength(10); + }); + + it("caps a trigger at 4 distillation calls; extra clusters wait for the next trigger", async () => { + // Five mutually-orthogonal fragments (4 keyword axes + one zero vector) + // form five singleton clusters — only MAX_CALLS_PER_TRIGGER (4) may call. + setApiKey(); + expect(consolidation.MAX_CALLS_PER_TRIGGER).toBe(4); + await storeFrag({ content: "auth alpha", confidence: 0.9 }); + await storeFrag({ content: "database beta", confidence: 0.9 }); + await storeFrag({ content: "routing gamma", confidence: 0.9 }); + await storeFrag({ content: "cache delta", confidence: 0.9 }); + await storeFrag({ content: "plain misc note", confidence: 0.9 }); + mockFetch.mockResolvedValue(openRouterReply(EMPTY_OUTPUT)); + + const result = await consolidation.consolidate(ctx()); + expect(result.status).toBe("ran"); + expect(mockFetch).toHaveBeenCalledTimes(4); + }); +}); + +// ─── 7. Idle trigger ───────────────────────────────────────────────────────── + +describe("idle trigger (noteSessionActivity / _checkIdleSessions / stopIdleWatcher)", () => { + it("consolidates a session idle > 30 min with un-consolidated fragments (reason 'idle')", async () => { + // No API key → the idle-triggered consolidation takes the concat path, + // which is observable via the knowledge table and cleared fragments. + await storeFrag({ sessionId: "sid", content: "auth: idle note one", tags: ["auth"] }); + await storeFrag({ sessionId: "sid", content: "auth idle: note two", tags: ["auth"] }); + consolidation.noteSessionActivity({ sessionId: "sid", repoRoot: "/repo", backendType: "claude" }); + + await consolidation._checkIdleSessions(Date.now() + consolidation.IDLE_TRIGGER_MS + 60_000); + + expect(await memory.getUnconsolidatedFragments("sid")).toEqual([]); + const rows = await memory.getKnowledgeByNamespace(memory.repoNamespace("/repo"), "auth"); + expect(rows).toHaveLength(1); + expect(rows[0].synthesisMethod).toBe("concat"); + }); + + it("does nothing while the session is within the 30-minute window", async () => { + await storeFrag({ sessionId: "sid", content: "auth: still active" }); + consolidation.noteSessionActivity({ sessionId: "sid", repoRoot: "/repo", backendType: "claude" }); + + await consolidation._checkIdleSessions(Date.now() + 5 * 60_000); + expect((await memory.getUnconsolidatedFragments("sid")).length).toBe(1); + + // Still tracked: crossing the threshold later does fire + await consolidation._checkIdleSessions(Date.now() + consolidation.IDLE_TRIGGER_MS + 60_000); + expect(await memory.getUnconsolidatedFragments("sid")).toEqual([]); + }); + + it("skips idle sessions that have nothing un-consolidated", async () => { + consolidation.noteSessionActivity({ sessionId: "empty-sess", repoRoot: "/repo", backendType: "claude" }); + await consolidation._checkIdleSessions(Date.now() + consolidation.IDLE_TRIGGER_MS + 60_000); + expect(await memory.getKnowledgeByNamespace(memory.repoNamespace("/repo"))).toEqual([]); + }); + + it("stopIdleWatcher clears all idle tracking (tests / shutdown)", async () => { + await storeFrag({ sessionId: "sid", content: "auth: never idle-consolidated" }); + consolidation.noteSessionActivity({ sessionId: "sid", repoRoot: "/repo", backendType: "claude" }); + consolidation.stopIdleWatcher(); + + await consolidation._checkIdleSessions(Date.now() + consolidation.IDLE_TRIGGER_MS + 60_000); + expect((await memory.getUnconsolidatedFragments("sid")).length).toBe(1); + }); +}); + +// ─── 8. Output validator unit cases ────────────────────────────────────────── + +describe("validateDistillationOutput", () => { + const clusterIds = new Set(["frag-1", "frag-2"]); + const existingIds = new Set(["know-1"]); + const validItem = { + tag: "auth-tokens", + type: "pattern", + summary: "A durable statement.", + confidence: 0.8, + sourceFragmentIds: ["frag-1"], + namespace: "repo", + }; + + it("accepts a valid payload wrapped in a markdown code fence", () => { + // Models routinely wrap JSON in ``` fences — stripping them is tolerated; + // everything inside is still validated strictly. + const raw = "```json\n" + JSON.stringify({ knowledge: [validItem], discardedFragmentIds: ["frag-2"] }) + "\n```"; + const result = consolidation.validateDistillationOutput(raw, clusterIds, existingIds); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.knowledge[0].tag).toBe("auth-tokens"); + expect(result.value.discardedFragmentIds).toEqual(["frag-2"]); + } + }); + + it("rejects sourceFragmentIds that are not from the input cluster", () => { + const raw = JSON.stringify({ + knowledge: [{ ...validItem, sourceFragmentIds: ["frag-999"] }], + discardedFragmentIds: [], + }); + const result = consolidation.validateDistillationOutput(raw, clusterIds, existingIds); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("frag-999"); + }); + + it("rejects supersedes ids that were not offered as existingKnowledge", () => { + const raw = JSON.stringify({ + knowledge: [{ ...validItem, supersedes: ["know-999"] }], + discardedFragmentIds: [], + }); + const result = consolidation.validateDistillationOutput(raw, clusterIds, existingIds); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("know-999"); + }); + + it("rejects invalid enum values (type, namespace) and non-kebab tags", () => { + for (const bad of [ + { ...validItem, type: "musing" }, + { ...validItem, namespace: "session" }, + { ...validItem, tag: "Not Kebab Case" }, + { ...validItem, confidence: 1.5 }, + ]) { + const result = consolidation.validateDistillationOutput( + JSON.stringify({ knowledge: [bad], discardedFragmentIds: [] }), + clusterIds, + existingIds, + ); + expect(result.ok).toBe(false); + } + }); +}); diff --git a/web/server/memory-consolidation.ts b/web/server/memory-consolidation.ts new file mode 100644 index 0000000..a9d7d47 --- /dev/null +++ b/web/server/memory-consolidation.ts @@ -0,0 +1,703 @@ +/** + * Memory consolidation pipeline — JUDGE → DISTILL → CONSOLIDATE. + * + * Implements docs/design/semantic-memory-v2.md §3.4 on top of the v2 store + * (semantic-memory.ts). The exported signatures are the contract with the + * call sites (ws-bridge / collective-intelligence) — do not change them. + * + * Flow per trigger (turn boundary / idle / session end / manual — all funnel + * into consolidate() with a per-session in-flight guard): + * + * 1. Candidates: the session's un-consolidated fragments (all namespaces the + * session wrote to — the store filters by sessionId). + * 2. JUDGE (local, cheap): drop fragments with w(t) × confidence < 0.15; + * drop near-duplicates within the batch (cosine > 0.97 when embeddings + * exist, content equality otherwise — higher judged score wins); group + * survivors by greedy embedding clustering at the 0.80 threshold + * (fragments without embeddings fall back into a single shared cluster). + * 3. DISTILL (OpenRouter chat call, temperature 0): the fixed §3.4 prompt + * contract — exact system prompt, JSON user message with the cluster and + * `existingKnowledge` (active rows within 0.80 of the cluster centroid). + * Output is strictly validated against the §3.4 schema; on parse or + * validation failure the call is retried ONCE with the validator error + * appended to the conversation; a second failure degrades those fragments + * to the concat fallback. Budgets: ≤ 40 fragments per call and ≤ 4 + * distillation calls per trigger (each call may retry once; clusters + * beyond the call budget stay un-consolidated for the next trigger). + * 4. CONSOLIDATE: upsertKnowledgeFromDistillation() with synthesisMethod + * "llm" (handles upsert-by-(namespace, tag), supersession tombstones, + * embedding, and source marking), then markFragmentsConsolidated() for + * discardedFragmentIds so judged-away noise never re-triggers. + * + * Degraded mode: no OpenRouter API key → straight to concatFallbackConsolidate + * (synthesisMethod "concat"); consolidation is never blocked on configuration. + * Fire-and-forget posture: consolidate() never throws to callers — internal + * errors are logged and reported with best-effort accounting. + */ + +import { DEFAULT_OPENROUTER_MODEL, getSettings } from "./settings-manager.js"; +import { + NEAR_DUP_COSINE, + computeDecayedWeight, + concatFallbackConsolidate, + findRelatedKnowledge, + getUnconsolidatedFragments, + markFragmentsConsolidated, + policyForNamespace, + repoNamespace, + upsertKnowledgeFromDistillation, + type ConsolidatedKnowledge, + type DistilledKnowledgeItem, + type KnowledgeType, + type MemoryFragment, +} from "./semantic-memory.js"; + +export type ConsolidationReason = "turn_boundary" | "idle" | "session_end" | "manual"; + +export interface ConsolidationContext { + sessionId: string; + repoRoot: string; + backendType: string; + reason: ConsolidationReason; +} + +export interface ConsolidationResult { + status: "ran" | "skipped" | "in_flight"; + synthesisMethod: "llm" | "concat" | "none"; + knowledgeUpserted: number; + fragmentsConsolidated: number; + reason: ConsolidationReason; +} + +// ─── Tunables (§3.4) ───────────────────────────────────────────────────────── + +/** Turn-boundary trigger: consolidate when ≥ this many un-consolidated fragments exist. */ +export const TURN_CONSOLIDATION_THRESHOLD = 8; +/** JUDGE floor: fragments with w(t) × confidence below this are dropped. */ +export const JUDGE_MIN_WEIGHTED_CONFIDENCE = 0.15; +/** Greedy clustering threshold — also the `existingKnowledge` centroid radius. */ +export const CLUSTER_SIM_THRESHOLD = 0.8; +/** Budget: max fragments per distillation call. */ +export const MAX_FRAGMENTS_PER_CALL = 40; +/** Budget: max distillation calls per trigger (each may retry once on invalid output). */ +export const MAX_CALLS_PER_TRIGGER = 4; +/** Idle trigger: a session inactive longer than this is consolidated. */ +export const IDLE_TRIGGER_MS = 30 * 60 * 1000; + +const IDLE_CHECK_INTERVAL_MS = 60 * 1000; +const DISTILL_TIMEOUT_MS = 30_000; +const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; +/** consolidatedInto sentinel for fragments the distiller discarded (no knowledge row). */ +const DISCARDED_KNOWLEDGE_ID = "discarded"; + +/** + * EXACT system prompt from the §3.4 prompt contract. Do not edit without + * updating docs/design/semantic-memory-v2.md — the doc is the source of truth. + */ +const DISTILL_SYSTEM_PROMPT = + "You distill working notes from an AI coding session into durable knowledge for future sessions in this repository. " + + "Output ONLY valid JSON matching the schema. Merge duplicates. Resolve contradictions by preferring later, " + + "higher-confidence notes and say what superseded what. Discard chit-chat, transient state (branch names, " + + "in-progress todo status), and anything true only for this one session. Each summary must be a standalone " + + "statement useful with zero session context, ≤ 60 words."; + +/** Compact schema restatement appended to retry messages so the model can self-correct. */ +const SCHEMA_HINT = + '{"knowledge":[{"tag":"kebab-case-topic","type":"pattern|decision|convention|failure|fact",' + + '"summary":"standalone statement","confidence":0.0,"sourceFragmentIds":["uuid"],' + + '"supersedes":["existing-knowledge-uuid"],"namespace":"repo|global|agent"}],"discardedFragmentIds":["uuid"]}'; + +const KNOWLEDGE_TYPES = new Set(["pattern", "decision", "convention", "failure", "fact"]); +const OUTPUT_NAMESPACES = new Set(["repo", "global", "agent"]); +const KEBAB_TAG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +// ─── Small vector helpers (local — the store does not export these) ───────── + +function cosineSimilarity(a: number[], b: number[]): number { + if (a.length === 0 || a.length !== b.length) return 0; + let dot = 0; + let magA = 0; + let magB = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + magA += a[i] * a[i]; + magB += b[i] * b[i]; + } + if (magA === 0 || magB === 0) return 0; + return dot / (Math.sqrt(magA) * Math.sqrt(magB)); +} + +function meanVector(vectors: number[][]): number[] { + const dim = vectors[0].length; + const out = Array(dim).fill(0) as number[]; + for (const v of vectors) for (let i = 0; i < dim; i++) out[i] += v[i]; + for (let i = 0; i < dim; i++) out[i] /= vectors.length; + return out; +} + +// ─── Stage 1 — JUDGE (§3.4) ────────────────────────────────────────────────── + +interface JudgedFragment { + fragment: MemoryFragment; + /** w(t) × confidence at judge time. */ + judgedScore: number; +} + +/** + * Cheap local filter: drop low-signal fragments (w(t) × confidence < 0.15) + * and near-duplicates within the batch (cosine > 0.97 when both embeddings + * exist; content-equality fallback otherwise). Higher judged score wins. + * Dropped fragments are simply excluded — decay/eviction handles them later. + */ +function judgeFragments(candidates: MemoryFragment[], now: number): MemoryFragment[] { + const weighted: JudgedFragment[] = []; + for (const fragment of candidates) { + const policy = policyForNamespace(fragment.namespace ?? ""); + const weight = computeDecayedWeight(fragment, now, policy); + const judgedScore = weight * fragment.confidence; + if (judgedScore >= JUDGE_MIN_WEIGHTED_CONFIDENCE) weighted.push({ fragment, judgedScore }); + } + + const kept: JudgedFragment[] = []; + for (const candidate of [...weighted].sort((a, b) => b.judgedScore - a.judgedScore)) { + const isDup = kept.some((existing) => { + if (existing.fragment.content === candidate.fragment.content) return true; + const a = existing.fragment.embedding; + const b = candidate.fragment.embedding; + if (!a || !b) return false; + return cosineSimilarity(a, b) > NEAR_DUP_COSINE; + }); + if (!isDup) kept.push(candidate); + } + return kept.map((k) => k.fragment); +} + +/** + * Greedy embedding clustering at the 0.80 threshold: each fragment joins the + * first cluster whose running-mean centroid is within the threshold, else it + * seeds a new cluster. Fragments without embeddings share a single fallback + * cluster (when nothing has embeddings that is the batch — §3.4 Stage 1). + */ +function clusterFragments(fragments: MemoryFragment[]): MemoryFragment[][] { + const clusters: Array<{ members: MemoryFragment[]; centroid: number[] }> = []; + const noEmbedding: MemoryFragment[] = []; + + for (const fragment of fragments) { + const vec = fragment.embedding; + if (!vec || vec.length === 0) { + noEmbedding.push(fragment); + continue; + } + let placed = false; + for (const cluster of clusters) { + if (cosineSimilarity(vec, cluster.centroid) >= CLUSTER_SIM_THRESHOLD) { + cluster.members.push(fragment); + const n = cluster.members.length; + for (let i = 0; i < cluster.centroid.length; i++) { + cluster.centroid[i] = (cluster.centroid[i] * (n - 1) + vec[i]) / n; + } + placed = true; + break; + } + } + if (!placed) clusters.push({ members: [fragment], centroid: [...vec] }); + } + + const out = clusters.map((c) => c.members); + if (noEmbedding.length > 0) out.push(noEmbedding); + return out; +} + +// ─── Stage 2 — DISTILL: strict output validation (§3.4 schema) ─────────────── + +interface DistillationOutput { + knowledge: DistilledKnowledgeItem[]; + discardedFragmentIds: string[]; +} + +type ValidationResult = { ok: true; value: DistillationOutput } | { ok: false; error: string }; + +/** Strip a single fenced code block (``` / ```json) around the payload, if present. */ +function stripCodeFences(raw: string): string { + const trimmed = raw.trim(); + const match = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/); + return match ? match[1] : trimmed; +} + +/** + * Hand-rolled strict validator for the §3.4 output schema. All fragment ids + * must come from the input cluster and all `supersedes` ids from the + * `existingKnowledge` we offered — the model may not touch rows it wasn't shown. + */ +export function validateDistillationOutput( + raw: string, + clusterIds: ReadonlySet, + existingKnowledgeIds: ReadonlySet, +): ValidationResult { + let parsed: unknown; + try { + parsed = JSON.parse(stripCodeFences(raw)); + } catch (err) { + return { + ok: false, + error: `output is not valid JSON (${err instanceof Error ? err.message : String(err)})`, + }; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return { ok: false, error: "top-level output must be a JSON object" }; + } + const obj = parsed as Record; + if (!Array.isArray(obj.knowledge)) { + return { ok: false, error: '"knowledge" must be an array' }; + } + if (!Array.isArray(obj.discardedFragmentIds)) { + return { ok: false, error: '"discardedFragmentIds" must be an array' }; + } + + const discarded: string[] = []; + for (const id of obj.discardedFragmentIds as unknown[]) { + if (typeof id !== "string") { + return { ok: false, error: '"discardedFragmentIds" entries must be strings' }; + } + if (!clusterIds.has(id)) { + return { + ok: false, + error: `discardedFragmentIds contains "${id}" which is not a fragment id from the input cluster`, + }; + } + discarded.push(id); + } + + const items: DistilledKnowledgeItem[] = []; + const rawItems = obj.knowledge as unknown[]; + for (let i = 0; i < rawItems.length; i++) { + const where = `knowledge[${i}]`; + const rawItem = rawItems[i]; + if (typeof rawItem !== "object" || rawItem === null || Array.isArray(rawItem)) { + return { ok: false, error: `${where} must be an object` }; + } + const item = rawItem as Record; + if (typeof item.tag !== "string" || !KEBAB_TAG_RE.test(item.tag)) { + return { ok: false, error: `${where}.tag must be a kebab-case string (e.g. "auth-tokens")` }; + } + if (typeof item.type !== "string" || !KNOWLEDGE_TYPES.has(item.type)) { + return { ok: false, error: `${where}.type must be one of pattern|decision|convention|failure|fact` }; + } + if (typeof item.summary !== "string" || item.summary.trim() === "") { + return { ok: false, error: `${where}.summary must be a non-empty string` }; + } + if ( + typeof item.confidence !== "number" || + !Number.isFinite(item.confidence) || + item.confidence < 0 || + item.confidence > 1 + ) { + return { ok: false, error: `${where}.confidence must be a number between 0 and 1` }; + } + if (!Array.isArray(item.sourceFragmentIds)) { + return { ok: false, error: `${where}.sourceFragmentIds must be an array of fragment ids` }; + } + const sourceIds: string[] = []; + for (const id of item.sourceFragmentIds as unknown[]) { + if (typeof id !== "string") { + return { ok: false, error: `${where}.sourceFragmentIds entries must be strings` }; + } + if (!clusterIds.has(id)) { + return { + ok: false, + error: `${where}.sourceFragmentIds contains "${id}" which is not a fragment id from the input cluster`, + }; + } + sourceIds.push(id); + } + let supersedes: string[] | undefined; + if (item.supersedes !== undefined) { + if (!Array.isArray(item.supersedes)) { + return { ok: false, error: `${where}.supersedes must be an array of existing knowledge ids` }; + } + supersedes = []; + for (const id of item.supersedes as unknown[]) { + if (typeof id !== "string" || !existingKnowledgeIds.has(id)) { + return { + ok: false, + error: `${where}.supersedes contains "${String(id)}" which is not an id from existingKnowledge`, + }; + } + supersedes.push(id); + } + } + if (typeof item.namespace !== "string" || !OUTPUT_NAMESPACES.has(item.namespace)) { + return { ok: false, error: `${where}.namespace must be one of repo|global|agent` }; + } + items.push({ + tag: item.tag, + type: item.type as KnowledgeType, + summary: item.summary, + confidence: item.confidence, + sourceFragmentIds: sourceIds, + supersedes, + namespace: item.namespace, + }); + } + + return { ok: true, value: { knowledge: items, discardedFragmentIds: discarded } }; +} + +// ─── Stage 2 — DISTILL: OpenRouter call (same pattern as auto-namer.ts) ────── + +interface ChatMessage { + role: "system" | "user" | "assistant"; + content: string; +} + +function extractTextContent(content: unknown): string | null { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const text = content + .map((item) => { + if (typeof item === "string") return item; + if (item && typeof item === "object") { + const maybe = item as { text?: unknown }; + return typeof maybe.text === "string" ? maybe.text : ""; + } + return ""; + }) + .join("\n") + .trim(); + return text || null; + } + return null; +} + +/** One OpenRouter chat completion at temperature 0. Returns the raw text or null on transport failure. */ +async function callOpenRouter( + messages: ChatMessage[], + apiKey: string, + model: string, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), DISTILL_TIMEOUT_MS); + try { + const res = await fetch(OPENROUTER_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ model, messages, temperature: 0 }), + signal: controller.signal, + }); + if (!res.ok) { + console.warn(`[memory-consolidation] OpenRouter request failed: ${res.status} ${res.statusText}`); + return null; + } + const data = (await res.json()) as { + choices?: Array<{ message?: { content?: unknown } }>; + }; + return extractTextContent(data.choices?.[0]?.message?.content); + } catch (err) { + console.warn("[memory-consolidation] OpenRouter request failed:", err); + return null; + } finally { + clearTimeout(timer); + } +} + +interface DistillOutcome { + ok: boolean; + items: DistilledKnowledgeItem[]; + discardedFragmentIds: string[]; +} + +/** + * Distill one ≤40-fragment chunk: build the §3.4 user message (cluster + + * existingKnowledge within 0.80 of the centroid), call OpenRouter, validate + * strictly; on failure retry ONCE with the validator error appended to the + * conversation. Returns ok=false after the second failure (→ concat fallback). + */ +async function distillChunk( + chunk: MemoryFragment[], + ctx: ConsolidationContext, + apiKey: string, + model: string, + now: number, +): Promise { + const knowledgeNamespace = ctx.repoRoot ? repoNamespace(ctx.repoRoot) : "global"; + const vectors = chunk + .map((f) => f.embedding) + .filter((v): v is number[] => Array.isArray(v) && v.length > 0); + + let related: ConsolidatedKnowledge[] = []; + try { + related = + vectors.length > 0 + ? await findRelatedKnowledge(knowledgeNamespace, meanVector(vectors), CLUSTER_SIM_THRESHOLD) + : await findRelatedKnowledge( + knowledgeNamespace, + chunk.map((f) => f.content), + CLUSTER_SIM_THRESHOLD, + ); + } catch (err) { + console.warn("[memory-consolidation] findRelatedKnowledge failed:", err); + } + + // User message — the exact JSON shape from the §3.4 prompt contract. + const userPayload = { + repoRoot: ctx.repoRoot, + cluster: chunk.map((f) => ({ + id: f.id, + type: f.type, + content: f.content, + confidence: f.confidence, + ageHours: Math.round(Math.max(0, now - f.timestamp) / 360_000) / 10, + files: f.gitContext.files ?? [], + })), + existingKnowledge: related.map((k) => ({ + id: k.id, + tag: k.tag, + summary: k.summary, + confidence: k.confidence, + })), + }; + + const messages: ChatMessage[] = [ + { role: "system", content: DISTILL_SYSTEM_PROMPT }, + { role: "user", content: JSON.stringify(userPayload) }, + ]; + const clusterIds = new Set(chunk.map((f) => f.id)); + const existingIds = new Set(related.map((k) => k.id)); + + for (let attempt = 0; attempt < 2; attempt++) { + const raw = await callOpenRouter(messages, apiKey, model); + if (raw === null) { + // Transport failure — nothing to append; one blind retry, then give up. + if (attempt === 0) continue; + return { ok: false, items: [], discardedFragmentIds: [] }; + } + const validated = validateDistillationOutput(raw, clusterIds, existingIds); + if (validated.ok) { + return { + ok: true, + items: validated.value.knowledge, + discardedFragmentIds: validated.value.discardedFragmentIds, + }; + } + if (attempt === 0) { + messages.push({ role: "assistant", content: raw }); + messages.push({ + role: "user", + content: `Your output failed validation: ${validated.error}\nRespond with ONLY valid JSON matching this schema: ${SCHEMA_HINT}`, + }); + } else { + console.warn(`[memory-consolidation] distillation output invalid after retry: ${validated.error}`); + } + } + return { ok: false, items: [], discardedFragmentIds: [] }; +} + +// ─── consolidate() — the single entry point for all triggers ───────────────── + +const _inFlight = new Set(); + +function emptyResult( + status: ConsolidationResult["status"], + reason: ConsolidationReason, +): ConsolidationResult { + return { status, synthesisMethod: "none", knowledgeUpserted: 0, fragmentsConsolidated: 0, reason }; +} + +async function runConcatFallback( + ctx: ConsolidationContext, + knowledgeUpserted: number, + consolidatedIds: Set, +): Promise { + const rows = await concatFallbackConsolidate(ctx.sessionId, ctx.repoRoot, ctx.backendType); + for (const row of rows) for (const id of row.sourceFragments) consolidatedIds.add(id); + return knowledgeUpserted + rows.length; +} + +async function runConsolidation(ctx: ConsolidationContext): Promise { + const candidates = await getUnconsolidatedFragments(ctx.sessionId); + if (candidates.length === 0) return emptyResult("skipped", ctx.reason); + + const settings = getSettings(); + const apiKey = settings.openrouterApiKey.trim(); + const consolidatedIds = new Set(); + + // No API key → degrade to concat, never block (§3.4). + if (!apiKey) { + const knowledgeUpserted = await runConcatFallback(ctx, 0, consolidatedIds); + return { + status: "ran", + synthesisMethod: "concat", + knowledgeUpserted, + fragmentsConsolidated: consolidatedIds.size, + reason: ctx.reason, + }; + } + const model = settings.openrouterModel?.trim() || DEFAULT_OPENROUTER_MODEL; + + // Stage 1 — JUDGE. + const now = Date.now(); + const survivors = judgeFragments(candidates, now); + if (survivors.length === 0) return { ...emptyResult("ran", ctx.reason) }; + const clusters = clusterFragments(survivors); + + // Budgets: split oversized clusters into ≤40-fragment chunks, cap at 4 calls. + const chunks: MemoryFragment[][] = []; + for (const cluster of clusters) { + for (let i = 0; i < cluster.length; i += MAX_FRAGMENTS_PER_CALL) { + chunks.push(cluster.slice(i, i + MAX_FRAGMENTS_PER_CALL)); + } + } + const budgeted = chunks.slice(0, MAX_CALLS_PER_TRIGGER); + + // Stage 2 + 3 — DISTILL each chunk, then CONSOLIDATE its validated output. + let knowledgeUpserted = 0; + let anyLlmSuccess = false; + let anyFailure = false; + for (const chunk of budgeted) { + const outcome = await distillChunk(chunk, ctx, apiKey, model, now); + if (!outcome.ok) { + anyFailure = true; + continue; + } + anyLlmSuccess = true; + if (outcome.items.length > 0) { + const rows = await upsertKnowledgeFromDistillation(outcome.items, { + sessionId: ctx.sessionId, + repoRoot: ctx.repoRoot, + backendType: ctx.backendType, + synthesisMethod: "llm", + }); + knowledgeUpserted += rows.length; + for (const item of outcome.items) for (const id of item.sourceFragmentIds) consolidatedIds.add(id); + } + if (outcome.discardedFragmentIds.length > 0) { + await markFragmentsConsolidated(outcome.discardedFragmentIds, DISCARDED_KNOWLEDGE_ID); + for (const id of outcome.discardedFragmentIds) consolidatedIds.add(id); + } + } + + // Chunks that failed twice degrade to the concat fallback (it consolidates + // whatever is still un-consolidated for the session — best-effort, §3.4). + if (anyFailure) { + try { + knowledgeUpserted = await runConcatFallback(ctx, knowledgeUpserted, consolidatedIds); + } catch (err) { + console.warn("[memory-consolidation] concat fallback failed:", err); + } + } + + const synthesisMethod: ConsolidationResult["synthesisMethod"] = anyLlmSuccess + ? "llm" + : anyFailure + ? "concat" + : "none"; + return { + status: "ran", + synthesisMethod, + knowledgeUpserted, + fragmentsConsolidated: consolidatedIds.size, + reason: ctx.reason, + }; +} + +/** + * Run consolidation for a session. Idempotent per in-flight guard: concurrent + * calls for the same session return { status: "in_flight" }. + */ +export async function consolidate(ctx: ConsolidationContext): Promise { + if (_inFlight.has(ctx.sessionId)) return emptyResult("in_flight", ctx.reason); + _inFlight.add(ctx.sessionId); + try { + return await runConsolidation(ctx); + } catch (err) { + // Fire-and-forget posture: never throw to callers. + console.warn("[memory-consolidation] consolidate failed:", err); + return emptyResult("ran", ctx.reason); + } finally { + _inFlight.delete(ctx.sessionId); + } +} + +/** True when the session has ≥ threshold un-consolidated fragments (turn-boundary trigger). */ +export async function shouldConsolidateOnTurn(sessionId: string): Promise { + try { + const fragments = await getUnconsolidatedFragments(sessionId, TURN_CONSOLIDATION_THRESHOLD); + return fragments.length >= TURN_CONSOLIDATION_THRESHOLD; + } catch (err) { + console.warn("[memory-consolidation] shouldConsolidateOnTurn failed:", err); + return false; + } +} + +// ─── Idle trigger (§3.4 trigger 2) ─────────────────────────────────────────── + +interface IdleEntry { + ctx: Omit; + lastActivityAt: number; +} + +const _idleSessions = new Map(); +let _idleTimer: ReturnType | null = null; + +function ensureIdleWatcher(): void { + // Tests drive the idle check explicitly via _checkIdleSessions — a live + // interval would fire consolidations mid-assertion (same gating as the + // store's maintenance timers in semantic-memory.ts). + if (process.env.VITEST || process.env.NODE_ENV === "test") return; + if (_idleTimer) return; + _idleTimer = setInterval(() => { + _checkIdleSessions().catch((err) => + console.warn("[memory-consolidation] idle check failed:", err), + ); + }, IDLE_CHECK_INTERVAL_MS); + _idleTimer.unref?.(); +} + +/** + * Record session activity for the idle trigger. The module owns the idle + * timer internally; callers just report activity with enough context to + * consolidate later. + */ +export function noteSessionActivity(ctx: Omit): void { + _idleSessions.set(ctx.sessionId, { ctx, lastActivityAt: Date.now() }); + ensureIdleWatcher(); +} + +/** + * Idle sweep: sessions inactive > 30 min with un-consolidated fragments are + * consolidated with reason "idle". One-shot per idle period — the entry is + * removed on firing and re-registered by the next noteSessionActivity call. + * Exported as a test hook (the interval is disabled under vitest) and invoked + * by the internal unref()'d interval in production. + */ +export async function _checkIdleSessions(now: number = Date.now()): Promise { + for (const [sessionId, entry] of [..._idleSessions]) { + if (now - entry.lastActivityAt <= IDLE_TRIGGER_MS) continue; + _idleSessions.delete(sessionId); + try { + const pending = await getUnconsolidatedFragments(sessionId, 1); + if (pending.length === 0) continue; + await consolidate({ ...entry.ctx, reason: "idle" }); + } catch (err) { + console.warn("[memory-consolidation] idle consolidation failed:", err); + } + } +} + +/** Stop all idle timers (tests / shutdown). */ +export function stopIdleWatcher(): void { + if (_idleTimer) { + clearInterval(_idleTimer); + _idleTimer = null; + } + _idleSessions.clear(); +} + +/** Reset module state between tests (in-flight guard + idle tracking). */ +export function _resetForTest(): void { + _inFlight.clear(); + stopIdleWatcher(); +} diff --git a/web/server/memory-migration.test.ts b/web/server/memory-migration.test.ts new file mode 100644 index 0000000..46d8ec0 --- /dev/null +++ b/web/server/memory-migration.test.ts @@ -0,0 +1,132 @@ +/** + * Tests for the pure helpers in memory-migration.ts: namespace model (§3.1), + * meta.json versioning (§3.5), zero-vector detection (§1.6), and namespace + * backfill rules. The end-to-end migration flows (v1 → v2 copy, dimension + * change, re-embed queue) are covered in semantic-memory.test.ts where the + * whole store is exercised against real LanceDB tables. + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + hashRepoRoot, + repoNamespace, + sessionNamespace, + agentNamespace, + namespaceClass, + isNamespaceString, + isZeroVector, + toNumberArray, + backfillFragmentNamespace, + backfillKnowledgeNamespace, + readMemoryMeta, + writeMemoryMeta, + metaPath, + MEMORY_SCHEMA_VERSION, + type MemoryMeta, +} from "./memory-migration.js"; + +describe("namespace model (§3.1)", () => { + it("hashRepoRoot is a stable 16-char hex prefix of SHA-256", () => { + // Stability matters: the hash is stored in rows and used in where() strings + const h1 = hashRepoRoot("/home/user/project"); + const h2 = hashRepoRoot("/home/user/project"); + expect(h1).toBe(h2); + expect(h1).toMatch(/^[0-9a-f]{16}$/); + expect(hashRepoRoot("/other")).not.toBe(h1); + }); + + it("builds namespace strings for each class", () => { + expect(repoNamespace("/repo")).toBe(`repo:${hashRepoRoot("/repo")}`); + expect(sessionNamespace("abc-123")).toBe("session:abc-123"); + expect(agentNamespace("codex")).toBe("agent:codex"); + }); + + it("classifies namespaces into decay-policy classes", () => { + expect(namespaceClass("global")).toBe("global"); + expect(namespaceClass(repoNamespace("/x"))).toBe("repo"); + expect(namespaceClass("session:s1")).toBe("session"); + expect(namespaceClass("agent:claude")).toBe("agent"); + // Unknown prefixes fall back to the most conservative (slowest-decay) class + expect(namespaceClass("weird")).toBe("global"); + }); + + it("distinguishes namespace strings from bare session ids", () => { + expect(isNamespaceString("global")).toBe(true); + expect(isNamespaceString("session:s1")).toBe(true); + expect(isNamespaceString("repo:abcd")).toBe(true); + // A bare UUID-ish session id is not a namespace + expect(isNamespaceString("f2b8d9a0-1")).toBe(false); + }); +}); + +describe("namespace backfill rules (§3.5.1)", () => { + it("fragments: repoRoot present → repo:, else session:", () => { + expect(backfillFragmentNamespace("/repo", "s1")).toBe(repoNamespace("/repo")); + expect(backfillFragmentNamespace("", "s1")).toBe("session:s1"); + }); + + it("consolidated rows: repoRoot === '' → global", () => { + expect(backfillKnowledgeNamespace("/repo")).toBe(repoNamespace("/repo")); + expect(backfillKnowledgeNamespace("")).toBe("global"); + }); +}); + +describe("zero-vector detection (§1.6)", () => { + it("treats missing, empty, and all-zero vectors as zero", () => { + expect(isZeroVector(undefined)).toBe(true); + expect(isZeroVector([])).toBe(true); + expect(isZeroVector([0, 0, 0])).toBe(true); + expect(isZeroVector(new Float32Array([0, 0]))).toBe(true); + }); + + it("recognizes real vectors, including typed arrays", () => { + expect(isZeroVector([0, 0.1, 0])).toBe(false); + expect(isZeroVector(new Float32Array([1, 0]))).toBe(false); + }); + + it("toNumberArray normalizes plain arrays and TypedArrays", () => { + expect(toNumberArray([1, 2])).toEqual([1, 2]); + expect(toNumberArray(new Float32Array([1, 2]))).toEqual([1, 2]); + expect(toNumberArray(null)).toEqual([]); + }); +}); + +describe("meta.json versioning (§3.5)", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "memory-meta-test-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("returns null when meta.json is absent (= schema v1)", () => { + expect(readMemoryMeta(dir)).toBeNull(); + }); + + it("round-trips meta through write/read", () => { + const meta: MemoryMeta = { + schemaVersion: MEMORY_SCHEMA_VERSION, + embeddingProvider: "openai", + dim: 1536, + activeFragmentsTable: "fragments_v2", + activeConsolidatedTable: "consolidated_v2", + updatedAt: 42, + }; + writeMemoryMeta(dir, meta); + expect(existsSync(metaPath(dir))).toBe(true); + expect(readMemoryMeta(dir)).toEqual(meta); + // File is human-readable JSON (debugging aid) + expect(JSON.parse(readFileSync(metaPath(dir), "utf-8")).schemaVersion).toBe(2); + }); + + it("treats corrupt or shape-less meta files as v1 (null)", () => { + writeMemoryMeta(dir, { garbage: true } as unknown as MemoryMeta); + expect(readMemoryMeta(dir)).toBeNull(); + }); +}); diff --git a/web/server/memory-migration.ts b/web/server/memory-migration.ts new file mode 100644 index 0000000..3f284dc --- /dev/null +++ b/web/server/memory-migration.ts @@ -0,0 +1,468 @@ +/** + * Semantic memory schema v2 — meta.json versioning, namespaces, and the + * v1 → v2 / dimension-change migrations (design doc §3.1 and §3.5). + * + * Layout under the memory root (default ~/.campfire/memory/): + * meta.json — { schemaVersion, embeddingProvider, dim, active table names } + * lancedb/ — LanceDB database directory (tables: fragments_v2, consolidated_v2, + * fragments_v2_ after a dimension change, plus retained v1 + * tables "fragments"/"consolidated" kept as backups) + * + * Migration properties (per §3.5): + * - Versioned tables, never in-place ALTER. + * - v1 rows are copied with namespace backfill (repoRoot → repo:, else + * session:; consolidated rows with repoRoot === "" → global). + * - Zero-vector rows are detected and marked embeddingStatus = "pending" so + * they are excluded from ANN (§1.6 fix) and lazily re-embedded. + * - v1 tables are retained untouched as backups. (The installed LanceDB SDK + * has no renameTable, so instead of renaming to *_v1_backup we simply never + * open them again once meta.schemaVersion >= 2.) + * - meta.json is written only after a successful copy, so an interrupted + * migration re-runs from scratch (partially-copied v2 tables are dropped). + * - Dimension/provider changes create a new active table fragments_v2_ + * and mark all rows "pending" for the lazy re-embed queue. + */ + +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +// ─── Namespace model (§3.1) ────────────────────────────────────────────────── + +export type NamespaceClass = "global" | "repo" | "session" | "agent"; + +/** Short SHA-256 of the absolute repo root — stable, path-privacy-friendly, safe in where() strings. */ +export function hashRepoRoot(repoRoot: string): string { + return createHash("sha256").update(repoRoot).digest("hex").slice(0, 16); +} + +export function repoNamespace(repoRoot: string): string { + return `repo:${hashRepoRoot(repoRoot)}`; +} + +export function sessionNamespace(sessionId: string): string { + return `session:${sessionId}`; +} + +export function agentNamespace(backendType: string): string { + return `agent:${backendType}`; +} + +/** Classify a namespace string into its decay-policy class. Unknown prefixes fall back to "global". */ +export function namespaceClass(namespace: string): NamespaceClass { + if (namespace.startsWith("repo:")) return "repo"; + if (namespace.startsWith("session:")) return "session"; + if (namespace.startsWith("agent:")) return "agent"; + return "global"; +} + +/** True when the string is a namespace (vs. a bare session id). */ +export function isNamespaceString(value: string): boolean { + return value === "global" || value.includes(":"); +} + +// ─── meta.json ─────────────────────────────────────────────────────────────── + +export const MEMORY_SCHEMA_VERSION = 2; + +/** Vector-column width used when no embedding provider is configured. */ +export const NO_PROVIDER_DIM = 1; + +export interface MemoryMeta { + schemaVersion: number; + embeddingProvider: string; + /** Width of the vector column on the active tables. */ + dim: number; + activeFragmentsTable: string; + activeConsolidatedTable: string; + /** v1 table names retained as backups (never opened again). */ + v1BackupTables?: string[]; + migratedFromV1?: boolean; + updatedAt: number; +} + +export function metaPath(memoryRoot: string): string { + return join(memoryRoot, "meta.json"); +} + +export function readMemoryMeta(memoryRoot: string): MemoryMeta | null { + try { + const p = metaPath(memoryRoot); + if (!existsSync(p)) return null; + const raw = JSON.parse(readFileSync(p, "utf-8")) as Partial; + if (typeof raw?.schemaVersion !== "number") return null; + return raw as MemoryMeta; + } catch { + return null; + } +} + +export function writeMemoryMeta(memoryRoot: string, meta: MemoryMeta): void { + mkdirSync(memoryRoot, { recursive: true }); + writeFileSync(metaPath(memoryRoot), JSON.stringify(meta, null, 2), "utf-8"); +} + +// ─── Vector helpers ────────────────────────────────────────────────────────── + +/** Normalize an Arrow/TypedArray/plain vector value into number[]. */ +export function toNumberArray(value: unknown): number[] { + if (value == null) return []; + if (Array.isArray(value)) return value as number[]; + if (ArrayBuffer.isView(value)) return Array.from(value as Float32Array); + const maybe = value as { toArray?: () => ArrayLike }; + if (typeof maybe.toArray === "function") return Array.from(maybe.toArray()); + return []; +} + +/** A vector that is missing, empty, or all zeros carries no embedding (§1.6). */ +export function isZeroVector(value: unknown): boolean { + const arr = toNumberArray(value); + if (arr.length === 0) return true; + return arr.every((v) => v === 0); +} + +// ─── LanceDB row shapes ────────────────────────────────────────────────────── + +type LanceDB = typeof import("@lancedb/lancedb"); +type LanceConnection = Awaited>; +type LanceTable = Awaited>; + +/** Build a v2 fragments seed row (used to establish the table schema). */ +export function fragmentSeedRow(dim: number): Record { + return { + id: "__seed__", + sessionId: "", + agentId: "", + backendType: "claude", + timestamp: 0, + type: "observation", + content: "", + gitContextJson: "{}", + referencesJson: "[]", + tagsJson: "[]", + confidence: 0, + consolidatedInto: "", + isConsolidated: false, + namespace: "", + repoRoot: "", + repoRootHash: "", + lastReinforcedAt: 0, + accessCount: 0, + pinned: false, + // 0 = no per-fragment override (null in the domain model) + halfLifeHours: 0, + embeddingStatus: "none", + vector: Array(dim).fill(0) as number[], + }; +} + +/** Build a v2 consolidated-knowledge seed row. */ +export function consolidatedSeedRow(dim: number): Record { + return { + id: "__seed__", + tag: "", + summary: "", + sourceFragmentsJson: "[]", + lastUpdated: 0, + confidence: 0, + repoRoot: "", + namespace: "", + repoRootHash: "", + knowledgeType: "", + synthesisMethod: "", + supersededBy: "", + accessCount: 0, + lastReinforcedAt: 0, + embeddingStatus: "none", + vector: Array(dim).fill(0) as number[], + }; +} + +async function createSeededTable( + db: LanceConnection, + name: string, + seed: Record, +): Promise { + const table = await db.createTable(name, [seed]); + await table.delete('id = "__seed__"'); + return table; +} + +async function readAllRows(table: LanceTable, limit = 100000): Promise[]> { + const rows = await table.query().limit(limit).toArray(); + return rows as unknown as Record[]; +} + +async function addInChunks(table: LanceTable, rows: Record[], chunk = 500): Promise { + for (let i = 0; i < rows.length; i += chunk) { + await table.add(rows.slice(i, i + chunk)); + } +} + +// ─── v1 → v2 row transforms ────────────────────────────────────────────────── + +function str(v: unknown): string { + return typeof v === "string" ? v : ""; +} + +function num(v: unknown): number { + return typeof v === "number" && Number.isFinite(v) ? v : 0; +} + +/** Backfill rule (§3.5.1): repoRoot present → repo:; else session:. */ +export function backfillFragmentNamespace(repoRoot: string, sessionId: string): string { + if (repoRoot) return repoNamespace(repoRoot); + return sessionNamespace(sessionId); +} + +/** Consolidated rows: repoRoot present → repo:; repoRoot === "" → global. */ +export function backfillKnowledgeNamespace(repoRoot: string): string { + return repoRoot ? repoNamespace(repoRoot) : "global"; +} + +function migrateFragmentRow( + row: Record, + targetDim: number, + providerDim: number | null, +): Record { + let repoRoot = ""; + try { + const git = JSON.parse(str(row.gitContextJson) || "{}") as { repoRoot?: string }; + repoRoot = typeof git.repoRoot === "string" ? git.repoRoot : ""; + } catch { + repoRoot = ""; + } + const sessionId = str(row.sessionId); + const vec = toNumberArray(row.vector); + const zero = isZeroVector(vec); + + // Vector carries over only when it is non-zero AND already matches the + // active dimension; otherwise it must be re-embedded (status "pending"). + const keepVector = !zero && vec.length === targetDim && providerDim !== null && providerDim === targetDim; + return { + id: str(row.id), + sessionId, + agentId: str(row.agentId), + backendType: str(row.backendType) || "claude", + timestamp: num(row.timestamp), + type: str(row.type) || "observation", + content: str(row.content), + gitContextJson: str(row.gitContextJson) || "{}", + referencesJson: str(row.referencesJson) || "[]", + tagsJson: str(row.tagsJson) || "[]", + confidence: num(row.confidence), + consolidatedInto: str(row.consolidatedInto), + isConsolidated: row.isConsolidated === true, + namespace: backfillFragmentNamespace(repoRoot, sessionId), + repoRoot, + repoRootHash: repoRoot ? hashRepoRoot(repoRoot) : "", + lastReinforcedAt: num(row.timestamp), + accessCount: 0, + pinned: false, + halfLifeHours: 0, + embeddingStatus: keepVector ? "ok" : "pending", + vector: keepVector ? vec : (Array(targetDim).fill(0) as number[]), + }; +} + +function migrateConsolidatedRow(row: Record, targetDim: number): Record { + const repoRoot = str(row.repoRoot); + return { + id: str(row.id), + tag: str(row.tag), + summary: str(row.summary), + sourceFragmentsJson: str(row.sourceFragmentsJson) || "[]", + lastUpdated: num(row.lastUpdated), + confidence: num(row.confidence), + repoRoot, + namespace: backfillKnowledgeNamespace(repoRoot), + repoRootHash: repoRoot ? hashRepoRoot(repoRoot) : "", + knowledgeType: "", + // v1 synthesize() was concatenation + synthesisMethod: "concat", + supersededBy: "", + accessCount: 0, + lastReinforcedAt: num(row.lastUpdated), + // v1 had no vector column on consolidated rows — needs embedding + embeddingStatus: "pending", + vector: Array(targetDim).fill(0) as number[], + }; +} + +// ─── Migration entry point ─────────────────────────────────────────────────── + +export interface EnsureSchemaOptions { + db: LanceConnection; + memoryRoot: string; + /** Current embedding provider name from settings ("openai" | "ollama" | "none"). */ + provider: string; + /** Current provider dim, or null when provider is "none". */ + providerDim: number | null; +} + +export interface EnsuredSchema { + meta: MemoryMeta; + fragments: LanceTable; + consolidated: LanceTable; +} + +/** + * Ensure the on-disk store is at schema v2 and consistent with the currently + * configured embedding provider. Handles, in order: + * 1. fresh install (no meta, no tables) + * 2. v1 → v2 copy migration with namespace backfill + zero-vector detection + * 3. provider/dimension changes on an existing v2 store + */ +export async function ensureSchemaV2(opts: EnsureSchemaOptions): Promise { + const { db, memoryRoot, provider, providerDim } = opts; + let meta = readMemoryMeta(memoryRoot); + const tableNames = await db.tableNames(); + + if (!meta || meta.schemaVersion < MEMORY_SCHEMA_VERSION) { + meta = await migrateToV2(db, memoryRoot, tableNames, provider, providerDim); + } + + meta = await reconcileProviderChange(db, memoryRoot, meta, provider, providerDim); + + const fragments = await db.openTable(meta.activeFragmentsTable); + const consolidated = await db.openTable(meta.activeConsolidatedTable); + return { meta, fragments, consolidated }; +} + +async function migrateToV2( + db: LanceConnection, + memoryRoot: string, + tableNames: string[], + provider: string, + providerDim: number | null, +): Promise { + const hasV1Fragments = tableNames.includes("fragments"); + const hasV1Consolidated = tableNames.includes("consolidated"); + + // Interrupted-migration safety: v2 tables without meta.json are partial — drop and redo. + for (const name of tableNames) { + if (name.startsWith("fragments_v2") || name.startsWith("consolidated_v2")) { + await db.dropTable(name); + } + } + + // Determine the active vector dimension: prefer the configured provider's + // dim; with no provider, inherit the v1 dim (keeps migrated vectors intact) + // or fall back to the placeholder width. + let v1Dim: number | null = null; + let v1FragmentRows: Record[] = []; + if (hasV1Fragments) { + const v1 = await db.openTable("fragments"); + v1FragmentRows = await readAllRows(v1); + const withVec = v1FragmentRows.find((r) => toNumberArray(r.vector).length > 0); + v1Dim = withVec ? toNumberArray(withVec.vector).length : null; + } + const dim = providerDim ?? v1Dim ?? NO_PROVIDER_DIM; + + const fragments = await createSeededTable(db, "fragments_v2", fragmentSeedRow(dim)); + const consolidated = await createSeededTable(db, "consolidated_v2", consolidatedSeedRow(dim)); + + if (v1FragmentRows.length > 0) { + await addInChunks(fragments, v1FragmentRows.map((r) => migrateFragmentRow(r, dim, providerDim))); + } + if (hasV1Consolidated) { + const v1c = await db.openTable("consolidated"); + const rows = await readAllRows(v1c); + if (rows.length > 0) { + await addInChunks(consolidated, rows.map((r) => migrateConsolidatedRow(r, dim))); + } + } + + const backups: string[] = []; + if (hasV1Fragments) backups.push("fragments"); + if (hasV1Consolidated) backups.push("consolidated"); + + // meta.json written last — an interrupted copy re-runs from scratch. + const meta: MemoryMeta = { + schemaVersion: MEMORY_SCHEMA_VERSION, + embeddingProvider: provider, + dim, + activeFragmentsTable: "fragments_v2", + activeConsolidatedTable: "consolidated_v2", + v1BackupTables: backups.length > 0 ? backups : undefined, + migratedFromV1: backups.length > 0 || undefined, + updatedAt: Date.now(), + }; + writeMemoryMeta(memoryRoot, meta); + return meta; +} + +/** + * Handle provider/dimension changes on an existing v2 store (§3.5.2): + * - provider → "none": keep tables; record provider; new rows get status "none". + * - same dim, different provider: embeddings are model-specific — mark all + * "ok" rows "pending" in place for re-embedding. + * - different dim: create fragments_v2_/consolidated_v2_ as the + * active tables, copy rows with zero vectors + status "pending". + */ +async function reconcileProviderChange( + db: LanceConnection, + memoryRoot: string, + meta: MemoryMeta, + provider: string, + providerDim: number | null, +): Promise { + if (provider === meta.embeddingProvider) return meta; + + if (providerDim === null) { + // Real provider → none: nothing to re-embed; just record it. + const next = { ...meta, embeddingProvider: provider, updatedAt: Date.now() }; + writeMemoryMeta(memoryRoot, next); + return next; + } + + if (providerDim === meta.dim) { + // Same width, different model — existing vectors are not comparable. + for (const name of [meta.activeFragmentsTable, meta.activeConsolidatedTable]) { + const table = await db.openTable(name); + await table.update({ where: "embeddingStatus = 'ok'", values: { embeddingStatus: "pending" } }); + } + const next = { ...meta, embeddingProvider: provider, updatedAt: Date.now() }; + writeMemoryMeta(memoryRoot, next); + return next; + } + + // Dimension change: versioned new active tables, rows queued for re-embed. + const newFragName = `fragments_v2_${providerDim}`; + const newConsName = `consolidated_v2_${providerDim}`; + const existing = await db.tableNames(); + // Re-created from the current active tables (source of truth) if left over + // from a previous switch. + if (existing.includes(newFragName)) await db.dropTable(newFragName); + if (existing.includes(newConsName)) await db.dropTable(newConsName); + + const newFragments = await createSeededTable(db, newFragName, fragmentSeedRow(providerDim)); + const newConsolidated = await createSeededTable(db, newConsName, consolidatedSeedRow(providerDim)); + + const oldFragments = await db.openTable(meta.activeFragmentsTable); + const fragRows = await readAllRows(oldFragments); + await addInChunks(newFragments, fragRows.map((r) => ({ + ...r, + vector: Array(providerDim).fill(0) as number[], + embeddingStatus: "pending", + }))); + + const oldConsolidated = await db.openTable(meta.activeConsolidatedTable); + const consRows = await readAllRows(oldConsolidated); + await addInChunks(newConsolidated, consRows.map((r) => ({ + ...r, + vector: Array(providerDim).fill(0) as number[], + embeddingStatus: "pending", + }))); + + const next: MemoryMeta = { + ...meta, + embeddingProvider: provider, + dim: providerDim, + activeFragmentsTable: newFragName, + activeConsolidatedTable: newConsName, + updatedAt: Date.now(), + }; + writeMemoryMeta(memoryRoot, next); + return next; +} diff --git a/web/server/routes/ci-routes.test.ts b/web/server/routes/ci-routes.test.ts new file mode 100644 index 0000000..bbca032 --- /dev/null +++ b/web/server/routes/ci-routes.test.ts @@ -0,0 +1,276 @@ +/** + * Tests for the semantic-memory REST endpoints in ci-routes.ts. + * + * Covers the semantic-memory v2 wiring (design doc §1.8 + §3.4): + * - GET /sessions/:id/memory — §1.8 fix: consolidated knowledge + * comes from the global namespace PLUS repo:; + * the old getConsolidatedKnowledge("") matched nothing. + * - GET /memory/global — §1.8 fix: explicit global namespace. + * - GET /sessions/:id/memory/overview — frontend contract (MemoryOverviewResponse). + * - POST /memory/pin — frontend contract ({ ok: boolean }). + * - POST /sessions/:id/memory/consolidate — §3.4 trigger 4 (manual) routes + * through the consolidation pipeline and returns its ConsolidationResult. + * + * semantic-memory and memory-consolidation are mocked: these tests assert the + * route wiring and response shapes, not LanceDB/pipeline behavior. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; +import { registerCiRoutes } from "./ci-routes.js"; +import * as semanticMemory from "../semantic-memory.js"; +import * as memoryConsolidation from "../memory-consolidation.js"; + +vi.mock("../semantic-memory.js", () => ({ + getSessionFragments: vi.fn(async () => [{ id: "frag-1", content: "session fragment" }]), + // v2 semantics: "" resolves to the global namespace, a path to repo: + getConsolidatedKnowledge: vi.fn(async (repoRoot: string) => + repoRoot === "" + ? [{ id: "k-global", tag: "conventions", summary: "global knowledge", confidence: 0.8, repoRoot: "", namespace: "global" }] + : [{ id: "k-repo", tag: "arch", summary: "repo knowledge", confidence: 0.9, repoRoot, namespace: `repo:hash-${repoRoot}` }], + ), + getKnowledgeByNamespace: vi.fn(async (namespace: string) => [ + { + id: `k-${namespace}`, + tag: "arch", + summary: `knowledge in ${namespace}`, + confidence: 0.9, + repoRoot: "/repo", + namespace, + synthesisMethod: "llm", + sourceFragments: [], + lastUpdated: 1, + }, + ]), + getNamespaceOverview: vi.fn(async () => [ + { namespace: "session:s1", count: 3, avgWeight: 0.7, pinnedCount: 0 }, + { namespace: "global", count: 10, avgWeight: 0.5, pinnedCount: 2 }, + ]), + setFragmentPinned: vi.fn(async () => true), + repoNamespace: vi.fn((repoRoot: string) => `repo:hash-${repoRoot}`), + storeFragment: vi.fn(async (opts: Record) => ({ id: "frag-new", ...opts })), + queryFragments: vi.fn(async () => []), + consolidateSession: vi.fn(async () => []), +})); + +vi.mock("../memory-consolidation.js", () => ({ + consolidate: vi.fn(async (ctx: { reason: string }) => ({ + status: "ran", + synthesisMethod: "llm", + knowledgeUpserted: 2, + fragmentsConsolidated: 5, + reason: ctx.reason, + })), + shouldConsolidateOnTurn: vi.fn(async () => false), + noteSessionActivity: vi.fn(), + stopIdleWatcher: vi.fn(), +})); + +// ─── Test setup ────────────────────────────────────────────────────────────── + +let app: Hono; +let getSession: ReturnType; + +beforeEach(() => { + // clearAllMocks resets call history only — the factory implementations + // above survive, so every test starts from the same store behavior. + vi.clearAllMocks(); + getSession = vi.fn(() => ({ + id: "s1", + backendType: "codex", + state: { cwd: "/repo", repo_root: "/repo", backend_type: "codex" }, + })); + app = new Hono(); + registerCiRoutes(app, { wsBridge: { getSession, getConnectedSessionIds: vi.fn(() => []) } } as any); +}); + +// ─── GET /sessions/:id/memory ──────────────────────────────────────────────── + +describe("GET /sessions/:id/memory", () => { + it("returns session fragments plus consolidated knowledge from repo AND global namespaces", async () => { + // §1.8 fix: the old handler called getConsolidatedKnowledge("") which + // matched only literally-empty repoRoot rows (nothing). It must now merge + // repo-scoped knowledge (repo:) with global. + const res = await app.request("/sessions/s1/memory"); + + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.fragments).toEqual([{ id: "frag-1", content: "session fragment" }]); + // Repo knowledge ranked before global + expect(json.consolidated.map((k: { id: string }) => k.id)).toEqual(["k-repo", "k-global"]); + expect(semanticMemory.getConsolidatedKnowledge).toHaveBeenCalledWith(""); + expect(semanticMemory.getConsolidatedKnowledge).toHaveBeenCalledWith("/repo"); + }); + + it("falls back to global-only knowledge when the session (and its cwd) is unknown", async () => { + getSession.mockReturnValue(null); + + const res = await app.request("/sessions/ghost/memory"); + + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.consolidated.map((k: { id: string }) => k.id)).toEqual(["k-global"]); + // No repo-scoped lookup without a repoRoot + expect(semanticMemory.getConsolidatedKnowledge).toHaveBeenCalledTimes(1); + expect(semanticMemory.getConsolidatedKnowledge).toHaveBeenCalledWith(""); + }); +}); + +// ─── GET /memory/global ────────────────────────────────────────────────────── + +describe("GET /memory/global", () => { + it("queries the global namespace explicitly (v2 semantics), passing the tag filter", async () => { + const res = await app.request("/memory/global?tag=arch"); + + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.knowledge).toHaveLength(1); + expect(json.knowledge[0].namespace).toBe("global"); + expect(semanticMemory.getKnowledgeByNamespace).toHaveBeenCalledWith("global", "arch"); + }); +}); + +// ─── GET /sessions/:id/memory/overview ─────────────────────────────────────── + +describe("GET /sessions/:id/memory/overview", () => { + it("returns the frontend MemoryOverviewResponse shape (namespaces + knowledge)", async () => { + const res = await app.request("/sessions/s1/memory/overview"); + + expect(res.status).toBe(200); + const json = await res.json(); + + // namespaces come straight from getNamespaceOverview, called with the + // session context (repoRoot + backendType plumbing, §3.6 item 5) + expect(json.namespaces).toEqual([ + { namespace: "session:s1", count: 3, avgWeight: 0.7, pinnedCount: 0 }, + { namespace: "global", count: 10, avgWeight: 0.5, pinnedCount: 2 }, + ]); + expect(semanticMemory.getNamespaceOverview).toHaveBeenCalledWith({ + sessionId: "s1", + repoRoot: "/repo", + backendType: "codex", + }); + + // knowledge is mapped to the exact frontend contract fields — repo + // namespace first, then global + expect(json.knowledge).toEqual([ + { + id: "k-repo:hash-/repo", + tag: "arch", + summary: "knowledge in repo:hash-/repo", + confidence: 0.9, + namespace: "repo:hash-/repo", + synthesisMethod: "llm", + }, + { + id: "k-global", + tag: "arch", + summary: "knowledge in global", + confidence: 0.9, + namespace: "global", + synthesisMethod: "llm", + }, + ]); + }); + + it("skips the repo namespace when the session has no cwd", async () => { + getSession.mockReturnValue({ id: "s1", backendType: "claude", state: { cwd: "", repo_root: "" } }); + + const res = await app.request("/sessions/s1/memory/overview"); + + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.knowledge.map((k: { namespace: string }) => k.namespace)).toEqual(["global"]); + expect(semanticMemory.getKnowledgeByNamespace).toHaveBeenCalledTimes(1); + }); +}); + +// ─── POST /memory/pin ──────────────────────────────────────────────────────── + +describe("POST /memory/pin", () => { + it("pins a fragment and returns { ok: true }", async () => { + const res = await app.request("/memory/pin", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "frag-1", pinned: true }), + }); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ ok: true }); + expect(semanticMemory.setFragmentPinned).toHaveBeenCalledWith("frag-1", true); + }); + + it("unpins a fragment (pinned: false is a valid body, not a missing field)", async () => { + const res = await app.request("/memory/pin", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "frag-1", pinned: false }), + }); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ ok: true }); + expect(semanticMemory.setFragmentPinned).toHaveBeenCalledWith("frag-1", false); + }); + + it("returns ok: false when the fragment does not exist", async () => { + vi.mocked(semanticMemory.setFragmentPinned).mockResolvedValueOnce(false); + + const res = await app.request("/memory/pin", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "ghost", pinned: true }), + }); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ ok: false }); + }); + + it("rejects requests missing id or pinned with 400", async () => { + for (const body of [{}, { id: "frag-1" }, { pinned: true }, { id: "frag-1", pinned: "yes" }]) { + const res = await app.request("/memory/pin", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(400); + } + expect(semanticMemory.setFragmentPinned).not.toHaveBeenCalled(); + }); +}); + +// ─── POST /sessions/:id/memory/consolidate ─────────────────────────────────── + +describe("POST /sessions/:id/memory/consolidate", () => { + it("routes through consolidate({ reason: 'manual' }) and returns its ConsolidationResult", async () => { + const res = await app.request("/sessions/s1/memory/consolidate", { method: "POST" }); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + status: "ran", + synthesisMethod: "llm", + knowledgeUpserted: 2, + fragmentsConsolidated: 5, + reason: "manual", + }); + expect(memoryConsolidation.consolidate).toHaveBeenCalledWith({ + sessionId: "s1", + repoRoot: "/repo", + backendType: "codex", + reason: "manual", + }); + }); + + it("defaults backendType to claude and repoRoot to '' for unknown sessions", async () => { + getSession.mockReturnValue(null); + + const res = await app.request("/sessions/ghost/memory/consolidate", { method: "POST" }); + + expect(res.status).toBe(200); + expect(memoryConsolidation.consolidate).toHaveBeenCalledWith({ + sessionId: "ghost", + repoRoot: "", + backendType: "claude", + reason: "manual", + }); + }); +}); diff --git a/web/server/routes/ci-routes.ts b/web/server/routes/ci-routes.ts index 52d6413..030c8e8 100644 --- a/web/server/routes/ci-routes.ts +++ b/web/server/routes/ci-routes.ts @@ -5,15 +5,26 @@ import type { BackendType } from "../session-types.js"; export function registerCiRoutes(api: Hono, deps: RouteDeps): void { const { wsBridge } = deps; + /** repoRoot for a session — the namespace anchor for repo-scoped memory. */ + const sessionRepoRoot = (sessionId: string): string => { + const session = wsBridge.getSession(sessionId); + return session?.state.repo_root || session?.state.cwd || ""; + }; + // ─── Collective Intelligence: Semantic Memory ────────────────────────────── api.get("/sessions/:id/memory", async (c) => { - const { queryFragments, getConsolidatedKnowledge, getSessionFragments } = await import("../semantic-memory.js"); + const { getConsolidatedKnowledge, getSessionFragments } = await import("../semantic-memory.js"); const sessionId = c.req.param("id"); - const [fragments, consolidated] = await Promise.all([ + // §1.8 fix: consolidated knowledge lives in namespaces now. "" resolves to + // the `global` namespace; repo-scoped rows come from repo: — the old getConsolidatedKnowledge("") matched nothing. + const repoRoot = sessionRepoRoot(sessionId); + const [fragments, globalKnowledge, repoKnowledge] = await Promise.all([ getSessionFragments(sessionId), - getConsolidatedKnowledge(""), // cross-session consolidated + getConsolidatedKnowledge(""), // global namespace + repoRoot ? getConsolidatedKnowledge(repoRoot) : Promise.resolve([]), ]); - return c.json({ fragments, consolidated }); + return c.json({ fragments, consolidated: [...repoKnowledge, ...globalKnowledge] }); }); api.post("/sessions/:id/memory", async (c) => { @@ -43,21 +54,68 @@ export function registerCiRoutes(api: Hono, deps: RouteDeps): void { }); api.post("/sessions/:id/memory/consolidate", async (c) => { - const { consolidateSession } = await import("../semantic-memory.js"); + // §3.4 trigger 4: manual consolidation routes through the JUDGE → DISTILL + // → CONSOLIDATE pipeline and returns its ConsolidationResult. + const { consolidate } = await import("../memory-consolidation.js"); const sessionId = c.req.param("id"); const session = wsBridge.getSession(sessionId); - const repoRoot = session?.state.cwd ?? ""; - const consolidated = await consolidateSession(sessionId, repoRoot); - return c.json({ consolidated, count: consolidated.length }); + const result = await consolidate({ + sessionId, + repoRoot: sessionRepoRoot(sessionId), + backendType: session?.backendType ?? session?.state.backend_type ?? "claude", + reason: "manual", + }); + return c.json(result); }); api.get("/memory/global", async (c) => { - const { getConsolidatedKnowledge } = await import("../semantic-memory.js"); + // §1.8 fix: query the `global` namespace explicitly (v2 semantics) — + // the old getConsolidatedKnowledge("") matched only literally-empty + // repoRoot rows, i.e. nothing. + const { getKnowledgeByNamespace } = await import("../semantic-memory.js"); const tag = c.req.query("tag"); - const knowledge = await getConsolidatedKnowledge("", tag); + const knowledge = await getKnowledgeByNamespace("global", tag); return c.json({ knowledge }); }); + // Per-namespace fragment stats + active consolidated knowledge for the + // memory panel. Shape is the frontend contract (api.ts MemoryOverviewResponse): + // { namespaces: [{namespace, count, avgWeight, pinnedCount}], + // knowledge: [{id, tag, summary, confidence, namespace, synthesisMethod?}] } + api.get("/sessions/:id/memory/overview", async (c) => { + const { getNamespaceOverview, getKnowledgeByNamespace, repoNamespace } = await import("../semantic-memory.js"); + const sessionId = c.req.param("id"); + const session = wsBridge.getSession(sessionId); + const repoRoot = sessionRepoRoot(sessionId); + const backendType = session?.backendType ?? session?.state.backend_type ?? "claude"; + + const namespaces = await getNamespaceOverview({ sessionId, repoRoot, backendType }); + const knowledgeRows = [ + ...(repoRoot ? await getKnowledgeByNamespace(repoNamespace(repoRoot)) : []), + ...(await getKnowledgeByNamespace("global")), + ]; + const knowledge = knowledgeRows.map((k) => ({ + id: k.id, + tag: k.tag, + summary: k.summary, + confidence: k.confidence, + namespace: k.namespace ?? "", + synthesisMethod: k.synthesisMethod, + })); + return c.json({ namespaces, knowledge }); + }); + + // Pin/unpin a memory fragment (§3.2: pinned rows never decay or get evicted). + api.post("/memory/pin", async (c) => { + const { setFragmentPinned } = await import("../semantic-memory.js"); + const body = await c.req.json() as { id?: string; pinned?: boolean }; + if (!body.id || typeof body.pinned !== "boolean") { + return c.json({ error: "id and pinned are required" }, 400); + } + const ok = await setFragmentPinned(body.id, body.pinned); + return c.json({ ok }); + }); + // ─── Collective Intelligence: Deliberation ───────────────────────────────── api.get("/sessions/:id/deliberations", (c) => { const { deliberationEngine } = require("../deliberation-engine.js") as typeof import("../deliberation-engine.js"); diff --git a/web/server/routes/settings-routes.ts b/web/server/routes/settings-routes.ts index 2376c1d..33272b1 100644 --- a/web/server/routes/settings-routes.ts +++ b/web/server/routes/settings-routes.ts @@ -15,6 +15,8 @@ function settingsResponse(s: CampfireSettings) { openaiApiKeyConfigured: !!s.openaiApiKey?.trim(), anthropicApiKeyConfigured: !!s.anthropicApiKey?.trim(), onboardingCompleted: s.onboardingCompleted, + // Semantic memory v2: decay policies + recall depths (not secret — full values) + memory: s.memory, }; } @@ -57,13 +59,19 @@ export function registerSettingsRoutes(api: Hono, _deps: RouteDeps): void { } const hasOnboarding = typeof body.onboardingCompleted === "boolean"; - const hasAnyField = STRING_FIELDS.some((f) => body[f] !== undefined) || hasOnboarding; + const hasMemory = body.memory !== undefined; + if (hasMemory && (typeof body.memory !== "object" || body.memory === null || Array.isArray(body.memory))) { + return c.json({ error: "memory must be an object" }, 400); + } + const hasAnyField = STRING_FIELDS.some((f) => body[f] !== undefined) || hasOnboarding || hasMemory; if (!hasAnyField) { return c.json({ error: "At least one settings field is required" }, 400); } - const patch: Record = {}; + const patch: Record = {}; if (hasOnboarding) patch.onboardingCompleted = body.onboardingCompleted; + // Partial memory patch — deep-merged + normalized by the settings manager + if (hasMemory) patch.memory = body.memory; for (const field of STRING_FIELDS) { if (typeof body[field] === "string") { patch[field] = field === "openrouterModel" diff --git a/web/server/semantic-memory.test.ts b/web/server/semantic-memory.test.ts index b19adb6..86aebbf 100644 --- a/web/server/semantic-memory.test.ts +++ b/web/server/semantic-memory.test.ts @@ -1,20 +1,32 @@ /** - * Tests for the SemanticMemory layer (Layer 1 of Collective Intelligence). + * Tests for the SemanticMemory layer (Layer 1 of Collective Intelligence) — v2. * * We use a temporary directory for each test to isolate LanceDB state. * Embedding calls are mocked to return predictable vectors, so tests * don't require a real OpenAI or Ollama instance. * - * Key scenarios: - * 1. storeFragment — writes a fragment with a mocked embedding - * 2. queryFragments — retrieves by semantic similarity (vector search) - * 3. queryFragments fallback — metadata filter when no embedding provider - * 4. consolidateSession — groups fragments by tag, synthesizes summaries - * 5. getConsolidatedKnowledge — retrieves consolidated entries by repoRoot/tag + * Covered areas: + * 1. v1-compatible API (storeFragment / queryFragments / consolidateSession / + * getConsolidatedKnowledge) — original tests preserved + * 2. Decay math (§3.2) — table-driven pure-function tests + * 3. Namespace where() strings + SQL quoting (§3.3 pushdown, §1.7 fix) + * 4. v2 storeFragment namespace resolution + embeddingStatus lifecycle (§1.6) + * 5. Scored retrieval: composite score, starvation fix, dedupe, fallback + * 6. Reinforcement, pinning, eviction sweep + hard cap (§3.2) + * 7. Consolidation support APIs (§3.4 Stage 1/3): unconsolidated fetch, + * mark-consolidated, upsert-by-(namespace,tag) + supersession, related + * knowledge, concat fallback idempotency (§1.2 fix) + * 8. Enrichment entry point: §3.6.2 block format, budgets, reinforcement + * 9. Migration (§3.5): v1 → v2 with namespace backfill + zero-vector → + * "pending", dimension change, lazy re-embed queue + * + * v2 harness change note: the embedding mock is hoisted so individual tests + * can switch provider/dim mid-test (needed for provider-change migration + * coverage), and it now includes getEmbeddingProviderName (new v2 export). */ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { mkdirSync, rmSync } from "node:fs"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdirSync, rmSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -22,36 +34,80 @@ import { tmpdir } from "node:os"; // We mock the embedding module so tests don't need a real API. // Deterministic: "auth" text → unit vector in first dim, "database" → second dim. +const mockEmbed = vi.hoisted(() => vi.fn()); +const mockGetDim = vi.hoisted(() => vi.fn()); +const mockProviderName = vi.hoisted(() => vi.fn()); + vi.mock("./embedding.js", () => ({ - embed: vi.fn(async (text: string) => { - // Return a simple deterministic 4-dim vector based on text content - const v = [0, 0, 0, 0]; + embed: mockEmbed, + getEmbeddingDim: mockGetDim, + getEmbeddingProviderName: mockProviderName, + OPENAI_DIM: 1536, + OLLAMA_DIM: 768, +})); + +/** Deterministic keyword → unit-vector embedding with `dim` dimensions. */ +function deterministicEmbed(dim: number) { + return async (text: string): Promise => { + const v = Array(dim).fill(0) as number[]; if (text.toLowerCase().includes("auth")) v[0] = 1; if (text.toLowerCase().includes("database")) v[1] = 1; if (text.toLowerCase().includes("routing")) v[2] = 1; if (text.toLowerCase().includes("cache")) v[3] = 1; const mag = Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1; return v.map((x) => x / mag); - }), - getEmbeddingDim: vi.fn(() => 4), -})); + }; +} -// ─── Tests ──────────────────────────────────────────────────────────────────── +// ─── Test harness ───────────────────────────────────────────────────────────── -describe("SemanticMemory", () => { - let testDir: string; - let memory: typeof import("./semantic-memory.js"); +import * as settingsManager from "./settings-manager.js"; - beforeEach(async () => { - // Fresh LanceDB dir for each test - testDir = join(tmpdir(), `campfire-test-memory-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(testDir, { recursive: true }); +let testDir: string; +let memory: typeof import("./semantic-memory.js"); - // Re-import fresh module (vitest caches modules, use resetModules if needed) - memory = await import("./semantic-memory.js"); - memory._resetForTest(testDir); - }); +beforeEach(async () => { + // Fresh memory root + settings file for each test + testDir = join(tmpdir(), `campfire-test-memory-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); + settingsManager._resetForTest(join(testDir, "settings.json")); + + // Default provider: "openai" with a tiny 4-dim space + mockEmbed.mockImplementation(deterministicEmbed(4)); + mockGetDim.mockReturnValue(4); + mockProviderName.mockReturnValue("openai"); + + memory = await import("./semantic-memory.js"); + memory._resetForTest(testDir); +}); + +afterEach(() => { + settingsManager._resetForTest(); + rmSync(testDir, { recursive: true, force: true }); +}); + +/** Shorthand for storeFragment options. */ +function frag(overrides: Partial[0]> = {}) { + return { + sessionId: "session-1", + agentId: "agent-1", + backendType: "claude" as const, + type: "observation" as const, + content: "auth: generic note", + gitContext: { branch: "main", files: [], repoRoot: "/repo" }, + ...overrides, + }; +} +async function rawTable(name: string) { + const lancedb = await import("@lancedb/lancedb"); + const db = await lancedb.connect(join(testDir, "lancedb")); + return db.openTable(name); +} + +// ─── Original v1-compatible API tests (preserved) ──────────────────────────── + +describe("SemanticMemory", () => { it("stores a memory fragment and returns it with an id", async () => { // Validates that storeFragment writes a MemoryFragment and returns it with a UUID const fragment = await memory.storeFragment({ @@ -247,3 +303,790 @@ describe("SemanticMemory", () => { expect(results[0].backendType).toBe("goose"); }); }); + +// ─── Decay math (§3.2) — pure, table-driven ────────────────────────────────── + +describe("computeDecayedWeight", () => { + const HOUR = 3_600_000; + const policy = { halfLifeHours: 100, reinforceMultiplier: 1.5 }; + const t0 = 1_700_000_000_000; + + it("follows the half-life table", () => { + // w(t) = 0.5^(age / halfLife) — one half-life halves the weight, etc. + const cases: Array<{ ageHours: number; expected: number }> = [ + { ageHours: 0, expected: 1 }, + { ageHours: 50, expected: Math.pow(0.5, 0.5) }, + { ageHours: 100, expected: 0.5 }, + { ageHours: 200, expected: 0.25 }, + { ageHours: 400, expected: 0.0625 }, + ]; + for (const { ageHours, expected } of cases) { + const w = memory.computeDecayedWeight( + { lastReinforcedAt: t0, accessCount: 0 }, + t0 + ageHours * HOUR, + policy, + ); + expect(w).toBeCloseTo(expected, 6); + } + }); + + it("pinned fragments never decay", () => { + const w = memory.computeDecayedWeight( + { pinned: true, lastReinforcedAt: t0 }, + t0 + 100000 * HOUR, + policy, + ); + expect(w).toBe(1); + }); + + it("null half-life (policy) means no decay", () => { + const w = memory.computeDecayedWeight( + { lastReinforcedAt: t0 }, + t0 + 100000 * HOUR, + { halfLifeHours: null, reinforceMultiplier: 1.5 }, + ); + expect(w).toBe(1); + }); + + it("reinforcement extends the effective half-life: halfLife × multiplier^accessCount", () => { + // accessCount 1 with ×1.5 → effective half-life 150h → at age 150h, w = 0.5 + const w = memory.computeDecayedWeight( + { lastReinforcedAt: t0, accessCount: 1 }, + t0 + 150 * HOUR, + policy, + ); + expect(w).toBeCloseTo(0.5, 6); + }); + + it("caps the reinforcement multiplier at accessCount 8", () => { + // accessCount 100 behaves exactly like accessCount 8 — no immortality by accident + const w100 = memory.computeDecayedWeight( + { lastReinforcedAt: t0, accessCount: 100 }, + t0 + 1000 * HOUR, + policy, + ); + const w8 = memory.computeDecayedWeight( + { lastReinforcedAt: t0, accessCount: 8 }, + t0 + 1000 * HOUR, + policy, + ); + expect(w100).toBeCloseTo(w8, 10); + }); + + it("honors a per-fragment halfLifeHours override", () => { + const w = memory.computeDecayedWeight( + { lastReinforcedAt: t0, halfLifeHours: 10 }, + t0 + 10 * HOUR, + policy, + ); + expect(w).toBeCloseTo(0.5, 6); + }); + + it("falls back to timestamp when lastReinforcedAt is missing, and clamps future anchors", () => { + const w = memory.computeDecayedWeight({ timestamp: t0 }, t0 + 100 * HOUR, policy); + expect(w).toBeCloseTo(0.5, 6); + // An anchor in the future must not produce w > 1 + const wFuture = memory.computeDecayedWeight({ lastReinforcedAt: t0 + HOUR }, t0, policy); + expect(wFuture).toBe(1); + }); +}); + +// ─── Namespace where() strings (§3.3 pushdown, §1.7 fix) ───────────────────── + +describe("namespace query planner where() strings", () => { + it("builds the pushed-down namespace + embeddingStatus predicate", () => { + // The exact shape from design §3.3: namespace AND embeddingStatus = 'ok' + expect(memory.buildNamespaceWhere("repo:abc123")).toBe( + "namespace = 'repo:abc123' AND embeddingStatus = 'ok'", + ); + }); + + it("omits the embedding filter for metadata-only scans", () => { + expect(memory.buildNamespaceWhere("global", false)).toBe("namespace = 'global'"); + }); + + it("escapes single quotes in values (SQL-injection-safe where strings)", () => { + expect(memory.sqlQuote("o'brien")).toBe("'o''brien'"); + expect(memory.buildNamespaceWhere("session:o'brien", false)).toBe( + "namespace = 'session:o''brien'", + ); + }); +}); + +// ─── v2 storeFragment: namespaces + embeddingStatus (§3.1, §1.6) ───────────── + +describe("v2 storeFragment", () => { + it("defaults to repo: namespace when a repoRoot is present", async () => { + const f = await memory.storeFragment(frag({ content: "auth: note" })); + expect(f.namespace).toBe(memory.repoNamespace("/repo")); + expect(f.repoRootHash).toBe(memory.hashRepoRoot("/repo")); + expect(f.embeddingStatus).toBe("ok"); + expect(f.accessCount).toBe(0); + expect(f.pinned).toBe(false); + expect(f.lastReinforcedAt).toBe(f.timestamp); + }); + + it("defaults to session: namespace when there is no repoRoot", async () => { + const f = await memory.storeFragment( + frag({ sessionId: "s9", gitContext: { branch: "main", files: [], repoRoot: "" } }), + ); + expect(f.namespace).toBe("session:s9"); + expect(f.repoRootHash).toBe(""); + }); + + it("honors an explicit namespace option", async () => { + const f = await memory.storeFragment(frag({ namespace: "global" })); + expect(f.namespace).toBe("global"); + }); + + it("stores embeddingStatus 'none' when no provider is configured (§1.6 fix)", async () => { + // Provider "none": no fake 1536-dim zero vectors in the ANN index + mockGetDim.mockReturnValue(null); + mockProviderName.mockReturnValue("none"); + mockEmbed.mockResolvedValue(null); + + const f = await memory.storeFragment(frag()); + expect(f.embeddingStatus).toBe("none"); + expect(f.embedding).toBeUndefined(); + }); + + it("stores embeddingStatus 'pending' when the embed call fails with a provider configured", async () => { + mockEmbed.mockResolvedValueOnce(null); // one failed call + const f = await memory.storeFragment(frag({ content: "auth: transient failure" })); + expect(f.embeddingStatus).toBe("pending"); + + // Pending rows are excluded from vector search results (where pushdown) + const results = await memory.queryFragments("auth", { repoRoot: "/repo" }); + expect(results.find((r) => r.id === f.id)).toBeUndefined(); + }); +}); + +// ─── Scored retrieval (§3.3) ───────────────────────────────────────────────── + +describe("scored retrieval", () => { + it("does not starve scoped queries when other scopes dominate the ANN neighborhood (§1.7 fix)", async () => { + // v1 over-fetched limit×3 globally then post-filtered — 10 near-identical + // repo-b rows would evict the single repo-a row from the candidate set. + for (let i = 0; i < 10; i++) { + await memory.storeFragment( + frag({ + sessionId: "sb", + content: `auth: repo-b note ${i}`, + gitContext: { branch: "main", files: [], repoRoot: "/repo-b" }, + }), + ); + } + const target = await memory.storeFragment( + frag({ + sessionId: "sa", + content: "auth: repo-a note", + gitContext: { branch: "main", files: [], repoRoot: "/repo-a" }, + }), + ); + + const results = await memory.queryFragments("auth", { repoRoot: "/repo-a", limit: 2 }); + expect(results.map((r) => r.id)).toContain(target.id); + expect(results.every((r) => r.gitContext.repoRoot === "/repo-a")).toBe(true); + }); + + it("respects per-namespace recall depth in queryScoredFragments", async () => { + // Distinct keyword mixes → distinct vectors (no near-dup collapse) + const contents = ["auth alpha", "auth database", "auth routing", "auth cache", "auth database routing"]; + for (const content of contents) { + await memory.storeFragment(frag({ content })); + } + const ns = memory.repoNamespace("/repo"); + const results = await memory.queryScoredFragments("auth", [{ namespace: ns, depth: 2 }]); + expect(results).toHaveLength(2); + // Best similarity ("auth alpha", simNorm 1) ranks first + expect(results[0].fragment.content).toBe("auth alpha"); + expect(results[0].simNorm).toBeGreaterThan(results[1].simNorm); + }); + + it("weights the composite score by confidence", async () => { + // Same similarity, different confidence → higher confidence wins + const low = await memory.storeFragment(frag({ content: "auth database low", confidence: 0.3 })); + const high = await memory.storeFragment(frag({ content: "auth routing high", confidence: 0.9 })); + const ns = memory.repoNamespace("/repo"); + const results = await memory.queryScoredFragments("auth", [{ namespace: ns, depth: 4 }]); + const ids = results.map((r) => r.fragment.id); + expect(ids.indexOf(high.id)).toBeLessThan(ids.indexOf(low.id)); + }); + + it("ranks reinforced fragments above equally-similar unreinforced ones after decay", async () => { + // Both fragments have simNorm ≈ 0.707 to "auth"; A is reinforced once. + // At one base half-life (repo: 720h), w(B) = 0.5 while w(A) = 0.5^(1/1.5). + const a = await memory.storeFragment(frag({ content: "auth database pattern" })); + const b = await memory.storeFragment(frag({ content: "auth routing pattern" })); + memory.reinforceFragments([a.id]); + await memory.flushReinforcements(); + + const ns = memory.repoNamespace("/repo"); + const future = Date.now() + 720 * 3_600_000; + const results = await memory.queryScoredFragments("auth", [{ namespace: ns, depth: 4 }], future); + const ids = results.map((r) => r.fragment.id); + expect(ids.indexOf(a.id)).toBeLessThan(ids.indexOf(b.id)); + const scoredA = results.find((r) => r.fragment.id === a.id)!; + const scoredB = results.find((r) => r.fragment.id === b.id)!; + expect(scoredA.weight).toBeGreaterThan(scoredB.weight); + }); + + it("dedupes near-duplicate fragments (cosine > 0.97), keeping the higher score", async () => { + await memory.storeFragment(frag({ content: "auth: identical insight", confidence: 0.5 })); + const better = await memory.storeFragment(frag({ content: "auth: identical insight", confidence: 0.9 })); + const ns = memory.repoNamespace("/repo"); + const results = await memory.queryScoredFragments("auth", [{ namespace: ns, depth: 4 }]); + expect(results).toHaveLength(1); + expect(results[0].fragment.id).toBe(better.id); + }); + + it("falls back to w(t)×confidence ranking when no provider is configured", async () => { + // §3.3: never zero-vector search — metadata scan ranked by weight × confidence + mockGetDim.mockReturnValue(null); + mockProviderName.mockReturnValue("none"); + mockEmbed.mockResolvedValue(null); + + const noRepo = { branch: "main", files: [], repoRoot: "" }; + const low = await memory.storeFragment( + frag({ sessionId: "sf", gitContext: noRepo, content: "low value note", confidence: 0.2 }), + ); + const high = await memory.storeFragment( + frag({ sessionId: "sf", gitContext: noRepo, content: "high value note", confidence: 0.9 }), + ); + + const results = await memory.queryScoredFragments("anything", [ + { namespace: "session:sf", depth: 4 }, + ]); + expect(results.map((r) => r.fragment.id)).toEqual([high.id, low.id]); + + // Legacy API takes the same fallback path + const legacy = await memory.queryFragments("anything", { sessionId: "sf" }); + expect(legacy[0].id).toBe(high.id); + }); +}); + +// ─── Reinforcement, pinning, eviction (§3.2) ───────────────────────────────── + +describe("reinforcement and pinning", () => { + it("reinforceFragments batches accessCount+1 and lastReinforcedAt=now", async () => { + const f = await memory.storeFragment(frag({ content: "auth: reinforce me" })); + const before = f.lastReinforcedAt!; + + memory.reinforceFragments([f.id]); + memory.reinforceFragments([f.id, f.id]); // batched increments accumulate + await memory.flushReinforcements(); + + const [row] = await memory.getSessionFragments("session-1"); + expect(row.accessCount).toBe(3); + expect(row.lastReinforcedAt!).toBeGreaterThanOrEqual(before); + }); + + it("setFragmentPinned pins/unpins and reports missing ids", async () => { + const f = await memory.storeFragment(frag({ content: "auth: pin me" })); + expect(await memory.setFragmentPinned(f.id, true)).toBe(true); + const [row] = await memory.getSessionFragments("session-1"); + expect(row.pinned).toBe(true); + expect(await memory.setFragmentPinned("no-such-id", true)).toBe(false); + }); + + it("pinned fragments keep weight 1 no matter how old", async () => { + const f = await memory.storeFragment(frag({ content: "auth: eternal" })); + await memory.setFragmentPinned(f.id, true); + const ns = memory.repoNamespace("/repo"); + const farFuture = Date.now() + 1_000_000 * 3_600_000; + const results = await memory.queryScoredFragments("auth", [{ namespace: ns, depth: 4 }], farFuture); + expect(results[0].fragment.id).toBe(f.id); + expect(results[0].weight).toBe(1); + }); +}); + +describe("eviction sweep", () => { + it("deletes only decayed+consolidated+unpinned fragments; leaves un-consolidated ones for consolidation", async () => { + const noRepo = { branch: "main", files: [], repoRoot: "" }; + // session namespace: half-life 168h — 2000h ≫ enough for w < 0.05 + const consolidated = await memory.storeFragment( + frag({ sessionId: "se", gitContext: noRepo, content: "old consolidated" }), + ); + const unconsolidated = await memory.storeFragment( + frag({ sessionId: "se", gitContext: noRepo, content: "old but never consolidated" }), + ); + const pinnedOld = await memory.storeFragment( + frag({ sessionId: "se", gitContext: noRepo, content: "old pinned" }), + ); + await memory.markFragmentsConsolidated([consolidated.id, pinnedOld.id], "k-1"); + await memory.setFragmentPinned(pinnedOld.id, true); + + const result = await memory.runEvictionSweep({ now: Date.now() + 2000 * 3_600_000 }); + expect(result.decayedDeleted).toBe(1); + + const remaining = await memory.getSessionFragments("se"); + const ids = remaining.map((r) => r.id); + expect(ids).not.toContain(consolidated.id); // essence lives in the consolidated table + expect(ids).toContain(unconsolidated.id); // do NOT silently drop (§3.2) + expect(ids).toContain(pinnedOld.id); // pinned never evicted + }); + + it("enforces the per-namespace hard cap with lowest-weight eviction, sparing pinned rows", async () => { + const noRepo = { branch: "main", files: [], repoRoot: "" }; + const stored = []; + for (let i = 0; i < 6; i++) { + stored.push( + await memory.storeFragment(frag({ sessionId: "sc", gitContext: noRepo, content: `note ${i}` })), + ); + } + await memory.setFragmentPinned(stored[0].id, true); + + const result = await memory.runEvictionSweep({ perNamespaceCap: 3 }); + expect(result.capEvicted).toBe(3); + + const remaining = await memory.getSessionFragments("sc"); + expect(remaining).toHaveLength(3); + expect(remaining.map((r) => r.id)).toContain(stored[0].id); // pinned survives + }); +}); + +// ─── Consolidation support APIs (§3.4 Stage 1/3, §1.2 fix) ─────────────────── + +describe("consolidation support APIs", () => { + it("getUnconsolidatedFragments works by session id and by namespace, excluding consolidated rows", async () => { + const a = await memory.storeFragment(frag({ sessionId: "sx", content: "auth one" })); + const b = await memory.storeFragment(frag({ sessionId: "sx", content: "auth database two" })); + await memory.markFragmentsConsolidated([a.id], "k-9"); + + const bySession = await memory.getUnconsolidatedFragments("sx"); + expect(bySession.map((f) => f.id)).toEqual([b.id]); + + const byNamespace = await memory.getUnconsolidatedFragments(memory.repoNamespace("/repo")); + expect(byNamespace.map((f) => f.id)).toEqual([b.id]); + + // Embeddings are included so Stage-1 JUDGE can cluster without re-embedding + expect(byNamespace[0].embedding).toHaveLength(4); + }); + + it("markFragmentsConsolidated sets isConsolidated + consolidatedInto (§1.2 fix)", async () => { + const f = await memory.storeFragment(frag({ content: "auth mark" })); + await memory.markFragmentsConsolidated([f.id], "knowledge-42"); + const [row] = await memory.getSessionFragments("session-1"); + expect(row.isConsolidated).toBe(true); + expect(row.consolidatedInto).toBe("knowledge-42"); + }); + + it("upsertKnowledgeFromDistillation upserts by (namespace, tag) with supersession tombstones", async () => { + const src = await memory.storeFragment(frag({ content: "auth source" })); + const ctx = { sessionId: "s1", repoRoot: "/repo", backendType: "claude" }; + + const [k1] = await memory.upsertKnowledgeFromDistillation( + [{ tag: "auth", type: "pattern", summary: "auth uses JWT", confidence: 0.8, sourceFragmentIds: [src.id], namespace: "repo" }], + ctx, + ); + expect(k1.namespace).toBe(memory.repoNamespace("/repo")); + expect(k1.synthesisMethod).toBe("llm"); // default + expect(k1.repoRoot).toBe("/repo"); + + // Sources are marked consolidated into the new row + const [srcRow] = await memory.getSessionFragments("session-1"); + expect(srcRow.isConsolidated).toBe(true); + expect(srcRow.consolidatedInto).toBe(k1.id); + + // Second distillation for the same (namespace, tag) replaces, never duplicates + const [k2] = await memory.upsertKnowledgeFromDistillation( + [{ tag: "auth", type: "pattern", summary: "auth uses JWT with RS256", confidence: 0.9, sourceFragmentIds: [], namespace: "repo" }], + ctx, + ); + const active = await memory.getKnowledgeByNamespace(memory.repoNamespace("/repo"), "auth"); + expect(active).toHaveLength(1); + expect(active[0].id).toBe(k2.id); + + // The replaced row keeps a supersededBy tombstone for audit + const table = await rawTable("consolidated_v2"); + const [oldRow] = (await table.query().where(`id = '${k1.id}'`).limit(1).toArray()) as unknown as Record[]; + expect(oldRow.supersededBy).toBe(k2.id); + }); + + it("tombstones rows named in an explicit supersedes list", async () => { + const ctx = { sessionId: "s1", repoRoot: "/repo", backendType: "claude" }; + const [k1] = await memory.upsertKnowledgeFromDistillation( + [{ tag: "auth-legacy", summary: "auth: old take", confidence: 0.5, sourceFragmentIds: [], namespace: "repo" }], + ctx, + ); + const [k2] = await memory.upsertKnowledgeFromDistillation( + [{ tag: "auth", summary: "auth: new take", confidence: 0.9, sourceFragmentIds: [], supersedes: [k1.id], namespace: "repo" }], + ctx, + ); + const legacy = await memory.getKnowledgeByNamespace(memory.repoNamespace("/repo"), "auth-legacy"); + expect(legacy).toHaveLength(0); // tombstoned by k2 via supersedes + const active = await memory.getKnowledgeByNamespace(memory.repoNamespace("/repo")); + expect(active.map((k) => k.id)).toEqual([k2.id]); + }); + + it("findRelatedKnowledge matches by centroid vector or texts at the 0.80 threshold", async () => { + const ctx = { sessionId: "s1", repoRoot: "/repo", backendType: "claude" }; + await memory.upsertKnowledgeFromDistillation( + [{ tag: "auth", summary: "auth signing conventions", confidence: 0.8, sourceFragmentIds: [], namespace: "repo" }], + ctx, + ); + const ns = memory.repoNamespace("/repo"); + + const hit = await memory.findRelatedKnowledge(ns, [1, 0, 0, 0]); + expect(hit).toHaveLength(1); + expect(hit[0].tag).toBe("auth"); + + const miss = await memory.findRelatedKnowledge(ns, [0, 0, 1, 0]); + expect(miss).toHaveLength(0); + + // Text form: embedded and averaged into a centroid + const textHit = await memory.findRelatedKnowledge(ns, ["auth token rules"]); + expect(textHit).toHaveLength(1); + }); + + it("concatFallbackConsolidate marks synthesisMethod 'concat' and is idempotent (§1.2 fix)", async () => { + await memory.storeFragment(frag({ sessionId: "si", content: "auth: JWT expiry is 7d", tags: ["auth"] })); + await memory.storeFragment(frag({ sessionId: "si", content: "auth: RS256 signing", tags: ["auth"] })); + + const first = await memory.concatFallbackConsolidate("si", "/repo"); + expect(first).toHaveLength(1); + expect(first[0].synthesisMethod).toBe("concat"); + expect(first[0].summary).toContain('Knowledge about "auth"'); + + // Second run: sources are already consolidated → nothing to do, no duplicates + const second = await memory.concatFallbackConsolidate("si", "/repo"); + expect(second).toEqual([]); + const active = await memory.getKnowledgeByNamespace(memory.repoNamespace("/repo"), "auth"); + expect(active).toHaveLength(1); + }); +}); + +// ─── Enrichment entry point (§3.6.2) ───────────────────────────────────────── + +describe("queryForEnrichment", () => { + it("returns null block and no items when nothing is recalled", async () => { + const result = await memory.queryForEnrichment({ + sessionId: "s1", + repoRoot: "/repo", + backendType: "claude", + queryText: "auth", + }); + expect(result.items).toEqual([]); + expect(result.block).toBeNull(); + }); + + it("formats the §3.6.2 block exactly: header, Knowledge, Notes, footer", async () => { + await memory.upsertKnowledgeFromDistillation( + [{ tag: "auth", type: "decision", summary: "Auth uses JWT with RS256", confidence: 0.9, sourceFragmentIds: [], namespace: "repo" }], + { sessionId: "s1", repoRoot: "/repo", backendType: "claude" }, + ); + const f = await memory.storeFragment( + frag({ content: "auth tokens expire after 7 days", tags: ["auth"], confidence: 0.9 }), + ); + + const result = await memory.queryForEnrichment({ + sessionId: "s1", + repoRoot: "/repo", + backendType: "claude", + queryText: "auth login flow", + }); + + expect(result.block).toBe( + [ + "--- Campfire memory (auto-recalled; may be stale) ---", + "Knowledge:", + "- [auth] Auth uses JWT with RS256", + "Notes:", + "- [observation] auth tokens expire after 7 days", + "--- end memory ---", + ].join("\n"), + ); + + // Item list mirrors the block: knowledge ranked above fragments + expect(result.items).toHaveLength(2); + expect(result.items[0].kind).toBe("knowledge"); + expect(result.items[0].tag).toBe("auth"); + expect(result.items[0].weight).toBe(1); // consolidated knowledge does not decay + expect(result.items[0].namespace).toBe(memory.repoNamespace("/repo")); + expect(result.items[1]).toMatchObject({ id: f.id, kind: "fragment", tag: "auth" }); + expect(result.items[1].weight).toBeGreaterThan(0.99); // fresh fragment ≈ 1 + }); + + it("excludes session-namespace fragments — same-session context is already in the conversation (§3.6.2)", async () => { + // Fragment without a repoRoot lands in session: and must NOT be recalled + await memory.storeFragment( + frag({ sessionId: "s1", gitContext: { branch: "main", files: [], repoRoot: "" }, content: "auth session-only secret" }), + ); + const result = await memory.queryForEnrichment({ + sessionId: "s1", + repoRoot: "/repo", + backendType: "claude", + queryText: "auth", + }); + expect(result.block).toBeNull(); + }); + + it("reinforces exactly the included fragments (§3.2: inclusion reinforces, matching does not)", async () => { + const f = await memory.storeFragment(frag({ content: "auth recalled note" })); + await memory.queryForEnrichment({ + sessionId: "s1", + repoRoot: "/repo", + backendType: "claude", + queryText: "auth", + }); + await memory.flushReinforcements(); + const [row] = await memory.getSessionFragments("session-1"); + expect(row.id).toBe(f.id); + expect(row.accessCount).toBe(1); + }); + + it("enforces the context budgets: ≤ ~1200 chars of fragment text, ≤ ~2000 total, max 5 fragment lines", async () => { + // 6 retrievable fragments of ~280 chars each — only 4 fit the 1200-char + // fragment budget (each line ≈ 296 chars incl. the "- [observation] " prefix) + const prefixes = ["auth", "auth database", "auth routing", "auth cache", "auth database routing", "auth database cache"]; + for (const p of prefixes) { + const content = `${p} ${"x".repeat(280 - p.length - 1)}`; + await memory.storeFragment(frag({ content })); + } + + const result = await memory.queryForEnrichment({ + sessionId: "s1", + repoRoot: "/repo", + backendType: "claude", + queryText: "auth", + }); + + const fragmentItems = result.items.filter((i) => i.kind === "fragment"); + expect(fragmentItems.length).toBeLessThanOrEqual(4); + expect(result.block!.length).toBeLessThanOrEqual(2000); + }); + + it("caps the total block size when knowledge alone would exceed it", async () => { + // 10 knowledge rows × ~250-char summaries ≈ 2600 chars — must be cut to fit + const ctx = { sessionId: "s1", repoRoot: "/repo", backendType: "claude" }; + for (let i = 0; i < 10; i++) { + await memory.upsertKnowledgeFromDistillation( + [{ tag: `topic-${i}`, summary: `auth ${"y".repeat(245)}`, confidence: 0.9, sourceFragmentIds: [], namespace: "repo" }], + ctx, + ); + } + const result = await memory.queryForEnrichment({ + sessionId: "s1", + repoRoot: "/repo", + backendType: "claude", + queryText: "auth", + }); + expect(result.block!.length).toBeLessThanOrEqual(2000); + expect(result.items.length).toBeLessThan(10); + }); +}); + +// ─── Namespace overview (UI) ───────────────────────────────────────────────── + +describe("getNamespaceOverview", () => { + it("reports count, avgWeight, and pinnedCount per namespace", async () => { + const a = await memory.storeFragment(frag({ content: "auth one" })); + await memory.storeFragment(frag({ content: "auth database two" })); + await memory.setFragmentPinned(a.id, true); + await memory.storeFragment( + frag({ sessionId: "s1", gitContext: { branch: "main", files: [], repoRoot: "" }, content: "session note" }), + ); + + const overview = await memory.getNamespaceOverview({ + sessionId: "s1", + repoRoot: "/repo", + backendType: "claude", + }); + + const repoEntry = overview.find((o) => o.namespace === memory.repoNamespace("/repo"))!; + expect(repoEntry.count).toBe(2); + expect(repoEntry.pinnedCount).toBe(1); + expect(repoEntry.avgWeight).toBeGreaterThan(0.99); // fresh rows ≈ weight 1 + + const sessionEntry = overview.find((o) => o.namespace === "session:s1")!; + expect(sessionEntry.count).toBe(1); + + const globalEntry = overview.find((o) => o.namespace === "global")!; + expect(globalEntry.count).toBe(0); + expect(globalEntry.avgWeight).toBe(0); + }); +}); + +// ─── Migration (§3.5) ──────────────────────────────────────────────────────── + +/** Create a v1-schema fixture database (pre-namespace, pre-lifecycle columns). */ +async function createV1Fixture(dim: number) { + const lancedb = await import("@lancedb/lancedb"); + const db = await lancedb.connect(join(testDir, "lancedb")); + await db.createTable("fragments", [ + { + id: "v1-frag-repo", + sessionId: "sess-1", + agentId: "a1", + backendType: "claude", + timestamp: 1_700_000_000_000, + type: "observation", + content: "auth: v1 fragment with a real embedding", + gitContextJson: JSON.stringify({ branch: "main", files: [], repoRoot: "/repo" }), + referencesJson: "[]", + tagsJson: JSON.stringify(["auth"]), + confidence: 0.8, + consolidatedInto: "", + isConsolidated: false, + vector: [1, ...Array(dim - 1).fill(0)] as number[], + }, + { + id: "v1-frag-zero", + sessionId: "sess-1", + agentId: "a1", + backendType: "claude", + timestamp: 1_700_000_100_000, + type: "observation", + content: "database: v1 fragment stored with a zero vector", + gitContextJson: JSON.stringify({ branch: "main", files: [], repoRoot: "" }), + referencesJson: "[]", + tagsJson: JSON.stringify(["database"]), + confidence: 0.6, + consolidatedInto: "", + isConsolidated: false, + vector: Array(dim).fill(0) as number[], + }, + ]); + await db.createTable("consolidated", [ + { + id: "v1-know-repo", + tag: "auth", + summary: "auth: v1 knowledge for /repo", + sourceFragmentsJson: JSON.stringify(["v1-frag-repo"]), + lastUpdated: 1_700_000_200_000, + confidence: 0.7, + repoRoot: "/repo", + }, + { + id: "v1-know-global", + tag: "conventions", + summary: "cross-repo v1 knowledge", + sourceFragmentsJson: "[]", + lastUpdated: 1_700_000_300_000, + confidence: 0.5, + repoRoot: "", + }, + ]); + return db; +} + +describe("v1 → v2 migration", () => { + it("copies rows with namespace backfill, zero-vector → pending, and retains v1 backups", async () => { + const db = await createV1Fixture(4); + + // First API call triggers the migration + const fragments = await memory.getSessionFragments("sess-1"); + expect(fragments).toHaveLength(2); + + const repoFrag = fragments.find((f) => f.id === "v1-frag-repo")!; + expect(repoFrag.namespace).toBe(memory.repoNamespace("/repo")); // repoRoot → repo: + expect(repoFrag.embeddingStatus).toBe("ok"); // real vector, matching dim + expect(repoFrag.lastReinforcedAt).toBe(repoFrag.timestamp); + expect(repoFrag.accessCount).toBe(0); + expect(repoFrag.pinned).toBe(false); + + const zeroFrag = fragments.find((f) => f.id === "v1-frag-zero")!; + expect(zeroFrag.namespace).toBe("session:sess-1"); // no repoRoot → session: + expect(zeroFrag.embeddingStatus).toBe("pending"); // §1.6: zero vector detected + + // Zero-vector rows are excluded from ANN results + const search = await memory.queryFragments("database", { sessionId: "sess-1" }); + expect(search.find((f) => f.id === "v1-frag-zero")).toBeUndefined(); + // ...but migrated ok rows are searchable immediately + const authHits = await memory.queryFragments("auth", { repoRoot: "/repo" }); + expect(authHits.map((f) => f.id)).toContain("v1-frag-repo"); + + // Consolidated: repoRoot "" → global namespace (§1.8 fix), synthesisMethod concat + const globalKnow = await memory.getConsolidatedKnowledge(""); + expect(globalKnow.map((k) => k.id)).toEqual(["v1-know-global"]); + const repoKnow = await memory.getConsolidatedKnowledge("/repo"); + expect(repoKnow.map((k) => k.id)).toEqual(["v1-know-repo"]); + expect(repoKnow[0].synthesisMethod).toBe("concat"); + + // meta.json versioning + v1 backup tables retained (never opened again) + const meta = JSON.parse(readFileSync(join(testDir, "meta.json"), "utf-8")); + expect(meta.schemaVersion).toBe(2); + expect(meta.dim).toBe(4); + expect(meta.activeFragmentsTable).toBe("fragments_v2"); + const names = await db.tableNames(); + expect(names).toContain("fragments"); + expect(names).toContain("consolidated"); + expect(names).toContain("fragments_v2"); + expect(names).toContain("consolidated_v2"); + }); + + it("handles a dimension change at migration time: all rows pending at the provider dim", async () => { + // v1 store was written at dim 4, but the configured provider is 8-dim now + await createV1Fixture(4); + mockGetDim.mockReturnValue(8); + mockEmbed.mockImplementation(deterministicEmbed(8)); + + const fragments = await memory.getSessionFragments("sess-1"); + // Even the non-zero v1 vector can't carry over across dims → pending + expect(fragments.every((f) => f.embeddingStatus === "pending")).toBe(true); + + const meta = JSON.parse(readFileSync(join(testDir, "meta.json"), "utf-8")); + expect(meta.dim).toBe(8); + }); +}); + +describe("provider/dimension change on a live v2 store", () => { + it("creates fragments_v2_ and marks rows pending; the re-embed queue restores them", async () => { + const f = await memory.storeFragment(frag({ content: "auth: survives provider switch" })); + expect(f.embeddingStatus).toBe("ok"); + + // Switch openai(4) → ollama(8) at runtime + mockProviderName.mockReturnValue("ollama"); + mockGetDim.mockReturnValue(8); + mockEmbed.mockImplementation(deterministicEmbed(8)); + + // Next store access reconciles: new active table, rows pending + const rows = await memory.getSessionFragments("session-1"); + expect(rows).toHaveLength(1); + expect(rows[0].embeddingStatus).toBe("pending"); + + const meta = JSON.parse(readFileSync(join(testDir, "meta.json"), "utf-8")); + expect(meta.dim).toBe(8); + expect(meta.embeddingProvider).toBe("ollama"); + expect(meta.activeFragmentsTable).toBe("fragments_v2_8"); + + // Lazy re-embed queue (≤2 req/s in production; driven manually in tests) + const processed = await memory.processReembedBatch(10); + expect(processed).toBeGreaterThanOrEqual(1); + const after = await memory.getSessionFragments("session-1"); + expect(after[0].embeddingStatus).toBe("ok"); + + // Re-embedded rows are searchable at the new dimension + const hits = await memory.queryFragments("auth", { repoRoot: "/repo" }); + expect(hits.map((r) => r.id)).toContain(f.id); + }); + + it("marks rows pending in place when the provider changes at the same dimension", async () => { + // Embeddings from different models are not comparable even at equal width + await memory.storeFragment(frag({ content: "auth: same-dim switch" })); + mockProviderName.mockReturnValue("custom-4dim"); + const rows = await memory.getSessionFragments("session-1"); + expect(rows[0].embeddingStatus).toBe("pending"); + const meta = JSON.parse(readFileSync(join(testDir, "meta.json"), "utf-8")); + expect(meta.activeFragmentsTable).toBe("fragments_v2"); // no new table needed + expect(meta.embeddingProvider).toBe("custom-4dim"); + }); + + it("re-embed queue leaves rows pending when the provider keeps failing", async () => { + mockEmbed.mockResolvedValueOnce(null); // store fails → pending + await memory.storeFragment(frag({ content: "auth: flaky provider" })); + mockEmbed.mockResolvedValue(null); // still down + const processed = await memory.processReembedBatch(5); + expect(processed).toBe(0); + const rows = await memory.getSessionFragments("session-1"); + expect(rows[0].embeddingStatus).toBe("pending"); + }); + + it("re-embed queue is a no-op with no provider configured", async () => { + mockGetDim.mockReturnValue(null); + mockProviderName.mockReturnValue("none"); + mockEmbed.mockResolvedValue(null); + await memory.storeFragment(frag()); + expect(await memory.processReembedBatch(5)).toBe(0); + }); +}); diff --git a/web/server/semantic-memory.ts b/web/server/semantic-memory.ts index c96713d..87e03f9 100644 --- a/web/server/semantic-memory.ts +++ b/web/server/semantic-memory.ts @@ -1,29 +1,62 @@ /** - * Layer 1: Semantic Memory + * Layer 1: Semantic Memory — v2 core store. * - * Persistent, shared knowledge base anchored to a git repository. - * Backed by LanceDB (embedded, TypeScript-native vector database — same stack as Continue.dev). + * Persistent, shared knowledge base anchored to namespaces (design doc + * docs/design/semantic-memory-v2.md §3.1–§3.5). Backed by LanceDB. * - * Two tables: - * - fragments: episodic/semantic MemoryFragments with vector embeddings - * - consolidated: distilled ConsolidatedKnowledge per semantic tag + * Two active tables (names tracked in ~/.campfire/memory/meta.json): + * - fragments_v2[_]: episodic/semantic MemoryFragments with vectors + * - consolidated_v2[_]: distilled ConsolidatedKnowledge per (namespace, tag) * - * Data is stored at ~/.campfire/memory/lancedb/ (one DB directory, two tables). - * - * Retrieval is vector similarity search via LanceDB's built-in ANN/flat-scan. - * Embeddings are generated by the configured provider (OpenAI or Ollama) at write time. + * v2 adds: + * - namespaces (global / repo: / session: / agent:) with + * pushed-down where() filtering (§3.1, fixes §1.7 starvation) + * - lazy decay + capped reinforcement (§3.2) — computed at read time + * - composite scored retrieval simNorm^1.5 × w(t) × confidence (§3.3) + * - embeddingStatus lifecycle ("ok"/"pending"/"none") — no zero vectors in + * ANN results (§1.6) + lazy re-embed queue at ≤ 2 req/s + * - consolidation primitives: idempotent upsert by (namespace, tag) with + * supersession tombstones and source marking (§1.2, §3.4 Stage 3) + * - v1 → v2 migration + dimension-change handling (§3.5, memory-migration.ts) */ -import { mkdirSync, appendFileSync } from "node:fs"; +import { mkdirSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { randomUUID } from "node:crypto"; -import type { BackendType } from "./session-types.js"; -import { embed, getEmbeddingDim } from "./embedding.js"; +import type { BackendType, MemoryEnrichmentItem } from "./session-types.js"; +import { embed, getEmbeddingDim, getEmbeddingProviderName } from "./embedding.js"; +import { getMemorySettings, type MemoryDecayPolicy } from "./settings-manager.js"; +import { + ensureSchemaV2, + hashRepoRoot, + repoNamespace, + sessionNamespace, + agentNamespace, + namespaceClass, + isNamespaceString, + toNumberArray, + type MemoryMeta, + type NamespaceClass, +} from "./memory-migration.js"; + +// Re-export the namespace model so consumers only need this module. +export { + hashRepoRoot, + repoNamespace, + sessionNamespace, + agentNamespace, + namespaceClass, + isNamespaceString, + type NamespaceClass, +}; // ─── Types ─────────────────────────────────────────────────────────────────── export type MemoryType = "observation" | "hypothesis" | "decision" | "pattern"; +export type EmbeddingStatus = "ok" | "pending" | "none"; +export type SynthesisMethod = "llm" | "concat"; +export type KnowledgeType = "pattern" | "decision" | "convention" | "failure" | "fact"; export interface GitContext { commitHash?: string; @@ -46,8 +79,19 @@ export interface MemoryFragment { tags: string[]; consolidatedInto?: string; isConsolidated: boolean; - // Stored in LanceDB as a Float32 vector column — null when provider = "none" + // Stored in LanceDB as a Float32 vector column — populated on read only + // when embeddingStatus === "ok" embedding?: number[]; + // ── v2 lifecycle fields (§3.1/§3.2) — always populated by the store; + // optional so pre-v2 constructors/mocks keep compiling. + namespace?: string; + repoRootHash?: string; + lastReinforcedAt?: number; + accessCount?: number; + pinned?: boolean; + /** Per-fragment half-life override in hours; null = use namespace policy. */ + halfLifeHours?: number | null; + embeddingStatus?: EmbeddingStatus; } export interface ConsolidatedKnowledge { @@ -58,6 +102,12 @@ export interface ConsolidatedKnowledge { lastUpdated: number; confidence: number; repoRoot: string; + // ── v2 fields + namespace?: string; + type?: KnowledgeType; + synthesisMethod?: SynthesisMethod; + /** Tombstone: id of the knowledge row that superseded this one ("" / undefined = active). */ + supersededBy?: string; } export interface MemoryQueryOptions { @@ -66,84 +116,144 @@ export interface MemoryQueryOptions { tags?: string[]; type?: MemoryType; sessionId?: string; + /** v2: restrict to a single namespace (pushed down into LanceDB where()). */ + namespace?: string; +} + +// ─── Tunables (§3.2/§3.3) ──────────────────────────────────────────────────── + +/** Similarity sharpening exponent — decay/confidence break ties, not override matches. */ +export const SIM_EXPONENT = 1.5; +/** Fragments with pairwise cosine above this are near-duplicates (keep higher score). */ +export const NEAR_DUP_COSINE = 0.97; +/** Decayed-weight floor below which consolidated, unpinned fragments are evicted. */ +export const EVICTION_WEIGHT_THRESHOLD = 0.05; +/** Hard cap of fragments per namespace (lowest-w eviction backstop). */ +export const NAMESPACE_HARD_CAP = 5000; +/** Reinforcement cap: half-life extension multiplier applies at most this many times. */ +export const REINFORCE_ACCESS_CAP = 8; +/** Enrichment budget: max chars of raw fragment text in the block. */ +export const FRAGMENT_BUDGET_CHARS = 1200; +/** Enrichment budget: max chars of the whole injected block. */ +export const TOTAL_BUDGET_CHARS = 2000; +/** Max fragment lines in the enrichment block (§3.6.2). */ +export const MAX_ENRICHMENT_FRAGMENTS = 5; + +const REINFORCE_DEBOUNCE_MS = 500; +const SWEEP_INTERVAL_MS = 60 * 60 * 1000; // hourly (§3.2) +const REEMBED_INTERVAL_MS = 1000; // batch of 2 per second = ≤ 2 req/s (§3.5) + +// ─── Decay + reinforcement (§3.2) ──────────────────────────────────────────── + +export type DecayPolicy = MemoryDecayPolicy; + +/** Minimal shape needed to compute a decayed weight (table-driven-testable). */ +export interface DecayableLike { + pinned?: boolean; + lastReinforcedAt?: number; + timestamp?: number; + accessCount?: number; + halfLifeHours?: number | null; +} + +/** + * Lazy decay weight, computed at read time and never stored: + * + * halfLife_eff = halfLife_base × reinforceMultiplier ^ min(accessCount, 8) + * w(t) = pinned ? 1.0 : 0.5 ^ ((now − lastReinforcedAt) / halfLife_eff) + * + * Pinned rows and null half-lives never decay (w = 1). + */ +export function computeDecayedWeight(fragment: DecayableLike, now: number, policy: DecayPolicy): number { + if (fragment.pinned) return 1; + const override = fragment.halfLifeHours; + const baseHalfLife = typeof override === "number" && override > 0 ? override : policy.halfLifeHours; + if (baseHalfLife === null || baseHalfLife <= 0) return 1; + const multiplier = policy.reinforceMultiplier > 0 ? policy.reinforceMultiplier : 1; + const capped = Math.min(Math.max(fragment.accessCount ?? 0, 0), REINFORCE_ACCESS_CAP); + const effectiveHalfLife = baseHalfLife * Math.pow(multiplier, capped); + const anchor = fragment.lastReinforcedAt ?? fragment.timestamp ?? now; + const ageHours = Math.max(0, now - anchor) / 3_600_000; + return Math.pow(0.5, ageHours / effectiveHalfLife); +} + +/** Decay policy for a namespace, from settings (defaults per §3.1). */ +export function policyForNamespace(namespace: string): DecayPolicy { + const cls: NamespaceClass = namespaceClass(namespace); + return getMemorySettings().decay[cls]; +} + +// ─── SQL helpers (pushed-down filters, §1.7 fix) ───────────────────────────── + +/** Quote a string literal for a LanceDB (DataFusion) where() predicate. */ +export function sqlQuote(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +/** + * Where-clause for a namespace query. `embeddedOnly` additionally excludes + * rows without a usable embedding from ANN search (§1.6 / §3.3). + */ +export function buildNamespaceWhere(namespace: string, embeddedOnly = true): string { + const base = `namespace = ${sqlQuote(namespace)}`; + return embeddedOnly ? `${base} AND embeddingStatus = 'ok'` : base; } // ─── LanceDB integration ───────────────────────────────────────────────────── -// Lazily import to avoid startup cost when memory is not used type LanceDB = typeof import("@lancedb/lancedb"); -type LanceTable = Awaited>["openTable"]>>; +type LanceConnection = Awaited>; +type LanceTable = Awaited>; -let _db: Awaited> | null = null; -let _fragmentsTable: LanceTable | null = null; -let _consolidatedTable: LanceTable | null = null; -let _memoryDir = join(homedir(), ".campfire", "memory", "lancedb"); +interface StoreState { + db: LanceConnection; + fragments: LanceTable; + consolidated: LanceTable; + meta: MemoryMeta; +} + +let _memoryRoot = join(homedir(), ".campfire", "memory"); +let _initPromise: Promise | null = null; -async function getDB() { - if (_db) return _db; +async function initState(): Promise { const lancedb = await import("@lancedb/lancedb"); - mkdirSync(_memoryDir, { recursive: true }); - _db = await lancedb.connect(_memoryDir); - return _db; + const dbDir = join(_memoryRoot, "lancedb"); + mkdirSync(dbDir, { recursive: true }); + const db = await lancedb.connect(dbDir); + const { meta, fragments, consolidated } = await ensureSchemaV2({ + db, + memoryRoot: _memoryRoot, + provider: getEmbeddingProviderName(), + providerDim: getEmbeddingDim(), + }); + return { db, fragments, consolidated, meta }; } -async function getFragmentsTable(): Promise { - if (_fragmentsTable) return _fragmentsTable; - const db = await getDB(); - const tableNames = await db.tableNames(); - if (tableNames.includes("fragments")) { - _fragmentsTable = await db.openTable("fragments"); - } else { - // Create with a seed row so the schema is established, then delete seed - const dim = getEmbeddingDim(); - const seed = [{ - id: "__seed__", - sessionId: "", - agentId: "", - backendType: "claude" as BackendType, - timestamp: 0, - type: "observation" as MemoryType, - content: "", - gitContextJson: "{}", - referencesJson: "[]", - tagsJson: "[]", - confidence: 0, - consolidatedInto: "", - isConsolidated: false, - vector: Array(dim).fill(0) as number[], - }]; - _fragmentsTable = await db.createTable("fragments", seed); - await _fragmentsTable.delete('id = "__seed__"'); +async function getState(): Promise { + let state = await (_initPromise ??= initState()); + // Live provider/dimension change (settings edited at runtime): re-run the + // reconciliation path so the active tables always match the provider (§3.5.2). + const provider = getEmbeddingProviderName(); + const dim = getEmbeddingDim(); + if (provider !== state.meta.embeddingProvider || (dim !== null && dim !== state.meta.dim)) { + _initPromise = initState(); + state = await _initPromise; } - return _fragmentsTable; + ensureMaintenanceTimers(); + return state; } -async function getConsolidatedTable(): Promise { - if (_consolidatedTable) return _consolidatedTable; - const db = await getDB(); - const tableNames = await db.tableNames(); - if (tableNames.includes("consolidated")) { - _consolidatedTable = await db.openTable("consolidated"); - } else { - const seed = [{ - id: "__seed__", - tag: "", - summary: "", - sourceFragmentsJson: "[]", - lastUpdated: 0, - confidence: 0, - repoRoot: "", - }]; - _consolidatedTable = await db.createTable("consolidated", seed); - await _consolidatedTable.delete('id = "__seed__"'); - } - return _consolidatedTable; +// ─── Row <-> domain object mapping ─────────────────────────────────────────── + +function rowStr(v: unknown): string { + return typeof v === "string" ? v : ""; } -// ─── Internal row <-> domain object mapping ────────────────────────────────── +function rowNum(v: unknown): number { + return typeof v === "number" && Number.isFinite(v) ? v : 0; +} -function fragmentToRow(fragment: MemoryFragment, embedding: number[] | null) { - const dim = getEmbeddingDim(); +function fragmentToRow(fragment: MemoryFragment, vector: number[], status: EmbeddingStatus) { return { id: fragment.id, sessionId: fragment.sessionId, @@ -158,41 +268,84 @@ function fragmentToRow(fragment: MemoryFragment, embedding: number[] | null) { confidence: fragment.confidence, consolidatedInto: fragment.consolidatedInto ?? "", isConsolidated: fragment.isConsolidated, - vector: embedding ?? Array(dim).fill(0), + namespace: fragment.namespace ?? "", + repoRoot: fragment.gitContext.repoRoot ?? "", + repoRootHash: fragment.repoRootHash ?? "", + lastReinforcedAt: fragment.lastReinforcedAt ?? fragment.timestamp, + accessCount: fragment.accessCount ?? 0, + pinned: fragment.pinned ?? false, + // 0 = no override (null in the domain model) + halfLifeHours: fragment.halfLifeHours ?? 0, + embeddingStatus: status, + vector, }; } function rowToFragment(row: Record): MemoryFragment { + const status = (rowStr(row.embeddingStatus) || "none") as EmbeddingStatus; + const halfLife = rowNum(row.halfLifeHours); return { - id: row.id as string, - sessionId: row.sessionId as string, - agentId: row.agentId as string, - backendType: row.backendType as BackendType, - timestamp: row.timestamp as number, - type: row.type as MemoryType, - content: row.content as string, - gitContext: JSON.parse(row.gitContextJson as string || "{}") as GitContext, - references: JSON.parse(row.referencesJson as string || "[]") as string[], - tags: JSON.parse(row.tagsJson as string || "[]") as string[], - confidence: row.confidence as number, - consolidatedInto: (row.consolidatedInto as string) || undefined, - isConsolidated: row.isConsolidated as boolean, + id: rowStr(row.id), + sessionId: rowStr(row.sessionId), + agentId: rowStr(row.agentId), + backendType: (rowStr(row.backendType) || "claude") as BackendType, + timestamp: rowNum(row.timestamp), + type: (rowStr(row.type) || "observation") as MemoryType, + content: rowStr(row.content), + gitContext: JSON.parse(rowStr(row.gitContextJson) || "{}") as GitContext, + references: JSON.parse(rowStr(row.referencesJson) || "[]") as string[], + tags: JSON.parse(rowStr(row.tagsJson) || "[]") as string[], + confidence: rowNum(row.confidence), + consolidatedInto: rowStr(row.consolidatedInto) || undefined, + isConsolidated: row.isConsolidated === true, + embedding: status === "ok" ? toNumberArray(row.vector) : undefined, + namespace: rowStr(row.namespace), + repoRootHash: rowStr(row.repoRootHash), + lastReinforcedAt: rowNum(row.lastReinforcedAt) || rowNum(row.timestamp), + accessCount: rowNum(row.accessCount), + pinned: row.pinned === true, + halfLifeHours: halfLife > 0 ? halfLife : null, + embeddingStatus: status, }; } function rowToConsolidated(row: Record): ConsolidatedKnowledge { return { - id: row.id as string, - tag: row.tag as string, - summary: row.summary as string, - sourceFragments: JSON.parse(row.sourceFragmentsJson as string || "[]") as string[], - lastUpdated: row.lastUpdated as number, - confidence: row.confidence as number, - repoRoot: row.repoRoot as string, + id: rowStr(row.id), + tag: rowStr(row.tag), + summary: rowStr(row.summary), + sourceFragments: JSON.parse(rowStr(row.sourceFragmentsJson) || "[]") as string[], + lastUpdated: rowNum(row.lastUpdated), + confidence: rowNum(row.confidence), + repoRoot: rowStr(row.repoRoot), + namespace: rowStr(row.namespace), + type: (rowStr(row.knowledgeType) || undefined) as KnowledgeType | undefined, + synthesisMethod: (rowStr(row.synthesisMethod) || undefined) as SynthesisMethod | undefined, + supersededBy: rowStr(row.supersededBy) || undefined, }; } -// ─── Public API ────────────────────────────────────────────────────────────── +// ─── Vector math ───────────────────────────────────────────────────────────── + +function cosineSimilarity(a: number[], b: number[]): number { + if (a.length === 0 || a.length !== b.length) return 0; + let dot = 0; + let magA = 0; + let magB = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + magA += a[i] * a[i]; + magB += b[i] * b[i]; + } + if (magA === 0 || magB === 0) return 0; + return dot / (Math.sqrt(magA) * Math.sqrt(magB)); +} + +function zeros(dim: number): number[] { + return Array(dim).fill(0) as number[]; +} + +// ─── storeFragment ─────────────────────────────────────────────────────────── export interface StoreFragmentOptions { sessionId: string; @@ -204,20 +357,32 @@ export interface StoreFragmentOptions { tags?: string[]; confidence?: number; references?: string[]; + /** v2: explicit target namespace. Default: repo: when a repoRoot is present, else session:. */ + namespace?: string; + pinned?: boolean; + /** Per-fragment half-life override in hours (null/omitted = namespace policy). */ + halfLifeHours?: number | null; } /** * Store a new memory fragment. Generates an embedding at write time. - * Non-blocking: embedding generation failures are logged and the fragment - * is stored with a zero vector (still retrievable by metadata filters). + * + * §1.6 fix: no zero-vector pollution — when the provider is "none" the row is + * stored with embeddingStatus "none"; when an embed call fails (or the dim + * mismatches mid-migration) it is stored "pending" and picked up by the lazy + * re-embed queue. Both states are excluded from ANN by where() (§3.3). */ export async function storeFragment(opts: StoreFragmentOptions): Promise { + const repoRoot = opts.gitContext.repoRoot ?? ""; + const namespace = + opts.namespace ?? (repoRoot ? repoNamespace(repoRoot) : sessionNamespace(opts.sessionId)); + const now = Date.now(); const fragment: MemoryFragment = { id: randomUUID(), sessionId: opts.sessionId, agentId: opts.agentId, backendType: opts.backendType, - timestamp: Date.now(), + timestamp: now, type: opts.type, content: opts.content, gitContext: opts.gitContext, @@ -225,90 +390,701 @@ export async function storeFragment(opts: StoreFragmentOptions): Promise, + queryVector: number[] | null, + policy: DecayPolicy, + now: number, +): ScoredFragment { + const fragment = rowToFragment(row); + const weight = computeDecayedWeight(fragment, now, policy); + let simNorm = 1; + if (queryVector) { + simNorm = Math.max(0, cosineSimilarity(queryVector, toNumberArray(row.vector))); + } + const score = Math.pow(simNorm, SIM_EXPONENT) * weight * fragment.confidence; + return { fragment, score, weight, simNorm }; +} + +/** Near-duplicate dedupe: cosine > 0.97 (or identical content) keeps the higher score. */ +function dedupeNearDuplicates(scored: ScoredFragment[]): ScoredFragment[] { + const kept: ScoredFragment[] = []; + const sorted = [...scored].sort((a, b) => b.score - a.score); + for (const candidate of sorted) { + const isDup = kept.some((existing) => { + if (existing.fragment.content === candidate.fragment.content) return true; + const a = existing.fragment.embedding; + const b = candidate.fragment.embedding; + if (!a || !b) return false; + return cosineSimilarity(a, b) > NEAR_DUP_COSINE; + }); + if (!isDup) kept.push(candidate); + } + return kept; +} + +/** + * Per-namespace scored search (§3.3). For each namespace: pushed-down + * where("namespace = ... AND embeddingStatus = 'ok'"), limit depth×4, composite + * scoring in TS, top-depth per namespace, merged + near-dup deduped. + * + * No-embedding fallback: metadata scan per namespace ranked by w(t)×confidence. + */ +export async function queryScoredFragments( + queryText: string, + plan: RecallPlanEntry[], + now: number = Date.now(), +): Promise { + const state = await getState(); + const queryVector = await embed(queryText); + const useVector = !!queryVector && queryVector.length === state.meta.dim; + const merged: ScoredFragment[] = []; + + for (const entry of plan) { + if (entry.depth <= 0) continue; + const policy = policyForNamespace(entry.namespace); + let rows: Record[]; + if (useVector) { + rows = (await state.fragments + .search(queryVector as number[]) + .where(buildNamespaceWhere(entry.namespace, true)) + .limit(entry.depth * 4) + .toArray()) as unknown as Record[]; + } else { + rows = (await state.fragments + .query() + .where(buildNamespaceWhere(entry.namespace, false)) + .limit(1000) + .toArray()) as unknown as Record[]; + } + const scored = rows + .map((row) => scoreRow(row, useVector ? (queryVector as number[]) : null, policy, now)) + .sort((a, b) => b.score - a.score) + .slice(0, entry.depth); + merged.push(...scored); + } + + return dedupeNearDuplicates(merged).sort((a, b) => b.score - a.score); +} + /** - * Semantic similarity search over stored fragments. - * If an embedding is available, uses vector search. - * Falls back to a full table scan filtered by metadata (tag/session/repo). + * Semantic similarity search over stored fragments (legacy-compatible API). + * + * v2: metadata filters (repoRoot/sessionId/type/namespace) are pushed down + * into LanceDB where() instead of post-hoc JS filtering (§1.7 fix), and + * results are ranked by the composite score simNorm^1.5 × w(t) × confidence. + * Without a provider, falls back to a metadata scan ranked by w(t)×confidence. */ export async function queryFragments( query: string, options: MemoryQueryOptions = {}, ): Promise { - const { limit = 10, repoRoot, tags, type, sessionId } = options; - const table = await getFragmentsTable(); + const { limit = 10, repoRoot, tags, type, sessionId, namespace } = options; + const state = await getState(); + const now = Date.now(); const queryVector = await embed(query); + const useVector = !!queryVector && queryVector.length === state.meta.dim; - let rows: Record[]; + const conds: string[] = []; + if (namespace) conds.push(`namespace = ${sqlQuote(namespace)}`); + if (repoRoot) conds.push(`repoRootHash = ${sqlQuote(hashRepoRoot(repoRoot))}`); + if (sessionId) conds.push(`sessionId = ${sqlQuote(sessionId)}`); + if (type) conds.push(`type = ${sqlQuote(type)}`); - if (queryVector) { - // Vector similarity search — LanceDB returns closest by cosine distance - let search = table.search(queryVector).limit(limit * 3); // over-fetch for post-filter - const result = await search.toArray(); - rows = result as unknown as Record[]; + let rows: Record[]; + if (useVector) { + conds.push("embeddingStatus = 'ok'"); + rows = (await state.fragments + .search(queryVector as number[]) + .where(conds.join(" AND ")) + .limit(limit * 4) + .toArray()) as unknown as Record[]; } else { - // No embedding configured — full scan - const result = await table.query().limit(500).toArray(); - rows = result as unknown as Record[]; + let q = state.fragments.query(); + if (conds.length > 0) q = q.where(conds.join(" AND ")); + rows = (await q.limit(2000).toArray()) as unknown as Record[]; } - // Post-filter by metadata - let fragments = rows.map(rowToFragment).filter((f) => { - if (repoRoot && f.gitContext.repoRoot !== repoRoot) return false; - if (sessionId && f.sessionId !== sessionId) return false; - if (type && f.type !== type) return false; - if (tags && tags.length > 0) { - const fragTags = new Set(f.tags); - if (!tags.some((t) => fragTags.has(t))) return false; - } - return true; + let scored = rows.map((row) => { + const ns = rowStr(row.namespace); + return scoreRow(row, useVector ? (queryVector as number[]) : null, policyForNamespace(ns), now); }); - // If no vector search, sort by recency - if (!queryVector) { - fragments.sort((a, b) => b.timestamp - a.timestamp); + // Tags live in a JSON column — post-filter only this one. + if (tags && tags.length > 0) { + scored = scored.filter((s) => { + const fragTags = new Set(s.fragment.tags); + return tags.some((t) => fragTags.has(t)); + }); } - return fragments.slice(0, limit); + return scored + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map((s) => s.fragment); } /** * Retrieve all fragments for a session (used during consolidation). + * v2: sessionId filter pushed down into where(). */ export async function getSessionFragments(sessionId: string): Promise { - const table = await getFragmentsTable(); - const result = await table.query().limit(10000).toArray(); - return (result as unknown as Record[]) - .map(rowToFragment) - .filter((f) => f.sessionId === sessionId); + const state = await getState(); + const rows = (await state.fragments + .query() + .where(`sessionId = ${sqlQuote(sessionId)}`) + .limit(10000) + .toArray()) as unknown as Record[]; + return rows.map(rowToFragment); +} + +// ─── Reinforcement (§3.2) ──────────────────────────────────────────────────── + +const _pendingReinforce = { + fragments: new Map(), + knowledge: new Map(), +}; +let _reinforceTimer: ReturnType | null = null; + +function scheduleReinforceFlush(): void { + if (_reinforceTimer) return; + _reinforceTimer = setTimeout(() => { + flushReinforcements().catch((err) => console.warn("[memory] reinforcement flush failed:", err)); + }, REINFORCE_DEBOUNCE_MS); + _reinforceTimer.unref?.(); } /** - * Consolidate episodic fragments from a session into semantic knowledge. - * Groups fragments by tag, synthesizes a summary per tag, and stores in - * the consolidated table. - * - * Returns the list of consolidated knowledge items created/updated. + * Reinforce fragments that were *actually used* (included in an enrichment + * block or explicitly returned by memory_query): accessCount += 1 and + * lastReinforcedAt = now. Writes are debounced/batched; the reinforcement cap + * (×multiplier at most 8 times) is applied at read time in computeDecayedWeight. + */ +export function reinforceFragments(ids: string[]): void { + for (const id of ids) { + _pendingReinforce.fragments.set(id, (_pendingReinforce.fragments.get(id) ?? 0) + 1); + } + if (ids.length > 0) scheduleReinforceFlush(); +} + +/** Same as reinforceFragments but for consolidated knowledge rows. */ +export function reinforceKnowledge(ids: string[]): void { + for (const id of ids) { + _pendingReinforce.knowledge.set(id, (_pendingReinforce.knowledge.get(id) ?? 0) + 1); + } + if (ids.length > 0) scheduleReinforceFlush(); +} + +async function applyReinforcements( + table: LanceTable, + pending: Map, + now: number, +): Promise { + if (pending.size === 0) return; + const ids = [...pending.keys()]; + const rows = (await table + .query() + .where(`id IN (${ids.map(sqlQuote).join(", ")})`) + .limit(ids.length) + .toArray()) as unknown as Record[]; + for (const row of rows) { + const id = rowStr(row.id); + const inc = pending.get(id) ?? 0; + if (inc <= 0) continue; + await table.update({ + where: `id = ${sqlQuote(id)}`, + values: { accessCount: rowNum(row.accessCount) + inc, lastReinforcedAt: now }, + }); + } +} + +/** Force the debounced reinforcement batch to write now (tests / shutdown). */ +export async function flushReinforcements(): Promise { + if (_reinforceTimer) { + clearTimeout(_reinforceTimer); + _reinforceTimer = null; + } + const fragmentBatch = new Map(_pendingReinforce.fragments); + const knowledgeBatch = new Map(_pendingReinforce.knowledge); + _pendingReinforce.fragments.clear(); + _pendingReinforce.knowledge.clear(); + if (fragmentBatch.size === 0 && knowledgeBatch.size === 0) return; + const state = await getState(); + const now = Date.now(); + await applyReinforcements(state.fragments, fragmentBatch, now); + await applyReinforcements(state.consolidated, knowledgeBatch, now); +} + +/** Pin/unpin a fragment. Pinned rows never decay and are never evicted. */ +export async function setFragmentPinned(id: string, pinned: boolean): Promise { + const state = await getState(); + const exists = (await state.fragments.countRows(`id = ${sqlQuote(id)}`)) > 0; + if (!exists) return false; + await state.fragments.update({ where: `id = ${sqlQuote(id)}`, values: { pinned } }); + return true; +} + +// ─── Eviction sweep (§3.2) ─────────────────────────────────────────────────── + +export interface EvictionSweepOptions { + now?: number; + perNamespaceCap?: number; + weightThreshold?: number; +} + +export interface EvictionSweepResult { + /** Consolidated, unpinned fragments deleted because w(t) < threshold. */ + decayedDeleted: number; + /** Fragments evicted by the per-namespace hard cap (lowest-w first). */ + capEvicted: number; +} + +async function deleteFragmentIds(table: LanceTable, ids: string[]): Promise { + const CHUNK = 200; + for (let i = 0; i < ids.length; i += CHUNK) { + const chunk = ids.slice(i, i + CHUNK); + await table.delete(`id IN (${chunk.map(sqlQuote).join(", ")})`); + } +} + +/** + * Eviction sweep (runs hourly): + * - w(t) < threshold AND unpinned AND isConsolidated → delete (essence lives + * in the consolidated table). + * - Un-consolidated low-w fragments are left alone — consolidation drops + * them, never a silent delete. + * - Per-namespace hard cap (default 5000) with lowest-w eviction as backstop. + * - Pinned rows are never evicted. + */ +export async function runEvictionSweep(opts: EvictionSweepOptions = {}): Promise { + const now = opts.now ?? Date.now(); + const cap = opts.perNamespaceCap ?? NAMESPACE_HARD_CAP; + const threshold = opts.weightThreshold ?? EVICTION_WEIGHT_THRESHOLD; + const state = await getState(); + + const rows = (await state.fragments.query().limit(200000).toArray()) as unknown as Record< + string, + unknown + >[]; + + const byNamespace = new Map>(); + for (const row of rows) { + const ns = rowStr(row.namespace) || "global"; + const fragment = rowToFragment(row); + const weight = computeDecayedWeight(fragment, now, policyForNamespace(ns)); + const list = byNamespace.get(ns) ?? []; + list.push({ + id: fragment.id, + weight, + pinned: fragment.pinned === true, + consolidated: fragment.isConsolidated, + }); + byNamespace.set(ns, list); + } + + const decayedIds: string[] = []; + const capIds: string[] = []; + for (const [, list] of byNamespace) { + const remaining: typeof list = []; + for (const item of list) { + if (!item.pinned && item.consolidated && item.weight < threshold) { + decayedIds.push(item.id); + } else { + remaining.push(item); + } + } + if (remaining.length > cap) { + const evictable = remaining.filter((i) => !i.pinned).sort((a, b) => a.weight - b.weight); + const overflow = remaining.length - cap; + for (const item of evictable.slice(0, overflow)) capIds.push(item.id); + } + } + + if (decayedIds.length > 0) await deleteFragmentIds(state.fragments, decayedIds); + if (capIds.length > 0) await deleteFragmentIds(state.fragments, capIds); + return { decayedDeleted: decayedIds.length, capEvicted: capIds.length }; +} + +// ─── Lazy re-embed queue (§3.5) ────────────────────────────────────────────── + +/** + * Re-embed up to `maxItems` rows with embeddingStatus = "pending" (fragments + * first, then consolidated knowledge). Called at ≤ 2 req/s by the maintenance + * timer whenever a real provider is configured; also directly callable. + * Returns the number of rows re-embedded. + */ +export async function processReembedBatch(maxItems = 2): Promise { + if (getEmbeddingDim() === null) return 0; // no provider — nothing to do + const state = await getState(); + let processed = 0; + + for (const table of [state.fragments, state.consolidated]) { + if (processed >= maxItems) break; + const rows = (await table + .query() + .where("embeddingStatus = 'pending'") + .limit(maxItems - processed) + .toArray()) as unknown as Record[]; + for (const row of rows) { + const id = rowStr(row.id); + const text = rowStr(row.content) || rowStr(row.summary); + if (!text) { + // Nothing to embed — flip to "none" so we don't retry forever. + await table.update({ where: `id = ${sqlQuote(id)}`, values: { embeddingStatus: "none" } }); + continue; + } + const vector = await embed(text); + if (!vector || vector.length !== state.meta.dim) return processed; // provider failing — retry next tick + await table.update({ + where: `id = ${sqlQuote(id)}`, + values: { vector, embeddingStatus: "ok" }, + }); + processed++; + } + } + return processed; +} + +// ─── Maintenance timers ────────────────────────────────────────────────────── + +let _sweepTimer: ReturnType | null = null; +let _reembedTimer: ReturnType | null = null; + +function ensureMaintenanceTimers(): void { + // Tests drive sweeps/re-embeds explicitly — background timers would make + // row states flip mid-assertion. + if (process.env.VITEST || process.env.NODE_ENV === "test") return; + if (!_sweepTimer) { + _sweepTimer = setInterval(() => { + runEvictionSweep().catch((err) => console.warn("[memory] eviction sweep failed:", err)); + }, SWEEP_INTERVAL_MS); + _sweepTimer.unref?.(); + } + if (!_reembedTimer) { + _reembedTimer = setInterval(() => { + processReembedBatch(2).catch((err) => console.warn("[memory] re-embed batch failed:", err)); + }, REEMBED_INTERVAL_MS); + _reembedTimer.unref?.(); + } +} + +/** Stop background maintenance timers (shutdown / tests). */ +export function stopMemoryMaintenance(): void { + if (_sweepTimer) { + clearInterval(_sweepTimer); + _sweepTimer = null; + } + if (_reembedTimer) { + clearInterval(_reembedTimer); + _reembedTimer = null; + } +} + +// ─── Consolidated knowledge retrieval ──────────────────────────────────────── + +/** + * Active (non-superseded) consolidated knowledge for a namespace, optionally + * filtered by tag, ranked by confidence then recency. + */ +export async function getKnowledgeByNamespace( + namespace: string, + tag?: string, +): Promise { + const state = await getState(); + const conds = [`namespace = ${sqlQuote(namespace)}`, "supersededBy = ''"]; + if (tag) conds.push(`tag = ${sqlQuote(tag)}`); + const rows = (await state.consolidated + .query() + .where(conds.join(" AND ")) + .limit(1000) + .toArray()) as unknown as Record[]; + return rows + .map(rowToConsolidated) + .sort((a, b) => b.confidence - a.confidence || b.lastUpdated - a.lastUpdated); +} + +/** + * Retrieve consolidated knowledge for a repo, optionally filtered by tag + * (legacy-compatible API). * - * Note: Summary synthesis uses simple concatenation by default. - * In production, replace the `synthesize` function with an LLM call. + * v2 behavior change (§1.8 fix): repoRoot === "" now means the `global` + * namespace instead of matching literally-empty repoRoot rows, so existing + * callers that pass "" get cross-repo knowledge rather than nothing. */ -export async function consolidateSession( +export async function getConsolidatedKnowledge( + repoRoot: string, + tag?: string, +): Promise { + const namespace = repoRoot ? repoNamespace(repoRoot) : "global"; + return getKnowledgeByNamespace(namespace, tag); +} + +// ─── Consolidation support APIs (§3.4 Stage 1/3) ───────────────────────────── + +/** + * Un-consolidated fragments for a namespace (e.g. "session:abc", "repo:") + * or a bare session id, newest first. Embeddings are included (when status is + * "ok") so Stage-1 JUDGE can cluster/dedupe without re-embedding. + */ +export async function getUnconsolidatedFragments( + namespaceOrSessionId: string, + limit = 200, +): Promise { + const state = await getState(); + const scopeCond = isNamespaceString(namespaceOrSessionId) + ? `namespace = ${sqlQuote(namespaceOrSessionId)}` + : `sessionId = ${sqlQuote(namespaceOrSessionId)}`; + const rows = (await state.fragments + .query() + .where(`${scopeCond} AND isConsolidated = false`) + .limit(10000) + .toArray()) as unknown as Record[]; + return rows + .map(rowToFragment) + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, limit); +} + +/** + * Mark source fragments as consolidated into a knowledge row (§1.2 fix — + * makes isConsolidated/consolidatedInto live state). + */ +export async function markFragmentsConsolidated(ids: string[], knowledgeId: string): Promise { + if (ids.length === 0) return; + const state = await getState(); + const CHUNK = 200; + for (let i = 0; i < ids.length; i += CHUNK) { + const chunk = ids.slice(i, i + CHUNK); + await state.fragments.update({ + where: `id IN (${chunk.map(sqlQuote).join(", ")})`, + values: { isConsolidated: true, consolidatedInto: knowledgeId }, + }); + } +} + +export interface DistilledKnowledgeItem { + tag: string; + type?: KnowledgeType; + summary: string; + confidence: number; + sourceFragmentIds: string[]; + /** Existing knowledge ids this item replaces (tombstoned with supersededBy). */ + supersedes?: string[]; + /** Namespace class from the distillation output ("repo" | "global" | "agent") or a full namespace string. */ + namespace: string; +} + +export interface DistillationContext { + sessionId: string; + repoRoot: string; + backendType: string; + /** Defaults to "llm"; the concat fallback passes "concat". */ + synthesisMethod?: SynthesisMethod; +} + +function resolveKnowledgeNamespace(nsField: string, ctx: DistillationContext): string { + if (isNamespaceString(nsField)) return nsField; + if (nsField === "repo") return ctx.repoRoot ? repoNamespace(ctx.repoRoot) : "global"; + if (nsField === "agent") return agentNamespace(ctx.backendType); + if (nsField === "session") return sessionNamespace(ctx.sessionId); + return "global"; +} + +/** + * Stage-3 CONSOLIDATE (§3.4): upsert distilled knowledge by (namespace, tag). + * - Any active row with the same (namespace, tag), plus any rows named in + * `supersedes`, is tombstoned with supersededBy = (audit trail). + * - The new summary is embedded (status "pending" if that fails/no provider). + * - All sourceFragmentIds are marked consolidated into the new row. + * Idempotent: re-running with the same (namespace, tag) replaces, never duplicates. + */ +export async function upsertKnowledgeFromDistillation( + items: DistilledKnowledgeItem[], + ctx: DistillationContext, +): Promise { + const state = await getState(); + const dim = state.meta.dim; + const results: ConsolidatedKnowledge[] = []; + const synthesisMethod: SynthesisMethod = ctx.synthesisMethod ?? "llm"; + + for (const item of items) { + const namespace = resolveKnowledgeNamespace(item.namespace, ctx); + const cls = namespaceClass(namespace); + const repoRoot = cls === "repo" ? ctx.repoRoot : ""; + const newId = randomUUID(); + const now = Date.now(); + + // Tombstone: same (namespace, tag) active rows + explicit supersedes list. + await state.consolidated.update({ + where: `namespace = ${sqlQuote(namespace)} AND tag = ${sqlQuote(item.tag)} AND supersededBy = ''`, + values: { supersededBy: newId }, + }); + const supersedes = (item.supersedes ?? []).filter(Boolean); + if (supersedes.length > 0) { + await state.consolidated.update({ + where: `id IN (${supersedes.map(sqlQuote).join(", ")})`, + values: { supersededBy: newId }, + }); + } + + const embedding = await embed(item.summary); + let status: EmbeddingStatus; + let vector: number[]; + if (embedding && embedding.length === dim) { + status = "ok"; + vector = embedding; + } else if (getEmbeddingDim() === null) { + status = "none"; + vector = zeros(dim); + } else { + status = "pending"; + vector = zeros(dim); + } + + await state.consolidated.add([ + { + id: newId, + tag: item.tag, + summary: item.summary, + sourceFragmentsJson: JSON.stringify(item.sourceFragmentIds), + lastUpdated: now, + confidence: item.confidence, + repoRoot, + namespace, + repoRootHash: repoRoot ? hashRepoRoot(repoRoot) : "", + knowledgeType: item.type ?? "", + synthesisMethod, + supersededBy: "", + accessCount: 0, + lastReinforcedAt: now, + embeddingStatus: status, + vector, + }, + ]); + + await markFragmentsConsolidated(item.sourceFragmentIds, newId); + + results.push({ + id: newId, + tag: item.tag, + summary: item.summary, + sourceFragments: item.sourceFragmentIds, + lastUpdated: now, + confidence: item.confidence, + repoRoot, + namespace, + type: item.type, + synthesisMethod, + supersededBy: undefined, + }); + } + + return results; +} + +/** + * Active knowledge in a namespace whose embedding is within `threshold` + * cosine similarity of a centroid (§3.4 Stage 2 — `existingKnowledge` input). + * Accepts a precomputed centroid vector or texts to embed-and-average. + * Returns [] when no provider/vectors are available. + */ +export async function findRelatedKnowledge( + namespace: string, + centroidVectorOrTexts: number[] | string[], + threshold = 0.8, +): Promise { + const state = await getState(); + if (centroidVectorOrTexts.length === 0) return []; + + let centroid: number[] | null = null; + if (typeof centroidVectorOrTexts[0] === "number") { + centroid = centroidVectorOrTexts as number[]; + } else { + const vectors: number[][] = []; + for (const text of centroidVectorOrTexts as string[]) { + const v = await embed(text); + if (v && v.length === state.meta.dim) vectors.push(v); + } + if (vectors.length > 0) { + centroid = zeros(state.meta.dim); + for (const v of vectors) for (let i = 0; i < v.length; i++) centroid[i] += v[i]; + for (let i = 0; i < centroid.length; i++) centroid[i] /= vectors.length; + } + } + if (!centroid || centroid.length !== state.meta.dim) return []; + + const rows = (await state.consolidated + .search(centroid) + .where(`namespace = ${sqlQuote(namespace)} AND supersededBy = '' AND embeddingStatus = 'ok'`) + .limit(50) + .toArray()) as unknown as Record[]; + return rows + .filter((row) => cosineSimilarity(centroid as number[], toNumberArray(row.vector)) >= threshold) + .map(rowToConsolidated); +} + +/** + * No-LLM consolidation fallback: today's concatenation synthesis, grouped by + * tag, upserted by (namespace, tag) with synthesisMethod = "concat" and all + * sources marked consolidated. Used when no OpenRouter key is configured or + * when distillation output fails validation (§3.4). + */ +export async function concatFallbackConsolidate( sessionId: string, repoRoot: string, + backendType = "claude", ): Promise { - const fragments = await getSessionFragments(sessionId); + const fragments = await getUnconsolidatedFragments(sessionId, 10000); if (fragments.length === 0) return []; - // Group by tag const byTag = new Map(); for (const f of fragments) { for (const tag of f.tags.length > 0 ? f.tags : ["general"]) { @@ -317,57 +1093,212 @@ export async function consolidateSession( } } - const table = await getConsolidatedTable(); - const results: ConsolidatedKnowledge[] = []; - + const items: DistilledKnowledgeItem[] = []; for (const [tag, tagFragments] of byTag) { - const avgConfidence = tagFragments.reduce((s, f) => s + f.confidence, 0) / tagFragments.length; - const summary = synthesize(tag, tagFragments); - const knowledge: ConsolidatedKnowledge = { - id: randomUUID(), + const avgConfidence = + tagFragments.reduce((s, f) => s + f.confidence, 0) / tagFragments.length; + items.push({ tag, - summary, - sourceFragments: tagFragments.map((f) => f.id), - lastUpdated: Date.now(), + summary: synthesize(tag, tagFragments), confidence: avgConfidence, - repoRoot, - }; - - await table.add([{ - id: knowledge.id, - tag: knowledge.tag, - summary: knowledge.summary, - sourceFragmentsJson: JSON.stringify(knowledge.sourceFragments), - lastUpdated: knowledge.lastUpdated, - confidence: knowledge.confidence, - repoRoot: knowledge.repoRoot, - }]); - - results.push(knowledge); + sourceFragmentIds: tagFragments.map((f) => f.id), + namespace: repoRoot ? "repo" : "global", + }); } - return results; + return upsertKnowledgeFromDistillation(items, { + sessionId, + repoRoot, + backendType, + synthesisMethod: "concat", + }); } /** - * Retrieve consolidated knowledge for a repo, optionally filtered by tag. + * Consolidate episodic fragments from a session into semantic knowledge + * (legacy-compatible API — routes to the v2 concat fallback). + * + * v2 (§1.2 fix): idempotent — upserts by (namespace, tag), tombstones the + * replaced rows, and marks source fragments isConsolidated/consolidatedInto. + * Running twice no longer duplicates rows (the second run sees no + * un-consolidated fragments and returns []). */ -export async function getConsolidatedKnowledge( +export async function consolidateSession( + sessionId: string, repoRoot: string, - tag?: string, ): Promise { - const table = await getConsolidatedTable(); - const result = await table.query().limit(1000).toArray(); - return (result as unknown as Record[]) - .map(rowToConsolidated) - .filter((k) => k.repoRoot === repoRoot && (!tag || k.tag === tag)); + return concatFallbackConsolidate(sessionId, repoRoot); +} + +// ─── Enrichment entry point (§3.3 + §3.6.2) ────────────────────────────────── + +export interface EnrichmentQueryOptions { + sessionId: string; + repoRoot: string; + backendType: string; + queryText: string; +} + +export interface EnrichmentResult { + items: MemoryEnrichmentItem[]; + /** Injectable block per §3.6.2, or null when nothing was recalled. */ + block: string | null; } -// ─── Internal helpers ───────────────────────────────────────────────────────── +const ENRICHMENT_HEADER = "--- Campfire memory (auto-recalled; may be stale) ---"; +const ENRICHMENT_FOOTER = "--- end memory ---"; +const LINE_CLIP_CHARS = 300; + +function clip(text: string, max: number): string { + const oneLine = text.replace(/\s+/g, " ").trim(); + return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 1)}…`; +} /** - * Simple synthesis: concatenate fragment contents into a bullet list. - * Replace with an LLM call for production-quality summaries. + * Query memory for prompt enrichment. Namespace recall order per §3.6.2: + * fragments query [repo:, agent:, global] — NOT session:, + * since same-session context is already in the agent's own conversation. + * Consolidated knowledge (same namespaces) is ranked above fragments. + * + * Budgets: ≤ ~1200 chars of fragment text, ≤ ~2000 chars total, max 5 + * fragment lines. Every included row is reinforced (§3.2). Returns the exact + * injectable block plus the item list for the UI `memory_enriched` chip. + */ +export async function queryForEnrichment(opts: EnrichmentQueryOptions): Promise { + const depths = getMemorySettings().recallDepth; + const now = Date.now(); + const nsRepo = opts.repoRoot ? repoNamespace(opts.repoRoot) : null; + const nsAgent = agentNamespace(opts.backendType); + + // Consolidated knowledge, priority order: repo → agent → global. + const knowledgeNamespaces = [nsRepo, nsAgent, "global"].filter((n): n is string => !!n); + const knowledgeCandidates: ConsolidatedKnowledge[] = []; + for (const ns of knowledgeNamespaces) { + knowledgeCandidates.push(...(await getKnowledgeByNamespace(ns))); + } + + // Fragments: repo/agent/global with per-namespace recall depths (§3.1). + const plan: RecallPlanEntry[] = []; + if (nsRepo) plan.push({ namespace: nsRepo, depth: depths.repo }); + plan.push({ namespace: nsAgent, depth: depths.agent }); + plan.push({ namespace: "global", depth: depths.global }); + const scoredFragments = await queryScoredFragments(opts.queryText, plan, now); + + // Assemble under budgets. Header/footer + section labels count toward total. + const items: MemoryEnrichmentItem[] = []; + const knowledgeLines: string[] = []; + const fragmentLines: string[] = []; + const baseOverhead = + ENRICHMENT_HEADER.length + ENRICHMENT_FOOTER.length + "Knowledge:".length + "Notes:".length + 4; + let totalChars = baseOverhead; + + for (const k of knowledgeCandidates) { + const line = `- [${k.tag}] ${clip(k.summary, LINE_CLIP_CHARS)}`; + if (totalChars + line.length + 1 > TOTAL_BUDGET_CHARS) break; + knowledgeLines.push(line); + totalChars += line.length + 1; + items.push({ + id: k.id, + kind: "knowledge", + namespace: k.namespace ?? "", + summary: k.summary, + tag: k.tag, + weight: 1, // consolidated knowledge does not decay (§3.2) + }); + } + + let fragmentChars = 0; + for (const s of scoredFragments) { + if (fragmentLines.length >= MAX_ENRICHMENT_FRAGMENTS) break; + if (s.score <= 0) continue; + const line = `- [${s.fragment.type}] ${clip(s.fragment.content, LINE_CLIP_CHARS)}`; + if (fragmentChars + line.length + 1 > FRAGMENT_BUDGET_CHARS) break; + if (totalChars + line.length + 1 > TOTAL_BUDGET_CHARS) break; + fragmentLines.push(line); + fragmentChars += line.length + 1; + totalChars += line.length + 1; + items.push({ + id: s.fragment.id, + kind: "fragment", + namespace: s.fragment.namespace ?? "", + summary: clip(s.fragment.content, LINE_CLIP_CHARS), + tag: s.fragment.tags[0], + weight: s.weight, + }); + } + + if (items.length === 0) return { items: [], block: null }; + + const parts: string[] = [ENRICHMENT_HEADER]; + if (knowledgeLines.length > 0) parts.push("Knowledge:", ...knowledgeLines); + if (fragmentLines.length > 0) parts.push("Notes:", ...fragmentLines); + parts.push(ENRICHMENT_FOOTER); + + // Reinforce exactly what was included (§3.2: inclusion reinforces, matching doesn't). + reinforceFragments(items.filter((i) => i.kind === "fragment").map((i) => i.id)); + reinforceKnowledge(items.filter((i) => i.kind === "knowledge").map((i) => i.id)); + + return { items, block: parts.join("\n") }; +} + +// ─── Namespace overview (UI) ───────────────────────────────────────────────── + +export interface NamespaceOverviewEntry { + namespace: string; + count: number; + avgWeight: number; + pinnedCount: number; +} + +/** + * Per-namespace fragment stats for the memory panel: count, average decayed + * weight, and pinned count, over [session:, repo:, agent:, global]. + */ +export async function getNamespaceOverview(opts: { + sessionId: string; + repoRoot: string; + backendType: string; +}): Promise { + const state = await getState(); + const now = Date.now(); + const namespaces = [ + sessionNamespace(opts.sessionId), + ...(opts.repoRoot ? [repoNamespace(opts.repoRoot)] : []), + agentNamespace(opts.backendType), + "global", + ]; + + const overview: NamespaceOverviewEntry[] = []; + for (const namespace of namespaces) { + const rows = (await state.fragments + .query() + .where(buildNamespaceWhere(namespace, false)) + .limit(100000) + .toArray()) as unknown as Record[]; + const policy = policyForNamespace(namespace); + let weightSum = 0; + let pinnedCount = 0; + for (const row of rows) { + const fragment = rowToFragment(row); + weightSum += computeDecayedWeight(fragment, now, policy); + if (fragment.pinned) pinnedCount++; + } + overview.push({ + namespace, + count: rows.length, + avgWeight: rows.length > 0 ? weightSum / rows.length : 0, + pinnedCount, + }); + } + return overview; +} + +// ─── Internal helpers ──────────────────────────────────────────────────────── + +/** + * Concatenation synthesis — the no-LLM fallback (§3.4). Kept from v1; the + * LLM distillation pipeline (memory-consolidation.ts) replaces this as the + * primary path. */ function synthesize(tag: string, fragments: MemoryFragment[]): string { const sorted = [...fragments].sort((a, b) => b.confidence - a.confidence); @@ -380,10 +1311,19 @@ function synthesize(tag: string, fragments: MemoryFragment[]): string { // ─── Test helpers ───────────────────────────────────────────────────────────── -/** Override memory dir for tests — must be called before any DB access. */ +/** + * Override the memory root dir for tests — must be called before any DB access. + * v2 note: the argument is the memory ROOT (meta.json lives here; LanceDB in + * /lancedb), where v1 treated it as the LanceDB dir itself. + */ export function _resetForTest(dir: string): void { - _db = null; - _fragmentsTable = null; - _consolidatedTable = null; - _memoryDir = dir; + _initPromise = null; + _memoryRoot = dir; + stopMemoryMaintenance(); + if (_reinforceTimer) { + clearTimeout(_reinforceTimer); + _reinforceTimer = null; + } + _pendingReinforce.fragments.clear(); + _pendingReinforce.knowledge.clear(); } diff --git a/web/server/session-types.ts b/web/server/session-types.ts index e190325..76aef48 100644 --- a/web/server/session-types.ts +++ b/web/server/session-types.ts @@ -223,6 +223,10 @@ export type BrowserIncomingMessageBase = | { type: "memory_stored"; fragment: import("./semantic-memory.js").MemoryFragment } | { type: "memory_query_result"; query: string; results: import("./semantic-memory.js").MemoryFragment[] } | { type: "memory_consolidated"; tag: string; knowledge: import("./semantic-memory.js").ConsolidatedKnowledge } + // Emitted when a user_message was enriched with recalled memories, so the + // UI can render a collapsible "recalled context" chip instead of the + // injected text being invisible. `items` lists exactly what was included. + | { type: "memory_enriched"; user_message_id?: string; items: MemoryEnrichmentItem[]; truncated?: boolean } // Layer 2: Deliberation | { type: "deliberation_proposal"; proposal: import("./deliberation-engine.js").DeliberationProposal } | { type: "deliberation_response"; response: import("./deliberation-engine.js").DeliberationResponse } @@ -237,6 +241,17 @@ export type BrowserIncomingMessageBase = | { type: "semantic_link_added"; sourceId: string; targetId: string; relation: string } | { type: "consensus_update"; state: import("./shared-context.js").ConsensusState }; +/** One recalled memory included in an enriched prompt (for the UI chip). */ +export interface MemoryEnrichmentItem { + id: string; + kind: "knowledge" | "fragment"; + namespace: string; + summary: string; + tag?: string; + /** Decayed weight at recall time (0..1). */ + weight: number; +} + export type BrowserIncomingMessage = BrowserIncomingMessageBase & { seq?: number }; export type ReplayableBrowserIncomingMessage = Exclude; diff --git a/web/server/settings-manager.test.ts b/web/server/settings-manager.test.ts index 4987e40..629b76b 100644 --- a/web/server/settings-manager.test.ts +++ b/web/server/settings-manager.test.ts @@ -6,6 +6,8 @@ import { updateSettings, _resetForTest, DEFAULT_OPENROUTER_MODEL, + DEFAULT_MEMORY_SETTINGS, + normalizeMemorySettings, } from "./settings-manager.js"; let tempDir: string; @@ -37,6 +39,8 @@ describe("settings-manager", () => { embeddingApiKey: "", embeddingModel: "", embeddingBaseUrl: "http://localhost:11434", + // Semantic memory v2 adds a `memory` section with decay/recall defaults + memory: DEFAULT_MEMORY_SETTINGS, // Onboarding wizard has not been completed by default onboardingCompleted: false, updatedAt: 0, @@ -80,6 +84,8 @@ describe("settings-manager", () => { embeddingApiKey: "", embeddingModel: "", embeddingBaseUrl: "http://localhost:11434", + // Semantic memory v2: absent `memory` section normalizes to defaults + memory: DEFAULT_MEMORY_SETTINGS, onboardingCompleted: false, updatedAt: 123, }); @@ -130,8 +136,78 @@ describe("settings-manager", () => { embeddingApiKey: "", embeddingModel: "", embeddingBaseUrl: "http://localhost:11434", + // Semantic memory v2: malformed values also normalize to defaults + memory: DEFAULT_MEMORY_SETTINGS, onboardingCompleted: false, updatedAt: 0, }); }); }); + +// ─── Semantic memory v2 settings (design doc §3.1/§3.2) ────────────────────── + +describe("settings-manager memory section", () => { + it("exposes the documented decay/recall defaults", () => { + // Validates §3.1 defaults: 90d/30d/7d/60d half-lives (hours) with + // ×1.5/1.5/1.2/1.2 reinforcement, and recall depths 4/6/2/3. + // (memory is typed optional for pre-v2 mock compat but always populated) + const memory = getSettings().memory!; + expect(memory.decay.global).toEqual({ halfLifeHours: 2160, reinforceMultiplier: 1.5 }); + expect(memory.decay.repo).toEqual({ halfLifeHours: 720, reinforceMultiplier: 1.5 }); + expect(memory.decay.session).toEqual({ halfLifeHours: 168, reinforceMultiplier: 1.2 }); + expect(memory.decay.agent).toEqual({ halfLifeHours: 1440, reinforceMultiplier: 1.2 }); + expect(memory.recallDepth).toEqual({ session: 4, repo: 6, agent: 2, global: 3 }); + }); + + it("deep-merges a partial memory patch over current values and persists it", () => { + // A patch touching only decay.repo.halfLifeHours must not clobber the + // sibling multiplier, the other namespace classes, or recallDepth. + const updated = updateSettings({ memory: { decay: { repo: { halfLifeHours: 100 } } } }); + expect(updated.memory!.decay.repo).toEqual({ halfLifeHours: 100, reinforceMultiplier: 1.5 }); + expect(updated.memory!.decay.global).toEqual(DEFAULT_MEMORY_SETTINGS.decay.global); + expect(updated.memory!.recallDepth).toEqual(DEFAULT_MEMORY_SETTINGS.recallDepth); + + const saved = JSON.parse(readFileSync(settingsPath, "utf-8")); + expect(saved.memory.decay.repo.halfLifeHours).toBe(100); + }); + + it("preserves an explicit null half-life (never decays)", () => { + // §3.2: halfLifeHours = null means no decay (used for pinned/curated memories) + const updated = updateSettings({ memory: { decay: { global: { halfLifeHours: null } } } }); + expect(updated.memory!.decay.global.halfLifeHours).toBeNull(); + }); + + it("normalizes invalid memory values back to defaults", () => { + // Negative half-lives, sub-1 multipliers, and negative depths are rejected + const updated = updateSettings({ + memory: { + decay: { repo: { halfLifeHours: -5, reinforceMultiplier: 0.5 } }, + recallDepth: { repo: -1, session: 2.9 }, + }, + }); + expect(updated.memory!.decay.repo).toEqual(DEFAULT_MEMORY_SETTINGS.decay.repo); + expect(updated.memory!.recallDepth.repo).toBe(DEFAULT_MEMORY_SETTINGS.recallDepth.repo); + // Fractional depths are floored + expect(updated.memory!.recallDepth.session).toBe(2); + }); + + it("loads a partial memory section from disk merged with defaults", () => { + writeFileSync( + settingsPath, + JSON.stringify({ memory: { recallDepth: { repo: 9 } } }), + "utf-8", + ); + _resetForTest(settingsPath); + const memory = getSettings().memory!; + expect(memory.recallDepth.repo).toBe(9); + expect(memory.recallDepth.global).toBe(3); + expect(memory.decay).toEqual(DEFAULT_MEMORY_SETTINGS.decay); + }); + + it("normalizeMemorySettings handles garbage input", () => { + // Non-object / array / nested-garbage inputs all yield the full default shape + expect(normalizeMemorySettings(null)).toEqual(DEFAULT_MEMORY_SETTINGS); + expect(normalizeMemorySettings("nope")).toEqual(DEFAULT_MEMORY_SETTINGS); + expect(normalizeMemorySettings({ decay: "x", recallDepth: 7 })).toEqual(DEFAULT_MEMORY_SETTINGS); + }); +}); diff --git a/web/server/settings-manager.ts b/web/server/settings-manager.ts index e5dd2a1..63cf95a 100644 --- a/web/server/settings-manager.ts +++ b/web/server/settings-manager.ts @@ -11,6 +11,85 @@ export const DEFAULT_OPENROUTER_MODEL = "openrouter/free"; export type EmbeddingProvider = "openai" | "ollama" | "none"; +// ─── Semantic memory settings (design doc §3.1/§3.2) ───────────────────────── + +/** Decay policy for one namespace class. halfLifeHours = null → never decays. */ +export interface MemoryDecayPolicy { + halfLifeHours: number | null; + reinforceMultiplier: number; +} + +export interface MemorySettings { + decay: { + global: MemoryDecayPolicy; + repo: MemoryDecayPolicy; + session: MemoryDecayPolicy; + agent: MemoryDecayPolicy; + }; + /** Per-namespace recall depth for retrieval (ADR-161's "depth is a tunable"). */ + recallDepth: { + session: number; + repo: number; + agent: number; + global: number; + }; +} + +/** Defaults per §3.1: 90d/30d/7d/60d half-lives (in hours), ×1.5/1.5/1.2/1.2. */ +export const DEFAULT_MEMORY_SETTINGS: MemorySettings = { + decay: { + global: { halfLifeHours: 90 * 24, reinforceMultiplier: 1.5 }, + repo: { halfLifeHours: 30 * 24, reinforceMultiplier: 1.5 }, + session: { halfLifeHours: 7 * 24, reinforceMultiplier: 1.2 }, + agent: { halfLifeHours: 60 * 24, reinforceMultiplier: 1.2 }, + }, + recallDepth: { session: 4, repo: 6, agent: 2, global: 3 }, +}; + +function normalizeDecayPolicy(raw: unknown, fallback: MemoryDecayPolicy): MemoryDecayPolicy { + const r = (raw ?? {}) as Partial; + const halfLifeHours = + r.halfLifeHours === null + ? null + : typeof r.halfLifeHours === "number" && Number.isFinite(r.halfLifeHours) && r.halfLifeHours > 0 + ? r.halfLifeHours + : fallback.halfLifeHours; + const reinforceMultiplier = + typeof r.reinforceMultiplier === "number" && + Number.isFinite(r.reinforceMultiplier) && + r.reinforceMultiplier >= 1 + ? r.reinforceMultiplier + : fallback.reinforceMultiplier; + return { halfLifeHours, reinforceMultiplier }; +} + +function normalizeRecallDepth(raw: unknown, key: keyof MemorySettings["recallDepth"]): number { + const r = (raw ?? {}) as Record; + const v = r[key]; + if (typeof v === "number" && Number.isFinite(v) && v >= 0) return Math.floor(v); + return DEFAULT_MEMORY_SETTINGS.recallDepth[key]; +} + +/** Deep-merge a (possibly partial/invalid) saved memory section over the defaults. */ +export function normalizeMemorySettings(raw: unknown): MemorySettings { + const r = (raw ?? {}) as { decay?: Record; recallDepth?: unknown }; + const decayRaw = r.decay ?? {}; + return { + decay: { + global: normalizeDecayPolicy(decayRaw.global, DEFAULT_MEMORY_SETTINGS.decay.global), + repo: normalizeDecayPolicy(decayRaw.repo, DEFAULT_MEMORY_SETTINGS.decay.repo), + session: normalizeDecayPolicy(decayRaw.session, DEFAULT_MEMORY_SETTINGS.decay.session), + agent: normalizeDecayPolicy(decayRaw.agent, DEFAULT_MEMORY_SETTINGS.decay.agent), + }, + recallDepth: { + session: normalizeRecallDepth(r.recallDepth, "session"), + repo: normalizeRecallDepth(r.recallDepth, "repo"), + agent: normalizeRecallDepth(r.recallDepth, "agent"), + global: normalizeRecallDepth(r.recallDepth, "global"), + }, + }; +} + export interface CampfireSettings { openrouterApiKey: string; openrouterModel: string; @@ -28,6 +107,13 @@ export interface CampfireSettings { embeddingApiKey: string; // OpenAI API key (if provider = "openai") embeddingModel: string; // e.g. "text-embedding-3-small" or "nomic-embed-text" embeddingBaseUrl: string; // Ollama base URL (if provider = "ollama"), default http://localhost:11434 + /** + * Semantic memory v2: decay policies + recall depths per namespace class. + * Always populated by normalize() at runtime — typed optional only so + * pre-v2 CampfireSettings literals (test mocks) keep compiling. Use + * getMemorySettings() for guaranteed-present typed access. + */ + memory?: MemorySettings; /** Whether the onboarding wizard has been completed or skipped */ onboardingCompleted: boolean; updatedAt: number; @@ -49,6 +135,7 @@ let settings: CampfireSettings = { embeddingApiKey: "", embeddingModel: "", embeddingBaseUrl: "http://localhost:11434", + memory: normalizeMemorySettings(null), onboardingCompleted: false, updatedAt: 0, }; @@ -71,6 +158,7 @@ function normalize(raw: Partial | null | undefined): CampfireS embeddingBaseUrl: typeof raw?.embeddingBaseUrl === "string" && raw.embeddingBaseUrl.trim() ? raw.embeddingBaseUrl : "http://localhost:11434", + memory: normalizeMemorySettings(raw?.memory), onboardingCompleted: raw?.onboardingCompleted === true, updatedAt: typeof raw?.updatedAt === "number" ? raw.updatedAt : 0, }; @@ -99,14 +187,45 @@ export function getSettings(): CampfireSettings { return { ...settings }; } +/** Semantic memory settings with defaults guaranteed (never undefined). */ +export function getMemorySettings(): MemorySettings { + ensureLoaded(); + return settings.memory ?? normalizeMemorySettings(null); +} + +/** Deep-merge a partial memory patch over the current memory settings, then normalize. */ +function mergeMemoryPatch(current: MemorySettings, patch: unknown): MemorySettings { + if (!patch || typeof patch !== "object") return current; + const p = patch as { + decay?: Record | undefined>; + recallDepth?: Record; + }; + return normalizeMemorySettings({ + decay: { + global: { ...current.decay.global, ...p.decay?.global }, + repo: { ...current.decay.repo, ...p.decay?.repo }, + session: { ...current.decay.session, ...p.decay?.session }, + agent: { ...current.decay.agent, ...p.decay?.agent }, + }, + recallDepth: { ...current.recallDepth, ...p.recallDepth }, + }); +} + export function updateSettings( - patch: Partial>, + patch: Partial> & { + /** Partial memory settings — deep-merged over the current values. */ + memory?: unknown; + }, ): CampfireSettings { ensureLoaded(); + const { memory: memoryPatch, ...rest } = patch; settings = { ...settings, - ...patch, - openrouterModel: patch.openrouterModel?.trim() || settings.openrouterModel || DEFAULT_OPENROUTER_MODEL, + ...rest, + openrouterModel: rest.openrouterModel?.trim() || settings.openrouterModel || DEFAULT_OPENROUTER_MODEL, + memory: memoryPatch !== undefined + ? mergeMemoryPatch(settings.memory ?? normalizeMemorySettings(null), memoryPatch) + : settings.memory, updatedAt: Date.now(), }; persist(); diff --git a/web/server/ws-bridge.test.ts b/web/server/ws-bridge.test.ts index 7ea2575..4c4369b 100644 --- a/web/server/ws-bridge.test.ts +++ b/web/server/ws-bridge.test.ts @@ -6,6 +6,25 @@ vi.mock("node:child_process", () => ({ })); vi.mock("node:crypto", () => ({ randomUUID: () => "test-uuid" })); +// ws-bridge imports memory-consolidation for the §3.4 consolidation triggers. +// Mock it for the whole file: (a) these tests assert the *wiring* (when the +// triggers fire), not the pipeline itself; (b) the real implementation pulls +// in semantic-memory → node:crypto createHash, which the fixed randomUUID +// mock above would break. +const mockConsolidation = vi.hoisted(() => ({ + consolidate: vi.fn(async (ctx: { reason: string }) => ({ + status: "ran" as const, + synthesisMethod: "none" as const, + knowledgeUpserted: 0, + fragmentsConsolidated: 0, + reason: ctx.reason, + })), + shouldConsolidateOnTurn: vi.fn(async () => false), + noteSessionActivity: vi.fn(), + stopIdleWatcher: vi.fn(), +})); +vi.mock("./memory-consolidation.js", () => mockConsolidation); + import { WsBridge, type SocketData } from "./ws-bridge.js"; import { SessionStore } from "./session-store.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -3469,3 +3488,343 @@ describe("invite token expiry", () => { expect(bridge.resolveInviteTokenRole(token)).toBeNull(); }); }); + +// ─── Memory enrichment + consolidation triggers (semantic-memory v2 §3.4/§3.6) ─ + +describe("Memory enrichment (user_message hook)", () => { + let cli: ReturnType; + let browser: ReturnType; + + /** Fake collective-intelligence layer with a scriptable enrichUserMessage. */ + function makeFakeCi(enrich: (...args: unknown[]) => unknown) { + return { + setBroadcast: vi.fn(), + processAgentMessage: vi.fn(), + processBrowserMessage: vi.fn(async () => null), + onSessionEnd: vi.fn(async () => {}), + enrichUserMessage: vi.fn(enrich as any), + }; + } + + beforeEach(() => { + mockConsolidation.consolidate.mockClear(); + mockConsolidation.shouldConsolidateOnTurn.mockClear(); + mockConsolidation.noteSessionActivity.mockClear(); + cli = makeCliSocket("s1"); + browser = makeBrowserSocket("s1"); + bridge.handleCLIOpen(cli, "s1"); + bridge.handleBrowserOpen(browser, "s1"); + cli.send.mockClear(); + browser.send.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("user_message: backend receives enriched content, history stores the original, memory_enriched carries the history id", async () => { + // Validates §3.6.1-3.6.4 end-to-end on the Claude CLI path: the block is + // prepended for the backend, messageHistory keeps what the user typed + // (replay must show the real prompt), and the memory_enriched broadcast + // references the history entry id so the UI can attach the recall chip. + const items = [ + { id: "k1", kind: "knowledge", namespace: "global", summary: "Use bun for scripts", tag: "tooling", weight: 1 }, + ]; + const block = "--- Campfire memory (auto-recalled; may be stale) ---\nKnowledge:\n- [tooling] Use bun for scripts\n--- end memory ---"; + const ci = makeFakeCi(async () => ({ items, block })); + bridge.setCollectiveIntelligence(ci as any); + + bridge.handleBrowserMessage(browser, JSON.stringify({ + type: "user_message", + content: "What runtime do we use?", + })); + + await vi.waitFor(() => expect(cli.send).toHaveBeenCalledTimes(1)); + + // Backend receives block + "\n\n" + original prompt + const sent = JSON.parse((cli.send.mock.calls[0][0] as string).trim()); + expect(sent.type).toBe("user"); + expect(sent.message.content).toBe(`${block}\n\nWhat runtime do we use?`); + + // History stores the ORIGINAL user text with a stable id + const session = bridge.getSession("s1")!; + const userEntry = session.messageHistory.find((m) => m.type === "user_message") as + { type: "user_message"; content: string; id?: string }; + expect(userEntry.content).toBe("What runtime do we use?"); + expect(userEntry.id).toMatch(/^user-\d+-\d+$/); + + // memory_enriched broadcast carries the history entry id + the items + const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg)); + const enriched = calls.find((c: any) => c.type === "memory_enriched"); + expect(enriched).toBeDefined(); + expect(enriched.user_message_id).toBe(userEntry.id); + expect(enriched.items).toEqual(items); + + // The CI hook received the session context (repoRoot plumbing, §3.6 item 5) + expect(ci.enrichUserMessage).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "s1", backendType: "claude" }), + "What runtime do we use?", + ); + }); + + it("user_message: slow enrichment (over the 250ms budget) passes the original through", async () => { + // Validates the Promise.race timeout (§3.6.1): chat must never block on + // memory. A never-resolving enrichment is abandoned at the deadline and + // the untouched prompt is dispatched; no memory_enriched is broadcast. + vi.useFakeTimers(); + const ci = makeFakeCi(() => new Promise(() => {})); // hangs forever + bridge.setCollectiveIntelligence(ci as any); + + bridge.handleBrowserMessage(browser, JSON.stringify({ + type: "user_message", + content: "hello there, agent", + })); + + // Still awaiting the race — nothing dispatched yet + await vi.advanceTimersByTimeAsync(0); + expect(cli.send).not.toHaveBeenCalled(); + + // Cross the enrichment deadline + await vi.advanceTimersByTimeAsync(251); + expect(cli.send).toHaveBeenCalledTimes(1); + const sent = JSON.parse((cli.send.mock.calls[0][0] as string).trim()); + expect(sent.message.content).toBe("hello there, agent"); + + const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg)); + expect(calls.some((c: any) => c.type === "memory_enriched")).toBe(false); + }); + + it("user_message: enrichment error passes the original through", async () => { + // Validates the try/catch pass-through (§3.6.1): a throwing memory layer + // must never break or mutate the chat flow. + const ci = makeFakeCi(async () => { + throw new Error("lancedb exploded"); + }); + bridge.setCollectiveIntelligence(ci as any); + + bridge.handleBrowserMessage(browser, JSON.stringify({ + type: "user_message", + content: "carry on regardless", + })); + + await vi.waitFor(() => expect(cli.send).toHaveBeenCalledTimes(1)); + const sent = JSON.parse((cli.send.mock.calls[0][0] as string).trim()); + expect(sent.message.content).toBe("carry on regardless"); + + const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg)); + expect(calls.some((c: any) => c.type === "memory_enriched")).toBe(false); + }); + + it("user_message: memory_enriched broadcast is skipped when block is null", async () => { + // Validates §3.6.4: nothing recalled → no broadcast, prompt unchanged. + const ci = makeFakeCi(async () => ({ items: [], block: null })); + bridge.setCollectiveIntelligence(ci as any); + + bridge.handleBrowserMessage(browser, JSON.stringify({ + type: "user_message", + content: "nothing recalled", + })); + + await vi.waitFor(() => expect(cli.send).toHaveBeenCalledTimes(1)); + const sent = JSON.parse((cli.send.mock.calls[0][0] as string).trim()); + expect(sent.message.content).toBe("nothing recalled"); + + const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg)); + expect(calls.some((c: any) => c.type === "memory_enriched")).toBe(false); + }); + + it("user_message: successive messages dispatch in order even when the first enrichment is slower", async () => { + // Validates the async-ripple decision: per-session user messages are + // serialized through a promise chain, so a slow enrichment on msg1 cannot + // let msg2 overtake it on the way to the backend. + vi.useFakeTimers(); + let call = 0; + const ci = makeFakeCi(() => { + call++; + if (call === 1) { + return new Promise((resolve) => setTimeout(() => resolve({ items: [], block: null }), 100)); + } + return Promise.resolve({ items: [], block: null }); + }); + bridge.setCollectiveIntelligence(ci as any); + + bridge.handleBrowserMessage(browser, JSON.stringify({ type: "user_message", content: "first" })); + bridge.handleBrowserMessage(browser, JSON.stringify({ type: "user_message", content: "second" })); + + await vi.advanceTimersByTimeAsync(300); + + expect(cli.send).toHaveBeenCalledTimes(2); + const first = JSON.parse((cli.send.mock.calls[0][0] as string).trim()); + const second = JSON.parse((cli.send.mock.calls[1][0] as string).trim()); + expect(first.message.content).toBe("first"); + expect(second.message.content).toBe("second"); + }); + + it("user_message (adapter path): adapter receives enriched content while history stores the original", async () => { + // Validates §3.6.1 for adapter-based backends (Codex/Goose/...): the + // enrichment block goes to the adapter, replay history stays clean. + const adapter = { + sendBrowserMessage: vi.fn(() => true), + onBrowserMessage: vi.fn(), + onSessionMeta: vi.fn(), + onDisconnect: vi.fn(), + onInitError: vi.fn(), + isConnected: vi.fn(() => true), + disconnect: vi.fn(async () => {}), + getBackendSessionId: vi.fn(() => null), + }; + bridge.attachAdapter("s1", adapter as any, "codex"); + adapter.sendBrowserMessage.mockClear(); + browser.send.mockClear(); + + const block = "--- Campfire memory (auto-recalled; may be stale) ---\nNotes:\n- [decision] use codex profiles\n--- end memory ---"; + const items = [{ id: "f1", kind: "fragment", namespace: "repo:abc", summary: "use codex profiles", weight: 0.9 }]; + const ci = makeFakeCi(async () => ({ items, block })); + bridge.setCollectiveIntelligence(ci as any); + + bridge.handleBrowserMessage(browser, JSON.stringify({ + type: "user_message", + content: "adapter question", + })); + + await vi.waitFor(() => expect(adapter.sendBrowserMessage).toHaveBeenCalledTimes(1)); + expect(adapter.sendBrowserMessage).toHaveBeenCalledWith(expect.objectContaining({ + type: "user_message", + content: `${block}\n\nadapter question`, + })); + + const session = bridge.getSession("s1")!; + const userEntry = session.messageHistory.find((m) => m.type === "user_message") as + { type: "user_message"; content: string; id?: string }; + expect(userEntry.content).toBe("adapter question"); + + const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg)); + const enriched = calls.find((c: any) => c.type === "memory_enriched"); + expect(enriched).toBeDefined(); + expect(enriched.user_message_id).toBe(userEntry.id); + }); + + it("user_message: stays fully synchronous and notes session activity when no CI layer is attached", () => { + // Validates the compatibility posture: without a collective-intelligence + // layer the v1 synchronous dispatch is preserved (no await anywhere), and + // the §3.4 idle trigger still records user activity. + bridge.handleBrowserMessage(browser, JSON.stringify({ + type: "user_message", + content: "ping", + })); + + // Dispatched synchronously — no microtask needed + expect(cli.send).toHaveBeenCalledTimes(1); + expect(mockConsolidation.noteSessionActivity).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "s1", backendType: "claude" }), + ); + }); +}); + +describe("Consolidation triggers (turn boundary)", () => { + let cli: ReturnType; + let browser: ReturnType; + + const resultMsg = (overrides: Record = {}) => JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + result: "Done!", + duration_ms: 1000, + duration_api_ms: 800, + num_turns: 1, + total_cost_usd: 0.01, + stop_reason: "end_turn", + usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + uuid: "uuid-consolidation", + session_id: "s1", + ...overrides, + }); + + beforeEach(() => { + mockConsolidation.consolidate.mockClear(); + mockConsolidation.shouldConsolidateOnTurn.mockClear(); + mockConsolidation.noteSessionActivity.mockClear(); + cli = makeCliSocket("s1"); + browser = makeBrowserSocket("s1"); + bridge.handleCLIOpen(cli, "s1"); + bridge.handleBrowserOpen(browser, "s1"); + cli.send.mockClear(); + browser.send.mockClear(); + }); + + it("result (CLI path): notes activity and consolidates when the turn threshold is met", async () => { + // Validates §3.4 trigger 1 wiring in handleResultMessage: every result + // resets the idle clock, and when shouldConsolidateOnTurn says the + // un-consolidated fragment threshold is met, consolidate() runs + // fire-and-forget with reason "turn_boundary". + mockConsolidation.shouldConsolidateOnTurn.mockResolvedValueOnce(true); + + bridge.handleCLIMessage(cli, resultMsg()); + + expect(mockConsolidation.noteSessionActivity).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "s1", backendType: "claude" }), + ); + await vi.waitFor(() => { + expect(mockConsolidation.consolidate).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "s1", reason: "turn_boundary" }), + ); + }); + }); + + it("result (CLI path): does not consolidate when below the threshold", async () => { + // Validates that the trigger is gated on shouldConsolidateOnTurn — a + // result on its own must not run the (expensive, LLM-backed) pipeline. + mockConsolidation.shouldConsolidateOnTurn.mockResolvedValueOnce(false); + + bridge.handleCLIMessage(cli, resultMsg()); + + // Drain the fire-and-forget promise + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + expect(mockConsolidation.shouldConsolidateOnTurn).toHaveBeenCalledWith("s1"); + expect(mockConsolidation.consolidate).not.toHaveBeenCalled(); + }); + + it("result (adapter path): fires the turn-boundary trigger for adapter-based backends", async () => { + // Validates the second wiring point (§3.4): results from Codex/Goose/etc. + // arrive via handleAdapterBrowserMessage, which must trigger consolidation + // identically to the Claude CLI path (backend parity per repo rules). + let emitFromAdapter: ((msg: any) => void) | undefined; + const adapter = { + sendBrowserMessage: vi.fn(() => true), + onBrowserMessage: vi.fn((cb: (msg: any) => void) => { emitFromAdapter = cb; }), + onSessionMeta: vi.fn(), + onDisconnect: vi.fn(), + onInitError: vi.fn(), + isConnected: vi.fn(() => true), + disconnect: vi.fn(async () => {}), + getBackendSessionId: vi.fn(() => null), + }; + bridge.attachAdapter("s2", adapter as any, "codex"); + mockConsolidation.consolidate.mockClear(); + mockConsolidation.shouldConsolidateOnTurn.mockClear(); + mockConsolidation.shouldConsolidateOnTurn.mockResolvedValueOnce(true); + + emitFromAdapter!({ + type: "result", + data: { + type: "result", + subtype: "success", + is_error: false, + duration_ms: 500, + duration_api_ms: 400, + num_turns: 1, + total_cost_usd: 0.02, + usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + session_id: "s2", + }, + }); + + await vi.waitFor(() => { + expect(mockConsolidation.consolidate).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "s2", reason: "turn_boundary", backendType: "codex" }), + ); + }); + }); +}); diff --git a/web/server/ws-bridge.ts b/web/server/ws-bridge.ts index 2cde63d..9bfc2a0 100644 --- a/web/server/ws-bridge.ts +++ b/web/server/ws-bridge.ts @@ -35,6 +35,7 @@ import type { CodexAdapter } from "./codex-adapter.js"; import type { AgentAdapter } from "./adapter-types.js"; import type { RecorderManager } from "./recorder.js"; import type { CollectiveIntelligenceLayer } from "./collective-intelligence.js"; +import { consolidate, noteSessionActivity, shouldConsolidateOnTurn } from "./memory-consolidation.js"; import { evaluateAutoInjection, scanMcpServers } from "./mcp-policy.js"; // ─── WebSocket data tags ────────────────────────────────────────────────────── @@ -294,6 +295,8 @@ export class WsBridge { ]); private static readonly VOTE_DEADLINE_MS = 30_000; // 30 seconds to vote private static readonly INVITE_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + /** Memory-enrichment budget (§3.6.1): past this, the original prompt is sent unenriched. */ + private static readonly ENRICHMENT_TIMEOUT_MS = 250; private readonly sessions = new Map(); private readonly inviteTokens = new Map(); private viewerCounter = 0; @@ -309,6 +312,8 @@ export class WsBridge { private readonly environmentMcpInjected = new Set(); private readonly autoNamingAttempted = new Set(); private userMsgCounter = 0; + /** Per-session promise chain serializing enriched user_message dispatch (§3.6.1). */ + private readonly userMessageChains = new Map>(); private onGitInfoReady: ((sessionId: string, cwd: string, branch: string) => void) | null = null; private static readonly GIT_SESSION_KEYS: GitSessionKey[] = [ "git_branch", @@ -1054,6 +1059,8 @@ export class WsBridge { } session.messageHistory.push(msg); this.persistSession(session); + // §3.4 trigger 1 (adapter result path): consolidate on turn boundary + this.maybeConsolidateOnTurn(session); } // Handle permission requests @@ -1582,6 +1589,9 @@ export class WsBridge { this.emitResultWebhooks(session, msg); + // §3.4 trigger 1: consolidate on turn boundary (fire-and-forget) + this.maybeConsolidateOnTurn(session); + // Trigger auto-naming after the first successful result this.tryAutoNaming(session, browserMsg); } @@ -1690,21 +1700,31 @@ export class WsBridge { return true; } - /** Route a browser message to an adapter-based backend (Codex, Goose, etc.). */ + /** Append a user message to session history with a stable id for reconnect dedup. */ + private pushUserHistoryEntry(session: Session, content: string): string { + const ts = Date.now(); + const id = `user-${ts}-${this.userMsgCounter++}`; + session.messageHistory.push({ type: "user_message", content, timestamp: ts, id }); + return id; + } + + /** + * Route a browser message to an adapter-based backend (Codex, Goose, etc.). + * For user_message, `userHistoryContent` (when set) is what goes into + * messageHistory — the original user text — while `msg.content` (possibly + * memory-enriched, §3.6.1) is what the adapter receives. Returns the + * history entry id for user messages. + */ private routeToAdapter( session: Session, msg: BrowserOutgoingMessage, ws?: ServerWebSocket, - ): void { + userHistoryContent?: string, + ): string | null { + let userHistoryId: string | null = null; // Store user messages in history for replay with stable ID for dedup on reconnect if (msg.type === "user_message") { - const ts = Date.now(); - session.messageHistory.push({ - type: "user_message", - content: msg.content, - timestamp: ts, - id: `user-${ts}-${this.userMsgCounter++}`, - }); + userHistoryId = this.pushUserHistoryEntry(session, userHistoryContent ?? msg.content); this.persistSession(session); } @@ -1714,7 +1734,7 @@ export class WsBridge { const eligibleVoters = this.countEligibleVoters(session); if (eligibleVoters > 1) { this.recordVote(session, msg.request_id, ws, msg.behavior, msg); - return; + return null; } const pending = session.pendingPermissions.get(msg.request_id); if (session.backendType === "claude" && msg.behavior === "allow" && !msg.updated_input && pending) { @@ -1731,18 +1751,24 @@ export class WsBridge { console.log(`[ws-bridge] ${session.backendType} adapter not yet attached for session ${session.id}, queuing ${msg.type}`); session.pendingMessages.push(JSON.stringify(outboundMsg)); } + return userHistoryId; } - /** Route a browser message to the Claude Code CLI backend. */ + /** + * Route a browser message to the Claude Code CLI backend. + * `userHistoryContent` behaves as in routeToAdapter (§3.6.1): history text + * override for user messages. Returns the history entry id for user + * messages, null otherwise. + */ private routeToClaude( session: Session, msg: BrowserOutgoingMessage, ws?: ServerWebSocket, - ): void { + userHistoryContent?: string, + ): string | null { switch (msg.type) { case "user_message": - this.handleUserMessage(session, msg); - break; + return this.handleUserMessage(session, msg, userHistoryContent); case "permission_response": this.handlePermissionResponse(session, msg, ws); break; @@ -1768,6 +1794,7 @@ export class WsBridge { this.handleMcpSetServers(session, msg.servers); break; } + return null; } private routeBrowserMessage( @@ -1819,15 +1846,167 @@ export class WsBridge { this.rememberClientMessage(session, msg.client_msg_id); } + // §3.6.1: user messages take the (possibly async) memory-enrichment path + // after all RBAC / idempotency gates above. Everything else dispatches + // synchronously exactly as before. + if (msg.type === "user_message") { + this.handleUserMessageWithEnrichment(session, msg, ws); + return; + } + + this.dispatchBrowserMessage(session, msg, ws); + } + + /** + * Final backend dispatch (adapter vs Claude CLI), extracted from + * routeBrowserMessage so the enrichment path can reuse it. + * + * For user_message, `userHistoryContent` overrides the text stored in + * messageHistory (history shows what the user typed; `msg.content` — which + * may carry the enrichment block — goes to the backend). Returns the + * history entry id for user messages, null otherwise. + */ + private dispatchBrowserMessage( + session: Session, + msg: BrowserOutgoingMessage, + ws?: ServerWebSocket, + userHistoryContent?: string, + ): string | null { if (session.adapter) { - this.routeToAdapter(session, msg, ws); + return this.routeToAdapter(session, msg, ws, userHistoryContent); } else if (session.backendType === "claude") { - this.routeToClaude(session, msg, ws); + return this.routeToClaude(session, msg, ws, userHistoryContent); } else { - this.routeToAdapter(session, msg, ws); + return this.routeToAdapter(session, msg, ws, userHistoryContent); + } + } + + /** + * user_message routing with the memory-enrichment hook (§3.6.1–3.6.4). + * + * ASYNC-RIPPLE DECISION: routeBrowserMessage stays synchronous. Enrichment + * is an async pre-step performed only for user_message and only when a + * collective-intelligence layer is attached — so all existing synchronous + * callers (handleBrowserMessage, injectUserMessage, setMcpServers, + * injectDetectedEnvironmentMcp) keep their contracts, and sessions without + * CI keep the fully synchronous v1 path. Because each enrichment awaits + * independently, two rapid user messages could otherwise dispatch out of + * order (a slow enrichment on msg1 racing a fast one on msg2); to preserve + * per-session ordering we serialize user-message dispatch through a + * per-session promise chain. The chain can back up at most + * ENRICHMENT_TIMEOUT_MS per message, so chat never blocks on memory. + */ + private handleUserMessageWithEnrichment( + session: Session, + msg: BrowserOutgoingMessage & { type: "user_message" }, + ws?: ServerWebSocket, + ): void { + // §3.4 idle trigger: any user activity resets the idle clock. + try { + noteSessionActivity(this.consolidationContext(session)); + } catch (err) { + console.warn(`[ws-bridge] noteSessionActivity failed for ${session.id}:`, err); + } + + const ci = this.collectiveIntelligence; + if (!ci || typeof ci.enrichUserMessage !== "function") { + // No CI layer attached — keep the fully synchronous dispatch path. + this.dispatchBrowserMessage(session, msg, ws); + return; + } + + const prior = this.userMessageChains.get(session.id) ?? Promise.resolve(); + const next = prior.then(() => + this.enrichAndDispatchUserMessage(session, msg, ws).catch((err) => { + console.warn(`[ws-bridge] user_message dispatch failed for ${session.id}:`, err); + }), + ); + this.userMessageChains.set(session.id, next); + // Drop the chain entry once fully drained so the map doesn't grow forever. + void next.finally(() => { + if (this.userMessageChains.get(session.id) === next) { + this.userMessageChains.delete(session.id); + } + }); + } + + /** + * Enrich a user message with recalled memory, dispatch it, and surface the + * recall to browsers (§3.6.1–3.6.4): + * - enrichment is raced against ENRICHMENT_TIMEOUT_MS and try/caught — + * on timeout or error the ORIGINAL content is sent unchanged; + * - the backend receives `\n\n`, while messageHistory + * stores the original user text; + * - after the history entry exists, a memory_enriched message carrying its + * id + the recalled items is broadcast (skipped when block is null). + */ + private async enrichAndDispatchUserMessage( + session: Session, + msg: BrowserOutgoingMessage & { type: "user_message" }, + ws?: ServerWebSocket, + ): Promise { + let enrichment: import("./semantic-memory.js").EnrichmentResult | null = null; + try { + const timeout = new Promise((resolveRace) => { + const timer = setTimeout(() => resolveRace(null), WsBridge.ENRICHMENT_TIMEOUT_MS); + timer.unref?.(); + }); + enrichment = await Promise.race([ + this.collectiveIntelligence!.enrichUserMessage(this.consolidationContext(session), msg.content), + timeout, + ]); + } catch (err) { + console.warn(`[ws-bridge] memory enrichment failed for ${session.id} (sending original):`, err); + enrichment = null; + } + + const block = enrichment?.block ?? null; + const outbound = block ? { ...msg, content: `${block}\n\n${msg.content}` } : msg; + const historyId = this.dispatchBrowserMessage(session, outbound, ws, msg.content); + + if (block && enrichment && enrichment.items.length > 0) { + this.broadcastToBrowsers(session, { + type: "memory_enriched", + user_message_id: historyId ?? undefined, + items: enrichment.items, + }); } } + /** Session context for memory consolidation / enrichment (§3.6 item 5). */ + private consolidationContext(session: Session): { + sessionId: string; + repoRoot: string; + backendType: BackendType; + } { + return { + sessionId: session.id, + repoRoot: session.state.repo_root || session.state.cwd || "", + backendType: session.backendType, + }; + } + + /** + * §3.4 turn-boundary trigger: on every result message, record activity for + * the idle watcher and — fire-and-forget, off the hot path — consolidate + * when the un-consolidated fragment threshold is met. + */ + private maybeConsolidateOnTurn(session: Session): void { + const ctx = this.consolidationContext(session); + try { + noteSessionActivity(ctx); + } catch (err) { + console.warn(`[ws-bridge] noteSessionActivity failed for ${session.id}:`, err); + } + void (async () => { + if (await shouldConsolidateOnTurn(session.id)) { + await consolidate({ ...ctx, reason: "turn_boundary" }); + } + })().catch((err) => { + console.warn(`[ws-bridge] turn-boundary consolidation failed for ${session.id}:`, err); + }); + } + private isDuplicateClientMessage(session: Session, clientMsgId: string): boolean { return session.processedClientMessageIdSet.has(clientMsgId); } @@ -1902,18 +2081,19 @@ export class WsBridge { } } + /** + * Send a user message to the Claude CLI. `historyContent` (when set) is the + * text stored in messageHistory — the original user prompt — while + * `msg.content` (possibly memory-enriched, §3.6.1) goes to the CLI. + * Returns the history entry id. + */ private handleUserMessage( session: Session, - msg: { type: "user_message"; content: string; session_id?: string; images?: { media_type: string; data: string }[] } - ) { + msg: { type: "user_message"; content: string; session_id?: string; images?: { media_type: string; data: string }[] }, + historyContent?: string, + ): string { // Store user message in history for replay with stable ID for dedup on reconnect - const ts = Date.now(); - session.messageHistory.push({ - type: "user_message", - content: msg.content, - timestamp: ts, - id: `user-${ts}-${this.userMsgCounter++}`, - }); + const historyId = this.pushUserHistoryEntry(session, historyContent ?? msg.content); // Build content: if images are present, use content block array; otherwise plain string let content: string | unknown[]; @@ -1939,6 +2119,7 @@ export class WsBridge { }); this.sendToCLI(session, ndjson); this.persistSession(session); + return historyId; } private handlePermissionResponse( diff --git a/web/src/api.ts b/web/src/api.ts index 2a8ffb8..6e5f421 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -319,6 +319,57 @@ export interface UsageLimits { } | null; } +// ─── Semantic Memory v2 ───────────────────────────────────────────────────── + +/** Namespace classes used for decay policy configuration (§3.1 of the memory design). */ +export type MemoryNamespaceClass = "global" | "repo" | "session" | "agent"; + +export interface MemoryDecayPolicy { + /** Base half-life in hours; null = never decays. */ + halfLifeHours: number | null; + /** Multiplier applied to half-life per reinforcement (capped server-side). */ + reinforceMultiplier: number; +} + +export interface MemorySettings { + decay: Record; + /** Per-namespace recall depth (how many items each namespace contributes). */ + recallDepth: { session: number; repo: number; agent: number; global: number }; +} + +/** Defaults per design doc §3.1: 90d/30d/7d/60d half-lives, ×1.5/×1.5/×1.2/×1.2, depths 4/6/2/3. */ +export const DEFAULT_MEMORY_SETTINGS: MemorySettings = { + decay: { + global: { halfLifeHours: 2160, reinforceMultiplier: 1.5 }, + repo: { halfLifeHours: 720, reinforceMultiplier: 1.5 }, + session: { halfLifeHours: 168, reinforceMultiplier: 1.2 }, + agent: { halfLifeHours: 1440, reinforceMultiplier: 1.2 }, + }, + recallDepth: { session: 4, repo: 6, agent: 2, global: 3 }, +}; + +export interface MemoryNamespaceOverview { + namespace: string; + count: number; + /** Average decayed weight (0..1) across fragments in this namespace. */ + avgWeight: number; + pinnedCount: number; +} + +export interface MemoryKnowledgeOverview { + id: string; + tag: string; + summary: string; + confidence: number; + namespace: string; + synthesisMethod?: "llm" | "concat"; +} + +export interface MemoryOverviewResponse { + namespaces: MemoryNamespaceOverview[]; + knowledge: MemoryKnowledgeOverview[]; +} + export interface AppSettings { openrouterApiKeyConfigured: boolean; openrouterModel: string; @@ -328,6 +379,7 @@ export interface AppSettings { openaiApiKeyConfigured: boolean; anthropicApiKeyConfigured: boolean; onboardingCompleted: boolean; + memory?: MemorySettings; } export interface AuthStatus { @@ -769,7 +821,7 @@ export const api = { // Settings getSettings: () => get("/settings"), - updateSettings: (data: Record) => + updateSettings: (data: Record) => put("/settings", data), getProviderAuthStatus: () => get("/settings/auth-status"), @@ -1087,6 +1139,12 @@ export const api = { get<{ knowledge: import("./types.js").ConsolidatedKnowledge[] }>( `/memory/global${tag ? `?tag=${encodeURIComponent(tag)}` : ""}`, ), + getMemoryOverview: (sessionId: string) => + get( + `/sessions/${encodeURIComponent(sessionId)}/memory/overview`, + ), + pinMemory: (id: string, pinned: boolean) => + post<{ ok: boolean }>("/memory/pin", { id, pinned }), // Collective Intelligence - Layer 2: Deliberation getDeliberations: (sessionId: string) => diff --git a/web/src/components/MemoryPanel.test.tsx b/web/src/components/MemoryPanel.test.tsx new file mode 100644 index 0000000..bf1161a --- /dev/null +++ b/web/src/components/MemoryPanel.test.tsx @@ -0,0 +1,171 @@ +// @vitest-environment jsdom +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import type { MemoryFragment } from "../types.js"; + +// MemoryPanel only reads currentSessionId from the store +vi.mock("../store.js", () => ({ + useStore: (selector: (s: Record) => unknown) => + selector({ currentSessionId: "s1" }), +})); + +// Mock the REST client (also keeps analytics/posthog out of the test) +vi.mock("../api.js", () => ({ + api: { + getSessionMemory: vi.fn(), + queryMemory: vi.fn(), + consolidateMemory: vi.fn(), + getMemoryOverview: vi.fn(), + pinMemory: vi.fn(), + }, +})); + +import { api } from "../api.js"; +import { MemoryPanel } from "./MemoryPanel.js"; + +const mockedApi = api as unknown as { + getSessionMemory: ReturnType; + queryMemory: ReturnType; + consolidateMemory: ReturnType; + getMemoryOverview: ReturnType; + pinMemory: ReturnType; +}; + +function makeFragment(overrides: Partial = {}): MemoryFragment & { pinned?: boolean } { + return { + id: "frag-1", + sessionId: "s1", + agentId: "agent-1", + backendType: "claude", + timestamp: 1700000000000, + type: "decision", + content: "Use token-bucket rate limiting", + gitContext: { branch: "main", files: [], repoRoot: "/repo" }, + references: [], + confidence: 0.8, + tags: ["rate-limiting"], + isConsolidated: false, + ...overrides, + }; +} + +const OVERVIEW = { + namespaces: [ + { namespace: "repo:a1b2c3", count: 12, avgWeight: 0.62, pinnedCount: 2 }, + { namespace: "global", count: 5, avgWeight: 0.9, pinnedCount: 0 }, + ], + knowledge: [ + { id: "know-1", tag: "auth", summary: "Auth uses JWT", confidence: 0.9, namespace: "repo:a1b2c3", synthesisMethod: "concat" as const }, + ], +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockedApi.getSessionMemory.mockResolvedValue({ fragments: [makeFragment()], consolidated: [] }); + mockedApi.getMemoryOverview.mockResolvedValue(OVERVIEW); + mockedApi.pinMemory.mockResolvedValue({ ok: true }); +}); + +describe("MemoryPanel - namespace overview", () => { + it("renders per-namespace counts, pinned counts, and avg-weight bars from the overview endpoint", async () => { + // Validates: GET /sessions/:id/memory/overview drives the Namespaces + // section — namespace name, item count, pinned count, and the decayed + // average weight rendered as a percentage bar. + render(); + + await waitFor(() => expect(screen.getByText("repo:a1b2c3")).toBeTruthy()); + expect(mockedApi.getMemoryOverview).toHaveBeenCalledWith("s1"); + + expect(screen.getByText("12 items · 2 pinned")).toBeTruthy(); + expect(screen.getByText("5 items")).toBeTruthy(); + expect(screen.getByText("62% avg weight")).toBeTruthy(); + expect(screen.getByText("90% avg weight")).toBeTruthy(); + // The bar width matches the rounded avg weight + expect(screen.getByTestId("ns-weight-repo:a1b2c3").style.width).toBe("62%"); + }); + + it("still renders fragments when the overview endpoint fails", async () => { + // Validates: overview is loaded independently — a failing/missing v2 + // endpoint must not break the existing fragments list. + mockedApi.getMemoryOverview.mockRejectedValue(new Error("not found")); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + render(); + + await waitFor(() => expect(screen.getByText("Use token-bucket rate limiting")).toBeTruthy()); + expect(screen.queryByText(/avg weight/)).toBeNull(); + consoleSpy.mockRestore(); + }); +}); + +describe("MemoryPanel - pin/unpin toggle", () => { + it("optimistically pins a fragment and calls POST /memory/pin", async () => { + // Validates: clicking the pin button flips the UI immediately (before the + // API resolves) and sends { id, pinned: true } to the pin endpoint. + let resolvePin: (v: { ok: boolean }) => void = () => {}; + mockedApi.pinMemory.mockReturnValue(new Promise((resolve) => { resolvePin = resolve; })); + + render(); + await waitFor(() => expect(screen.getByText("Use token-bucket rate limiting")).toBeTruthy()); + + fireEvent.click(screen.getByLabelText("Pin memory")); + + // Optimistic: button reflects pinned state before the request resolves + expect(screen.getByLabelText("Unpin memory")).toBeTruthy(); + expect(mockedApi.pinMemory).toHaveBeenCalledWith("frag-1", true); + + resolvePin({ ok: true }); + await waitFor(() => expect(screen.getByLabelText("Unpin memory")).toBeTruthy()); + }); + + it("reverts the optimistic pin when the API call fails", async () => { + // Validates: on error the toggle rolls back so the UI never lies about + // persisted pin state. + mockedApi.pinMemory.mockRejectedValue(new Error("boom")); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + render(); + await waitFor(() => expect(screen.getByText("Use token-bucket rate limiting")).toBeTruthy()); + + fireEvent.click(screen.getByLabelText("Pin memory")); + await waitFor(() => expect(screen.getByLabelText("Pin memory")).toBeTruthy()); + expect(screen.queryByLabelText("Unpin memory")).toBeNull(); + consoleSpy.mockRestore(); + }); + + it("unpins an already-pinned fragment", async () => { + // Validates: fragments arriving with pinned: true (v2 server) start in the + // pinned state and clicking sends pinned: false. + mockedApi.getSessionMemory.mockResolvedValue({ + fragments: [makeFragment({ pinned: true })], + consolidated: [], + }); + + render(); + await waitFor(() => expect(screen.getByLabelText("Unpin memory")).toBeTruthy()); + + fireEvent.click(screen.getByLabelText("Unpin memory")); + expect(mockedApi.pinMemory).toHaveBeenCalledWith("frag-1", false); + expect(screen.getByLabelText("Pin memory")).toBeTruthy(); + }); +}); + +describe("MemoryPanel - consolidated synthesis badge", () => { + it("badges consolidated knowledge synthesized via concat fallback", async () => { + // Validates: when the overview reports synthesisMethod "concat" for a + // consolidated row, the Consolidated tab shows the concat badge (design + // doc §3.4 — degraded no-LLM synthesis must be visible). + mockedApi.getSessionMemory.mockResolvedValue({ + fragments: [], + consolidated: [ + { id: "know-1", tag: "auth", summary: "Auth uses JWT", sourceFragments: ["f1"], lastUpdated: 1700000000000, confidence: 0.9, repoRoot: "/repo" }, + ], + }); + + render(); + await waitFor(() => expect(screen.getByText("Consolidated (1)")).toBeTruthy()); + + fireEvent.click(screen.getByText("Consolidated (1)")); + await waitFor(() => expect(screen.getByText("Auth uses JWT")).toBeTruthy()); + expect(screen.getByText("concat")).toBeTruthy(); + }); +}); diff --git a/web/src/components/MemoryPanel.tsx b/web/src/components/MemoryPanel.tsx index c6edcfa..12cabb8 100644 --- a/web/src/components/MemoryPanel.tsx +++ b/web/src/components/MemoryPanel.tsx @@ -1,12 +1,16 @@ import { useState, useEffect } from "react"; -import { api } from "../api.js"; +import { api, type MemoryOverviewResponse } from "../api.js"; import { useStore } from "../store.js"; import type { MemoryFragment, ConsolidatedKnowledge } from "../types.js"; +/** v2 servers report pinned state on fragments; older servers omit it. */ +type PinnableFragment = MemoryFragment & { pinned?: boolean }; + export function MemoryPanel() { const currentSessionId = useStore((s) => s.currentSessionId); - const [fragments, setFragments] = useState([]); + const [fragments, setFragments] = useState([]); const [consolidated, setConsolidated] = useState([]); + const [overview, setOverview] = useState(null); const [loading, setLoading] = useState(false); const [tab, setTab] = useState<"fragments" | "consolidated">("fragments"); const [searchQuery, setSearchQuery] = useState(""); @@ -29,6 +33,27 @@ export function MemoryPanel() { } finally { setLoading(false); } + // Overview loads independently so a missing/failing endpoint never + // breaks the fragments/consolidated lists. + try { + const ov = await api.getMemoryOverview(currentSessionId); + setOverview(ov); + } catch (err) { + console.error("[MemoryPanel] Failed to load memory overview:", err); + setOverview(null); + } + } + + /** Optimistic pin/unpin: flip locally, revert if the API call fails. */ + async function handleTogglePin(frag: PinnableFragment) { + const next = !frag.pinned; + setFragments((prev) => prev.map((f) => (f.id === frag.id ? { ...f, pinned: next } : f))); + try { + await api.pinMemory(frag.id, next); + } catch (err) { + console.error("[MemoryPanel] Failed to toggle pin:", err); + setFragments((prev) => prev.map((f) => (f.id === frag.id ? { ...f, pinned: !next } : f))); + } } async function handleSearch() { @@ -138,6 +163,45 @@ export function MemoryPanel() { {/* Content */}
+ {/* Per-namespace overview: counts, decayed-weight bars, pinned counts */} + {overview && overview.namespaces.length > 0 && ( +
+

+ Namespaces +

+
+ {overview.namespaces.map((ns) => { + const pct = Math.round(Math.max(0, Math.min(1, ns.avgWeight)) * 100); + return ( +
+
+ + {ns.namespace} + + + {ns.count} {ns.count === 1 ? "item" : "items"} + {ns.pinnedCount > 0 && ` · ${ns.pinnedCount} pinned`} + +
+
+
+
+
+ + {pct}% avg weight + +
+
+ ); + })} +
+
+ )} + {tab === "fragments" && (
{/* Tag filter */} @@ -190,9 +254,25 @@ export function MemoryPanel() { {new Date(frag.timestamp).toLocaleString()}
- - {(frag.confidence * 100).toFixed(0)}% - +
+ + {(frag.confidence * 100).toFixed(0)}% + + +

{frag.content}

{frag.tags.length > 0 && ( @@ -223,12 +303,24 @@ export function MemoryPanel() { {loading ? "Loading..." : "No consolidated knowledge yet"}
) : ( - consolidated.map((know) => ( + consolidated.map((know) => { + const synthesisMethod = overview?.knowledge.find((k) => k.id === know.id)?.synthesisMethod; + return (
- - #{know.tag} - +
+ + #{know.tag} + + {synthesisMethod === "concat" && ( + + concat + + )} +
Updated {new Date(know.lastUpdated).toLocaleDateString()} @@ -240,7 +332,8 @@ export function MemoryPanel() { Confidence: {(know.confidence * 100).toFixed(0)}%
- )) + ); + }) )} )} diff --git a/web/src/components/MessageFeed.test.tsx b/web/src/components/MessageFeed.test.tsx index 6e0fa52..5f4e3f1 100644 --- a/web/src/components/MessageFeed.test.tsx +++ b/web/src/components/MessageFeed.test.tsx @@ -34,6 +34,9 @@ vi.mock("../store.js", () => ({ // the mock must provide them or every render throws. sessions: mockStoreValues.sessions ?? new Map(), replaySessionId: mockStoreValues.replaySessionId ?? null, + // Recalled-memory enrichments (memory_enriched broadcasts) rendered as + // collapsible chips under the corresponding user messages. + memoryEnrichments: mockStoreValues.memoryEnrichments ?? new Map(), }; return selector(state); }, @@ -88,6 +91,13 @@ function resetStore() { mockStoreValues.sessionStatus = new Map(); mockStoreValues.sessions = new Map(); mockStoreValues.replaySessionId = null; + mockStoreValues.memoryEnrichments = new Map(); +} + +function setStoreMemoryEnrichments(sessionId: string, enrichments: Map) { + const map = new Map(); + map.set(sessionId, enrichments); + mockStoreValues.memoryEnrichments = map; } beforeEach(() => { @@ -411,3 +421,59 @@ describe("MessageFeed - subagent grouping", () => { expect(screen.getByText("researcher")).toBeTruthy(); }); }); + +// ─── Recalled-context chips (memory_enriched) ──────────────────────────────── + +describe("MessageFeed - recalled-context chips", () => { + const ENRICHMENT = { + items: [ + { id: "mem-1", kind: "knowledge" as const, namespace: "repo:abc", tag: "auth", summary: "Auth uses JWT", weight: 0.8 }, + ], + timestamp: Date.now(), + }; + + it("renders the chip under the user message matching the enrichment key", () => { + // Validates: an enrichment keyed by a user message id renders a collapsed + // "Recalled N memories" chip with that message, not with other messages. + const sid = "test-enrichment-keyed"; + setStoreMessages(sid, [ + makeMessage({ id: "u1", role: "user", content: "First question" }), + makeMessage({ id: "a1", role: "assistant", content: "Answer" }), + makeMessage({ id: "u2", role: "user", content: "Second question" }), + ]); + setStoreMemoryEnrichments(sid, new Map([["u1", ENRICHMENT]])); + + render(); + + const chip = screen.getByText("Recalled 1 memory"); + expect(chip).toBeTruthy(); + // The chip lives in the same feed-entry wrapper as the u1 bubble + const entry = chip.closest("div.mt-6, div.mt-2, div:not([class])"); + expect(screen.getByText("First question")).toBeTruthy(); + expect(entry).toBeTruthy(); + }); + + it("attaches a 'latest'-keyed enrichment to the most recent user message", () => { + // Validates: enrichments the ws layer couldn't resolve to a message id + // (stored under "latest") fall back to the last user message in the feed. + const sid = "test-enrichment-latest"; + setStoreMessages(sid, [ + makeMessage({ id: "u1", role: "user", content: "Old question" }), + makeMessage({ id: "u2", role: "user", content: "New question" }), + ]); + setStoreMemoryEnrichments(sid, new Map([["latest", ENRICHMENT]])); + + render(); + + expect(screen.getByText("Recalled 1 memory")).toBeTruthy(); + }); + + it("renders no chip when there are no enrichments", () => { + const sid = "test-enrichment-none"; + setStoreMessages(sid, [makeMessage({ id: "u1", role: "user", content: "Question" })]); + + render(); + + expect(screen.queryByText(/Recalled \d+ memor/)).toBeNull(); + }); +}); diff --git a/web/src/components/MessageFeed.tsx b/web/src/components/MessageFeed.tsx index 7949bdb..c2804e6 100644 --- a/web/src/components/MessageFeed.tsx +++ b/web/src/components/MessageFeed.tsx @@ -2,8 +2,9 @@ import { useEffect, useRef, useMemo, useState, useCallback } from "react"; import { useStore } from "../store.js"; import { api } from "../api.js"; import { MessageBubble } from "./MessageBubble.js"; +import { RecalledContextChip } from "./RecalledContextChip.js"; import { getToolIcon, getToolLabel, getPreview, ToolIcon } from "./ToolBlock.js"; -import type { ChatMessage, ContentBlock } from "../types.js"; +import type { ChatMessage, ContentBlock, MemoryEnrichment } from "../types.js"; const FEED_PAGE_SIZE = 100; @@ -276,7 +277,7 @@ function ToolMessageGroup({ group }: { group: ToolMsgGroup }) { ); } -function FeedEntries({ entries, onForkAt }: { entries: FeedEntry[]; onForkAt?: (msgId: string) => void }) { +function FeedEntries({ entries, onForkAt, enrichments }: { entries: FeedEntry[]; onForkAt?: (msgId: string) => void; enrichments?: Map | null }) { return ( <> {entries.map((entry, i) => { @@ -300,12 +301,16 @@ function FeedEntries({ entries, onForkAt }: { entries: FeedEntry[]; onForkAt?: ( ); } + const enrichment = entry.msg.role === "user" ? enrichments?.get(entry.msg.id) : undefined; return (
onForkAt(entry.msg.id) : undefined} /> + {enrichment && ( + + )}
); })} @@ -382,6 +387,7 @@ export function MessageFeed({ sessionId }: { sessionId: string }) { const streamingOutputTokens = useStore((s) => s.streamingOutputTokens.get(sessionId)); const sessionStatus = useStore((s) => s.sessionStatus.get(sessionId)); const toolProgress = useStore((s) => s.toolProgress.get(sessionId)); + const memoryEnrichments = useStore((s) => s.memoryEnrichments?.get(sessionId)); const bottomRef = useRef(null); const containerRef = useRef(null); const isNearBottom = useRef(true); @@ -413,6 +419,26 @@ export function MessageFeed({ sessionId }: { sessionId: string }) { const grouped = useMemo(() => groupMessages(messages), [messages]); + // Resolve memory enrichments to user message ids. Entries keyed "latest" + // (server couldn't name the message) attach to the most recent user message. + const enrichmentByMsgId = useMemo(() => { + if (!memoryEnrichments || memoryEnrichments.size === 0) return null; + const map = new Map(); + for (const [key, enrichment] of memoryEnrichments) { + if (key !== "latest") map.set(key, enrichment); + } + const latest = memoryEnrichments.get("latest"); + if (latest) { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "user") { + if (!map.has(messages[i].id)) map.set(messages[i].id, latest); + break; + } + } + } + return map; + }, [memoryEnrichments, messages]); + // Reset visible count when switching sessions useEffect(() => { setVisibleCount(FEED_PAGE_SIZE); @@ -493,7 +519,7 @@ export function MessageFeed({ sessionId }: { sessionId: string }) { )} - + {/* Tool progress indicator */} {toolProgress && toolProgress.size > 0 && !streamingText && ( diff --git a/web/src/components/Playground.test.tsx b/web/src/components/Playground.test.tsx index 7feaad9..1d90b03 100644 --- a/web/src/components/Playground.test.tsx +++ b/web/src/components/Playground.test.tsx @@ -17,7 +17,10 @@ vi.mock("remark-gfm", () => ({ import { Playground } from "./Playground.js"; describe("Playground", () => { - it("renders the real chat stack section with integrated chat components", () => { + // The Playground renders every mock section in one pass (~4-7s in jsdom + // depending on machine load), which flirts with the default 5s timeout. + // Give the full-page render explicit headroom. + it("renders the real chat stack section with integrated chat components", { timeout: 20000 }, () => { render(); expect(screen.getByText("Component Playground")).toBeTruthy(); @@ -34,4 +37,24 @@ describe("Playground", () => { within(realChat).getByText("I'm updating tests and then I'll run the full suite."), ).toBeTruthy(); }); + + // Validates: the recalled-context chip (memory_enriched UI) has Playground + // mocks in collapsed, expanded, and truncated variants, per the CLAUDE.md + // rule that all message-flow components appear in the Playground. + it("renders the recalled memory context section with chip variants", { timeout: 20000 }, () => { + render(); + + expect(screen.getByText("Recalled Memory Context")).toBeTruthy(); + // Collapsed + expanded + truncated(2 items) + single-item variants + expect(screen.getAllByText("Recalled 4 memories").length).toBe(2); + expect(screen.getByText("Recalled 2 memories")).toBeTruthy(); + expect(screen.getByText("Recalled 1 memory")).toBeTruthy(); + // The truncated variant surfaces its badge in the header + expect(screen.getByText("truncated")).toBeTruthy(); + // Expanded variants show summaries and the staleness hint + expect( + screen.getAllByText(/API routes use the Hono middleware stack/).length, + ).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText(/may be stale/).length).toBeGreaterThanOrEqual(1); + }); }); diff --git a/web/src/components/Playground.tsx b/web/src/components/Playground.tsx index d7b492f..7aab5fd 100644 --- a/web/src/components/Playground.tsx +++ b/web/src/components/Playground.tsx @@ -8,8 +8,9 @@ import { ClaudeMdEditor } from "./ClaudeMdEditor.js"; import { ChatView } from "./ChatView.js"; import { useStore } from "../store.js"; import { api } from "../api.js"; -import type { PermissionRequest, ChatMessage, ContentBlock, SessionState, McpServerDetail, PresenceViewer, PermissionVote } from "../types.js"; +import type { PermissionRequest, ChatMessage, ContentBlock, SessionState, McpServerDetail, PresenceViewer, PermissionVote, MemoryEnrichmentItem } from "../types.js"; import type { TaskItem } from "../types.js"; +import { RecalledContextChip } from "./RecalledContextChip.js"; import type { UpdateInfo, GitHubPRInfo } from "../api.js"; import { GitHubPRDisplay, CodexRateLimitsSection, CodexTokenDetailsSection, ClaudeTokenDetailsSection } from "./TaskPanel.js"; import { CostCard } from "./CostCard.js"; @@ -21,6 +22,40 @@ import type { GalleryEntryInfo } from "../api.js"; const MOCK_SESSION_ID = "playground-session"; +/** Recalled-memory items for the RecalledContextChip mocks (memory_enriched payload). */ +const MOCK_MEMORY_ITEMS: MemoryEnrichmentItem[] = [ + { + id: "mem-1", + kind: "knowledge", + namespace: "repo:a1b2c3d4", + tag: "rate-limiting", + summary: "API routes use the Hono middleware stack; rate limiting belongs in web/server/routes.ts before auth checks.", + weight: 0.92, + }, + { + id: "mem-2", + kind: "knowledge", + namespace: "global", + tag: "testing", + summary: "All new backend code must include colocated Vitest tests (routes.test.ts next to routes.ts).", + weight: 0.78, + }, + { + id: "mem-3", + kind: "fragment", + namespace: "repo:a1b2c3d4", + summary: "Decided to use token-bucket over sliding-window because the session store already tracks per-session timestamps.", + weight: 0.55, + }, + { + id: "mem-4", + kind: "fragment", + namespace: "agent:claude", + summary: "Claude Code sessions reconnect with --resume; middleware must tolerate duplicate session init.", + weight: 0.31, + }, +]; + function mockPermission(overrides: Partial & { tool_name: string; input: Record }): PermissionRequest { return { request_id: `perm-${Math.random().toString(36).slice(2, 8)}`, @@ -1620,6 +1655,25 @@ export function Playground() { + {/* ─── Recalled Memory Context ──────────────────────────────── */} +
+
+ + + + + + + + + + + + + +
+
+ {/* ─── Diff Viewer ──────────────────────────────── */}
diff --git a/web/src/components/RecalledContextChip.test.tsx b/web/src/components/RecalledContextChip.test.tsx new file mode 100644 index 0000000..1cbb2df --- /dev/null +++ b/web/src/components/RecalledContextChip.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment jsdom +import { render, screen, fireEvent } from "@testing-library/react"; +import type { MemoryEnrichmentItem } from "../types.js"; +import { RecalledContextChip } from "./RecalledContextChip.js"; + +const ITEMS: MemoryEnrichmentItem[] = [ + { + id: "mem-1", + kind: "knowledge", + namespace: "repo:a1b2c3", + tag: "auth", + summary: "Auth middleware validates JWT bearer tokens", + weight: 0.9, + }, + { + id: "mem-2", + kind: "fragment", + namespace: "global", + summary: "Prefer bun over npm for all scripts", + weight: 0.42, + }, +]; + +describe("RecalledContextChip - collapsed state", () => { + it("renders a one-line summary with the recalled count", () => { + // Validates: collapsed chip shows "Recalled N memories" without exposing + // the item summaries (expanded content is not mounted while collapsed). + render(); + + expect(screen.getByText("Recalled 2 memories")).toBeTruthy(); + expect(screen.queryByText("Auth middleware validates JWT bearer tokens")).toBeNull(); + expect(screen.queryByText(/may be stale/)).toBeNull(); + }); + + it("uses singular 'memory' for a single item", () => { + render(); + expect(screen.getByText("Recalled 1 memory")).toBeTruthy(); + }); + + it("shows a truncated badge in the collapsed header when items were omitted", () => { + // Validates: the truncated flag is visible without expanding so users know + // the recall was cut off by the context budget. + render(); + expect(screen.getByText("truncated")).toBeTruthy(); + }); + + it("renders nothing when there are no items", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); + +describe("RecalledContextChip - expand/collapse", () => { + it("expands on click to reveal summaries, namespace badges, tags, and the staleness hint", () => { + // Validates: clicking the header toggles the expanded item list including + // namespace class badge (prefix before ":"), tag, weight percent, and the + // "may be stale" hint required by the design doc (§3.6.4). + render(); + + fireEvent.click(screen.getByText("Recalled 2 memories").closest("button")!); + + expect(screen.getByText("Auth middleware validates JWT bearer tokens")).toBeTruthy(); + expect(screen.getByText("Prefer bun over npm for all scripts")).toBeTruthy(); + // Namespace badges show the class prefix; full namespace is in the title attr + expect(screen.getByText("repo")).toBeTruthy(); + expect(screen.getByText("repo").getAttribute("title")).toBe("repo:a1b2c3"); + expect(screen.getByText("global")).toBeTruthy(); + // Tag renders with a hash prefix + expect(screen.getByText("#auth")).toBeTruthy(); + // Weight rendered as percent alongside the bar + expect(screen.getByText("90%")).toBeTruthy(); + expect(screen.getByText("42%")).toBeTruthy(); + // Staleness hint + expect(screen.getByText(/may be stale/)).toBeTruthy(); + }); + + it("collapses again on second click", () => { + render(); + const header = screen.getByText("Recalled 2 memories").closest("button")!; + + fireEvent.click(header); + expect(screen.getByText("Prefer bun over npm for all scripts")).toBeTruthy(); + + fireEvent.click(header); + expect(screen.queryByText("Prefer bun over npm for all scripts")).toBeNull(); + }); + + it("respects defaultOpen for Playground mocks", () => { + render(); + expect(screen.getByText("Auth middleware validates JWT bearer tokens")).toBeTruthy(); + }); + + it("shows the omitted-items note when expanded with truncated set", () => { + render(); + expect(screen.getByText(/some items omitted/)).toBeTruthy(); + }); + + it("renders kind icons distinguishing knowledge from fragments", () => { + // Validates: each item row carries a kind icon (aria-label knowledge/fragment). + render(); + expect(screen.getByLabelText("knowledge")).toBeTruthy(); + expect(screen.getByLabelText("fragment")).toBeTruthy(); + }); + + it("clamps the weight bar width to 0-100%", () => { + // Validates: out-of-range weights (defensive against server bugs) don't + // overflow the bar. + render( + , + ); + const fills = screen.getAllByTestId("weight-bar-fill"); + expect(fills[0].style.width).toBe("100%"); + expect(fills[1].style.width).toBe("0%"); + }); +}); diff --git a/web/src/components/RecalledContextChip.tsx b/web/src/components/RecalledContextChip.tsx new file mode 100644 index 0000000..ce4db9e --- /dev/null +++ b/web/src/components/RecalledContextChip.tsx @@ -0,0 +1,126 @@ +import { useState } from "react"; +import type { MemoryEnrichmentItem } from "../types.js"; + +/** Display name for a namespace: the class prefix ("repo:abc123" -> "repo"). */ +function namespaceLabel(namespace: string): string { + const idx = namespace.indexOf(":"); + return idx > 0 ? namespace.slice(0, idx) : namespace; +} + +/** Kind icon: stacked layers for consolidated knowledge, a note dot for raw fragments. */ +function KindIcon({ kind }: { kind: MemoryEnrichmentItem["kind"] }) { + if (kind === "knowledge") { + return ( + + + + ); + } + return ( + + + + ); +} + +/** Subtle horizontal bar showing the decayed weight (0..1) of a recalled memory. */ +function WeightBar({ weight }: { weight: number }) { + const pct = Math.round(Math.max(0, Math.min(1, weight)) * 100); + return ( + + + + + {pct}% + + ); +} + +/** + * Collapsible "recalled context" chip rendered with the user message that was + * enriched by semantic memory (`memory_enriched` broadcast). Collapsed it is a + * one-line summary; expanded it lists each recalled item with namespace badge, + * tag, summary, kind icon, and a decayed-weight bar, plus a staleness hint. + */ +export function RecalledContextChip({ + items, + truncated, + defaultOpen = false, +}: { + items: MemoryEnrichmentItem[]; + truncated?: boolean; + defaultOpen?: boolean; +}) { + const [open, setOpen] = useState(defaultOpen); + if (items.length === 0) return null; + + return ( +
+ + + {open && ( +
+
+ {items.map((item) => ( +
+ + + + + {namespaceLabel(item.namespace)} + + {item.tag && ( + #{item.tag} + )} + {item.summary} + + + +
+ ))} +
+
+ + auto-recalled — may be stale + + {truncated && ( + + (some items omitted) + + )} +
+
+ )} +
+ ); +} diff --git a/web/src/components/SettingsPage.test.tsx b/web/src/components/SettingsPage.test.tsx index 9688cd7..880bded 100644 --- a/web/src/components/SettingsPage.test.tsx +++ b/web/src/components/SettingsPage.test.tsx @@ -71,6 +71,17 @@ vi.mock("../api.js", () => ({ disableAuth: (...args: unknown[]) => mockApi.disableAuth(...args), }, setAuthToken: (...args: unknown[]) => mockApi.setAuthToken(...args), + // Mirrors the real constant exported by api.ts — the Memory tab seeds its + // draft from it when loaded settings carry no `memory` section. + DEFAULT_MEMORY_SETTINGS: { + decay: { + global: { halfLifeHours: 2160, reinforceMultiplier: 1.5 }, + repo: { halfLifeHours: 720, reinforceMultiplier: 1.5 }, + session: { halfLifeHours: 168, reinforceMultiplier: 1.2 }, + agent: { halfLifeHours: 1440, reinforceMultiplier: 1.2 }, + }, + recallDepth: { session: 4, repo: 6, agent: 2, global: 3 }, + }, })); vi.mock("../analytics.js", () => ({ @@ -464,3 +475,106 @@ describe("SettingsPage", () => { expect(await screen.findByText("Saved")).toBeInTheDocument(); }); }); + +describe("SettingsPage - Memory tab", () => { + it("shows default memory settings (days) when the server has no memory section", async () => { + // Validates: with no `memory` in GET /settings, the Memory tab seeds from + // DEFAULT_MEMORY_SETTINGS — half-lives shown in days (90/30/7/60) and the + // default recall depths. + render(); + await waitForSettingsLoad(); + + openTab("Memory"); + + expect(document.getElementById("memory-halflife-global")).toHaveValue(90); + expect(document.getElementById("memory-halflife-repo")).toHaveValue(30); + expect(document.getElementById("memory-halflife-session")).toHaveValue(7); + expect(document.getElementById("memory-halflife-agent")).toHaveValue(60); + expect(document.getElementById("memory-depth-repo")).toHaveValue(6); + expect(document.getElementById("memory-depth-session")).toHaveValue(4); + }); + + it("saves memory settings converting days to hours and empty half-life to null", async () => { + // Validates: PUT /settings receives { memory } with halfLifeHours derived + // from the days inputs, and an empty half-life persists as null (never + // decays), matching the §3.1 settings contract. + mockApi.updateSettings.mockResolvedValueOnce({}); + render(); + await waitForSettingsLoad(); + + openTab("Memory"); + fireEvent.change(document.getElementById("memory-halflife-repo")!, { + target: { value: "14" }, + }); + fireEvent.change(document.getElementById("memory-halflife-global")!, { + target: { value: "" }, + }); + fireEvent.change(document.getElementById("memory-depth-agent")!, { + target: { value: "5" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Save Memory Settings" })); + + await waitFor(() => { + expect(mockApi.updateSettings).toHaveBeenCalledWith({ + memory: { + decay: { + global: { halfLifeHours: null, reinforceMultiplier: 1.5 }, + repo: { halfLifeHours: 336, reinforceMultiplier: 1.5 }, + session: { halfLifeHours: 168, reinforceMultiplier: 1.2 }, + agent: { halfLifeHours: 1440, reinforceMultiplier: 1.2 }, + }, + recallDepth: { session: 4, repo: 6, agent: 5, global: 3 }, + }, + }); + }); + expect(await screen.findByText("Saved")).toBeInTheDocument(); + }); + + it("rejects an invalid reinforce multiplier without calling the API", async () => { + // Validates: client-side validation blocks multipliers < 1 and surfaces + // an inline error instead of persisting bad settings. + render(); + await waitForSettingsLoad(); + + openTab("Memory"); + fireEvent.change(document.getElementById("memory-reinforce-repo")!, { + target: { value: "0.5" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save Memory Settings" })); + + expect(await screen.findByText(/reinforce multiplier must be/)).toBeInTheDocument(); + expect(mockApi.updateSettings).not.toHaveBeenCalled(); + }); + + it("loads server-provided memory settings into the form", async () => { + // Validates: when GET /settings includes a memory section, the form shows + // those values instead of defaults. + mockApi.getSettings.mockResolvedValueOnce({ + openrouterApiKeyConfigured: false, + openrouterModel: "openrouter/free", + moltbookApiKeyConfigured: false, + memory: { + decay: { + global: { halfLifeHours: null, reinforceMultiplier: 2 }, + repo: { halfLifeHours: 48, reinforceMultiplier: 1.5 }, + session: { halfLifeHours: 168, reinforceMultiplier: 1.2 }, + agent: { halfLifeHours: 1440, reinforceMultiplier: 1.2 }, + }, + recallDepth: { session: 1, repo: 2, agent: 3, global: 4 }, + }, + }); + + render(); + await waitForSettingsLoad(); + openTab("Memory"); + + await waitFor(() => { + expect(document.getElementById("memory-halflife-repo")).toHaveValue(2); + }); + // null half-life renders as an empty input (placeholder "never") + expect(document.getElementById("memory-halflife-global")).toHaveValue(null); + expect(document.getElementById("memory-reinforce-global")).toHaveValue(2); + expect(document.getElementById("memory-depth-global")).toHaveValue(4); + }); +}); diff --git a/web/src/components/SettingsPage.tsx b/web/src/components/SettingsPage.tsx index 851352a..044d2e1 100644 --- a/web/src/components/SettingsPage.tsx +++ b/web/src/components/SettingsPage.tsx @@ -1,16 +1,18 @@ import { useEffect, useState } from "react"; -import { api, setAuthToken } from "../api.js"; +import { api, setAuthToken, DEFAULT_MEMORY_SETTINGS } from "../api.js"; +import type { MemorySettings, MemoryNamespaceClass } from "../api.js"; import { useStore } from "../store.js"; import { getTelemetryPreferenceEnabled, setTelemetryPreferenceEnabled } from "../analytics.js"; /* ─── Tab Types ─────────────────────────────────────────────────── */ -type SettingsTab = "general" | "providers" | "api-keys" | "security" | "notifications" | "appearance" | "updates"; +type SettingsTab = "general" | "providers" | "api-keys" | "memory" | "security" | "notifications" | "appearance" | "updates"; const TABS: { id: SettingsTab; label: string }[] = [ { id: "general", label: "General" }, { id: "providers", label: "Providers" }, { id: "api-keys", label: "API Keys" }, + { id: "memory", label: "Memory" }, { id: "security", label: "Security" }, { id: "notifications", label: "Notifications" }, { id: "appearance", label: "Appearance" }, @@ -367,6 +369,198 @@ function ApiKeysTab({ openrouterApiKey, setOpenrouterApiKey, openrouterModel, se ); } +/* ─── Memory Tab ────────────────────────────────────────────────── */ + +const MEMORY_NAMESPACES: { key: MemoryNamespaceClass; label: string; description: string }[] = [ + { key: "global", label: "Global", description: "Cross-repo conventions, user preferences, tool quirks" }, + { key: "repo", label: "Repository", description: "Architecture, conventions, distilled patterns per repo" }, + { key: "session", label: "Session", description: "Episodic fragments of one session (pre-consolidation)" }, + { key: "agent", label: "Agent", description: "Backend-specific behavior notes" }, +]; + +/** String-typed draft so inputs can be empty while editing (empty half-life = never decays). */ +interface MemoryDraft { + decay: Record; + recallDepth: Record; +} + +function memorySettingsToDraft(s: MemorySettings): MemoryDraft { + const decay = {} as MemoryDraft["decay"]; + const recallDepth = {} as MemoryDraft["recallDepth"]; + for (const { key } of MEMORY_NAMESPACES) { + const policy = s.decay[key] ?? DEFAULT_MEMORY_SETTINGS.decay[key]; + decay[key] = { + halfLifeDays: policy.halfLifeHours == null ? "" : String(policy.halfLifeHours / 24), + reinforceMultiplier: String(policy.reinforceMultiplier), + }; + recallDepth[key] = String(s.recallDepth?.[key] ?? DEFAULT_MEMORY_SETTINGS.recallDepth[key]); + } + return { decay, recallDepth }; +} + +/** Convert the draft back to MemorySettings. Returns an error string on invalid input. */ +function draftToMemorySettings(draft: MemoryDraft): { settings?: MemorySettings; error?: string } { + const decay = {} as MemorySettings["decay"]; + const recallDepth = {} as MemorySettings["recallDepth"]; + for (const { key, label } of MEMORY_NAMESPACES) { + const days = draft.decay[key].halfLifeDays.trim(); + let halfLifeHours: number | null = null; + if (days !== "") { + const n = Number(days); + if (!Number.isFinite(n) || n <= 0) { + return { error: `${label}: half-life must be a positive number of days (leave empty to never decay)` }; + } + halfLifeHours = Math.round(n * 24); + } + const mult = Number(draft.decay[key].reinforceMultiplier); + if (!Number.isFinite(mult) || mult < 1) { + return { error: `${label}: reinforce multiplier must be a number ≥ 1` }; + } + const depth = Number(draft.recallDepth[key]); + if (!Number.isInteger(depth) || depth < 0) { + return { error: `${label}: recall depth must be a whole number ≥ 0` }; + } + decay[key] = { halfLifeHours, reinforceMultiplier: mult }; + recallDepth[key] = depth; + } + return { settings: { decay, recallDepth } }; +} + +function MemoryTab({ initial }: Readonly<{ initial: MemorySettings }>) { + const [draft, setDraft] = useState(() => memorySettingsToDraft(initial)); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(""); + + // Re-sync the draft when settings finish loading from the server + useEffect(() => { + setDraft(memorySettingsToDraft(initial)); + }, [initial]); + + function setDecayField(key: MemoryNamespaceClass, field: "halfLifeDays" | "reinforceMultiplier", value: string) { + setDraft((d) => ({ ...d, decay: { ...d.decay, [key]: { ...d.decay[key], [field]: value } } })); + } + + function setDepthField(key: MemoryNamespaceClass, value: string) { + setDraft((d) => ({ ...d, recallDepth: { ...d.recallDepth, [key]: value } })); + } + + async function onSave() { + setError(""); + setSaved(false); + const { settings, error: validationError } = draftToMemorySettings(draft); + if (!settings) { + setError(validationError || "Invalid memory settings"); + return; + } + setSaving(true); + try { + const res = await api.updateSettings({ memory: settings }); + if (res.memory) setDraft(memorySettingsToDraft(res.memory)); + setSaved(true); + setTimeout(() => setSaved(false), 1800); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Save failed"); + } finally { + setSaving(false); + } + } + + return ( +
+ +
+ {MEMORY_NAMESPACES.map(({ key, label, description }) => ( +
+
+ {label} +

{description}

+
+
+ + setDecayField(key, "halfLifeDays", e.target.value)} + placeholder="never" + className="w-24 h-9 px-3 rounded-lg border border-cc-border bg-cc-input-bg text-[12px] text-cc-fg font-mono-code" + /> +
+
+ + setDecayField(key, "reinforceMultiplier", e.target.value)} + className="w-20 h-9 px-3 rounded-lg border border-cc-border bg-cc-input-bg text-[12px] text-cc-fg font-mono-code" + /> +
+
+ ))} +
+
+ + +
+ {MEMORY_NAMESPACES.map(({ key, label }) => ( +
+ + setDepthField(key, e.target.value)} + className="w-20 h-9 px-3 rounded-lg border border-cc-border bg-cc-input-bg text-[12px] text-cc-fg font-mono-code" + /> +
+ ))} +
+
+ + {/* Save bar */} +
+ + {saved && Saved} + {error && {error}} +
+ +
+

+ Pinned memories never decay regardless of half-life. Defaults: Global 90d, Repository 30d, + Session 7d, Agent 60d. Settings are stored in ~/.campfire/settings.json. +

+
+
+ ); +} + function SecurityTab({ authEnabled, setAuthEnabled, authPassword, setAuthPassword, authSaving, setAuthSaving, authSaved, setAuthSaved, authError, setAuthError, authSessions }: Readonly<{ authEnabled: boolean; setAuthEnabled: (v: boolean) => void; authPassword: string; setAuthPassword: (v: string) => void; @@ -614,6 +808,7 @@ export function SettingsPage({ embedded = false }: Readonly) const [authSaved, setAuthSaved] = useState(false); const [authError, setAuthError] = useState(""); const [authSessions, setAuthSessions] = useState(0); + const [memorySettings, setMemorySettings] = useState(DEFAULT_MEMORY_SETTINGS); const darkMode = useStore((s) => s.darkMode); const toggleDarkMode = useStore((s) => s.toggleDarkMode); const notificationSound = useStore((s) => s.notificationSound); @@ -640,6 +835,7 @@ export function SettingsPage({ embedded = false }: Readonly) setClaudeConfigured(s.claudeOAuthTokenConfigured ?? false); setOpenaiConfigured(s.openaiApiKeyConfigured ?? false); setAnthropicConfigured(s.anthropicApiKeyConfigured ?? false); + setMemorySettings(s.memory ?? DEFAULT_MEMORY_SETTINGS); }) .catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error")) .finally(() => setLoading(false)); @@ -787,6 +983,9 @@ export function SettingsPage({ embedded = false }: Readonly) onSave={onSave} /> )} + {activeTab === "memory" && ( + + )} {activeTab === "security" && ( { expect(useStore.getState().mcpServers.has("s1")).toBe(false); }); }); + +// ─── Memory enrichments (semantic memory v2 recalled-context chips) ───────── + +describe("Memory enrichments", () => { + const MOCK_ENRICHMENT = { + items: [ + { id: "mem-1", kind: "knowledge" as const, namespace: "repo:abc", tag: "auth", summary: "Auth uses JWT", weight: 0.9 }, + ], + timestamp: 123, + }; + + it("setMemoryEnrichment: stores per session keyed by user message id", () => { + useStore.getState().setMemoryEnrichment("s1", "u1", MOCK_ENRICHMENT); + useStore.getState().setMemoryEnrichment("s1", "u2", { ...MOCK_ENRICHMENT, truncated: true }); + + const sessionEnrichments = useStore.getState().memoryEnrichments.get("s1"); + expect(sessionEnrichments?.get("u1")).toEqual(MOCK_ENRICHMENT); + expect(sessionEnrichments?.get("u2")?.truncated).toBe(true); + }); + + it("setMemoryEnrichment: overwrites an existing key (latest fallback re-broadcast)", () => { + useStore.getState().setMemoryEnrichment("s1", "latest", MOCK_ENRICHMENT); + const newer = { ...MOCK_ENRICHMENT, timestamp: 456 }; + useStore.getState().setMemoryEnrichment("s1", "latest", newer); + + expect(useStore.getState().memoryEnrichments.get("s1")?.get("latest")).toEqual(newer); + }); + + it("clearMemoryEnrichments: removes only the given session's enrichments", () => { + useStore.getState().setMemoryEnrichment("s1", "u1", MOCK_ENRICHMENT); + useStore.getState().setMemoryEnrichment("s2", "u9", MOCK_ENRICHMENT); + + useStore.getState().clearMemoryEnrichments("s1"); + + expect(useStore.getState().memoryEnrichments.has("s1")).toBe(false); + expect(useStore.getState().memoryEnrichments.has("s2")).toBe(true); + }); + + it("removeSession: clears memoryEnrichments like every other per-session map", () => { + // Validates: the enrichment map participates in the removeSession cleanup + // sweep so deleted sessions don't leak recalled-context state. + useStore.getState().addSession(makeSession("s1")); + useStore.getState().setMemoryEnrichment("s1", "u1", MOCK_ENRICHMENT); + + useStore.getState().removeSession("s1"); + + expect(useStore.getState().memoryEnrichments.has("s1")).toBe(false); + }); + + it("reset: clears all memory enrichments", () => { + useStore.getState().setMemoryEnrichment("s1", "u1", MOCK_ENRICHMENT); + useStore.getState().reset(); + expect(useStore.getState().memoryEnrichments.size).toBe(0); + }); +}); diff --git a/web/src/store.ts b/web/src/store.ts index 1fd8f15..a9f0a84 100644 --- a/web/src/store.ts +++ b/web/src/store.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import type { SessionState, PermissionRequest, ChatMessage, SdkSessionInfo, TaskItem, BackgroundAgentItem, McpServerDetail, SessionRole, PresenceViewer, PermissionVote, VotingPolicy } from "./types.js"; +import type { SessionState, PermissionRequest, ChatMessage, SdkSessionInfo, TaskItem, BackgroundAgentItem, McpServerDetail, SessionRole, PresenceViewer, PermissionVote, VotingPolicy, MemoryEnrichment } from "./types.js"; import type { UpdateInfo, PRStatusResponse } from "./api.js"; interface AppState { @@ -58,6 +58,10 @@ interface AppState { // Tool progress (session → tool_use_id → progress info) toolProgress: Map>; + // Recalled-memory enrichments per session (outer key = sessionId, + // inner key = user message id or "latest" when unresolvable) + memoryEnrichments: Map>; + // Sidebar project grouping collapsedProjects: Set; @@ -157,6 +161,10 @@ interface AppState { setToolProgress: (sessionId: string, toolUseId: string, data: { toolName: string; elapsedSeconds: number }) => void; clearToolProgress: (sessionId: string, toolUseId?: string) => void; + // Memory enrichment actions + setMemoryEnrichment: (sessionId: string, key: string, enrichment: MemoryEnrichment) => void; + clearMemoryEnrichments: (sessionId: string) => void; + // Sidebar project grouping actions toggleProjectCollapse: (projectKey: string) => void; @@ -293,6 +301,7 @@ export const useStore = create((set) => ({ prStatus: new Map(), mcpServers: new Map(), toolProgress: new Map(), + memoryEnrichments: new Map(), collapsedProjects: getInitialCollapsedProjects(), sessionStartTimes: new Map(), sessionViewers: new Map(), @@ -425,6 +434,8 @@ export const useStore = create((set) => ({ mcpServers.delete(sessionId); const toolProgress = new Map(s.toolProgress); toolProgress.delete(sessionId); + const memoryEnrichments = new Map(s.memoryEnrichments); + memoryEnrichments.delete(sessionId); const prStatus = new Map(s.prStatus); prStatus.delete(sessionId); const sessionStartTimes = new Map(s.sessionStartTimes); @@ -463,6 +474,7 @@ export const useStore = create((set) => ({ diffPanelSelectedFile, mcpServers, toolProgress, + memoryEnrichments, prStatus, sessionStartTimes, sessionViewers, @@ -743,6 +755,23 @@ export const useStore = create((set) => ({ return { toolProgress }; }), + setMemoryEnrichment: (sessionId, key, enrichment) => + set((s) => { + const memoryEnrichments = new Map(s.memoryEnrichments); + const sessionEnrichments = new Map(memoryEnrichments.get(sessionId) || []); + sessionEnrichments.set(key, enrichment); + memoryEnrichments.set(sessionId, sessionEnrichments); + return { memoryEnrichments }; + }), + + clearMemoryEnrichments: (sessionId) => + set((s) => { + if (!s.memoryEnrichments.has(sessionId)) return s; + const memoryEnrichments = new Map(s.memoryEnrichments); + memoryEnrichments.delete(sessionId); + return { memoryEnrichments }; + }), + toggleProjectCollapse: (projectKey) => set((s) => { const collapsedProjects = new Set(s.collapsedProjects); @@ -941,6 +970,7 @@ export const useStore = create((set) => ({ recentlyRenamed: new Set(), mcpServers: new Map(), toolProgress: new Map(), + memoryEnrichments: new Map(), prStatus: new Map(), activeTab: "chat" as const, diffPanelSelectedFile: new Map(), diff --git a/web/src/types.ts b/web/src/types.ts index 7f02af5..85760af 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -14,9 +14,23 @@ import type { DetectedEnvironment, DetectedEnvironmentRule, SubAgentUpdate, + MemoryEnrichmentItem, } from "../server/session-types.js"; -export type { SessionState, PermissionRequest, ContentBlock, BrowserIncomingMessage, BrowserOutgoingMessage, BackendType, SessionRole, PresenceViewer, VotingPolicy, PermissionVote, McpServerDetail, McpServerConfig, DetectedEnvironment, DetectedEnvironmentRule, SubAgentUpdate }; +export type { SessionState, PermissionRequest, ContentBlock, BrowserIncomingMessage, BrowserOutgoingMessage, BackendType, SessionRole, PresenceViewer, VotingPolicy, PermissionVote, McpServerDetail, McpServerConfig, DetectedEnvironment, DetectedEnvironmentRule, SubAgentUpdate, MemoryEnrichmentItem }; + +/** + * Client-side record of a `memory_enriched` broadcast: the recalled memories + * that were injected into a user message. Stored per session keyed by the + * user message id (or the literal "latest" when no user message could be + * resolved), so the chat feed can render a "recalled context" chip next to + * the corresponding user message. + */ +export interface MemoryEnrichment { + items: MemoryEnrichmentItem[]; + truncated?: boolean; + timestamp: number; +} export interface ChatMessage { id: string; diff --git a/web/src/ws.test.ts b/web/src/ws.test.ts index 9fb388c..496d959 100644 --- a/web/src/ws.test.ts +++ b/web/src/ws.test.ts @@ -1399,3 +1399,85 @@ describe("handleMessage: assistant clears only completed tool progress", () => { expect(progress?.get("tu-b")).toEqual({ toolName: "Glob", elapsedSeconds: 2 }); }); }); + +// =========================================================================== +// memory_enriched: recalled-context enrichment storage +// =========================================================================== +describe("handleMessage: memory_enriched", () => { + const MOCK_ITEMS = [ + { id: "mem-1", kind: "knowledge" as const, namespace: "repo:abc", tag: "auth", summary: "Auth uses JWT", weight: 0.9 }, + { id: "mem-2", kind: "fragment" as const, namespace: "global", summary: "Prefer bun over npm", weight: 0.4 }, + ]; + + it("stores enrichment keyed by user_message_id when it matches a known user message", () => { + // Validates: when the server names a user message we already have (e.g. + // ids restored from message_history), the enrichment is keyed directly + // by that id so the chip renders under the right message. + wsModule.connectSession("s1"); + fireMessage({ type: "session_init", session: makeSession("s1") }); + useStore.getState().appendMessage("s1", { id: "u1", role: "user", content: "hello", timestamp: 1 }); + + fireMessage({ type: "memory_enriched", user_message_id: "u1", items: MOCK_ITEMS }); + + const enrichment = useStore.getState().memoryEnrichments.get("s1")?.get("u1"); + expect(enrichment).toBeDefined(); + expect(enrichment!.items).toEqual(MOCK_ITEMS); + expect(enrichment!.truncated).toBeUndefined(); + }); + + it("falls back to the most recent user message when user_message_id is unknown", () => { + // Validates: the local echo of a sent message uses a client-generated id + // the server does not know, so an unmatched user_message_id attaches to + // the latest user message (the one that was just enriched). + wsModule.connectSession("s1"); + fireMessage({ type: "session_init", session: makeSession("s1") }); + useStore.getState().appendMessage("s1", { id: "u1", role: "user", content: "first", timestamp: 1 }); + useStore.getState().appendMessage("s1", { id: "a1", role: "assistant", content: "reply", timestamp: 2 }); + useStore.getState().appendMessage("s1", { id: "u2", role: "user", content: "second", timestamp: 3 }); + + fireMessage({ type: "memory_enriched", user_message_id: "server-side-id", items: MOCK_ITEMS, truncated: true }); + + const sessionEnrichments = useStore.getState().memoryEnrichments.get("s1"); + expect(sessionEnrichments?.get("u2")).toBeDefined(); + expect(sessionEnrichments?.get("u2")!.truncated).toBe(true); + expect(sessionEnrichments?.has("server-side-id")).toBe(false); + }); + + it("falls back to the most recent user message when user_message_id is absent", () => { + // Validates: omitted user_message_id (server couldn't name the message) + // still attaches the enrichment to the latest user message. + wsModule.connectSession("s1"); + fireMessage({ type: "session_init", session: makeSession("s1") }); + useStore.getState().appendMessage("s1", { id: "u1", role: "user", content: "hi", timestamp: 1 }); + + fireMessage({ type: "memory_enriched", items: MOCK_ITEMS }); + + expect(useStore.getState().memoryEnrichments.get("s1")?.get("u1")).toBeDefined(); + }); + + it("stores under the literal 'latest' key when no user message exists yet", () => { + // Validates: an enrichment arriving before any user message (e.g. replay + // ordering edge case) is retained under "latest" so the feed can still + // attach it once messages exist. + wsModule.connectSession("s1"); + fireMessage({ type: "session_init", session: makeSession("s1") }); + + fireMessage({ type: "memory_enriched", items: MOCK_ITEMS }); + + expect(useStore.getState().memoryEnrichments.get("s1")?.get("latest")).toBeDefined(); + }); + + it("disconnectSession clears stored enrichments for that session", () => { + // Validates: memory enrichments are cleaned up in disconnectSession like + // the other per-session state (task dedup sets, counters, etc.). + wsModule.connectSession("s1"); + fireMessage({ type: "session_init", session: makeSession("s1") }); + useStore.getState().appendMessage("s1", { id: "u1", role: "user", content: "hi", timestamp: 1 }); + fireMessage({ type: "memory_enriched", user_message_id: "u1", items: MOCK_ITEMS }); + expect(useStore.getState().memoryEnrichments.get("s1")).toBeDefined(); + + wsModule.disconnectSession("s1"); + + expect(useStore.getState().memoryEnrichments.get("s1")).toBeUndefined(); + }); +}); diff --git a/web/src/ws.ts b/web/src/ws.ts index 75efcc6..dcbeb53 100644 --- a/web/src/ws.ts +++ b/web/src/ws.ts @@ -690,6 +690,32 @@ function handleParsedMessage( break; } + case "memory_enriched": { + // Recalled-memory enrichment for a user message. Key by the server's + // user_message_id when it matches a message we have; otherwise attach + // to the most recent user message (the one that was just enriched — + // the local echo uses a client-generated id the server doesn't know). + // Fall back to the literal "latest" key when no user message exists yet. + const sessionMessages = store.messages.get(sessionId) || []; + let key: string | undefined; + if (data.user_message_id && sessionMessages.some((m) => m.id === data.user_message_id)) { + key = data.user_message_id; + } else { + for (let i = sessionMessages.length - 1; i >= 0; i--) { + if (sessionMessages[i].role === "user") { + key = sessionMessages[i].id; + break; + } + } + } + store.setMemoryEnrichment(sessionId, key ?? "latest", { + items: data.items, + truncated: data.truncated, + timestamp: Date.now(), + }); + break; + } + case "presence_update": { store.setSessionViewers(sessionId, data.viewers); break; @@ -888,6 +914,7 @@ export function disconnectSession(sessionId: string) { taskCounters.delete(sessionId); processedAgentIds.delete(sessionId); pendingBackgroundAgents.delete(sessionId); + useStore.getState().clearMemoryEnrichments(sessionId); } export function disconnectAll() {