agent-orchestrator-kit 0.12.0 → 0.14.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 +14 -0
- package/README.md +23 -4
- package/bin/agent-orchestrator.js +461 -114
- package/bin/claude-cost-estimate.js +51 -0
- package/bin/session-client.js +55 -6
- package/bin/spend-collect.js +79 -13
- package/package.json +1 -1
- package/templates/.agents/commands/opsx-propose.md +6 -0
- package/templates/.agents/commands/opsx-review.md +35 -11
- package/templates/.agents/rules/session-handoff.mdc +2 -2
- package/templates/.agents/skills/agent-orchestration/SKILL.md +5 -4
- package/templates/.agents/skills/openspec-propose/SKILL.md +6 -0
- package/templates/.agents/subagents/openspec-guide.md +3 -2
- package/templates/.agents/subagents/session-handoff.md +2 -1
- package/templates/.agents/subagents/spec-architect.md +6 -1
- package/templates/.agents/subagents/spec-archiver.md +2 -1
- package/templates/.agents/subagents/spec-reviewer.md +5 -1
- package/templates/AGENTS.md +1 -1
- package/templates/scripts/cursor-spend-collect.cjs +129 -60
|
@@ -5,7 +5,8 @@ import { readFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSyn
|
|
|
5
5
|
import { join, dirname, basename, resolve } from 'path';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
import { execSync } from 'child_process';
|
|
8
|
-
import { collectSpend
|
|
8
|
+
import { collectSpend } from './spend-collect.js';
|
|
9
|
+
import { describeClaudeCostEstimate } from './claude-cost-estimate.js';
|
|
9
10
|
import { resolveRestoreClient, ampThreadIdFromEnv } from './session-client.js';
|
|
10
11
|
import {
|
|
11
12
|
earlierTimestamp,
|
|
@@ -968,8 +969,14 @@ function firstLineCommand(value) {
|
|
|
968
969
|
function firstSpawnName(value) {
|
|
969
970
|
const tick = String(value || '').match(/`([a-z0-9-]+)`/i);
|
|
970
971
|
if (tick) return tick[1];
|
|
971
|
-
const
|
|
972
|
-
return
|
|
972
|
+
const role = canonicalRole(value);
|
|
973
|
+
return {
|
|
974
|
+
Explorer: 'explorer',
|
|
975
|
+
Architect: 'spec-architect',
|
|
976
|
+
'Spec Reviewer': 'spec-reviewer',
|
|
977
|
+
Implementer: '',
|
|
978
|
+
Archiver: '',
|
|
979
|
+
}[role] || '';
|
|
973
980
|
}
|
|
974
981
|
|
|
975
982
|
function sectionOr(sections, title, fallback = '') {
|
|
@@ -1611,7 +1618,7 @@ function readHandoffFields(projectDir, changeName) {
|
|
|
1611
1618
|
return { filePath, fields: fieldsFromSections(changeName, sections) };
|
|
1612
1619
|
}
|
|
1613
1620
|
|
|
1614
|
-
const METRICS_VERSION =
|
|
1621
|
+
const METRICS_VERSION = 2;
|
|
1615
1622
|
const METRICS_SPEND_KEYS = ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated'];
|
|
1616
1623
|
const LEFTOVER_GRACE_MS = 120000;
|
|
1617
1624
|
|
|
@@ -1675,6 +1682,92 @@ function defaultMetrics(changeName, nowIso) {
|
|
|
1675
1682
|
};
|
|
1676
1683
|
}
|
|
1677
1684
|
|
|
1685
|
+
const VALID_COST_SOURCES = new Set(['api-estimate', 'api-estimate-fallback', 'amp-usage']);
|
|
1686
|
+
|
|
1687
|
+
function compactModelRows(sources) {
|
|
1688
|
+
const byModel = new Map();
|
|
1689
|
+
for (const source of sources || []) {
|
|
1690
|
+
if (!source) continue;
|
|
1691
|
+
const platform = VALID_PLATFORMS.has(source.platform) ? source.platform : null;
|
|
1692
|
+
const model = source.model == null || source.model === '' ? null : String(source.model);
|
|
1693
|
+
const key = `${platform || ''}::${model || ''}`;
|
|
1694
|
+
const row = byModel.get(key) || {
|
|
1695
|
+
model,
|
|
1696
|
+
platform,
|
|
1697
|
+
inputTokens: null,
|
|
1698
|
+
outputTokens: null,
|
|
1699
|
+
totalTokens: null,
|
|
1700
|
+
costUsd: null,
|
|
1701
|
+
costUsdEstimated: null,
|
|
1702
|
+
};
|
|
1703
|
+
for (const field of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd']) {
|
|
1704
|
+
row[field] = addNullable(row[field], numOrNull(source[field]));
|
|
1705
|
+
}
|
|
1706
|
+
row.costUsdEstimated = roundUsd4(addNullable(row.costUsdEstimated, numOrNull(source.costUsdEstimated)));
|
|
1707
|
+
if (VALID_COST_SOURCES.has(source.costSource)) {
|
|
1708
|
+
if (!row.costSource || row.costSource === source.costSource) row.costSource = source.costSource;
|
|
1709
|
+
else delete row.costSource;
|
|
1710
|
+
}
|
|
1711
|
+
byModel.set(key, row);
|
|
1712
|
+
}
|
|
1713
|
+
return [...byModel.values()];
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
function compactSourceTotals(sources) {
|
|
1717
|
+
const sourceIds = [];
|
|
1718
|
+
const sourceTotals = {};
|
|
1719
|
+
for (const source of sources || []) {
|
|
1720
|
+
if (!source || source.id == null || source.id === '') continue;
|
|
1721
|
+
const id = String(source.id);
|
|
1722
|
+
if (!sourceIds.includes(id)) sourceIds.push(id);
|
|
1723
|
+
const total = numOrNull(source.totalTokens);
|
|
1724
|
+
if (total != null) sourceTotals[id] = Math.max(numOrNull(sourceTotals[id]) ?? 0, total);
|
|
1725
|
+
}
|
|
1726
|
+
return { sourceIds, sourceTotals };
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
function normalizeSessionV2(session) {
|
|
1730
|
+
const normalized = session && typeof session === 'object' && !Array.isArray(session) ? { ...session } : {};
|
|
1731
|
+
const legacySources = Array.isArray(normalized.sources) ? normalized.sources : null;
|
|
1732
|
+
if (legacySources) {
|
|
1733
|
+
const bestById = new Map();
|
|
1734
|
+
for (const source of legacySources) {
|
|
1735
|
+
if (!source || source.id == null || source.id === '') continue;
|
|
1736
|
+
const id = String(source.id);
|
|
1737
|
+
const previous = bestById.get(id);
|
|
1738
|
+
if (!previous || (numOrNull(source.totalTokens) ?? 0) >= (numOrNull(previous.totalTokens) ?? 0)) bestById.set(id, source);
|
|
1739
|
+
}
|
|
1740
|
+
const compactSources = [...bestById.values()].map((source) => ({
|
|
1741
|
+
...source,
|
|
1742
|
+
model: source.model || normalized.model || null,
|
|
1743
|
+
platform: source.platform || normalized.platform || null,
|
|
1744
|
+
}));
|
|
1745
|
+
const compact = compactSourceTotals(compactSources);
|
|
1746
|
+
normalized.sourceIds = compact.sourceIds;
|
|
1747
|
+
normalized.sourceTotals = compact.sourceTotals;
|
|
1748
|
+
normalized.byModel = compactModelRows(compactSources);
|
|
1749
|
+
if (!normalized.platform && compactSources[0] && compactSources[0].platform) normalized.platform = compactSources[0].platform;
|
|
1750
|
+
if (!normalized.model && compactSources[0] && compactSources[0].model) normalized.model = compactSources[0].model;
|
|
1751
|
+
delete normalized.sources;
|
|
1752
|
+
} else {
|
|
1753
|
+
normalized.sourceIds = Array.isArray(normalized.sourceIds)
|
|
1754
|
+
? [...new Set(normalized.sourceIds.filter((id) => id != null && id !== '').map(String))]
|
|
1755
|
+
: [];
|
|
1756
|
+
normalized.sourceTotals = normalized.sourceTotals && typeof normalized.sourceTotals === 'object' && !Array.isArray(normalized.sourceTotals)
|
|
1757
|
+
? { ...normalized.sourceTotals }
|
|
1758
|
+
: {};
|
|
1759
|
+
normalized.byModel = Array.isArray(normalized.byModel) ? normalized.byModel.map((row) => ({ ...row })) : [];
|
|
1760
|
+
}
|
|
1761
|
+
return normalized;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
function normalizeMetricsV2(metrics) {
|
|
1765
|
+
if (!metrics || typeof metrics !== 'object' || Array.isArray(metrics)) return metrics;
|
|
1766
|
+
metrics.version = METRICS_VERSION;
|
|
1767
|
+
metrics.sessions = Array.isArray(metrics.sessions) ? metrics.sessions.map(normalizeSessionV2) : [];
|
|
1768
|
+
return metrics;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1678
1771
|
function loadMetricsFile(filePath, changeName, nowIso) {
|
|
1679
1772
|
if (!existsSync(filePath)) return defaultMetrics(changeName, nowIso);
|
|
1680
1773
|
let parsed;
|
|
@@ -1687,7 +1780,7 @@ function loadMetricsFile(filePath, changeName, nowIso) {
|
|
|
1687
1780
|
return defaultMetrics(changeName, nowIso);
|
|
1688
1781
|
}
|
|
1689
1782
|
const base = defaultMetrics(changeName, parsed.createdAt || nowIso);
|
|
1690
|
-
return {
|
|
1783
|
+
return normalizeMetricsV2({
|
|
1691
1784
|
...base,
|
|
1692
1785
|
...parsed,
|
|
1693
1786
|
version: METRICS_VERSION,
|
|
@@ -1698,12 +1791,12 @@ function loadMetricsFile(filePath, changeName, nowIso) {
|
|
|
1698
1791
|
totals: { ...base.totals, ...(parsed.totals && typeof parsed.totals === 'object' ? parsed.totals : {}) },
|
|
1699
1792
|
phases: parsed.phases && typeof parsed.phases === 'object' && !Array.isArray(parsed.phases) ? parsed.phases : {},
|
|
1700
1793
|
sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [],
|
|
1701
|
-
};
|
|
1794
|
+
});
|
|
1702
1795
|
}
|
|
1703
1796
|
|
|
1704
1797
|
function saveMetricsFile(filePath, metrics) {
|
|
1705
1798
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
1706
|
-
const out = { ...metrics };
|
|
1799
|
+
const out = normalizeMetricsV2({ ...metrics });
|
|
1707
1800
|
delete out.timezone;
|
|
1708
1801
|
writeFileSync(filePath, `${JSON.stringify(out, null, 2)}\n`);
|
|
1709
1802
|
}
|
|
@@ -1750,7 +1843,7 @@ function phaseForRole(role) {
|
|
|
1750
1843
|
function sessionFieldOrSources(session, key) {
|
|
1751
1844
|
if (session[key] != null && session[key] !== '') return numOrNull(session[key]);
|
|
1752
1845
|
let sum = null;
|
|
1753
|
-
for (const src of session.
|
|
1846
|
+
for (const src of session.byModel || []) {
|
|
1754
1847
|
sum = addNullable(sum, numOrNull(src[key]));
|
|
1755
1848
|
}
|
|
1756
1849
|
return sum;
|
|
@@ -1764,8 +1857,14 @@ function lastSessionEndedAt(metrics) {
|
|
|
1764
1857
|
return last;
|
|
1765
1858
|
}
|
|
1766
1859
|
|
|
1767
|
-
function collectWindowStart(metrics, extra) {
|
|
1768
|
-
|
|
1860
|
+
function collectWindowStart(metrics, extra = {}) {
|
|
1861
|
+
const explicit = isoOrNull(extra.explicitStartedAt);
|
|
1862
|
+
const pending = isoOrNull(metrics.pending && metrics.pending.startedAt);
|
|
1863
|
+
const selected = explicit || pending || isoOrNull(extra.lastEndedAt) || isoOrNull(metrics.createdAt);
|
|
1864
|
+
if (!selected) return nowUtcIso();
|
|
1865
|
+
if (!explicit && !pending) return selected;
|
|
1866
|
+
const selectedMs = parseFlexibleIso(selected);
|
|
1867
|
+
return Number.isFinite(selectedMs) ? new Date(selectedMs - LEFTOVER_GRACE_MS).toISOString() : selected;
|
|
1769
1868
|
}
|
|
1770
1869
|
|
|
1771
1870
|
function leftoverGraceEnd(endedAt) {
|
|
@@ -1774,6 +1873,15 @@ function leftoverGraceEnd(endedAt) {
|
|
|
1774
1873
|
return new Date(ms + LEFTOVER_GRACE_MS).toISOString();
|
|
1775
1874
|
}
|
|
1776
1875
|
|
|
1876
|
+
function sessionLeftoverEnd(session, pendingStartedAt) {
|
|
1877
|
+
const pending = isoOrNull(pendingStartedAt);
|
|
1878
|
+
const grace = leftoverGraceEnd(session && session.endedAt);
|
|
1879
|
+
if (session && session.threadId) return pending || grace;
|
|
1880
|
+
if (!pending) return grace;
|
|
1881
|
+
if (!grace) return pending;
|
|
1882
|
+
return earlierTimestamp(pending, grace);
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1777
1885
|
function leftoverTimestampInWindow(at, windowStart, leftoverEnd, exclusiveEnd) {
|
|
1778
1886
|
const t = parseFlexibleIso(at);
|
|
1779
1887
|
if (!Number.isFinite(t)) return false;
|
|
@@ -1788,19 +1896,71 @@ function leftoverTimestampInWindow(at, windowStart, leftoverEnd, exclusiveEnd) {
|
|
|
1788
1896
|
return true;
|
|
1789
1897
|
}
|
|
1790
1898
|
|
|
1899
|
+
function uniqueAmpSourceThreadPrefix(sourceIds) {
|
|
1900
|
+
const prefixes = [];
|
|
1901
|
+
for (const sourceId of sourceIds || []) {
|
|
1902
|
+
const id = sourceId == null ? '' : String(sourceId);
|
|
1903
|
+
if (!id.startsWith('T-')) continue;
|
|
1904
|
+
const prefix = id.split(':')[0];
|
|
1905
|
+
if (!prefix) continue;
|
|
1906
|
+
if (!prefixes.includes(prefix)) prefixes.push(prefix);
|
|
1907
|
+
}
|
|
1908
|
+
return prefixes.length === 1 ? prefixes[0] : null;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
function leftoverAmpThreadId(session) {
|
|
1912
|
+
if (!session) return null;
|
|
1913
|
+
if (session.threadId) return String(session.threadId);
|
|
1914
|
+
return uniqueAmpSourceThreadPrefix(session.sourceIds);
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
function sessionAmpThreadKeys(session) {
|
|
1918
|
+
const keys = new Set();
|
|
1919
|
+
if (session && session.threadId) keys.add(String(session.threadId));
|
|
1920
|
+
for (const sourceId of (session && session.sourceIds) || []) {
|
|
1921
|
+
const id = sourceId == null ? '' : String(sourceId);
|
|
1922
|
+
if (!id.startsWith('T-')) continue;
|
|
1923
|
+
const prefix = id.split(':')[0];
|
|
1924
|
+
if (prefix) keys.add(prefix);
|
|
1925
|
+
}
|
|
1926
|
+
return keys;
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
function usageModelTokenCount(row) {
|
|
1930
|
+
const total = numOrNull(row && row.totalTokens);
|
|
1931
|
+
if (total != null) return total;
|
|
1932
|
+
return (numOrNull(row && row.inputTokens) ?? 0) + (numOrNull(row && row.outputTokens) ?? 0);
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
function usageModelsForSession(session, ampThreads) {
|
|
1936
|
+
const allowed = sessionAmpThreadKeys(session);
|
|
1937
|
+
const byModel = new Map();
|
|
1938
|
+
for (const thread of ampThreads || []) {
|
|
1939
|
+
const id = thread && thread.id != null ? String(thread.id) : '';
|
|
1940
|
+
if (!allowed.has(id)) continue;
|
|
1941
|
+
for (const row of (thread && thread.models) || []) {
|
|
1942
|
+
if (!row || !row.model) continue;
|
|
1943
|
+
const key = String(row.model);
|
|
1944
|
+
const prev = byModel.get(key);
|
|
1945
|
+
if (!prev || usageModelTokenCount(row) >= usageModelTokenCount(prev)) {
|
|
1946
|
+
byModel.set(key, row);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
return [...byModel.values()];
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1791
1953
|
function sessionSpendIsFrozen(session) {
|
|
1792
1954
|
if (!session) return false;
|
|
1793
1955
|
if (session.spendSource === 'flag') return true;
|
|
1794
|
-
|
|
1795
|
-
if (!session.spendSource || session.spendSource === 'adapter' || session.spendSource === 'unreported') return false;
|
|
1796
|
-
return true;
|
|
1956
|
+
return session.spendSource === 'self-report' && reportedHasSpendNumbers(session);
|
|
1797
1957
|
}
|
|
1798
1958
|
|
|
1799
|
-
function
|
|
1800
|
-
const out =
|
|
1959
|
+
function existingSourceTotals(metrics) {
|
|
1960
|
+
const out = {};
|
|
1801
1961
|
for (const session of metrics.sessions || []) {
|
|
1802
|
-
for (const
|
|
1803
|
-
|
|
1962
|
+
for (const [id, total] of Object.entries(session.sourceTotals || {})) {
|
|
1963
|
+
out[id] = Math.max(numOrNull(out[id]) ?? 0, numOrNull(total) ?? 0);
|
|
1804
1964
|
}
|
|
1805
1965
|
}
|
|
1806
1966
|
return out;
|
|
@@ -1809,13 +1969,15 @@ function existingSourceRecords(metrics) {
|
|
|
1809
1969
|
function existingSourceIdSet(metrics) {
|
|
1810
1970
|
const ids = new Set();
|
|
1811
1971
|
for (const session of metrics.sessions || []) {
|
|
1812
|
-
for (const
|
|
1813
|
-
if (src && src.id != null && src.id !== '') ids.add(String(src.id));
|
|
1814
|
-
}
|
|
1972
|
+
for (const id of session.sourceIds || []) ids.add(String(id));
|
|
1815
1973
|
}
|
|
1816
1974
|
return ids;
|
|
1817
1975
|
}
|
|
1818
1976
|
|
|
1977
|
+
function existingSessionThreadIds(metrics) {
|
|
1978
|
+
return [...new Set((metrics.sessions || []).map((session) => session && session.threadId).filter(Boolean).map(String))];
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1819
1981
|
function lastNonArchiverSession(sessions) {
|
|
1820
1982
|
const list = Array.isArray(sessions) ? sessions : [];
|
|
1821
1983
|
for (let i = list.length - 1; i >= 0; i -= 1) {
|
|
@@ -1851,6 +2013,63 @@ function uniqueSourceModels(sources) {
|
|
|
1851
2013
|
return seen;
|
|
1852
2014
|
}
|
|
1853
2015
|
|
|
2016
|
+
function sessionTotalsFromModels(byModel) {
|
|
2017
|
+
const totals = { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null, costUsdEstimated: null };
|
|
2018
|
+
for (const row of byModel || []) {
|
|
2019
|
+
for (const key of Object.keys(totals)) totals[key] = addNullable(totals[key], numOrNull(row[key]));
|
|
2020
|
+
}
|
|
2021
|
+
totals.costUsdEstimated = roundUsd4(totals.costUsdEstimated);
|
|
2022
|
+
return totals;
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
function mergeModelRows(current, incoming) {
|
|
2026
|
+
const merged = new Map();
|
|
2027
|
+
for (const raw of [...(current || []), ...(incoming || [])]) {
|
|
2028
|
+
if (!raw) continue;
|
|
2029
|
+
const model = raw.model == null || raw.model === '' ? null : String(raw.model);
|
|
2030
|
+
const key = `${raw.platform || ''}::${model || ''}`;
|
|
2031
|
+
const row = merged.get(key) || {
|
|
2032
|
+
model,
|
|
2033
|
+
platform: raw.platform || null,
|
|
2034
|
+
inputTokens: null,
|
|
2035
|
+
outputTokens: null,
|
|
2036
|
+
totalTokens: null,
|
|
2037
|
+
costUsd: null,
|
|
2038
|
+
costUsdEstimated: null,
|
|
2039
|
+
};
|
|
2040
|
+
for (const field of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd']) {
|
|
2041
|
+
row[field] = addNullable(row[field], numOrNull(raw[field]));
|
|
2042
|
+
}
|
|
2043
|
+
row.costUsdEstimated = roundUsd4(addNullable(row.costUsdEstimated, numOrNull(raw.costUsdEstimated)));
|
|
2044
|
+
if (raw.costSource) row.costSource = raw.costSource;
|
|
2045
|
+
merged.set(key, row);
|
|
2046
|
+
}
|
|
2047
|
+
return [...merged.values()];
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
function compactCollectedDelta(session, sources) {
|
|
2051
|
+
const adjusted = [];
|
|
2052
|
+
for (const source of sources || []) {
|
|
2053
|
+
if (!source || source.id == null) continue;
|
|
2054
|
+
const previous = numOrNull(session.sourceTotals && session.sourceTotals[source.id]);
|
|
2055
|
+
const next = numOrNull(source.totalTokens);
|
|
2056
|
+
if (previous == null || next == null) {
|
|
2057
|
+
adjusted.push(source);
|
|
2058
|
+
continue;
|
|
2059
|
+
}
|
|
2060
|
+
const delta = Math.max(0, next - previous);
|
|
2061
|
+
if (delta === 0) continue;
|
|
2062
|
+
const ratio = next > 0 ? delta / next : 0;
|
|
2063
|
+
const copy = { ...source };
|
|
2064
|
+
for (const field of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated']) {
|
|
2065
|
+
const value = numOrNull(copy[field]);
|
|
2066
|
+
if (value != null) copy[field] = field.includes('Usd') ? roundUsd4(value * ratio) : Math.round(value * ratio);
|
|
2067
|
+
}
|
|
2068
|
+
adjusted.push(copy);
|
|
2069
|
+
}
|
|
2070
|
+
return adjusted;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
1854
2073
|
function rankSources(sources) {
|
|
1855
2074
|
return [...sources].sort((a, b) => {
|
|
1856
2075
|
const ta = a.totalTokens ?? 0;
|
|
@@ -1922,7 +2141,15 @@ function ampThreadTotals(threads) {
|
|
|
1922
2141
|
if (row && row.model && !models.includes(row.model)) models.push(row.model);
|
|
1923
2142
|
}
|
|
1924
2143
|
}
|
|
1925
|
-
|
|
2144
|
+
let inputTokens = null;
|
|
2145
|
+
let outputTokens = null;
|
|
2146
|
+
let totalTokens = null;
|
|
2147
|
+
for (const thread of list) {
|
|
2148
|
+
inputTokens = addNullable(inputTokens, numOrNull(thread && thread.inputTokens));
|
|
2149
|
+
outputTokens = addNullable(outputTokens, numOrNull(thread && thread.outputTokens));
|
|
2150
|
+
totalTokens = addNullable(totalTokens, numOrNull(thread && thread.totalTokens));
|
|
2151
|
+
}
|
|
2152
|
+
return { costUsd, agentMode, models, inputTokens, outputTokens, totalTokens };
|
|
1926
2153
|
}
|
|
1927
2154
|
|
|
1928
2155
|
function resolveSessionSpend(opts, reported, sources, extra = {}) {
|
|
@@ -1936,9 +2163,9 @@ function resolveSessionSpend(opts, reported, sources, extra = {}) {
|
|
|
1936
2163
|
const flagTotal = numOrNull(flags.totalTokens);
|
|
1937
2164
|
const flagCost = numOrNull(flags.costUsd);
|
|
1938
2165
|
const flagCredits = numOrNull(flags.ampCredits);
|
|
1939
|
-
const inputTokens = firstNonNull(flagInput, self.inputTokens, fromSources.inputTokens);
|
|
1940
|
-
const outputTokens = firstNonNull(flagOutput, self.outputTokens, fromSources.outputTokens);
|
|
1941
|
-
let totalTokens = firstNonNull(flagTotal, self.totalTokens, fromSources.totalTokens);
|
|
2166
|
+
const inputTokens = firstNonNull(flagInput, self.inputTokens, fromAmp.inputTokens, fromSources.inputTokens);
|
|
2167
|
+
const outputTokens = firstNonNull(flagOutput, self.outputTokens, fromAmp.outputTokens, fromSources.outputTokens);
|
|
2168
|
+
let totalTokens = firstNonNull(flagTotal, self.totalTokens, fromAmp.totalTokens, fromSources.totalTokens);
|
|
1942
2169
|
if (totalTokens == null && (inputTokens != null || outputTokens != null)) {
|
|
1943
2170
|
totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
|
|
1944
2171
|
}
|
|
@@ -1994,65 +2221,132 @@ function sessionTotalsFromSources(sources) {
|
|
|
1994
2221
|
function runCollectSpend(metrics, windowStart, windowEnd, extra = {}) {
|
|
1995
2222
|
try {
|
|
1996
2223
|
return collectSpend({
|
|
1997
|
-
cwd: process.cwd(),
|
|
2224
|
+
cwd: extra.cwd || process.cwd(),
|
|
1998
2225
|
windowStart,
|
|
1999
2226
|
windowEnd,
|
|
2000
2227
|
existingSourceIds: existingSourceIdSet(metrics),
|
|
2001
|
-
|
|
2002
|
-
env: process.env,
|
|
2003
|
-
homedir: process.env.HOME,
|
|
2228
|
+
existingSourceTotals: existingSourceTotals(metrics),
|
|
2229
|
+
env: extra.env || process.env,
|
|
2230
|
+
homedir: extra.homedir || process.env.HOME,
|
|
2004
2231
|
platforms: extra.platforms,
|
|
2005
2232
|
ampThreadId: extra.ampThreadId,
|
|
2006
2233
|
ampCli: extra.ampCli === true,
|
|
2007
2234
|
cursorConversationId: extra.cursorConversationId,
|
|
2008
2235
|
exportAmpThread: extra.exportAmpThread,
|
|
2009
2236
|
usageAmpThread: extra.usageAmpThread,
|
|
2237
|
+
listRecentAmpThreads: extra.listRecentAmpThreads,
|
|
2238
|
+
listAmpThreads: extra.listAmpThreads,
|
|
2239
|
+
collectAll: extra.collectAll === true,
|
|
2240
|
+
existingThreadIds: existingSessionThreadIds(metrics),
|
|
2241
|
+
rebillThreadId: extra.rebillThreadId,
|
|
2010
2242
|
});
|
|
2011
2243
|
} catch {
|
|
2012
|
-
return { sources: [], byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
|
|
2244
|
+
return { sources: [], ids: [], totals: {}, byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
function leftoverCollectPlatforms(session, collectAll, ampThreadId) {
|
|
2249
|
+
if (collectAll) return undefined;
|
|
2250
|
+
if (session.platform === 'amp' || session.platform === 'cursor' || session.platform === 'claude') {
|
|
2251
|
+
if (session.platform === 'amp' && !ampThreadId) return [];
|
|
2252
|
+
return [session.platform];
|
|
2253
|
+
}
|
|
2254
|
+
if (session.platform == null && ampThreadId) return ['amp'];
|
|
2255
|
+
return [];
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
function resyncLeftoverSessionSpend(session, priorCost, priorSource) {
|
|
2259
|
+
const fromSources = sessionTotalsFromModels(session.byModel || []);
|
|
2260
|
+
session.inputTokens = fromSources.inputTokens;
|
|
2261
|
+
session.outputTokens = fromSources.outputTokens;
|
|
2262
|
+
let totalTokens = fromSources.totalTokens;
|
|
2263
|
+
if (totalTokens == null && (fromSources.inputTokens != null || fromSources.outputTokens != null)) {
|
|
2264
|
+
totalTokens = (fromSources.inputTokens ?? 0) + (fromSources.outputTokens ?? 0);
|
|
2265
|
+
}
|
|
2266
|
+
session.totalTokens = totalTokens;
|
|
2267
|
+
if (fromSources.costUsd == null && priorCost != null) {
|
|
2268
|
+
session.costUsd = priorCost;
|
|
2269
|
+
}
|
|
2270
|
+
if (priorSource === 'amp-usage' && numOrNull(session.costUsd) != null) {
|
|
2271
|
+
session.spendSource = 'amp-usage';
|
|
2013
2272
|
}
|
|
2014
2273
|
}
|
|
2015
2274
|
|
|
2016
2275
|
function attachLeftoverSources(metrics, session, leftoverEnd, extra = {}) {
|
|
2017
2276
|
if (!session || !session.endedAt || !leftoverEnd) return 0;
|
|
2018
2277
|
const exclusiveEnd = extra.exclusiveEnd === true;
|
|
2278
|
+
const collectAll = extra.collect === true;
|
|
2279
|
+
const ampThreadId = leftoverAmpThreadId(session);
|
|
2280
|
+
const platforms = leftoverCollectPlatforms(session, collectAll, ampThreadId);
|
|
2281
|
+
if (!collectAll && (!Array.isArray(platforms) || platforms.length === 0)) return 0;
|
|
2282
|
+
const collectAmp = collectAll || (Array.isArray(platforms) && platforms.includes('amp'));
|
|
2283
|
+
const collectCursor = collectAll || (Array.isArray(platforms) && platforms.includes('cursor'));
|
|
2019
2284
|
const collected = runCollectSpend(metrics, session.endedAt, leftoverEnd, {
|
|
2020
|
-
platforms
|
|
2021
|
-
ampThreadId
|
|
2022
|
-
|
|
2285
|
+
platforms,
|
|
2286
|
+
...(ampThreadId ? { ampThreadId } : {}),
|
|
2287
|
+
listRecentAmpThreads: false,
|
|
2288
|
+
ampCli: extra.ampCli === true || collectAmp,
|
|
2023
2289
|
cursorConversationId: extra.cursorConversationId
|
|
2024
|
-
|| (session.platform === 'cursor' ? session.threadId : undefined),
|
|
2290
|
+
|| (collectCursor && session.platform === 'cursor' ? session.threadId : undefined),
|
|
2291
|
+
exportAmpThread: extra.exportAmpThread,
|
|
2292
|
+
usageAmpThread: extra.usageAmpThread,
|
|
2293
|
+
listAmpThreads: extra.listAmpThreads,
|
|
2294
|
+
env: extra.env,
|
|
2295
|
+
cwd: extra.cwd,
|
|
2296
|
+
homedir: extra.homedir,
|
|
2297
|
+
rebillThreadId: ampThreadId,
|
|
2025
2298
|
});
|
|
2026
|
-
|
|
2299
|
+
let incoming = (collected.sources || []).filter((src) => leftoverTimestampInWindow(
|
|
2027
2300
|
src.at,
|
|
2028
2301
|
session.endedAt,
|
|
2029
2302
|
leftoverEnd,
|
|
2030
2303
|
exclusiveEnd,
|
|
2031
2304
|
));
|
|
2305
|
+
if (ampThreadId) {
|
|
2306
|
+
const prefix = `${ampThreadId}:`;
|
|
2307
|
+
incoming = incoming.filter((src) => {
|
|
2308
|
+
if (src && src.platform === 'amp') return String(src.id || '').startsWith(prefix);
|
|
2309
|
+
return true;
|
|
2310
|
+
});
|
|
2311
|
+
}
|
|
2032
2312
|
if (!incoming.length) return 0;
|
|
2033
|
-
const
|
|
2313
|
+
const beforeIds = new Set(session.sourceIds || []);
|
|
2034
2314
|
if (sessionSpendIsFrozen(session)) {
|
|
2035
|
-
|
|
2036
|
-
|
|
2315
|
+
const delta = compactCollectedDelta(session, incoming);
|
|
2316
|
+
session.sourceIds = [...new Set([...(session.sourceIds || []), ...incoming.map((source) => String(source.id))])];
|
|
2317
|
+
session.sourceTotals = { ...(session.sourceTotals || {}) };
|
|
2318
|
+
for (const source of incoming) session.sourceTotals[source.id] = Math.max(numOrNull(session.sourceTotals[source.id]) ?? 0, numOrNull(source.totalTokens) ?? 0);
|
|
2319
|
+
session.byModel = mergeModelRows(session.byModel, compactModelRows(delta));
|
|
2320
|
+
const uniqueModels = uniqueSourceModels(session.byModel);
|
|
2037
2321
|
if (uniqueModels.length > 1) session.models = uniqueModels;
|
|
2038
2322
|
} else {
|
|
2039
|
-
|
|
2323
|
+
const priorCost = numOrNull(session.costUsd);
|
|
2324
|
+
const priorSource = session.spendSource;
|
|
2325
|
+
applyCollectedSessionFields(session, incoming, session.model, {}, {
|
|
2040
2326
|
model: session.model,
|
|
2041
2327
|
platform: session.platform,
|
|
2042
|
-
}, { ampThreads: collected.ampThreads });
|
|
2328
|
+
}, { ampThreads: Array.isArray(extra.ampThreads) ? extra.ampThreads : collected.ampThreads });
|
|
2329
|
+
resyncLeftoverSessionSpend(session, priorCost, priorSource);
|
|
2043
2330
|
}
|
|
2044
|
-
return incoming.length;
|
|
2331
|
+
return incoming.filter((source) => !beforeIds.has(String(source.id))).length;
|
|
2045
2332
|
}
|
|
2046
2333
|
|
|
2047
2334
|
function applyCollectedSessionFields(session, sources, resolvedModel, opts, reported, extra = {}) {
|
|
2048
|
-
|
|
2049
|
-
const
|
|
2335
|
+
const incoming = sources || [];
|
|
2336
|
+
const delta = compactCollectedDelta(session, incoming);
|
|
2337
|
+
session.sourceIds = [...new Set([...(session.sourceIds || []), ...incoming.map((source) => String(source.id))])];
|
|
2338
|
+
session.sourceTotals = { ...(session.sourceTotals || {}) };
|
|
2339
|
+
for (const source of incoming) {
|
|
2340
|
+
session.sourceTotals[source.id] = Math.max(numOrNull(session.sourceTotals[source.id]) ?? 0, numOrNull(source.totalTokens) ?? 0);
|
|
2341
|
+
}
|
|
2342
|
+
session.byModel = mergeModelRows(session.byModel, compactModelRows(delta));
|
|
2343
|
+
const uniqueModels = uniqueSourceModels(session.byModel);
|
|
2050
2344
|
const fromAmp = ampThreadTotals(extra.ampThreads);
|
|
2051
2345
|
for (const model of fromAmp.models) {
|
|
2052
2346
|
if (model && !uniqueModels.includes(model)) uniqueModels.push(model);
|
|
2053
2347
|
}
|
|
2054
2348
|
if (uniqueModels.length > 1) session.models = uniqueModels;
|
|
2055
|
-
const sourceModel = primaryModelFromSources(
|
|
2349
|
+
const sourceModel = primaryModelFromSources(incoming);
|
|
2056
2350
|
if (sourceModel) {
|
|
2057
2351
|
session.model = sourceModel;
|
|
2058
2352
|
} else {
|
|
@@ -2060,8 +2354,8 @@ function applyCollectedSessionFields(session, sources, resolvedModel, opts, repo
|
|
|
2060
2354
|
|| fromAmp.models[0]
|
|
2061
2355
|
|| null;
|
|
2062
2356
|
}
|
|
2063
|
-
if (!session.platform) session.platform = primaryPlatformFromSources(
|
|
2064
|
-
const spend = resolveSessionSpend(opts, reported,
|
|
2357
|
+
if (!session.platform) session.platform = primaryPlatformFromSources(incoming) || null;
|
|
2358
|
+
const spend = resolveSessionSpend(opts, reported, incoming, extra);
|
|
2065
2359
|
session.inputTokens = spend.inputTokens;
|
|
2066
2360
|
session.outputTokens = spend.outputTokens;
|
|
2067
2361
|
session.totalTokens = spend.totalTokens;
|
|
@@ -2070,17 +2364,43 @@ function applyCollectedSessionFields(session, sources, resolvedModel, opts, repo
|
|
|
2070
2364
|
session.costUsdEstimated = spend.costUsdEstimated;
|
|
2071
2365
|
session.spendSource = spend.spendSource;
|
|
2072
2366
|
if (spend.agentMode) session.agentMode = spend.agentMode;
|
|
2073
|
-
const usageModels =
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2367
|
+
const usageModels = usageModelsForSession(session, extra.ampThreads);
|
|
2368
|
+
if (usageModels.length || Array.isArray(session.usageModels)) {
|
|
2369
|
+
session.usageModels = usageModels;
|
|
2370
|
+
}
|
|
2371
|
+
if (session.platform === 'amp' && usageModels.length) {
|
|
2372
|
+
session.byModel = usageModels.map((row) => ({
|
|
2373
|
+
model: row.model,
|
|
2374
|
+
platform: 'amp',
|
|
2375
|
+
inputTokens: numOrNull(row.inputTokens),
|
|
2376
|
+
outputTokens: numOrNull(row.outputTokens),
|
|
2377
|
+
totalTokens: numOrNull(row.totalTokens) ?? ((numOrNull(row.inputTokens) ?? 0) + (numOrNull(row.outputTokens) ?? 0)),
|
|
2378
|
+
costUsd: numOrNull(row.costUsd),
|
|
2379
|
+
costUsdEstimated: null,
|
|
2380
|
+
...(numOrNull(row.costUsd) != null ? { costSource: 'amp-usage' } : {}),
|
|
2381
|
+
}));
|
|
2382
|
+
}
|
|
2383
|
+
if (session.costUsd == null) {
|
|
2384
|
+
for (const row of session.byModel) {
|
|
2385
|
+
if (row.costUsd != null) continue;
|
|
2386
|
+
const described = describeClaudeCostEstimate({
|
|
2387
|
+
model: String(row.model || '').replace(/^Claude\s+/i, 'claude-').replaceAll(' ', '-').toLowerCase(),
|
|
2388
|
+
inputTokens: row.inputTokens,
|
|
2389
|
+
outputTokens: row.outputTokens,
|
|
2390
|
+
});
|
|
2391
|
+
if (described) {
|
|
2392
|
+
row.costUsdEstimated = described.usd;
|
|
2393
|
+
row.costSource = described.costSource;
|
|
2394
|
+
}
|
|
2077
2395
|
}
|
|
2396
|
+
session.costUsdEstimated = sessionTotalsFromModels(session.byModel).costUsdEstimated;
|
|
2397
|
+
} else {
|
|
2398
|
+
session.costUsdEstimated = null;
|
|
2078
2399
|
}
|
|
2079
|
-
if (usageModels.length) session.usageModels = usageModels;
|
|
2080
2400
|
}
|
|
2081
2401
|
|
|
2082
2402
|
function sessionTotalsLookOverridden(session) {
|
|
2083
|
-
const fromSources =
|
|
2403
|
+
const fromSources = sessionTotalsFromModels(session.byModel || []);
|
|
2084
2404
|
return ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'].some((key) => {
|
|
2085
2405
|
const sessionVal = numOrNull(session[key]);
|
|
2086
2406
|
const sourceVal = numOrNull(fromSources[key]);
|
|
@@ -2111,16 +2431,16 @@ function metricsBackfillFile(filePath, changeName) {
|
|
|
2111
2431
|
});
|
|
2112
2432
|
const incoming = collected.sources || [];
|
|
2113
2433
|
if (!incoming.length) return { filePath, added: 0 };
|
|
2114
|
-
const merged = [...(last.sources || []), ...incoming];
|
|
2115
2434
|
if (sessionSpendIsFrozen(last)) {
|
|
2116
|
-
last.
|
|
2117
|
-
const uniqueModels = uniqueSourceModels(merged);
|
|
2118
|
-
if (uniqueModels.length > 1) last.models = uniqueModels;
|
|
2435
|
+
applyCollectedSessionFields(last, incoming, last.model, {}, last, { ampThreads: collected.ampThreads });
|
|
2119
2436
|
} else {
|
|
2120
|
-
|
|
2437
|
+
const priorCost = numOrNull(last.costUsd);
|
|
2438
|
+
const priorSource = last.spendSource;
|
|
2439
|
+
applyCollectedSessionFields(last, incoming, last.model, {}, {
|
|
2121
2440
|
model: last.model,
|
|
2122
2441
|
platform: last.platform,
|
|
2123
2442
|
}, { ampThreads: collected.ampThreads });
|
|
2443
|
+
resyncLeftoverSessionSpend(last, priorCost, priorSource);
|
|
2124
2444
|
}
|
|
2125
2445
|
metrics.updatedAt = nowIso;
|
|
2126
2446
|
recomputeMetricsAggregates(metrics);
|
|
@@ -2185,27 +2505,19 @@ function recomputeSpendMaps(metrics) {
|
|
|
2185
2505
|
byModel.set(key, row);
|
|
2186
2506
|
}
|
|
2187
2507
|
|
|
2188
|
-
function addSourceRow(src) {
|
|
2189
|
-
const nums = spendTuple(src);
|
|
2190
|
-
if (src.platform && byPlatform[src.platform]) {
|
|
2191
|
-
addSpendNums(byPlatform[src.platform], nums);
|
|
2192
|
-
const label = adapterSourceName(src.platform, src.via);
|
|
2193
|
-
if (label) byPlatform[src.platform].source = label;
|
|
2194
|
-
}
|
|
2195
|
-
addModelRow(src.model, src.platform, nums);
|
|
2196
|
-
}
|
|
2197
|
-
|
|
2198
2508
|
for (const session of metrics.sessions || []) {
|
|
2199
|
-
const sources = session.sources || [];
|
|
2200
|
-
if (sources.length > 0) {
|
|
2201
|
-
for (const src of sources) addSourceRow(src);
|
|
2202
|
-
continue;
|
|
2203
|
-
}
|
|
2204
2509
|
const sessionNums = spendTuple(session);
|
|
2205
2510
|
if (session.platform && byPlatform[session.platform]) {
|
|
2206
2511
|
addSpendNums(byPlatform[session.platform], sessionNums);
|
|
2512
|
+
if ((session.sourceIds || []).length) {
|
|
2513
|
+
byPlatform[session.platform].source = adapterSourceName(session.platform, session.platform === 'amp' ? 'amp-cli' : null);
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
if ((session.byModel || []).length) {
|
|
2517
|
+
for (const row of session.byModel) addModelRow(row.model, row.platform || session.platform, spendTuple(row));
|
|
2518
|
+
} else {
|
|
2519
|
+
addModelRow(session.model, session.platform, sessionNums);
|
|
2207
2520
|
}
|
|
2208
|
-
addModelRow(session.model, session.platform, sessionNums);
|
|
2209
2521
|
}
|
|
2210
2522
|
for (const key of Object.keys(byPlatform)) {
|
|
2211
2523
|
byPlatform[key].costUsdEstimated = roundUsd4(byPlatform[key].costUsdEstimated);
|
|
@@ -2218,7 +2530,7 @@ function recomputeSpendMaps(metrics) {
|
|
|
2218
2530
|
}
|
|
2219
2531
|
|
|
2220
2532
|
function recomputeMetricsAggregates(metrics) {
|
|
2221
|
-
|
|
2533
|
+
normalizeMetricsV2(metrics);
|
|
2222
2534
|
const phases = {};
|
|
2223
2535
|
const totals = { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 };
|
|
2224
2536
|
const spend = emptySpendTotals();
|
|
@@ -2259,14 +2571,7 @@ function recomputeMetricsAggregates(metrics) {
|
|
|
2259
2571
|
: laterTimestamp(phase.endedAt, session.endedAt);
|
|
2260
2572
|
}
|
|
2261
2573
|
for (const spendKey of METRICS_SPEND_KEYS) {
|
|
2262
|
-
|
|
2263
|
-
if ((session.sources || []).length > 0) {
|
|
2264
|
-
for (const src of session.sources) {
|
|
2265
|
-
value = addNullable(value, numOrNull(src[spendKey]));
|
|
2266
|
-
}
|
|
2267
|
-
} else {
|
|
2268
|
-
value = sessionFieldOrSources(session, spendKey);
|
|
2269
|
-
}
|
|
2574
|
+
const value = sessionFieldOrSources(session, spendKey);
|
|
2270
2575
|
phase[spendKey] = addNullable(phase[spendKey], value);
|
|
2271
2576
|
spend[spendKey] = addNullable(spend[spendKey], value);
|
|
2272
2577
|
}
|
|
@@ -2303,7 +2608,7 @@ function metricsRecordSessionStart(projectDir, changeName, role, client = {}) {
|
|
|
2303
2608
|
const metrics = loadMetricsFile(filePath, changeName, nowIso);
|
|
2304
2609
|
metrics.pending = {
|
|
2305
2610
|
startedAt: nowIso,
|
|
2306
|
-
role: role || '',
|
|
2611
|
+
role: canonicalRole(role) || role || '',
|
|
2307
2612
|
platform: client.platform || null,
|
|
2308
2613
|
threadId: client.threadId || null,
|
|
2309
2614
|
clientSource: client.source || null,
|
|
@@ -2319,18 +2624,29 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
|
|
|
2319
2624
|
const nowIso = nowUtcIso();
|
|
2320
2625
|
const metrics = loadMetricsFile(filePath, fields.changeName, nowIso);
|
|
2321
2626
|
const lastClosed = (metrics.sessions || [])[(metrics.sessions || []).length - 1];
|
|
2322
|
-
const leftoverEnd = (metrics.pending && metrics.pending.startedAt)
|
|
2323
|
-
? metrics.pending.startedAt
|
|
2324
|
-
: leftoverGraceEnd(lastClosed && lastClosed.endedAt);
|
|
2627
|
+
const leftoverEnd = sessionLeftoverEnd(lastClosed, metrics.pending && metrics.pending.startedAt);
|
|
2325
2628
|
attachLeftoverSources(metrics, lastClosed, leftoverEnd, {
|
|
2326
2629
|
exclusiveEnd: Boolean(metrics.pending && metrics.pending.startedAt),
|
|
2630
|
+
collect: opts.collect === true,
|
|
2327
2631
|
});
|
|
2328
|
-
|
|
2632
|
+
const explicitStartedAt = isoOrNull(opts.startedAt);
|
|
2633
|
+
const pendingStartedAt = isoOrNull(metrics.pending && metrics.pending.startedAt);
|
|
2634
|
+
const startedAt = explicitStartedAt || pendingStartedAt || isoOrNull(lastClosed && lastClosed.endedAt) || isoOrNull(metrics.createdAt) || nowIso;
|
|
2635
|
+
if (!explicitStartedAt && !pendingStartedAt) console.error('Warning: persist without restore; collecting from the last closed session boundary.');
|
|
2329
2636
|
const reported = opts.reported || fields.metrics || emptyMetricsFields();
|
|
2330
2637
|
const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
|
|
2331
|
-
const windowStart = collectWindowStart(metrics, {
|
|
2638
|
+
const windowStart = collectWindowStart(metrics, {
|
|
2639
|
+
explicitStartedAt,
|
|
2640
|
+
lastEndedAt: lastClosed && lastClosed.endedAt,
|
|
2641
|
+
});
|
|
2332
2642
|
const pending = metrics.pending || {};
|
|
2333
2643
|
const platform = opts.platform || pending.platform || null;
|
|
2644
|
+
const cursorThreadId = platform === 'cursor'
|
|
2645
|
+
? (pending.threadId || String(process.env.CURSOR_CONVERSATION_ID || '').trim() || null)
|
|
2646
|
+
: null;
|
|
2647
|
+
const ampThreadId = platform === 'amp'
|
|
2648
|
+
? (opts.ampThreadId || pending.threadId || ampThreadIdFromEnv(process.env) || null)
|
|
2649
|
+
: null;
|
|
2334
2650
|
const collectAll = opts.collect === true;
|
|
2335
2651
|
const platforms = collectAll ? undefined : (platform ? [platform] : []);
|
|
2336
2652
|
const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
|
|
@@ -2338,9 +2654,11 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
|
|
|
2338
2654
|
const collected = shouldCollect
|
|
2339
2655
|
? runCollectSpend(metrics, windowStart, endedAt, {
|
|
2340
2656
|
platforms,
|
|
2341
|
-
ampThreadId
|
|
2657
|
+
ampThreadId,
|
|
2342
2658
|
ampCli: collectAll || platform === 'amp',
|
|
2343
|
-
cursorConversationId:
|
|
2659
|
+
cursorConversationId: cursorThreadId || undefined,
|
|
2660
|
+
listRecentAmpThreads: false,
|
|
2661
|
+
collectAll,
|
|
2344
2662
|
})
|
|
2345
2663
|
: { sources: [] };
|
|
2346
2664
|
const startedMs = parseFlexibleIso(startedAt);
|
|
@@ -2359,9 +2677,11 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
|
|
|
2359
2677
|
agentId: fields.agentId || 'none',
|
|
2360
2678
|
model: resolvedModel || null,
|
|
2361
2679
|
platform: opts.platform || pending.platform || null,
|
|
2362
|
-
threadId:
|
|
2680
|
+
threadId: cursorThreadId || ampThreadId || pending.threadId || null,
|
|
2363
2681
|
tasks: fields.tasks || null,
|
|
2364
|
-
|
|
2682
|
+
sourceIds: [],
|
|
2683
|
+
sourceTotals: {},
|
|
2684
|
+
byModel: [],
|
|
2365
2685
|
inputTokens: null,
|
|
2366
2686
|
outputTokens: null,
|
|
2367
2687
|
totalTokens: null,
|
|
@@ -2371,22 +2691,6 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
|
|
|
2371
2691
|
spendSource: 'unreported',
|
|
2372
2692
|
};
|
|
2373
2693
|
applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported, collected);
|
|
2374
|
-
if (!session.startedAt) {
|
|
2375
|
-
let earliest = null;
|
|
2376
|
-
for (const src of session.sources || []) {
|
|
2377
|
-
const at = isoOrNull(src.at);
|
|
2378
|
-
if (!at) continue;
|
|
2379
|
-
earliest = earliest == null ? at : earlierTimestamp(earliest, at);
|
|
2380
|
-
}
|
|
2381
|
-
if (earliest) {
|
|
2382
|
-
session.startedAt = earliest;
|
|
2383
|
-
const fromMs = parseFlexibleIso(session.startedAt);
|
|
2384
|
-
const toMs = parseFlexibleIso(session.endedAt);
|
|
2385
|
-
session.durationMs = Number.isFinite(fromMs) && Number.isFinite(toMs)
|
|
2386
|
-
? Math.max(0, toMs - fromMs)
|
|
2387
|
-
: null;
|
|
2388
|
-
}
|
|
2389
|
-
}
|
|
2390
2694
|
metrics.sessions.push(session);
|
|
2391
2695
|
metrics.pending = null;
|
|
2392
2696
|
metrics.updatedAt = nowIso;
|
|
@@ -2417,12 +2721,15 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
|
|
|
2417
2721
|
const platforms = collectAll ? undefined : (platform ? [platform] : []);
|
|
2418
2722
|
const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
|
|
2419
2723
|
const ampThreadId = opts.ampThreadId || pending.threadId || ampThreadIdFromEnv(process.env) || null;
|
|
2724
|
+
const archiveWindowStart = collectWindowStart(metrics, {});
|
|
2420
2725
|
const collected = shouldCollect
|
|
2421
|
-
? runCollectSpend(metrics,
|
|
2726
|
+
? runCollectSpend(metrics, archiveWindowStart, endedAt, {
|
|
2422
2727
|
platforms,
|
|
2423
2728
|
ampThreadId,
|
|
2424
2729
|
ampCli: collectAll || platform === 'amp',
|
|
2425
2730
|
cursorConversationId: platform === 'cursor' ? (pending.threadId || ampThreadId) : undefined,
|
|
2731
|
+
listRecentAmpThreads: false,
|
|
2732
|
+
collectAll,
|
|
2426
2733
|
})
|
|
2427
2734
|
: { sources: [] };
|
|
2428
2735
|
const reported = dropStaleArchiveSelfReport(opts.reported, metrics.sessions);
|
|
@@ -2439,7 +2746,9 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
|
|
|
2439
2746
|
platform,
|
|
2440
2747
|
threadId: ampThreadId || null,
|
|
2441
2748
|
tasks: opts.tasks || null,
|
|
2442
|
-
|
|
2749
|
+
sourceIds: [],
|
|
2750
|
+
sourceTotals: {},
|
|
2751
|
+
byModel: [],
|
|
2443
2752
|
inputTokens: null,
|
|
2444
2753
|
outputTokens: null,
|
|
2445
2754
|
totalTokens: null,
|
|
@@ -2464,7 +2773,7 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
|
|
|
2464
2773
|
return filePath;
|
|
2465
2774
|
}
|
|
2466
2775
|
|
|
2467
|
-
function metricsPrepareArchiveStart(changeRoot, changeName, client = {}) {
|
|
2776
|
+
function metricsPrepareArchiveStart(changeRoot, changeName, client = {}, extra = {}) {
|
|
2468
2777
|
const filePath = join(changeRoot, 'metrics.json');
|
|
2469
2778
|
if (!existsSync(filePath)) return;
|
|
2470
2779
|
const nowIso = nowUtcIso();
|
|
@@ -2478,9 +2787,11 @@ function metricsPrepareArchiveStart(changeRoot, changeName, client = {}) {
|
|
|
2478
2787
|
clientSource: client.source || null,
|
|
2479
2788
|
};
|
|
2480
2789
|
}
|
|
2481
|
-
const
|
|
2482
|
-
|
|
2790
|
+
const previous = lastNonArchiverSession(metrics.sessions);
|
|
2791
|
+
const leftoverEnd = sessionLeftoverEnd(previous, metrics.pending && metrics.pending.startedAt);
|
|
2792
|
+
attachLeftoverSources(metrics, previous, leftoverEnd, {
|
|
2483
2793
|
exclusiveEnd: true,
|
|
2794
|
+
collect: extra.collect === true,
|
|
2484
2795
|
});
|
|
2485
2796
|
metrics.updatedAt = nowIso;
|
|
2486
2797
|
recomputeMetricsAggregates(metrics);
|
|
@@ -3876,7 +4187,7 @@ program
|
|
|
3876
4187
|
homedir: process.env.HOME,
|
|
3877
4188
|
platform: opts.platform,
|
|
3878
4189
|
});
|
|
3879
|
-
metricsPrepareArchiveStart(changeRoot, name, archiveClient);
|
|
4190
|
+
metricsPrepareArchiveStart(changeRoot, name, archiveClient, { collect: opts.collect === true });
|
|
3880
4191
|
mkdirSync(dirname(targetDir), { recursive: true });
|
|
3881
4192
|
renameSync(changeRoot, targetDir);
|
|
3882
4193
|
|
|
@@ -4367,6 +4678,8 @@ program
|
|
|
4367
4678
|
.command('metrics [change-name]')
|
|
4368
4679
|
.description('Show recorded session metrics for a change: time per phase, tokens, cost, roles, and models')
|
|
4369
4680
|
.option('--json', 'Print raw metrics.json', false)
|
|
4681
|
+
.option('--summary-json', 'Print compact aggregate JSON for dashboards', false)
|
|
4682
|
+
.option('--migrate', 'Rewrite metrics.json using the current schema without recomputing numbers', false)
|
|
4370
4683
|
.option('--collect', 'Backfill the last session from local spend adapters without adding a new session', false)
|
|
4371
4684
|
.action((changeName, opts) => {
|
|
4372
4685
|
const projectDir = process.cwd();
|
|
@@ -4423,6 +4736,29 @@ program
|
|
|
4423
4736
|
}
|
|
4424
4737
|
|
|
4425
4738
|
const metrics = loadMetricsFile(filePath, name, nowUtcIso());
|
|
4739
|
+
if (opts.migrate) {
|
|
4740
|
+
saveMetricsFile(filePath, metrics);
|
|
4741
|
+
if (!opts.json && !opts.summaryJson) {
|
|
4742
|
+
log.ok(`migrated: ${filePath.replace(`${projectDir}/`, '')}`);
|
|
4743
|
+
return;
|
|
4744
|
+
}
|
|
4745
|
+
}
|
|
4746
|
+
if (opts.summaryJson) {
|
|
4747
|
+
recomputeMetricsAggregates(metrics);
|
|
4748
|
+
const summary = {
|
|
4749
|
+
version: metrics.version,
|
|
4750
|
+
change: metrics.change,
|
|
4751
|
+
createdAt: metrics.createdAt,
|
|
4752
|
+
archivedAt: metrics.archivedAt,
|
|
4753
|
+
totals: metrics.totals,
|
|
4754
|
+
phases: metrics.phases,
|
|
4755
|
+
spend: metrics.spend,
|
|
4756
|
+
spendByPlatform: metrics.spendByPlatform,
|
|
4757
|
+
spendByModel: metrics.spendByModel,
|
|
4758
|
+
};
|
|
4759
|
+
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
|
4760
|
+
return;
|
|
4761
|
+
}
|
|
4426
4762
|
if (opts.json) {
|
|
4427
4763
|
process.stdout.write(`${JSON.stringify(metrics, null, 2)}\n`);
|
|
4428
4764
|
return;
|
|
@@ -4447,4 +4783,15 @@ if (isDirectCliRun()) {
|
|
|
4447
4783
|
program.parse();
|
|
4448
4784
|
}
|
|
4449
4785
|
|
|
4450
|
-
export {
|
|
4786
|
+
export {
|
|
4787
|
+
formatMetricsCostLine,
|
|
4788
|
+
resolveSessionSpend,
|
|
4789
|
+
canonicalRole,
|
|
4790
|
+
phaseForRole,
|
|
4791
|
+
recomputeMetricsAggregates,
|
|
4792
|
+
sessionSpendIsFrozen,
|
|
4793
|
+
attachLeftoverSources,
|
|
4794
|
+
runCollectSpend,
|
|
4795
|
+
normalizeMetricsV2,
|
|
4796
|
+
firstSpawnName,
|
|
4797
|
+
};
|