@animalabs/connectome-host 0.3.7 → 0.3.9

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.
@@ -0,0 +1,52 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ build-and-test:
14
+ name: Build & Test (${{ matrix.os }})
15
+ runs-on: ${{ matrix.os }}
16
+ strategy:
17
+ fail-fast: false
18
+ # Both platforms on purpose: a package-lock.json regenerated over an
19
+ # existing node_modules tree records only that machine's native binaries
20
+ # (esbuild/rollup/tailwind-oxide), so a lock made on macOS breaks Linux
21
+ # installs and vice versa. Each runner catches the lock the other's
22
+ # platform produced. macOS runs the install+build lock guard only;
23
+ # the full test suite runs on ubuntu.
24
+ matrix:
25
+ os: [ubuntu-latest, macos-latest]
26
+
27
+ steps:
28
+ - uses: actions/checkout@v6
29
+
30
+ - name: Setup Node.js
31
+ uses: actions/setup-node@v6
32
+ with:
33
+ node-version: 24
34
+
35
+ - name: Setup Bun
36
+ uses: oven-sh/setup-bun@v2
37
+ with:
38
+ bun-version: 1.3.14
39
+
40
+ - name: Install dependencies
41
+ run: npm install
42
+
43
+ # npm ci is the actual lockfile guard: `npm install` (npm >= 11)
44
+ # silently re-resolves platform packages missing from the lock, so a
45
+ # broken lock sails through it. npm ci installs the lock verbatim and
46
+ # the vite build then fails loudly on the missing rollup binary.
47
+ - name: Build web assets (strict lockfile)
48
+ run: npm run build:web:ci
49
+
50
+ - name: Test
51
+ if: matrix.os == 'ubuntu-latest'
52
+ run: npm test
@@ -31,7 +31,7 @@ jobs:
31
31
  run: npm install
32
32
 
33
33
  - name: Build web assets
34
- run: npm run build:web
34
+ run: npm run build:web:ci
35
35
 
36
36
  - name: Test
37
37
  run: npm test
@@ -64,7 +64,7 @@ jobs:
64
64
  run: npm install
65
65
 
66
66
  - name: Build web assets
67
- run: npm run build:web
67
+ run: npm run build:web:ci
68
68
 
69
69
  - name: Publish
70
70
  run: npm publish --access public --provenance
package/bun.lock CHANGED
@@ -5,10 +5,10 @@
5
5
  "": {
6
6
  "name": "connectome-host",
7
7
  "dependencies": {
8
- "@animalabs/agent-framework": "^0.6.4",
8
+ "@animalabs/agent-framework": "^0.6.8",
9
9
  "@animalabs/chronicle": "^0.2.0",
10
- "@animalabs/context-manager": "^0.5.5",
11
- "@animalabs/membrane": "^0.5.71",
10
+ "@animalabs/context-manager": "^0.5.13",
11
+ "@animalabs/membrane": "^0.5.73",
12
12
  "@opentui/core": "^0.1.82",
13
13
  },
14
14
  "devDependencies": {
@@ -19,13 +19,13 @@
19
19
  },
20
20
  },
