@kairyou/agent-tools 0.11.0 → 0.13.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.
@@ -0,0 +1,926 @@
1
+ #!/usr/bin/env node
2
+ // Records agent session activity into a work log.
3
+ //
4
+ // Wired as an agent hook (UserPromptSubmit / PreToolUse / PostToolUse / Stop).
5
+ // Claude Code and Codex run it directly from their hook config; OpenCode goes
6
+ // through the opencode-plugin.mjs adapter, which synthesizes these payloads.
7
+ // Configured via `log` in ~/.agent-tools/config.jsonc:
8
+ // enabled default true; false pauses recording without uninstalling
9
+ // output daily: a single markdown file; detailed: a directory of <date>.md
10
+ // language zh | en (detailed report headings; daily entries carry no chrome)
11
+ // format daily | detailed
12
+ // projects optional allowlist of directories; only sessions inside them are
13
+ // recorded, and an entry may override format/output/language
14
+ //
15
+ // State and diff snapshots live in ~/.agent-tools/cache/log/ and only the
16
+ // current day is kept. Concurrent sessions share the day state without a lock,
17
+ // last-writer-wins: a simultaneous write from another session can drop that
18
+ // event's update, up to a whole turn with its outcome and file records.
19
+ // Accepted as best effort — the log is generated data, never user content.
20
+ // The opencode adapter serializes its own sends, so single-session events
21
+ // there never race each other.
22
+
23
+ import { spawnSync } from "node:child_process";
24
+ import fs from "node:fs/promises";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+ import { parse as parseJsonc } from "jsonc-parser";
28
+
29
+ const MAX_SNAPSHOT_BYTES = 512 * 1024;
30
+ const MIN_RESULT_SUMMARY_LENGTH = 24;
31
+ const DAILY_ITEM_MAX_CHARS = 160;
32
+
33
+ const INSTALL_ROOT = process.env.AGENT_TOOLS_HOME || path.join(os.homedir(), ".agent-tools");
34
+ const CACHE_ROOT = path.join(INSTALL_ROOT, "cache", "log");
35
+
36
+ async function main() {
37
+ const rawInput = await readStdin();
38
+ if (!rawInput.trim()) return;
39
+
40
+ let hookInput;
41
+ try {
42
+ hookInput = JSON.parse(stripBom(rawInput));
43
+ } catch (error) {
44
+ console.error(`[agent-tools log] Failed to parse hook input: ${error.message}`);
45
+ return;
46
+ }
47
+
48
+ const eventName = getEventName(hookInput);
49
+ if (!["UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"].includes(eventName)) {
50
+ return;
51
+ }
52
+
53
+ const config = await loadLogConfig();
54
+ if (!config.enabled) return;
55
+ const cwd = eventCwd(hookInput);
56
+ const scope = matchScope(config, cwd);
57
+ if (config.projects.length > 0 && !scope) return;
58
+ const effective = scope || config;
59
+ // Turns are grouped for rendering by their output target, not by project:
60
+ // scopes that inherit the same output must land in the same file instead of
61
+ // overwriting each other's entries.
62
+ const scopeKey = `${effective.format}|${pathKey(effective.output)}`;
63
+
64
+ const now = new Date();
65
+ const day = formatLocalDate(now);
66
+ const statePath = path.join(CACHE_ROOT, `${day}.state.json`);
67
+ const snapshotsRoot = path.join(CACHE_ROOT, `${day}.snapshots`);
68
+
69
+ await fs.mkdir(CACHE_ROOT, { recursive: true });
70
+ await cleanupCache(day);
71
+
72
+ const state = await loadState(statePath, day);
73
+ await handleEvent(state, hookInput, { now, snapshotsRoot, effective, scopeKey });
74
+ await writeFileAtomic(statePath, `${JSON.stringify(state, null, 2)}\n`);
75
+
76
+ if (effective.format === "detailed") {
77
+ const report = await renderDetailedReport(state, {
78
+ snapshotsRoot,
79
+ language: effective.language,
80
+ scopeKey,
81
+ });
82
+ await fs.mkdir(effective.output, { recursive: true });
83
+ await writeFileAtomic(path.join(effective.output, `${day}.md`), report);
84
+ } else {
85
+ await updateDailyFile(effective.output, day, buildDailyItems(state, scopeKey));
86
+ }
87
+ }
88
+
89
+ function eventCwd(input) {
90
+ return firstNonEmpty(input?.cwd, input?.workspace, input?.project_dir) || process.cwd();
91
+ }
92
+
93
+ // ---- Config. ----
94
+
95
+ async function loadLogConfig() {
96
+ let parsed = {};
97
+ try {
98
+ const raw = await fs.readFile(path.join(INSTALL_ROOT, "config.jsonc"), "utf8");
99
+ parsed = parseJsonc(stripBom(raw), [], { allowTrailingComma: true }) || {};
100
+ } catch {
101
+ // Missing or unreadable config falls back to defaults below.
102
+ }
103
+ const section = isPlainObject(parsed.log) ? parsed.log : {};
104
+ const enabled = section.enabled !== false;
105
+ const format = pickFormat(section.format, "daily");
106
+ const language = pickLanguage(section.language, "zh");
107
+ const output = pickOutput(section.output, format, defaultOutput(format));
108
+
109
+ // Optional allowlist; entries may override format/language/output per project.
110
+ const projects = [];
111
+ if (Array.isArray(section.projects)) {
112
+ for (const item of section.projects) {
113
+ const entry = typeof item === "string" ? { path: item } : isPlainObject(item) ? item : null;
114
+ if (!entry || typeof entry.path !== "string" || !entry.path.trim()) continue;
115
+ const scopeFormat = pickFormat(entry.format, format);
116
+ projects.push({
117
+ path: path.resolve(expandHome(entry.path.trim())),
118
+ format: scopeFormat,
119
+ language: pickLanguage(entry.language, language),
120
+ // An entry that switches format without naming an output cannot reuse
121
+ // the top-level one (file vs directory), so it gets the built-in default.
122
+ output: pickOutput(entry.output, scopeFormat, scopeFormat === format ? output : defaultOutput(scopeFormat)),
123
+ });
124
+ }
125
+ }
126
+ return { enabled, format, language, output, projects };
127
+ }
128
+
129
+ function pickFormat(value, fallback) {
130
+ return value === "detailed" || value === "daily" ? value : fallback;
131
+ }
132
+
133
+ function pickLanguage(value, fallback) {
134
+ return value === "en" || value === "zh" ? value : fallback;
135
+ }
136
+
137
+ function defaultOutput(format) {
138
+ return path.join(INSTALL_ROOT, "logs", format === "detailed" ? "ai-log" : "ai-log.md");
139
+ }
140
+
141
+ function pickOutput(value, format, fallback) {
142
+ return typeof value === "string" && value.trim()
143
+ ? path.resolve(expandHome(value.trim()))
144
+ : fallback;
145
+ }
146
+
147
+ function pathKey(value) {
148
+ const normalized = normalizePath(path.resolve(value));
149
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
150
+ }
151
+
152
+ function matchScope(config, cwd) {
153
+ const key = pathKey(cwd);
154
+ let best = null;
155
+ for (const entry of config.projects) {
156
+ const entryKey = pathKey(entry.path);
157
+ if (key !== entryKey && !key.startsWith(`${entryKey}/`)) continue;
158
+ if (!best || entryKey.length > pathKey(best.path).length) best = entry;
159
+ }
160
+ return best;
161
+ }
162
+
163
+ // ---- Event handling. ----
164
+
165
+ async function handleEvent(state, input, context) {
166
+ const timestamp = formatLocalDateTime(context.now);
167
+ const eventName = getEventName(input);
168
+ const sessionId = getSessionId(input);
169
+ const session = ensureSession(state, sessionId, timestamp);
170
+ session.last_time = timestamp;
171
+
172
+ if (eventName === "UserPromptSubmit") {
173
+ // Always start a turn, even for greetings: reusing the previous turn would
174
+ // let this prompt's Stop overwrite the previous result summary. Trivial
175
+ // turns are filtered at render time instead.
176
+ session.turns.push(
177
+ createTurn(session, timestamp, getPromptText(input), resolveProjectLabel(input), context.scopeKey)
178
+ );
179
+ return;
180
+ }
181
+
182
+ const turn = ensureCurrentTurn(session, timestamp, resolveProjectLabel(input), context.scopeKey);
183
+ turn.last_time = timestamp;
184
+
185
+ if (eventName === "PreToolUse") {
186
+ // Baseline snapshots only feed the detailed report's diff column.
187
+ if (context.effective.format === "detailed") {
188
+ await captureBaselineSnapshot(turn, sessionId, input, {
189
+ timestamp,
190
+ snapshotsRoot: context.snapshotsRoot,
191
+ });
192
+ }
193
+ return;
194
+ }
195
+
196
+ if (eventName === "Stop") {
197
+ const summary = excerptMultiline(getOutcomeText(input), 900);
198
+ if (summary) turn.result_summary = summary;
199
+ return;
200
+ }
201
+
202
+ recordToolUse(turn, input, { timestamp });
203
+ }
204
+
205
+ function resolveProjectLabel(input) {
206
+ const cwd = eventCwd(input);
207
+ const result = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
208
+ encoding: "utf8",
209
+ windowsHide: true,
210
+ });
211
+ const root = result.status === 0 ? result.stdout.trim() : "";
212
+ return path.basename(root || cwd);
213
+ }
214
+
215
+ async function cleanupCache(day) {
216
+ let entries = [];
217
+ try {
218
+ entries = await fs.readdir(CACHE_ROOT, { withFileTypes: true });
219
+ } catch {
220
+ return;
221
+ }
222
+ await Promise.all(
223
+ entries.map(async (entry) => {
224
+ const isState = entry.isFile() && /^\d{4}-\d{2}-\d{2}\.state\.json$/.test(entry.name);
225
+ const isSnapshots = entry.isDirectory() && /^\d{4}-\d{2}-\d{2}\.snapshots$/.test(entry.name);
226
+ if ((!isState && !isSnapshots) || entry.name.startsWith(day)) return;
227
+ try {
228
+ await fs.rm(path.join(CACHE_ROOT, entry.name), { recursive: true, force: true });
229
+ } catch {
230
+ // Cache cleanup must never block logging.
231
+ }
232
+ })
233
+ );
234
+ }
235
+
236
+ async function loadState(statePath, day) {
237
+ try {
238
+ const parsed = JSON.parse(await fs.readFile(statePath, "utf8"));
239
+ if (
240
+ isPlainObject(parsed) &&
241
+ parsed.date === day &&
242
+ isPlainObject(parsed.sessions)
243
+ ) {
244
+ return parsed;
245
+ }
246
+ } catch {
247
+ // Fresh day or unreadable state; rebuild below.
248
+ }
249
+ return { date: day, sessions: {} };
250
+ }
251
+
252
+ function ensureSession(state, sessionId, timestamp) {
253
+ if (!state.sessions[sessionId]) {
254
+ state.sessions[sessionId] = { first_time: timestamp, last_time: timestamp, turns: [] };
255
+ }
256
+ return state.sessions[sessionId];
257
+ }
258
+
259
+ function ensureCurrentTurn(session, timestamp, project, scopeKey) {
260
+ if (!Array.isArray(session.turns)) session.turns = [];
261
+ const currentTurn = session.turns[session.turns.length - 1];
262
+ if (currentTurn) return currentTurn;
263
+ const fallbackTurn = createTurn(session, timestamp, "", project, scopeKey);
264
+ session.turns.push(fallbackTurn);
265
+ return fallbackTurn;
266
+ }
267
+
268
+ function createTurn(session, timestamp, requestText, project, scopeKey) {
269
+ const turnIndex = Array.isArray(session.turns) ? session.turns.length + 1 : 1;
270
+ return {
271
+ turn_id: `turn-${turnIndex}-${simpleHash(`${timestamp}-${requestText}`).slice(0, 8)}`,
272
+ first_time: timestamp,
273
+ last_time: timestamp,
274
+ project: project || "",
275
+ scope: scopeKey || "",
276
+ request_text: excerptMultiline(requestText || "", 1600),
277
+ result_summary: "",
278
+ bash_commands: 0,
279
+ verification_commands: 0,
280
+ files: {},
281
+ };
282
+ }
283
+
284
+ function ensureTurnFile(turn, fileKey, relativePath, absolutePath, timestamp) {
285
+ if (!turn.files[fileKey]) {
286
+ turn.files[fileKey] = {
287
+ rel: relativePath,
288
+ abs: absolutePath,
289
+ writes: 0,
290
+ edits: 0,
291
+ first_time: timestamp,
292
+ last_time: timestamp,
293
+ snapshot_kind: "",
294
+ };
295
+ }
296
+ return turn.files[fileKey];
297
+ }
298
+
299
+ function recordToolUse(turn, input, context) {
300
+ const toolName = getToolName(input);
301
+
302
+ if (toolName === "Bash") {
303
+ const kind = classifyCommand(getShellCommand(input));
304
+ turn.bash_commands += 1;
305
+ if (["test", "lint", "typecheck", "build", "format"].includes(kind)) {
306
+ turn.verification_commands += 1;
307
+ }
308
+ return;
309
+ }
310
+
311
+ if (!["Write", "Edit", "MultiEdit"].includes(toolName)) return;
312
+
313
+ const located = locateToolFile(input);
314
+ if (!located) return;
315
+
316
+ const turnFile = ensureTurnFile(turn, located.key, located.rel, located.abs, context.timestamp);
317
+ turnFile.last_time = context.timestamp;
318
+ if (!turnFile.snapshot_kind) turnFile.snapshot_kind = "unknown";
319
+
320
+ if (toolName === "Write") turnFile.writes += 1;
321
+ else turnFile.edits += 1;
322
+ }
323
+
324
+ function locateToolFile(input) {
325
+ const rawPath = getToolFilePath(input);
326
+ if (!rawPath) return null;
327
+ const cwd = firstNonEmpty(input?.cwd, input?.workspace, input?.project_dir) || process.cwd();
328
+ const abs = normalizePath(path.resolve(cwd, rawPath));
329
+ const cwdNormalized = normalizePath(path.resolve(cwd));
330
+ const rel = abs.toLowerCase().startsWith(`${cwdNormalized.toLowerCase()}/`)
331
+ ? abs.slice(cwdNormalized.length + 1)
332
+ : abs;
333
+ const key = process.platform === "win32" ? abs.toLowerCase() : abs;
334
+ return { key, rel, abs };
335
+ }
336
+
337
+ // ---- Baseline snapshots (detailed format only). ----
338
+
339
+ async function captureBaselineSnapshot(turn, sessionId, input, context) {
340
+ const toolName = getToolName(input);
341
+ if (!["Write", "Edit", "MultiEdit"].includes(toolName)) return;
342
+
343
+ const located = locateToolFile(input);
344
+ if (!located) return;
345
+
346
+ const turnFile = ensureTurnFile(turn, located.key, located.rel, located.abs, context.timestamp);
347
+ if (turnFile.snapshot_kind) return;
348
+
349
+ turnFile.snapshot_kind = await createSnapshotFile(
350
+ buildSnapshotKey(sessionId, turn.turn_id),
351
+ located,
352
+ context.snapshotsRoot
353
+ );
354
+ }
355
+
356
+ async function createSnapshotFile(snapshotKey, located, snapshotsRoot) {
357
+ let stats;
358
+ try {
359
+ stats = await fs.stat(located.abs);
360
+ } catch (error) {
361
+ return error?.code === "ENOENT" ? "missing" : "unknown";
362
+ }
363
+ if (!stats.isFile()) return "unknown";
364
+ if (stats.size > MAX_SNAPSHOT_BYTES) return "large";
365
+
366
+ let buffer;
367
+ try {
368
+ buffer = await fs.readFile(located.abs);
369
+ } catch {
370
+ return "unknown";
371
+ }
372
+ if (buffer.includes(0)) return "binary";
373
+
374
+ const snapshotPath = getSnapshotPath(snapshotsRoot, snapshotKey, located);
375
+ await fs.mkdir(path.dirname(snapshotPath), { recursive: true });
376
+ await fs.writeFile(snapshotPath, buffer);
377
+ return "text";
378
+ }
379
+
380
+ function getSnapshotPath(snapshotsRoot, snapshotKey, located) {
381
+ return path.join(snapshotsRoot, sanitizeDirName(snapshotKey), `${simpleHash(located.key)}-${path.basename(located.abs)}`);
382
+ }
383
+
384
+ function buildSnapshotKey(sessionId, turnId) {
385
+ return `${sessionId}-${turnId}`;
386
+ }
387
+
388
+ function sanitizeDirName(value) {
389
+ const normalized = String(value || "")
390
+ .toLowerCase()
391
+ .replace(/[^a-z0-9_-]/g, "-")
392
+ .replace(/-+/g, "-")
393
+ .replace(/^-|-$/g, "");
394
+ return `${normalized || "session"}-${simpleHash(value).slice(0, 8)}`;
395
+ }
396
+
397
+ // ---- Daily format: one file, one dated entry per day, marker-guarded. The
398
+ // markers use the `log:` namespace (the capability name); at-daily-log stamps
399
+ // `daily-log:` markers, so a shared output file never collides. ----
400
+
401
+ function buildDailyItems(state, scopeKey) {
402
+ const items = [];
403
+ for (const session of orderedSessions(state)) {
404
+ for (const turn of session.turns || []) {
405
+ if (String(turn.scope || "") !== scopeKey) continue;
406
+ const text = dailyItemText(turn);
407
+ if (!text) continue;
408
+ items.push({ first_time: turn.first_time, project: turn.project || "", text });
409
+ }
410
+ }
411
+ items.sort((a, b) => String(a.first_time).localeCompare(String(b.first_time)));
412
+ return items;
413
+ }
414
+
415
+ function dailyItemText(turn) {
416
+ const request = String(turn.request_text || "");
417
+ const outcome = String(turn.result_summary || "");
418
+ if (!hasSubstantiveTurn(request, outcome) || isTrivialTurn(request, outcome)) return "";
419
+ const source = outcome || request;
420
+ const firstLine = source
421
+ .split("\n")
422
+ .map((line) => line.replace(/^[#>*\-\s`]+/, "").trim())
423
+ .find((line) => line.length > 0);
424
+ if (!firstLine) return "";
425
+ return firstLine.length > DAILY_ITEM_MAX_CHARS
426
+ ? `${firstLine.slice(0, DAILY_ITEM_MAX_CHARS - 3)}...`
427
+ : firstLine;
428
+ }
429
+
430
+ async function updateDailyFile(outputFile, day, items) {
431
+ if (items.length === 0) return;
432
+
433
+ const lines = items.map(
434
+ (item, index) => ` ${index + 1}. ${item.project ? `${item.project}: ` : ""}${item.text}`
435
+ );
436
+ const block = [`<!-- log:${day}:start -->`, ...lines, `<!-- log:${day}:end -->`];
437
+
438
+ let current = "";
439
+ try {
440
+ current = await fs.readFile(outputFile, "utf8");
441
+ } catch {
442
+ // First write creates the file.
443
+ }
444
+ const eol = current.includes("\r\n") ? "\r\n" : "\n";
445
+ const fileLines = current ? current.split(/\r?\n/) : [];
446
+
447
+ const startMarker = `<!-- log:${day}:start -->`;
448
+ const endMarker = `<!-- log:${day}:end -->`;
449
+ const startIndex = fileLines.findIndex((line) => line.trim() === startMarker);
450
+ const endIndex = fileLines.findIndex((line) => line.trim() === endMarker);
451
+
452
+ let nextLines;
453
+ if (startIndex !== -1 && endIndex > startIndex) {
454
+ nextLines = [...fileLines.slice(0, startIndex), ...block, ...fileLines.slice(endIndex + 1)];
455
+ } else if (startIndex !== -1 || endIndex !== -1) {
456
+ // Unpaired markers: refuse to guess an edit range in an unattended run.
457
+ console.error(`[agent-tools log] Unpaired markers for ${day} in ${outputFile}; skipped.`);
458
+ return;
459
+ } else {
460
+ nextLines = insertDatedBlock(fileLines, day, block);
461
+ }
462
+
463
+ const text = nextLines.join(eol).replace(/(\r?\n)*$/, eol);
464
+ await fs.mkdir(path.dirname(outputFile), { recursive: true });
465
+ await writeFileAtomic(outputFile, text);
466
+ }
467
+
468
+ function insertDatedBlock(fileLines, day, block) {
469
+ const dateLineRe = /^\+ (\d{4}-\d{2}-\d{2})\s*$/;
470
+ const dates = [];
471
+ for (let i = 0; i < fileLines.length; i += 1) {
472
+ const match = fileLines[i].match(dateLineRe);
473
+ if (match) dates.push({ index: i, date: match[1] });
474
+ }
475
+
476
+ const existing = dates.find((entry) => entry.date === day);
477
+ if (existing) {
478
+ // Date line exists without markers (user-written): append the block below
479
+ // that date's lines. The dated section ends at the next date line or the
480
+ // next heading (notes and todo lists after the entries stay untouched).
481
+ const boundaryRe = /^(\+ \d{4}-\d{2}-\d{2}\s*$|#{1,6}\s)/;
482
+ let insertAt = fileLines.length;
483
+ for (let i = existing.index + 1; i < fileLines.length; i += 1) {
484
+ if (boundaryRe.test(fileLines[i])) {
485
+ insertAt = i;
486
+ break;
487
+ }
488
+ }
489
+ while (insertAt - 1 > existing.index && fileLines[insertAt - 1].trim() === "") insertAt -= 1;
490
+ const trailing = fileLines.slice(insertAt);
491
+ const inserted = [...block];
492
+ if (trailing.length > 0 && trailing[0].trim() !== "") inserted.push("");
493
+ return [...fileLines.slice(0, insertAt), ...inserted, ...trailing];
494
+ }
495
+
496
+ // Insert a new date at its date-order position; ascending when ambiguous.
497
+ const ascending = dates.length < 2 || dates[0].date <= dates[dates.length - 1].date;
498
+ let insertAt = fileLines.length;
499
+ for (const entry of dates) {
500
+ if (ascending ? entry.date > day : entry.date < day) {
501
+ insertAt = entry.index;
502
+ break;
503
+ }
504
+ }
505
+ const dated = [`+ ${day}`, ...block];
506
+ const before = fileLines.slice(0, insertAt);
507
+ const after = fileLines.slice(insertAt);
508
+ if (before.length > 0 && before[before.length - 1].trim() !== "") before.push("");
509
+ if (after.length > 0 && after[0].trim() !== "") dated.push("");
510
+ return [...before, ...dated, ...after];
511
+ }
512
+
513
+ // ---- Detailed format: one report file per day. ----
514
+
515
+ const STRINGS = {
516
+ zh: {
517
+ title: (date) => `# AI 日报 - ${date}`,
518
+ updated: (time) => `更新时间: ${time}`,
519
+ overview: "## 今日概览",
520
+ prompts: (n) => `- 请求次数: ${n}`,
521
+ turns: (n) => `- 有效记录数: ${n}`,
522
+ changedFiles: (n) => `- 变更文件数: ${n}`,
523
+ fileOps: (n) => `- 代码操作次数: ${n}`,
524
+ lineChanges: (added, deleted) => `- 总行变更: +${added}/-${deleted}`,
525
+ sessions: "## 会话记录",
526
+ none: "- 无",
527
+ request: "Request",
528
+ outcome: "Outcome",
529
+ changes: "Changes",
530
+ fileLine: (file) => `- ${file.rel} | 变更 ${file.diff} | 操作 ${file.writes + file.edits} 次`,
531
+ noPrompt: "未记录 prompt",
532
+ },
533
+ en: {
534
+ title: (date) => `# AI Log - ${date}`,
535
+ updated: (time) => `Updated: ${time}`,
536
+ overview: "## Overview",
537
+ prompts: (n) => `- Prompts: ${n}`,
538
+ turns: (n) => `- Recorded turns: ${n}`,
539
+ changedFiles: (n) => `- Changed files: ${n}`,
540
+ fileOps: (n) => `- File operations: ${n}`,
541
+ lineChanges: (added, deleted) => `- Line changes: +${added}/-${deleted}`,
542
+ sessions: "## Sessions",
543
+ none: "- none",
544
+ request: "Request",
545
+ outcome: "Outcome",
546
+ changes: "Changes",
547
+ fileLine: (file) => `- ${file.rel} | diff ${file.diff} | ${file.writes + file.edits} ops`,
548
+ noPrompt: "no prompt recorded",
549
+ },
550
+ };
551
+
552
+ async function renderDetailedReport(state, context) {
553
+ const t = STRINGS[context.language] || STRINGS.zh;
554
+ const scopeTurns = collectScopeTurns(state, context.scopeKey);
555
+ const turnEntries = await buildTurnEntries(state, context);
556
+ const changedFiles = new Set(
557
+ turnEntries.flatMap((entry) => entry.files.map((file) => file.rel))
558
+ );
559
+ const fileOps = scopeTurns.reduce(
560
+ (sum, turn) =>
561
+ sum +
562
+ Object.values(turn.files || {}).reduce((s, f) => s + (f.writes || 0) + (f.edits || 0), 0),
563
+ 0
564
+ );
565
+ const lineChanges = summarizeLineChanges(turnEntries);
566
+
567
+ const lines = [
568
+ t.title(state.date),
569
+ "",
570
+ t.updated(latestSessionTime(state) || "unknown"),
571
+ "",
572
+ t.overview,
573
+ "",
574
+ t.prompts(scopeTurns.length),
575
+ t.turns(turnEntries.length),
576
+ t.changedFiles(changedFiles.size),
577
+ t.fileOps(fileOps),
578
+ t.lineChanges(lineChanges.added, lineChanges.deleted),
579
+ "",
580
+ t.sessions,
581
+ "",
582
+ ];
583
+
584
+ if (turnEntries.length === 0) {
585
+ lines.push(t.none);
586
+ } else {
587
+ turnEntries.forEach((entry, index) => {
588
+ lines.push(`- Time: ${entry.first_time} -> ${entry.last_time}`);
589
+ if (entry.project) lines.push(`- Project: ${entry.project}`);
590
+ const requestText = entry.request_text || t.noPrompt;
591
+ const requestFence = fenceFor(requestText);
592
+ lines.push("", t.request, "", `${requestFence}text`, requestText, requestFence);
593
+ if (entry.result_summary) {
594
+ const outcomeFence = fenceFor(entry.result_summary);
595
+ lines.push("", t.outcome, "", `${outcomeFence}text`, entry.result_summary, outcomeFence);
596
+ }
597
+ lines.push("", t.changes, "");
598
+ if (entry.files.length === 0) {
599
+ lines.push(t.none);
600
+ } else {
601
+ for (const file of entry.files) lines.push(t.fileLine(file));
602
+ }
603
+ if (index < turnEntries.length - 1) lines.push("", "---", "");
604
+ });
605
+ }
606
+
607
+ lines.push("");
608
+ return `${lines.join("\n")}\n`;
609
+ }
610
+
611
+ function latestSessionTime(state) {
612
+ let latest = "";
613
+ for (const session of Object.values(state.sessions)) {
614
+ if (String(session.last_time || "") > latest) latest = String(session.last_time);
615
+ }
616
+ return latest;
617
+ }
618
+
619
+ function orderedSessions(state) {
620
+ return Object.values(state.sessions).sort((a, b) =>
621
+ String(a.first_time || "").localeCompare(String(b.first_time || ""))
622
+ );
623
+ }
624
+
625
+ function collectScopeTurns(state, scopeKey) {
626
+ const turns = [];
627
+ for (const session of orderedSessions(state)) {
628
+ for (const turn of session.turns || []) {
629
+ if (String(turn.scope || "") === scopeKey) turns.push(turn);
630
+ }
631
+ }
632
+ return turns;
633
+ }
634
+
635
+ async function buildTurnEntries(state, context) {
636
+ const entries = [];
637
+ for (const session of orderedSessions(state)) {
638
+ const sessionId = Object.keys(state.sessions).find((key) => state.sessions[key] === session);
639
+ for (const turn of session.turns || []) {
640
+ if (String(turn.scope || "") !== context.scopeKey) continue;
641
+ const files = await buildTurnFileEntries(sessionId, turn, context);
642
+ const requestText = String(turn.request_text || "");
643
+ const resultSummary = String(turn.result_summary || "");
644
+ if (
645
+ files.length === 0 &&
646
+ (!hasSubstantiveTurn(requestText, resultSummary) || isTrivialTurn(requestText, resultSummary))
647
+ ) {
648
+ continue;
649
+ }
650
+ entries.push({
651
+ first_time: turn.first_time,
652
+ last_time: turn.last_time,
653
+ project: turn.project || "",
654
+ request_text: requestText,
655
+ result_summary: resultSummary,
656
+ files,
657
+ });
658
+ }
659
+ }
660
+ entries.sort((a, b) => String(a.first_time || "").localeCompare(String(b.first_time || "")));
661
+ return entries;
662
+ }
663
+
664
+ async function buildTurnFileEntries(sessionId, turn, context) {
665
+ const entries = [];
666
+ const orderedFiles = Object.values(turn.files || {})
667
+ .filter((item) => (item?.writes || 0) + (item?.edits || 0) > 0)
668
+ .sort((a, b) => b.writes + b.edits - (a.writes + a.edits));
669
+
670
+ for (const item of orderedFiles) {
671
+ entries.push({
672
+ rel: item.rel,
673
+ writes: item.writes || 0,
674
+ edits: item.edits || 0,
675
+ diff: await getTurnDiffLabel(sessionId, turn.turn_id, item, context),
676
+ });
677
+ }
678
+ return entries;
679
+ }
680
+
681
+ // A code fence longer than any backtick run inside the content, so embedded
682
+ // fenced samples cannot terminate the block early.
683
+ function fenceFor(text) {
684
+ const runs = String(text).match(/`{3,}/g);
685
+ const length = runs ? Math.max(...runs.map((run) => run.length)) + 1 : 3;
686
+ return "`".repeat(length);
687
+ }
688
+
689
+ // The diff compares the turn's baseline snapshot against the file as it is
690
+ // NOW, so when several turns touch one file (a common case) earlier turns
691
+ // absorb later changes and the day total double-counts them. Accepted
692
+ // approximation, and the docs call it that; exact per-turn numbers would need
693
+ // an end-of-turn snapshot per file on top of the baseline one.
694
+ async function getTurnDiffLabel(sessionId, turnId, turnFile, context) {
695
+ const baselineKind = turnFile.snapshot_kind || "unknown";
696
+ if (["binary", "large", "unknown"].includes(baselineKind)) return baselineKind;
697
+
698
+ const currentExists = await pathExists(turnFile.abs);
699
+ const emptyFilePath = await ensureEmptyFile(context.snapshotsRoot);
700
+ const leftPath =
701
+ baselineKind === "text"
702
+ ? getSnapshotPath(context.snapshotsRoot, buildSnapshotKey(sessionId, turnId), {
703
+ key: process.platform === "win32" ? turnFile.abs.toLowerCase() : turnFile.abs,
704
+ abs: turnFile.abs,
705
+ })
706
+ : emptyFilePath;
707
+ const rightPath = currentExists ? turnFile.abs : emptyFilePath;
708
+
709
+ const result = spawnSync(
710
+ "git",
711
+ ["diff", "--no-index", "--numstat", "--no-ext-diff", "--no-textconv", "--", leftPath, rightPath],
712
+ { encoding: "utf8", windowsHide: true }
713
+ );
714
+ if (result.error || ![0, 1].includes(result.status ?? -1)) return "unknown";
715
+
716
+ const line = (result.stdout || "").split(/\r?\n/).map((item) => item.trim()).find(Boolean);
717
+ if (!line) return "+0/-0";
718
+ const [added, deleted] = line.split("\t");
719
+ if (added === "-" || deleted === "-") return "binary";
720
+ return `+${Number(added || 0)}/-${Number(deleted || 0)}`;
721
+ }
722
+
723
+ function summarizeLineChanges(turnEntries) {
724
+ const total = { added: 0, deleted: 0 };
725
+ for (const entry of turnEntries) {
726
+ for (const file of entry.files || []) {
727
+ const match = String(file.diff || "").match(/^\+(\d+)\/-(\d+)$/);
728
+ if (!match) continue;
729
+ total.added += Number(match[1]);
730
+ total.deleted += Number(match[2]);
731
+ }
732
+ }
733
+ return total;
734
+ }
735
+
736
+ async function pathExists(targetPath) {
737
+ try {
738
+ await fs.access(targetPath);
739
+ return true;
740
+ } catch {
741
+ return false;
742
+ }
743
+ }
744
+
745
+ async function ensureEmptyFile(snapshotsRoot) {
746
+ const emptyFilePath = path.join(snapshotsRoot, ".empty");
747
+ try {
748
+ await fs.access(emptyFilePath);
749
+ } catch {
750
+ await fs.mkdir(snapshotsRoot, { recursive: true });
751
+ await fs.writeFile(emptyFilePath, "", "utf8");
752
+ }
753
+ return emptyFilePath;
754
+ }
755
+
756
+ // ---- Turn filtering. ----
757
+
758
+ function hasSubstantiveTurn(requestText, resultSummary) {
759
+ const request = String(requestText || "").trim();
760
+ const outcome = String(resultSummary || "").trim();
761
+ if (outcome.length >= MIN_RESULT_SUMMARY_LENGTH) return true;
762
+ if (request.length >= 32) return true;
763
+ if (/\n/.test(request) && request.length >= 16) return true;
764
+ return false;
765
+ }
766
+
767
+ function isTrivialTurn(requestText, resultSummary) {
768
+ const request = normalizeConversationText(requestText);
769
+ const outcome = normalizeConversationText(resultSummary);
770
+ if (!request && !outcome) return true;
771
+
772
+ const shortGreetingPattern =
773
+ /^(hi|hello|hey|yo|test|testing|ping|pong|ok|okay|thanks|thank you|thx|你好|嗨|哈喽|在吗|测试|试试|好的|收到)$/;
774
+ if (shortGreetingPattern.test(request) && outcome.length <= 40) return true;
775
+
776
+ if (
777
+ request.length <= 12 &&
778
+ outcome.length <= 40 &&
779
+ !/[一-龥a-z0-9].*[一-龥a-z0-9].*[一-龥a-z0-9]/i.test(request)
780
+ ) {
781
+ return true;
782
+ }
783
+ return false;
784
+ }
785
+
786
+ function normalizeConversationText(text) {
787
+ return String(text || "")
788
+ .toLowerCase()
789
+ .replace(/\r\n/g, "\n")
790
+ .replace(/[`#>*_-]+/g, " ")
791
+ .replace(/[.!?,,。!?]/g, " ")
792
+ .replace(/\s+/g, " ")
793
+ .trim();
794
+ }
795
+
796
+ function classifyCommand(command) {
797
+ const value = (command || "").toLowerCase();
798
+ if (!value.trim()) return "other";
799
+ if (/(^|\s)(pnpm|npm|yarn|bun|pytest|vitest|jest)\b/.test(value) || /\btest\b/.test(value)) return "test";
800
+ if (/\blint\b/.test(value)) return "lint";
801
+ if (/\btypecheck\b|\btsc\b/.test(value)) return "typecheck";
802
+ if (/\bbuild\b/.test(value)) return "build";
803
+ if (/\bformat\b|\bprettier\b|\bbiome\b/.test(value)) return "format";
804
+ if (/\bgit\b/.test(value)) return "git";
805
+ return "other";
806
+ }
807
+
808
+ // ---- Hook input accessors. ----
809
+
810
+ function getEventName(input) {
811
+ return firstNonEmpty(input?.hook_event_name, input?.event_name, input?.event, input?.type);
812
+ }
813
+
814
+ function getSessionId(input) {
815
+ return String(firstNonEmpty(input?.session_id, input?.sessionId, input?.conversation_id) || "unknown");
816
+ }
817
+
818
+ function getPromptText(input) {
819
+ return firstNonEmpty(input?.prompt, input?.user_prompt, input?.message, input?.text);
820
+ }
821
+
822
+ function getOutcomeText(input) {
823
+ return firstNonEmpty(
824
+ input?.last_assistant_message,
825
+ input?.assistant_message,
826
+ input?.response,
827
+ input?.output,
828
+ input?.result
829
+ );
830
+ }
831
+
832
+ function getToolName(input) {
833
+ return firstNonEmpty(input?.tool_name, input?.toolName, input?.tool?.name, input?.name);
834
+ }
835
+
836
+ function getToolInput(input) {
837
+ return firstPlainObject(input?.tool_input, input?.toolInput, input?.input, input?.arguments);
838
+ }
839
+
840
+ function getToolFilePath(input) {
841
+ const toolInput = getToolInput(input);
842
+ return firstNonEmpty(toolInput.file_path, toolInput.filePath, toolInput.path, toolInput.target_file);
843
+ }
844
+
845
+ function getShellCommand(input) {
846
+ const toolInput = getToolInput(input);
847
+ return firstNonEmpty(toolInput.command, toolInput.cmd, toolInput.script);
848
+ }
849
+
850
+ // ---- Small helpers. ----
851
+
852
+ async function writeFileAtomic(file, text) {
853
+ const temp = `${file}.${process.pid}.tmp`;
854
+ await fs.writeFile(temp, text, "utf8");
855
+ await fs.rename(temp, file);
856
+ }
857
+
858
+ function excerptMultiline(text, limit) {
859
+ const value = String(text || "")
860
+ .replace(/\r\n/g, "\n")
861
+ .trim();
862
+ if (value.length <= limit) return value;
863
+ return `${value.slice(0, Math.max(0, limit - 3))}...`;
864
+ }
865
+
866
+ function isPlainObject(value) {
867
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
868
+ }
869
+
870
+ function firstNonEmpty(...values) {
871
+ return values.find((value) => typeof value === "string" && value.trim()) || "";
872
+ }
873
+
874
+ function firstPlainObject(...values) {
875
+ return values.find((value) => isPlainObject(value)) || {};
876
+ }
877
+
878
+ function normalizePath(value) {
879
+ return String(value || "").replace(/\\/g, "/");
880
+ }
881
+
882
+ function expandHome(value) {
883
+ if (value === "~") return os.homedir();
884
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
885
+ return path.join(os.homedir(), value.slice(2));
886
+ }
887
+ return value;
888
+ }
889
+
890
+ function stripBom(text) {
891
+ return String(text || "").replace(/^\uFEFF/, "");
892
+ }
893
+
894
+ function simpleHash(input) {
895
+ let hash = 0;
896
+ const text = String(input || "");
897
+ for (let index = 0; index < text.length; index += 1) {
898
+ hash = (hash * 31 + text.charCodeAt(index)) >>> 0;
899
+ }
900
+ return hash.toString(16).padStart(8, "0");
901
+ }
902
+
903
+ function formatLocalDate(date) {
904
+ const year = date.getFullYear();
905
+ const month = `${date.getMonth() + 1}`.padStart(2, "0");
906
+ const day = `${date.getDate()}`.padStart(2, "0");
907
+ return `${year}-${month}-${day}`;
908
+ }
909
+
910
+ function formatLocalDateTime(date) {
911
+ const hours = `${date.getHours()}`.padStart(2, "0");
912
+ const minutes = `${date.getMinutes()}`.padStart(2, "0");
913
+ const seconds = `${date.getSeconds()}`.padStart(2, "0");
914
+ return `${formatLocalDate(date)} ${hours}:${minutes}:${seconds}`;
915
+ }
916
+
917
+ async function readStdin() {
918
+ const chunks = [];
919
+ for await (const chunk of process.stdin) chunks.push(chunk);
920
+ return Buffer.concat(chunks).toString("utf8");
921
+ }
922
+
923
+ main().catch((error) => {
924
+ console.error(`[agent-tools log] ${error.stack || error.message}`);
925
+ process.exit(0);
926
+ });