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,138 @@
1
+ /**
2
+ * 模型命令的共享动作(#模型命令交互):应用预设、添加目录模型、刷新目录。
3
+ *
4
+ * 从 model-catalog.ts 抽出,供「统一模型面板」与旧 /model browse 复用,避免第三处重复。
5
+ * 纯编排:UI 走 promptIntervention,落盘走 presets/file,运行时切 config。
6
+ */
7
+ import * as layout from '../../ui/layout.js';
8
+ import { ui } from '../../ui/theme.js';
9
+ import { bannerLines } from '../../ui/render.js';
10
+ import { config, updateModelConfig } from '../../config/index.js';
11
+ import { writeConfigKeys } from '../../config/file.js';
12
+ import { savePreset, setActivePresetName } from '../../config/presets.js';
13
+ import { reconfigureClient } from '../../llm/index.js';
14
+ import { promptIntervention } from '../../ui/intervention.js';
15
+ import { renderHistory } from '../message-format.js';
16
+ import { refreshCatalog } from '../../models/catalog.js';
17
+ import { buildPreset, defaultPresetName, resolveApiKey } from '../../models/map-preset.js';
18
+ /** 应用预设到 config + 落 config 文件 + 重建 client + 重显。 */
19
+ export function applyPresetConfig(ctx, p) {
20
+ updateModelConfig({
21
+ provider: p.provider,
22
+ model: p.model,
23
+ baseURL: p.baseURL,
24
+ apiKey: p.apiKey,
25
+ contextWindowTokens: p.contextWindow,
26
+ anthropicPromptCache: p.anthropicPromptCache,
27
+ ...(p.reasoningEffort ? { reasoningEffort: p.reasoningEffort } : {}),
28
+ });
29
+ writeConfigKeys({
30
+ LLM_PROVIDER: p.provider,
31
+ LLM_BASE_URL: p.baseURL,
32
+ LLM_API_KEY: p.apiKey,
33
+ LLM_MODEL: p.model,
34
+ CONTEXT_WINDOW_TOKENS: String(p.contextWindow),
35
+ ANTHROPIC_PROMPT_CACHE: p.anthropicPromptCache ? 'true' : 'false',
36
+ });
37
+ reconfigureClient();
38
+ ctx.refreshStatusBase(ctx.history);
39
+ layout.clearContent();
40
+ if (ctx.history.some((m) => m.role === 'user'))
41
+ renderHistory(ctx.history);
42
+ else
43
+ layout.writeBanner(bannerLines(ctx.banner()));
44
+ }
45
+ /**
46
+ * 把一个目录模型添加为预设并激活。
47
+ * @returns 'added' 已切换;'cancelled' 用户中途取消。
48
+ */
49
+ export async function addCatalogModel(ctx, entry, providers) {
50
+ const catalogProvider = providers[entry.providerId];
51
+ if (!catalogProvider)
52
+ return 'cancelled';
53
+ // key:无论环境变量是否找到,都弹一次确认——回车沿用预填值、粘贴即覆盖。
54
+ const envName = catalogProvider.env?.[0] ?? 'API_KEY';
55
+ const detectedKey = resolveApiKey(catalogProvider);
56
+ const kres = await promptIntervention({
57
+ type: 'input',
58
+ title: `${envName}`,
59
+ detail: detectedKey
60
+ ? `已从环境变量 ${envName} 预填 key;回车沿用,或粘贴新 key 覆盖(仅存入本预设与配置文件)。`
61
+ : `未在环境变量 ${envName} 找到 key;粘贴该厂商 key(仅存入本预设与配置文件)。`,
62
+ seed: detectedKey ?? '',
63
+ });
64
+ if (kres.action === 'cancelled')
65
+ return 'cancelled';
66
+ const apiKey = (kres.value ?? '').trim();
67
+ if (!apiKey) {
68
+ layout.contentWrite(`${ui.yellow}未提供 key,已取消。${ui.reset}\n`);
69
+ return 'cancelled';
70
+ }
71
+ const built = buildPreset({ provider: catalogProvider, model: entry.model, apiKey });
72
+ if (!built) {
73
+ layout.contentWrite(`${ui.yellow}该模型当前协议不支持直发。${ui.reset}\n`);
74
+ return 'cancelled';
75
+ }
76
+ const nres = await promptIntervention({
77
+ type: 'input',
78
+ title: '预设名',
79
+ detail: '回车用默认名;可改名。',
80
+ seed: defaultPresetName(entry.providerId, entry.modelId),
81
+ });
82
+ if (nres.action === 'cancelled')
83
+ return 'cancelled';
84
+ built.name = (nres.value ?? '').trim() || built.name;
85
+ const mres = await promptIntervention({
86
+ type: 'input',
87
+ title: '下发模型名',
88
+ detail: `端点 ${built.baseURL}\n一般保持默认;厂商要求专用 ID(如火山 endpoint id)时在此修改。`,
89
+ seed: built.model,
90
+ });
91
+ if (mres.action === 'cancelled')
92
+ return 'cancelled';
93
+ built.model = (mres.value ?? '').trim() || built.model;
94
+ const preset = {
95
+ ...built,
96
+ catalogProvider: entry.providerId,
97
+ catalogModel: entry.modelId,
98
+ capabilities: {
99
+ ...(typeof entry.model.reasoning === 'boolean' ? { reasoning: entry.model.reasoning } : {}),
100
+ ...(entry.model.reasoning_options ? { reasoningOptions: entry.model.reasoning_options } : {}),
101
+ ...(typeof entry.model.tool_call === 'boolean' ? { toolCall: entry.model.tool_call } : {}),
102
+ ...(typeof entry.model.attachment === 'boolean' ? { attachment: entry.model.attachment } : {}),
103
+ },
104
+ ...(entry.model.cost
105
+ ? {
106
+ pricing: {
107
+ ...(typeof entry.model.cost.input === 'number' ? { input: entry.model.cost.input } : {}),
108
+ ...(typeof entry.model.cost.output === 'number' ? { output: entry.model.cost.output } : {}),
109
+ ...(typeof entry.model.cost.cache_read === 'number' ? { cacheRead: entry.model.cost.cache_read } : {}),
110
+ },
111
+ }
112
+ : {}),
113
+ };
114
+ savePreset(preset);
115
+ try {
116
+ setActivePresetName(preset.name);
117
+ }
118
+ catch {
119
+ /* 指针失败不阻断 */
120
+ }
121
+ applyPresetConfig(ctx, preset);
122
+ layout.contentWrite(`${ui.dim}(已添加并切换 → ${preset.model} · ${preset.provider} @ ${preset.baseURL})${ui.reset}\n`);
123
+ layout.contentWrite(`${ui.dim}(已存为预设 “${preset.name}”)${ui.reset}\n`);
124
+ return 'added';
125
+ }
126
+ /** 强制刷新目录并打印统计。 */
127
+ export async function refreshAndReport() {
128
+ const r = await refreshCatalog();
129
+ const n = Object.values(r.providers).reduce((acc, p) => acc + Object.keys(p.models ?? {}).length, 0);
130
+ if (r.source === 'empty') {
131
+ layout.contentWrite(`${ui.yellow}刷新失败且无本地快照。${ui.reset}\n`);
132
+ }
133
+ else {
134
+ const tag = r.source === 'live' ? '已更新' : '离线,使用快照';
135
+ layout.contentWrite(`${ui.dim}模型目录${tag}:${Object.keys(r.providers).length} 厂商 / ${n} 模型(${r.fetchedAt?.slice(0, 10)})${ui.reset}\n`);
136
+ }
137
+ }
138
+ export { config };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * 模型目录命令(#model-catalog,兼容入口):/model browse · /model catalog [refresh]
3
+ *
4
+ * 交互逻辑已抽到 model-actions.ts,统一模型面板(裸 /model)与这里共用同一套动作。
5
+ * 本文件只保留命令路由与目录无参查看。
6
+ */
7
+ import * as layout from '../../ui/layout.js';
8
+ import { ui } from '../../ui/theme.js';
9
+ import { promptIntervention } from '../../ui/intervention.js';
10
+ import { loadCatalog } from '../../models/catalog.js';
11
+ import { searchModels } from '../../models/search.js';
12
+ import { addCatalogModel, refreshAndReport } from './model-actions.js';
13
+ import { unhandled, next } from './types.js';
14
+ /** 一次 browse 列表上限(统一面板可滚动更多;此兼容入口保持收窄)。 */
15
+ const MAX_BROWSE = 30;
16
+ export const modelCatalogCommands = [
17
+ async (ctx) => {
18
+ const { line } = ctx;
19
+ if (line === '/model browse' || line.startsWith('/model browse ')) {
20
+ const cat = await loadCatalog();
21
+ if (cat.source === 'empty' || Object.keys(cat.providers).length === 0) {
22
+ layout.contentWrite(`${ui.yellow}暂无模型目录且离线:联网后 /model catalog refresh,或用 /model 配置。${ui.reset}\n`);
23
+ return next();
24
+ }
25
+ // 兼容 browse:先输入关键字,再从收窄结果里选(添加动作与统一面板一致)。
26
+ const qres = await promptIntervention({
27
+ type: 'input',
28
+ title: '搜索模型',
29
+ detail: '输入厂商/模型关键字(glm、qwen、claude、doubao…),回车列出。',
30
+ seed: '',
31
+ });
32
+ if (qres.action === 'cancelled')
33
+ return next();
34
+ const query = (qres.value ?? '').trim();
35
+ const results = searchModels(cat.providers, { query }).slice(0, MAX_BROWSE);
36
+ if (results.length === 0) {
37
+ layout.contentWrite(`${ui.yellow}没有匹配 “${query}” 的可直发模型。${ui.reset}\n`);
38
+ return next();
39
+ }
40
+ const sel = await promptIntervention({
41
+ type: 'choice',
42
+ title: `选择模型(${results.length})`,
43
+ options: results.map((e) => e.model.name || e.modelId),
44
+ allowCustom: false,
45
+ });
46
+ if (sel.action === 'cancelled')
47
+ return next();
48
+ const picked = results.find((e) => (e.model.name || e.modelId) === sel.value);
49
+ if (picked)
50
+ await addCatalogModel(ctx, picked, cat.providers);
51
+ return next();
52
+ }
53
+ if (line === '/model catalog' || line === '/model catalog refresh') {
54
+ if (line === '/model catalog refresh')
55
+ await refreshAndReport();
56
+ else {
57
+ const cat = await loadCatalog();
58
+ const n = Object.values(cat.providers).reduce((acc, p) => acc + Object.keys(p.models ?? {}).length, 0);
59
+ layout.contentWrite(cat.source === 'empty'
60
+ ? `${ui.yellow}本地无目录:/model catalog refresh 拉取。${ui.reset}\n`
61
+ : `${ui.dim}目录(${cat.source === 'live' ? '在线' : '快照'}):${Object.keys(cat.providers).length} 厂商 / ${n} 模型(${cat.fetchedAt?.slice(0, 10)})${ui.reset}\n`);
62
+ }
63
+ return next();
64
+ }
65
+ return unhandled();
66
+ },
67
+ ];
@@ -0,0 +1,107 @@
1
+ /**
2
+ * 统一模型面板的交互编排(#模型命令交互):openModelPanel。
3
+ *
4
+ * 两级浏览:先列厂商,进入某厂商后再列其下模型/预设;搜索栏为作用域内过滤——
5
+ * 顶层只按厂商名、厂商内只按模型名,不跨层混搜。思考强度统一在 /effort 设置,
6
+ * 不放进模型面板。动作复用 model-actions.ts。
7
+ */
8
+ import * as layout from '../../ui/layout.js';
9
+ import { ui } from '../../ui/theme.js';
10
+ import { promptHierarchicalPicker } from '../../ui/hierarchical-picker.js';
11
+ import { promptIntervention } from '../../ui/intervention.js';
12
+ import { loadCatalog } from '../../models/catalog.js';
13
+ import { flattenCatalog } from '../../models/search.js';
14
+ import { buildModelTree } from '../../models/model-panel.js';
15
+ import { config } from '../../config/index.js';
16
+ import { savePreset } from '../../config/presets.js';
17
+ import { applyPresetConfig, addCatalogModel, refreshAndReport } from './model-actions.js';
18
+ /**
19
+ * 选中已存预设时确认 API key:回车沿用当前 key;粘贴新值则更新并写回预设文件。
20
+ * 修复「预设里存着失效 key、重选同一模型又静默套用 → 401 死循环」——套用任何预设前
21
+ * 都给一次改 key 的机会。取消返回 null。
22
+ */
23
+ async function confirmPresetKey(preset) {
24
+ const res = await promptIntervention({
25
+ type: 'input',
26
+ title: 'API Key',
27
+ detail: `端点:${preset.baseURL}\n` + `回车沿用当前 key;认证失败时粘贴新 key(会一并写回预设 “${preset.name}”)。`,
28
+ seed: preset.apiKey,
29
+ });
30
+ if (res.action === 'cancelled')
31
+ return null;
32
+ const v = (res.value ?? '').trim();
33
+ if (!v || v === preset.apiKey)
34
+ return preset;
35
+ const updated = { ...preset, apiKey: v };
36
+ savePreset(updated);
37
+ return updated;
38
+ }
39
+ /** 域模型树 → 层级选择器节点(叶子 value 为 PanelChoice)。 */
40
+ function toHPNode(node) {
41
+ const base = {
42
+ searchText: node.searchText,
43
+ label: node.label,
44
+ detail: node.detail,
45
+ };
46
+ if (node.children && node.children.length) {
47
+ base.children = node.children.map(toHPNode);
48
+ }
49
+ else if (node.choice) {
50
+ base.value = node.choice;
51
+ }
52
+ return base;
53
+ }
54
+ /**
55
+ * 打开统一模型面板。
56
+ * @returns 'custom' 表示用户选了「手动填写」,调用方回落到手填向导;其余均已处理。
57
+ */
58
+ export async function openModelPanel(ctx) {
59
+ // 目录:新鲜快照不联网;离线/无快照仍能列已存预设与操作行。
60
+ const cat = await loadCatalog();
61
+ const catalog = flattenCatalog(cat.providers);
62
+ const tree = buildModelTree({
63
+ presets: listAllPresets(),
64
+ providers: cat.providers,
65
+ catalog,
66
+ current: {
67
+ provider: config.provider,
68
+ baseURL: config.baseURL,
69
+ model: config.model,
70
+ contextWindowTokens: config.contextWindowTokens,
71
+ },
72
+ });
73
+ const srcTag = cat.source === 'empty' ? '无目录' : cat.source === 'live' ? '在线目录' : '目录快照';
74
+ const picked = await promptHierarchicalPicker({
75
+ title: `选择模型(${srcTag})`,
76
+ roots: tree.map(toHPNode),
77
+ maxVisible: 12,
78
+ });
79
+ if (!picked)
80
+ return 'handled'; // Esc:什么都不做
81
+ switch (picked.kind) {
82
+ case 'preset': {
83
+ const confirmed = await confirmPresetKey(picked.preset);
84
+ if (!confirmed)
85
+ return 'handled';
86
+ applyPresetConfig(ctx, confirmed);
87
+ layout.contentWrite(`${ui.dim}(已切换 → ${confirmed.model})${ui.reset}\n`);
88
+ return 'handled';
89
+ }
90
+ case 'catalog':
91
+ await addCatalogModel(ctx, picked.entry, cat.providers);
92
+ return 'handled';
93
+ case 'action':
94
+ if (picked.action === 'custom')
95
+ return 'custom';
96
+ if (picked.action === 'refresh') {
97
+ await refreshAndReport();
98
+ return 'handled';
99
+ }
100
+ return 'handled';
101
+ }
102
+ }
103
+ // 延迟取预设列表,避免在模块顶部引入多余耦合。
104
+ import { listPresets } from '../../config/presets.js';
105
+ function listAllPresets() {
106
+ return listPresets();
107
+ }
@@ -19,6 +19,7 @@ import { config, updateModelConfig } from '../../config/index.js';
19
19
  import { writeConfigKeys, CONFIG_PATH } from '../../config/file.js';
20
20
  import { deletePreset, getPreset, isValidPresetName, listPresets, savePreset, setActivePresetName, } from '../../config/presets.js';
21
21
  import { reconfigureClient } from '../../llm/index.js';
22
+ import { recognizesReasoning } from '../../llm/reasoning.js';
22
23
  import { promptIntervention } from '../../ui/intervention.js';
23
24
  import { renderHistory } from '../message-format.js';
24
25
  import { MODEL_PRESETS, maskKey } from '../commands.js';
@@ -57,6 +58,13 @@ export const modelCommands = [
57
58
  return unhandled();
58
59
  const arg = line.startsWith('/model ') ? line.slice('/model '.length).trim() : '';
59
60
  const { history } = ctx;
61
+ // 裸 /model → 统一模型面板(打字过滤+预设+目录+思考强度)。选「自定义」才落到下方手填向导。
62
+ if (arg === '') {
63
+ const { openModelPanel } = await import('./model-panel-ui.js');
64
+ const outcome = await openModelPanel(ctx);
65
+ if (outcome !== 'custom')
66
+ return next();
67
+ }
60
68
  // 共用:apply 一个预设到 config + 持久化 + 重建 client + 重显横幅。无参 /model 选菜单和 /model use 都走这里。
61
69
  const applyPresetAndPersist = (target) => {
62
70
  updateModelConfig({
@@ -66,6 +74,7 @@ export const modelCommands = [
66
74
  apiKey: target.apiKey,
67
75
  contextWindowTokens: target.contextWindow,
68
76
  anthropicPromptCache: target.anthropicPromptCache,
77
+ ...(target.reasoningEffort ? { reasoningEffort: target.reasoningEffort } : {}),
69
78
  });
70
79
  writeConfigKeys({
71
80
  LLM_PROVIDER: target.provider,
@@ -74,6 +83,7 @@ export const modelCommands = [
74
83
  LLM_MODEL: target.model,
75
84
  CONTEXT_WINDOW_TOKENS: String(target.contextWindow),
76
85
  ANTHROPIC_PROMPT_CACHE: target.anthropicPromptCache ? 'true' : 'false',
86
+ ...(target.reasoningEffort ? { REASONING_EFFORT: target.reasoningEffort } : {}),
77
87
  });
78
88
  reconfigureClient();
79
89
  // 记为激活预设:让上下文窗口等配置从此跟随该预设文件(下次启动也用它,不再回退 config 裸键)。
@@ -167,6 +177,13 @@ export const modelCommands = [
167
177
  layout.contentWrite(` ${ui.accent}baseURL ${ui.reset} ${config.baseURL}\n`);
168
178
  layout.contentWrite(` ${ui.accent}apiKey ${ui.reset} ${maskKey(config.apiKey)}\n`);
169
179
  layout.contentWrite(` ${ui.accent}model ${ui.reset} ${config.model}\n`);
180
+ const recognized = recognizesReasoning({
181
+ provider: config.provider === 'anthropic' ? 'anthropic' : 'openai',
182
+ model: config.model,
183
+ maxTokens: config.maxTokens,
184
+ });
185
+ layout.contentWrite(` ${ui.accent}effort ${ui.reset} ${config.reasoningEffort}` +
186
+ (recognized ? '\n' : `${ui.dim}(当前模型未识别,参数不会下发)${ui.reset}\n`));
170
187
  layout.contentWrite(` ${ui.accent}窗口 ${ui.reset} ${config.contextWindowTokens} tokens\n`);
171
188
  if (config.provider === 'anthropic') {
172
189
  layout.contentWrite(` ${ui.accent}缓存 ${ui.reset} Prompt Cache ${config.anthropicPromptCache ? 'on' : 'off'}\n`);
@@ -5,12 +5,15 @@ import { modeCommands } from './mode.js';
5
5
  import { petCommands } from './pet.js';
6
6
  import { imageCommands } from './image.js';
7
7
  import { contextCommands } from './context.js';
8
+ import { statsCommands } from './stats.js';
8
9
  import { sessionCommands } from './session.js';
9
10
  import { memoryCommands } from './memory.js';
10
11
  import { skillCommands } from './skill.js';
11
12
  import { compactCommands } from './compact.js';
12
13
  import { appearanceCommands } from './appearance.js';
13
14
  import { modelCommands } from './model.js';
15
+ import { modelCatalogCommands } from './model-catalog.js';
16
+ import { effortCommands } from './effort.js';
14
17
  import { routerCommands } from './router.js';
15
18
  import { toolGroupCommands } from './tool-group.js';
16
19
  /**
@@ -26,11 +29,14 @@ export const commandHandlers = [
26
29
  ...petCommands,
27
30
  ...imageCommands,
28
31
  ...contextCommands,
32
+ ...statsCommands,
29
33
  ...sessionCommands,
30
34
  ...memoryCommands,
31
35
  ...skillCommands,
32
36
  ...compactCommands,
33
37
  ...appearanceCommands,
38
+ ...effortCommands,
39
+ ...modelCatalogCommands,
34
40
  ...modelCommands,
35
41
  ...routerCommands,
36
42
  ...toolGroupCommands,
@@ -84,6 +84,60 @@ export const sessionCommands = [
84
84
  await ctx.resumeFromPick(pick);
85
85
  return next();
86
86
  },
87
+ // /session gc [--yes]:老会话垃圾回收。30 天未活动→gzip 归档(archive/),90 天→清正文。
88
+ // 默认(及 --dry-run)只列计划不写盘;--yes 才执行——删除不可逆,用显式参数而非交互确认。
89
+ async (ctx) => {
90
+ const m = /^\/sessions?\s+gc\b(.*)$/.exec(ctx.line);
91
+ if (!m)
92
+ return unhandled();
93
+ const args = (m[1] ?? '').trim();
94
+ const execute = args === '--yes';
95
+ if (args && args !== '--yes' && args !== '--dry-run') {
96
+ layout.contentWrite(`${ui.yellow}用法: /session gc [--dry-run|--yes]${ui.reset}\n`);
97
+ return next();
98
+ }
99
+ const fmtKb = (n) => `${(n / 1024).toFixed(0)}KB`;
100
+ if (execute) {
101
+ const result = await ctx.runtime.session.gc({ dryRun: false });
102
+ layout.contentWrite(`${ui.accent}已归档 ${result.archived}、清除 ${result.purged},回收 ${fmtKb(result.bytesReclaimed)}${ui.reset}\n`);
103
+ return next();
104
+ }
105
+ const plan = ctx.runtime.session.gc({ dryRun: true });
106
+ if (plan.items.length === 0) {
107
+ layout.contentWrite(`${ui.dim}(没有需要归档或清除的会话)${ui.reset}\n`);
108
+ return next();
109
+ }
110
+ for (const it of plan.items) {
111
+ const label = it.action === 'archive' ? `${ui.cyan}归档` : `${ui.yellow}清除`;
112
+ layout.contentWrite(` ${label}${ui.reset} ${it.id} ${it.ageDays.toFixed(0)}天 ${fmtKb(it.sizeBytes)} ${it.firstUser}\n`);
113
+ }
114
+ layout.contentWrite(` ${ui.dim}共 ${plan.items.length} 项,可回收 ${fmtKb(plan.bytesReclaimable)}${ui.reset}\n`);
115
+ layout.contentWrite(`${ui.dim}(dry-run;加 --yes 执行,归档/清除不可逆)${ui.reset}\n`);
116
+ return next();
117
+ },
118
+ // /ssearch <关键词>:跨会话搜索归档(episodic),命中显示 id/日期/摘要。
119
+ // 未 purge 的会话可直接 /resume <id> 取回;已 purge 仅可检索摘要。
120
+ (ctx) => {
121
+ if (!ctx.line.startsWith('/ssearch'))
122
+ return unhandled();
123
+ const query = ctx.line.replace(/^\/ssearch\s*/, '').trim();
124
+ if (!query) {
125
+ layout.contentWrite(`${ui.yellow}用法: /ssearch <关键词>${ui.reset}\n`);
126
+ return next();
127
+ }
128
+ const hits = ctx.runtime.session.searchArchive(query, 10);
129
+ if (hits.length === 0) {
130
+ layout.contentWrite(`${ui.dim}(无匹配归档会话)${ui.reset}\n`);
131
+ return next();
132
+ }
133
+ for (const h of hits) {
134
+ const tag = h.purgedAt ? ` ${ui.yellow}[仅摘要]${ui.reset}` : ` ${ui.dim}[可 resume]${ui.reset}`;
135
+ layout.contentWrite(` ${ui.cyan}${h.id}${ui.reset} ${h.createdAt}${tag}\n`);
136
+ const body = (h.summary || h.firstUser).split('\n').slice(0, 4).join('\n');
137
+ layout.contentWrite(` ${ui.dim}${body}${ui.reset}\n\n`);
138
+ }
139
+ return next();
140
+ },
87
141
  // /rollback:打开轮次菜单(↑/↓ 选,Enter 回滚到该轮并预填其输入,再 Enter 重新跑)。
88
142
  // 忽略任何数字参数(原「输数字选回滚」已删,统一走菜单)。无快照的旧轮次(/resume 重建)文件改动不可撤销。
89
143
  async (ctx) => {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 用量命令组:/stats(#token-efficiency P1)
3
+ *
4
+ * dialog-only:读取当前会话 usage.jsonl,输出 cache 命中率 / 分层用量 / compact 次数。
5
+ * 后端从未上报 cached 时命中率显示 n/a,不报错。
6
+ */
7
+ import * as layout from '../../ui/layout.js';
8
+ import { getActiveSessionStore } from '../../session/store.js';
9
+ import { formatUsageSummary, summarizeUsage } from '../../session/usage-stats.js';
10
+ import { unhandled, next } from './types.js';
11
+ export const statsCommands = [
12
+ (ctx) => {
13
+ if (ctx.line !== '/stats')
14
+ return unhandled();
15
+ const sessionId = ctx.state.currentSessionId;
16
+ if (!sessionId) {
17
+ layout.contentWrite(' No active session yet.\n');
18
+ return next();
19
+ }
20
+ const raw = getActiveSessionStore().readUsage(sessionId);
21
+ if (raw.length === 0) {
22
+ layout.contentWrite(' No usage recorded yet (run a task first).\n');
23
+ return next();
24
+ }
25
+ layout.contentWrite(` ${formatUsageSummary(summarizeUsage(raw))}\n`);
26
+ return next();
27
+ },
28
+ ];
@@ -24,9 +24,12 @@ export function buildSlashCommands() {
24
24
  { name: '/skills', desc: d('commands.skills') },
25
25
  { name: '/skill', desc: '执行某个 skill(/skill <name> [args-json])' },
26
26
  { name: '/compact', desc: d('commands.compact') },
27
+ { name: '/stats', desc: '查看本会话 token 用量与缓存命中率' },
28
+ { name: '/effort', desc: '设置思考强度(/effort [low|medium|high|auto])' },
27
29
  { name: '/resume', desc: d('commands.sessionResume') },
28
30
  { name: '/sessions', desc: d('commands.sessionBrowse') },
29
31
  { name: '/rollback', desc: d('commands.sessionRollback') },
32
+ { name: '/ssearch <关键词>', value: '/ssearch ', submit: false, desc: '跨会话搜索归档会话' },
30
33
  {
31
34
  name: '/memory',
32
35
  desc: d('commands.memory'),
@@ -1,6 +1,6 @@
1
1
  import { emitKeypressEvents } from 'node:readline';
2
2
  import { stdin } from 'node:process';
3
- import { config, isModelConfigured, isMemoryEnabled, buildBasePrompt, getPlanModeSuffix, pinSessionModel, } from '../config/index.js';
3
+ import { config, isModelConfigured, isMemoryEnabled, buildBasePrompt, getPlanModeSuffix, pinSessionModel, pinSessionEffort, } from '../config/index.js';
4
4
  import { t } from '../i18n/index.js';
5
5
  import { listPresets, migrateCurrentToPreset } from '../config/presets.js';
6
6
  import { runAgent } from '../agent/index.js';
@@ -166,6 +166,7 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
166
166
  setAgentMode('auto');
167
167
  // 钉死本会话模型:之后运行中 agent 一律用此值,其它窗口的 /model switch 不会影响本窗口。
168
168
  pinSessionModel();
169
+ pinSessionEffort();
169
170
  // 沙箱根:文件操作边界。优先级 --sandbox-root > SANDBOX_ROOT env > process.cwd()。
170
171
  // 纯边界记录(不 chdir),jail.ts 内部 resolve。子 agent 同进程继承全局 root。
171
172
  setSandboxRoot(sandboxRootOverride ?? config.sandboxRoot ?? process.cwd());
@@ -305,6 +306,9 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
305
306
  if (!history.some((m) => m.role === 'user')) {
306
307
  layout.writeWelcomeBlock(welcomeLines());
307
308
  }
309
+ // 启动时限频自动 GC(24h 闸门,归档+purge 全做):fire-and-forget,
310
+ // 不阻塞进入主循环;runRetention 内部归档本地秒完、reflection 后台跑。可用 MOCODE_AUTO_GC=false 关闭。
311
+ void runtime.session.autoGc().catch(() => undefined);
308
312
  /**
309
313
  * 切换 agent 模式(Shift+Tab 触发,经 prompt.ts 的 onCycleMode 回调)。
310
314
  * cycleMode 翻 agentMode + applyMode 重写 history[0] + refreshStatusBase 设状态行 modeTag;
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { ui } from '../ui/theme.js';
4
4
  import * as layout from '../ui/layout.js';
5
5
  import { t } from '../i18n/index.js';
6
- import { config } from '../config/index.js';
6
+ import { config, getActiveEffort } from '../config/index.js';
7
7
  import { contextState } from '../session/index.js';
8
8
  import { DEFAULT_BUDGET_POLICY } from '../context/budget.js';
9
9
  import { chatTools, correctTokenEstimate, estimateMessagesTokens, estimateToolSchemaTokens, estimateTokens, } from '../llm/index.js';
@@ -47,7 +47,8 @@ export function renderContextBar(history) {
47
47
  const lifecycleLine = lifecycle
48
48
  ? `\n lifecycle · live ${lifecycle.live} · referenced ${lifecycle.referenced} · digested ${lifecycle.digested} · stubbed ${lifecycle.stubbed}`
49
49
  : '\n lifecycle · no active snapshot (run a tool-enabled turn first)';
50
- const archiveLine = `\n archived tool results · ${archived.stubbed}`;
50
+ const archiveLine = `
51
+ archived tool results · ${archived.stubbed}${archived.cleared > 0 ? ` · cleared ${archived.cleared} (low-pressure)` : ''}`;
51
52
  return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${Math.round(pct * 100)}% ${k(est)}/${k(win)} tokens · ${t('status.messages', { count: history.length })} (${src})${measuredNote}${ui.reset}${artifactLine}${lifecycleLine}${archiveLine}`;
52
53
  }
53
54
  /** 状态行用量条(精简版,进底栏):[bar] pct% k/k。
@@ -167,6 +168,7 @@ export function refreshStatusBase(history, lastTurnUsage) {
167
168
  contextBar: renderContextBarInline(history),
168
169
  cwd: process.cwd(),
169
170
  modeTag: getAgentMode() === 'plan' ? 'Plan' : 'Auto',
171
+ effort: getActiveEffort() === 'auto' ? undefined : getActiveEffort(),
170
172
  planSummary: readPlanFromNotes(),
171
173
  lastTurnUsage,
172
174
  });
@@ -3,6 +3,7 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync
3
3
  import * as fsp from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { config } from '../config/index.js';
6
+ import { MAX_SNAPSHOT_TURNS } from '../tools/constants.js';
6
7
  import { toText } from '../context/utils.js';
7
8
  import { truncateDisplay } from '../ui/render.js';
8
9
  const EXCLUDED_WORKSPACE_DIRS = Object.freeze([
@@ -192,8 +193,24 @@ export class RollbackStore {
192
193
  this.turnIdCounter += 1;
193
194
  this.currentTurnId = this.turnIdCounter;
194
195
  this.turns.push({ turnId: this.currentTurnId, firstLine });
196
+ // 压缩前滚动窗口:snapshots.json 此前只在 compact 后剪枝,长会话压缩前持续累积
197
+ // (实测单文件 2MB)。每开新 turn 即丢掉超过 MAX_SNAPSHOT_TURNS 的老 turn 及其快照。
198
+ // env 覆盖仅供测试:MOCODE_MAX_SNAPSHOT_TURNS=2 可立刻触发滚动剪枝。
199
+ const envMax = Number(process.env.MOCODE_MAX_SNAPSHOT_TURNS);
200
+ this.pruneOldTurns(Number.isFinite(envMax) && envMax >= 1 ? envMax : MAX_SNAPSHOT_TURNS);
195
201
  return this.currentTurnId;
196
202
  }
203
+ /** 仅保留最近 maxTurns 个 turn 及其快照;currentTurnId 被剪掉时归零(不应发生,当前 turn 恒在尾部)。 */
204
+ pruneOldTurns(maxTurns) {
205
+ if (this.turns.length <= maxTurns)
206
+ return;
207
+ const kept = this.turns.slice(-maxTurns);
208
+ const alive = new Set(kept.map((turn) => turn.turnId));
209
+ this.turns = kept;
210
+ this.snapshots = this.snapshots.filter((snapshot) => alive.has(snapshot.turnId));
211
+ if (!alive.has(this.currentTurnId))
212
+ this.currentTurnId = 0;
213
+ }
197
214
  getCurrentTurnId() {
198
215
  return this.currentTurnId;
199
216
  }
@@ -1,7 +1,7 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { runAgentCore } from '../agent/core.js';
3
3
  import { createAgentRuntimeContext, defaultAgentRuntimeContext, } from '../agent/runtime-context.js';
4
- import { contextState as defaultContextState, createContextState, manualCompact, } from '../session/index.js';
4
+ import { contextState as defaultContextState, createContextState, manualCompact, planRetention, runRetention, searchArchiveIndex, shouldAutoGc, writeLastAutoGc, DEFAULT_RETENTION_POLICY, } from '../session/index.js';
5
5
  const activeRuntimes = new AsyncLocalStorage();
6
6
  /** Runtime facade active in the current agent async tree, used by nested agents to borrow the parent lifecycle. */
7
7
  export function getActiveRuntime() {
@@ -54,6 +54,36 @@ export class Runtime {
54
54
  return this.context.sessionStore.list(limit);
55
55
  },
56
56
  clear: () => this.createSession(true),
57
+ gc: (async (opts = {}) => {
58
+ this.assertIdle('gc sessions');
59
+ const root = this.context.sessionStore.sessionsRoot;
60
+ const currentId = this.context.sessionStore.getCurrentSessionId();
61
+ const plan = planRetention(root, DEFAULT_RETENTION_POLICY, Date.now(), currentId);
62
+ if (opts.dryRun !== false)
63
+ return plan;
64
+ const result = await runRetention(root, plan);
65
+ this.emit('session.gc', { archived: result.archived, purged: result.purged });
66
+ return result;
67
+ }),
68
+ searchArchive: (query, limit) => {
69
+ this.assertOpen();
70
+ return searchArchiveIndex(this.context.sessionStore.sessionsRoot, query, limit ?? 10);
71
+ },
72
+ autoGc: async () => {
73
+ this.assertOpen();
74
+ const root = this.context.sessionStore.sessionsRoot;
75
+ if (!shouldAutoGc(root))
76
+ return null;
77
+ const currentId = this.context.sessionStore.getCurrentSessionId();
78
+ const plan = planRetention(root, DEFAULT_RETENTION_POLICY, Date.now(), currentId);
79
+ // 先写闸门:即使本次没有命中项,也 记录“已检查”,避免每次启动都重新扫描。
80
+ writeLastAutoGc(root);
81
+ if (plan.items.length === 0)
82
+ return null;
83
+ const result = await runRetention(root, plan);
84
+ this.emit('session.gc', { archived: result.archived, purged: result.purged, auto: true });
85
+ return result;
86
+ },
57
87
  };
58
88
  this.rollback = {
59
89
  list: () => {