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.
- package/README.md +65 -52
- package/dist/agent/core.js +8 -4
- package/dist/agent/index.js +5 -10
- package/dist/commands/config.js +1 -1
- package/dist/config/file.js +1 -1
- package/dist/config/index.js +56 -13
- package/dist/context/classifier.js +2 -1
- package/dist/index.js +2 -1
- package/dist/llm/index.js +13 -1
- package/dist/repl/index.js +204 -15
- package/dist/session/drop.js +135 -0
- package/dist/session/index.js +1 -0
- package/dist/tools/builtins/ask-human.js +4 -4
- package/dist/tools/builtins/codegraph.js +5 -3
- package/dist/tools/builtins/drop-context.js +68 -0
- package/dist/tools/builtins/edit-file.js +1 -1
- package/dist/tools/builtins/glob.js +2 -2
- package/dist/tools/builtins/grep.js +2 -2
- package/dist/tools/builtins/index.js +2 -0
- package/dist/tools/builtins/memory-forget.js +1 -1
- package/dist/tools/builtins/memory-list.js +1 -1
- package/dist/tools/builtins/memory-save.js +1 -1
- package/dist/tools/builtins/memory-search.js +1 -1
- package/dist/tools/builtins/memory-update.js +1 -1
- package/dist/tools/builtins/read-file.js +2 -2
- package/dist/tools/builtins/run-command.js +1 -1
- package/dist/tools/builtins/switch-mode.js +3 -5
- package/dist/tools/builtins/task.js +4 -5
- package/dist/tools/builtins/use-skill.js +1 -1
- package/dist/tools/builtins/web-fetch.js +1 -1
- package/dist/tools/builtins/web-search.js +1 -1
- package/dist/tools/builtins/write-file.js +1 -1
- package/dist/tools/registry.js +6 -1
- package/dist/ui/intervention.js +0 -11
- package/dist/ui/layout.js +173 -91
- package/dist/ui/prompt.js +2 -14
- package/package.json +40 -40
package/dist/repl/index.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
//
|
|
167
|
-
//
|
|
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 };
|
package/dist/session/index.js
CHANGED
|
@@ -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
|
-
'
|
|
7
|
-
'Use when: multiple
|
|
8
|
-
'
|
|
9
|
-
'
|
|
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: '
|
|
54
|
-
' Returns
|
|
55
|
-
'
|
|
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: '
|
|
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
|
|
8
|
-
'
|
|
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
|
|
9
|
-
'
|
|
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:
|
|
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).
|
|
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
|
|
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 (
|
|
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 /
|
|
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: {
|