21
21
  "packages": {
22
- "@animalabs/agent-framework": ["@animalabs/agent-framework@0.6.4", "", { "dependencies": { "@animalabs/chronicle": "^0.2.2", "@animalabs/context-manager": "^0.5.5", "@animalabs/membrane": "^0.5.64", "chokidar": "^4.0.3", "discord.js": "^14.25.1", "ws": "^8.18.0" }, "bin": { "agent-framework-mcp": "dist/src/api/mcp-server.js" } }, "sha512-jLDNNrE7rEI017loFwIx2zTxTBQDwXgo5taqGBrG84Ve0Kaiykj5Cm8pnGm8Mlidl2FtXp5v1NqpRntwZT2idA=="],
22
+ "@animalabs/agent-framework": ["@animalabs/agent-framework@0.6.8", "", { "dependencies": { "@animalabs/chronicle": "^0.2.2", "@animalabs/context-manager": "^0.5.13", "@animalabs/membrane": "^0.5.73", "chokidar": "^4.0.3", "discord.js": "^14.25.1", "ws": "^8.18.0" }, "bin": { "agent-framework-mcp": "dist/src/api/mcp-server.js", "agent-framework-recover": "dist/src/recovery/recover-cli.js" } }, "sha512-hLu+DI0Xj40zSRm+KmjEZO/uzizd2Gbaiih4IGNXJJZf0mcIxYDbOynEMpaLHtXGYMS8eYFmQK02ytdL9thtoA=="],
23
23
 
24
24
  "@animalabs/chronicle": ["@animalabs/chronicle@0.2.1", "", { "optionalDependencies": { "@animalabs/chronicle-darwin-arm64": "0.2.1", "@animalabs/chronicle-darwin-x64": "0.2.1", "@animalabs/chronicle-linux-arm64-gnu": "0.2.1", "@animalabs/chronicle-linux-x64-gnu": "0.2.1", "@animalabs/chronicle-win32-x64-msvc": "0.2.1" } }, "sha512-rDFc069yfi7BQUusgsXBwBdqljUWLByXSYaTF40AP+tonBh0No3eF9VzKTSy1I8DYg/jAUHIKtVdKDGsVMVubw=="],
25
25
 
26
- "@animalabs/context-manager": ["@animalabs/context-manager@0.5.5", "", { "dependencies": { "@animalabs/chronicle": "^0.2.0", "@animalabs/membrane": "^0.5.64" } }, "sha512-KWTluwU5VIZKIPF/IdfUbkvPNZ5zMxSfZjaxZb81IXe+DBPmjHuUVNIqgXIQ2Mv1pCn1vCNqieZ/iSrc6dFH8w=="],
26
+ "@animalabs/context-manager": ["@animalabs/context-manager@0.5.13", "", { "dependencies": { "@animalabs/chronicle": "^0.2.6", "@animalabs/membrane": "^0.5.69" } }, "sha512-1NOsyCeiJoAa4MrBSGw1zbbs2XfvhRbkMfkFNCNFH9gSFTq/4tMwMu6lmZ082oNeB9FvptGrjCuZdVP8B9/UdA=="],
27
27
 
28
- "@animalabs/membrane": ["@animalabs/membrane@0.5.71", "", { "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } }, "sha512-Jos4Fh/kIbpOiJ43LKIFpmaCmWIzE6ly1x/4c5iHaMw8n6D8OJ2f4va7xo3vNdvfKWBbln4QghmNg4NWo3oFGA=="],
28
+ "@animalabs/membrane": ["@animalabs/membrane@0.5.73", "", { "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } }, "sha512-w1rt/JVOD6dWwEO54tfR0yrLzwwD45cpYJ9hWJhHxs8k9FvW1PgIIV1KmNFNbpY8MR9HC5g7Lqgvhzs3pDst+g=="],
29
29
 
30
30
  "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.52.0", "", { "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-d4c+fg+xy9e46c8+YnrrgIQR45CZlAi7PwdzIfDXDM6ACxEZli1/fxhURsq30ZpMZy6LvSkr41jGq5aF5TD7rQ=="],
31
31
 
@@ -277,7 +277,7 @@
277
277
 
278
278
  "@animalabs/agent-framework/@animalabs/chronicle": ["@animalabs/chronicle@0.2.6", "", { "optionalDependencies": { "@animalabs/chronicle-darwin-arm64": "0.2.6", "@animalabs/chronicle-darwin-x64": "0.2.6", "@animalabs/chronicle-linux-arm64-gnu": "0.2.6", "@animalabs/chronicle-linux-x64-gnu": "0.2.6", "@animalabs/chronicle-win32-x64-msvc": "0.2.6" } }, "sha512-+gbt2GR4SP8MnKxeqkJZf6oZn339fLiBtk6GHyBD/gHQ4e4eFqBpTaTd5eKycKBws0xWGcOqyiJFH4IdBkweUA=="],
279
279
 
280
- "@animalabs/context-manager/@animalabs/membrane": ["@animalabs/membrane@0.5.68", "", { "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } }, "sha512-MfSERy0ECxdBZ4NelHdZbqsP/ORWJsfeAI6vuyy6gP77aGcdJL8qf4XrUkOox5yZa5MqB50y0K58Ffv79jCnbg=="],
280
+ "@animalabs/context-manager/@animalabs/chronicle": ["@animalabs/chronicle@0.2.6", "", { "optionalDependencies": { "@animalabs/chronicle-darwin-arm64": "0.2.6", "@animalabs/chronicle-darwin-x64": "0.2.6", "@animalabs/chronicle-linux-arm64-gnu": "0.2.6", "@animalabs/chronicle-linux-x64-gnu": "0.2.6", "@animalabs/chronicle-win32-x64-msvc": "0.2.6" } }, "sha512-+gbt2GR4SP8MnKxeqkJZf6oZn339fLiBtk6GHyBD/gHQ4e4eFqBpTaTd5eKycKBws0xWGcOqyiJFH4IdBkweUA=="],
281
281
 
282
282
  "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
283
283
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/connectome-host",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "General-purpose agent TUI host with recipe-based configuration",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -8,13 +8,15 @@
8
8
  "dev": "bun --watch src/index.ts",
9
9
  "test": "bun test",
10
10
  "build:web": "npm --prefix web install && npm --prefix web run build",
11
+ "build:web:ci": "npm ci --prefix web && npm --prefix web run build",
12
+ "relock:web": "rm -rf web/node_modules web/package-lock.json && npm --prefix web install",
11
13
  "postinstall": "test -d web && npm --prefix web install && npm --prefix web run build || true"
12
14
  },
13
15
  "dependencies": {
14
- "@animalabs/agent-framework": "^0.6.4",
16
+ "@animalabs/agent-framework": "^0.6.8",
15
17
  "@animalabs/chronicle": "^0.2.0",
16
- "@animalabs/context-manager": "^0.5.5",
17
- "@animalabs/membrane": "^0.5.71",
18
+ "@animalabs/context-manager": "^0.5.13",
19
+ "@animalabs/membrane": "^0.5.73",
18
20
  "@opentui/core": "^0.1.82"
19
21
  },
20
22
  "devDependencies": {
@@ -0,0 +1,51 @@
1
+ import { AgentFramework } from '@animalabs/agent-framework';
2
+ import type { Recipe } from './recipe.js';
3
+
4
+ type AgentConfig = Parameters<typeof AgentFramework.create>[0]['agents'][number];
5
+
6
+ export type FrameworkAgentConfig = AgentConfig & {
7
+ // Forward recipe fields that newer Agent Framework releases understand
8
+ // while remaining structurally compatible with older installs.
9
+ refusalHandling?: Recipe['agent']['refusalHandling'];
10
+ sameRoundThinkTextPolicy?: 'public' | 'private';
11
+ };
12
+
13
+ export function buildFrameworkAgentConfig(
14
+ recipe: Recipe,
15
+ agentName: string,
16
+ model: string,
17
+ strategy: FrameworkAgentConfig['strategy'],
18
+ ): FrameworkAgentConfig {
19
+ return {
20
+ name: agentName,
21
+ model,
22
+ systemPrompt: recipe.agent.systemPrompt,
23
+ maxTokens: recipe.agent.maxTokens ?? 16384,
24
+ maxStreamTokens: recipe.agent.maxStreamTokens ?? 150000,
25
+ contextBudgetTokens: recipe.agent.contextBudgetTokens,
26
+ ...(recipe.agent.cacheTtl && { cacheTtl: recipe.agent.cacheTtl }),
27
+ ...(recipe.agent.provider === 'openai-responses' && {
28
+ providerParams: {
29
+ reasoning: {
30
+ effort: recipe.agent.responses?.reasoningEffort ?? 'high',
31
+ context: recipe.agent.responses?.reasoningContext ?? 'all_turns',
32
+ },
33
+ ...(recipe.agent.responses?.serviceTier ? {
34
+ service_tier: recipe.agent.responses.serviceTier,
35
+ } : {}),
36
+ ...(recipe.agent.responses?.compactThreshold ? {
37
+ context_management: [{
38
+ type: 'compaction',
39
+ compact_threshold: recipe.agent.responses.compactThreshold,
40
+ }],
41
+ } : {}),
42
+ },
43
+ }),
44
+ strategy,
45
+ ...(recipe.agent.thinking && { thinking: recipe.agent.thinking }),
46
+ ...(recipe.agent.refusalHandling && { refusalHandling: recipe.agent.refusalHandling }),
47
+ ...(recipe.agent.sameRoundThinkTextPolicy !== undefined
48
+ ? { sameRoundThinkTextPolicy: recipe.agent.sameRoundThinkTextPolicy }
49
+ : {}),
50
+ };
51
+ }
@@ -0,0 +1,70 @@
1
+ import {
2
+ AutobiographicalStrategy,
3
+ PassthroughStrategy,
4
+ type ContextStrategy,
5
+ } from '@animalabs/agent-framework';
6
+ import type { Recipe, RecipeStrategy } from './recipe.js';
7
+ import { FrontdeskStrategy } from './strategies/frontdesk-strategy.js';
8
+
9
+ const PASSTHROUGH_KEYS: ReadonlyArray<keyof RecipeStrategy> = [
10
+ 'enforceBudget',
11
+ 'maxSpeculativeL1s',
12
+ 'compressionRefusalCurveFallbacks',
13
+ 'compressionContextBudgetTokens',
14
+ 'positionedRecallPairs',
15
+ 'recallHeaderTemplate',
16
+ 'targetChunkTokens',
17
+ 'mergeThreshold',
18
+ 'summaryTargetTokens',
19
+ 'l1BudgetTokens',
20
+ 'l2BudgetTokens',
21
+ 'l3BudgetTokens',
22
+ 'toolResultMaxLastN',
23
+ 'toolUseInputMaxTokens',
24
+ 'adaptiveResolution',
25
+ 'kvStableReachTokens',
26
+ 'kvStableQualityGapRatio',
27
+ 'compressionSlackRatio',
28
+ 'overBudgetGraceRatio',
29
+ 'foldingStrategy',
30
+ 'speculativeProduction',
31
+ 'l1HoldbackChunks',
32
+ 'summaryParticipant',
33
+ 'summarySystemPrompt',
34
+ 'summaryUserPrompt',
35
+ 'summaryContextLabel',
36
+ ];
37
+
38
+ export function buildFrameworkStrategy(
39
+ recipe: Recipe,
40
+ model: string,
41
+ timeZone: string,
42
+ ): ContextStrategy {
43
+ const strategyConfig = recipe.agent.strategy;
44
+ const strategyType = strategyConfig?.type ?? 'autobiographical';
45
+ const autobiographicalOpts: Record<string, unknown> = {
46
+ headWindowTokens: strategyConfig?.headWindowTokens ?? 4000,
47
+ recentWindowTokens: strategyConfig?.recentWindowTokens ?? 30000,
48
+ compressionModel: strategyConfig?.compressionModel ?? model,
49
+ autoTickOnNewMessage: true,
50
+ maxMessageTokens: strategyConfig?.maxMessageTokens ?? 10000,
51
+ ...(strategyType === 'frontdesk' ? { timeZone } : {}),
52
+ };
53
+
54
+ for (const key of PASSTHROUGH_KEYS) {
55
+ const value = strategyConfig?.[key];
56
+ if (value !== undefined) autobiographicalOpts[key] = value;
57
+ }
58
+
59
+ // Autobiographical agents default to adaptive resolution unless a recipe
60
+ // opts out; frontdesk keeps its historical hierarchical renderer default.
61
+ if (strategyType === 'autobiographical' && autobiographicalOpts.adaptiveResolution === undefined) {
62
+ autobiographicalOpts.adaptiveResolution = true;
63
+ }
64
+
65
+ return strategyType === 'passthrough'
66
+ ? new PassthroughStrategy()
67
+ : strategyType === 'frontdesk'
68
+ ? new FrontdeskStrategy(autobiographicalOpts)
69
+ : new AutobiographicalStrategy(autobiographicalOpts);
70
+ }
package/src/index.ts CHANGED
@@ -24,15 +24,14 @@ import {
24
24
  import { LoggingAnthropicAdapter } from './logging-adapter.js';
25
25
  import { CallLedger } from './call-ledger.js';
26
26
  import { SettingsModule } from './modules/settings-module.js';
27
- import { AgentFramework, AutobiographicalStrategy, PassthroughStrategy, WorkspaceModule, resolveTimeZone, type Module, type MountConfig } from '@animalabs/agent-framework';
27
+ import { AgentFramework, WorkspaceModule, resolveTimeZone, type Module, type MountConfig } from '@animalabs/agent-framework';
28
28
  import { resolve, join, basename } from 'node:path';
29
29
  import { appendFile, mkdir, stat, rename } from 'node:fs/promises';
30
30
  import { readFileSync, existsSync } from 'node:fs';
31
- import { FrontdeskStrategy } from './strategies/frontdesk-strategy.js';
32
31
  import { SubagentModule } from './modules/subagent-module.js';
33
32
  import { LessonsModule } from './modules/lessons-module.js';
34
33
  import { RetrievalModule } from './modules/retrieval-module.js';
35
- import type { RecipeWorkspaceMount, RecipeStrategy } from './recipe.js';
34
+ import type { RecipeWorkspaceMount } from './recipe.js';
36
35
  import { TuiModule } from './modules/tui-module.js';
37
36
  import { TimeModule } from './modules/time-module.js';
38
37
  import { FleetModule, type FleetModuleConfig } from './modules/fleet-module.js';
@@ -56,6 +55,8 @@ import {
56
55
  parseRecipeArg,
57
56
  } from './recipe.js';
58
57
  import { createBranchState, resetBranchState, handleExport, type BranchState } from './commands.js';
58
+ import { buildFrameworkAgentConfig } from './framework-agent-config.js';
59
+ import { buildFrameworkStrategy } from './framework-strategy.js';
59
60
 
60
61
  export type { AppContext };
61
62
 
@@ -400,102 +401,14 @@ async function createFramework(
400
401
  // No server augmentation needed — gate is wired via FrameworkConfig.gate
401
402
 
402
403
  // -- Build strategy --
403
- //
404
- // Build the options object with typed property access — no
405
- // `Record<string, unknown>` cast on `strategyConfig`. Every field we
406
- // forward is declared on `RecipeStrategy` (see recipe.ts); a typo in a
407
- // recipe (e.g. `l1BudgetTokes`) now fails at recipe validation rather
408
- // than silently being a no-op at strategy construction. AutobiographicalStrategy
409
- // and FrontdeskStrategy share this option bag today; if strategy-specific
410
- // fields are ever added, this should split into per-strategy types.
411
- const strategyConfig = recipe.agent.strategy;
412
- const strategyType = strategyConfig?.type ?? 'autobiographical';
413
- const autobiographicalOpts: Record<string, unknown> = {
414
- headWindowTokens: strategyConfig?.headWindowTokens ?? 4000,
415
- recentWindowTokens: strategyConfig?.recentWindowTokens ?? 30000,
416
- compressionModel: strategyConfig?.compressionModel ?? model,
417
- autoTickOnNewMessage: true,
418
- maxMessageTokens: strategyConfig?.maxMessageTokens ?? 10000,
419
- ...(strategyType === 'frontdesk' ? { timeZone } : {}),
420
- };
421
- // Forward optional tuning fields when set. The key list is typed
422
- // against `RecipeStrategy`, so an unknown field name is a compile
423
- // error here rather than a silent no-op at runtime.
424
- const passthroughKeys: ReadonlyArray<keyof RecipeStrategy> = [
425
- 'enforceBudget',
426
- 'maxSpeculativeL1s',
427
- 'positionedRecallPairs',
428
- 'recallHeaderTemplate',
429
- 'targetChunkTokens',
430
- 'mergeThreshold',
431
- 'summaryTargetTokens',
432
- 'l1BudgetTokens',
433
- 'l2BudgetTokens',
434
- 'l3BudgetTokens',
435
- 'toolResultMaxLastN',
436
- 'toolUseInputMaxTokens',
437
- 'adaptiveResolution',
438
- 'kvStableReachTokens',
439
- 'kvStableQualityGapRatio',
440
- 'compressionSlackRatio',
441
- 'overBudgetGraceRatio',
442
- 'foldingStrategy',
443
- 'speculativeProduction',
444
- 'l1HoldbackChunks',
445
- 'summaryParticipant',
446
- 'summarySystemPrompt',
447
- 'summaryUserPrompt',
448
- 'summaryContextLabel',
449
- ];
450
- for (const key of passthroughKeys) {
451
- const v = strategyConfig?.[key];
452
- if (v !== undefined) autobiographicalOpts[key] = v;
453
- }
454
- // Adaptive resolution (document-based gradual compression) is the intended
455
- // default for autobiographical agents. Frontdesk keeps the hierarchical
456
- // renderer (its salience-biased L1 selection); it can still opt in via the
457
- // recipe. A recipe may set `adaptiveResolution: false` to opt back out.
458
- if (strategyType === 'autobiographical' && autobiographicalOpts.adaptiveResolution === undefined) {
459
- autobiographicalOpts.adaptiveResolution = true;
460
- }
461
- const strategy = strategyType === 'passthrough'
462
- ? new PassthroughStrategy()
463
- : strategyType === 'frontdesk'
464
- ? new FrontdeskStrategy(autobiographicalOpts)
465
- : new AutobiographicalStrategy(autobiographicalOpts);
404
+ const strategy = buildFrameworkStrategy(recipe, model, timeZone);
405
+ const agentConfig = buildFrameworkAgentConfig(recipe, agentName, model, strategy);
466
406
 
467
407
  // -- Create framework --
468
408
  const framework = await AgentFramework.create({
469
409
  storePath,
470
410
  membrane,
471
- agents: [
472
- {
473
- name: agentName,
474
- model,
475
- systemPrompt: recipe.agent.systemPrompt,
476
- maxTokens: recipe.agent.maxTokens ?? 16384,
477
- maxStreamTokens: recipe.agent.maxStreamTokens ?? 150000,
478
- contextBudgetTokens: recipe.agent.contextBudgetTokens,
479
- ...(recipe.agent.cacheTtl && { cacheTtl: recipe.agent.cacheTtl }),
480
- ...(recipe.agent.provider === 'openai-responses' && {
481
- providerParams: {
482
- reasoning: {
483
- effort: recipe.agent.responses?.reasoningEffort ?? 'high',
484
- context: recipe.agent.responses?.reasoningContext ?? 'all_turns',
485
- },
486
- ...(recipe.agent.responses?.compactThreshold ? {
487
- context_management: [{
488
- type: 'compaction',
489
- compact_threshold: recipe.agent.responses.compactThreshold,
490
- }],
491
- } : {}),
492
- },
493
- }),
494
- strategy,
495
- ...(recipe.agent.thinking && { thinking: recipe.agent.thinking }),
496
- ...(recipe.agent.refusalHandling && { refusalHandling: recipe.agent.refusalHandling }),
497
- },
498
- ],
411
+ agents: [agentConfig],
499
412
  modules: moduleInstances,
500
413
  mcplServers: finalServers,
501
414
  gate: gateOptions,
@@ -503,6 +416,45 @@ async function createFramework(
503
416
  });
504
417
 
505
418
  // Wire post-creation hooks
419
+ // Compression-quarantine klaxon → the framework's ops-alert channel
420
+ // (failures.log + ops:alert trace + CONNECTOME_OPS_WEBHOOK). The strategy
421
+ // re-fires this every alarm interval for as long as ANY chunk is
422
+ // quarantined: quarantined spans stay raw, the fold floor creeps, and the
423
+ // picker eventually cannot fit the window — a guaranteed future outage
424
+ // that must never be a silent state.
425
+ // Duck-typed on both sides so version skew in either dep degrades to a
426
+ // no-op (the strategy's own stderr klaxon still fires) instead of a crash.
427
+ {
428
+ const alarmCapable = strategy as unknown as {
429
+ setQuarantineAlarmHandler?: (fn: (status: { count: number; keys: string[] }) => void) => void;
430
+ };
431
+ const notify = (framework as unknown as {
432
+ notifyOps?: (kind: string, agent: string, message: string, data?: Record<string, unknown>) => void;
433
+ }).notifyOps?.bind(framework);
434
+ if (alarmCapable.setQuarantineAlarmHandler && notify) {
435
+ alarmCapable.setQuarantineAlarmHandler((status) => {
436
+ if (status.count === 0) {
437
+ // All-clear travels the same channel the alarm did, under a
438
+ // DISTINCT kind: the alarm kind's 15-min ops cooldown must never
439
+ // swallow the stand-down (silence after an alarm is ambiguous).
440
+ notify(
441
+ 'compression-quarantine-clear',
442
+ agentName,
443
+ 'compression quarantine EMPTY — all debt paid; alarm stands down.',
444
+ { count: 0 },
445
+ );
446
+ return;
447
+ }
448
+ notify(
449
+ 'compression-quarantine',
450
+ agentName,
451
+ `${status.count} chunk(s) in compression quarantine — spans stay raw and WILL eventually exhaust the context budget. Operator action required (inspect refusing content; branch, pin, or clear).`,
452
+ { count: status.count, keys: status.keys },
453
+ );
454
+ });
455
+ }
456
+ }
457
+
506
458
  if (subagentModule) {
507
459
  subagentModule.setFramework(framework);
508
460
  }
@@ -905,6 +857,28 @@ async function main() {
905
857
  },
906
858
  };
907
859
 
860
+ // Off-path refusal dragnet → ops alerts (observability M3): refusals on
861
+ // non-streamed calls (compression/summarizer drains, maintenance) never
862
+ // reach the framework's own noteRefusal — the 2026-07-15 mythos cascade
863
+ // started exactly there, silently. Surface them through the same
864
+ // opsAlert pipeline (failures.log + ops:alert trace + throttled webhook).
865
+ // Reads app.framework (not the closure) so session switches stay wired;
866
+ // feature-detects notifyOpsAlert for older framework versions.
867
+ if (adapter instanceof LoggingAnthropicAdapter) {
868
+ adapter.onRefusal = (info) => {
869
+ const fw = app.framework as unknown as {
870
+ notifyOpsAlert?: (kind: string, agent: string, msg: string, data?: Record<string, unknown>) => void;
871
+ };
872
+ fw.notifyOpsAlert?.(
873
+ 'refusal-offpath',
874
+ app.agentName,
875
+ `off-path refusal (category=${info.category ?? 'unknown'}) on a ${info.messages}-message ` +
876
+ `complete() call (~${Math.round(info.inputTokens / 1000)}k tok) — likely compression/summarizer`,
877
+ { ...info },
878
+ );
879
+ };
880
+ }
881
+
908
882
  framework.start();
909
883
  setupSynesthete(app);
910
884
  setupMcplStderrLog(app, storePath);
@@ -30,8 +30,8 @@ import { appendFileSync } from 'node:fs';
30
30
  import { summarizeCacheControls, type ProviderCallRecord } from './call-ledger.js';
31
31
 
32
32
  /** Live read of the current reasoning setting. The host wires this to
33
- * `SettingsModule.getReasoning()` so toggles via the `settings--reasoning_*`
34
- * tools take effect on the next call without restart. */
33
+ * `SettingsModule.getReasoning()` so toggles via the `agent_settings` tool's
34
+ * reasoning_enabled field take effect on the next call without restart. */
35
35
  export type ReasoningGetter = () => { enabled: boolean; budgetTokens: number };
36
36
  export type ProviderCallObserver = (record: ProviderCallRecord) => void;
37
37
 
@@ -95,7 +95,7 @@ export class LoggingAnthropicAdapter extends AnthropicAdapter {
95
95
  // form with: `"thinking.type.enabled" is not supported for this model.
96
96
  // Use "thinking.type.adaptive"`. Under adaptive the model sizes its own
97
97
  // thinking, so `budgetTokens` is no longer sent (it still shows in
98
- // reasoning_status as an informational hint).
98
+ // agent_settings as an informational hint).
99
99
  //
100
100
  // membrane's `ProviderRequest` doesn't type a top-level `thinking` field
101
101
  // (membrane is 0.5.68 — agent-framework is the package that went 0.6.0;
@@ -131,6 +131,24 @@ export class LoggingAnthropicAdapter extends AnthropicAdapter {
131
131
  };
132
132
  }
133
133
 
134
+ /**
135
+ * Off-path refusal dragnet (observability M3). The main inference driver
136
+ * instruments its own refusals (agent-framework noteRefusal), but that
137
+ * covers ONLY streamed agent turns. Every other model call in the process
138
+ * — compression/summarizer drains, maintenance — flows through this
139
+ * adapter's complete() and previously refused in silence; the 2026-07-15
140
+ * mythos cascade STARTED there and alerted nowhere. Fired for
141
+ * kind==='complete' only: agent turns stream, so this never double-reports
142
+ * a refusal the main driver already escalated.
143
+ */
144
+ onRefusal?: (info: {
145
+ kind: 'complete' | 'stream';
146
+ category?: string;
147
+ model: string;
148
+ messages: number;
149
+ inputTokens: number;
150
+ }) => void;
151
+
134
152
  private observeCall(
135
153
  kind: 'complete' | 'stream',
136
154
  timestamp: string,
@@ -140,6 +158,18 @@ export class LoggingAnthropicAdapter extends AnthropicAdapter {
140
158
  response?: ProviderResponse,
141
159
  error?: unknown,
142
160
  ): void {
161
+ const raw0 = (response as { raw?: { stop_reason?: string; stop_details?: { category?: string } } } | undefined)?.raw;
162
+ if (kind === 'complete' && raw0?.stop_reason === 'refusal' && this.onRefusal) {
163
+ try {
164
+ this.onRefusal({
165
+ kind,
166
+ category: raw0.stop_details?.category,
167
+ model: request.model,
168
+ messages: request.messages.length,
169
+ inputTokens: response?.usage.inputTokens ?? 0,
170
+ });
171
+ } catch { /* observers never affect provider traffic */ }
172
+ }
143
173
  if (!this.onCall) return;
144
174
  const cache = summarizeCacheControls(rawRequest);
145
175
  const raw = (response as { raw?: Record<string, unknown> } | undefined)?.raw;
@@ -25,6 +25,7 @@ export interface ServerFileEntry {
25
25
  disabledFeatureSets?: string[];
26
26
  enabledTools?: string[];
27
27
  disabledTools?: string[];
28
+ /** @deprecated One-time migration input for legacy installations. */
28
29
  channelSubscription?: 'auto' | 'manual' | string[];
29
30
  }
30
31
 
@@ -7,22 +7,22 @@
7
7
  * have to remember (and keep in sync) all three:
8
8
  *
9
9
  * debounced (wake on every message, batched):
10
- * 1. subscribe to the channel's ambient traffic (`<toolPrefix>--subscribe_channel`)
10
+ * 1. open the channel (`channel_open`)
11
11
  * 2. upsert a per-channel gate policy that DEBOUNCES the ambient tag
12
12
  * (framework.addGatePolicy → gate.json, hot-reloaded) so a burst wakes the
13
13
  * agent once after it settles
14
14
  * 3. pin subscription-gc to "off" for the channel, else a chatty channel
15
- * auto-unsubscribes itself at the idle-char limit and silently leaves
15
+ * auto-closes itself at the idle-char limit and silently leaves
16
16
  * debounced mode
17
17
  *
18
18
  * mentions (revert to mentions/DMs only):
19
19
  * 1. remove the per-channel debounce gate policy
20
- * 2. unsubscribe from ambient traffic
20
+ * 2. close the channel
21
21
  * 3. restore subscription-gc to its default limit
22
22
  *
23
23
  * The SAME `channelId` string is used for all three subsystems — it's the id the
24
24
  * gate sees on an incoming event (`GateEventInfo.channelId`) and the id
25
- * subscription-gc counts/unsubscribes, so they line up by construction (this is
25
+ * subscription-gc counts/closes, so they line up by construction (this is
26
26
  * exactly how SubscriptionGcModule already operates).
27
27
  *
28
28
  * Steps are best-effort with per-step reporting rather than a hard transaction:
@@ -45,7 +45,7 @@ import type { AgentFramework } from '@animalabs/agent-framework';
45
45
  export interface ChannelModeConfig {
46
46
  /** MCPL server id that owns subscriptions (default 'discord'). */
47
47
  serverId?: string;
48
- /** Tool-name prefix for that server (default `mcpl--<serverId>`). */
48
+ /** @deprecated Lifecycle tools are host-generic; retained for config compatibility. */
49
49
  toolPrefix?: string;
50
50
  /** subscription-gc module name, for pinning the idle limit (default 'subscription-gc'). */
51
51
  gcModuleName?: string;
@@ -69,13 +69,11 @@ export class ChannelModeModule implements Module {
69
69
  private callSeq = 0;
70
70
 
71
71
  private readonly serverId: string;
72
- private readonly toolPrefix: string;
73
72
  private readonly gcModuleName: string;
74
73
  private readonly defaultDebounceMs: number;
75
74
 
76
75
  constructor(config: ChannelModeConfig = {}) {
77
76
  this.serverId = config.serverId ?? 'discord';
78
- this.toolPrefix = config.toolPrefix ?? `mcpl--${this.serverId}`;
79
77
  this.gcModuleName = config.gcModuleName ?? 'subscription-gc';
80
78
  this.defaultDebounceMs =
81
79
  typeof config.defaultDebounceMs === 'number' && config.defaultDebounceMs > 0
@@ -103,12 +101,12 @@ export class ChannelModeModule implements Module {
103
101
  name: 'set_channel_mode',
104
102
  description:
105
103
  'Set how a channel gets your attention, in one step. ' +
106
- '`mode: "debounced"` = wake on EVERY message but batched: it subscribes ' +
104
+ '`mode: "debounced"` = wake on EVERY message but batched: it opens ' +
107
105
  'to the channel, adds a gate rule that debounces its ambient traffic ' +
108
106
  '(one wake after the burst settles), and pins the channel so it is never ' +
109
- 'auto-unsubscribed for being chatty. `mode: "mentions"` reverts to ' +
110
- 'mentions/DMs only: it removes that gate rule, unsubscribes, and restores ' +
111
- 'the default auto-unsubscribe limit. `debounceMs` (100–300000, default ' +
107
+ 'auto-closed for being chatty. `mode: "mentions"` reverts to ' +
108
+ 'direct-address-only: it removes that gate rule, closes, and restores ' +
109
+ 'the default auto-close limit. `debounceMs` (100–300000, default ' +
112
110
  `${this.defaultDebounceMs}) sets the quiet window for debounced mode.`,
113
111
  inputSchema: {
114
112
  type: 'object',
@@ -182,7 +180,7 @@ export class ChannelModeModule implements Module {
182
180
  const steps: StepResult[] = [];
183
181
 
184
182
  // 1. Subscribe to ambient traffic.
185
- steps.push(await this.callToolStep('subscribe', `${this.toolPrefix}--subscribe_channel`, { channelId }));
183
+ steps.push(await this.callToolStep('open', 'channel_open', { channelId, serverId: this.serverId }));
186
184
 
187
185
  // 2. Upsert the per-channel ambient debounce policy (prepend so it beats a
188
186
  // broad ambient defer/debounce — first match wins). Validation lives in
@@ -201,7 +199,7 @@ export class ChannelModeModule implements Module {
201
199
  steps.push({ step: 'gate-policy', ok: false, detail: errMsg(err) });
202
200
  }
203
201
 
204
- // 3. Pin subscription-gc off so a chatty channel doesn't auto-unsubscribe
202
+ // 3. Pin subscription-gc off so a chatty channel doesn't auto-close
205
203
  // itself back out of debounced mode.
206
204
  steps.push(
207
205
  await this.callToolStep('gc-off', `${this.gcModuleName}--set_channel_idle_limit`, {
@@ -224,10 +222,10 @@ export class ChannelModeModule implements Module {
224
222
  steps.push({ step: 'gate-policy', ok: false, detail: errMsg(err) });
225
223
  }
226
224
 
227
- // 2. Unsubscribe from ambient traffic.
228
- steps.push(await this.callToolStep('unsubscribe', `${this.toolPrefix}--unsubscribe_channel`, { channelId }));
225
+ // 2. Close ambient traffic through the generic host lifecycle.
226
+ steps.push(await this.callToolStep('close', 'channel_close', { channelId, serverId: this.serverId }));
229
227
 
230
- // 3. Restore the default auto-unsubscribe limit.
228
+ // 3. Restore the default auto-close limit.
231
229
  steps.push(
232
230
  await this.callToolStep('gc-default', `${this.gcModuleName}--set_channel_idle_limit`, {
233
231
  channelId,