@animalabs/connectome-host 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 (123) hide show
  1. package/.env.example +20 -0
  2. package/.github/workflows/publish.yml +65 -0
  3. package/ARCHITECTURE.md +355 -0
  4. package/CHANGELOG.md +30 -0
  5. package/HEADLESS-FLEET-PLAN.md +330 -0
  6. package/README.md +189 -0
  7. package/UNIFIED-TREE-PLAN.md +242 -0
  8. package/bun.lock +288 -0
  9. package/docs/AGENT-MEMORY-GUIDE.md +214 -0
  10. package/docs/AGENT-ONBOARDING.md +541 -0
  11. package/docs/ATTENTION-AND-GATING.md +120 -0
  12. package/docs/DEPLOYMENTS.md +130 -0
  13. package/docs/DEV-ENVIRONMENT.md +215 -0
  14. package/docs/LIBRARY-PIPELINE.md +286 -0
  15. package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
  16. package/docs/claudeai-evacuation.md +228 -0
  17. package/docs/debug-context-api.md +208 -0
  18. package/docs/webui-deployment.md +219 -0
  19. package/package.json +33 -0
  20. package/recipes/SETUP.md +308 -0
  21. package/recipes/TRIUMVIRATE-SETUP.md +467 -0
  22. package/recipes/claude-export-revive.json +19 -0
  23. package/recipes/clerk.json +157 -0
  24. package/recipes/knowledge-miner.json +174 -0
  25. package/recipes/knowledge-reviewer.json +76 -0
  26. package/recipes/mcpl-editor-test.json +38 -0
  27. package/recipes/prompts/transplant-addendum.md +17 -0
  28. package/recipes/triumvirate.json +63 -0
  29. package/recipes/webui-fleet-test.json +27 -0
  30. package/recipes/webui-test.json +16 -0
  31. package/recipes/zulip-miner.json +87 -0
  32. package/scripts/connectome-doctor +373 -0
  33. package/scripts/evacuator.ts +956 -0
  34. package/scripts/import-claudeai-export.ts +747 -0
  35. package/scripts/lib/line-reader.ts +53 -0
  36. package/scripts/test-historical-thinking.ts +148 -0
  37. package/scripts/warmup-session.ts +336 -0
  38. package/src/agent-name.ts +58 -0
  39. package/src/commands.ts +1074 -0
  40. package/src/headless.ts +528 -0
  41. package/src/index.ts +867 -0
  42. package/src/logging-adapter.ts +208 -0
  43. package/src/mcpl-config.ts +199 -0
  44. package/src/modules/activity-module.ts +270 -0
  45. package/src/modules/channel-mode-module.ts +260 -0
  46. package/src/modules/fleet-module.ts +1705 -0
  47. package/src/modules/fleet-types.ts +225 -0
  48. package/src/modules/lessons-module.ts +465 -0
  49. package/src/modules/mcpl-admin-module.ts +345 -0
  50. package/src/modules/retrieval-module.ts +327 -0
  51. package/src/modules/settings-module.ts +143 -0
  52. package/src/modules/subagent-module.ts +2074 -0
  53. package/src/modules/subscription-gc-module.ts +322 -0
  54. package/src/modules/time-module.ts +85 -0
  55. package/src/modules/tui-module.ts +76 -0
  56. package/src/modules/web-ui-curve-page.ts +249 -0
  57. package/src/modules/web-ui-module.ts +2595 -0
  58. package/src/recipe.ts +1003 -0
  59. package/src/session-manager.ts +278 -0
  60. package/src/state/agent-tree-reducer.ts +431 -0
  61. package/src/state/fleet-tree-aggregator.ts +195 -0
  62. package/src/strategies/frontdesk-strategy.ts +364 -0
  63. package/src/synesthete.ts +68 -0
  64. package/src/tui.ts +2200 -0
  65. package/src/types/bun-ffi.d.ts +6 -0
  66. package/src/web/protocol.ts +648 -0
  67. package/test/agent-name.test.ts +62 -0
  68. package/test/agent-tree-fleet-launch.test.ts +64 -0
  69. package/test/agent-tree-reducer-parity.test.ts +194 -0
  70. package/test/agent-tree-reducer.test.ts +443 -0
  71. package/test/agent-tree-rollup.test.ts +109 -0
  72. package/test/autobio-progress-snapshot.test.ts +40 -0
  73. package/test/claudeai-export-importer.test.ts +386 -0
  74. package/test/evacuator.test.ts +332 -0
  75. package/test/fleet-commands.test.ts +244 -0
  76. package/test/fleet-no-subfleets.test.ts +147 -0
  77. package/test/fleet-orchestration.test.ts +353 -0
  78. package/test/fleet-recipe.test.ts +124 -0
  79. package/test/fleet-route.test.ts +44 -0
  80. package/test/fleet-smoke.test.ts +479 -0
  81. package/test/fleet-subscribe-union-e2e.test.ts +123 -0
  82. package/test/fleet-subscribe-union.test.ts +85 -0
  83. package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
  84. package/test/fleet-tree-aggregator.test.ts +215 -0
  85. package/test/frontdesk-strategy.test.ts +326 -0
  86. package/test/headless-describe.test.ts +182 -0
  87. package/test/headless-smoke.test.ts +190 -0
  88. package/test/logging-adapter.test.ts +87 -0
  89. package/test/mcpl-admin-module.test.ts +213 -0
  90. package/test/mcpl-agent-overlay.test.ts +126 -0
  91. package/test/mock-headless-child.ts +133 -0
  92. package/test/recipe-cache-ttl.test.ts +37 -0
  93. package/test/recipe-env.test.ts +157 -0
  94. package/test/recipe-path-resolution.test.ts +170 -0
  95. package/test/subagent-async-timeout.test.ts +381 -0
  96. package/test/subagent-fork-materialise.test.ts +241 -0
  97. package/test/subagent-peek-scoping.test.ts +180 -0
  98. package/test/subagent-peek-zombie.test.ts +148 -0
  99. package/test/subagent-reaper-in-flight.test.ts +229 -0
  100. package/test/subscription-gc-module.test.ts +136 -0
  101. package/test/warmup-handoff.test.ts +150 -0
  102. package/test/web-ui-bind.test.ts +51 -0
  103. package/test/web-ui-module.test.ts +246 -0
  104. package/test/web-ui-protocol.test.ts +0 -0
  105. package/tsconfig.json +14 -0
  106. package/web/bun.lock +357 -0
  107. package/web/index.html +12 -0
  108. package/web/package.json +24 -0
  109. package/web/src/App.tsx +1931 -0
  110. package/web/src/Context.tsx +158 -0
  111. package/web/src/ContextDocument.tsx +150 -0
  112. package/web/src/Files.tsx +271 -0
  113. package/web/src/Lessons.tsx +164 -0
  114. package/web/src/Mcpl.tsx +310 -0
  115. package/web/src/Stream.tsx +119 -0
  116. package/web/src/TreeSidebar.tsx +283 -0
  117. package/web/src/Usage.tsx +182 -0
  118. package/web/src/main.tsx +7 -0
  119. package/web/src/styles.css +26 -0
  120. package/web/src/tree.ts +268 -0
  121. package/web/src/wire.ts +120 -0
  122. package/web/tsconfig.json +21 -0
  123. package/web/vite.config.ts +32 -0
