@compaction/cli 0.1.1 → 0.1.4

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.
@@ -1,5 +1,6 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
+ import { classifyDistinctness } from "./trace-fingerprint.js";
3
4
  const REQUIRED_STRING_FIELDS = ["run_id", "trace_title", "policy_name"];
4
5
  const REQUIRED_NUMBER_FIELDS = [
5
6
  "original_input_tokens",
@@ -25,6 +26,51 @@ function roundTo(value, digits) {
25
26
  const factor = 10 ** digits;
26
27
  return Math.round((value + Number.EPSILON) * factor) / factor;
27
28
  }
29
+ /** Read the OPTIONAL content fingerprint digest from a report (older reports have none). */
30
+ function fingerprintDigestOf(report) {
31
+ const fp = report.trace_fingerprint;
32
+ return fp && typeof fp.content_sha256 === "string" && fp.content_sha256.length > 0 ? fp.content_sha256 : null;
33
+ }
34
+ /**
35
+ * De-duplicate loaded reports by content fingerprint for a duplicate-safe headline.
36
+ *
37
+ * Keeps the FIRST report per distinct fingerprint; later same-fingerprint reports are duplicates
38
+ * and are dropped from the totals so a re-captured run never double-counts its savings. Reports
39
+ * without a fingerprint are each kept (cannot dedup what cannot be identified). PURE; never
40
+ * inflates savings.
41
+ */
42
+ function dedupeReportsByFingerprint(reports) {
43
+ const seen = new Set();
44
+ const distinctReports = [];
45
+ let withFingerprint = 0;
46
+ let withoutFingerprint = 0;
47
+ for (const loaded of reports) {
48
+ const fp = fingerprintDigestOf(loaded.report);
49
+ if (fp !== null) {
50
+ withFingerprint += 1;
51
+ if (seen.has(fp))
52
+ continue;
53
+ seen.add(fp);
54
+ distinctReports.push(loaded);
55
+ }
56
+ else {
57
+ withoutFingerprint += 1;
58
+ distinctReports.push(loaded);
59
+ }
60
+ }
61
+ const classified = classifyDistinctness(reports.map((loaded) => ({ runId: loaded.report.run_id, content_sha256: fingerprintDigestOf(loaded.report) })));
62
+ const distinctRunCount = classified.distinct_verified_count + withoutFingerprint;
63
+ return {
64
+ distinctReports,
65
+ distinctness: {
66
+ total_runs: reports.length,
67
+ distinct_runs: distinctRunCount,
68
+ duplicate_runs: reports.length - distinctRunCount,
69
+ runs_with_fingerprint: withFingerprint,
70
+ runs_without_fingerprint: withoutFingerprint
71
+ }
72
+ };
73
+ }
28
74
  function addReportToBucket(bucket, report) {
29
75
  bucket.total_runs += 1;
30
76
  bucket.total_original_input_tokens += report.original_input_tokens;
@@ -101,6 +147,9 @@ export async function readCompactionRunReports(runsDirectory = ".compaction/runs
101
147
  return { reports, skippedFiles };
102
148
  }
103
149
  export function aggregateCompactionReports(reports, skippedFiles = [], generatedAt = new Date().toISOString(), sourceGlob = ".compaction/runs/*/report.json") {
150
+ // Duplicate-safe headline: fold totals over DISTINCT reports only (a run captured/aggregated
151
+ // twice with the same content fingerprint is counted once). Never inflates summed savings.
152
+ const { distinctReports, distinctness } = dedupeReportsByFingerprint(reports);
104
153
  const summary = {
105
154
  ...emptyBucket(),
106
155
  average_percent_reduction: 0,
@@ -108,11 +157,12 @@ export function aggregateCompactionReports(reports, skippedFiles = [], generated
108
157
  savings_by_waste_pattern: {},
109
158
  top_savings_runs: [],
110
159
  skipped_files: skippedFiles,
160
+ distinctness,
111
161
  generated_at: generatedAt,
112
162
  source_glob: sourceGlob
113
163
  };
114
164
  let percentReductionTotal = 0;
115
- for (const { path, report } of reports) {
165
+ for (const { path, report } of distinctReports) {
116
166
  addReportToBucket(summary, report);
117
167
  percentReductionTotal += report.percent_reduction;
118
168
  const policyBucket = (summary.savings_by_policy[report.policy_name] ??= emptyBucket());
@@ -131,7 +181,8 @@ export function aggregateCompactionReports(reports, skippedFiles = [], generated
131
181
  waste_pattern: report.waste_pattern
132
182
  });
133
183
  }
134
- summary.average_percent_reduction = reports.length === 0 ? 0 : roundTo(percentReductionTotal / reports.length, 2);
184
+ summary.average_percent_reduction =
185
+ distinctReports.length === 0 ? 0 : roundTo(percentReductionTotal / distinctReports.length, 2);
135
186
  normalizeBucket(summary);
136
187
  for (const bucket of Object.values(summary.savings_by_policy)) {
137
188
  normalizeBucket(bucket);
@@ -156,6 +207,18 @@ export async function createRunSummary(runsDirectory = ".compaction/runs") {
156
207
  function formatCurrency(value) {
157
208
  return `$${value.toFixed(6)}`;
158
209
  }
210
+ /**
211
+ * "distinct runs N of M (K duplicates detected)" — a count of distinct captured runs by content
212
+ * fingerprint. The totals above are summed over the distinct set, so a re-captured run never
213
+ * double-counts. NOT a billing/provider/semantic claim.
214
+ */
215
+ function distinctnessLine(d) {
216
+ const noFp = d.runs_without_fingerprint > 0
217
+ ? `; ${d.runs_without_fingerprint} run(s) without a fingerprint counted as distinct (cannot de-duplicate)`
218
+ : "";
219
+ return (`distinct runs ${d.distinct_runs} of ${d.total_runs} (${d.duplicate_runs} duplicate(s) detected by content fingerprint, ` +
220
+ `excluded from the headline so savings are not double-counted)${noFp}`);
221
+ }
159
222
  function formatBucketLines(bucket) {
160
223
  return [
161
224
  `runs: ${bucket.total_runs}`,
@@ -184,6 +247,7 @@ export function formatRunSummary(summary) {
184
247
  `Generated at: ${summary.generated_at}`,
185
248
  "",
186
249
  "Totals",
250
+ `- ${distinctnessLine(summary.distinctness)}`,
187
251
  ...formatBucketLines(summary).map((line) => `- ${line}`),
188
252
  `- average percent reduction: ${summary.average_percent_reduction.toFixed(2)}%`,
189
253
  "",
@@ -213,6 +277,7 @@ export function formatRunSummaryMarkdown(summary) {
213
277
  "",
214
278
  "## Totals",
215
279
  "",
280
+ `- Distinct runs: ${distinctnessLine(summary.distinctness)}`,
216
281
  `- Total runs: ${summary.total_runs}`,
217
282
  `- Total original input tokens: ${summary.total_original_input_tokens}`,
218
283
  `- Total compacted input tokens: ${summary.total_compacted_input_tokens}`,
@@ -221,7 +286,7 @@ export function formatRunSummaryMarkdown(summary) {
221
286
  `- Total cost before per run: ${formatCurrency(summary.total_cost_before_per_run)}`,
222
287
  `- Total cost after per run: ${formatCurrency(summary.total_cost_after_per_run)}`,
223
288
  `- Total saving per run: ${formatCurrency(summary.total_saving_per_run)}`,
224
- `- Saving figures are estimated and summed over the ${summary.total_runs} recorded run${summary.total_runs === 1 ? "" : "s"}.`,
289
+ `- Saving figures are estimated and summed over the ${summary.distinctness.distinct_runs} distinct recorded run${summary.distinctness.distinct_runs === 1 ? "" : "s"} (${summary.distinctness.duplicate_runs} duplicate(s) detected by content fingerprint and excluded).`,
225
290
  "",
226
291
  "## Savings by policy",
227
292
  "",
@@ -46,6 +46,14 @@ export interface RunRecord {
46
46
  run_directory: string;
47
47
  report_path: string;
48
48
  model: string;
49
+ /**
50
+ * Content-addressed distinctness fingerprint digest of the source trace, when the run's
51
+ * report.json carried one (`trace_fingerprint.content_sha256`). Used to de-duplicate a
52
+ * run captured/aggregated twice (same digest → counted once) so headline savings are never
53
+ * inflated. `null` for older reports without a fingerprint — those are treated as distinct
54
+ * (cannot dedup what cannot be identified) and noted honestly, never collapsed.
55
+ */
56
+ trace_fingerprint: string | null;
49
57
  /** Token evidence tier for this run (drives the provider-reported vs local-estimate split). */
50
58
  token_evidence: RunTokenEvidence;
51
59
  input_tokens_before: number;
@@ -73,6 +81,32 @@ export interface SkippedRunDirectory {
73
81
  run_directory: string;
74
82
  reason: string;
75
83
  }
84
+ /**
85
+ * Distinctness (duplicate-safety) summary over a set of runs, by content fingerprint.
86
+ *
87
+ * This is a count of DISTINCT captured runs by one-way content fingerprint — NOT a billing,
88
+ * provider, or semantic claim. A run captured/aggregated twice with the SAME fingerprint is a
89
+ * duplicate and is counted once; a run WITHOUT a fingerprint cannot be de-duplicated and is
90
+ * treated as distinct (and reported in `runs_without_fingerprint`), never collapsed.
91
+ *
92
+ * The headline token/cost/savings totals in `AggregateTotals` are computed over the
93
+ * duplicate-safe set (each distinct run once), so a re-captured run never double-counts.
94
+ */
95
+ export interface DistinctnessSummary {
96
+ /** Total runs considered before de-duplication (M in "distinct N of M"). */
97
+ total_runs: number;
98
+ /**
99
+ * Distinct runs after de-duplication (N): distinct verified fingerprints + every run without
100
+ * a fingerprint (each unidentifiable run is its own distinct contribution).
101
+ */
102
+ distinct_runs: number;
103
+ /** Duplicate runs detected and excluded from the headline double-count (K = total - distinct). */
104
+ duplicate_runs: number;
105
+ /** Runs whose fingerprint was present and verifiable (contributed to `distinct_runs`, de-duplicated). */
106
+ runs_with_fingerprint: number;
107
+ /** Runs with NO fingerprint (older reports): each counted as distinct; cannot be de-duplicated. */
108
+ runs_without_fingerprint: number;
109
+ }
76
110
  /** Aggregated token/cost/savings totals over a set of runs, with the evidence split. */
77
111
  export interface AggregateTotals {
78
112
  run_count: number;
@@ -134,6 +168,8 @@ export interface SessionSummary {
134
168
  };
135
169
  totals: AggregateTotals;
136
170
  verification: VerificationCounts;
171
+ /** Duplicate-safety summary for this session's runs (distinct N of M by content fingerprint). */
172
+ distinctness: DistinctnessSummary;
137
173
  }
138
174
  export interface AggregateReport {
139
175
  aggregate_id: string;
@@ -146,6 +182,12 @@ export interface AggregateReport {
146
182
  session_count: number;
147
183
  totals: AggregateTotals;
148
184
  verification: VerificationCounts;
185
+ /**
186
+ * Cross-session duplicate-safety summary (distinct N of M captured runs by content fingerprint).
187
+ * The headline `totals` above are computed over the duplicate-safe set, so a run captured/
188
+ * aggregated twice with the same fingerprint is counted once and never inflates savings.
189
+ */
190
+ distinctness: DistinctnessSummary;
149
191
  sessions: SessionSummary[];
150
192
  /** Provider/runtime breakdown (operator-asserted provider label; unlabeled when absent). */
151
193
  by_provider: BreakdownBucket[];
@@ -166,6 +208,7 @@ export interface AggregateEvidenceLabels {
166
208
  compactions: string;
167
209
  verification: string;
168
210
  provider_runtime: string;
211
+ distinctness: string;
169
212
  }
170
213
  export declare const AGGREGATE_EVIDENCE_LABELS: AggregateEvidenceLabels;
171
214
  /** True when a run passes every supplied local-label filter (case-sensitive exact match). */
@@ -33,6 +33,7 @@ import { randomUUID } from "node:crypto";
33
33
  import { readdir, readFile } from "node:fs/promises";
34
34
  import { join } from "node:path";
35
35
  import { NO_LABEL, parseRunLabels } from "./run-labels.js";
36
+ import { classifyDistinctness } from "./trace-fingerprint.js";
36
37
  export const AGGREGATE_ARTIFACT_DIRECTORY = ".compaction/aggregates";
37
38
  export const AGGREGATE_EVIDENCE_LABELS = {
38
39
  input_tokens: "input tokens: provider-reported where the run's token accounting was measured (provider usage " +
@@ -50,7 +51,12 @@ export const AGGREGATE_EVIDENCE_LABELS = {
50
51
  "task-check outcomes copied from each run's strong-eval; runs without a strong-eval are not_evaluated, " +
51
52
  "never counted as a pass. These are DETERMINISTIC recoverability checks, NOT semantic guarantees.",
52
53
  provider_runtime: "provider/runtime: operator-ASSERTED local label (never provider-verified); runs without a provider " +
53
- "label are grouped under 'unlabeled'. This label is distinct from the token-evidence tier."
54
+ "label are grouped under 'unlabeled'. This label is distinct from the token-evidence tier.",
55
+ distinctness: "distinct runs: a count of DISTINCT captured runs by one-way content fingerprint (SHA-256 over the " +
56
+ "normalized trace). A run captured/aggregated twice with the SAME fingerprint is a duplicate and is " +
57
+ "counted once, so headline savings are never inflated; runs without a fingerprint cannot be " +
58
+ "de-duplicated and are each counted as distinct. This is a local content-distinctness count — NOT a " +
59
+ "billing, provider, or semantic claim."
54
60
  };
55
61
  const STANDARD_LIMITATIONS = [
56
62
  "Aggregated from LOCAL artifacts only (.compaction/runs/*/{report.json,strong-eval.json,run-labels.json}); no provider calls, no upload, no telemetry.",
@@ -59,11 +65,66 @@ const STANDARD_LIMITATIONS = [
59
65
  "Output tokens are summed only over runs that recorded them; runs without a strong-eval contribute unknown output tokens and are counted separately, never inferred.",
60
66
  "Verification outcomes are deterministic recoverability checks copied from each run's strong-eval; they are NOT semantic or meaning-preservation guarantees. Runs without a strong-eval are not_evaluated, never a pass.",
61
67
  "Labels (project/workflow/provider/user-label/session) are operator-supplied local tags with no evidence weight; provider here is operator-asserted, not provider-verified.",
68
+ "Headline savings are duplicate-safe: runs sharing a content fingerprint (a re-captured/re-aggregated run) are counted once; runs without a fingerprint cannot be de-duplicated and are each counted as distinct. This is a local content-distinctness count, not a billing/provider/semantic claim.",
62
69
  "This is a LOCAL, file-based rollup. There is no hosted dashboard, database, auth, billing, model routing, or provider integration."
63
70
  ];
64
71
  function round6(value) {
65
72
  return Number((value + Number.EPSILON).toFixed(6));
66
73
  }
74
+ /**
75
+ * De-duplicate a set of runs by content fingerprint for duplicate-safe headline totals.
76
+ *
77
+ * Keeps the FIRST run per distinct verified fingerprint; later runs with the same fingerprint
78
+ * are duplicates and are dropped from the headline (so a re-captured run never double-counts its
79
+ * savings). Runs WITHOUT a fingerprint are kept as-is — each is its own distinct contribution,
80
+ * because distinctness that cannot be verified is never collapsed (the anti-overcount contract in
81
+ * `classifyDistinctness`). Returns the duplicate-safe run set plus an honest distinctness summary.
82
+ *
83
+ * PURE: order-stable for the kept runs; makes savings MORE conservative, never larger.
84
+ */
85
+ function dedupeRunsByFingerprint(runs) {
86
+ const seenFingerprints = new Set();
87
+ const distinctRuns = [];
88
+ let runsWithFingerprint = 0;
89
+ let runsWithoutFingerprint = 0;
90
+ for (const run of runs) {
91
+ const fp = run.trace_fingerprint;
92
+ if (typeof fp === "string" && fp.length > 0) {
93
+ runsWithFingerprint += 1;
94
+ if (seenFingerprints.has(fp))
95
+ continue; // duplicate fingerprint: excluded from headline.
96
+ seenFingerprints.add(fp);
97
+ distinctRuns.push(run);
98
+ }
99
+ else {
100
+ runsWithoutFingerprint += 1;
101
+ distinctRuns.push(run); // unidentifiable: always its own distinct contribution.
102
+ }
103
+ }
104
+ // classifyDistinctness gives the verified-distinct count over the SAME records; distinct_runs is
105
+ // that verified-distinct count plus every unidentifiable run (each distinct by construction).
106
+ const classified = classifyDistinctness(runs.map((run) => ({ runId: run.run_id, content_sha256: run.trace_fingerprint })));
107
+ const distinctRunCount = classified.distinct_verified_count + runsWithoutFingerprint;
108
+ return {
109
+ distinctRuns,
110
+ distinctness: {
111
+ total_runs: runs.length,
112
+ distinct_runs: distinctRunCount,
113
+ duplicate_runs: runs.length - distinctRunCount,
114
+ runs_with_fingerprint: runsWithFingerprint,
115
+ runs_without_fingerprint: runsWithoutFingerprint
116
+ }
117
+ };
118
+ }
119
+ function emptyDistinctness() {
120
+ return {
121
+ total_runs: 0,
122
+ distinct_runs: 0,
123
+ duplicate_runs: 0,
124
+ runs_with_fingerprint: 0,
125
+ runs_without_fingerprint: 0
126
+ };
127
+ }
67
128
  function emptyTotals() {
68
129
  return {
69
130
  run_count: 0,
@@ -209,9 +270,12 @@ function buildSessions(runs) {
209
270
  }
210
271
  return [...sessions.entries()]
211
272
  .map(([sessionId, { explicit, runs: sessionRuns }]) => {
273
+ // Duplicate-safe: fold totals/verification over distinct runs only (re-captured runs with
274
+ // the same fingerprint are excluded from the double-count). run_ids still lists every run.
275
+ const { distinctRuns, distinctness } = dedupeRunsByFingerprint(sessionRuns);
212
276
  const totals = emptyTotals();
213
277
  const verification = emptyVerification();
214
- for (const run of sessionRuns) {
278
+ for (const run of distinctRuns) {
215
279
  addRunToTotals(totals, run);
216
280
  addRunToVerification(verification, run);
217
281
  }
@@ -227,7 +291,8 @@ function buildSessions(runs) {
227
291
  user_labels: distinct(sessionRuns.map((r) => r.labels.user_label))
228
292
  },
229
293
  totals,
230
- verification
294
+ verification,
295
+ distinctness
231
296
  };
232
297
  })
233
298
  .sort((a, b) => b.totals.total_input_tokens_saved - a.totals.total_input_tokens_saved ||
@@ -258,9 +323,13 @@ export function buildAggregateReport(runs, options = {}) {
258
323
  const generatedAt = options.generatedAt ?? new Date().toISOString();
259
324
  const filters = options.filters ?? {};
260
325
  const matched = runs.filter((run) => runMatchesFilters(run, filters));
326
+ // Duplicate-safe headline: fold cross-session totals/verification over the distinct run set only.
327
+ // A run captured/aggregated twice with the same content fingerprint is counted once; runs without
328
+ // a fingerprint are each distinct (cannot dedup what cannot be identified). Never inflates savings.
329
+ const { distinctRuns, distinctness } = dedupeRunsByFingerprint(matched);
261
330
  const totals = emptyTotals();
262
331
  const verification = emptyVerification();
263
- for (const run of matched) {
332
+ for (const run of distinctRuns) {
264
333
  addRunToTotals(totals, run);
265
334
  addRunToVerification(verification, run);
266
335
  }
@@ -275,10 +344,13 @@ export function buildAggregateReport(runs, options = {}) {
275
344
  session_count: sessions.length,
276
345
  totals,
277
346
  verification,
347
+ distinctness,
278
348
  sessions,
279
- by_provider: buildBreakdown(matched, (run) => run.labels.provider ?? NO_LABEL),
280
- by_project: buildBreakdown(matched, (run) => run.labels.project ?? NO_LABEL),
281
- by_workflow: buildBreakdown(matched, (run) => run.labels.workflow ?? NO_LABEL),
349
+ // Breakdowns also fold over the duplicate-safe distinct run set so per-bucket savings match the
350
+ // duplicate-safe headline (a re-captured run never double-counts inside its label bucket either).
351
+ by_provider: buildBreakdown(distinctRuns, (run) => run.labels.provider ?? NO_LABEL),
352
+ by_project: buildBreakdown(distinctRuns, (run) => run.labels.project ?? NO_LABEL),
353
+ by_workflow: buildBreakdown(distinctRuns, (run) => run.labels.workflow ?? NO_LABEL),
282
354
  skipped_run_directories: options.skippedRunDirectories ?? [],
283
355
  evidence_labels: AGGREGATE_EVIDENCE_LABELS,
284
356
  limitations: STANDARD_LIMITATIONS
@@ -320,6 +392,15 @@ function readinessFrom(status) {
320
392
  export function assembleRunRecord(input) {
321
393
  const { report, strongEval } = input;
322
394
  const notes = [];
395
+ // Content-addressed run identity: read the OPTIONAL trace_fingerprint digest if the report
396
+ // carried one. Older reports omit it; such a run cannot be de-duplicated and is treated as
397
+ // distinct (noted), never collapsed. Read defensively (digest string only; no content).
398
+ const fingerprintDigest = isRecord(report.trace_fingerprint) && typeof report.trace_fingerprint.content_sha256 === "string"
399
+ ? report.trace_fingerprint.content_sha256
400
+ : null;
401
+ if (fingerprintDigest === null) {
402
+ notes.push("No trace_fingerprint on report.json: this run is treated as distinct (cannot de-duplicate an unidentifiable run).");
403
+ }
323
404
  let tokenEvidence = "local_estimate";
324
405
  let inputBefore = report.original_input_tokens;
325
406
  let inputAfter = report.compacted_input_tokens;
@@ -389,6 +470,7 @@ export function assembleRunRecord(input) {
389
470
  run_directory: input.runDirectory,
390
471
  report_path: input.reportPath,
391
472
  model,
473
+ trace_fingerprint: fingerprintDigest,
392
474
  token_evidence: tokenEvidence,
393
475
  input_tokens_before: inputBefore,
394
476
  input_tokens_after: inputAfter,
@@ -225,6 +225,28 @@ export interface CompactionReport {
225
225
  * price-table), NOT billing-confirmed and NOT realized savings.
226
226
  */
227
227
  spend_by_source?: SpendBySourceSummary;
228
+ /**
229
+ * Content-addressed per-run distinctness fingerprint of the source trace (additive,
230
+ * OPTIONAL, backward-compatible). When present, it lets the aggregate attribute this
231
+ * run's savings to a verifiable run identity and de-duplicate a run captured/aggregated
232
+ * twice (same fingerprint → counted once) so the headline savings are never inflated.
233
+ *
234
+ * Older reports omit this field; a run WITHOUT a fingerprint cannot be de-duplicated and
235
+ * is treated as distinct (you cannot dedup what you cannot identify) and noted honestly.
236
+ *
237
+ * Privacy: this is a one-way SHA-256 digest plus non-sensitive structural counts — NO
238
+ * message/prompt/tool-output text. Structurally matches `TraceFingerprint` from
239
+ * `src/core/trace-fingerprint.ts` (kept inline here so `types.ts` stays import-free).
240
+ * This is an attribution/integrity signal, NOT a billing, provider, or semantic claim.
241
+ */
242
+ trace_fingerprint?: {
243
+ /** Algorithm + canonicalization version (e.g. `sha256-canonical-content-v1`). */
244
+ algorithm: string;
245
+ /** Hex SHA-256 digest over the canonical normalized trace content (one-way; never raw content). */
246
+ content_sha256: string;
247
+ /** Non-sensitive structural count: number of messages hashed. */
248
+ message_count: number;
249
+ };
228
250
  }
229
251
  export interface CompactionPolicy {
230
252
  policy_name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compaction/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -79,10 +79,14 @@
79
79
  "dependencies": {
80
80
  "chalk": "^5.4.1",
81
81
  "commander": "^12.1.0",
82
+ "ink": "^5.2.1",
83
+ "react": "^18.3.1",
82
84
  "zod": "^3.24.1"
83
85
  },
84
86
  "devDependencies": {
85
87
  "@types/node": "^22.10.2",
88
+ "@types/react": "^18.3.31",
89
+ "ink-testing-library": "^4.0.0",
86
90
  "tsx": "^4.19.2",
87
91
  "typescript": "^5.7.2",
88
92
  "vitest": "^4.1.8"