@cruxy/cli 0.17.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 +15 -0
- package/dist/agent/loop.js +21 -0
- package/dist/agent/prompts.d.ts +7 -0
- package/dist/agent/prompts.js +6 -0
- package/dist/agent/session.d.ts +26 -1
- package/dist/agent/session.js +39 -6
- package/dist/cli/commands/memory.d.ts +8 -0
- package/dist/cli/commands/memory.js +98 -0
- 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 +4 -0
- package/dist/cli/session-factory.js +39 -1
- package/dist/config/schema.d.ts +336 -12
- package/dist/config/schema.js +56 -0
- package/dist/constants.d.ts +18 -0
- package/dist/constants.js +18 -0
- package/dist/errors/constructors.d.ts +21 -0
- package/dist/errors/constructors.js +64 -0
- package/dist/errors/types.d.ts +12 -0
- package/dist/errors/types.js +24 -0
- package/dist/hooks/types.d.ts +1 -1
- package/dist/memory/index.d.ts +7 -0
- package/dist/memory/index.js +7 -0
- package/dist/memory/recall.d.ts +32 -0
- package/dist/memory/recall.js +73 -0
- package/dist/memory/remember-tool.d.ts +25 -0
- package/dist/memory/remember-tool.js +56 -0
- package/dist/memory/secrets.d.ts +29 -0
- package/dist/memory/secrets.js +61 -0
- package/dist/memory/service.d.ts +92 -0
- package/dist/memory/service.js +164 -0
- package/dist/memory/store.d.ts +32 -0
- package/dist/memory/store.js +100 -0
- package/dist/memory/trust.d.ts +52 -0
- package/dist/memory/trust.js +106 -0
- package/dist/memory/types.d.ts +101 -0
- package/dist/memory/types.js +58 -0
- package/dist/plan/service.d.ts +13 -1
- package/dist/plan/service.js +4 -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
|
@@ -40,6 +40,9 @@ export interface RunAgentArgs {
|
|
|
40
40
|
} | null;
|
|
41
41
|
/** Project instructions (e.g. from CRUXY.md) folded into the system prompt. */
|
|
42
42
|
projectInstructions?: string | null;
|
|
43
|
+
/** Persistent memory (C.29): the pre-rendered recall block for the system
|
|
44
|
+
* prompt, or null. Reference data only — never influences the U.3 gate. */
|
|
45
|
+
recalledMemory?: string | null;
|
|
43
46
|
/** Plan mode's propose phase (C.31): inject the plan-first system directive. */
|
|
44
47
|
planMode?: boolean;
|
|
45
48
|
/** Subagent runs (C.14): inject the bounded-subtask system directive. */
|
|
@@ -69,6 +72,18 @@ export interface RunAgentArgs {
|
|
|
69
72
|
/** The declared task class for routing; defaults to `main-turn`. Ignored
|
|
70
73
|
* unless `router` is set. */
|
|
71
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;
|
|
72
87
|
}
|
|
73
88
|
/**
|
|
74
89
|
* The budget seam for {@link runAgent}: implementations track their own caps
|
package/dist/agent/loop.js
CHANGED
|
@@ -56,6 +56,7 @@ async function driveLoop(args, renderer, routed) {
|
|
|
56
56
|
.map((tool) => ({ name: tool.name, description: tool.description })),
|
|
57
57
|
git: args.git ?? null,
|
|
58
58
|
projectInstructions: args.projectInstructions ?? null,
|
|
59
|
+
recalledMemory: args.recalledMemory ?? null,
|
|
59
60
|
planMode: args.planMode ?? false,
|
|
60
61
|
subagent: args.subagent ?? false,
|
|
61
62
|
});
|
|
@@ -80,6 +81,12 @@ async function driveLoop(args, renderer, routed) {
|
|
|
80
81
|
let turnText = "";
|
|
81
82
|
const pending = new Map();
|
|
82
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 };
|
|
83
90
|
// Live progress while waiting on the model; dismissed by the first delta.
|
|
84
91
|
// Token context is whatever the loop has actually accumulated (U.4): zero
|
|
85
92
|
// on the first turn → no figure shown, never a fabricated number.
|
|
@@ -120,6 +127,12 @@ async function driveLoop(args, renderer, routed) {
|
|
|
120
127
|
case "usage":
|
|
121
128
|
usage.input_tokens = ev.usage.input_tokens || usage.input_tokens;
|
|
122
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;
|
|
123
136
|
break;
|
|
124
137
|
case "message_stop":
|
|
125
138
|
// Turn complete; the stream ends after this.
|
|
@@ -130,6 +143,14 @@ async function driveLoop(args, renderer, routed) {
|
|
|
130
143
|
break;
|
|
131
144
|
}
|
|
132
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
|
+
});
|
|
133
154
|
// ── Record the assistant turn ───────────────────────────────────────────
|
|
134
155
|
if (turnText) {
|
|
135
156
|
// Streaming (renderer set): the text already reached the user delta by
|
package/dist/agent/prompts.d.ts
CHANGED
|
@@ -28,6 +28,13 @@ export interface PromptContext {
|
|
|
28
28
|
} | null;
|
|
29
29
|
/** Optional extra instructions (e.g. from a project CRUXY.md). */
|
|
30
30
|
projectInstructions?: string | null;
|
|
31
|
+
/**
|
|
32
|
+
* Persistent memory (C.29): the pre-rendered, demarcated recall block injected
|
|
33
|
+
* at session start. It is REFERENCE DATA, not instructions — the block carries
|
|
34
|
+
* its own data-only framing (see memory/recall.ts) and is appended as an
|
|
35
|
+
* ordinary section; the U.3 gate never reads it. Null when nothing is recalled.
|
|
36
|
+
*/
|
|
37
|
+
recalledMemory?: string | null;
|
|
31
38
|
/** Plan mode's propose phase (C.31): inject the plan-first directive. */
|
|
32
39
|
planMode?: boolean;
|
|
33
40
|
/** Subagent run (C.14): inject the bounded-subtask directive. */
|
package/dist/agent/prompts.js
CHANGED
|
@@ -91,6 +91,12 @@ export function buildSystemPrompt(ctx) {
|
|
|
91
91
|
if (ctx.projectInstructions?.trim()) {
|
|
92
92
|
sections.push(`## Project instructions\nThe following came from this project's configuration; honor it unless it conflicts with the rules above:\n\n${ctx.projectInstructions.trim()}`);
|
|
93
93
|
}
|
|
94
|
+
// Persistent memory (C.29). Appended last, as reference DATA — the block is
|
|
95
|
+
// pre-rendered with its own un-spoofable data-only demarcation, so it is added
|
|
96
|
+
// verbatim (never re-wrapped as an instruction). Absent when nothing recalled.
|
|
97
|
+
if (ctx.recalledMemory?.trim()) {
|
|
98
|
+
sections.push(ctx.recalledMemory.trim());
|
|
99
|
+
}
|
|
94
100
|
return sections.join("\n\n");
|
|
95
101
|
}
|
|
96
102
|
/**
|
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
|
|
@@ -14,7 +15,12 @@ import { type AgentResult, type LifecycleHookRunner } from "./loop.js";
|
|
|
14
15
|
export type PlanRunner = (args: {
|
|
15
16
|
messages: Message[];
|
|
16
17
|
projectInstructions: string | null;
|
|
18
|
+
recalledMemory: string | null;
|
|
17
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;
|
|
18
24
|
}) => Promise<AgentResult>;
|
|
19
25
|
export interface SessionArgs {
|
|
20
26
|
/** A constructed provider to stream from. */
|
|
@@ -32,6 +38,13 @@ export interface SessionArgs {
|
|
|
32
38
|
} | null;
|
|
33
39
|
/** Project instructions (e.g. CRUXY.md) folded into every turn's system prompt. */
|
|
34
40
|
projectInstructions?: string | null;
|
|
41
|
+
/**
|
|
42
|
+
* Persistent memory (C.29): the pre-rendered recall block, injected into every
|
|
43
|
+
* turn's system prompt as reference data. Built once at session start (user
|
|
44
|
+
* memory + trusted project memory); null when memory is off or empty. Fixed
|
|
45
|
+
* for the session — it does not re-read mid-session.
|
|
46
|
+
*/
|
|
47
|
+
recalledMemory?: string | null;
|
|
35
48
|
/** Start in plan mode (C.31). Toggleable at runtime via `setPlanMode`. */
|
|
36
49
|
planMode?: boolean;
|
|
37
50
|
/** The plan-mode turn runner; required for plan mode to actually engage. */
|
|
@@ -48,6 +61,12 @@ export interface SessionArgs {
|
|
|
48
61
|
* context compaction on `summarize`; omitted → the provider default (unchanged).
|
|
49
62
|
*/
|
|
50
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;
|
|
51
70
|
}
|
|
52
71
|
/**
|
|
53
72
|
* Estimate the token footprint of a message list with a cheap chars/4 heuristic
|
|
@@ -72,6 +91,12 @@ export declare class Session {
|
|
|
72
91
|
messages: Message[];
|
|
73
92
|
/** Token usage summed across every `send` (and every compaction) in this session. */
|
|
74
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;
|
|
75
100
|
private readonly args;
|
|
76
101
|
/** Mutable so `/reload` can refresh CRUXY.md mid-session. */
|
|
77
102
|
private projectInstructions;
|
|
@@ -112,7 +137,7 @@ export declare class Session {
|
|
|
112
137
|
* returns the number of older messages folded into the summary; otherwise
|
|
113
138
|
* returns `null` (under threshold, nothing safe to cut, or summary failed).
|
|
114
139
|
*/
|
|
115
|
-
maybeCompact(): Promise<number | null>;
|
|
140
|
+
maybeCompact(onRequestUsage?: (req: RequestUsage) => void): Promise<number | null>;
|
|
116
141
|
/**
|
|
117
142
|
* Force compaction regardless of the threshold (backs `/compact`). Returns the
|
|
118
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.
|
|
@@ -99,7 +114,9 @@ export class Session {
|
|
|
99
114
|
? await this.args.planRunner({
|
|
100
115
|
messages: this.messages,
|
|
101
116
|
projectInstructions: this.projectInstructions,
|
|
117
|
+
recalledMemory: this.args.recalledMemory ?? null,
|
|
102
118
|
renderer,
|
|
119
|
+
onRequestUsage: onReq,
|
|
103
120
|
})
|
|
104
121
|
: await runAgent({
|
|
105
122
|
messages: this.messages,
|
|
@@ -109,10 +126,17 @@ export class Session {
|
|
|
109
126
|
projectInstructions: this.projectInstructions,
|
|
110
127
|
planMode: false, // the plan directive belongs only to the runner's propose phase
|
|
111
128
|
renderer,
|
|
129
|
+
onRequestUsage: onReq,
|
|
112
130
|
});
|
|
113
131
|
this.messages = result.messages;
|
|
114
132
|
this.usage.input_tokens += result.usage.input_tokens;
|
|
115
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);
|
|
116
140
|
// after-run (C.19): advisory by default (a blocking after-run hook throws
|
|
117
141
|
// and surfaces at the boundary). The turn already completed and its history
|
|
118
142
|
// is adopted above — an advisory failure never rewrites it.
|
|
@@ -138,12 +162,12 @@ export class Session {
|
|
|
138
162
|
* returns the number of older messages folded into the summary; otherwise
|
|
139
163
|
* returns `null` (under threshold, nothing safe to cut, or summary failed).
|
|
140
164
|
*/
|
|
141
|
-
async maybeCompact() {
|
|
165
|
+
async maybeCompact(onRequestUsage) {
|
|
142
166
|
const { maxTokens, compactThreshold } = this.args.config.context;
|
|
143
167
|
if (estimateTokens(this.messages) <= compactThreshold * maxTokens) {
|
|
144
168
|
return null;
|
|
145
169
|
}
|
|
146
|
-
const n = await this.runCompaction();
|
|
170
|
+
const n = await this.runCompaction(onRequestUsage);
|
|
147
171
|
if (n) {
|
|
148
172
|
this.args.ctx.logger.info(`compacted ${n} older message${n === 1 ? "" : "s"} to stay within context`);
|
|
149
173
|
}
|
|
@@ -163,7 +187,7 @@ export class Session {
|
|
|
163
187
|
* failed summary call leaves the history untouched and returns `null` (fail
|
|
164
188
|
* open — losing compaction is degraded, not unsafe).
|
|
165
189
|
*/
|
|
166
|
-
async runCompaction() {
|
|
190
|
+
async runCompaction(onRequestUsage) {
|
|
167
191
|
const cut = this.findCut();
|
|
168
192
|
if (cut === null)
|
|
169
193
|
return null;
|
|
@@ -171,7 +195,7 @@ export class Session {
|
|
|
171
195
|
const kept = this.messages.slice(cut);
|
|
172
196
|
let synopsis;
|
|
173
197
|
try {
|
|
174
|
-
const summary = await this.summarize(prefix);
|
|
198
|
+
const summary = await this.summarize(prefix, onRequestUsage);
|
|
175
199
|
synopsis = summary.text;
|
|
176
200
|
this.usage.input_tokens += summary.usage.input_tokens;
|
|
177
201
|
this.usage.output_tokens += summary.usage.output_tokens;
|
|
@@ -224,7 +248,7 @@ export class Session {
|
|
|
224
248
|
* Summarize a prefix via a standalone, tool-less provider call over a rendered
|
|
225
249
|
* transcript. Throws on a stream error or empty output so callers fail open.
|
|
226
250
|
*/
|
|
227
|
-
async summarize(prefix) {
|
|
251
|
+
async summarize(prefix, onRequestUsage) {
|
|
228
252
|
const transcript = renderTranscript(prefix);
|
|
229
253
|
const usage = { input_tokens: 0, output_tokens: 0 };
|
|
230
254
|
let text = "";
|
|
@@ -233,6 +257,9 @@ export class Session {
|
|
|
233
257
|
const routed = this.args.router
|
|
234
258
|
? resolveTaskModel(this.args.router, "summarize")
|
|
235
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;
|
|
236
263
|
for await (const ev of this.args.provider.stream({
|
|
237
264
|
system: SUMMARY_SYSTEM,
|
|
238
265
|
messages: [{ role: "user", content: transcript }],
|
|
@@ -245,6 +272,7 @@ export class Session {
|
|
|
245
272
|
case "usage":
|
|
246
273
|
usage.input_tokens = ev.usage.input_tokens || usage.input_tokens;
|
|
247
274
|
usage.output_tokens += ev.usage.output_tokens;
|
|
275
|
+
sawUsage = true;
|
|
248
276
|
break;
|
|
249
277
|
case "error":
|
|
250
278
|
throw ev.error;
|
|
@@ -252,6 +280,11 @@ export class Session {
|
|
|
252
280
|
break;
|
|
253
281
|
}
|
|
254
282
|
}
|
|
283
|
+
// Attribute this compaction request to the `summarize` tier honestly.
|
|
284
|
+
onRequestUsage?.({
|
|
285
|
+
tier: routed?.tier,
|
|
286
|
+
usage: sawUsage ? { ...usage } : undefined,
|
|
287
|
+
});
|
|
255
288
|
if (!text.trim())
|
|
256
289
|
throw new Error("summary was empty");
|
|
257
290
|
return { text: text.trim(), usage };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
/**
|
|
3
|
+
* `cruxy memory` — inspect and control persistent memory (C.29). `list` shows
|
|
4
|
+
* user + project entries and whether project memory is trusted (recalled);
|
|
5
|
+
* `forget`/`clear` remove entries; `trust` records the explicit decision to
|
|
6
|
+
* recall a repo's project memory, bound to its current fingerprint.
|
|
7
|
+
*/
|
|
8
|
+
export declare function memoryCommand(): Command;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { loadConfig } from "../../config/index.js";
|
|
4
|
+
import { shouldUseColor } from "../../errors/index.js";
|
|
5
|
+
import { themeForColor } from "../../theme/index.js";
|
|
6
|
+
import { MemoryService } from "../../memory/index.js";
|
|
7
|
+
import { logger } from "../../utils/logger.js";
|
|
8
|
+
/**
|
|
9
|
+
* `cruxy memory` — inspect and control persistent memory (C.29). `list` shows
|
|
10
|
+
* user + project entries and whether project memory is trusted (recalled);
|
|
11
|
+
* `forget`/`clear` remove entries; `trust` records the explicit decision to
|
|
12
|
+
* recall a repo's project memory, bound to its current fingerprint.
|
|
13
|
+
*/
|
|
14
|
+
export function memoryCommand() {
|
|
15
|
+
const cmd = new Command("memory").description("inspect and control persistent memory recalled across sessions");
|
|
16
|
+
const service = () => {
|
|
17
|
+
const { config } = loadConfig();
|
|
18
|
+
return new MemoryService({ cwd: process.cwd(), config: config.memory });
|
|
19
|
+
};
|
|
20
|
+
cmd
|
|
21
|
+
.command("list", { isDefault: true })
|
|
22
|
+
.description("list saved memory entries and project-memory trust status")
|
|
23
|
+
.action(() => {
|
|
24
|
+
const t = themeForColor(shouldUseColor(process.stdout));
|
|
25
|
+
const { config } = loadConfig();
|
|
26
|
+
const status = service().status();
|
|
27
|
+
logger.print(`${t.strong("memory:")} ${config.memory.enabled ? t.success("enabled") : t.warning("disabled (memory.enabled = false)")}`);
|
|
28
|
+
logger.print(`\n${t.heading("user memory")} ${t.muted("(trusted)")}`);
|
|
29
|
+
printEntries(status.user, t);
|
|
30
|
+
const trustLabel = status.projectTrusted
|
|
31
|
+
? t.success("trusted — recalled")
|
|
32
|
+
: t.danger("NOT trusted — not recalled; run `cruxy memory trust .`");
|
|
33
|
+
logger.print(`\n${t.heading("project memory")} ${trustLabel}`);
|
|
34
|
+
printEntries(status.project, t);
|
|
35
|
+
if (status.errors.length > 0) {
|
|
36
|
+
logger.print(`\n${t.danger(t.heading("excluded (invalid or secret — never recalled):"))}`);
|
|
37
|
+
for (const e of status.errors) {
|
|
38
|
+
logger.print(` ${t.muted(`[${e.scope}]`)} ${t.strong(e.id)} — ${e.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
cmd
|
|
43
|
+
.command("forget <id>")
|
|
44
|
+
.description("forget one saved memory entry by id")
|
|
45
|
+
.action((id) => {
|
|
46
|
+
const t = themeForColor(shouldUseColor(process.stdout));
|
|
47
|
+
const removed = service().forget(id);
|
|
48
|
+
logger.print(removed
|
|
49
|
+
? `${t.success("forgotten")} — entry ${t.strong(id)} removed`
|
|
50
|
+
: t.warning(`no memory entry with id ${id}`));
|
|
51
|
+
});
|
|
52
|
+
cmd
|
|
53
|
+
.command("clear")
|
|
54
|
+
.description("clear saved memory entries in a scope")
|
|
55
|
+
.option("--scope <scope>", "which scope to clear: user | project | all", "all")
|
|
56
|
+
.action((opts) => {
|
|
57
|
+
const t = themeForColor(shouldUseColor(process.stdout));
|
|
58
|
+
const scope = opts.scope;
|
|
59
|
+
if (scope !== "user" && scope !== "project" && scope !== "all") {
|
|
60
|
+
logger.print(t.danger(`invalid --scope ${scope} (use user | project | all)`));
|
|
61
|
+
process.exitCode = 2;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const removed = service().clear(scope);
|
|
65
|
+
const where = scope === "all" ? "across all scopes" : `from ${scope} memory`;
|
|
66
|
+
logger.print(`${t.success("cleared")} — removed ${removed} ` +
|
|
67
|
+
`entr${removed === 1 ? "y" : "ies"} ${where}`);
|
|
68
|
+
});
|
|
69
|
+
cmd
|
|
70
|
+
.command("trust [path]")
|
|
71
|
+
.description("trust this repo's project memory after reviewing it (records the decision)")
|
|
72
|
+
.action((target) => {
|
|
73
|
+
const t = themeForColor(shouldUseColor(process.stdout));
|
|
74
|
+
const { config } = loadConfig();
|
|
75
|
+
const root = path.resolve(target ?? process.cwd());
|
|
76
|
+
const svc = new MemoryService({ cwd: root, config: config.memory });
|
|
77
|
+
const status = svc.status();
|
|
78
|
+
if (status.project.length === 0) {
|
|
79
|
+
logger.print(t.muted(`no project memory found under ${root}`));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
logger.print(t.strong(`trusting ${status.project.length} project entr${status.project.length === 1 ? "y" : "ies"}:`));
|
|
83
|
+
printEntries(status.project, t);
|
|
84
|
+
const count = svc.trustProject();
|
|
85
|
+
logger.print(`${t.success("trusted")} — ${count} project entr${count === 1 ? "y" : "ies"} will now be recalled for ${root}. ` +
|
|
86
|
+
t.muted("changing them will require re-trusting."));
|
|
87
|
+
});
|
|
88
|
+
return cmd;
|
|
89
|
+
}
|
|
90
|
+
function printEntries(entries, t) {
|
|
91
|
+
if (entries.length === 0) {
|
|
92
|
+
logger.print(t.muted(" (none)"));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
for (const e of entries) {
|
|
96
|
+
logger.print(` ${t.muted(e.id.slice(0, 8))} ${t.strong(`(${e.kind})`)} ${e.content}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
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
|
@@ -15,6 +15,8 @@ import { checkpointCommand } from "./commands/checkpoint.js";
|
|
|
15
15
|
import { rollbackCommand } from "./commands/rollback.js";
|
|
16
16
|
import { testCommand } from "./commands/test.js";
|
|
17
17
|
import { hooksCommand } from "./commands/hooks.js";
|
|
18
|
+
import { memoryCommand } from "./commands/memory.js";
|
|
19
|
+
import { usageCommand } from "./commands/usage.js";
|
|
18
20
|
import { loadConfig } from "../config/index.js";
|
|
19
21
|
import { maybeRunOnboarding } from "./onboard.js";
|
|
20
22
|
export function buildProgram() {
|
|
@@ -45,6 +47,8 @@ export function buildProgram() {
|
|
|
45
47
|
program.addCommand(rollbackCommand());
|
|
46
48
|
program.addCommand(testCommand());
|
|
47
49
|
program.addCommand(hooksCommand());
|
|
50
|
+
program.addCommand(memoryCommand());
|
|
51
|
+
program.addCommand(usageCommand());
|
|
48
52
|
// Default action: bare `cruxy` -> entrypoint. An unrecognized first operand
|
|
49
53
|
// means an unknown command (Commander runs the default action with it as an
|
|
50
54
|
// operand rather than erroring), so reject it as a usage error.
|
|
@@ -8,6 +8,8 @@ import { buildDefaultRegistry } from "../tools/index.js";
|
|
|
8
8
|
import { Session, } from "../agent/index.js";
|
|
9
9
|
import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
|
|
10
10
|
import { routerForConfig } from "../routing/index.js";
|
|
11
|
+
import { MemoryService, rememberTool } from "../memory/index.js";
|
|
12
|
+
import { appendRun } from "../usage/index.js";
|
|
11
13
|
import { SubagentOrchestrator, makeSpawnSubagentTool, } from "../subagent/index.js";
|
|
12
14
|
/**
|
|
13
15
|
* Wrap a PromptIO so the live region yields before any prompt text lands
|
|
@@ -101,9 +103,39 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
101
103
|
// session, so the default path threads `undefined` and behaves exactly as
|
|
102
104
|
// before. One router is shared by the main loop, subagents, and plan mode.
|
|
103
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;
|
|
104
119
|
const execRegistry = buildDefaultRegistry();
|
|
105
120
|
const git = getGitInfo(cwd);
|
|
106
121
|
const projectInstructions = loadProjectInstructions(cwd);
|
|
122
|
+
// Persistent memory (C.29): register the write tool and build the recall block
|
|
123
|
+
// ONCE at session start. User memory is always eligible; project memory is
|
|
124
|
+
// recalled only when trusted (a cloned repo's notes never inject silently).
|
|
125
|
+
// Fully inert when disabled. Recall is best-effort — a load problem degrades to
|
|
126
|
+
// "no memory", never a hard failure at session start.
|
|
127
|
+
let recalledMemory = null;
|
|
128
|
+
if (config.memory.enabled) {
|
|
129
|
+
execRegistry.register(rememberTool);
|
|
130
|
+
const recall = new MemoryService({ cwd, config: config.memory }).recall();
|
|
131
|
+
recalledMemory = recall.block;
|
|
132
|
+
if (recall.projectPresentButUntrusted) {
|
|
133
|
+
logger.info("project memory found but not trusted — run `cruxy memory trust .` to recall it");
|
|
134
|
+
}
|
|
135
|
+
for (const e of recall.errors) {
|
|
136
|
+
logger.warn(`memory: excluded ${e.scope} entry ${e.id} — ${e.message}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
107
139
|
// One io shared by every prompt in the session (plan approval, the U.3 gate,
|
|
108
140
|
// and any gate inside a subagent), so they all coordinate with the same live
|
|
109
141
|
// region. The full wrapper stack around an ApprovalService is factored here
|
|
@@ -150,7 +182,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
150
182
|
requestApproval: gate(approval),
|
|
151
183
|
sandbox,
|
|
152
184
|
};
|
|
153
|
-
const planRunner = ({ messages, projectInstructions, renderer: turnRenderer, }) => runPlanSession({
|
|
185
|
+
const planRunner = ({ messages, projectInstructions, recalledMemory: turnMemory, renderer: turnRenderer, onRequestUsage, }) => runPlanSession({
|
|
154
186
|
provider,
|
|
155
187
|
config,
|
|
156
188
|
ctx,
|
|
@@ -161,8 +193,10 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
161
193
|
messages,
|
|
162
194
|
git,
|
|
163
195
|
projectInstructions,
|
|
196
|
+
recalledMemory: turnMemory,
|
|
164
197
|
renderer: turnRenderer,
|
|
165
198
|
router,
|
|
199
|
+
onRequestUsage,
|
|
166
200
|
});
|
|
167
201
|
return new Session({
|
|
168
202
|
provider,
|
|
@@ -171,10 +205,12 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
171
205
|
ctx,
|
|
172
206
|
git,
|
|
173
207
|
projectInstructions,
|
|
208
|
+
recalledMemory,
|
|
174
209
|
planMode: true,
|
|
175
210
|
planRunner,
|
|
176
211
|
hooks,
|
|
177
212
|
router,
|
|
213
|
+
onRunUsage,
|
|
178
214
|
});
|
|
179
215
|
}
|
|
180
216
|
const approval = new ApprovalService({
|
|
@@ -190,7 +226,9 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
190
226
|
ctx,
|
|
191
227
|
git,
|
|
192
228
|
projectInstructions,
|
|
229
|
+
recalledMemory,
|
|
193
230
|
hooks,
|
|
194
231
|
router,
|
|
232
|
+
onRunUsage,
|
|
195
233
|
});
|
|
196
234
|
}
|