pi-mono-all 1.0.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.
Files changed (161) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENCE.md +7 -0
  3. package/node_modules/pi-common/package.json +22 -0
  4. package/node_modules/pi-common/src/auth-config.ts +290 -0
  5. package/node_modules/pi-common/src/auth.ts +63 -0
  6. package/node_modules/pi-common/src/cache.ts +60 -0
  7. package/node_modules/pi-common/src/errors.ts +47 -0
  8. package/node_modules/pi-common/src/http-client.ts +118 -0
  9. package/node_modules/pi-common/src/index.ts +7 -0
  10. package/node_modules/pi-common/src/rate-limiter.ts +32 -0
  11. package/node_modules/pi-common/src/tool-result.ts +27 -0
  12. package/node_modules/pi-mono-ask-user-question/CHANGELOG.md +185 -0
  13. package/node_modules/pi-mono-ask-user-question/README.md +226 -0
  14. package/node_modules/pi-mono-ask-user-question/index.ts +923 -0
  15. package/node_modules/pi-mono-ask-user-question/package.json +29 -0
  16. package/node_modules/pi-mono-auto-fix/CHANGELOG.md +59 -0
  17. package/node_modules/pi-mono-auto-fix/README.md +77 -0
  18. package/node_modules/pi-mono-auto-fix/index.ts +488 -0
  19. package/node_modules/pi-mono-auto-fix/package.json +23 -0
  20. package/node_modules/pi-mono-btw/CHANGELOG.md +180 -0
  21. package/node_modules/pi-mono-btw/README.md +24 -0
  22. package/node_modules/pi-mono-btw/index.ts +499 -0
  23. package/node_modules/pi-mono-btw/package.json +29 -0
  24. package/node_modules/pi-mono-clear/CHANGELOG.md +180 -0
  25. package/node_modules/pi-mono-clear/README.md +40 -0
  26. package/node_modules/pi-mono-clear/index.ts +45 -0
  27. package/node_modules/pi-mono-clear/package.json +29 -0
  28. package/node_modules/pi-mono-context/CHANGELOG.md +12 -0
  29. package/node_modules/pi-mono-context/README.md +74 -0
  30. package/node_modules/pi-mono-context/index.ts +641 -0
  31. package/node_modules/pi-mono-context/package.json +29 -0
  32. package/node_modules/pi-mono-context-guard/CHANGELOG.md +195 -0
  33. package/node_modules/pi-mono-context-guard/README.md +81 -0
  34. package/node_modules/pi-mono-context-guard/index.ts +212 -0
  35. package/node_modules/pi-mono-context-guard/package.json +23 -0
  36. package/node_modules/pi-mono-figma/CHANGELOG.md +59 -0
  37. package/node_modules/pi-mono-figma/README.md +236 -0
  38. package/node_modules/pi-mono-figma/__tests__/code-connect.test.ts +32 -0
  39. package/node_modules/pi-mono-figma/__tests__/figma-assets.test.ts +38 -0
  40. package/node_modules/pi-mono-figma/__tests__/figma-component-hints.test.ts +23 -0
  41. package/node_modules/pi-mono-figma/__tests__/figma-implementation-layout.test.ts +47 -0
  42. package/node_modules/pi-mono-figma/__tests__/figma-search.test.ts +51 -0
  43. package/node_modules/pi-mono-figma/__tests__/figma-summarizer.test.ts +65 -0
  44. package/node_modules/pi-mono-figma/__tests__/fixtures/complex-auto-layout.json +115 -0
  45. package/node_modules/pi-mono-figma/__tests__/fixtures/component-instance.json +50 -0
  46. package/node_modules/pi-mono-figma/__tests__/fixtures/hidden-and-vectors.json +28 -0
  47. package/node_modules/pi-mono-figma/__tests__/fixtures/variables-and-styles.json +40 -0
  48. package/node_modules/pi-mono-figma/docs/live-selection-bridge.md +16 -0
  49. package/node_modules/pi-mono-figma/index.ts +6 -0
  50. package/node_modules/pi-mono-figma/package.json +33 -0
  51. package/node_modules/pi-mono-figma/skills/figma/SKILL.md +143 -0
  52. package/node_modules/pi-mono-figma/src/code-connect.ts +110 -0
  53. package/node_modules/pi-mono-figma/src/figma-assets.ts +146 -0
  54. package/node_modules/pi-mono-figma/src/figma-cache.ts +6 -0
  55. package/node_modules/pi-mono-figma/src/figma-client.ts +471 -0
  56. package/node_modules/pi-mono-figma/src/figma-component-hints.ts +87 -0
  57. package/node_modules/pi-mono-figma/src/figma-implementation.ts +264 -0
  58. package/node_modules/pi-mono-figma/src/figma-schemas.ts +139 -0
  59. package/node_modules/pi-mono-figma/src/figma-search.ts +195 -0
  60. package/node_modules/pi-mono-figma/src/figma-summarizer.ts +673 -0
  61. package/node_modules/pi-mono-figma/src/figma-tokens.ts +57 -0
  62. package/node_modules/pi-mono-figma/src/figma-tools.ts +352 -0
  63. package/node_modules/pi-mono-linear/CHANGELOG.md +44 -0
  64. package/node_modules/pi-mono-linear/README.md +159 -0
  65. package/node_modules/pi-mono-linear/index.ts +6 -0
  66. package/node_modules/pi-mono-linear/package.json +30 -0
  67. package/node_modules/pi-mono-linear/skills/linear/SKILL.md +107 -0
  68. package/node_modules/pi-mono-linear/src/linear-client.ts +339 -0
  69. package/node_modules/pi-mono-linear/src/linear-queries.ts +101 -0
  70. package/node_modules/pi-mono-linear/src/linear-schemas.ts +90 -0
  71. package/node_modules/pi-mono-linear/src/linear-tools.ts +362 -0
  72. package/node_modules/pi-mono-loop/CHANGELOG.md +163 -0
  73. package/node_modules/pi-mono-loop/README.md +54 -0
  74. package/node_modules/pi-mono-loop/index.ts +291 -0
  75. package/node_modules/pi-mono-loop/package.json +26 -0
  76. package/node_modules/pi-mono-multi-edit/CHANGELOG.md +232 -0
  77. package/node_modules/pi-mono-multi-edit/README.md +244 -0
  78. package/node_modules/pi-mono-multi-edit/__tests__/classic.test.ts +277 -0
  79. package/node_modules/pi-mono-multi-edit/__tests__/diff.test.ts +77 -0
  80. package/node_modules/pi-mono-multi-edit/__tests__/patch.test.ts +287 -0
  81. package/node_modules/pi-mono-multi-edit/benchmark-edits.ts +966 -0
  82. package/node_modules/pi-mono-multi-edit/classic.ts +435 -0
  83. package/node_modules/pi-mono-multi-edit/diff.ts +143 -0
  84. package/node_modules/pi-mono-multi-edit/index.ts +266 -0
  85. package/node_modules/pi-mono-multi-edit/package.json +37 -0
  86. package/node_modules/pi-mono-multi-edit/patch.ts +463 -0
  87. package/node_modules/pi-mono-multi-edit/types.ts +53 -0
  88. package/node_modules/pi-mono-multi-edit/workspace.ts +85 -0
  89. package/node_modules/pi-mono-review/CHANGELOG.md +190 -0
  90. package/node_modules/pi-mono-review/README.md +30 -0
  91. package/node_modules/pi-mono-review/common.ts +930 -0
  92. package/node_modules/pi-mono-review/index.ts +8 -0
  93. package/node_modules/pi-mono-review/package.json +29 -0
  94. package/node_modules/pi-mono-review/review-tui.ts +194 -0
  95. package/node_modules/pi-mono-review/review.ts +119 -0
  96. package/node_modules/pi-mono-review/reviewer.ts +339 -0
  97. package/node_modules/pi-mono-sentinel/CHANGELOG.md +158 -0
  98. package/node_modules/pi-mono-sentinel/README.md +87 -0
  99. package/node_modules/pi-mono-sentinel/__tests__/output-scanner.test.ts +109 -0
  100. package/node_modules/pi-mono-sentinel/__tests__/permissions.test.ts +202 -0
  101. package/node_modules/pi-mono-sentinel/__tests__/whitelist.test.ts +59 -0
  102. package/node_modules/pi-mono-sentinel/guards/execution-tracker.ts +281 -0
  103. package/node_modules/pi-mono-sentinel/guards/output-scanner.ts +232 -0
  104. package/node_modules/pi-mono-sentinel/guards/permission-gate.ts +170 -0
  105. package/node_modules/pi-mono-sentinel/index.ts +43 -0
  106. package/node_modules/pi-mono-sentinel/package.json +26 -0
  107. package/node_modules/pi-mono-sentinel/patterns/permissions.ts +175 -0
  108. package/node_modules/pi-mono-sentinel/patterns/read-targets.ts +104 -0
  109. package/node_modules/pi-mono-sentinel/patterns/secrets.ts +143 -0
  110. package/node_modules/pi-mono-sentinel/session.ts +95 -0
  111. package/node_modules/pi-mono-sentinel/specs/2026/04/sentinel/001-permission-gate.md +145 -0
  112. package/node_modules/pi-mono-sentinel/types.ts +39 -0
  113. package/node_modules/pi-mono-sentinel/whitelist.ts +86 -0
  114. package/node_modules/pi-mono-simplify/CHANGELOG.md +163 -0
  115. package/node_modules/pi-mono-simplify/README.md +56 -0
  116. package/node_modules/pi-mono-simplify/index.ts +78 -0
  117. package/node_modules/pi-mono-simplify/package.json +29 -0
  118. package/node_modules/pi-mono-status-line/CHANGELOG.md +180 -0
  119. package/node_modules/pi-mono-status-line/README.md +96 -0
  120. package/node_modules/pi-mono-status-line/basic.ts +89 -0
  121. package/node_modules/pi-mono-status-line/expert.ts +689 -0
  122. package/node_modules/pi-mono-status-line/index.ts +54 -0
  123. package/node_modules/pi-mono-status-line/package.json +29 -0
  124. package/node_modules/pi-mono-team-mode/CHANGELOG.md +278 -0
  125. package/node_modules/pi-mono-team-mode/README.md +246 -0
  126. package/node_modules/pi-mono-team-mode/__tests__/agent-manager-transient.test.ts +75 -0
  127. package/node_modules/pi-mono-team-mode/__tests__/delegation-manager.test.ts +118 -0
  128. package/node_modules/pi-mono-team-mode/__tests__/formatters.test.ts +104 -0
  129. package/node_modules/pi-mono-team-mode/__tests__/model-config.test.ts +272 -0
  130. package/node_modules/pi-mono-team-mode/__tests__/notification-box.test.ts +34 -0
  131. package/node_modules/pi-mono-team-mode/__tests__/parallel-utils.test.ts +32 -0
  132. package/node_modules/pi-mono-team-mode/__tests__/pi-stream-parser.test.ts +64 -0
  133. package/node_modules/pi-mono-team-mode/__tests__/prompts.test.ts +106 -0
  134. package/node_modules/pi-mono-team-mode/__tests__/store.test.ts +164 -0
  135. package/node_modules/pi-mono-team-mode/__tests__/tasks.test.ts +267 -0
  136. package/node_modules/pi-mono-team-mode/__tests__/teammate-specs.test.ts +114 -0
  137. package/node_modules/pi-mono-team-mode/__tests__/widget.test.ts +41 -0
  138. package/node_modules/pi-mono-team-mode/__tests__/worktree.test.ts +78 -0
  139. package/node_modules/pi-mono-team-mode/core/chain-utils.ts +90 -0
  140. package/node_modules/pi-mono-team-mode/core/fs-utils.ts +44 -0
  141. package/node_modules/pi-mono-team-mode/core/model-config.ts +432 -0
  142. package/node_modules/pi-mono-team-mode/core/parallel-utils.ts +48 -0
  143. package/node_modules/pi-mono-team-mode/core/prompts.ts +158 -0
  144. package/node_modules/pi-mono-team-mode/core/store.ts +156 -0
  145. package/node_modules/pi-mono-team-mode/core/tasks.ts +99 -0
  146. package/node_modules/pi-mono-team-mode/core/teammate-specs.ts +124 -0
  147. package/node_modules/pi-mono-team-mode/core/types.ts +160 -0
  148. package/node_modules/pi-mono-team-mode/index.ts +825 -0
  149. package/node_modules/pi-mono-team-mode/managers/agent-manager.ts +654 -0
  150. package/node_modules/pi-mono-team-mode/managers/delegation-manager.ts +211 -0
  151. package/node_modules/pi-mono-team-mode/managers/task-manager.ts +238 -0
  152. package/node_modules/pi-mono-team-mode/managers/team-manager.ts +59 -0
  153. package/node_modules/pi-mono-team-mode/package.json +33 -0
  154. package/node_modules/pi-mono-team-mode/runtime/pi-stream-parser.ts +194 -0
  155. package/node_modules/pi-mono-team-mode/runtime/subprocess.ts +183 -0
  156. package/node_modules/pi-mono-team-mode/runtime/transient-session.ts +196 -0
  157. package/node_modules/pi-mono-team-mode/runtime/worktree.ts +90 -0
  158. package/node_modules/pi-mono-team-mode/ui/formatters.ts +149 -0
  159. package/node_modules/pi-mono-team-mode/ui/notification-box.ts +55 -0
  160. package/node_modules/pi-mono-team-mode/ui/widget.ts +94 -0
  161. package/package.json +76 -0
