@anchrd/intel-api 0.13.0 → 0.15.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 (57) hide show
  1. package/dist/adapters/cloudflare/cloudflare.js +1 -68
  2. package/dist/adapters/cloudflare/cloudflare.types.d.ts +0 -39
  3. package/dist/adapters/db/db-flows.js +1 -1
  4. package/dist/adapters/db/db-grants.js +1 -1
  5. package/dist/adapters/db/db-indexing.js +79 -0
  6. package/dist/adapters/db/db.js +81 -139
  7. package/dist/adapters/semantic-index/semantic-index.js +97 -17
  8. package/dist/adapters/semantic-index/semantic-index.types.d.ts +20 -1
  9. package/dist/bundle/bundle.js +42 -134
  10. package/dist/cli/cli.js +3 -9
  11. package/dist/http/http.js +5 -206
  12. package/dist/http/http.types.d.ts +0 -8
  13. package/dist/indexing/indexing.js +133 -55
  14. package/dist/indexing/indexing.types.d.ts +1 -0
  15. package/dist/intel/intel.js +4 -9
  16. package/dist/intel/intel.types.d.ts +0 -6
  17. package/dist/mcp/mcp.js +33 -308
  18. package/dist/mcp/mcp.types.d.ts +2 -7
  19. package/dist/nodes/document-links/document-links.d.ts +6 -8
  20. package/dist/nodes/document-links/document-links.js +8 -31
  21. package/dist/nodes/nodes.js +92 -826
  22. package/dist/nodes/nodes.types.d.ts +57 -158
  23. package/dist/tools/tools.js +37 -148
  24. package/dist/tools/tools.types.d.ts +0 -21
  25. package/migrations/0009_no_context_policy.sql +15 -0
  26. package/migrations/0017_a_vector_per_card.sql +38 -0
  27. package/migrations/0018_no_context_policy_at_last.sql +97 -0
  28. package/migrations/0019_one_name_for_the_grants.sql +52 -0
  29. package/package.json +2 -2
  30. package/dist/adapters/cloudflare-api/cloudflare-api.d.ts +0 -22
  31. package/dist/adapters/cloudflare-api/cloudflare-api.js +0 -214
  32. package/dist/adapters/cloudflare-api/cloudflare-api.types.d.ts +0 -64
  33. package/dist/adapters/cloudflare-api/cloudflare-api.types.js +0 -1
  34. package/dist/adapters/gate-applications/gate-applications.d.ts +0 -23
  35. package/dist/adapters/gate-applications/gate-applications.js +0 -88
  36. package/dist/adapters/tool-delegation/tool-delegation.d.ts +0 -22
  37. package/dist/adapters/tool-delegation/tool-delegation.js +0 -90
  38. package/dist/agent-costs/agent-costs.d.ts +0 -16
  39. package/dist/agent-costs/agent-costs.js +0 -105
  40. package/dist/agent-costs/agent-costs.types.d.ts +0 -30
  41. package/dist/agent-costs/agent-costs.types.js +0 -1
  42. package/dist/agent-runtime/agent-runtime.d.ts +0 -16
  43. package/dist/agent-runtime/agent-runtime.js +0 -150
  44. package/dist/agent-runtime/agent-runtime.types.d.ts +0 -122
  45. package/dist/agent-runtime/agent-runtime.types.js +0 -1
  46. package/dist/model-catalog/model-catalog.d.ts +0 -2
  47. package/dist/model-catalog/model-catalog.js +0 -99
  48. package/dist/model-catalog/model-catalog.types.d.ts +0 -15
  49. package/dist/model-catalog/model-catalog.types.js +0 -1
  50. package/dist/nodes/board/board.d.ts +0 -59
  51. package/dist/nodes/board/board.js +0 -528
  52. package/dist/nodes/board/board.types.d.ts +0 -31
  53. package/dist/nodes/board/board.types.js +0 -1
  54. package/migrations/0013_agents_in_the_tree.sql +0 -76
  55. package/migrations/0014_agent_applications.sql +0 -25
  56. package/migrations/0015_tools_delegated_from_a_connection.sql +0 -15
  57. package/migrations/0016_boards_in_the_tree.sql +0 -80
