@intentic/fileq 1.337.0 → 1.339.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 (63) hide show
  1. package/README.md +29 -3
  2. package/bin/fileq-rg-pre +66 -0
  3. package/dist/app.d.ts.map +1 -1
  4. package/dist/app.js +6 -0
  5. package/dist/app.js.map +1 -1
  6. package/dist/commands/derive.command.d.ts.map +1 -1
  7. package/dist/commands/derive.command.js +10 -0
  8. package/dist/commands/derive.command.js.map +1 -1
  9. package/dist/commands/read.command.js +1 -1
  10. package/dist/commands/read.command.js.map +1 -1
  11. package/dist/commands/ripgrep-config.command.d.ts +3 -0
  12. package/dist/commands/ripgrep-config.command.d.ts.map +1 -0
  13. package/dist/commands/ripgrep-config.command.js +24 -0
  14. package/dist/commands/ripgrep-config.command.js.map +1 -0
  15. package/dist/commands/stamps.command.d.ts +3 -0
  16. package/dist/commands/stamps.command.d.ts.map +1 -0
  17. package/dist/commands/stamps.command.js +13 -0
  18. package/dist/commands/stamps.command.js.map +1 -0
  19. package/dist/lib/check/check-pdf.d.ts.map +1 -1
  20. package/dist/lib/check/check-pdf.js +2 -1
  21. package/dist/lib/check/check-pdf.js.map +1 -1
  22. package/dist/lib/check/package.d.ts.map +1 -1
  23. package/dist/lib/check/package.js +2 -1
  24. package/dist/lib/check/package.js.map +1 -1
  25. package/dist/lib/derive.d.ts.map +1 -1
  26. package/dist/lib/derive.js +39 -8
  27. package/dist/lib/derive.js.map +1 -1
  28. package/dist/lib/derivers/pdf.d.ts +1 -0
  29. package/dist/lib/derivers/pdf.d.ts.map +1 -1
  30. package/dist/lib/derivers/pdf.js +17 -9
  31. package/dist/lib/derivers/pdf.js.map +1 -1
  32. package/dist/lib/formats.d.ts +4 -0
  33. package/dist/lib/formats.d.ts.map +1 -1
  34. package/dist/lib/formats.js +15 -0
  35. package/dist/lib/formats.js.map +1 -1
  36. package/dist/lib/render/render.d.ts.map +1 -1
  37. package/dist/lib/render/render.js +5 -7
  38. package/dist/lib/render/render.js.map +1 -1
  39. package/dist/lib/sidecar.d.ts +14 -0
  40. package/dist/lib/sidecar.d.ts.map +1 -1
  41. package/dist/lib/sidecar.js +40 -4
  42. package/dist/lib/sidecar.js.map +1 -1
  43. package/dist/skill.d.ts.map +1 -1
  44. package/dist/skill.js +23 -2
  45. package/dist/skill.js.map +1 -1
  46. package/package.json +9 -7
  47. package/src/app.ts +6 -0
  48. package/src/cli.integration.test.ts +9 -0
  49. package/src/commands/derive.command.ts +12 -0
  50. package/src/commands/read.command.ts +3 -1
  51. package/src/commands/ripgrep-config.command.ts +31 -0
  52. package/src/commands/stamps.command.ts +14 -0
  53. package/src/lib/check/check-pdf.ts +2 -1
  54. package/src/lib/check/package.ts +2 -1
  55. package/src/lib/derive.test.ts +27 -1
  56. package/src/lib/derive.ts +68 -8
  57. package/src/lib/derivers/pdf.ts +25 -10
  58. package/src/lib/formats.ts +32 -0
  59. package/src/lib/render/render.ts +7 -7
  60. package/src/lib/sidecar.test.ts +32 -1
  61. package/src/lib/sidecar.ts +67 -4
  62. package/src/rg-preprocessor.integration.test.ts +139 -0
  63. package/src/skill.ts +27 -2
@@ -7,6 +7,7 @@
7
7
  // https://github.com/MODSetter/SurfSense; see this package's NOTICE). Changed here: a page is blank only when nothing
8
8
  // at all is drawn on it (a cover image with no text is not), and the fourteen standard PDF fonts, which every reader
9
9
  // carries, are never reported as missing.
10
+ import { errorMessage } from "@intentic/base/errors";
10
11
  import { getDocumentProxy, getResolvedPDFJS } from "unpdf";
11
12
  import { error, plural, warning, type CheckReport, type Finding } from "./finding.js";
12
13
 
@@ -182,7 +183,7 @@ const openPdf = async (bytes: Uint8Array): Promise<PdfDocument | CheckReport> =>
182
183
  const reason =
183
184
  cause instanceof Error && cause.name === "PasswordException"
184
185
  ? "it is password-protected, so nothing past the password can be checked"
185
- : `it is not a readable PDF (${cause instanceof Error ? cause.message : String(cause)})`;
186
+ : `it is not a readable PDF (${errorMessage(cause)})`;
186
187
  return { format: "pdf", findings: [error("unreadable", "", reason)], notes: [] };
187
188
  }
188
189
  };
@@ -1,4 +1,5 @@
1
1
  import { posix } from "node:path";
2
+ import { errorMessage } from "@intentic/base/errors";
2
3
  import { unzipSync, type UnzipFileInfo } from "fflate";
3
4
  import { error, type Finding } from "./finding.js";
4
5
  import { childrenNamed, parseXml, type XmlElement } from "./xml-tree.js";
@@ -61,7 +62,7 @@ export const openPackage = (bytes: Uint8Array): OoxmlPackage => {
61
62
  },
62
63
  });
