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.
- package/README.md +146 -18
- package/dist/agent.js +293 -408
- package/dist/assistant-stream.js +11 -7
- package/dist/cli.js +397 -139
- package/dist/clipboard.js +59 -23
- package/dist/code-mode.js +3 -3
- package/dist/compaction.js +182 -81
- package/dist/config.js +186 -35
- package/dist/confirm.js +55 -6
- package/dist/context-window.js +67 -54
- package/dist/doom-loop.js +19 -12
- package/dist/http.js +119 -0
- package/dist/instructions.js +51 -33
- package/dist/logger.js +66 -0
- package/dist/markdown.js +3 -44
- package/dist/mcp.js +547 -100
- package/dist/memory.js +48 -6
- package/dist/output.js +36 -27
- package/dist/paste-handler.js +3 -3
- package/dist/plugins.js +33 -6
- package/dist/pricing.js +119 -0
- package/dist/provider.js +17 -15
- package/dist/serve.js +658 -369
- package/dist/sessions.js +151 -13
- package/dist/skills.js +466 -76
- package/dist/synthetic.js +7 -0
- package/dist/title-gen.js +2 -1
- package/dist/tool-display.js +173 -0
- package/dist/tool-output.js +54 -45
- package/dist/tools/apply_patch.js +191 -0
- package/dist/tools/backend.js +61 -0
- package/dist/tools/bash.js +147 -70
- package/dist/tools/code_search.js +6 -5
- package/dist/tools/edit.js +23 -7
- package/dist/tools/explore.js +80 -12
- package/dist/tools/glob.js +3 -3
- package/dist/tools/grep.js +146 -14
- package/dist/tools/index.js +7 -7
- package/dist/tools/question.js +4 -22
- package/dist/tools/read.js +71 -11
- package/dist/tools/task.js +33 -20
- package/dist/tools/todo.js +83 -73
- package/dist/tools/web_fetch.js +150 -46
- package/dist/tools/web_search.js +706 -28
- package/dist/tools/write.js +13 -7
- package/dist/tui/App.js +40 -6
- package/dist/tui/ConfirmBar.js +24 -3
- package/dist/tui/InputBar.js +390 -45
- package/dist/tui/MessageList.js +533 -20
- package/dist/tui/ModelPicker.js +108 -0
- package/dist/tui/QuestionBar.js +104 -0
- package/dist/tui/StatusBar.js +19 -11
- package/dist/tui/agent-runner.js +103 -0
- package/dist/tui/caret-pos.js +134 -0
- package/dist/tui/caret.js +69 -0
- package/dist/tui/diff-view.js +61 -0
- package/dist/tui/drag-state.js +44 -0
- package/dist/tui/index.js +153 -24
- package/dist/tui/input-history.js +44 -0
- package/dist/tui/layout.js +17 -0
- package/dist/tui/mouse.js +46 -0
- package/dist/tui/selection.js +134 -0
- package/dist/tui/slash-commands.js +90 -0
- package/dist/tui/slash-handler.js +370 -0
- package/dist/tui/text-width.js +91 -0
- package/dist/tui/theme.js +12 -0
- package/dist/tui/undo-stack.js +14 -0
- package/dist/tui/use-sgr-mouse.js +27 -0
- package/dist/tui-chat.js +111 -331
- package/dist/updater.js +57 -0
- package/docs/API.md +160 -14
- package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
- package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
- package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
- package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
- package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
- package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
- package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
- package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
- package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
- 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
|
|
6
|
-
const
|
|
7
|
-
const
|
|
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
|
|
9
|
+
return process.env.MIN_AGENT_CONFIG_DIR ?? DEFAULT_CONFIG_DIR;
|
|
10
10
|
}
|
|
11
11
|
export function getRulesFile() {
|
|
12
|
-
return
|
|
12
|
+
return path.join(getConfigDir(), RULES_FILE_NAME);
|
|
13
13
|
}
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
158
|
+
const file = modelsCachePath();
|
|
159
|
+
if (!existsSync(file))
|
|
62
160
|
return [];
|
|
63
161
|
try {
|
|
64
|
-
return JSON.parse(readFileSync(
|
|
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(
|
|
72
|
-
writeFileSync(
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/\
|
|
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
|
}
|
package/dist/context-window.js
CHANGED
|
@@ -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
|
|
17
|
-
|
|
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(
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (Date.now() -
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
41
|
-
|
|
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
|
-
//
|
|
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 (
|
|
150
|
-
return
|
|
151
|
-
const id = modelId ??
|
|
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
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
|
16
|
-
this.recentCalls.push(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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() {
|