pindeck 3.4.0 → 4.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.mjs CHANGED
@@ -10,20 +10,20 @@
10
10
  - answers `cli.mjs layout|shot|slides|pdf|pdf-figure` for the assistant's self-checks, exports and PDF figures
11
11
  - exports the deck as a PDF handout or a PDF presentation (build steps on separate pages)
12
12
  - stores figures and audio attached to chat messages in <deck>/figures/ for the assistant to use, and attached
13
- PDFs (papers, reports, figures) in ~/.pindeck-v3, outside the deck
13
+ PDFs (papers, reports, figures) in ~/.pindeck-v4, outside the deck
14
14
  - imports slides from PowerPoint files in the deck's own layouts
15
15
  Listens on 127.0.0.1 only; the editor, the deck and every API call need the per-run token (a login
16
16
  cookie in the browser, a header for the CLI).
17
- Runtime files (chat, screenshots, backups) live in ~/.pindeck-v3, never in the deck or the package. */
17
+ Runtime files (chat, screenshots, backups) live in ~/.pindeck-v4, never in the deck or the package. */
18
18
 
19
19
  import http from "node:http";
20
20
  import { EventEmitter } from "node:events";
21
21
  import fs from "node:fs";
22
22
  import path from "node:path";
23
23
  import crypto from "node:crypto";
24
- import { spawn, execFile } from "node:child_process";
24
+ import { spawn, execFile, execFileSync } from "node:child_process";
25
25
  import { fileURLToPath, pathToFileURL } from "node:url";
26
- import { assignEids, parse, findSlides, slideSelector, deckSize, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd, decode } from "./lib/eids.mjs";
26
+ import { assignEids, parse, findSlides, slideSelector, deckSize, setStyles, getInner, getOuter, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd, decode } from "./lib/eids.mjs";
27
27
  import { unifiedDiff } from "./lib/diff.mjs";
28
28
  import { ClaudeSession } from "./lib/claude.mjs";
29
29
  import { CodexSession } from "./lib/codex.mjs";
@@ -44,12 +44,14 @@ import { assetRoots } from "./lib/asset-roots.mjs";
44
44
  import { registerServer, printLogin, alive } from "./lib/servers.mjs";
45
45
  import { goodHost, presentHost, LOGIN_PAGE, createLogin, openOnEnter } from "./lib/login.mjs";
46
46
  import { USAGE } from "./lib/usage.mjs";
47
+ import { indexDeck, commentFromPin, locate, loadReview, saveReview, packet, request as commentsRequest, reviewFiles } from "./lib/review.mjs";
47
48
 
48
49
  /* ---- Config ------------------------------------------------------------------ */
49
50
  /* Started by the front (pindeck, front.mjs) for one of the decks it serves: the front logs browsers in and
50
51
  prints the links; this server tells it its address over IPC. */
51
52
  const FRONT = !!process.env.PINDECK_FRONT && !!process.send;
52
53
  const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
54
+ const VERSION = JSON.parse(fs.readFileSync(path.join(EDITOR_DIR, "package.json"), "utf8")).version;
53
55
  const argv = process.argv.slice(2);
54
56
  if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
55
57
  const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
@@ -70,9 +72,21 @@ const MODEL = opt("--model", "");
70
72
  if (process.env.CLAUDE_MODEL && !LOGIN && !FRONT) // PinDeck 2 read it; behind the front, the front says so
71
73
  console.warn(`CLAUDE_MODEL is not used by PinDeck 3: choose the model in the editor's model menu, or start with --model ${process.env.CLAUDE_MODEL}.`);
72
74
  /* The deck: a file (talk.html), or a folder, which means its index.html (see lib/deck-target.mjs) */
73
- const { dir: DECK_DIR, file: DECK_FILE, named: NAMED, name: DECK_NAME, key: deckKey } = deckTarget(argv[0] || process.cwd());
74
- const EDITABLE = [DECK_FILE, "theme.css"]; // both backends may edit these
75
- const LOCKED = ["brand.css"]; // never, enforced twice
75
+ const { dir: DECK_DIR, file: DECK_FILE, named: NAMED, name: DECK_NAME, key: deckKey, source: SOURCE } = deckTarget(argv[0] || process.cwd());
76
+ /* A Quarto deck (SOURCE: talk.qmd): DECK_FILE is what Quarto renders from it, build output. The assistant edits
77
+ the sources; PinDeck renders after every change (see Quarto below) and adds its element ids to the output. */
78
+ const OUTPUTS = SOURCE ? [DECK_FILE, DECK_FILE.replace(/\.html$/i, "_files"), ".quarto"] : [];
79
+ const isOutput = (rel) => OUTPUTS.some((o) => rel === o || rel.startsWith(o + "/"));
80
+ const EDITABLE = SOURCE ? [SOURCE, DECK_FILE] : [DECK_FILE, "theme.css"]; // both backends may edit these (a Quarto deck: its source)
81
+ /* A Quarto deck's format extensions (_extensions/[<org>/]<name>/, e.g. Ocean) carry their own brand.css and vendor/ */
82
+ function extensionBrands() {
83
+ try {
84
+ return fs.readdirSync(path.join(DECK_DIR, "_extensions"), { recursive: true }).map((f) => String(f).split(path.sep).join("/"))
85
+ .filter((f) => /(^|\/)brand\.css$/.test(f) && !f.includes("vendor/")).map((f) => `_extensions/${f}`);
86
+ } catch { return []; }
87
+ }
88
+ const BRANDS = SOURCE ? extensionBrands() : [];
89
+ const LOCKED = ["brand.css", ...BRANDS]; // never, enforced twice
76
90
  const HOME_DIR = homeDir();
77
91
  const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
78
92
  const SHOT_DIR = path.join(STATE_DIR, "shots");
