@animalabs/connectome-host 0.7.3 → 0.8.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 (97) hide show
  1. package/.env.example +12 -5
  2. package/.github/PULL_REQUEST_TEMPLATE.md +3 -2
  3. package/.github/workflows/changelog.yml +9 -4
  4. package/.github/workflows/ci.yml +5 -3
  5. package/.github/workflows/publish.yml +12 -6
  6. package/CHANGELOG.md +401 -10
  7. package/CONTRIBUTING.md +47 -19
  8. package/HEADLESS-FLEET-PLAN.md +22 -0
  9. package/README.md +39 -1
  10. package/bun.lock +27 -31
  11. package/changelog.d/README.md +28 -0
  12. package/docs/AGENT-ONBOARDING.md +1 -1
  13. package/docs/debug-context-api.md +2 -2
  14. package/docs/retrieval-traces.md +173 -0
  15. package/docs/webui-deployment.md +2 -1
  16. package/package.json +6 -6
  17. package/recipes/SETUP.md +11 -5
  18. package/recipes/TRIUMVIRATE-SETUP.md +68 -14
  19. package/recipes/knowledge-miner.json +0 -30
  20. package/recipes/mock-test.json +19 -0
  21. package/recipes/triumvirate.json +6 -1
  22. package/scripts/audit-module-optins.ts +288 -0
  23. package/scripts/release-changelog.ts +210 -21
  24. package/src/cache-keepalive-log.ts +41 -0
  25. package/src/commands.ts +96 -0
  26. package/src/framework-strategy.ts +50 -4
  27. package/src/gate-telemetry.ts +106 -0
  28. package/src/headless.ts +24 -0
  29. package/src/index.ts +179 -64
  30. package/src/mcpl-config.ts +99 -1
  31. package/src/modules/fleet-module.ts +60 -1
  32. package/src/modules/fleet-types.ts +30 -1
  33. package/src/modules/identity-module.ts +310 -2
  34. package/src/modules/instructions-module.ts +265 -0
  35. package/src/modules/mcpl-admin-module.ts +89 -13
  36. package/src/modules/retrieval-module.ts +249 -51
  37. package/src/modules/retrieval-trace-page.ts +254 -0
  38. package/src/modules/retrieval-trace.ts +904 -0
  39. package/src/modules/subagent-module.ts +18 -0
  40. package/src/modules/tts-relay-module.ts +33 -18
  41. package/src/modules/web-ui-module.ts +445 -894
  42. package/src/recipe.ts +787 -29
  43. package/src/retrieval-config.ts +39 -0
  44. package/src/strategies/frontdesk-strategy.ts +34 -125
  45. package/src/tui.ts +325 -54
  46. package/src/web/panel-data.ts +1206 -0
  47. package/src/web/protocol.ts +75 -10
  48. package/src/workspace-mounts.ts +73 -0
  49. package/test/audit-module-optins.test.ts +174 -0
  50. package/test/cache-keepalive-log.test.ts +83 -0
  51. package/test/conversations-recipe.test.ts +142 -0
  52. package/test/fleet-panel-request.test.ts +90 -0
  53. package/test/framework-fkm-composition.test.ts +35 -3
  54. package/test/framework-strategy-defaults.test.ts +41 -0
  55. package/test/frontdesk-strategy.test.ts +25 -37
  56. package/test/gate-telemetry-adapter.test.ts +84 -0
  57. package/test/gate-telemetry.test.ts +91 -0
  58. package/test/headless-panel-request.test.ts +201 -0
  59. package/test/identity-and-surfaces.test.ts +212 -1
  60. package/test/instructions-module.test.ts +258 -0
  61. package/test/mcpl-admin-module.test.ts +64 -0
  62. package/test/mcpl-agent-overlay.test.ts +51 -3
  63. package/test/mcpl-child-env.test.ts +64 -0
  64. package/test/mock-headless-child.ts +14 -0
  65. package/test/nudge-command.test.ts +47 -0
  66. package/test/recipe-cache-keepalive.test.ts +59 -0
  67. package/test/recipe-compression-fallback.test.ts +19 -0
  68. package/test/recipe-hybrid-prose-routing.test.ts +12 -0
  69. package/test/recipe-instructions.test.ts +176 -0
  70. package/test/recipe-kv-unified.test.ts +87 -0
  71. package/test/recipe-mcp-source.test.ts +54 -0
  72. package/test/recipe-openai-compatible.test.ts +54 -0
  73. package/test/recipe-path-resolution.test.ts +19 -8
  74. package/test/recipe-provider.test.ts +14 -0
  75. package/test/recipe-save-unresolved.test.ts +244 -0
  76. package/test/recipe-source-only.test.ts +38 -0
  77. package/test/release-changelog.test.ts +202 -0
  78. package/test/retrieval-auth-loopback.test.ts +49 -0
  79. package/test/retrieval-config.test.ts +74 -0
  80. package/test/retrieval-module.test.ts +821 -0
  81. package/test/subagent-prose-routing.test.ts +109 -0
  82. package/test/tui-format.test.ts +106 -0
  83. package/test/web-ui-context-coverage.test.ts +1 -1
  84. package/test/web-ui-module.test.ts +189 -3
  85. package/test/web-ui-observers.test.ts +8 -5
  86. package/test/web-ui-protocol.test.ts +0 -0
  87. package/test/workspace-mounts.test.ts +68 -0
  88. package/web/src/App.tsx +160 -44
  89. package/web/src/Context.tsx +35 -8
  90. package/web/src/ContextDocument.tsx +20 -5
  91. package/web/src/Files.tsx +2 -8
  92. package/web/src/Health.tsx +61 -1
  93. package/web/src/Lessons.tsx +2 -38
  94. package/web/src/Mcpl.tsx +80 -14
  95. package/web/src/Pins.tsx +5 -0
  96. package/web/src/Settings.tsx +5 -0
  97. package/web/vite.config.ts +8 -2
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Recipe surface for per-channel conversation routing:
3
+ * schema validation of the `conversations` block, and the host-side mapping
4
+ * to the framework's ConversationRouterConfig (templateAgent auto-filled
5
+ * from the recipe agent, strategyFactory building fresh per-fork strategy
6
+ * instances from the recipe's own strategy config).
7
+ */
8
+ import { describe, test, expect } from 'bun:test';
9
+ import { validateRecipe, type Recipe } from '../src/recipe.js';
10
+ import { buildConversationsConfig } from '../src/framework-strategy.js';
11
+
12
+ function baseRecipe(extra: Record<string, unknown> = {}): Record<string, unknown> {
13
+ return {
14
+ name: 'Test',
15
+ agent: { name: 'sherlock', systemPrompt: 'test' },
16
+ ...extra,
17
+ };
18
+ }
19
+
20
+ describe('validateRecipe — conversations schema', () => {
21
+ test('absent block is accepted', () => {
22
+ expect(() => validateRecipe(baseRecipe())).not.toThrow();
23
+ });
24
+
25
+ test('full valid block is accepted', () => {
26
+ expect(() => validateRecipe(baseRecipe({
27
+ conversations: {
28
+ bind: { dm: 'always', groupDm: 'always', channel: 'mention' },
29
+ trigger: { dm: 'always', groupDm: 'mention', channel: 'mention' },
30
+ idleTtlMs: 12 * 60 * 60 * 1000,
31
+ closurePrompt: 'Finalize the case report and post the answer.',
32
+ agentPrefix: 'case',
33
+ },
34
+ }))).not.toThrow();
35
+ });
36
+
37
+ test('empty object is accepted (all defaults come from the framework)', () => {
38
+ expect(() => validateRecipe(baseRecipe({ conversations: {} }))).not.toThrow();
39
+ });
40
+
41
+ test('non-object block is rejected', () => {
42
+ expect(() => validateRecipe(baseRecipe({ conversations: true }))).toThrow(/must be an object/);
43
+ expect(() => validateRecipe(baseRecipe({ conversations: [] }))).toThrow(/must be an object/);
44
+ });
45
+
46
+ test('unknown top-level conversation field is rejected rather than silently ignored', () => {
47
+ expect(() => validateRecipe(baseRecipe({
48
+ conversations: { idleTTLms: 1000 },
49
+ }))).toThrow(/unknown field \"idleTTLms\"/);
50
+ });
51
+
52
+ test('unknown channel kind is rejected', () => {
53
+ expect(() => validateRecipe(baseRecipe({
54
+ conversations: { bind: { thread: 'always' } },
55
+ }))).toThrow(/unknown channel kind "thread"/);
56
+ });
57
+
58
+ test('invalid bind rule is rejected', () => {
59
+ expect(() => validateRecipe(baseRecipe({
60
+ conversations: { bind: { channel: 'sometimes' } },
61
+ }))).toThrow(/conversations\.bind\.channel/);
62
+ });
63
+
64
+ test("trigger rule 'never' is rejected (bind-only rule)", () => {
65
+ expect(() => validateRecipe(baseRecipe({
66
+ conversations: { trigger: { channel: 'never' } },
67
+ }))).toThrow(/conversations\.trigger\.channel/);
68
+ });
69
+
70
+ test('non-positive idleTtlMs is rejected', () => {
71
+ expect(() => validateRecipe(baseRecipe({ conversations: { idleTtlMs: 0 } })))
72
+ .toThrow(/idleTtlMs/);
73
+ expect(() => validateRecipe(baseRecipe({ conversations: { idleTtlMs: -5 } })))
74
+ .toThrow(/idleTtlMs/);
75
+ for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, 1.5]) {
76
+ expect(() => validateRecipe(baseRecipe({ conversations: { idleTtlMs: bad } })))
77
+ .toThrow(/idleTtlMs/);
78
+ }
79
+ });
80
+
81
+ test('blank closurePrompt is rejected', () => {
82
+ expect(() => validateRecipe(baseRecipe({ conversations: { closurePrompt: ' ' } })))
83
+ .toThrow(/closurePrompt/);
84
+ });
85
+
86
+ test('agentPrefix with unsafe characters is rejected', () => {
87
+ for (const bad of ['with space', 'slash/py', 'dot.seg', '']) {
88
+ expect(() => validateRecipe(baseRecipe({ conversations: { agentPrefix: bad } })))
89
+ .toThrow(/agentPrefix/);
90
+ }
91
+ expect(() => validateRecipe(baseRecipe({ conversations: { agentPrefix: 'case-fork_2' } })))
92
+ .not.toThrow();
93
+ });
94
+ });
95
+
96
+ describe('buildConversationsConfig — recipe → FrameworkConfig mapping', () => {
97
+ const recipe = validateRecipe(baseRecipe({
98
+ conversations: {
99
+ bind: { channel: 'mention' },
100
+ idleTtlMs: 3600_000,
101
+ agentPrefix: 'case',
102
+ },
103
+ })) as Recipe;
104
+
105
+ test('returns undefined when the recipe has no conversations block', () => {
106
+ const bare = validateRecipe(baseRecipe()) as Recipe;
107
+ expect(buildConversationsConfig(bare, 'sherlock', 'model-x', 'UTC')).toBeUndefined();
108
+ });
109
+
110
+ test('templateAgent is auto-filled with the host agent name', () => {
111
+ const cfg = buildConversationsConfig(recipe, 'sherlock', 'model-x', 'UTC');
112
+ expect(cfg?.templateAgent).toBe('sherlock');
113
+ });
114
+
115
+ test('recipe fields pass through; omitted fields stay absent for framework defaults', () => {
116
+ const cfg = buildConversationsConfig(recipe, 'sherlock', 'model-x', 'UTC')!;
117
+ expect(cfg.bind).toEqual({ channel: 'mention' });
118
+ expect(cfg.idleTtlMs).toBe(3600_000);
119
+ expect(cfg.agentPrefix).toBe('case');
120
+ expect('trigger' in cfg).toBe(false);
121
+ expect('closurePrompt' in cfg).toBe(false);
122
+ });
123
+
124
+ test('strategyFactory builds a FRESH strategy instance per call', () => {
125
+ const cfg = buildConversationsConfig(recipe, 'sherlock', 'model-x', 'UTC')!;
126
+ expect(cfg.strategyFactory).toBeDefined();
127
+ const a = cfg.strategyFactory!();
128
+ const b = cfg.strategyFactory!();
129
+ expect(a).toBeDefined();
130
+ expect(b).toBeDefined();
131
+ expect(a).not.toBe(b);
132
+ });
133
+
134
+ test('strategyFactory honors the recipe strategy type', () => {
135
+ const passthrough = validateRecipe(baseRecipe({
136
+ agent: { name: 'p', systemPrompt: 't', strategy: { type: 'passthrough' } },
137
+ conversations: {},
138
+ })) as Recipe;
139
+ const cfg = buildConversationsConfig(passthrough, 'p', 'model-x', 'UTC')!;
140
+ expect(cfg.strategyFactory!().constructor.name).toBe('PassthroughStrategy');
141
+ });
142
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * FleetModule.requestPanel — the promise-based panel-op verb the WebUI's
3
+ * fleet-scope routing rides on (WS panels and the HTTP ?scope= proxy).
4
+ *
5
+ * Uses the mock headless child, which answers panel-request with:
6
+ * op 'hang' → nothing (timeout path)
7
+ * op 'fail' → {ok:false, error, status:418} (error passthrough)
8
+ * others → {ok:true, data:{op, echo:params}}
9
+ */
10
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
11
+ import { mkdtempSync, rmSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join, dirname } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { FleetModule, type FleetModuleConfig } from '../src/modules/fleet-module.js';
16
+
17
+ const TEST_DIR = dirname(fileURLToPath(import.meta.url));
18
+ const MOCK_CHILD_PATH = join(TEST_DIR, 'mock-headless-child.ts');
19
+
20
+ function makeFleet(overrides: Partial<FleetModuleConfig> = {}): FleetModule {
21
+ return new FleetModule({
22
+ childIndexPath: MOCK_CHILD_PATH,
23
+ socketWaitTimeoutMs: 10_000,
24
+ readyTimeoutMs: 5_000,
25
+ gracefulShutdownMs: 3_000,
26
+ sigtermEscalationMs: 1_000,
27
+ ...overrides,
28
+ });
29
+ }
30
+
31
+ describe('FleetModule.requestPanel', () => {
32
+ let tmpDir: string;
33
+ let fleet: FleetModule;
34
+
35
+ beforeAll(async () => {
36
+ tmpDir = mkdtempSync(join(tmpdir(), 'fkm-panel-'));
37
+ fleet = makeFleet();
38
+ const res = await fleet.handleToolCall({
39
+ id: 'launch-panelkid',
40
+ name: 'launch',
41
+ input: { name: 'panelkid', recipe: 'mock-recipe', dataDir: join(tmpDir, 'panelkid') },
42
+ });
43
+ if (!res.success) throw new Error(`launch failed: ${res.error}`);
44
+ });
45
+
46
+ afterAll(async () => {
47
+ try { await fleet.stop(); } catch { /* noop */ }
48
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
49
+ });
50
+
51
+ test('round-trips op + params and resolves the child data', async () => {
52
+ const result = await fleet.requestPanel('panelkid', 'settings', { agent: 'clerk' });
53
+ expect(result.ok).toBe(true);
54
+ expect(result.data).toEqual({ op: 'settings', echo: { agent: 'clerk' } });
55
+ });
56
+
57
+ test('concurrent requests correlate independently', async () => {
58
+ const [a, b] = await Promise.all([
59
+ fleet.requestPanel('panelkid', 'health', { n: 1 }),
60
+ fleet.requestPanel('panelkid', 'pins', { n: 2 }),
61
+ ]);
62
+ expect(a.ok).toBe(true);
63
+ expect((a.data as { op: string }).op).toBe('health');
64
+ expect(b.ok).toBe(true);
65
+ expect((b.data as { op: string }).op).toBe('pins');
66
+ });
67
+
68
+ test('child-reported failure passes error and status through', async () => {
69
+ const result = await fleet.requestPanel('panelkid', 'fail');
70
+ expect(result.ok).toBe(false);
71
+ expect(result.error).toBe('mock failure');
72
+ expect(result.status).toBe(418);
73
+ });
74
+
75
+ test('unanswered request resolves ok:false with 504 after timeoutMs', async () => {
76
+ const started = Date.now();
77
+ const result = await fleet.requestPanel('panelkid', 'hang', undefined, 400);
78
+ expect(Date.now() - started).toBeGreaterThanOrEqual(380);
79
+ expect(result.ok).toBe(false);
80
+ expect(result.status).toBe(504);
81
+ expect(result.error).toContain('timed out');
82
+ });
83
+
84
+ test('unknown child resolves ok:false with 404 without touching the wire', async () => {
85
+ const result = await fleet.requestPanel('nobody', 'settings');
86
+ expect(result.ok).toBe(false);
87
+ expect(result.status).toBe(404);
88
+ expect(result.error).toContain('nobody');
89
+ });
90
+ });
@@ -86,7 +86,7 @@ describe('framework FKM composition', () => {
86
86
  });
87
87
  });
