@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.
@@ -0,0 +1,337 @@
1
+ import { z } from "zod";
2
+ import { existsSync, readFileSync, writeFileSync } from "fs";
3
+ import { dirname, join, resolve } from "path";
4
+ import { SQD_FRAGMENT_KINDS, addFragment, addFragmentMessage, addMessage, addParticipant, allMessages, docKindOfPath, emptySequence, isSequenceDefinition, isSqdfPath, moveMessage, moveParticipant, parseMessageRef, removeFragment, removeMessage, removeParticipant, renameParticipant, renderMermaidSequence, summarizeSequence, updateFragment, updateMessage, updateParticipant, updateSqdSettings, validateSequence, } from "@miadi/stateloom-protocol";
5
+ import { stampedOutputPath } from "@miadi/stateloom-cli/render";
6
+ import { formatNotes } from "./erd.js";
7
+ import { writeFileAtomic } from "./atomicWrite.js";
8
+ const ok = (text) => ({ content: [{ type: "text", text }] });
9
+ const fail = (text) => ({ content: [{ type: "text", text }], isError: true });
10
+ const message = (e) => (e instanceof Error ? e.message : String(e));
11
+ const KIND_WORDS = { machine: "a state machine", erd: "an ERD", sequence: "a sequence", system: "a system" };
12
+ export function readSequence(path) {
13
+ if (!existsSync(path))
14
+ return null;
15
+ try {
16
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
17
+ return isSequenceDefinition(parsed) ? parsed : null;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ function writeSequence(path, def) {
24
+ writeFileAtomic(path, JSON.stringify(def, null, 2) + "\n");
25
+ }
26
+ /** One line for set_project_file / get_project_file when the active document is an SQDF. */
27
+ export function sequenceSummary(path) {
28
+ const def = readSequence(path);
29
+ if (def) {
30
+ return `existing sequence '${def.settings?.name ?? ""}' (${def.participants.length} participants, ${def.messages.length} messages, ${(def.fragments ?? []).length} fragments)`;
31
+ }
32
+ return existsSync(path)
33
+ ? "file exists but is not a readable sequence definition"
34
+ : "no file yet — create_sequence or load_definition will write it";
35
+ }
36
+ const NOT_SEQUENCE = (path) => `The active document ${path} is ${KIND_WORDS[docKindOfPath(path)]}, not a sequence. Use create_sequence, or set_project_file to a .sqdf.json document.`;
37
+ /** A 1-based message address → the protocol's position. Throws an Error that says how to address one. */
38
+ function position(def, ref) {
39
+ const hit = parseMessageRef(def, String(ref).trim());
40
+ if (!hit) {
41
+ const count = def.messages.length;
42
+ throw new Error(`Message '${ref}' not found. A main message is its number (1 to ${count}); a fragment's message is f<fragment>.<n>, e.g. 'f1.2'.`);
43
+ }
44
+ return { fragment: hit.fragment, index: hit.index };
45
+ }
46
+ const describe = (m) => {
47
+ const extra = [
48
+ m.event && `fires ${m.event}`,
49
+ m.machine && `for ${m.machine}`,
50
+ m.carries && `carries ${m.carries}`,
51
+ m.state && `then ${m.state}`,
52
+ m.optional && "optional",
53
+ m.reply && "reply",
54
+ ].filter(Boolean);
55
+ return `${m.from} → ${m.to}: ${m.label}${extra.length ? ` (${extra.join(", ")})` : ""}`;
56
+ };
57
+ /** Read the active SQDF, apply `edit`, persist, mirror, reconcile, and report. Errors become tool errors. */
58
+ async function mutate(host, edit, report, opts = {}) {
59
+ const path = host.projectFile();
60
+ if (!isSqdfPath(path))
61
+ return fail(NOT_SEQUENCE(path));
62
+ const def = readSequence(path);
63
+ if (!def)
64
+ return fail(`No sequence at ${path}. Use create_sequence first.`);
65
+ let next;
66
+ try {
67
+ next = edit(def);
68
+ }
69
+ catch (e) {
70
+ return fail(message(e));
71
+ }
72
+ writeSequence(path, next);
73
+ host.emitFull(next);
74
+ let tail;
75
+ if (opts.reconcile !== false && host.afterEdit) {
76
+ tail = await host.afterEdit(path).catch((e) => `reconcile could not run: ${message(e)}`);
77
+ }
78
+ return ok(report(next, def) + (tail ? `\n${tail}` : ""));
79
+ }
80
+ // ─── The tools that answer by extension (called from server.ts) ──────
81
+ export function sequenceGetDefinition(host) {
82
+ const def = readSequence(host.projectFile());
83
+ return def ? ok(JSON.stringify(def, null, 2)) : fail(`No sequence at ${host.projectFile()}.`);
84
+ }
85
+ export async function sequenceLoadDefinition(host, json) {
86
+ let parsed;
87
+ try {
88
+ parsed = JSON.parse(json);
89
+ }
90
+ catch (e) {
91
+ return fail(`Invalid JSON: ${e}`);
92
+ }
93
+ if (!isSequenceDefinition(parsed)) {
94
+ return fail(`The active document ${host.projectFile()} is a sequence, and this JSON is not one (it needs "participants" and "messages" arrays, and no "state" or "entities"). ` +
95
+ `To load another kind of document, set_project_file to a document of that kind first.`);
96
+ }
97
+ const path = host.projectFile();
98
+ writeSequence(path, parsed);
99
+ host.emitFull(parsed);
100
+ const errors = validateSequence(parsed);
101
+ const tail = host.afterEdit ? await host.afterEdit(path).catch((e) => `reconcile could not run: ${message(e)}`) : undefined;
102
+ return ok(`Loaded sequence → ${path}: ${parsed.settings?.name ?? ""} (${parsed.participants.length} participants, ${parsed.messages.length} messages)` +
103
+ (errors.length ? `\n${errors.map((e) => `[${e.ruleId}] ${e.message}`).join("\n")}` : "") +
104
+ (tail ? `\n${tail}` : ""));
105
+ }
106
+ export function sequenceRender(host, opts) {
107
+ const doc = host.projectFile();
108
+ const def = readSequence(doc);
109
+ if (!def)
110
+ return fail(`No sequence at ${doc}.`);
111
+ const format = opts.format ?? "mermaid";
112
+ if (format !== "mermaid") {
113
+ return fail(`A sequence renders as mermaid only (asked for ${format}); svg and png are not drawn for a sequence yet.`);
114
+ }
115
+ if (opts.path) {
116
+ const denial = host.outsideRoot(opts.path);
117
+ if (denial)
118
+ return fail(denial);
119
+ }
120
+ // `.seq.mmd`, so a machine, an ERD and a sequence that share a basename never overwrite each other.
121
+ const out = opts.path
122
+ ? resolve(opts.path)
123
+ : opts.stamp
124
+ ? stampedOutputPath(doc, def.settings?.name, "mermaid", new Date())
125
+ : resolve(doc).replace(/\.sqdf\.json$/i, "") + ".seq.mmd";
126
+ const text = renderMermaidSequence(def);
127
+ writeFileSync(out, text + "\n", "utf8");
128
+ return {
129
+ content: [
130
+ { type: "text", text: `Rendered sequence '${def.settings?.name ?? ""}' → ${out} (${text.length} bytes)` },
131
+ { type: "text", text },
132
+ ],
133
+ };
134
+ }
135
+ function sequenceNotes(def) {
136
+ const out = [];
137
+ if (def.settings?.notes?.trim())
138
+ out.push({ target: null, notes: def.settings.notes });
139
+ for (const p of def.participants ?? [])
140
+ if (p?.notes?.trim())
141
+ out.push({ target: p.name, notes: p.notes });
142
+ for (const { ref, message: m } of allMessages(def))
143
+ if (m?.notes?.trim())
144
+ out.push({ target: `message ${ref}`, notes: m.notes });
145
+ (def.fragments ?? []).forEach((f, i) => {
146
+ if (f?.notes?.trim())
147
+ out.push({ target: `fragment ${i + 1}`, notes: f.notes });
148
+ });
149
+ return out;
150
+ }
151
+ export function sequenceGetNotes(host) {
152
+ const def = readSequence(host.projectFile());
153
+ if (!def)
154
+ return fail(`No sequence at ${host.projectFile()}.`);
155
+ return ok(formatNotes(def.settings?.name ?? "", host.projectFile(), sequenceNotes(def)));
156
+ }
157
+ /**
158
+ * `target` is a participant's name, a message address (`9`, `f1.2`, or `message 9`
159
+ * as get_notes prints it), or `fragment N`. Omitted: the whole sequence.
160
+ */
161
+ export function sequenceSetNotes(host, target, notes) {
162
+ let what = "the sequence";
163
+ return mutate(host, (def) => {
164
+ if (!target)
165
+ return updateSqdSettings(def, { notes });
166
+ if (def.participants.some((p) => p.name === target)) {
167
+ what = `participant '${target}'`;
168
+ return updateParticipant(def, target, { notes });
169
+ }
170
+ const frag = /^fragment\s*:?\s*(\d+)$/i.exec(target.trim());
171
+ if (frag) {
172
+ what = `fragment ${frag[1]}`;
173
+ return updateFragment(def, Number(frag[1]) - 1, { notes });
174
+ }
175
+ const ref = target.trim().replace(/^message\s*:?\s*/i, "");
176
+ if (parseMessageRef(def, ref)) {
177
+ const { fragment, index } = position(def, ref);
178
+ what = `message ${ref}`;
179
+ return updateMessage(def, index, { notes }, fragment);
180
+ }
181
+ throw new Error(`'${target}' is not a participant, a message (9, f1.2) or a fragment (fragment 1) of '${def.settings?.name ?? ""}'.`);
182
+ }, () => `${notes.trim() ? "Saved" : "Cleared"} the notes on ${what}.`, { reconcile: false });
183
+ }
184
+ // ─── Tool registration ───────────────────────────────────────────────
185
+ const messageFields = {
186
+ from: z.string().describe("The participant that sends it"),
187
+ to: z.string().describe("The participant that receives it"),
188
+ label: z.string().describe("What is said or done, in a few words"),
189
+ event: z.string().optional().describe("An event of a member machine that this message fires"),
190
+ machine: z.string().optional().describe("settings.name of the machine the event is for, when several define it"),
191
+ carries: z.string().optional().describe("An ERD entity this message carries"),
192
+ state: z.string().optional().describe("The state the machine is in once this message is handled"),
193
+ optional: z.boolean().optional().describe("The scenario holds with or without this message"),
194
+ reply: z.boolean().optional().describe("A reply to an earlier message (drawn dashed)"),
195
+ description: z.string().optional(),
196
+ };
197
+ /** Drop `false` booleans, so `optional: false` removes the key instead of writing it. */
198
+ function messageOf(fields) {
199
+ const m = { ...fields };
200
+ if (m.optional === false)
201
+ delete m.optional;
202
+ if (m.reply === false)
203
+ delete m.reply;
204
+ return m;
205
+ }
206
+ export function registerSequenceTools(server, host) {
207
+ server.tool("create_sequence", "Create a new sequence definition (.sqdf.json) and make it the active document. A sequence is a usage scenario: who sends what to whom, in order. Its messages can fire events of the system's machines, carry its entities, and say which state a machine is then in, so a scenario can be replayed through the machines (replay_scenario) and can upgrade them (reconcile_scenario). Without `path` it lands next to the active document as <name>.sqdf.json. Refuses to replace a sequence that already has messages unless overwrite is true.", {
208
+ namespace: z.string(),
209
+ name: z.string(),
210
+ description: z.string().optional(),
211
+ path: z.string().optional(),
212
+ overwrite: z.boolean().optional(),
213
+ }, async ({ namespace, name, description, path, overwrite }) => {
214
+ const active = host.projectFile();
215
+ const target = resolve(path ?? (isSqdfPath(active) ? active : join(dirname(active), `${name}.sqdf.json`)));
216
+ if (!isSqdfPath(target))
217
+ return fail(`A sequence document ends in .sqdf.json (got '${target}').`);
218
+ const existing = readSequence(target);
219
+ if (existing && existing.messages.length > 0 && !overwrite) {
220
+ return fail(`${target} already holds sequence '${existing.settings?.name ?? ""}' with ${existing.messages.length} messages. ` +
221
+ `Pass overwrite: true to replace it, or set_project_file to it to keep working on it.`);
222
+ }
223
+ if (target !== active) {
224
+ const denial = host.switchTo(target);
225
+ if (denial)
226
+ return fail(denial);
227
+ }
228
+ else {
229
+ const denial = host.outsideRoot(target);
230
+ if (denial)
231
+ return fail(denial);
232
+ }
233
+ const def = emptySequence(namespace, name, description);
234
+ writeSequence(target, def);
235
+ host.emitFull(def);
236
+ return ok(`Created sequence '${name}' in namespace '${namespace}' → ${target}. It is now the active document. Add participants, then messages.`);
237
+ });
238
+ server.tool("add_participant", "Add a participant to the active sequence. Say what it is with at most one of: `actor` (an actor of the system — a person or an agent), `service` (a running service, as free text), `object` (a machine's object instance, settings.objects[].instance). `holds` lists the ERD entities it keeps. `at` is its 1-based position from the left; the end when omitted.", {
239
+ name: z.string(),
240
+ actor: z.string().optional(),
241
+ service: z.string().optional(),
242
+ object: z.string().optional(),
243
+ holds: z.array(z.string()).optional(),
244
+ description: z.string().optional(),
245
+ at: z.number().int().min(1).optional(),
246
+ }, async ({ at, ...participant }) => mutate(host, (def) => addParticipant(def, participant, at === undefined ? undefined : at - 1), (def) => `Added participant '${participant.name}' (${def.participants.findIndex((p) => p.name === participant.name) + 1} of ${def.participants.length}).`));
247
+ server.tool("update_participant", "Change what a participant of the active sequence is. Omit a field to leave it alone; an empty string (or an empty holds list) clears it. The name is how messages address it and does not change here.", {
248
+ name: z.string(),
249
+ actor: z.string().optional(),
250
+ service: z.string().optional(),
251
+ object: z.string().optional(),
252
+ holds: z.array(z.string()).optional(),
253
+ description: z.string().optional(),
254
+ }, async ({ name, ...patch }) => mutate(host, (def) => updateParticipant(def, name, patch), () => `Updated participant '${name}'.`));
255
+ server.tool("remove_participant", "Remove a participant from the active sequence, with every message it sends or receives (a fragment left empty goes too).", { name: z.string() }, async ({ name }) => mutate(host, (def) => removeParticipant(def, name), (def, before) => `Removed participant '${name}' and ${allMessages(before).length - allMessages(def).length} message(s) of theirs.`));
256
+ server.tool("rename_participant", "Rename a participant of the active sequence. Every message and fragment message it sends or receives follows the new name.", { name: z.string(), to: z.string() }, async ({ name, to }) => mutate(host, (def) => renameParticipant(def, name, to), (def, before) => {
257
+ const n = allMessages(before).filter(({ message: m }) => m.from === name || m.to === name).length;
258
+ return name === to ? `Participant '${name}' already has that name.` : `Renamed participant '${name}' to '${to}' in ${n} message(s).`;
259
+ }));
260
+ server.tool("move_participant", "Move a participant of the active sequence to another column, counted from 1 on the left. Messages are untouched.", { name: z.string(), to: z.number().int().min(1) }, async ({ name, to }) => mutate(host, (def) => moveParticipant(def, name, to - 1), (def) => `Moved participant '${name}' to column ${def.participants.findIndex((p) => p.name === name) + 1} of ${def.participants.length}.`));
261
+ server.tool("add_message", "Add a main message to the active sequence. `event` fires an event of a member machine (`machine` says which, when several define it); `state` says which state the machine is in once the message is handled — a scenario ahead of its machines is how new behaviour is designed, and reconcile_scenario (or reconcile mode auto) adds what it implies. `at` is the 1-based number the message will have; the end when omitted. Fragments keep the message they branch after.", { ...messageFields, at: z.number().int().min(1).optional() }, async ({ at, ...fields }) => {
262
+ const m = messageOf(fields);
263
+ return mutate(host, (def) => addMessage(def, m, at === undefined ? undefined : at - 1), (def) => {
264
+ const n = at === undefined ? def.messages.length : Math.min(at, def.messages.length);
265
+ return `Added message ${n}: ${describe(def.messages[n - 1])}.`;
266
+ });
267
+ });
268
+ server.tool("update_message", "Change a message of the active sequence, addressed by `ref`: its number ('9') or, inside a fragment, f<fragment>.<n> ('f1.2'). Omit a field to leave it alone; an empty string clears it, and optional:false or reply:false removes the flag.", {
269
+ ref: z.string(),
270
+ from: z.string().optional(),
271
+ to: z.string().optional(),
272
+ label: z.string().optional(),
273
+ event: z.string().optional(),
274
+ machine: z.string().optional(),
275
+ carries: z.string().optional(),
276
+ state: z.string().optional(),
277
+ optional: z.boolean().optional(),
278
+ reply: z.boolean().optional(),
279
+ description: z.string().optional(),
280
+ }, async ({ ref, ...fields }) => {
281
+ // A field the caller omitted is absent from `fields`; `""` or an `undefined` flag clears it.
282
+ const patch = { ...fields };
283
+ if (patch.optional === false)
284
+ patch.optional = undefined;
285
+ if (patch.reply === false)
286
+ patch.reply = undefined;
287
+ return mutate(host, (def) => {
288
+ const { fragment, index } = position(def, ref);
289
+ return updateMessage(def, index, patch, fragment);
290
+ }, (def) => `Updated message ${ref}: ${describe(parseMessageRef(def, ref).message)}.`);
291
+ });
292
+ server.tool("remove_message", "Remove a message of the active sequence, addressed by its number ('9') or f<fragment>.<n> ('f1.2'). The main messages after it move up by one; a fragment branching after it moves to the message before.", { ref: z.string() }, async ({ ref }) => {
293
+ let removed = "";
294
+ return mutate(host, (def) => {
295
+ const { fragment, index } = position(def, ref);
296
+ removed = describe(parseMessageRef(def, ref).message);
297
+ return removeMessage(def, index, fragment);
298
+ }, () => `Removed message ${ref}: ${removed}.`);
299
+ });
300
+ server.tool("move_message", "Move a main message of the active sequence to another number (both counted from 1). Fragments keep the message they branch after.", { ref: z.string(), to: z.number().int().min(1) }, async ({ ref, to }) => mutate(host, (def) => {
301
+ const { fragment, index } = position(def, ref);
302
+ if (fragment !== null)
303
+ throw new Error(`Only main messages move; ${ref} is inside fragment ${fragment + 1}.`);
304
+ return moveMessage(def, index, to - 1);
305
+ }, (def) => {
306
+ const n = Math.min(to, def.messages.length);
307
+ return `Moved message ${ref} to ${n}: ${describe(def.messages[n - 1])}.`;
308
+ }));
309
+ server.tool("add_fragment", `Add a fragment — a part of the scenario that happens only sometimes — to the active sequence. kind is one of ${SQD_FRAGMENT_KINDS.join(", ")}: alt replaces the main messages after \`after\`; opt may happen there and the main messages continue; loop repeats there. \`after\` is the number of the main message it branches after (0: before the first). \`label\` is the condition in words. Messages can be given here or added with add_fragment_message.`, {
310
+ kind: z.enum(SQD_FRAGMENT_KINDS),
311
+ label: z.string(),
312
+ after: z.number().int().min(0),
313
+ messages: z.array(z.object(messageFields)).optional(),
314
+ }, async ({ kind, label, after, messages }) => mutate(host, (def) => addFragment(def, { kind, label, after, messages: (messages ?? []).map(messageOf) }), (def) => `Added fragment ${def.fragments.length}: ${kind} after message ${after} — ${label} (${messages?.length ?? 0} message(s)).`));
315
+ server.tool("add_fragment_message", "Add a message inside a fragment of the active sequence. `fragment` is the fragment's number (from 1); `at` is the 1-based position inside it, the end when omitted.", { fragment: z.number().int().min(1), ...messageFields, at: z.number().int().min(1).optional() }, async ({ fragment, at, ...fields }) => {
316
+ const m = messageOf(fields);
317
+ return mutate(host, (def) => addFragmentMessage(def, fragment - 1, m, at === undefined ? undefined : at - 1), (def) => {
318
+ const list = def.fragments[fragment - 1].messages;
319
+ const n = at === undefined ? list.length : Math.min(at, list.length);
320
+ return `Added message f${fragment}.${n}: ${describe(list[n - 1])}.`;
321
+ });
322
+ });
323
+ server.tool("remove_fragment", "Remove a fragment of the active sequence, with its messages. `fragment` is its number, from 1; the fragments after it move up by one.", { fragment: z.number().int().min(1) }, async ({ fragment }) => mutate(host, (def) => removeFragment(def, fragment - 1), () => `Removed fragment ${fragment}.`));
324
+ server.tool("validate_sequence", "Validate the active sequence against rules S001–S006 (settings, participants, message ends and labels, fragments) and list what it holds. Whether the events, objects and entities it names exist is a question about the system: check_system answers it.", {}, async () => {
325
+ const path = host.projectFile();
326
+ if (!isSqdfPath(path))
327
+ return fail(NOT_SEQUENCE(path));
328
+ const def = readSequence(path);
329
+ if (!def)
330
+ return fail(`No sequence at ${path}. Use create_sequence first.`);
331
+ const errors = validateSequence(def);
332
+ const body = summarizeSequence(def) || "(empty)";
333
+ return errors.length
334
+ ? fail(`${errors.length} problem(s) in '${def.settings?.name ?? ""}':\n${errors.map((e) => `[${e.ruleId}] ${e.message}`).join("\n")}\n\n${body}`)
335
+ : ok(`Sequence '${def.settings?.name ?? ""}' is valid.\n${body}`);
336
+ });
337
+ }
package/dist/server.d.ts CHANGED
@@ -18,15 +18,34 @@
18
18
  * - load_definition: Load a definition from JSON
19
19
  * - list_states: List all states in the current definition
20
20
  * - list_events: List all events in the current definition
21
- * - set_project_file: Choose which document is active (.smdf.json or .erdf.json)
21
+ * - set_project_file: Choose which document is active — .smdf.json (state
22
+ * machine), .erdf.json (ERD), .sqdf.json (sequence) or .sysdf.json (system)
22
23
  * - get_project_file: Report the active document path and bridge status
23
24
  *
24
- * ERD tools (Spec 80, ./erd.ts) — the active document's type is its extension:
25
+ * The active document's type is its extension, and the tools for each type
26
+ * refuse to write over another type.
27
+ *
28
+ * ERD tools (Spec 80, ./erd.ts):
25
29
  * - create_erd, add_entity, update_entity, add_attribute, add_relationship
26
30
  * - remove_entity, remove_attribute, remove_relationship
27
31
  * - validate_erd, check_links
28
- * get_definition, load_definition and render_diagram answer for an ERD when
29
- * the active document is one.
32
+ *
33
+ * Sequence tools (Spec 81, ./sequence.ts) — a usage scenario:
34
+ * - create_sequence, add_participant, update_participant, remove_participant
35
+ * - add_message, update_message, remove_message, move_message
36
+ * - add_fragment, add_fragment_message, remove_fragment, validate_sequence
37
+ *
38
+ * System tools (Spec 82, ./system.ts) — the drawings of one thing, together:
39
+ * - create_system, add_member, remove_member, add_actor, remove_actor
40
+ * - set_reconcile_mode, check_system, replay_scenario, reconcile_scenario
41
+ * - show: tell the canvases on a system which member and element to show
42
+ * In reconcile mode auto, each sequence edit also updates the machines, the
43
+ * ERD and the system it implies (./live.ts pushes the changes to their rooms).
44
+ *
45
+ * Notes (every document type): set_notes, get_notes — what a person or an
46
+ * agent wrote down about the diagram or one of its shapes, saved in the file.
47
+ * get_definition, load_definition, render_diagram and check_links answer for
48
+ * the active document's type; generate_rispec also writes a system's rispec.
30
49
  *
31
50
  * Resources:
32
51
  * - smcraft://definition — Current state machine definition