pi-quiver 5.4.0 → 6.0.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/CHANGELOG.md CHANGED
@@ -8,6 +8,18 @@ Published to npm as `pi-quiver` (`pi install npm:pi-quiver`). Pushing a
8
8
  via OIDC trusted publishing. The release helper at
9
9
  `.agents/skills/release/scripts/release.sh` cuts the tag; CI publishes.
10
10
 
11
+ ## v6.0.0 - 2026-09-10
12
+
13
+ - doc_to_md (Excel, breaking): `maxCellsPerSheet` is removed from the tool schema, CLI (`--max-cells-per-sheet`), and `quiver.docToMd` settings; a leftover key is reported by the settings lint. Sheet indices are now 0-based workbook positions covering worksheets and chartsheets, so embedded-image files move from `<stem>-s1-<n>.*` to `<stem>-s0-<n>.*` and `SheetInfo.index` is rebased.
14
+ - doc_to_md (Excel): the Markdown opens with a `## Sheets` inventory (kind, size, hidden, chart/image counts, rendered view, CSV link); every non-empty worksheet's full content is exported to `sheets/<stem>-s<idx>-<slug>.csv`; the in-Markdown preview is capped at 100 rows x 50 columns and, when truncated, followed by a per-column profile (type, non-empty, min/max, distinct). Chartsheets and embedded charts are listed with type, title, and series refs.
15
+ - doc_to_md (Excel): sheets carrying charts or images get a rendered view (`images/<stem>-s<idx>.<fmt>`) when LibreOffice is on `PATH` - one PDF page per sheet via `calc_pdf_Export` `SinglePageSheets`, rasterized by a new PyMuPDF `render-pages` child under a 16 Mpx budget. Any soffice or render failure degrades to `Rendered view: unavailable (<reason>)` plus a handle note; the conversion never fails. `.xls` keeps inventory/CSV/preview but has no visual detection.
16
+ - doc_to_md: handle prints `Sheets-Dir` when CSVs were written; `info` lists chartsheets with kind and chart/image counts. Bundle overwrite now removes only this stem's owned-pattern files in `images/` and `sheets/`.
17
+
18
+ ## v5.5.0 - 2026-09-10
19
+
20
+ - doc_to_md: rewrite primary-PDF image links correctly on Windows.
21
+ - slack: optionally resolve the user token through a credential command on every user-identity tool call, with a configurable timeout in seconds, allowing running pi sessions to use replaced credentials without leaking secrets on command failure or falling back to stale env tokens.
22
+
11
23
  ## v5.4.0 - 2026-09-10
12
24
 
