slidemind 0.3.20 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +591 -15
- package/README.md +202 -7
- package/bin/slidemind.mjs +50 -2
- package/package.json +2 -2
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
package/src/bridge/bundle.mjs
CHANGED
|
@@ -39,6 +39,11 @@
|
|
|
39
39
|
// ├── lint-engine.mjs (wire.lintEngine.source — the add-in's slide-lint engine)
|
|
40
40
|
// ├── primitives.json (wire.lintEngine.primitives — closed component registry)
|
|
41
41
|
// └── bundle.json ({ presFP, name, version, etag, lintEngineVersion, tokenRoleMap, … })
|
|
42
|
+
//
|
|
43
|
+
// A deck REFERENCES this directory; it never carries a copy. The HTML preview
|
|
44
|
+
// points at these paths by absolute file: URL, the review server serves them
|
|
45
|
+
// from here, and the server render resolves brand assets from the design
|
|
46
|
+
// itself — so re-materializing a bundle changes no file inside any deck.
|
|
42
47
|
|
|
43
48
|
import fs from "node:fs";
|
|
44
49
|
import os from "node:os";
|
|
@@ -53,14 +58,49 @@ import {
|
|
|
53
58
|
listBrands,
|
|
54
59
|
} from "./client.mjs";
|
|
55
60
|
import { updateCliInstruction } from "../install-channel.mjs";
|
|
61
|
+
import { statePath } from "../lib/deck-paths.mjs";
|
|
56
62
|
|
|
57
63
|
// Highest bundle wire-contract version this CLI understands. The add-in stamps
|
|
58
64
|
// `schemaVersion` on every bundle; a higher value means the CLI is too old.
|
|
59
65
|
const CLI_MAX_BUNDLE_SCHEMA = 1;
|
|
60
66
|
|
|
67
|
+
// The bundle cache normally lives under the user's cache dir. Agent sandboxes
|
|
68
|
+
// (codex `workspace-write`, probed 2026-09-19: ~/.cache and ~/.config are
|
|
69
|
+
// DENIED, the cwd is allowed) refuse writes there, and a session that cannot
|
|
70
|
+
// materialize the bundle cannot author at all — two sim sessions "fixed" this
|
|
71
|
+
// by rewriting the harness wrapper. So when the preferred root is not
|
|
72
|
+
// writable, the cache falls back to `<cwd>/.slidemind-cache/bundles`, which
|
|
73
|
+
// every sandbox allows. A cache is a cache: a second copy costs a download.
|
|
74
|
+
// ponytail: probed once per process; a root that turns unwritable mid-run
|
|
75
|
+
// fails the write like before.
|
|
76
|
+
// Memo keyed by the preferred root: tests (and users) switch XDG_CACHE_HOME
|
|
77
|
+
// within one process.
|
|
78
|
+
const cacheRootMemo = new Map();
|
|
61
79
|
export function bundleCacheRoot() {
|
|
62
80
|
const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
|
|
63
|
-
|
|
81
|
+
const preferred = path.join(base, "slidemind", "bundles");
|
|
82
|
+
if (cacheRootMemo.has(preferred)) return cacheRootMemo.get(preferred);
|
|
83
|
+
cacheRootMemo.set(preferred, preferred);
|
|
84
|
+
try {
|
|
85
|
+
fs.mkdirSync(preferred, { recursive: true });
|
|
86
|
+
const probe = path.join(preferred, `.probe.${process.pid}`);
|
|
87
|
+
fs.writeFileSync(probe, "");
|
|
88
|
+
fs.rmSync(probe, { force: true });
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err?.code !== "EPERM" && err?.code !== "EACCES" && err?.code !== "EROFS") return preferred;
|
|
91
|
+
const fallback = path.join(process.cwd(), ".slidemind-cache", "bundles");
|
|
92
|
+
// Say it once per fallback dir, not once per command: an agent session
|
|
93
|
+
// runs sixty commands and read this banner 53 times (x30 audit).
|
|
94
|
+
const said = !fs.existsSync(fallback);
|
|
95
|
+
fs.mkdirSync(fallback, { recursive: true });
|
|
96
|
+
if (said) {
|
|
97
|
+
process.stderr.write(
|
|
98
|
+
`slidemind: bundle cache ${preferred} is not writable here (${err.code}) — using ${fallback} for this session\n`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
cacheRootMemo.set(preferred, fallback);
|
|
102
|
+
}
|
|
103
|
+
return cacheRootMemo.get(preferred);
|
|
64
104
|
}
|
|
65
105
|
|
|
66
106
|
function sanitizeKey(value) {
|
|
@@ -141,6 +181,21 @@ export function clearBundleCache() {
|
|
|
141
181
|
// add-in's self-signed dev certificate on loopback hosts (the same posture the
|
|
142
182
|
// bridge client uses). Azure blob SAS URLs are public HTTPS and need no special
|
|
143
183
|
// handling beyond a normal GET.
|
|
184
|
+
// Every file in the cache lands via tmp + rename. The cache is shared by every
|
|
185
|
+
// concurrent slidemind process on the machine, and each deck command re-syncs
|
|
186
|
+
// it (refreshDeckBundle) — a plain writeFileSync truncates first, so a sibling
|
|
187
|
+
// process dynamic-importing lint-engine.mjs / capture-engine.mjs at that instant
|
|
188
|
+
// saw an empty module ("Cached lint engine is corrupt", "predates the lint
|
|
189
|
+
// engine"; four hits in one 4-session sim batch, 2026-09-18). rename is atomic
|
|
190
|
+
// on the same filesystem, so a reader sees the old bytes or the new, never half.
|
|
191
|
+
const TMP_SUFFIX_RE = /\.\d+\.sm-tmp$/;
|
|
192
|
+
export function writeFileAtomic(target, data, encoding) {
|
|
193
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
194
|
+
const tmp = `${target}.${process.pid}.sm-tmp`;
|
|
195
|
+
fs.writeFileSync(tmp, data, encoding);
|
|
196
|
+
fs.renameSync(tmp, target);
|
|
197
|
+
}
|
|
198
|
+
|
|
144
199
|
function getBuffer(url, { redirects = 4 } = {}) {
|
|
145
200
|
return new Promise((resolve, reject) => {
|
|
146
201
|
let u;
|
|
@@ -187,8 +242,7 @@ function getBuffer(url, { redirects = 4 } = {}) {
|
|
|
187
242
|
|
|
188
243
|
async function downloadInto(targetPath, url) {
|
|
189
244
|
const buf = await getBuffer(url);
|
|
190
|
-
|
|
191
|
-
fs.writeFileSync(targetPath, buf);
|
|
245
|
+
writeFileAtomic(targetPath, buf);
|
|
192
246
|
}
|
|
193
247
|
|
|
194
248
|
const REUSABLE_KIND_ORDER = [
|
|
@@ -309,9 +363,7 @@ export async function materializeBundle(
|
|
|
309
363
|
// Everything this sync owns, as dir-relative paths; drives the prune pass.
|
|
310
364
|
const expected = new Set(["bundle.json"]);
|
|
311
365
|
const writeText = (rel, text) => {
|
|
312
|
-
|
|
313
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
314
|
-
fs.writeFileSync(target, text, "utf8");
|
|
366
|
+
writeFileAtomic(path.join(dir, rel), text, "utf8");
|
|
315
367
|
expected.add(rel);
|
|
316
368
|
};
|
|
317
369
|
|
|
@@ -423,8 +475,10 @@ export async function materializeBundle(
|
|
|
423
475
|
// vision pass lifted from the brand's own master and flagged `reusable`).
|
|
424
476
|
// Each carries a `workspacePath` (e.g. `assets/master-<sha8>.<ext>`) that the
|
|
425
477
|
// add-in's design persona references from slide JSX as `<img src="./assets/
|
|
426
|
-
// <file>">`; land them at that exact path
|
|
427
|
-
//
|
|
478
|
+
// <file>">`; land them at that exact path inside the BUNDLE, next to the
|
|
479
|
+
// role-keyed brand assets — the preview rewrites such a reference to this
|
|
480
|
+
// directory and the render resolves it server-side, so no deck carries a
|
|
481
|
+
// copy. Content-addressed name →
|
|
428
482
|
// write-once (a changed image gets a new sha8 path; the old one prunes out).
|
|
429
483
|
// The catalogue of these (name/kind/description) rides in design.md's
|
|
430
484
|
// `### Brand Reusable Images` block, so the local agent sees both halves.
|
|
@@ -447,7 +501,7 @@ export async function materializeBundle(
|
|
|
447
501
|
// HTML builder rewrites to `./assets/…`, so the local preview shows real brand
|
|
448
502
|
// chrome instead of bare layouts. Each carries a `workspacePath`
|
|
449
503
|
// (`assets/chrome-<componentName>.<ext>`); land it next to the other assets so
|
|
450
|
-
//
|
|
504
|
+
// the preview reads it from here. Etag-keyed → re-fetch only on change.
|
|
451
505
|
for (const chrome of wire.layoutBackgrounds ?? []) {
|
|
452
506
|
if (!chrome?.url || typeof chrome.workspacePath !== "string") continue;
|
|
453
507
|
const rel = path.join("assets", path.basename(chrome.workspacePath));
|
|
@@ -538,7 +592,7 @@ export async function materializeBundle(
|
|
|
538
592
|
fetchedAt: new Date().toISOString(),
|
|
539
593
|
incompleteAssets: warnings.length > 0,
|
|
540
594
|
};
|
|
541
|
-
|
|
595
|
+
writeFileAtomic(metaPath, JSON.stringify(meta, null, 2), "utf8");
|
|
542
596
|
|
|
543
597
|
// Last, after every write AND the prune — the overlay must win over the wire.
|
|
544
598
|
const overlaid = applyBundleOverlay(dir);
|
|
@@ -571,27 +625,29 @@ export function applyBundleOverlay(dir) {
|
|
|
571
625
|
let copied = 0;
|
|
572
626
|
try {
|
|
573
627
|
if (!fs.statSync(overlay).isDirectory()) return 0;
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
628
|
+
// Docs only (.md/.css): the bundle dir also holds lint-engine.mjs and
|
|
629
|
+
// capture-engine.mjs, which the CLI dynamic-imports — an overlay must
|
|
630
|
+
// never be able to swap executable code, and text files re-sync from the
|
|
631
|
+
// wire on every materialize so a doc overlay can't persist past the
|
|
632
|
+
// experiment (binaries would). Copied atomically like every other cache
|
|
633
|
+
// write: a sibling session reading create.md mid-copy must not get half.
|
|
634
|
+
const walk = (rel) => {
|
|
635
|
+
for (const entry of fs.readdirSync(path.join(overlay, rel), { withFileTypes: true })) {
|
|
636
|
+
const childRel = path.join(rel, entry.name);
|
|
583
637
|
let st;
|
|
584
638
|
try {
|
|
585
|
-
st = fs.
|
|
639
|
+
st = fs.statSync(path.join(overlay, childRel));
|
|
586
640
|
} catch {
|
|
587
|
-
|
|
641
|
+
continue; // dangling symlink — skip, never fail the materialize
|
|
588
642
|
}
|
|
589
|
-
if (st.isDirectory())
|
|
590
|
-
if (
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
643
|
+
if (st.isDirectory()) walk(childRel);
|
|
644
|
+
else if (/\.(md|css)$/i.test(entry.name)) {
|
|
645
|
+
writeFileAtomic(path.join(dir, childRel), fs.readFileSync(path.join(overlay, childRel)));
|
|
646
|
+
copied += 1;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
walk("");
|
|
595
651
|
} catch (err) {
|
|
596
652
|
// The overlay is an experiment convenience — a bad overlay must not break
|
|
597
653
|
// ready/build/push after an otherwise successful sync.
|
|
@@ -615,6 +671,16 @@ function pruneUnexpected(dir, expected) {
|
|
|
615
671
|
if (fs.readdirSync(path.join(dir, childRel)).length === 0) {
|
|
616
672
|
fs.rmdirSync(path.join(dir, childRel));
|
|
617
673
|
}
|
|
674
|
+
} else if (TMP_SUFFIX_RE.test(entry.name)) {
|
|
675
|
+
// A sibling process's in-flight write — leave it, or its rename fails.
|
|
676
|
+
// One that outlived its process (a crash mid-write) is swept after 60s.
|
|
677
|
+
let stale = false;
|
|
678
|
+
try {
|
|
679
|
+
stale = Date.now() - fs.statSync(path.join(dir, childRel)).mtimeMs > 60_000;
|
|
680
|
+
} catch {
|
|
681
|
+
continue; // renamed away between readdir and stat — the happy path
|
|
682
|
+
}
|
|
683
|
+
if (stale) fs.rmSync(path.join(dir, childRel), { force: true });
|
|
618
684
|
} else if (!expected.has(childRel)) {
|
|
619
685
|
fs.rmSync(path.join(dir, childRel), { force: true });
|
|
620
686
|
pruned += 1;
|
|
@@ -629,13 +695,14 @@ function pruneUnexpected(dir, expected) {
|
|
|
629
695
|
// against — lets `render-pptx` pass the etag for skew detection, and lets the
|
|
630
696
|
// local HTML/editor resolve the right materialized bundle.
|
|
631
697
|
export function deckBundleRefPath(deckDir) {
|
|
632
|
-
return
|
|
698
|
+
return statePath(deckDir, "bundle-ref.json");
|
|
633
699
|
}
|
|
634
700
|
|
|
635
701
|
export function writeDeckBundleRef(deckDir, { presFP, etag }) {
|
|
636
702
|
fs.mkdirSync(deckDir, { recursive: true });
|
|
637
|
-
|
|
638
|
-
|
|
703
|
+
const file = deckBundleRefPath(deckDir);
|
|
704
|
+
writeFileAtomic(
|
|
705
|
+
file,
|
|
639
706
|
JSON.stringify(
|
|
640
707
|
{ presFP, etag: etag ?? null, updatedAt: new Date().toISOString() },
|
|
641
708
|
null,
|
|
@@ -801,7 +868,9 @@ const IMAGE_ENGINE_KIND_RE = /^(image|icon|photo|illustration|vector)$/i;
|
|
|
801
868
|
// True when an engine produces insertable image bytes. Gates on the engine's
|
|
802
869
|
// real modality — `kind` for bundle engines.json entries, `subtype` for live
|
|
803
870
|
// `GET /api/cli/engines` catalog entries — NOT a guess over its
|
|
804
|
-
// label/description.
|
|
871
|
+
// label/description. Which of those image engines serves an icon and which a
|
|
872
|
+
// photo IS read from label and description (asset-fetch.mjs selectAssetEngine),
|
|
873
|
+
// since the catalog's subtype is "image" for both.
|
|
805
874
|
export function isImageEngine(engine) {
|
|
806
875
|
return IMAGE_ENGINE_KIND_RE.test(engine?.kind ?? engine?.subtype ?? "");
|
|
807
876
|
}
|
package/src/bridge/capture.mjs
CHANGED
|
@@ -15,12 +15,19 @@ import path from "node:path";
|
|
|
15
15
|
import { pathToFileURL } from "node:url";
|
|
16
16
|
|
|
17
17
|
import { convertedHostLayouts, loadConversions } from "../lib/conversions.mjs";
|
|
18
|
+
import { deckSlug } from "../lib/deck-paths.mjs";
|
|
19
|
+
import { selectSlideFiles } from "../lib/slide-selection.mjs";
|
|
18
20
|
|
|
19
21
|
// Same launch profile as the add-in's chrome-launcher.ts, so the client browser
|
|
20
22
|
// behaves identically to the server's during capture.
|
|
21
23
|
const LAUNCH_ARGS = [
|
|
22
24
|
"--no-sandbox",
|
|
23
25
|
"--disable-gpu",
|
|
26
|
+
// Headless Chrome ties its frames to the display's vsync: with the Mac's
|
|
27
|
+
// display asleep, requestAnimationFrame and screenshots never fire and every
|
|
28
|
+
// render hangs at 0% CPU (the add-in's chrome-launcher carries the same pair).
|
|
29
|
+
"--disable-gpu-vsync",
|
|
30
|
+
"--disable-frame-rate-limit",
|
|
24
31
|
"--hide-scrollbars",
|
|
25
32
|
"--disable-features=AppShimRemoteCocoa,AppShimNewCloseBehavior,WebAppSystemMediaControls",
|
|
26
33
|
"--disable-default-apps",
|
|
@@ -54,11 +61,10 @@ function readBundleMeta(bundleDir) {
|
|
|
54
61
|
}
|
|
55
62
|
}
|
|
56
63
|
|
|
57
|
-
function readJsxDir(dir) {
|
|
64
|
+
function readJsxDir(dir, only = null) {
|
|
58
65
|
const out = {};
|
|
59
66
|
if (!fs.existsSync(dir)) return out;
|
|
60
|
-
for (const name of fs.readdirSync(dir)) {
|
|
61
|
-
if (!name.endsWith(".jsx")) continue;
|
|
67
|
+
for (const name of selectSlideFiles(fs.readdirSync(dir).filter(name => name.endsWith(".jsx")), only)) {
|
|
62
68
|
out[name] = fs.readFileSync(path.join(dir, name), "utf8");
|
|
63
69
|
}
|
|
64
70
|
return out;
|
|
@@ -85,10 +91,10 @@ function readAssetsAsBase64(dir, out, prefix = "") {
|
|
|
85
91
|
// Build the in-memory workspace the capture engine renders: the deck's slides +
|
|
86
92
|
// the materialized bundle's layouts / css / assets / fonts / primitives.
|
|
87
93
|
// Exported for tests (pure: filesystem in, plain JSON out).
|
|
88
|
-
export function buildCaptureInput(deckDir, bundleDir) {
|
|
94
|
+
export function buildCaptureInput(deckDir, bundleDir, { only = null } = {}) {
|
|
89
95
|
const meta = readBundleMeta(bundleDir);
|
|
90
96
|
|
|
91
|
-
const slides = readJsxDir(path.join(deckDir, "slides"));
|
|
97
|
+
const slides = readJsxDir(path.join(deckDir, "slides"), only);
|
|
92
98
|
const layouts = readJsxDir(path.join(bundleDir, "layouts"));
|
|
93
99
|
// Converted slides (`slidemind adopt`) invoke a ConvertedHost layout the
|
|
94
100
|
// server mints per source canvas — no bundle carries it, so without a
|
|
@@ -151,7 +157,7 @@ export function buildCaptureInput(deckDir, bundleDir) {
|
|
|
151
157
|
assets,
|
|
152
158
|
fonts,
|
|
153
159
|
primitives,
|
|
154
|
-
deckName:
|
|
160
|
+
deckName: deckSlug(deckDir),
|
|
155
161
|
};
|
|
156
162
|
}
|
|
157
163
|
|
|
@@ -243,16 +249,7 @@ export async function captureDeckLocally({ deckDir, bundleDir, onProgress, only
|
|
|
243
249
|
);
|
|
244
250
|
}
|
|
245
251
|
|
|
246
|
-
const input = buildCaptureInput(deckDir, bundleDir);
|
|
247
|
-
// `only`: capture a subset of the authored slides (a per-slide builder
|
|
248
|
-
// re-checking its own file) — the engine renders each slide on its own, so
|
|
249
|
-
// dropping the others from the input is exact, not an approximation.
|
|
250
|
-
if (Array.isArray(only) && only.length > 0) {
|
|
251
|
-
const keep = new Set(only);
|
|
252
|
-
for (const name of Object.keys(input.slides)) {
|
|
253
|
-
if (!keep.has(name)) delete input.slides[name];
|
|
254
|
-
}
|
|
255
|
-
}
|
|
252
|
+
const input = buildCaptureInput(deckDir, bundleDir, { only });
|
|
256
253
|
return withBrowser((browser) => engine.captureDeck(browser, input, { onProgress }));
|
|
257
254
|
}
|
|
258
255
|
|
|
@@ -289,7 +286,7 @@ export async function loadConnectorPreview(bundleDir) {
|
|
|
289
286
|
|
|
290
287
|
/**
|
|
291
288
|
* The measured draft checks (`draftChecks: { fill, typeScale, typeScaleTokens,
|
|
292
|
-
* overlap }`) exported from the same artifact — the rules the add-in's own
|
|
289
|
+
* overlap, escape, chrome, wordFit, splitText, splitTextAdvice }`) exported from the same artifact — the rules the add-in's own
|
|
293
290
|
* see_draft runs. view-draft calls them on its local capture so both harnesses
|
|
294
291
|
* teach one contract. The CLI ships no draft-check rule of its own; null when
|
|
295
292
|
* the bundle has no engine or the engine predates the export.
|
|
@@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { deriveEnvLabel } from "../config.mjs";
|
|
7
7
|
import { resolveBackendForPres } from "./presentations.mjs";
|
|
8
|
-
import { listTimeoutMs, engineTimeoutMs, withTransientRetry, sessionsInScope, sessionForBackend, bridgeFetch, backendCapabilities, backendSupportsBrandRoutes, backendSupportsConceptImages, backendSupportsImageGeneration, backendSupportsBrandsList, graphHeaders } from "./transport.mjs";
|
|
8
|
+
import { listTimeoutMs, engineTimeoutMs, withTransientRetry, sessionsInScope, sessionForBackend, bridgeFetch, backendCapabilities, backendSupportsBrandRoutes, backendSupportsConceptImages, backendSupportsConceptSpecs, backendSupportsConceptSpecJobs, backendSupportsImageGeneration, backendSupportsBrandsList, assertImageIntentSupported, graphHeaders } from "./transport.mjs";
|
|
9
9
|
|
|
10
10
|
// GET the full design bundle (tokens, css, layouts, designRules, signed asset
|
|
11
11
|
// URLs, engines) for the presentation's active corporate design.
|
|
@@ -93,9 +93,11 @@ export async function getDesignBundleForBrandAnyBackend(brandId) {
|
|
|
93
93
|
export async function listBrands() {
|
|
94
94
|
const out = [];
|
|
95
95
|
for (const s of sessionsInScope()) {
|
|
96
|
-
if (!(await backendSupportsBrandsList(s.backendURL))) continue;
|
|
97
96
|
const env = deriveEnvLabel(s.backendURL);
|
|
98
97
|
try {
|
|
98
|
+
// Inside the try: a probe that got no answer is "that environment
|
|
99
|
+
// contributes none" like any other failure, not a crash of the listing.
|
|
100
|
+
if (!(await backendSupportsBrandsList(s.backendURL))) continue;
|
|
99
101
|
const res = await bridgeFetch(
|
|
100
102
|
"/api/cli/brands",
|
|
101
103
|
{ timeoutMs: listTimeoutMs() },
|
|
@@ -136,9 +138,12 @@ export function conceptTimeoutMs() {
|
|
|
136
138
|
|
|
137
139
|
export async function postConceptImages(
|
|
138
140
|
brandId,
|
|
139
|
-
{ planMarkdown, slideNumbers,
|
|
141
|
+
{ planMarkdown, slideNumbers, force, imageIntent = null },
|
|
140
142
|
backendURL,
|
|
141
143
|
) {
|
|
144
|
+
// An envelope the add-in would ignore is worse than no envelope: it would
|
|
145
|
+
// generate against the wrong picture and say nothing. Probe, then send.
|
|
146
|
+
if (imageIntent) await assertImageIntentSupported(backendURL);
|
|
142
147
|
const body = { planMarkdown };
|
|
143
148
|
// `--force`: the server keeps a per-plan mockup cache; an unchanged plan
|
|
144
149
|
// would otherwise hand back last time's images no matter what changed
|
|
@@ -147,12 +152,10 @@ export async function postConceptImages(
|
|
|
147
152
|
if (Array.isArray(slideNumbers) && slideNumbers.length > 0) {
|
|
148
153
|
body.slideNumbers = slideNumbers;
|
|
149
154
|
}
|
|
150
|
-
// The
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
if (
|
|
154
|
-
body.references = references;
|
|
155
|
-
}
|
|
155
|
+
// The deck's approved image obligations, with the sources inline
|
|
156
|
+
// (src/bridge/image-intent-envelope.mjs). Omitted entirely when the deck has
|
|
157
|
+
// none — which is what keeps every existing request byte-identical.
|
|
158
|
+
if (imageIntent) body.imageIntent = imageIntent;
|
|
156
159
|
const res = await bridgeFetch(
|
|
157
160
|
`/api/cli/brands/${encodeURIComponent(brandId)}/concept-images`,
|
|
158
161
|
{
|
|
@@ -170,9 +173,10 @@ export async function postConceptImages(
|
|
|
170
173
|
}
|
|
171
174
|
|
|
172
175
|
// Generate concept mockup images for a brand over the pane-free brand route.
|
|
173
|
-
// Body `{ planMarkdown, slideNumbers?,
|
|
176
|
+
// Body `{ planMarkdown, slideNumbers?, imageIntent? }`; the optional `slideNumbers`
|
|
174
177
|
// filters which storyboard beats are rendered (the CLI batches per-slide);
|
|
175
|
-
// `
|
|
178
|
+
// `imageIntent` carries the user's own images inline with their approved uses.
|
|
179
|
+
// Returns the
|
|
176
180
|
// server's `{ ok, images, slideCount }` augmented with the resolved
|
|
177
181
|
// `backendURL`. When `backendURL` is given it posts directly; otherwise it
|
|
178
182
|
// fans out — like getDesignBundleForBrandAnyBackend, presFP/brandId is globally
|
|
@@ -184,9 +188,9 @@ export async function generateConceptImagesForBrand(
|
|
|
184
188
|
{
|
|
185
189
|
planMarkdown,
|
|
186
190
|
slideNumbers = null,
|
|
187
|
-
references = null,
|
|
188
191
|
backendURL = null,
|
|
189
192
|
force = false,
|
|
193
|
+
imageIntent = null,
|
|
190
194
|
} = {},
|
|
191
195
|
) {
|
|
192
196
|
if (!brandId) {
|
|
@@ -198,7 +202,7 @@ export async function generateConceptImagesForBrand(
|
|
|
198
202
|
if (backendURL) {
|
|
199
203
|
return postConceptImages(
|
|
200
204
|
brandId,
|
|
201
|
-
{ planMarkdown, slideNumbers,
|
|
205
|
+
{ planMarkdown, slideNumbers, force, imageIntent },
|
|
202
206
|
backendURL,
|
|
203
207
|
);
|
|
204
208
|
}
|
|
@@ -215,7 +219,7 @@ export async function generateConceptImagesForBrand(
|
|
|
215
219
|
}
|
|
216
220
|
return await postConceptImages(
|
|
217
221
|
brandId,
|
|
218
|
-
{ planMarkdown, slideNumbers,
|
|
222
|
+
{ planMarkdown, slideNumbers, force, imageIntent },
|
|
219
223
|
s.backendURL,
|
|
220
224
|
);
|
|
221
225
|
} catch (err) {
|
|
@@ -241,18 +245,32 @@ export async function generateConceptImagesForBrand(
|
|
|
241
245
|
// null when several environments qualify (developer machines only; each request
|
|
242
246
|
// then fans out as before). Throws the typed `concept_route_unavailable` when
|
|
243
247
|
// none does, so the caller degrades to "continue without".
|
|
248
|
+
//
|
|
249
|
+
// An environment whose probe got no answer is passed over, not fatal: it cannot
|
|
250
|
+
// generate right now whatever it would have said, and the request names the
|
|
251
|
+
// deck's design, so an environment that does not serve it refuses instead of
|
|
252
|
+
// drawing the wrong one. Only when NO environment answered with the route is
|
|
253
|
+
// that unanswered probe the result — `capability_unknown`, retryable, never
|
|
254
|
+
// "no concept route" (2026-10-11: a stopped `local` session aborted every
|
|
255
|
+
// `concepts` run before the deck's own `dev` was asked).
|
|
244
256
|
export async function resolveConceptBackend() {
|
|
245
257
|
const sessions = sessionsInScope();
|
|
246
258
|
const capable = [];
|
|
247
259
|
const failures = [];
|
|
260
|
+
const unanswered = [];
|
|
248
261
|
for (const s of sessions) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
262
|
+
try {
|
|
263
|
+
if (await backendSupportsConceptImages(s.backendURL)) {
|
|
264
|
+
capable.push(s.backendURL);
|
|
265
|
+
} else {
|
|
266
|
+
failures.push(`${deriveEnvLabel(s.backendURL)}: no concept-images route`);
|
|
267
|
+
}
|
|
268
|
+
} catch (err) {
|
|
269
|
+
unanswered.push(err);
|
|
253
270
|
}
|
|
254
271
|
}
|
|
255
272
|
if (capable.length === 0) {
|
|
273
|
+
if (unanswered.length) throw unanswered[0];
|
|
256
274
|
const detail = failures.join("; ");
|
|
257
275
|
const err = new Error(
|
|
258
276
|
"no paired backend generates concept mockups" +
|
|
@@ -264,8 +282,129 @@ export async function resolveConceptBackend() {
|
|
|
264
282
|
return capable.length === 1 ? capable[0] : null;
|
|
265
283
|
}
|
|
266
284
|
|
|
285
|
+
// Extract concept specs for a brand over the pane-free brand route. Body
|
|
286
|
+
// `{ slides: [{ slide, image, storyboard? }], slot: { w, h } }` → the server's
|
|
287
|
+
// `{ ok, specs: [{ slide, spec }], failures }`. Returns null when the backend
|
|
288
|
+
// predates the route, so `slidemind concepts` degrades to PNGs only.
|
|
289
|
+
export async function extractConceptSpecsForBrand(
|
|
290
|
+
brandId,
|
|
291
|
+
{ slides, slot, backendURL, palette = null, onProgress = null, onSpec = null,
|
|
292
|
+
imageIntent = null,
|
|
293
|
+
schemaFormat = process.env.SLIDEMIND_SPEC_SCHEMA || "compact-v1" },
|
|
294
|
+
) {
|
|
295
|
+
if (!brandId) throw new Error("extractConceptSpecsForBrand: brandId is required");
|
|
296
|
+
if (!backendURL) throw new Error("extractConceptSpecsForBrand: backendURL is required");
|
|
297
|
+
if (!["legacy", "compact-v1"].includes(schemaFormat)) throw new Error(`Unknown spec schema: ${schemaFormat}`);
|
|
298
|
+
const schemaOption = { schemaFormat };
|
|
299
|
+
const checkSchema = (entry) => {
|
|
300
|
+
if (schemaFormat === "compact-v1" && entry?.spec?.schemaFormat !== schemaFormat) {
|
|
301
|
+
throw new Error("Backend did not return compact-v1 specs; update the add-in backend, or explicitly select SLIDEMIND_SPEC_SCHEMA=legacy for compatibility.");
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
// Same gate as the concept call: extraction that drops the envelope would
|
|
305
|
+
// inventory the mockup's approximation of the user's picture and record it as
|
|
306
|
+
// the slide's elements. Refuse before the request, never degrade.
|
|
307
|
+
if (imageIntent) await assertImageIntentSupported(backendURL);
|
|
308
|
+
if (!(await backendSupportsConceptSpecs(backendURL))) return null;
|
|
309
|
+
// The same envelope the concept call carried, so extraction sees the sources
|
|
310
|
+
// the mockup was made from. Absent for a deck with no image uses, and both
|
|
311
|
+
// the job and the sync body below spread it in — one contract, two routes.
|
|
312
|
+
const intentOption = imageIntent ? { imageIntent } : {};
|
|
313
|
+
// The polled job when the add-in has it: one POST, then a GET every few
|
|
314
|
+
// seconds until `state: done`. A dense slide gets its eight minutes; a
|
|
315
|
+
// sync request died at the ingress's ~240 s (x27–x32: 1–2 specs per deck).
|
|
316
|
+
if (await backendSupportsConceptSpecJobs(backendURL)) {
|
|
317
|
+
const base = `/api/cli/brands/${encodeURIComponent(brandId)}/concept-spec-jobs`;
|
|
318
|
+
const created = await bridgeFetch(
|
|
319
|
+
base,
|
|
320
|
+
{
|
|
321
|
+
method: "POST",
|
|
322
|
+
headers: { "content-type": "application/json" },
|
|
323
|
+
body: JSON.stringify({ slides, slot, ...schemaOption, ...(palette?.length ? { palette } : {}), ...intentOption }),
|
|
324
|
+
// ~7 MB of inlined mockups, and the server checks the image intent
|
|
325
|
+
// before its 202: the 8 s list timeout aborted the upload before the job
|
|
326
|
+
// existed (x69). The server takes no idempotency key to reuse one by.
|
|
327
|
+
timeoutMs: Math.max(conceptTimeoutMs(), 120_000),
|
|
328
|
+
},
|
|
329
|
+
backendURL,
|
|
330
|
+
);
|
|
331
|
+
const job = (await created.json()) ?? {};
|
|
332
|
+
if (job.jobId) {
|
|
333
|
+
const started = Date.now();
|
|
334
|
+
const deadline = started + Math.max(conceptTimeoutMs(), 20 * 60_000);
|
|
335
|
+
const delivered = new Set();
|
|
336
|
+
let lastBeat = started;
|
|
337
|
+
let lastDone = -1;
|
|
338
|
+
while (Date.now() < deadline) {
|
|
339
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
340
|
+
let state;
|
|
341
|
+
try {
|
|
342
|
+
const res = await bridgeFetch(`${base}/${encodeURIComponent(job.jobId)}`, { timeoutMs: listTimeoutMs() }, backendURL);
|
|
343
|
+
state = (await res.json()) ?? {};
|
|
344
|
+
} catch (err) {
|
|
345
|
+
if (err?.status === 404) break; // the container restarted; the sync route below
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
// Each spec is handed over the moment it exists: a command the agent
|
|
349
|
+
// gives up on still leaves what was read (x33: two stalls, nothing
|
|
350
|
+
// written), and the next run asks only for the rest.
|
|
351
|
+
for (const entry of state.specs ?? []) {
|
|
352
|
+
checkSchema(entry);
|
|
353
|
+
const n = Number(entry?.slide);
|
|
354
|
+
if (delivered.has(n)) continue;
|
|
355
|
+
delivered.add(n);
|
|
356
|
+
if (typeof onSpec === "function") onSpec(entry);
|
|
357
|
+
}
|
|
358
|
+
const done = delivered.size + (state.failures?.length ?? 0);
|
|
359
|
+
// A line when the count moves, else one heartbeat every 30 s.
|
|
360
|
+
if (typeof onProgress === "function" && (done !== lastDone || Date.now() - lastBeat >= 30_000)) {
|
|
361
|
+
onProgress({ done, total: state.total ?? slides.length, elapsedMs: Date.now() - started });
|
|
362
|
+
lastBeat = Date.now();
|
|
363
|
+
lastDone = done;
|
|
364
|
+
}
|
|
365
|
+
if (state.state === "done") return { ok: true, specs: state.specs ?? [], failures: state.failures ?? [], streamed: true };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const res = await bridgeFetch(
|
|
370
|
+
`/api/cli/brands/${encodeURIComponent(brandId)}/concept-specs`,
|
|
371
|
+
{
|
|
372
|
+
method: "POST",
|
|
373
|
+
headers: { "content-type": "application/json" },
|
|
374
|
+
body: JSON.stringify({ slides, slot, ...schemaOption, ...(palette?.length ? { palette } : {}), ...intentOption }),
|
|
375
|
+
// The server budgets 300s per slide and runs a batch in parallel; give
|
|
376
|
+
// the round trip headroom over that so a slow inventory is a server
|
|
377
|
+
// failure the response names, never a client abort.
|
|
378
|
+
timeoutMs: Math.max(conceptTimeoutMs(), 330_000),
|
|
379
|
+
},
|
|
380
|
+
backendURL,
|
|
381
|
+
);
|
|
382
|
+
const result = (await res.json()) ?? {};
|
|
383
|
+
for (const entry of result.specs ?? []) checkSchema(entry);
|
|
384
|
+
return result;
|
|
385
|
+
}
|
|
386
|
+
|
|
267
387
|
// gpt-image calls routinely take 60-120s; budget like the concept relay so a
|
|
268
388
|
// typed server error wins over a client timeout.
|
|
389
|
+
// Shorten the words of texts that do not fit their room (copy-fit.mjs), one
|
|
390
|
+
// slide per call, over `/api/cli/brands/:brandId/copy-fit`: `{ items, context,
|
|
391
|
+
// plan }` → `[{ id, text }]`. Null when the backend has no such route: the
|
|
392
|
+
// slide keeps its words and the author shortens them.
|
|
393
|
+
export async function fitCopyForBrand(brandId, { items, context = "", plan = null, backendURL }) {
|
|
394
|
+
if (!brandId || !backendURL || (await backendCapabilities(backendURL))?.copyFitV1 !== true) return null;
|
|
395
|
+
const res = await bridgeFetch(
|
|
396
|
+
`/api/cli/brands/${encodeURIComponent(brandId)}/copy-fit`,
|
|
397
|
+
{
|
|
398
|
+
method: "POST",
|
|
399
|
+
headers: { "content-type": "application/json" },
|
|
400
|
+
body: JSON.stringify({ items: items.map(({ id, role, text, maxChars, keepPrefix }) => ({ id, role, text, maxChars, ...(keepPrefix ? { keepPrefix } : {}) })), context, plan }),
|
|
401
|
+
timeoutMs: 120_000,
|
|
402
|
+
},
|
|
403
|
+
backendURL,
|
|
404
|
+
);
|
|
405
|
+
return ((await res.json()) ?? {}).items ?? [];
|
|
406
|
+
}
|
|
407
|
+
|
|
269
408
|
export function imagenTimeoutMs() {
|
|
270
409
|
return (
|
|
271
410
|
parseInt(process.env.SLIDEMIND_BRIDGE_IMAGEN_TIMEOUT_MS || "200000", 10) ||
|