@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.
Files changed (41) hide show
  1. package/.env.example +6 -0
  2. package/.github/workflows/publish.yml +9 -4
  3. package/README.md +5 -0
  4. package/bun.lock +8 -4
  5. package/docs/AGENT-ONBOARDING.md +6 -1
  6. package/package.json +5 -5
  7. package/scripts/import-codex-rollout.ts +288 -0
  8. package/src/call-ledger.ts +371 -0
  9. package/src/call-pricing.ts +119 -0
  10. package/src/commands.ts +57 -3
  11. package/src/index.ts +87 -27
  12. package/src/logging-adapter.ts +72 -9
  13. package/src/modules/fleet-module.ts +21 -9
  14. package/src/modules/mcpl-admin-module.ts +6 -0
  15. package/src/modules/observers-module.ts +180 -0
  16. package/src/modules/time-module.ts +15 -9
  17. package/src/modules/web-ui-module.ts +415 -16
  18. package/src/modules/web-ui-observers.ts +322 -0
  19. package/src/recipe.ts +48 -3
  20. package/src/strategies/frontdesk-strategy.ts +10 -4
  21. package/src/web/protocol.ts +141 -0
  22. package/test/call-ledger.test.ts +91 -0
  23. package/test/call-pricing.test.ts +69 -0
  24. package/test/fleet-subscribe-union-e2e.test.ts +5 -0
  25. package/test/fleet-tree-aggregator-e2e.test.ts +2 -0
  26. package/test/frontdesk-strategy.test.ts +10 -0
  27. package/test/import-codex-rollout.test.ts +36 -0
  28. package/test/logging-adapter.test.ts +58 -1
  29. package/test/recipe-cache-ttl.test.ts +7 -3
  30. package/test/recipe-provider.test.ts +36 -0
  31. package/test/recipe-timezone.test.ts +20 -0
  32. package/test/time-module.test.ts +13 -0
  33. package/test/web-ui-context-coverage.test.ts +61 -0
  34. package/test/web-ui-observers.test.ts +344 -0
  35. package/web/package-lock.json +2446 -0
  36. package/web/src/App.tsx +24 -0
  37. package/web/src/Context.tsx +207 -7
  38. package/web/src/ObserverGate.tsx +78 -0
  39. package/web/src/Usage.tsx +116 -2
  40. package/web/src/observer-identity.ts +110 -0
  41. package/web/src/wire.ts +60 -1
