@intentic/fileq 1.317.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 (184) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/app.d.ts +2 -0
  4. package/dist/app.d.ts.map +1 -0
  5. package/dist/app.js +43 -0
  6. package/dist/app.js.map +1 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +4 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/commands/derive.command.d.ts +5 -0
  12. package/dist/commands/derive.command.d.ts.map +1 -0
  13. package/dist/commands/derive.command.js +53 -0
  14. package/dist/commands/derive.command.js.map +1 -0
  15. package/dist/commands/git-attributes.command.d.ts +3 -0
  16. package/dist/commands/git-attributes.command.d.ts.map +1 -0
  17. package/dist/commands/git-attributes.command.js +12 -0
  18. package/dist/commands/git-attributes.command.js.map +1 -0
  19. package/dist/commands/read.command.d.ts +3 -0
  20. package/dist/commands/read.command.d.ts.map +1 -0
  21. package/dist/commands/read.command.js +126 -0
  22. package/dist/commands/read.command.js.map +1 -0
  23. package/dist/commands/sweep.command.d.ts +3 -0
  24. package/dist/commands/sweep.command.d.ts.map +1 -0
  25. package/dist/commands/sweep.command.js +41 -0
  26. package/dist/commands/sweep.command.js.map +1 -0
  27. package/dist/lib/archives.d.ts +18 -0
  28. package/dist/lib/archives.d.ts.map +1 -0
  29. package/dist/lib/archives.js +133 -0
  30. package/dist/lib/archives.js.map +1 -0
  31. package/dist/lib/derive.d.ts +32 -0
  32. package/dist/lib/derive.d.ts.map +1 -0
  33. package/dist/lib/derive.js +99 -0
  34. package/dist/lib/derive.js.map +1 -0
  35. package/dist/lib/derivers/archive.d.ts +3 -0
  36. package/dist/lib/derivers/archive.d.ts.map +1 -0
  37. package/dist/lib/derivers/archive.js +132 -0
  38. package/dist/lib/derivers/archive.js.map +1 -0
  39. package/dist/lib/derivers/deriver.d.ts +13 -0
  40. package/dist/lib/derivers/deriver.d.ts.map +1 -0
  41. package/dist/lib/derivers/deriver.js +8 -0
  42. package/dist/lib/derivers/deriver.js.map +1 -0
  43. package/dist/lib/derivers/docx.d.ts +6 -0
  44. package/dist/lib/derivers/docx.d.ts.map +1 -0
  45. package/dist/lib/derivers/docx.js +29 -0
  46. package/dist/lib/derivers/docx.js.map +1 -0
  47. package/dist/lib/derivers/epub.d.ts +3 -0
  48. package/dist/lib/derivers/epub.d.ts.map +1 -0
  49. package/dist/lib/derivers/epub.js +102 -0
  50. package/dist/lib/derivers/epub.js.map +1 -0
  51. package/dist/lib/derivers/html.d.ts +3 -0
  52. package/dist/lib/derivers/html.d.ts.map +1 -0
  53. package/dist/lib/derivers/html.js +18 -0
  54. package/dist/lib/derivers/html.js.map +1 -0
  55. package/dist/lib/derivers/image.d.ts +3 -0
  56. package/dist/lib/derivers/image.d.ts.map +1 -0
  57. package/dist/lib/derivers/image.js +42 -0
  58. package/dist/lib/derivers/image.js.map +1 -0
  59. package/dist/lib/derivers/ipynb.d.ts +3 -0
  60. package/dist/lib/derivers/ipynb.d.ts.map +1 -0
  61. package/dist/lib/derivers/ipynb.js +75 -0
  62. package/dist/lib/derivers/ipynb.js.map +1 -0
  63. package/dist/lib/derivers/media.d.ts +3 -0
  64. package/dist/lib/derivers/media.d.ts.map +1 -0
  65. package/dist/lib/derivers/media.js +45 -0
  66. package/dist/lib/derivers/media.js.map +1 -0
  67. package/dist/lib/derivers/odp.d.ts +3 -0
  68. package/dist/lib/derivers/odp.d.ts.map +1 -0
  69. package/dist/lib/derivers/odp.js +41 -0
  70. package/dist/lib/derivers/odp.js.map +1 -0
  71. package/dist/lib/derivers/ods.d.ts +3 -0
  72. package/dist/lib/derivers/ods.d.ts.map +1 -0
  73. package/dist/lib/derivers/ods.js +86 -0
  74. package/dist/lib/derivers/ods.js.map +1 -0
  75. package/dist/lib/derivers/odt.d.ts +4 -0
  76. package/dist/lib/derivers/odt.d.ts.map +1 -0
  77. package/dist/lib/derivers/odt.js +77 -0
  78. package/dist/lib/derivers/odt.js.map +1 -0
  79. package/dist/lib/derivers/pdf-furniture.d.ts +7 -0
  80. package/dist/lib/derivers/pdf-furniture.d.ts.map +1 -0
  81. package/dist/lib/derivers/pdf-furniture.js +76 -0
  82. package/dist/lib/derivers/pdf-furniture.js.map +1 -0
  83. package/dist/lib/derivers/pdf.d.ts +4 -0
  84. package/dist/lib/derivers/pdf.d.ts.map +1 -0
  85. package/dist/lib/derivers/pdf.js +79 -0
  86. package/dist/lib/derivers/pdf.js.map +1 -0
  87. package/dist/lib/derivers/pptx.d.ts +4 -0
  88. package/dist/lib/derivers/pptx.d.ts.map +1 -0
  89. package/dist/lib/derivers/pptx.js +51 -0
  90. package/dist/lib/derivers/pptx.js.map +1 -0
  91. package/dist/lib/derivers/profile.d.ts +3 -0
  92. package/dist/lib/derivers/profile.d.ts.map +1 -0
  93. package/dist/lib/derivers/profile.js +115 -0
  94. package/dist/lib/derivers/profile.js.map +1 -0
  95. package/dist/lib/derivers/rtf.d.ts +4 -0
  96. package/dist/lib/derivers/rtf.d.ts.map +1 -0
  97. package/dist/lib/derivers/rtf.js +187 -0
  98. package/dist/lib/derivers/rtf.js.map +1 -0
  99. package/dist/lib/derivers/xlsx.d.ts +3 -0
  100. package/dist/lib/derivers/xlsx.d.ts.map +1 -0
  101. package/dist/lib/derivers/xlsx.js +68 -0
  102. package/dist/lib/derivers/xlsx.js.map +1 -0
  103. package/dist/lib/env.d.ts +2 -0
  104. package/dist/lib/env.d.ts.map +1 -0
  105. package/dist/lib/env.js +5 -0
  106. package/dist/lib/env.js.map +1 -0
  107. package/dist/lib/flags.d.ts +2 -0
  108. package/dist/lib/flags.d.ts.map +1 -0
  109. package/dist/lib/flags.js +8 -0
  110. package/dist/lib/flags.js.map +1 -0
  111. package/dist/lib/formats.d.ts +6 -0
  112. package/dist/lib/formats.d.ts.map +1 -0
  113. package/dist/lib/formats.js +110 -0
  114. package/dist/lib/formats.js.map +1 -0
  115. package/dist/lib/sidecar.d.ts +30 -0
  116. package/dist/lib/sidecar.d.ts.map +1 -0
  117. package/dist/lib/sidecar.js +108 -0
  118. package/dist/lib/sidecar.js.map +1 -0
  119. package/dist/lib/sweep.d.ts +7 -0
  120. package/dist/lib/sweep.d.ts.map +1 -0
  121. package/dist/lib/sweep.js +56 -0
  122. package/dist/lib/sweep.js.map +1 -0
  123. package/dist/lib/tools.d.ts +2 -0
  124. package/dist/lib/tools.d.ts.map +1 -0
  125. package/dist/lib/tools.js +15 -0
  126. package/dist/lib/tools.js.map +1 -0
  127. package/dist/lib/xml.d.ts +3 -0
  128. package/dist/lib/xml.d.ts.map +1 -0
  129. package/dist/lib/xml.js +16 -0
  130. package/dist/lib/xml.js.map +1 -0
  131. package/dist/skill.d.ts +5 -0
  132. package/dist/skill.d.ts.map +1 -0
  133. package/dist/skill.js +75 -0
  134. package/dist/skill.js.map +1 -0
  135. package/dist/testing.d.ts +25 -0
  136. package/dist/testing.d.ts.map +1 -0
  137. package/dist/testing.js +220 -0
  138. package/dist/testing.js.map +1 -0
  139. package/dist/version.d.ts +2 -0
  140. package/dist/version.d.ts.map +1 -0
  141. package/dist/version.js +3 -0
  142. package/dist/version.js.map +1 -0
  143. package/package.json +89 -0
  144. package/src/app.ts +48 -0
  145. package/src/cli.integration.test.ts +200 -0
  146. package/src/cli.ts +6 -0
  147. package/src/commands/derive.command.ts +59 -0
  148. package/src/commands/git-attributes.command.ts +13 -0
  149. package/src/commands/read.command.ts +158 -0
  150. package/src/commands/sweep.command.ts +48 -0
  151. package/src/derivers.integration.test.ts +415 -0
  152. package/src/lib/archives.test.ts +72 -0
  153. package/src/lib/archives.ts +209 -0
  154. package/src/lib/derive.ts +132 -0
  155. package/src/lib/derivers/archive.ts +168 -0
  156. package/src/lib/derivers/deriver.ts +33 -0
  157. package/src/lib/derivers/docx.test.ts +38 -0
  158. package/src/lib/derivers/docx.ts +44 -0
  159. package/src/lib/derivers/epub.ts +130 -0
  160. package/src/lib/derivers/html.ts +21 -0
  161. package/src/lib/derivers/image.ts +54 -0
  162. package/src/lib/derivers/ipynb.ts +113 -0
  163. package/src/lib/derivers/load-cost.test.ts +44 -0
  164. package/src/lib/derivers/media.ts +51 -0
  165. package/src/lib/derivers/odp.ts +52 -0
  166. package/src/lib/derivers/ods.ts +104 -0
  167. package/src/lib/derivers/odt.ts +101 -0
  168. package/src/lib/derivers/pdf-furniture.test.ts +60 -0
  169. package/src/lib/derivers/pdf-furniture.ts +121 -0
  170. package/src/lib/derivers/pdf.ts +102 -0
  171. package/src/lib/derivers/pptx.ts +61 -0
  172. package/src/lib/derivers/profile.ts +172 -0
  173. package/src/lib/derivers/rtf.ts +233 -0
  174. package/src/lib/derivers/xlsx.ts +82 -0
  175. package/src/lib/env.ts +7 -0
  176. package/src/lib/formats.test.ts +50 -0
  177. package/src/lib/formats.ts +141 -0
  178. package/src/lib/sidecar.test.ts +60 -0
  179. package/src/lib/sidecar.ts +156 -0
  180. package/src/lib/sweep.ts +72 -0
  181. package/src/lib/tools.ts +16 -0
  182. package/src/lib/xml.ts +20 -0
  183. package/src/skill.ts +90 -0
  184. package/src/testing.ts +333 -0