@@ -1,7 +1,13 @@
1
- import { BlockNoteDocument, BlockNoteMediaType, BoardDocument } from "@anchrd/intel-contract";
2
- import { upgradeStoredBoard } from "../nodes/board/board.js";
1
+ import { BlockNoteDocument, BlockNoteMediaType } from "@anchrd/intel-contract";
3
2
  export class PermanentIndexingError extends Error {
4
3
  }
4
+ // The key under which a node with exactly one vector is filed — since #390 that is every kind. It
5
+ // is the bare node id in the index itself, which is why nothing written before anchrd/intel#301 has
6
+ // to be renamed or embedded again.
7
+ const wholeNodeChunkKey = "";
8
+ // What the pass hands to `purgeVectors` at its end: the names it has just written, in case the node
9
+ // was archived while it worked and the record of them was refused (anchrd/intel#348).
10
+ const writtenKeys = (chunks) => chunks.map((chunk) => chunk.key);
5
11
  function indexText(mediaType, content) {
6
12
  if (mediaType !== BlockNoteMediaType)
7
13
  return content;
@@ -15,43 +21,114 @@ function indexText(mediaType, content) {
15
21
  }
16
22
  }
17
23
  /**
18
- * One passage per task, never one per board (#285).
24
+ * What one chunk is embedded as (anchrd/intel#301).
19
25
  *
20
- * ⚠️ This is the whole reason a board is chunked at all. "Where do I stand with X" has to land on a
21
- * CARD: indexed as one blob, a board of three hundred tasks matches on any of them and answers with
22
- * whichever thousand characters the snippet function happened to cut text from a task that may
23
- * have nothing to do with the question. Each chunk carries the task's own title, which is the
24
- * column the FTS table weights highest.
26
+ * The node's title in front of a whole-node chunk, which is the exact string the semantic adapter
27
+ * used to compose on its own side. It moved here because the fingerprint has to cover what is
28
+ * embedded and nothing else: a prefix added downstream would sit outside the comparison and change
29
+ * nothing when it changed. The vector it produces therefore keeps the same name AND the same
30
+ * content it had before this — an upgrading installation loses no answer, though its first pass
31
+ * over any node does embed once more, because `node_vectors` starts empty and has no fingerprint to
32
+ * compare against. That cost is one embedding per node, paid when that node is next saved or when
33
+ * an administrator runs `reindex`, and never per card.
25
34
  *
26
- * ⚠️ The status list is not indexed. "Backlog" and "Done" appear on every board in the
27
- * installation, so they are the words most likely to match and the least likely to mean anything.
35
+ * ⚠️ Only the WHOLE-node chunk is prefixed, and the distinction is what kept a rename cheap while
36
+ * a node could hold many chunks: a per-chunk prefix would have put the node's title into every
37
+ * fingerprint, so renaming it once cost one embedding per chunk.
28
38
  */