88
88
 
89
- test('composes validation, serialization, and AF/CM wiring for the merged FKM settings without cross-agent bleed', () => {
89
+ test('composes validation, serialization, and AF/CM wiring for the merged FKM settings without cross-agent bleed', async () => {
90
90
  const parsed = validateRecipe(recipe({
91
91
  provider: 'openai-responses',
92
92
  maxTokens: 4_096,
@@ -103,13 +103,20 @@ describe('framework FKM composition', () => {
103
103
  type: 'autobiographical',
104
104
  compressionRefusalCurveFallbacks: 2,
105
105
  compressionContextBudgetTokens: 19_000,
106
+ compressionRecallBudgetTokens: 17_000,
107
+ compressionSourceOnly: false,
108
+ compressionSourceOnlyFallback: true,
109
+ compressionMergeSourceOnly: false,
110
+ compressionMergeSourceOnlyFallback: true,
106
111
  },
107
112
  }));
108
113
 
109
114
  const dir = mkdtempSync(join(tmpdir(), 'connectome-fkm-'));
110
115
  tempDirs.push(dir);
111
- saveRecipe(dir, parsed);
112
- const reloaded = loadSavedRecipe(dir);
116
+ // Saving an already-resolved recipe through the new unresolved-snapshot
117
+ // path is legal: substitution over resolved content is a no-op.
118
+ saveRecipe(dir, parsed as unknown as Record<string, unknown>);
119
+ const reloaded = await loadSavedRecipe(dir);
113
120
  expect(reloaded).not.toBeNull();
114
121
 
115
122
  expect(reloaded!.agent.sameRoundThinkTextPolicy).toBe('private');
@@ -118,11 +125,21 @@ describe('framework FKM composition', () => {
118
125
  });
119
126
  expect(reloaded!.agent.strategy?.compressionRefusalCurveFallbacks).toBe(2);
120
127
  expect(reloaded!.agent.strategy?.compressionContextBudgetTokens).toBe(19_000);
128
+ expect(reloaded!.agent.strategy?.compressionRecallBudgetTokens).toBe(17_000);
129
+ expect(reloaded!.agent.strategy?.compressionSourceOnly).toBe(false);
130
+ expect(reloaded!.agent.strategy?.compressionSourceOnlyFallback).toBe(true);
131
+ expect(reloaded!.agent.strategy?.compressionMergeSourceOnly).toBe(false);
132
+ expect(reloaded!.agent.strategy?.compressionMergeSourceOnlyFallback).toBe(true);
121
133
 
122
134
  const runtimeStrategy = buildFrameworkStrategy(reloaded!, 'model', 'America/Los_Angeles');
123
135
  const runtimeConfig = strategyConfigView(runtimeStrategy);
124
136
  expect(runtimeConfig.compressionRefusalCurveFallbacks).toBe(2);
125
137
  expect(runtimeConfig.compressionContextBudgetTokens).toBe(19_000);
138
+ expect(runtimeConfig.compressionRecallBudgetTokens).toBe(17_000);
139
+ expect(runtimeConfig.compressionSourceOnly).toBe(false);
140
+ expect(runtimeConfig.compressionSourceOnlyFallback).toBe(true);
141
+ expect(runtimeConfig.compressionMergeSourceOnly).toBe(false);
142
+ expect(runtimeConfig.compressionMergeSourceOnlyFallback).toBe(true);
126
143
 
127
144
  const agentConfig = buildFrameworkAgentConfig(reloaded!, 'agent', 'model', runtimeStrategy);
128
145
  expect(agentConfig.sameRoundThinkTextPolicy).toBe('private');
@@ -138,14 +155,29 @@ describe('framework FKM composition', () => {
138
155
  type: 'autobiographical',
139
156
  compressionRefusalCurveFallbacks: 0,
140
157
  compressionContextBudgetTokens: 50_000,
158
+ compressionRecallBudgetTokens: 41_000,
159
+ compressionSourceOnly: true,
160
+ compressionSourceOnlyFallback: false,
161
+ compressionMergeSourceOnly: true,
162
+ compressionMergeSourceOnlyFallback: false,
141
163
  },
142
164
  }));
143
165
  const otherStrategy = buildFrameworkStrategy(otherRecipe, 'other-model', 'America/Los_Angeles');
144
166
  const otherConfig = strategyConfigView(otherStrategy);
145
167
  expect(otherConfig.compressionRefusalCurveFallbacks).toBe(0);
146
168
  expect(otherConfig.compressionContextBudgetTokens).toBe(50_000);
169
+ expect(otherConfig.compressionRecallBudgetTokens).toBe(41_000);
170
+ expect(otherConfig.compressionSourceOnly).toBe(true);
171
+ expect(otherConfig.compressionSourceOnlyFallback).toBe(false);
172
+ expect(otherConfig.compressionMergeSourceOnly).toBe(true);
173
+ expect(otherConfig.compressionMergeSourceOnlyFallback).toBe(false);
147
174
  expect(otherConfig).not.toBe(runtimeConfig);
148
175
  expect(runtimeConfig.compressionRefusalCurveFallbacks).toBe(2);
149
176
  expect(runtimeConfig.compressionContextBudgetTokens).toBe(19_000);
177
+ expect(runtimeConfig.compressionRecallBudgetTokens).toBe(17_000);
178
+ expect(runtimeConfig.compressionSourceOnly).toBe(false);
179
+ expect(runtimeConfig.compressionSourceOnlyFallback).toBe(true);
180
+ expect(runtimeConfig.compressionMergeSourceOnly).toBe(false);
181
+ expect(runtimeConfig.compressionMergeSourceOnlyFallback).toBe(true);
150
182
  });
151
183
  });
