storymapper 0.1.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.
Files changed (61) hide show
  1. package/ARCHITECTURE.md +334 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +6 -0
  4. package/README.md +239 -0
  5. package/frontend/css/storymap.css +4637 -0
  6. package/frontend/js/adapters.js +423 -0
  7. package/frontend/js/card-animate.js +384 -0
  8. package/frontend/js/core/graph.js +825 -0
  9. package/frontend/js/core.js +3908 -0
  10. package/frontend/js/dialog-ticket-import.js +506 -0
  11. package/frontend/js/dnd.js +322 -0
  12. package/frontend/js/filter.js +215 -0
  13. package/frontend/js/main.js +2499 -0
  14. package/frontend/js/project-io.js +109 -0
  15. package/frontend/js/query-autocomplete.js +196 -0
  16. package/frontend/js/query-engine.js +339 -0
  17. package/frontend/js/query.js +280 -0
  18. package/frontend/js/renderer-card.js +639 -0
  19. package/frontend/js/renderer-dependencies.js +974 -0
  20. package/frontend/js/renderer-kanban.js +505 -0
  21. package/frontend/js/renderer-storymap.js +2530 -0
  22. package/frontend/js/renderer-ticket-editor.js +455 -0
  23. package/frontend/js/renderer-ticket-modal.js +758 -0
  24. package/frontend/js/save-pipeline.js +170 -0
  25. package/frontend/js/smartbar-autocomplete.js +162 -0
  26. package/frontend/js/store.js +197 -0
  27. package/frontend/js/ticket-editor-boot.js +24 -0
  28. package/frontend/js/ticket-form.js +2095 -0
  29. package/frontend/js/ticket-import.js +477 -0
  30. package/frontend/js/ui-shell.js +441 -0
  31. package/frontend/js/view-process-steps.js +233 -0
  32. package/frontend/js/view-requirements.js +361 -0
  33. package/frontend/js/view-settings.js +1864 -0
  34. package/frontend/js/view-table.js +659 -0
  35. package/frontend/js/wheel-pan.js +65 -0
  36. package/frontend/storymap.html +87 -0
  37. package/frontend/ticket-editor.html +29 -0
  38. package/package.json +76 -0
  39. package/server/bus.js +16 -0
  40. package/server/core/graph.js +10 -0
  41. package/server/core.js +10 -0
  42. package/server/identity.js +134 -0
  43. package/server/index.js +283 -0
  44. package/server/ingest.js +212 -0
  45. package/server/mcp.js +2510 -0
  46. package/server/server.js +1599 -0
  47. package/server/slice.js +103 -0
  48. package/server/storage.js +571 -0
  49. package/server/validation.js +225 -0
  50. package/shared/core/graph.js +825 -0
  51. package/shared/core.js +3908 -0
  52. package/shared/project-io.js +109 -0
  53. package/shared/query-autocomplete.js +196 -0
  54. package/shared/query-engine.js +339 -0
  55. package/shared/query.js +280 -0
  56. package/shared/ticket-import.js +477 -0
  57. package/skill/SKILL.md +458 -0
  58. package/skill/reference/anatomy.md +196 -0
  59. package/skill/reference/spec-evolution.md +52 -0
  60. package/skill/reference/tools.md +156 -0
  61. package/skill/reference/workflows.md +203 -0
