@hncode/hncode 0.6.2 → 0.6.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/package.json +1 -1
- package/src/agent.js +35 -4
- package/src/config.js +19 -10
- package/src/row-cache.js +31 -2
- package/src/settings-schema.js +17 -2
- package/src/team/team.js +7 -4
- package/src/tools/ask-user-question.js +18 -3
- package/src/tools/edit.js +44 -74
- package/src/tools/web-search.js +205 -139
- package/src/tui.js +859 -118
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -73,10 +73,13 @@ const COMPACTION_INSTRUCTION = [
|
|
|
73
73
|
'',
|
|
74
74
|
'Be concise and proportional: a long multi-step task warrants detail, a nearly finished exchange needs only a sentence or two.',
|
|
75
75
|
'',
|
|
76
|
-
'Respond with text only. Do not call any tools. Any tool-call syntax in your answer is discarded and scores the handoff as failed.'
|
|
76
|
+
'Respond with text only. Do not call any tools. Any tool-call syntax in your answer is discarded and scores the handoff as failed.',
|
|
77
|
+
'',
|
|
78
|
+
'That includes XML-style tool syntax such as `<||DSML||invoke name="...">` or `<function_call>`: no such tag is',
|
|
79
|
+
'recognised here, so it becomes part of the summary and the next turn reads it as a step that happened.',
|
|
77
80
|
].join('\n');
|
|
78
|
-
|
|
79
81
|
// Trailing note: the model should RESUME, not answer the summary.
|
|
82
|
+
|
|
80
83
|
// A summariser can answer with tool-call syntax even when no tools were offered — the format is in its
|
|
81
84
|
// training data, and a model interrupted mid-workflow reaches for it. Nothing was called, so the text is
|
|
82
85
|
// noise, and left in a handoff summary the next turn reads it as a step that happened.
|
|
@@ -96,10 +99,20 @@ function stripToolCallText(text) {
|
|
|
96
99
|
let out = text;
|
|
97
100
|
// A special-token block: closed form, or an unterminated one running to the end of its line.
|
|
98
101
|
out = out.replace(/<\|tool_call\|>[^\n]*(?:<\|\/tool_call\|>)?/g, '');
|
|
99
|
-
//
|
|
102
|
+
// A fenced block form.
|
|
100
103
|
out = out.replace(/```tool_call[\s\S]*?```/g, '');
|
|
101
104
|
// A bare JSON object on its own lines that declares a tool invocation.
|
|
102
105
|
out = out.replace(/^\s*\{[^\n]*"name"\s*:\s*"[^"]+"[^\n]*"arguments"[^\n]*\}\s*$/gm, '');
|
|
106
|
+
// THE DSML DIALECT. Some models write a tool call as an XML-ish block with a doubled delimiter around the
|
|
107
|
+
// tag name (`<||DSML||invoke name="…">`), which none of the patterns above match. It arrives as a whole
|
|
108
|
+
// block, so it is removed as one — an unterminated block included, since a summariser cut off by the token
|
|
109
|
+
// limit leaves exactly that.
|
|
110
|
+
out = out.replace(/<\|+\|?DSML\|?\|+[\s\S]*?(?=<\/\|\|+DSML\|\|+>|$)/gi, '');
|
|
111
|
+
out = out.replace(/<\/?\|+\|?DSML\|?\|+[^>]*>/gi, '');
|
|
112
|
+
// THE `<function_call>` DIALECT and its `<tool_call>` sibling: a JSON payload wrapped in a tag. Removed with
|
|
113
|
+
// its body when both tags are present, and on its own when the body follows on the next line.
|
|
114
|
+
out = out.replace(/<(function_call|tool_call)>[\s\S]*?<\/\1>/gi, '');
|
|
115
|
+
out = out.replace(/<(function_call|tool_call)>[^\n]*/gi, '');
|
|
103
116
|
out = out.replace(/\n{3,}/g, '\n\n').trim();
|
|
104
117
|
|
|
105
118
|
if (out.length > 0) return out;
|
|
@@ -115,7 +128,6 @@ function stripToolCallText(text) {
|
|
|
115
128
|
|
|
116
129
|
const COMPACTION_CONTINUATION_TEXT = 'Context compaction is complete — continue the work that was in progress when it began.';
|
|
117
130
|
|
|
118
|
-
// Clip to a TOKEN budget, counting non-ASCII as one token per character (the same
|
|
119
131
|
// Clip to a TOKEN budget, counting non-ASCII as one token per character (the same
|
|
120
132
|
// rule estimateTokens uses). Slicing by characters is wrong for CJK text: 4000
|
|
121
133
|
// Chinese characters are ~4000 tokens, not ~1000.
|
|
@@ -708,6 +720,16 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
|
708
720
|
// What it IS used for: cost and `/usage`, where it is the only honest source, and the turn's
|
|
709
721
|
// output counter.
|
|
710
722
|
this.usage = addUsage(this.usage, e.usage);
|
|
723
|
+
// THE REAL SIZE OF THE REQUEST THAT JUST WENT OUT.
|
|
724
|
+
//
|
|
725
|
+
// `usage.input` is what the endpoint actually billed for this request, which is the honest answer
|
|
726
|
+
// to "how full is the context". The compaction threshold reads it (see `usedNow` below) instead of
|
|
727
|
+
// the local estimate, which runs low because it cannot model the provider's framing. `input`
|
|
728
|
+
// INCLUDES the reply's own prompt but not the reply, which is exactly the figure to compare against
|
|
729
|
+
// the limit.
|
|
730
|
+
if (e.usage && Number.isFinite(e.usage.input) && e.usage.input > 0) {
|
|
731
|
+
this.ctxReportedTokens = e.usage.input;
|
|
732
|
+
}
|
|
711
733
|
this.onEvent({ type: 'usage', usage: e.usage, total: this.usage });
|
|
712
734
|
break;
|
|
713
735
|
case 'tool_start':
|
|
@@ -778,7 +800,16 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
|
778
800
|
// request that leaves less than the reserved headroom is compacted even
|
|
779
801
|
// below 85%, so the reply has room to be generated.
|
|
780
802
|
const maxCtx = this.cfg.maxContextTokens || 512000;
|
|
803
|
+
// THE PROVIDER'S OWN COUNT WINS WHEN THERE IS ONE.
|
|
804
|
+
//
|
|
805
|
+
// `usedTokens()` is a local estimate from the token ledger. The endpoint reports the real size with
|
|
806
|
+
// every reply, and that figure is what the status bar shows — but this decision used the estimate
|
|
807
|
+
// regardless, so a session the gauge put at 88% could stay under an 85% trigger indefinitely: the
|
|
808
|
+
// estimate runs low because it cannot model the provider's own message framing and tool preamble.
|
|
809
|
+
// Two numbers for the same thing is the bug; the reported one is the one that is true.
|
|
781
810
|
let usedNow = this.usedTokens();
|
|
811
|
+
const reported = Number(this.ctxReportedTokens);
|
|
812
|
+
if (Number.isFinite(reported) && reported > 0) usedNow = reported;
|
|
782
813
|
// THE MESSAGE THAT TRIGGERED THIS TURN IS NOT YET IN A REQUEST.
|
|
783
814
|
//
|
|
784
815
|
// The user's message is appended before `run()` is entered, so a fresh turn that pushes the
|
package/src/config.js
CHANGED
|
@@ -339,16 +339,16 @@ const b = baseUrl.replace(/\/+$/, '');
|
|
|
339
339
|
// An empty base URL produces a relative endpoint like "/chat/completions",
|
|
340
340
|
// which fetch() rejects with a URL-scheme error the model can't decode. Leave
|
|
341
341
|
// the endpoint empty instead so the caller can show "not set".
|
|
342
|
-
// ANTHROPIC ALWAYS SPEAKS /v1/messages. A base of `https://api.anthropic.com` (which is what the
|
|
343
|
-
// provider table stores, and what people type) produced `…/messages` and 404'd; adding /v1 only when
|
|
344
|
-
// the base does not already carry a version segment makes both spellings work.
|
|
345
|
-
//
|
|
346
|
-
// The trailing slashes are stripped above, which is what kept `https://api.anthropic.com/v1/` from
|
|
347
|
-
// becoming `…/v1//messages`.
|
|
348
|
-
const versioned = protocol === 'anthropic' && !/\/v\d+$/.test(b) ? `${b}/v1` : b;
|
|
349
|
-
const endpoint = !b ? '' : (protocol === 'anthropic' ? `${versioned}/messages`
|
|
350
|
-
: protocol === 'responses' ? `${b}/responses`
|
|
351
|
-
: `${b}/chat/completions`);
|
|
342
|
+
// ANTHROPIC ALWAYS SPEAKS /v1/messages. A base of `https://api.anthropic.com` (which is what the
|
|
343
|
+
// provider table stores, and what people type) produced `…/messages` and 404'd; adding /v1 only when
|
|
344
|
+
// the base does not already carry a version segment makes both spellings work.
|
|
345
|
+
//
|
|
346
|
+
// The trailing slashes are stripped above, which is what kept `https://api.anthropic.com/v1/` from
|
|
347
|
+
// becoming `…/v1//messages`.
|
|
348
|
+
const versioned = protocol === 'anthropic' && !/\/v\d+$/.test(b) ? `${b}/v1` : b;
|
|
349
|
+
const endpoint = !b ? '' : (protocol === 'anthropic' ? `${versioned}/messages`
|
|
350
|
+
: protocol === 'responses' ? `${b}/responses`
|
|
351
|
+
: `${b}/chat/completions`);
|
|
352
352
|
|
|
353
353
|
// Context window: an explicit env/root override wins, then the model's own
|
|
354
354
|
// `contextLength` (discovered from the provider's /v1/models), then the
|
|
@@ -509,6 +509,15 @@ const b = baseUrl.replace(/\/+$/, '');
|
|
|
509
509
|
// because the two are a matter of taste and the cost is one branch.
|
|
510
510
|
shimmerEdge: String(root.shimmer_edge || process.env.HNCODE_SHIMMER_EDGE || 'cosine').toLowerCase() === 'linear'
|
|
511
511
|
? 'linear' : 'cosine',
|
|
512
|
+
// WEB SEARCH KEYS (`web_search_api_key`).
|
|
513
|
+
//
|
|
514
|
+
// Keyless mode is rate-limited BY IP, so a shared or busy address gets refusals that look like "search is
|
|
515
|
+
// broken". Tavily keys are per account and the tool tries them IN ORDER, so the monthly quota is additive.
|
|
516
|
+
//
|
|
517
|
+
// The value is a LIST, one key per line. It is kept as a STRING here — newline-separated — because that is
|
|
518
|
+
// what the multi-line editor edits and what `setConfigString` writes; splitting happens at the call site so
|
|
519
|
+
// a stray blank line can never count as a key. A single key is a list of one, so an old config still works.
|
|
520
|
+
webSearchApiKey: String(root.web_search_api_key || process.env.HNCODE_WEB_SEARCH_API_KEY || '').trim(),
|
|
512
521
|
raw: root,
|
|
513
522
|
};
|
|
514
523
|
}
|
package/src/row-cache.js
CHANGED
|
@@ -29,6 +29,15 @@
|
|
|
29
29
|
// what "the same" means. `evictIfStale` is the second half of the same contract: a streamed
|
|
30
30
|
// message's text grows every frame, so an entry whose text no longer matches is not a
|
|
31
31
|
// reuse, and the cache has to notice rather than serve it.
|
|
32
|
+
//
|
|
33
|
+
// THE TEXT IS HASHED, NOT EMBEDDED
|
|
34
|
+
// --------------------------------
|
|
35
|
+
// The first version put the whole message text in the key. Measured: a 1260-character
|
|
36
|
+
// message produced a 1286-character key, so filling the 1500-entry cache held ~7MB of
|
|
37
|
+
// pure key strings — more than the rendered rows they identify. Every lookup also built a
|
|
38
|
+
// fresh megabyte-scale string to compare. A 32-bit hash plus the text LENGTH keeps a key
|
|
39
|
+
// under 60 characters; the length is what makes a collision mean "two texts of the same
|
|
40
|
+
// size hashed alike" rather than "any two texts", and a collision only costs a re-render.
|
|
32
41
|
|
|
33
42
|
/** How many messages keep their rows. Chosen by measurement, not by feel: the rows for a
|
|
34
43
|
* typical assistant message are a few hundred bytes each, so 1500 covers a long session's
|
|
@@ -36,8 +45,20 @@
|
|
|
36
45
|
export const ROW_CACHE_LIMIT = 1500;
|
|
37
46
|
|
|
38
47
|
/**
|
|
39
|
-
*
|
|
48
|
+
* FNV-1a over a string, as an unsigned 32-bit number.
|
|
40
49
|
*
|
|
50
|
+
* @param {string} s
|
|
51
|
+
* @returns {number}
|
|
52
|
+
*/
|
|
53
|
+
function hashText(s) {
|
|
54
|
+
let h = 2166136261 >>> 0;
|
|
55
|
+
for (let i = 0; i < s.length; i++) {
|
|
56
|
+
h ^= s.charCodeAt(i);
|
|
57
|
+
h = Math.imul(h, 16777619) >>> 0;
|
|
58
|
+
}
|
|
59
|
+
return h >>> 0;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
41
62
|
* Every input the render reads goes in, so "the same key" means "the same rows":
|
|
42
63
|
* `id` a stable identity for the message. Index-based ids break the moment a message
|
|
43
64
|
* is inserted or removed, which is exactly what compaction and /undo do.
|
|
@@ -49,9 +70,17 @@ export const ROW_CACHE_LIMIT = 1500;
|
|
|
49
70
|
* spinner key.
|
|
50
71
|
*/
|
|
51
72
|
export function makeRowKey(parts) {
|
|
73
|
+
// THE TEXT IS HASHED, NOT EMBEDDED. Measured: a 1260-character message produced a
|
|
74
|
+
// 1286-character key, so a full cache held megabytes of key strings — more than the rows
|
|
75
|
+
// they identify — and every lookup built a fresh one to compare. Hash plus LENGTH keeps a
|
|
76
|
+
// key around 50 characters. The length is what narrows a collision from "any two texts"
|
|
77
|
+
// to "two texts of the same size that also hash alike", and a collision costs a re-render,
|
|
78
|
+
// not a wrong row.
|
|
79
|
+
const text = parts.text == null ? '' : String(parts.text);
|
|
52
80
|
return [
|
|
53
81
|
parts.id,
|
|
54
|
-
|
|
82
|
+
text.length,
|
|
83
|
+
hashText(text),
|
|
55
84
|
parts.w,
|
|
56
85
|
parts.fg,
|
|
57
86
|
parts.codeFg,
|
package/src/settings-schema.js
CHANGED
|
@@ -79,8 +79,8 @@ export const SETTINGS_SCHEMA = [
|
|
|
79
79
|
ui: { tab: 'appearance', group: 'Display', label: 'Calm mode', hint: 'Fewer decorative flourishes; keeps the information, drops the animation' } },
|
|
80
80
|
{ key: 'reduced_motion', ...bool(false),
|
|
81
81
|
ui: { tab: 'appearance', group: 'Motion', label: 'Reduced motion', hint: 'No sweeps or pulses — for a terminal over a slow link, or a preference' } },
|
|
82
|
-
{ key: 'render_fps', kind: 'int', default: '30',
|
|
83
|
-
ui: { tab: 'appearance', group: 'Performance', label: 'Render FPS', hint: 'How often the screen is repainted. 0 means 1 — a terminal cannot render at zero. Higher is smoother and costs more CPU' } },
|
|
82
|
+
{ key: 'render_fps', kind: 'int', default: '30',
|
|
83
|
+
ui: { tab: 'appearance', group: 'Performance', label: 'Render FPS', hint: 'How often the screen is repainted. 0 means 1 — a terminal cannot render at zero. Higher is smoother and costs more CPU' } },
|
|
84
84
|
{ key: 'shimmer_edge', ...bool(true),
|
|
85
85
|
ui: { tab: 'appearance', group: 'Motion', label: 'Shimmer edge', hint: 'The travelling highlight on a running tool name' } },
|
|
86
86
|
{ key: 'show_tool_details', ...bool(true),
|
|
@@ -151,6 +151,21 @@ export const SETTINGS_SCHEMA = [
|
|
|
151
151
|
ui: { tab: 'tools', group: 'Delegation', label: 'Swarm mode', hint: 'Expose AgentSwarm for fanning work across subagents' } },
|
|
152
152
|
{ key: 'output_style', ...str(''),
|
|
153
153
|
ui: { tab: 'tools', group: 'Output', label: 'Output style', hint: 'Named style overriding how replies are written' } },
|
|
154
|
+
// THE WEB SEARCH KEYS. Keyless search is rate-limited per IP, so on a shared or busy address WebSearch
|
|
155
|
+
// answers with refusals that read as "search is broken". Tavily keys are per account, and the tool tries
|
|
156
|
+
// them in order — several keys make the monthly quota additive.
|
|
157
|
+
//
|
|
158
|
+
// `kind: 'text'` is what makes /settings open the multi-line editor instead of asking for one line: the
|
|
159
|
+
// value IS a list, one key per line.
|
|
160
|
+
{ key: 'web_search_api_key', ...text(''),
|
|
161
|
+
ui: {
|
|
162
|
+
tab: 'tools',
|
|
163
|
+
group: 'Web',
|
|
164
|
+
label: 'WebSearch API keys',
|
|
165
|
+
hint: 'One key per line, tried in order — a second key is used when the first is out of quota. '
|
|
166
|
+
+ 'Get them free at https://app.tavily.com (sign in, copy from the dashboard). '
|
|
167
|
+
+ 'Empty uses keyless mode, which is rate-limited per IP.',
|
|
168
|
+
} },
|
|
154
169
|
{ key: 'auto_trim_aggressive', ...bool(false), ui: null },
|
|
155
170
|
{ key: 'no_delegation', ...bool(false), ui: null },
|
|
156
171
|
|
package/src/team/team.js
CHANGED
|
@@ -238,10 +238,13 @@ export function leadOf(teamName) {
|
|
|
238
238
|
/**
|
|
239
239
|
* Mail for the team's lead that it has not read yet.
|
|
240
240
|
*
|
|
241
|
-
* "THE LEAD" IS A
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
241
|
+
* "THE LEAD" IS A NAME, NOT NECESSARILY A ROSTERED AGENT. `createTeam` records `leadName` — `team-lead` by
|
|
242
|
+
* default — and `addMember` gives every recruit the plain `member` role. Labelling the first recruit a lead
|
|
243
|
+
* was tried and reversed: `/team add` recruits someone to DO the work, and the person typing is already in
|
|
244
|
+
* charge, so a self-styled lead believed nobody had assigned it anything.
|
|
245
|
+
*
|
|
246
|
+
* `leadName` may still match a member on the roster, and when it does that member IS the lead and has a full
|
|
247
|
+
* agent identity — `agentId`, `agentType`, `cwd`, a phase, its own inbox. Sending it an instruction is the
|
|
245
248
|
* normal way to command the team.
|
|
246
249
|
*
|
|
247
250
|
* `TEAM_LEAD_NAME` is a separate thing: the fixed alias the USER'S OWN sends carry (`/team msg`,
|
|
@@ -53,6 +53,8 @@ Do NOT use it for what you can infer from context, or for trivial decisions —
|
|
|
53
53
|
|
|
54
54
|
Usage:
|
|
55
55
|
- Users always get an "Other" option; do not create one.
|
|
56
|
+
+ 'For an answer that cannot be enumerated (a name, a path, a description), set free_text: true and omit'
|
|
57
|
+
+ ' options — the user gets a focused field instead of a list.',
|
|
56
58
|
- 2-4 distinct options per question, labels 1-5 words, descriptions carry the trade-offs. Labels unique within a question, question texts unique across the call.
|
|
57
59
|
- 1-4 questions at a time, related ones grouped. multi_select allows several answers.
|
|
58
60
|
- Put a recommended option first and append "(Recommended)" to its label.
|
|
@@ -105,8 +107,15 @@ export const spec = {
|
|
|
105
107
|
type: 'boolean',
|
|
106
108
|
description: 'Whether the user can select multiple options.',
|
|
107
109
|
},
|
|
110
|
+
free_text: {
|
|
111
|
+
type: 'boolean',
|
|
112
|
+
description:
|
|
113
|
+
'Ask for a WRITTEN answer with no options: the field is focused immediately and the reply is'
|
|
114
|
+
+ ' the text typed. Use it when the answer cannot be enumerated — a name, a path, a'
|
|
115
|
+
+ ' description. Omit options when this is true.',
|
|
116
|
+
},
|
|
108
117
|
},
|
|
109
|
-
required: ['question'
|
|
118
|
+
required: ['question'],
|
|
110
119
|
},
|
|
111
120
|
},
|
|
112
121
|
},
|
|
@@ -139,7 +148,10 @@ export const spec = {
|
|
|
139
148
|
const text = String((q && q.question) || '').trim();
|
|
140
149
|
if (!text) return 'Error: every question needs a non-empty `question` string.';
|
|
141
150
|
const opts = Array.isArray(q && q.options) ? q.options : [];
|
|
142
|
-
|
|
151
|
+
const freeText = !!(q && q.free_text);
|
|
152
|
+
// A FREE-TEXT QUESTION HAS NO OPTIONS. The 2-4 rule exists so a choice is a real choice; applying it here
|
|
153
|
+
// would force a question like "what should the release be called?" to invent two decoys first.
|
|
154
|
+
if (!freeText && (opts.length < MIN_OPTIONS || opts.length > MAX_OPTIONS)) {
|
|
143
155
|
return `Error: question ${JSON.stringify(text)} needs ${MIN_OPTIONS}-${MAX_OPTIONS} options (got ${opts.length}).`;
|
|
144
156
|
}
|
|
145
157
|
const options = [];
|
|
@@ -153,9 +165,12 @@ export const spec = {
|
|
|
153
165
|
header: String((q && q.header) || '').trim().slice(0, 12),
|
|
154
166
|
options,
|
|
155
167
|
multiSelect: !!(q && q.multi_select),
|
|
168
|
+
// CARRIED THROUGH AS `freeText`, which is the name the dialog reads. Without this the flag stopped at
|
|
169
|
+
// the tool: the question arrived with no options AND no marker, so the dialog drew the option list —
|
|
170
|
+
// empty apart from its "Other" row — and the field never opened.
|
|
171
|
+
freeText,
|
|
156
172
|
});
|
|
157
173
|
}
|
|
158
|
-
|
|
159
174
|
const dup = questionUniquenessError(questions);
|
|
160
175
|
if (dup) return dup;
|
|
161
176
|
|
package/src/tools/edit.js
CHANGED
|
@@ -28,17 +28,36 @@ import { hashRegion as hashLines } from './read.js';
|
|
|
28
28
|
|
|
29
29
|
export const spec = {
|
|
30
30
|
name: 'Edit',
|
|
31
|
-
description:
|
|
31
|
+
description: `Edit a file. Three shapes: LINE-RANGE (path + start_line + end_line + new_string),
|
|
32
|
+
SUBSTRING (path + old_string + new_string, unique unless replace_all), RANGED SUBSTRING (the same plus
|
|
33
|
+
start_line + end_line, which narrows where old_string is searched).
|
|
34
|
+
|
|
35
|
+
BOTH BOUNDS ARE INCLUSIVE: start_line=3, end_line=5 covers lines 3, 4 and 5 — three lines, so new_string must
|
|
36
|
+
have exactly three lines.
|
|
37
|
+
|
|
38
|
+
TO DELETE, SEND AN EMPTY new_string. start_line=7, end_line=7 with new_string="" removes line 7 and nothing
|
|
39
|
+
else. That is the ONLY correct way to delete. Restating the next line as the replacement COPIES it, the file
|
|
40
|
+
then holds that line twice, and because the duplicate looks like damage the retry restates another neighbour —
|
|
41
|
+
the duplicates multiply. Never copy a neighbouring line into new_string to stand in for deleting.
|
|
42
|
+
|
|
43
|
+
new_string is written VERBATIM — no indentation is added, removed or re-derived. Every line carries its own
|
|
44
|
+
leading whitespace, the FIRST one included; copy it from the Read output after the tab that follows each line
|
|
45
|
+
number (Read prints '12' then a tab, then the indented code, so you send the leading spaces yourself).
|
|
46
|
+
|
|
47
|
+
COUNT FIRST: in line-range mode new_string must have exactly end_line - start_line + 1 lines, and every line
|
|
48
|
+
you write must be one you are replacing. A line outside the range is not deleted and survives as a duplicate.
|
|
49
|
+
|
|
50
|
+
Omitting new_string entirely is an error — pass "" to delete.`,
|
|
32
51
|
parameters: {
|
|
33
52
|
type: 'object',
|
|
34
53
|
properties: {
|
|
35
54
|
path: { type: 'string', description: 'Path to the file to edit.' },
|
|
36
55
|
old_string: { type: 'string', description: 'Exact text to replace, including whitespace and newlines. Omit for pure line-range mode. COMBINE it with start_line/end_line to replace only the occurrence inside that range.' },
|
|
37
|
-
new_string: { type: 'string', description: 'Replacement text, written VERBATIM in BOTH modes — no indentation is added, removed, or re-derived. Write the leading spaces on EVERY line yourself, including the FIRST one, copying them from the Read output after its `N\\t` line-number prefix (Read prints `12\\t return x`, so you send ` return x`). In LINE-RANGE mode it must contain EXACTLY the lines being replaced: a line that sits outside [start_line, end_line] is not deleted, so restating it here leaves a duplicate. In SUBSTRING mode it replaces old_string, which is itself matched exactly including its own indentation.' },
|
|
56
|
+
new_string: { type: 'string', description: 'Replacement text, written VERBATIM in BOTH modes — no indentation is added, removed, or re-derived. Write the leading spaces on EVERY line yourself, including the FIRST one, copying them from the Read output after its `N\\t` line-number prefix (Read prints `12\\t return x`, so you send ` return x`). In LINE-RANGE mode it must contain EXACTLY the lines being replaced: a line that sits outside [start_line, end_line] is not deleted, so restating it here leaves a duplicate. Count them first: new_string must have exactly end_line - start_line + 1 lines. In SUBSTRING mode it replaces old_string, which is itself matched exactly including its own indentation.' },
|
|
38
57
|
|
|
39
58
|
replace_all: { type: 'boolean', default: false, description: 'Replace all occurrences of old_string.' },
|
|
40
59
|
start_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based first line to replace, INCLUSIVE — the range covers start_line and end_line and every line between them. Requires end_line and new_string. With old_string too, it is the first line to SEARCH for it in.' },
|
|
41
|
-
end_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based last line to replace, INCLUSIVE (requires start_line). start_line=3, end_line=5 replaces lines 3, 4 AND 5 — three lines, so new_string must have three lines. With old_string too, it is the last line to search in.' },
|
|
60
|
+
end_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based last line to replace, INCLUSIVE (requires start_line). start_line=3, end_line=5 replaces lines 3, 4 AND 5 — three lines, so new_string must have three lines. If your new_string has four, one of them is outside the range and will survive as a duplicate. With old_string too, it is the last line to search in.' },
|
|
42
61
|
},
|
|
43
62
|
// Three accepted shapes:
|
|
44
63
|
// line-range start_line + end_line + new_string
|
|
@@ -181,14 +200,21 @@ export const spec = {
|
|
|
181
200
|
const stale = checkStaleRange(p, lines, start, end, ctx);
|
|
182
201
|
if (stale) return stale;
|
|
183
202
|
|
|
184
|
-
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
203
|
+
// AN EMPTY new_string DELETES THE RANGE. No line survives, so the replacement is nothing at all.
|
|
204
|
+
//
|
|
205
|
+
// `''.split('\n')` is `['']` — ONE EMPTY STRING — not an empty array. The splice therefore replaced the
|
|
206
|
+
// range with a single blank line, so "delete these lines" left a hole and shifted everything below it.
|
|
207
|
+
// Deleting has to mean deleting: when there is no replacement text, the range is removed outright.
|
|
208
|
+
//
|
|
209
|
+
// This is also the answer to the mistake that keeps appearing in edits: to remove one line, send an EMPTY
|
|
210
|
+
// new_string over that one line. Restating the neighbour instead copies it, and the file ends up with the
|
|
211
|
+
// line twice — the duplicate is a symptom of having no delete, not of carelessness.
|
|
212
|
+
const inserted = new_string === '' ? [] : normalizeText(new_string).split('\n');
|
|
213
|
+
|
|
214
|
+
// The lines being REPLACED, captured before the splice. The TUI renders an Edit's diff from the tool
|
|
215
|
+
// ARGUMENTS, and line-range mode has no `old_string` to diff against — so without reporting them here the
|
|
216
|
+
// edit drew NO diff at all.
|
|
190
217
|
const replacedText = lines.slice(start - 1, end).join('\n');
|
|
191
|
-
|
|
192
218
|
const out = [...lines.slice(0, start - 1), ...inserted, ...lines.slice(end)];
|
|
193
219
|
const normUpdated = out.join('\n');
|
|
194
220
|
const updated = eol === '\r\n' ? normUpdated.split('\n').join('\r\n') : normUpdated;
|
|
@@ -199,24 +225,12 @@ export const spec = {
|
|
|
199
225
|
ensureDir(p);
|
|
200
226
|
fs.writeFileSync(p, updated, 'utf8');
|
|
201
227
|
} catch (e) { return `Error writing ${args.path}: ${e.message}`; }
|
|
202
|
-
|
|
228
|
+
// NO READ-SNAPSHOT REFRESH. The write changed the file, so every region the model was holding is now a
|
|
229
|
+
// record of text that no longer exists — including the span just written, whose current content the
|
|
230
|
+
// model has never seen. Regenerating trust from the arguments would let it edit again on top of a change
|
|
231
|
+
// it only THINKS it knows. It reads the file instead.
|
|
203
232
|
const removed = end - start + 1;
|
|
204
|
-
// Publish the diff for the UI. The Edit line is drawn from these, so a
|
|
205
|
-
// line-range edit shows the same +/- rows and +N/-N counts as a substring
|
|
206
|
-
// edit instead of nothing. `ctx.lastEditDiff` is consumed (and cleared) by
|
|
207
|
-
// the agent loop right after this tool returns.
|
|
208
|
-
//
|
|
209
|
-
// `lines` is the pre-edit content, which is what `start` indexes into.
|
|
210
|
-
// The diff's NEW text is `inserted` — what the file now holds — not the
|
|
211
|
-
// raw `new_string`: when the seam-drop removed a line, the raw text showed a
|
|
212
|
-
// `+` row that is not in the file and shifted every after-context line
|
|
213
|
-
// number by the dropped count.
|
|
214
233
|
if (ctx) ctx.lastEditDiff = buildEditDiff({ fileLines: lines, oldText: replacedText, newText: inserted.join('\n'), startLine: start });
|
|
215
|
-
// Say what the seam did, whichever way it went: a silent drop and a silent keep are both
|
|
216
|
-
// indistinguishable from an edit that never had the ambiguity. The dropped line is named
|
|
217
|
-
// with its ACTUAL pair (the two result lines that would have been identical) — it is not
|
|
218
|
-
// always "the last line of new_string against end_line + 1": a block can carry the repeat
|
|
219
|
-
// inside itself, with blank lines between the copies.
|
|
220
234
|
return `Edited ${args.path}: replaced lines ${start}-${end} (${removed} line${removed === 1 ? '' : 's'}) with ${inserted.length} line${inserted.length === 1 ? '' : 's'}.`;
|
|
221
235
|
}
|
|
222
236
|
|
|
@@ -350,39 +364,9 @@ export const spec = {
|
|
|
350
364
|
ensureDir(p);
|
|
351
365
|
fs.writeFileSync(p, updated, 'utf8');
|
|
352
366
|
} catch (e) { return `Error writing ${args.path}: ${e.message}`; }
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
// also drop any previously-recorded region whose content no longer matches
|
|
357
|
-
// (those are genuinely stale now). Nothing here is told to the model.
|
|
358
|
-
if (ctx && ctx.readPool) {
|
|
359
|
-
const entry = ctx.readPool.get(p);
|
|
360
|
-
if (entry) {
|
|
361
|
-
const newLines = normUpdated.split('\n');
|
|
362
|
-
entry.fileHash = hashStr(normUpdated);
|
|
363
|
-
entry.lines = newLines.length;
|
|
364
|
-
// Locate the edit site in the NEW content: same character offset as the
|
|
365
|
-
// match in the old one, because the text before the match is untouched.
|
|
366
|
-
// An anchor taken from the NEW text's first line found the first copy of
|
|
367
|
-
// that line ANYWHERE in the file (and `indexOf('')` returned 0 for a
|
|
368
|
-
// deletion), recording the re-armed region at a line the model never edited.
|
|
369
|
-
if (matchAt >= 0) {
|
|
370
|
-
const at = normUpdated.slice(0, matchAt).split('\n').length - 1; // 0-based
|
|
371
|
-
const newSpan = Math.max(1, String(matchNew).split('\n').length);
|
|
372
|
-
const start = at + 1;
|
|
373
|
-
const end = Math.min(newLines.length, at + newSpan);
|
|
374
|
-
// Re-hash every surviving old region against the new content, and add
|
|
375
|
-
// the freshly-edited span. Regions that no longer match are dropped.
|
|
376
|
-
const kept = entry.regions.filter((r) => hashLines(newLines, r.start, r.end) === r.hash);
|
|
377
|
-
kept.push({ start, end, hash: hashLines(newLines, start, end) });
|
|
378
|
-
entry.regions = kept;
|
|
379
|
-
} else {
|
|
380
|
-
// Could not locate the site: drop stale regions so nothing false stays.
|
|
381
|
-
entry.regions = entry.regions.filter((r) => hashLines(newLines, r.start, r.end) === r.hash);
|
|
382
|
-
}
|
|
383
|
-
try { const st = fs.statSync(p); entry.size = st.size; entry.mtimeMs = st.mtimeMs; } catch {}
|
|
384
|
-
}
|
|
385
|
-
}
|
|
367
|
+
// NO READ-SNAPSHOT REFRESH — see the note on the line-range path above. Whatever the model was holding is
|
|
368
|
+
// now a record of a file that no longer exists, so the next edit of these lines has to be preceded by a
|
|
369
|
+
// Read of them.
|
|
386
370
|
const occurrences = replace_all ? count : 1;
|
|
387
371
|
// Publish the diff for the UI (same contract as the line-range path above).
|
|
388
372
|
// `startLine` comes from the actual match offset, so a snippet that starts
|
|
@@ -622,18 +606,4 @@ function checkStaleRange(p, curLines, start, end, ctx) {
|
|
|
622
606
|
return `Edit rejected: ${p} changed since it was Read (lines ${start}-${end} are not in an intact Read snapshot). Re-run ${args_readHint(p, start, end - start + 1)} to get the current content, then Edit.`;
|
|
623
607
|
}
|
|
624
608
|
|
|
625
|
-
|
|
626
|
-
// match the NEW content, and record the freshly-written span. Mirrors the
|
|
627
|
-
// re-arm block used by the substring path.
|
|
628
|
-
function refreshReadPool(p, normUpdated, written, ctx) {
|
|
629
|
-
if (!ctx || !ctx.readPool) return;
|
|
630
|
-
const entry = ctx.readPool.get(p);
|
|
631
|
-
if (!entry) return;
|
|
632
|
-
const newLines = normUpdated.split('\n');
|
|
633
|
-
entry.fileHash = hashStr(normUpdated);
|
|
634
|
-
entry.lines = newLines.length;
|
|
635
|
-
const kept = entry.regions.filter((r) => hashLines(newLines, r.start, r.end) === r.hash);
|
|
636
|
-
kept.push({ start: written.start, end: Math.min(newLines.length, Math.max(written.start, written.end)), hash: hashLines(newLines, written.start, Math.min(newLines.length, Math.max(written.start, written.end))) });
|
|
637
|
-
entry.regions = kept;
|
|
638
|
-
try { const st = fs.statSync(p); entry.size = st.size; entry.mtimeMs = st.mtimeMs; } catch {}
|
|
639
|
-
}
|
|
609
|
+
|