@@ -0,0 +1,209 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { unzipSync } from "fflate";
4
+ import { onPath } from "./tools.js";
5
+
6
+ // Reading an archive's SHAPE without unpacking it: which members are inside and how big each is, never their bytes.
7
+ // Three containers are read in process — zip's central directory, tar's header blocks, gzip's trailer — because those
8
+ // are the ones whose index is readable without a decompressor this package carries.
9
+ // Everything else (xz, bzip2, zstd) is listed by GNU tar when the image carries it, since the codecs are on the image
10
+ // but not in node_modules; a container nothing here can open is named as unlistable rather than guessed at.
11
+
12
+ const run = promisify(execFile);
13
+
14
+ export interface ArchiveEntry {
15
+ readonly path: string;
16
+ /** Unpacked bytes; undefined where the container records none (a tar listing's directory, a streamed zip entry). */
17
+ readonly size: number | undefined;
18
+ /** Bytes the member occupies inside the archive; zip alone records both, so it alone can state a ratio. */
19
+ readonly packed: number | undefined;
20
+ readonly directory: boolean;
21
+ }
22
+
23
+ /** Every member of a zip, from the central directory alone. */
24
+ export const zipEntries = (bytes: Uint8Array): ArchiveEntry[] => {
25
+ const entries: ArchiveEntry[] = [];
26
+ unzipSync(bytes, {
27
+ filter: (file) => {
28
+ entries.push({ path: file.name, size: file.originalSize, packed: file.size, directory: file.name.endsWith("/") });
29
+ return false;
30
+ },
31
+ });
32
+ return entries;
33
+ };
34
+
35
+ const BLOCK = 512;
36
+ const decoder = new TextDecoder();
37
+
38
+ // A NUL-terminated ASCII field, which is how every string in a tar header is written.
39
+ const field = (bytes: Uint8Array, offset: number, length: number): string => {
40
+ const raw = bytes.subarray(offset, offset + length);
41
+ const end = raw.indexOf(0);
42
+ return decoder.decode(end === -1 ? raw : raw.subarray(0, end)).trim();
43
+ };
44
+
45
+ // Numeric fields are octal text. A base-256 field (the >8GB encoding) reads as 0, which only a file far past the
46
+ // derivation size cap could carry.
47
+ const octal = (bytes: Uint8Array, offset: number, length: number): number => {
48
+ const text = field(bytes, offset, length).replaceAll(/[^0-7]/g, "");
49
+ return text === "" ? 0 : Number.parseInt(text, 8);
50
+ };
51
+
52
+ // The header's own checksum, with the checksum field itself read as spaces. The one test that recognizes a pre-POSIX
53
+ // tar, which carries no `ustar` magic at all.
54
+ const headerChecks = (block: Uint8Array): boolean => {
55
+ if (block.length < BLOCK) {
56
+ return false;
57
+ }
58
+ let sum = 0;
59
+ for (let index = 0; index < BLOCK; index++) {
60
+ sum += index >= 148 && index < 156 ? 0x20 : (block[index] ?? 0);
61
+ }
62
+ return sum === octal(block, 148, 8);
63
+ };
64
+
65
+ /** Whether these bytes open with a tar header, by magic or by checksum. */
66
+ export const looksLikeTar = (bytes: Uint8Array): boolean => bytes.length >= BLOCK && (field(bytes, 257, 6).startsWith("ustar") || headerChecks(bytes));
67
+
68
+ // A pax record block: `<length> <key>=<value>\n`, repeated. Only `path` matters here, and it overrides the ustar name.
69
+ const paxPath = (data: Uint8Array): string | undefined => {
70
+ for (const record of decoder.decode(data).split("\n")) {
71
+ const match = /^\d+ path=(.*)$/.exec(record);
72
+ if (match !== null) {
73
+ return match[1];
74
+ }
75
+ }
76
+ return undefined;
77
+ };
78
+
79
+ interface TarBlock {
80
+ readonly header: Uint8Array;
81
+ /** One of tar's typeflags: "" or "0" a file, "5" a directory, "L"/"x" a name carrier, "g" archive defaults. */
82
+ readonly type: string;
83
+ readonly size: number;
84
+ readonly data: Uint8Array;
85
+ /** Offset of the block after this one's data, already rounded up to the 512-byte grid. */
86
+ readonly next: number;
87
+ }
88
+
89
+ // The block at `offset`, or undefined at the zero block that ends every tar (and at a truncated tail).
90
+ const blockAt = (bytes: Uint8Array, offset: number): TarBlock | undefined => {
91
+ if (offset + BLOCK > bytes.length) {
92
+ return undefined;
93
+ }
94
+ const header = bytes.subarray(offset, offset + BLOCK);
95
+ if (header.every((byte) => byte === 0)) {
96
+ return undefined;
97
+ }
98
+ const size = octal(header, 124, 12);
99
+ return {
100
+ header,
101
+ type: field(header, 156, 1),
102
+ size,
103
+ data: bytes.subarray(offset + BLOCK, offset + BLOCK + size),
104
+ next: offset + BLOCK + Math.ceil(size / BLOCK) * BLOCK,
105
+ };
106
+ };
107
+
108
+ // Blocks that name the entry after them instead of being one; a path longer than the 100-byte name field arrives here.
109
+ const NAME_CARRIERS: ReadonlySet<string> = new Set(["L", "x", "X", "g"]);
110
+
111
+ const carriedName = (block: TarBlock, current: string | undefined): string | undefined => {
112
+ if (block.type === "L") {
113
+ return field(block.data, 0, block.data.length);
114
+ }
115
+ // "g" carries defaults for the whole archive rather than a name, so it leaves the pending one alone.
116
+ return block.type === "g" ? current : (paxPath(block.data) ?? current);
117
+ };
118
+
119
+ const entryOf = (block: TarBlock, carried: string | undefined): ArchiveEntry => {
120
+ const prefix = field(block.header, 345, 155);
121
+ const name = field(block.header, 0, 100);
122
+ const path = carried ?? (prefix === "" ? name : `${prefix}/${name}`);
123
+ const directory = block.type === "5" || path.endsWith("/");
124
+ return { path, size: directory ? undefined : block.size, packed: undefined, directory };
125
+ };
126
+
127
+ /**
128
+ * Every member of a tar, by walking its header blocks and skipping the data between them.
129
+ * Throws when the first block is not a header, which is how a file lying about being a tar becomes a loud skip.
130
+ */
131
+ export const tarEntries = (bytes: Uint8Array): ArchiveEntry[] => {
132
+ if (!looksLikeTar(bytes)) {
133
+ throw new Error("not a tar archive: no header block at the start");
134
+ }
135
+ const entries: ArchiveEntry[] = [];
136
+ // Set by a name-carrying block for the entry that follows it, cleared the moment that entry consumes it.
137
+ let carried: string | undefined;
138
+ let offset = 0;
139
+ for (let block = blockAt(bytes, offset); block !== undefined; block = blockAt(bytes, offset)) {
140
+ offset = block.next;
141
+ if (NAME_CARRIERS.has(block.type)) {
142
+ carried = carriedName(block, carried);
143
+ continue;
144
+ }
145
+ entries.push(entryOf(block, carried));
146
+ carried = undefined;
147
+ }
148
+ return entries;
149
+ };
150
+
151
+ export interface GzipHead {
152
+ /** The member name gzip stored, when it stored one; a file compressed from stdin carries none. */
153
+ readonly name: string | undefined;
154
+ /** Unpacked size from the trailer, exact below 4 GiB and wrapped above it, which nothing derived here reaches. */
155
+ readonly unpackedBytes: number;
156
+ }
157
+
158
+ // FNAME, the NUL-terminated original name, sits after the fixed header and after FEXTRA's own length-prefixed block.
159
+ const gzipName = (bytes: Uint8Array, flags: number): string | undefined => {
160
+ if ((flags & 0x08) === 0) {
161
+ return undefined;
162
+ }
163
+ const extra = (flags & 0x04) === 0 ? 0 : 2 + ((bytes[10] ?? 0) | ((bytes[11] ?? 0) << 8));
164
+ const end = bytes.indexOf(0, 10 + extra);
165
+ const name = end === -1 ? "" : decoder.decode(bytes.subarray(10 + extra, end));
166
+ return name === "" ? undefined : name;
167
+ };
168
+
169
+ /** A gzip member's header and trailer facts, without inflating a byte; undefined when the bytes are not gzip. */
170
+ export const gzipHead = (bytes: Uint8Array): GzipHead | undefined => {
171
+ if (bytes.length < 18 || bytes[0] !== 0x1f || bytes[1] !== 0x8b) {
172
+ return undefined;
173
+ }
174
+ const view = new DataView(bytes.buffer, bytes.byteOffset + bytes.length - 4, 4);
175
+ return { name: gzipName(bytes, bytes[3] ?? 0), unpackedBytes: view.getUint32(0, true) };
176
+ };
177
+
178
+ /** Whether GNU tar is on PATH, which is what lets this tier list the codecs node_modules cannot decompress. */
179
+ export const tarOnPath = (): boolean => onPath("tar");
180
+
181
+ // `tar -tvf` prints `<mode> <owner/group> <size> <date> <time> <path>`; the path is the rest of the line, so a name
182
+ // with spaces in it survives.
183
+ const LISTING_LINE = /^(\S+)\s+\S+\s+(\d+)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?\s+(.+)$/;
184
+
185
+ /** Parses `tar -tvf` output; split out from the spawn so the shape of a listing is testable without a tar on PATH. */
186
+ export const parseTarListing = (stdout: string): ArchiveEntry[] => {
187
+ const entries: ArchiveEntry[] = [];
188
+ for (const line of stdout.split("\n")) {
189
+ const match = LISTING_LINE.exec(line);
190
+ if (match === null) {
191
+ continue;
192
+ }
193
+ const [, mode = "", size = "0", path = ""] = match;
194
+ // A link prints as `target -> source`; the member is the name on the left of the arrow.
195
+ const directory = mode.startsWith("d");
196
+ entries.push({ path: path.split(" -> ")[0] ?? path, size: directory ? undefined : Number(size), packed: undefined, directory });
197
+ }
198
+ return entries;
199
+ };
200
+
201
+ // Long enough for a large archive's index, short enough that a hung tar cannot hold the sweep behind it.
202
+ const TAR_TIMEOUT_MS = 60_000;
203
+ const TAR_MAX_BUFFER = 16 * 1024 * 1024;
204
+
205
+ /** Lists an archive through GNU tar, which auto-detects gzip, bzip2, xz and zstd on read. */
206
+ export const listWithTar = async (absPath: string): Promise<ArchiveEntry[]> => {
207
+ const { stdout } = await run("tar", ["-tvf", absPath], { timeout: TAR_TIMEOUT_MS, maxBuffer: TAR_MAX_BUFFER });
208
+ return parseTarListing(stdout);
209
+ };
@@ -0,0 +1,132 @@
1
+ import type { Stats } from "node:fs";
2
+ import { stat } from "node:fs/promises";
3
+ import { isAbsolute, relative, sep } from "node:path";
4
+ import { estimateTokens } from "@intentic/base/format";
5
+ import { errnoCode, errorMessage, isMissing } from "@intentic/base/errors";
6
+ import { IGNORED_DIRS, isAgentWorktreePath, isReferencePath } from "@intentic/workspace-ignore";
7
+ import { STATE_DIR } from "@intentic/constants";
8
+ import { detectFormat, type Format } from "./formats.js";
9
+ import { deriverStamp, neutralizeDoc, type DerivedDoc, type Deriver } from "./derivers/deriver.js";
10
+ import { archiveDeriver } from "./derivers/archive.js";
11
+ import { docxDeriver } from "./derivers/docx.js";
12
+ import { epubDeriver } from "./derivers/epub.js";
13
+ import { htmlDeriver } from "./derivers/html.js";
14
+ import { imageDeriver } from "./derivers/image.js";
15
+ import { ipynbDeriver } from "./derivers/ipynb.js";
16
+ import { mediaDeriver } from "./derivers/media.js";
17
+ import { odpDeriver } from "./derivers/odp.js";
18
+ import { odsDeriver } from "./derivers/ods.js";
19
+ import { odtDeriver } from "./derivers/odt.js";
20
+ import { pdfDeriver } from "./derivers/pdf.js";
21
+ import { pptxDeriver } from "./derivers/pptx.js";
22
+ import { profileDeriver } from "./derivers/profile.js";
23
+ import { rtfDeriver } from "./derivers/rtf.js";
24
+ import { xlsxDeriver } from "./derivers/xlsx.js";
25
+ import { isFresh, readSidecar, removeSidecar, sidecarBody, sidecarPathFor, sha256OfFile, writeSidecar } from "./sidecar.js";
26
+
27
+ // Pipeline both commands and the daemon's sweep run: place the file, recognize it, route it, keep its shadow honest.
28
+ // One module, so `read` and `derive` cannot disagree about a file's markdown.
29
+
30
+ export const DERIVERS: Record<Format, Deriver> = {
31
+ docx: docxDeriver,
32
+ xlsx: xlsxDeriver,
33
+ pptx: pptxDeriver,
34
+ pdf: pdfDeriver,
35
+ image: imageDeriver,
36
+ media: mediaDeriver,
37
+ html: htmlDeriver,
38
+ ipynb: ipynbDeriver,
39
+ odt: odtDeriver,
40
+ ods: odsDeriver,
41
+ odp: odpDeriver,
42
+ rtf: rtfDeriver,
43
+ epub: epubDeriver,
44
+ archive: archiveDeriver,
45
+ profile: profileDeriver,
46
+ };
47
+
48
+ // Above this, a derivation isn't background-cheap and the file is data to process, not shadow; skipped loudly.
49
+ export const MAX_SOURCE_BYTES = 200 * 1024 * 1024;
50
+
51
+ /** Workspace-relative path when `abs` sits under `root`; undefined outside it (no sidecar can exist there). */
52
+ export const relPathIn = (root: string, abs: string): string | undefined => {
53
+ const rel = relative(root, abs);
54
+ return rel === "" || rel.startsWith("..") || isAbsolute(rel) ? undefined : rel.split(sep).join("/");
55
+ };
56
+
57
+ // What the shadow tree refuses: machine subtrees, the daemon's own state, the reference shelf, agent worktrees.
58
+ export const isDeriveIgnored = (relPath: string): boolean => {
59
+ const segments = relPath.split("/");
60
+ return segments.some((segment) => IGNORED_DIRS.has(segment) || segment === STATE_DIR) || isReferencePath(relPath) || isAgentWorktreePath(relPath);
61
+ };
62
+
63
+ export type Outcome =
64
+ | {
65
+ readonly kind: "derived";
66
+ readonly relPath: string;
67
+ readonly format: Format;
68
+ readonly sidecarPath: string;
69
+ readonly body: string;
70
+ readonly doc: DerivedDoc;
71
+ readonly tokens: number;
72
+ }
73
+ | {
74
+ readonly kind: "fresh";
75
+ readonly relPath: string;
76
+ readonly format: Format;
77
+ readonly sidecarPath: string;
78
+ readonly body: string;
79
+ readonly tokens: number;
80
+ }
81
+ | { readonly kind: "removed"; readonly relPath: string; readonly sidecarPath: string }
82
+ | { readonly kind: "skipped"; readonly relPath: string; readonly reason: string };
83
+
84
+ /** Converges a workspace file's sidecar with its source: derive when stale, reuse fresh, remove when gone. */
85
+ export const ensureSidecar = async (workspaceRoot: string, absPath: string, now: () => Date = () => new Date()): Promise<Outcome> => {
86
+ const relPath = relPathIn(workspaceRoot, absPath);
87
+ if (relPath === undefined) {
88
+ return { kind: "skipped", relPath: absPath, reason: "outside-workspace" };
89
+ }
90
+ if (isDeriveIgnored(relPath)) {
91
+ return { kind: "skipped", relPath, reason: "ignored-path" };
92
+ }
93
+ const sidecarPath = sidecarPathFor(workspaceRoot, relPath);
94
+ let source: Stats | undefined;
95
+ try {
96
+ source = await stat(absPath);
97
+ } catch (error) {
98
+ // Only a source that is gone takes its sidecar with it; one this process cannot stat is still there.
99
+ if (!isMissing(error)) {
100
+ return { kind: "skipped", relPath, reason: `unreadable (${errnoCode(error) ?? errorMessage(error)})` };
101
+ }
102
+ }
103
+ if (source === undefined || !source.isFile()) {
104
+ const removed = await removeSidecar(workspaceRoot, relPath);
105
+ return removed ? { kind: "removed", relPath, sidecarPath } : { kind: "skipped", relPath, reason: "missing" };
106
+ }
107
+ if (source.size > MAX_SOURCE_BYTES) {
108
+ return { kind: "skipped", relPath, reason: `too-large (${Math.round(source.size / 1024 / 1024)} MB)` };
109
+ }
110
+ const format = await detectFormat(absPath);
111
+ if (format === undefined) {
112
+ return { kind: "skipped", relPath, reason: "unsupported" };
113
+ }
114
+ const deriver = DERIVERS[format];
115
+ const stamp = deriverStamp(deriver);
116
+ const sourceSha = await sha256OfFile(absPath);
117
+ const existing = await readSidecar(sidecarPath);
118
+ if (isFresh(existing, sourceSha, stamp)) {
119
+ const body = sidecarBody(existing ?? "");
120
+ return { kind: "fresh", relPath, format, sidecarPath, body, tokens: estimateTokens(body) };
121
+ }
122
+ // Neutralized once here so every consumer gets folded text; a corrupt file's failure is a loud skip, not fatal.
123
+ let doc: DerivedDoc;
124
+ try {
125
+ doc = neutralizeDoc(await deriver.derive(absPath));
126
+ } catch (error) {
127
+ const detail = errorMessage(error).split("\n")[0];
128
+ return { kind: "skipped", relPath, reason: `derive-failed (${format}): ${detail}` };
129
+ }
130
+ const written = await writeSidecar(workspaceRoot, { relPath, sourceSha, deriverStamp: stamp, doc, derivedAt: now() });
131
+ return { kind: "derived", relPath, format, sidecarPath: written.path, body: written.body, doc, tokens: written.tokens };
132
+ };
@@ -0,0 +1,168 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename } from "node:path";
3
+ import { gunzipSync } from "fflate";
4
+ import { plural } from "@intentic/base/format";
5
+ import { gzipHead, listWithTar, looksLikeTar, tarEntries, tarOnPath, zipEntries, type ArchiveEntry } from "../archives.js";
6
+ import type { DerivedDoc, Deriver } from "./deriver.js";
7
+
8
+ /* Archives: what is inside, not what it says. */
9
+
10
+ // Members listed before the table is more index than shadow; the count above it still states the whole truth.
11
+ const MAX_ENTRIES = 500;
12
+ // A gzip member worth inflating into memory during a background sweep; above it the trailer's facts are the shadow.
13
+ const MAX_UNPACKED_BYTES = 128 * 1024 * 1024;
14
+ // Text of a single compressed file carried into the shadow; the rest stays one `zcat` away.
15
+ const MAX_MEMBER_TEXT_BYTES = 256 * 1024;
16
+
17
+ // What the container is, as a reader would name it; drives both the capsule's first line and which reader runs.
18
+ type Container = "zip" | "tar" | "gzip" | "xz" | "bzip2" | "zstd" | "7z" | "rar";
19
+
20
+ // Leading bytes that name a container. Checked before tar, whose own recognition is a checksum over the first block.
21
+ const MAGIC: readonly (readonly [Container, readonly number[]])[] = [
22
+ ["zip", [0x50, 0x4b]],
23
+ ["gzip", [0x1f, 0x8b]],
24
+ ["xz", [0xfd, 0x37, 0x7a, 0x58, 0x5a]],
25
+ ["bzip2", [0x42, 0x5a, 0x68]],
26
+ ["zstd", [0x28, 0xb5, 0x2f, 0xfd]],
27
+ ["7z", [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c]],
28
+ ["rar", [0x52, 0x61, 0x72, 0x21]],
29
+ ];
30
+
31
+ const containerOf = (bytes: Uint8Array): Container | undefined => {
32
+ const magic = MAGIC.find(([, signature]) => signature.every((byte, index) => bytes[index] === byte));
33
+ return magic?.[0] ?? (looksLikeTar(bytes) ? "tar" : undefined);
34
+ };
35
+
36
+ // Sizes read by a person, not a machine: three significant figures at most, since an exact byte count of a member
37
+ // nobody has unpacked yet is noise.
38
+ const formatBytes = (bytes: number): string => {
39
+ const units = ["B", "kB", "MB", "GB", "TB"];
40
+ let value = bytes;
41
+ let unit = 0;
42
+ while (value >= 1000 && unit < units.length - 1) {
43
+ value /= 1000;
44
+ unit += 1;
45
+ }
46
+ return `${unit === 0 ? value : value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
47
+ };
48
+
49
+ // The capsule above the table: what the container is and how much is in it, so a reader knows the shape before the list.
50
+ const summaryOf = (container: Container, entries: readonly ArchiveEntry[]): string[] => {
51
+ const files = entries.filter((entry) => !entry.directory);
52
+ const unpacked = files.reduce((sum, entry) => sum + (entry.size ?? 0), 0);
53
+ const packed = files.reduce((sum, entry) => sum + (entry.packed ?? 0), 0);
54
+ const folders = entries.length - files.length;
55
+ const lines = [`- Archive: ${container}`, `- Members: ${plural(files.length, "file")}${folders === 0 ? "" : `, ${plural(folders, "folder")}`}`];
56
+ // Ratio only where the container records both sides; a tar header knows nothing about what compressed it.
57
+ const ratio = packed > 0 && unpacked > 0 ? ` · packed ${formatBytes(packed)} (${Math.round((1 - packed / unpacked) * 100)}% smaller)` : "";
58
+ if (unpacked > 0) {
59
+ lines.push(`- Unpacked: ${formatBytes(unpacked)}${ratio}`);
60
+ }
61
+ return lines;
62
+ };
63
+
64
+ const tableRow = (cells: readonly string[]): string => `| ${cells.map((cell) => cell.replaceAll("|", "\\|")).join(" | ")} |`;
65
+
66
+ // Files only: a folder row carries no size and says nothing the paths beside it don't already.
67
+ const entryTable = (entries: readonly ArchiveEntry[]): string => {
68
+ const files = entries.filter((entry) => !entry.directory).slice(0, MAX_ENTRIES);
69
+ if (files.length === 0) {
70
+ return "";
71
+ }
72
+ return [
73
+ tableRow(["Path", "Size"]),
74
+ tableRow(["---", "---"]),
75
+ ...files.map((entry) => tableRow([entry.path, entry.size === undefined ? "" : formatBytes(entry.size)])),
76
+ ].join("\n");
77
+ };
78
+
79
+ const listingDoc = (container: Container, entries: readonly ArchiveEntry[], notes: readonly string[]): DerivedDoc => {
80
+ const files = entries.filter((entry) => !entry.directory).length;
81
+ const table = entryTable(entries);
82
+ return {
83
+ markdown: [summaryOf(container, entries).join("\n"), table].filter((part) => part !== "").join("\n\n"),
84
+ notes: [
85
+ ...notes,
86
+ ...(files > MAX_ENTRIES ? [`listing ${MAX_ENTRIES} of ${files} files: the rest are in the archive, not here`] : []),
87
+ ...(entries.length === 0 ? ["this archive holds nothing"] : []),
88
+ "member listing only: what the files inside SAY is not derived — unpack the archive to read them",
89
+ ],
90
+ };
91
+ };
92
+
93
+ // A NUL in the first pages is how every other surface here decides text from bytes; the same rule, so they agree.
94
+ const isText = (bytes: Uint8Array): boolean => !bytes.subarray(0, 8192).includes(0);
95
+
96
+ // One compressed file, where the archive is a wrapper rather than a container: the member's own text is the shadow.
97
+ const memberDoc = (name: string, unpacked: Uint8Array): DerivedDoc => {
98
+ const facts = [`- Archive: gzip`, `- Member: ${name}`, `- Unpacked: ${formatBytes(unpacked.length)}`].join("\n");
99
+ if (!isText(unpacked)) {
100
+ return { markdown: facts, notes: [`${name} is not text: nothing inside this archive can be shown as prose`] };
101
+ }
102
+ const clipped = unpacked.length > MAX_MEMBER_TEXT_BYTES;
103
+ const text = new TextDecoder().decode(unpacked.subarray(0, MAX_MEMBER_TEXT_BYTES));
104
+ return {
105
+ markdown: `${facts}\n\n## ${name}\n\n${clipped ? text.slice(0, text.lastIndexOf("\n") + 1) : text}`,
106
+ notes: clipped ? [`showing the first ${formatBytes(MAX_MEMBER_TEXT_BYTES)} of ${formatBytes(unpacked.length)}: the rest is in the archive`] : [],
107
+ };
108
+ };
109
+
110
+ const gzipDoc = (absPath: string, bytes: Uint8Array): DerivedDoc => {
111
+ const head = gzipHead(bytes);
112
+ const name = head?.name ?? basename(absPath).replace(/\.(gz|tgz)$/i, "");
113
+ if (head !== undefined && head.unpackedBytes > MAX_UNPACKED_BYTES) {
114
+ return {
115
+ markdown: [`- Archive: gzip`, `- Member: ${name}`, `- Unpacked: ${formatBytes(head.unpackedBytes)}`].join("\n"),
116
+ notes: [`too large to open in the background (${formatBytes(head.unpackedBytes)} unpacked): the header's facts are all this shadow carries`],
117
+ };
118
+ }
119
+ const unpacked = gunzipSync(bytes);
120
+ // A .tgz is a tar wearing gzip: its shadow is the manifest, the same one a plain .tar gets.
121
+ return looksLikeTar(unpacked) ? listingDoc("gzip", tarEntries(unpacked), ["gzip-compressed tar"]) : memberDoc(name, unpacked);
122
+ };
123
+
124
+ // Codecs the image carries as binaries but node_modules cannot decompress; GNU tar reads all three on the way in.
125
+ const externalDoc = async (container: Container, absPath: string): Promise<DerivedDoc> => {
126
+ if (!tarOnPath()) {
127
+ return { markdown: `- Archive: ${container}`, notes: [`no listing: reading a ${container} archive needs tar, which is not on PATH here`] };
128
+ }
129
+ try {
130
+ return listingDoc(container, await listWithTar(absPath), [`listed through tar`]);
131
+ } catch {
132
+ // tar refuses anything that is not a tar inside: a bare `notes.xz` is one compressed file, and this tier
133
+ // has no decompressor for it.
134
+ return {
135
+ markdown: `- Archive: ${container}`,
136
+ notes: [`no listing: this is a single ${container}-compressed file rather than an archive, and ${container} is not decompressed by this tier`],
137
+ };
138
+ }
139
+ };
140
+
141
+ // 7z and rar are the two containers nothing on the image opens; named rather than silently empty.
142
+ const UNREADABLE: ReadonlySet<Container> = new Set(["7z", "rar"]);
143
+
144
+ export const archiveDeriver: Deriver = {
145
+ // The stamp names the listing capability for the same reason the pdf deriver's names OCR: a shadow written
146
+ // without tar on PATH must read as stale once an image carries it.
147
+ get name(): string {
148
+ return tarOnPath() ? "archive+tar" : "archive";
149
+ },
150
+ version: 1,
151
+ derive: async (absPath): Promise<DerivedDoc> => {
152
+ const bytes = new Uint8Array(await readFile(absPath));
153
+ const container = containerOf(bytes);
154
+ if (container === undefined) {
155
+ throw new Error("not an archive: no container signature in the first bytes");
156
+ }
157
+ if (UNREADABLE.has(container)) {
158
+ return { markdown: `- Archive: ${container}`, notes: [`no listing: nothing in this image opens a ${container} archive`] };
159
+ }
160
+ if (container === "zip") {
161
+ return listingDoc(container, zipEntries(bytes), []);
162
+ }
163
+ if (container === "tar") {
164
+ return listingDoc(container, tarEntries(bytes), []);
165
+ }
166
+ return container === "gzip" ? gzipDoc(absPath, bytes) : externalDoc(container, absPath);
167
+ },
168
+ };
@@ -0,0 +1,33 @@
1
+ import { neutralizeOutsideText } from "@intentic/base/outside-text";
2
+
3
+ // Contract every format handler meets: markdown body (no front matter) plus a note for every cap or degradation hit,
4
+ // since a truncated document reading as complete is a lie.
5
+ // `version` is the staleness lever, stamped into sidecar front matter; a stamp mismatch re-derives on the next touch.
6
+ // Bump it whenever the output's shape changes, including a bug fix that changes what the markdown says.
7
+
8
+ export interface DerivedDoc {
9
+ /** Empty means nothing to say, and still gets a sidecar so freshness has somewhere to live. */
10
+ readonly markdown: string;
11
+ /** Human title when the format carries one (metadata, first heading); the capsule leads with it. */
12
+ readonly title?: string | undefined;
13
+ /** Degradations and caps, one line each; printed in the capsule and stored in sidecar front matter. */
14
+ readonly notes: string[];
15
+ }
16
+
17
+ export interface Deriver {
18
+ readonly name: string;
19
+ readonly version: number;
20
+ readonly derive: (absPath: string) => Promise<DerivedDoc>;
21
+ }
22
+
23
+ /** `name v<version>`, the exact string stamped into and compared against sidecar front matter. */
24
+ export const deriverStamp = (deriver: Deriver): string => `${deriver.name} v${deriver.version}`;
25
+
26
+ // Any field can carry forged text (a pdf's Title as easily as its body), so the whole doc is neutralized at the
27
+ // pipeline boundary before it reaches stdout or disk.
28
+ // The sidecar writer neutralizes again independently; the fold is idempotent, so the second pass costs nothing.
29
+ export const neutralizeDoc = (doc: DerivedDoc): DerivedDoc => ({
30
+ markdown: neutralizeOutsideText(doc.markdown),
31
+ title: doc.title === undefined ? undefined : neutralizeOutsideText(doc.title),
32
+ notes: doc.notes.map((note) => neutralizeOutsideText(note)),
33
+ });
@@ -0,0 +1,38 @@
1
+ import { notesOf } from "./docx.js";
2
+
3
+ // mammoth's messages as they reach the notes: a template's worth of style warnings folds to one line, everything
4
+ // else keeps its own, and the fold counts style names so two saves of one template agree.
5
+ const message = (text: string): { message: string } => ({ message: text });
6
+
7
+ describe("notesOf", () => {
8
+ test("style warnings fold to one line counting distinct style names, whatever their ids", () => {
9
+ const notes = notesOf([
10
+ message("Unrecognised run style: 'Tekst treści (4) Exact' (Style ID: Teksttreci4Exact)"),
11
+ message("Unrecognised run style: 'Tekst treści (4) Exact' (Style ID: 1009)"),
12
+ message("Unrecognised paragraph style: 'Body Text 2' (Style ID: Tekstpodstawowy2)"),
13
+ message("Unrecognised paragraph style: 'Body Text 2' (Style ID: Tekstpodstawowy2)"),
14
+ message("Unrecognised table style: 'Grid' (Style ID: Grid)"),
15
+ ]);
16
+ expect(notes).toEqual(["docx conversion: 3 Word styles without a markdown equivalent, read as plain text"]);
17
+ });
18
+
19
+ test("one style reads in the singular", () => {
20
+ expect(notesOf([message("Unrecognised paragraph style: 'Note' (Style ID: Note)")])).toEqual([
21
+ "docx conversion: 1 Word style without a markdown equivalent, read as plain text",
22
+ ]);
23
+ });
24
+
25
+ test("content the markdown lacks keeps a line each, capped, and comes before the style line", () => {
26
+ const dropped = Array.from({ length: 7 }, (_, index) => message(`An unrecognised element was ignored: w:custom${index}`));
27
+ const notes = notesOf([...dropped, message("Unrecognised run style: 'Emphasis' (Style ID: Emphasis)")]);
28
+ expect(notes).toEqual([
29
+ ...dropped.slice(0, 5).map((warning) => `docx conversion: ${warning.message}`),
30
+ "docx conversion: 2 more warnings of the same kind",
31
+ "docx conversion: 1 Word style without a markdown equivalent, read as plain text",
32
+ ]);
33
+ });
34
+
35
+ test("no messages, no notes", () => {
36
+ expect(notesOf([])).toEqual([]);
37
+ });
38
+ });
@@ -0,0 +1,44 @@
1
+ import { bodyOf, parseHtml } from "@intentic/webq/dom";
2
+ import { renderMarkdown } from "@intentic/webq/markdown";
3
+ import type { DerivedDoc, Deriver } from "./deriver.js";
4
+
5
+ /* Word documents: mammoth maps the docx to semantic HTML (headings, lists, tables — its whole reason to exist). */
6
+
7
+ // mammoth is 0.26s to load, and only a Word document needs it; see xlsx.ts for why that is loaded inside derive().
8
+
9
+ // A style mammoth's map does not name still yields its text, as a plain paragraph or run; what the reader loses is
10
+ // the heading or emphasis that style stood for, the same loss for every such style. One line says it; the style
11
+ // names would not, and a corporate template carries dozens, renumbered by every application that re-saves the file.
12
+ const STYLE_WARNING = /^Unrecognised (?:paragraph|run|table) style: '(.*)' \(Style ID: /;
13
+
14
+ // Every other warning is a piece of content the markdown lacks (an element it dropped, an image it could not carry)
15
+ // and stays a line of its own, up to this many.
16
+ const MAX_NOTES = 5;
17
+
18
+ /** The notes of a conversion, from mammoth's messages; exported for tests. */
19
+ export const notesOf = (messages: readonly { readonly message: string }[]): string[] => {
20
+ const distinct = [...new Set(messages.map((message) => message.message))];
21
+ // Counted by name, not id: the same template re-saved by another application renumbers every id.
22
+ const styles = new Set(distinct.flatMap((warning) => STYLE_WARNING.exec(warning)?.[1] ?? []));
23
+ const others = distinct.filter((warning) => !STYLE_WARNING.test(warning));
24
+ const notes = others.slice(0, MAX_NOTES).map((warning) => `docx conversion: ${warning}`);
25
+ if (others.length > MAX_NOTES) {
26
+ notes.push(`docx conversion: ${others.length - MAX_NOTES} more warnings of the same kind`);
27
+ }
28
+ if (styles.size > 0) {
29
+ notes.push(`docx conversion: ${styles.size} Word style${styles.size === 1 ? "" : "s"} without a markdown equivalent, read as plain text`);
30
+ }
31
+ return notes;
32
+ };
33
+
34
+ export const docxDeriver: Deriver = {
35
+ name: "docx",
36
+ version: 2,
37
+ derive: async (absPath): Promise<DerivedDoc> => {
38
+ const { default: mammoth } = await import("mammoth");
39
+ const converted = await mammoth.convertToHtml({ path: absPath });
40
+ const body = bodyOf(parseHtml(converted.value));
41
+ const markdown = body === undefined ? "" : renderMarkdown(body);
42
+ return { markdown, notes: notesOf(converted.messages) };
43
+ },
44
+ };