@anchrd/intel-api 0.13.0 → 0.14.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.
@@ -259,6 +259,7 @@ export default {
259
259
  content: createContentStore(env.CONTENT),
260
260
  semantic: env.AI && env.SEARCH ? createSemanticIndex({ ai: env.AI, index: env.SEARCH }) : undefined,
261
261
  converter: env.AI ? createDocumentConverter(env.AI) : undefined,
262
+ hash: async (content) => await sha256Hex(crypto, content),
262
263
  now: () => new Date(),
263
264
  });
264
265
  for (const message of batch.messages) {
@@ -36,6 +36,16 @@ const PerPage = 50;
36
36
  * yet (anchrd/intel#330).
37
37
  */
38
38
  const ModelPerPage = 100;
39
+ /**
40
+ * How many pages of the model catalog are read before the reader gives up.
41
+ *
42
+ * ⚠️ It is a runaway brake and not a window, which is the opposite of `MaxPages` above. The log has
43
+ * more calls than anybody wants to read; the catalog is finite and small — 61 entries on this
44
+ * account — so the loop normally ends on the first short page and this number is never reached.
45
+ * What it guards against is an endpoint that answers a full page forever, and a loop inside a
46
+ * request nobody is watching.
47
+ */
48
+ const ModelMaxPages = 10;
39
49
  /**
40
50
  * The gateway's log entry, read tolerantly.
41
51
  *
@@ -159,56 +169,138 @@ export function createCloudflareApi(deps) {
159
169
  }
160
170
  return {
161
171
  async gatewayCalls(query) {
162
- const calls = [];
163
- let partial = false;
164
- for (let page = 1; page <= MaxPages; page += 1) {
165
- const body = await get(`/accounts/${encodeURIComponent(deps.accountId)}/ai-gateway/gateways/${encodeURIComponent(deps.gatewayId)}/logs`, {
166
- page: String(page),
167
- per_page: String(PerPage),
168
- start_date: query.since.toISOString(),
169
- end_date: query.until.toISOString(),
170
- order_by: "created_at",
171
- order_by_direction: "desc",
172
- // ⚠️ Only documented scalar parameters travel. The endpoint also takes a `filters` array
173
- // whose query encoding Cloudflare documents nowhere — neither the reference nor the
174
- // curl example shows it so a guess at it would either be ignored (a slow read) or
175
- // rejected (no read at all), and there is no way to tell those apart from the status.
176
- // The agent is therefore picked out below, from the metadata the runtime stamped.
177
- });
178
- const parsed = LogResponse.safeParse(body);
179
- // A shape this reader cannot make sense of is a failure, not an empty window: an empty
180
- // window reads as "this agent cost nothing".
181
- if (!parsed.success) {
182
- throw new IntelError(502, "cloudflare_api_unreadable", "The Cloudflare API answered in a shape this version does not understand");
183
- }
184
- const entries = parsed.data.result ?? [];
185
- for (const entry of entries) {
186
- const metadata = readMetadata(entry.metadata);
187
- if (metadata.agentId !== query.agentId)
188
- continue;
189
- calls.push({
190
- runId: typeof metadata.runId === "string" ? metadata.runId : null,
191
- model: entry.model ?? "",
192
- cost: entry.cost ?? 0,
193
- at: entry.created_at ?? query.until.toISOString(),
172
+ const logPath = `/accounts/${encodeURIComponent(deps.accountId)}/ai-gateway/gateways/${encodeURIComponent(deps.gatewayId)}/logs`;
173
+ /**
174
+ * One window of the log, optionally cut to this agent by the gateway itself.
175
+ *
176
+ * The encoding Cloudflare documents nowhere, measured against the live gateway (#274):
177
+ * `filters` is a URL-encoded JSON array, and `value` is an ARRAY even for a single value.
178
+ *
179
+ * filters=[{"key":"metadata.value","operator":"eq","value":["<id>"]}] → 200, filtered
180
+ * value as a scalar string → 400 `Expected array, received string`
181
+ * filters as a JSON object → 400 `Expected array, received object`
182
+ * filters[0][key]=… (brackets) and filters.0.key=… (dots) 200, SILENTLY IGNORED
183
+ *
184
+ * ⚠️ The last line is why this was never guessed at: two of the five spellings answer 200
185
+ * with the whole unfiltered window, which reads exactly like a filter that matched
186
+ * everything. Two filters combine with AND (measured with `metadata.key` plus `runId`).
187
+ */
188
+ async function read(pages, filtered) {
189
+ const calls = [];
190
+ let partial = false;
191
+ for (let index = 1; index <= pages; index += 1) {
192
+ const body = await get(logPath, {
193
+ page: String(index),
194
+ per_page: String(PerPage),
195
+ start_date: query.since.toISOString(),
196
+ end_date: query.until.toISOString(),
197
+ order_by: "created_at",
198
+ order_by_direction: "desc",
199
+ ...(filtered
200
+ ? {
201
+ filters: JSON.stringify([
202
+ { key: "metadata.value", operator: "eq", value: [query.agentId] },
203
+ ]),
204
+ }
205
+ : {}),
194
206
  });
207
+ const parsed = LogResponse.safeParse(body);
208
+ // A shape this reader cannot make sense of is a failure, not an empty window: an empty
209
+ // window reads as "this agent cost nothing".
210
+ if (!parsed.success) {
211
+ throw new IntelError(502, "cloudflare_api_unreadable", "The Cloudflare API answered in a shape this version does not understand");
212
+ }
213
+ const entries = parsed.data.result ?? [];
214
+ // ⚠️ The local check stays even under the server-side filter, and it is no longer belt
215
+ // and braces: it is the only thing that could notice the filter matching the wrong rows.
216
+ for (const entry of entries) {
217
+ const metadata = readMetadata(entry.metadata);
218
+ if (metadata.agentId !== query.agentId)
219
+ continue;
220
+ calls.push({
221
+ runId: typeof metadata.runId === "string" ? metadata.runId : null,
222
+ model: entry.model ?? "",
223
+ cost: entry.cost ?? 0,
224
+ at: entry.created_at ?? query.until.toISOString(),
225
+ });
226
+ }
227
+ if (entries.length < PerPage)
228
+ return { calls, partial: false };
229
+ partial = index === pages;
195
230
  }
196
- if (entries.length < PerPage)
197
- return { calls, partial: false };
198
- partial = page === MaxPages;
231
+ return { calls, partial };
199
232
  }
200
- return { calls, partial };
233
+ const answer = await read(MaxPages, true);
234
+ /**
235
+ * ⚠️ Anything at all is taken at face value, and that is a decision with a hole in it. A
236
+ * filter that degraded PARTLY — matching some of this agent's rows and not others — returns
237
+ * here, and the under-count is then handed out as `partial: false`, which claims to be a
238
+ * total. The probe below cannot see that case, because the probe is only reached when the
239
+ * filtered read found nothing. It is the price of not reading the whole log twice on every
240
+ * request, and it is worth knowing before somebody reads `partial: false` as "complete".
241
+ */
242
+ if (answer.calls.length > 0)
243
+ return answer;
244
+ /**
245
+ * ⚠️ Zero is the ONE answer this reader may not take at face value, and the reason is the
246
+ * way `filters` fails. Measured: `cached` filtered to `["false"]` answers 200 with zero rows
247
+ * although every row in that window carries `cached: false` — a value the index does not
248
+ * match empties the page instead of being refused. So the day metadata stops being indexed
249
+ * the way it is today, this would report "cost nothing" for an agent that spent money, under
250
+ * `status: "read"`. That is precisely the sentence #251 exists for.
251
+ *
252
+ * One unfiltered page is the probe: if the newest calls contain any of this agent's, the
253
+ * filter is lying and the honest answer is that the log could not be read. It costs one
254
+ * request and only in the empty case, which is a fresh agent or a quiet window — and in the
255
+ * case it replaces, a quiet agent on a busy gateway, it costs 2 requests where reading the
256
+ * whole log unfiltered used to cost 20.
257
+ *
258
+ * ⚠️ It is a probe and not a proof: an agent whose only calls are older than the newest
259
+ * `PerPage` of the whole gateway is invisible to it, and that case still reports zero. What
260
+ * it buys is that a filter which stopped working ENTIRELY cannot pass as silence.
261
+ *
262
+ * ⚠️ A probe that cannot run leaves the answer standing, deliberately. The read this is
263
+ * checking SUCCEEDED and said "no calls"; letting a second opinion that never arrived turn
264
+ * that into `unreadable` would report an outage for the most ordinary state there is — a
265
+ * fresh agent — because one extra request met a 429. The guard is a second opinion, not the
266
+ * answer.
267
+ */
268
+ const probe = await read(1, false).catch(() => null);
269
+ if (probe === null || probe.calls.length === 0)
270
+ return answer;
271
+ throw new IntelError(502, "cloudflare_api_unreadable", "The Cloudflare API answered no calls for this agent while its own log holds some");
201
272
  },
202
273
  async workersAiModels() {
203
- const body = await get(`/accounts/${encodeURIComponent(deps.accountId)}/ai/models/search`, {
204
- per_page: String(ModelPerPage),
205
- hide_experimental: "true",
206
- });
207
- const parsed = ModelResponse.safeParse(body);
208
- if (!parsed.success) {
209
- throw new IntelError(502, "cloudflare_api_unreadable", "The Cloudflare API answered in a shape this version does not understand");
274
+ const models = [];
275
+ for (let index = 1; index <= ModelMaxPages; index += 1) {
276
+ const body = await get(`/accounts/${encodeURIComponent(deps.accountId)}/ai/models/search`, {
277
+ page: String(index),
278
+ per_page: String(ModelPerPage),
279
+ hide_experimental: "true",
280
+ });
281
+ const parsed = ModelResponse.safeParse(body);
282
+ if (!parsed.success) {
283
+ throw new IntelError(502, "cloudflare_api_unreadable", "The Cloudflare API answered in a shape this version does not understand");
284
+ }
285
+ const entries = parsed.data.result ?? [];
286
+ models.push(...entries.map(readModel));
287
+ // ⚠️ A SHORT page is the end, and a full one is not a reason to believe the answer was
288
+ // complete (#330). Before this the reader took page one and stopped: a model past position
289
+ // 100 was simply absent from the select, `liveStatus` still said `read`, and nothing said
290
+ // the list was shorter than the account. A missing entry has no `source` to mark stale
291
+ // with, so the per-entry labelling that covers a fallback cannot cover this at all.
292
+ //
293
+ // ⚠️ `result_info.total_count` is NOT the test, which is why it is not parsed. Measured on
294
+ // the live account (#297): it reports 286 while page one returns 61 and page two is empty.
295
+ // A reader that paginated on that figure would loop over empty pages and call the result
296
+ // partial. The full page is the only honest signal.
297
+ if (entries.length < ModelPerPage)
298
+ return models;
210
299
  }
211
- return (parsed.data.result ?? []).map(readModel);
300
+ // Ten full pages is not a big account, it is an endpoint that stopped ending. Refusing is the
301
+ // honest answer: the catalog service falls back to its built-in table and the screen says the
302
+ // figures may be old — better than a list that is silently missing whatever came after.
303
+ throw new IntelError(502, "cloudflare_api_unreadable", "The Cloudflare API kept answering full pages of models past the read limit");
212
304
  },
213
305
  };
214
306
  }
