mocode-ai 1.6.4 → 1.6.5

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 (64) hide show
  1. package/README.md +376 -365
  2. package/README.zh-CN.md +13 -2
  3. package/dist/agent/model-turn.js +14 -1
  4. package/dist/agent/run-coordinator.js +5 -0
  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/work-discipline.js +3 -1
  9. package/dist/config/index.js +52 -2
  10. package/dist/config/presets.js +40 -1
  11. package/dist/config/profiles.js +9 -1
  12. package/dist/context/clearing.js +53 -0
  13. package/dist/context/relevance.js +6 -0
  14. package/dist/context/text-search.js +113 -0
  15. package/dist/llm/index.js +35 -8
  16. package/dist/llm/providers/anthropic.js +14 -4
  17. package/dist/llm/reasoning.js +141 -0
  18. package/dist/memory/reflect.js +1 -1
  19. package/dist/memory/store.js +31 -28
  20. package/dist/models/budgets.js +11 -0
  21. package/dist/models/catalog.js +130 -0
  22. package/dist/models/map-preset.js +50 -0
  23. package/dist/models/model-panel.js +227 -0
  24. package/dist/models/protocol.js +44 -0
  25. package/dist/models/reasoning-cap.js +88 -0
  26. package/dist/models/search.js +58 -0
  27. package/dist/models/types.js +8 -0
  28. package/dist/repl/commands/effort.js +81 -0
  29. package/dist/repl/commands/model-actions.js +138 -0
  30. package/dist/repl/commands/model-catalog.js +67 -0
  31. package/dist/repl/commands/model-panel-ui.js +107 -0
  32. package/dist/repl/commands/model.js +17 -0
  33. package/dist/repl/commands/registry.js +6 -0
  34. package/dist/repl/commands/session.js +54 -0
  35. package/dist/repl/commands/stats.js +28 -0
  36. package/dist/repl/commands.js +3 -0
  37. package/dist/repl/runtime.js +5 -1
  38. package/dist/repl/status-bar.js +4 -2
  39. package/dist/rollback/store.js +17 -0
  40. package/dist/runtime/runtime.js +31 -1
  41. package/dist/session/compact.js +84 -3
  42. package/dist/session/index.js +2 -0
  43. package/dist/session/retention.js +297 -0
  44. package/dist/session/scheduler.js +30 -3
  45. package/dist/session/store.js +108 -6
  46. package/dist/session/usage-stats.js +90 -0
  47. package/dist/skills/activation.js +2 -0
  48. package/dist/skills/builtin-skills.js +153 -0
  49. package/dist/skills/discover.js +3 -1
  50. package/dist/skills/runner.js +4 -1
  51. package/dist/tools/builtins/index.js +4 -0
  52. package/dist/tools/builtins/read-file.js +19 -1
  53. package/dist/tools/builtins/session-search.js +33 -0
  54. package/dist/tools/builtins/task.js +2 -0
  55. package/dist/tools/constants.js +8 -0
  56. package/dist/tools/read-dedup.js +62 -0
  57. package/dist/tools/tool-runtime.js +1 -0
  58. package/dist/ui/batch.js +109 -103
  59. package/dist/ui/fuzzy-picker.js +227 -0
  60. package/dist/ui/hierarchical-picker.js +302 -0
  61. package/dist/ui/intervention.js +11 -1
  62. package/dist/ui/layout-internal/statusbar.js +18 -7
  63. package/dist/ui/render.js +2 -0
  64. package/package.json +1 -1
