mocode-ai 1.2.3 → 1.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/index.js +3 -2
- package/dist/agent/spawn.js +25 -1
- package/dist/config/index.js +3 -2
- package/dist/context/classifier.js +1 -0
- package/dist/context/pipeline.js +1 -1
- package/dist/i18n/index.js +8 -0
- package/dist/memory/graph.js +409 -0
- package/dist/memory/index.js +2 -1
- package/dist/memory/reflect.js +25 -6
- package/dist/repl/index.js +71 -5
- package/dist/sandbox/policy.js +1 -1
- package/dist/skills/activation.js +26 -0
- package/dist/skills/builtin-skills.js +5 -0
- package/dist/skills/discover.js +167 -21
- package/dist/skills/index.js +32 -7
- package/dist/skills/runner.js +191 -33
- package/dist/skills/toolmap.js +56 -0
- package/dist/skills/trust.js +134 -0
- package/dist/tools/builtins/index.js +8 -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/run-skill.js +42 -0
- package/dist/tools/builtins/use-skill.js +43 -5
- package/dist/tools/constants.js +14 -0
- package/dist/ui/layout.js +2 -2
- package/dist/ui/theme.js +11 -11
- package/package.json +1 -1
package/dist/agent/index.js
CHANGED
|
@@ -118,8 +118,9 @@ function writeToolHeader(tc) {
|
|
|
118
118
|
}
|
|
119
119
|
return;
|
|
120
120
|
}
|
|
121
|
-
// sub-agent
|
|
122
|
-
|
|
121
|
+
// sub-agent 组还在跑,或组刚收口但尚未写入分隔空行时,
|
|
122
|
+
// 普通/mutation 工具先来了:先 flush 补一条分隔空行,避免摘要行粘在一起。
|
|
123
|
+
if (subAgentGroupId || subAgentGroupPendingSeparator) {
|
|
123
124
|
flushToolBatch();
|
|
124
125
|
}
|
|
125
126
|
if (isMutationTool(tc.name)) {
|
package/dist/agent/spawn.js
CHANGED
|
@@ -74,6 +74,8 @@ export async function spawnAgent(opts) {
|
|
|
74
74
|
const requested = opts.tools?.length ? new Set(opts.tools) : null;
|
|
75
75
|
const readOnly = new Set(['read_file', 'glob', 'grep', 'web_search', 'web_fetch', 'use_skill', 'memory_search', 'memory_list']);
|
|
76
76
|
toolsOverride = chatTools.filter((tool) => tool.function.name !== 'sub-agent' &&
|
|
77
|
+
// run_skill 会再次 spawn,禁止递归(避免 fork skill 里又 run_skill 套娃)。
|
|
78
|
+
tool.function.name !== 'run_skill' &&
|
|
77
79
|
// plan_update 直写主会话 notes.md(不走 overlay),子代理不应改动主计划——统一排除。
|
|
78
80
|
tool.function.name !== 'plan_update' &&
|
|
79
81
|
(!requested || requested.has(tool.function.name)) &&
|
|
@@ -93,7 +95,11 @@ export async function spawnAgent(opts) {
|
|
|
93
95
|
// 主屏实时渲染(子 agent 透明化):TUI 激活时把子 agent 内部步骤实时写入主内容区,
|
|
94
96
|
// 复用 batch 折叠机制(mouse 点击摘要行可展开/收起)。TUI 未激活(host/非 TTY)时
|
|
95
97
|
// 保持纯静默——只缓冲 transcript,不写屏,兼容嵌入宿主。
|
|
96
|
-
|
|
98
|
+
//
|
|
99
|
+
// quiet 模式(供 run_skill 等 opaque workflow 用):不产可展开 batch,
|
|
100
|
+
// 只在首次工具调用时写一行「◐ 执行 skill…」,完成后替换为「● skill 完成: 摘要」。
|
|
101
|
+
const live = isTuiActive() && !opts.quiet;
|
|
102
|
+
const quiet = isTuiActive() && !!opts.quiet;
|
|
97
103
|
let liveBatchId = null;
|
|
98
104
|
const liveLayout = () => ({
|
|
99
105
|
contentWrite: (s) => layout.contentWrite(s),
|
|
@@ -106,6 +112,16 @@ export async function spawnAgent(opts) {
|
|
|
106
112
|
});
|
|
107
113
|
/** 本批是否挂在主侧调用行下(挂上了就由主侧负责分隔空行,自己不能往 buffer 末尾追加)。 */
|
|
108
114
|
let nested = false;
|
|
115
|
+
// quiet 模式:完全静默,不写任何行(连 spinner 都没有);
|
|
116
|
+
// 子 agent 的执行过程与结果只回灌为主 agent 的 run_skill 工具结果。
|
|
117
|
+
const ensureQuietLine = (_label) => {
|
|
118
|
+
if (!quiet)
|
|
119
|
+
return; // 静默:什么都不输出
|
|
120
|
+
};
|
|
121
|
+
const replaceQuietLine = (_status, _detail) => {
|
|
122
|
+
if (!quiet)
|
|
123
|
+
return; // 静默:什么都不输出
|
|
124
|
+
};
|
|
109
125
|
const ensureLiveBatch = () => {
|
|
110
126
|
if (!live || liveBatchId)
|
|
111
127
|
return;
|
|
@@ -163,6 +179,7 @@ export async function spawnAgent(opts) {
|
|
|
163
179
|
const summary = summarizeToolCall(tc.name, tc.arguments);
|
|
164
180
|
writeBuf(` ● ${tc.name} ${summary}\n`);
|
|
165
181
|
// 实时写入主内容区:子 agent 的每次工具调用累计到摘要行计数。
|
|
182
|
+
ensureQuietLine(opts.quietLabel ?? t('skill.executing', { name: opts.prompt.slice(0, 40) }));
|
|
166
183
|
ensureLiveBatch();
|
|
167
184
|
if (liveBatchId) {
|
|
168
185
|
batch.recordCall(liveBatchId, tc.name, summary);
|
|
@@ -198,6 +215,8 @@ export async function spawnAgent(opts) {
|
|
|
198
215
|
onMaxSteps: () => {
|
|
199
216
|
writeBuf(` ● 达到最大步数(${maxSteps}),子 agent 停止。\n`);
|
|
200
217
|
finishLiveBatch('failed');
|
|
218
|
+
if (quiet)
|
|
219
|
+
replaceQuietLine('failed', t('skill.maxSteps', { max: String(maxSteps) }));
|
|
201
220
|
},
|
|
202
221
|
onDone: (elapsedMs, usage) => {
|
|
203
222
|
const tok = usage && usage.totalTokens
|
|
@@ -205,10 +224,15 @@ export async function spawnAgent(opts) {
|
|
|
205
224
|
: '';
|
|
206
225
|
writeBuf(` ✻ 子 agent 耗时 ${(elapsedMs / 1000).toFixed(1)}s${tok}\n`);
|
|
207
226
|
finishLiveBatch('complete');
|
|
227
|
+
if (quiet) {
|
|
228
|
+
replaceQuietLine('complete', `${t('skill.complete')} ${(elapsedMs / 1000).toFixed(1)}s${tok}`);
|
|
229
|
+
}
|
|
208
230
|
},
|
|
209
231
|
onAbort: () => {
|
|
210
232
|
// 中断:子 agent 未完成,收尾批(不折叠——用户可能想看中断前做了什么)。
|
|
211
233
|
finishLiveBatch('aborted');
|
|
234
|
+
if (quiet)
|
|
235
|
+
replaceQuietLine('aborted', t('skill.aborted'));
|
|
212
236
|
},
|
|
213
237
|
// onStepStart / onChatDone / onToolStart / onToolDone:子 agent 静默,无需 spinner 渲染。
|
|
214
238
|
// abort 还原(history 还原 + 模式还原)由 core 的 abortRestore 处理,hooks 只管展示。
|
package/dist/config/index.js
CHANGED
|
@@ -216,8 +216,9 @@ export function reinjectActivePlanIntoSystem(history) {
|
|
|
216
216
|
}
|
|
217
217
|
const SYSTEM_PROMPT_MEMORY_SECTION = `
|
|
218
218
|
## Memory (cross-session facts)
|
|
219
|
-
- The prompt may contain a title/summary index; retrieve details with memory_search or inspect all with memory_list.
|
|
220
|
-
- Save only stable, non-obvious cross-session facts. Search before saving; update an existing entry instead of duplicating it, and archive stale entries
|
|
219
|
+
- The prompt may contain a title/summary index; retrieve details with memory_search or inspect all with memory_list. memory_search also surfaces knowledge-graph facts (relations between entities) alongside entry bodies.
|
|
220
|
+
- Save only stable, non-obvious cross-session facts. Search before saving; update an existing entry instead of duplicating it, and archive stale entries.
|
|
221
|
+
- A knowledge-graph layer links entities across memories: explore relations/neighbors with memory_graph (neighbors/add/stats), and attach meaningful links via the links parameter of memory_save when saving.`;
|
|
221
222
|
/** Inject only retrieval guidance; MOCODE.md contents stay outside the prompt until read on demand. */
|
|
222
223
|
function buildMemoryPromptSection() {
|
|
223
224
|
if (!isMemoryEnabled())
|
package/dist/context/pipeline.js
CHANGED
|
@@ -37,7 +37,7 @@ function tryParseArgs(raw) {
|
|
|
37
37
|
* 仅作 encoder 软目标;最终裁剪仍由末尾 capToolResultForHistory 兜底,故两处常量偶有漂移不致命。
|
|
38
38
|
*/
|
|
39
39
|
function budgetFor(name) {
|
|
40
|
-
if (name === 'use_skill')
|
|
40
|
+
if (name === 'use_skill' || name === 'run_skill')
|
|
41
41
|
return MAX_SKILL_RESULT;
|
|
42
42
|
if (name === 'memory_search')
|
|
43
43
|
return MAX_MEMORY_RESULT;
|
package/dist/i18n/index.js
CHANGED
|
@@ -217,6 +217,10 @@ const zhCN = {
|
|
|
217
217
|
'subagent.stateOff': '关闭',
|
|
218
218
|
'subagent.changedOn': '已开启子 Agent;sub-agent 将从下一次模型请求起可用。',
|
|
219
219
|
'subagent.changedOff': '已关闭子 Agent;sub-agent 已从模型工具表移除。',
|
|
220
|
+
'skill.executing': '执行 skill: {name}…',
|
|
221
|
+
'skill.complete': 'Skill 执行完成',
|
|
222
|
+
'skill.aborted': 'Skill 执行已中断',
|
|
223
|
+
'skill.maxSteps': 'Skill 达到最大步数({max})',
|
|
220
224
|
'subagent.usage': '用法:/subagent on|off|status',
|
|
221
225
|
'fe.status': '前端工具簇:{state}',
|
|
222
226
|
'fe.stateOn': '开启',
|
|
@@ -472,6 +476,10 @@ const en = {
|
|
|
472
476
|
'subagent.stateOff': 'disabled',
|
|
473
477
|
'subagent.changedOn': 'Sub-agents enabled; sub-agent will be available from the next model request.',
|
|
474
478
|
'subagent.changedOff': 'Sub-agents disabled; sub-agent has been removed from the model tool list.',
|
|
479
|
+
'skill.executing': 'Executing skill: {name}…',
|
|
480
|
+
'skill.complete': 'Skill complete',
|
|
481
|
+
'skill.aborted': 'Skill aborted',
|
|
482
|
+
'skill.maxSteps': 'Skill reached max steps ({max})',
|
|
475
483
|
'subagent.usage': 'Usage: /subagent on|off|status',
|
|
476
484
|
'fe.status': 'Frontend tools: {state}',
|
|
477
485
|
'fe.stateOn': 'enabled',
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// memory 知识图谱层(Tier-2):Graphiti 式时序三元组,纯 JSON 文件存储。
|
|
2
|
+
// 叶子模块:仅依赖 node 标准库 + tools/constants(常量叶子)+ store.ts 的类型,
|
|
3
|
+
// 与 store.ts 同风格:同步读写、整文件 tmp+rename 原子落盘、静默容错。
|
|
4
|
+
//
|
|
5
|
+
// 两文件(镜像 store.ts 的双 scope):
|
|
6
|
+
// 全局 ~/.mocode/memory-graph.json
|
|
7
|
+
// 项目 <cwd>/.mocode/memory-graph.json
|
|
8
|
+
// 文件形如 {"entities":[...],"edges":[...]}。scope 以所在文件为准(loadAllGraph 归一化)。
|
|
9
|
+
//
|
|
10
|
+
// 时序语义(抄 Graphiti/Zep 的核心思想,文件实现):边带 validAt/invalidAt。
|
|
11
|
+
// 新三元组与既有 active 边同(src,dst,relation)且 fact 不同 → 旧边 invalidAt=now(不删,可追溯);
|
|
12
|
+
// fact 相同 → 幂等跳过。查询默认只看 active(invalidAt==null)。
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { MAX_GRAPH_EDGES, MAX_GRAPH_ENTITIES } from '../tools/constants.js';
|
|
17
|
+
// ── 路径 / 原子写(同 store.ts 风格)──────────────────────────────────────
|
|
18
|
+
function globalGraphPath() {
|
|
19
|
+
return path.join(os.homedir(), '.mocode', 'memory-graph.json');
|
|
20
|
+
}
|
|
21
|
+
function projectGraphPath() {
|
|
22
|
+
return path.join(process.cwd(), '.mocode', 'memory-graph.json');
|
|
23
|
+
}
|
|
24
|
+
function graphPathForScope(scope) {
|
|
25
|
+
return scope === 'global' ? globalGraphPath() : projectGraphPath();
|
|
26
|
+
}
|
|
27
|
+
function ensureDir(p) {
|
|
28
|
+
const dir = path.dirname(p);
|
|
29
|
+
if (!existsSync(dir))
|
|
30
|
+
mkdirSync(dir, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
function writeGraphAtomic(p, g) {
|
|
33
|
+
const tmp = p + '.tmp';
|
|
34
|
+
writeFileSync(tmp, JSON.stringify(g, null, 2), 'utf8');
|
|
35
|
+
renameSync(tmp, p);
|
|
36
|
+
}
|
|
37
|
+
function readGraphFile(p) {
|
|
38
|
+
if (!existsSync(p))
|
|
39
|
+
return { entities: [], edges: [] };
|
|
40
|
+
try {
|
|
41
|
+
const raw = readFileSync(p, 'utf8');
|
|
42
|
+
if (!raw.trim())
|
|
43
|
+
return { entities: [], edges: [] };
|
|
44
|
+
const obj = JSON.parse(raw);
|
|
45
|
+
return {
|
|
46
|
+
entities: Array.isArray(obj.entities) ? obj.entities : [],
|
|
47
|
+
edges: Array.isArray(obj.edges) ? obj.edges : [],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return { entities: [], edges: [] }; // 损坏文件不连累全局
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function nowIso() {
|
|
55
|
+
return new Date().toISOString();
|
|
56
|
+
}
|
|
57
|
+
let entCounter = 0;
|
|
58
|
+
/** 实体名 → ASCII slug;纯 CJK 等空结果用 ent 前缀兜底(同 store.ts slugify 思路)。 */
|
|
59
|
+
function entitySlug(name) {
|
|
60
|
+
const s = name
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
63
|
+
.replace(/^-+|-+$/g, '');
|
|
64
|
+
if (s)
|
|
65
|
+
return s;
|
|
66
|
+
entCounter++;
|
|
67
|
+
return 'ent-' + Date.now().toString(36) + entCounter.toString(36);
|
|
68
|
+
}
|
|
69
|
+
let edgeCounter = 0;
|
|
70
|
+
function nextEdgeId() {
|
|
71
|
+
edgeCounter++;
|
|
72
|
+
return 'edge-' + Date.now().toString(36) + edgeCounter.toString(36);
|
|
73
|
+
}
|
|
74
|
+
/** 读两文件并按文件归一化 scope。 */
|
|
75
|
+
export function loadAllGraph() {
|
|
76
|
+
const out = { entities: [], edges: [] };
|
|
77
|
+
for (const scope of ['global', 'project']) {
|
|
78
|
+
const g = readGraphFile(graphPathForScope(scope));
|
|
79
|
+
for (const e of g.entities) {
|
|
80
|
+
e.scope = scope;
|
|
81
|
+
out.entities.push(e);
|
|
82
|
+
}
|
|
83
|
+
for (const e of g.edges) {
|
|
84
|
+
e.scope = scope;
|
|
85
|
+
out.edges.push(e);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
function writeGraphForScope(scope, g) {
|
|
91
|
+
const p = graphPathForScope(scope);
|
|
92
|
+
ensureDir(p);
|
|
93
|
+
writeGraphAtomic(p, { entities: g.entities, edges: g.edges });
|
|
94
|
+
}
|
|
95
|
+
/** 单 scope 的完整图(读文件,scope 归一化)。 */
|
|
96
|
+
function loadScopeGraph(scope) {
|
|
97
|
+
const g = readGraphFile(graphPathForScope(scope));
|
|
98
|
+
for (const e of g.entities)
|
|
99
|
+
e.scope = scope;
|
|
100
|
+
for (const e of g.edges)
|
|
101
|
+
e.scope = scope;
|
|
102
|
+
return g;
|
|
103
|
+
}
|
|
104
|
+
// ── 实体 ─────────────────────────────────────────────────────────────────
|
|
105
|
+
const norm = (s) => s.trim().toLowerCase();
|
|
106
|
+
function findEntityIn(g, name) {
|
|
107
|
+
const q = norm(name);
|
|
108
|
+
if (!q)
|
|
109
|
+
return undefined;
|
|
110
|
+
return g.entities.find((e) => e.id === q || norm(e.name) === q || e.aliases.some((a) => norm(a) === q));
|
|
111
|
+
}
|
|
112
|
+
/** 全局找实体(两 scope,project 优先——项目事实比全局更具体)。 */
|
|
113
|
+
export function findEntity(name) {
|
|
114
|
+
const proj = findEntityIn(loadScopeGraph('project'), name);
|
|
115
|
+
if (proj)
|
|
116
|
+
return proj;
|
|
117
|
+
return findEntityIn(loadScopeGraph('global'), name);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* upsert 实体:按 name/alias/id 匹配,命中则合并 alias/summary;未命中建新。
|
|
121
|
+
* scope 容量保护:超限先清孤儿实体(无任何 active 边相连);仍超 → 拒绝新实体。
|
|
122
|
+
*/
|
|
123
|
+
export function upsertEntity(name, opts = {}) {
|
|
124
|
+
const scope = opts.scope === 'global' ? 'global' : 'project';
|
|
125
|
+
const trimmed = name.trim();
|
|
126
|
+
if (!trimmed)
|
|
127
|
+
return { id: '', created: false, rejected: 'empty-name' };
|
|
128
|
+
const g = loadScopeGraph(scope);
|
|
129
|
+
const hit = findEntityIn(g, trimmed);
|
|
130
|
+
if (hit) {
|
|
131
|
+
let dirty = false;
|
|
132
|
+
if (opts.alias && !hit.aliases.some((a) => norm(a) === norm(opts.alias))) {
|
|
133
|
+
hit.aliases.push(opts.alias.trim());
|
|
134
|
+
dirty = true;
|
|
135
|
+
}
|
|
136
|
+
if (opts.summary && opts.summary.trim() && opts.summary.trim() !== hit.summary) {
|
|
137
|
+
hit.summary = opts.summary.trim();
|
|
138
|
+
dirty = true;
|
|
139
|
+
}
|
|
140
|
+
if (dirty) {
|
|
141
|
+
hit.updatedAt = nowIso();
|
|
142
|
+
writeGraphForScope(scope, g);
|
|
143
|
+
}
|
|
144
|
+
return { id: hit.id, created: false };
|
|
145
|
+
}
|
|
146
|
+
// 新建:容量保护
|
|
147
|
+
if (g.entities.length >= MAX_GRAPH_ENTITIES) {
|
|
148
|
+
const linked = new Set();
|
|
149
|
+
for (const e of g.edges) {
|
|
150
|
+
if (!e.invalidAt) {
|
|
151
|
+
linked.add(e.src);
|
|
152
|
+
linked.add(e.dst);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
g.entities = g.entities.filter((e) => linked.has(e.id));
|
|
156
|
+
if (g.entities.length >= MAX_GRAPH_ENTITIES) {
|
|
157
|
+
return { id: '', created: false, rejected: 'entity-cap' };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const now = nowIso();
|
|
161
|
+
const ent = {
|
|
162
|
+
id: entitySlug(trimmed),
|
|
163
|
+
name: trimmed,
|
|
164
|
+
aliases: opts.alias && norm(opts.alias) !== norm(trimmed) ? [opts.alias.trim()] : [],
|
|
165
|
+
summary: opts.summary?.trim() ?? '',
|
|
166
|
+
createdAt: now,
|
|
167
|
+
updatedAt: now,
|
|
168
|
+
scope,
|
|
169
|
+
};
|
|
170
|
+
// slug 碰撞兜底(两个不同名字 slug 相同)
|
|
171
|
+
while (g.entities.some((e) => e.id === ent.id))
|
|
172
|
+
ent.id += 'x';
|
|
173
|
+
g.entities.push(ent);
|
|
174
|
+
writeGraphForScope(scope, g);
|
|
175
|
+
return { id: ent.id, created: true };
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* 写一条三元组。src/dst 自动 upsert 为实体。
|
|
179
|
+
* 冲突策略:同 scope 内已有 active 边同(src,dst,relation):
|
|
180
|
+
* - fact 相同 → 幂等跳过(duplicate);
|
|
181
|
+
* - fact 不同 → 旧边 invalidAt=now(时序失效),新边入库。
|
|
182
|
+
* 容量保护:边超限先清已失效边;仍超 → 拒绝。
|
|
183
|
+
*/
|
|
184
|
+
export function addTriple(input) {
|
|
185
|
+
const src = input.src?.trim();
|
|
186
|
+
const dst = input.dst?.trim();
|
|
187
|
+
const relation = input.relation?.trim().toLowerCase().replace(/\s+/g, '_');
|
|
188
|
+
if (!src || !dst || !relation)
|
|
189
|
+
return { ok: false, reason: 'missing src/relation/dst' };
|
|
190
|
+
if (src.length > 80 || dst.length > 80 || relation.length > 60) {
|
|
191
|
+
return { ok: false, reason: 'src/dst/relation too long' };
|
|
192
|
+
}
|
|
193
|
+
const scope = input.scope === 'global' ? 'global' : 'project';
|
|
194
|
+
const s = upsertEntity(src, { scope });
|
|
195
|
+
if (s.rejected)
|
|
196
|
+
return { ok: false, reason: `src entity: ${s.rejected}` };
|
|
197
|
+
const d = upsertEntity(dst, { scope });
|
|
198
|
+
if (d.rejected)
|
|
199
|
+
return { ok: false, reason: `dst entity: ${d.rejected}` };
|
|
200
|
+
const g = loadScopeGraph(scope);
|
|
201
|
+
const fact = (input.fact ?? '').trim();
|
|
202
|
+
const clash = g.edges.find((e) => !e.invalidAt && e.src === s.id && e.dst === d.id && e.relation === relation);
|
|
203
|
+
if (clash) {
|
|
204
|
+
if (!fact || clash.fact === fact) {
|
|
205
|
+
return { ok: true, edgeId: clash.id, superseded: 0, duplicate: true };
|
|
206
|
+
}
|
|
207
|
+
clash.invalidAt = nowIso(); // 时序失效,保留可追溯
|
|
208
|
+
}
|
|
209
|
+
// 容量保护:先清失效边
|
|
210
|
+
if (g.edges.length >= MAX_GRAPH_EDGES) {
|
|
211
|
+
g.edges = g.edges.filter((e) => !e.invalidAt);
|
|
212
|
+
if (g.edges.length >= MAX_GRAPH_EDGES) {
|
|
213
|
+
return { ok: false, reason: 'edge-cap' };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const edge = {
|
|
217
|
+
id: nextEdgeId(),
|
|
218
|
+
src: s.id,
|
|
219
|
+
dst: d.id,
|
|
220
|
+
relation,
|
|
221
|
+
fact,
|
|
222
|
+
validAt: nowIso(),
|
|
223
|
+
invalidAt: null,
|
|
224
|
+
sourceEntry: input.sourceEntry ?? null,
|
|
225
|
+
scope,
|
|
226
|
+
};
|
|
227
|
+
g.edges.push(edge);
|
|
228
|
+
writeGraphForScope(scope, g);
|
|
229
|
+
return { ok: true, edgeId: edge.id, superseded: clash ? 1 : 0 };
|
|
230
|
+
}
|
|
231
|
+
/** 实体关键词搜索(多词子串,name/alias/id 加权),返回命中实体 + 相连 active 边。 */
|
|
232
|
+
export function searchGraph(query, limit = 8) {
|
|
233
|
+
const g = loadAllGraph();
|
|
234
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
235
|
+
const scored = g.entities
|
|
236
|
+
.map((e) => {
|
|
237
|
+
let sc = 0;
|
|
238
|
+
const name = e.name.toLowerCase();
|
|
239
|
+
const id = e.id.toLowerCase();
|
|
240
|
+
const aliases = e.aliases.map((a) => a.toLowerCase());
|
|
241
|
+
for (const t of terms) {
|
|
242
|
+
if (id.includes(t))
|
|
243
|
+
sc += 8;
|
|
244
|
+
if (name.includes(t))
|
|
245
|
+
sc += 10;
|
|
246
|
+
if (aliases.some((a) => a.includes(t)))
|
|
247
|
+
sc += 6;
|
|
248
|
+
if (e.summary.toLowerCase().includes(t))
|
|
249
|
+
sc += 2;
|
|
250
|
+
}
|
|
251
|
+
return { e, sc };
|
|
252
|
+
})
|
|
253
|
+
.filter((x) => (terms.length === 0 ? true : x.sc > 0))
|
|
254
|
+
.sort((a, b) => b.sc - a.sc)
|
|
255
|
+
.slice(0, Math.max(1, Math.min(limit, 20)));
|
|
256
|
+
const hitIds = new Set(scored.map((x) => x.e.id));
|
|
257
|
+
const edges = g.edges.filter((e) => !e.invalidAt && (hitIds.has(e.src) || hitIds.has(e.dst)));
|
|
258
|
+
return { entities: scored.map((x) => x.e), edges };
|
|
259
|
+
}
|
|
260
|
+
function buildAdjacency(g, relation) {
|
|
261
|
+
const adj = new Map();
|
|
262
|
+
for (const e of g.edges) {
|
|
263
|
+
if (e.invalidAt)
|
|
264
|
+
continue;
|
|
265
|
+
if (relation && e.relation !== relation)
|
|
266
|
+
continue;
|
|
267
|
+
const a = adj.get(e.src);
|
|
268
|
+
if (a)
|
|
269
|
+
a.push(e);
|
|
270
|
+
else
|
|
271
|
+
adj.set(e.src, [e]);
|
|
272
|
+
const b = adj.get(e.dst);
|
|
273
|
+
if (b)
|
|
274
|
+
b.push(e);
|
|
275
|
+
else
|
|
276
|
+
adj.set(e.dst, [e]);
|
|
277
|
+
}
|
|
278
|
+
return adj;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* 邻居遍历:depth 1-3(BFS + 邻接表 O(V+E)),只看 active 边,边数封顶 40 防图爆炸。
|
|
282
|
+
* relation 可选:只沿该类型的边走(深跳聚焦用,如沿 depends_on 链追踪)。
|
|
283
|
+
*/
|
|
284
|
+
export function neighborsOf(nameOrId, depth = 1, relation) {
|
|
285
|
+
const g = loadAllGraph();
|
|
286
|
+
const center = findEntityIn(g, nameOrId) ?? g.entities.find((e) => e.id === nameOrId);
|
|
287
|
+
if (!center)
|
|
288
|
+
return { center: null, entities: [], edges: [], truncated: false };
|
|
289
|
+
const rel = relation?.trim().toLowerCase().replace(/\s+/g, '_') || undefined;
|
|
290
|
+
const maxDepth = Math.max(1, Math.min(Math.floor(depth) || 1, 3));
|
|
291
|
+
const MAX_EDGES_OUT = 40;
|
|
292
|
+
const adj = buildAdjacency(g, rel);
|
|
293
|
+
const visited = new Set([center.id]);
|
|
294
|
+
const seenEdges = new Set();
|
|
295
|
+
const outEdges = [];
|
|
296
|
+
let frontier = [center.id];
|
|
297
|
+
let truncated = false;
|
|
298
|
+
outer: for (let d = 0; d < maxDepth; d++) {
|
|
299
|
+
const next = [];
|
|
300
|
+
for (const id of frontier) {
|
|
301
|
+
for (const e of adj.get(id) ?? []) {
|
|
302
|
+
if (seenEdges.has(e.id))
|
|
303
|
+
continue;
|
|
304
|
+
seenEdges.add(e.id);
|
|
305
|
+
if (outEdges.length >= MAX_EDGES_OUT) {
|
|
306
|
+
truncated = true;
|
|
307
|
+
break outer; // 截断立即退出,不再空扫剩余边
|
|
308
|
+
}
|
|
309
|
+
outEdges.push(e);
|
|
310
|
+
const other = e.src === id ? e.dst : e.src;
|
|
311
|
+
if (!visited.has(other)) {
|
|
312
|
+
visited.add(other);
|
|
313
|
+
next.push(other);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
frontier = next;
|
|
318
|
+
}
|
|
319
|
+
const entities = g.entities.filter((e) => visited.has(e.id) && e.id !== center.id);
|
|
320
|
+
return { center, entities, edges: outEdges, truncated };
|
|
321
|
+
}
|
|
322
|
+
const MAX_PATH_DEPTH = 6;
|
|
323
|
+
/**
|
|
324
|
+
* 两实体间最短路径:active 边视为无向,双向 BFS(每次展开较小前沿),
|
|
325
|
+
* 总跳数封顶 MAX_PATH_DEPTH 防图爆炸。不连通 / 未知端 → null。
|
|
326
|
+
*/
|
|
327
|
+
export function pathBetween(aNameOrId, bNameOrId) {
|
|
328
|
+
const g = loadAllGraph();
|
|
329
|
+
const from = findEntityIn(g, aNameOrId) ?? g.entities.find((e) => e.id === aNameOrId);
|
|
330
|
+
const to = findEntityIn(g, bNameOrId) ?? g.entities.find((e) => e.id === bNameOrId);
|
|
331
|
+
if (!from || !to)
|
|
332
|
+
return null;
|
|
333
|
+
if (from.id === to.id)
|
|
334
|
+
return { from, to, path: [from], edges: [] };
|
|
335
|
+
const fwd = new Map([[from.id, { prev: null, edge: null }]]);
|
|
336
|
+
const bwd = new Map([[to.id, { prev: null, edge: null }]]);
|
|
337
|
+
let fFront = [from.id];
|
|
338
|
+
let bFront = [to.id];
|
|
339
|
+
const adj = buildAdjacency(g);
|
|
340
|
+
let meet = null;
|
|
341
|
+
for (let d = 0; d < MAX_PATH_DEPTH && !meet; d++) {
|
|
342
|
+
const expandFwd = fFront.length <= bFront.length;
|
|
343
|
+
const mine = expandFwd ? fwd : bwd;
|
|
344
|
+
const theirs = expandFwd ? bwd : fwd;
|
|
345
|
+
const frontier = expandFwd ? fFront : bFront;
|
|
346
|
+
const next = [];
|
|
347
|
+
for (const id of frontier) {
|
|
348
|
+
for (const e of adj.get(id) ?? []) {
|
|
349
|
+
const other = e.src === id ? e.dst : e.src;
|
|
350
|
+
if (mine.has(other))
|
|
351
|
+
continue;
|
|
352
|
+
mine.set(other, { prev: id, edge: e });
|
|
353
|
+
next.push(other);
|
|
354
|
+
if (theirs.has(other)) {
|
|
355
|
+
meet = other;
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (meet)
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
if (expandFwd)
|
|
363
|
+
fFront = next;
|
|
364
|
+
else
|
|
365
|
+
bFront = next;
|
|
366
|
+
}
|
|
367
|
+
if (!meet)
|
|
368
|
+
return null;
|
|
369
|
+
// 重组:fwd 链 from→…→meet,再接 bwd 链 meet→…→to
|
|
370
|
+
const rev = [];
|
|
371
|
+
for (let cur = meet; cur; cur = fwd.get(cur)?.prev ?? null)
|
|
372
|
+
rev.push(cur);
|
|
373
|
+
rev.reverse(); // [from, ..., meet]
|
|
374
|
+
const pathIds = [...rev];
|
|
375
|
+
const pathEdges = [];
|
|
376
|
+
for (const id of rev.slice(1)) {
|
|
377
|
+
const e = fwd.get(id)?.edge;
|
|
378
|
+
if (e)
|
|
379
|
+
pathEdges.push(e);
|
|
380
|
+
}
|
|
381
|
+
for (let cur = meet;;) {
|
|
382
|
+
const t = bwd.get(cur);
|
|
383
|
+
if (!t?.prev)
|
|
384
|
+
break;
|
|
385
|
+
if (t.edge)
|
|
386
|
+
pathEdges.push(t.edge);
|
|
387
|
+
pathIds.push(t.prev);
|
|
388
|
+
cur = t.prev;
|
|
389
|
+
}
|
|
390
|
+
const byId = new Map();
|
|
391
|
+
for (const e of g.entities)
|
|
392
|
+
if (!byId.has(e.id))
|
|
393
|
+
byId.set(e.id, e);
|
|
394
|
+
const path = pathIds.map((id) => byId.get(id)).filter((e) => !!e);
|
|
395
|
+
return { from, to, path, edges: pathEdges };
|
|
396
|
+
}
|
|
397
|
+
export function graphStats() {
|
|
398
|
+
const g = loadAllGraph();
|
|
399
|
+
const count = (scope) => ({
|
|
400
|
+
entities: g.entities.filter((e) => e.scope === scope).length,
|
|
401
|
+
edges: g.edges.filter((e) => e.scope === scope && !e.invalidAt).length,
|
|
402
|
+
});
|
|
403
|
+
return {
|
|
404
|
+
entities: g.entities.length,
|
|
405
|
+
edgesActive: g.edges.filter((e) => !e.invalidAt).length,
|
|
406
|
+
edgesInvalid: g.edges.filter((e) => !!e.invalidAt).length,
|
|
407
|
+
byScope: { project: count('project'), global: count('global') },
|
|
408
|
+
};
|
|
409
|
+
}
|
package/dist/memory/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// Memory barrel: Tier-2 JSONL store + background reflection.
|
|
1
|
+
// Memory barrel: Tier-2 JSONL store + knowledge-graph layer + background reflection.
|
|
2
2
|
// MOCODE.md is intentionally not loaded here: the system prompt only tells the agent
|
|
3
3
|
// to read the workspace file on demand, keeping its full body out of every request.
|
|
4
4
|
export { buildMemoryIndexSection, loadAll, gcMemories, } from './store.js';
|
|
5
|
+
export { addTriple, upsertEntity, findEntity, searchGraph, neighborsOf, pathBetween, graphStats, } from './graph.js';
|
|
5
6
|
export { kickoffReflection, drainMemoryBackground, getLastReflectResult, clearLastReflectResult, snapshotTranscript, formatReflectResult, runReflection, } from './reflect.js';
|
package/dist/memory/reflect.js
CHANGED
|
@@ -13,6 +13,7 @@ import path from 'node:path';
|
|
|
13
13
|
import { chat } from '../llm/index.js';
|
|
14
14
|
import { config, isMemoryEnabled } from '../config/index.js';
|
|
15
15
|
import { saveEntry, updateEntry, forgetEntry, loadAll, gcMemories, } from './store.js';
|
|
16
|
+
import { addTriple } from './graph.js';
|
|
16
17
|
// ── 日志(静默容错,裁尾保最近)─────────────────────────────────────────────
|
|
17
18
|
function logPath() {
|
|
18
19
|
return path.join(process.cwd(), '.mocode', 'memory.log');
|
|
@@ -93,15 +94,16 @@ function buildMemorySample() {
|
|
|
93
94
|
}
|
|
94
95
|
const TYPES = 'decision | fact | pitfall | reference | feedback';
|
|
95
96
|
const REFLECT_SYS = `You are mocode's memory reflector. Review the recent session and existing memories, producing **only** updates worth remembering long-term.
|
|
96
|
-
Output strictly JSON (no markdown code blocks, no explanatory text): {"saves":[{"type":"...","name":"...","summary":"...","body":"..."}],"updates":[{"id":"...","reason":"...","summary":"...","body":"..."}],"forgets":[{"id":"...","reason":"..."}]}
|
|
97
|
-
Empty arrays are valid (if nothing is worth saving, all
|
|
97
|
+
Output strictly JSON (no markdown code blocks, no explanatory text): {"saves":[{"type":"...","name":"...","summary":"...","body":"..."}],"updates":[{"id":"...","reason":"...","summary":"...","body":"..."}],"forgets":[{"id":"...","reason":"..."}],"triples":[{"src":"...","relation":"...","dst":"...","fact":"..."}]}
|
|
98
|
+
Empty arrays are valid (if nothing is worth saving, all arrays are empty).
|
|
98
99
|
Rules:
|
|
99
100
|
① Only store non-obvious, cross-session-useful facts/decisions/pitfalls; do not store current bugs, temp files, undecided TODOs, or volatile items;
|
|
100
101
|
② Better to store less than to store trivially correct info (e.g. "keep it concise");
|
|
101
102
|
③ ids in updates/forgets must come from the "existing memories" list below; do not fabricate ids not listed there;
|
|
102
103
|
④ names in saves must be concise and not collide with existing ones; type ∈ {${TYPES}};
|
|
103
104
|
⑤ If an existing memory contradicts new facts or is outdated, update the old entry (modify summary/body) rather than creating a duplicate;
|
|
104
|
-
⑥ forgets are for memories clearly stale / superseded by a new entry (archive, not hard-delete)
|
|
105
|
+
⑥ forgets are for memories clearly stale / superseded by a new entry (archive, not hard-delete);
|
|
106
|
+
⑦ triples are knowledge-graph facts distilled from this session: concise entity names (lowercase snake_case or proper nouns), relation in snake_case (e.g. depends_on, decided_by, implemented_in, conflicts_with), plus a one-line fact. Only emit triples that are stable, non-obvious and cross-session-useful (2-6 at most); they may reference entities from saves/updates or existing memories.`;
|
|
105
107
|
const REFLECT_USER = (transcript, sample) => `## Recent session\n${transcript}\n\n## Existing memories\n${sample}\n\nProduce JSON:`;
|
|
106
108
|
function parsePlan(content) {
|
|
107
109
|
if (!content)
|
|
@@ -141,6 +143,7 @@ export async function runReflection(transcript, signal) {
|
|
|
141
143
|
saves: 0,
|
|
142
144
|
updates: 0,
|
|
143
145
|
forgets: 0,
|
|
146
|
+
triples: 0,
|
|
144
147
|
gcDecayed: 0,
|
|
145
148
|
gcCapped: 0,
|
|
146
149
|
gcGced: 0,
|
|
@@ -200,8 +203,24 @@ export async function runReflection(transcript, signal) {
|
|
|
200
203
|
forgets++;
|
|
201
204
|
}
|
|
202
205
|
}
|
|
206
|
+
// 知识图谱三元组:容错——单项失败跳过,不影响 saves/updates/forgets 已落地的结果。
|
|
207
|
+
let triples = 0;
|
|
208
|
+
if (Array.isArray(plan.triples)) {
|
|
209
|
+
for (const t of plan.triples.slice(0, 10)) {
|
|
210
|
+
if (!t?.src || !t?.relation || !t?.dst)
|
|
211
|
+
continue;
|
|
212
|
+
const r = addTriple({
|
|
213
|
+
src: String(t.src),
|
|
214
|
+
relation: String(t.relation),
|
|
215
|
+
dst: String(t.dst),
|
|
216
|
+
fact: t.fact ? String(t.fact) : undefined,
|
|
217
|
+
});
|
|
218
|
+
if (r.ok && !r.duplicate)
|
|
219
|
+
triples++;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
203
222
|
const gc = gcMemories();
|
|
204
|
-
result = { ...result, saves, updates, forgets, gcDecayed: gc.decayed, gcCapped: gc.capped, gcGced: gc.gced };
|
|
223
|
+
result = { ...result, saves, updates, forgets, triples, gcDecayed: gc.decayed, gcCapped: gc.capped, gcGced: gc.gced };
|
|
205
224
|
return result;
|
|
206
225
|
}
|
|
207
226
|
function normalizeType(t) {
|
|
@@ -215,9 +234,9 @@ function normalizeType(t) {
|
|
|
215
234
|
// ── 后台编排:kickoff / drain / 缓存 ─────────────────────────────────────────
|
|
216
235
|
let inflight = null;
|
|
217
236
|
let lastReflectResult = null;
|
|
218
|
-
/** 摘要串(供 repl flush):存N 改N 忘N;有错误附上。 */
|
|
237
|
+
/** 摘要串(供 repl flush):存N 改N 忘N 图N;有错误附上。 */
|
|
219
238
|
export function formatReflectResult(r) {
|
|
220
|
-
const parts = [`存${r.saves}`, `改${r.updates}`, `忘${r.forgets}`];
|
|
239
|
+
const parts = [`存${r.saves}`, `改${r.updates}`, `忘${r.forgets}`, `图${r.triples}`];
|
|
221
240
|
if (r.gcDecayed || r.gcCapped || r.gcGced) {
|
|
222
241
|
parts.push(`遗忘(衰减${r.gcDecayed}/封顶${r.gcCapped}/清除${r.gcGced})`);
|
|
223
242
|
}
|