@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.
- package/README.md +1 -1
- package/dist/agent/session.js +11 -0
- package/dist/cli/command-catalog.js +5 -1
- package/dist/cli/commands/config.js +18 -3
- package/dist/cli/commands/logs.js +149 -0
- package/dist/cli/commands/pr.js +1 -10
- package/dist/cli/commands/run.js +6 -3
- package/dist/cli/commands/sessions.js +27 -2
- package/dist/cli/onboard.js +0 -9
- package/dist/cli/program.js +15 -1
- package/dist/cli/session-commands.js +45 -2
- package/dist/config/manager.js +91 -11
- package/dist/config/schema.js +53 -11
- package/dist/errors/constructors.js +35 -2
- package/dist/errors/types.js +6 -0
- package/dist/jobs/index.js +1 -0
- package/dist/jobs/log-renderer.js +10 -5
- package/dist/jobs/log-store.js +505 -0
- package/dist/jobs/manager.js +69 -1
- package/dist/routing/index.js +1 -1
- package/dist/routing/router.js +34 -14
- package/dist/routing/types.js +0 -2
- package/dist/session/prune.js +83 -23
- package/dist/subagent/orchestrator.js +16 -3
- package/dist/usage/types.js +25 -0
- package/package.json +1 -1
package/dist/routing/router.js
CHANGED
|
@@ -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
|
|
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
|
|
32
|
-
//
|
|
33
|
-
|
|
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
|
|
46
|
-
* Used so that when a user has pinned a single tier, an opt-in routing
|
|
47
|
-
* that omits `routing.default` still defaults to THEIR tier, not a fixed
|
|
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
|
-
:
|
|
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,
|
package/dist/routing/types.js
CHANGED
package/dist/session/prune.js
CHANGED
|
@@ -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
|
|
82
|
+
* Drop the job logs this project no longer has a reason to keep (#172 item 1).
|
|
80
83
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
/**
|
package/dist/usage/types.js
CHANGED
|
@@ -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
|
/**
|