@cruxy/cli 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,16 +2,11 @@ import { MODEL_TIERS } from "../brand/voice.js";
2
2
  import { routingTierUnavailable } from "../errors/index.js";
3
3
  import { resolveModelId } from "./resolve.js";
4
4
  import { AUTO_MODEL, } from "./types.js";
5
- /**
6
- * The tier a config resolves to when nothing else pins one down — mirrors the
7
- * gateway's `auto` fallback (`AUTO_FALLBACK_TIER` in the SDK), so an unrouted
8
- * cruxy session lands on exactly the tier it does today.
9
- */
10
- export const DEFAULT_TIER = "vaani";
11
5
  /**
12
6
  * The config-driven {@link Router}: maps a declared task class to a tier from
13
7
  * `{ default, map }`, and fails loud when the resolved tier is not offered. It
14
- * NEVER inspects prompt content — selection is purely `map[taskClass] ?? default`.
8
+ * NEVER inspects prompt content — selection is purely
9
+ * `map[taskClass] ?? default ?? null`.
15
10
  */
16
11
  export class ConfigRouter {
17
12
  cfg;
@@ -28,9 +23,17 @@ export class ConfigRouter {
28
23
  this.offered = new Set(offered);
29
24
  }
30
25
  select(taskClass) {
31
- // Explicit override, else the default an unmapped/unknown class is not an
32
- // error, it just takes the default (never a crash, never the cheapest).
33
- const tier = this.cfg.map[taskClass] ?? this.cfg.default;
26
+ // Explicit override, else the table's default, else DECLINE. An unmapped
27
+ // class is not an error and never a crash but with no `default` it is not
28
+ // a tier either. Declining means `auto`: the gateway routes that request
29
+ // (see `Router.select`), which is what a table saying nothing about a class
30
+ // actually asked for. Picking some tier here instead would be the same
31
+ // silent substitution the throw below refuses, just quieter — a user who
32
+ // wrote `map: { summarize: kavi }` and no default said nothing whatsoever
33
+ // about the other four classes, and "nothing" is not a vote for a tier.
34
+ const tier = this.cfg.map[taskClass] ?? this.cfg.default ?? null;
35
+ if (tier === null)
36
+ return null;
34
37
  // Fail loud: a configured tier the gateway does not offer is a usage error
35
38
  // to fix, NOT a silent substitution to some other tier (a user who asked for
36
39
  // mira reasoning must never be quietly handed kavi).
@@ -42,14 +45,16 @@ export class ConfigRouter {
42
45
  }
43
46
  /**
44
47
  * The base tier implied by the session's `model.model`: a real tier passes
45
- * through; `auto` (and any non-tier value) falls back to {@link DEFAULT_TIER}.
46
- * Used so that when a user has pinned a single tier, an opt-in routing table
47
- * that omits `routing.default` still defaults to THEIR tier, not a fixed one.
48
+ * through, and `auto` (or any non-tier value) implies none `undefined`, not a
49
+ * stand-in. Used so that when a user has pinned a single tier, an opt-in routing
50
+ * table that omits `routing.default` still defaults to THEIR tier, not a fixed
51
+ * one; and so that when they pinned nothing, the table does not acquire a
52
+ * default they never wrote.
48
53
  */
49
54
  function baseTierFromModel(model) {
50
55
  return MODEL_TIERS.includes(model)
51
56
  ? model
52
- : DEFAULT_TIER;
57
+ : undefined;
53
58
  }
54
59
  /**
55
60
  * Build a router from resolved config, or `null` when routing should stay
@@ -68,6 +73,21 @@ export function routerForConfig(config) {
68
73
  const configured = def !== undefined || Object.keys(map).length > 0;
69
74
  if (!configured)
70
75
  return null;
76
+ // The fill is deliberate in ONE direction. When `model.model` names a real
77
+ // tier the user declared a session-wide model, so a table without a `default`
78
+ // inherits it — dropping those classes to `auto` would substitute the
79
+ // gateway's judgement for a choice they made, which is the same wrong as
80
+ // substituting vaani for `auto`, from the other side. When it names no tier
81
+ // (`auto`, the schema default) `baseTierFromModel` returns undefined and the
82
+ // table keeps its silence.
83
+ //
84
+ // NOT DEAD CODE, despite looking it from the session path: `session-factory`
85
+ // hands this router to a `SessionModel` that consults it only while the choice
86
+ // is `auto` (session-factory.ts:341), and a `model.model` naming a tier makes
87
+ // the choice that tier — so the tier branch never fires there. `cruxy pr`
88
+ // calls `routerForConfig` directly (cli/commands/pr.ts:69) with no
89
+ // `SessionModel` in front of it, and that is where a pinned `model.model`
90
+ // reaches this line.
71
91
  return new ConfigRouter({
72
92
  default: def ?? baseTierFromModel(config.model.model),
73
93
  map,
@@ -20,8 +20,6 @@ export const TASK_CLASSES = [
20
20
  "plan",
21
21
  /** Commit / pull-request text generation. */
22
22
  "commit-msg",
23
- /** A cheap one-shot classification. */
24
- "classify",
25
23
  /** Context compaction / summarization. */
26
24
  "summarize",
27
25
  ];
@@ -1,5 +1,6 @@
1
1
  import { unlinkSync } from "node:fs";
2
2
  import path from "node:path";
3
+ import { INTERRUPTED, idKey, jobLogFilesByRecency, readJobLogTerminal, sessionKeysPresent, } from "../jobs/log-store.js";
3
4
  import { SESSION_RETENTION_FLOOR, } from "../config/index.js";
4
5
  import { sessionFilesByRecency } from "./list.js";
5
6
  import { SESSION_FILE_EXT } from "./paths.js";
@@ -30,6 +31,8 @@ export function pruneSessions(cwd, opts) {
30
31
  kept: 0,
31
32
  bytesFreed: 0,
32
33
  failed: 0,
34
+ jobLogsRemoved: 0,
35
+ jobLogBytesFreed: 0,
33
36
  };
34
37
  if (!opts.sessions.enabled)
35
38
  return result;
@@ -72,35 +75,92 @@ export function pruneSessions(cwd, opts) {
72
75
  result.kept++;
73
76
  }
74
77
  }
