@animalabs/connectome-host 0.3.1 → 0.3.7
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 +6 -0
- package/.github/workflows/publish.yml +9 -4
- package/README.md +5 -0
- package/bun.lock +8 -4
- package/docs/AGENT-ONBOARDING.md +6 -1
- package/package.json +5 -5
- package/scripts/import-codex-rollout.ts +288 -0
- package/src/call-ledger.ts +371 -0
- package/src/call-pricing.ts +119 -0
- package/src/commands.ts +57 -3
- package/src/index.ts +87 -27
- package/src/logging-adapter.ts +72 -9
- package/src/modules/fleet-module.ts +21 -9
- package/src/modules/mcpl-admin-module.ts +6 -0
- package/src/modules/observers-module.ts +180 -0
- package/src/modules/time-module.ts +15 -9
- package/src/modules/web-ui-module.ts +415 -16
- package/src/modules/web-ui-observers.ts +322 -0
- package/src/recipe.ts +48 -3
- package/src/strategies/frontdesk-strategy.ts +10 -4
- package/src/web/protocol.ts +141 -0
- package/test/call-ledger.test.ts +91 -0
- package/test/call-pricing.test.ts +69 -0
- package/test/fleet-subscribe-union-e2e.test.ts +5 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +2 -0
- package/test/frontdesk-strategy.test.ts +10 -0
- package/test/import-codex-rollout.test.ts +36 -0
- package/test/logging-adapter.test.ts +58 -1
- package/test/recipe-cache-ttl.test.ts +7 -3
- package/test/recipe-provider.test.ts +36 -0
- package/test/recipe-timezone.test.ts +20 -0
- package/test/time-module.test.ts +13 -0
- package/test/web-ui-context-coverage.test.ts +61 -0
- package/test/web-ui-observers.test.ts +344 -0
- package/web/package-lock.json +2446 -0
- package/web/src/App.tsx +24 -0
- package/web/src/Context.tsx +207 -7
- package/web/src/ObserverGate.tsx +78 -0
- package/web/src/Usage.tsx +116 -2
- package/web/src/observer-identity.ts +110 -0
- package/web/src/wire.ts +60 -1
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observer identity for the webui — the M2 layer of connectome
|
|
3
|
+
* docs/observability.md.
|
|
4
|
+
*
|
|
5
|
+
* An observer is an Archipelago layer-0 principal: an ed25519 keypair,
|
|
6
|
+
* human device or agent or service. Authorization is a per-agent grant file
|
|
7
|
+
* (data/observers.json) that is hot-reloaded on mtime (the proven
|
|
8
|
+
* discord-filters pattern: atomic tmp+rename writes, parse errors keep the
|
|
9
|
+
* previous state) and editable by the agent itself through the observers
|
|
10
|
+
* module's tools — interiority access is a consent question, so the agent
|
|
11
|
+
* holds the pen.
|
|
12
|
+
*
|
|
13
|
+
* The handshake is challenge-less: the client signs a statement binding the
|
|
14
|
+
* Host it connected to + a fresh timestamp (replay/relay-proof, no extra
|
|
15
|
+
* round trip):
|
|
16
|
+
*
|
|
17
|
+
* sign( "connectome-observer|v1|<host>|<timestamp>" )
|
|
18
|
+
*
|
|
19
|
+
* The server verifies signature, freshness (±5 min), and that the key has a
|
|
20
|
+
* live grant — then the connection carries that grant's scope mask.
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync, renameSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
23
|
+
import { dirname } from 'node:path';
|
|
24
|
+
import { createPublicKey, verify as cryptoVerify, randomBytes } from 'node:crypto';
|
|
25
|
+
|
|
26
|
+
/** Scope = event-family mask. See docs/observability.md §4. */
|
|
27
|
+
export const OBSERVER_SCOPES = ['health', 'ops', 'messages', 'tools', 'thinking', 'debug'] as const;
|
|
28
|
+
export type ObserverScope = typeof OBSERVER_SCOPES[number];
|
|
29
|
+
|
|
30
|
+
export interface ObserverGrant {
|
|
31
|
+
/** `ed25519:<base64url of the raw 32-byte public key>` */
|
|
32
|
+
key: string;
|
|
33
|
+
/** Human-readable label. Testimony, not identity — never treat as verified naming. */
|
|
34
|
+
label: string;
|
|
35
|
+
scopes: ObserverScope[];
|
|
36
|
+
/** ISO expiry; null/absent = no expiry. */
|
|
37
|
+
expires?: string | null;
|
|
38
|
+
/** Protected grants cannot be revoked via the agent-facing tools
|
|
39
|
+
* (recipe-designated operator baseline). File edits can still remove them. */
|
|
40
|
+
protected?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ObserversFile {
|
|
44
|
+
observers: ObserverGrant[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The identity envelope a client presents in its observer-hello frame. */
|
|
48
|
+
export interface ObserverHelloIdentity {
|
|
49
|
+
scheme: 'ed25519';
|
|
50
|
+
/** `ed25519:<base64url raw pubkey>` — must match a grant. */
|
|
51
|
+
id: string;
|
|
52
|
+
/** base64url ed25519 signature over the bound statement. */
|
|
53
|
+
proof: string;
|
|
54
|
+
/** ISO timestamp the statement was signed at (freshness-checked). */
|
|
55
|
+
timestamp: string;
|
|
56
|
+
displayName?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const FRESHNESS_MS = 5 * 60_000;
|
|
60
|
+
|
|
61
|
+
export function observerStatement(host: string, timestamp: string): string {
|
|
62
|
+
return `connectome-observer|v1|${host}|${timestamp}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function b64urlToBuf(s: string): Buffer | null {
|
|
66
|
+
try {
|
|
67
|
+
return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Raw 32-byte ed25519 public key → node KeyObject (SPKI DER wrap). */
|
|
74
|
+
export function ed25519PublicKey(raw: Buffer) {
|
|
75
|
+
const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
76
|
+
return createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw]), format: 'der', type: 'spki' });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// File ops — the discord-mcpl filters.ts pattern, verbatim mechanics.
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
export function loadObserversFile(path: string): ObserversFile | null {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as ObserversFile;
|
|
86
|
+
if (!parsed || !Array.isArray(parsed.observers)) return null;
|
|
87
|
+
for (const g of parsed.observers) {
|
|
88
|
+
if (typeof g.key !== 'string' || !g.key.startsWith('ed25519:')) return null;
|
|
89
|
+
if (!Array.isArray(g.scopes)) return null;
|
|
90
|
+
if (!g.scopes.every((s) => (OBSERVER_SCOPES as readonly string[]).includes(s))) return null;
|
|
91
|
+
}
|
|
92
|
+
return parsed;
|
|
93
|
+
} catch {
|
|
94
|
+
return null; // parse/validation error → caller keeps previous (fail-safe)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function saveObserversFile(path: string, file: ObserversFile): void {
|
|
99
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
100
|
+
const tmp = `${path}.tmp`;
|
|
101
|
+
writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n');
|
|
102
|
+
renameSync(tmp, path); // atomic — mtime pollers never read a half-write
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function observersFileMtime(path: string): number | null {
|
|
106
|
+
try {
|
|
107
|
+
return statSync(path).mtimeMs;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Registry
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
export interface VerifyResult {
|
|
118
|
+
grant: ObserverGrant;
|
|
119
|
+
scopes: Set<ObserverScope>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class ObserverRegistry {
|
|
123
|
+
private file: ObserversFile | null = null;
|
|
124
|
+
private lastMtime: number | null = null;
|
|
125
|
+
private poll: ReturnType<typeof setInterval> | null = null;
|
|
126
|
+
|
|
127
|
+
constructor(private readonly path: string) {}
|
|
128
|
+
|
|
129
|
+
start(): void {
|
|
130
|
+
this.lastMtime = observersFileMtime(this.path);
|
|
131
|
+
if (this.lastMtime !== null) {
|
|
132
|
+
this.file = loadObserversFile(this.path);
|
|
133
|
+
if (!this.file) console.error(`[webui-observers] ${this.path} unparseable — observers disabled until fixed`);
|
|
134
|
+
}
|
|
135
|
+
this.poll = setInterval(() => {
|
|
136
|
+
const m = observersFileMtime(this.path);
|
|
137
|
+
if (m === null || m === this.lastMtime) return; // missing/mid-rename or unchanged
|
|
138
|
+
this.lastMtime = m;
|
|
139
|
+
const next = loadObserversFile(this.path);
|
|
140
|
+
if (!next) {
|
|
141
|
+
console.error(`[webui-observers] ${this.path} unparseable — keeping previous grants`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
this.file = next;
|
|
145
|
+
console.error(`[webui-observers] reloaded ${next.observers.length} grant(s)`);
|
|
146
|
+
}, 3000);
|
|
147
|
+
this.poll.unref();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
stop(): void {
|
|
151
|
+
if (this.poll) clearInterval(this.poll);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** True when at least one live grant exists — gates the whole feature:
|
|
155
|
+
* no grants ⇒ webui behaves exactly as before this layer existed. */
|
|
156
|
+
active(): boolean {
|
|
157
|
+
return (this.file?.observers.length ?? 0) > 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
grants(): ObserverGrant[] {
|
|
161
|
+
return this.file?.observers ?? [];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Verify an observer-hello identity envelope against the live grants.
|
|
166
|
+
* Returns the grant + scope set, or null (with a stderr reason — auth
|
|
167
|
+
* failures on a headless box must be diagnosable).
|
|
168
|
+
*/
|
|
169
|
+
verifyHello(identity: ObserverHelloIdentity, hostHeader: string, now = Date.now()): VerifyResult | null {
|
|
170
|
+
const fail = (why: string): null => {
|
|
171
|
+
console.error(`[webui-observers] hello rejected (${identity?.id ?? 'no-id'}): ${why}`);
|
|
172
|
+
return null;
|
|
173
|
+
};
|
|
174
|
+
if (!identity || identity.scheme !== 'ed25519') return fail('unsupported scheme');
|
|
175
|
+
if (typeof identity.id !== 'string' || !identity.id.startsWith('ed25519:')) return fail('bad key id');
|
|
176
|
+
|
|
177
|
+
const ts = Date.parse(identity.timestamp ?? '');
|
|
178
|
+
if (!Number.isFinite(ts)) return fail('bad timestamp');
|
|
179
|
+
if (Math.abs(now - ts) > FRESHNESS_MS) return fail('stale timestamp');
|
|
180
|
+
|
|
181
|
+
const grant = this.grants().find((g) => g.key === identity.id);
|
|
182
|
+
if (!grant) return fail('no grant for key');
|
|
183
|
+
if (grant.expires && Date.parse(grant.expires) < now) return fail('grant expired');
|
|
184
|
+
|
|
185
|
+
const raw = b64urlToBuf(identity.id.slice('ed25519:'.length));
|
|
186
|
+
if (!raw || raw.length !== 32) return fail('malformed public key');
|
|
187
|
+
const sig = b64urlToBuf(identity.proof ?? '');
|
|
188
|
+
if (!sig || sig.length !== 64) return fail('malformed signature');
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const okSig = cryptoVerify(
|
|
192
|
+
null,
|
|
193
|
+
Buffer.from(observerStatement(hostHeader, identity.timestamp), 'utf8'),
|
|
194
|
+
ed25519PublicKey(raw),
|
|
195
|
+
sig,
|
|
196
|
+
);
|
|
197
|
+
if (!okSig) return fail('signature verify failed');
|
|
198
|
+
} catch (err) {
|
|
199
|
+
return fail(`verify error: ${err instanceof Error ? err.message : err}`);
|
|
200
|
+
}
|
|
201
|
+
return { grant, scopes: new Set(grant.scopes) };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Sessions — short-lived bearer tokens minted after WS auth so the SPA can
|
|
207
|
+
// hit /debug/* and /healthz over plain HTTP. The WS is the authentication
|
|
208
|
+
// event; HTTP rides it.
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
const SESSION_TTL_MS = 12 * 60 * 60_000;
|
|
212
|
+
|
|
213
|
+
export class ObserverSessions {
|
|
214
|
+
private sessions = new Map<string, { scopes: Set<ObserverScope>; expiresAt: number }>();
|
|
215
|
+
|
|
216
|
+
mint(scopes: Set<ObserverScope>): string {
|
|
217
|
+
// Opportunistic sweep — the map stays tiny (one entry per WS auth).
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
for (const [t, s] of this.sessions) if (s.expiresAt < now) this.sessions.delete(t);
|
|
220
|
+
const token = randomBytes(32).toString('hex');
|
|
221
|
+
this.sessions.set(token, { scopes, expiresAt: now + SESSION_TTL_MS });
|
|
222
|
+
return token;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
lookup(token: string | null | undefined): Set<ObserverScope> | null {
|
|
226
|
+
if (!token) return null;
|
|
227
|
+
const s = this.sessions.get(token);
|
|
228
|
+
if (!s || s.expiresAt < Date.now()) return null;
|
|
229
|
+
return s.scopes;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Extract the observer session token from a request (cookie `fkm_obs`). */
|
|
234
|
+
export function sessionTokenFromRequest(req: Request): string | null {
|
|
235
|
+
const cookie = req.headers.get('cookie');
|
|
236
|
+
if (!cookie) return null;
|
|
237
|
+
const m = /(?:^|;\s*)fkm_obs=([a-f0-9]{64})/.exec(cookie);
|
|
238
|
+
return m ? m[1]! : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Scope filtering — pure projections of wire data through a grant's mask
|
|
243
|
+
// (docs/observability.md §4). Kept here (not in the module) so they are
|
|
244
|
+
// unit-testable without the webui's process-level server singleton.
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
import type { WelcomeMessage, WelcomeMessageEntry } from '../web/protocol.js';
|
|
248
|
+
|
|
249
|
+
/** Which scope a trace event requires. Token deltas map by blockType;
|
|
250
|
+
* ops/mcpl lifecycle → 'ops'; usage → 'health'; the rest is conversation
|
|
251
|
+
* lifecycle → 'messages'. */
|
|
252
|
+
export function traceRequiredScope(event: { type: string }): ObserverScope {
|
|
253
|
+
const t = event.type;
|
|
254
|
+
if (t.startsWith('ops:') || t.startsWith('mcpl:')) return 'ops';
|
|
255
|
+
if (t === 'usage:updated') return 'health';
|
|
256
|
+
if (t === 'inference:tokens' || t === 'inference:content_block') {
|
|
257
|
+
const bt = (event as { blockType?: string }).blockType;
|
|
258
|
+
if (bt === 'thinking') return 'thinking';
|
|
259
|
+
if (bt === 'tool_call' || bt === 'tool_result') return 'tools';
|
|
260
|
+
return 'messages';
|
|
261
|
+
}
|
|
262
|
+
if (t.startsWith('tool:')) return 'tools';
|
|
263
|
+
return 'messages';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Project a wire entry through a scope mask: null when the observer may not
|
|
267
|
+
* see messages at all; otherwise thinking/tool blocks are elided per scope.
|
|
268
|
+
* Filtering, not rewriting — the wire is already typed blocks. */
|
|
269
|
+
export function filterEntryForScopes(
|
|
270
|
+
entry: WelcomeMessageEntry,
|
|
271
|
+
scopes: Set<ObserverScope>,
|
|
272
|
+
): WelcomeMessageEntry | null {
|
|
273
|
+
if (!scopes.has('messages')) return null;
|
|
274
|
+
if (scopes.has('thinking') && scopes.has('tools')) return entry;
|
|
275
|
+
const blocks = entry.blocks.filter((b) => {
|
|
276
|
+
if ((b.kind === 'thinking' || b.kind === 'redacted_thinking') && !scopes.has('thinking')) return false;
|
|
277
|
+
if ((b.kind === 'tool_use' || b.kind === 'tool_result') && !scopes.has('tools')) return false;
|
|
278
|
+
return true;
|
|
279
|
+
});
|
|
280
|
+
return { ...entry, blocks };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Project a welcome through an observer's scope mask. Without 'messages'
|
|
284
|
+
* the entire conversation payload (entries + agent trees, which carry
|
|
285
|
+
* streaming buffers) is emptied — a health/ops observer like the fleet hub
|
|
286
|
+
* gets structure and usage, never content. Without 'health' the telemetry
|
|
287
|
+
* fields (usage / perAgentCost / callLedger) are masked too: their live
|
|
288
|
+
* frames (`usage`, `call-ledger`) are gated on 'health', and the welcome
|
|
289
|
+
* must refuse exactly what the stream refuses — otherwise a messages-only
|
|
290
|
+
* observer reads model IDs, per-call costs, and raw provider errors on
|
|
291
|
+
* connect that it is denied a second later. */
|
|
292
|
+
export function scopeWelcome(welcome: WelcomeMessage, scopes: Set<ObserverScope>): WelcomeMessage {
|
|
293
|
+
const emptyTree = { asOfTs: Date.now(), nodes: [], callIdIndex: {} };
|
|
294
|
+
const scoped: WelcomeMessage = { ...welcome };
|
|
295
|
+
if (!scopes.has('health')) {
|
|
296
|
+
delete scoped.callLedger;
|
|
297
|
+
delete scoped.perAgentCost;
|
|
298
|
+
// `usage` is required by the wire type, so it is zeroed rather than
|
|
299
|
+
// dropped (matches the emptied-not-deleted style of the fields below).
|
|
300
|
+
scoped.usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
301
|
+
}
|
|
302
|
+
if (!scopes.has('messages')) {
|
|
303
|
+
return {
|
|
304
|
+
...scoped,
|
|
305
|
+
messages: [],
|
|
306
|
+
history: { startIndex: welcome.history.totalCount, totalCount: welcome.history.totalCount },
|
|
307
|
+
localTree: emptyTree,
|
|
308
|
+
childTrees: [],
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
...scoped,
|
|
313
|
+
messages: welcome.messages
|
|
314
|
+
.map((e) => filterEntryForScopes(e, scopes))
|
|
315
|
+
.filter((e): e is WelcomeMessageEntry => e !== null),
|
|
316
|
+
// Agent trees replay tool calls + streaming thinking; only full
|
|
317
|
+
// interiority scopes get them.
|
|
318
|
+
...(scopes.has('thinking') && scopes.has('tools')
|
|
319
|
+
? {}
|
|
320
|
+
: { localTree: emptyTree, childTrees: [] }),
|
|
321
|
+
};
|
|
322
|
+
}
|
package/src/recipe.ts
CHANGED
|
@@ -75,6 +75,10 @@ export interface RecipeStrategy {
|
|
|
75
75
|
export interface RecipeAgent {
|
|
76
76
|
name?: string;
|
|
77
77
|
model?: string;
|
|
78
|
+
/** IANA zone used when rendering wall-clock times to the agent. */
|
|
79
|
+
timezone?: string;
|
|
80
|
+
/** Provider transport. Omitted preserves the historical Anthropic default. */
|
|
81
|
+
provider?: 'anthropic' | 'openai-responses';
|
|
78
82
|
systemPrompt: string;
|
|
79
83
|
maxTokens?: number;
|
|
80
84
|
/**
|
|
@@ -86,9 +90,8 @@ export interface RecipeAgent {
|
|
|
86
90
|
/** Per-agent context compile budget (input tokens). When unset, the
|
|
87
91
|
* ContextManager default (100k) applies. Raise for large-context models. */
|
|
88
92
|
contextBudgetTokens?: number;
|
|
89
|
-
/** Prompt-cache TTL ('5m' | '1h') forwarded to the provider.
|
|
90
|
-
*
|
|
91
|
-
* the full context to cache after every gap. Unset: provider default (5m). */
|
|
93
|
+
/** Prompt-cache TTL ('5m' | '1h') forwarded to the provider. Defaults to
|
|
94
|
+
* '1h'; set '5m' explicitly for high-frequency, sub-5-minute workloads. */
|
|
92
95
|
cacheTtl?: '5m' | '1h';
|
|
93
96
|
strategy?: RecipeStrategy;
|
|
94
97
|
/**
|
|
@@ -100,6 +103,13 @@ export interface RecipeAgent {
|
|
|
100
103
|
enabled: boolean;
|
|
101
104
|
budgetTokens?: number;
|
|
102
105
|
};
|
|
106
|
+
/** Stateless OpenAI Responses settings. Only used with
|
|
107
|
+
* `provider: "openai-responses"`. */
|
|
108
|
+
responses?: {
|
|
109
|
+
reasoningEffort?: 'none' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
|
110
|
+
reasoningContext?: 'current_turn' | 'all_turns';
|
|
111
|
+
compactThreshold?: number;
|
|
112
|
+
};
|
|
103
113
|
/**
|
|
104
114
|
* Content-refusal handling. When `autoRewind` is on, a `stop_reason: refusal`
|
|
105
115
|
* turn triggers an automatic rewind of the triggering turn + retry (keeping
|
|
@@ -500,6 +510,7 @@ export const DEFAULT_RECIPE: Recipe = {
|
|
|
500
510
|
description: 'General-purpose assistant with tool access',
|
|
501
511
|
agent: {
|
|
502
512
|
name: 'agent',
|
|
513
|
+
cacheTtl: '1h',
|
|
503
514
|
systemPrompt: [
|
|
504
515
|
'You are a helpful assistant. You have access to tools provided by connected MCP servers.',
|
|
505
516
|
'Use them to help the user with their tasks.',
|
|
@@ -678,6 +689,39 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
678
689
|
throw new Error('Recipe agent must have a "systemPrompt" string');
|
|
679
690
|
}
|
|
680
691
|
|
|
692
|
+
if (agent.provider !== undefined && agent.provider !== 'anthropic' && agent.provider !== 'openai-responses') {
|
|
693
|
+
throw new Error(`Recipe agent.provider must be 'anthropic' or 'openai-responses', got ${JSON.stringify(agent.provider)}.`);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (agent.timezone !== undefined) {
|
|
697
|
+
if (typeof agent.timezone !== 'string' || !agent.timezone.trim()) {
|
|
698
|
+
throw new Error('Recipe agent.timezone must be a non-empty IANA time zone string.');
|
|
699
|
+
}
|
|
700
|
+
try {
|
|
701
|
+
new Intl.DateTimeFormat('en-US', { timeZone: agent.timezone }).format(new Date(0));
|
|
702
|
+
} catch {
|
|
703
|
+
throw new Error(`Recipe agent.timezone is not a valid IANA time zone: ${JSON.stringify(agent.timezone)}.`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (agent.responses !== undefined) {
|
|
708
|
+
if (!agent.responses || typeof agent.responses !== 'object' || Array.isArray(agent.responses)) {
|
|
709
|
+
throw new Error('Recipe agent.responses must be an object.');
|
|
710
|
+
}
|
|
711
|
+
const responses = agent.responses as Record<string, unknown>;
|
|
712
|
+
const efforts = ['none', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
713
|
+
if (responses.reasoningEffort !== undefined && !efforts.includes(String(responses.reasoningEffort))) {
|
|
714
|
+
throw new Error(`Invalid agent.responses.reasoningEffort ${JSON.stringify(responses.reasoningEffort)}.`);
|
|
715
|
+
}
|
|
716
|
+
if (responses.reasoningContext !== undefined && responses.reasoningContext !== 'current_turn' && responses.reasoningContext !== 'all_turns') {
|
|
717
|
+
throw new Error(`Invalid agent.responses.reasoningContext ${JSON.stringify(responses.reasoningContext)}.`);
|
|
718
|
+
}
|
|
719
|
+
if (responses.compactThreshold !== undefined &&
|
|
720
|
+
(typeof responses.compactThreshold !== 'number' || responses.compactThreshold <= 0)) {
|
|
721
|
+
throw new Error('Recipe agent.responses.compactThreshold must be a positive number.');
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
681
725
|
if (agent.maxStreamTokens !== undefined && (typeof agent.maxStreamTokens !== 'number' || agent.maxStreamTokens <= 0)) {
|
|
682
726
|
throw new Error('Recipe agent.maxStreamTokens must be a positive number.');
|
|
683
727
|
}
|
|
@@ -687,6 +731,7 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
687
731
|
if (agent.cacheTtl !== undefined && agent.cacheTtl !== '5m' && agent.cacheTtl !== '1h') {
|
|
688
732
|
throw new Error(`Recipe agent.cacheTtl must be '5m' or '1h', got ${JSON.stringify(agent.cacheTtl)}.`);
|
|
689
733
|
}
|
|
734
|
+
agent.cacheTtl ??= '1h';
|
|
690
735
|
|
|
691
736
|
// Validate agent.thinking if present. Catches typos and constraint
|
|
692
737
|
// violations (notably max_tokens > budget_tokens) at recipe-load time
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
SummaryEntry,
|
|
10
10
|
} from '@animalabs/context-manager';
|
|
11
11
|
import type { ContentBlock } from '@animalabs/membrane';
|
|
12
|
+
import { formatZonedTime, resolveTimeZone } from '@animalabs/agent-framework';
|
|
12
13
|
|
|
13
14
|
// Structural mirror of AutobiographicalStrategy's internal Chunk.
|
|
14
15
|
// Kept inline because @animalabs/context-manager does not currently export it.
|
|
@@ -24,7 +25,7 @@ interface Chunk {
|
|
|
24
25
|
phaseType?: string;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
export type FrontdeskStrategyOptions = Partial<AutobiographicalConfig
|
|
28
|
+
export type FrontdeskStrategyOptions = Partial<AutobiographicalConfig> & { timeZone?: string };
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Chatbot-flavoured context strategy for agents that receive messages via
|
|
@@ -43,6 +44,13 @@ export class FrontdeskStrategy extends AutobiographicalStrategy {
|
|
|
43
44
|
override readonly name: string = 'frontdesk';
|
|
44
45
|
|
|
45
46
|
private salientSourceIds: Set<string> = new Set();
|
|
47
|
+
private readonly timeZone: string;
|
|
48
|
+
|
|
49
|
+
constructor(options: FrontdeskStrategyOptions = {}) {
|
|
50
|
+
const { timeZone, ...strategyOptions } = options;
|
|
51
|
+
super(strategyOptions);
|
|
52
|
+
this.timeZone = resolveTimeZone(timeZone);
|
|
53
|
+
}
|
|
46
54
|
|
|
47
55
|
override select(
|
|
48
56
|
store: MessageStoreView,
|
|
@@ -146,9 +154,7 @@ export class FrontdeskStrategy extends AutobiographicalStrategy {
|
|
|
146
154
|
}
|
|
147
155
|
if (!d && msgTs instanceof Date && !isNaN(msgTs.getTime())) d = msgTs;
|
|
148
156
|
if (!d) return null;
|
|
149
|
-
|
|
150
|
-
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
151
|
-
return `${hh}:${mm}`;
|
|
157
|
+
return formatZonedTime(d, this.timeZone);
|
|
152
158
|
}
|
|
153
159
|
|
|
154
160
|
// ==========================================================================
|
package/src/web/protocol.ts
CHANGED
|
@@ -93,6 +93,9 @@ export interface WelcomeMessage {
|
|
|
93
93
|
/** Per-agent cost breakdown for the parent process, present when the
|
|
94
94
|
* framework's usage tracker has data. Empty during cold start. */
|
|
95
95
|
perAgentCost?: PerAgentCost[];
|
|
96
|
+
/** Recent provider calls with cache verdicts. Present when the host's
|
|
97
|
+
* provider adapter exposes the call ledger. */
|
|
98
|
+
callLedger?: CallLedgerSnapshot;
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
/**
|
|
@@ -164,6 +167,91 @@ export interface UsageMessage {
|
|
|
164
167
|
perAgentCost?: PerAgentCost[];
|
|
165
168
|
}
|
|
166
169
|
|
|
170
|
+
/** Live replacement snapshot emitted after each provider call. */
|
|
171
|
+
export interface CallLedgerMessage {
|
|
172
|
+
type: 'call-ledger';
|
|
173
|
+
ledger: CallLedgerSnapshot;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export type CallLedgerVerdict =
|
|
177
|
+
| 'HIT'
|
|
178
|
+
| 'hit+extend'
|
|
179
|
+
| 'uncached'
|
|
180
|
+
| 'rewrite:expired'
|
|
181
|
+
| 'rewrite:prefix-mutated'
|
|
182
|
+
| 'rewrite:prefix-truncated'
|
|
183
|
+
| 'rewrite:unexplained'
|
|
184
|
+
| 'first-write'
|
|
185
|
+
| 'ERROR'
|
|
186
|
+
| 'empty'
|
|
187
|
+
| 'unknown';
|
|
188
|
+
|
|
189
|
+
export interface CallLedgerRow {
|
|
190
|
+
id: string;
|
|
191
|
+
timestamp: string;
|
|
192
|
+
kind: 'complete' | 'stream';
|
|
193
|
+
/** Honest call-class estimate; `~` means the trigger plumbing did not
|
|
194
|
+
* provide a definitive origin. */
|
|
195
|
+
originEstimate: 'turn~' | 'aux~';
|
|
196
|
+
model: string;
|
|
197
|
+
messages: number;
|
|
198
|
+
durationMs: number;
|
|
199
|
+
tokens: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
200
|
+
cost?: CallCostBreakdown;
|
|
201
|
+
cache: {
|
|
202
|
+
/** Undefined for older compact logs that predate marker summaries. */
|
|
203
|
+
breakpoints?: number;
|
|
204
|
+
ttls: string[];
|
|
205
|
+
effectiveTtl: '5m' | '1h';
|
|
206
|
+
};
|
|
207
|
+
verdict: CallLedgerVerdict;
|
|
208
|
+
cause: string;
|
|
209
|
+
stopReason?: string;
|
|
210
|
+
error?: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface CallCostBreakdown {
|
|
214
|
+
input: number;
|
|
215
|
+
cacheWrite5m: number;
|
|
216
|
+
cacheWrite1h: number;
|
|
217
|
+
cacheRead: number;
|
|
218
|
+
output: number;
|
|
219
|
+
total: number;
|
|
220
|
+
currency: 'USD';
|
|
221
|
+
/** `billing` means every charged usage bucket and pricing modifier was
|
|
222
|
+
* known. Unknown/mixed buckets are left unpriced rather than estimated. */
|
|
223
|
+
grade: 'billing';
|
|
224
|
+
pricingVersion: string;
|
|
225
|
+
rates: {
|
|
226
|
+
inputPerMillion: number;
|
|
227
|
+
outputPerMillion: number;
|
|
228
|
+
cacheWrite5mPerMillion: number;
|
|
229
|
+
cacheWrite1hPerMillion: number;
|
|
230
|
+
cacheReadPerMillion: number;
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface CallLedgerSnapshot {
|
|
235
|
+
/** Oldest to newest; clients may reverse for display. */
|
|
236
|
+
rows: CallLedgerRow[];
|
|
237
|
+
summary: {
|
|
238
|
+
calls: number;
|
|
239
|
+
input: number;
|
|
240
|
+
output: number;
|
|
241
|
+
cacheRead: number;
|
|
242
|
+
cacheWrite: number;
|
|
243
|
+
cacheHitRatio: number;
|
|
244
|
+
cost?: {
|
|
245
|
+
total: number;
|
|
246
|
+
currency: 'USD';
|
|
247
|
+
pricedCalls: number;
|
|
248
|
+
unpricedCalls: number;
|
|
249
|
+
pricingVersion: string;
|
|
250
|
+
};
|
|
251
|
+
byVerdict: Partial<Record<CallLedgerVerdict, number>>;
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
167
255
|
export interface TokenUsage {
|
|
168
256
|
input: number;
|
|
169
257
|
output: number;
|
|
@@ -352,6 +440,7 @@ export type WebUiServerMessage =
|
|
|
352
440
|
| ChildEventMessage
|
|
353
441
|
| CommandResultMessage
|
|
354
442
|
| UsageMessage
|
|
443
|
+
| CallLedgerMessage
|
|
355
444
|
| BranchChangedMessage
|
|
356
445
|
| SessionChangedMessage
|
|
357
446
|
| PeekMessage
|
|
@@ -364,8 +453,35 @@ export type WebUiServerMessage =
|
|
|
364
453
|
| WorkspaceFileMessage
|
|
365
454
|
| HistoryPageMessage
|
|
366
455
|
| MessageAppendedMessage
|
|
456
|
+
| ObserverAuthRequiredMessage
|
|
457
|
+
| ObserverAckMessage
|
|
367
458
|
| ErrorMessage;
|
|
368
459
|
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
// Observer identity (docs/observability.md M2) — additive to protocol v1.
|
|
462
|
+
// Basic-auth clients never see these frames; key-authenticated observers
|
|
463
|
+
// authenticate in-band over the WS before any data flows.
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
/** Sent to a connection that upgraded without basic auth (allowed only when
|
|
467
|
+
* observer grants exist): the client must reply with observer-hello before
|
|
468
|
+
* anything else is sent. `host` is what the server will verify the signed
|
|
469
|
+
* statement against — the Host header of the upgrade request. */
|
|
470
|
+
export interface ObserverAuthRequiredMessage {
|
|
471
|
+
type: 'observer-auth-required';
|
|
472
|
+
host: string;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Successful observer authentication. `sessionToken` is a short-lived
|
|
476
|
+
* bearer for HTTP routes (set as the `fkm_obs` cookie by the SPA);
|
|
477
|
+
* `scopes` is the grant's mask — the SPA adapts its UI to it. */
|
|
478
|
+
export interface ObserverAckMessage {
|
|
479
|
+
type: 'observer-ack';
|
|
480
|
+
scopes: string[];
|
|
481
|
+
sessionToken: string;
|
|
482
|
+
label: string;
|
|
483
|
+
}
|
|
484
|
+
|
|
369
485
|
// ---------------------------------------------------------------------------
|
|
370
486
|
// Client → Server
|
|
371
487
|
// ---------------------------------------------------------------------------
|
|
@@ -522,8 +638,26 @@ export interface RequestHistoryMessage {
|
|
|
522
638
|
limit?: number;
|
|
523
639
|
}
|
|
524
640
|
|
|
641
|
+
/** In-band observer authentication (docs/observability.md §3): the client
|
|
642
|
+
* signs `connectome-observer|v1|<host>|<timestamp>` with its ed25519 key.
|
|
643
|
+
* Challenge-less — the Host binding + freshness window replace a nonce. */
|
|
644
|
+
export interface ObserverHelloMessage {
|
|
645
|
+
type: 'observer-hello';
|
|
646
|
+
identity: {
|
|
647
|
+
scheme: 'ed25519';
|
|
648
|
+
/** `ed25519:<base64url raw 32-byte public key>` */
|
|
649
|
+
id: string;
|
|
650
|
+
/** base64url signature over the bound statement. */
|
|
651
|
+
proof: string;
|
|
652
|
+
/** ISO timestamp used in the statement (±5 min freshness). */
|
|
653
|
+
timestamp: string;
|
|
654
|
+
displayName?: string;
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
525
658
|
export type WebUiClientMessage =
|
|
526
659
|
| UserMessageMessage
|
|
660
|
+
| ObserverHelloMessage
|
|
527
661
|
| RequestHistoryMessage
|
|
528
662
|
| CommandMessage
|
|
529
663
|
| RouteToChildMessage
|
|
@@ -565,6 +699,13 @@ export function isClientMessage(value: unknown): value is WebUiClientMessage {
|
|
|
565
699
|
return true;
|
|
566
700
|
case 'user-message':
|
|
567
701
|
return typeof v.content === 'string';
|
|
702
|
+
case 'observer-hello': {
|
|
703
|
+
const id = v.identity as Record<string, unknown> | undefined;
|
|
704
|
+
return !!id && id.scheme === 'ed25519'
|
|
705
|
+
&& typeof id.id === 'string'
|
|
706
|
+
&& typeof id.proof === 'string'
|
|
707
|
+
&& typeof id.timestamp === 'string';
|
|
708
|
+
}
|
|
568
709
|
case 'command':
|
|
569
710
|
return typeof v.command === 'string'
|
|
570
711
|
&& (v.corrId === undefined || typeof v.corrId === 'string');
|