pindeck 3.3.0 → 4.0.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 } from "./lib/eids.mjs";
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";
27
27
  import { unifiedDiff } from "./lib/diff.mjs";
28
28
  import { ClaudeSession } from "./lib/claude.mjs";
29
29
  import { CodexSession } from "./lib/codex.mjs";
@@ -34,7 +34,7 @@ import { printLayout, printPdf, chromeReason, ROOT_ARGS, PDF_HEAD_TAG, PDF_PAGE_
34
34
  import { readPptx } from "./lib/pptx.mjs";
35
35
  import { exportPdf as pptxToPdf, renderJobs, renderAvailable, pdfInfo } from "./lib/pptx-render.mjs";
36
36
  import { buildImport, planSlides } from "./lib/pptx-import.mjs";
37
- import { isSupportedPlatform, detectCapabilities, publicCapabilities, capabilityPrompt, parsePort, cliCommand, findExecutable } from "./lib/capabilities.mjs";
37
+ import { isSupportedPlatform, detectCapabilities, publicCapabilities, capabilityPrompt, parsePort, cliCommand, useCliCommand, cliRefusal, findExecutable } from "./lib/capabilities.mjs";
38
38
  import { unsupportedMessage } from "./lib/frameworks.mjs";
39
39
  import { readInstructions } from "./lib/instructions.mjs";
40
40
  import { writeAtomic, sameContent, revision, scanFiles, scanChanges } from "./lib/deck-files.mjs";
@@ -42,8 +42,9 @@ import { deckTarget, homeDir, homeWarning } from "./lib/deck-target.mjs";
42
42
  import { listDecks, linkProblem } from "./lib/deck-list.mjs";
43
43
  import { assetRoots } from "./lib/asset-roots.mjs";
44
44
  import { registerServer, printLogin, alive } from "./lib/servers.mjs";
45
- import { goodHost, LOGIN_PAGE, createLogin, openOnEnter } from "./lib/login.mjs";
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
@@ -70,9 +71,21 @@ const MODEL = opt("--model", "");
70
71
  if (process.env.CLAUDE_MODEL && !LOGIN && !FRONT) // PinDeck 2 read it; behind the front, the front says so
71
72
  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
73
  /* 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
74
+ const { dir: DECK_DIR, file: DECK_FILE, named: NAMED, name: DECK_NAME, key: deckKey, source: SOURCE } = deckTarget(argv[0] || process.cwd());
75
+ /* A Quarto deck (SOURCE: talk.qmd): DECK_FILE is what Quarto renders from it, build output. The assistant edits
76
+ the sources; PinDeck renders after every change (see Quarto below) and adds its element ids to the output. */
77
+ const OUTPUTS = SOURCE ? [DECK_FILE, DECK_FILE.replace(/\.html$/i, "_files"), ".quarto"] : [];
78
+ const isOutput = (rel) => OUTPUTS.some((o) => rel === o || rel.startsWith(o + "/"));
79
+ const EDITABLE = SOURCE ? [SOURCE, DECK_FILE] : [DECK_FILE, "theme.css"]; // both backends may edit these (a Quarto deck: its source)
80
+ /* A Quarto deck's format extensions (_extensions/[<org>/]<name>/, e.g. Ocean) carry their own brand.css and vendor/ */
81
+ function extensionBrands() {
82
+ try {
83
+ return fs.readdirSync(path.join(DECK_DIR, "_extensions"), { recursive: true }).map((f) => String(f).split(path.sep).join("/"))
84
+ .filter((f) => /(^|\/)brand\.css$/.test(f) && !f.includes("vendor/")).map((f) => `_extensions/${f}`);
85
+ } catch { return []; }
86
+ }
87
+ const BRANDS = SOURCE ? extensionBrands() : [];
88
+ const LOCKED = ["brand.css", ...BRANDS]; // never, enforced twice
76
89
  const HOME_DIR = homeDir();
77
90
  const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
78
91
  const SHOT_DIR = path.join(STATE_DIR, "shots");
@@ -92,6 +105,34 @@ const CLI_REL = path.relative(DECK_DIR, path.join(EDITOR_DIR, "cli.mjs")).split(
92
105
  const CLI_CMD = cliCommand(CLI_REL);
93
106
  const CAPS = detectCapabilities();
94
107
 
108
+ /* ---- Quarto ----------------------------------------------------------------------------------------
109
+ `quarto render <source>` makes DECK_FILE. One render at a time; a change to a source while one runs renders
110
+ again when it ends. What the assistant checks (shot, layout, slides, pdf) waits for the render, so it always
111
+ sees its last edit. Quarto's own errors go to the chat, and to the assistant. */
112
+ const QUARTO = SOURCE ? findExecutable(process.env.QUARTO_BIN || "quarto") : null;
113
+ const RENDER_TIMEOUT = 180000;
114
+ 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
115
+ /* Quarto's message, without colours and its JavaScript stack: what says what is wrong in the source */
116
+ const quartoError = (stderr) => `Quarto could not render ${SOURCE}:\n` + String(stderr).replace(/\x1b\[[0-9;]*m/g, "")
117
+ .split(/\nStack trace:|\n\s+at /)[0].trim().split("\n").slice(-25).join("\n");
118
+ function renderSync() {
119
+ console.log(`Rendering ${SOURCE} with Quarto…`);
120
+ try { execFileSync(QUARTO.path, ["render", SOURCE], { cwd: DECK_DIR, stdio: ["ignore", "ignore", "pipe"], timeout: RENDER_TIMEOUT }); }
121
+ catch (e) { return quartoError(e.stderr || e.message); }
122
+ return fs.existsSync(path.join(DECK_DIR, DECK_FILE)) ? null
123
+ : `Quarto rendered ${SOURCE}, but not to ${DECK_FILE} beside it (an output-dir in _quarto.yml?). PinDeck needs the HTML next to its source.`;
124
+ }
125
+ let startRenderError = null; // a failed render at start with an older output: PinDeck opens that, the assistant fixes the source
126
+ if (SOURCE && !LOGIN) {
127
+ 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); }
128
+ const mtime = (f) => { try { return fs.statSync(path.join(DECK_DIR, f)).mtimeMs; } catch { return 0; } };
129
+ if (mtime(DECK_FILE) < mtime(SOURCE)) {
130
+ const failed = renderSync();
131
+ if (failed && !fs.existsSync(path.join(DECK_DIR, DECK_FILE))) { console.error(failed); process.exit(1); }
132
+ if (failed) { console.error(failed + `\nOpening the last render of ${SOURCE}.`); startRenderError = failed; }
133
+ }
134
+ }
135
+
95
136
  /* Checks before anything is written: a deck that cannot be opened or saved is left exactly as it is. */
96
137
  function checkDeck() {
97
138
  const file = path.join(DECK_DIR, DECK_FILE);
@@ -304,13 +345,14 @@ function refreshGit() {
304
345
  }
305
346
  function slideList() {
306
347
  const html = readDeck(DECK_FILE);
307
- return findSlides(parse(html)).map((s, index) => ({ index, eid: s.attrs["data-eid"]?.value || null }));
348
+ return findSlides(parse(html)).map((s, index) => ({ index, eid: s.attrs["data-eid"]?.value || null, id: s.attrs.id?.value || null }));
308
349
  }
309
350
  function resolveSlide(spec) {
310
351
  const slides = slideList();
311
352
  if (!spec || spec === "current") return slides[state.current?.index ?? 0] || slides[0];
312
353
  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));
354
+ // or the slide's own id attribute (Quarto's heading anchor, which stays when the title changes)
355
+ return slides.find((s) => s.eid === spec) || slides.find((s) => s.id === spec) || slides.find((s) => s.eid && s.eid.startsWith(spec));
314
356
  }
