@vincemakes/kiso-code 0.1.21 → 0.1.23

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
@@ -21,10 +21,10 @@ export interface AutoCompact {
21
21
  /** Parse KISO_AUTO_COMPACT — an invalid value is OFF, never a crash. */
22
22
  export declare function autoCompactFromEnv(): AutoCompact | undefined;
23
23
  /**
24
- * C 区: the model window in tokens — KISO_CONTEXT_WINDOW overrides the
25
- * 200k default. The microcompact threshold is derived from it (50%), and
26
- * the status line's ~ctx estimate is measured against it — one source of
27
- * truth for the window.
24
+ * C 区: the model window in tokens — env (KISO_CONTEXT_WINDOW) beats the
25
+ * config window (合并轮 B), both beat the 200k default. The microcompact
26
+ * threshold is derived from it (50%), and the status line's ~ctx estimate
27
+ * is measured against it — one source of truth for the window.
28
28
  */
29
29
  export declare function contextWindowTokens(): number;
30
30
  /**
package/dist/chat.js CHANGED
@@ -9,7 +9,7 @@ import { escapeTerminal, kUnit, palette, renderEvent, renderRecap } from "@vince
9
9
  import { editFileDiff, writeFileDiff } from "@vincemakes/kiso-tui";
10
10
  import { canonicalTargetPath } from "@vincemakes/kiso-tools-node";
11
11
  import { dispatch } from "./dispatch.js";
12
- import { CANCELLED, agentModel, body, bodyLog, dock } from "./state.js";
12
+ import { CANCELLED, agentModel, body, bodyLog, configuredWindow, dock } from "./state.js";
13
13
  import { ask, pendingAsk, resolveUncertains } from "./trust-ui.js";
14
14
  import { FauxExhaustionError, failOnFauxExhaustion } from "./faux-glue.js";
15
15
  import { MODES, getMode, setMode } from "./mode.js";
@@ -26,12 +26,15 @@ export function autoCompactFromEnv() {
26
26
  return { thresholdRatio: ratio };
27
27
  }
28
28
  /**
29
- * C 区: the model window in tokens — KISO_CONTEXT_WINDOW overrides the
30
- * 200k default. The microcompact threshold is derived from it (50%), and
31
- * the status line's ~ctx estimate is measured against it — one source of
32
- * truth for the window.
29
+ * C 区: the model window in tokens — env (KISO_CONTEXT_WINDOW) beats the
30
+ * config window (合并轮 B), both beat the 200k default. The microcompact
31
+ * threshold is derived from it (50%), and the status line's ~ctx estimate
32
+ * is measured against it — one source of truth for the window.
33
33
  */
34
34
  export function contextWindowTokens() {
35
+ const windowOverride = configuredWindow;
36
+ if (windowOverride !== undefined)
37
+ return windowOverride;
35
38
  const window = Number.parseInt(process.env.KISO_CONTEXT_WINDOW ?? "", 10);
36
39
  return Number.isFinite(window) && window > 0 ? window : DEFAULT_CONTEXT_WINDOW;
37
40
  }
@@ -303,7 +306,7 @@ export async function chat(session, faux, input, autoCompact) {
303
306
  // 手感批 C8: the opt-in auto-compact — checked AFTER the
304
307
  // turn ended (the run's terminal is in the log, the ratio
305
308
  // is post-run).
306
- maybeAutoCompact();
309
+ await maybeAutoCompact();
307
310
  resolve();
308
311
  }
309
312
  catch (err) {
@@ -419,15 +422,19 @@ export async function chat(session, faux, input, autoCompact) {
419
422
  // 手感批 C8: the auto-compact check — the /compact FULL path via the
420
423
  // shared dispatch (same notices, same chain ordering, same mid-run
421
424
  // refusal — the isRunning guard here only avoids the refusal's noise).
425
+ // The appended segment is NOT awaited here on purpose: from inside a
426
+ // chain segment, awaiting the append would be circular (the segment
427
+ // chains after THIS segment's promise). The exit path re-awaits the
428
+ // chain once more after the turn — see the final awaits in chat().
422
429
  const maybeAutoCompact = () => {
423
430
  if (autoCompact === undefined)
424
431
  return;
425
432
  if (currentRun !== null)
426
433
  return; // dispatch would refuse — skip the noise
427
434
  const ratio = estimateCtxRatio(session);
428
- if (Number.isFinite(ratio) && ratio >= autoCompact.thresholdRatio) {
429
- dispatch("/compact", dispatchCtx);
430
- }
435
+ if (!Number.isFinite(ratio) || ratio < autoCompact.thresholdRatio)
436
+ return;
437
+ dispatch("/compact", dispatchCtx);
431
438
  };
432
439
  input.onLine((line) => {
433
440
  if (!replReady) {
@@ -449,7 +456,7 @@ export async function chat(session, faux, input, autoCompact) {
449
456
  const last = await consumeRun(session, recoveryRun, input, turnNo, faux, liveInput, statusCb);
450
457
  currentRun = null;
451
458
  failOnFauxExhaustion(last, faux, input);
452
- maybeAutoCompact(); // 手感批 C8: the recovery run ended too — same check
459
+ maybeAutoCompact(); // 手感批 C8: the recovery run ended too — same check (awaited by the exit re-await)
453
460
  }
454
461
  if (cancelled) {
455
462
  input.close();
@@ -470,4 +477,10 @@ export async function chat(session, faux, input, autoCompact) {
470
477
  input.prompt();
471
478
  await input.closed;
472
479
  await chainRef.current; // never exit while a turn is in flight
480
+ // 手感批 C8: the auto-compact may have appended ITS segment inside the
481
+ // turn (the check runs at the turn's end, after the exit-await above
482
+ // already captured the chain) — re-await once so the summarize either
483
+ // runs before the exit or the chain is already settled. One level is
484
+ // enough: the /compact segment appends nothing of its own.
485
+ await chainRef.current;
473
486
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * 合并轮 B — the config surface (schema v1).
3
+ *
4
+ * Two files: the user config `~/.kiso/config.json` and the project config
5
+ * `<cwd>/.kiso/config.json` (an artifact of the E3 trust package — a
6
+ * trusted project's config applies, an untrusted one is never even read).
7
+ * Precedence: flags > env > project config > user config > defaults.
8
+ *
9
+ * Schema v1:
10
+ * model?: string — a profile NAME (models.<name>) or "provider/model"
11
+ * direct write (provider: "openai-compat" | "anthropic")
12
+ * models?: { [name]: { kind: "openai-compat"|"anthropic",
13
+ * baseUrl?: string, model: string, apiKeyEnv: string } }
14
+ * mode?: "manual"|"default"|"accept-edits"|"plan"|"bypass"
15
+ * contextWindow?: number — tokens
16
+ * autoCompact?: { thresholdRatio: number } — 0<r<1; default off
17
+ * projectTrust?: "ask" | "never" — no "always" (the ruling)
18
+ *
19
+ * Credential discipline (HARD): a config NEVER stores a key — only the
20
+ * apiKeyEnv NAME it reads from the environment at use time. A profile
21
+ * whose apiKeyEnv is unset is UNAVAILABLE (listed as such; switching to
22
+ * it is refused loudly) — never a crash.
23
+ *
24
+ * Failure discipline: a broken JSON file fails LOUDLY (file + reason,
25
+ * non-zero exit) — a silently ignored config would mislead. A known key
26
+ * with an invalid value is likewise loud; unknown top-level keys are
27
+ * ignored (forward compatibility).
28
+ */
29
+ import type { Mode } from "./mode.js";
30
+ export type ProfileKind = "openai-compat" | "anthropic";
31
+ export interface ModelProfile {
32
+ readonly kind: ProfileKind;
33
+ readonly baseUrl?: string;
34
+ readonly model: string;
35
+ /** The env var NAME holding the key — never the key itself. */
36
+ readonly apiKeyEnv: string;
37
+ }
38
+ export interface AutoCompactConfig {
39
+ readonly thresholdRatio: number;
40
+ }
41
+ export interface KisoConfig {
42
+ readonly model?: string;
43
+ readonly models?: Readonly<Record<string, ModelProfile>>;
44
+ readonly mode?: Mode;
45
+ readonly contextWindow?: number;
46
+ readonly autoCompact?: AutoCompactConfig;
47
+ readonly projectTrust?: "ask" | "never";
48
+ }
49
+ /** The resolved, merged config — project wins over user, both validated. */
50
+ export interface ResolvedConfig {
51
+ readonly user: KisoConfig;
52
+ readonly project: KisoConfig;
53
+ }
54
+ export declare class ConfigError extends Error {
55
+ }
56
+ /** Parse + validate one config file. Broken JSON / invalid known values →
57
+ * ConfigError with the file path (LOUD). Unknown keys pass (forward compat). */
58
+ export declare function parseConfig(text: string, source: string): KisoConfig;
59
+ /** The user config — never gated (the user's own file). */
60
+ export declare function loadUserConfig(): KisoConfig | null;
61
+ /** The project config — read ONLY when the project's .kiso passed the E3
62
+ * trust gate (an untrusted project's config is never even read). */
63
+ export declare function loadProjectConfig(cwd: string, trusted: boolean): KisoConfig | null;
64
+ /** Merge: project wins over user (each layer's own keys only). */
65
+ export declare function mergeConfigs(user: KisoConfig | null, project: KisoConfig | null): KisoConfig;
66
+ /** A profile is available when its apiKeyEnv var is set (the key exists). */
67
+ export declare function profileAvailable(p: ModelProfile): boolean;
68
+ /** The resolved runtime model — what the adapter is built from. */
69
+ export interface ResolvedModel {
70
+ readonly name: string;
71
+ readonly profile: ModelProfile;
72
+ readonly apiKey: string;
73
+ }
74
+ /** "provider/model" direct write → a profile. */
75
+ export declare function directWriteProfile(value: string): ModelProfile | null;
76
+ /**
77
+ * Resolve the model — precedence: --model flag > env (the OPENAI_* /
78
+ * ANTHROPIC_* key vars) > project config > user config > default (faux).
79
+ * The flag names a profile or writes provider/model directly. A named
80
+ * profile that does not exist is a LOUD ConfigError; an unavailable one
81
+ * (env key missing) is refused with the reason — never a silent fallback.
82
+ * Returns null when nothing resolves (faux mode).
83
+ */
84
+ export declare function resolveModel(modelFlag: string | undefined, merged: KisoConfig): ResolvedModel | null;
85
+ /** Mode: env (KISO_MODE) beats config.mode; the --mode flag is applied by
86
+ * main before this runs (flags are the top of the chain). */
87
+ export declare function resolveModeFromConfig(merged: KisoConfig): Mode | undefined;
88
+ /** Context window: env (KISO_CONTEXT_WINDOW) > config.contextWindow >
89
+ * default (200k — the caller's default). */
90
+ export declare function resolveContextWindow(merged: KisoConfig): number | undefined;
91
+ /** Auto-compact: env (KISO_AUTO_COMPACT) > config.autoCompact > off. An
92
+ * invalid env value is OFF (it set the env → it wins, and it is invalid). */
93
+ export declare function resolveAutoCompact(merged: KisoConfig): AutoCompactConfig | undefined;
94
+ /** Project-trust policy: config.projectTrust (project wins over user) —
95
+ * "ask" (the E3 gate, default) or "never" (the gate auto-refuses). */
96
+ export declare function resolveProjectTrustPolicy(merged: KisoConfig): "ask" | "never";
package/dist/config.js ADDED
@@ -0,0 +1,263 @@
1
+ /**
2
+ * 合并轮 B — the config surface (schema v1).
3
+ *
4
+ * Two files: the user config `~/.kiso/config.json` and the project config
5
+ * `<cwd>/.kiso/config.json` (an artifact of the E3 trust package — a
6
+ * trusted project's config applies, an untrusted one is never even read).
7
+ * Precedence: flags > env > project config > user config > defaults.
8
+ *
9
+ * Schema v1:
10
+ * model?: string — a profile NAME (models.<name>) or "provider/model"
11
+ * direct write (provider: "openai-compat" | "anthropic")
12
+ * models?: { [name]: { kind: "openai-compat"|"anthropic",
13
+ * baseUrl?: string, model: string, apiKeyEnv: string } }
14
+ * mode?: "manual"|"default"|"accept-edits"|"plan"|"bypass"
15
+ * contextWindow?: number — tokens
16
+ * autoCompact?: { thresholdRatio: number } — 0<r<1; default off
17
+ * projectTrust?: "ask" | "never" — no "always" (the ruling)
18
+ *
19
+ * Credential discipline (HARD): a config NEVER stores a key — only the
20
+ * apiKeyEnv NAME it reads from the environment at use time. A profile
21
+ * whose apiKeyEnv is unset is UNAVAILABLE (listed as such; switching to
22
+ * it is refused loudly) — never a crash.
23
+ *
24
+ * Failure discipline: a broken JSON file fails LOUDLY (file + reason,
25
+ * non-zero exit) — a silently ignored config would mislead. A known key
26
+ * with an invalid value is likewise loud; unknown top-level keys are
27
+ * ignored (forward compatibility).
28
+ */
29
+ import { readFileSync } from "node:fs";
30
+ import { join } from "node:path";
31
+ import { kisoHome } from "./state.js";
32
+ const KINDS = ["openai-compat", "anthropic"];
33
+ const MODES_LIST = ["manual", "default", "accept-edits", "plan", "bypass"];
34
+ export class ConfigError extends Error {
35
+ }
36
+ /** Parse + validate one config file. Broken JSON / invalid known values →
37
+ * ConfigError with the file path (LOUD). Unknown keys pass (forward compat). */
38
+ export function parseConfig(text, source) {
39
+ let raw;
40
+ try {
41
+ raw = JSON.parse(text);
42
+ }
43
+ catch (err) {
44
+ throw new ConfigError(`config ${source}: broken JSON — ${err instanceof Error ? err.message : String(err)}`);
45
+ }
46
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
47
+ throw new ConfigError(`config ${source}: the root must be a JSON object`);
48
+ }
49
+ const out = {};
50
+ const obj = raw;
51
+ const fail = (key, why) => {
52
+ throw new ConfigError(`config ${source}: ${key} — ${why}`);
53
+ };
54
+ if (obj.model !== undefined) {
55
+ if (typeof obj.model !== "string" || obj.model === "")
56
+ fail("model", "expected a profile name or provider/model string");
57
+ out.model = obj.model;
58
+ }
59
+ if (obj.models !== undefined) {
60
+ if (obj.models === null || typeof obj.models !== "object" || Array.isArray(obj.models))
61
+ fail("models", "expected an object of profiles");
62
+ const models = {};
63
+ for (const [name, v] of Object.entries(obj.models)) {
64
+ if (v === null || typeof v !== "object" || Array.isArray(v))
65
+ fail(`models.${name}`, "expected a profile object");
66
+ const p = v;
67
+ if (typeof p.kind !== "string" || !KINDS.includes(p.kind))
68
+ fail(`models.${name}.kind`, `expected one of ${KINDS.join(", ")}`);
69
+ if (typeof p.model !== "string" || p.model === "")
70
+ fail(`models.${name}.model`, "expected a model string");
71
+ if (typeof p.apiKeyEnv !== "string" || p.apiKeyEnv === "")
72
+ fail(`models.${name}.apiKeyEnv`, "expected an env var name (the config never stores keys)");
73
+ if (p.baseUrl !== undefined && typeof p.baseUrl !== "string")
74
+ fail(`models.${name}.baseUrl`, "expected a string");
75
+ models[name] = {
76
+ kind: p.kind,
77
+ model: p.model,
78
+ apiKeyEnv: p.apiKeyEnv,
79
+ ...(typeof p.baseUrl === "string" ? { baseUrl: p.baseUrl } : {}),
80
+ };
81
+ }
82
+ out.models = models;
83
+ }
84
+ if (obj.mode !== undefined) {
85
+ if (typeof obj.mode !== "string" || !MODES_LIST.includes(obj.mode))
86
+ fail("mode", `expected one of ${MODES_LIST.join(", ")}`);
87
+ out.mode = obj.mode;
88
+ }
89
+ if (obj.contextWindow !== undefined) {
90
+ if (typeof obj.contextWindow !== "number" || !Number.isFinite(obj.contextWindow) || obj.contextWindow <= 0) {
91
+ fail("contextWindow", "expected a positive token count");
92
+ }
93
+ out.contextWindow = obj.contextWindow;
94
+ }
95
+ if (obj.autoCompact !== undefined) {
96
+ if (obj.autoCompact === null || typeof obj.autoCompact !== "object" || Array.isArray(obj.autoCompact))
97
+ fail("autoCompact", "expected { thresholdRatio }");
98
+ const r = obj.autoCompact.thresholdRatio;
99
+ if (typeof r !== "number" || !Number.isFinite(r) || r <= 0 || r >= 1)
100
+ fail("autoCompact.thresholdRatio", "expected a number strictly between 0 and 1");
101
+ out.autoCompact = { thresholdRatio: r };
102
+ }
103
+ if (obj.projectTrust !== undefined) {
104
+ if (obj.projectTrust !== "ask" && obj.projectTrust !== "never")
105
+ fail("projectTrust", 'expected "ask" or "never" (there is deliberately no "always")');
106
+ out.projectTrust = obj.projectTrust;
107
+ }
108
+ return out;
109
+ }
110
+ function readConfigFile(path, source) {
111
+ let text;
112
+ try {
113
+ text = readFileSync(path, "utf8");
114
+ }
115
+ catch (err) {
116
+ if (err.code === "ENOENT")
117
+ return null;
118
+ throw err;
119
+ }
120
+ return parseConfig(text, source);
121
+ }
122
+ /** The user config — never gated (the user's own file). */
123
+ export function loadUserConfig() {
124
+ return readConfigFile(join(kisoHome(), "config.json"), "~/.kiso/config.json");
125
+ }
126
+ /** The project config — read ONLY when the project's .kiso passed the E3
127
+ * trust gate (an untrusted project's config is never even read). */
128
+ export function loadProjectConfig(cwd, trusted) {
129
+ if (!trusted)
130
+ return null;
131
+ return readConfigFile(join(cwd, ".kiso", "config.json"), "<cwd>/.kiso/config.json");
132
+ }
133
+ /** Merge: project wins over user (each layer's own keys only). */
134
+ export function mergeConfigs(user, project) {
135
+ const u = user ?? {};
136
+ const p = project ?? {};
137
+ return {
138
+ ...(u.model !== undefined ? { model: u.model } : {}),
139
+ ...(p.model !== undefined ? { model: p.model } : {}),
140
+ ...(u.models !== undefined ? { models: u.models } : {}),
141
+ ...(p.models !== undefined ? { models: { ...u.models, ...p.models } } : u.models !== undefined ? { models: u.models } : {}),
142
+ ...(u.mode !== undefined ? { mode: u.mode } : {}),
143
+ ...(p.mode !== undefined ? { mode: p.mode } : {}),
144
+ ...(u.contextWindow !== undefined ? { contextWindow: u.contextWindow } : {}),
145
+ ...(p.contextWindow !== undefined ? { contextWindow: p.contextWindow } : {}),
146
+ ...(u.autoCompact !== undefined ? { autoCompact: u.autoCompact } : {}),
147
+ ...(p.autoCompact !== undefined ? { autoCompact: p.autoCompact } : {}),
148
+ ...(u.projectTrust !== undefined ? { projectTrust: u.projectTrust } : {}),
149
+ ...(p.projectTrust !== undefined ? { projectTrust: p.projectTrust } : {}),
150
+ };
151
+ }
152
+ /** A profile is available when its apiKeyEnv var is set (the key exists). */
153
+ export function profileAvailable(p) {
154
+ return process.env[p.apiKeyEnv] !== undefined;
155
+ }
156
+ /** "provider/model" direct write → a profile. */
157
+ export function directWriteProfile(value) {
158
+ const slash = value.indexOf("/");
159
+ if (slash <= 0 || slash === value.length - 1)
160
+ return null;
161
+ const kind = value.slice(0, slash);
162
+ if (kind !== "openai-compat" && kind !== "anthropic")
163
+ return null;
164
+ return {
165
+ kind,
166
+ model: value.slice(slash + 1),
167
+ apiKeyEnv: kind === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY",
168
+ };
169
+ }
170
+ /**
171
+ * Resolve the model — precedence: --model flag > env (the OPENAI_* /
172
+ * ANTHROPIC_* key vars) > project config > user config > default (faux).
173
+ * The flag names a profile or writes provider/model directly. A named
174
+ * profile that does not exist is a LOUD ConfigError; an unavailable one
175
+ * (env key missing) is refused with the reason — never a silent fallback.
176
+ * Returns null when nothing resolves (faux mode).
177
+ */
178
+ export function resolveModel(modelFlag, merged) {
179
+ if (modelFlag !== undefined) {
180
+ const direct = directWriteProfile(modelFlag);
181
+ if (direct !== null)
182
+ return resolveProfile(modelFlag, direct);
183
+ const p = merged.models?.[modelFlag];
184
+ if (p === undefined)
185
+ throw new ConfigError(`unknown model profile: ${modelFlag} (see models in ~/.kiso/config.json)`);
186
+ return resolveProfile(modelFlag, p);
187
+ }
188
+ // env beats config: a key in the environment names the provider.
189
+ if (process.env.OPENAI_API_KEY !== undefined) {
190
+ return {
191
+ name: process.env.OPENAI_MODEL ?? "gpt-4o",
192
+ profile: {
193
+ kind: "openai-compat",
194
+ model: process.env.OPENAI_MODEL ?? "gpt-4o",
195
+ ...(process.env.OPENAI_BASE_URL !== undefined ? { baseUrl: process.env.OPENAI_BASE_URL } : {}),
196
+ apiKeyEnv: "OPENAI_API_KEY",
197
+ },
198
+ apiKey: process.env.OPENAI_API_KEY,
199
+ };
200
+ }
201
+ if (process.env.ANTHROPIC_API_KEY !== undefined) {
202
+ return {
203
+ name: process.env.ANTHROPIC_MODEL ?? "claude-sonnet-5",
204
+ profile: {
205
+ kind: "anthropic",
206
+ model: process.env.ANTHROPIC_MODEL ?? "claude-sonnet-5",
207
+ apiKeyEnv: "ANTHROPIC_API_KEY",
208
+ },
209
+ apiKey: process.env.ANTHROPIC_API_KEY,
210
+ };
211
+ }
212
+ // config (project wins over user) names a model; default is faux.
213
+ const configured = merged.model;
214
+ if (configured !== undefined) {
215
+ const direct = directWriteProfile(configured);
216
+ if (direct !== null)
217
+ return resolveProfile(configured, direct);
218
+ const p = merged.models?.[configured];
219
+ if (p === undefined)
220
+ throw new ConfigError(`config model "${configured}" is not a defined profile (see models in ~/.kiso/config.json)`);
221
+ return resolveProfile(configured, p);
222
+ }
223
+ return null;
224
+ }
225
+ function resolveProfile(name, p) {
226
+ if (!profileAvailable(p)) {
227
+ throw new ConfigError(`model ${name}: unavailable — the env var ${p.apiKeyEnv} is not set (configs never store keys, only the env-var name)`);
228
+ }
229
+ return { name, profile: p, apiKey: process.env[p.apiKeyEnv] };
230
+ }
231
+ /** Mode: env (KISO_MODE) beats config.mode; the --mode flag is applied by
232
+ * main before this runs (flags are the top of the chain). */
233
+ export function resolveModeFromConfig(merged) {
234
+ const fromEnv = process.env.KISO_MODE;
235
+ if (fromEnv !== undefined)
236
+ return fromEnv;
237
+ return merged.mode;
238
+ }
239
+ /** Context window: env (KISO_CONTEXT_WINDOW) > config.contextWindow >
240
+ * default (200k — the caller's default). */
241
+ export function resolveContextWindow(merged) {
242
+ const fromEnv = Number.parseInt(process.env.KISO_CONTEXT_WINDOW ?? "", 10);
243
+ if (Number.isFinite(fromEnv) && fromEnv > 0)
244
+ return fromEnv;
245
+ return merged.contextWindow;
246
+ }
247
+ /** Auto-compact: env (KISO_AUTO_COMPACT) > config.autoCompact > off. An
248
+ * invalid env value is OFF (it set the env → it wins, and it is invalid). */
249
+ export function resolveAutoCompact(merged) {
250
+ const raw = process.env.KISO_AUTO_COMPACT;
251
+ if (raw !== undefined) {
252
+ const ratio = Number.parseFloat(raw);
253
+ if (!Number.isFinite(ratio) || ratio <= 0 || ratio >= 1)
254
+ return undefined;
255
+ return { thresholdRatio: ratio };
256
+ }
257
+ return merged.autoCompact;
258
+ }
259
+ /** Project-trust policy: config.projectTrust (project wins over user) —
260
+ * "ask" (the E3 gate, default) or "never" (the gate auto-refuses). */
261
+ export function resolveProjectTrustPolicy(merged) {
262
+ return merged.projectTrust ?? "ask";
263
+ }
@@ -3,7 +3,7 @@
3
3
  * turns. The bodies moved verbatim from chat()'s closure; chat provides
4
4
  * the context (the chain, the run state, the prompt arming).
5
5
  */
6
- import type { AgentSession } from "@vincemakes/kiso-runtime";
6
+ import { type AgentSession } from "@vincemakes/kiso-runtime";
7
7
  import { type LineInput } from "./state.js";
8
8
  /** Everything dispatch touches that chat() owns. */
9
9
  export interface DispatchCtx {
package/dist/dispatch.js CHANGED
@@ -4,8 +4,10 @@
4
4
  * the context (the chain, the run state, the prompt arming).
5
5
  */
6
6
  import { escapeTerminal, palette } from "@vincemakes/kiso-tui";
7
+ import { buildAdapter } from "@vincemakes/kiso-runtime";
7
8
  import { MODES, getMode, setMode } from "./mode.js";
8
- import { body, bodyLog } from "./state.js";
9
+ import { agentModel, body, bodyLog, configModels, setAgentModel, setCurrentModelName } from "./state.js";
10
+ import { directWriteProfile, profileAvailable } from "./config.js";
9
11
  /** The ONE dispatcher — slash commands, exit, and turns. The recovery
10
12
  * replay routes through it too — a queued "/last" must never become a
11
13
  * user turn (v2c: the rl lives in main, so lines arrive earlier and the
@@ -23,6 +25,7 @@ export function dispatch(line, ctx) {
23
25
  bodyLog(cmd("/last", "show the most recent tool call's input and output"));
24
26
  bodyLog(cmd("/status", "show session id, event count, and context estimate"));
25
27
  bodyLog(cmd("/mode", "show the approval tier; /mode <name> switches (manual/default/accept-edits/plan/bypass)"));
28
+ bodyLog(cmd("/model", "list model profiles; /model <name|provider/model> switches"));
26
29
  bodyLog(cmd("/compact", "summarize the older conversation to free context"));
27
30
  bodyLog(cmd("exit", "leave the session"));
28
31
  ctx.input.prompt();
@@ -100,6 +103,57 @@ export function dispatch(line, ctx) {
100
103
  });
101
104
  return;
102
105
  }
106
+ if (trimmed === "/model" || trimmed.startsWith("/model ")) {
107
+ // 合并轮 B: /model lists the profiles (with availability — the
108
+ // config never stores keys, only apiKeyEnv NAMES; an unset env
109
+ // marks the profile unavailable, never a crash) and switches the
110
+ // session's adapter — the NEXT turn uses it (session.setAdapter),
111
+ // the notice cell leaves the audit line in the body.
112
+ ctx.chainRef.current = ctx.chainRef.current.then(async () => {
113
+ const arg = trimmed.slice(6).trim();
114
+ if (arg === "") {
115
+ bodyLog(`model: ${agentModel}`);
116
+ const names = Object.keys(configModels);
117
+ if (names.length === 0) {
118
+ bodyLog("profiles: (none — define models in ~/.kiso/config.json)");
119
+ }
120
+ else {
121
+ for (const name of names) {
122
+ const p = configModels[name];
123
+ bodyLog(` ${name} → ${p.kind}/${p.model} · ${p.apiKeyEnv} ${profileAvailable(p) ? "(available)" : "(unavailable)"}`);
124
+ }
125
+ }
126
+ bodyLog("switch with /model <profile-name|provider/model>");
127
+ }
128
+ else {
129
+ try {
130
+ const direct = directWriteProfile(arg);
131
+ const profile = direct ?? configModels[arg];
132
+ if (profile === undefined) {
133
+ bodyLog(`no such model profile: ${arg}`);
134
+ }
135
+ else if (!profileAvailable(profile)) {
136
+ bodyLog(`model ${arg}: unavailable — the env var ${profile.apiKeyEnv} is not set (configs never store keys, only the env-var name)`);
137
+ }
138
+ else {
139
+ const adapter = await buildAdapter(profile.kind, {
140
+ apiKey: process.env[profile.apiKeyEnv],
141
+ ...(profile.baseUrl !== undefined ? { baseUrl: profile.baseUrl } : {}),
142
+ });
143
+ ctx.session.setAdapter(adapter);
144
+ setAgentModel(profile.model);
145
+ setCurrentModelName(arg);
146
+ body.notice(`model → ${arg} (${profile.model}) — takes effect on the next turn`);
147
+ }
148
+ }
149
+ catch (err) {
150
+ body.notice(`[/model] failed: ${err instanceof Error ? err.message : String(err)}`);
151
+ }
152
+ }
153
+ ctx.input.prompt();
154
+ });
155
+ return;
156
+ }
103
157
  if (trimmed === "/compact") {
104
158
  // /compact (ADR-0044): the older conversation becomes one
105
159
  // model summary — an OFF-LOOP call through the session's own
package/dist/index.js CHANGED
@@ -31,10 +31,11 @@ import { createAgent, disposeExtensions, loadExtensions, loadProjectExtensions,
31
31
  import { createFauxProvider } from "@vincemakes/kiso-evals";
32
32
  import { createCodingTools } from "@vincemakes/kiso-tools-node";
33
33
  import { MODES, modeExtensions, modeFromEnv, modeSystemPrompt, setMode } from "./mode.js";
34
- import { body, bodyLog, dock, extensionsDir, loadedExtensions, mergedTempPaths, projectExtensions, sessionsDir, setAgentModel, setBody, setExtensionLists, userExtensions, VERSION } from "./state.js";
34
+ import { body, bodyLog, currentFaux, dock, extensionsDir, loadedExtensions, mergedConfig, mergedTempPaths, projectExtensions, sessionsDir, setAgentModel, setBody, setConfigModels, setConfiguredWindow, setCurrentFaux, setCurrentModelName, setExtensionLists, setMergedConfig, userExtensions, VERSION } from "./state.js";
35
35
  import { interactivePrompt, resolveProjectTrust } from "./trust-ui.js";
36
36
  import { fauxSkip, readFauxScript } from "./faux-glue.js";
37
37
  import { autoCompactFromEnv, chat, contextWindowTokens } from "./chat.js";
38
+ import { loadProjectConfig, loadUserConfig, mergeConfigs, resolveAutoCompact, resolveContextWindow, resolveModel } from "./config.js";
38
39
  import { resume } from "./resume.js";
39
40
  // The moved exports stay reachable from this entry — the test imports
40
41
  // (project-trust, coding-agent) never change (B4: 断言零改动).
@@ -250,7 +251,7 @@ export function composeSystemPrompt(cwd) {
250
251
  const injected = readProjectInstructions(cwd);
251
252
  return injected === "" ? SYSTEM_PROMPT : `${SYSTEM_PROMPT}\n${injected}`;
252
253
  }
253
- async function makeAgent(fauxSkipTurns = 0, input) {
254
+ async function makeAgent(fauxSkipTurns = 0, input, modelFlag) {
254
255
  const store = new SessionStore(sessionsDir());
255
256
  // E3: the project-level trust gate runs BEFORE any extension load (the
256
257
  // mcp/skills merges must be in the env when the user-level extensions
@@ -266,24 +267,27 @@ async function makeAgent(fauxSkipTurns = 0, input) {
266
267
  else {
267
268
  setExtensionLists(user, [], user);
268
269
  }
269
- // Provider wiring (F 组): the CLI never imports provider SDKs directly
270
- // the runtime's lazy provider resolution owns them. Real key → real
271
- // provider; none faux.
272
- const anthropicKey = process.env.ANTHROPIC_API_KEY;
273
- const openaiKey = process.env.OPENAI_API_KEY;
274
- let provider;
275
- let model;
276
- if (anthropicKey) {
277
- provider = "anthropic";
278
- model = process.env.ANTHROPIC_MODEL ?? "claude-sonnet-5";
279
- }
280
- else if (openaiKey) {
281
- provider = "openai-compat";
282
- model = process.env.OPENAI_MODEL ?? "gpt-4o";
270
+ // 合并轮 B the config surface: user config + (trusted) project config,
271
+ // resolved with flags > env > project > user > default. The CLI never
272
+ // imports provider SDKs directly — the runtime's lazy provider
273
+ // resolution owns them (a config profile only ever NAMES an env var for
274
+ // its key; the key itself never sits in a config file).
275
+ const userCfg = loadUserConfig();
276
+ const projectCfg = loadProjectConfig(process.cwd(), project !== null);
277
+ const merged = mergeConfigs(userCfg, projectCfg);
278
+ setMergedConfig(merged);
279
+ setConfigModels(merged.models ?? {});
280
+ setConfiguredWindow(resolveContextWindow(merged));
281
+ const resolved = resolveModel(modelFlag, merged);
282
+ const model = resolved === null ? "faux" : resolved.profile.model;
283
+ if (resolved === null) {
284
+ console.log("[faux mode — set ANTHROPIC_API_KEY or OPENAI_API_KEY, or configure models in ~/.kiso/config.json]\n");
285
+ setCurrentFaux(true);
286
+ setCurrentModelName("faux");
283
287
  }
284
288
  else {
285
- console.log("[faux mode — set ANTHROPIC_API_KEY or OPENAI_API_KEY for a real model]\n");
286
- model = "faux";
289
+ setCurrentFaux(false);
290
+ setCurrentModelName(resolved.name);
287
291
  }
288
292
  setAgentModel(model); // v2b: the status bar shows it
289
293
  const definition = {
@@ -312,11 +316,11 @@ async function makeAgent(fauxSkipTurns = 0, input) {
312
316
  // project extensions (the deny>ask>allow composition keeps a user
313
317
  // deny winning over any mode tier — bypass included).
314
318
  extensions: [...modeExtensions(), ...loadedExtensions],
315
- ...(provider !== undefined
319
+ ...(resolved !== null
316
320
  ? {
317
- provider,
318
- apiKey: (anthropicKey ?? openaiKey),
319
- ...(process.env.OPENAI_BASE_URL !== undefined ? { baseUrl: process.env.OPENAI_BASE_URL } : {}),
321
+ provider: resolved.profile.kind,
322
+ apiKey: resolved.apiKey,
323
+ ...(resolved.profile.baseUrl !== undefined ? { baseUrl: resolved.profile.baseUrl } : {}),
320
324
  }
321
325
  : { adapter: createFauxProvider(readFauxScript().slice(fauxSkipTurns)) }),
322
326
  };
@@ -327,6 +331,21 @@ async function main() {
327
331
  // first makeAgent (the tier extensions read `current` live). The flag
328
332
  // is stripped from the positional args, so it works in any position.
329
333
  const args = process.argv.slice(2);
334
+ // 合并轮 B: --model <profile|provider/model> — the top of the model
335
+ // precedence chain; the value flows into makeAgent's config resolution.
336
+ let modelFlag;
337
+ const modelArgIdx = args.indexOf("--model");
338
+ if (modelArgIdx !== -1) {
339
+ modelFlag = args[modelArgIdx + 1];
340
+ if (modelFlag === undefined) {
341
+ console.error("usage: --model <profile-name|provider/model>");
342
+ process.exit(2);
343
+ }
344
+ args.splice(modelArgIdx, 2);
345
+ }
346
+ // Modes: --mode wins over KISO_MODE, which wins over the USER config's
347
+ // mode (the project config's mode applies later — after the trust gate,
348
+ // inside makeAgent — unless a higher layer already decided).
330
349
  const modeFlag = args.indexOf("--mode");
331
350
  if (modeFlag !== -1) {
332
351
  const m = MODES.find((x) => x === args[modeFlag + 1]);
@@ -338,12 +357,14 @@ async function main() {
338
357
  args.splice(modeFlag, 2);
339
358
  }
340
359
  else {
341
- setMode(modeFromEnv());
360
+ setMode(modeFromEnv() ?? loadUserConfig()?.mode ?? "default");
342
361
  }
343
362
  const [command, arg] = args;
344
363
  // 八: faux mode is the keyless demo script — an exhausted script must
345
- // exit non-zero, never masquerade as a successful provider run.
346
- const faux = process.env.ANTHROPIC_API_KEY === undefined && process.env.OPENAI_API_KEY === undefined;
364
+ // exit non-zero, never masquerade as a successful provider run. The
365
+ // verdict comes from makeAgent's config resolution now (a config
366
+ // profile can provide a real model with no OPENAI_* env).
367
+ let faux = true;
347
368
  let agent;
348
369
  // v2c: ONE input source per process — the raw-mode editor on a TTY
349
370
  // (entered here, dock-bound, trusted before any extension loads),
@@ -360,6 +381,16 @@ async function main() {
360
381
  onDock: () => dock.redraw(), // v2d-B: the freeze scrolls the dock up — re-pin it
361
382
  }));
362
383
  try {
384
+ // 合并轮 B: the project config's mode applies AFTER the trust gate
385
+ // (its verdict decides whether the project config exists at all) —
386
+ // unless a higher layer (--mode flag / KISO_MODE) already decided.
387
+ const applyConfigMode = () => {
388
+ if (modeFlag !== -1 || process.env.KISO_MODE !== undefined)
389
+ return;
390
+ const m = mergedConfig.mode;
391
+ if (m !== undefined)
392
+ setMode(m);
393
+ };
363
394
  switch (command) {
364
395
  case "chat": {
365
396
  const id = arg ?? new Date().toISOString().replace(/[:.]/g, "-").slice(0, 16);
@@ -368,11 +399,13 @@ async function main() {
368
399
  dock.enter();
369
400
  // E 区: a resumed session continues the script at its durable
370
401
  // position — never restarts it (fauxSkip).
371
- const agent = await makeAgent(fauxSkip(id), input);
402
+ const agent = await makeAgent(fauxSkip(id), input, modelFlag);
403
+ applyConfigMode();
372
404
  const session = await agent.session({ id });
373
405
  bodyLog(`session ${id}\n`);
374
406
  extensionsBanner();
375
- await chat(session, faux, input, autoCompactFromEnv());
407
+ faux = currentFaux;
408
+ await chat(session, faux, input, resolveAutoCompact(mergedConfig));
376
409
  break;
377
410
  }
378
411
  case "resume": {
@@ -384,13 +417,15 @@ async function main() {
384
417
  // (argv = [node, script, resume, id, prompt?]).
385
418
  const prompt = process.argv[4];
386
419
  dock.enter();
387
- const agent = await makeAgent(fauxSkip(arg), input);
420
+ const agent = await makeAgent(fauxSkip(arg), input, modelFlag);
421
+ applyConfigMode();
388
422
  const session = await agent.session({ id: arg });
423
+ faux = currentFaux;
389
424
  await resume(session, prompt, faux, input);
390
425
  break;
391
426
  }
392
427
  case "sessions": {
393
- const agent = await makeAgent();
428
+ const agent = await makeAgent(0, undefined, modelFlag);
394
429
  for (const meta of agent.sessions()) {
395
430
  console.log(renderSessionLine(meta));
396
431
  }
package/dist/mode.d.ts CHANGED
@@ -14,9 +14,10 @@ export type Mode = "manual" | "default" | "accept-edits" | "plan" | "bypass";
14
14
  export declare const MODES: readonly Mode[];
15
15
  export declare function getMode(): Mode;
16
16
  export declare function setMode(m: Mode): void;
17
- /** The startup mode: KISO_MODE env (or the --mode flag the CLI applies
18
- * it before the first makeAgent). */
19
- export declare function modeFromEnv(): Mode;
17
+ /** The startup mode from env: KISO_MODE, or undefined when unset (the
18
+ * config layer's mode then applies — 合并轮 B; the --mode flag is
19
+ * applied by main before the first makeAgent and wins over everything). */
20
+ export declare function modeFromEnv(): Mode | undefined;
20
21
  /** The five built-in mode tiers as chain extensions — named "mode:<tier>"
21
22
  * so the runtime's decidedBy records exactly that (the runtime derives
22
23
  * approvalPolicies from extensions[].approvals, tagging each with the
package/dist/mode.js CHANGED
@@ -20,12 +20,12 @@ export function getMode() {
20
20
  export function setMode(m) {
21
21
  current = m;
22
22
  }
23
- /** The startup mode: KISO_MODE env (or the --mode flag the CLI applies
24
- * it before the first makeAgent). */
23
+ /** The startup mode from env: KISO_MODE, or undefined when unset (the
24
+ * config layer's mode then applies — 合并轮 B; the --mode flag is
25
+ * applied by main before the first makeAgent and wins over everything). */
25
26
  export function modeFromEnv() {
26
27
  const raw = process.env.KISO_MODE;
27
- const m = MODES.find((x) => x === raw);
28
- return m ?? "default";
28
+ return MODES.find((x) => x === raw);
29
29
  }
30
30
  /** The per-tier verdict for a tool call — only when this tier is current. */
31
31
  function tierVerdict(tier, call) {
package/dist/state.d.ts CHANGED
@@ -52,6 +52,26 @@ export declare function bodyLog(text: string): void;
52
52
  /** The model name for the status bar — set by makeAgent. */
53
53
  export declare let agentModel: string;
54
54
  export declare function setAgentModel(value: string): void;
55
+ /** 合并轮 B: whether the agent runs on the faux provider (no real key) —
56
+ * set inside makeAgent, read by main for chat/resume's exhaustion check. */
57
+ export declare let currentFaux: boolean;
58
+ export declare function setCurrentFaux(value: boolean): void;
59
+ /** 合并轮 B: the merged config (user + trusted project) as resolved by the
60
+ * LAST makeAgent — /model and autoCompact resolve against it. */
61
+ export declare let mergedConfig: import("./config.js").KisoConfig;
62
+ export declare function setMergedConfig(value: import("./config.js").KisoConfig): void;
63
+ /** 合并轮 B: the resolved context window (env > config.contextWindow) —
64
+ * chat.ts's contextWindowTokens() consults it before the env. */
65
+ export declare let configuredWindow: number | undefined;
66
+ export declare function setConfiguredWindow(value: number | undefined): void;
67
+ /** 合并轮 B: the merged config (user + trusted project) + the current
68
+ * model's NAME — /model lists and switches against them. */
69
+ export declare let configModels: Readonly<Record<string, import("./config.js").ModelProfile>>;
70
+ export declare function setConfigModels(models: Readonly<Record<string, import("./config.js").ModelProfile>>): void;
71
+ /** The name of the model currently driving the session ("faux" or the
72
+ * profile name / provider/model write / env model). */
73
+ export declare let currentModelName: string;
74
+ export declare function setCurrentModelName(value: string): void;
55
75
  /** E1: the extensions loaded by makeAgent — their names feed the banner. */
56
76
  export declare let loadedExtensions: readonly KisoExtension[];
57
77
  /** E1: the USER-level extensions alone — the banner's unmarked part (E3:
package/dist/state.js CHANGED
@@ -46,6 +46,36 @@ export let agentModel = "faux";
46
46
  export function setAgentModel(value) {
47
47
  agentModel = value;
48
48
  }
49
+ /** 合并轮 B: whether the agent runs on the faux provider (no real key) —
50
+ * set inside makeAgent, read by main for chat/resume's exhaustion check. */
51
+ export let currentFaux = true;
52
+ export function setCurrentFaux(value) {
53
+ currentFaux = value;
54
+ }
55
+ /** 合并轮 B: the merged config (user + trusted project) as resolved by the
56
+ * LAST makeAgent — /model and autoCompact resolve against it. */
57
+ export let mergedConfig = {};
58
+ export function setMergedConfig(value) {
59
+ mergedConfig = value;
60
+ }
61
+ /** 合并轮 B: the resolved context window (env > config.contextWindow) —
62
+ * chat.ts's contextWindowTokens() consults it before the env. */
63
+ export let configuredWindow;
64
+ export function setConfiguredWindow(value) {
65
+ configuredWindow = value;
66
+ }
67
+ /** 合并轮 B: the merged config (user + trusted project) + the current
68
+ * model's NAME — /model lists and switches against them. */
69
+ export let configModels = {};
70
+ export function setConfigModels(models) {
71
+ configModels = models;
72
+ }
73
+ /** The name of the model currently driving the session ("faux" or the
74
+ * profile name / provider/model write / env model). */
75
+ export let currentModelName = "faux";
76
+ export function setCurrentModelName(value) {
77
+ currentModelName = value;
78
+ }
49
79
  /** E1: the extensions loaded by makeAgent — their names feed the banner. */
50
80
  export let loadedExtensions = [];
51
81
  /** E1: the USER-level extensions alone — the banner's unmarked part (E3:
package/dist/trust-ui.js CHANGED
@@ -10,6 +10,7 @@ import { join } from "node:path";
10
10
  import { escapeTerminal, palette } from "@vincemakes/kiso-tui";
11
11
  import { projectArtifacts, recordTrust, trustFor } from "@vincemakes/kiso-runtime";
12
12
  import { CANCELLED, bodyLog, dock, kisoHome, mergedTempPaths } from "./state.js";
13
+ import { loadUserConfig, resolveProjectTrustPolicy } from "./config.js";
13
14
  /** v2a: the interactive prompt — blue, the identity accent. readline owns
14
15
  * the echo of what the user types; we own the prompt's color. (v2c: the
15
16
  * readline prompt keeps "you> " — the brick ▌ is the dock's row only;
@@ -90,6 +91,12 @@ export async function resolveProjectTrust(input) {
90
91
  const artifacts = await projectArtifacts(process.cwd());
91
92
  if (artifacts === null)
92
93
  return null; // no .kiso artifacts — nothing to gate
94
+ // 合并轮 B: projectTrust "never" (user config) — the gate auto-refuses:
95
+ // no ask, no record, nothing loads. There is deliberately no "always".
96
+ if (resolveProjectTrustPolicy(loadUserConfig() ?? {}) === "never") {
97
+ bodyLog(`[project .kiso] projectTrust: never — ${artifacts.root} not loaded`);
98
+ return null;
99
+ }
93
100
  const record = trustFor(artifacts.root, artifacts.digest);
94
101
  if (record?.decision === "granted") {
95
102
  applyProjectMerges(artifacts);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-code",
3
- "version": "0.1.21",
4
- "description": "kiso CLI the coding-agent reference product: kiso chat / kiso resume / kiso sessions.",
3
+ "version": "0.1.23",
4
+ "description": "kiso CLI \u2014 the coding-agent reference product: kiso chat / kiso resume / kiso sessions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -18,13 +18,13 @@
18
18
  "test": "vitest run"
19
19
  },
20
20
  "dependencies": {
21
- "@vincemakes/kiso-core": "0.1.21",
22
- "@vincemakes/kiso-evals": "0.1.21",
23
- "@vincemakes/kiso-provider-anthropic": "0.1.21",
24
- "@vincemakes/kiso-provider-openai": "0.1.21",
25
- "@vincemakes/kiso-runtime": "0.1.21",
26
- "@vincemakes/kiso-tools-node": "0.1.21",
27
- "@vincemakes/kiso-tui": "0.1.21"
21
+ "@vincemakes/kiso-core": "0.1.23",
22
+ "@vincemakes/kiso-evals": "0.1.23",
23
+ "@vincemakes/kiso-provider-anthropic": "0.1.23",
24
+ "@vincemakes/kiso-provider-openai": "0.1.23",
25
+ "@vincemakes/kiso-runtime": "0.1.23",
26
+ "@vincemakes/kiso-tools-node": "0.1.23",
27
+ "@vincemakes/kiso-tui": "0.1.23"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/node": "^26.1.2",