@anchrd/intel-api 0.39.0 → 0.41.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/mcp/mcp.js CHANGED
@@ -10,6 +10,7 @@ import { AppendTableRowsInput, DefineTableInput, DeleteTableRowsInput, GetTableI
10
10
  import { ExecuteToolInput, TestToolInput } from "@anchrd/intel-contract/tool";
11
11
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
12
12
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
13
+ import { GetPromptRequestSchema, ListPromptsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
13
14
  import { z } from "zod";
14
15
  import { permits } from "../shared/gate-authorization/gate-authorization.js";
15
16
  import { IntelError } from "../shared/intel-error/intel-error.js";
@@ -158,15 +159,46 @@ export async function handleMcp(request, deps) {
158
159
  email: deps.authorization.identity.email,
159
160
  name: deps.authorization.identity.name ?? null,
160
161
  }));
162
+ // The change journal, read forward from a stable position (#620). It is what makes Intel the
163
+ // simplest of all signal sources: a consumer keeps its own cursor and asks what happened since,
164
+ // and Intel never learns that the consumer exists. That is the D24 test passed rather than
165
+ // argued — remove every reader and nothing piles up here.
166
+ //
167
+ // ⚠️ **The capability follows the KIND asked for** (#774), which is why this tool stands outside
168
+ // both capability blocks rather than inside one. An audit row says the same thing its resource
169
+ // says, so the journal asks what reading that resource asks — `nodes/read` for `node`,
170
+ // `flows/read` for `flow` and `flow-run`. A capability of its own would be a second answer to one
171
+ // question and the two would drift; `nodes/read` for all three would drop the first of the three
172
+ // checks for exactly the two kinds #774 opened.
173
+ //
174
+ // ⚠️ **Offered on EITHER capability, decided per call.** Registration is per connection and the
175
+ // kind arrives per call, so the two cannot be the same gate. Holding one of the two is what makes
176
+ // the tool worth listing at all; which of the three journals it then answers is the handler's
177
+ // question. Same shape, same order, same refusal as `GET /audit` — one business layer, three thin
178
+ // surfaces.
179
+ if (permits(deps.authorization, "nodes", "read") ||
180
+ permits(deps.authorization, "flows", "read")) {
181
+ server.registerTool("audit_list", {
182
+ title: "List change events",
183
+ description: "Read the change journal forward from a stable position: what happened to the resources you may read, oldest first. `resourceType` picks which of the three journals to read — `node`, `flow` or `flow-run` — and each has its own cursor; do not carry one over to another. Pass `nextCursor` back as `after` to continue where you stopped — it says WHERE YOU GOT TO and comes back on the last page as well. Whether another page may follow is `hasMore`; a null `nextCursor` means nothing was returned, not that you have caught up. Events about permanently deleted nodes and flows are never returned, to anybody.",
184
+ inputSchema: AuditListRequest,
185
+ annotations: {
186
+ title: "List change events",
187
+ readOnlyHint: true,
188
+ destructiveHint: false,
189
+ idempotentHint: true,
190
+ openWorldHint: false,
191
+ },
192
+ }, async (input) => {
193
+ // ⚠️ Before the service, so a refusal costs no journal read. A 403 that arrived after the
194
+ // rows were read is a 403 that already read them.
195
+ if (!permits(deps.authorization, input.resourceType === "node" ? "nodes" : "flows", "read")) {
196
+ throw new IntelError(403, "permission_required", "Permission required");
197
+ }
198
+ return text(await deps.audit.list(actor, input));
199
+ });
200
+ }
161
201
  if (permits(deps.authorization, "nodes", "read")) {
162
- // The change journal, read forward from a stable position (#620). It is what makes Intel the
163
- // simplest of all signal sources: a consumer keeps its own cursor and asks what happened since,
164
- // and Intel never learns that the consumer exists. That is the D24 test passed rather than
165
- // argued — remove every reader and nothing piles up here.
166
- //
167
- // ⚠️ `nodes/read`, not a capability of its own. An audit row about a node says the same thing
168
- // the node says; a second capability would be a second answer to one question, and the two
169
- // would drift.
170
202
  // The board as one answer (#648). Four tools, not five: a move is an update of `status` and
171
203
  // `position`, and deleting a task is `node_archive` — nothing here removes a `nodes` row.
172
204
  server.registerTool("board_get", {
@@ -252,18 +284,6 @@ export async function handleMcp(request, deps) {
252
284
  openWorldHint: false,
253
285
  },
254
286
  }, async (input) => text(await deps.boards.update(actor, input)));
255
- server.registerTool("audit_list", {
256
- title: "List change events",
257
- description: "Read the change journal forward from a stable position: what happened to the nodes you may read, oldest first. Pass `nextCursor` back as `after` to continue where you stopped; a null `nextCursor` means you have caught up. Only node events can be listed — flow events need their own visibility check and are refused rather than left out. Events about permanently deleted nodes are never returned, to anybody.",
258
- inputSchema: AuditListRequest,
259
- annotations: {
260
- title: "List change events",
261
- readOnlyHint: true,
262
- destructiveHint: false,
263
- idempotentHint: true,
264
- openWorldHint: false,
265
- },
266
- }, async (input) => text(await deps.audit.list(actor, input)));
267
287
  server.registerTool("feed_list", {
268
288
  title: "List recent activity",
269
289
  description: "Read the change journal backwards: what most recently happened to the nodes you may read, newest first, one entry per event. Each entry carries the node's current title and the folders above it, and only the folders you may open. Pass `nextCursor` back as `before` to keep going further into the past; a null `nextCursor` means you have reached the beginning. Pass `actor` to see one person's or one agent's work alone. This is the human-facing view — for catching up on everything in order, use audit_list, and do not mix the two cursors.",
@@ -929,7 +949,7 @@ export async function handleMcp(request, deps) {
929
949
  // whoever started it, and only their own runs are the calling user's to read out that way.
930
950
  server.registerTool("flow_run_list", {
931
951
  title: "List flow runs",
932
- description: "List the runs of one flow, newest first, with status, start, duration, what triggered them, and for a failed run the step that ended it. Optionally only the failed ones. Runs the calling user may not see are absent, and a failure inside a called flow they may not see is named by the calling step alone.",
952
+ description: 'List flow runs, newest first, with status, start, duration, what triggered them, and for a failed run the step that ended it. Name a `flowId` for one flow, or leave it out for the runs of every flow you may read — which is the way to answer "did anything run at all" without knowing a flow first. Optionally only the failed ones. Runs the calling user may not see are absent, and a failure inside a called flow they may not see is named by the calling step alone.',
933
953
  inputSchema: ListFlowRunsInput,
934
954
  annotations: {
935
955
  title: "List flow runs",
@@ -1217,6 +1237,55 @@ export async function handleMcp(request, deps) {
1217
1237
  },
1218
1238
  }, async (input) => text(await deps.tools.execute(toolActor, input)));
1219
1239
  }
1240
+ /**
1241
+ * The slash-command surface (#775). A prompt is the one thing on this server the HUMAN chooses:
1242
+ * a tool is called when the model thinks of it, a prompt when somebody types `/name`.
1243
+ *
1244
+ * ⚠️ **Registered through the low-level handlers rather than `registerPrompt`, and the cost is
1245
+ * the reason.** `registerPrompt` takes one name at a time, so a catalogue that lives in D1 would
1246
+ * have to be read and registered while the server is being built — on EVERY request, including
1247
+ * every `tools/call` that will never look at a prompt. These two handlers run the queries only
1248
+ * when a client actually asks for prompts.
1249
+ *
1250
+ * ⚠️ **The capability is declared here because nothing else declares it.** Without
1251
+ * `registerCapabilities` the SDK would answer `prompts/list` while telling clients during
1252
+ * initialization that this server has no prompts, and a client that believes the handshake never
1253
+ * asks. `listChanged: false` is the truthful value: the catalogue changes when somebody edits a
1254
+ * document, and this server sends no notification about it.
1255
+ *
1256
+ * ⚠️ **Offered on EITHER capability, decided per call** — the shape `audit_list` uses one screen
1257
+ * up, for the same reason. Holding one of the two is what makes the surface worth offering at
1258
+ * all; which half of the catalogue it then answers is `listOffered`'s question, and it asks it
1259
+ * per row against the resource ACL underneath.
1260
+ */
1261
+ if (permits(deps.authorization, "nodes", "read") ||
1262
+ permits(deps.authorization, "flows", "read")) {
1263
+ const promptActor = {
1264
+ ...actor,
1265
+ canReadNodes: permits(deps.authorization, "nodes", "read"),
1266
+ canReadFlows: permits(deps.authorization, "flows", "read"),
1267
+ };
1268
+ server.server.registerCapabilities({ prompts: { listChanged: false } });
1269
+ server.server.setRequestHandler(ListPromptsRequestSchema, async () => ({
1270
+ prompts: (await deps.prompts.list(promptActor)).map((entry) => ({
1271
+ name: entry.name,
1272
+ title: entry.title,
1273
+ // ⚠️ No `arguments`, and that is a decision rather than a gap (#775). A prompt without
1274
+ // arguments is completely usable — it is an instruction somebody wrote down — and the
1275
+ // obvious source of an argument schema, a flow's requirements, is a round of its own.
1276
+ // Declaring an empty `arguments` array would tell a client the question was asked and
1277
+ // answered "none", which is a different claim.
1278
+ ...(entry.description === null ? {} : { description: entry.description }),
1279
+ })),
1280
+ }));
1281
+ server.server.setRequestHandler(GetPromptRequestSchema, async (promptRequest) => {
1282
+ const { entry, text } = await deps.prompts.get(promptActor, promptRequest.params.name);
1283
+ return {
1284
+ ...(entry.description === null ? {} : { description: entry.description }),
1285
+ messages: [{ role: "user", content: { type: "text", text } }],
1286
+ };
1287
+ });
1288
+ }
1220
1289
  const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
1221
1290
  await server.connect(transport);
1222
1291
  return await transport.handleRequest(request);
@@ -5,6 +5,7 @@ import type { BundleService } from "../bundle/bundle.types.js";
5
5
  import type { FeedService } from "../feed/feed.types.js";
6
6
  import type { FlowService } from "../flows/flows.types.js";
7
7
  import type { NodeService } from "../nodes/nodes.types.js";
8
+ import type { Prompts } from "../prompts/prompts.types.js";
8
9
  import type { ToolService } from "../tools/tools.types.js";
9
10
  export interface McpDeps {
10
11
  authorization: Authorized;
@@ -20,4 +21,5 @@ export interface McpDeps {
20
21
  audit: AuditService;
21
22
  feed: FeedService;
22
23
  boards: BoardService;
24
+ prompts: Prompts;
23
25
  }
@@ -637,6 +637,11 @@ export function createNodes(deps) {
637
637
  const timestamp = deps.now().toISOString();
638
638
  const created = await deps.repository.insertNode({
639
639
  node: {
640
+ // ⚠️ A node is never born offered (#775). Naming a slash command is a curation decision
641
+ // about a document that already exists, and `node_create` takes no name: a creation that
642
+ // could also take the last free name in the catalogue would fail for a reason that has
643
+ // nothing to do with creating anything.
644
+ promptName: null,
640
645
  id: deps.id(),
641
646
  parentId: input.parentId,
642
647
  kind: input.kind,
@@ -997,6 +1002,22 @@ export function createNodes(deps) {
997
1002
  if (!(await deps.repository.can(actor, current.id, "write"))) {
998
1003
  throw new IntelError(403, "node_forbidden", "This node cannot be edited");
999
1004
  }
1005
+ /**
1006
+ * ⚠️ **Only a document can be offered as a slash command, and the refusal names the kind**
1007
+ * (#775). A prompt is an instruction; a folder, a table, an attachment, a board or a task is
1008
+ * material, and material is read through `node_get` rather than typed as a command.
1009
+ *
1010
+ * ⚠️ It stands BEFORE the idempotency replay on purpose: a call that is wrong about the kind
1011
+ * is wrong on every attempt, and answering the second one out of the replay table would tell
1012
+ * a caller their refused write had succeeded.
1013
+ *
1014
+ * ⚠️ Clearing it — `null` on a kind that could never have had one — is deliberately allowed.
1015
+ * It changes nothing, and refusing a no-op would make "take the command off everything in
1016
+ * this folder" a call that fails on the folder itself.
1017
+ */
1018
+ if (input.promptName != null && current.kind !== "document") {
1019
+ throw new IntelError(409, "prompt_not_a_document", `Only a document can be offered as a slash command, and this is a ${current.kind}.`);
1020
+ }
1000
1021
  const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.update", input.idempotencyKey);
1001
1022
  if (replayedId)
1002
1023
  return await requireVisible(actor, replayedId);
@@ -1045,6 +1066,7 @@ export function createNodes(deps) {
1045
1066
  parentId: input.parentId === undefined ? current.parentId : input.parentId,
1046
1067
  title: input.title === undefined ? current.title : plainTitle(input.title),
1047
1068
  description: input.description === undefined ? current.description : input.description,
1069
+ promptName: input.promptName === undefined ? current.promptName : input.promptName,
1048
1070
  updatedAt,
1049
1071
  },
1050
1072
  baseUpdatedAt: input.baseUpdatedAt,
@@ -1055,6 +1077,22 @@ export function createNodes(deps) {
1055
1077
  if (updated === "cycle") {
1056
1078
  throw new IntelError(409, "move_cycle", "A node cannot be moved into its descendant");
1057
1079
  }
1080
+ if (updated === "prompt_name_taken") {
1081
+ /**
1082
+ * ⚠️ **The sentence names the holder only when this actor may see it.** That a name is
1083
+ * taken has to be said — otherwise the caller retries forever against a wall they cannot
1084
+ * see — but WHICH document holds it is a fact about the tree, and ADR-0004 §3 does not let
1085
+ * a refusal become a way of reading it. `promptNameHolder` answers `null` for the title in
1086
+ * that case, and the second sentence below is what the caller gets instead.
1087
+ */
1088
+ const holder = await deps.repository.promptNameHolder(actor, input.promptName);
1089
+ const held = holder === null
1090
+ ? "It is already taken."
1091
+ : holder.title === null
1092
+ ? `A ${holder.kind} you cannot see already uses it.`
1093
+ : `The ${holder.kind} "${holder.title}" already uses it.`;
1094
+ throw new IntelError(409, "prompt_name_taken", `The slash command "${input.promptName}" is not free. ${held} Pick another name — names are never changed for you, because the catalogue would then mean a different thing to every reader.`);
1095
+ }
1058
1096
  if (updated === "conflict") {
1059
1097
  throw new IntelError(409, "update_conflict", "This node was changed by another editor");
1060
1098
  }
@@ -3,6 +3,7 @@ import type { ArchiveNodeInput, CreateNodeInput, ListNodesInput, Node, NodeAttac
3
3
  import type { ResourceGrant, ResourceGrantList, ResourceVerb, RevokeGrantInput, ShareInput, ShareResult } from "@anchrd/intel-contract/share";
4
4
  import type { AppendTableRowsInput, AppendTableRowsResult, DefineTableInput, DeleteTableRowsInput, DeleteTableRowsResult, RedefineTableInput, UpdateTableRowsInput, UpdateTableRowsResult } from "@anchrd/intel-contract/table";
5
5
  import type { SemanticIndex } from "../adapters/semantic-index/semantic-index.types.js";
6
+ import type { PromptNameHolder } from "../prompts/prompts.types.js";
6
7
  export interface Actor {
7
8
  id: string;
8
9
  email: string;
@@ -78,7 +79,13 @@ export interface NodeRepository {
78
79
  actorId: string;
79
80
  idempotencyKey: string;
80
81
  auditId: string;
81
- }): Promise<"cycle" | "conflict" | Node>;
82
+ }): Promise<"cycle" | "conflict" | "prompt_name_taken" | Node>;
83
+ /**
84
+ * Who holds a slash command name, for the sentence a refused rename gets (#775). `title` is
85
+ * `null` when the holder exists but this actor may not see it — the name is taken either way, and
86
+ * which document it is stays behind the same wall every other read stands behind.
87
+ */
88
+ promptNameHolder(actor: Actor, name: string): Promise<PromptNameHolder | null>;
82
89
  archiveNode(input: {
83
90
  nodeId: string;
84
91
  baseUpdatedAt: string;
@@ -0,0 +1,2 @@
1
+ import type { Prompts, PromptsDeps } from "./prompts.types.js";
2
+ export declare function createPrompts(deps: PromptsDeps): Prompts;
@@ -0,0 +1,65 @@
1
+ import { documentText } from "../shared/document-text/document-text.js";
2
+ import { IntelError } from "../shared/intel-error/intel-error.js";
3
+ /**
4
+ * What a client is handed when it opens the command of a FLOW.
5
+ *
6
+ * ⚠️ **This text starts nothing, and that is the point** (D24: the agent acts, Intel does not).
7
+ * `prompts/get` is a read — a model asked for the wording of a command, not for the command to
8
+ * happen. Returning instructions rather than performing them keeps the one property that makes
9
+ * Intel safe to point a model at: nothing in this package moves because something was READ.
10
+ *
11
+ * ⚠️ It names the `flowId` rather than the prompt name, because the name is not what the next call
12
+ * takes. A model handed "run the flow `crm-outreach`" has to translate that back into an id, and
13
+ * the translation is exactly the step it gets wrong — `flow_run_start` takes an id and refuses a
14
+ * name with a message about the id being unknown.
15
+ */
16
+ function flowInstruction(entry) {
17
+ const description = entry.description === null ? "" : `\n\n${entry.description}`;
18
+ return `Start the Intel flow "${entry.title}" by calling \`flow_run_start\` with \`flowId: "${entry.targetId}"\`. Do not describe the flow instead of starting it, and do not invent a different id: this one is the flow the command \`/${entry.name}\` stands for.${description}`;
19
+ }
20
+ export function createPrompts(deps) {
21
+ return {
22
+ async list(actor) {
23
+ return await deps.repository.listOffered(actor);
24
+ },
25
+ async get(actor, name) {
26
+ const entry = await deps.repository.findOffered(actor, name);
27
+ /**
28
+ * ⚠️ **One refusal for "no such command" and for "not yours to see", and it says the first.**
29
+ * `findOffered` searches the catalogue as this actor sees it, so a document offered under
30
+ * this name inside a folder they may not read is simply absent from it. Two different
31
+ * messages here would make the refusal a way of asking whether a command exists — the same
32
+ * reading of the tree ADR-0004 §3 refuses everywhere else.
33
+ */
34
+ if (entry === null) {
35
+ throw new IntelError(404, "prompt_not_found", "No command by that name is offered to you. `prompts/list` names the ones that are.");
36
+ }
37
+ if (entry.kind === "flow")
38
+ return { entry, text: flowInstruction(entry) };
39
+ const document = await deps.readDocument(actor, entry.targetId);
40
+ /**
41
+ * ⚠️ **A document with no version yet is an EMPTY command, not a failure.** Somebody offered a
42
+ * document they have not written in; the honest answer is the empty instruction they have.
43
+ */
44
+ if (document.version === null || document.content === null)
45
+ return { entry, text: "" };
46
+ /**
47
+ * ⚠️ **What a document node stores is not its text.** The editor writes a BlockNote payload,
48
+ * and handing that on gives a model a JSON blob where its instruction should be — for
49
+ * practically every document written in the app. `documentText` is the same extraction the
50
+ * search index runs, out of the same function, so the words a model is given and the words
51
+ * somebody can search for cannot drift apart.
52
+ */
53
+ const text = documentText(document.version.mediaType, document.content);
54
+ /**
55
+ * ⚠️ And an unreadable payload is REFUSED rather than passed on raw. Half a JSON document as
56
+ * an instruction is the failure this whole finding is about, one step further along: the
57
+ * model would act on it rather than report it.
58
+ */
59
+ if (text === null) {
60
+ throw new IntelError(422, "prompt_unreadable", `The document behind /${entry.name} is not stored in a form this command can be read from. Open it once and save it, or offer a document written in the editor.`);
61
+ }
62
+ return { entry, text };
63
+ },
64
+ };
65
+ }
@@ -0,0 +1,71 @@
1
+ import type { NodeDocument } from "@anchrd/intel-contract/node";
2
+ import type { Actor } from "../nodes/nodes.types.js";
3
+ /**
4
+ * One entry of the slash-command catalogue (#775): a document offered as an instruction, or a
5
+ * published flow offered as a way to start one.
6
+ *
7
+ * ⚠️ `kind` is two values and not `NodeKind`. A flow is not a node, and the five node kinds that
8
+ * cannot be offered have no business appearing in this union — a widened type here would be a
9
+ * standing invitation to hand `prompts/list` a folder.
10
+ */
11
+ export interface PromptEntry {
12
+ name: string;
13
+ kind: "document" | "flow";
14
+ targetId: string;
15
+ title: string;
16
+ description: string | null;
17
+ }
18
+ /**
19
+ * Who holds a name, as much of it as the asking actor is entitled to learn.
20
+ *
21
+ * ⚠️ `title` is `null` when the holder exists but this actor may not see it, and that is not the
22
+ * same as "no holder". ADR-0004 §3 is explicit that a refusal must not become a way of reading the
23
+ * tree: naming the document that holds `payroll` would tell somebody without access that a document
24
+ * by that title exists. The caller is told the name is taken — which they must be, or they cannot
25
+ * proceed — and nothing else.
26
+ */
27
+ export interface PromptNameHolder {
28
+ kind: "document" | "flow";
29
+ title: string | null;
30
+ }
31
+ /**
32
+ * Who is asking for the catalogue, and which halves of it they are entitled to at all.
33
+ *
34
+ * ⚠️ **The capability follows the KIND, exactly as it does for `audit_list`** (#774). A prompt
35
+ * entry says the same thing its resource says, so listing it asks what reading that resource asks:
36
+ * `nodes/read` for a document, `flows/read` for a flow. Somebody holding only one of the two gets
37
+ * the half they hold — not an empty list, and not the other half by accident.
38
+ *
39
+ * ⚠️ These are the GATE capabilities and they are the coarse half of the answer. The resource ACL
40
+ * still runs underneath, per row, in the query itself; a caller with `nodes/read` sees the offered
41
+ * documents THEY may read and no others.
42
+ */
43
+ export interface PromptActor extends Actor {
44
+ canReadNodes: boolean;
45
+ canReadFlows: boolean;
46
+ }
47
+ export interface PromptRepository {
48
+ listOffered(actor: PromptActor): Promise<PromptEntry[]>;
49
+ findOffered(actor: PromptActor, name: string): Promise<PromptEntry | null>;
50
+ }
51
+ export interface PromptsDeps {
52
+ repository: PromptRepository;
53
+ /**
54
+ * One document, as the node service reads it: the same authorization walk, the same content
55
+ * store. `prompts/get` does not reach into R2 on its own — a second reader would be a second
56
+ * place for the ACL to be applied, and the one that gets it wrong is the one nobody looks at.
57
+ *
58
+ * ⚠️ **The whole document, not a string.** What is stored under a document node is the editor's
59
+ * payload, and turning that into readable text needs the `mediaType` beside the content. Handing
60
+ * a string in would put that decision in whatever wired this up — in the Cloudflare shell, which
61
+ * is the one place in this package that may not hold business behaviour.
62
+ */
63
+ readDocument(actor: Actor, nodeId: string): Promise<NodeDocument>;
64
+ }
65
+ export interface Prompts {
66
+ list(actor: PromptActor): Promise<PromptEntry[]>;
67
+ get(actor: PromptActor, name: string): Promise<{
68
+ entry: PromptEntry;
69
+ text: string;
70
+ }>;
71
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The READABLE text of one stored version, for the two places that need a document as prose rather
3
+ * than as the editor's own payload.
4
+ *
5
+ * ⚠️ **What a document node stores is NOT its text.** The editor writes a BlockNote payload —
6
+ * `{ blocks, markdown, … }` — and `markdown` beside the blocks is the whole of what anybody outside
7
+ * the editor can read. `packages/ui/CLAUDE.md` states it for the search: `indexing.ts` pulls that
8
+ * field and nothing else into full text and vectors, so it is what anyone searching Intel matches
9
+ * against. A caller who hands the raw content on instead is handing on a JSON blob.
10
+ *
11
+ * ⚠️ **Every other media type travels unchanged**, and that is not an oversight: a `text/markdown`
12
+ * version IS its text, and a table renders its own. Only the editor's payload has a wrapper around
13
+ * the words.
14
+ *
15
+ * ⚠️ **`null` means "this content is not readable as text", not "empty".** Immutable content never
16
+ * becomes parsable on a retry, so the two callers answer it differently and each is right for its
17
+ * own surface: the indexer refuses the version permanently rather than requeueing it forever, and
18
+ * `prompts/get` refuses the call rather than handing a model half a JSON document. Returning `""`
19
+ * here would collapse both into "there is nothing to say".
20
+ */
21
+ export declare function documentText(mediaType: string, content: string): string | null;
@@ -0,0 +1,31 @@
1
+ import { BlockNoteDocument, BlockNoteMediaType } from "@anchrd/intel-contract/node";
2
+ /**
3
+ * The READABLE text of one stored version, for the two places that need a document as prose rather
4
+ * than as the editor's own payload.
5
+ *
6
+ * ⚠️ **What a document node stores is NOT its text.** The editor writes a BlockNote payload —
7
+ * `{ blocks, markdown, … }` — and `markdown` beside the blocks is the whole of what anybody outside
8
+ * the editor can read. `packages/ui/CLAUDE.md` states it for the search: `indexing.ts` pulls that
9
+ * field and nothing else into full text and vectors, so it is what anyone searching Intel matches
10
+ * against. A caller who hands the raw content on instead is handing on a JSON blob.
11
+ *
12
+ * ⚠️ **Every other media type travels unchanged**, and that is not an oversight: a `text/markdown`
13
+ * version IS its text, and a table renders its own. Only the editor's payload has a wrapper around
14
+ * the words.
15
+ *
16
+ * ⚠️ **`null` means "this content is not readable as text", not "empty".** Immutable content never
17
+ * becomes parsable on a retry, so the two callers answer it differently and each is right for its
18
+ * own surface: the indexer refuses the version permanently rather than requeueing it forever, and
19
+ * `prompts/get` refuses the call rather than handing a model half a JSON document. Returning `""`
20
+ * here would collapse both into "there is nothing to say".
21
+ */
22
+ export function documentText(mediaType, content) {
23
+ if (mediaType !== BlockNoteMediaType)
24
+ return content;
25
+ try {
26
+ return BlockNoteDocument.parse(JSON.parse(content)).markdown;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
@@ -0,0 +1,27 @@
1
+ -- `flow_run_list` may now be asked without a flow (#774): "did anything run at all", over every
2
+ -- flow the caller may read. `flow_runs_flow_idx (flow_id, created_at DESC)` cannot serve that — it
3
+ -- leads with `flow_id`, and without an equality on that column the ordering falls off the index and
4
+ -- SQLite sorts the whole table instead.
5
+ --
6
+ -- ⚠️ Measured against the real `flowRunsPageQuery({ scoped: false })` before this file existed, on
7
+ -- the same engine and the same schema:
8
+ --
9
+ -- SCAN run
10
+ -- … | USE TEMP B-TREE FOR ORDER BY
11
+ --
12
+ -- That last line is a sorting pass over every run in the installation. It costs nothing while a
13
+ -- customer has fifty runs and everything once an agent has been running for a month.
14
+ --
15
+ -- ⚠️ Both columns, in the order the cursor compares them, for the reason `0022` and `0026` give:
16
+ -- two runs created in the same millisecond are not exotic, and without the tie-break on `id` a
17
+ -- reader continuing on `created_at` alone skips the second one with no error and no log.
18
+ --
19
+ -- ⚠️ `DESC` on both, because this page is newest-first. An ASC index can be walked backwards by
20
+ -- SQLite, but only as long as EVERY term agrees on the direction — matching the `ORDER BY` exactly
21
+ -- is the one shape that keeps that true when a third term is added later.
22
+ --
23
+ -- ⚠️ This does NOT replace `flow_runs_flow_idx`. That one still serves the scoped page — the runs
24
+ -- of ONE flow — which is the older and by far the more frequent question; dropping it would put a
25
+ -- full scan into the call that has none today. Two questions, two shapes, two indexes.
26
+ CREATE INDEX flow_runs_time_idx
27
+ ON flow_runs(created_at DESC, id DESC);
@@ -0,0 +1,70 @@
1
+ -- A document and a published flow can both be offered as a slash command (#775). What they need is
2
+ -- one short name, and the name has to be unique across BOTH of them: they land in the same
3
+ -- `prompts/list` catalogue, so a document and a flow can take each other's name.
4
+ --
5
+ -- ⚠️ **One field, not two.** `prompt_name` set means offered, `NULL` means not. A boolean beside it
6
+ -- would allow the state "on, without a name", and that is not a thing `prompts/list` could answer.
7
+ --
8
+ -- ⚠️ **No third table, and the choice is deliberate.** A `prompt_names(name PRIMARY KEY, …)`
9
+ -- catalogue would carry the cross-kind uniqueness as a real constraint, and it would cost two
10
+ -- things this schema is not willing to pay: a second truth about the name of one node — every
11
+ -- `SELECT` over `nodes` would have to remember a join, and the one that forgets it answers `NULL`
12
+ -- in silence — and a fourth child table for the `nodes` rebuild recipe to carry (see the package
13
+ -- CLAUDE.md; `node_links` is the one that already has to be carried out and back). The uniqueness
14
+ -- WITHIN one kind is an index here; the uniqueness ACROSS the two is a condition on the write, on
15
+ -- every statement that sets a name, in the same batch that writes it.
16
+ --
17
+ -- ⚠️ The condition on the write is not a nicety. A `SELECT` before an `UPDATE` in a separate call
18
+ -- decides on a state that may be gone by the time the write lands; the check has to be part of the
19
+ -- statement so that the two cannot come apart. What the caller is told — WHICH node or flow holds
20
+ -- the name — is a second, cheap read that only runs once the write has already refused.
21
+
22
+ -- ⚠️ **This form is MEASURED against real D1, not only against miniflare.** No other file in this
23
+ -- folder adds a `CHECK` through `ALTER TABLE … ADD COLUMN` that reads a DIFFERENT column, and the
24
+ -- package CLAUDE.md is explicit that a green run under the emulator proves nothing about the
25
+ -- deployment — `foreign_keys = OFF` is honoured there and ignored by D1 over its HTTP API. A
26
+ -- migration file is immutable after release, so the failure would surface at the deploy.
27
+ --
28
+ -- Run against a throwaway D1 (`intel-check-0028`, `--remote`) on 2026-08-25 and then deleted. Both
29
+ -- statements applied, `sqlite_master` shows the CHECK on the column, and all six refusals fire with
30
+ -- `SQLITE_CONSTRAINT_CHECK` / `SQLITE_CONSTRAINT_UNIQUE`: a folder and a table with a name, an
31
+ -- uppercase name, a name with a space, a 41-character name, and a second row taking a name that is
32
+ -- already held. A document with a valid name and two rows with none go through.
33
+ --
34
+ -- ⚠️ `kind = 'document'` is in the CHECK, not only in the service. A prompt is an instruction, not
35
+ -- material: a folder, a table, an attachment, a board or a task never carries one (#775). The
36
+ -- service refuses it with a sentence, and this line is what makes that refusal true even for a
37
+ -- write that never passes the service.
38
+ --
39
+ -- ⚠️ `NOT GLOB '*[^a-z0-9-]*'` is the grammar `^[a-z0-9-]{1,40}$` says on the contract, spelled the
40
+ -- one way SQLite can spell it: "no character outside the class anywhere". The obvious
41
+ -- `GLOB '[a-z0-9-]*'` is NOT that check — it constrains the first character and lets every other
42
+ -- one through, so `a_B C` passes it. The hyphen sits last inside the class, where it is a literal
43
+ -- rather than a range.
44
+ ALTER TABLE nodes ADD COLUMN prompt_name TEXT
45
+ CHECK (
46
+ prompt_name IS NULL
47
+ OR (
48
+ kind = 'document'
49
+ AND length(prompt_name) BETWEEN 1 AND 40
50
+ AND prompt_name NOT GLOB '*[^a-z0-9-]*'
51
+ )
52
+ );
53
+
54
+ ALTER TABLE flows ADD COLUMN prompt_name TEXT
55
+ CHECK (
56
+ prompt_name IS NULL
57
+ OR (length(prompt_name) BETWEEN 1 AND 40 AND prompt_name NOT GLOB '*[^a-z0-9-]*')
58
+ );
59
+
60
+ -- ⚠️ Partial, and that is the whole reason they work. A plain `UNIQUE` index would let several
61
+ -- rows carry `NULL` too — SQLite treats NULLs as distinct — so the `WHERE` clause buys no
62
+ -- correctness here; it buys the SIZE. Almost every node has no prompt name, and an index over
63
+ -- them all would be one entry per node in the tree to serve a handful of rows.
64
+ --
65
+ -- ⚠️ These two carry the uniqueness WITHIN a kind and nothing more. Two documents cannot share a
66
+ -- name and two flows cannot; a document and a flow can, as far as these indexes are concerned, and
67
+ -- the condition on the write is the only thing that stops them. Reading one of these index names in
68
+ -- a query plan is therefore not evidence that the cross-kind rule held.
69
+ CREATE UNIQUE INDEX nodes_prompt_name_idx ON nodes(prompt_name) WHERE prompt_name IS NOT NULL;
70
+ CREATE UNIQUE INDEX flows_prompt_name_idx ON flows(prompt_name) WHERE prompt_name IS NOT NULL;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.39.0",
3
+ "version": "0.41.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.26.0",
46
- "@anchrd/intel-contract": "^0.31.0",
46
+ "@anchrd/intel-contract": "^0.33.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",