315
357
 
316
358
  /* ---- Headless Chrome (screenshots, and layout when no editor tab is open) --------------- */
@@ -543,8 +585,9 @@ let session = null, starting = null;
543
585
  const status = () => ({ busy: state.busy, switching: state.switching, preparing: !!state.preparing, model: state.model, sessionId: state.sessionId, settings: { ...settings },
544
586
  running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
545
587
  chrome: CAPS.screenshot.available, slideSelector: deckNow.selector, deckProblem: deckNow.problem,
546
- capabilities: publicCapabilities(CAPS), rev: deckRev(),
547
- undo: changeLabel(liveTurns().at(-1)), undoId: liveTurns().at(-1)?.id ?? null, redo: changeLabel(state.redo.at(-1)) });
588
+ capabilities: publicCapabilities(CAPS), rev: deckRev(), view: VIEW.slice(1) + "/",
589
+ undo: changeLabel(liveTurns().at(-1)), undoId: liveTurns().at(-1)?.id ?? null, redo: changeLabel(state.redo.at(-1)),
590
+ source: SOURCE, rendering: !!rendering, renderError });
548
591
  /* Pushed to the editors on every change. The model lists (both providers' catalogs) go along only when they
549
592
  changed since the last push: the editor keeps the last ones it got. */
550
593
  let catalogSent = "";
@@ -563,6 +606,19 @@ const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md
563
606
  (ASSET_ROOTS.length ? `\n\n## Files outside the deck folder\nThe deck may use files in ${ASSET_ROOTS.join(", ")}: refer to ` +
564
607
  `them with paths relative to ${DECK_FILE} (such as ../shared/style.css). You can read them; do not edit them, ` +
565
608
  `other decks may use them too.\n` : "");
