@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.
- package/dist/adapters/cloudflare/cloudflare.js +1 -68
- package/dist/adapters/cloudflare/cloudflare.types.d.ts +0 -39
- package/dist/adapters/db/db-flows.js +1 -1
- package/dist/adapters/db/db-grants.js +1 -1
- package/dist/adapters/db/db-indexing.js +79 -0
- package/dist/adapters/db/db.js +81 -139
- package/dist/adapters/semantic-index/semantic-index.js +97 -17
- package/dist/adapters/semantic-index/semantic-index.types.d.ts +20 -1
- package/dist/bundle/bundle.js +42 -134
- package/dist/cli/cli.js +3 -9
- package/dist/http/http.js +5 -206
- package/dist/http/http.types.d.ts +0 -8
- package/dist/indexing/indexing.js +133 -55
- package/dist/indexing/indexing.types.d.ts +1 -0
- package/dist/intel/intel.js +4 -9
- package/dist/intel/intel.types.d.ts +0 -6
- package/dist/mcp/mcp.js +33 -308
- package/dist/mcp/mcp.types.d.ts +2 -7
- package/dist/nodes/document-links/document-links.d.ts +6 -8
- package/dist/nodes/document-links/document-links.js +8 -31
- package/dist/nodes/nodes.js +92 -826
- package/dist/nodes/nodes.types.d.ts +57 -158
- package/dist/tools/tools.js +37 -148
- package/dist/tools/tools.types.d.ts +0 -21
- package/migrations/0009_no_context_policy.sql +15 -0
- package/migrations/0017_a_vector_per_card.sql +38 -0
- package/migrations/0018_no_context_policy_at_last.sql +97 -0
- package/migrations/0019_one_name_for_the_grants.sql +52 -0
- package/package.json +2 -2
- package/dist/adapters/cloudflare-api/cloudflare-api.d.ts +0 -22
- package/dist/adapters/cloudflare-api/cloudflare-api.js +0 -214
- package/dist/adapters/cloudflare-api/cloudflare-api.types.d.ts +0 -64
- package/dist/adapters/cloudflare-api/cloudflare-api.types.js +0 -1
- package/dist/adapters/gate-applications/gate-applications.d.ts +0 -23
- package/dist/adapters/gate-applications/gate-applications.js +0 -88
- package/dist/adapters/tool-delegation/tool-delegation.d.ts +0 -22
- package/dist/adapters/tool-delegation/tool-delegation.js +0 -90
- package/dist/agent-costs/agent-costs.d.ts +0 -16
- package/dist/agent-costs/agent-costs.js +0 -105
- package/dist/agent-costs/agent-costs.types.d.ts +0 -30
- package/dist/agent-costs/agent-costs.types.js +0 -1
- package/dist/agent-runtime/agent-runtime.d.ts +0 -16
- package/dist/agent-runtime/agent-runtime.js +0 -150
- package/dist/agent-runtime/agent-runtime.types.d.ts +0 -122
- package/dist/agent-runtime/agent-runtime.types.js +0 -1
- package/dist/model-catalog/model-catalog.d.ts +0 -2
- package/dist/model-catalog/model-catalog.js +0 -99
- package/dist/model-catalog/model-catalog.types.d.ts +0 -15
- package/dist/model-catalog/model-catalog.types.js +0 -1
- package/dist/nodes/board/board.d.ts +0 -59
- package/dist/nodes/board/board.js +0 -528
- package/dist/nodes/board/board.types.d.ts +0 -31
- package/dist/nodes/board/board.types.js +0 -1
- package/migrations/0013_agents_in_the_tree.sql +0 -76
- package/migrations/0014_agent_applications.sql +0 -25
- package/migrations/0015_tools_delegated_from_a_connection.sql +0 -15
- package/migrations/0016_boards_in_the_tree.sql +0 -80
package/dist/adapters/db/db.js
CHANGED
|
@@ -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 —
|
|
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,
|
|
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'),
|
|
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
|
|
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,
|
|
389
|
+
id, parent_id, kind, title, description, owner_id,
|
|
467
390
|
current_version_id, created_at, updated_at, archived_at
|
|
468
|
-
) VALUES (?, ?, ?, ?, ?,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
1132
|
-
//
|
|
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
|
|
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,
|
|
1148
|
-
|
|
1149
|
-
|
|
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
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
1165
|
-
.bind(...readBindings(actor), ...scopeBindings(scopeId), ...
|
|
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(
|
|
1170
|
-
// The same one-per-node rule as the lexical half above, for the same reason: a
|
|
1171
|
-
//
|
|
1172
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
36
|
-
return
|
|
93
|
+
async function embed(texts) {
|
|
94
|
+
return embeddings(await deps.ai.run(model, { text: texts }), texts.length);
|
|
37
95
|
}
|
|
38
96
|
return {
|
|
39
|
-
async
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|