75
- sweepOrphanedJobLogs();
78
+ sweepOrphanedJobLogs(cwd, result, opts);
76
79
  return result;
77
80
  }
78
81
  /**
79
- * Drop retained job logs whose owning session has just been pruned.
82
+ * Drop the job logs this project no longer has a reason to keep (#172 item 1).
80
83
  *
81
- * DELIBERATELY EMPTY, and reserved rather than forgotten. #172 item 1 will
82
- * write background-job output to the `subagents/` subtree `paths.ts` reserves
83
- * under this same project directory, and #257 exists because that issue asked
84
- * "whether the retained-on-disk log is pruned with the session or independently"
85
- * and found no answer. The answer is HERE, and it is: with the session, keyed on
86
- * the session.
84
+ * This is the second phase of the same mark-and-sweep `CheckpointService.prune`
85
+ * runs: the doomed sessions are already gone above, and what follows is the
86
+ * content that belonged to them. A job log IS content owned by its session,
87
+ * keyed on the session, exactly as a checkpoint's shadow objects are content
88
+ * owned by its manifest which is the answer #172 asked for in passing and
89
+ * #257 was filed to settle: pruned WITH the session, not independently.
87
90
  *
88
- * That makes a job log content owned by its session in exactly the way a
89
- * checkpoint's shadow objects are content owned by its manifest — so this is
90
- * `CheckpointService.prune`'s second phase, the mark-and-sweep that runs after
91
- * the doomed manifests are gone. Filling it in needs one thing the writer does
92
- * not have yet: a job log has to record which session owns it, in its path or
93
- * its first line.
91
+ * ## The three rules, and why each one is where the line falls
94
92
  *
95
- * The hook is here so item 1 fills it rather than inventing a parallel
96
- * retention policy alongside this one. Whatever lands must not `rm -r` this
97
- * directory and must not assume every entry is a session's — `subagents/` is a
98
- * sibling of the session files, not one of them.
93
+ * **A finished job's log goes at the next start.** A `done` job's OUTCOME is
94
+ * already durable somewhere better: the dispatch tool's result is a
95
+ * `tool_result` in the session's own transcript, its file mutations are a
96
+ * checkpoint set `cruxy rollback <id>` still finds, and its spend is a usage
97
+ * record under the same id. What only this file holds is the intermediate
98
+ * chatter of a run that went fine — the least interesting artefact in the set.
99
+ * Dropping it eagerly is what keeps the steady-state cost of this subtree near
100
+ * zero, so the logs that survive are the ones somebody would actually open: the
101
+ * failed and the interrupted.
99
102
  *
100
- * It takes nothing today because it does nothing; item 1 will want the `cwd`
101
- * and the {@link PruneResult} whose `removed` names the sessions whose logs are
102
- * now orphaned.
103
+ * **An orphan goes when its session does.** Not only the sessions THIS prune
104
+ * removed: the rule is "no session file with this key is present", which also
105
+ * covers `cruxy sessions rm`, a prune from another process, and a file the user
106
+ * deleted by hand. Keying it on what the directory holds rather than on
107
+ * `result.removed` means there is no path by which a log outlives its session
108
+ * unnoticed.
109
+ *
110
+ * **Nothing without a terminal record is deleted while it is young.** A log
111
+ * with no `end` record is one of two things, and this sweep cannot tell them
112
+ * apart: a job that was interrupted (the whole reason to keep logs at all) or a
113
+ * job that is RUNNING RIGHT NOW in another terminal, whose session may not have
114
+ * flushed its own file yet. Treating it as garbage would delete a live job's
115
+ * output from under it. Both cases are handled by leaving it alone until it is
116
+ * older than `maxAgeDays`, at which point it is certainly not live and its
117
+ * session is certainly gone.
118
+ *
119
+ * The active session is skipped outright, for the reason `pruneSessions` spares
120
+ * its own file: "almost always survives" is not a policy to hand a user's
121
+ * running work.
122
+ *
123
+ * ## What it must not do
124
+ *
125
+ * No `rm -r`, and no assumption that every entry here is a session's — the
126
+ * scan behind {@link jobLogFilesByRecency} filters to regular `.jsonl` files
127
+ * whose name splits into exactly two ids, so anything else in `subagents/`
128
+ * (including the subtree's own subdirectories, should P3 add any) is never even
129
+ * considered. Never throws: a log that cannot be unlinked is left, exactly as a
130
+ * session that cannot be is.
103
131
  */
