mocode-ai 0.1.3 → 0.1.4
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 +34 -7
- package/dist/agent/index.js +129 -59
- package/dist/config/index.js +11 -0
- 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 +105 -14
- package/dist/session/compact.js +50 -2
- package/dist/tools/builtins/ask-human.js +50 -0
- package/dist/tools/builtins/index.js +12 -0
- 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/use-skill.js +1 -1
- package/dist/tools/builtins/web-fetch.js +1 -1
- package/dist/tools/builtins/web-search.js +1 -1
- package/dist/tools/constants.js +13 -0
- package/dist/ui/intervention.js +297 -0
- package/dist/ui/layout.js +115 -30
- package/dist/ui/prompt.js +211 -23
- package/dist/ui/render.js +18 -0
- package/dist/ui/spinner.js +15 -0
- package/dist/ui/theme.js +1 -0
- package/package.json +1 -1
|
@@ -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 = `你是 mocode 的记忆反思器。审阅近期会话与现有记忆,产出**仅**值得长期记住的更新。
|
|
96
|
+
严格输出 JSON(无 markdown 代码块、无解释文字):{"saves":[{"type":"...","name":"...","summary":"...","body":"..."}],"updates":[{"id":"...","reason":"...","summary":"...","body":"..."}],"forgets":[{"id":"...","reason":"..."}]}
|
|
97
|
+
空数组合法(无可记则三个数组都空)。
|
|
98
|
+
规则:
|
|
99
|
+
① 只记非显然、跨会话有用的事实/决策/坑;不记当前 bug、临时文件、未决 TODO、易变项;
|
|
100
|
+
② 宁可少记,不记正确废话(如"保持简洁");
|
|
101
|
+
③ updates/forgets 的 id 必须来自下方「现有记忆」列表;不在此列的不要编 id;
|
|
102
|
+
④ saves 的 name 须简洁且与现有不撞;type ∈ {${TYPES}};
|
|
103
|
+
⑤ 若现有记忆与新事实矛盾或过时,update 旧条(改 summary/body)而非新建重复条;
|
|
104
|
+
⑥ forgets 用于明显已失效 / 被新条取代的记忆(归档,非硬删)。`;
|
|
105
|
+
const REFLECT_USER = (transcript, sample) => `## 近期会话\n${transcript}\n\n## 现有记忆\n${sample}\n\n产出 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} 条,只显前 ${MAX_INDEX_ENTRIES};用 memory_search <id 或关键词> 查更多)`
|
|
329
|
+
: '';
|
|
330
|
+
return [
|
|
331
|
+
'',
|
|
332
|
+
'',
|
|
333
|
+
'## 记忆索引(按需 memory_search 取详情)',
|
|
334
|
+
'以下是已保存的记忆条目(标题/摘要)。需要正文时调 memory_search(传 id 或关键词);用 memory_list 看全部,'
|
|
335
|
+
+ 'memory_update 改、memory_forget 忘。本列表为启动快照,会话期间新增的不在此——用 memory_list/memory_search 查最新。',
|
|
336
|
+
...lines,
|
|
337
|
+
tail,
|
|
338
|
+
].join('\n');
|
|
339
|
+
}
|