@anchrd/intel-api 0.6.7 → 0.9.0

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 (58) hide show
  1. package/README.md +63 -3
  2. package/dist/adapters/cloudflare/cloudflare.js +102 -37
  3. package/dist/adapters/cloudflare/cloudflare.types.d.ts +20 -0
  4. package/dist/adapters/content/content.d.ts +1 -1
  5. package/dist/adapters/db/db-flows.js +148 -20
  6. package/dist/adapters/db/db-grants.d.ts +13 -2
  7. package/dist/adapters/db/db-grants.js +25 -8
  8. package/dist/adapters/db/db-indexing.d.ts +2 -2
  9. package/dist/adapters/db/db-indexing.js +26 -19
  10. package/dist/adapters/db/db.d.ts +3 -3
  11. package/dist/adapters/db/db.js +442 -118
  12. package/dist/adapters/gate-applications/gate-applications.d.ts +23 -0
  13. package/dist/adapters/gate-applications/gate-applications.js +88 -0
  14. package/dist/adapters/index-queue/index-queue.d.ts +1 -1
  15. package/dist/adapters/index-queue/index-queue.js +2 -2
  16. package/dist/adapters/semantic-index/semantic-index.types.d.ts +2 -2
  17. package/dist/adapters/tool-delegation/tool-delegation.d.ts +22 -0
  18. package/dist/adapters/tool-delegation/tool-delegation.js +90 -0
  19. package/dist/agent-runtime/agent-runtime.d.ts +16 -0
  20. package/dist/agent-runtime/agent-runtime.js +150 -0
  21. package/dist/agent-runtime/agent-runtime.types.d.ts +122 -0
  22. package/dist/bundle/bundle.d.ts +4 -0
  23. package/dist/bundle/bundle.js +1048 -0
  24. package/dist/bundle/bundle.types.d.ts +33 -0
  25. package/dist/bundle/bundle.types.js +1 -0
  26. package/dist/cli/cli.js +10 -1
  27. package/dist/flows/flows.d.ts +8 -8
  28. package/dist/flows/flows.js +158 -42
  29. package/dist/flows/flows.types.d.ts +40 -7
  30. package/dist/http/http.d.ts +1 -0
  31. package/dist/http/http.js +348 -61
  32. package/dist/http/http.types.d.ts +6 -2
  33. package/dist/indexing/indexing.js +14 -2
  34. package/dist/indexing/indexing.types.d.ts +2 -2
  35. package/dist/intel/intel.js +12 -3
  36. package/dist/intel/intel.types.d.ts +6 -2
  37. package/dist/mcp/mcp.js +519 -124
  38. package/dist/mcp/mcp.types.d.ts +11 -2
  39. package/dist/nodes/nodes.d.ts +2 -0
  40. package/dist/nodes/nodes.js +1466 -0
  41. package/dist/nodes/nodes.types.d.ts +402 -0
  42. package/dist/nodes/nodes.types.js +1 -0
  43. package/dist/tools/tool-servers/tool-servers.d.ts +46 -0
  44. package/dist/tools/tool-servers/tool-servers.js +114 -0
  45. package/dist/tools/tools.js +190 -31
  46. package/dist/tools/tools.types.d.ts +23 -1
  47. package/migrations/0011_one_name_for_the_tree.sql +53 -0
  48. package/migrations/0012_table_snapshots.sql +29 -0
  49. package/migrations/0013_agents_in_the_tree.sql +76 -0
  50. package/migrations/0014_agent_applications.sql +25 -0
  51. package/migrations/0015_tools_delegated_from_a_connection.sql +15 -0
  52. package/package.json +3 -2
  53. package/dist/knowledge/knowledge.d.ts +0 -2
  54. package/dist/knowledge/knowledge.js +0 -761
  55. package/dist/knowledge/knowledge.types.d.ts +0 -198
  56. /package/dist/{knowledge/knowledge.types.js → agent-runtime/agent-runtime.types.js} +0 -0
  57. /package/dist/{knowledge → nodes}/document-links/document-links.d.ts +0 -0
  58. /package/dist/{knowledge → nodes}/document-links/document-links.js +0 -0
@@ -1,10 +1,24 @@
1
+ import { serverOf } from "@anchrd/intel-contract";
1
2
  import { IntelError } from "../shared/intel-error/intel-error.js";
2
3
  import { reportUnexpectedError } from "../shared/report-unexpected-error/report-unexpected-error.js";
4
+ import { ServerDirectoryTool, toolServersFrom, } from "./tool-servers/tool-servers.js";
3
5
  const CallTimeoutMs = 15_000;
4
6
  const MaxTools = 1_000;
