min-agent 0.3.0 → 0.4.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.
Files changed (120) hide show
  1. package/README.md +111 -28
  2. package/dist/agent.js +1119 -256
  3. package/dist/cli/commands/chat.js +10 -0
  4. package/dist/cli/commands/exec.js +32 -0
  5. package/dist/cli/commands/history.js +58 -0
  6. package/dist/cli/commands/index.js +224 -0
  7. package/dist/cli/commands/init.js +18 -0
  8. package/dist/cli/commands/mcp.js +173 -0
  9. package/dist/cli/commands/memory.js +69 -0
  10. package/dist/cli/commands/models.js +21 -0
  11. package/dist/cli/commands/permission.js +12 -0
  12. package/dist/cli/commands/rules.js +33 -0
  13. package/dist/cli/commands/sandbox.js +13 -0
  14. package/dist/cli/commands/serve.js +9 -0
  15. package/dist/cli/commands/setup.js +4 -0
  16. package/dist/cli/commands/shared.js +16 -0
  17. package/dist/cli/commands/skills.js +119 -0
  18. package/dist/cli/commands/update.js +7 -0
  19. package/dist/cli/commands/write-config.js +30 -0
  20. package/dist/cli/errors.js +36 -0
  21. package/dist/cli/exec-prompt.js +26 -0
  22. package/dist/cli/option-helpers.js +53 -0
  23. package/dist/cli/program.js +180 -0
  24. package/dist/cli.js +5 -888
  25. package/dist/code-mode.js +32 -14
  26. package/dist/compaction.js +347 -160
  27. package/dist/config.js +119 -10
  28. package/dist/confirm.js +56 -9
  29. package/dist/context-window.js +107 -39
  30. package/dist/doom-loop.js +264 -29
  31. package/dist/fetch-timeout.js +152 -0
  32. package/dist/http-approvals.js +60 -0
  33. package/dist/instructions.js +21 -0
  34. package/dist/logger.js +33 -4
  35. package/dist/markdown.js +37 -11
  36. package/dist/mcp.js +328 -30
  37. package/dist/memory.js +97 -56
  38. package/dist/output.js +7 -5
  39. package/dist/permission-cli.js +43 -0
  40. package/dist/plugins.js +46 -8
  41. package/dist/pricing.js +4 -4
  42. package/dist/provider.js +23 -6
  43. package/dist/question-format.js +60 -0
  44. package/dist/sandbox-cli.js +82 -0
  45. package/dist/sandbox.js +403 -0
  46. package/dist/save-throttle.js +45 -0
  47. package/dist/serve/common.js +404 -0
  48. package/dist/serve/routes-chat.js +347 -0
  49. package/dist/serve/routes-mcp.js +212 -0
  50. package/dist/serve/routes-memory.js +66 -0
  51. package/dist/serve/routes-meta.js +205 -0
  52. package/dist/serve/routes-sessions.js +61 -0
  53. package/dist/serve/routes-skills.js +70 -0
  54. package/dist/serve.js +33 -883
  55. package/dist/sessions.js +53 -9
  56. package/dist/skills.js +82 -18
  57. package/dist/title-gen.js +8 -2
  58. package/dist/token-display.js +36 -0
  59. package/dist/tool-display.js +5 -0
  60. package/dist/tool-output.js +1 -3
  61. package/dist/tools/apply_patch.js +85 -11
  62. package/dist/tools/atomic-file.js +35 -0
  63. package/dist/tools/backend.js +2 -2
  64. package/dist/tools/bash.js +57 -19
  65. package/dist/tools/code_search.js +7 -1
  66. package/dist/tools/edit.js +11 -10
  67. package/dist/tools/explore.js +74 -14
  68. package/dist/tools/glob.js +4 -0
  69. package/dist/tools/grep.js +17 -10
  70. package/dist/tools/index.js +6 -21
  71. package/dist/tools/question.js +28 -9
  72. package/dist/tools/read.js +6 -4
  73. package/dist/tools/search-searxng.js +223 -0
  74. package/dist/tools/search-serper.js +189 -0
  75. package/dist/tools/task.js +84 -30
  76. package/dist/tools/todo.js +120 -19
  77. package/dist/tools/web_fetch.js +11 -3
  78. package/dist/tools/web_search.js +66 -556
  79. package/dist/tools/write.js +23 -6
  80. package/dist/tui/App.js +63 -14
  81. package/dist/tui/ConfirmBar.js +45 -13
  82. package/dist/tui/InputBar.js +150 -35
  83. package/dist/tui/MessageList.js +266 -125
  84. package/dist/tui/ModelPicker.js +8 -3
  85. package/dist/tui/QuestionBar.js +51 -19
  86. package/dist/tui/SessionPicker.js +79 -0
  87. package/dist/tui/StatusBar.js +8 -14
  88. package/dist/tui/agent-runner.js +142 -22
  89. package/dist/tui/caret-pos.js +48 -5
  90. package/dist/tui/caret.js +1 -1
  91. package/dist/tui/click-count.js +13 -0
  92. package/dist/tui/drag-state.js +8 -3
  93. package/dist/tui/hydrate.js +129 -0
  94. package/dist/tui/index.js +42 -13
  95. package/dist/tui/input-history.js +92 -11
  96. package/dist/tui/layout.js +75 -4
  97. package/dist/tui/prompt-queue.js +24 -0
  98. package/dist/tui/selection.js +113 -21
  99. package/dist/tui/session-switch.js +28 -0
  100. package/dist/tui/slash-commands.js +22 -6
  101. package/dist/tui/slash-handler.js +233 -58
  102. package/dist/tui/text-width.js +38 -16
  103. package/dist/tui/token-info.js +7 -0
  104. package/dist/tui/tool-children.js +19 -0
  105. package/dist/tui/undo-stack.js +1 -1
  106. package/dist/tui/use-sgr-mouse.js +3 -1
  107. package/dist/tui-chat.js +276 -40
  108. package/dist/updater.js +88 -29
  109. package/dist/xml-search.js +194 -0
  110. package/docs/API.md +257 -25
  111. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  112. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  113. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  114. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  115. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  116. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  117. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  118. package/package.json +6 -1
  119. package/skills/self-config/SKILL.md +90 -0
  120. package/skills/self-config/reference.md +149 -0
