agent-orchestrator-kit 0.8.0 → 0.9.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/CHANGELOG.md +12 -0
- package/README.md +17 -7
- package/bin/agent-orchestrator.js +189 -51
- package/bin/amp-usage.js +63 -0
- package/bin/cursor-cost-estimate.js +50 -0
- package/bin/metrics-time.js +99 -0
- package/bin/spend-collect.js +125 -12
- package/package.json +1 -1
- package/templates/.agents/commands/opsx-archive.md +1 -1
- package/templates/.agents/subagents/spec-archiver.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.9.0] - 2026-08-31
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **UTC timestamps in `metrics.json`** — `createdAt` / `updatedAt` / `archivedAt` / session times / source `at` are stored as ISO-8601 UTC (`2026-08-31T07:08:17.563Z`). Broken Amp stamps like `2026-08-31T07:08:17.563464.000Z` are parsed and normalized to UTC. Human `metrics` output still prints Kyiv wall time (`31.08.2026 10:08:17 (Київ +03:00)`).
|
|
11
|
+
- **Amp `threads usage`** — when the locked client is Amp, persist also runs `amp threads usage <id> --details` (fail-open) and writes the billed `costUsd`, per-model costs, and `agentMode` (`low` / `medium` / `high` / `ultra`) from `amp threads export`. Mode is never stored as `session.model`. Placeholder `amp-default` yields to the real LLM id from sources.
|
|
12
|
+
- **Cursor API-equivalent estimate** — hook tokens for `cursor-grok-4.6` / `4.5` (including `-fast`, 200k long-context cliff, cache reads when present) fill `costUsdEstimated` with `costSource: "api-estimate"`. This is **not** the Cursor invoice and is never mixed into billed `costUsd`.
|
|
13
|
+
- **Archive auto-collect** — `archive <name>` detects the current client (`resolveRestoreClient`) and records Archiver spend from that adapter (Cursor hook / Amp export+usage / Claude JSONL) in the window after the last session. Leftover apply `## Metrics` numbers are ignored when they match the previous session. `--collect` still runs all three adapters.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- `metrics` / archive summary cost line shows `$1.30 billed + ~$8.98 est.` when both exist.
|
|
17
|
+
|
|
7
18
|
## [0.8.0] - 2026-08-31
|
|
8
19
|
|
|
9
20
|
### Added
|
|
@@ -241,6 +252,7 @@ All notable changes to this project will be documented in this file.
|
|
|
241
252
|
### Added
|
|
242
253
|
- Initial release: 5-role orchestration pipeline, `/opsx:*` commands, IDE sync
|
|
243
254
|
|
|
255
|
+
[0.9.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.8.0...v0.9.0
|
|
244
256
|
[0.8.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.7.0...v0.8.0
|
|
245
257
|
[0.7.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.6.0...v0.7.0
|
|
246
258
|
[0.6.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.5.0...v0.6.0
|
package/README.md
CHANGED
|
@@ -74,7 +74,7 @@ npx agent-orchestrator-kit@latest init --profile generic --ci gitlab --spec-veri
|
|
|
74
74
|
|
|
75
75
|
See [Installation](#installation) for profile/CI options.
|
|
76
76
|
|
|
77
|
-
**🔄 Already have the kit installed? Upgrade to latest (
|
|
77
|
+
**🔄 Already have the kit installed? Upgrade to latest (UTC timestamps + Amp stamp parse, Amp billed `$`, Cursor API estimate, archive auto-collect in v0.9.0; locked Amp/Cursor client in v0.8.0; `## Metrics` self-report + opt-in `--collect` in v0.7.0 — **BREAKING:** `--no-collect` is gone; change metrics in v0.5.0+, factory phases 1–3 in v0.4.0+, lean pipeline / archive CLI in v0.3.0+, handoff CLI in v0.1.14+, Figma PAT in v0.1.11+):**
|
|
78
78
|
|
|
79
79
|
```bash
|
|
80
80
|
npx agent-orchestrator-kit@latest update
|
|
@@ -600,7 +600,7 @@ Archive is a **deterministic CLI**, not an agent workflow:
|
|
|
600
600
|
npx agent-orchestrator-kit archive <name> [--sync | --no-sync --force] [--collect]
|
|
601
601
|
```
|
|
602
602
|
|
|
603
|
-
It checks the gates (APPROVE in `review.md` when required, all tasks `[x]`, target folder free), merges delta specs into `openspec/specs/` (`--sync`: ADDED append, MODIFIED replace, REMOVED delete), moves the change to `openspec/changes/archive/YYYY-MM-DD-<name>`, and runs `npx openspec validate --all --strict` with a full rollback on failure (main specs restored, new spec files deleted, move reverted). With delta specs present you must decide: `--sync` merges, `--no-sync --force` archives without merging, and no flag refuses with exit 1. It finishes by writing the final `handoff.md` (`next_command: none`), updating memory, and printing the same human metrics summary as `metrics <name>`.
|
|
603
|
+
It checks the gates (APPROVE in `review.md` when required, all tasks `[x]`, target folder free), merges delta specs into `openspec/specs/` (`--sync`: ADDED append, MODIFIED replace, REMOVED delete), moves the change to `openspec/changes/archive/YYYY-MM-DD-<name>`, and runs `npx openspec validate --all --strict` with a full rollback on failure (main specs restored, new spec files deleted, move reverted). With delta specs present you must decide: `--sync` merges, `--no-sync --force` archives without merging, and no flag refuses with exit 1. It finishes by writing the final `handoff.md` (`next_command: none`), updating memory, appending an Archiver session, and printing the same human metrics summary as `metrics <name>`. Archive collects the locked client (Cursor hook / Amp threads / Claude JSONL) in the window after the last session — the same flow as persist. `--collect` still runs every adapter. Unique `## Metrics` in the change `handoff.md` still counts as Archiver self-report; leftover apply numbers that match the previous session are ignored. The `/opsx:archive` command is a thin wrapper that calls this CLI; the `spec-archiver` subagent remains only as a fallback when the CLI is unavailable.
|
|
604
604
|
|
|
605
605
|
## Configuration
|
|
606
606
|
|
|
@@ -804,11 +804,11 @@ Every change accumulates git-tracked `openspec/changes/<name>/metrics.json` —
|
|
|
804
804
|
- **`## Metrics` self-report** — Session Exit fills `handoff.md` with `platform`, `model`, `input_tokens`, `output_tokens`, `cost_usd`, `amp_credits`, `spend_source` (`unknown` when missing). Persist reads that section; `metrics.json` is the source of truth for what landed. CLI flags do not rewrite the section.
|
|
805
805
|
- **`session.model`** — `--model` → `## Metrics: model` → `AOK_MODEL` → collected sources (only with `--collect`) → `null` (stderr warning). Never a Closed role.
|
|
806
806
|
- **Session start** — `handoff --restore` writes a `pending` marker (`startedAt`, expected role).
|
|
807
|
-
- **Session end** — `handoff <name>` closes the pending session: duration, closed role, mapped phase (`explore` / `design` / `spec` / `review` / `apply` / `archive`), runtime (local/cloud), tasks snapshot (`n/m`), and spend from flags → self-report →
|
|
808
|
-
- **Archive** — successful `archive <name>` always creates or finalizes `metrics.json`, sets `archivedAt`, appends an Archiver session
|
|
807
|
+
- **Session end** — `handoff <name>` closes the pending session: duration, closed role, mapped phase (`explore` / `design` / `spec` / `review` / `apply` / `archive`), runtime (local/cloud), tasks snapshot (`n/m`), and spend from flags → self-report → adapters. `--input-tokens` / `--output-tokens` / `--total-tokens` / `--cost-usd` override session totals only and do not wipe `spendByPlatform` / `spendByModel`. Amp billed USD comes from `amp threads usage --details` (fail-open), not from converting credits. Cursor tokens get a labeled `costUsdEstimated` (xAI API rates for grok-4.6/4.5, including `-fast` and the 200k cliff) — that estimate is **not** the Cursor invoice and is never added into billed `costUsd`. Timestamps are ISO-8601 UTC (`…Z`). No restore marker? Pass `--started-at <iso>` or the duration stays honestly `null`.
|
|
808
|
+
- **Archive** — successful `archive <name>` always creates or finalizes `metrics.json`, sets `archivedAt`, appends an Archiver session, collects the locked client (Cursor hook / Amp export+usage / Claude JSONL) in `[last session.endedAt, now]`, and prints the same human summary as `metrics <name>`. `--collect` still runs all three adapters. Leftover apply `## Metrics` that repeats the previous session is ignored so those tokens are not counted twice.
|
|
809
809
|
- **Platform** — `--platform` → `## Metrics: platform` → `AOK_PLATFORM` → **pending client from `--restore`** → host env (Amp / Cursor / Claude Code) → collected sources (`cursor|claude|amp` only). Invalid `--platform` fails before persist/move.
|
|
810
|
-
- **Locked client** — `--restore` records `pending.platform` and Amp `pending.threadId` before phase work. Persist follows that client’s flow even if persist runs in another shell (no `AMP_*` / `CURSOR_*`). Amp: `amp threads export` (`AOK_AMP_BIN`)
|
|
811
|
-
- **Cursor spend hook (optional)** — Cursor never writes token usage to disk, so the kit can install `scripts/cursor-spend-hook.cjs` plus `.cursor/hooks.json` entries (`stop` / `subagentStop` / `afterAgentResponse`) in `init` / `update` / `sync` / `mcp-setup`: the hook appends each turn's tokens to gitignored `.agents/spend/cursor-usage.jsonl`. Persist auto-reads that file when the locked client is Cursor. Persist and restore do not self-heal the hook. `sessionEnd` still runs `scripts/cursor-spend-collect.cjs`. Restart Cursor once after the first install. `status` shows a `Spend capture` section. Claude JSONL
|
|
810
|
+
- **Locked client** — `--restore` records `pending.platform` and Amp `pending.threadId` before phase work. Persist follows that client’s flow even if persist runs in another shell (no `AMP_*` / `CURSOR_*`). Amp: `amp threads export` plus `amp threads usage --details` (`AOK_AMP_BIN`) and local `threads/*.json`. Export supplies `model` / tokens / `agentMode`; usage supplies billed `$`. If Amp runs tools over a pipe (`/dev/null`), thread id comes from `amp threads list`, not stale `session.json` `lastThreadId`. Cursor: spend hook file. Claude: `~/.claude/projects`. `--collect` still runs all three adapters.
|
|
811
|
+
- **Cursor spend hook (optional)** — Cursor never writes token usage to disk, so the kit can install `scripts/cursor-spend-hook.cjs` plus `.cursor/hooks.json` entries (`stop` / `subagentStop` / `afterAgentResponse`) in `init` / `update` / `sync` / `mcp-setup`: the hook appends each turn's tokens to gitignored `.agents/spend/cursor-usage.jsonl`. Persist auto-reads that file when the locked client is Cursor. Persist and restore do not self-heal the hook. `sessionEnd` still runs `scripts/cursor-spend-collect.cjs`. Restart Cursor once after the first install. `status` shows a `Spend capture` section. Claude JSONL remains a fallback. Amp web/CLI spend is taken from `amp threads export` (tokens, model, `agentMode`) and `amp threads usage` (billed USD).
|
|
812
812
|
|
|
813
813
|
Aggregates are recomputed on every write: per-phase totals (`durationMs`, tokens, `costUsd`, `sessions`, `roles`, `models`) plus overall `totals` (`sessions`, `cloudSessions`, `durationMs` = sum of session work time, `leadTimeMs` = wall clock from first session start to last session end), `spend` (USD only), and separate **by platform** / **by model** tables. Numbers are null-honest: a metric nobody reported stays `null`, never a fake `0`. No single total that adds Amp credits to USD.
|
|
814
814
|
|
|
@@ -833,7 +833,7 @@ npx agent-orchestrator-kit metrics add-thing --json # raw metrics.json (wor
|
|
|
833
833
|
npx agent-orchestrator-kit archive add-thing --sync # finalize + the same tables as metrics
|
|
834
834
|
```
|
|
835
835
|
|
|
836
|
-
Recording is on by default and never a persist/archive/`gate-check` gate; opt out per persist with `--no-metrics`.
|
|
836
|
+
Recording is on by default and never a persist/archive/`gate-check` gate; opt out per persist with `--no-metrics`. Persist and archive collect the locked client without `--collect`; `--collect` runs every adapter. Flags (`--model`, `--platform`, `--input-tokens`, …) override session totals in `metrics.json` and do not rewrite the `## Metrics` section.
|
|
837
837
|
|
|
838
838
|
### Skill inventory
|
|
839
839
|
|
|
@@ -1003,6 +1003,16 @@ Phase bounds and non-goals: [`openspec/specs/agentic-factory-roadmap/spec.md`](o
|
|
|
1003
1003
|
|
|
1004
1004
|
## Changelog
|
|
1005
1005
|
|
|
1006
|
+
### 0.9.0
|
|
1007
|
+
- **UTC timestamps** in `metrics.json` (`…Z`; broken Amp stamps parsed and normalized)
|
|
1008
|
+
- Amp `threads usage --details` billed `$` + `agentMode` (never stored as `session.model`)
|
|
1009
|
+
- Cursor `costUsdEstimated` from xAI API rates for grok-4.6/4.5 — labeled, not mixed into billed `costUsd`
|
|
1010
|
+
- **Archive auto-collect** — Archiver session picks up the locked client after the last session; leftover apply `## Metrics` is not double-counted
|
|
1011
|
+
|
|
1012
|
+
### 0.8.0
|
|
1013
|
+
- Locked session client on `--restore`; persist collects only that client without `--collect`
|
|
1014
|
+
- Amp CLI `threads export` + `threads list` when Amp runs over a pipe
|
|
1015
|
+
|
|
1006
1016
|
### 0.7.0
|
|
1007
1017
|
- **`## Metrics` self-report** — persist reads the session section in `handoff.md`; `metrics.json` is the source of truth; CLI flags do not rewrite the section
|
|
1008
1018
|
- **BREAKING** — `--no-collect` removed; `handoff` and `archive` collect local adapters only with opt-in `--collect`
|
|
@@ -7,6 +7,14 @@ import { fileURLToPath } from 'url';
|
|
|
7
7
|
import { execSync } from 'child_process';
|
|
8
8
|
import { collectSpend } from './spend-collect.js';
|
|
9
9
|
import { resolveRestoreClient, ampThreadIdFromEnv } from './session-client.js';
|
|
10
|
+
import {
|
|
11
|
+
earlierTimestamp,
|
|
12
|
+
formatKyivDisplay,
|
|
13
|
+
isoOrNull,
|
|
14
|
+
laterTimestamp,
|
|
15
|
+
nowUtcIso,
|
|
16
|
+
parseFlexibleIso,
|
|
17
|
+
} from './metrics-time.js';
|
|
10
18
|
|
|
11
19
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
20
|
const KIT_ROOT = join(__dirname, '..');
|
|
@@ -1123,12 +1131,22 @@ function firstNonNull(...values) {
|
|
|
1123
1131
|
return null;
|
|
1124
1132
|
}
|
|
1125
1133
|
|
|
1134
|
+
function isPlaceholderModel(value) {
|
|
1135
|
+
const normalized = String(value || '').trim().toLowerCase();
|
|
1136
|
+
return !normalized || ['unknown', 'none', 'n/a', '-', '—', 'null', 'amp-default'].includes(normalized);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1126
1139
|
function resolveModel(opts, env, reported) {
|
|
1127
|
-
const
|
|
1140
|
+
const pick = (value) => {
|
|
1141
|
+
const text = value == null ? '' : String(value).trim();
|
|
1142
|
+
if (!text || isPlaceholderModel(text)) return '';
|
|
1143
|
+
return text;
|
|
1144
|
+
};
|
|
1145
|
+
const flag = pick(opts && opts.model);
|
|
1128
1146
|
if (flag) return flag;
|
|
1129
|
-
const fromReport = reported && reported.model
|
|
1147
|
+
const fromReport = pick(reported && reported.model);
|
|
1130
1148
|
if (fromReport) return fromReport;
|
|
1131
|
-
const fromEnv = env && env.AOK_MODEL
|
|
1149
|
+
const fromEnv = pick(env && env.AOK_MODEL);
|
|
1132
1150
|
if (fromEnv) return fromEnv;
|
|
1133
1151
|
return null;
|
|
1134
1152
|
}
|
|
@@ -1594,14 +1612,14 @@ function readHandoffFields(projectDir, changeName) {
|
|
|
1594
1612
|
}
|
|
1595
1613
|
|
|
1596
1614
|
const METRICS_VERSION = 1;
|
|
1597
|
-
const METRICS_SPEND_KEYS = ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'];
|
|
1615
|
+
const METRICS_SPEND_KEYS = ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated'];
|
|
1598
1616
|
|
|
1599
1617
|
function metricsFilePath(projectDir, changeName) {
|
|
1600
1618
|
return join(projectDir, 'openspec', 'changes', changeName, 'metrics.json');
|
|
1601
1619
|
}
|
|
1602
1620
|
|
|
1603
1621
|
function emptySpendTotals() {
|
|
1604
|
-
return { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null };
|
|
1622
|
+
return { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null, costUsdEstimated: null };
|
|
1605
1623
|
}
|
|
1606
1624
|
|
|
1607
1625
|
function emptyPlatformSpend(source = 'none') {
|
|
@@ -1611,6 +1629,7 @@ function emptyPlatformSpend(source = 'none') {
|
|
|
1611
1629
|
totalTokens: null,
|
|
1612
1630
|
costUsd: null,
|
|
1613
1631
|
ampCredits: null,
|
|
1632
|
+
costUsdEstimated: null,
|
|
1614
1633
|
source,
|
|
1615
1634
|
};
|
|
1616
1635
|
}
|
|
@@ -1678,7 +1697,9 @@ function loadMetricsFile(filePath, changeName, nowIso) {
|
|
|
1678
1697
|
|
|
1679
1698
|
function saveMetricsFile(filePath, metrics) {
|
|
1680
1699
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
1681
|
-
|
|
1700
|
+
const out = { ...metrics };
|
|
1701
|
+
delete out.timezone;
|
|
1702
|
+
writeFileSync(filePath, `${JSON.stringify(out, null, 2)}\n`);
|
|
1682
1703
|
}
|
|
1683
1704
|
|
|
1684
1705
|
function numOrNull(value) {
|
|
@@ -1703,12 +1724,6 @@ function phaseForRole(role) {
|
|
|
1703
1724
|
return 'other';
|
|
1704
1725
|
}
|
|
1705
1726
|
|
|
1706
|
-
function isoOrNull(value) {
|
|
1707
|
-
if (!value) return null;
|
|
1708
|
-
const ms = Date.parse(String(value));
|
|
1709
|
-
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
|
1710
|
-
}
|
|
1711
|
-
|
|
1712
1727
|
function sessionFieldOrSources(session, key) {
|
|
1713
1728
|
if (session[key] != null && session[key] !== '') return numOrNull(session[key]);
|
|
1714
1729
|
let sum = null;
|
|
@@ -1721,7 +1736,7 @@ function sessionFieldOrSources(session, key) {
|
|
|
1721
1736
|
function lastSessionEndedAt(metrics) {
|
|
1722
1737
|
let last = null;
|
|
1723
1738
|
for (const session of metrics.sessions || []) {
|
|
1724
|
-
if (session.endedAt
|
|
1739
|
+
if (session.endedAt) last = last == null ? session.endedAt : laterTimestamp(last, session.endedAt);
|
|
1725
1740
|
}
|
|
1726
1741
|
return last;
|
|
1727
1742
|
}
|
|
@@ -1740,6 +1755,33 @@ function existingSourceIdSet(metrics) {
|
|
|
1740
1755
|
return ids;
|
|
1741
1756
|
}
|
|
1742
1757
|
|
|
1758
|
+
function lastNonArchiverSession(sessions) {
|
|
1759
|
+
const list = Array.isArray(sessions) ? sessions : [];
|
|
1760
|
+
for (let i = list.length - 1; i >= 0; i -= 1) {
|
|
1761
|
+
if (list[i] && list[i].role !== 'Archiver') return list[i];
|
|
1762
|
+
}
|
|
1763
|
+
return null;
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
function dropStaleArchiveSelfReport(reported, sessions) {
|
|
1767
|
+
const base = reported && typeof reported === 'object' ? { ...reported } : emptyMetricsFields();
|
|
1768
|
+
const last = lastNonArchiverSession(sessions);
|
|
1769
|
+
if (!last) return base;
|
|
1770
|
+
const sameInput = numOrNull(base.inputTokens) != null && numOrNull(base.inputTokens) === numOrNull(last.inputTokens);
|
|
1771
|
+
const sameOutput = numOrNull(base.outputTokens) === numOrNull(last.outputTokens);
|
|
1772
|
+
if (sameInput && sameOutput) {
|
|
1773
|
+
base.inputTokens = null;
|
|
1774
|
+
base.outputTokens = null;
|
|
1775
|
+
base.totalTokens = null;
|
|
1776
|
+
base.costUsd = null;
|
|
1777
|
+
base.ampCredits = null;
|
|
1778
|
+
if (!base.spendSource || base.spendSource === 'self-report') base.spendSource = null;
|
|
1779
|
+
}
|
|
1780
|
+
if (base.model && last.model && base.model === last.model) base.model = null;
|
|
1781
|
+
if (base.platform && last.platform && base.platform === last.platform) base.platform = null;
|
|
1782
|
+
return base;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1743
1785
|
function uniqueSourceModels(sources) {
|
|
1744
1786
|
const seen = [];
|
|
1745
1787
|
for (const src of sources || []) {
|
|
@@ -1798,11 +1840,35 @@ function sourceAmpCredits(sources) {
|
|
|
1798
1840
|
return sum;
|
|
1799
1841
|
}
|
|
1800
1842
|
|
|
1801
|
-
function
|
|
1843
|
+
function sourceEstimatedUsd(sources) {
|
|
1844
|
+
let sum = null;
|
|
1845
|
+
for (const src of sources || []) {
|
|
1846
|
+
sum = addNullable(sum, numOrNull(src.costUsdEstimated));
|
|
1847
|
+
}
|
|
1848
|
+
return sum;
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
function ampThreadTotals(threads) {
|
|
1852
|
+
const list = Array.isArray(threads) ? threads : [];
|
|
1853
|
+
let costUsd = null;
|
|
1854
|
+
let agentMode = null;
|
|
1855
|
+
const models = [];
|
|
1856
|
+
for (const thread of list) {
|
|
1857
|
+
costUsd = addNullable(costUsd, numOrNull(thread && thread.costUsd));
|
|
1858
|
+
if (!agentMode && thread && thread.agentMode) agentMode = String(thread.agentMode);
|
|
1859
|
+
for (const row of (thread && thread.models) || []) {
|
|
1860
|
+
if (row && row.model && !models.includes(row.model)) models.push(row.model);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
return { costUsd, agentMode, models };
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
function resolveSessionSpend(opts, reported, sources, extra = {}) {
|
|
1802
1867
|
const flags = opts || {};
|
|
1803
1868
|
const self = reported || emptyMetricsFields();
|
|
1804
1869
|
const fromSources = sessionTotalsFromSources(sources || []);
|
|
1805
1870
|
const sourceCredits = sourceAmpCredits(sources || []);
|
|
1871
|
+
const fromAmp = ampThreadTotals(extra.ampThreads);
|
|
1806
1872
|
const flagInput = numOrNull(flags.inputTokens);
|
|
1807
1873
|
const flagOutput = numOrNull(flags.outputTokens);
|
|
1808
1874
|
const flagTotal = numOrNull(flags.totalTokens);
|
|
@@ -1814,12 +1880,14 @@ function resolveSessionSpend(opts, reported, sources) {
|
|
|
1814
1880
|
if (totalTokens == null && (inputTokens != null || outputTokens != null)) {
|
|
1815
1881
|
totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
|
|
1816
1882
|
}
|
|
1817
|
-
const costUsd = firstNonNull(flagCost, self.costUsd, fromSources.costUsd);
|
|
1883
|
+
const costUsd = firstNonNull(flagCost, self.costUsd, fromAmp.costUsd, fromSources.costUsd);
|
|
1818
1884
|
const ampCredits = firstNonNull(flagCredits, self.ampCredits, sourceCredits);
|
|
1885
|
+
const costUsdEstimated = sourceEstimatedUsd(sources || []);
|
|
1819
1886
|
let spendSource = 'unreported';
|
|
1820
1887
|
if (self.spendSource) spendSource = String(self.spendSource);
|
|
1821
1888
|
else if (hasSpendOverride(flags)) spendSource = 'flag';
|
|
1822
1889
|
else if (reportedHasSpendNumbers(self)) spendSource = 'self-report';
|
|
1890
|
+
else if (fromAmp.costUsd != null) spendSource = 'amp-usage';
|
|
1823
1891
|
else if (
|
|
1824
1892
|
fromSources.inputTokens != null
|
|
1825
1893
|
|| fromSources.outputTokens != null
|
|
@@ -1829,7 +1897,7 @@ function resolveSessionSpend(opts, reported, sources) {
|
|
|
1829
1897
|
) {
|
|
1830
1898
|
spendSource = 'adapter';
|
|
1831
1899
|
}
|
|
1832
|
-
return { inputTokens, outputTokens, totalTokens, costUsd, ampCredits, spendSource };
|
|
1900
|
+
return { inputTokens, outputTokens, totalTokens, costUsd, ampCredits, costUsdEstimated, spendSource, agentMode: fromAmp.agentMode };
|
|
1833
1901
|
}
|
|
1834
1902
|
|
|
1835
1903
|
function sessionTotalsFromFlags(opts) {
|
|
@@ -1874,25 +1942,44 @@ function runCollectSpend(metrics, windowStart, windowEnd, extra = {}) {
|
|
|
1874
1942
|
ampThreadId: extra.ampThreadId,
|
|
1875
1943
|
ampCli: extra.ampCli === true,
|
|
1876
1944
|
exportAmpThread: extra.exportAmpThread,
|
|
1945
|
+
usageAmpThread: extra.usageAmpThread,
|
|
1877
1946
|
});
|
|
1878
1947
|
} catch {
|
|
1879
1948
|
return { sources: [], byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
|
|
1880
1949
|
}
|
|
1881
1950
|
}
|
|
1882
1951
|
|
|
1883
|
-
function applyCollectedSessionFields(session, sources, resolvedModel, opts, reported) {
|
|
1952
|
+
function applyCollectedSessionFields(session, sources, resolvedModel, opts, reported, extra = {}) {
|
|
1884
1953
|
session.sources = sources || [];
|
|
1885
1954
|
const uniqueModels = uniqueSourceModels(session.sources);
|
|
1886
|
-
|
|
1955
|
+
const fromAmp = ampThreadTotals(extra.ampThreads);
|
|
1956
|
+
for (const model of fromAmp.models) {
|
|
1957
|
+
if (model && !uniqueModels.includes(model)) uniqueModels.push(model);
|
|
1958
|
+
}
|
|
1959
|
+
if (!session.model || isPlaceholderModel(session.model)) {
|
|
1960
|
+
session.model = (resolvedModel && !isPlaceholderModel(resolvedModel) ? resolvedModel : null)
|
|
1961
|
+
|| primaryModelFromSources(session.sources)
|
|
1962
|
+
|| fromAmp.models[0]
|
|
1963
|
+
|| null;
|
|
1964
|
+
}
|
|
1887
1965
|
if (!session.platform) session.platform = primaryPlatformFromSources(session.sources) || null;
|
|
1888
1966
|
if (uniqueModels.length > 1) session.models = uniqueModels;
|
|
1889
|
-
const spend = resolveSessionSpend(opts, reported, session.sources);
|
|
1967
|
+
const spend = resolveSessionSpend(opts, reported, session.sources, extra);
|
|
1890
1968
|
session.inputTokens = spend.inputTokens;
|
|
1891
1969
|
session.outputTokens = spend.outputTokens;
|
|
1892
1970
|
session.totalTokens = spend.totalTokens;
|
|
1893
1971
|
session.costUsd = spend.costUsd;
|
|
1894
1972
|
session.ampCredits = spend.ampCredits;
|
|
1973
|
+
session.costUsdEstimated = spend.costUsdEstimated;
|
|
1895
1974
|
session.spendSource = spend.spendSource;
|
|
1975
|
+
if (spend.agentMode) session.agentMode = spend.agentMode;
|
|
1976
|
+
const usageModels = [];
|
|
1977
|
+
for (const thread of extra.ampThreads || []) {
|
|
1978
|
+
for (const row of thread.models || []) {
|
|
1979
|
+
if (row && row.model) usageModels.push(row);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
if (usageModels.length) session.usageModels = usageModels;
|
|
1896
1983
|
}
|
|
1897
1984
|
|
|
1898
1985
|
function sessionTotalsLookOverridden(session) {
|
|
@@ -1913,7 +2000,7 @@ function metricsBackfillLastSession(projectDir, changeName) {
|
|
|
1913
2000
|
}
|
|
1914
2001
|
|
|
1915
2002
|
function metricsBackfillFile(filePath, changeName) {
|
|
1916
|
-
const nowIso =
|
|
2003
|
+
const nowIso = nowUtcIso();
|
|
1917
2004
|
const metrics = loadMetricsFile(filePath, changeName, nowIso);
|
|
1918
2005
|
const sessions = metrics.sessions || [];
|
|
1919
2006
|
if (!sessions.length) return { filePath, added: 0, empty: true };
|
|
@@ -1940,7 +2027,7 @@ function metricsBackfillFile(filePath, changeName) {
|
|
|
1940
2027
|
applyCollectedSessionFields(last, merged, last.model, {}, {
|
|
1941
2028
|
model: last.model,
|
|
1942
2029
|
platform: last.platform,
|
|
1943
|
-
});
|
|
2030
|
+
}, { ampThreads: collected.ampThreads });
|
|
1944
2031
|
}
|
|
1945
2032
|
metrics.updatedAt = nowIso;
|
|
1946
2033
|
recomputeMetricsAggregates(metrics);
|
|
@@ -1962,11 +2049,12 @@ function spendTuple(obj) {
|
|
|
1962
2049
|
totalTokens: numOrNull(obj && obj.totalTokens),
|
|
1963
2050
|
costUsd: numOrNull(obj && obj.costUsd),
|
|
1964
2051
|
ampCredits: numOrNull(obj && obj.ampCredits),
|
|
2052
|
+
costUsdEstimated: numOrNull(obj && obj.costUsdEstimated),
|
|
1965
2053
|
};
|
|
1966
2054
|
}
|
|
1967
2055
|
|
|
1968
2056
|
function spendTuplesMatch(a, b) {
|
|
1969
|
-
return ['inputTokens', 'outputTokens', 'totalTokens'
|
|
2057
|
+
return ['inputTokens', 'outputTokens', 'totalTokens'].every((key) => {
|
|
1970
2058
|
const left = a[key];
|
|
1971
2059
|
const right = b[key];
|
|
1972
2060
|
if (left == null && right == null) return true;
|
|
@@ -1980,6 +2068,7 @@ function addSpendNums(target, nums) {
|
|
|
1980
2068
|
target.totalTokens = addNullable(target.totalTokens, nums.totalTokens);
|
|
1981
2069
|
target.costUsd = addNullable(target.costUsd, nums.costUsd);
|
|
1982
2070
|
target.ampCredits = addNullable(target.ampCredits, nums.ampCredits);
|
|
2071
|
+
target.costUsdEstimated = addNullable(target.costUsdEstimated, nums.costUsdEstimated);
|
|
1983
2072
|
}
|
|
1984
2073
|
|
|
1985
2074
|
function recomputeSpendMaps(metrics) {
|
|
@@ -1997,6 +2086,7 @@ function recomputeSpendMaps(metrics) {
|
|
|
1997
2086
|
totalTokens: null,
|
|
1998
2087
|
costUsd: null,
|
|
1999
2088
|
ampCredits: null,
|
|
2089
|
+
costUsdEstimated: null,
|
|
2000
2090
|
};
|
|
2001
2091
|
addSpendNums(row, nums);
|
|
2002
2092
|
byModel.set(key, row);
|
|
@@ -2006,7 +2096,7 @@ function recomputeSpendMaps(metrics) {
|
|
|
2006
2096
|
const nums = spendTuple(src);
|
|
2007
2097
|
if (src.platform && byPlatform[src.platform]) {
|
|
2008
2098
|
addSpendNums(byPlatform[src.platform], nums);
|
|
2009
|
-
const label = adapterSourceName(src.platform);
|
|
2099
|
+
const label = adapterSourceName(src.platform, src.via);
|
|
2010
2100
|
if (label) byPlatform[src.platform].source = label;
|
|
2011
2101
|
}
|
|
2012
2102
|
addModelRow(src.model, src.platform, nums);
|
|
@@ -2021,6 +2111,19 @@ function recomputeSpendMaps(metrics) {
|
|
|
2021
2111
|
|
|
2022
2112
|
if (sourcesMatchSession) {
|
|
2023
2113
|
for (const src of sources) addSourceRow(src);
|
|
2114
|
+
if (session.costUsd != null && sourceTotals.costUsd == null && session.platform && byPlatform[session.platform]) {
|
|
2115
|
+
byPlatform[session.platform].costUsd = addNullable(byPlatform[session.platform].costUsd, session.costUsd);
|
|
2116
|
+
}
|
|
2117
|
+
for (const row of session.usageModels || []) {
|
|
2118
|
+
addModelRow(row.model, session.platform || 'amp', {
|
|
2119
|
+
inputTokens: null,
|
|
2120
|
+
outputTokens: null,
|
|
2121
|
+
totalTokens: null,
|
|
2122
|
+
costUsd: numOrNull(row.costUsd),
|
|
2123
|
+
ampCredits: null,
|
|
2124
|
+
costUsdEstimated: null,
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
2024
2127
|
continue;
|
|
2025
2128
|
}
|
|
2026
2129
|
|
|
@@ -2044,8 +2147,8 @@ function recomputeMetricsAggregates(metrics) {
|
|
|
2044
2147
|
totals.sessions += 1;
|
|
2045
2148
|
if (session.runtime === 'cloud') totals.cloudSessions += 1;
|
|
2046
2149
|
totals.durationMs = addNullable(totals.durationMs, numOrNull(session.durationMs));
|
|
2047
|
-
if (session.startedAt
|
|
2048
|
-
if (session.endedAt
|
|
2150
|
+
if (session.startedAt) firstStart = firstStart == null ? session.startedAt : earlierTimestamp(firstStart, session.startedAt);
|
|
2151
|
+
if (session.endedAt) lastEnd = lastEnd == null ? session.endedAt : laterTimestamp(lastEnd, session.endedAt);
|
|
2049
2152
|
const key = session.phase || 'other';
|
|
2050
2153
|
const phase = phases[key] || { sessions: 0, durationMs: null, ...emptySpendTotals(), agents: [], models: [] };
|
|
2051
2154
|
phase.sessions += 1;
|
|
@@ -2065,7 +2168,7 @@ function recomputeMetricsAggregates(metrics) {
|
|
|
2065
2168
|
phases[key] = phase;
|
|
2066
2169
|
}
|
|
2067
2170
|
if (firstStart && lastEnd) {
|
|
2068
|
-
totals.leadTimeMs = Math.max(0,
|
|
2171
|
+
totals.leadTimeMs = Math.max(0, parseFlexibleIso(lastEnd) - parseFlexibleIso(firstStart));
|
|
2069
2172
|
}
|
|
2070
2173
|
metrics.phases = phases;
|
|
2071
2174
|
metrics.totals = totals;
|
|
@@ -2075,7 +2178,7 @@ function recomputeMetricsAggregates(metrics) {
|
|
|
2075
2178
|
|
|
2076
2179
|
function metricsRecordSessionStart(projectDir, changeName, role, client = {}) {
|
|
2077
2180
|
const filePath = metricsFilePath(projectDir, changeName);
|
|
2078
|
-
const nowIso =
|
|
2181
|
+
const nowIso = nowUtcIso();
|
|
2079
2182
|
const metrics = loadMetricsFile(filePath, changeName, nowIso);
|
|
2080
2183
|
metrics.pending = {
|
|
2081
2184
|
startedAt: nowIso,
|
|
@@ -2091,10 +2194,14 @@ function metricsRecordSessionStart(projectDir, changeName, role, client = {}) {
|
|
|
2091
2194
|
|
|
2092
2195
|
function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
|
|
2093
2196
|
const filePath = metricsFilePath(projectDir, fields.changeName);
|
|
2094
|
-
const nowIso =
|
|
2197
|
+
const nowIso = nowUtcIso();
|
|
2095
2198
|
const metrics = loadMetricsFile(filePath, fields.changeName, nowIso);
|
|
2096
|
-
const startedAt = isoOrNull(opts.startedAt) || (metrics.pending && metrics.pending.startedAt) || null;
|
|
2097
|
-
const
|
|
2199
|
+
const startedAt = isoOrNull(opts.startedAt) || isoOrNull(metrics.pending && metrics.pending.startedAt) || null;
|
|
2200
|
+
const startedMs = parseFlexibleIso(startedAt);
|
|
2201
|
+
const endedMs = parseFlexibleIso(nowIso);
|
|
2202
|
+
const durationMs = Number.isFinite(startedMs) && Number.isFinite(endedMs)
|
|
2203
|
+
? Math.max(0, endedMs - startedMs)
|
|
2204
|
+
: null;
|
|
2098
2205
|
const reported = opts.reported || fields.metrics || emptyMetricsFields();
|
|
2099
2206
|
const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
|
|
2100
2207
|
const windowStart = collectWindowStart(metrics);
|
|
@@ -2128,9 +2235,10 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
|
|
|
2128
2235
|
totalTokens: null,
|
|
2129
2236
|
costUsd: null,
|
|
2130
2237
|
ampCredits: null,
|
|
2238
|
+
costUsdEstimated: null,
|
|
2131
2239
|
spendSource: 'unreported',
|
|
2132
2240
|
};
|
|
2133
|
-
applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported);
|
|
2241
|
+
applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported, collected);
|
|
2134
2242
|
metrics.sessions.push(session);
|
|
2135
2243
|
metrics.pending = null;
|
|
2136
2244
|
metrics.updatedAt = nowIso;
|
|
@@ -2146,21 +2254,22 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
|
|
|
2146
2254
|
|
|
2147
2255
|
function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
|
|
2148
2256
|
const filePath = join(targetDir, 'metrics.json');
|
|
2149
|
-
const nowIso =
|
|
2257
|
+
const nowIso = nowUtcIso();
|
|
2150
2258
|
const metrics = loadMetricsFile(filePath, changeName, nowIso);
|
|
2151
2259
|
const windowStart = lastSessionEndedAt(metrics) || metrics.createdAt;
|
|
2152
2260
|
const collectAll = opts.collect === true;
|
|
2153
2261
|
const platform = opts.platform || null;
|
|
2154
2262
|
const platforms = collectAll ? undefined : (platform ? [platform] : []);
|
|
2155
2263
|
const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
|
|
2264
|
+
const ampThreadId = opts.ampThreadId || ampThreadIdFromEnv(process.env) || null;
|
|
2156
2265
|
const collected = shouldCollect
|
|
2157
2266
|
? runCollectSpend(metrics, windowStart, nowIso, {
|
|
2158
2267
|
platforms,
|
|
2159
|
-
ampThreadId
|
|
2268
|
+
ampThreadId,
|
|
2160
2269
|
ampCli: collectAll || platform === 'amp',
|
|
2161
2270
|
})
|
|
2162
2271
|
: { sources: [] };
|
|
2163
|
-
const reported = opts.reported
|
|
2272
|
+
const reported = dropStaleArchiveSelfReport(opts.reported, metrics.sessions);
|
|
2164
2273
|
const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
|
|
2165
2274
|
const session = {
|
|
2166
2275
|
startedAt: nowIso,
|
|
@@ -2171,7 +2280,8 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
|
|
|
2171
2280
|
runtime: opts.runtime || 'local',
|
|
2172
2281
|
agentId: opts.agentId || 'none',
|
|
2173
2282
|
model: resolvedModel || null,
|
|
2174
|
-
platform
|
|
2283
|
+
platform,
|
|
2284
|
+
threadId: ampThreadId || null,
|
|
2175
2285
|
tasks: opts.tasks || null,
|
|
2176
2286
|
sources: [],
|
|
2177
2287
|
inputTokens: null,
|
|
@@ -2179,9 +2289,10 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
|
|
|
2179
2289
|
totalTokens: null,
|
|
2180
2290
|
costUsd: null,
|
|
2181
2291
|
ampCredits: null,
|
|
2292
|
+
costUsdEstimated: null,
|
|
2182
2293
|
spendSource: 'unreported',
|
|
2183
2294
|
};
|
|
2184
|
-
applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported);
|
|
2295
|
+
applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported, collected);
|
|
2185
2296
|
metrics.sessions.push(session);
|
|
2186
2297
|
metrics.archivedAt = nowIso;
|
|
2187
2298
|
metrics.pending = null;
|
|
@@ -2216,6 +2327,17 @@ function formatMetricsCost(value) {
|
|
|
2216
2327
|
return value == null ? '—' : `$${Number(value).toFixed(2)}`;
|
|
2217
2328
|
}
|
|
2218
2329
|
|
|
2330
|
+
function formatMetricsCostLine(spend) {
|
|
2331
|
+
const billed = spend && spend.costUsd;
|
|
2332
|
+
const estimated = spend && spend.costUsdEstimated;
|
|
2333
|
+
if (billed == null && estimated == null) return '—';
|
|
2334
|
+
if (billed != null && estimated != null) {
|
|
2335
|
+
return `${formatMetricsCost(billed)} billed + ~${formatMetricsCost(estimated)} est.`;
|
|
2336
|
+
}
|
|
2337
|
+
if (billed != null) return formatMetricsCost(billed);
|
|
2338
|
+
return `~${formatMetricsCost(estimated)} est.`;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2219
2341
|
function sessionSpendSourceLabel(session) {
|
|
2220
2342
|
const raw = session && session.spendSource;
|
|
2221
2343
|
if (raw == null || String(raw).trim() === '') return 'unreported';
|
|
@@ -2230,9 +2352,9 @@ function renderMetricsSummary(metrics) {
|
|
|
2230
2352
|
lines.push(`work time: ${formatMetricsDuration(metrics.totals.durationMs)}`);
|
|
2231
2353
|
lines.push(`lead time: ${formatMetricsDuration(metrics.totals.leadTimeMs)}`);
|
|
2232
2354
|
lines.push(`tokens: ${formatMetricsNumber(metrics.spend.totalTokens)} (in: ${formatMetricsNumber(metrics.spend.inputTokens)}, out: ${formatMetricsNumber(metrics.spend.outputTokens)})`);
|
|
2233
|
-
lines.push(`cost: ${
|
|
2355
|
+
lines.push(`cost: ${formatMetricsCostLine(metrics.spend)}`);
|
|
2234
2356
|
lines.push(`unreported: ${unreported}`);
|
|
2235
|
-
if (metrics.archivedAt) lines.push(`archived: ${metrics.archivedAt}`);
|
|
2357
|
+
if (metrics.archivedAt) lines.push(`archived: ${formatKyivDisplay(metrics.archivedAt)}`);
|
|
2236
2358
|
if (metrics.pending) {
|
|
2237
2359
|
const pendingClient = metrics.pending.platform
|
|
2238
2360
|
? ` ${metrics.pending.platform}${metrics.pending.threadId ? ` ${metrics.pending.threadId}` : ''}`
|
|
@@ -2252,7 +2374,7 @@ function renderMetricsSummary(metrics) {
|
|
|
2252
2374
|
String(phase.sessions).padEnd(9),
|
|
2253
2375
|
formatMetricsDuration(phase.durationMs).padEnd(9),
|
|
2254
2376
|
formatMetricsNumber(phase.totalTokens).padEnd(9),
|
|
2255
|
-
|
|
2377
|
+
formatMetricsCostLine(phase).padEnd(9),
|
|
2256
2378
|
(phase.agents.join(', ') || '—').padEnd(20),
|
|
2257
2379
|
phase.models.join(', ') || '—',
|
|
2258
2380
|
].join(' '));
|
|
@@ -2268,7 +2390,7 @@ function renderMetricsSummary(metrics) {
|
|
|
2268
2390
|
lines.push([
|
|
2269
2391
|
key.padEnd(10),
|
|
2270
2392
|
formatMetricsNumber(row.totalTokens).padEnd(9),
|
|
2271
|
-
|
|
2393
|
+
formatMetricsCostLine(row).padEnd(9),
|
|
2272
2394
|
formatMetricsNumber(row.ampCredits).padEnd(9),
|
|
2273
2395
|
row.source || 'none',
|
|
2274
2396
|
].join(' '));
|
|
@@ -2286,7 +2408,7 @@ function renderMetricsSummary(metrics) {
|
|
|
2286
2408
|
String(row.model || '—').padEnd(20),
|
|
2287
2409
|
String(row.platform || '—').padEnd(10),
|
|
2288
2410
|
formatMetricsNumber(row.totalTokens).padEnd(9),
|
|
2289
|
-
|
|
2411
|
+
formatMetricsCostLine(row).padEnd(9),
|
|
2290
2412
|
formatMetricsNumber(row.ampCredits),
|
|
2291
2413
|
].join(' '));
|
|
2292
2414
|
}
|
|
@@ -2296,10 +2418,11 @@ function renderMetricsSummary(metrics) {
|
|
|
2296
2418
|
lines.push('');
|
|
2297
2419
|
lines.push('recent sessions:');
|
|
2298
2420
|
for (const session of sessions.slice(-5)) {
|
|
2299
|
-
const spendLabel = session.totalTokens != null || session.costUsd != null
|
|
2300
|
-
? ` — ${formatMetricsNumber(session.totalTokens)} tok, ${
|
|
2421
|
+
const spendLabel = session.totalTokens != null || session.costUsd != null || session.costUsdEstimated != null
|
|
2422
|
+
? ` — ${formatMetricsNumber(session.totalTokens)} tok, ${formatMetricsCostLine(session)}`
|
|
2301
2423
|
: '';
|
|
2302
|
-
|
|
2424
|
+
const modeLabel = session.agentMode ? ` mode:${session.agentMode}` : '';
|
|
2425
|
+
lines.push(`- ${formatKyivDisplay(session.endedAt)} ${String(session.phase || '').padEnd(7)} ${formatMetricsDuration(session.durationMs).padEnd(9)} ${session.role || '(no role)'}${session.model ? ` [${session.model}]` : ''}${modeLabel} (${sessionSpendSourceLabel(session)})${spendLabel}`);
|
|
2303
2426
|
}
|
|
2304
2427
|
}
|
|
2305
2428
|
return lines;
|
|
@@ -3478,7 +3601,7 @@ program
|
|
|
3478
3601
|
.option('--output-tokens <n>', 'Output tokens spent in the Archiver session')
|
|
3479
3602
|
.option('--total-tokens <n>', 'Total tokens spent in the Archiver session (default: input + output)')
|
|
3480
3603
|
.option('--cost-usd <usd>', 'Cost of the Archiver session in USD')
|
|
3481
|
-
.option('--collect', '
|
|
3604
|
+
.option('--collect', 'Collect all spend adapters (default: locked Cursor/Amp/Claude client only)', false)
|
|
3482
3605
|
.action((name, opts) => {
|
|
3483
3606
|
const projectDir = process.cwd();
|
|
3484
3607
|
const fail = (msg) => {
|
|
@@ -3598,11 +3721,25 @@ program
|
|
|
3598
3721
|
if (existsSync(archivedHandoffPath)) {
|
|
3599
3722
|
priorFields = fieldsFromSections(name, parseHandoffMarkdown(readFileSync(archivedHandoffPath, 'utf-8')));
|
|
3600
3723
|
}
|
|
3601
|
-
const
|
|
3602
|
-
const
|
|
3724
|
+
const metricsPreview = loadMetricsFile(join(targetDir, 'metrics.json'), name, nowUtcIso());
|
|
3725
|
+
const reported = dropStaleArchiveSelfReport(priorFields.metrics, metricsPreview.sessions);
|
|
3726
|
+
const client = resolveRestoreClient({
|
|
3727
|
+
env: process.env,
|
|
3728
|
+
cwd: projectDir,
|
|
3729
|
+
homedir: process.env.HOME,
|
|
3730
|
+
platform: opts.platform,
|
|
3731
|
+
});
|
|
3732
|
+
const archivePlatform = resolvePlatform(opts, process.env, reported, {
|
|
3733
|
+
platform: client.platform,
|
|
3734
|
+
threadId: client.threadId,
|
|
3735
|
+
});
|
|
3603
3736
|
const archiveModel = resolveModel(opts, process.env, reported);
|
|
3604
3737
|
if (archivePlatform.warn) console.error(archivePlatform.warn);
|
|
3605
3738
|
printMetricsSectionWarnings(reported, Boolean(archivePlatform.warn));
|
|
3739
|
+
const clientLabel = archivePlatform.value || client.platform
|
|
3740
|
+
? `${archivePlatform.value || client.platform}${client.threadId ? ` ${client.threadId}` : ''}${client.source ? ` (${client.source})` : ''}`
|
|
3741
|
+
: 'unknown — pass --platform or run archive from Cursor/Amp/Claude';
|
|
3742
|
+
console.error(`metrics: archive client ${clientLabel}`);
|
|
3606
3743
|
const runtimeResult = resolveRuntime({}, process.env, priorFields);
|
|
3607
3744
|
const progress = parseTasksProgress(targetDir);
|
|
3608
3745
|
const fields = {
|
|
@@ -3629,7 +3766,8 @@ program
|
|
|
3629
3766
|
const memoryPath = persistMemoryFromHandoff(projectDir, fields);
|
|
3630
3767
|
const metricsPath = metricsFinalizeArchive(targetDir, name, {
|
|
3631
3768
|
model: archiveModel,
|
|
3632
|
-
platform: archivePlatform.value || null,
|
|
3769
|
+
platform: archivePlatform.value || client.platform || null,
|
|
3770
|
+
ampThreadId: client.threadId || ampThreadIdFromEnv(process.env) || null,
|
|
3633
3771
|
runtime: fields.runtime,
|
|
3634
3772
|
agentId: fields.agentId,
|
|
3635
3773
|
tasks: fields.tasks,
|
|
@@ -3649,7 +3787,7 @@ program
|
|
|
3649
3787
|
console.log(`memory: ${memoryPath.replace(`${projectDir}/`, '')}`);
|
|
3650
3788
|
console.log(`metrics: ${metricsPath.replace(`${projectDir}/`, '')} (archived_at set)`);
|
|
3651
3789
|
try {
|
|
3652
|
-
const archivedMetrics = loadMetricsFile(metricsPath, name,
|
|
3790
|
+
const archivedMetrics = loadMetricsFile(metricsPath, name, nowUtcIso());
|
|
3653
3791
|
for (const line of renderMetricsSummary(archivedMetrics)) console.log(line);
|
|
3654
3792
|
} catch {}
|
|
3655
3793
|
log.ok(`archived ${name}`);
|
|
@@ -3998,7 +4136,7 @@ program
|
|
|
3998
4136
|
}
|
|
3999
4137
|
|
|
4000
4138
|
const reported = fields.metrics || emptyMetricsFields();
|
|
4001
|
-
const metricsPreview = loadMetricsFile(metricsFilePath(projectDir, name), name,
|
|
4139
|
+
const metricsPreview = loadMetricsFile(metricsFilePath(projectDir, name), name, nowUtcIso());
|
|
4002
4140
|
const platformResult = resolvePlatform(opts, process.env, reported, metricsPreview.pending);
|
|
4003
4141
|
if (platformResult.error) {
|
|
4004
4142
|
log.err(platformResult.error);
|
|
@@ -4099,7 +4237,7 @@ program
|
|
|
4099
4237
|
return;
|
|
4100
4238
|
}
|
|
4101
4239
|
|
|
4102
|
-
const metrics = loadMetricsFile(filePath, name,
|
|
4240
|
+
const metrics = loadMetricsFile(filePath, name, nowUtcIso());
|
|
4103
4241
|
if (opts.json) {
|
|
4104
4242
|
process.stdout.write(`${JSON.stringify(metrics, null, 2)}\n`);
|
|
4105
4243
|
return;
|
package/bin/amp-usage.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
function numOrNull(value) {
|
|
2
|
+
if (value == null || value === '') return null;
|
|
3
|
+
const n = Number(String(value).replace(/,/g, ''));
|
|
4
|
+
return Number.isFinite(n) ? n : null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function parseAmpUsageDetails(text) {
|
|
8
|
+
const raw = String(text || '');
|
|
9
|
+
const costMatch = /(?:^|\n)Cost:\s*\$([0-9]+(?:\.[0-9]+)?)/.exec(raw);
|
|
10
|
+
const totalMatch = /Total tokens:\s*([\d,]+)/i.exec(raw);
|
|
11
|
+
const inputMatch = /Input tokens:\s*([\d,]+)(?:\s*\(([\d,]+)\s*cache reads\))?/i.exec(raw);
|
|
12
|
+
const outputMatch = /Output tokens:\s*([\d,]+)/i.exec(raw);
|
|
13
|
+
const models = [];
|
|
14
|
+
const tableStart = raw.search(/##\s*Models\b/i);
|
|
15
|
+
if (tableStart >= 0) {
|
|
16
|
+
const section = raw.slice(tableStart);
|
|
17
|
+
const rowRe = /^\|\s*(?!Model\b|[-: ]+)([^|]+?)\s*\|\s*([\d,]+)\s*\|\s*([\d,]+)\s*\|\s*([\d,]+)\s*\|\s*\$([0-9]+(?:\.[0-9]+)?)\s*\|/gim;
|
|
18
|
+
let row;
|
|
19
|
+
while ((row = rowRe.exec(section))) {
|
|
20
|
+
models.push({
|
|
21
|
+
model: row[1].trim(),
|
|
22
|
+
requests: numOrNull(row[2]),
|
|
23
|
+
inputTokens: numOrNull(row[3]),
|
|
24
|
+
outputTokens: numOrNull(row[4]),
|
|
25
|
+
costUsd: numOrNull(row[5]),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
costUsd: costMatch ? numOrNull(costMatch[1]) : null,
|
|
31
|
+
totalTokens: totalMatch ? numOrNull(totalMatch[1]) : null,
|
|
32
|
+
inputTokens: inputMatch ? numOrNull(inputMatch[1]) : null,
|
|
33
|
+
cacheReadTokens: inputMatch && inputMatch[2] ? numOrNull(inputMatch[2]) : null,
|
|
34
|
+
outputTokens: outputMatch ? numOrNull(outputMatch[1]) : null,
|
|
35
|
+
models,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ampAgentMode(thread) {
|
|
40
|
+
if (!thread || typeof thread !== 'object') return null;
|
|
41
|
+
const direct = thread.agentMode || (thread.meta && thread.meta.agentMode);
|
|
42
|
+
if (direct == null || String(direct).trim() === '') return null;
|
|
43
|
+
return String(direct).trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function compactModel(value) {
|
|
47
|
+
return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '').replace(/(\d)p(\d)/g, '$1$2');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function matchAmpUsageModel(displayName, sourceModels) {
|
|
51
|
+
const wanted = compactModel(displayName);
|
|
52
|
+
if (!wanted) return displayName;
|
|
53
|
+
let best = null;
|
|
54
|
+
for (const id of sourceModels || []) {
|
|
55
|
+
const compact = compactModel(id);
|
|
56
|
+
if (!compact) continue;
|
|
57
|
+
if (compact === wanted || compact.includes(wanted) || wanted.includes(compact.replace(/^accountsfireworkmodels/, ''))) {
|
|
58
|
+
best = id;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return best || displayName;
|
|
63
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
function numOrNull(value) {
|
|
2
|
+
if (value == null || value === '') return null;
|
|
3
|
+
const n = Number(value);
|
|
4
|
+
return Number.isFinite(n) ? n : null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const GROK_46 = {
|
|
8
|
+
inputPerM: 2,
|
|
9
|
+
cachedPerM: 0.5,
|
|
10
|
+
outputPerM: 6,
|
|
11
|
+
longInputPerM: 4,
|
|
12
|
+
longCachedPerM: 1,
|
|
13
|
+
longOutputPerM: 12,
|
|
14
|
+
longAt: 200000,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function ratesForModel(model) {
|
|
18
|
+
const id = String(model || '').toLowerCase();
|
|
19
|
+
if (!id) return null;
|
|
20
|
+
let rates = null;
|
|
21
|
+
if (id.includes('grok-4.6') || id.includes('grok-4-6')) rates = { ...GROK_46 };
|
|
22
|
+
else if (id.includes('grok-4.5') || id.includes('grok-4-5')) {
|
|
23
|
+
rates = { ...GROK_46, cachedPerM: 0.3, longCachedPerM: 0.6 };
|
|
24
|
+
} else {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (id.includes('fast')) {
|
|
28
|
+
for (const key of ['inputPerM', 'cachedPerM', 'outputPerM', 'longInputPerM', 'longCachedPerM', 'longOutputPerM']) {
|
|
29
|
+
rates[key] *= 2;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return rates;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function estimateCursorCostUsd({ model, inputTokens, outputTokens, cacheReadTokens } = {}) {
|
|
36
|
+
const rates = ratesForModel(model);
|
|
37
|
+
if (!rates) return null;
|
|
38
|
+
const input = numOrNull(inputTokens);
|
|
39
|
+
const output = numOrNull(outputTokens) ?? 0;
|
|
40
|
+
if (input == null && output == 0) return null;
|
|
41
|
+
const totalInput = input ?? 0;
|
|
42
|
+
const cached = Math.min(numOrNull(cacheReadTokens) ?? 0, totalInput);
|
|
43
|
+
const fresh = Math.max(0, totalInput - cached);
|
|
44
|
+
const long = totalInput >= rates.longAt;
|
|
45
|
+
const inputRate = long ? rates.longInputPerM : rates.inputPerM;
|
|
46
|
+
const cachedRate = long ? rates.longCachedPerM : rates.cachedPerM;
|
|
47
|
+
const outputRate = long ? rates.longOutputPerM : rates.outputPerM;
|
|
48
|
+
const usd = (fresh * inputRate + cached * cachedRate + output * outputRate) / 1e6;
|
|
49
|
+
return Math.round(usd * 10000) / 10000;
|
|
50
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const DISPLAY_TIMEZONE = 'Europe/Kyiv';
|
|
2
|
+
|
|
3
|
+
function pad2(value) {
|
|
4
|
+
return String(value).padStart(2, '0');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function parseFlexibleIso(value) {
|
|
8
|
+
if (value == null || value === '') return NaN;
|
|
9
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
10
|
+
return value < 1e12 ? value * 1000 : value;
|
|
11
|
+
}
|
|
12
|
+
let raw = String(value).trim();
|
|
13
|
+
if (!raw) return NaN;
|
|
14
|
+
raw = raw.replace(/^(\d{4}-\d{2}-\d{2})[ ]+(\d{2}:)/, '$1T$2');
|
|
15
|
+
raw = raw.replace(/(\.\d{3})\d*\.000(?=Z$|[+-]\d{2}:?\d{2}$)/i, '$1');
|
|
16
|
+
raw = raw.replace(/(\.\d{3})\d+(?=Z$|[+-]\d{2}:?\d{2}$)/i, '$1');
|
|
17
|
+
raw = raw.replace(/([+-]\d{2})(\d{2})$/, '$1:$2');
|
|
18
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?$/.test(raw)) raw += 'Z';
|
|
19
|
+
const ms = Date.parse(raw);
|
|
20
|
+
return Number.isFinite(ms) ? ms : NaN;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatUtcIso(value) {
|
|
24
|
+
const ms = parseFlexibleIso(value);
|
|
25
|
+
if (!Number.isFinite(ms)) return null;
|
|
26
|
+
return new Date(ms).toISOString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function nowUtcIso(now = Date.now()) {
|
|
30
|
+
return formatUtcIso(now);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function kyivOffset(ms) {
|
|
34
|
+
const d = new Date(ms);
|
|
35
|
+
const fmt = new Intl.DateTimeFormat('en-US', {
|
|
36
|
+
timeZone: DISPLAY_TIMEZONE,
|
|
37
|
+
timeZoneName: 'longOffset',
|
|
38
|
+
hour: '2-digit',
|
|
39
|
+
});
|
|
40
|
+
const name = fmt.formatToParts(d).find((part) => part.type === 'timeZoneName')?.value || '';
|
|
41
|
+
const match = /GMT([+-])(\d{1,2})(?::(\d{2}))?/.exec(name);
|
|
42
|
+
if (match) {
|
|
43
|
+
return `${match[1]}${pad2(match[2])}:${match[3] || '00'}`;
|
|
44
|
+
}
|
|
45
|
+
const utc = new Date(d.toLocaleString('en-US', { timeZone: 'UTC' })).getTime();
|
|
46
|
+
const kyiv = new Date(d.toLocaleString('en-US', { timeZone: DISPLAY_TIMEZONE })).getTime();
|
|
47
|
+
const minutes = Math.round((kyiv - utc) / 60000);
|
|
48
|
+
const sign = minutes >= 0 ? '+' : '-';
|
|
49
|
+
const abs = Math.abs(minutes);
|
|
50
|
+
return `${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function formatKyivIso(value) {
|
|
54
|
+
const ms = parseFlexibleIso(value);
|
|
55
|
+
if (!Number.isFinite(ms)) return null;
|
|
56
|
+
const d = new Date(ms);
|
|
57
|
+
const parts = Object.fromEntries(
|
|
58
|
+
new Intl.DateTimeFormat('en-US', {
|
|
59
|
+
timeZone: DISPLAY_TIMEZONE,
|
|
60
|
+
year: 'numeric',
|
|
61
|
+
month: '2-digit',
|
|
62
|
+
day: '2-digit',
|
|
63
|
+
hour: '2-digit',
|
|
64
|
+
minute: '2-digit',
|
|
65
|
+
second: '2-digit',
|
|
66
|
+
hourCycle: 'h23',
|
|
67
|
+
}).formatToParts(d).map((part) => [part.type, part.value]),
|
|
68
|
+
);
|
|
69
|
+
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}.${String(ms % 1000).padStart(3, '0')}${kyivOffset(ms)}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function formatKyivDisplay(value) {
|
|
73
|
+
const iso = formatKyivIso(value);
|
|
74
|
+
if (!iso) return '—';
|
|
75
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?([+-]\d{2}:\d{2})$/.exec(iso);
|
|
76
|
+
if (!match) return iso;
|
|
77
|
+
return `${match[3]}.${match[2]}.${match[1]} ${match[4]}:${match[5]}:${match[6]} (Київ ${match[7]})`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isoOrNull(value) {
|
|
81
|
+
if (value == null || value === '') return null;
|
|
82
|
+
return formatUtcIso(value);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function laterTimestamp(a, b) {
|
|
86
|
+
const left = parseFlexibleIso(a);
|
|
87
|
+
const right = parseFlexibleIso(b);
|
|
88
|
+
if (!Number.isFinite(left)) return b;
|
|
89
|
+
if (!Number.isFinite(right)) return a;
|
|
90
|
+
return left >= right ? a : b;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function earlierTimestamp(a, b) {
|
|
94
|
+
const left = parseFlexibleIso(a);
|
|
95
|
+
const right = parseFlexibleIso(b);
|
|
96
|
+
if (!Number.isFinite(left)) return b;
|
|
97
|
+
if (!Number.isFinite(right)) return a;
|
|
98
|
+
return left <= right ? a : b;
|
|
99
|
+
}
|
package/bin/spend-collect.js
CHANGED
|
@@ -3,6 +3,9 @@ import { join, basename } from 'path';
|
|
|
3
3
|
import { homedir as osHomedir } from 'os';
|
|
4
4
|
import { execFileSync } from 'child_process';
|
|
5
5
|
import { listRecentAmpThreadIds } from './session-client.js';
|
|
6
|
+
import { formatUtcIso, parseFlexibleIso } from './metrics-time.js';
|
|
7
|
+
import { estimateCursorCostUsd } from './cursor-cost-estimate.js';
|
|
8
|
+
import { ampAgentMode, matchAmpUsageModel, parseAmpUsageDetails } from './amp-usage.js';
|
|
6
9
|
|
|
7
10
|
const PLATFORMS = ['cursor', 'claude', 'amp'];
|
|
8
11
|
|
|
@@ -24,6 +27,7 @@ function emptyPlatform(source = 'none') {
|
|
|
24
27
|
totalTokens: null,
|
|
25
28
|
costUsd: null,
|
|
26
29
|
ampCredits: null,
|
|
30
|
+
costUsdEstimated: null,
|
|
27
31
|
source,
|
|
28
32
|
};
|
|
29
33
|
}
|
|
@@ -59,11 +63,7 @@ function pathsEqual(a, b) {
|
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
function parseTime(value) {
|
|
62
|
-
|
|
63
|
-
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
64
|
-
return value < 1e12 ? value * 1000 : value;
|
|
65
|
-
}
|
|
66
|
-
return Date.parse(String(value));
|
|
66
|
+
return parseFlexibleIso(value);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
function inWindow(timestamp, windowStart, windowEnd) {
|
|
@@ -80,12 +80,25 @@ function inWindow(timestamp, windowStart, windowEnd) {
|
|
|
80
80
|
return true;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
function sourceRecord({
|
|
83
|
+
function sourceRecord({
|
|
84
|
+
id,
|
|
85
|
+
platform,
|
|
86
|
+
model,
|
|
87
|
+
inputTokens,
|
|
88
|
+
outputTokens,
|
|
89
|
+
costUsd,
|
|
90
|
+
ampCredits,
|
|
91
|
+
at,
|
|
92
|
+
cacheReadTokens,
|
|
93
|
+
costUsdEstimated,
|
|
94
|
+
costSource,
|
|
95
|
+
agentMode,
|
|
96
|
+
}) {
|
|
84
97
|
const input = numOrNull(inputTokens);
|
|
85
98
|
const output = numOrNull(outputTokens);
|
|
86
99
|
let total = null;
|
|
87
100
|
if (input != null || output != null) total = (input ?? 0) + (output ?? 0);
|
|
88
|
-
|
|
101
|
+
const record = {
|
|
89
102
|
id: String(id),
|
|
90
103
|
platform,
|
|
91
104
|
model: model == null || model === '' ? null : String(model),
|
|
@@ -94,8 +107,15 @@ function sourceRecord({ id, platform, model, inputTokens, outputTokens, costUsd,
|
|
|
94
107
|
totalTokens: total,
|
|
95
108
|
costUsd: numOrNull(costUsd),
|
|
96
109
|
ampCredits: numOrNull(ampCredits),
|
|
97
|
-
at: at == null ? null : String(at),
|
|
110
|
+
at: at == null || at === '' ? null : (formatUtcIso(at) || String(at)),
|
|
98
111
|
};
|
|
112
|
+
const cache = numOrNull(cacheReadTokens);
|
|
113
|
+
if (cache != null) record.cacheReadTokens = cache;
|
|
114
|
+
const estimated = numOrNull(costUsdEstimated);
|
|
115
|
+
if (estimated != null) record.costUsdEstimated = estimated;
|
|
116
|
+
if (costSource) record.costSource = String(costSource);
|
|
117
|
+
if (agentMode) record.agentMode = String(agentMode);
|
|
118
|
+
return record;
|
|
99
119
|
}
|
|
100
120
|
|
|
101
121
|
function claudeInputTokens(usage) {
|
|
@@ -334,6 +354,7 @@ export function sourcesFromAmpThread(thread, ctx, fileName = '', via = null) {
|
|
|
334
354
|
costUsd: null,
|
|
335
355
|
ampCredits: null,
|
|
336
356
|
at: usage.timestamp,
|
|
357
|
+
agentMode: ampAgentMode(thread),
|
|
337
358
|
});
|
|
338
359
|
if (via) record.via = via;
|
|
339
360
|
sources.push(record);
|
|
@@ -395,6 +416,39 @@ export function exportAmpThread(threadId, options = {}) {
|
|
|
395
416
|
}
|
|
396
417
|
}
|
|
397
418
|
|
|
419
|
+
export function fetchAmpThreadUsage(threadId, options = {}) {
|
|
420
|
+
const id = threadId == null ? '' : String(threadId).trim();
|
|
421
|
+
if (!id) return null;
|
|
422
|
+
if (typeof options.usageAmpThread === 'function') {
|
|
423
|
+
try {
|
|
424
|
+
const injected = options.usageAmpThread(id);
|
|
425
|
+
if (injected == null) return null;
|
|
426
|
+
if (typeof injected === 'string') return parseAmpUsageDetails(injected);
|
|
427
|
+
if (typeof injected === 'object') {
|
|
428
|
+
if (injected.text && injected.costUsd == null) return { ...parseAmpUsageDetails(injected.text), ...injected };
|
|
429
|
+
return injected;
|
|
430
|
+
}
|
|
431
|
+
return null;
|
|
432
|
+
} catch {
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (typeof options.exportAmpThread === 'function') return null;
|
|
437
|
+
const bin = options.ampBin || (options.env && options.env.AOK_AMP_BIN) || 'amp';
|
|
438
|
+
if (bin !== 'amp' && !existsSync(bin)) return null;
|
|
439
|
+
try {
|
|
440
|
+
const out = execFileSync(bin, ['threads', 'usage', id, '--details'], {
|
|
441
|
+
encoding: 'utf-8',
|
|
442
|
+
timeout: options.timeoutMs != null ? Number(options.timeoutMs) : 25000,
|
|
443
|
+
env: options.env || process.env,
|
|
444
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
445
|
+
});
|
|
446
|
+
return parseAmpUsageDetails(out);
|
|
447
|
+
} catch {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
398
452
|
function collectAmpCli(ctx) {
|
|
399
453
|
const { env, notes, ampThreadId } = ctx;
|
|
400
454
|
const ids = [];
|
|
@@ -408,17 +462,41 @@ function collectAmpCli(ctx) {
|
|
|
408
462
|
for (const id of listRecentAmpThreadIds(ctx)) push(id);
|
|
409
463
|
}
|
|
410
464
|
const sources = [];
|
|
465
|
+
const threads = [];
|
|
411
466
|
for (const id of ids) {
|
|
412
467
|
const thread = exportAmpThread(id, ctx);
|
|
413
468
|
if (!thread) {
|
|
414
469
|
notes.push(`amp: export failed for ${id}`);
|
|
415
470
|
continue;
|
|
416
471
|
}
|
|
472
|
+
const agentMode = ampAgentMode(thread);
|
|
417
473
|
const extracted = sourcesFromAmpThread(thread, ctx, `${id}.json`, 'amp-cli');
|
|
418
474
|
if (!extracted.length) notes.push(`amp: export ${id} had no matching usage`);
|
|
419
475
|
sources.push(...extracted);
|
|
476
|
+
const usage = fetchAmpThreadUsage(id, ctx);
|
|
477
|
+
if (!usage) notes.push(`amp: usage failed for ${id}`);
|
|
478
|
+
const sourceModels = extracted.map((src) => src.model).filter(Boolean);
|
|
479
|
+
const usageModels = (usage && Array.isArray(usage.models) ? usage.models : []).map((row) => ({
|
|
480
|
+
...row,
|
|
481
|
+
model: matchAmpUsageModel(row.model, sourceModels),
|
|
482
|
+
}));
|
|
483
|
+
if (usage && usage.costUsd != null) {
|
|
484
|
+
for (const src of extracted) {
|
|
485
|
+
src.costSource = 'amp-usage';
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
threads.push({
|
|
489
|
+
id,
|
|
490
|
+
agentMode,
|
|
491
|
+
costUsd: usage ? numOrNull(usage.costUsd) : null,
|
|
492
|
+
inputTokens: usage ? numOrNull(usage.inputTokens) : null,
|
|
493
|
+
outputTokens: usage ? numOrNull(usage.outputTokens) : null,
|
|
494
|
+
totalTokens: usage ? numOrNull(usage.totalTokens) : null,
|
|
495
|
+
cacheReadTokens: usage ? numOrNull(usage.cacheReadTokens) : null,
|
|
496
|
+
models: usageModels,
|
|
497
|
+
});
|
|
420
498
|
}
|
|
421
|
-
return sources;
|
|
499
|
+
return { sources, threads };
|
|
422
500
|
}
|
|
423
501
|
|
|
424
502
|
export const CURSOR_USAGE_FILE_REL = join('.agents', 'spend', 'cursor-usage.jsonl');
|
|
@@ -455,15 +533,26 @@ function collectCursor({ cwd, windowStart, windowEnd, existing, notes }) {
|
|
|
455
533
|
const inputTokens = numOrNull(row.inputTokens);
|
|
456
534
|
const outputTokens = numOrNull(row.outputTokens);
|
|
457
535
|
if (inputTokens == null && outputTokens == null) continue;
|
|
536
|
+
const model = row.model || row.modelId;
|
|
537
|
+
const cacheReadTokens = numOrNull(row.cacheReadTokens);
|
|
538
|
+
const estimated = estimateCursorCostUsd({
|
|
539
|
+
model,
|
|
540
|
+
inputTokens,
|
|
541
|
+
outputTokens,
|
|
542
|
+
cacheReadTokens,
|
|
543
|
+
});
|
|
458
544
|
const record = sourceRecord({
|
|
459
545
|
id,
|
|
460
546
|
platform: 'cursor',
|
|
461
|
-
model
|
|
547
|
+
model,
|
|
462
548
|
inputTokens,
|
|
463
549
|
outputTokens,
|
|
464
550
|
costUsd: null,
|
|
465
551
|
ampCredits: null,
|
|
466
552
|
at: row.at,
|
|
553
|
+
cacheReadTokens,
|
|
554
|
+
costUsdEstimated: estimated,
|
|
555
|
+
costSource: estimated != null ? 'api-estimate' : null,
|
|
467
556
|
});
|
|
468
557
|
const previous = bestById.get(id);
|
|
469
558
|
if (!previous || (record.totalTokens ?? 0) >= (previous.totalTokens ?? 0)) {
|
|
@@ -485,6 +574,7 @@ function aggregate(sources) {
|
|
|
485
574
|
bucket.totalTokens = addNullable(bucket.totalTokens, src.totalTokens);
|
|
486
575
|
bucket.costUsd = addNullable(bucket.costUsd, src.costUsd);
|
|
487
576
|
bucket.ampCredits = addNullable(bucket.ampCredits, src.ampCredits);
|
|
577
|
+
bucket.costUsdEstimated = addNullable(bucket.costUsdEstimated, src.costUsdEstimated);
|
|
488
578
|
if (platform === 'claude') bucket.source = 'claude-jsonl';
|
|
489
579
|
else if (platform === 'amp') bucket.source = src.via === 'amp-cli' ? 'amp-cli' : 'amp-thread';
|
|
490
580
|
else bucket.source = 'cursor-hook';
|
|
@@ -500,12 +590,14 @@ function aggregate(sources) {
|
|
|
500
590
|
totalTokens: null,
|
|
501
591
|
costUsd: null,
|
|
502
592
|
ampCredits: null,
|
|
593
|
+
costUsdEstimated: null,
|
|
503
594
|
};
|
|
504
595
|
row.inputTokens = addNullable(row.inputTokens, src.inputTokens);
|
|
505
596
|
row.outputTokens = addNullable(row.outputTokens, src.outputTokens);
|
|
506
597
|
row.totalTokens = addNullable(row.totalTokens, src.totalTokens);
|
|
507
598
|
row.costUsd = addNullable(row.costUsd, src.costUsd);
|
|
508
599
|
row.ampCredits = addNullable(row.ampCredits, src.ampCredits);
|
|
600
|
+
row.costUsdEstimated = addNullable(row.costUsdEstimated, src.costUsdEstimated);
|
|
509
601
|
byModel.set(key, row);
|
|
510
602
|
}
|
|
511
603
|
}
|
|
@@ -541,10 +633,12 @@ export function collectSpend(options = {}) {
|
|
|
541
633
|
ampThreadId: options.ampThreadId,
|
|
542
634
|
exportAmpThread: options.exportAmpThread,
|
|
543
635
|
listAmpThreads: options.listAmpThreads,
|
|
636
|
+
usageAmpThread: options.usageAmpThread,
|
|
544
637
|
ampBin: options.ampBin,
|
|
545
638
|
timeoutMs: options.timeoutMs,
|
|
546
639
|
};
|
|
547
640
|
let sources = [];
|
|
641
|
+
const ampThreads = [];
|
|
548
642
|
if (run('claude')) {
|
|
549
643
|
try {
|
|
550
644
|
sources = sources.concat(collectClaude(ctx));
|
|
@@ -555,7 +649,9 @@ export function collectSpend(options = {}) {
|
|
|
555
649
|
if (run('amp')) {
|
|
556
650
|
if (options.ampCli === true || typeof options.exportAmpThread === 'function') {
|
|
557
651
|
try {
|
|
558
|
-
|
|
652
|
+
const cli = collectAmpCli(ctx);
|
|
653
|
+
sources = sources.concat(cli.sources || []);
|
|
654
|
+
if (Array.isArray(cli.threads)) ampThreads.push(...cli.threads);
|
|
559
655
|
} catch {
|
|
560
656
|
notes.push('amp: cli export failed');
|
|
561
657
|
}
|
|
@@ -574,5 +670,22 @@ export function collectSpend(options = {}) {
|
|
|
574
670
|
}
|
|
575
671
|
}
|
|
576
672
|
const { byPlatform, byModel } = aggregate(sources);
|
|
577
|
-
|
|
673
|
+
applyAmpThreadSpend(byPlatform, byModel, ampThreads);
|
|
674
|
+
return { sources, byPlatform, byModel, notes, ampThreads };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function applyAmpThreadSpend(byPlatform, byModel, threads) {
|
|
678
|
+
if (!threads || !threads.length) return;
|
|
679
|
+
let cost = null;
|
|
680
|
+
for (const thread of threads) {
|
|
681
|
+
cost = addNullable(cost, numOrNull(thread.costUsd));
|
|
682
|
+
for (const row of thread.models || []) {
|
|
683
|
+
if (!row.model) continue;
|
|
684
|
+
const key = `${row.model}::amp`;
|
|
685
|
+
const existing = byModel.find((item) => `${item.model}::${item.platform || ''}` === key)
|
|
686
|
+
|| byModel.find((item) => item.platform === 'amp' && item.model === row.model);
|
|
687
|
+
if (existing && row.costUsd != null) existing.costUsd = addNullable(existing.costUsd, row.costUsd);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (cost != null) byPlatform.amp.costUsd = addNullable(byPlatform.amp.costUsd, cost);
|
|
578
691
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-orchestrator-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Universal AI agent orchestration kit for Cursor, Claude Code, and Amp Code — spec-driven OpenSpec pipeline, conductor subagents, durable session handoff, factory gates and MCP setup, cloud-agent handoff, and optional local Figma PAT setup",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agent",
|
|
@@ -21,7 +21,7 @@ Archive is one CLI call, no phase subagents.
|
|
|
21
21
|
npx agent-orchestrator-kit archive <name> [--sync | --no-sync --force]
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
Gates, optional `--sync`, move to `archive/YYYY-MM-DD-<name>`, validate+rollback, final `handoff.md` (`next_command: none`) + memory. A successful `archive` always creates or updates `metrics.json` (`archivedAt`, Archiver session) and prints the change-wide metrics summary.
|
|
24
|
+
Gates, optional `--sync`, move to `archive/YYYY-MM-DD-<name>`, validate+rollback, final `handoff.md` (`next_command: none`) + memory. A successful `archive` always creates or updates `metrics.json` (`archivedAt`, Archiver session), collects the locked client into that session (Cursor hook / Amp usage / Claude JSONL; `--collect` = all adapters), and prints the change-wide metrics summary. If `spend.costUsd` is `null` — stderr warning, not a gate.
|
|
25
25
|
|
|
26
26
|
4. **Show stdout as-is.** On exit ≠ 0, report the gate from stderr and stop — no manual merge/move.
|
|
27
27
|
|
|
@@ -9,7 +9,7 @@ Workflow:
|
|
|
9
9
|
|
|
10
10
|
1. Read `.agents/orchestrator.yaml`, the complete change, review verdict, task state, and verification/merge evidence supplied by the conductor.
|
|
11
11
|
2. Refuse to archive unless required review is approved, all tasks are complete, and the configured merge/CI gate is satisfied.
|
|
12
|
-
3. Fill `## Metrics` in the change `handoff.md`
|
|
12
|
+
3. Fill `## Metrics` in the change `handoff.md` only when reporting Archiver-specific numbers (use `unknown` when missing). Do not copy the previous apply session. The CLI auto-collects the locked client into the Archiver session.
|
|
13
13
|
4. Run `npx agent-orchestrator-kit archive <name>` so delta requirements are merged into main specs, the change moves to the dated archive path, and stdout prints the change-wide metrics summary (by phase / by platform / by model).
|
|
14
14
|
5. Run strict validation after the move and report the resulting archive path and modified main specs.
|
|
15
15
|
|