609
+ /* A Quarto deck: what to edit, and how its output relates to the source */
610
+ const quartoInstructions = () => SOURCE ? `\n\n## This deck is made with Quarto
611
+ ${DECK_FILE} is rendered by Quarto from ${SOURCE}: it is build output, locked, and overwritten by every render. Make every ` +
612
+ `change in ${SOURCE} (Quarto Markdown: \`##\` headings start slides and become the slides' id attributes, \`::: {.notes}\` holds ` +
613
+ `speaker notes, \`::: {.incremental}\` and \`. . .\` make build steps, \`{ojs}\` cells are Observable JS; when you change a ` +
614
+ `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 ` +
615
+ `(its .scss theme, figures). Where these instructions say to edit ${DECK_FILE} or theme.css, edit ${SOURCE} or its stylesheet ` +
616
+ `instead. Pins and element ids (data-eid) name elements of the rendered HTML: read ${DECK_FILE} to see what they are, then ` +
617
+ `find the matching text in ${SOURCE}. PinDeck renders after every change you save; ${CLI_CMD} shot, layout and slides wait ` +
618
+ `for that render, and report Quarto's error if it fails. Interactive OJS output is drawn in the browser, so screenshots show it ` +
619
+ `only once its libraries have loaded.\n` + (BRANDS.length ? `The deck's look comes from a Quarto format extension (` +
620
+ BRANDS.map((b) => b.replace(/\/brand\.css$/, "/")).join(", ") + `): read its README.md for the slide layouts and classes. ` +
621
+ `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
622
  /* For a deck made without reveal.js: what its slides are, from its selector as it is at each session start */
567
623
  function slidesInstructions() {
568
624
  const { selector } = deckSlides();
@@ -577,7 +633,7 @@ function slidesInstructions() {
577
633
  win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
578
634
  function lockedPaths() {
579
635
  const { files, folders } = otherDecks();
580
- return [...LOCKED, "vendor/**", ".git", ".git/**", ...files, ...folders.map((f) => `${f}/**`)];
636
+ 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
637
  }
582
638
  /* The other decks in this deck's folder: its other .html files, and the subfolders that hold a deck of their
583
639
  own (as the deck menu lists them). Neither the assistant nor this deck's undo touches them. */
@@ -588,7 +644,7 @@ function otherDecks() {
588
644
  .map((d) => path.relative(DECK_DIR, d.dir).split(path.sep).join("/")))];
589
645
  return { files, folders };
590
646
  }
591
- const scanDeck = () => { const { files, folders } = otherDecks(); return scanFiles(DECK_DIR, { skip: [...files, ...folders] }); };
647
+ const scanDeck = () => { const { files, folders } = otherDecks(); return scanFiles(DECK_DIR, { skip: [...files, ...folders, ...OUTPUTS] }); };
592
648
 
593
649
  /* Decks that share a folder share its files (theme.css, figures/): while the assistant of one works, that of
594
650
  another may change them too. Each deck server keeps the times of its turns in a file of its own under
@@ -625,12 +681,12 @@ function othersWorkedSince(start) {
625
681
  MCP servers have no place in a slide editor). That also skips CLAUDE.md files, so the deck's own go
626
682
  into the system prompt instead, read afresh at each session start: CLAUDE.md and AGENTS.md (the name
627
683
  other coding agents read), with their @path imports (lib/instructions.mjs). Parent folders' files are
628
- not loaded; a deck imports shared rules explicitly. Decks copied from the example call the CLI as
629
- `node ../cli.mjs`, which is right only next to PinDeck: that becomes this server's command. */
684
+ not loaded; a deck imports shared rules explicitly. The CLI path written there becomes this server's
685
+ command (useCliCommand). */
630
686
  function deckInstructions() {
631
687
  return ["CLAUDE.md", "AGENTS.md"].map((name) => {
632
688
  const s = readInstructions(path.join(DECK_DIR, name));
633
- return s ? `\n\n## The deck's ${name}\n${s.replace(/node \.\.\/cli\.mjs\b/g, () => CLI_CMD)}\n` : "";
689
+ return s ? `\n\n## The deck's ${name}\n${useCliCommand(s, CLI_CMD)}\n` : "";
634
690
  }).join("");
635
691
  }
636
692
  // a function: ORIGIN is known only once the port is bound