package/dist/config.js CHANGED
@@ -1,7 +1,9 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from "fs";
1
+ import { readFileSync, mkdirSync, existsSync, statSync } from "fs";
2
2
  import path from "path";
3
3
  import os from "os";
4
4
  import readline from "readline";
5
+ import { atomicWriteFileSync } from "./tools/atomic-file.js";
6
+ import { getEffectiveSandboxPolicy, mergeSandboxConfig, parseSandboxConfig, sandboxLaunchSource, sandboxStatusLabel, } from "./sandbox.js";
5
7
  const DEFAULT_CONFIG_DIR = path.join(os.homedir(), ".min-agent");
6
8
  const CONFIG_FILE_NAME = "config.json";
7
9
  const RULES_FILE_NAME = "rules.md";
@@ -47,11 +49,11 @@ export function saveProjectConfig(config) {
47
49
  mkdirSync(dir, { recursive: true });
48
50
  // Keep unknown keys, drop empty skill arrays so the file stays minimal.
49
51
  const { disabledSkills, enabledSkills, ...rest } = config;
50
- writeFileSync(getProjectConfigFile(), JSON.stringify({
52
+ atomicWriteFileSync(getProjectConfigFile(), JSON.stringify({
51
53
  ...rest,
52
54
  ...(disabledSkills?.length ? { disabledSkills } : {}),
53
55
  ...(enabledSkills?.length ? { enabledSkills } : {}),
54
- }, null, 2), "utf-8");
56
+ }, null, 2));
55
57
  projectCache.delete(getProjectConfigFile());
56
58
  }
