the-campfire 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +28 -4
  2. package/dist/assets/{MermaidDiagram-D_MwUXi3.js → MermaidDiagram-Cas1Ngze.js} +2 -2
  3. package/dist/assets/{_basePickBy-DsMRBmhy.js → _basePickBy-D1h3RRT9.js} +1 -1
  4. package/dist/assets/{_baseUniq-CZ0BR5Mx.js → _baseUniq-Z9p6uaM9.js} +1 -1
  5. package/dist/assets/{arc-D6Y0ooDg.js → arc-DJLNkmre.js} +1 -1
  6. package/dist/assets/{architectureDiagram-VXUJARFQ-3T5ZoEn1.js → architectureDiagram-VXUJARFQ-CBHHYg8R.js} +1 -1
  7. package/dist/assets/{blockDiagram-VD42YOAC-CvOCnB8f.js → blockDiagram-VD42YOAC-DMtvAHPO.js} +1 -1
  8. package/dist/assets/{c4Diagram-YG6GDRKO-DPXJvInm.js → c4Diagram-YG6GDRKO-G9Zj2LWB.js} +1 -1
  9. package/dist/assets/channel-CcCVj0rn.js +1 -0
  10. package/dist/assets/{chunk-4BX2VUAB-DPHGuPB4.js → chunk-4BX2VUAB-s3svQOtk.js} +1 -1
  11. package/dist/assets/{chunk-55IACEB6-BbI83gIK.js → chunk-55IACEB6-C6fhtS_L.js} +1 -1
  12. package/dist/assets/{chunk-B4BG7PRW-DGvVY1Y_.js → chunk-B4BG7PRW-CpePj3eQ.js} +1 -1
  13. package/dist/assets/{chunk-DI55MBZ5-2YN0FhPB.js → chunk-DI55MBZ5-Dq9AChBD.js} +1 -1
  14. package/dist/assets/{chunk-FMBD7UC4-Ctqs0bWj.js → chunk-FMBD7UC4-DSOMddvX.js} +1 -1
  15. package/dist/assets/{chunk-QN33PNHL-CfvfTLi4.js → chunk-QN33PNHL-ZrdWE4nY.js} +1 -1
  16. package/dist/assets/{chunk-QZHKN3VN-C-dG1NiD.js → chunk-QZHKN3VN-BqLE4kwR.js} +1 -1
  17. package/dist/assets/{chunk-TZMSLE5B-Bh07YiOi.js → chunk-TZMSLE5B-DY7GliuX.js} +1 -1
  18. package/dist/assets/classDiagram-2ON5EDUG-8I6xWkSV.js +1 -0
  19. package/dist/assets/classDiagram-v2-WZHVMYZB-8I6xWkSV.js +1 -0
  20. package/dist/assets/clone-zAvPgvOP.js +1 -0
  21. package/dist/assets/{cose-bilkent-S5V4N54A-CTC2n8Hd.js → cose-bilkent-S5V4N54A-DmRYvz2l.js} +1 -1
  22. package/dist/assets/{dagre-6UL2VRFP-CAgGZKYt.js → dagre-6UL2VRFP-D4IfaYgb.js} +1 -1
  23. package/dist/assets/{diagram-PSM6KHXK-DymwxXvC.js → diagram-PSM6KHXK-ClfgeCpw.js} +1 -1
  24. package/dist/assets/{diagram-QEK2KX5R-CAEL5_ej.js → diagram-QEK2KX5R-BaBfarh-.js} +1 -1
  25. package/dist/assets/{diagram-S2PKOQOG-B6onWI1I.js → diagram-S2PKOQOG-DT1eYmOM.js} +1 -1
  26. package/dist/assets/{erDiagram-Q2GNP2WA-DP-dbxgu.js → erDiagram-Q2GNP2WA-C5ZVoHhn.js} +1 -1
  27. package/dist/assets/{flowDiagram-NV44I4VS-OMmA9Rvb.js → flowDiagram-NV44I4VS-C9MYAakn.js} +1 -1
  28. package/dist/assets/{ganttDiagram-JELNMOA3-B2oAHD8b.js → ganttDiagram-JELNMOA3-HHt6rJj9.js} +1 -1
  29. package/dist/assets/{gitGraphDiagram-V2S2FVAM-CAZO9ISt.js → gitGraphDiagram-V2S2FVAM-CfA3qwoT.js} +1 -1
  30. package/dist/assets/{graph-CeeDmBlM.js → graph-BLJloKEi.js} +1 -1
  31. package/dist/assets/index-CvOAfVHK.css +32 -0
  32. package/dist/assets/{index-CAGJQXdJ.js → index-CxNtY8EJ.js} +82 -82
  33. package/dist/assets/{infoDiagram-HS3SLOUP-m0CP23H-.js → infoDiagram-HS3SLOUP-ASXl-hwl.js} +1 -1
  34. package/dist/assets/{journeyDiagram-XKPGCS4Q-C9WvzT-H.js → journeyDiagram-XKPGCS4Q-B-wDjjzW.js} +1 -1
  35. package/dist/assets/{kanban-definition-3W4ZIXB7-KIEeugzr.js → kanban-definition-3W4ZIXB7-BNWLVKm2.js} +1 -1
  36. package/dist/assets/{layout-P-20nhx7.js → layout-cpBTqNBI.js} +1 -1
  37. package/dist/assets/{linear-CMM5aRIM.js → linear-DAonGknK.js} +1 -1
  38. package/dist/assets/{mermaid.core-Z9YQUdi5.js → mermaid.core-BdetWGOO.js} +4 -4
  39. package/dist/assets/{mindmap-definition-VGOIOE7T-B-NyLDuB.js → mindmap-definition-VGOIOE7T-CJOEqJKm.js} +1 -1
  40. package/dist/assets/{pieDiagram-ADFJNKIX-Dp8zgF7X.js → pieDiagram-ADFJNKIX-Dnc1ekZC.js} +1 -1
  41. package/dist/assets/{quadrantDiagram-AYHSOK5B-Cs07PJ50.js → quadrantDiagram-AYHSOK5B-Dr56Xspi.js} +1 -1
  42. package/dist/assets/{requirementDiagram-UZGBJVZJ-CWMm228C.js → requirementDiagram-UZGBJVZJ-z-G-ThCz.js} +1 -1
  43. package/dist/assets/{sankeyDiagram-TZEHDZUN-Cf7zGgTA.js → sankeyDiagram-TZEHDZUN-D_4p6Me1.js} +1 -1
  44. package/dist/assets/{sequenceDiagram-WL72ISMW-TzZvjAhX.js → sequenceDiagram-WL72ISMW-DD1K4OoS.js} +1 -1
  45. package/dist/assets/{stateDiagram-FKZM4ZOC-CH45r52Y.js → stateDiagram-FKZM4ZOC-ENIvCriI.js} +1 -1
  46. package/dist/assets/stateDiagram-v2-4FDKWEC3-BgJoZY9E.js +1 -0
  47. package/dist/assets/{timeline-definition-IT6M3QCI-CLaDCX_H.js → timeline-definition-IT6M3QCI-V8I1MBPc.js} +1 -1
  48. package/dist/assets/{treemap-GDKQZRPO-BNNAKE7E.js → treemap-GDKQZRPO-1lnlvkR3.js} +1 -1
  49. package/dist/assets/{xychartDiagram-PRI3JC2R-BG_0_QvU.js → xychartDiagram-PRI3JC2R-CrSrN3UV.js} +1 -1
  50. package/dist/index.html +2 -2
  51. package/package.json +1 -1
  52. package/server/collective-intelligence.ts +134 -43
  53. package/server/embedding.ts +12 -3
  54. package/server/memory-consolidation.ts +703 -0
  55. package/server/memory-migration.ts +468 -0
  56. package/server/routes/ci-routes.ts +68 -10
  57. package/server/routes/settings-routes.ts +10 -2
  58. package/server/semantic-memory.ts +1149 -187
  59. package/server/session-types.ts +15 -0
  60. package/server/settings-manager.ts +122 -3
  61. package/server/ws-bridge.ts +209 -26
  62. package/dist/assets/channel-D6cof2B8.js +0 -1
  63. package/dist/assets/classDiagram-2ON5EDUG-BG_dxrC_.js +0 -1
  64. package/dist/assets/classDiagram-v2-WZHVMYZB-BG_dxrC_.js +0 -1
  65. package/dist/assets/clone-vuZ4vhTG.js +0 -1
  66. package/dist/assets/index-Byjg9zgI.css +0 -32
  67. package/dist/assets/stateDiagram-v2-4FDKWEC3-DaPUshfK.js +0 -1
