@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/adapters/cloudflare/cloudflare.js +16 -0
- package/dist/adapters/db/db-audit.d.ts +28 -0
- package/dist/adapters/db/db-audit.js +77 -16
- package/dist/adapters/db/db-feed.js +65 -17
- package/dist/adapters/db/db-flows.d.ts +36 -0
- package/dist/adapters/db/db-flows.js +119 -23
- package/dist/adapters/db/db-grants.d.ts +14 -1
- package/dist/adapters/db/db-grants.js +16 -3
- package/dist/adapters/db/db-prompts.d.ts +46 -0
- package/dist/adapters/db/db-prompts.js +144 -0
- package/dist/adapters/db/db.js +58 -6
- package/dist/audit/audit.js +27 -12
- package/dist/audit/audit.types.d.ts +3 -2
- package/dist/bundle/bundle.js +12 -0
- package/dist/flows/flows.js +29 -2
- package/dist/flows/flows.types.d.ts +16 -2
- package/dist/http/http.js +46 -6
- package/dist/indexing/indexing.js +9 -10
- package/dist/intel/intel.js +1 -0
- package/dist/intel/intel.types.d.ts +2 -0
- package/dist/mcp/mcp.js +90 -21
- package/dist/mcp/mcp.types.d.ts +2 -0
- package/dist/nodes/nodes.js +38 -0
- package/dist/nodes/nodes.types.d.ts +8 -1
- package/dist/prompts/prompts.d.ts +2 -0
- package/dist/prompts/prompts.js +65 -0
- package/dist/prompts/prompts.types.d.ts +71 -0
- package/dist/prompts/prompts.types.js +1 -0
- package/dist/shared/document-text/document-text.d.ts +21 -0
- package/dist/shared/document-text/document-text.js +31 -0
- package/migrations/0027_the_runs_of_every_flow.sql +27 -0
- package/migrations/0028_a_prompt_name_over_two_kinds.sql +70 -0
- package/package.json +2 -2
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:
|
|
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);
|
package/dist/mcp/mcp.types.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/nodes/nodes.js
CHANGED
|
@@ -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,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.
|
|
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.
|
|
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",
|