swarmdo 1.27.0 → 1.30.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.
- package/.claude/helpers/auto-memory-hook.mjs +71 -34
- package/.claude-plugin/README.md +3 -3
- package/.claude-plugin/docs/INSTALLATION.md +1 -1
- package/.claude-plugin/docs/PLUGIN_SUMMARY.md +1 -1
- package/.claude-plugin/docs/QUICKSTART.md +3 -3
- package/.claude-plugin/hooks/hooks.json +19 -1
- package/.claude-plugin/marketplace.json +17 -17
- package/.claude-plugin/plugin.json +7 -39
- package/.claude-plugin/scripts/install.sh +3 -3
- package/.claude-plugin/scripts/swarmdo-hook.sh +1 -1
- package/.claude-plugin/scripts/verify.sh +2 -2
- package/README.md +11 -9
- package/package.json +1 -1
- package/v3/@swarmdo/cli/.claude/helpers/auto-memory-hook.mjs +70 -33
- package/v3/@swarmdo/cli/bin/cli.js +38 -0
- package/v3/@swarmdo/cli/dist/src/apply/apply.d.ts +6 -0
- package/v3/@swarmdo/cli/dist/src/apply/apply.js +41 -8
- package/v3/@swarmdo/cli/dist/src/commands/apply.js +17 -4
- package/v3/@swarmdo/cli/dist/src/commands/compact-snapshot.d.ts +17 -0
- package/v3/@swarmdo/cli/dist/src/commands/compact-snapshot.js +133 -0
- package/v3/@swarmdo/cli/dist/src/commands/index.js +4 -3
- package/v3/@swarmdo/cli/dist/src/commands/pack.d.ts +8 -0
- package/v3/@swarmdo/cli/dist/src/commands/pack.js +28 -2
- package/v3/@swarmdo/cli/dist/src/commands/usage.js +174 -2
- package/v3/@swarmdo/cli/dist/src/compact/compact.js +17 -7
- package/v3/@swarmdo/cli/dist/src/compact-snapshot/compact-snapshot.d.ts +58 -0
- package/v3/@swarmdo/cli/dist/src/compact-snapshot/compact-snapshot.js +104 -0
- package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +16 -2
- package/v3/@swarmdo/cli/dist/src/init/helpers-generator.js +33 -3
- package/v3/@swarmdo/cli/dist/src/redact/redact.js +1 -1
- package/v3/@swarmdo/cli/dist/src/sbom/sbom.js +5 -2
- package/v3/@swarmdo/cli/dist/src/swarmvector/model-prices.js +12 -2
- package/v3/@swarmdo/cli/dist/src/testreport/testreport.js +11 -2
- package/v3/@swarmdo/cli/dist/src/transcript/export.d.ts +5 -0
- package/v3/@swarmdo/cli/dist/src/transcript/export.js +8 -1
- package/v3/@swarmdo/cli/dist/src/usage/claude-pricing.d.ts +6 -2
- package/v3/@swarmdo/cli/dist/src/usage/claude-pricing.js +33 -4
- package/v3/@swarmdo/cli/dist/src/usage/limits.d.ts +77 -0
- package/v3/@swarmdo/cli/dist/src/usage/limits.js +134 -0
- package/v3/@swarmdo/cli/dist/src/usage/reflect-html.d.ts +23 -0
- package/v3/@swarmdo/cli/dist/src/usage/reflect-html.js +94 -0
- package/v3/@swarmdo/cli/dist/src/usage/reflect.d.ts +105 -0
- package/v3/@swarmdo/cli/dist/src/usage/reflect.js +165 -0
- package/v3/@swarmdo/cli/dist/src/usage/spend-forecast.d.ts +28 -0
- package/v3/@swarmdo/cli/dist/src/usage/spend-forecast.js +33 -0
- package/v3/@swarmdo/cli/dist/src/usage/transcript-errors.d.ts +14 -0
- package/v3/@swarmdo/cli/dist/src/usage/transcript-errors.js +10 -0
- package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.js +1 -1
- package/v3/@swarmdo/cli/dist/src/util/csv.d.ts +14 -0
- package/v3/@swarmdo/cli/dist/src/util/csv.js +20 -0
- package/v3/@swarmdo/cli/package.json +2 -2
|
@@ -14,10 +14,16 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { output } from '../output.js';
|
|
16
16
|
import { aggregateBlocks, aggregateUsage, collectUsage, totalUsage, localDateKey, } from '../usage/transcript-usage.js';
|
|
17
|
-
import { collectToolErrors } from '../usage/transcript-errors.js';
|
|
17
|
+
import { collectToolErrors, delegationFromReport } from '../usage/transcript-errors.js';
|
|
18
18
|
import { computeCacheStats } from '../usage/cache-stats.js';
|
|
19
19
|
import { evaluateGuard } from '../usage/spend-guard.js';
|
|
20
20
|
import { resolvePeriodPair, parseRange, diffPeriods, modelMovers } from '../usage/diff.js';
|
|
21
|
+
import { computeReflection, monthsBefore, hourSparkline } from '../usage/reflect.js';
|
|
22
|
+
import { renderReflectionHtml } from '../usage/reflect-html.js';
|
|
23
|
+
import { forecastWindow, parseRateLimits, worstStatus, formatForecast, formatLimitSegment } from '../usage/limits.js';
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
import { toCsv } from '../util/csv.js';
|
|
26
|
+
import { projectMonthEnd, daysInMonthOf } from '../usage/spend-forecast.js';
|
|
21
27
|
const VIEWS = {
|
|
22
28
|
daily: { dimension: 'day', label: 'Date' },
|
|
23
29
|
monthly: { dimension: 'month', label: 'Month' },
|
|
@@ -376,6 +382,143 @@ function runGuardView(ctx, collection) {
|
|
|
376
382
|
output.writeln(output.dim('exit 1 (--strict + over budget)'));
|
|
377
383
|
return { success: exitCode === 0, exitCode };
|
|
378
384
|
}
|
|
385
|
+
const PERIOD_MONTHS = { '1m': 1, '3m': 3, '6m': 6, '12m': 12 };
|
|
386
|
+
/** `usage reflect` — a wrapped-style retrospective over the local transcripts (#47). */
|
|
387
|
+
function runReflectView(ctx, collection) {
|
|
388
|
+
const periodFlag = (typeof ctx.flags.period === 'string' ? ctx.flags.period : '3m').toLowerCase();
|
|
389
|
+
const months = PERIOD_MONTHS[periodFlag];
|
|
390
|
+
if (!months) {
|
|
391
|
+
output.printError(`unknown --period "${periodFlag}" (expected ${Object.keys(PERIOD_MONTHS).join('|')})`);
|
|
392
|
+
return { success: false, exitCode: 1 };
|
|
393
|
+
}
|
|
394
|
+
const to = localDateKey(new Date());
|
|
395
|
+
const from = monthsBefore(to, months);
|
|
396
|
+
const dayRows = aggregateUsage(collection.events, 'day').map((r) => ({ key: r.key, totals: r.totals }));
|
|
397
|
+
const md = new Map();
|
|
398
|
+
const pd = new Map(); // per-(project, day) for top projects
|
|
399
|
+
const hourHistogram = new Array(24).fill(0); // local-hour cost, windowed to [from,to]
|
|
400
|
+
for (const e of collection.events) {
|
|
401
|
+
const k = `${e.model}\u0000${e.dateKey}`;
|
|
402
|
+
const row = md.get(k) ?? { key: e.model, day: e.dateKey, totals: { costUsd: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 0 } };
|
|
403
|
+
row.totals.costUsd += e.costUsd;
|
|
404
|
+
row.totals.totalTokens += e.inputTokens + e.outputTokens + e.cacheWriteTokens + e.cacheReadTokens;
|
|
405
|
+
md.set(k, row);
|
|
406
|
+
// per-(project, day) fold (space separator is safe — dateKey is fixed-width)
|
|
407
|
+
const pk = `${e.project} ${e.dateKey}`;
|
|
408
|
+
const prow = pd.get(pk) ?? { key: e.project, day: e.dateKey, totals: { costUsd: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 0 } };
|
|
409
|
+
prow.totals.costUsd += e.costUsd;
|
|
410
|
+
prow.totals.totalTokens += e.inputTokens + e.outputTokens + e.cacheWriteTokens + e.cacheReadTokens;
|
|
411
|
+
pd.set(pk, prow);
|
|
412
|
+
if (e.dateKey >= from && e.dateKey <= to)
|
|
413
|
+
hourHistogram[new Date(e.timestampMs).getHours()] += e.costUsd;
|
|
414
|
+
}
|
|
415
|
+
const reflection = computeReflection(dayRows, [...md.values()], from, to, {}, [...pd.values()], hourHistogram);
|
|
416
|
+
// Delegation ratio (#47): reuse the (windowed) tool-error scan to count Task
|
|
417
|
+
// (subagent) calls vs all tool calls — the parsing is already tested there.
|
|
418
|
+
const delDirFlag = ctx.flags.dir;
|
|
419
|
+
const delDirs = typeof delDirFlag === 'string' ? [delDirFlag] : Array.isArray(delDirFlag) ? delDirFlag.map(String) : undefined;
|
|
420
|
+
const delegation = delegationFromReport(collectToolErrors({ dirs: delDirs, since: from, until: to }));
|
|
421
|
+
if (ctx.flags.json === true) {
|
|
422
|
+
output.printJson({ ...reflection, delegation });
|
|
423
|
+
return { success: true, data: reflection };
|
|
424
|
+
}
|
|
425
|
+
if (ctx.flags.html === true) {
|
|
426
|
+
output.writeln(renderReflectionHtml(reflection, { generatedAt: to, delegation }));
|
|
427
|
+
return { success: true, exitCode: 0 };
|
|
428
|
+
}
|
|
429
|
+
const r = reflection;
|
|
430
|
+
const pctStr = (x) => `${Math.round(x * 100)}%`;
|
|
431
|
+
const arrow = r.trend.direction === 'up' ? '↑' : r.trend.direction === 'down' ? '↓' : '→';
|
|
432
|
+
output.writeln(output.bold(`Usage reflect ${r.period.from}..${r.period.to}`) + output.dim(` (${periodFlag}, ${r.period.spanDays} days)`));
|
|
433
|
+
if (r.totals.activeDays === 0) {
|
|
434
|
+
output.writeln(output.info('no Claude Code activity in this window'));
|
|
435
|
+
return { success: true, exitCode: 0 };
|
|
436
|
+
}
|
|
437
|
+
output.writeln('');
|
|
438
|
+
output.writeln(` Total spend ${output.bold(fmtCost(r.totals.costUsd))} ${output.dim(`across ${r.totals.activeDays} active day${r.totals.activeDays === 1 ? '' : 's'} of ${r.period.spanDays}`)}`);
|
|
439
|
+
output.writeln(` Total tokens ${r.totals.totalTokens.toLocaleString()}`);
|
|
440
|
+
if (r.busiestDay)
|
|
441
|
+
output.writeln(` Busiest day ${r.busiestDay.day} ${output.dim(`(${fmtCost(r.busiestDay.costUsd)}, ${r.busiestDay.totalTokens.toLocaleString()} tok)`)}`);
|
|
442
|
+
output.writeln(` Longest streak ${r.longestStreak} day${r.longestStreak === 1 ? '' : 's'}`);
|
|
443
|
+
output.writeln(` Avg / active day ${fmtCost(r.avgCostPerActiveDay)}`);
|
|
444
|
+
output.writeln(` Cache read share ${pctStr(r.cacheReadPct)}`);
|
|
445
|
+
if (delegation.toolCalls > 0) {
|
|
446
|
+
output.writeln(` Delegation ${pctStr(delegation.ratio)} ${output.dim(`(${delegation.taskCalls} of ${delegation.toolCalls} tool calls to subagents)`)}`);
|
|
447
|
+
}
|
|
448
|
+
output.writeln(` Cost trend ${arrow} ${fmtCost(r.trend.firstHalfCost)} → ${fmtCost(r.trend.secondHalfCost)} ${output.dim(`(${r.trend.direction})`)}`);
|
|
449
|
+
if (r.peakHour)
|
|
450
|
+
output.writeln(` Peak hour ${String(r.peakHour.hour).padStart(2, '0')}:00 ${output.dim(`(${fmtCost(r.peakHour.value)})`)}`);
|
|
451
|
+
if (r.hourHistogram.some((v) => v > 0)) {
|
|
452
|
+
output.writeln(` By hour ${hourSparkline(r.hourHistogram)} ${output.dim('0…23')}`);
|
|
453
|
+
}
|
|
454
|
+
if (r.topModels.length) {
|
|
455
|
+
output.writeln('');
|
|
456
|
+
output.writeln(output.bold(' Top models'));
|
|
457
|
+
r.topModels.forEach((m, i) => {
|
|
458
|
+
output.writeln(` ${i + 1}. ${m.model.padEnd(22)} ${fmtCost(m.costUsd).padStart(9)} ${output.dim(pctStr(m.pct))}`);
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
if (r.topProjects.length) {
|
|
462
|
+
output.writeln('');
|
|
463
|
+
output.writeln(output.bold(' Top projects'));
|
|
464
|
+
r.topProjects.forEach((p, i) => {
|
|
465
|
+
const name = p.model.length > 34 ? '…' + p.model.slice(-33) : p.model;
|
|
466
|
+
output.writeln(` ${i + 1}. ${name.padEnd(34)} ${fmtCost(p.costUsd).padStart(9)} ${output.dim(pctStr(p.pct))}`);
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
return { success: true, exitCode: 0 };
|
|
470
|
+
}
|
|
471
|
+
/** `usage limits` — forecast the official 5h/7d quota windows from the
|
|
472
|
+
* statusline rate_limits payload (stdin or --payload). #46. */
|
|
473
|
+
function runLimitsView(ctx) {
|
|
474
|
+
const nowMs = Date.now();
|
|
475
|
+
let raw = typeof ctx.flags.payload === 'string' ? ctx.flags.payload : '';
|
|
476
|
+
if (!raw && !process.stdin.isTTY) {
|
|
477
|
+
try {
|
|
478
|
+
raw = readFileSync(0, 'utf8');
|
|
479
|
+
}
|
|
480
|
+
catch { /* no piped input */ }
|
|
481
|
+
}
|
|
482
|
+
if (!raw.trim()) {
|
|
483
|
+
output.writeln(output.info("no rate_limits payload — pipe Claude Code's statusline JSON in, or pass --payload '<json>'"));
|
|
484
|
+
output.writeln(output.dim(' e.g. in a statusline command: … | swarmdo usage limits'));
|
|
485
|
+
return { success: true, exitCode: 0 };
|
|
486
|
+
}
|
|
487
|
+
let payload;
|
|
488
|
+
try {
|
|
489
|
+
payload = JSON.parse(raw);
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
output.printError('payload is not valid JSON');
|
|
493
|
+
return { success: false, exitCode: 1 };
|
|
494
|
+
}
|
|
495
|
+
const windows = parseRateLimits(payload);
|
|
496
|
+
if (windows.length === 0) {
|
|
497
|
+
output.writeln(output.info('no five_hour / seven_day rate_limits found in the payload'));
|
|
498
|
+
return { success: true, exitCode: 0 };
|
|
499
|
+
}
|
|
500
|
+
const forecasts = windows.map((w) => ({ label: w.label, f: forecastWindow(w.state, nowMs) }));
|
|
501
|
+
if (ctx.flags.json === true) {
|
|
502
|
+
output.printJson(forecasts.map((x) => ({ label: x.label, ...x.f })));
|
|
503
|
+
return { success: true, data: forecasts };
|
|
504
|
+
}
|
|
505
|
+
if (ctx.flags.oneline === true) {
|
|
506
|
+
// Compact statusline-ready indicator, e.g. "5h 72%⚠ · 7d 12%".
|
|
507
|
+
output.writeln(formatLimitSegment(forecasts));
|
|
508
|
+
const worstOne = worstStatus(forecasts.map((x) => x.f));
|
|
509
|
+
return { success: true, exitCode: ctx.flags.strict === true && worstOne === 'over' ? 1 : 0 };
|
|
510
|
+
}
|
|
511
|
+
const worst = worstStatus(forecasts.map((x) => x.f));
|
|
512
|
+
output.writeln(output.bold('Usage limits') + output.dim(` (${worst})`));
|
|
513
|
+
for (const { label, f } of forecasts) {
|
|
514
|
+
const line = formatForecast(label, f, nowMs);
|
|
515
|
+
output.writeln(' ' + (f.status === 'over' ? output.error(line) : f.status === 'warn' ? line : output.dim(line)));
|
|
516
|
+
}
|
|
517
|
+
const exitCode = ctx.flags.strict === true && worst === 'over' ? 1 : 0;
|
|
518
|
+
if (exitCode === 1)
|
|
519
|
+
output.writeln(output.dim('exit 1 (--strict + cap reached)'));
|
|
520
|
+
return { success: exitCode === 0, exitCode };
|
|
521
|
+
}
|
|
379
522
|
async function run(ctx) {
|
|
380
523
|
const viewName = (ctx.args[0] || 'daily').toLowerCase();
|
|
381
524
|
if (viewName === 'blocks') {
|
|
@@ -409,9 +552,17 @@ async function run(ctx) {
|
|
|
409
552
|
const dirs = typeof dirFlag === 'string' ? [dirFlag] : Array.isArray(dirFlag) ? dirFlag.map(String) : undefined;
|
|
410
553
|
return runDiffView(ctx, collectUsage({ dirs }));
|
|
411
554
|
}
|
|
555
|
+
if (viewName === 'reflect') {
|
|
556
|
+
const dirFlag = ctx.flags.dir;
|
|
557
|
+
const dirs = typeof dirFlag === 'string' ? [dirFlag] : Array.isArray(dirFlag) ? dirFlag.map(String) : undefined;
|
|
558
|
+
return runReflectView(ctx, collectUsage({ dirs }));
|
|
559
|
+
}
|
|
560
|
+
if (viewName === 'limits') {
|
|
561
|
+
return runLimitsView(ctx); // reads the rate_limits payload from stdin/--payload, not transcripts
|
|
562
|
+
}
|
|
412
563
|
const view = VIEWS[viewName];
|
|
413
564
|
if (!view) {
|
|
414
|
-
output.writeln(output.error(`unknown view: ${viewName} (expected ${Object.keys(VIEWS).join('|')}|blocks|errors|cache|guard|diff)`));
|
|
565
|
+
output.writeln(output.error(`unknown view: ${viewName} (expected ${Object.keys(VIEWS).join('|')}|blocks|errors|cache|guard|diff|reflect|limits)`));
|
|
415
566
|
return { success: false, exitCode: 1 };
|
|
416
567
|
}
|
|
417
568
|
const since = typeof ctx.flags.since === 'string' ? ctx.flags.since : undefined;
|
|
@@ -433,12 +584,31 @@ async function run(ctx) {
|
|
|
433
584
|
output.writeln(JSON.stringify(jsonPayload(viewName, collection, rows, grand, since, until), null, 2));
|
|
434
585
|
return { success: true, exitCode: 0, data: grand };
|
|
435
586
|
}
|
|
587
|
+
if (ctx.flags.csv === true) {
|
|
588
|
+
// Portable export for spreadsheets / expense reports.
|
|
589
|
+
const headers = ['key', 'costUsd', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'totalTokens', 'entries'];
|
|
590
|
+
const csvRows = rows.map((r) => [r.key, r.totals.costUsd, r.totals.inputTokens, r.totals.outputTokens, r.totals.cacheReadTokens, r.totals.cacheWriteTokens, r.totals.totalTokens, r.totals.entries]);
|
|
591
|
+
output.writeln(toCsv(headers, csvRows));
|
|
592
|
+
return { success: true, exitCode: 0, data: grand };
|
|
593
|
+
}
|
|
436
594
|
if (collection.events.length === 0) {
|
|
437
595
|
output.writeln(output.info(`no usage entries found in ${collection.filesScanned} transcript files`));
|
|
438
596
|
return { success: true, exitCode: 0 };
|
|
439
597
|
}
|
|
440
598
|
output.writeln(output.bold(`Claude Code usage — ${viewName}`) + (since || until ? output.dim(` (${since ?? '…'} → ${until ?? '…'})`) : ''));
|
|
441
599
|
renderTable(view.label, rows, grand);
|
|
600
|
+
if (viewName === 'monthly') {
|
|
601
|
+
// Month-end pace projection for the current calendar month.
|
|
602
|
+
const today = localDateKey(new Date());
|
|
603
|
+
const curMonth = today.slice(0, 7);
|
|
604
|
+
const dom = Number(today.slice(8, 10));
|
|
605
|
+
const curRow = rows.find((r) => r.key === curMonth);
|
|
606
|
+
const dim = daysInMonthOf(curMonth);
|
|
607
|
+
if (curRow && dom > 0 && Number.isFinite(dim)) {
|
|
608
|
+
const p = projectMonthEnd(curRow.totals.costUsd, dom, dim);
|
|
609
|
+
output.writeln(output.dim(`${curMonth} on pace for ~${fmtCost(p.projectedUsd)} (${fmtCost(p.monthToDateUsd)} over ${dom}/${p.daysInMonth} days · ~${fmtCost(p.remainingUsd)} to go)`));
|
|
610
|
+
}
|
|
611
|
+
}
|
|
442
612
|
output.writeln(output.dim(`${collection.filesScanned} transcript files · ${grand.entries} billed responses · sources: ${collection.dirsScanned.join(', ')}`));
|
|
443
613
|
if (collection.unpricedModels.length > 0) {
|
|
444
614
|
const { unpricedResponses, unpricedTokens } = unpricedStats(collection.events);
|
|
@@ -456,11 +626,13 @@ export const usageCommand = {
|
|
|
456
626
|
{ name: 'dir', description: 'explicit Claude projects dir (replaces auto-discovery)', type: 'string' },
|
|
457
627
|
{ name: 'limit', description: 'max rows for models/projects/sessions views', type: 'number' },
|
|
458
628
|
{ name: 'json', description: 'machine-readable output', type: 'boolean', default: false },
|
|
629
|
+
{ name: 'csv', description: 'CSV export (daily|monthly|models|projects|sessions views)', type: 'boolean', default: false },
|
|
459
630
|
],
|
|
460
631
|
examples: [
|
|
461
632
|
{ command: 'swarmdo usage', description: 'Daily token/cost table across all local Claude Code sessions' },
|
|
462
633
|
{ command: 'swarmdo usage models --since 2026-07-01', description: 'Spend per model this month' },
|
|
463
634
|
{ command: 'swarmdo usage projects --json', description: 'Per-project totals as JSON' },
|
|
635
|
+
{ command: 'swarmdo usage monthly --csv > usage.csv', description: 'Export monthly spend to CSV' },
|
|
464
636
|
{ command: 'swarmdo cost sessions --limit 10', description: 'Ten most expensive sessions (alias)' },
|
|
465
637
|
],
|
|
466
638
|
action: run,
|
|
@@ -95,21 +95,31 @@ function foldNodeFrames(lines) {
|
|
|
95
95
|
}
|
|
96
96
|
return out;
|
|
97
97
|
}
|
|
98
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Collapse runs of ≥3 consecutive blank lines to a single blank. Runs of 1 or 2
|
|
100
|
+
* blanks are preserved (a 2-blank run is meaningful — e.g. PEP 8 spacing between
|
|
101
|
+
* top-level defs), so the full run length is counted before deciding.
|
|
102
|
+
*/
|
|
99
103
|
function collapseBlankLines(lines) {
|
|
100
104
|
const out = [];
|
|
101
105
|
let blanks = 0;
|
|
106
|
+
const flush = () => {
|
|
107
|
+
if (blanks === 0)
|
|
108
|
+
return;
|
|
109
|
+
const keep = blanks >= 3 ? 1 : blanks; // ≥3 → 1; 1 or 2 → unchanged
|
|
110
|
+
for (let i = 0; i < keep; i++)
|
|
111
|
+
out.push('');
|
|
112
|
+
blanks = 0;
|
|
113
|
+
};
|
|
102
114
|
for (const line of lines) {
|
|
103
115
|
if (line.trim() === '') {
|
|
104
116
|
blanks++;
|
|
105
|
-
|
|
106
|
-
out.push('');
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
blanks = 0;
|
|
110
|
-
out.push(line);
|
|
117
|
+
continue;
|
|
111
118
|
}
|
|
119
|
+
flush();
|
|
120
|
+
out.push(line);
|
|
112
121
|
}
|
|
122
|
+
flush();
|
|
113
123
|
return out;
|
|
114
124
|
}
|
|
115
125
|
/** Keep head + tail lines, eliding the middle with a marker. */
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compact-snapshot.ts — build a "survival digest" that lets an agent re-ground
|
|
3
|
+
* after context compaction. Post-compaction, the model loses its working set
|
|
4
|
+
* and tends to re-explore files it just edited and repeat corrected mistakes
|
|
5
|
+
* (a widely-reported pain, #45). This captures the cheap signals swarmdo
|
|
6
|
+
* already tracks — recently edited files (the pending-insights edit ledger),
|
|
7
|
+
* uncommitted changes (`git status --porcelain`), and the current branch —
|
|
8
|
+
* into a compact digest that the first post-compaction prompt re-injects.
|
|
9
|
+
*
|
|
10
|
+
* Pure + deterministic: inputs in (including `now`, so no clock dependency),
|
|
11
|
+
* a digest / rendered string out. The fs read (edit ledger), git calls, and
|
|
12
|
+
* digest persistence live in ../commands/compact-snapshot.ts, so this is
|
|
13
|
+
* fixture-testable.
|
|
14
|
+
*/
|
|
15
|
+
/** One edit-ledger record (a line of .swarmdo/data/pending-insights.jsonl). */
|
|
16
|
+
export interface EditRecord {
|
|
17
|
+
file: string;
|
|
18
|
+
timestamp: number;
|
|
19
|
+
}
|
|
20
|
+
export interface DigestInput {
|
|
21
|
+
/** recent edit-ledger records; most-recent-wins when a file repeats */
|
|
22
|
+
edits?: EditRecord[];
|
|
23
|
+
/** raw `git status --porcelain` lines (uncommitted changes) */
|
|
24
|
+
gitStatus?: string[];
|
|
25
|
+
/** current branch name, if known */
|
|
26
|
+
branch?: string;
|
|
27
|
+
/** epoch ms the snapshot was taken (injected, not read from a clock) */
|
|
28
|
+
now: number;
|
|
29
|
+
}
|
|
30
|
+
export interface CompactDigest {
|
|
31
|
+
takenAt: number;
|
|
32
|
+
branch?: string;
|
|
33
|
+
/** distinct edited files, most-recently-edited first, capped */
|
|
34
|
+
recentFiles: string[];
|
|
35
|
+
/** distinct uncommitted file paths, capped */
|
|
36
|
+
uncommitted: string[];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Extract the working-tree path from one `git status --porcelain` line.
|
|
40
|
+
* Format is `XY <path>` (two status chars + space); a rename is
|
|
41
|
+
* `R <old> -> <new>` — we want the destination. Quoted paths (git quotes
|
|
42
|
+
* names with special chars) are unwrapped. Returns null for a blank line.
|
|
43
|
+
* Pure.
|
|
44
|
+
*/
|
|
45
|
+
export declare function porcelainPath(line: string): string | null;
|
|
46
|
+
/** Build the digest from raw session signals. Pure. */
|
|
47
|
+
export declare function buildDigest(input: DigestInput, opts?: {
|
|
48
|
+
maxFiles?: number;
|
|
49
|
+
}): CompactDigest;
|
|
50
|
+
/** A digest with no files and no branch carries nothing worth re-injecting. Pure. */
|
|
51
|
+
export declare function isDigestEmpty(d: CompactDigest): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Render the digest as a re-grounding block for the first post-compaction
|
|
54
|
+
* prompt. Empty digest → '' (nothing to inject). `now` is passed so the
|
|
55
|
+
* relative age is deterministic. Pure.
|
|
56
|
+
*/
|
|
57
|
+
export declare function formatDigest(d: CompactDigest, now: number): string;
|
|
58
|
+
//# sourceMappingURL=compact-snapshot.d.ts.map
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compact-snapshot.ts — build a "survival digest" that lets an agent re-ground
|
|
3
|
+
* after context compaction. Post-compaction, the model loses its working set
|
|
4
|
+
* and tends to re-explore files it just edited and repeat corrected mistakes
|
|
5
|
+
* (a widely-reported pain, #45). This captures the cheap signals swarmdo
|
|
6
|
+
* already tracks — recently edited files (the pending-insights edit ledger),
|
|
7
|
+
* uncommitted changes (`git status --porcelain`), and the current branch —
|
|
8
|
+
* into a compact digest that the first post-compaction prompt re-injects.
|
|
9
|
+
*
|
|
10
|
+
* Pure + deterministic: inputs in (including `now`, so no clock dependency),
|
|
11
|
+
* a digest / rendered string out. The fs read (edit ledger), git calls, and
|
|
12
|
+
* digest persistence live in ../commands/compact-snapshot.ts, so this is
|
|
13
|
+
* fixture-testable.
|
|
14
|
+
*/
|
|
15
|
+
const DEFAULT_MAX_FILES = 12;
|
|
16
|
+
/**
|
|
17
|
+
* Extract the working-tree path from one `git status --porcelain` line.
|
|
18
|
+
* Format is `XY <path>` (two status chars + space); a rename is
|
|
19
|
+
* `R <old> -> <new>` — we want the destination. Quoted paths (git quotes
|
|
20
|
+
* names with special chars) are unwrapped. Returns null for a blank line.
|
|
21
|
+
* Pure.
|
|
22
|
+
*/
|
|
23
|
+
export function porcelainPath(line) {
|
|
24
|
+
if (!line || line.length < 4)
|
|
25
|
+
return null;
|
|
26
|
+
let rest = line.slice(3); // strip the 2 status chars + the separating space
|
|
27
|
+
const arrow = rest.indexOf(' -> ');
|
|
28
|
+
if (arrow >= 0)
|
|
29
|
+
rest = rest.slice(arrow + 4); // rename/copy → destination path
|
|
30
|
+
rest = rest.trim();
|
|
31
|
+
if (rest.startsWith('"') && rest.endsWith('"') && rest.length >= 2) {
|
|
32
|
+
rest = rest.slice(1, -1); // unwrap git's quoted path (escapes left as-is)
|
|
33
|
+
}
|
|
34
|
+
return rest || null;
|
|
35
|
+
}
|
|
36
|
+
/** Build the digest from raw session signals. Pure. */
|
|
37
|
+
export function buildDigest(input, opts = {}) {
|
|
38
|
+
const max = opts.maxFiles ?? DEFAULT_MAX_FILES;
|
|
39
|
+
// Distinct edited files, most-recent edit first. A file edited repeatedly
|
|
40
|
+
// keeps its latest timestamp; ties preserve first-seen (stable) order.
|
|
41
|
+
const latest = new Map();
|
|
42
|
+
for (const e of input.edits ?? []) {
|
|
43
|
+
if (!e || typeof e.file !== 'string' || !e.file)
|
|
44
|
+
continue;
|
|
45
|
+
const t = typeof e.timestamp === 'number' ? e.timestamp : 0;
|
|
46
|
+
const prev = latest.get(e.file);
|
|
47
|
+
if (prev === undefined || t >= prev)
|
|
48
|
+
latest.set(e.file, t);
|
|
49
|
+
}
|
|
50
|
+
const recentFiles = [...latest.entries()]
|
|
51
|
+
.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
52
|
+
.slice(0, max)
|
|
53
|
+
.map(([f]) => f);
|
|
54
|
+
const seenUncommitted = new Set();
|
|
55
|
+
const uncommitted = [];
|
|
56
|
+
for (const line of input.gitStatus ?? []) {
|
|
57
|
+
const p = porcelainPath(line);
|
|
58
|
+
if (p && !seenUncommitted.has(p)) {
|
|
59
|
+
seenUncommitted.add(p);
|
|
60
|
+
uncommitted.push(p);
|
|
61
|
+
}
|
|
62
|
+
if (uncommitted.length >= max)
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
takenAt: input.now,
|
|
67
|
+
...(input.branch ? { branch: input.branch } : {}),
|
|
68
|
+
recentFiles,
|
|
69
|
+
uncommitted,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** A digest with no files and no branch carries nothing worth re-injecting. Pure. */
|
|
73
|
+
export function isDigestEmpty(d) {
|
|
74
|
+
return d.recentFiles.length === 0 && d.uncommitted.length === 0;
|
|
75
|
+
}
|
|
76
|
+
/** Compact human "Nm ago" for the snapshot age. Pure. */
|
|
77
|
+
function ago(fromMs, now) {
|
|
78
|
+
const s = Math.max(0, Math.round((now - fromMs) / 1000));
|
|
79
|
+
if (s < 60)
|
|
80
|
+
return `${s}s ago`;
|
|
81
|
+
const m = Math.round(s / 60);
|
|
82
|
+
if (m < 60)
|
|
83
|
+
return `${m}m ago`;
|
|
84
|
+
return `${Math.round(m / 60)}h ago`;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Render the digest as a re-grounding block for the first post-compaction
|
|
88
|
+
* prompt. Empty digest → '' (nothing to inject). `now` is passed so the
|
|
89
|
+
* relative age is deterministic. Pure.
|
|
90
|
+
*/
|
|
91
|
+
export function formatDigest(d, now) {
|
|
92
|
+
if (isDigestEmpty(d))
|
|
93
|
+
return '';
|
|
94
|
+
const lines = [`[swarmdo] Working context restored after compaction (snapshot ${ago(d.takenAt, now)}):`];
|
|
95
|
+
if (d.branch)
|
|
96
|
+
lines.push(`- Branch: ${d.branch}`);
|
|
97
|
+
if (d.recentFiles.length)
|
|
98
|
+
lines.push(`- Recently edited (${d.recentFiles.length}): ${d.recentFiles.join(', ')}`);
|
|
99
|
+
if (d.uncommitted.length)
|
|
100
|
+
lines.push(`- Uncommitted changes (${d.uncommitted.length}): ${d.uncommitted.join(', ')}`);
|
|
101
|
+
lines.push('Resume this work; re-read these files only as needed rather than re-exploring from scratch.');
|
|
102
|
+
return lines.join('\n');
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=compact-snapshot.js.map
|
|
@@ -13,9 +13,23 @@ export const TOPOLOGIES = ['hierarchical', 'mesh', 'hierarchical-mesh', 'ring',
|
|
|
13
13
|
export const MEMORY_BACKENDS = ['agentdb', 'sqlite', 'hybrid', 'memory'];
|
|
14
14
|
import { parseOpenRouterConfig } from '../providers/openrouter-config.js';
|
|
15
15
|
export const KNOWN_CONFIG_KEYS = ['topology', 'maxAgents', 'strategy', 'consensus', 'memory', 'memoryBackend', 'hnsw', 'neural', 'embeddings', 'providers', 'mcp', 'logging', 'daemon', 'hooks', 'version', 'openrouter', '$schema'];
|
|
16
|
+
// Current Claude Code hook events (source: code.claude.com/docs/en/hooks).
|
|
17
|
+
// Kept in sync with the runtime; a stale list here false-warns on valid hooks.
|
|
16
18
|
export const HOOK_EVENTS = [
|
|
17
|
-
|
|
18
|
-
'SessionStart', '
|
|
19
|
+
// session lifecycle
|
|
20
|
+
'SessionStart', 'Setup', 'SessionEnd',
|
|
21
|
+
// per-turn
|
|
22
|
+
'UserPromptSubmit', 'UserPromptExpansion', 'Stop', 'StopFailure',
|
|
23
|
+
// agentic loop
|
|
24
|
+
'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'PostToolBatch', 'PermissionRequest', 'PermissionDenied',
|
|
25
|
+
// subagent & task
|
|
26
|
+
'SubagentStart', 'SubagentStop', 'TeammateIdle', 'TaskCreated', 'TaskCompleted',
|
|
27
|
+
// system & file
|
|
28
|
+
'Notification', 'MessageDisplay', 'CwdChanged', 'FileChanged', 'ConfigChange', 'InstructionsLoaded', 'PreCompact', 'PostCompact',
|
|
29
|
+
// MCP elicitation
|
|
30
|
+
'Elicitation', 'ElicitationResult',
|
|
31
|
+
// worktree
|
|
32
|
+
'WorktreeCreate', 'WorktreeRemove',
|
|
19
33
|
];
|
|
20
34
|
/** Parse a JSON file's raw text; a null raw means "file absent" (fine). */
|
|
21
35
|
export function lintJson(file, raw) {
|
|
@@ -790,7 +790,20 @@ export function generateIntelligenceStub() {
|
|
|
790
790
|
' return { entry: e, score: matchScore(promptWords, e.words || tokenize(e.content + " " + e.summary)) };',
|
|
791
791
|
' }).filter(function(s) { return s.score > 0.05; });',
|
|
792
792
|
' scored.sort(function(a, b) { return b.score - a.score; });',
|
|
793
|
-
'
|
|
793
|
+
' // Dedupe by content signature before taking the top 5. The store can',
|
|
794
|
+
' // accumulate many copies of one memory (re-imported each session) with',
|
|
795
|
+
' // DISTINCT ids, so an id-only dedupe leaves identical memories that fill',
|
|
796
|
+
' // every slot; key on normalized content so the 5 slots hold 5 DISTINCT',
|
|
797
|
+
' // memories (parity with the full helper deduplicateByContent).',
|
|
798
|
+
' var top = [];',
|
|
799
|
+
' var seenSig = {};',
|
|
800
|
+
' for (var si = 0; si < scored.length && top.length < 5; si++) {',
|
|
801
|
+
' var sEntry = scored[si].entry;',
|
|
802
|
+
' var sig = ((sEntry.summary || "") + "\\u0001" + (sEntry.content || "")).replace(/\\s+/g, " ").trim().toLowerCase().substring(0, 200);',
|
|
803
|
+
' if (seenSig[sig]) continue;',
|
|
804
|
+
' seenSig[sig] = true;',
|
|
805
|
+
' top.push(scored[si]);',
|
|
806
|
+
' }',
|
|
794
807
|
' if (!top.length) return null;',
|
|
795
808
|
' var prevMatched = sessionGet("lastMatchedPatterns");',
|
|
796
809
|
' var matchedIds = top.map(function(s) { return s.entry.id; });',
|
|
@@ -800,11 +813,28 @@ export function generateIntelligenceStub() {
|
|
|
800
813
|
' for (var i = 0; i < matchedIds.length; i++) newSet[matchedIds[i]] = true;',
|
|
801
814
|
' }',
|
|
802
815
|
' var lines2 = ["[INTELLIGENCE] Relevant patterns for this task:"];',
|
|
816
|
+
' // Inject the matched memory CONTENT (budgeted), not just an 80-char',
|
|
817
|
+
' // label, so stored memories are usable mid-session without a separate',
|
|
818
|
+
' // lookup (#43 Phase 1). Total budget ~800 tokens; lexical match reused',
|
|
819
|
+
' // so there is no per-prompt embedding cost.',
|
|
820
|
+
' var BUDGET = 3200;',
|
|
821
|
+
' var used = 0;',
|
|
803
822
|
' for (var j = 0; j < top.length; j++) {',
|
|
804
823
|
' var e = top[j];',
|
|
805
824
|
' var conf = e.entry.confidence || 0.5;',
|
|
806
|
-
' var
|
|
807
|
-
'
|
|
825
|
+
' var label = (e.entry.summary || e.entry.category || "memory").substring(0, 80);',
|
|
826
|
+
' var body = (e.entry.content || "").replace(/\\s+/g, " ").trim();',
|
|
827
|
+
' var remaining = BUDGET - used;',
|
|
828
|
+
' if (remaining <= 0) break;',
|
|
829
|
+
' var perEntry = Math.max(160, Math.floor(remaining / (top.length - j)));',
|
|
830
|
+
' var snippet = body.substring(0, perEntry);',
|
|
831
|
+
' if (snippet.length < body.length) snippet += "\\u2026";',
|
|
832
|
+
' used += label.length + snippet.length;',
|
|
833
|
+
' if (snippet && snippet !== label) {',
|
|
834
|
+
' lines2.push(" * (" + conf.toFixed(2) + ") " + label + " \\u2014 " + snippet);',
|
|
835
|
+
' } else {',
|
|
836
|
+
' lines2.push(" * (" + conf.toFixed(2) + ") " + label);',
|
|
837
|
+
' }',
|
|
808
838
|
' }',
|
|
809
839
|
' return lines2.join("\\n");',
|
|
810
840
|
' },',
|
|
@@ -47,7 +47,7 @@ export const RULES = [
|
|
|
47
47
|
* anchors it, so `keyboard=`/`monkey_val=` don't match; the entropy + length
|
|
48
48
|
* gates keep low-entropy values (paths, short flags) from being flagged.
|
|
49
49
|
*/
|
|
50
|
-
const ASSIGNMENT_RE = /(?:pass(?:word|wd)?|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|auth[_-]?token|client[_-]?secret|credential|creds|key)["']?\s*[:=]\s*["']?([^\s"'`,;]{8,})/gi;
|
|
50
|
+
const ASSIGNMENT_RE = /(?:pass(?:word|phrase|wd)?|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|auth[_-]?token|client[_-]?secret|credential|creds|key)["']?\s*[:=]\s*["']?([^\s"'`,;]{8,})/gi;
|
|
51
51
|
/** Shannon entropy in bits/char. Pure; used by the assignment fallback. */
|
|
52
52
|
export function shannonEntropy(s) {
|
|
53
53
|
if (!s)
|
|
@@ -139,8 +139,11 @@ export function buildSpdx(components, meta) {
|
|
|
139
139
|
documentNamespace: `https://swarmdo/spdx/${safe(meta.name)}-${meta.version}`,
|
|
140
140
|
packages: [
|
|
141
141
|
{ SPDXID: 'SPDXRef-Package-root', name: meta.name, versionInfo: meta.version, downloadLocation: 'NOASSERTION' },
|
|
142
|
-
...components.map((c) => ({
|
|
143
|
-
|
|
142
|
+
...components.map((c, i) => ({
|
|
143
|
+
// Index prefix makes the ID self-delimiting (#11): name and version can
|
|
144
|
+
// both contain `-` after sanitizing, so `${name}-${version}` alone can
|
|
145
|
+
// collide across distinct (name, version) pairs.
|
|
146
|
+
SPDXID: `SPDXRef-Package-${i}-${safe(c.name)}-${safe(c.version)}`,
|
|
144
147
|
name: c.name,
|
|
145
148
|
versionInfo: c.version,
|
|
146
149
|
downloadLocation: 'NOASSERTION',
|
|
@@ -35,14 +35,24 @@ export const MODEL_PRICES = {
|
|
|
35
35
|
// Mid tier
|
|
36
36
|
'anthropic/claude-haiku-4.5': { in: 1.00, out: 5.00 },
|
|
37
37
|
'openai/gpt-4.1': { in: 2.00, out: 8.00 },
|
|
38
|
-
// Strong tier
|
|
38
|
+
// Strong tier — current-gen Claude (first-party list prices; OpenRouter
|
|
39
|
+
// mirrors them for anthropic/* slugs). Opus dropped to $5/$25 at 4.5 and
|
|
40
|
+
// held there through 4.8 — do NOT price these at the legacy $15/$75.
|
|
41
|
+
'anthropic/claude-sonnet-5': { in: 3.00, out: 15.00 },
|
|
39
42
|
'anthropic/claude-sonnet-4-6': { in: 3.00, out: 15.00 },
|
|
43
|
+
'anthropic/claude-opus-4-8': { in: 5.00, out: 25.00 },
|
|
44
|
+
'anthropic/claude-opus-4-5': { in: 5.00, out: 25.00 },
|
|
45
|
+
'anthropic/claude-fable-5': { in: 10.00, out: 50.00 },
|
|
46
|
+
// Legacy Opus 4.0 kept — its $15/$75 is correct for THAT model, still a
|
|
47
|
+
// valid slug; the current opus tier is priced by claude-opus-4-8 above.
|
|
40
48
|
'anthropic/claude-opus-4': { in: 15.00, out: 75.00 },
|
|
41
49
|
// Tier-label fallbacks (when the trajectory only carries a coarse tier
|
|
42
50
|
// and not a concrete modelId — happens before iter 13 wiring landed).
|
|
51
|
+
// "opus tier" today resolves to Opus 4.8-class ($5/$25); pricing it at the
|
|
52
|
+
// retired $15/$75 made the cost-optimal router avoid opus 3x too eagerly.
|
|
43
53
|
haiku: { in: 1.00, out: 5.00 },
|
|
44
54
|
sonnet: { in: 3.00, out: 15.00 },
|
|
45
|
-
opus: { in:
|
|
55
|
+
opus: { in: 5.00, out: 25.00 },
|
|
46
56
|
inherit: { in: 3.00, out: 15.00 },
|
|
47
57
|
};
|
|
48
58
|
/**
|
|
@@ -11,14 +11,23 @@
|
|
|
11
11
|
* No XML dependency — well-formed JUnit escapes <>& in attribute values, so a
|
|
12
12
|
* focused tokenizer over <testcase>/<failure> is safe.
|
|
13
13
|
*/
|
|
14
|
-
/**
|
|
14
|
+
/** A numeric character reference → its char, or the original ref if the code
|
|
15
|
+
* point is out of the valid Unicode range (so a malformed entity never throws
|
|
16
|
+
* and leaks literally instead of aborting the parse). Pure. */
|
|
17
|
+
function fromCodePointSafe(n, original) {
|
|
18
|
+
return Number.isInteger(n) && n >= 0 && n <= 0x10ffff ? String.fromCodePoint(n) : original;
|
|
19
|
+
}
|
|
20
|
+
/** Decode the five predefined XML entities plus decimal AND hex numeric
|
|
21
|
+
* character references (`A`, ``). XML 1.0 allows both numeric forms;
|
|
22
|
+
* JUnit stack traces with ANSI colouring escape control chars as hex. Pure. */
|
|
15
23
|
function decodeXml(s) {
|
|
16
24
|
return s
|
|
17
25
|
.replace(/</g, '<')
|
|
18
26
|
.replace(/>/g, '>')
|
|
19
27
|
.replace(/"/g, '"')
|
|
20
28
|
.replace(/'/g, "'")
|
|
21
|
-
.replace(/&#(
|
|
29
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (m, h) => fromCodePointSafe(parseInt(h, 16), m))
|
|
30
|
+
.replace(/&#(\d+);/g, (m, d) => fromCodePointSafe(parseInt(d, 10), m))
|
|
22
31
|
.replace(/&/g, '&'); // last, so &lt; → < not <
|
|
23
32
|
}
|
|
24
33
|
/**
|
|
@@ -45,6 +45,11 @@ export interface RenderOptions {
|
|
|
45
45
|
export declare function cleanUserText(s: string): string;
|
|
46
46
|
/** Flatten tool_result content (string | array of {text}) to text. */
|
|
47
47
|
export declare function contentToText(content: unknown): string;
|
|
48
|
+
/** Count the conversational turns in a rendered body by matching only the
|
|
49
|
+
* exact role-heading lines. Anchored to end-of-line so `### Foo` markdown
|
|
50
|
+
* headings written *inside* a user/assistant message don't inflate the count.
|
|
51
|
+
* Pure. */
|
|
52
|
+
export declare function countRenderedTurns(markdown: string): number;
|
|
48
53
|
/** Render an ordered list of parsed transcript lines into Markdown. */
|
|
49
54
|
export declare function renderTranscriptMarkdown(lines: RawTranscriptLine[], opts?: RenderOptions): string;
|
|
50
55
|
export interface SessionSummary {
|
|
@@ -92,6 +92,13 @@ function renderLine(role, content, opts) {
|
|
|
92
92
|
sections.push(results.join('\n'));
|
|
93
93
|
return sections.join('\n\n');
|
|
94
94
|
}
|
|
95
|
+
/** Count the conversational turns in a rendered body by matching only the
|
|
96
|
+
* exact role-heading lines. Anchored to end-of-line so `### Foo` markdown
|
|
97
|
+
* headings written *inside* a user/assistant message don't inflate the count.
|
|
98
|
+
* Pure. */
|
|
99
|
+
export function countRenderedTurns(markdown) {
|
|
100
|
+
return (markdown.match(/^### (?:🤖 Assistant|👤 User)$/gm) || []).length;
|
|
101
|
+
}
|
|
95
102
|
/** Render an ordered list of parsed transcript lines into Markdown. */
|
|
96
103
|
export function renderTranscriptMarkdown(lines, opts = {}) {
|
|
97
104
|
const resolved = {
|
|
@@ -202,7 +209,7 @@ export function exportSession(idOrLatest, opts = {}) {
|
|
|
202
209
|
const sessionId = sessionIdFromFile(file);
|
|
203
210
|
const project = path.basename(path.dirname(file));
|
|
204
211
|
const body = renderTranscriptMarkdown(lines, opts);
|
|
205
|
-
const turns = (body
|
|
212
|
+
const turns = countRenderedTurns(body);
|
|
206
213
|
const header = `# Claude Code session \`${sessionId}\`\n\n- Project: \`${project}\`\n- Turns: ${turns}\n\n---\n\n`;
|
|
207
214
|
return { markdown: header + body, sessionId, project, turns, file };
|
|
208
215
|
}
|
|
@@ -29,8 +29,12 @@ export interface TranscriptModelPrice {
|
|
|
29
29
|
* 'claude-sonnet-4-6@20260115' → 'claude-sonnet-4-6' (Vertex)
|
|
30
30
|
*/
|
|
31
31
|
export declare function normalizeTranscriptModelId(raw: string): string;
|
|
32
|
-
/**
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Longest-prefix price lookup; undefined = unpriced (report it, don't guess).
|
|
34
|
+
* Pass the transcript entry's timestamp as `atMs` to get date-ranged promo
|
|
35
|
+
* rates (e.g. Sonnet 5 intro pricing); omitted → sticker price.
|
|
36
|
+
*/
|
|
37
|
+
export declare function resolveTranscriptPrice(rawModelId: string, atMs?: number): TranscriptModelPrice | undefined;
|
|
34
38
|
/** Token counts for one billed API response. */
|
|
35
39
|
export interface TokenBundle {
|
|
36
40
|
inputTokens: number;
|