mocode-ai 0.1.3 → 0.1.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/README.md +196 -161
- package/dist/agent/core.js +302 -0
- package/dist/agent/index.js +85 -202
- package/dist/agent/mode.js +46 -0
- package/dist/agent/spawn.js +123 -0
- package/dist/commands/config.js +8 -26
- package/dist/config/file.js +48 -0
- package/dist/config/index.js +76 -22
- package/dist/llm/index.js +11 -9
- package/dist/memory/discover.js +49 -0
- package/dist/memory/index.js +45 -0
- package/dist/memory/reflect.js +265 -0
- package/dist/memory/store.js +339 -0
- package/dist/repl/index.js +334 -87
- package/dist/session/compact.js +51 -3
- package/dist/skills/index.js +2 -2
- package/dist/tools/builtins/ask-human.js +50 -0
- package/dist/tools/builtins/codegraph.js +113 -0
- package/dist/tools/builtins/edit-file.js +3 -3
- package/dist/tools/builtins/glob.js +3 -2
- package/dist/tools/builtins/grep.js +4 -3
- package/dist/tools/builtins/index.js +19 -1
- package/dist/tools/builtins/memory-forget.js +32 -0
- package/dist/tools/builtins/memory-list.js +34 -0
- package/dist/tools/builtins/memory-save.js +51 -0
- package/dist/tools/builtins/memory-search.js +42 -0
- package/dist/tools/builtins/memory-update.js +41 -0
- package/dist/tools/builtins/read-file.js +5 -4
- package/dist/tools/builtins/run-command.js +40 -7
- package/dist/tools/builtins/switch-mode.js +46 -0
- package/dist/tools/builtins/task.js +62 -0
- package/dist/tools/builtins/use-skill.js +2 -2
- package/dist/tools/builtins/web-fetch.js +16 -3
- package/dist/tools/builtins/web-search.js +6 -6
- package/dist/tools/builtins/write-file.js +3 -3
- package/dist/tools/constants.js +29 -0
- package/dist/tools/registry.js +11 -4
- package/dist/ui/content.js +25 -17
- package/dist/ui/diff.js +28 -26
- package/dist/ui/intervention.js +318 -0
- package/dist/ui/layout.js +402 -103
- package/dist/ui/markdown.js +607 -0
- package/dist/ui/mouse.js +93 -0
- package/dist/ui/prompt.js +469 -27
- package/dist/ui/render.js +19 -1
- package/dist/ui/spinner.js +15 -0
- package/dist/ui/theme.js +147 -13
- package/package.json +2 -2
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// memory 反思 pass:后台异步复盘近期会话 + 现有记忆,产出 saves/updates/forgets 落地。
|
|
2
|
+
// 依赖 llm(chat)——同 session/compact.ts 模式,非环(llm 不反向依赖 memory)。
|
|
3
|
+
// store.ts 仍是叶子;本模块是 memory 子系统里唯一调 LLM 的部分。
|
|
4
|
+
//
|
|
5
|
+
// 异步与静默:kickoffReflection fire-and-forget(repl 轮末调),与下一轮 agent 并发跑;
|
|
6
|
+
// 期间不碰 contentWrite / 状态行(否则与 RUNNING 态 agent 争屏)。结果缓存到 lastReflectResult,
|
|
7
|
+
// repl 在下次进 INPUT 态的安全点 flush 一行 dim 摘要。错误写 <cwd>/.mocode/memory.log。
|
|
8
|
+
//
|
|
9
|
+
// 并发安全:store 读写全同步(单 tick 原子),本模块里唯一让出事件循环的是 await chat();
|
|
10
|
+
// 其前后的 store 调用不会与 agent 的 store 调用交错(单线程 + await 间不重叠)→ 无竞态。
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { chat } from '../llm/index.js';
|
|
14
|
+
import { config } from '../config/index.js';
|
|
15
|
+
import { saveEntry, updateEntry, forgetEntry, loadAll, gcMemories, } from './store.js';
|
|
16
|
+
// ── 日志(静默容错,裁尾保最近)─────────────────────────────────────────────
|
|
17
|
+
function logPath() {
|
|
18
|
+
return path.join(process.cwd(), '.mocode', 'memory.log');
|
|
19
|
+
}
|
|
20
|
+
function appendLog(line) {
|
|
21
|
+
try {
|
|
22
|
+
const p = logPath();
|
|
23
|
+
const dir = path.dirname(p);
|
|
24
|
+
if (!existsSync(dir))
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
let content = '';
|
|
27
|
+
if (existsSync(p))
|
|
28
|
+
content = readFileSync(p, 'utf8');
|
|
29
|
+
content = (content + '\n' + line).slice(-20000);
|
|
30
|
+
writeFileSync(p, content, 'utf8');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// 静默:日志失败不阻断
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// ── 转录快照(同步,避免下一轮 mutate history 的竞态)─────────────────────────
|
|
37
|
+
function textOf(c) {
|
|
38
|
+
if (typeof c === 'string')
|
|
39
|
+
return c;
|
|
40
|
+
if (c == null)
|
|
41
|
+
return '';
|
|
42
|
+
if (Array.isArray(c)) {
|
|
43
|
+
return c
|
|
44
|
+
.map((p) => (typeof p === 'string' ? p : p?.text ?? ''))
|
|
45
|
+
.join('');
|
|
46
|
+
}
|
|
47
|
+
return String(c);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 同步拍平最近 K 条对话(跳过 history[0] 大系统提示)。每条裁到 600 字符、总体裁到 6000,
|
|
51
|
+
* 控制反思 prompt 体积。在 kickoff 调用前同步取快照,异步 pass 用这份文本,不再读 history。
|
|
52
|
+
*/
|
|
53
|
+
export function snapshotTranscript(history, K) {
|
|
54
|
+
const convo = history.slice(1).slice(-K);
|
|
55
|
+
const lines = convo.map((m) => {
|
|
56
|
+
const role = m.role ?? '?';
|
|
57
|
+
let line = `${role}: ${textOf(m.content)}`;
|
|
58
|
+
const tcs = m
|
|
59
|
+
.tool_calls;
|
|
60
|
+
if (Array.isArray(tcs)) {
|
|
61
|
+
for (const tc of tcs) {
|
|
62
|
+
line += `\n [tool_call ${tc?.function?.name ?? ''}] ${tc?.function?.arguments ?? ''}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (line.length > 600)
|
|
66
|
+
line = line.slice(0, 586) + '…[截断]';
|
|
67
|
+
return line;
|
|
68
|
+
});
|
|
69
|
+
const joined = lines.join('\n');
|
|
70
|
+
if (joined.length <= 6000)
|
|
71
|
+
return joined;
|
|
72
|
+
return joined.slice(0, 5980) + '\n…[转录已截断]';
|
|
73
|
+
}
|
|
74
|
+
// ── 记忆样本(供 LLM 判断 update/forget)──────────────────────────────────────
|
|
75
|
+
function buildMemorySample() {
|
|
76
|
+
const all = loadAll().filter((e) => e.status === 'active');
|
|
77
|
+
if (all.length === 0)
|
|
78
|
+
return '(无)';
|
|
79
|
+
const byCreated = [...all].sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || ''));
|
|
80
|
+
const byRecall = [...all].sort((a, b) => a.recallCount - b.recallCount);
|
|
81
|
+
const picked = new Map();
|
|
82
|
+
for (const e of byCreated.slice(0, 10))
|
|
83
|
+
picked.set(e.id, e);
|
|
84
|
+
for (const e of byRecall.slice(0, 10))
|
|
85
|
+
picked.set(e.id, e);
|
|
86
|
+
const list = [...picked.values()].slice(0, 20);
|
|
87
|
+
return list
|
|
88
|
+
.map((e) => {
|
|
89
|
+
const body = e.body.length > 600 ? e.body.slice(0, 586) + '…[截断]' : e.body;
|
|
90
|
+
return `[${e.id}] ${e.name} (${e.type}, recalled ${e.recallCount})\nsummary: ${e.summary}\nbody: ${body}`;
|
|
91
|
+
})
|
|
92
|
+
.join('\n---\n');
|
|
93
|
+
}
|
|
94
|
+
const TYPES = 'decision | fact | pitfall | reference | feedback';
|
|
95
|
+
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 three arrays are empty).
|
|
98
|
+
Rules:
|
|
99
|
+
① Only store non-obvious, cross-session-useful facts/decisions/pitfalls; do not store current bugs, temp files, undecided TODOs, or volatile items;
|
|
100
|
+
② Better to store less than to store trivially correct info (e.g. "keep it concise");
|
|
101
|
+
③ ids in updates/forgets must come from the "existing memories" list below; do not fabricate ids not listed there;
|
|
102
|
+
④ names in saves must be concise and not collide with existing ones; type ∈ {${TYPES}};
|
|
103
|
+
⑤ 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
|
+
const REFLECT_USER = (transcript, sample) => `## Recent session\n${transcript}\n\n## Existing memories\n${sample}\n\nProduce JSON:`;
|
|
106
|
+
function parsePlan(content) {
|
|
107
|
+
if (!content)
|
|
108
|
+
return null;
|
|
109
|
+
let s = content.trim();
|
|
110
|
+
// 去 ```json … ``` 代码块(模型偶发包裹)
|
|
111
|
+
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
112
|
+
if (fence)
|
|
113
|
+
s = fence[1].trim();
|
|
114
|
+
// 容错:截到首个 { 到末个 }
|
|
115
|
+
const first = s.indexOf('{');
|
|
116
|
+
const last = s.lastIndexOf('}');
|
|
117
|
+
if (first >= 0 && last > first)
|
|
118
|
+
s = s.slice(first, last + 1);
|
|
119
|
+
try {
|
|
120
|
+
const p = JSON.parse(s);
|
|
121
|
+
if (!p || typeof p !== 'object')
|
|
122
|
+
return null;
|
|
123
|
+
return p;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 跑一次反思:chat() 空 handlers(静默)→ 解析 JSON → 经 store 落地 → gcMemories。
|
|
131
|
+
* 解析失败整 pass 放弃(不部分落地)。store 调用全同步,落地是一个原子块。
|
|
132
|
+
* 60s 超时(AbortSignal.timeout)防 exit 时 drain 挂死。
|
|
133
|
+
*/
|
|
134
|
+
export async function runReflection(transcript, signal) {
|
|
135
|
+
const ts = new Date().toISOString();
|
|
136
|
+
const sample = buildMemorySample();
|
|
137
|
+
const sys = { role: 'system', content: REFLECT_SYS };
|
|
138
|
+
const user = { role: 'user', content: REFLECT_USER(transcript, sample) };
|
|
139
|
+
let result = {
|
|
140
|
+
ts,
|
|
141
|
+
saves: 0,
|
|
142
|
+
updates: 0,
|
|
143
|
+
forgets: 0,
|
|
144
|
+
gcDecayed: 0,
|
|
145
|
+
gcCapped: 0,
|
|
146
|
+
gcGced: 0,
|
|
147
|
+
};
|
|
148
|
+
let content = null;
|
|
149
|
+
try {
|
|
150
|
+
const r = await chat([sys, user], {}, signal ?? AbortSignal.timeout(60000));
|
|
151
|
+
// 推理模型偶发只返 reasoning_content(content null)或幻觉 tool_calls → 视为无产出
|
|
152
|
+
if (!r.toolCalls.length && r.content)
|
|
153
|
+
content = r.content;
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
result.error = e instanceof Error ? e.name + ': ' + e.message : String(e);
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
const plan = parsePlan(content);
|
|
160
|
+
if (!plan) {
|
|
161
|
+
result.error = 'parse-failed';
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
let saves = 0;
|
|
165
|
+
let updates = 0;
|
|
166
|
+
let forgets = 0;
|
|
167
|
+
if (Array.isArray(plan.saves)) {
|
|
168
|
+
for (const s of plan.saves) {
|
|
169
|
+
if (!s?.name || !s?.summary)
|
|
170
|
+
continue;
|
|
171
|
+
const r = saveEntry({
|
|
172
|
+
name: String(s.name),
|
|
173
|
+
summary: String(s.summary),
|
|
174
|
+
body: String(s.body ?? s.summary),
|
|
175
|
+
type: normalizeType(s.type),
|
|
176
|
+
});
|
|
177
|
+
if (r.ok)
|
|
178
|
+
saves++;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (Array.isArray(plan.updates)) {
|
|
182
|
+
for (const u of plan.updates) {
|
|
183
|
+
if (!u?.id)
|
|
184
|
+
continue;
|
|
185
|
+
const r = updateEntry(String(u.id), {
|
|
186
|
+
summary: u.summary ? String(u.summary) : undefined,
|
|
187
|
+
body: u.body ? String(u.body) : undefined,
|
|
188
|
+
reason: u.reason ? String(u.reason) : undefined,
|
|
189
|
+
});
|
|
190
|
+
if (r.ok)
|
|
191
|
+
updates++;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (Array.isArray(plan.forgets)) {
|
|
195
|
+
for (const f of plan.forgets) {
|
|
196
|
+
if (!f?.id)
|
|
197
|
+
continue;
|
|
198
|
+
const r = forgetEntry(String(f.id), 'archive');
|
|
199
|
+
if (r.ok)
|
|
200
|
+
forgets++;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const gc = gcMemories();
|
|
204
|
+
result = { ...result, saves, updates, forgets, gcDecayed: gc.decayed, gcCapped: gc.capped, gcGced: gc.gced };
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
function normalizeType(t) {
|
|
208
|
+
if (typeof t !== 'string')
|
|
209
|
+
return undefined;
|
|
210
|
+
const v = t.trim().toLowerCase();
|
|
211
|
+
if (v === 'decision' || v === 'fact' || v === 'pitfall' || v === 'reference' || v === 'feedback')
|
|
212
|
+
return v;
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
// ── 后台编排:kickoff / drain / 缓存 ─────────────────────────────────────────
|
|
216
|
+
let inflight = null;
|
|
217
|
+
let lastReflectResult = null;
|
|
218
|
+
/** 摘要串(供 repl flush):存N 改N 忘N;有错误附上。 */
|
|
219
|
+
export function formatReflectResult(r) {
|
|
220
|
+
const parts = [`存${r.saves}`, `改${r.updates}`, `忘${r.forgets}`];
|
|
221
|
+
if (r.gcDecayed || r.gcCapped || r.gcGced) {
|
|
222
|
+
parts.push(`遗忘(衰减${r.gcDecayed}/封顶${r.gcCapped}/清除${r.gcGced})`);
|
|
223
|
+
}
|
|
224
|
+
return `记忆反思:${parts.join(' ')}${r.error ? ` [${r.error}]` : ''}`;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* fire-and-forget 触发反思。已有在飞任务 / autoReflect 关闭 → 跳过。
|
|
228
|
+
* repl 轮末调:与下一轮 agent 并发跑,不阻塞。
|
|
229
|
+
*/
|
|
230
|
+
export function kickoffReflection(transcript) {
|
|
231
|
+
if (inflight)
|
|
232
|
+
return;
|
|
233
|
+
if (!config.autoReflect)
|
|
234
|
+
return;
|
|
235
|
+
inflight = (async () => {
|
|
236
|
+
try {
|
|
237
|
+
const r = await runReflection(transcript);
|
|
238
|
+
lastReflectResult = r;
|
|
239
|
+
appendLog(`[${r.ts}] ${formatReflectResult(r)}`);
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
appendLog(`[${new Date().toISOString()}] 反思异常: ${e instanceof Error ? e.message : String(e)}`);
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
inflight = null;
|
|
246
|
+
}
|
|
247
|
+
})();
|
|
248
|
+
}
|
|
249
|
+
/** 退出前等在飞反思收尾(startRepl 尾调;Ctrl+C 走 SIGINT 直退不等)。 */
|
|
250
|
+
export async function drainMemoryBackground() {
|
|
251
|
+
if (inflight) {
|
|
252
|
+
try {
|
|
253
|
+
await inflight;
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// 已在 kickoff 内 log
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
export function getLastReflectResult() {
|
|
261
|
+
return lastReflectResult;
|
|
262
|
+
}
|
|
263
|
+
export function clearLastReflectResult() {
|
|
264
|
+
lastReflectResult = null;
|
|
265
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// memory 工具库(Tier-2):JSONL 存储 + CRUD + 遗忘 GC + 索引段。
|
|
2
|
+
// 叶子模块:仅依赖 node 标准库 + tools/constants(常量叶子),不反向依赖 agent/llm/skills/config/ui,
|
|
3
|
+
// 避免环(反思 pass 要调 LLM,单独放 reflect.ts,同 session/ 模式)。
|
|
4
|
+
//
|
|
5
|
+
// 约定:一行一条 JSON(JSONL)。突变统一走「整文件读改写 + tmp+rename 原子落盘」(对齐
|
|
6
|
+
// discover.ts/persist.ts 的同步 fs 风格)。所有读写全同步(无 await),单 tick 原子——反思里唯一让出
|
|
7
|
+
// 事件循环的是 chat(),其前后的 store 读写不会与 agent 的 store 调用交错(单线程 + await 间不重叠)→ 无竞态、无锁。
|
|
8
|
+
//
|
|
9
|
+
// 两文件:全局 ~/.mocode/memory.jsonl + 项目 <cwd>/.mocode/memory.jsonl(镜像 resolveMemoryFiles
|
|
10
|
+
// 的 global+cwd 与 sessionDir 的 cwd 习惯)。条目带 scope 字段标识归属,loadAll 时按文件归一化。
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { MAX_ACTIVE, MAX_INDEX_ENTRIES, MAX_MEMORY_ENTRY, DECAY_DAYS, GC_DAYS, } from '../tools/constants.js';
|
|
15
|
+
// ── 路径 ──────────────────────────────────────────────────────────────────
|
|
16
|
+
function globalPath() {
|
|
17
|
+
return path.join(os.homedir(), '.mocode', 'memory.jsonl');
|
|
18
|
+
}
|
|
19
|
+
function projectPath() {
|
|
20
|
+
return path.join(process.cwd(), '.mocode', 'memory.jsonl');
|
|
21
|
+
}
|
|
22
|
+
function pathForScope(scope) {
|
|
23
|
+
return scope === 'global' ? globalPath() : projectPath();
|
|
24
|
+
}
|
|
25
|
+
function ensureDir(p) {
|
|
26
|
+
const dir = path.dirname(p);
|
|
27
|
+
if (!existsSync(dir))
|
|
28
|
+
mkdirSync(dir, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
// ── 原子写 / 同步读 ───────────────────────────────────────────────────────
|
|
31
|
+
/** 整文件原子落盘:写 tmp 再 rename(POSIX 原子;Windows rename 覆盖既有文件)。空数组写空文件。 */
|
|
32
|
+
function writeAtomic(p, entries) {
|
|
33
|
+
const lines = entries.map((e) => JSON.stringify(e)).join('\n');
|
|
34
|
+
const tmp = p + '.tmp';
|
|
35
|
+
writeFileSync(tmp, lines, 'utf8');
|
|
36
|
+
renameSync(tmp, p);
|
|
37
|
+
}
|
|
38
|
+
/** 读一个文件的条目(静默容错:不存在 / 读失败 / 行非法 → 跳过,不抛)。 */
|
|
39
|
+
function readFileEntries(p) {
|
|
40
|
+
if (!existsSync(p))
|
|
41
|
+
return [];
|
|
42
|
+
let content;
|
|
43
|
+
try {
|
|
44
|
+
content = readFileSync(p, 'utf8');
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
if (!content.trim())
|
|
50
|
+
return [];
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const line of content.split('\n')) {
|
|
53
|
+
if (!line.trim())
|
|
54
|
+
continue;
|
|
55
|
+
try {
|
|
56
|
+
const e = JSON.parse(line);
|
|
57
|
+
if (e && typeof e.id === 'string')
|
|
58
|
+
out.push(e);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
continue; // 单行损坏跳过,不连累全文件
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/** 加载全部(global + project),按文件归一化 scope(防旧条目缺字段 / 字段错)。 */
|
|
67
|
+
export function loadAll() {
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const scope of ['global', 'project']) {
|
|
70
|
+
for (const e of readFileEntries(pathForScope(scope))) {
|
|
71
|
+
e.scope = scope; // 归一化:以实际所在文件为准
|
|
72
|
+
out.push(e);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/** 按 scope 分组写回:仅写有条目或文件已存在的 scope(避免凭空建空文件)。 */
|
|
78
|
+
function writeBackByScope(all) {
|
|
79
|
+
for (const scope of ['global', 'project']) {
|
|
80
|
+
const entries = all.filter((e) => e.scope === scope);
|
|
81
|
+
const p = pathForScope(scope);
|
|
82
|
+
if (entries.length === 0 && !existsSync(p))
|
|
83
|
+
continue;
|
|
84
|
+
ensureDir(p);
|
|
85
|
+
writeAtomic(p, entries);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// ── 工具:slugify / 截断 ───────────────────────────────────────────────────
|
|
89
|
+
let slugCounter = 0;
|
|
90
|
+
/** name → ASCII slug;空(纯 CJK 等)则 m+时间戳+计数防同毫秒碰撞。 */
|
|
91
|
+
function slugify(name) {
|
|
92
|
+
const s = name
|
|
93
|
+
.toLowerCase()
|
|
94
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
95
|
+
.replace(/^-+|-+$/g, '');
|
|
96
|
+
if (s)
|
|
97
|
+
return s;
|
|
98
|
+
slugCounter++;
|
|
99
|
+
return 'm' + Date.now().toString(36) + slugCounter.toString(36);
|
|
100
|
+
}
|
|
101
|
+
function truncateBody(body) {
|
|
102
|
+
if (body.length <= MAX_MEMORY_ENTRY)
|
|
103
|
+
return body;
|
|
104
|
+
const removed = body.length - MAX_MEMORY_ENTRY;
|
|
105
|
+
const marker = `…[已截断 ${removed} 字符]…`;
|
|
106
|
+
return body.slice(0, Math.max(0, MAX_MEMORY_ENTRY - marker.length)) + marker;
|
|
107
|
+
}
|
|
108
|
+
function nowIso() {
|
|
109
|
+
return new Date().toISOString();
|
|
110
|
+
}
|
|
111
|
+
/** 新建:id 全局唯一(跨两文件),撞库返 exists 让工具层提示用 memory_update。 */
|
|
112
|
+
export function saveEntry(input) {
|
|
113
|
+
const id = slugify(input.name);
|
|
114
|
+
const all = loadAll();
|
|
115
|
+
if (all.some((e) => e.id === id))
|
|
116
|
+
return { ok: false, exists: id };
|
|
117
|
+
const scope = input.scope === 'global' ? 'global' : 'project';
|
|
118
|
+
const now = nowIso();
|
|
119
|
+
const entry = {
|
|
120
|
+
id,
|
|
121
|
+
type: input.type ?? 'fact',
|
|
122
|
+
name: input.name,
|
|
123
|
+
summary: input.summary,
|
|
124
|
+
body: truncateBody(input.body),
|
|
125
|
+
createdAt: now,
|
|
126
|
+
updatedAt: now,
|
|
127
|
+
lastRecalledAt: null,
|
|
128
|
+
recallCount: 0,
|
|
129
|
+
status: 'active',
|
|
130
|
+
supersededBy: null,
|
|
131
|
+
pinned: !!input.pinned,
|
|
132
|
+
scope,
|
|
133
|
+
source: input.source ?? null,
|
|
134
|
+
};
|
|
135
|
+
const p = pathForScope(scope);
|
|
136
|
+
const fileEntries = readFileEntries(p);
|
|
137
|
+
fileEntries.push(entry);
|
|
138
|
+
ensureDir(p);
|
|
139
|
+
writeAtomic(p, fileEntries);
|
|
140
|
+
return { ok: true, id };
|
|
141
|
+
}
|
|
142
|
+
function scoreEntry(e, terms) {
|
|
143
|
+
if (terms.length === 0)
|
|
144
|
+
return 1; // 无关键词:全命中(取前 limit)
|
|
145
|
+
const id = e.id.toLowerCase();
|
|
146
|
+
const name = e.name.toLowerCase();
|
|
147
|
+
const summary = e.summary.toLowerCase();
|
|
148
|
+
const body = e.body.toLowerCase();
|
|
149
|
+
let s = 0;
|
|
150
|
+
for (const t of terms) {
|
|
151
|
+
if (id.includes(t))
|
|
152
|
+
s += 8; // 含 id 匹配:模型常按索引里的 id 取详情(slug 带连字符,名字带空格,不单独匹配 id 会漏)
|
|
153
|
+
if (name.includes(t))
|
|
154
|
+
s += 10;
|
|
155
|
+
if (summary.includes(t))
|
|
156
|
+
s += 5;
|
|
157
|
+
if (body.includes(t))
|
|
158
|
+
s += 1;
|
|
159
|
+
}
|
|
160
|
+
return s;
|
|
161
|
+
}
|
|
162
|
+
/** 关键词搜索:多词子串匹配(name 权重最高)。命中即 bump recallCount/lastRecalledAt 写回(遗忘衰减依据)。 */
|
|
163
|
+
export function searchEntries(query, opts = {}) {
|
|
164
|
+
const all = loadAll();
|
|
165
|
+
const status = opts.status ?? 'active';
|
|
166
|
+
const pool = all.filter((e) => (status === 'any' ? true : e.status === status))
|
|
167
|
+
.filter((e) => (opts.type ? e.type === opts.type : true));
|
|
168
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
169
|
+
const scored = pool
|
|
170
|
+
.map((e) => ({ e, s: scoreEntry(e, terms) }))
|
|
171
|
+
.filter((x) => x.s > 0);
|
|
172
|
+
scored.sort((a, b) => b.s - a.s);
|
|
173
|
+
const limit = Math.max(1, Math.min(opts.limit ?? 5, 20));
|
|
174
|
+
const top = scored.slice(0, limit);
|
|
175
|
+
if (top.length === 0)
|
|
176
|
+
return [];
|
|
177
|
+
// bump recall:只写有命中的 scope 文件
|
|
178
|
+
const now = nowIso();
|
|
179
|
+
const hitIds = new Set(top.map((x) => x.e.id));
|
|
180
|
+
for (const e of all) {
|
|
181
|
+
if (hitIds.has(e.id)) {
|
|
182
|
+
e.recallCount++;
|
|
183
|
+
e.lastRecalledAt = now;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const hitScopes = new Set(top.map((x) => x.e.scope));
|
|
187
|
+
for (const scope of hitScopes) {
|
|
188
|
+
const p = pathForScope(scope);
|
|
189
|
+
const entries = all.filter((e) => e.scope === scope);
|
|
190
|
+
ensureDir(p);
|
|
191
|
+
writeAtomic(p, entries);
|
|
192
|
+
}
|
|
193
|
+
return top.map((x) => x.e);
|
|
194
|
+
}
|
|
195
|
+
/** 索引(无 body、不 bump recall)。 */
|
|
196
|
+
export function listEntries(opts = {}) {
|
|
197
|
+
const status = opts.status ?? 'active';
|
|
198
|
+
return loadAll()
|
|
199
|
+
.filter((e) => (status === 'any' ? true : e.status === status))
|
|
200
|
+
.filter((e) => (opts.type ? e.type === opts.type : true))
|
|
201
|
+
.map((e) => ({ id: e.id, name: e.name, summary: e.summary, type: e.type, status: e.status }));
|
|
202
|
+
}
|
|
203
|
+
/** 原地改:id 不变(name 可改 → 但 id 仍是旧 slug,故 name 改不会触发重 slug);记 lastUpdateReason;pinned 可切换。 */
|
|
204
|
+
export function updateEntry(id, patch) {
|
|
205
|
+
const all = loadAll();
|
|
206
|
+
const e = all.find((x) => x.id === id);
|
|
207
|
+
if (!e)
|
|
208
|
+
return { ok: false, notFound: true };
|
|
209
|
+
if (patch.name)
|
|
210
|
+
e.name = patch.name;
|
|
211
|
+
if (patch.summary)
|
|
212
|
+
e.summary = patch.summary;
|
|
213
|
+
if (patch.body)
|
|
214
|
+
e.body = truncateBody(patch.body);
|
|
215
|
+
if (patch.reason)
|
|
216
|
+
e.lastUpdateReason = patch.reason;
|
|
217
|
+
if (patch.pinned !== undefined)
|
|
218
|
+
e.pinned = patch.pinned;
|
|
219
|
+
e.updatedAt = nowIso();
|
|
220
|
+
const p = pathForScope(e.scope);
|
|
221
|
+
const entries = all.filter((x) => x.scope === e.scope);
|
|
222
|
+
ensureDir(p);
|
|
223
|
+
writeAtomic(p, entries);
|
|
224
|
+
return { ok: true };
|
|
225
|
+
}
|
|
226
|
+
/** 归档(默认,可复活)/ 硬删;pinned 拒删。 */
|
|
227
|
+
export function forgetEntry(id, mode = 'archive') {
|
|
228
|
+
const all = loadAll();
|
|
229
|
+
const e = all.find((x) => x.id === id);
|
|
230
|
+
if (!e)
|
|
231
|
+
return { ok: false, notFound: true };
|
|
232
|
+
if (e.pinned)
|
|
233
|
+
return { ok: false, pinned: true };
|
|
234
|
+
const p = pathForScope(e.scope);
|
|
235
|
+
let entries = all.filter((x) => x.scope === e.scope);
|
|
236
|
+
if (mode === 'delete') {
|
|
237
|
+
entries = entries.filter((x) => x.id !== id);
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
const t = entries.find((x) => x.id === id);
|
|
241
|
+
if (t) {
|
|
242
|
+
t.status = 'archived';
|
|
243
|
+
t.updatedAt = nowIso();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
ensureDir(p);
|
|
247
|
+
writeAtomic(p, entries);
|
|
248
|
+
return { ok: true, mode };
|
|
249
|
+
}
|
|
250
|
+
// ── 遗忘 GC(纯数据,不调 LLM)─────────────────────────────────────────────
|
|
251
|
+
const DAY_MS = 86400000;
|
|
252
|
+
/**
|
|
253
|
+
* 遗忘策略(全同步,后台调):
|
|
254
|
+
* ① archived 超 GC_DAYS → 硬删;
|
|
255
|
+
* ② active + !pinned + (lastRecalledAt|createdAt) 早于 DECAY_DAYS → archived;
|
|
256
|
+
* ③ active 数 > MAX_ACTIVE → 按 recallCount 低 × 最近未召回久 淘汰到 archived。
|
|
257
|
+
* pinned 豁免一切自动衰减。写回两文件(均按 scope 过滤)。
|
|
258
|
+
*/
|
|
259
|
+
export function gcMemories() {
|
|
260
|
+
const all = loadAll();
|
|
261
|
+
if (all.length === 0)
|
|
262
|
+
return { decayed: 0, capped: 0, gced: 0 };
|
|
263
|
+
const now = Date.now();
|
|
264
|
+
const decayMs = DECAY_DAYS * DAY_MS;
|
|
265
|
+
const gcMs = GC_DAYS * DAY_MS;
|
|
266
|
+
const result = { decayed: 0, capped: 0, gced: 0 };
|
|
267
|
+
const refTs = (e) => {
|
|
268
|
+
const r = e.lastRecalledAt ? Date.parse(e.lastRecalledAt) : Date.parse(e.createdAt);
|
|
269
|
+
return Number.isFinite(r) ? r : now;
|
|
270
|
+
};
|
|
271
|
+
// ① archived GC
|
|
272
|
+
for (const e of all) {
|
|
273
|
+
if (e.status !== 'archived')
|
|
274
|
+
continue;
|
|
275
|
+
const u = Date.parse(e.updatedAt || e.createdAt);
|
|
276
|
+
if (Number.isFinite(u) && now - u > gcMs) {
|
|
277
|
+
e.status = '__DELETE__'; // 标记硬删(下方过滤)
|
|
278
|
+
result.gced++;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// ② decay
|
|
282
|
+
for (const e of all) {
|
|
283
|
+
if (e.status !== 'active' || e.pinned)
|
|
284
|
+
continue;
|
|
285
|
+
if (now - refTs(e) > decayMs) {
|
|
286
|
+
e.status = 'archived';
|
|
287
|
+
e.updatedAt = nowIso();
|
|
288
|
+
result.decayed++;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// ③ cap active
|
|
292
|
+
const active = all
|
|
293
|
+
.filter((e) => e.status === 'active')
|
|
294
|
+
.sort((a, b) => a.recallCount - b.recallCount || refTs(a) - refTs(b));
|
|
295
|
+
const excess = active.length - MAX_ACTIVE;
|
|
296
|
+
if (excess > 0) {
|
|
297
|
+
for (let i = 0; i < excess; i++) {
|
|
298
|
+
const e = active[i];
|
|
299
|
+
if (e.pinned)
|
|
300
|
+
continue; // 双保险:排序后仍跳过 pinned
|
|
301
|
+
e.status = 'archived';
|
|
302
|
+
e.updatedAt = nowIso();
|
|
303
|
+
result.capped++;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// 写回:剔除 __DELETE__ 标记
|
|
307
|
+
const survivors = all.filter((e) => e.status !== '__DELETE__');
|
|
308
|
+
// 仅当确有变化才写(避免每次 gc 都重写)
|
|
309
|
+
if (result.gced === 0 && result.decayed === 0 && result.capped === 0)
|
|
310
|
+
return result;
|
|
311
|
+
writeBackByScope(survivors);
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
314
|
+
// ── 启动索引段(注入 systemPrompt,同步)────────────────────────────────────
|
|
315
|
+
/**
|
|
316
|
+
* active 条目按 updatedAt 降序,封顶 MAX_INDEX_ENTRIES,只注 id/name/summary/type。
|
|
317
|
+
* 无 active 返空串(零行为变化)。body 不注入——按需 memory_search 取。
|
|
318
|
+
*/
|
|
319
|
+
export function buildMemoryIndexSection() {
|
|
320
|
+
const active = loadAll()
|
|
321
|
+
.filter((e) => e.status === 'active')
|
|
322
|
+
.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''));
|
|
323
|
+
if (active.length === 0)
|
|
324
|
+
return '';
|
|
325
|
+
const shown = active.slice(0, MAX_INDEX_ENTRIES);
|
|
326
|
+
const lines = shown.map((e) => `- ${e.id}: ${e.name} — ${e.summary} (${e.type})`);
|
|
327
|
+
const tail = active.length > MAX_INDEX_ENTRIES
|
|
328
|
+
? `\n\n…(${active.length} total, showing first ${MAX_INDEX_ENTRIES}; use memory_search <id or keyword> for more)`
|
|
329
|
+
: '';
|
|
330
|
+
return [
|
|
331
|
+
'',
|
|
332
|
+
'',
|
|
333
|
+
'## Memory Index (retrieve full body via memory_search)',
|
|
334
|
+
'The following are saved memory entries (title/summary only). Retrieve full body via memory_search (pass id or keyword); use memory_list to see all,'
|
|
335
|
+
+ ' memory_update to modify, memory_forget to archive. This list is a startup snapshot; entries added during the session are not listed here — use memory_list/memory_search to find them.',
|
|
336
|
+
...lines,
|
|
337
|
+
tail,
|
|
338
|
+
].join('\n');
|
|
339
|
+
}
|