@@ -0,0 +1,283 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * storymap CLI. Two subcommands:
6
+ * storymap server [--port=N] [--data-dir=PATH] [--host=HOST]
7
+ * storymap mcp [--data-dir=PATH]
8
+ *
9
+ * Both honor SIGINT / SIGTERM / SIGHUP for graceful shutdown.
10
+ * MCP mode also exits on stdin EOF (Ctrl-D), matching cmapper.
11
+ */
12
+
13
+ const path = require("path");
14
+ const fs = require("fs");
15
+
16
+ // --- SM-215: same-data-dir server guard --------------------------------------
17
+ // Two `storymap server` processes on one data dir would split-brain the WS
18
+ // sync and collide revision ids (the minter is process-local). A PID lock
19
+ // file refuses the second server. SERVER-MODE ONLY — the concurrent
20
+ // server+mcp combo on one data dir is a documented workflow (E20.E).
21
+ const SERVER_LOCK = {
22
+ FILENAME: "storymap-server.lock",
23
+ EXIT_CODE: 3
24
+ };
25
+
26
+ function pidAlive(pid) {
27
+ try { process.kill(pid, 0); return true; }
28
+ catch (e) { return e && e.code === "EPERM"; } // EPERM = alive, not ours
29
+ }
30
+
31
+ /**
32
+ * Acquire the server lock for dataDir. Returns a release() function.
33
+ * A live PID in an existing lock refuses (exit SERVER_LOCK.EXIT_CODE) unless
34
+ * `force` is set; a stale lock (dead PID) is replaced silently.
35
+ */
36
+ function acquireServerLock(dataDir, force) {
37
+ fs.mkdirSync(dataDir, { recursive: true });
38
+ const lockPath = path.join(dataDir, SERVER_LOCK.FILENAME);
39
+ if (fs.existsSync(lockPath) && !force) {
40
+ const pid = parseInt(fs.readFileSync(lockPath, "utf8"), 10);
41
+ if (pid && pid !== process.pid && pidAlive(pid)) {
42
+ process.stderr.write(
43
+ `[storymap] another 'storymap server' (pid ${pid}) is already running on this data-dir ` +
44
+ `(lock: ${lockPath}).\n[storymap] One server per data-dir — MCP may share. ` +
45
+ `Use --force to take over a stale environment.\n`);
46
+ process.exit(SERVER_LOCK.EXIT_CODE);
47
+ }
48
+ }
49
+ fs.writeFileSync(lockPath, String(process.pid));
50
+ return function release() {
51
+ try {
52
+ // Only remove our own lock — a --force takeover may have replaced it.
53
+ const cur = parseInt(fs.readFileSync(lockPath, "utf8"), 10);
54
+ if (cur === process.pid) fs.unlinkSync(lockPath);
55
+ } catch (_) { /* already gone */ }
56
+ };
57
+ }
58
+
59
+ function parseArgs(argv) {
60
+ const positional = [];
61
+ const flags = {};
62
+ for (const a of argv) {
63
+ if (a.startsWith("--")) {
64
+ const eq = a.indexOf("=");
65
+ if (eq < 0) flags[a.slice(2)] = true;
66
+ else flags[a.slice(2, eq)] = a.slice(eq + 1);
67
+ } else positional.push(a);
68
+ }
69
+ return { positional, flags };
70
+ }
71
+
72
+ function resolveDataDir(flag) {
73
+ if (typeof flag === "string" && flag.length > 0) return path.resolve(flag);
74
+ return path.resolve(process.cwd(), ".storymap-data");
75
+ }
76
+
77
+ // --- Non-loopback bind guard (security) --------------------------------------
78
+ // Storymapper has NO authentication layer: anyone who can reach the port has
79
+ // full read/write on every project. Binding to a non-loopback interface
80
+ // therefore exposes all data to the network. The server refuses such a bind
81
+ // unless the operator explicitly opts in with --allow-remote, and warns loudly
82
+ // when they do. Note `0.0.0.0` / `::` bind ALL interfaces (the most exposed)
83
+ // and are deliberately NOT treated as loopback.
84
+ const REMOTE_BIND = { EXIT_CODE: 4 };
85
+
86
+ function isLoopbackHost(host) {
87
+ if (!host) return true; // the default bind is localhost
88
+ const h = String(host).trim().toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
89
+ if (h === "localhost" || h === "::1") return true;
90
+ // the whole 127.0.0.0/8 loopback block
91
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
92
+ return false;
93
+ }
94
+
95
+ async function main() {
96
+ const { positional, flags } = parseArgs(process.argv.slice(2));
97
+ const cmd = positional[0] || "help";
98
+
99
+ if (cmd === "help" || cmd === "--help" || cmd === "-h") {
100
+ console.log(`Usage:
101
+ storymap server [--port=N] [--data-dir=PATH] [--host=HOST] [--allow-remote] [--force]
102
+ storymap mcp [--data-dir=PATH]
103
+ storymap tool <name> ['<json-args>'] [--data-dir=PATH]
104
+
105
+ 'storymap tool' is a pass-through: it runs ONE MCP tool over the same tool core
106
+ the MCP server uses and prints the result JSON to stdout. Arguments come from a
107
+ positional JSON string, --json=<json>, or piped stdin. Exit: 0 ok, 1 tool
108
+ isError, 2 unknown tool / bad args.
109
+
110
+ The server binds to loopback (localhost) only. Binding to a non-loopback host
111
+ exposes all projects with NO authentication and is refused unless you pass
112
+ --allow-remote (put your own auth proxy in front).
113
+
114
+ One 'storymap server' per data-dir (PID lock; --force takes over a stale
115
+ lock). 'storymap mcp' may always share the data-dir with a running server.
116
+ `);
117
+ return;
118
+ }
119
+
120
+ if (cmd === "server") {
121
+ const { startServer } = require("./server.js");
122
+ const port = flags.port ? Number(flags.port) : 8770;
123
+ const host = flags.host || "localhost";
124
+ const allowRemote = flags["allow-remote"] === true || flags["allow-remote"] === "true";
125
+ // Security: refuse a network-exposed bind unless explicitly opted in.
126
+ if (!isLoopbackHost(host) && !allowRemote) {
127
+ process.stderr.write(
128
+ `[storymap] refusing to bind to non-loopback host '${host}'.\n` +
129
+ `[storymap] Storymapper has NO authentication — a non-loopback bind exposes\n` +
130
+ `[storymap] every project to anyone who can reach the port. If you really intend\n` +
131
+ `[storymap] this (e.g. behind your own auth proxy), re-run with --allow-remote.\n`);
132
+ process.exit(REMOTE_BIND.EXIT_CODE);
133
+ }
134
+ const dataDir = resolveDataDir(flags["data-dir"]);
135
+ // SM-215: refuse a second server on the same data-dir (MCP may share).
136
+ // Accept bare `--force` and `--force=true` (parseArgs yields true|"true").
137
+ const releaseLock = acquireServerLock(dataDir, flags.force === true || flags.force === "true");
138
+ const handle = await startServer({ port, host, dataDir });
139
+ const addr = handle.httpServer.address();
140
+ // Attach signal/shutdown handlers BEFORE announcing "listening". A Ctrl-C
141
+ // (or a test's SIGINT) in the window between "listening" and handler-install
142
+ // would otherwise hit Node's default SIGINT handler, terminating the process
143
+ // without running releaseLock() — leaving a stale lock behind.
144
+ installShutdown("storymap", async () => {
145
+ await handle.shutdown();
146
+ releaseLock();
147
+ }, { allowStdin: true });
148
+ // "listening" is the magic substring tests grep for.
149
+ process.stderr.write(`[storymap] listening on http://${host}:${addr.port}/ (data: ${dataDir})\n`);
150
+ if (!isLoopbackHost(host)) {
151
+ process.stderr.write(
152
+ `\n[storymap] ============================================================\n` +
153
+ `[storymap] WARNING: bound to non-loopback host '${host}' with --allow-remote.\n` +
154
+ `[storymap] There is NO authentication — every project is readable and\n` +
155
+ `[storymap] writable by anyone who can reach this port. Put your own\n` +
156
+ `[storymap] authentication in front of it.\n` +
157
+ `[storymap] ============================================================\n\n`);
158
+ }
159
+ return;
160
+ }
161
+
162
+ if (cmd === "mcp") {
163
+ const Storage = require("./storage.js");
164
+ const { runStdio } = require("./mcp.js");
165
+ const dataDir = resolveDataDir(flags["data-dir"]);
166
+ const storage = new Storage(dataDir);
167
+ await storage.init();
168
+ const { transport } = await runStdio(storage);
169
+ process.stderr.write(`[storymap-mcp] listening on stdio (data: ${dataDir})\n`);
170
+ // stdin EOF (no TTY in mcp-mode — Claude Desktop closes stdin to signal exit).
171
+ process.stdin.on("end", () => {
172
+ try { transport.close(); } catch (_) { /* ignore */ }
173
+ try { storage.close(); } catch (_) { /* ignore */ }
174
+ process.exit(0);
175
+ });
176
+ installShutdown("storymap-mcp", async () => {
177
+ try { await transport.close(); } catch (_) { /* ignore */ }
178
+ try { storage.close(); } catch (_) { /* ignore */ }
179
+ }, { allowStdin: false }); // mcp-mode has its own stdin lifecycle
180
+ return;
181
+ }
182
+
183
+ if (cmd === "tool") {
184
+ // Pass-through CLI: run ONE MCP tool over the same native tool core the MCP
185
+ // server uses (SM-311). `storymap tool <name> ['<json>']` or pipe JSON stdin.
186
+ const Storage = require("./storage.js");
187
+ const { buildServer } = require("./mcp.js");
188
+ const { runCli } = require("../packages/native-mcp");
189
+ const dataDir = resolveDataDir(flags["data-dir"]);
190
+ const storage = new Storage(dataDir);
191
+ await storage.init();
192
+ const httpUrl = (typeof flags["http-url"] === "string")
193
+ ? flags["http-url"] : (process.env.STORYMAP_HTTP_URL || "http://localhost:8770");
194
+ // buildServer registers all ~70 tools on the native registry (same as MCP).
195
+ const registry = buildServer(storage, { httpUrl });
196
+ // JSON args come from `tool <name> '<json>'` (positional) or --json=<json>;
197
+ // if neither is given and stdin is piped, read the JSON from stdin.
198
+ const jsonArg = (typeof flags.json === "string") ? flags.json : positional[2];
199
+ let stdinData;
200
+ if (jsonArg === undefined && !process.stdin.isTTY) stdinData = await readAllStdin();
201
+ const code = await runCli(registry, { name: positional[1], json: jsonArg }, { stdinData });
202
+ try { storage.close(); } catch (_) { /* ignore */ }
203
+ process.exit(code);
204
+ }
205
+
206
+ console.error("unknown command: " + cmd);
207
+ process.exit(2);
208
+ }
209
+
210
+ /**
211
+ * Graceful-shutdown wiring — portiert von cmapper/server/index.js.
212
+ *
213
+ * - Schreibt jeden Schritt nach stderr (SIGINT empfangen / Force-Timeout).
214
+ * - Force-Timer mit unref() — sonst hält der Timer den Event-Loop offen
215
+ * auch nach erfolgreichem Shutdown.
216
+ * - Zweites Signal während Shutdown = sofortiger Exit (Code 130).
217
+ * - allowStdin=true: Ctrl-D auf TTY = graceful shutdown (für server-mode).
218
+ * allowStdin=false: stdin-Lebenszyklus gehört dem Caller (mcp-mode).
219
+ */
220
+ // Read all of stdin as a UTF-8 string — used by `tool` mode when the JSON args
221
+ // are piped rather than passed as an argument.
222
+ function readAllStdin() {
223
+ return new Promise((resolve) => {
224
+ let data = "";
225
+ process.stdin.setEncoding("utf8");
226
+ process.stdin.on("data", (c) => { data += c; });
227
+ process.stdin.on("end", () => resolve(data));
228
+ process.stdin.resume();
229
+ });
230
+ }
231
+
232
+ function installShutdown(label, shutdownFn, opts) {
233
+ opts = opts || {};
234
+ let shuttingDown = false;
235
+
236
+ async function stop(signal, exitCode) {
237
+ if (shuttingDown) {
238
+ process.stderr.write(`[${label}] second ${signal} — forcing exit\n`);
239
+ process.exit(130);
240
+ }
241
+ shuttingDown = true;
242
+ process.stderr.write(`[${label}] ${signal} received — shutting down\n`);
243
+ const forceTimer = setTimeout(() => {
244
+ process.stderr.write(`[${label}] shutdown timed out after 2s — forcing exit\n`);
245
+ process.exit(1);
246
+ }, 2000);
247
+ forceTimer.unref();
248
+ try { await shutdownFn(); }
249
+ catch (e) { process.stderr.write(`[${label}] shutdown error: ${e.message}\n`); }
250
+ process.exit(exitCode || 0);
251
+ }
252
+
253
+ process.on("SIGINT", () => stop("SIGINT"));
254
+ process.on("SIGTERM", () => stop("SIGTERM"));
255
+ process.on("SIGHUP", () => stop("SIGHUP"));
256
+
257
+ // SM-213: a stray rejection/exception must not vanish or kill the process
258
+ // without cleanup — log the cause, run the SAME graceful path, exit 1.
259
+ process.on("uncaughtException", (err) => {
260
+ process.stderr.write(`[${label}] uncaughtException: ${(err && err.stack) || err}\n`);
261
+ stop("uncaughtException", 1);
262
+ });
263
+ process.on("unhandledRejection", (reason) => {
264
+ process.stderr.write(`[${label}] unhandledRejection: ${(reason && reason.stack) || reason}\n`);
265
+ stop("unhandledRejection", 1);
266
+ });
267
+
268
+ if (opts.allowStdin && process.stdin.isTTY) {
269
+ process.stdin.on("end", () => stop("stdin EOF"));
270
+ process.stdin.resume();
271
+ }
272
+ }
273
+
274
+ // SM-213: requiring this file must NOT auto-start the CLI — tests import
275
+ // installShutdown to drive the process-level error handlers in a subprocess.
276
+ if (require.main === module) {
277
+ main().catch((err) => {
278
+ console.error(err && err.stack || err);
279
+ process.exit(1);
280
+ });
281
+ }
282
+
283
+ module.exports = { installShutdown, parseArgs, resolveDataDir, acquireServerLock, SERVER_LOCK, isLoopbackHost, REMOTE_BIND };
@@ -0,0 +1,212 @@
1
+ "use strict";
2
+
3
+ // SM-199 R-3: convert a source document (a PRD attachment) to Markdown,
4
+ // server-side, with a deliberately tiny dependency surface:
5
+ // - pdf2json (zero transitive deps, pure JS, NO OCR, NO runtime CDN) for PDF
6
+ // - fflate (zero transitive deps) to unzip .docx, then a minimal hand-
7
+ // rolled OOXML→Markdown walk
8
+ // - .md / .txt are read natively (zero deps)
9
+ //
10
+ // This replaces the originally-scoped `officeparser`, which pulled tesseract.js
11
+ // (OCR, runtime-CDN traineddata fetch) + a pdf.js worker as HARD deps — 40
12
+ // packages for a feature that never needs OCR. The two libs here are loaded
13
+ // lazily (require inside the binary paths) so md/txt ingestion has zero cost.
14
+ //
15
+ // The hard parsing logic lives in PURE helpers (`docxXmlToMarkdown`,
16
+ // `pdfDataToText`) that are unit-tested directly; the binary unzip/parse
17
+ // wrappers around the third-party libs are thin.
18
+
19
+ const FORMAT = { MD: "md", TXT: "txt", DOCX: "docx", PDF: "pdf" };
20
+
21
+ // Map a {filename, mimeType} to a supported format, or null if unsupported.
22
+ function detectFormat(meta) {
23
+ meta = meta || {};
24
+ const name = String(meta.filename || "").toLowerCase();
25
+ const mime = String(meta.mimeType || "").toLowerCase();
26
+ if (name.endsWith(".md") || name.endsWith(".markdown") || mime === "text/markdown") return FORMAT.MD;
27
+ if (name.endsWith(".txt") || mime === "text/plain") return FORMAT.TXT;
28
+ if (name.endsWith(".docx") || mime.indexOf("officedocument.wordprocessingml") >= 0) return FORMAT.DOCX;
29
+ if (name.endsWith(".pdf") || mime === "application/pdf") return FORMAT.PDF;
30
+ return null;
31
+ }
32
+
33
+ // Decode the five predefined XML entities + numeric refs. `&amp;` is decoded
34
+ // LAST so an encoded "&amp;lt;" doesn't become "<".
35
+ function decodeXmlEntities(s) {
36
+ return String(s)
37
+ .replace(/&lt;/g, "<")
38
+ .replace(/&gt;/g, ">")
39
+ .replace(/&quot;/g, '"')
40
+ .replace(/&apos;/g, "'")
41
+ // numeric refs: decimal + hex, full Unicode (astral-safe via fromCodePoint)
42
+ .replace(/&#(\d+);/g, (_, d) => _fromCp(parseInt(d, 10)))
43
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => _fromCp(parseInt(h, 16)))
44
+ .replace(/&amp;/g, "&");
45
+ }
46
+ function _fromCp(cp) {
47
+ return (Number.isFinite(cp) && cp >= 0 && cp <= 0x10ffff) ? String.fromCodePoint(cp) : "";
48
+ }
49
+
50
+ // docx (Office Open XML) word/document.xml → Markdown. Each <w:p> paragraph
51
+ // becomes: a heading (#-level) when its <w:pStyle> is a Heading/Title style, a
52
+ // "- " list item when it carries <w:numPr>, else a plain paragraph. Inline text
53
+ // is the concatenation of the paragraph's <w:t> runs. Deliberately minimal —
54
+ // it covers the PRD subset (headings + paragraphs + lists), not full OOXML.
55
+ function docxXmlToMarkdown(xml) {
56
+ if (typeof xml !== "string" || !xml) return "";
57
+ const bodyMatch = xml.match(/<w:body[^>]*>([\s\S]*?)<\/w:body>/);
58
+ const body = bodyMatch ? bodyMatch[1] : xml;
59
+ const out = [];
60
+ const paraRe = /<w:p\b[^>]*>([\s\S]*?)<\/w:p>/g;
61
+ let m;
62
+ while ((m = paraRe.exec(body)) !== null) {
63
+ const p = m[1];
64
+ // Walk inline content IN ORDER so tabs/line-breaks between runs keep their
65
+ // separators — concatenating only <w:t> would mash "Col1<tab>Col2" into
66
+ // "Col1Col2". <w:tab/> → tab, <w:br/> → newline.
67
+ let text = "";
68
+ const inlineRe = /<w:t\b[^>]*>([\s\S]*?)<\/w:t>|<w:tab\b[^>]*\/?>|<w:br\b[^>]*\/?>/g;
69
+ let tm;
70
+ while ((tm = inlineRe.exec(p)) !== null) {
71
+ if (tm[1] !== undefined) text += decodeXmlEntities(tm[1]);
72
+ else if (tm[0].indexOf("<w:tab") === 0) text += "\t";
73
+ else text += "\n";
74
+ }
75
+ text = text.replace(/[ \t]+\n/g, "\n").trim();
76
+ if (!text) continue;
77
+
78
+ const styleMatch = p.match(/<w:pStyle\b[^>]*w:val="([^"]*)"/i);
79
+ const style = styleMatch ? styleMatch[1] : "";
80
+ if (/^title$/i.test(style)) { out.push("# " + text); continue; }
81
+ // Heading style: must START with the heading token + a level digit, with a
82
+ // word boundary after — so a custom style merely CONTAINING the word (e.g.
83
+ // "Heading1Char", "Heading2Caption", "NotAHeading4") is NOT promoted.
84
+ const hMatch = style.match(/^(?:heading|überschrift|berschrift)\s*([1-6])\b/i);
85
+ if (hMatch) {
86
+ const lvl = Math.min(6, Math.max(1, parseInt(hMatch[1], 10)));
87
+ out.push("#".repeat(lvl) + " " + text);
88
+ continue;
89
+ }
90
+ if (/<w:numPr\b/.test(p)) { out.push("- " + text); continue; }
91
+ out.push(text);
92
+ }
93
+ return out.join("\n\n");
94
+ }
95
+
96
+ // pdf2json "pdfParser_dataReady" payload → plain text. Text runs (Page.Texts[])
97
+ // are grouped into lines by rounded y, ordered left→right by x, and pages are
98
+ // separated by a blank line. Each run's URL-encoded text (R[].T) is decoded.
99
+ // Pure — testable with a synthetic payload, no real PDF needed.
100
+ // Insert a space between two same-line runs whose x-gap (next.x − prev right
101
+ // edge) exceeds this (pdf2json page units). Keeps mid-word run splits glued
102
+ // (tiny gap) while separating genuinely-spaced tokens that carry no explicit
103
+ // whitespace.
104
+ const PDF_GAP_SPACE = 0.3;
105
+
106
+ function pdfDataToText(data) {
107
+ if (!data || !Array.isArray(data.Pages)) return "";
108
+ const pages = [];
109
+ for (const page of data.Pages) {
110
+ const texts = Array.isArray(page.Texts) ? page.Texts : [];
111
+ const lines = new Map(); // yBucket -> [{x, w, t}]
112
+ for (const t of texts) {
113
+ const y = Math.round((t.y || 0) * 2) / 2; // 0.5-unit buckets
114
+ const runs = Array.isArray(t.R) ? t.R : [];
115
+ let s = "";
116
+ for (const r of runs) {
117
+ const raw = r && r.T != null ? r.T : "";
118
+ try { s += decodeURIComponent(raw); } catch (_e) { s += raw; }
119
+ }
120
+ if (!lines.has(y)) lines.set(y, []);
121
+ lines.get(y).push({ x: t.x || 0, w: t.w || 0, t: s });
122
+ }
123
+ const ys = Array.from(lines.keys()).sort((a, b) => a - b);
124
+ const pageLines = ys.map(y => {
125
+ const runs = lines.get(y).sort((a, b) => a.x - b.x);
126
+ let s = "";
127
+ for (let k = 0; k < runs.length; k++) {
128
+ if (k > 0) {
129
+ const prev = runs[k - 1];
130
+ const gap = runs[k].x - (prev.x + prev.w);
131
+ // Don't double-space when one side already carries whitespace.
132
+ if (gap > PDF_GAP_SPACE && !/\s$/.test(s) && !/^\s/.test(runs[k].t)) s += " ";
133
+ }
134
+ s += runs[k].t;
135
+ }
136
+ return s.replace(/\s+$/, "");
137
+ });
138
+ pages.push(pageLines.join("\n").trim());
139
+ }
140
+ return pages.filter(Boolean).join("\n\n");
141
+ }
142
+
143
+ // Thin binary wrappers around the lazy-loaded libs.
144
+
145
+ function docxBufferToMarkdown(buffer) {
146
+ const { unzipSync, strFromU8 } = require("fflate");
147
+ const u8 = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
148
+ let files;
149
+ try {
150
+ files = unzipSync(u8);
151
+ } catch (e) {
152
+ // A truncated/corrupt upload is a client problem, not a 500 — surface it
153
+ // with the same structured shape as the unsupported-format path.
154
+ throw Object.assign(new Error("corrupt or unreadable .docx archive"),
155
+ { statusCode: 422, kind: "INGEST_CORRUPT" });
156
+ }
157
+ const doc = files["word/document.xml"];
158
+ if (!doc) return "";
159
+ return docxXmlToMarkdown(strFromU8(doc));
160
+ }
161
+
162
+ function pdfBufferToText(buffer) {
163
+ const PDFParser = require("pdf2json");
164
+ return new Promise((resolve, reject) => {
165
+ const parser = new PDFParser(null, false);
166
+ parser.on("pdfParser_dataError", err => reject((err && err.parserError) || err));
167
+ parser.on("pdfParser_dataReady", data => {
168
+ try { resolve(pdfDataToText(data)); } catch (e) { reject(e); }
169
+ });
170
+ parser.parseBuffer(Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer));
171
+ });
172
+ }
173
+
174
+ // Top-level: a buffer + {filename, mimeType} → { format, markdown }.
175
+ // Throws { statusCode: 415, kind: "INGEST_FORMAT" } for an unsupported format.
176
+ async function ingestToMarkdown(buffer, meta) {
177
+ const fmt = detectFormat(meta);
178
+ const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || []);
179
+ let markdown;
180
+ switch (fmt) {
181
+ case FORMAT.MD:
182
+ case FORMAT.TXT:
183
+ markdown = buf.toString("utf8");
184
+ break;
185
+ case FORMAT.DOCX:
186
+ markdown = docxBufferToMarkdown(buf);
187
+ break;
188
+ case FORMAT.PDF:
189
+ markdown = await pdfBufferToText(buf);
190
+ break;
191
+ default:
192
+ throw Object.assign(
193
+ new Error("unsupported ingest format: " + ((meta && (meta.filename || meta.mimeType)) || "?")),
194
+ { statusCode: 415, kind: "INGEST_FORMAT" }
195
+ );
196
+ }
197
+ // Normalize to LF so downstream slicing + reconciliation see consistent
198
+ // offsets regardless of the source's line endings.
199
+ markdown = String(markdown).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
200
+ return { format: fmt, markdown: markdown };
201
+ }
202
+
203
+ module.exports = {
204
+ FORMAT,
205
+ detectFormat,
206
+ decodeXmlEntities,
207
+ docxXmlToMarkdown,
208
+ pdfDataToText,
209
+ docxBufferToMarkdown,
210
+ pdfBufferToText,
211
+ ingestToMarkdown
212
+ };