@anchrd/intel-api 0.40.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-flows.js +62 -6
- 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/bundle/bundle.js +12 -0
- package/dist/flows/flows.js +22 -0
- package/dist/flows/flows.types.d.ts +15 -1
- 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 +50 -0
- 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/0028_a_prompt_name_over_two_kinds.sql +70 -0
- package/package.json +2 -2
|
@@ -10,6 +10,7 @@ import { createFlows } from "../../flows/flows.js";
|
|
|
10
10
|
import { createIndexing, PermanentIndexingError } from "../../indexing/indexing.js";
|
|
11
11
|
import { createIntel } from "../../intel/intel.js";
|
|
12
12
|
import { createNodes } from "../../nodes/nodes.js";
|
|
13
|
+
import { createPrompts } from "../../prompts/prompts.js";
|
|
13
14
|
import { bearer } from "../../shared/gate-authorization/gate-authorization.js";
|
|
14
15
|
import { IntelError } from "../../shared/intel-error/intel-error.js";
|
|
15
16
|
import { sha256Hex } from "../../shared/sha256/sha256.js";
|
|
@@ -22,6 +23,7 @@ import { createFeedRepository } from "../db/db-feed.js";
|
|
|
22
23
|
import { createFlowRepository } from "../db/db-flows.js";
|
|
23
24
|
import { createNodeIndexRepository } from "../db/db-indexing.js";
|
|
24
25
|
import { createOAuthClientStore } from "../db/db-oauth.js";
|
|
26
|
+
import { createPromptRepository } from "../db/db-prompts.js";
|
|
25
27
|
import { createDocumentConverter } from "../document-converter/document-converter.js";
|
|
26
28
|
import { createFlowRuntime } from "../flow-runtime/flow-runtime.js";
|
|
27
29
|
import { createIndexQueue, IndexMessage } from "../index-queue/index-queue.js";
|
|
@@ -265,6 +267,20 @@ export default {
|
|
|
265
267
|
tools,
|
|
266
268
|
audit: createAudit({ audit: auditRepository }),
|
|
267
269
|
feed: createFeed({ feed: feedRepository }),
|
|
270
|
+
/**
|
|
271
|
+
* The slash-command catalogue (#775). It reads its own rows through its own repository and
|
|
272
|
+
* borrows exactly one thing from the node service: reading one document, with the
|
|
273
|
+
* authorization walk that service already performs. A second reader into R2 here would be a
|
|
274
|
+
* second place for that ACL to be applied, and the one that gets it wrong is the one nobody
|
|
275
|
+
* looks at.
|
|
276
|
+
*/
|
|
277
|
+
prompts: createPrompts({
|
|
278
|
+
repository: createPromptRepository({ db: env.DB, now }),
|
|
279
|
+
// ⚠️ Handed on whole, and nothing is decided here. Turning a stored version into readable
|
|
280
|
+
// text is business behaviour, and this shell maps bindings — it does not get to know what a
|
|
281
|
+
// BlockNote payload is.
|
|
282
|
+
readDocument: async (actor, nodeId) => await nodes.get(actor, nodeId),
|
|
283
|
+
}),
|
|
268
284
|
boards: createBoards({
|
|
269
285
|
boards: boardRepository,
|
|
270
286
|
nodes,
|
|
@@ -2,6 +2,7 @@ import { Flow, FlowGraph, FlowVersion, } from "@anchrd/intel-contract/flow";
|
|
|
2
2
|
import { FlowRun } from "@anchrd/intel-contract/flow-run";
|
|
3
3
|
import { calleeIds, treeLinkKinds } from "../../flows/flows.js";
|
|
4
4
|
import { flowCallable, flowCallableBindings, flowInSubtree, flowInSubtreeBindings, flowVerbBindings, flowVerbQuery, grantColumns, grantInForce, mapGrant, principalColumns, readableOrRunnableCte, subtreeBindings, subtreeCte, } from "./db-grants.js";
|
|
5
|
+
import { promptNameHolder } from "./db-prompts.js";
|
|
5
6
|
const flowColumnNames = [
|
|
6
7
|
"id",
|
|
7
8
|
"parent_id",
|
|
@@ -13,6 +14,7 @@ const flowColumnNames = [
|
|
|
13
14
|
"created_at",
|
|
14
15
|
"updated_at",
|
|
15
16
|
"archived_at",
|
|
17
|
+
"prompt_name",
|
|
16
18
|
];
|
|
17
19
|
const flowColumns = flowColumnNames.join(", ");
|
|
18
20
|
const runColumnNames = [
|
|
@@ -53,6 +55,7 @@ function mapFlow(row) {
|
|
|
53
55
|
createdAt: row.created_at,
|
|
54
56
|
updatedAt: row.updated_at,
|
|
55
57
|
archivedAt: row.archived_at,
|
|
58
|
+
promptName: row.prompt_name,
|
|
56
59
|
});
|
|
57
60
|
}
|
|
58
61
|
function mapVersion(row) {
|
|
@@ -904,23 +907,61 @@ export function createFlowRepository(deps) {
|
|
|
904
907
|
.first();
|
|
905
908
|
return stored ? "saved" : "conflict";
|
|
906
909
|
},
|
|
910
|
+
async promptNameHolder(actor, name) {
|
|
911
|
+
// One question, one implementation, shared with the node repository — see `db-prompts.ts`.
|
|
912
|
+
return await promptNameHolder(deps, actor, name);
|
|
913
|
+
},
|
|
914
|
+
/**
|
|
915
|
+
* ⚠️ **Two statement forms, not one with a conditional value** (#775). Publishing without
|
|
916
|
+
* mentioning a prompt name must leave the name alone; `SET prompt_name = ?` with the current
|
|
917
|
+
* value read back beforehand would work until two publishes race, and then the second one
|
|
918
|
+
* writes a name the first had just replaced. The form that does not mention the column cannot
|
|
919
|
+
* do that, and the form that does carries its guard.
|
|
920
|
+
*
|
|
921
|
+
* ⚠️ **The guard is on all three statements** (`destructive.md`, `anchrd/intel#457`). A
|
|
922
|
+
* statement matching zero rows is not an error, so an idempotency row and an audit row written
|
|
923
|
+
* without the same condition would book a publication that never happened — and the caller's
|
|
924
|
+
* retry would then be answered out of the replay table.
|
|
925
|
+
*/
|
|
907
926
|
async publish(input) {
|
|
927
|
+
const setsName = input.promptName !== undefined;
|
|
928
|
+
const nameAssignment = setsName ? ", prompt_name = ?" : "";
|
|
929
|
+
const nameBinding = setsName ? [input.promptName] : [];
|
|
930
|
+
// ⚠️ `other.id <> ?` lets a flow keep the name it already holds: republishing an offered flow
|
|
931
|
+
// sends the same name again, and without the exclusion it would collide with itself.
|
|
932
|
+
const nameGuard = setsName
|
|
933
|
+
? `AND (
|
|
934
|
+
? IS NULL
|
|
935
|
+
OR (
|
|
936
|
+
NOT EXISTS (SELECT 1 FROM nodes holder WHERE holder.prompt_name = ?)
|
|
937
|
+
AND NOT EXISTS (
|
|
938
|
+
SELECT 1 FROM flows other WHERE other.prompt_name = ? AND other.id <> ?
|
|
939
|
+
)
|
|
940
|
+
)
|
|
941
|
+
)`
|
|
942
|
+
: "";
|
|
943
|
+
const nameGuardBindings = setsName
|
|
944
|
+
? [input.promptName, input.promptName, input.promptName, input.flowId]
|
|
945
|
+
: [];
|
|
946
|
+
const nameEcho = setsName ? "AND prompt_name IS ?" : "";
|
|
947
|
+
const nameEchoBinding = setsName ? [input.promptName] : [];
|
|
908
948
|
try {
|
|
909
949
|
await deps.db.batch([
|
|
910
950
|
deps.db
|
|
911
|
-
.prepare(`UPDATE flows SET published_version_id =
|
|
951
|
+
.prepare(`UPDATE flows SET published_version_id = ?${nameAssignment}, updated_at = ?
|
|
912
952
|
WHERE id = ? AND EXISTS (
|
|
913
953
|
SELECT 1 FROM flow_versions WHERE id = ? AND flow_id = ?
|
|
914
|
-
)
|
|
915
|
-
|
|
954
|
+
)
|
|
955
|
+
${nameGuard}`)
|
|
956
|
+
.bind(input.versionId, ...nameBinding, input.occurredAt, input.flowId, input.versionId, input.flowId, ...nameGuardBindings),
|
|
916
957
|
deps.db
|
|
917
958
|
.prepare(`INSERT INTO idempotency_keys (
|
|
918
959
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
919
960
|
) SELECT ?, 'flows.publish', ?, ?, ?
|
|
920
961
|
WHERE EXISTS (
|
|
921
|
-
SELECT 1 FROM flows WHERE id = ? AND published_version_id = ?
|
|
962
|
+
SELECT 1 FROM flows WHERE id = ? AND published_version_id = ? ${nameEcho}
|
|
922
963
|
)`)
|
|
923
|
-
.bind(input.actorId, input.idempotencyKey, input.versionId, input.occurredAt, input.flowId, input.versionId),
|
|
964
|
+
.bind(input.actorId, input.idempotencyKey, input.versionId, input.occurredAt, input.flowId, input.versionId, ...nameEchoBinding),
|
|
924
965
|
deps.db
|
|
925
966
|
.prepare(`INSERT INTO audit_events (
|
|
926
967
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
@@ -937,7 +978,22 @@ export function createFlowRepository(deps) {
|
|
|
937
978
|
if (!replayed)
|
|
938
979
|
throw error;
|
|
939
980
|
}
|
|
940
|
-
|
|
981
|
+
const published = (await this.findIdempotent(input.actorId, "flows.publish", input.idempotencyKey)) !== null;
|
|
982
|
+
// ⚠️ Which of the two refusals it was, asked only after the write has failed — the same order
|
|
983
|
+
// `updateNode` uses, and for the same reason: deciding first and writing unguarded is the
|
|
984
|
+
// shape that loses the race the guard exists for.
|
|
985
|
+
if (!published && setsName && input.promptName !== null) {
|
|
986
|
+
const taken = await deps.db
|
|
987
|
+
.prepare(`SELECT 1 AS found FROM nodes WHERE prompt_name = ?
|
|
988
|
+
UNION ALL
|
|
989
|
+
SELECT 1 AS found FROM flows WHERE prompt_name = ? AND id <> ?
|
|
990
|
+
LIMIT 1`)
|
|
991
|
+
.bind(input.promptName, input.promptName, input.flowId)
|
|
992
|
+
.first();
|
|
993
|
+
if (taken?.found === 1)
|
|
994
|
+
return "prompt_name_taken";
|
|
995
|
+
}
|
|
996
|
+
return published;
|
|
941
997
|
},
|
|
942
998
|
// The mirror of `publish`, with the same shape of guards: the idempotency row is written only
|
|
943
999
|
// if the UPDATE actually withdrew something, so revoking what was never published leaves no key
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Actor } from "../../nodes/nodes.types.js";
|
|
2
|
+
import type { PromptNameHolder, PromptRepository } from "../../prompts/prompts.types.js";
|
|
3
|
+
import type { D1Database } from "./db.types.js";
|
|
4
|
+
/**
|
|
5
|
+
* The documents this actor may read that are offered as a slash command (#775).
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ **Two statements rather than one `UNION`, and the reason is the visibility walk.** A document
|
|
8
|
+
* is visible through `allowed`; a flow is visible through `flowInSubtree`, which is a different
|
|
9
|
+
* predicate over different tables and needs its own bindings in its own order. Welded into one
|
|
10
|
+
* statement the two would share a placeholder sequence that nobody can read back — and the failure
|
|
11
|
+
* that produces is a shifted binding, which does not throw: it answers the wrong list.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ **`archived_at IS NULL` is part of being offered.** Archiving is what "delete" means here, and
|
|
14
|
+
* a command that starts an archived document is a command onto something the tree no longer shows.
|
|
15
|
+
* The name stays taken while it is archived — a restore brings the command back rather than finding
|
|
16
|
+
* its name given away in the meantime.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ The `kind = 'document'` is redundant against the CHECK in `0028` and stays anyway: it is the
|
|
19
|
+
* one line that keeps this query honest if that CHECK is ever loosened, and it costs nothing on a
|
|
20
|
+
* partial index that only holds the offered rows.
|
|
21
|
+
*/
|
|
22
|
+
export declare const offeredDocumentsQuery: string;
|
|
23
|
+
/**
|
|
24
|
+
* The published flows this actor may read that are offered as a slash command.
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ **`published_version_id IS NOT NULL` is the switch, and it is the whole of it** (#775).
|
|
27
|
+
* Publishing is already the decision that a flow is for other people, so `flow_publish` sets the
|
|
28
|
+
* name and nothing else asks again. A flow that is withdrawn KEEPS its name and leaves this list:
|
|
29
|
+
* the name is what makes republishing it the same command rather than a new one, and a client that
|
|
30
|
+
* stored it is not left pointing at a command that has since become somebody else's.
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ `read`, not `execute`, and that is not the obvious choice. A flow in the library — runnable by
|
|
33
|
+
* people who may not open it (ADR-0004 §2/§3) — is deliberately NOT listed: a catalogue entry
|
|
34
|
+
* carries a title and a description, and those are the readable half. Somebody who may only run it
|
|
35
|
+
* still can, through `flow_run_start` with the id they were given; what they do not get is a
|
|
36
|
+
* listing that names it.
|
|
37
|
+
*/
|
|
38
|
+
export declare const offeredFlowsQuery: string;
|
|
39
|
+
export declare function promptNameHolder(deps: {
|
|
40
|
+
db: D1Database;
|
|
41
|
+
now(): Date;
|
|
42
|
+
}, actor: Actor, name: string): Promise<PromptNameHolder | null>;
|
|
43
|
+
export declare function createPromptRepository(deps: {
|
|
44
|
+
db: D1Database;
|
|
45
|
+
now(): Date;
|
|
46
|
+
}): PromptRepository;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { flowInSubtree, flowInSubtreeBindings, subtreeBindings, subtreeCte } from "./db-grants.js";
|
|
2
|
+
/**
|
|
3
|
+
* The documents this actor may read that are offered as a slash command (#775).
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ **Two statements rather than one `UNION`, and the reason is the visibility walk.** A document
|
|
6
|
+
* is visible through `allowed`; a flow is visible through `flowInSubtree`, which is a different
|
|
7
|
+
* predicate over different tables and needs its own bindings in its own order. Welded into one
|
|
8
|
+
* statement the two would share a placeholder sequence that nobody can read back — and the failure
|
|
9
|
+
* that produces is a shifted binding, which does not throw: it answers the wrong list.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ **`archived_at IS NULL` is part of being offered.** Archiving is what "delete" means here, and
|
|
12
|
+
* a command that starts an archived document is a command onto something the tree no longer shows.
|
|
13
|
+
* The name stays taken while it is archived — a restore brings the command back rather than finding
|
|
14
|
+
* its name given away in the meantime.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ The `kind = 'document'` is redundant against the CHECK in `0028` and stays anyway: it is the
|
|
17
|
+
* one line that keeps this query honest if that CHECK is ever loosened, and it costs nothing on a
|
|
18
|
+
* partial index that only holds the offered rows.
|
|
19
|
+
*/
|
|
20
|
+
export const offeredDocumentsQuery = `${subtreeCte}
|
|
21
|
+
SELECT n.prompt_name, n.id, n.title, n.description
|
|
22
|
+
FROM nodes n
|
|
23
|
+
JOIN allowed ON allowed.id = n.id
|
|
24
|
+
WHERE n.prompt_name IS NOT NULL
|
|
25
|
+
AND n.kind = 'document'
|
|
26
|
+
AND n.archived_at IS NULL
|
|
27
|
+
ORDER BY n.prompt_name`;
|
|
28
|
+
/**
|
|
29
|
+
* The published flows this actor may read that are offered as a slash command.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ **`published_version_id IS NOT NULL` is the switch, and it is the whole of it** (#775).
|
|
32
|
+
* Publishing is already the decision that a flow is for other people, so `flow_publish` sets the
|
|
33
|
+
* name and nothing else asks again. A flow that is withdrawn KEEPS its name and leaves this list:
|
|
34
|
+
* the name is what makes republishing it the same command rather than a new one, and a client that
|
|
35
|
+
* stored it is not left pointing at a command that has since become somebody else's.
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ `read`, not `execute`, and that is not the obvious choice. A flow in the library — runnable by
|
|
38
|
+
* people who may not open it (ADR-0004 §2/§3) — is deliberately NOT listed: a catalogue entry
|
|
39
|
+
* carries a title and a description, and those are the readable half. Somebody who may only run it
|
|
40
|
+
* still can, through `flow_run_start` with the id they were given; what they do not get is a
|
|
41
|
+
* listing that names it.
|
|
42
|
+
*/
|
|
43
|
+
export const offeredFlowsQuery = `${subtreeCte}
|
|
44
|
+
SELECT flow.prompt_name, flow.id, flow.title, flow.description
|
|
45
|
+
FROM flows flow
|
|
46
|
+
WHERE flow.prompt_name IS NOT NULL
|
|
47
|
+
AND flow.published_version_id IS NOT NULL
|
|
48
|
+
AND flow.archived_at IS NULL
|
|
49
|
+
AND ${flowInSubtree}
|
|
50
|
+
ORDER BY flow.prompt_name`;
|
|
51
|
+
/**
|
|
52
|
+
* Who holds one name, asked of both tables at once, for the refusal a taken name gets.
|
|
53
|
+
*
|
|
54
|
+
* ⚠️ **Existence is answered to everybody; the TITLE only to those entitled to it.** The question
|
|
55
|
+
* "is this name free" has to be answered truthfully or the caller can do nothing — they are refused
|
|
56
|
+
* by an index they cannot see and told the node was changed by another editor. What must not travel
|
|
57
|
+
* is the title: naming the document that holds `payroll` tells somebody without access that a
|
|
58
|
+
* document by that title exists, and ADR-0004 §3 is explicit that a refusal must not become a way
|
|
59
|
+
* of reading the tree. The `CASE` below is where the two halves part.
|
|
60
|
+
*
|
|
61
|
+
* ⚠️ **One function, called from both repositories, rather than one query per repository.** A node
|
|
62
|
+
* write and a flow publish refuse for the same reason and have to say the same sentence; two
|
|
63
|
+
* spellings of this would drift, and the one that drifts is the one whose surface is used less.
|
|
64
|
+
* `destructive.md` calls it by name: two queries with similar names are two different questions.
|
|
65
|
+
*/
|
|
66
|
+
const holderQuery = `${subtreeCte}
|
|
67
|
+
SELECT 'document' AS kind, CASE WHEN allowed.id IS NULL THEN NULL ELSE n.title END AS title
|
|
68
|
+
FROM nodes n
|
|
69
|
+
LEFT JOIN allowed ON allowed.id = n.id
|
|
70
|
+
WHERE n.prompt_name = ?
|
|
71
|
+
UNION ALL
|
|
72
|
+
SELECT 'flow' AS kind, CASE WHEN ${flowInSubtree} THEN flow.title ELSE NULL END AS title
|
|
73
|
+
FROM flows flow
|
|
74
|
+
WHERE flow.prompt_name = ?`;
|
|
75
|
+
export async function promptNameHolder(deps, actor, name) {
|
|
76
|
+
const now = deps.now().toISOString();
|
|
77
|
+
const row = await deps.db
|
|
78
|
+
.prepare(holderQuery)
|
|
79
|
+
.bind(...subtreeBindings(actor, "read", now), name, ...flowInSubtreeBindings(actor, "read", now), name)
|
|
80
|
+
.first();
|
|
81
|
+
return row === null ? null : { kind: row.kind, title: row.title };
|
|
82
|
+
}
|
|
83
|
+
function mapEntry(row, kind) {
|
|
84
|
+
return {
|
|
85
|
+
name: row.prompt_name,
|
|
86
|
+
kind,
|
|
87
|
+
targetId: row.id,
|
|
88
|
+
title: row.title,
|
|
89
|
+
description: row.description,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export function createPromptRepository(deps) {
|
|
93
|
+
return {
|
|
94
|
+
async listOffered(actor) {
|
|
95
|
+
const now = deps.now().toISOString();
|
|
96
|
+
/**
|
|
97
|
+
* ⚠️ **A half the actor has no capability for is not asked for at all** (#775, following
|
|
98
|
+
* #774). Running the statement and discarding its rows would be the same answer at the cost
|
|
99
|
+
* of a walk over the tree — and worse, it would put the decision in the mapping step, where
|
|
100
|
+
* the next reader of this function cannot see it.
|
|
101
|
+
*
|
|
102
|
+
* ⚠️ **Each statement carries its own kind, rather than the two being read back out of fixed
|
|
103
|
+
* positions.** With only `flows/read` the flow query is the ONLY element, so it arrives as
|
|
104
|
+
* result zero: a `const [documents, flows]` here puts the flows in a variable called
|
|
105
|
+
* `documents`, and the name is then a lie in one of the four branches. It answers correctly
|
|
106
|
+
* today and mis-binds the moment somebody adds a third conditional query — which is exactly
|
|
107
|
+
* the kind of edit this list invites.
|
|
108
|
+
*/
|
|
109
|
+
const asked = [];
|
|
110
|
+
if (actor.canReadNodes) {
|
|
111
|
+
asked.push({
|
|
112
|
+
kind: "document",
|
|
113
|
+
statement: deps.db
|
|
114
|
+
.prepare(offeredDocumentsQuery)
|
|
115
|
+
.bind(...subtreeBindings(actor, "read", now)),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (actor.canReadFlows) {
|
|
119
|
+
asked.push({
|
|
120
|
+
kind: "flow",
|
|
121
|
+
statement: deps.db
|
|
122
|
+
.prepare(offeredFlowsQuery)
|
|
123
|
+
.bind(...subtreeBindings(actor, "read", now), ...flowInSubtreeBindings(actor, "read", now)),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
const results = await deps.db.batch(asked.map((each) => each.statement));
|
|
127
|
+
return (asked
|
|
128
|
+
.flatMap((each, index) => (results[index]?.results ?? []).map((row) => mapEntry(row, each.kind)))
|
|
129
|
+
// ⚠️ Sorted over the MERGED list rather than relying on the two `ORDER BY` clauses. Each
|
|
130
|
+
// statement orders its own half, and concatenating two ordered halves gives an unordered
|
|
131
|
+
// whole — `prompts/list` would then be alphabetical within documents and again within
|
|
132
|
+
// flows, which reads as "unsorted with a pattern" to anybody scrolling it.
|
|
133
|
+
.sort((left, right) => left.name.localeCompare(right.name)));
|
|
134
|
+
},
|
|
135
|
+
async findOffered(actor, name) {
|
|
136
|
+
// The catalogue is the list this actor may see, so looking one entry up is looking in that
|
|
137
|
+
// list. A separate statement with its own `WHERE prompt_name = ?` would be a second walk to
|
|
138
|
+
// keep in step with the first, for a set that is small by construction: the offered rows are
|
|
139
|
+
// a curated handful, not the tree.
|
|
140
|
+
const offered = await this.listOffered(actor);
|
|
141
|
+
return offered.find((entry) => entry.name === name) ?? null;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
package/dist/adapters/db/db.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { descendantsCte, flowInSubtree, flowInSubtreeBindings, grantColumns as grantColumnsFor, grantInForce, mapGrant, nodeVerbBindings, nodeVerbQuery, principalColumns, subtreeBindings, subtreeCte, } from "./db-grants.js";
|
|
2
|
+
import { promptNameHolder } from "./db-prompts.js";
|
|
2
3
|
const versionColumns = `id, node_id, sequence, content_key, media_type, content_hash,
|
|
3
4
|
size, segment, created_by, created_at`;
|
|
4
5
|
const grantColumns = grantColumnsFor("node_id");
|
|
@@ -17,7 +18,7 @@ function dedupedByNode(rows) {
|
|
|
17
18
|
});
|
|
18
19
|
}
|
|
19
20
|
const nodeColumns = `n.id, n.parent_id, n.kind, n.title, n.description,
|
|
20
|
-
n.owner_id, n.current_version_id, n.created_at, n.updated_at, n.archived_at`;
|
|
21
|
+
n.owner_id, n.current_version_id, n.created_at, n.updated_at, n.archived_at, n.prompt_name`;
|
|
21
22
|
function mapNode(row) {
|
|
22
23
|
return {
|
|
23
24
|
id: row.id,
|
|
@@ -30,6 +31,7 @@ function mapNode(row) {
|
|
|
30
31
|
createdAt: row.created_at,
|
|
31
32
|
updatedAt: row.updated_at,
|
|
32
33
|
archivedAt: row.archived_at,
|
|
34
|
+
promptName: row.prompt_name,
|
|
33
35
|
};
|
|
34
36
|
}
|
|
35
37
|
function mapVersion(row) {
|
|
@@ -529,6 +531,22 @@ export function createNodeRepository(deps) {
|
|
|
529
531
|
.all();
|
|
530
532
|
return (result.results ?? []).map(mapVersion);
|
|
531
533
|
},
|
|
534
|
+
/**
|
|
535
|
+
* ⚠️ **The prompt name is the one field here whose write can fail for a reason that is not a
|
|
536
|
+
* conflict, and its guard sits ON the statement rather than in front of it** (#775). A
|
|
537
|
+
* `SELECT` beforehand decides on a state that may be gone by the time the `UPDATE` lands; the
|
|
538
|
+
* condition below is evaluated inside the same transaction as the write it guards.
|
|
539
|
+
*
|
|
540
|
+
* ⚠️ **And it is on every statement of the batch, not only the first** — `destructive.md`,
|
|
541
|
+
* `anchrd/intel#457`. A statement matching zero rows is not an error and the batch commits
|
|
542
|
+
* anyway, so the idempotency row and the audit row carry the same `prompt_name IS ?` the
|
|
543
|
+
* `UPDATE` carries. Without it a refused rename would still be booked as done, and the replay
|
|
544
|
+
* path would hand the caller back a node it never wrote.
|
|
545
|
+
*
|
|
546
|
+
* ⚠️ `other.id <> ?` is what lets a node keep its OWN name. Writing the name it already holds
|
|
547
|
+
* is the everyday case — every `node_update` that changes a title sends the current prompt
|
|
548
|
+
* name along — and without the exclusion the node would be refused for colliding with itself.
|
|
549
|
+
*/
|
|
532
550
|
async updateNode(input) {
|
|
533
551
|
const node = input.node;
|
|
534
552
|
try {
|
|
@@ -542,10 +560,19 @@ export function createNodeRepository(deps) {
|
|
|
542
560
|
JOIN descendants parent ON child.parent_id = parent.id
|
|
543
561
|
)
|
|
544
562
|
UPDATE nodes
|
|
545
|
-
SET parent_id = ?, title = ?, description = ?, updated_at = ?
|
|
563
|
+
SET parent_id = ?, title = ?, description = ?, prompt_name = ?, updated_at = ?
|
|
546
564
|
WHERE id = ? AND updated_at = ?
|
|
547
|
-
AND (? IS NULL OR ? NOT IN (SELECT id FROM descendants))
|
|
548
|
-
|
|
565
|
+
AND (? IS NULL OR ? NOT IN (SELECT id FROM descendants))
|
|
566
|
+
AND (
|
|
567
|
+
? IS NULL
|
|
568
|
+
OR (
|
|
569
|
+
NOT EXISTS (
|
|
570
|
+
SELECT 1 FROM nodes other WHERE other.prompt_name = ? AND other.id <> ?
|
|
571
|
+
)
|
|
572
|
+
AND NOT EXISTS (SELECT 1 FROM flows holder WHERE holder.prompt_name = ?)
|
|
573
|
+
)
|
|
574
|
+
)`)
|
|
575
|
+
.bind(node.id, node.parentId, node.title, node.description, node.promptName, node.updatedAt, node.id, input.baseUpdatedAt, node.parentId, node.parentId, node.promptName, node.promptName, node.id, node.promptName),
|
|
549
576
|
deps.db
|
|
550
577
|
.prepare(`INSERT INTO idempotency_keys (
|
|
551
578
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
@@ -553,9 +580,9 @@ export function createNodeRepository(deps) {
|
|
|
553
580
|
WHERE EXISTS (
|
|
554
581
|
SELECT 1 FROM nodes
|
|
555
582
|
WHERE id = ? AND parent_id IS ? AND title = ? AND description IS ?
|
|
556
|
-
AND updated_at = ?
|
|
583
|
+
AND prompt_name IS ? AND updated_at = ?
|
|
557
584
|
)`)
|
|
558
|
-
.bind(input.actorId, input.idempotencyKey, node.id, node.updatedAt, node.id, node.parentId, node.title, node.description, node.updatedAt),
|
|
585
|
+
.bind(input.actorId, input.idempotencyKey, node.id, node.updatedAt, node.id, node.parentId, node.title, node.description, node.promptName, node.updatedAt),
|
|
559
586
|
deps.db
|
|
560
587
|
.prepare(`INSERT INTO audit_events (
|
|
561
588
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
@@ -608,8 +635,33 @@ export function createNodeRepository(deps) {
|
|
|
608
635
|
if (cycle?.found === 1)
|
|
609
636
|
return "cycle";
|
|
610
637
|
}
|
|
638
|
+
/**
|
|
639
|
+
* ⚠️ **Which of the two refusals it was, asked only once the write has already failed.** The
|
|
640
|
+
* condition on the statement is what makes the guard hold; this read is what makes the answer
|
|
641
|
+
* sayable. Doing it the other way round — deciding here and writing unguarded — is the shape
|
|
642
|
+
* that loses the race it was written for.
|
|
643
|
+
*
|
|
644
|
+
* ⚠️ It is asked of BOTH tables, because the name is one catalogue across the two (#775). A
|
|
645
|
+
* check against `nodes` alone answers "free" for a name a published flow holds, and the write
|
|
646
|
+
* would then keep failing with a message that says the node was changed by another editor.
|
|
647
|
+
*/
|
|
648
|
+
if (node.promptName !== null) {
|
|
649
|
+
const taken = await deps.db
|
|
650
|
+
.prepare(`SELECT 1 AS found FROM nodes WHERE prompt_name = ? AND id <> ?
|
|
651
|
+
UNION ALL
|
|
652
|
+
SELECT 1 AS found FROM flows WHERE prompt_name = ?
|
|
653
|
+
LIMIT 1`)
|
|
654
|
+
.bind(node.promptName, node.id, node.promptName)
|
|
655
|
+
.first();
|
|
656
|
+
if (taken?.found === 1)
|
|
657
|
+
return "prompt_name_taken";
|
|
658
|
+
}
|
|
611
659
|
return "conflict";
|
|
612
660
|
},
|
|
661
|
+
async promptNameHolder(actor, name) {
|
|
662
|
+
// One question, one implementation, shared with the flow repository — see `db-prompts.ts`.
|
|
663
|
+
return await promptNameHolder(deps, actor, name);
|
|
664
|
+
},
|
|
613
665
|
async archiveNode(input) {
|
|
614
666
|
const archiving = input.archivedAt !== null;
|
|
615
667
|
/**
|
package/dist/bundle/bundle.js
CHANGED
|
@@ -992,6 +992,15 @@ export function createBundle(deps) {
|
|
|
992
992
|
});
|
|
993
993
|
}
|
|
994
994
|
flows.push({
|
|
995
|
+
/**
|
|
996
|
+
* ⚠️ **An import never carries a slash command over, and that is not a gap** (#775). A
|
|
997
|
+
* prompt name is unique across the whole installation the bundle lands IN, and the
|
|
998
|
+
* bundle knows nothing about it: importing a flow offered as `/billing` into a tree
|
|
999
|
+
* that already has one would fail the whole import over a name nobody chose here. The
|
|
1000
|
+
* import brings the content; offering it is a decision somebody makes afterwards, in
|
|
1001
|
+
* the installation that has to live with the name.
|
|
1002
|
+
*/
|
|
1003
|
+
promptName: null,
|
|
995
1004
|
id: entry.newId,
|
|
996
1005
|
parentId,
|
|
997
1006
|
title: plainTitle(entry.title),
|
|
@@ -1075,6 +1084,9 @@ export function createBundle(deps) {
|
|
|
1075
1084
|
if (version !== null)
|
|
1076
1085
|
versions.push(version);
|
|
1077
1086
|
nodes.push({
|
|
1087
|
+
// Not carried over, for the reason the flow half above spells out: the catalogue belongs
|
|
1088
|
+
// to the installation, not to the bundle (#775).
|
|
1089
|
+
promptName: null,
|
|
1078
1090
|
id: entry.newId,
|
|
1079
1091
|
parentId,
|
|
1080
1092
|
// Flows took the `continue` above; what reaches here is one of the six node kinds.
|
package/dist/flows/flows.js
CHANGED
|
@@ -1321,6 +1321,9 @@ export function createFlows(deps) {
|
|
|
1321
1321
|
const occurredAt = deps.now().toISOString();
|
|
1322
1322
|
return await deps.repository.insertFlow({
|
|
1323
1323
|
flow: {
|
|
1324
|
+
// A flow is offered at `flow_publish` and never at creation: publishing is the decision
|
|
1325
|
+
// that it is for other people, and an unpublished flow is in nobody's catalogue (#775).
|
|
1326
|
+
promptName: null,
|
|
1324
1327
|
id: deps.id(),
|
|
1325
1328
|
parentId: input.parentId,
|
|
1326
1329
|
title: plainTitle(input.title),
|
|
@@ -1641,7 +1644,26 @@ export function createFlows(deps) {
|
|
|
1641
1644
|
idempotencyKey: input.idempotencyKey,
|
|
1642
1645
|
auditId: deps.id(),
|
|
1643
1646
|
occurredAt: deps.now().toISOString(),
|
|
1647
|
+
// ⚠️ Spread rather than assigned, and `exactOptionalPropertyTypes` is only half the reason.
|
|
1648
|
+
// The repository distinguishes three states, and "the field is present carrying
|
|
1649
|
+
// `undefined`" is not one of them: it has to be ABSENT for "leave the name alone".
|
|
1650
|
+
...(input.promptName === undefined ? {} : { promptName: input.promptName }),
|
|
1644
1651
|
});
|
|
1652
|
+
if (published === "prompt_name_taken") {
|
|
1653
|
+
/**
|
|
1654
|
+
* ⚠️ **The holder is named only to somebody who may see it** — the same sentence
|
|
1655
|
+
* `node_update` gives, out of the same query (`db-prompts.ts`). The catalogue is one across
|
|
1656
|
+
* documents and flows, so the name a flow is refused for may well be held by a document,
|
|
1657
|
+
* and the refusal has to be able to say so.
|
|
1658
|
+
*/
|
|
1659
|
+
const holder = await deps.repository.promptNameHolder(actor, input.promptName);
|
|
1660
|
+
const held = holder === null
|
|
1661
|
+
? "It is already taken."
|
|
1662
|
+
: holder.title === null
|
|
1663
|
+
? `A ${holder.kind} you cannot see already uses it.`
|
|
1664
|
+
: `The ${holder.kind} "${holder.title}" already uses it.`;
|
|
1665
|
+
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.`);
|
|
1666
|
+
}
|
|
1645
1667
|
if (!published)
|
|
1646
1668
|
throw new IntelError(409, "flow_publish_conflict", "Flow could not be published");
|
|
1647
1669
|
return await requireFlow(actor, flow.id);
|
|
@@ -2,6 +2,7 @@ import type { ArchiveFlowInput, CreateFlowInput, Flow, FlowDocument, FlowGraph,
|
|
|
2
2
|
import type { CancelFlowRunInput, CompleteFlowRunStepInput, FlowRun, FlowRunHistory, FlowRunList, FlowRunStep, ListFlowRunsInput, StartFlowRunInput } from "@anchrd/intel-contract/flow-run";
|
|
3
3
|
import type { Node } from "@anchrd/intel-contract/node";
|
|
4
4
|
import type { ResourceAccessList, ResourceGrant, ResourceGrantList, ResourceVerb, RevokeFlowGrantInput, RevokeGrantResult, ShareFlowInput, ShareResult } from "@anchrd/intel-contract/share";
|
|
5
|
+
import type { PromptNameHolder } from "../prompts/prompts.types.js";
|
|
5
6
|
export type FlowPrincipal = Pick<FlowActor, "id" | "email" | "isAdmin">;
|
|
6
7
|
export type FlowCallReach = "subtree" | "library" | "out-of-reach";
|
|
7
8
|
export interface FlowRunChainEntry {
|
|
@@ -159,7 +160,20 @@ export interface FlowRepository {
|
|
|
159
160
|
idempotencyKey: string;
|
|
160
161
|
auditId: string;
|
|
161
162
|
occurredAt: string;
|
|
162
|
-
|
|
163
|
+
/**
|
|
164
|
+
* The slash command this flow is offered under (#775). ⚠️ Three states, and `undefined` is the
|
|
165
|
+
* one that does nothing: publishing a second version of an already-offered flow must not
|
|
166
|
+
* withdraw its command, so a caller who says nothing about prompts changes nothing about them.
|
|
167
|
+
* `null` gives the name up, a string takes one.
|
|
168
|
+
*/
|
|
169
|
+
promptName?: string | null;
|
|
170
|
+
}): Promise<boolean | "prompt_name_taken">;
|
|
171
|
+
/**
|
|
172
|
+
* Who holds a slash command name, for the sentence a refused publish gets. `title` is `null` when
|
|
173
|
+
* the holder exists but this actor may not see it — the same one implementation the node
|
|
174
|
+
* repository answers with (`db-prompts.ts`).
|
|
175
|
+
*/
|
|
176
|
+
promptNameHolder(actor: FlowActor, name: string): Promise<PromptNameHolder | null>;
|
|
163
177
|
unpublish(input: {
|
|
164
178
|
flowId: string;
|
|
165
179
|
versionId: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { documentText } from "../shared/document-text/document-text.js";
|
|
2
2
|
export class PermanentIndexingError extends Error {
|
|
3
3
|
}
|
|
4
4
|
// The key under which a node with exactly one vector is filed — since #390 that is every kind. It
|
|
@@ -9,16 +9,15 @@ const wholeNodeChunkKey = "";
|
|
|
9
9
|
// was archived while it worked and the record of them was refused (anchrd/intel#348).
|
|
10
10
|
const writtenKeys = (chunks) => chunks.map((chunk) => chunk.key);
|
|
11
11
|
function indexText(mediaType, content) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
// requeue the version until the queue gives up and would bury the real cause.
|
|
20
|
-
throw new PermanentIndexingError(`Node version content is not a valid BlockNote document: ${error instanceof Error ? error.message : "unknown parse failure"}`);
|
|
12
|
+
const text = documentText(mediaType, content);
|
|
13
|
+
// Immutable content never becomes parsable on a retry, so treating this as transient would
|
|
14
|
+
// requeue the version until the queue gives up and would bury the real cause. `documentText`
|
|
15
|
+
// answers `null` rather than throwing, because the other caller — `prompts/get` — owes its reader
|
|
16
|
+
// a different sentence about the same broken payload (#775).
|
|
17
|
+
if (text === null) {
|
|
18
|
+
throw new PermanentIndexingError("Node version content is not a valid BlockNote document");
|
|
21
19
|
}
|
|
20
|
+
return text;
|
|
22
21
|
}
|
|
23
22
|
/**
|
|
24
23
|
* What one chunk is embedded as (anchrd/intel#301).
|
package/dist/intel/intel.js
CHANGED
|
@@ -6,6 +6,7 @@ import type { BundleService } from "../bundle/bundle.types.js";
|
|
|
6
6
|
import type { FeedService } from "../feed/feed.types.js";
|
|
7
7
|
import type { FlowService } from "../flows/flows.types.js";
|
|
8
8
|
import type { NodeService } from "../nodes/nodes.types.js";
|
|
9
|
+
import type { Prompts } from "../prompts/prompts.types.js";
|
|
9
10
|
import type { ToolService } from "../tools/tools.types.js";
|
|
10
11
|
export interface IntelDeps {
|
|
11
12
|
baseUrl: string;
|
|
@@ -18,5 +19,6 @@ export interface IntelDeps {
|
|
|
18
19
|
audit: AuditService;
|
|
19
20
|
feed: FeedService;
|
|
20
21
|
boards: BoardService;
|
|
22
|
+
prompts: Prompts;
|
|
21
23
|
auth?: BrowserAuth;
|
|
22
24
|
}
|
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";
|
|
@@ -1236,6 +1237,55 @@ export async function handleMcp(request, deps) {
|
|
|
1236
1237
|
},
|
|
1237
1238
|
}, async (input) => text(await deps.tools.execute(toolActor, input)));
|
|
1238
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
|
+
}
|
|
1239
1289
|
const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
|
|
1240
1290
|
await server.connect(transport);
|
|
1241
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,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",
|