@cruxy/cli 1.2.1 → 1.4.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/context.js +178 -0
- package/dist/agent/index.js +1 -0
- package/dist/agent/loop.js +20 -1
- package/dist/agent/mode.js +103 -0
- package/dist/agent/prompts.js +1 -1
- package/dist/agent/session.js +171 -69
- package/dist/agent/status.js +56 -0
- package/dist/approval/classify.js +204 -0
- package/dist/approval/policy.js +41 -3
- package/dist/approval/prompt.js +49 -22
- package/dist/checkpoint/gate.js +12 -0
- package/dist/cli/commands/run.js +401 -227
- package/dist/cli/commands/usage.js +45 -45
- package/dist/cli/onboard.js +2 -1
- package/dist/cli/program.js +60 -18
- package/dist/cli/repl.js +67 -249
- package/dist/cli/session-commands.js +717 -0
- package/dist/cli/session-factory.js +198 -76
- package/dist/cli/suggest.js +77 -0
- package/dist/components/fuzzy.js +3 -3
- package/dist/components/input.js +17 -2
- package/dist/components/keys.js +65 -3
- package/dist/components/select.js +3 -3
- package/dist/config/effective.js +225 -0
- package/dist/config/index.js +1 -0
- package/dist/config/manager.js +50 -20
- package/dist/config/project.js +53 -1
- package/dist/config/schema.js +49 -16
- package/dist/jobs/log-renderer.js +47 -0
- package/dist/onboarding/steps.js +13 -22
- package/dist/plan/approve.js +36 -24
- package/dist/plan/execute.js +9 -7
- package/dist/plan/render.js +10 -23
- package/dist/plan/service.js +4 -1
- package/dist/render/capabilities.js +30 -1
- package/dist/render/context-view.js +106 -0
- package/dist/render/diff.js +204 -12
- package/dist/render/index.js +31 -5
- package/dist/render/plain-renderer.js +38 -2
- package/dist/render/plan-view.js +108 -0
- package/dist/render/resize.js +7 -2
- package/dist/render/status-view.js +66 -0
- package/dist/render/test-view.js +89 -0
- package/dist/render/tty-renderer.js +40 -0
- package/dist/routing/index.js +1 -0
- package/dist/routing/router.js +13 -4
- package/dist/routing/session-model.js +109 -0
- package/dist/routing/types.js +14 -0
- package/dist/session/export.js +88 -0
- package/dist/session/index.js +20 -0
- package/dist/session/list.js +137 -0
- package/dist/session/log.js +137 -0
- package/dist/session/paths.js +73 -0
- package/dist/session/replay.js +169 -0
- package/dist/session/resume.js +128 -0
- package/dist/session/types.js +223 -0
- package/dist/subagent/orchestrator.js +23 -0
- package/dist/testing/run-tests-tool.js +8 -0
- package/dist/tools/registry.js +3 -3
- package/dist/tui/app.js +508 -0
- package/dist/tui/approval-overlay.js +160 -0
- package/dist/tui/context-gauge.js +48 -0
- package/dist/tui/git-status.js +108 -0
- package/dist/tui/git-view.js +121 -0
- package/dist/tui/index.js +15 -0
- package/dist/tui/layout.js +314 -0
- package/dist/tui/overlay.js +105 -0
- package/dist/tui/overview.js +49 -0
- package/dist/tui/palette.js +73 -0
- package/dist/tui/panels.js +235 -0
- package/dist/tui/renderer.js +1121 -0
- package/dist/tui/settings-view.js +282 -0
- package/dist/tui/supports.js +20 -0
- package/dist/tui/tasks-view.js +215 -0
- package/dist/tui/tool-versions.js +129 -0
- package/dist/tui/views.js +66 -0
- package/dist/usage/collect.js +6 -6
- package/dist/usage/index.js +10 -2
- package/dist/usage/report.js +76 -0
- package/dist/usage/summary.js +106 -17
- package/dist/usage/types.js +5 -2
- package/dist/usage/weighted.js +77 -0
- package/dist/utils/git.js +163 -4
- package/package.json +1 -1
- package/dist/usage/cost.js +0 -29
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { COMPACTION_MARKER } from "./prompts.js";
|
|
2
|
+
/** Characters a single content block contributes to the estimate. */
|
|
3
|
+
function blockChars(block) {
|
|
4
|
+
switch (block.type) {
|
|
5
|
+
case "text":
|
|
6
|
+
return block.text.length;
|
|
7
|
+
case "tool_use":
|
|
8
|
+
return block.name.length + JSON.stringify(block.input).length;
|
|
9
|
+
case "tool_result":
|
|
10
|
+
return block.content.length;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Characters one message contributes. Block structure and role labels are ignored. */
|
|
14
|
+
export function messageChars(message) {
|
|
15
|
+
if (typeof message.content === "string")
|
|
16
|
+
return message.content.length;
|
|
17
|
+
let chars = 0;
|
|
18
|
+
for (const block of message.content)
|
|
19
|
+
chars += blockChars(block);
|
|
20
|
+
return chars;
|
|
21
|
+
}
|
|
22
|
+
/** Chars → tokens, the one place the chars/4 heuristic is applied. */
|
|
23
|
+
export function charsToTokens(chars) {
|
|
24
|
+
return Math.ceil(chars / 4);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Estimate the token footprint of a message list with a cheap chars/4 heuristic
|
|
28
|
+
* — no tokenizer dependency. Good enough to decide *when* to compact; exact
|
|
29
|
+
* counts are deferred to a later phase. Counts only textual payload (block
|
|
30
|
+
* structure and role labels are negligible and ignored).
|
|
31
|
+
*/
|
|
32
|
+
export function estimateTokens(messages) {
|
|
33
|
+
let chars = 0;
|
|
34
|
+
for (const message of messages)
|
|
35
|
+
chars += messageChars(message);
|
|
36
|
+
return charsToTokens(chars);
|
|
37
|
+
}
|
|
38
|
+
/** Compute a reading from a history — pure, and shared by the gauge and `/context`. */
|
|
39
|
+
export function readContext(messages, budget) {
|
|
40
|
+
// Deliberately the SAME expression `compactIfOverThreshold` branches on: the
|
|
41
|
+
// reserve covers the system prompt and tool schemas that `estimateTokens`
|
|
42
|
+
// never sees, and omitting it here would under-report by ~4.5k tokens and let
|
|
43
|
+
// the panel read "comfortable" while the seam was about to compact.
|
|
44
|
+
const used = estimateTokens(messages) + budget.reserveTokens;
|
|
45
|
+
const total = budget.maxTokens;
|
|
46
|
+
return {
|
|
47
|
+
used,
|
|
48
|
+
total,
|
|
49
|
+
fraction: total <= 0 ? 1 : Math.min(1, Math.max(0, used / total)),
|
|
50
|
+
compactAt: Math.round(budget.compactThreshold * total),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Choose the boundary between the summarized prefix and the kept-recent tail.
|
|
55
|
+
*
|
|
56
|
+
* Tool-call integrity is the constraint: a `tool_use` (assistant) and its
|
|
57
|
+
* matching `tool_result` (the next user message) must never straddle the cut,
|
|
58
|
+
* or the next provider call breaks. A real user *prompt* (`role:"user"` with
|
|
59
|
+
* string content) only occurs at a completed turn boundary, where every prior
|
|
60
|
+
* tool exchange is already resolved — so the kept region must begin there. The
|
|
61
|
+
* synthetic compaction-summary user message is also string content, so a repeat
|
|
62
|
+
* compaction always finds at least the previous summary as a clean cut.
|
|
63
|
+
*
|
|
64
|
+
* Start from `length - keepRecentMessages` and walk *backwards* to the nearest
|
|
65
|
+
* such prompt: this keeps at least the recent floor and lands clean. Returns
|
|
66
|
+
* the cut index, or `null` if no safe boundary leaves a non-empty prefix (e.g.
|
|
67
|
+
* a single long in-progress turn — nothing safe to compact).
|
|
68
|
+
*
|
|
69
|
+
* Extracted from `Session` (P6 track 3) so `/context` can report what compaction
|
|
70
|
+
* would do by calling the function that decides what it does. A second
|
|
71
|
+
* implementation for the explanation would be free to be subtly wrong exactly
|
|
72
|
+
* where it mattered.
|
|
73
|
+
*/
|
|
74
|
+
export function findCut(messages, keepRecentMessages) {
|
|
75
|
+
const start = messages.length - keepRecentMessages;
|
|
76
|
+
for (let i = start; i >= 1; i--) {
|
|
77
|
+
const message = messages[i];
|
|
78
|
+
if (message.role === "user" && typeof message.content === "string")
|
|
79
|
+
return i;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
/** Which part a message belongs to. */
|
|
84
|
+
function classify(message) {
|
|
85
|
+
if (typeof message.content === "string") {
|
|
86
|
+
// The synthetic pair a previous compaction spliced in. Worth its own bucket:
|
|
87
|
+
// "40% of your context is a summary of earlier context" is a different fact
|
|
88
|
+
// from "40% is your prompts", and it is the one that says compaction has
|
|
89
|
+
// already run.
|
|
90
|
+
return message.content.startsWith(COMPACTION_MARKER)
|
|
91
|
+
? "summaries"
|
|
92
|
+
: "prompts";
|
|
93
|
+
}
|
|
94
|
+
if (message.role === "user")
|
|
95
|
+
return "tool results";
|
|
96
|
+
return message.content.some((b) => b.type === "tool_use")
|
|
97
|
+
? "tool calls"
|
|
98
|
+
: "assistant";
|
|
99
|
+
}
|
|
100
|
+
/** A one-line excerpt of a message, for recognition rather than reading. */
|
|
101
|
+
function excerpt(message, max = 60) {
|
|
102
|
+
let text;
|
|
103
|
+
if (typeof message.content === "string") {
|
|
104
|
+
text = message.content;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
const first = message.content[0];
|
|
108
|
+
text =
|
|
109
|
+
first === undefined
|
|
110
|
+
? ""
|
|
111
|
+
: first.type === "text"
|
|
112
|
+
? first.text
|
|
113
|
+
: first.type === "tool_use"
|
|
114
|
+
? first.name
|
|
115
|
+
: first.content;
|
|
116
|
+
}
|
|
117
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
118
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Analyse a history against a budget. Pure; no I/O, no theme, no rendering.
|
|
122
|
+
*
|
|
123
|
+
* @param topN how many individual messages to name as the largest contributors.
|
|
124
|
+
*/
|
|
125
|
+
export function contextReport(messages, budget, topN = 5) {
|
|
126
|
+
const charsByPart = new Map();
|
|
127
|
+
const contributors = [];
|
|
128
|
+
for (const [index, message] of messages.entries()) {
|
|
129
|
+
const chars = messageChars(message);
|
|
130
|
+
const part = classify(message);
|
|
131
|
+
const bucket = charsByPart.get(part) ?? { chars: 0, messages: 0 };
|
|
132
|
+
bucket.chars += chars;
|
|
133
|
+
bucket.messages += 1;
|
|
134
|
+
charsByPart.set(part, bucket);
|
|
135
|
+
contributors.push({ chars, message, index });
|
|
136
|
+
}
|
|
137
|
+
const parts = [...charsByPart.entries()]
|
|
138
|
+
.map(([part, b]) => ({
|
|
139
|
+
part,
|
|
140
|
+
tokens: charsToTokens(b.chars),
|
|
141
|
+
messages: b.messages,
|
|
142
|
+
}))
|
|
143
|
+
.filter((p) => p.tokens > 0)
|
|
144
|
+
.sort((a, b) => b.tokens - a.tokens);
|
|
145
|
+
const largest = contributors
|
|
146
|
+
.filter((c) => c.chars > 0)
|
|
147
|
+
.sort((a, b) => b.chars - a.chars)
|
|
148
|
+
.slice(0, topN)
|
|
149
|
+
.map((c) => ({
|
|
150
|
+
label: classify(c.message),
|
|
151
|
+
excerpt: excerpt(c.message),
|
|
152
|
+
tokens: charsToTokens(c.chars),
|
|
153
|
+
position: c.index + 1,
|
|
154
|
+
}));
|
|
155
|
+
const cut = findCut(messages, budget.keepRecentMessages);
|
|
156
|
+
const reading = readContext(messages, budget);
|
|
157
|
+
return {
|
|
158
|
+
reading,
|
|
159
|
+
messages: messages.length,
|
|
160
|
+
reserveTokens: budget.reserveTokens,
|
|
161
|
+
parts,
|
|
162
|
+
largest,
|
|
163
|
+
compaction: {
|
|
164
|
+
cut,
|
|
165
|
+
droppedMessages: cut ?? 0,
|
|
166
|
+
droppedTokens: cut === null ? 0 : estimateTokens(messages.slice(0, cut)),
|
|
167
|
+
keptMessages: cut === null ? messages.length : messages.length - cut,
|
|
168
|
+
keptTokens: cut === null
|
|
169
|
+
? estimateTokens(messages)
|
|
170
|
+
: estimateTokens(messages.slice(cut)),
|
|
171
|
+
// Compared against the UNROUNDED product, exactly as
|
|
172
|
+
// `compactIfOverThreshold` does — `compactAt` is rounded for display, and
|
|
173
|
+
// a report claiming to say what compaction would do must not disagree
|
|
174
|
+
// with the seam over a fraction of a token.
|
|
175
|
+
overThreshold: reading.used > budget.compactThreshold * budget.maxTokens,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
package/dist/agent/index.js
CHANGED
package/dist/agent/loop.js
CHANGED
|
@@ -24,6 +24,11 @@ export async function runAgent(args) {
|
|
|
24
24
|
// gateway does not offer throws CRUXY_E_ROUTING_TIER_UNAVAILABLE here — so a
|
|
25
25
|
// misrouted run never reaches provider.stream (no request sent with the wrong
|
|
26
26
|
// tier), and never silently falls back to a different one.
|
|
27
|
+
//
|
|
28
|
+
// Resolving per RUN, not per iteration, is still right after P6 track 1 made
|
|
29
|
+
// the choice mutable: a `/model` mid-turn is impossible (the input loop has
|
|
30
|
+
// released stdin and is awaiting `session.send`), and a run that changed model
|
|
31
|
+
// between its own iterations would attribute one history to two tiers.
|
|
27
32
|
const routed = args.router
|
|
28
33
|
? resolveTaskModel(args.router, args.taskClass ?? "main-turn")
|
|
29
34
|
: null;
|
|
@@ -38,7 +43,11 @@ export async function runAgent(args) {
|
|
|
38
43
|
}
|
|
39
44
|
}
|
|
40
45
|
/** The body of {@link runAgent}, split out so turn cleanup lives in one finally. */
|
|
41
|
-
async function driveLoop(args, renderer,
|
|
46
|
+
async function driveLoop(args, renderer,
|
|
47
|
+
// `tier` is optional because a router may decline and send the request as
|
|
48
|
+
// `auto` — no tier was chosen client-side, so there is none to report until
|
|
49
|
+
// the gateway's routing frame arrives. See `resolveTaskModel`.
|
|
50
|
+
routed) {
|
|
42
51
|
const { provider, registry, config, ctx } = args;
|
|
43
52
|
const { logger } = ctx;
|
|
44
53
|
// Work on a copy so we never mutate the caller's array as a side effect; the
|
|
@@ -142,6 +151,16 @@ async function driveLoop(args, renderer, routed) {
|
|
|
142
151
|
case "routing":
|
|
143
152
|
servedTier = ev.routing.tier;
|
|
144
153
|
routingMode = ev.routing.mode;
|
|
154
|
+
// Publish to the renderer HERE, not at the `setPhase` above. That one
|
|
155
|
+
// fires before `provider.stream` is called, so the served tier is not
|
|
156
|
+
// knowable yet and it can only carry `routed?.tier` — what this run
|
|
157
|
+
// ASKED for. This frame is the first moment the backend's answer
|
|
158
|
+
// exists, and it is the answer worth showing: it resolves `auto`, and
|
|
159
|
+
// it reflects a downgrade the client never chose.
|
|
160
|
+
renderer?.servedRouting({
|
|
161
|
+
tier: ev.routing.tier,
|
|
162
|
+
...(ev.routing.mode !== undefined ? { mode: ev.routing.mode } : {}),
|
|
163
|
+
});
|
|
145
164
|
break;
|
|
146
165
|
case "text_delta":
|
|
147
166
|
turnText += ev.text;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session modes (P5 track 3) — the one piece of state that says how much a turn
|
|
3
|
+
* does without asking.
|
|
4
|
+
*
|
|
5
|
+
* Two facts govern that, and before P5 they were kept in different places and at
|
|
6
|
+
* different lifetimes: plan mode lived on the `Session` (runtime, journaled,
|
|
7
|
+
* restored on resume) while auto-approve was a config key read from disk. The
|
|
8
|
+
* config key was also dead — nothing consumed `agent.autoApprove`, and
|
|
9
|
+
* `ApprovalConfigSchema` in the same file stated flatly that no auto-approve
|
|
10
|
+
* mode exists "not behind a footgun flag". One file promised something another
|
|
11
|
+
* refused to do.
|
|
12
|
+
*
|
|
13
|
+
* A mode is the cross product of the two, named. That is what makes it a
|
|
14
|
+
* SUBSUMPTION rather than a third source of truth: `Session` holds exactly one
|
|
15
|
+
* mode and derives both booleans from it, so there is no state in which plan
|
|
16
|
+
* mode and the mode indicator can disagree.
|
|
17
|
+
*
|
|
18
|
+
* Auto-approve is deliberately a RUNTIME mode and only that — never a config
|
|
19
|
+
* key. A flag on disk silently disarms every approval in every session that
|
|
20
|
+
* loads it, with nothing on screen to say so; a mode is chosen in the session it
|
|
21
|
+
* affects, shown while it is active, and gone when the session ends.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Cycle order, and the order every list of modes uses. Shift+Tab walks this
|
|
25
|
+
* ring; the last entry wraps to the first.
|
|
26
|
+
*/
|
|
27
|
+
export const SESSION_MODES = [
|
|
28
|
+
"manual",
|
|
29
|
+
"auto-approve",
|
|
30
|
+
"plan",
|
|
31
|
+
"full-auto",
|
|
32
|
+
];
|
|
33
|
+
/** The default for a session that says nothing — the pre-P5 behaviour exactly. */
|
|
34
|
+
export const DEFAULT_MODE = "manual";
|
|
35
|
+
/** The next mode in the ring. Total: every mode has a successor. */
|
|
36
|
+
export function nextMode(mode) {
|
|
37
|
+
const i = SESSION_MODES.indexOf(mode);
|
|
38
|
+
return SESSION_MODES[(i + 1) % SESSION_MODES.length];
|
|
39
|
+
}
|
|
40
|
+
/** Whether this mode proposes a plan before executing (C.31). */
|
|
41
|
+
export function modePlans(mode) {
|
|
42
|
+
return mode === "plan" || mode === "full-auto";
|
|
43
|
+
}
|
|
44
|
+
/** Whether this mode allows gated actions without prompting. */
|
|
45
|
+
export function modeAutoApproves(mode) {
|
|
46
|
+
return mode === "auto-approve" || mode === "full-auto";
|
|
47
|
+
}
|
|
48
|
+
/** Short label for the status line and the mode indicator. */
|
|
49
|
+
export const MODE_LABELS = {
|
|
50
|
+
manual: "manual",
|
|
51
|
+
"auto-approve": "auto-approve",
|
|
52
|
+
plan: "plan",
|
|
53
|
+
"full-auto": "full-auto",
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* One line saying what the mode will actually do, shown when it changes.
|
|
57
|
+
*
|
|
58
|
+
* A mode that skips approvals has to SAY it skips approvals at the moment it is
|
|
59
|
+
* switched on. The whole objection to the config flag was that it disarmed the
|
|
60
|
+
* gate with nothing on screen; a runtime mode that announced itself as vaguely
|
|
61
|
+
* as "auto-approve on" would reproduce the same problem more slowly.
|
|
62
|
+
*
|
|
63
|
+
* These lines must not overstate the mode either. Saying "including destructive
|
|
64
|
+
* ones" when the ceiling stops exactly those would teach the user to expect a
|
|
65
|
+
* silence that never comes — and, worse, teach them the gate is gone when it is
|
|
66
|
+
* not. Each auto line names the suppression AND its limit.
|
|
67
|
+
*/
|
|
68
|
+
export function modeDescription(mode) {
|
|
69
|
+
switch (mode) {
|
|
70
|
+
case "manual":
|
|
71
|
+
return "every action asks first";
|
|
72
|
+
case "auto-approve":
|
|
73
|
+
return "reversible actions run WITHOUT asking — irreversible ones still ask";
|
|
74
|
+
case "plan":
|
|
75
|
+
return "propose a plan for approval, then ask before each action";
|
|
76
|
+
case "full-auto":
|
|
77
|
+
return "propose a plan, then run it WITHOUT asking — irreversible actions still ask";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Parse a mode from user input (`/mode <name>`), or null when it names nothing.
|
|
82
|
+
* Never guesses: a typo must not silently arm auto-approve.
|
|
83
|
+
*/
|
|
84
|
+
export function parseMode(text) {
|
|
85
|
+
const want = text.trim().toLowerCase();
|
|
86
|
+
return SESSION_MODES.find((m) => m === want) ?? null;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Recover a mode from the two booleans an older session journaled.
|
|
90
|
+
*
|
|
91
|
+
* Sessions recorded before P5 carry `plan-mode` events and nothing else, so
|
|
92
|
+
* auto-approve reads false — which is right: it was not a thing that could be
|
|
93
|
+
* on. This is what lets a pre-P5 session resume into the mode it actually had.
|
|
94
|
+
*/
|
|
95
|
+
export function modeFromFlags(plans, autoApproves) {
|
|
96
|
+
if (plans && autoApproves)
|
|
97
|
+
return "full-auto";
|
|
98
|
+
if (plans)
|
|
99
|
+
return "plan";
|
|
100
|
+
if (autoApproves)
|
|
101
|
+
return "auto-approve";
|
|
102
|
+
return "manual";
|
|
103
|
+
}
|
package/dist/agent/prompts.js
CHANGED
package/dist/agent/session.js
CHANGED
|
@@ -1,39 +1,19 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { loadProjectInstructions } from "../config/index.js";
|
|
3
|
-
import { resolveTaskModel } from "../routing/index.js";
|
|
3
|
+
import { resolveTaskModel, } from "../routing/index.js";
|
|
4
4
|
import { UsageCollector, accumulateCacheTokens, } from "../usage/index.js";
|
|
5
5
|
import { Budget } from "./budget.js";
|
|
6
|
+
import { estimateTokens, findCut } from "./context.js";
|
|
6
7
|
import { runAgent, } from "./loop.js";
|
|
8
|
+
import { DEFAULT_MODE, modeAutoApproves, modePlans, nextMode, parseMode, } from "./mode.js";
|
|
7
9
|
import { SUMMARY_SYSTEM, COMPACTION_MARKER } from "./prompts.js";
|
|
8
10
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Re-exported from `agent/context.ts`, where the estimate now lives beside the
|
|
12
|
+
* cut-point search and the budget reading (P6 track 3) — one module for
|
|
13
|
+
* everything that measures the window, so the seam that acts on the numbers and
|
|
14
|
+
* the surfaces that explain them cannot drift.
|
|
13
15
|
*/
|
|
14
|
-
export
|
|
15
|
-
let chars = 0;
|
|
16
|
-
for (const msg of messages) {
|
|
17
|
-
if (typeof msg.content === "string") {
|
|
18
|
-
chars += msg.content.length;
|
|
19
|
-
continue;
|
|
20
|
-
}
|
|
21
|
-
for (const block of msg.content) {
|
|
22
|
-
switch (block.type) {
|
|
23
|
-
case "text":
|
|
24
|
-
chars += block.text.length;
|
|
25
|
-
break;
|
|
26
|
-
case "tool_use":
|
|
27
|
-
chars += block.name.length + JSON.stringify(block.input).length;
|
|
28
|
-
break;
|
|
29
|
-
case "tool_result":
|
|
30
|
-
chars += block.content.length;
|
|
31
|
-
break;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
return Math.ceil(chars / 4);
|
|
36
|
-
}
|
|
16
|
+
export { estimateTokens } from "./context.js";
|
|
37
17
|
/**
|
|
38
18
|
* Owns the state of one multi-turn conversation: the running message history and
|
|
39
19
|
* the usage accumulated across turns. Each `send` continues from the prior
|
|
@@ -52,43 +32,150 @@ export class Session {
|
|
|
52
32
|
usage = { input_tokens: 0, output_tokens: 0 };
|
|
53
33
|
/** Stable id for this session (C.22), so a run's usage record groups with the
|
|
54
34
|
* other runs of the same interactive session (`cruxy usage --session`). */
|
|
55
|
-
sessionId
|
|
35
|
+
sessionId;
|
|
56
36
|
/** The most recent run's usage record (C.22) — the one-shot path reads it to
|
|
57
37
|
* print the end-of-run summary. */
|
|
58
38
|
lastRun;
|
|
59
39
|
args;
|
|
60
40
|
/** Mutable so `/reload` can refresh CRUXY.md mid-session. */
|
|
61
41
|
projectInstructions;
|
|
62
|
-
/**
|
|
63
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The session's mode (P5 track 3) — the ONE piece of state saying how much a
|
|
44
|
+
* turn does without asking. Mutable so `/mode` and Shift+Tab can change it
|
|
45
|
+
* mid-session.
|
|
46
|
+
*
|
|
47
|
+
* Plan mode is DERIVED from this, not stored beside it. Before P5 the session
|
|
48
|
+
* held a `planMode` boolean while auto-approve was a (dead) config key, which
|
|
49
|
+
* is two sources of truth for one question and exactly the shape of bug the
|
|
50
|
+
* tier work had just finished removing.
|
|
51
|
+
*/
|
|
52
|
+
mode;
|
|
53
|
+
/**
|
|
54
|
+
* How many messages of the CURRENT history the recorder already holds (P2).
|
|
55
|
+
*
|
|
56
|
+
* This is what lets an append-only log track an array that is rewritten in
|
|
57
|
+
* place. The invariant: a replay of the log so far reproduces exactly the
|
|
58
|
+
* first `recordedCount` messages of the live history. So an append is
|
|
59
|
+
* `slice(recordedCount)`, and a compaction updates the watermark to the
|
|
60
|
+
* post-compaction length rather than re-emitting anything.
|
|
61
|
+
*
|
|
62
|
+
* It is only ever advanced at points where the history is COHERENT — a
|
|
63
|
+
* tool_use and its tool_result are never split across a flush — because the
|
|
64
|
+
* loop calls the compaction seam at the top of an iteration, after the
|
|
65
|
+
* previous iteration fully resolved its tool calls.
|
|
66
|
+
*/
|
|
67
|
+
recordedCount = 0;
|
|
64
68
|
constructor(args) {
|
|
65
69
|
this.args = args;
|
|
66
70
|
this.projectInstructions = args.projectInstructions ?? null;
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
|
|
71
|
+
this.mode = this.resolveMode(args.mode ?? DEFAULT_MODE);
|
|
72
|
+
// Resume (P2): adopt the replayed state verbatim. The history is already
|
|
73
|
+
// recorded in the log we are continuing, so the watermark starts at its
|
|
74
|
+
// full length — otherwise the first flush would append the whole restored
|
|
75
|
+
// conversation a second time.
|
|
76
|
+
const restore = args.restore;
|
|
77
|
+
this.sessionId = restore?.sessionId ?? randomUUID();
|
|
78
|
+
if (restore) {
|
|
79
|
+
this.messages = restore.messages;
|
|
80
|
+
this.usage.input_tokens = restore.usage.input_tokens;
|
|
81
|
+
this.usage.output_tokens = restore.usage.output_tokens;
|
|
82
|
+
this.recordedCount = restore.messages.length;
|
|
83
|
+
this.mode = this.resolveMode(restore.mode);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Resolve a requested mode to one this session can actually honour.
|
|
88
|
+
*
|
|
89
|
+
* A planning mode needs a wired `planRunner`; without one it degrades to the
|
|
90
|
+
* non-planning mode with the same approval behaviour rather than half-engaging
|
|
91
|
+
* (plan directive injected, nothing orchestrating it). Auto-approve is
|
|
92
|
+
* unaffected — it needs nothing wired.
|
|
93
|
+
*
|
|
94
|
+
* Unknown strings (a journal from a newer build, a hand-edited log) fall back
|
|
95
|
+
* to the default. Failing closed matters here specifically: the fallback must
|
|
96
|
+
* be the mode that asks MORE, never one that asks less.
|
|
97
|
+
*/
|
|
98
|
+
resolveMode(want) {
|
|
99
|
+
const mode = parseMode(String(want)) ?? DEFAULT_MODE;
|
|
100
|
+
if (!modePlans(mode) || this.args.planRunner !== undefined)
|
|
101
|
+
return mode;
|
|
102
|
+
return mode === "full-auto" ? "auto-approve" : "manual";
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Record everything appended to `current` since the last flush.
|
|
106
|
+
*
|
|
107
|
+
* Callers must only pass a history whose first `recordedCount` messages are
|
|
108
|
+
* unchanged — every call site satisfies this because the only operation that
|
|
109
|
+
* rewrites the head is compaction, which updates the watermark itself.
|
|
110
|
+
*/
|
|
111
|
+
flushRecorded(current) {
|
|
112
|
+
if (!this.args.recorder)
|
|
113
|
+
return;
|
|
114
|
+
if (current.length <= this.recordedCount)
|
|
115
|
+
return;
|
|
116
|
+
this.args.recorder.append(current.slice(this.recordedCount));
|
|
117
|
+
this.recordedCount = current.length;
|
|
70
118
|
}
|
|
71
119
|
/** The ambient tool capabilities (gate + sandbox + cwd/config). Exposed so a
|
|
72
120
|
* shell-bound custom slash command (C.19) runs through the SAME gated path. */
|
|
73
121
|
get toolContext() {
|
|
74
122
|
return this.args.ctx;
|
|
75
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* The tool catalogue advertised to the model this session — the one
|
|
126
|
+
* `registerRuntimeTools` built, so it reflects which optional families
|
|
127
|
+
* (memory, LSP, web, MCP, subagents, jobs) are actually enabled. Exposed
|
|
128
|
+
* read-only, for `/status` to count.
|
|
129
|
+
*/
|
|
130
|
+
get toolRegistry() {
|
|
131
|
+
return this.args.registry;
|
|
132
|
+
}
|
|
76
133
|
/** The background-job manager (C.28), or undefined when jobs are disabled.
|
|
77
134
|
* The REPL uses it to service paused-job approvals and drive `/jobs`; `cruxy
|
|
78
135
|
* run` uses it to cancel every live job on exit. */
|
|
79
136
|
get jobs() {
|
|
80
137
|
return this.args.jobs;
|
|
81
138
|
}
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
139
|
+
/**
|
|
140
|
+
* The session's live model choice (P6 track 1), or undefined when tiers do not
|
|
141
|
+
* apply — a bring-your-own provider has no cruxy tiers to choose between, and
|
|
142
|
+
* `/model` says so rather than offering a menu that could not take effect.
|
|
143
|
+
*
|
|
144
|
+
* Exposed as the object rather than proxied through `getModel`/`setModel`
|
|
145
|
+
* accessors, because the renderer needs to SUBSCRIBE to it, not just read it:
|
|
146
|
+
* the model panel and the status line have to react to a change within the
|
|
147
|
+
* same paint, and a getter pair cannot carry that.
|
|
148
|
+
*/
|
|
149
|
+
get model() {
|
|
150
|
+
return this.args.model;
|
|
151
|
+
}
|
|
152
|
+
/** The session's current mode. */
|
|
153
|
+
getMode() {
|
|
154
|
+
return this.mode;
|
|
85
155
|
}
|
|
86
156
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
157
|
+
* Set the mode. Returns the EFFECTIVE mode, which differs from the request
|
|
158
|
+
* when no plan runner is wired — so a caller reports what happened rather
|
|
159
|
+
* than what it asked for.
|
|
89
160
|
*/
|
|
90
|
-
|
|
91
|
-
this.
|
|
161
|
+
setMode(mode) {
|
|
162
|
+
this.mode = this.resolveMode(mode);
|
|
163
|
+
// Record the effective value, not the request: the log must say what the
|
|
164
|
+
// session actually did.
|
|
165
|
+
this.args.recorder?.mode(this.mode);
|
|
166
|
+
return this.mode;
|
|
167
|
+
}
|
|
168
|
+
/** Advance one step around the mode ring (Shift+Tab). Returns the new mode. */
|
|
169
|
+
cycleMode() {
|
|
170
|
+
return this.setMode(nextMode(this.mode));
|
|
171
|
+
}
|
|
172
|
+
/** Whether this session proposes a plan before executing (C.31). */
|
|
173
|
+
getPlanMode() {
|
|
174
|
+
return modePlans(this.mode);
|
|
175
|
+
}
|
|
176
|
+
/** Whether gated actions run without a prompt in this session. */
|
|
177
|
+
getAutoApprove() {
|
|
178
|
+
return modeAutoApproves(this.mode);
|
|
92
179
|
}
|
|
93
180
|
/**
|
|
94
181
|
* Run one user turn: append the prompt, compact if the history has grown past
|
|
@@ -107,6 +194,9 @@ export class Session {
|
|
|
107
194
|
for (const tool of this.args.registry.list())
|
|
108
195
|
tool.onTurnStart?.();
|
|
109
196
|
this.messages.push({ role: "user", content: userPrompt });
|
|
197
|
+
// Record the user's turn before anything can fail (P2): a turn that dies in
|
|
198
|
+
// the provider still leaves what the user asked for on disk.
|
|
199
|
+
this.flushRecorded(this.messages);
|
|
110
200
|
// Usage telemetry (C.22): one collector per run. `onReq` is threaded into
|
|
111
201
|
// every real model request this turn drives — the main loop, compaction, and
|
|
112
202
|
// (in plan mode) the propose + execution steps — so usage is captured exactly
|
|
@@ -137,7 +227,7 @@ export class Session {
|
|
|
137
227
|
// Plan mode (C.31) delegates the whole turn to the injected runner: propose a
|
|
138
228
|
// plan, approve/revise, then execute step-by-step. Falls back to the normal
|
|
139
229
|
// single-shot loop when off or unwired, so existing behavior is untouched.
|
|
140
|
-
const result = this.
|
|
230
|
+
const result = modePlans(this.mode) && this.args.planRunner
|
|
141
231
|
? await this.args.planRunner({
|
|
142
232
|
messages: this.messages,
|
|
143
233
|
projectInstructions: this.projectInstructions,
|
|
@@ -167,6 +257,15 @@ export class Session {
|
|
|
167
257
|
this.messages = result.messages;
|
|
168
258
|
this.usage.input_tokens += result.usage.input_tokens;
|
|
169
259
|
this.usage.output_tokens += result.usage.output_tokens;
|
|
260
|
+
// Persist what the turn produced (P2). The loop's mid-iteration flushes
|
|
261
|
+
// (via the compaction seam) already recorded most of it; this catches the
|
|
262
|
+
// tail after the final iteration. Usage is copied into the session log
|
|
263
|
+
// rather than referenced, because the usage store keeps only its newest 50
|
|
264
|
+
// runs while sessions are kept indefinitely.
|
|
265
|
+
this.flushRecorded(this.messages);
|
|
266
|
+
if (result.usage.input_tokens > 0 || result.usage.output_tokens > 0) {
|
|
267
|
+
this.args.recorder?.usage(result.usage.input_tokens, result.usage.output_tokens);
|
|
268
|
+
}
|
|
170
269
|
// Publish the run's usage record (C.22): stash it for the one-shot summary
|
|
171
270
|
// and hand it to the persistence sink. Building the record never touches the
|
|
172
271
|
// network and never blocks the turn's result.
|
|
@@ -191,6 +290,11 @@ export class Session {
|
|
|
191
290
|
/** Drop the conversation history but keep the session (for `/clear`). */
|
|
192
291
|
clear() {
|
|
193
292
|
this.messages = [];
|
|
293
|
+
// The log keeps every earlier message — `clear` is an event, not an
|
|
294
|
+
// erasure. Replay honours it, so a resumed session starts empty exactly as
|
|
295
|
+
// the live one did, while the transcript of what was said survives.
|
|
296
|
+
this.args.recorder?.clear();
|
|
297
|
+
this.recordedCount = 0;
|
|
194
298
|
}
|
|
195
299
|
/**
|
|
196
300
|
* Compact `this.messages` only when it has grown past threshold, adopting the
|
|
@@ -212,6 +316,16 @@ export class Session {
|
|
|
212
316
|
* unchanged — cheap, no model call.
|
|
213
317
|
*/
|
|
214
318
|
async compactLoopHistory(messages, onRequestUsage) {
|
|
319
|
+
// Flush BEFORE compacting (P2). The loop owns this array and only ever
|
|
320
|
+
// appends to it between calls, so everything since the last flush is a pure
|
|
321
|
+
// append — and it has to be on disk before a compaction event can refer to
|
|
322
|
+
// a prefix length, or the recorded `replaced` count would be measured
|
|
323
|
+
// against a shorter history than the one being compacted.
|
|
324
|
+
//
|
|
325
|
+
// The loop calls this at the TOP of each iteration, after the previous
|
|
326
|
+
// iteration resolved all its tool calls, so the history flushed here is
|
|
327
|
+
// always coherent: no tool_use is ever recorded without its tool_result.
|
|
328
|
+
this.flushRecorded(messages);
|
|
215
329
|
const result = await this.compactIfOverThreshold(messages, onRequestUsage);
|
|
216
330
|
return result.messages;
|
|
217
331
|
}
|
|
@@ -257,7 +371,12 @@ export class Session {
|
|
|
257
371
|
* the summary's usage into the session total.
|
|
258
372
|
*/
|
|
259
373
|
async runCompaction(messages, onRequestUsage) {
|
|
260
|
-
|
|
374
|
+
// Self-contained sync (P2): whatever array is about to be rewritten must be
|
|
375
|
+
// fully recorded first, so the `replaced` count in the compaction event is
|
|
376
|
+
// measured against the same history a replay will have reconstructed. A
|
|
377
|
+
// no-op when the caller already flushed, which every caller does.
|
|
378
|
+
this.flushRecorded(messages);
|
|
379
|
+
const cut = findCut(messages, this.args.config.context.keepRecentMessages);
|
|
261
380
|
if (cut === null)
|
|
262
381
|
return { messages, compacted: null };
|
|
263
382
|
const prefix = messages.slice(0, cut);
|
|
@@ -286,37 +405,20 @@ export class Session {
|
|
|
286
405
|
content: `${COMPACTION_MARKER} Summary of the conversation so far:\n\n${synopsis}`,
|
|
287
406
|
},
|
|
288
407
|
];
|
|
408
|
+
// Record the rewrite as an EVENT (P2): the log stays append-only, and the
|
|
409
|
+
// messages that were folded away remain readable earlier in the file even
|
|
410
|
+
// though the model can no longer see them. The watermark moves to the
|
|
411
|
+
// post-compaction length so the next append is measured against the new
|
|
412
|
+
// array, not the old one.
|
|
413
|
+
if (this.args.recorder) {
|
|
414
|
+
this.args.recorder.compaction(prefix.length, summaryMessages);
|
|
415
|
+
this.recordedCount = summaryMessages.length + kept.length;
|
|
416
|
+
}
|
|
289
417
|
return {
|
|
290
418
|
messages: [...summaryMessages, ...kept],
|
|
291
419
|
compacted: prefix.length,
|
|
292
420
|
};
|
|
293
421
|
}
|
|
294
|
-
/**
|
|
295
|
-
* Choose the boundary between the summarized prefix and the kept-recent tail.
|
|
296
|
-
*
|
|
297
|
-
* Tool-call integrity is the constraint: a `tool_use` (assistant) and its
|
|
298
|
-
* matching `tool_result` (the next user message) must never straddle the cut,
|
|
299
|
-
* or the next provider call breaks. A real user *prompt* (`role:"user"` with
|
|
300
|
-
* string content) only occurs at a completed turn boundary, where every prior
|
|
301
|
-
* tool exchange is already resolved — so the kept region must begin there. The
|
|
302
|
-
* synthetic compaction-summary user message is also string content, so a
|
|
303
|
-
* repeat compaction always finds at least the previous summary as a clean cut.
|
|
304
|
-
*
|
|
305
|
-
* Start from `length - keepRecentMessages` and walk *backwards* to the nearest
|
|
306
|
-
* such prompt: this keeps at least the recent floor and lands clean. Returns
|
|
307
|
-
* the cut index, or `null` if no safe boundary leaves a non-empty prefix
|
|
308
|
-
* (e.g. a single long in-progress turn — nothing safe to compact).
|
|
309
|
-
*/
|
|
310
|
-
findCut(messages) {
|
|
311
|
-
const { keepRecentMessages } = this.args.config.context;
|
|
312
|
-
const start = messages.length - keepRecentMessages;
|
|
313
|
-
for (let i = start; i >= 1; i--) {
|
|
314
|
-
const msg = messages[i];
|
|
315
|
-
if (msg.role === "user" && typeof msg.content === "string")
|
|
316
|
-
return i;
|
|
317
|
-
}
|
|
318
|
-
return null;
|
|
319
|
-
}
|
|
320
422
|
/**
|
|
321
423
|
* Summarize a prefix via a standalone, tool-less provider call over a rendered
|
|
322
424
|
* transcript. Throws on a stream error or empty output so callers fail open.
|