mocode-ai 0.1.8 → 0.1.10

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 (37) hide show
  1. package/README.md +65 -52
  2. package/dist/agent/core.js +8 -4
  3. package/dist/agent/index.js +5 -10
  4. package/dist/commands/config.js +1 -1
  5. package/dist/config/file.js +1 -1
  6. package/dist/config/index.js +56 -13
  7. package/dist/context/classifier.js +2 -1
  8. package/dist/index.js +2 -1
  9. package/dist/llm/index.js +13 -1
  10. package/dist/repl/index.js +204 -15
  11. package/dist/session/drop.js +135 -0
  12. package/dist/session/index.js +1 -0
  13. package/dist/tools/builtins/ask-human.js +4 -4
  14. package/dist/tools/builtins/codegraph.js +5 -3
  15. package/dist/tools/builtins/drop-context.js +68 -0
  16. package/dist/tools/builtins/edit-file.js +1 -1
  17. package/dist/tools/builtins/glob.js +2 -2
  18. package/dist/tools/builtins/grep.js +2 -2
  19. package/dist/tools/builtins/index.js +2 -0
  20. package/dist/tools/builtins/memory-forget.js +1 -1
  21. package/dist/tools/builtins/memory-list.js +1 -1
  22. package/dist/tools/builtins/memory-save.js +1 -1
  23. package/dist/tools/builtins/memory-search.js +1 -1
  24. package/dist/tools/builtins/memory-update.js +1 -1
  25. package/dist/tools/builtins/read-file.js +2 -2
  26. package/dist/tools/builtins/run-command.js +1 -1
  27. package/dist/tools/builtins/switch-mode.js +3 -5
  28. package/dist/tools/builtins/task.js +4 -5
  29. package/dist/tools/builtins/use-skill.js +1 -1
  30. package/dist/tools/builtins/web-fetch.js +1 -1
  31. package/dist/tools/builtins/web-search.js +1 -1
  32. package/dist/tools/builtins/write-file.js +1 -1
  33. package/dist/tools/registry.js +6 -1
  34. package/dist/ui/intervention.js +0 -11
  35. package/dist/ui/layout.js +173 -91
  36. package/dist/ui/prompt.js +2 -14
  37. package/package.json +40 -40
@@ -1,19 +1,18 @@
1
1
  import readline from 'node:readline/promises';
2
2
  import { emitKeypressEvents } from 'node:readline';
3
3
  import { stdin, stdout } from 'node:process';
4
- import { config, PLAN_MODE_SUFFIX } from '../config/index.js';
5
- import { updateConfigKey } from '../config/file.js';
4
+ import { config, PLAN_MODE_SUFFIX, updateModelConfig, isModelConfigured } from '../config/index.js';
5
+ import { updateConfigKey, writeConfigKeys, CONFIG_PATH } from '../config/file.js';
6
6
  import { runAgent } from '../agent/index.js';
7
7
  import { getAgentMode, setAgentMode, onModeChange } from '../agent/mode.js';
8
8
  import { setSandboxRoot } from '../sandbox/root.js';
9
9
  import { ui, setTheme, getTheme, listThemes, themeExists } from '../ui/theme.js';
10
10
  import { bannerString, displayWidth, padEndDisplay, summarizeToolCall, summarizeToolResult } from '../ui/render.js';
11
11
  import * as layout from '../ui/layout.js';
12
- import * as mouse from '../ui/mouse.js';
13
12
  import { promptWithSlashMenu, promptTurnPicker, promptSessionPicker, promptThemePicker, promptRevertChoice, } from '../ui/prompt.js';
14
13
  import { promptIntervention } from '../ui/intervention.js';
15
14
  import { tools } from '../tools/registry.js';
16
- import { estimateMessagesTokens, } from '../llm/index.js';
15
+ import { estimateMessagesTokens, reconfigureClient, } from '../llm/index.js';
17
16
  import { compactHistory, contextState, newSessionId, saveSession, loadSession, listSessions, } from '../session/index.js';
18
17
  import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, } from '../rollback/index.js';
19
18
  import { listSkills, effectiveSystemPrompt } from '../skills/index.js';
@@ -36,6 +35,7 @@ const SLASH_COMMANDS = [
36
35
  { name: '/reflect', desc: '手动触发后台记忆反思 pass' },
37
36
  { name: '/init', desc: '扫描项目生成 MOCODE.md 项目记忆' },
38
37
  { name: '/theme', desc: '切换颜色主题(↑↓·Enter)' },
38
+ { name: '/model', desc: '配置大模型(baseURL/key/model/窗口)' },
39
39
  { name: '/plan', desc: '切到 plan 模式(只读探查+产出计划)' },
40
40
  { name: '/auto', desc: '切回 auto 模式(全工具执行)' },
41
41
  ];
@@ -47,6 +47,23 @@ const THEME_DESCRIPTIONS = {
47
47
  gruvbox: 'Gruvbox 暖色',
48
48
  nord: 'Nord 冷色',
49
49
  };
