@vincemakes/kiso-code 0.40.1 → 0.40.3

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/chat.d.ts CHANGED
@@ -9,6 +9,7 @@ import { type RunUsage } from "@vincemakes/kiso-tui";
9
9
  import { type SaferFailure, type SaferOption } from "@vincemakes/kiso-tui";
10
10
  import type { AgentSession, Run } from "@vincemakes/kiso-runtime";
11
11
  import { type LineInput } from "./state.js";
12
+ import { type ContextWindowSource } from "@vincemakes/kiso-runtime/internal";
12
13
  /**
13
14
  * The ergonomics batch C8 — the /compact auto-trigger, OPT-IN (default off: only an
14
15
  * explicit KISO_AUTO_COMPACT=<ratio> enables it — the CLI never defaults
@@ -42,7 +43,9 @@ export declare function autoCompactFromEnv(): AutoCompact | undefined;
42
43
  * THE WINDOW SOMEBODY STATED, or null when nobody has.
43
44
  *
44
45
  * Three sources, in order: the config/profile window, KISO_CONTEXT_WINDOW,
45
- * and the metadata registry. Each is a claim someone made and dated. When
46
+ * and the metadata registry (CW-1: this endpoint's row, the upstream's,
47
+ * then the model's own — see lookupContextWindow). Each is a claim
48
+ * someone made and dated. When
46
49
  * none of them speaks, this returns NULL rather than a number — because
47
50
  * the question "how much of the window is left" has no answer without a
48
51
  * window, and every display that shows a percentage needs this one, not
@@ -57,6 +60,37 @@ export declare function knownContextWindow(of?: {
57
60
  readonly model: string;
58
61
  readonly baseUrl?: string;
59
62
  }): number | null;
63
+ /** CW-1: a stated window and WHO stated it — `set` is the user (profile,
64
+ * config or KISO_CONTEXT_WINDOW), `faux` is ours, the rest are the
65
+ * registry's steps (lookupContextWindow). */
66
+ export interface StatedWindow {
67
+ readonly tokens: number;
68
+ readonly source: "set" | "faux" | "learned" | ContextWindowSource;
69
+ /** the registry row's model id, for the registry's steps */
70
+ readonly from?: string;
71
+ /** CW-1 batch 2: the day an endpoint's refusal stated it, for `learned` */
72
+ readonly observedAt?: string;
73
+ }
74
+ /** CW-1: `knownContextWindow` with its source — the chain the displays name.
75
+ * The user's figure is the live binding's unless `own` carries a
76
+ * profile's: the /model listing passes each row's, and a row with none
77
+ * must not borrow the live one (`own: { configured: undefined }` — an
78
+ * optional parameter defaulted to the live value would take it back,
79
+ * since an explicit undefined selects the default). */
80
+ export declare function statedContextWindow(of?: {
81
+ readonly model: string;
82
+ readonly baseUrl?: string;
83
+ readonly upstream?: string;
84
+ }, own?: {
85
+ readonly configured: number | undefined;
86
+ }): StatedWindow | null;
87
+ /** CW-1: a window as the displays write it — `1M`, `1.05M`, `272K`,
88
+ * `1,048,576` (a figure that is not round is shown whole). */
89
+ export declare function windowLabel(tokens: number): string;
90
+ /** CW-1: the source, said. The short form rides a /model row; the long
91
+ * form is /status's. Only the model step is marked in the short form —
92
+ * it is the one figure no row states for this endpoint. */
93
+ export declare function windowSourceNote(w: StatedWindow | null, form: "short" | "long"): string;
60
94
  export declare function contextWindowTokens(of?: {
61
95
  readonly model: string;
62
96
  readonly baseUrl?: string;
@@ -100,6 +134,22 @@ export declare function statusModelLabel(session: {
100
134
  readonly effort: string;
101
135
  };
102
136
  }): string;
137
+ /** ADR-0055 Amendment 2 (decision 5): with no stated window the status row
138
+ * shows `ctx ?`, yet the compaction tiers still assume the fallback (128K
139
+ * since CW-1 batch 2)
140
+ * — the assumption is said out loud once, at agent build. */
141
+ export declare function unknownWindowNotice(model: string): string;
142
+ /** CW-1 batch 2: said once, when an endpoint's refusal states its cap — the
143
+ * tiers aim below it from the next request, and later sessions start on it. */
144
+ export declare function windowLearnedNotice(model: string, host: string, tokens: number): string;
145
+ /** ADR-0055 Amendment 2: the notice for a checkpoint the shrink invariant
146
+ * discarded — chars/4 sizes only; the checkpoint's text is the owner's
147
+ * work and never reaches the screen. */
148
+ export declare function compactionDiscardedNotice(d: {
149
+ readonly pre: number;
150
+ readonly post: number;
151
+ readonly summary: number;
152
+ }): string;
103
153
  export declare function displayCtxRatio(session: AgentSession): number;
104
154
  /** 0.40.0 item 9: how long a prompt cache is assumed to live. PROVISIONAL:
105
155
  * DeepSeek does not publish its TTL (the owner's cache was gone after 27
@@ -219,6 +269,28 @@ export declare function turnUsageLedger(): {
219
269
  report: (u: RunUsage) => void;
220
270
  total: () => RunUsage | null;
221
271
  };
272
+ /**
273
+ * The per-CALL view of usage reports for the two figures that must not
274
+ * double: the cache-miss estimate and the session's running cost.
275
+ *
276
+ * A call may report its usage more than once — the op gateway sent two
277
+ * usage chunks for one request (the owner's session
278
+ * 2026-09-23T03-00-01-230c: `fresh 3k · miss 3k` on a first turn, both
279
+ * reports 3,041 in / 69 out). The ledger above already makes a call's
280
+ * LATEST report replace the earlier; the miss and the cost were read per
281
+ * EVENT, so the second report compared itself with the first and called
282
+ * the whole prompt a miss, and a priced route would have been charged
283
+ * twice. Here the carrier is the PREVIOUS call's total, and a call's cost
284
+ * is added as the difference from what that call already added.
285
+ */
286
+ export declare function callUsageMeter(): {
287
+ /** what a report's miss is measured against: the previous call's total */
288
+ carrier: () => number | null;
289
+ /** one report of the current call; returns the cost to ADD (or null) */
290
+ report: (total: number | null, costUsd: number | null) => number | null;
291
+ /** a stop ends the call */
292
+ endCall: () => void;
293
+ };
222
294
  /** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
223
295
  * is gone) — docked only, 200ms rotation between the request and the
224
296
  * first event. */
package/dist/chat.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * estimates. All bodies moved verbatim from index.ts.
6
6
  */
7
7
  import { readFileSync, statSync } from "node:fs";
8
- import { escapeTerminal, cacheHitPct, decodeRate, idleStatus, palette, renderEvent, renderRecap, runningStatus, toolTarget, verifyOfferView, STATUS_GLYPHS, } from "@vincemakes/kiso-tui";
8
+ import { escapeTerminal, cacheHitPct, decodeRate, idleStatus, palette, renderEvent, renderRecap, runningStatus, toolTarget, verifyOfferView, STATUS_GLYPHS, kUnit, } from "@vincemakes/kiso-tui";
9
9
  import { askView, coldResumeLine, coldResumeView, deletionRiskHint, editFileDiff, writeFileDiff } from "@vincemakes/kiso-tui";
10
10
  import { canonicalTargetPath, isProtectedPath, protectedIdentity, shellProgressPath } from "@vincemakes/kiso-tools-node";
11
11
  import { queuedSwitchLines } from "./state.js";
@@ -14,14 +14,19 @@ import { canonicalizeUsage } from "@vincemakes/kiso-runtime";
14
14
  import { canonicalizeUsageForModel, requestBudget } from "@vincemakes/kiso-runtime/internal";
15
15
  import { dispatch, abortBangCommand } from "./dispatch.js";
16
16
  import { paintWindowTitle } from "./window-title.js";
17
- import { agentBaseUrl, agentModel, body, bodyLog, configuredWindow, dock, retryOnRow, retryShown, setRetryShown, floorOn, protectedFiles } from "./state.js";
17
+ import { agentBaseUrl, agentModel, body, bodyLog, configuredWindow, dock, retryOnRow, retryShown, setRetryShown, floorOn, protectedFiles, upstreamOf } from "./state.js";
18
18
  import { attachImages } from "./attachments.js";
19
- import { lookupModelMetadata } from "@vincemakes/kiso-runtime/internal";
19
+ import { learnedWindowFor } from "./learned-windows.js";
20
+ import { lookupContextWindow, lookupModelMetadata } from "@vincemakes/kiso-runtime/internal";
20
21
  import { addDontAskAgainRule, askPanel, fixHintFor, pendingAsk, resolveUncertains } from "./trust-ui.js";
21
22
  import { FauxExhaustionError, failOnFauxExhaustion } from "./faux-glue.js";
22
23
  import { OFFERED_MODES, getMode, setMode } from "./mode.js";
23
- /** B area: default context window for the ~ctx estimate (config overridable). */
24
- const DEFAULT_CONTEXT_WINDOW = 200_000;
24
+ /** B area: default context window for the ~ctx estimate (config overridable).
25
+ * CW-1 batch 2: 128,000, down from 200,000 — the figure a model nobody
26
+ * states a window for is assumed to hold (the reference implementation's
27
+ * default too). Too small costs an early compaction; too large costs a
28
+ * refused request on a 128K model. Registered models never reach it. */
29
+ const DEFAULT_CONTEXT_WINDOW = 128_000;
25
30
  /** The in-process fake provider's id, and the window we declare for it.
26
31
  * Ours to state: faux is not a vendor's model, so "nobody published a
27
32
  * window" — the reason a real model's capacity is unknown — cannot apply.
@@ -61,7 +66,9 @@ export function autoCompactFromEnv() {
61
66
  * THE WINDOW SOMEBODY STATED, or null when nobody has.
62
67
  *
63
68
  * Three sources, in order: the config/profile window, KISO_CONTEXT_WINDOW,
64
- * and the metadata registry. Each is a claim someone made and dated. When
69
+ * and the metadata registry (CW-1: this endpoint's row, the upstream's,
70
+ * then the model's own — see lookupContextWindow). Each is a claim
71
+ * someone made and dated. When
65
72
  * none of them speaks, this returns NULL rather than a number — because
66
73
  * the question "how much of the window is left" has no answer without a
67
74
  * window, and every display that shows a percentage needs this one, not
@@ -73,11 +80,21 @@ export function autoCompactFromEnv() {
73
80
  * confident `ctx left ~82%` against a figure nobody had measured.
74
81
  */
75
82
  export function knownContextWindow(of) {
76
- if (configuredWindow !== undefined)
77
- return configuredWindow;
83
+ return statedContextWindow(of)?.tokens ?? null;
84
+ }
85
+ /** CW-1: `knownContextWindow` with its source — the chain the displays name.
86
+ * The user's figure is the live binding's unless `own` carries a
87
+ * profile's: the /model listing passes each row's, and a row with none
88
+ * must not borrow the live one (`own: { configured: undefined }` — an
89
+ * optional parameter defaulted to the live value would take it back,
90
+ * since an explicit undefined selects the default). */
91
+ export function statedContextWindow(of, own) {
92
+ const configured = own !== undefined ? own.configured : configuredWindow;
93
+ if (configured !== undefined)
94
+ return { tokens: configured, source: "set" };
78
95
  const env = Number.parseInt(process.env.KISO_CONTEXT_WINDOW ?? "", 10);
79
96
  if (Number.isFinite(env) && env > 0)
80
- return env;
97
+ return { tokens: env, source: "set" };
81
98
  const model = of?.model ?? agentModel;
82
99
  // `faux` is OURS. The registry carries no row for it because it is not a
83
100
  // vendor's model, but the reason the window is unknown elsewhere — nobody
@@ -86,9 +103,47 @@ export function knownContextWindow(of) {
86
103
  // keeps faux mode showing a real percentage instead of the `ctx ?` that
87
104
  // belongs to models whose capacity genuinely nobody states.
88
105
  if (model === FAUX_MODEL)
89
- return FAUX_CONTEXT_WINDOW;
90
- const known = lookupModelMetadata(model, of !== undefined ? of.baseUrl : agentBaseUrl)?.capabilities.contextWindow;
91
- return known ?? null;
106
+ return { tokens: FAUX_CONTEXT_WINDOW, source: "faux" };
107
+ const baseUrl = of !== undefined ? of.baseUrl : agentBaseUrl;
108
+ // CW-1 batch 2: what this endpoint's own refusal stated, before any
109
+ // statement about the model — a measurement of the route.
110
+ const learned = learnedWindowFor(model, baseUrl);
111
+ if (learned !== undefined)
112
+ return { tokens: learned.tokens, source: "learned", observedAt: learned.observedAt };
113
+ return lookupContextWindow(model, baseUrl, of?.upstream ?? upstreamOf(baseUrl));
114
+ }
115
+ /** CW-1: a window as the displays write it — `1M`, `1.05M`, `272K`,
116
+ * `1,048,576` (a figure that is not round is shown whole). */
117
+ export function windowLabel(tokens) {
118
+ if (tokens >= 1_000_000 && tokens % 10_000 === 0)
119
+ return `${tokens / 1_000_000}M`;
120
+ if (tokens % 1000 === 0)
121
+ return `${tokens / 1000}K`;
122
+ return tokens.toLocaleString("en-US");
123
+ }
124
+ /** CW-1: the source, said. The short form rides a /model row; the long
125
+ * form is /status's. Only the model step is marked in the short form —
126
+ * it is the one figure no row states for this endpoint. */
127
+ export function windowSourceNote(w, form) {
128
+ if (w === null)
129
+ return form === "short" ? "ctx ?" : `window unknown — compaction assumes ${windowLabel(DEFAULT_CONTEXT_WINDOW)}; set contextWindow on the profile to state it`;
130
+ const size = windowLabel(w.tokens);
131
+ if (form === "short")
132
+ return w.source === "model" ? `ctx ${size} inferred` : `ctx ${size}`;
133
+ switch (w.source) {
134
+ case "set":
135
+ return `window ${size}, as you set it`;
136
+ case "faux":
137
+ return `window ${size} (faux)`;
138
+ case "learned":
139
+ return `window ${size}, learned from this endpoint's refusal (${w.observedAt ?? "date unknown"})`;
140
+ case "route":
141
+ return `window ${size}, the registry's for this endpoint`;
142
+ case "upstream":
143
+ return `window ${size}, the registry's for the upstream`;
144
+ case "model":
145
+ return `window ${size}, inferred from the model (${w.from}) — not stated for this endpoint`;
146
+ }
92
147
  }
93
148
  export function contextWindowTokens(of) {
94
149
  const windowOverride = configuredWindow;
@@ -112,9 +167,21 @@ export function contextWindowTokens(of) {
112
167
  // asked again. The claim is true now because the switch path calls
113
168
  // `microcompactThresholdFor` below, not because deriving a number
114
169
  // from this function makes anything follow it.
115
- const known = lookupModelMetadata(of?.model ?? agentModel, of !== undefined ? of.baseUrl : agentBaseUrl)?.capabilities.contextWindow;
116
- if (known !== undefined && known !== null)
117
- return known;
170
+ //
171
+ // CW-1: the registry's steps are lookupContextWindow's — the route's row,
172
+ // the upstream's, then the model's own identity — the same chain
173
+ // `statedContextWindow` names for the displays.
174
+ const baseUrl = of !== undefined ? of.baseUrl : agentBaseUrl;
175
+ // CW-1 batch 2: faux is declared here too — it read the old 200K
176
+ // fallback by accident, and the fallback moving must not move it.
177
+ if ((of?.model ?? agentModel) === FAUX_MODEL)
178
+ return FAUX_CONTEXT_WINDOW;
179
+ const learned = learnedWindowFor(of?.model ?? agentModel, baseUrl);
180
+ if (learned !== undefined)
181
+ return learned.tokens;
182
+ const known = lookupContextWindow(of?.model ?? agentModel, baseUrl, upstreamOf(baseUrl));
183
+ if (known !== null)
184
+ return known.tokens;
118
185
  return DEFAULT_CONTEXT_WINDOW;
119
186
  }
120
187
  /**
@@ -160,10 +227,32 @@ export function statusModelLabel(session) {
160
227
  // identity is asked for (`/status`), not in a row that has ~40 columns and
161
228
  // cut the host to `deepseek/d…ndcode.ai`. Unnecessary at best, misleading at
162
229
  // worst: it read as a path, not as an account.
163
- return effort !== undefined && effort !== "default" ? `${agentModel} · ${effort}` : agentModel;
230
+ // The owner, 2026-09-22: the row shows the MODEL's name only — a vendor
231
+ // prefix (`deepseek/deepseek-v4.1-flash`) cost the row its tail. The full
232
+ // id stays in `/status` and `/model`, where identity is asked for.
233
+ const name = agentModel.slice(agentModel.lastIndexOf("/") + 1) || agentModel;
234
+ return effort !== undefined && effort !== "default" ? `${name} · ${effort}` : name;
235
+ }
236
+ /** ADR-0055 Amendment 2 (decision 5): with no stated window the status row
237
+ * shows `ctx ?`, yet the compaction tiers still assume the fallback (128K
238
+ * since CW-1 batch 2)
239
+ * — the assumption is said out loud once, at agent build. */
240
+ export function unknownWindowNotice(model) {
241
+ return `[kiso] context window unknown for ${model} at this endpoint — compaction assumes ${DEFAULT_CONTEXT_WINDOW / 1000}K; set contextWindow on the profile to state it`;
242
+ }
243
+ /** CW-1 batch 2: said once, when an endpoint's refusal states its cap — the
244
+ * tiers aim below it from the next request, and later sessions start on it. */
245
+ export function windowLearnedNotice(model, host, tokens) {
246
+ return `✦ window learned — ${host === "" ? "the endpoint" : host} refused ${model} past ${windowLabel(tokens)} tokens; compaction now aims below it`;
247
+ }
248
+ /** ADR-0055 Amendment 2: the notice for a checkpoint the shrink invariant
249
+ * discarded — chars/4 sizes only; the checkpoint's text is the owner's
250
+ * work and never reaches the screen. */
251
+ export function compactionDiscardedNotice(d) {
252
+ return `✦ compaction discarded — the checkpoint did not shrink the context (~${kUnit(d.pre)} → ~${kUnit(d.post)}; it wrote ~${kUnit(d.summary)})`;
164
253
  }
165
254
  export function displayCtxRatio(session) {
166
- // CAPACITY is not POLICY. `contextWindowTokens` falls back to 200,000 so
255
+ // CAPACITY is not POLICY. `contextWindowTokens` falls back to 128,000 so
167
256
  // that the compaction threshold always HAS a value — a policy needs a
168
257
  // number. A percentage on screen is a different kind of thing: it is a
169
258
  // claim about the model, and an unstated window makes it unanswerable.
@@ -369,6 +458,43 @@ export function turnUsageLedger() {
369
458
  },
370
459
  };
371
460
  }
461
+ /**
462
+ * The per-CALL view of usage reports for the two figures that must not
463
+ * double: the cache-miss estimate and the session's running cost.
464
+ *
465
+ * A call may report its usage more than once — the op gateway sent two
466
+ * usage chunks for one request (the owner's session
467
+ * 2026-09-23T03-00-01-230c: `fresh 3k · miss 3k` on a first turn, both
468
+ * reports 3,041 in / 69 out). The ledger above already makes a call's
469
+ * LATEST report replace the earlier; the miss and the cost were read per
470
+ * EVENT, so the second report compared itself with the first and called
471
+ * the whole prompt a miss, and a priced route would have been charged
472
+ * twice. Here the carrier is the PREVIOUS call's total, and a call's cost
473
+ * is added as the difference from what that call already added.
474
+ */
475
+ export function callUsageMeter() {
476
+ let prevCallTotal = null;
477
+ let callTotal = null;
478
+ let callCost = null;
479
+ return {
480
+ carrier: () => prevCallTotal,
481
+ report(total, costUsd) {
482
+ if (total !== null)
483
+ callTotal = total;
484
+ if (costUsd === null)
485
+ return null;
486
+ const add = costUsd - (callCost ?? 0);
487
+ callCost = costUsd;
488
+ return add;
489
+ },
490
+ endCall() {
491
+ if (callTotal !== null)
492
+ prevCallTotal = callTotal;
493
+ callTotal = null;
494
+ callCost = null;
495
+ },
496
+ };
497
+ }
372
498
  /** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
373
499
  * is gone) — docked only, 200ms rotation between the request and the
374
500
  * first event. */
@@ -791,7 +917,7 @@ submitTurn) {
791
917
  // and read at that call's usage event. Per CALL, not per turn — a turn
792
918
  // with three model calls reports the third, and each one times itself.
793
919
  let callFirstEventAt = null;
794
- let prevTotal = null;
920
+ const meter = callUsageMeter();
795
921
  let missed = null;
796
922
  // v3 §02: the recap line derives ENTIRELY from the local event stream
797
923
  // (zero tokens). R3g: what it derives is the turn's COST — wall
@@ -828,6 +954,8 @@ submitTurn) {
828
954
  // the stream watchdog and the repeated-failure breaker remain active.
829
955
  // W22-R1: the ledger sees every event; a stop is what ends a call.
830
956
  ledger.observe(ev);
957
+ if (ev.type === "stop")
958
+ meter.endCall();
831
959
  // ADR-0055 Amendment 1 (A1b): a compaction inside the run says so, once.
832
960
  if (ev.type === "summarized" || ev.type === "microcompacted") {
833
961
  const r = displayCtxRatio(session);
@@ -944,11 +1072,12 @@ submitTurn) {
944
1072
  body.notice("stream interrupted — the draft above is abandoned");
945
1073
  break;
946
1074
  case "usage": {
947
- const delta = usageFromEvent(session.provider, ev, prevTotal, agentModel, session.baseUrl);
1075
+ const delta = usageFromEvent(session.provider, ev, meter.carrier(), agentModel, session.baseUrl);
948
1076
  // W22-R1: this call's LATEST report replaces its earlier one;
949
- // the turn's figure is the settled calls plus this one.
1077
+ // the turn's figure is the settled calls plus this one. The
1078
+ // miss and the cost follow the same rule (callUsageMeter).
950
1079
  ledger.report(delta.usage);
951
- prevTotal = delta.total;
1080
+ const costToAdd = meter.report(delta.total, delta.costUsd);
952
1081
  missed = delta.missed;
953
1082
  // TUI2-R1 (E): the request's canonical cost rides the same
954
1083
  // callback the usage does — one settled request, one addition.
@@ -957,7 +1086,7 @@ submitTurn) {
957
1086
  // second call is timed from ITS own first event.
958
1087
  const tokPerSec = callFirstEventAt === null ? null : decodeRate(ev.outputTokens, Date.now() - callFirstEventAt);
959
1088
  callFirstEventAt = null;
960
- statusCb?.(turnUsage() ?? UNKNOWN_USAGE, displayCtxRatio(session), delta.costUsd, tokPerSec);
1089
+ statusCb?.(turnUsage() ?? UNKNOWN_USAGE, displayCtxRatio(session), costToAdd, tokPerSec);
961
1090
  break;
962
1091
  }
963
1092
  case "uncertain_pending":
package/dist/config.d.ts CHANGED
@@ -11,7 +11,8 @@
11
11
  * direct write (provider: "openai-compat" |
12
12
  * "anthropic" | "openai-responses")
13
13
  * models?: { [name]: { kind: "openai-compat"|"anthropic"|"openai-responses",
14
- * baseUrl?: string, model: string, apiKeyEnv: string } }
14
+ * baseUrl?: string, model: string, apiKeyEnv: string,
15
+ * upstream?: string } } — upstream: never where requests go
15
16
  * mode?: "manual"|"default"|"accept-edits"|"plan"|"bypass"|"dontAsk"
16
17
  * contextWindow?: number — tokens
17
18
  * autoCompact?: { thresholdRatio: number } — 0<r<1; default off
@@ -64,6 +65,16 @@ export interface ModelProfile {
64
65
  * registry, where every figure has to carry a source.
65
66
  */
66
67
  readonly contextWindow?: number;
68
+ /**
69
+ * What this endpoint FORWARDS to, when `baseUrl` is a local proxy — a URL
70
+ * (`https://gateway.example/v1`) or a name (`my gateway`). It never
71
+ * moves a request — they still go to `baseUrl`. The owner, 2026-09-23: a
72
+ * profile behind a local forwarder showed `@127.0.0.1:47821`, which says
73
+ * where the bytes go and not who is billed; the row now reads
74
+ * `@gateway.example via 127.0.0.1:47821`. CW-1: a URL here is also where
75
+ * the registry looks for this model's window when `baseUrl` has no row.
76
+ */
77
+ readonly upstream?: string;
67
78
  }
68
79
  export interface AutoCompactConfig {
69
80
  readonly thresholdRatio: number;
package/dist/config.js CHANGED
@@ -11,7 +11,8 @@
11
11
  * direct write (provider: "openai-compat" |
12
12
  * "anthropic" | "openai-responses")
13
13
  * models?: { [name]: { kind: "openai-compat"|"anthropic"|"openai-responses",
14
- * baseUrl?: string, model: string, apiKeyEnv: string } }
14
+ * baseUrl?: string, model: string, apiKeyEnv: string,
15
+ * upstream?: string } } — upstream: never where requests go
15
16
  * mode?: "manual"|"default"|"accept-edits"|"plan"|"bypass"|"dontAsk"
