@animalabs/connectome-host 0.3.5 → 0.3.8

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 (51) hide show
  1. package/.env.example +6 -0
  2. package/.github/workflows/ci.yml +52 -0
  3. package/.github/workflows/publish.yml +2 -2
  4. package/README.md +5 -0
  5. package/bun.lock +14 -6
  6. package/docs/AGENT-ONBOARDING.md +6 -1
  7. package/package.json +6 -4
  8. package/scripts/import-codex-rollout.ts +288 -0
  9. package/src/call-ledger.ts +371 -0
  10. package/src/call-pricing.ts +119 -0
  11. package/src/framework-agent-config.ts +51 -0
  12. package/src/framework-strategy.ts +70 -0
  13. package/src/index.ts +130 -105
  14. package/src/logging-adapter.ts +105 -12
  15. package/src/mcpl-config.ts +1 -0
  16. package/src/modules/channel-mode-module.ts +14 -16
  17. package/src/modules/fleet-module.ts +17 -7
  18. package/src/modules/mcpl-admin-module.ts +6 -15
  19. package/src/modules/settings-module.ts +83 -59
  20. package/src/modules/subscription-gc-module.ts +17 -20
  21. package/src/modules/time-module.ts +15 -9
  22. package/src/modules/web-ui-module.ts +88 -10
  23. package/src/modules/web-ui-observers.ts +35 -9
  24. package/src/recipe.ts +133 -6
  25. package/src/state/agent-tree-reducer.ts +17 -3
  26. package/src/strategies/frontdesk-strategy.ts +15 -9
  27. package/src/web/protocol.ts +89 -0
  28. package/test/agent-tree-reducer.test.ts +35 -3
  29. package/test/autobio-progress-snapshot.test.ts +27 -12
  30. package/test/call-ledger.test.ts +91 -0
  31. package/test/call-pricing.test.ts +69 -0
  32. package/test/framework-fkm-composition.test.ts +160 -0
  33. package/test/frontdesk-strategy.test.ts +10 -0
  34. package/test/import-codex-rollout.test.ts +36 -0
  35. package/test/logging-adapter-refusal.test.ts +57 -0
  36. package/test/logging-adapter.test.ts +58 -1
  37. package/test/recipe-cache-ttl.test.ts +7 -3
  38. package/test/recipe-compression-fallback.test.ts +36 -0
  39. package/test/recipe-primary-summary-fallback.test.ts +40 -0
  40. package/test/recipe-provider.test.ts +36 -0
  41. package/test/recipe-think-policy.test.ts +39 -0
  42. package/test/recipe-timezone.test.ts +20 -0
  43. package/test/subscription-gc-module.test.ts +7 -5
  44. package/test/time-module.test.ts +13 -0
  45. package/test/web-ui-observers.test.ts +70 -2
  46. package/web/package-lock.json +425 -302
  47. package/web/src/App.tsx +9 -0
  48. package/web/src/ObserverGate.tsx +21 -11
  49. package/web/src/Usage.tsx +116 -2
  50. package/web/src/wire.ts +5 -2
  51. package/web/bun.lock +0 -357
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { validateRecipe } from '../src/recipe.js';
3
+
4
+ function recipe(strategy: Record<string, unknown>) {
5
+ return {
6
+ name: 'compression-fallback-test',
7
+ agent: { systemPrompt: 'sys', strategy: { type: 'autobiographical', ...strategy } },
8
+ };
9
+ }
10
+
11
+ describe('compression recall-curve recipe settings', () => {
12
+ test('preserves valid fallback count and complete-request budget', () => {
13
+ const parsed = validateRecipe(recipe({
14
+ compressionRefusalCurveFallbacks: 3,
15
+ compressionContextBudgetTokens: 200_000,
16
+ }));
17
+ expect(parsed.agent.strategy?.compressionRefusalCurveFallbacks).toBe(3);
18
+ expect(parsed.agent.strategy?.compressionContextBudgetTokens).toBe(200_000);
19
+ });
20
+
21
+ test('accepts zero as an explicit fallback disable', () => {
22
+ expect(validateRecipe(recipe({ compressionRefusalCurveFallbacks: 0 }))
23
+ .agent.strategy?.compressionRefusalCurveFallbacks).toBe(0);
24
+ });
25
+
26
+ test('rejects malformed fallback settings', () => {
27
+ expect(() => validateRecipe(recipe({ compressionRefusalCurveFallbacks: -1 })))
28
+ .toThrow(/compressionRefusalCurveFallbacks/);
29
+ expect(() => validateRecipe(recipe({ compressionRefusalCurveFallbacks: 1.5 })))
30
+ .toThrow(/compressionRefusalCurveFallbacks/);
31
+ expect(() => validateRecipe(recipe({ compressionContextBudgetTokens: 0 })))
32
+ .toThrow(/compressionContextBudgetTokens/);
33
+ expect(() => validateRecipe(recipe({ compressionContextBudgetTokens: '200000' })))
34
+ .toThrow(/compressionContextBudgetTokens/);
35
+ });
36
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { validateRecipe } from '../src/recipe.js';
3
+
4
+ function recipe(primarySummaryFallback?: Record<string, unknown>) {
5
+ return {
6
+ name: 'primary-summary-fallback-test',
7
+ agent: {
8
+ systemPrompt: 'sys',
9
+ refusalHandling: primarySummaryFallback
10
+ ? { primarySummaryFallback }
11
+ : undefined,
12
+ },
13
+ };
14
+ }
15
+
16
+ describe('recipe primary summary fallback validation', () => {
17
+ test('leaves the fallback disabled by default when omitted', () => {
18
+ const parsed = validateRecipe(recipe());
19
+ expect(parsed.agent.refusalHandling?.primarySummaryFallback).toBeUndefined();
20
+ });
21
+
22
+ test('preserves valid fallback settings', () => {
23
+ const parsed = validateRecipe(recipe({
24
+ enabled: true,
25
+ maxNewSummaries: 4,
26
+ requestBudgetTokens: 216_000,
27
+ }));
28
+ expect(parsed.agent.refusalHandling?.primarySummaryFallback).toEqual({
29
+ enabled: true,
30
+ maxNewSummaries: 4,
31
+ requestBudgetTokens: 216_000,
32
+ });
33
+ });
34
+
35
+ test('rejects malformed fallback settings', () => {
36
+ expect(() => validateRecipe(recipe({ enabled: 'yes' }))).toThrow(/primarySummaryFallback\.enabled/);
37
+ expect(() => validateRecipe(recipe({ maxNewSummaries: -1 }))).toThrow(/maxNewSummaries/);
38
+ expect(() => validateRecipe(recipe({ requestBudgetTokens: 0 }))).toThrow(/requestBudgetTokens/);
39
+ });
40
+ });
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { validateRecipe } from '../src/recipe.js';
3
+
4
+ function recipe(agent: Record<string, unknown> = {}) {
5
+ return { name: 'provider-test', agent: { systemPrompt: 'sys', ...agent } };
6
+ }
7
+
8
+ describe('recipe provider validation', () => {
9
+ test('preserves Anthropic as the omitted provider and accepts Responses', () => {
10
+ expect(validateRecipe(recipe()).agent.provider).toBeUndefined();
11
+ expect(validateRecipe(recipe({ provider: 'openai-responses' })).agent.provider)
12
+ .toBe('openai-responses');
13
+ });
14
+
15
+ test('accepts Responses reasoning and compaction settings', () => {
16
+ expect(validateRecipe(recipe({
17
+ provider: 'openai-responses',
18
+ responses: {
19
+ reasoningEffort: 'xhigh',
20
+ reasoningContext: 'all_turns',
21
+ compactThreshold: 100_000,
22
+ },
23
+ })).agent.responses).toEqual({
24
+ reasoningEffort: 'xhigh',
25
+ reasoningContext: 'all_turns',
26
+ compactThreshold: 100_000,
27
+ });
28
+ });
29
+
30
+ test('rejects unknown providers and malformed Responses settings', () => {
31
+ expect(() => validateRecipe(recipe({ provider: 'openai-chat' }))).toThrow(/agent.provider/);
32
+ expect(() => validateRecipe(recipe({ responses: { reasoningEffort: 'ultra' } }))).toThrow(/reasoningEffort/);
33
+ expect(() => validateRecipe(recipe({ responses: { reasoningContext: 'previous_turn' } }))).toThrow(/reasoningContext/);
34
+ expect(() => validateRecipe(recipe({ responses: { compactThreshold: 0 } }))).toThrow(/compactThreshold/);
35
+ });
36
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { validateRecipe } from '../src/recipe.js';
3
+ import { buildFrameworkAgentConfig } from '../src/framework-agent-config.js';
4
+
5
+ function recipe(agent: Record<string, unknown> = {}) {
6
+ return { name: 'think-policy-test', agent: { systemPrompt: 'sys', ...agent } };
7
+ }
8
+
9
+ describe('recipe sameRoundThinkTextPolicy', () => {
10
+ test('valid public/private values are preserved and passed through to Agent Framework config', () => {
11
+ const publicRecipe = validateRecipe(recipe({ sameRoundThinkTextPolicy: 'public' }));
12
+ expect(publicRecipe.agent.sameRoundThinkTextPolicy).toBe('public');
13
+ expect(
14
+ buildFrameworkAgentConfig(publicRecipe, 'agent', 'model', undefined).sameRoundThinkTextPolicy,
15
+ ).toBe('public');
16
+
17
+ const privateRecipe = validateRecipe(recipe({ sameRoundThinkTextPolicy: 'private' }));
18
+ expect(privateRecipe.agent.sameRoundThinkTextPolicy).toBe('private');
19
+ expect(
20
+ buildFrameworkAgentConfig(privateRecipe, 'agent', 'model', undefined).sameRoundThinkTextPolicy,
21
+ ).toBe('private');
22
+ });
23
+
24
+ test('omitted value stays omitted so Agent Framework can report the compatibility source', () => {
25
+ const parsed = validateRecipe(recipe());
26
+ expect(parsed.agent.sameRoundThinkTextPolicy).toBeUndefined();
27
+ const config = buildFrameworkAgentConfig(parsed, 'agent', 'model', undefined);
28
+ expect(Object.prototype.hasOwnProperty.call(config, 'sameRoundThinkTextPolicy')).toBe(false);
29
+ });
30
+
31
+ test('invalid strings and types are rejected', () => {
32
+ expect(() => validateRecipe(recipe({ sameRoundThinkTextPolicy: 'secret' })))
33
+ .toThrow(/sameRoundThinkTextPolicy/);
34
+ expect(() => validateRecipe(recipe({ sameRoundThinkTextPolicy: true })))
35
+ .toThrow(/sameRoundThinkTextPolicy/);
36
+ expect(() => validateRecipe(recipe({ sameRoundThinkTextPolicy: { mode: 'public' } })))
37
+ .toThrow(/sameRoundThinkTextPolicy/);
38
+ });
39
+ });
@@ -0,0 +1,20 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { validateRecipe } from '../src/recipe.js';
3
+
4
+ function recipe(timezone?: unknown) {
5
+ return { name: 'tz', agent: { systemPrompt: 'test', ...(timezone === undefined ? {} : { timezone }) } };
6
+ }
7
+
8
+ describe('recipe agent.timezone validation', () => {
9
+ test('accepts an IANA timezone', () => {
10
+ expect(validateRecipe(recipe('America/Los_Angeles')).agent.timezone).toBe('America/Los_Angeles');
11
+ });
12
+
13
+ test('allows the setting to be omitted', () => {
14
+ expect(validateRecipe(recipe()).agent.timezone).toBeUndefined();
15
+ });
16
+
17
+ test('rejects invalid timezone names', () => {
18
+ expect(() => validateRecipe(recipe('Pacific/Definitely_Not'))).toThrow(/valid IANA time zone/);
19
+ });
20
+ });
@@ -37,6 +37,7 @@ function ambient(
37
37
  origin: {
38
38
  source: 'discord',
39
39
  channelId,
40
+ mcplChannelId: `discord:g1:${channelId}`,
40
41
  isMention: !!opts.isMention,
41
42
  isDM: !!opts.isDM,
42
43
  },
@@ -59,8 +60,9 @@ describe('SubscriptionGcModule', () => {
59
60
 
60
61
  r = await m.onProcess(ambient('c1', 'ghijkl'), PS); // +6 = 12 > 10 → unsub
61
62
  expect(toolCalls.length).toBe(1);
62
- expect(toolCalls[0].name).toBe('mcpl--discord--unsubscribe_channel');
63
- expect(toolCalls[0].input.channelId).toBe('c1');
63
+ expect(toolCalls[0].name).toBe('channel_close');
64
+ expect(toolCalls[0].input.channelId).toBe('discord:g1:c1');
65
+ expect(toolCalls[0].input.serverId).toBe('discord');
64
66
  expect(r.addMessages?.length).toBe(1);
65
67
 
66
68
  await m.stop();
@@ -100,7 +102,7 @@ describe('SubscriptionGcModule', () => {
100
102
  await m.handleToolCall({
101
103
  id: 't1',
102
104
  name: 'set_channel_idle_limit',
103
- input: { channelId: 'c1', limit: 'off' },
105
+ input: { channelId: 'discord:g1:c1', limit: 'off' },
104
106
  });
105
107
  await m.onProcess(ambient('c1', 'waytoolongambient'), PS);
106
108
  expect(toolCalls.length).toBe(0); // pinned → never unsubscribed
@@ -108,7 +110,7 @@ describe('SubscriptionGcModule', () => {
108
110
  // A different channel still accrues, and state is persisted.
109
111
  await m.onProcess(ambient('c2', 'abc'), PS);
110
112
  const persisted = getState() as { overrides: Record<string, unknown>; counters: Record<string, number> };
111
- expect(persisted.overrides.c1).toBe('off');
113
+ expect(persisted.overrides['discord:g1:c1']).toBe('off');
112
114
 
113
115
  // Simulate restart: a new module loads the persisted state (counters carry
114
116
  // across — a restart is not an activation).
@@ -128,7 +130,7 @@ describe('SubscriptionGcModule', () => {
128
130
  // c2 was at 3; +3 = 6 > 5 → unsubscribe (counter survived the "restart")
129
131
  await m2.onProcess(ambient('c2', 'def'), PS);
130
132
  expect(restart.toolCalls.length).toBe(1);
131
- expect(restart.toolCalls[0].input.channelId).toBe('c2');
133
+ expect(restart.toolCalls[0].input.channelId).toBe('discord:g1:c2');
132
134
 
133
135
  await m.stop();
134
136
  await m2.stop();
@@ -0,0 +1,13 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { formatNow } from '../src/modules/time-module.js';
3
+
4
+ describe('TimeModule presentation timezone', () => {
5
+ test('formats current time independently of the host timezone', () => {
6
+ expect(formatNow(new Date('2026-07-15T12:34:56.789Z'), 'America/Los_Angeles')).toEqual({
7
+ iso: '2026-07-15T05:34:56.789-07:00',
8
+ local: '2026-07-15T05:34:56.789-07:00 [America/Los_Angeles]',
9
+ timezone: 'America/Los_Angeles',
10
+ unixMs: 1_784_118_896_789,
11
+ });
12
+ });
13
+ });
@@ -174,17 +174,65 @@ describe('scope filters', () => {
174
174
  expect(w.history.startIndex).toBe(240);
175
175
  expect(w.usage.input).toBe(1); // structure/usage survive
176
176
  });
177
+
178
+ // Health-gated telemetry must be masked exactly like its live frames:
179
+ // `usage` and `call-ledger` pushes require the 'health' scope, so the
180
+ // welcome cannot hand the same data to a messages-only observer.
181
+ test('scopeWelcome without health strips callLedger/perAgentCost and zeroes usage', () => {
182
+ const welcome = {
183
+ type: 'welcome', protocolVersion: 1,
184
+ messages: [entry],
185
+ history: { startIndex: 40, totalCount: 240 },
186
+ localTree: { asOfTs: 1, nodes: [], callIdIndex: {} },
187
+ childTrees: [],
188
+ usage: { input: 1, output: 2, cacheRead: 3, cacheWrite: 4, cost: { total: 5, currency: 'USD' } },
189
+ perAgentCost: [{ name: 'main', usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 } }],
190
+ callLedger: {
191
+ calls: [{ model: 'secret-model', error: 'raw provider error', costUsd: 0.42 }],
192
+ },
193
+ } as unknown as WelcomeMessage;
194
+
195
+ const w = scopeWelcome(welcome, new Set<ObserverScope>(['messages']));
196
+ expect(w.callLedger).toBeUndefined();
197
+ expect(w.perAgentCost).toBeUndefined();
198
+ expect(w.usage).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
199
+ expect(w.messages.length).toBe(1); // messages scope still gets content
200
+ // No trace of the ledger anywhere in the serialized frame.
201
+ expect(JSON.stringify(w)).not.toContain('secret-model');
202
+ expect(JSON.stringify(w)).not.toContain('raw provider error');
203
+
204
+ // Same masking on the no-messages branch (e.g. ops-only observer).
205
+ const opsOnly = scopeWelcome(welcome, new Set<ObserverScope>(['ops']));
206
+ expect(opsOnly.callLedger).toBeUndefined();
207
+ expect(opsOnly.perAgentCost).toBeUndefined();
208
+ expect(opsOnly.usage).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
209
+
210
+ // A health-scoped observer keeps the telemetry verbatim.
211
+ const h = scopeWelcome(welcome, new Set<ObserverScope>(['health']));
212
+ expect(h.callLedger).toEqual(welcome.callLedger);
213
+ expect(h.perAgentCost).toEqual(welcome.perAgentCost);
214
+ expect(h.usage).toEqual(welcome.usage);
215
+
216
+ // Input welcome is never mutated (shared across clients in sendWelcome).
217
+ expect(welcome.callLedger).toBeDefined();
218
+ expect(welcome.usage.input).toBe(1);
219
+ });
177
220
  });
178
221
 
179
222
  describe('ObserverSessions', () => {
180
- test('mint/lookup round-trip; bad token null', () => {
223
+ test('mint/lookup round-trip; bad token null; full flag carried', () => {
181
224
  const s = new ObserverSessions();
182
225
  const scopes = new Set<ObserverScope>(['health']);
183
226
  const token = s.mint(scopes);
184
227
  expect(token).toMatch(/^[a-f0-9]{64}$/);
185
- expect(s.lookup(token)).toBe(scopes);
228
+ expect(s.lookup(token)!.scopes).toBe(scopes);
229
+ expect(s.lookup(token)!.full).toBe(false);
186
230
  expect(s.lookup('0'.repeat(64))).toBeNull();
187
231
  expect(s.lookup(null)).toBeNull();
232
+
233
+ // Full sessions (password sign-in path) carry operator authority.
234
+ const fullToken = s.mint(new Set(), { full: true });
235
+ expect(s.lookup(fullToken)!.full).toBe(true);
188
236
  });
189
237
  });
190
238
 
@@ -289,6 +337,26 @@ describe('WebUiModule observer flow (e2e)', () => {
289
337
  ws.close();
290
338
  }, 15_000);
291
339
 
340
+ test('password sign-in mints a full-session cookie the WS upgrade honors', async () => {
341
+ // /auth/basic with valid credentials → 302 + full-session cookie.
342
+ const res = await fetch(`${base()}/auth/basic`, { headers: { authorization: BASIC }, redirect: 'manual' });
343
+ expect(res.status).toBe(302);
344
+ const m = /fkm_obs=([a-f0-9]{64})/.exec(res.headers.get('set-cookie') ?? '');
345
+ expect(m).not.toBeNull();
346
+
347
+ // A WS upgrade carrying ONLY the cookie (no Authorization — the Chrome
348
+ // situation) must be treated as a FULL client: no observer-auth-required
349
+ // demand. (Full clients are parked silently in this app-less harness;
350
+ // the observer path would send auth-required immediately.)
351
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`, { headers: { cookie: `fkm_obs=${m![1]}` } } as never);
352
+ const firstFrame = await Promise.race([
353
+ new Promise<string>((r) => ws.addEventListener('message', (ev) => r(String((ev as MessageEvent).data)))),
354
+ new Promise<string>((r) => setTimeout(() => r('SILENCE'), 1500)),
355
+ ]);
356
+ ws.close();
357
+ expect(firstFrame).toBe('SILENCE'); // not observer-auth-required
358
+ }, 10_000);
359
+
292
360
  test('wrong key: hello rejected and socket closed', async () => {
293
361
  const stranger = makeKeypair();
294
362
  const host = `127.0.0.1:${port}`;