mocode-ai 1.2.4 → 1.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/README.zh-CN.md +4 -4
- package/dist/agent/core.js +9 -9
- package/dist/config/index.js +78 -13
- package/dist/context/classifier.js +1 -0
- package/dist/context/encoders/table.js +1 -1
- package/dist/i18n/index.js +4 -4
- package/dist/llm/index.js +0 -1
- package/dist/memory/discover.js +8 -8
- package/dist/memory/graph.js +409 -0
- package/dist/memory/index.js +5 -3
- package/dist/memory/reflect.js +25 -6
- package/dist/repl/index.js +14 -14
- package/dist/sandbox/policy.js +1 -1
- package/dist/session/notes.js +233 -0
- package/dist/skills/runner.js +0 -1
- package/dist/tools/builtins/index.js +9 -1
- package/dist/tools/builtins/memory-graph.js +118 -0
- package/dist/tools/builtins/memory-save.js +42 -5
- package/dist/tools/builtins/memory-search.js +26 -5
- package/dist/tools/builtins/note-append.js +103 -0
- package/dist/tools/constants.js +6 -0
- package/dist/tools/registry.js +13 -4
- package/dist/ui/diff.js +1 -1
- package/dist/ui/layout.js +3 -3
- package/dist/ui/render.js +7 -1
- package/dist/ui/theme.js +11 -11
- package/package.json +3 -2
package/dist/session/notes.js
CHANGED
|
@@ -105,3 +105,236 @@ export function writePlanToNotes(plan, sessionId = getCurrentSessionId()) {
|
|
|
105
105
|
}
|
|
106
106
|
return { path: p, settled: plan.steps.length > 0 && plan.steps.every((s) => s.status === 'completed') };
|
|
107
107
|
}
|
|
108
|
+
// ── Session notes(单会话永久记忆):note_append 写入,reinject 常驻 system ──────
|
|
109
|
+
// 设计:notes.md 不止放 Plan。note_append 往预设笔记段追加一条 finding/decision/
|
|
110
|
+
// open_question/risk;extractActiveNotesSections 读出活跃笔记段正文(排除 Plan/Done),
|
|
111
|
+
// 按 5k token 预算裁剪后由 reinjectSessionStateIntoSystem 注入 system prompt——compact
|
|
112
|
+
// 后仍能恢复,让 agent 始终记得本会话做过什么、发现过什么(单会话永久记忆)。
|
|
113
|
+
/** note_append 接受的预设段 key → 渲染标题。 */
|
|
114
|
+
const NOTE_SECTION_TITLES = {
|
|
115
|
+
findings: 'Findings',
|
|
116
|
+
decisions: 'Decisions',
|
|
117
|
+
open_questions: 'Open Questions',
|
|
118
|
+
risks: 'Risks',
|
|
119
|
+
};
|
|
120
|
+
/** 预设段 key 列表(供工具 schema enum 与校验用)。 */
|
|
121
|
+
export const NOTE_SECTION_KEYS = Object.keys(NOTE_SECTION_TITLES);
|
|
122
|
+
/** 段注入优先级:数值越大越先占预算、越后丢弃正文。Risks 最重要。 */
|
|
123
|
+
const SECTION_PRIORITY = {
|
|
124
|
+
risks: 4, findings: 3, decisions: 2, open_questions: 1,
|
|
125
|
+
};
|
|
126
|
+
/** 常驻笔记正文总预算(token)。5k:占百万级 context 的 0.5%,可常驻相当量笔记。 */
|
|
127
|
+
const NOTES_INJECT_BUDGET_TOKENS = 5000;
|
|
128
|
+
/** 单段正文上限(token):防单段独占预算。 */
|
|
129
|
+
const NOTES_PER_SECTION_TOKENS = 2000;
|
|
130
|
+
/** 单条笔记上限(token):防一条过长吃掉整段预算。 */
|
|
131
|
+
const NOTES_PER_ENTRY_TOKENS = 800;
|
|
132
|
+
/**
|
|
133
|
+
* 轻量 token 估算(启发式,不依赖 tokenizer):CJK ≈ 0.6 token/字,
|
|
134
|
+
* ASCII ≈ 0.25 token/字。对 GLM/DeepSeek/Qwen 等中文偏多的后端略偏保守
|
|
135
|
+
* (估算略高于实际 → 注入实际 token 略低于预算 → 安全侧)。仅供笔记 cap 用,
|
|
136
|
+
* 不替换 llm/estimatePromptTokens 的主口径。
|
|
137
|
+
*/
|
|
138
|
+
function estimateTokens(text) {
|
|
139
|
+
let cjk = 0;
|
|
140
|
+
let other = 0;
|
|
141
|
+
for (let i = 0; i < text.length; i++) {
|
|
142
|
+
const c = text.charCodeAt(i);
|
|
143
|
+
if ((c >= 0x3000 && c <= 0x9fff) || (c >= 0xff00 && c <= 0xffef))
|
|
144
|
+
cjk++;
|
|
145
|
+
else
|
|
146
|
+
other++;
|
|
147
|
+
}
|
|
148
|
+
return Math.ceil(cjk * 0.6 + other * 0.25);
|
|
149
|
+
}
|
|
150
|
+
/** 把标题映射回预设 key(非预设段返 '',优先级 0,最后注入)。 */
|
|
151
|
+
function matchSectionKey(title) {
|
|
152
|
+
const t = title.trim().toLowerCase();
|
|
153
|
+
for (const [k, v] of Object.entries(NOTE_SECTION_TITLES)) {
|
|
154
|
+
if (v.toLowerCase() === t)
|
|
155
|
+
return k;
|
|
156
|
+
}
|
|
157
|
+
return '';
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* 往 notes.md 的指定笔记段追加一条。段存在则在其末尾追加(保留其它段不动);
|
|
161
|
+
* 段不存在则在文件末新建。返回 { path } 或 { error }。
|
|
162
|
+
*/
|
|
163
|
+
export function appendNoteToSection(section, entry, tag, sessionId = getCurrentSessionId()) {
|
|
164
|
+
const title = NOTE_SECTION_TITLES[section];
|
|
165
|
+
if (!title)
|
|
166
|
+
return { error: `unknown note section "${section}"` };
|
|
167
|
+
const p = getNotesFilePath(sessionId);
|
|
168
|
+
if (!p)
|
|
169
|
+
return { error: 'no active session' };
|
|
170
|
+
const line = tag ? `- **[${tag}]** ${entry}` : `- ${entry}`;
|
|
171
|
+
let existing = '';
|
|
172
|
+
try {
|
|
173
|
+
existing = fs.readFileSync(p, 'utf8').replace(/\r\n?/g, '\n');
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
existing = '';
|
|
177
|
+
}
|
|
178
|
+
const header = `## ${title}`;
|
|
179
|
+
const lines = existing.split('\n');
|
|
180
|
+
const start = lines.findIndex((l) => l.trim() === header);
|
|
181
|
+
let next;
|
|
182
|
+
if (start >= 0) {
|
|
183
|
+
// 段末 = 下一个 ## 或文件末
|
|
184
|
+
let end = lines.length;
|
|
185
|
+
for (let k = start + 1; k < lines.length; k++) {
|
|
186
|
+
if (/^##\s/.test(lines[k])) {
|
|
187
|
+
end = k;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const before = lines.slice(0, start).join('\n').replace(/\s+$/, '');
|
|
192
|
+
const sectionLines = lines.slice(start, end);
|
|
193
|
+
// 去段尾空行后追加新条目
|
|
194
|
+
while (sectionLines.length && sectionLines[sectionLines.length - 1].trim() === '')
|
|
195
|
+
sectionLines.pop();
|
|
196
|
+
sectionLines.push(line);
|
|
197
|
+
const section = sectionLines.join('\n');
|
|
198
|
+
const after = lines.slice(end).join('\n').replace(/^\s+/, '');
|
|
199
|
+
next = [before, section, after].filter((s) => s.length > 0).join('\n\n') + '\n';
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
// 新建段:放文件末,与已有内容以空行分隔
|
|
203
|
+
const rest = existing.trim();
|
|
204
|
+
const newSection = `${header}\n${line}`;
|
|
205
|
+
next = rest ? `${rest}\n\n${newSection}\n` : `${newSection}\n`;
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
209
|
+
fs.writeFileSync(p, next, 'utf8');
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
213
|
+
}
|
|
214
|
+
return { path: p };
|
|
215
|
+
}
|
|
216
|
+
/** 按字符二分截断条目到 token 上限,加省略标记。条目不长,线性二分足够。 */
|
|
217
|
+
function truncateEntry(entry, maxTokens) {
|
|
218
|
+
if (estimateTokens(entry) <= maxTokens)
|
|
219
|
+
return entry;
|
|
220
|
+
let lo = 0;
|
|
221
|
+
let hi = entry.length;
|
|
222
|
+
while (lo < hi) {
|
|
223
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
224
|
+
if (estimateTokens(entry.slice(0, mid)) <= maxTokens)
|
|
225
|
+
lo = mid;
|
|
226
|
+
else
|
|
227
|
+
hi = mid - 1;
|
|
228
|
+
}
|
|
229
|
+
return entry.slice(0, lo).replace(/\s+$/, '') + ' …[truncated]';
|
|
230
|
+
}
|
|
231
|
+
/** 把单段正文按条目分割,从末尾(最近)保留,丢最旧,裁到 token 预算内。 */
|
|
232
|
+
function trimSectionToBudget(body, budgetTokens) {
|
|
233
|
+
if (budgetTokens <= 0)
|
|
234
|
+
return null;
|
|
235
|
+
const bodyLines = body.split('\n');
|
|
236
|
+
const header = bodyLines[0] ?? '';
|
|
237
|
+
const rest = bodyLines.slice(1);
|
|
238
|
+
// 分条目:以 "- " 开头为一条起始,后续非 "- " 行归入该条
|
|
239
|
+
const entries = [];
|
|
240
|
+
let cur = [];
|
|
241
|
+
for (const ln of rest) {
|
|
242
|
+
if (/^-\s+/.test(ln)) {
|
|
243
|
+
if (cur.length)
|
|
244
|
+
entries.push(cur.join('\n'));
|
|
245
|
+
cur = [ln];
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
cur.push(ln);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (cur.length)
|
|
252
|
+
entries.push(cur.join('\n'));
|
|
253
|
+
const kept = [];
|
|
254
|
+
let used = estimateTokens(header);
|
|
255
|
+
for (let k = entries.length - 1; k >= 0; k--) {
|
|
256
|
+
let e = entries[k];
|
|
257
|
+
if (estimateTokens(e) > NOTES_PER_ENTRY_TOKENS) {
|
|
258
|
+
e = truncateEntry(e, NOTES_PER_ENTRY_TOKENS);
|
|
259
|
+
}
|
|
260
|
+
const t = estimateTokens(e);
|
|
261
|
+
if (used + t > budgetTokens)
|
|
262
|
+
break;
|
|
263
|
+
kept.unshift(e);
|
|
264
|
+
used += t;
|
|
265
|
+
}
|
|
266
|
+
if (kept.length === 0)
|
|
267
|
+
return null;
|
|
268
|
+
return `${header}\n${kept.join('\n')}`;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* 读 notes.md,提取所有活跃笔记段正文(排除 `## Plan:` 与 `## Done:`),
|
|
272
|
+
* 按 NOTES_INJECT_BUDGET_TOKENS 裁剪后返回——供 reinject 注入 system prompt。
|
|
273
|
+
* 裁剪策略:段按优先级排序(Risks>Findings>Decisions>Open Questions>自定义),
|
|
274
|
+
* 逐段注入累计 token;单段超 per-section 则段内从最近条目保留丢最旧;
|
|
275
|
+
* 总预算用尽则后续段不注入正文(其标题仍由 buildNotepadSection 索引常驻,
|
|
276
|
+
* agent 可 read_file 取细节)。这样 5k 预算内"写了就常驻",超出降级为索引+按需 read。
|
|
277
|
+
*/
|
|
278
|
+
export function extractActiveNotesSections(budget = NOTES_INJECT_BUDGET_TOKENS, sessionId = getCurrentSessionId()) {
|
|
279
|
+
const p = getNotesFilePath(sessionId);
|
|
280
|
+
if (!p)
|
|
281
|
+
return '';
|
|
282
|
+
let content = '';
|
|
283
|
+
try {
|
|
284
|
+
content = fs.readFileSync(p, 'utf8').replace(/\r\n?/g, '\n');
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return '';
|
|
288
|
+
}
|
|
289
|
+
const lines = content.split('\n');
|
|
290
|
+
const sections = [];
|
|
291
|
+
let i = 0;
|
|
292
|
+
while (i < lines.length) {
|
|
293
|
+
const m = lines[i].match(/^##\s+(.+?)\s*$/);
|
|
294
|
+
if (!m) {
|
|
295
|
+
i++;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const title = m[1];
|
|
299
|
+
// 跳过 Plan/Done 段(Plan 有专属 ACTIVE_PLAN_MARKER 重注入;Done 是归档不常驻)
|
|
300
|
+
if (/^Plan:/.test(title) || /^Done:/.test(title)) {
|
|
301
|
+
i++;
|
|
302
|
+
while (i < lines.length && !/^##\s/.test(lines[i]))
|
|
303
|
+
i++;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const start = i;
|
|
307
|
+
i++;
|
|
308
|
+
while (i < lines.length && !/^##\s/.test(lines[i]))
|
|
309
|
+
i++;
|
|
310
|
+
const body = lines.slice(start, i).join('\n').trim();
|
|
311
|
+
if (body)
|
|
312
|
+
sections.push({ key: matchSectionKey(title), body });
|
|
313
|
+
}
|
|
314
|
+
// 按优先级降序(优先级高的先占预算)
|
|
315
|
+
sections.sort((a, b) => (SECTION_PRIORITY[b.key] ?? 0) - (SECTION_PRIORITY[a.key] ?? 0));
|
|
316
|
+
let used = 0;
|
|
317
|
+
const out = [];
|
|
318
|
+
for (const s of sections) {
|
|
319
|
+
const remaining = budget - used;
|
|
320
|
+
if (remaining <= 0)
|
|
321
|
+
break;
|
|
322
|
+
const bodyTokens = estimateTokens(s.body);
|
|
323
|
+
if (bodyTokens <= Math.min(remaining, NOTES_PER_SECTION_TOKENS)) {
|
|
324
|
+
out.push(s.body);
|
|
325
|
+
used += bodyTokens;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
// 段超预算:段内裁条目(从最近保留)
|
|
329
|
+
const cap = Math.min(remaining, NOTES_PER_SECTION_TOKENS);
|
|
330
|
+
const trimmed = trimSectionToBudget(s.body, cap);
|
|
331
|
+
if (trimmed) {
|
|
332
|
+
out.push(trimmed);
|
|
333
|
+
used += estimateTokens(trimmed);
|
|
334
|
+
}
|
|
335
|
+
// 预算用尽则后续段不再注入正文(降级为索引)
|
|
336
|
+
if (used >= budget)
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
return out.join('\n\n');
|
|
340
|
+
}
|
package/dist/skills/runner.js
CHANGED
|
@@ -144,7 +144,6 @@ const WRITE_TOOLS = new Set(['write_file', 'edit_file', 'run_command']);
|
|
|
144
144
|
* fork 子 agent 模式:`agent:` 显式声明优先;否则按工具面推断——
|
|
145
145
|
* 未声明 allowed-tools(完整工具集)或白名单含写工具 → 'write',纯只读白名单 → 'read'。
|
|
146
146
|
* 避免写类 skill 因缺省字段被静默降级为只读。
|
|
147
|
-
* 导出仅供 scripts/check-skills.ts 离线断言。
|
|
148
147
|
*/
|
|
149
148
|
export function resolveSpawnMode(skill, tools) {
|
|
150
149
|
if (skill.agentMode)
|
|
@@ -14,17 +14,19 @@ import { useSkillTool } from './use-skill.js';
|
|
|
14
14
|
import { runSkillTool } from './run-skill.js';
|
|
15
15
|
import { askHumanTool } from './ask-human.js';
|
|
16
16
|
import { planUpdateTool } from './plan-update.js';
|
|
17
|
+
import { noteAppendTool } from './note-append.js';
|
|
17
18
|
import { memorySaveTool } from './memory-save.js';
|
|
18
19
|
import { memorySearchTool } from './memory-search.js';
|
|
19
20
|
import { memoryListTool } from './memory-list.js';
|
|
20
21
|
import { memoryUpdateTool } from './memory-update.js';
|
|
21
22
|
import { memoryForgetTool } from './memory-forget.js';
|
|
23
|
+
import { memoryGraphTool } from './memory-graph.js';
|
|
22
24
|
import { subAgentTool } from './task.js';
|
|
23
25
|
/**
|
|
24
26
|
* 所有内置工具,按注册顺序排列。
|
|
25
27
|
* 加新工具:在本目录新建 `xxx.ts` 导出一个 Tool,再在下面数组里加一行。无需改 agent / llm。
|
|
26
28
|
*
|
|
27
|
-
* 记忆子系统总开关(MEMORY_ENABLED !== 'true'):
|
|
29
|
+
* 记忆子系统总开关(MEMORY_ENABLED !== 'true'):6 个 memory_* 工具整体不进 builtinTools,
|
|
28
30
|
* 进而不进 LLM 的工具表(模型根本看不到、也不会想着去调)。运行时通过 /memory_switch 切;
|
|
29
31
|
* 切换对当前会话的 tool list 不重算(取的是模块初始化时的快照),所以需要重启 REPL 才生效
|
|
30
32
|
* —— 这是有意为之,避免切开关瞬间把已发出请求的工具列表打乱。
|
|
@@ -41,6 +43,7 @@ const _memoryTools = _memoryEnabledAtBoot
|
|
|
41
43
|
memoryListTool,
|
|
42
44
|
memoryUpdateTool,
|
|
43
45
|
memoryForgetTool,
|
|
46
|
+
memoryGraphTool,
|
|
44
47
|
]
|
|
45
48
|
: [];
|
|
46
49
|
const pathResource = (args) => typeof args.path === 'string' && args.path ? [`file:${args.path}`] : ['workspace'];
|
|
@@ -66,11 +69,15 @@ const CAPABILITIES = {
|
|
|
66
69
|
// plan_update 只写内部 notes.md(session 工作面),不作为用户代码 mutation 追踪/回滚/diff;
|
|
67
70
|
// 串行即可(调用不频繁),固定资源键让并发调用排队。
|
|
68
71
|
plan_update: { effect: 'write', concurrency: 'serial', resources: () => ['session-notepad'] },
|
|
72
|
+
// note_append 与 plan_update 同款:只写内部 notes.md 笔记段,不作 project mutation 追踪/diff/回滚;串行 + 固定资源键排队。
|
|
73
|
+
note_append: { effect: 'write', concurrency: 'serial', resources: () => ['session-notepad'] },
|
|
69
74
|
memory_save: { effect: 'write', concurrency: 'serial', resources: memoryResource },
|
|
70
75
|
memory_search: { effect: 'write', concurrency: 'serial', resources: memoryResource },
|
|
71
76
|
memory_list: { effect: 'read', concurrency: 'serial', resources: memoryResource },
|
|
72
77
|
memory_update: { effect: 'write', concurrency: 'serial', resources: memoryResource },
|
|
73
78
|
memory_forget: { effect: 'write', concurrency: 'serial', resources: memoryResource },
|
|
79
|
+
// memory_graph:search/neighbors/stats 只读、add 写,统一按写处理走串行(调用不频繁,简化)。
|
|
80
|
+
memory_graph: { effect: 'write', concurrency: 'serial', resources: memoryResource },
|
|
74
81
|
// sub-agent 动态协调:只读任务无锁并行;写任务在 overlay 中执行,merge 时由 ChangeSet 持 canonical lock。
|
|
75
82
|
'sub-agent': {
|
|
76
83
|
effect: 'write',
|
|
@@ -99,6 +106,7 @@ const rawBuiltinTools = [
|
|
|
99
106
|
runSkillTool,
|
|
100
107
|
askHumanTool,
|
|
101
108
|
planUpdateTool,
|
|
109
|
+
noteAppendTool,
|
|
102
110
|
..._memoryTools,
|
|
103
111
|
subAgentTool,
|
|
104
112
|
];
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { addTriple, graphStats, neighborsOf, pathBetween, } from '../../memory/graph.js';
|
|
2
|
+
// ---------- memory_graph ----------
|
|
3
|
+
// 知识图谱维护工具:邻居遍历(1-3 跳,可 relation 过滤)、两点最短路径、手工加三元组、看图统计。
|
|
4
|
+
// 关键词搜索已并入 memory_search(条目 + 图谱事实一次返回),本工具不再提供 search。
|
|
5
|
+
// 底层 memory-graph.json(Graphiti 式时序边:新事实取代旧边时旧边置 invalidAt,不删)。
|
|
6
|
+
// neighbors/path/stats 只读;add 写。
|
|
7
|
+
function fmtEdges(edges) {
|
|
8
|
+
if (edges.length === 0)
|
|
9
|
+
return '(无边)';
|
|
10
|
+
return edges
|
|
11
|
+
.map((e) => `${e.src} --[${e.relation}]--> ${e.dst}${e.fact ? ` (${e.fact})` : ''}`)
|
|
12
|
+
.join('\n');
|
|
13
|
+
}
|
|
14
|
+
function fmtEntities(entities) {
|
|
15
|
+
if (entities.length === 0)
|
|
16
|
+
return '(无实体)';
|
|
17
|
+
return entities
|
|
18
|
+
.map((e) => `- ${e.id}: ${e.name}${e.summary ? ` — ${e.summary}` : ''} [${e.scope}]`)
|
|
19
|
+
.join('\n');
|
|
20
|
+
}
|
|
21
|
+
export const memoryGraphTool = {
|
|
22
|
+
name: 'memory_graph',
|
|
23
|
+
description: 'Maintain/explore the knowledge-graph memory layer (entities + temporal triples). Keyword search lives in memory_search. ' +
|
|
24
|
+
'action=neighbors: BFS 1-3 hops around an entity (optional relation filter to follow one edge type); ' +
|
|
25
|
+
'action=path: shortest path between two entities (bidirectional BFS, max 6 hops); ' +
|
|
26
|
+
'action=add: add a triple (src --relation--> dst); existing edges with same src/relation/dst are temporally invalidated, not deleted; ' +
|
|
27
|
+
'action=stats: graph size overview.',
|
|
28
|
+
parameters: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
action: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
enum: ['neighbors', 'path', 'add', 'stats'],
|
|
34
|
+
description: 'What to do',
|
|
35
|
+
},
|
|
36
|
+
query: { type: 'string', description: 'neighbors: entity name or id; path: start entity' },
|
|
37
|
+
depth: { type: 'integer', description: 'neighbors: hops, 1-3, default 1' },
|
|
38
|
+
src: { type: 'string', description: 'add: source entity name' },
|
|
39
|
+
relation: {
|
|
40
|
+
type: 'string',
|
|
41
|
+
description: 'add: relation in snake_case, e.g. depends_on / decided_by. neighbors: optional edge-type filter to follow only that relation',
|
|
42
|
+
},
|
|
43
|
+
dst: { type: 'string', description: 'add: target entity name; path: end entity' },
|
|
44
|
+
fact: { type: 'string', description: 'add: optional one-line statement for the edge' },
|
|
45
|
+
scope: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
enum: ['project', 'global'],
|
|
48
|
+
description: 'add: which graph file to write, default project',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
required: ['action'],
|
|
52
|
+
},
|
|
53
|
+
async execute(args) {
|
|
54
|
+
const action = String(args.action ?? '').trim();
|
|
55
|
+
if (action === 'stats') {
|
|
56
|
+
const s = graphStats();
|
|
57
|
+
return [
|
|
58
|
+
`实体 ${s.entities} 条边(active ${s.edgesActive} / 已失效 ${s.edgesInvalid})`,
|
|
59
|
+
`project: ${s.byScope.project.entities} 实体, ${s.byScope.project.edges} 边`,
|
|
60
|
+
`global: ${s.byScope.global.entities} 实体, ${s.byScope.global.edges} 边`,
|
|
61
|
+
].join('\n');
|
|
62
|
+
}
|
|
63
|
+
if (action === 'neighbors') {
|
|
64
|
+
const query = String(args.query ?? '').trim();
|
|
65
|
+
if (!query)
|
|
66
|
+
return '错误:neighbors 需要 query(实体名或 id)。';
|
|
67
|
+
const depth = typeof args.depth === 'number' ? args.depth : 1;
|
|
68
|
+
const rel = typeof args.relation === 'string' ? args.relation.trim() : '';
|
|
69
|
+
const r = neighborsOf(query, depth, rel || undefined);
|
|
70
|
+
if (!r.center)
|
|
71
|
+
return `(图中没有实体 "${query}")`;
|
|
72
|
+
const relNote = rel ? `,仅 ${rel} 边` : '';
|
|
73
|
+
const lines = [
|
|
74
|
+
`## ${r.center.name} (${r.center.id})${r.center.summary ? ` — ${r.center.summary}` : ''}`,
|
|
75
|
+
r.entities.length > 0 ? `\n## 邻居实体\n${fmtEntities(r.entities)}` : '',
|
|
76
|
+
`\n## 边(${r.edges.length}${relNote}${r.truncated ? ',已截断' : ''})\n${fmtEdges(r.edges)}`,
|
|
77
|
+
].filter(Boolean);
|
|
78
|
+
return lines.join('\n');
|
|
79
|
+
}
|
|
80
|
+
if (action === 'path') {
|
|
81
|
+
const from = String(args.query ?? '').trim();
|
|
82
|
+
const to = String(args.dst ?? '').trim();
|
|
83
|
+
if (!from || !to)
|
|
84
|
+
return '错误:path 需要 query(起点实体)和 dst(终点实体)。';
|
|
85
|
+
const r = pathBetween(from, to);
|
|
86
|
+
if (!r)
|
|
87
|
+
return `(无 active 路径:${from} ⇸ ${to},或端点实体不存在)`;
|
|
88
|
+
const chain = r.path.map((e) => e.name).join(' → ');
|
|
89
|
+
const lines = [
|
|
90
|
+
`## ${r.from.name} ⇢ ${r.to.name}(${r.edges.length} 跳)`,
|
|
91
|
+
`路径:${chain}`,
|
|
92
|
+
`\n## 边\n${fmtEdges(r.edges)}`,
|
|
93
|
+
];
|
|
94
|
+
return lines.join('\n');
|
|
95
|
+
}
|
|
96
|
+
if (action === 'add') {
|
|
97
|
+
const src = String(args.src ?? '').trim();
|
|
98
|
+
const relation = String(args.relation ?? '').trim();
|
|
99
|
+
const dst = String(args.dst ?? '').trim();
|
|
100
|
+
if (!src || !relation || !dst)
|
|
101
|
+
return '错误:add 需要 src、relation、dst。';
|
|
102
|
+
const r = addTriple({
|
|
103
|
+
src,
|
|
104
|
+
relation,
|
|
105
|
+
dst,
|
|
106
|
+
fact: typeof args.fact === 'string' ? args.fact : undefined,
|
|
107
|
+
scope: args.scope === 'global' ? 'global' : 'project',
|
|
108
|
+
});
|
|
109
|
+
if (!r.ok)
|
|
110
|
+
return `错误:三元组未写入 (${r.reason})。`;
|
|
111
|
+
if (r.duplicate)
|
|
112
|
+
return `已存在相同三元组 (${r.edgeId}),幂等跳过。`;
|
|
113
|
+
const sup = r.superseded > 0 ? `;旧边已时序失效(${r.superseded} 条)` : '';
|
|
114
|
+
return `已写入三元组 ${src} --[${relation.toLowerCase().replace(/\s+/g, '_')}]--> ${dst} [${r.edgeId}]${sup}。`;
|
|
115
|
+
}
|
|
116
|
+
return `错误:未知 action "${action}",可用 neighbors/path/add/stats(关键词搜索请用 memory_search)。`;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { saveEntry } from '../../memory/store.js';
|
|
2
|
+
import { addTriple } from '../../memory/graph.js';
|
|
2
3
|
// ---------- memory_save ----------
|
|
3
4
|
// 存一条长期记忆(跨会话)。启动只把标题/摘要注入索引(几百 token);详情按需 memory_search 取。
|
|
4
5
|
// 撞库(name→id 已存在)拒绝,引导用 memory_update。
|
|
6
|
+
// 可选 links:把本条记忆挂进知识图谱(memory-graph.json)——src 省略时默认以记忆 name 为主实体。
|
|
5
7
|
export const memorySaveTool = {
|
|
6
8
|
name: 'memory_save',
|
|
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.',
|
|
9
|
+
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. Optionally attach knowledge-graph links (triples) to relate this memory to entities.',
|
|
8
10
|
risk: 'confirm',
|
|
9
11
|
parameters: {
|
|
10
12
|
type: 'object',
|
|
@@ -23,6 +25,20 @@ export const memorySaveTool = {
|
|
|
23
25
|
enum: ['project', 'global'],
|
|
24
26
|
description: 'Store at project level (<cwd>/.mocode/) or global (~/.mocode/), default project',
|
|
25
27
|
},
|
|
28
|
+
links: {
|
|
29
|
+
type: 'array',
|
|
30
|
+
description: 'Optional knowledge-graph triples relating this memory to entities, e.g. [{"src":"mocode","relation":"depends_on","dst":"JSONL store"}]. src defaults to the memory name when omitted.',
|
|
31
|
+
items: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
src: { type: 'string', description: 'Source entity name (defaults to the memory name)' },
|
|
35
|
+
relation: { type: 'string', description: 'Relation, snake_case, e.g. depends_on / decided_by / conflicts_with' },
|
|
36
|
+
dst: { type: 'string', description: 'Target entity name' },
|
|
37
|
+
fact: { type: 'string', description: 'Optional one-line statement for the edge' },
|
|
38
|
+
},
|
|
39
|
+
required: ['relation', 'dst'],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
26
42
|
},
|
|
27
43
|
required: ['name', 'summary', 'body'],
|
|
28
44
|
},
|
|
@@ -37,16 +53,37 @@ export const memorySaveTool = {
|
|
|
37
53
|
if (!body)
|
|
38
54
|
return '错误:缺少 body。';
|
|
39
55
|
const type = typeof args.type === 'string' ? args.type : undefined;
|
|
56
|
+
const scope = args.scope === 'global' ? 'global' : 'project';
|
|
40
57
|
const r = saveEntry({
|
|
41
58
|
name,
|
|
42
59
|
summary,
|
|
43
60
|
body,
|
|
44
61
|
type,
|
|
45
62
|
pinned: args.pinned === true,
|
|
46
|
-
scope
|
|
63
|
+
scope,
|
|
47
64
|
});
|
|
48
|
-
if (r.ok)
|
|
49
|
-
return
|
|
50
|
-
|
|
65
|
+
if (!r.ok) {
|
|
66
|
+
return `已存在同名记忆 [${r.exists}]。改用 memory_update(id="${r.exists}", …) 更新,或换一个 name。`;
|
|
67
|
+
}
|
|
68
|
+
// 知识图谱挂边:容错——图失败不影响记忆保存结果
|
|
69
|
+
const links = Array.isArray(args.links) ? args.links : [];
|
|
70
|
+
let linked = 0;
|
|
71
|
+
for (const l of links) {
|
|
72
|
+
if (!l || typeof l !== 'object')
|
|
73
|
+
continue;
|
|
74
|
+
const link = l;
|
|
75
|
+
const tr = addTriple({
|
|
76
|
+
src: typeof link.src === 'string' && link.src.trim() ? link.src : name,
|
|
77
|
+
relation: typeof link.relation === 'string' ? link.relation : '',
|
|
78
|
+
dst: typeof link.dst === 'string' ? link.dst : '',
|
|
79
|
+
fact: typeof link.fact === 'string' ? link.fact : undefined,
|
|
80
|
+
sourceEntry: r.id,
|
|
81
|
+
scope,
|
|
82
|
+
});
|
|
83
|
+
if (tr.ok)
|
|
84
|
+
linked++;
|
|
85
|
+
}
|
|
86
|
+
const linkNote = links.length > 0 ? `;知识图谱挂边 ${linked}/${links.length}` : '';
|
|
87
|
+
return `已保存记忆 [${r.id}] "${name}"(下次启动进索引)${linkNote}。`;
|
|
51
88
|
},
|
|
52
89
|
};
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { searchEntries } from '../../memory/store.js';
|
|
2
|
+
import { searchGraph } from '../../memory/graph.js';
|
|
2
3
|
// ---------- memory_search ----------
|
|
3
|
-
//
|
|
4
|
+
// 唯一记忆搜索入口:关键词搜记忆正文(多词子串匹配,name 权重最高)+ 知识图谱事实段
|
|
5
|
+
// (命中实体的 active 边)。命中条目即 bump recallCount(遗忘衰减依据)。
|
|
4
6
|
// 结果走 capToolResultForHistory 的放宽上限(同 use_skill,保正文完整)。
|
|
7
|
+
const GRAPH_FACTS_LIMIT = 10;
|
|
5
8
|
export const memorySearchTool = {
|
|
6
9
|
name: 'memory_search',
|
|
7
|
-
description: 'Search memory entries by keyword (substring match), returning full body.',
|
|
10
|
+
description: 'Search memory entries by keyword (substring match), returning full body. Also surfaces knowledge-graph facts (active edges) for entities matching the query.',
|
|
8
11
|
parameters: {
|
|
9
12
|
type: 'object',
|
|
10
13
|
properties: {
|
|
@@ -33,10 +36,28 @@ export const memorySearchTool = {
|
|
|
33
36
|
: undefined,
|
|
34
37
|
limit: typeof args.limit === 'number' ? args.limit : undefined,
|
|
35
38
|
});
|
|
36
|
-
|
|
37
|
-
return `(无匹配记忆:query="${query}")`;
|
|
38
|
-
return r
|
|
39
|
+
const entryText = r
|
|
39
40
|
.map((e) => `# [${e.id}] ${e.name} (${e.type}, recalled ${e.recallCount})\nsummary: ${e.summary}\n\n${e.body}`)
|
|
40
41
|
.join('\n\n---\n\n');
|
|
42
|
+
// 知识图谱事实段:命中实体的 active 边(容错:图坏了不连累条目搜索)。
|
|
43
|
+
let graphText = '';
|
|
44
|
+
try {
|
|
45
|
+
const g = searchGraph(query, 8);
|
|
46
|
+
if (g.edges.length > 0) {
|
|
47
|
+
const lines = g.edges
|
|
48
|
+
.slice(0, GRAPH_FACTS_LIMIT)
|
|
49
|
+
.map((e) => `${e.src} --[${e.relation}]--> ${e.dst}${e.fact ? ` (${e.fact})` : ''}`);
|
|
50
|
+
const more = g.edges.length > GRAPH_FACTS_LIMIT ? `\n…(共 ${g.edges.length} 条,其余用 memory_graph action=neighbors 展开)` : '';
|
|
51
|
+
graphText = `\n\n## 知识图谱事实\n${lines.join('\n')}${more}`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// 静默:图谱段是增强,失败只降级为纯条目结果
|
|
56
|
+
}
|
|
57
|
+
if (!entryText && !graphText)
|
|
58
|
+
return `(无匹配记忆:query="${query}")`;
|
|
59
|
+
if (!entryText)
|
|
60
|
+
return `(无匹配记忆条目,但图谱有命中)\n${graphText.trimStart()}`;
|
|
61
|
+
return entryText + graphText;
|
|
41
62
|
},
|
|
42
63
|
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { appendNoteToSection, NOTE_SECTION_KEYS } from '../../session/notes.js';
|
|
2
|
+
/**
|
|
3
|
+
* note_append:往会话笔记 notes.md 的预设笔记段追加一条 finding/decision/
|
|
4
|
+
* open_question/risk。与 plan_update 的边界:
|
|
5
|
+
* - plan_update 维护执行计划(步骤进度),写 `## Plan:` 段;
|
|
6
|
+
* - note_append 记发现/决策/问题/风险,写 `## Findings` 等笔记段。
|
|
7
|
+
* 写入的笔记正文会由 reinject 注入 system prompt 并常驻(5k token 预算内),
|
|
8
|
+
* compact 后仍可恢复——构成单会话永久记忆。与 memory_* 的边界:
|
|
9
|
+
* - note_append 记本会话内、抗 compact 的笔记;
|
|
10
|
+
* - memory_* 记跨会话稳定事实(另一系统,默认关)。
|
|
11
|
+
*
|
|
12
|
+
* 仿 plan_update:risk=safe,免权限/免 diff/免回滚;capabilities 由 builtins/index.ts
|
|
13
|
+
* 声明为 session-notepad 资源串行(与 plan_update 同款)。
|
|
14
|
+
*/
|
|
15
|
+
function err(message) {
|
|
16
|
+
return { status: 'error', code: 'INVALID_ARGUMENTS', retryable: false, output: `错误:${message}` };
|
|
17
|
+
}
|
|
18
|
+
/** 归一化 section:兼容单复数、下划线/空格/连字符、大小写偏差。 */
|
|
19
|
+
function normalizeSection(raw) {
|
|
20
|
+
const s = String(raw ?? '').trim().toLowerCase().replace(/[-\s]+/g, '_');
|
|
21
|
+
if (NOTE_SECTION_KEYS.includes(s))
|
|
22
|
+
return s;
|
|
23
|
+
// 单数/别名归一
|
|
24
|
+
if (['finding', 'find', 'insight', 'insights'].includes(s))
|
|
25
|
+
return 'findings';
|
|
26
|
+
if (['decision', 'decide', 'choice', 'choices'].includes(s))
|
|
27
|
+
return 'decisions';
|
|
28
|
+
if (['open_question', 'question', 'questions', 'openquestion', 'openquestions'].includes(s))
|
|
29
|
+
return 'open_questions';
|
|
30
|
+
if (['risk', 'hazard', 'caveat', 'caveats'].includes(s))
|
|
31
|
+
return 'risks';
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
export const noteAppendTool = {
|
|
35
|
+
name: 'note_append',
|
|
36
|
+
description: 'Append a decision-grade note (a finding, decision, open question, or risk) to the session notepad ' +
|
|
37
|
+
'(`.mocode/sessions/<id>/notes.md`) so it survives context compaction and stays resident in the prompt. ' +
|
|
38
|
+
'Use this for NON-OBVIOUS, lasting-value discoveries — subtle constraints, decisions with downstream impact, ' +
|
|
39
|
+
'open questions that block a choice, or risks that affect later steps. Do NOT use it for routine progress ' +
|
|
40
|
+
'(that is the plan via `plan_update`) or for stable cross-session facts (that is `memory_save`). ' +
|
|
41
|
+
'Notes you write here persist across compaction within this session and are re-injected into the prompt ' +
|
|
42
|
+
'automatically, so the agent keeps remembering what it found/decided. Call it the moment you make the ' +
|
|
43
|
+
'discovery or decision — do not batch to the end.',
|
|
44
|
+
risk: 'safe',
|
|
45
|
+
parameters: {
|
|
46
|
+
type: 'object',
|
|
47
|
+
properties: {
|
|
48
|
+
section: {
|
|
49
|
+
type: 'string',
|
|
50
|
+
enum: NOTE_SECTION_KEYS,
|
|
51
|
+
description: 'Note category: findings (a non-obvious discovery/constraint), decisions (a choice with ' +
|
|
52
|
+
'lasting impact), open_questions (a blocker needing resolution), risks (a hazard affecting later work).',
|
|
53
|
+
},
|
|
54
|
+
entry: {
|
|
55
|
+
type: 'string',
|
|
56
|
+
description: 'The note text. One concise, self-contained item: what was found/decided and why it matters. ' +
|
|
57
|
+
'Keep each call to one item — call again for a second item.',
|
|
58
|
+
},
|
|
59
|
+
tag: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
description: 'Optional short label for grouping (e.g. "parser-bug", "api-shape"). Rendered as **[tag]**.',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
required: ['section', 'entry'],
|
|
65
|
+
additionalProperties: false,
|
|
66
|
+
},
|
|
67
|
+
// 兼容模型的 section 命名偏差:单复数/分隔符/别名归一到预设 key。
|
|
68
|
+
normalizeArguments(args) {
|
|
69
|
+
const s = normalizeSection(args.section);
|
|
70
|
+
if (s)
|
|
71
|
+
args.section = s;
|
|
72
|
+
if (typeof args.tag === 'string')
|
|
73
|
+
args.tag = args.tag.trim();
|
|
74
|
+
},
|
|
75
|
+
async execute(args) {
|
|
76
|
+
const section = normalizeSection(args.section);
|
|
77
|
+
if (!section) {
|
|
78
|
+
return err(`section 非法:"${String(args.section ?? '')}"(仅 ${NOTE_SECTION_KEYS.join('/')} 或常见别名)。`);
|
|
79
|
+
}
|
|
80
|
+
const entry = String(args.entry ?? '').trim();
|
|
81
|
+
if (!entry)
|
|
82
|
+
return err('entry 不能为空。');
|
|
83
|
+
if (entry.length > 2000) {
|
|
84
|
+
return err(`entry 过长(${entry.length} 字符,上限 2000)——拆成多条 note_append 或精简。`);
|
|
85
|
+
}
|
|
86
|
+
const tag = typeof args.tag === 'string' && args.tag.trim() ? args.tag.trim() : undefined;
|
|
87
|
+
const result = appendNoteToSection(section, entry, tag);
|
|
88
|
+
if ('error' in result) {
|
|
89
|
+
return { status: 'error', code: 'EXECUTION_ERROR', retryable: false, output: `错误:写入 notes.md 失败: ${result.error}` };
|
|
90
|
+
}
|
|
91
|
+
// note_append 写内部 notes.md,不作为用户代码 mutation 上报 changedFiles(与 plan_update 一致)。
|
|
92
|
+
const titleMap = {
|
|
93
|
+
findings: 'Findings', decisions: 'Decisions', open_questions: 'Open Questions', risks: 'Risks',
|
|
94
|
+
};
|
|
95
|
+
const rendered = tag ? `- **[${tag}]** ${entry}` : `- ${entry}`;
|
|
96
|
+
return {
|
|
97
|
+
status: 'success',
|
|
98
|
+
code: 'OK',
|
|
99
|
+
retryable: false,
|
|
100
|
+
output: `已追加笔记到 ## ${titleMap[section]} 段(将常驻 prompt,抗 compact):\n${rendered}`,
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
};
|
package/dist/tools/constants.js
CHANGED
|
@@ -23,6 +23,11 @@ export const DECAY_DAYS = 30;
|
|
|
23
23
|
export const GC_DAYS = 90;
|
|
24
24
|
/** memory_search 结果(召回的记忆正文)的放宽上限:指令性内容,中截破坏语义,对齐 use_skill。 */
|
|
25
25
|
export const MAX_MEMORY_RESULT = 64000;
|
|
26
|
+
// ── 知识图谱层(memory-graph.json,单 scope 容量)──────────────────────────
|
|
27
|
+
/** 单 scope 实体封顶:超限先清孤儿实体(无 active 边相连),仍超则拒绝新建。 */
|
|
28
|
+
export const MAX_GRAPH_ENTITIES = 500;
|
|
29
|
+
/** 单 scope 边封顶:超限先清已失效边,仍超则拒绝新边。 */
|
|
30
|
+
export const MAX_GRAPH_EDGES = 2000;
|
|
26
31
|
// .codegraph:codegraph 索引目录(codegraph.db 是 SQLite 二进制 + daemon.log),
|
|
27
32
|
// grep/glob 扫它无意义且会产出数 KB 的超长「行」,污染 TUI 展开渲染。
|
|
28
33
|
export const IGNORE = ['**/node_modules/**', '**/.git/**', '**/.codegraph/**'];
|
|
@@ -56,6 +61,7 @@ export const PLAN_DISABLED_TOOLS = new Set([
|
|
|
56
61
|
'memory_save',
|
|
57
62
|
'memory_update',
|
|
58
63
|
'memory_forget',
|
|
64
|
+
'memory_graph', // 混合工具(add 写图),plan 只读模式整体屏蔽
|
|
59
65
|
'sub-agent',
|
|
60
66
|
'run_skill', // fork 子 agent 执行面;plan 模式不应派生子工作流
|
|
61
67
|
]);
|