@@ -92,6 +106,34 @@ const CLI_REL = path.relative(DECK_DIR, path.join(EDITOR_DIR, "cli.mjs")).split(
92
106
  const CLI_CMD = cliCommand(CLI_REL);
93
107
  const CAPS = detectCapabilities();
94
108
 
109
+ /* ---- Quarto ----------------------------------------------------------------------------------------
110
+ `quarto render <source>` makes DECK_FILE. One render at a time; a change to a source while one runs renders
111
+ again when it ends. What the assistant checks (shot, layout, slides, pdf) waits for the render, so it always
112
+ sees its last edit. Quarto's own errors go to the chat, and to the assistant. */
113
+ const QUARTO = SOURCE ? findExecutable(process.env.QUARTO_BIN || "quarto") : null;
114
+ const RENDER_TIMEOUT = 180000;
115
+ const SOURCE_FILE = /\.(qmd|md|scss|css|ya?ml|bib|csl|lua|js|html?|png|jpe?g|gif|svg|webp|pdf|mp4|wav|mp3)$/i; // what a render reads
116
+ /* Quarto's message, without colours and its JavaScript stack: what says what is wrong in the source */
117
+ const quartoError = (stderr) => `Quarto could not render ${SOURCE}:\n` + String(stderr).replace(/\x1b\[[0-9;]*m/g, "")
118
+ .split(/\nStack trace:|\n\s+at /)[0].trim().split("\n").slice(-25).join("\n");
119
+ function renderSync() {
120
+ console.log(`Rendering ${SOURCE} with Quarto…`);
121
+ try { execFileSync(QUARTO.path, ["render", SOURCE], { cwd: DECK_DIR, stdio: ["ignore", "ignore", "pipe"], timeout: RENDER_TIMEOUT }); }
122
+ catch (e) { return quartoError(e.stderr || e.message); }
123
+ return fs.existsSync(path.join(DECK_DIR, DECK_FILE)) ? null
124
+ : `Quarto rendered ${SOURCE}, but not to ${DECK_FILE} beside it (an output-dir in _quarto.yml?). PinDeck needs the HTML next to its source.`;
125
+ }
126
+ let startRenderError = null; // a failed render at start with an older output: PinDeck opens that, the assistant fixes the source
127
+ if (SOURCE && !LOGIN) {
128
+ if (!QUARTO.path) { console.error(`${SOURCE} is a Quarto deck, and Quarto is not installed (${QUARTO.reason}). Install it from https://quarto.org.`); process.exit(1); }
129
+ const mtime = (f) => { try { return fs.statSync(path.join(DECK_DIR, f)).mtimeMs; } catch { return 0; } };
130
+ if (mtime(DECK_FILE) < mtime(SOURCE)) {
131
+ const failed = renderSync();
132
+ if (failed && !fs.existsSync(path.join(DECK_DIR, DECK_FILE))) { console.error(failed); process.exit(1); }
133
+ if (failed) { console.error(failed + `\nOpening the last render of ${SOURCE}.`); startRenderError = failed; }
134
+ }
135
+ }
136
+
95
137
  /* Checks before anything is written: a deck that cannot be opened or saved is left exactly as it is. */
96
138
  function checkDeck() {
97
139
  const file = path.join(DECK_DIR, DECK_FILE);
@@ -304,13 +346,14 @@ function refreshGit() {
304
346
  }
305
347
  function slideList() {
306
348
  const html = readDeck(DECK_FILE);
307
- return findSlides(parse(html)).map((s, index) => ({ index, eid: s.attrs["data-eid"]?.value || null }));
349
+ return findSlides(parse(html)).map((s, index) => ({ index, eid: s.attrs["data-eid"]?.value || null, id: s.attrs.id?.value || null }));
308
350
  }
309
351
  function resolveSlide(spec) {
310
352
  const slides = slideList();
311
353
  if (!spec || spec === "current") return slides[state.current?.index ?? 0] || slides[0];
312
354
  if (/^\d+$/.test(spec)) return slides[+spec - 1];
313
- return slides.find((s) => s.eid === spec) || slides.find((s) => s.eid && s.eid.startsWith(spec));
355
+ // or the slide's own id attribute (Quarto's heading anchor, which stays when the title changes)
356
+ return slides.find((s) => s.eid === spec) || slides.find((s) => s.id === spec) || slides.find((s) => s.eid && s.eid.startsWith(spec));
314
357
  }
315
358
 
316
359
  /* ---- Headless Chrome (screenshots, and layout when no editor tab is open) --------------- */
@@ -544,7 +587,8 @@ const status = () => ({ busy: state.busy, switching: state.switching, preparing:
544
587
  running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
545
588
  chrome: CAPS.screenshot.available, slideSelector: deckNow.selector, deckProblem: deckNow.problem,
546
589
  capabilities: publicCapabilities(CAPS), rev: deckRev(), view: VIEW.slice(1) + "/",
547
- undo: changeLabel(liveTurns().at(-1)), undoId: liveTurns().at(-1)?.id ?? null, redo: changeLabel(state.redo.at(-1)) });
590
+ undo: changeLabel(liveTurns().at(-1)), undoId: liveTurns().at(-1)?.id ?? null, redo: changeLabel(state.redo.at(-1)),
591
+ source: SOURCE, rendering: !!rendering, renderError });
548
592
  /* Pushed to the editors on every change. The model lists (both providers' catalogs) go along only when they
549
593
  changed since the last push: the editor keeps the last ones it got. */
550
594
  let catalogSent = "";
@@ -563,6 +607,19 @@ const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md
563
607
  (ASSET_ROOTS.length ? `\n\n## Files outside the deck folder\nThe deck may use files in ${ASSET_ROOTS.join(", ")}: refer to ` +
564
608
  `them with paths relative to ${DECK_FILE} (such as ../shared/style.css). You can read them; do not edit them, ` +
565
609
  `other decks may use them too.\n` : "");
610
+ /* A Quarto deck: what to edit, and how its output relates to the source */
611
+ const quartoInstructions = () => SOURCE ? `\n\n## This deck is made with Quarto
612
+ ${DECK_FILE} is rendered by Quarto from ${SOURCE}: it is build output, locked, and overwritten by every render. Make every ` +
613
+ `change in ${SOURCE} (Quarto Markdown: \`##\` headings start slides and become the slides' id attributes, \`::: {.notes}\` holds ` +
614
+ `speaker notes, \`::: {.incremental}\` and \`. . .\` make build steps, \`{ojs}\` cells are Observable JS; when you change a ` +
615
+ `slide's title, keep its id by adding \`{#<old id>}\` to the heading, so comments and links still find the slide), or in the files it uses ` +
616
+ `(its .scss theme, figures). Where these instructions say to edit ${DECK_FILE} or theme.css, edit ${SOURCE} or its stylesheet ` +
617
+ `instead. Pins and element ids (data-eid) name elements of the rendered HTML: read ${DECK_FILE} to see what they are, then ` +
618
+ `find the matching text in ${SOURCE}. PinDeck renders after every change you save; ${CLI_CMD} shot, layout and slides wait ` +
619
+ `for that render, and report Quarto's error if it fails. Interactive OJS output is drawn in the browser, so screenshots show it ` +
620
+ `only once its libraries have loaded.\n` + (BRANDS.length ? `The deck's look comes from a Quarto format extension (` +
621
+ BRANDS.map((b) => b.replace(/\/brand\.css$/, "/")).join(", ") + `): read its README.md for the slide layouts and classes. ` +
622
+ `Its brand.css and vendor/ are locked, like brand.css in an HTML deck; its theme.css takes the talk's own styles.\n` : "") : "";
566
623
  /* For a deck made without reveal.js: what its slides are, from its selector as it is at each session start */
567
624
  function slidesInstructions() {
568
625
  const { selector } = deckSlides();
@@ -577,7 +634,7 @@ function slidesInstructions() {
577
634
  win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
578
635
  function lockedPaths() {
579
636
  const { files, folders } = otherDecks();
580
- return [...LOCKED, "vendor/**", ".git", ".git/**", ...files, ...folders.map((f) => `${f}/**`)];
637
+ return [...LOCKED, "vendor/**", ...BRANDS.map((b) => b.replace(/brand\.css$/, "vendor/**")), ".git", ".git/**", ".pindeck", ".pindeck/**", ...OUTPUTS.flatMap((o) => [o, `${o}/**`]), ...files, ...folders.map((f) => `${f}/**`)];
581
638
  }
582
639
  /* The other decks in this deck's folder: its other .html files, and the subfolders that hold a deck of their
583
640
  own (as the deck menu lists them). Neither the assistant nor this deck's undo touches them. */
@@ -588,7 +645,7 @@ function otherDecks() {
588
645
  .map((d) => path.relative(DECK_DIR, d.dir).split(path.sep).join("/")))];
589
646
  return { files, folders };
590
647
  }
591
- const scanDeck = () => { const { files, folders } = otherDecks(); return scanFiles(DECK_DIR, { skip: [...files, ...folders] }); };
648
+ const scanDeck = () => { const { files, folders } = otherDecks(); return scanFiles(DECK_DIR, { skip: [...files, ...folders, ...OUTPUTS] }); };
592
649
 
593
650
  /* Decks that share a folder share its files (theme.css, figures/): while the assistant of one works, that of
594
651
  another may change them too. Each deck server keeps the times of its turns in a file of its own under
@@ -642,13 +699,13 @@ const codexInstructions = () => systemPrompt.replace(/## Attached files[\s\S]*?#
642
699
  Figures and audio are already saved under figures/. Keep their paths and use the deck's existing figure/audio patterns. Do not rename or move attachments.
643
700
  Use pindeck_read for text files, images and PDF pages (supply page: 1, then further pages as needed).
644
701
  Use pindeck_edit for exact replacements in ${DECK_FILE}, theme.css and the deck's other files, and pindeck_write to create a new file in the deck folder. Never change brand.css.
645
- Use pindeck_command with separate command and args for layout, shot, slides, pdf or pdf-figure.
702
+ Use pindeck_command with separate command and args for layout, shot, slides, element (the HTML of data-eid ids), pdf, pdf-figure, resolve or reply.
646
703
  Do not use shell commands, native file edits, or other tools to bypass these restrictions.
647
704
  When context mentions Read, use pindeck_read. Read screenshots with pindeck_read.
648
705
  Read README.md and the deck instructions below for conventions. Use layout after every visual change.
649
706
  PDF tables should be reconstructed as HTML; pdf-figure renders figures. Audio is embedded by path.
650
707
 
651
- ## Hand edits`) + slidesInstructions() + deckInstructions();
708
+ ## Hand edits`) + slidesInstructions() + quartoInstructions() + deckInstructions();
652
709
  function makeSession(choice) {
653
710
  let initial = null;
654
711
  const options = {
@@ -670,11 +727,11 @@ function makeSession(choice) {
670
727
  instructions: codexInstructions(), tools: agentTools }) : new ClaudeSession({ ...options,
671
728
  // a tool the rules do not allow comes to PinDeck (--permission-prompt-tool stdio), always refused: with the right command if it was the CLI
672
729
  denyMessage: (r) => r?.tool_name === "Bash" ? cliRefusal(r.input?.command, CLI_CMD) : null,
673
- args: () => ["--append-system-prompt", systemPrompt + slidesInstructions() + deckInstructions(),
730
+ args: () => ["--append-system-prompt", systemPrompt + slidesInstructions() + quartoInstructions() + deckInstructions(),
674
731
  "--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands", // no settings files, no MCP servers or connectors
675
732
  "--permission-mode", "default", "--permission-prompt-tool", "stdio", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
676
733
  "--allowedTools", ...readRules(), "Edit(./**)", "Write(./**)",
677
- ...["layout", "shot", "slides", "pdf", "pdf-figure"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
734
+ ...["layout", "shot", "slides", "element", "pdf", "pdf-figure", "resolve", "reply"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
678
735
  "--disallowedTools", ...lockedPaths().flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
679
736
  ...(choice.model ? ["--model", choice.model] : []), ...(choice.effort ? ["--effort", choice.effort] : [])],
680
737
  });
@@ -848,7 +905,7 @@ function toolSummary(name, input = {}) {
848
905
  // must show as the script, or a refused script looks like a refused layout check
849
906
  const cmd = String(input.command || "").trim();
850
907
  const m = cmd.startsWith(CLI_CMD + " ") && cmd.slice(CLI_CMD.length).match(/^\s+([\w-]+)\s*([^\n;&|<>`$]*)$/);
851
- const names = { shot: "Screenshot", layout: "Check layout", "pdf-figure": "PDF figure" };
908
+ const names = { shot: "Screenshot", layout: "Check layout", element: "Read element", "pdf-figure": "PDF figure", resolve: "Resolve comment", reply: "Reply to comment" };
852
909
  return m ? { name: names[m[1]] || m[1], detail: m[1] === "pdf-figure" ? m[2].trim().replace(/^\S*\//, "") : m[2].trim() }
853
910
  : { name: "Run", detail: cmd.split("\n")[0].slice(0, 80) };
854
911
  }
@@ -1034,8 +1091,10 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
1034
1091
  ctx.push(...notes.map((n) => "Note: " + n));
1035
1092
  state.notes = [];
1036
1093
  }
1037
- if (selection) ctx.push(`Selected element (what "this" or "it" most likely refers to): ${selection.eid} (${selection.tag}), ` +
1038
- `box x=${selection.box.x} y=${selection.box.y} w=${selection.box.w} h=${selection.box.h} on slide ${selection.slide.index + 1}.`);
1094
+ const selBox = (s) => `${s.eid} (${s.tag}), box x=${s.box.x} y=${s.box.y} w=${s.box.w} h=${s.box.h}`;
1095
+ if (selection?.also?.length) ctx.push(`Selected elements (what "these", "them" or "this" most likely refer to), on slide ${selection.slide.index + 1}:\n` +
1096
+ [selection, ...selection.also].map((s) => "- " + selBox(s)).join("\n"));
1097
+ else if (selection) ctx.push(`Selected element (what "this" or "it" most likely refers to): ${selBox(selection)} on slide ${selection.slide.index + 1}.`);
1039
1098
  if (pins.length) ctx.push("Pins (numbered clicks; the user refers to them by number):\n" + pins.map(formatPin).join("\n"));
1040
1099
  for (const L of layouts) ctx.push("Layout:\n" + formatLayout(L));
1041
1100
  if (screenshot) ctx.push(`Screenshot of slide ${screenshot.slide}${screenshot.pins ? " with the pin markers" : ""}: ${screenshot.path} (Read it).`);
@@ -1082,6 +1141,98 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
1082
1141
  return { text: `${out} (${size})` };
1083
1142
  }
1084
1143
 
1144
+ /* ---- Review comments (lib/review.mjs) ---------------------------------------------------------------
1145
+ Kept in <deck folder>/.pindeck/: never a deck file, never in an undo, locked for the assistant. The packet
1146
+ next to them is written once there are comments, and again whenever they or the deck change. An agent outside
1147
+ PinDeck may set a comment's status in the file; the editor follows. A file that cannot be read is left as it
1148
+ is: comments are then off, with the reason. */
1149
+ const REVIEW = reviewFiles(DECK_DIR, DECK_FILE);
1150
+ let review = null, reviewProblem = null, reviewSaved = null;
1151
+ function readReview() {
1152
+ try { review = loadReview(REVIEW.json); reviewProblem = null; }
1153
+ catch (e) { review = null; reviewProblem = `The comments file ${REVIEW.json} cannot be read (${e.message}): fix or remove it.`; }
1154
+ try { reviewSaved = fs.readFileSync(REVIEW.json, "utf8"); } catch { reviewSaved = null; }
1155
+ }
1156
+ readReview();
1157
+ const deckIndex = () => indexDeck(readDeck(DECK_FILE) || "");
1158
+ // the Quarto source's text, for the source lines of comments (null for an HTML deck)
1159
+ const sourceText = () => { try { return SOURCE ? fs.readFileSync(path.join(DECK_DIR, SOURCE), "utf8") : null; } catch { return null; } };
1160
+ function commentsView(deck = deckIndex()) {
1161
+ if (!review) return { comments: [], problem: reviewProblem };
1162
+ return { file: path.relative(DECK_DIR, REVIEW.json), packet: path.relative(DECK_DIR, REVIEW.packet), problem: null,
1163
+ comments: review.comments.map((c, i) => {
1164
+ const loc = locate(deck, c);
1165
+ return { ...c, n: i + 1, loc: { status: loc.status, index: loc.slide?.index ?? null, slideEid: loc.slide?.eid ?? null, eid: loc.eid, now: loc.now } };
1166
+ }) };
1167
+ }
1168
+ function writePacket(deck = deckIndex()) {
1169
+ if (!review || (!review.comments.length && !fs.existsSync(REVIEW.packet))) return;
1170
+ fs.mkdirSync(path.dirname(REVIEW.packet), { recursive: true });
1171
+ writeAtomic(REVIEW.packet, packet(review, deck, { deckFile: DECK_FILE, source: SOURCE, srcText: sourceText(), commentsFile: path.relative(DECK_DIR, REVIEW.json) }));
1172
+ }
1173
+ function commentsChanged({ save = true } = {}) {
1174
+ const deck = deckIndex();
1175
+ if (save) { saveReview(REVIEW.json, review); reviewSaved = fs.readFileSync(REVIEW.json, "utf8"); }
1176
+ try { writePacket(deck); } catch (e) { console.error(e.message); }
1177
+ const view = commentsView(deck);
1178
+ broadcast("comments", view);
1179
+ return view;
1180
+ }
1181
+ // edits of the file by another program (an agent marks a comment resolved): polled, which survives replaced files
1182
+ function syncReview() {
1183
+ let now = null;
1184
+ try { now = fs.readFileSync(REVIEW.json, "utf8"); } catch {}
1185
+ if (now === reviewSaved) return false;
1186
+ readReview();
1187
+ return true;
1188
+ }
1189
+ fs.watchFile(REVIEW.json, { interval: 1000 }, () => { if (syncReview()) commentsChanged({ save: false }); });
1190
+
1191
+ /* Rendering while PinDeck runs (see Quarto above): started by a change to a source file */
1192
+ let rendering = null, renderAgain = false, renderError = startRenderError, renderTimer = null, renderStarted = Date.now();
1193
+ if (renderError) {
1194
+ addEntry({ kind: "notice", text: renderError + `\nThe deck shown is the last render that worked.` });
1195
+ state.notes.push(renderError + "\nFix the source; PinDeck renders again after every change.");
1196
+ }
1197
+ function render() {
1198
+ clearTimeout(renderTimer);
1199
+ renderTimer = null;
1200
+ if (rendering) { renderAgain = true; return rendering; }
1201
+ renderStarted = Date.now();
1202
+ rendering = new Promise((done) => {
1203
+ execFile(QUARTO.path, ["render", SOURCE], { cwd: DECK_DIR, timeout: RENDER_TIMEOUT, maxBuffer: 1 << 24 }, (err, _out, stderr) => {
1204
+ const was = renderError;
1205
+ renderError = err ? quartoError(stderr || err.message) : null;
1206
+ if (renderError && renderError !== was) {
1207
+ addEntry({ kind: "notice", text: renderError });
1208
+ state.notes.push(renderError + "\nFix the source; PinDeck renders again after every change.");
1209
+ } else if (!renderError && was) addEntry({ kind: "notice", text: `${SOURCE} renders again.` });
1210
+ if (!err) runEidPass(); // the fresh output gets its element ids (and the editor reloads it)
1211
+ done();
1212
+ });
1213
+ }).finally(() => {
1214
+ rendering = null;
1215
+ if (renderAgain) { renderAgain = false; render(); }
1216
+ pushStatus();
1217
+ });
1218
+ pushStatus();
1219
+ return rendering;
1220
+ }
1221
+ function sourceChanged() {
1222
+ clearTimeout(renderTimer);
1223
+ renderTimer = setTimeout(render, 150); // an editor's save is often several events
1224
+ }
1225
+ /* Before the assistant looks at the deck: a pending or running render ends first, and a source saved since the
1226
+ last render began (its file event may not have come yet) is rendered now */
1227
+ async function rendered() {
1228
+ if (!SOURCE) return null;
1229
+ let saved = 0;
1230
+ try { saved = fs.statSync(path.join(DECK_DIR, SOURCE)).mtimeMs; } catch {}
1231
+ if (renderTimer || saved > renderStarted) render();
1232
+ while (rendering) await rendering;
1233
+ return renderError;
1234
+ }
1235
+
1085
1236
  /* ---- File watching --------------------------------------------------------------------------
1086
1237
  Changes are collected for 120 ms and sent to the editor with the deck's revision, so a tab that missed
1087
1238
  events (reconnect) can still tell that it shows an old state. Dot files (PinDeck's temporary files,
@@ -1092,6 +1243,10 @@ function onFileEvent(_ev, file) {
1092
1243
  if (!file) { changed.add(DECK_FILE); return scheduleFlush(); } // some systems omit the name: assume the deck
1093
1244
  file = String(file).split(path.sep).join("/");
1094
1245
  if (/(^|\/)\.|\.bak$|~$|^export\//.test(file)) return;
1246
+ if (SOURCE) {
1247
+ if (isOutput(file)) { if (rendering || file !== DECK_FILE) return; } // a render's own writes: its id pass reloads
1248
+ else if (SOURCE_FILE.test(file)) sourceChanged();
1249
+ }
1095
1250
  changed.add(file);
1096
1251
  scheduleFlush();
1097
1252
  }
@@ -1128,6 +1283,7 @@ function scheduleFlush() {
1128
1283
  };
1129
1284
  eidTimer = setTimeout(pass, 800);
1130
1285
  }
1286
+ if (files.includes(DECK_FILE) && review?.comments.length) commentsChanged({ save: false }); // found again, or flagged
1131
1287
  if (files.includes(DECK_FILE)) { // the slide tags, by hand or by Claude: tell both when they stop working
1132
1288
  const now = { selector: deckSlides().selector, problem: deckProblemNow() };
1133
1289
  if (now.selector !== deckNow.selector || now.problem !== deckNow.problem) {
@@ -1211,14 +1367,17 @@ function recordHandEdit(before, html, label, note, { manual = true, deletedFiles
1211
1367
  /* What deleting an element would remove, and which of its files nothing else uses. Files outside the
1212
1368
  deck folder, in vendor/, or still referenced elsewhere (the rest of the deck, theme.css, brand.css, or
1213
1369
  another deck in the folder or one folder down, which may share figures/) are never offered. */
1214
- function deletePlan(eid) {
1370
+ /* What deleting one or more elements removes: the elements (a picture with its caption; an element inside another
1371
+ one that goes too is left out) and the files they refer to */
1372
+ function deletePlan(eids) {
1215
1373
  const html = readDeck(DECK_FILE);
1216
- const target = deleteTarget(html, eid);
1217
- if (!target) return null;
1218
- const rest = removeElement(html, target.eid);
1374
+ let targets = [].concat(eids).map((eid) => deleteTarget(html, eid));
1375
+ if (!targets.length || targets.some((t) => !t)) return null;
1376
+ targets = targets.filter((t, i) => !targets.some((o, j) => o.eid === t.eid ? j < i : o.outer.includes(`data-eid="${t.eid}"`)));
1377
+ const rest = targets.reduce((h, t) => removeElement(h, t.eid), html);
1219
1378
  const css = (readDeck("theme.css") || "") + (readDeck("brand.css") || "");
1220
1379
  const others = otherDeckRefs();
1221
- const files = fileRefs(target.outer).flatMap((ref) => {
1380
+ const files = [...new Set(targets.flatMap((t) => fileRefs(t.outer)))].flatMap((ref) => {
1222
1381
  const abs = path.resolve(DECK_DIR, ref);
1223
1382
  const rel = path.relative(DECK_DIR, abs).split(path.sep).join("/");
1224
1383
  if (rel.startsWith("..") || rel.startsWith("vendor/") || !fs.existsSync(abs) || !fs.statSync(abs).isFile()) return [];
@@ -1227,7 +1386,7 @@ function deletePlan(eid) {
1227
1386
  const usedElsewhere = fileRefs(rest).some((r) => path.resolve(DECK_DIR, r) === abs) || css.includes(ref) || css.includes(rel) || usedBy.length > 0;
1228
1387
  return [{ rel, bytes: fs.statSync(abs).size, usedElsewhere, usedBy }];
1229
1388
  });
1230
- return { ...target, html: rest, files };
1389
+ return { ...targets[0], targets: targets.map(({ eid, tag }) => ({ eid, tag })), html: rest, files };
1231
1390
  }
1232
1391
  /* The other decks of this folder and one folder down, each with a test of whether it refers to a file: in its
1233
1392
  HTML (relative to its own folder), or in the stylesheets beside it */
@@ -1302,7 +1461,7 @@ function undo(res) {
1302
1461
  /* ---- PowerPoint import -----------------------------------------------------------------------------
1303
1462
  Upload: the file is read at once (slide list for the dialog); in the background PowerPoint or Keynote
1304
1463
  makes a PDF of it, from which come the thumbnails and, on import, the pictures of diagrams.
1305
- Only the latest upload is kept, in ~/.pindeck-v3/decks/<deck>/imports/. */
1464
+ Only the latest upload is kept, in ~/.pindeck-v4/decks/<deck>/imports/. */
1306
1465
  const IMPORT_DIR = path.join(STATE_DIR, "imports");
1307
1466
  const IMPORT_MAX = 500 * 1024 * 1024;
1308
1467
  let lastImport = null; // { id, name, dir, model, render: Promise<pdf path | null>, renderError }
@@ -1468,6 +1627,7 @@ function serveFile(res, file, transform, req, extra = {}) {
1468
1627
  let ready = false;
1469
1628
  const NEEDS_DECK = /^\/api\/(message|slides|layout|shot|pdf|pdf-figure|edit|text|split|remove|notes|delete|delete-info|source|slide-hidden|move-slide|add-slide|import-apply|export)$/;
1470
1629
  const CLI_ROUTES = /^\/api\/(slides|layout|shot|pdf|pdf-figure)$/; // answered as text, for cli.mjs
1630
+ const HAND_EDITS = /^\/api\/(edit|text|split|remove|notes|delete|slide-hidden|move-slide|add-slide|import|import-apply)$/; // refused for a Quarto deck
1471
1631
  /* Scriptable files (HTML, SVG) from the deck get the sandbox, wherever they are asked for. Under /v/ they are
1472
1632
  read by documents of that sandboxed origin, so fonts and module scripts need CORS; the path is the secret.
1473
1633
  On localhost (Present) there is no sandbox: no login works there (see presentHost). */
@@ -1494,7 +1654,7 @@ const server = http.createServer(async (req, res) => {
1494
1654
  const ok = authed(req);
1495
1655
  if (req.method === "GET" && (p === "/" || p === "/index.html")) {
1496
1656
  if (!ok) { res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); return res.end(LOGIN_PAGE); }
1497
- return serveFile(res, path.join(EDITOR_DIR, "public/index.html"), null, req);
1657
+ return serveFile(res, path.join(EDITOR_DIR, "public/index.html"), (s) => s.replace("{{VERSION}}", VERSION), req);
1498
1658
  }
1499
1659
  if (req.method === "GET" && p.startsWith("/editor/")) {
1500
1660
  const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
@@ -1571,6 +1731,14 @@ const server = http.createServer(async (req, res) => {
1571
1731
  return;
1572
1732
  }
1573
1733
  // the deck file gone (moved, renamed, deleted) while PinDeck runs: say so, instead of failing on it
1734
+ // a Quarto deck is rendered from its source: no hand edits of the output (the next render would undo them)
1735
+ if (SOURCE && req.method === "POST" && HAND_EDITS.test(p))
1736
+ return json(res, 409, { code: "quarto", error: `${DECK_FILE} is rendered by Quarto from ${SOURCE}: ask the assistant, or edit ${SOURCE} yourself.` });
1737
+ // what the assistant checks is the deck rendered from its last edit
1738
+ if (SOURCE && CLI_ROUTES.test(p)) {
1739
+ const failed = await rendered();
1740
+ if (failed) return text(res, 409, failed + `\nFix ${SOURCE}; PinDeck renders again after every change.`);
1741
+ }
1574
1742
  if (NEEDS_DECK.test(p) && readDeck(DECK_FILE) == null) {
1575
1743
  const why = `${path.join(DECK_DIR, DECK_FILE)} is not there any more (moved, renamed or deleted). Put it back, ` +
1576
1744
  `or start PinDeck for the deck where it is now.`;
@@ -1599,6 +1767,7 @@ const server = http.createServer(async (req, res) => {
1599
1767
  const placed = (x) => x && typeof x.eid === "string" && x.slide && Number.isInteger(x.slide.index) && x.box;
1600
1768
  b.pins = (Array.isArray(b.pins) ? b.pins : []).filter((p) => placed(p) && p.point && p.at);
1601
1769
  if (!placed(b.selection)) b.selection = null;
1770
+ else b.selection.also = (Array.isArray(b.selection.also) ? b.selection.also : []).filter((x) => x && typeof x.eid === "string" && x.box);
1602
1771
  if (!Array.isArray(b.layouts)) b.layouts = [];
1603
1772
  if (b.current && !Number.isInteger(b.current.index)) b.current = null;
1604
1773
  const preparation = { cancelled: false, controller: new AbortController() };
@@ -1625,13 +1794,72 @@ const server = http.createServer(async (req, res) => {
1625
1794
  const entry = addEntry({ kind: "user", text: b.text,
1626
1795
  screenshot: screenshot && { file: path.basename(screenshot.path), slide: screenshot.slide },
1627
1796
  attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
1628
- selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
1797
+ selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, "") + (b.selection.also.length ? ` +${b.selection.also.length}` : ""), slide: b.selection.slide.index + 1 } : null,
1629
1798
  pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });
1630
1799
  if (shotFailed) addEntry({ kind: "notice", text: `Screenshot of slide ${shotFailed.slide} failed: ${shotFailed.error}` });
1631
1800
  startTurn(buildPrompt({ ...b, screenshot, shotFailed }), entry);
1632
1801
  return json(res, 200, { id: entry.id });
1633
1802
  } finally { state.preparing = null; if (!state.turn) { state.busy = false; pushStatus(); } }
1634
1803
  }
1804
+ if (p === "/api/comments" && req.method === "GET") return json(res, 200, commentsView());
1805
+ // `cli.mjs resolve <id or C3> [what was done]`: the assistant (or a terminal) marks a comment resolved;
1806
+ // `cli.mjs reply <id or C3> <text>`: it answers one it leaves open (why not, a question), in the comment's thread
1807
+ if (p === "/api/resolve" || p === "/api/reply") {
1808
+ syncReview(); // the poll may not have seen another program's edit yet: saving now would undo it
1809
+ if (!review) return text(res, 409, reviewProblem);
1810
+ const want = String(url.searchParams.get("id") || "").trim();
1811
+ const n = /^C(\d+)$/i.exec(want)?.[1];
1812
+ const c = n ? review.comments[+n - 1] : review.comments.find((x) => x.id === want);
1813
+ if (!c) return text(res, 404, `No comment "${want}". Comment ids look like c-1a2b3c4d (or C3, its number in the editor).`);
1814
+ const note = String(url.searchParams.get("note") || "").trim().slice(0, 2000);
1815
+ const by = state.busy ? "assistant" : "command line";
1816
+ const name = `C${review.comments.indexOf(c) + 1} (${c.id})`;
1817
+ if (p === "/api/reply") {
1818
+ if (!note) return text(res, 400, "Write the reply after the comment id.");
1819
+ (c.replies ||= []).push({ by, text: note, at: new Date().toISOString() });
1820
+ } else {
1821
+ c.status = "resolved";
1822
+ c.resolvedBy = by;
1823
+ c.resolution = note.slice(0, 500) || null;
1824
+ }
1825
+ c.updated = new Date().toISOString();
1826
+ commentsChanged();
1827
+ return text(res, 200, p === "/api/reply" ? `Replied to ${name}; it stays open.` : `Resolved ${name}.`);
1828
+ }
1829
+ if (p === "/api/comments-request" && req.method === "GET") {
1830
+ if (!review) return json(res, 409, { error: reviewProblem });
1831
+ return json(res, 200, { text: commentsRequest(review, deckIndex(), { source: SOURCE, srcText: sourceText() }) });
1832
+ }
1833
+ if (/^\/api\/comment(-update|-delete)?$/.test(p) && req.method === "POST") {
1834
+ syncReview();
1835
+ if (!review) return json(res, 409, { error: reviewProblem });
1836
+ const b = await body(req);
1837
+ if (p === "/api/comment") {
1838
+ if (typeof b.text !== "string" || !b.text.trim()) return json(res, 400, { error: "Write the comment first." });
1839
+ let c;
1840
+ try { c = commentFromPin(deckIndex(), { pin: b.pin || null, slide: b.slide }, b.text); }
1841
+ catch (e) { return json(res, 409, { error: e.message }); }
1842
+ review.comments.push(c);
1843
+ return json(res, 200, { id: c.id, ...commentsChanged() });
1844
+ }
1845
+ const c = review.comments.find((x) => x.id === b.id);
1846
+ if (!c) return json(res, 404, { error: "No such comment (removed meanwhile?)." });
1847
+ if (p === "/api/comment-delete") review.comments = review.comments.filter((x) => x !== c);
1848
+ else {
1849
+ if (typeof b.text === "string") { if (!b.text.trim()) return json(res, 400, { error: "A comment cannot be empty." }); c.text = b.text.trim(); }
1850
+ if (typeof b.reply === "string") {
1851
+ if (!b.reply.trim()) return json(res, 400, { error: "Write the reply first." });
1852
+ (c.replies ||= []).push({ by: "you", text: b.reply.trim(), at: new Date().toISOString() });
1853
+ }
1854
+ if (b.status === "open" || b.status === "resolved") {
1855
+ c.status = b.status;
1856
+ c.resolvedBy = b.status === "resolved" ? "you" : undefined;
1857
+ if (b.status === "open") c.resolution = undefined;
1858
+ }
1859
+ c.updated = new Date().toISOString();
1860
+ }
1861
+ return json(res, 200, commentsChanged());
1862
+ }
1635
1863
  if (p === "/api/upload" && req.method === "POST") {
1636
1864
  const original = decodeURIComponent(String(req.headers["x-filename"] || "file"));
1637
1865
  const kind = attachKind(original);
@@ -1817,27 +2045,27 @@ const server = http.createServer(async (req, res) => {
1817
2045
 
1818
2046
  // Delete an element (and, if the user agrees, the files only it uses)
1819
2047
  if (p === "/api/delete-info") {
1820
- const plan = deletePlan(url.searchParams.get("eid"));
2048
+ const plan = deletePlan(url.searchParams.getAll("eid"));
1821
2049
  if (!plan) return json(res, 404, { error: "Element not found in the file." });
1822
- return json(res, 200, { eid: plan.eid, tag: plan.tag, files: plan.files });
2050
+ return json(res, 200, { eid: plan.eid, tag: plan.tag, targets: plan.targets, files: plan.files });
1823
2051
  }
1824
2052
  if (p === "/api/delete" && req.method === "POST") {
1825
2053
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1826
2054
  const b = await body(req);
1827
2055
  if (staleEdit(res, b)) return;
1828
- const plan = deletePlan(b.eid);
2056
+ const plan = deletePlan(Array.isArray(b.eids) ? b.eids : b.eid);
1829
2057
  if (!plan) return json(res, 404, { error: "Element not found in the file." });
1830
2058
  const before = snapshot(EDITABLE);
1831
2059
  const wanted = new Set(b.files || []);
1832
2060
  const deletedFiles = plan.files.filter((f) => wanted.has(f.rel) && !f.usedElsewhere)
1833
2061
  .map((f) => ({ rel: f.rel, data: fs.readFileSync(path.join(DECK_DIR, f.rel)) }));
1834
- const short = plan.eid.replace(/^[^/]+\//, "/");
2062
+ const short = plan.targets.map((t) => t.eid.replace(/^[^/]+\//, "/")).join(", ");
1835
2063
  const label = `Deleted ${short}` + (deletedFiles.length ? ` and ${deletedFiles.map((f) => f.rel).join(", ")}` : "");
1836
2064
  const turnId = recordHandEdit(before, plan.html, label,
1837
- `The user deleted ${plan.eid} (${plan.tag}) by hand` + (deletedFiles.length ? `, together with the file(s) ${deletedFiles.map((f) => f.rel).join(", ")}` : "") + ".",
2065
+ `The user deleted ${plan.targets.map((t) => `${t.eid} (${t.tag})`).join(", ")} by hand` + (deletedFiles.length ? `, together with the file(s) ${deletedFiles.map((f) => f.rel).join(", ")}` : "") + ".",
1838
2066
  { manual: false, deletedFiles });
1839
2067
  for (const f of deletedFiles) removeDeckFile(f.rel);
1840
- return json(res, 200, { turnId, deleted: plan.eid, files: deletedFiles.map((f) => f.rel) });
2068
+ return json(res, 200, { turnId, deleted: plan.targets.map((t) => t.eid), files: deletedFiles.map((f) => f.rel) });
1841
2069
  }
1842
2070
 
1843
2071
  // Hand edits from the editor: drag/resize (inline styles) and text
@@ -1948,6 +2176,11 @@ const server = http.createServer(async (req, res) => {
1948
2176
  if (why) return text(res, 409, why);
1949
2177
  return text(res, 200, list.map((s) => `${String(s.index + 1).padStart(2)} ${s.eid}`).join("\n"));
1950
2178
  }
2179
+ if (p === "/api/element") {
2180
+ const eid = url.searchParams.get("eid") || "";
2181
+ const outer = getOuter(readDeck(DECK_FILE) || "", eid);
2182
+ return outer === null ? text(res, 404, `No element "${eid}" in ${DECK_FILE}. Give a data-eid.`) : text(res, 200, outer);
2183
+ }
1951
2184
  if (p === "/api/layout" || p === "/api/shot") {
1952
2185
  const slide = resolveSlide(url.searchParams.get("slide"));
1953
2186
  if (!slide) return text(res, 404, deckProblemNow() || `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
@@ -1991,6 +2224,7 @@ server.listen(PORT, "127.0.0.1", () => {
1991
2224
  const first = runEidPass();
1992
2225
  if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
1993
2226
  startWatcher();
2227
+ try { writePacket(); } catch (e) { console.error(e.message); }
1994
2228
  } catch (e) {
1995
2229
  console.error(e.plain ? e.message : `PinDeck could not open the deck: ${e.message}`);
1996
2230
  removeServerInfo();
@@ -2019,6 +2253,7 @@ async function quit() {
2019
2253
  if (quitting) process.exit(1);
2020
2254
  quitting = true;
2021
2255
  try { watcher?.close(); } catch {}
2256
+ fs.unwatchFile(REVIEW.json);
2022
2257
  clearTimeout(flush); clearTimeout(eidTimer);
2023
2258
  for (const res of clients) res.end();
2024
2259
  for (const w of pending.values()) { clearTimeout(w.t); w.reject(new Error("PinDeck is stopping")); }