min-agent 0.2.1 → 0.3.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 (81) hide show
  1. package/README.md +146 -18
  2. package/dist/agent.js +293 -408
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli.js +397 -139
  5. package/dist/clipboard.js +59 -23
  6. package/dist/code-mode.js +3 -3
  7. package/dist/compaction.js +182 -81
  8. package/dist/config.js +186 -35
  9. package/dist/confirm.js +55 -6
  10. package/dist/context-window.js +67 -54
  11. package/dist/doom-loop.js +19 -12
  12. package/dist/http.js +119 -0
  13. package/dist/instructions.js +51 -33
  14. package/dist/logger.js +66 -0
  15. package/dist/markdown.js +3 -44
  16. package/dist/mcp.js +547 -100
  17. package/dist/memory.js +48 -6
  18. package/dist/output.js +36 -27
  19. package/dist/paste-handler.js +3 -3
  20. package/dist/plugins.js +33 -6
  21. package/dist/pricing.js +119 -0
  22. package/dist/provider.js +17 -15
  23. package/dist/serve.js +658 -369
  24. package/dist/sessions.js +151 -13
  25. package/dist/skills.js +466 -76
  26. package/dist/synthetic.js +7 -0
  27. package/dist/title-gen.js +2 -1
  28. package/dist/tool-display.js +173 -0
  29. package/dist/tool-output.js +54 -45
  30. package/dist/tools/apply_patch.js +191 -0
  31. package/dist/tools/backend.js +61 -0
  32. package/dist/tools/bash.js +147 -70
  33. package/dist/tools/code_search.js +6 -5
  34. package/dist/tools/edit.js +23 -7
  35. package/dist/tools/explore.js +80 -12
  36. package/dist/tools/glob.js +3 -3
  37. package/dist/tools/grep.js +146 -14
  38. package/dist/tools/index.js +7 -7
  39. package/dist/tools/question.js +4 -22
  40. package/dist/tools/read.js +71 -11
  41. package/dist/tools/task.js +33 -20
  42. package/dist/tools/todo.js +83 -73
  43. package/dist/tools/web_fetch.js +150 -46
  44. package/dist/tools/web_search.js +706 -28
  45. package/dist/tools/write.js +13 -7
  46. package/dist/tui/App.js +40 -6
  47. package/dist/tui/ConfirmBar.js +24 -3
  48. package/dist/tui/InputBar.js +390 -45
  49. package/dist/tui/MessageList.js +533 -20
  50. package/dist/tui/ModelPicker.js +108 -0
  51. package/dist/tui/QuestionBar.js +104 -0
  52. package/dist/tui/StatusBar.js +19 -11
  53. package/dist/tui/agent-runner.js +103 -0
  54. package/dist/tui/caret-pos.js +134 -0
  55. package/dist/tui/caret.js +69 -0
  56. package/dist/tui/diff-view.js +61 -0
  57. package/dist/tui/drag-state.js +44 -0
  58. package/dist/tui/index.js +153 -24
  59. package/dist/tui/input-history.js +44 -0
  60. package/dist/tui/layout.js +17 -0
  61. package/dist/tui/mouse.js +46 -0
  62. package/dist/tui/selection.js +134 -0
  63. package/dist/tui/slash-commands.js +90 -0
  64. package/dist/tui/slash-handler.js +370 -0
  65. package/dist/tui/text-width.js +91 -0
  66. package/dist/tui/theme.js +12 -0
  67. package/dist/tui/undo-stack.js +14 -0
  68. package/dist/tui/use-sgr-mouse.js +27 -0
  69. package/dist/tui-chat.js +111 -331
  70. package/dist/updater.js +57 -0
  71. package/docs/API.md +160 -14
  72. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  73. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  74. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  75. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  76. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  77. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  78. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  79. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  80. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  81. package/package.json +7 -8
package/dist/config.js CHANGED
@@ -1,33 +1,127 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from "fs";
2
2
  import path from "path";
