+ 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() {