@miadi/stateloom-mcp 0.2.5 → 0.3.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/README.md +21 -1
- package/dist/atomicWrite.d.ts +1 -0
- package/dist/atomicWrite.js +28 -0
- package/dist/erd.d.ts +9 -0
- package/dist/erd.js +21 -2
- package/dist/live.d.ts +26 -0
- package/dist/live.js +131 -0
- package/dist/projectSwitch.d.ts +6 -2
- package/dist/projectSwitch.js +18 -6
- package/dist/sequence.d.ts +55 -0
- package/dist/sequence.js +337 -0
- package/dist/server.d.ts +23 -4
- package/dist/server.js +216 -68
- package/dist/system.d.ts +88 -0
- package/dist/system.js +813 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -116,8 +116,28 @@ outside the root. `generate_code` writes only to a temporary directory. Note tha
|
|
|
116
116
|
| `generate_code` | Emit Python or TypeScript from the definition |
|
|
117
117
|
| `generate_rispec` | Emit a RISE rispec in markdown, folding in the source PDE's intent when the SMDF carries one |
|
|
118
118
|
| `render_diagram` | Draw the machine as `png`, `svg`, `mermaid` or `ascii`, write it beside the document, and hand a `png` back inside the tool result so the agent sees what it designed |
|
|
119
|
-
| `set_project_file` | Re-point the loom at another `.smdf.json`
|
|
119
|
+
| `set_project_file` | Re-point the loom at another document mid-session — a `.smdf.json` or a `.erdf.json`; disk target and live room both move |
|
|
120
120
|
| `get_project_file` | The active document path, whether it exists, and bridge status |
|
|
121
|
+
| `get_notes` | Every note in the active document in one call — the diagram's own first, then each shape that has one |
|
|
122
|
+
| `set_notes` | Save (or, with an empty string, clear) working notes on the diagram or on one state / entity. Kept in the document for whoever opens it next; ignored by engines and codegen |
|
|
123
|
+
|
|
124
|
+
### Entity-relationship diagrams
|
|
125
|
+
|
|
126
|
+
The same server designs the **data** beside the machines, in a sibling `.erdf.json`. The
|
|
127
|
+
active document's type is its extension: while an ERD is active, `get_definition`,
|
|
128
|
+
`load_definition` and `render_diagram` (mermaid `erDiagram`) answer for it, and the
|
|
129
|
+
state-machine tools refuse to write a machine over it.
|
|
130
|
+
|
|
131
|
+
| Tool | What it does |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `create_erd` | Start an ERD beside the active document and make it the active one |
|
|
134
|
+
| `add_entity` / `update_entity` / `remove_entity` | An entity, optionally `weak`; removing one takes its relationships with it |
|
|
135
|
+
| `add_attribute` / `remove_attribute` | `type`, `key` (`pk` / `uk`), `references` (the foreign key), `nullable`, and `stateOf` — the state machine whose state this attribute stores |
|
|
136
|
+
| `add_relationship` / `remove_relationship` | `1:1`, `1:N`, `N:1`, `N:M`, read left to right, with a verb as the label |
|
|
137
|
+
| `validate_erd` | Rules E001–E005 |
|
|
138
|
+
| `check_links` | Rules L001–L004: what a state machine names — an object's class, a field in a guard, a `stateOf` — that the ERD does not carry |
|
|
139
|
+
|
|
140
|
+
Format and rules: [Spec 80 — ERDF](https://github.com/jgwill/smcraft/blob/main/rispecs/80-erdf-format.spec.md).
|
|
121
141
|
|
|
122
142
|
Also exposed: the resource `smcraft://definition` (the current definition), and the prompt `design-state-machine` — a guided conversation for building one from a domain description.
|
|
123
143
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function writeFileAtomic(path: string, text: string): void;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write a document whole or not at all: to a temp file beside it, then rename.
|
|
3
|
+
*
|
|
4
|
+
* A reader (the hub's file watch, a canvas, another tool) never sees half a
|
|
5
|
+
* file. An existing file that is not writable stays refused — a rename would
|
|
6
|
+
* replace it regardless of its mode, and a read-only document is a person's
|
|
7
|
+
* word that it should not change — and the new file keeps the old one's mode.
|
|
8
|
+
*/
|
|
9
|
+
import { accessSync, chmodSync, constants, existsSync, renameSync, rmSync, statSync, writeFileSync } from "fs";
|
|
10
|
+
import { basename, dirname, join } from "path";
|
|
11
|
+
export function writeFileAtomic(path, text) {
|
|
12
|
+
let mode;
|
|
13
|
+
if (existsSync(path)) {
|
|
14
|
+
accessSync(path, constants.W_OK);
|
|
15
|
+
mode = statSync(path).mode & 0o7777;
|
|
16
|
+
}
|
|
17
|
+
const temp = join(dirname(path), `.${basename(path)}.${process.pid}.${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}.tmp`);
|
|
18
|
+
try {
|
|
19
|
+
writeFileSync(temp, text, "utf8");
|
|
20
|
+
if (mode !== undefined)
|
|
21
|
+
chmodSync(temp, mode);
|
|
22
|
+
renameSync(temp, path);
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
rmSync(temp, { force: true });
|
|
26
|
+
throw e;
|
|
27
|
+
}
|
|
28
|
+
}
|
package/dist/erd.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface ErdHost {
|
|
|
25
25
|
emitFull(def: EntityRelationshipDefinition): void;
|
|
26
26
|
/** The refusal for a path outside the permitted document root, if any. */
|
|
27
27
|
outsideRoot(path: string): string | undefined;
|
|
28
|
+
/** check_links from a sequence or a system: the whole system's check (check_system). */
|
|
29
|
+
checkSystem?(): Promise<ToolResult>;
|
|
28
30
|
}
|
|
29
31
|
type ToolResult = {
|
|
30
32
|
content: {
|
|
@@ -43,5 +45,12 @@ export declare function erdRender(host: ErdHost, opts: {
|
|
|
43
45
|
path?: string;
|
|
44
46
|
stamp?: boolean;
|
|
45
47
|
}): ToolResult;
|
|
48
|
+
/** One block per note, the diagram's own first. What `get_notes` answers with for either document type. */
|
|
49
|
+
export declare function formatNotes(name: string, path: string, entries: {
|
|
50
|
+
target: string | null;
|
|
51
|
+
notes: string;
|
|
52
|
+
}[]): string;
|
|
53
|
+
export declare function erdGetNotes(host: ErdHost): ToolResult;
|
|
54
|
+
export declare function erdSetNotes(host: ErdHost, target: string | undefined, notes: string): ToolResult;
|
|
46
55
|
export declare function registerErdTools(server: McpServer, host: ErdHost): void;
|
|
47
56
|
export {};
|
package/dist/erd.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { existsSync, readFileSync, readdirSync, writeFileSync } from "fs";
|
|
3
3
|
import { dirname, join, resolve } from "path";
|
|
4
|
-
import { addAttribute, addEntity, addRelationship, checkLinks, checkStateOf, emptyErd, ERD_CARDINALITIES, isErdDefinition, isErdfPath, removeAttribute, removeEntity, removeRelationship, renderMermaidEr, updateEntity, summarizeErd, validateErd, } from "@miadi/stateloom-protocol";
|
|
4
|
+
import { addAttribute, addEntity, addRelationship, checkLinks, checkStateOf, collectNotes, emptyErd, ERD_CARDINALITIES, isErdDefinition, isErdfPath, isSqdfPath, isSysdfPath, removeAttribute, removeEntity, removeRelationship, renderMermaidEr, updateEntity, updateErdSettings, summarizeErd, validateErd, } from "@miadi/stateloom-protocol";
|
|
5
5
|
import { stampedOutputPath } from "@miadi/stateloom-cli/render";
|
|
6
6
|
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
7
7
|
const fail = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
@@ -101,6 +101,22 @@ export function erdRender(host, opts) {
|
|
|
101
101
|
],
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
|
+
/** One block per note, the diagram's own first. What `get_notes` answers with for either document type. */
|
|
105
|
+
export function formatNotes(name, path, entries) {
|
|
106
|
+
if (entries.length === 0)
|
|
107
|
+
return `No notes in '${name}' (${path}).`;
|
|
108
|
+
const blocks = entries.map((n) => `[${n.target ?? "diagram"}]\n${n.notes}`);
|
|
109
|
+
return `${entries.length} note(s) in '${name}' (${path}):\n\n${blocks.join("\n\n")}`;
|
|
110
|
+
}
|
|
111
|
+
export function erdGetNotes(host) {
|
|
112
|
+
const def = readErd(host.projectFile());
|
|
113
|
+
if (!def)
|
|
114
|
+
return fail(`No ERD at ${host.projectFile()}.`);
|
|
115
|
+
return ok(formatNotes(def.settings?.name ?? "", host.projectFile(), collectNotes(def)));
|
|
116
|
+
}
|
|
117
|
+
export function erdSetNotes(host, target, notes) {
|
|
118
|
+
return mutate(host, (def) => (target ? updateEntity(def, target, { notes }) : updateErdSettings(def, { notes })), () => `${notes.trim() ? "Saved" : "Cleared"} the notes on ${target ? `entity '${target}'` : "the diagram"}.`);
|
|
119
|
+
}
|
|
104
120
|
const formatErrors = (errors) => errors.map((e) => `[${e.ruleId}] ${e.message}`).join("\n");
|
|
105
121
|
function readSmdf(path) {
|
|
106
122
|
if (!existsSync(path))
|
|
@@ -177,8 +193,11 @@ export function registerErdTools(server, host) {
|
|
|
177
193
|
? fail(`${errors.length} problem(s) in '${def.settings?.name ?? ""}':\n${formatErrors(errors)}\n\n${body}`)
|
|
178
194
|
: ok(`ERD '${def.settings?.name ?? ""}' is valid.\n${body}`);
|
|
179
195
|
});
|
|
180
|
-
server.tool("check_links", "Check the names a state machine uses for its data against an ERD (rules L001–L004): the class of each object a guard reads a field of must be an entity, each <instance>.<field> in a guard must be an attribute of it, and an attribute's stateOf must point at a machine built with that entity. With no arguments: when the active document is an ERD, every .smdf.json beside it is checked; when it is a state machine, pass erd_path.", { erd_path: z.string().optional(), smdf_paths: z.array(z.string()).optional() }, async ({ erd_path, smdf_paths }) => {
|
|
196
|
+
server.tool("check_links", "Check the names a state machine uses for its data against an ERD (rules L001–L004): the class of each object a guard reads a field of must be an entity, each <instance>.<field> in a guard must be an attribute of it, and an attribute's stateOf must point at a machine built with that entity. With no arguments: when the active document is an ERD, every .smdf.json beside it is checked; when it is a state machine, pass erd_path; when it is a sequence or a system, the whole system is checked (check_system).", { erd_path: z.string().optional(), smdf_paths: z.array(z.string()).optional() }, async ({ erd_path, smdf_paths }) => {
|
|
181
197
|
const active = host.projectFile();
|
|
198
|
+
if (!erd_path && !smdf_paths?.length && host.checkSystem && (isSqdfPath(active) || isSysdfPath(active))) {
|
|
199
|
+
return host.checkSystem();
|
|
200
|
+
}
|
|
182
201
|
const erdPath = erd_path ? resolve(erd_path) : isErdfPath(active) ? active : undefined;
|
|
183
202
|
if (!erdPath) {
|
|
184
203
|
return fail(`The active document ${active} is a state machine — pass erd_path, the .erdf.json to check it against.`);
|
package/dist/live.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type PatchOp, type ViewEnvelope } from "@miadi/stateloom-protocol";
|
|
2
|
+
export interface LiveOutcome {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
text: string;
|
|
5
|
+
/** No bridge is configured: nothing was attempted. */
|
|
6
|
+
off?: true;
|
|
7
|
+
}
|
|
8
|
+
export interface Live {
|
|
9
|
+
/** Granular ops to a machine's room. `def` is what the file now holds. */
|
|
10
|
+
patch(docId: string, ops: PatchOp[], def: unknown, mtime: number): Promise<LiveOutcome>;
|
|
11
|
+
/** A whole document (ERD, sequence, system) to its room. */
|
|
12
|
+
full(docId: string, def: unknown, mtime: number): Promise<LiveOutcome>;
|
|
13
|
+
/** A view to a system's room; needs a connection, not a join. */
|
|
14
|
+
view(view: Omit<ViewEnvelope, "origin"> & {
|
|
15
|
+
origin?: string;
|
|
16
|
+
}): Promise<LiveOutcome>;
|
|
17
|
+
}
|
|
18
|
+
export interface LiveTarget {
|
|
19
|
+
/** The hub. Undefined: every push reports that the bridge is not configured. */
|
|
20
|
+
url?: string;
|
|
21
|
+
token?: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
/** How long to wait for the hub at each step. Default 3000 ms. */
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
}
|
|
26
|
+
export declare function liveBridge(target: LiveTarget): Live;
|
package/dist/live.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot pushes to rooms other than the active document's (Spec 82).
|
|
3
|
+
*
|
|
4
|
+
* The server's own bridge client is joined to the active document's room, and
|
|
5
|
+
* the hub makes a socket that re-joins leave its old room. So when a scenario
|
|
6
|
+
* upgrades a machine, or an agent asks the system's canvas to show something,
|
|
7
|
+
* the push goes through a short-lived client of its own: join that room, send,
|
|
8
|
+
* wait for the hub's answer, disconnect.
|
|
9
|
+
*
|
|
10
|
+
* Every push is best-effort and comes AFTER the file is written: an unreachable
|
|
11
|
+
* hub leaves the file standing, and the outcome says so in words.
|
|
12
|
+
*
|
|
13
|
+
* Joining a room the hub has not opened yet seeds it from disk, which already
|
|
14
|
+
* holds the change; applying the ops on top would add them twice. So a push is
|
|
15
|
+
* skipped when the room's snapshot already equals what was written.
|
|
16
|
+
*/
|
|
17
|
+
import { createBridgeClient } from "@miadi/stateloom-client";
|
|
18
|
+
import { hashDef, } from "@miadi/stateloom-protocol";
|
|
19
|
+
const message = (e) => (e instanceof Error ? e.message : String(e));
|
|
20
|
+
function withTimeout(p, ms, what) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const timer = setTimeout(() => reject(new Error(`${what} took longer than ${ms} ms`)), ms);
|
|
23
|
+
p.then((v) => {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
resolve(v);
|
|
26
|
+
}, (e) => {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
reject(e);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/** The hub's answer to a patch or a full: an ack with its seq, or an error frame. */
|
|
33
|
+
function answer(client, ms) {
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
const offs = [];
|
|
36
|
+
const done = (r) => {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
for (const off of offs)
|
|
39
|
+
off();
|
|
40
|
+
resolve(r);
|
|
41
|
+
};
|
|
42
|
+
const timer = setTimeout(() => done({ error: `no answer from the hub in ${ms} ms` }), ms);
|
|
43
|
+
offs.push(client.on("ack", (a) => done({ seq: a.seq })));
|
|
44
|
+
offs.push(client.on("error", (e) => done({ error: e.message || e.code })));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/** The hub said no: a refused token, or a frame it could not take. */
|
|
48
|
+
class Refused extends Error {
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The first error frame the hub sends this client, as a rejection. A failed
|
|
52
|
+
* connection is left to `connect()`, which rejects on it by itself.
|
|
53
|
+
*/
|
|
54
|
+
function refusal(client) {
|
|
55
|
+
let off = () => { };
|
|
56
|
+
const promise = new Promise((_, reject) => {
|
|
57
|
+
off = client.on("error", (e) => {
|
|
58
|
+
if (e.code !== "connect_error")
|
|
59
|
+
reject(new Refused(e.message || e.code));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
promise.catch(() => { });
|
|
63
|
+
return { promise, off };
|
|
64
|
+
}
|
|
65
|
+
export function liveBridge(target) {
|
|
66
|
+
const ms = target.timeoutMs ?? 3000;
|
|
67
|
+
const unconfigured = {
|
|
68
|
+
ok: false,
|
|
69
|
+
off: true,
|
|
70
|
+
text: "not live: the bridge is not configured (STATELOOM_BRIDGE_URL unset)",
|
|
71
|
+
};
|
|
72
|
+
const open = (docId) => createBridgeClient({
|
|
73
|
+
url: target.url,
|
|
74
|
+
role: "agent",
|
|
75
|
+
docId,
|
|
76
|
+
name: target.name ?? "mcp-agent",
|
|
77
|
+
token: target.token,
|
|
78
|
+
autoResync: false,
|
|
79
|
+
});
|
|
80
|
+
async function send(docId, def, kind, emit) {
|
|
81
|
+
if (!target.url)
|
|
82
|
+
return unconfigured;
|
|
83
|
+
const client = open(docId);
|
|
84
|
+
const refused = refusal(client);
|
|
85
|
+
try {
|
|
86
|
+
const joined = await withTimeout(Promise.race([client.join(), refused.promise]), ms, "joining the room");
|
|
87
|
+
const held = joined.snapshot?.def;
|
|
88
|
+
if (held && hashDef(held) === hashDef(def)) {
|
|
89
|
+
return { ok: true, text: "the room already holds it" };
|
|
90
|
+
}
|
|
91
|
+
const reply = answer(client, ms);
|
|
92
|
+
emit(client);
|
|
93
|
+
const r = await reply;
|
|
94
|
+
return r.error
|
|
95
|
+
? { ok: false, text: `the hub refused the ${kind}: ${r.error}` }
|
|
96
|
+
: { ok: true, text: `${kind} sent live (seq ${r.seq})` };
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
const why = e instanceof Refused ? `the hub refused the ${kind}: ${e.message}` : message(e);
|
|
100
|
+
return { ok: false, text: `not live: ${why}; the file stands` };
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
refused.off();
|
|
104
|
+
client.disconnect();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
patch: (docId, ops, def, mtime) => send(docId, def, "patch", (c) => c.emitPatch(ops, mtime)),
|
|
109
|
+
full: (docId, def, mtime) => send(docId, def, "document", (c) => c.emitFull(def, mtime)),
|
|
110
|
+
async view(view) {
|
|
111
|
+
if (!target.url)
|
|
112
|
+
return unconfigured;
|
|
113
|
+
const client = open(view.docId);
|
|
114
|
+
const refused = refusal(client);
|
|
115
|
+
try {
|
|
116
|
+
await withTimeout(Promise.race([client.emitView({ ...view, origin: view.origin ?? target.name ?? "agent" }), refused.promise]), ms, "reaching the hub");
|
|
117
|
+
// The hub acks no view, but it answers a refused one with an error
|
|
118
|
+
// frame: wait a moment for that before calling the view delivered.
|
|
119
|
+
await Promise.race([new Promise((r) => setTimeout(r, Math.min(ms, 250))), refused.promise]);
|
|
120
|
+
return { ok: true, text: "view sent to the hub" };
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
return { ok: false, text: e instanceof Refused ? `the hub refused the view: ${e.message}` : `not sent: ${message(e)}` };
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
refused.off();
|
|
127
|
+
client.disconnect();
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
package/dist/projectSwitch.d.ts
CHANGED
|
@@ -10,8 +10,12 @@ export interface SwitchResult {
|
|
|
10
10
|
}
|
|
11
11
|
export declare function chronicleRoot(env?: Record<string, string | undefined>): string;
|
|
12
12
|
/**
|
|
13
|
-
* Resolve `miadi-chronicle://<episode>/[diagrams/]<name>[
|
|
14
|
-
* absolute path `<root>/<episode-dir>/diagrams/<name>.
|
|
13
|
+
* Resolve `miadi-chronicle://<episode>/[diagrams/]<name>[.<type>.json]` to the
|
|
14
|
+
* absolute path `<root>/<episode-dir>/diagrams/<name>.<type>.json`. The loom
|
|
15
|
+
* weaves four types, told apart by extension: `.smdf.json` (state machine),
|
|
16
|
+
* `.erdf.json` (ERD), `.sqdf.json` (sequence), `.sysdf.json` (system). An
|
|
17
|
+
* explicit extension is kept; `.smdf`/`.erdf`/`.sqdf`/`.sysdf` without `.json`
|
|
18
|
+
* gets it; a bare name is a state machine, `<name>.smdf.json`, as it always was.
|
|
15
19
|
*
|
|
16
20
|
* Episode matching: exact folder name first; else a bare number matches
|
|
17
21
|
* `-episode-NNN-` folders. Episode numbers are NOT unique in the chronicle
|
package/dist/projectSwitch.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure logic behind the `set_project_file` MCP tool (path power — the agent
|
|
3
|
-
* chooses which
|
|
3
|
+
* chooses which document the loom weaves: a `.smdf.json`, `.erdf.json`,
|
|
4
|
+
* `.sqdf.json` or `.sysdf.json`).
|
|
4
5
|
*
|
|
5
6
|
* Two modalities, by William's orientation (2026-08-07): STANDALONE — a plain
|
|
6
7
|
* path, no episode, just designing — and RELATIONAL — the document lives
|
|
@@ -17,8 +18,12 @@ export function chronicleRoot(env = process.env) {
|
|
|
17
18
|
return env.MIADI_CHRONICLE_ROOT ?? "/srv/miadi/episodes/miadi-chronicle";
|
|
18
19
|
}
|
|
19
20
|
/**
|
|
20
|
-
* Resolve `miadi-chronicle://<episode>/[diagrams/]<name>[
|
|
21
|
-
* absolute path `<root>/<episode-dir>/diagrams/<name>.
|
|
21
|
+
* Resolve `miadi-chronicle://<episode>/[diagrams/]<name>[.<type>.json]` to the
|
|
22
|
+
* absolute path `<root>/<episode-dir>/diagrams/<name>.<type>.json`. The loom
|
|
23
|
+
* weaves four types, told apart by extension: `.smdf.json` (state machine),
|
|
24
|
+
* `.erdf.json` (ERD), `.sqdf.json` (sequence), `.sysdf.json` (system). An
|
|
25
|
+
* explicit extension is kept; `.smdf`/`.erdf`/`.sqdf`/`.sysdf` without `.json`
|
|
26
|
+
* gets it; a bare name is a state machine, `<name>.smdf.json`, as it always was.
|
|
22
27
|
*
|
|
23
28
|
* Episode matching: exact folder name first; else a bare number matches
|
|
24
29
|
* `-episode-NNN-` folders. Episode numbers are NOT unique in the chronicle
|
|
@@ -64,13 +69,20 @@ export function resolveChronicleUri(uri, env = process.env) {
|
|
|
64
69
|
if (matches.length > 1) {
|
|
65
70
|
throw new Error(`episode '${episodeRef}' is ambiguous — candidates:\n ${matches.sort().join("\n ")}\nName the full folder.`);
|
|
66
71
|
}
|
|
67
|
-
|
|
68
|
-
|
|
72
|
+
return join(root, matches[0], "diagrams", loomFileName(name));
|
|
73
|
+
}
|
|
74
|
+
/** `<name>` → `<name>.smdf.json`; a loom type without `.json` gets it; any `.json` is kept. */
|
|
75
|
+
function loomFileName(name) {
|
|
76
|
+
if (name.toLowerCase().endsWith(".json"))
|
|
77
|
+
return name;
|
|
78
|
+
if (/\.(smdf|erdf|sqdf|sysdf)$/i.test(name))
|
|
79
|
+
return `${name}.json`;
|
|
80
|
+
return `${name}.smdf.json`;
|
|
69
81
|
}
|
|
70
82
|
export function resolveProjectSwitch(next, previous) {
|
|
71
83
|
const path = CHRONICLE_SCHEME.test(next) ? resolveChronicleUri(next) : resolve(next);
|
|
72
84
|
if (!path.endsWith(".json")) {
|
|
73
|
-
throw new Error(`project file must be a .json document (got '${path}') — the loom weaves .smdf.json files`);
|
|
85
|
+
throw new Error(`project file must be a .json document (got '${path}') — the loom weaves .smdf.json (state machine), .erdf.json (ERD), .sqdf.json (sequence) and .sysdf.json (system) files`);
|
|
74
86
|
}
|
|
75
87
|
return {
|
|
76
88
|
path,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sequence half of the MCP server (Spec 81).
|
|
3
|
+
*
|
|
4
|
+
* A sequence (`.sqdf.json`) is a usage scenario: who sends what to whom, in
|
|
5
|
+
* order. The tools here act on the active document when it is an SQDF;
|
|
6
|
+
* `create_sequence` is the one that also moves the active document.
|
|
7
|
+
*
|
|
8
|
+
* People and tools count from 1: a main message is addressed by its number
|
|
9
|
+
* (`"9"`), a fragment's message as `f<fragment>.<n>` (`"f1.2"`), a fragment by
|
|
10
|
+
* its number. The protocol's edits count from 0; the conversion happens here.
|
|
11
|
+
*
|
|
12
|
+
* A sequence travels to the live canvas as a whole document (`emitFull`), as an
|
|
13
|
+
* ERD does. After each edit that changes the scenario, `afterEdit` gives the
|
|
14
|
+
* system a chance to reconcile (Spec 82 reconcile mode), and its one line is
|
|
15
|
+
* appended to the tool result.
|
|
16
|
+
*/
|
|
17
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
18
|
+
import { type SequenceDefinition } from "@miadi/stateloom-protocol";
|
|
19
|
+
export interface SequenceHost {
|
|
20
|
+
/** The active document's absolute path. */
|
|
21
|
+
projectFile(): string;
|
|
22
|
+
/** Re-point the active document under set_project_file's guards. Returns the refusal, if any. */
|
|
23
|
+
switchTo(path: string): string | undefined;
|
|
24
|
+
/** Mirror the whole document to the live bridge room. Best-effort. */
|
|
25
|
+
emitFull(def: SequenceDefinition): void;
|
|
26
|
+
/** The refusal for a path outside the permitted document root, if any. */
|
|
27
|
+
outsideRoot(path: string): string | undefined;
|
|
28
|
+
/** After an edit that changed the scenario was written: one line to append (reconcile mode), or nothing. */
|
|
29
|
+
afterEdit?(path: string): Promise<string | undefined>;
|
|
30
|
+
}
|
|
31
|
+
type ToolResult = {
|
|
32
|
+
content: {
|
|
33
|
+
type: "text";
|
|
34
|
+
text: string;
|
|
35
|
+
}[];
|
|
36
|
+
isError?: boolean;
|
|
37
|
+
};
|
|
38
|
+
export declare function readSequence(path: string): SequenceDefinition | null;
|
|
39
|
+
/** One line for set_project_file / get_project_file when the active document is an SQDF. */
|
|
40
|
+
export declare function sequenceSummary(path: string): string;
|
|
41
|
+
export declare function sequenceGetDefinition(host: SequenceHost): ToolResult;
|
|
42
|
+
export declare function sequenceLoadDefinition(host: SequenceHost, json: string): Promise<ToolResult>;
|
|
43
|
+
export declare function sequenceRender(host: SequenceHost, opts: {
|
|
44
|
+
format?: string;
|
|
45
|
+
path?: string;
|
|
46
|
+
stamp?: boolean;
|
|
47
|
+
}): ToolResult;
|
|
48
|
+
export declare function sequenceGetNotes(host: SequenceHost): ToolResult;
|
|
49
|
+
/**
|
|
50
|
+
* `target` is a participant's name, a message address (`9`, `f1.2`, or `message 9`
|
|
51
|
+
* as get_notes prints it), or `fragment N`. Omitted: the whole sequence.
|
|
52
|
+
*/
|
|
53
|
+
export declare function sequenceSetNotes(host: SequenceHost, target: string | undefined, notes: string): Promise<ToolResult>;
|
|
54
|
+
export declare function registerSequenceTools(server: McpServer, host: SequenceHost): void;
|
|
55
|
+
export {};
|