3
3
  import os from "os";
4
4
  import readline from "readline";
5
- const CONFIG_DIR = path.join(os.homedir(), ".min-agent");
6
- const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
7
- const RULES_FILE = path.join(CONFIG_DIR, "rules.md");
5
+ const DEFAULT_CONFIG_DIR = path.join(os.homedir(), ".min-agent");
6
+ const CONFIG_FILE_NAME = "config.json";
7
+ const RULES_FILE_NAME = "rules.md";
8
8
  export function getConfigDir() {
9
- return CONFIG_DIR;
9
+ return process.env.MIN_AGENT_CONFIG_DIR ?? DEFAULT_CONFIG_DIR;
10
10
  }
11
11
  export function getRulesFile() {
12
- return RULES_FILE;
12
+ return path.join(getConfigDir(), RULES_FILE_NAME);
13
13
  }
14
- export function loadConfig() {
15
- if (!existsSync(CONFIG_FILE))
14
+ let cachedConfig = null;
15
+ const projectCache = new Map();
16
+ export function getProjectConfigDir() {
17
+ return path.join(process.cwd(), ".min-agent");
18
+ }
19
+ export function getProjectConfigFile() {
20
+ return path.join(getProjectConfigDir(), CONFIG_FILE_NAME);
21
+ }
22
+ export function loadProjectConfig() {
23
+ const file = getProjectConfigFile();
24
+ let st;
25
+ try {
26
+ st = statSync(file);
27
+ }
28
+ catch {
16
29
  return {};
30
+ }
31
+ const cached = projectCache.get(file);
32
+ if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size)
33
+ return cached.config;
34
+ let config;
17
35
  try {
18
- return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
36
+ config = JSON.parse(readFileSync(file, "utf-8"));
19
37
  }
20
38
  catch {
39
+ console.warn(`\x1b[33m ⚠ Project config ${file} is not valid JSON; ignoring\x1b[0m`);
21
40
  return {};
22
41
  }
42
+ projectCache.set(file, { mtimeMs: st.mtimeMs, size: st.size, config });
43
+ return config;
44
+ }
45
+ export function saveProjectConfig(config) {
46
+ const dir = getProjectConfigDir();
47
+ mkdirSync(dir, { recursive: true });
48
+ // Keep unknown keys, drop empty skill arrays so the file stays minimal.
49
+ const { disabledSkills, enabledSkills, ...rest } = config;
50
+ writeFileSync(getProjectConfigFile(), JSON.stringify({
51
+ ...rest,
52
+ ...(disabledSkills?.length ? { disabledSkills } : {}),
53
+ ...(enabledSkills?.length ? { enabledSkills } : {}),
54
+ }, null, 2), "utf-8");
55
+ projectCache.delete(getProjectConfigFile());
56
+ }
57
+ export function loadConfigFile(configFile) {
58
+ let st;
59
+ try {
60
+ st = statSync(configFile);
61
+ }
62
+ catch {
63
+ return {};
64
+ }
65
+ if (cachedConfig && cachedConfig.file === configFile && cachedConfig.mtimeMs === st.mtimeMs && cachedConfig.size === st.size) {
66
+ return cachedConfig.config;
67
+ }
68
+ let config;
69
+ try {
70
+ config = JSON.parse(readFileSync(configFile, "utf-8"));
71
+ }
72
+ catch {
73
+ console.warn(`\x1b[33m ⚠ Config file ${configFile} is not valid JSON; using defaults\x1b[0m`);
74
+ return {};
75
+ }
76
+ cachedConfig = { file: configFile, mtimeMs: st.mtimeMs, size: st.size, config };
77
+ return config;
78
+ }
79
+ export function loadConfig() {
80
+ const raw = loadConfigFile(path.join(getConfigDir(), CONFIG_FILE_NAME));
81
+ const { config, migrated } = migrateLegacyConfig(raw);
82
+ if (migrated) {
83
+ try {
84
+ saveConfig(config);
85
+ }
86
+ catch { }
87
+ }
88
+ return config;
89
+ }
90
+ export function migrateLegacyConfig(config) {
91
+ const legacy = config.provider;
92
+ if (!legacy)
93
+ return { config, migrated: false };
94
+ const { provider, ...rest } = config;
95
+ return {
96
+ config: { ...rest, providers: [{ name: "default", ...provider }], activeProvider: "default" },
97
+ migrated: true,
98
+ };
99
+ }
100
+ export function getActiveProvider(config) {
101
+ if (!config.providers || config.providers.length === 0)
102
+ return undefined;
103
+ if (config.activeProvider) {
104
+ const found = config.providers.find((p) => p.name === config.activeProvider);
105
+ if (found)
106
+ return found;
107
+ }
108
+ return config.providers[0];
109
+ }
110
+ /** Appends /v1 to an Ollama base URL unless already present */
111
+ export function normalizeOllamaBaseURL(baseURL) {
112
+ const trimmed = baseURL.replace(/\/$/, "");
113
+ return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
23
114
  }
