@danypops/jittor 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +8 -5
  2. package/extension/src/benchmark-tui.ts +4 -0
  3. package/extension/src/capabilities/codex-recovery.ts +127 -0
  4. package/extension/src/capabilities/http-headers.ts +5 -0
  5. package/extension/src/capabilities/local-run-telemetry.ts +104 -0
  6. package/extension/src/capabilities/provider-response-telemetry.ts +96 -0
  7. package/extension/src/footer.ts +10 -53
  8. package/extension/src/index.ts +92 -272
  9. package/extension/src/session-identity.ts +20 -0
  10. package/extension/src/tui.ts +20 -11
  11. package/package.json +1 -1
  12. package/src/adapters/sqlite-metric-store.ts +11 -2
  13. package/src/adapters/sqlite-session-identity-store.ts +45 -0
  14. package/src/cli-commands/benchmarks.ts +140 -0
  15. package/src/cli-commands/compaction.ts +17 -0
  16. package/src/cli-commands/context.ts +49 -0
  17. package/src/cli-commands/metrics.ts +296 -0
  18. package/src/cli-commands/op.ts +40 -0
  19. package/src/cli-commands/route-args.ts +15 -0
  20. package/src/cli-commands/router.ts +207 -0
  21. package/src/cli-commands/service-daemon.ts +72 -0
  22. package/src/cli-commands/session.ts +42 -0
  23. package/src/cli-commands/support.ts +33 -0
  24. package/src/cli.ts +42 -769
  25. package/src/constants.ts +7 -0
  26. package/src/daemon.ts +13 -3
  27. package/src/db.ts +15 -1
  28. package/src/operations/benchmark-operations.ts +12 -0
  29. package/src/operations/context-operations.ts +30 -0
  30. package/src/operations/metrics-operations.ts +77 -0
  31. package/src/operations/model-ranking-operations.ts +16 -0
  32. package/src/operations/router-operations.ts +19 -0
  33. package/src/operations/session-identity-operations.ts +15 -0
  34. package/src/operations/session-scope.ts +31 -0
  35. package/src/operations/types.ts +3 -0
  36. package/src/ports/metric-store.ts +2 -0
  37. package/src/ports/router-controller.ts +9 -9
  38. package/src/ports/session-identity-store.ts +5 -0
  39. package/src/providers/telemetry-sources.ts +2 -1
  40. package/src/router.ts +124 -67
  41. package/src/service.ts +60 -118
  42. package/src/session-identity-service.ts +55 -0
@@ -1,15 +1,26 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
3
  import { HUMAN_STATUS_MAX_SOURCES, HUMAN_TEXT_FIELD_MAX_CHARACTERS } from "../../src/constants.ts";
4
- import type { StoredMetricObservation } from "../../src/domain/metric.ts";
4
+ import type { MetricQuery, StoredMetricObservation } from "../../src/domain/metric.ts";
5
5
  import type { PolicyAction, Route } from "../../src/policy.ts";
6
6
  import type { RouterStatus } from "../../src/ports/router-controller.ts";
7
7
  import type { ProviderBudget } from "./footer.ts";
8
+ import { sessionSecretField } from "./session-identity.ts";
8
9
 
9
10
  export interface JittorPanelClient {
10
11
  call(operation: string, input: unknown): Promise<any>;
11
12
  }
12
13
 
14
+ export function providerBudgetMetricQuery(status: RouterStatus): MetricQuery | null {
15
+ switch (status.currentRoute?.provider) {
16
+ case "openai-codex": return { source: "codex-subscription", metric: "used-fraction", order: "desc", limit: 100 };
17
+ case "openrouter": return { source: "openrouter", order: "desc", limit: 20 };
18
+ case "anthropic": return { source: "anthropic", metric: "used-fraction", order: "desc", limit: 20 };
19
+ case "anthropic-vertex": return { source: "anthropic-vertex", metric: "used-fraction", order: "desc", limit: 20 };
20
+ default: return null;
21
+ }
22
+ }
23
+
13
24
  type PanelAction = "pause" | "resume" | "refresh" | "override" | "clear-override" | "close";
14
25
 
