@wasabeef/agentnote 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +446 -65
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -25,12 +25,18 @@ var TRUNCATE_PROMPT = 120;
25
25
  var TRUNCATE_RESPONSE_SHOW = 200;
26
26
  var TRUNCATE_RESPONSE_PR = 500;
27
27
  var TRUNCATE_RESPONSE_CHAT = 800;
28
+ var ARCHIVE_ID_RE = /^[0-9a-z]{6,}$/;
29
+ var HEARTBEAT_FILE = "heartbeat";
30
+ var PRE_BLOBS_FILE = "pre_blobs.jsonl";
31
+ var COMMITTED_PAIRS_FILE = "committed_pairs.jsonl";
32
+ var EMPTY_BLOB = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391";
28
33
  var SCHEMA_VERSION = 1;
29
34
  var DEBUG = !!process.env.AGENTNOTE_DEBUG;
30
35
 
31
36
  // src/core/record.ts
32
37
  import { existsSync as existsSync3 } from "node:fs";
33
- import { readdir, readFile as readFile3 } from "node:fs/promises";
38
+ import { readdir, readFile as readFile3, unlink, writeFile as writeFile2 } from "node:fs/promises";
39
+ import { tmpdir } from "node:os";
34
40
  import { join as join2 } from "node:path";
35
41
 
36
42
  // src/agents/claude-code.ts
