Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.3.0"
".": "0.3.1"
}
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
17 changes: 17 additions & 0 deletions web/server/collective-intelligence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)", () => {
Expand Down
11 changes: 10 additions & 1 deletion web/server/collective-intelligence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 ────────────────────────────────────────────────────

/**
Expand Down
17 changes: 17 additions & 0 deletions web/server/semantic-memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
22 changes: 22 additions & 0 deletions web/server/semantic-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1254,6 +1254,28 @@ export interface NamespaceOverviewEntry {
* Per-namespace fragment stats for the memory panel: count, average decayed
* weight, and pinned count, over [session:<id>, repo:<hash>, agent:<backend>, 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<void> {
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;
Expand Down
2 changes: 2 additions & 0 deletions web/server/ws-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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";

Expand Down
Loading