@oh-my-pi/pi-coding-agent 17.2.5 → 17.2.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 (201) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/dist/{CHANGELOG-q2w43aw3.md → CHANGELOG-1p8yvde7.md} +35 -1
  3. package/dist/cli.js +3340 -3339
  4. package/dist/types/advisor/advise-tool.d.ts +10 -4
  5. package/dist/types/autoresearch/tools/init-experiment.d.ts +19 -7
  6. package/dist/types/autoresearch/tools/log-experiment.d.ts +19 -11
  7. package/dist/types/autoresearch/tools/run-experiment.d.ts +4 -2
  8. package/dist/types/autoresearch/tools/update-notes.d.ts +5 -2
  9. package/dist/types/cli/gc-cli.d.ts +1 -0
  10. package/dist/types/commit/agentic/tools/analyze-file.d.ts +5 -2
  11. package/dist/types/commit/agentic/tools/git-file-diff.d.ts +5 -2
  12. package/dist/types/commit/agentic/tools/git-hunk.d.ts +6 -2
  13. package/dist/types/commit/agentic/tools/git-overview.d.ts +5 -2
  14. package/dist/types/commit/agentic/tools/propose-changelog.d.ts +29 -7
  15. package/dist/types/commit/agentic/tools/propose-commit.d.ts +14 -4
  16. package/dist/types/commit/agentic/tools/recent-commits.d.ts +4 -2
  17. package/dist/types/commit/agentic/tools/schemas.d.ts +7 -3
  18. package/dist/types/commit/agentic/tools/split-commit.d.ts +37 -10
  19. package/dist/types/commit/changelog/generate.d.ts +13 -3
  20. package/dist/types/commit/shared-llm.d.ts +13 -4
  21. package/dist/types/config/config-file.d.ts +1 -1
  22. package/dist/types/config/models-config-schema-bundle.d.ts +1992 -981
  23. package/dist/types/config/models-config-schema.d.ts +1201 -580
  24. package/dist/types/config/models-config.d.ts +355 -371
  25. package/dist/types/edit/hashline/params.d.ts +4 -2
  26. package/dist/types/edit/modes/apply-patch.d.ts +4 -2
  27. package/dist/types/edit/modes/patch.d.ts +16 -5
  28. package/dist/types/edit/modes/replace.d.ts +8 -3
  29. package/dist/types/eval/bridge-timeout.d.ts +5 -2
  30. package/dist/types/eval/completion-bridge.d.ts +13 -0
  31. package/dist/types/eval/py/tool-bridge.d.ts +12 -0
  32. package/dist/types/extensibility/custom-commands/types.d.ts +4 -4
  33. package/dist/types/extensibility/custom-tools/types.d.ts +4 -4
  34. package/dist/types/extensibility/extensions/types.d.ts +4 -4
  35. package/dist/types/extensibility/hooks/types.d.ts +4 -4
  36. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +1 -1
  37. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -1
  38. package/dist/types/extensibility/legacy-typebox.d.ts +8 -0
  39. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +6 -13
  40. package/dist/types/goals/tools/goal-tool.d.ts +12 -4
  41. package/dist/types/launch/client.d.ts +9 -1
  42. package/dist/types/launch/protocol.d.ts +17 -0
  43. package/dist/types/lsp/index.d.ts +15 -5
  44. package/dist/types/lsp/types.d.ts +15 -5
  45. package/dist/types/modes/components/btw-panel.d.ts +3 -0
  46. package/dist/types/modes/controllers/btw-controller.d.ts +2 -0
  47. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  48. package/dist/types/modes/interactive-mode.d.ts +4 -1
  49. package/dist/types/modes/types.d.ts +3 -1
  50. package/dist/types/security/contracts/schemas.d.ts +1115 -537
  51. package/dist/types/security/publication.d.ts +92 -34
  52. package/dist/types/session/agent-session-types.d.ts +5 -0
  53. package/dist/types/session/agent-session.d.ts +26 -2
  54. package/dist/types/session/launch-completion.d.ts +10 -0
  55. package/dist/types/session/session-entries.d.ts +15 -1
  56. package/dist/types/session/session-manager.d.ts +7 -0
  57. package/dist/types/session/yield-queue.d.ts +6 -1
  58. package/dist/types/task/types.d.ts +63 -18
  59. package/dist/types/tools/ask.d.ts +34 -8
  60. package/dist/types/tools/ast-edit.d.ts +16 -4
  61. package/dist/types/tools/ast-grep.d.ts +12 -4
  62. package/dist/types/tools/bash.d.ts +20 -11
  63. package/dist/types/tools/browser.d.ts +64 -20
  64. package/dist/types/tools/checkpoint.d.ts +16 -8
  65. package/dist/types/tools/computer.d.ts +1 -1
  66. package/dist/types/tools/debug.d.ts +118 -48
  67. package/dist/types/tools/eval.d.ts +11 -5
  68. package/dist/types/tools/gh.d.ts +74 -26
  69. package/dist/types/tools/glob.d.ts +16 -6
  70. package/dist/types/tools/grep.d.ts +18 -6
  71. package/dist/types/tools/hub/index.d.ts +116 -46
  72. package/dist/types/tools/image-gen.d.ts +25 -8
  73. package/dist/types/tools/index.d.ts +9 -0
  74. package/dist/types/tools/inspect-image.d.ts +10 -4
  75. package/dist/types/tools/learn.d.ts +26 -8
  76. package/dist/types/tools/manage-skill.d.ts +16 -6
  77. package/dist/types/tools/memory-edit.d.ts +18 -6
  78. package/dist/types/tools/memory-recall.d.ts +8 -4
  79. package/dist/types/tools/memory-reflect.d.ts +10 -4
  80. package/dist/types/tools/memory-retain.d.ts +14 -4
  81. package/dist/types/tools/read.d.ts +8 -4
  82. package/dist/types/tools/security-scan.d.ts +82 -30
  83. package/dist/types/tools/todo.d.ts +30 -10
  84. package/dist/types/tools/tts.d.ts +12 -5
  85. package/dist/types/tools/vibe.d.ts +42 -18
  86. package/dist/types/tools/write.d.ts +10 -4
  87. package/dist/types/web/search/index.d.ts +20 -6
  88. package/package.json +13 -13
  89. package/scripts/legacy-pi-virtual-module.ts +2 -2
  90. package/src/advisor/advise-tool.ts +1 -1
  91. package/src/advisor/config.ts +1 -1
  92. package/src/autoresearch/tools/init-experiment.ts +1 -2
  93. package/src/autoresearch/tools/log-experiment.ts +1 -2
  94. package/src/autoresearch/tools/run-experiment.ts +1 -1
  95. package/src/autoresearch/tools/update-notes.ts +1 -1
  96. package/src/cli/gc-cli.ts +641 -21
  97. package/src/commit/agentic/tools/analyze-file.ts +1 -1
  98. package/src/commit/agentic/tools/git-file-diff.ts +1 -1
  99. package/src/commit/agentic/tools/git-hunk.ts +1 -1
  100. package/src/commit/agentic/tools/git-overview.ts +1 -1
  101. package/src/commit/agentic/tools/propose-changelog.ts +1 -1
  102. package/src/commit/agentic/tools/propose-commit.ts +1 -1
  103. package/src/commit/agentic/tools/recent-commits.ts +1 -1
  104. package/src/commit/agentic/tools/schemas.ts +1 -1
  105. package/src/commit/agentic/tools/split-commit.ts +1 -1
  106. package/src/commit/analysis/summary.ts +1 -1
  107. package/src/commit/changelog/generate.ts +1 -1
  108. package/src/config/config-file.ts +2 -2
  109. package/src/config/model-discovery.ts +14 -14
  110. package/src/config/model-registry.ts +10 -8
  111. package/src/config/models-config-schema-bundle.ts +1 -8
  112. package/src/config/settings.ts +1 -1
  113. package/src/discovery/codex.ts +22 -9
  114. package/src/edit/hashline/params.ts +1 -1
  115. package/src/edit/modes/apply-patch.ts +1 -1
  116. package/src/edit/modes/patch.ts +1 -1
  117. package/src/edit/modes/replace.ts +2 -1
  118. package/src/eval/agent-bridge.ts +4 -2
  119. package/src/eval/bridge-timeout.ts +5 -2
  120. package/src/eval/completion-bridge.ts +3 -2
  121. package/src/eval/executor-base.ts +20 -1
  122. package/src/eval/js/context-manager.ts +94 -6
  123. package/src/eval/py/tool-bridge.ts +29 -9
  124. package/src/export/html/index.ts +10 -3
  125. package/src/export/share.ts +4 -0
  126. package/src/extensibility/custom-commands/loader.ts +3 -3
  127. package/src/extensibility/custom-commands/types.ts +4 -4
  128. package/src/extensibility/custom-tools/loader.ts +3 -3
  129. package/src/extensibility/custom-tools/types.ts +5 -4
  130. package/src/extensibility/extensions/loader.ts +3 -3
  131. package/src/extensibility/extensions/types.ts +5 -4
  132. package/src/extensibility/hooks/loader.ts +3 -3
  133. package/src/extensibility/hooks/types.ts +4 -4
  134. package/src/extensibility/legacy-pi-ai-shim.ts +1 -1
  135. package/src/extensibility/legacy-pi-coding-agent-shim.ts +2 -2
  136. package/src/extensibility/legacy-typebox.ts +101 -0
  137. package/src/extensibility/plugins/legacy-pi-compat.ts +13 -22
  138. package/src/goals/tools/goal-tool.ts +1 -1
  139. package/src/launch/broker.ts +222 -6
  140. package/src/launch/client.ts +161 -9
  141. package/src/launch/protocol.ts +52 -0
  142. package/src/lsp/types.ts +1 -1
  143. package/src/main.ts +13 -6
  144. package/src/mcp/config-writer.ts +1 -1
  145. package/src/modes/components/btw-panel.ts +41 -4
  146. package/src/modes/controllers/btw-controller.ts +55 -7
  147. package/src/modes/controllers/command-controller.ts +31 -0
  148. package/src/modes/controllers/input-controller.ts +24 -7
  149. package/src/modes/interactive-mode.ts +16 -2
  150. package/src/modes/theme/theme.ts +3 -3
  151. package/src/modes/types.ts +8 -1
  152. package/src/prompts/session/launch-completion.md +1 -0
  153. package/src/sdk.ts +15 -0
  154. package/src/security/contracts/schemas.ts +202 -184
  155. package/src/security/contracts/validation.ts +6 -2
  156. package/src/security/publication.ts +1 -1
  157. package/src/security/store.ts +2 -2
  158. package/src/session/agent-session-types.ts +6 -0
  159. package/src/session/agent-session.ts +190 -14
  160. package/src/session/launch-completion.ts +37 -0
  161. package/src/session/session-context.ts +26 -0
  162. package/src/session/session-entries.ts +17 -1
  163. package/src/session/session-manager.ts +21 -0
  164. package/src/session/yield-queue.ts +121 -16
  165. package/src/slash-commands/builtin-registry.ts +10 -1
  166. package/src/task/types.ts +1 -1
  167. package/src/tools/ask.ts +1 -1
  168. package/src/tools/ast-edit.ts +1 -1
  169. package/src/tools/ast-grep.ts +1 -1
  170. package/src/tools/bash.ts +61 -3
  171. package/src/tools/browser/cmux/cmux-tab.ts +92 -4
  172. package/src/tools/browser.ts +1 -1
  173. package/src/tools/checkpoint.ts +1 -1
  174. package/src/tools/computer.ts +1 -1
  175. package/src/tools/debug.ts +1 -1
  176. package/src/tools/eval.ts +1 -1
  177. package/src/tools/gh.ts +1 -2
  178. package/src/tools/glob.ts +1 -1
  179. package/src/tools/grep.ts +1 -1
  180. package/src/tools/hub/index.ts +1 -1
  181. package/src/tools/hub/launch.ts +122 -6
  182. package/src/tools/image-gen.ts +1 -1
  183. package/src/tools/index.ts +9 -0
  184. package/src/tools/inspect-image.ts +1 -1
  185. package/src/tools/learn.ts +1 -1
  186. package/src/tools/manage-skill.ts +1 -1
  187. package/src/tools/memory-edit.ts +1 -1
  188. package/src/tools/memory-recall.ts +1 -1
  189. package/src/tools/memory-reflect.ts +1 -1
  190. package/src/tools/memory-retain.ts +1 -1
  191. package/src/tools/read.ts +1 -1
  192. package/src/tools/security-scan.ts +1 -1
  193. package/src/tools/todo.ts +1 -1
  194. package/src/tools/tts.ts +1 -1
  195. package/src/tools/vibe.ts +2 -1
  196. package/src/tools/write.ts +1 -1
  197. package/src/web/search/index.ts +2 -1
  198. package/dist/types/config/file-lock.d.ts +0 -29
  199. package/dist/types/extensibility/typebox.d.ts +0 -181
  200. package/src/config/file-lock.ts +0 -164
  201. package/src/extensibility/typebox.ts +0 -958
