mocode-ai 1.7.9 → 1.7.11
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 +5 -4
- package/dist/agent/run-coordinator.js +6 -0
- package/dist/agent/stages/tool-dispatcher.js +1 -0
- package/dist/agent/tool-helpers.js +18 -1
- package/dist/config/index.js +4 -1
- package/dist/headless.js +8 -1
- package/dist/i18n/index.js +4 -0
- package/dist/index.js +1 -1
- package/dist/memory/graph.js +262 -1
- package/dist/memory/index.js +3 -1
- package/dist/memory/persona.js +142 -0
- package/dist/memory/recall-stats.js +61 -0
- package/dist/memory/recall.js +47 -0
- package/dist/memory/reflect.js +112 -17
- package/dist/memory/relation-vocab.js +84 -0
- package/dist/repl/message-format.js +23 -1
- package/dist/repl/runtime.js +13 -4
- package/dist/runtime/runtime.js +1 -1
- package/dist/session/compact-archive.js +232 -0
- package/dist/session/compact.js +19 -2
- package/dist/session/persist.js +4 -3
- package/dist/session/retention.js +33 -2
- package/dist/session/store.js +187 -3
- package/dist/tools/builtins/grep.js +5 -2
- package/dist/tools/builtins/memory-graph.js +2 -0
- package/dist/tools/builtins/memory-search.js +20 -14
- package/dist/tools/builtins/run-command.js +12 -3
- package/dist/tools/constants.js +4 -0
- package/dist/tools/ripgrep-client.js +11 -1
- package/dist/tools/tool-runtime.js +1 -0
- package/dist/ui/batch.js +89 -8
- package/package.json +1 -1
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// compact-archive.ts — 压缩旧区原文的归档渲染器。
|
|
2
|
+
//
|
|
3
|
+
// 目的:compactHistory 成功摘要后旧区消息会被整体丢弃(连 session.json 也被
|
|
4
|
+
// 压缩后的 history 覆盖)。这里把旧区渲染成 grep 友好的 Markdown 段文件,落盘到
|
|
5
|
+
// `<session>/compacted/seg-NNN.md`,模型事后可用现有 grep/read_file 回捞精确原文,
|
|
6
|
+
// 不新增任何工具。
|
|
7
|
+
//
|
|
8
|
+
// 设计约束:
|
|
9
|
+
// - 纯函数,不碰文件系统(落盘/编号/淘汰在 SessionStore)。
|
|
10
|
+
// - 每个 seg 文件首行是单行 HTML 注释元数据(自描述,index.md 可随时重建)。
|
|
11
|
+
// - 图片不归档(base64 无检索价值且体积爆炸),只留占位,与 stripImagesForSummary 同口径。
|
|
12
|
+
// - 消息按 history 原时序;turn 序号按非空 user 消息递增,方便模型按回合定位。
|
|
13
|
+
/** 压缩归档子目录名(相对会话目录)。 */
|
|
14
|
+
export const COMPACTED_DIRNAME = 'compacted';
|
|
15
|
+
/** 段索引文件名。 */
|
|
16
|
+
export const ARCHIVE_INDEX_FILE = 'index.md';
|
|
17
|
+
/** seg 文件名:seg-001.md … seg-999.md(超出也不报错,只是不补零)。 */
|
|
18
|
+
export function segmentFileName(seq) {
|
|
19
|
+
return `seg-${String(seq).padStart(3, '0')}.md`;
|
|
20
|
+
}
|
|
21
|
+
export const SEGMENT_FILE_RE = /^seg-(\d+)\.md$/;
|
|
22
|
+
/** push-time 被 capToolResultForHistory 截断的工具完整结果,按 tool_call_id 单存。 */
|
|
23
|
+
export function resultBlobFileName(toolCallId) {
|
|
24
|
+
// call id 理论上安全(toolu_xxx / call-N),仍做一次白名单清洗防路径穿越。
|
|
25
|
+
const safe = toolCallId.replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 120) || 'unknown';
|
|
26
|
+
return `result-${safe}.txt`;
|
|
27
|
+
}
|
|
28
|
+
export const RESULT_BLOB_FILE_RE = /^result-.+\.txt$/;
|
|
29
|
+
/** retention 归档时打包进 gz 的 compacted/ 资产白名单(其余临时文件不带走)。 */
|
|
30
|
+
export function isCompactedAssetName(name) {
|
|
31
|
+
return SEGMENT_FILE_RE.test(name) || RESULT_BLOB_FILE_RE.test(name) || name === ARCHIVE_INDEX_FILE;
|
|
32
|
+
}
|
|
33
|
+
function asToolCalls(m) {
|
|
34
|
+
const tcs = m.tool_calls;
|
|
35
|
+
if (!Array.isArray(tcs))
|
|
36
|
+
return [];
|
|
37
|
+
return tcs;
|
|
38
|
+
}
|
|
39
|
+
/** 多模态 content 拍平:text 拼接;image_url 留占位;其余形状 JSON 化(与摘要器同口径)。 */
|
|
40
|
+
function messageText(content) {
|
|
41
|
+
if (content == null)
|
|
42
|
+
return '';
|
|
43
|
+
if (typeof content === 'string')
|
|
44
|
+
return content;
|
|
45
|
+
if (Array.isArray(content)) {
|
|
46
|
+
const parts = [];
|
|
47
|
+
for (const p of content) {
|
|
48
|
+
if (p && typeof p === 'object') {
|
|
49
|
+
const part = p;
|
|
50
|
+
if (part.type === 'text')
|
|
51
|
+
parts.push(part.text ?? '');
|
|
52
|
+
else if (part.type === 'image_url') {
|
|
53
|
+
const url = part.image_url?.url ?? '';
|
|
54
|
+
const mime = url.startsWith('data:') ? url.slice(5, url.indexOf(';')) : 'image';
|
|
55
|
+
parts.push(`[图片已剥离: ${mime}]`);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
parts.push(`[${part.type ?? 'content'} part]`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
parts.push(String(p ?? ''));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return parts.join('');
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
return JSON.stringify(content);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return String(content);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const PATH_KEYS = new Set(['path', 'file', 'filePath', 'filename', 'dir', 'directory']);
|
|
75
|
+
const MAX_META_FILES = 8;
|
|
76
|
+
const MAX_FIRST_USER_CHARS = 60;
|
|
77
|
+
function collectFilesFromArgs(argsRaw, sink) {
|
|
78
|
+
if (!argsRaw || sink.length >= MAX_META_FILES)
|
|
79
|
+
return;
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(argsRaw);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!parsed || typeof parsed !== 'object')
|
|
88
|
+
return;
|
|
89
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
90
|
+
if (typeof v !== 'string' || !PATH_KEYS.has(k))
|
|
91
|
+
continue;
|
|
92
|
+
const norm = v.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
93
|
+
if (norm && !sink.includes(norm))
|
|
94
|
+
sink.push(norm);
|
|
95
|
+
if (sink.length >= MAX_META_FILES)
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function oneLine(text, max) {
|
|
100
|
+
const line = text.replace(/\s+/g, ' ').trim();
|
|
101
|
+
return line.length > max ? `${line.slice(0, max)}…` : line;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* 把一段旧区消息渲染成 Markdown 段。输出同时包含文件全文与元数据,
|
|
105
|
+
* 调用方负责把返回的 { markdown, meta } 一起落盘。
|
|
106
|
+
*/
|
|
107
|
+
export function renderCompactedSegment(seq, messages, archivedAt = new Date().toISOString()) {
|
|
108
|
+
const file = segmentFileName(seq);
|
|
109
|
+
const tools = [];
|
|
110
|
+
const files = [];
|
|
111
|
+
let turns = 0;
|
|
112
|
+
let firstUser = '';
|
|
113
|
+
// tool_call_id → tool 名(tool 消息自身不带工具名,靠前导 assistant 配对)。
|
|
114
|
+
const callNames = new Map();
|
|
115
|
+
for (const m of messages) {
|
|
116
|
+
if (m.role !== 'assistant')
|
|
117
|
+
continue;
|
|
118
|
+
for (const tc of asToolCalls(m)) {
|
|
119
|
+
const name = tc.function?.name;
|
|
120
|
+
if (tc.id && name)
|
|
121
|
+
callNames.set(tc.id, name);
|
|
122
|
+
if (name && !tools.includes(name))
|
|
123
|
+
tools.push(name);
|
|
124
|
+
collectFilesFromArgs(tc.function?.arguments, files);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const blocks = [];
|
|
128
|
+
for (const m of messages) {
|
|
129
|
+
if (m.role === 'user') {
|
|
130
|
+
const text = messageText(m.content);
|
|
131
|
+
if (text.trim()) {
|
|
132
|
+
turns++;
|
|
133
|
+
if (!firstUser)
|
|
134
|
+
firstUser = oneLine(text, MAX_FIRST_USER_CHARS);
|
|
135
|
+
}
|
|
136
|
+
blocks.push(`### [turn ${turns}] user\n${text.trim()}`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (m.role === 'system') {
|
|
140
|
+
// 上一代摘要的叙述段(pin 除外):内容会随本代重新压缩,归档里标记出处。
|
|
141
|
+
blocks.push(`### [prior-summary] system\n${messageText(m.content).trim()}`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (m.role === 'tool') {
|
|
145
|
+
const id = m.tool_call_id ?? '';
|
|
146
|
+
const name = (id && callNames.get(id)) || '';
|
|
147
|
+
const label = [`### [turn ${turns}] tool_result`, id && `call=${id}`, name && `tool=${name}`]
|
|
148
|
+
.filter(Boolean)
|
|
149
|
+
.join(' ');
|
|
150
|
+
blocks.push(`${label}\n${messageText(m.content).trim()}`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// assistant:正文一个块,每个 tool_call 一个块(参数是 grep 的高频目标,独立成块)。
|
|
154
|
+
const text = messageText(m.content).trim();
|
|
155
|
+
if (text)
|
|
156
|
+
blocks.push(`### [turn ${turns}] assistant\n${text}`);
|
|
157
|
+
for (const tc of asToolCalls(m)) {
|
|
158
|
+
const name = tc.function?.name;
|
|
159
|
+
const label = [`### [turn ${turns}] assistant call`, tc.id && `call=${tc.id}`, name && `tool=${name}`]
|
|
160
|
+
.filter(Boolean)
|
|
161
|
+
.join(' ');
|
|
162
|
+
const args = (tc.function?.arguments ?? '').trim();
|
|
163
|
+
blocks.push(`${label}\n${args ? `args: ${args}` : '(no args)'}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const body = blocks.join('\n\n') + '\n';
|
|
167
|
+
const baseMeta = {
|
|
168
|
+
seq,
|
|
169
|
+
file,
|
|
170
|
+
archivedAt,
|
|
171
|
+
messages: messages.length,
|
|
172
|
+
turns,
|
|
173
|
+
tools,
|
|
174
|
+
files,
|
|
175
|
+
firstUser,
|
|
176
|
+
};
|
|
177
|
+
const heading = `# 已压缩历史段 ${file}\n\n`;
|
|
178
|
+
// chars 是淘汰策略的依据,需含 header 自身:先估后定(header 只差 chars 一个数字位)。
|
|
179
|
+
const measure = (chars) => `<!-- mocode-archive ${JSON.stringify({ ...baseMeta, chars })} -->\n\n`;
|
|
180
|
+
let chars = measure(0).length + heading.length + body.length;
|
|
181
|
+
let header = measure(chars);
|
|
182
|
+
if (header.length !== measure(0).length) {
|
|
183
|
+
// 数字位数变化(9→10)的罕见情况,再定标一次即收敛。
|
|
184
|
+
chars = header.length + heading.length + body.length;
|
|
185
|
+
header = measure(chars);
|
|
186
|
+
}
|
|
187
|
+
const meta = { ...baseMeta, chars };
|
|
188
|
+
return { markdown: `${header}${heading}${body}`, meta };
|
|
189
|
+
}
|
|
190
|
+
/** 从 seg 文件内容解析首行元数据;损坏/旧格式返 null。 */
|
|
191
|
+
export function parseSegmentMeta(markdown) {
|
|
192
|
+
const nl = markdown.indexOf('\n');
|
|
193
|
+
const first = (nl >= 0 ? markdown.slice(0, nl) : markdown).trim();
|
|
194
|
+
const m = /^<!-- mocode-archive (\{.*\}) -->$/.exec(first);
|
|
195
|
+
if (!m)
|
|
196
|
+
return null;
|
|
197
|
+
try {
|
|
198
|
+
const meta = JSON.parse(m[1]);
|
|
199
|
+
if (typeof meta.seq === 'number' && typeof meta.file === 'string')
|
|
200
|
+
return meta;
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* 由全部段元数据重建 index.md。模型读它决定去哪段 grep;确定性输出,
|
|
209
|
+
* 不经过摘要器(避免漏写)。relativeDir 是相对 cwd 的 POSIX 路径,
|
|
210
|
+
* 直接写给模型/grep 用。
|
|
211
|
+
*/
|
|
212
|
+
export function renderArchiveIndex(metas, relativeDir) {
|
|
213
|
+
const sorted = [...metas].sort((a, b) => a.seq - b.seq);
|
|
214
|
+
const lines = [
|
|
215
|
+
'# 已压缩历史索引(compacted history index)',
|
|
216
|
+
'',
|
|
217
|
+
'本目录是历次上下文压缩前的原始消息(用户原话、assistant 决策、工具参数与完整结果)。',
|
|
218
|
+
`路径:\`${relativeDir}/\``,
|
|
219
|
+
'需要精确原文(报错、命令/测试输出、旧决策)时,不要凭摘要猜也不要急着重跑:',
|
|
220
|
+
`1. 用 grep 搜索并把 glob 限定在 \`${relativeDir}/**\`(.mocode 在 .gitignore 里,必须 glob 限定才搜得到);`,
|
|
221
|
+
'2. 按命中行号用 read_file 的 offset/limit 精读对应 seg 文件。',
|
|
222
|
+
'',
|
|
223
|
+
];
|
|
224
|
+
for (const m of sorted) {
|
|
225
|
+
const tools = m.tools.length ? m.tools.join(', ') : '-';
|
|
226
|
+
const files = m.files.length ? m.files.join(', ') : '-';
|
|
227
|
+
const first = m.firstUser ? m.firstUser.replace(/\|/g, '/') : '(无 user 消息)';
|
|
228
|
+
lines.push(`- [${m.file}](${m.file}) · ${m.archivedAt} · ${m.turns} turns · ${m.messages} msgs · 工具 ${tools} · 文件 ${files} · 首句:${first}`);
|
|
229
|
+
}
|
|
230
|
+
lines.push('');
|
|
231
|
+
return lines.join('\n');
|
|
232
|
+
}
|
package/dist/session/compact.js
CHANGED
|
@@ -160,6 +160,8 @@ const ANY_HEADING_RE = /^ {0,3}##\s+\S/;
|
|
|
160
160
|
const PRIOR_NARRATIVE_LABEL = '[prior summary — 上一代摘要的叙述部分:与下方新内容合并压缩,不要重复罗列细节]';
|
|
161
161
|
/** 尾部 provenance 块:解析时剥掉,重建时按当次 older 重算(否则跨代累积并重复)。 */
|
|
162
162
|
const ARTIFACT_REFS_RE = /\n*\[artifact refs:[^\]]*\]\s*$/;
|
|
163
|
+
/** 尾部归档指针:同理按当次压缩重算(指向最新 seg),跨代不累积进叙述/Key Facts。 */
|
|
164
|
+
const ARCHIVED_REFS_RE = /\n*\[archived:[^\]]*\]\s*$/;
|
|
163
165
|
/** Key Facts 累计预算(字符):窗口比例换算 + 上下限——小窗口留得住初始约束,
|
|
164
166
|
* 大窗口也不会让索引段膨胀成正文。 */
|
|
165
167
|
export function keyFactsBudgetChars(window) {
|
|
@@ -172,7 +174,7 @@ export function keyFactsBudgetChars(window) {
|
|
|
172
174
|
* older 重算,不该跨代累积。无 Key Facts 段时 rest 为全文、keyFacts 为空。
|
|
173
175
|
*/
|
|
174
176
|
export function splitSummaryText(raw) {
|
|
175
|
-
let text = (raw ?? '').replace(ARTIFACT_REFS_RE, '').trim();
|
|
177
|
+
let text = (raw ?? '').replace(ARCHIVED_REFS_RE, '').replace(ARTIFACT_REFS_RE, '').trim();
|
|
176
178
|
if (text.startsWith(SUMMARY_MARKER)) {
|
|
177
179
|
const nl = text.indexOf('\n');
|
|
178
180
|
text = (nl < 0 ? '' : text.slice(nl + 1)).trim();
|
|
@@ -874,10 +876,25 @@ export async function compactHistory(history, opts) {
|
|
|
874
876
|
if (snapshot)
|
|
875
877
|
writeCompactionSnapshot(snapshot);
|
|
876
878
|
}
|
|
879
|
+
// 旧区原文归档(仅主 agent):摘要成功后这些消息将被整体丢弃且 session.json
|
|
880
|
+
// 随后被覆盖——落盘 compacted/seg-NNN.md,模型事后靠 grep/read_file 回捞精确原文。
|
|
881
|
+
// best-effort:失败只丢回捞能力,绝不阻塞压缩。
|
|
882
|
+
let archivedPath = null;
|
|
883
|
+
if (state === contextState) {
|
|
884
|
+
try {
|
|
885
|
+
archivedPath = getActiveSessionStore().archiveCompactedMessages(getCurrentSessionId() ?? '', older);
|
|
886
|
+
}
|
|
887
|
+
catch {
|
|
888
|
+
archivedPath = null;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
877
891
|
const body = [parsed.rest, pinnedFacts ? `## Key Facts\n${pinnedFacts}` : ''].filter(Boolean).join('\n\n');
|
|
892
|
+
const archivedRef = archivedPath
|
|
893
|
+
? `\n\n[archived: ${archivedPath} — 被压缩内容的精确原文;更早段见同目录 index.md]`
|
|
894
|
+
: '';
|
|
878
895
|
const summaryMsg = {
|
|
879
896
|
role: 'system',
|
|
880
|
-
content: `${SUMMARY_MARKER}\n${body}${provenance}`,
|
|
897
|
+
content: `${SUMMARY_MARKER}\n${body}${provenance}${archivedRef}`,
|
|
881
898
|
};
|
|
882
899
|
// 原地重建:[systemPrompt, summaryMsg, ...kept]
|
|
883
900
|
const systemMsg = history[0];
|
package/dist/session/persist.js
CHANGED
|
@@ -11,9 +11,10 @@ export function newSessionId() {
|
|
|
11
11
|
export function saveSession(history, id, queryHistory = [], lastToolGroups = []) {
|
|
12
12
|
return defaultSessionStore.save(history, id, queryHistory, lastToolGroups);
|
|
13
13
|
}
|
|
14
|
-
/** 加载会话;不存在或损坏时返回 null。
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
/** 加载会话;不存在或损坏时返回 null。restoreAssets=true 时(resume)把归档 gz
|
|
15
|
+
* 内嵌的 compacted 资产写回磁盘;列表/检索等只读场景保持默认不产生写盘副作用。 */
|
|
16
|
+
export function loadSession(id, opts) {
|
|
17
|
+
return defaultSessionStore.load(id, opts);
|
|
17
18
|
}
|
|
18
19
|
/** 列出最近会话,按 createdAt 降序。 */
|
|
19
20
|
export function listSessions(limit) {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
15
|
+
import { COMPACTED_DIRNAME, isCompactedAssetName } from './compact-archive.js';
|
|
15
16
|
import { TextSearchIndex } from '../context/text-search.js';
|
|
16
17
|
import { config } from '../config/index.js';
|
|
17
18
|
import { runReflection, snapshotTranscript } from '../memory/index.js';
|
|
@@ -174,15 +175,45 @@ export function planRetention(sessionsRoot, policy = DEFAULT_RETENTION_POLICY, n
|
|
|
174
175
|
return { items, bytesReclaimable: items.reduce((s, i) => s + i.sizeBytes, 0) };
|
|
175
176
|
}
|
|
176
177
|
// ── 执行 ─────────────────────────────────────────────────────────────────
|
|
177
|
-
/**
|
|
178
|
+
/** 收集 <id>/compacted/ 下白名单资产为 relPath → utf8;无/读失败返空对象。 */
|
|
179
|
+
function readCompactedAssets(sessionsRoot, id) {
|
|
180
|
+
const dir = path.join(sessionsRoot, id, COMPACTED_DIRNAME);
|
|
181
|
+
const assets = {};
|
|
182
|
+
let names;
|
|
183
|
+
try {
|
|
184
|
+
names = readdirSync(dir, { withFileTypes: true })
|
|
185
|
+
.filter((e) => e.isFile())
|
|
186
|
+
.map((e) => e.name);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return assets;
|
|
190
|
+
}
|
|
191
|
+
for (const name of names) {
|
|
192
|
+
if (!isCompactedAssetName(name))
|
|
193
|
+
continue;
|
|
194
|
+
try {
|
|
195
|
+
assets[`${COMPACTED_DIRNAME}/${name}`] = readFileSync(path.join(dir, name), 'utf8');
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// 单个资产读失败不拖垮归档。
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return assets;
|
|
202
|
+
}
|
|
203
|
+
/** 归档单个会话:读 session.json(+compacted 资产)→ gzip → 写 index → 删原目录。失败抛错由调用方统一处理。 */
|
|
178
204
|
function archiveSession(sessionsRoot, id, now) {
|
|
179
205
|
const paths = retentionPaths(sessionsRoot);
|
|
180
206
|
const dir = path.join(sessionsRoot, id);
|
|
181
207
|
const sessionPath = path.join(dir, 'session.json');
|
|
182
208
|
const record = JSON.parse(readFileSync(sessionPath, 'utf8'));
|
|
209
|
+
// 把压缩归档资产(seg/index/截断原文 blob)内嵌进 gz,避免 rmSync 目录时永久丢失
|
|
210
|
+
// ——否则 30 天后 resume,历史回捞链整体消失。purge 时随 gz 一起删。
|
|
211
|
+
const assets = readCompactedAssets(sessionsRoot, id);
|
|
212
|
+
if (Object.keys(assets).length > 0)
|
|
213
|
+
record.compactedAssets = assets;
|
|
183
214
|
mkdirSync(paths.archiveDir, { recursive: true });
|
|
184
215
|
const gzPath = path.join(paths.archiveDir, `${id}.json.gz`);
|
|
185
|
-
writeFileSync(gzPath + '.tmp', gzipSync(
|
|
216
|
+
writeFileSync(gzPath + '.tmp', gzipSync(Buffer.from(JSON.stringify(record), 'utf8')));
|
|
186
217
|
renameSync(gzPath + '.tmp', gzPath);
|
|
187
218
|
upsertArchiveIndex(paths.indexPath, {
|
|
188
219
|
id: record.id,
|
package/dist/session/store.js
CHANGED
|
@@ -3,8 +3,9 @@ import { AsyncLocalStorage } from 'node:async_hooks';
|
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
4
4
|
import { gzipSync } from 'node:zlib';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { MAX_TRACE_BYTES, MAX_TRACE_ROTATIONS } from '../tools/constants.js';
|
|
6
|
+
import { MAX_TRACE_BYTES, MAX_TRACE_ROTATIONS, MAX_COMPACTED_ARCHIVE_BYTES } from '../tools/constants.js';
|
|
7
7
|
import { loadArchivedSession, readArchiveIndex, retentionPaths } from './retention.js';
|
|
8
|
+
import { COMPACTED_DIRNAME, isCompactedAssetName, parseSegmentMeta, renderArchiveIndex, renderCompactedSegment, resultBlobFileName, RESULT_BLOB_FILE_RE, SEGMENT_FILE_RE, } from './compact-archive.js';
|
|
8
9
|
import { config, getActiveModel } from '../config/index.js';
|
|
9
10
|
import { getWorkspaceRoot } from '../workspace/index.js';
|
|
10
11
|
import { isToolRouteGroupName } from '../config/profiles.js';
|
|
@@ -46,6 +47,14 @@ function firstUserOf(history) {
|
|
|
46
47
|
}
|
|
47
48
|
return '';
|
|
48
49
|
}
|
|
50
|
+
/** compacted/ 总量上限:MOCODE_MAX_COMPACTED_BYTES 覆盖(供测试/小盘),非法值回退默认。 */
|
|
51
|
+
function envMaxCompactedBytes() {
|
|
52
|
+
const raw = process.env.MOCODE_MAX_COMPACTED_BYTES;
|
|
53
|
+
if (!raw)
|
|
54
|
+
return MAX_COMPACTED_ARCHIVE_BYTES;
|
|
55
|
+
const n = Number(raw);
|
|
56
|
+
return Number.isFinite(n) && n >= 0 ? n : MAX_COMPACTED_ARCHIVE_BYTES;
|
|
57
|
+
}
|
|
49
58
|
/**
|
|
50
59
|
* Runtime-local session persistence and identity.
|
|
51
60
|
*
|
|
@@ -240,6 +249,175 @@ export class SessionStore {
|
|
|
240
249
|
}
|
|
241
250
|
}
|
|
242
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* 压缩归档:把本次压缩丢弃的旧区消息渲染成 `compacted/seg-NNN.md`,
|
|
254
|
+
* 并重建 `compacted/index.md`。best-effort——这是压缩的增值能力,不是用户数据主副本
|
|
255
|
+
* (session.json 仍是主副本),任何失败静默降级,绝不阻塞压缩。
|
|
256
|
+
*
|
|
257
|
+
* 编号:扫描已有 seg-NNN.md 取 max+1(外部删除/重命名后仍单调)。
|
|
258
|
+
* 淘汰:全部 seg 总量超 MAX_COMPACTED_ARCHIVE_BYTES 时,从最老段硬删并重建 index。
|
|
259
|
+
* 目录缓存失真时与 appendTrace 同模式:清缓存重试一次。
|
|
260
|
+
*
|
|
261
|
+
* @returns 段相对路径(供摘要写指针);失败返 null。
|
|
262
|
+
*/
|
|
263
|
+
archiveCompactedMessages(sessionId, messages) {
|
|
264
|
+
if (!sessionId || messages.length === 0)
|
|
265
|
+
return null;
|
|
266
|
+
const dir = path.join(this.sessionsRoot, sessionId, COMPACTED_DIRNAME);
|
|
267
|
+
const budget = envMaxCompactedBytes();
|
|
268
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
269
|
+
try {
|
|
270
|
+
this.ensureDir(dir);
|
|
271
|
+
const seq = this.nextSegmentSeq(dir);
|
|
272
|
+
const { markdown, meta } = renderCompactedSegment(seq, messages);
|
|
273
|
+
writeFileSync(path.join(dir, meta.file), markdown, 'utf8');
|
|
274
|
+
this.pruneCompacted(dir, budget);
|
|
275
|
+
// 新增了一个 seg,index 必须重建才能列全(prune 是否删旧段不影响这一点;
|
|
276
|
+
// result blob 不走这里,故不需要每次写 blob 都重建)。
|
|
277
|
+
this.rebuildCompactedIndex(sessionId, dir);
|
|
278
|
+
return `.mocode/sessions/${sessionId}/${COMPACTED_DIRNAME}/${meta.file}`;
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
this.invalidateEnsuredDir(dir);
|
|
282
|
+
if (attempt === 1)
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* push-time 截断兜底:工具完整结果在进 history 前被 capToolResultForHistory 裁掉时,
|
|
290
|
+
* 把**完整原文**另存为 `compacted/result-<toolCallId>.txt`,供事后 grep/read_file 回捞。
|
|
291
|
+
* best-effort,写失败静默(绝不阻塞工具回灌);纳入与段相同的总量预算淘汰。
|
|
292
|
+
*
|
|
293
|
+
* @returns 全文相对路径(供 history 截断内容留指针);失败返 null。
|
|
294
|
+
*/
|
|
295
|
+
archiveToolResult(sessionId, toolCallId, fullOutput) {
|
|
296
|
+
if (!sessionId || !toolCallId || !fullOutput)
|
|
297
|
+
return null;
|
|
298
|
+
const dir = path.join(this.sessionsRoot, sessionId, COMPACTED_DIRNAME);
|
|
299
|
+
const fileName = resultBlobFileName(toolCallId);
|
|
300
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
301
|
+
try {
|
|
302
|
+
this.ensureDir(dir);
|
|
303
|
+
writeFileSync(path.join(dir, fileName), fullOutput, 'utf8');
|
|
304
|
+
this.pruneCompacted(dir, envMaxCompactedBytes());
|
|
305
|
+
return `.mocode/sessions/${sessionId}/${COMPACTED_DIRNAME}/${fileName}`;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
this.invalidateEnsuredDir(dir);
|
|
309
|
+
if (attempt === 1)
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* resume 归档会话时,把 gz 内嵌的 compacted 资产(seg/index/截断原文 blob)写回磁盘,
|
|
317
|
+
* 恢复历史回捞链。best-effort:任何坏条目跳过。防 zip-slip:只接受 compacted/ 下
|
|
318
|
+
* 白名单单层文件名。
|
|
319
|
+
*/
|
|
320
|
+
restoreCompactedAssets(id, record) {
|
|
321
|
+
const assets = record.compactedAssets;
|
|
322
|
+
if (!assets || typeof assets !== 'object')
|
|
323
|
+
return;
|
|
324
|
+
const dir = path.join(this.sessionsRoot, id, COMPACTED_DIRNAME);
|
|
325
|
+
for (const [relPath, content] of Object.entries(assets)) {
|
|
326
|
+
if (typeof content !== 'string')
|
|
327
|
+
continue;
|
|
328
|
+
const base = relPath.startsWith(`${COMPACTED_DIRNAME}/`) ? relPath.slice(COMPACTED_DIRNAME.length + 1) : null;
|
|
329
|
+
if (!base || base.includes('/') || base.includes('\\') || !isCompactedAssetName(base))
|
|
330
|
+
continue;
|
|
331
|
+
try {
|
|
332
|
+
this.ensureDir(dir);
|
|
333
|
+
writeFileSync(path.join(dir, base), content, 'utf8');
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
this.invalidateEnsuredDir(dir);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/** 扫描 seg-NNN.md 取下一个编号;损坏文件名跳过。 */
|
|
341
|
+
nextSegmentSeq(dir) {
|
|
342
|
+
let max = 0;
|
|
343
|
+
for (const name of this.listDirNames(dir)) {
|
|
344
|
+
const m = SEGMENT_FILE_RE.exec(name);
|
|
345
|
+
if (m)
|
|
346
|
+
max = Math.max(max, Number(m[1]));
|
|
347
|
+
}
|
|
348
|
+
return max + 1;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* compacted/ 全目录(seg 段 + result blob)统一字节预算淘汰:
|
|
352
|
+
* 超 MAX_COMPACTED_ARCHIVE_BYTES 时从最老文件(mtime,再按名)硬删。
|
|
353
|
+
* 唯一豁免 = 编号最大的那个 seg(始终保住最近一代压缩的完整回捞入口);
|
|
354
|
+
* index.md 不占删除候选(段变更后重建)。
|
|
355
|
+
*
|
|
356
|
+
* @returns 是否有 seg 被删(调用方据此重建 index)。
|
|
357
|
+
*/
|
|
358
|
+
pruneCompacted(dir, budget) {
|
|
359
|
+
if (!(budget > 0))
|
|
360
|
+
return false;
|
|
361
|
+
const newestSeq = this.listDirNames(dir).reduce((max, name) => {
|
|
362
|
+
const m = SEGMENT_FILE_RE.exec(name);
|
|
363
|
+
return m ? Math.max(max, Number(m[1])) : max;
|
|
364
|
+
}, 0);
|
|
365
|
+
const candidates = this.listDirNames(dir)
|
|
366
|
+
.filter((name) => SEGMENT_FILE_RE.test(name) || RESULT_BLOB_FILE_RE.test(name))
|
|
367
|
+
.map((name) => {
|
|
368
|
+
const m = SEGMENT_FILE_RE.exec(name);
|
|
369
|
+
const st = statSync(path.join(dir, name));
|
|
370
|
+
return { name, size: st.size, mtimeMs: st.mtimeMs, seq: m ? Number(m[1]) : null };
|
|
371
|
+
})
|
|
372
|
+
// 最老在前:blob 与跨类型文件按 mtime;同刻按名,保证确定性。
|
|
373
|
+
.sort((a, b) => a.mtimeMs - b.mtimeMs || (a.name < b.name ? -1 : 1));
|
|
374
|
+
let total = candidates.reduce((s, e) => s + e.size, 0);
|
|
375
|
+
let removedSeg = false;
|
|
376
|
+
for (const e of candidates) {
|
|
377
|
+
if (total <= budget)
|
|
378
|
+
break;
|
|
379
|
+
if (e.seq !== null && e.seq === newestSeq)
|
|
380
|
+
continue; // 永不删最新段
|
|
381
|
+
try {
|
|
382
|
+
unlinkSync(path.join(dir, e.name));
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
break; // 删不动就停止淘汰,避免死循环
|
|
386
|
+
}
|
|
387
|
+
total -= e.size;
|
|
388
|
+
if (e.seq !== null)
|
|
389
|
+
removedSeg = true;
|
|
390
|
+
}
|
|
391
|
+
return removedSeg;
|
|
392
|
+
}
|
|
393
|
+
/** 仅凭现存 seg 文件重建 index.md(段被外部增删后也一致)。 */
|
|
394
|
+
rebuildCompactedIndex(sessionId, dir) {
|
|
395
|
+
const metas = [];
|
|
396
|
+
for (const name of this.listDirNames(dir)) {
|
|
397
|
+
if (!SEGMENT_FILE_RE.test(name))
|
|
398
|
+
continue;
|
|
399
|
+
try {
|
|
400
|
+
const meta = parseSegmentMeta(readFileSync(path.join(dir, name), 'utf8'));
|
|
401
|
+
if (meta)
|
|
402
|
+
metas.push(meta);
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
// 单个段损坏不拖垮索引。
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const relativeDir = `.mocode/sessions/${sessionId}/compacted`;
|
|
409
|
+
writeFileSync(path.join(dir, 'index.md'), renderArchiveIndex(metas, relativeDir), 'utf8');
|
|
410
|
+
}
|
|
411
|
+
listDirNames(dir) {
|
|
412
|
+
try {
|
|
413
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
414
|
+
.filter((e) => e.isFile())
|
|
415
|
+
.map((e) => e.name);
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return [];
|
|
419
|
+
}
|
|
420
|
+
}
|
|
243
421
|
/** 读取 <id>/usage.jsonl 全部记录;文件不存在或损坏行跳过。 */
|
|
244
422
|
readUsage(id) {
|
|
245
423
|
const file = path.join(this.sessionsRoot, id, 'usage.jsonl');
|
|
@@ -308,14 +486,20 @@ export class SessionStore {
|
|
|
308
486
|
}
|
|
309
487
|
}
|
|
310
488
|
}
|
|
311
|
-
load(id) {
|
|
489
|
+
load(id, opts) {
|
|
312
490
|
const currentPath = this.sessionPath(id);
|
|
313
491
|
const legacyPath = path.join(this.sessionsRoot, `${id}.json`);
|
|
314
492
|
const source = existsSync(currentPath) ? currentPath : legacyPath;
|
|
315
493
|
// 活会话与 legacy 单文件都没有:回退从归档 gz 取回(30-90 天的会话仍可 resume)。
|
|
316
494
|
if (!existsSync(source)) {
|
|
317
495
|
const archived = loadArchivedSession(this.sessionsRoot, id);
|
|
318
|
-
|
|
496
|
+
if (!archived)
|
|
497
|
+
return null;
|
|
498
|
+
// 仅 resume 显式要求时把 gz 内嵌的 compacted 资产写回磁盘(load 也被会话列表等
|
|
499
|
+
// 只读路径调用,不能在那些场景产生写盘副作用)。
|
|
500
|
+
if (opts?.restoreAssets)
|
|
501
|
+
this.restoreCompactedAssets(id, archived);
|
|
502
|
+
return archived;
|
|
319
503
|
}
|
|
320
504
|
try {
|
|
321
505
|
const rec = JSON.parse(readFileSync(source, 'utf8'));
|
|
@@ -160,7 +160,8 @@ export const grepTool = {
|
|
|
160
160
|
name: 'grep',
|
|
161
161
|
description: 'Search file contents by regex (recursive; powered by ripgrep: respects .gitignore, skips binaries).\n' +
|
|
162
162
|
'Output: per-file header "<path>: N matches, lines [l1, l2, ...]" plus rendered lines with ORIGINAL INDENTATION kept.\n' +
|
|
163
|
-
'Pass context=2..5 to include neighbouring lines inline (like ripgrep -C) instead of following each hit with a read_file round-trip. Hard wall 120s; narrow the glob if a tree contains network mounts. Use read_file when you need a whole region or exact edit text
|
|
163
|
+
'Pass context=2..5 to include neighbouring lines inline (like ripgrep -C) instead of following each hit with a read_file round-trip. Hard wall 120s; narrow the glob if a tree contains network mounts. Use read_file when you need a whole region or exact edit text.\n' +
|
|
164
|
+
'Note: .mocode/ is gitignored; to search session data (e.g. .mocode/sessions/<id>/compacted archived history) you MUST narrow glob to that path, e.g. glob=".mocode/sessions/**".',
|
|
164
165
|
parameters: {
|
|
165
166
|
type: 'object',
|
|
166
167
|
properties: {
|
|
@@ -200,7 +201,9 @@ export const grepTool = {
|
|
|
200
201
|
controller.abort();
|
|
201
202
|
}, GREP_TIMEOUT_MS);
|
|
202
203
|
try {
|
|
203
|
-
|
|
204
|
+
// glob 显式限定到 .mocode(gitignore 内)时放开 VCS ignore,否则 rg 零文件退出。
|
|
205
|
+
const noIgnoreVcs = glob === '.mocode/**' || glob.startsWith('.mocode/');
|
|
206
|
+
const result = await runRipgrep({ pattern, glob, context, noIgnoreVcs }, controller.signal);
|
|
204
207
|
switch (result.status) {
|
|
205
208
|
case 'regex_error':
|
|
206
209
|
return `错误:非法正则 ${pattern}: ${result.message}`;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { addTriple, graphStats, neighborsOf, pathBetween } from '../../memory/graph.js';
|
|
2
|
+
import { formatRecallStats } from '../../memory/recall-stats.js';
|
|
2
3
|
// ---------- memory_graph ----------
|
|
3
4
|
// 知识图谱维护工具:邻居遍历(1-3 跳,可 relation 过滤)、两点最短路径、手工加三元组、看图统计。
|
|
4
5
|
// 关键词搜索已并入 memory_search(条目 + 图谱事实一次返回),本工具不再提供 search。
|
|
@@ -55,6 +56,7 @@ export const memoryGraphTool = {
|
|
|
55
56
|
`实体 ${s.entities} 条边(active ${s.edgesActive} / 已失效 ${s.edgesInvalid})`,
|
|
56
57
|
`project: ${s.byScope.project.entities} 实体, ${s.byScope.project.edges} 边`,
|
|
57
58
|
`global: ${s.byScope.global.entities} 实体, ${s.byScope.global.edges} 边`,
|
|
59
|
+
formatRecallStats(),
|
|
58
60
|
].join('\n');
|
|
59
61
|
}
|
|
60
62
|
if (action === 'neighbors') {
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { searchEntries } from '../../memory/store.js';
|
|
2
|
-
import {
|
|
2
|
+
import { searchGraphFacts } from '../../memory/graph.js';
|
|
3
|
+
import { recordRecallSearch } from '../../memory/recall-stats.js';
|
|
3
4
|
// ---------- memory_search ----------
|
|
4
5
|
// 唯一记忆搜索入口:关键词搜记忆正文(多词子串匹配,name 权重最高)+ 知识图谱事实段
|
|
5
|
-
// (
|
|
6
|
-
//
|
|
6
|
+
// (CJK 分词加权、边级相关度 + 一跳邻接,跨 global/project 两 scope)。命中条目即 bump
|
|
7
|
+
// recallCount(遗忘衰减依据)。结果走 capToolResultForHistory 的放宽上限(同 use_skill)。
|
|
7
8
|
const GRAPH_FACTS_LIMIT = 10;
|
|
8
9
|
export const memorySearchTool = {
|
|
9
10
|
name: 'memory_search',
|
|
10
|
-
description: 'Search memory entries by keyword (substring match), returning full body. Also surfaces knowledge-graph facts (active edges)
|
|
11
|
+
description: 'Search memory entries by keyword (substring match), returning full body. Also surfaces knowledge-graph facts (active edges across project + global), ranked by CJK-aware term overlap on entity names/aliases/relation/fact, plus one-hop neighbor edges for context.',
|
|
11
12
|
parameters: {
|
|
12
13
|
type: 'object',
|
|
13
14
|
properties: {
|
|
@@ -37,23 +38,28 @@ export const memorySearchTool = {
|
|
|
37
38
|
const entryText = r
|
|
38
39
|
.map((e) => `# [${e.id}] ${e.name} (${e.type}, recalled ${e.recallCount})\nsummary: ${e.summary}\n\n${e.body}`)
|
|
39
40
|
.join('\n\n---\n\n');
|
|
40
|
-
//
|
|
41
|
+
// 知识图谱事实段:边级相关度检索(容错:图坏了不连累条目搜索)。
|
|
41
42
|
let graphText = '';
|
|
43
|
+
let graphHit = false;
|
|
42
44
|
try {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
?
|
|
50
|
-
: '';
|
|
51
|
-
|
|
45
|
+
const hits = searchGraphFacts(query, { scope: 'all', limit: GRAPH_FACTS_LIMIT, neighbors: 4 });
|
|
46
|
+
graphHit = hits.length > 0;
|
|
47
|
+
if (graphHit) {
|
|
48
|
+
const lines = hits.map((h) => {
|
|
49
|
+
const src = h.srcEntity?.name ?? h.edge.src;
|
|
50
|
+
const dst = h.dstEntity?.name ?? h.edge.dst;
|
|
51
|
+
const fact = h.edge.fact ? ` (${h.edge.fact})` : '';
|
|
52
|
+
const tag = h.viaNeighbor ? ' [neighbor]' : '';
|
|
53
|
+
const scopeTag = h.edge.scope === 'global' ? '[global]' : '[project]';
|
|
54
|
+
return `${scopeTag} ${src} --[${h.edge.relation}]--> ${dst}${fact}${tag}`;
|
|
55
|
+
});
|
|
56
|
+
graphText = `\n\n## 知识图谱事实\n${lines.join('\n')}`;
|
|
52
57
|
}
|
|
53
58
|
}
|
|
54
59
|
catch {
|
|
55
60
|
// 静默:图谱段是增强,失败只降级为纯条目结果
|
|
56
61
|
}
|
|
62
|
+
recordRecallSearch(graphHit); // 埋点:pull 召回使用情况(写盘失败静默)
|
|
57
63
|
if (!entryText && !graphText)
|
|
58
64
|
return `(无匹配记忆:query="${query}")`;
|
|
59
65
|
if (!entryText)
|