5
7
  const MaxResultBytes = 1_000_000;
6
8
  // Refresh slightly early so a call cannot start with a token that expires mid-flight.
7
9
  const RefreshWindowMs = 30_000;
10
+ /**
11
+ * An agent that delegates nothing — no servers, or no delegator to act for.
12
+ *
13
+ * ⚠️ It is answered without touching the token store at all. `delegatedBy` is empty for an archived
14
+ * agent, one whose definition Intel could not read, and one that was never given tools; asking the
15
+ * store for the connection of user "" would be a lookup that can only ever fail, on a path that has
16
+ * already decided the answer.
17
+ */
18
+ function delegatesNothing(who) {
19
+ return (who.delegation !== null &&
20
+ (who.delegation.servers.length === 0 || who.delegation.delegatedBy.length === 0));
21
+ }
8
22
  export function createTools(deps) {
9
23
  function portal() {
10
24
  if (!deps.portalUrl || !deps.sourceAllowed(deps.portalUrl)) {
@@ -12,12 +26,23 @@ export function createTools(deps) {
12
26
  }
13
27
  return deps.portalUrl;
14
28
  }
29
+ // One lookup per request, before anything else happens: everything below has to know whether it
30
+ // is answering a person or an agent, and a second lookup could answer differently mid-call.
31
+ async function acting(actor) {
32
+ const delegation = await deps.delegation(actor.id);
33
+ return {
34
+ actor,
35
+ connectionOf: delegation?.delegatedBy ?? actor.id,
36
+ delegation,
37
+ };
38
+ }
15
39
  // Authorization for tools lives entirely in the portal, so "may this user act" reduces to "does
16
40
  // this user have a usable portal token". ⚠️ The token is read per actor and never shared: one
17
41
  // operator token for everybody would make every catalog the same one and the portal's Access
18
- // policies decorative (ADR-0003).
19
- async function accessToken(actor) {
20
- const stored = await deps.tokens.read(actor.id);
42
+ // policies decorative (ADR-0003). For an agent the actor IS somebody else — the delegator — which
43
+ // is the whole of D30 and the reason this takes an `Acting` rather than a `ToolActor`.
44
+ async function accessToken(who) {
45
+ const stored = await deps.tokens.read(who.connectionOf);
21
46
  if (!stored) {
22
47
  throw new IntelError(401, "portal_not_connected", "The portal has not signed this user in yet");
23
48
  }
@@ -33,20 +58,81 @@ export function createTools(deps) {
33
58
  // A token that cannot be renewed is dropped: leaving it would keep failing every call with a
34
59
  // stale credential. The browser answers this by signing in silently again (#60); an MCP
35
60
  // client sees the code and repeats its own authorization.
36
- await deps.tokens.clear(actor.id);
61
+ await deps.tokens.clear(who.connectionOf);
37
62
  throw new IntelError(401, "portal_reconnect_required", "The portal sign-in for this user has expired");
38
63
  }
39
- await deps.tokens.write(actor.id, refreshed);
64
+ await deps.tokens.write(who.connectionOf, refreshed);
40
65
  return refreshed.accessToken;
41
66
  }
42
- // One live tools/list is both the catalog and the authorization answer: the portal only returns
43
- // what this user may reach. Nothing here is cached as a permission.
44
- async function capabilities(actor) {
45
- const token = await accessToken(actor);
67
+ async function remoteTools(who) {
68
+ const token = await accessToken(who);
46
69
  const remote = await deps.remote.list(portal(), token, AbortSignal.timeout(CallTimeoutMs));
47
70
  if (remote.length > MaxTools) {
48
71
  throw new IntelError(502, "tool_catalog_too_large", "The portal returned too many tools");
49
72
  }
73
+ return remote;
74
+ }
75
+ /**
76
+ * The servers the portal says this connection reaches, confirmed against its live tool list.
77
+ *
78
+ * ⚠️ Fails rather than answering "none" when the portal keeps no directory. An empty list would
79
+ * be indistinguishable from "you have no servers", and the difference decides whether a screen
80
+ * says "connect something" or "this portal cannot be delegated from".
81
+ */
82
+ async function serverList(who, toolNames) {
83
+ if (!toolNames.includes(ServerDirectoryTool)) {
84
+ throw new IntelError(502, "tool_servers_unavailable", `The portal does not offer ${ServerDirectoryTool}, so its servers cannot be listed`);
85
+ }
86
+ const answer = await deps.remote.call({
87
+ url: portal(),
88
+ accessToken: await accessToken(who),
89
+ name: ServerDirectoryTool,
90
+ arguments: {},
91
+ signal: AbortSignal.timeout(CallTimeoutMs),
92
+ });
93
+ const directory = answer.isError ? null : toolServersFrom({ directory: answer, toolNames });
94
+ if (directory === null) {
95
+ throw new IntelError(502, "tool_servers_unavailable", "The portal did not answer with a server list this Intel can read");
96
+ }
97
+ return directory;
98
+ }
99
+ /**
100
+ * One live tools/list is both the catalog and the authorization answer: the portal only returns
101
+ * what this user may reach. Nothing here is cached as a permission.
102
+ *
103
+ * ⚠️ For an agent the list is cut to the delegated servers, and the cut is made against the
104
+ * portal's own directory rather than against the tool names — `tool-servers.ts` says why the
105
+ * namespace alone is not enough to attribute a tool. The second return value is the attribution
106
+ * itself, so the audit below names the server the cut actually used rather than guessing again.
107
+ */
108
+ async function capabilities(who) {
109
+ const delegated = who.delegation;
110
+ const serverOfTool = new Map();
111
+ if (delegatesNothing(who))
112
+ return { items: [], serverOfTool };
113
+ let remote = await remoteTools(who);
114
+ if (delegated) {
115
+ const directory = await serverList(who, remote.map((tool) => tool.name));
116
+ // ⚠️ Attributed against every identifier the portal DECLARED — including rows it marked
117
+ // disabled — and only then checked against the delegation. Attributing against the offerable
118
+ // subset (or against the delegation itself) would widen it: with `wiki` delegated and
119
+ // `wiki_extra` merely declared, `wiki_extra__read` starts with `wiki_` and would be handed
120
+ // over as a `wiki` tool. The longest match over the widest declared set is the only reading
121
+ // that no missing row can loosen.
122
+ const delegatedSet = new Set(delegated.servers);
123
+ // ⚠️ And the attribution is then required to land on a server that is still OFFERABLE — the
124
+ // narrow set, enabled and confirmed. Wide for attribution, narrow for permission: a portal
125
+ // that switches a server off takes it away from the agent here, which is revocation reaching
126
+ // an agent without anybody touching its definition (D30).
127
+ const offerable = new Set(directory.servers.map((server) => server.handle));
128
+ remote = remote.filter((tool) => {
129
+ const handle = serverOf(tool.name, directory.declared);
130
+ if (handle === null || !delegatedSet.has(handle) || !offerable.has(handle))
131
+ return false;
132
+ serverOfTool.set(tool.name, handle);
133
+ return true;
134
+ });
135
+ }
50
136
  const items = [];
51
137
  for (const tool of remote) {
52
138
  items.push({
@@ -59,27 +145,63 @@ export function createTools(deps) {
59
145
  }),
60
146
  });
61
147
  }
62
- return items;
148
+ return { items, serverOfTool };
149
+ }
150
+ /**
151
+ * ⚠️ The refusal that costs nothing. A delegated caller naming a tool outside every delegated
152
+ * namespace is turned away here — before the token store, before `tools/list`, before the portal
153
+ * hears anything at all. The authoritative cut still happens in `capabilities`, against the
154
+ * portal's directory; this one exists so the portal is never touched on behalf of a call that was
155
+ * always going to be refused, and so the refusal can name the tool and the servers (D30).
156
+ */
157
+ function requireDelegated(who, name) {
158
+ const delegated = who.delegation;
159
+ if (!delegated)
160
+ return;
161
+ if (delegatesNothing(who)) {
162
+ throw new IntelError(403, "tool_not_delegated", `${name} is not available: this agent has no delegated MCP servers`);
163
+ }
164
+ if (serverOf(name, delegated.servers) === null) {
165
+ throw new IntelError(403, "tool_not_delegated", `${name} is not part of this agent's delegated servers (${delegated.servers.join(", ")})`);
166
+ }
63
167
  }
64
- async function requireCapability(actor, name) {
65
- const found = (await capabilities(actor)).find((capability) => capability.name === name);
168
+ async function requireCapability(who, name) {
169
+ const reachable = await capabilities(who);
170
+ const found = reachable.items.find((capability) => capability.name === name);
66
171
  if (!found) {
67
172
  throw new IntelError(404, "tool_not_available", `Tool ${name} is not available to you`);
68
173
  }
69
- return found;
174
+ return { capability: found, server: reachable.serverOfTool.get(name) };
70
175
  }
71
- async function call(actor, input) {
72
- if (!actor.canExecute) {
176
+ async function call(who, input) {
177
+ if (!who.actor.canExecute) {
73
178
  throw new IntelError(403, "tool_execute_forbidden", "Tool execution permission is required");
74
179
  }
75
- const capability = await requireCapability(actor, input.name);
180
+ requireDelegated(who, input.name);
181
+ const { capability, server } = await requireCapability(who, input.name);
76
182
  const validation = deps.validate(capability.inputSchema, input.arguments);
77
183
  if (!validation.valid) {
78
184
  throw new IntelError(400, "tool_arguments_invalid", validation.detail ?? "Invalid arguments");
79
185
  }
186
+ // ⚠️ Written before the call, not after it: an audit that only records what succeeded is a
187
+ // record of the harmless half. Both principals are named — the agent that acted and the person
188
+ // whose connection carried it (D30) — and no argument is.
189
+ //
190
+ // ⚠️ The server is the one the CUT used, carried out of `capabilities`, not a second guess made
191
+ // against the delegated handles. Re-deriving it here would name `wiki` for a tool the cut
192
+ // attributed to `wiki_extra`, and an audit row that names the wrong system is worse than none.
193
+ if (who.delegation) {
194
+ await deps.audit({
195
+ agentId: who.delegation.agentId,
196
+ applicationId: who.actor.id,
197
+ delegatedBy: who.delegation.delegatedBy,
198
+ server: server ?? "",
199
+ tool: capability.name,
200
+ });
201
+ }
80
202
  const result = await deps.remote.call({
81
203
  url: portal(),
82
- accessToken: await accessToken(actor),
204
+ accessToken: await accessToken(who),
83
205
  name: capability.name,
84
206
  arguments: input.arguments,
85
207
  signal: AbortSignal.timeout(CallTimeoutMs),
@@ -89,41 +211,78 @@ export function createTools(deps) {
89
211
  }
90
212
  return result;
91
213
  }
214
+ // A token that is gone or beyond renewal is the same answer as never having signed in, so reading
215
+ // a catalog reports it as a state. Only a portal that does not answer stays an error — the view
216
+ // has to tell "sign in again" apart from "the portal failed", and a 401 here would otherwise look
217
+ // like an expired Intel session to the browser.
218
+ function disconnected(error) {
219
+ return (error instanceof IntelError &&
220
+ (error.code === "portal_not_connected" || error.code === "portal_reconnect_required"));
221
+ }
92
222
  return {
93
223
  async catalog(actor) {
94
- const stored = await deps.tokens.read(actor.id);
224
+ const who = await acting(actor);
225
+ if (delegatesNothing(who))
226
+ return { portalConnected: true, items: [] };
227
+ const stored = await deps.tokens.read(who.connectionOf);
95
228
  // No portal sign-in yet is a normal state, not an error: the browser answers it by running
96
- // the silent sign-in and asking again (#60).
229
+ // the silent sign-in and asking again (#60). For an agent it is the ordinary shape of
230
+ // revocation — the delegator disconnected, so the agent reaches nothing (D30).
231
+ if (!stored)
232
+ return { portalConnected: false, items: [] };
233
+ try {
234
+ return { portalConnected: true, items: (await capabilities(who)).items };
235
+ }
236
+ catch (error) {
237
+ if (disconnected(error))
238
+ return { portalConnected: false, items: [] };
239
+ throw error;
240
+ }
241
+ },
242
+ async servers(actor) {
243
+ const who = await acting(actor);
244
+ if (delegatesNothing(who))
245
+ return { portalConnected: true, items: [] };
246
+ const stored = await deps.tokens.read(who.connectionOf);
97
247
  if (!stored)
98
248
  return { portalConnected: false, items: [] };
99
249
  try {
100
- return { portalConnected: true, items: await capabilities(actor) };
250
+ const remote = await remoteTools(who);
251
+ const { servers } = await serverList(who, remote.map((tool) => tool.name));
252
+ // ⚠️ An agent sees its own delegation, never the delegator's whole shelf. Its catalog is cut
253
+ // anyway, so the uncut list would grant nothing — it would only tell an agent, and through
254
+ // it a model, which other systems the person it acts for is connected to.
255
+ const delegated = who.delegation;
256
+ return {
257
+ portalConnected: true,
258
+ items: delegated
259
+ ? servers.filter((server) => delegated.servers.includes(server.handle))
260
+ : servers,
261
+ };
101
262
  }
102
263
  catch (error) {
103
- // A token that is gone or beyond renewal is the same answer as never having signed in, so
104
- // reading the catalog reports it as a state. Only a portal that does not answer stays an
105
- // error — the view has to tell "sign in again" apart from "the portal failed", and a 401
106
- // here would otherwise look like an expired Intel session to the browser.
107
- if (error instanceof IntelError &&
108
- (error.code === "portal_not_connected" || error.code === "portal_reconnect_required")) {
264
+ if (disconnected(error))
109
265
  return { portalConnected: false, items: [] };
110
- }
111
266
  throw error;
112
267
  }
113
268
  },
114
- execute: async (actor, input) => await call(actor, input),
269
+ execute: async (actor, input) => await call(await acting(actor), input),
115
270
  async test(actor, input) {
116
- const capability = await requireCapability(actor, input.name);
271
+ const who = await acting(actor);
272
+ requireDelegated(who, input.name);
273
+ const { capability } = await requireCapability(who, input.name);
117
274
  if (capability.annotations.readOnlyHint !== true ||
118
275
  capability.annotations.destructiveHint === true) {
119
276
  throw new IntelError(409, "tool_test_unsafe", "Only explicitly read-only, non-destructive tools can run in the test surface");
120
277
  }
121
- return await call(actor, input);
278
+ return await call(who, input);
122
279
  },
123
280
  async unavailable(actor, names) {
124
281
  if (names.length === 0)
125
282
  return [];
126
- const available = new Set((await capabilities(actor)).map((capability) => capability.name));
283
+ const who = await acting(actor);
284
+ const reachable = await capabilities(who);
285
+ const available = new Set(reachable.items.map((capability) => capability.name));
127
286
  return names.filter((name) => !available.has(name));
128
287
  },
129
288
  };
@@ -1,4 +1,4 @@
1
- import type { ExecuteToolInput, TestToolInput, ToolCapability, ToolCatalog, ToolTestResult } from "@anchrd/intel-contract";
1
+ import type { ExecuteToolInput, TestToolInput, ToolCapability, ToolCatalog, ToolServerCatalog, ToolTestResult } from "@anchrd/intel-contract";
2
2
  export interface ToolActor {
3
3
  id: string;
4
4
  email: string;
@@ -35,6 +35,25 @@ export interface RemoteTools {
35
35
  signal: AbortSignal;
36
36
  }): Promise<ToolTestResult>;
37
37
  }
38
+ /**
39
+ * What an agent's definition delegates, resolved from the Gate Application the caller authenticated
40
+ * as (D30). `null` from the port means "this caller is not an agent" — the ordinary user path.
41
+ *
42
+ * ⚠️ Read fresh on every call and never cached as a permission. It is the same rule the catalog
43
+ * follows: the answer has to be able to change between two runs without anybody editing anything.
44
+ */
45
+ export interface ToolDelegation {
46
+ agentId: string;
47
+ delegatedBy: string;
48
+ servers: string[];
49
+ }
50
+ export interface ToolAuditEvent {
51
+ agentId: string;
52
+ applicationId: string;
53
+ delegatedBy: string;
54
+ server: string;
55
+ tool: string;
56
+ }
38
57
  export interface ToolDeps {
39
58
  portalUrl: string | null;
40
59
  remote: RemoteTools;
@@ -47,9 +66,12 @@ export interface ToolDeps {
47
66
  valid: boolean;
48
67
  detail?: string;
49
68
  };
69
+ delegation(applicationId: string): Promise<ToolDelegation | null>;
70
+ audit(event: ToolAuditEvent): Promise<void>;
50
71
  }
51
72
  export interface ToolService {
52
73
  catalog(actor: ToolActor): Promise<ToolCatalog>;
74
+ servers(actor: ToolActor): Promise<ToolServerCatalog>;
53
75
  execute(actor: ToolActor, input: ExecuteToolInput): Promise<ToolTestResult>;
54
76
  test(actor: ToolActor, input: TestToolInput): Promise<ToolTestResult>;
55
77
  unavailable(actor: ToolActor, names: string[]): Promise<string[]>;
@@ -0,0 +1,53 @@
1
+ -- #125: `knowledge` was the first draft's word for the shared tree. The product is called intel,
2
+ -- and a second name for one concept costs a translation in every session. The prefix goes: the tree
3
+ -- is `nodes`, and the four tables around it are named after the node they belong to.
4
+ --
5
+ -- Nothing is rebuilt here, and that is the whole point of the file. `ALTER TABLE ... RENAME TO` is
6
+ -- the one way to change a table's name without a `DROP TABLE`, and `DROP TABLE` is what made the
7
+ -- two migrations that touched this table difficult:
8
+ --
9
+ -- - 0005 had to carry `knowledge_links` out of the way and put it back afterwards, because the
10
+ -- implicit `DELETE FROM` behind `DROP TABLE` fires the ON DELETE CASCADE those rows hang on.
11
+ -- Without the rescue the migration would have committed with every relationship between two
12
+ -- documents quietly gone.
13
+ -- - 0009 gave up on a rebuild altogether. D1 commits the statements of a migration file one at a
14
+ -- time, so `PRAGMA defer_foreign_keys` is spent before the DROP arrives, and
15
+ -- `PRAGMA foreign_keys = OFF` is ignored by D1 over its HTTP API while miniflare honours it —
16
+ -- which is how a green local test can accompany a red remote migration.
17
+ --
18
+ -- A rename touches no row, so neither trap is reachable from here. SQLite rewrites the REFERENCES
19
+ -- clauses of every table pointing at the renamed one — `node_versions`, `node_links` (twice),
20
+ -- `tree_grants`, `flows`, and the tree's reference to itself — so all six foreign keys survive under
21
+ -- the new name and no cascade has anything to fire on. That rewriting is conditional on foreign keys
22
+ -- being enabled, which is exactly what 0005 established the hard way when the cascade took its link
23
+ -- rows: D1 has them on.
24
+ --
25
+ -- ⚠️ Indexes do not follow a rename. They keep working, because an index is bound to its table
26
+ -- rather than to its table's name, but they keep the old name in `sqlite_master` and would be the
27
+ -- last place `knowledge` survives. Dropping and recreating one is free of everything above: an
28
+ -- index holds no rows of its own, and no foreign key points at it.
29
+ --
30
+ -- `node_fts` is a derived FTS5 index and is renamed along with the rest rather than rebuilt; FTS5
31
+ -- renames its own shadow tables. If it ever did come out empty, `intel reindex` builds it back from
32
+ -- D1 and R2 — but nothing here asks it to.
33
+ ALTER TABLE knowledge_nodes RENAME TO nodes;
34
+ ALTER TABLE knowledge_versions RENAME TO node_versions;
35
+ ALTER TABLE knowledge_links RENAME TO node_links;
36
+ ALTER TABLE knowledge_index_state RENAME TO node_index_state;
37
+ ALTER TABLE knowledge_fts RENAME TO node_fts;
38
+
39
+ DROP INDEX knowledge_nodes_parent_idx;
40
+ DROP INDEX knowledge_nodes_owner_idx;
41
+ DROP INDEX knowledge_versions_node_idx;
42
+ DROP INDEX knowledge_links_source_idx;
43
+ DROP INDEX knowledge_links_target_idx;
44
+ DROP INDEX knowledge_links_origin_idx;
45
+ DROP INDEX knowledge_index_state_status_idx;
46
+
47
+ CREATE INDEX nodes_parent_idx ON nodes(parent_id, archived_at, title);
48
+ CREATE INDEX nodes_owner_idx ON nodes(owner_id, archived_at);
49
+ CREATE INDEX node_versions_node_idx ON node_versions(node_id, sequence DESC);
50
+ CREATE INDEX node_links_source_idx ON node_links(source_node_id, created_at);
51
+ CREATE INDEX node_links_target_idx ON node_links(target_node_id, created_at);
52
+ CREATE INDEX node_links_origin_idx ON node_links(source_node_id, origin);
53
+ CREATE INDEX node_index_state_status_idx ON node_index_state(status, updated_at);
@@ -0,0 +1,29 @@
1
+ -- #135: a table version says what it carries. An 'append' holds only the rows one write added; a
2
+ -- 'snapshot' holds the complete table — header and every row — so reading starts at the newest
3
+ -- snapshot and everything before it stays history. Update, delete, and redefine write snapshots;
4
+ -- append keeps writing appends; documents and attachments stay NULL because each of their versions
5
+ -- is complete by construction and the word would say nothing about them.
6
+ --
7
+ -- A plain ADD COLUMN, deliberately: unlike 0005 this touches no CHECK an existing column carries,
8
+ -- so nothing has to be rebuilt and no foreign key is ever in flight.
9
+ ALTER TABLE node_versions ADD COLUMN segment TEXT
10
+ CHECK (segment IS NULL OR segment IN ('append', 'snapshot'));
11
+
12
+ -- Every table that exists was defined through `defineTable`, so its first version is the header
13
+ -- segment — the complete state of the moment it was written, which is exactly what a snapshot is.
14
+ -- Marking it so is what lets "read from the newest snapshot" answer for old tables without a
15
+ -- special case for "no snapshot yet". Every later segment of an existing table is an append.
16
+ UPDATE node_versions
17
+ SET segment = CASE
18
+ WHEN sequence = (
19
+ SELECT MIN(inner_version.sequence) FROM node_versions inner_version
20
+ WHERE inner_version.node_id = node_versions.node_id
21
+ ) THEN 'snapshot'
22
+ ELSE 'append'
23
+ END
24
+ WHERE node_id IN (SELECT id FROM nodes WHERE kind = 'table');
25
+
26
+ -- The two questions every table read asks — "where is the newest snapshot" and "which segments
27
+ -- follow it" — must not scan the whole history to be answered (#30).
28
+ CREATE INDEX node_versions_segment_idx
29
+ ON node_versions(node_id, segment, sequence DESC);
@@ -0,0 +1,76 @@
1
+ -- #139: a fifth kind in the shared tree — `agent`. It is a node like the other four: same
2
+ -- `parent_id`, same folder grants, same immutable `node_versions` rows, same R2 body (ADR-0005 §1).
3
+ -- Its body happens to be a JSON definition rather than prose, which the schema neither knows nor
4
+ -- needs to: nothing here creates a second content model, only the CHECK has to learn the word.
5
+ --
6
+ -- SQLite cannot alter a CHECK constraint, so the table is rebuilt — the same rebuild 0005 performed
7
+ -- for `table`, written the same long way round for the same two reasons. Both detours below are
8
+ -- copied from a file that earned them against a real database, not from caution.
9
+ --
10
+ -- ⚠️ First detour: the new table is created under the final name rather than built beside the old
11
+ -- one and renamed over it. `DROP TABLE` on a parent runs an implicit `DELETE FROM` first, so the
12
+ -- moment the old `nodes` goes, every row of `node_versions`, `tree_grants` and `flows` pointing at a
13
+ -- node is a foreign-key violation. `defer_foreign_keys` postpones the complaint to COMMIT but does
14
+ -- not withdraw it, and `ALTER TABLE ... RENAME` does not settle it either: a rename puts the name
15
+ -- back, not the rows. Only inserting the nodes again, under the name the children have referenced
16
+ -- all along, does. This is also why the self-reference below reads `REFERENCES nodes(id)` — the
17
+ -- final name — which is lesson 2 of the three recorded in `0009_no_context_policy.sql`.
18
+ --
19
+ -- ⚠️ Second detour: `node_links` is the only child of `nodes` declared ON DELETE CASCADE, so that
20
+ -- same implicit delete does not merely flag its rows, it removes them — the migration would commit
21
+ -- with every relationship between two documents quietly gone. The rows are carried out of the way
22
+ -- first and put back afterwards. That is a rescue, not a decision about the data: nothing is
23
+ -- dropped, rewritten or reinterpreted here.
24
+ --
25
+ -- ⚠️ On an empty database neither detour is visible, because nothing points at anything. That is
26
+ -- exactly how 0005's first version passed a green suite and then failed against the first database
27
+ -- with content in it, and why the proof for this file is a row count of every referencing table
28
+ -- before and after rather than a migration that merely ran.
29
+ --
30
+ -- `context_policy` is carried over unchanged and still NOT NULL. It is dead for every consumer
31
+ -- (#76) and only D1's refusal to drop a column a CHECK names keeps it here; removing it is #86 and
32
+ -- deliberately not smuggled into this rebuild.
33
+ PRAGMA defer_foreign_keys = TRUE;
34
+
35
+ -- Plain holding tables on purpose: no keys, no CHECKs, no foreign keys, and the column set taken
36
+ -- from whatever the live table has. Anything enforced here would only be enforced a second time on
37
+ -- the way back in, and a holding table that can reject a row is a holding table that can lose one.
38
+ CREATE TABLE nodes_carry AS SELECT * FROM nodes;
39
+ CREATE TABLE node_links_carry AS SELECT * FROM node_links;
40
+
41
+ DROP TABLE nodes;
42
+
43
+ CREATE TABLE nodes (
44
+ id TEXT PRIMARY KEY NOT NULL,
45
+ parent_id TEXT REFERENCES nodes(id),
46
+ kind TEXT NOT NULL CHECK (kind IN ('folder', 'document', 'attachment', 'table', 'agent')),
47
+ title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 240),
48
+ description TEXT CHECK (description IS NULL OR length(description) <= 2000),
49
+ context_policy TEXT NOT NULL CHECK (context_policy IN ('pinned', 'relevant', 'explicit')),
50
+ owner_id TEXT NOT NULL,
51
+ current_version_id TEXT,
52
+ created_at TEXT NOT NULL,
53
+ updated_at TEXT NOT NULL,
54
+ archived_at TEXT
55
+ );
56
+
57
+ INSERT INTO nodes (
58
+ id, parent_id, kind, title, description, context_policy, owner_id,
59
+ current_version_id, created_at, updated_at, archived_at
60
+ )
61
+ SELECT
62
+ id, parent_id, kind, title, description, context_policy, owner_id,
63
+ current_version_id, created_at, updated_at, archived_at
64
+ FROM nodes_carry;
65
+
66
+ -- `OR IGNORE` because whether the cascade above actually fired is SQLite's business, not this
67
+ -- migration's: if it did, this puts the rows back; if it did not, each one is already present under
68
+ -- the same primary key and this is a no-op. Either way `node_links` ends up holding exactly what it
69
+ -- held before, which is the only outcome this statement is permitted to have.
70
+ INSERT OR IGNORE INTO node_links SELECT * FROM node_links_carry;
71
+
72
+ DROP TABLE nodes_carry;
73
+ DROP TABLE node_links_carry;
74
+
75
+ CREATE INDEX nodes_parent_idx ON nodes(parent_id, archived_at, title);
76
+ CREATE INDEX nodes_owner_idx ON nodes(owner_id, archived_at);
@@ -0,0 +1,25 @@
1
+ -- #182: which Gate Application an agent node runs as. One row per agent that has a principal, and
2
+ -- nothing else — the row is a NAME, never a credential.
3
+ --
4
+ -- ⚠️ The Application KEY has no column here and must never get one (D27). Gate hands a key out once
5
+ -- in plain text and keeps only its hash, so there is nothing to store even in principle; the key
6
+ -- lives in the runtime's `AGENT_APPLICATION_KEYS` secret and reaches it through the one response
7
+ -- that created the agent. A column for it would turn every backup of this database into a set of
8
+ -- machine credentials.
9
+ --
10
+ -- ⚠️ A table rather than a column on `nodes`, for two reasons. The column would be NULL for every
11
+ -- folder, document, attachment and table there will ever be, which is a shape that says nothing
12
+ -- about four of the five kinds; and adding it would mean rebuilding `nodes` — SQLite cannot alter
13
+ -- a table a CHECK constrains — with the two detours `0013_agents_in_the_tree.sql` records. This
14
+ -- file writes one new table and touches nothing that exists.
15
+ --
16
+ -- ⚠️ Deliberately NOT `ON DELETE CASCADE`, unlike `node_links`. Migration 0013 explains what that
17
+ -- one cascade cost the rebuild it had to survive: an implicit `DELETE FROM` on the parent removed
18
+ -- its rows outright rather than merely flagging them, and they had to be carried out of the way and
19
+ -- put back. A second cascading child would hand the next rebuild the same trap twice. Nodes are
20
+ -- archived, never deleted, so nothing is being kept alive by leaving the cascade off.
21
+ CREATE TABLE agent_applications (
22
+ node_id TEXT PRIMARY KEY NOT NULL REFERENCES nodes(id),
23
+ application_id TEXT NOT NULL,
24
+ created_at TEXT NOT NULL
25
+ );
@@ -0,0 +1,15 @@
1
+ -- #208 (D30): an agent's tools are whole MCP servers its owner delegated from the owner's own
2
+ -- portal connection. Nothing about that selection is stored here — it lives in the agent's
3
+ -- definition, versioned in R2 like the rest of the document.
4
+ --
5
+ -- What this file adds is the one lookup the run path needs and `0014` did not provide: a call
6
+ -- arrives authenticated as a Gate Application, and Intel has to answer "which agent is that"
7
+ -- before it may read anybody's portal token. `0014` indexed `node_id` only (it is the primary
8
+ -- key), so the reverse question was a table scan on every delegated tool call.
9
+ --
10
+ -- ⚠️ UNIQUE, not just an index. Two agents sharing one Application would make the lookup ambiguous
11
+ -- and would hand one agent the other's delegation — the same class of hole `AGENT_APPLICATION_KEYS`
12
+ -- refuses a doubly listed id for (D27). The constraint is what makes "one Application per agent"
13
+ -- a fact of the database rather than a promise of the code that writes it.
14
+ CREATE UNIQUE INDEX agent_applications_application_idx
15
+ ON agent_applications(application_id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.6.7",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -43,9 +43,10 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@anchrd/gate-sdk": "^0.7.0",
46
- "@anchrd/intel-contract": "^0.4.0",
46
+ "@anchrd/intel-contract": "^0.7.0",
47
47
  "@modelcontextprotocol/sdk": "^1.30.0",
48
48
  "ajv": "^8.20.0",
49
+ "fflate": "^0.8.3",
49
50
  "hono": "^4.12.32",
50
51
  "openid-client": "^6.8.4",
51
52
  "ulid": "^3.0.2",
@@ -1,2 +0,0 @@
1
- import type { KnowledgeDeps, KnowledgeService } from "./knowledge.types.js";
2
- export declare function createKnowledge(deps: KnowledgeDeps): KnowledgeService;