29
- function boardChunks(content) {
30
- let parsed;
31
- try {
32
- parsed = JSON.parse(content);
33
- }
34
- catch (error) {
35
- throw new PermanentIndexingError(`Node version content is not valid JSON: ${error instanceof Error ? error.message : "unknown parse failure"}`);
36
- }
37
- /**
38
- * ⚠️ Upgraded first, exactly like `parseStoredBoard` — this is the SECOND place a stored board
39
- * body is parsed, and it reads bodies nobody has rewritten since (anchrd/intel#318).
40
- *
41
- * `reindex` walks every current version in the installation, so an old board arrives here as it
42
- * was written: without `terminal` (#311) or with a repeated `dependsOn`. Without the upgrade the
43
- * refusal below is PERMANENT the version is marked failed and never retried so that board
44
- * would simply stop being findable, quietly, while its screen still worked. A rule added at one
45
- * parse site and not the other is how a fix looks complete and is not.
46
- */
47
- const board = BoardDocument.safeParse(upgradeStoredBoard(parsed));
48
- if (!board.success) {
49
- throw new PermanentIndexingError("Node version content is not a valid board document");
39
+ function embeddedText(target, chunk) {
40
+ return chunk.key === wholeNodeChunkKey ? `${target.title}\n\n${chunk.text}` : chunk.text;
41
+ }
42
+ /**
43
+ * The vectors this node needs, minus the ones it already has (anchrd/intel#301).
44
+ *
45
+ * ⚠️ This is the answer to "an unchanged chunk must not be embedded again". A save writes the
46
+ * whole document, so without this every save would re-embed everything it holds. The fingerprint of
47
+ * the exact text that was embedded is what tells an untouched chunk from a changed one.
48
+ *
49
+ * ⚠️ The comparison is against D1 and deliberately not against Vectorize. Vectorize writes are
50
+ * asynchronous — a vector upserted a moment ago is not readable yet — so a check made there would
51
+ * answer about the save before last and re-embed a node that had just been saved twice.
52
+ *
53
+ * ⚠️ The record is written only after the upsert returned, in the caller. A pass that dies in
54
+ * between leaves the record short, so the next one embeds again; the opposite order would leave a
55
+ * record claiming a vector nobody ever wrote, and nothing would ever notice.
56
+ */
57
+ async function replaceVectors(deps, target, chunks) {
58
+ const semantic = deps.semantic;
59
+ if (!semantic)
60
+ return;
61
+ const stored = await deps.repository.listVectors(target.nodeId);
62
+ const known = new Map(stored.map((record) => [record.chunkKey, record.fingerprint]));
63
+ const records = [];
64
+ const changed = [];
65
+ for (const chunk of chunks) {
66
+ const text = embeddedText(target, chunk);
67
+ const fingerprint = await deps.hash(text);
68
+ if (known.get(chunk.key) !== fingerprint)
69
+ changed.push({ key: chunk.key, text });
70
+ records.push({ chunkKey: chunk.key, fingerprint, passage: chunk.text });
50
71
  }
51
- return board.data.tasks.map((task) => ({
52
- title: task.title,
53
- text: [task.title, ...task.labels, task.description].filter(Boolean).join("\n\n"),
54
- }));
72
+ const wanted = new Set(chunks.map((chunk) => chunk.key));
73
+ const stale = stored.map((record) => record.chunkKey).filter((key) => !wanted.has(key));
74
+ // ⚠️ The one-time sweep of the vector this node had BEFORE it was chunked. An empty record with a
75
+ // chunked node means this is the first pass since anchrd/intel#301 (or since `reindex` emptied
76
+ // the record), and the whole-node vector written under the bare node id is still sitting there —
77
+ // matching questions and answering with the wrong card's passage. Once a record exists this
78
+ // branch is never taken again, so it costs one call per node rather than one per save.
79
+ if (stored.length === 0 && !wanted.has(wholeNodeChunkKey))
80
+ stale.push(wholeNodeChunkKey);
81
+ await semantic.upsert(target, changed);
82
+ if (stale.length > 0)
83
+ await semantic.remove(target.nodeId, stale);
84
+ await deps.repository.replaceVectors(target, records);
85
+ }
86
+ /**
87
+ * Everything an archived node had in the vector index, taken out of it (anchrd/intel#348).
88
+ *
89
+ * ⚠️ The ORDER is the whole safety argument, and it is the exact mirror of the one above. There the
90
+ * record is written last, because a row claiming a vector nobody wrote would make the next pass skip
91
+ * that card forever. Here the record is DELETED last, because the row is the only thing that knows
92
+ * what a vector is called: drop it first and the vectors stay in the index under names nothing left
93
+ * anywhere can produce. A pass that dies in between leaves vectors already gone and a record that
94
+ * still names them — which costs the next pass one embedding per card and nothing else, because the
95
+ * queue redelivers this message and the second run asks Vectorize to forget names it has already
96
+ * forgotten. Both failures are repaired by repeating; neither ends in a wrong answer.
97
+ *
98
+ * ⚠️ The bare node id goes with them whether or not it is in the record. That name is the ONE vector
99
+ * a node can hold without any record of it — everything written before anchrd/intel#301 is filed
100
+ * under it — and an archived node has no later pass in which the one-time sweep in `replaceVectors`
101
+ * could find it. The standing cost of that is one `deleteByIds` of a single name on a redelivered
102
+ * message, which Vectorize answers without complaint for an id it no longer holds.
103
+ *
104
+ * ⚠️ `alsoNamed` is what makes this run after a NORMAL pass too, and it closes the one hole a purge
105
+ * driven from `archive` alone cannot. A pass that has already upserted its vectors when somebody
106
+ * archives the node writes no record at all — `replaceVectors` refuses on `archived_at IS NULL` —
107
+ * so those vectors would sit in the index with nothing anywhere able to name them, and no message
108
+ * left to repair it. The pass knows the names it just wrote, so it hands them over.
109
+ *
110
+ * ⚠️ Without a semantic index configured nothing happens at all, `node_vectors` included. The rows
111
+ * can only have been written while one WAS configured, so a deployment that has temporarily lost the
112
+ * binding must not take the record away — that would leave the vectors behind it unnameable, which
113
+ * is the failure this whole function exists to avoid.
114
+ *
115
+ * ⚠️ One race is left and it is the harmless direction: a restore that lands between the read below
116
+ * and the deletion loses the vectors that restore's own pass had just written. The node keeps
117
+ * answering out of the lexical half — `hydrateVisibleCitations` falls back on the full-text passage
118
+ * when there is no `node_vectors` row — and the next save or `reindex` puts the vectors back. The
119
+ * record is gone with them, so nothing claims otherwise in the meantime.
120
+ */
121
+ async function purgeVectors(deps, versionId, alsoNamed = []) {
122
+ const semantic = deps.semantic;
123
+ if (!semantic)
124
+ return;
125
+ const nodeId = await deps.repository.archivedNodeId(versionId);
126
+ if (nodeId === null)
127
+ return;
128
+ const recorded = (await deps.repository.listVectors(nodeId)).map((record) => record.chunkKey);
129
+ const keys = [...new Set([wholeNodeChunkKey, ...recorded, ...alsoNamed])];
130
+ await semantic.remove(nodeId, keys);
131
+ await deps.repository.deleteVectors(nodeId);
55
132
  }
56
133
  async function readCanonical(deps, target) {
57
134
  if (target.kind === "attachment") {
@@ -67,20 +144,13 @@ export function createIndexing(deps) {
67
144
  return {
68
145
  async index(versionId) {
69
146
  const target = await deps.repository.getTarget(versionId);
70
- if (!target)
71
- return;
72
- // ⚠️ An agent's body is a definition, not something to read: node IDs, a cron line and a model
73
- // name (ADR-0005 §4). Indexing it verbatim would fill the index with identifiers and, worse,
74
- // put them into the passage a searcher is shown — the definition names nodes the searcher may
75
- // have no access to, and a snippet is the one place their IDs would become visible without
76
- // the tree being asked. What makes an agent findable is what a person wrote about it, so the
77
- // text is its description, and the FTS table indexes the title on its own (#139).
78
- if (target.kind === "agent") {
79
- await deps.repository.replace(target, [
80
- { title: target.title, text: target.description ?? "" },
81
- ]);
82
- await deps.semantic?.replace(target, target.description ?? "");
83
- await deps.repository.markIndexed(versionId, deps.now().toISOString());
147
+ // ⚠️ No target has meant "nothing to do" since the pass existed, and for a superseded version
148
+ // it still does. For an ARCHIVED node it never did: its vectors stayed in the index, invisible
149
+ // because every citation is hydrated through a join on `nodes` and costing places in a
150
+ // candidate list that Vectorize caps at 100 for everybody (anchrd/intel#348). `purgeVectors`
151
+ // is the one that tells the two apart; this call cannot.
152
+ if (!target) {
153
+ await purgeVectors(deps, versionId);
84
154
  return;
85
155
  }
86
156
  // An attachment is one object of bytes; text kinds may be several, because a table's content
@@ -100,12 +170,20 @@ export function createIndexing(deps) {
100
170
  if (text === undefined) {
101
171
  throw new PermanentIndexingError(`No document converter is configured for ${target.mediaType}`);
102
172
  }
103
- // ⚠️ The semantic index stays per NODE while the lexical one gains a row per task. Vectorize
104
- // is keyed by node id (`semantic-index.ts`), so a board would need its own key space there
105
- // before it could carry one vector per card — a bigger change than #285, and one nothing
106
- // asks for yet: the lexical half is what "find the card" needs today.
107
- await deps.repository.replace(target, target.kind === "board" ? boardChunks(text) : [{ title: target.title, text }]);
108
- await deps.semantic?.replace(target, text);
173
+ // ⚠️ One chunk per node since #390, and the LIST is what stays. Both indexes read it, so
174
+ // they cannot come to disagree about what a node is made of which is the property that
175
+ // let a board be one passage and one vector per card (#285, anchrd/intel#301) without
176
+ // either half being told about it separately.
177
+ const chunks = [{ key: wholeNodeChunkKey, title: target.title, text }];
178
+ await deps.repository.replace(target, chunks);
179
+ await replaceVectors(deps, target, chunks);
180
+ // ⚠️ Asked again, at the END of a pass that started on a live node (anchrd/intel#348). The
181
+ // node may have been archived while this pass was embedding, and then `replaceVectors` above
182
+ // wrote no record at all — its statements refuse on `archived_at IS NULL`. The vectors are in
183
+ // the index either way, so without this they would stay there with nothing anywhere able to
184
+ // name them and no message left to try again: the archive's own pass has an empty record to
185
+ // read from. One extra `SELECT` per pass buys that, and on a live node it answers null.
186
+ await purgeVectors(deps, versionId, writtenKeys(chunks));
109
187
  await deps.repository.markIndexed(versionId, deps.now().toISOString());
110
188
  }
111
189
  catch (error) {
@@ -6,5 +6,6 @@ export interface IndexingDeps {
6
6
  content: ContentStore;
7
7
  semantic?: SemanticIndex | undefined;
8
8
  converter?: DocumentConverter | undefined;
9
+ hash(content: string): Promise<string>;
9
10
  now(): Date;
10
11
  }
@@ -72,9 +72,6 @@ export function createIntel(deps) {
72
72
  flows: deps.flows,
73
73
  tools: deps.tools,
74
74
  bundle: deps.bundle,
75
- agents: deps.agents,
76
- agentCosts: deps.agentCosts,
77
- models: deps.models,
78
75
  resource,
79
76
  resourceMetadataUrl,
80
77
  ...(deps.auth ? { auth: deps.auth } : {}),
@@ -87,13 +84,11 @@ export function createIntel(deps) {
87
84
  }
88
85
  return await handleMcp(context.req.raw, {
89
86
  authorization,
90
- // ⚠️ The very token this call was authorized with, so the agent tools reach the runtime as the
91
- // caller and not as Intel. There is no cookie path on `/mcp`, so `bearer` is the same value
92
- // `authorize` just used — read again rather than passed out of it, because a function that
93
- // returned a credential beside its answer would be one somebody logs.
87
+ // ⚠️ The very token this call was authorized with, so a tool that has to speak to Gate does
88
+ // it as the caller and not as Intel. There is no cookie path on `/mcp`, so `bearer` is the
89
+ // same value `authorize` just used — read again rather than passed out of it, because a
90
+ // function that returned a credential beside its answer would be one somebody logs.
94
91
  bearer: bearer(context.req.raw.headers) ?? "",
95
- agents: deps.agents,
96
- agentCosts: deps.agentCosts,
97
92
  flows: deps.flows,
98
93
  nodes: deps.nodes,
99
94
  tools: deps.tools,
@@ -1,10 +1,7 @@
1
1
  import type { GateClient } from "@anchrd/gate-sdk";
2
- import type { AgentCostsService } from "../agent-costs/agent-costs.types.js";
3
- import type { AgentRuntimeService } from "../agent-runtime/agent-runtime.types.js";
4
2
  import type { BrowserAuth } from "../auth/auth.types.js";
5
3
  import type { BundleService } from "../bundle/bundle.types.js";
6
4
  import type { FlowService } from "../flows/flows.types.js";
7
- import type { ModelCatalogService } from "../model-catalog/model-catalog.types.js";
8
5
  import type { NodeService } from "../nodes/nodes.types.js";
9
6
  import type { ToolService } from "../tools/tools.types.js";
10
7
  export interface IntelDeps {
@@ -15,8 +12,5 @@ export interface IntelDeps {
15
12
  flows: FlowService;
16
13
  tools: ToolService;
17
14
  bundle: BundleService;
18
- agents: AgentRuntimeService;
19
- agentCosts: AgentCostsService;
20
- models: ModelCatalogService;
21
15
  auth?: BrowserAuth;
22
16
  }