@@ -0,0 +1,113 @@
1
+ // 共享全文检索:CJK 感知分词 + BM25。零依赖纯函数,供 memory 与 archive 搜索复用。
2
+ //
3
+ // 分词:ASCII/数字按词;CJK 字符同时生成 unigram 与相邻 bigram——中文词多为两字,
4
+ // 单字召回噪声大、纯 bigram 又漏掉单字成词的情况,unigram+bigram 是 Elasticsearch
5
+ // cjk analyzer 的成熟折中。
6
+ //
7
+ // 多字段加权 BM25:每个字段独立长度归一,字段分按 weight 加权求和。
8
+ const CJK_RE = /[㐀-䶿一-鿿豈-﫿぀-ヿ가-힯]/;
9
+ /** CJK 感知分词(小写)。 */
10
+ export function tokenize(input) {
11
+ const text = (input ?? '').toLowerCase();
12
+ const tokens = [];
13
+ const chars = Array.from(text);
14
+ let word = '';
15
+ const flushWord = () => {
16
+ if (word) {
17
+ tokens.push(word);
18
+ word = '';
19
+ }
20
+ };
21
+ for (let i = 0; i < chars.length; i++) {
22
+ const ch = chars[i];
23
+ if (/[a-z0-9]/.test(ch)) {
24
+ word += ch;
25
+ continue;
26
+ }
27
+ flushWord();
28
+ if (CJK_RE.test(ch)) {
29
+ tokens.push(ch); // unigram
30
+ if (i + 1 < chars.length && CJK_RE.test(chars[i + 1])) {
31
+ tokens.push(ch + chars[i + 1]); // bigram
32
+ }
33
+ }
34
+ }
35
+ flushWord();
36
+ return tokens;
37
+ }
38
+ /** BM25 多字段索引。文档量小(memory ≤百条、archive 索引),构建成本可忽略。 */
39
+ export class TextSearchIndex {
40
+ k1;
41
+ b;
42
+ ids;
43
+ fieldStats;
44
+ fieldWeights;
45
+ constructor(docs, k1 = 1.5, b = 0.75) {
46
+ this.k1 = k1;
47
+ this.b = b;
48
+ this.ids = docs.map((d) => d.id);
49
+ const fieldCount = Math.max(1, ...docs.map((d) => d.fields.length));
50
+ this.fieldWeights = Array.from({ length: fieldCount }, (_, f) => docs[0]?.fields[f]?.weight ?? 1);
51
+ this.fieldStats = Array.from({ length: fieldCount }, () => ({
52
+ df: new Map(),
53
+ tf: new Map(),
54
+ dl: new Map(),
55
+ avgdl: 0,
56
+ }));
57
+ docs.forEach((doc, docId) => {
58
+ doc.fields.forEach((field, f) => {
59
+ const stats = this.fieldStats[f];
60
+ const toks = tokenize(field.text);
61
+ stats.dl.set(docId, toks.length);
62
+ const seen = new Set();
63
+ for (const t of toks) {
64
+ let perDoc = stats.tf.get(t);
65
+ if (!perDoc) {
66
+ perDoc = new Map();
67
+ stats.tf.set(t, perDoc);
68
+ }
69
+ perDoc.set(docId, (perDoc.get(docId) ?? 0) + 1);
70
+ seen.add(t);
71
+ }
72
+ for (const t of seen)
73
+ stats.df.set(t, (stats.df.get(t) ?? 0) + 1);
74
+ });
75
+ });
76
+ const n = docs.length || 1;
77
+ for (const stats of this.fieldStats) {
78
+ let total = 0;
79
+ for (const len of stats.dl.values())
80
+ total += len;
81
+ stats.avgdl = total / n;
82
+ }
83
+ }
84
+ /** 检索;无有效查询词时返回空数组(调用方自行决定是否回退全量)。 */
85
+ search(query, limit = 10) {
86
+ const terms = tokenize(query);
87
+ if (terms.length === 0 || this.ids.length === 0)
88
+ return [];
89
+ const n = this.ids.length;
90
+ const hits = [];
91
+ for (let docId = 0; docId < n; docId++) {
92
+ let score = 0;
93
+ this.fieldStats.forEach((stats, f) => {
94
+ const dl = stats.dl.get(docId);
95
+ if (dl === undefined)
96
+ return;
97
+ const norm = this.k1 * (1 - this.b + (this.b * dl) / Math.max(stats.avgdl, 1));
98
+ for (const term of terms) {
99
+ const tf = stats.tf.get(term)?.get(docId);
100
+ if (!tf)
101
+ continue;
102
+ const df = stats.df.get(term) ?? 0;
103
+ const idf = Math.log(1 + (n - df + 0.5) / (df + 0.5));
104
+ score += ((this.fieldWeights[f] ?? 1) * (idf * (tf * (this.k1 + 1)))) / (tf + norm);
105
+ }
106
+ });
107
+ if (score > 0)
108
+ hits.push({ id: this.ids[docId], score });
109
+ }
110
+ hits.sort((a, b) => b.score - a.score);
111
+ return hits.slice(0, Math.max(1, limit));
112
+ }
113
+ }
package/dist/llm/index.js CHANGED
@@ -8,6 +8,9 @@ import { sanitizeToolSchemas } from './tool-schema.js';
8
8
  import { isMarkedStreamInterrupted } from './stream-interrupt.js';