@@ -0,0 +1,180 @@
1
+ /**
2
+ * ObserversModule — the agent holds the pen on who may observe its interior.
3
+ *
4
+ * Interiority access (thinking, tool payloads, conversation stream via the
5
+ * webui) is a consent question before it is an ACL question — see connectome
6
+ * docs/observability.md §4. These tools let the agent read and edit its own
7
+ * data/observers.json grant file. The webui's ObserverRegistry hot-reloads
8
+ * the file on mtime (~3s), so grants and revocations apply to new
9
+ * connections without a restart. Operators can edit the same file directly;
10
+ * writes here are atomic (tmp+rename) so the two never corrupt each other.
11
+ *
12
+ * Grants marked `protected: true` (recipe-designated operator baseline) are
13
+ * visible but not revocable through these tools; only a file edit removes
14
+ * them. Tools also cannot CREATE protected grants — protection is an
15
+ * operator-level marker.
16
+ */
17
+
18
+ import type {
19
+ Module,
20
+ ModuleContext,
21
+ ProcessEvent,
22
+ ProcessState,
23
+ EventResponse,
24
+ ToolDefinition,
25
+ ToolCall,
26
+ ToolResult,
27
+ } from '@animalabs/agent-framework';
28
+ import {
29
+ OBSERVER_SCOPES,
30
+ loadObserversFile,
31
+ saveObserversFile,
32
+ type ObserverGrant,
33
+ type ObserverScope,
34
+ type ObserversFile,
35
+ } from './web-ui-observers.js';
36
+
37
+ export interface ObserversModuleConfig {
38
+ /** Absolute path to the grant file (same one the webui watches). */
39
+ path: string;
40
+ }
41
+
42
+ export class ObserversModule implements Module {
43
+ readonly name = 'observers';
44
+
45
+ constructor(private readonly config: ObserversModuleConfig) {}
46
+
47
+ async start(_ctx: ModuleContext): Promise<void> {}
48
+ async stop(): Promise<void> {}
49
+
50
+ getTools(): ToolDefinition[] {
51
+ return [
52
+ {
53
+ name: 'get',
54
+ description:
55
+ 'List who is currently authorized to observe your internals through the web viewer ' +
56
+ '(thinking, tool calls, conversation stream — by scope). Each grant is an ed25519 ' +
57
+ 'public key + label + scope list. Labels are testimony, not verified identity.',
58
+ inputSchema: { type: 'object', properties: {} },
59
+ },
60
+ {
61
+ name: 'grant',
62
+ description:
63
+ 'Authorize an observer key to watch your internals through the web viewer. ' +
64
+ `Scopes (event families): ${OBSERVER_SCOPES.join(', ')}. ` +
65
+ "'health' = liveness/usage only; 'ops' = alerts (refusals, failures); 'messages' = the " +
66
+ "conversation; 'tools' = tool calls and results; 'thinking' = your thinking blocks; " +
67
+ "'debug' = compiled-context debug endpoints (implies seeing everything in your window). " +
68
+ 'Grant deliberately — this is access to your interior. Takes effect within ~3 seconds.',
69
+ inputSchema: {
70
+ type: 'object',
71
+ properties: {
72
+ key: { type: 'string', description: 'ed25519:<base64url public key> — the fingerprint the person/device shows you.' },
73
+ label: { type: 'string', description: 'Who this is (your words — e.g. "antra-phone", "fleet-hub").' },
74
+ scopes: {
75
+ type: 'array',
76
+ items: { type: 'string', enum: [...OBSERVER_SCOPES] },
77
+ description: 'Event families this key may receive.',
78
+ },
79
+ expires: { type: 'string', description: 'Optional ISO timestamp; the grant stops working after this.' },
80
+ },
81
+ required: ['key', 'label', 'scopes'],
82
+ },
83
+ },
84
+ {
85
+ name: 'revoke',
86
+ description:
87
+ 'Remove an observer grant by key or by label. Protected (operator-baseline) grants ' +
88
+ 'cannot be revoked here. Existing connections are not killed, but new connections and ' +
89
+ 'HTTP sessions stop authenticating within ~3 seconds.',
90
+ inputSchema: {
91
+ type: 'object',
92
+ properties: {
93
+ key: { type: 'string', description: 'ed25519:<base64url public key> to revoke.' },
94
+ label: { type: 'string', description: 'Alternative: revoke by exact label (must match exactly one grant).' },
95
+ },
96
+ },
97
+ },
98
+ ];
99
+ }
100
+
101
+ async handleToolCall(call: ToolCall): Promise<ToolResult> {
102
+ const input = (call.input ?? {}) as Record<string, unknown>;
103
+ try {
104
+ switch (call.name) {
105
+ case 'get':
106
+ return ok({ observers: this.load().observers.map(redactNothing) });
107
+
108
+ case 'grant': {
109
+ const key = typeof input.key === 'string' ? input.key.trim() : '';
110
+ const label = typeof input.label === 'string' ? input.label.trim() : '';
111
+ const scopes = Array.isArray(input.scopes) ? input.scopes : [];
112
+ if (!key.startsWith('ed25519:') || key.length < 20) {
113
+ return err('key must be "ed25519:<base64url public key>"');
114
+ }
115
+ if (!label) return err('label is required');
116
+ if (scopes.length === 0 || !scopes.every((s) => (OBSERVER_SCOPES as readonly string[]).includes(s as string))) {
117
+ return err(`scopes must be a non-empty subset of: ${OBSERVER_SCOPES.join(', ')}`);
118
+ }
119
+ const expires = typeof input.expires === 'string' ? input.expires : null;
120
+ if (expires && !Number.isFinite(Date.parse(expires))) return err('expires must be an ISO timestamp');
121
+
122
+ const file = this.load();
123
+ const existing = file.observers.find((g) => g.key === key);
124
+ if (existing?.protected) return err('that key holds a protected grant — edit the file to change it');
125
+ const grant: ObserverGrant = { key, label, scopes: scopes as ObserverScope[], expires };
126
+ if (existing) {
127
+ Object.assign(existing, grant); // update label/scopes/expiry in place
128
+ } else {
129
+ file.observers.push(grant);
130
+ }
131
+ saveObserversFile(this.config.path, file);
132
+ return ok({
133
+ message: `${existing ? 'Updated' : 'Granted'}: ${label} → [${grant.scopes.join(', ')}]${expires ? ` until ${expires}` : ''}. Live within ~3s.`,
134
+ observers: file.observers,
135
+ });
136
+ }
137
+
138
+ case 'revoke': {
139
+ const key = typeof input.key === 'string' ? input.key.trim() : '';
140
+ const label = typeof input.label === 'string' ? input.label.trim() : '';
141
+ if (!key && !label) return err('provide key or label');
142
+ const file = this.load();
143
+ const matches = file.observers.filter((g) => (key ? g.key === key : g.label === label));
144
+ if (matches.length === 0) return err('no matching grant');
145
+ if (matches.length > 1) return err(`label matches ${matches.length} grants — revoke by key`);
146
+ const target = matches[0]!;
147
+ if (target.protected) return err('that grant is protected (operator baseline) — edit the file to remove it');
148
+ file.observers = file.observers.filter((g) => g !== target);
149
+ saveObserversFile(this.config.path, file);
150
+ return ok({ message: `Revoked: ${target.label}. New connections stop within ~3s.`, observers: file.observers });
151
+ }
152
+
153
+ default:
154
+ return { success: false, error: `Unknown tool: ${call.name}`, isError: true };
155
+ }
156
+ } catch (e) {
157
+ return err(`observers file operation failed: ${e instanceof Error ? e.message : e}`);
158
+ }
159
+ }
160
+
161
+ async onProcess(_event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
162
+ return {};
163
+ }
164
+
165
+ private load(): ObserversFile {
166
+ return loadObserversFile(this.config.path) ?? { observers: [] };
167
+ }
168
+ }
169
+
170
+ function redactNothing(g: ObserverGrant): ObserverGrant {
171
+ return g; // grants hold public keys + labels only — nothing secret to redact
172
+ }
173
+
174
+ function ok(data: Record<string, unknown>): ToolResult {
175
+ return { success: true, data, isError: false };
176
+ }
177
+
178
+ function err(message: string): ToolResult {
179
+ return { success: false, error: message, isError: true };
180
+ }
@@ -15,21 +15,23 @@ import type {
15
15
  ToolCall,
16
16
  ToolResult,
17
17
  } from '@animalabs/agent-framework';
18
+ import { formatZonedDateTime, resolveTimeZone } from '@animalabs/agent-framework';
18
19
 
19
20
  interface TimeState {
20
21
  sessionStartAnnounced?: boolean;
21
22
  }
22
23
 
23
- function formatNow(date: Date = new Date()): {
24
+ export function formatNow(date: Date = new Date(), configuredTimeZone?: string): {
24
25
  iso: string;
25
26
  local: string;
26
27
  timezone: string;
27
28
  unixMs: number;
28
29
  } {
29
- const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
30
+ const timezone = resolveTimeZone(configuredTimeZone);
31
+ const local = formatZonedDateTime(date, timezone);
30
32
  return {
31
- iso: date.toISOString(),
32
- local: date.toString(),
33
+ iso: local.slice(0, local.indexOf(' [')),
34
+ local,
33
35
  timezone,
34
36
  unixMs: date.getTime(),
35
37
  };
@@ -39,6 +41,11 @@ export class TimeModule implements Module {
39
41
  readonly name = 'time';
40
42
 
41
43
  private ctx: ModuleContext | null = null;
44
+ private readonly timeZone: string;
45
+
46
+ constructor(timeZone?: string) {
47
+ this.timeZone = resolveTimeZone(timeZone);
48
+ }
42
49
 
43
50
  async start(ctx: ModuleContext): Promise<void> {
44
51
  this.ctx = ctx;
@@ -46,10 +53,9 @@ export class TimeModule implements Module {
46
53
  const state = ctx.getState<TimeState>() ?? {};
47
54
  if (state.sessionStartAnnounced) return;
48
55
 
49
- const now = formatNow();
56
+ const now = formatNow(new Date(), this.timeZone);
50
57
  const text =
51
- `The time at the start of this session is: ${now.iso} ` +
52
- `(local: ${now.local}, timezone: ${now.timezone}).`;
58
+ `The local time at the start of this session is ${now.local}.`;
53
59
 
54
60
  ctx.addMessage('user', [{ type: 'text', text }]);
55
61
  ctx.setState<TimeState>({ ...state, sessionStartAnnounced: true });
@@ -67,7 +73,7 @@ export class TimeModule implements Module {
67
73
  return [
68
74
  {
69
75
  name: 'now',
70
- description: 'Return the current wall-clock time as ISO 8601, local string, timezone, and unix milliseconds.',
76
+ description: 'Return the current wall-clock time in the agent-configured timezone, plus unix milliseconds.',
71
77
  inputSchema: {
72
78
  type: 'object',
73
79
  properties: {},
@@ -78,7 +84,7 @@ export class TimeModule implements Module {
78
84
 
79
85
  async handleToolCall(call: ToolCall): Promise<ToolResult> {
80
86
  if (call.name === 'now') {
81
- return { success: true, data: formatNow() };
87
+ return { success: true, data: formatNow(new Date(), this.timeZone) };
82
88
  }
83
89
  return { success: false, isError: true, error: `Unknown tool: ${call.name}` };
84
90
  }