mocode-ai 1.6.4 → 1.6.6

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 (91) hide show
  1. package/README.md +382 -365
  2. package/README.zh-CN.md +19 -2
  3. package/dist/agent/model-turn.js +14 -1
  4. package/dist/agent/run-coordinator.js +7 -1
  5. package/dist/agent/spawn.js +38 -5
  6. package/dist/agent/stages/model-runner.js +1 -1
  7. package/dist/agent/stages/tool-dispatcher.js +1 -0
  8. package/dist/agent/tool-turn.js +2 -0
  9. package/dist/agent/work-discipline.js +3 -1
  10. package/dist/bots/bus.js +117 -0
  11. package/dist/bots/cli.js +102 -0
  12. package/dist/bots/store.js +100 -0
  13. package/dist/config/index.js +53 -2
  14. package/dist/config/presets.js +40 -1
  15. package/dist/config/profiles.js +9 -1
  16. package/dist/context/clearing.js +53 -0
  17. package/dist/context/relevance.js +6 -0
  18. package/dist/context/text-search.js +113 -0
  19. package/dist/headless.js +302 -0
  20. package/dist/index.js +171 -3
  21. package/dist/jobs/approval.js +153 -0
  22. package/dist/jobs/approve-cli.js +30 -0
  23. package/dist/jobs/attach.js +61 -0
  24. package/dist/jobs/checkpoint.js +33 -0
  25. package/dist/jobs/launch.js +107 -0
  26. package/dist/jobs/runner.js +59 -0
  27. package/dist/jobs/store.js +101 -0
  28. package/dist/jobs/worktree.js +63 -0
  29. package/dist/llm/index.js +35 -8
  30. package/dist/llm/providers/anthropic.js +14 -4
  31. package/dist/llm/reasoning.js +141 -0
  32. package/dist/memory/reflect.js +1 -1
  33. package/dist/memory/store.js +31 -28
  34. package/dist/models/budgets.js +11 -0
  35. package/dist/models/catalog.js +130 -0
  36. package/dist/models/map-preset.js +50 -0
  37. package/dist/models/model-panel.js +227 -0
  38. package/dist/models/protocol.js +44 -0
  39. package/dist/models/reasoning-cap.js +88 -0
  40. package/dist/models/search.js +58 -0
  41. package/dist/models/types.js +8 -0
  42. package/dist/notify/index.js +144 -0
  43. package/dist/repl/commands/bots.js +32 -0
  44. package/dist/repl/commands/effort.js +81 -0
  45. package/dist/repl/commands/jobs.js +109 -0
  46. package/dist/repl/commands/model-actions.js +138 -0
  47. package/dist/repl/commands/model-catalog.js +67 -0
  48. package/dist/repl/commands/model-panel-ui.js +107 -0
  49. package/dist/repl/commands/model.js +22 -20
  50. package/dist/repl/commands/registry.js +12 -0
  51. package/dist/repl/commands/schedule.js +37 -0
  52. package/dist/repl/commands/session.js +54 -0
  53. package/dist/repl/commands/stats.js +28 -0
  54. package/dist/repl/commands.js +6 -58
  55. package/dist/repl/runtime.js +5 -1
  56. package/dist/repl/status-bar.js +4 -2
  57. package/dist/rollback/store.js +17 -0
  58. package/dist/runtime/runtime.js +31 -1
  59. package/dist/schedule/cli.js +114 -0
  60. package/dist/schedule/cron.js +79 -0
  61. package/dist/schedule/daemon.js +166 -0
  62. package/dist/schedule/store.js +98 -0
  63. package/dist/session/compact.js +84 -3
  64. package/dist/session/index.js +2 -0
  65. package/dist/session/retention.js +297 -0
  66. package/dist/session/scheduler.js +30 -3
  67. package/dist/session/store.js +108 -6
  68. package/dist/session/usage-stats.js +90 -0
  69. package/dist/skills/activation.js +2 -0
  70. package/dist/skills/builtin-skills.js +153 -0
  71. package/dist/skills/discover.js +3 -1
  72. package/dist/skills/runner.js +4 -1
  73. package/dist/tools/builtins/arena.js +156 -0
  74. package/dist/tools/builtins/index.js +8 -0
  75. package/dist/tools/builtins/message-bus.js +98 -0
  76. package/dist/tools/builtins/read-file.js +19 -1
  77. package/dist/tools/builtins/run-command.js +2 -1
  78. package/dist/tools/builtins/session-search.js +33 -0
  79. package/dist/tools/builtins/task.js +2 -0
  80. package/dist/tools/constants.js +8 -0
  81. package/dist/tools/read-dedup.js +62 -0
  82. package/dist/tools/tool-runtime.js +1 -0
  83. package/dist/ui/batch.js +140 -104
  84. package/dist/ui/composer.js +4 -18
  85. package/dist/ui/fuzzy-picker.js +227 -0
  86. package/dist/ui/hierarchical-picker.js +302 -0
  87. package/dist/ui/intervention.js +17 -1
  88. package/dist/ui/layout-internal/statusbar.js +18 -7
  89. package/dist/ui/prompt-internal/editor.js +10 -27
  90. package/dist/ui/render.js +2 -0
  91. package/package.json +1 -1
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Reasoning Effort 适配层(#token-efficiency P3)。
3
+ *
4
+ * 用户只面对归一化五档 `off | low | medium | high | auto`,本模块按 provider +
5
+ * 模型名模式匹配,翻译成各家方言字段:
6
+ * - Anthropic 4.6+:`output_config.effort` + `thinking:{type:'adaptive'}`(新)
7
+ * - Anthropic 旧版:`thinking:{type:'enabled', budget_tokens:N}`(budget 严格小于 max_tokens)
8
+ * - OpenAI o 系 / gpt-5:`reasoning_effort`
9
+ * - Qwen3(vLLM/OpenAI 兼容):`enable_thinking` + `thinking_budget`
10
+ * - GLM-4.5/4.6/5:`thinking:{type:'enabled'|'disabled'}`
11
+ *
12
+ * 白名单驱动:未匹配到已知族的模型一律返回 {} —— 第三方中转可能因严格 schema
13
+ * 校验对未知字段直接 400,不下发是唯一在所有后端都安全的策略。
14
+ */
15
+ import { AsyncLocalStorage } from 'node:async_hooks';
16
+ import { paramsFromCatalog } from '../models/reasoning-cap.js';
17
+ /**
18
+ * Skill 激活期 effort scope(P3 §4.8):嵌套异步树(含子 agent)自动继承。
19
+ * 取值优先级:显式 per-request overrides 参数 > 本 scope > 会话级 getActiveEffort()。
20
+ * 刻意低于显式参数 —— compact/reflect 显式传 low 不被 skill effort 改写。
21
+ */
22
+ const effortScope = new AsyncLocalStorage();
23
+ export function getScopedEffort() {
24
+ return effortScope.getStore();
25
+ }
26
+ /** 在 skill effort scope 内执行 fn;effort=auto 等同于不包。 */
27
+ export function runWithEffortScope(effort, fn) {
28
+ if (effort === 'auto')
29
+ return fn();
30
+ return effortScope.run(effort, fn);
31
+ }
32
+ const EFFORTS = ['off', 'low', 'medium', 'high', 'auto'];
33
+ /** 解析外部输入(env / 命令行);非法值返回 undefined(调用方决定回落策略)。 */
34
+ export function parseReasoningEffort(value) {
35
+ if (typeof value !== 'string')
36
+ return undefined;
37
+ let v = value.trim();
38
+ if (v.length >= 2 && (v[0] === '"' || v[0] === "'") && v[v.length - 1] === v[0])
39
+ v = v.slice(1, -1);
40
+ v = v.trim().toLowerCase();
41
+ return EFFORTS.includes(v) ? v : undefined;
42
+ }
43
+ /** 各档对应的思考预算(token):Anthropic budget_tokens / Qwen thinking_budget。 */
44
+ const THINK_BUDGET = {
45
+ low: 1024,
46
+ medium: 4096,
47
+ high: 10240,
48
+ };
49
+ /** Anthropic:为正文预留的最小 token;budget = min(raw, maxTokens - RESERVED)。 */
50
+ const ANTHROPIC_OUTPUT_RESERVED = 1024;
51
+ const ANTHROPIC_DEFAULT_MAX = 8192;
52
+ /**
53
+ * 目标模型是否识别 reasoning 参数(白名单命中)。
54
+ * 供 /model show 给出「参数不会下发」提示,避免用户设了 high 却静默无效。
55
+ */
56
+ export function recognizesReasoning(target) {
57
+ // 目录来源显式声明了思考能力 → 直接采纳(含声明 false 时不识别)。
58
+ if (target.catalogProviderId && typeof target.catalogReasoning === 'boolean') {
59
+ return target.catalogReasoning;
60
+ }
61
+ if (target.provider === 'anthropic')
62
+ return true;
63
+ const m = target.model.toLowerCase();
64
+ return (/\bo[134]\b/.test(m) ||
65
+ /gpt-?5/.test(m) ||
66
+ /qwen[-_]?3/.test(m) ||
67
+ /deepseek[-_]?r1/.test(m) ||
68
+ /glm[-_]?4[.-]?[56]/.test(m) ||
69
+ /glm[-_]?5/.test(m) ||
70
+ /doubao[-_]?seed/.test(m));
71
+ }
72
+ /**
73
+ * 归一化 effort + 目标 → 要 merge 进请求体的字段(空对象 = 不下发)。纯函数。
74
+ */
75
+ export function resolveReasoningParams(effort, target) {
76
+ if (effort === 'auto')
77
+ return {};
78
+ // 目录优先:带 catalogProviderId 时按目录能力+厂商方言产出。
79
+ // 注意 anthropic 路径的思考在 anthropic provider 内仍走它自己的 target.provider,
80
+ // 目录只对 OpenAI 兼容路径有意义;Anthropic 官方协议继续走下方既有分支。
81
+ if (target.catalogProviderId && target.provider !== 'anthropic') {
82
+ const fromCatalog = paramsFromCatalog({
83
+ effort,
84
+ catalogProviderId: target.catalogProviderId,
85
+ reasoningOptions: target.catalogReasoningOptions,
86
+ maxTokens: target.maxTokens,
87
+ });
88
+ // 目录能产出非空字段就用;方言未知/返 {} 则落到正则兜底(而不是直接放弃)。
89
+ if (Object.keys(fromCatalog).length > 0)
90
+ return fromCatalog;
91
+ }
92
+ if (target.provider === 'anthropic') {
93
+ const m = target.model.toLowerCase();
94
+ // 仅认显式分隔的版本号 4-6/4.6/4_6/4-10…;宽松写法会误匹配名字里的日期(…20241022 中的 410)。
95
+ const is46Plus = /4[.\-_](?:[6-9]|[1-9]\d)(?!\d)/.test(m);
96
+ if (is46Plus) {
97
+ if (effort === 'off')
98
+ return { thinking: { type: 'disabled' } };
99
+ return { output_config: { effort }, thinking: { type: 'adaptive' } };
100
+ }
101
+ if (effort === 'off')
102
+ return {};
103
+ // 旧版:budget 必须严格小于 max_tokens,clamp 后非正 → 无法启用,不下发。
104
+ const maxTokens = target.maxTokens ?? ANTHROPIC_DEFAULT_MAX;
105
+ const budget = Math.min(THINK_BUDGET[effort], maxTokens - ANTHROPIC_OUTPUT_RESERVED);
106
+ if (budget <= 0)
107
+ return {};
108
+ return { thinking: { type: 'enabled', budget_tokens: budget } };
109
+ }
110
+ // ── OpenAI 兼容路径 ──
111
+ const m = target.model.toLowerCase();
112
+ // Qwen3(先于通用规则:名字里不含冲突模式,但显式更清晰)。
113
+ if (/qwen[-_]?3/.test(m)) {
114
+ if (effort === 'off')
115
+ return { enable_thinking: false };
116
+ return { enable_thinking: true, thinking_budget: THINK_BUDGET[effort] };
117
+ }
118
+ // GLM-4.5/4.6/5。
119
+ if (/glm[-_]?4[.-]?[56]/.test(m) || /glm[-_]?5/.test(m)) {
120
+ if (effort === 'off')
121
+ return { thinking: { type: 'disabled' } };
122
+ return { thinking: { type: 'enabled' } };
123
+ }
124
+ // 豆包 Seed(火山 Ark):思考是开关式,无 low/medium/high 预算档——非 off 即启用。
125
+ if (/doubao[-_]?seed/.test(m)) {
126
+ if (effort === 'off')
127
+ return { thinking: { type: 'disabled' } };
128
+ return { thinking: { type: 'enabled' } };
129
+ }
130
+ // DeepSeek-R1:无开关参数,任何档位都不下发。
131
+ if (/deepseek[-_]?r1/.test(m))
132
+ return {};
133
+ // OpenAI o1/o3/o4 + gpt-5:off 无法真关,官方最低档 low。
134
+ if (/\bo[134]\b/.test(m) || /gpt-?5/.test(m)) {
135
+ if (effort === 'off')
136
+ return { reasoning_effort: 'low' };
137
+ return { reasoning_effort: effort };
138
+ }
139
+ // 未知模型/第三方网关:白名单策略,不下发。
140
+ return {};
141
+ }
@@ -147,7 +147,7 @@ export async function runReflection(transcript, signal) {
147
147
  };
148
148
  let content = null;
149
149
  try {
150
- const r = await chat([sys, user], {}, signal ?? AbortSignal.timeout(60000));
150
+ const r = await chat([sys, user], {}, signal ?? AbortSignal.timeout(60000), undefined, { reasoningEffort: 'low' });
151
151
  // 推理模型偶发只返 reasoning_content(content null)或幻觉 tool_calls → 视为无产出
152
152
  if (!r.toolCalls.length && r.content)
153
153
  content = r.content;
@@ -12,6 +12,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from '
12
12
  import os from 'node:os';
13
13
  import path from 'node:path';
14
14
  import { MAX_ACTIVE, MAX_INDEX_ENTRIES, MAX_MEMORY_ENTRY, DECAY_DAYS, GC_DAYS } from '../tools/constants.js';
15
+ import { TextSearchIndex } from '../context/text-search.js';
15
16
  // ── 路径 ──────────────────────────────────────────────────────────────────
16
17
  function globalPath() {
17
18
  return path.join(os.homedir(), '.mocode', 'memory.jsonl');
@@ -139,57 +140,59 @@ export function saveEntry(input) {
139
140
  writeAtomic(p, fileEntries);
140
141
  return { ok: true, id };
141
142
  }
142
- function scoreEntry(e, terms) {
143
- if (terms.length === 0)
144
- return 1; // 无关键词:全命中(取前 limit)
145
- const id = e.id.toLowerCase();
146
- const name = e.name.toLowerCase();
147
- const summary = e.summary.toLowerCase();
148
- const body = e.body.toLowerCase();
149
- let s = 0;
150
- for (const t of terms) {
151
- if (id.includes(t))
152
- s += 8; // 含 id 匹配:模型常按索引里的 id 取详情(slug 带连字符,名字带空格,不单独匹配 id 会漏)
153
- if (name.includes(t))
154
- s += 10;
155
- if (summary.includes(t))
156
- s += 5;
157
- if (body.includes(t))
158
- s += 1;
159
- }
160
- return s;
161
- }
162
- /** 关键词搜索:多词子串匹配(name 权重最高)。命中即 bump recallCount/lastRecalledAt 写回(遗忘衰减依据)。 */
143
+ /**
144
+ * 关键词搜索:CJK 感知 BM25(共享 context/text-search),字段权重 name 10 > id 8 >
145
+ * summary 5 > body 1。命中即 bump recallCount/lastRecalledAt 写回(遗忘衰减依据)。
146
+ * 空查询:按旧行为返回前 limit 条(全命中)。
147
+ */
163
148
  export function searchEntries(query, opts = {}) {
164
149
  const all = loadAll();
165
150
  const status = opts.status ?? 'active';
166
151
  const pool = all
167
152
  .filter((e) => (status === 'any' ? true : e.status === status))
168
153
  .filter((e) => (opts.type ? e.type === opts.type : true));
169
- const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
170
- const scored = pool.map((e) => ({ e, s: scoreEntry(e, terms) })).filter((x) => x.s > 0);
171
- scored.sort((a, b) => b.s - a.s);
172
154
  const limit = Math.max(1, Math.min(opts.limit ?? 5, 20));
173
- const top = scored.slice(0, limit);
155
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
156
+ const top = [];
157
+ if (terms.length === 0) {
158
+ top.push(...pool.slice(0, limit));
159
+ }
160
+ else {
161
+ const index = new TextSearchIndex(pool.map((e) => ({
162
+ id: e.id,
163
+ fields: [
164
+ { text: e.name, weight: 10 },
165
+ { text: e.id, weight: 8 },
166
+ { text: e.summary, weight: 5 },
167
+ { text: e.body, weight: 1 },
168
+ ],
169
+ })));
170
+ const byId = new Map(pool.map((e) => [e.id, e]));
171
+ for (const hit of index.search(query, limit)) {
172
+ const e = byId.get(hit.id);
173
+ if (e)
174
+ top.push(e);
175
+ }
176
+ }
174
177
  if (top.length === 0)
175
178
  return [];
176
179
  // bump recall:只写有命中的 scope 文件
177
180
  const now = nowIso();
178
- const hitIds = new Set(top.map((x) => x.e.id));
181
+ const hitIds = new Set(top.map((x) => x.id));
179
182
  for (const e of all) {
180
183
  if (hitIds.has(e.id)) {
181
184
  e.recallCount++;
182
185
  e.lastRecalledAt = now;
183
186
  }
184
187
  }
185
- const hitScopes = new Set(top.map((x) => x.e.scope));
188
+ const hitScopes = new Set(top.map((x) => x.scope));
186
189
  for (const scope of hitScopes) {
187
190
  const p = pathForScope(scope);
188
191
  const entries = all.filter((e) => e.scope === scope);
189
192
  ensureDir(p);
190
193
  writeAtomic(p, entries);
191
194
  }
192
- return top.map((x) => x.e);
195
+ return top;
193
196
  }
194
197
  /** 索引(无 body、不 bump recall)。 */
195
198
  export function listEntries(opts = {}) {
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 各档思考预算(#model-catalog M3)。
3
+ *
4
+ * 独立小模块:reasoning.ts 与 reasoning-cap.ts 都要用,放在 models 下可避免
5
+ * reasoning-cap → reasoning 的反向依赖(reasoning 会 import reasoning-cap)。
6
+ */
7
+ export const THINK_BUDGET_BY_EFFORT = {
8
+ low: 1024,
9
+ medium: 4096,
10
+ high: 10240,
11
+ };
@@ -0,0 +1,130 @@
1
+ /**
2
+ * models.dev 目录的拉取 / ETag 协商 / 快照落盘 / 离线回退(#model-catalog M1)。
3
+ *
4
+ * 仅在用户显式命令时调用,不随启动或每轮联网。fetch 与快照路径均可注入,便于单测
5
+ * (不打真实 models.dev)。
6
+ */
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ export const CATALOG_URL = 'https://models.dev/api.json';
11
+ export const CATALOG_SNAPSHOT_PATH = path.join(os.homedir(), '.mocode', 'catalog.json');
12
+ /** 默认快照新鲜期(小时);MOCODE_CATALOG_TTL_HOURS 可覆盖。 */
13
+ export const DEFAULT_TTL_HOURS = 24;
14
+ /** 拉取超时(毫秒)。 */
15
+ export const FETCH_TIMEOUT_MS = 10_000;
16
+ function ttlHours() {
17
+ const v = Number(process.env.MOCODE_CATALOG_TTL_HOURS);
18
+ return Number.isFinite(v) && v > 0 ? v : DEFAULT_TTL_HOURS;
19
+ }
20
+ /** 读本地快照;不存在/损坏返回 null。路径可注入(测试用临时 HOME)。 */
21
+ export function readSnapshot(snapshotPath = CATALOG_SNAPSHOT_PATH) {
22
+ try {
23
+ const obj = JSON.parse(fs.readFileSync(snapshotPath, 'utf8'));
24
+ if (!obj.providers || typeof obj.providers !== 'object')
25
+ return null;
26
+ return obj;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ /** 写快照(原子:tmp + rename)。 */
33
+ export function writeSnapshot(snapshot, snapshotPath = CATALOG_SNAPSHOT_PATH) {
34
+ fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
35
+ const tmp = `${snapshotPath}.tmp-${process.pid}-${Date.now()}`;
36
+ fs.writeFileSync(tmp, JSON.stringify(snapshot), 'utf8');
37
+ fs.renameSync(tmp, snapshotPath);
38
+ }
39
+ /** 快照是否仍在 TTL 内。 */
40
+ export function isFresh(snapshot, now = new Date()) {
41
+ const fetched = Date.parse(snapshot.fetchedAt);
42
+ if (!Number.isFinite(fetched))
43
+ return false;
44
+ return now.getTime() - fetched < ttlHours() * 3600 * 1000;
45
+ }
46
+ function fetchWithTimeout(fetchImpl, init) {
47
+ const ac = new AbortController();
48
+ const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
49
+ return fetchImpl(CATALOG_URL, { ...init, signal: ac.signal }).finally(() => clearTimeout(timer));
50
+ }
51
+ function emptyCatalog(source, snapshot) {
52
+ return {
53
+ providers: snapshot?.providers ?? {},
54
+ source,
55
+ fetchedAt: snapshot?.fetchedAt,
56
+ etag: snapshot?.etag,
57
+ };
58
+ }
59
+ /**
60
+ * 强制拉取最新目录。
61
+ * - 200:解析 + 落盘,source='live'
62
+ * - 304:复用本地快照(仅刷新 fetchedAt/etag 不强制),source='cache'
63
+ * - 网络/解析失败:有快照回退快照(source='cache'),否则 source='empty'
64
+ */
65
+ export async function refreshCatalog(opts = {}) {
66
+ const fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
67
+ const snapshotPath = opts.snapshotPath ?? CATALOG_SNAPSHOT_PATH;
68
+ const cached = readSnapshot(snapshotPath);
69
+ let res;
70
+ try {
71
+ const headers = { Accept: 'application/json' };
72
+ if (cached?.etag)
73
+ headers['If-None-Match'] = cached.etag;
74
+ res = await fetchWithTimeout(fetchImpl, { headers });
75
+ }
76
+ catch {
77
+ return cached ? emptyCatalog('cache', cached) : emptyCatalog('empty');
78
+ }
79
+ if (res.status === 304 && cached) {
80
+ // 内容未变:更新 fetchedAt 表示「刚确认仍最新」,保留原 providers。
81
+ const renewed = { ...cached, fetchedAt: new Date().toISOString() };
82
+ writeSnapshot(renewed, snapshotPath);
83
+ return {
84
+ providers: renewed.providers,
85
+ source: 'cache',
86
+ fetchedAt: renewed.fetchedAt,
87
+ etag: renewed.etag,
88
+ };
89
+ }
90
+ if (res.status >= 200 && res.status < 300) {
91
+ let body;
92
+ try {
93
+ body = await res.json();
94
+ }
95
+ catch {
96
+ return cached ? emptyCatalog('cache', cached) : emptyCatalog('empty');
97
+ }
98
+ const providers = body.providers;
99
+ // models.dev 直接以 provider id 为顶层键(无外层 providers 包裹);兼容两种形态。
100
+ const record = providers && typeof providers === 'object' ? providers : body;
101
+ if (!record || typeof record !== 'object') {
102
+ return cached ? emptyCatalog('cache', cached) : emptyCatalog('empty');
103
+ }
104
+ const etag = res.headers.get('etag') ?? cached?.etag ?? undefined;
105
+ const snapshot = {
106
+ fetchedAt: new Date().toISOString(),
107
+ ...(etag ? { etag } : {}),
108
+ providers: record,
109
+ };
110
+ writeSnapshot(snapshot, snapshotPath);
111
+ return {
112
+ providers: record,
113
+ source: 'live',
114
+ fetchedAt: snapshot.fetchedAt,
115
+ ...(etag ? { etag } : {}),
116
+ };
117
+ }
118
+ return cached ? emptyCatalog('cache', cached) : emptyCatalog('empty');
119
+ }
120
+ /**
121
+ * 命令入口:快照新鲜直接用快照(不联网);过期或 force 才拉取。
122
+ */
123
+ export async function loadCatalog(opts = {}) {
124
+ const snapshotPath = opts.snapshotPath ?? CATALOG_SNAPSHOT_PATH;
125
+ const cached = readSnapshot(snapshotPath);
126
+ if (!opts.force && cached && isFresh(cached, opts.now)) {
127
+ return { providers: cached.providers, source: 'cache', fetchedAt: cached.fetchedAt, etag: cached.etag };
128
+ }
129
+ return refreshCatalog({ fetch: opts.fetch, snapshotPath });
130
+ }
@@ -0,0 +1,50 @@
1
+ import { classifyProvider, resolveBaseURL, toPresetProvider } from './protocol.js';
2
+ /** 上下文窗口缺失/为 0 时的保守默认(宁可小,避免超长发出去被拒)。 */
3
+ export const FALLBACK_CONTEXT_WINDOW = 128000;
4
+ /**
5
+ * 按 provider.env 声明的变量名顺序找一个已设置的 key;都没有返回 null。
6
+ * 读 process.env 是本模块唯一触碰环境的地方,调用方可传 env 覆盖以便测试。
7
+ */
8
+ export function resolveApiKey(provider, env = process.env) {
9
+ for (const keyName of provider.env ?? []) {
10
+ const v = env[keyName];
11
+ if (typeof v === 'string' && v.trim())
12
+ return v.trim();
13
+ }
14
+ return null;
15
+ }
16
+ /** 把目录来源拼成合法预设名:`<provider>-<model>` 去非法字符。 */
17
+ export function defaultPresetName(providerId, modelId) {
18
+ const raw = `${providerId}-${modelId}`;
19
+ const sanitized = raw
20
+ .replace(/[^a-zA-Z0-9_-]+/g, '-')
21
+ .replace(/^-+|-+$/g, '')
22
+ .slice(0, 32) || 'preset';
23
+ return sanitized;
24
+ }
25
+ /**
26
+ * 产出可直接 savePreset 的对象。provider 不被现有 fetch 支持时返回 null
27
+ * (命令层据此置灰/拒绝,避免存一个必错的预设)。
28
+ */
29
+ export function buildPreset(input) {
30
+ const { provider, model } = input;
31
+ const proto = classifyProvider(provider);
32
+ const presetProvider = toPresetProvider(proto);
33
+ if (!presetProvider)
34
+ return null;
35
+ const baseURL = resolveBaseURL(provider);
36
+ if (!baseURL)
37
+ return null;
38
+ const contextWindow = typeof model.limit?.context === 'number' && model.limit.context > 0
39
+ ? Math.floor(model.limit.context)
40
+ : FALLBACK_CONTEXT_WINDOW;
41
+ return {
42
+ name: input.name ?? defaultPresetName(provider.id, model.id),
43
+ provider: presetProvider,
44
+ baseURL,
45
+ apiKey: input.apiKey,
46
+ model: input.modelOverride ?? model.id,
47
+ contextWindow,
48
+ anthropicPromptCache: presetProvider === 'anthropic',
49
+ };
50
+ }
@@ -0,0 +1,227 @@
1
+ import { classifyProvider, resolveBaseURL } from './protocol.js';
2
+ function fmtK(n) {
3
+ if (!n || n <= 0)
4
+ return '-';
5
+ return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
6
+ }
7
+ /** 该预设是否与当前生效配置一致(用于标 ★)。 */
8
+ export function isPresetCurrent(p, cur) {
9
+ return (p.provider === cur.provider &&
10
+ p.baseURL === cur.baseURL &&
11
+ p.model === cur.model &&
12
+ p.contextWindow === cur.contextWindowTokens);
13
+ }
14
+ /** 组装统一面板的全部行(顺序:当前预设 → 其他预设 → 目录模型 → 操作行)。 */
15
+ export function buildPanelEntries(input) {
16
+ const rows = [];
17
+ // 已存预设:当前的排最前。
18
+ const withCurrent = input.presets.map((p) => ({
19
+ p,
20
+ cur: isPresetCurrent(p, input.current),
21
+ }));
22
+ withCurrent.sort((a, b) => Number(b.cur) - Number(a.cur) || a.p.name.localeCompare(b.p.name));
23
+ for (const { p, cur } of withCurrent) {
24
+ rows.push({
25
+ choice: { kind: 'preset', preset: p },
26
+ searchText: `${p.name} ${p.model} ${p.baseURL} ${p.provider}`,
27
+ label: `${cur ? '★ ' : ''}${p.name}`,
28
+ detail: `${p.model} · ${p.provider}`,
29
+ });
30
+ }
31
+ // 目录模型:跳过不支持的、以及与某预设重复的。
32
+ const presetKeys = new Set(input.presets.map((p) => `${p.baseURL}|${p.model}`));
33
+ for (const e of input.catalog) {
34
+ if (!e.supported)
35
+ continue;
36
+ const provider = input.providers[e.providerId];
37
+ if (!provider)
38
+ continue;
39
+ if (classifyProvider(provider) === 'unsupported')
40
+ continue;
41
+ if (presetKeys.has(`${provider.api ?? ''}|${e.modelId}`))
42
+ continue;
43
+ const c = e.model.cost;
44
+ const caps = [e.model.reasoning ? '思考' : '', e.model.tool_call ? '工具' : '', e.model.attachment ? '多模态' : '']
45
+ .filter(Boolean)
46
+ .join('·');
47
+ rows.push({
48
+ choice: { kind: 'catalog', entry: e },
49
+ searchText: `${e.model.name ?? ''} ${e.modelId}`,
50
+ label: e.model.name || e.modelId,
51
+ detail: [
52
+ e.providerName,
53
+ `${fmtK(e.model.limit?.context)} ctx`,
54
+ c ? `$${c.input ?? 0}/$${c.output ?? 0}` : '',
55
+ caps,
56
+ ]
57
+ .filter(Boolean)
58
+ .join(' · '),
59
+ });
60
+ }
61
+ // 操作行。
62
+ rows.push({
63
+ choice: { kind: 'action', action: 'custom' },
64
+ searchText: '自定义 手动填写 custom baseURL model key',
65
+ label: '+ 自定义模型(手动填写)',
66
+ detail: '',
67
+ });
68
+ rows.push({
69
+ choice: { kind: 'action', action: 'refresh' },
70
+ searchText: '刷新模型目录 refresh catalog 更新',
71
+ label: '⟳ 刷新模型目录',
72
+ detail: '',
73
+ });
74
+ return rows;
75
+ }
76
+ /** 归一化端点为归并键:小写 host + 去尾斜杠的 path,使预设 baseURL 与目录 api 对齐。 */
77
+ function endpointKey(raw) {
78
+ const v = raw.trim();
79
+ try {
80
+ const u = new URL(v);
81
+ return `${u.host.toLowerCase()}${u.pathname.replace(/\/+$/, '')}`;
82
+ }
83
+ catch {
84
+ return v.replace(/\/+$/, '').toLowerCase();
85
+ }
86
+ }
87
+ /** 从 URL 取 host 作合成组名;失败回退整串。 */
88
+ function hostOf(raw) {
89
+ try {
90
+ return new URL(raw.trim()).host;
91
+ }
92
+ catch {
93
+ return raw.trim();
94
+ }
95
+ }
96
+ function presetLeaf(p, cur) {
97
+ return {
98
+ choice: { kind: 'preset', preset: p },
99
+ searchText: `${p.model}`,
100
+ label: `${cur ? '★ ' : ''}${p.name}`,
101
+ detail: `${p.model} · ${p.provider}`,
102
+ };
103
+ }
104
+ function catalogLeaf(e) {
105
+ const c = e.model.cost;
106
+ const caps = [e.model.reasoning ? '思考' : '', e.model.tool_call ? '工具' : '', e.model.attachment ? '多模态' : '']
107
+ .filter(Boolean)
108
+ .join('·');
109
+ return {
110
+ choice: { kind: 'catalog', entry: e },
111
+ searchText: `${e.model.name ?? ''} ${e.modelId}`,
112
+ label: e.model.name || e.modelId,
113
+ detail: [e.providerName, `${fmtK(e.model.limit?.context)} ctx`, c ? `$${c.input ?? 0}/$${c.output ?? 0}` : '', caps]
114
+ .filter(Boolean)
115
+ .join(' · '),
116
+ };
117
+ }
118
+ function actionLeaf(action) {
119
+ if (action === 'custom') {
120
+ return {
121
+ choice: { kind: 'action', action },
122
+ searchText: '自定义 手动填写 custom baseURL model key',
123
+ label: '+ 自定义模型(手动填写)',
124
+ detail: '',
125
+ };
126
+ }
127
+ return {
128
+ choice: { kind: 'action', action },
129
+ searchText: '刷新模型目录 refresh catalog 更新',
130
+ label: '⟳ 刷新模型目录',
131
+ detail: '',
132
+ };
133
+ }
134
+ /**
135
+ * 组装两级模型树:root = 厂商组 + 操作行;每个厂商组内 = 当前预设★ → 其他预设 → 目录模型。
136
+ *
137
+ * 归并规则:预设按 baseURL 归并到「端点相同」的目录厂商;目录里没有的端点按 URL host
138
+ * 合成一个组。目录模型与预设同 model 名时不重复列(预设优先)。无 I/O。
139
+ */
140
+ export function buildModelTree(input) {
141
+ const groups = [];
142
+ const byKey = new Map();
143
+ // 1) 目录厂商:仅可直发且有模型的。
144
+ for (const [providerId, provider] of Object.entries(input.providers)) {
145
+ if (classifyProvider(provider) === 'unsupported')
146
+ continue;
147
+ const endpoint = resolveBaseURL(provider);
148
+ if (!endpoint)
149
+ continue;
150
+ if (Object.keys(provider.models ?? {}).length === 0)
151
+ continue;
152
+ const label = provider.name || providerId;
153
+ const g = {
154
+ label,
155
+ key: endpointKey(endpoint),
156
+ providerId,
157
+ presetLeaves: [],
158
+ catalogLeaves: [],
159
+ seenModels: new Set(),
160
+ searchText: `${label} ${providerId}`,
161
+ };
162
+ groups.push(g);
163
+ if (!byKey.has(g.key))
164
+ byKey.set(g.key, g);
165
+ for (const e of input.catalog) {
166
+ if (!e.supported || e.providerId !== providerId)
167
+ continue;
168
+ if (g.seenModels.has(e.modelId))
169
+ continue;
170
+ g.seenModels.add(e.modelId);
171
+ g.catalogLeaves.push(catalogLeaf(e));
172
+ }
173
+ }
174
+ // 2) 预设归并:端点命中目录厂商 → 进该组;否则进/建合成组。
175
+ for (const p of input.presets) {
176
+ const cur = isPresetCurrent(p, input.current);
177
+ const key = endpointKey(p.baseURL);
178
+ let g = byKey.get(key);
179
+ if (!g) {
180
+ g = {
181
+ label: hostOf(p.baseURL) || p.baseURL,
182
+ key,
183
+ providerId: null,
184
+ presetLeaves: [],
185
+ catalogLeaves: [],
186
+ seenModels: new Set(),
187
+ searchText: `${hostOf(p.baseURL)}`,
188
+ };
189
+ groups.push(g);
190
+ byKey.set(key, g);
191
+ }
192
+ g.presetLeaves.push({ node: presetLeaf(p, cur), model: p.model, cur });
193
+ }
194
+ // 3) 组内:当前预设 → 其他预设 → 目录模型(跳过与预设同名的);空组不列。
195
+ const roots = [];
196
+ for (const g of groups) {
197
+ const presetModelNames = new Set(g.presetLeaves.map((x) => x.model));
198
+ g.presetLeaves.sort((a, b) => Number(b.cur) - Number(a.cur));
199
+ const children = [
200
+ ...g.presetLeaves.map((x) => x.node),
201
+ ...g.catalogLeaves.filter((leaf) => {
202
+ const e = leaf.choice;
203
+ return !(e?.kind === 'catalog' && presetModelNames.has(e.entry.modelId));
204
+ }),
205
+ ];
206
+ if (children.length === 0)
207
+ continue;
208
+ roots.push({
209
+ searchText: g.searchText,
210
+ label: g.label,
211
+ detail: `${children.length} 个模型`,
212
+ children,
213
+ });
214
+ }
215
+ // 目录厂商在前,合成组在后;各自按名排序。
216
+ roots.sort((a, b) => {
217
+ const ga = groups.find((g) => g.label === a.label && g.searchText === a.searchText);
218
+ const gb = groups.find((g) => g.label === b.label && g.searchText === b.searchText);
219
+ const sa = ga?.providerId ? 0 : 1;
220
+ const sb = gb?.providerId ? 0 : 1;
221
+ return sa - sb || a.label.localeCompare(b.label);
222
+ });
223
+ // 4) 操作行固定在 root 末尾。
224
+ roots.push(actionLeaf('custom'));
225
+ roots.push(actionLeaf('refresh'));
226
+ return roots;
227
+ }