@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
@@ -5,7 +5,7 @@ const grantColumns = `id, node_id, principal_type, principal_id, verb, expires_a
5
5
  created_by, created_at`;
6
6
  const linkColumns = `link.id, link.source_node_id, link.target_node_id, link.relation,
7
7
  link.origin, link.label, link.created_by, link.created_at`;
8
- // The first row of each node, in the order they arrive. A chunked kind — a board, whose index holds
8
+ // The first row of each node, in the order they arrive. A chunked kind — one whose index holds
9
9
  // one row per task (#285) — answers a query once per matching card; a citation names a node, so the
10
10
  // best of them is the one that gets to speak for it.
11
11
  function dedupedByNode(rows) {
@@ -146,66 +146,7 @@ export function createNodeRepository(deps) {
146
146
  JOIN allowed ON allowed.id = n.id
147
147
  WHERE n.archived_at IS NOT NULL
148
148
  ORDER BY n.archived_at DESC, lower(n.title), n.id`;
149
- /**
150
- * The agents this actor may see, over the whole tree or under one folder (#139).
151
- *
152
- * ⚠️ The same `allowed` set every other read joins against, not a second predicate written for
153
- * agents. An agent is a node, and the moment its list answers from its own rule it can answer
154
- * differently from `list` about the very same row.
155
- *
156
- * Three scopes, matching the flow list: no clause at all for the whole tree, `IS NULL` for the
157
- * root level, and one folder by ID.
158
- */
159
- const visibleAgents = (clause, includeArchived) => `${visibleCte}
160
- SELECT ${nodeColumns}
161
- FROM nodes n
162
- JOIN allowed ON allowed.id = n.id
163
- WHERE n.kind = 'agent' ${includeArchived ? "" : "AND n.archived_at IS NULL"} ${clause}
164
- ORDER BY lower(n.title), n.id`;
165
- const agentScopeOf = (parentId) => parentId === undefined
166
- ? { clause: "", bindings: [] }
167
- : parentId === null
168
- ? { clause: "AND n.parent_id IS NULL", bindings: [] }
169
- : { clause: "AND n.parent_id = ?", bindings: [parentId] };
170
149
  return {
171
- async listVisibleAgents(actor, input) {
172
- const scope = agentScopeOf(input.parentId);
173
- const result = await deps.db
174
- .prepare(visibleAgents(scope.clause, input.includeArchived === true))
175
- .bind(...readBindings(actor), ...scope.bindings)
176
- .all();
177
- return (result.results ?? []).map(mapNode);
178
- },
179
- /**
180
- * The R2 keys of the current definitions of every agent in this folder's subtree that this
181
- * actor may see — the input to the share warning (#139, ADR-0005 §2).
182
- *
183
- * ⚠️ Keys, never bodies and never titles. Whether a referenced node may be NAMED to whoever is
184
- * sharing is the tree's question and is asked afterwards through `getVisible`, exactly as the
185
- * flow side does it. Answering it here would be the second rule that #17 and #19 got wrong.
186
- *
187
- * ⚠️ Agents this actor may not see contribute nothing, not even a count. Their references would
188
- * otherwise be attributed to a folder this actor administers, which discloses that the agent is
189
- * there at all.
190
- */
191
- async listVisibleAgentDefinitionKeys(actor, folderId) {
192
- const result = await deps.db
193
- .prepare(`${visibleCte},
194
- scope(id) AS (
195
- SELECT id FROM nodes WHERE id = ?
196
- UNION
197
- SELECT child.id FROM nodes child JOIN scope ON child.parent_id = scope.id
198
- )
199
- SELECT version.content_key AS content_key
200
- FROM nodes n
201
- JOIN allowed ON allowed.id = n.id
202
- JOIN node_versions version ON version.id = n.current_version_id
203
- WHERE n.kind = 'agent' AND n.archived_at IS NULL AND n.id IN (SELECT id FROM scope)
204
- ORDER BY n.id`)
205
- .bind(...readBindings(actor), folderId)
206
- .all();
207
- return (result.results ?? []).map((row) => row.content_key);
208
- },
209
150
  async listVisible(actor, input) {
210
151
  const result = await deps.db
211
152
  .prepare(input.archivedOnly ? archivedEverywhere : visibleChildren(false))
@@ -289,20 +230,17 @@ export function createNodeRepository(deps) {
289
230
  * ⚠️ `nodes` arrive parents before children: `parent_id REFERENCES nodes(id)` is checked as
290
231
  * each row lands, and a child before its parent would abort the batch that is otherwise valid.
291
232
  * The service owns that order; this only writes what it is handed.
292
- *
293
- * `context_policy` is the dead column migration 0009 explains; 'relevant' is the fixed value
294
- * `insertNode` writes for the same reason.
295
233
  */
296
234
  async importTree(input) {
297
235
  const statements = [
298
236
  deps.db
299
237
  .prepare(`INSERT INTO nodes (
300
- id, parent_id, kind, title, description, context_policy, owner_id,
238
+ id, parent_id, kind, title, description, owner_id,
301
239
  current_version_id, created_at, updated_at, archived_at
302
240
  ) SELECT
303
241
  json_extract(value, '$.id'), json_extract(value, '$.parentId'),
304
242
  json_extract(value, '$.kind'), json_extract(value, '$.title'),
305
- json_extract(value, '$.description'), 'relevant',
243
+ json_extract(value, '$.description'),
306
244
  json_extract(value, '$.ownerId'), json_extract(value, '$.currentVersionId'),
307
245
  json_extract(value, '$.createdAt'), json_extract(value, '$.updatedAt'), NULL
308
246
  FROM json_each(?)`)
@@ -380,7 +318,7 @@ export function createNodeRepository(deps) {
380
318
  *
381
319
  * ⚠️ The idempotency row points at the VERSION, and the audit event points at the node, so the
382
320
  * two are joined through the version id the snapshot writer puts into the metadata. That
383
- * metadata is where a board operation says which task it touched and how many it removed —
321
+ * metadata is where a snapshot says which positions it touched and how many it removed —
384
322
  * facts a replay cannot recompute from the stored document, because the document is the state
385
323
  * AFTER the write. Without this, repeating a delete would answer with a count nobody measured.
386
324
  */
@@ -442,30 +380,15 @@ export function createNodeRepository(deps) {
442
380
  return null;
443
381
  return row.resource_id.startsWith("1:");
444
382
  },
445
- // ⚠️ Its own statement rather than a column on the node read: four of the five kinds can never
446
- // have an Application, and the two joins every node read already carries are not going to grow
447
- // a third for a question only agents ask (#182).
448
- async agentApplicationId(nodeId) {
449
- const row = await deps.db
450
- .prepare("SELECT application_id FROM agent_applications WHERE node_id = ?")
451
- .bind(nodeId)
452
- .first();
453
- return row?.application_id ?? null;
454
- },
455
383
  async insertNode(input) {
456
384
  const node = input.node;
457
- const application = input.application;
458
385
  try {
459
386
  await deps.db.batch([
460
387
  deps.db
461
- // ⚠️ `context_policy` is dead and is written anyway (#76). The column is NOT NULL
462
- // without a DEFAULT and D1 will not let it be dropped — migration 0009 carries the
463
- // reason. The fixed value is the price; nothing reads it, and the contract no longer
464
- // knows the field. When the column goes (anchrd/intel#86), this line goes with it.
465
388
  .prepare(`INSERT INTO nodes (
466
- id, parent_id, kind, title, description, context_policy, owner_id,
389
+ id, parent_id, kind, title, description, owner_id,
467
390
  current_version_id, created_at, updated_at, archived_at
468
- ) VALUES (?, ?, ?, ?, ?, 'relevant', ?, ?, ?, ?, ?)`)
391
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
469
392
  .bind(node.id, node.parentId, node.kind, node.title, node.description, node.ownerId, node.currentVersionId, node.createdAt, node.updatedAt, node.archivedAt),
470
393
  deps.db
471
394
  .prepare(`INSERT INTO idempotency_keys (
@@ -476,23 +399,7 @@ export function createNodeRepository(deps) {
476
399
  .prepare(`INSERT INTO audit_events (
477
400
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
478
401
  ) VALUES (?, ?, 'node.create', 'node', ?, ?, ?)`)
479
- .bind(input.auditId, input.actorId, node.id,
480
- // ⚠️ The Application ID is deliberately NOT in this metadata, and the key is not
481
- // within reach of it at all. An audit event is read back by people and by the
482
- // reindexer; what belongs in it is that an agent was created, not the name of the
483
- // principal behind it — the mapping row below is where that is kept, once (D27).
484
- JSON.stringify({ kind: node.kind, parentId: node.parentId }), node.createdAt),
485
- // Last in the batch, and in the SAME batch: D1 runs a batch as one transaction, so the
486
- // mapping cannot end up without its node or the node without its mapping. An agent whose
487
- // principal is recorded nowhere is one that archiving would quietly fail to switch off.
488
- ...(application
489
- ? [
490
- deps.db
491
- .prepare(`INSERT INTO agent_applications (node_id, application_id, created_at)
492
- VALUES (?, ?, ?)`)
493
- .bind(node.id, application.id, node.createdAt),
494
- ]
495
- : []),
402
+ .bind(input.auditId, input.actorId, node.id, JSON.stringify({ kind: node.kind, parentId: node.parentId }), node.createdAt),
496
403
  ]);
497
404
  }
498
405
  catch (error) {
@@ -839,7 +746,7 @@ export function createNodeRepository(deps) {
839
746
  .bind(version.id, version.nodeId, version.sequence, version.contentKey, version.mediaType, version.contentHash, version.size,
840
747
  // ⚠️ Taken from the version rather than written in as `'snapshot'`. A table's mutation
841
748
  // marks a snapshot, because reading a table starts at the newest one and everything
842
- // before it is history (#135). A board's version carries the whole board by
749
+ // before it is history (#135). A snapshot version carries the whole document by
843
750
  // construction, so it marks nothing — the same `null` a document's version carries, and
844
751
  // for the same reason (#285).
845
752
  version.segment, version.createdBy, version.createdAt, version.nodeId, input.baseVersionId),
@@ -875,7 +782,7 @@ export function createNodeRepository(deps) {
875
782
  },
876
783
  async listGrants(resourceId) {
877
784
  const result = await deps.db
878
- .prepare(`SELECT ${grantColumns} FROM tree_grants
785
+ .prepare(`SELECT ${grantColumns} FROM node_grants
879
786
  WHERE node_id = ?
880
787
  ORDER BY principal_type, principal_id, verb`)
881
788
  .bind(resourceId)
@@ -894,7 +801,7 @@ export function createNodeRepository(deps) {
894
801
  JOIN ancestors child ON child.parent_id = parent.id
895
802
  )
896
803
  SELECT 1 AS reaches
897
- FROM tree_grants grant_row
804
+ FROM node_grants grant_row
898
805
  JOIN ancestors ON ancestors.id = grant_row.node_id
899
806
  WHERE grant_row.id <> ?
900
807
  AND grant_row.principal_type = 'organization'
@@ -973,17 +880,6 @@ export function createNodeRepository(deps) {
973
880
  .bind(input.auditId, input.actorId, input.sourceNodeId, JSON.stringify({ targetNodeIds: input.links.map((link) => link.targetNodeId) }), input.occurredAt),
974
881
  ]);
975
882
  },
976
- async recordAgentKeyRotation(input) {
977
- // ⚠️ No node row moves, so there is nothing to batch this with — the event IS the write. The
978
- // key is not among the columns and must not be added to them: audit is metadata, and a row
979
- // that carried the credential would outlive every other copy of it.
980
- await deps.db
981
- .prepare(`INSERT INTO audit_events (
982
- id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
983
- ) VALUES (?, ?, 'agent.rotate_key', 'node', ?, ?, ?)`)
984
- .bind(input.auditId, input.actorId, input.nodeId, JSON.stringify({ applicationId: input.applicationId }), input.occurredAt)
985
- .run();
986
- },
987
883
  async graphVisible(actor, input) {
988
884
  const nodeResult = await deps.db
989
885
  .prepare(`${visibleCte}
@@ -1028,7 +924,7 @@ export function createNodeRepository(deps) {
1028
924
  deps.db
1029
925
  // The verb is part of the key, so re-granting the same verb only refreshes its expiry
1030
926
  // and never turns one verb into another.
1031
- .prepare(`INSERT INTO tree_grants (
927
+ .prepare(`INSERT INTO node_grants (
1032
928
  id, node_id, principal_type, principal_id, verb,
1033
929
  expires_at, created_by, created_at
1034
930
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
@@ -1038,7 +934,7 @@ export function createNodeRepository(deps) {
1038
934
  deps.db
1039
935
  .prepare(`INSERT INTO idempotency_keys (
1040
936
  actor_id, operation, idempotency_key, resource_id, created_at
1041
- ) SELECT ?, 'node.share', ?, id, ? FROM tree_grants
937
+ ) SELECT ?, 'node.share', ?, id, ? FROM node_grants
1042
938
  WHERE node_id = ? AND principal_type = ? AND principal_id = ? AND verb = ?`)
1043
939
  .bind(input.actorId, input.idempotencyKey, grant.createdAt, grant.resourceId, principalType, principalId, grant.verb),
1044
940
  deps.db
@@ -1048,7 +944,7 @@ export function createNodeRepository(deps) {
1048
944
  .bind(input.auditId, input.actorId, grant.resourceId, JSON.stringify({ principalType, verb: grant.verb }), grant.createdAt),
1049
945
  ]);
1050
946
  const stored = await deps.db
1051
- .prepare(`SELECT ${grantColumns} FROM tree_grants
947
+ .prepare(`SELECT ${grantColumns} FROM node_grants
1052
948
  WHERE node_id = ? AND principal_type = ? AND principal_id = ? AND verb = ?`)
1053
949
  .bind(grant.resourceId, principalType, principalId, grant.verb)
1054
950
  .first();
@@ -1064,7 +960,7 @@ export function createNodeRepository(deps) {
1064
960
  .first();
1065
961
  if (replayed) {
1066
962
  const row = await deps.db
1067
- .prepare(`SELECT ${grantColumns} FROM tree_grants WHERE id = ?`)
963
+ .prepare(`SELECT ${grantColumns} FROM node_grants WHERE id = ?`)
1068
964
  .bind(replayed.resource_id)
1069
965
  .first();
1070
966
  if (row)
@@ -1081,11 +977,11 @@ export function createNodeRepository(deps) {
1081
977
  actor_id, operation, idempotency_key, resource_id, created_at
1082
978
  ) SELECT ?, 'node.revoke', ?,
1083
979
  (CASE WHEN EXISTS (
1084
- SELECT 1 FROM tree_grants WHERE id = ? AND node_id = ?
980
+ SELECT 1 FROM node_grants WHERE id = ? AND node_id = ?
1085
981
  ) THEN '1:' ELSE '0:' END) || ?, ?`)
1086
982
  .bind(input.actorId, input.idempotencyKey, input.grantId, input.resourceId, input.grantId, input.occurredAt),
1087
983
  deps.db
1088
- .prepare("DELETE FROM tree_grants WHERE id = ? AND node_id = ?")
984
+ .prepare("DELETE FROM node_grants WHERE id = ? AND node_id = ?")
1089
985
  .bind(input.grantId, input.resourceId),
1090
986
  deps.db
1091
987
  .prepare(`INSERT INTO audit_events (
@@ -1128,10 +1024,10 @@ export function createNodeRepository(deps) {
1128
1024
  LIMIT ?`)
1129
1025
  .bind(...readBindings(actor), ...scopeBindings(input.scopeId), query, input.limit)
1130
1026
  .all();
1131
- // ⚠️ One citation per node, and the FIRST is the one kept. A board holds one index row per
1132
- // task (#285), so a query matching three cards of the same board arrives here three times;
1027
+ // ⚠️ One citation per node, and the FIRST is the one kept. The index is CHUNKED a node
1028
+ // may hold several rows (#285) so a query matching three of them arrives here three times;
1133
1029
  // the rows come back in rank order, so the survivor is the best-matching card and its own
1134
- // passage is what the reader is shown. Without this, one busy board could fill a whole page
1030
+ // passage is what the reader is shown. Without this, one busy node could fill a whole page
1135
1031
  // of results and push every other node off it.
1136
1032
  return dedupedByNode(result.results ?? []).map((row) => ({
1137
1033
  nodeId: row.node_id,
@@ -1144,32 +1040,46 @@ export function createNodeRepository(deps) {
1144
1040
  match: "lexical",
1145
1041
  }));
1146
1042
  },
1147
- async hydrateVisibleCitations(actor, nodeIds, scopeId) {
1148
- const uniqueIds = [...new Set(nodeIds)].slice(0, 100);
1149
- if (uniqueIds.length === 0)
1043
+ async hydrateVisibleCitations(actor, hits, scopeId) {
1044
+ // One entry per node: the caller has already picked the best-scoring chunk of each, so a
1045
+ // node that answered with four passages arrives as the one passage that answered best.
1046
+ const unique = [...new Map(hits.map((hit) => [hit.nodeId, hit])).values()].slice(0, 100);
1047
+ if (unique.length === 0)
1150
1048
  return [];
1151
1049
  const rows = [];
1152
- for (let offset = 0; offset < uniqueIds.length; offset += 96) {
1153
- const ids = uniqueIds.slice(offset, offset + 96);
1154
- const placeholders = ids.map(() => "?").join(", ");
1050
+ // Two bindings per hit now instead of one, so the batch is halved and then some: the actor's
1051
+ // predicate and the optional scope are bound in front of them, and D1 counts every one of
1052
+ // them against the same per-statement ceiling.
1053
+ for (let offset = 0; offset < unique.length; offset += 40) {
1054
+ const batch = unique.slice(offset, offset + 40);
1055
+ const pairs = batch.map(() => "(?, ?)").join(", ");
1155
1056
  const result = await deps.db
1156
- .prepare(`${visibleCte}${scopeCte(scopeId)}
1057
+ .prepare(`${visibleCte}${scopeCte(scopeId)},
1058
+ hits(node_id, chunk_key) AS (VALUES ${pairs})
1157
1059
  SELECT n.id AS node_id, v.id AS version_id, n.title,
1158
- substr(node_fts.content, 1, 480) AS passage,
1060
+ -- ⚠️ The card that answered, and only otherwise the first passage of the node
1061
+ -- (anchrd/intel#301). Every vector written before #301 is named by the bare node id
1062
+ -- and has no row here, so the fallback is not defensive coding but the exact
1063
+ -- behaviour those vectors had — a board found through one of them still answers,
1064
+ -- with the passage it always answered with, until the next indexing pass.
1065
+ COALESCE(vector.passage, substr(node_fts.content, 1, 480)) AS passage,
1159
1066
  n.updated_at AS freshness
1160
- FROM nodes n
1067
+ FROM hits
1068
+ JOIN nodes n ON n.id = hits.node_id
1161
1069
  JOIN node_versions v ON v.id = n.current_version_id
1162
1070
  JOIN node_fts ON node_fts.version_id = v.id
1071
+ LEFT JOIN node_vectors vector
1072
+ ON vector.node_id = hits.node_id AND vector.chunk_key = hits.chunk_key
1163
1073
  JOIN allowed ON allowed.id = n.id${scopeJoin(scopeId)}
1164
- WHERE n.id IN (${placeholders}) AND n.archived_at IS NULL`)
1165
- .bind(...readBindings(actor), ...scopeBindings(scopeId), ...ids)
1074
+ WHERE n.archived_at IS NULL`)
1075
+ .bind(...readBindings(actor), ...scopeBindings(scopeId), ...batch.flatMap((hit) => [hit.nodeId, hit.chunkKey]))
1166
1076
  .all();
1167
1077
  rows.push(...(result.results ?? []));
1168
1078
  }
1169
- const order = new Map(uniqueIds.map((id, index) => [id, index]));
1170
- // The same one-per-node rule as the lexical half above, for the same reason: a semantic hit
1171
- // is a hit on a NODE (the vector index is keyed by node id), so hydrating it must not turn one
1172
- // hit into one row per task.
1079
+ const order = new Map(unique.map((hit, index) => [hit.nodeId, index]));
1080
+ // The same one-per-node rule as the lexical half above, for the same reason: a citation names
1081
+ // a NODE, and a board holds one full-text row per task (#285), so the join above still
1082
+ // produces one row per card even though only one of them was asked about.
1173
1083
  const deduped = dedupedByNode(rows);
1174
1084
  deduped.sort((left, right) => (order.get(left.node_id) ?? 0) - (order.get(right.node_id) ?? 0));
1175
1085
  return deduped.map((row) => ({
@@ -1183,10 +1093,42 @@ export function createNodeRepository(deps) {
1183
1093
  match: "semantic",
1184
1094
  }));
1185
1095
  },
1096
+ async invalidateVectors() {
1097
+ /**
1098
+ * ⚠️ The fingerprints are cleared and the ROWS are kept, which is not a detail. A record row
1099
+ * says two things at once: "this chunk was embedded from this text" and "this vector exists".
1100
+ * Only the first is stale after a rebuild is asked for; deleting the row would throw the
1101
+ * second away, and then a card removed between this call and that board's pass would leave a
1102
+ * vector nothing could ever name again — matching questions and answering with a card that is
1103
+ * not on the board.
1104
+ *
1105
+ * The empty string is a value no digest produces, so every chunk compares as changed and the
1106
+ * next pass embeds all of them. `node_fts` is deliberately not touched: its rows are
1107
+ * overwritten by the pass that rewrites them, and emptying it would take the lexical half of
1108
+ * the installation offline for as long as the queue needs to work through the tree.
1109
+ */
1110
+ await deps.db.prepare("UPDATE node_vectors SET fingerprint = ''").run();
1111
+ },
1112
+ /**
1113
+ * ⚠️ Archived nodes are IN this walk since anchrd/intel#348, and the omission of
1114
+ * `archived_at IS NULL` is the point. The pass an archived node gets is a purge, not an index —
1115
+ * `getTarget` refuses it and `archivedNodeId` says why — so including them is what makes
1116
+ * `reindex` the way back for a vector that is in the index with nothing left to name it.
1117
+ *
1118
+ * Three of those exist and none of them has another repair: everything archived before #348,
1119
+ * which nothing ever swept; a node whose `archive` wrote its row and then failed to reach the
1120
+ * queue; and a pass that upserted vectors in the moment somebody archived the node underneath
1121
+ * it. Without this line an administrator's rebuild walked straight past all three, and the only
1122
+ * remaining answer would have been to delete the whole Vectorize index by hand.
1123
+ *
1124
+ * It does mean `queued` counts them. That is still what the number says — versions handed to the
1125
+ * queue — and a rebuild that silently skipped part of the tree is the failure `reindex` exists
1126
+ * to prevent.
1127
+ */
1186
1128
  async listCurrentVersionIds(input) {
1187
1129
  const result = await deps.db
1188
1130
  .prepare(`SELECT current_version_id AS id FROM nodes
1189
- WHERE current_version_id IS NOT NULL AND archived_at IS NULL
1131
+ WHERE current_version_id IS NOT NULL
1190
1132
  AND (? IS NULL OR current_version_id > ?)
1191
1133
  ORDER BY id LIMIT ?`)
1192
1134
  .bind(input.after, input.after, input.limit)
@@ -1,15 +1,48 @@
1
1
  const defaultModel = "@cf/baai/bge-m3";
2
2
  const maxEmbeddingCharacters = 24_000;
3
- function embedding(result) {
3
+ // How much of one embedding request this adapter is willing to be: at most this many texts, and at
4
+ // most this many characters across them. Both bounds are ours rather than a number Cloudflare
5
+ // publishes — what the model documents is the shape of `text`, not a batch size — so they are set
6
+ // well inside anything plausible. The point is that a board with three hundred cards costs a
7
+ // handful of requests on its first pass instead of three hundred round trips.
8
+ const maxBatchTexts = 25;
9
+ const maxBatchCharacters = 96_000;
10
+ // Vectorize takes at most 1000 vectors in one call from a Worker (platform limits). A board may
11
+ // hold 5000 tasks, so the write is cut rather than sent whole and refused.
12
+ const maxVectorsPerCall = 1000;
13
+ /**
14
+ * How a chunk is named in the vector index (anchrd/intel#301).
15
+ *
16
+ * ⚠️ `""` keeps the bare node id, which is what every vector written before this was called. That
17
+ * is the whole reason the separator sits on the SUFFIX side: nothing an installation already holds
18
+ * changes its name, so this ships without re-embedding a tree.
19
+ *
20
+ * ⚠️ Reading it back splits at the FIRST separator, and that direction matters. A node id is minted
21
+ * here (`ulid`) and cannot contain a `#`; a task id can, because a bundle import carries the task
22
+ * ids written in the file. Splitting at the first `#` therefore always recovers the node id exactly
23
+ * and leaves the rest to the key — and even a key that never matched a card costs a hit that
24
+ * hydrates to nothing, never a citation for a node the searcher did not match.
25
+ */
26
+ const chunkSeparator = "#";
27
+ function vectorId(nodeId, key) {
28
+ return key === "" ? nodeId : `${nodeId}${chunkSeparator}${key}`;
29
+ }
30
+ function embeddings(result, expected) {
4
31
  if (typeof result !== "object" ||
5
32
  result === null ||
6
33
  !("data" in result) ||
7
34
  !Array.isArray(result.data) ||
8
- !Array.isArray(result.data[0]) ||
9
- !result.data[0].every((value) => typeof value === "number")) {
35
+ // ⚠️ The count is part of the check. The rows come back in the order the texts went out and
36
+ // nothing else identifies them, so a short answer would silently pair every embedding after the
37
+ // gap with the wrong card — a search that answers confidently with the neighbouring task.
38
+ result.data.length !== expected ||
39
+ // ⚠️ And every row has to hold something. An empty one passes "is an array of numbers" and would
40
+ // be upserted under a real card's name as a vector that matches nothing — indexed, recorded as
41
+ // fingerprinted, and never looked at again until somebody edits that card.
42
+ !result.data.every((row) => Array.isArray(row) && row.length > 0 && row.every((value) => typeof value === "number"))) {
10
43
  throw new Error("The embedding provider returned an invalid response");
11
44
  }
12
- return result.data[0];
45
+ return result.data;
13
46
  }
14
47
  function matches(result) {
15
48
  if (typeof result !== "object" ||
@@ -27,27 +60,74 @@ function matches(result) {
27
60
  typeof match.score !== "number") {
28
61
  return [];
29
62
  }
30
- return [{ nodeId: match.id, score: Math.max(0, Math.min(1, match.score)) }];
63
+ const separator = match.id.indexOf(chunkSeparator);
64
+ return [
65
+ {
66
+ nodeId: separator < 0 ? match.id : match.id.slice(0, separator),
67
+ chunkKey: separator < 0 ? "" : match.id.slice(separator + 1),
68
+ score: Math.max(0, Math.min(1, match.score)),
69
+ },
70
+ ];
31
71
  });
32
72
  }
73
+ function batched(chunks) {
74
+ const batches = [];
75
+ let current = [];
76
+ let characters = 0;
77
+ for (const chunk of chunks) {
78
+ if (current.length > 0 &&
79
+ (current.length >= maxBatchTexts || characters >= maxBatchCharacters)) {
80
+ batches.push(current);
81
+ current = [];
82
+ characters = 0;
83
+ }
84
+ current.push(chunk);
85
+ characters += chunk.text.length;
86
+ }
87
+ if (current.length > 0)
88
+ batches.push(current);
89
+ return batches;
90
+ }
33
91
  export function createSemanticIndex(deps) {
34
92
  const model = deps.model ?? defaultModel;
35
- async function embed(text) {
36
- return embedding(await deps.ai.run(model, { text: [text] }));
93
+ async function embed(texts) {
94
+ return embeddings(await deps.ai.run(model, { text: texts }), texts.length);
37
95
  }
38
96
  return {
39
- async replace(target, content) {
40
- const text = `${target.title}\n\n${content}`.slice(0, maxEmbeddingCharacters);
41
- await deps.index.upsert([
42
- {
43
- id: target.nodeId,
44
- values: await embed(text),
45
- metadata: { versionId: target.versionId },
46
- },
47
- ]);
97
+ async upsert(target, chunks) {
98
+ const vectors = [];
99
+ for (const batch of batched(chunks)) {
100
+ const values = await embed(batch.map((chunk) => chunk.text.slice(0, maxEmbeddingCharacters)));
101
+ batch.forEach((chunk, index) => {
102
+ const embedded = values[index];
103
+ // The index is what makes this reachable at all — `embeddings` has already refused a short
104
+ // answer and an empty row, so this is the type system's question rather than the
105
+ // provider's. It throws anyway rather than skipping: the alternative to a missing card is
106
+ // never a shorter list, it is a card recorded as fingerprinted with no vector behind it.
107
+ if (!embedded)
108
+ throw new Error("The embedding provider returned an invalid response");
109
+ vectors.push({
110
+ id: vectorId(target.nodeId, chunk.key),
111
+ values: embedded,
112
+ metadata: { versionId: target.versionId },
113
+ });
114
+ });
115
+ }
116
+ for (let offset = 0; offset < vectors.length; offset += maxVectorsPerCall) {
117
+ await deps.index.upsert(vectors.slice(offset, offset + maxVectorsPerCall));
118
+ }
119
+ },
120
+ async remove(nodeId, keys) {
121
+ const ids = keys.map((key) => vectorId(nodeId, key));
122
+ for (let offset = 0; offset < ids.length; offset += maxVectorsPerCall) {
123
+ await deps.index.deleteByIds(ids.slice(offset, offset + maxVectorsPerCall));
124
+ }
48
125
  },
49
126
  async search(query, limit) {
50
- const result = await deps.index.query(await embed(query), {
127
+ const [vector] = await embed([query]);
128
+ if (!vector)
129
+ throw new Error("The embedding provider returned an invalid response");
130
+ const result = await deps.index.query(vector, {
51
131
  topK: Math.max(1, Math.min(100, limit)),
52
132
  returnMetadata: "none",
53
133
  });
@@ -12,17 +12,36 @@ export interface VectorizeBinding {
12
12
  versionId: string;
13
13
  };
14
14
  }>): Promise<unknown>;
15
+ deleteByIds(ids: string[]): Promise<unknown>;
15
16
  query(vector: number[], options: {
16
17
  topK: number;
17
18
  returnMetadata: "none";
18
19
  }): Promise<unknown>;
19
20
  }
21
+ /**
22
+ * One vector's worth of a node (anchrd/intel#301).
23
+ *
24
+ * ⚠️ `key` is `""` for a node that carries exactly one vector — every kind but `board` — and the
25
+ * vector is then named by the bare node id it has always been named by. A board card carries its
26
+ * task id, and its vector is `<node id>#<task id>`.
27
+ *
28
+ * `text` is embedded verbatim. Composing it — the node's title in front of a document's body, the
29
+ * card's own title in front of a card — belongs to the indexing pass rather than here, because the
30
+ * same string has to be fingerprinted there to decide whether this vector needs making at all. A
31
+ * title prefixed on this side would sit outside that fingerprint and change nothing when it changed.
32
+ */
33
+ export interface SemanticChunk {
34
+ key: string;
35
+ text: string;
36
+ }
20
37
  export interface SemanticHit {
21
38
  nodeId: string;
39
+ chunkKey: string;
22
40
  score: number;
23
41
  }
24
42
  export interface SemanticIndex {
25
- replace(target: NodeIndexTarget, content: string): Promise<void>;
43
+ upsert(target: NodeIndexTarget, chunks: SemanticChunk[]): Promise<void>;
44
+ remove(nodeId: string, keys: string[]): Promise<void>;
26
45
  search(query: string, limit: number): Promise<SemanticHit[]>;
27
46
  }
28
47
  export interface SemanticIndexDeps {