@animalabs/connectome-host 0.7.4 → 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 (60) 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 +245 -0
  7. package/CONTRIBUTING.md +47 -19
  8. package/README.md +27 -0
  9. package/bun.lock +27 -31
  10. package/changelog.d/README.md +28 -0
  11. package/package.json +5 -5
  12. package/recipes/SETUP.md +11 -5
  13. package/recipes/TRIUMVIRATE-SETUP.md +68 -14
  14. package/recipes/knowledge-miner.json +0 -30
  15. package/recipes/mock-test.json +19 -0
  16. package/recipes/triumvirate.json +6 -1
  17. package/scripts/release-changelog.ts +210 -21
  18. package/src/cache-keepalive-log.ts +41 -0
  19. package/src/commands.ts +96 -0
  20. package/src/framework-strategy.ts +37 -0
  21. package/src/gate-telemetry.ts +106 -0
  22. package/src/headless.ts +10 -0
  23. package/src/index.ts +167 -55
  24. package/src/mcpl-config.ts +99 -1
  25. package/src/modules/identity-module.ts +310 -2
  26. package/src/modules/instructions-module.ts +265 -0
  27. package/src/modules/mcpl-admin-module.ts +58 -11
  28. package/src/modules/subagent-module.ts +18 -0
  29. package/src/recipe.ts +732 -25
  30. package/src/web/panel-data.ts +19 -0
  31. package/src/workspace-mounts.ts +73 -0
  32. package/test/audit-module-optins.test.ts +10 -3
  33. package/test/cache-keepalive-log.test.ts +83 -0
  34. package/test/conversations-recipe.test.ts +142 -0
  35. package/test/framework-fkm-composition.test.ts +35 -3
  36. package/test/framework-strategy-defaults.test.ts +19 -0
  37. package/test/gate-telemetry-adapter.test.ts +84 -0
  38. package/test/gate-telemetry.test.ts +91 -0
  39. package/test/identity-and-surfaces.test.ts +212 -1
  40. package/test/instructions-module.test.ts +258 -0
  41. package/test/mcpl-admin-module.test.ts +41 -0
  42. package/test/mcpl-agent-overlay.test.ts +51 -3
  43. package/test/mcpl-child-env.test.ts +64 -0
  44. package/test/nudge-command.test.ts +47 -0
  45. package/test/recipe-cache-keepalive.test.ts +59 -0
  46. package/test/recipe-compression-fallback.test.ts +19 -0
  47. package/test/recipe-hybrid-prose-routing.test.ts +12 -0
  48. package/test/recipe-instructions.test.ts +176 -0
  49. package/test/recipe-kv-unified.test.ts +87 -0
  50. package/test/recipe-mcp-source.test.ts +54 -0
  51. package/test/recipe-openai-compatible.test.ts +54 -0
  52. package/test/recipe-path-resolution.test.ts +19 -8
  53. package/test/recipe-provider.test.ts +14 -0
  54. package/test/recipe-save-unresolved.test.ts +244 -0
  55. package/test/recipe-source-only.test.ts +38 -0
  56. package/test/release-changelog.test.ts +202 -0
  57. package/test/subagent-prose-routing.test.ts +109 -0
  58. package/test/workspace-mounts.test.ts +68 -0
  59. package/web/src/App.tsx +1 -0
  60. package/web/src/Health.tsx +61 -1
@@ -589,6 +589,25 @@ export function buildHealthSnapshot(app: PanelAppRef): Record<string, unknown> {
589
589
  } catch {
590
590
  // Health reads never throw.
591
591
  }
592
+ // Compression DEBT per agent — the single-authority reduction from cm
593
+ // (healthy / degraded / critical + the numbers behind it). Every wedge of
594
+ // the 2026-08-06 five-resident day was weeks of silently-failing
595
+ // compression surfacing as a sudden budget crisis; this block is what
596
+ // lets fleet-watch (and later the resident notice, af #99) catch the
597
+ // debt while it is still cheap.
598
+ try {
599
+ const debt: Record<string, unknown> = {};
600
+ for (const agent of app.framework.getAllAgents()) {
601
+ const strategy = (agent.getContextManager() as unknown as {
602
+ getStrategy?: () => { getCompressionDebt?: () => unknown };
603
+ }).getStrategy?.();
604
+ const d = strategy?.getCompressionDebt?.();
605
+ if (d) debt[(agent as unknown as { name: string }).name] = d;
606
+ }
607
+ (snapshot as Record<string, unknown>).compressionDebt = debt;
608
+ } catch {
609
+ // Health reads never throw.
610
+ }
592
611
  // Rendered context COMPOSITION per agent — head / raw middle / summaries
593
612
  // by level / tail, as actually emitted by the last compile. Sourced from