@@ -642,13 +698,13 @@ const codexInstructions = () => systemPrompt.replace(/## Attached files[\s\S]*?#
642
698
  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
699
  Use pindeck_read for text files, images and PDF pages (supply page: 1, then further pages as needed).
644
700
  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.
701
+ Use pindeck_command with separate command and args for layout, shot, slides, pdf, pdf-figure, resolve or reply.
646
702
  Do not use shell commands, native file edits, or other tools to bypass these restrictions.
647
703
  When context mentions Read, use pindeck_read. Read screenshots with pindeck_read.
648
704
  Read README.md and the deck instructions below for conventions. Use layout after every visual change.
649
705
  PDF tables should be reconstructed as HTML; pdf-figure renders figures. Audio is embedded by path.
650
706
 
651
- ## Hand edits`) + slidesInstructions() + deckInstructions();
707
+ ## Hand edits`) + slidesInstructions() + quartoInstructions() + deckInstructions();
652
708
  function makeSession(choice) {
653
709
  let initial = null;
654
710
  const options = {
@@ -668,11 +724,13 @@ function makeSession(choice) {
668
724
  explicit), PinDeck's own commands, never the locked paths. Deny rules win over allow rules. */
669
725
  const agent = choice.provider === "codex" ? new CodexSession({ ...options, settings: choice,
670
726
  instructions: codexInstructions(), tools: agentTools }) : new ClaudeSession({ ...options,
671
- args: () => ["--append-system-prompt", systemPrompt + slidesInstructions() + deckInstructions(),
727
+ // 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
728
+ denyMessage: (r) => r?.tool_name === "Bash" ? cliRefusal(r.input?.command, CLI_CMD) : null,
729
+ args: () => ["--append-system-prompt", systemPrompt + slidesInstructions() + quartoInstructions() + deckInstructions(),
672
730
  "--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands", // no settings files, no MCP servers or connectors
673
- "--permission-mode", "default", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
731
+ "--permission-mode", "default", "--permission-prompt-tool", "stdio", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
674
732
  "--allowedTools", ...readRules(), "Edit(./**)", "Write(./**)",
675
- ...["layout", "shot", "slides", "pdf", "pdf-figure"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
733
+ ...["layout", "shot", "slides", "pdf", "pdf-figure", "resolve", "reply"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
676
734
  "--disallowedTools", ...lockedPaths().flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
677
735
  ...(choice.model ? ["--model", choice.model] : []), ...(choice.effort ? ["--effort", choice.effort] : [])],
678
736
  });
@@ -846,7 +904,7 @@ function toolSummary(name, input = {}) {
846
904
  // must show as the script, or a refused script looks like a refused layout check
847
905
  const cmd = String(input.command || "").trim();
848
906
  const m = cmd.startsWith(CLI_CMD + " ") && cmd.slice(CLI_CMD.length).match(/^\s+([\w-]+)\s*([^\n;&|<>`$]*)$/);
849
- const names = { shot: "Screenshot", layout: "Check layout", "pdf-figure": "PDF figure" };
907
+ const names = { shot: "Screenshot", layout: "Check layout", "pdf-figure": "PDF figure", resolve: "Resolve comment", reply: "Reply to comment" };
850
908
  return m ? { name: names[m[1]] || m[1], detail: m[1] === "pdf-figure" ? m[2].trim().replace(/^\S*\//, "") : m[2].trim() }
851
909
  : { name: "Run", detail: cmd.split("\n")[0].slice(0, 80) };
852
910
  }
@@ -1080,6 +1138,98 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
1080
1138
  return { text: `${out} (${size})` };
1081
1139
  }
1082
1140
 
1141
+ /* ---- Review comments (lib/review.mjs) ---------------------------------------------------------------
1142
+ Kept in <deck folder>/.pindeck/: never a deck file, never in an undo, locked for the assistant. The packet
1143
+ next to them is written once there are comments, and again whenever they or the deck change. An agent outside
1144
+ PinDeck may set a comment's status in the file; the editor follows. A file that cannot be read is left as it
1145
+ is: comments are then off, with the reason. */
1146
+ const REVIEW = reviewFiles(DECK_DIR, DECK_FILE);
1147
+ let review = null, reviewProblem = null, reviewSaved = null;
1148
+ function readReview() {
1149
+ try { review = loadReview(REVIEW.json); reviewProblem = null; }
1150
+ catch (e) { review = null; reviewProblem = `The comments file ${REVIEW.json} cannot be read (${e.message}): fix or remove it.`; }
1151
+ try { reviewSaved = fs.readFileSync(REVIEW.json, "utf8"); } catch { reviewSaved = null; }
1152
+ }
1153
+ readReview();
1154
+ const deckIndex = () => indexDeck(readDeck(DECK_FILE) || "");
1155
+ // the Quarto source's text, for the source lines of comments (null for an HTML deck)
1156
+ const sourceText = () => { try { return SOURCE ? fs.readFileSync(path.join(DECK_DIR, SOURCE), "utf8") : null; } catch { return null; } };
1157
+ function commentsView(deck = deckIndex()) {
1158
+ if (!review) return { comments: [], problem: reviewProblem };
1159
+ return { file: path.relative(DECK_DIR, REVIEW.json), packet: path.relative(DECK_DIR, REVIEW.packet), problem: null,
1160
+ comments: review.comments.map((c, i) => {
1161
+ const loc = locate(deck, c);
1162
+ 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 } };
1163
+ }) };
1164
+ }
1165
+ function writePacket(deck = deckIndex()) {
1166
+ if (!review || (!review.comments.length && !fs.existsSync(REVIEW.packet))) return;
1167
+ fs.mkdirSync(path.dirname(REVIEW.packet), { recursive: true });
1168
+ writeAtomic(REVIEW.packet, packet(review, deck, { deckFile: DECK_FILE, source: SOURCE, srcText: sourceText(), commentsFile: path.relative(DECK_DIR, REVIEW.json) }));
1169
+ }
1170
+ function commentsChanged({ save = true } = {}) {
1171
+ const deck = deckIndex();
1172
+ if (save) { saveReview(REVIEW.json, review); reviewSaved = fs.readFileSync(REVIEW.json, "utf8"); }
1173
+ try { writePacket(deck); } catch (e) { console.error(e.message); }
1174
+ const view = commentsView(deck);
1175
+ broadcast("comments", view);
1176
+ return view;
1177
+ }
1178
+ // edits of the file by another program (an agent marks a comment resolved): polled, which survives replaced files
1179
+ function syncReview() {
1180
+ let now = null;
1181
+ try { now = fs.readFileSync(REVIEW.json, "utf8"); } catch {}
1182
+ if (now === reviewSaved) return false;
1183
+ readReview();
1184
+ return true;
1185
+ }
1186
+ fs.watchFile(REVIEW.json, { interval: 1000 }, () => { if (syncReview()) commentsChanged({ save: false }); });
1187
+
1188
+ /* Rendering while PinDeck runs (see Quarto above): started by a change to a source file */
1189
+ let rendering = null, renderAgain = false, renderError = startRenderError, renderTimer = null, renderStarted = Date.now();
1190
+ if (renderError) {
1191
+ addEntry({ kind: "notice", text: renderError + `\nThe deck shown is the last render that worked.` });
1192
+ state.notes.push(renderError + "\nFix the source; PinDeck renders again after every change.");
1193
+ }
1194
+ function render() {
1195
+ clearTimeout(renderTimer);
1196
+ renderTimer = null;
1197
+ if (rendering) { renderAgain = true; return rendering; }
1198
+ renderStarted = Date.now();
1199
+ rendering = new Promise((done) => {
1200
+ execFile(QUARTO.path, ["render", SOURCE], { cwd: DECK_DIR, timeout: RENDER_TIMEOUT, maxBuffer: 1 << 24 }, (err, _out, stderr) => {
1201
+ const was = renderError;
1202
+ renderError = err ? quartoError(stderr || err.message) : null;
1203
+ if (renderError && renderError !== was) {
1204
+ addEntry({ kind: "notice", text: renderError });
1205
+ state.notes.push(renderError + "\nFix the source; PinDeck renders again after every change.");
1206
+ } else if (!renderError && was) addEntry({ kind: "notice", text: `${SOURCE} renders again.` });
1207
+ if (!err) runEidPass(); // the fresh output gets its element ids (and the editor reloads it)
1208
+ done();
1209
+ });
1210
+ }).finally(() => {
1211
+ rendering = null;
1212
+ if (renderAgain) { renderAgain = false; render(); }
1213
+ pushStatus();
1214
+ });
1215
+ pushStatus();
1216
+ return rendering;
1217
+ }
1218
+ function sourceChanged() {
1219
+ clearTimeout(renderTimer);
1220
+ renderTimer = setTimeout(render, 150); // an editor's save is often several events
1221
+ }
1222
+ /* Before the assistant looks at the deck: a pending or running render ends first, and a source saved since the
1223
+ last render began (its file event may not have come yet) is rendered now */
1224
+ async function rendered() {
1225
+ if (!SOURCE) return null;
1226
+ let saved = 0;
1227
+ try { saved = fs.statSync(path.join(DECK_DIR, SOURCE)).mtimeMs; } catch {}
1228
+ if (renderTimer || saved > renderStarted) render();
1229
+ while (rendering) await rendering;
1230
+ return renderError;
1231
+ }
1232
+
1083
1233
  /* ---- File watching --------------------------------------------------------------------------
1084
1234
  Changes are collected for 120 ms and sent to the editor with the deck's revision, so a tab that missed
1085
1235
  events (reconnect) can still tell that it shows an old state. Dot files (PinDeck's temporary files,
@@ -1090,6 +1240,10 @@ function onFileEvent(_ev, file) {
1090
1240
  if (!file) { changed.add(DECK_FILE); return scheduleFlush(); } // some systems omit the name: assume the deck
1091
1241
  file = String(file).split(path.sep).join("/");
1092
1242
  if (/(^|\/)\.|\.bak$|~$|^export\//.test(file)) return;
1243
+ if (SOURCE) {
1244
+ if (isOutput(file)) { if (rendering || file !== DECK_FILE) return; } // a render's own writes: its id pass reloads
1245
+ else if (SOURCE_FILE.test(file)) sourceChanged();
1246
+ }
1093
1247
  changed.add(file);
1094
1248
  scheduleFlush();
1095
1249
  }
@@ -1126,6 +1280,7 @@ function scheduleFlush() {
1126
1280
  };
1127
1281
  eidTimer = setTimeout(pass, 800);
1128
1282
  }
1283
+ if (files.includes(DECK_FILE) && review?.comments.length) commentsChanged({ save: false }); // found again, or flagged
1129
1284
  if (files.includes(DECK_FILE)) { // the slide tags, by hand or by Claude: tell both when they stop working
1130
1285
  const now = { selector: deckSlides().selector, problem: deckProblemNow() };
1131
1286
  if (now.selector !== deckNow.selector || now.problem !== deckNow.problem) {
@@ -1300,7 +1455,7 @@ function undo(res) {
1300
1455
  /* ---- PowerPoint import -----------------------------------------------------------------------------
1301
1456
  Upload: the file is read at once (slide list for the dialog); in the background PowerPoint or Keynote
1302
1457
  makes a PDF of it, from which come the thumbnails and, on import, the pictures of diagrams.
1303
- Only the latest upload is kept, in ~/.pindeck-v3/decks/<deck>/imports/. */
1458
+ Only the latest upload is kept, in ~/.pindeck-v4/decks/<deck>/imports/. */
1304
1459
  const IMPORT_DIR = path.join(STATE_DIR, "imports");
1305
1460
  const IMPORT_MAX = 500 * 1024 * 1024;
1306
1461
  let lastImport = null; // { id, name, dir, model, render: Promise<pdf path | null>, renderError }
@@ -1466,10 +1621,12 @@ function serveFile(res, file, transform, req, extra = {}) {
1466
1621
  let ready = false;
1467
1622
  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)$/;
1468
1623
  const CLI_ROUTES = /^\/api\/(slides|layout|shot|pdf|pdf-figure)$/; // answered as text, for cli.mjs
1624
+ const HAND_EDITS = /^\/api\/(edit|text|split|remove|notes|delete|slide-hidden|move-slide|add-slide|import|import-apply)$/; // refused for a Quarto deck
1469
1625
  /* Scriptable files (HTML, SVG) from the deck get the sandbox, wherever they are asked for. Under /v/ they are
1470
- read by documents of that sandboxed origin, so fonts and module scripts need CORS; the path is the secret. */
1626
+ read by documents of that sandboxed origin, so fonts and module scripts need CORS; the path is the secret.
1627
+ On localhost (Present) there is no sandbox: no login works there (see presentHost). */
1471
1628
  const SCRIPTABLE = /\.(html|svg|xml)$/i;
1472
- const deckHeaders = (f, viewing) => ({ ...(SCRIPTABLE.test(f) ? { "content-security-policy": SANDBOX } : {}),
1629
+ const deckHeaders = (f, viewing, open) => ({ ...(SCRIPTABLE.test(f) && !open ? { "content-security-policy": SANDBOX } : {}),
1473
1630
  ...(viewing ? { "access-control-allow-origin": "*" } : {}) });
1474
1631
  const server = http.createServer(async (req, res) => {
1475
1632
  if (!ready) return text(res, 503, "PinDeck is starting. Reload in a moment.");
@@ -1478,7 +1635,7 @@ const server = http.createServer(async (req, res) => {
1478
1635
  try {
1479
1636
  const url = new URL(req.url, ORIGIN);
1480
1637
  let p = url.pathname;
1481
- if (req.method === "GET" && p === "/login") return login(res, url);
1638
+ if (req.method === "GET" && p === "/login") return login(res, url, host);
1482
1639
  // /v/<token>/deck/... and /v/<token>/files/...: the deck's pages and files, read-only, nothing else
1483
1640
  let viewing = false;
1484
1641
  if (p.startsWith("/v/")) {
@@ -1526,7 +1683,7 @@ const server = http.createServer(async (req, res) => {
1526
1683
  const f = deckPath;
1527
1684
  if (!viewing && /\.html$/i.test(f)) return toView(p);
1528
1685
  const mode = url.searchParams.get("editor");
1529
- if (!mode || !/\.html$/i.test(f)) return serveFile(res, f, null, req, deckHeaders(f, viewing));
1686
+ if (!mode || !/\.html$/i.test(f)) return serveFile(res, f, null, req, deckHeaders(f, viewing, presentHost(host)));
1530
1687
  return serveFile(res, f, (s) => {
1531
1688
  // headless runs must not stream media, or Chrome never finishes the page
1532
1689
  if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
@@ -1537,7 +1694,7 @@ const server = http.createServer(async (req, res) => {
1537
1694
  }
1538
1695
  return s.replace(/<\/body>/i, BRIDGE_TAG);
1539
1696
  }, req, ["shot", "dump", "pdf"].includes(mode) && headlessKeys.delete(url.searchParams.get("headless"))
1540
- ? deckHeaders(".css", viewing) : deckHeaders(f, viewing)); // headless Chrome's page, once (see chromeStart)
1697
+ ? deckHeaders(".css", viewing) : deckHeaders(f, viewing, presentHost(host))); // headless Chrome's page, once (see chromeStart)
1541
1698
  }
1542
1699
  if (viewing) return text(res, 404, "Not found");
1543
1700
 
@@ -1568,6 +1725,14 @@ const server = http.createServer(async (req, res) => {
1568
1725
  return;
1569
1726
  }
1570
1727
  // the deck file gone (moved, renamed, deleted) while PinDeck runs: say so, instead of failing on it
1728
+ // a Quarto deck is rendered from its source: no hand edits of the output (the next render would undo them)
1729
+ if (SOURCE && req.method === "POST" && HAND_EDITS.test(p))
1730
+ return json(res, 409, { code: "quarto", error: `${DECK_FILE} is rendered by Quarto from ${SOURCE}: ask the assistant, or edit ${SOURCE} yourself.` });
1731
+ // what the assistant checks is the deck rendered from its last edit
1732
+ if (SOURCE && CLI_ROUTES.test(p)) {
1733
+ const failed = await rendered();
1734
+ if (failed) return text(res, 409, failed + `\nFix ${SOURCE}; PinDeck renders again after every change.`);
1735
+ }
1571
1736
  if (NEEDS_DECK.test(p) && readDeck(DECK_FILE) == null) {
1572
1737
  const why = `${path.join(DECK_DIR, DECK_FILE)} is not there any more (moved, renamed or deleted). Put it back, ` +
1573
1738
  `or start PinDeck for the deck where it is now.`;
@@ -1629,6 +1794,65 @@ const server = http.createServer(async (req, res) => {
1629
1794
  return json(res, 200, { id: entry.id });
1630
1795
  } finally { state.preparing = null; if (!state.turn) { state.busy = false; pushStatus(); } }
1631
1796
  }
1797
+ if (p === "/api/comments" && req.method === "GET") return json(res, 200, commentsView());
1798
+ // `cli.mjs resolve <id or C3> [what was done]`: the assistant (or a terminal) marks a comment resolved;
1799
+ // `cli.mjs reply <id or C3> <text>`: it answers one it leaves open (why not, a question), in the comment's thread
1800
+ if (p === "/api/resolve" || p === "/api/reply") {
1801
+ syncReview(); // the poll may not have seen another program's edit yet: saving now would undo it
1802
+ if (!review) return text(res, 409, reviewProblem);
1803
+ const want = String(url.searchParams.get("id") || "").trim();
1804
+ const n = /^C(\d+)$/i.exec(want)?.[1];
1805
+ const c = n ? review.comments[+n - 1] : review.comments.find((x) => x.id === want);
1806
+ if (!c) return text(res, 404, `No comment "${want}". Comment ids look like c-1a2b3c4d (or C3, its number in the editor).`);
1807
+ const note = String(url.searchParams.get("note") || "").trim().slice(0, 2000);
1808
+ const by = state.busy ? "assistant" : "command line";
1809
+ const name = `C${review.comments.indexOf(c) + 1} (${c.id})`;
1810
+ if (p === "/api/reply") {
1811
+ if (!note) return text(res, 400, "Write the reply after the comment id.");
1812
+ (c.replies ||= []).push({ by, text: note, at: new Date().toISOString() });
1813
+ } else {
1814
+ c.status = "resolved";
1815
+ c.resolvedBy = by;
1816
+ c.resolution = note.slice(0, 500) || null;
1817
+ }
1818
+ c.updated = new Date().toISOString();
1819
+ commentsChanged();
1820
+ return text(res, 200, p === "/api/reply" ? `Replied to ${name}; it stays open.` : `Resolved ${name}.`);
1821
+ }
1822
+ if (p === "/api/comments-request" && req.method === "GET") {
1823
+ if (!review) return json(res, 409, { error: reviewProblem });
1824
+ return json(res, 200, { text: commentsRequest(review, deckIndex(), { source: SOURCE, srcText: sourceText() }) });
1825
+ }
1826
+ if (/^\/api\/comment(-update|-delete)?$/.test(p) && req.method === "POST") {
1827
+ syncReview();
1828
+ if (!review) return json(res, 409, { error: reviewProblem });
1829
+ const b = await body(req);
1830
+ if (p === "/api/comment") {
1831
+ if (typeof b.text !== "string" || !b.text.trim()) return json(res, 400, { error: "Write the comment first." });
1832
+ let c;
1833
+ try { c = commentFromPin(deckIndex(), { pin: b.pin || null, slide: b.slide }, b.text); }
1834
+ catch (e) { return json(res, 409, { error: e.message }); }
1835
+ review.comments.push(c);
1836
+ return json(res, 200, { id: c.id, ...commentsChanged() });
1837
+ }
1838
+ const c = review.comments.find((x) => x.id === b.id);
1839
+ if (!c) return json(res, 404, { error: "No such comment (removed meanwhile?)." });
1840
+ if (p === "/api/comment-delete") review.comments = review.comments.filter((x) => x !== c);
1841
+ else {
1842
+ if (typeof b.text === "string") { if (!b.text.trim()) return json(res, 400, { error: "A comment cannot be empty." }); c.text = b.text.trim(); }
1843
+ if (typeof b.reply === "string") {
1844
+ if (!b.reply.trim()) return json(res, 400, { error: "Write the reply first." });
1845
+ (c.replies ||= []).push({ by: "you", text: b.reply.trim(), at: new Date().toISOString() });
1846
+ }
1847
+ if (b.status === "open" || b.status === "resolved") {
1848
+ c.status = b.status;
1849
+ c.resolvedBy = b.status === "resolved" ? "you" : undefined;
1850
+ if (b.status === "open") c.resolution = undefined;
1851
+ }
1852
+ c.updated = new Date().toISOString();
1853
+ }
1854
+ return json(res, 200, commentsChanged());
1855
+ }
1632
1856
  if (p === "/api/upload" && req.method === "POST") {
1633
1857
  const original = decodeURIComponent(String(req.headers["x-filename"] || "file"));
1634
1858
  const kind = attachKind(original);
@@ -1872,8 +2096,9 @@ const server = http.createServer(async (req, res) => {
1872
2096
  if (!r.changed) return json(res, 200, { unchanged: true });
1873
2097
  const n = findSlides(parse(before[DECK_FILE])).findIndex((s) => s.attrs["data-eid"]?.value === b.eid) + 1;
1874
2098
  const text = String(b.text || "").trim();
2099
+ const said = b.html ? decode(text.replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]*>/g, "")).trim() : text; // Claude reads the words, not the tags
1875
2100
  const turnId = recordHandEdit(before, r.html, text ? `Edited the notes of slide ${n}` : `Removed the notes of slide ${n}`,
1876
- text ? `The user edited the speaker notes of slide ${n} (${b.eid}) by hand. They now read: "${text.slice(0, 300)}".`
2101
+ text ? `The user edited the speaker notes of slide ${n} (${b.eid}) by hand. They now read: "${said.slice(0, 300)}".`
1877
2102
  : `The user removed the speaker notes of slide ${n} (${b.eid}).`); // manual: the slide itself does not change
1878
2103
  return json(res, 200, { turnId });
1879
2104
  }
@@ -1987,6 +2212,7 @@ server.listen(PORT, "127.0.0.1", () => {
1987
2212
  const first = runEidPass();
1988
2213
  if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
1989
2214
  startWatcher();
2215
+ try { writePacket(); } catch (e) { console.error(e.message); }
1990
2216
  } catch (e) {
1991
2217
  console.error(e.plain ? e.message : `PinDeck could not open the deck: ${e.message}`);
1992
2218
  removeServerInfo();
@@ -2015,6 +2241,7 @@ async function quit() {
2015
2241
  if (quitting) process.exit(1);
2016
2242
  quitting = true;
2017
2243
  try { watcher?.close(); } catch {}
2244
+ fs.unwatchFile(REVIEW.json);
2018
2245
  clearTimeout(flush); clearTimeout(eidTimer);
2019
2246
  for (const res of clients) res.end();
2020
2247
  for (const w of pending.values()) { clearTimeout(w.t); w.reject(new Error("PinDeck is stopping")); }