@cruxy/cli 1.11.2 → 1.11.4
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/instruction-loss.js +204 -0
- package/dist/agent/prompts.js +25 -4
- package/dist/agent/session.js +165 -33
- package/dist/agent/status.js +18 -0
- package/dist/cli/commands/pr.js +14 -0
- package/dist/cli/commands/run.js +44 -8
- package/dist/cli/session-commands.js +3 -1
- package/dist/cli/session-factory.js +54 -6
- package/dist/config/schema.js +9 -0
- package/dist/mcp/bounds.js +8 -1
- package/dist/plan/execute.js +4 -1
- package/dist/plan/service.js +42 -5
- package/dist/plan/step-message.js +49 -0
- package/dist/render/context-view.js +44 -1
- package/dist/render/status-view.js +13 -0
- package/dist/session/index.js +6 -3
- package/dist/session/log.js +97 -2
- package/dist/session/recorded-runs.js +56 -0
- package/dist/session/replay.js +75 -1
- package/dist/session/resume.js +88 -0
- package/dist/session/types.js +158 -0
- package/dist/testing/run-tests-tool.js +3 -1
- package/dist/tools/create-pull-request.js +8 -1
- package/dist/tools/file/apply-patch.js +6 -2
- package/dist/tools/file/edit-file.js +6 -2
- package/dist/tools/file/snapshot.js +9 -4
- package/dist/tools/file/write-file.js +7 -2
- package/dist/tools/registry.js +39 -8
- package/dist/tools/schema-depth.js +79 -6
- package/dist/tools/shell/exec.js +7 -0
- package/dist/tools/shell/run-command.js +45 -21
- package/dist/tui/renderer.js +59 -8
- package/dist/vcs/generate.js +48 -6
- package/dist/verification/index.js +15 -0
- package/dist/verification/ledger.js +99 -0
- package/dist/verification/types.js +26 -0
- package/dist/verification/view.js +87 -0
- package/package.json +1 -1
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { COMPACTION_MARKER } from "./prompts.js";
|
|
2
|
+
/**
|
|
3
|
+
* Detect-and-warn for compaction (P3 context quality): after a prefix has been
|
|
4
|
+
* folded into a synopsis, which of the user's imperative or prohibitive
|
|
5
|
+
* sentences can no longer be found in it?
|
|
6
|
+
*
|
|
7
|
+
* A HEURISTIC, AND SAID TO BE ONE. It costs no tokens, it runs after the fact,
|
|
8
|
+
* and it can be wrong both ways: a paraphrased constraint that survived in
|
|
9
|
+
* spirit is reported lost, and a constraint whose key terms happen to appear
|
|
10
|
+
* elsewhere in the synopsis is reported kept. That is accepted. Before P3 a
|
|
11
|
+
* dropped constraint was SILENT — the user found out when the model touched
|
|
12
|
+
* the migrations. This turns silent loss into visible loss, in the same
|
|
13
|
+
* posture as the P2 verification record: evidence, not enforcement. It does
|
|
14
|
+
* not re-inject, pin, or refuse the synopsis (those need a constraint store,
|
|
15
|
+
* a retirement rule and an extractor, and the last changes the fail-open
|
|
16
|
+
* contract — deferred until a real loss has been observed AND recorded, which
|
|
17
|
+
* is what this makes possible).
|
|
18
|
+
*
|
|
19
|
+
* WHAT IT SCANS: the user's OWN words only — string-content user messages and
|
|
20
|
+
* text blocks in user messages. Tool results (also `role: "user"`) are not
|
|
21
|
+
* the user speaking, and a previous compaction's synthetic user message is
|
|
22
|
+
* not either. WHAT IT DOES NOT DO: infer that a sentence was a constraint
|
|
23
|
+
* from anything but its surface form, or decide what the constraint meant.
|
|
24
|
+
*/
|
|
25
|
+
/** Words that mark a sentence as an instruction, a constraint, or a prohibition. */
|
|
26
|
+
const IMPERATIVE = /\b(never|don'?t|do not|must(?: not)?|mustn'?t|always|only|no longer|stop|avoid|keep|without|not allowed|forbidden|prohibited|refrain|ensure|make sure|should(?: not)?|shouldn'?t|can'?not|can'?t|leave .* alone|hands off)\b/i;
|
|
27
|
+
/** Tokens that carry no identity of their own — never a key term. */
|
|
28
|
+
const STOPWORDS = new Set([
|
|
29
|
+
"the",
|
|
30
|
+
"and",
|
|
31
|
+
"this",
|
|
32
|
+
"that",
|
|
33
|
+
"these",
|
|
34
|
+
"those",
|
|
35
|
+
"with",
|
|
36
|
+
"from",
|
|
37
|
+
"you",
|
|
38
|
+
"your",
|
|
39
|
+
"for",
|
|
40
|
+
"are",
|
|
41
|
+
"was",
|
|
42
|
+
"were",
|
|
43
|
+
"have",
|
|
44
|
+
"has",
|
|
45
|
+
"had",
|
|
46
|
+
"but",
|
|
47
|
+
"not",
|
|
48
|
+
"any",
|
|
49
|
+
"all",
|
|
50
|
+
"into",
|
|
51
|
+
"onto",
|
|
52
|
+
"just",
|
|
53
|
+
"also",
|
|
54
|
+
"then",
|
|
55
|
+
"than",
|
|
56
|
+
"when",
|
|
57
|
+
"what",
|
|
58
|
+
"which",
|
|
59
|
+
"who",
|
|
60
|
+
"will",
|
|
61
|
+
"would",
|
|
62
|
+
"should",
|
|
63
|
+
"could",
|
|
64
|
+
"can",
|
|
65
|
+
"may",
|
|
66
|
+
"might",
|
|
67
|
+
"please",
|
|
68
|
+
"let",
|
|
69
|
+
"use",
|
|
70
|
+
"our",
|
|
71
|
+
"its",
|
|
72
|
+
"they",
|
|
73
|
+
"them",
|
|
74
|
+
"there",
|
|
75
|
+
"here",
|
|
76
|
+
"about",
|
|
77
|
+
"after",
|
|
78
|
+
"before",
|
|
79
|
+
"over",
|
|
80
|
+
"under",
|
|
81
|
+
"again",
|
|
82
|
+
"still",
|
|
83
|
+
"very",
|
|
84
|
+
"some",
|
|
85
|
+
"more",
|
|
86
|
+
"most",
|
|
87
|
+
"other",
|
|
88
|
+
"only",
|
|
89
|
+
"never",
|
|
90
|
+
"always",
|
|
91
|
+
"must",
|
|
92
|
+
"don't",
|
|
93
|
+
"dont",
|
|
94
|
+
"avoid",
|
|
95
|
+
"keep",
|
|
96
|
+
"stop",
|
|
97
|
+
"make",
|
|
98
|
+
"sure",
|
|
99
|
+
"ensure",
|
|
100
|
+
"without",
|
|
101
|
+
"longer",
|
|
102
|
+
"allowed",
|
|
103
|
+
"forbidden",
|
|
104
|
+
"prohibited",
|
|
105
|
+
"refrain",
|
|
106
|
+
"cannot",
|
|
107
|
+
"can't",
|
|
108
|
+
"shouldn't",
|
|
109
|
+
"mustn't",
|
|
110
|
+
"want",
|
|
111
|
+
"need",
|
|
112
|
+
"like",
|
|
113
|
+
"thing",
|
|
114
|
+
"things",
|
|
115
|
+
"something",
|
|
116
|
+
"anything",
|
|
117
|
+
"everything",
|
|
118
|
+
"ever",
|
|
119
|
+
"each",
|
|
120
|
+
"every",
|
|
121
|
+
]);
|
|
122
|
+
/** Most sentences reported per compaction — an index, not a transcript. */
|
|
123
|
+
export const MAX_LOST_SENTENCES = 5;
|
|
124
|
+
/** Each reported sentence is cut here: enough to recognise, never the whole prompt. */
|
|
125
|
+
export const MAX_SENTENCE_CHARS = 200;
|
|
126
|
+
/**
|
|
127
|
+
* The user's imperative sentences in `prefix` whose key terms are NOT found
|
|
128
|
+
* in `synopsis`, bounded and truncated for the record. Empty when nothing
|
|
129
|
+
* looks lost — or when nothing looked like an instruction to begin with.
|
|
130
|
+
*/
|
|
131
|
+
export function detectInstructionLoss(prefix, synopsis) {
|
|
132
|
+
const haystack = synopsis.toLowerCase();
|
|
133
|
+
const lost = [];
|
|
134
|
+
const seen = new Set();
|
|
135
|
+
for (const sentence of userSentences(prefix)) {
|
|
136
|
+
if (!IMPERATIVE.test(sentence))
|
|
137
|
+
continue;
|
|
138
|
+
const terms = keyTerms(sentence);
|
|
139
|
+
if (terms.length === 0)
|
|
140
|
+
continue; // nothing to look for → cannot judge
|
|
141
|
+
const found = terms.filter((t) => haystack.includes(t)).length;
|
|
142
|
+
// Half the terms, rounded up, is "survived": a paraphrase that keeps the
|
|
143
|
+
// nouns passes, a synopsis that never mentions the subject does not.
|
|
144
|
+
if (found >= Math.ceil(terms.length / 2))
|
|
145
|
+
continue;
|
|
146
|
+
const cut = truncate(sentence);
|
|
147
|
+
if (seen.has(cut))
|
|
148
|
+
continue;
|
|
149
|
+
seen.add(cut);
|
|
150
|
+
lost.push(cut);
|
|
151
|
+
if (lost.length >= MAX_LOST_SENTENCES)
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
return lost;
|
|
155
|
+
}
|
|
156
|
+
/** Every sentence the USER wrote in `messages`, in order. */
|
|
157
|
+
function userSentences(messages) {
|
|
158
|
+
const out = [];
|
|
159
|
+
for (const m of messages) {
|
|
160
|
+
if (m.role !== "user")
|
|
161
|
+
continue;
|
|
162
|
+
const texts = [];
|
|
163
|
+
if (typeof m.content === "string") {
|
|
164
|
+
if (m.content.startsWith(COMPACTION_MARKER))
|
|
165
|
+
continue;
|
|
166
|
+
texts.push(m.content);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
for (const b of m.content)
|
|
170
|
+
if (b.type === "text")
|
|
171
|
+
texts.push(b.text);
|
|
172
|
+
}
|
|
173
|
+
for (const text of texts) {
|
|
174
|
+
for (const raw of text.split(/(?<=[.!?])\s+|\n+/)) {
|
|
175
|
+
const s = raw.trim();
|
|
176
|
+
if (s.length >= 8)
|
|
177
|
+
out.push(s);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
/** The words in `sentence` worth looking for: lowercased, ≥3 chars, not stopwords. */
|
|
184
|
+
export function keyTerms(sentence) {
|
|
185
|
+
const terms = new Set();
|
|
186
|
+
for (const raw of sentence.toLowerCase().split(/\s+/)) {
|
|
187
|
+
// Strip the quotes and brackets a word is wrapped in, then the punctuation
|
|
188
|
+
// it ends with — `.` and `/` are kept INSIDE a word so `db/migrate` and
|
|
189
|
+
// `parse.ts` survive as the identifiers they are.
|
|
190
|
+
const word = raw
|
|
191
|
+
.replace(/^[^a-z0-9_./-]+|[^a-z0-9_./-]+$/g, "")
|
|
192
|
+
.replace(/[./-]+$/, "");
|
|
193
|
+
if (word.length < 3 || STOPWORDS.has(word))
|
|
194
|
+
continue;
|
|
195
|
+
terms.add(word);
|
|
196
|
+
}
|
|
197
|
+
return [...terms];
|
|
198
|
+
}
|
|
199
|
+
function truncate(s) {
|
|
200
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
201
|
+
return flat.length > MAX_SENTENCE_CHARS
|
|
202
|
+
? `${flat.slice(0, MAX_SENTENCE_CHARS - 1)}…`
|
|
203
|
+
: flat;
|
|
204
|
+
}
|
package/dist/agent/prompts.js
CHANGED
|
@@ -131,17 +131,38 @@ export function buildSystemPrompt(ctx) {
|
|
|
131
131
|
return sections.join("\n\n");
|
|
132
132
|
}
|
|
133
133
|
/**
|
|
134
|
-
*
|
|
135
|
-
*
|
|
134
|
+
* The heading under which the synopsis must carry the user's instructions
|
|
135
|
+
* verbatim (P3 context quality). A fixed string so the carry rule is checkable
|
|
136
|
+
* — by a test, and by `detectInstructionLoss` after the fact.
|
|
136
137
|
*/
|
|
137
|
-
export const
|
|
138
|
+
export const STANDING_INSTRUCTIONS_HEADING = "## Standing instructions";
|
|
138
139
|
/**
|
|
139
140
|
* System prompt for the side conversation that compacts an over-long history
|
|
140
141
|
* (see Session.compact). It runs as a standalone, tool-less completion over a
|
|
141
142
|
* rendered transcript — the goal is a synopsis dense enough that the main loop
|
|
142
143
|
* can continue without the verbatim prefix.
|
|
144
|
+
*
|
|
145
|
+
* THE CARRY RULE (P3 context quality). Until P3 this prompt ended "omit
|
|
146
|
+
* pleasantries and restated instructions". That was never a decision to drop
|
|
147
|
+
* the user's constraints: C.11 wrote it for density, and "restated" meant the
|
|
148
|
+
* assistant echoing the task back. But a model reads "restated instructions"
|
|
149
|
+
* and applies it to a user's repeated "never touch the migrations" just as
|
|
150
|
+
* readily — so a constraint stated three turns before a compaction was, by
|
|
151
|
+
* the prompt's own wording, the first thing to go. Mid-session constraints
|
|
152
|
+
* live ONLY in the history (CRUXY.md and recalled memory are rebuilt every
|
|
153
|
+
* turn and never compacted; memory is framed as data and cannot carry an
|
|
154
|
+
* instruction by design), so the synopsis is the one place they can survive.
|
|
155
|
+
*
|
|
156
|
+
* The prompt now says the opposite, explicitly: every instruction, constraint
|
|
157
|
+
* or prohibition the user stated goes under a fixed heading, verbatim. A test
|
|
158
|
+
* asserts that we ask for this; nothing can assert that the model complies,
|
|
159
|
+
* which is what `detectInstructionLoss` is for.
|
|
143
160
|
*/
|
|
144
|
-
export const SUMMARY_SYSTEM = `You are compacting a coding assistant's conversation to fit within its context window. Summarize the conversation so far into a compact synopsis that preserves: decisions made and their rationale, concrete file paths and identifiers touched, the current state of the work, and any open or pending tasks. Be specific and terse
|
|
161
|
+
export const SUMMARY_SYSTEM = `You are compacting a coding assistant's conversation to fit within its context window. Summarize the conversation so far into a compact synopsis that preserves: decisions made and their rationale, concrete file paths and identifiers touched, the current state of the work, and any open or pending tasks. Be specific and terse; omit pleasantries.
|
|
162
|
+
|
|
163
|
+
Then, under the heading "${STANDING_INSTRUCTIONS_HEADING}", list every instruction, constraint, or prohibition the user stated at any point, each VERBATIM in the user's own words — never paraphrased, never merged with another, and never dropped as redundant, restated, or obvious. A constraint the user gave once still binds. If the user stated none, write the heading followed by "none".
|
|
164
|
+
|
|
165
|
+
Output only the synopsis.`;
|
|
145
166
|
/**
|
|
146
167
|
* Marker embedded in the synthetic messages that replace a compacted prefix, so
|
|
147
168
|
* they're recognizable in the history (and fold cleanly into a later
|
package/dist/agent/session.js
CHANGED
|
@@ -9,6 +9,8 @@ import { resolveTaskModel, } from "../routing/index.js";
|
|
|
9
9
|
import { UsageCollector, accumulateCacheTokens, } from "../usage/index.js";
|
|
10
10
|
import { Budget } from "./budget.js";
|
|
11
11
|
import { estimateTokens, findCut } from "./context.js";
|
|
12
|
+
import { detectInstructionLoss } from "./instruction-loss.js";
|
|
13
|
+
import { emptyCompactionTally, } from "../session/index.js";
|
|
12
14
|
import { runAgent, } from "./loop.js";
|
|
13
15
|
import { DEFAULT_MODE, modeAutoApproves, modePlans, parseMode, } from "./mode.js";
|
|
14
16
|
import { SUMMARY_SYSTEM, COMPACTION_MARKER } from "./prompts.js";
|
|
@@ -41,6 +43,37 @@ export class Session {
|
|
|
41
43
|
/** The most recent run's usage record (C.22) — the one-shot path reads it to
|
|
42
44
|
* print the end-of-run summary. */
|
|
43
45
|
lastRun;
|
|
46
|
+
/**
|
|
47
|
+
* The most recent run that actually executed this session (P2 verification),
|
|
48
|
+
* or undefined when nothing has — the `lastRun` pattern, for `/status` and
|
|
49
|
+
* the Overview view. Read from the ledger, never re-derived.
|
|
50
|
+
*/
|
|
51
|
+
get lastVerification() {
|
|
52
|
+
return this.args.verification?.lastVerification;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* What THIS turn recorded (P2 verification) — the one-shot summary's unit:
|
|
56
|
+
* every run that executed, and every write refused because its target
|
|
57
|
+
* moved. Empty lists when nothing did, or when no ledger is wired.
|
|
58
|
+
*/
|
|
59
|
+
turnVerification() {
|
|
60
|
+
return (this.args.verification?.turn() ?? {
|
|
61
|
+
verifications: [],
|
|
62
|
+
externalChanges: [],
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* What compaction has cost this session so far (P3 context quality): a
|
|
67
|
+
* count, what it freed, what the summaries cost, and how many times a user
|
|
68
|
+
* instruction may have been dropped. Kept in memory for the live surfaces
|
|
69
|
+
* (`/context`, the one-shot summary) and written to the log event by event,
|
|
70
|
+
* from which a resume folds the same shape back (`SessionState.compactions`).
|
|
71
|
+
*/
|
|
72
|
+
compactionTally = emptyCompactionTally();
|
|
73
|
+
/** The tally, read-only — see {@link compactionTally}. */
|
|
74
|
+
compactions() {
|
|
75
|
+
return this.compactionTally;
|
|
76
|
+
}
|
|
44
77
|
args;
|
|
45
78
|
/** Mutable so `/reload` can refresh CRUXY.md mid-session. */
|
|
46
79
|
projectInstructions;
|
|
@@ -105,6 +138,12 @@ export class Session {
|
|
|
105
138
|
this.usage.output_tokens = restore.usage.output_tokens;
|
|
106
139
|
this.recordedCount = restore.messages.length;
|
|
107
140
|
this.mode = this.resolveMode(restore.mode);
|
|
141
|
+
// The last run the log recorded is adopted, never re-derived: a resumed
|
|
142
|
+
// session's `/status` says what last ran and when, not "none".
|
|
143
|
+
args.verification?.seed(restore.lastVerification);
|
|
144
|
+
if (restore.compactions) {
|
|
145
|
+
Object.assign(this.compactionTally, restore.compactions);
|
|
146
|
+
}
|
|
108
147
|
}
|
|
109
148
|
}
|
|
110
149
|
/**
|
|
@@ -221,6 +260,10 @@ export class Session {
|
|
|
221
260
|
// model iterations WITHIN this turn — it just never leaks into the next one.
|
|
222
261
|
for (const tool of this.args.registry.list())
|
|
223
262
|
tool.onTurnStart?.();
|
|
263
|
+
// The verification record's turn boundary (P2 verification): this turn's
|
|
264
|
+
// runs start empty, so the one-shot summary's "none ran this turn" is
|
|
265
|
+
// about THIS turn. What last ran across the session is untouched.
|
|
266
|
+
this.args.verification?.beginTurn();
|
|
224
267
|
// Re-read the account's headroom (P9 / cli#212), on EVERY turn and on every
|
|
225
268
|
// surface — the REPL and headless included, neither of which has a rail to
|
|
226
269
|
// have justified the probe before. It is fired here rather than after the
|
|
@@ -281,36 +324,89 @@ export class Session {
|
|
|
281
324
|
// hooks — throws here and aborts the turn before the model is engaged
|
|
282
325
|
// (fail-closed). No-op when hooks are disabled or none are registered.
|
|
283
326
|
await this.args.hooks?.fire("before-run", this.args.ctx);
|
|
327
|
+
// The mid-loop seam (build item 3), built ONCE for both branches below: let
|
|
328
|
+
// a long turn compact between iterations, not just once up front, reusing
|
|
329
|
+
// this session's threshold/cut/summarize path over the loop's own history
|
|
330
|
+
// and attributing the summary usage to this run's collector.
|
|
331
|
+
//
|
|
332
|
+
// IT IS ALSO THE FLUSH (P2), which is why it remembers what it returned.
|
|
333
|
+
// `compactLoopHistory` records the history before it compacts, and the
|
|
334
|
+
// loop calls it at the top of every iteration — so the array it hands back
|
|
335
|
+
// is, at that moment, exactly what the log holds: coherent (every tool_use
|
|
336
|
+
// beside its tool_result) and recorded. A turn that throws later has no
|
|
337
|
+
// `result.messages` to adopt, and without this the live history would stay
|
|
338
|
+
// at the user's prompt while the log had moved past it. See the catch.
|
|
339
|
+
const seen = {
|
|
340
|
+
coherent: null,
|
|
341
|
+
};
|
|
342
|
+
const compact = async (messages) => {
|
|
343
|
+
const out = await this.compactLoopHistory(messages, onReq);
|
|
344
|
+
seen.coherent = { messages: out, length: out.length };
|
|
345
|
+
return out;
|
|
346
|
+
};
|
|
284
347
|
// Plan mode (C.31) delegates the whole turn to the injected runner: propose a
|
|
285
348
|
// plan, approve/revise, then execute step-by-step. Falls back to the normal
|
|
286
349
|
// single-shot loop when off or unwired, so existing behavior is untouched.
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
350
|
+
//
|
|
351
|
+
// Both branches get the seam (plan-durability). Before it, the plan branch
|
|
352
|
+
// passed none: a plan turn's propose phase, approval and every step lived
|
|
353
|
+
// only in the runner's local array until the whole turn returned, so a
|
|
354
|
+
// provider error in step 3 left the session holding the prompt alone — in
|
|
355
|
+
// RAM and on disk — while the tree already carried steps 1 and 2.
|
|
356
|
+
let result;
|
|
357
|
+
try {
|
|
358
|
+
result =
|
|
359
|
+
modePlans(this.mode) && this.args.planRunner
|
|
360
|
+
? await this.args.planRunner({
|
|
361
|
+
messages: this.messages,
|
|
362
|
+
projectInstructions: this.projectInstructions,
|
|
363
|
+
recalledMemory: this.args.recalledMemory ?? null,
|
|
364
|
+
renderer,
|
|
365
|
+
onRequestUsage: onReq,
|
|
366
|
+
compact,
|
|
367
|
+
record: this.args.recorder,
|
|
368
|
+
})
|
|
369
|
+
: await runAgent({
|
|
370
|
+
messages: this.messages,
|
|
371
|
+
...this.args, // carries `router` through to the loop
|
|
372
|
+
// The per-turn token guard (build item 4). After the spread because
|
|
373
|
+
// SessionArgs has no `budget` field — it is a per-turn construction,
|
|
374
|
+
// not session state. `undefined` when the cap is off (no-op check).
|
|
375
|
+
budget,
|
|
376
|
+
taskClass: "main-turn",
|
|
377
|
+
// After the spread so a mid-session `/reload` wins over the initial value.
|
|
378
|
+
projectInstructions: this.projectInstructions,
|
|
379
|
+
planMode: false, // the plan directive belongs only to the runner's propose phase
|
|
380
|
+
renderer,
|
|
381
|
+
onRequestUsage: onReq,
|
|
382
|
+
compact,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
// ADOPT WHAT THE LOG ALREADY HOLDS, then fail. The seam flushed every
|
|
387
|
+
// coherent prefix it saw and advanced the recorder's watermark past it;
|
|
388
|
+
// the live array has to catch up or the two diverge — and the divergence
|
|
389
|
+
// is not benign. `flushRecorded` skips while the array is shorter than
|
|
390
|
+
// the watermark, so the NEXT turns would go unrecorded until the array
|
|
391
|
+
// grew past it, and from then on `slice(recordedCount)` would record the
|
|
392
|
+
// wrong messages at those positions. The log would be wrong, not merely
|
|
393
|
+
// short.
|
|
394
|
+
//
|
|
395
|
+
// The loop pushes onto the array the seam returned, so the coherent
|
|
396
|
+
// history is that array up to the length it had when the seam saw it:
|
|
397
|
+
// a half-iteration (an assistant tool_use whose tool threw before its
|
|
398
|
+
// result landed) is past that mark and is dropped, exactly as the log
|
|
399
|
+
// never received it.
|
|
400
|
+
//
|
|
401
|
+
// This was the shape of every plain turn before plan-durability too —
|
|
402
|
+
// the plan branch made it visible by being the one that throws for a
|
|
403
|
+
// step the user can see.
|
|
404
|
+
const { coherent } = seen;
|
|
405
|
+
if (coherent !== null && coherent.length > this.messages.length) {
|
|
406
|
+
this.messages = coherent.messages.slice(0, coherent.length);
|
|
407
|
+
}
|
|
408
|
+
throw err;
|
|
409
|
+
}
|
|
314
410
|
this.messages = result.messages;
|
|
315
411
|
this.usage.input_tokens += result.usage.input_tokens;
|
|
316
412
|
this.usage.output_tokens += result.usage.output_tokens;
|
|
@@ -523,10 +619,15 @@ export class Session {
|
|
|
523
619
|
return { messages, compacted: null };
|
|
524
620
|
const prefix = messages.slice(0, cut);
|
|
525
621
|
const kept = messages.slice(cut);
|
|
622
|
+
// Measured before anything is spent, over the SAME history the trigger
|
|
623
|
+
// measured (P3): the cost record is what the trigger saw, not a re-estimate.
|
|
624
|
+
const estimatedBefore = estimateTokens(messages);
|
|
526
625
|
let synopsis;
|
|
626
|
+
let summaryUsage;
|
|
527
627
|
try {
|
|
528
628
|
const summary = await this.summarize(prefix, onRequestUsage);
|
|
529
629
|
synopsis = summary.text;
|
|
630
|
+
summaryUsage = summary.reported ? summary.usage : undefined;
|
|
530
631
|
this.usage.input_tokens += summary.usage.input_tokens;
|
|
531
632
|
this.usage.output_tokens += summary.usage.output_tokens;
|
|
532
633
|
}
|
|
@@ -547,19 +648,48 @@ export class Session {
|
|
|
547
648
|
content: `${COMPACTION_MARKER} Summary of the conversation so far:\n\n${synopsis}`,
|
|
548
649
|
},
|
|
549
650
|
];
|
|
651
|
+
const next = [...summaryMessages, ...kept];
|
|
652
|
+
// What it cost (P3 context quality): the four facts in hand right here,
|
|
653
|
+
// which until P3 were thrown away at this line. Freed is the difference
|
|
654
|
+
// and is derived by every reader, never stored. Tallied in memory for the
|
|
655
|
+
// live surfaces and written to the log for the resumed ones.
|
|
656
|
+
const cost = {
|
|
657
|
+
estimatedBefore,
|
|
658
|
+
estimatedAfter: estimateTokens(next),
|
|
659
|
+
...(summaryUsage
|
|
660
|
+
? {
|
|
661
|
+
summaryInputTokens: summaryUsage.input_tokens,
|
|
662
|
+
summaryOutputTokens: summaryUsage.output_tokens,
|
|
663
|
+
}
|
|
664
|
+
: {}),
|
|
665
|
+
};
|
|
666
|
+
this.compactionTally.count++;
|
|
667
|
+
this.compactionTally.measured++;
|
|
668
|
+
this.compactionTally.freedTokens += Math.max(0, cost.estimatedBefore - cost.estimatedAfter);
|
|
669
|
+
this.compactionTally.summaryInputTokens += cost.summaryInputTokens ?? 0;
|
|
670
|
+
this.compactionTally.summaryOutputTokens += cost.summaryOutputTokens ?? 0;
|
|
550
671
|
// Record the rewrite as an EVENT (P2): the log stays append-only, and the
|
|
551
672
|
// messages that were folded away remain readable earlier in the file even
|
|
552
673
|
// though the model can no longer see them. The watermark moves to the
|
|
553
674
|
// post-compaction length so the next append is measured against the new
|
|
554
675
|
// array, not the old one.
|
|
555
676
|
if (this.args.recorder) {
|
|
556
|
-
this.args.recorder.compaction(prefix.length, summaryMessages);
|
|
677
|
+
this.args.recorder.compaction(prefix.length, summaryMessages, cost);
|
|
557
678
|
this.recordedCount = summaryMessages.length + kept.length;
|
|
558
679
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
680
|
+
// Detect and warn (P3 context quality): which of the user's instructions
|
|
681
|
+
// in the folded prefix can no longer be found in the synopsis? A
|
|
682
|
+
// heuristic — it says so — and it costs no tokens. It does NOT re-inject
|
|
683
|
+
// or refuse the synopsis; it turns a silent loss into a visible one, and
|
|
684
|
+
// records it so the loss is auditable after the warning has scrolled away.
|
|
685
|
+
const lost = detectInstructionLoss(prefix, synopsis);
|
|
686
|
+
if (lost.length > 0) {
|
|
687
|
+
this.compactionTally.instructionLosses++;
|
|
688
|
+
this.args.recorder?.instructionLoss(lost);
|
|
689
|
+
this.args.ctx.logger.warn(`compaction may have dropped ${lost.length} instruction${lost.length === 1 ? "" : "s"} you gave — ` +
|
|
690
|
+
`restate any that still apply: ${lost.map((s) => `"${s}"`).join("; ")}`);
|
|
691
|
+
}
|
|
692
|
+
return { messages: next, compacted: prefix.length };
|
|
563
693
|
}
|
|
564
694
|
/**
|
|
565
695
|
* Summarize a prefix via a standalone, tool-less provider call over a rendered
|
|
@@ -636,7 +766,9 @@ export class Session {
|
|
|
636
766
|
});
|
|
637
767
|
if (!text.trim())
|
|
638
768
|
throw new Error("summary was empty");
|
|
639
|
-
|
|
769
|
+
// `reported` is the same honesty pivot the usage record keeps: a provider
|
|
770
|
+
// that streamed no usage event leaves the cost UNKNOWN, not zero.
|
|
771
|
+
return { text: text.trim(), usage, reported: sawUsage };
|
|
640
772
|
}
|
|
641
773
|
}
|
|
642
774
|
/** Render a message list to a compact plain-text transcript for summarization. */
|
package/dist/agent/status.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readContext } from "./context.js";
|
|
|
2
2
|
import { modeDescription } from "./mode.js";
|
|
3
3
|
import { globalDir } from "../config/paths.js";
|
|
4
4
|
import { GLOBAL_DIR_NAME } from "../constants.js";
|
|
5
|
+
import { relativeAge } from "../session/resume.js";
|
|
5
6
|
/**
|
|
6
7
|
* Where the disk figures come from: every declared root, plus `~/.cruxy`.
|
|
7
8
|
*
|
|
@@ -63,6 +64,7 @@ disk) {
|
|
|
63
64
|
};
|
|
64
65
|
});
|
|
65
66
|
const jobs = session.jobs?.list();
|
|
67
|
+
const last = session.lastVerification;
|
|
66
68
|
const locations = disk
|
|
67
69
|
? diskLocations(toolCtx.workspace.roots(), disk)
|
|
68
70
|
: undefined;
|
|
@@ -92,5 +94,21 @@ disk) {
|
|
|
92
94
|
}
|
|
93
95
|
: {}),
|
|
94
96
|
tools: session.toolRegistry.list().length,
|
|
97
|
+
// The last run that executed (P2 verification), dated. Absent when none
|
|
98
|
+
// has — and the renderer SAYS so, because here absence is the fact worth
|
|
99
|
+
// showing: a session in which nothing ran is what this record exists to
|
|
100
|
+
// make visible. `lastRun`'s pattern: a field read, no probe.
|
|
101
|
+
...(last
|
|
102
|
+
? {
|
|
103
|
+
verification: {
|
|
104
|
+
tool: last.tool,
|
|
105
|
+
command: last.command,
|
|
106
|
+
passed: last.passed,
|
|
107
|
+
exitCode: last.exitCode,
|
|
108
|
+
durationMs: last.durationMs,
|
|
109
|
+
age: relativeAge(last.at),
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
: {}),
|
|
95
113
|
};
|
|
96
114
|
}
|
package/dist/cli/commands/pr.js
CHANGED
|
@@ -6,6 +6,7 @@ import { authMissingKey, shouldUseColor } from "../../errors/index.js";
|
|
|
6
6
|
import { themeForColor } from "../../theme/index.js";
|
|
7
7
|
import { ApprovalService, defaultPromptIO, InteractivePolicy, SessionAllowlist, } from "../../approval/index.js";
|
|
8
8
|
import { resolveTaskModel, routerForConfig } from "../../routing/index.js";
|
|
9
|
+
import { latestRecordedRuns, shortId } from "../../session/index.js";
|
|
9
10
|
import { createForgeProvider, createPrService, generateWithLlm, loadCommitGuidance, resolveForgeToken, } from "../../vcs/index.js";
|
|
10
11
|
/**
|
|
11
12
|
* `cruxy pr` — turn the current changes into a pull request (C.15). Generates the
|
|
@@ -48,6 +49,18 @@ export function prCommand() {
|
|
|
48
49
|
// resolveForgeToken throws CRUXY_E_FORGE_AUTH (exit 4) if none is found.
|
|
49
50
|
const forge = createForgeProvider(resolveForgeToken());
|
|
50
51
|
const guidance = await loadCommitGuidance(cwd);
|
|
52
|
+
// The verification record (P2): this command runs outside any
|
|
53
|
+
// session, so the evidence is the project's latest session log — its
|
|
54
|
+
// runs, each dated, under a header naming that session rather than
|
|
55
|
+
// "this session". The model never writes the section (generate.ts);
|
|
56
|
+
// no session here, or one that ran nothing → no section at all.
|
|
57
|
+
const recorded = latestRecordedRuns(cwd);
|
|
58
|
+
const verification = recorded
|
|
59
|
+
? {
|
|
60
|
+
runs: recorded.runs,
|
|
61
|
+
from: `in the latest session for this project (${shortId(recorded.sessionId)})`,
|
|
62
|
+
}
|
|
63
|
+
: undefined;
|
|
51
64
|
// The allowlist here is provably inert, and stated rather than implied
|
|
52
65
|
// (cli#251). `cruxy pr` submits exactly one kind of action — `vcs` — and
|
|
53
66
|
// `vcsRequest` gives every one of them `scope: {kind: "none"}` and an
|
|
@@ -70,6 +83,7 @@ export function prCommand() {
|
|
|
70
83
|
...i,
|
|
71
84
|
scopes: guidance.scopes,
|
|
72
85
|
skillBody: guidance.skillBody,
|
|
86
|
+
...(verification ? { verification } : {}),
|
|
73
87
|
}, { model: genModel }),
|
|
74
88
|
});
|
|
75
89
|
logger.info(t.muted("generating pull request content…"));
|