@@ -0,0 +1,966 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Benchmark & analysis tool for multi-edit.
4
+ *
5
+ * Modes:
6
+ * Synthetic benchmark (default):
7
+ * npx tsx benchmark-edits.ts # built-in scenarios
8
+ * npx tsx benchmark-edits.ts scenarios.json # custom scenarios
9
+ *
10
+ * Session analysis:
11
+ * npx tsx benchmark-edits.ts --from-session <path|dir> [...]
12
+ * npx tsx benchmark-edits.ts --from-session --all # ~/.pi/agent/sessions/
13
+ *
14
+ * Synthetic mode measures engine latency and correctness on controlled
15
+ * scenarios. Session mode parses pi JSONL logs and reports per-session and
16
+ * aggregate cost, token, failure, and throughput metrics — comparing
17
+ * multi-edit sessions against base (single-edit-only) sessions.
18
+ *
19
+ * Custom scenario file format (JSON array):
20
+ *
21
+ * [
22
+ * {
23
+ * "name": "rename variable",
24
+ * "files": { "src/app.ts": "const foo = 1;\nconst bar = foo + 1;\n" },
25
+ * "edits": [
26
+ * { "path": "src/app.ts", "oldText": "const foo = 1;", "newText": "const baz = 1;" },
27
+ * { "path": "src/app.ts", "oldText": "const bar = foo + 1;", "newText": "const bar = baz + 1;" }
28
+ * ],
29
+ * "patch": null
30
+ * }
31
+ * ]
32
+ *
33
+ * Each scenario is run in three modes when applicable:
34
+ * - base: N sequential single-edit calls (simulating no multi-edit extension)
35
+ * - multi: 1 batched applyClassicEdits call
36
+ * - patch: 1 applyPatchOperations call (only when `patch` field is provided)
37
+ */
38
+
39
+ import { readFile, readdir, stat as fsStat } from "node:fs/promises";
40
+ import { homedir } from "node:os";
41
+ import { basename, extname, join } from "node:path";
42
+ import { performance } from "node:perf_hooks";
43
+
44
+ import { applyClassicEdits } from "./classic.ts";
45
+ import { applyPatchOperations, parsePatch } from "./patch.ts";
46
+ import type { EditItem, Workspace } from "./types.ts";
47
+
48
+ // ===========================================================================
49
+ // Shared utilities
50
+ // ===========================================================================
51
+
52
+ function fmtMs(ms: number): string {
53
+ return ms < 1 ? `${(ms * 1000).toFixed(0)} µs` : `${ms.toFixed(2)} ms`;
54
+ }
55
+
56
+ function fmtDuration(ms: number | null): string {
57
+ if (ms === null) return "n/a";
58
+ if (ms < 1000) return `${Math.round(ms)} ms`;
59
+ return `${(ms / 1000).toFixed(2)} s`;
60
+ }
61
+
62
+ function fmtBytes(b: number): string {
63
+ if (b < 1024) return `${b} B`;
64
+ if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
65
+ return `${(b / (1024 * 1024)).toFixed(1)} MB`;
66
+ }
67
+
68
+ function fmtPct(n: number): string {
69
+ return `${(n * 100).toFixed(1)}%`;
70
+ }
71
+
72
+ function fmtCost(c: number): string {
73
+ return c < 0.01 ? `$${c.toFixed(4)}` : `$${c.toFixed(2)}`;
74
+ }
75
+
76
+ function incr(counter: Record<string, number>, key: string, amount = 1): void {
77
+ counter[key] = (counter[key] ?? 0) + amount;
78
+ }
79
+
80
+ function percentile(sorted: number[], p: number): number {
81
+ return sorted[Math.floor(sorted.length * p)];
82
+ }
83
+
84
+ // ===========================================================================
85
+ // In-memory workspace (no disk I/O, isolates timing to pure engine work)
86
+ // ===========================================================================
87
+
88
+ function createMemoryWorkspace(files: Map<string, string>): Workspace {
89
+ return {
90
+ readText: async (path) => {
91
+ const content = files.get(path);
92
+ if (content === undefined) throw new Error(`File not found: ${path}`);
93
+ return content;
94
+ },
95
+ writeText: async (path, content) => {
96
+ files.set(path, content);
97
+ },
98
+ deleteFile: async (path) => {
99
+ if (!files.has(path)) throw new Error(`File not found: ${path}`);
100
+ files.delete(path);
101
+ },
102
+ exists: async (path) => files.has(path),
103
+ checkWriteAccess: async () => {},
104
+ };
105
+ }
106
+
107
+ // ===========================================================================
108
+ // Synthetic benchmark
109
+ // ===========================================================================
110
+
111
+ interface Scenario {
112
+ name: string;
113
+ /** Map of relative path -> file content. */
114
+ files: Record<string, string>;
115
+ /** Classic edits to apply. Omit or empty to skip classic modes. */
116
+ edits?: EditItem[];
117
+ /** Codex-style patch string. Omit or null to skip patch mode. */
118
+ patch?: string | null;
119
+ }
120
+
121
+ interface RunResult {
122
+ mode: string;
123
+ ok: boolean;
124
+ error?: string;
125
+ durationMs: number;
126
+ editsAttempted: number;
127
+ editsSucceeded: number;
128
+ }
129
+
130
+ // --- Runners ---
131
+
132
+ function materializeFiles(files: Record<string, string>, cwd: string): Map<string, string> {
133
+ const map = new Map<string, string>();
134
+ for (const [rel, content] of Object.entries(files)) {
135
+ map.set(join(cwd, rel), content);
136
+ }
137
+ return map;
138
+ }
139
+
140
+ async function runBase(scenario: Scenario, cwd: string): Promise<RunResult> {
141
+ const edits = scenario.edits ?? [];
142
+ if (edits.length === 0) return { mode: "base", ok: true, durationMs: 0, editsAttempted: 0, editsSucceeded: 0 };
143
+
144
+ const files = materializeFiles(scenario.files, cwd);
145
+ let succeeded = 0;
146
+ let lastError: string | undefined;
147
+
148
+ const t0 = performance.now();
149
+ for (const edit of edits) {
150
+ const ws = createMemoryWorkspace(files);
151
+ try {
152
+ const results = await applyClassicEdits([edit], ws, cwd);
153
+ if (results[0]?.success) succeeded++;
154
+ else lastError = results[0]?.message;
155
+ } catch (err: unknown) {
156
+ lastError = err instanceof Error ? err.message : String(err);
157
+ }
158
+ }
159
+ const durationMs = performance.now() - t0;
160
+
161
+ return {
162
+ mode: "base",
163
+ ok: succeeded === edits.length,
164
+ error: lastError,
165
+ durationMs,
166
+ editsAttempted: edits.length,
167
+ editsSucceeded: succeeded,
168
+ };
169
+ }
170
+
171
+ async function runMulti(scenario: Scenario, cwd: string): Promise<RunResult> {
172
+ const edits = scenario.edits ?? [];
173
+ if (edits.length === 0) return { mode: "multi", ok: true, durationMs: 0, editsAttempted: 0, editsSucceeded: 0 };
174
+
175
+ const files = materializeFiles(scenario.files, cwd);
176
+ const ws = createMemoryWorkspace(files);
177
+
178
+ const t0 = performance.now();
179
+ try {
180
+ const results = await applyClassicEdits(edits, ws, cwd, undefined, { continueOnError: true });
181
+ const durationMs = performance.now() - t0;
182
+ const succeeded = results.filter((r) => r?.success).length;
183
+ return {
184
+ mode: "multi",
185
+ ok: succeeded === edits.length,
186
+ durationMs,
187
+ editsAttempted: edits.length,
188
+ editsSucceeded: succeeded,
189
+ error: succeeded < edits.length ? results.find((r) => r && !r.success)?.message : undefined,
190
+ };
191
+ } catch (err: unknown) {
192
+ return {
193
+ mode: "multi",
194
+ ok: false,
195
+ error: err instanceof Error ? err.message : String(err),
196
+ durationMs: performance.now() - t0,
197
+ editsAttempted: edits.length,
198
+ editsSucceeded: 0,
199
+ };
200
+ }
201
+ }
202
+
203
+ async function runPatch(scenario: Scenario, cwd: string): Promise<RunResult | null> {
204
+ if (!scenario.patch) return null;
205
+
206
+ const files = materializeFiles(scenario.files, cwd);
207
+ const ws = createMemoryWorkspace(files);
208
+
209
+ const t0 = performance.now();
210
+ try {
211
+ const ops = parsePatch(scenario.patch);
212
+ const results = await applyPatchOperations(ops, ws, cwd);
213
+ const durationMs = performance.now() - t0;
214
+ return {
215
+ mode: "patch",
216
+ ok: true,
217
+ durationMs,
218
+ editsAttempted: results.length,
219
+ editsSucceeded: results.length,
220
+ };
221
+ } catch (err: unknown) {
222
+ return {
223
+ mode: "patch",
224
+ ok: false,
225
+ error: err instanceof Error ? err.message : String(err),
226
+ durationMs: performance.now() - t0,
227
+ editsAttempted: 1,
228
+ editsSucceeded: 0,
229
+ };
230
+ }
231
+ }
232
+
233
+ // --- Built-in scenarios ---
234
+
235
+ function generateLargeFile(lines: number): string {
236
+ return Array.from({ length: lines }, (_, i) => `// line ${i + 1}: placeholder content here`).join("\n") + "\n";
237
+ }
238
+
239
+ const BUILTIN_SCENARIOS: Scenario[] = [
240
+ {
241
+ name: "single edit — simple replacement",
242
+ files: { "app.ts": 'const version = "1.0.0";\nconsole.log(version);\n' },
243
+ edits: [{ path: "app.ts", oldText: '"1.0.0"', newText: '"2.0.0"' }],
244
+ },
245
+ {
246
+ name: "multi edit — 5 edits same file",
247
+ files: {
248
+ "config.ts": [
249
+ "const A = 1;",
250
+ "const B = 2;",
251
+ "const C = 3;",
252
+ "const D = 4;",
253
+ "const E = 5;",
254
+ "",
255
+ ].join("\n"),
256
+ },
257
+ edits: [
258
+ { path: "config.ts", oldText: "const A = 1;", newText: "const A = 10;" },
259
+ { path: "config.ts", oldText: "const B = 2;", newText: "const B = 20;" },
260
+ { path: "config.ts", oldText: "const C = 3;", newText: "const C = 30;" },
261
+ { path: "config.ts", oldText: "const D = 4;", newText: "const D = 40;" },
262
+ { path: "config.ts", oldText: "const E = 5;", newText: "const E = 50;" },
263
+ ],
264
+ },
265
+ {
266
+ name: "multi edit — 3 files, 2 edits each",
267
+ files: {
268
+ "a.ts": "const x = 1;\nconst y = 2;\n",
269
+ "b.ts": "let a = true;\nlet b = false;\n",
270
+ "c.ts": "export const NAME = 'old';\nexport const VER = '0';\n",
271
+ },
272
+ edits: [
273
+ { path: "a.ts", oldText: "const x = 1;", newText: "const x = 10;" },
274
+ { path: "a.ts", oldText: "const y = 2;", newText: "const y = 20;" },
275
+ { path: "b.ts", oldText: "let a = true;", newText: "let a = false;" },
276
+ { path: "b.ts", oldText: "let b = false;", newText: "let b = true;" },
277
+ { path: "c.ts", oldText: "'old'", newText: "'new'" },
278
+ { path: "c.ts", oldText: "'0'", newText: "'1'" },
279
+ ],
280
+ },
281
+ {
282
+ name: "trailing whitespace mismatch",
283
+ files: { "ws.ts": "function foo() { \n return 1; \n}\n" },
284
+ edits: [
285
+ {
286
+ path: "ws.ts",
287
+ oldText: "function foo() {\n return 1;\n}",
288
+ newText: "function foo() {\n return 2;\n}",
289
+ },
290
+ ],
291
+ },
292
+ {
293
+ name: "curly quote mismatch",
294
+ files: { "q.ts": "const msg = 'hello world';\n" },
295
+ edits: [
296
+ {
297
+ path: "q.ts",
298
+ oldText: "const msg = \u2018hello world\u2019;",
299
+ newText: "const msg = 'goodbye world';",
300
+ },
301
+ ],
302
+ },
303
+ {
304
+ name: "partial failure — 1 bad edit in batch of 4",
305
+ files: { "mix.ts": "aaa\nbbb\nccc\nddd\n" },
306
+ edits: [
307
+ { path: "mix.ts", oldText: "aaa", newText: "AAA" },
308
+ { path: "mix.ts", oldText: "NONEXISTENT", newText: "X" },
309
+ { path: "mix.ts", oldText: "ccc", newText: "CCC" },
310
+ { path: "mix.ts", oldText: "ddd", newText: "DDD" },
311
+ ],
312
+ },
313
+ {
314
+ name: "large file — 10 edits across 1000-line file",
315
+ files: { "big.ts": generateLargeFile(1000) },
316
+ edits: Array.from({ length: 10 }, (_, i) => {
317
+ const lineNum = 100 * (i + 1);
318
+ return {
319
+ path: "big.ts",
320
+ oldText: `// line ${lineNum}: placeholder content here`,
321
+ newText: `// line ${lineNum}: MODIFIED`,
322
+ };
323
+ }),
324
+ },
325
+ {
326
+ name: "patch — add + update + delete",
327
+ files: {
328
+ "keep.ts": "const old = true;\nexport default old;\n",
329
+ "remove.ts": "deprecated();\n",
330
+ },
331
+ patch: [
332
+ "*** Begin Patch",
333
+ "*** Add File: new.ts",
334
+ "+export const fresh = true;",
335
+ "*** Update File: keep.ts",
336
+ "@@",
337
+ "-const old = true;",
338
+ "+const updated = true;",
339
+ " export default old;",
340
+ "*** Delete File: remove.ts",
341
+ "*** End Patch",
342
+ ].join("\n"),
343
+ edits: [
344
+ { path: "keep.ts", oldText: "const old = true;", newText: "const updated = true;" },
345
+ ],
346
+ },
347
+ {
348
+ name: "patch — trailing whitespace in hunk context",
349
+ files: { "ctx.ts": "function bar() { \n return 0;\n}\n" },
350
+ patch: [
351
+ "*** Begin Patch",
352
+ "*** Update File: ctx.ts",
353
+ "@@ function bar() {",
354
+ "- return 0;",
355
+ "+ return 42;",
356
+ "*** End Patch",
357
+ ].join("\n"),
358
+ },
359
+ ];
360
+
361
+ // --- Benchmark output ---
362
+
363
+ function printResults(scenarioName: string, results: RunResult[]): void {
364
+ console.log(`\n ${scenarioName}`);
365
+ const modeW = 8;
366
+ const statW = 6;
367
+ const editW = 12;
368
+ const timeW = 12;
369
+
370
+ for (const r of results) {
371
+ const status = r.ok ? " OK" : "FAIL";
372
+ const edits = `${r.editsSucceeded}/${r.editsAttempted}`;
373
+ const err = r.error ? ` → ${r.error.slice(0, 80)}` : "";
374
+ console.log(
375
+ ` ${r.mode.padEnd(modeW)} ${status.padStart(statW)} ${edits.padStart(editW)} ${fmtMs(r.durationMs).padStart(timeW)}${err}`,
376
+ );
377
+ }
378
+ }
379
+
380
+ // --- Benchmark main ---
381
+
382
+ async function loadCustomScenarios(filePath: string): Promise<Scenario[]> {
383
+ const raw = await readFile(filePath, "utf-8");
384
+ const parsed = JSON.parse(raw);
385
+ if (!Array.isArray(parsed)) throw new Error("Scenario file must be a JSON array");
386
+ return parsed as Scenario[];
387
+ }
388
+
389
+ async function runBenchmark(scenarioFile?: string): Promise<void> {
390
+ const scenarios = scenarioFile ? await loadCustomScenarios(scenarioFile) : BUILTIN_SCENARIOS;
391
+
392
+ const CWD = "/bench"; // virtual cwd for in-memory workspace
393
+ const WARMUP_RUNS = 3;
394
+ const MEASURED_RUNS = 10;
395
+
396
+ console.log("=".repeat(72));
397
+ console.log(" Multi-Edit Benchmark");
398
+ console.log(` ${scenarios.length} scenarios × ${MEASURED_RUNS} runs (${WARMUP_RUNS} warmup)`);
399
+ console.log("=".repeat(72));
400
+
401
+ const allResults: Array<{ name: string; results: RunResult[] }> = [];
402
+
403
+ for (const scenario of scenarios) {
404
+ // Warmup
405
+ for (let w = 0; w < WARMUP_RUNS; w++) {
406
+ if (scenario.edits?.length) {
407
+ await runBase(scenario, CWD);
408
+ await runMulti(scenario, CWD);
409
+ }
410
+ if (scenario.patch) await runPatch(scenario, CWD);
411
+ }
412
+
413
+ // Measured runs
414
+ const baseRuns: RunResult[] = [];
415
+ const multiRuns: RunResult[] = [];
416
+ const patchRuns: RunResult[] = [];
417
+
418
+ for (let r = 0; r < MEASURED_RUNS; r++) {
419
+ if (scenario.edits?.length) {
420
+ baseRuns.push(await runBase(scenario, CWD));
421
+ multiRuns.push(await runMulti(scenario, CWD));
422
+ }
423
+ if (scenario.patch) {
424
+ const pr = await runPatch(scenario, CWD);
425
+ if (pr) patchRuns.push(pr);
426
+ }
427
+ }
428
+
429
+ const aggregate = (runs: RunResult[]): RunResult | null => {
430
+ if (runs.length === 0) return null;
431
+ const durations = runs.map((r) => r.durationMs).sort((a, b) => a - b);
432
+ const p50 = durations[Math.floor(durations.length / 2)];
433
+ const allOk = runs.every((r) => r.ok);
434
+ const lastRun = runs[runs.length - 1];
435
+ return {
436
+ mode: lastRun.mode,
437
+ ok: allOk,
438
+ error: allOk ? undefined : runs.find((r) => !r.ok)?.error,
439
+ durationMs: p50,
440
+ editsAttempted: lastRun.editsAttempted,
441
+ editsSucceeded: lastRun.editsSucceeded,
442
+ };
443
+ };
444
+
445
+ const results: RunResult[] = [];
446
+ const baseAgg = aggregate(baseRuns);
447
+ const multiAgg = aggregate(multiRuns);
448
+ const patchAgg = aggregate(patchRuns);
449
+ if (baseAgg) results.push(baseAgg);
450
+ if (multiAgg) results.push(multiAgg);
451
+ if (patchAgg) results.push(patchAgg);
452
+
453
+ allResults.push({ name: scenario.name, results });
454
+ printResults(scenario.name, results);
455
+ }
456
+
457
+ // Summary table
458
+ console.log("\n" + "=".repeat(72));
459
+ console.log(" Summary (P50 latency)");
460
+ console.log("=".repeat(72));
461
+
462
+ const nameW = 42;
463
+ const colW = 14;
464
+ console.log(
465
+ `\n${"Scenario".padEnd(nameW)} ${"Base".padStart(colW)} ${"Multi".padStart(colW)} ${"Patch".padStart(colW)}`,
466
+ );
467
+ console.log("-".repeat(nameW + colW * 3 + 3));
468
+
469
+ for (const { name, results } of allResults) {
470
+ const base = results.find((r) => r.mode === "base");
471
+ const multi = results.find((r) => r.mode === "multi");
472
+ const patch = results.find((r) => r.mode === "patch");
473
+
474
+ const fmtCell = (r: RunResult | undefined): string => {
475
+ if (!r) return "—".padStart(colW);
476
+ const status = r.ok ? "" : " ✗";
477
+ return `${fmtMs(r.durationMs)}${status}`.padStart(colW);
478
+ };
479
+
480
+ console.log(`${name.slice(0, nameW - 1).padEnd(nameW)} ${fmtCell(base)} ${fmtCell(multi)} ${fmtCell(patch)}`);
481
+ }
482
+
483
+ // Speedup summary
484
+ const multiSpeedups: number[] = [];
485
+ for (const { results } of allResults) {
486
+ const base = results.find((r) => r.mode === "base");
487
+ const multi = results.find((r) => r.mode === "multi");
488
+ if (base && multi && base.durationMs > 0) {
489
+ multiSpeedups.push(base.durationMs / multi.durationMs);
490
+ }
491
+ }
492
+
493
+ if (multiSpeedups.length > 0) {
494
+ const avgSpeedup = multiSpeedups.reduce((a, b) => a + b, 0) / multiSpeedups.length;
495
+ const maxSpeedup = Math.max(...multiSpeedups);
496
+ console.log(`\nMulti vs Base speedup: avg ${avgSpeedup.toFixed(1)}x, max ${maxSpeedup.toFixed(1)}x`);
497
+ }
498
+
499
+ // Check for failures
500
+ const failures = allResults.flatMap(({ name, results }) =>
501
+ results.filter((r) => !r.ok).map((r) => ({ scenario: name, ...r })),
502
+ );
503
+
504
+ if (failures.length > 0) {
505
+ console.log(`\n⚠ ${failures.length} failure(s) detected:`);
506
+ for (const f of failures) {
507
+ console.log(` [${f.mode}] ${f.scenario}: ${f.error?.slice(0, 100)}`);
508
+ }
509
+ }
510
+
511
+ console.log();
512
+ }
513
+
514
+ // ===========================================================================
515
+ // Session analysis (--from-session)
516
+ // ===========================================================================
517
+
518
+ interface EditCall {
519
+ mode: "single" | "multi" | "patch" | "unknown";
520
+ logicalEdits: number;
521
+ extensions: string[];
522
+ failed: boolean;
523
+ durationMs: number | null;
524
+ payloadBytes: number;
525
+ timestamp: string;
526
+ }
527
+
528
+ interface SessionStats {
529
+ path: string;
530
+ project: string;
531
+ kind: "multi-edit" | "base";
532
+ calls: EditCall[];
533
+ totalCost: number;
534
+ totalInputTokens: number;
535
+ totalOutputTokens: number;
536
+ }
537
+
538
+ // --- Parsing helpers ---
539
+
540
+ function getExt(path: string): string {
541
+ return extname(path) || "(none)";
542
+ }
543
+
544
+ function payloadSize(args: Record<string, unknown>): number {
545
+ let total = 0;
546
+ if (typeof args.oldText === "string") total += args.oldText.length;
547
+ if (typeof args.newText === "string") total += args.newText.length;
548
+ if (typeof args.patch === "string") total += args.patch.length;
549
+ if (Array.isArray(args.multi)) {
550
+ for (const item of args.multi) {
551
+ if (typeof item.oldText === "string") total += item.oldText.length;
552
+ if (typeof item.newText === "string") total += item.newText.length;
553
+ }
554
+ }
555
+ return total;
556
+ }
557
+
558
+ function classifyCall(
559
+ args: Record<string, unknown>,
560
+ ): { mode: EditCall["mode"]; logicalEdits: number; extensions: string[] } {
561
+ const paths: string[] = [];
562
+
563
+ // Patch mode
564
+ if (typeof args.patch === "string") {
565
+ const prefixes = ["*** Add File: ", "*** Delete File: ", "*** Update File: "];
566
+ for (const line of args.patch.split("\n")) {
567
+ const stripped = line.trim();
568
+ for (const prefix of prefixes) {
569
+ if (stripped.startsWith(prefix)) paths.push(stripped.slice(prefix.length));
570
+ }
571
+ }
572
+ return { mode: "patch", logicalEdits: Math.max(paths.length, 1), extensions: paths.map(getExt) };
573
+ }
574
+
575
+ const multi = Array.isArray(args.multi) ? (args.multi as Record<string, unknown>[]) : [];
576
+ const hasSingle = typeof args.path === "string" && typeof args.oldText === "string";
577
+
578
+ // Single + multi combined
579
+ if (hasSingle && multi.length > 0) {
580
+ paths.push(args.path as string);
581
+ for (const item of multi) paths.push((item.path as string) ?? (args.path as string));
582
+ return { mode: "multi", logicalEdits: 1 + multi.length, extensions: paths.map(getExt) };
583
+ }
584
+
585
+ // Multi only
586
+ if (multi.length > 0) {
587
+ const topPath = (args.path as string) ?? "";
588
+ for (const item of multi) paths.push((item.path as string) ?? topPath);
589
+ return { mode: "multi", logicalEdits: multi.length, extensions: paths.map(getExt) };
590
+ }
591
+
592
+ // Single only
593
+ if (hasSingle) {
594
+ paths.push(args.path as string);
595
+ return { mode: "single", logicalEdits: 1, extensions: paths.map(getExt) };
596
+ }
597
+
598
+ return { mode: "unknown", logicalEdits: 1, extensions: [] };
599
+ }
600
+
601
+ /** Parse one JSONL session file into stats. */
602
+ function analyzeSession(filepath: string, lines: string[]): SessionStats | null {
603
+ const entries: Record<string, unknown>[] = [];
604
+ for (const line of lines) {
605
+ const trimmed = line.trim();
606
+ if (!trimmed) continue;
607
+ try {
608
+ entries.push(JSON.parse(trimmed) as Record<string, unknown>);
609
+ } catch {
610
+ /* skip malformed lines */
611
+ }
612
+ }
613
+
614
+ // Session metadata
615
+ const sessionMeta = entries.find((e) => e.type === "session") ?? {};
616
+ const cwd = (sessionMeta as Record<string, unknown>).cwd as string | undefined;
617
+ const project = cwd ? basename(cwd) : basename(filepath);
618
+
619
+ // Collect tool calls and results
620
+ const pendingCalls = new Map<string, { args: Record<string, unknown>; ts: string }>();
621
+ const toolResults = new Map<string, { isError: boolean; ts: string }>();
622
+ let totalCost = 0;
623
+ let totalInput = 0;
624
+ let totalOutput = 0;
625
+
626
+ for (const entry of entries) {
627
+ if (entry.type !== "message") continue;
628
+ const msg = (entry.message ?? {}) as Record<string, unknown>;
629
+ const role = msg.role as string;
630
+ const ts = (entry.timestamp as string) ?? "";
631
+
632
+ if (role === "assistant") {
633
+ const usage = (msg.usage ?? {}) as Record<string, unknown>;
634
+ totalInput += ((usage.input as number) ?? 0) + ((usage.cacheRead as number) ?? 0);
635
+ totalOutput += (usage.output as number) ?? 0;
636
+ const costInfo = (usage.cost ?? {}) as Record<string, unknown>;
637
+ totalCost += (costInfo.total as number) ?? 0;
638
+
639
+ for (const c of (msg.content ?? []) as Record<string, unknown>[]) {
640
+ if (c.type === "toolCall" && (c.name === "edit" || c.name === "Edit")) {
641
+ pendingCalls.set(
642
+ (c.id as string) ?? "",
643
+ { args: (c.arguments ?? {}) as Record<string, unknown>, ts },
644
+ );
645
+ }
646
+ }
647
+ } else if (role === "toolResult") {
648
+ toolResults.set(
649
+ (msg.toolCallId as string) ?? "",
650
+ { isError: (msg.isError as boolean) ?? false, ts },
651
+ );
652
+ }
653
+ }
654
+
655
+ // Match calls to results
656
+ const calls: EditCall[] = [];
657
+ for (const [tcId, { args, ts: callTs }] of pendingCalls) {
658
+ const { mode, logicalEdits, extensions } = classifyCall(args);
659
+ let failed = false;
660
+ let durationMs: number | null = null;
661
+
662
+ const result = toolResults.get(tcId);
663
+ if (result) {
664
+ failed = result.isError;
665
+ if (callTs && result.ts) {
666
+ const dt = new Date(result.ts).getTime() - new Date(callTs).getTime();
667
+ if (!Number.isNaN(dt)) durationMs = dt;
668
+ }
669
+ }
670
+
671
+ calls.push({ mode, logicalEdits, extensions, failed, durationMs, payloadBytes: payloadSize(args), timestamp: callTs });
672
+ }
673
+
674
+ if (calls.length === 0) return null;
675
+
676
+ return {
677
+ path: filepath,
678
+ project,
679
+ kind: calls.some((c) => c.mode === "multi" || c.mode === "patch") ? "multi-edit" : "base",
680
+ calls,
681
+ totalCost,
682
+ totalInputTokens: totalInput,
683
+ totalOutputTokens: totalOutput,
684
+ };
685
+ }
686
+
687
+ // --- Aggregation ---
688
+
689
+ interface AggregateStats {
690
+ sessions: number;
691
+ totalCalls: number;
692
+ totalLogicalEdits: number;
693
+ totalFailures: number;
694
+ totalPayloadBytes: number;
695
+ durations: number[];
696
+ totalCost: number;
697
+ totalInputTokens: number;
698
+ totalOutputTokens: number;
699
+ modeCounts: Record<string, number>;
700
+ modeFailures: Record<string, number>;
701
+ extCounts: Record<string, number>;
702
+ }
703
+
704
+ function createAggregate(): AggregateStats {
705
+ return {
706
+ sessions: 0, totalCalls: 0, totalLogicalEdits: 0, totalFailures: 0, totalPayloadBytes: 0,
707
+ durations: [], totalCost: 0, totalInputTokens: 0, totalOutputTokens: 0,
708
+ modeCounts: {}, modeFailures: {}, extCounts: {},
709
+ };
710
+ }
711
+
712
+ function ingestSession(agg: AggregateStats, session: SessionStats): void {
713
+ agg.sessions++;
714
+ agg.totalCost += session.totalCost;
715
+ agg.totalInputTokens += session.totalInputTokens;
716
+ agg.totalOutputTokens += session.totalOutputTokens;
717
+
718
+ for (const c of session.calls) {
719
+ agg.totalCalls++;
720
+ agg.totalLogicalEdits += c.logicalEdits;
721
+ agg.totalPayloadBytes += c.payloadBytes;
722
+ if (c.failed) agg.totalFailures++;
723
+
724
+ incr(agg.modeCounts, c.mode);
725
+ if (c.failed) incr(agg.modeFailures, c.mode);
726
+ if (c.durationMs !== null) agg.durations.push(c.durationMs);
727
+ for (const ext of new Set(c.extensions)) incr(agg.extCounts, ext);
728
+ }
729
+ }
730
+
731
+ // --- Session display ---
732
+
733
+ function printComparison(base: AggregateStats, multi: AggregateStats): void {
734
+ const w = 72;
735
+ const labelW = 28;
736
+ const colW = 16;
737
+
738
+ console.log(`\n${"=".repeat(w)}`);
739
+ console.log(" Multi-Edit vs Base Edit — Performance Comparison");
740
+ console.log("=".repeat(w));
741
+ console.log(`\n${"Metric".padEnd(labelW)} ${"Base".padStart(colW)} ${"Multi-Edit".padStart(colW)} ${"Delta".padStart(colW)}`);
742
+ console.log("-".repeat(w));
743
+
744
+ type Row = [label: string, bv: string, mv: string, dv: string];
745
+ const rows: Row[] = [];
746
+
747
+ rows.push(["Sessions", String(base.sessions), String(multi.sessions), ""]);
748
+ rows.push(["Tool calls", String(base.totalCalls), String(multi.totalCalls), ""]);
749
+ rows.push(["Logical edits", String(base.totalLogicalEdits), String(multi.totalLogicalEdits), ""]);
750
+
751
+ // Edits per call
752
+ const bEpc = base.totalCalls > 0 ? base.totalLogicalEdits / base.totalCalls : 0;
753
+ const mEpc = multi.totalCalls > 0 ? multi.totalLogicalEdits / multi.totalCalls : 0;
754
+ rows.push(["Edits / tool call", bEpc.toFixed(2), mEpc.toFixed(2), `${mEpc >= bEpc ? "+" : ""}${(mEpc - bEpc).toFixed(2)}`]);
755
+
756
+ // Failure rate
757
+ const bFr = base.totalCalls > 0 ? base.totalFailures / base.totalCalls : 0;
758
+ const mFr = multi.totalCalls > 0 ? multi.totalFailures / multi.totalCalls : 0;
759
+ rows.push(["Failure rate", fmtPct(bFr), fmtPct(mFr), `${((mFr - bFr) * 100).toFixed(1)}pp`]);
760
+
761
+ // Duration stats
762
+ const avg = (d: number[]): number | null => (d.length > 0 ? d.reduce((a, b) => a + b, 0) / d.length : null);
763
+ rows.push(["Avg duration", fmtDuration(avg(base.durations)), fmtDuration(avg(multi.durations)), ""]);
764
+
765
+ const bSorted = [...base.durations].sort((a, b) => a - b);
766
+ const mSorted = [...multi.durations].sort((a, b) => a - b);
767
+ const p = (s: number[], pct: number): number | null => (s.length > 0 ? percentile(s, pct) : null);
768
+ rows.push(["P50 duration", fmtDuration(p(bSorted, 0.5)), fmtDuration(p(mSorted, 0.5)), ""]);
769
+ rows.push(["P95 duration", fmtDuration(p(bSorted, 0.95)), fmtDuration(p(mSorted, 0.95)), ""]);
770
+
771
+ // Payload
772
+ const bAvgPl = base.totalCalls > 0 ? base.totalPayloadBytes / base.totalCalls : 0;
773
+ const mAvgPl = multi.totalCalls > 0 ? multi.totalPayloadBytes / multi.totalCalls : 0;
774
+ rows.push(["Avg payload / call", fmtBytes(Math.round(bAvgPl)), fmtBytes(Math.round(mAvgPl)), ""]);
775
+ rows.push(["Total payload", fmtBytes(base.totalPayloadBytes), fmtBytes(multi.totalPayloadBytes), ""]);
776
+
777
+ // Cost
778
+ rows.push(["Session cost (total)", fmtCost(base.totalCost), fmtCost(multi.totalCost), ""]);
779
+ if (base.sessions > 0 && multi.sessions > 0) {
780
+ const bAvgCost = base.totalCost / base.sessions;
781
+ const mAvgCost = multi.totalCost / multi.sessions;
782
+ if (bAvgCost > 0) {
783
+ const savings = (bAvgCost - mAvgCost) / bAvgCost;
784
+ rows.push(["Avg cost / session", fmtCost(bAvgCost), fmtCost(mAvgCost), `${(savings * 100).toFixed(1)}%`]);
785
+ }
786
+ }
787
+
788
+ if (base.totalLogicalEdits > 0 && multi.totalLogicalEdits > 0) {
789
+ const bCpe = base.totalCost / base.totalLogicalEdits;
790
+ const mCpe = multi.totalCost / multi.totalLogicalEdits;
791
+ if (bCpe > 0) {
792
+ const delta = (mCpe - bCpe) / bCpe;
793
+ rows.push(["Cost / logical edit", fmtCost(bCpe), fmtCost(mCpe), `${(delta * 100).toFixed(1)}%`]);
794
+ }
795
+ }
796
+
797
+ // Call reduction estimate
798
+ if (multi.totalCalls > 0) {
799
+ const hypothetical = multi.totalLogicalEdits;
800
+ const saved = hypothetical - multi.totalCalls;
801
+ rows.push(["", "", "", ""]);
802
+ rows.push(["Calls saved vs base*", "", String(saved), `(${fmtPct(saved / hypothetical)} fewer)`]);
803
+ }
804
+
805
+ for (const [label, bv, mv, dv] of rows) {
806
+ if (!label) { console.log(); continue; }
807
+ console.log(`${label.padEnd(labelW)} ${bv.padStart(colW)} ${mv.padStart(colW)} ${dv.padStart(colW)}`);
808
+ }
809
+
810
+ console.log("\n* Hypothetical: if multi-edit sessions used 1 call per logical edit");
811
+
812
+ // Mode breakdown for multi-edit
813
+ const modeEntries = Object.entries(multi.modeCounts).sort(([, a], [, b]) => b - a);
814
+ if (modeEntries.length > 0) {
815
+ console.log("\n--- Multi-Edit Mode Breakdown ---");
816
+ for (const [mode, count] of modeEntries) {
817
+ const fails = multi.modeFailures[mode] ?? 0;
818
+ const failStr = fails > 0 ? ` fail: ${fails} (${fmtPct(fails / count)})` : "";
819
+ console.log(` ${mode.padEnd(12)} ${String(count).padStart(4)} calls (${fmtPct(count / multi.totalCalls)})${failStr}`);
820
+ }
821
+ }
822
+
823
+ // Extension breakdown (combined)
824
+ const allExt: Record<string, number> = {};
825
+ for (const [k, v] of Object.entries(base.extCounts)) incr(allExt, k, v);
826
+ for (const [k, v] of Object.entries(multi.extCounts)) incr(allExt, k, v);
827
+ const extEntries = Object.entries(allExt).sort(([, a], [, b]) => b - a).slice(0, 10);
828
+ if (extEntries.length > 0) {
829
+ console.log("\n--- File Extensions (all sessions) ---");
830
+ for (const [ext, count] of extEntries) {
831
+ console.log(` ${ext.padEnd(12)} ${String(count).padStart(4)}`);
832
+ }
833
+ }
834
+ }
835
+
836
+ function printSessionTable(sessions: SessionStats[]): void {
837
+ if (sessions.length === 0) return;
838
+
839
+ const w = 100;
840
+ console.log(`\n${"=".repeat(w)}`);
841
+ console.log(" Per-Session Detail");
842
+ console.log("=".repeat(w));
843
+
844
+ const hdr = `${"Project".padEnd(25)} ${"Kind".padEnd(12)} ${"Calls".padStart(6)} ${"Edits".padStart(6)} ${"E/C".padStart(5)} ${"Fail%".padStart(6)} ${"Avg ms".padStart(8)} ${"Cost".padStart(8)}`;
845
+ console.log(`\n${hdr}`);
846
+ console.log("-".repeat(hdr.length));
847
+
848
+ for (const s of [...sessions].sort((a, b) => a.path.localeCompare(b.path))) {
849
+ const totalCalls = s.calls.length;
850
+ const totalEdits = s.calls.reduce((sum, c) => sum + c.logicalEdits, 0);
851
+ const totalFails = s.calls.filter((c) => c.failed).length;
852
+ const epc = totalCalls > 0 ? (totalEdits / totalCalls).toFixed(1) : "0.0";
853
+ const fr = fmtPct(totalCalls > 0 ? totalFails / totalCalls : 0);
854
+ const durations = s.calls.filter((c) => c.durationMs !== null).map((c) => c.durationMs!);
855
+ const avgD = durations.length > 0
856
+ ? fmtDuration(durations.reduce((a, b) => a + b, 0) / durations.length)
857
+ : "n/a";
858
+
859
+ console.log(
860
+ `${s.project.slice(0, 24).padEnd(25)} ${s.kind.padEnd(12)} ${String(totalCalls).padStart(6)} ${String(totalEdits).padStart(6)} ${epc.padStart(5)} ${fr.padStart(6)} ${avgD.padStart(8)} ${fmtCost(s.totalCost).padStart(8)}`,
861
+ );
862
+ }
863
+ }
864
+
865
+ // --- Session file collection ---
866
+
867
+ async function findJsonlFiles(dir: string): Promise<string[]> {
868
+ try {
869
+ const entries = await readdir(dir, { recursive: true });
870
+ return entries
871
+ .filter((e) => e.endsWith(".jsonl"))
872
+ .map((e) => join(dir, e))
873
+ .sort();
874
+ } catch {
875
+ return [];
876
+ }
877
+ }
878
+
879
+ async function collectSessionFiles(args: string[]): Promise<string[]> {
880
+ if (args.includes("--all")) {
881
+ const sessionsDir = join(homedir(), ".pi", "agent", "sessions");
882
+ const files = await findJsonlFiles(sessionsDir);
883
+ if (files.length === 0) {
884
+ console.error(`No .jsonl files found in ${sessionsDir}`);
885
+ process.exit(1);
886
+ }
887
+ return files;
888
+ }
889
+
890
+ const paths = args.filter((a) => !a.startsWith("--"));
891
+ if (paths.length === 0) {
892
+ console.error("Usage: benchmark-edits.ts --from-session <path|dir> [...] | --all");
893
+ process.exit(1);
894
+ }
895
+
896
+ const files: string[] = [];
897
+ for (const arg of paths) {
898
+ try {
899
+ const s = await fsStat(arg);
900
+ if (s.isDirectory()) files.push(...(await findJsonlFiles(arg)));
901
+ else files.push(arg);
902
+ } catch {
903
+ console.error(`Warning: skipping ${arg} (not found)`);
904
+ }
905
+ }
906
+ return files.sort();
907
+ }
908
+
909
+ async function runSessionAnalysis(args: string[]): Promise<void> {
910
+ const jsonlFiles = await collectSessionFiles(args);
911
+ if (jsonlFiles.length === 0) {
912
+ console.log("No JSONL session files found.");
913
+ process.exit(1);
914
+ }
915
+
916
+ const sessions: SessionStats[] = [];
917
+ for (const fp of jsonlFiles) {
918
+ try {
919
+ const raw = await readFile(fp, "utf-8");
920
+ const stats = analyzeSession(fp, raw.split("\n"));
921
+ if (stats) sessions.push(stats);
922
+ } catch {
923
+ /* skip unreadable files */
924
+ }
925
+ }
926
+
927
+ if (sessions.length === 0) {
928
+ console.log("No edit tool calls found in any session.");
929
+ return;
930
+ }
931
+
932
+ const baseAgg = createAggregate();
933
+ const multiAgg = createAggregate();
934
+ for (const s of sessions) {
935
+ ingestSession(s.kind === "multi-edit" ? multiAgg : baseAgg, s);
936
+ }
937
+
938
+ printComparison(baseAgg, multiAgg);
939
+ printSessionTable(sessions);
940
+
941
+ const totalEdits = sessions.reduce((sum, s) => sum + s.calls.reduce((cs, c) => cs + c.logicalEdits, 0), 0);
942
+ const totalFails = sessions.reduce((sum, s) => sum + s.calls.filter((c) => c.failed).length, 0);
943
+ console.log(
944
+ `\nScanned ${jsonlFiles.length} files, ${sessions.length} sessions with edits, ${totalEdits} logical edits, ${totalFails} failures.`,
945
+ );
946
+ }
947
+
948
+ // ===========================================================================
949
+ // Main
950
+ // ===========================================================================
951
+
952
+ async function main(): Promise<void> {
953
+ const args = process.argv.slice(2);
954
+ const sessionIdx = args.indexOf("--from-session");
955
+
956
+ if (sessionIdx !== -1) {
957
+ await runSessionAnalysis([...args.slice(0, sessionIdx), ...args.slice(sessionIdx + 1)]);
958
+ } else {
959
+ await runBenchmark(args[0]);
960
+ }
961
+ }
962
+
963
+ main().catch((err) => {
964
+ console.error(err);
965
+ process.exit(1);
966
+ });