63
64
  } catch (cause) {
64
- throw new NotAPackage(`not a readable zip package (${cause instanceof Error ? cause.message : String(cause)})`);
65
+ throw new NotAPackage(`not a readable zip package (${errorMessage(cause)})`);
65
66
  }
66
67
  const decoder = new TextDecoder();
67
68
  const texts = new Map<string, string>();
@@ -1,4 +1,7 @@
1
- import { STATE_DIR } from "@intentic/constants";
1
+ import { SHADOWED_DOCUMENT_EXTENSIONS, STATE_DIR } from "@intentic/constants";
2
+ import { documentExtensions } from "./formats.js";
3
+ import { ocrPageCap } from "./derivers/pdf.js";
4
+ import { ocrCapOf } from "./sidecar.js";
2
5
  import { isDeriveIgnored } from "./derive.js";
3
6
 
4
7
  describe("the derive floor (what never gets a shadow, whoever asks)", () => {
@@ -19,3 +22,26 @@ describe("the derive floor (what never gets a shadow, whoever asks)", () => {
19
22
  }
20
23
  });
21
24
  });
25
+
26
+ describe("the OCR page cap (interactive 20, the background keeper more)", () => {
27
+ afterEach(() => {
28
+ delete process.env["FILEQ_OCR_PAGES"];
29
+ });
30
+
31
+ test("FILEQ_OCR_PAGES raises it; nonsense leaves the default", () => {
32
+ expect(ocrPageCap()).toBe(20);
33
+ process.env["FILEQ_OCR_PAGES"] = "500";
34
+ expect(ocrPageCap()).toBe(500);
35
+ process.env["FILEQ_OCR_PAGES"] = "lots";
36
+ expect(ocrPageCap()).toBe(20);
37
+ });
38
+
39
+ test("a rendering's cap is read back off its note; an uncapped one has none", () => {
40
+ expect(ocrCapOf(["scanned: text recognised by OCR", "OCR stops at 20 pages: draw the rest with pdftoppm"])).toBe(20);
41
+ expect(ocrCapOf(["scanned: text recognised by OCR"])).toBeUndefined();
42
+ });
43
+ });
44
+
45
+ test("fileq's document formats are the extensions iq and the preprocessor treat as documents", () => {
46
+ expect(documentExtensions()).toEqual([...SHADOWED_DOCUMENT_EXTENSIONS].toSorted());
47
+ });
package/src/lib/derive.ts CHANGED
@@ -17,13 +17,26 @@ import { mediaDeriver } from "./derivers/media.js";
17
17
  import { odpDeriver } from "./derivers/odp.js";
18
18
  import { odsDeriver } from "./derivers/ods.js";
19
19
  import { odtDeriver } from "./derivers/odt.js";
20
- import { pdfDeriver } from "./derivers/pdf.js";
20
+ import { ocrPageCap, pdfDeriver } from "./derivers/pdf.js";
21
21
  import { pptxDeriver } from "./derivers/pptx.js";
22
22
  import { profileDeriver } from "./derivers/profile.js";
23
23
  import { rtfDeriver } from "./derivers/rtf.js";
24
24
  import { xlsxDeriver } from "./derivers/xlsx.js";
25
25
  import { cacheRendering, readCachedRendering } from "./content-cache.js";
26
- import { isFresh, readSidecar, removeSidecar, sidecarBody, sidecarPathFor, sha256OfFile, writeSidecar } from "./sidecar.js";
26
+ import {
27
+ isFresh,
28
+ ocrCapOf,
29
+ parseSidecarFront,
30
+ readSidecar,
31
+ readSourceStat,
32
+ removeSidecar,
33
+ sidecarBody,
34
+ sidecarPathFor,
35
+ sha256OfFile,
36
+ sourceUnchanged,
37
+ writeSidecar,
38
+ type SourceStat,
39
+ } from "./sidecar.js";
27
40
 
28
41
  // Pipeline `read` and `derive` both run: place the file, recognize it, route it, keep its shadow honest.
29
42
  // One module, so `read` and `derive` cannot disagree about a file's markdown.
@@ -90,6 +103,10 @@ interface Rendered {
90
103
  readonly cachedPath: string | undefined;
91
104
  }
92
105
 
106
+ // A rendering whose OCR stopped short of the page cap this run asks for (a 20-page interactive read, met by the shadow
107
+ // keeper's 500) is not good enough for it; one that went deeper serves a shallower ask as it is.
108
+ const deepEnough = (notes: readonly string[]): boolean => (ocrCapOf(notes) ?? Number.POSITIVE_INFINITY) >= ocrPageCap();
109
+
93
110
  // These bytes as this deriver renders them: from the content cache when rendered before, else derived and stored there.
94
111
  // Throws only what the deriver throws.