@@ -44,6 +50,10 @@ var HOOKS_CONFIG = {
44
50
  Stop: [{ hooks: [{ type: "command", command: HOOK_COMMAND, async: true }] }],
45
51
  UserPromptSubmit: [{ hooks: [{ type: "command", command: HOOK_COMMAND, async: true }] }],
46
52
  PreToolUse: [
53
+ {
54
+ matcher: "Edit|Write|NotebookEdit",
55
+ hooks: [{ type: "command", command: HOOK_COMMAND }]
56
+ },
47
57
  {
48
58
  matcher: "Bash",
49
59
  hooks: [{ type: "command", if: "Bash(*git commit*)", command: HOOK_COMMAND }]
@@ -147,8 +157,19 @@ var claudeCode = {
147
157
  case "UserPromptSubmit":
148
158
  return e.prompt ? { kind: "prompt", sessionId: sid, timestamp: ts, prompt: e.prompt } : null;
149
159
  case "PreToolUse": {
160
+ const tool = e.tool_name;
150
161
  const cmd = e.tool_input?.command ?? "";
151
- if (e.tool_name === "Bash" && isGitCommit(cmd)) {
162
+ if ((tool === "Edit" || tool === "Write" || tool === "NotebookEdit") && e.tool_input?.file_path) {
163
+ return {
164
+ kind: "pre_edit",
165
+ sessionId: sid,
166
+ timestamp: ts,
167
+ tool,
168
+ file: e.tool_input.file_path,
169
+ toolUseId: e.tool_use_id
170
+ };
171
+ }
172
+ if (tool === "Bash" && isGitCommit(cmd)) {
152
173
  return { kind: "pre_commit", sessionId: sid, timestamp: ts, commitCommand: cmd };
153
174
  }
154
175
  return null;
@@ -161,7 +182,8 @@ var claudeCode = {
161
182
  sessionId: sid,
162
183
  timestamp: ts,
163
184
  tool,
164
- file: e.tool_input.file_path
185
+ file: e.tool_input.file_path,
186
+ toolUseId: e.tool_use_id
165
187
  };
166
188
  }
167
189
  if (tool === "Bash" && isGitCommit(e.tool_input?.command ?? "")) {
@@ -260,15 +282,97 @@ async function repoRoot() {
260
282
  return git(["rev-parse", "--show-toplevel"]);
261
283
  }
262
284
 
285
+ // src/core/attribution.ts
286
+ function parseUnifiedHunks(diffOutput) {
287
+ const hunks = [];
288
+ for (const line of diffOutput.split("\n")) {
289
+ const m = line.match(/^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/);
290
+ if (m) {
291
+ hunks.push({
292
+ oldStart: Number(m[1]),
293
+ oldCount: m[2] != null ? Number(m[2]) : 1,
294
+ newStart: Number(m[3]),
295
+ newCount: m[4] != null ? Number(m[4]) : 1
296
+ });
297
+ }
298
+ }
299
+ return hunks;
300
+ }
301
+ function expandNewPositions(hunks) {
302
+ const positions = /* @__PURE__ */ new Set();
303
+ for (const h of hunks) {
304
+ for (let i = 0; i < h.newCount; i++) {
305
+ positions.add(h.newStart + i);
306
+ }
307
+ }
308
+ return positions;
309
+ }
310
+ function countLines(hunks) {
311
+ let added = 0;
312
+ let deleted = 0;
313
+ for (const h of hunks) {
314
+ added += h.newCount;
315
+ deleted += h.oldCount;
316
+ }
317
+ return { added, deleted };
318
+ }
319
+ async function computePositionAttribution(parentBlob, committedBlob, turnPairs) {
320
+ const diff1Output = await gitDiffUnified0(parentBlob, committedBlob);
321
+ const diff1Hunks = parseUnifiedHunks(diff1Output);
322
+ const diff1Added = expandNewPositions(diff1Hunks);
323
+ const { added: totalAddedLines, deleted: deletedLines } = countLines(diff1Hunks);
324
+ if (turnPairs.length === 0 || totalAddedLines === 0) {
325
+ return {
326
+ aiAddedLines: 0,
327
+ humanAddedLines: totalAddedLines,
328
+ totalAddedLines,
329
+ deletedLines
330
+ };
331
+ }
332
+ const aiPositions = /* @__PURE__ */ new Set();
333
+ for (const { preBlob, postBlob } of turnPairs) {
334
+ const diff2Output = await gitDiffUnified0(preBlob, committedBlob);
335
+ const diff2Positions = expandNewPositions(parseUnifiedHunks(diff2Output));
336
+ const diff3Output = await gitDiffUnified0(postBlob, committedBlob);
337
+ const diff3Positions = expandNewPositions(parseUnifiedHunks(diff3Output));
338
+ for (const pos of diff2Positions) {
339
+ if (!diff3Positions.has(pos)) {
340
+ aiPositions.add(pos);
341
+ }
342
+ }
343
+ }
344
+ let aiAddedLines = 0;
345
+ let humanAddedLines = 0;
346
+ for (const pos of diff1Added) {
347
+ if (aiPositions.has(pos)) {
348
+ aiAddedLines++;
349
+ } else {
350
+ humanAddedLines++;
351
+ }
352
+ }
353
+ return { aiAddedLines, humanAddedLines, totalAddedLines, deletedLines };
354
+ }
355
+ async function gitDiffUnified0(blobA, blobB) {
356
+ if (!blobA || !blobB || blobA === blobB) return "";
357
+ const { stdout, exitCode } = await gitSafe(["diff", "--unified=0", "--no-color", blobA, blobB]);
358
+ if (exitCode !== 0 && exitCode !== 1) {
359
+ throw new Error(`git diff failed with exit code ${exitCode}`);
360
+ }
361
+ return stdout;
362
+ }
363
+
263
364
  // src/core/entry.ts
264
- function calcAiRatio(commitFiles, aiFiles) {
365
+ function calcAiRatio(commitFiles, aiFiles, lineCounts) {
366
+ if (lineCounts && lineCounts.totalAddedLines > 0) {
367
+ return Math.round(lineCounts.aiAddedLines / lineCounts.totalAddedLines * 100);
368
+ }
265
369
  if (commitFiles.length === 0) return 0;
266
370
  const aiSet = new Set(aiFiles);
267
371
  const matched = commitFiles.filter((f) => aiSet.has(f));
268
372
  return Math.round(matched.length / commitFiles.length * 100);
269
373
  }
270
374
  function buildEntry(opts) {
271
- return {
375
+ const entry = {
272
376
  v: SCHEMA_VERSION,
273
377
  session_id: opts.sessionId,
274
378
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -281,8 +385,14 @@ function buildEntry(opts) {
281
385
  }),
282
386
  files_in_commit: opts.commitFiles,
283
387
  files_by_ai: opts.aiFiles,
284
- ai_ratio: calcAiRatio(opts.commitFiles, opts.aiFiles)
388
+ ai_ratio: calcAiRatio(opts.commitFiles, opts.aiFiles, opts.lineCounts)
285
389
  };
390
+ if (opts.lineCounts) {
391
+ entry.ai_added_lines = opts.lineCounts.aiAddedLines;
392
+ entry.total_added_lines = opts.lineCounts.totalAddedLines;
393
+ entry.deleted_lines = opts.lineCounts.deletedLines;
394
+ }
395
+ return entry;
286
396
  }
287
397
 
288
398
  // src/core/jsonl.ts
@@ -332,25 +442,53 @@ async function recordCommitEntry(opts) {
332
442
  } catch {
333
443
  }
334
444
  const commitFileSet = new Set(commitFiles);
335
- const changeEntries = await readAllSessionJsonl(sessionDir, CHANGES_FILE);
445
+ const allChangeEntries = await readAllSessionJsonl(sessionDir, CHANGES_FILE);
336
446
  const promptEntries = await readAllSessionJsonl(sessionDir, PROMPTS_FILE);
447
+ const allPreBlobEntries = await readAllSessionJsonl(sessionDir, PRE_BLOBS_FILE);
448
+ const preBlobTurnById = /* @__PURE__ */ new Map();
449
+ for (const e of allPreBlobEntries) {
450
+ const id = e.tool_use_id;
451
+ if (id && typeof e.turn === "number") preBlobTurnById.set(id, e.turn);
452
+ }
453
+ for (const entry2 of allChangeEntries) {
454
+ const id = entry2.tool_use_id;
455
+ if (id && preBlobTurnById.has(id)) {
456
+ entry2.turn = preBlobTurnById.get(id);
457
+ }
458
+ }
459
+ const consumedPairs = await readConsumedPairs(sessionDir);
460
+ const changeEntries = allChangeEntries.filter((e) => !consumedPairs.has(consumedKey(e)));
461
+ const preBlobEntriesForTurnFix = allPreBlobEntries.filter(
462
+ (e) => !consumedPairs.has(consumedKey(e))
463
+ );
337
464
  const hasTurnData = promptEntries.some((e) => typeof e.turn === "number" && e.turn > 0);
338
465
  let aiFiles;
339
466
  let prompts;
340
467
  let relevantPromptEntries;
468
+ const relevantTurns = /* @__PURE__ */ new Set();
341
469
  if (hasTurnData) {
342
- aiFiles = [
343
- ...new Set(
344
- changeEntries.map((e) => e.file).filter((f) => f && commitFileSet.has(f))
345
- )
346
- ];
347
- const relevantTurns = /* @__PURE__ */ new Set();
470
+ const aiFileSet = /* @__PURE__ */ new Set();
471
+ for (const e of changeEntries) {
472
+ const f = e.file;
473
+ if (f && commitFileSet.has(f)) aiFileSet.add(f);
474
+ }
475
+ for (const e of preBlobEntriesForTurnFix) {
476
+ const f = e.file;
477
+ if (f && commitFileSet.has(f)) aiFileSet.add(f);
478
+ }
479
+ aiFiles = [...aiFileSet];
348
480
  for (const entry2 of changeEntries) {
349
481
  const file = entry2.file;
350
482
  if (file && commitFileSet.has(file)) {
351
483
  relevantTurns.add(typeof entry2.turn === "number" ? entry2.turn : 0);
352
484
  }
353
485
  }
486
+ for (const entry2 of preBlobEntriesForTurnFix) {
487
+ const file = entry2.file;
488
+ if (file && commitFileSet.has(file)) {
489
+ relevantTurns.add(typeof entry2.turn === "number" ? entry2.turn : 0);
490
+ }
491
+ }
354
492
  relevantPromptEntries = promptEntries.filter((e) => {
355
493
  const turn = typeof e.turn === "number" ? e.turn : 0;
356
494
  return relevantTurns.has(turn);
@@ -362,8 +500,18 @@ async function recordCommitEntry(opts) {
362
500
  relevantPromptEntries = promptEntries;
363
501
  }
364
502
  const transcriptPath = opts.transcriptPath ?? await readSavedTranscriptPath(sessionDir);
503
+ let crossTurnCommit = false;
504
+ if (hasTurnData && relevantTurns.size > 0) {
505
+ const turnFilePath = join2(sessionDir, TURN_FILE);
506
+ let currentTurn = 0;
507
+ if (existsSync3(turnFilePath)) {
508
+ currentTurn = Number.parseInt((await readFile3(turnFilePath, "utf-8")).trim(), 10) || 0;
509
+ }
510
+ const minRelevantTurn = Math.min(...relevantTurns);
511
+ crossTurnCommit = minRelevantTurn < currentTurn;
512
+ }
365
513
  let interactions;
366
- if (transcriptPath && prompts.length > 0) {
514
+ if (transcriptPath && prompts.length > 0 && !crossTurnCommit) {
367
515
  const allInteractions = await claudeCode.extractInteractions(transcriptPath);
368
516
  interactions = allInteractions.length > 0 ? allInteractions.slice(-prompts.length) : prompts.map((p) => ({ prompt: p, response: null }));
369
517
  } else {
@@ -372,13 +520,23 @@ async function recordCommitEntry(opts) {
372
520
  if (hasTurnData) {
373
521
  attachFilesTouched(changeEntries, relevantPromptEntries, interactions, commitFileSet);
374
522
  }
523
+ const lineCounts = await computeLineAttribution({
524
+ sessionDir,
525
+ commitFileSet,
526
+ aiFileSet: new Set(aiFiles),
527
+ relevantTurns,
528
+ hasTurnData,
529
+ changeEntries
530
+ });
375
531
  const entry = buildEntry({
376
532
  sessionId: opts.sessionId,
377
533
  interactions,
378
534
  commitFiles,
379
- aiFiles
535
+ aiFiles,
536
+ lineCounts: lineCounts ?? void 0
380
537
  });
381
538
  await writeNote(commitSha, entry);
539
+ await recordConsumedPairs(sessionDir, changeEntries, commitFileSet);
382
540
  return { promptCount: interactions.length, aiRatio: entry.ai_ratio };
383
541
  }
384
542
  function attachFilesTouched(changeEntries, promptEntries, interactions, commitFileSet) {
@@ -403,7 +561,17 @@ function attachFilesTouched(changeEntries, promptEntries, interactions, commitFi
403
561
  async function readAllSessionJsonl(sessionDir, baseFile) {
404
562
  const stem = baseFile.slice(0, baseFile.lastIndexOf(".jsonl"));
405
563
  const files = await readdir(sessionDir).catch(() => []);
406
- const matching = files.filter((f) => f === baseFile || f.startsWith(`${stem}-`) && f.endsWith(".jsonl")).sort().map((f) => join2(sessionDir, f));
564
+ const matching = files.filter((f) => {
565
+ if (f === baseFile) return true;
566
+ const suffix = f.slice(stem.length + 1, -".jsonl".length);
567
+ return f.startsWith(`${stem}-`) && f.endsWith(".jsonl") && ARCHIVE_ID_RE.test(suffix);
568
+ }).sort((a, b) => {
569
+ const getId = (f) => {
570
+ const s = f.slice(stem.length + 1, -".jsonl".length);
571
+ return s ? parseInt(s, 36) : Infinity;
572
+ };
573
+ return getId(a) - getId(b);
574
+ }).map((f) => join2(sessionDir, f));
407
575
  const all = [];
408
576
  for (const file of matching) {
409
577
  const entries = await readJsonlEntries(file);
@@ -417,6 +585,168 @@ async function readSavedTranscriptPath(sessionDir) {
417
585
  const p = (await readFile3(saved, "utf-8")).trim();
418
586
  return p || null;
419
587
  }
588
+ async function computeLineAttribution(opts) {
589
+ const { sessionDir, commitFileSet, aiFileSet, relevantTurns, hasTurnData, changeEntries } = opts;
590
+ let diffTreeOutput;
591
+ try {
592
+ diffTreeOutput = await git(["diff-tree", "--raw", "--root", "-r", "HEAD"]);
593
+ } catch {
594
+ return null;
595
+ }
596
+ const committedBlobs = parseDiffTreeBlobs(diffTreeOutput);
597
+ if (committedBlobs.size === 0) return null;
598
+ await ensureEmptyBlobInStore();
599
+ const preBlobEntries = await readAllSessionJsonl(sessionDir, PRE_BLOBS_FILE);
600
+ const hasPreBlobData = preBlobEntries.some((e) => e.blob);
601
+ const hasPostBlobData = changeEntries.some((e) => e.blob);
602
+ if (!hasPreBlobData && !hasPostBlobData) return null;
603
+ const preBlobById = /* @__PURE__ */ new Map();
604
+ const preBlobsFallback = /* @__PURE__ */ new Map();
605
+ for (const entry of preBlobEntries) {
606
+ const file = entry.file;
607
+ const turn = typeof entry.turn === "number" ? entry.turn : 0;
608
+ const id = entry.tool_use_id;
609
+ if (!file || !commitFileSet.has(file)) continue;
610
+ if (hasTurnData && !relevantTurns.has(turn)) continue;
611
+ if (id) {
612
+ preBlobById.set(id, { file, blob: entry.blob || "", turn });
613
+ } else {
614
+ if (!preBlobsFallback.has(file)) preBlobsFallback.set(file, []);
615
+ preBlobsFallback.get(file)?.push(entry.blob || "");
616
+ }
617
+ }
618
+ const turnPairsByFile = /* @__PURE__ */ new Map();
619
+ const hadNewFileEditByFile = /* @__PURE__ */ new Map();
620
+ const postBlobsFallback = /* @__PURE__ */ new Map();
621
+ for (const entry of changeEntries) {
622
+ const file = entry.file;
623
+ const turn = typeof entry.turn === "number" ? entry.turn : 0;
624
+ const id = entry.tool_use_id;
625
+ const postBlob = entry.blob || "";
626
+ if (!file || !commitFileSet.has(file) || !postBlob) continue;
627
+ if (id) {
628
+ const pre = preBlobById.get(id);
629
+ if (!pre) continue;
630
+ if (hasTurnData && !relevantTurns.has(pre.turn)) continue;
631
+ if (!pre.blob) {
632
+ hadNewFileEditByFile.set(file, true);
633
+ } else {
634
+ if (!turnPairsByFile.has(file)) turnPairsByFile.set(file, []);
635
+ turnPairsByFile.get(file)?.push({ preBlob: pre.blob, postBlob });
636
+ }
637
+ } else {
638
+ if (hasTurnData && !relevantTurns.has(turn)) continue;
639
+ if (!postBlobsFallback.has(file)) postBlobsFallback.set(file, []);
640
+ postBlobsFallback.get(file)?.push(postBlob);
641
+ }
642
+ }
643
+ for (const [file, postBlobs] of postBlobsFallback) {
644
+ const preBlobs = preBlobsFallback.get(file) ?? [];
645
+ const pairCount = Math.min(preBlobs.length, postBlobs.length);
646
+ for (let i = 0; i < pairCount; i++) {
647
+ const pre = preBlobs[i] || "";
648
+ const post = postBlobs[i] || "";
649
+ if (!pre) {
650
+ hadNewFileEditByFile.set(file, true);
651
+ } else if (post) {
652
+ if (!turnPairsByFile.has(file)) turnPairsByFile.set(file, []);
653
+ turnPairsByFile.get(file)?.push({ preBlob: pre, postBlob: post });
654
+ }
655
+ }
656
+ }
657
+ for (const file of aiFileSet) {
658
+ if (!commitFileSet.has(file)) continue;
659
+ const hasPairs = (turnPairsByFile.get(file) ?? []).length > 0;
660
+ const hasNewFileEdit = hadNewFileEditByFile.get(file) ?? false;
661
+ if (!hasPairs && !hasNewFileEdit) {
662
+ return null;
663
+ }
664
+ }
665
+ let totalAiAdded = 0;
666
+ let totalAdded = 0;
667
+ let totalDeleted = 0;
668
+ for (const file of commitFileSet) {
669
+ const blobs = committedBlobs.get(file);
670
+ if (!blobs) continue;
671
+ const { parentBlob, committedBlob } = blobs;
672
+ const turnPairs = turnPairsByFile.get(file) ?? [];
673
+ const hadNewFileEdit = hadNewFileEditByFile.get(file) ?? false;
674
+ try {
675
+ const result = await computePositionAttribution(parentBlob, committedBlob, turnPairs);
676
+ if (hadNewFileEdit && aiFileSet.has(file) && turnPairs.length === 0) {
677
+ totalAiAdded += result.totalAddedLines;
678
+ } else {
679
+ totalAiAdded += result.aiAddedLines;
680
+ }
681
+ totalAdded += result.totalAddedLines;
682
+ totalDeleted += result.deletedLines;
683
+ } catch {
684
+ }
685
+ }
686
+ return { aiAddedLines: totalAiAdded, totalAddedLines: totalAdded, deletedLines: totalDeleted };
687
+ }
688
+ function parseDiffTreeBlobs(output) {
689
+ const map = /* @__PURE__ */ new Map();
690
+ const ZEROS = "0000000000000000000000000000000000000000";
691
+ for (const line of output.split("\n")) {
692
+ const m = line.match(/^:\d+ \d+ ([0-9a-f]+) ([0-9a-f]+) \w+\t(.+)$/);
693
+ if (!m) continue;
694
+ const parentBlob = m[1] === ZEROS ? EMPTY_BLOB : m[1];
695
+ const committedBlob = m[2] === ZEROS ? EMPTY_BLOB : m[2];
696
+ const paths = m[3];
697
+ const parts = paths.split(" ");
698
+ const file = parts[parts.length - 1];
699
+ map.set(file, { parentBlob, committedBlob });
700
+ }
701
+ return map;
702
+ }
703
+ async function readConsumedPairs(sessionDir) {
704
+ const file = join2(sessionDir, COMMITTED_PAIRS_FILE);
705
+ if (!existsSync3(file)) return /* @__PURE__ */ new Set();
706
+ const entries = await readJsonlEntries(file);
707
+ const set = /* @__PURE__ */ new Set();
708
+ for (const e of entries) {
709
+ if (e.tool_use_id) {
710
+ set.add(`id:${e.tool_use_id}`);
711
+ } else if (e.turn !== void 0 && e.file) {
712
+ set.add(`${e.turn}:${e.file}`);
713
+ }
714
+ }
715
+ return set;
716
+ }
717
+ function consumedKey(entry) {
718
+ if (entry.tool_use_id) return `id:${entry.tool_use_id}`;
719
+ return `${entry.turn}:${entry.file}`;
720
+ }
721
+ async function recordConsumedPairs(sessionDir, changeEntries, commitFileSet) {
722
+ const seen = /* @__PURE__ */ new Set();
723
+ const pairsFile = join2(sessionDir, COMMITTED_PAIRS_FILE);
724
+ for (const entry of changeEntries) {
725
+ const file = entry.file;
726
+ if (!file || !commitFileSet.has(file)) continue;
727
+ const key = consumedKey(entry);
728
+ if (seen.has(key)) continue;
729
+ seen.add(key);
730
+ await appendJsonl(pairsFile, {
731
+ turn: entry.turn,
732
+ file,
733
+ tool_use_id: entry.tool_use_id ?? null
734
+ });
735
+ }
736
+ }
737
+ async function ensureEmptyBlobInStore() {
738
+ const tmp = join2(tmpdir(), `agentnote-empty-${process.pid}.tmp`);
739
+ try {
740
+ await writeFile2(tmp, "");
741
+ await git(["hash-object", "-w", tmp]);
742
+ } catch {
743
+ } finally {
744
+ try {
745
+ await unlink(tmp);
746
+ } catch {
747
+ }
748
+ }
749
+ }
420
750
 
421
751
  // src/paths.ts
422
752
  import { join as join3 } from "node:path";
@@ -484,34 +814,48 @@ async function commit(args2) {
484
814
 
485
815
  // src/commands/hook.ts
486
816
  import { existsSync as existsSync6 } from "node:fs";
487
- import { mkdir as mkdir2, readFile as readFile5, realpath, writeFile as writeFile2 } from "node:fs/promises";
817
+ import { mkdir as mkdir2, readFile as readFile5, realpath, writeFile as writeFile3 } from "node:fs/promises";
488
818
  import { isAbsolute, join as join5, relative } from "node:path";
489
819
 
490
820
  // src/core/rotate.ts
491
821
  import { existsSync as existsSync5 } from "node:fs";
492
- import { readdir as readdir2, rename, unlink } from "node:fs/promises";
822
+ import { rename } from "node:fs/promises";
493
823
  import { join as join4 } from "node:path";
494
- async function rotateLogs(sessionDir, commitSha, fileNames = [PROMPTS_FILE, CHANGES_FILE]) {
495
- await purgeRotatedArchives(sessionDir, fileNames);
824
+ async function rotateLogs(sessionDir, rotateId, fileNames = [PROMPTS_FILE, CHANGES_FILE]) {
496
825
  for (const name of fileNames) {
497
826
  const src = join4(sessionDir, name);
498
827
  if (existsSync5(src)) {
499
828
  const base = name.replace(".jsonl", "");
500
- await rename(src, join4(sessionDir, `${base}-${commitSha.slice(0, 8)}.jsonl`));
829
+ await rename(src, join4(sessionDir, `${base}-${rotateId}.jsonl`));
501
830
  }
502
831
  }
503
832
  }
504
- async function purgeRotatedArchives(sessionDir, fileNames) {
505
- const files = await readdir2(sessionDir).catch(() => []);
506
- for (const name of fileNames) {
507
- const stem = name.replace(".jsonl", "");
508
- const rotated = files.filter((f) => f.startsWith(`${stem}-`) && f.endsWith(".jsonl"));
509
- await Promise.all(rotated.map((f) => unlink(join4(sessionDir, f)).catch(() => {
510
- })));
511
- }
512
- }
513
833
 
514
834
  // src/commands/hook.ts
835
+ async function normalizeToRepoRelative(filePath) {
836
+ if (!isAbsolute(filePath)) return filePath;
837
+ try {
838
+ const rawRoot = (await git(["rev-parse", "--show-toplevel"])).trim();
839
+ const repoRoot2 = await realpath(rawRoot);
840
+ let normalized = filePath;
841
+ if (repoRoot2.startsWith("/private") && !normalized.startsWith("/private")) {
842
+ normalized = `/private${normalized}`;
843
+ } else if (!repoRoot2.startsWith("/private") && normalized.startsWith("/private")) {
844
+ normalized = normalized.replace(/^\/private/, "");
845
+ }
846
+ return relative(repoRoot2, normalized);
847
+ } catch {
848
+ return filePath;
849
+ }
850
+ }
851
+ async function blobHash(absPath) {
852
+ try {
853
+ if (!existsSync6(absPath)) return EMPTY_BLOB;
854
+ return (await git(["hash-object", "-w", absPath])).trim();
855
+ } catch {
856
+ return EMPTY_BLOB;
857
+ }
858
+ }
515
859
  async function readStdin() {
516
860
  const chunks = [];
517
861
  for await (const chunk of process.stdin) {
@@ -537,9 +881,9 @@ async function hook() {
537
881
  await mkdir2(sessionDir, { recursive: true });
538
882
  switch (event.kind) {
539
883
  case "session_start": {
540
- await writeFile2(join5(agentnoteDirPath, SESSION_FILE), event.sessionId);
884
+ await writeFile3(join5(agentnoteDirPath, SESSION_FILE), event.sessionId);
541
885
  if (event.transcriptPath) {
542
- await writeFile2(join5(sessionDir, TRANSCRIPT_PATH_FILE), event.transcriptPath);
886
+ await writeFile3(join5(sessionDir, TRANSCRIPT_PATH_FILE), event.transcriptPath);
543
887
  }
544
888
  await appendJsonl(join5(sessionDir, EVENTS_FILE), {
545
889
  event: "session_start",
@@ -547,12 +891,13 @@ async function hook() {
547
891
  timestamp: event.timestamp,
548
892
  model: event.model ?? null
549
893
  });
894
+ await writeFile3(join5(sessionDir, HEARTBEAT_FILE), String(Date.now()));
550
895
  break;
551
896
  }
552
897
  case "stop": {
553
- await writeFile2(join5(agentnoteDirPath, SESSION_FILE), event.sessionId);
898
+ await writeFile3(join5(agentnoteDirPath, SESSION_FILE), event.sessionId);
554
899
  if (event.transcriptPath) {
555
- await writeFile2(join5(sessionDir, TRANSCRIPT_PATH_FILE), event.transcriptPath);
900
+ await writeFile3(join5(sessionDir, TRANSCRIPT_PATH_FILE), event.transcriptPath);
556
901
  }
557
902
  await appendJsonl(join5(sessionDir, EVENTS_FILE), {
558
903
  event: "stop",
@@ -563,7 +908,7 @@ async function hook() {
563
908
  }
564
909
  case "prompt": {
565
910
  const rotateId = Date.now().toString(36);
566
- await rotateLogs(sessionDir, rotateId);
911
+ await rotateLogs(sessionDir, rotateId, [PROMPTS_FILE, CHANGES_FILE, PRE_BLOBS_FILE]);
567
912
  const turnPath = join5(sessionDir, TURN_FILE);
568
913
  let turn = 0;
569
914
  if (existsSync6(turnPath)) {
@@ -571,44 +916,58 @@ async function hook() {
571
916
  turn = Number.parseInt(raw2, 10) || 0;
572
917
  }
573
918
  turn += 1;
574
- await writeFile2(turnPath, String(turn));
919
+ await writeFile3(turnPath, String(turn));
575
920
  await appendJsonl(join5(sessionDir, PROMPTS_FILE), {
576
921
  event: "prompt",
577
922
  timestamp: event.timestamp,
578
923
  prompt: event.prompt,
579
924
  turn
580
925
  });
926
+ await writeFile3(join5(sessionDir, HEARTBEAT_FILE), String(Date.now()));
581
927
  break;
582
928
  }
583
- case "file_change": {
584
- let filePath = event.file ?? "";
585
- if (isAbsolute(filePath)) {
586
- try {
587
- const rawRoot = (await git(["rev-parse", "--show-toplevel"])).trim();
588
- const repoRoot2 = await realpath(rawRoot);
589
- let normalizedFile = filePath;
590
- if (repoRoot2.startsWith("/private") && !normalizedFile.startsWith("/private")) {
591
- normalizedFile = `/private${normalizedFile}`;
592
- } else if (!repoRoot2.startsWith("/private") && normalizedFile.startsWith("/private")) {
593
- normalizedFile = normalizedFile.replace(/^\/private/, "");
594
- }
595
- filePath = relative(repoRoot2, normalizedFile);
596
- } catch {
597
- }
929
+ case "pre_edit": {
930
+ const absPath = event.file ?? "";
931
+ const filePath = await normalizeToRepoRelative(absPath);
932
+ let turn = 0;
933
+ const turnPath = join5(sessionDir, TURN_FILE);
934
+ if (existsSync6(turnPath)) {
935
+ const raw2 = (await readFile5(turnPath, "utf-8")).trim();
936
+ turn = Number.parseInt(raw2, 10) || 0;
598
937
  }
938
+ const preBlob = isAbsolute(absPath) ? await blobHash(absPath) : EMPTY_BLOB;
939
+ await appendJsonl(join5(sessionDir, PRE_BLOBS_FILE), {
940
+ event: "pre_blob",
941
+ turn,
942
+ file: filePath,
943
+ blob: preBlob,
944
+ // tool_use_id links this pre-blob to its PostToolUse counterpart,
945
+ // enabling correct pairing even when async hooks fire out of order.
946
+ tool_use_id: event.toolUseId ?? null
947
+ });
948
+ break;
949
+ }
950
+ case "file_change": {
951
+ const absPath = event.file ?? "";
952
+ const filePath = await normalizeToRepoRelative(absPath);
599
953
  let turn = 0;
600
954
  const turnPath = join5(sessionDir, TURN_FILE);
601
955
  if (existsSync6(turnPath)) {
602
956
  const raw2 = (await readFile5(turnPath, "utf-8")).trim();
603
957
  turn = Number.parseInt(raw2, 10) || 0;
604
958
  }
959
+ const postBlob = isAbsolute(absPath) ? await blobHash(absPath) : EMPTY_BLOB;
605
960
  await appendJsonl(join5(sessionDir, CHANGES_FILE), {
606
961
  event: "file_change",
607
962
  timestamp: event.timestamp,
608
963
  tool: event.tool,
609
964
  file: filePath,
610
965
  session_id: event.sessionId,
611
- turn
966
+ turn,
967
+ blob: postBlob,
968
+ // Same tool_use_id as the matching pre_blob entry — used for reliable pairing
969
+ // even when this async hook fires after the next prompt has advanced the turn counter.
970
+ tool_use_id: event.toolUseId ?? null
612
971
  });
613
972
  break;
614
973
  }
@@ -644,7 +1003,7 @@ async function hook() {
644
1003
 
645
1004
  // src/commands/init.ts
646
1005
  import { existsSync as existsSync7 } from "node:fs";
647
- import { mkdir as mkdir3, writeFile as writeFile3 } from "node:fs/promises";
1006
+ import { mkdir as mkdir3, writeFile as writeFile4 } from "node:fs/promises";
648
1007
  import { join as join6 } from "node:path";
649
1008
  var WORKFLOW_TEMPLATE = `name: Agent Note
650
1009
  on:
@@ -691,7 +1050,7 @@ async function init(args2) {
691
1050
  results.push(" \xB7 workflow already exists at .github/workflows/agentnote.yml");
692
1051
  } else {
693
1052
  await mkdir3(workflowDir, { recursive: true });
694
- await writeFile3(workflowPath, WORKFLOW_TEMPLATE);
1053
+ await writeFile4(workflowPath, WORKFLOW_TEMPLATE);
695
1054
  results.push(" \u2713 workflow created at .github/workflows/agentnote.yml");
696
1055
  }
697
1056
  }
@@ -790,7 +1149,9 @@ async function collectReport(base) {
790
1149
  files_total: 0,
791
1150
  files_ai: 0,
792
1151
  files: [],
793
- interactions: []
1152
+ interactions: [],
1153
+ ai_added_lines: null,
1154
+ total_added_lines: null
794
1155
  });
795
1156
  continue;
796
1157
  }
@@ -811,12 +1172,23 @@ async function collectReport(base) {
811
1172
  path: f,
812
1173
  by_ai: filesByAi.includes(f)
813
1174
  })),
814
- interactions
1175
+ interactions,
1176
+ ai_added_lines: entry.ai_added_lines ?? null,
1177
+ total_added_lines: entry.total_added_lines ?? null
815
1178
  });
816
1179
  }
817
1180
  const tracked = commits.filter((c) => c.session_id !== null);
818
1181
  const totalFiles = tracked.reduce((s, c) => s + c.files_total, 0);
819
1182
  const totalFilesAi = tracked.reduce((s, c) => s + c.files_ai, 0);
1183
+ const allHaveLineData = tracked.every((c) => c.total_added_lines !== null);
1184
+ let overallAiRatio;
1185
+ if (allHaveLineData) {
1186
+ const totalAiLines = tracked.reduce((s, c) => s + (c.ai_added_lines ?? 0), 0);
1187
+ const totalAllLines = tracked.reduce((s, c) => s + (c.total_added_lines ?? 0), 0);
1188
+ overallAiRatio = totalAllLines > 0 ? Math.round(totalAiLines / totalAllLines * 100) : 0;
1189
+ } else {
1190
+ overallAiRatio = totalFiles > 0 ? Math.round(totalFilesAi / totalFiles * 100) : 0;
1191
+ }
820
1192
  return {
821
1193
  base,
822
1194
  head,
@@ -825,7 +1197,7 @@ async function collectReport(base) {
825
1197
  total_prompts: tracked.reduce((s, c) => s + c.prompts_count, 0),
826
1198
  total_files: totalFiles,
827
1199
  total_files_ai: totalFilesAi,
828
- overall_ai_ratio: totalFiles > 0 ? Math.round(totalFilesAi / totalFiles * 100) : 0,
1200
+ overall_ai_ratio: overallAiRatio,
829
1201
  commits
830
1202
  };
831
1203
  }
@@ -1082,6 +1454,8 @@ async function session(sessionId) {
1082
1454
  console.log(`Commits: ${matches.length}`);
1083
1455
  console.log();
1084
1456
  let totalPrompts = 0;
1457
+ let totalAiLines = 0;
1458
+ let totalAllLines = 0;
1085
1459
  let totalRatio = 0;
1086
1460
  let ratioCount = 0;
1087
1461
  for (const m of matches) {
@@ -1089,16 +1463,22 @@ async function session(sessionId) {
1089
1463
  if (m.entry) {
1090
1464
  const promptCount = m.entry.interactions?.length ?? m.entry.prompts?.length ?? 0;
1091
1465
  totalPrompts += promptCount;
1092
- totalRatio += m.entry.ai_ratio;
1093
- ratioCount++;
1466
+ if (m.entry.ai_added_lines !== void 0 && m.entry.total_added_lines !== void 0) {
1467
+ totalAiLines += m.entry.ai_added_lines;
1468
+ totalAllLines += m.entry.total_added_lines;
1469
+ } else {
1470
+ totalRatio += m.entry.ai_ratio;
1471
+ ratioCount++;
1472
+ }
1094
1473
  suffix = ` [\u{1F916}${m.entry.ai_ratio}% | ${promptCount}p]`;
1095
1474
  }
1096
1475
  console.log(`${m.shortInfo}${suffix}`);
1097
1476
  }
1098
1477
  console.log();
1099
- if (ratioCount > 0) {
1100
- const avgRatio = Math.round(totalRatio / ratioCount);
1101
- console.log(`Total: ${totalPrompts} prompts, avg AI ratio ${avgRatio}%`);
1478
+ const displayRatio = totalAllLines > 0 ? Math.round(totalAiLines / totalAllLines * 100) : ratioCount > 0 ? Math.round(totalRatio / ratioCount) : null;
1479
+ if (displayRatio !== null) {
1480
+ const lineDetail = totalAllLines > 0 ? ` (${totalAiLines}/${totalAllLines} lines)` : "";
1481
+ console.log(`Total: ${totalPrompts} prompts, AI ratio ${displayRatio}%${lineDetail}`);
1102
1482
  }
1103
1483
  }
1104
1484
 
@@ -1120,7 +1500,8 @@ async function show(commitRef) {
1120
1500
  if (entry) {
1121
1501
  console.log();
1122
1502
  const ratioBar = renderRatioBar(entry.ai_ratio);
1123
- console.log(`ai: ${entry.ai_ratio}% ${ratioBar}`);
1503
+ const lineDetail = entry.ai_added_lines !== void 0 && entry.total_added_lines !== void 0 && entry.total_added_lines > 0 ? ` (${entry.ai_added_lines}/${entry.total_added_lines} lines)` : "";
1504
+ console.log(`ai: ${entry.ai_ratio}%${lineDetail} ${ratioBar}`);
1124
1505
  console.log(
1125
1506
  `files: ${entry.files_in_commit.length} changed, ${entry.files_by_ai.length} by AI`
1126
1507
  );
@@ -1181,7 +1562,7 @@ function truncateLines(text, maxLen) {
1181
1562
  // src/commands/status.ts
1182
1563
  import { existsSync as existsSync8 } from "node:fs";
1183
1564
  import { readFile as readFile6 } from "node:fs/promises";
1184
- var VERSION = "0.1.6";
1565
+ var VERSION = "0.1.7";
1185
1566
  async function status() {
1186
1567
  console.log(`agentnote v${VERSION}`);
1187
1568
  console.log();
@@ -1210,7 +1591,7 @@ async function status() {
1210
1591
  }
1211
1592
 
1212
1593
  // src/cli.ts
1213
- var VERSION2 = "0.1.6";
1594
+ var VERSION2 = "0.1.7";
1214
1595
  var HELP = `
1215
1596
  agentnote v${VERSION2} \u2014 remember why your code changed
1216
1597
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wasabeef/agentnote",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Remember why your code changed. Link AI agent sessions to git commits.",
5
5
  "type": "module",
6
6
  "bin": {