16
17
  * contextWindow?: number — tokens
17
18
  * autoCompact?: { thresholdRatio: number } — 0<r<1; default off
@@ -146,6 +147,8 @@ export function parseConfig(text, source) {
146
147
  // your profile") had never done anything.
147
148
  if (p.contextWindow !== undefined && (typeof p.contextWindow !== "number" || !Number.isFinite(p.contextWindow) || p.contextWindow <= 0))
148
149
  fail(`models.${name}.contextWindow`, "expected a positive token count");
150
+ if (p.upstream !== undefined && (typeof p.upstream !== "string" || p.upstream.trim() === ""))
151
+ fail(`models.${name}.upstream`, "expected the URL or name of what this endpoint forwards to");
149
152
  models[name] = {
150
153
  kind: p.kind,
151
154
  model: p.model,
@@ -154,6 +157,7 @@ export function parseConfig(text, source) {
154
157
  ...(typeof p.promptCaching === "boolean" ? { promptCaching: p.promptCaching } : {}),
155
158
  ...(typeof p.streamIdleMs === "number" ? { streamIdleMs: p.streamIdleMs } : {}),
156
159
  ...(typeof p.contextWindow === "number" ? { contextWindow: p.contextWindow } : {}),
160
+ ...(typeof p.upstream === "string" ? { upstream: p.upstream.trim() } : {}),
157
161
  };
158
162
  }
159
163
  out.models = models;
package/dist/dispatch.js CHANGED
@@ -9,11 +9,11 @@ import { buildAdapter, lookupModelMetadata, resolveContinuationScope, resolveRea
9
9
  import { MODES, MODE_NOTE, OFFERED_MODES, getMode, setMode } from "./mode.js";
10
10
  import { clipboardWrite, lastAnswer } from "./clipboard.js";
11
11
  import { protectedBangReason, protectedShellVerdict } from "./protected-shell.js";
12
- import { agentBaseUrl, currentProfileName, setCurrentProfileName, currentModelName, agentModel, body, bodyLog, codingToolOptions, protectedFiles, kisoHome, configModels, dock, lastBinding, loadedSkillsCatalog, mergedConfig, readContextLedger, retryOnRow, sessionsDir, setAgentModel, setConfiguredWindow, setCurrentModelName, setModelChoice, setRetryShown, setLastBinding } from "./state.js";
12
+ import { agentBaseUrl, currentProfileName, setCurrentProfileName, currentModelName, agentModel, body, bodyLog, codingToolOptions, protectedFiles, kisoHome, configModels, dock, lastBinding, loadedSkillsCatalog, mergedConfig, readContextLedger, retryOnRow, sessionsDir, setAgentModel, setConfiguredWindow, setCurrentModelName, setModelChoice, setRetryShown, upstreamOf, setLastBinding } from "./state.js";
13
13
  import { adapterOptionsFor } from "./auth/adapter-options.js";
14
14
  import { profileProviderLabel, providerLabel } from "./provider-label.js";
15
15
  import { queuedSwitchLines } from "./state.js";
16
- import { contextWindowTokens, microcompactThresholdFor, startStatusSpinner } from "./chat.js";
16
+ import { contextWindowTokens, microcompactThresholdFor, startStatusSpinner, statedContextWindow, windowSourceNote } from "./chat.js";
17
17
  import { authForProfile, directWriteProfile, profileAvailable, resolveContextWindow, unavailableReason } from "./config.js";
18
18
  import { shellTool } from "@vincemakes/kiso-tools-node";
19
19
  import { dirname, join } from "node:path";
@@ -109,6 +109,14 @@ function signInNote(p) {
109
109
  }
110
110
  return p.apiKeyEnv ?? "no key";
111
111
  }
112
+ /** CW-1 (owner, 2026-09-23): a profile's window on its /model row — the
113
+ * profile's own stated figure first, then the registry's chain for its
114
+ * model, endpoint and upstream; `inferred` marks the one no row states
115
+ * for that endpoint. */
116
+ function windowNoteOf(p) {
117
+ const w = statedContextWindow({ model: p.model, ...(p.baseUrl !== undefined ? { baseUrl: p.baseUrl } : {}), ...(p.upstream !== undefined ? { upstream: p.upstream } : {}) }, { configured: resolveContextWindow(mergedConfig, p) });
118
+ return windowSourceNote(w, "short");
119
+ }
112
120
  /** The ONE dispatcher — slash commands, exit, and turns. The recovery
113
121
  * replay routes through it too — a queued "/last" must never become a
114
122
  * user turn (v2c: the rl lives in main, so lines arrive earlier and the
@@ -487,7 +495,10 @@ export function dispatch(line, ctx) {
487
495
  const ctxPct = Number.isFinite(ctxRatio) ? `~${Math.round(ctxRatio * 100)}%` : "~?";
488
496
  bodyLog(`session ${ctx.session.id}`);
489
497
  bodyLog(`${ctx.session.log.all.length} events`);
490
- bodyLog(`ctx ${ctxPct}`);
498
+ // CW-1: the percentage names its denominator and who stated it — a
499
+ // window inferred from the model reads differently from one the
500
+ // registry states for this endpoint.
501
+ bodyLog(`ctx ${ctxPct} · ${windowSourceNote(statedContextWindow(), "long")}`);
491
502
  // The owner, 2026-09-21: /status is where "what am I actually
492
503
  // running on" is answered, so the identity is spelled out HERE —
493
504
  // the model, the host the request goes to, the profile it came
@@ -502,7 +513,7 @@ export function dispatch(line, ctx) {
502
513
  // restored, not switched. (The real fix is to stop overloading that
503
514
  // variable; until then, a label that cannot lie.)
504
515
  const profileOf = currentProfileName === null ? "" : ` · profile ${currentProfileName}`;
505
- bodyLog(`model ${agentModel}${providerLabel(agentBaseUrl)}${profileOf}`);
516
+ bodyLog(`model ${agentModel}${providerLabel(agentBaseUrl, upstreamOf(agentBaseUrl))}${profileOf}`);
506
517
  ctx.input.prompt();
507
518
  });
508
519
  return;
@@ -607,8 +618,10 @@ export function dispatch(line, ctx) {
607
618
  // profile key is what `/model <name>` takes, not what a chooser
608
619
  // reading a list needs. What remains is what the chooser CANNOT
609
620
  // infer: availability, and which one is live.
610
- const marks = [...(profileAvailable(profile) ? [] : ["unavailable"]), ...(name === currentProfileName ? ["current"] : [])];
611
- const host = profileProviderLabel(profile.kind, profile.baseUrl);
621
+ // CW-1: the window rides LAST — availability and the live mark
622
+ // are what a narrow row must keep.
623
+ const marks = [...(profileAvailable(profile) ? [] : ["unavailable"]), ...(name === currentProfileName ? ["current"] : []), windowNoteOf(profile)];
624
+ const host = profileProviderLabel(profile.kind, profile.baseUrl, profile.upstream);
612
625
  return { label: `${profile.kind}/${profile.model}${host === "" ? "" : ` ${host}`}`, note: marks.join(" · "), ...effortAxis(profile) };
613
626
  }),
614
627
  // PH-1a (finding PH-F4): the example must be a syntax
@@ -658,8 +671,8 @@ export function dispatch(line, ctx) {
658
671
  else {
659
672
  for (const name of names) {
660
673
  const p = configModels[name];
661
- const hostOf = profileProviderLabel(p.kind, p.baseUrl);
662
- bodyLog(` ${name} → ${p.kind}/${p.model}${hostOf === "" ? "" : ` ${hostOf}`} · ${signInNote(p)} ${profileAvailable(p) ? "(available)" : "(unavailable)"} · ${effortNote(p)}`);
674
+ const hostOf = profileProviderLabel(p.kind, p.baseUrl, p.upstream);
675
+ bodyLog(` ${name} → ${p.kind}/${p.model}${hostOf === "" ? "" : ` ${hostOf}`} · ${signInNote(p)} ${profileAvailable(p) ? "(available)" : "(unavailable)"} · ${effortNote(p)} · ${windowNoteOf(p)}`);
663
676
  }
664
677
  }
665
678
  bodyLog("switch with /model <profile-name|provider/model>");
@@ -775,7 +788,7 @@ export function dispatch(line, ctx) {
775
788
  // (and the owner's) read `gpt-6-astra · CH 92%`, the new
776
789
  // model beside the previous model's figure.
777
790
  ctx.modelSwitched();
778
- body.notice(`model → ${profName} (${profile.model}${providerLabel(profile.baseUrl) === "" ? "" : ` ${providerLabel(profile.baseUrl)}`}${effortTok !== undefined ? ` · ${effortTok}` : ""}) — takes effect on the next turn`);
791
+ body.notice(`model → ${profName} (${profile.model}${providerLabel(profile.baseUrl, profile.upstream) === "" ? "" : ` ${providerLabel(profile.baseUrl, profile.upstream)}`}${effortTok !== undefined ? ` · ${effortTok}` : ""}) — takes effect on the next turn`);
779
792
  }
780
793
  }
781
794
  }
@@ -0,0 +1,22 @@
1
+ export interface EmptySession {
2
+ /** The session folder (a project folder, or the legacy sessions dir). */
3
+ readonly dir: string;
4
+ readonly id: string;
5
+ /** The files that name it, relative to `dir`. */
6
+ readonly files: readonly string[];
7
+ }
8
+ export interface EmptyScan {
9
+ readonly empty: readonly EmptySession[];
10
+ /** Empty by the files, but a live process holds the lock. */
11
+ readonly inUse: number;
12
+ }
13
+ /** A lock whose pid answers signal 0 belongs to a running process. */
14
+ export declare function pidAlive(pid: number): boolean;
15
+ /** Every session in `dirs` with a sidecar and no log. Reads only. */
16
+ export declare function findEmptySessions(dirs: readonly string[], alive?: (pid: number) => boolean): EmptyScan;
17
+ /** The platform's Trash: ~/.Trash on macOS, the freedesktop one elsewhere. */
18
+ export declare function defaultTrashRoot(): string;
19
+ /** Move the sessions into one new folder under `trashRoot`, keeping each
20
+ * session folder's name; returns that folder. Never deletes: a file that
21
+ * cannot be renamed across devices is copied, then unlinked. */
22
+ export declare function moveToTrash(sessions: readonly EmptySession[], trashRoot: string, now?: Date): string;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * DC-60 — the empty sessions an older kiso left behind.
3
+ *
4
+ * Until DC-60 every process that opened a session wrote its sidecar (and,
5
+ * through the start-up recovery, a trace) whether or not anything ever
6
+ * happened in it: a started-and-closed kiso, a /resume that went nowhere, a
7
+ * delegated child that failed before its first turn. Each left a sidecar
8
+ * with no log, and each showed up in /resume as a card with no summary.
9
+ * The owner's disk held 28 on 2026-09-23.
10
+ *
11
+ * `kiso sessions --prune-empty` lists them. With `--yes` it MOVES them to
12
+ * the Trash — never deletes — keeping each project folder's name, so a
13
+ * mistake is a drag back. A session whose lock names a live process is
14
+ * skipped: something is using it right now.
15
+ */
16
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { basename, join } from "node:path";
19
+ const SIDECAR = ".meta.json";
20
+ /** A lock whose pid answers signal 0 belongs to a running process. */
21
+ export function pidAlive(pid) {
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ }
26
+ catch (err) {
27
+ return err.code === "EPERM";
28
+ }
29
+ }
30
+ function lockPid(path) {
31
+ try {
32
+ const pid = JSON.parse(readFileSync(path, "utf8")).pid;
33
+ return typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? pid : null;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ /** Every session in `dirs` with a sidecar and no log. Reads only. */
40
+ export function findEmptySessions(dirs, alive = pidAlive) {
41
+ const empty = [];
42
+ let inUse = 0;
43
+ for (const dir of dirs) {
44
+ let names;
45
+ try {
46
+ names = readdirSync(dir);
47
+ }
48
+ catch {
49
+ continue;
50
+ }
51
+ for (const name of names) {
52
+ if (!name.endsWith(SIDECAR))
53
+ continue;
54
+ const id = name.slice(0, -SIDECAR.length);
55
+ if (names.includes(`${id}.jsonl`))
56
+ continue;
57
+ const lock = `${id}.lock`;
58
+ if (names.includes(lock)) {
59
+ const pid = lockPid(join(dir, lock));
60
+ if (pid !== null && alive(pid)) {
61
+ inUse += 1;
62
+ continue;
63
+ }
64
+ }
65
+ const files = [name];
66
+ if (names.includes(lock))
67
+ files.push(lock);
68
+ if (existsSync(join(dir, "traces", `${id}.jsonl`)))
69
+ files.push(join("traces", `${id}.jsonl`));
70
+ empty.push({ dir, id, files });
71
+ }
72
+ }
73
+ return { empty, inUse };
74
+ }
75
+ /** The platform's Trash: ~/.Trash on macOS, the freedesktop one elsewhere. */
76
+ export function defaultTrashRoot() {
77
+ if (process.platform === "darwin")
78
+ return join(homedir(), ".Trash");
79
+ return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "Trash", "files");
80
+ }
81
+ /** Move the sessions into one new folder under `trashRoot`, keeping each
82
+ * session folder's name; returns that folder. Never deletes: a file that
83
+ * cannot be renamed across devices is copied, then unlinked. */
84
+ export function moveToTrash(sessions, trashRoot, now = new Date()) {
85
+ const target = join(trashRoot, `kiso-empty-sessions-${now.toISOString().replace(/[:.]/g, "-")}`);
86
+ for (const s of sessions) {
87
+ for (const file of s.files) {
88
+ const to = join(target, basename(s.dir), file);
89
+ mkdirSync(join(to, ".."), { recursive: true });
90
+ try {
91
+ renameSync(join(s.dir, file), to);
92
+ }
93
+ catch (err) {
94
+ if (err.code !== "EXDEV")
95
+ throw err;
96
+ copyFileSync(join(s.dir, file), to);
97
+ unlinkSync(join(s.dir, file));
98
+ }
99
+ }
100
+ }
101
+ return target;
102
+ }
package/dist/index.js CHANGED
@@ -29,13 +29,14 @@ import { newSessionId } from "./session-id.js";
29
29
  import { createInterface } from "node:readline";
30
30
  import { Readable } from "node:stream";
31
31
  import { fileURLToPath } from "node:url";
32
- import { join } from "node:path";
32
+ import { basename, join } from "node:path";
33
33
  import { Body, Editor, PROMPT, bannerLines, currentGround, resolveGround, setGround, escapeTerminal, extensionsBannerText, idColumn, idleStatus, interactivePrompt, palette, renderSessionLine, sessionListFooter, sessionListHeader, sessionListRow, sessionListUnknownLine, slashCommandNames } from "@vincemakes/kiso-tui";
34
34
  import { createAgent, disposeExtensions, loadExtensions, loadProjectExtensions, SessionStore, } from "@vincemakes/kiso-runtime";
35
35
  import { listSessionSidecars, migrateSummaries, readProfile, runsACheck, summaryMigrationPending } from "@vincemakes/kiso-runtime/internal";
36
36
  import { skillMenuItems } from "./skill-invoke.js";
37
37
  import { canonicalPath, claimProjectDir, hasSession, locateSession, projectLayoutActive, sessionFolders } from "./projects.js";
38
38
  import { migrationNotice, pendingLegacyIds, planMigration, reverseMigration, runMigration } from "./session-migration.js";
39
+ import { defaultTrashRoot, findEmptySessions, moveToTrash } from "./empty-sessions.js";
39
40
  import { createFauxProvider } from "@vincemakes/kiso-evals";
40
41
  import { createCodingTools, isProtectedPath, protectedIdentity, PROTECTED_REFUSAL } from "@vincemakes/kiso-tools-node";
41
42
  import { MODES, OFFERED_MODES, getMode, modeExtensions, modeFromEnv, modeSystemPrompt, setMode } from "./mode.js";
@@ -50,7 +51,9 @@ import { maxRetriesFromEnv } from "./retries.js";
50
51
  import { askUi, resolveProjectTrust } from "./trust-ui.js";
51
52
  import { isFirstRun, scaffoldFirstRun } from "./first-run.js";
52
53
  import { fauxSkip, readFauxScript } from "./faux-glue.js";
53
- import { chat, contextWindowTokens, displayCtxRatio, microcompactThresholdFor, statusModelLabel } from "./chat.js";
54
+ import { chat, compactionDiscardedNotice, contextWindowTokens, displayCtxRatio, knownContextWindow, microcompactThresholdFor, statusModelLabel, unknownWindowNotice, windowLearnedNotice } from "./chat.js";
55
+ import { recordLearnedWindow, useLearnedWindows } from "./learned-windows.js";
56
+ import { providerHost } from "./provider-label.js";
54
57
  import { adapterOptionsFor } from "./auth/adapter-options.js";
55
58
  import { loadProjectConfig, loadUserConfig, mergeConfigs, resolveAutoCompact, resolveContextWindow, resolveModel } from "./config.js";
56
59
  import { checkForUpdate, knownUpdate, updateCardLines } from "./update-check.js";
@@ -775,6 +778,9 @@ async function makeAgent(sessionId, input, modelFlag) {
775
778
  // shell tool would then write into this project's folder.
776
779
  process.env.KISO_DELEGATION_CONFIG_JSON = JSON.stringify({ checks: merged.checks ?? {}, profiles: Object.keys(merged.models ?? {}), sessionsDir: sessionsDir() });
777
780
  setConfigModels(merged.models ?? {});
781
+ // CW-1 batch 2: the windows endpoints stated by refusing — read before the
782
+ // first window is asked for (the unknown-window notice below).
783
+ useLearnedWindows();
778
784
  const resolved = resolveModel(modelFlag, merged);
779
785
  // AFTER the model resolves: the window a PROFILE states is about that
780
786
  // profile's model, so it cannot be read before we know which profile is
@@ -794,6 +800,13 @@ async function makeAgent(sessionId, input, modelFlag) {
794
800
  setCurrentProfileName(resolved.name);
795
801
  }
796
802
  setAgentModel(model, resolved?.profile.baseUrl); // v2b: the status bar shows it; OR-1: the endpoint rides along
803
+ // ADR-0055 Amendment 2: the row says `ctx ?` for an unstated window, but
804
+ // the tiers still need a number and assume the fallback — say so, once,
805
+ // at build.
806
+ if (!unknownWindowNoticed && knownContextWindow() === null) {
807
+ unknownWindowNoticed = true;
808
+ console.error(unknownWindowNotice(model));
809
+ }
797
810
  // W21: the extensions array is built ONCE per agent and shared with
798
811
  // the runtime by reference — the don't-ask-again writer pushes the
799
812
  // generated extension into it so a first-time rule joins the chain
@@ -860,7 +873,24 @@ async function makeAgent(sessionId, input, modelFlag) {
860
873
  // 1 reads the user's configured checks first, then the runner table.
861
874
  contextPolicy: {
862
875
  ...(contextPolicy ?? {}),
863
- tiers: { windowTokens: contextWindowTokens(), isCheck: (command) => runsACheck(command, Object.values(merged.checks ?? {})) },
876
+ tiers: {
877
+ windowTokens: contextWindowTokens(),
878
+ isCheck: (command) => runsACheck(command, Object.values(merged.checks ?? {})),
879
+ // ADR-0055 Amendment 2: a discarded checkpoint says so, in sizes only.
880
+ onDiscard: (d) => body.notice(compactionDiscardedNotice(d)),
881
+ // ADR-0055 Amendment 2 (decision 3): only a window someone stated
882
+ // arms the overflow belt — read from the LIVE binding, so it moves
883
+ // with /model and /resume; the fallback reads as null.
884
+ statedWindow: () => knownContextWindow(),
885
+ // CW-1 batch 2: an endpoint's refusal stated its cap. The tiers
886
+ // took it already; kept, it is where the next session starts, and
887
+ // the status row's denominator moves with it. Said once per new
888
+ // figure — a refusal at a cap already kept says nothing.
889
+ onWindowLearned: (w) => {
890
+ if (recordLearnedWindow(w.model, w.baseUrl, w.tokens))
891
+ body.notice(windowLearnedNotice(w.model, providerHost(w.baseUrl) ?? "", w.tokens));
892
+ },
893
+ },
864
894
  },
865
895
  // R3e (owner ruling, 2026-08-28): NO turn limit on an interactive
866
896
  // session. This was `maxTurns: 20`, hardcoded on 2026-08-03 with no
@@ -1109,6 +1139,7 @@ async function pickSession(agent, input) {
1109
1139
  * once; 0.41.0 removes it.
1110
1140
  */
1111
1141
  let autoCompactNoticed = false;
1142
+ let unknownWindowNoticed = false;
1112
1143
  function retiredAutoCompact(merged) {
1113
1144
  if (!autoCompactNoticed && (process.env.KISO_AUTO_COMPACT !== undefined || resolveAutoCompact(merged) !== undefined)) {
1114
1145
  autoCompactNoticed = true;
@@ -1646,7 +1677,18 @@ async function main() {
1646
1677
  // = each form's default (TTY: current; pipe: all, today's bytes).
1647
1678
  let listScope;
1648
1679
  let reverseManifest;
1680
+ // DC-60: `kiso sessions --prune-empty [--yes]` — list the sessions that
1681
+ // never began; with --yes, move them to the Trash
1682
+ let pruneEmpty;
1649
1683
  if (args[0] === "sessions") {
1684
+ const pe = args.indexOf("--prune-empty");
1685
+ if (pe !== -1) {
1686
+ args.splice(pe, 1);
1687
+ const yes = args.indexOf("--yes");
1688
+ if (yes !== -1)
1689
+ args.splice(yes, 1);
1690
+ pruneEmpty = yes !== -1 ? "move" : "list";
1691
+ }
1650
1692
  // 0.40.0: the undo of the per-project move, named by its manifest
1651
1693
  const r = args.indexOf("--reverse-migration");
1652
1694
  if (r !== -1) {
@@ -1874,6 +1916,25 @@ async function main() {
1874
1916
  // "reading 'question'" on the dock-less branch). The input
1875
1917
  // exists so the gate's ask can be answered; the listing
1876
1918
  // itself never touches it.
1919
+ if (pruneEmpty !== undefined) {
1920
+ // DC-60: before makeAgent — the scan only reads, and nothing
1921
+ // needs an agent to move a file to the Trash
1922
+ const { empty, inUse } = findEmptySessions(listingFolders().map((f) => f.dir));
1923
+ const n = (k, w) => `${k} ${w}${k === 1 ? "" : "s"}`;
1924
+ if (empty.length === 0)
1925
+ console.log("no empty sessions");
1926
+ else if (pruneEmpty === "list") {
1927
+ console.log(`${n(empty.length, "empty session")} — a sidecar and no log; none of them ever ran:`);
1928
+ for (const s of empty)
1929
+ console.log(` ${basename(s.dir)}/${s.id}`);
1930
+ console.log("kiso sessions --prune-empty --yes moves them to the Trash");
1931
+ }
1932
+ else
1933
+ console.log(`moved ${n(empty.length, "empty session")} to ${moveToTrash(empty, defaultTrashRoot())}`);
1934
+ if (inUse > 0)
1935
+ console.log(`skipped ${n(inUse, "empty session")} in use — a live process holds the lock`);
1936
+ break;
1937
+ }
1877
1938
  if (reverseManifest !== undefined) {
1878
1939
  // before makeAgent: its folder preparation would otherwise run
1879
1940
  // the very move this undoes
@@ -0,0 +1,23 @@
1
+ export interface LearnedWindow {
2
+ readonly tokens: number;
3
+ /** the day the refusal was read, YYYY-MM-DD */
4
+ readonly observedAt: string;
5
+ }
6
+ export declare function learnedWindowsPath(home?: string): string;
7
+ /** The key for one model at one endpoint; no baseUrl is the provider's own. */
8
+ export declare function learnedKey(model: string, baseUrl: string | undefined): string;
9
+ /** Startup: read the file and keep figures learned from here on in it.
10
+ * Until this is called, nothing is read and nothing is kept. */
11
+ export declare function useLearnedWindows(path?: string): void;
12
+ /** The learned window for a model at an endpoint, if a refusal stated one. */
13
+ export declare function learnedWindowFor(model: string, baseUrl: string | undefined): LearnedWindow | undefined;
14
+ /**
15
+ * Keep a refusal's cap. Returns true when it changed what is kept (a new
16
+ * key, or a smaller figure), false when a figure at or below it is kept
17
+ * already — or when no file was named (`useLearnedWindows`). The file is
18
+ * re-read before the write, so a figure another process learned meanwhile
19
+ * is not lost.
20
+ */
21
+ export declare function recordLearnedWindow(model: string, baseUrl: string | undefined, tokens: number, today?: Date): boolean;
22
+ /** Tests: back to the state before startup — nothing read, nothing kept. */
23
+ export declare function resetLearnedWindows(): void;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * CW-1 batch 2 — the windows endpoints have STATED by refusing a request,
3
+ * kept across sessions in `<KISO_HOME>/learned-windows.json`.
4
+ *
5
+ * A refusal like "maximum context length is 1048576 tokens" is the one
6
+ * window source that is right for any route, relays included (the runtime
7
+ * reads it: window-learner.ts). The runtime's tiers take it at once; this
8
+ * file keeps it, so the next session starts on the real figure instead of
9
+ * meeting the wall again. It sits in the window chain after what the user
10
+ * set and before the registry: a measurement of this endpoint beats a
11
+ * statement about the model.
12
+ *
13
+ * Keyed by the endpoint (the baseUrl, trailing slashes dropped — a
14
+ * forwarder can send two paths to two upstreams) and the model id. A
15
+ * figure only ever goes DOWN for a key: a later, larger refusal cannot
16
+ * widen what a route already refused. The file is private (0600, tmp +
17
+ * rename); a missing or unreadable file is an empty one — the chain falls
18
+ * through to the registry, as it did before anything was learned.
19
+ *
20
+ * Nothing is read until the CLI's startup names the file
21
+ * (`useLearnedWindows`): the window chain is library code that in-process
22
+ * tests call, and a chain that read `~/.kiso` on its own would make their
23
+ * verdict depend on the machine they run on.
24
+ */
25
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
26
+ import { dirname, join } from "node:path";
27
+ import { kisoHome } from "./state.js";
28
+ let table = null;
29
+ let tablePath = null;
30
+ export function learnedWindowsPath(home = kisoHome()) {
31
+ return join(home, "learned-windows.json");
32
+ }
33
+ /** The key for one model at one endpoint; no baseUrl is the provider's own. */
34
+ export function learnedKey(model, baseUrl) {
35
+ return `${baseUrl === undefined ? "-" : baseUrl.replace(/\/+$/, "")} ${model}`;
36
+ }
37
+ function read(path) {
38
+ try {
39
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
40
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
41
+ return {};
42
+ const out = {};
43
+ for (const [k, v] of Object.entries(parsed)) {
44
+ const w = v;
45
+ if (w !== null && typeof w === "object" && typeof w.tokens === "number" && Number.isSafeInteger(w.tokens) && w.tokens > 0 && typeof w.observedAt === "string") {
46
+ out[k] = { tokens: w.tokens, observedAt: w.observedAt };
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+ catch {
52
+ return {};
53
+ }
54
+ }
55
+ /** Startup: read the file and keep figures learned from here on in it.
56
+ * Until this is called, nothing is read and nothing is kept. */
57
+ export function useLearnedWindows(path = learnedWindowsPath()) {
58
+ tablePath = path;
59
+ table = read(path);
60
+ }
61
+ /** The learned window for a model at an endpoint, if a refusal stated one. */
62
+ export function learnedWindowFor(model, baseUrl) {
63
+ return table === null ? undefined : table[learnedKey(model, baseUrl)];
64
+ }
65
+ /**
66
+ * Keep a refusal's cap. Returns true when it changed what is kept (a new
67
+ * key, or a smaller figure), false when a figure at or below it is kept
68
+ * already — or when no file was named (`useLearnedWindows`). The file is
69
+ * re-read before the write, so a figure another process learned meanwhile
70
+ * is not lost.
71
+ */
72
+ export function recordLearnedWindow(model, baseUrl, tokens, today = new Date()) {
73
+ const path = tablePath;
74
+ if (path === null)
75
+ return false;
76
+ const key = learnedKey(model, baseUrl);
77
+ const current = read(path);
78
+ const held = current[key];
79
+ if (held !== undefined && held.tokens <= tokens) {
80
+ table = current;
81
+ return false;
82
+ }
83
+ const next = { ...current, [key]: { tokens, observedAt: today.toISOString().slice(0, 10) } };
84
+ table = next;
85
+ try {
86
+ mkdirSync(dirname(path), { recursive: true });
87
+ const tmp = `${path}.${process.pid}.tmp`;
88
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
89
+ renameSync(tmp, path);
90
+ }
91
+ catch {
92
+ // unwritable home: the figure still holds for this process
93
+ }
94
+ return true;
95
+ }
96
+ /** Tests: back to the state before startup — nothing read, nothing kept. */
97
+ export function resetLearnedWindows() {
98
+ table = null;
99
+ tablePath = null;
100
+ }
@@ -24,9 +24,14 @@
24
24
  export declare function providerHost(baseUrl?: string): string | null;
25
25
  /** `@commandcode.ai` — the provider named beside a model. `""` when the
26
26
  * binding names no endpoint (the provider's own default origin), so a row
27
- * that has nothing to say does not print a stray `@`. */
28
- export declare function providerLabel(baseUrl?: string): string;
27
+ * that has nothing to say does not print a stray `@`.
28
+ *
29
+ * With an `upstream` (a profile whose baseUrl is a local forwarder), the
30
+ * label names who is billed FIRST and where the bytes go second:
31
+ * `@gateway.example via 127.0.0.1:47821`. A name that is not a URL is shown as
32
+ * written. */
33
+ export declare function providerLabel(baseUrl?: string, upstream?: string): string;
29
34
  /** The same label for a PROFILE, which knows its kind as well as its URL: a
30
35
  * profile with no baseUrl (a first-party provider) still names itself by
31
36
  * providerId, because a row that shows two profiles must tell them apart. */
32
- export declare function profileProviderLabel(kind: string, baseUrl?: string): string;
37
+ export declare function profileProviderLabel(kind: string, baseUrl?: string, upstream?: string): string;
@@ -40,14 +40,22 @@ export function providerHost(baseUrl) {
40
40
  }
41
41
  /** `@commandcode.ai` — the provider named beside a model. `""` when the
42
42
  * binding names no endpoint (the provider's own default origin), so a row
43
- * that has nothing to say does not print a stray `@`. */
44
- export function providerLabel(baseUrl) {
43
+ * that has nothing to say does not print a stray `@`.
44
+ *
45
+ * With an `upstream` (a profile whose baseUrl is a local forwarder), the
46
+ * label names who is billed FIRST and where the bytes go second:
47
+ * `@gateway.example via 127.0.0.1:47821`. A name that is not a URL is shown as
48
+ * written. */
49
+ export function providerLabel(baseUrl, upstream) {
45
50
  const host = providerHost(baseUrl);
51
+ const up = upstream === undefined || upstream.trim() === "" ? null : (providerHost(upstream) ?? upstream.trim());
52
+ if (up !== null)
53
+ return host === null || host === up ? `@${up}` : `@${up} via ${host}`;
46
54
  return host === null ? "" : `@${host}`;
47
55
  }
48
56
  /** The same label for a PROFILE, which knows its kind as well as its URL: a
49
57
  * profile with no baseUrl (a first-party provider) still names itself by
50
58
  * providerId, because a row that shows two profiles must tell them apart. */
51
- export function profileProviderLabel(kind, baseUrl) {
52
- return providerLabel(baseUrl) || `@${providerIdOf(kind, baseUrl)}`;
59
+ export function profileProviderLabel(kind, baseUrl, upstream) {
60
+ return providerLabel(baseUrl, upstream) || `@${providerIdOf(kind, baseUrl)}`;
53
61
  }
package/dist/state.d.ts CHANGED
@@ -253,6 +253,10 @@ export declare function setConfiguredWindow(value: number | undefined): void;
253
253
  * model's NAME — /model lists and switches against them. */
254
254
  export declare let configModels: Readonly<Record<string, import("./config.js").ModelProfile>>;
255
255
  export declare function setConfigModels(models: Readonly<Record<string, import("./config.js").ModelProfile>>): void;
256
+ /** CW-1: the upstream a forwarder at `baseUrl` names — a fact about the
257
+ * ADDRESS, so a resumed session (no profile name in hand) finds it too:
258
+ * the first profile at that address that names one. */
259
+ export declare function upstreamOf(baseUrl: string | undefined): string | undefined;
256
260
  /** The name of the model currently driving the session ("faux" or the
257
261
  * profile name / provider/model write / env model). */
258
262
  /**
package/dist/state.js CHANGED
@@ -342,6 +342,17 @@ export let configModels = {};
342
342
  export function setConfigModels(models) {
343
343
  configModels = models;
344
344
  }
345
+ /** CW-1: the upstream a forwarder at `baseUrl` names — a fact about the
346
+ * ADDRESS, so a resumed session (no profile name in hand) finds it too:
347
+ * the first profile at that address that names one. */
348
+ export function upstreamOf(baseUrl) {
349
+ if (baseUrl === undefined)
350
+ return undefined;
351
+ for (const p of Object.values(configModels))
352
+ if (p.baseUrl === baseUrl && p.upstream !== undefined)
353
+ return p.upstream;
354
+ return undefined;
355
+ }
345
356
  /** The name of the model currently driving the session ("faux" or the
346
357
  * profile name / provider/model write / env model). */
347
358
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-code",
3
- "version": "0.40.1",
3
+ "version": "0.40.3",
4
4
  "description": "kiso-code \u2014 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",
@@ -18,20 +18,20 @@
18
18
  "test": "vitest run"
19
19
  },
20
20
  "dependencies": {
21
- "@vincemakes/kiso-ask-ext": "0.40.1",
22
- "@vincemakes/kiso-core": "0.40.1",
23
- "@vincemakes/kiso-evals": "0.40.1",
24
- "@vincemakes/kiso-mcp-ext": "0.40.1",
25
- "@vincemakes/kiso-provider-anthropic": "0.40.1",
26
- "@vincemakes/kiso-provider-openai": "0.40.1",
27
- "@vincemakes/kiso-provider-openai-responses": "0.40.1",
28
- "@vincemakes/kiso-runtime": "0.40.1",
29
- "@vincemakes/kiso-skills-ext": "0.40.1",
30
- "@vincemakes/kiso-subagent-ext": "0.40.1",
31
- "@vincemakes/kiso-task-ext": "0.40.1",
32
- "@vincemakes/kiso-tools-node": "0.40.1",
33
- "@vincemakes/kiso-tui": "0.40.1",
34
- "@vincemakes/kiso-tui-cells": "0.40.1"
21
+ "@vincemakes/kiso-ask-ext": "0.40.3",
22
+ "@vincemakes/kiso-core": "0.40.3",
23
+ "@vincemakes/kiso-evals": "0.40.3",
24
+ "@vincemakes/kiso-mcp-ext": "0.40.3",
25
+ "@vincemakes/kiso-provider-anthropic": "0.40.3",
26
+ "@vincemakes/kiso-provider-openai": "0.40.3",
27
+ "@vincemakes/kiso-provider-openai-responses": "0.40.3",
28
+ "@vincemakes/kiso-runtime": "0.40.3",
29
+ "@vincemakes/kiso-skills-ext": "0.40.3",
30
+ "@vincemakes/kiso-subagent-ext": "0.40.3",
31
+ "@vincemakes/kiso-task-ext": "0.40.3",
32
+ "@vincemakes/kiso-tools-node": "0.40.3",
33
+ "@vincemakes/kiso-tui": "0.40.3",
34
+ "@vincemakes/kiso-tui-cells": "0.40.3"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^26.1.2",