@@ -39,6 +39,47 @@ describe('standard-recipe memory defaults', () => {
39
39
  expect(config.summaryParticipant).toBe('Mira');
40
40
  });
41
41
 
42
+ test('frontdesk gets adaptive + kv-stable too (2026-08-03 clerk outage: hierarchical saturates)', () => {
43
+ const strategy = buildFrameworkStrategy(
44
+ recipe({ name: 'Desk', strategy: { type: 'frontdesk' } }),
45
+ 'some-model',
46
+ 'Europe/Kyiv',
47
+ );
48
+ const config = configView(strategy);
49
+ expect(config.adaptiveResolution).toBe(true);
50
+ expect(config.foldingStrategy).toBe('kv-stable');
51
+ });
52
+
53
+ test('frontdesk adaptiveResolution: false is the hierarchical rollback lever', () => {
54
+ const strategy = buildFrameworkStrategy(
55
+ recipe({ name: 'Desk', strategy: { type: 'frontdesk', adaptiveResolution: false } }),
56
+ 'some-model',
57
+ 'Europe/Kyiv',
58
+ );
59
+ const config = configView(strategy);
60
+ expect(config.adaptiveResolution).toBe(false);
61
+ expect(config.foldingStrategy).toBeUndefined();
62
+ });
63
+
64
+ test('productionBudgetTokens is passed through exactly and omission stays omitted', () => {
65
+ const configured = buildFrameworkStrategy(
66
+ recipe({
67
+ name: 'Mira',
68
+ strategy: { type: 'autobiographical', productionBudgetTokens: 123_456 },
69
+ }),
70
+ 'some-model',
71
+ 'America/Los_Angeles',
72
+ );
73
+ expect(configView(configured).productionBudgetTokens).toBe(123_456);
74
+
75
+ const omitted = buildFrameworkStrategy(
76
+ recipe({ name: 'Mira', strategy: { type: 'autobiographical' } }),
77
+ 'some-model',
78
+ 'America/Los_Angeles',
79
+ );
80
+ expect(configView(omitted).productionBudgetTokens).toBeUndefined();
81
+ });
82
+
42
83
  test('explicit recipe values override the defaults', () => {
43
84
  const strategy = buildFrameworkStrategy(
44
85
  recipe({
@@ -2,7 +2,6 @@ import { describe, test, expect } from 'bun:test';
2
2
  import type {
3
3
  MessageStoreView,
4
4
  StoredMessage,
5
- SummaryEntry,
6
5
  ContextEntry,
7
6
  } from '@animalabs/context-manager';
8
7
  import { FrontdeskStrategy } from '../src/strategies/frontdesk-strategy.js';
@@ -54,12 +53,12 @@ class TestFrontdesk extends FrontdeskStrategy {
54
53
  public pub_updateSalience(store: MessageStoreView) {
55
54
  this.updateSalience(store);
56
55
  }
57
- public pub_selectL1(l1: SummaryEntry[], budget: number, maxTokens: number) {
58
- return this.selectL1Summaries(l1, budget, maxTokens);
59
- }
60
56
  public pub_isTopicBoundary(a: StoredMessage, b: StoredMessage) {
61
57
  return this.isTopicBoundary(a, b);
62
58
  }
59
+ public pub_chunkBoundaryHint(a: StoredMessage, b: StoredMessage) {
60
+ return this.chunkBoundaryHint(a, b);
61
+ }
63
62
  public pub_compressionInstruction(chunkMessages: StoredMessage[], target: number) {
64
63
  // Build a minimal Chunk shape sufficient for getCompressionInstruction
65
64
  const chunk = {
@@ -293,44 +292,33 @@ describe('compression instruction', () => {
293
292
  });
294
293
  });
295
294
 
296
- describe('salience-biased L1 selection', () => {
297
- function summary(id: string, tokens: number, sourceIds: string[]): SummaryEntry {
298
- return {
299
- id,
300
- level: 1,
301
- content: '',
302
- tokens,
303
- sourceLevel: 0,
304
- sourceIds,
305
- sourceRange: { first: sourceIds[0] ?? '', last: sourceIds[sourceIds.length - 1] ?? '' },
306
- created: Date.now(),
307
- };
308
- }
295
+ describe('chunk boundary hint (topic-aware chunking via the base seam)', () => {
296
+ // The chunking mechanics record persistence, minimum-size, tool-pairing
297
+ // guard — are context-manager's contract, gated by its
298
+ // chunk-boundary-hook tests. What is conhost's to pin is the hint policy:
299
+ // frontdesk hints exactly at topic boundaries.
309
300
 
310
- test('prefers L1 summaries whose sources contain salient messages', () => {
301
+ test('hints a close when adjacent messages change topic on one channel', () => {
311
302
  const s = makeStrategy();
312
- const q = msg('User', 'what is x?', {}); // salient: unanswered
313
- s.pub_updateSalience(makeStore([q]));
314
-
315
- // Two L1 summaries, each 100 tokens; budget fits only one
316
- const routineFirst = summary('L1-0', 100, ['unrelated-1']);
317
- const salientSecond = summary('L1-1', 100, [q.id]);
318
-
319
- const { selected } = s.pub_selectL1([routineFirst, salientSecond], 100, 100);
320
- expect(selected).toHaveLength(1);
321
- expect(selected[0].id).toBe('L1-1');
303
+ const a = msg('User', 'x', { serverId: 'zulip', channelId: 'zulip:eng', topic: 'retries' });
304
+ const b = msg('User', 'y', { serverId: 'zulip', channelId: 'zulip:eng', topic: 'deploys' });
305
+ expect(s.pub_chunkBoundaryHint(a, b)).toBe(true);
322
306
  });
323
307
 
324
- test('falls back to routine summaries once salient are exhausted', () => {
308
+ test('does not hint within a topic or when topic metadata is absent', () => {
325
309
  const s = makeStrategy();
326
- const q = msg('User', 'what is x?', {});
327
- s.pub_updateSalience(makeStore([q]));
310
+ const a = msg('User', 'x', { serverId: 'zulip', channelId: 'zulip:eng', topic: 'retries' });
311
+ const b = msg('User', 'y', { serverId: 'zulip', channelId: 'zulip:eng', topic: 'retries' });
312
+ const bare = msg('User', 'z', {});
313
+ expect(s.pub_chunkBoundaryHint(a, b)).toBe(false);
314
+ expect(s.pub_chunkBoundaryHint(a, bare)).toBe(false);
315
+ expect(s.pub_chunkBoundaryHint(bare, a)).toBe(false);
316
+ });
328
317
 
329
- const salient = summary('L1-1', 50, [q.id]);
330
- const routine = summary('L1-2', 50, ['unrelated-1']);
331
- const { selected } = s.pub_selectL1([routine, salient], 200, 200);
332
- expect(selected).toHaveLength(2);
333
- expect(selected[0].id).toBe('L1-1'); // salient first
334
- expect(selected[1].id).toBe('L1-2');
318
+ test('hint agrees with isTopicBoundary across channels (same topic name, different channel)', () => {
319
+ const s = makeStrategy();
320
+ const a = msg('User', 'x', { serverId: 'zulip', channelId: 'zulip:eng', topic: 'retries' });
321
+ const b = msg('User', 'y', { serverId: 'zulip', channelId: 'zulip:ops', topic: 'retries' });
322
+ expect(s.pub_chunkBoundaryHint(a, b)).toBe(s.pub_isTopicBoundary(a, b));
335
323
  });
336
324
  });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Adapter-level proof (review finding on #113): with the PUBLISHED membrane,
3
+ * a stream call consults the active-turn trigger and carries the origin trio,
4
+ * while a complete call (compression / side-calls / keepalive) carries the
5
+ * debt stamp only. Runs the real AnthropicAdapter with a mocked SDK client.
6
+ */
7
+ import { describe, expect, it } from 'bun:test';
8
+ import { AnthropicAdapter } from '@animalabs/membrane';
9
+ import { gateTelemetryHeaders } from '../src/gate-telemetry.js';
10
+
11
+ const env = { GATE_TELEMETRY: '1', ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic' };
12
+ const wake = { reason: 'mcpl:channel-incoming', source: 'discord', channelId: 'discord:1:2', counterparty: 'discord:user:42' };
13
+
14
+ const REQUEST = {
15
+ model: 'claude-test',
16
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
17
+ } as any;
18
+
19
+ const RESPONSE = {
20
+ id: 'msg_test', model: 'claude-test', role: 'assistant',
21
+ content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn',
22
+ usage: { input_tokens: 1, output_tokens: 1 },
23
+ };
24
+
25
+ /** Minimal SDK stream: an async iterable of the events the adapter reads. */
26
+ function mockStream() {
27
+ const events = [
28
+ { type: 'message_start', message: { id: 'msg_s', model: 'claude-test', role: 'assistant', content: [], usage: { input_tokens: 1, output_tokens: 0 } } },
29
+ { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
30
+ { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'ok' } },
31
+ { type: 'content_block_stop', index: 0 },
32
+ { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } },
33
+ { type: 'message_stop' },
34
+ ];
35
+ return {
36
+ async *[Symbol.asyncIterator]() { for (const e of events) yield e; },
37
+ };
38
+ }
39
+
40
+ function adapterWithTrigger(trigger: () => typeof wake | null) {
41
+ let asked = 0;
42
+ const dyn = gateTelemetryHeaders(env, () => 3, () => { asked++; return trigger(); })!;
43
+ const adapter = new AnthropicAdapter({ apiKey: 'sk-test', cacheKeepalive: { enabled: false }, dynamicHeaders: dyn } as any);
44
+ const calls: Array<{ kind: string; headers: Record<string, string> | undefined }> = [];
45
+ (adapter as any).client = {
46
+ messages: {
47
+ create: async (_req: unknown, opts: { headers?: Record<string, string> }) => { calls.push({ kind: 'complete', headers: opts?.headers }); return RESPONSE; },
48
+ stream: async (_req: unknown, opts: { headers?: Record<string, string> }) => { calls.push({ kind: 'stream', headers: opts?.headers }); return mockStream(); },
49
+ },
50
+ };
51
+ return { adapter, calls, asked: () => asked };
52
+ }
53
+
54
+ describe('gate telemetry through the published AnthropicAdapter', () => {
55
+ it('complete lane: debt only — the active trigger is not consulted', async () => {
56
+ const { adapter, calls, asked } = adapterWithTrigger(() => wake);
57
+ await adapter.complete(REQUEST);
58
+ expect(calls).toHaveLength(1);
59
+ expect(calls[0]!.kind).toBe('complete');
60
+ expect(calls[0]!.headers).toEqual({ 'x-gate-debt-chunks': '3' });
61
+ expect(asked()).toBe(0);
62
+ });
63
+
64
+ it('stream lane: the trigger is consulted and the origin trio rides the request', async () => {
65
+ const { adapter, calls, asked } = adapterWithTrigger(() => wake);
66
+ const chunks: string[] = [];
67
+ await adapter.stream(REQUEST, { onChunk: (c: string) => { chunks.push(c); } } as any);
68
+ expect(calls).toHaveLength(1);
69
+ expect(calls[0]!.kind).toBe('stream');
70
+ expect(calls[0]!.headers).toEqual({
71
+ 'x-gate-debt-chunks': '3',
72
+ 'x-gate-origin': 'event',
73
+ 'x-gate-channel': 'discord:1:2',
74
+ 'x-gate-counterparty': 'discord:user:42',
75
+ });
76
+ expect(asked()).toBe(1);
77
+ });
78
+
79
+ it('stream lane with no turn in progress: debt only, nothing guessed', async () => {
80
+ const { adapter, calls } = adapterWithTrigger(() => null);
81
+ await adapter.stream(REQUEST, { onChunk: () => {} } as any);
82
+ expect(calls[0]!.headers).toEqual({ 'x-gate-debt-chunks': '3' });
83
+ });
84
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The x-gate-* stamp must be impossible to send to a vendor: attachment is
3
+ * double-gated on the operator's explicit GATE_TELEMETRY declaration AND a
4
+ * configured base URL. Review finding on the first wiring: the stamp was
5
+ * attached unconditionally, so with ANTHROPIC_BASE_URL unset the household
6
+ * value went to the vendor's default endpoint.
7
+ */
8
+ import { describe, expect, it } from 'bun:test';
9
+ import { gateTelemetryHeaders, originClass } from '../src/gate-telemetry.js';
10
+
11
+ const debt = () => 7;
12
+
13
+ describe('gateTelemetryHeaders', () => {
14
+ it('default vendor endpoint (no base URL): never attaches, even when flagged', () => {
15
+ expect(gateTelemetryHeaders({ GATE_TELEMETRY: '1' }, debt)).toBeUndefined();
16
+ });
17
+
18
+ it('base URL without the operator declaration: never attaches', () => {
19
+ expect(gateTelemetryHeaders({ ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic' }, debt)).toBeUndefined();
20
+ expect(gateTelemetryHeaders({ GATE_TELEMETRY: '0', ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic' }, debt)).toBeUndefined();
21
+ expect(gateTelemetryHeaders({ GATE_TELEMETRY: 'false', ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic' }, debt)).toBeUndefined();
22
+ });
23
+
24
+ it('declared gateway: attaches a live stamp', () => {
25
+ const fn = gateTelemetryHeaders({ GATE_TELEMETRY: '1', ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic' }, debt);
26
+ expect(fn).toBeDefined();
27
+ expect(fn!()).toEqual({ 'x-gate-debt-chunks': 7 });
28
+ });
29
+
30
+ it('unreadable debt stays null (membrane drops null values — unstamped, never guessed)', () => {
31
+ const fn = gateTelemetryHeaders({ GATE_TELEMETRY: '1', ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic' }, () => null);
32
+ expect(fn!()).toEqual({ 'x-gate-debt-chunks': null });
33
+ });
34
+
35
+ const env = { GATE_TELEMETRY: '1', ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic' };
36
+ const wake = { reason: 'mcpl:channel-incoming', source: 'discord', channelId: 'discord:1:2', counterparty: 'discord:user:42' };
37
+
38
+ it('origin trio rides the stream lane: why, where, by whom — ids only', () => {
39
+ const fn = gateTelemetryHeaders(env, debt, () => wake);
40
+ expect(fn!({ lane: 'stream' })).toEqual({
41
+ 'x-gate-debt-chunks': 7,
42
+ 'x-gate-origin': 'event',
43
+ 'x-gate-channel': 'discord:1:2',
44
+ 'x-gate-counterparty': 'discord:user:42',
45
+ });
46
+ });
47
+
48
+ it('complete lane (compression, side-calls, keepalive) carries debt only — a background call is not the turn', () => {
49
+ const fn = gateTelemetryHeaders(env, debt, () => wake);
50
+ expect(fn!({ lane: 'complete' })).toEqual({ 'x-gate-debt-chunks': 7 });
51
+ });
52
+
53
+ it('no lane told (older membrane) or no trigger known: honest — debt only, or trio without guessing', () => {
54
+ expect(gateTelemetryHeaders(env, debt, () => null)!({ lane: 'stream' })).toEqual({ 'x-gate-debt-chunks': 7 });
55
+ expect(gateTelemetryHeaders(env, debt, () => wake)!()).toMatchObject({ 'x-gate-origin': 'event' });
56
+ expect(gateTelemetryHeaders(env, debt)!({ lane: 'stream' })).toEqual({ 'x-gate-debt-chunks': 7 });
57
+ });
58
+
59
+ it('heartbeat wakes carry no channel or counterparty (null → dropped by membrane)', () => {
60
+ const fn = gateTelemetryHeaders(env, debt, () => ({ reason: 'heartbeat:tick', source: 'heartbeat' }));
61
+ expect(fn!({ lane: 'stream' })).toEqual({ 'x-gate-debt-chunks': 7, 'x-gate-origin': 'heartbeat', 'x-gate-channel': null, 'x-gate-counterparty': null });
62
+ });
63
+
64
+ it('originClass: heartbeat / mail / event / operator, raw reason otherwise (sanitized, clipped)', () => {
65
+ expect(originClass({ reason: 'heartbeat', source: 'heartbeat' })).toBe('heartbeat');
66
+ expect(originClass({ reason: 'mail:incoming', source: 'fenmail' })).toBe('mail');
67
+ expect(originClass({ reason: 'mcpl:push-event', source: 'discord' })).toBe('event');
68
+ expect(originClass({ reason: 'admin-nudge (someone)', source: 'framework' })).toBe('operator');
69
+ expect(originClass({ reason: 'external-message', source: 'headless' })).toBe('operator');
70
+ expect(originClass({ reason: 'external-message', source: 'tui' })).toBe('operator');
71
+ expect(originClass({ reason: 'provider-acceleration-retry', source: 'framework' })).toBe('provider-acceleration-retry');
72
+ expect(originClass({ reason: 'weird reason!!'.repeat(6), source: 'x' })).toHaveLength(40);
73
+ });
74
+
75
+ it('attributes are header-safe: a value with any non-ASCII or control character is withheld whole, never rewritten', () => {
76
+ const fn = gateTelemetryHeaders(env, debt, () => ({ reason: 'mcpl:channel-incoming', source: 'discord', channelId: 'discord:\u{1F600}', counterparty: 'discord:user:4\r\n2' }));
77
+ const h = fn!({ lane: 'stream' });
78
+ expect(h['x-gate-channel']).toBeNull();
79
+ expect(h['x-gate-counterparty']).toBeNull();
80
+ // and what IS sent can always be put in real Headers (Fetch ByteString rule)
81
+ const ok = gateTelemetryHeaders(env, debt, () => wake)!({ lane: 'stream' });
82
+ const sendable = Object.fromEntries(Object.entries(ok).filter(([, v]) => v !== null).map(([k, v]) => [k, String(v)]));
83
+ expect(() => new Headers(sendable)).not.toThrow();
84
+ expect(new Headers(sendable).get('x-gate-counterparty')).toBe('discord:user:42');
85
+ });
86
+
87
+ it('attributes are clipped to 120 visible-ASCII characters', () => {
88
+ const fn = gateTelemetryHeaders(env, debt, () => ({ reason: 'mcpl:channel-incoming', source: 'discord', counterparty: 'x'.repeat(200) }));
89
+ expect((fn!({ lane: 'stream' })['x-gate-counterparty'] as string).length).toBe(120);
90
+ });
91
+ });