overleaf-forge 2.9.1 → 2.11.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/overleaf-forge.svg)](https://www.npmjs.com/package/overleaf-forge) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](#license) ![Node](https://img.shields.io/badge/node-%E2%89%A518-43853d.svg)
4
4
 
5
- A [Model Context Protocol](https://modelcontextprotocol.io) server that lets an AI assistant (Claude Code, Claude Desktop, or any MCP client) read, edit, compile, and verify an [Overleaf](https://www.overleaf.com) project over Overleaf's built-in **git** integration. The model edits a local clone with surgical, conflict-safe operations and pushes to Overleaf; nothing depends on scraping the web UI.
5
+ A [Model Context Protocol](https://modelcontextprotocol.io) server that lets an AI assistant (Claude Code, Claude Desktop, or any MCP client) read, edit, compile, and verify an [Overleaf](https://www.overleaf.com) project over Overleaf's built-in **git** integration. The model edits a local clone with surgical, conflict-safe operations and publishes verified batches to Overleaf; nothing depends on scraping the web UI.
6
6
 
7
7
  > **Acknowledgement.** Forked from [mjyoo2/OverleafMCP](https://github.com/mjyoo2/OverleafMCP), the original Overleaf MCP server. This fork adds conflict-safe editing, binary/figure upload, a clean-build PASS/FAIL gate, citation and voice linting, snapshots, a bootstrap for recurring structured documents, per-project contexts, and a hardened git layer (no-shell `execFile`, credential-helper auth, error redaction).
8
8
 
@@ -49,7 +49,7 @@ For an iterative edit, build, and review loop on a large document the tool traff
49
49
 
50
50
  - Node.js ≥ 18 (ESM).
51
51
  - `git` on `PATH`.
52
- - A LaTeX distribution with `latexmk` (only for `compile_file` / `verify_build`; the rest works without it). The default engine is LuaLaTeX; `latexmk` is expected at `/Library/TeX/texbin` (MacTeX) or otherwise on `PATH`.
52
+ - A LaTeX distribution with `latexmk` (only for `verify_build`; the rest works without it). The default engine is LuaLaTeX; `latexmk` is expected at `/Library/TeX/texbin` (MacTeX) or otherwise on `PATH`.
53
53
  - An Overleaf account with **Git integration** enabled (a paid feature at time of writing).
54
54
 
55
55
  ## Install
@@ -180,7 +180,7 @@ That creates `~/.overleaf-mcp/projects.json` from the example and copies the edi
180
180
 
181
181
  ### Where files live
182
182
 
183
- User state (the `projects.json`, per-project `contexts/`, customised `templates/`, and the git clones) lives in the **data home**, resolved as: `$OVERLEAF_MCP_HOME` if set, else the package directory when it already holds a `projects.json` (so an existing local clone keeps working untouched), else `~/.overleaf-mcp`. Bundled, read-only defaults (the templates and the stock writing-guidelines) ship inside the package; a copy you place in the data home overrides the bundled one.
183
+ User state (the `projects.json`, per-project `contexts/`, customised `templates/`, and the git clones) lives in the **data home**, resolved as: `$OVERLEAF_MCP_HOME` if set, else the package directory when it already holds a `projects.json` (so an existing local clone keeps working untouched), else `~/.overleaf-mcp`. Bundled, read-only defaults (the templates and the stock writing-guidelines) ship inside the package; a copy you place in the data home overrides the bundled one. For personal writing rules, use `writing-guidelines.local.md` (gitignored, read first on every `get_context` call); it stays distinct from the bundled file even when the data home is the package directory.
184
184
 
185
185
  ### Getting Overleaf credentials
186
186
 
@@ -224,15 +224,17 @@ A typical editing session, in the model's words:
224
224
  4. *"Add a figure: upload `~/plots/fig1.png` to `figures/fig1.png`."* → `upload_file`
225
225
  5. *"Verify the build."* → `verify_build` → `✓ PASS — 12 pages`
226
226
 
227
- Every write commits and pushes to Overleaf; `verify_build` is the gate before calling the work done.
227
+ Every write commits locally; `verify_build` is the gate before calling the work done, and `publish_changes` pushes the verified commits to Overleaf in one step. Set `settings.autoPush: true` (or pass `push: true`) to push on every write instead.
228
228
 
229
229
  ## Conflict safety
230
230
 
231
231
  Edits never silently overwrite a concurrent Overleaf change.
232
232
 
233
- - **`edit_file`** pulls the latest first (so a non-overlapping browser edit is absorbed), then replaces an exact anchor string. If the anchor is gone, the region changed since you read it, and the edit refuses rather than guessing. On the rare push race, git performs a real 3-way merge and the edit refuses only on a true overlap.
234
- - **`write_file`** (full-file create or overwrite) creates a new file freely. To overwrite an existing file it requires either the `baseSha` you got from `read_file` (a stale one is refused) or an explicit `overwrite: true`. It never merges a wholesale replacement; a push race refuses and resets.
233
+ - **Local by default.** Write tools commit to the local clone and stay off the network unless `push: true` or `settings.autoPush` is set. `publish_changes` pushes the accumulated commits after verification. When any push is refused, only that operation's own commit is rolled back; earlier unpublished commits are never discarded.
234
+ - **`edit_file`** replaces an exact anchor string. If the anchor is gone, the region changed since you read it, and the edit refuses rather than guessing. When pushing, it pulls first (so a non-overlapping browser edit is absorbed), and on the rare push race git performs a real 3-way merge and the edit refuses only on a true overlap.
235
+ - **`write_file`** (full-file create or overwrite) creates a new file freely. To overwrite an existing file it requires either the `baseSha` you got from `read_file` (a stale one is refused) or an explicit `overwrite: true`. It never merges a wholesale replacement; a push race refuses and rolls back its own commit.
235
236
  - **`upload_file`** uses the same gate for binaries, never merges, and confines every destination path inside the project clone.
237
+ - **`sync_project`** fetches and fast-forwards when the clone is only behind. On divergence it changes nothing and reports the local and remote commits; `strategy: "rebase"` replays local work onto Overleaf (aborting cleanly on conflict), and `strategy: "reset"` discards local work only with `confirm` equal to the reported head, after tagging `mcp-backup/*` copies of the old head and any uncommitted edits.
236
238
  - An explicit `projectName` that doesn't resolve is an **error**, never a silent fall-through to a different project, so a write cannot land in the wrong repo.
237
239
 
238
240
  ## Bootstrap for recurring structured documents
@@ -267,7 +269,7 @@ By the built-in convention this parses the name, locates the parent course folde
267
269
  | `read_file` | Read a file. The first line carries the file's `baseSha` (its git blob hash) for conflict-safe writes. |
268
270
  | `list_files` | List files in the project, filtered by extension. |
269
271
  | `get_sections` | List `\section` / `\subsection` / `\subsubsection` entries in a `.tex`. |
270
- | `get_section_content` | Pull a single section's body by title (level-aware: a section keeps its subsections). |
272
+ | `get_section_content` | One section's body by exact, unique title, `\section` down to `\paragraph` (level-aware: a section keeps its subsections). `bundle: true` adds the referenced equation/figure blocks, bibliography entries and assets. |
271
273
  | `search_text` | Grep tracked files. Regex by default; `fixed` for a literal, `ignoreCase`, `extension` to scope. Returns `file:line:match`. |
272
274
  | `status_summary` | File count, main file, section count. |
273
275
 
@@ -275,7 +277,7 @@ By the built-in convention this parses the name, locates the parent course folde
275
277
 
276
278
  | Tool | Purpose |
277
279
  | --- | --- |
278
- | `edit_file` | Anchored `oldString` → `newString` edit + commit + push. Conflict-safe; auto-merges non-overlapping concurrent edits. Preferred for existing files. |
280
+ | `edit_file` | Anchored `oldString` → `newString` edit + commit (push per `push` / `settings.autoPush`). Conflict-safe; auto-merges non-overlapping concurrent edits. Preferred for existing files. |
279
281
  | `write_file` | Create a new file, or overwrite one wholesale. Existing-file overwrite needs `baseSha` or `overwrite: true`. |
280
282
  | `upload_file` | Upload binary file(s) (figures) from a local path. Single or batch (one commit). Byte-exact, path-confined, same conflict gate. |
281
283
 
@@ -283,22 +285,23 @@ By the built-in convention this parses the name, locates the parent course folde
283
285
 
284
286
  | Tool | Purpose |
285
287
  | --- | --- |
286
- | `compile_file` | Compile with `latexmk` from the repo root, so the project `.latexmkrc`, reruns, and bibliography all apply; reports errors, undefined refs, overfull boxes. |
287
- | `verify_build` | Clean-from-scratch compile + PASS/FAIL verdict: PASS only with a PDF and zero errors / undefined references / undefined citations. Reports page count. The done-bar gate. |
288
+ | `verify_build` | Compile with `latexmk` from the repo root (project `.latexmkrc`, reruns and bibliography apply) + PASS/FAIL verdict: PASS only with a PDF and zero errors / undefined references / undefined citations. Reports page count. Default is the clean from-scratch done-bar gate; `clean: false` is a quick incremental rebuild; `lint` adds voice-linter findings to the gate. |
289
+ | `sync_project` | Fetch and reconcile with Overleaf: fast-forward, report divergence, or resolve it with `rebase` / confirmed `reset` (see Conflict safety). |
290
+ | `publish_changes` | Verify the clean local HEAD and push every unpublished commit once. |
288
291
 
289
292
  **Citations, snapshots, voice**
290
293
 
291
294
  | Tool | Purpose |
292
295
  | --- | --- |
293
- | `add_citation` | Append a BibTeX entry to `refs.bib` (refuses a duplicate key) + push. |
296
+ | `add_citation` | Append a BibTeX entry to `refs.bib` (refuses a duplicate key) + commit. |
294
297
  | `cite_lint` | Report undefined (`\cite` with no entry) and unused (entry never cited) citations. Read-only. |
295
298
  | `checkpoint` | Mark a local rollback point (a `mcp-snap/<label>` tag) before a risky edit. |
296
- | `restore` | Roll back to a checkpoint via a forward commit + push (no force, no history rewrite). |
297
- | `voice_lint` | Run a prose linter on a `.tex` (the bundled `examples/voice-lint.mjs` by default; override via `settings.voiceLinter`). Lints the local working copy as-is, never pulls. Read-only, advisory. |
299
+ | `restore` | Roll back to a checkpoint via a forward commit (no force, no history rewrite). |
300
+ | `voice_lint` | Run a prose linter on a `.tex` (the bundled `examples/voice-lint.mjs` by default; override via `settings.voiceLinter`). Lints the local working copy as-is, never pulls. Read-only and advisory; `verify_build` with `lint` makes it gating. |
298
301
 
299
302
  ## How it works
300
303
 
301
- Each project is a normal git clone of its Overleaf repo, kept under `repoDir` (or `localPath`). On every operation the server pulls the latest, performs the read/edit/build locally, and pushes. Git runs through `execFile` with argument arrays (no shell), so file paths, commit messages, and patterns can't inject commands. Authentication uses an inline git credential helper that reads the token from the process environment, so the token is never written into a remote URL, a command line, or an error message; the clone's `origin` stays token-free. `compile_file` and `verify_build` shell out to `latexmk` from the repo root so the project's own `.latexmkrc` governs the build.
304
+ Each project is a normal git clone of its Overleaf repo, kept under `repoDir` (or `localPath`). `sync_project` is the only synchronization command. Read, search, lint, dependency, render and build tools use the existing clone without pulling. This gives a task a stable source snapshot and prevents a read from replacing local edits. Git runs through `execFile` with argument arrays (no shell), so file paths, commit messages, and patterns can't inject commands. Authentication uses an inline git credential helper that reads the token from the process environment, so the token is never written into a remote URL, a command line, or an error message; the clone's `origin` stays token-free.
302
305
 
303
306
  ## Testing & development
304
307
 
@@ -324,3 +327,17 @@ Forked from [mjyoo2/OverleafMCP](https://github.com/mjyoo2/OverleafMCP). The ori
324
327
  ## License
325
328
 
326
329
  MIT. See [LICENSE](LICENSE) if present, or treat this as MIT per the upstream project.
330
+
331
+ ## Efficient local reading and builds
332
+
333
+ - `get_context({projectName, previousVersion})` returns a context version and omits unchanged content. The version covers project identity and the rendered guidance/context.
334
+ - `get_section_content({projectName, filePath, sectionTitle, bundle: true, maxChars})` reads locally without pulling and returns directly referenced equation/figure blocks, matching BibTeX entries and asset paths. It reports missing matches and truncation. Macro-generated references, recursive TeX expansion and parenthesized BibTeX entries require a focused follow-up read.
335
+ - All read, search, lint, dependency, render and build tools are local-only. Use `sync_project` explicitly before reading when needed. It fast-forwards only; divergence is reported, never auto-resolved.
336
+ - Build output is compact by default. `verbose: true` includes a bounded log tail; the full log remains at the returned path.
337
+ - `verify_build` can reuse a successful verification within the running server when project files, recorder inputs, tool binaries, environment and output artifacts are unchanged. `force: true` rebuilds. Missing recorder data, symlinks or executable build configuration conservatively disable reuse. The cache is in-memory and disappears on server restart. Custom build commands can have undeclared external dependencies, so projects with latexmkrc files or detected shell/Lua generation rebuild.
338
+ - `controlled: true` runs `latexmk -norc -no-shell-escape` and permits caching in projects with a `.latexmkrc`, because the rc file cannot run. The mode still rejects shell escape, `minted` and Lua file generation. Add required absolute regular `externalInputs` so their content enters the cache key.
339
+ - `dependency_index` records static TeX includes, figures, labels, references, citations and declared values. `change_report` compares a later index to a retained baseline and names affected sections. Dynamic macros are reported as unresolved rather than guessed.
340
+ - `render_pages` caches a requested PDF page by PDF content hash, page, DPI and renderer version. `usage_stats` exposes aggregate in-process timing, response-size and cache-hit measurements without retaining request or document text.
341
+ - `apply_changes` verifies an SHA-guarded UTF-8 multi-file candidate in an isolated worktree and fast-forwards one local commit only if verification passes. `publish_changes` separately re-verifies its exact clean revision, then pushes it once, carrying every unpublished local commit. Neither operation pulls, resets, retries or silently merges.
342
+
343
+ After updating the server, reconnect the MCP client so it reloads the process and tool schemas. Package publication is separate from installing these local changes.
@@ -0,0 +1,189 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, readFile, realpath, readdir } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ const parseCache = new Map();
6
+ const snapshots = new Map();
7
+ const MAX_FILES = 1000;
8
+ const MAX_RESPONSE = 5000;
9
+ const MAX_SNAPSHOTS = 8;
10
+
11
+ const digest = bytes => createHash('sha256').update(bytes).digest('hex');
12
+ const lineAt = (text, offset) => text.slice(0, offset).split('\n').length;
13
+ const rel = (root, file) => path.relative(root, file).split(path.sep).join('/');
14
+
15
+ function inside(root, candidate) {
16
+ const r = path.resolve(root);
17
+ const c = path.resolve(candidate);
18
+ return c === r || c.startsWith(`${r}${path.sep}`);
19
+ }
20
+
21
+ function withoutComments(source) {
22
+ return source.replace(/(^|[^\\])%[^\n]*/g, '$1');
23
+ }
24
+
25
+ function sectionFor(sections, offset) {
26
+ let result = null;
27
+ for (const section of sections) {
28
+ if (section.offset <= offset) result = section;
29
+ else break;
30
+ }
31
+ return result;
32
+ }
33
+
34
+ function parseFile(file, source, hash) {
35
+ const text = withoutComments(source);
36
+ const sections = [];
37
+ const sectionRe = /\\(part|chapter|section|subsection|subsubsection|paragraph|subparagraph)\*?\{((?:[^{}]|\{[^{}]*\})*)\}/g;
38
+ let match;
39
+ while ((match = sectionRe.exec(text))) sections.push({
40
+ id: `${file}#${match[2]}`,
41
+ title: match[2], type: match[1], line: lineAt(text, match.index), offset: match.index,
42
+ });
43
+ for (let i = 0; i < sections.length; i++) {
44
+ const nextOffset = i + 1 < sections.length ? sections[i + 1].offset : source.length;
45
+ sections[i].endLine = i + 1 < sections.length ? sections[i + 1].line - 1 : source.split('\n').length;
46
+ // Hash the section's own source span. This keeps an unchanged subsection
47
+ // stable when text in a sibling section shifts its line number.
48
+ sections[i].hash = digest(source.slice(sections[i].offset, nextOffset));
49
+ if (i > 0 && sections[i - 1].id === sections[i].id) sections[i].id += `:${i + 1}`;
50
+ }
51
+ const declarations = [];
52
+ const labels = [];
53
+ const refs = [];
54
+ const citations = [];
55
+ const includes = [];
56
+ const graphics = [];
57
+ const values = [];
58
+ if (/\.bib$/i.test(file)) {
59
+ const bibRe = /@\w+\s*\{\s*([^,\s]+)\s*,/g;
60
+ while ((match = bibRe.exec(text))) citations.push({ kind: 'bib', symbol: match[1], line: lineAt(text, match.index), section: null });
61
+ }
62
+ const declarationRe = /\\(?:newcommand|renewcommand|providecommand)\s*\{\\([^}]+)\}(?:\s*\[[^\]]*\])?\s*\{([^{}]*)\}/g;
63
+ while ((match = declarationRe.exec(text))) {
64
+ const symbol = `\\${match[1]}`;
65
+ const declaration = { symbol, value: match[2], line: lineAt(text, match.index), section: sectionFor(sections, match.index)?.id ?? null };
66
+ declarations.push(declaration); values.push(declaration);
67
+ }
68
+ const labelRe = /\\label\s*\{([^}]+)\}/g;
69
+ while ((match = labelRe.exec(text))) labels.push({ symbol: match[1], line: lineAt(text, match.index), section: sectionFor(sections, match.index)?.id ?? null });
70
+ const refRe = /\\(ref|pageref|autoref|cref|Cref)\s*\{([^}]+)\}/g;
71
+ while ((match = refRe.exec(text))) refs.push({ kind: match[1], symbol: match[2], line: lineAt(text, match.index), section: sectionFor(sections, match.index)?.id ?? null, dynamic: /\\|\$|#/.test(match[2]) });
72
+ const citeRe = /\\cite[a-zA-Z*]*\s*(?:\[[^\]]*\]\s*)?\{([^}]+)\}/g;
73
+ while ((match = citeRe.exec(text))) for (const symbol of match[1].split(',').map(x => x.trim()).filter(Boolean)) citations.push({ kind: 'cite', symbol, line: lineAt(text, match.index), section: sectionFor(sections, match.index)?.id ?? null });
74
+ const includeRe = /\\(input|include|includegraphics)\s*(?:\[[^\]]*\])?\s*\{([^}]+)\}/g;
75
+ while ((match = includeRe.exec(text))) {
76
+ const item = { kind: match[1], target: match[2].trim(), line: lineAt(text, match.index), section: sectionFor(sections, match.index)?.id ?? null, dynamic: /\\|\$|#/.test(match[2]) };
77
+ (match[1] === 'includegraphics' ? graphics : includes).push(item);
78
+ }
79
+ return { file, hash, sections, declarations, labels, refs, citations, includes, graphics, values };
80
+ }
81
+
82
+ async function filesUnder(root, notices) {
83
+ const result = [];
84
+ async function walk(dir) {
85
+ let entries;
86
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch (error) { notices.push(`cannot read ${rel(root, dir)}: ${error.message}`); return; }
87
+ for (const entry of entries) {
88
+ const full = path.join(dir, entry.name);
89
+ if (entry.isSymbolicLink()) { notices.push(`skipped symlink ${rel(root, full)}`); continue; }
90
+ if (entry.isDirectory()) { await walk(full); continue; }
91
+ if (entry.isFile() && /\.(?:tex|sty|cls|bib|bbx|cbx|ltx)$/i.test(entry.name)) result.push(full);
92
+ }
93
+ }
94
+ await walk(root);
95
+ return result.sort();
96
+ }
97
+
98
+ function resolveTarget(target, from, root, files) {
99
+ if (!target || /[\\$#]/.test(target)) return null;
100
+ const base = path.resolve(root, path.dirname(from), target);
101
+ const candidates = [base, ...(!path.extname(base) ? ['.tex', '.sty', '.cls', '.bib'].map(ext => `${base}${ext}`) : [])];
102
+ return candidates.find(candidate => files.has(rel(root, candidate))) ? rel(root, candidates.find(candidate => files.has(rel(root, candidate)))) : null;
103
+ }
104
+
105
+ function cap(list, limit, field, truncated) {
106
+ if (list.length <= limit) return list;
107
+ truncated[field] = true;
108
+ return list.slice(0, limit);
109
+ }
110
+
111
+ export async function dependencyIndex(root, { changedFiles = [], changedSymbols = [] } = {}) {
112
+ const rootReal = await realpath(root);
113
+ const notices = [];
114
+ const paths = await filesUnder(rootReal, notices);
115
+ const truncated = {};
116
+ const files = new Map();
117
+ const pathSet = new Set(paths.map(file => rel(rootReal, file)));
118
+ for (const file of paths.slice(0, MAX_FILES)) {
119
+ const relative = rel(rootReal, file);
120
+ const bytes = await readFile(file);
121
+ const hash = digest(bytes);
122
+ const key = `${rootReal}:${relative}`;
123
+ const old = parseCache.get(key);
124
+ const parsed = old?.hash === hash ? old : parseFile(relative, bytes.toString('utf8'), hash);
125
+ parseCache.set(key, parsed);
126
+ files.set(relative, parsed);
127
+ }
128
+ if (paths.length > MAX_FILES) truncated.files = true;
129
+ const labels = new Map();
130
+ const citations = new Map();
131
+ for (const file of files.values()) {
132
+ for (const label of file.labels) labels.set(label.symbol, { file: file.file, section: label.section, line: label.line });
133
+ for (const citation of file.citations) citations.set(citation.symbol, { file: file.file, section: citation.section, line: citation.line });
134
+ }
135
+ const edges = [];
136
+ const unresolved = [];
137
+ for (const file of files.values()) {
138
+ const add = (kind, item, target, symbol = null) => edges.push({ kind, from: { file: file.file, section: item.section, line: item.line }, to: target ? { file: target.file, section: target.section ?? null, line: target.line ?? null, symbol } : { symbol: target ?? symbol, unresolved: true } });
139
+ for (const item of file.refs) { const target = labels.get(item.symbol); add(item.kind, item, target, item.symbol); if (!target) unresolved.push({ kind: item.kind, file: file.file, section: item.section, line: item.line, symbol: item.symbol, dynamic: item.dynamic }); }
140
+ for (const item of file.citations) { const target = citations.get(item.symbol); add('cite', item, target, item.symbol); if (!target) unresolved.push({ kind: 'cite', file: file.file, section: item.section, line: item.line, symbol: item.symbol }); }
141
+ for (const item of [...file.includes, ...file.graphics]) { const targetFile = resolveTarget(item.target, file.file, rootReal, pathSet); add(item.kind, item, targetFile ? { file: targetFile } : item.target); if (!targetFile) unresolved.push({ kind: item.kind, file: file.file, section: item.section, line: item.line, symbol: item.target, dynamic: item.dynamic }); }
142
+ }
143
+ const changed = new Set();
144
+ for (const file of changedFiles) {
145
+ const candidate = path.resolve(rootReal, file);
146
+ if (!inside(rootReal, candidate)) throw new Error(`changed file must be inside root: ${file}`);
147
+ changed.add(rel(rootReal, candidate));
148
+ }
149
+ const affected = edges.filter(edge => changed.has(edge.to.file) || (edge.to.symbol && changedSymbols.includes(edge.to.symbol))).map(edge => edge.from);
150
+ const version = digest(JSON.stringify([...files].map(([file, value]) => [file, value.hash])));
151
+ return { version, files: cap([...files.values()].map(({ file, hash, sections, declarations }) => ({ file, hash, sections, symbols: declarations.map(item => item.symbol), definitions: declarations.slice(0, 200) })), MAX_FILES, 'files', truncated), edges: cap(edges, MAX_RESPONSE, 'edges', truncated), unresolved: cap(unresolved, MAX_RESPONSE, 'unresolved', truncated), affectedSections: cap([...new Map(affected.map(x => [x.file + '#' + x.section + ':' + x.line, x])).values()], MAX_RESPONSE, 'affectedSections', truncated), notices, truncated, truncatedFields: Object.keys(truncated) };
152
+ }
153
+
154
+ export async function changeReport(root, baselineVersion) {
155
+ const index = await dependencyIndex(root);
156
+ const key = await realpath(root);
157
+ const previous = baselineVersion ? snapshots.get(key)?.get(baselineVersion) : null;
158
+ const firstCall = !baselineVersion;
159
+ const baselineFound = firstCall || Boolean(previous);
160
+ const oldFiles = previous?.files ?? [];
161
+ const oldMap = new Map(oldFiles.map(file => [file.file, file]));
162
+ const currentMap = new Map(index.files.map(file => [file.file, file]));
163
+ const allFileNames = new Set([...oldMap.keys(), ...currentMap.keys()]);
164
+ const changedFiles = [...allFileNames].filter(file => oldMap.get(file)?.hash !== currentMap.get(file)?.hash).sort();
165
+ const removedFiles = [...oldMap.keys()].filter(file => !currentMap.has(file)).sort();
166
+ const changedFileSet = new Set(changedFiles);
167
+ const oldEdges = previous?.edges ?? [];
168
+ const affectedEdges = firstCall ? [] : [...index.edges, ...oldEdges].filter(edge => changedFileSet.has(edge.to.file));
169
+ const affectedSections = [...new Map(affectedEdges.map(edge => [edge.from.file + '#' + edge.from.section + ':' + edge.from.line, edge.from])).values()];
170
+ const notices = [...index.notices];
171
+ if (baselineVersion && !previous) notices.push(`baseline version not found: ${baselineVersion}; full refresh required`);
172
+ const changedFileHashes = Object.fromEntries(index.files.filter(file => changedFiles.includes(file.file)).map(file => [file.file, file.hash]));
173
+ const changedSections = [];
174
+ if (!firstCall && previous) {
175
+ for (const file of allFileNames) {
176
+ const before = new Map((oldMap.get(file)?.sections ?? []).map(section => [section.id, section]));
177
+ const after = new Map((currentMap.get(file)?.sections ?? []).map(section => [section.id, section]));
178
+ for (const id of new Set([...before.keys(), ...after.keys()])) {
179
+ if (before.get(id)?.hash !== after.get(id)?.hash) changedSections.push({ file, section: id, beforeHash: before.get(id)?.hash ?? null, hash: after.get(id)?.hash ?? null, removed: !after.has(id) });
180
+ }
181
+ }
182
+ }
183
+ const report = { version: index.version, baselineVersion: baselineVersion ?? null, baselineFound, fullRefreshRequired: Boolean(baselineVersion && !previous), firstCall, changedFiles: firstCall || !previous ? [] : changedFiles, removedFiles: firstCall || !previous ? [] : removedFiles, changedFileHashes, changedSections: cap(changedSections, MAX_RESPONSE, 'changedSections', index.truncated), affectedSections: cap(affectedSections, MAX_RESPONSE, 'affectedSections', index.truncated), affectedRefs: cap(affectedEdges, MAX_RESPONSE, 'affectedRefs', index.truncated), suggestions: firstCall || !previous ? [] : affectedSections.map(section => ({ file: section.file, section: section.section, action: 'review references and recompile' })), unresolved: index.unresolved, notices, truncated: index.truncated, truncatedFields: Object.keys(index.truncated) };
184
+ if (!snapshots.has(key)) snapshots.set(key, new Map());
185
+ snapshots.get(key).set(index.version, index);
186
+ const retained = snapshots.get(key);
187
+ while (retained.size > MAX_SNAPSHOTS) retained.delete(retained.keys().next().value);
188
+ return report;
189
+ }
package/efficiency.js ADDED
@@ -0,0 +1,115 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, readFile, readdir, realpath } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ const hash = value => createHash('sha256').update(value).digest('hex');
6
+ export function versionedContext(key, text, previousVersion) {
7
+ const version = hash(JSON.stringify([key, text]));
8
+ return { version, unchanged: previousVersion === version, text: previousVersion === version ? 'Context unchanged.' : text };
9
+ }
10
+
11
+ // Cache keys include file contents, not mtimes. Hash external recorder inputs too;
12
+ // an edited image, bibliography, package or deleted PDF must invalidate reuse.
13
+ // Controlled mode is safe to cache only when the caller also controls the
14
+ // latexmk invocation. It relaxes the project-config check, while executable
15
+ // TeX features remain ineligible because their dependency closure is opaque.
16
+ export function controlledBuildOptions(options = {}) {
17
+ if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('build options must be an object');
18
+ const controlled = options.controlled === true;
19
+ const externalInputs = options.externalInputs ?? [];
20
+ if (!Array.isArray(externalInputs) || externalInputs.some(input => typeof input !== 'string' || !path.isAbsolute(input))) {
21
+ throw new TypeError('externalInputs must contain absolute paths');
22
+ }
23
+ return { sourcesOnly: options.sourcesOnly === true, controlled, externalInputs: [...new Set(externalInputs)] };
24
+ }
25
+
26
+ export async function buildFingerprint(root, entry, engine, options = {}) {
27
+ const { sourcesOnly, controlled, externalInputs } = controlledBuildOptions(options);
28
+ const records = [];
29
+ const job = path.resolve(root, entry.replace(/\.tex$/, ''));
30
+ const generated = new Set(['aux','log','pdf','fls','fdb_latexmk','synctex.gz','toc','out','bbl','bcf','blg','run.xml','lof','lot'].map(e => `${job}.${e}`));
31
+ let unsafe = false;
32
+ const visit = async dir => {
33
+ for (const d of (await readdir(dir, { withFileTypes: true })).sort((a,b)=>a.name.localeCompare(b.name))) {
34
+ if (d.name === '.git') continue;
35
+ const f = path.join(dir,d.name);
36
+ if (d.isSymbolicLink()) { unsafe = true; continue; }
37
+ if (d.isDirectory()) await visit(f);
38
+ else if (d.isFile() && !(sourcesOnly && generated.has(f))) {
39
+ const bytes = await readFile(f);
40
+ records.push([f,hash(bytes)]);
41
+ // Executable config and shell/Lua-driven generation can read dependencies
42
+ // outside the TeX recorder. Rebuild rather than certify an incomplete key.
43
+ if ((!controlled && /latexmkrc$/.test(f)) || (/\.(tex|sty|cls)$/.test(f) && /\\(?:write18|directlua|inputminted)|\\begin\{minted\}/.test(bytes.toString()))) unsafe = true;
44
+ }
45
+ }
46
+ };
47
+ await visit(root);
48
+ for (const input of externalInputs) {
49
+ const stat = await lstat(input).catch(() => null);
50
+ if (!stat || !stat.isFile() || stat.isSymbolicLink()) throw new Error(`external input must be a regular non-symlink file: ${input}`);
51
+ records.push([input, hash(await readFile(input))]);
52
+ }
53
+ if (unsafe && !sourcesOnly) return null;
54
+ if (!sourcesOnly) {
55
+ const fls = await readFile(`${job}.fls`,'utf8');
56
+ const inputs = new Set(fls.split(/\r?\n/).filter(l=>l.startsWith('INPUT ')).map(l=>path.resolve(root,l.slice(6))));
57
+ if (!inputs.size) return null;
58
+ for (const f of [...inputs].sort()) records.push([await realpath(f),hash(await readFile(f))]);
59
+ for (const tool of ['latexmk',engine]) {
60
+ const f = await realpath(`/Library/TeX/texbin/${tool}`);
61
+ records.push([f,hash(await readFile(f))]);
62
+ }
63
+ }
64
+ return hash(JSON.stringify([engine,entry,process.env,new Date().toDateString(),{ controlled, externalInputs },records]));
65
+ }
66
+
67
+ export function sectionText(content, title) {
68
+ const re = /\\(section|subsection|subsubsection|paragraph)\*?\{((?:[^{}]|\{[^{}]*\})*)\}/g;
69
+ const headings = [...content.matchAll(re)];
70
+ const matches = headings.filter(m=>m[2]===title);
71
+ if (matches.length !== 1) throw new Error(`Expected one section titled "${title}"; found ${matches.length}.`);
72
+ const ranks = {section:1,subsection:2,subsubsection:3,paragraph:4};
73
+ const t = matches[0];
74
+ const next = headings.find(m=>m.index>t.index && ranks[m[1]]<=ranks[t[1]]);
75
+ return content.slice(t.index,next?.index ?? content.length);
76
+ }
77
+
78
+ // Brace scanning preserves nested BibTeX values and equation/figure bodies.
79
+ function entries(text) {
80
+ const out=[]; const re=/@\w+\s*\{\s*([^,\s]+)\s*,/g; let m;
81
+ while ((m=re.exec(text))) {
82
+ let depth=1,i=re.lastIndex;
83
+ for (;i<text.length && depth;i++) { if (text[i-1]==='\\') continue; if(text[i]==='{')depth++; if(text[i]==='}')depth--; }
84
+ out.push({key:m[1],text:text.slice(m.index,i)}); re.lastIndex=i;
85
+ }
86
+ return out;
87
+ }
88
+ export async function sectionBundle(root, filePath, title, maxChars = 16000) {
89
+ const full=path.resolve(root,filePath);
90
+ if (!full.startsWith(path.resolve(root)+path.sep)) throw new Error('Section path must be inside project');
91
+ if (!(await realpath(full)).startsWith(await realpath(root) + path.sep)) throw new Error('Section symlink escapes project');
92
+ const source=await readFile(full,'utf8'); const body=sectionText(source,title);
93
+ const wanted=new Set([...body.matchAll(/\\(?:auto|eq|page)?ref\{([^}]+)\}/g)].map(m=>m[1]));
94
+ const citations=new Set([...body.matchAll(/\\(?:cite\w*|autocite|parencite|textcite)\*?(?:\[[^\]]*\])*\{([^}]+)\}/g)].flatMap(m=>m[1].split(',').map(k=>k.trim())));
95
+ const blocks=[];const bibliography=[];
96
+ const walk=async dir=>{
97
+ for(const d of await readdir(dir,{withFileTypes:true})) {
98
+ if(d.name==='.git'||d.isSymbolicLink())continue;
99
+ const f=path.join(dir,d.name); if(d.isDirectory()){await walk(f);continue;}
100
+ if(!/\.(tex|bib)$/.test(f))continue;
101
+ const t=await readFile(f,'utf8');
102
+ if(f.endsWith('.bib')) { for(const e of entries(t))if(citations.has(e.key))bibliography.push({file:path.relative(root,f),...e}); continue; }
103
+ for(const m of t.matchAll(/\\begin\{(figure\*?|equation\*?|align\*?|gather\*?|multline\*?)\}[\s\S]*?\\end\{\1\}/g)) {
104
+ const labels=[...m[0].matchAll(/\\label\{([^}]+)\}/g)].map(x=>x[1]);
105
+ if(labels.some(l=>wanted.has(l))) blocks.push({file:path.relative(root,f),labels,text:m[0]});
106
+ }
107
+ }
108
+ }; await walk(root);
109
+ const resolved=new Set(blocks.flatMap(b=>b.labels));
110
+ const result={file:filePath,section:title,body,blocks,bibliography,unresolvedLabels:[...wanted].filter(l=>!resolved.has(l)),unresolvedCitations:[...citations].filter(k=>!bibliography.some(e=>e.key===k)),assets:[...body.matchAll(/\\(?:includegraphics(?:\[[^\]]*\])?|input)\{([^}]+)\}/g)].map(m=>m[1]),truncated:false};
111
+ // Never silently truncate: preserve the main section first and identify omissions.
112
+ while(JSON.stringify(result).length>maxChars && (result.blocks.length||result.bibliography.length)) {result.truncated=true;if(result.blocks.length)result.blocks.pop();else result.bibliography.pop();}
113
+ if(JSON.stringify(result).length>maxChars){result.truncated=true;result.body=body.slice(0,Math.max(0,maxChars-JSON.stringify({...result,body:''}).length-100));}
114
+ return result;
115
+ }