@vincemakes/kiso-code 0.40.2 → 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;
@@ -101,9 +135,13 @@ export declare function statusModelLabel(session: {
101
135
  };
102
136
  }): string;
103
137
  /** ADR-0055 Amendment 2 (decision 5): with no stated window the status row
104
- * shows `ctx ?`, yet the compaction tiers still assume the 200K fallback
138
+ * shows `ctx ?`, yet the compaction tiers still assume the fallback (128K
139
+ * since CW-1 batch 2)
105
140
  * — the assumption is said out loud once, at agent build. */
106
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;
107
145
  /** ADR-0055 Amendment 2: the notice for a checkpoint the shrink invariant
108
146
  * discarded — chars/4 sizes only; the checkpoint's text is the owner's
109
147
  * work and never reaches the screen. */
@@ -231,6 +269,28 @@ export declare function turnUsageLedger(): {
231
269
  report: (u: RunUsage) => void;
232
270
  total: () => RunUsage | null;
233
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
+ };
234
294
  /** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
235
295
  * is gone) — docked only, 200ms rotation between the request and the
236
296
  * first event. */
package/dist/chat.js CHANGED
@@ -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
  /**
@@ -167,11 +234,17 @@ export function statusModelLabel(session) {
167
234
  return effort !== undefined && effort !== "default" ? `${name} · ${effort}` : name;
168
235
  }
169
236
  /** ADR-0055 Amendment 2 (decision 5): with no stated window the status row
170
- * shows `ctx ?`, yet the compaction tiers still assume the 200K fallback
237
+ * shows `ctx ?`, yet the compaction tiers still assume the fallback (128K
238
+ * since CW-1 batch 2)
171
239
  * — the assumption is said out loud once, at agent build. */