package/src/cli/gc-cli.ts CHANGED
@@ -2,7 +2,16 @@ import { Database } from "bun:sqlite";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
4
  import { gunzipSync, gzipSync } from "node:zlib";
5
- import { getAgentDir, getBlobsDir, getHistoryDbPath, getModelDbPath, getSessionsDir } from "@oh-my-pi/pi-utils";
5
+ import { withStatsSyncLock } from "@oh-my-pi/omp-stats/aggregator";
6
+ import {
7
+ getAgentDir,
8
+ getBlobsDir,
9
+ getHistoryDbPath,
10
+ getModelDbPath,
11
+ getSessionsDir,
12
+ getStatsDbPath,
13
+ readLines,
14
+ } from "@oh-my-pi/pi-utils";
6
15
  import { Settings } from "../config/settings";
7
16
  import { getDefault } from "../config/settings-schema";
8
17
  import { BLOB_HASH_RE } from "../session/blob-store";
@@ -54,6 +63,7 @@ export interface ArchiveGcResult {
54
63
  wouldArchive: number;
55
64
  archived: number;
56
65
  historyRowsDeleted: number;
66
+ statsRowsDeleted: number;
57
67
  ftsRebuilt: boolean;
58
68
  errors: string[];
59
69
  }
@@ -420,33 +430,39 @@ function sessionArtifactsPath(sessionPath: string): string {
420
430
  return sessionPath.slice(0, -SESSION_SUFFIX.length);
421
431
  }
