@miadi/stateloom-mcp 0.3.5 → 0.4.1

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 CHANGED
@@ -139,6 +139,25 @@ state-machine tools refuse to write a machine over it.
139
139
 
140
140
  Format and rules: [Spec 80 — ERDF](https://github.com/jgwill/smcraft/blob/main/rispecs/80-erdf-format.spec.md).
141
141
 
142
+ ### The stage and the marks
143
+
144
+ The agent shows its work on the person's canvas and hears what they point at
145
+ ([Spec 83](https://github.com/jgwill/smcraft/blob/main/rispecs/83-stage-and-marks.spec.md)).
146
+ Needs the hub (`STATELOOM_BRIDGE_URL`); the canvas and this server on one stage
147
+ (`STATELOOM_STAGE`, default `default`).
148
+
149
+ | Tool | What it does |
150
+ |---|---|
151
+ | `show_step` | One step now: open a drawing, select `focus`, light `spotlight` while the rest dims, frame `region`, show `say` as a caption |
152
+ | `write_tour` | A walkthrough as `<name>.tour.json`, played back by the canvas with its own controls (T001–T009) |
153
+ | `play_tour` | Hand a tour to the canvases, from a step, playing or paused |
154
+ | `stage_control` | play, pause, next, prev, goto, stop |
155
+ | `read_marks` | What is on the person's screen, and the marks waiting — notes on elements or an area of a drawing, kept in `<drawing>.marks.json` |
156
+ | `await_marks` | Wait for the person's next mark or reply |
157
+ | `answer_mark` | Reply in a mark's thread; `resolve`; `show` lights what it is about with the answer as the caption |
158
+ | `add_mark` | Leave a question pinned on elements of a drawing |
159
+ | `what_is_on_screen` | What the person's canvas shows right now |
160
+
142
161
  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.
143
162
 
144
163
  ## What a session looks like
package/dist/live.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type PatchOp, type ViewEnvelope } from "@miadi/stateloom-protocol";
1
+ import { type MarksEnvelope, type PatchOp, type StageControlEnvelope, type StageLoadEnvelope, type StageState, type StageStepEnvelope, type ViewEnvelope } from "@miadi/stateloom-protocol";
2
2
  export interface LiveOutcome {
3
3
  ok: boolean;
4
4
  text: string;
@@ -14,6 +14,27 @@ export interface Live {
14
14
  view(view: Omit<ViewEnvelope, "origin"> & {
15
15
  origin?: string;
16
16
  }): Promise<LiveOutcome>;
17
+ /** One step to a stage (Spec 83). */
18
+ step?(env: Omit<StageStepEnvelope, "origin"> & {
19
+ origin?: string;
20
+ }): Promise<LiveOutcome>;
21
+ /** A whole tour to a stage. */
22
+ load?(env: Omit<StageLoadEnvelope, "origin"> & {
23
+ origin?: string;
24
+ }): Promise<LiveOutcome>;
25
+ control?(env: Omit<StageControlEnvelope, "origin"> & {
26
+ origin?: string;
27
+ }): Promise<LiveOutcome>;
28
+ /** A drawing's marks changed: its room and a stage. The sidecar is already written. */
29
+ marks?(env: Omit<MarksEnvelope, "origin"> & {
30
+ origin?: string;
31
+ }): Promise<LiveOutcome>;
32
+ /** What the hub keeps of a stage: the step on screen, the tour, what the person is looking at. */
33
+ stageState?(stage: string): Promise<{
34
+ ok: boolean;
35
+ text: string;
36
+ state?: StageState;
37
+ }>;
17
38
  }
18
39
  export interface LiveTarget {
19
40
  /** The hub. Undefined: every push reports that the bridge is not configured. */
package/dist/live.js CHANGED
@@ -104,9 +104,53 @@ export function liveBridge(target) {
104
104
  client.disconnect();
105
105
  }
106
106
  }
107
+ /**
108
+ * A frame the hub acks no answer to (a step, a tour, a control, marks): send
109
+ * it from a short-lived client, and wait a moment for a refusal before
110
+ * calling it delivered — as a view is sent.
111
+ */
112
+ async function oneShot(what, emit) {
113
+ if (!target.url)
114
+ return unconfigured;
115
+ const client = open(`stage:${what}`);
116
+ const refused = refusal(client);
117
+ try {
118
+ await withTimeout(Promise.race([emit(client), refused.promise]), ms, "reaching the hub");
119
+ await Promise.race([new Promise((r) => setTimeout(r, Math.min(ms, 250))), refused.promise]);
120
+ return { ok: true, text: `${what} sent to the hub` };
121
+ }
122
+ catch (e) {
123
+ return { ok: false, text: e instanceof Refused ? `the hub refused the ${what}: ${e.message}` : `not sent: ${message(e)}` };
124
+ }
125
+ finally {
126
+ refused.off();
127
+ client.disconnect();
128
+ }
129
+ }
107
130
  return {
108
131
  patch: (docId, ops, def, mtime) => send(docId, def, "patch", (c) => c.emitPatch(ops, mtime)),
109
132
  full: (docId, def, mtime) => send(docId, def, "document", (c) => c.emitFull(def, mtime)),
133
+ step: (env) => oneShot("step", (c) => c.emitStep({ ...env, origin: env.origin ?? target.name ?? "agent" })),
134
+ load: (env) => oneShot("tour", (c) => c.emitLoad({ ...env, origin: env.origin ?? target.name ?? "agent" })),
135
+ control: (env) => oneShot("control", (c) => c.emitControl({ ...env, origin: env.origin ?? target.name ?? "agent" })),
136
+ marks: (env) => oneShot("marks", (c) => c.emitMarks({ ...env, origin: env.origin ?? target.name ?? "agent" })),
137
+ async stageState(stage) {
138
+ if (!target.url)
139
+ return unconfigured;
140
+ const client = open(`stage:${stage}`);
141
+ const refused = refusal(client);
142
+ try {
143
+ const joined = await withTimeout(Promise.race([client.joinStage(stage), refused.promise]), ms, "joining the stage");
144
+ return { ok: true, text: "read from the hub", state: joined.state };
145
+ }
146
+ catch (e) {
147
+ return { ok: false, text: e instanceof Refused ? `the hub refused: ${e.message}` : `not read: ${message(e)}` };
148
+ }
149
+ finally {
150
+ refused.off();
151
+ client.disconnect();
152
+ }
153
+ },
110
154
  async view(view) {
111
155
  if (!target.url)
112
156
  return unconfigured;
package/dist/server.js CHANGED
@@ -71,6 +71,7 @@ import { resolveProjectSwitch } from "./projectSwitch.js";
71
71
  import { registerErdTools, erdSummary, erdGetDefinition, erdLoadDefinition, erdRender, erdGetNotes, erdSetNotes, formatNotes, } from "./erd.js";
72
72
  import { registerSequenceTools, sequenceSummary, sequenceGetDefinition, sequenceLoadDefinition, sequenceRender, sequenceGetNotes, sequenceSetNotes, } from "./sequence.js";
73
73
  import { registerSystemTools, checkSystemReport, reconcileAfterEdit, reconcileModeLine, systemSummary, systemGetDefinition, systemLoadDefinition, systemRender, systemGetNotes, systemSetNotes, systemRispec, } from "./system.js";
74
+ import { registerStageTools } from "./stage.js";
74
75
  import { liveBridge } from "./live.js";
75
76
  import { writeFileAtomic } from "./atomicWrite.js";
76
77
  // STATELOOM_* read first, SMCRAFT_* legacy twin honored — the live MCP
@@ -82,8 +83,11 @@ const BRIDGE_TOKEN = envAlias("BRIDGE_TOKEN");
82
83
  // The canvas address for a document (chart_1785683055671): when the agent
83
84
  // re-points the loom, the human gets a link to LOOK at the same document —
84
85
  // the `?doc=` parameter is resolved and allowlist-guarded by the web side.
86
+ // With the system that lists it, the link opens the drawing as its member, so
87
+ // the system strip leads back to the map. The canvas looks the system up when
88
+ // the link has none, but only near the drawing; the link names one it would miss.
85
89
  const CANVAS_URL = envAlias("CANVAS_URL") ?? `http://127.0.0.1:${envAlias("WEB_PORT") ?? "4598"}`;
86
- const canvasLink = (path) => `${CANVAS_URL}/?doc=${encodeURIComponent(path)}`;
90
+ const canvasLink = (path, system) => `${CANVAS_URL}/?doc=${encodeURIComponent(path)}${system ? `&system=${encodeURIComponent(system)}` : ""}`;
87
91
  // ─── File-backed shared store ────────────────────────────────────────
88
92
  // Both web/ and mcp/ resolve the same path from STATELOOM_PROJECT_FILE
89
93
  // (legacy twin SMCRAFT_PROJECT_FILE still honored).
@@ -269,7 +273,7 @@ const erdHost = {
269
273
  };
270
274
  // Pushes to rooms other than the active document's: a reconciled machine, an
271
275
  // ERD, a view on a system. Each is a short-lived client of its own (./live.ts).
272
- const live = liveBridge({ url: BRIDGE_URL, token: BRIDGE_TOKEN, name: AGENT_NAME ?? "mcp-agent" });
276
+ const live = liveBridge({ url: BRIDGE_URL, token: BRIDGE_TOKEN, name: AGENT_NAME || "mcp-agent" });
273
277
  // The system this server last created, set or checked — where `show` and the
274
278
  // system tools go when neither an argument nor the active document names one.
275
279
  let LAST_SYSTEM = isSysdfPath(PROJECT_FILE) ? PROJECT_FILE : undefined;
@@ -1241,6 +1245,21 @@ function buildServer() {
1241
1245
  registerErdTools(server, erdHost);
1242
1246
  registerSequenceTools(server, sequenceHost);
1243
1247
  registerSystemTools(server, systemHost);
1248
+ // The stage and the marks (Spec 83): the agent shows, the person points back.
1249
+ const stageHost = {
1250
+ projectFile: () => PROJECT_FILE,
1251
+ outsideRoot: (path) => outsideRoot(path),
1252
+ live,
1253
+ // `||`, not `??`: a plugin's .mcp.json passes an unset variable as "".
1254
+ stageName: () => envAlias("STAGE") || "default",
1255
+ agent: () => {
1256
+ const name = AGENT_NAME || "agent";
1257
+ return { id: name.toLowerCase().replace(/[^a-z0-9._-]+/g, "-"), name, role: "agent" };
1258
+ },
1259
+ lastSystem: () => LAST_SYSTEM,
1260
+ canvasUrl: (doc, system) => canvasLink(doc, system),
1261
+ };
1262
+ registerStageTools(server, stageHost);
1244
1263
  // Notes — the same two tools for either document type.
1245
1264
  server.tool("set_notes", "Save working notes on the active diagram or on one of its shapes — what was said about it, an open question, a decision — so whoever opens the document next (a person or an agent) finds them. Omit `target` for the whole diagram; otherwise `target` is a state name (state machine), an entity name (ERD), a participant name, a message ('9', 'f1.2') or 'fragment N' (sequence), or a member's path (system). The text replaces what was there; an empty string clears it. Notes are kept in the document and are not part of the model: engines and code generation ignore them.", { target: z.string().optional(), notes: z.string() }, async ({ target, notes }) => {
1246
1265
  if (isErdfPath(PROJECT_FILE))
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The stage and the marks in the MCP server (Spec 83).
3
+ *
4
+ * An agent shows its work on the canvases a person has open:
5
+ *
6
+ * - `show_step` one step now, while it talks: open a drawing, light some
7
+ * of it, say a sentence beside it.
8
+ * - `write_tour` a walkthrough as a file (`.tour.json`), so it can be
9
+ * played again — by the person later, by the next instance.
10
+ * - `play_tour` hand a tour to the canvases; they play it with their own
11
+ * controls (and voice, when the person turned it on).
12
+ * - `stage_control` play, pause, next, prev, goto, stop.
13
+ *
14
+ * And hears what the person points at:
15
+ *
16
+ * - `read_marks` what is on the person's screen, and the marks waiting —
17
+ * each says which elements of which drawing it is about.
18
+ * Read it before replying to the person.
19
+ * - `await_marks` wait for the person's next mark or reply.
20
+ * - `answer_mark` reply in a mark's thread (and show what it is about).
21
+ * - `add_mark` leave a question on an element for the person.
22
+ *
23
+ * Marks live beside the drawing they are on (`<drawing>.marks.json`); this
24
+ * server writes the sidecar first and tells the hub after, as every edit.
25
+ * Nothing here changes a drawing.
26
+ */
27
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
28
+ import { type MarkAuthor } from "@miadi/stateloom-protocol";
29
+ import type { Live } from "./live.js";
30
+ export interface StageHost {
31
+ /** The active document's absolute path. */
32
+ projectFile(): string;
33
+ outsideRoot(path: string): string | undefined;
34
+ live: Live;
35
+ /** STATELOOM_STAGE, else `default`. */
36
+ stageName(): string;
37
+ /** Who this agent is on a mark: STATELOOM_AGENT_NAME, else `agent`. */
38
+ agent(): MarkAuthor;
39
+ /** The system this server last created, set or checked. */
40
+ lastSystem(): string | undefined;
41
+ /** A canvas link for a drawing, for the person; inside `system` when given. */
42
+ canvasUrl(doc: string, system?: string): string;
43
+ }
44
+ export declare function registerStageTools(server: McpServer, host: StageHost): void;
package/dist/stage.js ADDED
@@ -0,0 +1,468 @@
1
+ import { z } from "zod";
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "fs";
3
+ import { basename, dirname, join, resolve } from "path";
4
+ import { MARK_KINDS, STAGE_ACTIONS, addMark, formatContext, formatReview, isStageName, isSysdfPath, isTourDefinition, isTourPath, marksPathOf, marksWaitingOn, markAnchorText, readMarksFile, relativeMemberPath, replyToMark, resolveMark, resolveMemberPath, resolveStepPaths, resolveTourPaths, summarizeTour, validateStep, validateTour, } from "@miadi/stateloom-protocol";
5
+ import { writeFileAtomic } from "./atomicWrite.js";
6
+ import { readSystem } from "./system.js";
7
+ const ok = (text) => ({ content: [{ type: "text", text }] });
8
+ const fail = (text) => ({ content: [{ type: "text", text }], isError: true });
9
+ const message = (e) => (e instanceof Error ? e.message : String(e));
10
+ // ─── Disk ────────────────────────────────────────────────────────────
11
+ function readMarks(doc) {
12
+ const path = marksPathOf(doc);
13
+ if (!existsSync(path))
14
+ return { doc: basename(doc), marks: [] };
15
+ return readMarksFile(JSON.parse(readFileSync(path, "utf8")), basename(doc));
16
+ }
17
+ function marksMtime(doc) {
18
+ try {
19
+ return statSync(marksPathOf(doc)).mtimeMs;
20
+ }
21
+ catch {
22
+ return 0;
23
+ }
24
+ }
25
+ function writeMarks(doc, file) {
26
+ writeFileAtomic(marksPathOf(doc), JSON.stringify(file, null, 2) + "\n");
27
+ }
28
+ function readTour(path) {
29
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
30
+ if (!isTourDefinition(parsed))
31
+ throw new Error(`${path} is not a tour (no steps).`);
32
+ return parsed;
33
+ }
34
+ function slug(name) {
35
+ return name.trim().replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "tour";
36
+ }
37
+ // ─── Which drawings ──────────────────────────────────────────────────
38
+ /**
39
+ * The drawings a question about marks is about: the one named; else the active
40
+ * document, and — when it is a system, or a member of the last system — every
41
+ * member of that system.
42
+ */
43
+ function scopeDocs(host, args) {
44
+ const out = [];
45
+ const add = (p) => {
46
+ const abs = resolve(p);
47
+ if (!out.includes(abs))
48
+ out.push(abs);
49
+ };
50
+ if (args.doc) {
51
+ const abs = resolve(args.doc);
52
+ const denial = host.outsideRoot(abs);
53
+ if (denial)
54
+ return { error: denial };
55
+ add(abs);
56
+ return out;
57
+ }
58
+ const active = host.projectFile();
59
+ const systems = [args.system, isSysdfPath(active) ? active : undefined, host.lastSystem()].filter((s) => !!s);
60
+ for (const sys of systems) {
61
+ const abs = resolve(sys);
62
+ if (host.outsideRoot(abs))
63
+ continue;
64
+ const def = readSystem(abs);
65
+ if (!def)
66
+ continue;
67
+ const members = def.members.map((m) => resolveMemberPath(abs, m.path));
68
+ // A remembered system counts only when it is the one the active document belongs to.
69
+ if (!args.system && abs !== active && !members.includes(active))
70
+ continue;
71
+ if (!isSysdfPath(active))
72
+ add(active);
73
+ for (const m of members)
74
+ if (!host.outsideRoot(m))
75
+ add(m);
76
+ return out;
77
+ }
78
+ add(active);
79
+ return out;
80
+ }
81
+ /** The system a step's drawing belongs to: the one named, else the active or last one that lists it. */
82
+ function systemFor(host, doc, named) {
83
+ if (named)
84
+ return resolve(named);
85
+ const active = host.projectFile();
86
+ for (const sys of [isSysdfPath(active) ? active : undefined, host.lastSystem()]) {
87
+ if (!sys)
88
+ continue;
89
+ const def = readSystem(sys);
90
+ if (def?.members.some((m) => resolveMemberPath(sys, m.path) === doc))
91
+ return sys;
92
+ }
93
+ // A system kept beside the drawing that lists it: the strip of its siblings stays on screen.
94
+ try {
95
+ for (const name of readdirSync(dirname(doc)).filter((f) => isSysdfPath(f)).sort()) {
96
+ const sys = join(dirname(doc), name);
97
+ if (readSystem(sys)?.members.some((m) => resolveMemberPath(sys, m.path) === doc))
98
+ return sys;
99
+ }
100
+ }
101
+ catch {
102
+ // No folder to read: the step opens the drawing alone.
103
+ }
104
+ return undefined;
105
+ }
106
+ // ─── Arguments ───────────────────────────────────────────────────────
107
+ const box = z.object({ x: z.number(), y: z.number(), width: z.number(), height: z.number() });
108
+ const stageArg = z.string().optional().describe("The stage to present on; default STATELOOM_STAGE, else 'default'");
109
+ const stepShape = {
110
+ say: z.string().describe("What you say at this step — one or two sentences, shown as the caption (read aloud when the person turns the voice on)"),
111
+ doc: z.string().optional().describe("The drawing to open: absolute, or relative to the active document's folder. Default: the active document"),
112
+ focus: z.string().optional().describe("One element to select and centre: <kind>:<name>, kind one of entity, attribute, machine, object, event, state, participant, message ('state:Pending', 'message:9', 'attribute:Order.status')"),
113
+ spotlight: z.array(z.string()).optional().describe("Elements to light while the rest of the drawing dims; the view fits them all. Same <kind>:<name> as focus"),
114
+ region: box.optional().describe("An area of the drawing, in its own coordinates, to frame and fit"),
115
+ title: z.string().optional().describe("A few words over the caption"),
116
+ system: z.string().optional().describe("Open the drawing as a member of this .sysdf.json, so the system strip stays. Default: the active or last system that lists the drawing"),
117
+ audio: z.string().optional().describe("A recording of `say` (an http(s) URL, or a path on the canvas's server) to play instead of the canvas's own voice — e.g. what the miadi-voice MCP rendered"),
118
+ holdMs: z.number().optional().describe("How long the step stays during play when it is not heard, in ms. Default: from the words"),
119
+ };
120
+ const tourStepShape = z.object({
121
+ ...stepShape,
122
+ doc: z.string().optional().describe("The drawing: absolute, or relative to the active document's folder (stored relative to the tour file). Absent: the previous step's drawing"),
123
+ });
124
+ // ─── The tools ───────────────────────────────────────────────────────
125
+ export function registerStageTools(server, host) {
126
+ let lastTour;
127
+ const stageOf = (arg) => {
128
+ const name = arg ?? host.stageName();
129
+ return isStageName(name) ? name : { error: `A stage name is letters, digits, '.', '_' or '-' (got '${name}').` };
130
+ };
131
+ const live = host.live;
132
+ const notLive = "not live: this server's bridge has no stage support";
133
+ server.tool("show_step", "Show the person one step on the canvas while you talk (Spec 83): open a drawing, light part of it, and say a sentence beside it. Use it to point at what you mean instead of describing it — the canvas opens the drawing, selects `focus`, dims everything but `spotlight`, frames `region`, and shows `say` as a caption (spoken aloud when the person turned the voice on). A step changes no document. A tour that was playing pauses under it. The person answers by marking what they see; read_marks reads it.", { ...stepShape, stage: stageArg }, async ({ stage, ...given }) => {
134
+ const st = stageOf(stage);
135
+ if (typeof st !== "string")
136
+ return fail(st.error);
137
+ const active = host.projectFile();
138
+ const doc = given.doc ? resolve(dirname(active), given.doc) : active;
139
+ const denial = host.outsideRoot(doc);
140
+ if (denial)
141
+ return fail(denial);
142
+ const step = { ...given, doc };
143
+ const system = systemFor(host, doc, given.system ? resolve(dirname(active), given.system) : undefined);
144
+ if (system)
145
+ step.system = system;
146
+ const problems = validateStep(step);
147
+ if (problems.length)
148
+ return fail(problems.map((p) => `${p.ruleId} ${p.message}`).join("\n"));
149
+ const missing = existsSync(doc) ? "" : ` (no file at ${doc} yet — the canvas will show it empty)`;
150
+ if (!live.step)
151
+ return fail(notLive);
152
+ const sent = await live.step({ stage: st, step });
153
+ const what = [basename(doc), step.focus, step.spotlight?.length ? `lighting ${step.spotlight.join(", ")}` : "", step.region ? "framing an area" : ""]
154
+ .filter(Boolean)
155
+ .join(" · ");
156
+ return sent.ok
157
+ ? ok(`On stage '${st}': ${what}${missing}. ${sent.text}. The person sees it at ${host.canvasUrl(doc, step.system)}.`)
158
+ : fail(`Could not show ${what} on stage '${st}': ${sent.text}.`);
159
+ });
160
+ server.tool("write_tour", "Write a walkthrough as a file (<name>.tour.json, Spec 83): an ordered list of steps — each a drawing, what to light in it, and what to say — that the canvas plays back with its own controls, like slides over the real drawings. Prepare one when the work needs explaining in an order, when the person will review it later, or when an unfinished session hands its design on. Step `doc` paths are given absolute or relative to the active document's folder, and stored relative to the tour file; a step without `doc` stays on the previous step's drawing. Without `path` the tour lands beside the active document. Checked T001–T009; play it with play_tour.", {
161
+ name: z.string(),
162
+ namespace: z.string().optional(),
163
+ description: z.string().optional(),
164
+ notes: z.string().optional().describe("Why this tour exists, for whoever plays it next"),
165
+ steps: z.array(tourStepShape).min(1),
166
+ path: z.string().optional(),
167
+ overwrite: z.boolean().optional(),
168
+ }, async ({ name, namespace, description, notes, steps, path, overwrite }) => {
169
+ const active = host.projectFile();
170
+ const target = resolve(dirname(active), path ?? `${slug(name)}.tour.json`);
171
+ if (!isTourPath(target))
172
+ return fail(`A tour file ends in .tour.json (got '${target}').`);
173
+ const denial = host.outsideRoot(target);
174
+ if (denial)
175
+ return fail(denial);
176
+ if (existsSync(target) && !overwrite) {
177
+ return fail(`${target} already exists. Pass overwrite: true to replace it, or another path.`);
178
+ }
179
+ // Paths as the caller gave them are relative to the active document's
180
+ // folder; stored relative to the tour file (they share a folder unless
181
+ // `path` says otherwise).
182
+ const stored = steps.map((s) => {
183
+ const abs = s.doc ? resolve(dirname(active), s.doc) : undefined;
184
+ const sys = s.system ? resolve(dirname(active), s.system) : undefined;
185
+ const { doc: _d, system: _s, ...rest } = s;
186
+ return {
187
+ ...rest,
188
+ ...(abs ? { doc: relativeMemberPath(target, abs) } : {}),
189
+ ...(sys ? { system: relativeMemberPath(target, sys) } : {}),
190
+ };
191
+ });
192
+ const def = {
193
+ settings: {
194
+ namespace: namespace ?? "",
195
+ name,
196
+ ...(description ? { description } : {}),
197
+ ...(notes ? { notes } : {}),
198
+ author: host.agent().name,
199
+ },
200
+ steps: stored,
201
+ };
202
+ const problems = validateTour(def);
203
+ if (problems.length)
204
+ return fail(problems.map((p) => `${p.ruleId}${p.step !== undefined ? ` step ${p.step + 1}` : ""}: ${p.message}`).join("\n"));
205
+ const absent = resolveTourPaths(def, target)
206
+ .steps.map((s, i) => (s.doc && !existsSync(s.doc) ? `step ${i + 1}: no file at ${s.doc}` : ""))
207
+ .filter(Boolean);
208
+ for (const s of resolveTourPaths(def, target).steps) {
209
+ if (s.doc && host.outsideRoot(s.doc))
210
+ return fail(host.outsideRoot(s.doc));
211
+ }
212
+ writeFileAtomic(target, JSON.stringify(def, null, 2) + "\n");
213
+ lastTour = target;
214
+ return ok(`Wrote ${target}.\n${summarizeTour(def)}${absent.length ? `\nNot there yet: ${absent.join("; ")}.` : ""}\nPlay it with play_tour.`);
215
+ });
216
+ server.tool("play_tour", "Hand a tour (.tour.json) to the canvases on the stage (Spec 83). They open each step's drawing, light it, show the words, and move on by themselves when `autoplay` (default true) — or wait for the person's ▶. The person can step back and forth; stage_control moves it from here. With no `tour`: the one this server wrote last, else the only .tour.json beside the active document.", {
217
+ tour: z.string().optional(),
218
+ from: z.number().int().min(1).optional().describe("The step to start at, counted from 1"),
219
+ autoplay: z.boolean().optional(),
220
+ stage: stageArg,
221
+ }, async ({ tour, from, autoplay, stage }) => {
222
+ const st = stageOf(stage);
223
+ if (typeof st !== "string")
224
+ return fail(st.error);
225
+ const active = host.projectFile();
226
+ let path = tour ? resolve(dirname(active), tour) : lastTour;
227
+ if (!path) {
228
+ const beside = readdirSync(dirname(active)).filter((f) => isTourPath(f));
229
+ if (beside.length === 1)
230
+ path = join(dirname(active), beside[0]);
231
+ else
232
+ return fail(beside.length ? `Several tours beside ${basename(active)}: ${beside.join(", ")}. Name one with tour:.` : `No tour given and none beside ${basename(active)}. Write one with write_tour.`);
233
+ }
234
+ const denial = host.outsideRoot(path);
235
+ if (denial)
236
+ return fail(denial);
237
+ let def;
238
+ try {
239
+ def = readTour(path);
240
+ }
241
+ catch (e) {
242
+ return fail(message(e));
243
+ }
244
+ const problems = validateTour(def);
245
+ if (problems.length)
246
+ return fail(`${path} does not check:\n` + problems.map((p) => `${p.ruleId}${p.step !== undefined ? ` step ${p.step + 1}` : ""}: ${p.message}`).join("\n"));
247
+ if (!live.load)
248
+ return fail(notLive);
249
+ const start = Math.min(def.steps.length, from ?? 1) - 1;
250
+ // Each drawing opens inside the system that lists it, so its siblings stay in reach.
251
+ const resolved = resolveTourPaths(def, path);
252
+ const placed = {
253
+ ...resolved,
254
+ steps: resolved.steps.map((s) => {
255
+ if (s.system || !s.doc)
256
+ return s;
257
+ const system = systemFor(host, s.doc);
258
+ return system ? { ...s, system } : s;
259
+ }),
260
+ };
261
+ const sent = await live.load({ stage: st, tour: placed, path, from: start, autoplay: autoplay !== false });
262
+ lastTour = path;
263
+ return sent.ok
264
+ ? ok(`Playing '${def.settings.name}' (${def.steps.length} steps) on stage '${st}' from step ${start + 1}${autoplay === false ? ", paused" : ""}: ${sent.text}.`)
265
+ : fail(`Could not play ${path}: ${sent.text}.`);
266
+ });
267
+ server.tool("stage_control", `Move what the canvases on the stage are playing: ${STAGE_ACTIONS.join(", ")}. goto takes index, counted from 1. stop puts the tour away and clears the caption.`, {
268
+ action: z.enum(STAGE_ACTIONS),
269
+ index: z.number().int().min(1).optional(),
270
+ stage: stageArg,
271
+ }, async ({ action, index, stage }) => {
272
+ const st = stageOf(stage);
273
+ if (typeof st !== "string")
274
+ return fail(st.error);
275
+ if (action === "goto" && index === undefined)
276
+ return fail("goto needs index (counted from 1).");
277
+ if (!live.control)
278
+ return fail(notLive);
279
+ const sent = await live.control({ stage: st, action, ...(index !== undefined ? { index: index - 1 } : {}) });
280
+ return sent.ok ? ok(`${action}${index ? ` ${index}` : ""} on stage '${st}': ${sent.text}.`) : fail(`Could not ${action}: ${sent.text}.`);
281
+ });
282
+ async function contextOf(stage) {
283
+ if (!live.stageState)
284
+ return { ctx: null, note: "" };
285
+ const r = await live.stageState(stage);
286
+ if (!r.ok)
287
+ return { ctx: null, note: ` (what is on screen is unknown: ${r.text})` };
288
+ return { ctx: r.state?.context ?? null, note: "" };
289
+ }
290
+ function gather(docs, all) {
291
+ const entries = [];
292
+ const errors = [];
293
+ for (const doc of docs) {
294
+ try {
295
+ const file = readMarks(doc);
296
+ const marks = all ? file.marks : marksWaitingOn(file.marks, "agent");
297
+ if (marks.length)
298
+ entries.push({ doc, marks });
299
+ }
300
+ catch (e) {
301
+ errors.push(`${marksPathOf(doc)}: ${message(e)}`);
302
+ }
303
+ }
304
+ return { entries, errors };
305
+ }
306
+ server.tool("read_marks", "What the person is pointing at (Spec 83): what their canvas shows right now (drawing, element in hand, tour step), then the marks waiting for you — each a note on some elements or an area of a drawing, with its thread. Read it before you reply to the person: their words are about what they marked. Default scope: the active document and, when it is a system or a member of one, every member. all: true lists every mark, answered and resolved too. Answer with answer_mark.", { doc: z.string().optional(), system: z.string().optional(), all: z.boolean().optional(), stage: stageArg }, async ({ doc, system, all, stage }) => {
307
+ const st = stageOf(stage);
308
+ if (typeof st !== "string")
309
+ return fail(st.error);
310
+ const docs = scopeDocs(host, { doc, system });
311
+ if (!Array.isArray(docs))
312
+ return fail(docs.error);
313
+ const { ctx, note } = await contextOf(st);
314
+ const { entries, errors } = gather(docs, !!all);
315
+ const scope = docs.length === 1 ? basename(docs[0]) : `${docs.length} drawings (${docs.map((d) => basename(d)).join(", ")})`;
316
+ const text = formatReview(ctx, entries) +
317
+ note +
318
+ `\nScope: ${scope}${all ? ", every mark" : ", marks waiting for you"}.` +
319
+ (entries.length ? `\nDrawings: ${entries.map((e) => e.doc).join(", ")}` : "") +
320
+ (errors.length ? `\nUnreadable: ${errors.join("; ")}` : "");
321
+ return ok(text);
322
+ });
323
+ server.tool("await_marks", "Wait for the person to mark something or reply in a thread (Spec 83), then return what they said with what is on their screen. Use it after showing work when the next move is theirs. Returns at the first new mark or reply waiting for you in scope (as read_marks), or after timeoutSeconds (default 120, at most 600) with what is waiting already. For a session that must wake on a person's mark without holding a tool call, run `smcx marks await` as a background task instead.", { timeoutSeconds: z.number().min(1).max(600).optional(), doc: z.string().optional(), system: z.string().optional(), stage: stageArg }, async ({ timeoutSeconds, doc, system, stage }) => {
324
+ const st = stageOf(stage);
325
+ if (typeof st !== "string")
326
+ return fail(st.error);
327
+ const docs = scopeDocs(host, { doc, system });
328
+ if (!Array.isArray(docs))
329
+ return fail(docs.error);
330
+ const seen = new Map();
331
+ const keyOf = (m) => `${m.id}:${m.replies?.length ?? 0}:${m.status}`;
332
+ for (const { doc: d, marks } of gather(docs, false).entries)
333
+ for (const m of marks)
334
+ seen.set(`${d}#${m.id}`, keyOf(m));
335
+ const mtimes = new Map(docs.map((d) => [d, marksMtime(d)]));
336
+ const deadline = Date.now() + (timeoutSeconds ?? 120) * 1000;
337
+ while (Date.now() < deadline) {
338
+ await new Promise((r) => setTimeout(r, 1000));
339
+ const changed = docs.filter((d) => marksMtime(d) !== mtimes.get(d));
340
+ if (!changed.length)
341
+ continue;
342
+ for (const d of changed)
343
+ mtimes.set(d, marksMtime(d));
344
+ const fresh = [];
345
+ for (const { doc: d, marks } of gather(changed, false).entries) {
346
+ const news = marks.filter((m) => seen.get(`${d}#${m.id}`) !== keyOf(m));
347
+ if (news.length)
348
+ fresh.push({ doc: d, marks: news });
349
+ }
350
+ if (fresh.length) {
351
+ const { ctx } = await contextOf(st);
352
+ return ok(`The person marked something.\n${formatReview(ctx, fresh)}\nAnswer with answer_mark (id, text); show_step points at what you mean.`);
353
+ }
354
+ }
355
+ const { ctx, note } = await contextOf(st);
356
+ const waiting = gather(docs, false).entries;
357
+ return ok(`No new mark in ${timeoutSeconds ?? 120}s.${note}\n${formatReview(ctx, waiting)}`);
358
+ });
359
+ /** Find a mark by id in the scope; the drawing it is on. */
360
+ function findMark(id, docs) {
361
+ for (const d of docs) {
362
+ try {
363
+ const file = readMarks(d);
364
+ const mark = file.marks.find((m) => m.id === id);
365
+ if (mark)
366
+ return { doc: d, file, mark };
367
+ }
368
+ catch {
369
+ // An unreadable sidecar holds no mark to answer.
370
+ }
371
+ }
372
+ return null;
373
+ }
374
+ server.tool("answer_mark", "Reply in a mark's thread (Spec 83): the person sees your words under their note, on the drawing, and the mark is answered. resolve: true closes it (text may then be empty). show: true also puts the mark's elements on stage with your words as the caption, so the person sees what you mean while they read it. `id` is the mark's id from read_marks (m-…).", {
375
+ id: z.string(),
376
+ text: z.string().optional(),
377
+ resolve: z.boolean().optional(),
378
+ show: z.boolean().optional(),
379
+ doc: z.string().optional(),
380
+ system: z.string().optional(),
381
+ stage: stageArg,
382
+ }, async ({ id, text, resolve: close, show, doc, system, stage }) => {
383
+ const st = stageOf(stage);
384
+ if (typeof st !== "string")
385
+ return fail(st.error);
386
+ if (!text?.trim() && !close)
387
+ return fail("Say something (text), or pass resolve: true to close the mark.");
388
+ const docs = scopeDocs(host, { doc, system });
389
+ if (!Array.isArray(docs))
390
+ return fail(docs.error);
391
+ const found = findMark(id, docs);
392
+ if (!found)
393
+ return fail(`No mark '${id}' in ${docs.map((d) => basename(d)).join(", ")}. read_marks lists them (all: true for answered ones).`);
394
+ const at = new Date().toISOString();
395
+ let next = found.file;
396
+ try {
397
+ if (text?.trim())
398
+ next = replyToMark(next, id, { author: host.agent(), text }, at);
399
+ if (close)
400
+ next = resolveMark(next, id);
401
+ }
402
+ catch (e) {
403
+ return fail(message(e));
404
+ }
405
+ const denial = host.outsideRoot(marksPathOf(found.doc));
406
+ if (denial)
407
+ return fail(denial);
408
+ writeMarks(found.doc, next);
409
+ const mark = next.marks.find((m) => m.id === id);
410
+ const told = live.marks
411
+ ? await live.marks({ docId: found.doc, marks: next.marks, change: { action: text?.trim() ? "reply" : "resolve", id }, stage: st })
412
+ : { ok: false, text: notLive };
413
+ let shown = "";
414
+ if (show && text?.trim() && live.step) {
415
+ const step = resolveStepPaths({
416
+ doc: found.doc,
417
+ say: text,
418
+ title: `Re: ${mark.text.slice(0, 60)}`,
419
+ ...(mark.elements?.length ? { spotlight: mark.elements } : {}),
420
+ ...(mark.region ? { region: mark.region } : {}),
421
+ }, found.doc);
422
+ const sys = systemFor(host, found.doc);
423
+ if (sys)
424
+ step.system = sys;
425
+ const r = await live.step({ stage: st, step });
426
+ shown = r.ok ? " Shown on stage." : ` Not shown: ${r.text}.`;
427
+ }
428
+ return ok(`${text?.trim() ? "Answered" : "Resolved"} ${id} on ${basename(found.doc)} (${markAnchorText(mark)}), now ${mark.status}. Canvas: ${told.ok ? told.text : `not told live (${told.text}); it reads the sidecar on its next look`}.${shown}`);
429
+ });
430
+ server.tool("add_mark", `Leave a mark for the person on some elements or an area of a drawing (Spec 83) — a question you need them to answer, a decision to confirm, a thing to look at — pinned where it applies. kind is one of ${MARK_KINDS.join(", ")} (default question). Their reply arrives in the thread; await_marks waits for it.`, {
431
+ text: z.string(),
432
+ elements: z.array(z.string()).optional().describe("<kind>:<name> of each element it is about"),
433
+ region: box.optional(),
434
+ kind: z.enum(MARK_KINDS).optional(),
435
+ doc: z.string().optional(),
436
+ stage: stageArg,
437
+ }, async ({ text, elements, region, kind, doc, stage }) => {
438
+ const st = stageOf(stage);
439
+ if (typeof st !== "string")
440
+ return fail(st.error);
441
+ const active = host.projectFile();
442
+ const target = doc ? resolve(dirname(active), doc) : active;
443
+ const denial = host.outsideRoot(target) ?? host.outsideRoot(marksPathOf(target));
444
+ if (denial)
445
+ return fail(denial);
446
+ let next;
447
+ try {
448
+ next = addMark(readMarks(target), { text, elements, region, kind: kind ?? "question", author: host.agent() }, new Date().toISOString());
449
+ }
450
+ catch (e) {
451
+ return fail(message(e));
452
+ }
453
+ writeMarks(target, next);
454
+ const mark = next.marks[next.marks.length - 1];
455
+ const told = live.marks
456
+ ? await live.marks({ docId: target, marks: next.marks, change: { action: "add", id: mark.id }, stage: st })
457
+ : { ok: false, text: notLive };
458
+ return ok(`Marked ${markAnchorText(mark)} on ${basename(target)} as ${mark.kind} (${mark.id}). Canvas: ${told.ok ? told.text : `not told live (${told.text})`}.`);
459
+ });
460
+ // Present for agents that ask what the person sees without wanting the marks.
461
+ server.tool("what_is_on_screen", "What the person's canvas on the stage shows right now: the drawing, the element in hand, the visible area, where a tour stands (Spec 83). One line; read_marks adds the marks.", { stage: stageArg }, async ({ stage }) => {
462
+ const st = stageOf(stage);
463
+ if (typeof st !== "string")
464
+ return fail(st.error);
465
+ const { ctx, note } = await contextOf(st);
466
+ return ok(formatContext(ctx) + note);
467
+ });
468
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miadi/stateloom-mcp",
3
- "version": "0.3.5",
3
+ "version": "0.4.1",
4
4
  "description": "MCP server for miadi-stateloom (formerly smcraft-mcp) — lets LLM agents design state machines through conversation",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,9 +38,9 @@
38
38
  "node": ">=20"
39
39
  },
40
40
  "dependencies": {
41
- "@miadi/stateloom-cli": "^0.1.4",
42
- "@miadi/stateloom-client": "^0.1.3",
43
- "@miadi/stateloom-protocol": "^0.1.14",
41
+ "@miadi/stateloom-cli": "^0.1.7",
42
+ "@miadi/stateloom-client": "^0.1.4",
43
+ "@miadi/stateloom-protocol": "^0.1.16",
44
44
  "@modelcontextprotocol/sdk": "^1.12.1",
45
45
  "zod": "^4.3.6"
46
46
  },