the-campfire 0.2.0 → 0.3.0

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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2388 -0
  3. package/dist/assets/{MermaidDiagram-D_MwUXi3.js → MermaidDiagram-Cas1Ngze.js} +2 -2
  4. package/dist/assets/{_basePickBy-DsMRBmhy.js → _basePickBy-D1h3RRT9.js} +1 -1
  5. package/dist/assets/{_baseUniq-CZ0BR5Mx.js → _baseUniq-Z9p6uaM9.js} +1 -1
  6. package/dist/assets/{arc-D6Y0ooDg.js → arc-DJLNkmre.js} +1 -1
  7. package/dist/assets/{architectureDiagram-VXUJARFQ-3T5ZoEn1.js → architectureDiagram-VXUJARFQ-CBHHYg8R.js} +1 -1
  8. package/dist/assets/{blockDiagram-VD42YOAC-CvOCnB8f.js → blockDiagram-VD42YOAC-DMtvAHPO.js} +1 -1
  9. package/dist/assets/{c4Diagram-YG6GDRKO-DPXJvInm.js → c4Diagram-YG6GDRKO-G9Zj2LWB.js} +1 -1
  10. package/dist/assets/channel-CcCVj0rn.js +1 -0
  11. package/dist/assets/{chunk-4BX2VUAB-DPHGuPB4.js → chunk-4BX2VUAB-s3svQOtk.js} +1 -1
  12. package/dist/assets/{chunk-55IACEB6-BbI83gIK.js → chunk-55IACEB6-C6fhtS_L.js} +1 -1
  13. package/dist/assets/{chunk-B4BG7PRW-DGvVY1Y_.js → chunk-B4BG7PRW-CpePj3eQ.js} +1 -1
  14. package/dist/assets/{chunk-DI55MBZ5-2YN0FhPB.js → chunk-DI55MBZ5-Dq9AChBD.js} +1 -1
  15. package/dist/assets/{chunk-FMBD7UC4-Ctqs0bWj.js → chunk-FMBD7UC4-DSOMddvX.js} +1 -1
  16. package/dist/assets/{chunk-QN33PNHL-CfvfTLi4.js → chunk-QN33PNHL-ZrdWE4nY.js} +1 -1
  17. package/dist/assets/{chunk-QZHKN3VN-C-dG1NiD.js → chunk-QZHKN3VN-BqLE4kwR.js} +1 -1
  18. package/dist/assets/{chunk-TZMSLE5B-Bh07YiOi.js → chunk-TZMSLE5B-DY7GliuX.js} +1 -1
  19. package/dist/assets/classDiagram-2ON5EDUG-8I6xWkSV.js +1 -0
  20. package/dist/assets/classDiagram-v2-WZHVMYZB-8I6xWkSV.js +1 -0
  21. package/dist/assets/clone-zAvPgvOP.js +1 -0
  22. package/dist/assets/{cose-bilkent-S5V4N54A-CTC2n8Hd.js → cose-bilkent-S5V4N54A-DmRYvz2l.js} +1 -1
  23. package/dist/assets/{dagre-6UL2VRFP-CAgGZKYt.js → dagre-6UL2VRFP-D4IfaYgb.js} +1 -1
  24. package/dist/assets/{diagram-PSM6KHXK-DymwxXvC.js → diagram-PSM6KHXK-ClfgeCpw.js} +1 -1
  25. package/dist/assets/{diagram-QEK2KX5R-CAEL5_ej.js → diagram-QEK2KX5R-BaBfarh-.js} +1 -1
  26. package/dist/assets/{diagram-S2PKOQOG-B6onWI1I.js → diagram-S2PKOQOG-DT1eYmOM.js} +1 -1
  27. package/dist/assets/{erDiagram-Q2GNP2WA-DP-dbxgu.js → erDiagram-Q2GNP2WA-C5ZVoHhn.js} +1 -1
  28. package/dist/assets/{flowDiagram-NV44I4VS-OMmA9Rvb.js → flowDiagram-NV44I4VS-C9MYAakn.js} +1 -1
  29. package/dist/assets/{ganttDiagram-JELNMOA3-B2oAHD8b.js → ganttDiagram-JELNMOA3-HHt6rJj9.js} +1 -1
  30. package/dist/assets/{gitGraphDiagram-V2S2FVAM-CAZO9ISt.js → gitGraphDiagram-V2S2FVAM-CfA3qwoT.js} +1 -1
  31. package/dist/assets/{graph-CeeDmBlM.js → graph-BLJloKEi.js} +1 -1
  32. package/dist/assets/index-CvOAfVHK.css +32 -0
  33. package/dist/assets/{index-CAGJQXdJ.js → index-CxNtY8EJ.js} +82 -82
  34. package/dist/assets/{infoDiagram-HS3SLOUP-m0CP23H-.js → infoDiagram-HS3SLOUP-ASXl-hwl.js} +1 -1
  35. package/dist/assets/{journeyDiagram-XKPGCS4Q-C9WvzT-H.js → journeyDiagram-XKPGCS4Q-B-wDjjzW.js} +1 -1
  36. package/dist/assets/{kanban-definition-3W4ZIXB7-KIEeugzr.js → kanban-definition-3W4ZIXB7-BNWLVKm2.js} +1 -1
  37. package/dist/assets/{layout-P-20nhx7.js → layout-cpBTqNBI.js} +1 -1
  38. package/dist/assets/{linear-CMM5aRIM.js → linear-DAonGknK.js} +1 -1
  39. package/dist/assets/{mermaid.core-Z9YQUdi5.js → mermaid.core-BdetWGOO.js} +4 -4
  40. package/dist/assets/{mindmap-definition-VGOIOE7T-B-NyLDuB.js → mindmap-definition-VGOIOE7T-CJOEqJKm.js} +1 -1
  41. package/dist/assets/{pieDiagram-ADFJNKIX-Dp8zgF7X.js → pieDiagram-ADFJNKIX-Dnc1ekZC.js} +1 -1
  42. package/dist/assets/{quadrantDiagram-AYHSOK5B-Cs07PJ50.js → quadrantDiagram-AYHSOK5B-Dr56Xspi.js} +1 -1
  43. package/dist/assets/{requirementDiagram-UZGBJVZJ-CWMm228C.js → requirementDiagram-UZGBJVZJ-z-G-ThCz.js} +1 -1
  44. package/dist/assets/{sankeyDiagram-TZEHDZUN-Cf7zGgTA.js → sankeyDiagram-TZEHDZUN-D_4p6Me1.js} +1 -1
  45. package/dist/assets/{sequenceDiagram-WL72ISMW-TzZvjAhX.js → sequenceDiagram-WL72ISMW-DD1K4OoS.js} +1 -1
  46. package/dist/assets/{stateDiagram-FKZM4ZOC-CH45r52Y.js → stateDiagram-FKZM4ZOC-ENIvCriI.js} +1 -1
  47. package/dist/assets/stateDiagram-v2-4FDKWEC3-BgJoZY9E.js +1 -0
  48. package/dist/assets/{timeline-definition-IT6M3QCI-CLaDCX_H.js → timeline-definition-IT6M3QCI-V8I1MBPc.js} +1 -1
  49. package/dist/assets/{treemap-GDKQZRPO-BNNAKE7E.js → treemap-GDKQZRPO-1lnlvkR3.js} +1 -1
  50. package/dist/assets/{xychartDiagram-PRI3JC2R-BG_0_QvU.js → xychartDiagram-PRI3JC2R-CrSrN3UV.js} +1 -1
  51. package/dist/index.html +2 -2
  52. package/package.json +2 -2
  53. package/server/collective-intelligence.ts +125 -43
  54. package/server/embedding.ts +12 -3
  55. package/server/memory-consolidation.ts +703 -0
  56. package/server/memory-migration.ts +468 -0
  57. package/server/routes/ci-routes.ts +68 -10
  58. package/server/routes/settings-routes.ts +10 -2
  59. package/server/semantic-memory.ts +1127 -187
  60. package/server/session-types.ts +15 -0
  61. package/server/settings-manager.ts +122 -3
  62. package/server/ws-bridge.ts +207 -26
  63. package/dist/assets/channel-D6cof2B8.js +0 -1
  64. package/dist/assets/classDiagram-2ON5EDUG-BG_dxrC_.js +0 -1
  65. package/dist/assets/classDiagram-v2-WZHVMYZB-BG_dxrC_.js +0 -1
  66. package/dist/assets/clone-vuZ4vhTG.js +0 -1
  67. package/dist/assets/index-Byjg9zgI.css +0 -32
  68. 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",
@@ -1054,6 +1059,8 @@ export class WsBridge {
1054
1059
  }
1055
1060
  session.messageHistory.push(msg);
1056
1061
  this.persistSession(session);
1062
+ // §3.4 trigger 1 (adapter result path): consolidate on turn boundary
1063
+ this.maybeConsolidateOnTurn(session);
1057
1064
  }
1058
1065
 
1059
1066
  // Handle permission requests
@@ -1582,6 +1589,9 @@ export class WsBridge {
1582
1589
 
1583
1590
  this.emitResultWebhooks(session, msg);
1584
1591
 
1592
+ // §3.4 trigger 1: consolidate on turn boundary (fire-and-forget)
1593
+ this.maybeConsolidateOnTurn(session);
1594
+
1585
1595
  // Trigger auto-naming after the first successful result
1586
1596
  this.tryAutoNaming(session, browserMsg);
1587
1597
  }
@@ -1690,21 +1700,31 @@ export class WsBridge {
1690
1700
  return true;
1691
1701
  }
1692
1702
 
1693
- /** Route a browser message to an adapter-based backend (Codex, Goose, etc.). */
1703
+ /** Append a user message to session history with a stable id for reconnect dedup. */
1704
+ private pushUserHistoryEntry(session: Session, content: string): string {
1705
+ const ts = Date.now();
1706
+ const id = `user-${ts}-${this.userMsgCounter++}`;
1707
+ session.messageHistory.push({ type: "user_message", content, timestamp: ts, id });
1708
+ return id;
1709
+ }
1710
+
1711
+ /**
1712
+ * Route a browser message to an adapter-based backend (Codex, Goose, etc.).
1713
+ * For user_message, `userHistoryContent` (when set) is what goes into
1714
+ * messageHistory — the original user text — while `msg.content` (possibly
1715
+ * memory-enriched, §3.6.1) is what the adapter receives. Returns the
1716
+ * history entry id for user messages.
1717
+ */
1694
1718
  private routeToAdapter(
1695
1719
  session: Session,
1696
1720
  msg: BrowserOutgoingMessage,
1697
1721
  ws?: ServerWebSocket<SocketData>,
1698
- ): void {
1722
+ userHistoryContent?: string,
1723
+ ): string | null {
1724
+ let userHistoryId: string | null = null;
1699
1725
  // Store user messages in history for replay with stable ID for dedup on reconnect
1700
1726
  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
- });
1727
+ userHistoryId = this.pushUserHistoryEntry(session, userHistoryContent ?? msg.content);
1708
1728
  this.persistSession(session);