@@ -223,6 +223,10 @@ export type BrowserIncomingMessageBase =
223
223
  | { type: "memory_stored"; fragment: import("./semantic-memory.js").MemoryFragment }
224
224
  | { type: "memory_query_result"; query: string; results: import("./semantic-memory.js").MemoryFragment[] }
225
225
  | { type: "memory_consolidated"; tag: string; knowledge: import("./semantic-memory.js").ConsolidatedKnowledge }
226
+ // Emitted when a user_message was enriched with recalled memories, so the
227
+ // UI can render a collapsible "recalled context" chip instead of the
228
+ // injected text being invisible. `items` lists exactly what was included.
229
+ | { type: "memory_enriched"; user_message_id?: string; items: MemoryEnrichmentItem[]; truncated?: boolean }
226
230
  // Layer 2: Deliberation
227
231
  | { type: "deliberation_proposal"; proposal: import("./deliberation-engine.js").DeliberationProposal }
228
232
  | { type: "deliberation_response"; response: import("./deliberation-engine.js").DeliberationResponse }
@@ -237,6 +241,17 @@ export type BrowserIncomingMessageBase =
237
241
  | { type: "semantic_link_added"; sourceId: string; targetId: string; relation: string }
238
242
  | { type: "consensus_update"; state: import("./shared-context.js").ConsensusState };
239
243
 