@@ -1,3 +1,7 @@
1
+ // How much of a chunk is kept as the passage a searcher is shown. The same 480 characters
2
+ // `hydrateVisibleCitations` cuts out of the full-text row, so a citation reads the same length
3
+ // whether it came from a card's own vector or from the fallback beside it.
4
+ const maxPassageCharacters = 480;
1
5
  function mapTarget(row, contentKeys) {
2
6
  return {
3
7
  nodeId: row.node_id,
@@ -68,6 +72,81 @@ export function createNodeIndexRepository(db) {
68
72
  .bind(target.versionId, target.nodeId, chunk.title, chunk.text, target.nodeId, target.versionId)),
69
73
  ]);
70
74
  },
75
+ /**
76
+ * ⚠️ `archived_at IS NOT NULL` carries this on its own, and it is the whole guard
77
+ * (anchrd/intel#348). `getTarget` above answers null for two reasons and only one of them means
78
+ * "purge": the other is a queue message for a version a later save superseded, and Cloudflare
79
+ * Queues deliver at least once, so those arrive as a matter of course. Acting on one would empty
80
+ * the vector index of a live board behind the back of the save that had just filled it — and
81
+ * nothing would report it, because a search that finds less is not an error.
82
+ *
83
+ * ⚠️ Deliberately NOT `current_version_id = v.id` as well, although that is what would make this
84
+ * the mirror image of `getTarget`. Symmetry is not a reason to write a condition: once the node
85
+ * IS archived, every version of it says the same thing about that node's vectors, so the extra
86
+ * clause could only ever refuse a purge that was right. A condition no test can turn red is a
87
+ * condition the next reader has to guess the purpose of.
88
+ */
89
+ async archivedNodeId(versionId) {
90
+ const row = await db
91
+ .prepare(`SELECT n.id AS node_id
92
+ FROM node_versions v
93
+ JOIN nodes n ON n.id = v.node_id
94
+ WHERE v.id = ? AND n.archived_at IS NOT NULL`)
95
+ .bind(versionId)
96
+ .first();
97
+ return row?.node_id ?? null;
98
+ },
99
+ async listVectors(nodeId) {
100
+ const result = await db
101
+ .prepare(`SELECT chunk_key, fingerprint, passage FROM node_vectors WHERE node_id = ?`)
102
+ .bind(nodeId)
103
+ .all();
104
+ return (result.results ?? []).map((row) => ({
105
+ chunkKey: row.chunk_key,
106
+ fingerprint: row.fingerprint,
107
+ passage: row.passage,
108
+ }));
109
+ },
110
+ /**
111
+ * The record of every vector this node has, replacing the record it had.
112
+ *
113
+ * ⚠️ The same delete-then-insert in ONE batch as `replace` above, and the same guard in front of
114
+ * every statement: a pass for a version that is no longer current, or for a node that has been
115
+ * archived meanwhile, writes nothing at all rather than half a record. What must not happen is
116
+ * the delete landing without the inserts — the next pass would then re-embed the whole board,
117
+ * which is exactly the cost anchrd/intel#301 exists to avoid.
118
+ */
119
+ async replaceVectors(target, records) {
120
+ await db.batch([
121
+ db
122
+ .prepare(`DELETE FROM node_vectors WHERE node_id = ? AND EXISTS (
123
+ SELECT 1 FROM nodes
124
+ WHERE id = ? AND current_version_id = ? AND archived_at IS NULL
125
+ )`)
126
+ .bind(target.nodeId, target.nodeId, target.versionId),
127
+ ...records.map((record) => db
128
+ .prepare(`INSERT INTO node_vectors (node_id, chunk_key, version_id, fingerprint, passage)
129
+ SELECT ?, ?, ?, ?, ? WHERE EXISTS (
130
+ SELECT 1 FROM nodes
131
+ WHERE id = ? AND current_version_id = ? AND archived_at IS NULL
132
+ )`)
133
+ .bind(target.nodeId, record.chunkKey, target.versionId, record.fingerprint, record.passage.slice(0, maxPassageCharacters), target.nodeId, target.versionId)),
134
+ ]);
135
+ },
136
+ /**
137
+ * The record of one node, gone — because its vectors are (anchrd/intel#348).
138
+ *
139
+ * ⚠️ No guard at all, and the absence is deliberate in both directions. There is no
140
+ * `archived_at IS NOT NULL`: the state this write exists to prevent is a row that outlives the
141
+ * vector it names, and a node restored between the deletion and this statement would keep
142
+ * exactly that — a record claiming vectors that are gone, which the next pass reads as "already
143
+ * embedded" and skips, leaving those cards silently unfindable. And there is no version
144
+ * condition: `archivedNodeId` has already established which node this is, from that same
145
+ * version, and a second reading of a moving row is a second answer rather than a safer one.
146
+ */
147
+ async deleteVectors(nodeId) {
148
+ await db.prepare("DELETE FROM node_vectors WHERE node_id = ?").bind(nodeId).run();
149
+ },
71
150
  async markIndexed(versionId, occurredAt) {
72
151
  await db
73
152
  .prepare(`UPDATE node_index_state
@@ -289,20 +289,17 @@ export function createNodeRepository(deps) {
289
289
  * ⚠️ `nodes` arrive parents before children: `parent_id REFERENCES nodes(id)` is checked as
290
290
  * each row lands, and a child before its parent would abort the batch that is otherwise valid.
291
291
  * 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
292
  */
296
293
  async importTree(input) {
297
294
  const statements = [
298
295
  deps.db
299
296
  .prepare(`INSERT INTO nodes (
300
- id, parent_id, kind, title, description, context_policy, owner_id,
297
+ id, parent_id, kind, title, description, owner_id,
301
298
  current_version_id, created_at, updated_at, archived_at
302
299
  ) SELECT
303
300
  json_extract(value, '$.id'), json_extract(value, '$.parentId'),
304
301
  json_extract(value, '$.kind'), json_extract(value, '$.title'),
305
- json_extract(value, '$.description'), 'relevant',
302
+ json_extract(value, '$.description'),
306
303
  json_extract(value, '$.ownerId'), json_extract(value, '$.currentVersionId'),
307
304
  json_extract(value, '$.createdAt'), json_extract(value, '$.updatedAt'), NULL
308
305
  FROM json_each(?)`)
@@ -458,14 +455,10 @@ export function createNodeRepository(deps) {
458
455
  try {
459
456
  await deps.db.batch([
460
457
  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
458
  .prepare(`INSERT INTO nodes (
466
- id, parent_id, kind, title, description, context_policy, owner_id,
459
+ id, parent_id, kind, title, description, owner_id,
467
460
  current_version_id, created_at, updated_at, archived_at
468
- ) VALUES (?, ?, ?, ?, ?, 'relevant', ?, ?, ?, ?, ?)`)
461
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
469
462
  .bind(node.id, node.parentId, node.kind, node.title, node.description, node.ownerId, node.currentVersionId, node.createdAt, node.updatedAt, node.archivedAt),
470
463
  deps.db
471
464
  .prepare(`INSERT INTO idempotency_keys (
@@ -1144,32 +1137,46 @@ export function createNodeRepository(deps) {
1144
1137
  match: "lexical",
1145
1138
  }));
1146
1139
  },
