@vincemakes/kiso-code 0.31.0 → 0.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/oauth/chatgpt.js +6 -0
- package/dist/auth/oauth/index.d.ts +5 -0
- package/dist/chat.d.ts +9 -0
- package/dist/chat.js +38 -2
- package/dist/config.js +17 -0
- package/dist/dispatch.js +75 -6
- package/dist/index.js +113 -27
- package/dist/update-check.d.ts +9 -3
- package/dist/update-check.js +27 -28
- package/package.json +15 -15
- package/dist/diff.d.ts +0 -32
- package/dist/diff.js +0 -122
|
@@ -164,6 +164,12 @@ export const chatgptFlow = {
|
|
|
164
164
|
const flow = createAuthorizationFlow(redirectUri);
|
|
165
165
|
const server = await startCallbackServer(flow.state, cb.host, cb.port);
|
|
166
166
|
interaction.notify(`open this URL to sign in:\n ${flow.url}`);
|
|
167
|
+
// OR-4 (owner, 2026-09-09): the URL is opened for the person when the
|
|
168
|
+
// caller can — the printed line stays the fallback and the paste path.
|
|
169
|
+
if (interaction.open !== undefined) {
|
|
170
|
+
interaction.open(flow.url);
|
|
171
|
+
interaction.notify("(opened in your browser — if nothing opened, copy the URL above)");
|
|
172
|
+
}
|
|
167
173
|
let code = null;
|
|
168
174
|
if (server !== null) {
|
|
169
175
|
interaction.notify(`waiting for the browser to come back to ${redirectUri} … (or paste the redirected URL here)`);
|
|
@@ -14,6 +14,11 @@ export interface LoginInteraction {
|
|
|
14
14
|
/** Ask the person for a line (the pasted redirect URL or code when the
|
|
15
15
|
* local callback cannot be reached); resolves "" when unavailable. */
|
|
16
16
|
readonly prompt: (question: string) => Promise<string>;
|
|
17
|
+
/** OR-4: open the authorize URL in the person's browser, when there is
|
|
18
|
+
* one. OPTIONAL — the CLI passes it on a TTY; tests and pipes never do,
|
|
19
|
+
* so nothing opens a browser under a test rig. The URL is always
|
|
20
|
+
* printed too: this is a convenience, never the only path. */
|
|
21
|
+
readonly open?: (url: string) => void;
|
|
17
22
|
readonly signal?: AbortSignal;
|
|
18
23
|
/** Test seam: the callback listener's host/port (defaults 127.0.0.1:1455). */
|
|
19
24
|
readonly callback?: {
|
package/dist/chat.d.ts
CHANGED
|
@@ -38,6 +38,15 @@ export declare function estimateCtxRatio(session: AgentSession): number;
|
|
|
38
38
|
* their continuation envelopes, the output reserve when known) over the
|
|
39
39
|
* window. Still ~ (chars/4). The auto-compact policy does NOT read this
|
|
40
40
|
* (see autoCompactRatio) — moving the policy's number is A1b's. */
|
|
41
|
+
/** OR-8 (owner, 2026-09-09): the status row names the effort next to the
|
|
42
|
+
* model — `gpt-5.6-sol · xhigh` — whenever the live binding carries one;
|
|
43
|
+
* the provider's default shows as the bare model, as before. The effort
|
|
44
|
+
* is the session's own (the durable profile), never the CLI's memory. */
|
|
45
|
+
export declare function statusModelLabel(session: {
|
|
46
|
+
readonly reasoning?: {
|
|
47
|
+
readonly effort: string;
|
|
48
|
+
};
|
|
49
|
+
}): string;
|
|
41
50
|
export declare function displayCtxRatio(session: AgentSession): number;
|
|
42
51
|
/** A1a: the number the auto-compact decision reads — the pre-A1a estimate,
|
|
43
52
|
* unchanged this round, named so a gate can pin that it did not move. */
|
package/dist/chat.js
CHANGED
|
@@ -69,6 +69,14 @@ export function estimateCtxRatio(session) {
|
|
|
69
69
|
* their continuation envelopes, the output reserve when known) over the
|
|
70
70
|
* window. Still ~ (chars/4). The auto-compact policy does NOT read this
|
|
71
71
|
* (see autoCompactRatio) — moving the policy's number is A1b's. */
|
|
72
|
+
/** OR-8 (owner, 2026-09-09): the status row names the effort next to the
|
|
73
|
+
* model — `gpt-5.6-sol · xhigh` — whenever the live binding carries one;
|
|
74
|
+
* the provider's default shows as the bare model, as before. The effort
|
|
75
|
+
* is the session's own (the durable profile), never the CLI's memory. */
|
|
76
|
+
export function statusModelLabel(session) {
|
|
77
|
+
const effort = session.reasoning?.effort;
|
|
78
|
+
return effort !== undefined && effort !== "default" ? `${agentModel} · ${effort}` : agentModel;
|
|
79
|
+
}
|
|
72
80
|
export function displayCtxRatio(session) {
|
|
73
81
|
return requestBudget(session.requestParts(), contextWindowTokens()).ratio;
|
|
74
82
|
}
|
|
@@ -127,7 +135,24 @@ endpoint) {
|
|
|
127
135
|
const m = Math.min(prevTotal, total) - c.cacheRead;
|
|
128
136
|
missed = m > CACHE_MISS_FLOOR ? m : null;
|
|
129
137
|
}
|
|
130
|
-
|
|
138
|
+
// OR-10 (owner, 2026-09-09): a backend whose cache figure cannot be
|
|
139
|
+
// observed has no cache figure. The ChatGPT backend answers
|
|
140
|
+
// `cached_tokens` 0 to a third-party client on every request, so its 0
|
|
141
|
+
// and "not reported" are one value, and a CH 0% built on it claims a
|
|
142
|
+
// measurement that never happened. The registry ROW says which
|
|
143
|
+
// endpoints these are; the status row, the recap and the usage line
|
|
144
|
+
// already render a null cache as nothing ("an unmeasured cache is not
|
|
145
|
+
// a 0% cache"), so this is one decision at the one place the endpoint
|
|
146
|
+
// is known. The trace ledger is written by the runtime from the
|
|
147
|
+
// backend's own words and keeps its 0 — the display's rule, not the
|
|
148
|
+
// record's.
|
|
149
|
+
const unobservable = model !== undefined && lookupModelMetadata(model, endpoint)?.capabilities.promptCaching === "unobservable";
|
|
150
|
+
return {
|
|
151
|
+
usage: { in: c.input, out: c.output, cache: unobservable ? null : c.cacheRead, known: ev.known },
|
|
152
|
+
total,
|
|
153
|
+
missed: unobservable ? null : missed,
|
|
154
|
+
costUsd: c.costUsd,
|
|
155
|
+
};
|
|
131
156
|
}
|
|
132
157
|
/** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
|
|
133
158
|
* is gone) — docked only, 200ms rotation between the request and the
|
|
@@ -791,6 +816,17 @@ submitTurn) {
|
|
|
791
816
|
// is a sentence addressed to a human; it needs no edge.
|
|
792
817
|
body.notice('answer truncated at max_tokens — say "continue" to finish');
|
|
793
818
|
}
|
|
819
|
+
// OR-5 (the ChatGPT real leg, 2026-09-09): a run that ended in an
|
|
820
|
+
// ERROR terminal printed nothing but the recap — the vendor's 400
|
|
821
|
+
// lived in the durable log alone and the turn read as "took 1s".
|
|
822
|
+
// A failure that fires silently is indistinguishable from a
|
|
823
|
+
// crash, the one thing this product must never be: the mapped
|
|
824
|
+
// error is named here, before the recap, code and status and the
|
|
825
|
+
// provider's own words.
|
|
826
|
+
if (ev.outcome.kind === "error") {
|
|
827
|
+
const e = ev.outcome.error;
|
|
828
|
+
body.notice(`run failed — ${e.code}${e.status !== undefined ? ` ${e.status}` : ""}${e.retryable ? " (retryable)" : ""}: ${escapeTerminal(e.message)}`);
|
|
829
|
+
}
|
|
794
830
|
bodyLog(renderRecap({
|
|
795
831
|
seconds: Math.round((Date.now() - turnStart) / 1000),
|
|
796
832
|
// R3g: the work terms are NOT passed — the fold line
|
|
@@ -1083,7 +1119,7 @@ export async function chat(session, faux, input, autoCompact, nav) {
|
|
|
1083
1119
|
// TUI2-R1 (E): the meter rides the idle row — both fields omitted
|
|
1084
1120
|
// when unknown, so a session that has not called the model paints
|
|
1085
1121
|
// exactly the pre-round row.
|
|
1086
|
-
dock.setStatus(idleStatus(getMode() === "plan" ? "plan (read-only)" : getMode(),
|
|
1122
|
+
dock.setStatus(idleStatus(getMode() === "plan" ? "plan (read-only)" : getMode(), statusModelLabel(session), displayCtxRatio(session), {
|
|
1087
1123
|
cacheHitPct: cacheHitPct(runUsage),
|
|
1088
1124
|
costUsd: spentUsd,
|
|
1089
1125
|
}));
|
package/dist/config.js
CHANGED
|
@@ -31,6 +31,7 @@ import { readFileSync } from "node:fs";
|
|
|
31
31
|
import { join } from "node:path";
|
|
32
32
|
import { kisoHome } from "./state.js";
|
|
33
33
|
import { AuthError, getCredential, providerIdOf } from "./auth/credentials.js";
|
|
34
|
+
import { BUILTIN_MANIFESTS } from "@vincemakes/kiso-runtime/internal";
|
|
34
35
|
const KINDS = ["openai-compat", "anthropic", "openai-responses"];
|
|
35
36
|
const MODES_LIST = ["manual", "default", "accept-edits", "plan", "bypass"];
|
|
36
37
|
export class ConfigError extends Error {
|
|
@@ -209,6 +210,15 @@ export function authForProfile(name, p) {
|
|
|
209
210
|
throw new ConfigError(`model ${name}: signed in to ${providerId} with OAuth, but this profile's adapter needs an API key — run \`kiso login ${providerId}\` with a key, or \`kiso logout ${providerId}\` to use the env var ${p.apiKeyEnv ?? "(none configured)"}`);
|
|
210
211
|
}
|
|
211
212
|
}
|
|
213
|
+
// OR-2 (2026-09-09): a provider whose manifest takes NO API key (the ChatGPT
|
|
214
|
+
// backend: authMethods ["oauth"]) is never an "unauthenticated endpoint" —
|
|
215
|
+
// with nothing stored it is a profile waiting for its sign-in. Without this
|
|
216
|
+
// the keyless rule below handed it a placeholder key, `/model` said
|
|
217
|
+
// "available", and the adapter (an apiKey selects the first-party target)
|
|
218
|
+
// would have sent an unauthenticated request to the wrong URL.
|
|
219
|
+
if (providerId !== null && !manifestTakesKey(providerId)) {
|
|
220
|
+
throw new ConfigError(`model ${name}: unavailable — not signed in: run \`kiso login ${providerId}\` (this provider takes no API key)`);
|
|
221
|
+
}
|
|
212
222
|
if (p.apiKeyEnv === undefined)
|
|
213
223
|
return { type: "api-key", apiKey: "none", source: "none" };
|
|
214
224
|
const fromEnv = process.env[p.apiKeyEnv];
|
|
@@ -217,6 +227,13 @@ export function authForProfile(name, p) {
|
|
|
217
227
|
const hint = providerId !== null ? `run \`kiso login ${providerId}\` or set the env var ${p.apiKeyEnv}` : `set the env var ${p.apiKeyEnv}`;
|
|
218
228
|
throw new ConfigError(`model ${name}: unavailable — no credential: ${hint} (configs never store keys, only the env-var name)`);
|
|
219
229
|
}
|
|
230
|
+
/** OR-2: does the provider's manifest admit an API key (or no auth at all)?
|
|
231
|
+
* An unknown id answers yes — the keyless-endpoint rule stays for custom
|
|
232
|
+
* origins; only a manifest that says OAuth-only takes the sign-in path. */
|
|
233
|
+
function manifestTakesKey(providerId) {
|
|
234
|
+
const m = BUILTIN_MANIFESTS.find((x) => x.id === providerId);
|
|
235
|
+
return m === undefined || m.authMethods.includes("api-key") || m.authMethods.includes("none");
|
|
236
|
+
}
|
|
220
237
|
/** The reason a profile is unavailable, for the surfaces that print it. */
|
|
221
238
|
export function unavailableReason(name, p) {
|
|
222
239
|
try {
|
package/dist/dispatch.js
CHANGED
|
@@ -8,7 +8,7 @@ import { newSessionId } from "./session-id.js";
|
|
|
8
8
|
import { buildAdapter, lookupModelMetadata, resolveContinuationScope, resolveReasoning } from "@vincemakes/kiso-runtime/internal";
|
|
9
9
|
import { MODES, MODE_NOTE, getMode, setMode } from "./mode.js";
|
|
10
10
|
import { clipboardWrite, lastAnswer } from "./clipboard.js";
|
|
11
|
-
import { agentModel, body, bodyLog, configModels, dock, readContextLedger, sessionsDir, setAgentModel, setCurrentModelName, setLastBinding } from "./state.js";
|
|
11
|
+
import { agentModel, body, bodyLog, configModels, dock, lastBinding, readContextLedger, sessionsDir, setAgentModel, setCurrentModelName, setLastBinding } from "./state.js";
|
|
12
12
|
import { authForProfile, directWriteProfile, profileAvailable, unavailableReason } from "./config.js";
|
|
13
13
|
/** The picker's CLI half (owner 2026-09-08): a profile's LEGAL effort levels
|
|
14
14
|
* and its default, from the registry, shown wherever the profile is listed
|
|
@@ -22,6 +22,43 @@ function effortNote(p) {
|
|
|
22
22
|
return "effort: unknown";
|
|
23
23
|
return `effort: ${effort.levels.map((l) => (l === effort.default ? `[${l}]` : l)).join(" · ")}`;
|
|
24
24
|
}
|
|
25
|
+
/** OR-7 — the pick panel's second axis, as DATA. `effortNote` above stays
|
|
26
|
+
* the PRINTED list's string (a machine-readable surface whose bytes do
|
|
27
|
+
* not move); the panel gets the levels structured so its cursor can walk
|
|
28
|
+
* them, and so the bracket can mean "the cursor is here" rather than
|
|
29
|
+
* "the registry default".
|
|
30
|
+
*
|
|
31
|
+
* Where the cursor starts: the session's CURRENT effort when this model
|
|
32
|
+
* has that level, else the model's default, else nowhere — a row whose
|
|
33
|
+
* registry default is null (the model page states none) marks nothing,
|
|
34
|
+
* and enter on it applies the profile alone. Nothing is invented. */
|
|
35
|
+
function effortAxis(p) {
|
|
36
|
+
const reasoning = lookupModelMetadata(p.model, p.baseUrl)?.capabilities.reasoning ?? null;
|
|
37
|
+
if (reasoning === null)
|
|
38
|
+
return {};
|
|
39
|
+
const effort = reasoning.effort;
|
|
40
|
+
if (effort === null)
|
|
41
|
+
return {};
|
|
42
|
+
const levels = effort.levels;
|
|
43
|
+
const carried = lastBinding()?.reasoning?.effort;
|
|
44
|
+
const carriedIdx = carried === undefined || carried === "default" ? -1 : levels.indexOf(carried);
|
|
45
|
+
const defaultIdx = effort.default === null ? -1 : levels.indexOf(effort.default);
|
|
46
|
+
const level = carriedIdx >= 0 ? carriedIdx : defaultIdx;
|
|
47
|
+
// the forbidden set is per THINKING mode, and the panel applies its
|
|
48
|
+
// pick as thinking "default" — the same setting `/model <p> <e>`
|
|
49
|
+
// builds — so only pairs forbidden at that mode are dimmed here.
|
|
50
|
+
const disabled = (reasoning.forbidden ?? []).filter((f) => f.thinking === "default").map((f) => levels.indexOf(f.effort)).filter((i) => i >= 0);
|
|
51
|
+
return {
|
|
52
|
+
levels,
|
|
53
|
+
...(level >= 0 ? { level } : {}),
|
|
54
|
+
...(disabled.length > 0 ? { disabled } : {}),
|
|
55
|
+
// the sentence the coordination note asked for: said only when the
|
|
56
|
+
// cursor could NOT land where the previous selection asked.
|
|
57
|
+
...(carried !== undefined && carried !== "default" && carriedIdx < 0 && defaultIdx >= 0
|
|
58
|
+
? { levelNote: `effort ${carried} \u2192 ${levels[defaultIdx]}: the nearest this model supports` }
|
|
59
|
+
: {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
25
62
|
/** What signs a profile in, for the listing: the env var's name, `oauth`
|
|
26
63
|
* for a subscription sign-in, or `no key` for an unauthenticated endpoint. */
|
|
27
64
|
function signInNote(p) {
|
|
@@ -316,13 +353,17 @@ export function dispatch(line, ctx) {
|
|
|
316
353
|
// surface and this round moves no bytes on one.
|
|
317
354
|
if (arg === "" && dock.active && ctx.input.panelAsk !== undefined) {
|
|
318
355
|
const names = Object.keys(configModels);
|
|
356
|
+
let pickedLevel;
|
|
319
357
|
const picked = await new Promise((resolve) => {
|
|
320
358
|
ctx.input.panelAsk(modelPickView({
|
|
321
359
|
header: `model — current: ${agentModel}`,
|
|
322
360
|
options: names.map((name) => {
|
|
323
361
|
const profile = configModels[name];
|
|
324
|
-
|
|
325
|
-
|
|
362
|
+
// the note keeps what tells two rows apart; the levels
|
|
363
|
+
// left it for the axis below (they were being cut off
|
|
364
|
+
// the end of the note column at 100 columns).
|
|
365
|
+
const marks = [`profile: ${name}`, ...(profileAvailable(profile) ? [] : ["unavailable"]), ...(profile.model === agentModel ? ["current"] : [])];
|
|
366
|
+
return { label: `${profile.kind}/${profile.model}`, note: marks.join(" · "), ...effortAxis(profile) };
|
|
326
367
|
}),
|
|
327
368
|
// PH-1a (finding PH-F4): the example must be a syntax
|
|
328
369
|
// directWriteProfile actually ACCEPTS — the old
|
|
@@ -337,14 +378,30 @@ export function dispatch(line, ctx) {
|
|
|
337
378
|
// DC-12 (design §4): a panel WAITING ON A HUMAN says ❯. The
|
|
338
379
|
// else-branch keeps ▸ — it names the current tier, which is
|
|
339
380
|
// what ▸ means everywhere else.
|
|
340
|
-
ctx.isRunning() ? "❯ run paused" : `▸ ${getMode()}`), (v) =>
|
|
381
|
+
ctx.isRunning() ? "❯ run paused" : `▸ ${getMode()}`), (v) => {
|
|
382
|
+
// OR-7: the level rides beside the result, so a pick
|
|
383
|
+
// with no second axis is byte-for-byte the old one.
|
|
384
|
+
if (v.action === "picked")
|
|
385
|
+
pickedLevel = v.level;
|
|
386
|
+
resolve(v.action === "picked" ? v.result : null);
|
|
387
|
+
});
|
|
341
388
|
});
|
|
342
389
|
ctx.paintIdle();
|
|
343
390
|
if (picked === null) {
|
|
344
391
|
ctx.input.prompt();
|
|
345
392
|
return; // esc — nothing switched, nothing said
|
|
346
393
|
}
|
|
347
|
-
|
|
394
|
+
if ("index" in picked) {
|
|
395
|
+
// OR-7: the panel hands back both axes and the command
|
|
396
|
+
// string is where they meet — everything below is the
|
|
397
|
+
// typed `/model <profile> <effort>` path, unchanged, so
|
|
398
|
+
// the refusal wording and the durable revision are its.
|
|
399
|
+
const name = names[picked.index];
|
|
400
|
+
const level = pickedLevel === undefined ? undefined : effortAxis(configModels[name]).levels?.[pickedLevel];
|
|
401
|
+
arg = level === undefined ? name : `${name} ${level}`;
|
|
402
|
+
}
|
|
403
|
+
else
|
|
404
|
+
arg = picked.custom;
|
|
348
405
|
}
|
|
349
406
|
if (arg === "") {
|
|
350
407
|
bodyLog(`model: ${agentModel}`);
|
|
@@ -373,7 +430,13 @@ export function dispatch(line, ctx) {
|
|
|
373
430
|
bodyLog(`no such model profile: ${profName}`);
|
|
374
431
|
}
|
|
375
432
|
else if (!profileAvailable(profile)) {
|
|
376
|
-
|
|
433
|
+
// the PROFILE name, not the whole argument: what is
|
|
434
|
+
// unavailable is the credential, and an effort token
|
|
435
|
+
// riding along ("claude high: unavailable") reads as
|
|
436
|
+
// though the level were part of the problem. The panel
|
|
437
|
+
// began producing that token in OR-7; the typed path
|
|
438
|
+
// could always produce it too.
|
|
439
|
+
bodyLog(unavailableReason(profName, profile));
|
|
377
440
|
}
|
|
378
441
|
else {
|
|
379
442
|
const auth = authForProfile(profName, profile);
|
|
@@ -430,6 +493,12 @@ export function dispatch(line, ctx) {
|
|
|
430
493
|
setLastBinding(binding);
|
|
431
494
|
setAgentModel(profile.model, profile.baseUrl);
|
|
432
495
|
setCurrentModelName(arg);
|
|
496
|
+
// OR-7 (owner, 2026-09-09): the status row is repainted BY the
|
|
497
|
+
// switch — it used to keep the old model until the next
|
|
498
|
+
// recap repainted it, so "takes effect on the next turn"
|
|
499
|
+
// read as "did not take". Painted BEFORE the notice so the
|
|
500
|
+
// frame that carries the notice already carries the row.
|
|
501
|
+
ctx.paintIdle();
|
|
433
502
|
body.notice(`model → ${profName} (${profile.model}${effortTok !== undefined ? ` · ${effortTok}` : ""}) — takes effect on the next turn`);
|
|
434
503
|
}
|
|
435
504
|
}
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* makeAgent, and main.
|
|
25
25
|
*/
|
|
26
26
|
import { appendFileSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
27
28
|
import { newSessionId } from "./session-id.js";
|
|
28
29
|
import { createInterface } from "node:readline";
|
|
29
30
|
import { Readable } from "node:stream";
|
|
@@ -39,10 +40,10 @@ import { agentModel, atFiles, body, bodyLog, kisoHome, builtInExtensions, curren
|
|
|
39
40
|
import { askUi, resolveProjectTrust } from "./trust-ui.js";
|
|
40
41
|
import { isFirstRun, scaffoldFirstRun } from "./first-run.js";
|
|
41
42
|
import { fauxSkip, readFauxScript } from "./faux-glue.js";
|
|
42
|
-
import { chat, contextWindowTokens, displayCtxRatio } from "./chat.js";
|
|
43
|
+
import { chat, contextWindowTokens, displayCtxRatio, statusModelLabel } from "./chat.js";
|
|
43
44
|
import { loadProjectConfig, loadUserConfig, mergeConfigs, resolveAutoCompact, resolveContextWindow, resolveModel } from "./config.js";
|
|
44
45
|
import { oauthTokenThunk } from "./auth/token.js";
|
|
45
|
-
import { checkForUpdate } from "./update-check.js";
|
|
46
|
+
import { checkForUpdate, knownUpdate, updateCardLines } from "./update-check.js";
|
|
46
47
|
import { resume } from "./resume.js";
|
|
47
48
|
import { resumeTail } from "./resume-tail.js";
|
|
48
49
|
import { armByteTrace } from "./byte-trace.js";
|
|
@@ -62,6 +63,29 @@ export { applyProjectMerges } from "./trust-ui.js";
|
|
|
62
63
|
* exits, so the turn runs to its terminal and nothing else is read.
|
|
63
64
|
* Built on an EMPTY readline so every other member keeps the pipe
|
|
64
65
|
* path's exact semantics (auto-denied asks, no chips, no pops). */
|
|
66
|
+
/** OR-4: hand a URL to the platform's opener, detached, and never let a
|
|
67
|
+
* missing or failing opener touch the sign-in — the printed URL is the
|
|
68
|
+
* path that always works. macOS `open`, Linux `xdg-open`; elsewhere the
|
|
69
|
+
* URL stays printed (Windows is unsupported by the CLI as a whole). */
|
|
70
|
+
function openInBrowser(url) {
|
|
71
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "linux" ? "xdg-open" : null;
|
|
72
|
+
if (opener === null)
|
|
73
|
+
return;
|
|
74
|
+
try {
|
|
75
|
+
spawn(opener, [url], { detached: true, stdio: "ignore" }).unref();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// the URL is on screen; opening it is the person's fallback
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** OR-3: the commands that own their own stdin reader (a hidden key prompt,
|
|
82
|
+
* an OAuth paste prompt) or read nothing at all — never the editor.
|
|
83
|
+
* OR-9 adds `update`: npm inherits the terminal for the install's own
|
|
84
|
+
* progress and prompts, so no editor may hold stdin while it runs. */
|
|
85
|
+
const CREDENTIAL_COMMANDS = new Set(["login", "logout", "auth", "update"]);
|
|
86
|
+
/** OR-9: the one install command the README gives, which `kiso update`
|
|
87
|
+
* runs and the failure message names. */
|
|
88
|
+
const INSTALL_COMMAND = ["npm", "i", "-g", "@vincemakes/kiso-code@latest"];
|
|
65
89
|
function taskFileInput(content) {
|
|
66
90
|
const base = readlineInput(createInterface({ input: Readable.from([]), output: process.stdout }));
|
|
67
91
|
return {
|
|
@@ -384,28 +408,42 @@ function bannerExtensionText() {
|
|
|
384
408
|
* resize; the resume list re-gates with the tier); off-TTY: the
|
|
385
409
|
* historical `[N extensions: ...]` standalone line (zero change). */
|
|
386
410
|
/**
|
|
387
|
-
* The update
|
|
411
|
+
* The update card, when there is one, appended after the opening.
|
|
388
412
|
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
413
|
+
* Two painters, one card. The boot paints what the CACHE already knows,
|
|
414
|
+
* synchronously, so a known newer version is under the banner on the
|
|
415
|
+
* first frame; the check then runs fired-and-forgotten (at most one
|
|
416
|
+
* request a day) and paints only a version the boot did not. If the check
|
|
417
|
+
* fails, times out, is switched off, or finds nothing newer, it does
|
|
418
|
+
* nothing at all and says nothing about having tried.
|
|
393
419
|
*/
|
|
420
|
+
/** OR-9: the version this process has already carded — the boot's cache
|
|
421
|
+
* paint and the async check's answer never both card one version. */
|
|
422
|
+
let updateShown = null;
|
|
423
|
+
const updateDeps = () => ({ kisoHome: kisoHome(), version: VERSION, isTTY: process.stdout.isTTY === true, faux: currentFaux });
|
|
424
|
+
/** OR-9 (owner, 2026-09-09): the update is a CARD under the banner — a
|
|
425
|
+
* rule, a bold title, the version with the command that installs it, the
|
|
426
|
+
* changelog, a rule — the shape the reference shows at every start. The
|
|
427
|
+
* RAW channel (not `notice`, which strips SGR through escapeTerminal); a
|
|
428
|
+
* raw cell lands at the transcript's end, never spliced mid-frame. A
|
|
429
|
+
* declared exception to R2 law 1.1 (a notice wears no edge): a card is
|
|
430
|
+
* not a notice, and the owner asked for the edges. */
|
|
431
|
+
function paintUpdateCard(latest) {
|
|
432
|
+
if (latest === updateShown)
|
|
433
|
+
return;
|
|
434
|
+
updateShown = latest;
|
|
435
|
+
const p = palette();
|
|
436
|
+
const width = Math.min(process.stdout.columns ?? 80, 80);
|
|
437
|
+
const rule = `${p.dim}${"─".repeat(width)}${p.reset}`;
|
|
438
|
+
const [title, line, changelog] = updateCardLines(latest);
|
|
439
|
+
bodyLog(`${rule}\n${p.bold}${title}${p.reset}\n${line}\n${changelog}\n${rule}`, "words");
|
|
440
|
+
}
|
|
394
441
|
async function announceUpdate() {
|
|
395
442
|
try {
|
|
396
|
-
const
|
|
397
|
-
if (
|
|
443
|
+
const latest = await checkForUpdate(updateDeps());
|
|
444
|
+
if (latest === null)
|
|
398
445
|
return;
|
|
399
|
-
|
|
400
|
-
// which calls `escapeTerminal` — so every SGR in it is stripped and
|
|
401
|
-
// the line could only ever be full-strength prose. The raw channel
|
|
402
|
-
// is the one the banner itself uses: the styling is applied HERE, at
|
|
403
|
-
// composition, and the fold keeps the span intact (#16b).
|
|
404
|
-
//
|
|
405
|
-
// A turn may have started while the request was in flight. A raw
|
|
406
|
-
// cell is a cell like any other, so it lands at the transcript's
|
|
407
|
-
// end — never spliced into a frame the reader is mid-way through.
|
|
408
|
-
bodyLog(`${palette().dim}${line}${palette().reset}`, "words");
|
|
446
|
+
paintUpdateCard(latest);
|
|
409
447
|
}
|
|
410
448
|
catch {
|
|
411
449
|
// belt and braces: checkForUpdate does not throw, and if it ever
|
|
@@ -742,7 +780,7 @@ async function pickSession(agent, input) {
|
|
|
742
780
|
function paintBootStatus(session) {
|
|
743
781
|
if (!dock.active)
|
|
744
782
|
return;
|
|
745
|
-
dock.setStatus(idleStatus(getMode() === "plan" ? "plan (read-only)" : getMode(),
|
|
783
|
+
dock.setStatus(idleStatus(getMode() === "plan" ? "plan (read-only)" : getMode(), statusModelLabel(session), displayCtxRatio(session)));
|
|
746
784
|
}
|
|
747
785
|
/** PH-1a (finding PH-F12): a usage error raised from inside the TUI —
|
|
748
786
|
* main's finally still runs (dock teardown, agent close, temp cleanup)
|
|
@@ -778,11 +816,16 @@ async function chatLoop(agent, firstId, input, autoCompact) {
|
|
|
778
816
|
// throws on profile drift — so one drifted session anywhere in the
|
|
779
817
|
// history stopped kiso from starting at all.
|
|
780
818
|
extensionsBanner();
|
|
781
|
-
//
|
|
782
|
-
// a newer
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
//
|
|
819
|
+
// OR-9 (owner, 2026-09-09): the update card under the banner, at
|
|
820
|
+
// EVERY start while a newer version is known — §7.10's once-only
|
|
821
|
+
// line is superseded. What the cache knows is painted NOW, with
|
|
822
|
+
// no request, so the card is part of the opening rather than a
|
|
823
|
+
// late arrival; the check itself stays FIRED AND FORGOTTEN — the
|
|
824
|
+
// banner is already on screen and it never delays it — and it
|
|
825
|
+
// paints only a version the boot did not know.
|
|
826
|
+
const known = knownUpdate(updateDeps());
|
|
827
|
+
if (known !== null)
|
|
828
|
+
paintUpdateCard(known);
|
|
786
829
|
void announceUpdate();
|
|
787
830
|
}
|
|
788
831
|
else {
|
|
@@ -830,10 +873,15 @@ async function readSecret(prompt) {
|
|
|
830
873
|
process.stdin.resume();
|
|
831
874
|
});
|
|
832
875
|
}
|
|
833
|
-
process.stdout.write(prompt);
|
|
834
876
|
return await new Promise((resolve) => {
|
|
835
877
|
let buf = "";
|
|
878
|
+
// OR-3: raw mode BEFORE the prompt — the hidden prompt must be hidden
|
|
879
|
+
// before it is shown. Written the other way round, a key that arrives
|
|
880
|
+
// between the prompt and setRawMode is echoed by the tty line
|
|
881
|
+
// discipline (the auth-tty gate caught it: a driver that types the
|
|
882
|
+
// instant the prompt appears saw the key on screen two runs in three).
|
|
836
883
|
process.stdin.setRawMode(true);
|
|
884
|
+
process.stdout.write(prompt);
|
|
837
885
|
process.stdin.resume();
|
|
838
886
|
process.stdin.setEncoding("utf8");
|
|
839
887
|
const onData = (chunk) => {
|
|
@@ -975,7 +1023,20 @@ async function main() {
|
|
|
975
1023
|
// (entered here, dock-bound, trusted before any extension loads),
|
|
976
1024
|
// readline elsewhere. The trust question, chat, and resume all read
|
|
977
1025
|
// through it; main's finally closes it on every exit path.
|
|
978
|
-
|
|
1026
|
+
// OR-3 (owner, 2026-09-09): the credential commands never read the shared
|
|
1027
|
+
// line input, and on a TTY makeLineInput() enters the raw-mode editor and
|
|
1028
|
+
// sends the ground probe (CSI ?996n + OSC 11) BEFORE the command switch.
|
|
1029
|
+
// `kiso login chatgpt` then had two readers on stdin: the editor swallowed
|
|
1030
|
+
// the keystrokes, the terminal's OSC reply was typed into the login's own
|
|
1031
|
+
// readline, the "open this URL" line vanished under the dock's repaint,
|
|
1032
|
+
// and the sign-in never completed. These commands get an input over an
|
|
1033
|
+
// EMPTY readable instead — no editor, no probe, stdin untouched — so the
|
|
1034
|
+
// hidden key prompt and the OAuth paste prompt are the only readers.
|
|
1035
|
+
const input = taskFile !== undefined
|
|
1036
|
+
? taskFileInput(taskFile)
|
|
1037
|
+
: CREDENTIAL_COMMANDS.has(command ?? "")
|
|
1038
|
+
? readlineInput(createInterface({ input: Readable.from([]), output: process.stdout }))
|
|
1039
|
+
: makeLineInput();
|
|
979
1040
|
// R3a — cross-session input history: ~/.kiso/history, one line per
|
|
980
1041
|
// entry, appended on submit, tail-500 at load (truncated by REWRITE
|
|
981
1042
|
// at startup so the file never grows unbounded). Unreadable file =
|
|
@@ -1195,6 +1256,9 @@ async function main() {
|
|
|
1195
1256
|
const cred = await chatgptFlow.login({
|
|
1196
1257
|
notify: (line) => process.stdout.write(`${line}\n`),
|
|
1197
1258
|
prompt: (q) => new Promise((resolve) => rl.question(q, resolve)),
|
|
1259
|
+
// OR-4: a person at a terminal gets the browser opened for
|
|
1260
|
+
// them; a pipe, a test rig or KISO_NO_BROWSER=1 gets the URL only.
|
|
1261
|
+
...(process.stdout.isTTY === true && process.env.KISO_NO_BROWSER === undefined ? { open: openInBrowser } : {}),
|
|
1198
1262
|
...(cbPort !== undefined ? { callback: { host: process.env.KISO_OAUTH_CALLBACK_HOST ?? "127.0.0.1", port: cbPort } } : {}),
|
|
1199
1263
|
...(process.env.KISO_OAUTH_TOKEN_URL !== undefined ? { tokenUrl: process.env.KISO_OAUTH_TOKEN_URL } : {}),
|
|
1200
1264
|
});
|
|
@@ -1213,6 +1277,27 @@ async function main() {
|
|
|
1213
1277
|
process.stdout.write(`signed in to ${provider} with an API key (${maskSecret(key)}) — stored in ${authPath()}\n`);
|
|
1214
1278
|
break;
|
|
1215
1279
|
}
|
|
1280
|
+
case "update": {
|
|
1281
|
+
// OR-9: the command the update card names. A thin hand-off to
|
|
1282
|
+
// the ONE install command the README gives — npm resolved from
|
|
1283
|
+
// PATH, its stdio inherited so its progress, its prompts and
|
|
1284
|
+
// its own errors are what the person sees, its exit code the
|
|
1285
|
+
// outcome. kiso adds nothing to what npm knows: no version
|
|
1286
|
+
// pinning, no registry, no elevation. A failure (a global
|
|
1287
|
+
// prefix this user cannot write, a network that is down) is
|
|
1288
|
+
// npm's to explain; the message names the manual command so
|
|
1289
|
+
// the person can run it with whatever their setup needs.
|
|
1290
|
+
const [bin, ...installArgs] = INSTALL_COMMAND;
|
|
1291
|
+
const r = spawnSync(bin, installArgs, { stdio: "inherit" });
|
|
1292
|
+
if (r.error !== undefined || r.status !== 0) {
|
|
1293
|
+
const why = r.error !== undefined ? `could not run npm (${r.error.message})` : `npm exited ${r.status ?? "on a signal"}`;
|
|
1294
|
+
process.stderr.write(`kiso update: ${why} — run it yourself: ${INSTALL_COMMAND.join(" ")}\n`);
|
|
1295
|
+
process.exitCode = r.status ?? 1;
|
|
1296
|
+
break;
|
|
1297
|
+
}
|
|
1298
|
+
process.stdout.write("kiso updated — the next `kiso` runs the new version\n");
|
|
1299
|
+
break;
|
|
1300
|
+
}
|
|
1216
1301
|
case "help": {
|
|
1217
1302
|
// PH-1b (finding PH-F21): the CLI must be able to describe its
|
|
1218
1303
|
// own configuration — the old help listed five commands and
|
|
@@ -1230,6 +1315,7 @@ async function main() {
|
|
|
1230
1315
|
" chatgpt: sign in with a ChatGPT subscription (browser; unofficial third-party flow)\n" +
|
|
1231
1316
|
" kiso logout <provider> remove the stored credential\n" +
|
|
1232
1317
|
" kiso auth list stored credentials (keys masked)\n" +
|
|
1318
|
+
" kiso update install the latest release (npm i -g @vincemakes/kiso-code@latest)\n" +
|
|
1233
1319
|
" kiso help this help\n\n" +
|
|
1234
1320
|
"flags (any position):\n" +
|
|
1235
1321
|
" --model <profile|provider/model> pick the model (also /model in-session)\n" +
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-code",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.1",
|
|
4
4
|
"description": "kiso CLI — the durable coding agent that survives kill -9: kiso chat / kiso resume / kiso sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,20 +18,20 @@
|
|
|
18
18
|
"test": "vitest run"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@vincemakes/kiso-ask-ext": "0.31.
|
|
22
|
-
"@vincemakes/kiso-core": "0.31.
|
|
23
|
-
"@vincemakes/kiso-evals": "0.31.
|
|
24
|
-
"@vincemakes/kiso-mcp-ext": "0.31.
|
|
25
|
-
"@vincemakes/kiso-provider-anthropic": "0.31.
|
|
26
|
-
"@vincemakes/kiso-provider-openai": "0.31.
|
|
27
|
-
"@vincemakes/kiso-provider-openai-responses": "0.31.
|
|
28
|
-
"@vincemakes/kiso-runtime": "0.31.
|
|
29
|
-
"@vincemakes/kiso-skills-ext": "0.31.
|
|
30
|
-
"@vincemakes/kiso-subagent-ext": "0.31.
|
|
31
|
-
"@vincemakes/kiso-task-ext": "0.31.
|
|
32
|
-
"@vincemakes/kiso-tools-node": "0.31.
|
|
33
|
-
"@vincemakes/kiso-tui": "0.31.
|
|
34
|
-
"@vincemakes/kiso-tui-cells": "0.31.
|
|
21
|
+
"@vincemakes/kiso-ask-ext": "0.31.1",
|
|
22
|
+
"@vincemakes/kiso-core": "0.31.1",
|
|
23
|
+
"@vincemakes/kiso-evals": "0.31.1",
|
|
24
|
+
"@vincemakes/kiso-mcp-ext": "0.31.1",
|
|
25
|
+
"@vincemakes/kiso-provider-anthropic": "0.31.1",
|
|
26
|
+
"@vincemakes/kiso-provider-openai": "0.31.1",
|
|
27
|
+
"@vincemakes/kiso-provider-openai-responses": "0.31.1",
|
|
28
|
+
"@vincemakes/kiso-runtime": "0.31.1",
|
|
29
|
+
"@vincemakes/kiso-skills-ext": "0.31.1",
|
|
30
|
+
"@vincemakes/kiso-subagent-ext": "0.31.1",
|
|
31
|
+
"@vincemakes/kiso-task-ext": "0.31.1",
|
|
32
|
+
"@vincemakes/kiso-tools-node": "0.31.1",
|
|
33
|
+
"@vincemakes/kiso-tui": "0.31.1",
|
|
34
|
+
"@vincemakes/kiso-tui-cells": "0.31.1"
|
|
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
|
-
}
|