24
115
  export function saveConfig(config) {
25
- mkdirSync(CONFIG_DIR, { recursive: true });
26
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
116
+ const dir = getConfigDir();
117
+ mkdirSync(dir, { recursive: true });
118
+ writeFileSync(path.join(dir, CONFIG_FILE_NAME), JSON.stringify(config, null, 2), "utf-8");
119
+ cachedConfig = null;
27
120
  }
28
121
  export function isConfigured() {
29
122
  const config = loadConfig();
30
- return !!(config.provider?.baseURL && config.provider?.apiKey);
123
+ const provider = getActiveProvider(config);
124
+ return !!(provider?.baseURL && provider?.apiKey);
31
125
  }
32
126
  async function fetchModelsFromURL(url, apiKey) {
33
127
  const noCacheURL = new URL(url);
@@ -56,20 +150,24 @@ function ask(rl, question, defaultValue) {
56
150
  });
57
151
  });
58
152
  }
59
- const MODELS_CACHE_FILE = path.join(CONFIG_DIR, "models-cache.json");
153
+ const MODELS_CACHE_FILE = "models-cache.json";
154
+ function modelsCachePath() {
155
+ return path.join(getConfigDir(), MODELS_CACHE_FILE);
156
+ }
60
157
  function loadModelsCache() {
61
- if (!existsSync(MODELS_CACHE_FILE))
158
+ const file = modelsCachePath();
159
+ if (!existsSync(file))
62
160
  return [];
63
161
  try {
64
- return JSON.parse(readFileSync(MODELS_CACHE_FILE, "utf-8"));
162
+ return JSON.parse(readFileSync(file, "utf-8"));
65
163
  }
66
164
  catch {
67
165
  return [];
68
166
  }
69
167
  }
70
168
  function saveModelsCache(models) {
71
- mkdirSync(CONFIG_DIR, { recursive: true });
72
- writeFileSync(MODELS_CACHE_FILE, JSON.stringify(models), "utf-8");
169
+ mkdirSync(getConfigDir(), { recursive: true });
170
+ writeFileSync(modelsCachePath(), JSON.stringify(models), "utf-8");
73
171
  }