594
613
  // the strategy's own render stats (already computed in-process), so it is
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Workspace mount construction — the single source of truth for which mounts
3
+ * a recipe's `modules.workspace` produces and with what flags.
4
+ *
5
+ * Used by BOTH the runtime (src/index.ts, to build the real WorkspaceModule
6
+ * config) and recipe validation (validateRecipe's instructions cross-check,
7
+ * which reasons about mount names, modes, and autoMaterialize). Keeping one
8
+ * builder prevents the validator from modeling a mount differently than the
9
+ * host constructs it — the exact split-brain the `_config` mount had when
10
+ * validation assumed it auto-materialized and the runtime built it without.
11
+ */
12
+
13
+ import { join, resolve } from 'node:path';
14
+ import type { MountConfig } from '@animalabs/agent-framework';
15
+ import type { RecipeModules, RecipeWorkspaceMount } from './recipe.js';
16
+
17
+ /**
18
+ * Build the mount list for a recipe's workspace declaration.
19
+ *
20
+ * Returns null when the workspace is disabled (`workspace: false`).
21
+ * `storePath` anchors the `_config` mount (session store config dir); pass
22
+ * any placeholder when only mount names/modes/flags are needed (validation).
23
+ */
24
+ export function buildWorkspaceMounts(
25
+ workspace: RecipeModules['workspace'],
26
+ storePath: string,
27
+ ): MountConfig[] | null {
28
+ if (workspace === false) return null;
29
+
30
+ let mounts: MountConfig[];
31
+ if (typeof workspace === 'object' && workspace.mounts) {
32
+ // Only pass fields the recipe explicitly provides; let WorkspaceModule
33
+ // default the rest. watch is overridden to 'never' since the host does
34
+ // not need chokidar filesystem watchers by default.
35
+ mounts = workspace.mounts.map((m: RecipeWorkspaceMount) => {
36
+ const mount: MountConfig = {
37
+ name: m.name,
38
+ path: resolve(m.path),
39
+ mode: m.mode ?? 'read-write',
40
+ watch: m.watch ?? 'never',
41
+ };
42
+ if (m.ignore) mount.ignore = m.ignore;
43
+ if (m.maxFileSize !== undefined) mount.maxFileSize = m.maxFileSize;
44
+ if (m.wakeOnChange !== undefined) mount.wakeOnChange = m.wakeOnChange;
45
+ if (m.autoMaterialize !== undefined) mount.autoMaterialize = m.autoMaterialize;
46
+ return mount;
47
+ });
48
+ } else {
49
+ // Default: read-only input mount + read-write products mount.
50
+ mounts = [
51
+ { name: 'input', path: resolve('./input'), mode: 'read-only', watch: 'never' },
52
+ { name: 'products', path: resolve('./output'), mode: 'read-write', watch: 'never' },
53
+ ];
54
+ }
55
+
56
+ // Config mount: version-controls gate.json (and future config files) via
57
+ // Chronicle. Opt-in via recipe: workspace.configMount = true. NOTE: this
58
+ // mount is deliberately NOT autoMaterialize — the host re-materializes it
59
+ // explicitly after branch-changing commands, and ordinary agent edits stay
60
+ // Chronicle-side until then. Anything that assumes `_config` disk content
61
+ // tracks agent edits is wrong (see the instructions-path validation).
62
+ const wantConfigMount = typeof workspace === 'object' && workspace.configMount;
63
+ if (wantConfigMount) {
64
+ mounts.push({
65
+ name: '_config',
66
+ path: resolve(join(storePath, 'config')),
67
+ mode: 'read-write',
68
+ watch: 'always',
69
+ });
70
+ }
71
+
72
+ return mounts;
73
+ }
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { describe, test, expect } from 'bun:test';
10
10
  import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
11
- import { join } from 'node:path';
11
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
12
12
  import { tmpdir } from 'node:os';
13
13
  import {
14
14
  classifyModule,
@@ -74,15 +74,22 @@ describe('auditRecipe', () => {
74
74
  });
75
75
 
76
76
  test('collects local fleet children, resolves relative paths, skips URLs', () => {
77
+ const parentPath = resolve('/deploy/recipes/parent.json');
77
78
  const a = auditRecipe(
78
79
  {
79
80
  name: 'parent',
80
81
  agent: AGENT,
81
82
  modules: { fleet: { children: [{ recipe: 'child.json' }, { recipe: 'https://x.example/c.json' }] } },
82
83
  },
83
- '/deploy/recipes/parent.json',
84
+ parentPath,
84
85
  );
85
- expect(a.childRecipePaths).toEqual(['/deploy/recipes/child.json']);
86
+
87
+ // The https child is skipped; the relative one resolves beside the parent.
88
+ expect(a.childRecipePaths).toHaveLength(1);
89
+ const [childPath] = a.childRecipePaths;
90
+ expect(isAbsolute(childPath)).toBe(true);
91
+ expect(dirname(childPath)).toBe(dirname(parentPath));
92
+ expect(basename(childPath)).toBe('child.json');
86
93
  });
87
94
  });
