@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hncode/hncode",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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
- // Fenced block form.
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
- * A cache key that identifies a rendered message.
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
- parts.text == null ? '' : parts.text,
82
+ text.length,
83
+ hashText(text),
55
84
  parts.w,
56
85
  parts.fg,
57
86
  parts.codeFg,
@@ -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 ROSTER ROLE HELD BY A REAL AGENT. `addMember` gives the `lead` role to the
242
- * FIRST member added, and that member has a full agent identity like anyone else — `agentId`,
243
- * `agentType`, `cwd`, a phase, its own inbox. The user is never on the roster, so a message
244
- * addressed to the lead is addressed to an agent, and sending the lead an instruction is the
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', 'options'],
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
- if (opts.length < MIN_OPTIONS || opts.length > MAX_OPTIONS) {
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: 'Edit a file. Three shapes: LINE-RANGE (path + start_line + end_line + new_string), SUBSTRING (path + old_string + new_string, unique unless replace_all), RANGED SUBSTRING (the same plus start_line + end_line, which narrows where old_string is searched). \nBOTH BOUNDS ARE INCLUSIVE: start_line=3, end_line=5 replaces lines 3, 4 and 5 — three lines, so new_string must have exactly three lines. A fourth line is a duplicate, not an addition. \nnew_string is VERBATIM: no re-indent, no trim. Every line carries its own leading whitespace, the first one included — copy it from the Read output after the tab that follows each line number. old_string is matched exactly, including its own first-line indentation. \nOmitting new_string is an error. After a multi-line edit, Read the region again: mis-indented code still parses, so a green test is not proof the edit landed where you meant.',
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
- const inserted = normalizeText(new_string).split('\n');
185
- // The lines being REPLACED, captured before the splice. The TUI renders an
186
- // Edit's diff from the tool ARGUMENTS, and line-range mode has no
187
- // `old_string` to diff against — so without reporting them here the edit
188
- // drew NO diff at all (no +/- rows, no +N/-N counts). Reporting them lets
189
- // the caller diff the replaced region -> `new_string`.
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
- refreshReadPool(p, normUpdated, { start, end: start + inserted.length - 1 }, ctx);
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
- // Silently re-arm the Read snapshot for the edited region so the AI can keep
354
- // editing on top of its own change WITHOUT another Read: we recompute the
355
- // affected line range + hash from the NEW content and refresh the pool. We
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
- // Refresh the region cache after a successful edit: drop regions that no longer
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
+