@awebai/oats 0.22.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 (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -0
  3. package/bin/oats.mjs +3294 -0
  4. package/capabilities/oats-authoring/oats.json +12 -0
  5. package/capabilities/oats-aweb/bin/oats-aweb.mjs +297 -0
  6. package/capabilities/oats-aweb/injects/aweb.md +55 -0
  7. package/capabilities/oats-aweb/oats.json +47 -0
  8. package/capabilities/oats-aweb/package.json +20 -0
  9. package/capabilities/oats-jira/bin/oats-jira.mjs +40 -0
  10. package/capabilities/oats-jira/injects/jira.md +10 -0
  11. package/capabilities/oats-jira/oats.json +22 -0
  12. package/capabilities/oats-jira/package.json +25 -0
  13. package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +176 -0
  14. package/capabilities/oats-linear/README.md +234 -0
  15. package/capabilities/oats-linear/bin/oats-linear-hook.mjs +32 -0
  16. package/capabilities/oats-linear/bin/oats-linear.mjs +344 -0
  17. package/capabilities/oats-linear/injects/linear.md +8 -0
  18. package/capabilities/oats-linear/oats.json +24 -0
  19. package/capabilities/oats-linear/package.json +29 -0
  20. package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +217 -0
  21. package/capabilities/oats-linear/test/oats-linear.test.mjs +168 -0
  22. package/capabilities/oats-okf/agents/memory-harvest.md +25 -0
  23. package/capabilities/oats-okf/bin/oats-okf.mjs +283 -0
  24. package/capabilities/oats-okf/injects/okf.md +62 -0
  25. package/capabilities/oats-okf/oats.json +20 -0
  26. package/capabilities/oats-okf/package.json +22 -0
  27. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +100 -0
  28. package/capabilities/oats-okf/skills/okf/SKILL.md +107 -0
  29. package/capabilities/oats-okf/skills/okf/scripts/okf-validate.mjs +123 -0
  30. package/capabilities/oats-review/agents/reviewer/AGENTS.md +53 -0
  31. package/capabilities/oats-review/agents/reviewer/soul.yaml +6 -0
  32. package/capabilities/oats-review/injects/review.md +68 -0
  33. package/capabilities/oats-review/oats.json +10 -0
  34. package/capabilities/oats-review/skills/code-review/SKILL.md +44 -0
  35. package/capabilities/oats-review/skills/security-review/SKILL.md +59 -0
  36. package/docs/capabilities.md +505 -0
  37. package/docs/capability-manifest.schema.json +223 -0
  38. package/docs/configuration.md +482 -0
  39. package/docs/conventions.md +73 -0
  40. package/docs/design/desktop-ux-plan.md +362 -0
  41. package/docs/design/package-engine-contract.md +813 -0
  42. package/docs/design/package-runtime-api.md +414 -0
  43. package/docs/desktop-cli-api.md +89 -0
  44. package/docs/desktop-succession.md +51 -0
  45. package/docs/desktop.md +187 -0
  46. package/docs/implementation.md +282 -0
  47. package/docs/integrations.md +123 -0
  48. package/docs/knowledge-theory.md +97 -0
  49. package/docs/knowledge.md +139 -0
  50. package/docs/layers.md +110 -0
  51. package/docs/migration-from-oas.md +122 -0
  52. package/docs/oats-config.schema.json +140 -0
  53. package/docs/oats-lock.schema.json +175 -0
  54. package/docs/oats-package.schema.json +129 -0
  55. package/docs/packages.md +442 -0
  56. package/docs/release-lane.md +132 -0
  57. package/docs/release-notes/v0.18.2.md +79 -0
  58. package/docs/release-notes/v0.18.3.md +63 -0
  59. package/docs/release-notes/v0.18.4.md +65 -0
  60. package/docs/release-notes/v0.18.5.md +66 -0
  61. package/docs/release-notes/v0.18.6.md +87 -0
  62. package/docs/release-notes/v0.19.0.md +186 -0
  63. package/docs/release-notes/v0.19.1.md +24 -0
  64. package/docs/release-notes/v0.19.2.md +32 -0
  65. package/docs/release-notes/v0.19.3.md +16 -0
  66. package/docs/release-notes/v0.19.4.md +12 -0
  67. package/docs/release-notes/v0.20.0.md +89 -0
  68. package/docs/release-notes/v0.22.0.md +180 -0
  69. package/docs/souls-and-instances.md +344 -0
  70. package/injects/framework-workspace.md +7 -0
  71. package/injects/instance-boundary.md +43 -0
  72. package/injects/local-soul.md +19 -0
  73. package/injects/oats.md +11 -0
  74. package/injects/work-attached.md +16 -0
  75. package/injects/work-checkout.md +12 -0
  76. package/injects/work-workspace.md +22 -0
  77. package/injects/work-worktree.md +13 -0
  78. package/lib/core.mjs +6792 -0
  79. package/lib/packages.mjs +1375 -0
  80. package/lib/tmux-config.mjs +35 -0
  81. package/package-catalog.json +44 -0
  82. package/package.json +55 -0
  83. package/packages/record/README.md +151 -0
  84. package/packages/record/bin/capture.mjs +256 -0
  85. package/packages/record/bin/recall.mjs +102 -0
  86. package/packages/record/bin/setup.mjs +281 -0
  87. package/packages/record/bin/turn-record.mjs +47 -0
  88. package/packages/record/docs/turn-record-sot.md +350 -0
  89. package/packages/record/lib/canonical.mjs +151 -0
  90. package/packages/record/lib/capture-aw.mjs +179 -0
  91. package/packages/record/lib/capture-cc.mjs +322 -0
  92. package/packages/record/lib/formats.mjs +296 -0
  93. package/packages/record/lib/ignore.mjs +155 -0
  94. package/packages/record/lib/index-db.mjs +524 -0
  95. package/packages/record/lib/project-aweb.mjs +251 -0
  96. package/packages/record/lib/segments.mjs +186 -0
  97. package/packages/record/lib/store.mjs +447 -0
  98. package/packages/record/lib/tags.mjs +114 -0
  99. package/packages/record/package.json +45 -0
  100. package/packages/record/test/vectors/README.md +34 -0
  101. package/packages/record/test/vectors/aweb-projection-v1.json +231 -0
  102. package/packages/record/test/vectors/journal-merge-v1.json +261 -0
  103. package/packages/record/test/vectors/turn-id-v1.json +152 -0
  104. package/packages/record/test/vectors/validate.mjs +391 -0
  105. package/skills/integration-authoring/SKILL.md +76 -0
  106. package/skills/oats/SKILL.md +151 -0
  107. package/skills/oats-config/SKILL.md +162 -0
  108. package/skills/oats-getting-started/SKILL.md +159 -0
  109. package/skills/oats-packages/SKILL.md +180 -0
  110. package/skills/oats-support/SKILL.md +79 -0
  111. package/skills/skill-craft/SKILL.md +109 -0
  112. package/skills/soul-craft/SKILL.md +109 -0
@@ -0,0 +1,296 @@
1
+ // Session transcript formats the record knows how to capture.
2
+ //
3
+ // The storage contract is format-agnostic (verbatim blob + session turn);
4
+ // a format contributes only: where its transcripts live, how to name a
5
+ // session, and how to extract conversational text for the derived index.
6
+ // Every format's records carry a top-level `timestamp` string, so the
7
+ // last-timestamp/event-count scan in capture is shared.
8
+ //
9
+ // Extraction policy (corrected 2026-08-19, Juan): NEVER strip anything
10
+ // model-visible. An agent cannot be recovered if tool calls and their
11
+ // results are not part of the session, so extraction surfaces the full
12
+ // conversation: user/assistant text, thinking (where the harness persists
13
+ // it readably), tool calls with their inputs, tool results, attachments.
14
+ // Roles label every piece so consumers can filter; nothing is dropped.
15
+ // Harness bookkeeping the model never saw as conversation (file
16
+ // snapshots, queue operations, mode flips) yields no docs — but its
17
+ // native line is still stored verbatim in the turn, so nothing is lost.
18
+
19
+ import { existsSync, readdirSync } from "node:fs";
20
+ import { basename, join } from "node:path";
21
+ import { homedir } from "node:os";
22
+
23
+ // Iterate JSONL lines of a buffer without materializing the whole file as
24
+ // one string — real transcripts reach hundreds of MB (a 789 MB Codex
25
+ // rollout exists on this machine), beyond V8's single-string limit. A line
26
+ // that itself exceeds the limit yields text:null (counted, not decoded).
27
+ export function* jsonlLines(bytes) {
28
+ let start = 0;
29
+ let lineNo = 0;
30
+ while (start < bytes.length) {
31
+ let nl = bytes.indexOf(10, start);
32
+ if (nl === -1) nl = bytes.length;
33
+ lineNo++;
34
+ let text = null;
35
+ try {
36
+ text = bytes.toString("utf8", start, nl);
37
+ } catch {
38
+ // single line beyond the string limit: undecodable, still an event
39
+ }
40
+ yield { text, lineNo };
41
+ start = nl + 1;
42
+ }
43
+ }
44
+
45
+ function* parsedLines(bytes) {
46
+ for (const { text, lineNo } of jsonlLines(bytes)) {
47
+ if (text === null || text.trim() === "") continue;
48
+ try {
49
+ yield { d: JSON.parse(text), lineNo };
50
+ } catch {
51
+ /* unparseable line: preserved in the blob, nothing to extract */
52
+ }
53
+ }
54
+ }
55
+
56
+ function listJsonlFiles(root, maxDepth) {
57
+ const out = [];
58
+ const walk = (dir, depth) => {
59
+ let names;
60
+ try {
61
+ names = readdirSync(dir, { withFileTypes: true });
62
+ } catch {
63
+ return;
64
+ }
65
+ for (const entry of names.sort((a, b) => a.name.localeCompare(b.name))) {
66
+ const path = join(dir, entry.name);
67
+ if (entry.isDirectory()) {
68
+ if (depth < maxDepth) walk(path, depth + 1);
69
+ } else if (entry.name.endsWith(".jsonl")) {
70
+ out.push(path);
71
+ }
72
+ }
73
+ };
74
+ walk(root, 0);
75
+ return out;
76
+ }
77
+
78
+ // ------------------------------------------------------------ claude code
79
+
80
+ function ccRoots(home = homedir()) {
81
+ const roots = [];
82
+ for (const name of readdirSync(home).sort()) {
83
+ if (!name.startsWith(".claude")) continue;
84
+ const projects = join(home, name, "projects");
85
+ if (existsSync(projects)) roots.push(projects);
86
+ }
87
+ return roots;
88
+ }
89
+
90
+ // Cap for the unknown-part fallback: prefix stays searchable, the full
91
+ // bytes are always in the verbatim blob — this bounds the index, it does
92
+ // not strip the record.
93
+ const FALLBACK_CAP = 2000;
94
+
95
+ function binaryPlaceholder(part) {
96
+ // Base64-payload parts (documents, images) are not searchable text;
97
+ // index a descriptive placeholder, the blob keeps the bytes.
98
+ const src = part.source;
99
+ if (src && typeof src.data === "string" && src.data.length > 256) {
100
+ const media = src.media_type ?? src.mediaType ?? "binary";
101
+ return `[${part.type}: ${media}, ${src.data.length} base64 chars; full content in session blob]`;
102
+ }
103
+ return null;
104
+ }
105
+
106
+ function pushPart(docs, lineNo, baseRole, part, ts = "") {
107
+ if (typeof part !== "object" || part === null) return;
108
+ if (part.type === "text" && typeof part.text === "string") {
109
+ if (part.text.trim()) docs.push({ loc: `line:${lineNo}`, role: baseRole, text: part.text, ts });
110
+ } else if (part.type === "thinking") {
111
+ const t = typeof part.thinking === "string" ? part.thinking : "";
112
+ if (t.trim()) docs.push({ loc: `line:${lineNo}`, role: "thinking", text: t, ts });
113
+ } else if (part.type === "tool_use" || part.type === "toolCall") {
114
+ // pi spells it toolCall with pre-encoded string arguments; cc spells it
115
+ // tool_use with an input object. One normalized role either way.
116
+ const args =
117
+ typeof part.arguments === "string" ? part.arguments : safeJson(part.input ?? part.arguments);
118
+ docs.push({ loc: `line:${lineNo}`, role: "tool_use", text: `${part.name ?? "tool"} ${args}`, ts });
119
+ } else if (part.type === "tool_result") {
120
+ const c = part.content;
121
+ const text =
122
+ typeof c === "string"
123
+ ? c
124
+ : Array.isArray(c)
125
+ ? c.map((p) => (typeof p?.text === "string" ? p.text : safeJson(p))).join("\n")
126
+ : safeJson(c);
127
+ if (String(text).trim()) docs.push({ loc: `line:${lineNo}`, role: "tool_result", text: String(text), ts });
128
+ } else if (typeof part.text === "string" && part.text.trim()) {
129
+ docs.push({ loc: `line:${lineNo}`, role: baseRole, text: part.text, ts });
130
+ } else {
131
+ // Unknown model-visible part: keep it, but bounded — binary payloads
132
+ // become descriptive placeholders, anything else is capped with the
133
+ // full bytes always in the blob.
134
+ const placeholder = binaryPlaceholder(part);
135
+ if (placeholder) {
136
+ docs.push({ loc: `line:${lineNo}`, role: part.type ?? baseRole, text: placeholder, ts });
137
+ return;
138
+ }
139
+ let text = safeJson(part);
140
+ if (text.length > FALLBACK_CAP) {
141
+ text = text.slice(0, FALLBACK_CAP) + ` [+${text.length - FALLBACK_CAP} chars; full content in session blob]`;
142
+ }
143
+ docs.push({ loc: `line:${lineNo}`, role: part.type ?? baseRole, text, ts });
144
+ }
145
+ }
146
+
147
+ function safeJson(v) {
148
+ try {
149
+ return JSON.stringify(v) ?? "";
150
+ } catch {
151
+ return String(v);
152
+ }
153
+ }
154
+
155
+ export function extractCcText(bytes) {
156
+ const docs = [];
157
+ for (const { d, lineNo } of parsedLines(bytes)) {
158
+ const ts = typeof d.timestamp === "string" ? d.timestamp : "";
159
+ if (d.type === "user" || d.type === "assistant") {
160
+ const content = d.message?.content;
161
+ if (typeof content === "string") {
162
+ if (content.trim()) docs.push({ loc: `line:${lineNo}`, role: d.type, text: content, ts });
163
+ } else if (Array.isArray(content)) {
164
+ for (const part of content) pushPart(docs, lineNo, d.type, part, ts);
165
+ }
166
+ } else if (d.type === "attachment") {
167
+ const text = safeJson(d.attachment);
168
+ if (text && text !== "null") docs.push({ loc: `line:${lineNo}`, role: "attachment", text, ts });
169
+ } else if (d.type === "system") {
170
+ const text = typeof d.content === "string" ? d.content : safeJson(d.content);
171
+ if (String(text).trim()) docs.push({ loc: `line:${lineNo}`, role: "system", text: String(text), ts });
172
+ }
173
+ // Everything else (mode, file-history-*, queue-operation, ...) is
174
+ // harness bookkeeping: not conversation, preserved verbatim in the blob.
175
+ }
176
+ return docs;
177
+ }
178
+
179
+ // --------------------------------------------------------------------- pi
180
+
181
+ function piRoots(home = homedir()) {
182
+ const root = join(home, ".pi", "agent", "sessions");
183
+ return existsSync(root) ? [root] : [];
184
+ }
185
+
186
+ export function extractPiText(bytes) {
187
+ const docs = [];
188
+ for (const { d, lineNo } of parsedLines(bytes)) {
189
+ const ts = typeof d.timestamp === "string" ? d.timestamp : "";
190
+ if (d.type === "message") {
191
+ const rawRole = d.message?.role;
192
+ const role = rawRole === "toolResult" ? "tool_result" : rawRole;
193
+ if (!role) continue;
194
+ const content = d.message?.content;
195
+ if (typeof content === "string") {
196
+ if (content.trim()) docs.push({ loc: `line:${lineNo}`, role, text: content, ts });
197
+ } else if (Array.isArray(content)) {
198
+ for (const part of content) pushPart(docs, lineNo, role, part, ts);
199
+ }
200
+ } else if (d.type === "custom_message") {
201
+ const text = typeof d.content === "string" ? d.content : safeJson(d.content ?? d);
202
+ if (String(text).trim()) docs.push({ loc: `line:${lineNo}`, role: "system", text: String(text), ts });
203
+ }
204
+ }
205
+ return docs;
206
+ }
207
+
208
+ // pi filenames: <iso-ts>_<uuid>.jsonl — the uuid is the session id.
209
+ function piSessionId(path) {
210
+ const stem = basename(path, ".jsonl");
211
+ const i = stem.lastIndexOf("_");
212
+ return i >= 0 ? stem.slice(i + 1) : stem;
213
+ }
214
+
215
+ // ------------------------------------------------------------------ codex
216
+
217
+ function codexRoots(home = homedir()) {
218
+ const root = join(home, ".codex", "sessions");
219
+ return existsSync(root) ? [root] : [];
220
+ }
221
+
222
+ export function extractCodexText(bytes) {
223
+ const docs = [];
224
+ for (const { d, lineNo } of parsedLines(bytes)) {
225
+ if (d.type !== "response_item") continue;
226
+ const p = d.payload;
227
+ if (!p || typeof p !== "object") continue;
228
+ const ts = typeof d.timestamp === "string" ? d.timestamp : "";
229
+ if (p.type === "message" && (p.role === "user" || p.role === "assistant")) {
230
+ const text = Array.isArray(p.content)
231
+ ? p.content
232
+ .filter((x) => typeof x?.text === "string")
233
+ .map((x) => x.text)
234
+ .join("\n")
235
+ : "";
236
+ if (text.trim()) docs.push({ loc: `line:${lineNo}`, role: p.role, text, ts });
237
+ } else if (p.type === "reasoning") {
238
+ const text = Array.isArray(p.summary)
239
+ ? p.summary.map((x) => (typeof x?.text === "string" ? x.text : "")).join("\n")
240
+ : "";
241
+ if (text.trim()) docs.push({ loc: `line:${lineNo}`, role: "thinking", text, ts });
242
+ } else if (p.type === "function_call" || p.type === "custom_tool_call") {
243
+ const text = `${p.name ?? "tool"} ${typeof p.arguments === "string" ? p.arguments : safeJson(p.arguments ?? p.input)}`;
244
+ docs.push({ loc: `line:${lineNo}`, role: "tool_use", text, ts });
245
+ } else if (p.type === "function_call_output" || p.type === "custom_tool_call_output") {
246
+ const out = p.output;
247
+ const text = typeof out === "string" ? out : safeJson(out);
248
+ if (String(text).trim()) docs.push({ loc: `line:${lineNo}`, role: "tool_result", text: String(text), ts });
249
+ }
250
+ // ghost_snapshot and other non-conversation payloads: no docs; the
251
+ // native line survives verbatim in the turn body regardless.
252
+ }
253
+ return docs;
254
+ }
255
+
256
+ // codex filenames: rollout-<ts>-<uuid>.jsonl — trailing uuid is the id.
257
+ function codexSessionId(path) {
258
+ const stem = basename(path, ".jsonl");
259
+ const tail = stem.slice(-36);
260
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(tail)
261
+ ? tail
262
+ : stem;
263
+ }
264
+
265
+ // --------------------------------------------------------------- registry
266
+
267
+ export const SESSION_FORMATS = {
268
+ cc: {
269
+ source: "cc",
270
+ defaultRoots: ccRoots,
271
+ listFiles: (roots) => roots.flatMap((r) => listJsonlFiles(r, 1)),
272
+ sessionId: (path) => basename(path, ".jsonl"),
273
+ extractText: extractCcText,
274
+ },
275
+ pi: {
276
+ source: "pi",
277
+ defaultRoots: piRoots,
278
+ listFiles: (roots) => roots.flatMap((r) => listJsonlFiles(r, 1)),
279
+ sessionId: piSessionId,
280
+ extractText: extractPiText,
281
+ },
282
+ codex: {
283
+ source: "codex",
284
+ defaultRoots: codexRoots,
285
+ listFiles: (roots) => roots.flatMap((r) => listJsonlFiles(r, 3)),
286
+ sessionId: codexSessionId,
287
+ extractText: extractCodexText,
288
+ },
289
+ };
290
+
291
+ // Text extraction for an already-captured session turn, by its provenance
292
+ // source. Unknown sources index nothing (the blob remains recallable).
293
+ export function extractSessionTextFor(source, bytes) {
294
+ const format = SESSION_FORMATS[source];
295
+ return format ? format.extractText(bytes) : [];
296
+ }
@@ -0,0 +1,155 @@
1
+ // Capture ignore list — a per-root privacy control.
2
+ //
3
+ // `<root>/ignore` is a plain text file of glob patterns, one per line
4
+ // (blank lines and `#` comments skipped). Any capture source file that
5
+ // matches is never captured at all: it is skipped before being opened, so
6
+ // no blob is stored, no turn is appended, and the seen cache never learns
7
+ // about it (removing the pattern later makes the next pass capture the
8
+ // file normally).
9
+ //
10
+ // Matching, deliberately simple (no negation, no escapes):
11
+ // - a pattern containing `/` is matched against the source file's
12
+ // absolute path;
13
+ // - a pattern without `/` is matched against the file's basename and
14
+ // against its session id (for session transcripts) or account name
15
+ // (for aw comm logs);
16
+ // - `*` matches within a path segment, `**` matches across segments,
17
+ // `?` matches one non-separator character; the whole candidate must
18
+ // match. Everything else is literal — there are no regex characters.
19
+ //
20
+ // Matching is NOT compiled to regexes: adjacent wildcards translated to
21
+ // overlapping quantifiers backtrack catastrophically on non-matching
22
+ // candidates (a "*?"-repeated pattern — one typo away from documented
23
+ // syntax — hangs a regex engine for seconds to forever, which would stall
24
+ // every capture pass at 100% CPU). Instead each pattern is tokenized once
25
+ // and matched by dynamic programming, cost bounded by
26
+ // O(pattern length x candidate length), with no backtracking of any kind.
27
+ //
28
+ // The file is local policy, not record truth: it lives beside streams/ and
29
+ // objects/ but the sync guidance replicates only those two, so each machine
30
+ // decides what its own capture process refuses to read. Ignoring is
31
+ // forward-looking only — turns already in the record stay there; use a
32
+ // tombstone to hide those.
33
+
34
+ import { readFileSync } from "node:fs";
35
+ import { join } from "node:path";
36
+
37
+ export class IgnoreError extends Error {}
38
+
39
+ export function ignoreFilePath(root) {
40
+ return join(root, "ignore");
41
+ }
42
+
43
+ export function parseIgnorePatterns(text) {
44
+ const patterns = [];
45
+ for (const rawLine of text.split("\n")) {
46
+ const line = rawLine.trim();
47
+ if (line === "" || line.startsWith("#")) continue;
48
+ patterns.push(line);
49
+ }
50
+ return patterns;
51
+ }
52
+
53
+ // Tokenize a pattern into wildcard ops and literal characters. "**" is one
54
+ // token (any chars), "*" one segment's worth, "?" one non-separator char.
55
+ function tokenize(pattern) {
56
+ const tokens = [];
57
+ for (let i = 0; i < pattern.length; i++) {
58
+ const c = pattern[i];
59
+ if (c === "*") {
60
+ if (pattern[i + 1] === "*") {
61
+ tokens.push("**");
62
+ i++;
63
+ } else {
64
+ tokens.push("*");
65
+ }
66
+ } else if (c === "?") {
67
+ tokens.push("?");
68
+ } else {
69
+ tokens.push({ c });
70
+ }
71
+ }
72
+ return tokens;
73
+ }
74
+
75
+ // Whole-candidate glob match by dynamic programming over (tokens consumed,
76
+ // candidate chars consumed). Row i+1 holds, for each prefix length j of the
77
+ // candidate, whether tokens[0..i] can consume exactly that prefix. Linear
78
+ // passes only; worst case is |tokens| * |candidate| steps regardless of how
79
+ // pathological the pattern is.
80
+ export function globMatch(tokens, s) {
81
+ const n = s.length;
82
+ let prev = new Uint8Array(n + 1); // matches with i tokens consumed
83
+ prev[0] = 1;
84
+ for (const tok of tokens) {
85
+ const cur = new Uint8Array(n + 1);
86
+ if (tok === "**") {
87
+ cur[0] = prev[0];
88
+ for (let j = 1; j <= n; j++) cur[j] = prev[j] || cur[j - 1] ? 1 : 0;
89
+ } else if (tok === "*") {
90
+ cur[0] = prev[0];
91
+ for (let j = 1; j <= n; j++) {
92
+ cur[j] = prev[j] || (cur[j - 1] && s[j - 1] !== "/") ? 1 : 0;
93
+ }
94
+ } else if (tok === "?") {
95
+ for (let j = 1; j <= n; j++) {
96
+ cur[j] = prev[j - 1] && s[j - 1] !== "/" ? 1 : 0;
97
+ }
98
+ } else {
99
+ for (let j = 1; j <= n; j++) {
100
+ cur[j] = prev[j - 1] && s[j - 1] === tok.c ? 1 : 0;
101
+ }
102
+ }
103
+ prev = cur;
104
+ }
105
+ return Boolean(prev[n]);
106
+ }
107
+
108
+ export class IgnoreMatcher {
109
+ constructor(patterns = []) {
110
+ this.patterns = patterns;
111
+ this.pathRules = []; // patterns with "/": match the absolute path
112
+ this.nameRules = []; // patterns without "/": match basename / session id
113
+ for (const pattern of patterns) {
114
+ (pattern.includes("/") ? this.pathRules : this.nameRules).push(tokenize(pattern));
115
+ }
116
+ }
117
+
118
+ get size() {
119
+ return this.patterns.length;
120
+ }
121
+
122
+ // path: absolute source file path. names: basename, session id / account.
123
+ ignores(path, names = []) {
124
+ if (this.patterns.length === 0) return false;
125
+ for (const rule of this.pathRules) {
126
+ if (globMatch(rule, path)) return true;
127
+ }
128
+ for (const rule of this.nameRules) {
129
+ for (const name of names) {
130
+ if (typeof name === "string" && globMatch(rule, name)) return true;
131
+ }
132
+ }
133
+ return false;
134
+ }
135
+ }
136
+
137
+ // Load `<root>/ignore`. A missing file is an empty matcher; a file that
138
+ // exists but cannot be read is an error — a privacy control must not fail
139
+ // open silently — raised as IgnoreError with an actionable message.
140
+ export function loadIgnore(root) {
141
+ const path = ignoreFilePath(root);
142
+ let text;
143
+ try {
144
+ text = readFileSync(path, "utf8");
145
+ } catch (err) {
146
+ if (err.code === "ENOENT") return new IgnoreMatcher([]);
147
+ throw new IgnoreError(
148
+ `cannot read capture ignore file ${path} (${err.code ?? err.message}); ` +
149
+ `capture refuses to run with an unreadable privacy control — ` +
150
+ `make it a readable text file of patterns, or remove it`,
151
+ { cause: err },
152
+ );
153
+ }
154
+ return new IgnoreMatcher(parseIgnorePatterns(text));
155
+ }