@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.
- package/LICENSE +21 -0
- package/README.md +380 -0
- package/bin/oats.mjs +3294 -0
- package/capabilities/oats-authoring/oats.json +12 -0
- package/capabilities/oats-aweb/bin/oats-aweb.mjs +297 -0
- package/capabilities/oats-aweb/injects/aweb.md +55 -0
- package/capabilities/oats-aweb/oats.json +47 -0
- package/capabilities/oats-aweb/package.json +20 -0
- package/capabilities/oats-jira/bin/oats-jira.mjs +40 -0
- package/capabilities/oats-jira/injects/jira.md +10 -0
- package/capabilities/oats-jira/oats.json +22 -0
- package/capabilities/oats-jira/package.json +25 -0
- package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +176 -0
- package/capabilities/oats-linear/README.md +234 -0
- package/capabilities/oats-linear/bin/oats-linear-hook.mjs +32 -0
- package/capabilities/oats-linear/bin/oats-linear.mjs +344 -0
- package/capabilities/oats-linear/injects/linear.md +8 -0
- package/capabilities/oats-linear/oats.json +24 -0
- package/capabilities/oats-linear/package.json +29 -0
- package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +217 -0
- package/capabilities/oats-linear/test/oats-linear.test.mjs +168 -0
- package/capabilities/oats-okf/agents/memory-harvest.md +25 -0
- package/capabilities/oats-okf/bin/oats-okf.mjs +283 -0
- package/capabilities/oats-okf/injects/okf.md +62 -0
- package/capabilities/oats-okf/oats.json +20 -0
- package/capabilities/oats-okf/package.json +22 -0
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +100 -0
- package/capabilities/oats-okf/skills/okf/SKILL.md +107 -0
- package/capabilities/oats-okf/skills/okf/scripts/okf-validate.mjs +123 -0
- package/capabilities/oats-review/agents/reviewer/AGENTS.md +53 -0
- package/capabilities/oats-review/agents/reviewer/soul.yaml +6 -0
- package/capabilities/oats-review/injects/review.md +68 -0
- package/capabilities/oats-review/oats.json +10 -0
- package/capabilities/oats-review/skills/code-review/SKILL.md +44 -0
- package/capabilities/oats-review/skills/security-review/SKILL.md +59 -0
- package/docs/capabilities.md +505 -0
- package/docs/capability-manifest.schema.json +223 -0
- package/docs/configuration.md +482 -0
- package/docs/conventions.md +73 -0
- package/docs/design/desktop-ux-plan.md +362 -0
- package/docs/design/package-engine-contract.md +813 -0
- package/docs/design/package-runtime-api.md +414 -0
- package/docs/desktop-cli-api.md +89 -0
- package/docs/desktop-succession.md +51 -0
- package/docs/desktop.md +187 -0
- package/docs/implementation.md +282 -0
- package/docs/integrations.md +123 -0
- package/docs/knowledge-theory.md +97 -0
- package/docs/knowledge.md +139 -0
- package/docs/layers.md +110 -0
- package/docs/migration-from-oas.md +122 -0
- package/docs/oats-config.schema.json +140 -0
- package/docs/oats-lock.schema.json +175 -0
- package/docs/oats-package.schema.json +129 -0
- package/docs/packages.md +442 -0
- package/docs/release-lane.md +132 -0
- package/docs/release-notes/v0.18.2.md +79 -0
- package/docs/release-notes/v0.18.3.md +63 -0
- package/docs/release-notes/v0.18.4.md +65 -0
- package/docs/release-notes/v0.18.5.md +66 -0
- package/docs/release-notes/v0.18.6.md +87 -0
- package/docs/release-notes/v0.19.0.md +186 -0
- package/docs/release-notes/v0.19.1.md +24 -0
- package/docs/release-notes/v0.19.2.md +32 -0
- package/docs/release-notes/v0.19.3.md +16 -0
- package/docs/release-notes/v0.19.4.md +12 -0
- package/docs/release-notes/v0.20.0.md +89 -0
- package/docs/release-notes/v0.22.0.md +180 -0
- package/docs/souls-and-instances.md +344 -0
- package/injects/framework-workspace.md +7 -0
- package/injects/instance-boundary.md +43 -0
- package/injects/local-soul.md +19 -0
- package/injects/oats.md +11 -0
- package/injects/work-attached.md +16 -0
- package/injects/work-checkout.md +12 -0
- package/injects/work-workspace.md +22 -0
- package/injects/work-worktree.md +13 -0
- package/lib/core.mjs +6792 -0
- package/lib/packages.mjs +1375 -0
- package/lib/tmux-config.mjs +35 -0
- package/package-catalog.json +44 -0
- package/package.json +55 -0
- package/packages/record/README.md +151 -0
- package/packages/record/bin/capture.mjs +256 -0
- package/packages/record/bin/recall.mjs +102 -0
- package/packages/record/bin/setup.mjs +281 -0
- package/packages/record/bin/turn-record.mjs +47 -0
- package/packages/record/docs/turn-record-sot.md +350 -0
- package/packages/record/lib/canonical.mjs +151 -0
- package/packages/record/lib/capture-aw.mjs +179 -0
- package/packages/record/lib/capture-cc.mjs +322 -0
- package/packages/record/lib/formats.mjs +296 -0
- package/packages/record/lib/ignore.mjs +155 -0
- package/packages/record/lib/index-db.mjs +524 -0
- package/packages/record/lib/project-aweb.mjs +251 -0
- package/packages/record/lib/segments.mjs +186 -0
- package/packages/record/lib/store.mjs +447 -0
- package/packages/record/lib/tags.mjs +114 -0
- package/packages/record/package.json +45 -0
- package/packages/record/test/vectors/README.md +34 -0
- package/packages/record/test/vectors/aweb-projection-v1.json +231 -0
- package/packages/record/test/vectors/journal-merge-v1.json +261 -0
- package/packages/record/test/vectors/turn-id-v1.json +152 -0
- package/packages/record/test/vectors/validate.mjs +391 -0
- package/skills/integration-authoring/SKILL.md +76 -0
- package/skills/oats/SKILL.md +151 -0
- package/skills/oats-config/SKILL.md +162 -0
- package/skills/oats-getting-started/SKILL.md +159 -0
- package/skills/oats-packages/SKILL.md +180 -0
- package/skills/oats-support/SKILL.md +79 -0
- package/skills/skill-craft/SKILL.md +109 -0
- 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
|
+
}
|