57
59
  export function loadConfigFile(configFile) {
@@ -62,7 +64,10 @@ export function loadConfigFile(configFile) {
62
64
  catch {
63
65
  return {};
64
66
  }
65
- if (cachedConfig && cachedConfig.file === configFile && cachedConfig.mtimeMs === st.mtimeMs && cachedConfig.size === st.size) {
67
+ if (cachedConfig &&
68
+ cachedConfig.file === configFile &&
69
+ cachedConfig.mtimeMs === st.mtimeMs &&
70
+ cachedConfig.size === st.size) {
66
71
  return cachedConfig.config;
67
72
  }
68
73
  let config;
@@ -97,6 +102,110 @@ export function migrateLegacyConfig(config) {
97
102
  migrated: true,
98
103
  };
99
104
  }
105
+ export function mergeProjectOverGlobal(global, project) {
106
+ const activeProvider = project.activeProvider ?? global.activeProvider;
107
+ const sampling = project.sampling ? { ...global.sampling, ...project.sampling } : global.sampling;
108
+ const budget = project.budget ? { ...global.budget, ...project.budget } : global.budget;
109
+ const permission = parsePermissionMode(project.permission) ?? parsePermissionMode(global.permission);
110
+ const sandbox = mergeSandboxConfig(parseSandboxConfig(global.sandbox), parseSandboxConfig(project.sandbox));
111
+ const compaction = project.compaction ? { ...global.compaction, ...project.compaction } : global.compaction;
112
+ const agent = project.agent ? { ...global.agent, ...project.agent } : global.agent;
113
+ let providers = global.providers;
114
+ if (project.defaultModel && providers && providers.length > 0) {
115
+ const idx = activeProvider ? providers.findIndex((p) => p.name === activeProvider) : 0;
116
+ const target = idx >= 0 ? idx : 0;
117
+ providers = providers.map((p, i) => (i === target ? { ...p, defaultModel: project.defaultModel } : p));
118
+ }
119
+ return {
120
+ ...global,
121
+ activeProvider,
122
+ sampling,
123
+ budget,
124
+ permission,
125
+ ...(sandbox ? { sandbox } : {}),
126
+ compaction,
127
+ agent,
128
+ ...(providers ? { providers } : {}),
129
+ };
130
+ }
131
+ export function getEffectiveConfig() {
132
+ return mergeProjectOverGlobal(loadConfig(), loadProjectConfig());
133
+ }
134
+ export function getBudgetSnapshot() {
135
+ const global = loadConfig();
136
+ const project = loadProjectConfig();
137
+ const maxCostUSD = mergeProjectOverGlobal(global, project).budget?.maxCostUSD;
138
+ const source = project.budget?.maxCostUSD != null ? "project" : global.budget?.maxCostUSD != null ? "global" : null;
139
+ return { maxCostUSD, source };
140
+ }
141
+ export function parsePermissionMode(value) {
142
+ if (value === "ask" || value === "accept-edits" || value === "allow-all")
143
+ return value;
144
+ return undefined;
145
+ }
146
+ export function permissionModeLabel(mode) {
147
+ if (mode === "allow-all")
148
+ return "allow all";
149
+ if (mode === "accept-edits")
150
+ return "accept edits";
151
+ return "ask";
152
+ }
153
+ export function getPermissionSnapshot() {
154
+ const global = loadConfig();
155
+ const project = loadProjectConfig();
156
+ const permission = parsePermissionMode(project.permission) ?? parsePermissionMode(global.permission) ?? "ask";
157
+ const source = parsePermissionMode(project.permission) != null
158
+ ? "project"
159
+ : parsePermissionMode(global.permission) != null
160
+ ? "global"
161
+ : null;
162
+ return { permission, source };
163
+ }
164
+ export function setPermissionMode(permission, scope) {
165
+ if (scope === "project") {
166
+ const project = loadProjectConfig();
167
+ saveProjectConfig({ ...project, permission });
168
+ return;
169
+ }
170
+ saveConfig({ ...loadConfig(), permission });
171
+ }
172
+ export function setSandboxConfig(partial, scope) {
173
+ if (scope === "project") {
174
+ const project = loadProjectConfig();
175
+ const current = parseSandboxConfig(project.sandbox) ?? {};
176
+ saveProjectConfig({ ...project, sandbox: { ...current, ...partial } });
177
+ return;
178
+ }
179
+ const cfg = loadConfig();
180
+ const current = parseSandboxConfig(cfg.sandbox) ?? {};
181
+ saveConfig({ ...cfg, sandbox: { ...current, ...partial } });
182
+ }
183
+ export function getSandboxSnapshot() {
184
+ const policy = getEffectiveSandboxPolicy();
185
+ const launch = sandboxLaunchSource();
186
+ const project = parseSandboxConfig(loadProjectConfig().sandbox);
187
+ const global = parseSandboxConfig(loadConfig().sandbox);
188
+ const projectSet = Boolean(project?.mode || project?.network || project?.extraWriteRoots?.length || project?.extraReadRoots?.length);
189
+ const globalSet = Boolean(global?.mode || global?.network || global?.extraWriteRoots?.length || global?.extraReadRoots?.length);
190
+ const source = launch ?? (projectSet ? "project" : globalSet ? "global" : null);
191
+ return {
192
+ mode: policy.mode,
193
+ network: policy.network,
194
+ extraWriteRoots: policy.extraWriteRoots,
195
+ extraReadRoots: policy.extraReadRoots,
196
+ source,
197
+ label: sandboxStatusLabel(policy),
198
+ };
199
+ }
200
+ export function setBudgetMaxCost(maxCostUSD, scope) {
201
+ if (scope === "project") {
202
+ const project = loadProjectConfig();
203
+ saveProjectConfig({ ...project, budget: { ...project.budget, maxCostUSD } });
204
+ return;
205
+ }
206
+ const cfg = loadConfig();
207
+ saveConfig({ ...cfg, budget: { ...cfg.budget, maxCostUSD } });
208
+ }
100
209
  export function getActiveProvider(config) {
101
210
  if (!config.providers || config.providers.length === 0)
102
211
  return undefined;
@@ -115,7 +224,7 @@ export function normalizeOllamaBaseURL(baseURL) {
115
224
  export function saveConfig(config) {
116
225
  const dir = getConfigDir();
117
226
  mkdirSync(dir, { recursive: true });
118
- writeFileSync(path.join(dir, CONFIG_FILE_NAME), JSON.stringify(config, null, 2), "utf-8");
227
+ atomicWriteFileSync(path.join(dir, CONFIG_FILE_NAME), JSON.stringify(config, null, 2));
119
228
  cachedConfig = null;
120
229
  }
121
230
  export function isConfigured() {
@@ -167,7 +276,7 @@ function loadModelsCache() {
167
276
  }
168
277
  function saveModelsCache(models) {
169
278
  mkdirSync(getConfigDir(), { recursive: true });
170
- writeFileSync(modelsCachePath(), JSON.stringify(models), "utf-8");
279
+ atomicWriteFileSync(modelsCachePath(), JSON.stringify(models));
171
280
  }
172
281
  export async function fetchModels(baseURL, apiKey) {
173
282
  try {
@@ -217,7 +326,9 @@ export async function runSetup() {
217
326
  const choice = await ask(rl, "选择 (1/2/3/4)", "4");
218
327
  if (choice === "1") {
219
328
  const provider = await collectProvider(rl, undefined);
220
- provider.name = (await ask(rl, "Provider 名称", `provider-${providers.length + 1}`)).trim() || `provider-${providers.length + 1}`;
329
+ provider.name =
330
+ (await ask(rl, "Provider 名称", `provider-${providers.length + 1}`)).trim() ||
331
+ `provider-${providers.length + 1}`;
221
332
  providers.push(provider);
222
333
  config.activeProvider = provider.name;
223
334
  config.providers = providers;
@@ -272,9 +383,7 @@ async function collectProvider(rl, existing) {
272
383
  console.log(" 3. ollama (本地 Ollama)");
273
384
  console.log();
274
385
  const typeChoice = await ask(rl, "选择 Provider (1/2/3)", existing?.type === "ollama" ? "3" : existing?.type === "openai" ? "2" : "1");
275
- const providerType = typeChoice === "3" ? "ollama"
276
- : typeChoice === "2" ? "openai"
277
- : "openai-compatible";
386
+ const providerType = typeChoice === "3" ? "ollama" : typeChoice === "2" ? "openai" : "openai-compatible";
278
387
  let baseURL;
279
388
  let apiKey;
280
389
  if (providerType === "ollama") {
package/dist/confirm.js CHANGED
@@ -1,14 +1,42 @@
1
- import { loadConfig } from "./config.js";
1
+ import { AsyncLocalStorage } from "async_hooks";
2
+ import { getEffectiveConfig } from "./config.js";
3
+ import { normalizeQuestionOptions, normalizeQuestionPrompt } from "./question-format.js";
2
4
  import readline from "readline";
3
5
  let autoApprove = false;
4
6
  export function setAutoApprove(value) {
5
7
  autoApprove = value;
6
8
  }
7
9
  export function isAutoApprove() {
10
+ const ctx = confirmAls.getStore();
11
+ if (ctx)
12
+ return ctx.autoApprove;
8
13
  if (autoApprove)
9
14
  return true;
10
- const config = loadConfig();
11
- return config.permission === "allow-all";
15
+ return getPermissionMode() === "allow-all";
16
+ }
17
+ let permissionOverride;
18
+ export function setPermissionOverride(mode) {
19
+ permissionOverride = mode;
20
+ }
21
+ export function getPermissionOverride() {
22
+ return permissionOverride;
23
+ }
24
+ export function getPermissionMode() {
25
+ if (permissionOverride)
26
+ return permissionOverride;
27
+ const config = getEffectiveConfig();
28
+ return config.permission === "allow-all" || config.permission === "accept-edits" || config.permission === "ask"
29
+ ? config.permission
30
+ : "ask";
31
+ }
32
+ export function isEditAutoApprove() {
33
+ if (isAutoApprove())
34
+ return true;
35
+ return getPermissionMode() === "accept-edits";
36
+ }
37
+ const confirmAls = new AsyncLocalStorage();
38
+ export function runWithConfirmContext(ctx, fn) {
39
+ return confirmAls.run(ctx, fn);
12
40
  }
13
41
  /** Optional readline interface to pause/resume during confirmation prompts. */
14
42
  let _rl = null;
@@ -27,17 +55,28 @@ export function setTuiQuestion(handler) {
27
55
  }
28
56
  /** Ask the user a free-form question. Returns the answer, or null if cancelled. */
29
57
  export async function askQuestion(prompt, options) {
58
+ const safePrompt = normalizeQuestionPrompt(prompt);
59
+ const choices = normalizeQuestionOptions(options);
60
+ const safeOptions = choices.length > 0 ? choices : undefined;
61
+ const ctx = confirmAls.getStore();
62
+ if (ctx) {
63
+ if (ctx.autoApprove)
64
+ return null;
65
+ if (ctx.onQuestion)
66
+ return ctx.onQuestion(safePrompt, safeOptions);
67
+ return null;
68
+ }
30
69
  if (_tuiQuestion)
31
- return _tuiQuestion(prompt, options);
70
+ return _tuiQuestion(safePrompt, safeOptions);
32
71
  if (!process.stdin.isTTY) {
33
- console.log(`\n\x1b[33m❓ ${prompt} (no interactive terminal — skipped)\x1b[0m`);
72
+ console.log(`\n\x1b[33m❓ ${safePrompt} (no interactive terminal — skipped)\x1b[0m`);
34
73
  return null;
35
74
  }
36
75
  console.log();
37
- console.log(`\x1b[33m❓ ${prompt}\x1b[0m`);
38
- if (options && options.length > 0) {
39
- for (let i = 0; i < options.length; i++) {
40
- console.log(`\x1b[90m ${i + 1}. ${options[i]}\x1b[0m`);
76
+ console.log(`\x1b[33m❓ ${safePrompt}\x1b[0m`);
77
+ if (safeOptions) {
78
+ for (let i = 0; i < safeOptions.length; i++) {
79
+ console.log(`\x1b[90m ${i + 1}. ${safeOptions[i]}\x1b[0m`);
41
80
  }
42
81
  }
43
82
  return new Promise((resolve) => {
@@ -56,6 +95,14 @@ export async function askQuestion(prompt, options) {
56
95
  * Display order: detail first, then [y/N] prompt at the bottom.
57
96
  */
58
97
  export async function confirm(message) {
98
+ const ctx = confirmAls.getStore();
99
+ if (ctx) {
100
+ if (ctx.autoApprove)
101
+ return true;
102
+ if (ctx.onConfirm)
103
+ return ctx.onConfirm(message);
104
+ return false;
105
+ }
59
106
  if (isAutoApprove())
60
107
  return true;
61
108
  // If TUI mode is active, delegate to the TUI overlay
@@ -1,17 +1,19 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
1
+ import { readFileSync, mkdirSync, existsSync } from "fs";
2
2
  import path from "path";
3
- import { getConfigDir, loadConfig, getActiveProvider } from "./config.js";
3
+ import { atomicWriteFileSync } from "./tools/atomic-file.js";
4
+ import { getConfigDir, getEffectiveConfig, getActiveProvider } from "./config.js";
4
5
  /**
5
6
  * Auto-detect context window size for the current model.
6
7
  *
7
8
  * Resolution order:
8
9
  * 1. User config: provider.contextWindow (explicit override)
9
- * 2. Provider-specific API (OpenRouter, vLLM, Ollama)
10
+ * 2. Provider-specific API (OpenRouter, vLLM, Ollama, OpenAI-compatible /models)
10
11
  * 3. models.dev API lookup
11
- * 4. Fallback: 128000
12
+ * 4. Fallback: 512000 (memory only — never persisted as a detected value)
12
13
  */
13
- const DEFAULT_CONTEXT_WINDOW = 128000;
14
- const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
14
+ export const DEFAULT_CONTEXT_WINDOW = 512000;
15
+ const CACHE_TTL = 7 * 24 * 60 * 60 * 1000;
16
+ const FALLBACK_MEMORY_TTL = 10 * 60 * 1000;
15
17
  function cacheFile() {
16
18
  return path.join(getConfigDir(), "context-window-cache.json");
17
19
  }
@@ -31,26 +33,72 @@ function loadDiskCache() {
31
33
  function saveCache(cache) {
32
34
  const file = cacheFile();
33
35
  mkdirSync(path.dirname(file), { recursive: true });
34
- writeFileSync(file, JSON.stringify(cache), "utf-8");
36
+ atomicWriteFileSync(file, JSON.stringify(cache));
37
+ }
38
+ function cacheTtl(source) {
39
+ return source === "fallback" ? FALLBACK_MEMORY_TTL : CACHE_TTL;
35
40
  }
36
41
  function getCached(modelId) {
37
42
  const memory = memoryCache.get(modelId);
38
- if (memory && Date.now() - memory.timestamp <= CACHE_TTL)
39
- return memory.contextWindow;
40
- const disk = loadDiskCache()[modelId];
41
- if (disk && Date.now() - disk.timestamp <= CACHE_TTL) {
42
- memoryCache.set(modelId, disk);
43
- return disk.contextWindow;
43
+ if (memory && Date.now() - memory.timestamp <= cacheTtl(memory.source)) {
44
+ return { tokens: memory.contextWindow, source: memory.source };
44
45
  }
45
- return null;
46
+ const disk = loadDiskCache()[modelId];
47
+ if (!disk || disk.source !== "detected")
48
+ return null;
49
+ if (Date.now() - disk.timestamp > CACHE_TTL)
50
+ return null;
51
+ memoryCache.set(modelId, { ...disk, source: "detected" });
52
+ return { tokens: disk.contextWindow, source: "detected" };
46
53
  }
47
- function setCache(modelId, contextWindow) {
48
- const entry = { contextWindow, timestamp: Date.now() };
54
+ function setCache(modelId, contextWindow, source) {
55
+ const entry = { contextWindow, timestamp: Date.now(), source };
49
56
  memoryCache.set(modelId, entry);
57
+ if (source !== "detected")
58
+ return;
50
59
  const cache = loadDiskCache();
51
60
  cache[modelId] = entry;
52
61
  saveCache(cache);
53
62
  }
63
+ export function extractModelContextLength(model) {
64
+ if (!model || typeof model !== "object")
65
+ return null;
66
+ const rec = model;
67
+ const nested = [rec, rec.meta, rec.limits, rec.limit, rec.model_info];
68
+ const keys = [
69
+ "context_length",
70
+ "max_model_len",
71
+ "max_model_length",
72
+ "max_context_length",
73
+ "context_window",
74
+ "max_input_tokens",
75
+ "max_total_tokens",
76
+ "context",
77
+ "general.context_length",
78
+ ];
79
+ for (const obj of nested) {
80
+ if (!obj || typeof obj !== "object")
81
+ continue;
82
+ const bag = obj;
83
+ for (const key of keys) {
84
+ const n = bag[key];
85
+ if (typeof n === "number" && Number.isFinite(n) && n >= 4096)
86
+ return Math.floor(n);
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+ function matchListedModel(models, modelId) {
92
+ const exact = models.find((m) => m && typeof m === "object" && m.id === modelId);
93
+ if (exact)
94
+ return exact;
95
+ return models.find((m) => {
96
+ if (!m || typeof m !== "object")
97
+ return false;
98
+ const id = m.id;
99
+ return typeof id === "string" && (id.endsWith(`/${modelId}`) || modelId.endsWith(`/${id}`));
100
+ });
101
+ }
54
102
  /** Try OpenRouter: GET /api/v1/models returns context_length per model */
55
103
  async function tryOpenRouter(baseURL, apiKey, modelId) {
56
104
  if (!baseURL.includes("openrouter"))
@@ -63,8 +111,7 @@ async function tryOpenRouter(baseURL, apiKey, modelId) {
63
111
  if (!response.ok)
64
112
  return null;
65
113
  const data = (await response.json());
66
- const model = data.data?.find((m) => m.id === modelId);
67
- return model?.context_length ?? null;
114
+ return extractModelContextLength(matchListedModel(data.data ?? [], modelId));
68
115
  }
69
116
  catch {
70
117
  return null;
@@ -83,9 +130,27 @@ async function tryVllm(baseURL, apiKey, modelId) {
83
130
  if (!response.ok)
84
131
  return null;
85
132
  const data = (await response.json());
86
- const model = (data.data ?? []).find((m) => m.id === modelId);
87
- // vLLM exposes max_model_len on the model object
88
- return model?.max_model_len ?? model?.max_model_length ?? null;
133
+ return extractModelContextLength(matchListedModel(data.data ?? [], modelId));
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ }
139
+ /** Any OpenAI-compatible /models that advertises context length (1M custom endpoints included). */
140
+ async function tryProviderModels(baseURL, apiKey, modelId) {
141
+ if (!baseURL)
142
+ return null;
143
+ try {
144
+ const url = `${baseURL.replace(/\/$/, "")}/models`;
145
+ const response = await fetch(url, {
146
+ headers: { Authorization: `Bearer ${apiKey}` },
147
+ signal: AbortSignal.timeout(8000),
148
+ });
149
+ if (!response.ok)
150
+ return null;
151
+ const data = (await response.json());
152
+ const models = Array.isArray(data) ? data : (data.data ?? []);
153
+ return extractModelContextLength(matchListedModel(models, modelId));
89
154
  }
90
155
  catch {
91
156
  return null;
@@ -108,9 +173,7 @@ async function tryOllama(baseURL, modelId) {
108
173
  return null;
109
174
  const data = (await response.json());
110
175
  // Ollama returns model_info with context length
111
- const ctxLength = data.model_info?.["general.context_length"] ??
112
- data.model_info?.context_length ??
113
- data.parameters?.num_ctx;
176
+ const ctxLength = data.model_info?.["general.context_length"] ?? data.model_info?.context_length ?? data.parameters?.num_ctx;
114
177
  return typeof ctxLength === "number" ? ctxLength : null;
115
178
  }
116
179
  catch {
@@ -155,35 +218,32 @@ async function detectContextWindow(config, id) {
155
218
  tryOpenRouter(baseURL, apiKey, id),
156
219
  tryOllama(baseURL, id),
157
220
  tryVllm(baseURL, apiKey, id),
221
+ tryProviderModels(baseURL, apiKey, id),
158
222
  tryModelsDev(id),
159
223
  ]);
160
224
  const found = results.find((v) => v !== null);
161
225
  if (found) {
162
- setCache(id, found);
163
- return found;
226
+ setCache(id, found, "detected");
227
+ return { tokens: found, source: "detected" };
164
228
  }
165
- setCache(id, DEFAULT_CONTEXT_WINDOW);
166
- return DEFAULT_CONTEXT_WINDOW;
229
+ setCache(id, DEFAULT_CONTEXT_WINDOW, "fallback");
230
+ return { tokens: DEFAULT_CONTEXT_WINDOW, source: "fallback" };
167
231
  }
168
232
  /**
169
- * Get context window size for the current model.
170
- * Tries multiple sources, caches the result in memory and on disk.
171
- * Concurrent calls for the same model share a single probe.
233
+ * Get context window size and whether it was configured, detected, or guessed.
234
+ * Fallback 512k is never written to disk as a detected value.
172
235
  */
173
- export async function getContextWindow(modelId) {
174
- const config = loadConfig();
236
+ export async function getContextWindowInfo(modelId) {
237
+ const config = getEffectiveConfig();
175
238
  const provider = getActiveProvider(config);
176
- // 1. Explicit user config
177
239
  if (provider?.contextWindow)
178
- return provider.contextWindow;
240
+ return { tokens: provider.contextWindow, source: "config" };
179
241
  const id = modelId ?? provider?.defaultModel;
180
242
  if (!id)
181
- return DEFAULT_CONTEXT_WINDOW;
182
- // 2. Check cache
243
+ return { tokens: DEFAULT_CONTEXT_WINDOW, source: "fallback" };
183
244
  const cached = getCached(id);
184
- if (cached !== null)
245
+ if (cached)
185
246
  return cached;
186
- // 3. Reuse an in-flight probe for the same model
187
247
  const pending = inFlight.get(id);
188
248
  if (pending)
189
249
  return pending;
@@ -196,3 +256,11 @@ export async function getContextWindow(modelId) {
196
256
  inFlight.delete(id);
197
257
  }
198
258
  }
259
+ /**
260
+ * Get context window size for the current model.
261
+ * Tries multiple sources, caches detected results in memory and on disk.
262
+ * Concurrent calls for the same model share a single probe.
263
+ */
264
+ export async function getContextWindow(modelId) {
265
+ return (await getContextWindowInfo(modelId)).tokens;
266
+ }