9
9
  import { defaultAnthropicFetch, anthropicChatOnce } from './providers/anthropic.js';
10
10
  import { registerModelProvider, getModelProvider, listModelProviders } from './provider.js';
11
+ import { effectiveReasoningEffort } from '../config/index.js';
12
+ import { resolveReasoningParams } from './reasoning.js';
13
+ import { getActivePresetName, readPreset } from '../config/presets.js';
11
14
  // 强制关闭第三方调试日志泄漏:openai SDK 在 process.env.DEBUG === 'true' 时用裸
12
15
  // console.log 把请求/响应直写 stdout,会污染 TUI 输入框(并泄露 headers/URL)。
13
16
  // 仅拦截 'true' 这一开关值——保留 namespace 形式的 DEBUG(如 DEBUG=express:*) 调试能力。
@@ -394,11 +397,11 @@ export function refreshChatTools() {
394
397
  // 新增 provider 只需 registerModelProvider,不必改 chat()。
395
398
  registerModelProvider({
396
399
  name: 'openai',
397
- chatOnce: (messages, handlers, signal, tools, runtime) => chatOnce(messages, handlers, signal, tools, runtime),
400
+ chatOnce: (messages, handlers, signal, tools, runtime, overrides) => chatOnce(messages, handlers, signal, tools, runtime, overrides),
398
401
  });
399
402
  registerModelProvider({
400
403
  name: 'anthropic',
401
- chatOnce: (messages, handlers, signal, tools, runtime) => anthropicChatOnce(messages, handlers, signal, tools ?? chatTools, runtime),
404
+ chatOnce: (messages, handlers, signal, tools, runtime, overrides) => anthropicChatOnce(messages, handlers, signal, tools ?? chatTools, runtime, overrides),
402
405
  });
403
406
  /**
404
407
  * 多 provider 兼容的 cache / reasoning 字段提取。
@@ -474,14 +477,14 @@ function retryErrorCode(error) {
474
477
  }
475
478
  /** Bind chat dispatch, retries and built-in providers to one explicit runtime. */
476
479
  export function createChatTransport(runtime) {
477
- return (messages, handlers = {}, signal, toolsOverride) => chatWithRuntime(runtime, messages, handlers, signal, toolsOverride);
480
+ return (messages, handlers = {}, signal, toolsOverride, overrides) => chatWithRuntime(runtime, messages, handlers, signal, toolsOverride, overrides);
478
481
  }
479
482
  export function chat(messages, handlers = {}, signal,
480
483
  /** 覆盖默认工具 schema;plan 模式传 planChatTools(只读子集),缺省=全量 chatTools。 */
481
- toolsOverride) {
482
- return chatWithRuntime(defaultProviderRuntime, messages, handlers, signal, toolsOverride);
484
+ toolsOverride, overrides) {
485
+ return chatWithRuntime(defaultProviderRuntime, messages, handlers, signal, toolsOverride, overrides);
483
486
  }
484
- async function chatWithRuntime(runtime, messages, handlers, signal, toolsOverride) {
487
+ async function chatWithRuntime(runtime, messages, handlers, signal, toolsOverride, overrides) {
485
488
  let lastErr;
486
489
  // 本次尝试是否已向调用方产出过内容(可见文本 / 工具名)。用于判断「流中途故障可否安全重试」:
487
490
  // 重试会从请求头重放,已流出并显示在内容区的半截文本会再写一遍(history 因异常未落,倒是不脏)。
@@ -511,7 +514,7 @@ async function chatWithRuntime(runtime, messages, handlers, signal, toolsOverrid
511
514
  // 未注册的 provider:给出可用名单,避免悄悄落到错误实现。
512
515
  throw new Error(`未知的 LLM provider "${runtime.config.provider}";已注册:${listModelProviders().join(', ') || '(空)'}`);
513
516
  }
514
- return await provider.chatOnce(messages, guardedHandlers, signal, toolsOverride, runtime);
517
+ return await provider.chatOnce(messages, guardedHandlers, signal, toolsOverride, runtime, overrides);
515
518
  }
516
519
  catch (err) {
517
520
  lastErr = err;
@@ -584,7 +587,7 @@ export function normalizeImageDetail(messages) {
584
587
  return changed ? next : messages;
585
588
  }
586
589
  /** 单次流式 LLM 请求(无重试);chat() 的内部实现,可被 __setChatCreateImpl 注入桩以做单测。 */
587
- async function chatOnce(messages, handlers, signal, toolsOverride, runtime = defaultProviderRuntime) {
590
+ async function chatOnce(messages, handlers, signal, toolsOverride, runtime = defaultProviderRuntime, overrides) {
588
591
  // 防御:messages 必须至少含一条非空 user 消息,否则 OpenAI/Anthropic 都会 400。
589
592
  // compact force 分支曾把所有 user 丢进摘要 → 重建 history 无 user → 下一轮 400。
590
593
  // 在 transport 边界拦住所有类似回归(compact / 外部注入 / resume 损坏)。
@@ -611,6 +614,29 @@ async function chatOnce(messages, handlers, signal, toolsOverride, runtime = def
611
614
  // transport 边界消毒:剔除部分后端(kimi-k3@dashscope 实测)整请求 400 拒绝的
612
615
  // uniqueItems 关键字。无需改写时返回原引用,前缀缓存逐字节稳定不受影响。
613
616
  const activeTools = sanitizeToolSchemas(toolsOverride ?? chatTools);
617
+ const effort = effectiveReasoningEffort(overrides?.reasoningEffort);
618
+ // 实时读激活预设(#model-catalog M3):capabilities 只存在预设文件里,启动快照不反映切换。
619
+ const activePreset = (() => {
620
+ const n = getActivePresetName();
621
+ return n ? readPreset(n) : null;
622
+ })();
623
+ const reasoningParams = effort === 'auto'
624
+ ? {}
625
+ : resolveReasoningParams(effort, {
626
+ provider: runtimeConfig.provider === 'anthropic' ? 'anthropic' : 'openai',
627
+ model: runtime.getModel(),
628
+ maxTokens: runtimeConfig.maxTokens,
629
+ // 仅当激活预设来自目录且对应当前模型时携带,否则走正则兜底。
630
+ ...(activePreset && activePreset.catalogProvider && activePreset.model === runtime.getModel()
631
+ ? {
632
+ catalogProviderId: activePreset.catalogProvider,
633
+ catalogReasoning: activePreset.capabilities ? activePreset.capabilities.reasoning : undefined,
634
+ catalogReasoningOptions: activePreset.capabilities
635
+ ? activePreset.capabilities.reasoningOptions
636
+ : undefined,
637
+ }
638
+ : {}),
639
+ });
614
640
  const stream = await create({
615
641
  model: runtime.getModel(),
616
642
  messages: normalizeImageDetail(messages),
@@ -626,6 +652,7 @@ async function chatOnce(messages, handlers, signal, toolsOverride, runtime = def
626
652
  : {}),
627
653
  ...(runtimeConfig.maxTokens ? { max_tokens: runtimeConfig.maxTokens } : {}),
628
654
  ...(runtimeConfig.includeUsage ? { stream_options: { include_usage: true } } : {}),
655
+ ...reasoningParams,
629
656
  }, signal ? { signal } : undefined);
630
657
  // content 内嵌 think 标签由独立增量状态机过滤。它只暂存“可能组成标签”的后缀,
631
658
  // 因而既能覆盖标签任意位置跨 chunk,也不会让普通正文固定延迟数个字符。
@@ -1,4 +1,5 @@
1
- import { config, getActiveModel } from '../../config/index.js';
1
+ import { config, effectiveReasoningEffort, getActiveModel } from '../../config/index.js';
2
+ import { resolveReasoningParams } from '../reasoning.js';
2
3
  import { markStreamInterrupted } from '../stream-interrupt.js';
3
4
  let fetchImplOverride = null;
4
5
  /** 仅供单测注入;生产路径使用 Node 18+ 全局 fetch。 */
@@ -179,10 +180,18 @@ function endpoint(baseURL) {
179
180
  return base;
180
181
  return `${base}/v1/messages`;
181
182
  }
182
- export function buildAnthropicRequest(messages, tools, runtime) {
183
+ export function buildAnthropicRequest(messages, tools, runtime, overrides) {
183
184
  const runtimeConfig = runtime?.config ?? config;
184
185
  const encoded = encodeAnthropicMessages(messages, runtimeConfig.anthropicPromptCache);
185
186
  const anthropicTools = encodeAnthropicTools(tools, runtimeConfig.anthropicPromptCache);
187
+ const effort = effectiveReasoningEffort(overrides?.reasoningEffort);
188
+ const reasoningParams = effort === 'auto'
189
+ ? {}
190
+ : resolveReasoningParams(effort, {
191
+ provider: 'anthropic',
192
+ model: runtime?.getModel() ?? getActiveModel(),
193
+ maxTokens: runtimeConfig.maxTokens,
194
+ });
186
195
  return {
187
196
  model: runtime?.getModel() ?? getActiveModel(),
188
197
  max_tokens: runtimeConfig.maxTokens ?? 8192,
@@ -190,6 +199,7 @@ export function buildAnthropicRequest(messages, tools, runtime) {
190
199
  system: encoded.system,
191
200
  messages: encoded.messages,
192
201
  ...(anthropicTools.length > 0 ? { tools: anthropicTools } : {}),
202
+ ...reasoningParams,
193
203
  };
194
204
  }
195
205
  class AnthropicHttpError extends Error {
@@ -287,7 +297,7 @@ function addLiveCount(text, state) {
287
297
  }
288
298
  }
289
299
  /** Anthropic Messages API 单次请求;外层 chat() 继续统一负责重试。 */
290
- export async function anthropicChatOnce(messages, handlers, signal, tools, runtime) {
300
+ export async function anthropicChatOnce(messages, handlers, signal, tools, runtime, overrides) {
291
301
  const runtimeConfig = runtime?.config ?? config;
292
302
  const fetchImpl = runtime?.clientState.anthropicFetchImpl ?? defaultAnthropicFetch;
293
303
  const response = await fetchImpl(endpoint(runtimeConfig.baseURL), {
@@ -298,7 +308,7 @@ export async function anthropicChatOnce(messages, handlers, signal, tools, runti
298
308
  'x-api-key': runtimeConfig.apiKey,
299
309
  'anthropic-version': process.env.ANTHROPIC_VERSION || '2023-06-01',
300
310
  },
301
- body: JSON.stringify(buildAnthropicRequest(messages, tools, runtime)),
311
+ body: JSON.stringify(buildAnthropicRequest(messages, tools, runtime, overrides)),
302
312
  signal,
303
313
  });
304
314
  if (!response.ok)
@@ -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
+ }