1147
- async hydrateVisibleCitations(actor, nodeIds, scopeId) {
1148
- const uniqueIds = [...new Set(nodeIds)].slice(0, 100);
1149
- if (uniqueIds.length === 0)
1140
+ async hydrateVisibleCitations(actor, hits, scopeId) {
1141
+ // One entry per node: the caller has already picked the best-scoring chunk of each, so a
1142
+ // board that answered with four cards arrives as the one card that answered best.
1143
+ const unique = [...new Map(hits.map((hit) => [hit.nodeId, hit])).values()].slice(0, 100);
1144
+ if (unique.length === 0)
1150
1145
  return [];
1151
1146
  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(", ");
1147
+ // Two bindings per hit now instead of one, so the batch is halved and then some: the actor's
1148
+ // predicate and the optional scope are bound in front of them, and D1 counts every one of
1149
+ // them against the same per-statement ceiling.
1150
+ for (let offset = 0; offset < unique.length; offset += 40) {
1151
+ const batch = unique.slice(offset, offset + 40);
1152
+ const pairs = batch.map(() => "(?, ?)").join(", ");
1155
1153
  const result = await deps.db
1156
- .prepare(`${visibleCte}${scopeCte(scopeId)}
1154
+ .prepare(`${visibleCte}${scopeCte(scopeId)},
1155
+ hits(node_id, chunk_key) AS (VALUES ${pairs})
1157
1156
  SELECT n.id AS node_id, v.id AS version_id, n.title,
1158
- substr(node_fts.content, 1, 480) AS passage,
1157
+ -- ⚠️ The card that answered, and only otherwise the first passage of the node
1158
+ -- (anchrd/intel#301). Every vector written before #301 is named by the bare node id
1159
+ -- and has no row here, so the fallback is not defensive coding but the exact
1160
+ -- behaviour those vectors had — a board found through one of them still answers,
1161
+ -- with the passage it always answered with, until the next indexing pass.
1162
+ COALESCE(vector.passage, substr(node_fts.content, 1, 480)) AS passage,
1159
1163
  n.updated_at AS freshness
1160
- FROM nodes n
1164
+ FROM hits
1165
+ JOIN nodes n ON n.id = hits.node_id
1161
1166
  JOIN node_versions v ON v.id = n.current_version_id
1162
1167
  JOIN node_fts ON node_fts.version_id = v.id
1168
+ LEFT JOIN node_vectors vector
1169
+ ON vector.node_id = hits.node_id AND vector.chunk_key = hits.chunk_key
1163
1170
  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)
1171
+ WHERE n.archived_at IS NULL`)
1172
+ .bind(...readBindings(actor), ...scopeBindings(scopeId), ...batch.flatMap((hit) => [hit.nodeId, hit.chunkKey]))
1166
1173
  .all();
1167
1174
  rows.push(...(result.results ?? []));
1168
1175
  }
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.
1176
+ const order = new Map(unique.map((hit, index) => [hit.nodeId, index]));
1177
+ // The same one-per-node rule as the lexical half above, for the same reason: a citation names
1178
+ // a NODE, and a board holds one full-text row per task (#285), so the join above still
1179
+ // produces one row per card even though only one of them was asked about.
1173
1180
  const deduped = dedupedByNode(rows);
1174
1181
  deduped.sort((left, right) => (order.get(left.node_id) ?? 0) - (order.get(right.node_id) ?? 0));
1175
1182
  return deduped.map((row) => ({
@@ -1183,10 +1190,42 @@ export function createNodeRepository(deps) {
1183
1190
  match: "semantic",
1184
1191
  }));
1185
1192
  },
1193
+ async invalidateVectors() {
1194
+ /**
1195
+ * ⚠️ The fingerprints are cleared and the ROWS are kept, which is not a detail. A record row
1196
+ * says two things at once: "this chunk was embedded from this text" and "this vector exists".
1197
+ * Only the first is stale after a rebuild is asked for; deleting the row would throw the
1198
+ * second away, and then a card removed between this call and that board's pass would leave a
1199
+ * vector nothing could ever name again — matching questions and answering with a card that is
1200
+ * not on the board.
1201
+ *
1202
+ * The empty string is a value no digest produces, so every chunk compares as changed and the
1203
+ * next pass embeds all of them. `node_fts` is deliberately not touched: its rows are
1204
+ * overwritten by the pass that rewrites them, and emptying it would take the lexical half of
1205
+ * the installation offline for as long as the queue needs to work through the tree.
1206
+ */
1207
+ await deps.db.prepare("UPDATE node_vectors SET fingerprint = ''").run();
1208
+ },
1209
+ /**
1210
+ * ⚠️ Archived nodes are IN this walk since anchrd/intel#348, and the omission of
1211
+ * `archived_at IS NULL` is the point. The pass an archived node gets is a purge, not an index —
1212
+ * `getTarget` refuses it and `archivedNodeId` says why — so including them is what makes
1213
+ * `reindex` the way back for a vector that is in the index with nothing left to name it.
1214
+ *
1215
+ * Three of those exist and none of them has another repair: everything archived before #348,
1216
+ * which nothing ever swept; a node whose `archive` wrote its row and then failed to reach the
1217
+ * queue; and a pass that upserted vectors in the moment somebody archived the node underneath
1218
+ * it. Without this line an administrator's rebuild walked straight past all three, and the only
1219
+ * remaining answer would have been to delete the whole Vectorize index by hand.
1220
+ *
1221
+ * It does mean `queued` counts them. That is still what the number says — versions handed to the
1222
+ * queue — and a rebuild that silently skipped part of the tree is the failure `reindex` exists
1223
+ * to prevent.
1224
+ */
1186
1225
  async listCurrentVersionIds(input) {
1187
1226
  const result = await deps.db
1188
1227
  .prepare(`SELECT current_version_id AS id FROM nodes
1189
- WHERE current_version_id IS NOT NULL AND archived_at IS NULL
1228
+ WHERE current_version_id IS NOT NULL
1190
1229
  AND (? IS NULL OR current_version_id > ?)
1191
1230
  ORDER BY id LIMIT ?`)
1192
1231
  .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 {