@vincemakes/kiso-code 0.34.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/chat.js CHANGED
@@ -20,6 +20,15 @@ import { FauxExhaustionError, failOnFauxExhaustion } from "./faux-glue.js";
20
20
  import { MODES, getMode, setMode } from "./mode.js";
21
21
  /** B area: default context window for the ~ctx estimate (config overridable). */
22
22
  const DEFAULT_CONTEXT_WINDOW = 200_000;
23
+ /** The in-process fake provider's id, and the window we declare for it.
24
+ * Ours to state: faux is not a vendor's model, so "nobody published a
25
+ * window" — the reason a real model's capacity is unknown — cannot apply.
26
+ * 200,000 is the figure every faux transcript has been measured against
27
+ * since the fallback existed, so declaring it changes no behaviour; it
28
+ * only stops an honest `ctx ?` from firing where the honest answer is a
29
+ * number we own. */
30
+ const FAUX_MODEL = "faux";
31
+ const FAUX_CONTEXT_WINDOW = 200_000;
23
32
  /** Parse KISO_AUTO_COMPACT — an invalid value is OFF, never a crash. */
24
33
  export function autoCompactFromEnv() {
25
34
  const raw = process.env.KISO_AUTO_COMPACT;
@@ -35,8 +44,51 @@ export function autoCompactFromEnv() {
35
44
  * config window (merge round B), both beat the 200k default. The microcompact
36
45
  * threshold is derived from it (50%), and the status line's ~ctx estimate
37
46
  * is measured against it — one source of truth for the window.
47
+ *
48
+ * CTX-1: with no argument this reads the LIVE binding, which is what every
49
+ * display caller wants. `/model` needs the window of the model it is
50
+ * switching TO, before that binding is live, so it passes the pair
51
+ * explicitly. Reading module state a caller is about to change is how the
52
+ * threshold got stuck in the first place.
53
+ *
54
+ * The argument is a PAIR, never two optional halves: a profile with no
55
+ * `baseUrl` is endpoint-less, and a half-given argument would have it
56
+ * inherit the OUTGOING model's endpoint — the same bug one field over.
57
+ */
58
+ /**
59
+ * THE WINDOW SOMEBODY STATED, or null when nobody has.
60
+ *
61
+ * Three sources, in order: the config/profile window, KISO_CONTEXT_WINDOW,
62
+ * and the metadata registry. Each is a claim someone made and dated. When
63
+ * none of them speaks, this returns NULL rather than a number — because
64
+ * the question "how much of the window is left" has no answer without a
65
+ * window, and every display that shows a percentage needs this one, not
66
+ * the policy value below.
67
+ *
68
+ * DeepSeek is the live case: its /models endpoint returns ids only, the
69
+ * responses carry no window, and the registry records null on purpose. The
70
+ * display used to divide by a hardcoded 200,000 anyway and print a
71
+ * confident `ctx left ~82%` against a figure nobody had measured.
38
72
  */
39
- export function contextWindowTokens() {
73
+ export function knownContextWindow(of) {
74
+ if (configuredWindow !== undefined)
75
+ return configuredWindow;
76
+ const env = Number.parseInt(process.env.KISO_CONTEXT_WINDOW ?? "", 10);
77
+ if (Number.isFinite(env) && env > 0)
78
+ return env;
79
+ const model = of?.model ?? agentModel;
80
+ // `faux` is OURS. The registry carries no row for it because it is not a
81
+ // vendor's model, but the reason the window is unknown elsewhere — nobody
82
+ // published one — does not apply to a model we wrote. Declaring it is a
83
+ // statement about our own artifact, with ourselves as the source, and it
84
+ // keeps faux mode showing a real percentage instead of the `ctx ?` that
85
+ // belongs to models whose capacity genuinely nobody states.
86
+ if (model === FAUX_MODEL)
87
+ return FAUX_CONTEXT_WINDOW;
88
+ const known = lookupModelMetadata(model, of !== undefined ? of.baseUrl : agentBaseUrl)?.capabilities.contextWindow;
89
+ return known ?? null;
90
+ }
91
+ export function contextWindowTokens(of) {
40
92
  const windowOverride = configuredWindow;
41
93
  if (windowOverride !== undefined)
42
94
  return windowOverride;
@@ -45,17 +97,41 @@ export function contextWindowTokens() {
45
97
  return window;
46
98
  // PH-1c (finding PH-F15): the window follows the LIVE model when the
47
99
  // metadata registry knows it — /model to a known model moves the
48
- // window (and the microcompact threshold derived from it) without an
49
- // env var. agentModel is the same live binding the status row shows;
50
- // an unknown model keeps the 200k default — the registry never
51
- // guesses, so neither do we. OR-1: the ENDPOINT narrows the row —
52
- // gpt-5.5 is 1,050,000 at the first-party API and 272,000 at the
53
- // subscription backend; the two are set together (setAgentModel).
54
- const known = lookupModelMetadata(agentModel, agentBaseUrl)?.capabilities.contextWindow;
100
+ // window without an env var. The default argument is the same live
101
+ // binding the status row shows; an unknown model keeps the 200k
102
+ // default — the registry never guesses, so neither do we. OR-1: the
103
+ // ENDPOINT narrows the row — gpt-5.5 is 1,050,000 at the first-party
104
+ // API and 272,000 at the subscription backend; the two are set
105
+ // together (setAgentModel).
106
+ //
107
+ // CTX-1 erratum: this comment used to add "(and the microcompact
108
+ // threshold derived from it)". The window moved; the threshold did
109
+ // not — it was computed once at startup and the switch path never
110
+ // asked again. The claim is true now because the switch path calls
111
+ // `microcompactThresholdFor` below, not because deriving a number
112
+ // from this function makes anything follow it.
113
+ const known = lookupModelMetadata(of?.model ?? agentModel, of !== undefined ? of.baseUrl : agentBaseUrl)?.capabilities.contextWindow;
55
114
  if (known !== undefined && known !== null)
56
115
  return known;
57
116
  return DEFAULT_CONTEXT_WINDOW;
58
117
  }
118
+ /**
119
+ * CTX-1: THE ONE derivation of the compaction threshold from the window.
120
+ *
121
+ * Startup (`index.ts`) and the `/model` switch (`dispatch.ts`) both need
122
+ * this number, and two call sites computing the same thing is how
123
+ * `promptCacheKey` drifted before `adapterOptionsFor` collected it. The
124
+ * policy — half the window — lives here and nowhere else.
125
+ *
126
+ * CAPACITY is not POLICY. The window is what the model can hold; this is
127
+ * when we choose to clear old tool results. They are 2:1 today because
128
+ * that ratio has never been measured, not because it is derived from
129
+ * anything. Moving the policy means changing this line, and the whole
130
+ * product moves with it.
131
+ */
132
+ export function microcompactThresholdFor(of) {
133
+ return contextWindowTokens(of) / 2;
134
+ }
59
135
  /**
60
136
  * B area: approximate context ratio — chars/4 of the projected messages vs
61
137
  * the model window. Marked ~ everywhere it is shown; no counting API.
@@ -79,7 +155,15 @@ export function statusModelLabel(session) {
79
155
  return effort !== undefined && effort !== "default" ? `${agentModel} · ${effort}` : agentModel;
80
156
  }
81
157
  export function displayCtxRatio(session) {
82
- return requestBudget(session.requestParts(), contextWindowTokens()).ratio;
158
+ // CAPACITY is not POLICY. `contextWindowTokens` falls back to 200,000 so
159
+ // that the compaction threshold always HAS a value — a policy needs a
160
+ // number. A percentage on screen is a different kind of thing: it is a
161
+ // claim about the model, and an unstated window makes it unanswerable.
162
+ // NaN reaches the status row as `ctx ?`.
163
+ const window = knownContextWindow();
164
+ if (window === null)
165
+ return Number.NaN;
166
+ return requestBudget(session.requestParts(), window).ratio;
83
167
  }
84
168
  /** A1a: the number the auto-compact decision reads — the pre-A1a estimate,
85
169
  * unchanged this round, named so a gate can pin that it did not move. */
@@ -165,6 +249,84 @@ endpoint) {
165
249
  costUsd: c.costUsd,
166
250
  };
167
251
  }
252
+ /** The accumulator's zero value — nothing measured yet. One shared
253
+ * literal: RunUsage's fields are readonly, and the old code repeated this
254
+ * object at every reset. */
255
+ const UNKNOWN_USAGE = { in: null, out: null, cache: null, known: false };
256
+ /** W22 (owner, 2026-09-14) — the TURN's usage, summed across its calls.
257
+ *
258
+ * EXISTING-BEHAVIOR CHANGE — declared, never a silent side-fix:
259
+ * the recap and the running row carried the LAST call's figures
260
+ * (`usage = delta.usage`), and read as the turn's by everyone who saw
261
+ * them: the owner's own dogfood, a nine-call turn that had spent 120,358
262
+ * prompt tokens and 4,608 output tokens, reported `in 412 out 924 ·
263
+ * cache 98%` — the ninth call's fresh input and the ninth call's output.
264
+ * The variables are the same and the definitions are unchanged; the SCOPE
265
+ * is the turn's.
266
+ *
267
+ * Two rules, inherited rather than invented:
268
+ * - `in` stays the canonical FRESH count (E2, R2a-1): the turn's sum is
269
+ * the input the turn actually bought at full price. `cache` is summed
270
+ * too, but only the recap's ratio reads it (cache/(in+cache)) — the raw
271
+ * total is the ledger's business, never the row's, because a sum over
272
+ * calls counts the same prefix once per call.
273
+ * - an unmeasured term makes the sum unmeasured (OR-10's rule, applied to
274
+ * the sum): `null` propagates, and one call with `known: false` makes
275
+ * the turn's figure unknown rather than a lower bound dressed as a
276
+ * total. The row then says nothing, as it always did for an unknown
277
+ * call.
278
+ *
279
+ * The miss estimate and the decode rate stay PER CALL: `missed` is the
280
+ * overlap of consecutive prompts (a total-side quantity), and the rate is
281
+ * the call that just settled timing itself. */
282
+ export function accumulateUsage(prev, delta) {
283
+ const sum = (a, b) => (a === null || b === null ? null : a + b);
284
+ return {
285
+ in: sum(prev.in, delta.in),
286
+ out: sum(prev.out, delta.out),
287
+ cache: sum(prev.cache, delta.cache),
288
+ known: prev.known && delta.known,
289
+ };
290
+ }
291
+ /**
292
+ * W22-R1 (Astra): the turn's usage ledger, as ONE definition.
293
+ *
294
+ * A TURN is the sum over its CALLS; a CALL is its LATEST report. Both
295
+ * halves are load-bearing: the openai-compat adapter yields a usage event
296
+ * from either of two stream shapes and its `usageSent` flag only decides
297
+ * whether to append a trailing unknown — it does not stop both from
298
+ * firing. W22 substituted, so a second report silently replaced the first
299
+ * and no screen changed; summing without the call boundary turned the same
300
+ * stream into a WRONG NUMBER (a ledger of fresh 80 / out 10 shown as
301
+ * 160 / 15).
302
+ *
303
+ * It is a function rather than three variables in the loop because the rule
304
+ * then has one place to be right — the same reason `microcompactThresholdFor`
305
+ * exists, and the drift `promptCacheKey` demonstrated when it did not.
306
+ */
307
+ export function turnUsageLedger() {
308
+ let settled = null;
309
+ let inFlight = null;
310
+ const fold = (a, b) => a === null ? b : b === null ? a : accumulateUsage(a, b);
311
+ return {
312
+ // every event of the run passes through here: a stop ENDS a call, so
313
+ // the report in flight settles and the next call starts its own.
314
+ observe(ev) {
315
+ if (ev.type !== "stop")
316
+ return;
317
+ settled = fold(settled, inFlight);
318
+ inFlight = null;
319
+ },
320
+ // this call's latest report REPLACES its earlier one.
321
+ report(u) {
322
+ inFlight = u;
323
+ },
324
+ // the turn so far: the settled calls plus the one in flight.
325
+ total() {
326
+ return fold(settled, inFlight);
327
+ },
328
+ };
329
+ }
168
330
  /** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
169
331
  * is gone) — docked only, 200ms rotation between the request and the
170
332
  * first event. */
@@ -553,44 +715,24 @@ function toRenderInput(ev) {
553
715
  * echo is UI, the chip is the record; the momentary double-render is
554
716
  * the design's explicit point).
555
717
  */
556
- /** LT-2b — the turn checkpoint (kiso-doc/kiso-lt1-mini-spec-2026-09-09.md,
557
- * part two). R3e stands: no hard turn limit on an interactive session. What
558
- * a long run gets instead is a QUESTION every CHECKPOINT_TURNS model turns —
559
- * the run is paused between two model turns (the generator is simply not
560
- * pulled while the panel is up; the kernel does nothing meanwhile) and the
561
- * human answers. "Keep going" resumes it, the next question one checkpoint
562
- * later; "Stop here" aborts the run — `aborted by user`, resumable, never
563
- * silent. A decline (esc) is "keep going": the run was already running, and
564
- * esc on a panel is the panel's decline, not the run's abort. Headless
565
- * entries never see it: the panel needs the dock, and a pipe, `-p` or a
566
- * task file has none. 50 is named here; a finding moves it, never a stray
567
- * edit. */
568
- export const CHECKPOINT_TURNS = 50;
569
- async function checkpointAsk(input, turns) {
570
- const verdict = await askPanel(input, askView({
571
- questions: [
572
- {
573
- question: `${turns} turns and still working — keep going?`,
574
- header: "checkpoint",
575
- options: [
576
- { label: "Keep going", description: `the next question at ${turns + CHECKPOINT_TURNS}` },
577
- { label: "Stop here", description: "the run ends as aborted by you; kiso resume continues it later" },
578
- ],
579
- },
580
- ],
581
- }));
582
- if (verdict.action !== "answers" || !("answers" in verdict.result))
583
- return true; // declined: nothing changes
584
- const first = verdict.result.answers[0];
585
- return !(first !== undefined && "choice" in first && first.choice === "Stop here");
586
- }
587
718
  export async function consumeRun(session, run, input, turnNo, faux, statusCb,
588
719
  /** W21: the amend words ("Yes + feedback") ride the NEXT user turn —
589
720
  * threaded from chat's submitTurn; absent in the recovery flow
590
721
  * (resume) where a dropped amend is noticed instead. */
591
722
  submitTurn) {
592
723
  let last;
593
- let usage = { in: null, out: null, cache: null, known: false };
724
+ // W22: the TURN's usage — null until a call settles, then the sum of
725
+ // every call in this turn (accumulateUsage). Null rather than an empty
726
+ // accumulator, so the first call is the sum rather than an addition to
727
+ // nothing.
728
+ //
729
+ // W22-R1 (Astra): one call can report TWICE, so the turn is a sum over
730
+ // CALLS and a call is its LATEST report — see `turnUsageLedger`, which
731
+ // holds that rule. Every event passes through `observe` (a stop ends a
732
+ // call); the usage case reports; the status line and the recap read
733
+ // `total()`.
734
+ const ledger = turnUsageLedger();
735
+ const turnUsage = () => ledger.total();
594
736
  // TPS-1: the decode clock, armed by the FIRST streamed event of a call
595
737
  // and read at that call's usage event. Per CALL, not per turn — a turn
596
738
  // with three model calls reports the third, and each one times itself.
@@ -618,16 +760,13 @@ submitTurn) {
618
760
  tailers.delete(callId);
619
761
  };
620
762
  try {
621
- let modelTurns = 0;
622
763
  for await (const ev of run) {
623
764
  last = ev;
624
- // LT-2b: a model turn ends with its stop; every CHECKPOINT_TURNS of them
625
- // the human is asked before the run is pulled any further
626
- if (ev.type === "stop" && dock.active) {
627
- modelTurns += 1;
628
- if (modelTurns % CHECKPOINT_TURNS === 0 && !(await checkpointAsk(input, modelTurns)))
629
- run.abort();
630
- }
765
+ // LT2B-F1: a turn count is not evidence of a loop. Keep consuming
766
+ // healthy runs without a periodic presence check; explicit cancellation,
767
+ // the stream watchdog and the repeated-failure breaker remain active.
768
+ // W22-R1: the ledger sees every event; a stop is what ends a call.
769
+ ledger.observe(ev);
631
770
  if (ev.type !== "thinking") {
632
771
  if (thinkingSince !== null) {
633
772
  thoughtSeconds += (Date.now() - thinkingSince) / 1000;
@@ -726,7 +865,9 @@ submitTurn) {
726
865
  break;
727
866
  case "usage": {
728
867
  const delta = usageFromEvent(session.provider, ev, prevTotal, agentModel, session.baseUrl);
729
- usage = delta.usage;
868
+ // W22-R1: this call's LATEST report replaces its earlier one;
869
+ // the turn's figure is the settled calls plus this one.
870
+ ledger.report(delta.usage);
730
871
  prevTotal = delta.total;
731
872
  missed = delta.missed;
732
873
  // TUI2-R1 (E): the request's canonical cost rides the same
@@ -736,7 +877,7 @@ submitTurn) {
736
877
  // second call is timed from ITS own first event.
737
878
  const tokPerSec = callFirstEventAt === null ? null : decodeRate(ev.outputTokens, Date.now() - callFirstEventAt);
738
879
  callFirstEventAt = null;
739
- statusCb?.(usage, displayCtxRatio(session), delta.costUsd, tokPerSec);
880
+ statusCb?.(turnUsage() ?? UNKNOWN_USAGE, displayCtxRatio(session), delta.costUsd, tokPerSec);
740
881
  break;
741
882
  }
742
883
  case "uncertain_pending":
@@ -843,7 +984,7 @@ submitTurn) {
843
984
  // v3 §02: the run's recap line REPLACES the old "done" label
844
985
  // + status line — one local line, derived from this run's
845
986
  // events (zero tokens). The dock's status bar still paints.
846
- statusCb?.(usage, displayCtxRatio(session));
987
+ statusCb?.(turnUsage() ?? UNKNOWN_USAGE, displayCtxRatio(session));
847
988
  const ratio = displayCtxRatio(session);
848
989
  // TV-1B: the settle verdict — the checklist stops lying. When
849
990
  // every item is CLAIMED done, the settled block's tail says
@@ -901,7 +1042,7 @@ submitTurn) {
901
1042
  // the recap cut itself down to one character. A width
902
1043
  // that is not a positive number is not a width.
903
1044
  width: process.stdout.columns > 0 ? process.stdout.columns : 80,
904
- usage,
1045
+ usage: turnUsage() ?? UNKNOWN_USAGE,
905
1046
  // R-C item 4: only an above-floor miss is surfaced —
906
1047
  // the recap gains "· miss N" on the cache segment.
907
1048
  ...(missed !== null ? { missed } : {}),
package/dist/config.d.ts CHANGED
@@ -49,6 +49,21 @@ export interface ModelProfile {
49
49
  /** LT-1: milliseconds the model stream may go silent before the request
50
50
  * is aborted and retried. Default 120,000; 0 disables the watchdog. */
51
51
  readonly streamIdleMs?: number;
52
+ /**
53
+ * The model's context window, in tokens, when YOU know it and nobody
54
+ * else publishes it.
55
+ *
56
+ * The registry carries a window only where a vendor states one, dated
57
+ * and sourced. DeepSeek states none: its /models endpoint returns ids
58
+ * only and its responses carry no window. Without a window the status
59
+ * row has no denominator and shows `ctx ?` rather than a percentage of
60
+ * a number nobody measured.
61
+ *
62
+ * Setting this makes the percentage real FOR YOU, and it is your claim,
63
+ * not ours — which is why it lives in your profile rather than in the
64
+ * registry, where every figure has to carry a source.
65
+ */
66
+ readonly contextWindow?: number;
52
67
  }
53
68
  export interface AutoCompactConfig {
54
69
  readonly thresholdRatio: number;
@@ -145,7 +160,7 @@ export declare function resolveModel(modelFlag: string | undefined, merged: Kiso
145
160
  export declare function resolveModeFromConfig(merged: KisoConfig): Mode | undefined;
146
161
  /** Context window: env (KISO_CONTEXT_WINDOW) > config.contextWindow >
147
162
  * default (200k — the caller's default). */
148
- export declare function resolveContextWindow(merged: KisoConfig): number | undefined;
163
+ export declare function resolveContextWindow(merged: KisoConfig, profile?: ModelProfile): number | undefined;
149
164
  /** Auto-compact: env (KISO_AUTO_COMPACT) > config.autoCompact > off. An
150
165
  * invalid env value is OFF (it set the env → it wins, and it is invalid). */
151
166
  export declare function resolveAutoCompact(merged: KisoConfig): AutoCompactConfig | undefined;
package/dist/config.js CHANGED
@@ -296,31 +296,53 @@ export function resolveModel(modelFlag, merged) {
296
296
  return resolveProfile(modelFlag, p);
297
297
  }
298
298
  // env beats config: a key in the environment names the provider.
299
+ //
300
+ // Astra F2: the credential goes through authForProfile like every other
301
+ // route. This returned process.env.OPENAI_API_KEY directly, so the two
302
+ // ways into the same adapter disagreed about which key a profile uses.
303
+ // The safety property held by accident — the env key is not the stored
304
+ // one — and an accident is not a rule. Now: the stored key at the
305
+ // vendor's origin, the env key at a custom one, and a stored vendor key
306
+ // is never forwarded to a custom environment URL.
307
+ //
308
+ // The env var is folded into the profile EXPLICITLY, so the resolved
309
+ // endpoint is the profile's and the SDK never reads it again (F1).
299
310
  if (process.env.OPENAI_API_KEY !== undefined) {
300
- return {
301
- name: process.env.OPENAI_MODEL ?? "gpt-4o",
302
- profile: {
303
- kind: "openai-compat",
304
- model: process.env.OPENAI_MODEL ?? "gpt-4o",
305
- ...(process.env.OPENAI_BASE_URL !== undefined ? { baseUrl: process.env.OPENAI_BASE_URL } : {}),
306
- apiKeyEnv: "OPENAI_API_KEY",
307
- },
308
- apiKey: process.env.OPENAI_API_KEY,
311
+ const profile = {
312
+ kind: "openai-compat",
313
+ model: process.env.OPENAI_MODEL ?? "gpt-4o",
314
+ ...(process.env.OPENAI_BASE_URL !== undefined ? { baseUrl: process.env.OPENAI_BASE_URL } : {}),
315
+ apiKeyEnv: "OPENAI_API_KEY",
309
316
  };
317
+ const auth = authForProfile(process.env.OPENAI_MODEL ?? "gpt-4o", profile);
318
+ // The same total shape the config route returns (resolveProfile).
319
+ // The `oauth` arm is unreachable from HERE — authForProfile returns
320
+ // oauth only for an openai-responses profile and this route builds
321
+ // an openai-compat one — but writing the union out beats a fallback
322
+ // to the raw env var, which is the very disagreement F2 removes.
323
+ return auth.type === "oauth"
324
+ ? { name: process.env.OPENAI_MODEL ?? "gpt-4o", profile, oauthProviderId: auth.providerId }
325
+ : { name: process.env.OPENAI_MODEL ?? "gpt-4o", profile, apiKey: auth.apiKey };
310
326
  }
311
327
  if (process.env.ANTHROPIC_API_KEY !== undefined) {
312
- return {
313
- name: process.env.ANTHROPIC_MODEL ?? "claude-sonnet-5",
314
- profile: {
315
- kind: "anthropic",
316
- model: process.env.ANTHROPIC_MODEL ?? "claude-sonnet-5",
317
- apiKeyEnv: "ANTHROPIC_API_KEY",
318
- // PH-1c (finding PH-F19): symmetric with OPENAI_BASE_URL —
319
- // proxies and compat gateways serve the anthropic dialect too.
320
- ...(process.env.ANTHROPIC_BASE_URL !== undefined ? { baseUrl: process.env.ANTHROPIC_BASE_URL } : {}),
321
- },
322
- apiKey: process.env.ANTHROPIC_API_KEY,
328
+ // Astra F2, the sibling. Same rule, same reason.
329
+ const profile = {
330
+ kind: "anthropic",
331
+ model: process.env.ANTHROPIC_MODEL ?? "claude-sonnet-5",
332
+ apiKeyEnv: "ANTHROPIC_API_KEY",
333
+ // PH-1c (finding PH-F19): symmetric with OPENAI_BASE_URL —
334
+ // proxies and compat gateways serve the anthropic dialect too.
335
+ ...(process.env.ANTHROPIC_BASE_URL !== undefined ? { baseUrl: process.env.ANTHROPIC_BASE_URL } : {}),
323
336
  };
337
+ const auth = authForProfile(process.env.ANTHROPIC_MODEL ?? "claude-sonnet-5", profile);
338
+ // The same total shape the config route returns (resolveProfile).
339
+ // The `oauth` arm is unreachable from HERE — authForProfile returns
340
+ // oauth only for an openai-responses profile and this route builds
341
+ // an anthropic one — but writing the union out beats a fallback
342
+ // to the raw env var, which is the very disagreement F2 removes.
343
+ return auth.type === "oauth"
344
+ ? { name: process.env.ANTHROPIC_MODEL ?? "claude-sonnet-5", profile, oauthProviderId: auth.providerId }
345
+ : { name: process.env.ANTHROPIC_MODEL ?? "claude-sonnet-5", profile, apiKey: auth.apiKey };
324
346
  }
325
347
  // config (project wins over user) names a model; default is faux.
326
348
  const configured = merged.model;
@@ -351,10 +373,15 @@ export function resolveModeFromConfig(merged) {
351
373
  }
352
374
  /** Context window: env (KISO_CONTEXT_WINDOW) > config.contextWindow >
353
375
  * default (200k — the caller's default). */
354
- export function resolveContextWindow(merged) {
376
+ export function resolveContextWindow(merged, profile) {
355
377
  const fromEnv = Number.parseInt(process.env.KISO_CONTEXT_WINDOW ?? "", 10);
356
378
  if (Number.isFinite(fromEnv) && fromEnv > 0)
357
379
  return fromEnv;
380
+ // The PROFILE's window beats the global one: it is stated about a
381
+ // specific model, and the global figure is a default for whatever is
382
+ // selected. Env still beats both — someone who set it meant it.
383
+ if (profile?.contextWindow !== undefined && profile.contextWindow > 0)
384
+ return profile.contextWindow;
358
385
  return merged.contextWindow;
359
386
  }
360
387
  /** Auto-compact: env (KISO_AUTO_COMPACT) > config.autoCompact > off. An
package/dist/dispatch.js CHANGED
@@ -8,8 +8,10 @@ import { newSessionId } from "./session-id.js";
8
8
  import { buildAdapter, lookupModelMetadata, resolveContinuationScope, resolveReasoning } from "@vincemakes/kiso-runtime/internal";
9
9
  import { MODES, MODE_NOTE, getMode, setMode } from "./mode.js";
10
10
  import { clipboardWrite, lastAnswer } from "./clipboard.js";
11
- import { agentModel, body, bodyLog, codingToolOptions, kisoHome, configModels, dock, lastBinding, readContextLedger, sessionsDir, setAgentModel, setCurrentModelName, setModelChoice, setLastBinding } from "./state.js";
12
- import { authForProfile, directWriteProfile, profileAvailable, unavailableReason } from "./config.js";
11
+ import { agentModel, body, bodyLog, codingToolOptions, kisoHome, configModels, dock, lastBinding, mergedConfig, readContextLedger, sessionsDir, setAgentModel, setConfiguredWindow, setCurrentModelName, setModelChoice, setLastBinding } from "./state.js";
12
+ import { adapterOptionsFor } from "./auth/adapter-options.js";
13
+ import { microcompactThresholdFor } from "./chat.js";
14
+ import { authForProfile, directWriteProfile, profileAvailable, resolveContextWindow, unavailableReason } from "./config.js";
13
15
  import { shellTool } from "@vincemakes/kiso-tools-node";
14
16
  import { join } from "node:path";
15
17
  /** The picker's CLI half (owner 2026-09-08): a profile's LEGAL effort levels
@@ -74,7 +76,6 @@ function signInNote(p) {
74
76
  }
75
77
  return p.apiKeyEnv ?? "no key";
76
78
  }
77
- import { oauthTokenThunk } from "./auth/token.js";
78
79
  /** The ONE dispatcher — slash commands, exit, and turns. The recovery
79
80
  * replay routes through it too — a queued "/last" must never become a
80
81
  * user turn (v2c: the rl lives in main, so lines arrive earlier and the
@@ -601,18 +602,11 @@ export function dispatch(line, ctx) {
601
602
  }
602
603
  else {
603
604
  const auth = authForProfile(profName, profile);
604
- const adapter = await buildAdapter(profile.kind, {
605
- // PH-1c (PH-F19): a keyless profile = an unauthenticated
606
- // endpoint — the placeholder satisfies the SDK's ctor.
607
- // OR-1: an OAuth profile passes a token thunk instead
608
- // — there is no key, and the adapter re-resolves the
609
- // token per request.
610
- ...(auth.type === "oauth"
611
- ? { oauth: oauthTokenThunk(auth.providerId), ...(ctx.session.id !== undefined ? { promptCacheKey: ctx.session.id } : {}) }
612
- : { apiKey: auth.apiKey }),
613
- ...(profile.baseUrl !== undefined ? { baseUrl: profile.baseUrl } : {}),
614
- ...(profile.promptCaching !== undefined ? { promptCaching: profile.promptCaching } : {}),
615
- });
605
+ // Astra F1 (P0): the wire config is built in ONE place
606
+ // (auth/adapter-options.ts) — this site and the startup
607
+ // site were the same spread written twice, which is the
608
+ // finding's own defect class.
609
+ const adapter = await buildAdapter(profile.kind, adapterOptionsFor(profile, auth, ctx.session.id));
616
610
  // PH-1a (finding PH-F8, P0): the switch is ATOMIC —
617
611
  // adapter, model id, and provider route move together.
618
612
  // setAdapter alone left the session's frozen config
@@ -644,6 +638,23 @@ export function dispatch(line, ctx) {
644
638
  adapter,
645
639
  model: profile.model,
646
640
  provider: profile.kind,
641
+ // CTX-1: the compaction threshold is derived from the
642
+ // LIVE model's window, so it moves with the binding.
643
+ // It used to be computed once at startup: switching
644
+ // from a 200k model to a 1M one showed 1,000,000 on
645
+ // the status row while still clearing tool results at
646
+ // 100,000, and switching the other way waited for
647
+ // 500,000 in a window that cannot hold it.
648
+ //
649
+ // PH-F8 fixed this class for the model id, the
650
+ // provider, the endpoint and the scope. This is the
651
+ // field one over that was never asked.
652
+ microcompact: {
653
+ thresholdTokens: microcompactThresholdFor({
654
+ model: profile.model,
655
+ ...(profile.baseUrl !== undefined ? { baseUrl: profile.baseUrl } : {}),
656
+ }),
657
+ },
647
658
  // OR-1: the endpoint is the fourth passenger — the cost
648
659
  // path and the window lookup key on (model, endpoint).
649
660
  ...(profile.baseUrl !== undefined ? { baseUrl: profile.baseUrl } : {}),
@@ -657,6 +668,13 @@ export function dispatch(line, ctx) {
657
668
  // §2.5: the ONE source a reload reads for the model — a
658
669
  // switch made here must survive the rebuild.
659
670
  setModelChoice(arg);
671
+ // The window a PROFILE states travels with the switch,
672
+ // like the model id and the endpoint. Leaving it behind
673
+ // is CTX-1 one field over: the row would show a
674
+ // percentage of the OUTGOING model's window. An
675
+ // explicit env still wins, which resolveContextWindow
676
+ // decides.
677
+ setConfiguredWindow(resolveContextWindow(mergedConfig, profile));
660
678
  // OR-7 (owner, 2026-09-09): the status row is repainted BY the
661
679
  // switch — it used to keep the old model until the next
662
680
  // recap repainted it, so "takes effect on the next turn"
package/dist/index.d.ts CHANGED
@@ -31,7 +31,7 @@ export { applyProjectMerges } from "./trust-ui.js";
31
31
  */
32
32
  /** The built-in prompt. Exported for scripts/request-surface.mjs — the
33
33
  * model-side token-rent counter measures the REAL bytes, never a copy. */
34
- export declare const SYSTEM_PROMPT = "You are kiso, a coding agent. You work in a workspace\ndirectory and change code with tools. Be concise: answer in a few lines\nunless the task genuinely needs more. Never claim a file was changed\nunless a tool confirmed it.\n\nTool discipline:\n- READ BEFORE YOU EDIT. For any file you are about to change, read it\n first \u2014 never guess its content.\n- Use edit_file for targeted changes and write_file for full rewrites.\n Prefer many small edits over one large write.\n- shell is for commands: builds, tests, git, grep. Be careful \u2014 shell has\n side effects and may take time. Run one command at a time and inspect\n the output before continuing.\n- Batch independent tool calls into one reply \u2014 they run in parallel.\n- search_text and list_dir are cheap \u2014 locate first, then read ranges\n with read_file offset/limit; never read a whole large file in one call.\n- Do not re-read a file you already read unchanged \u2014 rely on the earlier\n result.\n- When a tool fails, read the error and adjust; do not repeat the same\n call blindly.\n\nWorkflow: understand the request, find the relevant code, make the\nsmallest change that works, then verify with a command (tests/build).\nReport what you did in one or two lines per change.";
34
+ export declare const SYSTEM_PROMPT = "You are kiso, a coding agent. You work in a workspace\ndirectory and change code with tools. Be concise: answer in a few lines\nunless the task genuinely needs more. Never claim a file was changed\nunless a tool confirmed it.\n\nWhat you can reach:\n- The workspace: read_file, list_dir, search_text, write_file, edit_file.\n- This machine and the network: shell \u2014 builds, tests, git, package\n managers, curl for HTTP, system queries. A request one command can\n answer is answered by running it.\n- The human: ask_user, for a decision that is theirs.\n\nTool discipline:\n- READ BEFORE YOU EDIT. For any file you are about to change, read it\n first \u2014 never guess its content.\n- Use edit_file for targeted changes and write_file for full rewrites.\n Prefer many small edits over one large write.\n- shell is for commands: builds, tests, git, grep. Be careful \u2014 shell has\n side effects and may take time. Run one command at a time and inspect\n the output before continuing.\n- Batch independent tool calls into one reply \u2014 they run in parallel.\n- search_text and list_dir are cheap \u2014 locate first, then read ranges\n with read_file offset/limit; never read a whole large file in one call.\n- Do not re-read a file you already read unchanged, or one you changed\n yourself through a confirmed edit \u2014 rely on the earlier result and\n on the change you just made.\n- When a tool fails, read the error and adjust; do not repeat the same\n call blindly.\n\nAn authorization covers what it NAMES: when the scope is not named \u2014\nwhich files, which branches, whether to push \u2014 ask before acting.\nDelivering, sharing or handing over changes means committing locally and\nstopping; pushing, publishing or sending happens only when the human\nnames it.\n\nWorkflow: understand the request, find the relevant code, make the\nsmallest change that works, then verify with a command (tests/build).\nReport what you did in one or two lines per change.";
35
35
  /** Hard cap for injected instructions — truncate and say so. */
36
36
  /**
37
37
  * A area: read the FIRST present instruction file (AGENTS.md preferred) and