@vincemakes/kiso-code 0.31.0 → 0.32.0

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/trust-ui.js CHANGED
@@ -162,17 +162,38 @@ export function askUi(input) {
162
162
  export async function addDontAskAgainRule(rule) {
163
163
  const dir = extensionsDir();
164
164
  const file = join(dir, "dont-ask-again.mjs");
165
- const existed = existsSync(file);
165
+ // §2.5: the LIVE extension, found by name in the array the chain reads.
166
+ // This is the only handle on the rules that is guaranteed to be the one
167
+ // in force, and finding it also tells us whether this is a first rule.
168
+ const at = currentAgentExtensions.findIndex((e) => e.name === "dont-ask-again");
169
+ const live = at >= 0 ? currentAgentExtensions[at] : undefined;
170
+ // Every rule the session already grants, plus this one (RL-F2: the file
171
+ // used to be rewritten with ONLY the newest rule, so a second grant
172
+ // erased the first from disk — invisible in-session, because the
173
+ // in-memory Set kept both, and only discovered at the next start).
174
+ //
175
+ // RL-F5: a file generated BEFORE this change exports RULES and puts
176
+ // nothing on the extension, so `live.rules` is undefined for it and a
177
+ // union starting from nothing would rewrite the file with this grant
178
+ // alone — every rule the human gave under 0.31.x gone, in exactly the
179
+ // way RL-F2 was invisible. The rules come out of the file instead, and
180
+ // the migration costs one grant.
181
+ const existing = live?.rules !== undefined ? [...live.rules] : rulesInFile(file);
182
+ const rules = [...new Set([...existing, rule])].sort();
166
183
  const module = [
167
184
  "// generated by kiso — the \"2 Yes, don't ask again for <tool>\" rule.",
168
185
  "// HUMAN-EDITABLE and HUMAN-DELETABLE — this file IS the revocation path:",
169
186
  "// delete a rule from the set (or the whole file) to be asked again.",
170
187
  "// ALLOW-ONLY by design (R3): never emits deny or ask — the mode moat",
171
188
  "// and the safe-defaults moat keep their teeth.",
172
- `export const RULES = new Set(${JSON.stringify([rule])});`,
189
+ `const RULES = new Set(${JSON.stringify(rules)});`,
173
190
  "",
174
191
  "export default {",
175
192
  ' name: "dont-ask-again",',
193
+ " // the live handle: kiso mutates THIS set to make a new rule take",
194
+ " // effect without a reload. It is the same object the decide below",
195
+ " // closes over, which is the whole point.",
196
+ " rules: RULES,",
176
197
  " approvals: [",
177
198
  " {",
178
199
  " decide(call) {",
@@ -185,21 +206,57 @@ export async function addDontAskAgainRule(rule) {
185
206
  ].join("\n");
186
207
  mkdirSync(dir, { recursive: true });
187
208
  writeFileSync(file, module, "utf8");
188
- // The live in-session effect: a file that existed at startup was
189
- // imported by the loader — ONE namespace — so mutating the exported
190
- // RULES Set is live NOW (the current run's chain reads the same Set).
191
- const mod = (await import(pathToFileURL(file).href));
192
- if (mod.RULES !== undefined && !mod.RULES.has(rule))
193
- mod.RULES.add(rule);
194
- // A FIRST-time rule (a file that did not exist at startup) is not yet
195
- // in the current run's chain — the chain is fixed at a run's start —
196
- // so the generated extension joins the NEXT run's policies (run.ts
197
- // re-reads the session config's extensions array, shared by
198
- // reference). The honest story: a new rule applies from the next run.
199
- if (!existed && mod.default !== undefined) {
209
+ // The live in-session effect. This used to re-import the plain file URL
210
+ // and mutate the module's exported Set, on the stated assumption that a
211
+ // file the loader imported is ONE namespace. §2.5's loader appends a
212
+ // per-load nonce so `/reload` can pick up edited code, and that
213
+ // assumption died with it: the loader holds `file?load=N` and a plain
214
+ // import is the startup namespace. A rule granted after any reload
215
+ // would mutate a Set nothing reads — the human clicks don't-ask-again
216
+ // and is asked again on the very next identical call.
217
+ //
218
+ // So there is no second import. The rule is added THROUGH the live
219
+ // extension, which is one source of truth by construction.
220
+ if (live?.rules !== undefined) {
221
+ live.rules.add(rule);
222
+ return;
223
+ }
224
+ // Either a FIRST rule (no such extension loaded) or the RL-F5 migration
225
+ // (a legacy extension is loaded but has no live handle). The freshly
226
+ // written file is imported and joins the chain — REPLACING the legacy
227
+ // entry rather than sitting beside it, because two extensions of the
228
+ // same name in the chain is a second source of truth and the whole point
229
+ // of the handle is that there is one. The chain is fixed at a run's
230
+ // start, so a first rule applies from the NEXT run (run.ts re-reads the
231
+ // session config's extensions array, shared by reference).
232
+ //
233
+ // The nonce is the loader's own lesson applied here: a plain file URL is
234
+ // cached forever, so a second grant in one process would import the
235
+ // bytes of the first.
236
+ const mod = (await import(`${pathToFileURL(file).href}?grant=${(grantSerial += 1)}`));
237
+ if (mod.default === undefined)
238
+ return;
239
+ if (at >= 0)
240
+ currentAgentExtensions[at] = mod.default;
241
+ else
200
242
  currentAgentExtensions.push(mod.default);
243
+ }
244
+ /** RL-F5 — the rules a generated file already holds. The `new Set([...])`
245
+ * literal is JSON, which is what makes reading it back honest rather than
246
+ * a parser. An unreadable or hand-edited-past-recognition file yields
247
+ * nothing, and the new rule stands alone — the file is documented as
248
+ * human-editable, so kiso must not fail on what a human wrote there. */
249
+ function rulesInFile(file) {
250
+ try {
251
+ const m = /new Set\((\[[^\]]*\])\)/.exec(readFileSync(file, "utf8"));
252
+ return m === null ? [] : JSON.parse(m[1]);
253
+ }
254
+ catch {
255
+ return [];
201
256
  }
202
257
  }
258
+ /** RL-F5: distinguishes each grant's import of the generated file. */
259
+ let grantSerial = 0;
203
260
  /**
204
261
  * W21 — the §3.5 fix-hint table: per speaker, the ONE-line path to stop
205
262
  * being asked. The hint names the FIX, never a bypass ("switch to
@@ -309,8 +366,24 @@ function readMcpConfig(path) {
309
366
  }
310
367
  /** Merge user-level + project-level mcp.json into one temp file and point
311
368
  * KISO_MCP_CONFIG at it — the mcp extension reads it at load time. */
369
+ /**
370
+ * RL-F4 — the user mcp.json as it was BEFORE any merge, and RL-F1's exact
371
+ * sibling one function up. `applyMcpMerge` read `KISO_MCP_CONFIG` and then
372
+ * assigned the merged file to it, so a second merge — which is what a
373
+ * reload is — took the first merged file as the user config. That file
374
+ * already holds the project's servers, so the "exists in both" check
375
+ * throws for every one of them and EVERY reload fails in exactly the
376
+ * projects that carry MCP servers.
377
+ *
378
+ * Found by the lead reviewing the commit that fixed RL-F1 in the function
379
+ * below. The lesson is the sibling, not the loop: a defect found in one
380
+ * function is a question to ask of every function beside it, and I fixed
381
+ * one merge without looking at the other.
382
+ */
383
+ let originalUserMcpPath;
312
384
  function applyMcpMerge(root) {
313
- const userPath = process.env.KISO_MCP_CONFIG ?? join(kisoHome(), "mcp.json");
385
+ originalUserMcpPath ??= process.env.KISO_MCP_CONFIG ?? join(kisoHome(), "mcp.json");
386
+ const userPath = originalUserMcpPath;
314
387
  const user = readMcpConfig(userPath);
315
388
  const project = readMcpConfig(join(root, "mcp.json"));
316
389
  const userServers = user.mcpServers ?? {};
@@ -323,17 +396,37 @@ function applyMcpMerge(root) {
323
396
  }
324
397
  }
325
398
  const merged = { mcpServers: { ...userServers, ...projectServers } };
326
- const temp = join(tmpdir(), `kiso-mcp-merged-${process.pid}.json`);
399
+ // RL-F4, second half: the path used to be `kiso-mcp-merged-<pid>.json`,
400
+ // the SAME file on every merge in one process — so a reload's cleanup of
401
+ // the paths the previous set owned deleted the file the new set had just
402
+ // been merged into. One directory per merge, and the directory is what is
403
+ // recorded, so the cleanup removes exactly what that merge created.
404
+ const dir = mkdtempSync(join(tmpdir(), "kiso-mcp-"));
405
+ const temp = join(dir, "mcp.json");
327
406
  writeFileSync(temp, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
328
407
  process.env.KISO_MCP_CONFIG = temp;
329
- mergedTempPaths.push(temp);
408
+ mergedTempPaths.push(dir);
330
409
  }
331
410
  /** Merge user-level + project-level skills into one temp scan dir (project
332
411
  * skill dirs symlinked first; a name in both → project wins + a stderr
333
412
  * note) and point KISO_SKILLS_DIR at it — the skills extension's existing
334
413
  * scan reads it at load time and per read_skill call. */
414
+ /**
415
+ * §2.5 — the user skills directory as it was BEFORE any merge.
416
+ *
417
+ * `applySkillsMerge` ends by pointing `KISO_SKILLS_DIR` at the merged
418
+ * directory it just made. Read twice — which is exactly what a reload
419
+ * does — that is a loop: the second merge takes the FIRST merge as its
420
+ * user directory. A skill deleted from the real user directory would
421
+ * survive forever, and each merge would produce a temp directory the
422
+ * previous one's symlinks depend on, so none of them could ever be
423
+ * removed. This tree has already lost an afternoon to 449,956 stale
424
+ * temp directories.
425
+ */
426
+ let originalUserSkillsDir;
335
427
  function applySkillsMerge(root) {
336
- const userDir = process.env.KISO_SKILLS_DIR ?? join(kisoHome(), "skills");
428
+ originalUserSkillsDir ??= process.env.KISO_SKILLS_DIR ?? join(kisoHome(), "skills");
429
+ const userDir = originalUserSkillsDir;
337
430
  const projectDir = join(root, "skills");
338
431
  const merged = mkdtempSync(join(tmpdir(), "kiso-skills-"));
339
432
  mergedTempPaths.push(merged);
@@ -32,8 +32,6 @@ export interface UpdateCache {
32
32
  readonly checkedAt: number;
33
33
  /** the latest version the registry reported, if the check succeeded */
34
34
  readonly latest?: string;
35
- /** the version already mentioned to this human — never mentioned twice */
36
- readonly told?: string;
37
35
  }
38
36
  /**
39
37
  * Semver-ish "is b newer than a", for the shapes this line ever sees:
@@ -44,7 +42,15 @@ export interface UpdateCache {
44
42
  export declare function isNewer(current: string, latest: string): boolean;
45
43
  /** The one line, when there is one. Words only — a human can read it,
46
44
  * paste it, and nothing about it needs colour to be understood (§1.2). */
47
- export declare const updateLine: (latest: string) => string;
45
+ export declare const CHANGELOG_URL = "https://github.com/vincemakes/kiso/releases";
46
+ /** OR-9 (owner, 2026-09-09): the card under the banner, as WORDS — a
47
+ * title, the version with the command that installs it, the changelog.
48
+ * The CLI styles them at composition; nothing here needs colour. */
49
+ export declare function updateCardLines(latest: string): readonly string[];
50
+ /** OR-9: what the cache already knows, with NO request — the boot paints
51
+ * it under the banner before any check runs. Same silences as the check
52
+ * (opt-out, faux without a seam, a pipe); a merely-recent cache speaks. */
53
+ export declare function knownUpdate(deps: UpdateCheckDeps): string | null;
48
54
  export interface UpdateCheckDeps {
49
55
  readonly kisoHome: string;
50
56
  readonly version: string;
@@ -51,7 +51,6 @@ function readCache(kisoHome) {
51
51
  return {
52
52
  checkedAt: c.checkedAt,
53
53
  ...(typeof c.latest === "string" ? { latest: c.latest } : {}),
54
- ...(typeof c.told === "string" ? { told: c.told } : {}),
55
54
  };
56
55
  }
57
56
  catch {
@@ -97,7 +96,23 @@ export function isNewer(current, latest) {
97
96
  }
98
97
  /** The one line, when there is one. Words only — a human can read it,
99
98
  * paste it, and nothing about it needs colour to be understood (§1.2). */
100
- export const updateLine = (latest) => `${latest} is out · npm i -g @vincemakes/kiso-code@latest`;
99
+ export const CHANGELOG_URL = "https://github.com/vincemakes/kiso/releases";
100
+ /** OR-9 (owner, 2026-09-09): the card under the banner, as WORDS — a
101
+ * title, the version with the command that installs it, the changelog.
102
+ * The CLI styles them at composition; nothing here needs colour. */
103
+ export function updateCardLines(latest) {
104
+ return ["Update available", `New version ${latest} is available. Run kiso update`, `Changelog: ${CHANGELOG_URL}`];
105
+ }
106
+ /** OR-9: what the cache already knows, with NO request — the boot paints
107
+ * it under the banner before any check runs. Same silences as the check
108
+ * (opt-out, faux without a seam, a pipe); a merely-recent cache speaks. */
109
+ export function knownUpdate(deps) {
110
+ const why = reasonNotToCheck(deps);
111
+ if (why !== null && why !== "recent")
112
+ return null;
113
+ const cached = readCache(deps.kisoHome);
114
+ return cached?.latest !== undefined && isNewer(deps.version, cached.latest) ? cached.latest : null;
115
+ }
101
116
  /**
102
117
  * Should this launch even ask? Pure, so the reasons are testable without
103
118
  * a network, a clock or a terminal.
@@ -142,21 +157,14 @@ function reasonNotToCheck({ kisoHome, isTTY, faux, now = Date.now() }) {
142
157
  export async function checkForUpdate(deps) {
143
158
  const { kisoHome, version, now = Date.now() } = deps;
144
159
  const why = reasonNotToCheck(deps);
145
- if (why !== null) {
146
- // ONLY a recent check may still speak. The other three reasons are
147
- // about the LINE, not about the request, so the cache is no way
148
- // around them — an opt-out that still got announced, or a piped
149
- // run that gained a row of kiso's prose, would be the feature
150
- // ignoring the answer it was given.
151
- if (why !== "recent")
152
- return null;
153
- const cached = readCache(kisoHome);
154
- if (cached?.latest !== undefined && cached.told !== cached.latest && isNewer(version, cached.latest)) {
155
- writeCache(kisoHome, { ...cached, told: cached.latest });
156
- return updateLine(cached.latest);
157
- }
158
- return null;
159
- }
160
+ // ONLY a recent check may still speak from cache. The other three
161
+ // reasons are about the CARD, not the request, so the cache is no way
162
+ // around them. OR-9: no `told` gate — a newer version is known at
163
+ // EVERY start (the card is shown every start; the once-only rule of
164
+ // §7.10 is superseded by the owner's 2026-09-09 ruling), only the
165
+ // REQUEST stays at most once a day.
166
+ if (why !== null)
167
+ return why === "recent" ? knownUpdate(deps) : null;
160
168
  let latest;
161
169
  try {
162
170
  const ctl = new AbortController();
@@ -177,15 +185,6 @@ export async function checkForUpdate(deps) {
177
185
  catch {
178
186
  // every failure is silence — see the header
179
187
  }
180
- const prev = readCache(kisoHome);
181
- const show = latest !== undefined && isNewer(version, latest) && prev?.told !== latest;
182
- // `told` survives a check that learned nothing: forgetting it would
183
- // re-announce a version this human has already been told about.
184
- const told = show ? latest : prev?.told;
185
- writeCache(kisoHome, {
186
- checkedAt: now,
187
- ...(latest === undefined ? {} : { latest }),
188
- ...(told === undefined ? {} : { told }),
189
- });
190
- return show && latest !== undefined ? updateLine(latest) : null;
188
+ writeCache(kisoHome, { checkedAt: now, ...(latest === undefined ? {} : { latest }) });
189
+ return latest !== undefined && isNewer(version, latest) ? latest : null;
191
190
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-code",
3
- "version": "0.31.0",
4
- "description": "kiso CLI — the durable coding agent that survives kill -9: kiso chat / kiso resume / kiso sessions.",
3
+ "version": "0.32.0",
4
+ "description": "kiso-code — the coding agent on kiso, the durable runtime for AI agents: kiso chat / kiso resume / kiso sessions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -18,20 +18,20 @@
18
18
  "test": "vitest run"
19
19
  },
20
20
  "dependencies": {
21
- "@vincemakes/kiso-ask-ext": "0.31.0",
22
- "@vincemakes/kiso-core": "0.31.0",
23
- "@vincemakes/kiso-evals": "0.31.0",
24
- "@vincemakes/kiso-mcp-ext": "0.31.0",
25
- "@vincemakes/kiso-provider-anthropic": "0.31.0",
26
- "@vincemakes/kiso-provider-openai": "0.31.0",
27
- "@vincemakes/kiso-provider-openai-responses": "0.31.0",
28
- "@vincemakes/kiso-runtime": "0.31.0",
29
- "@vincemakes/kiso-skills-ext": "0.31.0",
30
- "@vincemakes/kiso-subagent-ext": "0.31.0",
31
- "@vincemakes/kiso-task-ext": "0.31.0",
32
- "@vincemakes/kiso-tools-node": "0.31.0",
33
- "@vincemakes/kiso-tui": "0.31.0",
34
- "@vincemakes/kiso-tui-cells": "0.31.0"
21
+ "@vincemakes/kiso-ask-ext": "0.32.0",
22
+ "@vincemakes/kiso-core": "0.32.0",
23
+ "@vincemakes/kiso-evals": "0.32.0",
24
+ "@vincemakes/kiso-mcp-ext": "0.32.0",
25
+ "@vincemakes/kiso-provider-anthropic": "0.32.0",
26
+ "@vincemakes/kiso-provider-openai": "0.32.0",
27
+ "@vincemakes/kiso-provider-openai-responses": "0.32.0",
28
+ "@vincemakes/kiso-runtime": "0.32.0",
29
+ "@vincemakes/kiso-skills-ext": "0.32.0",
30
+ "@vincemakes/kiso-subagent-ext": "0.32.0",
31
+ "@vincemakes/kiso-task-ext": "0.32.0",
32
+ "@vincemakes/kiso-tools-node": "0.32.0",
33
+ "@vincemakes/kiso-tui": "0.32.0",
34
+ "@vincemakes/kiso-tui-cells": "0.32.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^26.1.2",
package/dist/diff.d.ts DELETED
@@ -1,32 +0,0 @@
1
- /**
2
- * v2e — the diff renderer: edit/write changes as inline ± lines, zero
3
- * dependencies, no syntax highlighting (the spec's scope line). Shown at
4
- * the approval moment ONLY — the frozen summary stays one line (v2d's
5
- * anti-leak principle), /last has the full data.
6
- *
7
- * edit_file diffs IN PLACE (the search→replace windows are known — no
8
- * general engine needed); write_file does a row-level LCS over the old
9
- * file (small files are the target). Context: 2 rows each side. The
10
- * RENDERER truncates (18 head + 18 tail + "… N lines"); the stats come
11
- * from the full diff.
12
- */
13
- /** The diff block's per-row kind. */
14
- export type DiffLine = {
15
- kind: "-" | "+" | " ";
16
- text: string;
17
- };
18
- export interface DiffResult {
19
- /** The FULL diff (with context, not truncated) — the display truncates. */
20
- lines: DiffLine[];
21
- added: number;
22
- removed: number;
23
- }
24
- /** The RENDERER's truncation: head + "… N lines (/last for full)" + tail. */
25
- export declare function truncateDiff(diff: DiffLine[]): DiffLine[];
26
- /** edit_file: the search→replace windows replace in place — the changed
27
- * region is KNOWN, so the diff is the old window vs the new window,
28
- * context from the surrounding file. */
29
- export declare function editFileDiff(oldContent: string, search: string, replace: string): DiffResult;
30
- /** write_file: a new file is all +; an existing file diffs row-level
31
- * against its old content. */
32
- export declare function writeFileDiff(oldContent: string | null, newContent: string): DiffResult;
package/dist/diff.js DELETED
@@ -1,122 +0,0 @@
1
- /**
2
- * v2e — the diff renderer: edit/write changes as inline ± lines, zero
3
- * dependencies, no syntax highlighting (the spec's scope line). Shown at
4
- * the approval moment ONLY — the frozen summary stays one line (v2d's
5
- * anti-leak principle), /last has the full data.
6
- *
7
- * edit_file diffs IN PLACE (the search→replace windows are known — no
8
- * general engine needed); write_file does a row-level LCS over the old
9
- * file (small files are the target). Context: 2 rows each side. The
10
- * RENDERER truncates (18 head + 18 tail + "… N lines"); the stats come
11
- * from the full diff.
12
- */
13
- /** A line-level LCS diff — the classic two-row DP, ~small inputs. */
14
- function lcsDiff(oldLines, newLines) {
15
- const n = oldLines.length;
16
- const m = newLines.length;
17
- const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
18
- for (let i = n - 1; i >= 0; i -= 1) {
19
- for (let j = m - 1; j >= 0; j -= 1) {
20
- dp[i][j] = oldLines[i] === newLines[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
21
- }
22
- }
23
- const out = [];
24
- let i = 0;
25
- let j = 0;
26
- while (i < n && j < m) {
27
- if (oldLines[i] === newLines[j]) {
28
- out.push({ kind: " ", text: oldLines[i] });
29
- i += 1;
30
- j += 1;
31
- }
32
- else if (dp[i + 1][j] >= dp[i][j + 1]) {
33
- out.push({ kind: "-", text: oldLines[i] });
34
- i += 1;
35
- }
36
- else {
37
- out.push({ kind: "+", text: newLines[j] });
38
- j += 1;
39
- }
40
- }
41
- while (i < n) {
42
- out.push({ kind: "-", text: oldLines[i] });
43
- i += 1;
44
- }
45
- while (j < m) {
46
- out.push({ kind: "+", text: newLines[j] });
47
- j += 1;
48
- }
49
- return out;
50
- }
51
- /** Keep 2 context rows around each change — the unified-style window. */
52
- function withContext(diff) {
53
- const out = [];
54
- let lastAdded = -10;
55
- for (let k = 0; k < diff.length; k += 1) {
56
- if (diff[k].kind === " ")
57
- continue;
58
- const from = Math.max(0, k - 2);
59
- const to = Math.min(diff.length - 1, k + 2);
60
- for (let c = from; c <= to; c += 1) {
61
- if (c > lastAdded) {
62
- out.push(diff[c]);
63
- lastAdded = c;
64
- }
65
- }
66
- lastAdded = to;
67
- }
68
- return out;
69
- }
70
- const MAX_DIFF_LINES = 40; // the RENDERED cap
71
- const TRUNCATE_KEEP = 18;
72
- /** The RENDERER's truncation: head + "… N lines (/last for full)" + tail. */
73
- export function truncateDiff(diff) {
74
- if (diff.length <= MAX_DIFF_LINES)
75
- return diff;
76
- const omitted = diff.length - 2 * TRUNCATE_KEEP;
77
- return [
78
- ...diff.slice(0, TRUNCATE_KEEP),
79
- { kind: " ", text: `… ${omitted} lines (/last for full)` },
80
- ...diff.slice(diff.length - TRUNCATE_KEEP),
81
- ];
82
- }
83
- function stats(diff) {
84
- let added = 0;
85
- let removed = 0;
86
- for (const d of diff) {
87
- if (d.kind === "+")
88
- added += 1;
89
- else if (d.kind === "-")
90
- removed += 1;
91
- }
92
- return { added, removed };
93
- }
94
- /** edit_file: the search→replace windows replace in place — the changed
95
- * region is KNOWN, so the diff is the old window vs the new window,
96
- * context from the surrounding file. */
97
- export function editFileDiff(oldContent, search, replace) {
98
- const oldLines = oldContent.split("\n");
99
- const searchLines = search.split("\n");
100
- const replaceLines = replace.split("\n");
101
- // Locate the search window (the first occurrence — the edit tool's own
102
- // semantics); no occurrence → the whole file is the old side.
103
- let at = -1;
104
- for (let i = 0; i + searchLines.length <= oldLines.length; i += 1) {
105
- if (oldLines.slice(i, i + searchLines.length).join("\n") === search) {
106
- at = i;
107
- break;
108
- }
109
- }
110
- const lines = at < 0 ? withContext(lcsDiff(oldLines, replaceLines)) : withContext(lcsDiff(oldLines, [...oldLines.slice(0, at), ...replaceLines, ...oldLines.slice(at + searchLines.length)]));
111
- return { lines, ...stats(lines) };
112
- }
113
- /** write_file: a new file is all +; an existing file diffs row-level
114
- * against its old content. */
115
- export function writeFileDiff(oldContent, newContent) {
116
- if (oldContent === null) {
117
- const lines = newContent.split("\n").map((text) => ({ kind: "+", text }));
118
- return { lines, added: lines.length, removed: 0 };
119
- }
120
- const lines = withContext(lcsDiff(oldContent.split("\n"), newContent.split("\n")));
121
- return { lines, ...stats(lines) };
122
- }