104
- function sweepOrphanedJobLogs() {
105
- // #172 item 1.
132
+ function sweepOrphanedJobLogs(cwd, result, opts) {
133
+ const refs = jobLogFilesByRecency(cwd);
134
+ if (refs.length === 0)
135
+ return;
136
+ // Derived from the directory as it stands NOW — after the deletes above — so
137
+ // the sessions this prune just removed are absent from it by construction and
138
+ // need no separate bookkeeping.
139
+ const present = sessionKeysPresent(sessionFilesByRecency(cwd));
140
+ const activeKey = opts.activeSessionId === undefined
141
+ ? undefined
142
+ : idKey(opts.activeSessionId);
143
+ const now = opts.now ?? Date.now();
144
+ const cutoff = now - opts.sessions.maxAgeDays * 24 * 60 * 60 * 1000;
145
+ for (const ref of refs) {
146
+ if (activeKey !== undefined && ref.sessionKey === activeKey)
147
+ continue;
148
+ // One bounded tail read per file, never a full one: see `TAIL_BYTES`.
149
+ const status = readJobLogTerminal(ref.file);
150
+ const orphaned = !present.has(ref.sessionKey);
151
+ const doomed = status === "done" ||
152
+ (orphaned && (status !== INTERRUPTED || ref.mtimeMs < cutoff));
153
+ if (!doomed)
154
+ continue;
155
+ try {
156
+ unlinkSync(ref.file);
157
+ result.jobLogsRemoved++;
158
+ result.jobLogBytesFreed += ref.size;
159
+ }
160
+ catch {
161
+ // Left in place, and deliberately NOT counted in `failed` — that field
162
+ // counts sessions, and inflating it here would report a conversation that
163
+ // could not be deleted when none was even tried.
164
+ }
165
+ }
106
166
  }
@@ -147,7 +147,6 @@ export class SubagentOrchestrator {
147
147
  const startedAt = new Date().toISOString();
148
148
  try {
149
149
  return await this.runChild({
150
- spec,
151
150
  opts,
152
151
  messages,
153
152
  registry,
@@ -193,7 +192,7 @@ export class SubagentOrchestrator {
193
192
  * usage fold above can be a `finally` over every path this can leave by. */
194
193
  async runChild(args) {
195
194
  const { deps } = this;
196
- const { spec, opts, messages, registry, budget, ctx, artifacts } = args;
195
+ const { opts, messages, registry, budget, ctx, artifacts } = args;
197
196
  const { label, noun, tag, usage } = args;
198
197
  let run;
199
198
  try {
@@ -211,7 +210,13 @@ export class SubagentOrchestrator {
211
210
  subagent: true,
212
211
  budget,
213
212
  router: deps.router,
214
- taskClass: spec.taskClass ?? "subagent",
213
+ // A LITERAL, not a per-spawn override (cli#281). Every child is a
214
+ // `subagent` by construction; letting the spec name its own class would
215
+ // hand the model a lever to route itself out of `routing.map.subagent`
216
+ // by relabelling, and would put the difficulty guess back in a call site
217
+ // whose whole contract (`routing/types.ts`) is that callers declare and
218
+ // nobody sniffs.
219
+ taskClass: "subagent",
215
220
  signal: opts.signal,
216
221
  // Both, and in this order (cli#244). The child's OWN collector is what
217
222
  // the budget fold below reads — it needs a per-child record — while the
@@ -439,6 +444,14 @@ export class SubagentOrchestrator {
439
444
  return undefined; // no cruxy routing — not a weighted-pool request
440
445
  // No tier means the router chose `auto` and the gateway decides — a request
441
446
  // that still draws on the pool, so it is weighed at the worst case.
447
+ //
448
+ // A routing table that maps some classes and declares no `default` now
449
+ // lands here for `subagent` (it used to be filled with vaani), so such a
450
+ // fan-out is weighed at MAX_TIER_MULTIPLIER 3.8 rather than vaani's 3.25 —
451
+ // about 17% heavier against the pool. That is the intended answer, not
452
+ // drift: the tier genuinely is not known before the request, and a
453
+ // fan-out the gateway sends to kavi must not have been admitted on
454
+ // vaani's arithmetic.
442
455
  return resolveTaskModel(router, "subagent").tier ?? UNRESOLVED_TIER;
443
456
  }
444
457
  /**
@@ -108,6 +108,31 @@ export const UsageEntrySchema = z
108
108
  *
109
109
  * Absent means the gateway reported nothing, NOT that no reasoning ran; a
110
110
  * gateway saying so writes the literal `"none"`.
111
+ *
112
+ * FORENSIC ONLY, AND IT CANNOT BE MADE INTO A CONTROL BY READING HARDER.
113
+ * cli#195 proposed a per-task-class effort FLOOR — a config table whose most
114
+ * valuable entry was `none` on the cheap classes — and its stated ordering
115
+ * put a baseline first: collect real efforts, confirm compaction is being
116
+ * reasoned at `high`, then decide. That baseline cannot be collected from
117
+ * this field, and the obstacle is one field down. {@link origin} folds
118
+ * threshold-triggered compaction into `"turn"` — the main loop AND its
119
+ * compaction, deliberately, so `runCount` keeps meaning "turns" — so an
120
+ * effort recorded for a `summarize` call is indistinguishable from one
121
+ * recorded for the turn that triggered it. Only a manual `/compact` is
122
+ * separable, and it is separable because it is NOT the case in question.
123
+ * A week of passive collection therefore yields efforts that cannot be
124
+ * attributed to the class the floor would target. That is a property of the
125
+ * record's shape, not of the sample size, and it is why #195 closed on the
126
+ * measurement rather than on the empty week that prompted the look.
127
+ *
128
+ * WHAT TO REACH FOR INSTEAD, when the goal is a cheaper compaction. Effort
129
+ * lives inside output, and output is a minority of the weighted unit —
130
+ * roughly 7.8% across the store's own history, which is the ceiling for
131
+ * eliminating ALL reasoning on ALL traffic, not the floor's share of it.
132
+ * `TIER_MULTIPLIERS` moves the whole unit instead: kavi 3.8 → vaani 3.25 is
133
+ * 14.5% off input and output together, on the most input-heavy request the
134
+ * CLI makes. `routing.map.summarize` already expresses that, in config, with
135
+ * no code. Reach for the multiplier before reaching for the effort.
111
136
  */
112
137
  reasoningEffort: z.string().optional(),
113
138
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {