@animalabs/connectome-host 0.3.1 → 0.3.5

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.
@@ -15,13 +15,18 @@ jobs:
15
15
  runs-on: ubuntu-latest
16
16
 
17
17
  steps:
18
- - uses: actions/checkout@v4
18
+ - uses: actions/checkout@v6
19
19
 
20
20
  - name: Setup Node.js
21
- uses: actions/setup-node@v4
21
+ uses: actions/setup-node@v6
22
22
  with:
23
23
  node-version: 24
24
24
 
25
+ - name: Setup Bun
26
+ uses: oven-sh/setup-bun@v2
27
+ with:
28
+ bun-version: 1.3.14
29
+
25
30
  - name: Install dependencies
26
31
  run: npm install
27
32
 
@@ -46,10 +51,10 @@ jobs:
46
51
  id-token: write
47
52
 
48
53
  steps:
49
- - uses: actions/checkout@v4
54
+ - uses: actions/checkout@v6
50
55
 
51
56
  - name: Setup Node.js
52
- uses: actions/setup-node@v4
57
+ uses: actions/setup-node@v6
53
58
  with:
54
59
  # node 24 ships npm >= 11.5.1, required for OIDC publishing.
55
60
  node-version: 24
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@animalabs/connectome-host",
3
- "version": "0.3.1",
3
+ "version": "0.3.5",
4
4
  "description": "General-purpose agent TUI host with recipe-based configuration",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "start": "bun src/index.ts",
8
8
  "dev": "bun --watch src/index.ts",
9
9
  "test": "bun test",
10
- "build:web": "cd web && bun install && bun run build",
11
- "postinstall": "test -d web && cd web && bun install && bun run build || true"
10
+ "build:web": "npm --prefix web install && npm --prefix web run build",
11
+ "postinstall": "test -d web && npm --prefix web install && npm --prefix web run build || true"
12
12
  },
13
13
  "dependencies": {
14
14
  "@animalabs/agent-framework": "^0.6.0",
package/src/commands.ts CHANGED
@@ -197,7 +197,13 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
197
197
  return handleCheckout(framework, args[0]);
198
198
 
199
199
  case 'history':
200
- return handleHistory(framework);
200
+ return handleHistory(framework, args[0]);
201
+
202
+ case 'branchto':
203
+ return handleBranchTo(app, args[0]);
204
+
205
+ case 'find':
206
+ return handleFind(framework, args.join(' '));
201
207
 
202
208
  case 'mcp':
203
209
  return handleMcp(args);
@@ -793,7 +799,55 @@ function handleCheckout(framework: AgentFramework, name?: string): CommandResult
793
799
  };
794
800
  }
795
801
 
796
- function handleHistory(framework: AgentFramework): CommandResult {
802
+ function handleFind(framework: AgentFramework, needle: string): CommandResult {
803
+ const cm = getAgentCM(framework);
804
+ if (!cm) return { lines: [{ text: 'No agent context manager.', style: 'system' }] };
805
+ if (!needle) return { lines: [{ text: 'Usage: /find <text>', style: 'system' }] };
806
+ const { messages } = cm.queryMessages({});
807
+ const lines: Line[] = [{ text: `--- Find "${needle}" in ${messages.length} msgs ---`, style: 'system' }];
808
+ const needleLc = needle.toLowerCase();
809
+ for (const msg of messages) {
810
+ // Search text blocks plus stringified tool i/o (case-insensitive), so ids
811
+ // buried in tool_use inputs / tool_result payloads are findable too.
812
+ const full = msg.content
813
+ .map((b) => b.type === 'text' ? b.text : JSON.stringify(b))
814
+ .join(' ');
815
+ const at = full.toLowerCase().indexOf(needleLc);
816
+ if (at >= 0) {
817
+ const snip = full.slice(Math.max(0, at - 30), at + needle.length + 45).replace(/\s+/g, ' ');
818
+ lines.push({ text: ` [${msg.id}] ${msg.participant}: ...${snip}...`, style: 'system' });
819
+ }
820
+ }
821
+ if (lines.length === 1) lines.push({ text: ' (no matches)', style: 'system' });
822
+ return { lines };
823
+ }
824
+
825
+ function handleBranchTo(app: AppContext, messageId: string | undefined): CommandResult {
826
+ const cm = getAgentCM(app.framework);
827
+ if (!cm) return { lines: [{ text: 'No agent context manager.', style: 'system' }] };
828
+ if (!messageId) return { lines: [{ text: 'Usage: /branchto <messageId>', style: 'system' }] };
829
+ const { messages } = cm.queryMessages({});
830
+ const target = messages.find(m => String(m.id) === String(messageId));
831
+ if (!target) return { lines: [{ text: `No message with id ${messageId} on current branch.`, style: 'system' }] };
832
+ const newBranchName = `rollback-at-${messageId}-${Date.now()}`;
833
+ let createdBranchName: string;
834
+ try {
835
+ createdBranchName = cm.branchAt(String(messageId), newBranchName);
836
+ } catch (err) {
837
+ return { lines: [{ text: `branchto failed (branchAt): ${err}`, style: 'system' }] };
838
+ }
839
+ const asyncWork = Promise.resolve(cm.switchBranch(createdBranchName))
840
+ .then(() => ({
841
+ lines: [{ text: `Branched at [${messageId}] -> "${createdBranchName}" and switched. Everything after [${messageId}] is dropped on this branch; old branch preserved.`, style: 'system' as const }],
842
+ branchChanged: true,
843
+ }))
844
+ .catch((err: unknown) => ({
845
+ lines: [{ text: `branchto failed (switchBranch): ${err}`, style: 'system' as const }],
846
+ }));
847
+ return { lines: [{ text: `Branching at [${messageId}] -> "${createdBranchName}"...`, style: 'system' }], asyncWork };
848
+ }
849
+
850
+ function handleHistory(framework: AgentFramework, countArg?: string): CommandResult {
797
851
  const cm = getAgentCM(framework);
798
852
  if (!cm) return { lines: [{ text: 'No agent context manager.', style: 'system' }] };
799
853
 
@@ -801,7 +855,7 @@ function handleHistory(framework: AgentFramework): CommandResult {
801
855
  const lines: Line[] = [{ text: `--- History (${messages.length} messages) ---`, style: 'system' }];
802
856
 
803
857
  // Show the last 20 messages in summary form
804
- const recent = messages.slice(-20);
858
+ const recent = messages.slice(-(countArg && Number(countArg) > 0 ? Number(countArg) : 20));
805
859
  for (const msg of recent) {
806
860
  const text = msg.content
807
861
  .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
package/src/index.ts CHANGED
@@ -34,6 +34,7 @@ import { ActivityModule } from './modules/activity-module.js';
34
34
  import { SubscriptionGcModule } from './modules/subscription-gc-module.js';
35
35
  import { ChannelModeModule } from './modules/channel-mode-module.js';
36
36
  import { WebUiModule } from './modules/web-ui-module.js';
37
+ import { ObserversModule } from './modules/observers-module.js';
37
38
  import { McplAdminModule } from './modules/mcpl-admin-module.js';
38
39
  import { loadMcplServers, applyAgentOverlay, DEFAULT_CONFIG_PATH, DEFAULT_AGENT_OVERLAY_PATH } from './mcpl-config.js';
39
40
  import { SessionManager } from './session-manager.js';
@@ -317,13 +318,21 @@ async function createFramework(
317
318
  let webUiModule: WebUiModule | null = null;
318
319
  if (modules.webui !== undefined && modules.webui !== false) {
319
320
  const webuiConfig = typeof modules.webui === 'object' ? modules.webui : {};
321
+ // Observer grants (docs/observability.md): data/observers.json by
322
+ // default, overridable via OBSERVERS_FILE. The feature is inert until
323
+ // the file holds at least one grant. The companion ObserversModule
324
+ // gives the agent grant/revoke tools over the same file — interiority
325
+ // access is the agent's to give.
326
+ const observersPath = process.env.OBSERVERS_FILE || resolve(config.dataDir, 'observers.json');
320
327
  webUiModule = new WebUiModule({
321
328
  port: webuiConfig.port,
322
329
  host: webuiConfig.host,
323
330
  basicAuth: webuiConfig.basicAuth,
324
331
  allowedOrigins: webuiConfig.allowedOrigins,
332
+ observersPath,
325
333
  });
326
334
  moduleInstances.push(webUiModule);
335
+ moduleInstances.push(new ObserversModule({ path: observersPath }));
327
336
  }
328
337
 
329
338
  // -- Build MCP server list --
@@ -31,7 +31,7 @@ import type {
31
31
  } from '@animalabs/agent-framework';
32
32
  import { spawn as spawnProcess, type ChildProcess } from 'node:child_process';
33
33
  import { connect as netConnect, type Socket } from 'node:net';
34
- import { existsSync, mkdirSync, unlinkSync, openSync, closeSync, appendFileSync } from 'node:fs';
34
+ import { existsSync, mkdirSync, unlinkSync, openSync, closeSync, appendFileSync, realpathSync } from 'node:fs';
35
35
  import { join, resolve, isAbsolute } from 'node:path';
36
36
  import { type IncomingCommand, type WireEvent, matchesSubscription } from './fleet-types.js';
37
37
  import { loadRecipe } from '../recipe.js';
@@ -1202,9 +1202,11 @@ export class FleetModule implements Module {
1202
1202
  * entries derived from `children[]`, which are absolute post-load).
1203
1203
  */
1204
1204
  private matchesAllowlist(recipe: string, resolved: string): boolean {
1205
+ const canonicalResolved = existsSync(resolved) ? realpathSync(resolved) : resolved;
1205
1206
  for (const entry of this.allowlist) {
1206
1207
  if (entry === '*') return true;
1207
- if (entry === recipe || entry === resolved) return true;
1208
+ const canonicalEntry = !entry.includes('*') && existsSync(entry) ? realpathSync(entry) : entry;
1209
+ if (entry === recipe || entry === resolved || canonicalEntry === canonicalResolved) return true;
1208
1210
  if (entry.endsWith('*') && !entry.slice(0, -1).includes('*')) {
1209
1211
  const prefix = entry.slice(0, -1);
1210
1212
  if (recipe.startsWith(prefix) || resolved.startsWith(prefix)) return true;
@@ -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
+ }