@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,110 @@
1
+ /**
2
+ * End-to-end test for FleetTreeAggregator + FleetModule + headless child.
3
+ *
4
+ * Spawns a real headless child via FleetModule.handleLaunch, registers it
5
+ * with the aggregator, and asserts the snapshot round-trips through the
6
+ * IPC and ends up populating the per-child reducer.
7
+ *
8
+ * This is the integration check that Phase 1 + 2 + 3 + 6 actually compose:
9
+ * - Phase 6 lets the child recipe pass validation
10
+ * - Phase 1's describe verb returns a snapshot event
11
+ * - Phase 2's reducer accepts applySnapshot from that event's tree
12
+ * - Phase 3's aggregator orchestrates the describe handshake
13
+ */
14
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
15
+ import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join, resolve, dirname } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { FleetModule } from '../src/modules/fleet-module.js';
20
+ import { FleetTreeAggregator } from '../src/state/fleet-tree-aggregator.js';
21
+
22
+ const TEST_DIR = dirname(fileURLToPath(import.meta.url));
23
+ const REPO_ROOT = resolve(TEST_DIR, '..');
24
+ const INDEX_PATH = join(REPO_ROOT, 'src', 'index.ts');
25
+
26
+ const CHILD_RECIPE = {
27
+ name: 'AggregatorChildRecipe',
28
+ agent: { name: 'commander', systemPrompt: 'never inferred in this test' },
29
+ modules: {
30
+ subagents: false,
31
+ lessons: false,
32
+ retrieval: false,
33
+ wake: false,
34
+ workspace: false,
35
+ },
36
+ };
37
+
38
+ async function waitFor(check: () => boolean, timeoutMs: number, label: string): Promise<void> {
39
+ const start = Date.now();
40
+ while (Date.now() - start < timeoutMs) {
41
+ if (check()) return;
42
+ await new Promise((r) => setTimeout(r, 50));
43
+ }
44
+ throw new Error(`waitFor timed out after ${timeoutMs}ms: ${label}`);
45
+ }
46
+
47
+ describe('FleetTreeAggregator — e2e against headless child', () => {
48
+ let tmpDir: string;
49
+ let recipePath: string;
50
+ let fleet: FleetModule;
51
+ let aggregator: FleetTreeAggregator;
52
+
53
+ beforeAll(async () => {
54
+ tmpDir = mkdtempSync(join(tmpdir(), 'fkm-agg-e2e-'));
55
+ recipePath = join(tmpDir, 'recipe.json');
56
+ writeFileSync(recipePath, JSON.stringify(CHILD_RECIPE), 'utf-8');
57
+
58
+ fleet = new FleetModule({
59
+ childRuntimePath: 'bun',
60
+ childIndexPath: INDEX_PATH,
61
+ readyTimeoutMs: 15_000,
62
+ });
63
+ aggregator = new FleetTreeAggregator(fleet);
64
+ });
65
+
66
+ afterAll(async () => {
67
+ aggregator.dispose();
68
+ // Best-effort cleanup of the spawned child.
69
+ try {
70
+ await fleet.handleToolCall({
71
+ id: 'cleanup',
72
+ name: 'kill',
73
+ input: { name: 'aggregated-child' },
74
+ });
75
+ } catch { /* noop */ }
76
+ await new Promise((r) => setTimeout(r, 500));
77
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
78
+ });
79
+
80
+ test('snapshot round-trips through aggregator after launch', async () => {
81
+ // Register the child name BEFORE launch so we don't miss lifecycle:ready.
82
+ aggregator.registerChild('aggregated-child');
83
+
84
+ const launch = await fleet.handleToolCall({
85
+ id: 'launch-1',
86
+ name: 'launch',
87
+ input: {
88
+ name: 'aggregated-child',
89
+ recipe: recipePath,
90
+ dataDir: join(tmpDir, 'aggregated-child-data'),
91
+ },
92
+ });
93
+ expect((launch as { success?: boolean }).success).toBe(true);
94
+
95
+ // Wait for the snapshot to arrive and populate the reducer.
96
+ // The aggregator requests describe on lifecycle:ready, the child responds
97
+ // with a snapshot event carrying the seeded 'commander' framework agent.
98
+ await waitFor(
99
+ () => aggregator.getChildNodes('aggregated-child').some(n => n.name === 'commander'),
100
+ 15_000,
101
+ 'commander node populated from snapshot',
102
+ );
103
+
104
+ const nodes = aggregator.getChildNodes('aggregated-child');
105
+ const commander = nodes.find(n => n.name === 'commander');
106
+ expect(commander).toBeDefined();
107
+ expect(commander!.kind).toBe('framework');
108
+ expect(commander!.phase).toBe('idle');
109
+ });
110
+ });
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Unit tests for FleetTreeAggregator using a mock FleetModule.
3
+ *
4
+ * The aggregator's job is to:
5
+ * 1. Maintain a per-child AgentTreeReducer.
6
+ * 2. Request describe on lifecycle:ready (cold start + restart).
7
+ * 3. Apply snapshots, reseed reducers, drop stale events.
8
+ * 4. Notify listeners on tree changes.
9
+ *
10
+ * These tests drive a fake FleetModule API to assert that orchestration
11
+ * happens correctly, without actually spawning child processes.
12
+ */
13
+ import { describe, test, expect } from 'bun:test';
14
+ import { FleetTreeAggregator } from '../src/state/fleet-tree-aggregator.js';
15
+ import type { FleetEventCallback } from '../src/modules/fleet-module.js';
16
+ import type { WireEvent } from '../src/modules/fleet-types.js';
17
+
18
+ // Minimal stub: only the surface FleetTreeAggregator actually touches.
19
+ interface FakeChild { name: string; status: string }
20
+
21
+ class FakeFleet {
22
+ private subscribers = new Map<string, Set<FleetEventCallback>>();
23
+ private children: FakeChild[] = [];
24
+ describeRequests: Array<{ name: string; corrId?: string }> = [];
25
+
26
+ setChildren(children: FakeChild[]): void { this.children = children; }
27
+ getChildren(): Map<string, FakeChild> {
28
+ const m = new Map<string, FakeChild>();
29
+ for (const c of this.children) m.set(c.name, c);
30
+ return m;
31
+ }
32
+
33
+ onChildEvent(name: string, callback: FleetEventCallback): () => void {
34
+ if (!this.subscribers.has(name)) this.subscribers.set(name, new Set());
35
+ this.subscribers.get(name)!.add(callback);
36
+ return () => { this.subscribers.get(name)?.delete(callback); };
37
+ }
38
+
39
+ requestDescribe(name: string, corrId?: string): boolean {
40
+ if (corrId !== undefined) this.describeRequests.push({ name, corrId });
41
+ else this.describeRequests.push({ name });
42
+ return this.children.some(c => c.name === name);
43
+ }
44
+
45
+ /** Drive an event from a child (for test injection). */
46
+ emit(name: string, event: WireEvent): void {
47
+ const subs = this.subscribers.get(name);
48
+ if (!subs) return;
49
+ for (const cb of subs) cb(name, event);
50
+ }
51
+ }
52
+
53
+ function makeSnapshot(asOfTs: number, agents: Array<{ name: string; phase?: string; input?: number; toolCalls?: number }>): WireEvent {
54
+ return {
55
+ type: 'snapshot',
56
+ corrId: 'test',
57
+ asOfTs,
58
+ child: { name: 'c', pid: 0, startedAt: 0 },
59
+ tree: {
60
+ nodes: agents.map(a => ({
61
+ name: a.name,
62
+ kind: 'framework',
63
+ status: 'running',
64
+ phase: a.phase ?? 'idle',
65
+ tokens: { input: a.input ?? 0, output: 0, cacheRead: 0, cacheWrite: 0 },
66
+ toolCallsCount: a.toolCalls ?? 0,
67
+ findingsCount: 0,
68
+ })),
69
+ callIdIndex: {},
70
+ },
71
+ ts: asOfTs,
72
+ } as unknown as WireEvent;
73
+ }
74
+
75
+ describe('FleetTreeAggregator', () => {
76
+ test('register triggers describe when child is already ready', () => {
77
+ const fake = new FakeFleet();
78
+ fake.setChildren([{ name: 'miner', status: 'ready' }]);
79
+ const agg = new FleetTreeAggregator(fake as never);
80
+ agg.registerChild('miner');
81
+ expect(fake.describeRequests.map(r => r.name)).toEqual(['miner']);
82
+ });
83
+
84
+ test('register does NOT describe when child not yet ready; lifecycle:ready triggers it', () => {
85
+ const fake = new FakeFleet();
86
+ fake.setChildren([{ name: 'miner', status: 'starting' }]);
87
+ const agg = new FleetTreeAggregator(fake as never);
88
+ agg.registerChild('miner');
89
+ expect(fake.describeRequests).toEqual([]);
90
+
91
+ fake.emit('miner', { type: 'lifecycle', phase: 'ready', pid: 1, dataDir: '/tmp' } as WireEvent);
92
+ expect(fake.describeRequests.map(r => r.name)).toEqual(['miner']);
93
+ });
94
+
95
+ test('snapshot reseeds the per-child reducer', () => {
96
+ const fake = new FakeFleet();
97
+ fake.setChildren([{ name: 'miner', status: 'ready' }]);
98
+ const agg = new FleetTreeAggregator(fake as never);
99
+ agg.registerChild('miner');
100
+
101
+ fake.emit('miner', makeSnapshot(1000, [{ name: 'commander', input: 5000, toolCalls: 3 }]));
102
+ const nodes = agg.getChildNodes('miner');
103
+ expect(nodes.length).toBe(1);
104
+ expect(nodes[0]!.name).toBe('commander');
105
+ expect(nodes[0]!.tokens.input).toBe(5000);
106
+ expect(nodes[0]!.toolCallsCount).toBe(3);
107
+ });
108
+
109
+ test('events with ts < lastSnapshotTs are dropped', () => {
110
+ const fake = new FakeFleet();
111
+ fake.setChildren([{ name: 'miner', status: 'ready' }]);
112
+ const agg = new FleetTreeAggregator(fake as never);
113
+ agg.registerChild('miner');
114
+
115
+ // Snapshot at t=1000 with toolCallsCount=3
116
+ fake.emit('miner', makeSnapshot(1000, [{ name: 'commander', toolCalls: 3 }]));
117
+ expect(agg.getChildNodes('miner')[0]!.toolCallsCount).toBe(3);
118
+
119
+ // Stale tool event (t=500 < snapshot t=1000) — should be dropped
120
+ fake.emit('miner', {
121
+ type: 'inference:tool_calls_yielded',
122
+ agentName: 'commander',
123
+ calls: [{ id: 'old-c1', name: 'x', input: {} }],
124
+ timestamp: 500,
125
+ ts: 500,
126
+ } as WireEvent);
127
+ fake.emit('miner', { type: 'tool:started', callId: 'old-c1', tool: 'x', module: 'm', ts: 500 } as WireEvent);
128
+ expect(agg.getChildNodes('miner')[0]!.toolCallsCount).toBe(3); // unchanged
129
+
130
+ // Fresh tool event (t=2000 > snapshot t=1000) — applied
131
+ fake.emit('miner', {
132
+ type: 'inference:tool_calls_yielded',
133
+ agentName: 'commander',
134
+ calls: [{ id: 'new-c1', name: 'y', input: {} }],
135
+ timestamp: 2000,
136
+ ts: 2000,
137
+ } as WireEvent);
138
+ fake.emit('miner', { type: 'tool:started', callId: 'new-c1', tool: 'y', module: 'm', ts: 2001 } as WireEvent);
139
+ expect(agg.getChildNodes('miner')[0]!.toolCallsCount).toBe(4);
140
+ });
141
+
142
+ test('events with no ts are applied (best-effort)', () => {
143
+ const fake = new FakeFleet();
144
+ fake.setChildren([{ name: 'miner', status: 'ready' }]);
145
+ const agg = new FleetTreeAggregator(fake as never);
146
+ agg.registerChild('miner');
147
+
148
+ fake.emit('miner', makeSnapshot(1000, []));
149
+ fake.emit('miner', {
150
+ type: 'inference:started',
151
+ agentName: 'lazy-agent',
152
+ timestamp: 5000,
153
+ // No ts field on the wrapper
154
+ } as WireEvent);
155
+ const nodes = agg.getChildNodes('miner');
156
+ expect(nodes.find(n => n.name === 'lazy-agent')).toBeDefined();
157
+ });
158
+
159
+ test('describe is not re-requested while one is in flight', () => {
160
+ const fake = new FakeFleet();
161
+ fake.setChildren([{ name: 'miner', status: 'ready' }]);
162
+ const agg = new FleetTreeAggregator(fake as never);
163
+ agg.registerChild('miner');
164
+ expect(fake.describeRequests.length).toBe(1);
165
+
166
+ // Second lifecycle:ready before snapshot returns: should NOT re-request.
167
+ fake.emit('miner', { type: 'lifecycle', phase: 'ready', pid: 1, dataDir: '/tmp' } as WireEvent);
168
+ expect(fake.describeRequests.length).toBe(1);
169
+
170
+ // After snapshot arrives, in-flight clears.
171
+ fake.emit('miner', makeSnapshot(1000, []));
172
+
173
+ // A subsequent lifecycle:ready (e.g. after parent reconnect) DOES re-request.
174
+ fake.emit('miner', { type: 'lifecycle', phase: 'ready', pid: 1, dataDir: '/tmp' } as WireEvent);
175
+ expect(fake.describeRequests.length).toBe(2);
176
+ });
177
+
178
+ test('listeners fire on child tree changes', () => {
179
+ const fake = new FakeFleet();
180
+ fake.setChildren([{ name: 'miner', status: 'ready' }]);
181
+ const agg = new FleetTreeAggregator(fake as never);
182
+ agg.registerChild('miner');
183
+
184
+ const updates: string[] = [];
185
+ agg.onTreeUpdate((scope) => updates.push(scope));
186
+
187
+ fake.emit('miner', makeSnapshot(1000, [{ name: 'commander' }]));
188
+ expect(updates).toContain('miner');
189
+ });
190
+
191
+ test('unregister stops further event handling for that child', () => {
192
+ const fake = new FakeFleet();
193
+ fake.setChildren([{ name: 'miner', status: 'ready' }]);
194
+ const agg = new FleetTreeAggregator(fake as never);
195
+ agg.registerChild('miner');
196
+ fake.emit('miner', makeSnapshot(1000, [{ name: 'commander', toolCalls: 1 }]));
197
+ expect(agg.getChildNodes('miner')[0]!.toolCallsCount).toBe(1);
198
+
199
+ agg.unregisterChild('miner');
200
+ fake.emit('miner', { type: 'lifecycle', phase: 'ready', pid: 1, dataDir: '/tmp', ts: 9999 } as WireEvent);
201
+ // After unregister, child is gone; getChildNodes returns empty.
202
+ expect(agg.getChildNodes('miner')).toEqual([]);
203
+ });
204
+
205
+ test('dispose tears down all subscriptions', () => {
206
+ const fake = new FakeFleet();
207
+ fake.setChildren([{ name: 'a', status: 'ready' }, { name: 'b', status: 'ready' }]);
208
+ const agg = new FleetTreeAggregator(fake as never);
209
+ agg.registerChild('a');
210
+ agg.registerChild('b');
211
+ expect(agg.getAllChildNames().sort()).toEqual(['a', 'b']);
212
+ agg.dispose();
213
+ expect(agg.getAllChildNames()).toEqual([]);
214
+ });
215
+ });
@@ -0,0 +1,326 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import type {
3
+ MessageStoreView,
4
+ StoredMessage,
5
+ SummaryEntry,
6
+ ContextEntry,
7
+ } from '@animalabs/context-manager';
8
+ import { FrontdeskStrategy } from '../src/strategies/frontdesk-strategy.js';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Test helpers
12
+ // ---------------------------------------------------------------------------
13
+
14
+ let idCounter = 0;
15
+ function msg(
16
+ participant: string,
17
+ text: string,
18
+ metadata: Record<string, unknown> = {},
19
+ timestamp = new Date('2026-04-17T14:32:00Z'),
20
+ ): StoredMessage {
21
+ return {
22
+ id: `m${++idCounter}`,
23
+ sequence: idCounter,
24
+ participant,
25
+ content: [{ type: 'text', text }],
26
+ metadata,
27
+ timestamp,
28
+ };
29
+ }
30
+
31
+ function makeStore(messages: StoredMessage[]): MessageStoreView {
32
+ return {
33
+ getAll: () => messages,
34
+ get: (id) => messages.find((m) => m.id === id) ?? null,
35
+ getFrom: (i) => messages.slice(i),
36
+ getTail: (n) => messages.slice(-n),
37
+ length: () => messages.length,
38
+ estimateTokens: (m) => {
39
+ let t = 0;
40
+ for (const b of m.content) if (b.type === 'text') t += Math.ceil(b.text.length / 4);
41
+ return t;
42
+ },
43
+ };
44
+ }
45
+
46
+ // Expose protected methods for focused testing
47
+ class TestFrontdesk extends FrontdeskStrategy {
48
+ public pub_wrapProvenance(entry: ContextEntry, store: MessageStoreView) {
49
+ return this.wrapProvenance(entry, store);
50
+ }
51
+ public pub_buildHeader(m: StoredMessage) {
52
+ return this.buildProvenanceHeader(m);
53
+ }
54
+ public pub_updateSalience(store: MessageStoreView) {
55
+ this.updateSalience(store);
56
+ }
57
+ public pub_selectL1(l1: SummaryEntry[], budget: number, maxTokens: number) {
58
+ return this.selectL1Summaries(l1, budget, maxTokens);
59
+ }
60
+ public pub_isTopicBoundary(a: StoredMessage, b: StoredMessage) {
61
+ return this.isTopicBoundary(a, b);
62
+ }
63
+ public pub_compressionInstruction(chunkMessages: StoredMessage[], target: number) {
64
+ // Build a minimal Chunk shape sufficient for getCompressionInstruction
65
+ const chunk = {
66
+ index: 0,
67
+ startIndex: 0,
68
+ endIndex: chunkMessages.length,
69
+ messages: chunkMessages,
70
+ tokens: 0,
71
+ compressed: false,
72
+ } as any;
73
+ return this.getCompressionInstruction(chunk, target);
74
+ }
75
+ }
76
+
77
+ function makeStrategy(): TestFrontdesk {
78
+ return new TestFrontdesk({
79
+ headWindowTokens: 0,
80
+ recentWindowTokens: 0,
81
+ targetChunkTokens: 50,
82
+ autoTickOnNewMessage: false,
83
+ maxMessageTokens: 0,
84
+ });
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Feature 1: Provenance wrapping
89
+ // ---------------------------------------------------------------------------
90
+
91
+ describe('provenance wrapping', () => {
92
+ test('wraps a Zulip MCPL-originated entry with a full header', () => {
93
+ const s = makeStrategy();
94
+ const m = msg('User', 'where are the packet retry constants defined?', {
95
+ serverId: 'zulip',
96
+ channelId: 'zulip:tracker-miner-f',
97
+ topic: 'packet pipeline',
98
+ author: { id: 'u1', name: 'alice' },
99
+ messageId: 'M987654',
100
+ timestamp: '2026-04-17T14:32:00Z',
101
+ });
102
+ const header = s.pub_buildHeader(m);
103
+ expect(header).not.toBeNull();
104
+ expect(header).toContain('zulip');
105
+ expect(header).toContain('#tracker-miner-f');
106
+ expect(header).toContain('topic "packet pipeline"');
107
+ expect(header).toContain('@alice');
108
+ expect(header).toContain('14:32');
109
+ expect(header).toContain('msg M987654');
110
+ expect(header!.endsWith('\n')).toBe(true);
111
+ });
112
+
113
+ test('wrapProvenance prepends header into the first text block', () => {
114
+ const s = makeStrategy();
115
+ const m = msg('User', 'hello', {
116
+ serverId: 'zulip',
117
+ channelId: 'zulip:general',
118
+ author: { name: 'bob' },
119
+ });
120
+ const store = makeStore([m]);
121
+ const entry: ContextEntry = {
122
+ index: 0,
123
+ sourceMessageId: m.id,
124
+ sourceRelation: 'copy',
125
+ participant: 'User',
126
+ content: [{ type: 'text', text: 'hello' }],
127
+ };
128
+ const wrapped = s.pub_wrapProvenance(entry, store);
129
+ expect(wrapped.content).toHaveLength(1);
130
+ const first = wrapped.content[0] as { type: 'text'; text: string };
131
+ expect(first.type).toBe('text');
132
+ expect(first.text.startsWith('[')).toBe(true);
133
+ expect(first.text).toContain('@bob');
134
+ expect(first.text).toContain('\nhello');
135
+ });
136
+
137
+ test('degrades gracefully when fields are missing (no empty separators)', () => {
138
+ const s = makeStrategy();
139
+ const m = msg('User', 'x', {
140
+ serverId: 'zulip',
141
+ // no channelId, no topic, no author, no messageId
142
+ });
143
+ const header = s.pub_buildHeader(m);
144
+ expect(header).not.toBeNull();
145
+ expect(header).not.toContain('· ·');
146
+ expect(header).not.toMatch(/\[ /);
147
+ expect(header).not.toMatch(/ \]/);
148
+ });
149
+
150
+ test('pass-through (unchanged) when no serverId (e.g. TUI-origin or summary)', () => {
151
+ const s = makeStrategy();
152
+ const m = msg('User', 'typed from tui', {}); // no serverId
153
+ expect(s.pub_buildHeader(m)).toBeNull();
154
+
155
+ const store = makeStore([m]);
156
+ const entry: ContextEntry = {
157
+ index: 0,
158
+ sourceMessageId: m.id,
159
+ sourceRelation: 'copy',
160
+ participant: 'User',
161
+ content: [{ type: 'text', text: 'typed from tui' }],
162
+ };
163
+ const wrapped = s.pub_wrapProvenance(entry, store);
164
+ expect(wrapped).toBe(entry);
165
+ });
166
+
167
+ test('derived (summary) entries are not wrapped even if sourceMessageId matched', () => {
168
+ const s = makeStrategy();
169
+ const m = msg('User', 'x', {
170
+ serverId: 'zulip',
171
+ channelId: 'zulip:general',
172
+ author: { name: 'bob' },
173
+ });
174
+ const store = makeStore([m]);
175
+ const entry: ContextEntry = {
176
+ index: 0,
177
+ participant: 'Summary',
178
+ content: [{ type: 'text', text: '...summary...' }],
179
+ sourceRelation: 'derived',
180
+ };
181
+ const wrapped = s.pub_wrapProvenance(entry, store);
182
+ expect(wrapped).toBe(entry);
183
+ });
184
+ });
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Feature 2: Topic-aware chunking (boundary detection)
188
+ // ---------------------------------------------------------------------------
189
+
190
+ describe('topic boundary detection', () => {
191
+ test('returns true when adjacent messages have different topics in same channel', () => {
192
+ const s = makeStrategy();
193
+ const a = msg('User', 'a', { channelId: 'zulip:x', topic: 'T1' });
194
+ const b = msg('User', 'b', { channelId: 'zulip:x', topic: 'T2' });
195
+ expect(s.pub_isTopicBoundary(a, b)).toBe(true);
196
+ });
197
+
198
+ test('returns false when topics match', () => {
199
+ const s = makeStrategy();
200
+ const a = msg('User', 'a', { channelId: 'zulip:x', topic: 'T1' });
201
+ const b = msg('User', 'b', { channelId: 'zulip:x', topic: 'T1' });
202
+ expect(s.pub_isTopicBoundary(a, b)).toBe(false);
203
+ });
204
+
205
+ test('returns false when either side lacks topic metadata (graceful fallback)', () => {
206
+ const s = makeStrategy();
207
+ const a = msg('User', 'a', {});
208
+ const b = msg('User', 'b', { channelId: 'zulip:x', topic: 'T1' });
209
+ expect(s.pub_isTopicBoundary(a, b)).toBe(false);
210
+ expect(s.pub_isTopicBoundary(b, a)).toBe(false);
211
+ });
212
+
213
+ test('same topic name on different channels is NOT the same boundary', () => {
214
+ const s = makeStrategy();
215
+ const a = msg('User', 'a', { channelId: 'zulip:x', topic: 'general' });
216
+ const b = msg('User', 'b', { channelId: 'zulip:y', topic: 'general' });
217
+ expect(s.pub_isTopicBoundary(a, b)).toBe(true);
218
+ });
219
+ });
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Feature 3: Salience + compression instruction + L1 selection
223
+ // ---------------------------------------------------------------------------
224
+
225
+ describe('question/mention salience', () => {
226
+ test('marks a user question as salient when no assistant reply follows', () => {
227
+ const s = makeStrategy();
228
+ const q = msg('User', 'what is the packet retry policy?', {});
229
+ const noise = msg('User', 'just ambient chat', {});
230
+ s.pub_updateSalience(makeStore([q, noise]));
231
+ const state = (s as unknown as { salientSourceIds: Set<string> }).salientSourceIds;
232
+ expect(state.has(q.id)).toBe(true);
233
+ });
234
+
235
+ test('does NOT mark a question as salient when assistant replies within window', () => {
236
+ const s = makeStrategy();
237
+ const q = msg('User', 'what is x?', {});
238
+ const a = msg('Claude', 'x is y', {});
239
+ s.pub_updateSalience(makeStore([q, a]));
240
+ const state = (s as unknown as { salientSourceIds: Set<string> }).salientSourceIds;
241
+ expect(state.has(q.id)).toBe(false);
242
+ });
243
+
244
+ test('marks Zulip-style @mentions as salient', () => {
245
+ const s = makeStrategy();
246
+ const m = msg('User', 'hey @**clerk** are you around', {});
247
+ s.pub_updateSalience(makeStore([m]));
248
+ const state = (s as unknown as { salientSourceIds: Set<string> }).salientSourceIds;
249
+ expect(state.has(m.id)).toBe(true);
250
+ });
251
+ });
252
+
253
+ describe('compression instruction', () => {
254
+ test('adds topic clause when chunk spans multiple topics', () => {
255
+ const s = makeStrategy();
256
+ const msgs = [
257
+ msg('User', 'a', { channelId: 'zulip:x', topic: 'T1' }),
258
+ msg('User', 'b', { channelId: 'zulip:x', topic: 'T2' }),
259
+ ];
260
+ const instr = s.pub_compressionInstruction(msgs, 2000);
261
+ expect(instr).toContain('multiple Zulip topics');
262
+ });
263
+
264
+ test('omits topic clause when all messages share a topic', () => {
265
+ const s = makeStrategy();
266
+ const msgs = [
267
+ msg('User', 'a', { channelId: 'zulip:x', topic: 'T1' }),
268
+ msg('User', 'b', { channelId: 'zulip:x', topic: 'T1' }),
269
+ ];
270
+ const instr = s.pub_compressionInstruction(msgs, 2000);
271
+ expect(instr).not.toContain('multiple Zulip topics');
272
+ });
273
+
274
+ test('preserves open-question text verbatim when the chunk has unanswered questions', () => {
275
+ const s = makeStrategy();
276
+ const q = msg('User', 'where are the packet retry constants defined?', {});
277
+ const noise = msg('User', 'ok thanks', {});
278
+ // Run salience first so the question is marked salient
279
+ s.pub_updateSalience(makeStore([q, noise]));
280
+ const instr = s.pub_compressionInstruction([q, noise], 2000);
281
+ expect(instr).toContain('packet retry constants');
282
+ expect(instr).toContain('Preserve verbatim');
283
+ });
284
+ });
285
+
286
+ describe('salience-biased L1 selection', () => {
287
+ function summary(id: string, tokens: number, sourceIds: string[]): SummaryEntry {
288
+ return {
289
+ id,
290
+ level: 1,
291
+ content: '',
292
+ tokens,
293
+ sourceLevel: 0,
294
+ sourceIds,
295
+ sourceRange: { first: sourceIds[0] ?? '', last: sourceIds[sourceIds.length - 1] ?? '' },
296
+ created: Date.now(),
297
+ };
298
+ }
299
+
300
+ test('prefers L1 summaries whose sources contain salient messages', () => {
301
+ const s = makeStrategy();
302
+ const q = msg('User', 'what is x?', {}); // salient: unanswered
303
+ s.pub_updateSalience(makeStore([q]));
304
+
305
+ // Two L1 summaries, each 100 tokens; budget fits only one
306
+ const routineFirst = summary('L1-0', 100, ['unrelated-1']);
307
+ const salientSecond = summary('L1-1', 100, [q.id]);
308
+
309
+ const { selected } = s.pub_selectL1([routineFirst, salientSecond], 100, 100);
310
+ expect(selected).toHaveLength(1);
311
+ expect(selected[0].id).toBe('L1-1');
312
+ });
313
+
314
+ test('falls back to routine summaries once salient are exhausted', () => {
315
+ const s = makeStrategy();
316
+ const q = msg('User', 'what is x?', {});
317
+ s.pub_updateSalience(makeStore([q]));
318
+
319
+ const salient = summary('L1-1', 50, [q.id]);
320
+ const routine = summary('L1-2', 50, ['unrelated-1']);
321
+ const { selected } = s.pub_selectL1([routine, salient], 200, 200);
322
+ expect(selected).toHaveLength(2);
323
+ expect(selected[0].id).toBe('L1-1'); // salient first
324
+ expect(selected[1].id).toBe('L1-2');
325
+ });
326
+ });