13
25
  - Settings: one condensed `pi-quiver settings` warning (each distinct message
package/README.md CHANGED
@@ -65,7 +65,7 @@ A 300 KB changelog page never touches your context window - you get a preview an
65
65
  | Extension | Tool | What it does |
66
66
  | --- | --- | --- |
67
67
  | `extensions/fetch.ts` | `fetch` | Retrieve URLs over HTTP(S). HTML -> Markdown (Readability extraction, Turndown conversion). Binary saved untouched to a temp file. GitHub issue/PR/repo/actions-run/actions-job URLs auto-route through `gh` (falls back to HTTP); failed runs/jobs include failed-step logs (best-effort, summary-only otherwise). Same size gate as `fetch`. Behavior lives in `lib/fetch-core.ts`; also exposed as the `pi-quiver fetch` CLI (see [Claude Code support](#claude-code-support)). |
68
- | `extensions/doc_to_md.ts` | `doc_to_md` | Convert a local PDF/DOCX/PPTX/XLSX/XLS to a Markdown bundle on disk (`<stem>.md` + `images/`) and return a handle (paths, page count, outline, diagnostics) - never inline Markdown. `info` mode inspects first; `pages` selects 1-based pages; every page ends with `--- end of page.page_number=N ---`. Tiers: pymupdf4llm -> PyMuPDF text (degraded) -> unpdf worker (no Python only). Excel -> per-worksheet matrices with formulas/cached values/merged/hidden. Settings under `quiver.docToMd`. |
68
+ | `extensions/doc_to_md.ts` | `doc_to_md` | Convert a local PDF/DOCX/PPTX/XLSX/XLS to a Markdown bundle on disk (`<stem>.md` + `images/` and spreadsheet `sheets/`) and return a handle (paths, page count, outline, diagnostics) - never inline Markdown. `info` mode inspects first; `pages` selects 1-based pages; every page ends with `--- end of page.page_number=N ---`. Tiers: pymupdf4llm -> PyMuPDF text (degraded) -> unpdf worker (no Python only). Excel -> sheet inventory, full CSVs, bounded previews, and optional rendered views. Settings under `quiver.docToMd`. |
69
69
  | `extensions/session-name.ts` | `/session-name` | Manual + opt-in automatic session naming, naming rules and deny list, long-session revisits, and Ghostty/Herdr tab rename. OFF by default. |
70
70
  | `extensions/sword-header.ts` | `/builtin-header` | Themed ASCII startup header replacing pi's default logo. OFF by default. |
71
71
  | `extensions/fast-mode.ts` | `/fast` | Inject Anthropic fast-mode (`speed: "fast"` + `anthropic-beta: fast-mode-2026-02-01`) into every Claude Opus 4.8 / Opus 5 request, any thinking level. `--fast` flag + `/fast [on\|off\|status]`. OFF by default. |
@@ -93,7 +93,7 @@ Full routing rules, size-gate mechanics, and config: [doc/fetch.md](doc/fetch.md
93
93
  ## When NOT to use
94
94
 
95
95
  - You need a general-purpose web scraper (JS-rendered pages, pagination, auth flows) - `fetch` does plain HTTP + Readability extraction, nothing more.
96
- - You need `.xlsm`, sheet/range selection or chart rendering - out of scope.
96
+ - You need in-grid chart/image placement, `.xls` visual detection, `.xlsm`, or range selection - out of scope; chart/image association is per sheet only.
97
97
  - You want automatic session naming, a custom header, fast mode, or stall recovery without opting in - all stay off until you flip the config.
98
98
  - You need *mid-stream* stall recovery in JSON, RPC, or print runs - only the pre-first-event tier arms there; mid-stream silence falls through to pi's transport timeout.
99
99
 
@@ -274,11 +274,13 @@ Operational notes:
274
274
  | --- | --- | --- |
275
275
  | `enabled` | `false` | Master switch, checked at `session_start`; toggling takes effect next session. |
276
276
  | `cachePath` | user-scope per-OS cache dir | Overrides where the workspace-keyed channel/user name->ID cache file is written; relative paths resolve against the repo root. |
277
- | `userTokenEnv` | `SLACK_USER_TOKEN` | Env var name holding the user token (required for `slack_search`/`slack_thread`, no bot fallback). |
277
+ | `userTokenEnv` | `SLACK_USER_TOKEN` | Env var name holding the user token when `userTokenCommand` is unset (required for `slack_search`/`slack_thread`, no bot fallback). |
278
+ | `userTokenCommand` | unset | Executable argv array that prints the current user token to stdout; resolved on every user-identity tool call. |
279
+ | `userTokenCommandTimeoutSeconds` | `10` | Positive finite timeout in seconds for `userTokenCommand`. Increase this when an interactive credential helper may need authorization. |
278
280
  | `botTokenEnv` | `SLACK_BOT_TOKEN` | Env var name holding the bot token. |
279
281
  | `uploadThresholdChars` | `4000` | Link-collapsed length above which an announce/thread detail body is delivered as a file upload instead of inline text. |
280
282
 
281
- Each setting can also be overridden per-process via `PI_QUIVER_SLACK_ENABLED`, `PI_QUIVER_SLACK_CACHE_PATH`, `PI_QUIVER_SLACK_USER_TOKEN_ENV`, `PI_QUIVER_SLACK_BOT_TOKEN_ENV`, and `PI_QUIVER_SLACK_UPLOAD_THRESHOLD_CHARS` - applied on top of the resolved `settings.json` layers, same override rung the extension's config resolver defines. Tokens themselves are resolved per call: process env first, then the repo's `.env` file (or the primary checkout's, for a worktree with none) - never a fallback across identities. Full reference incl. cache layering, the announce protocol, and the `search.messages`/`conversations.replies` throttle caveats: [doc/slack.md](doc/slack.md).
283
+ Each setting can also be overridden per-process via `PI_QUIVER_SLACK_ENABLED`, `PI_QUIVER_SLACK_CACHE_PATH`, `PI_QUIVER_SLACK_USER_TOKEN_ENV`, `PI_QUIVER_SLACK_BOT_TOKEN_ENV`, and `PI_QUIVER_SLACK_UPLOAD_THRESHOLD_CHARS` - applied on top of the resolved `settings.json` layers, same override rung the extension's config resolver defines. `userTokenCommand` and `userTokenCommandTimeoutSeconds` are settings-only: pi executes the argv directly, without a shell, on every user-identity Slack tool call. For example, macOS Keychain can supply the token with `"userTokenCommand": ["security", "find-generic-password", "-s", "slack-user-token", "-w"]`. Its stdout is the token; empty output, nonzero exit, or timeout is a sanitized hard error and never falls back to `userTokenEnv`. Without the command, tokens resolve per call from process env and then `.env` (or the primary checkout's, for a worktree with none). Bot resolution is unchanged. Restart pi after changing Slack settings because the extension captures them at session start. Full reference incl. cache layering, the announce protocol, and the `search.messages`/`conversations.replies` throttle caveats: [doc/slack.md](doc/slack.md).
282
284
 
283
285
  ### doc_to_md settings
284
286
 
@@ -287,22 +289,21 @@ Each setting can also be overridden per-process via `PI_QUIVER_SLACK_ENABLED`, `
287
289
  | Key | Default | Meaning |
288
290
  |---|---|---|
289
291
  | `primaryTimeoutMs` | `60000` | pymupdf4llm tier and unpdf tier deadline. |
290
- | `fallbackTimeoutMs` | `30000` | PyMuPDF text tier and PDF info deadline. |
291
- | `sofficeTimeoutMs` | `120000` | DOCX/PPTX -> PDF deadline. |
292
+ | `fallbackTimeoutMs` | `30000` | PyMuPDF text tier, PDF info, and Excel rendered-view rasterization deadline. |
293
+ | `sofficeTimeoutMs` | `120000` | DOCX/PPTX -> PDF deadline; also the Excel rendered-view export. |
292
294
  | `excelTimeoutMs` | `60000` | Excel child and Excel info deadline. |
293
295
  | `warmTimeoutMs` | `120000` | Absolute first-call backend discovery/bootstrap deadline. |
294
296
  | `pymupdfVersion` | `1.27.2.3` | pymupdf4llm pin, minimum `1.27.0`. |
295
- | `imageDpi` | `150` | Render DPI for page images. |
297
+ | `imageDpi` | `150` | Render DPI for page images and Excel rendered views (capped by a 16 Mpx budget). |
296
298
  | `imageFormat` | `png` | Rendered image format: `png` or `jpg`; embedded images retain their extension. |
297
- | `maxCellsPerSheet` | `50000` | Rows x columns budget per worksheet. |
298
299
  | `maxOutputBytes` | `20000000` | Child stdout cap in bytes. |
299
300
  | `outlineMaxEntries` | `40` | Heading outline, TOC, or sheet inventory entries in the handle. |
300
301
 
301
- A bundle is `<outputDir>/<stem>.md` plus `<outputDir>/images/`; without `outputDir`, the tool creates a per-call temp root. A conversion owns `<stem>.md.lock` until it atomically publishes the Markdown. An existing `<stem>.md` fails the call unless `overwrite` is set; `overwrite` replaces that Markdown and the images it owns (`<stem>-p<N>-<n>.*` / `<stem>-s<idx>-<n>.*`), nothing else. Temp bundles are caller-owned - the tool never deletes a bundle it produced.
302
+ A bundle is `<outputDir>/<stem>.md` plus `<outputDir>/images/` and, for spreadsheets with data, `<outputDir>/sheets/`; without `outputDir`, the tool creates a per-call temp root. A conversion owns `<stem>.md.lock` until it atomically publishes the Markdown. An existing `<stem>.md` fails the call unless `overwrite` is set; `overwrite` replaces that Markdown and the files it owns (`images/<stem>-p<N>-<n>.*`, `images/<stem>-s<idx>[-<n>].*`, `sheets/<stem>-s<idx>-<slug>.csv`), nothing else. Temp bundles are caller-owned - the tool never deletes a bundle it produced.
302
303
 
303
- Excel needs a Python backend with openpyxl, xlrd and pillow - otherwise the call fails with `Remedy: install uv, or pip install openpyxl xlrd pillow`.
304
+ Excel needs a Python backend with openpyxl, xlrd and pillow - otherwise the call fails with `Remedy: install uv, or pip install openpyxl xlrd pillow`. The Markdown opens with a `## Sheets` table listing every sheet in workbook order (0-based `#`, `worksheet`/`chartsheet`, size, hidden, chart and image counts, rendered view, CSV link for non-empty worksheets), then one section per sheet: a `Data:` line linking the full-content CSV under `sheets/` for non-empty worksheets, chart metadata from the workbook model, embedded images, an optional rendered view, a preview of at most the first 100 rows x 50 columns, and - only when the preview is truncated - a `Columns:` profile (type, non-empty count, min/max, distinct up to 50). Sizes are the extent of non-empty cells (the `info` handle reports the raw worksheet dimensions instead, which may be larger). Rendered views (`images/<stem>-s<idx>.<fmt>`) are produced for sheets carrying charts or images when LibreOffice is on `PATH`: the workbook is exported one PDF page per sheet and rasterized under a 16 Mpx budget. Any LibreOffice or rasterization failure degrades to `Rendered view: unavailable (<reason>)` and a handle note; it never fails the conversion. Workbooks whose chartsheet drawings carry a zero-size anchor (openpyxl-authored files; Excel-authored files are unaffected) render as a degenerate page and are reported as such. `.xls` gets the inventory, CSVs and previews but no visual detection or rendering.
304
305
 
305
- Worst-case wall time is `warmTimeoutMs (first call) + sofficeTimeoutMs (Office only) + primaryTimeoutMs + fallbackTimeoutMs + KILL_GRACE_MS x kills` (Excel: `warmTimeoutMs + excelTimeoutMs + KILL_GRACE_MS`). There is no cap on image count, image bytes or workbook memory - deliberately; the per-tier timeouts and `maxOutputBytes` are the bounds.
306
+ Worst-case wall time is `warmTimeoutMs (first call) + sofficeTimeoutMs (Office only) + primaryTimeoutMs + fallbackTimeoutMs + KILL_GRACE_MS x kills` (Excel: `warmTimeoutMs + excelTimeoutMs + sofficeTimeoutMs + fallbackTimeoutMs + 2 * KILL_GRACE_MS`). There is no cap on image count, image bytes, cell count or workbook memory - deliberately; the per-tier timeouts, the rendered-view pixel budget and `maxOutputBytes` are the bounds.
306
307
 
307
308
  ### Migrating from flat keys
308
309
 
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // bin/pi-quiver.ts
4
- import { existsSync as existsSync3, readFileSync as readFileSync2, realpathSync } from "node:fs";
4
+ import { existsSync as existsSync3, readFileSync, realpathSync } from "node:fs";
5
5
  import { homedir as homedir2 } from "node:os";
6
6
  import { join as join4, resolve as resolve3 } from "node:path";
7
7
  import { fileURLToPath as fileURLToPath2 } from "node:url";
@@ -524,30 +524,24 @@ import { basename, dirname, extname as extname3, join as join3, resolve as resol
524
524
  import { fileURLToPath, pathToFileURL } from "node:url";
525
525
 
526
526
  // lib/doc-to-md-bundle.ts
527
- import fs, { closeSync, existsSync, mkdirSync as mkdirSync2, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
527
+ import fs, { closeSync, existsSync, mkdirSync as mkdirSync2, openSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
528
528
  import { tmpdir as tmpdir2 } from "node:os";
529
529
  import { extname, join as join2, resolve } from "node:path";
530
530
  import { randomBytes } from "node:crypto";
531
+ var escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
531
532
  function ownedPattern(stem) {
532
- const esc = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
533
- return new RegExp(`^${esc}-(p|s)\\d+-\\d+\\.[a-z0-9]+$`);
533
+ return new RegExp(`^${escRe(stem)}-(p|s)\\d+(-\\d+)?\\.[a-z0-9]+$`);
534
534
  }
535
+ function ownedCsvPattern(stem) {
536
+ return new RegExp(`^${escRe(stem)}-s\\d+-[a-z0-9-]+\\.csv$`);
537
+ }
538
+ var SHEET_LINK_RE = /\[[^\]]*\]\(\s*(sheets\/[^)\s]+)\s*\)/g;
535
539
  var IMG_LINK_RE = /!\[[^\]]*\]\(\s*(?:<([^>]*)>|([^)]*?))\s*\)|<img\b[^>]*\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>"']+))/gi;
536
540
  function imageTarget(m) {
537
541
  const target = (m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5] ?? "").trim();
538
542
  const destination = target.replace(/^<([\s\S]*)>$/, "$1").trim();
539
543
  return m[2] === void 0 ? destination : destination.replace(/\s+(?:"[^"]*"|'[^']*'|\([^)]*\))$/, "");
540
544
  }
541
- function linkedFiles(md) {
542
- const out = [];
543
- for (const m of md.matchAll(IMG_LINK_RE)) {
544
- const target = imageTarget(m);
545
- if (!target.startsWith("images/")) continue;
546
- const file = target.slice("images/".length);
547
- if (file && file !== "." && file !== ".." && /^[^/\\]+$/.test(file)) out.push(file);
548
- }
549
- return out;
550
- }
551
545
  function openBundle(root, stem, overwrite) {
552
546
  mkdirSync2(root, { recursive: true });
553
547
  const mdPath = join2(root, `${stem}.md`);
@@ -561,20 +555,24 @@ function openBundle(root, stem, overwrite) {
561
555
  }
562
556
  closeSync(fd);
563
557
  const imagesDir = join2(root, "images");
558
+ const sheetsDir = join2(root, "sheets");
564
559
  try {
565
560
  if (existsSync(mdPath)) {
566
561
  if (!overwrite) throw new Error(`Output exists: ${mdPath} (pass overwrite)`);
567
- const owned = ownedPattern(stem);
568
- const linked = new Set(linkedFiles(readFileSync(mdPath, "utf8")));
562
+ const owned = ownedPattern(stem), ownedCsv = ownedCsvPattern(stem);
569
563
  unlinkSync(mdPath);
570
564
  if (existsSync(imagesDir)) {
571
- for (const f of readdirSync(imagesDir)) if (owned.test(f) || linked.has(f)) rmSync(join2(imagesDir, f), { force: true });
565
+ for (const f of readdirSync(imagesDir)) if (owned.test(f)) rmSync(join2(imagesDir, f), { force: true });
566
+ }
567
+ if (existsSync(sheetsDir)) {
568
+ for (const f of readdirSync(sheetsDir)) if (ownedCsv.test(f)) rmSync(join2(sheetsDir, f), { force: true });
572
569
  }
573
570
  }
574
571
  const lockId = randomBytes(6).toString("hex");
575
572
  const stagingDir = join2(imagesDir, `.stage-${lockId}`);
573
+ const sheetsStagingDir = join2(sheetsDir, `.stage-${lockId}`);
576
574
  mkdirSync2(stagingDir, { recursive: true });
577
- return { root, stem, mdPath, lockPath, imagesDir, stagingDir, lockId, manifest: /* @__PURE__ */ new Set(), sourceMap: /* @__PURE__ */ new Map() };
575
+ return { root, stem, mdPath, lockPath, imagesDir, stagingDir, lockId, sheetsDir, sheetsStagingDir, manifest: /* @__PURE__ */ new Set(), csvManifest: /* @__PURE__ */ new Set(), sourceMap: /* @__PURE__ */ new Map() };
578
576
  } catch (e) {
579
577
  rmSync(lockPath, { force: true });
580
578
  throw e;
@@ -608,25 +606,42 @@ function publishStaged(b) {
608
606
  }
609
607
  function publishSheetImages(b) {
610
608
  for (const f of readdirSync(b.stagingDir).sort()) {
611
- if (!/^s\d+-\d+\.[a-z0-9]+$/i.test(f)) continue;
609
+ if (!/^s\d+(-\d+)?\.[a-z0-9]+$/i.test(f)) continue;
612
610
  const name = `${b.stem}-${f.toLowerCase()}`;
613
611
  renameSync(join2(b.stagingDir, f), join2(b.imagesDir, name));
614
612
  b.manifest.add(name);
615
613
  b.sourceMap.set(f, `images/${name}`);
616
614
  }
617
615
  }
618
- function rewriteImageLinks(md, sourceMap) {
619
- return md.replace(IMG_LINK_RE, (whole, mdAngle, mdPlain, htmlDouble, htmlSingle, htmlUnquoted) => {
616
+ function publishSheetCsvs(b) {
617
+ if (!existsSync(b.sheetsStagingDir)) return;
618
+ for (const f of readdirSync(b.sheetsStagingDir).sort()) {
619
+ if (!/^s\d+-[a-z0-9-]+\.csv$/.test(f)) continue;
620
+ const name = `${b.stem}-${f}`;
621
+ renameSync(join2(b.sheetsStagingDir, f), join2(b.sheetsDir, name));
622
+ b.csvManifest.add(name);
623
+ b.sourceMap.set(`sheets/${f}`, `sheets/${name}`);
624
+ }
625
+ }
626
+ function rewriteLinks(md, sourceMap) {
627
+ const images = md.replace(IMG_LINK_RE, (whole, mdAngle, mdPlain, htmlDouble, htmlSingle, htmlUnquoted) => {
620
628
  const target = imageTarget([whole, mdAngle, mdPlain, htmlDouble, htmlSingle, htmlUnquoted]);
621
629
  const dest = sourceMap.get(target) ?? sourceMap.get(target.replace(/^\.\//, ""));
622
630
  return dest ? whole.replace(mdAngle === void 0 ? target : `<${mdAngle}>`, dest) : whole;
623
631
  });
632
+ return images.replace(SHEET_LINK_RE, (whole, target) => {
633
+ const dest = sourceMap.get(target);
634
+ return dest ? whole.split(target).join(dest) : whole;
635
+ });
624
636
  }
625
- function validateImageLinks(md, manifest) {
637
+ function validateImageLinks(md, manifest, csvManifest = /* @__PURE__ */ new Set()) {
626
638
  for (const m of md.matchAll(IMG_LINK_RE)) {
627
639
  const target = imageTarget(m);
628
640
  if (!target.startsWith("images/") || !manifest.has(target.slice("images/".length))) throw new Error(`unexpected image reference in output: ${target}`);
629
641
  }
642
+ for (const m of md.matchAll(SHEET_LINK_RE)) {
643
+ if (!csvManifest.has(m[1].slice("sheets/".length))) throw new Error(`unexpected sheet reference in output: ${m[1]}`);
644
+ }
630
645
  }
631
646
  function commitBundle(b, markdown) {
632
647
  const tmp = `${b.mdPath}.tmp`;
@@ -636,6 +651,10 @@ function commitBundle(b, markdown) {
636
651
  fs.rmSync(b.stagingDir, { recursive: true, force: true });
637
652
  } catch {
638
653
  }
654
+ try {
655
+ fs.rmSync(b.sheetsStagingDir, { recursive: true, force: true });
656
+ } catch {
657
+ }
639
658
  try {
640
659
  fs.rmSync(b.lockPath, { force: true });
641
660
  } catch {
@@ -643,8 +662,10 @@ function commitBundle(b, markdown) {
643
662
  }
644
663
  function abortBundle(b) {
645
664
  for (const f of b.manifest) rmSync(join2(b.imagesDir, f), { force: true });
665
+ for (const f of b.csvManifest) rmSync(join2(b.sheetsDir, f), { force: true });
646
666
  rmSync(`${b.mdPath}.tmp`, { force: true });
647
667
  rmSync(b.stagingDir, { recursive: true, force: true });
668
+ rmSync(b.sheetsStagingDir, { recursive: true, force: true });
648
669
  rmSync(b.lockPath, { force: true });
649
670
  }
650
671
  function tempBundleRoot() {
@@ -703,6 +724,7 @@ function outlineLines(entries, total) {
703
724
  function formatHandle(h) {
704
725
  const lines = [`Saved-To: ${h.savedTo}`];
705
726
  if (h.imagesDir && h.imageCount > 0) lines.push(`Images-Dir: ${h.imagesDir}`);
727
+ if (h.sheetsDir) lines.push(`Sheets-Dir: ${h.sheetsDir}`);
706
728
  lines.push(`Type: ${h.type} Engine: ${h.engine} Tier: ${h.tier}`);
707
729
  lines.push(`Page-Count: ${h.pageCount ?? "?"} Pages: ${h.pages ? compactRanges(h.pages) : "all"} Images: ${h.imageCount} Size: ${formatSize2(h.bytes)} / ${h.lines} lines`);
708
730
  if (h.degraded) lines.push(`Degraded: ${h.degraded}`);
@@ -719,7 +741,7 @@ function formatInfoHandle(i, max) {
719
741
  if (i.sheets) {
720
742
  const lines2 = [`Type: ${i.type} Sheets: ${i.sheetsTotal}`];
721
743
  for (const s of i.sheets.slice(0, max)) {
722
- const dims = `rows=${s.rows ?? "?"} cols=${s.cols ?? "?"}`;
744
+ const dims = `${s.kind} rows=${s.rows ?? "-"} cols=${s.cols ?? "-"} charts=${s.charts} images=${s.images}`;
723
745
  const hidden = s.hiddenRows || s.hiddenCols ? ` hiddenRows=${s.hiddenRows} hiddenCols=${s.hiddenCols}` : "";
724
746
  lines2.push(` ${trunc(s.name, TITLE_MAX)} ${s.hidden ? "hidden " : ""}${dims}${hidden}`);
725
747
  }
@@ -749,14 +771,13 @@ var DOC_TO_MD_OPTIONS = [
749
771
  { key: "outputDir", flag: "--output-dir", type: "string", default: null, settable: false, help: "Bundle root for <stem>.md + images/; default a per-call temp dir" },
750
772
  { key: "overwrite", flag: "--overwrite", type: "bool", default: false, settable: false, help: "Replace an existing completed <stem>.md bundle" },
751
773
  { key: "primaryTimeoutMs", flag: "--primary-timeout", type: "int", default: 6e4, settable: true, env: "PI_DOC_TO_MD_CONVERT_TIMEOUT_MS", help: "pymupdf4llm tier; also the unpdf tier" },
752
- { key: "fallbackTimeoutMs", flag: "--fallback-timeout", type: "int", default: 3e4, settable: true, help: "PyMuPDF get_text tier; also info on PDF" },
753
- { key: "sofficeTimeoutMs", flag: "--soffice-timeout", type: "int", default: 12e4, settable: true, env: "PI_DOC_TO_MD_SOFFICE_TIMEOUT_MS", help: "DOCX/PPTX -> PDF via LibreOffice" },
774
+ { key: "fallbackTimeoutMs", flag: "--fallback-timeout", type: "int", default: 3e4, settable: true, help: "PyMuPDF get_text tier; also info on PDF and Excel rendered views" },
775
+ { key: "sofficeTimeoutMs", flag: "--soffice-timeout", type: "int", default: 12e4, settable: true, env: "PI_DOC_TO_MD_SOFFICE_TIMEOUT_MS", help: "DOCX/PPTX -> PDF via LibreOffice; also Excel rendered views" },
754
776
  { key: "excelTimeoutMs", flag: "--excel-timeout", type: "int", default: 6e4, settable: true, help: "Excel child (both openpyxl loads); also info on Excel" },
755
777
  { key: "warmTimeoutMs", flag: "--warm-timeout", type: "int", default: 12e4, settable: true, env: "PI_DOC_TO_MD_WARM_TIMEOUT_MS", help: "Absolute backend discovery/bootstrap deadline (first call per process)" },
756
778
  { key: "pymupdfVersion", flag: "--pymupdf-version", type: "version", default: "1.27.2.3", settable: true, env: "PI_DOC_TO_MD_PYMUPDF_VERSION", help: "pymupdf4llm pin (>= 1.27.0)" },
757
- { key: "imageDpi", flag: "--image-dpi", type: "int", default: 150, settable: true, help: "Render DPI for page images" },
779
+ { key: "imageDpi", flag: "--image-dpi", type: "int", default: 150, settable: true, help: "Render DPI for page images and Excel rendered views" },
758
780
  { key: "imageFormat", flag: "--image-format", type: "enum", default: "png", settable: true, enumValues: ["png", "jpg"], help: "Rendered image format (embedded images keep their native extension)" },
759
- { key: "maxCellsPerSheet", flag: "--max-cells-per-sheet", type: "int", default: 5e4, settable: true, help: "rows x cols budget per worksheet" },
760
781
  { key: "maxOutputBytes", flag: "--max-output-bytes", type: "int", default: 2e7, settable: true, help: "Child stdout cap in bytes" },
761
782
  { key: "outlineMaxEntries", flag: "--outline-max-entries", type: "int", default: 40, settable: true, help: "Heading outline / TOC / sheet inventory cap in the handle" }
762
783
  ];
@@ -877,6 +898,7 @@ var VENV_DIR_NAME = "doc-to-md-venv-v2";
877
898
  var LEGACY_VENV_DIR_NAME = "pymupdf-venv";
878
899
  var STDERR_CAP = 1e6;
879
900
  var OUTPUT_MAX_BYTES = 2e7;
901
+ var EXCEL_PDF_FILTER = 'pdf:calc_pdf_Export:{"SinglePageSheets":{"type":"boolean","value":"true"}}';
880
902
  function withArgs(cfg) {
881
903
  return ["--with", `pymupdf4llm==${cfg.pymupdfVersion}`, "--with", `openpyxl==${PACKAGE_PINS.openpyxl}`, "--with", `xlrd==${PACKAGE_PINS.xlrd}`, "--with", `pillow==${PACKAGE_PINS.pillow}`];
882
904
  }
@@ -892,7 +914,7 @@ function uvChildArgs(cfg, script, mode) {
892
914
  function pythonChildArgs(script, mode) {
893
915
  return [script, mode];
894
916
  }
895
- function soffArgs(src, profileDir, outDir) {
917
+ function soffArgs(src, profileDir, outDir, filter = "pdf") {
896
918
  return [
897
919
  "--headless",
898
920
  "--invisible",
@@ -905,7 +927,7 @@ function soffArgs(src, profileDir, outDir) {
905
927
  "--quickstart=no",
906
928
  `-env:UserInstallation=${pathToFileURL(profileDir).href}`,
907
929
  "--convert-to",
908
- "pdf",
930
+ filter,
909
931
  "--outdir",
910
932
  outDir,
911
933
  src
@@ -1162,37 +1184,42 @@ function getBackend(cfg, deps, signal) {
1162
1184
  }
1163
1185
  return backendPromise;
1164
1186
  }
1165
- async function convertOffice(sofficeTimeoutMs, src, signal, run = runCapped) {
1187
+ async function tryConvertOffice(sofficeTimeoutMs, src, signal, run = runCapped, filter = "pdf") {
1166
1188
  const profileDir = mkdtempSync(join3(tmpdir3(), "pi-doc-soffice-prof-"));
1167
1189
  const outDir = mkdtempSync(join3(tmpdir3(), "pi-doc-soffice-out-"));
1168
1190
  const cleanup = () => {
1169
- for (const d of [profileDir, outDir]) {
1170
- try {
1171
- rmSync2(d, { recursive: true, force: true });
1172
- } catch {
1173
- }
1191
+ for (const d of [profileDir, outDir]) try {
1192
+ rmSync2(d, { recursive: true, force: true });
1193
+ } catch {
1174
1194
  }
1175
1195
  };
1176
1196
  try {
1177
1197
  const env = { ...process.env, SAL_USE_VCLPLUGIN: "svp", OOO_DISABLE_RECOVERY: "1", SAL_NO_MOUSEGRABS: "1" };
1178
- const r = await run("soffice", soffArgs(src, profileDir, outDir), { timeoutMs: sofficeTimeoutMs, capBytes: OUTPUT_MAX_BYTES, env, signal });
1198
+ const r = await run("soffice", soffArgs(src, profileDir, outDir, filter), { timeoutMs: sofficeTimeoutMs, capBytes: OUTPUT_MAX_BYTES, env, signal });
1179
1199
  if (signal?.aborted) throw new Error("aborted");
1180
- if (r.code === null && !r.timedOut) {
1181
- throw new Error("LibreOffice (soffice) is required to convert .docx/.pptx but was not found on PATH. Install LibreOffice or convert the file to PDF first.");
1182
- }
1183
- if (r.timedOut || r.code !== 0) throw new Error(`soffice failed (code=${r.code} timedOut=${r.timedOut}): ${r.stderr.slice(0, 500)}`);
1184
- const base = basename(src).replace(/\.[^.]+$/, "");
1185
- const pdfPath = join3(outDir, `${base}.pdf`);
1200
+ const fail = (kind) => {
1201
+ cleanup();
1202
+ return { ok: false, kind, code: r.code, timedOut: r.timedOut, stderr: r.stderr.slice(0, 500) };
1203
+ };
1204
+ if (r.code === null && !r.timedOut) return fail("missing");
1205
+ if (r.timedOut) return fail("timeout");
1206
+ if (r.code !== 0) return fail("exit");
1207
+ const pdfPath = join3(outDir, `${basename(src).replace(/\.[^.]+$/, "")}.pdf`);
1186
1208
  const st = statSync2(pdfPath, { throwIfNoEntry: false });
1187
- if (!st || !st.isFile() || st.size === 0) {
1188
- throw new Error("LibreOffice (soffice) ran but produced no usable PDF for this file. Ensure LibreOffice can open the document, or convert it to PDF manually first.");
1189
- }
1190
- return { pdfPath, cleanup };
1209
+ if (!st || !st.isFile() || st.size === 0) return fail("no-pdf");
1210
+ return { ok: true, pdfPath, cleanup };
1191
1211
  } catch (e) {
1192
1212
  cleanup();
1193
1213
  throw e;
1194
1214
  }
1195
1215
  }
1216
+ async function convertOffice(sofficeTimeoutMs, src, signal, run = runCapped) {
1217
+ const r = await tryConvertOffice(sofficeTimeoutMs, src, signal, run);
1218
+ if (r.ok) return r;
1219
+ if (r.kind === "missing") throw new Error("LibreOffice (soffice) is required to convert .docx/.pptx but was not found on PATH. Install LibreOffice or convert the file to PDF first.");
1220
+ if (r.kind === "no-pdf") throw new Error("LibreOffice (soffice) ran but produced no usable PDF for this file. Ensure LibreOffice can open the document, or convert it to PDF manually first.");
1221
+ throw new Error(`soffice failed (code=${r.code} timedOut=${r.timedOut}): ${r.stderr}`);
1222
+ }
1196
1223
  var DEGRADED_TEXT = "PyMuPDF text extraction - layout/tables not preserved";
1197
1224
  var DEGRADED_UNPDF = "unpdf text extraction - structure not preserved";
1198
1225
  var EXCEL_REMEDY = "Remedy: install uv, or pip install openpyxl xlrd pillow";
@@ -1239,8 +1266,18 @@ async function runTierReal(mode, childOptions, _b, signal, timeoutMs, backend) {
1239
1266
  function detailSuffix(r) {
1240
1267
  return r.detail ? ` (${r.detail})` : "";
1241
1268
  }
1269
+ function reconcileRenderMarkers(md, renderPages, fmt, sourceMap, reason) {
1270
+ for (const idx of renderPages) {
1271
+ const file = `s${idx}.${fmt}`;
1272
+ const ok = sourceMap.has(file);
1273
+ md = md.replace(`<!--rv:${idx}-->`, ok ? `Rendered view: ![Rendered view of sheet ${idx}](${file})` : `Rendered view: unavailable (${reason(idx)})`);
1274
+ md = md.replace(`<!--rvs:${idx}-->`, ok ? "yes" : "no");
1275
+ }
1276
+ if (/<!--rvs?:\d+-->/.test(md)) throw new Error("internal: unresolved render marker");
1277
+ return md;
1278
+ }
1242
1279
  async function convertDocument(o, signal, seams) {
1243
- const s = { backend: (c) => getBackend(c, void 0, signal), runTier: runTierReal, ...seams };
1280
+ const s = { backend: (c) => getBackend(c, void 0, signal), runTier: runTierReal, office: tryConvertOffice, ...seams };
1244
1281
  const inputPath = resolve2(o.path);
1245
1282
  const st = statSync2(inputPath, { throwIfNoEntry: false });
1246
1283
  if (!st || !st.isFile()) throw new Error(`Not a readable file: ${o.path}`);
@@ -1258,19 +1295,46 @@ async function convertDocument(o, signal, seams) {
1258
1295
  office = await convertOffice(o.sofficeTimeoutMs, inputPath, signal);
1259
1296
  pdfPath = office.pdfPath;
1260
1297
  }
1261
- const base = { path: pdfPath, pages: o.pages, stagingDir: b.stagingDir, imageDpi: o.imageDpi, imageFormat: o.imageFormat, maxCellsPerSheet: o.maxCellsPerSheet, maxOutputBytes: o.maxOutputBytes, pymupdfVersion: o.pymupdfVersion };
1298
+ const base = { path: pdfPath, pages: o.pages, stagingDir: b.stagingDir, sheetsStagingDir: b.sheetsStagingDir, imageDpi: o.imageDpi, imageFormat: o.imageFormat, maxOutputBytes: o.maxOutputBytes, pymupdfVersion: o.pymupdfVersion };
1262
1299
  let tier, engine, json, degraded = null, fallbackReason = null;
1300
+ let notes = [];
1263
1301
  if (isExcel) {
1264
1302
  const r = await s.runTier("xlsx", base, b, signal, o.excelTimeoutMs, backend);
1265
1303
  if (!r.ok) {
1266
1304
  if ("userError" in r) throw new Error(r.userError);
1267
- const remedy = r.reason.startsWith("timeout after") || r.reason === "output exceeded maxOutputBytes" ? ". Remedy: raise excelTimeoutMs or lower maxCellsPerSheet" : "";
1305
+ const remedy = r.reason.startsWith("timeout after") || r.reason === "output exceeded maxOutputBytes" ? ". Remedy: raise excelTimeoutMs" : "";
1268
1306
  throw new Error(`Excel conversion failed: ${r.reason}${detailSuffix(r)}${remedy}`);
1269
1307
  }
1270
1308
  publishSheetImages(b);
1309
+ publishSheetCsvs(b);
1271
1310
  tier = "excel";
1272
1311
  engine = type === "xls" ? "xlrd" : "openpyxl";
1273
1312
  json = r.json;
1313
+ notes = [...json.notes ?? []];
1314
+ const renderPages = json.renderPages ?? [];
1315
+ let skip = null;
1316
+ const perSheet = /* @__PURE__ */ new Map();
1317
+ if (renderPages.length) {
1318
+ const off = await s.office(o.sofficeTimeoutMs, inputPath, signal, runCapped, EXCEL_PDF_FILTER);
1319
+ if (!off.ok) skip = off.kind === "missing" ? "LibreOffice not found" : off.kind === "timeout" ? `soffice failed: timeout after ${o.sofficeTimeoutMs}ms` : off.kind === "exit" ? `soffice failed: exit ${off.code}` : "soffice produced no PDF";
1320
+ else {
1321
+ try {
1322
+ const rp = await s.runTier("render-pages", { path: off.pdfPath, sheetIndices: renderPages, expectedPages: json.sheetCount, imageDpi: o.imageDpi, imageFormat: o.imageFormat, stagingDir: b.stagingDir, maxOutputBytes: o.maxOutputBytes, pymupdfVersion: o.pymupdfVersion }, b, signal, o.fallbackTimeoutMs, backend);
1323
+ if (!rp.ok) skip = `render failed: ${"userError" in rp ? rp.userError : rp.reason}`;
1324
+ else if (rp.json.ok === false) skip = rp.json.reason ?? "render failed";
1325
+ else {
1326
+ publishSheetImages(b);
1327
+ for (const f of rp.json.failed ?? []) perSheet.set(f.idx, f.reason);
1328
+ for (const d of rp.json.rendered ?? []) if (d.dpi < o.imageDpi) notes.push(`Rendered view s${d.idx}: rendered at ${d.dpi} dpi`);
1329
+ }
1330
+ } finally {
1331
+ off.cleanup();
1332
+ }
1333
+ }
1334
+ if (skip) notes.push(`Rendered views skipped: ${skip}`);
1335
+ else if (perSheet.size) notes.push(`Rendered views: ${perSheet.size} of ${renderPages.length} unavailable`);
1336
+ }
1337
+ json = { ...json, markdown: reconcileRenderMarkers(json.markdown ?? "", renderPages, o.imageFormat, b.sourceMap, (idx) => perSheet.get(idx) ?? skip ?? "render failed") };
1274
1338
  } else if (backend.kind === "none") {
1275
1339
  const r = await s.runTier("pdf-text", base, b, signal, o.primaryTimeoutMs, backend);
1276
1340
  if (!r.ok) throw new Error("userError" in r ? r.userError : `Conversion failed: unpdf ${r.reason}${detailSuffix(r)}`);
@@ -1299,20 +1363,21 @@ async function convertDocument(o, signal, seams) {
1299
1363
  fallbackReason = `primary ${p.reason}`;
1300
1364
  }
1301
1365
  }
1302
- let body = rewriteImageLinks(json.markdown ?? "", b.sourceMap);
1303
- validateImageLinks(body, b.manifest);
1366
+ if (!isExcel) notes = json.notes ?? [];
1367
+ const body = rewriteLinks(json.markdown ?? "", b.sourceMap);
1368
+ validateImageLinks(body, b.manifest, b.csvManifest);
1304
1369
  const head = [];
1305
1370
  if (degraded) head.push(`Degraded: ${degraded}`);
1306
1371
  if (fallbackReason) head.push(`Fallback-Reason: ${fallbackReason}`);
1307
1372
  if (json.failedPages?.length) head.push(`Failed pages: ${json.failedPages.map((f) => `${f.page} (${f.error})`).join("; ")}`);
1308
1373
  if (json.emptyPages?.length) head.push(`Empty pages: ${json.emptyPages.join(", ")}`);
1309
- for (const n of json.notes ?? []) head.push(`Notes: ${n}`);
1374
+ for (const n of notes) head.push(`Notes: ${n}`);
1310
1375
  const markdown = (head.length ? `${head.join("\n")}
1311
1376
 
1312
1377
  ` : "") + body;
1313
1378
  commitBundle(b, markdown);
1314
1379
  const outline = scanOutline(markdown, o.outlineMaxEntries);
1315
- const details = { path: inputPath, backend: backend.kind, pymupdfVersion: o.pymupdfVersion, inputType: type, file: b.mdPath, outputDir: b.root, savedTo: b.mdPath, imagesDir: b.imagesDir, type, engine, tier, pageCount: json.pageCount ?? null, pages: o.pages, imageCount: b.manifest.size, bytes: Buffer.byteLength(markdown, "utf8"), lines: markdown.split("\n").length, degraded, fallbackReason, failedPages: (json.failedPages ?? []).map((f) => f.page), emptyPages: json.emptyPages ?? [], notes: json.notes ?? [], outline: outline.entries, outlineTotal: outline.total };
1380
+ const details = { path: inputPath, backend: backend.kind, pymupdfVersion: o.pymupdfVersion, inputType: type, file: b.mdPath, outputDir: b.root, savedTo: b.mdPath, imagesDir: b.imagesDir, sheetsDir: b.csvManifest.size ? b.sheetsDir : null, type, engine, tier, pageCount: json.pageCount ?? null, pages: o.pages, imageCount: b.manifest.size, bytes: Buffer.byteLength(markdown, "utf8"), lines: markdown.split("\n").length, degraded, fallbackReason, failedPages: (json.failedPages ?? []).map((f) => f.page), emptyPages: json.emptyPages ?? [], notes, outline: outline.entries, outlineTotal: outline.total };
1316
1381
  return { output: formatHandle(details), details };
1317
1382
  } catch (e) {
1318
1383
  abortBundle(b);
@@ -1322,7 +1387,7 @@ async function convertDocument(o, signal, seams) {
1322
1387
  }
1323
1388
  }
1324
1389
  async function inspectDocument(o, signal, seams) {
1325
- const s = { backend: (c) => getBackend(c, void 0, signal), runTier: runTierReal, ...seams };
1390
+ const s = { backend: (c) => getBackend(c, void 0, signal), runTier: runTierReal, office: tryConvertOffice, ...seams };
1326
1391
  const inputPath = resolve2(o.path);
1327
1392
  const st = statSync2(inputPath, { throwIfNoEntry: false });
1328
1393
  if (!st || !st.isFile()) throw new Error(`Not a readable file: ${o.path}`);
@@ -1340,7 +1405,7 @@ async function inspectDocument(o, signal, seams) {
1340
1405
  const r = await s.runTier("info", { path, maxOutputBytes: o.maxOutputBytes, pymupdfVersion: o.pymupdfVersion }, { stagingDir: "" }, signal, isExcel ? o.excelTimeoutMs : o.fallbackTimeoutMs, backend);
1341
1406
  if (!r.ok) {
1342
1407
  if ("userError" in r) throw new Error(r.userError);
1343
- if (isExcel && (r.reason.startsWith("timeout after") || r.reason === "output exceeded maxOutputBytes")) throw new Error(`Excel inspection failed: ${r.reason}. Remedy: raise excelTimeoutMs or lower maxCellsPerSheet${detailSuffix(r)}`);
1408
+ if (isExcel && (r.reason.startsWith("timeout after") || r.reason === "output exceeded maxOutputBytes")) throw new Error(`Excel inspection failed: ${r.reason}. Remedy: raise excelTimeoutMs${detailSuffix(r)}`);
1344
1409
  throw new Error(`Inspection failed: ${r.reason}${detailSuffix(r)}`);
1345
1410
  }
1346
1411
  const toc = (r.json.toc ?? []).map(([level, title, page]) => ({ level, title, page }));
@@ -1397,7 +1462,7 @@ function readCliSettings(cwd, env, warn) {
1397
1462
  if (!existsSync3(file)) continue;
1398
1463
  let raw;
1399
1464
  try {
1400
- raw = JSON.parse(readFileSync2(file, "utf8")).quiver?.docToMd;
1465
+ raw = JSON.parse(readFileSync(file, "utf8")).quiver?.docToMd;
1401
1466
  } catch {
1402
1467
  warn(`pi-quiver: ${file} is not valid JSON; ignored.`);
1403
1468
  continue;
@@ -35,7 +35,7 @@ export default function docToMdExtension(pi: ExtensionAPI) {
35
35
  name: "doc_to_md",
36
36
  label: "Convert doc to Markdown bundle",
37
37
  description:
38
- "Convert a local PDF/DOCX/PPTX/XLSX/XLS to a Markdown bundle on disk and return a handle (Saved-To, Images-Dir, Page-Count, Outline, diagnostics) - the Markdown itself is never inlined; read the Saved-To file (offset/limit) for content. `info: true` returns page count, metadata and TOC (or the sheet inventory) without converting - use it to pick `pages`. `pages` selects inclusive 1-based pages (PDF/DOCX/PPTX); every page ends with `--- end of page.page_number=N ---`. Images are always extracted into images/ with relative links. Primary engine pymupdf4llm, fallback PyMuPDF text (degraded, marked), pure-JS unpdf only when no Python backend exists. Excel yields per-worksheet matrices with formulas, cached values, merged/hidden disclosure. DOCX/PPTX need LibreOffice (soffice). Use `outputDir` for a durable bundle; without it the bundle lands in a per-call temp dir. Input must be a local file path (use fetch first for URLs).",
38
+ "Convert a local PDF/DOCX/PPTX/XLSX/XLS to a Markdown bundle on disk and return a handle (Saved-To, Images-Dir, Page-Count, Outline, diagnostics) - the Markdown itself is never inlined; read the Saved-To file (offset/limit) for content. `info: true` returns page count, metadata and TOC (or the sheet inventory) without converting - use it to pick `pages`. `pages` selects inclusive 1-based pages (PDF/DOCX/PPTX); every page ends with `--- end of page.page_number=N ---`. Images are always extracted into images/ with relative links. Primary engine pymupdf4llm, fallback PyMuPDF text (degraded, marked), pure-JS unpdf only when no Python backend exists. Excel yields a sheet inventory (worksheets + chartsheets), a full CSV per non-empty worksheet under sheets/, a bounded preview with formulas and cached values, merged/hidden disclosure, and rendered chart views when LibreOffice is available. DOCX/PPTX need LibreOffice (soffice). Use `outputDir` for a durable bundle; without it the bundle lands in a per-call temp dir. Input must be a local file path (use fetch first for URLs).",
39
39
  promptSnippet: "Convert a local PDF/DOCX/PPTX/XLSX to a Markdown bundle (handle returned; read Saved-To)",
40
40
  parameters: buildSchema(),
41
41
 
@@ -19,6 +19,7 @@ import {
19
19
  buildPolicyBlock,
20
20
  discoverRepoRoot,
21
21
  resolveSlackConfig,
22
+ resolveCredential,
22
23
  resolveToken,
23
24
  searchMessages,
24
25
  readThread,
@@ -40,7 +41,7 @@ import {
40
41
  import { cacheFilePath, teamIdFor, resolveChannel, refreshCache, resolveMentions, assertSameTeam, type CacheCtx } from "../lib/slack-cache.ts";
41
42
 
42
43
  const IDENTITY = Type.Union([Type.Literal("user"), Type.Literal("bot")], {
43
- description: 'Which token to act as: "user" (a real person, needed for slack_search/slack_thread) or "bot" (an app identity). Determines which token env var is used and whose name shows as the author.',
44
+ description: 'Which token to act as: "user" (a real person, needed for slack_search/slack_thread) or "bot" (an app identity). Determines which credential source is used and whose name shows as the author.',
44
45
  });
45
46
 
46
47
  // Approved recovery fields only - never dump the raw Slack API response (err.data can carry it
@@ -75,12 +76,13 @@ async function resolveCall(
75
76
  signal: AbortSignal | undefined,
76
77
  repoRoot: string,
77
78
  ): Promise<ResolvedCall> {
78
- const token = resolveToken(identity, cfg, process.env, repoRoot);
79
+ const token = await resolveCredential(identity, cfg, process.env, repoRoot);
79
80
  const teamId = await teamIdFor(token, defaultApiCall, signal);
80
81
  const otherIdentity = identity === "user" ? "bot" : "user";
81
82
  const otherTeamId = await (async () => {
83
+ if (otherIdentity === "user" && cfg.userTokenCommand) return undefined;
82
84
  try {
83
- const otherToken = resolveToken(otherIdentity, cfg, process.env, repoRoot);
85
+ const otherToken = await resolveCredential(otherIdentity, cfg, process.env, repoRoot);
84
86
  return await teamIdFor(otherToken, defaultApiCall, signal);
85
87
  } catch {
86
88
  // Other identity's token can't be resolved/authenticated - best-effort
@@ -99,14 +101,14 @@ async function resolveCall(
99
101
 
100
102
  /**
101
103
  * slack_cache_refresh's identity pick: user token when present, else bot (spec "Cache" section).
102
- * Pure and network-free - resolveToken only reads env/.env - so it's unit-testable without a
103
- * transport seam.
104
+ * Pure and network-free - command configuration counts as a user credential without executing it.
104
105
  */
105
106
  export function pickCacheRefreshIdentity(
106
107
  cfg: SlackConfig,
107
108
  env: Record<string, string | undefined>,
108
109
  repoRoot: string,
109
110
  ): "user" | "bot" {
111
+ if (cfg.userTokenCommand) return "user";
110
112
  try {
111
113
  resolveToken("user", cfg, env, repoRoot);
112
114
  return "user";