@hncode/hncode 0.5.0 → 0.5.1
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/package.json +1 -1
- package/src/agent.js +4 -2
- package/src/colors.js +478 -387
- package/src/config.js +38 -11
- package/src/doctor.js +209 -11
- package/src/memory-profile.js +247 -0
- package/src/row-cache.js +147 -142
- package/src/session-hub.js +1 -0
- package/src/settings-schema.js +301 -0
- package/src/tool-result.js +264 -236
- package/src/tools/edit.js +1 -1
- package/src/tui.js +544 -80
- package/src/web.js +3 -1
package/src/tool-result.js
CHANGED
|
@@ -1,236 +1,264 @@
|
|
|
1
|
-
// Tool-result size control.
|
|
2
|
-
//
|
|
3
|
-
// Two separate problems, both of which used to be silent:
|
|
4
|
-
//
|
|
5
|
-
// 1. A SINGLE huge result. Per-tool caps exist (`MAX_OUTPUT_BYTES` in
|
|
6
|
-
// tools/utils.js) but `Read` deliberately has none, and nothing bounded the
|
|
7
|
-
// AGGREGATE of a step's results — a turn with four Reads could add half a
|
|
8
|
-
// megabyte to a history that is then resent on every later request. Real
|
|
9
|
-
// sessions in ~/.hncode/sessions reached 5 MB this way.
|
|
10
|
-
// 2. OLD results that no longer matter. A file read forty turns ago is dead
|
|
11
|
-
// weight, but the whole history is resent every request, so it is paid for
|
|
12
|
-
// again and again until something compacts.
|
|
13
|
-
//
|
|
14
|
-
// The cap is applied where the result is BUILT (see `shapeToolResult`), and the
|
|
15
|
-
// overflow is kept, not lost: it goes to a file under the session's own
|
|
16
|
-
// directory and the model gets a bounded preview plus the path, so it can Read
|
|
17
|
-
// the rest if it turns out to need it. This mirrors Claude Code's
|
|
18
|
-
// `<persisted-output>` handling (src/utils/toolResultStorage.ts).
|
|
19
|
-
//
|
|
20
|
-
// The ages pass (stale-pass) is the cheap half of compaction: it clears the
|
|
21
|
-
// BODY of old tool results while leaving the message — and therefore the
|
|
22
|
-
// assistant/tool pairing and the exact prefix length — untouched, so it does not
|
|
23
|
-
// disturb the message list shape the provider has already cached. Claude Code
|
|
24
|
-
// does the same thing in microCompact.ts ("[Old tool result content cleared]").
|
|
25
|
-
|
|
26
|
-
import fs from 'node:fs';
|
|
27
|
-
import path from 'node:path';
|
|
28
|
-
|
|
29
|
-
// Bytes of a single tool result that are kept inline. Above this the overflow is
|
|
30
|
-
// written to disk. 50 KB is Claude Code's DEFAULT_MAX_RESULT_SIZE_CHARS and is
|
|
31
|
-
// comfortably more than a whole source file's worth of lines.
|
|
32
|
-
export const MAX_RESULT_BYTES = 50 * 1024;
|
|
33
|
-
|
|
34
|
-
// Bytes of a spilled result that stay in the transcript as a preview.
|
|
35
|
-
export const PREVIEW_BYTES = 2 * 1024;
|
|
36
|
-
|
|
37
|
-
// Trigger ratio: trim once the request reaches this fraction of the model window.
|
|
38
|
-
// 50% by default (`trim_threshold` / HNCODE_TRIM_THRESHOLD). Well below the
|
|
39
|
-
// compaction trigger on purpose — this is the cheap pass that keeps the history
|
|
40
|
-
// from ever getting near compaction in the first place.
|
|
41
|
-
export const DEFAULT_TRIM_THRESHOLD = 0.5;
|
|
42
|
-
|
|
43
|
-
// Fraction of the tool-result TEXT that survives a trim. 30% by default
|
|
44
|
-
// (`trim_keep_ratio` / HNCODE_TRIM_KEEP_RATIO)
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
1
|
+
// Tool-result size control.
|
|
2
|
+
//
|
|
3
|
+
// Two separate problems, both of which used to be silent:
|
|
4
|
+
//
|
|
5
|
+
// 1. A SINGLE huge result. Per-tool caps exist (`MAX_OUTPUT_BYTES` in
|
|
6
|
+
// tools/utils.js) but `Read` deliberately has none, and nothing bounded the
|
|
7
|
+
// AGGREGATE of a step's results — a turn with four Reads could add half a
|
|
8
|
+
// megabyte to a history that is then resent on every later request. Real
|
|
9
|
+
// sessions in ~/.hncode/sessions reached 5 MB this way.
|
|
10
|
+
// 2. OLD results that no longer matter. A file read forty turns ago is dead
|
|
11
|
+
// weight, but the whole history is resent every request, so it is paid for
|
|
12
|
+
// again and again until something compacts.
|
|
13
|
+
//
|
|
14
|
+
// The cap is applied where the result is BUILT (see `shapeToolResult`), and the
|
|
15
|
+
// overflow is kept, not lost: it goes to a file under the session's own
|
|
16
|
+
// directory and the model gets a bounded preview plus the path, so it can Read
|
|
17
|
+
// the rest if it turns out to need it. This mirrors Claude Code's
|
|
18
|
+
// `<persisted-output>` handling (src/utils/toolResultStorage.ts).
|
|
19
|
+
//
|
|
20
|
+
// The ages pass (stale-pass) is the cheap half of compaction: it clears the
|
|
21
|
+
// BODY of old tool results while leaving the message — and therefore the
|
|
22
|
+
// assistant/tool pairing and the exact prefix length — untouched, so it does not
|
|
23
|
+
// disturb the message list shape the provider has already cached. Claude Code
|
|
24
|
+
// does the same thing in microCompact.ts ("[Old tool result content cleared]").
|
|
25
|
+
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
|
|
29
|
+
// Bytes of a single tool result that are kept inline. Above this the overflow is
|
|
30
|
+
// written to disk. 50 KB is Claude Code's DEFAULT_MAX_RESULT_SIZE_CHARS and is
|
|
31
|
+
// comfortably more than a whole source file's worth of lines.
|
|
32
|
+
export const MAX_RESULT_BYTES = 50 * 1024;
|
|
33
|
+
|
|
34
|
+
// Bytes of a spilled result that stay in the transcript as a preview.
|
|
35
|
+
export const PREVIEW_BYTES = 2 * 1024;
|
|
36
|
+
|
|
37
|
+
// Trigger ratio: trim once the request reaches this fraction of the model window.
|
|
38
|
+
// 50% by default (`trim_threshold` / HNCODE_TRIM_THRESHOLD). Well below the
|
|
39
|
+
// compaction trigger on purpose — this is the cheap pass that keeps the history
|
|
40
|
+
// from ever getting near compaction in the first place.
|
|
41
|
+
export const DEFAULT_TRIM_THRESHOLD = 0.5;
|
|
42
|
+
|
|
43
|
+
// Fraction of the tool-result TEXT that survives a trim. 30% by default
|
|
44
|
+
// (`trim_keep_ratio` / HNCODE_TRIM_KEEP_RATIO); the rest of the eligible tool text is
|
|
45
|
+
// elided.
|
|
46
|
+
//
|
|
47
|
+
// A DIFFERENT denominator from `trim_threshold` above, on purpose. The threshold asks
|
|
48
|
+
// whether the request is too big for the window (a share of the WINDOW); this asks how much
|
|
49
|
+
// of the removable output stays (a share of the TOOL TEXT). Measuring this against the
|
|
50
|
+
// request was tried and reverted — at a 1M window the budget then exceeded all the elidable
|
|
51
|
+
// text, so `/trim 0.6` could not trim anything at all.
|
|
52
|
+
//
|
|
53
|
+
// The conversation is protected by the candidate filter in `planTrim`, not by a ratio:
|
|
54
|
+
// only messages with `role === 'tool'` are ever elided.
|
|
55
|
+
export const DEFAULT_TRIM_KEEP_RATIO = 0.3;
|
|
56
|
+
|
|
57
|
+
// A tool result shorter than this is never worth eliding: the marker plus the
|
|
58
|
+
// pointer would cost more than the text it replaces.
|
|
59
|
+
export const MIN_TRIM_BYTES = 512;
|
|
60
|
+
|
|
61
|
+
export const CLEARED_MARKER = '[Old tool result content cleared]';
|
|
62
|
+
export const PERSISTED_TAG = 'persisted-output';
|
|
63
|
+
|
|
64
|
+
// Directory for spilled results, beside the session file so it is cleaned up
|
|
65
|
+
// with the session rather than accumulating in a temp dir.
|
|
66
|
+
export function spillDir(sessionId) {
|
|
67
|
+
const root = process.env.HNCODE_HOME || path.join(process.env.USERPROFILE || process.env.HOME || '.', '.hncode');
|
|
68
|
+
return path.join(root, 'tool-results', String(sessionId || 'unscoped'));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Write `text` to disk and return the preview + path, or null when the write
|
|
72
|
+
// fails (a read-only disk must not fail the tool call).
|
|
73
|
+
export function spillResult(sessionId, toolCallId, text) {
|
|
74
|
+
try {
|
|
75
|
+
const dir = spillDir(sessionId);
|
|
76
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
77
|
+
// The tool-call id is already a unique, filesystem-safe token from the
|
|
78
|
+
// provider, so it needs no sanitizing beyond dropping path separators.
|
|
79
|
+
const safe = String(toolCallId || 'result').replace(/[^A-Za-z0-9_.-]/g, '_');
|
|
80
|
+
const file = path.join(dir, `${safe}.txt`);
|
|
81
|
+
fs.writeFileSync(file, text, 'utf8');
|
|
82
|
+
return { file, preview: previewOf(text, PREVIEW_BYTES), originalBytes: Buffer.byteLength(text, 'utf8') };
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A preview cut on a line boundary when one is close enough, so the model does
|
|
89
|
+
// not receive half a line of code as its last visible detail.
|
|
90
|
+
export function previewOf(text, maxBytes) {
|
|
91
|
+
const s = String(text == null ? '' : text);
|
|
92
|
+
if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;
|
|
93
|
+
let cut = Buffer.from(s, 'utf8').subarray(0, maxBytes).toString('utf8');
|
|
94
|
+
if (cut.endsWith('\uFFFD')) cut = cut.slice(0, -1);
|
|
95
|
+
const nl = cut.lastIndexOf('\n');
|
|
96
|
+
if (nl > maxBytes * 0.5) cut = cut.slice(0, nl);
|
|
97
|
+
return cut;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The message the model sees in place of an oversized result. It says what
|
|
101
|
+
// happened, how big it was, where the rest is, and how to get it — an agent that
|
|
102
|
+
// cannot tell why its output is missing retries the same call forever.
|
|
103
|
+
export function spillNotice(spilled) {
|
|
104
|
+
return [
|
|
105
|
+
`Output too large (${fmtBytes(spilled.originalBytes)}). Full output saved to: ${spilled.file}`,
|
|
106
|
+
'',
|
|
107
|
+
`Preview (first ${fmtBytes(PREVIEW_BYTES)}):`,
|
|
108
|
+
spilled.preview,
|
|
109
|
+
'',
|
|
110
|
+
`Read the file above to see the rest — use line_offset / n_lines to page through it.`,
|
|
111
|
+
].join('\n');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function fmtBytes(n) {
|
|
115
|
+
const b = Number(n) || 0;
|
|
116
|
+
if (b < 1024) return `${b} B`;
|
|
117
|
+
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
|
118
|
+
return `${(b / (1024 * 1024)).toFixed(1)} MB`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Shape ONE tool result for the model: pass small results through, spill big
|
|
122
|
+
// ones. Returns { content, spilled } where `content` is what goes into the
|
|
123
|
+
// message. `sessionId`/`toolCallId` identify the spill file.
|
|
124
|
+
export function shapeToolResult(content, { sessionId, toolCallId, maxBytes = MAX_RESULT_BYTES } = {}) {
|
|
125
|
+
if (typeof content !== 'string') return { content, spilled: false };
|
|
126
|
+
if (Buffer.byteLength(content, 'utf8') <= maxBytes) return { content, spilled: false };
|
|
127
|
+
const spilled = spillResult(sessionId, toolCallId, content);
|
|
128
|
+
// The write failed: a normal-sized preview is still better than megabytes, and
|
|
129
|
+
// the notice must not claim a path that does not exist.
|
|
130
|
+
if (!spilled) {
|
|
131
|
+
return {
|
|
132
|
+
content: `${previewOf(content, PREVIEW_BYTES)}\n\n[Output truncated: ${fmtBytes(Buffer.byteLength(content, 'utf8'))} total; the full output could not be saved.]`,
|
|
133
|
+
spilled: false,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return { content: spillNotice(spilled), spilled: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Which tool results to elide, and by how much.
|
|
140
|
+
//
|
|
141
|
+
// RULE (the user's): keep a FRACTION of the TOOL-RESULT TEXT, the newest part.
|
|
142
|
+
// Walk the results from newest to oldest accumulating their size; once the total kept
|
|
143
|
+
// exceeds `keepRatio` of ALL tool-result text, everything older is elided.
|
|
144
|
+
//
|
|
145
|
+
// THE DENOMINATOR IS THE TOOL TEXT, NOT THE REQUEST, and the two knobs deliberately use
|
|
146
|
+
// different scales because they answer different questions:
|
|
147
|
+
//
|
|
148
|
+
// * `trimThreshold` asks "is the request too big for the window yet?" — a share of the
|
|
149
|
+
// WINDOW.
|
|
150
|
+
// * `keep` asks "of the output we are allowed to remove, how much stays?" — a share of
|
|
151
|
+
// the TOOL TEXT.
|
|
152
|
+
//
|
|
153
|
+
// Measuring `keep` against the request was tried and reverted: at a 1M window with 270k of
|
|
154
|
+
// tool text, `threshold` fires at 500k while `keep=0.6` asks to retain 300k — MORE than the
|
|
155
|
+
// 270k that could ever be elided. So `/trim 0.6` reported "nothing to trim", and it did so
|
|
156
|
+
// for every request above 450k. A knob whose behaviour depends on a total the user cannot
|
|
157
|
+
// see is a knob nobody can predict; a share of the tool text behaves the same in a short
|
|
158
|
+
// session and a long one.
|
|
159
|
+
//
|
|
160
|
+
// ONLY TOOL RESULTS ARE TOUCHED, whatever the ratio. The `role !== 'tool'` filter below is
|
|
161
|
+
// the real protection for the conversation: user messages, assistant prose and the system
|
|
162
|
+
// prompt are never candidates, so no ratio can reach them. (An earlier comment here claimed
|
|
163
|
+
// the denominator was what protected the conversation. It never was — the filter is.)
|
|
164
|
+
//
|
|
165
|
+
// Walking newest-first is what makes this behave the way a person expects: the
|
|
166
|
+
// results you were just working from are the ones that survive, and the material
|
|
167
|
+
// from twenty turns ago goes first.
|
|
168
|
+
//
|
|
169
|
+
// Returns { keep: [indices], elide: [indices], totalBytes, keepBytes, elideBytes }.
|
|
170
|
+
// `mustKeep` (an index) is never elided — the tool result belonging to the step
|
|
171
|
+
// currently in flight, if the caller knows it.
|
|
172
|
+
export function planTrim(messages, keepRatio, mustKeep) {
|
|
173
|
+
const empty = { keep: [], elide: [], totalBytes: 0, keepBytes: 0, elideBytes: 0 };
|
|
174
|
+
if (!Array.isArray(messages)) return empty;
|
|
175
|
+
const ratio = Number.isFinite(keepRatio) ? Math.min(Math.max(keepRatio, 0), 1) : DEFAULT_TRIM_KEEP_RATIO;
|
|
176
|
+
|
|
177
|
+
// Only string-bodied results can be elided; a media result carries content
|
|
178
|
+
// blocks and clearing it would delete the image itself.
|
|
179
|
+
const candidates = [];
|
|
180
|
+
let totalBytes = 0;
|
|
181
|
+
for (let i = 0; i < messages.length; i++) {
|
|
182
|
+
const m = messages[i];
|
|
183
|
+
if (!m || m.role !== 'tool') continue;
|
|
184
|
+
if (typeof m.content !== 'string') continue;
|
|
185
|
+
totalBytes += m.content.length;
|
|
186
|
+
if (m.content === CLEARED_MARKER) continue;
|
|
187
|
+
if (m.content.length < MIN_TRIM_BYTES) continue;
|
|
188
|
+
candidates.push(i);
|
|
189
|
+
}
|
|
190
|
+
if (!candidates.length) return { ...empty, totalBytes };
|
|
191
|
+
|
|
192
|
+
// A budget of 0 means "elide everything eligible"; a budget of 1 means "keep it
|
|
193
|
+
// all", in which case we still elide the already-marked ones so a repeated call
|
|
194
|
+
// is a no-op rather than a growing patch.
|
|
195
|
+
const budget = totalBytes * ratio;
|
|
196
|
+
const keep = new Set();
|
|
197
|
+
let keepBytes = 0;
|
|
198
|
+
for (let k = candidates.length - 1; k >= 0; k--) {
|
|
199
|
+
const i = candidates[k];
|
|
200
|
+
if (i === mustKeep) { keep.add(i); keepBytes += messages[i].content.length; continue; }
|
|
201
|
+
const size = messages[i].content.length;
|
|
202
|
+
// Keep this chunk if we are still under budget, but ALWAYS keep the single
|
|
203
|
+
// newest result: a trim that elides the output the model is currently reading
|
|
204
|
+
// is how a working turn turns into a confused one.
|
|
205
|
+
if (keepBytes + size <= budget || keep.size === 0) {
|
|
206
|
+
keep.add(i);
|
|
207
|
+
keepBytes += size;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const elide = [];
|
|
212
|
+
let elideBytes = 0;
|
|
213
|
+
for (const i of candidates) {
|
|
214
|
+
if (keep.has(i)) continue;
|
|
215
|
+
elide.push(i);
|
|
216
|
+
elideBytes += messages[i].content.length;
|
|
217
|
+
}
|
|
218
|
+
return { keep: [...keep].sort((a, b) => a - b), elide, totalBytes, keepBytes, elideBytes };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Apply a trim to `messages` IN PLACE, replacing each elided result with a short
|
|
222
|
+
// pointer to the text on disk.
|
|
223
|
+
//
|
|
224
|
+
// Two properties matter here and both are deliberate:
|
|
225
|
+
//
|
|
226
|
+
// * The message COUNT and every message's role stay identical. The provider's
|
|
227
|
+
// prompt cache is keyed on the prefix, and a message list that changes SHAPE
|
|
228
|
+
// invalidates it — replacing bodies keeps the shape.
|
|
229
|
+
//
|
|
230
|
+
// * The full text is written to disk first (spillDir), so an elided result is
|
|
231
|
+
// RECOVERABLE: the model reads the pointer and pages through the file. That is
|
|
232
|
+
// what makes "elided" different from "deleted" — the point the user raised
|
|
233
|
+
// about trimming eating their work.
|
|
234
|
+
//
|
|
235
|
+
// `sessionId`/`messages` identify where the copies go. Returns
|
|
236
|
+
// { elided, elidedBytes, kept, keptBytes }.
|
|
237
|
+
export function applyTrim(messages, plan, sessionId) {
|
|
238
|
+
const out = { elided: 0, elidedBytes: 0, kept: plan.keep.length, keptBytes: plan.keepBytes };
|
|
239
|
+
if (!Array.isArray(messages) || !plan.elide.length) return out;
|
|
240
|
+
for (const i of plan.elide) {
|
|
241
|
+
const m = messages[i];
|
|
242
|
+
const original = m.content;
|
|
243
|
+
// Copy it out BEFORE overwriting: this is the whole difference between a trim
|
|
244
|
+
// and a loss. If the write fails we still elide, but the pointer says so.
|
|
245
|
+
const spilled = spillResult(sessionId, `trim-${m.toolCallId || i}`, original);
|
|
246
|
+
const tool = m.name || 'tool';
|
|
247
|
+
m.content = spilled
|
|
248
|
+
? `[Output elided to save context — ${fmtBytes(original.length)} from ${tool}.\n`
|
|
249
|
+
+ `Full text saved to: ${spilled.file}\n`
|
|
250
|
+
+ `Read that file (with line_offset/n_lines) if you need this output again.]`
|
|
251
|
+
: `[Output elided to save context — ${fmtBytes(original.length)} from ${tool}; the full text could not be saved.]`;
|
|
252
|
+
out.elided++;
|
|
253
|
+
out.elidedBytes += original.length;
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Convenience: plan + apply in one go. Returns null when nothing needed trimming,
|
|
259
|
+
// so a caller can skip its UI event entirely.
|
|
260
|
+
export function trimToolResults(messages, opts = {}) {
|
|
261
|
+
const plan = planTrim(messages, opts.keepRatio, opts.mustKeep);
|
|
262
|
+
if (!plan.elide.length) return null;
|
|
263
|
+
return { ...applyTrim(messages, plan, opts.sessionId), plan };
|
|
264
|
+
}
|
package/src/tools/edit.js
CHANGED
|
@@ -28,7 +28,7 @@ import { hashRegion as hashLines } from './read.js';
|
|
|
28
28
|
|
|
29
29
|
export const spec = {
|
|
30
30
|
name: 'Edit',
|
|
31
|
-
description: 'Edit a file. THE TWO MODES ARE MUTUALLY EXCLUSIVE — NEVER combine them:\n- LINE-RANGE mode: path + start_line + end_line + new_string. Replaces lines [start_line, end_line] with new_string.\nPREFERRED — use it whenever you have Read the file and know the line numbers.\n- EXACT-SUBSTRING mode: path + old_string + new_string. Replaces text exactly matching old_string. Use ONLY when the exact text is known and a line range is not convenient.\nLEADING WHITESPACE — SPELL IT OUT ON EVERY LINE, INCLUDING THE FIRST. new_string is written VERBATIM in both modes: nothing is re-indented, auto-indented, or trimmed, and there is no tolerance for a missing first-line indent. Reproduce the indentation the code SHOULD have; it need not match the surrounding block. Copy it from the Read output, after its `N\\t` line-number prefix (Read prints `12\\t return x`, so you send ` return x`).\nWRONG —
|
|
31
|
+
description: 'Edit a file. THE TWO MODES ARE MUTUALLY EXCLUSIVE — NEVER combine them:\n- LINE-RANGE mode: path + start_line + end_line + new_string. Replaces lines [start_line, end_line] with new_string.\nPREFERRED — use it whenever you have Read the file and know the line numbers.\n- EXACT-SUBSTRING mode: path + old_string + new_string. Replaces text exactly matching old_string. Use ONLY when the exact text is known and a line range is not convenient.\nLEADING WHITESPACE — SPELL IT OUT ON EVERY LINE, INCLUDING THE FIRST. new_string is written VERBATIM in both modes: nothing is re-indented, auto-indented, or trimmed, and there is no tolerance for a missing first-line indent. Reproduce the indentation the code SHOULD have; it need not match the surrounding block. Copy it from the Read output, after its `N\\t` line-number prefix (Read prints `12\\t return x`, so you send ` return x`).\nWRONG — nothing between the quote and `return`, so the line lands at column 0 inside an indented block. Look at the SHAPE as well: the quote touches the first character, and spaces only turn up after the first newline.\nnew_string: "return total;\n total += 1;"\nRIGHT — four spaces, then `return`. The opening quote is followed by the indent as well, so every line carries its own indentation INCLUDING the first:\nnew_string: " return total;\n total += 1;"\nSame call, same words. The only difference is the spaces at the START of the first line, and that is the whole point — in the RIGHT form the indent sits between the quote and the code, exactly where it sits in the file you copied it from.\nThe same applies to tabs, and to a line whose indentation changes partway through.\nRANGE HYGIENE — WRITE ONLY THE LINES YOU ARE REPLACING. Lines [start_line, end_line] are deleted and new_string is inserted in their place, so a line you leave OUTSIDE that range survives — and if you also restated it inside new_string you get TWO copies. Check what sits on end_line + 1 before deciding where the range ends.\nWRONG — end_line: 3 deletes lines 2-3, but line 4 is repeated because its content was also written into new_string:\nnew_string: "const a = 1;\\nconst b = 20;\\nconst c = 3;"\nRIGHT — the range covers exactly what is being replaced, and nothing beyond it is restated:\nnew_string: "const a = 1;\\nconst b = 20;"\nTo touch line 4 as well, set end_line: 4 and include its new content in new_string.\nIn SUBSTRING mode old_string is matched EXACTLY, including the indentation of its own first line.\nDo NOT pass start_line/end_line together with old_string/new_string in one call, and do NOT pass any of these four keys when you are not using that mode. Omitting new_string is an error, not an empty replacement (in line-range mode pass new_string; in substring mode pass old_string + new_string).\nMis-indented lines often still parse, so a green test run is not proof that an edit landed correctly — re-Read the region before editing it again.\',',
|
|
32
32
|
parameters: {
|
|
33
33
|
type: 'object',
|
|
34
34
|
properties: {
|