88
95
 
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Keepalive events must ALL reach stderr.
3
+ *
4
+ * The host unit routes StandardError to service-stderr.log and leaves stdout on
5
+ * the journal, so an event written with console.log lands where no operator
6
+ * greps. That actually happened on fable-cm 2026-08-23: the keepalive refreshed
7
+ * a 523k prefix three times, correctly, while a monitor tailing
8
+ * service-stderr.log reported zero activity for three hours.
9
+ *
10
+ * These tests fail if anyone re-introduces severity routing.
11
+ */
12
+ import { describe, test, expect } from 'bun:test';
13
+ import {
14
+ logKeepaliveEvent,
15
+ formatKeepaliveEvent,
16
+ KEEPALIVE_LOG_PREFIX,
17
+ } from '../src/cache-keepalive-log.js';
18
+ import type { KeepaliveEvent } from '@animalabs/membrane';
19
+
20
+ const EVENTS: KeepaliveEvent[] = [
21
+ { type: 'refreshed', key: 'k1', lane: 'stream', readTokens: 523102, idleMs: 2987020 },
22
+ { type: 'expired', key: 'k1', idleMs: 21600000 },
23
+ { type: 'ineffective', key: 'k1', reason: 'wrote-instead-of-read', readTokens: 0, writeTokens: 523102 },
24
+ { type: 'skipped', key: 'k1', reason: 'no-1h-breakpoint' },
25
+ { type: 'error', key: 'k1', error: '400 invalid_request_error', consecutive: 1 },
26
+ { type: 'disabled', reason: '3 consecutive keepalive failures' },
27
+ ];
28
+
29
+ describe('keepalive event logging', () => {
30
+ test('every event type is written, none silently dropped', () => {
31
+ for (const event of EVENTS) {
32
+ const lines: string[] = [];
33
+ logKeepaliveEvent(event, (l) => lines.push(l));
34
+ expect(lines.length).toBe(1);
35
+ }
36
+ });
37
+
38
+ test('routine refreshes are logged, not filtered as noise', () => {
39
+ // The regression this guards: treating `refreshed` as too chatty to log
40
+ // leaves no positive evidence the feature ran at all.
41
+ const lines: string[] = [];
42
+ logKeepaliveEvent(EVENTS[0]!, (l) => lines.push(l));
43
+ expect(lines[0]).toContain('refreshed');
44
+ expect(lines[0]).toContain('523102');
45
+ });
46
+
47
+ test('lines carry the greppable prefix', () => {
48
+ for (const event of EVENTS) {
49
+ expect(formatKeepaliveEvent(event).startsWith(KEEPALIVE_LOG_PREFIX)).toBe(true);
50
+ }
51
+ });
52
+
53
+ test('the payload is machine-readable JSON after the prefix', () => {
54
+ const line = formatKeepaliveEvent(EVENTS[2]!);
55
+ const json = line.slice(KEEPALIVE_LOG_PREFIX.length).trim();
56
+ const parsed = JSON.parse(json) as { type: string; reason: string; writeTokens: number };
57
+ expect(parsed.type).toBe('ineffective');
58
+ expect(parsed.reason).toBe('wrote-instead-of-read');
59
+ expect(parsed.writeTokens).toBe(523102);
60
+ });
61
+
62
+ test('DEFAULT SINK IS STDERR for every event type — not stdout', () => {
63
+ // The actual bug. console.error -> stderr -> service-stderr.log;
64
+ // console.log -> stdout -> journal, where nobody looks.
65
+ const origLog = console.log;
66
+ const origErr = console.error;
67
+ const origWarn = console.warn;
68
+ const toStdout: string[] = [];
69
+ const toStderr: string[] = [];
70
+ try {
71
+ console.log = (...a: unknown[]) => { toStdout.push(String(a[0])); };
72
+ console.error = (...a: unknown[]) => { toStderr.push(String(a[0])); };
73
+ console.warn = (...a: unknown[]) => { toStderr.push(String(a[0])); };
74
+ for (const event of EVENTS) logKeepaliveEvent(event);
75
+ } finally {
76
+ console.log = origLog;
77
+ console.error = origErr;
78
+ console.warn = origWarn;
79
+ }
80
+ expect(toStderr.length).toBe(EVENTS.length);
81
+ expect(toStdout.length).toBe(0);
82
+ });
83
+ });
@@ -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
+ });
@@ -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
  });
@@ -61,6 +61,25 @@ describe('standard-recipe memory defaults', () => {
61
61
  expect(config.foldingStrategy).toBeUndefined();
62
62
  });
63
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
+
64
83
  test('explicit recipe values override the defaults', () => {
65
84
  const strategy = buildFrameworkStrategy(
66
85
  recipe({
@@ -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
+ });