172
240
  export function unknownWindowNotice(model) {
173
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`;
174
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
+ }
175
248
  /** ADR-0055 Amendment 2: the notice for a checkpoint the shrink invariant
176
249
  * discarded — chars/4 sizes only; the checkpoint's text is the owner's
177
250
  * work and never reaches the screen. */
@@ -179,7 +252,7 @@ export function compactionDiscardedNotice(d) {
179
252
  return `✦ compaction discarded — the checkpoint did not shrink the context (~${kUnit(d.pre)} → ~${kUnit(d.post)}; it wrote ~${kUnit(d.summary)})`;
180
253
  }
181
254
  export function displayCtxRatio(session) {
182
- // CAPACITY is not POLICY. `contextWindowTokens` falls back to 200,000 so
255
+ // CAPACITY is not POLICY. `contextWindowTokens` falls back to 128,000 so
183
256
  // that the compaction threshold always HAS a value — a policy needs a
184
257
  // number. A percentage on screen is a different kind of thing: it is a
185
258
  // claim about the model, and an unstated window makes it unanswerable.
@@ -385,6 +458,43 @@ export function turnUsageLedger() {
385
458
  },
386
459
  };
387
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
+ }
388
498
  /** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
389
499
  * is gone) — docked only, 200ms rotation between the request and the
390
500
  * first event. */
@@ -807,7 +917,7 @@ submitTurn) {
807
917
  // and read at that call's usage event. Per CALL, not per turn — a turn
808
918
  // with three model calls reports the third, and each one times itself.
809
919
  let callFirstEventAt = null;
810
- let prevTotal = null;
920
+ const meter = callUsageMeter();
811
921
  let missed = null;
812
922
  // v3 §02: the recap line derives ENTIRELY from the local event stream
813
923
  // (zero tokens). R3g: what it derives is the turn's COST — wall
@@ -844,6 +954,8 @@ submitTurn) {
844
954
  // the stream watchdog and the repeated-failure breaker remain active.
845
955
  // W22-R1: the ledger sees every event; a stop is what ends a call.
846
956
  ledger.observe(ev);
957
+ if (ev.type === "stop")
958
+ meter.endCall();
847
959
  // ADR-0055 Amendment 1 (A1b): a compaction inside the run says so, once.
848
960
  if (ev.type === "summarized" || ev.type === "microcompacted") {
849
961
  const r = displayCtxRatio(session);
@@ -960,11 +1072,12 @@ submitTurn) {
960
1072
  body.notice("stream interrupted — the draft above is abandoned");
961
1073
  break;
962
1074
  case "usage": {
963
- const delta = usageFromEvent(session.provider, ev, prevTotal, agentModel, session.baseUrl);
1075
+ const delta = usageFromEvent(session.provider, ev, meter.carrier(), agentModel, session.baseUrl);
964
1076
  // W22-R1: this call's LATEST report replaces its earlier one;
965
- // 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).
966
1079
  ledger.report(delta.usage);
967
- prevTotal = delta.total;
1080
+ const costToAdd = meter.report(delta.total, delta.costUsd);
968
1081
  missed = delta.missed;
969
1082
  // TUI2-R1 (E): the request's canonical cost rides the same
970
1083
  // callback the usage does — one settled request, one addition.
@@ -973,7 +1086,7 @@ submitTurn) {
973
1086
  // second call is timed from ITS own first event.
974
1087
  const tokPerSec = callFirstEventAt === null ? null : decodeRate(ev.outputTokens, Date.now() - callFirstEventAt);
975
1088
  callFirstEventAt = null;
976
- statusCb?.(turnUsage() ?? UNKNOWN_USAGE, displayCtxRatio(session), delta.costUsd, tokPerSec);
1089
+ statusCb?.(turnUsage() ?? UNKNOWN_USAGE, displayCtxRatio(session), costToAdd, tokPerSec);
977
1090
  break;
978
1091
  }
979
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, compactionDiscardedNotice, contextWindowTokens, displayCtxRatio, knownContextWindow, microcompactThresholdFor, statusModelLabel, unknownWindowNotice } 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
@@ -795,7 +801,8 @@ async function makeAgent(sessionId, input, modelFlag) {
795
801
  }
796
802
  setAgentModel(model, resolved?.profile.baseUrl); // v2b: the status bar shows it; OR-1: the endpoint rides along
797
803
  // ADR-0055 Amendment 2: the row says `ctx ?` for an unstated window, but
798
- // the tiers still need a number and assume 200K — say so, once, at build.
804
+ // the tiers still need a number and assume the fallback — say so, once,
805
+ // at build.
799
806
  if (!unknownWindowNoticed && knownContextWindow() === null) {
800
807
  unknownWindowNoticed = true;
801
808
  console.error(unknownWindowNotice(model));
@@ -873,8 +880,16 @@ async function makeAgent(sessionId, input, modelFlag) {
873
880
  onDiscard: (d) => body.notice(compactionDiscardedNotice(d)),
874
881
  // ADR-0055 Amendment 2 (decision 3): only a window someone stated
875
882
  // arms the overflow belt — read from the LIVE binding, so it moves
876
- // with /model and /resume; the 200K fallback reads as null.
883
+ // with /model and /resume; the fallback reads as null.
877
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
+ },
878
893
  },
879
894
  },
880
895
  // R3e (owner ruling, 2026-08-28): NO turn limit on an interactive
@@ -1662,7 +1677,18 @@ async function main() {
1662
1677
  // = each form's default (TTY: current; pipe: all, today's bytes).
1663
1678
  let listScope;
1664
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;
1665
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
+ }
1666
1692
  // 0.40.0: the undo of the per-project move, named by its manifest
1667
1693
  const r = args.indexOf("--reverse-migration");
1668
1694
  if (r !== -1) {
@@ -1890,6 +1916,25 @@ async function main() {
1890
1916
  // "reading 'question'" on the dock-less branch). The input
1891
1917
  // exists so the gate's ask can be answered; the listing
1892
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
+ }
1893
1938
  if (reverseManifest !== undefined) {
1894
1939
  // before makeAgent: its folder preparation would otherwise run
1895
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.2",
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.2",
22
- "@vincemakes/kiso-core": "0.40.2",
23
- "@vincemakes/kiso-evals": "0.40.2",
24
- "@vincemakes/kiso-mcp-ext": "0.40.2",
25
- "@vincemakes/kiso-provider-anthropic": "0.40.2",
26
- "@vincemakes/kiso-provider-openai": "0.40.2",
27
- "@vincemakes/kiso-provider-openai-responses": "0.40.2",
28
- "@vincemakes/kiso-runtime": "0.40.2",
29
- "@vincemakes/kiso-skills-ext": "0.40.2",
30
- "@vincemakes/kiso-subagent-ext": "0.40.2",
31
- "@vincemakes/kiso-task-ext": "0.40.2",
32
- "@vincemakes/kiso-tools-node": "0.40.2",
33
- "@vincemakes/kiso-tui": "0.40.2",
34
- "@vincemakes/kiso-tui-cells": "0.40.2"
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",