95
112
  const renderCached = async (
@@ -101,7 +118,7 @@ const renderCached = async (
101
118
  ): Promise<Rendered> => {
102
119
  const stamp = deriverStamp(deriver);
103
120
  const cached = await readCachedRendering(workspaceRoot, sourceSha, stamp);
104
- if (cached !== undefined) {
121
+ if (cached !== undefined && deepEnough(cached.doc.notes)) {
105
122
  return { kind: "fresh", doc: cached.doc, derivedAt: cached.derivedAt, cachedPath: cached.path };
106
123
  }
107
124
  // Neutralized once here so every consumer gets folded text.
@@ -110,6 +127,40 @@ const renderCached = async (
110
127
  return { kind: "derived", doc, derivedAt, cachedPath: await cacheRendering(workspaceRoot, { sourceSha, deriverStamp: stamp, doc, derivedAt }) };
111
128
  };
112
129
 
130
+ interface ReuseAsk {
131
+ readonly relPath: string;
132
+ readonly sourceSha: string;
133
+ readonly stamp: string;
134
+ readonly sourceStat: SourceStat | undefined;
135
+ }
136
+
137
+ // The sidecar's body when it is fresh for these bytes and deep enough for this run; undefined when it must be derived.
138
+ // Same bytes under a new stat (a touch, a checkout, a sidecar from before stats were kept) get the stat rewritten, so
139
+ // hash-free readers find the shadow current again.
140
+ const reuseFresh = async (workspaceRoot: string, ask: ReuseAsk, now: () => Date): Promise<string | undefined> => {
141
+ const existing = await readSidecar(sidecarPathFor(workspaceRoot, ask.relPath));
142
+ if (existing === undefined || !isFresh(existing, ask.sourceSha, ask.stamp)) {
143
+ return undefined;
144
+ }
145
+ const front = parseSidecarFront(existing);
146
+ if (!deepEnough(front.notes)) {
147
+ return undefined;
148
+ }
149
+ const body = sidecarBody(existing);
150
+ if (!sourceUnchanged(front, ask.sourceStat)) {
151
+ const derivedAt = new Date(front.derivedAt ?? Number.NaN);
152
+ await writeSidecar(workspaceRoot, {
153
+ relPath: ask.relPath,
154
+ sourceSha: ask.sourceSha,
155
+ deriverStamp: ask.stamp,
156
+ sourceStat: ask.sourceStat,
157
+ doc: { markdown: body.endsWith("\n") ? body.slice(0, -1) : body, title: front.title, notes: [...front.notes] },
158
+ derivedAt: Number.isNaN(derivedAt.getTime()) ? now() : derivedAt,
159
+ });
160
+ }
161
+ return body;
162
+ };
163
+
113
164
  /** Converges a workspace file's sidecar with its source: derive when stale, reuse fresh, remove when gone. */
114
165
  export const ensureSidecar = async (workspaceRoot: string, absPath: string, now: () => Date = () => new Date()): Promise<Outcome> => {
115
166
  const relPath = relPathIn(workspaceRoot, absPath);
@@ -142,11 +193,13 @@ export const ensureSidecar = async (workspaceRoot: string, absPath: string, now:
142
193
  }
143
194
  const deriver = DERIVERS[format];
144
195
  const stamp = deriverStamp(deriver);
196
+ // Taken before the hash, so a write racing the read leaves a stat that no longer matches, never one that vouches
197
+ // for bytes it did not see.
198
+ const sourceStat = await readSourceStat(absPath);
145
199
  const sourceSha = await sha256OfFile(absPath);
146
- const existing = await readSidecar(sidecarPath);
147
- if (isFresh(existing, sourceSha, stamp)) {
148
- const body = sidecarBody(existing ?? "");
149
- return { kind: "fresh", relPath, format, sidecarPath, body, tokens: estimateTokens(body) };
200
+ const fresh = await reuseFresh(workspaceRoot, { relPath, sourceSha, stamp, sourceStat }, now);
201
+ if (fresh !== undefined) {
202
+ return { kind: "fresh", relPath, format, sidecarPath, body: fresh, tokens: estimateTokens(fresh) };
150
203
  }
151
204
  // A corrupt file's failure is a loud skip, not fatal.
152
205
  let rendered: Rendered;
@@ -156,7 +209,14 @@ export const ensureSidecar = async (workspaceRoot: string, absPath: string, now:
156
209
  const detail = errorMessage(error).split("\n")[0];
157
210
  return { kind: "skipped", relPath, reason: `derive-failed (${format}): ${detail}` };
158
211
  }
159
- const written = await writeSidecar(workspaceRoot, { relPath, sourceSha, deriverStamp: stamp, doc: rendered.doc, derivedAt: rendered.derivedAt });
212
+ const written = await writeSidecar(workspaceRoot, {
213
+ relPath,
214
+ sourceSha,
215
+ deriverStamp: stamp,
216
+ sourceStat,
217
+ doc: rendered.doc,
218
+ derivedAt: rendered.derivedAt,
219
+ });
160
220
  return rendered.kind === "fresh"
161
221
  ? { kind: "fresh", relPath, format, sidecarPath: written.path, body: written.body, tokens: written.tokens }
162
222
  : { kind: "derived", relPath, format, sidecarPath: written.path, body: written.body, doc: rendered.doc, tokens: written.tokens };
@@ -18,11 +18,24 @@ import { stripRunningFurniture } from "./pdf-furniture.js";
18
18
  // Below this many chars per page, the text layer is furniture, not content: a scan with a vestigial layer.
19
19
  const SCAN_THRESHOLD_CHARS_PER_PAGE = 24;
20
20
 
21
- // OCR costs seconds per page, so a long scan gets its first pages and a note.
22
- const MAX_OCR_PAGES = 20;
21
+ // OCR costs seconds per page, so a long scan gets its first pages and a note. Someone reading now gets 20; the daemon's
22
+ // shadow keeper, working in the background with nobody waiting, asks for more (`fileq derive --ocr-pages 500`, which
23
+ // sets FILEQ_OCR_PAGES for the run).
24
+ const DEFAULT_OCR_PAGES = 20;
25
+ const MAX_OCR_PAGES = 5000;
23
26
  const OCR_DPI = "200";
27
+
28
+ /** How many pages of a scan this run recognises: FILEQ_OCR_PAGES when it is a sane count, else 20. */
29
+ export const ocrPageCap = (): number => {
30
+ const asked = Number(process.env["FILEQ_OCR_PAGES"]);
31
+ return Number.isInteger(asked) && asked > 0 ? Math.min(asked, MAX_OCR_PAGES) : DEFAULT_OCR_PAGES;
32
+ };
33
+
34
+ // The note a capped recognition leaves; ocrCapOf (sidecar.ts) reads the cap back off it.
35
+ const capNote = (cap: number): string => `OCR stops at ${cap} pages: draw the rest with pdftoppm and read them with \`ocr <page.png>...\` if they matter`;
36
+
24
37
  // The reader loads its models once per run and then takes a second or few per page.
25
- const OCR_TIMEOUT_MS = 60_000 + MAX_OCR_PAGES * 15_000;
38
+ const ocrTimeoutMs = (pages: number): number => 60_000 + pages * 15_000;
26
39
 
27
40
  // Asked once per PATH: the answer is a fact of the image, and a stamp is read on every derive.
28
41
  const checked = new Map<string, boolean>();
@@ -46,18 +59,19 @@ export const ocrAvailable = (): boolean => {
46
59
  // could not read leaves its page empty (exit 1) rather than shifting the ones after it. The pages are split on those
47
60
  // form feeds, so the cap stays well past what twenty pages of text come to: a cut middle would shift every page after it.
48
61
  const readPages = async (images: readonly string[]): Promise<string> => {
49
- const result = await runBounded("ocr", images, { timeoutMs: OCR_TIMEOUT_MS, maxOutput: 16 * 1024 * 1024 });
62
+ const result = await runBounded("ocr", images, { timeoutMs: ocrTimeoutMs(images.length), maxOutput: 64 * 1024 * 1024 });
50
63
  if (!result.timedOut && (result.exitCode === 0 || result.exitCode === 1)) {
51
64
  return result.stdout.text;
52
65
  }
53
66
  throw new ProcessError("ocr", images, result);
54
67
  };
55
68
 
56
- const recognisePages = async (absPath: string, totalPages: number): Promise<string[]> => {
69
+ const recognisePages = async (absPath: string, totalPages: number, cap: number): Promise<string[]> => {
57
70
  const dir = await mkdtemp(join(tmpdir(), "fileq-ocr-"));
71
+ const last = Math.min(totalPages, cap);
58
72
  try {
59
- await execBounded("pdftoppm", ["-r", OCR_DPI, "-png", "-f", "1", "-l", String(Math.min(totalPages, MAX_OCR_PAGES)), absPath, join(dir, "page")], {
60
- timeoutMs: 120_000,
73
+ await execBounded("pdftoppm", ["-r", OCR_DPI, "-png", "-f", "1", "-l", String(last), absPath, join(dir, "page")], {
74
+ timeoutMs: 120_000 + last * 2_000,
61
75
  });
62
76
  const images = (await readdir(dir)).filter((name) => name.endsWith(".png")).toSorted();
63
77
  if (images.length === 0) {
@@ -114,12 +128,13 @@ export const pdfDeriver: Deriver = {
114
128
  notes: [`no usable text layer across ${totalPages} page${plural} (a scan?): OCR is not part of this tier`],
115
129
  };
116
130
  }
117
- const recognised = await recognisePages(absPath, totalPages);
131
+ const cap = ocrPageCap();
132
+ const recognised = await recognisePages(absPath, totalPages, cap);
118
133
  const notes = [
119
134
  `scanned: text recognised by OCR (PaddleOCR) on ${recognised.length} of ${totalPages} page${plural} — recognised, not exact; check figures against the page image`,
120
135
  ];
121
- if (totalPages > MAX_OCR_PAGES) {
122
- notes.push(`OCR stops at ${MAX_OCR_PAGES} pages: draw the rest with pdftoppm and read them with \`ocr <page.png>...\` if they matter`);
136
+ if (totalPages > cap) {
137
+ notes.push(capNote(cap));
123
138
  }
124
139
  const { markdown, notes: stripped } = withoutFurniture(recognised);
125
140
  return { markdown, title, notes: [...notes, ...stripped] };
@@ -129,6 +129,38 @@ export const detectFormat = async (absPath: string): Promise<Format | undefined>
129
129
  */
130
130
  export const claimedFormat = async (absPath: string): Promise<Format | undefined> => (await detectFormat(absPath)) ?? EXTENSION_FORMAT[extname(absPath).toLowerCase()];
131
131
 
132
+ // Documents: formats whose text is what a reader searches for, and cheap to derive (no model is asked anything, unless a
133
+ // scanned pdf finds the OCR pack). The daemon's shadow keeper keeps a shadow of every one of these in the workspace, and
134
+ // ripgrep reads them through fileq's preprocessor; pictures, recordings, archives and notebooks are left to be read
135
+ // when someone asks.
136
+ export const DOCUMENT_FORMATS: ReadonlySet<Format> = new Set(["pdf", "docx", "xlsx", "pptx", "odt", "ods", "odp", "rtf", "epub"]);
137
+
138
+ /** Every extension (lower case, with its dot) a document format is recognised by. */
139
+ export const documentExtensions = (): string[] =>
140
+ Object.entries(EXTENSION_FORMAT)
141
+ .filter(([, format]) => DOCUMENT_FORMATS.has(format))
142
+ .map(([extension]) => extension)
143
+ .toSorted();
144
+
145
+ /** The format a path's extension names when it is a document; undefined for anything else. */
146
+ export const documentFormatOf = (path: string): Format | undefined => {
147
+ const format = EXTENSION_FORMAT[extname(path).toLowerCase()];
148
+ return format !== undefined && DOCUMENT_FORMATS.has(format) ? format : undefined;
149
+ };
150
+
151
+ // Both spellings of each extension, since ripgrep's globs are case-sensitive and a scanner names files `SCAN.PDF`.
152
+ const caseGlob = (extension: string): string => `*.{${extension.slice(1)},${extension.slice(1).toUpperCase()}}`;
153
+
154
+ /**
155
+ * A ripgrep config (RIPGREP_CONFIG_PATH) routing every document through `pre`, fileq's preprocessor: one flag per
156
+ * line, as ripgrep reads it. Code and every other file are untouched, since only these globs reach the preprocessor.
157
+ */
158
+ export const ripgrepConfigLines = (pre: string): string[] => [
159
+ "# fileq: ripgrep searches the text of documents, cited by page (`[p. 12] `); see `fileq ripgrep-config --help`.",
160
+ `--pre=${pre}`,
161
+ ...documentExtensions().map((extension) => `--pre-glob=${caseGlob(extension)}`),
162
+ ];
163
+
132
164
  // Formats whose text diff is already the better reading: a textconv over them would replace a line diff with a
133
165
  // rendering of the same text.
134
166
  const TEXT_UNDERNEATH: ReadonlySet<Format> = new Set(["html"]);
@@ -2,6 +2,8 @@ import { createHash } from "node:crypto";
2
2
  import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { basename, join } from "node:path";
5
+ import { parseJsonOr } from "@intentic/base/json";
6
+ import { readTextIfExists } from "@intentic/base/fs";
5
7
  import { toolOutDir } from "@intentic/agent-cli/env";
6
8
  import { STATE_DIR } from "@intentic/constants";
7
9
  import { getDocumentProxy } from "unpdf";
@@ -75,13 +77,11 @@ export const defaultOutDir = (absPath: string, workspaceRoot: string | undefined
75
77
  };
76
78
 
77
79
  const readStamp = async (dir: string): Promise<Stamp | undefined> => {
78
- try {
79
- const parsed: Partial<Stamp> = JSON.parse(await readFile(join(dir, STAMP_FILE), "utf8"));
80
- return parsed.sha256 === undefined || parsed.size === undefined || parsed.total === undefined ? undefined : { sha256: parsed.sha256, size: parsed.size, total: parsed.total };
81
- } catch {
82
- // allow(silent-catch): no earlier render, or a stamp someone edited; either way the answer is to render afresh.
83
- return undefined;
84
- }
80
+ // No earlier render, or a stamp someone edited; either way the answer is to render afresh.
81
+ const text = await readTextIfExists(join(dir, STAMP_FILE));
82
+ // SAFETY: only this module writes the stamp, and each field is checked for presence before the stamp is believed.
83
+ const parsed = text === undefined ? undefined : (parseJsonOr(text, undefined) as Partial<Stamp> | null | undefined);
84
+ return parsed?.sha256 === undefined || parsed.size === undefined || parsed.total === undefined ? undefined : { sha256: parsed.sha256, size: parsed.size, total: parsed.total };
85
85
  };
86
86
 
87
87
  // Removes the images an earlier render of different bytes left, and nothing else in the folder.
@@ -1,4 +1,4 @@
1
- import { isFresh, parseSidecarFront, sidecarBody, sidecarPathFor } from "./sidecar.js";
1
+ import { isFresh, parseSidecarFront, sidecarBody, sidecarPathFor, sourceStatOf, sourceUnchanged } from "./sidecar.js";
2
2
 
3
3
  const SIDECAR = `---
4
4
  source: docs/spec.docx
@@ -24,6 +24,8 @@ describe("sidecar front matter", () => {
24
24
  derivedAt: "2026-08-29T10:00:00.000Z",
25
25
  // JSON-encoded on the way in, so a colon in a title cannot split the line it rides on.
26
26
  title: "Quarterly: the plan",
27
+ sourceSize: undefined,
28
+ sourceMtime: undefined,
27
29
  notes: ["sheet cut", "showing 200 of 4,000 rows"],
28
30
  });
29
31
  });
@@ -39,6 +41,8 @@ describe("sidecar front matter", () => {
39
41
  deriver: undefined,
40
42
  derivedAt: undefined,
41
43
  title: undefined,
44
+ sourceSize: undefined,
45
+ sourceMtime: undefined,
42
46
  notes: [],
43
47
  });
44
48
  expect(sidecarBody("just text")).toBe("just text");
@@ -58,3 +62,30 @@ describe("freshness", () => {
58
62
  test("the shadow of a path is the path, mirrored under the derived tree", () => {
59
63
  expect(sidecarPathFor("/work", "docs/spec.docx")).toBe("/work/.intentic/local/cache/derived/docs/spec.docx.md");
60
64
  });
65
+
66
+ describe("source stat (the hash-free test)", () => {
67
+ const WITH_STAT = SIDECAR.replace("deriver: docx v1\n", "deriver: docx v1\nsource_size: 4096\nsource_mtime: 1791910037.034237706\n");
68
+
69
+ test("spelled as GNU stat -c '%s %.9Y' prints it, nanoseconds zero-padded", () => {
70
+ expect(sourceStatOf({ size: 4096n, mtimeNs: 1_791_910_037_034_237_706n })).toEqual({ size: "4096", mtime: "1791910037.034237706" });
71
+ expect(sourceStatOf({ size: 0n, mtimeNs: 5_000_000_007n })).toEqual({ size: "0", mtime: "5.000000007" });
72
+ });
73
+
74
+ test("a sidecar carrying it matches only the same size and mtime", () => {
75
+ const front = parseSidecarFront(WITH_STAT);
76
+ expect(front.sourceSize).toBe("4096");
77
+ expect(sourceUnchanged(front, { size: "4096", mtime: "1791910037.034237706" })).toBe(true);
78
+ expect(sourceUnchanged(front, { size: "4096", mtime: "1791910038.034237706" })).toBe(false);
79
+ expect(sourceUnchanged(front, { size: "4097", mtime: "1791910037.034237706" })).toBe(false);
80
+ expect(sourceUnchanged(front, undefined)).toBe(false);
81
+ // The stat is a hint beside the hash, never part of freshness itself.
82
+ expect(isFresh(WITH_STAT, "abc123", "docx v1")).toBe(true);
83
+ });
84
+
85
+ test("an older sidecar without it still reads, still counts as fresh by hash, and never as unchanged", () => {
86
+ const front = parseSidecarFront(SIDECAR);
87
+ expect(front.sha256).toBe("abc123");
88
+ expect(isFresh(SIDECAR, "abc123", "docx v1")).toBe(true);
89
+ expect(sourceUnchanged(front, { size: "4096", mtime: "1791910037.034237706" })).toBe(false);
90
+ });
91
+ });
@@ -1,11 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { createReadStream } from "node:fs";
3
- import { readFile, rm, writeFile } from "node:fs/promises";
3
+ import { readFile, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { estimateTokens } from "@intentic/base/format";
6
6
  import { writeFileAtomic } from "@intentic/base/fs";
7
7
  import { neutralizeOutsideText } from "@intentic/base/outside-text";
8
- import { STATE_DIR } from "@intentic/constants";
8
+ import { DERIVED_SHADOWS_DIR } from "@intentic/constants";
9
9
  import type { DerivedDoc } from "./derivers/deriver.js";
10
10
 
11
11
  // One markdown shadow per derivable file, at a predictable mirrored path under .intentic/local/cache/derived (portable,
@@ -14,8 +14,12 @@ import type { DerivedDoc } from "./derivers/deriver.js";
14
14
  // deriver stamp both still match, never by mtime.
15
15
  // Writes (sidecars and content-cache entries alike) go through exactly one function, since either is read back by a
16
16
  // plain `Read` with no untrusted-content wrapper, so neutralization must happen at write time.
17
+ // A sidecar also records the source's size and mtime (`source_size`, `source_mtime`). They are not what freshness
18
+ // means here, and fileq never trusts them over the hash; they let a reader that cannot afford a hash (the ripgrep
19
+ // preprocessor, a POSIX shell script; the daemon's shadow keeper, over thousands of files) tell an untouched source
20
+ // with one `stat`. A sidecar written before they existed simply lacks them, and such a reader takes its slow path.
17
21
 
18
- export const DERIVED_DIR = `${STATE_DIR}/local/cache/derived`;
22
+ export const DERIVED_DIR = DERIVED_SHADOWS_DIR;
19
23
 
20
24
  export const sidecarPathFor = (workspaceRoot: string, relPath: string): string => join(workspaceRoot, DERIVED_DIR, `${relPath}.md`);
21
25
 
@@ -35,11 +39,65 @@ export interface SidecarFront {
35
39
  readonly deriver: string | undefined;
36
40
  readonly derivedAt: string | undefined;
37
41
  readonly title: string | undefined;
42
+ /** The source's byte size and mtime when this was written (sourceStatOf's spelling); absent on older sidecars. */
43
+ readonly sourceSize: string | undefined;
44
+ readonly sourceMtime: string | undefined;
38
45
  /** Every cap and degradation the derivation hit, in the order it wrote them. */
39
46
  readonly notes: readonly string[];
40
47
  }
41
48
 
42
- const EMPTY_FRONT: SidecarFront = { source: undefined, sha256: undefined, deriver: undefined, derivedAt: undefined, title: undefined, notes: [] };
49
+ const EMPTY_FRONT: SidecarFront = {
50
+ source: undefined,
51
+ sha256: undefined,
52
+ deriver: undefined,
53
+ derivedAt: undefined,
54
+ title: undefined,
55
+ sourceSize: undefined,
56
+ sourceMtime: undefined,
57
+ notes: [],
58
+ };
59
+
60
+ /** A source's size and mtime as a sidecar records them: what GNU `stat -c '%s %.9Y'` prints, so a shell can compare. */
61
+ export interface SourceStat {
62
+ readonly size: string;
63
+ readonly mtime: string;
64
+ }
65
+
66
+ export const sourceStatOf = (stats: { readonly size: bigint; readonly mtimeNs: bigint }): SourceStat => {
67
+ const seconds = stats.mtimeNs / 1_000_000_000n;
68
+ const nanos = stats.mtimeNs % 1_000_000_000n;
69
+ return { size: stats.size.toString(), mtime: `${seconds.toString()}.${nanos.toString().padStart(9, "0")}` };
70
+ };
71
+
72
+ /** The file's SourceStat now; undefined when it cannot be stat'd. */
73
+ export const readSourceStat = async (absPath: string): Promise<SourceStat | undefined> => {
74
+ try {
75
+ return sourceStatOf(await stat(absPath, { bigint: true }));
76
+ } catch {
77
+ // allow(silent-catch): no stat means no hash-free answer; every caller then takes the path that hashes.
78
+ return undefined;
79
+ }
80
+ };
81
+
82
+ // pdf.ts's note for a scan whose OCR stopped at a page cap (interactive reads recognise 20 pages, the daemon's
83
+ // background keeper more). Read here, beside the front matter, so a caller that never loads the pdf reader (the
84
+ // daemon) can tell a shadow cut shorter than it wants.
85
+ const CAP_NOTE = /^OCR stops at (\d+) pages/;
86
+
87
+ /** The page cap a rendering's OCR stopped at, off its notes; undefined when it recognised every page (or none). */
88
+ export const ocrCapOf = (notes: readonly string[]): number | undefined => {
89
+ for (const note of notes) {
90
+ const match = CAP_NOTE.exec(note);
91
+ if (match !== null) {
92
+ return Number(match[1]);
93
+ }
94
+ }
95
+ return undefined;
96
+ };
97
+
98
+ /** Whether a sidecar's recorded size and mtime are this source's: the hash-free test, false when either is missing. */
99
+ export const sourceUnchanged = (front: SidecarFront, now: SourceStat | undefined): boolean =>
100
+ now !== undefined && front.sourceSize === now.size && front.sourceMtime === now.mtime;
43
101
 
44
102
  // A line scan rather than a YAML parse, so an edited-by-hand sidecar simply reads as stale instead of throwing.
45
103
  // Title and notes are JSON-encoded on the way in (writeSidecar), so they decode back rather than splitting on a colon.
@@ -67,6 +125,8 @@ export const parseSidecarFront = (content: string): SidecarFront => {
67
125
  deriver: field("deriver"),
68
126
  derivedAt: field("derived_at"),
69
127
  title: decode(field("title")),
128
+ sourceSize: field("source_size"),
129
+ sourceMtime: field("source_mtime"),
70
130
  notes: [...head.matchAll(/^note: (.+)$/gm)].map((match) => decode(match[1]) ?? ""),
71
131
  };
72
132
  };
@@ -103,6 +163,8 @@ export interface DerivedEntry {
103
163
  readonly deriverStamp: string;
104
164
  readonly doc: DerivedDoc;
105
165
  readonly derivedAt: Date;
166
+ /** The source's stat when it was read, for a sidecar only (see SourceStat). */
167
+ readonly sourceStat?: SourceStat | undefined;
106
168
  }
107
169
 
108
170
  export interface WriteSidecarInput extends Omit<DerivedEntry, "source"> {
@@ -123,6 +185,7 @@ export const writeDerived = async (path: string, entry: DerivedEntry): Promise<{
123
185
  "---",
124
186
  ...(entry.source === undefined ? [] : [`source: ${entry.source}`]),
125
187
  `sha256: ${entry.sourceSha}`,
188
+ ...(entry.sourceStat === undefined ? [] : [`source_size: ${entry.sourceStat.size}`, `source_mtime: ${entry.sourceStat.mtime}`]),
126
189
  `deriver: ${entry.deriverStamp}`,
127
190
  `derived_at: ${entry.derivedAt.toISOString()}`,
128
191
  ...(title === undefined ? [] : [`title: ${JSON.stringify(title)}`]),
@@ -0,0 +1,139 @@
1
+ // The ripgrep preprocessor (bin/fileq-rg-pre) against a temp workspace: a fresh shadow is printed with page marks
2
+ // and no fileq run, a stale or pre-stat shadow falls back to `fileq read --plain`, a non-document passes through, and
3
+ // plain `rg` under the generated config cites documents by page while code reads exactly as without it.
4
+ import { spawnSync } from "node:child_process";
5
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { markPages } from "@intentic/base/shadow-text";
10
+ import { captureCli } from "@intentic/agent-cli/testing";
11
+ import { app } from "./app.js";
12
+ import { documentExtensions, ripgrepConfigLines } from "./lib/formats.js";
13
+ import { DERIVED_DIR, sidecarBody } from "./lib/sidecar.js";
14
+ import { pdfPagesBytes } from "./testing.js";
15
+
16
+ const PRE = fileURLToPath(new URL("../bin/fileq-rg-pre", import.meta.url));
17
+ const hasRg = spawnSync("rg", ["--version"]).status === 0;
18
+
19
+ let root: string;
20
+ let bin: string;
21
+ let calls: string;
22
+
23
+ const page = (text: string) => ({ content: `BT /F1 12 Tf 72 700 Td (${text}) Tj ET` });
24
+
25
+ beforeAll(() => {
26
+ root = mkdtempSync(join(tmpdir(), "fileq-rgpre-"));
27
+ bin = mkdtempSync(join(tmpdir(), "fileq-rgpre-bin-"));
28
+ calls = join(bin, "calls");
29
+ // A stand-in fileq on PATH: records that the slow path ran, and answers as `read --plain` would.
30
+ writeFileSync(join(bin, "fileq"), `#!/bin/sh\necho "$@" >> '${calls}'\nprintf '## Page 1\\n\\nslow first\\n\\n## Page 2\\n\\nslow second\\n'\n`);
31
+ chmodSync(join(bin, "fileq"), 0o755);
32
+ process.env["WORKSPACE_ROOT"] = root;
33
+ mkdirSync(join(root, "docs"), { recursive: true });
34
+ writeFileSync(join(root, "docs/terms.pdf"), pdfPagesBytes([page("Introduction to the terms of service here"), page("Each wniosek must be filed in writing today")]));
35
+ });
36
+ afterAll(() => {
37
+ delete process.env["WORKSPACE_ROOT"];
38
+ rmSync(root, { recursive: true, force: true });
39
+ rmSync(bin, { recursive: true, force: true });
40
+ });
41
+
42
+ const pre = (file: string, cwd = root) => {
43
+ rmSync(calls, { force: true });
44
+ const result = spawnSync("sh", [PRE, file], { cwd, encoding: "utf8", env: { PATH: `${bin}:${process.env["PATH"] ?? ""}`, WORKSPACE_ROOT: root } });
45
+ expect(result.status).toBe(0);
46
+ return { out: result.stdout, slow: existsSync(calls) };
47
+ };
48
+
49
+ const shadowOf = (relPath: string): string => join(root, DERIVED_DIR, `${relPath}.md`);
50
+
51
+ describe("fast path: a shadow whose recorded stat is the file's", () => {
52
+ test("prints the body with page marks, without running fileq", async () => {
53
+ expect((await captureCli(app, ["derive", "docs/terms.pdf"])).exitCode).toBe(0);
54
+ const shadow = readFileSync(shadowOf("docs/terms.pdf"), "utf8");
55
+ expect(shadow).toMatch(/^source_size: \d+$/m);
56
+ expect(shadow).toMatch(/^source_mtime: \d+\.\d{9}$/m);
57
+ const { out, slow } = pre("./docs/terms.pdf");
58
+ expect(slow).toBe(false);
59
+ // The shell's marks are exactly the TypeScript ones iq indexes with.
60
+ expect(out).toBe(markPages(sidecarBody(shadow)));
61
+ expect(out).toContain("[p. 2] Each wniosek must be filed in writing today");
62
+ expect(out.split("\n")[0]).toBe("[p. 1] ## Page 1");
63
+ });
64
+
65
+ test("an absolute path and a path from a subdirectory find the same shadow", () => {
66
+ expect(pre(join(root, "docs/terms.pdf")).slow).toBe(false);
67
+ expect(pre("terms.pdf", join(root, "docs")).slow).toBe(false);
68
+ expect(pre("../docs/terms.pdf", join(root, "docs")).slow).toBe(false);
69
+ });
70
+ });
71
+
72
+ describe("slow path: fileq read --plain, marked the same way", () => {
73
+ test("a file touched since its shadow was written", () => {
74
+ const later = new Date(Date.now() + 60_000);
75
+ utimesSync(join(root, "docs/terms.pdf"), later, later);
76
+ const { out, slow } = pre("docs/terms.pdf");
77
+ expect(slow).toBe(true);
78
+ expect(readFileSync(calls, "utf8")).toContain(`read --plain ${join(root, "docs/terms.pdf")}`);
79
+ expect(out).toBe("[p. 1] ## Page 1\n[p. 1] \n[p. 1] slow first\n[p. 1] \n[p. 2] ## Page 2\n[p. 2] \n[p. 2] slow second\n");
80
+ });
81
+
82
+ test("fileq rewrites the stat of an unchanged file, and the next run is fast again", async () => {
83
+ expect((await captureCli(app, ["derive", "docs/terms.pdf"])).out).toContain("fresh docs/terms.pdf");
84
+ expect(pre("docs/terms.pdf").slow).toBe(false);
85
+ });
86
+
87
+ test("a sidecar written before stats were recorded still reads, through the slow path", () => {
88
+ const path = shadowOf("docs/terms.pdf");
89
+ const old = readFileSync(path, "utf8").replaceAll(/^source_(size|mtime): .*\n/gm, "");
90
+ writeFileSync(path, old);
91
+ expect(pre("docs/terms.pdf").slow).toBe(true);
92
+ });
93
+
94
+ test("a document with no shadow at all", () => {
95
+ writeFileSync(join(root, "docs/fresh.pdf"), pdfPagesBytes([page("No shadow yet for this one")]));
96
+ expect(pre("docs/fresh.pdf").slow).toBe(true);
97
+ });
98
+ });
99
+
100
+ test("a file that is not a document passes through untouched", () => {
101
+ writeFileSync(join(root, "notes.ts"), "export const wniosek = 1;\n");
102
+ const { out, slow } = pre("notes.ts");
103
+ expect(out).toBe("export const wniosek = 1;\n");
104
+ expect(slow).toBe(false);
105
+ });
106
+
107
+ test("the script's extension list is fileq's document list", () => {
108
+ const script = readFileSync(PRE, "utf8");
109
+ for (const extension of documentExtensions()) {
110
+ const bracketed = Array.from(extension.slice(1), (char) => `[${char}${char.toUpperCase()}]`).join("");
111
+ expect(script, extension).toContain(`*.${bracketed}`);
112
+ }
113
+ });
114
+
115
+ describe.skipIf(!hasRg)("plain rg under the generated config", () => {
116
+ const rg = (args: string[], config: boolean): string => {
117
+ const configPath = join(bin, "ripgreprc");
118
+ // rg execs `--pre` directly, and git records bin/ scripts without the executable bit (the image's Dockerfile
119
+ // adds it), so a fresh checkout's copy cannot start: an executable copy stands in for the image's.
120
+ const executable = join(bin, "fileq-rg-pre");
121
+ copyFileSync(PRE, executable);
122
+ chmodSync(executable, 0o755);
123
+ writeFileSync(configPath, `${ripgrepConfigLines(executable).join("\n")}\n`);
124
+ // ripgrep reads an empty RIPGREP_CONFIG_PATH as unset.
125
+ const env = { PATH: process.env["PATH"] ?? "", WORKSPACE_ROOT: root, RIPGREP_CONFIG_PATH: config ? configPath : "" };
126
+ return spawnSync("rg", args, { cwd: root, encoding: "utf8", env }).stdout;
127
+ };
128
+
129
+ test("a document hit is cited by path, line and page", async () => {
130
+ await captureCli(app, ["derive", "docs/terms.pdf"]);
131
+ expect(rg(["-n", "wniosek", "docs"], true)).toMatch(/^docs\/terms\.pdf:\d+:\[p\. 2\] Each wniosek must be filed/m);
132
+ });
133
+
134
+ test("code reads exactly as it does without the config", () => {
135
+ expect(rg(["-n", "wniosek", "notes.ts"], true)).toBe(rg(["-n", "wniosek", "notes.ts"], false));
136
+ // Without it, rg sees the file's raw bytes: this fixture's stream happens to be uncompressed, and still no page.
137
+ expect(rg(["-n", "Each wniosek must", "docs"], false)).not.toContain("[p. ");
138
+ });
139
+ });