@@ -0,0 +1,195 @@
1
+ /**
2
+ * FleetTreeAggregator — owns one AgentTreeReducer per fleet child and
3
+ * orchestrates `describe` requests at sync points (cold start,
4
+ * lifecycle:ready, post-restart).
5
+ *
6
+ * Lockstep model (see UNIFIED-TREE-PLAN.md §3):
7
+ * - Live event stream is the primary path. Each child's events are folded
8
+ * into its reducer as they arrive.
9
+ * - `describe` is a *recovery* verb, requested rarely:
10
+ * * once per child on first lifecycle:ready (cold start / reattach)
11
+ * * once per child after a restart (process:exited → new lifecycle:ready)
12
+ * - When a `snapshot` arrives, applySnapshot wipes the child's reducer and
13
+ * reseeds from ground truth. Subsequent events resume the fold.
14
+ * - Events with `ts < asOfTs` are dropped after a snapshot — they're already
15
+ * reflected in the snapshot's state (no double-application).
16
+ *
17
+ * The aggregator exposes a clean read API for the TUI (Phase 5) without the
18
+ * TUI needing to know about IPC, describe handshakes, or stale-event dedup.
19
+ *
20
+ * Local-process state is NOT mirrored here. The TUI's existing inline fold
21
+ * (subagentPhase / agentContextTokens / agentParent in tui.ts) is the
22
+ * canonical local store; running a parallel local reducer that nothing reads
23
+ * from would be paid-for-but-unused complexity. If a future pass migrates
24
+ * local rendering off those inline maps, instantiate a local reducer at
25
+ * that point.
26
+ */
27
+
28
+ import type { FleetModule, FleetEventCallback } from '../modules/fleet-module.js';
29
+ import type { WireEvent } from '../modules/fleet-types.js';
30
+ import { AgentTreeReducer, type AgentTreeSnapshot, type AgentNode } from './agent-tree-reducer.js';
31
+
32
+ interface ChildState {
33
+ reducer: AgentTreeReducer;
34
+ /** asOfTs of the last applied snapshot; events older than this are dropped. */
35
+ lastSnapshotTs: number;
36
+ /** True once we've requested a describe and are awaiting a snapshot. */
37
+ describeInFlight: boolean;
38
+ /** Set once we've ever received a snapshot — used to detect post-restart. */
39
+ hasInitialSnapshot: boolean;
40
+ /** Unsubscribe handle for the per-child event subscription. */
41
+ unsubscribe: () => void;
42
+ }
43
+
44
+ export type TreeUpdateListener = (childName: string | 'local') => void;
45
+
46
+ export class FleetTreeAggregator {
47
+ private fleet: FleetModule;
48
+ private childStates = new Map<string, ChildState>();
49
+ private listeners = new Set<TreeUpdateListener>();
50
+ /** Generation counter for corrIds; debugging convenience. */
51
+ private corrIdSeq = 0;
52
+
53
+ constructor(fleet: FleetModule) {
54
+ this.fleet = fleet;
55
+ }
56
+
57
+ /** Register a child. Idempotent — re-registering with the same name is a noop
58
+ * unless the prior subscription was torn down (in which case it re-subscribes). */
59
+ registerChild(name: string): void {
60
+ if (this.childStates.has(name)) return;
61
+
62
+ const reducer = new AgentTreeReducer();
63
+ const callback: FleetEventCallback = (childName, event) => {
64
+ if (childName !== name) return;
65
+ this.handleChildEvent(name, event);
66
+ };
67
+ const unsubscribe = this.fleet.onChildEvent(name, callback);
68
+
69
+ this.childStates.set(name, {
70
+ reducer,
71
+ lastSnapshotTs: 0,
72
+ describeInFlight: false,
73
+ hasInitialSnapshot: false,
74
+ unsubscribe,
75
+ });
76
+
77
+ // If the child is already ready when we register, request describe right away.
78
+ // (lifecycle:ready already fired before we subscribed.)
79
+ const fleetChild = this.fleet.getChildren().get(name);
80
+ if (fleetChild?.status === 'ready') {
81
+ this.requestDescribe(name);
82
+ }
83
+ }
84
+
85
+ /** Stop tracking a child and tear down its subscription. */
86
+ unregisterChild(name: string): void {
87
+ const state = this.childStates.get(name);
88
+ if (!state) return;
89
+ state.unsubscribe();
90
+ this.childStates.delete(name);
91
+ }
92
+
93
+ // ----- read API ---------------------------------------------------------
94
+
95
+ getChildNodes(name: string): AgentNode[] {
96
+ const state = this.childStates.get(name);
97
+ return state ? state.reducer.getNodes() : [];
98
+ }
99
+
100
+ getAllChildNames(): string[] {
101
+ return [...this.childStates.keys()];
102
+ }
103
+
104
+ /** Subscribe to tree-updated notifications. Returns an unsubscribe function.
105
+ * Listener fires for any change to local or any child's tree. */
106
+ onTreeUpdate(listener: TreeUpdateListener): () => void {
107
+ this.listeners.add(listener);
108
+ return () => { this.listeners.delete(listener); };
109
+ }
110
+
111
+ /** Tear down all subscriptions. Call on TUI shutdown. */
112
+ dispose(): void {
113
+ for (const [, state] of this.childStates) {
114
+ state.unsubscribe();
115
+ }
116
+ this.childStates.clear();
117
+ this.listeners.clear();
118
+ }
119
+
120
+ // ----- internals --------------------------------------------------------
121
+
122
+ private handleChildEvent(name: string, event: WireEvent): void {
123
+ const state = this.childStates.get(name);
124
+ if (!state) return;
125
+
126
+ const eventTs = event.ts;
127
+
128
+ // Snapshot response: reseed from ground truth.
129
+ if (event.type === 'snapshot') {
130
+ const snapshotEvent = event as unknown as {
131
+ asOfTs?: number;
132
+ tree?: { nodes?: unknown[]; callIdIndex?: Record<string, string> };
133
+ };
134
+ const asOfTs = snapshotEvent.asOfTs ?? Date.now();
135
+ const treeNodes = (snapshotEvent.tree?.nodes ?? []) as AgentNode[];
136
+ const callIdIndex = snapshotEvent.tree?.callIdIndex ?? {};
137
+ const snap: AgentTreeSnapshot = {
138
+ asOfTs,
139
+ nodes: treeNodes,
140
+ callIdIndex,
141
+ };
142
+ state.reducer.applySnapshot(snap);
143
+ state.lastSnapshotTs = asOfTs;
144
+ state.describeInFlight = false;
145
+ state.hasInitialSnapshot = true;
146
+ this.notify(name);
147
+ return;
148
+ }
149
+
150
+ // Lifecycle: ready fires on initial connect AND after parent reconnect to
151
+ // a still-running child AND when adopt-on-restart re-establishes a socket
152
+ // post-spawn-restart. Request describe in all those cases.
153
+ if (event.type === 'lifecycle') {
154
+ const phase = (event as { phase?: string }).phase;
155
+ if (phase === 'ready') {
156
+ // Always request describe on ready — covers cold-start, reconnect, restart.
157
+ // The reducer's applySnapshot wipes prior state, so re-requesting is safe
158
+ // even if we have current data.
159
+ this.requestDescribe(name);
160
+ return;
161
+ }
162
+ // 'exiting' is purely informational here. There's no reset code below;
163
+ // the implicit recovery is: when the child restarts and emits a fresh
164
+ // lifecycle:ready, the branch above triggers a describe, and
165
+ // applySnapshot wipes the stale reducer state at that point. The
166
+ // hasInitialSnapshot flag below is what gates stale-event filtering
167
+ // through the gap.
168
+ }
169
+
170
+ // Drop events older than the most recent snapshot — they're already
171
+ // reflected in the snapshot's state. This handles the in-flight window
172
+ // between describe-send and snapshot-receive.
173
+ if (state.hasInitialSnapshot && typeof eventTs === 'number' && eventTs < state.lastSnapshotTs) {
174
+ return;
175
+ }
176
+
177
+ state.reducer.applyEvent(event);
178
+ this.notify(name);
179
+ }
180
+
181
+ private requestDescribe(name: string): void {
182
+ const state = this.childStates.get(name);
183
+ if (!state) return;
184
+ if (state.describeInFlight) return;
185
+ const corrId = `agg-${++this.corrIdSeq}`;
186
+ const ok = this.fleet.requestDescribe(name, corrId);
187
+ if (ok) state.describeInFlight = true;
188
+ }
189
+
190
+ private notify(scope: string | 'local'): void {
191
+ for (const l of this.listeners) {
192
+ try { l(scope); } catch { /* one bad listener doesn't kill the others */ }
193
+ }
194
+ }
195
+ }
@@ -0,0 +1,364 @@
1
+ import { AutobiographicalStrategy } from '@animalabs/context-manager';
2
+ import type {
3
+ AutobiographicalConfig,
4
+ ContextEntry,
5
+ MessageStoreView,
6
+ ContextLogView,
7
+ TokenBudget,
8
+ StoredMessage,
9
+ SummaryEntry,
10
+ } from '@animalabs/context-manager';
11
+ import type { ContentBlock } from '@animalabs/membrane';
12
+
13
+ // Structural mirror of AutobiographicalStrategy's internal Chunk.
14
+ // Kept inline because @animalabs/context-manager does not currently export it.
15
+ interface Chunk {
16
+ index: number;
17
+ startIndex: number;
18
+ endIndex: number;
19
+ messages: StoredMessage[];
20
+ tokens: number;
21
+ compressed: boolean;
22
+ diary?: string;
23
+ summaryId?: string;
24
+ phaseType?: string;
25
+ }
26
+
27
+ export type FrontdeskStrategyOptions = Partial<AutobiographicalConfig>;
28
+
29
+ /**
30
+ * Chatbot-flavoured context strategy for agents that receive messages via
31
+ * MCPL channels (Zulip, Discord, etc.) and reply back through them.
32
+ *
33
+ * Extends AutobiographicalStrategy with three features:
34
+ * 1. Provenance wrapping — prepends a `[zulip · #channel · topic · @user · HH:MM · msg-id]`
35
+ * header to each MCPL-originated entry so the agent knows the message came from a
36
+ * channel and which reply path to use.
37
+ * 2. Topic-aware compression — chunk boundaries prefer Zulip-topic transitions and the
38
+ * compression prompt instructs per-topic structure.
39
+ * 3. Question/mention salience — unanswered user questions and @mentions are preserved
40
+ * verbatim longer (both during compression and during L1 selection under budget).
41
+ */
42
+ export class FrontdeskStrategy extends AutobiographicalStrategy {
43
+ override readonly name: string = 'frontdesk';
44
+
45
+ private salientSourceIds: Set<string> = new Set();
46
+
47
+ override select(
48
+ store: MessageStoreView,
49
+ log: ContextLogView,
50
+ budget: TokenBudget,
51
+ ): ContextEntry[] {
52
+ this.updateSalience(store);
53
+ const entries = super.select(store, log, budget);
54
+ return entries.map((e) => this.wrapProvenance(e, store));
55
+ }
56
+
57
+ // ==========================================================================
58
+ // Feature 1: Provenance wrapping
59
+ // ==========================================================================
60
+
61
+ protected wrapProvenance(entry: ContextEntry, store: MessageStoreView): ContextEntry {
62
+ if (!entry.sourceMessageId || entry.sourceRelation !== 'copy') return entry;
63
+ const msg = store.get(entry.sourceMessageId);
64
+ if (!msg) return entry;
65
+ const header = this.buildProvenanceHeader(msg);
66
+ if (!header) return entry;
67
+
68
+ // Prepend a text block. If the first block is already text, merge the header
69
+ // into it so tool_use/tool_result blocks stay paired with their neighbours.
70
+ const first = entry.content[0];
71
+ if (first && first.type === 'text') {
72
+ return {
73
+ ...entry,
74
+ content: [
75
+ { type: 'text', text: `${header}${first.text}` } as ContentBlock,
76
+ ...entry.content.slice(1),
77
+ ],
78
+ };
79
+ }
80
+ return {
81
+ ...entry,
82
+ content: [{ type: 'text', text: header } as ContentBlock, ...entry.content],
83
+ };
84
+ }
85
+
86
+ protected buildProvenanceHeader(msg: StoredMessage): string | null {
87
+ const meta = (msg.metadata ?? {}) as Record<string, unknown>;
88
+ if (meta.serverId === undefined || meta.serverId === null || meta.serverId === '') {
89
+ return null;
90
+ }
91
+
92
+ const parts: string[] = [];
93
+ const serverId = String(meta.serverId);
94
+ const channelId = meta.channelId !== undefined && meta.channelId !== null ? String(meta.channelId) : '';
95
+
96
+ const protocol = this.deriveProtocol(serverId, channelId);
97
+ if (protocol) parts.push(protocol);
98
+
99
+ if (channelId) {
100
+ const stripped = channelId.replace(/^[a-z][a-z0-9_-]*:/, '');
101
+ if (stripped) parts.push(`#${stripped}`);
102
+ }
103
+
104
+ const topicRaw = meta.topic ?? meta.subject;
105
+ const topic = topicRaw !== undefined && topicRaw !== null ? String(topicRaw) : '';
106
+ if (topic) parts.push(`topic "${topic}"`);
107
+
108
+ const author = meta.author as { id?: string; name?: string } | undefined;
109
+ if (author && typeof author === 'object') {
110
+ const display = author.name || author.id || '';
111
+ if (display) parts.push(`@${display}`);
112
+ }
113
+
114
+ const ts = this.formatTimestamp(meta.timestamp, msg.timestamp);
115
+ if (ts) parts.push(ts);
116
+
117
+ if (meta.messageId !== undefined && meta.messageId !== null && meta.messageId !== '') {
118
+ const mid = String(meta.messageId);
119
+ const short = mid.length > 12 ? mid.slice(0, 12) : mid;
120
+ parts.push(`msg ${short}`);
121
+ }
122
+
123
+ const threadId = meta.threadId !== undefined && meta.threadId !== null ? String(meta.threadId) : '';
124
+ if (threadId && threadId !== topic) {
125
+ const short = threadId.length > 12 ? threadId.slice(0, 12) : threadId;
126
+ parts.push(`thread ${short}`);
127
+ }
128
+
129
+ if (parts.length === 0) return null;
130
+ return `[${parts.join(' · ')}]\n`;
131
+ }
132
+
133
+ protected deriveProtocol(serverId: string, channelId: string): string {
134
+ if (channelId) {
135
+ const match = channelId.match(/^([a-z][a-z0-9_-]*):/);
136
+ if (match) return match[1];
137
+ }
138
+ return serverId;
139
+ }
140
+
141
+ protected formatTimestamp(metaTs: unknown, msgTs: Date): string | null {
142
+ let d: Date | null = null;
143
+ if (typeof metaTs === 'string' || typeof metaTs === 'number') {
144
+ const parsed = new Date(metaTs);
145
+ if (!isNaN(parsed.getTime())) d = parsed;
146
+ }
147
+ if (!d && msgTs instanceof Date && !isNaN(msgTs.getTime())) d = msgTs;
148
+ if (!d) return null;
149
+ const hh = String(d.getHours()).padStart(2, '0');
150
+ const mm = String(d.getMinutes()).padStart(2, '0');
151
+ return `${hh}:${mm}`;
152
+ }
153
+
154
+ // ==========================================================================
155
+ // Feature 2: Topic-aware chunking
156
+ // ==========================================================================
157
+
158
+ /**
159
+ * Override rebuildChunks to additionally close chunk boundaries at Zulip-topic
160
+ * transitions. Falls back to base size/count behaviour when topic metadata is absent.
161
+ */
162
+ protected override rebuildChunks(store: MessageStoreView): void {
163
+ const messagesToChunk = this.getCompressibleMessages(store);
164
+
165
+ const existingCompressed = new Map<string, Chunk>();
166
+ for (const chunk of this.chunks as unknown as Chunk[]) {
167
+ if (chunk.compressed) {
168
+ existingCompressed.set(this.chunkKey(chunk as never), chunk);
169
+ }
170
+ }
171
+
172
+ this.chunks = [];
173
+ this.compressionQueue = [];
174
+
175
+ let currentChunk: StoredMessage[] = [];
176
+ let currentTokens = 0;
177
+ let chunkFilteredStart = 0;
178
+ const MIN_CHUNK = 4;
179
+
180
+ const push = (startIdx: number, endIdx: number, msgs: StoredMessage[], tokens: number) => {
181
+ const chunk = this.createChunk(
182
+ this.chunks.length,
183
+ startIdx,
184
+ endIdx,
185
+ msgs,
186
+ tokens,
187
+ existingCompressed as never,
188
+ );
189
+ this.chunks.push(chunk);
190
+ if (!chunk.compressed) this.compressionQueue.push(chunk.index);
191
+ };
192
+
193
+ for (let i = 0; i < messagesToChunk.length; i++) {
194
+ const msg = messagesToChunk[i];
195
+ let msgTokens = store.estimateTokens(msg);
196
+ if (this.config.attachmentsIgnoreSize) {
197
+ msgTokens = this.estimateTextOnlyTokens(msg);
198
+ }
199
+
200
+ // Topic boundary: close current chunk BEFORE adding msg when topic changes
201
+ // and the chunk has at least MIN_CHUNK messages. This keeps summaries of
202
+ // unrelated topics from being merged.
203
+ if (
204
+ currentChunk.length >= MIN_CHUNK &&
205
+ this.isTopicBoundary(currentChunk[currentChunk.length - 1], msg)
206
+ ) {
207
+ push(chunkFilteredStart, i, currentChunk, currentTokens);
208
+ currentChunk = [];
209
+ currentTokens = 0;
210
+ chunkFilteredStart = i;
211
+ }
212
+
213
+ currentChunk.push(msg);
214
+ currentTokens += msgTokens;
215
+
216
+ const shouldClose =
217
+ currentTokens >= this.config.targetChunkTokens && currentChunk.length >= MIN_CHUNK;
218
+
219
+ if (shouldClose) {
220
+ push(chunkFilteredStart, i + 1, currentChunk, currentTokens);
221
+ currentChunk = [];
222
+ currentTokens = 0;
223
+ chunkFilteredStart = i + 1;
224
+ }
225
+ }
226
+
227
+ if (currentChunk.length >= MIN_CHUNK) {
228
+ push(chunkFilteredStart, messagesToChunk.length, currentChunk, currentTokens);
229
+ }
230
+ }
231
+
232
+ protected isTopicBoundary(prev: StoredMessage, curr: StoredMessage): boolean {
233
+ const a = this.extractTopicKey(prev);
234
+ const b = this.extractTopicKey(curr);
235
+ if (!a || !b) return false;
236
+ return a !== b;
237
+ }
238
+
239
+ protected extractTopicKey(msg: StoredMessage): string | null {
240
+ const meta = (msg.metadata ?? {}) as Record<string, unknown>;
241
+ const topicRaw = meta.topic ?? meta.subject;
242
+ if (topicRaw === undefined || topicRaw === null || topicRaw === '') return null;
243
+ const channelId = meta.channelId !== undefined && meta.channelId !== null ? String(meta.channelId) : '';
244
+ return `${channelId}::${String(topicRaw)}`;
245
+ }
246
+
247
+ // ==========================================================================
248
+ // Feature 3a: Compression instruction with topic + open-question clauses
249
+ // ==========================================================================
250
+
251
+ protected override getCompressionInstruction(chunk: Chunk, targetTokens: number): string {
252
+ const topics = new Set<string>();
253
+ for (const m of chunk.messages) {
254
+ const t = this.extractTopicKey(m);
255
+ if (t) topics.add(t);
256
+ }
257
+
258
+ const openQuestions: string[] = [];
259
+ for (const m of chunk.messages) {
260
+ if (this.salientSourceIds.has(m.id)) {
261
+ const text = this.extractText(m.content).trim().replace(/\s+/g, ' ');
262
+ if (text) {
263
+ openQuestions.push(text.length > 200 ? `${text.slice(0, 200)}…` : text);
264
+ }
265
+ }
266
+ }
267
+
268
+ const base = `Starting from my last message, please describe everything that has happened. Aim for about ${targetTokens} tokens. Describe it as you would to yourself, as if you are remembering what has happened.`;
269
+
270
+ const clauses: string[] = [];
271
+ if (topics.size > 1) {
272
+ clauses.push(
273
+ 'This window spans multiple Zulip topics — structure the memory with one short section per topic, keeping topic names verbatim.',
274
+ );
275
+ }
276
+ if (openQuestions.length > 0) {
277
+ clauses.push(
278
+ `Preserve verbatim any user question that was asked in this window and has not yet been answered, including @mentions of the agent. Questions to preserve: ${openQuestions.map((q) => `"${q}"`).join('; ')}.`,
279
+ );
280
+ }
281
+
282
+ return clauses.length === 0 ? base : `${base}\n\n${clauses.join(' ')}`;
283
+ }
284
+
285
+ // ==========================================================================
286
+ // Feature 3b: Salience-biased L1 selection
287
+ // ==========================================================================
288
+
289
+ protected override selectL1Summaries(
290
+ shownL1: SummaryEntry[],
291
+ budget: number,
292
+ maxTokens: number,
293
+ ): { selected: SummaryEntry[]; tokensUsed: number } {
294
+ if (shownL1.length === 0) return { selected: [], tokensUsed: 0 };
295
+
296
+ const isSalient = (s: SummaryEntry): boolean =>
297
+ s.sourceIds.some((id) => this.salientSourceIds.has(id));
298
+
299
+ const salient: SummaryEntry[] = [];
300
+ const routine: SummaryEntry[] = [];
301
+ for (const s of shownL1) {
302
+ (isSalient(s) ? salient : routine).push(s);
303
+ }
304
+
305
+ const selected: SummaryEntry[] = [];
306
+ let used = 0;
307
+
308
+ for (const group of [salient, routine]) {
309
+ for (const s of group) {
310
+ if (used + s.tokens > budget) break;
311
+ if (used + s.tokens > maxTokens) break;
312
+ selected.push(s);
313
+ used += s.tokens;
314
+ }
315
+ }
316
+
317
+ return { selected, tokensUsed: used };
318
+ }
319
+
320
+ // ==========================================================================
321
+ // Salience tracking (shared by 3a and 3b)
322
+ // ==========================================================================
323
+
324
+ /**
325
+ * Recompute which user messages are "unanswered questions or mentions":
326
+ * a user message that contains `?` or an @mention and has no assistant
327
+ * message within the following WINDOW messages.
328
+ */
329
+ protected updateSalience(store: MessageStoreView): void {
330
+ const messages = store.getAll();
331
+ const assistant = this.config.summaryParticipant ?? 'Claude';
332
+ const salient = new Set<string>();
333
+ const WINDOW = 8;
334
+
335
+ for (let i = 0; i < messages.length; i++) {
336
+ const m = messages[i];
337
+ if (m.participant === assistant) continue;
338
+ if (!this.messageIsQuestionOrMention(m)) continue;
339
+
340
+ let answered = false;
341
+ const end = Math.min(i + 1 + WINDOW, messages.length);
342
+ for (let j = i + 1; j < end; j++) {
343
+ if (messages[j].participant === assistant) {
344
+ answered = true;
345
+ break;
346
+ }
347
+ }
348
+ if (!answered) salient.add(m.id);
349
+ }
350
+
351
+ this.salientSourceIds = salient;
352
+ }
353
+
354
+ protected messageIsQuestionOrMention(msg: StoredMessage): boolean {
355
+ for (const b of msg.content) {
356
+ if (b.type !== 'text') continue;
357
+ const text = b.text;
358
+ if (text.includes('?')) return true;
359
+ if (/@\*\*[^*]+\*\*/.test(text)) return true; // Zulip-style mention
360
+ if (/(^|\s)@[A-Za-z][\w-]*/.test(text)) return true; // Discord/Slack-style
361
+ }
362
+ return false;
363
+ }
364
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Synesthete — auto-generates evocative session names via a quick Haiku call.
3
+ *
4
+ * Called after a few user messages in an auto-named session. Produces a
5
+ * 2–4 word name that captures the session's topic/mood.
6
+ */
7
+
8
+ import type { Membrane } from '@animalabs/membrane';
9
+
10
+ const DEFAULT_EXAMPLES = [
11
+ 'Context Window Budgeting',
12
+ 'Agent Memory Architecture',
13
+ 'Pipeline Debug Session',
14
+ 'Schema Migration Plan',
15
+ 'API Integration Review',
16
+ ];
17
+
18
+ function buildNamingPrompt(examples?: string[]): string {
19
+ const exampleList = (examples ?? DEFAULT_EXAMPLES)
20
+ .map(e => `- "${e}"`)
21
+ .join('\n');
22
+
23
+ return `You are a session naming assistant. Given a brief summary of a conversation, generate a short, evocative name (2-4 words) that captures its essence. The name should be memorable and descriptive, like a chapter title.
24
+
25
+ Examples of good names:
26
+ ${exampleList}
27
+
28
+ Respond with ONLY the name, nothing else. No quotes, no explanation.`;
29
+ }
30
+
31
+ /**
32
+ * Generate a session name from conversation content.
33
+ * Returns the generated name, or null if the call fails.
34
+ */
35
+ export async function generateSessionName(
36
+ membrane: Membrane,
37
+ conversationSummary: string,
38
+ examples?: string[],
39
+ ): Promise<string | null> {
40
+ try {
41
+ const response = await membrane.complete({
42
+ messages: [
43
+ {
44
+ participant: 'user',
45
+ content: [{ type: 'text', text: conversationSummary }],
46
+ },
47
+ ],
48
+ system: buildNamingPrompt(examples),
49
+ config: {
50
+ model: 'claude-haiku-4-5-20251001',
51
+ maxTokens: 30,
52
+ temperature: 0.8,
53
+ },
54
+ });
55
+
56
+ const text = response.content
57
+ .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
58
+ .map(b => b.text)
59
+ .join('')
60
+ .trim();
61
+
62
+ // Sanity check: should be short and non-empty
63
+ if (!text || text.length > 60 || text.includes('\n')) return null;
64
+ return text;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }