From d126e2b6e794c3d22b6cbeb0157b40d6d16e7656 Mon Sep 17 00:00:00 2001 From: Prasenjit Sarkar Date: Tue, 7 Jul 2026 20:12:18 +0100 Subject: [PATCH] fix(memory): warm the store on session init so the first message enriches Cold-start: the first enrichment query in a fresh process paid the LanceDB connect+open cost (~230ms), tipping over the 250ms budget so the enrichment hook gracefully passed the message through with no recalled context. Warm the fragments/consolidated tables (reinforcement-free, fire-and-forget) when a session becomes ready. Measured: first enrichment after warm ~24ms vs ~238ms cold, so the recalled-context chip now shows on the first message. --- .release-please-manifest.json | 2 +- CHANGELOG.md | 6 ++++++ package.json | 2 +- web/package.json | 2 +- web/server/collective-intelligence.test.ts | 17 +++++++++++++++++ web/server/collective-intelligence.ts | 11 ++++++++++- web/server/semantic-memory.test.ts | 17 +++++++++++++++++ web/server/semantic-memory.ts | 22 ++++++++++++++++++++++ web/server/ws-bridge.ts | 2 ++ 9 files changed, 77 insertions(+), 4 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 0ee8c01..816df2d 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.3.0" + ".": "0.3.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index c2a36ab..dfee548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ > Campfire began as a fork of [the-companion](https://github.com/The-Vibe-Company/companion) and diverged into a separate product. Pre-fork history (versions up to 0.42.0) lives in the upstream repository; Campfire's own releases start at 0.1.0. +## 0.3.1 (2026-07-07) + +### Fixes + +* **memory:** warm the memory store's tables when a session becomes ready, so the session's first user message is enriched within the 250 ms budget instead of gracefully passing through on the cold (~230 ms) first query. The warm-up is fire-and-forget at session init, off the hot path, and never reinforces + ## 0.3.0 (2026-07-07) ### Features diff --git a/package.json b/package.json index 4b34279..6e77f89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "the-campfire-workspace", - "version": "0.3.0", + "version": "0.3.1", "private": true, "description": "Workspace root for Campfire \u2014 the collaborative web platform for AI coding agents. The published package lives in web/.", "scripts": { diff --git a/web/package.json b/web/package.json index 0be732c..597fc43 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "the-campfire", - "version": "0.3.0", + "version": "0.3.1", "type": "module", "description": "Campfire \u2014 collaborative web platform for AI coding agents. Run Claude Code, Codex, Goose, Aider, and more from one browser UI with real-time collaboration, permission voting, and automation.", "license": "MIT", diff --git a/web/server/collective-intelligence.test.ts b/web/server/collective-intelligence.test.ts index cf4e631..666ab2f 100644 --- a/web/server/collective-intelligence.test.ts +++ b/web/server/collective-intelligence.test.ts @@ -45,6 +45,9 @@ vi.mock("./semantic-memory.js", () => ({ getConsolidatedKnowledge: vi.fn(async () => []), // v2 enrichment entry point (§3.6.2) — reinforcement happens inside it queryForEnrichment: vi.fn(async () => ({ items: [], block: null })), + // Startup warm-up hook (opens the store tables so the first enrichment + // lands within budget). + warmMemory: vi.fn(async () => {}), })); // Mock the consolidation pipeline (§3.4) — onSessionEnd routes through it @@ -291,6 +294,20 @@ describe("enrichUserMessage (§3.6.2)", () => { }); expect(result).toBe(enrichment); }); + + it("warmForSession delegates to warmMemory fire-and-forget with repo + backend", () => { + // Called from WsBridge on session init so the first enrichment is warm. + // It must not throw synchronously and must pass through the store call. + vi.mocked(semanticMemory.warmMemory).mockClear(); + ci.warmForSession("/repo", "codex"); + expect(semanticMemory.warmMemory).toHaveBeenCalledWith({ repoRoot: "/repo", backendType: "codex" }); + }); + + it("warmForSession swallows store rejection (best-effort)", async () => { + vi.mocked(semanticMemory.warmMemory).mockRejectedValueOnce(new Error("lancedb down")); + expect(() => ci.warmForSession("/repo", "claude")).not.toThrow(); + await Promise.resolve(); + }); }); describe("memory extraction (§3.6.5 recall-biased upgrade)", () => { diff --git a/web/server/collective-intelligence.ts b/web/server/collective-intelligence.ts index 9c8e9c6..13459f0 100644 --- a/web/server/collective-intelligence.ts +++ b/web/server/collective-intelligence.ts @@ -24,7 +24,7 @@ */ import type { BrowserIncomingMessage, BrowserOutgoingMessage, BackendType } from "./session-types.js"; -import { storeFragment, queryFragments, queryForEnrichment } from "./semantic-memory.js"; +import { storeFragment, queryFragments, queryForEnrichment, warmMemory } 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"; @@ -355,6 +355,15 @@ export class CollectiveIntelligenceLayer { }); } + /** + * Fire-and-forget: open the memory store's tables when a session becomes + * ready so the session's first user message enriches within budget. Called + * from WsBridge on session init; never awaited, never throws. + */ + warmForSession(repoRoot: string, backendType: BackendType): void { + void warmMemory({ repoRoot, backendType }).catch(() => { /* best-effort */ }); + } + // ─── Session lifecycle ──────────────────────────────────────────────────── /** diff --git a/web/server/semantic-memory.test.ts b/web/server/semantic-memory.test.ts index 86aebbf..ffeba13 100644 --- a/web/server/semantic-memory.test.ts +++ b/web/server/semantic-memory.test.ts @@ -1089,4 +1089,21 @@ describe("provider/dimension change on a live v2 store", () => { await memory.storeFragment(frag()); expect(await memory.processReembedBatch(5)).toBe(0); }); + + // warmMemory opens the store's tables without throwing and without + // reinforcing — it is a startup optimization, not a real recall, so access + // counts must be untouched (a warm must never inflate a fragment's weight). + it("warmMemory succeeds on an empty store and never throws", async () => { + await expect(memory.warmMemory({ repoRoot: "/repo", backendType: "claude" })).resolves.toBeUndefined(); + }); + + it("warmMemory does not reinforce fragments (accessCount unchanged)", async () => { + await memory.storeFragment(frag({ content: "auth: warm should not touch me" })); + const before = (await memory.getSessionFragments("session-1"))[0]; + await memory.warmMemory({ repoRoot: "/repo", backendType: "claude" }); + await memory.flushReinforcements(); + const after = (await memory.getSessionFragments("session-1"))[0]; + expect(after.accessCount ?? 0).toBe(before.accessCount ?? 0); + expect(after.lastReinforcedAt).toBe(before.lastReinforcedAt); + }); }); diff --git a/web/server/semantic-memory.ts b/web/server/semantic-memory.ts index 87e03f9..447f25f 100644 --- a/web/server/semantic-memory.ts +++ b/web/server/semantic-memory.ts @@ -1254,6 +1254,28 @@ export interface NamespaceOverviewEntry { * Per-namespace fragment stats for the memory panel: count, average decayed * weight, and pinned count, over [session:, repo:, agent:, global]. */ +/** + * Open the LanceDB connection and the tables a subsequent enrichment query + * will touch, so the first real `queryForEnrichment` for a session lands + * within the 250 ms budget instead of paying the cold connect+open cost + * (~230 ms on a fresh process) and getting skipped. Best-effort and + * reinforcement-free — it must never throw into session startup, and it must + * not touch access counts (it is not a real recall). + */ +export async function warmMemory(opts: { repoRoot: string; backendType: string }): Promise { + try { + const ns = opts.repoRoot ? repoNamespace(opts.repoRoot) : "global"; + // getNamespaceOverview opens the fragments table + connection; + // getKnowledgeByNamespace opens the consolidated table. Neither reinforces. + await Promise.all([ + getNamespaceOverview({ sessionId: "warm", repoRoot: opts.repoRoot, backendType: opts.backendType }), + getKnowledgeByNamespace(ns), + ]); + } catch { + // Warming is an optimization; a cold first query still works, just slower. + } +} + export async function getNamespaceOverview(opts: { sessionId: string; repoRoot: string; diff --git a/web/server/ws-bridge.ts b/web/server/ws-bridge.ts index 9bfc2a0..9a8b63b 100644 --- a/web/server/ws-bridge.ts +++ b/web/server/ws-bridge.ts @@ -1038,6 +1038,7 @@ export class WsBridge { this.persistSession(session); this.injectDetectedEnvironmentMcp(session); this.agentMcpBridge?.onSessionReady(session.id, backendType, session.state.cwd); + this.collectiveIntelligence?.warmForSession(session.state.repo_root || session.state.cwd || "", backendType); } else if (msg.type === "session_update") { session.state = { ...session.state, ...msg.session, backend_type: backendType }; this.refreshGitInfo(session, { notifyPoller: true }); @@ -1382,6 +1383,7 @@ export class WsBridge { this.persistSession(session); this.injectDetectedEnvironmentMcp(session); this.agentMcpBridge?.onSessionReady(session.id, session.backendType, session.state.cwd); + this.collectiveIntelligence?.warmForSession(session.state.repo_root || session.state.cwd || "", session.backendType); } else if (msg.subtype === "status") { session.state.is_compacting = msg.status === "compacting";