slidemind 0.3.13 → 0.3.14
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +86 -21
- package/README.md +14 -17
- package/bin/slidemind.mjs +65 -4969
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +113 -74
- package/skills/search/SKILL.md +25 -7
- package/src/bridge/build-and-push.mjs +108 -15
- package/src/bridge/bundle.mjs +69 -0
- package/src/bridge/capture.mjs +19 -0
- package/src/bridge/client/brands.mjs +622 -0
- package/src/bridge/client/engine-routing.mjs +237 -0
- package/src/bridge/client/engine-search.mjs +310 -0
- package/src/bridge/client/engine-slides.mjs +435 -0
- package/src/bridge/client/pane-ops.mjs +335 -0
- package/src/bridge/client/presentations.mjs +587 -0
- package/src/bridge/client/render.mjs +317 -0
- package/src/bridge/client/transport.mjs +695 -0
- package/src/bridge/client.mjs +80 -3452
- package/src/bridge/config.mjs +1 -1
- package/src/bridge/engine-download.mjs +15 -2
- package/src/builders/build.mjs +11 -804
- package/src/builders/draft-check.mjs +376 -0
- package/src/builders/lint.mjs +38 -2
- package/src/builders/preview.html +799 -0
- package/src/builders/review.mjs +23 -4
- package/src/builders/visual-families.mjs +6 -8
- package/src/cli/common.mjs +179 -0
- package/src/cli/concepts.mjs +310 -0
- package/src/cli/connection.mjs +147 -0
- package/src/cli/deck.mjs +608 -0
- package/src/cli/designs.mjs +113 -0
- package/src/cli/diagnose.mjs +141 -0
- package/src/cli/download-bundle.mjs +144 -0
- package/src/cli/engine-helpers.mjs +429 -0
- package/src/cli/engine-results.mjs +320 -0
- package/src/cli/help.mjs +201 -0
- package/src/cli/install.mjs +257 -0
- package/src/cli/read.mjs +120 -0
- package/src/cli/ready.mjs +805 -0
- package/src/cli/search.mjs +608 -0
- package/src/cli/session.mjs +177 -0
- package/src/cli/setup.mjs +282 -0
- package/src/cli/slide-reuse.mjs +334 -0
- package/src/cli/status.mjs +118 -0
- package/src/cli/workspace.mjs +302 -0
- package/src/installers/codex.mjs +1 -1
- package/src/update-check.mjs +231 -0
package/src/builders/review.mjs
CHANGED
|
@@ -46,6 +46,7 @@ import path from "node:path";
|
|
|
46
46
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
47
47
|
|
|
48
48
|
import { buildDeck, openInBrowser } from "./build.mjs";
|
|
49
|
+
import { draftCheckBlocker, verdictBlocker } from "./draft-check.mjs";
|
|
49
50
|
import { resolveSkillForDeck } from "./resolve-skill.mjs";
|
|
50
51
|
import { resolveBrandAsset } from "../bridge/bundle.mjs";
|
|
51
52
|
import {
|
|
@@ -502,9 +503,25 @@ export async function runReview({
|
|
|
502
503
|
// quiet: serveAndOpen's BROWSER: line owns all open/failed messaging.
|
|
503
504
|
open = (url) => openInBrowser(url, { quiet: true }),
|
|
504
505
|
openFailedGraceMs = OPEN_FAILED_GRACE_MS,
|
|
506
|
+
allowDraftDefects = false,
|
|
505
507
|
} = {}) {
|
|
506
508
|
await buildDeck(deckDir);
|
|
507
509
|
|
|
510
|
+
// The gate does not open on defects the last draft check MEASURED (fill
|
|
511
|
+
// bands, dropped connectors, unused assets) while the slide files are
|
|
512
|
+
// unchanged since: a warning printed after "task done" is a warning nobody
|
|
513
|
+
// acts on. Editing the slide re-arms the check; --allow-draft-defects is
|
|
514
|
+
// the explicit override.
|
|
515
|
+
if (!allowDraftDefects) {
|
|
516
|
+
const standing = draftCheckBlocker(deckDir) ?? verdictBlocker(deckDir);
|
|
517
|
+
if (standing) {
|
|
518
|
+
throw new Error(
|
|
519
|
+
`review not opened — ${standing}\n` +
|
|
520
|
+
"(pass --allow-draft-defects to open the gate anyway)",
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
508
525
|
// Headless simulation runs (scripts/codex-sim.mjs): approve without a browser.
|
|
509
526
|
// buildDeck still ran above, so lint/build errors surface before the approve.
|
|
510
527
|
if (process.env.SLIDEMIND_GATE_AUTOAPPROVE === "1") {
|
|
@@ -613,17 +630,19 @@ export async function runReview({
|
|
|
613
630
|
function parseArgs(argv) {
|
|
614
631
|
// port null ⇒ derive the deterministic per-deck port; an explicit --port
|
|
615
632
|
// (incl. --port 0 for OS-random) overrides it.
|
|
616
|
-
const args = { deck: null, port: null };
|
|
633
|
+
const args = { deck: null, port: null, allowDraftDefects: false };
|
|
617
634
|
for (let i = 0; i < argv.length; i++) {
|
|
618
635
|
const a = argv[i];
|
|
619
636
|
if (a === "--deck" && i + 1 < argv.length) {
|
|
620
637
|
args.deck = argv[++i];
|
|
621
638
|
} else if (a === "--port" && i + 1 < argv.length) {
|
|
622
639
|
args.port = parseInt(argv[++i], 10) || 0;
|
|
640
|
+
} else if (a === "--allow-draft-defects") {
|
|
641
|
+
args.allowDraftDefects = true;
|
|
623
642
|
} else if (a === "-h" || a === "--help") {
|
|
624
643
|
process.stdout.write(
|
|
625
|
-
"Usage: slidemind review <deck> [--port <n>]\n" +
|
|
626
|
-
" node src/builders/review.mjs --deck <deck> [--port <n>]\n",
|
|
644
|
+
"Usage: slidemind review <deck> [--port <n>] [--allow-draft-defects]\n" +
|
|
645
|
+
" node src/builders/review.mjs --deck <deck> [--port <n>] [--allow-draft-defects]\n",
|
|
627
646
|
);
|
|
628
647
|
process.exit(0);
|
|
629
648
|
} else {
|
|
@@ -641,7 +660,7 @@ function parseArgs(argv) {
|
|
|
641
660
|
async function main() {
|
|
642
661
|
const args = parseArgs(process.argv.slice(2));
|
|
643
662
|
const deckDir = path.resolve(args.deck);
|
|
644
|
-
await runReview({ deckDir, port: args.port });
|
|
663
|
+
await runReview({ deckDir, port: args.port, allowDraftDefects: args.allowDraftDefects });
|
|
645
664
|
}
|
|
646
665
|
|
|
647
666
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
@@ -18,7 +18,7 @@ export function plannedSlideNumbers(planMarkdown) {
|
|
|
18
18
|
return [...slides].sort((a, b) => a - b);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export function conceptGenerationWaves(planMarkdown
|
|
21
|
+
export function conceptGenerationWaves(planMarkdown) {
|
|
22
22
|
const groups = new Map();
|
|
23
23
|
for (const segment of splitPlanMarkdownIntoSegments(planMarkdown)) {
|
|
24
24
|
if (segment.type !== "storyboard" || !segment.data) continue;
|
|
@@ -35,14 +35,12 @@ export function conceptGenerationWaves(planMarkdown, slideCount) {
|
|
|
35
35
|
for (const slide of members.slice(1)) variants.add(slide);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
//
|
|
39
|
-
//
|
|
38
|
+
// Every slide the plan names, slide 1 included. The server derives its own
|
|
39
|
+
// beat list from the SAME storyboard-canvas blocks, so there is nothing a
|
|
40
|
+
// request to it could tell us about the deck's size that this does not.
|
|
40
41
|
const planned = plannedSlideNumbers(planMarkdown);
|
|
41
|
-
const remaining = planned.length
|
|
42
|
-
? planned.filter((slide) => slide !== 1)
|
|
43
|
-
: Array.from({ length: Math.max(0, slideCount - 1) }, (_, i) => i + 2);
|
|
44
42
|
return {
|
|
45
|
-
firstWave:
|
|
46
|
-
variants:
|
|
43
|
+
firstWave: planned.filter((slide) => !variants.has(slide)),
|
|
44
|
+
variants: planned.filter((slide) => variants.has(slide)),
|
|
47
45
|
};
|
|
48
46
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Shared plumbing for the slidemind CLI command modules (src/cli/*.mjs).
|
|
2
|
+
// Split out of bin/slidemind.mjs verbatim; bin stays the dispatcher.
|
|
3
|
+
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = path.dirname(__filename);
|
|
11
|
+
export const PACKAGE_ROOT = path.resolve(__dirname, "..", "..");
|
|
12
|
+
export const SRC_DIR = path.join(PACKAGE_ROOT, "src");
|
|
13
|
+
export const BUILDERS_DIR = path.join(SRC_DIR, "builders");
|
|
14
|
+
export const INSTALLERS_DIR = path.join(SRC_DIR, "installers");
|
|
15
|
+
// The CLI entry point, for commands that re-spawn the CLI (setup → ready).
|
|
16
|
+
export const BIN_PATH = path.join(PACKAGE_ROOT, "bin", "slidemind.mjs");
|
|
17
|
+
|
|
18
|
+
export function readPackageJson() {
|
|
19
|
+
return JSON.parse(
|
|
20
|
+
fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8"),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function fail(msg, code = 1) {
|
|
25
|
+
process.stderr.write(`slidemind: ${msg}\n`);
|
|
26
|
+
process.exit(code);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The two human checkmarks the CLI enforces itself, so no harness timeout,
|
|
30
|
+
// context loss, or agent rationalisation can walk past a gate: nothing after
|
|
31
|
+
// the plan gate (concepts, build, view-draft, review) runs on an unapproved or
|
|
32
|
+
// since-edited plan, and nothing ships (push, build-pptx) from a preview the
|
|
33
|
+
// user has not approved exactly as it stands. Both fast-fail before any
|
|
34
|
+
// bridge call with the command that clears them.
|
|
35
|
+
export async function requirePlanApproved(deckDir) {
|
|
36
|
+
const plan = await import(pathToFileURL(path.join(BUILDERS_DIR, "plan.mjs")).href);
|
|
37
|
+
const why = plan.requirePlanApproved(deckDir);
|
|
38
|
+
if (why) fail(why);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function requireReviewApproved(deckDir) {
|
|
42
|
+
const review = await import(pathToFileURL(path.join(BUILDERS_DIR, "review.mjs")).href);
|
|
43
|
+
const why = review.requireReviewApproved(deckDir);
|
|
44
|
+
if (why) fail(why);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A render that stalls on "Multiple PowerPoint windows are open" is, for an
|
|
48
|
+
// UNBOUND deck, really a binding problem: bound, the deck renders pane-free
|
|
49
|
+
// and never asks. Name that fix first — the window pick is the workaround.
|
|
50
|
+
export function withUnboundHint(deckDir, err) {
|
|
51
|
+
const msg = err?.message ?? String(err);
|
|
52
|
+
if (!/Multiple PowerPoint windows are open/.test(msg)) return msg;
|
|
53
|
+
if (fs.existsSync(path.join(deckDir, ".bundle-ref.json"))) return msg;
|
|
54
|
+
const slug = path.basename(deckDir);
|
|
55
|
+
return (
|
|
56
|
+
`deck ${slug} is not bound to a corporate design, so the render has to go through an open window. ` +
|
|
57
|
+
`Bind it once and no window is needed: \`slidemind ready ${slug}\` (or \`--brand <presFP>\` to name the design). ` +
|
|
58
|
+
`Or pick a window for this run:\n${msg.replace(/^[^\n]*\n/, "")}`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The local PowerPoint files `read` and `add-slide --from` accept host-free.
|
|
63
|
+
// All are OPC (zip) OOXML packages with the same ppt/presentation.xml +
|
|
64
|
+
// ppt/slides/ structure — presentation (.pptx/.pptm), template (.potx/.potm),
|
|
65
|
+
// slideshow (.ppsx/.ppsm) — so the src/pptx/ reader/swapper handle them
|
|
66
|
+
// identically. Presentation IDs are opaque and never carry these extensions,
|
|
67
|
+
// so this stays a safe discriminator for "positional is a local file, not a
|
|
68
|
+
// presId".
|
|
69
|
+
export const LOCAL_PPTX_RE = /\.(pptx|pptm|potx|potm|ppsx|ppsm)$/i;
|
|
70
|
+
|
|
71
|
+
export function resolveDecksDir() {
|
|
72
|
+
if (process.env.SLIDEMIND_DECKS) {
|
|
73
|
+
return path.resolve(process.env.SLIDEMIND_DECKS);
|
|
74
|
+
}
|
|
75
|
+
return path.resolve(process.cwd(), "decks");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function resolveDeckArg(arg) {
|
|
79
|
+
// A user can pass either "decks/foo" (relative to CWD), "/abs/path/foo",
|
|
80
|
+
// or just "foo" — in which case we look it up under the resolved decks dir.
|
|
81
|
+
if (!arg) return null;
|
|
82
|
+
if (path.isAbsolute(arg)) return arg;
|
|
83
|
+
if (fs.existsSync(path.resolve(process.cwd(), arg))) {
|
|
84
|
+
return path.resolve(process.cwd(), arg);
|
|
85
|
+
}
|
|
86
|
+
const decksDir = resolveDecksDir();
|
|
87
|
+
return path.join(decksDir, arg);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function runNode(script, args) {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const child = spawn(process.execPath, [script, ...args], {
|
|
93
|
+
stdio: "inherit",
|
|
94
|
+
env: process.env,
|
|
95
|
+
});
|
|
96
|
+
child.on("error", reject);
|
|
97
|
+
child.on("exit", (code) => {
|
|
98
|
+
if (code === 0) resolve();
|
|
99
|
+
else reject(new Error(`${path.basename(script)} exited with code ${code}`));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function splitDeckArg(rest, cmd) {
|
|
105
|
+
// The first non-flag positional is the deck. Everything else passes
|
|
106
|
+
// through to the underlying script verbatim.
|
|
107
|
+
const passthrough = [...rest];
|
|
108
|
+
let deck = null;
|
|
109
|
+
for (let i = 0; i < passthrough.length; i++) {
|
|
110
|
+
if (!passthrough[i].startsWith("-")) {
|
|
111
|
+
deck = passthrough.splice(i, 1)[0];
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!deck) fail(`usage: slidemind ${cmd} <deck>`);
|
|
116
|
+
return { deck, passthrough };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Live render progress for build-pptx/push (the job protocol streams it; the
|
|
120
|
+
// sync fallback emits nothing). One stderr line per state change — works in
|
|
121
|
+
// plain logs and TTYs alike.
|
|
122
|
+
export function renderProgressPrinter() {
|
|
123
|
+
let last = "";
|
|
124
|
+
let queuedAt = null;
|
|
125
|
+
return (p) => {
|
|
126
|
+
let line = null;
|
|
127
|
+
if (p.state === "queued") {
|
|
128
|
+
queuedAt ??= Date.now();
|
|
129
|
+
if (Date.now() - queuedAt > 5000) {
|
|
130
|
+
line = " render queued behind another render…";
|
|
131
|
+
}
|
|
132
|
+
} else if (p.state === "restarted") {
|
|
133
|
+
line = " render restarted (backend replica was replaced)…";
|
|
134
|
+
} else if (p.stage === "assembling") {
|
|
135
|
+
line = " assembling deck…";
|
|
136
|
+
} else if (p.stage === "rendering" && typeof p.total === "number") {
|
|
137
|
+
line = ` rendering slide ${Math.min((p.done ?? 0) + 1, p.total)}/${p.total}…`;
|
|
138
|
+
}
|
|
139
|
+
if (line && line !== last) {
|
|
140
|
+
last = line;
|
|
141
|
+
process.stderr.write(`${line}\n`);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function dataUriToPng(dataUri) {
|
|
147
|
+
const m = /^data:image\/png;base64,(.+)$/.exec(dataUri || "");
|
|
148
|
+
return m ? Buffer.from(m[1], "base64") : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Keep the deck working against the design as it stands RIGHT NOW: every deck
|
|
152
|
+
// command re-syncs its bound design before it works, so a corporate design that
|
|
153
|
+
// changed mid-session is picked up instead of silently building against the
|
|
154
|
+
// copy `ready` fetched hours ago. The mechanics (etag gate, short timeout,
|
|
155
|
+
// never-throw) live in bundle.mjs; this only reports a design that moved.
|
|
156
|
+
export async function freshenDeckBundle(deckDir) {
|
|
157
|
+
const bundle = await import(
|
|
158
|
+
pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
|
|
159
|
+
);
|
|
160
|
+
const sync = await bundle.refreshDeckBundle(deckDir);
|
|
161
|
+
if (sync?.changed) {
|
|
162
|
+
process.stdout.write(
|
|
163
|
+
` design bundle updated (${sync.result.downloaded} file(s) changed) — building against the current corporate design\n`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return sync;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Strip the `data:<mime>;base64,` prefix off a data URI and return the raw
|
|
170
|
+
// base64 payload (or null when the URI isn't a base64 data URI we can decode).
|
|
171
|
+
export function base64FromDataUri(dataUri) {
|
|
172
|
+
if (typeof dataUri !== "string") return null;
|
|
173
|
+
const m = /^data:[^;,]*;base64,(.*)$/s.exec(dataUri.trim());
|
|
174
|
+
return m ? m[1] : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function loadBridgeModule(name) {
|
|
178
|
+
return import(pathToFileURL(path.join(SRC_DIR, "bridge", `${name}.mjs`)).href);
|
|
179
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { fail, requirePlanApproved, resolveDeckArg, freshenDeckBundle, base64FromDataUri, SRC_DIR, BUILDERS_DIR } from "./common.mjs";
|
|
5
|
+
|
|
6
|
+
export const REFERENCE_MIME = {
|
|
7
|
+
".png": "image/png",
|
|
8
|
+
".jpg": "image/jpeg",
|
|
9
|
+
".jpeg": "image/jpeg",
|
|
10
|
+
".webp": "image/webp",
|
|
11
|
+
".gif": "image/gif",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// `--concept <image>[@N,M]` / `--style <image>[@N,M]` → one inline reference
|
|
15
|
+
// `{ role, image, slides?, name }` for the concept-images route.
|
|
16
|
+
export function readReferenceArg(role, spec) {
|
|
17
|
+
if (!spec) fail(`--${role} expects <image>[@slides]`);
|
|
18
|
+
const at = spec.lastIndexOf("@");
|
|
19
|
+
const file = at > 0 ? spec.slice(0, at) : spec;
|
|
20
|
+
const slides =
|
|
21
|
+
at > 0
|
|
22
|
+
? spec
|
|
23
|
+
.slice(at + 1)
|
|
24
|
+
.split(",")
|
|
25
|
+
.map((n) => Number(n.trim()))
|
|
26
|
+
.filter((n) => Number.isInteger(n) && n > 0)
|
|
27
|
+
: [];
|
|
28
|
+
if (at > 0 && slides.length === 0) {
|
|
29
|
+
fail(`--${role} ${spec}: expected slide numbers after '@', e.g. sketch.png@2,3`);
|
|
30
|
+
}
|
|
31
|
+
const mime = REFERENCE_MIME[path.extname(file).toLowerCase()];
|
|
32
|
+
if (!mime) fail(`--${role} ${file}: expected a png/jpg/webp/gif image`);
|
|
33
|
+
let bytes;
|
|
34
|
+
try {
|
|
35
|
+
bytes = fs.readFileSync(file);
|
|
36
|
+
} catch {
|
|
37
|
+
fail(`--${role} ${file}: file not found`);
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
role,
|
|
41
|
+
image: `data:${mime};base64,${bytes.toString("base64")}`,
|
|
42
|
+
...(slides.length > 0 ? { slides } : {}),
|
|
43
|
+
name: path.basename(file),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function cmdConceptImages(rest) {
|
|
48
|
+
let deck = null;
|
|
49
|
+
let force = false;
|
|
50
|
+
let asJson = false;
|
|
51
|
+
const references = [];
|
|
52
|
+
for (let i = 0; i < rest.length; i++) {
|
|
53
|
+
const a = rest[i];
|
|
54
|
+
if (a === "--deck") {
|
|
55
|
+
deck = rest[++i];
|
|
56
|
+
if (!deck) fail("--deck expects a deck name or path");
|
|
57
|
+
} else if (a === "--force") {
|
|
58
|
+
force = true;
|
|
59
|
+
} else if (a === "--json") {
|
|
60
|
+
asJson = true;
|
|
61
|
+
} else if (a === "--concept" || a === "--style") {
|
|
62
|
+
references.push(readReferenceArg(a.slice(2), rest[++i]));
|
|
63
|
+
// A new user reference changes what the mockups should be.
|
|
64
|
+
force = true;
|
|
65
|
+
} else if (a.startsWith("-")) {
|
|
66
|
+
fail(`unknown flag: ${a}`);
|
|
67
|
+
} else if (deck === null) {
|
|
68
|
+
deck = a;
|
|
69
|
+
} else {
|
|
70
|
+
fail(`unexpected argument: ${a}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (!deck) fail("usage: slidemind concepts <deck>");
|
|
74
|
+
|
|
75
|
+
const deckDir = resolveDeckArg(deck);
|
|
76
|
+
const planPath = path.join(deckDir, "plan.md");
|
|
77
|
+
if (!fs.existsSync(planPath)) {
|
|
78
|
+
fail(
|
|
79
|
+
`no plan.md in ${path.relative(process.cwd(), deckDir) || deckDir}. ` +
|
|
80
|
+
`Run \`slidemind plan ${deck}\` to compose and sign off a plan first.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const planMarkdown = fs.readFileSync(planPath, "utf8");
|
|
84
|
+
// Every exit ends on ONE machine-readable line — `CONCEPTS: n/m …` — so a
|
|
85
|
+
// harness that cut the output short, and an agent reading a summary, can
|
|
86
|
+
// tell "generated", "none available" and "still running" apart without a
|
|
87
|
+
// double-evidence ritual. --json prints the same facts as a payload.
|
|
88
|
+
const finish = ({ paths = [], planned = 0, status, reason = null, missing = [], seeded = false, extra = "" }) => {
|
|
89
|
+
const conceptsDir = path.join(deckDir, "concepts");
|
|
90
|
+
if (asJson) {
|
|
91
|
+
process.stdout.write(
|
|
92
|
+
`${JSON.stringify({ deck: path.basename(deckDir), status, generated: paths.length, planned, paths, missing, reason, conceptsDir, assetPlan: path.join(deckDir, "asset-plan.md"), seededAssetPlan: seeded }, null, 2)}\n`,
|
|
93
|
+
);
|
|
94
|
+
} else {
|
|
95
|
+
if (paths.length) process.stdout.write(paths.join("\n") + "\n");
|
|
96
|
+
process.stdout.write(
|
|
97
|
+
`CONCEPTS: ${paths.length}/${planned} ${status}${reason ? ` — ${reason}` : ""}` +
|
|
98
|
+
(missing.length ? ` (no mockup for slide${missing.length > 1 ? "s" : ""} ${missing.join(", ")} — author from the storyboard)` : "") +
|
|
99
|
+
(paths.length ? ` — Read each PNG, then rebuild as slide JSX (workflow/create.md).` : "") +
|
|
100
|
+
`\n${extra}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
process.exit(0);
|
|
104
|
+
};
|
|
105
|
+
// Mockups are the first post-plan step, so this is where an unapproved or
|
|
106
|
+
// since-edited plan stops the agent — before any authoring, and before the
|
|
107
|
+
// fast path below re-prints mockups that would belong to a stale plan.
|
|
108
|
+
await requirePlanApproved(deckDir);
|
|
109
|
+
await freshenDeckBundle(deckDir);
|
|
110
|
+
|
|
111
|
+
// The slides the plan's storyboards name — the set every step below works
|
|
112
|
+
// against (fast-path completeness, request waves, pruning, the missing
|
|
113
|
+
// note). The server's slideCount is a progress figure only: it counts the
|
|
114
|
+
// beats IT parsed, and a beat it drops must not shrink the request range.
|
|
115
|
+
const families = await import(
|
|
116
|
+
pathToFileURL(path.join(BUILDERS_DIR, "visual-families.mjs")).href
|
|
117
|
+
);
|
|
118
|
+
const planned = new Set(families.plannedSlideNumbers(planMarkdown));
|
|
119
|
+
|
|
120
|
+
// Idempotent fast path: mockups already on disk are re-printed IMMEDIATELY.
|
|
121
|
+
// Generation takes 1-2 minutes and agent harnesses time commands out and
|
|
122
|
+
// show the model only the output-so-far — while the detached process still
|
|
123
|
+
// finishes writing the PNGs. The model's mandated retry then lands here and
|
|
124
|
+
// gets the full path list within any timeout, instead of racing generation
|
|
125
|
+
// again (measured: sessions that never saw the paths authored without the
|
|
126
|
+
// concepts — the single biggest fidelity loss). `--force` regenerates.
|
|
127
|
+
if (!force) {
|
|
128
|
+
const conceptsDirEarly = path.join(deckDir, "concepts");
|
|
129
|
+
let existing = [];
|
|
130
|
+
try {
|
|
131
|
+
existing = fs
|
|
132
|
+
.readdirSync(conceptsDirEarly)
|
|
133
|
+
.filter((f) => /^concept-slide-\d+\.png$/.test(f))
|
|
134
|
+
.sort();
|
|
135
|
+
} catch {}
|
|
136
|
+
// Only when the on-disk set EXACTLY matches the plan's slide numbering —
|
|
137
|
+
// a partial or stale set (leftover concept-slide-03.png on a 2-slide
|
|
138
|
+
// plan) falls through to regeneration, which prunes and refills.
|
|
139
|
+
const existingNums = new Set(
|
|
140
|
+
existing.map((f) => Number(f.match(/(\d+)/)[1])),
|
|
141
|
+
);
|
|
142
|
+
const matchesPlan =
|
|
143
|
+
planned.size > 0 &&
|
|
144
|
+
planned.size === existingNums.size &&
|
|
145
|
+
[...planned].every((n) => existingNums.has(n));
|
|
146
|
+
if (existing.length > 0 && matchesPlan) {
|
|
147
|
+
finish({
|
|
148
|
+
paths: existing.map((f) => path.join(conceptsDirEarly, f)),
|
|
149
|
+
planned: planned.size,
|
|
150
|
+
status: "on disk",
|
|
151
|
+
reason: "already generated for this plan (--force regenerates)",
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// No storyboards, nothing to generate: the server builds its beats from the
|
|
157
|
+
// same blocks, so a request would come back with zero images anyway.
|
|
158
|
+
if (planned.size === 0) {
|
|
159
|
+
finish({ status: "none", reason: "plan.md has no storyboard-canvas blocks (re-run `slidemind plan` to compose the storyboards)" });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const bundle = await import(
|
|
163
|
+
pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
|
|
164
|
+
);
|
|
165
|
+
const brandId = bundle.readDeckBundleRef(deckDir)?.presFP ?? null;
|
|
166
|
+
if (!brandId) {
|
|
167
|
+
fail(
|
|
168
|
+
`deck ${deck} is not bound to a corporate design (no .bundle-ref.json). ` +
|
|
169
|
+
`Run \`slidemind ready ${deck}\` to bind it first.`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const client = await import(
|
|
174
|
+
pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const skipNote = () =>
|
|
178
|
+
finish({ planned: planned.size, status: "unavailable", reason: "this add-in does not generate concept mockups; author from the storyboards" });
|
|
179
|
+
|
|
180
|
+
// Generation is silent for 1–2 min otherwise, and a harness that yields a
|
|
181
|
+
// long-running command early then hands the agent an empty output it reads
|
|
182
|
+
// as "no mockups". Say up front what is happening and what to wait for.
|
|
183
|
+
process.stderr.write(
|
|
184
|
+
`slidemind: generating concept mockups for ${deck} — this takes 1–2 minutes. ` +
|
|
185
|
+
`Wait for the concept-slide-NN.png paths below; an empty output so far means still running, ` +
|
|
186
|
+
`not "no mockups". Do not re-run this command while it is still running.\n`,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
// Pin the serving backend up front. This is a cached capabilities probe, not
|
|
190
|
+
// a generation — the deck's slide set comes from the plan's own storyboard
|
|
191
|
+
// canvases, which is the SAME source the server parses its beats from, so
|
|
192
|
+
// there is nothing left for a throwaway single-slide request to learn.
|
|
193
|
+
let backendURL;
|
|
194
|
+
try {
|
|
195
|
+
backendURL = await client.resolveConceptBackend();
|
|
196
|
+
} catch (err) {
|
|
197
|
+
if (err?.code === "concept_route_unavailable") skipNote();
|
|
198
|
+
throw err;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const slideCount = planned.size;
|
|
202
|
+
const images = [];
|
|
203
|
+
const collect = (res) => {
|
|
204
|
+
if (!Array.isArray(res?.images)) return;
|
|
205
|
+
for (const img of res.images) {
|
|
206
|
+
images.push(img);
|
|
207
|
+
process.stderr.write(
|
|
208
|
+
`slidemind: concept mockup ${images.length}/${slideCount} ready (slide ${img?.slideNumber})\n`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// Family variants depend on their lowest-numbered anchor. Run independent
|
|
214
|
+
// slides + anchors first, then variants; the server's per-family anchor cache
|
|
215
|
+
// (keyed by brand + CLI user) is what makes the anchor cross the two waves'
|
|
216
|
+
// separate requests.
|
|
217
|
+
const waves = families.conceptGenerationWaves(planMarkdown);
|
|
218
|
+
const CONCURRENCY = 3;
|
|
219
|
+
async function runWave(slides) {
|
|
220
|
+
let cursor = 0;
|
|
221
|
+
async function worker() {
|
|
222
|
+
for (;;) {
|
|
223
|
+
const idx = cursor++;
|
|
224
|
+
if (idx >= slides.length) return;
|
|
225
|
+
const n = slides[idx];
|
|
226
|
+
try {
|
|
227
|
+
const res = await client.generateConceptImagesForBrand(brandId, {
|
|
228
|
+
planMarkdown,
|
|
229
|
+
slideNumbers: [n],
|
|
230
|
+
references,
|
|
231
|
+
backendURL,
|
|
232
|
+
force,
|
|
233
|
+
});
|
|
234
|
+
collect(res);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
process.stderr.write(
|
|
237
|
+
`slidemind: concept mockup for slide ${n} failed: ${err?.message ?? err}\n`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
await Promise.all(
|
|
243
|
+
Array.from({ length: Math.min(CONCURRENCY, slides.length) }, () => worker()),
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
await runWave(waves.firstWave);
|
|
247
|
+
await runWave(waves.variants);
|
|
248
|
+
|
|
249
|
+
if (images.length === 0) {
|
|
250
|
+
finish({ planned: planned.size, status: "generated", reason: "the add-in returned no image for any slide (see the per-slide failures above); author from the storyboards" });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const conceptsDir = path.join(deckDir, "concepts");
|
|
254
|
+
fs.mkdirSync(conceptsDir, { recursive: true });
|
|
255
|
+
const written = [];
|
|
256
|
+
const writtenNums = new Set();
|
|
257
|
+
for (const img of images) {
|
|
258
|
+
const n = Number(img?.slideNumber);
|
|
259
|
+
if (!Number.isFinite(n) || n < 1) continue;
|
|
260
|
+
const b64 = base64FromDataUri(img?.dataUri);
|
|
261
|
+
if (!b64) continue;
|
|
262
|
+
const name = `concept-slide-${String(n).padStart(2, "0")}.png`;
|
|
263
|
+
const abs = path.join(conceptsDir, name);
|
|
264
|
+
fs.writeFileSync(abs, Buffer.from(b64, "base64"));
|
|
265
|
+
written.push(abs);
|
|
266
|
+
writtenNums.add(n);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Drop stale mockups from a previous, longer plan — any concept-slide-NN.png
|
|
270
|
+
// the plan no longer names (fallback: beyond the server's slideCount).
|
|
271
|
+
const isStale = (n) =>
|
|
272
|
+
planned.size > 0 ? !planned.has(n) : n > slideCount;
|
|
273
|
+
for (const entry of fs.readdirSync(conceptsDir)) {
|
|
274
|
+
const m = /^concept-slide-(\d+)\.png$/.exec(entry);
|
|
275
|
+
if (m && isStale(Number(m[1]))) {
|
|
276
|
+
fs.rmSync(path.join(conceptsDir, entry), { force: true });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// A planned slide the server returned nothing for is invisible otherwise —
|
|
280
|
+
// the agent would author it blind without knowing a mockup was expected.
|
|
281
|
+
const missing = [...planned].filter((n) => !writtenNums.has(n));
|
|
282
|
+
|
|
283
|
+
if (written.length === 0) {
|
|
284
|
+
finish({ planned: planned.size, status: "generated", reason: "the add-in returned images without usable bytes; author from the storyboards", missing });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
written.sort();
|
|
288
|
+
// The inventory form is seeded next to the mockups (never over a filled
|
|
289
|
+
// one): the organism/connector list becomes a file the draft check counts,
|
|
290
|
+
// not a step the agent may skip.
|
|
291
|
+
const check = await import(
|
|
292
|
+
pathToFileURL(path.join(BUILDERS_DIR, "draft-check.mjs")).href
|
|
293
|
+
);
|
|
294
|
+
const seeded = check.seedAssetPlan(
|
|
295
|
+
deckDir,
|
|
296
|
+
images
|
|
297
|
+
.filter((img) => writtenNums.has(Number(img?.slideNumber)))
|
|
298
|
+
.map((img) => ({ slideNumber: Number(img.slideNumber), label: img.label })),
|
|
299
|
+
);
|
|
300
|
+
finish({
|
|
301
|
+
paths: written,
|
|
302
|
+
planned: planned.size,
|
|
303
|
+
status: "generated",
|
|
304
|
+
missing,
|
|
305
|
+
seeded,
|
|
306
|
+
extra: seeded
|
|
307
|
+
? `Seeded ${path.join(deckDir, check.ASSET_PLAN_FILE)} — fill its regions/organisms/connectors/icons rows per slide from the mockups BEFORE writing any slide.\n`
|
|
308
|
+
: "",
|
|
309
|
+
});
|
|
310
|
+
}
|