chainfess 0.0.0-stage → 0.1.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/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/bin/chainfess.mjs +7 -0
- package/fixtures/demo/rpc.json +50 -0
- package/fixtures/demo/session.jsonl +8 -0
- package/package.json +11 -4
- package/src/audit.mjs +344 -0
- package/src/blocks.mjs +127 -0
- package/src/canonical.mjs +19 -0
- package/src/classify.mjs +111 -0
- package/src/cli.mjs +231 -0
- package/src/hook.mjs +202 -0
- package/src/keccak.mjs +75 -0
- package/src/ledger.mjs +216 -0
- package/src/parse.mjs +621 -0
- package/src/pubkey.mjs +5 -0
- package/src/replay.mjs +67 -0
- package/src/report.mjs +273 -0
- package/src/rpc.mjs +225 -0
- package/src/sign.mjs +139 -0
package/src/parse.mjs
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
// Claude Code log parsing (BRIEF §2.1, SPIKE). Streaming, memory bounded.
|
|
2
|
+
// Command text and free text are read here and never retained: only hex tokens, numbers and pattern names leave.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { createReadStream, existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
5
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { basename, dirname, extname, isAbsolute, join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
export const MAX_TOOL_RESULT_BYTES = 512 * 1024 * 1024;
|
|
10
|
+
export const MAX_LINE_CHARS = 16 * 1024 * 1024;
|
|
11
|
+
const MAX_BROADCAST_BYTES = 5 * 1024 * 1024;
|
|
12
|
+
const MAX_BARE = 200000;
|
|
13
|
+
const MAX_PARTIAL = 100000;
|
|
14
|
+
const OVERLAP = 4096;
|
|
15
|
+
const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.ico', '.heic', '.avif']);
|
|
16
|
+
|
|
17
|
+
const FULL_RE = /0x[0-9a-fA-F]{64}(?![0-9a-fA-F])/g;
|
|
18
|
+
const BARE_RE = /(?<![0-9a-fA-Fx])[0-9a-fA-F]{64}(?![0-9a-fA-F])/g;
|
|
19
|
+
const PARTIAL_RE = /0x([0-9a-fA-F]{4,20})(?:…|\.{2,3}|\\u2026)(?:([0-9a-fA-F]{4,8})(?![0-9a-fA-F]))?/g;
|
|
20
|
+
// A private key has the exact shape of a tx hash, so a 32-byte value is local-only unless its context says it is a hash.
|
|
21
|
+
// Next to one of these words, anywhere in the log, the value is never sent to any RPC, whatever else says about it.
|
|
22
|
+
const KEY_CONTEXT_RE = /(priv(ate)?[\s_\-]?key|privkey|secret|mnemonic|seed|passw|token|signing[\s_\-]?key|_key\b|\bkey\b|--key|\bpk\b|wallet\s*\(|signer\s*\(|account\s*\()[^0-9a-fA-F]{0,32}(\(\d+\)\s*)?["'`]?$/i;
|
|
23
|
+
// The same words on the value's line or in its JSON object beat any positive context.
|
|
24
|
+
const KEY_WORDS_RE = /key|secret|mnemonic|seed|passw|token|private|\bpk\b/i;
|
|
25
|
+
const ENV_ASSIGN_RE = /(^|[\s;&"'])(export\s+[A-Za-z_]\w*|[A-Z][A-Z0-9_]*)=["'`\\]*$/;
|
|
26
|
+
// Positive context: what immediately precedes the value says it is a tx hash.
|
|
27
|
+
const HASH_LABEL_RE = /(transaction[\s_-]?hash|tx[\s_-]?hash|txn[\s_-]?hash|(^|[^A-Za-z0-9_])hash)[\s"'`\\:=]*$/i;
|
|
28
|
+
const EXPLORER_RE = /\/tx\/$/;
|
|
29
|
+
const READ_ARG_RE = /\bcast\s+(tx|receipt|run)\s+([^\s|;&]+\s+)*["'`\\]*$|eth_getTransaction(ByHash|Receipt)[^|;&]{0,80}$/;
|
|
30
|
+
const RAW_RESULT_RE = /["\\]*result["\\]*\s*:\s*["\\]*$/;
|
|
31
|
+
// A sending command that could also print something else does not vouch for bare hex in its output.
|
|
32
|
+
const PRINTS_OTHER_RE = /\b(cat|echo|printf|print|printenv|env|set|grep|rg|sed|awk|head|tail|less|more|jq|python3?|node|bun|deno|tsx|ts-node|npx|curl|wget)\b/;
|
|
33
|
+
const VOUCHING_PATTERNS = new Set(['cast-send', 'cast-publish', 'forge-broadcast', 'forge-create']);
|
|
34
|
+
const LINE_BREAK_RE = /\r|\n|\\n|\\r/g;
|
|
35
|
+
const MAX_CONTEXT = 65536;
|
|
36
|
+
const OBJECT_WINDOW = 4096;
|
|
37
|
+
|
|
38
|
+
const PATTERNS = [
|
|
39
|
+
['cast-send', (c) => /\bcast\s+send\b/.test(c)],
|
|
40
|
+
['cast-publish', (c) => /\bcast\s+publish\b/.test(c) || /\bcast\s+mktx\b/.test(c)],
|
|
41
|
+
['forge-broadcast', (c) => /\bforge\s+script\b[\s\S]*--broadcast/.test(c)],
|
|
42
|
+
['forge-create', (c) => /\bforge\s+create\b/.test(c)],
|
|
43
|
+
['raw-rpc', (c) => /eth_sendRawTransaction|eth_sendTransaction/.test(c)],
|
|
44
|
+
['script-with-key', (c) => /(^|[^\w-])(node|npx|bun|deno|python3?|ts-node|tsx)\b/.test(c) && /--private-key|PRIVATE_KEY|MNEMONIC/.test(c)],
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/** Pure. Returns a pattern name or null; the input is not retained. */
|
|
48
|
+
export function sendingPattern(toolName, input) {
|
|
49
|
+
if (toolName !== 'Bash' && toolName !== 'bash') return null;
|
|
50
|
+
let cmd = '';
|
|
51
|
+
if (input && typeof input.command === 'string') cmd = input.command;
|
|
52
|
+
else if (input !== undefined) cmd = JSON.stringify(input);
|
|
53
|
+
for (const [name, test] of PATTERNS) if (test(cmd)) return name;
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function slugOf(path) {
|
|
58
|
+
return path.replace(/[^A-Za-z0-9-]/g, '-');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function claudeProjectsDir() {
|
|
62
|
+
const base = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
|
63
|
+
return join(base, 'projects');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readHead(path, bytes) {
|
|
67
|
+
const fd = openSync(path, 'r');
|
|
68
|
+
try {
|
|
69
|
+
const buf = Buffer.alloc(bytes);
|
|
70
|
+
const n = readSync(fd, buf, 0, bytes, 0);
|
|
71
|
+
return buf.subarray(0, n).toString('utf8');
|
|
72
|
+
} finally { closeSync(fd); }
|
|
73
|
+
}
|
|
74
|
+
function readTail(path, bytes) {
|
|
75
|
+
const fd = openSync(path, 'r');
|
|
76
|
+
try {
|
|
77
|
+
const size = statSync(path).size;
|
|
78
|
+
const start = Math.max(0, size - bytes);
|
|
79
|
+
const buf = Buffer.alloc(Math.min(size, bytes));
|
|
80
|
+
const n = readSync(fd, buf, 0, buf.length, start);
|
|
81
|
+
return buf.subarray(0, n).toString('utf8');
|
|
82
|
+
} finally { closeSync(fd); }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const listLogs = (dir) => {
|
|
86
|
+
try {
|
|
87
|
+
return readdirSync(dir).filter((f) => f.endsWith('.jsonl')).map((f) => join(dir, f));
|
|
88
|
+
} catch { return []; }
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Project dir of a cwd: slug of the real path, then a scan of logs whose `cwd` field matches. */
|
|
92
|
+
export function findProjectDir(cwd, projects = claudeProjectsDir()) {
|
|
93
|
+
let real = cwd;
|
|
94
|
+
try { real = realpathSync(cwd); } catch { /* keep as given */ }
|
|
95
|
+
const candidates = [join(projects, slugOf(real)), join(projects, slugOf(cwd))];
|
|
96
|
+
for (const c of candidates) if (listLogs(c).length) return { dir: c, lookedIn: candidates[0] };
|
|
97
|
+
let names = [];
|
|
98
|
+
try { names = readdirSync(projects); } catch { /* no projects dir */ }
|
|
99
|
+
const needles = [JSON.stringify(real), JSON.stringify(cwd)].map((s) => `"cwd":${s}`);
|
|
100
|
+
for (const name of names) {
|
|
101
|
+
const dir = join(projects, name);
|
|
102
|
+
for (const log of listLogs(dir).slice(0, 50)) {
|
|
103
|
+
try {
|
|
104
|
+
const head = readHead(log, 128 * 1024);
|
|
105
|
+
if (needles.some((n) => head.includes(n))) return { dir, lookedIn: candidates[0] };
|
|
106
|
+
} catch { /* unreadable, next */ }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { dir: null, lookedIn: candidates[0] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function newestLog(dir) {
|
|
113
|
+
let best = null;
|
|
114
|
+
let bestT = -1;
|
|
115
|
+
for (const log of listLogs(dir)) {
|
|
116
|
+
const t = statSync(log).mtimeMs;
|
|
117
|
+
if (t > bestT) { bestT = t; best = log; }
|
|
118
|
+
}
|
|
119
|
+
return best;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Same-project session files whose first..last timestamps overlap the range. Reads only head and tail. */
|
|
123
|
+
export function overlappingLogs(mainPaths, tStartMs, tEndMs) {
|
|
124
|
+
const mains = new Set(mainPaths.map((p) => join(dirname(p), basename(p))));
|
|
125
|
+
const dirs = new Set(mainPaths.map((p) => dirname(p)));
|
|
126
|
+
const out = [];
|
|
127
|
+
const tsRe = /"timestamp":"([^"]+)"/g;
|
|
128
|
+
for (const dir of dirs) {
|
|
129
|
+
for (const log of listLogs(dir)) {
|
|
130
|
+
if (mains.has(log)) continue;
|
|
131
|
+
try {
|
|
132
|
+
if (statSync(log).mtimeMs < tStartMs) continue;
|
|
133
|
+
const first = [...readHead(log, 256 * 1024).matchAll(tsRe)].map((m) => Date.parse(m[1])).filter(Number.isFinite);
|
|
134
|
+
const last = [...readTail(log, 256 * 1024).matchAll(tsRe)].map((m) => Date.parse(m[1])).filter(Number.isFinite);
|
|
135
|
+
if (!first.length || !last.length) continue;
|
|
136
|
+
const a = Math.min(...first);
|
|
137
|
+
const b = Math.max(...last);
|
|
138
|
+
if (a <= tEndMs && b >= tStartMs) out.push(log);
|
|
139
|
+
} catch { /* unreadable: not counted */ }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function createSink() {
|
|
146
|
+
return {
|
|
147
|
+
hashes: new Map(), // lower hash -> { first, other }
|
|
148
|
+
bare: new Map(), // 64 hex with no 0x -> occurrence; matched locally, never sent anywhere
|
|
149
|
+
partials: new Map(), // "prefix|suffix" -> occurrence
|
|
150
|
+
keyLike: new Set(), // full candidates seen in key context: never sent
|
|
151
|
+
positive: new Set(), // full candidates seen in hash context: the only ones that may be sent
|
|
152
|
+
tools: new Map(), // tool id -> { start, end, pattern }
|
|
153
|
+
textFull: new Set(), // mentioned in assistant text
|
|
154
|
+
textPartials: new Set(),
|
|
155
|
+
finals: new Map(), // sessionId -> { full:Set, partials:Set }
|
|
156
|
+
referenced: new Map(), // persisted file path -> occurrence meta
|
|
157
|
+
tStart: null, tEnd: null,
|
|
158
|
+
validEntries: 0, timestamped: 0, unreadableLines: 0, seenLines: 0,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function earlier(a, b) {
|
|
163
|
+
if (!b) return true;
|
|
164
|
+
if (a.ts === null) return false;
|
|
165
|
+
if (b.ts === null) return true;
|
|
166
|
+
return a.ts < b.ts;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function addFull(sink, key, occ) {
|
|
170
|
+
let rec = sink.hashes.get(key);
|
|
171
|
+
if (!rec) { rec = { first: null, other: null }; sink.hashes.set(key, rec); }
|
|
172
|
+
if (occ.kind === 'other') { if (earlier(occ, rec.other)) rec.other = occ; } else if (earlier(occ, rec.first)) rec.first = occ;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Finds hex tokens in a piece of text. `locate(token)` returns the occurrence to record. */
|
|
176
|
+
function harvest(sink, text, locate, accept = () => true) {
|
|
177
|
+
if (text.indexOf('0x') !== -1) {
|
|
178
|
+
FULL_RE.lastIndex = 0;
|
|
179
|
+
for (let m; (m = FULL_RE.exec(text));) {
|
|
180
|
+
if (!accept(m.index + m[0].length)) continue;
|
|
181
|
+
const key = m[0].toLowerCase();
|
|
182
|
+
if (KEY_CONTEXT_RE.test(text.slice(Math.max(0, m.index - 64), m.index))) sink.keyLike.add(key);
|
|
183
|
+
addFull(sink, key, locate(m[0]));
|
|
184
|
+
}
|
|
185
|
+
PARTIAL_RE.lastIndex = 0;
|
|
186
|
+
for (let m; (m = PARTIAL_RE.exec(text));) {
|
|
187
|
+
const prefix = m[1].toLowerCase();
|
|
188
|
+
const suffix = m[2] ? m[2].toLowerCase() : '';
|
|
189
|
+
if (suffix ? prefix.length > 12 : prefix.length < 8) continue;
|
|
190
|
+
const key = `${prefix}|${suffix}`;
|
|
191
|
+
if (!sink.partials.has(key) && sink.partials.size < MAX_PARTIAL) sink.partials.set(key, locate('0x' + m[1]));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (text.length >= 64) {
|
|
195
|
+
BARE_RE.lastIndex = 0;
|
|
196
|
+
for (let m; (m = BARE_RE.exec(text));) {
|
|
197
|
+
if (!accept(m.index + m[0].length)) continue;
|
|
198
|
+
const key = m[0].toLowerCase();
|
|
199
|
+
if (!sink.bare.has(key) && sink.bare.size < MAX_BARE) sink.bare.set(key, locate(m[0]));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function enclosingObject(text, index) {
|
|
205
|
+
let depth = 0;
|
|
206
|
+
let open = -1;
|
|
207
|
+
for (let i = index - 1; i >= Math.max(0, index - OBJECT_WINDOW); i--) {
|
|
208
|
+
const c = text[i];
|
|
209
|
+
if (c === '}') depth++;
|
|
210
|
+
else if (c === '{') { if (depth === 0) { open = i; break; } depth--; }
|
|
211
|
+
}
|
|
212
|
+
if (open === -1) return null;
|
|
213
|
+
depth = 0;
|
|
214
|
+
for (let i = index; i < Math.min(text.length, index + OBJECT_WINDOW); i++) {
|
|
215
|
+
const c = text[i];
|
|
216
|
+
if (c === '{') depth++;
|
|
217
|
+
else if (c === '}') { if (depth === 0) return text.slice(open, i + 1); depth--; }
|
|
218
|
+
}
|
|
219
|
+
return text.slice(open, Math.min(text.length, index + OBJECT_WINDOW));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function lineAround(text, index, end) {
|
|
223
|
+
let a = index;
|
|
224
|
+
const lo = Math.max(0, index - MAX_CONTEXT);
|
|
225
|
+
LINE_BREAK_RE.lastIndex = lo;
|
|
226
|
+
let m;
|
|
227
|
+
let start = lo;
|
|
228
|
+
while ((m = LINE_BREAK_RE.exec(text)) && m.index < index) start = m.index + m[0].length;
|
|
229
|
+
a = start;
|
|
230
|
+
LINE_BREAK_RE.lastIndex = end;
|
|
231
|
+
const next = LINE_BREAK_RE.exec(text);
|
|
232
|
+
const b = next && next.index - end < MAX_CONTEXT ? next.index : Math.min(text.length, end + MAX_CONTEXT);
|
|
233
|
+
return { a, b, touchesEnd: !next };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The send rule. Sets keyLike or positive for every full candidate in one decoded piece of text.
|
|
238
|
+
* tool: the tool call this text is the output of, or null. complete: false when the text is a slice of a bigger file.
|
|
239
|
+
* Default for anything else: neither set, so the value stays local.
|
|
240
|
+
*/
|
|
241
|
+
export function judgeText(sink, text, { tool = null, isOutput = false, atStart = true, atEnd = true } = {}) {
|
|
242
|
+
if (text.indexOf('0x') === -1) return;
|
|
243
|
+
FULL_RE.lastIndex = 0;
|
|
244
|
+
const vouches = isOutput && tool && tool.plainSend;
|
|
245
|
+
const rawRpc = isOutput && tool && tool.pattern === 'raw-rpc';
|
|
246
|
+
for (let m; (m = FULL_RE.exec(text));) {
|
|
247
|
+
const value = m[0].toLowerCase();
|
|
248
|
+
const endIdx = m.index + m[0].length;
|
|
249
|
+
const savedLast = FULL_RE.lastIndex;
|
|
250
|
+
const { a, b, touchesEnd } = lineAround(text, m.index, endIdx);
|
|
251
|
+
const before = text.slice(a, m.index);
|
|
252
|
+
const after = text.slice(endIdx, b);
|
|
253
|
+
const object = enclosingObject(text, m.index);
|
|
254
|
+
// A value introduced as a key is vetoed for the whole log. A key word merely elsewhere on the line or in the
|
|
255
|
+
// object only stops this occurrence from vouching: prose like "the token deploy tx 0x…" must not erase a labelled hash.
|
|
256
|
+
if (ENV_ASSIGN_RE.test(before) || KEY_CONTEXT_RE.test(before.slice(-64))) {
|
|
257
|
+
sink.keyLike.add(value);
|
|
258
|
+
} else if (!KEY_WORDS_RE.test(before) && !KEY_WORDS_RE.test(after) && !(object && KEY_WORDS_RE.test(object))) {
|
|
259
|
+
// A line cut by a chunk edge may hold a key word in the part not seen yet: the next slice decides.
|
|
260
|
+
const cut = (a === 0 && !atStart) || (touchesEnd && !atEnd);
|
|
261
|
+
if (!cut) {
|
|
262
|
+
const labelled = HASH_LABEL_RE.test(before) || EXPLORER_RE.test(before) || READ_ARG_RE.test(before);
|
|
263
|
+
const bareLine = vouches && /^[\s"'`\\]*$/.test(before) && /^[\s"'`\\]*$/.test(after);
|
|
264
|
+
const rawResult = rawRpc && RAW_RESULT_RE.test(before);
|
|
265
|
+
if (labelled || bareLine || rawResult) sink.positive.add(value);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
FULL_RE.lastIndex = savedLast;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function resultText(content) {
|
|
273
|
+
if (typeof content === 'string') return content;
|
|
274
|
+
if (Array.isArray(content)) {
|
|
275
|
+
return content.map((b) => (b && typeof b.text === 'string' ? b.text : typeof b === 'string' ? b : '')).join('\n');
|
|
276
|
+
}
|
|
277
|
+
return content === undefined || content === null ? '' : JSON.stringify(content);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function notePersisted(sink, path, meta) {
|
|
281
|
+
if (!path || typeof path !== 'string' || !isAbsolute(path) || !path.includes('/tool-results/')) return;
|
|
282
|
+
if (!sink.referenced.has(path)) sink.referenced.set(path, meta);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const SAVED_RES = [/Full output saved to: (\/[^\n"\\]+)/g, /Output has been saved to (\/[^\n"\\]+?\.(?:txt|json|html|md|log))/g];
|
|
286
|
+
|
|
287
|
+
function findPersistedPaths(sink, text, meta) {
|
|
288
|
+
if (text.indexOf('saved to') === -1) return;
|
|
289
|
+
for (const re of SAVED_RES) {
|
|
290
|
+
re.lastIndex = 0;
|
|
291
|
+
for (let m; (m = re.exec(text));) notePersisted(sink, m[1].trim(), meta);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function collectMentions(sink, text, final) {
|
|
296
|
+
if (text.indexOf('0x') === -1) return;
|
|
297
|
+
FULL_RE.lastIndex = 0;
|
|
298
|
+
for (let m; (m = FULL_RE.exec(text));) {
|
|
299
|
+
const key = m[0].toLowerCase();
|
|
300
|
+
sink.textFull.add(key);
|
|
301
|
+
if (final) final.full.add(key);
|
|
302
|
+
}
|
|
303
|
+
PARTIAL_RE.lastIndex = 0;
|
|
304
|
+
for (let m; (m = PARTIAL_RE.exec(text));) {
|
|
305
|
+
const prefix = m[1].toLowerCase();
|
|
306
|
+
const suffix = m[2] ? m[2].toLowerCase() : '';
|
|
307
|
+
if (suffix ? prefix.length > 12 : prefix.length < 8) continue;
|
|
308
|
+
const key = `${prefix}|${suffix}`;
|
|
309
|
+
sink.textPartials.add(key);
|
|
310
|
+
if (final) final.partials.add(key);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Handles one complete log line. Exported for tests. */
|
|
315
|
+
export function handleLine(sink, raw, lineNo, ctx) {
|
|
316
|
+
sink.seenLines++;
|
|
317
|
+
let entry = null;
|
|
318
|
+
try { entry = JSON.parse(raw); } catch { /* counted below */ }
|
|
319
|
+
if (!entry || typeof entry !== 'object') {
|
|
320
|
+
sink.unreadableLines++;
|
|
321
|
+
harvest(sink, raw, () => ({ kind: 'other', origin: ctx.origin, line: lineNo, ts: ctx.lastTs, toolId: null }));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
sink.validEntries++;
|
|
325
|
+
const parsedTs = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
|
|
326
|
+
const hasTs = Number.isFinite(parsedTs);
|
|
327
|
+
if (hasTs) ctx.lastTs = parsedTs;
|
|
328
|
+
const ts = ctx.lastTs;
|
|
329
|
+
const isMessage = entry.type === 'user' || entry.type === 'assistant';
|
|
330
|
+
if (hasTs && isMessage && !ctx.evidenceOnly) {
|
|
331
|
+
sink.timestamped++;
|
|
332
|
+
if (sink.tStart === null || parsedTs < sink.tStart) sink.tStart = parsedTs;
|
|
333
|
+
if (sink.tEnd === null || parsedTs > sink.tEnd) sink.tEnd = parsedTs;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const blocks = [];
|
|
337
|
+
if (isMessage) {
|
|
338
|
+
const content = entry.message && entry.message.content;
|
|
339
|
+
let firstResultId = null;
|
|
340
|
+
let hasToolUse = false;
|
|
341
|
+
let finalText = null;
|
|
342
|
+
const list = typeof content === 'string' ? [{ type: 'text', text: content }] : Array.isArray(content) ? content : [];
|
|
343
|
+
for (const b of list) {
|
|
344
|
+
if (!b || typeof b !== 'object') continue;
|
|
345
|
+
if (b.type === 'text' && typeof b.text === 'string') {
|
|
346
|
+
blocks.push({ kind: 'text', toolId: null, text: b.text });
|
|
347
|
+
judgeText(sink, b.text);
|
|
348
|
+
if (entry.type === 'assistant') {
|
|
349
|
+
const t = b.text.trim();
|
|
350
|
+
if (t && t !== '(no content)') finalText = finalText === null ? b.text : `${finalText}\n${b.text}`;
|
|
351
|
+
collectMentions(sink, b.text, null);
|
|
352
|
+
}
|
|
353
|
+
} else if (b.type === 'tool_use' && entry.type === 'assistant') {
|
|
354
|
+
hasToolUse = true;
|
|
355
|
+
const id = typeof b.id === 'string' ? b.id : null;
|
|
356
|
+
if (id) {
|
|
357
|
+
const pattern = sendingPattern(b.name, b.input);
|
|
358
|
+
const command = b.input && typeof b.input.command === 'string' ? b.input.command : '';
|
|
359
|
+
const plainSend = VOUCHING_PATTERNS.has(pattern) && !PRINTS_OTHER_RE.test(command);
|
|
360
|
+
const prev = sink.tools.get(id);
|
|
361
|
+
sink.tools.set(id, { start: prev && prev.start !== null ? Math.min(prev.start, ts ?? Infinity) : ts, end: prev ? prev.end : null, pattern, plainSend });
|
|
362
|
+
}
|
|
363
|
+
const inputText = b.input === undefined ? '' : JSON.stringify(b.input);
|
|
364
|
+
blocks.push({ kind: 'tool_use', toolId: id, text: inputText });
|
|
365
|
+
judgeText(sink, b.input && typeof b.input.command === 'string' ? b.input.command : inputText);
|
|
366
|
+
} else if (b.type === 'tool_result') {
|
|
367
|
+
const id = typeof b.tool_use_id === 'string' ? b.tool_use_id : null;
|
|
368
|
+
if (firstResultId === null) firstResultId = id;
|
|
369
|
+
if (id) {
|
|
370
|
+
const prev = sink.tools.get(id);
|
|
371
|
+
if (prev) { if (prev.end === null) prev.end = ts; } else sink.tools.set(id, { start: null, end: ts, pattern: null, plainSend: false });
|
|
372
|
+
}
|
|
373
|
+
const text = resultText(b.content);
|
|
374
|
+
blocks.push({ kind: 'tool_result', toolId: id, text });
|
|
375
|
+
judgeText(sink, text, { tool: id ? sink.tools.get(id) : null, isOutput: true });
|
|
376
|
+
findPersistedPaths(sink, text, { line: lineNo, ts, toolId: id, origin: ctx.origin });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const tur = entry.toolUseResult;
|
|
380
|
+
if (tur !== undefined && tur !== null) {
|
|
381
|
+
const text = typeof tur === 'string' ? tur : JSON.stringify(tur);
|
|
382
|
+
blocks.push({ kind: 'tool_result', toolId: firstResultId, text });
|
|
383
|
+
const turTool = firstResultId ? sink.tools.get(firstResultId) : null;
|
|
384
|
+
if (typeof tur === 'object') {
|
|
385
|
+
for (const part of [tur.stdout, tur.stderr]) if (typeof part === 'string') judgeText(sink, part, { tool: turTool, isOutput: true });
|
|
386
|
+
} else judgeText(sink, text, { tool: turTool, isOutput: true });
|
|
387
|
+
if (typeof tur === 'object' && typeof tur.persistedOutputPath === 'string') {
|
|
388
|
+
notePersisted(sink, tur.persistedOutputPath, { line: lineNo, ts, toolId: firstResultId, origin: ctx.origin });
|
|
389
|
+
} else if (typeof tur === 'string') {
|
|
390
|
+
findPersistedPaths(sink, tur, { line: lineNo, ts, toolId: firstResultId, origin: ctx.origin });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// Final assistant message (§2.4): main log only, not a sidechain, last text with no later tool_use.
|
|
394
|
+
if (ctx.origin === 'main' && entry.type === 'assistant') {
|
|
395
|
+
const sid = entry.sessionId || '';
|
|
396
|
+
if (hasToolUse) sink.finals.set(sid, null);
|
|
397
|
+
else if (finalText !== null && !entry.isSidechain) {
|
|
398
|
+
const rec = { full: new Set(), partials: new Set() };
|
|
399
|
+
collectMentions(sink, finalText, rec);
|
|
400
|
+
sink.finals.set(sid, rec);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const locate = (token) => {
|
|
406
|
+
const needle = token;
|
|
407
|
+
for (const b of blocks) {
|
|
408
|
+
if (b.text.includes(needle)) return { kind: b.kind, origin: ctx.origin, line: lineNo, ts, toolId: b.toolId };
|
|
409
|
+
}
|
|
410
|
+
return { kind: 'other', origin: ctx.origin, line: lineNo, ts, toolId: null };
|
|
411
|
+
};
|
|
412
|
+
harvest(sink, raw, locate);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Scans a whole file in chunks with overlap; lines are not assumed. */
|
|
416
|
+
async function scanFileText(sink, path, meta) {
|
|
417
|
+
const decoder = new StringDecoder('utf8');
|
|
418
|
+
let tail = '';
|
|
419
|
+
const locate = () => ({ kind: 'tool-results', origin: meta.origin, line: meta.line, ts: meta.ts, toolId: meta.toolId });
|
|
420
|
+
const stream = createReadStream(path, { highWaterMark: 1024 * 1024 });
|
|
421
|
+
const tool = meta.toolId ? sink.tools.get(meta.toolId) : null;
|
|
422
|
+
let atStart = true;
|
|
423
|
+
for await (const chunk of stream) {
|
|
424
|
+
const text = tail + decoder.write(chunk);
|
|
425
|
+
const safeEnd = text.length - 1;
|
|
426
|
+
harvest(sink, text, locate, (end) => end < safeEnd);
|
|
427
|
+
judgeText(sink, text, { tool, isOutput: true, atStart, atEnd: false });
|
|
428
|
+
atStart = atStart && text.length <= OVERLAP; // the kept tail still starts the file
|
|
429
|
+
tail = text.slice(-OVERLAP);
|
|
430
|
+
}
|
|
431
|
+
const last = tail + decoder.end();
|
|
432
|
+
harvest(sink, last, locate);
|
|
433
|
+
judgeText(sink, last, { tool, isOutput: true, atStart, atEnd: true });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function resolvePersisted(path, sessionDirs) {
|
|
437
|
+
try { if (lstatSync(path).isFile()) return path; } catch { /* try the session dirs */ }
|
|
438
|
+
for (const dir of sessionDirs) {
|
|
439
|
+
const alt = join(dir, 'tool-results', basename(path));
|
|
440
|
+
try { if (lstatSync(alt).isFile()) return alt; } catch { /* next */ }
|
|
441
|
+
}
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Reads one .jsonl line by line. Lines over MAX_LINE_CHARS are scanned in pieces, not parsed. */
|
|
446
|
+
export async function parseLogFile(sink, path, ctx, { hash = null, onBytes = null, maxLineChars = MAX_LINE_CHARS } = {}) {
|
|
447
|
+
const decoder = new StringDecoder('utf8');
|
|
448
|
+
let buf = '';
|
|
449
|
+
let lineNo = 0;
|
|
450
|
+
let oversized = false;
|
|
451
|
+
let oversizeTail = '';
|
|
452
|
+
const stream = createReadStream(path, { highWaterMark: 1024 * 1024 });
|
|
453
|
+
const overLocate = () => ({ kind: 'other', origin: ctx.origin, line: lineNo + 1, ts: ctx.lastTs, toolId: null });
|
|
454
|
+
for await (const chunk of stream) {
|
|
455
|
+
if (hash) hash.update(chunk);
|
|
456
|
+
if (onBytes) onBytes(chunk.length);
|
|
457
|
+
buf += decoder.write(chunk);
|
|
458
|
+
let start = 0;
|
|
459
|
+
for (;;) {
|
|
460
|
+
const nl = buf.indexOf('\n', start);
|
|
461
|
+
if (nl === -1) break;
|
|
462
|
+
const line = buf.slice(start, nl);
|
|
463
|
+
start = nl + 1;
|
|
464
|
+
if (oversized) {
|
|
465
|
+
harvest(sink, oversizeTail + line, overLocate);
|
|
466
|
+
oversized = false;
|
|
467
|
+
oversizeTail = '';
|
|
468
|
+
sink.unreadableLines++;
|
|
469
|
+
sink.seenLines++;
|
|
470
|
+
lineNo++;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
lineNo++;
|
|
474
|
+
if (line.trim()) handleLine(sink, line, lineNo, ctx);
|
|
475
|
+
}
|
|
476
|
+
buf = start ? buf.slice(start) : buf;
|
|
477
|
+
if (buf.length > maxLineChars) {
|
|
478
|
+
const safeEnd = (oversizeTail + buf).length - 1;
|
|
479
|
+
harvest(sink, oversizeTail + buf, overLocate, (end) => end < safeEnd);
|
|
480
|
+
oversizeTail = buf.slice(-OVERLAP);
|
|
481
|
+
buf = '';
|
|
482
|
+
oversized = true;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const rest = buf + decoder.end();
|
|
486
|
+
if (oversized) {
|
|
487
|
+
harvest(sink, oversizeTail + rest, overLocate);
|
|
488
|
+
sink.unreadableLines++;
|
|
489
|
+
sink.seenLines++;
|
|
490
|
+
} else if (rest.trim()) {
|
|
491
|
+
lineNo++;
|
|
492
|
+
handleLine(sink, rest, lineNo, ctx);
|
|
493
|
+
}
|
|
494
|
+
return lineNo;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function listRec(dir, pred, depth = 3, out = []) {
|
|
498
|
+
let names;
|
|
499
|
+
try { names = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
500
|
+
for (const d of names) {
|
|
501
|
+
const p = join(dir, d.name);
|
|
502
|
+
if (d.isDirectory() && depth > 0) listRec(p, pred, depth - 1, out);
|
|
503
|
+
else if (d.isFile() && pred(d.name, p)) out.push(p);
|
|
504
|
+
}
|
|
505
|
+
return out;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Broadcast files under cwd (§2.1): depth <= 6, <= 5 MB each. */
|
|
509
|
+
export function scanBroadcast(sink, cwd, stats) {
|
|
510
|
+
const seen = { dirs: 0 };
|
|
511
|
+
const walk = (dir, depth, inside) => {
|
|
512
|
+
if (seen.dirs++ > 50000) { stats.broadcastTruncated = true; return; }
|
|
513
|
+
let names;
|
|
514
|
+
try { names = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
515
|
+
for (const d of names) {
|
|
516
|
+
if (d.isSymbolicLink()) continue;
|
|
517
|
+
const p = join(dir, d.name);
|
|
518
|
+
if (d.isDirectory()) {
|
|
519
|
+
if (d.name === 'node_modules' || d.name.startsWith('.')) continue;
|
|
520
|
+
if (depth < 6) walk(p, depth + 1, inside || d.name === 'broadcast');
|
|
521
|
+
} else if (inside && d.isFile() && /^run-.*\.json$/.test(d.name)) {
|
|
522
|
+
readBroadcast(sink, p, stats);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
walk(cwd, 0, false);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function readBroadcast(sink, path, stats) {
|
|
530
|
+
try {
|
|
531
|
+
if (statSync(path).size > MAX_BROADCAST_BYTES) { stats.skippedBroadcast++; return; }
|
|
532
|
+
const doc = JSON.parse(readFileSync(path, 'utf8'));
|
|
533
|
+
const list = [
|
|
534
|
+
...(Array.isArray(doc.transactions) ? doc.transactions.map((t) => t && t.hash) : []),
|
|
535
|
+
...(Array.isArray(doc.receipts) ? doc.receipts.map((r) => r && r.transactionHash) : []),
|
|
536
|
+
];
|
|
537
|
+
let any = false;
|
|
538
|
+
for (const h of list) {
|
|
539
|
+
if (typeof h !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(h)) continue;
|
|
540
|
+
any = true;
|
|
541
|
+
const key = h.toLowerCase();
|
|
542
|
+
let rec = sink.hashes.get(key);
|
|
543
|
+
if (!rec) { rec = { first: null, other: null, broadcast: null }; sink.hashes.set(key, rec); }
|
|
544
|
+
sink.positive.add(key);
|
|
545
|
+
if (!rec.broadcast) rec.broadcast = { kind: 'broadcast', origin: 'broadcast', line: null, ts: null, toolId: null };
|
|
546
|
+
}
|
|
547
|
+
if (any || list.length === 0) stats.broadcast++;
|
|
548
|
+
} catch {
|
|
549
|
+
stats.skippedBroadcast++;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* parseAll: mainLogs (session span comes from these and their subagents), evidenceLogs (extra hash evidence only).
|
|
555
|
+
* Returns the sink plus file statistics.
|
|
556
|
+
*/
|
|
557
|
+
export async function parseAll({
|
|
558
|
+
mainLogs, evidenceLogs = [], includeSubagents = true, cwd = null,
|
|
559
|
+
maxToolResultBytes = MAX_TOOL_RESULT_BYTES, maxLineChars = MAX_LINE_CHARS, onBytes = null,
|
|
560
|
+
}) {
|
|
561
|
+
const sink = createSink();
|
|
562
|
+
const stats = {
|
|
563
|
+
main: mainLogs.map((p) => basename(p)), subagents: 0, toolResults: 0, broadcast: 0,
|
|
564
|
+
missingToolResults: 0, skippedToolResults: 0, skippedBroadcast: 0, broadcastTruncated: false,
|
|
565
|
+
};
|
|
566
|
+
const hash = createHash('sha256');
|
|
567
|
+
const sessionDirs = [];
|
|
568
|
+
|
|
569
|
+
const parseOne = async (log, evidenceOnly, isMain) => {
|
|
570
|
+
const sessionDir = log.replace(/\.jsonl$/, '');
|
|
571
|
+
sessionDirs.push(sessionDir);
|
|
572
|
+
await parseLogFile(sink, log, { origin: 'main', lastTs: null, evidenceOnly }, { hash: isMain ? hash : null, onBytes, maxLineChars });
|
|
573
|
+
if (!includeSubagents) return;
|
|
574
|
+
const subs = listRec(join(sessionDir, 'subagents'), (n) => n.endsWith('.jsonl'));
|
|
575
|
+
for (const sub of subs) {
|
|
576
|
+
if (!evidenceOnly) stats.subagents++;
|
|
577
|
+
await parseLogFile(sink, sub, { origin: 'subagent', lastTs: null, evidenceOnly }, { onBytes, maxLineChars });
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
for (const log of mainLogs) await parseOne(log, false, true);
|
|
581
|
+
for (const log of evidenceLogs) await parseOne(log, true, false);
|
|
582
|
+
|
|
583
|
+
// Persisted outputs: every referenced file, then whatever else the session's tool-results dirs hold.
|
|
584
|
+
const done = new Set();
|
|
585
|
+
const scanOne = async (path, meta) => {
|
|
586
|
+
if (done.has(path)) return;
|
|
587
|
+
done.add(path);
|
|
588
|
+
let size;
|
|
589
|
+
try { size = statSync(path).size; } catch { stats.missingToolResults++; return; }
|
|
590
|
+
if (size > maxToolResultBytes) { stats.skippedToolResults++; return; }
|
|
591
|
+
try {
|
|
592
|
+
await scanFileText(sink, path, meta);
|
|
593
|
+
stats.toolResults++;
|
|
594
|
+
} catch {
|
|
595
|
+
stats.missingToolResults++;
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
for (const [path, meta] of sink.referenced) {
|
|
599
|
+
if (IMAGE_EXT.has(extname(path).toLowerCase())) continue;
|
|
600
|
+
const real = resolvePersisted(path, sessionDirs);
|
|
601
|
+
if (!real) { stats.missingToolResults++; continue; }
|
|
602
|
+
await scanOne(real, meta);
|
|
603
|
+
}
|
|
604
|
+
for (const dir of sessionDirs) {
|
|
605
|
+
const inDir = [
|
|
606
|
+
...listRec(join(dir, 'tool-results'), (n) => !IMAGE_EXT.has(extname(n).toLowerCase()), 1),
|
|
607
|
+
];
|
|
608
|
+
if (includeSubagents) {
|
|
609
|
+
inDir.push(...listRec(join(dir, 'subagents'), (n, p) => p.includes('/tool-results/') && !IMAGE_EXT.has(extname(n).toLowerCase())));
|
|
610
|
+
}
|
|
611
|
+
for (const file of inDir) {
|
|
612
|
+
const stem = basename(file).replace(/\.[^.]+$/, '');
|
|
613
|
+
const tool = sink.tools.get(stem);
|
|
614
|
+
await scanOne(file, { line: null, ts: tool ? tool.end : null, toolId: tool ? stem : null, origin: 'main' });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (cwd) scanBroadcast(sink, cwd, stats);
|
|
619
|
+
stats.logSha256 = hash.digest('hex');
|
|
620
|
+
return { sink, stats };
|
|
621
|
+
}
|
package/src/pubkey.mjs
ADDED