min-agent 0.5.0 → 0.5.1

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/config.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { readFileSync, mkdirSync, existsSync, statSync } from "fs";
2
2
  import path from "path";
3
3
  import os from "os";
4
- import readline from "readline";
5
4
  import { atomicWriteFileSync } from "./tools/atomic-file.js";
6
5
  import { getEffectiveSandboxPolicy, mergeSandboxConfig, parseSandboxConfig, sandboxLaunchSource, sandboxStatusLabel, } from "./sandbox.js";
7
6
  const DEFAULT_CONFIG_DIR = path.join(os.homedir(), ".min-agent");
@@ -287,184 +286,78 @@ async function fetchModelsFromURL(url, apiKey) {
287
286
  signal: AbortSignal.timeout(10000),
288
287
  });
289
288
  if (!response.ok)
290
- return [];
291
- const data = (await response.json());
292
- const models = (data.data ?? data ?? []);
293
- return models.map((m) => m.id).sort();
294
- }
295
- function ask(rl, question, defaultValue) {
296
- const suffix = defaultValue ? ` (${defaultValue})` : "";
297
- return new Promise((resolve) => {
298
- rl.question(`${question}${suffix}: `, (answer) => {
299
- resolve(answer.trim() || defaultValue || "");
300
- });
301
- });
289
+ return { models: [], ok: false, status: response.status };
290
+ const data = await response.json();
291
+ return { models: parseModelIds(data), ok: true, status: response.status };
292
+ }
293
+ function parseModelIds(data) {
294
+ const list = Array.isArray(data)
295
+ ? data
296
+ : data !== null && typeof data === "object" && "data" in data && Array.isArray(data.data)
297
+ ? data.data
298
+ : [];
299
+ return list
300
+ .map((item) => item !== null && typeof item === "object" && "id" in item && typeof item.id === "string" ? item.id : null)
301
+ .filter((id) => id != null)
302
+ .sort();
302
303
  }
303
304
  const MODELS_CACHE_FILE = "models-cache.json";
304
305
  function modelsCachePath() {
305
306
  return path.join(getConfigDir(), MODELS_CACHE_FILE);
306
307
  }
