slidemind 0.1.9 → 0.2.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.md +3 -0
- package/bin/slidemind.mjs +450 -55
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +6 -5
- package/skills/search/SKILL.md +10 -4
- package/src/bridge/bundle.mjs +21 -3
- package/src/bridge/client.mjs +414 -7
- package/src/bridge/engine-download.mjs +229 -5
- package/src/bridge/image-dimensions.mjs +181 -0
- package/src/builders/resolve-skill.mjs +45 -1
- package/src/builders/review.mjs +4 -1
- package/src/pptx/fill/engine.mjs +1273 -0
- package/src/pptx/fill/freeform.mjs +156 -0
- package/src/pptx/fill/parse-slides.mjs +154 -0
- package/src/pptx/fill/xml.mjs +486 -0
- package/src/pptx/ooxml.mjs +10 -3
package/bin/slidemind.mjs
CHANGED
|
@@ -68,7 +68,7 @@ function printHelp() {
|
|
|
68
68
|
` slidemind review <deck> Open a spatial preview review; blocks until the user\n` +
|
|
69
69
|
` approves or leaves comments (host-free)\n` +
|
|
70
70
|
` --port <n> bind a fixed port (default: ephemeral)\n` +
|
|
71
|
-
` slidemind build-pptx <deck> Export native .pptx via the add-in (
|
|
71
|
+
` slidemind build-pptx <deck> Export native .pptx — via the add-in for compose decks (host), LOCALLY for template packs (host-free)\n` +
|
|
72
72
|
` --to <presId> target a specific window (required if >1 open)\n` +
|
|
73
73
|
` --out <path> output path (default <deck>/<deck>.pptx)\n` +
|
|
74
74
|
` slidemind view-draft <deck> Render the deck's slides to per-slide PNGs (no push, no .pptx)\n` +
|
|
@@ -77,8 +77,10 @@ function printHelp() {
|
|
|
77
77
|
` --to <presId> window to fetch the bundle from (only if not cached)\n` +
|
|
78
78
|
` --out <dir> output dir (default <deck>/.draft-preview/)\n` +
|
|
79
79
|
` --open open each rendered PNG\n` +
|
|
80
|
-
` slidemind search "<query>" Search an engine for images or slides (requires an open host)\n` +
|
|
81
|
-
` --engine <id> engine
|
|
80
|
+
` slidemind search "<query>" Search an engine or scope for images or slides (requires an open host)\n` +
|
|
81
|
+
` --engine <id> engine, or scope:all | scope:sharepoint to search unfiltered\n` +
|
|
82
|
+
` (default: first image engine; scope:all for slide intents)\n` +
|
|
83
|
+
` --result-type <t> slide | image — required for a scope:* target\n` +
|
|
82
84
|
` --intent <i> auto | content | visual (slide engines)\n` +
|
|
83
85
|
` --top <N> max results\n` +
|
|
84
86
|
` --to <presId> target a specific window (required if >1 open)\n` +
|
|
@@ -88,8 +90,8 @@ function printHelp() {
|
|
|
88
90
|
` --pick <n> 1-based selection number to view\n` +
|
|
89
91
|
` --to <presId> the window the search ran on (required if >1 open)\n` +
|
|
90
92
|
` --out <dir> output dir (default: a slidemind temp dir)\n` +
|
|
91
|
-
` slidemind ls engines List the live engine catalog for an active pane\n` +
|
|
92
|
-
` --type <t> collection | search\n` +
|
|
93
|
+
` slidemind ls engines List the live engine catalog + search scopes for an active pane\n` +
|
|
94
|
+
` --type <t> collection | search | scope\n` +
|
|
93
95
|
` --subtype <s> image | slide | template | content\n` +
|
|
94
96
|
` --to <presId> target a specific window/backend\n` +
|
|
95
97
|
` --all include every paired backend\n` +
|
|
@@ -112,6 +114,12 @@ function printHelp() {
|
|
|
112
114
|
` --deck <deck> deck to write the asset(s) into\n` +
|
|
113
115
|
` --to <presId> the window the search ran on (required if >1 open)\n` +
|
|
114
116
|
` --icon-color <hex[,hex,..]> bake a colour into SVG result(s) (one for all, or one per --pick)\n` +
|
|
117
|
+
` slidemind imagen "<prompt>" Generate ONE image with the add-in's image model into a deck's assets/ (paired backend, pane-free)\n` +
|
|
118
|
+
` --deck <deck> bound deck to write the image into (required)\n` +
|
|
119
|
+
` --out <name> file name under assets/ (default imagen-N.png)\n` +
|
|
120
|
+
` --size <s> auto|1024x1024|1536x1024|1024x1536 (default auto)\n` +
|
|
121
|
+
` --quality <q> low|medium|high|auto\n` +
|
|
122
|
+
` --brand-style condition on the CD's reference samples\n` +
|
|
115
123
|
` slidemind download-bundle [deck] Materialize the active corporate design's bundle locally\n` +
|
|
116
124
|
` --to <presId> target a specific window\n` +
|
|
117
125
|
` --force re-download even if the cached bundle is current\n` +
|
|
@@ -306,6 +314,103 @@ function renderProgressPrinter() {
|
|
|
306
314
|
};
|
|
307
315
|
}
|
|
308
316
|
|
|
317
|
+
// Generate one regular image via the paired add-in's image model and write it
|
|
318
|
+
// into the deck's assets/. Pane-free: routes by the deck's bound design
|
|
319
|
+
// (.bundle-ref.json), so it works with PowerPoint backgrounded or closed.
|
|
320
|
+
async function cmdImagen(rest) {
|
|
321
|
+
let prompt = null;
|
|
322
|
+
let deck = null;
|
|
323
|
+
let outName = null;
|
|
324
|
+
let size = null;
|
|
325
|
+
let quality = null;
|
|
326
|
+
let brandStyle = false;
|
|
327
|
+
for (let i = 0; i < rest.length; i++) {
|
|
328
|
+
const a = rest[i];
|
|
329
|
+
if (a === "--deck") {
|
|
330
|
+
deck = rest[++i];
|
|
331
|
+
if (!deck) fail("--deck expects a deck");
|
|
332
|
+
} else if (a === "--out" || a === "-o") {
|
|
333
|
+
outName = rest[++i];
|
|
334
|
+
if (!outName) fail("--out expects a file name");
|
|
335
|
+
} else if (a === "--size") {
|
|
336
|
+
size = rest[++i];
|
|
337
|
+
if (!["auto", "1024x1024", "1536x1024", "1024x1536"].includes(size ?? "")) {
|
|
338
|
+
fail("--size expects auto|1024x1024|1536x1024|1024x1536");
|
|
339
|
+
}
|
|
340
|
+
} else if (a === "--quality") {
|
|
341
|
+
quality = rest[++i];
|
|
342
|
+
if (!["low", "medium", "high", "auto"].includes(quality ?? "")) {
|
|
343
|
+
fail("--quality expects low|medium|high|auto");
|
|
344
|
+
}
|
|
345
|
+
} else if (a === "--brand-style") {
|
|
346
|
+
brandStyle = true;
|
|
347
|
+
} else if (a.startsWith("-")) {
|
|
348
|
+
fail(`unknown flag: ${a}`);
|
|
349
|
+
} else if (prompt === null) {
|
|
350
|
+
prompt = a;
|
|
351
|
+
} else {
|
|
352
|
+
fail(`unexpected argument: ${a} (quote the prompt as one argument)`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (!prompt || !prompt.trim() || !deck) {
|
|
356
|
+
fail(
|
|
357
|
+
'usage: slidemind imagen "<prompt>" --deck <deck> [--out <name.png>] ' +
|
|
358
|
+
"[--size auto|1024x1024|1536x1024|1024x1536] [--quality low|medium|high|auto] [--brand-style]",
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
const deckDir = resolveDeckArg(deck);
|
|
362
|
+
const bundleMod = await import(
|
|
363
|
+
pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
|
|
364
|
+
);
|
|
365
|
+
const ref = bundleMod.readDeckBundleRef(deckDir);
|
|
366
|
+
if (!ref?.presFP) {
|
|
367
|
+
fail(
|
|
368
|
+
`deck ${path.basename(deckDir)} is not bound to a design yet — run ` +
|
|
369
|
+
`'slidemind ready ${deck}' first (imagen routes by the deck's bound design).`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
const client = await import(
|
|
373
|
+
pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
|
|
374
|
+
);
|
|
375
|
+
process.stdout.write(" generating image (this can take a minute)…\n");
|
|
376
|
+
let result;
|
|
377
|
+
try {
|
|
378
|
+
result = await client.generateImageForBrand(ref.presFP, {
|
|
379
|
+
prompt,
|
|
380
|
+
size,
|
|
381
|
+
quality,
|
|
382
|
+
brandStyle,
|
|
383
|
+
});
|
|
384
|
+
} catch (err) {
|
|
385
|
+
if (err?.code === "imagen_route_unavailable") {
|
|
386
|
+
fail(
|
|
387
|
+
`${err.message}\n The paired add-in does not offer image generation yet — ` +
|
|
388
|
+
`it ships with the next add-in update; until then add pictures to assets/ by hand ` +
|
|
389
|
+
`or via 'slidemind download-result'.`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
throw err;
|
|
393
|
+
}
|
|
394
|
+
const m = /^data:image\/(png|jpe?g);base64,(.+)$/.exec(result?.image ?? "");
|
|
395
|
+
if (!m) fail("backend returned no image data");
|
|
396
|
+
const ext = m[1] === "png" ? "png" : "jpeg";
|
|
397
|
+
const assetsDir = path.join(deckDir, "assets");
|
|
398
|
+
fs.mkdirSync(assetsDir, { recursive: true });
|
|
399
|
+
let file = outName;
|
|
400
|
+
if (!file) {
|
|
401
|
+
let n = 1;
|
|
402
|
+
while (fs.existsSync(path.join(assetsDir, `imagen-${n}.${ext}`))) n++;
|
|
403
|
+
file = `imagen-${n}.${ext}`;
|
|
404
|
+
}
|
|
405
|
+
const outPath = path.join(assetsDir, file);
|
|
406
|
+
fs.writeFileSync(outPath, Buffer.from(m[2], "base64"));
|
|
407
|
+
const styled = result.styleReferences > 0 ? ` (styled on ${result.styleReferences} brand sample(s))` : "";
|
|
408
|
+
process.stdout.write(
|
|
409
|
+
`Wrote ${path.relative(process.cwd(), outPath)}${styled}\n` +
|
|
410
|
+
` Reference it from slide props as "assets/${file}" (e.g. summaryImage, illustration).\n`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
309
414
|
async function cmdBuildPptx(rest) {
|
|
310
415
|
const { deck, passthrough } = splitDeckArg(rest, "build-pptx");
|
|
311
416
|
let to = null;
|
|
@@ -328,6 +433,57 @@ async function cmdBuildPptx(rest) {
|
|
|
328
433
|
if (!fs.existsSync(path.join(deckDir, "slides"))) {
|
|
329
434
|
fail(`expected 'slides/' folder in ${deckDir}`);
|
|
330
435
|
}
|
|
436
|
+
|
|
437
|
+
// Template packs (mode: fill-template) build LOCALLY: the deck's slides are
|
|
438
|
+
// declarative fills over a template .pptx that ships in the pack, so no
|
|
439
|
+
// add-in render is involved and no PowerPoint host is needed. Compose decks
|
|
440
|
+
// take the bridge path below UNTOUCHED — the mode probe never throws, so a
|
|
441
|
+
// compose deck with an unmaterialized bundle still self-heals in the render.
|
|
442
|
+
const resolveMod = await import(
|
|
443
|
+
pathToFileURL(path.join(SRC_DIR, "builders", "resolve-skill.mjs")).href
|
|
444
|
+
);
|
|
445
|
+
if (resolveMod.resolveDeckMode(deckDir).mode === "fill-template") {
|
|
446
|
+
const skill = resolveMod.resolveSkillForDeck(deckDir);
|
|
447
|
+
if (to) {
|
|
448
|
+
fail(
|
|
449
|
+
"--to targets a PowerPoint window, but fill-template decks build " +
|
|
450
|
+
"locally from the pack's template — drop --to (use 'slidemind push' " +
|
|
451
|
+
"to get slides into a window).",
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
// In-deck freeform slides (create-slides JSX next to the master-backed
|
|
455
|
+
// ones) render to sibling NN.pptx artifacts first — the only step that
|
|
456
|
+
// talks to the add-in; unchanged freeform slides keep the build offline.
|
|
457
|
+
const freeform = await import(
|
|
458
|
+
pathToFileURL(path.join(SRC_DIR, "pptx", "fill", "freeform.mjs")).href
|
|
459
|
+
);
|
|
460
|
+
const ff = await freeform.ensureFreeformRenders({
|
|
461
|
+
deckDir,
|
|
462
|
+
locale: skill.locale ?? "en",
|
|
463
|
+
srcDir: SRC_DIR,
|
|
464
|
+
onLog: (line) => process.stdout.write(` ${line}\n`),
|
|
465
|
+
});
|
|
466
|
+
for (const w of ff.warnings) process.stderr.write(` warning: ${w}\n`);
|
|
467
|
+
|
|
468
|
+
const engine = await import(
|
|
469
|
+
pathToFileURL(path.join(SRC_DIR, "pptx", "fill", "engine.mjs")).href
|
|
470
|
+
);
|
|
471
|
+
const result = await engine.buildFillTemplateDeck({
|
|
472
|
+
deckDir,
|
|
473
|
+
skill,
|
|
474
|
+
outPath,
|
|
475
|
+
onLog: (line) => process.stdout.write(` ${line}\n`),
|
|
476
|
+
});
|
|
477
|
+
process.stdout.write(
|
|
478
|
+
`Wrote ${result.outPath} (${result.slideCount} slides) from template ` +
|
|
479
|
+
`'${skill.templateKey}' — host-free\n`,
|
|
480
|
+
);
|
|
481
|
+
for (const w of result.warnings ?? []) {
|
|
482
|
+
process.stderr.write(` warning: ${w}\n`);
|
|
483
|
+
}
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
331
487
|
// The .pptx is rendered server-side by the add-in (one exporter, one truth).
|
|
332
488
|
// A bound deck renders pane-free over the brand routes — no PowerPoint
|
|
333
489
|
// window needed; unbound decks (or --to) resolve through the pane registry.
|
|
@@ -433,10 +589,96 @@ async function cmdViewDraft(rest) {
|
|
|
433
589
|
}
|
|
434
590
|
}
|
|
435
591
|
|
|
592
|
+
// Turn the add-in's deliberately terse search failures into something actionable.
|
|
593
|
+
// It withholds detail on purpose: the raw message used to carry the built OData
|
|
594
|
+
// filter and physical index names, and "Engine not found" doubles as a permission
|
|
595
|
+
// denial so engine existence stays undisclosed. So the CLI can't diagnose — it can
|
|
596
|
+
// only name the next move.
|
|
597
|
+
function searchFailureHint(error, engineId) {
|
|
598
|
+
const raw = error ?? "unknown error";
|
|
599
|
+
if (/engine not found/i.test(raw)) {
|
|
600
|
+
return (
|
|
601
|
+
`${raw} — '${engineId}' is unknown or not visible to you. ` +
|
|
602
|
+
`Re-run 'slidemind ls engines' (ids can be retired) and pick from the live list, ` +
|
|
603
|
+
`or search 'scope:all' to skip engines entirely.`
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
if (/^search failed$/i.test(raw)) {
|
|
607
|
+
return (
|
|
608
|
+
`${raw} — the add-in withholds the reason (it can leak index internals); ` +
|
|
609
|
+
`the detail is in the add-in's logs. Retry once, then try 'scope:all'.`
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
return raw;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Zero results is now a routine, ambiguous outcome: the add-in answers "success,
|
|
616
|
+
// nothing" for a corpus that isn't connected, one that is still embedding, and a
|
|
617
|
+
// folder filter that matches nothing — all indistinguishable on the wire. Say that
|
|
618
|
+
// instead of letting an empty list read as "this content does not exist".
|
|
619
|
+
function writeEmptySearchDiagnostic(engineId, bundleMod) {
|
|
620
|
+
const isScope = bundleMod.isSearchScopeId(engineId);
|
|
621
|
+
const lines = [
|
|
622
|
+
` No results — this does NOT mean the content doesn't exist. Any of these looks identical here:`,
|
|
623
|
+
` · nothing is connected yet, so there is no corpus to search`,
|
|
624
|
+
` · the corpus is still indexing (content appears progressively)`,
|
|
625
|
+
];
|
|
626
|
+
if (!isScope) {
|
|
627
|
+
lines.push(
|
|
628
|
+
` · this engine is a saved FOLDER FILTER and the content sits outside it`,
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
lines.push(` Next:`);
|
|
632
|
+
if (!isScope) {
|
|
633
|
+
lines.push(
|
|
634
|
+
` · widen to the unfiltered corpus: slidemind search "<query>" --engine ${bundleMod.SCOPE_ALL} --result-type slide`,
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
lines.push(
|
|
638
|
+
` · re-word the query (content search matches phrasing, so a synonym can decide a hit)`,
|
|
639
|
+
` · slidemind ls engines — see every engine and scope you can reach`,
|
|
640
|
+
);
|
|
641
|
+
if (isScope) {
|
|
642
|
+
lines.push(
|
|
643
|
+
` · if the unfiltered corpus is also empty, report it as "not found" and name what you searched — don't author a replacement as if reuse had been tried`,
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Write the search's numbered collage to a file and tell the agent to look at it.
|
|
650
|
+
//
|
|
651
|
+
// The add-in composes ONE bounded 800x600 image showing every candidate with its
|
|
652
|
+
// selection number burned in — the same picture the in-app agent sees. That is the
|
|
653
|
+
// intended way to choose a visual result: one look at one file, rather than N
|
|
654
|
+
// per-result previews (which is also why the relay ack carries no per-result bytes
|
|
655
|
+
// — a corpus image is multi-megabyte and N of them can't cross the relay).
|
|
656
|
+
//
|
|
657
|
+
// Best-effort by design: a missing/…unparseable grid must never fail a search that
|
|
658
|
+
// otherwise returned usable results. Content-intent searches have no grid at all —
|
|
659
|
+
// their signal is the printed text.
|
|
660
|
+
function writeSearchGrid(gridDataUri, searchId, resolveExt) {
|
|
661
|
+
if (typeof gridDataUri !== "string" || !gridDataUri.startsWith("data:")) {
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
try {
|
|
665
|
+
const m = /^data:([^;]+);base64,(.+)$/.exec(gridDataUri);
|
|
666
|
+
if (!m) return null;
|
|
667
|
+
const bytes = Buffer.from(m[2], "base64");
|
|
668
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "slidemind-grid-"));
|
|
669
|
+
const target = path.join(dir, `grid-${searchId}.${resolveExt(m[1], bytes)}`);
|
|
670
|
+
fs.writeFileSync(target, bytes);
|
|
671
|
+
return target;
|
|
672
|
+
} catch {
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
436
677
|
async function cmdSearch(rest) {
|
|
437
678
|
let asJson = false;
|
|
438
679
|
let to = null;
|
|
439
680
|
let engineId = null;
|
|
681
|
+
let resultType = null;
|
|
440
682
|
let intent = null;
|
|
441
683
|
let top = null;
|
|
442
684
|
const queryParts = [];
|
|
@@ -450,6 +692,12 @@ async function cmdSearch(rest) {
|
|
|
450
692
|
} else if (a === "--engine" || a === "-e" || a === "--engineId") {
|
|
451
693
|
engineId = rest[++i];
|
|
452
694
|
if (!engineId) fail("--engine expects an engineId");
|
|
695
|
+
} else if (a === "--result-type" || a === "--resultType") {
|
|
696
|
+
resultType = rest[++i];
|
|
697
|
+
if (!resultType) fail("--result-type expects slide|image");
|
|
698
|
+
if (!["slide", "image"].includes(resultType)) {
|
|
699
|
+
fail(`--result-type expects slide|image, got '${resultType}'`);
|
|
700
|
+
}
|
|
453
701
|
} else if (a === "--intent") {
|
|
454
702
|
intent = rest[++i];
|
|
455
703
|
if (!intent) fail("--intent expects auto|content|visual");
|
|
@@ -472,7 +720,7 @@ async function cmdSearch(rest) {
|
|
|
472
720
|
const query = queryParts.join(" ").trim();
|
|
473
721
|
if (!query) {
|
|
474
722
|
fail(
|
|
475
|
-
`usage: slidemind search "<query>" [--engine <id>] [--intent auto|content|visual] [--top <N>] [--to <presId>] [--json]`,
|
|
723
|
+
`usage: slidemind search "<query>" [--engine <id|scope:all|scope:sharepoint>] [--result-type slide|image] [--intent auto|content|visual] [--top <N>] [--to <presId>] [--json]`,
|
|
476
724
|
);
|
|
477
725
|
}
|
|
478
726
|
|
|
@@ -484,6 +732,9 @@ async function cmdSearch(rest) {
|
|
|
484
732
|
const bundle = await import(
|
|
485
733
|
pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
|
|
486
734
|
);
|
|
735
|
+
const { resolveAssetExt } = await import(
|
|
736
|
+
pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
|
|
737
|
+
);
|
|
487
738
|
const registry = await client.requirePaneHost();
|
|
488
739
|
// The engine is per-user, not window-bound — so search just attaches to any
|
|
489
740
|
// connected window (preferring a live one). No "which window?" prompt; pass
|
|
@@ -493,42 +744,64 @@ async function cmdSearch(rest) {
|
|
|
493
744
|
const presId = entry.presId;
|
|
494
745
|
const backendURL = entry.backendURL ?? null;
|
|
495
746
|
|
|
496
|
-
//
|
|
497
|
-
// only applies to image/visual intent. content/visual on a slide engine is
|
|
498
|
-
// the caller's choice via --engine.
|
|
747
|
+
// An explicit content/visual intent means "search slides", not imagery.
|
|
499
748
|
const slideIntent = intent === "content" || intent === "visual";
|
|
500
749
|
// Thread the engine subtype on the wire whenever the CLI can determine it:
|
|
501
750
|
// - the pickImageEngine default branch is, by construction, an image engine;
|
|
502
|
-
// - an explicit slide intent (--intent content|visual) targets
|
|
503
|
-
// engine.
|
|
751
|
+
// - an explicit slide intent (--intent content|visual) targets slides.
|
|
504
752
|
// This is belt-and-suspenders for an add-in regression where a missing
|
|
505
753
|
// subtype no longer defaulted to "image"; when the subtype is unknown we omit
|
|
506
754
|
// it and let the add-in resolve it from the snapshot.
|
|
507
755
|
let subtype = slideIntent ? "slide" : null;
|
|
508
756
|
if (!engineId) {
|
|
509
757
|
if (slideIntent) {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
758
|
+
// Slide search with no engine named: search the corpus UNFILTERED rather
|
|
759
|
+
// than refusing. An engine is a saved folder filter over the add-in's
|
|
760
|
+
// shared corpus, so a workspace can hold a full corpus and zero slide
|
|
761
|
+
// engines — demanding --engine here dead-ended exactly that case.
|
|
762
|
+
engineId = bundle.SCOPE_ALL;
|
|
763
|
+
resultType = resultType ?? "slide";
|
|
764
|
+
} else {
|
|
765
|
+
// Imagery: resolve a real image engine (a scope is not a brand image
|
|
766
|
+
// source). Ask the LIVE catalog first — the same one the add-in's own
|
|
767
|
+
// design agent reads — and fall back to the materialized bundle only when
|
|
768
|
+
// the host can't answer. Bundle-first would be stale: engine ids can be
|
|
769
|
+
// retired server-side, and a dead id surfaces as "Engine not found".
|
|
520
770
|
const live = await client
|
|
521
771
|
.listEngines({ subtype: "image", backendURL })
|
|
522
772
|
.catch(() => ({ engines: [] }));
|
|
523
|
-
pick = bundle.pickImageEngine(live.engines);
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
773
|
+
let pick = bundle.pickImageEngine(live.engines);
|
|
774
|
+
if (!pick) {
|
|
775
|
+
// Best-effort: a bundle that can't be materialized (e.g. an incomplete
|
|
776
|
+
// one) must not replace the actionable "wire an image engine" message
|
|
777
|
+
// below with a bundle error — the live catalog is the real answer here.
|
|
778
|
+
const mat = await bundle
|
|
779
|
+
.materializeBundle(presId, { backendURL })
|
|
780
|
+
.catch(() => null);
|
|
781
|
+
if (mat) pick = bundle.pickImageEngine(bundle.readEnginesFromDir(mat.dir));
|
|
782
|
+
}
|
|
783
|
+
if (!pick) {
|
|
784
|
+
fail(
|
|
785
|
+
"This corporate design has no image engines to search. Run 'slidemind ls engines' to see what exists, or ask the add-in owner to wire an image/icon engine.",
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
engineId = pick.id;
|
|
789
|
+
subtype = "image";
|
|
529
790
|
}
|
|
530
|
-
|
|
531
|
-
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// A scope has no subtype of its own, so the index must be named explicitly —
|
|
794
|
+
// the add-in rejects a scope search without it. Default to slides: reusing an
|
|
795
|
+
// existing slide is what an unfiltered corpus search is overwhelmingly for.
|
|
796
|
+
if (bundle.isSearchScopeId(engineId)) {
|
|
797
|
+
resultType = resultType ?? "slide";
|
|
798
|
+
subtype = resultType;
|
|
799
|
+
} else if (resultType) {
|
|
800
|
+
// Harmless but meaningless on an engine, whose own subtype is authoritative.
|
|
801
|
+
process.stderr.write(
|
|
802
|
+
`note: --result-type is ignored for engine '${engineId}' (it applies to scope:* targets); the engine's own type decides.\n`,
|
|
803
|
+
);
|
|
804
|
+
resultType = null;
|
|
532
805
|
}
|
|
533
806
|
|
|
534
807
|
// Window-agnostic: tries the picked pane, then fails over to any other live
|
|
@@ -540,6 +813,7 @@ async function cmdSearch(rest) {
|
|
|
540
813
|
engineId,
|
|
541
814
|
query,
|
|
542
815
|
subtype,
|
|
816
|
+
resultType,
|
|
543
817
|
intent: intent ?? null,
|
|
544
818
|
top: top ?? null,
|
|
545
819
|
});
|
|
@@ -550,7 +824,7 @@ async function cmdSearch(rest) {
|
|
|
550
824
|
return;
|
|
551
825
|
}
|
|
552
826
|
if (!res?.ok) {
|
|
553
|
-
process.stderr.write(`search failed: ${res?.error
|
|
827
|
+
process.stderr.write(`search failed: ${searchFailureHint(res?.error, engineId)}\n`);
|
|
554
828
|
return;
|
|
555
829
|
}
|
|
556
830
|
// Prefer the new `results` array (image + slide engines); fall back to the
|
|
@@ -559,11 +833,18 @@ async function cmdSearch(rest) {
|
|
|
559
833
|
? res.results
|
|
560
834
|
: (res.thumbnails ?? []).map((t, i) => ({ selection: i + 1, title: t.title }));
|
|
561
835
|
const count = res.resultCount ?? results.length;
|
|
836
|
+
const targetLabel = bundle.isSearchScopeId(engineId)
|
|
837
|
+
? `scope ${engineId}`
|
|
838
|
+
: `engine ${engineId}`;
|
|
562
839
|
process.stdout.write(
|
|
563
|
-
`searchId ${res.searchId} — ${count} result(s) from
|
|
840
|
+
`searchId ${res.searchId} — ${count} result(s) from ${targetLabel}` +
|
|
564
841
|
(res.intent ? ` (${res.intent})` : "") +
|
|
565
842
|
`\n`,
|
|
566
843
|
);
|
|
844
|
+
if (count === 0) {
|
|
845
|
+
writeEmptySearchDiagnostic(engineId, bundle);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
567
848
|
const isContent = res.intent === "content";
|
|
568
849
|
results.forEach((r, i) => {
|
|
569
850
|
const sel = r.selection ?? i + 1;
|
|
@@ -577,10 +858,27 @@ async function cmdSearch(rest) {
|
|
|
577
858
|
}
|
|
578
859
|
}
|
|
579
860
|
} else {
|
|
580
|
-
// Visual / image results: the numbered list.
|
|
581
|
-
|
|
861
|
+
// Visual / image results: the numbered list. An image result carries its
|
|
862
|
+
// intrinsic size from the engine index — show it, so the box can be sized
|
|
863
|
+
// to the real aspect ratio at authoring time rather than after the fetch.
|
|
864
|
+
const dims =
|
|
865
|
+
r.kind === "image" && Number.isFinite(r.width) && Number.isFinite(r.height)
|
|
866
|
+
? ` ${r.width}×${r.height}`
|
|
867
|
+
: "";
|
|
868
|
+
process.stdout.write(` ${sel}. ${r.title ?? "(untitled)"}${dims}\n`);
|
|
582
869
|
}
|
|
583
870
|
});
|
|
871
|
+
// Visual results: point at the ONE collage rather than making the caller pull
|
|
872
|
+
// each preview separately. The numbers burned into it match the list above.
|
|
873
|
+
if (!isContent) {
|
|
874
|
+
const gridPath = writeSearchGrid(res.gridDataUri, res.searchId, resolveAssetExt);
|
|
875
|
+
if (gridPath) {
|
|
876
|
+
process.stdout.write(
|
|
877
|
+
`Contact sheet of all ${count} result(s) — LOOK AT THIS FILE to choose, ` +
|
|
878
|
+
`don't fetch previews one by one: ${gridPath}\n`,
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
584
882
|
// Tailor the next-step hint to the result kind: slide verbs (insert-slide /
|
|
585
883
|
// add-slide) for slide results, the image-download hint otherwise. The result
|
|
586
884
|
// subtype is authoritative; fall back to a per-result `kind` for older
|
|
@@ -609,7 +907,7 @@ async function cmdSearch(rest) {
|
|
|
609
907
|
async function cmdLs(rest) {
|
|
610
908
|
const [sub, ...subRest] = rest;
|
|
611
909
|
if (sub !== "engines") {
|
|
612
|
-
fail("usage: slidemind ls engines [--type collection|search] [--subtype image|slide|template|content] [--json]");
|
|
910
|
+
fail("usage: slidemind ls engines [--type collection|search|scope] [--subtype image|slide|template|content] [--json]");
|
|
613
911
|
}
|
|
614
912
|
let type = null;
|
|
615
913
|
let subtype = null;
|
|
@@ -620,9 +918,10 @@ async function cmdLs(rest) {
|
|
|
620
918
|
const a = subRest[i];
|
|
621
919
|
if (a === "--type") {
|
|
622
920
|
type = subRest[++i];
|
|
623
|
-
if (!type) fail("--type expects collection|search");
|
|
624
|
-
|
|
625
|
-
|
|
921
|
+
if (!type) fail("--type expects collection|search|scope");
|
|
922
|
+
// "scope" narrows to the non-engine targets (scope:all / scope:sharepoint).
|
|
923
|
+
if (!["collection", "search", "scope"].includes(type)) {
|
|
924
|
+
fail(`--type expects collection|search|scope, got '${type}'`);
|
|
626
925
|
}
|
|
627
926
|
} else if (a === "--subtype") {
|
|
628
927
|
subtype = subRest[++i];
|
|
@@ -659,7 +958,18 @@ async function cmdLs(rest) {
|
|
|
659
958
|
return;
|
|
660
959
|
}
|
|
661
960
|
if (engines.length === 0) {
|
|
662
|
-
|
|
961
|
+
// Deliberately not "nothing to search": an engine is a saved folder filter
|
|
962
|
+
// over the add-in's shared corpus, so an empty catalog says only that nobody
|
|
963
|
+
// saved a filter. An older add-in that predates scopes lands here too.
|
|
964
|
+
const { SCOPE_ALL } = await import(
|
|
965
|
+
pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
|
|
966
|
+
);
|
|
967
|
+
process.stderr.write(
|
|
968
|
+
"No engines or scopes matched. An engine is a saved folder filter over the corpus, " +
|
|
969
|
+
"so an empty list does NOT mean there is nothing to search — try " +
|
|
970
|
+
`'slidemind search "<query>" --engine ${SCOPE_ALL} --result-type slide' ` +
|
|
971
|
+
"to search it unfiltered.\n",
|
|
972
|
+
);
|
|
663
973
|
return;
|
|
664
974
|
}
|
|
665
975
|
// Show the env tag only when more than one environment contributed engines.
|
|
@@ -668,7 +978,15 @@ async function cmdLs(rest) {
|
|
|
668
978
|
const kind = [e.type, e.subtype].filter(Boolean).join("/");
|
|
669
979
|
const env = multiEnv && e.env ? `[${e.env}] ` : "";
|
|
670
980
|
const desc = e.description ? ` — ${e.description}` : "";
|
|
671
|
-
|
|
981
|
+
// A scope takes --result-type instead of having a subtype of its own; spell
|
|
982
|
+
// that out so the agent doesn't have to infer it from the missing subtype.
|
|
983
|
+
const hint =
|
|
984
|
+
e.type === "scope"
|
|
985
|
+
? ` (needs --result-type ${(e.capabilities?.resultTypes ?? ["slide", "image"]).join("|")})`
|
|
986
|
+
: "";
|
|
987
|
+
process.stdout.write(
|
|
988
|
+
`${env}${e.id} · ${e.label ?? e.id} · ${kind}${hint}${desc}\n`,
|
|
989
|
+
);
|
|
672
990
|
}
|
|
673
991
|
}
|
|
674
992
|
|
|
@@ -867,18 +1185,61 @@ async function cmdAddSlide(rest) {
|
|
|
867
1185
|
const reused = await import(
|
|
868
1186
|
pathToFileURL(path.join(SRC_DIR, "lib", "reused-slides.mjs")).href
|
|
869
1187
|
);
|
|
1188
|
+
const { createTransferProgress } = await import(
|
|
1189
|
+
pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
|
|
1190
|
+
);
|
|
1191
|
+
// Stream each pick to a provisional file: the play-order filename can only be
|
|
1192
|
+
// computed once every pick is in hand, and buffering a multi-megabyte slide in
|
|
1193
|
+
// memory just to name it would defeat the streaming path.
|
|
870
1194
|
const extracted = [];
|
|
871
|
-
|
|
872
|
-
const
|
|
873
|
-
|
|
874
|
-
searchId,
|
|
875
|
-
selection: pick,
|
|
876
|
-
backendURL,
|
|
877
|
-
});
|
|
878
|
-
if (!res?.ok || !res.base64) {
|
|
879
|
-
fail(`add-slide failed for selection ${pick}: ${res?.error ?? "no slide bytes returned"}`);
|
|
1195
|
+
const cleanupTemps = () => {
|
|
1196
|
+
for (const s of extracted) {
|
|
1197
|
+
if (s.tempPath) fs.rmSync(s.tempPath, { force: true });
|
|
880
1198
|
}
|
|
881
|
-
|
|
1199
|
+
};
|
|
1200
|
+
try {
|
|
1201
|
+
for (const pick of picks) {
|
|
1202
|
+
const tempPath = path.join(slidesDir, `.reused-${searchId}-${pick}.part`);
|
|
1203
|
+
if (typeof client.extractSlideToFile === "function") {
|
|
1204
|
+
try {
|
|
1205
|
+
const res = await client.extractSlideToFile({
|
|
1206
|
+
presId,
|
|
1207
|
+
searchId,
|
|
1208
|
+
selection: pick,
|
|
1209
|
+
destPath: tempPath,
|
|
1210
|
+
backendURL,
|
|
1211
|
+
onProgress: createTransferProgress(`extracting slide ${pick}`),
|
|
1212
|
+
});
|
|
1213
|
+
if (!res?.bytesWritten) throw new Error("stream produced no bytes");
|
|
1214
|
+
extracted.push({ selection: pick, slideId: res.slideId ?? null, tempPath });
|
|
1215
|
+
continue;
|
|
1216
|
+
} catch (err) {
|
|
1217
|
+
fs.rmSync(tempPath, { force: true });
|
|
1218
|
+
// Route absent (bodyless 404 / 501) → this add-in predates the streaming
|
|
1219
|
+
// route; fall through to the capped base64 ack path below.
|
|
1220
|
+
const routeMissing =
|
|
1221
|
+
(err?.status === 404 && !err?.code) || err?.status === 501;
|
|
1222
|
+
if (!routeMissing) {
|
|
1223
|
+
cleanupTemps();
|
|
1224
|
+
fail(`add-slide failed for selection ${pick}: ${err.message}`);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
const res = await client.extractSlide({
|
|
1229
|
+
presId,
|
|
1230
|
+
searchId,
|
|
1231
|
+
selection: pick,
|
|
1232
|
+
backendURL,
|
|
1233
|
+
});
|
|
1234
|
+
if (!res?.ok || !res.base64) {
|
|
1235
|
+
cleanupTemps();
|
|
1236
|
+
fail(`add-slide failed for selection ${pick}: ${res?.error ?? "no slide bytes returned"}`);
|
|
1237
|
+
}
|
|
1238
|
+
extracted.push({ selection: pick, slideId: res.slideId ?? null, base64: res.base64 });
|
|
1239
|
+
}
|
|
1240
|
+
} catch (err) {
|
|
1241
|
+
cleanupTemps();
|
|
1242
|
+
throw err;
|
|
882
1243
|
}
|
|
883
1244
|
const existing = fs
|
|
884
1245
|
.readdirSync(slidesDir)
|
|
@@ -887,7 +1248,11 @@ async function cmdAddSlide(rest) {
|
|
|
887
1248
|
const written = [];
|
|
888
1249
|
extracted.forEach((slide, i) => {
|
|
889
1250
|
const target = path.join(slidesDir, targets[i].filename);
|
|
890
|
-
|
|
1251
|
+
if (slide.tempPath) {
|
|
1252
|
+
fs.renameSync(slide.tempPath, target);
|
|
1253
|
+
} else {
|
|
1254
|
+
fs.writeFileSync(target, Buffer.from(slide.base64, "base64"));
|
|
1255
|
+
}
|
|
891
1256
|
written.push(path.relative(process.cwd(), target));
|
|
892
1257
|
});
|
|
893
1258
|
process.stdout.write(
|
|
@@ -957,7 +1322,7 @@ async function cmdViewSlide(rest) {
|
|
|
957
1322
|
const client = await import(
|
|
958
1323
|
pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
|
|
959
1324
|
);
|
|
960
|
-
const {
|
|
1325
|
+
const { resolveAssetExt } = await import(
|
|
961
1326
|
pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
|
|
962
1327
|
);
|
|
963
1328
|
const registry = await client.requirePaneHost();
|
|
@@ -979,8 +1344,15 @@ async function cmdViewSlide(rest) {
|
|
|
979
1344
|
? path.resolve(process.cwd(), outDir)
|
|
980
1345
|
: fs.mkdtempSync(path.join(os.tmpdir(), "slidemind-view-"));
|
|
981
1346
|
fs.mkdirSync(dir, { recursive: true });
|
|
982
|
-
|
|
983
|
-
|
|
1347
|
+
// Extension from the BYTES, not the declared MIME — engines inherit a
|
|
1348
|
+
// Content-Type from upstream and it is demonstrably wrong for some corpus
|
|
1349
|
+
// images (PNG served as image/jpeg).
|
|
1350
|
+
const bytes = Buffer.from(m[2], "base64");
|
|
1351
|
+
const target = path.join(
|
|
1352
|
+
dir,
|
|
1353
|
+
`slide-${searchId}-${pick}.${resolveAssetExt(m[1], bytes)}`,
|
|
1354
|
+
);
|
|
1355
|
+
fs.writeFileSync(target, bytes);
|
|
984
1356
|
process.stdout.write(
|
|
985
1357
|
`Wrote preview of result ${pick} (searchId ${searchId}) to ${target}\n`,
|
|
986
1358
|
);
|
|
@@ -1058,7 +1430,11 @@ async function cmdDownloadResult(rest) {
|
|
|
1058
1430
|
? r.value
|
|
1059
1431
|
: { selection: picks[i], error: r.reason?.message ?? String(r.reason) },
|
|
1060
1432
|
);
|
|
1433
|
+
const { formatSize } = await import(
|
|
1434
|
+
pathToFileURL(path.join(SRC_DIR, "bridge", "image-dimensions.mjs")).href
|
|
1435
|
+
);
|
|
1061
1436
|
let ok = 0;
|
|
1437
|
+
let anySized = false;
|
|
1062
1438
|
const lines = [];
|
|
1063
1439
|
for (const r of results) {
|
|
1064
1440
|
if (r.error) {
|
|
@@ -1067,12 +1443,21 @@ async function cmdDownloadResult(rest) {
|
|
|
1067
1443
|
}
|
|
1068
1444
|
ok++;
|
|
1069
1445
|
const note = r.recolor?.note ? ` — ${r.recolor.note}` : "";
|
|
1070
|
-
|
|
1446
|
+
// The intrinsic size is the agent's only handle on the asset's aspect
|
|
1447
|
+
// ratio — without it the <img> box is a guess and the render stretches.
|
|
1448
|
+
const dims = formatSize(r.size);
|
|
1449
|
+
anySized ||= Boolean(dims);
|
|
1450
|
+
const size = dims ? ` ${dims}` : " (size unknown)";
|
|
1451
|
+
lines.push(` ${r.reference}${size} (${r.recolor?.mode ?? "?"})${note}`);
|
|
1071
1452
|
}
|
|
1072
1453
|
process.stdout.write(
|
|
1073
1454
|
`Downloaded ${ok}/${results.length} result(s) into ${path.relative(process.cwd(), assetsDir)}:\n` +
|
|
1074
1455
|
lines.join("\n") +
|
|
1075
|
-
`\nReference each from slide JSX as <img src="./assets/...">.\n
|
|
1456
|
+
`\nReference each from slide JSX as <img src="./assets/...">.\n` +
|
|
1457
|
+
(anySized
|
|
1458
|
+
? `Sizes above are the assets' intrinsic width×height — give each <img> a box ` +
|
|
1459
|
+
`with that aspect ratio, or the render stretches it to whatever box you wrote.\n`
|
|
1460
|
+
: ""),
|
|
1076
1461
|
);
|
|
1077
1462
|
if (ok === 0) process.exit(1);
|
|
1078
1463
|
}
|
|
@@ -1561,6 +1946,10 @@ async function cmdReady(rest) {
|
|
|
1561
1946
|
const client = await import(
|
|
1562
1947
|
pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
|
|
1563
1948
|
);
|
|
1949
|
+
// Same opt-out as doctor: `ready` OWNS the cli_update_required blocker (it is
|
|
1950
|
+
// terminal, `actor: "agent"`, with the update command as its nextAction), so
|
|
1951
|
+
// the raw header throw would only replace a structured answer with a worse one.
|
|
1952
|
+
client.setDiagnosticMode(true);
|
|
1564
1953
|
|
|
1565
1954
|
// --- Network (one parallel round): readiness ladder + the merged registry. ---
|
|
1566
1955
|
const [statusRaw, detailed] = await Promise.all([
|
|
@@ -1878,6 +2267,9 @@ async function cmdDoctor(rest) {
|
|
|
1878
2267
|
const client = await import(
|
|
1879
2268
|
pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
|
|
1880
2269
|
);
|
|
2270
|
+
// doctor diagnoses skew — it must never be killed BY skew. The version floor
|
|
2271
|
+
// still reaches the reader, as the `cli_update_required` rung of the ladder.
|
|
2272
|
+
client.setDiagnosticMode(true);
|
|
1881
2273
|
if (sessions.length === 0) {
|
|
1882
2274
|
const pub = {
|
|
1883
2275
|
paired: false,
|
|
@@ -2610,6 +3002,9 @@ async function main(rawArgv) {
|
|
|
2610
3002
|
case "review":
|
|
2611
3003
|
await cmdReview(rest);
|
|
2612
3004
|
return 0;
|
|
3005
|
+
case "imagen":
|
|
3006
|
+
await cmdImagen(rest);
|
|
3007
|
+
break;
|
|
2613
3008
|
case "build-pptx":
|
|
2614
3009
|
await cmdBuildPptx(rest);
|
|
2615
3010
|
return 0;
|