422
432
 
423
- function sessionIdFromSessionPath(sessionPath: string): string | undefined {
424
- const basename = path.basename(sessionPath);
425
- if (basename.endsWith(COMPRESSED_SESSION_SUFFIX)) {
426
- const id = basename.slice(0, -COMPRESSED_SESSION_SUFFIX.length);
427
- return id || undefined;
428
- }
429
- if (basename.endsWith(SESSION_SUFFIX)) {
430
- const id = basename.slice(0, -SESSION_SUFFIX.length);
431
- return id || undefined;
432
- }
433
- return undefined;
433
+ interface SessionLineageHeader {
434
+ id: string;
435
+ parentSession?: string;
436
+ previousSessionFiles: string[];
434
437
  }
435
438
 
436
- function sessionIdFromSessionText(text: string): string | undefined {
439
+ function sessionLineageHeaderFromText(text: string): SessionLineageHeader | undefined {
437
440
  let sawTitleSlot = false;
438
441
  for (const rawLine of text.split(/\r?\n/)) {
439
442
  const line = rawLine.trim();
440
443
  if (!line) continue;
441
444
  try {
442
- const record = JSON.parse(line) as { type?: unknown; id?: unknown };
445
+ const record = JSON.parse(line) as {
446
+ type?: unknown;
447
+ id?: unknown;
448
+ parentSession?: unknown;
449
+ previousSessionFiles?: unknown;
450
+ };
443
451
  if (!sawTitleSlot && record.type === "title") {
444
452
  sawTitleSlot = true;
445
453
  continue;
446
454
  }
447
- return record.type === "session" && typeof record.id === "string" && record.id.length > 0
448
- ? record.id
449
- : undefined;
455
+ if (record.type !== "session" || typeof record.id !== "string" || record.id.length === 0) return undefined;
456
+ return {
457
+ id: record.id,
458
+ parentSession: typeof record.parentSession === "string" ? record.parentSession : undefined,
459
+ previousSessionFiles: Array.isArray(record.previousSessionFiles)
460
+ ? record.previousSessionFiles.filter(
461
+ (previousSessionFile): previousSessionFile is string =>
462
+ typeof previousSessionFile === "string" && previousSessionFile.length > 0,
463
+ )
464
+ : [],
465
+ };
450
466
  } catch {
451
467
  return undefined;
452
468
  }
@@ -454,8 +470,17 @@ function sessionIdFromSessionText(text: string): string | undefined {
454
470
  return undefined;
455
471
  }
456
472
 
457
- async function archivedSessionIdFromFile(file: string): Promise<string | undefined> {
458
- return sessionIdFromSessionText(await readTextIfPresent(file)) ?? sessionIdFromSessionPath(file);
473
+ async function readSessionLineageHeader(file: string): Promise<SessionLineageHeader | undefined> {
474
+ const decoder = new TextDecoder();
475
+ const lines: string[] = [];
476
+ for await (const line of readLines(Bun.file(file).stream())) {
477
+ const decoded = decoder.decode(line).trim();
478
+ if (!decoded) continue;
479
+ lines.push(decoded);
480
+ const header = sessionLineageHeaderFromText(lines.join("\n"));
481
+ if (header || lines.length >= 2) return header;
482
+ }
483
+ return undefined;
459
484
  }
460
485
 
461
486
  async function gzipSessionFile(source: string, destination: string): Promise<void> {
@@ -563,7 +588,7 @@ function deleteHistoryRowsForSessions(dbPath: string, sessionIds: string[]): { d
563
588
  async function collectArchivedSessionIds(archiveRoot: string): Promise<string[]> {
564
589
  const ids = new Set<string>();
565
590
  for (const file of await collectCompressedJsonlFiles(archiveRoot)) {
566
- const id = await archivedSessionIdFromFile(file);
591
+ const id = sessionLineageHeaderFromText(await readTextIfPresent(file))?.id;
567
592
  if (id) ids.add(id);
568
593
  }
569
594
  return [...ids].sort();
@@ -594,6 +619,597 @@ async function cleanupHistoryRowsForArchivedSessions(
594
619
  }
595
620
  }
596
621
 
622
+ const STATS_SESSION_TABLES = ["messages", "user_messages", "tool_calls", "file_offsets"] as const;
623
+ const STATS_ENTRY_TABLES = ["messages", "user_messages", "tool_calls"] as const;
624
+ type StatsEntryTable = (typeof STATS_ENTRY_TABLES)[number];
625
+
626
+ const STATS_IDENTITY_COLUMNS: Record<StatsEntryTable, readonly string[]> = {
627
+ messages: ["entry_id", "timestamp"],
628
+ user_messages: ["entry_id", "timestamp"],
629
+ tool_calls: ["entry_id", "timestamp", "tool_call_id"],
630
+ };
631
+
632
+ interface StatsSession {
633
+ path: string;
634
+ id: string;
635
+ parentSession?: string;
636
+ historicalPaths: string[];
637
+ identities: Record<StatsEntryTable, StatsEntryIdentity[]>;
638
+ }
639
+
640
+ interface StatsLineageNode extends StatsSession {
641
+ statsPaths: Set<string>;
642
+ identityKeys: Set<string>;
643
+ }
644
+
645
+ interface StatsEntryIdentity {
646
+ entryId: string;
647
+ timestamp: number;
648
+ toolCallId: string;
649
+ }
650
+
651
+ interface StatsTransferTarget {
652
+ path: string;
653
+ identities: Record<StatsEntryTable, StatsEntryIdentity[]>;
654
+ }
655
+
656
+ interface StatsCleanupPlan {
657
+ sessionPaths: string[];
658
+ retainedSessions: StatsLineageNode[];
659
+ transfers: StatsTransferTarget[];
660
+ archivedIdentityKeys: Set<string>;
661
+ preserveAll: boolean;
662
+ }
663
+
664
+ interface StatsCleanupContext {
665
+ plans: StatsCleanupPlan[];
666
+ incompleteRetainedSessions: StatsLineageNode[];
667
+ }
668
+
669
+ function createStatsIdentities(): Record<StatsEntryTable, StatsEntryIdentity[]> {
670
+ return {
671
+ messages: [],
672
+ user_messages: [],
673
+ tool_calls: [],
674
+ };
675
+ }
676
+
677
+ function statsIdentityKey(table: StatsEntryTable, identity: StatsEntryIdentity): string {
678
+ return `${table}\0${identity.entryId}\0${identity.timestamp}\0${identity.toolCallId}`;
679
+ }
680
+
681
+ function statsIdentityKeys(identities: Record<StatsEntryTable, StatsEntryIdentity[]>): Set<string> {
682
+ const keys = new Set<string>();
683
+ for (const table of STATS_ENTRY_TABLES) {
684
+ for (const identity of identities[table]) keys.add(statsIdentityKey(table, identity));
685
+ }
686
+ return keys;
687
+ }
688
+
689
+ function tableHasColumn(db: Database, table: string, column: string): boolean {
690
+ const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: string | null }>;
691
+ return rows.some(row => row.name === column);
692
+ }
693
+
694
+ function collectStoredStatsSessionPaths(db: Database): string[] {
695
+ const sessionPaths = new Set<string>();
696
+ for (const table of STATS_SESSION_TABLES) {
697
+ if (!tableExists(db, table) || !tableHasColumn(db, table, "session_file")) continue;
698
+ const rows = db.prepare(`SELECT DISTINCT session_file FROM ${table}`).all() as Array<{
699
+ session_file?: string | null;
700
+ }>;
701
+ for (const row of rows) {
702
+ if (typeof row.session_file === "string") sessionPaths.add(row.session_file);
703
+ }
704
+ }
705
+ return [...sessionPaths];
706
+ }
707
+
708
+ /**
709
+ * `/move` preserves the session filename and artifacts-directory basename.
710
+ * Recover the top-level path represented by any main or nested stats row so
711
+ * one archived logical session can reconcile every historical location.
712
+ */
713
+ function logicalSessionRootForStatsPath(statsPath: string, sessionPath: string): string | undefined {
714
+ const sessionFilename = path.basename(sessionPath);
715
+ if (path.basename(statsPath) === sessionFilename) return statsPath;
716
+
717
+ const artifactsDirname = sessionFilename.slice(0, -SESSION_SUFFIX.length);
718
+ let directory = path.dirname(path.resolve(statsPath));
719
+ while (true) {
720
+ if (path.basename(directory) === artifactsDirname) return `${directory}${SESSION_SUFFIX}`;
721
+ const parent = path.dirname(directory);
722
+ if (parent === directory) return undefined;
723
+ directory = parent;
724
+ }
725
+ }
726
+
727
+ function sessionPathEncodesId(sessionPath: string, sessionId: string): boolean {
728
+ const stem = path.basename(sessionPath, SESSION_SUFFIX);
729
+ return stem === sessionId || stem.endsWith(`_${sessionId}`);
730
+ }
731
+
732
+ function managedHistoricalSessionPaths(
733
+ header: SessionLineageHeader,
734
+ currentSessionPath: string,
735
+ sessionsRoot: string,
736
+ ): string[] {
737
+ const root = path.resolve(sessionsRoot);
738
+ const filename = path.basename(currentSessionPath);
739
+ const paths = new Set<string>();
740
+ for (const previousSessionFile of header.previousSessionFiles) {
741
+ if (!path.isAbsolute(previousSessionFile) || path.basename(previousSessionFile) !== filename) continue;
742
+ const resolved = path.resolve(previousSessionFile);
743
+ const relative = path.relative(root, resolved);
744
+ if (
745
+ !relative ||
746
+ relative === ".." ||
747
+ relative.startsWith(`..${path.sep}`) ||
748
+ path.isAbsolute(relative) ||
749
+ !resolved.endsWith(SESSION_SUFFIX)
750
+ ) {
751
+ continue;
752
+ }
753
+ paths.add(resolved);
754
+ }
755
+ return [...paths];
756
+ }
757
+
758
+ function resolveLogicalSessionMatch(
759
+ statsPath: string,
760
+ nodes: StatsLineageNode[],
761
+ ): { node: StatsLineageNode; logicalRoot: string } | undefined {
762
+ const matches: Array<{ node: StatsLineageNode; logicalRoot: string }> = [];
763
+ for (const node of nodes) {
764
+ const logicalRoot = logicalSessionRootForStatsPath(statsPath, node.path);
765
+ if (logicalRoot) matches.push({ node, logicalRoot });
766
+ }
767
+ const exact = matches.filter(match => path.resolve(match.logicalRoot) === path.resolve(match.node.path));
768
+ if (exact.length === 1) return exact[0];
769
+ if (exact.length > 1) return undefined;
770
+
771
+ const known = matches.filter(match =>
772
+ [...match.node.statsPaths].some(statsPath => path.resolve(statsPath) === path.resolve(match.logicalRoot)),
773
+ );
774
+ if (known.length === 1) return known[0];
775
+ if (known.length > 1) return undefined;
776
+
777
+ const idMatches = matches.filter(match => sessionPathEncodesId(match.logicalRoot, match.node.id));
778
+ return idMatches.length === 1 ? idMatches[0] : undefined;
779
+ }
780
+
781
+ /**
782
+ * Resolve retained peers through `parentSession`, which is historically either
783
+ * a session id or an absolute path. Ambiguous aliases are deliberately left
784
+ * unresolved so cleanup fails safe instead of transferring across sessions.
785
+ */
786
+ function buildStatsCleanupPlans(
787
+ archivedSessions: StatsSession[],
788
+ retainedSessions: StatsSession[],
789
+ dbPath: string,
790
+ ): StatsCleanupContext {
791
+ const archivedNodes: StatsLineageNode[] = archivedSessions.map(session => ({
792
+ ...session,
793
+ statsPaths: new Set([session.path, ...session.historicalPaths]),
794
+ identityKeys: statsIdentityKeys(session.identities),
795
+ }));
796
+ const retainedNodes: StatsLineageNode[] = retainedSessions.map(session => ({
797
+ ...session,
798
+ statsPaths: new Set([session.path, ...session.historicalPaths]),
799
+ identityKeys: statsIdentityKeys(session.identities),
800
+ }));
801
+ const nodes = [...archivedNodes, ...retainedNodes];
802
+
803
+ const db = new Database(dbPath);
804
+ try {
805
+ db.run("PRAGMA busy_timeout = 5000");
806
+ for (const storedPath of collectStoredStatsSessionPaths(db)) {
807
+ const match = resolveLogicalSessionMatch(storedPath, nodes);
808
+ if (match) match.node.statsPaths.add(match.logicalRoot);
809
+ }
810
+ } finally {
811
+ db.close();
812
+ }
813
+
814
+ for (const child of nodes) {
815
+ if (
816
+ !child.parentSession ||
817
+ (!path.isAbsolute(child.parentSession) && !child.parentSession.endsWith(SESSION_SUFFIX))
818
+ ) {
819
+ continue;
820
+ }
821
+ const match = resolveLogicalSessionMatch(child.parentSession, nodes);
822
+ if (match) match.node.statsPaths.add(match.logicalRoot);
823
+ }
824
+
825
+ const archivedPathClaimCounts = new Map<string, number>();
826
+ for (const archived of archivedNodes) {
827
+ for (const statsPath of archived.statsPaths) {
828
+ const key = path.resolve(statsPath);
829
+ archivedPathClaimCounts.set(key, (archivedPathClaimCounts.get(key) ?? 0) + 1);
830
+ }
831
+ }
832
+ const ambiguousArchivedPathKeys = new Set(
833
+ [...archivedPathClaimCounts].filter(([, count]) => count > 1).map(([key]) => key),
834
+ );
835
+
836
+ const aliases = new Map<string, StatsLineageNode | null>();
837
+ const identityKeysByNode = new Map<StatsLineageNode, Set<string>>();
838
+ for (const node of nodes) {
839
+ const keys = new Set([
840
+ `id:${node.id}`,
841
+ ...[...node.statsPaths].map(statsPath => `path:${path.resolve(statsPath)}`),
842
+ ]);
843
+ identityKeysByNode.set(node, keys);
844
+ for (const key of keys) {
845
+ if (!aliases.has(key)) {
846
+ aliases.set(key, node);
847
+ } else if (aliases.get(key) !== node) {
848
+ aliases.set(key, null);
849
+ }
850
+ }
851
+ }
852
+
853
+ const incompleteLineage = new Set<StatsLineageNode>();
854
+ const lineageKeysByNode = new Map<StatsLineageNode, Set<string>>();
855
+ for (const node of nodes) {
856
+ const lineageKeys = new Set(identityKeysByNode.get(node));
857
+ let current: StatsLineageNode | null = node;
858
+ const seen = new Set<StatsLineageNode>();
859
+ while (current?.parentSession) {
860
+ if (seen.has(current)) {
861
+ incompleteLineage.add(node);
862
+ break;
863
+ }
864
+ seen.add(current);
865
+ const parentReference = current.parentSession;
866
+ const parentKey =
867
+ path.isAbsolute(parentReference) || parentReference.endsWith(SESSION_SUFFIX)
868
+ ? `path:${path.resolve(parentReference)}`
869
+ : `id:${parentReference}`;
870
+ lineageKeys.add(parentKey);
871
+ const parent = aliases.get(parentKey);
872
+ if (!parent) {
873
+ incompleteLineage.add(node);
874
+ break;
875
+ }
876
+ for (const key of identityKeysByNode.get(parent) ?? []) lineageKeys.add(key);
877
+ current = parent;
878
+ }
879
+ lineageKeysByNode.set(node, lineageKeys);
880
+ }
881
+
882
+ const retainedPathKeys = new Set(
883
+ retainedNodes.flatMap(retained => [...retained.statsPaths].map(statsPath => path.resolve(statsPath))),
884
+ );
885
+ const plans = archivedNodes.map(archived => {
886
+ const archivedLineage = lineageKeysByNode.get(archived) ?? new Set<string>();
887
+ return {
888
+ sessionPaths: [...archived.statsPaths].filter(statsPath => {
889
+ const key = path.resolve(statsPath);
890
+ return !retainedPathKeys.has(key) && !ambiguousArchivedPathKeys.has(key);
891
+ }),
892
+ retainedSessions: retainedNodes.filter(retained => {
893
+ if (incompleteLineage.has(retained)) return false;
894
+ const retainedLineage = lineageKeysByNode.get(retained);
895
+ return retainedLineage ? [...retainedLineage].some(key => archivedLineage.has(key)) : false;
896
+ }),
897
+ transfers: [],
898
+ archivedIdentityKeys: archived.identityKeys,
899
+ preserveAll: false,
900
+ };
901
+ });
902
+ return {
903
+ plans,
904
+ incompleteRetainedSessions: retainedNodes.filter(retained => incompleteLineage.has(retained)),
905
+ };
906
+ }
907
+
908
+ function addSessionStatsIdentity(line: string, identities: Record<StatsEntryTable, StatsEntryIdentity[]>): void {
909
+ if (line.length === 0) return;
910
+ try {
911
+ const record: unknown = JSON.parse(line);
912
+ if (
913
+ !record ||
914
+ typeof record !== "object" ||
915
+ !("type" in record) ||
916
+ record.type !== "message" ||
917
+ !("id" in record) ||
918
+ typeof record.id !== "string" ||
919
+ record.id.length === 0 ||
920
+ !("message" in record) ||
921
+ !record.message ||
922
+ typeof record.message !== "object"
923
+ ) {
924
+ return;
925
+ }
926
+ const message = record.message;
927
+ if (!("role" in message)) return;
928
+ const parsedEntryTimestamp =
929
+ "timestamp" in record && typeof record.timestamp === "string" ? Date.parse(record.timestamp) : Number.NaN;
930
+ if (message.role === "user") {
931
+ identities.user_messages.push({
932
+ entryId: record.id,
933
+ timestamp: Number.isFinite(parsedEntryTimestamp) ? parsedEntryTimestamp : 0,
934
+ toolCallId: "",
935
+ });
936
+ return;
937
+ }
938
+ if (message.role !== "assistant") return;
939
+ const timestamp =
940
+ "timestamp" in message && typeof message.timestamp === "number" && Number.isFinite(message.timestamp)
941
+ ? message.timestamp
942
+ : Number.isFinite(parsedEntryTimestamp)
943
+ ? parsedEntryTimestamp
944
+ : 0;
945
+ identities.messages.push({ entryId: record.id, timestamp, toolCallId: "" });
946
+ if (!("content" in message) || !Array.isArray(message.content)) return;
947
+ for (const block of message.content) {
948
+ if (
949
+ block &&
950
+ typeof block === "object" &&
951
+ "type" in block &&
952
+ block.type === "toolCall" &&
953
+ "id" in block &&
954
+ typeof block.id === "string"
955
+ ) {
956
+ identities.tool_calls.push({ entryId: record.id, timestamp, toolCallId: block.id });
957
+ }
958
+ }
959
+ } catch {
960
+ // Stats parsing is also lenient: a malformed line cannot own a retained row.
961
+ }
962
+ }
963
+
964
+ function collectSessionStatsIdentitiesFromText(text: string): Record<StatsEntryTable, StatsEntryIdentity[]> {
965
+ const identities = createStatsIdentities();
966
+ for (const line of text.split(/\r?\n/)) addSessionStatsIdentity(line, identities);
967
+ return identities;
968
+ }
969
+
970
+ async function collectSessionStatsIdentities(
971
+ sessionPath: string,
972
+ ): Promise<Record<StatsEntryTable, StatsEntryIdentity[]>> {
973
+ const identities = createStatsIdentities();
974
+ const decoder = new TextDecoder();
975
+ for await (const line of readLines(Bun.file(sessionPath).stream())) {
976
+ addSessionStatsIdentity(decoder.decode(line), identities);
977
+ }
978
+ return identities;
979
+ }
980
+
981
+ async function populateStatsTransferTargets(context: StatsCleanupContext): Promise<void> {
982
+ const identitiesBySession = new Map<string, Promise<Record<StatsEntryTable, StatsEntryIdentity[]>>>();
983
+ const identitiesFor = (session: StatsLineageNode): Promise<Record<StatsEntryTable, StatsEntryIdentity[]>> => {
984
+ let identities = identitiesBySession.get(session.path);
985
+ if (!identities) {
986
+ identities = collectSessionStatsIdentities(session.path);
987
+ identitiesBySession.set(session.path, identities);
988
+ }
989
+ return identities;
990
+ };
991
+
992
+ const incompleteIdentityKeys = new Set<string>();
993
+ let hasUnidentifiableIncompleteSession = false;
994
+ for (const retained of context.incompleteRetainedSessions) {
995
+ const keys = statsIdentityKeys(await identitiesFor(retained));
996
+ if (keys.size === 0) hasUnidentifiableIncompleteSession = true;
997
+ for (const key of keys) incompleteIdentityKeys.add(key);
998
+ }
999
+
1000
+ for (const plan of context.plans) {
1001
+ if (context.incompleteRetainedSessions.length > 0) {
1002
+ plan.preserveAll =
1003
+ hasUnidentifiableIncompleteSession ||
1004
+ plan.archivedIdentityKeys.size === 0 ||
1005
+ [...plan.archivedIdentityKeys].some(key => incompleteIdentityKeys.has(key));
1006
+ }
1007
+ for (const retained of plan.retainedSessions) {
1008
+ plan.transfers.push({ path: retained.path, identities: await identitiesFor(retained) });
1009
+ }
1010
+ }
1011
+ }
1012
+
1013
+ /**
1014
+ * Rekey copied entry rows to the newest retained lineage peer before pruning.
1015
+ * The retained file's own offset is never rewritten: it already describes that
1016
+ * file's byte position, while every archived historical offset is deleted.
1017
+ */
1018
+ function reconcileStatsRowsForSessions(dbPath: string, plans: StatsCleanupPlan[]): number {
1019
+ if (plans.length === 0) return 0;
1020
+ const db = new Database(dbPath);
1021
+ try {
1022
+ db.run("PRAGMA busy_timeout = 5000");
1023
+ const sessionTables = STATS_SESSION_TABLES.filter(
1024
+ table => tableExists(db, table) && tableHasColumn(db, table, "session_file"),
1025
+ );
1026
+ const entryTables = STATS_ENTRY_TABLES.filter(
1027
+ table =>
1028
+ sessionTables.includes(table) &&
1029
+ STATS_IDENTITY_COLUMNS[table].every(column => tableHasColumn(db, table, column)),
1030
+ );
1031
+
1032
+ db.run(`
1033
+ CREATE TEMP TABLE gc_retained_entries (
1034
+ table_name TEXT NOT NULL,
1035
+ entry_id TEXT NOT NULL,
1036
+ timestamp INTEGER NOT NULL,
1037
+ tool_call_id TEXT NOT NULL,
1038
+ target_session_file TEXT NOT NULL,
1039
+ PRIMARY KEY (table_name, entry_id, timestamp, tool_call_id)
1040
+ )
1041
+ `);
1042
+ const clearRetainedEntries = db.prepare("DELETE FROM gc_retained_entries");
1043
+ const insertRetainedEntry = db.prepare(`
1044
+ INSERT OR IGNORE INTO gc_retained_entries (
1045
+ table_name, entry_id, timestamp, tool_call_id, target_session_file
1046
+ ) VALUES (?, ?, ?, ?, ?)
1047
+ `);
1048
+ const transferStatements = entryTables.map(table => {
1049
+ const toolCallMatch =
1050
+ table === "tool_calls" ? `retained.tool_call_id = ${table}.tool_call_id` : "retained.tool_call_id = ''";
1051
+ const identityMatch = `
1052
+ retained.table_name = '${table}'
1053
+ AND retained.entry_id = ${table}.entry_id
1054
+ AND retained.timestamp = ${table}.timestamp
1055
+ AND ${toolCallMatch}
1056
+ `;
1057
+ return db.prepare(`
1058
+ UPDATE OR IGNORE ${table}
1059
+ SET session_file = (
1060
+ SELECT retained.target_session_file
1061
+ FROM gc_retained_entries AS retained
1062
+ WHERE ${identityMatch}
1063
+ )
1064
+ WHERE session_file = ?
1065
+ AND EXISTS (
1066
+ SELECT 1
1067
+ FROM gc_retained_entries AS retained
1068
+ WHERE ${identityMatch}
1069
+ )
1070
+ `);
1071
+ });
1072
+ const deletionStatements = sessionTables.map(table => ({
1073
+ table,
1074
+ statement: db.prepare(`DELETE FROM ${table} WHERE session_file = ? OR instr(session_file, ?) = 1`),
1075
+ }));
1076
+ let deleted = 0;
1077
+ const tx = db.transaction((cleanupPlans: StatsCleanupPlan[]) => {
1078
+ for (const plan of cleanupPlans) {
1079
+ if (plan.preserveAll) continue;
1080
+ clearRetainedEntries.run();
1081
+ for (const target of plan.transfers) {
1082
+ for (const table of entryTables) {
1083
+ for (const identity of target.identities[table]) {
1084
+ insertRetainedEntry.run(
1085
+ table,
1086
+ identity.entryId,
1087
+ identity.timestamp,
1088
+ identity.toolCallId,
1089
+ target.path,
1090
+ );
1091
+ }
1092
+ }
1093
+ }
1094
+ for (const sessionPath of new Set(plan.sessionPaths)) {
1095
+ for (const statement of transferStatements) statement.run(sessionPath);
1096
+ }
1097
+ for (const sessionPath of new Set(plan.sessionPaths)) {
1098
+ const nestedPrefix = `${sessionArtifactsPath(sessionPath)}${path.sep}`;
1099
+ for (const { table, statement } of deletionStatements) {
1100
+ const requiresTransfer =
1101
+ plan.retainedSessions.length > 0 &&
1102
+ table !== "file_offsets" &&
1103
+ !entryTables.some(entryTable => entryTable === table);
1104
+ if (requiresTransfer) continue;
1105
+ const result = statement.run(sessionPath, nestedPrefix) as SqliteRunResult;
1106
+ deleted += sqliteNumber(result.changes);
1107
+ }
1108
+ }
1109
+ }
1110
+ });
1111
+ tx(plans);
1112
+ return deleted;
1113
+ } finally {
1114
+ db.close();
1115
+ }
1116
+ }
1117
+
1118
+ async function collectArchivedStatsSessions(
1119
+ archiveRoot: string,
1120
+ sessionsRoot: string,
1121
+ onError: (file: string, error: unknown) => void,
1122
+ ): Promise<StatsSession[]> {
1123
+ const sessions: StatsSession[] = [];
1124
+ for (const file of await collectCompressedJsonlFiles(archiveRoot)) {
1125
+ const relative = path.relative(archiveRoot, file);
1126
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) continue;
1127
+ const sourcePath = path.join(sessionsRoot, relative.slice(0, -".gz".length));
1128
+ try {
1129
+ const text = await readTextIfPresent(file);
1130
+ const header = sessionLineageHeaderFromText(text);
1131
+ if (!header) throw new Error("archive is missing a valid session header");
1132
+ sessions.push({
1133
+ path: sourcePath,
1134
+ id: header.id,
1135
+ parentSession: header.parentSession,
1136
+ historicalPaths: managedHistoricalSessionPaths(header, sourcePath, sessionsRoot),
1137
+ identities: collectSessionStatsIdentitiesFromText(text),
1138
+ });
1139
+ } catch (error) {
1140
+ onError(file, error);
1141
+ }
1142
+ }
1143
+ return sessions;
1144
+ }
1145
+
1146
+ async function cleanupStatsRowsForArchivedSessions(
1147
+ options: ResolvedGcOptions,
1148
+ archiveRoot: string,
1149
+ newlyArchivedSessions: SessionInfo[],
1150
+ result: ArchiveGcResult,
1151
+ ): Promise<void> {
1152
+ const dbPath =
1153
+ path.resolve(options.agentDir) === path.resolve(getAgentDir())
1154
+ ? getStatsDbPath()
1155
+ : path.join(options.agentDir, "stats.db");
1156
+ if (!(await pathExists(dbPath))) return;
1157
+ const sessionsRoot = getSessionsDir(options.agentDir);
1158
+
1159
+ const archivedByPath = new Map<string, StatsSession>();
1160
+ for (const session of newlyArchivedSessions) {
1161
+ archivedByPath.set(path.resolve(session.path), {
1162
+ path: session.path,
1163
+ id: session.id,
1164
+ parentSession: session.parentSessionPath,
1165
+ historicalPaths: [],
1166
+ identities: createStatsIdentities(),
1167
+ });
1168
+ }
1169
+
1170
+ let retainedSessions: SessionInfo[];
1171
+ try {
1172
+ for (const session of await collectArchivedStatsSessions(archiveRoot, sessionsRoot, (file, error) => {
1173
+ result.errors.push(`stats cleanup scan ${file}: ${errorMessage(error)}`);
1174
+ })) {
1175
+ archivedByPath.set(path.resolve(session.path), session);
1176
+ }
1177
+ } catch (error) {
1178
+ result.errors.push(`stats cleanup scan: ${errorMessage(error)}`);
1179
+ }
1180
+ try {
1181
+ retainedSessions = await listActiveSessions(sessionsRoot);
1182
+ } catch (error) {
1183
+ result.errors.push(`stats cleanup scan: ${errorMessage(error)}`);
1184
+ return;
1185
+ }
1186
+
1187
+ try {
1188
+ await withStatsSyncLock(dbPath, async () => {
1189
+ const retainedStatsSessions = await Promise.all(
1190
+ retainedSessions.map(async session => {
1191
+ const header = await readSessionLineageHeader(session.path);
1192
+ if (!header || header.id !== session.id) {
1193
+ throw new Error(`session header changed during stats cleanup: ${session.path}`);
1194
+ }
1195
+ return {
1196
+ path: session.path,
1197
+ id: session.id,
1198
+ parentSession: header.parentSession,
1199
+ historicalPaths: managedHistoricalSessionPaths(header, session.path, sessionsRoot),
1200
+ identities: createStatsIdentities(),
1201
+ };
1202
+ }),
1203
+ );
1204
+ const context = buildStatsCleanupPlans([...archivedByPath.values()], retainedStatsSessions, dbPath);
1205
+ await populateStatsTransferTargets(context);
1206
+ result.statsRowsDeleted = reconcileStatsRowsForSessions(dbPath, context.plans);
1207
+ });
1208
+ } catch (error) {
1209
+ result.errors.push(`stats cleanup: ${errorMessage(error)}`);
1210
+ }
1211
+ }
1212
+
597
1213
  async function runArchiveGc(options: ResolvedGcOptions, archiveRoot: string): Promise<ArchiveGcResult> {
598
1214
  const sessionsRoot = getSessionsDir(options.agentDir);
599
1215
  const sessions = await listActiveSessions(sessionsRoot);
@@ -606,6 +1222,7 @@ async function runArchiveGc(options: ResolvedGcOptions, archiveRoot: string): Pr
606
1222
  wouldArchive: 0,
607
1223
  archived: 0,
608
1224
  historyRowsDeleted: 0,
1225
+ statsRowsDeleted: 0,
609
1226
  ftsRebuilt: false,
610
1227
  errors: [],
611
1228
  };
@@ -651,17 +1268,20 @@ async function runArchiveGc(options: ResolvedGcOptions, archiveRoot: string): Pr
651
1268
  if (!options.apply) return result;
652
1269
 
653
1270
  const archivedSessionIds: string[] = [];
1271
+ const archivedSessions: SessionInfo[] = [];
654
1272
  for (const candidate of candidates) {
655
1273
  try {
656
1274
  await moveSessionWithArtifacts(candidate);
657
1275
  result.archived += 1;
658
1276
  archivedSessionIds.push(candidate.session.id);
1277
+ archivedSessions.push(candidate.session);
659
1278
  } catch (error) {
660
1279
  result.errors.push(`${candidate.session.path}: ${errorMessage(error)}`);
661
1280
  }
662
1281
  }
663
1282
 
664
1283
  await cleanupHistoryRowsForArchivedSessions(options, archiveRoot, archivedSessionIds, result);
1284
+ await cleanupStatsRowsForArchivedSessions(options, archiveRoot, archivedSessions, result);
665
1285
  return result;
666
1286
  }
667
1287
 
@@ -917,7 +1537,7 @@ function renderText(result: GcResult): string {
917
1537
  }
918
1538
  if (result.archive) {
919
1539
  lines.push(
920
- `sessions: ${result.archive.archived}/${result.archive.wouldArchive} archived, ${result.archive.historyRowsDeleted} history rows removed`,
1540
+ `sessions: ${result.archive.archived}/${result.archive.wouldArchive} archived, ${result.archive.historyRowsDeleted} history rows and ${result.archive.statsRowsDeleted} stats rows removed`,
921
1541
  );
922
1542
  if (result.archive.skippedActive > 0) lines.push(`sessions skipped active: ${result.archive.skippedActive}`);
923
1543
  if (result.archive.errors.length > 0) lines.push(`session errors: ${result.archive.errors.length}`);