244
+ /** One recalled memory included in an enriched prompt (for the UI chip). */
245
+ export interface MemoryEnrichmentItem {
246
+ id: string;
247
+ kind: "knowledge" | "fragment";
248
+ namespace: string;
249
+ summary: string;
250
+ tag?: string;
251
+ /** Decayed weight at recall time (0..1). */
252
+ weight: number;
253
+ }
254
+
240
255
  export type BrowserIncomingMessage = BrowserIncomingMessageBase & { seq?: number };
241
256
 
242
257
  export type ReplayableBrowserIncomingMessage = Exclude<BrowserIncomingMessageBase, { type: "event_replay" }>;
@@ -11,6 +11,85 @@ export const DEFAULT_OPENROUTER_MODEL = "openrouter/free";
11
11
 
12
12
  export type EmbeddingProvider = "openai" | "ollama" | "none";
13
13
 
14
+ // ─── Semantic memory settings (design doc §3.1/§3.2) ─────────────────────────
15
+
16
+ /** Decay policy for one namespace class. halfLifeHours = null → never decays. */
17
+ export interface MemoryDecayPolicy {
18
+ halfLifeHours: number | null;
19
+ reinforceMultiplier: number;
20
+ }
21
+
22
+ export interface MemorySettings {
23
+ decay: {
24
+ global: MemoryDecayPolicy;
25
+ repo: MemoryDecayPolicy;
26
+ session: MemoryDecayPolicy;
27
+ agent: MemoryDecayPolicy;
28
+ };
29
+ /** Per-namespace recall depth for retrieval (ADR-161's "depth is a tunable"). */
30
+ recallDepth: {
31
+ session: number;
32
+ repo: number;
33
+ agent: number;
34
+ global: number;
35
+ };
36
+ }
37
+
38
+ /** Defaults per §3.1: 90d/30d/7d/60d half-lives (in hours), ×1.5/1.5/1.2/1.2. */
39
+ export const DEFAULT_MEMORY_SETTINGS: MemorySettings = {
40
+ decay: {
41
+ global: { halfLifeHours: 90 * 24, reinforceMultiplier: 1.5 },
42
+ repo: { halfLifeHours: 30 * 24, reinforceMultiplier: 1.5 },
43
+ session: { halfLifeHours: 7 * 24, reinforceMultiplier: 1.2 },
44
+ agent: { halfLifeHours: 60 * 24, reinforceMultiplier: 1.2 },
45
+ },
46
+ recallDepth: { session: 4, repo: 6, agent: 2, global: 3 },
47
+ };
48
+
49
+ function normalizeDecayPolicy(raw: unknown, fallback: MemoryDecayPolicy): MemoryDecayPolicy {
50
+ const r = (raw ?? {}) as Partial<MemoryDecayPolicy>;
51
+ const halfLifeHours =
52
+ r.halfLifeHours === null
53
+ ? null
54
+ : typeof r.halfLifeHours === "number" && Number.isFinite(r.halfLifeHours) && r.halfLifeHours > 0
55
+ ? r.halfLifeHours
56
+ : fallback.halfLifeHours;
57
+ const reinforceMultiplier =
58
+ typeof r.reinforceMultiplier === "number" &&
59
+ Number.isFinite(r.reinforceMultiplier) &&
60
+ r.reinforceMultiplier >= 1
61
+ ? r.reinforceMultiplier
62
+ : fallback.reinforceMultiplier;
63
+ return { halfLifeHours, reinforceMultiplier };
64
+ }
65
+
66
+ function normalizeRecallDepth(raw: unknown, key: keyof MemorySettings["recallDepth"]): number {
67
+ const r = (raw ?? {}) as Record<string, unknown>;
68
+ const v = r[key];
69
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0) return Math.floor(v);
70
+ return DEFAULT_MEMORY_SETTINGS.recallDepth[key];
71
+ }
72
+
73
+ /** Deep-merge a (possibly partial/invalid) saved memory section over the defaults. */
74
+ export function normalizeMemorySettings(raw: unknown): MemorySettings {
75
+ const r = (raw ?? {}) as { decay?: Record<string, unknown>; recallDepth?: unknown };
76
+ const decayRaw = r.decay ?? {};
77
+ return {
78
+ decay: {
79
+ global: normalizeDecayPolicy(decayRaw.global, DEFAULT_MEMORY_SETTINGS.decay.global),
80
+ repo: normalizeDecayPolicy(decayRaw.repo, DEFAULT_MEMORY_SETTINGS.decay.repo),
81
+ session: normalizeDecayPolicy(decayRaw.session, DEFAULT_MEMORY_SETTINGS.decay.session),
82
+ agent: normalizeDecayPolicy(decayRaw.agent, DEFAULT_MEMORY_SETTINGS.decay.agent),
83
+ },
84
+ recallDepth: {
85
+ session: normalizeRecallDepth(r.recallDepth, "session"),
86
+ repo: normalizeRecallDepth(r.recallDepth, "repo"),
87
+ agent: normalizeRecallDepth(r.recallDepth, "agent"),
88
+ global: normalizeRecallDepth(r.recallDepth, "global"),
89
+ },
90
+ };
91
+ }
92
+
14
93
  export interface CampfireSettings {
15
94
  openrouterApiKey: string;
16
95
  openrouterModel: string;
@@ -28,6 +107,13 @@ export interface CampfireSettings {
28
107
  embeddingApiKey: string; // OpenAI API key (if provider = "openai")
29
108
  embeddingModel: string; // e.g. "text-embedding-3-small" or "nomic-embed-text"
30
109
  embeddingBaseUrl: string; // Ollama base URL (if provider = "ollama"), default http://localhost:11434
110
+ /**
111
+ * Semantic memory v2: decay policies + recall depths per namespace class.
112
+ * Always populated by normalize() at runtime — typed optional only so
113
+ * pre-v2 CampfireSettings literals (test mocks) keep compiling. Use
114
+ * getMemorySettings() for guaranteed-present typed access.
115
+ */
116
+ memory?: MemorySettings;
31
117
  /** Whether the onboarding wizard has been completed or skipped */
32
118
  onboardingCompleted: boolean;
33
119
  updatedAt: number;
@@ -49,6 +135,7 @@ let settings: CampfireSettings = {
49
135
  embeddingApiKey: "",
50
136
  embeddingModel: "",
51
137
  embeddingBaseUrl: "http://localhost:11434",
138
+ memory: normalizeMemorySettings(null),
52
139
  onboardingCompleted: false,
53
140
  updatedAt: 0,
54
141
  };
@@ -71,6 +158,7 @@ function normalize(raw: Partial<CampfireSettings> | null | undefined): CampfireS
71
158
  embeddingBaseUrl: typeof raw?.embeddingBaseUrl === "string" && raw.embeddingBaseUrl.trim()
72
159
  ? raw.embeddingBaseUrl
73
160
  : "http://localhost:11434",
161
+ memory: normalizeMemorySettings(raw?.memory),
74
162
  onboardingCompleted: raw?.onboardingCompleted === true,
75
163
  updatedAt: typeof raw?.updatedAt === "number" ? raw.updatedAt : 0,
76
164
  };
@@ -99,14 +187,45 @@ export function getSettings(): CampfireSettings {
99
187
  return { ...settings };
100
188
  }
101
189
 
190
+ /** Semantic memory settings with defaults guaranteed (never undefined). */
191
+ export function getMemorySettings(): MemorySettings {
192
+ ensureLoaded();
193
+ return settings.memory ?? normalizeMemorySettings(null);
194
+ }
195
+
196
+ /** Deep-merge a partial memory patch over the current memory settings, then normalize. */
197
+ function mergeMemoryPatch(current: MemorySettings, patch: unknown): MemorySettings {
198
+ if (!patch || typeof patch !== "object") return current;
199
+ const p = patch as {
200
+ decay?: Record<string, Record<string, unknown> | undefined>;
201
+ recallDepth?: Record<string, unknown>;
202
+ };
203
+ return normalizeMemorySettings({
204
+ decay: {
205
+ global: { ...current.decay.global, ...p.decay?.global },
206
+ repo: { ...current.decay.repo, ...p.decay?.repo },
207
+ session: { ...current.decay.session, ...p.decay?.session },
208
+ agent: { ...current.decay.agent, ...p.decay?.agent },
209
+ },
210
+ recallDepth: { ...current.recallDepth, ...p.recallDepth },
211
+ });
212
+ }
213
+
102
214
  export function updateSettings(
103
- patch: Partial<Pick<CampfireSettings, "openrouterApiKey" | "openrouterModel" | "moltbookApiKey" | "linearApiKey" | "claudeOAuthToken" | "openaiApiKey" | "anthropicApiKey" | "embeddingProvider" | "embeddingApiKey" | "embeddingModel" | "embeddingBaseUrl" | "onboardingCompleted">>,
215
+ patch: Partial<Pick<CampfireSettings, "openrouterApiKey" | "openrouterModel" | "moltbookApiKey" | "linearApiKey" | "claudeOAuthToken" | "openaiApiKey" | "anthropicApiKey" | "embeddingProvider" | "embeddingApiKey" | "embeddingModel" | "embeddingBaseUrl" | "onboardingCompleted">> & {
216
+ /** Partial memory settings — deep-merged over the current values. */
217
+ memory?: unknown;
218
+ },
104
219
  ): CampfireSettings {
105
220
  ensureLoaded();
221
+ const { memory: memoryPatch, ...rest } = patch;
106
222
  settings = {
107
223
  ...settings,
108
- ...patch,
109
- openrouterModel: patch.openrouterModel?.trim() || settings.openrouterModel || DEFAULT_OPENROUTER_MODEL,
224
+ ...rest,
225
+ openrouterModel: rest.openrouterModel?.trim() || settings.openrouterModel || DEFAULT_OPENROUTER_MODEL,
226
+ memory: memoryPatch !== undefined
227
+ ? mergeMemoryPatch(settings.memory ?? normalizeMemorySettings(null), memoryPatch)
228
+ : settings.memory,
110
229
  updatedAt: Date.now(),
111
230
  };
112
231
  persist();
@@ -35,6 +35,7 @@ import type { CodexAdapter } from "./codex-adapter.js";
35
35
  import type { AgentAdapter } from "./adapter-types.js";
36
36
  import type { RecorderManager } from "./recorder.js";
37
37
  import type { CollectiveIntelligenceLayer } from "./collective-intelligence.js";
38
+ import { consolidate, noteSessionActivity, shouldConsolidateOnTurn } from "./memory-consolidation.js";
38
39
  import { evaluateAutoInjection, scanMcpServers } from "./mcp-policy.js";
39
40
 
40
41
  // ─── WebSocket data tags ──────────────────────────────────────────────────────
@@ -294,6 +295,8 @@ export class WsBridge {
294
295
  ]);
295
296
  private static readonly VOTE_DEADLINE_MS = 30_000; // 30 seconds to vote
296
297
  private static readonly INVITE_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
298
+ /** Memory-enrichment budget (§3.6.1): past this, the original prompt is sent unenriched. */
299
+ private static readonly ENRICHMENT_TIMEOUT_MS = 250;
297
300
  private readonly sessions = new Map<string, Session>();
298
301
  private readonly inviteTokens = new Map<string, { sessionId: string; role: SessionRole; expiresAt: number }>();
299
302
  private viewerCounter = 0;
@@ -309,6 +312,8 @@ export class WsBridge {
309
312
  private readonly environmentMcpInjected = new Set<string>();
310
313
  private readonly autoNamingAttempted = new Set<string>();
311
314
  private userMsgCounter = 0;
315
+ /** Per-session promise chain serializing enriched user_message dispatch (§3.6.1). */
316
+ private readonly userMessageChains = new Map<string, Promise<void>>();
312
317
  private onGitInfoReady: ((sessionId: string, cwd: string, branch: string) => void) | null = null;
313
318
  private static readonly GIT_SESSION_KEYS: GitSessionKey[] = [
314
319
  "git_branch",
@@ -1033,6 +1038,7 @@ export class WsBridge {
1033
1038
  this.persistSession(session);
1034
1039
  this.injectDetectedEnvironmentMcp(session);
1035
1040
  this.agentMcpBridge?.onSessionReady(session.id, backendType, session.state.cwd);
1041
+ this.collectiveIntelligence?.warmForSession(session.state.repo_root || session.state.cwd || "", backendType);
1036
1042
  } else if (msg.type === "session_update") {
1037
1043
  session.state = { ...session.state, ...msg.session, backend_type: backendType };
1038
1044
  this.refreshGitInfo(session, { notifyPoller: true });
@@ -1054,6 +1060,8 @@ export class WsBridge {
1054
1060
  }
1055
1061
  session.messageHistory.push(msg);
1056
1062
  this.persistSession(session);
1063
+ // §3.4 trigger 1 (adapter result path): consolidate on turn boundary
1064
+ this.maybeConsolidateOnTurn(session);
1057
1065
  }
1058
1066
 
1059
1067
  // Handle permission requests
@@ -1375,6 +1383,7 @@ export class WsBridge {
1375
1383
  this.persistSession(session);
1376
1384
  this.injectDetectedEnvironmentMcp(session);
1377
1385
  this.agentMcpBridge?.onSessionReady(session.id, session.backendType, session.state.cwd);
1386
+ this.collectiveIntelligence?.warmForSession(session.state.repo_root || session.state.cwd || "", session.backendType);
1378
1387
  } else if (msg.subtype === "status") {
1379
1388
  session.state.is_compacting = msg.status === "compacting";
1380
1389
 
@@ -1582,6 +1591,9 @@ export class WsBridge {
1582
1591
 
1583
1592
  this.emitResultWebhooks(session, msg);
1584
1593
 
1594
+ // §3.4 trigger 1: consolidate on turn boundary (fire-and-forget)
1595
+ this.maybeConsolidateOnTurn(session);
1596
+
1585
1597
  // Trigger auto-naming after the first successful result
1586
1598
  this.tryAutoNaming(session, browserMsg);
1587
1599
  }
@@ -1690,21 +1702,31 @@ export class WsBridge {
1690
1702
  return true;
1691
1703
  }
1692
1704
 
1693
- /** Route a browser message to an adapter-based backend (Codex, Goose, etc.). */
1705
+ /** Append a user message to session history with a stable id for reconnect dedup. */
1706
+ private pushUserHistoryEntry(session: Session, content: string): string {
1707
+ const ts = Date.now();
1708
+ const id = `user-${ts}-${this.userMsgCounter++}`;
1709
+ session.messageHistory.push({ type: "user_message", content, timestamp: ts, id });
1710
+ return id;
1711
+ }
1712
+
1713
+ /**
1714
+ * Route a browser message to an adapter-based backend (Codex, Goose, etc.).
1715
+ * For user_message, `userHistoryContent` (when set) is what goes into
1716
+ * messageHistory — the original user text — while `msg.content` (possibly
1717
+ * memory-enriched, §3.6.1) is what the adapter receives. Returns the
1718
+ * history entry id for user messages.
1719
+ */
1694
1720
  private routeToAdapter(
1695
1721
  session: Session,
1696
1722
  msg: BrowserOutgoingMessage,
1697
1723
  ws?: ServerWebSocket<SocketData>,
1698
- ): void {
1724
+ userHistoryContent?: string,
1725
+ ): string | null {
1726
+ let userHistoryId: string | null = null;
1699
1727
  // Store user messages in history for replay with stable ID for dedup on reconnect
1700
1728
  if (msg.type === "user_message") {
1701
- const ts = Date.now();
1702
- session.messageHistory.push({
1703
- type: "user_message",
1704
- content: msg.content,
1705
- timestamp: ts,
1706
- id: `user-${ts}-${this.userMsgCounter++}`,
1707
- });
1729
+ userHistoryId = this.pushUserHistoryEntry(session, userHistoryContent ?? msg.content);
1708
1730
  this.persistSession(session);
1709
1731
  }
1710
1732
 
@@ -1714,7 +1736,7 @@ export class WsBridge {
1714
1736
  const eligibleVoters = this.countEligibleVoters(session);
1715
1737
  if (eligibleVoters > 1) {
1716
1738
  this.recordVote(session, msg.request_id, ws, msg.behavior, msg);
1717
- return;
1739
+ return null;
1718
1740
  }
1719
1741
  const pending = session.pendingPermissions.get(msg.request_id);
1720
1742
  if (session.backendType === "claude" && msg.behavior === "allow" && !msg.updated_input && pending) {
@@ -1731,18 +1753,24 @@ export class WsBridge {
1731
1753
  console.log(`[ws-bridge] ${session.backendType} adapter not yet attached for session ${session.id}, queuing ${msg.type}`);
1732
1754
  session.pendingMessages.push(JSON.stringify(outboundMsg));
1733
1755
  }
1756
+ return userHistoryId;
1734
1757
  }
1735
1758
 
1736
- /** Route a browser message to the Claude Code CLI backend. */
1759
+ /**
1760
+ * Route a browser message to the Claude Code CLI backend.
1761
+ * `userHistoryContent` behaves as in routeToAdapter (§3.6.1): history text
1762
+ * override for user messages. Returns the history entry id for user
1763
+ * messages, null otherwise.
1764
+ */
1737
1765
  private routeToClaude(
1738
1766
  session: Session,
1739
1767
  msg: BrowserOutgoingMessage,
1740
1768
  ws?: ServerWebSocket<SocketData>,
1741
- ): void {
1769
+ userHistoryContent?: string,
1770
+ ): string | null {
1742
1771
  switch (msg.type) {
1743
1772
  case "user_message":
1744
- this.handleUserMessage(session, msg);
1745
- break;
1773
+ return this.handleUserMessage(session, msg, userHistoryContent);
1746
1774
  case "permission_response":
1747
1775
  this.handlePermissionResponse(session, msg, ws);
1748
1776
  break;
@@ -1768,6 +1796,7 @@ export class WsBridge {
1768
1796
  this.handleMcpSetServers(session, msg.servers);
1769
1797
  break;
1770
1798
  }
1799
+ return null;
1771
1800
  }
1772
1801
 
1773
1802
  private routeBrowserMessage(
@@ -1819,15 +1848,167 @@ export class WsBridge {
1819
1848
  this.rememberClientMessage(session, msg.client_msg_id);
1820
1849
  }
1821
1850
 
1851
+ // §3.6.1: user messages take the (possibly async) memory-enrichment path
1852
+ // after all RBAC / idempotency gates above. Everything else dispatches
1853
+ // synchronously exactly as before.
1854
+ if (msg.type === "user_message") {
1855
+ this.handleUserMessageWithEnrichment(session, msg, ws);
1856
+ return;
1857
+ }
1858
+
1859
+ this.dispatchBrowserMessage(session, msg, ws);
1860
+ }
1861
+
1862
+ /**
1863
+ * Final backend dispatch (adapter vs Claude CLI), extracted from
1864
+ * routeBrowserMessage so the enrichment path can reuse it.
1865
+ *
1866
+ * For user_message, `userHistoryContent` overrides the text stored in
1867
+ * messageHistory (history shows what the user typed; `msg.content` — which
1868
+ * may carry the enrichment block — goes to the backend). Returns the
1869
+ * history entry id for user messages, null otherwise.
1870
+ */
1871
+ private dispatchBrowserMessage(
1872
+ session: Session,
1873
+ msg: BrowserOutgoingMessage,
1874
+ ws?: ServerWebSocket<SocketData>,
1875
+ userHistoryContent?: string,
1876
+ ): string | null {
1822
1877
  if (session.adapter) {
1823
- this.routeToAdapter(session, msg, ws);
1878
+ return this.routeToAdapter(session, msg, ws, userHistoryContent);
1824
1879
  } else if (session.backendType === "claude") {
1825
- this.routeToClaude(session, msg, ws);
1880
+ return this.routeToClaude(session, msg, ws, userHistoryContent);
1826
1881
  } else {
1827
- this.routeToAdapter(session, msg, ws);
1882
+ return this.routeToAdapter(session, msg, ws, userHistoryContent);
1883
+ }
1884
+ }
1885
+
1886
+ /**
1887
+ * user_message routing with the memory-enrichment hook (§3.6.1–3.6.4).
1888
+ *
1889
+ * ASYNC-RIPPLE DECISION: routeBrowserMessage stays synchronous. Enrichment
1890
+ * is an async pre-step performed only for user_message and only when a
1891
+ * collective-intelligence layer is attached — so all existing synchronous
1892
+ * callers (handleBrowserMessage, injectUserMessage, setMcpServers,
1893
+ * injectDetectedEnvironmentMcp) keep their contracts, and sessions without
1894
+ * CI keep the fully synchronous v1 path. Because each enrichment awaits
1895
+ * independently, two rapid user messages could otherwise dispatch out of
1896
+ * order (a slow enrichment on msg1 racing a fast one on msg2); to preserve
1897
+ * per-session ordering we serialize user-message dispatch through a
1898
+ * per-session promise chain. The chain can back up at most
1899
+ * ENRICHMENT_TIMEOUT_MS per message, so chat never blocks on memory.
1900
+ */
1901
+ private handleUserMessageWithEnrichment(
1902
+ session: Session,
1903
+ msg: BrowserOutgoingMessage & { type: "user_message" },
1904
+ ws?: ServerWebSocket<SocketData>,
1905
+ ): void {
1906
+ // §3.4 idle trigger: any user activity resets the idle clock.
1907
+ try {
1908
+ noteSessionActivity(this.consolidationContext(session));
1909
+ } catch (err) {
1910
+ console.warn(`[ws-bridge] noteSessionActivity failed for ${session.id}:`, err);
1911
+ }
1912
+
1913
+ const ci = this.collectiveIntelligence;
1914
+ if (!ci || typeof ci.enrichUserMessage !== "function") {
1915
+ // No CI layer attached — keep the fully synchronous dispatch path.
1916
+ this.dispatchBrowserMessage(session, msg, ws);
1917
+ return;
1918
+ }
1919
+
1920
+ const prior = this.userMessageChains.get(session.id) ?? Promise.resolve();
1921
+ const next = prior.then(() =>
1922
+ this.enrichAndDispatchUserMessage(session, msg, ws).catch((err) => {
1923
+ console.warn(`[ws-bridge] user_message dispatch failed for ${session.id}:`, err);
1924
+ }),
1925
+ );
1926
+ this.userMessageChains.set(session.id, next);
1927
+ // Drop the chain entry once fully drained so the map doesn't grow forever.
1928
+ void next.finally(() => {
1929
+ if (this.userMessageChains.get(session.id) === next) {
1930
+ this.userMessageChains.delete(session.id);
1931
+ }
1932
+ });
1933
+ }
1934
+
1935
+ /**
1936
+ * Enrich a user message with recalled memory, dispatch it, and surface the
1937
+ * recall to browsers (§3.6.1–3.6.4):
1938
+ * - enrichment is raced against ENRICHMENT_TIMEOUT_MS and try/caught —
1939
+ * on timeout or error the ORIGINAL content is sent unchanged;
1940
+ * - the backend receives `<block>\n\n<original>`, while messageHistory
1941
+ * stores the original user text;
1942
+ * - after the history entry exists, a memory_enriched message carrying its
1943
+ * id + the recalled items is broadcast (skipped when block is null).
1944
+ */
1945
+ private async enrichAndDispatchUserMessage(
1946
+ session: Session,
1947
+ msg: BrowserOutgoingMessage & { type: "user_message" },
1948
+ ws?: ServerWebSocket<SocketData>,
1949
+ ): Promise<void> {
1950
+ let enrichment: import("./semantic-memory.js").EnrichmentResult | null = null;
1951
+ try {
1952
+ const timeout = new Promise<null>((resolveRace) => {
1953
+ const timer = setTimeout(() => resolveRace(null), WsBridge.ENRICHMENT_TIMEOUT_MS);
1954
+ timer.unref?.();
1955
+ });
1956
+ enrichment = await Promise.race([
1957
+ this.collectiveIntelligence!.enrichUserMessage(this.consolidationContext(session), msg.content),
1958
+ timeout,
1959
+ ]);
1960
+ } catch (err) {
1961
+ console.warn(`[ws-bridge] memory enrichment failed for ${session.id} (sending original):`, err);
1962
+ enrichment = null;
1963
+ }
1964
+
1965
+ const block = enrichment?.block ?? null;
1966
+ const outbound = block ? { ...msg, content: `${block}\n\n${msg.content}` } : msg;
1967
+ const historyId = this.dispatchBrowserMessage(session, outbound, ws, msg.content);
1968
+
1969
+ if (block && enrichment && enrichment.items.length > 0) {
1970
+ this.broadcastToBrowsers(session, {
1971
+ type: "memory_enriched",
1972
+ user_message_id: historyId ?? undefined,
1973
+ items: enrichment.items,
1974
+ });
1828
1975
  }
1829
1976
  }
1830
1977
 
1978
+ /** Session context for memory consolidation / enrichment (§3.6 item 5). */
1979
+ private consolidationContext(session: Session): {
1980
+ sessionId: string;
1981
+ repoRoot: string;
1982
+ backendType: BackendType;
1983
+ } {
1984
+ return {
1985
+ sessionId: session.id,
1986
+ repoRoot: session.state.repo_root || session.state.cwd || "",
1987
+ backendType: session.backendType,
1988
+ };
1989
+ }
1990
+
1991
+ /**
1992
+ * §3.4 turn-boundary trigger: on every result message, record activity for
1993
+ * the idle watcher and — fire-and-forget, off the hot path — consolidate
1994
+ * when the un-consolidated fragment threshold is met.
1995
+ */
1996
+ private maybeConsolidateOnTurn(session: Session): void {
1997
+ const ctx = this.consolidationContext(session);
1998
+ try {
1999
+ noteSessionActivity(ctx);
2000
+ } catch (err) {
2001
+ console.warn(`[ws-bridge] noteSessionActivity failed for ${session.id}:`, err);
2002
+ }
2003
+ void (async () => {
2004
+ if (await shouldConsolidateOnTurn(session.id)) {
2005
+ await consolidate({ ...ctx, reason: "turn_boundary" });
2006
+ }
2007
+ })().catch((err) => {
2008
+ console.warn(`[ws-bridge] turn-boundary consolidation failed for ${session.id}:`, err);
2009
+ });
2010
+ }
2011
+
1831
2012
  private isDuplicateClientMessage(session: Session, clientMsgId: string): boolean {
1832
2013
  return session.processedClientMessageIdSet.has(clientMsgId);
1833
2014
  }
@@ -1902,18 +2083,19 @@ export class WsBridge {
1902
2083
  }
1903
2084
  }
1904
2085
 
2086
+ /**
2087
+ * Send a user message to the Claude CLI. `historyContent` (when set) is the
2088
+ * text stored in messageHistory — the original user prompt — while
2089
+ * `msg.content` (possibly memory-enriched, §3.6.1) goes to the CLI.
2090
+ * Returns the history entry id.
2091
+ */
1905
2092
  private handleUserMessage(
1906
2093
  session: Session,
1907
- msg: { type: "user_message"; content: string; session_id?: string; images?: { media_type: string; data: string }[] }
1908
- ) {
2094
+ msg: { type: "user_message"; content: string; session_id?: string; images?: { media_type: string; data: string }[] },
2095
+ historyContent?: string,
2096
+ ): string {
1909
2097
  // Store user message in history for replay with stable ID for dedup on reconnect
1910
- const ts = Date.now();
1911
- session.messageHistory.push({
1912
- type: "user_message",
1913
- content: msg.content,
1914
- timestamp: ts,
1915
- id: `user-${ts}-${this.userMsgCounter++}`,
1916
- });
2098
+ const historyId = this.pushUserHistoryEntry(session, historyContent ?? msg.content);
1917
2099
 
1918
2100
  // Build content: if images are present, use content block array; otherwise plain string
1919
2101
  let content: string | unknown[];
@@ -1939,6 +2121,7 @@ export class WsBridge {
1939
2121
  });
1940
2122
  this.sendToCLI(session, ndjson);
1941
2123
  this.persistSession(session);
2124
+ return historyId;
1942
2125
  }
1943
2126
 
1944
2127
  private handlePermissionResponse(
@@ -1 +0,0 @@
1
- import{ap as o,aq as n}from"./mermaid.core-Z9YQUdi5.js";const t=(a,r)=>o.lang.round(n.parse(a)[r]);export{t as c};
@@ -1 +0,0 @@
1
- import{s as a,c as s,a as e,C as t}from"./chunk-B4BG7PRW-DGvVY1Y_.js";import{_ as i}from"./mermaid.core-Z9YQUdi5.js";import"./chunk-FMBD7UC4-Ctqs0bWj.js";import"./chunk-55IACEB6-BbI83gIK.js";import"./chunk-QN33PNHL-CfvfTLi4.js";import"./index-CAGJQXdJ.js";var u={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{u as diagram};
@@ -1 +0,0 @@
1
- import{s as a,c as s,a as e,C as t}from"./chunk-B4BG7PRW-DGvVY1Y_.js";import{_ as i}from"./mermaid.core-Z9YQUdi5.js";import"./chunk-FMBD7UC4-Ctqs0bWj.js";import"./chunk-55IACEB6-BbI83gIK.js";import"./chunk-QN33PNHL-CfvfTLi4.js";import"./index-CAGJQXdJ.js";var u={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{u as diagram};
@@ -1 +0,0 @@
1
- import{b as r}from"./_baseUniq-CZ0BR5Mx.js";var e=4;function a(o){return r(o,e)}export{a as c};