@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.
- package/dist/adapters/cloudflare/cloudflare.js +1 -0
- package/dist/adapters/cloudflare-api/cloudflare-api.js +153 -44
- package/dist/adapters/db/db-indexing.js +79 -0
- package/dist/adapters/db/db.js +66 -27
- package/dist/adapters/openid/openid.js +70 -10
- 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 +46 -1
- package/dist/http/http.js +4 -0
- package/dist/indexing/indexing.js +177 -17
- package/dist/indexing/indexing.types.d.ts +1 -0
- package/dist/mcp/mcp.js +48 -34
- package/dist/nodes/board/board.d.ts +46 -0
- package/dist/nodes/board/board.js +475 -8
- package/dist/nodes/board/board.types.d.ts +7 -0
- package/dist/nodes/document-links/document-links.js +12 -1
- package/dist/nodes/nodes.js +152 -16
- package/dist/nodes/nodes.types.d.ts +61 -3
- package/dist/tools/tools.js +7 -1
- 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 +90 -0
- package/package.json +2 -2
package/dist/nodes/nodes.js
CHANGED
|
@@ -2,7 +2,7 @@ import { AgentDefinition, AgentMediaType, agentReferenceAccepts, BoardDocument,
|
|
|
2
2
|
import { encodeCsv, parseCsv } from "../shared/csv/csv.js";
|
|
3
3
|
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
4
4
|
import { plainTitle } from "../shared/plain-title/plain-title.js";
|
|
5
|
-
import { createBoard } from "./board/board.js";
|
|
5
|
+
import { createBoard, upgradeStoredBoard } from "./board/board.js";
|
|
6
6
|
import { documentLinkTargets } from "./document-links/document-links.js";
|
|
7
7
|
// ⚠️ The R2 key of a version written before #125 begins `knowledge/`, and it stays that way. A key
|
|
8
8
|
// is stored in `node_versions.content_key` and read back from there; nothing derives one from ids,
|
|
@@ -470,7 +470,11 @@ export function createNodes(deps) {
|
|
|
470
470
|
catch {
|
|
471
471
|
throw new IntelError(500, "board_unreadable", "The stored board cannot be read");
|
|
472
472
|
}
|
|
473
|
-
|
|
473
|
+
// ⚠️ Upgraded BEFORE validation, not after. `terminal` is required now (anchrd/intel#311), and
|
|
474
|
+
// this parse is deliberately loud — so a board written before the flag existed would answer
|
|
475
|
+
// `board_unreadable` rather than "old", and every board in every installation would break on
|
|
476
|
+
// deploy. `upgradeStoredBoard` fills only what is missing; the next write persists it.
|
|
477
|
+
const document = BoardDocument.safeParse(upgradeStoredBoard(parsed));
|
|
474
478
|
// ⚠️ Loud, unlike the tolerant read the share warning makes of an agent definition. A board IS
|
|
475
479
|
// its document: answering with an empty one would show somebody a board with no tasks on it,
|
|
476
480
|
// and the next write would store that as the truth.
|
|
@@ -685,6 +689,34 @@ export function createNodes(deps) {
|
|
|
685
689
|
throw new IntelError(500, "board_unreadable", "The written task cannot be read back");
|
|
686
690
|
return task;
|
|
687
691
|
}
|
|
692
|
+
/**
|
|
693
|
+
* What the repair renumbered, read back out of the version it wrote (anchrd/intel#341).
|
|
694
|
+
*
|
|
695
|
+
* ⚠️ The pairs come from the audit metadata for the same reason the added task's id does: a
|
|
696
|
+
* replayed key has to answer with what the FIRST attempt actually did, and the document alone
|
|
697
|
+
* cannot say. After the write every id on that board is distinct and none of them says it is new.
|
|
698
|
+
*
|
|
699
|
+
* ⚠️ The metadata carries ids and no titles — audit is metadata, and a title is text somebody
|
|
700
|
+
* wrote (the same line `configureBoard` draws for its status labels). The tasks themselves are
|
|
701
|
+
* read out of the document, so the answer is the board's own truth rather than a copy made at
|
|
702
|
+
* write time.
|
|
703
|
+
*/
|
|
704
|
+
function renumberedTasks(written) {
|
|
705
|
+
const recorded = written.metadata.renumbered;
|
|
706
|
+
if (!Array.isArray(recorded) || recorded.length === 0) {
|
|
707
|
+
throw new IntelError(500, "board_unreadable", "The repair did not record what it renumbered");
|
|
708
|
+
}
|
|
709
|
+
return recorded.map((entry) => {
|
|
710
|
+
const pair = entry;
|
|
711
|
+
const task = typeof pair.taskId === "string"
|
|
712
|
+
? written.document.tasks.find((candidate) => candidate.id === pair.taskId)
|
|
713
|
+
: undefined;
|
|
714
|
+
if (typeof pair.previousId !== "string" || !task) {
|
|
715
|
+
throw new IntelError(500, "board_unreadable", "The repaired task cannot be read back");
|
|
716
|
+
}
|
|
717
|
+
return { previousId: pair.previousId, task };
|
|
718
|
+
});
|
|
719
|
+
}
|
|
688
720
|
/**
|
|
689
721
|
* What the grant just written does not cover: the documents the flows in this folder read that
|
|
690
722
|
* the new principal still cannot.
|
|
@@ -880,11 +912,19 @@ export function createNodes(deps) {
|
|
|
880
912
|
return (await deps.repository.can(actor, folder.id, "write")) ? "ok" : "forbidden";
|
|
881
913
|
},
|
|
882
914
|
async create(actor, input) {
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
|
|
887
|
-
|
|
915
|
+
// ⚠️ An agent is never filed through the generic path, whatever the installation looks like
|
|
916
|
+
// (#193). The row it produced was an agent by kind and nothing else: no definition, and no
|
|
917
|
+
// Gate Application — so its first run failed with `agent_key_missing` in the runtime, and the
|
|
918
|
+
// repair was a second, easily forgotten step in Gate. `createAgent` makes both in one go and
|
|
919
|
+
// asks Gate BEFORE it writes anything, so a refusal leaves nothing behind.
|
|
920
|
+
//
|
|
921
|
+
// The refusal is here rather than on each surface for the reason every rule in this file is:
|
|
922
|
+
// `POST /nodes` and the `node_create` MCP tool are two doors into one service, and a check at
|
|
923
|
+
// a door is a check somebody adds a third door past. #190 put the runtime gate here and left
|
|
924
|
+
// this case open on purpose — the ticket demanded "exactly as today" where a runtime exists.
|
|
925
|
+
// This is that case, closed.
|
|
926
|
+
if (input.kind === "agent") {
|
|
927
|
+
throw new IntelError(400, "agent_needs_agent_endpoint", "An agent is created with its definition and its principal together — use the agent endpoint (POST /nodes/agents, or the agent_create tool) rather than the generic node create");
|
|
888
928
|
}
|
|
889
929
|
const existingId = await deps.repository.findIdempotentNode(actor.id, "node.create", input.idempotencyKey);
|
|
890
930
|
if (existingId)
|
|
@@ -1506,6 +1546,38 @@ export function createNodes(deps) {
|
|
|
1506
1546
|
}
|
|
1507
1547
|
return { node: written.node, version: written.version, deleted };
|
|
1508
1548
|
},
|
|
1549
|
+
/**
|
|
1550
|
+
* The way out of a board that names one task id twice (anchrd/intel#341).
|
|
1551
|
+
*
|
|
1552
|
+
* ⚠️ It goes through `applyToBoard` like the other five, so it is one version against the board
|
|
1553
|
+
* as it stands, it replays on its key, and it loses a race the same way. What it does NOT do is
|
|
1554
|
+
* name a task — see `repairTaskIds` in `board.ts` for why the pair is exactly what cannot be
|
|
1555
|
+
* named, and for which entry keeps the id.
|
|
1556
|
+
*
|
|
1557
|
+
* ⚠️ No `checkBoardTargets`: nothing about `references` or `assignee` moves, so there is no
|
|
1558
|
+
* fresh claim on the tree to check. Re-checking the ones already stored would refuse the repair
|
|
1559
|
+
* over a reference somebody lost access to since the import — a board stuck for a second reason
|
|
1560
|
+
* on the way out of the first.
|
|
1561
|
+
*/
|
|
1562
|
+
async repairBoardTaskIds(actor, input) {
|
|
1563
|
+
const written = await applyToBoard(actor, input, "node.board_task_repair", (document) => {
|
|
1564
|
+
const repaired = board.repairTaskIds(document);
|
|
1565
|
+
return {
|
|
1566
|
+
document: repaired.board,
|
|
1567
|
+
metadata: {
|
|
1568
|
+
renumbered: repaired.renumbered.map((entry) => ({
|
|
1569
|
+
previousId: entry.previousId,
|
|
1570
|
+
taskId: entry.task.id,
|
|
1571
|
+
})),
|
|
1572
|
+
},
|
|
1573
|
+
};
|
|
1574
|
+
});
|
|
1575
|
+
return {
|
|
1576
|
+
node: written.node,
|
|
1577
|
+
version: written.version,
|
|
1578
|
+
renumbered: renumberedTasks(written),
|
|
1579
|
+
};
|
|
1580
|
+
},
|
|
1509
1581
|
async listVersions(actor, nodeId) {
|
|
1510
1582
|
await requireVisible(actor, nodeId);
|
|
1511
1583
|
return { items: await deps.repository.listVersions(nodeId) };
|
|
@@ -1621,6 +1693,26 @@ export function createNodes(deps) {
|
|
|
1621
1693
|
}
|
|
1622
1694
|
throw new IntelError(409, "update_conflict", "This node was changed by another editor");
|
|
1623
1695
|
}
|
|
1696
|
+
/**
|
|
1697
|
+
* ⚠️ Both directions, through the same queue every save goes through (anchrd/intel#348). The
|
|
1698
|
+
* pass reads the node's state and does the matching thing: an archived node has its vectors
|
|
1699
|
+
* taken out of the index, a restored one is embedded again from a record that was emptied when
|
|
1700
|
+
* it went. Two calls to one door rather than a purge written out here, because Vectorize is a
|
|
1701
|
+
* second system and a call into it can fail — the queue is the only thing in this repository
|
|
1702
|
+
* that comes back for it, and a deletion nobody retries is a deletion that quietly did not
|
|
1703
|
+
* happen.
|
|
1704
|
+
*
|
|
1705
|
+
* ⚠️ The full-text half is deliberately NOT emptied on the way in, and the asymmetry is the
|
|
1706
|
+
* point rather than an oversight: an FTS row costs storage and is already invisible (every
|
|
1707
|
+
* read joins `nodes` and drops what is archived), while a vector costs a place in a candidate
|
|
1708
|
+
* list Vectorize caps at 100 for the whole installation. The archived board pays with
|
|
1709
|
+
* somebody else's search results.
|
|
1710
|
+
*
|
|
1711
|
+
* A replayed archive never reaches this line — it returned above, on the idempotency key — so
|
|
1712
|
+
* repeating the same call does not ask the index to forget the same names twice.
|
|
1713
|
+
*/
|
|
1714
|
+
if (updated.currentVersionId)
|
|
1715
|
+
await deps.indexing.enqueue(updated.currentVersionId);
|
|
1624
1716
|
return updated;
|
|
1625
1717
|
},
|
|
1626
1718
|
async listGrants(actor, resourceId) {
|
|
@@ -1768,18 +1860,50 @@ export function createNodes(deps) {
|
|
|
1768
1860
|
if (!deps.semantic)
|
|
1769
1861
|
return { items: lexical.slice(0, input.limit) };
|
|
1770
1862
|
try {
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1863
|
+
/**
|
|
1864
|
+
* Every candidate Vectorize will give for one query, scoped or not (anchrd/intel#348).
|
|
1865
|
+
*
|
|
1866
|
+
* ⚠️ A candidate is a CARD since anchrd/intel#301, not a node, so a busy board can take a
|
|
1867
|
+
* large share of these places and push other nodes out before this code ever sees them —
|
|
1868
|
+
* the fold below cannot repair that, it runs on what came back. `limit * 4` was written when
|
|
1869
|
+
* a board was one vector and forty candidates were forty nodes; against one vector per card
|
|
1870
|
+
* it is a list a single board fills on its own. Asking for the ceiling is the one widening
|
|
1871
|
+
* available. Vectorize charges the query and not the depth, so that side is free; the price
|
|
1872
|
+
* is on the D1 side, where `hydrateVisibleCitations` batches 40 pairs per statement and the
|
|
1873
|
+
* ordinary unscoped search therefore goes from one statement to as many as three. Nothing
|
|
1874
|
+
* about the ceiling itself moves: a `limit` of 25 and every scoped search reached 100 before.
|
|
1875
|
+
*
|
|
1876
|
+
* ⚠️ It is a widening and not a fix, and the reason it is not is written down in
|
|
1877
|
+
* `packages/api/CLAUDE.md`: bounding the fan-out per NODE means filtering on a `nodeId`
|
|
1878
|
+
* metadata index, and Cloudflare only puts a vector into such an index when it is upserted
|
|
1879
|
+
* AFTER the index was created — so it would cost every installation a full re-embed of its
|
|
1880
|
+
* tree, plus an operational step no deployment has taken. That is anchrd/intel#356.
|
|
1881
|
+
*
|
|
1882
|
+
* ⚠️ 100 is the port's own clamp too, and it is Vectorize's documented ceiling for a query
|
|
1883
|
+
* that returns neither values nor metadata (50 for one that does). The scoped case has asked
|
|
1884
|
+
* for it since #126, because a scope cuts the candidates AFTERWARDS, in the D1 statement
|
|
1885
|
+
* that re-checks the ACL — a folder of a dozen documents inside a tree of thousands is not
|
|
1886
|
+
* reached by a narrow fan-out, and a starved scope looks like an empty folder.
|
|
1887
|
+
*/
|
|
1888
|
+
const candidates = 100;
|
|
1777
1889
|
const hits = await deps.semantic.search(input.query, candidates);
|
|
1778
|
-
|
|
1890
|
+
/**
|
|
1891
|
+
* The best-scoring chunk of each node, and its score (anchrd/intel#301).
|
|
1892
|
+
*
|
|
1893
|
+
* ⚠️ The best, not the sum and not the first. A board answers once per card whose vector
|
|
1894
|
+
* matched, and a citation names a node — so a board of three hundred mediocre cards must not
|
|
1895
|
+
* out-rank one document that actually answers, and the passage the reader is shown has to be
|
|
1896
|
+
* the card that scored, not the one that happened to come back first.
|
|
1897
|
+
*/
|
|
1898
|
+
const best = new Map();
|
|
1779
1899
|
for (const hit of hits) {
|
|
1780
|
-
|
|
1900
|
+
const current = best.get(hit.nodeId);
|
|
1901
|
+
if (current === undefined || hit.score > current.score) {
|
|
1902
|
+
best.set(hit.nodeId, { chunkKey: hit.chunkKey, score: hit.score });
|
|
1903
|
+
}
|
|
1781
1904
|
}
|
|
1782
|
-
const
|
|
1905
|
+
const semanticScores = new Map([...best].map(([nodeId, winner]) => [nodeId, winner.score]));
|
|
1906
|
+
const semantic = await deps.repository.hydrateVisibleCitations(actor, [...best].map(([nodeId, winner]) => ({ nodeId, chunkKey: winner.chunkKey })), input.scopeId);
|
|
1783
1907
|
return {
|
|
1784
1908
|
items: mergeSearchResults(lexical, semantic, semanticScores, input.limit),
|
|
1785
1909
|
};
|
|
@@ -1792,6 +1916,18 @@ export function createNodes(deps) {
|
|
|
1792
1916
|
if (actor.isAdmin !== true) {
|
|
1793
1917
|
throw new IntelError(403, "reindex_forbidden", "Reindex permission is required");
|
|
1794
1918
|
}
|
|
1919
|
+
/**
|
|
1920
|
+
* ⚠️ First, and before a single version is enqueued: the record of what the vector index
|
|
1921
|
+
* holds (anchrd/intel#301). Since #301 an indexing pass skips a chunk whose fingerprint has
|
|
1922
|
+
* not moved, which is what keeps a board of three hundred cards from costing three hundred
|
|
1923
|
+
* embeddings per save — and it would equally make `reindex` skip everything, so a vector
|
|
1924
|
+
* index that had been emptied would stay empty while every version was dutifully requeued.
|
|
1925
|
+
* That is the one failure this call exists to prevent, and it is a silent one: the answer
|
|
1926
|
+
* would be a search that finds less and a `queued` count that says all is well.
|
|
1927
|
+
*
|
|
1928
|
+
* The full-text half needs no equivalent because its rows are overwritten, never skipped.
|
|
1929
|
+
*/
|
|
1930
|
+
await deps.repository.invalidateVectors();
|
|
1795
1931
|
let queued = 0;
|
|
1796
1932
|
let after = null;
|
|
1797
1933
|
for (;;) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AddBoardTaskInput, AgentKeyRotated, AgentList, AppendTableRowsInput, AppendTableRowsResult, ArchiveNodeInput, BoardTaskResult, ConfigureBoardInput, ConfigureBoardResult, CreateAgentInput, CreatedAgent, CreateNodeInput, DefineTableInput, DeleteBoardTaskInput, DeleteBoardTaskResult, DeleteTableRowsInput, DeleteTableRowsResult, Flow, FlowVersion, GetAgentInput, GetBoardInput, ListAgentsInput, ListNodesInput, MoveBoardTaskInput, Node, NodeAgent, NodeAttachment, NodeBoard, NodeCitation, NodeDocument, NodeGraph, NodeGraphInput, NodeLink, NodeTable, NodeVersion, RedefineTableInput, ResolveNodeLinksInput, ResolveNodeLinksResult, ResourceGrant, ResourceGrantList, ResourceVerb, RevokeGrantInput, RotateAgentKeyInput, SaveAgentDefinitionInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, ShareInput, ShareResult, UpdateBoardTaskInput, UpdateNodeInput, UpdateTableRowsInput, UpdateTableRowsResult } from "@anchrd/intel-contract";
|
|
1
|
+
import type { AddBoardTaskInput, AgentKeyRotated, AgentList, AppendTableRowsInput, AppendTableRowsResult, ArchiveNodeInput, BoardTaskResult, ConfigureBoardInput, ConfigureBoardResult, CreateAgentInput, CreatedAgent, CreateNodeInput, DefineTableInput, DeleteBoardTaskInput, DeleteBoardTaskResult, DeleteTableRowsInput, DeleteTableRowsResult, Flow, FlowVersion, GetAgentInput, GetBoardInput, ListAgentsInput, ListNodesInput, MoveBoardTaskInput, Node, NodeAgent, NodeAttachment, NodeBoard, NodeCitation, NodeDocument, NodeGraph, NodeGraphInput, NodeLink, NodeTable, NodeVersion, RedefineTableInput, RepairBoardTaskIdsInput, RepairBoardTaskIdsResult, ResolveNodeLinksInput, ResolveNodeLinksResult, ResourceGrant, ResourceGrantList, ResourceVerb, RevokeGrantInput, RotateAgentKeyInput, SaveAgentDefinitionInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, ShareInput, ShareResult, UpdateBoardTaskInput, UpdateNodeInput, UpdateTableRowsInput, UpdateTableRowsResult } from "@anchrd/intel-contract";
|
|
2
2
|
import type { SemanticIndex } from "../adapters/semantic-index/semantic-index.types.js";
|
|
3
3
|
export interface Actor {
|
|
4
4
|
id: string;
|
|
@@ -27,7 +27,7 @@ export interface NewTableVersion {
|
|
|
27
27
|
idempotencyKey: string;
|
|
28
28
|
auditId: string;
|
|
29
29
|
}
|
|
30
|
-
export type SnapshotOperation = "node.table_update" | "node.table_delete" | "node.table_redefine" | "node.board_configure" | "node.board_task_add" | "node.board_task_update" | "node.board_task_move" | "node.board_task_delete";
|
|
30
|
+
export type SnapshotOperation = "node.table_update" | "node.table_delete" | "node.table_redefine" | "node.board_configure" | "node.board_task_add" | "node.board_task_update" | "node.board_task_move" | "node.board_task_delete" | "node.board_task_repair";
|
|
31
31
|
/**
|
|
32
32
|
* One version that replaces the readable state, written only against the state it replaces.
|
|
33
33
|
*
|
|
@@ -145,7 +145,21 @@ export interface NodeRepository {
|
|
|
145
145
|
occurredAt: string;
|
|
146
146
|
}): Promise<boolean>;
|
|
147
147
|
searchVisible(actor: Actor, input: SearchInput): Promise<NodeCitation[]>;
|
|
148
|
-
|
|
148
|
+
/**
|
|
149
|
+
* `scopeId` cuts the candidates to one folder subtree, which is how the semantic half of a scoped
|
|
150
|
+
* search is narrowed (#126): the vector index answers over everything and this join is where the
|
|
151
|
+
* subtree — a relation in D1, not a value on a vector — is applied. The ACL still applies too.
|
|
152
|
+
*
|
|
153
|
+
* ⚠️ One entry per node, and it names WHICH chunk answered (anchrd/intel#301). The passage a
|
|
154
|
+
* searcher is shown is then that card rather than whichever one happens to be first in the board;
|
|
155
|
+
* a node whose winning chunk has no `node_vectors` row — every vector written before #301 — falls
|
|
156
|
+
* back on the first full-text passage, which is exactly what this returned before.
|
|
157
|
+
*/
|
|
158
|
+
hydrateVisibleCitations(actor: Actor, hits: Array<{
|
|
159
|
+
nodeId: string;
|
|
160
|
+
chunkKey: string;
|
|
161
|
+
}>, scopeId?: string): Promise<NodeCitation[]>;
|
|
162
|
+
invalidateVectors(): Promise<void>;
|
|
149
163
|
listCurrentVersionIds(input: {
|
|
150
164
|
after: string | null;
|
|
151
165
|
limit: number;
|
|
@@ -354,6 +368,15 @@ export interface NodeService {
|
|
|
354
368
|
updateBoardTask(actor: Actor, input: UpdateBoardTaskInput): Promise<BoardTaskResult>;
|
|
355
369
|
moveBoardTask(actor: Actor, input: MoveBoardTaskInput): Promise<BoardTaskResult>;
|
|
356
370
|
deleteBoardTask(actor: Actor, input: DeleteBoardTaskInput): Promise<DeleteBoardTaskResult>;
|
|
371
|
+
/**
|
|
372
|
+
* The sixth, and the only one that is not about somebody's project (anchrd/intel#341).
|
|
373
|
+
*
|
|
374
|
+
* ⚠️ It names no task id, because a board that needs it holds two entries under one — the pair a
|
|
375
|
+
* bundle written before anchrd/intel#321 could carry in. Everything else here is unreachable for
|
|
376
|
+
* that second entry: the five operations above address a task by id and would take, write or
|
|
377
|
+
* remove both at once.
|
|
378
|
+
*/
|
|
379
|
+
repairBoardTaskIds(actor: Actor, input: RepairBoardTaskIdsInput): Promise<RepairBoardTaskIdsResult>;
|
|
357
380
|
getAgent(actor: Actor, input: GetAgentInput): Promise<NodeAgent>;
|
|
358
381
|
/**
|
|
359
382
|
* An agent node, its first definition, and the Gate Application it runs as (#182, D29).
|
|
@@ -435,12 +458,47 @@ export interface NodeIndexTarget {
|
|
|
435
458
|
* task's own title, which is the text the FTS table weights highest.
|
|
436
459
|
*/
|
|
437
460
|
export interface IndexChunk {
|
|
461
|
+
/**
|
|
462
|
+
* What this passage is called inside its node (anchrd/intel#301).
|
|
463
|
+
*
|
|
464
|
+
* `""` for a node that is one passage — every kind but `board` — and the task's own id for a card.
|
|
465
|
+
* The full-text index does not store it; the vector index is named by it, which is how a semantic
|
|
466
|
+
* hit can say WHICH card answered instead of only which board.
|
|
467
|
+
*/
|
|
468
|
+
key: string;
|
|
438
469
|
title: string;
|
|
439
470
|
text: string;
|
|
440
471
|
}
|
|
472
|
+
/**
|
|
473
|
+
* What the vector index holds for one chunk of one node (anchrd/intel#301).
|
|
474
|
+
*
|
|
475
|
+
* `fingerprint` is over the exact text that was embedded, so the next pass can tell an untouched
|
|
476
|
+
* card from a changed one without asking Vectorize — whose writes are asynchronous and would answer
|
|
477
|
+
* about the save before last. `passage` is what a searcher is shown when this vector is the hit.
|
|
478
|
+
*/
|
|
479
|
+
export interface NodeVectorRecord {
|
|
480
|
+
chunkKey: string;
|
|
481
|
+
fingerprint: string;
|
|
482
|
+
passage: string;
|
|
483
|
+
}
|
|
441
484
|
export interface NodeIndexRepository {
|
|
442
485
|
getTarget(versionId: string): Promise<NodeIndexTarget | null>;
|
|
486
|
+
/**
|
|
487
|
+
* The node behind this version when the reason `getTarget` refused it is that the node has been
|
|
488
|
+
* ARCHIVED — and null for every other reason (anchrd/intel#348).
|
|
489
|
+
*
|
|
490
|
+
* ⚠️ The distinction is the whole method. `getTarget` answers null for two very different
|
|
491
|
+
* situations: the node is archived, or this version has been superseded by a later save. The
|
|
492
|
+
* first one means "take this node out of the vector index"; the second is a queue message that
|
|
493
|
+
* arrived late — the ordinary case, because Cloudflare Queues deliver at least once — and acting
|
|
494
|
+
* on it would delete the vectors of a node that is perfectly alive. So this asks for the archived
|
|
495
|
+
* case by name and never infers it from an absence.
|
|
496
|
+
*/
|
|
497
|
+
archivedNodeId(versionId: string): Promise<string | null>;
|
|
443
498
|
replace(target: NodeIndexTarget, chunks: IndexChunk[]): Promise<void>;
|
|
499
|
+
listVectors(nodeId: string): Promise<NodeVectorRecord[]>;
|
|
500
|
+
replaceVectors(target: NodeIndexTarget, records: NodeVectorRecord[]): Promise<void>;
|
|
501
|
+
deleteVectors(nodeId: string): Promise<void>;
|
|
444
502
|
markIndexed(versionId: string, occurredAt: string): Promise<void>;
|
|
445
503
|
markError(versionId: string, message: string, occurredAt: string): Promise<void>;
|
|
446
504
|
}
|
package/dist/tools/tools.js
CHANGED
|
@@ -260,7 +260,13 @@ export function createTools(deps) {
|
|
|
260
260
|
if (!stored)
|
|
261
261
|
return { portalConnected: false, items: [] };
|
|
262
262
|
try {
|
|
263
|
-
|
|
263
|
+
const { items, serverOfTool } = await capabilities(who);
|
|
264
|
+
// ⚠️ Named only for a delegated caller, and it costs nothing there: the attribution had to
|
|
265
|
+
// be made anyway to cut the list. For an ordinary user it would mean a second portal
|
|
266
|
+
// request per call — `serverList` is not on their path — and their screen never asks the
|
|
267
|
+
// question. The runtime's is the one that does (#289).
|
|
268
|
+
const reached = who.delegation ? [...new Set(serverOfTool.values())].sort() : undefined;
|
|
269
|
+
return { portalConnected: true, items, ...(reached ? { reached } : {}) };
|
|
264
270
|
}
|
|
265
271
|
catch (error) {
|
|
266
272
|
if (disconnected(error))
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
-- #76. `context_policy` leaves the contract, the UI and every MCP answer. The COLUMN stays.
|
|
2
2
|
--
|
|
3
|
+
-- ⚠️ HISTORY, and no longer an explanation of what is possible. The column was finally dropped by
|
|
4
|
+
-- `0018_no_context_policy_at_last.sql` (#86), through an ordinary migration file — the very thing
|
|
5
|
+
-- the conclusion at the bottom of this file says cannot be done. What changed is not D1 but the
|
|
6
|
+
-- recipe: `0005`, `0013` and `0016` worked out that the new table has to be created under the FINAL
|
|
7
|
+
-- name instead of renamed into place, and that `node_links` has to be carried out of the way
|
|
8
|
+
-- because it is the one child declared ON DELETE CASCADE. Read on for the three failures that
|
|
9
|
+
-- produced the lessons; read `0018` for the shape that works.
|
|
10
|
+
--
|
|
3
11
|
-- ⚠️ That is not the intent, it is what D1 permits. SQLite cannot drop a column a CHECK names, and
|
|
4
12
|
-- this one names itself. The way around it is a table rebuild, and `knowledge_nodes` carries six
|
|
5
13
|
-- foreign keys, one of them from itself.
|
|
@@ -22,6 +30,13 @@
|
|
|
22
30
|
-- A table rebuild with foreign keys is therefore not possible inside a migration file. It needs a
|
|
23
31
|
-- session that drives the transaction itself.
|
|
24
32
|
--
|
|
33
|
+
-- ⚠️ THAT CONCLUSION WAS WRONG, and the way it was wrong is worth more than the conclusion. The
|
|
34
|
+
-- three lessons above are all correct; what they did not contain was the fourth — do not RENAME at
|
|
35
|
+
-- all. Create the new table under the final name and insert the rows back under the name the
|
|
36
|
+
-- children have referenced the whole time, and there is nothing left for a deferred check to
|
|
37
|
+
-- complain about at COMMIT. `0005` found it, `0013` and `0016` repeated it, and `0018` used it to
|
|
38
|
+
-- drop this column at last (#86).
|
|
39
|
+
--
|
|
25
40
|
-- What holds instead: the column sits in D1, nothing reads it, and `db.ts` writes a fixed value on
|
|
26
41
|
-- insert because it is NOT NULL without a DEFAULT. The contract does not know it — for every
|
|
27
42
|
-- consumer it is gone. What remains is one dead column, and that is the price of Intel running.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- anchrd/intel#301: one vector per board card, and the record of which vectors a node has.
|
|
2
|
+
--
|
|
3
|
+
-- Vectorize is keyed by node id (`adapters/semantic-index`), so a board was ONE vector holding the
|
|
4
|
+
-- whole document while the full-text half had held one row per card since #285. "Where do I stand
|
|
5
|
+
-- with X" is the question a board exists to answer and it is an imprecise one: lexically it reaches
|
|
6
|
+
-- a card only where the searched word is written on it verbatim. A vector id therefore becomes
|
|
7
|
+
-- `<node id>#<task id>` for a board card and stays the bare node id for every other kind — no
|
|
8
|
+
-- existing vector changes its name, so no installation re-embeds its whole tree to get this.
|
|
9
|
+
--
|
|
10
|
+
-- This table is the D1 side of that index and nothing more: which vectors a node has, what text
|
|
11
|
+
-- each one was made from, and the passage a searcher is shown when that vector is the hit. It is
|
|
12
|
+
-- DERIVED like `node_fts` beside it, and `reindex` empties it so that a rebuild really rebuilds.
|
|
13
|
+
--
|
|
14
|
+
-- ⚠️ `fingerprint` is what keeps a board of three hundred cards from costing three hundred
|
|
15
|
+
-- embeddings per save. The indexing pass compares it against the text it is about to embed and
|
|
16
|
+
-- upserts only what changed; a row is written ONLY AFTER the upsert it describes succeeded. That is
|
|
17
|
+
-- why a fingerprint is stored rather than a timestamp: a pass that dies between the two leaves a
|
|
18
|
+
-- row missing, never a row claiming a vector that was never written, so the next pass repairs
|
|
19
|
+
-- itself instead of trusting a lie.
|
|
20
|
+
--
|
|
21
|
+
-- ⚠️ `chunk_key` is `''` for a node that has exactly one vector — every kind but `board`. NULL
|
|
22
|
+
-- would be the honest spelling and is the wrong one: NULLs are distinct from one another inside a
|
|
23
|
+
-- SQLite primary key, so two rows for the same node could both exist and neither would be found by
|
|
24
|
+
-- the other's write.
|
|
25
|
+
--
|
|
26
|
+
-- ⚠️ No foreign key on `node_id`, deliberately, and for the same reason `node_fts` has none: a
|
|
27
|
+
-- derived index must not be able to make a write to `nodes` fail, and every rebuild of `nodes`
|
|
28
|
+
-- (0005, 0013, 0016, 0018) has to carry each declared child through the detour those files describe.
|
|
29
|
+
-- A row that outlives its node is invisible anyway — hydration joins `nodes` and drops what is
|
|
30
|
+
-- archived or gone, exactly as it does for `node_fts`.
|
|
31
|
+
CREATE TABLE node_vectors (
|
|
32
|
+
node_id TEXT NOT NULL,
|
|
33
|
+
chunk_key TEXT NOT NULL,
|
|
34
|
+
version_id TEXT NOT NULL,
|
|
35
|
+
fingerprint TEXT NOT NULL,
|
|
36
|
+
passage TEXT NOT NULL,
|
|
37
|
+
PRIMARY KEY (node_id, chunk_key)
|
|
38
|
+
);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
-- #86: `context_policy` leaves `nodes` for good.
|
|
2
|
+
--
|
|
3
|
+
-- #76 removed it from the contract, the UI and every MCP answer — for every consumer it has been
|
|
4
|
+
-- gone since. The COLUMN stayed because SQLite cannot drop one a CHECK names, and `db.ts` has been
|
|
5
|
+
-- writing the fixed value `'relevant'` into it ever since so that inserting a node works at all.
|
|
6
|
+
-- A dead column plus a line of code serving it: harmless, and ballast the next reader has to be
|
|
7
|
+
-- told about.
|
|
8
|
+
--
|
|
9
|
+
-- ⚠️ THE reason this ticket sat open for weeks is that three earlier attempts failed against
|
|
10
|
+
-- `--remote` while passing locally, and the file that recorded them (`0009_no_context_policy.sql`)
|
|
11
|
+
-- concluded that a table rebuild with foreign keys needs "a session that runs the transaction
|
|
12
|
+
-- itself, not a migration file". That conclusion is out of date, and the proof is in this
|
|
13
|
+
-- directory: `0005`, `0013` and `0016` each rebuilt this very table through an ordinary migration
|
|
14
|
+
-- file. `0016` did it against the live database on 2026-08-08 with 120 nodes, and every referencing
|
|
15
|
+
-- table came out with the same count it went in with.
|
|
16
|
+
--
|
|
17
|
+
-- ⚠️ One thing about that run is worth knowing before it is quoted as a precedent: the installation
|
|
18
|
+
-- held ZERO `node_links` rows, so the rescue below would have had nothing to rescue and the run
|
|
19
|
+
-- would have passed without exercising it at all. The one row it did carry was created for the
|
|
20
|
+
-- purpose, minutes before, by linking two throwaway documents. The proof was arranged, not found —
|
|
21
|
+
-- and the next person rebuilding this table has to arrange it again, because the installation still
|
|
22
|
+
-- has almost no links.
|
|
23
|
+
--
|
|
24
|
+
-- What the three of them worked out, and what this file copies rather than rediscovers:
|
|
25
|
+
--
|
|
26
|
+
-- 1. `PRAGMA foreign_keys = OFF` is IGNORED by D1 over the HTTP API. Locally miniflare obeys it,
|
|
27
|
+
-- so the integration test was green while the real database answered `FOREIGN KEY constraint
|
|
28
|
+
-- failed`. That is why the proof of a migration here is a run against `--remote`.
|
|
29
|
+
-- 2. The new table is created under the FINAL name rather than built beside the old one and
|
|
30
|
+
-- renamed over it. `ALTER TABLE ... RENAME` makes references FOLLOW the rename, so renaming
|
|
31
|
+
-- the old table out of the way quietly re-points every other table at a table about to be
|
|
32
|
+
-- dropped. Inserting the rows again under the name the children have referenced all along is
|
|
33
|
+
-- what settles them.
|
|
34
|
+
-- 3. `DROP TABLE` on a parent runs an implicit `DELETE FROM` first, and `node_links` is the one
|
|
35
|
+
-- child declared ON DELETE CASCADE — so that delete does not merely flag its rows, it REMOVES
|
|
36
|
+
-- them. They are carried out of the way and put back. That is a rescue, not a decision about
|
|
37
|
+
-- the data.
|
|
38
|
+
--
|
|
39
|
+
-- ⚠️ On an empty database neither detour is visible, because nothing points at anything. That is
|
|
40
|
+
-- how `0005`'s first version passed a green suite and then failed against the first database with
|
|
41
|
+
-- content in it, and why the proof for this file is a row count of every referencing table before
|
|
42
|
+
-- and after rather than a migration that merely ran.
|
|
43
|
+
PRAGMA defer_foreign_keys = TRUE;
|
|
44
|
+
|
|
45
|
+
-- Plain holding tables on purpose: no keys, no CHECKs, no foreign keys, and the column set taken
|
|
46
|
+
-- from whatever the live table has. Anything enforced here would only be enforced a second time on
|
|
47
|
+
-- the way back in, and a holding table that can reject a row is a holding table that can lose one.
|
|
48
|
+
CREATE TABLE nodes_carry AS SELECT * FROM nodes;
|
|
49
|
+
CREATE TABLE node_links_carry AS SELECT * FROM node_links;
|
|
50
|
+
|
|
51
|
+
DROP TABLE nodes;
|
|
52
|
+
|
|
53
|
+
-- The same table as `0016` left it, minus one column. Nothing else about it changes: same keys,
|
|
54
|
+
-- same CHECKs, same six kinds — a rebuild is the only way to drop the column, not an invitation to
|
|
55
|
+
-- change anything else while the table is open.
|
|
56
|
+
CREATE TABLE nodes (
|
|
57
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
58
|
+
parent_id TEXT REFERENCES nodes(id),
|
|
59
|
+
kind TEXT NOT NULL CHECK (kind IN ('folder', 'document', 'attachment', 'table', 'agent', 'board')),
|
|
60
|
+
title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 240),
|
|
61
|
+
description TEXT CHECK (description IS NULL OR length(description) <= 2000),
|
|
62
|
+
owner_id TEXT NOT NULL,
|
|
63
|
+
current_version_id TEXT,
|
|
64
|
+
created_at TEXT NOT NULL,
|
|
65
|
+
updated_at TEXT NOT NULL,
|
|
66
|
+
archived_at TEXT
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
-- Columns named on both sides rather than `SELECT *`: the holding table still HAS `context_policy`,
|
|
70
|
+
-- and a positional insert would either fail or, worse, shift every value one place to the left.
|
|
71
|
+
INSERT INTO nodes (
|
|
72
|
+
id, parent_id, kind, title, description, owner_id,
|
|
73
|
+
current_version_id, created_at, updated_at, archived_at
|
|
74
|
+
)
|
|
75
|
+
SELECT
|
|
76
|
+
id, parent_id, kind, title, description, owner_id,
|
|
77
|
+
current_version_id, created_at, updated_at, archived_at
|
|
78
|
+
FROM nodes_carry;
|
|
79
|
+
|
|
80
|
+
-- `OR IGNORE` because whether the cascade above actually fired is SQLite's business, not this
|
|
81
|
+
-- migration's: if it did, this puts the rows back; if it did not, each one is already present under
|
|
82
|
+
-- the same primary key and this is a no-op. Either way `node_links` ends up holding exactly what it
|
|
83
|
+
-- held before, which is the only outcome this statement is permitted to have.
|
|
84
|
+
INSERT OR IGNORE INTO node_links SELECT * FROM node_links_carry;
|
|
85
|
+
|
|
86
|
+
DROP TABLE nodes_carry;
|
|
87
|
+
DROP TABLE node_links_carry;
|
|
88
|
+
|
|
89
|
+
CREATE INDEX nodes_parent_idx ON nodes(parent_id, archived_at, title);
|
|
90
|
+
CREATE INDEX nodes_owner_idx ON nodes(owner_id, archived_at);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anchrd/gate-sdk": "^0.7.0",
|
|
46
|
-
"@anchrd/intel-contract": "^0.
|
|
46
|
+
"@anchrd/intel-contract": "^0.12.0",
|
|
47
47
|
"@cfworker/json-schema": "^4.1.1",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
49
49
|
"fflate": "^0.8.3",
|