@yottameta/yotta-memory 0.6.5 → 0.8.1
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 +26 -13
- package/SKILL.md +59 -21
- package/USER_GUIDE.md +82 -7
- package/bin/yotta-memory.js +1737 -122
- package/package.json +2 -2
- package/references/pinyin-common.json +1 -0
- package/references/protocol.md +162 -121
package/bin/yotta-memory.js
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
// v0.4.2 新增:iam/whoami 智能体身份自注册(agents.json 唯一性)+ 自我档案 PREF + 私密记忆须声明 owner
|
|
6
6
|
// v0.5.3 新增:CLI 选项可前置(--agent 等允许放在子命令前,不再误报"未知命令")/ lan enable 成功补"服务不会立刻启动"提示(v0.5.2 lan 引号修复无回归)
|
|
7
7
|
// v0.5.4 新增:lan enable 在非管理员(schtasks Access denied)时自动降级为用户级 Startup 静默自启(VBS sh.Run 窗口0 + autostart.cmd,node 路径 process.execPath 自动探测);lan disable/status 同时管理计划任务与 Startup 双机制
|
|
8
|
-
// v0.6.0
|
|
9
|
-
// v0.6.1
|
|
8
|
+
// v0.6.0 新增:profile(用户画像聚合,零推断)/ context(开工上下文包)/ iam 扩展(--name/--user/--relationship)/ remember --verify(写后回读)与 --no-hint(关闭类型启发式提示)+ SKILL「记忆守则」
|
|
9
|
+
// v0.6.1 新增:context --budget(token 预算,近记忆按剩余预算放行)/ context 内嵌「多智能体接入铁律」段 / remember --source/--weight(来源 + 重要性权重,去重 weight 取 max)/ 近期记忆排序融合 importance(confidence×recency+updated+weight+immutable)
|
|
10
10
|
// v0.6.3 修复:lan 开机自启 VBS 自愈——VBS 内联 autostart.cmd 内容,启动时自动重建 .cmd(根治 80070002:wscript 找不到被引用启动文件)
|
|
11
11
|
// v0.6.4 新增:lan 命令扩展 Linux——systemd 用户单元(systemctl --user enable/start,登录自启;--onstart 附加 loginctl enable-linger 开机即启)/ systemd 不可用时自动降级用户 crontab @reboot;lanPlatform 测试钩子(YOTTA_LAN_PLATFORM)
|
|
12
12
|
// v0.6.5 修复:recall/context 对同一文件显示 2 条——projectRoot 与 userRoot 指向同一目录(如 cwd=home 或其父)时同一索引被遍历两次;新增 memoryRoots() 唯一化根,hasGrant/recallCore/forgetCore/cmdReindex/contextCore 统一走 memoryRoots()
|
|
13
|
+
// v0.7.0 新增:私密区机制级加密(AES-256-GCM 信封加密 + PBKDF2 主密钥 + 恢复钥匙)/ 每 owner 加密索引 / 用户查看平台 yotta-memory view / migrate 迁移 / reset-password / key 授权 / context 收工纪律 / init 新建默认加密(--no-encrypt 降级)
|
|
14
|
+
// v0.8.0 新增:自我学习/自我进化/自我提升——语义检索(同义词/拼音/字段加权/模糊 + 可选 embedding 插件协议预留)/ feedback 显式反馈闭环(weight/confidence/feedback_net 演化)/ maintain 规则层自组织(统一效用分 utility + 归档/遗忘/去重,默认 dry-run)/ distill 心理日志蒸馏(启发式统计/主题画像/知识地图 + 可选 --model)
|
|
13
15
|
// v0.6.2 修复:remember --verify 写后回读改为直查索引 + 权限判定 + 召回匹配性(不再依赖 recall top-N 排序,消除泛化 subject 下偶发误报「回读未命中」)
|
|
14
16
|
'use strict';
|
|
15
17
|
|
|
@@ -20,7 +22,7 @@ const crypto = require('crypto');
|
|
|
20
22
|
const http = require('http');
|
|
21
23
|
const child_process = require('child_process');
|
|
22
24
|
|
|
23
|
-
const VERSION = '0.
|
|
25
|
+
const VERSION = '0.8.1';
|
|
24
26
|
const TYPES = ['FACT', 'PREF', 'BOUND', 'COMMIT'];
|
|
25
27
|
const TYPE_DIRS = { FACT: 'facts', PREF: 'prefs', BOUND: 'bounds', COMMIT: 'commits' };
|
|
26
28
|
const PUBLIC_DIR = 'facts';
|
|
@@ -29,7 +31,7 @@ const PRIVATE_LEAF = ['prefs', 'bounds', 'commits'];
|
|
|
29
31
|
const ARCHIVE_DIR = '.archive';
|
|
30
32
|
const INDEX_FILE = 'index.json';
|
|
31
33
|
const PRIVATE_TYPES = ['PREF', 'BOUND', 'COMMIT'];
|
|
32
|
-
const FIELD_ORDER = ['type', 'subject', 'statement', 'confidence', 'created', 'updated', 'tags', 'immutable', 'scope', 'owner', 'source', 'weight', 'access_count', 'last_accessed'];
|
|
34
|
+
const FIELD_ORDER = ['type', 'subject', 'statement', 'confidence', 'created', 'updated', 'tags', 'immutable', 'scope', 'owner', 'source', 'weight', 'access_count', 'last_accessed', 'feedback_net'];
|
|
33
35
|
const CONFIG_FILE = 'config.json';
|
|
34
36
|
const SERVER_SUBDIR = '.server';
|
|
35
37
|
const TOKENS_FILE = 'tokens.json';
|
|
@@ -141,11 +143,197 @@ function tokenize(s) {
|
|
|
141
143
|
return toks;
|
|
142
144
|
}
|
|
143
145
|
function buildTokens(subject, statement, tags) {
|
|
144
|
-
const toks = tokenize(subject + ' ' + statement + ' ' + tags.join(' '));
|
|
145
146
|
const m = {};
|
|
146
|
-
|
|
147
|
+
const add = function (tok, w) { if (tok) m[tok] = (m[tok] || 0) + w; };
|
|
148
|
+
const subj = String(subject || '');
|
|
149
|
+
const stmt = String(statement || '');
|
|
150
|
+
const tagArr = Array.isArray(tags) ? tags : parseTags(tags);
|
|
151
|
+
// v0.8.0 字段加权:subject × 3 / tags × 2 / statement × 1
|
|
152
|
+
for (const t of tokenize(subj)) add(t, 3);
|
|
153
|
+
for (const t of tokenize(stmt)) add(t, 1);
|
|
154
|
+
for (const t of tokenize(tagArr.join(' '))) add(t, 2);
|
|
155
|
+
// v0.8.0 拼音 token(py: 全拼 / pyi: 首字母),命中按 0.4 权重(去重防长文本重复字虚高)
|
|
156
|
+
for (const t of new Set(pinyinTokens(subj + ' ' + stmt + ' ' + tagArr.join(' ')))) add(t, 0.4);
|
|
147
157
|
return m;
|
|
148
158
|
}
|
|
159
|
+
// ---- v0.8.0 自我学习:拼音表 + 同义词表 + 语义匹配(零依赖内置数据)----
|
|
160
|
+
function loadPinyinTable() {
|
|
161
|
+
try {
|
|
162
|
+
const p = path.join(__dirname, '..', 'references', 'pinyin-common.json');
|
|
163
|
+
return JSON.parse(fs.readFileSync(p, 'utf8')) || {};
|
|
164
|
+
} catch (e) { return {}; }
|
|
165
|
+
}
|
|
166
|
+
const PINYIN_TABLE = loadPinyinTable();
|
|
167
|
+
// 同义词组:同组内互相视为同义(查询侧扩展,命中按 0.5 权重)
|
|
168
|
+
const SYNONYM_GROUPS = [
|
|
169
|
+
['记', '记住', '记忆', '记录', '铭记'],
|
|
170
|
+
['忘', '忘记', '遗忘', '忘掉'],
|
|
171
|
+
['读', '读取', '检索', '查询', '搜索'],
|
|
172
|
+
['写', '写入', '记录', '保存', '存储'],
|
|
173
|
+
['偏好', '喜好', '喜欢', '偏爱'],
|
|
174
|
+
['承诺', '保证', '约定'],
|
|
175
|
+
['收工', '结束', '完成', '完毕'],
|
|
176
|
+
['归档', '封存', '存档'],
|
|
177
|
+
['删除', '移除', '去掉'],
|
|
178
|
+
['更新', '修改', '刷新'],
|
|
179
|
+
['边界', '红线', '禁区', '界限'],
|
|
180
|
+
['画像', '档案', 'profile'],
|
|
181
|
+
['命令', '指令', 'cli'],
|
|
182
|
+
['记忆', 'memory', '记忆库']
|
|
183
|
+
];
|
|
184
|
+
function synonymSet(token) {
|
|
185
|
+
const out = new Set();
|
|
186
|
+
for (const g of SYNONYM_GROUPS) {
|
|
187
|
+
if (g.indexOf(token) !== -1) { for (const w of g) out.add(w); }
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
function pinyinOf(ch) {
|
|
192
|
+
const arr = PINYIN_TABLE[ch];
|
|
193
|
+
if (!arr || !arr.length) return null;
|
|
194
|
+
return arr[0];
|
|
195
|
+
}
|
|
196
|
+
// 中文字符串 -> 拼音 token:全拼 bigram 'py:yueyong' + 首字母 bigram 'pyi:yy'
|
|
197
|
+
function pinyinTokens(s) {
|
|
198
|
+
const out = [];
|
|
199
|
+
const han = String(s || '').match(/[\u4e00-\u9fa5]+/g);
|
|
200
|
+
if (!han) return out;
|
|
201
|
+
const all = han.join('');
|
|
202
|
+
if (all.length === 1) {
|
|
203
|
+
const p = pinyinOf(all);
|
|
204
|
+
if (p) { out.push('py:' + p); out.push('pyi:' + p[0]); }
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
let initials = '';
|
|
208
|
+
for (let i = 0; i < all.length; i++) { const p = pinyinOf(all[i]); if (p) initials += p[0]; }
|
|
209
|
+
for (let i = 0; i < all.length - 1; i++) {
|
|
210
|
+
const p1 = pinyinOf(all[i]), p2 = pinyinOf(all[i + 1]);
|
|
211
|
+
if (p1 && p2) out.push('py:' + p1 + p2);
|
|
212
|
+
}
|
|
213
|
+
for (let i = 0; i < initials.length - 1; i++) out.push('pyi:' + initials.slice(i, i + 2));
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
function editDistance(a, b) {
|
|
217
|
+
a = String(a); b = String(b);
|
|
218
|
+
if (a === b) return 0;
|
|
219
|
+
const la = a.length, lb = b.length;
|
|
220
|
+
if (!la) return lb; if (!lb) return la;
|
|
221
|
+
if (Math.abs(la - lb) > 2) return 3;
|
|
222
|
+
let prev = [];
|
|
223
|
+
for (let j = 0; j <= lb; j++) prev[j] = j;
|
|
224
|
+
for (let i = 1; i <= la; i++) {
|
|
225
|
+
const cur = [i];
|
|
226
|
+
for (let j = 1; j <= lb; j++) {
|
|
227
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
228
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
|
|
229
|
+
}
|
|
230
|
+
prev = cur;
|
|
231
|
+
}
|
|
232
|
+
return prev[lb];
|
|
233
|
+
}
|
|
234
|
+
// 语义匹配:精确(1.0×,字段加权已入 tokens) / 同义(0.5×) / 拼音(0.4×) / 子串兜底 / 模糊(0.3×)
|
|
235
|
+
function semanticMatch(entry, query, wantDetail) {
|
|
236
|
+
const q = String(query || '').toLowerCase();
|
|
237
|
+
const qtoks = tokenize(q);
|
|
238
|
+
const qpy = pinyinTokens(q);
|
|
239
|
+
const toks = entry.tokens || {};
|
|
240
|
+
const hay = ((entry.subject || '') + ' ' + (entry.statement || '') + ' ' + (entry.tags || []).join(' ')).toLowerCase();
|
|
241
|
+
let score = 0;
|
|
242
|
+
const detail = [];
|
|
243
|
+
const qset = new Set(qtoks);
|
|
244
|
+
for (const tt of qtoks) {
|
|
245
|
+
if (toks[tt]) { score += toks[tt]; if (wantDetail) detail.push('精确:' + tt + '(+' + round3(toks[tt]) + ')'); }
|
|
246
|
+
}
|
|
247
|
+
const ext = new Set();
|
|
248
|
+
for (const tt of qtoks) { const s = synonymSet(tt); for (const w of s) ext.add(w); }
|
|
249
|
+
for (const w of ext) {
|
|
250
|
+
if (toks[w] && !qset.has(w)) { score += 0.5 * toks[w]; if (wantDetail) detail.push('同义:' + w + '(+' + round3(0.5 * toks[w]) + ')'); }
|
|
251
|
+
}
|
|
252
|
+
for (const t of qpy) {
|
|
253
|
+
if (toks[t]) { score += 0.4 * toks[t]; if (wantDetail) detail.push('拼音:' + t + '(+' + round3(0.4 * toks[t]) + ')'); }
|
|
254
|
+
}
|
|
255
|
+
// 查询为拼音(拉丁 token)时,匹配条目侧 py:/pyi: token(去前缀)
|
|
256
|
+
for (const tt of qtoks) {
|
|
257
|
+
if (/^[a-z]+$/.test(tt)) {
|
|
258
|
+
if (toks['py:' + tt]) { score += 0.4 * toks['py:' + tt]; if (wantDetail) detail.push('拼音:' + tt + '(+' + round3(0.4 * toks['py:' + tt]) + ')'); }
|
|
259
|
+
if (toks['pyi:' + tt]) { score += 0.4 * toks['pyi:' + tt]; if (wantDetail) detail.push('拼音首字母:' + tt + '(+' + round3(0.4 * toks['pyi:' + tt]) + ')'); }
|
|
260
|
+
// 首字母串(如 yyyd)拆 bigram 匹配 pyi: token
|
|
261
|
+
if (tt.length >= 3) {
|
|
262
|
+
for (let i = 0; i < tt.length - 1; i++) {
|
|
263
|
+
const bg = tt.slice(i, i + 2);
|
|
264
|
+
if (toks['pyi:' + bg]) { score += 0.4 * toks['pyi:' + bg]; if (wantDetail) detail.push('拼音首字母:' + bg + '(+' + round3(0.4 * toks['pyi:' + bg]) + ')'); }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (score === 0 && hay.indexOf(q) !== -1) { score = 1; if (wantDetail) detail.push('子串命中'); }
|
|
270
|
+
if (score === 0) {
|
|
271
|
+
const longQtoks = qtoks.filter(function (t) { return t.length >= 4; });
|
|
272
|
+
const keys = Object.keys(toks).filter(function (k) { return k.length >= 4 && k.indexOf(':') === -1; });
|
|
273
|
+
outer:
|
|
274
|
+
for (const t of longQtoks) {
|
|
275
|
+
for (const k of keys) {
|
|
276
|
+
if (Math.abs(k.length - t.length) <= 2 && editDistance(k, t) <= 2) {
|
|
277
|
+
score += 0.3 * toks[k];
|
|
278
|
+
if (wantDetail) detail.push('模糊:' + t + '~' + k + '(+' + round3(0.3 * toks[k]) + ')');
|
|
279
|
+
break outer;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { score: score, detail: detail };
|
|
285
|
+
}
|
|
286
|
+
function round3(n) { return Math.round(n * 1000) / 1000; }
|
|
287
|
+
// ---- v0.8.0 自我进化:统一效用分(盖棺分)----
|
|
288
|
+
function feedbackNetOf(meta) { return parseInt(meta.feedback_net || '0', 10) || 0; }
|
|
289
|
+
function utilityScore(meta) {
|
|
290
|
+
const conf = Math.min(Math.max(parseFloat(meta.confidence || 1.0) || 1.0, 0), 1);
|
|
291
|
+
const acc = parseInt(meta.access_count || '0', 10) || 0;
|
|
292
|
+
const fb = feedbackNetOf(meta);
|
|
293
|
+
const usageScore = 0.5 * (Math.min(acc, 20) / 20) + 0.5 * (fb / (1 + Math.abs(fb)));
|
|
294
|
+
const last = meta.last_accessed || meta.created || '';
|
|
295
|
+
let recencyScore = 0;
|
|
296
|
+
if (last) {
|
|
297
|
+
const d = daysBetween(last, today());
|
|
298
|
+
recencyScore = Math.max(0, 1 - d / 365);
|
|
299
|
+
}
|
|
300
|
+
const typeScore = ({ BOUND: 1.0, COMMIT: 0.9, PREF: 0.8, FACT: 0.6 })[String(meta.type || 'FACT').toUpperCase()] || 0.6;
|
|
301
|
+
let structureScore = 0;
|
|
302
|
+
if (meta.source) structureScore += 0.2;
|
|
303
|
+
const tags = parseTags(meta.tags);
|
|
304
|
+
if (tags.length) structureScore += 0.3;
|
|
305
|
+
if (meta.subject) structureScore += 0.2;
|
|
306
|
+
if (meta.confidence !== undefined && meta.confidence !== null && meta.confidence !== '') structureScore += 0.1;
|
|
307
|
+
structureScore = Math.min(structureScore, 1.0);
|
|
308
|
+
const w = parseFloat(meta.weight || '1.0');
|
|
309
|
+
const weight = (w > 0 ? w : 1.0);
|
|
310
|
+
return ((0.30 * conf) + (0.25 * usageScore) + (0.20 * recencyScore) + (0.15 * typeScore) + (0.10 * structureScore)) * weight;
|
|
311
|
+
}
|
|
312
|
+
// utility 分项明细(explain / 审计用)
|
|
313
|
+
function utilityBreakdown(meta) {
|
|
314
|
+
const conf = Math.min(Math.max(parseFloat(meta.confidence || 1.0) || 1.0, 0), 1);
|
|
315
|
+
const acc = parseInt(meta.access_count || '0', 10) || 0;
|
|
316
|
+
const fb = feedbackNetOf(meta);
|
|
317
|
+
const usageScore = 0.5 * (Math.min(acc, 20) / 20) + 0.5 * (fb / (1 + Math.abs(fb)));
|
|
318
|
+
const last = meta.last_accessed || meta.created || '';
|
|
319
|
+
let recencyScore = 0;
|
|
320
|
+
if (last) { const d = daysBetween(last, today()); recencyScore = Math.max(0, 1 - d / 365); }
|
|
321
|
+
const typeScore = ({ BOUND: 1.0, COMMIT: 0.9, PREF: 0.8, FACT: 0.6 })[String(meta.type || 'FACT').toUpperCase()] || 0.6;
|
|
322
|
+
let structureScore = 0;
|
|
323
|
+
if (meta.source) structureScore += 0.2;
|
|
324
|
+
const tags = parseTags(meta.tags);
|
|
325
|
+
if (tags.length) structureScore += 0.3;
|
|
326
|
+
if (meta.subject) structureScore += 0.2;
|
|
327
|
+
if (meta.confidence !== undefined && meta.confidence !== null && meta.confidence !== '') structureScore += 0.1;
|
|
328
|
+
structureScore = Math.min(structureScore, 1.0);
|
|
329
|
+
const w = parseFloat(meta.weight || '1.0');
|
|
330
|
+
const weight = (w > 0 ? w : 1.0);
|
|
331
|
+
return {
|
|
332
|
+
confidence: round3(0.30 * conf), usage: round3(0.25 * usageScore), recency: round3(0.20 * recencyScore),
|
|
333
|
+
type: round3(0.15 * typeScore), structure: round3(0.10 * structureScore), weight: round3(weight),
|
|
334
|
+
total: round3(((0.30 * conf) + (0.25 * usageScore) + (0.20 * recencyScore) + (0.15 * typeScore) + (0.10 * structureScore)) * weight)
|
|
335
|
+
};
|
|
336
|
+
}
|
|
149
337
|
function frontmatterToText(meta, body) {
|
|
150
338
|
const lines = ['---'];
|
|
151
339
|
const ordered = FIELD_ORDER.filter(function (k) { return meta[k] !== undefined && meta[k] !== null && meta[k] !== ''; });
|
|
@@ -173,33 +361,300 @@ function nextSeq(dir) {
|
|
|
173
361
|
let max = 0;
|
|
174
362
|
if (fs.existsSync(dir)) {
|
|
175
363
|
for (const f of fs.readdirSync(dir)) {
|
|
176
|
-
const m = f.match(/^\d{4}-\d{2}-\d{2}-(\d{4})\.md
|
|
364
|
+
const m = f.match(/^\d{4}-\d{2}-\d{2}-(\d{4})\.md(\.enc)?$/);
|
|
177
365
|
if (m) max = Math.max(max, parseInt(m[1], 10));
|
|
178
366
|
}
|
|
179
367
|
}
|
|
180
368
|
return String(max + 1).padStart(4, '0');
|
|
181
369
|
}
|
|
182
370
|
|
|
371
|
+
|
|
372
|
+
// ================= v0.7.0 加密(AES-256-GCM 信封加密 + PBKDF2 主密钥 + 恢复钥匙)=================
|
|
373
|
+
const ENC_MAGIC = 'YTMENC1';
|
|
374
|
+
const IDX_MAGIC = 'YTMIDX1';
|
|
375
|
+
const KEY_MAGIC = 'YTMKEY1';
|
|
376
|
+
const KEYS_DIR = 'keys';
|
|
377
|
+
const KEY_CACHE_DIR = 'cache';
|
|
378
|
+
const ENC_INDEX_FILE = 'index.enc';
|
|
379
|
+
const ENC_SUFFIX = '.enc';
|
|
380
|
+
const PBKDF2_ITER = 600000;
|
|
381
|
+
const GCM_NONCE = 12;
|
|
382
|
+
const GCM_TAG = 16;
|
|
383
|
+
const AES_ALGO = 'aes-256-gcm';
|
|
384
|
+
|
|
385
|
+
function encKeysDir(root) { return path.join(root, KEYS_DIR); }
|
|
386
|
+
function encSaltPath(root) { return path.join(encKeysDir(root), 'salt'); }
|
|
387
|
+
function encOwnerKeyPath(root, id) { return path.join(encKeysDir(root), id + '.key.enc'); }
|
|
388
|
+
function encOwnerRecPath(root, id) { return path.join(encKeysDir(root), id + '.key.recovery'); }
|
|
389
|
+
function encRecoveryEncPath(root) { return path.join(encKeysDir(root), 'recovery.key.enc'); }
|
|
390
|
+
function encCacheDir(root) { return path.join(encKeysDir(root), KEY_CACHE_DIR); }
|
|
391
|
+
function encCachePath(root, id) { return path.join(encCacheDir(root), id + '.key'); }
|
|
392
|
+
function isEncrypted(root) { return fs.existsSync(encKeysDir(root)); }
|
|
393
|
+
function isEncFile(fp) { return String(fp).slice(-ENC_SUFFIX.length) === ENC_SUFFIX; }
|
|
394
|
+
function ownerFromPrivatePath(root, fp) {
|
|
395
|
+
const rel = path.relative(root, fp).replace(/\\/g, '/');
|
|
396
|
+
const seg = rel.split('/');
|
|
397
|
+
if (seg[0] === PRIVATE_DIR && seg[1]) return seg[1];
|
|
398
|
+
return '';
|
|
399
|
+
}
|
|
400
|
+
function collectOwners(root) {
|
|
401
|
+
const pdir = path.join(root, PRIVATE_DIR);
|
|
402
|
+
if (!fs.existsSync(pdir)) return [];
|
|
403
|
+
return fs.readdirSync(pdir).filter(function (id) {
|
|
404
|
+
try { return fs.statSync(path.join(pdir, id)).isDirectory(); } catch (e) { return false; }
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
function ensureKeysDir(root) { fs.mkdirSync(encCacheDir(root), { recursive: true }); }
|
|
408
|
+
// owner 来源联合:private/<owner>/ 目录 + keys/*.key.enc(只授权未写记忆时 owner 只在 keys/)
|
|
409
|
+
function keyOwners(root) {
|
|
410
|
+
const set = new Set();
|
|
411
|
+
for (const o of collectOwners(root)) set.add(o);
|
|
412
|
+
const kd = encKeysDir(root);
|
|
413
|
+
if (fs.existsSync(kd)) {
|
|
414
|
+
for (const f of fs.readdirSync(kd)) {
|
|
415
|
+
if (f === 'recovery.key.enc') continue;
|
|
416
|
+
const m = /^(.+)\.key\.enc$/.exec(f);
|
|
417
|
+
if (m) set.add(m[1]);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return Array.from(set);
|
|
421
|
+
}
|
|
422
|
+
function aesEncryptBytes(key, buf, aad) {
|
|
423
|
+
const nonce = crypto.randomBytes(GCM_NONCE);
|
|
424
|
+
const c = crypto.createCipheriv(AES_ALGO, key, nonce);
|
|
425
|
+
if (aad) c.setAAD(aad);
|
|
426
|
+
const ct = Buffer.concat([c.update(buf), c.final()]);
|
|
427
|
+
return Buffer.concat([nonce, c.getAuthTag(), ct]);
|
|
428
|
+
}
|
|
429
|
+
function aesDecryptBytes(key, env, aad) {
|
|
430
|
+
if (!env || env.length < GCM_NONCE + GCM_TAG) throw new Error('密文数据损坏');
|
|
431
|
+
const nonce = env.slice(0, GCM_NONCE);
|
|
432
|
+
const tag = env.slice(GCM_NONCE, GCM_NONCE + GCM_TAG);
|
|
433
|
+
const ct = env.slice(GCM_NONCE + GCM_TAG);
|
|
434
|
+
const d = crypto.createDecipheriv(AES_ALGO, key, nonce);
|
|
435
|
+
if (aad) d.setAAD(aad);
|
|
436
|
+
d.setAuthTag(tag);
|
|
437
|
+
return Buffer.concat([d.update(ct), d.final()]);
|
|
438
|
+
}
|
|
439
|
+
function deriveUmk(password, salt) {
|
|
440
|
+
return crypto.pbkdf2Sync(Buffer.from(String(password), 'utf8'), salt, PBKDF2_ITER, 32, 'sha256');
|
|
441
|
+
}
|
|
442
|
+
function loadSalt(root) {
|
|
443
|
+
const p = encSaltPath(root);
|
|
444
|
+
if (!fs.existsSync(p)) return null;
|
|
445
|
+
return fs.readFileSync(p);
|
|
446
|
+
}
|
|
447
|
+
function initEncryptionCore(root, password, recoveryKeyIn) {
|
|
448
|
+
ensureKeysDir(root);
|
|
449
|
+
const salt = crypto.randomBytes(16);
|
|
450
|
+
fs.writeFileSync(encSaltPath(root), salt);
|
|
451
|
+
const umk = deriveUmk(password, salt);
|
|
452
|
+
const rk = recoveryKeyIn ? Buffer.from(String(recoveryKeyIn), 'base64') : crypto.randomBytes(32);
|
|
453
|
+
if (rk.length !== 32) throw new Error('恢复钥匙须为 32 字节(base64)');
|
|
454
|
+
fs.writeFileSync(encRecoveryEncPath(root), Buffer.concat([Buffer.from(KEY_MAGIC, 'utf8'), aesEncryptBytes(umk, rk, Buffer.from('recovery', 'utf8'))]));
|
|
455
|
+
return { umk: umk, rk: rk, salt: salt };
|
|
456
|
+
}
|
|
457
|
+
function unwrapRecoveryEnc(root, umk) {
|
|
458
|
+
const p = encRecoveryEncPath(root);
|
|
459
|
+
if (!fs.existsSync(p)) return null;
|
|
460
|
+
const buf = fs.readFileSync(p);
|
|
461
|
+
if (buf.slice(0, KEY_MAGIC.length).toString('utf8') !== KEY_MAGIC) throw new Error('恢复钥匙文件格式错误');
|
|
462
|
+
return aesDecryptBytes(umk, buf.slice(KEY_MAGIC.length), Buffer.from('recovery', 'utf8'));
|
|
463
|
+
}
|
|
464
|
+
function wrapOwnerKey(root, umk, rk, owner) {
|
|
465
|
+
const ownerKey = crypto.randomBytes(32);
|
|
466
|
+
const aad = Buffer.from('owner:' + owner, 'utf8');
|
|
467
|
+
fs.writeFileSync(encOwnerKeyPath(root, owner), Buffer.concat([Buffer.from(KEY_MAGIC, 'utf8'), aesEncryptBytes(umk, ownerKey, aad)]));
|
|
468
|
+
fs.writeFileSync(encOwnerRecPath(root, owner), Buffer.concat([Buffer.from(KEY_MAGIC, 'utf8'), aesEncryptBytes(rk, ownerKey, aad)]));
|
|
469
|
+
return ownerKey;
|
|
470
|
+
}
|
|
471
|
+
function unwrapOwnerKey(root, owner, umk) {
|
|
472
|
+
const p = encOwnerKeyPath(root, owner);
|
|
473
|
+
if (!fs.existsSync(p)) return null;
|
|
474
|
+
const buf = fs.readFileSync(p);
|
|
475
|
+
if (buf.slice(0, KEY_MAGIC.length).toString('utf8') !== KEY_MAGIC) throw new Error('密钥文件格式错误: ' + p);
|
|
476
|
+
return aesDecryptBytes(umk, buf.slice(KEY_MAGIC.length), Buffer.from('owner:' + owner, 'utf8'));
|
|
477
|
+
}
|
|
478
|
+
function unwrapOwnerKeyRecovery(root, owner, rk) {
|
|
479
|
+
const p = encOwnerRecPath(root, owner);
|
|
480
|
+
if (!fs.existsSync(p)) return null;
|
|
481
|
+
const buf = fs.readFileSync(p);
|
|
482
|
+
if (buf.slice(0, KEY_MAGIC.length).toString('utf8') !== KEY_MAGIC) throw new Error('恢复密钥文件格式错误: ' + p);
|
|
483
|
+
return aesDecryptBytes(rk, buf.slice(KEY_MAGIC.length), Buffer.from('owner:' + owner, 'utf8'));
|
|
484
|
+
}
|
|
485
|
+
function writeOwnerKeyCache(root, owner, ownerKey) {
|
|
486
|
+
ensureKeysDir(root);
|
|
487
|
+
const p = encCachePath(root, owner);
|
|
488
|
+
fs.writeFileSync(p, ownerKey);
|
|
489
|
+
try { fs.chmodSync(p, 0o600); } catch (e) {}
|
|
490
|
+
}
|
|
491
|
+
function loadOwnerKeyCache(root, owner) {
|
|
492
|
+
const p = encCachePath(root, owner);
|
|
493
|
+
if (!fs.existsSync(p)) return null;
|
|
494
|
+
return fs.readFileSync(p);
|
|
495
|
+
}
|
|
496
|
+
function revokeOwnerKeyCache(root, owner) {
|
|
497
|
+
const p = encCachePath(root, owner);
|
|
498
|
+
if (fs.existsSync(p)) { fs.unlinkSync(p); return true; }
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
function getOwnerKeyFor(root, owner) {
|
|
502
|
+
if (!owner) return null;
|
|
503
|
+
if (!isEncrypted(root)) return null;
|
|
504
|
+
return loadOwnerKeyCache(root, owner);
|
|
505
|
+
}
|
|
506
|
+
function encryptMemoryText(text, ownerKey) {
|
|
507
|
+
const fileKey = crypto.randomBytes(32);
|
|
508
|
+
const wk = aesEncryptBytes(ownerKey, fileKey, Buffer.from('filekey', 'utf8'));
|
|
509
|
+
const data = aesEncryptBytes(fileKey, Buffer.from(text, 'utf8'), Buffer.from('file', 'utf8'));
|
|
510
|
+
return Buffer.concat([Buffer.from(ENC_MAGIC, 'utf8'), wk, data]);
|
|
511
|
+
}
|
|
512
|
+
function decryptMemoryText(buf, ownerKey) {
|
|
513
|
+
if (buf.slice(0, ENC_MAGIC.length).toString('utf8') !== ENC_MAGIC) throw new Error('非密文文件(缺 ' + ENC_MAGIC + ' 头)');
|
|
514
|
+
const env = buf.slice(ENC_MAGIC.length);
|
|
515
|
+
const wkEnv = env.slice(0, GCM_NONCE + GCM_TAG + 32);
|
|
516
|
+
const fileKey = aesDecryptBytes(ownerKey, wkEnv, Buffer.from('filekey', 'utf8'));
|
|
517
|
+
const dataEnv = env.slice(GCM_NONCE + GCM_TAG + 32);
|
|
518
|
+
return aesDecryptBytes(fileKey, dataEnv, Buffer.from('file', 'utf8')).toString('utf8');
|
|
519
|
+
}
|
|
520
|
+
function readMemoryText(root, fp, owner) {
|
|
521
|
+
if (!isEncFile(fp)) return fs.readFileSync(fp, 'utf8');
|
|
522
|
+
const key = getOwnerKeyFor(root, owner);
|
|
523
|
+
if (!key) throw new Error('私密区已加密:当前无 ' + owner + ' 的授权密钥,请在用户平台授权(yotta-memory view → 授权本智能体)。');
|
|
524
|
+
return decryptMemoryText(fs.readFileSync(fp), key);
|
|
525
|
+
}
|
|
526
|
+
function writeMemoryText(root, fp, text, owner) {
|
|
527
|
+
if (!isEncFile(fp)) { fs.writeFileSync(fp, text, 'utf8'); return; }
|
|
528
|
+
const key = getOwnerKeyFor(root, owner);
|
|
529
|
+
if (!key) throw new Error('私密区已加密:当前无 ' + owner + ' 的授权密钥,请在用户平台授权(yotta-memory view → 授权本智能体)。');
|
|
530
|
+
fs.writeFileSync(fp, encryptMemoryText(text, key));
|
|
531
|
+
}
|
|
532
|
+
function ownerIndexPath(root, owner) { return path.join(root, PRIVATE_DIR, owner, ENC_INDEX_FILE); }
|
|
533
|
+
function loadOwnerIndex(root, owner, ownerKey) {
|
|
534
|
+
const p = ownerIndexPath(root, owner);
|
|
535
|
+
if (!fs.existsSync(p)) return [];
|
|
536
|
+
const buf = fs.readFileSync(p);
|
|
537
|
+
if (buf.slice(0, IDX_MAGIC.length).toString('utf8') !== IDX_MAGIC) return [];
|
|
538
|
+
const d = aesDecryptBytes(ownerKey, buf.slice(IDX_MAGIC.length), Buffer.from('index:' + owner, 'utf8'));
|
|
539
|
+
const j = JSON.parse(d.toString('utf8'));
|
|
540
|
+
return Array.isArray(j.entries) ? j.entries : [];
|
|
541
|
+
}
|
|
542
|
+
function saveOwnerIndex(root, owner, ownerKey, entries) {
|
|
543
|
+
const data = JSON.stringify({ version: 1, updated: today(), entries: entries }, null, 2);
|
|
544
|
+
const env = aesEncryptBytes(ownerKey, Buffer.from(data, 'utf8'), Buffer.from('index:' + owner, 'utf8'));
|
|
545
|
+
const dir = path.join(root, PRIVATE_DIR, owner);
|
|
546
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
547
|
+
fs.writeFileSync(path.join(dir, ENC_INDEX_FILE), Buffer.concat([Buffer.from(IDX_MAGIC, 'utf8'), env]));
|
|
548
|
+
}
|
|
549
|
+
function hasPlaintextPrivate(root) {
|
|
550
|
+
for (const owner of collectOwners(root)) {
|
|
551
|
+
for (const t of PRIVATE_LEAF) {
|
|
552
|
+
const d = path.join(root, PRIVATE_DIR, owner, t);
|
|
553
|
+
if (!fs.existsSync(d)) continue;
|
|
554
|
+
for (const f of fs.readdirSync(d)) if (f.endsWith('.md')) return true;
|
|
555
|
+
}
|
|
556
|
+
if (fs.existsSync(path.join(root, PRIVATE_DIR, owner, PROFILE_FILE))) return true;
|
|
557
|
+
}
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
function profilePathFor(root, owner) {
|
|
561
|
+
const base = path.join(root, PRIVATE_DIR, owner, PROFILE_FILE);
|
|
562
|
+
if (fs.existsSync(base)) return base;
|
|
563
|
+
return base + ENC_SUFFIX;
|
|
564
|
+
}
|
|
565
|
+
function readProfileText(root, owner) {
|
|
566
|
+
const p = profilePathFor(root, owner);
|
|
567
|
+
if (!fs.existsSync(p)) return null;
|
|
568
|
+
return readMemoryText(root, p, owner);
|
|
569
|
+
}
|
|
570
|
+
function writeProfileText(root, owner, text) {
|
|
571
|
+
const dir = path.join(root, PRIVATE_DIR, owner);
|
|
572
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
573
|
+
const encrypted = isEncrypted(root);
|
|
574
|
+
const p = encrypted ? path.join(dir, PROFILE_FILE + ENC_SUFFIX) : path.join(dir, PROFILE_FILE);
|
|
575
|
+
writeMemoryText(root, p, text, owner);
|
|
576
|
+
if (encrypted && fs.existsSync(path.join(dir, PROFILE_FILE))) {
|
|
577
|
+
try { fs.unlinkSync(path.join(dir, PROFILE_FILE)); } catch (e) {}
|
|
578
|
+
}
|
|
579
|
+
return p;
|
|
580
|
+
}
|
|
581
|
+
|
|
183
582
|
// ---- 索引(index.json)----
|
|
184
583
|
function indexPath(root) { return path.join(root, INDEX_FILE); }
|
|
584
|
+
const INDEX_VERSION = 4;
|
|
585
|
+
// v0.8.1 超大库索引分片:公共 index.json 超过阈值按年份分片(index-<year>.json),避免单文件膨胀。
|
|
586
|
+
const INDEX_SHARD_THRESHOLD = 5000;
|
|
587
|
+
// 安全校验:分片文件名必须匹配 index-YYYY.json,防 manifest 篡改导致路径穿越。
|
|
588
|
+
const SHARD_RE = /^index-\d{4}\.json$/;
|
|
589
|
+
function isSafeShardName(name) { return typeof name === 'string' && SHARD_RE.test(name); }
|
|
590
|
+
function indexShardName(year) { return 'index-' + year + '.json'; }
|
|
591
|
+
function loadIndexManifest(root) {
|
|
592
|
+
const p = indexPath(root);
|
|
593
|
+
if (!fs.existsSync(p)) return null;
|
|
594
|
+
try {
|
|
595
|
+
const d = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
596
|
+
if (d && Array.isArray(d.shards)) return d;
|
|
597
|
+
} catch (e) { /* ignore */ }
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
185
600
|
function loadIndex(root) {
|
|
186
601
|
const p = indexPath(root);
|
|
187
602
|
if (!fs.existsSync(p)) return null;
|
|
188
603
|
try {
|
|
189
604
|
const d = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
190
|
-
if (d
|
|
605
|
+
if (!d) return null;
|
|
606
|
+
if (Array.isArray(d.entries)) {
|
|
607
|
+
// v0.8.0 语义索引(字段加权 + 拼音 token)version=3;v0.8.1 分片 version=4;旧版索引返回 null 触发重建
|
|
608
|
+
if (d.version && d.version >= INDEX_VERSION) return d.entries;
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
if (Array.isArray(d.shards)) {
|
|
612
|
+
// v0.8.1 分片 manifest:串联各分片条目
|
|
613
|
+
if (d.version && d.version < INDEX_VERSION) return null;
|
|
614
|
+
const out = [];
|
|
615
|
+
for (const sh of d.shards) {
|
|
616
|
+
if (!isSafeShardName(sh)) continue;
|
|
617
|
+
const sp = path.join(root, sh);
|
|
618
|
+
if (!fs.existsSync(sp)) continue;
|
|
619
|
+
const sd = JSON.parse(fs.readFileSync(sp, 'utf8'));
|
|
620
|
+
if (sd && Array.isArray(sd.entries)) out.push.apply(out, sd.entries);
|
|
621
|
+
}
|
|
622
|
+
return out;
|
|
623
|
+
}
|
|
191
624
|
} catch (e) { /* ignore */ }
|
|
192
625
|
return null;
|
|
193
626
|
}
|
|
194
627
|
function getIndex(root) { return loadIndex(root) || []; }
|
|
195
628
|
function saveIndex(root, entries) {
|
|
196
629
|
const clean = entries.map(function (e) { const c = Object.assign({}, e); delete c.meta; return c; });
|
|
197
|
-
|
|
630
|
+
const old = loadIndexManifest(root);
|
|
631
|
+
if (clean.length > INDEX_SHARD_THRESHOLD) {
|
|
632
|
+
const byYear = {};
|
|
633
|
+
for (const e of clean) {
|
|
634
|
+
const y = String(e.created || '').slice(0, 4) || String(today()).slice(0, 4);
|
|
635
|
+
(byYear[y] = byYear[y] || []).push(e);
|
|
636
|
+
}
|
|
637
|
+
const newShards = [];
|
|
638
|
+
for (const y of Object.keys(byYear).sort()) {
|
|
639
|
+
const name = indexShardName(y);
|
|
640
|
+
fs.writeFileSync(path.join(root, name), JSON.stringify({ version: INDEX_VERSION, year: (parseInt(y, 10) || 0), entries: byYear[y] }, null, 2), 'utf8');
|
|
641
|
+
newShards.push(name);
|
|
642
|
+
}
|
|
643
|
+
fs.writeFileSync(indexPath(root), JSON.stringify({ version: INDEX_VERSION, updated: today(), shards: newShards, count: clean.length }, null, 2), 'utf8');
|
|
644
|
+
if (old && Array.isArray(old.shards)) {
|
|
645
|
+
for (const sh of old.shards) if (newShards.indexOf(sh) === -1 && isSafeShardName(sh)) { try { fs.unlinkSync(path.join(root, sh)); } catch (e) {} }
|
|
646
|
+
}
|
|
647
|
+
} else {
|
|
648
|
+
if (old && Array.isArray(old.shards)) {
|
|
649
|
+
for (const sh of old.shards) { try { fs.unlinkSync(path.join(root, sh)); } catch (e) {} }
|
|
650
|
+
}
|
|
651
|
+
fs.writeFileSync(indexPath(root), JSON.stringify({ version: INDEX_VERSION, updated: today(), entries: clean }, null, 2), 'utf8');
|
|
652
|
+
}
|
|
198
653
|
}
|
|
199
654
|
function collectEntryFiles(root) {
|
|
200
655
|
const dirs = [];
|
|
201
656
|
const facts = path.join(root, PUBLIC_DIR);
|
|
202
|
-
if (fs.existsSync(facts)) dirs.push(
|
|
657
|
+
if (fs.existsSync(facts)) dirs.push(facts);
|
|
203
658
|
const pdir = path.join(root, PRIVATE_DIR);
|
|
204
659
|
if (fs.existsSync(pdir)) {
|
|
205
660
|
for (const id of fs.readdirSync(pdir)) {
|
|
@@ -214,6 +669,7 @@ function collectEntryFiles(root) {
|
|
|
214
669
|
const out = [];
|
|
215
670
|
for (const dir of dirs) {
|
|
216
671
|
for (const f of fs.readdirSync(dir)) {
|
|
672
|
+
if (!/\.md(\.enc)?$/.test(f)) continue;
|
|
217
673
|
const fp = path.join(dir, f);
|
|
218
674
|
if (fs.statSync(fp).isFile()) out.push(fp);
|
|
219
675
|
}
|
|
@@ -242,37 +698,113 @@ function migrateLayout(root) {
|
|
|
242
698
|
}
|
|
243
699
|
function buildIndex(root) {
|
|
244
700
|
migrateLayout(root);
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
701
|
+
const publicEntries = [];
|
|
702
|
+
const privateByOwner = {};
|
|
703
|
+
for (const fp of collectEntryFiles(root)) {
|
|
704
|
+
const rel = path.relative(root, fp).replace(/\\/g, '/');
|
|
705
|
+
if (rel.indexOf(PRIVATE_DIR + '/') === 0) {
|
|
706
|
+
const owner = rel.split('/')[1] || '';
|
|
707
|
+
if (!getOwnerKeyFor(root, owner)) continue;
|
|
708
|
+
let e;
|
|
709
|
+
try { e = readEntry(fp, root); } catch (err) { continue; }
|
|
710
|
+
(privateByOwner[owner] = privateByOwner[owner] || []).push(e);
|
|
711
|
+
} else {
|
|
712
|
+
try { publicEntries.push(readEntry(fp, root)); } catch (err) { /* ignore */ }
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
saveIndex(root, publicEntries);
|
|
716
|
+
for (const owner of Object.keys(privateByOwner)) {
|
|
717
|
+
const key = getOwnerKeyFor(root, owner);
|
|
718
|
+
if (key) saveOwnerIndex(root, owner, key, privateByOwner[owner]);
|
|
719
|
+
}
|
|
720
|
+
const all = publicEntries.slice();
|
|
721
|
+
for (const owner of Object.keys(privateByOwner)) all.push.apply(all, privateByOwner[owner]);
|
|
722
|
+
return all;
|
|
249
723
|
}
|
|
250
724
|
function ensureIndex(root) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
725
|
+
if (!isEncrypted(root)) {
|
|
726
|
+
const idx = loadIndex(root);
|
|
727
|
+
return idx || buildIndex(root);
|
|
728
|
+
}
|
|
729
|
+
const all = [];
|
|
730
|
+
const pub = loadIndex(root);
|
|
731
|
+
if (pub) all.push.apply(all, pub);
|
|
732
|
+
for (const owner of collectOwners(root)) {
|
|
733
|
+
const key = getOwnerKeyFor(root, owner);
|
|
734
|
+
if (!key) continue;
|
|
735
|
+
let idx = [];
|
|
736
|
+
try { idx = loadOwnerIndex(root, owner, key); } catch (err) { idx = []; }
|
|
737
|
+
all.push.apply(all, idx);
|
|
738
|
+
}
|
|
739
|
+
return all;
|
|
254
740
|
}
|
|
255
741
|
function upsertIndexEntry(root, entry) {
|
|
742
|
+
if (isEncrypted(root) && entry.scope !== 'public' && String(entry.file).indexOf(PUBLIC_DIR + '/') !== 0) {
|
|
743
|
+
const owner = entry.owner || String(entry.file).split('/')[1] || '';
|
|
744
|
+
const key = getOwnerKeyFor(root, owner);
|
|
745
|
+
if (!key) return;
|
|
746
|
+
const idx = loadOwnerIndex(root, owner, key);
|
|
747
|
+
const i = idx.findIndex(function (e) { return e.file === entry.file; });
|
|
748
|
+
if (i >= 0) idx[i] = entry; else idx.push(entry);
|
|
749
|
+
saveOwnerIndex(root, owner, key, idx);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
256
752
|
const idx = getIndex(root);
|
|
257
753
|
const i = idx.findIndex(function (e) { return e.file === entry.file; });
|
|
258
754
|
if (i >= 0) idx[i] = entry; else idx.push(entry);
|
|
259
755
|
saveIndex(root, idx);
|
|
260
756
|
}
|
|
261
757
|
function removeIndexEntry(root, file) {
|
|
758
|
+
if (isEncrypted(root) && String(file).indexOf(PRIVATE_DIR + '/') === 0) {
|
|
759
|
+
const owner = String(file).split('/')[1] || '';
|
|
760
|
+
const key = getOwnerKeyFor(root, owner);
|
|
761
|
+
if (!key) return;
|
|
762
|
+
const idx = loadOwnerIndex(root, owner, key);
|
|
763
|
+
const next = idx.filter(function (e) { return e.file !== file; });
|
|
764
|
+
if (next.length !== idx.length) saveOwnerIndex(root, owner, key, next);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
262
767
|
const idx = getIndex(root);
|
|
263
768
|
const i = idx.findIndex(function (e) { return e.file === file; });
|
|
264
769
|
if (i >= 0) { idx.splice(i, 1); saveIndex(root, idx); }
|
|
265
770
|
}
|
|
266
771
|
function removeIndexEntries(root, files) {
|
|
267
772
|
const set = new Set(files);
|
|
268
|
-
const
|
|
269
|
-
const
|
|
270
|
-
|
|
773
|
+
const byOwner = {};
|
|
774
|
+
const pub = [];
|
|
775
|
+
for (const f of files) {
|
|
776
|
+
if (String(f).indexOf(PRIVATE_DIR + '/') === 0) {
|
|
777
|
+
const o = String(f).split('/')[1] || '';
|
|
778
|
+
(byOwner[o] = byOwner[o] || []).push(f);
|
|
779
|
+
} else pub.push(f);
|
|
780
|
+
}
|
|
781
|
+
if (pub.length) {
|
|
782
|
+
const idx = getIndex(root);
|
|
783
|
+
const next = idx.filter(function (e) { return !set.has(e.file); });
|
|
784
|
+
if (next.length !== idx.length) saveIndex(root, next);
|
|
785
|
+
}
|
|
786
|
+
for (const o of Object.keys(byOwner)) {
|
|
787
|
+
const key = getOwnerKeyFor(root, o);
|
|
788
|
+
if (!key) continue;
|
|
789
|
+
const idx = loadOwnerIndex(root, o, key);
|
|
790
|
+
const oset = new Set(byOwner[o]);
|
|
791
|
+
const next = idx.filter(function (e) { return !oset.has(e.file); });
|
|
792
|
+
if (next.length !== idx.length) saveOwnerIndex(root, o, key, next);
|
|
793
|
+
}
|
|
271
794
|
}
|
|
272
795
|
|
|
273
796
|
// ---- 读取 / 写入记忆文件 ----
|
|
274
797
|
function readEntry(fp, root) {
|
|
275
|
-
const
|
|
798
|
+
const owner = ownerFromPrivatePath(root, fp);
|
|
799
|
+
let text;
|
|
800
|
+
if (isEncFile(fp)) {
|
|
801
|
+
const key = getOwnerKeyFor(root, owner);
|
|
802
|
+
if (!key) throw new Error('私密区已加密:当前无 ' + owner + ' 的授权密钥(yotta-memory view → 授权本智能体)。');
|
|
803
|
+
text = decryptMemoryText(fs.readFileSync(fp), key);
|
|
804
|
+
} else {
|
|
805
|
+
text = fs.readFileSync(fp, 'utf8');
|
|
806
|
+
}
|
|
807
|
+
const parsed = parseFrontmatter(text);
|
|
276
808
|
const meta = parsed.meta;
|
|
277
809
|
const type = (meta.type || 'FACT').toUpperCase();
|
|
278
810
|
const subject = meta.subject || '';
|
|
@@ -294,34 +826,55 @@ function readEntry(fp, root) {
|
|
|
294
826
|
immutable: meta.immutable === 'true',
|
|
295
827
|
source: meta.source || '',
|
|
296
828
|
weight: (parseFloat(meta.weight) > 0 ? parseFloat(meta.weight) : 1.0),
|
|
829
|
+
feedback_net: feedbackNetOf(meta),
|
|
297
830
|
tokens: buildTokens(subject, statement, tags),
|
|
298
831
|
meta: meta,
|
|
299
832
|
};
|
|
300
833
|
}
|
|
301
|
-
function rewriteFrontmatter(fp, patch) {
|
|
302
|
-
|
|
303
|
-
|
|
834
|
+
function rewriteFrontmatter(fp, patch, root, owner) {
|
|
835
|
+
let text;
|
|
836
|
+
try { text = readMemoryText(root || memoryRootForFile(fp), fp, owner || ownerFromPrivatePath(root || memoryRootForFile(fp), fp)); }
|
|
837
|
+
catch (e) { throw e; }
|
|
838
|
+
const parsed = parseFrontmatter(text);
|
|
304
839
|
const meta = Object.assign({}, parsed.meta);
|
|
305
840
|
for (const k of Object.keys(patch)) meta[k] = patch[k];
|
|
306
|
-
|
|
841
|
+
writeMemoryText(root || memoryRootForFile(fp), fp, frontmatterToText(meta, parsed.body), owner || ownerFromPrivatePath(root || memoryRootForFile(fp), fp));
|
|
307
842
|
}
|
|
308
843
|
function bumpReadMeta(root, relFiles) {
|
|
309
844
|
const now = today();
|
|
310
845
|
for (const rel of relFiles) {
|
|
311
846
|
const fp = path.join(root, rel);
|
|
312
847
|
if (!fs.existsSync(fp)) continue;
|
|
313
|
-
const
|
|
848
|
+
const owner = ownerFromPrivatePath(root, fp);
|
|
849
|
+
let parsed;
|
|
850
|
+
try { parsed = parseFrontmatter(readMemoryText(root, fp, owner)); } catch (e) { continue; }
|
|
314
851
|
const meta = parsed.meta;
|
|
315
852
|
if (meta.immutable === 'true') continue;
|
|
316
853
|
const acc = (parseInt(meta.access_count || '0', 10) || 0) + 1;
|
|
317
|
-
rewriteFrontmatter(fp, { access_count: acc, last_accessed: now });
|
|
854
|
+
rewriteFrontmatter(fp, { access_count: acc, last_accessed: now }, root, owner);
|
|
318
855
|
}
|
|
319
856
|
}
|
|
320
857
|
function touchIndex(root, relFiles) {
|
|
321
858
|
const set = new Set(relFiles);
|
|
859
|
+
const now = today();
|
|
860
|
+
const manifest = loadIndexManifest(root);
|
|
861
|
+
if (manifest && Array.isArray(manifest.shards)) {
|
|
862
|
+
for (const sh of manifest.shards) {
|
|
863
|
+
if (!isSafeShardName(sh)) continue;
|
|
864
|
+
const sp = path.join(root, sh);
|
|
865
|
+
if (!fs.existsSync(sp)) continue;
|
|
866
|
+
let sd = null; try { sd = JSON.parse(fs.readFileSync(sp, 'utf8')); } catch (e) { continue; }
|
|
867
|
+
if (!sd || !Array.isArray(sd.entries)) continue;
|
|
868
|
+
let dirty = false;
|
|
869
|
+
for (const e of sd.entries) {
|
|
870
|
+
if (set.has(e.file)) { e.access_count = (parseInt(e.access_count, 10) || 0) + 1; e.last_accessed = now; dirty = true; }
|
|
871
|
+
}
|
|
872
|
+
if (dirty) fs.writeFileSync(sp, JSON.stringify(sd, null, 2), 'utf8');
|
|
873
|
+
}
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
322
876
|
const idx = getIndex(root);
|
|
323
877
|
let dirty = false;
|
|
324
|
-
const now = today();
|
|
325
878
|
for (const e of idx) {
|
|
326
879
|
if (set.has(e.file)) { e.access_count = (parseInt(e.access_count, 10) || 0) + 1; e.last_accessed = now; dirty = true; }
|
|
327
880
|
}
|
|
@@ -342,7 +895,7 @@ function vitality(meta) {
|
|
|
342
895
|
if (last) { const d = daysBetween(last, today()); recency = Math.max(0, 1 - Math.floor(d / 30) * 0.2); }
|
|
343
896
|
return 0.4 * conf + 0.3 * accScore + 0.3 * recency;
|
|
344
897
|
}
|
|
345
|
-
//
|
|
898
|
+
// importance 融合:confidence × (0.5 + recency) + updated 加分 + weight 乘子 + immutable 加分
|
|
346
899
|
function importanceScore(meta) {
|
|
347
900
|
const conf = parseFloat(meta.confidence || 1.0);
|
|
348
901
|
let ageDays = 999;
|
|
@@ -404,9 +957,20 @@ function verifyWrittenReadable(root, rel, subj, agent) {
|
|
|
404
957
|
|
|
405
958
|
// ---- 命令 core(CLI 与 MCP 共用;返回 { error, exitCode, text },不 process.exit)----
|
|
406
959
|
function initCore(opts) {
|
|
960
|
+
opts = opts || {};
|
|
407
961
|
const root = opts.dir ? path.resolve(String(opts.dir)) : (opts.project ? projectRoot() : userRoot());
|
|
962
|
+
const fresh = !fs.existsSync(path.join(root, PUBLIC_DIR)) && !fs.existsSync(path.join(root, PRIVATE_DIR)) && !fs.existsSync(path.join(root, INDEX_FILE));
|
|
408
963
|
ensureInit(root);
|
|
409
|
-
|
|
964
|
+
const wantEncrypt = opts.encrypt || (!opts.noEncrypt && fresh);
|
|
965
|
+
if (wantEncrypt) {
|
|
966
|
+
if (isEncrypted(root)) return { error: false, text: '已初始化记忆库(已启用加密): ' + root };
|
|
967
|
+
if (hasPlaintextPrivate(root)) return { error: true, text: '检测到明文私密区,请先 yotta-memory migrate 迁移到密文;新建空库可直接 init --encrypt。' };
|
|
968
|
+
const password = String(opts.password || process.env.YOTTA_MEMORY_PASS || '');
|
|
969
|
+
if (!password) return { error: true, text: '启用加密需要主口令:交互输入,或 YOTTA_MEMORY_PASS 环境变量(自动化)。' };
|
|
970
|
+
const r = initEncryptionCore(root, password, null);
|
|
971
|
+
return { error: false, text: '已初始化加密记忆库: ' + root + '\n[恢复钥匙](务必离线保存,仅此一次,泄露=可解全部私密): ' + r.rk.toString('base64') };
|
|
972
|
+
}
|
|
973
|
+
return { error: false, text: '已初始化记忆库: ' + root + (isEncrypted(root) ? '(已启用加密)' : '(明文;建议 init --encrypt 或 migrate 启用加密)') };
|
|
410
974
|
}
|
|
411
975
|
function rememberCore(type, subject, statement, opts) {
|
|
412
976
|
opts = opts || {};
|
|
@@ -427,13 +991,18 @@ function rememberCore(type, subject, statement, opts) {
|
|
|
427
991
|
if (scope === 'private' && owner && selfAgent && owner !== selfAgent && !opts.unsafe) {
|
|
428
992
|
return { error: true, text: '拒绝: 当前声明身份 ' + selfAgent + ' 不能写入其它智能体 ' + owner + ' 的私密区。请用 YOTTA_AGENT_ID 声明自己的身份,或加 --unsafe(用户显式授权)。' };
|
|
429
993
|
}
|
|
994
|
+
const encrypted = isEncrypted(root);
|
|
995
|
+
if (scope === 'private' && encrypted && !getOwnerKeyFor(root, owner)) {
|
|
996
|
+
return { error: true, text: '私密区已加密:当前无 ' + owner + ' 的授权密钥,请在用户平台授权(yotta-memory view → 授权本智能体)后再写私密记忆。公共 FACT 不受影响。' };
|
|
997
|
+
}
|
|
430
998
|
const dir = path.join(root, typeSubdir(t, owner));
|
|
431
999
|
fs.mkdirSync(dir, { recursive: true });
|
|
432
1000
|
if (fs.existsSync(dir)) {
|
|
433
1001
|
for (const f of fs.readdirSync(dir)) {
|
|
434
1002
|
const fp = path.join(dir, f);
|
|
435
1003
|
if (!fs.statSync(fp).isFile()) continue;
|
|
436
|
-
|
|
1004
|
+
let parsed;
|
|
1005
|
+
try { parsed = parseFrontmatter(readMemoryText(root, fp, owner)); } catch (err) { continue; }
|
|
437
1006
|
const meta = parsed.meta;
|
|
438
1007
|
if ((meta.type || '').toUpperCase() === t && meta.subject === subj && meta.statement === stmt) {
|
|
439
1008
|
const patch = { updated: today() };
|
|
@@ -441,7 +1010,7 @@ function rememberCore(type, subject, statement, opts) {
|
|
|
441
1010
|
if (scope && !meta.scope) patch.scope = scope;
|
|
442
1011
|
if (opts.source && !meta.source) patch.source = opts.source;
|
|
443
1012
|
const w = parseFloat(opts.weight); if (w > 0) patch.weight = Math.max(parseFloat(meta.weight || '1.0') || 1.0, w);
|
|
444
|
-
rewriteFrontmatter(fp, patch);
|
|
1013
|
+
rewriteFrontmatter(fp, patch, root, owner);
|
|
445
1014
|
upsertIndexEntry(root, readEntry(fp, root));
|
|
446
1015
|
let text = '已更新: ' + fp;
|
|
447
1016
|
if (opts.hint !== false && t === 'FACT') {
|
|
@@ -458,7 +1027,8 @@ function rememberCore(type, subject, statement, opts) {
|
|
|
458
1027
|
}
|
|
459
1028
|
}
|
|
460
1029
|
const seq = nextSeq(dir);
|
|
461
|
-
const
|
|
1030
|
+
const suffix = (encrypted && scope === 'private') ? ENC_SUFFIX : '';
|
|
1031
|
+
const file = path.join(dir, today() + '-' + seq + '.md' + suffix);
|
|
462
1032
|
const rec = {
|
|
463
1033
|
type: t, subject: subj, statement: stmt,
|
|
464
1034
|
confidence: 1.0, created: today(), updated: today(),
|
|
@@ -468,7 +1038,7 @@ function rememberCore(type, subject, statement, opts) {
|
|
|
468
1038
|
weight: (parseFloat(opts.weight) > 0 ? parseFloat(opts.weight) : 1.0),
|
|
469
1039
|
access_count: 0, last_accessed: '',
|
|
470
1040
|
};
|
|
471
|
-
|
|
1041
|
+
writeMemoryText(root, file, frontmatterToText(rec, stmt), owner);
|
|
472
1042
|
upsertIndexEntry(root, readEntry(file, root));
|
|
473
1043
|
let text = '已记录: ' + file;
|
|
474
1044
|
if (opts.hint !== false && t === 'FACT') {
|
|
@@ -482,7 +1052,44 @@ function rememberCore(type, subject, statement, opts) {
|
|
|
482
1052
|
}
|
|
483
1053
|
return { error: false, text: text };
|
|
484
1054
|
}
|
|
1055
|
+
// v0.8.1: recall 候选预过滤——查询侧一次构建候选 token 集(精确/同义/拼音/拉丁 py),
|
|
1056
|
+
// 逐条用 token 交集 + 子串 + 模糊长度门槛做粗筛,命中候选才进语义打分,避免 N 大时逐条全量语义/模糊/编辑距离。
|
|
1057
|
+
// 该闸门是 semanticMatch 命中集的超集,绝不漏掉同义词/拼音/模糊命中,仅做性能粗筛不改语义。
|
|
1058
|
+
function recallPrefilter(q) {
|
|
1059
|
+
const qtoks = tokenize(q);
|
|
1060
|
+
const qset = new Set(qtoks);
|
|
1061
|
+
const qpy = pinyinTokens(q);
|
|
1062
|
+
const ext = new Set();
|
|
1063
|
+
for (const tt of qtoks) { const s = synonymSet(tt); for (const w of s) ext.add(w); }
|
|
1064
|
+
const qkeys = new Set();
|
|
1065
|
+
for (const tt of qtoks) qkeys.add(tt);
|
|
1066
|
+
for (const w of ext) qkeys.add(w);
|
|
1067
|
+
for (const t of qpy) qkeys.add(t);
|
|
1068
|
+
const longQtoks = qtoks.filter(function (t) { return t.length >= 4; });
|
|
1069
|
+
for (const tt of qtoks) {
|
|
1070
|
+
if (/^[a-z]+$/.test(tt)) {
|
|
1071
|
+
qkeys.add('py:' + tt); qkeys.add('pyi:' + tt);
|
|
1072
|
+
if (tt.length >= 3) { for (let i = 0; i < tt.length - 1; i++) qkeys.add('pyi:' + tt.slice(i, i + 2)); }
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
return function (e) {
|
|
1076
|
+
const toks = e.tokens || {};
|
|
1077
|
+
const keys = Object.keys(toks);
|
|
1078
|
+
for (const k of keys) {
|
|
1079
|
+
if (qkeys.has(k)) return true;
|
|
1080
|
+
if (longQtoks.length && k.length >= 4 && k.indexOf(':') === -1) {
|
|
1081
|
+
for (const t of longQtoks) {
|
|
1082
|
+
if (Math.abs(k.length - t.length) <= 2) return true;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
const hay = ((e.subject || '') + ' ' + (e.statement || '') + ' ' + (e.tags || []).join(' ')).toLowerCase();
|
|
1087
|
+
if (hay.indexOf(q) !== -1) return true;
|
|
1088
|
+
return false;
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
485
1091
|
function recallCore(query, opts) {
|
|
1092
|
+
opts = opts || {};
|
|
486
1093
|
const roots = memoryRoots();
|
|
487
1094
|
if (!roots.length) return { error: false, exitCode: 0, text: '记忆库不存在,请先运行: yotta-memory init' };
|
|
488
1095
|
const limit = opts.limit || 50;
|
|
@@ -494,6 +1101,9 @@ function recallCore(query, opts) {
|
|
|
494
1101
|
const allSafe = !!opts.unsafe;
|
|
495
1102
|
const impersonation = !!(opts.agent && selfAgent && opts.agent !== selfAgent);
|
|
496
1103
|
const explicitCross = !!opts.all || impersonation || (!!ownerFilter && ownerFilter.toLowerCase() !== 'user' && ownerFilter !== agent);
|
|
1104
|
+
const wantExplain = !!opts.explain;
|
|
1105
|
+
const useSemantic = opts.semantic !== false;
|
|
1106
|
+
const prefilter = (q && useSemantic) ? recallPrefilter(q) : null;
|
|
497
1107
|
const hits = [];
|
|
498
1108
|
let deniedCount = 0;
|
|
499
1109
|
for (const root of roots) {
|
|
@@ -503,27 +1113,61 @@ function recallCore(query, opts) {
|
|
|
503
1113
|
const r = classifyRead(e, agent, ownerFilter, allSafe, selfAgent);
|
|
504
1114
|
if (r === 'denied') { deniedCount++; continue; }
|
|
505
1115
|
let score = 0;
|
|
1116
|
+
let detail = null;
|
|
506
1117
|
if (q) {
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
if (
|
|
1118
|
+
if (useSemantic) {
|
|
1119
|
+
if (prefilter && !prefilter(e)) continue; // v0.8.1 候选预过滤:粗筛后再语义打分
|
|
1120
|
+
const m = semanticMatch(e, q, wantExplain);
|
|
1121
|
+
score = m.score;
|
|
1122
|
+
if (wantExplain && m.detail && m.detail.length) detail = m.detail;
|
|
1123
|
+
} else {
|
|
1124
|
+
const qtoks = tokenize(q);
|
|
1125
|
+
for (const tt of qtoks) { if (e.tokens && e.tokens[tt]) score += e.tokens[tt]; }
|
|
1126
|
+
if (score === 0) {
|
|
1127
|
+
const hay = ((e.subject || '') + ' ' + (e.statement || '') + ' ' + e.tags.join(' ')).toLowerCase();
|
|
1128
|
+
if (hay.indexOf(q) !== -1) score = 1;
|
|
1129
|
+
}
|
|
512
1130
|
}
|
|
513
1131
|
if (score === 0) continue;
|
|
514
1132
|
} else {
|
|
515
1133
|
score = 1;
|
|
516
1134
|
}
|
|
517
|
-
hits.push({ entry: e, score: score, root: root });
|
|
1135
|
+
hits.push({ entry: e, score: score, root: root, detail: detail });
|
|
518
1136
|
}
|
|
519
1137
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
1138
|
+
// v0.8.0 融合排序:0.65 × 语义分归一 + 0.35 × 效用分归一
|
|
1139
|
+
if (hits.length > 1) {
|
|
1140
|
+
let minSem = Infinity, maxSem = -Infinity, minUtil = Infinity, maxUtil = -Infinity;
|
|
1141
|
+
for (const h of hits) {
|
|
1142
|
+
if (h.score < minSem) minSem = h.score;
|
|
1143
|
+
if (h.score > maxSem) maxSem = h.score;
|
|
1144
|
+
const u = utilityScore(h.entry);
|
|
1145
|
+
if (u < minUtil) minUtil = u;
|
|
1146
|
+
if (u > maxUtil) maxUtil = u;
|
|
1147
|
+
}
|
|
1148
|
+
const spanSem = maxSem - minSem;
|
|
1149
|
+
const spanUtil = maxUtil - minUtil;
|
|
1150
|
+
for (const h of hits) {
|
|
1151
|
+
h.semNorm = spanSem > 0 ? (h.score - minSem) / spanSem : 1;
|
|
1152
|
+
h.utilNorm = spanUtil > 0 ? (utilityScore(h.entry) - minUtil) / spanUtil : 1;
|
|
1153
|
+
h.finalScore = 0.65 * h.semNorm + 0.35 * h.utilNorm;
|
|
1154
|
+
}
|
|
1155
|
+
hits.sort(function (a, b) {
|
|
1156
|
+
if (b.finalScore !== a.finalScore) return b.finalScore - a.finalScore;
|
|
1157
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
1158
|
+
const pa = a.root === projectRoot() ? 0 : 1;
|
|
1159
|
+
const pb = b.root === projectRoot() ? 0 : 1;
|
|
1160
|
+
if (pa !== pb) return pa - pb;
|
|
1161
|
+
return String(b.entry.created).localeCompare(String(a.entry.created));
|
|
1162
|
+
});
|
|
1163
|
+
} else {
|
|
1164
|
+
hits.sort(function (a, b) {
|
|
1165
|
+
const pa = a.root === projectRoot() ? 0 : 1;
|
|
1166
|
+
const pb = b.root === projectRoot() ? 0 : 1;
|
|
1167
|
+
if (pa !== pb) return pa - pb;
|
|
1168
|
+
return String(b.entry.created).localeCompare(String(a.entry.created));
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
527
1171
|
const shown = hits.slice(0, limit);
|
|
528
1172
|
if (!shown.length) {
|
|
529
1173
|
if (deniedCount > 0 && explicitCross) {
|
|
@@ -542,6 +1186,11 @@ function recallCore(query, opts) {
|
|
|
542
1186
|
for (const h of shown) {
|
|
543
1187
|
lines.push('[' + h.entry.type + '] ' + h.entry.subject + ': ' + h.entry.statement);
|
|
544
1188
|
lines.push(' ' + path.join(h.root, h.entry.file));
|
|
1189
|
+
if (wantExplain) {
|
|
1190
|
+
if (h.detail && h.detail.length) lines.push(' 命中: ' + h.detail.join('、'));
|
|
1191
|
+
const ub = utilityBreakdown(h.entry);
|
|
1192
|
+
lines.push(' 效用分: ' + round3(ub.total) + '(conf ' + ub.confidence + ' + 使用 ' + ub.usage + ' + 时效 ' + ub.recency + ' + 类型 ' + ub.type + ' + 结构 ' + ub.structure + ')×weight ' + ub.weight);
|
|
1193
|
+
}
|
|
545
1194
|
}
|
|
546
1195
|
if (deniedCount > 0 && explicitCross) {
|
|
547
1196
|
lines.push('\n[警告] 本次检索共拒绝 ' + deniedCount + ' 条越界访问(其它智能体私密记忆,未授权不展示)。如需读取请加 --unsafe 或 --owner user。');
|
|
@@ -585,21 +1234,26 @@ function forgetCore(fileRef, opts) {
|
|
|
585
1234
|
return { error: false, text: '已删除: ' + target };
|
|
586
1235
|
}
|
|
587
1236
|
function archiveCore(opts) {
|
|
588
|
-
|
|
589
|
-
const
|
|
1237
|
+
opts = opts || {};
|
|
1238
|
+
const cfg = loadConfig();
|
|
1239
|
+
const days = opts.days || parseInt(cfg.maintain_archived_age || '180', 10) || 180;
|
|
1240
|
+
const threshold = (opts.threshold !== undefined && opts.threshold !== null) ? opts.threshold : (parseFloat(cfg.maintain_archived_utility || 0.35));
|
|
590
1241
|
const root = userRoot();
|
|
591
1242
|
if (!fs.existsSync(root)) return { error: false, text: '记忆库不存在。' };
|
|
592
1243
|
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
593
1244
|
let moved = 0;
|
|
594
1245
|
const movedFiles = [];
|
|
595
1246
|
for (const fp of collectEntryFiles(root)) {
|
|
596
|
-
const
|
|
1247
|
+
const owner = ownerFromPrivatePath(root, fp);
|
|
1248
|
+
let meta;
|
|
1249
|
+
try { meta = parseFrontmatter(readMemoryText(root, fp, owner)).meta; } catch (e) { continue; }
|
|
597
1250
|
if (meta.immutable === 'true') continue;
|
|
598
1251
|
if (!meta.created) continue;
|
|
599
1252
|
const createdTs = new Date(meta.created).getTime();
|
|
600
1253
|
if (isNaN(createdTs)) continue;
|
|
601
1254
|
const t = (meta.type || 'FACT').toUpperCase();
|
|
602
|
-
|
|
1255
|
+
// v0.8.0 统一效用分(盖棺分)替代 vitality
|
|
1256
|
+
if (utilityScore(meta) < threshold && createdTs < cutoff) {
|
|
603
1257
|
const destDir = path.join(root, ARCHIVE_DIR, TYPE_DIRS[t]);
|
|
604
1258
|
fs.mkdirSync(destDir, { recursive: true });
|
|
605
1259
|
fs.renameSync(fp, path.join(destDir, path.basename(fp)));
|
|
@@ -612,10 +1266,779 @@ function archiveCore(opts) {
|
|
|
612
1266
|
return { error: false, text: '已归档 ' + moved + ' 条旧记忆到 ' + path.join(root, ARCHIVE_DIR) };
|
|
613
1267
|
}
|
|
614
1268
|
|
|
1269
|
+
// ---- v0.8.0 自我学习/自我进化/自我提升:feedback / explain / maintain / distill ----
|
|
1270
|
+
function auditPath(root, kind) {
|
|
1271
|
+
return path.join(root, ARCHIVE_DIR, kind + '-' + today() + '.jsonl');
|
|
1272
|
+
}
|
|
1273
|
+
function appendAudit(root, kind, rec) {
|
|
1274
|
+
try {
|
|
1275
|
+
fs.mkdirSync(path.join(root, ARCHIVE_DIR), { recursive: true });
|
|
1276
|
+
fs.appendFileSync(auditPath(root, kind), JSON.stringify(rec) + '\n', 'utf8');
|
|
1277
|
+
} catch (e) { /* 审计失败不阻断主流程 */ }
|
|
1278
|
+
}
|
|
1279
|
+
function resolveMemoryTarget(ref) {
|
|
1280
|
+
const roots = memoryRoots();
|
|
1281
|
+
const targetRef = String(ref || '').replace(/\\/g, '/');
|
|
1282
|
+
for (const root of roots) {
|
|
1283
|
+
const found = resolveMemoryFile(root, targetRef);
|
|
1284
|
+
if (found) return { fp: found.fp, root: root, rel: found.rel };
|
|
1285
|
+
}
|
|
1286
|
+
return null;
|
|
1287
|
+
}
|
|
1288
|
+
function checkOwnerWritable(targetRoot, targetRel, selfAgent, unsafe) {
|
|
1289
|
+
const seg = String(targetRel).split('/');
|
|
1290
|
+
if (seg[0] === PRIVATE_DIR) {
|
|
1291
|
+
const owner = seg[1] || '';
|
|
1292
|
+
if (!unsafe && (owner && (selfAgent ? owner !== selfAgent : true))) {
|
|
1293
|
+
return '拒绝: 不能操作其它智能体 ' + owner + ' 的私密记忆(当前身份 ' + (selfAgent || '未声明') + ')。请用 YOTTA_AGENT_ID 声明自己的身份,或加 --unsafe(用户显式授权)。';
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
return null;
|
|
1297
|
+
}
|
|
1298
|
+
// 显式使用反馈:useful/useless → weight/confidence/feedback_net 演化(自我学习闭环)
|
|
1299
|
+
function feedbackCore(ref, opts) {
|
|
1300
|
+
opts = opts || {};
|
|
1301
|
+
const selfAgent = opts.selfAgent || currentAgent();
|
|
1302
|
+
const target = resolveMemoryTarget(ref);
|
|
1303
|
+
if (!target) return { error: true, text: '未找到记忆文件: ' + ref };
|
|
1304
|
+
const deny = checkOwnerWritable(target.root, target.rel, selfAgent, opts.unsafe);
|
|
1305
|
+
if (deny) return { error: true, text: deny };
|
|
1306
|
+
const owner = ownerFromPrivatePath(target.root, target.fp);
|
|
1307
|
+
let meta;
|
|
1308
|
+
try { meta = parseFrontmatter(readMemoryText(target.root, target.fp, owner)).meta; } catch (e) { return { error: true, text: '读取失败: ' + e.message }; }
|
|
1309
|
+
if (opts.undo) {
|
|
1310
|
+
return feedbackUndoCore(target, owner, meta, selfAgent, opts);
|
|
1311
|
+
}
|
|
1312
|
+
const useful = !!opts.useful;
|
|
1313
|
+
const useless = !!opts.useless;
|
|
1314
|
+
if (!useful && !useless) return { error: true, text: '请指定 --useful(有用)或 --useless(没用)。' };
|
|
1315
|
+
const cfg = loadConfig();
|
|
1316
|
+
const stepW = parseFloat(cfg.feedback_weight_step || 0.2);
|
|
1317
|
+
const stepC = parseFloat(cfg.feedback_confidence_step || 0.05);
|
|
1318
|
+
const w = parseFloat(meta.weight || '1.0'); const weight = (w > 0 ? w : 1.0);
|
|
1319
|
+
const conf = parseFloat(meta.confidence || '1.0'); const confidence = (conf > 0 ? conf : 1.0);
|
|
1320
|
+
const fb = feedbackNetOf(meta);
|
|
1321
|
+
let nw = weight, nc = confidence, nfb = fb;
|
|
1322
|
+
if (useful) { nw = Math.min(weight * (1 + stepW), 3.0); nc = Math.min(confidence + stepC, 1.0); nfb = fb + 1; }
|
|
1323
|
+
if (useless) { nw = Math.max(weight * (1 - stepW), 0.2); nc = Math.max(confidence - stepC, 0.3); nfb = fb - 1; }
|
|
1324
|
+
const before = { weight: round3(weight), confidence: round3(confidence), feedback_net: fb };
|
|
1325
|
+
const after = { weight: round3(nw), confidence: round3(nc), feedback_net: nfb };
|
|
1326
|
+
const patch = { weight: after.weight, confidence: after.confidence, feedback_net: after.feedback_net, updated: today() };
|
|
1327
|
+
rewriteFrontmatter(target.fp, patch, target.root, owner);
|
|
1328
|
+
upsertIndexEntry(target.root, readEntry(target.fp, target.root));
|
|
1329
|
+
appendAudit(target.root, 'feedback', { ts: new Date().toISOString(), file: target.rel, action: useful ? 'useful' : 'useless', before: before, after: after, reason: opts.reason || '' });
|
|
1330
|
+
const lines = [(useful ? '已反馈 [有用]' : '已反馈 [没用]') + ': ' + target.rel];
|
|
1331
|
+
lines.push(' weight ' + before.weight + ' -> ' + after.weight + ' / confidence ' + before.confidence + ' -> ' + after.confidence + ' / feedback_net ' + before.feedback_net + ' -> ' + after.feedback_net);
|
|
1332
|
+
if (opts.reason) lines.push(' 原因: ' + opts.reason);
|
|
1333
|
+
lines.push(' 提示: yotta-memory explain ' + target.rel + ' 查看效用分项;feedback --undo 回滚最近一次');
|
|
1334
|
+
return { error: false, text: lines.join('\n') };
|
|
1335
|
+
}
|
|
1336
|
+
function feedbackUndoCore(target, owner, meta, selfAgent, opts) {
|
|
1337
|
+
const cfg = loadConfig();
|
|
1338
|
+
const lines = [];
|
|
1339
|
+
let restored = false;
|
|
1340
|
+
try {
|
|
1341
|
+
const log = auditPath(target.root, 'feedback');
|
|
1342
|
+
if (!fs.existsSync(log)) return { error: true, text: '无可回滚的反馈记录(' + log + ' 不存在)。' };
|
|
1343
|
+
const recs = fs.readFileSync(log, 'utf8').split('\n').filter(Boolean).map(function (l) { try { return JSON.parse(l); } catch (e) { return null; } }).filter(Boolean);
|
|
1344
|
+
const mine = recs.filter(function (r) { return r.file === target.rel && (r.action === 'useful' || r.action === 'useless'); });
|
|
1345
|
+
if (!mine.length) return { error: true, text: '该文件无反馈记录可回滚。' };
|
|
1346
|
+
const last = mine[mine.length - 1];
|
|
1347
|
+
const patch = { weight: last.before.weight, confidence: last.before.confidence, feedback_net: last.before.feedback_net, updated: today() };
|
|
1348
|
+
rewriteFrontmatter(target.fp, patch, target.root, owner);
|
|
1349
|
+
upsertIndexEntry(target.root, readEntry(target.fp, target.root));
|
|
1350
|
+
appendAudit(target.root, 'feedback', { ts: new Date().toISOString(), file: target.rel, action: 'undo', before: last.after, after: last.before, reason: opts.reason || '' });
|
|
1351
|
+
lines.push('已回滚最近一次反馈: ' + target.rel);
|
|
1352
|
+
lines.push(' ' + last.action + ' -> 恢复 weight ' + last.after.weight + ' -> ' + last.before.weight + ' / feedback_net ' + last.after.feedback_net + ' -> ' + last.before.feedback_net);
|
|
1353
|
+
restored = true;
|
|
1354
|
+
} catch (e) { return { error: true, text: '回滚失败: ' + e.message }; }
|
|
1355
|
+
return { error: false, text: lines.join('\n') };
|
|
1356
|
+
}
|
|
1357
|
+
// 解释单条记忆效用分项(为什么靠前/归档/遗忘)
|
|
1358
|
+
function explainCore(ref, opts) {
|
|
1359
|
+
opts = opts || {};
|
|
1360
|
+
const selfAgent = opts.selfAgent || currentAgent();
|
|
1361
|
+
const target = resolveMemoryTarget(ref);
|
|
1362
|
+
if (!target) return { error: true, text: '未找到记忆文件: ' + ref };
|
|
1363
|
+
const owner = ownerFromPrivatePath(target.root, target.fp);
|
|
1364
|
+
let entry;
|
|
1365
|
+
try { entry = readEntry(target.fp, target.root); } catch (e) { return { error: true, text: '读取失败: ' + e.message }; }
|
|
1366
|
+
const deny = checkOwnerWritable(target.root, target.rel, selfAgent, opts.unsafe);
|
|
1367
|
+
const readable = !deny;
|
|
1368
|
+
const ub = utilityBreakdown(entry);
|
|
1369
|
+
const ageDays = entry.created ? daysBetween(entry.created, today()) : 0;
|
|
1370
|
+
const cfg = loadConfig();
|
|
1371
|
+
const archU = parseFloat(cfg.maintain_archived_utility || 0.35);
|
|
1372
|
+
const archAge = parseInt(cfg.maintain_archived_age || '180', 10) || 180;
|
|
1373
|
+
const forgetU = parseFloat(cfg.maintain_forget_utility || 0.12);
|
|
1374
|
+
const forgetAge = parseInt(cfg.maintain_forget_age || '365', 10) || 365;
|
|
1375
|
+
const lines = ['[' + entry.type + '] ' + entry.subject + ': ' + entry.statement];
|
|
1376
|
+
lines.push(' 文件: ' + target.rel + (readable ? '' : '(其它智能体私密,仅元数据)'));
|
|
1377
|
+
lines.push(' 效用分: ' + ub.total + ' = confidence ' + ub.confidence + ' + 使用 ' + ub.usage + ' + 时效 ' + ub.recency + ' + 类型 ' + ub.type + ' + 结构 ' + ub.structure + ')×weight ' + ub.weight);
|
|
1378
|
+
lines.push(' 年龄: ' + ageDays + ' 天 / access_count ' + (entry.access_count || 0) + ' / feedback_net ' + entry.feedback_net + ' / immutable ' + (entry.immutable ? '是' : '否'));
|
|
1379
|
+
const importScore = round3(importanceScore(entry));
|
|
1380
|
+
lines.push(' importance(旧): ' + importScore + ' / vitality(旧): ' + round3(vitality(entry)));
|
|
1381
|
+
if (entry.immutable) lines.push(' 状态: immutable 豁免自动归档/遗忘');
|
|
1382
|
+
else if (ub.total < forgetU && ageDays > forgetAge && entry.type !== 'BOUND') lines.push(' 状态: 遗忘候选(utility < ' + forgetU + ' 且年龄 > ' + forgetAge + ' 天;--purge 才真删)');
|
|
1383
|
+
else if (ub.total < archU && ageDays > archAge) lines.push(' 状态: 归档候选(utility < ' + archU + ' 且年龄 > ' + archAge + ' 天)');
|
|
1384
|
+
else lines.push(' 状态: 保留(未达归档/遗忘阈值)');
|
|
1385
|
+
return { error: false, text: lines.join('\n') };
|
|
1386
|
+
}
|
|
1387
|
+
function jaccardTokens(a, b) {
|
|
1388
|
+
const sa = new Set(Object.keys(a.tokens || {}).filter(function (k) { return k.indexOf(':') === -1; }));
|
|
1389
|
+
const sb = new Set(Object.keys(b.tokens || {}).filter(function (k) { return k.indexOf(':') === -1; }));
|
|
1390
|
+
if (!sa.size || !sb.size) return 0;
|
|
1391
|
+
let inter = 0;
|
|
1392
|
+
for (const k of sa) { if (sb.has(k)) inter++; }
|
|
1393
|
+
const union = sa.size + sb.size - inter;
|
|
1394
|
+
return union ? inter / union : 0;
|
|
1395
|
+
}
|
|
1396
|
+
function subjectsSimilar(a, b) {
|
|
1397
|
+
const sa = String(a.subject || '').toLowerCase();
|
|
1398
|
+
const sb = String(b.subject || '').toLowerCase();
|
|
1399
|
+
if (!sa || !sb) return false;
|
|
1400
|
+
if (sa === sb) return true;
|
|
1401
|
+
return editDistance(sa, sb) <= 3;
|
|
1402
|
+
}
|
|
1403
|
+
// 记忆自组织:规则层自动归档/遗忘/去重(默认 dry-run,--apply 才执行,--purge 才真删)
|
|
1404
|
+
function maintainCore(opts) {
|
|
1405
|
+
opts = opts || {};
|
|
1406
|
+
const apply = !!opts.apply;
|
|
1407
|
+
const purge = !!opts.purge;
|
|
1408
|
+
const root = userRoot();
|
|
1409
|
+
if (!fs.existsSync(root)) return { error: false, text: '记忆库不存在。' };
|
|
1410
|
+
const cfg = loadConfig();
|
|
1411
|
+
const archU = parseFloat(opts.threshold !== undefined && opts.threshold !== null ? opts.threshold : (cfg.maintain_archived_utility || 0.35));
|
|
1412
|
+
const archAge = parseInt(opts.age || cfg.maintain_archived_age || '180', 10) || 180;
|
|
1413
|
+
const forgetU = parseFloat(cfg.maintain_forget_utility || 0.12);
|
|
1414
|
+
const forgetAge = parseInt(cfg.maintain_forget_age || '365', 10) || 365;
|
|
1415
|
+
const mode = apply ? (purge ? '执行(含遗忘真删 --purge)' : '执行') : '预览(dry-run,未改动;加 --apply 执行,--purge 才真删)';
|
|
1416
|
+
const lines = ['## yotta-memory maintain(记忆自组织)'];
|
|
1417
|
+
lines.push('- 模式: ' + mode);
|
|
1418
|
+
lines.push('- 归档阈值: utility < ' + archU + ' 且年龄 > ' + archAge + ' 天;遗忘阈值: utility < ' + forgetU + ' 且年龄 > ' + forgetAge + ' 天');
|
|
1419
|
+
lines.push('');
|
|
1420
|
+
const toArchive = [], toForget = [], skipped = [];
|
|
1421
|
+
for (const fp of collectEntryFiles(root)) {
|
|
1422
|
+
const owner = ownerFromPrivatePath(root, fp);
|
|
1423
|
+
let meta;
|
|
1424
|
+
try { meta = parseFrontmatter(readMemoryText(root, fp, owner)).meta; } catch (e) { continue; }
|
|
1425
|
+
if (meta.immutable === 'true') { skipped.push({ fp: fp, reason: 'immutable 豁免' }); continue; }
|
|
1426
|
+
if (!meta.created) { skipped.push({ fp: fp, reason: '无 created' }); continue; }
|
|
1427
|
+
const type = (meta.type || 'FACT').toUpperCase();
|
|
1428
|
+
const ageDays = daysBetween(meta.created, today());
|
|
1429
|
+
const u = utilityScore(meta);
|
|
1430
|
+
const rec = { fp: fp, rel: relOf(root, fp), type: type, ageDays: ageDays, utility: u, meta: meta, owner: owner };
|
|
1431
|
+
if (u < forgetU && ageDays > forgetAge) {
|
|
1432
|
+
if (type === 'BOUND') { skipped.push({ fp: fp, reason: 'BOUND 豁免遗忘' }); }
|
|
1433
|
+
else { toForget.push(rec); continue; }
|
|
1434
|
+
}
|
|
1435
|
+
if (u < archU && ageDays > archAge) toArchive.push(rec);
|
|
1436
|
+
}
|
|
1437
|
+
let archived = 0, forgotten = 0;
|
|
1438
|
+
if (toArchive.length) {
|
|
1439
|
+
lines.push('### 归档候选(' + toArchive.length + ' 条)');
|
|
1440
|
+
for (const rec of toArchive) {
|
|
1441
|
+
lines.push('- [' + rec.type + '] ' + rec.rel + '(utility ' + round3(rec.utility) + ',' + rec.ageDays + ' 天)— ' + (rec.meta.subject || '') + ': ' + String(rec.meta.statement || '').slice(0, 40) + (rec.type === 'COMMIT' ? ' ⚠️ COMMIT 收工纪律锚点,请确认' : ''));
|
|
1442
|
+
if (apply) {
|
|
1443
|
+
const destDir = path.join(root, ARCHIVE_DIR, TYPE_DIRS[rec.type]);
|
|
1444
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
1445
|
+
fs.renameSync(rec.fp, path.join(destDir, path.basename(rec.fp)));
|
|
1446
|
+
appendAudit(root, 'audit', { ts: new Date().toISOString(), file: rec.rel, action: 'archive', reason: 'utility<' + round3(rec.utility) + ',age>' + rec.ageDays, utility: round3(rec.utility) });
|
|
1447
|
+
archived++;
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
lines.push('');
|
|
1451
|
+
}
|
|
1452
|
+
if (toForget.length) {
|
|
1453
|
+
lines.push('### 遗忘候选(' + toForget.length + ' 条,默认不真删;--purge 才删除)');
|
|
1454
|
+
for (const rec of toForget) {
|
|
1455
|
+
lines.push('- [' + rec.type + '] ' + rec.rel + '(utility ' + round3(rec.utility) + ',' + rec.ageDays + ' 天)');
|
|
1456
|
+
if (apply && purge) {
|
|
1457
|
+
const destDir = path.join(root, ARCHIVE_DIR, TYPE_DIRS[rec.type]);
|
|
1458
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
1459
|
+
const dest = path.join(destDir, path.basename(rec.fp));
|
|
1460
|
+
fs.renameSync(rec.fp, dest);
|
|
1461
|
+
appendAudit(root, 'audit', { ts: new Date().toISOString(), file: rec.rel, action: 'forget', reason: 'utility<' + round3(rec.utility) + ',age>' + rec.ageDays + ',purge', utility: round3(rec.utility) });
|
|
1462
|
+
// 真删前二次确认标记:purge 已显式授权,删除 .archive 内副本前的最终动作 = 记录审计后删除
|
|
1463
|
+
try { fs.unlinkSync(dest); } catch (e) { /* 保留副本 */ }
|
|
1464
|
+
forgotten++;
|
|
1465
|
+
} else if (apply) {
|
|
1466
|
+
lines.push(' (--purge 未开启,跳过真删)');
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
lines.push('');
|
|
1470
|
+
}
|
|
1471
|
+
if (apply) {
|
|
1472
|
+
if (archived || forgotten) {
|
|
1473
|
+
const allMoved = toArchive.concat(toForget).filter(function (r) { return true; });
|
|
1474
|
+
removeIndexEntries(root, allMoved.map(function (r) { return r.rel; }));
|
|
1475
|
+
}
|
|
1476
|
+
lines.push('- 已执行: 归档 ' + archived + ' 条,遗忘 ' + forgotten + ' 条。审计: ' + auditPath(root, 'audit'));
|
|
1477
|
+
} else {
|
|
1478
|
+
lines.push('- 未执行任何变更(dry-run)。加 --apply 执行归档,--purge 才真删遗忘候选。');
|
|
1479
|
+
}
|
|
1480
|
+
if (skipped.length) {
|
|
1481
|
+
lines.push('');
|
|
1482
|
+
lines.push('### 跳过(' + skipped.length + ' 条)');
|
|
1483
|
+
const grouped = {};
|
|
1484
|
+
for (const s of skipped) { grouped[s.reason] = (grouped[s.reason] || 0) + 1; }
|
|
1485
|
+
for (const k of Object.keys(grouped)) lines.push('- ' + k + ': ' + grouped[k] + ' 条');
|
|
1486
|
+
}
|
|
1487
|
+
// 去重
|
|
1488
|
+
if (opts.dedup) {
|
|
1489
|
+
lines.push('');
|
|
1490
|
+
lines.push('### 去重候选(--dedup)');
|
|
1491
|
+
const entries = ensureIndex(root);
|
|
1492
|
+
const groups = [];
|
|
1493
|
+
const used = new Set();
|
|
1494
|
+
for (let i = 0; i < entries.length; i++) {
|
|
1495
|
+
if (used.has(i)) continue;
|
|
1496
|
+
const g = [entries[i]];
|
|
1497
|
+
used.add(i);
|
|
1498
|
+
for (let j = i + 1; j < entries.length; j++) {
|
|
1499
|
+
if (used.has(j)) continue;
|
|
1500
|
+
if (subjectsSimilar(entries[i], entries[j]) && jaccardTokens(entries[i], entries[j]) >= 0.7) {
|
|
1501
|
+
g.push(entries[j]); used.add(j);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
if (g.length > 1) groups.push(g);
|
|
1505
|
+
}
|
|
1506
|
+
if (!groups.length) lines.push('(无重复候选)');
|
|
1507
|
+
for (const g of groups) {
|
|
1508
|
+
lines.push('- 组: ' + g.map(function (e) { return e.file; }).join(' | '));
|
|
1509
|
+
lines.push(' ' + g.map(function (e) { return e.subject + ': ' + e.statement; }).join(' / '));
|
|
1510
|
+
}
|
|
1511
|
+
lines.push(' 合并: yotta-memory maintain --merge <fileA> <fileB>');
|
|
1512
|
+
}
|
|
1513
|
+
return { error: false, text: lines.join('\n') };
|
|
1514
|
+
}
|
|
1515
|
+
// 手动合并两条相似记忆:保留高 confidence,低 confidence 移入 .archive
|
|
1516
|
+
function mergeCore(refA, refB, opts) {
|
|
1517
|
+
opts = opts || {};
|
|
1518
|
+
const selfAgent = opts.selfAgent || currentAgent();
|
|
1519
|
+
const ta = resolveMemoryTarget(refA);
|
|
1520
|
+
const tb = resolveMemoryTarget(refB);
|
|
1521
|
+
if (!ta || !tb) return { error: true, text: '未找到记忆文件: ' + refA + ' / ' + refB };
|
|
1522
|
+
for (const t of [ta, tb]) {
|
|
1523
|
+
const deny = checkOwnerWritable(t.root, t.rel, selfAgent, opts.unsafe);
|
|
1524
|
+
if (deny) return { error: true, text: deny };
|
|
1525
|
+
}
|
|
1526
|
+
if (ta.root !== tb.root) return { error: true, text: '暂不支持跨记忆库合并(' + ta.root + ' vs ' + tb.root + ')。' };
|
|
1527
|
+
const root = ta.root;
|
|
1528
|
+
const ea = readEntry(ta.fp, root);
|
|
1529
|
+
const eb = readEntry(tb.fp, root);
|
|
1530
|
+
const keep = ea.confidence >= eb.confidence ? ea : eb;
|
|
1531
|
+
const drop = ea.confidence >= eb.confidence ? eb : ea;
|
|
1532
|
+
const keepFp = ea.confidence >= eb.confidence ? ta.fp : tb.fp;
|
|
1533
|
+
const dropFp = ea.confidence >= eb.confidence ? tb.fp : ta.fp;
|
|
1534
|
+
const owner = ownerFromPrivatePath(root, keepFp);
|
|
1535
|
+
const tags = Array.from(new Set((keep.tags || []).concat(drop.tags || [])));
|
|
1536
|
+
const patch = { updated: today(), tags: JSON.stringify(tags), access_count: (keep.access_count || 0) + (drop.access_count || 0), feedback_net: (keep.feedback_net || 0) + (drop.feedback_net || 0) };
|
|
1537
|
+
rewriteFrontmatter(keepFp, patch, root, owner);
|
|
1538
|
+
upsertIndexEntry(root, readEntry(keepFp, root));
|
|
1539
|
+
const destDir = path.join(root, ARCHIVE_DIR, TYPE_DIRS[drop.type]);
|
|
1540
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
1541
|
+
const dest = path.join(destDir, path.basename(dropFp));
|
|
1542
|
+
fs.renameSync(dropFp, dest);
|
|
1543
|
+
removeIndexEntry(root, drop.file);
|
|
1544
|
+
appendAudit(root, 'audit', { ts: new Date().toISOString(), file: drop.file, action: 'merge', reason: '并入 ' + keep.file, utility: round3(utilityScore(keep)) });
|
|
1545
|
+
return { error: false, text: '已合并: ' + drop.file + ' -> ' + keep.file + '\n 保留: [' + keep.type + '] ' + keep.subject + ': ' + keep.statement + '\n 归档低 confidence: ' + drop.file };
|
|
1546
|
+
}
|
|
1547
|
+
// 心理日志蒸馏:统计摘要 / 主题画像 / 知识地图(启发式默认,可选 --model)
|
|
1548
|
+
function distillCore(opts) {
|
|
1549
|
+
opts = opts || {};
|
|
1550
|
+
const root = userRoot();
|
|
1551
|
+
if (!fs.existsSync(root)) return { error: false, text: '记忆库不存在。' };
|
|
1552
|
+
const selfAgent = opts.selfAgent || currentAgent();
|
|
1553
|
+
const owner = opts.owner || selfAgent;
|
|
1554
|
+
const entries = [];
|
|
1555
|
+
for (const r of memoryRoots()) {
|
|
1556
|
+
for (const e of ensureIndex(r)) {
|
|
1557
|
+
if (classifyRead(e, owner, '', !!opts.unsafe, selfAgent) === 'denied') continue;
|
|
1558
|
+
entries.push(e);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
const lines = [];
|
|
1562
|
+
lines.push('# 元忆记忆蒸馏报告(v0.8.0)');
|
|
1563
|
+
lines.push('');
|
|
1564
|
+
lines.push('> 生成: yotta-memory distill | 启发式蒸馏(零依赖);--model <cmd> 可选外部模型增强');
|
|
1565
|
+
lines.push('> 时间: ' + new Date().toISOString() + ' / 可读条目: ' + entries.length + ' 条');
|
|
1566
|
+
lines.push('');
|
|
1567
|
+
// 1) 统计摘要
|
|
1568
|
+
lines.push('## 一、统计摘要');
|
|
1569
|
+
lines.push('');
|
|
1570
|
+
if (!entries.length) lines.push('(无记忆)');
|
|
1571
|
+
const byType = {};
|
|
1572
|
+
for (const e of entries) byType[e.type] = (byType[e.type] || 0) + 1;
|
|
1573
|
+
lines.push('- 类型分布: ' + Object.keys(byType).map(function (k) { return k + ' ' + byType[k]; }).join(' / '));
|
|
1574
|
+
let young = 0, mid = 0, old = 0, veryOld = 0;
|
|
1575
|
+
for (const e of entries) {
|
|
1576
|
+
if (!e.created) continue;
|
|
1577
|
+
const d = daysBetween(e.created, today());
|
|
1578
|
+
if (d < 30) young++; else if (d < 90) mid++; else if (d < 180) old++; else veryOld++;
|
|
1579
|
+
}
|
|
1580
|
+
lines.push('- 年龄分布: <30天 ' + young + ' / 30-90 ' + mid + ' / 90-180 ' + old + ' / >180天 ' + veryOld);
|
|
1581
|
+
const hot = entries.slice().sort(function (a, b) { return (b.access_count || 0) - (a.access_count || 0); }).slice(0, 5);
|
|
1582
|
+
lines.push('- 热度 Top5(access_count):');
|
|
1583
|
+
for (const h of hot) lines.push(' - [' + h.type + '] ' + h.subject + '(' + (h.access_count || 0) + ' 次)');
|
|
1584
|
+
let fbSum = 0, usefulN = 0, uselessN = 0;
|
|
1585
|
+
for (const e of entries) { const n = e.feedback_net || 0; fbSum += n; if (n > 0) usefulN++; else if (n < 0) uselessN++; }
|
|
1586
|
+
lines.push('- 反馈统计: feedback_net 总和 ' + fbSum + '(正 ' + usefulN + ' 条 / 负 ' + uselessN + ' 条)');
|
|
1587
|
+
lines.push('');
|
|
1588
|
+
// 2) 主题画像(按 subject 聚类合并)
|
|
1589
|
+
lines.push('## 二、主题画像(按 subject 聚类,启发式合并)');
|
|
1590
|
+
lines.push('');
|
|
1591
|
+
const groups = {};
|
|
1592
|
+
for (const e of entries) {
|
|
1593
|
+
const key = String(e.subject || '(无主题)');
|
|
1594
|
+
(groups[key] = groups[key] || []).push(e);
|
|
1595
|
+
}
|
|
1596
|
+
const sortedKeys = Object.keys(groups).sort(function (a, b) { return groups[b].length - groups[a].length; });
|
|
1597
|
+
for (const k of sortedKeys) {
|
|
1598
|
+
const g = groups[k];
|
|
1599
|
+
const best = g.slice().sort(function (a, b) { return (b.confidence || 0) - (a.confidence || 0); })[0];
|
|
1600
|
+
const tags = Array.from(new Set([].concat.apply([], g.map(function (e) { return e.tags || []; }))));
|
|
1601
|
+
lines.push('### ' + k + '(' + g.length + ' 条)');
|
|
1602
|
+
lines.push('- 代表: ' + best.statement);
|
|
1603
|
+
if (tags.length) lines.push('- 标签: ' + tags.join(', '));
|
|
1604
|
+
lines.push('- 引用: ' + g.map(function (e) { return e.file; }).join(' / '));
|
|
1605
|
+
lines.push('');
|
|
1606
|
+
}
|
|
1607
|
+
// 3) 知识地图(type + tags 层级)
|
|
1608
|
+
lines.push('## 三、知识地图(type → tags)');
|
|
1609
|
+
lines.push('');
|
|
1610
|
+
const map = {};
|
|
1611
|
+
for (const e of entries) {
|
|
1612
|
+
const t = e.type;
|
|
1613
|
+
(map[t] = map[t] || {});
|
|
1614
|
+
const tagKey = (e.tags && e.tags.length) ? e.tags.join(',') : '(无标签)';
|
|
1615
|
+
(map[t][tagKey] = map[t][tagKey] || []).push(e);
|
|
1616
|
+
}
|
|
1617
|
+
for (const t of Object.keys(map).sort()) {
|
|
1618
|
+
lines.push('### ' + t);
|
|
1619
|
+
for (const tk of Object.keys(map[t]).sort()) {
|
|
1620
|
+
lines.push('- ' + tk + ': ' + map[t][tk].map(function (e) { return e.subject; }).join(' / '));
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
let body = lines.join('\n') + '\n';
|
|
1624
|
+
// 可选模型增强:--model <cmd>,stdin 传结构化摘要,stdout 取提炼文本
|
|
1625
|
+
let modelOut = '';
|
|
1626
|
+
if (opts.model) {
|
|
1627
|
+
try {
|
|
1628
|
+
const payload = JSON.stringify({ summary: lines.join('\n'), entries: entries.map(function (e) { return { type: e.type, subject: e.subject, statement: e.statement, tags: e.tags, confidence: e.confidence, access_count: e.access_count, feedback_net: e.feedback_net }; }).slice(0, 200) });
|
|
1629
|
+
const r = child_process.spawnSync(String(opts.model), { input: payload, encoding: 'utf8', maxBuffer: 1024 * 1024, shell: process.platform === 'win32' });
|
|
1630
|
+
if (r.stdout) modelOut = String(r.stdout).trim();
|
|
1631
|
+
if (!modelOut) modelOut = '(模型无输出)';
|
|
1632
|
+
body += '\n## 四、模型提炼(--model ' + opts.model + ')\n\n' + modelOut + '\n';
|
|
1633
|
+
} catch (e) {
|
|
1634
|
+
modelOut = '(模型调用失败: ' + e.message + ')';
|
|
1635
|
+
body += '\n## 四、模型提炼\n\n' + modelOut + '\n';
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
// 落盘:--out 指定 / 私密 distills / 公共 facts/distills
|
|
1639
|
+
let written = '';
|
|
1640
|
+
try {
|
|
1641
|
+
if (opts.out) {
|
|
1642
|
+
const outPath = path.resolve(String(opts.out));
|
|
1643
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
1644
|
+
fs.writeFileSync(outPath, body, 'utf8');
|
|
1645
|
+
written = outPath;
|
|
1646
|
+
} else {
|
|
1647
|
+
const slug = String((opts.subject || 'distill').replace(/[^\w\u4e00-\u9fa5-]+/g, '-').slice(0, 40));
|
|
1648
|
+
const fname = today() + '-' + slug + '.md';
|
|
1649
|
+
if (owner && entries.some(function (e) { return e.scope !== 'public' && e.owner === owner; })) {
|
|
1650
|
+
const dir = path.join(root, PRIVATE_DIR, owner, 'distills');
|
|
1651
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1652
|
+
const fp = path.join(dir, fname);
|
|
1653
|
+
const text = frontmatterToText({ type: 'COMMIT', subject: '蒸馏报告 ' + slug, statement: '记忆蒸馏报告(' + today() + ')', confidence: 1.0, created: today(), updated: today(), tags: ['distill'], immutable: false, scope: 'private', owner: owner, source: 'distill', weight: 1.0, access_count: 0, last_accessed: '', feedback_net: 0 }, body);
|
|
1654
|
+
writeMemoryText(root, fp, text, owner);
|
|
1655
|
+
upsertIndexEntry(root, readEntry(fp, root));
|
|
1656
|
+
written = fp;
|
|
1657
|
+
} else {
|
|
1658
|
+
const dir = path.join(root, PUBLIC_DIR, 'distills');
|
|
1659
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1660
|
+
const fp = path.join(dir, fname);
|
|
1661
|
+
const text = frontmatterToText({ type: 'FACT', subject: '蒸馏报告 ' + slug, statement: '记忆蒸馏报告(' + today() + ')', confidence: 1.0, created: today(), updated: today(), tags: ['distill'], immutable: false, scope: 'public', owner: '', source: 'distill', weight: 1.0, access_count: 0, last_accessed: '', feedback_net: 0 }, body);
|
|
1662
|
+
fs.writeFileSync(fp, text, 'utf8');
|
|
1663
|
+
upsertIndexEntry(root, readEntry(fp, root));
|
|
1664
|
+
written = fp;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
} catch (e) {
|
|
1668
|
+
return { error: false, text: lines.join('\n') + '\n\n[落盘失败] ' + e.message };
|
|
1669
|
+
}
|
|
1670
|
+
return { error: false, text: body + '\n[已写入] ' + written };
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
function promptPassword(promptText) {
|
|
1674
|
+
return new Promise(function (resolve) {
|
|
1675
|
+
const stdin = process.stdin;
|
|
1676
|
+
if (!stdin.isTTY) { resolve(null); return; }
|
|
1677
|
+
process.stdout.write(promptText);
|
|
1678
|
+
let buf = '';
|
|
1679
|
+
function done(abort) {
|
|
1680
|
+
try { stdin.setRawMode(false); } catch (e) {}
|
|
1681
|
+
stdin.pause();
|
|
1682
|
+
stdin.removeListener('data', onData);
|
|
1683
|
+
process.stdout.write('\n');
|
|
1684
|
+
resolve(abort ? null : buf);
|
|
1685
|
+
}
|
|
1686
|
+
function onData(chunk) {
|
|
1687
|
+
const s = String(chunk);
|
|
1688
|
+
for (const c of s) {
|
|
1689
|
+
if (c === '\r' || c === '\n') { done(false); return; }
|
|
1690
|
+
if (c === '\u0003') { done(true); return; }
|
|
1691
|
+
if (c === '\u007f' || c === '\b') buf = buf.slice(0, -1);
|
|
1692
|
+
else buf += c;
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
try { stdin.setRawMode(true); } catch (e) { resolve(null); return; }
|
|
1696
|
+
stdin.resume();
|
|
1697
|
+
stdin.setEncoding('utf8');
|
|
1698
|
+
stdin.on('data', onData);
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
function migrateCore(root, password, recoveryKeyIn) {
|
|
1703
|
+
if (!fs.existsSync(root)) return { error: true, text: '记忆库不存在,请先 yotta-memory init。' };
|
|
1704
|
+
if (isEncrypted(root)) return { error: true, text: '记忆库已启用加密(keys/ 存在),无需迁移。' };
|
|
1705
|
+
if (!hasPlaintextPrivate(root)) return { error: false, text: '无私密明文可迁移(private/ 下无 .md 文件);如需新建加密库请 init --encrypt。' };
|
|
1706
|
+
const salt = crypto.randomBytes(16);
|
|
1707
|
+
ensureKeysDir(root);
|
|
1708
|
+
fs.writeFileSync(encSaltPath(root), salt);
|
|
1709
|
+
const umk = deriveUmk(password, salt);
|
|
1710
|
+
const rk = crypto.randomBytes(32);
|
|
1711
|
+
fs.writeFileSync(encRecoveryEncPath(root), Buffer.concat([Buffer.from(KEY_MAGIC, 'utf8'), aesEncryptBytes(umk, rk, Buffer.from('recovery', 'utf8'))]));
|
|
1712
|
+
let moved = 0;
|
|
1713
|
+
const owners = collectOwners(root);
|
|
1714
|
+
const self = currentAgent();
|
|
1715
|
+
for (const owner of owners) {
|
|
1716
|
+
const ok = wrapOwnerKey(root, umk, rk, owner);
|
|
1717
|
+
if (self && owner === self) writeOwnerKeyCache(root, owner, ok);
|
|
1718
|
+
for (const t of PRIVATE_LEAF) {
|
|
1719
|
+
const d = path.join(root, PRIVATE_DIR, owner, t);
|
|
1720
|
+
if (!fs.existsSync(d)) continue;
|
|
1721
|
+
for (const f of fs.readdirSync(d)) {
|
|
1722
|
+
if (!f.endsWith('.md')) continue;
|
|
1723
|
+
const fp = path.join(d, f);
|
|
1724
|
+
const text = fs.readFileSync(fp, 'utf8');
|
|
1725
|
+
fs.writeFileSync(fp + ENC_SUFFIX, encryptMemoryText(text, ok));
|
|
1726
|
+
fs.unlinkSync(fp);
|
|
1727
|
+
moved++;
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
const pf = path.join(root, PRIVATE_DIR, owner, PROFILE_FILE);
|
|
1731
|
+
if (fs.existsSync(pf)) {
|
|
1732
|
+
const text = fs.readFileSync(pf, 'utf8');
|
|
1733
|
+
fs.writeFileSync(pf + ENC_SUFFIX, encryptMemoryText(text, ok));
|
|
1734
|
+
fs.unlinkSync(pf);
|
|
1735
|
+
moved++;
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
buildIndex(root);
|
|
1739
|
+
let tail = '已迁移 ' + moved + ' 个私密文件(' + owners.length + ' 个 owner)到密文。';
|
|
1740
|
+
tail += '\n[恢复钥匙](务必离线保存,仅此一次,泄露=可解全部私密): ' + rk.toString('base64');
|
|
1741
|
+
if (self && owners.indexOf(self) !== -1) tail += '\n已为本智能体 ' + self + ' 写入授权缓存,可继续读写自己的私密。';
|
|
1742
|
+
else tail += '\n各智能体需在用户平台授权(yotta-memory view → 授权本智能体)后才能读写自己的私密。';
|
|
1743
|
+
return { error: false, text: tail };
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
async function cmdMigrate(opts) {
|
|
1747
|
+
const root = userRoot();
|
|
1748
|
+
if (isEncrypted(root)) { console.error('记忆库已启用加密,无需迁移。'); process.exit(2); }
|
|
1749
|
+
if (!hasPlaintextPrivate(root)) { console.error('无私密明文可迁移。'); process.exit(2); }
|
|
1750
|
+
let password = opts.password || process.env.YOTTA_MEMORY_PASS || '';
|
|
1751
|
+
if (!password) {
|
|
1752
|
+
password = await promptPassword('主口令(迁移后私密区加密,勿忘;忘口令可用恢复钥匙重设): ') || '';
|
|
1753
|
+
if (!password) { console.error('已取消。'); process.exit(2); }
|
|
1754
|
+
}
|
|
1755
|
+
const r = migrateCore(root, password, null);
|
|
1756
|
+
console.log(r.text);
|
|
1757
|
+
if (r.error) process.exit(2);
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
function resetPasswordCore(root, opts) {
|
|
1761
|
+
opts = opts || {};
|
|
1762
|
+
if (!isEncrypted(root)) return { error: true, text: '记忆库未启用加密。' };
|
|
1763
|
+
const salt = loadSalt(root);
|
|
1764
|
+
if (!salt) return { error: true, text: '密钥库损坏(缺 keys/salt)。' };
|
|
1765
|
+
const owners = keyOwners(root).filter(function (o) { return fs.existsSync(encOwnerKeyPath(root, o)); });
|
|
1766
|
+
if (!owners.length) return { error: true, text: '没有可重设的 owner 密钥(keys/ 下无 *.key.enc)。' };
|
|
1767
|
+
const newPass = String(opts.newPassword || '');
|
|
1768
|
+
if (!newPass) return { error: true, text: '请提供新口令(--new-password 或交互输入)。' };
|
|
1769
|
+
if (opts.recoveryKey) {
|
|
1770
|
+
const rk = Buffer.from(String(opts.recoveryKey).trim(), 'base64');
|
|
1771
|
+
if (rk.length !== 32) return { error: true, text: '恢复钥匙格式错误(须为 32 字节 base64)。' };
|
|
1772
|
+
let verified = false;
|
|
1773
|
+
for (const o of owners) { try { unwrapOwnerKeyRecovery(root, o, rk); verified = true; break; } catch (e) {} }
|
|
1774
|
+
if (!verified) return { error: true, text: '恢复钥匙无效(无法解开任何 owner 密钥)。' };
|
|
1775
|
+
const newUmk = deriveUmk(newPass, salt);
|
|
1776
|
+
for (const o of owners) {
|
|
1777
|
+
const ok = unwrapOwnerKeyRecovery(root, o, rk);
|
|
1778
|
+
const aad = Buffer.from('owner:' + o, 'utf8');
|
|
1779
|
+
fs.writeFileSync(encOwnerKeyPath(root, o), Buffer.concat([Buffer.from(KEY_MAGIC, 'utf8'), aesEncryptBytes(newUmk, ok, aad)]));
|
|
1780
|
+
}
|
|
1781
|
+
fs.writeFileSync(encRecoveryEncPath(root), Buffer.concat([Buffer.from(KEY_MAGIC, 'utf8'), aesEncryptBytes(newUmk, rk, Buffer.from('recovery', 'utf8'))]));
|
|
1782
|
+
return { error: false, text: '口令已重设(恢复钥匙验证通过,已重新包裹全部 owner 密钥)。' };
|
|
1783
|
+
}
|
|
1784
|
+
const curPass = String(opts.password || '');
|
|
1785
|
+
if (!curPass) return { error: true, text: '请提供当前口令(--password 或交互输入),或用 --recovery-key。' };
|
|
1786
|
+
const curUmk = deriveUmk(curPass, salt);
|
|
1787
|
+
let verified = false;
|
|
1788
|
+
for (const o of owners) { try { unwrapOwnerKey(root, o, curUmk); verified = true; break; } catch (e) {} }
|
|
1789
|
+
if (!verified) return { error: true, text: '当前口令错误。' };
|
|
1790
|
+
const newUmk = deriveUmk(newPass, salt);
|
|
1791
|
+
for (const o of owners) {
|
|
1792
|
+
const ok = unwrapOwnerKey(root, o, curUmk);
|
|
1793
|
+
const aad = Buffer.from('owner:' + o, 'utf8');
|
|
1794
|
+
fs.writeFileSync(encOwnerKeyPath(root, o), Buffer.concat([Buffer.from(KEY_MAGIC, 'utf8'), aesEncryptBytes(newUmk, ok, aad)]));
|
|
1795
|
+
}
|
|
1796
|
+
fs.writeFileSync(encRecoveryEncPath(root), Buffer.concat([Buffer.from(KEY_MAGIC, 'utf8'), aesEncryptBytes(newUmk, unwrapRecoveryEnc(root, curUmk), Buffer.from('recovery', 'utf8'))]));
|
|
1797
|
+
return { error: false, text: '口令已重设(当前口令验证通过)。' };
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
async function cmdResetPassword(opts) {
|
|
1801
|
+
const root = userRoot();
|
|
1802
|
+
if (!isEncrypted(root)) { console.error('记忆库未启用加密。'); process.exit(2); }
|
|
1803
|
+
let cur = opts.password || '';
|
|
1804
|
+
let rk = opts.recoveryKey || '';
|
|
1805
|
+
let np = opts.newPassword || process.env.YOTTA_MEMORY_PASS || '';
|
|
1806
|
+
if (!rk && !cur) {
|
|
1807
|
+
cur = await promptPassword('当前口令(或 --recovery-key 用恢复钥匙): ') || '';
|
|
1808
|
+
if (!cur) { console.error('已取消。'); process.exit(2); }
|
|
1809
|
+
}
|
|
1810
|
+
if (!np) {
|
|
1811
|
+
np = await promptPassword('新口令: ') || '';
|
|
1812
|
+
if (!np) { console.error('已取消。'); process.exit(2); }
|
|
1813
|
+
const np2 = await promptPassword('再次输入确认: ') || '';
|
|
1814
|
+
if (np2 !== np) { console.error('两次输入不一致。'); process.exit(2); }
|
|
1815
|
+
}
|
|
1816
|
+
const r = resetPasswordCore(root, { password: cur, recoveryKey: rk, newPassword: np });
|
|
1817
|
+
console.log(r.text);
|
|
1818
|
+
if (r.error) process.exit(2);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
function keyAuthorizeCore(root, owner, password) {
|
|
1822
|
+
if (!isEncrypted(root)) return { error: true, text: '记忆库未启用加密。' };
|
|
1823
|
+
const salt = loadSalt(root);
|
|
1824
|
+
if (!salt) return { error: true, text: '密钥库损坏(缺 keys/salt)。' };
|
|
1825
|
+
const umk = deriveUmk(password, salt);
|
|
1826
|
+
let ok = null;
|
|
1827
|
+
try { ok = unwrapOwnerKey(root, owner, umk); } catch (e) { ok = null; }
|
|
1828
|
+
if (!ok) {
|
|
1829
|
+
let rk = null;
|
|
1830
|
+
try { rk = unwrapRecoveryEnc(root, umk); } catch (e) { rk = null; }
|
|
1831
|
+
if (!rk) return { error: true, text: '口令错误。' };
|
|
1832
|
+
ok = wrapOwnerKey(root, umk, rk, owner);
|
|
1833
|
+
}
|
|
1834
|
+
writeOwnerKeyCache(root, owner, ok);
|
|
1835
|
+
return { error: false, text: '已授权 ' + owner + ' 读取其私密(写入授权缓存 keys/cache/' + owner + '.key)。' };
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
function keyRevokeCore(root, owner) {
|
|
1839
|
+
if (!revokeOwnerKeyCache(root, owner)) return { error: false, text: owner + ' 无授权缓存(已是未授权状态)。' };
|
|
1840
|
+
return { error: false, text: '已吊销 ' + owner + ' 的授权缓存(该智能体将无法解密其私密)。' };
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
function cmdKeyList() {
|
|
1844
|
+
const root = userRoot();
|
|
1845
|
+
if (!isEncrypted(root)) { console.log('记忆库未启用加密。'); return; }
|
|
1846
|
+
const owners = collectOwners(root);
|
|
1847
|
+
const authorized = owners.filter(function (o) { return fs.existsSync(encCachePath(root, o)); });
|
|
1848
|
+
console.log('owner: ' + (owners.length ? owners.join(', ') : '(无)'));
|
|
1849
|
+
console.log('已授权缓存: ' + (authorized.length ? authorized.join(', ') : '(无)'));
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
async function cmdKeyAuthorize(id, opts) {
|
|
1853
|
+
const root = userRoot();
|
|
1854
|
+
if (!isEncrypted(root)) { console.error('记忆库未启用加密。'); process.exit(2); }
|
|
1855
|
+
if (!id) { console.error('请指定 <id>:yotta-memory key authorize <id>'); process.exit(2); }
|
|
1856
|
+
let password = opts.password || process.env.YOTTA_MEMORY_PASS || '';
|
|
1857
|
+
if (!password) {
|
|
1858
|
+
password = await promptPassword('主口令: ') || '';
|
|
1859
|
+
if (!password) { console.error('已取消。'); process.exit(2); }
|
|
1860
|
+
}
|
|
1861
|
+
const r = keyAuthorizeCore(root, id, password);
|
|
1862
|
+
console.log(r.text);
|
|
1863
|
+
if (r.error) process.exit(2);
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
function cmdKeyRevoke(id) {
|
|
1867
|
+
const root = userRoot();
|
|
1868
|
+
if (!id) { console.error('请指定 <id>:yotta-memory key revoke <id>'); process.exit(2); }
|
|
1869
|
+
const r = keyRevokeCore(root, id);
|
|
1870
|
+
console.log(r.text);
|
|
1871
|
+
if (r.error) process.exit(2);
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
// v0.8.1: 查看平台分页——服务端只返回当前页(offset/limit),避免记忆多了一次全量加载/渲染/传输
|
|
1875
|
+
function viewEntriesCore(root, session, query, offset, limit) {
|
|
1876
|
+
limit = Math.max(1, parseInt(limit, 10) || 50);
|
|
1877
|
+
offset = Math.max(0, parseInt(offset, 10) || 0);
|
|
1878
|
+
const q = String(query || '').toLowerCase();
|
|
1879
|
+
const entries = [];
|
|
1880
|
+
for (const e of (loadIndex(root) || [])) entries.push(e);
|
|
1881
|
+
for (const o of collectOwners(root)) {
|
|
1882
|
+
const key = session.ownerKeys[o];
|
|
1883
|
+
if (!key) continue;
|
|
1884
|
+
try { for (const e of loadOwnerIndex(root, o, key)) entries.push(e); } catch (err) {}
|
|
1885
|
+
}
|
|
1886
|
+
const all = q ? entries.filter(function (e) {
|
|
1887
|
+
return ((e.subject || '') + ' ' + (e.statement || '') + ' ' + (e.tags || []).join(' ')).toLowerCase().indexOf(q) !== -1;
|
|
1888
|
+
}) : entries;
|
|
1889
|
+
all.sort(function (a, b) { return String(b.updated || b.created || '').localeCompare(String(a.updated || a.created || '')); });
|
|
1890
|
+
const count = all.length;
|
|
1891
|
+
const page = all.slice(offset, offset + limit);
|
|
1892
|
+
return { count: count, offset: offset, limit: limit, hasMore: offset + limit < count, entries: page };
|
|
1893
|
+
}
|
|
1894
|
+
function viewServerCore(root, port, host) {
|
|
1895
|
+
let session = { umk: null, ownerKeys: {} };
|
|
1896
|
+
const server = http.createServer(function (req, res) {
|
|
1897
|
+
let pathname = '/';
|
|
1898
|
+
try { pathname = new URL(req.url, 'http://' + (req.headers.host || 'localhost')).pathname; } catch (e) {}
|
|
1899
|
+
function json(code, obj) {
|
|
1900
|
+
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1901
|
+
res.end(JSON.stringify(obj));
|
|
1902
|
+
}
|
|
1903
|
+
function readBody(cb) {
|
|
1904
|
+
let body = '';
|
|
1905
|
+
req.on('data', function (c) { body += c; });
|
|
1906
|
+
req.on('end', function () {
|
|
1907
|
+
let d = {};
|
|
1908
|
+
try { d = JSON.parse(body || '{}'); } catch (e) {}
|
|
1909
|
+
cb(d);
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1912
|
+
if (req.method === 'GET' && pathname === '/') {
|
|
1913
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1914
|
+
res.end(VIEW_HTML);
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
if (req.method === 'POST' && pathname === '/api/status') {
|
|
1918
|
+
return json(200, { encrypted: isEncrypted(root), unlocked: !!session.umk, version: VERSION });
|
|
1919
|
+
}
|
|
1920
|
+
if (req.method === 'POST' && pathname === '/api/unlock') {
|
|
1921
|
+
return readBody(function (d) {
|
|
1922
|
+
const password = String(d.password || '');
|
|
1923
|
+
const salt = loadSalt(root);
|
|
1924
|
+
if (!salt) return json(400, { error: '记忆库未启用加密。' });
|
|
1925
|
+
const umk = deriveUmk(password, salt);
|
|
1926
|
+
const ownerKeys = {};
|
|
1927
|
+
const owners = keyOwners(root);
|
|
1928
|
+
let verified = false;
|
|
1929
|
+
for (const o of owners) {
|
|
1930
|
+
try { ownerKeys[o] = unwrapOwnerKey(root, o, umk); verified = true; } catch (e) {}
|
|
1931
|
+
}
|
|
1932
|
+
if (!verified) return json(401, { error: '口令错误。' });
|
|
1933
|
+
session = { umk: umk, ownerKeys: ownerKeys };
|
|
1934
|
+
json(200, { ok: true, owners: Object.keys(ownerKeys) });
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
if (req.method === 'POST' && pathname === '/api/owners') {
|
|
1938
|
+
if (!session.umk) return json(401, { error: '请先解锁。' });
|
|
1939
|
+
const agents = loadAgents(root).agents || {};
|
|
1940
|
+
const owners = keyOwners(root);
|
|
1941
|
+
const all = Array.from(new Set(owners.concat(Object.keys(agents))));
|
|
1942
|
+
return json(200, { owners: all.map(function (o) { return { owner: o, registered: !!agents[o], authorized: !!session.ownerKeys[o] && fs.existsSync(encCachePath(root, o)) }; }) });
|
|
1943
|
+
}
|
|
1944
|
+
if (req.method === 'POST' && pathname === '/api/entries') {
|
|
1945
|
+
if (!session.umk) return json(401, { error: '请先解锁。' });
|
|
1946
|
+
return readBody(function (d) { json(200, viewEntriesCore(root, session, d.query, d.offset, d.limit)); });
|
|
1947
|
+
}
|
|
1948
|
+
if (req.method === 'POST' && pathname === '/api/export') {
|
|
1949
|
+
if (!session.umk) return json(401, { error: '请先解锁。' });
|
|
1950
|
+
const memories = [];
|
|
1951
|
+
for (const e of (loadIndex(root) || [])) memories.push({ file: e.file, meta: e.meta });
|
|
1952
|
+
for (const o of collectOwners(root)) {
|
|
1953
|
+
const key = session.ownerKeys[o];
|
|
1954
|
+
if (!key) continue;
|
|
1955
|
+
try { for (const e of loadOwnerIndex(root, o, key)) memories.push({ file: e.file, meta: e.meta }); } catch (err) {}
|
|
1956
|
+
}
|
|
1957
|
+
return json(200, { format: 'yottamemory', version: 2, exported: today(), memories: memories });
|
|
1958
|
+
}
|
|
1959
|
+
if (req.method === 'POST' && pathname === '/api/authorize') {
|
|
1960
|
+
if (!session.umk) return json(401, { error: '请先解锁。' });
|
|
1961
|
+
return readBody(function (d) {
|
|
1962
|
+
const owner = String(d.owner || '');
|
|
1963
|
+
if (!owner) return json(400, { error: '缺少 owner。' });
|
|
1964
|
+
let ok = session.ownerKeys[owner];
|
|
1965
|
+
let rk = null;
|
|
1966
|
+
try { rk = unwrapRecoveryEnc(root, session.umk); } catch (e) { rk = null; }
|
|
1967
|
+
if (!ok) {
|
|
1968
|
+
if (!rk) return json(400, { error: '密钥库缺少恢复钥匙(recovery.key.enc),无法为新 owner 建钥。' });
|
|
1969
|
+
ok = wrapOwnerKey(root, session.umk, rk, owner);
|
|
1970
|
+
session.ownerKeys[owner] = ok;
|
|
1971
|
+
}
|
|
1972
|
+
writeOwnerKeyCache(root, owner, ok);
|
|
1973
|
+
json(200, { ok: true });
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
if (req.method === 'POST' && pathname === '/api/revoke') {
|
|
1977
|
+
if (!session.umk) return json(401, { error: '请先解锁。' });
|
|
1978
|
+
return readBody(function (d) {
|
|
1979
|
+
revokeOwnerKeyCache(root, String(d.owner || ''));
|
|
1980
|
+
json(200, { ok: true });
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
if (req.method === 'POST' && pathname === '/api/reset-password') {
|
|
1984
|
+
if (!session.umk) return json(401, { error: '请先解锁。' });
|
|
1985
|
+
return readBody(function (d) {
|
|
1986
|
+
const r = resetPasswordCore(root, { password: String(d.currentPassword || ''), recoveryKey: String(d.recoveryKey || ''), newPassword: String(d.newPassword || '') });
|
|
1987
|
+
if (r.error) return json(400, { error: r.text });
|
|
1988
|
+
const salt = loadSalt(root);
|
|
1989
|
+
if (salt) session = { umk: deriveUmk(String(d.newPassword || ''), salt), ownerKeys: {} };
|
|
1990
|
+
json(200, { ok: true, text: r.text });
|
|
1991
|
+
});
|
|
1992
|
+
}
|
|
1993
|
+
if (req.method === 'POST' && pathname === '/api/recovery-key') {
|
|
1994
|
+
if (!session.umk) return json(401, { error: '请先解锁。' });
|
|
1995
|
+
const rk = unwrapRecoveryEnc(root, session.umk);
|
|
1996
|
+
if (!rk) return json(400, { error: '未找到恢复钥匙。' });
|
|
1997
|
+
return json(200, { recoveryKey: rk.toString('base64') });
|
|
1998
|
+
}
|
|
1999
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
2000
|
+
res.end('not found');
|
|
2001
|
+
});
|
|
2002
|
+
server.listen(port, host, function () {
|
|
2003
|
+
console.log('yotta-memory 用户查看平台已启动(v' + VERSION + ')');
|
|
2004
|
+
console.log('URL: http://' + host + ':' + port + '(默认仅本机 127.0.0.1;远程需 --host 显式开启)');
|
|
2005
|
+
console.log('记忆库: ' + root);
|
|
2006
|
+
console.log('输入主口令解锁后可浏览 / 搜索 / 导出全部 AI 的记忆(含私密)。');
|
|
2007
|
+
console.log('按 Ctrl+C 停止');
|
|
2008
|
+
});
|
|
2009
|
+
return server;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
function cmdView(opts) {
|
|
2013
|
+
const root = userRoot();
|
|
2014
|
+
ensureInit(root);
|
|
2015
|
+
if (!isEncrypted(root)) {
|
|
2016
|
+
console.error('记忆库未启用加密(无 keys/)。yotta-memory view 是加密库的用户查看平台;请先 init --encrypt 或 migrate。');
|
|
2017
|
+
process.exit(2);
|
|
2018
|
+
}
|
|
2019
|
+
const host = opts.host || '127.0.0.1';
|
|
2020
|
+
const port = opts.port || 8788;
|
|
2021
|
+
viewServerCore(root, port, host);
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
const VIEW_HTML = "<!doctype html><html lang=\"zh\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>元忆 · 用户查看平台</title><style>\nbody{font-family:system-ui,-apple-system,\"Microsoft YaHei\",sans-serif;max-width:1000px;margin:24px auto;padding:0 16px;color:#1f2328;background:#fafafa}\nh1{font-size:22px} .card{background:#fff;border:1px solid #e2e2e2;border-radius:10px;padding:16px 18px;margin:14px 0;box-shadow:0 1px 2px rgba(0,0,0,.04)}\nbutton{background:#2563eb;color:#fff;border:0;border-radius:6px;padding:7px 14px;cursor:pointer;margin:2px;font-size:14px}\nbutton.danger{background:#dc2626} button.ghost{background:#e5e7eb;color:#1f2328}\ninput,select{padding:8px;border:1px solid #c9c9c9;border-radius:6px;margin:2px;font-size:14px;box-sizing:border-box}\ntable{border-collapse:collapse;width:100%;font-size:13px} td,th{border:1px solid #ececec;padding:6px 8px;text-align:left;vertical-align:top}\n.owner{display:inline-flex;align-items:center;gap:6px;border:1px solid #ddd;border-radius:8px;padding:5px 10px;margin:4px 6px 4px 0;background:#f6f8fa}\n.entry{border-bottom:1px solid #eee;padding:8px 0} .meta{color:#8a8a8a;font-size:12px}\n.err{color:#dc2626;margin-top:8px} .ok{color:#16a34a;margin-top:8px}\n#app{display:none} code{background:#f0f0f0;padding:1px 5px;border-radius:4px;font-size:12px}\n</style></head><body>\n<h1>元忆 · 用户查看平台 <span id=\"ver\" style=\"font-size:14px;color:#888\"></span></h1>\n<div id=\"lock\" class=\"card\">\n <p><b>输入主口令解锁</b>(口令只在本地内存派生,不落盘、不发送远端)。忘口令可在 CLI 用恢复钥匙重设:<code>yotta-memory reset-password --recovery-key <钥匙></code></p>\n <input type=\"password\" id=\"pw\" placeholder=\"主口令\" style=\"width:260px\">\n <button onclick=\"unlock()\">解锁</button>\n <div class=\"err\" id=\"lockerr\"></div>\n</div>\n<div id=\"app\">\n <div class=\"card\">\n <b>AI 列表</b>(✅=已授权可读自己私密,🔒=未授权)\n <div id=\"owners\" style=\"margin-top:8px\"></div>\n </div>\n <div class=\"card\">\n <b>记忆</b>\n <input id=\"q\" placeholder=\"搜索关键词\" style=\"width:220px\" onkeydown=\"if(event.key==='Enter'){off=0;load()}\">\n <button onclick=\"off=0;load()\">搜索</button>\n <button class=\"ghost\" onclick=\"doExport()\">导出 JSON</button>\n <button class=\"ghost\" onclick=\"showRk()\">显示恢复钥匙</button>\n <span id=\"rkout\" style=\"font-size:12px;color:#888;margin-left:8px\"></span>\n <div id=\"meta\" style=\"margin-top:10px;font-size:12px;color:#666\"></div>\n <div id=\"entries\" style=\"margin-top:6px\"></div>\n <div id=\"pager\" style=\"margin-top:10px\">\n <button class=\"ghost\" id=\"prevb\" onclick=\"prevPage()\">上一页</button>\n <span id=\"pageinfo\" style=\"font-size:12px;color:#888;margin:0 8px\"></span>\n <button class=\"ghost\" id=\"nextb\" onclick=\"nextPage()\">下一页</button>\n </div>\n </div>\n <div class=\"card\">\n <b>重设口令</b><br>\n <input type=\"password\" id=\"cur\" placeholder=\"当前口令\">\n <input type=\"password\" id=\"np1\" placeholder=\"新口令\">\n <input type=\"password\" id=\"np2\" placeholder=\"确认新口令\">\n <button onclick=\"resetPw()\">重设</button>\n <span id=\"pwout\"></span>\n </div>\n</div>\n<script>\nfunction esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return{'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c];});}\nasync function api(p,b){try{const r=await fetch(p,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})});return await r.json();}catch(e){return{error:String(e)};}}\nasync function boot(){const s=await api('/api/status');document.getElementById('ver').textContent='v'+(s.version||'');if(s.unlocked){showApp();}}\nfunction showApp(){document.getElementById('lock').style.display='none';document.getElementById('app').style.display='block';loadOwners();load();}\nasync function unlock(){const d=await api('/api/unlock',{password:document.getElementById('pw').value});if(d.error){document.getElementById('lockerr').textContent=d.error;return;}showApp();}\nasync function loadOwners(){const d=await api('/api/owners');const box=document.getElementById('owners');box.innerHTML='';if(!d.owners||!d.owners.length){box.innerHTML='(无 owner)';return;}\n for(const o of d.owners){const c=document.createElement('span');c.className='owner';c.innerHTML=esc(o.owner)+(o.authorized?' ✅':' 🔒')+' <button class=\"ghost\" data-a=\"'+esc(o.owner)+'\">授权</button><button class=\"danger\" data-r=\"'+esc(o.owner)+'\">吊销</button>';box.appendChild(c);}\n box.querySelectorAll('[data-a]').forEach(function(b){b.onclick=function(){api('/api/authorize',{owner:b.getAttribute('data-a')}).then(function(){loadOwners();});};});\n box.querySelectorAll('[data-r]').forEach(function(b){b.onclick=function(){api('/api/revoke',{owner:b.getAttribute('data-r')}).then(function(){loadOwners();});};});\n}\nlet off=0,PS=50;\nasync function load(){const d=await api('/api/entries',{query:document.getElementById('q').value,offset:off,limit:PS});const meta=document.getElementById('meta');const pg=document.getElementById('pageinfo');if(meta)meta.textContent='共 '+d.count+' 条';const lim=d.limit||PS;const totalPg=Math.max(1,Math.ceil(d.count/lim));const curPg=Math.floor((d.offset||0)/lim)+1;if(pg)pg.textContent='第 '+curPg+' / '+totalPg+' 页';const box=document.getElementById('entries');box.innerHTML='';if(d.entries)for(const e of d.entries){const div=document.createElement('div');div.className='entry';div.innerHTML='<b>['+esc(e.type)+'] '+esc(e.subject)+'</b><div>'+esc(e.statement)+'</div><div class=\"meta\">'+esc(e.file)+' · owner='+esc(e.owner||'-')+' · '+esc(e.updated||e.created||'')+'</div>';box.appendChild(div);}const pb=document.getElementById('prevb'),nb=document.getElementById('nextb');if(pb)pb.disabled=(d.offset||0)<=0;if(nb)nb.disabled=!d.hasMore;}\nfunction prevPage(){if(off>=PS){off-=PS;load();}}\nfunction nextPage(){off+=PS;load();}\nasync function doExport(){const d=await api('/api/export');if(d.error){alert(d.error);return;}const blob=new Blob([JSON.stringify(d,null,2)],{type:'application/json'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='yottamemory-view-export.json';a.click();}\nasync function showRk(){const d=await api('/api/recovery-key');document.getElementById('rkout').textContent=d.recoveryKey?('恢复钥匙: '+d.recoveryKey):(d.error||'');}\nasync function resetPw(){const np1=document.getElementById('np1').value,np2=document.getElementById('np2').value;if(np1!==np2){document.getElementById('pwout').innerHTML='<span class=\"err\">两次新口令不一致</span>';return;}\n const d=await api('/api/reset-password',{currentPassword:document.getElementById('cur').value,newPassword:np1});document.getElementById('pwout').innerHTML=d.error?('<span class=\"err\">'+esc(d.error)+'</span>'):('<span class=\"ok\">'+esc(d.text||'ok')+'</span>');}\nboot();\n</script></body></html>";
|
|
2025
|
+
|
|
615
2026
|
// ---- 命令包装(CLI 入口)----
|
|
616
|
-
function cmdInit(opts) {
|
|
617
|
-
const
|
|
2027
|
+
async function cmdInit(opts) {
|
|
2028
|
+
const root = opts.dir ? path.resolve(String(opts.dir)) : (opts.project ? projectRoot() : userRoot());
|
|
2029
|
+
const fresh = !fs.existsSync(path.join(root, PUBLIC_DIR)) && !fs.existsSync(path.join(root, PRIVATE_DIR)) && !fs.existsSync(path.join(root, INDEX_FILE));
|
|
2030
|
+
const wantEncrypt = opts.encrypt || (!opts.noEncrypt && fresh);
|
|
2031
|
+
let o = Object.assign({}, opts);
|
|
2032
|
+
if (wantEncrypt && !o.password && !process.env.YOTTA_MEMORY_PASS) {
|
|
2033
|
+
const pw = await promptPassword('主口令(启用加密,勿忘;忘口令可用恢复钥匙重设): ');
|
|
2034
|
+
if (!pw) { console.error('已取消。'); process.exit(2); }
|
|
2035
|
+
const pw2 = await promptPassword('再次输入确认: ');
|
|
2036
|
+
if (pw2 !== pw) { console.error('两次输入不一致。'); process.exit(2); }
|
|
2037
|
+
o.password = pw;
|
|
2038
|
+
}
|
|
2039
|
+
const r = initCore(o);
|
|
618
2040
|
console.log(r.text);
|
|
2041
|
+
if (r.error) process.exit(2);
|
|
619
2042
|
}
|
|
620
2043
|
function cmdRemember(type, subject, statement, opts) {
|
|
621
2044
|
const o = Object.assign({}, opts);
|
|
@@ -629,6 +2052,36 @@ function cmdRecall(query, opts) {
|
|
|
629
2052
|
console.log(r.text);
|
|
630
2053
|
if (r.exitCode) process.exit(r.exitCode);
|
|
631
2054
|
}
|
|
2055
|
+
function cmdFeedback(ref, opts) {
|
|
2056
|
+
const r = feedbackCore(ref, opts);
|
|
2057
|
+
console.log(r.text);
|
|
2058
|
+
if (r.exitCode) process.exit(r.exitCode);
|
|
2059
|
+
if (r.error) process.exit(2);
|
|
2060
|
+
}
|
|
2061
|
+
function cmdExplain(ref, opts) {
|
|
2062
|
+
const r = explainCore(ref, opts);
|
|
2063
|
+
console.log(r.text);
|
|
2064
|
+
if (r.exitCode) process.exit(r.exitCode);
|
|
2065
|
+
if (r.error) process.exit(2);
|
|
2066
|
+
}
|
|
2067
|
+
function cmdMaintain(opts) {
|
|
2068
|
+
if (opts.merge) {
|
|
2069
|
+
const parts = String(opts.merge).split(',').map(function (s) { return s.trim(); }).filter(Boolean);
|
|
2070
|
+
if (parts.length < 2) { console.error('--merge 需要两个文件,逗号分隔'); process.exit(2); }
|
|
2071
|
+
const r = mergeCore(parts[0], parts[1], opts);
|
|
2072
|
+
console.log(r.text);
|
|
2073
|
+
if (r.error) process.exit(2);
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
const r = maintainCore(opts);
|
|
2077
|
+
console.log(r.text);
|
|
2078
|
+
if (r.error) process.exit(2);
|
|
2079
|
+
}
|
|
2080
|
+
function cmdDistill(opts) {
|
|
2081
|
+
const r = distillCore(opts);
|
|
2082
|
+
console.log(r.text);
|
|
2083
|
+
if (r.error) process.exit(2);
|
|
2084
|
+
}
|
|
632
2085
|
function cmdForget(fileRef) {
|
|
633
2086
|
const r = forgetCore(fileRef);
|
|
634
2087
|
console.log(r.text);
|
|
@@ -648,17 +2101,23 @@ function cmdReindex() {
|
|
|
648
2101
|
}
|
|
649
2102
|
function collectAll(root) {
|
|
650
2103
|
const out = [];
|
|
2104
|
+
const skipped = {};
|
|
651
2105
|
for (const fp of collectEntryFiles(root)) {
|
|
652
|
-
const
|
|
653
|
-
|
|
2106
|
+
const owner = ownerFromPrivatePath(root, fp);
|
|
2107
|
+
if (isEncFile(fp) && !getOwnerKeyFor(root, owner)) { skipped[owner] = true; continue; }
|
|
2108
|
+
try {
|
|
2109
|
+
const e = readEntry(fp, root);
|
|
2110
|
+
out.push({ file: e.file, meta: e.meta });
|
|
2111
|
+
} catch (err) { if (owner) skipped[owner] = true; }
|
|
654
2112
|
}
|
|
655
|
-
return out;
|
|
2113
|
+
return { memories: out, skipped: Object.keys(skipped) };
|
|
656
2114
|
}
|
|
657
2115
|
function exportCore(root, outPath) {
|
|
658
|
-
const
|
|
2116
|
+
const res = collectAll(root);
|
|
2117
|
+
const data = { format: 'yottamemory', version: 2, exported: today(), memories: res.memories, skippedOwners: res.skipped };
|
|
659
2118
|
const target = outPath || path.join(root, 'yottamemory-export-' + today() + '.json');
|
|
660
2119
|
fs.writeFileSync(target, JSON.stringify(data, null, 2), 'utf8');
|
|
661
|
-
return { error: false, text: '已导出 ' + data.memories.length + ' 条记忆 -> ' + target };
|
|
2120
|
+
return { error: false, text: '已导出 ' + data.memories.length + ' 条记忆 -> ' + target + (res.skipped.length ? '(跳过无授权私密 owner: ' + res.skipped.join(', ') + ')' : '') };
|
|
662
2121
|
}
|
|
663
2122
|
function importCore(root, src) {
|
|
664
2123
|
if (!src) return { error: true, text: '请提供 JSON 文件路径' };
|
|
@@ -673,16 +2132,21 @@ function importCore(root, src) {
|
|
|
673
2132
|
catch (e) { return { error: true, text: 'JSON 解析失败: ' + (e && e.message ? e.message : String(e)) }; }
|
|
674
2133
|
if (!data.memories || !Array.isArray(data.memories)) return { error: true, text: 'JSON 格式不正确(缺少 memories 数组)' };
|
|
675
2134
|
ensureInit(root);
|
|
676
|
-
|
|
2135
|
+
const encrypted = isEncrypted(root);
|
|
2136
|
+
let m = 0, skippedPriv = 0;
|
|
677
2137
|
for (const item of data.memories) {
|
|
678
2138
|
const meta = item.meta || {};
|
|
679
2139
|
const t = (meta.type || 'FACT').toUpperCase();
|
|
680
2140
|
if (!TYPE_DIRS[t]) continue;
|
|
681
|
-
const
|
|
2141
|
+
const owner = meta.owner || '';
|
|
2142
|
+
const scope = meta.scope || defaultScope(t);
|
|
2143
|
+
if (encrypted && scope === 'private' && !getOwnerKeyFor(root, owner)) { skippedPriv++; continue; }
|
|
2144
|
+
const dir = path.join(root, typeSubdir(t, owner));
|
|
682
2145
|
fs.mkdirSync(dir, { recursive: true });
|
|
683
2146
|
let seq = nextSeq(dir);
|
|
684
|
-
|
|
685
|
-
|
|
2147
|
+
const suffix = (encrypted && scope === 'private') ? ENC_SUFFIX : '';
|
|
2148
|
+
let file = path.join(dir, today() + '-' + seq + '.md' + suffix);
|
|
2149
|
+
while (fs.existsSync(file)) { seq = String(parseInt(seq, 10) + 1).padStart(4, '0'); file = path.join(dir, today() + '-' + seq + '.md' + suffix); }
|
|
686
2150
|
const rec = {
|
|
687
2151
|
type: t,
|
|
688
2152
|
subject: meta.subject || '',
|
|
@@ -694,16 +2158,16 @@ function importCore(root, src) {
|
|
|
694
2158
|
immutable: meta.immutable === true || meta.immutable === 'true',
|
|
695
2159
|
source: meta.source || '',
|
|
696
2160
|
weight: (parseFloat(meta.weight) > 0 ? parseFloat(meta.weight) : 1.0),
|
|
697
|
-
scope:
|
|
698
|
-
owner:
|
|
2161
|
+
scope: scope,
|
|
2162
|
+
owner: owner,
|
|
699
2163
|
access_count: parseInt(meta.access_count || '0', 10) || 0,
|
|
700
2164
|
last_accessed: meta.last_accessed || '',
|
|
701
2165
|
};
|
|
702
|
-
|
|
2166
|
+
writeMemoryText(root, file, frontmatterToText(rec, rec.statement), owner);
|
|
703
2167
|
upsertIndexEntry(root, readEntry(file, root));
|
|
704
2168
|
m++;
|
|
705
2169
|
}
|
|
706
|
-
return { error: false, text: '已导入 ' + m + ' 条记忆 -> ' + root };
|
|
2170
|
+
return { error: false, text: '已导入 ' + m + ' 条记忆 -> ' + root + (skippedPriv ? '(跳过 ' + skippedPriv + ' 条无授权私密)' : '') };
|
|
707
2171
|
}
|
|
708
2172
|
function cmdExport(outPath) {
|
|
709
2173
|
const r = exportCore(userRoot(), outPath || 'yottamemory-export-' + today() + '.json');
|
|
@@ -777,9 +2241,12 @@ function findSelfProfile(root, agentId) {
|
|
|
777
2241
|
const dir = selfPrefsDir(root, agentId);
|
|
778
2242
|
if (!fs.existsSync(dir)) return '';
|
|
779
2243
|
for (const f of fs.readdirSync(dir)) {
|
|
2244
|
+
if (!/\.md(\.enc)?$/.test(f)) continue;
|
|
780
2245
|
const fp = path.join(dir, f);
|
|
781
2246
|
if (!fs.statSync(fp).isFile()) continue;
|
|
782
|
-
|
|
2247
|
+
let text;
|
|
2248
|
+
try { text = readMemoryText(root, fp, agentId); } catch (err) { continue; }
|
|
2249
|
+
const meta = parseFrontmatter(text).meta;
|
|
783
2250
|
if (meta.subject === SELF_PROFILE_SUBJECT && (meta.owner || '') === agentId) return f;
|
|
784
2251
|
}
|
|
785
2252
|
return '';
|
|
@@ -809,11 +2276,11 @@ function writeSelfProfile(root, agentId, extra) {
|
|
|
809
2276
|
extra = extra || {};
|
|
810
2277
|
const dir = selfPrefsDir(root, agentId);
|
|
811
2278
|
fs.mkdirSync(dir, { recursive: true });
|
|
2279
|
+
const encrypted = isEncrypted(root);
|
|
812
2280
|
const existing = findSelfProfile(root, agentId);
|
|
813
2281
|
if (existing) {
|
|
814
|
-
// 已有自我档案:合并 kv 字段更新(不重复建文件)
|
|
815
2282
|
const fp = path.join(dir, existing);
|
|
816
|
-
const parsed = parseFrontmatter(
|
|
2283
|
+
const parsed = parseFrontmatter(readMemoryText(root, fp, agentId));
|
|
817
2284
|
const kv = parseKvBody(parsed.body);
|
|
818
2285
|
kv.agent_id = agentId;
|
|
819
2286
|
kv.host = os.hostname();
|
|
@@ -828,12 +2295,12 @@ function writeSelfProfile(root, agentId, extra) {
|
|
|
828
2295
|
const meta = Object.assign({}, parsed.meta);
|
|
829
2296
|
meta.updated = today();
|
|
830
2297
|
meta.statement = body;
|
|
831
|
-
|
|
2298
|
+
writeMemoryText(root, fp, frontmatterToText(meta, body), agentId);
|
|
832
2299
|
upsertIndexEntry(root, readEntry(fp, root));
|
|
833
2300
|
return fp;
|
|
834
2301
|
}
|
|
835
2302
|
const seq = nextSeq(dir);
|
|
836
|
-
const file = path.join(dir, today() + '-' + seq + '.md');
|
|
2303
|
+
const file = path.join(dir, today() + '-' + seq + '.md' + (encrypted ? ENC_SUFFIX : ''));
|
|
837
2304
|
const body = selfProfileBody(agentId, root, extra);
|
|
838
2305
|
const rec = {
|
|
839
2306
|
type: 'PREF', subject: SELF_PROFILE_SUBJECT, statement: body,
|
|
@@ -841,7 +2308,7 @@ function writeSelfProfile(root, agentId, extra) {
|
|
|
841
2308
|
tags: ['自我档案'], immutable: false,
|
|
842
2309
|
scope: 'private', owner: agentId, access_count: 0, last_accessed: '',
|
|
843
2310
|
};
|
|
844
|
-
|
|
2311
|
+
writeMemoryText(root, file, frontmatterToText(rec, body), agentId);
|
|
845
2312
|
upsertIndexEntry(root, readEntry(file, root));
|
|
846
2313
|
return file;
|
|
847
2314
|
}
|
|
@@ -897,7 +2364,7 @@ function cmdWhoami() {
|
|
|
897
2364
|
console.log('登记状态: ' + reg);
|
|
898
2365
|
console.log('自我档案: ' + (profile ? profile : '未写入(请执行 yotta-memory iam ' + id + ')'));
|
|
899
2366
|
if (profile) {
|
|
900
|
-
const kv =
|
|
2367
|
+
const kv = selfProfileKv(root, id);
|
|
901
2368
|
if (kv.agent_name) console.log('显示名: ' + kv.agent_name);
|
|
902
2369
|
if (kv.user_name) console.log('用户: ' + kv.user_name);
|
|
903
2370
|
if (kv.relationship) console.log('关系: ' + kv.relationship);
|
|
@@ -913,9 +2380,11 @@ function profileGroups(root, owner) {
|
|
|
913
2380
|
const dir = path.join(root, PRIVATE_DIR, owner, t);
|
|
914
2381
|
if (!fs.existsSync(dir)) continue;
|
|
915
2382
|
for (const f of fs.readdirSync(dir)) {
|
|
2383
|
+
if (!/\.md(\.enc)?$/.test(f)) continue;
|
|
916
2384
|
const fp = path.join(dir, f);
|
|
917
2385
|
if (!fs.statSync(fp).isFile()) continue;
|
|
918
|
-
|
|
2386
|
+
let e;
|
|
2387
|
+
try { e = readEntry(fp, root); } catch (err) { continue; }
|
|
919
2388
|
const key = e.type + '\u0000' + (e.subject || '') + '\u0000' + e.tags.join(',');
|
|
920
2389
|
if (!map[key]) {
|
|
921
2390
|
map[key] = { type: e.type, subject: e.subject || '', tags: e.tags || [], items: [] };
|
|
@@ -940,6 +2409,9 @@ function profileCore(opts) {
|
|
|
940
2409
|
}
|
|
941
2410
|
const ownerDir = path.join(root, PRIVATE_DIR, owner);
|
|
942
2411
|
if (!fs.existsSync(ownerDir)) return { error: false, text: '该智能体(' + owner + ')暂无画像:private/' + owner + '/ 不存在或为空。' };
|
|
2412
|
+
if (isEncrypted(root) && !getOwnerKeyFor(root, owner)) {
|
|
2413
|
+
return { error: true, exitCode: 3, text: '私密区已加密:当前无 ' + owner + ' 的授权密钥,请在用户平台授权(yotta-memory view → 授权本智能体)后生成画像。' };
|
|
2414
|
+
}
|
|
943
2415
|
const groups = profileGroups(root, owner);
|
|
944
2416
|
if (!groups.length) return { error: false, text: '该智能体(' + owner + ')暂无画像:private/' + owner + '/ 下无 PREF / BOUND / COMMIT 条目。' };
|
|
945
2417
|
const lines = [];
|
|
@@ -959,8 +2431,7 @@ function profileCore(opts) {
|
|
|
959
2431
|
lines.push('');
|
|
960
2432
|
}
|
|
961
2433
|
const text = lines.join('\n');
|
|
962
|
-
const outFile =
|
|
963
|
-
fs.writeFileSync(outFile, text + '\n', 'utf8');
|
|
2434
|
+
const outFile = writeProfileText(root, owner, text + '\n');
|
|
964
2435
|
return { error: false, text: text + '\n\n[已写入] ' + outFile };
|
|
965
2436
|
}
|
|
966
2437
|
function cmdProfile(opts) {
|
|
@@ -974,7 +2445,9 @@ function cmdProfile(opts) {
|
|
|
974
2445
|
function selfProfileKv(root, id) {
|
|
975
2446
|
const profile = findSelfProfile(root, id);
|
|
976
2447
|
if (!profile) return {};
|
|
977
|
-
|
|
2448
|
+
let text;
|
|
2449
|
+
try { text = readMemoryText(root, path.join(selfPrefsDir(root, id), profile), id); } catch (e) { return {}; }
|
|
2450
|
+
return parseKvBody(parseFrontmatter(text).body);
|
|
978
2451
|
}
|
|
979
2452
|
function contextCore(opts) {
|
|
980
2453
|
opts = opts || {};
|
|
@@ -987,6 +2460,7 @@ function contextCore(opts) {
|
|
|
987
2460
|
const unsafe = !!opts.unsafe;
|
|
988
2461
|
const budget = opts.budget ? parseInt(opts.budget, 10) : 0;
|
|
989
2462
|
const lines = [];
|
|
2463
|
+
const FENCE = String.fromCharCode(96, 96, 96);
|
|
990
2464
|
function usedChars() { return lines.reduce(function (s, x) { return s + String(x).length + 1; }, 0); }
|
|
991
2465
|
lines.push('# 开工上下文包(yotta-memory context)');
|
|
992
2466
|
lines.push('');
|
|
@@ -1013,18 +2487,22 @@ function contextCore(opts) {
|
|
|
1013
2487
|
lines.push('## 2. 用户画像摘要');
|
|
1014
2488
|
lines.push('');
|
|
1015
2489
|
if (owner) {
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
lines.push('
|
|
2490
|
+
let pf = profilePathFor(root, owner);
|
|
2491
|
+
let pfText = null;
|
|
2492
|
+
if (fs.existsSync(pf)) { try { pfText = readProfileText(root, owner); } catch (e) { pfText = null; } }
|
|
2493
|
+
if (pfText !== null && pfText !== undefined) {
|
|
2494
|
+
lines.push(FENCE + 'markdown');
|
|
2495
|
+
lines.push(pfText.trim());
|
|
2496
|
+
lines.push(FENCE);
|
|
1021
2497
|
} else {
|
|
1022
2498
|
const pr = profileCore({ owner: owner, unsafe: unsafe, selfAgent: selfAgent });
|
|
2499
|
+
pf = profilePathFor(root, owner);
|
|
1023
2500
|
if (!pr.error && pr.text.indexOf('[已写入]') !== -1 && fs.existsSync(pf)) {
|
|
1024
2501
|
lines.push('(画像不存在,已自动生成一次,见下)');
|
|
1025
|
-
lines.push('
|
|
1026
|
-
|
|
1027
|
-
lines.push('
|
|
2502
|
+
lines.push(FENCE + 'markdown');
|
|
2503
|
+
let t2 = null; try { t2 = readProfileText(root, owner); } catch (e) {}
|
|
2504
|
+
lines.push((t2 === null || t2 === undefined) ? '' : t2.trim());
|
|
2505
|
+
lines.push(FENCE);
|
|
1028
2506
|
} else {
|
|
1029
2507
|
lines.push('(该身份暂无画像;可运行 yotta-memory profile 生成)');
|
|
1030
2508
|
}
|
|
@@ -1039,7 +2517,8 @@ function contextCore(opts) {
|
|
|
1039
2517
|
for (const r of roots) {
|
|
1040
2518
|
for (const e of ensureIndex(r)) {
|
|
1041
2519
|
if (classifyRead(e, owner, '', unsafe, selfAgent) === 'denied') continue;
|
|
1042
|
-
|
|
2520
|
+
// v0.8.0 排序融合:0.5×importance(旧) + 0.5×utility(新盖棺分)
|
|
2521
|
+
recent.push({ e: e, s: 0.5 * importanceScore(e) + 0.5 * utilityScore(e) });
|
|
1043
2522
|
}
|
|
1044
2523
|
}
|
|
1045
2524
|
recent.sort(function (a, b) { return b.s - a.s; });
|
|
@@ -1076,6 +2555,21 @@ function contextCore(opts) {
|
|
|
1076
2555
|
}
|
|
1077
2556
|
if (!commits.length) lines.push('(无)');
|
|
1078
2557
|
for (const e of commits) lines.push('- ' + (e.subject || '承诺') + ': ' + e.statement);
|
|
2558
|
+
lines.push('');
|
|
2559
|
+
lines.push('## 6. 收工纪律');
|
|
2560
|
+
lines.push('');
|
|
2561
|
+
const allEntries = [];
|
|
2562
|
+
for (const r of roots) for (const e of ensureIndex(r)) allEntries.push(e);
|
|
2563
|
+
const myCommits = allEntries.filter(function (e) { return e.type === 'COMMIT' && classifyRead(e, owner, '', unsafe, selfAgent) !== 'denied'; });
|
|
2564
|
+
if (!myCommits.length) {
|
|
2565
|
+
lines.push('- 最近承诺: 无(收工请补 COMMIT / 交接锚点)');
|
|
2566
|
+
} else {
|
|
2567
|
+
const latest = myCommits.map(function (e) { return e.updated || e.created || ''; }).sort().pop();
|
|
2568
|
+
const d = daysBetween(latest, today());
|
|
2569
|
+
lines.push('- 最近承诺: ' + latest + (d > 7 ? '(超期 ' + d + ' 天,请补 COMMIT)' : ''));
|
|
2570
|
+
}
|
|
2571
|
+
const oldCount = allEntries.filter(function (e) { return e.created && daysBetween(e.created, today()) > 180 && classifyRead(e, owner, '', unsafe, selfAgent) !== 'denied'; }).length;
|
|
2572
|
+
if (oldCount > 0) lines.push('- 归档提醒: ' + oldCount + ' 条超 180 天,建议 archive');
|
|
1079
2573
|
return { error: false, exitCode: 0, text: lines.join('\n') };
|
|
1080
2574
|
}
|
|
1081
2575
|
function cmdContext(opts) {
|
|
@@ -1113,6 +2607,10 @@ function mcpTools() {
|
|
|
1113
2607
|
{ name: 'import', description: '从引擎主机上的 JSON 文件导入记忆。src 为文件路径(可绝对路径,或相对记忆库目录)', inputSchema: { type: 'object', properties: { src: { type: 'string' } }, required: ['src'] } },
|
|
1114
2608
|
{ name: 'agent_info', description: '查看当前智能体身份与登记状态(远端读经 token 校验的 X-Agent-Id;本机读 YOTTA_AGENT_ID)。开工先确认「我是谁」,禁止从记忆里抄别人的 ID', inputSchema: { type: 'object', properties: {} } },
|
|
1115
2609
|
{ name: 'profile', description: '生成当前智能体的用户画像(只读聚合 private/<owner>/ 下 PREF/BOUND/COMMIT 原文,零推断,写入 private/<owner>/profile.md)。owner 默认当前智能体', inputSchema: { type: 'object', properties: { owner: { type: 'string' } } } },
|
|
2610
|
+
{ name: 'feedback', description: '显式使用反馈(自我学习):useful/useless 调整记忆 weight/confidence/feedback_net。file 为记忆文件路径或文件名', inputSchema: { type: 'object', properties: { file: { type: 'string' }, useful: { type: 'boolean' }, useless: { type: 'boolean' }, reason: { type: 'string' } }, required: ['file'] } },
|
|
2611
|
+
{ name: 'maintain', description: '记忆自组织(自我进化):规则层归档/遗忘/去重预览。默认 dry-run;apply 才执行,purge 才真删', inputSchema: { type: 'object', properties: { apply: { type: 'boolean' }, purge: { type: 'boolean' }, dedup: { type: 'boolean' }, threshold: { type: 'number' }, age: { type: 'number' } } } },
|
|
2612
|
+
{ name: 'distill', description: '心理日志蒸馏(自我提升):统计摘要/主题画像/知识地图。owner 默认当前智能体', inputSchema: { type: 'object', properties: { owner: { type: 'string' }, subject: { type: 'string' }, model: { type: 'string' } } } },
|
|
2613
|
+
{ name: 'explain', description: '解释单条记忆效用分项(为什么靠前/归档/遗忘)。file 为记忆文件路径或文件名', inputSchema: { type: 'object', properties: { file: { type: 'string' } }, required: ['file'] } },
|
|
1116
2614
|
];
|
|
1117
2615
|
}
|
|
1118
2616
|
function callTool(name, args, ctx) {
|
|
@@ -1174,6 +2672,22 @@ function callTool(name, args, ctx) {
|
|
|
1174
2672
|
const r = profileCore({ owner: ownerArg || agent, selfAgent: agent });
|
|
1175
2673
|
return { text: r.text, error: r.error };
|
|
1176
2674
|
}
|
|
2675
|
+
if (name === 'feedback') {
|
|
2676
|
+
const r = feedbackCore(String(args.file || ''), { selfAgent: agent, useful: !!args.useful, useless: !!args.useless, reason: args.reason ? String(args.reason) : '' });
|
|
2677
|
+
return { text: r.text, error: r.error };
|
|
2678
|
+
}
|
|
2679
|
+
if (name === 'maintain') {
|
|
2680
|
+
const r = maintainCore({ selfAgent: agent, apply: !!args.apply, purge: !!args.purge, dedup: !!args.dedup, threshold: args.threshold, age: args.age });
|
|
2681
|
+
return { text: r.text, error: r.error };
|
|
2682
|
+
}
|
|
2683
|
+
if (name === 'distill') {
|
|
2684
|
+
const r = distillCore({ selfAgent: agent, owner: args.owner ? String(args.owner) : agent, subject: args.subject ? String(args.subject) : '', model: args.model ? String(args.model) : '' });
|
|
2685
|
+
return { text: r.text, error: r.error };
|
|
2686
|
+
}
|
|
2687
|
+
if (name === 'explain') {
|
|
2688
|
+
const r = explainCore(String(args.file || ''), { selfAgent: agent });
|
|
2689
|
+
return { text: r.text, error: r.error };
|
|
2690
|
+
}
|
|
1177
2691
|
return { text: '未知工具: ' + name, error: true };
|
|
1178
2692
|
} catch (e) {
|
|
1179
2693
|
return { text: '错误: ' + (e && e.message ? e.message : String(e)), error: true };
|
|
@@ -1260,7 +2774,7 @@ function cmdServe(opts) {
|
|
|
1260
2774
|
});
|
|
1261
2775
|
}
|
|
1262
2776
|
|
|
1263
|
-
// ---- stdio
|
|
2777
|
+
// ---- stdio 本地零进程模式(客户端按需拉起 CLI)----
|
|
1264
2778
|
function cmdServeStdio() {
|
|
1265
2779
|
const root = userRoot();
|
|
1266
2780
|
ensureInit(root);
|
|
@@ -1715,44 +3229,59 @@ function cmdLanStatus() {
|
|
|
1715
3229
|
}
|
|
1716
3230
|
// ---- usage / main ----
|
|
1717
3231
|
function usage() {
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
'',
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
'
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
'
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
'
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
3232
|
+
const banner = 'yotta-memory v' + VERSION + ' — 元忆:有权限边界的文件式智能体记忆';
|
|
3233
|
+
const sections = [
|
|
3234
|
+
['核心记忆', [
|
|
3235
|
+
['init', '初始化记忆库(新建默认加密:需主口令 + 恢复钥匙;--no-encrypt 降级明文)'],
|
|
3236
|
+
['remember', '写入记忆(--source 来源;--weight 权重;--verify 写后回读校验;--no-hint 关启发)'],
|
|
3237
|
+
['recall', '检索记忆(--type/--limit/--agent/--owner/--all/--unsafe)'],
|
|
3238
|
+
['forget', '删除一条记忆'],
|
|
3239
|
+
['archive', '归档(--days/--threshold 盖棺分+年龄)'],
|
|
3240
|
+
['reindex', '重建索引'],
|
|
3241
|
+
['export', '导出全部记忆(--out 文件.json)'],
|
|
3242
|
+
['import', '导入记忆(<文件.json>)']
|
|
3243
|
+
]],
|
|
3244
|
+
['身份与画像', [
|
|
3245
|
+
['iam', '登记本智能体唯一身份(--name/--user/--relationship;agents.json)'],
|
|
3246
|
+
['whoami', '查看当前智能体身份与登记状态'],
|
|
3247
|
+
['profile', '生成用户画像(聚合 private/<owner>/ 原文,零推断)'],
|
|
3248
|
+
['context', '生成开工上下文包(--limit/--owner/--budget)'],
|
|
3249
|
+
['token', '生成/列出/吊销访问 token(new --agent / list / revoke --agent)']
|
|
3250
|
+
]],
|
|
3251
|
+
['加密与安全', [
|
|
3252
|
+
['migrate', '把明文私密区迁移为密文(需主口令;迁移后打印恢复钥匙)'],
|
|
3253
|
+
['view', '启动用户查看平台(--port/--host;口令解锁浏览/授权/吊销 AI)'],
|
|
3254
|
+
['reset-password', '重设主口令(忘口令用恢复钥匙)'],
|
|
3255
|
+
['key', '管理 AI 私密读取授权缓存(list / authorize <id> / revoke <id>)'],
|
|
3256
|
+
['config', '查看/记住记忆库位置(get / set memory_home <目录>)']
|
|
3257
|
+
]],
|
|
3258
|
+
['平台与服务', [
|
|
3259
|
+
['serve', '启动 MCP 记忆引擎(streamable HTTP;--stdio 本地零进程)'],
|
|
3260
|
+
['lan', '开机自启管理(enable/disable/status;Windows 计划任务 / Linux systemd/crontab)'],
|
|
3261
|
+
['--version', '版本']
|
|
3262
|
+
]]
|
|
3263
|
+
];
|
|
3264
|
+
let col = 0;
|
|
3265
|
+
for (const s of sections) for (const r of s[1]) if (r[0].length > col) col = r[0].length;
|
|
3266
|
+
col += 2;
|
|
3267
|
+
const lines = [banner, '', '用法:', ' yotta-memory <命令> [选项]', ''];
|
|
3268
|
+
for (const s of sections) {
|
|
3269
|
+
lines.push(s[0] + ':');
|
|
3270
|
+
for (const r of s[1]) lines.push(' ' + r[0].padEnd(col) + r[1]);
|
|
3271
|
+
lines.push('');
|
|
3272
|
+
}
|
|
3273
|
+
lines.push('类型: FACT(公共共享) / PREF(偏好) / BOUND(边界) / COMMIT(承诺)');
|
|
3274
|
+
lines.push('环境变量: YOTTA_MEMORY_HOME 临时覆盖用户级位置; YOTTA_AGENT_ID/AGENT_ID 当前 agent 标识(本机声明身份用;私密记忆必须有 owner)');
|
|
3275
|
+
lines.push('隔离: 公共 FACT 在 facts/;私密 PREF/BOUND/COMMIT 物理分目录 private/<agent_id>/<type>/,禁止 shell 直读写记忆库,一律走本命令');
|
|
3276
|
+
lines.push('远端接入: MCP url http://<主机IP>:8787/mcp;请求头 Authorization: Bearer <token> + X-Agent-Id: <id>');
|
|
3277
|
+
console.log(lines.join('\n'));
|
|
1749
3278
|
}
|
|
1750
|
-
function main() {
|
|
3279
|
+
async function main() {
|
|
1751
3280
|
const args = process.argv.slice(2);
|
|
1752
3281
|
if (!args.length) { usage(); return; }
|
|
1753
3282
|
const opts = {};
|
|
1754
3283
|
const positional = [];
|
|
1755
|
-
const valueOpts = new Set(['--type', '--limit', '--days', '--out', '--owner', '--agent', '--threshold', '--scope', '--host', '--port', '--dir', '--name', '--user', '--relationship', '--source', '--weight', '--budget']);
|
|
3284
|
+
const valueOpts = new Set(['--type', '--limit', '--days', '--out', '--owner', '--agent', '--threshold', '--scope', '--host', '--port', '--dir', '--name', '--user', '--relationship', '--source', '--weight', '--budget', '--password', '--new-password', '--recovery-key', '--reason', '--merge', '--model', '--subject']);
|
|
1756
3285
|
for (let i = 0; i < args.length; i++) {
|
|
1757
3286
|
const a = args[i];
|
|
1758
3287
|
if (a === '--version' || a === '-v') { console.log(VERSION); return; }
|
|
@@ -1766,6 +3295,17 @@ function main() {
|
|
|
1766
3295
|
else if (a === '--force') opts.force = true;
|
|
1767
3296
|
else if (a === '--verify') opts.verify = true;
|
|
1768
3297
|
else if (a === '--no-hint') opts.noHint = true;
|
|
3298
|
+
else if (a === '--encrypt') opts.encrypt = true;
|
|
3299
|
+
else if (a === '--no-encrypt') opts.noEncrypt = true;
|
|
3300
|
+
else if (a === '--useful') opts.useful = true;
|
|
3301
|
+
else if (a === '--useless') opts.useless = true;
|
|
3302
|
+
else if (a === '--undo') opts.undo = true;
|
|
3303
|
+
else if (a === '--dry-run') opts.dryRun = true;
|
|
3304
|
+
else if (a === '--apply') opts.apply = true;
|
|
3305
|
+
else if (a === '--purge') opts.purge = true;
|
|
3306
|
+
else if (a === '--dedup') opts.dedup = true;
|
|
3307
|
+
else if (a === '--explain') opts.explain = true;
|
|
3308
|
+
else if (a === '--semantic') opts.semantic = true;
|
|
1769
3309
|
else if (valueOpts.has(a)) {
|
|
1770
3310
|
const v = args[++i];
|
|
1771
3311
|
if (a === '--type') opts.type = v;
|
|
@@ -1785,6 +3325,9 @@ function main() {
|
|
|
1785
3325
|
else if (a === '--source') opts.source = v;
|
|
1786
3326
|
else if (a === '--weight') opts.weight = parseFloat(v);
|
|
1787
3327
|
else if (a === '--budget') opts.budget = parseInt(v, 10) || 0;
|
|
3328
|
+
else if (a === '--password') opts.password = v;
|
|
3329
|
+
else if (a === '--new-password') opts.newPassword = v;
|
|
3330
|
+
else if (a === '--recovery-key') opts.recoveryKey = v;
|
|
1788
3331
|
} else if (a.startsWith('--')) {
|
|
1789
3332
|
console.error('未知选项: ' + a);
|
|
1790
3333
|
process.exit(2);
|
|
@@ -1795,7 +3338,7 @@ function main() {
|
|
|
1795
3338
|
const first = positional[0];
|
|
1796
3339
|
const rest = positional.slice(1);
|
|
1797
3340
|
switch (first) {
|
|
1798
|
-
case 'init': cmdInit(opts); break;
|
|
3341
|
+
case 'init': await cmdInit(opts); break;
|
|
1799
3342
|
case 'whoami': cmdWhoami(); break;
|
|
1800
3343
|
case 'iam': cmdIam(rest[0], opts); break;
|
|
1801
3344
|
case 'config': {
|
|
@@ -1807,6 +3350,11 @@ function main() {
|
|
|
1807
3350
|
}
|
|
1808
3351
|
case 'remember': cmdRemember(rest[0], rest[1], rest[2], opts); break;
|
|
1809
3352
|
case 'recall': cmdRecall(rest[0] || null, opts); break;
|
|
3353
|
+
case 'search': cmdRecall(rest[0] || null, opts); break;
|
|
3354
|
+
case 'feedback': cmdFeedback(rest[0], opts); break;
|
|
3355
|
+
case 'explain': cmdExplain(rest[0], opts); break;
|
|
3356
|
+
case 'maintain': cmdMaintain(opts); break;
|
|
3357
|
+
case 'distill': cmdDistill(opts); break;
|
|
1810
3358
|
case 'forget': cmdForget(rest[0]); break;
|
|
1811
3359
|
case 'archive': cmdArchive(opts); break;
|
|
1812
3360
|
case 'reindex': cmdReindex(); break;
|
|
@@ -1822,6 +3370,17 @@ function main() {
|
|
|
1822
3370
|
else { console.error('token 子命令: new --agent <id> / list / revoke --agent <id>'); process.exit(2); }
|
|
1823
3371
|
break;
|
|
1824
3372
|
}
|
|
3373
|
+
case 'migrate': await cmdMigrate(opts); break;
|
|
3374
|
+
case 'view': cmdView(opts); break;
|
|
3375
|
+
case 'reset-password': await cmdResetPassword(opts); break;
|
|
3376
|
+
case 'key': {
|
|
3377
|
+
const sub = rest[0];
|
|
3378
|
+
if (sub === 'list') cmdKeyList();
|
|
3379
|
+
else if (sub === 'authorize') await cmdKeyAuthorize(rest[1], opts);
|
|
3380
|
+
else if (sub === 'revoke') cmdKeyRevoke(rest[1]);
|
|
3381
|
+
else { console.error('key 子命令: list / authorize <id> / revoke <id>'); process.exit(2); }
|
|
3382
|
+
break;
|
|
3383
|
+
}
|
|
1825
3384
|
case 'serve': cmdServe(opts); break;
|
|
1826
3385
|
case 'lan': {
|
|
1827
3386
|
const sub = rest[0];
|
|
@@ -1839,7 +3398,7 @@ function main() {
|
|
|
1839
3398
|
}
|
|
1840
3399
|
}
|
|
1841
3400
|
|
|
1842
|
-
if (require.main === module) main();
|
|
3401
|
+
if (require.main === module) { main().catch(function (e) { console.error('错误: ' + (e && e.message ? e.message : String(e))); process.exit(2); }); }
|
|
1843
3402
|
module.exports = {
|
|
1844
3403
|
VERSION: VERSION,
|
|
1845
3404
|
userRoot: userRoot,
|
|
@@ -1903,5 +3462,61 @@ module.exports = {
|
|
|
1903
3462
|
cmdLanLinuxEnable: cmdLanLinuxEnable,
|
|
1904
3463
|
cmdLanLinuxDisable: cmdLanLinuxDisable,
|
|
1905
3464
|
cmdLanLinuxStatus: cmdLanLinuxStatus,
|
|
3465
|
+
feedbackCore: feedbackCore,
|
|
3466
|
+
explainCore: explainCore,
|
|
3467
|
+
maintainCore: maintainCore,
|
|
3468
|
+
mergeCore: mergeCore,
|
|
3469
|
+
distillCore: distillCore,
|
|
3470
|
+
utilityScore: utilityScore,
|
|
3471
|
+
utilityBreakdown: utilityBreakdown,
|
|
3472
|
+
semanticMatch: semanticMatch,
|
|
3473
|
+
pinyinTokens: pinyinTokens,
|
|
3474
|
+
isEncrypted: isEncrypted,
|
|
3475
|
+
collectOwners: collectOwners,
|
|
3476
|
+
hasPlaintextPrivate: hasPlaintextPrivate,
|
|
3477
|
+
initEncryptionCore: initEncryptionCore,
|
|
3478
|
+
deriveUmk: deriveUmk,
|
|
3479
|
+
loadSalt: loadSalt,
|
|
3480
|
+
wrapOwnerKey: wrapOwnerKey,
|
|
3481
|
+
unwrapOwnerKey: unwrapOwnerKey,
|
|
3482
|
+
unwrapOwnerKeyRecovery: unwrapOwnerKeyRecovery,
|
|
3483
|
+
unwrapRecoveryEnc: unwrapRecoveryEnc,
|
|
3484
|
+
writeOwnerKeyCache: writeOwnerKeyCache,
|
|
3485
|
+
revokeOwnerKeyCache: revokeOwnerKeyCache,
|
|
3486
|
+
loadOwnerKeyCache: loadOwnerKeyCache,
|
|
3487
|
+
getOwnerKeyFor: getOwnerKeyFor,
|
|
3488
|
+
encryptMemoryText: encryptMemoryText,
|
|
3489
|
+
decryptMemoryText: decryptMemoryText,
|
|
3490
|
+
readMemoryText: readMemoryText,
|
|
3491
|
+
writeMemoryText: writeMemoryText,
|
|
3492
|
+
loadOwnerIndex: loadOwnerIndex,
|
|
3493
|
+
saveOwnerIndex: saveOwnerIndex,
|
|
3494
|
+
viewEntriesCore: viewEntriesCore,
|
|
3495
|
+
ensureIndex: ensureIndex,
|
|
3496
|
+
recallPrefilter: recallPrefilter,
|
|
3497
|
+
tokenize: tokenize,
|
|
3498
|
+
buildTokens: buildTokens,
|
|
3499
|
+
synonymSet: synonymSet,
|
|
3500
|
+
|
|
3501
|
+
loadIndex: loadIndex,
|
|
3502
|
+
saveIndex: saveIndex,
|
|
3503
|
+
getIndex: getIndex,
|
|
3504
|
+
touchIndex: touchIndex,
|
|
3505
|
+
loadIndexManifest: loadIndexManifest,
|
|
3506
|
+
indexShardName: indexShardName,
|
|
3507
|
+
isSafeShardName: isSafeShardName,
|
|
3508
|
+
ownerFromPrivatePath: ownerFromPrivatePath,
|
|
3509
|
+
isEncFile: isEncFile,
|
|
3510
|
+
profilePathFor: profilePathFor,
|
|
3511
|
+
readProfileText: readProfileText,
|
|
3512
|
+
writeProfileText: writeProfileText,
|
|
3513
|
+
migrateCore: migrateCore,
|
|
3514
|
+
resetPasswordCore: resetPasswordCore,
|
|
3515
|
+
keyAuthorizeCore: keyAuthorizeCore,
|
|
3516
|
+
keyRevokeCore: keyRevokeCore,
|
|
3517
|
+
promptPassword: promptPassword,
|
|
3518
|
+
initCore: initCore,
|
|
3519
|
+
viewServerCore: viewServerCore,
|
|
1906
3520
|
|
|
1907
3521
|
};
|
|
3522
|
+
|