@cruxy/cli 0.18.0 → 0.19.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/dist/agent/loop.d.ts +12 -0
- package/dist/agent/loop.js +20 -0
- package/dist/agent/session.d.ts +18 -1
- package/dist/agent/session.js +38 -6
- package/dist/cli/commands/run.js +19 -0
- package/dist/cli/commands/usage.d.ts +9 -0
- package/dist/cli/commands/usage.js +81 -0
- package/dist/cli/program.js +2 -0
- package/dist/cli/session-factory.js +18 -1
- package/dist/config/schema.d.ts +280 -0
- package/dist/config/schema.js +38 -0
- package/dist/constants.d.ts +7 -0
- package/dist/constants.js +7 -0
- package/dist/errors/constructors.d.ts +7 -0
- package/dist/errors/constructors.js +19 -0
- package/dist/errors/types.d.ts +3 -0
- package/dist/errors/types.js +8 -0
- package/dist/plan/service.d.ts +10 -1
- package/dist/plan/service.js +2 -0
- package/dist/usage/collect.d.ts +40 -0
- package/dist/usage/collect.js +34 -0
- package/dist/usage/cost.d.ts +19 -0
- package/dist/usage/cost.js +29 -0
- package/dist/usage/index.d.ts +15 -0
- package/dist/usage/index.js +15 -0
- package/dist/usage/store.d.ts +37 -0
- package/dist/usage/store.js +83 -0
- package/dist/usage/summary.d.ts +32 -0
- package/dist/usage/summary.js +119 -0
- package/dist/usage/types.d.ts +220 -0
- package/dist/usage/types.js +47 -0
- package/package.json +1 -1
package/dist/agent/loop.d.ts
CHANGED
|
@@ -72,6 +72,18 @@ export interface RunAgentArgs {
|
|
|
72
72
|
/** The declared task class for routing; defaults to `main-turn`. Ignored
|
|
73
73
|
* unless `router` is set. */
|
|
74
74
|
taskClass?: TaskClass;
|
|
75
|
+
/**
|
|
76
|
+
* Usage telemetry (C.22): fired ONCE per completed model request with the
|
|
77
|
+
* routing tier (C.30) and the provider's usage for THAT request — or
|
|
78
|
+
* `usage: undefined` when the provider emitted no usage event, so the caller
|
|
79
|
+
* records it as unknown (never a fabricated zero). LOCAL accounting only:
|
|
80
|
+
* this is a callback into the process, nothing is transmitted. Omitted → no
|
|
81
|
+
* collection, behavior unchanged.
|
|
82
|
+
*/
|
|
83
|
+
onRequestUsage?: (req: {
|
|
84
|
+
tier?: string;
|
|
85
|
+
usage?: Usage;
|
|
86
|
+
}) => void;
|
|
75
87
|
}
|
|
76
88
|
/**
|
|
77
89
|
* The budget seam for {@link runAgent}: implementations track their own caps
|
package/dist/agent/loop.js
CHANGED
|
@@ -81,6 +81,12 @@ async function driveLoop(args, renderer, routed) {
|
|
|
81
81
|
let turnText = "";
|
|
82
82
|
const pending = new Map();
|
|
83
83
|
const toolUses = [];
|
|
84
|
+
// Per-request usage capture for telemetry (C.22). `sawUsage` is the honesty
|
|
85
|
+
// pivot: a request that emits NO usage event stays `false`, so it is reported
|
|
86
|
+
// as unknown rather than a fabricated zero. A provider-reported 0 flips it
|
|
87
|
+
// true and is recorded as a real 0.
|
|
88
|
+
let sawUsage = false;
|
|
89
|
+
const reqUsage = { input_tokens: 0, output_tokens: 0 };
|
|
84
90
|
// Live progress while waiting on the model; dismissed by the first delta.
|
|
85
91
|
// Token context is whatever the loop has actually accumulated (U.4): zero
|
|
86
92
|
// on the first turn → no figure shown, never a fabricated number.
|
|
@@ -121,6 +127,12 @@ async function driveLoop(args, renderer, routed) {
|
|
|
121
127
|
case "usage":
|
|
122
128
|
usage.input_tokens = ev.usage.input_tokens || usage.input_tokens;
|
|
123
129
|
usage.output_tokens += ev.usage.output_tokens;
|
|
130
|
+
// Mirror the accumulation into the per-request figure the telemetry
|
|
131
|
+
// callback reports (same last-non-zero-in / summed-out semantics).
|
|
132
|
+
sawUsage = true;
|
|
133
|
+
reqUsage.input_tokens =
|
|
134
|
+
ev.usage.input_tokens || reqUsage.input_tokens;
|
|
135
|
+
reqUsage.output_tokens += ev.usage.output_tokens;
|
|
124
136
|
break;
|
|
125
137
|
case "message_stop":
|
|
126
138
|
// Turn complete; the stream ends after this.
|
|
@@ -131,6 +143,14 @@ async function driveLoop(args, renderer, routed) {
|
|
|
131
143
|
break;
|
|
132
144
|
}
|
|
133
145
|
}
|
|
146
|
+
// Request complete: report its usage honestly — the real figure when a usage
|
|
147
|
+
// event arrived, or `undefined` (unknown) when the provider reported none. A
|
|
148
|
+
// stream that threw above never reaches here, so failed requests aren't
|
|
149
|
+
// recorded with a misleading zero.
|
|
150
|
+
args.onRequestUsage?.({
|
|
151
|
+
tier: routed?.tier,
|
|
152
|
+
usage: sawUsage ? { ...reqUsage } : undefined,
|
|
153
|
+
});
|
|
134
154
|
// ── Record the assistant turn ───────────────────────────────────────────
|
|
135
155
|
if (turnText) {
|
|
136
156
|
// Streaming (renderer set): the text already reached the user delta by
|
package/dist/agent/session.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { StreamRenderer } from "../render/index.js";
|
|
|
4
4
|
import { type Router } from "../routing/index.js";
|
|
5
5
|
import type { ToolContext } from "../tools/index.js";
|
|
6
6
|
import type { ToolRegistry } from "../tools/index.js";
|
|
7
|
+
import { type RequestUsage, type UsageRecord } from "../usage/index.js";
|
|
7
8
|
import { type AgentResult, type LifecycleHookRunner } from "./loop.js";
|
|
8
9
|
/**
|
|
9
10
|
* Plan-mode turn runner (C.31), injected so the agent package doesn't depend on
|
|
@@ -16,6 +17,10 @@ export type PlanRunner = (args: {
|
|
|
16
17
|
projectInstructions: string | null;
|
|
17
18
|
recalledMemory: string | null;
|
|
18
19
|
renderer?: StreamRenderer;
|
|
20
|
+
/** Usage telemetry (C.22): forwarded to every model request the plan-mode
|
|
21
|
+
* turn drives (propose + each execution step), so plan runs are attributed
|
|
22
|
+
* exactly like a normal turn. */
|
|
23
|
+
onRequestUsage?: (req: RequestUsage) => void;
|
|
19
24
|
}) => Promise<AgentResult>;
|
|
20
25
|
export interface SessionArgs {
|
|
21
26
|
/** A constructed provider to stream from. */
|
|
@@ -56,6 +61,12 @@ export interface SessionArgs {
|
|
|
56
61
|
* context compaction on `summarize`; omitted → the provider default (unchanged).
|
|
57
62
|
*/
|
|
58
63
|
router?: Router;
|
|
64
|
+
/**
|
|
65
|
+
* Usage telemetry sink (C.22): called once per `send` with that run's
|
|
66
|
+
* {@link UsageRecord} (real per-request usage, tier-attributed). The sink
|
|
67
|
+
* persists it locally — it never transmits. Omitted → no persistence.
|
|
68
|
+
*/
|
|
69
|
+
onRunUsage?: (record: UsageRecord) => void;
|
|
59
70
|
}
|
|
60
71
|
/**
|
|
61
72
|
* Estimate the token footprint of a message list with a cheap chars/4 heuristic
|
|
@@ -80,6 +91,12 @@ export declare class Session {
|
|
|
80
91
|
messages: Message[];
|
|
81
92
|
/** Token usage summed across every `send` (and every compaction) in this session. */
|
|
82
93
|
readonly usage: Usage;
|
|
94
|
+
/** Stable id for this session (C.22), so a run's usage record groups with the
|
|
95
|
+
* other runs of the same interactive session (`cruxy usage --session`). */
|
|
96
|
+
readonly sessionId: string;
|
|
97
|
+
/** The most recent run's usage record (C.22) — the one-shot path reads it to
|
|
98
|
+
* print the end-of-run summary. */
|
|
99
|
+
lastRun?: UsageRecord;
|
|
83
100
|
private readonly args;
|
|
84
101
|
/** Mutable so `/reload` can refresh CRUXY.md mid-session. */
|
|
85
102
|
private projectInstructions;
|
|
@@ -120,7 +137,7 @@ export declare class Session {
|
|
|
120
137
|
* returns the number of older messages folded into the summary; otherwise
|
|
121
138
|
* returns `null` (under threshold, nothing safe to cut, or summary failed).
|
|
122
139
|
*/
|
|
123
|
-
maybeCompact(): Promise<number | null>;
|
|
140
|
+
maybeCompact(onRequestUsage?: (req: RequestUsage) => void): Promise<number | null>;
|
|
124
141
|
/**
|
|
125
142
|
* Force compaction regardless of the threshold (backs `/compact`). Returns the
|
|
126
143
|
* number of older messages summarized, or `null` if there was nothing safe to
|
package/dist/agent/session.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { loadProjectInstructions } from "../config/index.js";
|
|
2
3
|
import { resolveTaskModel } from "../routing/index.js";
|
|
4
|
+
import { UsageCollector, } from "../usage/index.js";
|
|
3
5
|
import { runAgent, } from "./loop.js";
|
|
4
6
|
import { SUMMARY_SYSTEM, COMPACTION_MARKER } from "./prompts.js";
|
|
5
7
|
/**
|
|
@@ -47,6 +49,12 @@ export class Session {
|
|
|
47
49
|
messages = [];
|
|
48
50
|
/** Token usage summed across every `send` (and every compaction) in this session. */
|
|
49
51
|
usage = { input_tokens: 0, output_tokens: 0 };
|
|
52
|
+
/** Stable id for this session (C.22), so a run's usage record groups with the
|
|
53
|
+
* other runs of the same interactive session (`cruxy usage --session`). */
|
|
54
|
+
sessionId = randomUUID();
|
|
55
|
+
/** The most recent run's usage record (C.22) — the one-shot path reads it to
|
|
56
|
+
* print the end-of-run summary. */
|
|
57
|
+
lastRun;
|
|
50
58
|
args;
|
|
51
59
|
/** Mutable so `/reload` can refresh CRUXY.md mid-session. */
|
|
52
60
|
projectInstructions;
|
|
@@ -86,8 +94,15 @@ export class Session {
|
|
|
86
94
|
*/
|
|
87
95
|
async send(userPrompt, renderer) {
|
|
88
96
|
this.messages.push({ role: "user", content: userPrompt });
|
|
97
|
+
// Usage telemetry (C.22): one collector per run. `onReq` is threaded into
|
|
98
|
+
// every real model request this turn drives — the main loop, compaction, and
|
|
99
|
+
// (in plan mode) the propose + execution steps — so usage is captured exactly
|
|
100
|
+
// where the provider reports it, honestly (unknown when it reports nothing).
|
|
101
|
+
const collector = new UsageCollector();
|
|
102
|
+
const startedAt = new Date().toISOString();
|
|
103
|
+
const onReq = (req) => collector.record(req);
|
|
89
104
|
// Compact *before* the agent call so the turn runs against a bounded history.
|
|
90
|
-
await this.maybeCompact();
|
|
105
|
+
await this.maybeCompact(onReq);
|
|
91
106
|
// before-run (C.19): a blocking pre-run hook — or an untrusted project's
|
|
92
107
|
// hooks — throws here and aborts the turn before the model is engaged
|
|
93
108
|
// (fail-closed). No-op when hooks are disabled or none are registered.
|
|
@@ -101,6 +116,7 @@ export class Session {
|
|
|
101
116
|
projectInstructions: this.projectInstructions,
|
|
102
117
|
recalledMemory: this.args.recalledMemory ?? null,
|
|
103
118
|
renderer,
|
|
119
|
+
onRequestUsage: onReq,
|
|
104
120
|
})
|
|
105
121
|
: await runAgent({
|
|
106
122
|
messages: this.messages,
|
|
@@ -110,10 +126,17 @@ export class Session {
|
|
|
110
126
|
projectInstructions: this.projectInstructions,
|
|
111
127
|
planMode: false, // the plan directive belongs only to the runner's propose phase
|
|
112
128
|
renderer,
|
|
129
|
+
onRequestUsage: onReq,
|
|
113
130
|
});
|
|
114
131
|
this.messages = result.messages;
|
|
115
132
|
this.usage.input_tokens += result.usage.input_tokens;
|
|
116
133
|
this.usage.output_tokens += result.usage.output_tokens;
|
|
134
|
+
// Publish the run's usage record (C.22): stash it for the one-shot summary
|
|
135
|
+
// and hand it to the persistence sink. Building the record never touches the
|
|
136
|
+
// network and never blocks the turn's result.
|
|
137
|
+
const record = collector.toRecord(randomUUID(), this.sessionId, startedAt);
|
|
138
|
+
this.lastRun = record;
|
|
139
|
+
this.args.onRunUsage?.(record);
|
|
117
140
|
// after-run (C.19): advisory by default (a blocking after-run hook throws
|
|
118
141
|
// and surfaces at the boundary). The turn already completed and its history
|
|
119
142
|
// is adopted above — an advisory failure never rewrites it.
|
|
@@ -139,12 +162,12 @@ export class Session {
|
|
|
139
162
|
* returns the number of older messages folded into the summary; otherwise
|
|
140
163
|
* returns `null` (under threshold, nothing safe to cut, or summary failed).
|
|
141
164
|
*/
|
|
142
|
-
async maybeCompact() {
|
|
165
|
+
async maybeCompact(onRequestUsage) {
|
|
143
166
|
const { maxTokens, compactThreshold } = this.args.config.context;
|
|
144
167
|
if (estimateTokens(this.messages) <= compactThreshold * maxTokens) {
|
|
145
168
|
return null;
|
|
146
169
|
}
|
|
147
|
-
const n = await this.runCompaction();
|
|
170
|
+
const n = await this.runCompaction(onRequestUsage);
|
|
148
171
|
if (n) {
|
|
149
172
|
this.args.ctx.logger.info(`compacted ${n} older message${n === 1 ? "" : "s"} to stay within context`);
|
|
150
173
|
}
|
|
@@ -164,7 +187,7 @@ export class Session {
|
|
|
164
187
|
* failed summary call leaves the history untouched and returns `null` (fail
|
|
165
188
|
* open — losing compaction is degraded, not unsafe).
|
|
166
189
|
*/
|
|
167
|
-
async runCompaction() {
|
|
190
|
+
async runCompaction(onRequestUsage) {
|
|
168
191
|
const cut = this.findCut();
|
|
169
192
|
if (cut === null)
|
|
170
193
|
return null;
|
|
@@ -172,7 +195,7 @@ export class Session {
|
|
|
172
195
|
const kept = this.messages.slice(cut);
|
|
173
196
|
let synopsis;
|
|
174
197
|
try {
|
|
175
|
-
const summary = await this.summarize(prefix);
|
|
198
|
+
const summary = await this.summarize(prefix, onRequestUsage);
|
|
176
199
|
synopsis = summary.text;
|
|
177
200
|
this.usage.input_tokens += summary.usage.input_tokens;
|
|
178
201
|
this.usage.output_tokens += summary.usage.output_tokens;
|
|
@@ -225,7 +248,7 @@ export class Session {
|
|
|
225
248
|
* Summarize a prefix via a standalone, tool-less provider call over a rendered
|
|
226
249
|
* transcript. Throws on a stream error or empty output so callers fail open.
|
|
227
250
|
*/
|
|
228
|
-
async summarize(prefix) {
|
|
251
|
+
async summarize(prefix, onRequestUsage) {
|
|
229
252
|
const transcript = renderTranscript(prefix);
|
|
230
253
|
const usage = { input_tokens: 0, output_tokens: 0 };
|
|
231
254
|
let text = "";
|
|
@@ -234,6 +257,9 @@ export class Session {
|
|
|
234
257
|
const routed = this.args.router
|
|
235
258
|
? resolveTaskModel(this.args.router, "summarize")
|
|
236
259
|
: null;
|
|
260
|
+
// Per-request usage capture for telemetry (C.22), same honesty pivot as the
|
|
261
|
+
// main loop: unknown unless a usage event actually arrives.
|
|
262
|
+
let sawUsage = false;
|
|
237
263
|
for await (const ev of this.args.provider.stream({
|
|
238
264
|
system: SUMMARY_SYSTEM,
|
|
239
265
|
messages: [{ role: "user", content: transcript }],
|
|
@@ -246,6 +272,7 @@ export class Session {
|
|
|
246
272
|
case "usage":
|
|
247
273
|
usage.input_tokens = ev.usage.input_tokens || usage.input_tokens;
|
|
248
274
|
usage.output_tokens += ev.usage.output_tokens;
|
|
275
|
+
sawUsage = true;
|
|
249
276
|
break;
|
|
250
277
|
case "error":
|
|
251
278
|
throw ev.error;
|
|
@@ -253,6 +280,11 @@ export class Session {
|
|
|
253
280
|
break;
|
|
254
281
|
}
|
|
255
282
|
}
|
|
283
|
+
// Attribute this compaction request to the `summarize` tier honestly.
|
|
284
|
+
onRequestUsage?.({
|
|
285
|
+
tier: routed?.tier,
|
|
286
|
+
usage: sawUsage ? { ...usage } : undefined,
|
|
287
|
+
});
|
|
256
288
|
if (!text.trim())
|
|
257
289
|
throw new Error("summary was empty");
|
|
258
290
|
return { text: text.trim(), usage };
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -4,6 +4,7 @@ import { loadConfig, resolveApiKey } from "../../config/index.js";
|
|
|
4
4
|
import { authMissingKey, shouldUseColor, usageError, } from "../../errors/index.js";
|
|
5
5
|
import { createRenderer } from "../../render/index.js";
|
|
6
6
|
import { themeForColor } from "../../theme/index.js";
|
|
7
|
+
import { summarizeRuns, renderSummary, } from "../../usage/index.js";
|
|
7
8
|
import { CheckpointService } from "../../checkpoint/index.js";
|
|
8
9
|
import { SandboxService } from "../../sandbox/index.js";
|
|
9
10
|
import { buildHooksService } from "../../hooks/index.js";
|
|
@@ -110,5 +111,23 @@ export function runCommand() {
|
|
|
110
111
|
finally {
|
|
111
112
|
renderer.close();
|
|
112
113
|
}
|
|
114
|
+
// End-of-run usage summary (C.22): honest tokens + per-tier breakdown +
|
|
115
|
+
// cost (only when priced). Printed after the live region is torn down.
|
|
116
|
+
// Reaches here only on success — a thrown run propagates past it — and
|
|
117
|
+
// never affects the run's outcome.
|
|
118
|
+
if (config.usage.enabled && session.lastRun) {
|
|
119
|
+
printRunUsage(session.lastRun, config);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/** Render the just-finished run's usage as a single themed line (C.22). */
|
|
124
|
+
function printRunUsage(record, config) {
|
|
125
|
+
if (record.entries.length === 0)
|
|
126
|
+
return;
|
|
127
|
+
const t = themeForColor(shouldUseColor(process.stdout));
|
|
128
|
+
const summary = summarizeRuns([record], {
|
|
129
|
+
prices: config.usage.prices,
|
|
130
|
+
currency: config.usage.currency,
|
|
113
131
|
});
|
|
132
|
+
logger.print(renderSummary(summary, t));
|
|
114
133
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
/**
|
|
3
|
+
* `cruxy usage` (C.22) — show LOCAL token usage and (when priced) cost, read
|
|
4
|
+
* back from `~/.cruxy/usage`. Read-only and local: it prints your own accounting
|
|
5
|
+
* and transmits nothing. Every figure is real — a request the provider never
|
|
6
|
+
* reported usage for is shown as unreported, never a fabricated number, and cost
|
|
7
|
+
* appears only for tiers you have priced.
|
|
8
|
+
*/
|
|
9
|
+
export declare function usageCommand(): Command;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { loadConfig } from "../../config/index.js";
|
|
3
|
+
import { shouldUseColor, usageError } from "../../errors/index.js";
|
|
4
|
+
import { themeForColor } from "../../theme/index.js";
|
|
5
|
+
import { logger } from "../../utils/logger.js";
|
|
6
|
+
import { loadUsage, summarizeRuns, renderSummary, } from "../../usage/index.js";
|
|
7
|
+
/**
|
|
8
|
+
* `cruxy usage` (C.22) — show LOCAL token usage and (when priced) cost, read
|
|
9
|
+
* back from `~/.cruxy/usage`. Read-only and local: it prints your own accounting
|
|
10
|
+
* and transmits nothing. Every figure is real — a request the provider never
|
|
11
|
+
* reported usage for is shown as unreported, never a fabricated number, and cost
|
|
12
|
+
* appears only for tiers you have priced.
|
|
13
|
+
*/
|
|
14
|
+
export function usageCommand() {
|
|
15
|
+
return new Command("usage")
|
|
16
|
+
.description("show token usage and cost for the session and recent runs")
|
|
17
|
+
.option("--session", "only the most recent session's runs")
|
|
18
|
+
.option("--last <n>", "only the last N runs")
|
|
19
|
+
.action((opts) => {
|
|
20
|
+
const t = themeForColor(shouldUseColor(process.stdout));
|
|
21
|
+
const { config } = loadConfig();
|
|
22
|
+
// A corrupt/unreadable store is surfaced as CRUXY_E_USAGE_READ with an
|
|
23
|
+
// actionable fix — it is not silently ignored, and it never crashes.
|
|
24
|
+
const { data, error } = loadUsage();
|
|
25
|
+
if (error)
|
|
26
|
+
throw error;
|
|
27
|
+
const scoped = selectRuns(data.runs, opts);
|
|
28
|
+
if (scoped.length === 0) {
|
|
29
|
+
logger.print(t.muted("no usage recorded yet"));
|
|
30
|
+
if (!config.usage.enabled) {
|
|
31
|
+
logger.print(t.muted("usage tracking is off (usage.enabled = false)"));
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const summary = summarizeRuns(scoped, {
|
|
36
|
+
prices: config.usage.prices,
|
|
37
|
+
currency: config.usage.currency,
|
|
38
|
+
});
|
|
39
|
+
const scopeLabel = opts.session
|
|
40
|
+
? "current session"
|
|
41
|
+
: opts.last
|
|
42
|
+
? `last ${scoped.length} run${scoped.length === 1 ? "" : "s"}`
|
|
43
|
+
: `all ${scoped.length} run${scoped.length === 1 ? "" : "s"}`;
|
|
44
|
+
logger.print(t.heading(`usage — ${scopeLabel}`));
|
|
45
|
+
logger.print(renderSummary(summary, t));
|
|
46
|
+
// State when NO price is configured, so an absent cost never reads as $0.
|
|
47
|
+
if (!summary.priced) {
|
|
48
|
+
logger.print(t.muted("cost omitted — no prices configured (set usage.prices.<tier>.{input,output}, per million tokens)"));
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Narrow the stored runs to the requested scope. `--last N` keeps the newest N;
|
|
54
|
+
* `--session` keeps the runs sharing the most recent run's session id; no flag
|
|
55
|
+
* keeps everything retained. `--session` and `--last` are mutually exclusive.
|
|
56
|
+
*/
|
|
57
|
+
function selectRuns(runs, opts) {
|
|
58
|
+
if (opts.session && opts.last !== undefined) {
|
|
59
|
+
throw usageError("pass only one of --session or --last", [
|
|
60
|
+
"cruxy usage --session (the most recent session)",
|
|
61
|
+
"cruxy usage --last 5 (the last 5 runs)",
|
|
62
|
+
]);
|
|
63
|
+
}
|
|
64
|
+
if (opts.last !== undefined) {
|
|
65
|
+
const n = Number(opts.last);
|
|
66
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
67
|
+
throw usageError(`--last must be a positive integer (got "${opts.last}")`);
|
|
68
|
+
}
|
|
69
|
+
return runs.slice(-n);
|
|
70
|
+
}
|
|
71
|
+
if (opts.session) {
|
|
72
|
+
const latest = runs[runs.length - 1];
|
|
73
|
+
if (!latest)
|
|
74
|
+
return [];
|
|
75
|
+
// Runs without a session id can't be grouped; scope to the latest run alone.
|
|
76
|
+
if (latest.sessionId === undefined)
|
|
77
|
+
return [latest];
|
|
78
|
+
return runs.filter((r) => r.sessionId === latest.sessionId);
|
|
79
|
+
}
|
|
80
|
+
return [...runs];
|
|
81
|
+
}
|
package/dist/cli/program.js
CHANGED
|
@@ -16,6 +16,7 @@ import { rollbackCommand } from "./commands/rollback.js";
|
|
|
16
16
|
import { testCommand } from "./commands/test.js";
|
|
17
17
|
import { hooksCommand } from "./commands/hooks.js";
|
|
18
18
|
import { memoryCommand } from "./commands/memory.js";
|
|
19
|
+
import { usageCommand } from "./commands/usage.js";
|
|
19
20
|
import { loadConfig } from "../config/index.js";
|
|
20
21
|
import { maybeRunOnboarding } from "./onboard.js";
|
|
21
22
|
export function buildProgram() {
|
|
@@ -47,6 +48,7 @@ export function buildProgram() {
|
|
|
47
48
|
program.addCommand(testCommand());
|
|
48
49
|
program.addCommand(hooksCommand());
|
|
49
50
|
program.addCommand(memoryCommand());
|
|
51
|
+
program.addCommand(usageCommand());
|
|
50
52
|
// Default action: bare `cruxy` -> entrypoint. An unrecognized first operand
|
|
51
53
|
// means an unknown command (Commander runs the default action with it as an
|
|
52
54
|
// operand rather than erroring), so reject it as a usage error.
|
|
@@ -9,6 +9,7 @@ import { Session, } from "../agent/index.js";
|
|
|
9
9
|
import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
|
|
10
10
|
import { routerForConfig } from "../routing/index.js";
|
|
11
11
|
import { MemoryService, rememberTool } from "../memory/index.js";
|
|
12
|
+
import { appendRun } from "../usage/index.js";
|
|
12
13
|
import { SubagentOrchestrator, makeSpawnSubagentTool, } from "../subagent/index.js";
|
|
13
14
|
/**
|
|
14
15
|
* Wrap a PromptIO so the live region yields before any prompt text lands
|
|
@@ -102,6 +103,19 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
102
103
|
// session, so the default path threads `undefined` and behaves exactly as
|
|
103
104
|
// before. One router is shared by the main loop, subagents, and plan mode.
|
|
104
105
|
const router = routerForConfig(config) ?? undefined;
|
|
106
|
+
// Usage telemetry (C.22): when enabled, each run's usage record is persisted
|
|
107
|
+
// to the LOCAL store. Best-effort and non-fatal — a corrupt/unwritable store
|
|
108
|
+
// is downgraded to a warning (CRUXY_E_USAGE_READ) and NEVER takes a run down.
|
|
109
|
+
// Nothing is transmitted. Off → the sink is undefined and no usage is written.
|
|
110
|
+
const onRunUsage = config.usage.enabled
|
|
111
|
+
? (record) => {
|
|
112
|
+
const { error } = appendRun(record, {
|
|
113
|
+
retention: config.usage.retention,
|
|
114
|
+
});
|
|
115
|
+
if (error)
|
|
116
|
+
logger.warn(`${error.code}: ${error.title} — ${error.cause}`);
|
|
117
|
+
}
|
|
118
|
+
: undefined;
|
|
105
119
|
const execRegistry = buildDefaultRegistry();
|
|
106
120
|
const git = getGitInfo(cwd);
|
|
107
121
|
const projectInstructions = loadProjectInstructions(cwd);
|
|
@@ -168,7 +182,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
168
182
|
requestApproval: gate(approval),
|
|
169
183
|
sandbox,
|
|
170
184
|
};
|
|
171
|
-
const planRunner = ({ messages, projectInstructions, recalledMemory: turnMemory, renderer: turnRenderer, }) => runPlanSession({
|
|
185
|
+
const planRunner = ({ messages, projectInstructions, recalledMemory: turnMemory, renderer: turnRenderer, onRequestUsage, }) => runPlanSession({
|
|
172
186
|
provider,
|
|
173
187
|
config,
|
|
174
188
|
ctx,
|
|
@@ -182,6 +196,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
182
196
|
recalledMemory: turnMemory,
|
|
183
197
|
renderer: turnRenderer,
|
|
184
198
|
router,
|
|
199
|
+
onRequestUsage,
|
|
185
200
|
});
|
|
186
201
|
return new Session({
|
|
187
202
|
provider,
|
|
@@ -195,6 +210,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
195
210
|
planRunner,
|
|
196
211
|
hooks,
|
|
197
212
|
router,
|
|
213
|
+
onRunUsage,
|
|
198
214
|
});
|
|
199
215
|
}
|
|
200
216
|
const approval = new ApprovalService({
|
|
@@ -213,5 +229,6 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
213
229
|
recalledMemory,
|
|
214
230
|
hooks,
|
|
215
231
|
router,
|
|
232
|
+
onRunUsage,
|
|
216
233
|
});
|
|
217
234
|
}
|