74
172
  export async function fetchModels(baseURL, apiKey) {
75
173
  try {
@@ -101,9 +199,73 @@ export async function fetchModels(baseURL, apiKey) {
101
199
  }
102
200
  export async function runSetup() {
103
201
  const config = loadConfig();
104
- const existing = config.provider;
202
+ const providers = config.providers ?? [];
105
203
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
106
204
  console.log("\n🔧 min-agent 配置\n");
205
+ if (providers.length > 0) {
206
+ console.log("已配置的 Provider:");
207
+ providers.forEach((p, i) => {
208
+ const marker = p.name === config.activeProvider ? " ← 当前" : "";
209
+ console.log(` ${i + 1}. ${p.name ?? "(未命名)"} ${p.baseURL} (${p.defaultModel ?? "未设置模型"})${marker}`);
210
+ });
211
+ console.log();
212
+ console.log("选项:");
213
+ console.log(" 1. 添加新 Provider");
214
+ console.log(" 2. 切换当前 Provider");
215
+ console.log(" 3. 修改当前 Provider");
216
+ console.log(" 4. 完成");
217
+ const choice = await ask(rl, "选择 (1/2/3/4)", "4");
218
+ if (choice === "1") {
219
+ const provider = await collectProvider(rl, undefined);
220
+ provider.name = (await ask(rl, "Provider 名称", `provider-${providers.length + 1}`)).trim() || `provider-${providers.length + 1}`;
221
+ providers.push(provider);
222
+ config.activeProvider = provider.name;
223
+ config.providers = providers;
224
+ saveConfig(config);
225
+ console.log(`\n✓ Provider "${provider.name}" 已添加并设为当前`);
226
+ }
227
+ else if (choice === "2") {
228
+ const sel = await ask(rl, "输入序号选择当前 Provider");
229
+ const idx = parseInt(sel, 10) - 1;
230
+ const target = providers[idx];
231
+ if (!target?.name) {
232
+ console.error("无效序号");
233
+ }
234
+ else {
235
+ config.activeProvider = target.name;
236
+ saveConfig(config);
237
+ console.log(`\n✓ 当前 Provider 已切换为: ${target.name}`);
238
+ }
239
+ }
240
+ else if (choice === "3") {
241
+ const name = getActiveProvider(config)?.name ?? providers[0]?.name;
242
+ if (!name) {
243
+ console.error("无可用 Provider");
244
+ rl.close();
245
+ return;
246
+ }
247
+ const idx = providers.findIndex((p) => p.name === name);
248
+ providers[idx] = { ...(await collectProvider(rl, providers[idx])), name };
249
+ config.providers = providers;
250
+ saveConfig(config);
251
+ console.log(`\n✓ Provider "${name}" 已更新`);
252
+ }
253
+ else {
254
+ console.log("\n配置未变更");
255
+ }
256
+ rl.close();
257
+ return;
258
+ }
259
+ // 首次配置
260
+ const provider = await collectProvider(rl, undefined);
261
+ provider.name = "default";
262
+ config.providers = [provider];
263
+ config.activeProvider = "default";
264
+ saveConfig(config);
265
+ rl.close();
266
+ console.log(`\n✓ 配置已保存到 ${path.join(getConfigDir(), CONFIG_FILE_NAME)}`);
267
+ }
268
+ async function collectProvider(rl, existing) {
107
269
  console.log("Provider 类型:");
108
270
  console.log(" 1. openai-compatible (默认,兼容 OpenAI API 的任意服务)");
109
271
  console.log(" 2. openai (OpenAI 官方)");
@@ -117,10 +279,8 @@ export async function runSetup() {
117
279
  let apiKey;
118
280
  if (providerType === "ollama") {
119
281
  baseURL = await ask(rl, "Ollama API URL", existing?.baseURL || "http://localhost:11434/v1");
120
- if (!baseURL.replace(/\/$/, "").endsWith("/v1")) {
121
- baseURL = `${baseURL.replace(/\/$/, "")}/v1`;
122
- }
123
- apiKey = "ollama"; // Ollama doesn't need a real key
282
+ baseURL = normalizeOllamaBaseURL(baseURL);
283
+ apiKey = "ollama";
124
284
  }
125
285
  else if (providerType === "openai") {
126
286
  baseURL = "https://api.openai.com/v1";
@@ -132,7 +292,6 @@ export async function runSetup() {
132
292
  }
133
293
  if (!baseURL || (!apiKey && providerType !== "ollama")) {
134
294
  console.error("URL 和 Key 不能为空");
135
- rl.close();
136
295
  process.exit(1);
137
296
  }
138
297
  console.log("\n正在获取模型列表...");
@@ -146,24 +305,16 @@ export async function runSetup() {
146
305
  });
147
306
  console.log();
148
307
  const choice = await ask(rl, "选择默认模型 (输入序号或模型名)", defaultModel);
149
- const idx = parseInt(choice) - 1;
150
- if (idx >= 0 && idx < models.length) {
308
+ const idx = parseInt(choice, 10) - 1;
309
+ if (idx >= 0 && idx < models.length)
151
310
  defaultModel = models[idx];
152
- }
153
- else if (choice) {
311
+ else if (choice)
154
312
  defaultModel = choice;
155
- }
156
313
  }
157
314
  else {
158
315
  console.log(" ⚠ 无法获取模型列表,请手动输入模型名");
159
316
  const hint = providerType === "ollama" ? "llama3" : providerType === "openai" ? "gpt-4o" : "";
160
317
  defaultModel = await ask(rl, "默认模型", defaultModel || hint);
161
318
  }
162
- rl.close();
163
- config.provider = { type: providerType, baseURL, apiKey, defaultModel };
164
- saveConfig(config);
165
- console.log(`\n✓ 配置已保存到 ${CONFIG_FILE}`);
166
- console.log(` Type: ${providerType}`);
167
- console.log(` URL: ${baseURL}`);
168
- console.log(` Model: ${defaultModel}\n`);
319
+ return { type: providerType, baseURL, apiKey, defaultModel };
169
320
  }
package/dist/confirm.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { loadConfig } from "./config.js";
2
+ import readline from "readline";
2
3
  let autoApprove = false;
3
4
  export function setAutoApprove(value) {
4
5
  autoApprove = value;
@@ -19,6 +20,37 @@ let _tuiConfirm = null;
19
20
  export function setTuiConfirm(handler) {
20
21
  _tuiConfirm = handler;
21
22
  }
23
+ /** Optional TUI question handler — when set, askQuestion() delegates to the TUI. Returns null on cancel. */
24
+ let _tuiQuestion = null;
25
+ export function setTuiQuestion(handler) {
26
+ _tuiQuestion = handler;
27
+ }
28
+ /** Ask the user a free-form question. Returns the answer, or null if cancelled. */
29
+ export async function askQuestion(prompt, options) {
30
+ if (_tuiQuestion)
31
+ return _tuiQuestion(prompt, options);
32
+ if (!process.stdin.isTTY) {
33
+ console.log(`\n\x1b[33m❓ ${prompt} (no interactive terminal — skipped)\x1b[0m`);
34
+ return null;
35
+ }
36
+ 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`);
41
+ }
42
+ }
43
+ return new Promise((resolve) => {
44
+ const rl = readline.createInterface({
45
+ input: process.stdin,
46
+ output: process.stdout,
47
+ });
48
+ rl.question("\x1b[36m → \x1b[0m", (answer) => {
49
+ rl.close();
50
+ resolve(answer.trim() || "(no answer)");
51
+ });
52
+ });
53
+ }
22
54
  /**
23
55
  * Ask user for confirmation. Returns true if approved.
24
56
  * Display order: detail first, then [y/N] prompt at the bottom.
@@ -29,6 +61,11 @@ export async function confirm(message) {
29
61
  // If TUI mode is active, delegate to the TUI overlay
30
62
  if (_tuiConfirm)
31
63
  return _tuiConfirm(message);
64
+ // Non-interactive stdin can never answer — reject instead of hanging
65
+ if (!process.stdin.isTTY) {
66
+ console.log(`\n\x1b[33m⚠ Rejected (no interactive terminal): ${message}\x1b[0m`);
67
+ return false;
68
+ }
32
69
  // Pause readline so it doesn't consume/echo the keystroke
33
70
  _rl?.pause();
34
71
  // Detail on top, prompt at the bottom
@@ -60,17 +97,29 @@ export async function confirm(message) {
60
97
  /** Check if a shell command is potentially dangerous */
61
98
  export function isDangerousCommand(command) {
62
99
  const dangerous = [
63
- /\brm\s+(-rf?|--recursive)\s/,
64
- /\brm\s+-[a-z]*f/,
100
+ /\brm\s+(-[a-z]*[rf][a-z]*|--recursive)\s/,
65
101
  /\bsudo\b/,
66
102
  /\bmkfs\b/,
67
103
  /\bdd\s+/,
68
104
  /\b(shutdown|reboot|halt|poweroff)\b/,
69
- /\bgit\s+(push|reset\s+--hard|clean\s+-[a-z]*f)/,
70
- /\bnpm\s+publish\b/,
71
- /\bdrop\s+(table|database)\b/i,
72
- /\btruncate\s+table\b/i,
105
+ /\bgit\s+(push|reset\s+--hard|clean\s+-[a-z]*f|rebase)/,
106
+ /\bnpm\s+(publish|unpublish)\b/,
107
+ /\b(drop|truncate)\s+(table|database)\b/i,
73
108
  /\bformat\b.*\b[a-z]:\b/i,
109
+ /\bfind\b[^|;&]*\s+-delete\b/,
110
+ /\|\s*(ba|z|fi)?sh\b/,
111
+ /\bchmod\s+(-[a-zA-Z]+\s+)?[0-7]{3,4}\b/,
112
+ /\bchown\s+-R\b/,
113
+ /\bkill(all)?\s+(-9|-[a-z]*9)/,
114
+ /\b(pkill|killall)\b/,
115
+ /\bgit\s+checkout\s+\./,
116
+ /\bgit\s+stash\b/,
117
+ /\b(docker|podman)\s+(rm|prune|system)\b/,
118
+ /\b(shred|wipefs)\b/,
119
+ /\b(cp|mv)\s+-[a-z]*f\b/,
120
+ /[^<>=!&]\s*>>?\s*[^<>=&]|^\s*>>?\s*[^<>=&]/,
121
+ /:\(\)\s*\{/,
122
+ />+\s*\/dev\/(sd[a-z]+|nvme\d|disk\d)/,
74
123
  ];
75
124
  return dangerous.some((re) => re.test(command));
76
125
  }
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
2
2
  import path from "path";
3
- import { getConfigDir, loadConfig } from "./config.js";
3
+ import { getConfigDir, loadConfig, getActiveProvider } from "./config.js";
4
4
  /**
5
5
  * Auto-detect context window size for the current model.
6
6
  *
@@ -11,34 +11,44 @@ import { getConfigDir, loadConfig } from "./config.js";
11
11
  * 4. Fallback: 128000
12
12
  */
13
13
  const DEFAULT_CONTEXT_WINDOW = 128000;
14
- const CACHE_FILE = path.join(getConfigDir(), "context-window-cache.json");
15
14
  const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
16
- function loadCache() {
17
- if (!existsSync(CACHE_FILE))
15
+ function cacheFile() {
16
+ return path.join(getConfigDir(), "context-window-cache.json");
17
+ }
18
+ const memoryCache = new Map();
19
+ const inFlight = new Map();
20
+ function loadDiskCache() {
21
+ const file = cacheFile();
22
+ if (!existsSync(file))
18
23
  return {};
19
24
  try {
20
- return JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
25
+ return JSON.parse(readFileSync(file, "utf-8"));
21
26
  }
22
27
  catch {
23
28
  return {};
24
29
  }
25
30
  }
26
31
  function saveCache(cache) {
27
- mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
28
- writeFileSync(CACHE_FILE, JSON.stringify(cache), "utf-8");
32
+ const file = cacheFile();
33
+ mkdirSync(path.dirname(file), { recursive: true });
34
+ writeFileSync(file, JSON.stringify(cache), "utf-8");
29
35
  }
30
36
  function getCached(modelId) {
31
- const cache = loadCache();
32
- const entry = cache[modelId];
33
- if (!entry)
34
- return null;
35
- if (Date.now() - entry.timestamp > CACHE_TTL)
36
- return null;
37
- return entry.contextWindow;
37
+ 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;
44
+ }
45
+ return null;
38
46
  }
39
47
  function setCache(modelId, contextWindow) {
40
- const cache = loadCache();
41
- cache[modelId] = { contextWindow, timestamp: Date.now() };
48
+ const entry = { contextWindow, timestamp: Date.now() };
49
+ memoryCache.set(modelId, entry);
50
+ const cache = loadDiskCache();
51
+ cache[modelId] = entry;
42
52
  saveCache(cache);
43
53
  }
44
54
  /** Try OpenRouter: GET /api/v1/models returns context_length per model */
@@ -62,6 +72,8 @@ async function tryOpenRouter(baseURL, apiKey, modelId) {
62
72
  }
63
73
  /** Try vLLM: GET /v1/models returns max_model_len */
64
74
  async function tryVllm(baseURL, apiKey, modelId) {
75
+ if (!baseURL.includes("vllm"))
76
+ return null;
65
77
  try {
66
78
  const url = `${baseURL.replace(/\/$/, "")}/models`;
67
79
  const response = await fetch(url, {
@@ -114,15 +126,7 @@ async function tryModelsDev(modelId) {
114
126
  if (!response.ok)
115
127
  return null;
116
128
  const providers = (await response.json());
117
- // Search all providers for the model ID
118
- for (const provider of Object.values(providers)) {
119
- if (!provider.models)
120
- continue;
121
- const model = provider.models[modelId];
122
- if (model?.limit?.context)
123
- return model.limit.context;
124
- }
125
- // Try partial match (some providers prefix model IDs)
129
+ // Single pass: exact match or partial match (some providers prefix model IDs)
126
130
  for (const provider of Object.values(providers)) {
127
131
  if (!provider.models)
128
132
  continue;
@@ -139,47 +143,56 @@ async function tryModelsDev(modelId) {
139
143
  return null;
140
144
  }
141
145
  }
146
+ /**
147
+ * Detect context window size for a model by probing all sources in parallel.
148
+ * Returns the first non-null result, respecting the original precedence.
149
+ */
150
+ async function detectContextWindow(config, id) {
151
+ const provider = getActiveProvider(config);
152
+ const baseURL = provider?.baseURL ?? "";
153
+ const apiKey = provider?.apiKey ?? "";
154
+ const results = await Promise.all([
155
+ tryOpenRouter(baseURL, apiKey, id),
156
+ tryOllama(baseURL, id),
157
+ tryVllm(baseURL, apiKey, id),
158
+ tryModelsDev(id),
159
+ ]);
160
+ const found = results.find((v) => v !== null);
161
+ if (found) {
162
+ setCache(id, found);
163
+ return found;
164
+ }
165
+ setCache(id, DEFAULT_CONTEXT_WINDOW);
166
+ return DEFAULT_CONTEXT_WINDOW;
167
+ }
142
168
  /**
143
169
  * Get context window size for the current model.
144
- * Tries multiple sources, caches the result.
170
+ * Tries multiple sources, caches the result in memory and on disk.
171
+ * Concurrent calls for the same model share a single probe.
145
172
  */
146
173
  export async function getContextWindow(modelId) {
147
174
  const config = loadConfig();
175
+ const provider = getActiveProvider(config);
148
176
  // 1. Explicit user config
149
- if (config.provider?.contextWindow)
150
- return config.provider.contextWindow;
151
- const id = modelId ?? config.provider?.defaultModel;
177
+ if (provider?.contextWindow)
178
+ return provider.contextWindow;
179
+ const id = modelId ?? provider?.defaultModel;
152
180
  if (!id)
153
181
  return DEFAULT_CONTEXT_WINDOW;
154
182
  // 2. Check cache
155
183
  const cached = getCached(id);
156
184
  if (cached !== null)
157
185
  return cached;
158
- const baseURL = config.provider?.baseURL ?? "";
159
- const apiKey = config.provider?.apiKey ?? "";
160
- // 3. Provider-specific APIs
161
- const openRouter = await tryOpenRouter(baseURL, apiKey, id);
162
- if (openRouter) {
163
- setCache(id, openRouter);
164
- return openRouter;
165
- }
166
- const ollama = await tryOllama(baseURL, id);
167
- if (ollama) {
168
- setCache(id, ollama);
169
- return ollama;
170
- }
171
- const vllm = await tryVllm(baseURL, apiKey, id);
172
- if (vllm) {
173
- setCache(id, vllm);
174
- return vllm;
186
+ // 3. Reuse an in-flight probe for the same model
187
+ const pending = inFlight.get(id);
188
+ if (pending)
189
+ return pending;
190
+ const probing = detectContextWindow(config, id);
191
+ inFlight.set(id, probing);
192
+ try {
193
+ return await probing;
175
194
  }
176
- // 4. models.dev lookup
177
- const modelsDev = await tryModelsDev(id);
178
- if (modelsDev) {
179
- setCache(id, modelsDev);
180
- return modelsDev;
195
+ finally {
196
+ inFlight.delete(id);
181
197
  }
182
- // 5. Fallback
183
- setCache(id, DEFAULT_CONTEXT_WINDOW);
184
- return DEFAULT_CONTEXT_WINDOW;
185
198
  }
package/dist/doom-loop.js CHANGED
@@ -8,26 +8,33 @@
8
8
  * Based on opencode's processor.ts doom loop detection.
9
9
  */
10
10
  const THRESHOLD = 3;
11
+ /** Deterministic serialization so equivalent objects compare equal regardless of key order. */
12
+ function serializeInput(input) {
13
+ if (input === null || typeof input !== "object")
14
+ return JSON.stringify(input);
15
+ if (Array.isArray(input))
16
+ return JSON.stringify(input.map(serializeInput));
17
+ const sorted = {};
18
+ for (const key of Object.keys(input).sort()) {
19
+ sorted[key] = serializeInput(input[key]);
20
+ }
21
+ return JSON.stringify(sorted);
22
+ }
11
23
  export class DoomLoopDetector {
12
24
  recentCalls = [];
13
25
  /** Record a tool call. Returns true if a doom loop is detected. */
14
26
  record(toolName, input) {
15
- const serialized = JSON.stringify(input);
16
- this.recentCalls.push({ toolName, input: serialized });
17
- // Only check the last THRESHOLD calls
18
- if (this.recentCalls.length < THRESHOLD)
19
- return false;
20
- const recent = this.recentCalls.slice(-THRESHOLD);
21
- const allSame = recent.every((call) => call.toolName === recent[0].toolName && call.input === recent[0].input);
27
+ const call = { toolName, input: serializeInput(input) };
28
+ this.recentCalls.push(call);
29
+ const window = this.recentCalls.slice(-THRESHOLD);
30
+ const allSame = window.length === THRESHOLD &&
31
+ window.every((c) => c.toolName === call.toolName && c.input === call.input);
32
+ // Keep only the most recent THRESHOLD-1 calls to bound memory
33
+ this.recentCalls = window.slice(-(THRESHOLD - 1));
22
34
  if (allSame) {
23
- // Reset to prevent repeated warnings
24
35
  this.recentCalls = [];
25
36
  return true;
26
37
  }
27
- // Keep only last THRESHOLD entries to bound memory
28
- if (this.recentCalls.length > THRESHOLD * 2) {
29
- this.recentCalls = this.recentCalls.slice(-THRESHOLD);
30
- }
31
38
  return false;
32
39
  }
33
40
  reset() {