15
26
  function latest(rows: StoredMetricObservation[], predicate: (row: StoredMetricObservation) => boolean): StoredMetricObservation | undefined {
@@ -200,12 +211,9 @@ export function buildStatusView(status: RouterStatus, metrics: StoredMetricObser
200
211
  return lines;
201
212
  }
202
213
 
203
- async function snapshot(client: JittorPanelClient): Promise<{ status: RouterStatus; metrics: StoredMetricObservation[] }> {
204
- const status = await client.call("router.status", {}) as RouterStatus;
205
- const provider = status.currentRoute?.provider;
206
- const query = provider === "openai-codex"
207
- ? { source: "codex-subscription", metric: "used-fraction", order: "desc", limit: 100 }
208
- : provider === "openrouter" ? { source: "openrouter", order: "desc", limit: 20 } : null;
214
+ async function snapshot(client: JittorPanelClient, sessionId: string): Promise<{ status: RouterStatus; metrics: StoredMetricObservation[] }> {
215
+ const status = await client.call("router.status", { session_id: sessionId }) as RouterStatus;
216
+ const query = providerBudgetMetricQuery(status);
209
217
  const metrics = query ? await client.call("metrics.query", query) as StoredMetricObservation[] : [];
210
218
  return { status, metrics };
211
219
  }
@@ -219,8 +227,9 @@ async function chooseOverride(ctx: ExtensionCommandContext, routes: Route[]): Pr
219
227
  }
220
228
 
221
229
  export async function showJittorPanel(ctx: ExtensionCommandContext, client: JittorPanelClient): Promise<void> {
230
+ const session_id = ctx.sessionManager.getSessionId();
222
231
  for (;;) {
223
- const current = await snapshot(client);
232
+ const current = await snapshot(client, session_id);
224
233
  if (ctx.mode !== "tui") {
225
234
  ctx.ui.notify(buildStatusView(current.status, current.metrics).join("\n"), "info");
226
235
  return;
@@ -254,17 +263,17 @@ export async function showJittorPanel(ctx: ExtensionCommandContext, client: Jitt
254
263
  if (action === "refresh") { await client.call("telemetry.poll", {}); continue; }
255
264
  if (action === "pause" || action === "resume") {
256
265
  if (await ctx.ui.confirm(action === "pause" ? "Emergency-halt provider requests?" : "Release emergency halt?", "This changes provider-request enforcement. Use /jittor off to disable blocking entirely.")) {
257
- await client.call(action === "pause" ? "router.pause" : "router.resume", {});
266
+ await client.call(action === "pause" ? "router.pause" : "router.resume", { session_id, ...sessionSecretField(session_id) });
258
267
  }
259
268
  continue;
260
269
  }
261
270
  if (action === "clear-override") {
262
- if (await ctx.ui.confirm("Clear route override?", "Policy-controlled routing will resume.")) await client.call("router.clear_override", {});
271
+ if (await ctx.ui.confirm("Clear route override?", "Policy-controlled routing will resume.")) await client.call("router.clear_override", { session_id, ...sessionSecretField(session_id) });
263
272
  continue;
264
273
  }
265
274
  const route = await chooseOverride(ctx, current.status.availableRoutes);
266
275
  if (route && await ctx.ui.confirm("Apply route override?", `${routeText(route)} for one hour`)) {
267
- await client.call("router.override", { route, expiresAt: Date.now() + 60 * 60 * 1_000 });
276
+ await client.call("router.override", { route, expiresAt: Date.now() + 60 * 60 * 1_000, session_id, ...sessionSecretField(session_id) });
268
277
  }
269
278
  }
270
279
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/jittor",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Just-in-Time Token Optimizing Router for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package", "llm-router", "token-budget"],
@@ -34,7 +34,17 @@ export class SQLiteMetricStore implements MetricStore {
34
34
 
35
35
  record(input: MetricObservation): StoredMetricObservation {
36
36
  const observation = validateMetricObservation(input);
37
- const result = this.db.query(`
37
+ const result = this.insert(observation);
38
+ return this.get(Number(result.lastInsertRowid));
39
+ }
40
+
41
+ recordBatch(inputs: MetricObservation[]): StoredMetricObservation[] {
42
+ const observations = inputs.map((input) => validateMetricObservation(input));
43
+ return this.db.transaction((rows: typeof observations) => rows.map((observation) => this.get(Number(this.insert(observation).lastInsertRowid))))(observations);
44
+ }
45
+
46
+ private insert(observation: MetricObservation): { lastInsertRowid: number | bigint } {
47
+ return this.db.query(`
38
48
  INSERT INTO metric_observations (source, scope, metric, value, unit, observed_at, attributes)
39
49
  VALUES (?, ?, ?, ?, ?, ?, ?)
40
50
  `).run(
@@ -46,7 +56,6 @@ export class SQLiteMetricStore implements MetricStore {
46
56
  observation.observedAt,
47
57
  JSON.stringify(observation.attributes ?? {}),
48
58
  );
49
- return this.get(Number(result.lastInsertRowid));
50
59
  }
51
60
 
52
61
  query(filter: MetricQuery = {}): StoredMetricObservation[] {
@@ -0,0 +1,45 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { SESSION_IDENTITY_MAX_ROWS } from "../constants.ts";
3
+ import type { SessionIdentityRecord, SessionIdentityStore } from "../ports/session-identity-store.ts";
4
+
5
+ export class SQLiteSessionIdentityStore implements SessionIdentityStore {
6
+ constructor(private readonly db: Database) {}
7
+
8
+ find(sessionId: string): SessionIdentityRecord | undefined {
9
+ const row = this.db.query("SELECT session_id, secret_hash, registered_at, last_seen_at FROM session_identities WHERE session_id = ?").get(sessionId) as
10
+ | { session_id: string; secret_hash: string; registered_at: string; last_seen_at: string }
11
+ | null;
12
+ return row ? { sessionId: row.session_id, secretHash: row.secret_hash, registeredAt: row.registered_at, lastSeenAt: row.last_seen_at } : undefined;
13
+ }
14
+
15
+ upsert(record: SessionIdentityRecord): void {
16
+ this.db.transaction(() => {
17
+ this.evictOldestBeyondCap(record.sessionId);
18
+ this.db.query(`
19
+ INSERT INTO session_identities (session_id, secret_hash, registered_at, last_seen_at)
20
+ VALUES (?, ?, ?, ?)
21
+ ON CONFLICT(session_id) DO UPDATE SET secret_hash = excluded.secret_hash, registered_at = excluded.registered_at, last_seen_at = excluded.last_seen_at
22
+ `).run(record.sessionId, record.secretHash, record.registeredAt, record.lastSeenAt);
23
+ })();
24
+ }
25
+
26
+ remove(sessionId: string): void {
27
+ this.db.query("DELETE FROM session_identities WHERE session_id = ?").run(sessionId);
28
+ }
29
+
30
+ touch(sessionId: string, lastSeenAt: string): void {
31
+ this.db.query("UPDATE session_identities SET last_seen_at = ? WHERE session_id = ?").run(lastSeenAt, sessionId);
32
+ }
33
+
34
+ count(): number {
35
+ return (this.db.query("SELECT COUNT(*) AS count FROM session_identities").get() as { count: number }).count;
36
+ }
37
+
38
+ /** Bounds distinct registered session identities; evicts the least-recently-seen beyond the cap. */
39
+ private evictOldestBeyondCap(sessionId: string): void {
40
+ const exists = this.db.query("SELECT 1 FROM session_identities WHERE session_id = ?").get(sessionId);
41
+ if (exists) return;
42
+ if (this.count() < SESSION_IDENTITY_MAX_ROWS) return;
43
+ this.db.exec("DELETE FROM session_identities WHERE session_id = (SELECT session_id FROM session_identities ORDER BY last_seen_at ASC LIMIT 1)");
44
+ }
45
+ }
@@ -0,0 +1,140 @@
1
+ import {
2
+ BENCHMARK_MAX_QUERY_LIMIT,
3
+ MODEL_RANKING_DEFAULT_CONTEXT_WEIGHT,
4
+ MODEL_RANKING_DEFAULT_COST_WEIGHT,
5
+ MODEL_RANKING_DEFAULT_LATENCY_WEIGHT,
6
+ MODEL_RANKING_DEFAULT_QUALITY_WEIGHT,
7
+ MODEL_RANKING_DEFAULT_RELIABILITY_WEIGHT,
8
+ MODEL_RANKING_MAX_SOURCES,
9
+ } from "../constants.ts";
10
+ import type { BenchmarkQuery, BenchmarkQueryResult, BenchmarkRefreshResult } from "../domain/benchmark.ts";
11
+ import type { ModelRecommendationInput } from "../domain/model-ranking-service.ts";
12
+ import type { ModelCandidate, ModelRankingResult, ScopeAuthority, UtilityWeights } from "../domain/model-ranking.ts";
13
+ import { TASK_DOMAINS, TASK_TYPES, type ModelTaskDomain, type ModelTaskType } from "../domain/model-observation.ts";
14
+ import { humanField, type CliDependencies } from "./support.ts";
15
+ import { parseCandidate } from "./route-args.ts";
16
+
17
+ export const BENCHMARKS_USAGE_LINES = [" benchmarks <status|refresh|list|rank> [options] [--json]"];
18
+
19
+ interface BenchmarkArgs {
20
+ action: "status" | "refresh" | "list" | "rank";
21
+ json: boolean;
22
+ force: boolean;
23
+ query?: BenchmarkQuery;
24
+ recommendation?: ModelRecommendationInput & { session_id?: string; session_secret?: string };
25
+ }
26
+
27
+ function parseBenchmarkArgs(action: string | undefined, args: string[]): BenchmarkArgs | null {
28
+ if (action !== "status" && action !== "refresh" && action !== "list" && action !== "rank") return null;
29
+ let json = false;
30
+ let force = false;
31
+ const query: Partial<BenchmarkQuery> = {};
32
+ const candidates: ModelCandidate[] = [];
33
+ const sourceIds: string[] = [];
34
+ let scopeAuthority: ScopeAuthority = "available-models";
35
+ let domain: ModelTaskDomain = "general";
36
+ let type: ModelTaskType = "general";
37
+ let budgetPressure = 0;
38
+ let sessionId: string | undefined;
39
+ let sessionSecret: string | undefined;
40
+ const weights: UtilityWeights = {
41
+ quality: MODEL_RANKING_DEFAULT_QUALITY_WEIGHT, cost: MODEL_RANKING_DEFAULT_COST_WEIGHT,
42
+ latency: MODEL_RANKING_DEFAULT_LATENCY_WEIGHT, context: MODEL_RANKING_DEFAULT_CONTEXT_WEIGHT,
43
+ reliability: MODEL_RANKING_DEFAULT_RELIABILITY_WEIGHT,
44
+ };
45
+ for (let index = 0; index < args.length; index += 1) {
46
+ const argument = args[index];
47
+ if (argument === "--json") { json = true; continue; }
48
+ if (argument === "--force" && action === "refresh") { force = true; continue; }
49
+ const allowed = action === "list" ? ["--source", "--model", "--dimension", "--limit"]
50
+ : action === "rank" ? ["--candidate", "--source", "--domain", "--type", "--scope", "--budget", "--weight-quality", "--weight-cost", "--weight-latency", "--weight-context", "--weight-reliability", "--session-id", "--session-secret"] : [];
51
+ if (!allowed.includes(argument ?? "")) return null;
52
+ const raw = args[++index];
53
+ if (raw === undefined || raw.length === 0) return null;
54
+ if (action === "list") {
55
+ if (argument === "--limit") {
56
+ const limit = Number(raw);
57
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > BENCHMARK_MAX_QUERY_LIMIT) return null;
58
+ query.limit = limit;
59
+ } else if (argument === "--source") query.sourceId = raw;
60
+ else if (argument === "--model") query.model = raw;
61
+ else query.dimension = raw;
62
+ continue;
63
+ }
64
+ if (argument === "--candidate") {
65
+ const candidate = parseCandidate(raw);
66
+ if (!candidate) return null;
67
+ candidates.push(candidate);
68
+ } else if (argument === "--source") sourceIds.push(raw);
69
+ else if (argument === "--domain") {
70
+ if (!TASK_DOMAINS.includes(raw as ModelTaskDomain)) return null;
71
+ domain = raw as ModelTaskDomain;
72
+ } else if (argument === "--type") {
73
+ if (!TASK_TYPES.includes(raw as ModelTaskType)) return null;
74
+ type = raw as ModelTaskType;
75
+ } else if (argument === "--scope") {
76
+ if (raw !== "exact-session" && raw !== "available-models") return null;
77
+ scopeAuthority = raw;
78
+ } else if (argument === "--budget") budgetPressure = Number(raw);
79
+ else if (argument === "--session-id") sessionId = raw;
80
+ else if (argument === "--session-secret") sessionSecret = raw;
81
+ else {
82
+ const weight = Number(raw);
83
+ if (!Number.isFinite(weight) || weight < 0 || weight > 10) return null;
84
+ weights[argument!.slice("--weight-".length) as keyof UtilityWeights] = weight;
85
+ }
86
+ }
87
+ if (action === "list" && query.sourceId === undefined) return null;
88
+ if (action === "rank" && (candidates.length === 0 || sourceIds.length > MODEL_RANKING_MAX_SOURCES || !Number.isFinite(budgetPressure) || budgetPressure < 0 || budgetPressure > 2)) return null;
89
+ return {
90
+ action, json, force,
91
+ ...(action === "list" ? { query: query as BenchmarkQuery } : {}),
92
+ ...(action === "rank" ? { recommendation: { candidates, sourceIds: [...new Set(sourceIds)], scopeAuthority, domain, type, budgetPressure, weights, ...(sessionId ? { session_id: sessionId } : {}), ...(sessionSecret ? { session_secret: sessionSecret } : {}) } } : {}),
93
+ };
94
+ }
95
+
96
+ export function formatBenchmarkStatus(result: BenchmarkRefreshResult): string {
97
+ if (result.sources.length === 0) return "Benchmark sources: none configured";
98
+ return ["Benchmark sources:", ...result.sources.map((source) => {
99
+ const state = source.ok === null ? "not refreshed" : source.ok ? "ready" : "refresh failed";
100
+ return `- ${source.id}: ${state} · ${source.observations.toLocaleString()} observations · ${source.hasEvidence ? "evidence retained" : "no evidence"}`;
101
+ })].join("\n");
102
+ }
103
+
104
+ export function formatBenchmarkQuery(result: BenchmarkQueryResult): string {
105
+ return [
106
+ `Benchmark evidence: ${humanField(result.sourceId)} · ${result.completeness} · ${result.freshness} · ${result.observations.length.toLocaleString()} observations`,
107
+ ...result.observations.map((observation) => `- ${humanField(observation.model.canonical)} · ${humanField(observation.dimension)} ${observation.value.toLocaleString()} ${observation.unit} · ${humanField(observation.provenance.publisher)} · confidence ${(observation.provenance.confidence * 100).toFixed(0)}%`),
108
+ ].join("\n");
109
+ }
110
+
111
+ export function formatModelRanking(result: ModelRankingResult): string {
112
+ return [
113
+ `Model ranking: ${result.completeness} · scope ${result.scopeAuthority}${result.scopeWarning ? " · advisory only" : ""}`,
114
+ ...result.ranked.map((item, index) => `${index + 1}. ${humanField(item.identity)} · utility ${item.utility === null ? "unknown" : item.utility.toFixed(3)} · confidence ${(item.confidence * 100).toFixed(0)}%`),
115
+ ...(result.scopeWarning ? [result.scopeWarning] : []),
116
+ ].join("\n");
117
+ }
118
+
119
+ export async function runBenchmarksCommand(action: string | undefined, rest: string[], deps: CliDependencies, usage: () => number): Promise<number> {
120
+ const parsed = parseBenchmarkArgs(action, rest);
121
+ if (!parsed) return usage();
122
+ try {
123
+ if (parsed.action === "list") {
124
+ const result = await deps.client.call("benchmark.query", parsed.query!);
125
+ deps.stdout(parsed.json ? JSON.stringify(result) : formatBenchmarkQuery(result));
126
+ } else if (parsed.action === "rank") {
127
+ const result = await deps.client.call("models.rank", parsed.recommendation!);
128
+ deps.stdout(parsed.json ? JSON.stringify(result) : formatModelRanking(result));
129
+ } else {
130
+ const result = parsed.action === "refresh"
131
+ ? await deps.client.call("benchmark.refresh", { force: parsed.force })
132
+ : await deps.client.call("benchmark.status", {});
133
+ deps.stdout(parsed.json ? JSON.stringify(result) : formatBenchmarkStatus(result));
134
+ }
135
+ return 0;
136
+ } catch (error) {
137
+ deps.stderr(error instanceof Error ? error.message : String(error));
138
+ return 1;
139
+ }
140
+ }
@@ -0,0 +1,17 @@
1
+ import type { CompactionDurationEstimate } from "../domain/context-telemetry.ts";
2
+ import { callAndPrint, type CliDependencies } from "./support.ts";
3
+ import { parseJsonOnlyArgs } from "./router.ts";
4
+
5
+ export function formatCompactionEstimate(estimate: CompactionDurationEstimate): string {
6
+ if (estimate.confidence === "cold-start" || estimate.ms === null) {
7
+ return `Compaction duration: cold-start (${estimate.sampleSize.toLocaleString()} sample(s), not enough evidence yet)`;
8
+ }
9
+ return `Compaction duration: ~${estimate.ms.toLocaleString()}ms learned from ${estimate.sampleSize.toLocaleString()} sample(s)`;
10
+ }
11
+
12
+ export async function runCompactionCommand(action: string | undefined, rest: string[], deps: CliDependencies, usage: () => number): Promise<number> {
13
+ if (action !== "estimate") return usage();
14
+ const parsed = parseJsonOnlyArgs(rest);
15
+ if (!parsed) return usage();
16
+ return callAndPrint(deps, "compaction.estimate", {}, parsed.json, formatCompactionEstimate);
17
+ }
@@ -0,0 +1,49 @@
1
+ import type { ContextAssessment } from "../domain/context-telemetry.ts";
2
+ import type { CliDependencies } from "./support.ts";
3
+
4
+ export const CONTEXT_USAGE_LINES = [" context [--since <ms>] [--until <ms>] [--json]"];
5
+
6
+ function parseContextArgs(args: string[]): { input: { since?: number; until?: number }; json: boolean } | null {
7
+ const input: { since?: number; until?: number } = {};
8
+ let json = false;
9
+ for (let index = 0; index < args.length; index += 1) {
10
+ const argument = args[index];
11
+ if (argument === "--json") { json = true; continue; }
12
+ if (argument !== "--since" && argument !== "--until") return null;
13
+ const raw = args[++index];
14
+ const value = raw === undefined ? Number.NaN : Number(raw);
15
+ if (!Number.isSafeInteger(value) || value < 0) return null;
16
+ if (argument === "--since") input.since = value;
17
+ else input.until = value;
18
+ }
19
+ if (input.since !== undefined && input.until !== undefined && input.until < input.since) return null;
20
+ return { input, json };
21
+ }
22
+
23
+ function value(value: number | null, suffix = ""): string {
24
+ return value === null ? "unknown" : `${Math.round(value).toLocaleString()}${suffix}`;
25
+ }
26
+
27
+ export function formatContextAssessment(summary: ContextAssessment): string {
28
+ return [
29
+ `Context assessment: ${summary.completeness}`,
30
+ `Papyrus injection: ${summary.injection.runs} runs · avg ${value(summary.injection.averageCharacters, " chars")} · p95 ${value(summary.injection.p95Characters, " chars")} · max ${value(summary.injection.maxCharacters, " chars")}`,
31
+ `Injection mix: rules ${summary.injection.ruleCharacters.toLocaleString()} chars · tasks ${summary.injection.taskCharacters.toLocaleString()} chars · estimated ${summary.injection.estimatedTokens.toLocaleString()} tokens · unchanged ${summary.injection.unchangedRate === null ? "unknown" : `${(summary.injection.unchangedRate * 100).toFixed(1)}%`}`,
32
+ `Compactions: ${summary.compaction.completed} completed · ${summary.compaction.aborted} aborted · avg ${value(summary.compaction.averageDurationMs, "ms")} · ${summary.compaction.perRun === null ? "unknown" : summary.compaction.perRun.toFixed(3)} per agent run · ${summary.compaction.perTurn === null ? "unknown" : summary.compaction.perTurn.toFixed(3)} per turn`,
33
+ `Between compactions: ${value(summary.compaction.averageTurnsBetween, " turns")} · ${value(summary.compaction.averageProviderTokensBetween, " provider tokens")} · ${value(summary.compaction.averageCacheReadTokensBetween, " cache-read tokens")}`,
34
+ `Reasons: threshold ${summary.compaction.reasons.threshold} · overflow ${summary.compaction.reasons.overflow} · manual ${summary.compaction.reasons.manual}`,
35
+ ].join("\n");
36
+ }
37
+
38
+ export async function runContextCommand(action: string | undefined, rest: string[], deps: CliDependencies, usage: () => number): Promise<number> {
39
+ const parsed = parseContextArgs([...(action === undefined ? [] : [action]), ...rest]);
40
+ if (!parsed) return usage();
41
+ try {
42
+ const summary = await deps.client.call("context.assess", parsed.input);
43
+ deps.stdout(parsed.json ? JSON.stringify(summary) : formatContextAssessment(summary));
44
+ return 0;
45
+ } catch (error) {
46
+ deps.stderr(error instanceof Error ? error.message : String(error));
47
+ return 1;
48
+ }
49
+ }
@@ -0,0 +1,296 @@
1
+ import { CLI_METRICS_HUMAN_MAX_ROWS, MAX_QUERY_LIMIT, MAX_USAGE_BUCKETS, METRIC_BATCH_MAX_OBSERVATIONS, USAGE_MAX_DISTINCT_SCOPES } from "../constants.ts";
2
+ import { METRIC_UNITS, type MetricObservation, type MetricQuery, type MetricUnit, type StoredMetricObservation } from "../domain/metric.ts";
3
+ import type { TaskCostSummary } from "../domain/task-cost.ts";
4
+ import { callAndPrint, humanField, type CliDependencies } from "./support.ts";
5
+
6
+ export const METRICS_USAGE_LINES = [
7
+ " metrics record --source <s> --scope <s> --metric <s> --value <number|null> --unit <unit> [--observed-at <ms>] [--attributes <json>] [--json]",
8
+ ` metrics record-batch --observations <json-array, max ${METRIC_BATCH_MAX_OBSERVATIONS}> [--json]`,
9
+ " metrics query [--source <s>] [--scope <s>] [--metric <s>] [--since <ms>] [--until <ms>] [--limit <n>] [--order asc|desc] [--json]",
10
+ " metrics prune --before <ms> [--force] [--json] (force required if before is newer than 24h ago)",
11
+ ` metrics distinct-scopes --source <s> --since <ms> --until <ms> [--limit 1..${USAGE_MAX_DISTINCT_SCOPES}] [--json]`,
12
+ ` metrics usage-series --source <s> --since <ms> --until <ms> --bucket-size-ms <ms> --bucket-count 1..${MAX_USAGE_BUCKETS} [--scope-limit 1..${USAGE_MAX_DISTINCT_SCOPES}] [--json]`,
13
+ " metrics cost-by-task --since <ms> --until <ms> [--json]",
14
+ ];
15
+
16
+ interface MetricsRecordArgs { input: MetricObservation; json: boolean }
17
+
18
+ function parseMetricsRecordArgs(args: string[]): MetricsRecordArgs | null {
19
+ let json = false;
20
+ let source: string | undefined;
21
+ let scope: string | undefined;
22
+ let metric: string | undefined;
23
+ let value: number | null | undefined;
24
+ let unit: MetricUnit | undefined;
25
+ let observedAt: number | undefined;
26
+ let attributes: Record<string, unknown> | undefined;
27
+ for (let index = 0; index < args.length; index += 1) {
28
+ const argument = args[index];
29
+ if (argument === "--json") { json = true; continue; }
30
+ if (!["--source", "--scope", "--metric", "--value", "--unit", "--observed-at", "--attributes"].includes(argument ?? "")) return null;
31
+ const raw = args[++index];
32
+ if (raw === undefined || raw.length === 0) return null;
33
+ if (argument === "--source") source = raw;
34
+ else if (argument === "--scope") scope = raw;
35
+ else if (argument === "--metric") metric = raw;
36
+ else if (argument === "--value") {
37
+ if (raw.toLowerCase() === "null") value = null;
38
+ else {
39
+ const parsed = Number(raw);
40
+ if (!Number.isFinite(parsed)) return null;
41
+ value = parsed;
42
+ }
43
+ } else if (argument === "--unit") {
44
+ if (!METRIC_UNITS.includes(raw as MetricUnit)) return null;
45
+ unit = raw as MetricUnit;
46
+ } else if (argument === "--observed-at") {
47
+ const parsed = Number(raw);
48
+ if (!Number.isSafeInteger(parsed) || parsed < 0) return null;
49
+ observedAt = parsed;
50
+ } else {
51
+ try {
52
+ const parsed = JSON.parse(raw);
53
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
54
+ attributes = parsed as Record<string, unknown>;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+ }
60
+ if (source === undefined || scope === undefined || metric === undefined || value === undefined || unit === undefined) return null;
61
+ return {
62
+ json,
63
+ input: {
64
+ source, scope, metric, value, unit,
65
+ observedAt: observedAt ?? Date.now(),
66
+ ...(attributes ? { attributes } : {}),
67
+ },
68
+ };
69
+ }
70
+
71
+ interface MetricsRecordBatchArgs { input: { observations: MetricObservation[] }; json: boolean }
72
+
73
+ function parseMetricsRecordBatchArgs(args: string[]): MetricsRecordBatchArgs | null {
74
+ let json = false;
75
+ let observations: unknown;
76
+ for (let index = 0; index < args.length; index += 1) {
77
+ const argument = args[index];
78
+ if (argument === "--json") { json = true; continue; }
79
+ if (argument !== "--observations") return null;
80
+ const raw = args[++index];
81
+ if (raw === undefined || raw.length === 0) return null;
82
+ try {
83
+ observations = JSON.parse(raw);
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+ if (!Array.isArray(observations) || observations.length === 0 || observations.length > METRIC_BATCH_MAX_OBSERVATIONS) return null;
89
+ return { input: { observations: observations as MetricObservation[] }, json };
90
+ }
91
+
92
+ interface MetricsQueryArgs { input: MetricQuery; json: boolean }
93
+
94
+ function parseMetricsQueryArgs(args: string[]): MetricsQueryArgs | null {
95
+ let json = false;
96
+ const input: MetricQuery = {};
97
+ for (let index = 0; index < args.length; index += 1) {
98
+ const argument = args[index];
99
+ if (argument === "--json") { json = true; continue; }
100
+ if (!["--source", "--scope", "--metric", "--since", "--until", "--limit", "--order"].includes(argument ?? "")) return null;
101
+ const raw = args[++index];
102
+ if (raw === undefined || raw.length === 0) return null;
103
+ if (argument === "--source") input.source = raw;
104
+ else if (argument === "--scope") input.scope = raw;
105
+ else if (argument === "--metric") input.metric = raw;
106
+ else if (argument === "--order") {
107
+ if (raw !== "asc" && raw !== "desc") return null;
108
+ input.order = raw;
109
+ } else {
110
+ const parsed = Number(raw);
111
+ if (!Number.isSafeInteger(parsed) || parsed < 0) return null;
112
+ if (argument === "--since") input.since = parsed;
113
+ else if (argument === "--until") input.until = parsed;
114
+ else {
115
+ if (parsed < 1 || parsed > MAX_QUERY_LIMIT) return null;
116
+ input.limit = parsed;
117
+ }
118
+ }
119
+ }
120
+ if (input.since !== undefined && input.until !== undefined && input.until < input.since) return null;
121
+ return { input, json };
122
+ }
123
+
124
+ interface MetricsDistinctScopesArgs { input: { source: string; since: number; until: number; limit?: number }; json: boolean }
125
+
126
+ function parseMetricsDistinctScopesArgs(args: string[]): MetricsDistinctScopesArgs | null {
127
+ let json = false;
128
+ let source: string | undefined;
129
+ let since: number | undefined;
130
+ let until: number | undefined;
131
+ let limit: number | undefined;
132
+ for (let index = 0; index < args.length; index += 1) {
133
+ const argument = args[index];
134
+ if (argument === "--json") { json = true; continue; }
135
+ if (!["--source", "--since", "--until", "--limit"].includes(argument ?? "")) return null;
136
+ const raw = args[++index];
137
+ if (raw === undefined || raw.length === 0) return null;
138
+ if (argument === "--source") { source = raw; continue; }
139
+ const parsed = Number(raw);
140
+ if (!Number.isSafeInteger(parsed) || parsed < 0) return null;
141
+ if (argument === "--since") since = parsed;
142
+ else if (argument === "--until") until = parsed;
143
+ else {
144
+ if (parsed < 1 || parsed > USAGE_MAX_DISTINCT_SCOPES) return null;
145
+ limit = parsed;
146
+ }
147
+ }
148
+ if (source === undefined || since === undefined || until === undefined || until < since) return null;
149
+ return { input: { source, since, until, ...(limit === undefined ? {} : { limit }) }, json };
150
+ }
151
+
152
+ interface MetricsUsageSeriesArgs { input: { source: string; since: number; until: number; bucketSizeMs: number; bucketCount: number; scopeLimit?: number }; json: boolean }
153
+
154
+ function parseMetricsUsageSeriesArgs(args: string[]): MetricsUsageSeriesArgs | null {
155
+ let json = false;
156
+ let source: string | undefined;
157
+ let since: number | undefined;
158
+ let until: number | undefined;
159
+ let bucketSizeMs: number | undefined;
160
+ let bucketCount: number | undefined;
161
+ let scopeLimit: number | undefined;
162
+ for (let index = 0; index < args.length; index += 1) {
163
+ const argument = args[index];
164
+ if (argument === "--json") { json = true; continue; }
165
+ if (!(["--source", "--since", "--until", "--bucket-size-ms", "--bucket-count", "--scope-limit"].includes(argument ?? ""))) return null;
166
+ const raw = args[++index];
167
+ if (raw === undefined || raw.length === 0) return null;
168
+ if (argument === "--source") { source = raw; continue; }
169
+ const parsed = Number(raw);
170
+ if (!Number.isSafeInteger(parsed) || parsed < 0) return null;
171
+ if (argument === "--since") since = parsed;
172
+ else if (argument === "--until") until = parsed;
173
+ else if (argument === "--bucket-size-ms") { if (parsed < 1) return null; bucketSizeMs = parsed; }
174
+ else if (argument === "--bucket-count") { if (parsed < 1 || parsed > MAX_USAGE_BUCKETS) return null; bucketCount = parsed; }
175
+ else { if (parsed < 1 || parsed > USAGE_MAX_DISTINCT_SCOPES) return null; scopeLimit = parsed; }
176
+ }
177
+ if (source === undefined || since === undefined || until === undefined || until < since || bucketSizeMs === undefined || bucketCount === undefined) return null;
178
+ return { input: { source, since, until, bucketSizeMs, bucketCount, ...(scopeLimit === undefined ? {} : { scopeLimit }) }, json };
179
+ }
180
+
181
+ interface CostByTaskArgs { input: { since: number; until: number }; json: boolean }
182
+
183
+ function parseCostByTaskArgs(args: string[]): CostByTaskArgs | null {
184
+ let json = false;
185
+ let since: number | undefined;
186
+ let until: number | undefined;
187
+ for (let index = 0; index < args.length; index += 1) {
188
+ const argument = args[index];
189
+ if (argument === "--json") { json = true; continue; }
190
+ if (!["--since", "--until"].includes(argument ?? "")) return null;
191
+ const raw = args[++index];
192
+ const parsed = Number(raw);
193
+ if (!Number.isSafeInteger(parsed) || parsed < 0) return null;
194
+ if (argument === "--since") since = parsed;
195
+ else until = parsed;
196
+ }
197
+ if (since === undefined || until === undefined || until < since) return null;
198
+ return { input: { since, until }, json };
199
+ }
200
+
201
+ interface MetricsPruneArgs { input: { before: number; force?: boolean }; json: boolean }
202
+
203
+ function parseMetricsPruneArgs(args: string[]): MetricsPruneArgs | null {
204
+ let json = false;
205
+ let force = false;
206
+ let before: number | undefined;
207
+ for (let index = 0; index < args.length; index += 1) {
208
+ const argument = args[index];
209
+ if (argument === "--json") { json = true; continue; }
210
+ if (argument === "--force") { force = true; continue; }
211
+ if (argument !== "--before") return null;
212
+ const raw = args[++index];
213
+ const parsed = Number(raw);
214
+ if (!Number.isSafeInteger(parsed) || parsed < 0) return null;
215
+ before = parsed;
216
+ }
217
+ if (before === undefined) return null;
218
+ return { input: { before, ...(force ? { force } : {}) }, json };
219
+ }
220
+
221
+ export function formatMetricsQuery(rows: StoredMetricObservation[]): string {
222
+ if (rows.length === 0) return "Metrics: no observations matched";
223
+ const shown = rows.slice(0, CLI_METRICS_HUMAN_MAX_ROWS);
224
+ const lines = [
225
+ `Metrics: ${rows.length.toLocaleString()} observation(s)${rows.length > shown.length ? ` (showing first ${shown.length})` : ""}`,
226
+ ...shown.map((row) => `- ${humanField(row.source)}/${humanField(row.scope)}/${humanField(row.metric)} = ${row.value === null ? "null" : row.value} ${row.unit} @ ${new Date(row.observedAt).toISOString()}`),
227
+ ];
228
+ return lines.join("\n");
229
+ }
230
+
231
+ export function formatMetricsDistinctScopes(scopes: string[]): string {
232
+ if (scopes.length === 0) return "Scopes: none matched";
233
+ return [`Scopes: ${scopes.length.toLocaleString()}`, ...scopes.map((scope) => `- ${humanField(scope)}`)].join("\n");
234
+ }
235
+
236
+ export function formatMetricsUsageSeries(result: { rows: Array<{ scope: string; metric: string; bucketIndex: number; sum: number }>; truncated: boolean }): string {
237
+ if (result.rows.length === 0) return `Usage series: no data${result.truncated ? " (scope limit reached)" : ""}`;
238
+ const lines = [`Usage series: ${result.rows.length.toLocaleString()} bucket(s)${result.truncated ? " (scope limit reached)" : ""}`];
239
+ for (const row of result.rows) lines.push(`- ${humanField(row.scope)}/${humanField(row.metric)} bucket ${row.bucketIndex}: ${row.sum.toLocaleString()}`);
240
+ return lines.join("\n");
241
+ }
242
+
243
+ function formatUsdAmount(amount: number): string {
244
+ return `$${amount.toFixed(Math.abs(amount) < 0.01 && amount !== 0 ? 4 : 2)}`;
245
+ }
246
+
247
+ export function formatCostByTask(summary: TaskCostSummary): string {
248
+ const lines = [
249
+ `Cost by task: ${summary.entries.length.toLocaleString()} task(s)${summary.truncated ? " (query limit reached; totals are a lower bound)" : ""}`,
250
+ ...summary.entries.flatMap((entry) => [
251
+ `- ${humanField(entry.taskId)}: ${formatUsdAmount(entry.costUsd)} · ↑${entry.inputTokens.toLocaleString()} ↓${entry.outputTokens.toLocaleString()} R${entry.cacheReadTokens.toLocaleString()} W${entry.cacheWriteTokens.toLocaleString()}`,
252
+ ...entry.byModel.map((model) => ` · ${humanField(model.provider)}/${humanField(model.model)} (${humanField(model.thinking)}): ${formatUsdAmount(model.costUsd)} · ↑${model.inputTokens.toLocaleString()} ↓${model.outputTokens.toLocaleString()} R${model.cacheReadTokens.toLocaleString()} W${model.cacheWriteTokens.toLocaleString()}`),
253
+ ]),
254
+ `Unattributed spend (no task was focused): ${formatUsdAmount(summary.unattributedCostUsd)}`,
255
+ ];
256
+ return lines.join("\n");
257
+ }
258
+
259
+ export async function runMetricsCommand(action: string | undefined, rest: string[], deps: CliDependencies, usage: () => number): Promise<number> {
260
+ if (action === "record") {
261
+ const parsed = parseMetricsRecordArgs(rest);
262
+ if (!parsed) return usage();
263
+ return callAndPrint(deps, "metrics.record", parsed.input, parsed.json, (row) => formatMetricsQuery([row]));
264
+ }
265
+ if (action === "record-batch") {
266
+ const parsed = parseMetricsRecordBatchArgs(rest);
267
+ if (!parsed) return usage();
268
+ return callAndPrint(deps, "metrics.record_batch", parsed.input, parsed.json, formatMetricsQuery);
269
+ }
270
+ if (action === "query") {
271
+ const parsed = parseMetricsQueryArgs(rest);
272
+ if (!parsed) return usage();
273
+ return callAndPrint(deps, "metrics.query", parsed.input, parsed.json, formatMetricsQuery);
274
+ }
275
+ if (action === "prune") {
276
+ const parsed = parseMetricsPruneArgs(rest);
277
+ if (!parsed) return usage();
278
+ return callAndPrint(deps, "metrics.prune", parsed.input, parsed.json, (result) => `Pruned ${result.deleted.toLocaleString()} observation(s)`);
279
+ }
280
+ if (action === "distinct-scopes") {
281
+ const parsed = parseMetricsDistinctScopesArgs(rest);
282
+ if (!parsed) return usage();
283
+ return callAndPrint(deps, "metrics.distinct_scopes", parsed.input, parsed.json, formatMetricsDistinctScopes);
284
+ }
285
+ if (action === "usage-series") {
286
+ const parsed = parseMetricsUsageSeriesArgs(rest);
287
+ if (!parsed) return usage();
288
+ return callAndPrint(deps, "metrics.usage_series", parsed.input, parsed.json, formatMetricsUsageSeries);
289
+ }
290
+ if (action === "cost-by-task") {
291
+ const parsed = parseCostByTaskArgs(rest);
292
+ if (!parsed) return usage();
293
+ return callAndPrint(deps, "metrics.cost_by_task", parsed.input, parsed.json, formatCostByTask);
294
+ }
295
+ return usage();
296
+ }