@animalabs/connectome-host 0.3.9 → 0.3.10
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.
- package/.env.example +7 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +26 -0
- package/.github/workflows/changelog.yml +32 -0
- package/.github/workflows/publish.yml +46 -0
- package/CHANGELOG.md +59 -0
- package/CONTRIBUTING.md +126 -0
- package/README.md +52 -5
- package/package.json +2 -1
- package/scripts/release-changelog.ts +32 -0
- package/src/codex-subscription-adapter.ts +938 -0
- package/src/commands.ts +45 -0
- package/src/extensions.ts +201 -0
- package/src/framework-agent-config.ts +17 -9
- package/src/framework-strategy.ts +21 -0
- package/src/index.ts +102 -19
- package/src/logging-bedrock-adapter.ts +145 -0
- package/src/modules/web-ui-module.ts +45 -1
- package/src/recipe.ts +138 -9
- package/src/tui.ts +165 -34
- package/src/web/protocol.ts +35 -0
- package/test/codex-subscription-adapter.test.ts +226 -0
- package/test/fast-command.test.ts +39 -0
- package/test/recipe-extensions.test.ts +295 -0
- package/test/recipe-provider.test.ts +14 -1
- package/test/web-ui-observers.test.ts +14 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/web/src/App.tsx +235 -20
- package/web/src/Branches.tsx +150 -0
- package/web/src/Context.tsx +21 -13
- package/web/src/Health.tsx +274 -0
- package/web/src/Lessons.tsx +2 -1
package/src/commands.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* /clear — Clear conversation display
|
|
15
15
|
* /mcp list|add|remove|env — Manage MCPL server config
|
|
16
16
|
* /budget [N] — Show/set stream token budget (e.g. /budget 1m)
|
|
17
|
+
* /fast [on|off|status] — Toggle Codex subscription Fast mode
|
|
17
18
|
* /session — Session management (list, new, switch, rename, delete)
|
|
18
19
|
* /recipe — Show current recipe info
|
|
19
20
|
* /newtopic — Reset head window (auto-summarize or with user context)
|
|
@@ -37,6 +38,10 @@ interface AppContext {
|
|
|
37
38
|
sessionManager: import('./session-manager.js').SessionManager;
|
|
38
39
|
recipe: Recipe;
|
|
39
40
|
branchState: BranchState;
|
|
41
|
+
codexAdapter?: {
|
|
42
|
+
isFastMode(): boolean;
|
|
43
|
+
setFastMode(enabled: boolean): void;
|
|
44
|
+
};
|
|
40
45
|
switchSession(id: string): Promise<void>;
|
|
41
46
|
}
|
|
42
47
|
|
|
@@ -152,6 +157,7 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
|
|
|
152
157
|
{ text: ' /mcp remove <id> Remove a server', style: 'system' },
|
|
153
158
|
{ text: ' /mcp env <id> K=V ... Set env vars on server', style: 'system' },
|
|
154
159
|
{ text: ' /budget [tokens] Show/set stream token budget', style: 'system' },
|
|
160
|
+
{ text: ' /fast [on|off|status] Toggle Codex subscription Fast mode', style: 'system' },
|
|
155
161
|
{ text: ' /session Show current session', style: 'system' },
|
|
156
162
|
{ text: ' /session list List all sessions', style: 'system' },
|
|
157
163
|
{ text: ' /session new [name] Create new session', style: 'system' },
|
|
@@ -211,6 +217,9 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
|
|
|
211
217
|
case 'budget':
|
|
212
218
|
return handleBudget(framework, args[0]);
|
|
213
219
|
|
|
220
|
+
case 'fast':
|
|
221
|
+
return handleFast(app, args[0]);
|
|
222
|
+
|
|
214
223
|
case 'session':
|
|
215
224
|
return handleSession(app, args);
|
|
216
225
|
|
|
@@ -233,6 +242,42 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
|
|
|
233
242
|
}
|
|
234
243
|
}
|
|
235
244
|
|
|
245
|
+
function handleFast(app: AppContext, mode?: string): CommandResult {
|
|
246
|
+
const adapter = app.codexAdapter;
|
|
247
|
+
if (!adapter) {
|
|
248
|
+
return {
|
|
249
|
+
lines: [{
|
|
250
|
+
text: '/fast is available only when agent.provider is "openai-codex".',
|
|
251
|
+
style: 'system',
|
|
252
|
+
}],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const normalized = mode?.toLowerCase();
|
|
257
|
+
if (normalized === undefined || normalized === 'status') {
|
|
258
|
+
return {
|
|
259
|
+
lines: [{
|
|
260
|
+
text: `Codex Fast mode request is ${adapter.isFastMode() ? 'ON' : 'OFF'}.`,
|
|
261
|
+
style: 'system',
|
|
262
|
+
}],
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
if (normalized !== 'on' && normalized !== 'off') {
|
|
266
|
+
return { lines: [{ text: 'Usage: /fast [on|off|status]', style: 'system' }] };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const enabled = normalized === 'on';
|
|
270
|
+
adapter.setFastMode(enabled);
|
|
271
|
+
return {
|
|
272
|
+
lines: [{
|
|
273
|
+
text: enabled
|
|
274
|
+
? 'Codex Fast mode requested (higher subscription credit consumption when applied).'
|
|
275
|
+
: 'Codex Fast mode request disabled.',
|
|
276
|
+
style: 'system',
|
|
277
|
+
}],
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
236
281
|
// ---------------------------------------------------------------------------
|
|
237
282
|
// /session subcommands
|
|
238
283
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe extensions — the loading seam for deployment-specific code.
|
|
3
|
+
*
|
|
4
|
+
* A recipe may declare an `extensions` block mapping a name to a local
|
|
5
|
+
* TypeScript/JavaScript module that registers custom context strategies
|
|
6
|
+
* and/or framework modules:
|
|
7
|
+
*
|
|
8
|
+
* "extensions": {
|
|
9
|
+
* "zk-strategy": {
|
|
10
|
+
* "kind": "strategy",
|
|
11
|
+
* "path": "./extensions/zk-strategy/index.ts",
|
|
12
|
+
* "config": { "floodWindowMs": 250 }
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* The entry module must export a `register` function (named or default):
|
|
17
|
+
*
|
|
18
|
+
* export function register(api: ExtensionApi, config: Record<string, unknown>) {
|
|
19
|
+
* api.registerStrategy('zk', (ctx) => new ZkStrategy({ ...ctx.config }));
|
|
20
|
+
* api.registerModule((ctx) => new ZkFeederModule(config));
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* Design constraint: this loader is deliberately dumb. It resolves nothing,
|
|
24
|
+
* fetches nothing, and negotiates no versions — it imports a local path and
|
|
25
|
+
* calls `register`. The invariant "this path exists and is compatible with
|
|
26
|
+
* the host's dependency tree" is established at install time by build
|
|
27
|
+
* tooling (connectome-cook), not here. Extensions run in-process and share
|
|
28
|
+
* the host's node_modules, so `extends AutobiographicalStrategy` resolves
|
|
29
|
+
* against the exact @animalabs/* versions the host ships.
|
|
30
|
+
*
|
|
31
|
+
* `kind` declares the extension's primary purpose. It gates recipe
|
|
32
|
+
* validation (a non-built-in `agent.strategy.type` is only accepted when at
|
|
33
|
+
* least one `kind: "strategy"` extension is declared) and informs build
|
|
34
|
+
* tooling; it does NOT restrict what `register` may call — a strategy
|
|
35
|
+
* extension may also register a companion module.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { isAbsolute } from 'node:path';
|
|
39
|
+
import { pathToFileURL } from 'node:url';
|
|
40
|
+
import type { ContextStrategy, Module } from '@animalabs/agent-framework';
|
|
41
|
+
import type { Recipe, RecipeExtension, RecipeStrategy } from './recipe.js';
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Factory contexts
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
export interface StrategyFactoryContext {
|
|
48
|
+
/** The recipe's `agent.strategy` block verbatim — including keys the
|
|
49
|
+
* built-in schema doesn't know about, so custom strategies can define
|
|
50
|
+
* their own knobs without upstream schema changes. */
|
|
51
|
+
config: RecipeStrategy & Record<string, unknown>;
|
|
52
|
+
/** Resolved agent model id (recipe/env fallback chain already applied). */
|
|
53
|
+
model: string;
|
|
54
|
+
/** Resolved IANA time zone for the session. */
|
|
55
|
+
timeZone: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type StrategyFactory = (ctx: StrategyFactoryContext) => ContextStrategy;
|
|
59
|
+
|
|
60
|
+
export interface ModuleFactoryContext {
|
|
61
|
+
timeZone: string;
|
|
62
|
+
/** Session store path (Chronicle data dir) — same value createFramework uses. */
|
|
63
|
+
storePath: string;
|
|
64
|
+
/** Resolved agent model id. */
|
|
65
|
+
model: string;
|
|
66
|
+
/** The declaring extension entry's `config` blob, verbatim. */
|
|
67
|
+
config: Record<string, unknown>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type ModuleFactory = (ctx: ModuleFactoryContext) => Module;
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Registry
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
export interface ExtensionApi {
|
|
77
|
+
/** Register a context-strategy factory under a `agent.strategy.type` name.
|
|
78
|
+
* Built-in names and duplicate registrations are rejected. */
|
|
79
|
+
registerStrategy(type: string, factory: StrategyFactory): void;
|
|
80
|
+
/** Register a framework-module factory. Instantiated after the built-in
|
|
81
|
+
* module list is assembled; if the instance has a `setFramework` method
|
|
82
|
+
* it is called after framework creation (same duck-typed contract the
|
|
83
|
+
* built-in modules use). */
|
|
84
|
+
registerModule(factory: ModuleFactory): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface RegisteredModule {
|
|
88
|
+
/** Name of the extension entry that registered this factory. */
|
|
89
|
+
extensionName: string;
|
|
90
|
+
factory: ModuleFactory;
|
|
91
|
+
/** The extension entry's `config`, passed to the factory context. */
|
|
92
|
+
config: Record<string, unknown>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ExtensionRegistry {
|
|
96
|
+
strategies: Map<string, StrategyFactory>;
|
|
97
|
+
modules: RegisteredModule[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const BUILTIN_STRATEGY_TYPES = ['autobiographical', 'passthrough', 'frontdesk'] as const;
|
|
101
|
+
|
|
102
|
+
export function isBuiltinStrategyType(type: string): boolean {
|
|
103
|
+
return (BUILTIN_STRATEGY_TYPES as readonly string[]).includes(type);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** An empty registry — used when a recipe declares no extensions. */
|
|
107
|
+
export function emptyExtensionRegistry(): ExtensionRegistry {
|
|
108
|
+
return { strategies: new Map(), modules: [] };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Loader
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Import every extension declared by the recipe and collect registrations.
|
|
117
|
+
*
|
|
118
|
+
* Paths must be absolute by the time this runs — `loadRecipe` resolves
|
|
119
|
+
* relative extension paths against the recipe file's directory. Errors are
|
|
120
|
+
* wrapped with the extension name + path so a broken deployment names the
|
|
121
|
+
* component instead of surfacing a bare import stack.
|
|
122
|
+
*/
|
|
123
|
+
export async function loadExtensions(recipe: Recipe): Promise<ExtensionRegistry> {
|
|
124
|
+
const registry = emptyExtensionRegistry();
|
|
125
|
+
for (const [name, ext] of Object.entries(recipe.extensions ?? {})) {
|
|
126
|
+
await loadOneExtension(name, ext, registry);
|
|
127
|
+
}
|
|
128
|
+
return registry;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function loadOneExtension(
|
|
132
|
+
name: string,
|
|
133
|
+
ext: RecipeExtension,
|
|
134
|
+
registry: ExtensionRegistry,
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
if (!isAbsolute(ext.path)) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`extension "${name}": path "${ext.path}" is not absolute. ` +
|
|
139
|
+
`Relative paths are resolved by loadRecipe against the recipe file's directory; ` +
|
|
140
|
+
`URL-loaded recipes must use absolute extension paths.`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let mod: Record<string, unknown>;
|
|
145
|
+
try {
|
|
146
|
+
mod = await import(pathToFileURL(ext.path).href) as Record<string, unknown>;
|
|
147
|
+
} catch (err) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`extension "${name}": failed to import ${ext.path}: ` +
|
|
150
|
+
`${err instanceof Error ? err.message : String(err)}`,
|
|
151
|
+
{ cause: err },
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const register = (mod.register ?? mod.default) as unknown;
|
|
156
|
+
if (typeof register !== 'function') {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`extension "${name}" (${ext.path}) must export a "register" function ` +
|
|
159
|
+
`(named export or default export); got ${typeof register}.`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const config = ext.config ?? {};
|
|
164
|
+
const api: ExtensionApi = {
|
|
165
|
+
registerStrategy(type, factory) {
|
|
166
|
+
if (typeof type !== 'string' || !type) {
|
|
167
|
+
throw new Error(`extension "${name}": registerStrategy requires a non-empty type name`);
|
|
168
|
+
}
|
|
169
|
+
if (isBuiltinStrategyType(type)) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`extension "${name}": strategy type "${type}" collides with a built-in strategy`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (registry.strategies.has(type)) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`extension "${name}": strategy type "${type}" is already registered by another extension`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (typeof factory !== 'function') {
|
|
180
|
+
throw new Error(`extension "${name}": registerStrategy("${type}") requires a factory function`);
|
|
181
|
+
}
|
|
182
|
+
registry.strategies.set(type, factory);
|
|
183
|
+
},
|
|
184
|
+
registerModule(factory) {
|
|
185
|
+
if (typeof factory !== 'function') {
|
|
186
|
+
throw new Error(`extension "${name}": registerModule requires a factory function`);
|
|
187
|
+
}
|
|
188
|
+
registry.modules.push({ extensionName: name, factory, config });
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
await Promise.resolve((register as (api: ExtensionApi, config: Record<string, unknown>) => unknown)(api, config));
|
|
194
|
+
} catch (err) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`extension "${name}" (${ext.path}): register() threw: ` +
|
|
197
|
+
`${err instanceof Error ? err.message : String(err)}`,
|
|
198
|
+
{ cause: err },
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -24,20 +24,28 @@ export function buildFrameworkAgentConfig(
|
|
|
24
24
|
maxStreamTokens: recipe.agent.maxStreamTokens ?? 150000,
|
|
25
25
|
contextBudgetTokens: recipe.agent.contextBudgetTokens,
|
|
26
26
|
...(recipe.agent.cacheTtl && { cacheTtl: recipe.agent.cacheTtl }),
|
|
27
|
-
|
|
27
|
+
// Bedrock legacy Claude models reject cache_control outright
|
|
28
|
+
// ("your request did not allow prompt caching") — suppress markers.
|
|
29
|
+
...(recipe.agent.provider === 'bedrock' && { promptCaching: false }),
|
|
30
|
+
// Prefill scaffold (anthropic-xml formatter), e.g. chapterx CLI-sim's
|
|
31
|
+
// '<cmd>cat untitled.txt</cmd>' — part of migrating prefill-era bots.
|
|
32
|
+
...(recipe.agent.prefillUserMessage && { prefillUserMessage: recipe.agent.prefillUserMessage }),
|
|
33
|
+
...((recipe.agent.provider === 'openai-responses' || recipe.agent.provider === 'openai-codex') && {
|
|
28
34
|
providerParams: {
|
|
29
35
|
reasoning: {
|
|
30
36
|
effort: recipe.agent.responses?.reasoningEffort ?? 'high',
|
|
31
37
|
context: recipe.agent.responses?.reasoningContext ?? 'all_turns',
|
|
32
38
|
},
|
|
33
|
-
...(recipe.agent.responses
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
...(recipe.agent.provider === 'openai-responses' ? {
|
|
40
|
+
...(recipe.agent.responses?.serviceTier ? {
|
|
41
|
+
service_tier: recipe.agent.responses.serviceTier,
|
|
42
|
+
} : {}),
|
|
43
|
+
...(recipe.agent.responses?.compactThreshold ? {
|
|
44
|
+
context_management: [{
|
|
45
|
+
type: 'compaction',
|
|
46
|
+
compact_threshold: recipe.agent.responses.compactThreshold,
|
|
47
|
+
}],
|
|
48
|
+
} : {}),
|
|
41
49
|
} : {}),
|
|
42
50
|
},
|
|
43
51
|
}),
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from '@animalabs/agent-framework';
|
|
6
6
|
import type { Recipe, RecipeStrategy } from './recipe.js';
|
|
7
7
|
import { FrontdeskStrategy } from './strategies/frontdesk-strategy.js';
|
|
8
|
+
import { isBuiltinStrategyType, type ExtensionRegistry } from './extensions.js';
|
|
8
9
|
|
|
9
10
|
const PASSTHROUGH_KEYS: ReadonlyArray<keyof RecipeStrategy> = [
|
|
10
11
|
'enforceBudget',
|
|
@@ -39,9 +40,29 @@ export function buildFrameworkStrategy(
|
|
|
39
40
|
recipe: Recipe,
|
|
40
41
|
model: string,
|
|
41
42
|
timeZone: string,
|
|
43
|
+
extensions?: ExtensionRegistry,
|
|
42
44
|
): ContextStrategy {
|
|
43
45
|
const strategyConfig = recipe.agent.strategy;
|
|
44
46
|
const strategyType = strategyConfig?.type ?? 'autobiographical';
|
|
47
|
+
|
|
48
|
+
// Non-built-in types resolve through the extension registry. Validation
|
|
49
|
+
// already required a strategy-kind extension to be declared; this catches
|
|
50
|
+
// the declared-but-didn't-register case with a precise error.
|
|
51
|
+
if (!isBuiltinStrategyType(strategyType)) {
|
|
52
|
+
const factory = extensions?.strategies.get(strategyType);
|
|
53
|
+
if (!factory) {
|
|
54
|
+
const known = extensions ? Array.from(extensions.strategies.keys()) : [];
|
|
55
|
+
throw new Error(
|
|
56
|
+
`strategy type "${strategyType}" is not built-in and no loaded extension registered it. ` +
|
|
57
|
+
`Registered custom types: ${known.length ? known.join(', ') : '(none)'}.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return factory({
|
|
61
|
+
config: (strategyConfig ?? {}) as RecipeStrategy & Record<string, unknown>,
|
|
62
|
+
model,
|
|
63
|
+
timeZone,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
45
66
|
const autobiographicalOpts: Record<string, unknown> = {
|
|
46
67
|
headWindowTokens: strategyConfig?.headWindowTokens ?? 4000,
|
|
47
68
|
recentWindowTokens: strategyConfig?.recentWindowTokens ?? 30000,
|
package/src/index.ts
CHANGED
|
@@ -16,12 +16,17 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
|
+
AnthropicXmlFormatter,
|
|
20
|
+
BedrockAdapter,
|
|
19
21
|
Membrane,
|
|
20
22
|
NativeFormatter,
|
|
21
23
|
OpenAIResponsesAPIAdapter,
|
|
22
24
|
OpenAIResponsesFormatter,
|
|
25
|
+
OpenRouterAdapter,
|
|
23
26
|
} from '@animalabs/membrane';
|
|
24
27
|
import { LoggingAnthropicAdapter } from './logging-adapter.js';
|
|
28
|
+
import { LoggingBedrockAdapter } from './logging-bedrock-adapter.js';
|
|
29
|
+
import { CodexSubscriptionAdapter } from './codex-subscription-adapter.js';
|
|
25
30
|
import { CallLedger } from './call-ledger.js';
|
|
26
31
|
import { SettingsModule } from './modules/settings-module.js';
|
|
27
32
|
import { AgentFramework, WorkspaceModule, resolveTimeZone, type Module, type MountConfig } from '@animalabs/agent-framework';
|
|
@@ -57,6 +62,7 @@ import {
|
|
|
57
62
|
import { createBranchState, resetBranchState, handleExport, type BranchState } from './commands.js';
|
|
58
63
|
import { buildFrameworkAgentConfig } from './framework-agent-config.js';
|
|
59
64
|
import { buildFrameworkStrategy } from './framework-strategy.js';
|
|
65
|
+
import { loadExtensions } from './extensions.js';
|
|
60
66
|
|
|
61
67
|
export type { AppContext };
|
|
62
68
|
|
|
@@ -69,6 +75,8 @@ const config = {
|
|
|
69
75
|
// precedence over the API key so requests never carry both auth schemes.
|
|
70
76
|
authToken: process.env.ANTHROPIC_AUTH_TOKEN,
|
|
71
77
|
openaiApiKey: process.env.OPENAI_API_KEY,
|
|
78
|
+
openrouterApiKey: process.env.OPENROUTER_API_KEY,
|
|
79
|
+
codexBinary: process.env.CODEX_BINARY,
|
|
72
80
|
model: process.env.MODEL,
|
|
73
81
|
dataDir: process.env.DATA_DIR || './data',
|
|
74
82
|
};
|
|
@@ -91,6 +99,7 @@ interface AppContext {
|
|
|
91
99
|
agentName: string;
|
|
92
100
|
branchState: BranchState;
|
|
93
101
|
userMessageCount: number;
|
|
102
|
+
codexAdapter?: CodexSubscriptionAdapter;
|
|
94
103
|
|
|
95
104
|
/** Stop current framework, switch to a different session, start new framework. */
|
|
96
105
|
switchSession(id: string): Promise<void>;
|
|
@@ -143,10 +152,16 @@ async function createFramework(
|
|
|
143
152
|
settingsModule: SettingsModule,
|
|
144
153
|
callLedger: CallLedger | null,
|
|
145
154
|
): Promise<AgentFramework> {
|
|
146
|
-
const model = config.model || recipe.agent.model ||
|
|
155
|
+
const model = config.model || recipe.agent.model ||
|
|
156
|
+
(recipe.agent.provider === 'openai-codex' ? 'gpt-5.4' : 'claude-opus-4-6');
|
|
147
157
|
const modules = recipe.modules ?? {};
|
|
148
158
|
const timeZone = resolveTimeZone(recipe.agent.timezone);
|
|
149
159
|
|
|
160
|
+
// Load recipe extensions first — custom strategies must be registered
|
|
161
|
+
// before buildFrameworkStrategy runs, and custom modules join the module
|
|
162
|
+
// list below. The loader is a dumb local import; see src/extensions.ts.
|
|
163
|
+
const extensionRegistry = await loadExtensions(recipe);
|
|
164
|
+
|
|
150
165
|
// -- Build module list --
|
|
151
166
|
// SettingsModule is constructed in main() (before the adapter, so the
|
|
152
167
|
// adapter can read its state for cross-cutting concerns like reasoning).
|
|
@@ -342,6 +357,16 @@ async function createFramework(
|
|
|
342
357
|
moduleInstances.push(new ObserversModule({ path: observersPath }));
|
|
343
358
|
}
|
|
344
359
|
|
|
360
|
+
// Extension-registered modules. Instantiated last so built-in modules keep
|
|
361
|
+
// their historical positions; each factory gets the minimal runtime context
|
|
362
|
+
// plus its declaring extension's config blob.
|
|
363
|
+
const extensionModules: Module[] = [];
|
|
364
|
+
for (const entry of extensionRegistry.modules) {
|
|
365
|
+
const instance = entry.factory({ timeZone, storePath, model, config: entry.config });
|
|
366
|
+
extensionModules.push(instance);
|
|
367
|
+
moduleInstances.push(instance);
|
|
368
|
+
}
|
|
369
|
+
|
|
345
370
|
// -- Build MCP server list --
|
|
346
371
|
//
|
|
347
372
|
// Recipes are opt-in: a file entry from mcpl-servers.json is loaded only
|
|
@@ -401,14 +426,14 @@ async function createFramework(
|
|
|
401
426
|
// No server augmentation needed — gate is wired via FrameworkConfig.gate
|
|
402
427
|
|
|
403
428
|
// -- Build strategy --
|
|
404
|
-
const strategy = buildFrameworkStrategy(recipe, model, timeZone);
|
|
429
|
+
const strategy = buildFrameworkStrategy(recipe, model, timeZone, extensionRegistry);
|
|
405
430
|
const agentConfig = buildFrameworkAgentConfig(recipe, agentName, model, strategy);
|
|
406
431
|
|
|
407
432
|
// -- Create framework --
|
|
408
433
|
const framework = await AgentFramework.create({
|
|
409
434
|
storePath,
|
|
410
435
|
membrane,
|
|
411
|
-
|
|
436
|
+
agents: [agentConfig],
|
|
412
437
|
modules: moduleInstances,
|
|
413
438
|
mcplServers: finalServers,
|
|
414
439
|
gate: gateOptions,
|
|
@@ -467,6 +492,13 @@ async function createFramework(
|
|
|
467
492
|
channelModeModule.setFramework(framework);
|
|
468
493
|
}
|
|
469
494
|
|
|
495
|
+
// Extension modules get the same duck-typed post-creation hook the
|
|
496
|
+
// built-ins use: if the instance exposes setFramework, call it.
|
|
497
|
+
for (const instance of extensionModules) {
|
|
498
|
+
const hooked = instance as unknown as { setFramework?: (f: AgentFramework) => void };
|
|
499
|
+
hooked.setFramework?.(framework);
|
|
500
|
+
}
|
|
501
|
+
|
|
470
502
|
if (mcplAdminModule) {
|
|
471
503
|
mcplAdminModule.setFramework(framework);
|
|
472
504
|
}
|
|
@@ -733,10 +765,18 @@ async function main() {
|
|
|
733
765
|
const recipe = await resolveRecipe();
|
|
734
766
|
const provider = recipe.agent.provider ?? 'anthropic';
|
|
735
767
|
|
|
768
|
+
if (provider === 'openrouter' && !config.openrouterApiKey) {
|
|
769
|
+
console.error('Missing OPENROUTER_API_KEY for recipe provider "openrouter".');
|
|
770
|
+
process.exit(1);
|
|
771
|
+
}
|
|
736
772
|
if (provider === 'openai-responses' && !config.openaiApiKey) {
|
|
737
773
|
console.error('Missing OPENAI_API_KEY for recipe provider "openai-responses".');
|
|
738
774
|
process.exit(1);
|
|
739
775
|
}
|
|
776
|
+
if (provider === 'bedrock' && !(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY)) {
|
|
777
|
+
console.error('Missing AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY for recipe provider "bedrock".');
|
|
778
|
+
process.exit(1);
|
|
779
|
+
}
|
|
740
780
|
if (provider === 'anthropic' && !config.apiKey && !config.authToken) {
|
|
741
781
|
console.error('Missing ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN). Set one in .env or environment.');
|
|
742
782
|
process.exit(1);
|
|
@@ -761,22 +801,54 @@ async function main() {
|
|
|
761
801
|
defaultTtl: recipe.agent.cacheTtl ?? '5m',
|
|
762
802
|
})
|
|
763
803
|
: null;
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
|
|
804
|
+
// The Codex subscription adapter owns ChatGPT login/refresh independently
|
|
805
|
+
// of the API-key transports below.
|
|
806
|
+
const codexAdapter = provider === 'openai-codex'
|
|
807
|
+
? new CodexSubscriptionAdapter({
|
|
808
|
+
codexBinary: config.codexBinary,
|
|
809
|
+
fastMode: recipe.agent.codex?.fastMode ?? false,
|
|
810
|
+
})
|
|
811
|
+
: undefined;
|
|
812
|
+
const openrouterAdapter = provider === 'openrouter'
|
|
813
|
+
? new OpenRouterAdapter({
|
|
814
|
+
apiKey: config.openrouterApiKey!,
|
|
815
|
+
xTitle: recipe.agent.name ?? recipe.name,
|
|
816
|
+
})
|
|
817
|
+
: undefined;
|
|
818
|
+
// Bedrock: legacy Claude models (3.5 Sonnet 0620/1022, Opus 3) that have
|
|
819
|
+
// left the Anthropic API but survive on AWS. The adapter reads AWS_* env
|
|
820
|
+
// vars (AWS_REGION defaults us-west-2) and maps standard Claude model IDs
|
|
821
|
+
// to Bedrock IDs (explicit map + `anthropic.<id>-v1:0` fallback). Uses the
|
|
822
|
+
// Anthropic-native message shape, so NativeFormatter applies unchanged.
|
|
823
|
+
// No CallLedger: prompt caching is rejected outright by legacy Bedrock
|
|
824
|
+
// models (tested 2026-07-21). Wrapped for llm-calls.jsonl visibility.
|
|
825
|
+
const bedrockAdapter = provider === 'bedrock'
|
|
826
|
+
? new LoggingBedrockAdapter({}, llmLogPath)
|
|
827
|
+
: undefined;
|
|
767
828
|
const adapter = provider === 'openai-responses'
|
|
768
829
|
? new OpenAIResponsesAPIAdapter({
|
|
769
830
|
apiKey: config.openaiApiKey!,
|
|
770
831
|
baseURL: process.env.OPENAI_BASE_URL || undefined,
|
|
771
832
|
})
|
|
772
|
-
: new LoggingAnthropicAdapter(
|
|
833
|
+
: bedrockAdapter ?? openrouterAdapter ?? codexAdapter ?? new LoggingAnthropicAdapter(
|
|
773
834
|
{
|
|
835
|
+
// Anthropic OAuth wins over API-key auth when both are present.
|
|
836
|
+
// Subscription tokens additionally require this beta header.
|
|
837
|
+
// Recipe-declared betas (agent.anthropicBetas, e.g. context-1m)
|
|
838
|
+
// are merged into the same anthropic-beta header either way.
|
|
774
839
|
...(config.authToken
|
|
775
840
|
? {
|
|
776
841
|
authToken: config.authToken,
|
|
777
|
-
defaultHeaders: {
|
|
842
|
+
defaultHeaders: {
|
|
843
|
+
'anthropic-beta': ['oauth-2025-04-20', ...(recipe.agent.anthropicBetas ?? [])].join(','),
|
|
844
|
+
},
|
|
778
845
|
}
|
|
779
|
-
: {
|
|
846
|
+
: {
|
|
847
|
+
apiKey: config.apiKey!,
|
|
848
|
+
...(recipe.agent.anthropicBetas?.length
|
|
849
|
+
? { defaultHeaders: { 'anthropic-beta': recipe.agent.anthropicBetas.join(',') } }
|
|
850
|
+
: {}),
|
|
851
|
+
}),
|
|
780
852
|
baseURL: process.env.ANTHROPIC_BASE_URL || undefined,
|
|
781
853
|
},
|
|
782
854
|
llmLogPath,
|
|
@@ -815,9 +887,15 @@ async function main() {
|
|
|
815
887
|
const agentName = resolved.name;
|
|
816
888
|
|
|
817
889
|
const membrane = new Membrane(adapter, {
|
|
818
|
-
formatter: provider === 'openai-responses'
|
|
890
|
+
formatter: provider === 'openai-responses' || provider === 'openai-codex'
|
|
819
891
|
? new OpenAIResponsesFormatter()
|
|
820
|
-
:
|
|
892
|
+
: recipe.agent.formatter === 'anthropic-xml'
|
|
893
|
+
? new AnthropicXmlFormatter()
|
|
894
|
+
: new NativeFormatter(),
|
|
895
|
+
// Bedrock legacy Claude models 400 on any cache_control block
|
|
896
|
+
// ("your request did not allow prompt caching") — suppress the
|
|
897
|
+
// historical promptCaching=true default on that transport.
|
|
898
|
+
...(provider === 'bedrock' ? { defaultPromptCaching: false } : {}),
|
|
821
899
|
// Anchor the assistant role for internal callers that don't set
|
|
822
900
|
// request.assistantParticipant themselves (autobio compression,
|
|
823
901
|
// executeMerge). Mismatch here flips stored assistant turns to
|
|
@@ -837,6 +915,7 @@ async function main() {
|
|
|
837
915
|
agentName,
|
|
838
916
|
branchState: createBranchState(),
|
|
839
917
|
userMessageCount: 0,
|
|
918
|
+
codexAdapter,
|
|
840
919
|
|
|
841
920
|
async switchSession(id: string) {
|
|
842
921
|
handleExport(this);
|
|
@@ -884,14 +963,18 @@ async function main() {
|
|
|
884
963
|
setupMcplStderrLog(app, storePath);
|
|
885
964
|
getWebUiModule(framework)?.setApp(app);
|
|
886
965
|
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
966
|
+
try {
|
|
967
|
+
if (headless) {
|
|
968
|
+
const { runHeadless } = await import('./headless.js');
|
|
969
|
+
await runHeadless(app, process.argv.slice(2));
|
|
970
|
+
} else if (noTui) {
|
|
971
|
+
await runPiped(app);
|
|
972
|
+
} else {
|
|
973
|
+
const { runTui } = await import('./tui.js');
|
|
974
|
+
await runTui(app);
|
|
975
|
+
}
|
|
976
|
+
} finally {
|
|
977
|
+
codexAdapter?.dispose();
|
|
895
978
|
}
|
|
896
979
|
}
|
|
897
980
|
|