@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.
Files changed (53) hide show
  1. package/dist/agent/loop.d.ts +15 -0
  2. package/dist/agent/loop.js +21 -0
  3. package/dist/agent/prompts.d.ts +7 -0
  4. package/dist/agent/prompts.js +6 -0
  5. package/dist/agent/session.d.ts +26 -1
  6. package/dist/agent/session.js +39 -6
  7. package/dist/cli/commands/memory.d.ts +8 -0
  8. package/dist/cli/commands/memory.js +98 -0
  9. package/dist/cli/commands/run.js +19 -0
  10. package/dist/cli/commands/usage.d.ts +9 -0
  11. package/dist/cli/commands/usage.js +81 -0
  12. package/dist/cli/program.js +4 -0
  13. package/dist/cli/session-factory.js +39 -1
  14. package/dist/config/schema.d.ts +336 -12
  15. package/dist/config/schema.js +56 -0
  16. package/dist/constants.d.ts +18 -0
  17. package/dist/constants.js +18 -0
  18. package/dist/errors/constructors.d.ts +21 -0
  19. package/dist/errors/constructors.js +64 -0
  20. package/dist/errors/types.d.ts +12 -0
  21. package/dist/errors/types.js +24 -0
  22. package/dist/hooks/types.d.ts +1 -1
  23. package/dist/memory/index.d.ts +7 -0
  24. package/dist/memory/index.js +7 -0
  25. package/dist/memory/recall.d.ts +32 -0
  26. package/dist/memory/recall.js +73 -0
  27. package/dist/memory/remember-tool.d.ts +25 -0
  28. package/dist/memory/remember-tool.js +56 -0
  29. package/dist/memory/secrets.d.ts +29 -0
  30. package/dist/memory/secrets.js +61 -0
  31. package/dist/memory/service.d.ts +92 -0
  32. package/dist/memory/service.js +164 -0
  33. package/dist/memory/store.d.ts +32 -0
  34. package/dist/memory/store.js +100 -0
  35. package/dist/memory/trust.d.ts +52 -0
  36. package/dist/memory/trust.js +106 -0
  37. package/dist/memory/types.d.ts +101 -0
  38. package/dist/memory/types.js +58 -0
  39. package/dist/plan/service.d.ts +13 -1
  40. package/dist/plan/service.js +4 -0
  41. package/dist/usage/collect.d.ts +40 -0
  42. package/dist/usage/collect.js +34 -0
  43. package/dist/usage/cost.d.ts +19 -0
  44. package/dist/usage/cost.js +29 -0
  45. package/dist/usage/index.d.ts +15 -0
  46. package/dist/usage/index.js +15 -0
  47. package/dist/usage/store.d.ts +37 -0
  48. package/dist/usage/store.js +83 -0
  49. package/dist/usage/summary.d.ts +32 -0
  50. package/dist/usage/summary.js +119 -0
  51. package/dist/usage/types.d.ts +220 -0
  52. package/dist/usage/types.js +47 -0
  53. package/package.json +1 -1
@@ -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
@@ -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
@@ -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. */
@@ -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
  /**
@@ -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
@@ -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
+ }
@@ -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
+ }
@@ -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
  }