307
- function loadModelsCache() {
308
+ function modelsCacheKey(baseURL) {
309
+ return baseURL.replace(/\/$/, "");
310
+ }
311
+ function loadModelsCacheMap() {
308
312
  const file = modelsCachePath();
309
313
  if (!existsSync(file))
310
- return [];
314
+ return {};
311
315
  try {
312
- return JSON.parse(readFileSync(file, "utf-8"));
316
+ const parsed = JSON.parse(readFileSync(file, "utf-8"));
317
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
318
+ return {};
319
+ return Object.fromEntries(Object.entries(parsed).filter((entry) => Array.isArray(entry[1]) && entry[1].every((item) => typeof item === "string")));
313
320
  }
314
321
  catch {
315
- return [];
322
+ return {};
316
323
  }
317
324
  }
318
- function saveModelsCache(models) {
325
+ function loadModelsCacheBucket(baseURL) {
326
+ return loadModelsCacheMap()[modelsCacheKey(baseURL)] ?? [];
327
+ }
328
+ function saveModelsCacheBucket(baseURL, models) {
329
+ const map = loadModelsCacheMap();
330
+ map[modelsCacheKey(baseURL)] = models;
319
331
  mkdirSync(getConfigDir(), { recursive: true });
320
- atomicWriteFileSync(modelsCachePath(), JSON.stringify(models));
332
+ atomicWriteFileSync(modelsCachePath(), JSON.stringify(map));
321
333
  }
322
- export async function fetchModels(baseURL, apiKey) {
334
+ export async function fetchModelsLive(baseURL, apiKey) {
323
335
  try {
324
- const trimmed = baseURL.replace(/\/$/, "");
325
- let models = await fetchModelsFromURL(`${trimmed}/models`, apiKey);
326
- // Ollama users often provide host without /v1; auto-retry that variant.
327
- if (models.length === 0 && !trimmed.endsWith("/v1")) {
328
- models = await fetchModelsFromURL(`${trimmed}/v1/models`, apiKey);
336
+ const trimmed = modelsCacheKey(baseURL);
337
+ let result = await fetchModelsFromURL(`${trimmed}/models`, apiKey);
338
+ if (result.models.length === 0 && !trimmed.endsWith("/v1")) {
339
+ result = await fetchModelsFromURL(`${trimmed}/v1/models`, apiKey);
329
340
  }
330
- if (models.length > 0) {
331
- saveModelsCache(models);
332
- return models;
341
+ if (result.ok && result.models.length > 0) {
342
+ saveModelsCacheBucket(trimmed, result.models);
343
+ return { models: result.models, ok: true, status: result.status };
333
344
  }
334
- // Fallback to cache if live fetch returned nothing
335
- const cached = loadModelsCache();
336
- if (cached.length > 0) {
337
- console.log("\x1b[90m (using cached model list)\x1b[0m");
338
- }
339
- return cached;
345
+ if (!result.ok)
346
+ return { models: [], ok: false, status: result.status };
347
+ return { models: [], ok: true, status: result.status };
340
348
  }
341
349
  catch {
342
- // Network error fallback to cache
343
- const cached = loadModelsCache();
344
- if (cached.length > 0) {
345
- console.log("\x1b[90m (using cached model list — network unavailable)\x1b[0m");
346
- }
347
- return cached;
350
+ return { models: [], ok: false };
348
351
  }
349
352
  }
350
- export async function runSetup() {
351
- const config = loadConfig();
352
- const providers = config.providers ?? [];
353
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
354
- console.log("\n🔧 min-agent 配置\n");
355
- if (providers.length > 0) {
356
- console.log("已配置的 Provider:");
357
- providers.forEach((p, i) => {
358
- const marker = p.name === config.activeProvider ? " ← 当前" : "";
359
- console.log(` ${i + 1}. ${p.name ?? "(未命名)"} ${p.baseURL} (${p.defaultModel ?? "未设置模型"})${marker}`);
360
- });
361
- console.log();
362
- console.log("选项:");
363
- console.log(" 1. 添加新 Provider");
364
- console.log(" 2. 切换当前 Provider");
365
- console.log(" 3. 修改当前 Provider");
366
- console.log(" 4. 完成");
367
- const choice = await ask(rl, "选择 (1/2/3/4)", "4");
368
- if (choice === "1") {
369
- const provider = await collectProvider(rl, undefined);
370
- provider.name =
371
- (await ask(rl, "Provider 名称", `provider-${providers.length + 1}`)).trim() ||
372
- `provider-${providers.length + 1}`;
373
- providers.push(provider);
374
- config.activeProvider = provider.name;
375
- config.providers = providers;
376
- saveConfig(config);
377
- console.log(`\n✓ Provider "${provider.name}" 已添加并设为当前`);
378
- }
379
- else if (choice === "2") {
380
- const sel = await ask(rl, "输入序号选择当前 Provider");
381
- const idx = parseInt(sel, 10) - 1;
382
- const target = providers[idx];
383
- if (!target?.name) {
384
- console.error("无效序号");
385
- }
386
- else {
387
- config.activeProvider = target.name;
388
- saveConfig(config);
389
- console.log(`\n✓ 当前 Provider 已切换为: ${target.name}`);
390
- }
391
- }
392
- else if (choice === "3") {
393
- const name = getActiveProvider(config)?.name ?? providers[0]?.name;
394
- if (!name) {
395
- console.error("无可用 Provider");
396
- rl.close();
397
- return;
398
- }
399
- const idx = providers.findIndex((p) => p.name === name);
400
- providers[idx] = { ...(await collectProvider(rl, providers[idx])), name };
401
- config.providers = providers;
402
- saveConfig(config);
403
- console.log(`\n✓ Provider "${name}" 已更新`);
404
- }
405
- else {
406
- console.log("\n配置未变更");
407
- }
408
- rl.close();
409
- return;
410
- }
411
- // 首次配置
412
- const provider = await collectProvider(rl, undefined);
413
- provider.name = "default";
414
- config.providers = [provider];
415
- config.activeProvider = "default";
416
- saveConfig(config);
417
- rl.close();
418
- console.log(`\n✓ 配置已保存到 ${path.join(getConfigDir(), CONFIG_FILE_NAME)}`);
419
- }
420
- async function collectProvider(rl, existing) {
421
- console.log("Provider 类型:");
422
- console.log(" 1. openai-compatible (默认,兼容 OpenAI API 的任意服务)");
423
- console.log(" 2. openai (OpenAI 官方)");
424
- console.log(" 3. ollama (本地 Ollama)");
425
- console.log();
426
- const typeChoice = await ask(rl, "选择 Provider (1/2/3)", existing?.type === "ollama" ? "3" : existing?.type === "openai" ? "2" : "1");
427
- const providerType = typeChoice === "3" ? "ollama" : typeChoice === "2" ? "openai" : "openai-compatible";
428
- let baseURL;
429
- let apiKey;
430
- if (providerType === "ollama") {
431
- baseURL = await ask(rl, "Ollama API URL", existing?.baseURL || "http://localhost:11434/v1");
432
- baseURL = normalizeOllamaBaseURL(baseURL);
433
- apiKey = "ollama";
434
- }
435
- else if (providerType === "openai") {
436
- baseURL = "https://api.openai.com/v1";
437
- apiKey = await ask(rl, "OpenAI API Key", existing?.apiKey);
438
- }
439
- else {
440
- baseURL = await ask(rl, "API Base URL", existing?.baseURL || "https://api.openai.com/v1");
441
- apiKey = await ask(rl, "API Key", existing?.apiKey);
442
- }
443
- if (!baseURL || (!apiKey && providerType !== "ollama")) {
444
- console.error("URL 和 Key 不能为空");
445
- process.exit(1);
446
- }
447
- console.log("\n正在获取模型列表...");
448
- const models = await fetchModels(baseURL, apiKey);
449
- let defaultModel = existing?.defaultModel ?? "";
450
- if (models.length > 0) {
451
- console.log(`\n可用模型 (${models.length}):`);
452
- models.forEach((m, i) => {
453
- const marker = m === defaultModel ? " ← 当前默认" : "";
454
- console.log(` ${i + 1}. ${m}${marker}`);
455
- });
456
- console.log();
457
- const choice = await ask(rl, "选择默认模型 (输入序号或模型名)", defaultModel);
458
- const idx = parseInt(choice, 10) - 1;
459
- if (idx >= 0 && idx < models.length)
460
- defaultModel = models[idx];
461
- else if (choice)
462
- defaultModel = choice;
463
- }
464
- else {
465
- console.log(" ⚠ 无法获取模型列表,请手动输入模型名");
466
- const hint = providerType === "ollama" ? "llama3" : providerType === "openai" ? "gpt-4o" : "";
467
- defaultModel = await ask(rl, "默认模型", defaultModel || hint);
353
+ export async function fetchModels(baseURL, apiKey) {
354
+ const live = await fetchModelsLive(baseURL, apiKey);
355
+ if (live.models.length > 0)
356
+ return live.models;
357
+ const cached = loadModelsCacheBucket(baseURL);
358
+ if (cached.length > 0) {
359
+ const suffix = live.status == null && !live.ok ? " — network unavailable" : "";
360
+ console.log(`\x1b[90m (using cached model list${suffix})\x1b[0m`);
468
361
  }
469
- return { type: providerType, baseURL, apiKey, defaultModel };
362
+ return cached;
470
363
  }
@@ -3,14 +3,17 @@ import path from "path";
3
3
  import { atomicWriteFileSync } from "./tools/atomic-file.js";
4
4
  import { getConfigDir, getEffectiveConfig, getActiveProvider } from "./config.js";
5
5
  import { getCachedModelCatalog, getModelCatalog } from "./model-catalog.js";
6
+ import { getCachedOllamaModel, getOllamaModel, OLLAMA_AGENT_CONTEXT_CAP, OLLAMA_DEFAULT_CONTEXT_WINDOW, } from "./ollama-model.js";
6
7
  /**
7
8
  * Auto-detect context window size for the current model.
8
9
  *
9
10
  * Resolution order:
10
11
  * 1. User config: provider.contextWindow (explicit override)
11
- * 2. Provider-specific API (OpenRouter, vLLM, Ollama, OpenAI-compatible /models)
12
- * 3. Lonae model catalog (context + thinking options)
13
- * 4. Fallback: 512000 (memory only never persisted as a detected value)
12
+ * 2. Ollama providers (`type: "ollama"`): native POST /api/show operational window
13
+ * (Modelfile num_ctx, else architecture length capped at 32k) — never Lonae
14
+ * 3. Provider-specific API (OpenRouter, vLLM, hostname-based Ollama, OpenAI-compatible /models)
15
+ * 4. Lonae model catalog (context + thinking options)
16
+ * 5. Fallback: 512000 (memory only — never persisted as a detected value)
14
17
  */
15
18
  export const DEFAULT_CONTEXT_WINDOW = 512000;
16
19
  const CACHE_TTL = 7 * 24 * 60 * 60 * 1000;
@@ -164,25 +167,23 @@ async function tryProviderModels(baseURL, apiKey, modelId) {
164
167
  async function tryOllama(baseURL, modelId) {
165
168
  if (!baseURL.includes("localhost") && !baseURL.includes("127.0.0.1") && !baseURL.includes("ollama"))
166
169
  return null;
167
- try {
168
- // Ollama's /api/show endpoint
169
- const ollamaBase = baseURL.replace(/\/v1\/?$/, "");
170
- const response = await fetch(`${ollamaBase}/api/show`, {
171
- method: "POST",
172
- headers: { "Content-Type": "application/json" },
173
- body: JSON.stringify({ name: modelId }),
174
- signal: AbortSignal.timeout(5000),
175
- });
176
- if (!response.ok)
177
- return null;
178
- const data = (await response.json());
179
- // Ollama returns model_info with context length
180
- const ctxLength = data.model_info?.["general.context_length"] ?? data.model_info?.context_length ?? data.parameters?.num_ctx;
181
- return typeof ctxLength === "number" ? ctxLength : null;
170
+ const info = await getOllamaModel(modelId, baseURL);
171
+ return info?.contextWindow ?? null;
172
+ }
173
+ async function detectOllamaWindow(baseURL, id) {
174
+ const shown = await getOllamaModel(id, baseURL);
175
+ if (shown?.contextWindow) {
176
+ setCache(id, shown.contextWindow, "detected");
177
+ return { tokens: shown.contextWindow, source: "detected" };
182
178
  }
183
- catch {
184
- return null;
179
+ const listed = await tryProviderModels(baseURL, "ollama", id);
180
+ if (listed) {
181
+ const tokens = Math.min(listed, OLLAMA_AGENT_CONTEXT_CAP);
182
+ setCache(id, tokens, "detected");
183
+ return { tokens, source: "detected" };
185
184
  }
185
+ setCache(id, OLLAMA_DEFAULT_CONTEXT_WINDOW, "fallback");
186
+ return { tokens: OLLAMA_DEFAULT_CONTEXT_WINDOW, source: "fallback" };
186
187
  }
187
188
  /**
188
189
  * Detect context window size for a model by probing all sources in parallel.
@@ -192,6 +193,8 @@ async function detectContextWindow(config, id) {
192
193
  const provider = getActiveProvider(config);
193
194
  const baseURL = provider?.baseURL ?? "";
194
195
  const apiKey = provider?.apiKey ?? "";
196
+ if (provider?.type === "ollama")
197
+ return detectOllamaWindow(baseURL, id);
195
198
  const catalog = await getModelCatalog(id, baseURL);
196
199
  if (catalog?.contextWindow)
197
200
  return { tokens: catalog.contextWindow, source: "detected" };
@@ -221,9 +224,16 @@ export async function getContextWindowInfo(modelId) {
221
224
  const id = modelId ?? provider?.defaultModel;
222
225
  if (!id)
223
226
  return { tokens: DEFAULT_CONTEXT_WINDOW, source: "fallback" };
224
- const cached = getCached(id);
225
- if (cached)
226
- return cached;
227
+ if (provider?.type === "ollama") {
228
+ const shown = getCachedOllamaModel(id, provider.baseURL);
229
+ if (shown?.contextWindow)
230
+ return { tokens: shown.contextWindow, source: "detected" };
231
+ }
232
+ else {
233
+ const cached = getCached(id);
234
+ if (cached)
235
+ return cached;
236
+ }
227
237
  const pending = inFlight.get(id);
228
238
  if (pending)
229
239
  return pending;
@@ -0,0 +1,30 @@
1
+ import { activeProviderIsOllama, configuredCtxChoice, ctxChoiceLabel, parseCtxChoice, setOllamaContextChoice, } from "./ctx.js";
2
+ export const CTX_CLI_USAGE = [
3
+ "Usage: min-agent ctx [2k|4k|8k|12k|16k|32k|64k|128k|256k|auto]",
4
+ " Context levels are only available for an Ollama provider.",
5
+ ].join("\n");
6
+ export function runCtxCli(input) {
7
+ if (input.positionals[0] === "--help" || input.positionals[0] === "-h") {
8
+ return { ok: true, lines: [CTX_CLI_USAGE] };
9
+ }
10
+ if (!activeProviderIsOllama()) {
11
+ return { ok: false, lines: ["Context levels are only available for an Ollama provider.", CTX_CLI_USAGE] };
12
+ }
13
+ if (input.positionals.length === 0) {
14
+ const current = configuredCtxChoice();
15
+ const label = current.choice === "auto" && current.tokens != null ? `${current.tokens}` : current.choice;
16
+ return { ok: true, lines: [`Current context window: ${label}`, CTX_CLI_USAGE] };
17
+ }
18
+ if (input.positionals.length > 1)
19
+ return { ok: false, lines: [CTX_CLI_USAGE] };
20
+ const parsed = parseCtxChoice(input.positionals[0]);
21
+ if (!parsed)
22
+ return { ok: false, lines: [CTX_CLI_USAGE] };
23
+ const result = setOllamaContextChoice(parsed);
24
+ if (!result.ok)
25
+ return { ok: false, lines: ["Context levels are only available for an Ollama provider.", CTX_CLI_USAGE] };
26
+ return {
27
+ ok: true,
28
+ lines: [`✓ Context window set to ${result.choice === "auto" ? "auto" : ctxChoiceLabel(result.choice)}`],
29
+ };
30
+ }
package/dist/ctx.js ADDED
@@ -0,0 +1,80 @@
1
+ import { getActiveProvider, loadConfig, saveConfig } from "./config.js";
2
+ export const CTX_LEVELS = ["2k", "4k", "8k", "12k", "16k", "32k", "64k", "128k", "256k"];
3
+ export const CTX_AUTO = "auto";
4
+ export const CTX_CHOICES = [...CTX_LEVELS, CTX_AUTO];
5
+ const CTX_TOKENS = {
6
+ "2k": 2048,
7
+ "4k": 4096,
8
+ "8k": 8192,
9
+ "12k": 12288,
10
+ "16k": 16384,
11
+ "32k": 32768,
12
+ "64k": 65536,
13
+ "128k": 131072,
14
+ "256k": 262144,
15
+ };
16
+ const TOKEN_TO_LEVEL = new Map(CTX_LEVELS.map((level) => [CTX_TOKENS[level], level]));
17
+ export function isCtxLevel(value) {
18
+ return CTX_LEVELS.includes(value);
19
+ }
20
+ export function isCtxChoice(value) {
21
+ return value === CTX_AUTO || isCtxLevel(value);
22
+ }
23
+ export function tokensForCtxLevel(level) {
24
+ return CTX_TOKENS[level];
25
+ }
26
+ export function matchCtxLevel(tokens) {
27
+ return TOKEN_TO_LEVEL.get(Math.floor(tokens)) ?? null;
28
+ }
29
+ export function parseCtxChoice(raw) {
30
+ if (raw == null)
31
+ return null;
32
+ const text = raw.trim().toLowerCase();
33
+ if (!text)
34
+ return null;
35
+ if (text === "auto" || text === "default")
36
+ return CTX_AUTO;
37
+ if (isCtxLevel(text))
38
+ return text;
39
+ if (!/^\d+$/.test(text))
40
+ return null;
41
+ return matchCtxLevel(Number(text));
42
+ }
43
+ export function ctxChoiceLabel(choice) {
44
+ return choice === CTX_AUTO ? "自动" : choice;
45
+ }
46
+ export function activeProviderIsOllama() {
47
+ return getActiveProvider(loadConfig())?.type === "ollama";
48
+ }
49
+ export function configuredCtxChoice() {
50
+ const tokens = getActiveProvider(loadConfig())?.contextWindow;
51
+ if (typeof tokens !== "number" || !Number.isFinite(tokens) || tokens <= 0)
52
+ return { choice: CTX_AUTO };
53
+ const level = matchCtxLevel(tokens);
54
+ if (level)
55
+ return { choice: level, tokens: tokensForCtxLevel(level) };
56
+ return { choice: CTX_AUTO, tokens: Math.floor(tokens) };
57
+ }
58
+ export function setOllamaContextChoice(choice) {
59
+ const cfg = loadConfig();
60
+ const provider = getActiveProvider(cfg);
61
+ if (!provider)
62
+ return { ok: false, reason: "no-provider" };
63
+ if (provider.type !== "ollama")
64
+ return { ok: false, reason: "not-ollama" };
65
+ if (choice === CTX_AUTO)
66
+ delete provider.contextWindow;
67
+ else
68
+ provider.contextWindow = tokensForCtxLevel(choice);
69
+ saveConfig(cfg);
70
+ return choice === CTX_AUTO ? { ok: true, choice } : { ok: true, choice, tokens: tokensForCtxLevel(choice) };
71
+ }
72
+ export function ctxPayload() {
73
+ const provider = getActiveProvider(loadConfig());
74
+ if (provider?.type !== "ollama")
75
+ return { configurable: false, level: null };
76
+ const tokens = provider.contextWindow;
77
+ if (typeof tokens !== "number" || !Number.isFinite(tokens) || tokens <= 0)
78
+ return { configurable: true, level: CTX_AUTO };
79
+ return { configurable: true, level: matchCtxLevel(tokens) };
80
+ }