@anchrd/intel-api 0.12.5 → 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.
@@ -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 {
@@ -1,5 +1,6 @@
1
1
  import { AgentDefinition, AgentMediaType, BlockNoteMediaType, BoardDocument, BoardMediaType, BundleImportResult, BundleManifest, BundleManifestFilename, DocumentLinkInlineType, FlowGraph, TableMediaType, } from "@anchrd/intel-contract";
2
2
  import { Unzip, UnzipInflate, Zip, ZipDeflate, ZipPassThrough } from "fflate";
3
+ import { repeatedBoardId, upgradeStoredBoard } from "../nodes/board/board.js";
3
4
  import { documentLinkTargets } from "../nodes/document-links/document-links.js";
4
5
  import { parseCsv } from "../shared/csv/csv.js";
5
6
  import { IntelError } from "../shared/intel-error/intel-error.js";
@@ -925,6 +926,20 @@ export function createBundle(deps) {
925
926
  if (header.length === 0 || header.every((column) => column.trim().length === 0)) {
926
927
  throw new IntelError(400, "import_invalid_table", `Bundle entry is not a table with a header row: ${entry.path}`);
927
928
  }
929
+ // ⚠️ Distinct column names, read the same way `DefineTableInput` and
930
+ // `RedefineTableInput` read them — case-insensitively (#322). Both refuse a duplicate,
931
+ // and the import used to walk past both: a table could only enter the tree this way
932
+ // with two columns of one name.
933
+ //
934
+ // The consumer assumes the opposite and says so. `packages/ui/src/node-table` keys its
935
+ // header cells and every row cell with the column name and carries the comment
936
+ // "Column names are distinct by contract (`DefineTableInput`)" — true for the two write
937
+ // paths, and false for this one. Two equal names are two React keys with one value, in
938
+ // the header AND in every row.
939
+ const seen = new Set(header.map((column) => column.trim().toLowerCase()));
940
+ if (seen.size !== header.length) {
941
+ throw new IntelError(400, "import_invalid_table", `Bundle entry has two columns of the same name: ${entry.path}`);
942
+ }
928
943
  // The whole file as one snapshot: the same shape a definition or a redefine writes
929
944
  // (#135), so reading starts here and nothing older is expected to exist.
930
945
  version = versionRowFor(entry, text, TableMediaType, await deps.hash(text), "snapshot");
@@ -935,11 +950,41 @@ export function createBundle(deps) {
935
950
  if (text.trim().length > 0) {
936
951
  let document;
937
952
  try {
938
- document = BoardDocument.parse(JSON.parse(text));
953
+ // Same upgrade the stored read does (anchrd/intel#311): a bundle exported before
954
+ // `terminal` existed is exactly an old board in a file, and importing it must not be
955
+ // the one way to lose a board.
956
+ document = BoardDocument.parse(upgradeStoredBoard(JSON.parse(text)));
939
957
  }
940
958
  catch {
941
959
  throw new IntelError(400, "import_invalid_board", `Bundle entry is not a board: ${entry.path}`);
942
960
  }
961
+ /**
962
+ * ⚠️ Refused by name, and refused rather than folded (anchrd/intel#321).
963
+ *
964
+ * A board arriving in a file is the ONE place a document somebody else wrote becomes
965
+ * truth here — everywhere else a task id is minted by `deps.id()` and a status list
966
+ * goes through `ConfigureBoardInput`, which has demanded distinct ids since #285. Two
967
+ * tasks under one id threw `graph.addNode` out of the board's graph view, and two
968
+ * statuses under one id drew the same cards in two lanes with a drop that had two
969
+ * targets meaning one.
970
+ *
971
+ * Outside the catch above on purpose: swallowed into `import_invalid_board` this would
972
+ * read as "that file is not a board", and whoever is holding the file would look for
973
+ * the wrong thing. `repeatedBoardId` names the id, which is the line they have to fix.
974
+ *
975
+ * ⚠️ And it names the way out, not only the fault (anchrd/intel#341). The usual holder
976
+ * of such a file is not its author but somebody moving a tree between installations,
977
+ * and the pair is in the BOARD the export came from — where `board_repair_task_ids`
978
+ * and `board_configure` each remove one kind of it. Told only what is wrong, they were
979
+ * left editing JSON by hand or leaving the board behind, and an import is all or
980
+ * nothing: one such board stops the whole move.
981
+ */
982
+ const repeated = repeatedBoardId(document);
983
+ if (repeated !== null) {
984
+ throw new IntelError(400, "import_duplicate_board_id", repeated.list === "tasks"
985
+ ? `Bundle entry names the task “${repeated.id}” twice: ${entry.path}. Repair that board with board_repair_task_ids where it came from, then export it again.`
986
+ : `Bundle entry names the status “${repeated.id}” twice: ${entry.path}. Repair that board with board_configure — the status list written whole, with distinct ids — where it came from, then export it again.`);
987
+ }
943
988
  // ⚠️ `references` are remapped the way a document's inline links are, and `dependsOn`
944
989
  // and `parentId` are NOT (#285). A task's references point at NODES, which the import
945
990
  // renumbers; its dependencies and its parent point at tasks inside this very file,
package/dist/http/http.js CHANGED
@@ -486,6 +486,10 @@ export function createHttp(deps) {
486
486
  }
487
487
  return context.json(await deps.nodes.deleteBoardTask(asActor(auth), input), 201);
488
488
  });
489
+ // ⚠️ There is deliberately no route for the sixth board operation. `board_repair_task_ids`
490
+ // (anchrd/intel#341) is MCP-only until a screen calls it: the routes here are what the browser
491
+ // reaches, nothing in `intel-data-provider` asks for it, and a route with no caller is the thing
492
+ // YAGNI is about. The screen is anchrd/intel#358, and it brings this route with it.
489
493
  app.get("/nodes/:nodeId/links", async (context) => {
490
494
  const auth = requireCapability(context, "knowledge", "read");
491
495
  return context.json(await deps.nodes.listLinks(asActor(auth), context.req.param("nodeId")));
@@ -1,6 +1,14 @@
1
- import { BlockNoteDocument, BlockNoteMediaType, BoardDocument } from "@anchrd/intel-contract";
1
+ import { ArchivedBoardStatusId, BlockNoteDocument, BlockNoteMediaType, BoardDocument, } from "@anchrd/intel-contract";
2
+ import { upgradeStoredBoard } from "../nodes/board/board.js";
2
3
  export class PermanentIndexingError extends Error {
3
4
  }
5
+ // The key under which a node that has exactly one vector is filed — every kind but `board`. It is
6
+ // the bare node id in the index itself, which is why nothing written before anchrd/intel#301 has to
7
+ // be renamed or embedded again.
8
+ const wholeNodeChunkKey = "";
9
+ // What the pass hands to `purgeVectors` at its end: the names it has just written, in case the node
10
+ // was archived while it worked and the record of them was refused (anchrd/intel#348).
11
+ const writtenKeys = (chunks) => chunks.map((chunk) => chunk.key);
4
12
  function indexText(mediaType, content) {
5
13
  if (mediaType !== BlockNoteMediaType)
6
14
  return content;
@@ -14,7 +22,8 @@ function indexText(mediaType, content) {
14
22
  }
15
23
  }
16
24
  /**
17
- * One passage per task, never one per board (#285).
25
+ * One passage per task, never one per board (#285) — and since anchrd/intel#301 one VECTOR per task
26
+ * too, which is what `key` carries.
18
27
  *
19
28
  * ⚠️ This is the whole reason a board is chunked at all. "Where do I stand with X" has to land on a
20
29
  * CARD: indexed as one blob, a board of three hundred tasks matches on any of them and answers with
@@ -22,8 +31,17 @@ function indexText(mediaType, content) {
22
31
  * have nothing to do with the question. Each chunk carries the task's own title, which is the
23
32
  * column the FTS table weights highest.
24
33
  *
25
- * ⚠️ The status list is not indexed. "Backlog" and "Done" appear on every board in the
26
- * installation, so they are the words most likely to match and the least likely to mean anything.
34
+ * ⚠️ The status list is not indexed, and neither is a task's own status. "Backlog" and "Done" appear
35
+ * on every board in the installation, so they are the words most likely to match and the least
36
+ * likely to mean anything — and in the semantic half they would drag every card of every board
37
+ * towards every question phrased as a state.
38
+ *
39
+ * ⚠️ A task on the `archived` shelf is left out of BOTH halves (anchrd/intel#301). That shelf is
40
+ * what a board has instead of deleting a card (`ArchivedBoardStatusId`), so it is the same statement
41
+ * `archived_at` makes about a node one level up — and an archived node has never been searchable.
42
+ * Answering "where do I stand with X" out of a card somebody swept away is the failure the ticket
43
+ * names. A card in a `terminal` status is NOT swept: "we finished it" is an answer to where the work
44
+ * stands, and the only thing that says a card no longer counts is the shelf.
27
45
  */
28
46
  function boardChunks(content) {
29
47
  let parsed;
@@ -33,15 +51,139 @@ function boardChunks(content) {
33
51
  catch (error) {
34
52
  throw new PermanentIndexingError(`Node version content is not valid JSON: ${error instanceof Error ? error.message : "unknown parse failure"}`);
35
53
  }
36
- const board = BoardDocument.safeParse(parsed);
54
+ /**
55
+ * ⚠️ Upgraded first, exactly like `parseStoredBoard` — this is the SECOND place a stored board
56
+ * body is parsed, and it reads bodies nobody has rewritten since (anchrd/intel#318).
57
+ *
58
+ * `reindex` walks every current version in the installation, so an old board arrives here as it
59
+ * was written: without `terminal` (#311) or with a repeated `dependsOn`. Without the upgrade the
60
+ * refusal below is PERMANENT — the version is marked failed and never retried — so that board
61
+ * would simply stop being findable, quietly, while its screen still worked. A rule added at one
62
+ * parse site and not the other is how a fix looks complete and is not.
63
+ */
64
+ const board = BoardDocument.safeParse(upgradeStoredBoard(parsed));
37
65
  if (!board.success) {
38
66
  throw new PermanentIndexingError("Node version content is not a valid board document");
39
67
  }
40
- return board.data.tasks.map((task) => ({
68
+ return board.data.tasks
69
+ .filter((task) => task.status !== ArchivedBoardStatusId)
70
+ .map((task) => ({
71
+ key: task.id,
41
72
  title: task.title,
42
73
  text: [task.title, ...task.labels, task.description].filter(Boolean).join("\n\n"),
43
74
  }));
44
75
  }
76
+ /**
77
+ * What one chunk is embedded as (anchrd/intel#301).
78
+ *
79
+ * The node's title in front of a whole-node chunk, which is the exact string the semantic adapter
80
+ * used to compose on its own side. It moved here because the fingerprint has to cover what is
81
+ * embedded and nothing else: a prefix added downstream would sit outside the comparison and change
82
+ * nothing when it changed. The vector it produces therefore keeps the same name AND the same
83
+ * content it had before this — an upgrading installation loses no answer, though its first pass
84
+ * over any node does embed once more, because `node_vectors` starts empty and has no fingerprint to
85
+ * compare against. That cost is one embedding per node, paid when that node is next saved or when
86
+ * an administrator runs `reindex`, and never per card.
87
+ *
88
+ * ⚠️ A card is NOT prefixed with the board's title. It brings its own title as the first line of its
89
+ * text already, and the board's would put a value into all three hundred fingerprints that moves
90
+ * when somebody renames the board — one rename, three hundred embeddings.
91
+ */
92
+ function embeddedText(target, chunk) {
93
+ return chunk.key === wholeNodeChunkKey ? `${target.title}\n\n${chunk.text}` : chunk.text;
94
+ }
95
+ /**
96
+ * The vectors this node needs, minus the ones it already has (anchrd/intel#301).
97
+ *
98
+ * ⚠️ This is the answer to "a changed card must not reindex the board". A save writes the whole
99
+ * document — that is what a board version IS — so without this every keystroke-save of a board with
100
+ * three hundred cards would cost three hundred embeddings. The fingerprint of the exact text that
101
+ * was embedded is what tells an untouched card from a changed one.
102
+ *
103
+ * ⚠️ The comparison is against D1 and deliberately not against Vectorize. Vectorize writes are
104
+ * asynchronous — a vector upserted a moment ago is not readable yet — so a check made there would
105
+ * answer about the save before last and re-embed a board that had just been saved twice.
106
+ *
107
+ * ⚠️ The record is written only after the upsert returned, in the caller. A pass that dies in
108
+ * between leaves the record short, so the next one embeds again; the opposite order would leave a
109
+ * record claiming a vector nobody ever wrote, and nothing would ever notice.
110
+ */
111
+ async function replaceVectors(deps, target, chunks) {
112
+ const semantic = deps.semantic;
113
+ if (!semantic)
114
+ return;
115
+ const stored = await deps.repository.listVectors(target.nodeId);
116
+ const known = new Map(stored.map((record) => [record.chunkKey, record.fingerprint]));
117
+ const records = [];
118
+ const changed = [];
119
+ for (const chunk of chunks) {
120
+ const text = embeddedText(target, chunk);
121
+ const fingerprint = await deps.hash(text);
122
+ if (known.get(chunk.key) !== fingerprint)
123
+ changed.push({ key: chunk.key, text });
124
+ records.push({ chunkKey: chunk.key, fingerprint, passage: chunk.text });
125
+ }
126
+ const wanted = new Set(chunks.map((chunk) => chunk.key));
127
+ const stale = stored.map((record) => record.chunkKey).filter((key) => !wanted.has(key));
128
+ // ⚠️ The one-time sweep of the vector this node had BEFORE it was chunked. An empty record with a
129
+ // chunked node means this is the first pass since anchrd/intel#301 (or since `reindex` emptied
130
+ // the record), and the whole-board vector written under the bare node id is still sitting there —
131
+ // matching questions and answering with the wrong card's passage. Once a record exists this
132
+ // branch is never taken again, so it costs one call per board rather than one per save.
133
+ if (stored.length === 0 && !wanted.has(wholeNodeChunkKey))
134
+ stale.push(wholeNodeChunkKey);
135
+ await semantic.upsert(target, changed);
136
+ if (stale.length > 0)
137
+ await semantic.remove(target.nodeId, stale);
138
+ await deps.repository.replaceVectors(target, records);
139
+ }
140
+ /**
141
+ * Everything an archived node had in the vector index, taken out of it (anchrd/intel#348).
142
+ *
143
+ * ⚠️ The ORDER is the whole safety argument, and it is the exact mirror of the one above. There the
144
+ * record is written last, because a row claiming a vector nobody wrote would make the next pass skip
145
+ * that card forever. Here the record is DELETED last, because the row is the only thing that knows
146
+ * what a vector is called: drop it first and the vectors stay in the index under names nothing left
147
+ * anywhere can produce. A pass that dies in between leaves vectors already gone and a record that
148
+ * still names them — which costs the next pass one embedding per card and nothing else, because the
149
+ * queue redelivers this message and the second run asks Vectorize to forget names it has already
150
+ * forgotten. Both failures are repaired by repeating; neither ends in a wrong answer.
151
+ *
152
+ * ⚠️ The bare node id goes with them whether or not it is in the record. That name is the ONE vector
153
+ * a node can hold without any record of it — everything written before anchrd/intel#301 is filed
154
+ * under it — and an archived node has no later pass in which the one-time sweep in `replaceVectors`
155
+ * could find it. The standing cost of that is one `deleteByIds` of a single name on a redelivered
156
+ * message, which Vectorize answers without complaint for an id it no longer holds.
157
+ *
158
+ * ⚠️ `alsoNamed` is what makes this run after a NORMAL pass too, and it closes the one hole a purge
159
+ * driven from `archive` alone cannot. A pass that has already upserted its vectors when somebody
160
+ * archives the node writes no record at all — `replaceVectors` refuses on `archived_at IS NULL` —
161
+ * so those vectors would sit in the index with nothing anywhere able to name them, and no message
162
+ * left to repair it. The pass knows the names it just wrote, so it hands them over.
163
+ *
164
+ * ⚠️ Without a semantic index configured nothing happens at all, `node_vectors` included. The rows
165
+ * can only have been written while one WAS configured, so a deployment that has temporarily lost the
166
+ * binding must not take the record away — that would leave the vectors behind it unnameable, which
167
+ * is the failure this whole function exists to avoid.
168
+ *
169
+ * ⚠️ One race is left and it is the harmless direction: a restore that lands between the read below
170
+ * and the deletion loses the vectors that restore's own pass had just written. The node keeps
171
+ * answering out of the lexical half — `hydrateVisibleCitations` falls back on the full-text passage
172
+ * when there is no `node_vectors` row — and the next save or `reindex` puts the vectors back. The
173
+ * record is gone with them, so nothing claims otherwise in the meantime.
174
+ */
175
+ async function purgeVectors(deps, versionId, alsoNamed = []) {
176
+ const semantic = deps.semantic;
177
+ if (!semantic)
178
+ return;
179
+ const nodeId = await deps.repository.archivedNodeId(versionId);
180
+ if (nodeId === null)
181
+ return;
182
+ const recorded = (await deps.repository.listVectors(nodeId)).map((record) => record.chunkKey);
183
+ const keys = [...new Set([wholeNodeChunkKey, ...recorded, ...alsoNamed])];
184
+ await semantic.remove(nodeId, keys);
185
+ await deps.repository.deleteVectors(nodeId);
186
+ }
45
187
  async function readCanonical(deps, target) {
46
188
  if (target.kind === "attachment") {
47
189
  const key = target.contentKeys[0];
@@ -56,8 +198,15 @@ export function createIndexing(deps) {
56
198
  return {
57
199
  async index(versionId) {
58
200
  const target = await deps.repository.getTarget(versionId);
59
- if (!target)
201
+ // ⚠️ No target has meant "nothing to do" since the pass existed, and for a superseded version
202
+ // it still does. For an ARCHIVED node it never did: its vectors stayed in the index, invisible
203
+ // because every citation is hydrated through a join on `nodes` — and costing places in a
204
+ // candidate list that Vectorize caps at 100 for everybody (anchrd/intel#348). `purgeVectors`
205
+ // is the one that tells the two apart; this call cannot.
206
+ if (!target) {
207
+ await purgeVectors(deps, versionId);
60
208
  return;
209
+ }
61
210
  // ⚠️ An agent's body is a definition, not something to read: node IDs, a cron line and a model
62
211
  // name (ADR-0005 §4). Indexing it verbatim would fill the index with identifiers and, worse,
63
212
  // put them into the passage a searcher is shown — the definition names nodes the searcher may
@@ -65,10 +214,12 @@ export function createIndexing(deps) {
65
214
  // the tree being asked. What makes an agent findable is what a person wrote about it, so the
66
215
  // text is its description, and the FTS table indexes the title on its own (#139).
67
216
  if (target.kind === "agent") {
68
- await deps.repository.replace(target, [
69
- { title: target.title, text: target.description ?? "" },
70
- ]);
71
- await deps.semantic?.replace(target, target.description ?? "");
217
+ const chunks = [
218
+ { key: wholeNodeChunkKey, title: target.title, text: target.description ?? "" },
219
+ ];
220
+ await deps.repository.replace(target, chunks);
221
+ await replaceVectors(deps, target, chunks);
222
+ await purgeVectors(deps, versionId, writtenKeys(chunks));
72
223
  await deps.repository.markIndexed(versionId, deps.now().toISOString());
73
224
  return;
74
225
  }
@@ -89,12 +240,21 @@ export function createIndexing(deps) {
89
240
  if (text === undefined) {
90
241
  throw new PermanentIndexingError(`No document converter is configured for ${target.mediaType}`);
91
242
  }
92
- // ⚠️ The semantic index stays per NODE while the lexical one gains a row per task. Vectorize
93
- // is keyed by node id (`semantic-index.ts`), so a board would need its own key space there
94
- // before it could carry one vector per card a bigger change than #285, and one nothing
95
- // asks for yet: the lexical half is what "find the card" needs today.
96
- await deps.repository.replace(target, target.kind === "board" ? boardChunks(text) : [{ title: target.title, text }]);
97
- await deps.semantic?.replace(target, text);
243
+ // Both halves are chunked the same way now (anchrd/intel#301): a board is one passage and
244
+ // one vector per card, everything else is one of each for the node. The two indexes read the
245
+ // same list, so they cannot come to disagree about what a board is made of.
246
+ const chunks = target.kind === "board"
247
+ ? boardChunks(text)
248
+ : [{ key: wholeNodeChunkKey, title: target.title, text }];
249
+ await deps.repository.replace(target, chunks);
250
+ await replaceVectors(deps, target, chunks);
251
+ // ⚠️ Asked again, at the END of a pass that started on a live node (anchrd/intel#348). The
252
+ // node may have been archived while this pass was embedding, and then `replaceVectors` above
253
+ // wrote no record at all — its statements refuse on `archived_at IS NULL`. The vectors are in
254
+ // the index either way, so without this they would stay there with nothing anywhere able to
255
+ // name them and no message left to try again: the archive's own pass has an empty record to
256
+ // read from. One extra `SELECT` per pass buys that, and on a live node it answers null.
257
+ await purgeVectors(deps, versionId, writtenKeys(chunks));
98
258
  await deps.repository.markIndexed(versionId, deps.now().toISOString());
99
259
  }
100
260
  catch (error) {
@@ -6,5 +6,6 @@ export interface IndexingDeps {
6
6
  content: ContentStore;
7
7
  semantic?: SemanticIndex | undefined;
8
8
  converter?: DocumentConverter | undefined;
9
+ hash(content: string): Promise<string>;
9
10
  now(): Date;
10
11
  }