@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/auth/oauth/chatgpt.js +6 -0
- package/dist/auth/oauth/index.d.ts +5 -0
- package/dist/breaker.d.ts +57 -0
- package/dist/breaker.js +119 -0
- package/dist/chat.d.ts +30 -0
- package/dist/chat.js +112 -5
- package/dist/config.d.ts +3 -0
- package/dist/config.js +20 -0
- package/dist/dispatch.d.ts +24 -4
- package/dist/dispatch.js +257 -6
- package/dist/external-editor.d.ts +30 -0
- package/dist/external-editor.js +55 -0
- package/dist/index.js +303 -34
- package/dist/state.d.ts +35 -0
- package/dist/state.js +27 -0
- package/dist/trust-ui.js +111 -18
- package/dist/update-check.d.ts +9 -3
- package/dist/update-check.js +27 -28
- package/package.json +16 -16
- package/dist/diff.d.ts +0 -32
- package/dist/diff.js +0 -122
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
|
-
|
|
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
|
-
`
|
|
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
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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);
|
package/dist/update-check.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/update-check.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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.
|
|
4
|
-
"description": "kiso
|
|
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.
|
|
22
|
-
"@vincemakes/kiso-core": "0.
|
|
23
|
-
"@vincemakes/kiso-evals": "0.
|
|
24
|
-
"@vincemakes/kiso-mcp-ext": "0.
|
|
25
|
-
"@vincemakes/kiso-provider-anthropic": "0.
|
|
26
|
-
"@vincemakes/kiso-provider-openai": "0.
|
|
27
|
-
"@vincemakes/kiso-provider-openai-responses": "0.
|
|
28
|
-
"@vincemakes/kiso-runtime": "0.
|
|
29
|
-
"@vincemakes/kiso-skills-ext": "0.
|
|
30
|
-
"@vincemakes/kiso-subagent-ext": "0.
|
|
31
|
-
"@vincemakes/kiso-task-ext": "0.
|
|
32
|
-
"@vincemakes/kiso-tools-node": "0.
|
|
33
|
-
"@vincemakes/kiso-tui": "0.
|
|
34
|
-
"@vincemakes/kiso-tui-cells": "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
|
-
}
|