50
+ /** /model 预设后端:选一个预填 baseURL,仍可逐项改。base_url 取自 README 常见表。 */
51
+ const MODEL_PRESETS = [
52
+ { label: 'GLM(智谱)', baseURL: 'https://open.bigmodel.cn/api/v3', model: 'glm-4.6', window: 128000 },
53
+ { label: 'DeepSeek', baseURL: 'https://api.deepseek.com', model: 'deepseek-chat', window: 64000 },
54
+ { label: 'Qwen(阿里)', baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', window: 128000 },
55
+ { label: '本地 Ollama', baseURL: 'http://localhost:11434/v1', model: 'qwen2.5:7b', window: 32768 },
56
+ { label: '本地 vLLM', baseURL: 'http://localhost:8000/v1', model: 'default', window: 32768 },
57
+ { label: '自定义 base_url', baseURL: '', model: '', window: 128000 },
58
+ ];
59
+ /** apiKey 脱敏:只露末 4 位,前面打星号(显示用,绝不把明文 key 写进内容区)。 */
60
+ function maskKey(k) {
61
+ if (!k)
62
+ return '(未设置)';
63
+ if (k.length <= 8)
64
+ return '****';
65
+ return `${'='.repeat(Math.min(k.length - 4, 20))}${k.slice(-4)}`;
66
+ }
50
67
  /**
51
68
  * /init 指令:发给 agent 扫描项目并生成 MOCODE.md(对标 Claude Code /init 生成 CLAUDE.md,
52
69
  * 但 mocode 读 MOCODE.md)。已存在则让 agent 读后更新(不丢失事实)。写完供 memory 子系统下轮加载。
@@ -95,7 +112,7 @@ function renderContextBar(history) {
95
112
  const pct = Math.min(1, est / win);
96
113
  const W = 10;
97
114
  const filled = Math.round(pct * W);
98
- const bar = filled === 0 ? ' '.repeat(W) : '█'.repeat(filled) + '░'.repeat(W - filled);
115
+ const bar = '█'.repeat(filled) + '░'.repeat(W - filled);
99
116
  const src = contextState.lastUsage ? '实测' : '估算';
100
117
  const k = (n) => `${Math.round(n / 1000)}k`;
101
118
  const pctCol = pct >= config.compactThreshold ? ui.yellow : ui.cyan;
@@ -111,7 +128,7 @@ function renderContextBarInline(history) {
111
128
  const pct = Math.min(1, est / win);
112
129
  const W = 10;
113
130
  const filled = Math.round(pct * W);
114
- const bar = filled === 0 ? ' '.repeat(W) : '█'.repeat(filled) + '░'.repeat(W - filled);
131
+ const bar = '█'.repeat(filled) + '░'.repeat(W - filled);
115
132
  const k = (n) => `${Math.round(n / 1000)}k`;
116
133
  const pctCol = pct >= config.compactThreshold ? ui.yellow : ui.cyan;
117
134
  return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${pctCol}${Math.round(pct * 100)}%${ui.reset} ${ui.dim}${k(est)}/${k(win)}${ui.reset}`;
@@ -144,6 +161,8 @@ function runningStateFor(cmd) {
144
161
  return { status: '清空', placeholder: '…' };
145
162
  case '/theme':
146
163
  return { status: '切主题', placeholder: '选择主题…' };
164
+ case '/model':
165
+ return { status: '配模型', placeholder: '配置中…' };
147
166
  default:
148
167
  // 输入框留空(运行中可 typeahead 打字,dim 回显);运行状态由内联 spinner 承载(思考中/执行…),
149
168
  // 状态行只显走时——故常态 status 留空,不塞「处理」这种与内联重复的泛标签。
@@ -163,15 +182,8 @@ let pendingPrefill = null; // /rollback 选中后预填的 user 输入(下轮 IN
163
182
  function onRunningKey(_str, key) {
164
183
  if (!key)
165
184
  return;
166
- // SGR 鼠标滚轮:readline 把 \x1B[<btn;col;rowM 拆成多 fragment,经 mouse.consumeMouse 重组;
167
- // 滚轮 → scrollBy(±5)(不调 setUserActive——与键盘滚动一致;流式 flushTimer 在 scrollOffset>0 时不重画,安全)。
168
- const _m = mouse.consumeMouse(key.sequence ?? '');
169
- if (_m.suppress) {
170
- if (_m.wheel)
171
- layout.scrollBy(_m.wheel * 5);
172
- return;
173
- }
174
- // 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,↑/↓ 每次 5 行(键盘;鼠标滚轮已由上方守卫处理)。
185
+ // 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,↑/↓ 每次 5 行(键盘 + alt 屏内鼠标滚轮转发的
186
+ // \x1B[A/\x1B[B,经 DECSET 1007 转发,与键盘 ↑/↓ 同序列,天然共用此分支)。
175
187
  // 运行态无输入光标,↑/↓ 无其他用途,直接作滚动。
176
188
  if (key.name === 'pageup' ||
177
189
  key.name === 'pagedown' ||
@@ -396,6 +408,10 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
396
408
  // 自更新提示:开场静态段(进 INPUT 态前),dim 一行,不与流式 / 输入争用。
397
409
  layout.contentWrite(` ${ui.gray}↳ ${updateNotice}${ui.reset}\n`);
398
410
  }
411
+ if (!isModelConfigured()) {
412
+ // 未配置 baseURL/apiKey:醒目提示引导 /model(不退出,REPL 仍可用;发消息会失败但不崩)。
413
+ layout.contentWrite(`${ui.yellow} ⚠ 未配置大模型。输入 ${ui.cyan}/model${ui.yellow} 配置 baseURL / apiKey / model(即时生效),或退出后运行 ${ui.cyan}mocode config${ui.yellow} 走向导。${ui.reset}\n`);
414
+ }
399
415
  layout.contentWrite(`${ui.dim} /plan · /auto · Shift+Tab 切换模式(plan:只读探查 + 产出计划,审批后切 auto 执行)${ui.reset}\n`);
400
416
  /**
401
417
  * 切换 agent 模式(Shift+Tab 触发,经 prompt.ts 的 onCycleMode 回调)。
@@ -772,6 +788,179 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
772
788
  }
773
789
  continue;
774
790
  }
791
+ if (line === '/model' || line.startsWith('/model ')) {
792
+ // /model:运行时配置大模型(baseURL/apiKey/model/contextWindowTokens)。
793
+ // 即时生效(updateModelConfig 改内存 + env,reconfigureClient 重建 OpenAI 实例)+ 持久化(writeConfigKeys 写 ~/.mocode/config)。
794
+ // 仿 /theme:promptIntervention 弹菜单/输入 → 改 config → refreshStatusBase 刷底栏 → clearContent+banner 重显横幅 → dim 警告(shell env 覆盖)。
795
+ const arg = line.startsWith('/model ') ? line.slice('/model '.length).trim() : '';
796
+ // /model list:显示当前四项配置(apiKey 脱敏)。
797
+ if (arg === 'list' || arg === 'show') {
798
+ layout.contentWrite(`${ui.dim}当前模型配置:${ui.reset}\n`);
799
+ layout.contentWrite(` ${ui.cyan}baseURL${ui.reset} ${config.baseURL}\n`);
800
+ layout.contentWrite(` ${ui.cyan}apiKey ${ui.reset} ${maskKey(config.apiKey)}\n`);
801
+ layout.contentWrite(` ${ui.cyan}model ${ui.reset} ${config.model}\n`);
802
+ layout.contentWrite(` ${ui.cyan}窗口 ${ui.reset} ${config.contextWindowTokens} tokens\n`);
803
+ layout.contentWrite(`${ui.dim}(配置文件: ${CONFIG_PATH})${ui.reset}\n`);
804
+ continue;
805
+ }
806
+ // 1) 选 provider 预设(预填 baseURL,后续仍可逐项改)。
807
+ let preset;
808
+ try {
809
+ const res = await promptIntervention({
810
+ type: 'choice',
811
+ title: '选择后端预设(预填 baseURL,后续可改)',
812
+ detail: '选一个会预填 baseURL/model/窗口,之后逐项确认。选「自定义」全部手填。',
813
+ options: MODEL_PRESETS.map((p) => p.label),
814
+ });
815
+ if (res.action === 'cancelled') {
816
+ continue;
817
+ }
818
+ const idx = MODEL_PRESETS.findIndex((p) => p.label === res.value);
819
+ if (idx === -1) {
820
+ continue;
821
+ }
822
+ preset = MODEL_PRESETS[idx];
823
+ }
824
+ catch {
825
+ continue; // Ctrl+C
826
+ }
827
+ // 1.5) 一键应用确认:非「自定义」预设(带预填值)给直接应用入口,免连按 4 次回车。
828
+ // 直接应用 = 用预设 model/baseURL/window + 保留当前 apiKey(等价于下方逐项链连按回车)。
829
+ // 逐项修改 / 自定义输入(promptIntervention choice 自动追加的「其他」项 submitted)→ 回落 4 步链。
830
+ // 「自定义」预设字段空,跳过确认直接进链。
831
+ let quickApply = false;
832
+ if (preset.model || preset.baseURL) {
833
+ try {
834
+ const res = await promptIntervention({
835
+ type: 'choice',
836
+ title: `应用 ${preset.label}?`,
837
+ detail: `model ${preset.model}\nbaseURL ${preset.baseURL}\napiKey ${maskKey(config.apiKey)}(直接应用=保留当前)\n窗口 ${preset.window}`,
838
+ options: ['直接应用', '逐项修改'],
839
+ });
840
+ if (res.action === 'cancelled') {
841
+ continue;
842
+ }
843
+ if (res.action === 'selected' && res.value === '直接应用') {
844
+ quickApply = true;
845
+ }
846
+ // 其余(逐项修改 / 自定义输入 submitted)→ quickApply 保持 false,走下方逐项链
847
+ }
848
+ catch {
849
+ continue; // Ctrl+C
850
+ }
851
+ }
852
+ // 2) 收集 baseURL / apiKey / model / contextWindowTokens。
853
+ // quickApply:直接取预设值 + 当前 apiKey;否则逐项 input(预填 preset 值,回车=采纳;apiKey 不预填明文,回车=保留旧值)。
854
+ let baseURL;
855
+ let apiKey;
856
+ let model;
857
+ let window;
858
+ if (quickApply) {
859
+ baseURL = preset.baseURL;
860
+ apiKey = config.apiKey;
861
+ model = preset.model;
862
+ window = preset.window;
863
+ }
864
+ else {
865
+ // baseURL
866
+ {
867
+ const res = await promptIntervention({
868
+ type: 'input',
869
+ title: 'LLM_BASE_URL',
870
+ detail: 'OpenAI 兼容 API 端点。回车采纳预填值。',
871
+ seed: preset.baseURL || config.baseURL,
872
+ });
873
+ if (res.action === 'cancelled') {
874
+ continue;
875
+ }
876
+ baseURL = (res.value ?? '').trim() || preset.baseURL || config.baseURL;
877
+ }
878
+ if (!baseURL) {
879
+ layout.contentWrite(`${ui.yellow}baseURL 不能为空,已取消。${ui.reset}\n`);
880
+ continue;
881
+ }
882
+ // apiKey(不预填明文:回车=保留旧值,输入新值=覆盖)
883
+ {
884
+ const res = await promptIntervention({
885
+ type: 'input',
886
+ title: 'LLM_API_KEY',
887
+ detail: `回车保留当前 ${maskKey(config.apiKey)};输入新值则覆盖。`,
888
+ seed: '',
889
+ });
890
+ if (res.action === 'cancelled') {
891
+ continue;
892
+ }
893
+ const v = (res.value ?? '').trim();
894
+ apiKey = v || config.apiKey;
895
+ }
896
+ if (!apiKey) {
897
+ layout.contentWrite(`${ui.yellow}apiKey 不能为空,已取消。${ui.reset}\n`);
898
+ continue;
899
+ }
900
+ // model
901
+ {
902
+ const res = await promptIntervention({
903
+ type: 'input',
904
+ title: 'LLM_MODEL',
905
+ detail: '模型名(须支持 function calling)。回车采纳预填值。',
906
+ seed: preset.model || config.model,
907
+ });
908
+ if (res.action === 'cancelled') {
909
+ continue;
910
+ }
911
+ model = (res.value ?? '').trim() || preset.model || config.model;
912
+ }
913
+ if (!model) {
914
+ layout.contentWrite(`${ui.yellow}model 不能为空,已取消。${ui.reset}\n`);
915
+ continue;
916
+ }
917
+ // contextWindowTokens
918
+ {
919
+ const res = await promptIntervention({
920
+ type: 'input',
921
+ title: 'CONTEXT_WINDOW_TOKENS',
922
+ detail: '模型上下文窗口(须对齐真实模型;GLM≈128k,DeepSeek-V3≈64k)。回车采纳预填值。',
923
+ seed: String(preset.window || config.contextWindowTokens),
924
+ });
925
+ if (res.action === 'cancelled') {
926
+ continue;
927
+ }
928
+ const v = (res.value ?? '').trim();
929
+ const n = Number(v);
930
+ if (!v || !Number.isFinite(n) || n <= 0) {
931
+ // 非法输入:保留旧值,不阻断(用 preset.window 或当前值兜底)
932
+ window = preset.window || config.contextWindowTokens;
933
+ }
934
+ else {
935
+ window = Math.floor(n);
936
+ }
937
+ }
938
+ }
939
+ // 3) 应用:内存 config + env(updateModelConfig)→ 持久化(writeConfigKeys)→ 重建 client(reconfigureClient)。
940
+ updateModelConfig({ model, baseURL, apiKey, contextWindowTokens: window });
941
+ writeConfigKeys({
942
+ LLM_BASE_URL: baseURL,
943
+ LLM_API_KEY: apiKey,
944
+ LLM_MODEL: model,
945
+ CONTEXT_WINDOW_TOKENS: String(window),
946
+ });
947
+ reconfigureClient();
948
+ // 4) 刷新 UI:底栏模型名 + 重显横幅(banner() 闭包实时读 config,自动反映新值)。
949
+ refreshStatusBase(history);
950
+ layout.clearContent();
951
+ if (history.some((m) => m.role === 'user')) {
952
+ renderHistory(history);
953
+ }
954
+ else {
955
+ layout.contentWrite(bannerString(banner()));
956
+ }
957
+ layout.contentWrite(`${ui.dim}(已切换模型 → ${model} @ ${baseURL})${ui.reset}\n`);
958
+ // 5) dim 警告:shell export 的 LLM 键下次启动会覆盖文件值。
959
+ if (config.llmKeysFromShell.length > 0) {
960
+ layout.contentWrite(`${ui.dim}(shell 环境变量已设 ${config.llmKeysFromShell.join(' / ')},文件写入下次启动被其覆盖;取消该 shell 设置后生效)${ui.reset}\n`);
961
+ }
962
+ continue;
963
+ }
775
964
  if (line === '/rollback' || line.startsWith('/rollback ')) {
776
965
  // /rollback:打开轮次菜单(↑/↓ 选,Enter 回滚到该轮并预填其输入,再 Enter 重新跑)。
777
966
  // 忽略任何数字参数(原「输数字选回滚」已删,统一走菜单)。无快照的旧轮次(/resume 重建)文件改动不可撤销。
@@ -0,0 +1,135 @@
1
+ // 运行中上下文剔除(drop_context 工具的核心):把历史里无关的 tool 结果替换为存根。
2
+ //
3
+ // 与 compact 的区别:compact 是阈值触发的整体压缩(微截 + 摘要),drop_context 是 agent 主动、
4
+ // 精准剔除"已判定无关"的具体 tool 结果——agent 检索到大量无关信息后主动调用,释放上下文。
5
+ //
6
+ // 不变量(对齐 compact.ts):
7
+ // - 永不动 history[0](system prompt)。
8
+ // - 永不动当前轮:从末尾向前找到最后一个 user 消息,该 user 及其之后的 tool 结果一律保留
9
+ // (agent 本轮还在用,踢了会丢失正在进行的上下文)。
10
+ // - tool_call_id 配对:只改 tool 消息的 content,不删消息、不动 tool_calls 数组结构、不改 id。
11
+ // - 原地修改 history(同 compact:length=0;push 重建,repl 持有同一引用)。
12
+ // - 永不抛错(对齐「调度器永不抛错」契约);无匹配 / 无可剔除 → 返 dropped=0。
13
+ import { messageTokens, estimateTokens, } from '../llm/index.js';
14
+ /** 把消息 content 拍平成字符串(OpenAI 可能 string / null / 多模态数组)。 */
15
+ function toText(content) {
16
+ if (content == null)
17
+ return '';
18
+ if (typeof content === 'string')
19
+ return content;
20
+ try {
21
+ return JSON.stringify(content);
22
+ }
23
+ catch {
24
+ return String(content);
25
+ }
26
+ }
27
+ /**
28
+ * 从 history 末尾向前找最后一个 user 消息的索引;无 user 返 -1。
29
+ * 用于划定"当前轮保护区"——该 user 及其之后的消息一律不剔除。
30
+ */
31
+ function lastUserIndex(history) {
32
+ for (let i = history.length - 1; i >= 1; i--) {
33
+ if (history[i].role === 'user')
34
+ return i;
35
+ }
36
+ return -1;
37
+ }
38
+ /** 取 tool 消息对应的工具名(从紧邻的前导 assistant.tool_calls 按 tool_call_id 配对找)。 */
39
+ function toolNameOf(history, idx) {
40
+ const tcId = history[idx].tool_call_id;
41
+ if (!tcId)
42
+ return null;
43
+ for (let j = idx - 1; j >= 1; j--) {
44
+ const m = history[j];
45
+ if (m.role !== 'assistant')
46
+ continue;
47
+ const tcs = m
48
+ .tool_calls;
49
+ if (!tcs)
50
+ continue;
51
+ const hit = tcs.find((tc) => tc?.id === tcId);
52
+ if (hit)
53
+ return hit.function?.name ?? null;
54
+ }
55
+ return null;
56
+ }
57
+ /**
58
+ * 剔除历史里命中的旧 tool 结果(原地修改 history)。
59
+ *
60
+ * 筛选(各维度 AND 组合):
61
+ * - toolNames:只剔除这些工具名的结果(空 = 不限)
62
+ * - contains:只剔除内容包含所有这些词(AND、大小写不敏感)的结果(空 = 不限)
63
+ *
64
+ * 保护:history[0](system)+ 当前轮(最后一个 user 及其之后)永不剔除。
65
+ * 已是存根的 tool 消息(含「已剔除」标记)不重复剔除(幂等)。
66
+ *
67
+ * 永不抛错;无匹配返 dropped=0。
68
+ */
69
+ export function dropContextFromHistory(history, filter) {
70
+ const toolNames = filter.toolNames && filter.toolNames.length > 0
71
+ ? new Set(filter.toolNames)
72
+ : null;
73
+ const contains = filter.contains && filter.contains.length > 0
74
+ ? filter.contains.map((s) => s.toLowerCase())
75
+ : null;
76
+ // 当前轮保护区:最后一个 user 及其之后一律保留(agent 还在用)。
77
+ const guard = lastUserIndex(history);
78
+ // guard <= 0 表示无 user 或 user 就是 history[0](不会):整段历史都可剔除(除 history[0])。
79
+ const protectedFrom = guard > 0 ? guard : 0; // < protectedFrom 的才可剔除(即 [1, protectedFrom)
80
+ const items = [];
81
+ let freedTokens = 0;
82
+ const STUB_PREFIX = '⌦[已剔除:与当前任务无关]';
83
+ for (let i = 1; i < protectedFrom; i++) {
84
+ const m = history[i];
85
+ if (m.role !== 'tool')
86
+ continue;
87
+ const content = toText(m.content);
88
+ // 幂等:已是存根(含标记)不重复剔除。
89
+ if (content.startsWith(STUB_PREFIX))
90
+ continue;
91
+ // 维度 1:工具名
92
+ const tname = toolNameOf(history, i);
93
+ if (toolNames && (!tname || !toolNames.has(tname)))
94
+ continue;
95
+ // 维度 2:内容关键词(AND)
96
+ if (contains) {
97
+ const lower = content.toLowerCase();
98
+ if (!contains.every((kw) => lower.includes(kw)))
99
+ continue;
100
+ }
101
+ // 命中 → 替换为存根(保 tool_call_id 不动,只改 content)
102
+ const before = messageTokens(m);
103
+ const id = m.tool_call_id ?? '';
104
+ const stub = `${STUB_PREFIX} 原 ${tname ?? 'tool'} 结果(${content.length} 字符,约 ${before} tokens)${id ? ` · id …${id.slice(-6)}` : ''}⌫`;
105
+ m.content = stub;
106
+ const after = messageTokens(m);
107
+ freedTokens += Math.max(0, before - after);
108
+ items.push({
109
+ toolName: tname ?? 'tool',
110
+ toolCallId: id.slice(-6),
111
+ });
112
+ }
113
+ return {
114
+ dropped: items.length,
115
+ freedTokens,
116
+ items,
117
+ };
118
+ }
119
+ /** 给 drop_context 工具结果格式化人类可读摘要(回灌给 agent)。 */
120
+ export function formatDropResult(r) {
121
+ if (r.dropped === 0) {
122
+ return '未剔除任何工具结果(无匹配的旧 tool 消息,或均在当前轮保护区内不可剔除)。';
123
+ }
124
+ const lines = [
125
+ `已剔除 ${r.dropped} 条无关工具结果,释放约 ${r.freedTokens} tokens。`,
126
+ '被剔除项(已替换为存根,tool_call_id 配对不变):',
127
+ ];
128
+ for (const it of r.items) {
129
+ lines.push(` - ${it.toolName} (id …${it.toolCallId})`);
130
+ }
131
+ lines.push('这些结果在后续上下文中仅保留存根标记,不再占用篇幅。');
132
+ return lines.join('\n');
133
+ }
134
+ // 供 drop_context 工具估算用(避免直接 import llm 的公开 API 造成耦合,这里重导出)。
135
+ export { estimateTokens };
@@ -5,4 +5,5 @@
5
5
  * 依赖方向:session → {llm(摘要复用 chat), config, ui};llm 不反向依赖 session。
6
6
  */
7
7
  export { compactHistory, maybeCompact, capToolResultForHistory, truncateMid, contextState, } from './compact.js';
8
+ export { dropContextFromHistory, formatDropResult, } from './drop.js';
8
9
  export { newSessionId, saveSession, loadSession, listSessions, sessionDir, } from './persist.js';
@@ -3,10 +3,10 @@ import { promptIntervention } from '../../ui/intervention.js';
3
3
  export const askHumanTool = {
4
4
  name: 'ask_human',
5
5
  description: [
6
- 'Call this tool when you hit a decision point during a task that requires human input — it pops up a question panel in the terminal for the user to choose.',
7
- 'Use when: multiple implementation approaches need a user decision, user intent is unclear and needs clarification, or extra info is needed to proceed.',
8
- 'Blocks until the user responds; the user can pick a preset option or choose "custom input" to answer freely; the result is returned as the tool result.',
9
- 'Do not call frequently when the task is clear and you can decide yourself — it interrupts the user. When options is omitted or empty, it becomes free-text input instead.',
6
+ 'Ask the user for input at a decision point (blocks until they respond).',
7
+ ' Use when: multiple approaches need a user decision, intent is unclear, or extra info is needed.',
8
+ ' Don\'t call when the task is clear and you can decide — it interrupts the user.',
9
+ ' Options (2-6) let the user pick; omit/empty for free-text input. Their answer is returned as the result.',
10
10
  ].join(''),
11
11
  parameters: {
12
12
  type: 'object',
@@ -50,9 +50,11 @@ function runCodegraph(args) {
50
50
  }
51
51
  export const codegraphTool = {
52
52
  name: 'codegraph',
53
- description: 'Preferred tool for understanding/locating code, tracing call chains, and assessing the impact of changes in repos with a code index (.codegraph/).' +
54
- ' Returns relevant symbol source + call paths in one shot — more accurate and economical than piecing together via read_file/grep.' +
55
- ' Returns a hint when .codegraph/ is absent (build it first with `codegraph init`).',
53
+ description: 'FIRST CHOICE for understanding/locating code, tracing call chains, or assessing the impact of changes — use this before read_file/grep when a .codegraph/ index exists.' +
54
+ ' Returns symbol source + call paths in one shot — more accurate and economical than piecing together read_file/grep.' +
55
+ ' When to use: starting any code exploration; locating a symbol; understanding how a feature works; seeing what calls a function or what a change affects.' +
56
+ ' When NOT to use: no .codegraph/ index (build it first with `codegraph init`); reading a file you just edited; editing a single known small file — in those cases go straight to read_file/edit_file.' +
57
+ ' Fallback: if codegraph misses or the result is incomplete, then use read_file/grep/glob to fill the gaps.',
56
58
  parameters: {
57
59
  type: 'object',
58
60
  properties: {
@@ -0,0 +1,68 @@
1
+ // ---------- drop_context ----------
2
+ /**
3
+ * 运行中上下文剔除工具:agent 检索到大量无关信息后,主动把历史里无关的 tool 结果替换为存根,
4
+ * 释放上下文空间(保 tool_call_id 配对不变量,只改 content)。
5
+ *
6
+ * 与 compact 的区别:compact 是阈值触发的整体压缩(微截 + 摘要);drop_context 是 agent 主动、
7
+ * 精准剔除"已判定无关"的具体 tool 结果。
8
+ *
9
+ * 保护:history[0](system)与当前轮(最后一个 user 及其之后)永不剔除——agent 还在用。
10
+ * 已是存根的不重复剔除(幂等)。永不抛错。
11
+ *
12
+ * 筛选(各维度 AND 组合,全部可选;不传 = 剔除所有可剔除的旧 tool 结果):
13
+ * - toolNames:只剔除这些工具名的结果(如 ["grep","read_file"])
14
+ * - contains:只剔除内容包含所有这些词(AND、大小写不敏感)的结果
15
+ *
16
+ * plan 模式不禁用:纯上下文管理,无文件 / 命令副作用。
17
+ */
18
+ export const dropContextTool = {
19
+ name: 'drop_context',
20
+ description: [
21
+ 'Drop (stub-replace) irrelevant OLDER tool results from history to free context.',
22
+ 'COST-AWARE: ~300-token round-trip; only call if freed tokens clearly exceed it — i.e. MULTIPLE bulky results (e.g. a wide grep/read sweep of mostly-irrelevant hits), not a single small one or near done.',
23
+ 'Never dropped: system prompt and the CURRENT turn (last user message onward). Idempotent. Filters AND-combine; omit both = drop all droppable. Returns dropped count, freed tokens, tool names.',
24
+ ].join(' '),
25
+ parameters: {
26
+ type: 'object',
27
+ properties: {
28
+ toolNames: {
29
+ type: 'array',
30
+ items: { type: 'string' },
31
+ description: 'Only drop results from these tool names (e.g. ["grep","read_file"]). Empty/omitted = no tool-name filter.',
32
+ },
33
+ contains: {
34
+ type: 'array',
35
+ items: { type: 'string' },
36
+ description: 'Only drop results whose content contains ALL of these keywords (AND, case-insensitive). Empty/omitted = no content filter.',
37
+ },
38
+ },
39
+ required: [],
40
+ },
41
+ async execute(args, ctx) {
42
+ const dropContext = ctx?.dropContext;
43
+ if (!dropContext) {
44
+ // 无注入(理论上不会:runAgentCore 总注入)。降级:不改 history,告知 agent。
45
+ return '错误:上下文剔除回调不可用(未由 agent 循环注入),无法剔除。';
46
+ }
47
+ const filter = {};
48
+ if (Array.isArray(args.toolNames)) {
49
+ filter.toolNames = args.toolNames
50
+ .filter((v) => typeof v === 'string' && v.length > 0)
51
+ .map((v) => String(v));
52
+ }
53
+ if (Array.isArray(args.contains)) {
54
+ filter.contains = args.contains
55
+ .filter((v) => typeof v === 'string' && v.length > 0)
56
+ .map((v) => String(v));
57
+ }
58
+ const result = dropContext(filter);
59
+ return result.dropped === 0
60
+ ? '未剔除任何工具结果(无匹配的旧 tool 消息,或均在当前轮保护区内不可剔除)。'
61
+ : [
62
+ `已剔除 ${result.dropped} 条无关工具结果,释放约 ${result.freedTokens} tokens。`,
63
+ '被剔除项(已替换为存根,tool_call_id 配对不变):',
64
+ ...result.items.map((it) => ` - ${it.toolName} (id …${it.toolCallId})`),
65
+ '这些结果在后续上下文中仅保留存根标记,不再占用篇幅。',
66
+ ].join('\n');
67
+ },
68
+ };
@@ -3,7 +3,7 @@ import { resolve } from 'node:path';
3
3
  // ---------- edit_file ----------
4
4
  export const editFileTool = {
5
5
  name: 'edit_file',
6
- description: 'Make a precise string replacement in a file. old_string must occur exactly once in the file and match exactly (including indentation/newlines). Use write_file for new files.',
6
+ description: 'Replace a string in a file. old_string must occur exactly once and match exactly (including indentation/newlines). Use write_file for new files.',
7
7
  parameters: {
8
8
  type: 'object',
9
9
  properties: {
@@ -4,8 +4,8 @@ import { getSandboxRoot, isInsideRoot } from '../../sandbox/index.js';
4
4
  // ---------- glob ----------
5
5
  export const globTool = {
6
6
  name: 'glob',
7
- description: 'Find file paths matching a glob pattern (e.g. **/*.ts). Returns a list of matches (auto-excludes node_modules / .git).' +
8
- ' Note: when understanding code architecture/call chains, if a .codegraph/ index exists, prefer the codegraph tool over piecing together via glob one file at a time.',
7
+ description: 'Find files matching a glob pattern (e.g. **/*.ts). Auto-excludes node_modules/.git.' +
8
+ ' For architecture/call chains, prefer codegraph.',
9
9
  parameters: {
10
10
  type: 'object',
11
11
  properties: {
@@ -5,8 +5,8 @@ import { getSandboxRoot, isInsideRoot, jailResolve } from '../../sandbox/index.j
5
5
  // ---------- grep ----------
6
6
  export const grepTool = {
7
7
  name: 'grep',
8
- description: 'Search file contents by regex, returning file:line: matched lines. Recursively searches the current directory by default (excluding node_modules/.git). Optional glob to restrict file types.' +
9
- ' Note: when understanding code architecture/call chains, if a .codegraph/ index exists, prefer the codegraph tool over piecing together via grep one file at a time.',
8
+ description: 'Search file contents by regex, returning file:line: matched lines. Recursively searches cwd excluding node_modules/.git. Optional glob restricts file types.' +
9
+ ' For architecture/call chains, prefer codegraph.',
10
10
  parameters: {
11
11
  type: 'object',
12
12
  properties: {
@@ -10,6 +10,7 @@ import { useSkillTool } from './use-skill.js';
10
10
  import { askHumanTool } from './ask-human.js';
11
11
  import { codegraphTool } from './codegraph.js';
12
12
  import { switchModeTool } from './switch-mode.js';
13
+ import { dropContextTool } from './drop-context.js';
13
14
  import { memorySaveTool } from './memory-save.js';
14
15
  import { memorySearchTool } from './memory-search.js';
15
16
  import { memoryListTool } from './memory-list.js';
@@ -33,6 +34,7 @@ export const builtinTools = [
33
34
  useSkillTool,
34
35
  askHumanTool,
35
36
  switchModeTool, // plan↔auto 自切(两模式都可见,不进 PLAN_DISABLED_TOOLS;副作用控制工具→串行分支)
37
+ dropContextTool, // 运行中剔除无关 tool 结果(上下文管理,无副作用;两模式都可见,串行分支)
36
38
  memorySaveTool,
37
39
  memorySearchTool,
38
40
  memoryListTool,
@@ -3,7 +3,7 @@ import { forgetEntry } from '../../memory/store.js';
3
3
  // 遗忘:默认归档(archived,从索引/默认搜索隐藏,可复活);mode=delete 硬删。pinned 拒删。
4
4
  export const memoryForgetTool = {
5
5
  name: 'memory_forget',
6
- description: 'Forget a memory entry: by default archives (archived, hidden from index and default search, can be revived via memory_update); mode=delete hard-deletes. Pinned entries cannot be deleted (first memory_update pinned=false to unpin).',
6
+ description: 'Forget a memory entry: default archive (hidden, revivable via memory_update); mode=delete hard-deletes. Pinned entries can\'t be deleted (unpin first).',
7
7
  parameters: {
8
8
  type: 'object',
9
9
  properties: {
@@ -3,7 +3,7 @@ import { listEntries } from '../../memory/store.js';
3
3
  // 列索引(id/name/summary,无正文、不 bump recall)。用于浏览有哪些、拿 id 再 memory_search 取正文。
4
4
  export const memoryListTool = {
5
5
  name: 'memory_list',
6
- description: 'List the memory index (id/name/summary, no body). Defaults to active. Get an id, then use memory_search to retrieve the full body.',
6
+ description: 'List the memory index (id/name/summary, no body). Get an id, then use memory_search for the full body.',
7
7
  parameters: {
8
8
  type: 'object',
9
9
  properties: {
@@ -4,7 +4,7 @@ import { saveEntry } from '../../memory/store.js';
4
4
  // 撞库(name→id 已存在)拒绝,引导用 memory_update。
5
5
  export const memorySaveTool = {
6
6
  name: 'memory_save',
7
- description: 'Save a long-term memory entry (cross-session). Store only non-obvious, long-term-useful facts/decisions/pitfalls. The title goes into the startup index; retrieve full body on demand via memory_search.',
7
+ description: 'Save a cross-session long-term memory entry. Store only non-obvious, useful facts/decisions/pitfalls. Title enters the startup index; retrieve body via memory_search.',
8
8
  parameters: {
9
9
  type: 'object',
10
10
  properties: {
@@ -4,7 +4,7 @@ import { searchEntries } from '../../memory/store.js';
4
4
  // 结果走 capToolResultForHistory 的放宽上限(同 use_skill,保正文完整)。
5
5
  export const memorySearchTool = {
6
6
  name: 'memory_search',
7
- description: 'Search memory entries by keyword (multi-word substring match, ranked by relevance), returning full body of matching entries. A hit bumps recall count (affects forgetting decay).',
7
+ description: 'Search memory entries by keyword (substring match), returning full body.',
8
8
  parameters: {
9
9
  type: 'object',
10
10
  properties: {
@@ -3,7 +3,7 @@ import { updateEntry } from '../../memory/store.js';
3
3
  // 原地改一条记忆(id 不变)。反思的弱意义:干活时发现事实变了/过时即纠正。
4
4
  export const memoryUpdateTool = {
5
5
  name: 'memory_update',
6
- description: 'Update a memory entry in place (id unchanged). Use when facts changed / correcting outdated info / updating summary or body / toggling pinned. Get id from memory_list or memory_search.',
6
+ description: 'Update a memory entry in place (id unchanged). Use when facts changed / correcting outdated info / toggling pinned. Get id from memory_list or memory_search.',
7
7
  parameters: {
8
8
  type: 'object',
9
9
  properties: {