1709
1729
  }
1710
1730
 
@@ -1714,7 +1734,7 @@ export class WsBridge {
1714
1734
  const eligibleVoters = this.countEligibleVoters(session);
1715
1735
  if (eligibleVoters > 1) {
1716
1736
  this.recordVote(session, msg.request_id, ws, msg.behavior, msg);
1717
- return;
1737
+ return null;
1718
1738
  }
1719
1739
  const pending = session.pendingPermissions.get(msg.request_id);
1720
1740
  if (session.backendType === "claude" && msg.behavior === "allow" && !msg.updated_input && pending) {
@@ -1731,18 +1751,24 @@ export class WsBridge {
1731
1751
  console.log(`[ws-bridge] ${session.backendType} adapter not yet attached for session ${session.id}, queuing ${msg.type}`);
1732
1752
  session.pendingMessages.push(JSON.stringify(outboundMsg));
1733
1753
  }
1754
+ return userHistoryId;
1734
1755
  }
1735
1756
 
1736
- /** Route a browser message to the Claude Code CLI backend. */
1757
+ /**
1758
+ * Route a browser message to the Claude Code CLI backend.
1759
+ * `userHistoryContent` behaves as in routeToAdapter (§3.6.1): history text
1760
+ * override for user messages. Returns the history entry id for user
1761
+ * messages, null otherwise.
1762
+ */
1737
1763
  private routeToClaude(
1738
1764
  session: Session,
1739
1765
  msg: BrowserOutgoingMessage,
1740
1766
  ws?: ServerWebSocket<SocketData>,
1741
- ): void {
1767
+ userHistoryContent?: string,
1768
+ ): string | null {
1742
1769
  switch (msg.type) {
1743
1770
  case "user_message":
1744
- this.handleUserMessage(session, msg);
1745
- break;
1771
+ return this.handleUserMessage(session, msg, userHistoryContent);
1746
1772
  case "permission_response":
1747
1773
  this.handlePermissionResponse(session, msg, ws);
1748
1774
  break;
@@ -1768,6 +1794,7 @@ export class WsBridge {
1768
1794
  this.handleMcpSetServers(session, msg.servers);
1769
1795
  break;
1770
1796
  }
1797
+ return null;
1771
1798
  }
1772
1799
 
1773
1800
  private routeBrowserMessage(
@@ -1819,15 +1846,167 @@ export class WsBridge {
1819
1846
  this.rememberClientMessage(session, msg.client_msg_id);
1820
1847
  }
1821
1848
 
1849
+ // §3.6.1: user messages take the (possibly async) memory-enrichment path
1850
+ // after all RBAC / idempotency gates above. Everything else dispatches
1851
+ // synchronously exactly as before.
1852
+ if (msg.type === "user_message") {
1853
+ this.handleUserMessageWithEnrichment(session, msg, ws);
1854
+ return;
1855
+ }
1856
+
1857
+ this.dispatchBrowserMessage(session, msg, ws);
1858
+ }
1859
+
1860
+ /**
1861
+ * Final backend dispatch (adapter vs Claude CLI), extracted from
1862
+ * routeBrowserMessage so the enrichment path can reuse it.
1863
+ *
1864
+ * For user_message, `userHistoryContent` overrides the text stored in
1865
+ * messageHistory (history shows what the user typed; `msg.content` — which
1866
+ * may carry the enrichment block — goes to the backend). Returns the
1867
+ * history entry id for user messages, null otherwise.
1868
+ */
1869
+ private dispatchBrowserMessage(
1870
+ session: Session,
1871
+ msg: BrowserOutgoingMessage,
1872
+ ws?: ServerWebSocket<SocketData>,
1873
+ userHistoryContent?: string,
1874
+ ): string | null {
1822
1875
  if (session.adapter) {
1823
- this.routeToAdapter(session, msg, ws);
1876
+ return this.routeToAdapter(session, msg, ws, userHistoryContent);
1824
1877
  } else if (session.backendType === "claude") {
1825
- this.routeToClaude(session, msg, ws);
1878
+ return this.routeToClaude(session, msg, ws, userHistoryContent);
1826
1879
  } else {
1827
- this.routeToAdapter(session, msg, ws);
1880
+ return this.routeToAdapter(session, msg, ws, userHistoryContent);
1881
+ }
1882
+ }
1883
+
1884
+ /**
1885
+ * user_message routing with the memory-enrichment hook (§3.6.1–3.6.4).
1886
+ *
1887
+ * ASYNC-RIPPLE DECISION: routeBrowserMessage stays synchronous. Enrichment
1888
+ * is an async pre-step performed only for user_message and only when a
1889
+ * collective-intelligence layer is attached — so all existing synchronous
1890
+ * callers (handleBrowserMessage, injectUserMessage, setMcpServers,
1891
+ * injectDetectedEnvironmentMcp) keep their contracts, and sessions without
1892
+ * CI keep the fully synchronous v1 path. Because each enrichment awaits
1893
+ * independently, two rapid user messages could otherwise dispatch out of
1894
+ * order (a slow enrichment on msg1 racing a fast one on msg2); to preserve
1895
+ * per-session ordering we serialize user-message dispatch through a
1896
+ * per-session promise chain. The chain can back up at most
1897
+ * ENRICHMENT_TIMEOUT_MS per message, so chat never blocks on memory.
1898
+ */
1899
+ private handleUserMessageWithEnrichment(
1900
+ session: Session,
1901
+ msg: BrowserOutgoingMessage & { type: "user_message" },
1902
+ ws?: ServerWebSocket<SocketData>,
1903
+ ): void {
1904
+ // §3.4 idle trigger: any user activity resets the idle clock.
1905
+ try {
1906
+ noteSessionActivity(this.consolidationContext(session));
1907
+ } catch (err) {
1908
+ console.warn(`[ws-bridge] noteSessionActivity failed for ${session.id}:`, err);
1909
+ }
1910
+
1911
+ const ci = this.collectiveIntelligence;
1912
+ if (!ci || typeof ci.enrichUserMessage !== "function") {
1913
+ // No CI layer attached — keep the fully synchronous dispatch path.
1914
+ this.dispatchBrowserMessage(session, msg, ws);
1915
+ return;
1916
+ }
1917
+
1918
+ const prior = this.userMessageChains.get(session.id) ?? Promise.resolve();
1919
+ const next = prior.then(() =>
1920
+ this.enrichAndDispatchUserMessage(session, msg, ws).catch((err) => {
1921
+ console.warn(`[ws-bridge] user_message dispatch failed for ${session.id}:`, err);
1922
+ }),
1923
+ );
1924
+ this.userMessageChains.set(session.id, next);
1925
+ // Drop the chain entry once fully drained so the map doesn't grow forever.
1926
+ void next.finally(() => {
1927
+ if (this.userMessageChains.get(session.id) === next) {
1928
+ this.userMessageChains.delete(session.id);
1929
+ }
1930
+ });
1931
+ }
1932
+
1933
+ /**
1934
+ * Enrich a user message with recalled memory, dispatch it, and surface the
1935
+ * recall to browsers (§3.6.1–3.6.4):
1936
+ * - enrichment is raced against ENRICHMENT_TIMEOUT_MS and try/caught —
1937
+ * on timeout or error the ORIGINAL content is sent unchanged;
1938
+ * - the backend receives `<block>\n\n<original>`, while messageHistory
1939
+ * stores the original user text;
1940
+ * - after the history entry exists, a memory_enriched message carrying its
1941
+ * id + the recalled items is broadcast (skipped when block is null).
1942
+ */
1943
+ private async enrichAndDispatchUserMessage(
1944
+ session: Session,
1945
+ msg: BrowserOutgoingMessage & { type: "user_message" },
1946
+ ws?: ServerWebSocket<SocketData>,
1947
+ ): Promise<void> {
1948
+ let enrichment: import("./semantic-memory.js").EnrichmentResult | null = null;
1949
+ try {
1950
+ const timeout = new Promise<null>((resolveRace) => {
1951
+ const timer = setTimeout(() => resolveRace(null), WsBridge.ENRICHMENT_TIMEOUT_MS);
1952
+ timer.unref?.();
1953
+ });
1954
+ enrichment = await Promise.race([
1955
+ this.collectiveIntelligence!.enrichUserMessage(this.consolidationContext(session), msg.content),
1956
+ timeout,
1957
+ ]);
1958
+ } catch (err) {
1959
+ console.warn(`[ws-bridge] memory enrichment failed for ${session.id} (sending original):`, err);
1960
+ enrichment = null;
1961
+ }
1962
+
1963
+ const block = enrichment?.block ?? null;
1964
+ const outbound = block ? { ...msg, content: `${block}\n\n${msg.content}` } : msg;
1965
+ const historyId = this.dispatchBrowserMessage(session, outbound, ws, msg.content);
1966
+
1967
+ if (block && enrichment && enrichment.items.length > 0) {
1968
+ this.broadcastToBrowsers(session, {
1969
+ type: "memory_enriched",
1970
+ user_message_id: historyId ?? undefined,
1971
+ items: enrichment.items,
1972
+ });
1828
1973
  }
1829
1974
  }
1830
1975
 
1976
+ /** Session context for memory consolidation / enrichment (§3.6 item 5). */
1977
+ private consolidationContext(session: Session): {
1978
+ sessionId: string;
1979
+ repoRoot: string;
1980
+ backendType: BackendType;
1981
+ } {
1982
+ return {
1983
+ sessionId: session.id,
1984
+ repoRoot: session.state.repo_root || session.state.cwd || "",
1985
+ backendType: session.backendType,
1986
+ };
1987
+ }
1988
+
1989
+ /**
1990
+ * §3.4 turn-boundary trigger: on every result message, record activity for
1991
+ * the idle watcher and — fire-and-forget, off the hot path — consolidate
1992
+ * when the un-consolidated fragment threshold is met.
1993
+ */
1994
+ private maybeConsolidateOnTurn(session: Session): void {
1995
+ const ctx = this.consolidationContext(session);
1996
+ try {
1997
+ noteSessionActivity(ctx);
1998
+ } catch (err) {
1999
+ console.warn(`[ws-bridge] noteSessionActivity failed for ${session.id}:`, err);
2000
+ }
2001
+ void (async () => {
2002
+ if (await shouldConsolidateOnTurn(session.id)) {
2003
+ await consolidate({ ...ctx, reason: "turn_boundary" });
2004
+ }
2005
+ })().catch((err) => {
2006
+ console.warn(`[ws-bridge] turn-boundary consolidation failed for ${session.id}:`, err);
2007
+ });
2008
+ }
2009
+
1831
2010
  private isDuplicateClientMessage(session: Session, clientMsgId: string): boolean {
1832
2011
  return session.processedClientMessageIdSet.has(clientMsgId);
1833
2012
  }
@@ -1902,18 +2081,19 @@ export class WsBridge {
1902
2081
  }
1903
2082
  }
1904
2083
 
2084
+ /**
2085
+ * Send a user message to the Claude CLI. `historyContent` (when set) is the
2086
+ * text stored in messageHistory — the original user prompt — while
2087
+ * `msg.content` (possibly memory-enriched, §3.6.1) goes to the CLI.
2088
+ * Returns the history entry id.
2089
+ */
1905
2090
  private handleUserMessage(
1906
2091
  session: Session,
1907
- msg: { type: "user_message"; content: string; session_id?: string; images?: { media_type: string; data: string }[] }
1908
- ) {
2092
+ msg: { type: "user_message"; content: string; session_id?: string; images?: { media_type: string; data: string }[] },
2093
+ historyContent?: string,
2094
+ ): string {
1909
2095
  // 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
- });
2096
+ const historyId = this.pushUserHistoryEntry(session, historyContent ?? msg.content);
1917
2097
 
1918
2098
  // Build content: if images are present, use content block array; otherwise plain string
1919
2099
  let content: string | unknown[];
@@ -1939,6 +2119,7 @@ export class WsBridge {
1939
2119
  });
1940
2120
  this.sendToCLI(session, ndjson);
1941
2121
  this.persistSession(session);
2122
+ return historyId;
1942
2123
  }
1943
2124
 
1944
2125
  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};