@cruxy/cli 1.11.1 → 1.11.3
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/checkpoint/service.js +44 -3
- package/dist/cli/commands/pr.js +14 -0
- package/dist/cli/commands/run.js +35 -0
- package/dist/cli/commands/sessions.js +8 -0
- 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/errors/constructors.js +15 -6
- package/dist/errors/types.js +7 -0
- package/dist/indexing/embedder.js +34 -11
- package/dist/indexing/model-cache.js +399 -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 +7 -3
- package/dist/session/log.js +163 -2
- package/dist/session/owner.js +123 -0
- package/dist/session/prune.js +11 -0
- package/dist/session/recorded-runs.js +56 -0
- package/dist/session/replay.js +75 -1
- package/dist/session/resume.js +110 -3
- package/dist/session/types.js +158 -0
- package/dist/subagent/orchestrator.js +2 -2
- package/dist/subagent/registry-scope.js +28 -5
- 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 +53 -23
- package/dist/tools/file/edit-file.js +19 -1
- package/dist/tools/file/snapshot.js +68 -0
- package/dist/tools/file/write-file.js +31 -5
- 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/utils/process-owner.js +107 -0
- 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 +3 -2
|
@@ -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
|
}
|
|
@@ -8,6 +8,7 @@ import { captureFiles } from "./capture.js";
|
|
|
8
8
|
import { GitCheckpointStore } from "./git-store.js";
|
|
9
9
|
import { ShadowCheckpointStore } from "./shadow-store.js";
|
|
10
10
|
import { applyRollback, buildRollbackPreview, computeRollbackPlan, } from "./restore.js";
|
|
11
|
+
import { describeOwner, selfStamp, } from "../utils/process-owner.js";
|
|
11
12
|
/** Is `root` inside a git working tree? (Decides the checkpoint substrate.) */
|
|
12
13
|
export function isGitWorkTree(root) {
|
|
13
14
|
const res = runGitCapture(["rev-parse", "--is-inside-work-tree"], root);
|
|
@@ -112,6 +113,7 @@ export class CheckpointService {
|
|
|
112
113
|
files: entries,
|
|
113
114
|
touchedPaths: [],
|
|
114
115
|
hasShellMutations: false,
|
|
116
|
+
owner: selfStamp(),
|
|
115
117
|
};
|
|
116
118
|
await this.writeManifest(checkpoint);
|
|
117
119
|
await this.prune();
|
|
@@ -203,10 +205,27 @@ export class CheckpointService {
|
|
|
203
205
|
const applied = await applyRollback(this.root, plan, store);
|
|
204
206
|
return { kind: "applied", checkpoint, applied };
|
|
205
207
|
}
|
|
206
|
-
/**
|
|
208
|
+
/**
|
|
209
|
+
* Enforce `checkpoint.retention`: drop oldest manifests, then GC content.
|
|
210
|
+
*
|
|
211
|
+
* ACROSS PROCESSES (P1). `.cruxy/checkpoints/` is per root, not per process,
|
|
212
|
+
* and every cruxy in the project prunes it by count — so a second session
|
|
213
|
+
* creating its own checkpoints pushed the first session's out of the
|
|
214
|
+
* window and swept their objects, and the first session's `cruxy rollback`
|
|
215
|
+
* then failed or restored the wrong thing. A checkpoint whose recorded
|
|
216
|
+
* owner is another LIVE process is therefore not this process's to prune:
|
|
217
|
+
* it is set aside, its objects stay referenced, and the retention count is
|
|
218
|
+
* applied to everything else. The bound is exceeded by at most the live
|
|
219
|
+
* foreign checkpoints, and only while their owners run: a crashed owner's
|
|
220
|
+
* checkpoints are ordinary candidates the next time anyone prunes, because
|
|
221
|
+
* liveness is pid + start-time asked of the OS, never a file that must be
|
|
222
|
+
* cleaned up.
|
|
223
|
+
*/
|
|
207
224
|
async prune() {
|
|
208
225
|
const all = await this.list();
|
|
209
|
-
const
|
|
226
|
+
const protectedIds = foreignLive(all);
|
|
227
|
+
const candidates = all.filter((c) => !protectedIds.has(c.id));
|
|
228
|
+
const doomed = candidates.slice(this.config.checkpoint.retention);
|
|
210
229
|
if (doomed.length === 0)
|
|
211
230
|
return;
|
|
212
231
|
for (const checkpoint of doomed) {
|
|
@@ -214,7 +233,7 @@ export class CheckpointService {
|
|
|
214
233
|
force: true,
|
|
215
234
|
});
|
|
216
235
|
}
|
|
217
|
-
const survivors = all.
|
|
236
|
+
const survivors = all.filter((c) => !doomed.includes(c));
|
|
218
237
|
const referenced = new Set(survivors.flatMap((c) => c.files.map((f) => f.oid)));
|
|
219
238
|
// The shadow pool is ours to sweep; git's dangling objects belong to git gc.
|
|
220
239
|
await new ShadowCheckpointStore(this.root).collect(referenced);
|
|
@@ -266,6 +285,28 @@ export class CheckpointService {
|
|
|
266
285
|
return parsed;
|
|
267
286
|
}
|
|
268
287
|
}
|
|
288
|
+
/**
|
|
289
|
+
* Ids of the checkpoints another LIVE process owns (P1 — see {@link prune}).
|
|
290
|
+
* One liveness lookup per distinct owner, not per manifest: the darwin lookup
|
|
291
|
+
* is a `ps` call, and a project can hold many checkpoints from one session.
|
|
292
|
+
*/
|
|
293
|
+
function foreignLive(all) {
|
|
294
|
+
const ids = new Set();
|
|
295
|
+
const status = new Map();
|
|
296
|
+
for (const c of all) {
|
|
297
|
+
if (!c.owner)
|
|
298
|
+
continue;
|
|
299
|
+
const key = `${c.owner.pid}:${c.owner.token}`;
|
|
300
|
+
let s = status.get(key);
|
|
301
|
+
if (s === undefined) {
|
|
302
|
+
s = describeOwner(c.owner);
|
|
303
|
+
status.set(key, s);
|
|
304
|
+
}
|
|
305
|
+
if (s === "live")
|
|
306
|
+
ids.add(c.id);
|
|
307
|
+
}
|
|
308
|
+
return ids;
|
|
309
|
+
}
|
|
269
310
|
/** `ck-<utc-stamp>-<rand>` — sortable, collision-safe enough for a local CLI. */
|
|
270
311
|
function newCheckpointId() {
|
|
271
312
|
const stamp = new Date()
|