@yottameta/yotta-memory 0.2.4 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +2 -2
- package/bin/yotta-memory.js +605 -607
- package/package.json +1 -1
- package/references/protocol.md +1 -1
package/SKILL.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: yotta-memory
|
|
3
3
|
description: "元忆 —— 有权限边界的文件式智能体记忆。文件式、零依赖、可 diff/回滚:让任何 AI 智能体活过会话,开工 recall 恢复上下文、重要信息 remember 落盘、收工归档。类型体系 FACT(公共共享)/ PREF / BOUND / COMMIT(私密隔离)。触发:记住、别忘了、记一笔、记忆、remember、recall、跨会话、上次说到、续测、交接、归档"
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.5
|
|
5
5
|
license: MIT
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# yotta-memory(元忆)— 有权限边界的文件式智能体记忆
|
|
9
9
|
|
|
10
10
|
> 一句话:元忆 —— 有权限边界的文件式智能体记忆(不注入、可 diff、能回滚;FACT 共享、PREF / BOUND / COMMIT 私密隔离)。
|
|
11
|
-
> 版本:0.2.
|
|
11
|
+
> 版本:0.2.5 | 最后更新:2026-08-23
|
|
12
12
|
|
|
13
13
|
## 这是什么
|
|
14
14
|
|
package/bin/yotta-memory.js
CHANGED
|
@@ -1,608 +1,606 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// yotta-memory(元忆): 有权限边界的文件式智能体记忆 CLI(零依赖)
|
|
3
|
-
// v0.2 增强:① index.json 索引+TF 打分 ② 质量分+盖棺分(access_count/last_accessed)③ 读取分区(scope/owner)
|
|
4
|
-
'use strict';
|
|
5
|
-
|
|
6
|
-
const fs = require('fs');
|
|
7
|
-
const path = require('path');
|
|
8
|
-
const os = require('os');
|
|
9
|
-
|
|
10
|
-
const VERSION = '0.2.
|
|
11
|
-
const TYPES = ['FACT', 'PREF', 'BOUND', 'COMMIT'];
|
|
12
|
-
const TYPE_DIRS = { FACT: 'facts', PREF: 'prefs', BOUND: 'bounds', COMMIT: 'commits' };
|
|
13
|
-
const ARCHIVE_DIR = '.archive';
|
|
14
|
-
const INDEX_FILE = 'index.json';
|
|
15
|
-
const PRIVATE_TYPES = ['PREF', 'BOUND', 'COMMIT'];
|
|
16
|
-
const FIELD_ORDER = ['type', 'subject', 'statement', 'confidence', 'created', 'updated', 'tags', 'immutable', 'scope', 'owner', 'access_count', 'last_accessed'];
|
|
17
|
-
|
|
18
|
-
function userRoot() {
|
|
19
|
-
return process.env.YOTTA_MEMORY_HOME || path.join(os.homedir(), '.yottamemory');
|
|
20
|
-
}
|
|
21
|
-
function projectRoot() {
|
|
22
|
-
return path.join(process.cwd(), '.yottamemory');
|
|
23
|
-
}
|
|
24
|
-
function today() {
|
|
25
|
-
const d = new Date();
|
|
26
|
-
const p = (n) => String(n).padStart(2, '0');
|
|
27
|
-
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
|
|
28
|
-
}
|
|
29
|
-
function currentAgent() {
|
|
30
|
-
return process.env.YOTTA_AGENT_ID || process.env.AGENT_ID || '';
|
|
31
|
-
}
|
|
32
|
-
function typeDir(type) {
|
|
33
|
-
const t = String(type).toUpperCase();
|
|
34
|
-
if (!TYPE_DIRS[t]) {
|
|
35
|
-
console.error('未知记忆类型: ' + type + '(可用: ' + TYPES.join(' / ') + ')');
|
|
36
|
-
process.exit(2);
|
|
37
|
-
}
|
|
38
|
-
return TYPE_DIRS[t];
|
|
39
|
-
}
|
|
40
|
-
function defaultScope(type) {
|
|
41
|
-
return PRIVATE_TYPES.indexOf(String(type).toUpperCase()) === -1 ? 'public' : 'private';
|
|
42
|
-
}
|
|
43
|
-
function parseFrontmatter(text) {
|
|
44
|
-
const m = text.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
45
|
-
if (!m) return { meta: {}, body: text };
|
|
46
|
-
const meta = {};
|
|
47
|
-
for (const line of m[1].split('\n')) {
|
|
48
|
-
const i = line.indexOf(':');
|
|
49
|
-
if (i === -1) continue;
|
|
50
|
-
let k = line.slice(0, i).trim();
|
|
51
|
-
let v = line.slice(i + 1).trim();
|
|
52
|
-
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
53
|
-
v = v.slice(1, -1);
|
|
54
|
-
}
|
|
55
|
-
meta[k] = v;
|
|
56
|
-
}
|
|
57
|
-
return { meta: meta, body: text.slice(m[0][0].length + m[0].length) };
|
|
58
|
-
}
|
|
59
|
-
function escapeYaml(v) {
|
|
60
|
-
return String(v).replace(/\n/g, ' ').replace(/"/g, '\\"');
|
|
61
|
-
}
|
|
62
|
-
function parseTags(v) {
|
|
63
|
-
if (Array.isArray(v)) return v.map(String);
|
|
64
|
-
if (typeof v === 'string') {
|
|
65
|
-
let s = v.trim();
|
|
66
|
-
if (s.startsWith('[') && s.endsWith(']')) s = s.slice(1, -1);
|
|
67
|
-
return s.split(',').map(function (x) { return x.trim(); }).filter(Boolean);
|
|
68
|
-
}
|
|
69
|
-
return [];
|
|
70
|
-
}
|
|
71
|
-
function tokenize(s) {
|
|
72
|
-
s = String(s || '').toLowerCase();
|
|
73
|
-
const toks = [];
|
|
74
|
-
const latin = s.match(/[a-z0-9_]+/g);
|
|
75
|
-
if (latin) for (const t of latin) toks.push(t);
|
|
76
|
-
const han = s.match(/[\u4e00-\u9fa5]+/g);
|
|
77
|
-
if (han) {
|
|
78
|
-
for (const seg of han) {
|
|
79
|
-
if (seg.length === 1) toks.push(seg);
|
|
80
|
-
else for (let i = 0; i < seg.length - 1; i++) toks.push(seg.slice(i, i + 2));
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return toks;
|
|
84
|
-
}
|
|
85
|
-
function buildTokens(subject, statement, tags) {
|
|
86
|
-
const toks = tokenize(subject + ' ' + statement + ' ' + tags.join(' '));
|
|
87
|
-
const m = {};
|
|
88
|
-
for (const t of toks) m[t] = (m[t] || 0) + 1;
|
|
89
|
-
return m;
|
|
90
|
-
}
|
|
91
|
-
function frontmatterToText(meta, body) {
|
|
92
|
-
const lines = ['---'];
|
|
93
|
-
const ordered = FIELD_ORDER.filter(function (k) { return meta[k] !== undefined && meta[k] !== null && meta[k] !== ''; });
|
|
94
|
-
const extra = Object.keys(meta).filter(function (k) { return FIELD_ORDER.indexOf(k) === -1 && meta[k] !== undefined && meta[k] !== null && meta[k] !== ''; });
|
|
95
|
-
for (const k of ordered.concat(extra)) {
|
|
96
|
-
const v = meta[k];
|
|
97
|
-
if (Array.isArray(v)) lines.push(k + ': ' + JSON.stringify(v));
|
|
98
|
-
else if (typeof v === 'number') lines.push(k + ': ' + v);
|
|
99
|
-
else lines.push(k + ': ' + escapeYaml(v));
|
|
100
|
-
}
|
|
101
|
-
lines.push('---', '');
|
|
102
|
-
const b = body === undefined || body === null ? '' : body;
|
|
103
|
-
return lines.join('\n') + String(b).replace(/^\n+/, '') + '\n';
|
|
104
|
-
}
|
|
105
|
-
function ensureInit(root) {
|
|
106
|
-
fs.mkdirSync(path.join(root, 'facts'), { recursive: true });
|
|
107
|
-
fs.mkdirSync(path.join(root, 'prefs'), { recursive: true });
|
|
108
|
-
fs.mkdirSync(path.join(root, 'bounds'), { recursive: true });
|
|
109
|
-
fs.mkdirSync(path.join(root, 'commits'), { recursive: true });
|
|
110
|
-
fs.mkdirSync(path.join(root, ARCHIVE_DIR), { recursive: true });
|
|
111
|
-
const readme = path.join(root, 'README.md');
|
|
112
|
-
if (!fs.existsSync(readme)) {
|
|
113
|
-
fs.writeFileSync(readme, '# yotta-memory(元忆)记忆库\n\n有权限边界的文件式智能体记忆存储目录。结构:facts/ prefs/ bounds/ commits/ .archive/。\n', 'utf8');
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
function nextSeq(dir) {
|
|
117
|
-
let max = 0;
|
|
118
|
-
if (fs.existsSync(dir)) {
|
|
119
|
-
for (const f of fs.readdirSync(dir)) {
|
|
120
|
-
const m = f.match(/^\d{4}-\d{2}-\d{2}-(\d{4})\.md$/);
|
|
121
|
-
if (m) max = Math.max(max, parseInt(m[1], 10));
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
return String(max + 1).padStart(4, '0');
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// ---- 索引(index.json)----
|
|
128
|
-
function indexPath(root) { return path.join(root, INDEX_FILE); }
|
|
129
|
-
function loadIndex(root) {
|
|
130
|
-
const p = indexPath(root);
|
|
131
|
-
if (!fs.existsSync(p)) return null;
|
|
132
|
-
try {
|
|
133
|
-
const d = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
134
|
-
if (d && Array.isArray(d.entries)) return d.entries;
|
|
135
|
-
} catch (e) { /* ignore */ }
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
function getIndex(root) { return loadIndex(root) || []; }
|
|
139
|
-
function saveIndex(root, entries) {
|
|
140
|
-
const clean = entries.map(function (e) { const c = Object.assign({}, e); delete c.meta; return c; });
|
|
141
|
-
fs.writeFileSync(indexPath(root), JSON.stringify({ version: 2, updated: today(), entries: clean }, null, 2), 'utf8');
|
|
142
|
-
}
|
|
143
|
-
function buildIndex(root) {
|
|
144
|
-
const entries = [];
|
|
145
|
-
for (const t of Object.keys(TYPE_DIRS)) {
|
|
146
|
-
const dir = path.join(root, TYPE_DIRS[t]);
|
|
147
|
-
if (!fs.existsSync(dir)) continue;
|
|
148
|
-
for (const f of fs.readdirSync(dir)) {
|
|
149
|
-
const fp = path.join(dir, f);
|
|
150
|
-
if (!fs.statSync(fp).isFile()) continue;
|
|
151
|
-
entries.push(readEntry(fp, root));
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
saveIndex(root, entries);
|
|
155
|
-
return entries;
|
|
156
|
-
}
|
|
157
|
-
function ensureIndex(root) {
|
|
158
|
-
const idx = loadIndex(root);
|
|
159
|
-
if (idx) return idx;
|
|
160
|
-
return buildIndex(root);
|
|
161
|
-
}
|
|
162
|
-
function upsertIndexEntry(root, entry) {
|
|
163
|
-
const idx = getIndex(root);
|
|
164
|
-
const i = idx.findIndex(function (e) { return e.file === entry.file; });
|
|
165
|
-
if (i >= 0) idx[i] = entry; else idx.push(entry);
|
|
166
|
-
saveIndex(root, idx);
|
|
167
|
-
}
|
|
168
|
-
function removeIndexEntry(root, file) {
|
|
169
|
-
const idx = getIndex(root);
|
|
170
|
-
const i = idx.findIndex(function (e) { return e.file === file; });
|
|
171
|
-
if (i >= 0) { idx.splice(i, 1); saveIndex(root, idx); }
|
|
172
|
-
}
|
|
173
|
-
function removeIndexEntries(root, files) {
|
|
174
|
-
const set = new Set(files);
|
|
175
|
-
const idx = getIndex(root);
|
|
176
|
-
const next = idx.filter(function (e) { return !set.has(e.file); });
|
|
177
|
-
if (next.length !== idx.length) saveIndex(root, next);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// ---- 读取 / 写入记忆文件 ----
|
|
181
|
-
function readEntry(fp, root) {
|
|
182
|
-
const parsed = parseFrontmatter(fs.readFileSync(fp, 'utf8'));
|
|
183
|
-
const meta = parsed.meta;
|
|
184
|
-
const type = (meta.type || 'FACT').toUpperCase();
|
|
185
|
-
const subject = meta.subject || '';
|
|
186
|
-
const statement = meta.statement || '';
|
|
187
|
-
const tags = parseTags(meta.tags);
|
|
188
|
-
return {
|
|
189
|
-
file: path.relative(root, fp).replace(/\\/g, '/'),
|
|
190
|
-
type: type,
|
|
191
|
-
scope: meta.scope || defaultScope(type),
|
|
192
|
-
owner: meta.owner || '',
|
|
193
|
-
subject: subject,
|
|
194
|
-
statement: statement,
|
|
195
|
-
tags: tags,
|
|
196
|
-
confidence: parseFloat(meta.confidence || 1.0),
|
|
197
|
-
created: meta.created || '',
|
|
198
|
-
updated: meta.updated || '',
|
|
199
|
-
access_count: parseInt(meta.access_count || '0', 10) || 0,
|
|
200
|
-
last_accessed: meta.last_accessed || '',
|
|
201
|
-
immutable: meta.immutable === 'true',
|
|
202
|
-
tokens: buildTokens(subject, statement, tags),
|
|
203
|
-
meta: meta,
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
function rewriteFrontmatter(fp, patch) {
|
|
207
|
-
const txt = fs.readFileSync(fp, 'utf8');
|
|
208
|
-
const parsed = parseFrontmatter(txt);
|
|
209
|
-
const meta = Object.assign({}, parsed.meta);
|
|
210
|
-
for (const k of Object.keys(patch)) meta[k] = patch[k];
|
|
211
|
-
fs.writeFileSync(fp, frontmatterToText(meta, parsed.body), 'utf8');
|
|
212
|
-
}
|
|
213
|
-
function bumpReadMeta(root, relFiles) {
|
|
214
|
-
const now = today();
|
|
215
|
-
for (const rel of relFiles) {
|
|
216
|
-
const fp = path.join(root, rel);
|
|
217
|
-
if (!fs.existsSync(fp)) continue;
|
|
218
|
-
const parsed = parseFrontmatter(fs.readFileSync(fp, 'utf8'));
|
|
219
|
-
const meta = parsed.meta;
|
|
220
|
-
if (meta.immutable === 'true') continue;
|
|
221
|
-
const acc = (parseInt(meta.access_count || '0', 10) || 0) + 1;
|
|
222
|
-
rewriteFrontmatter(fp, { access_count: acc, last_accessed: now });
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
function touchIndex(root, relFiles) {
|
|
226
|
-
const set = new Set(relFiles);
|
|
227
|
-
const idx = getIndex(root);
|
|
228
|
-
let dirty = false;
|
|
229
|
-
const now = today();
|
|
230
|
-
for (const e of idx) {
|
|
231
|
-
if (set.has(e.file)) { e.access_count = (parseInt(e.access_count, 10) || 0) + 1; e.last_accessed = now; dirty = true; }
|
|
232
|
-
}
|
|
233
|
-
if (dirty) saveIndex(root, idx);
|
|
234
|
-
}
|
|
235
|
-
function daysBetween(a, b) {
|
|
236
|
-
const da = new Date(a);
|
|
237
|
-
const db = new Date(b);
|
|
238
|
-
if (isNaN(da.getTime()) || isNaN(db.getTime())) return 99999;
|
|
239
|
-
return Math.round((db.getTime() - da.getTime()) / (24 * 60 * 60 * 1000));
|
|
240
|
-
}
|
|
241
|
-
function vitality(meta) {
|
|
242
|
-
const conf = parseFloat(meta.confidence || 1.0);
|
|
243
|
-
const acc = parseInt(meta.access_count || '0', 10) || 0;
|
|
244
|
-
const last = meta.last_accessed || '';
|
|
245
|
-
const accScore = Math.min(acc, 10) / 10;
|
|
246
|
-
let recency = 0;
|
|
247
|
-
if (last) { const d = daysBetween(last, today()); recency = Math.max(0, 1 - Math.floor(d / 30) * 0.2); }
|
|
248
|
-
return 0.4 * conf + 0.3 * accScore + 0.3 * recency;
|
|
249
|
-
}
|
|
250
|
-
function loadGrants(root) {
|
|
251
|
-
const fp = path.join(root, 'grants.json');
|
|
252
|
-
try {
|
|
253
|
-
return JSON.parse(fs.readFileSync(fp, 'utf8')) || {};
|
|
254
|
-
} catch (e) {
|
|
255
|
-
return {};
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
function hasGrant(userAgent, ownerAgent) {
|
|
259
|
-
if (!userAgent || !ownerAgent) return false;
|
|
260
|
-
for (const root of [projectRoot(), userRoot()]) {
|
|
261
|
-
if (!fs.existsSync(root)) continue;
|
|
262
|
-
const grants = loadGrants(root);
|
|
263
|
-
const list = grants[userAgent];
|
|
264
|
-
if (Array.isArray(list) && list.indexOf(ownerAgent) !== -1) return true;
|
|
265
|
-
}
|
|
266
|
-
return false;
|
|
267
|
-
}
|
|
268
|
-
// 三态读取判定:'read' | 'denied'
|
|
269
|
-
function classifyRead(entry, agent, ownerFilter, unsafe) {
|
|
270
|
-
if (entry.scope === 'public') return 'read';
|
|
271
|
-
const owner = entry.owner || '';
|
|
272
|
-
// 无归属的 private:所有 agent 视为自身私密(默认行为保留)
|
|
273
|
-
if (!owner) return 'read';
|
|
274
|
-
// 归属等于当前 agent 或(显式 --owner 等于当前 agent):自身授权
|
|
275
|
-
if (agent && owner === agent) return 'read';
|
|
276
|
-
if (ownerFilter && owner === ownerFilter && agent && ownerFilter === agent) return 'read';
|
|
277
|
-
// 越界读:需要 --unsafe / identity=user / grant 授权,否则拒绝
|
|
278
|
-
if (unsafe || String(ownerFilter).toLowerCase() === 'user' || String(agent && '' + agent).toLowerCase() === 'user') return 'read';
|
|
279
|
-
if (hasGrant(agent, owner)) return 'read';
|
|
280
|
-
return 'denied';
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// ---- 命令 ----
|
|
284
|
-
function cmdInit(opts) {
|
|
285
|
-
const root = opts.project ? projectRoot() : userRoot();
|
|
286
|
-
ensureInit(root);
|
|
287
|
-
console.log('已初始化记忆库: ' + root);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function cmdRemember(type, subject, statement, opts) {
|
|
291
|
-
const root = userRoot();
|
|
292
|
-
ensureInit(root);
|
|
293
|
-
const t = String(type).toUpperCase();
|
|
294
|
-
const dir = path.join(root, typeDir(t));
|
|
295
|
-
const stmt = String(statement || '').trim();
|
|
296
|
-
const subj = String(subject || '').trim();
|
|
297
|
-
if (!stmt) { console.error('statement 不能为空'); process.exit(2); }
|
|
298
|
-
if (!subj) { console.error('subject 不能为空'); process.exit(2); }
|
|
299
|
-
const owner = opts.owner || currentAgent();
|
|
300
|
-
const scope = opts.scope || defaultScope(t);
|
|
301
|
-
if (fs.existsSync(dir)) {
|
|
302
|
-
for (const f of fs.readdirSync(dir)) {
|
|
303
|
-
const fp = path.join(dir, f);
|
|
304
|
-
if (!fs.statSync(fp).isFile()) continue;
|
|
305
|
-
const parsed = parseFrontmatter(fs.readFileSync(fp, 'utf8'));
|
|
306
|
-
const meta = parsed.meta;
|
|
307
|
-
if ((meta.type || '').toUpperCase() === t && meta.subject === subj && meta.statement === stmt) {
|
|
308
|
-
const patch = { updated: today() };
|
|
309
|
-
if (owner && !meta.owner) patch.owner = owner;
|
|
310
|
-
if (scope && !meta.scope) patch.scope = scope;
|
|
311
|
-
rewriteFrontmatter(fp, patch);
|
|
312
|
-
upsertIndexEntry(root, readEntry(fp, root));
|
|
313
|
-
console.log('已更新: ' + fp);
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
const seq = nextSeq(dir);
|
|
319
|
-
const file = path.join(dir, today() + '-' + seq + '.md');
|
|
320
|
-
const rec = {
|
|
321
|
-
type: t, subject: subj, statement: stmt,
|
|
322
|
-
confidence: 1.0, created: today(), updated: today(),
|
|
323
|
-
tags: [], immutable: false,
|
|
324
|
-
scope: scope, owner: owner, access_count: 0, last_accessed: '',
|
|
325
|
-
};
|
|
326
|
-
fs.writeFileSync(file, frontmatterToText(rec, stmt), 'utf8');
|
|
327
|
-
upsertIndexEntry(root, readEntry(file, root));
|
|
328
|
-
console.log('已记录: ' + file);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function cmdRecall(query, opts) {
|
|
332
|
-
const roots = [projectRoot(), userRoot()].filter(function (r) { return fs.existsSync(r); });
|
|
333
|
-
if (!roots.length) {
|
|
334
|
-
console.log('记忆库不存在,请先运行: yotta-memory init');
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
const limit = opts.limit || 50;
|
|
338
|
-
const onlyType = opts.type ? String(opts.type).toUpperCase() : null;
|
|
339
|
-
const q = query ? String(query).toLowerCase() : '';
|
|
340
|
-
const agent = opts.agent || currentAgent();
|
|
341
|
-
const ownerFilter = opts.owner || '';
|
|
342
|
-
const allSafe = !!opts.unsafe;
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if (
|
|
351
|
-
|
|
352
|
-
if (
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const
|
|
404
|
-
const
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const
|
|
412
|
-
if (
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
const
|
|
423
|
-
if (
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
if (
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
const
|
|
452
|
-
if (
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
if (
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
if (!
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
'
|
|
543
|
-
'',
|
|
544
|
-
'
|
|
545
|
-
' yotta-memory
|
|
546
|
-
' yotta-memory
|
|
547
|
-
' yotta-memory
|
|
548
|
-
' yotta-memory
|
|
549
|
-
' yotta-memory
|
|
550
|
-
' yotta-memory
|
|
551
|
-
' yotta-memory
|
|
552
|
-
'
|
|
553
|
-
'
|
|
554
|
-
'',
|
|
555
|
-
'
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
const
|
|
563
|
-
if (
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
if (a === '--
|
|
573
|
-
else if (a
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
if (a === '--
|
|
578
|
-
else if (a === '--
|
|
579
|
-
else if (a === '--
|
|
580
|
-
else if (a === '--
|
|
581
|
-
else if (a === '--
|
|
582
|
-
else if (a === '--
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
case '
|
|
594
|
-
case '
|
|
595
|
-
case '
|
|
596
|
-
case '
|
|
597
|
-
case '
|
|
598
|
-
case '
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
}
|
|
607
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// yotta-memory(元忆): 有权限边界的文件式智能体记忆 CLI(零依赖)
|
|
3
|
+
// v0.2 增强:① index.json 索引+TF 打分 ② 质量分+盖棺分(access_count/last_accessed)③ 读取分区(scope/owner)
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
|
|
10
|
+
const VERSION = '0.2.5';
|
|
11
|
+
const TYPES = ['FACT', 'PREF', 'BOUND', 'COMMIT'];
|
|
12
|
+
const TYPE_DIRS = { FACT: 'facts', PREF: 'prefs', BOUND: 'bounds', COMMIT: 'commits' };
|
|
13
|
+
const ARCHIVE_DIR = '.archive';
|
|
14
|
+
const INDEX_FILE = 'index.json';
|
|
15
|
+
const PRIVATE_TYPES = ['PREF', 'BOUND', 'COMMIT'];
|
|
16
|
+
const FIELD_ORDER = ['type', 'subject', 'statement', 'confidence', 'created', 'updated', 'tags', 'immutable', 'scope', 'owner', 'access_count', 'last_accessed'];
|
|
17
|
+
|
|
18
|
+
function userRoot() {
|
|
19
|
+
return process.env.YOTTA_MEMORY_HOME || path.join(os.homedir(), '.yottamemory');
|
|
20
|
+
}
|
|
21
|
+
function projectRoot() {
|
|
22
|
+
return path.join(process.cwd(), '.yottamemory');
|
|
23
|
+
}
|
|
24
|
+
function today() {
|
|
25
|
+
const d = new Date();
|
|
26
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
27
|
+
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
|
|
28
|
+
}
|
|
29
|
+
function currentAgent() {
|
|
30
|
+
return process.env.YOTTA_AGENT_ID || process.env.AGENT_ID || '';
|
|
31
|
+
}
|
|
32
|
+
function typeDir(type) {
|
|
33
|
+
const t = String(type).toUpperCase();
|
|
34
|
+
if (!TYPE_DIRS[t]) {
|
|
35
|
+
console.error('未知记忆类型: ' + type + '(可用: ' + TYPES.join(' / ') + ')');
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
return TYPE_DIRS[t];
|
|
39
|
+
}
|
|
40
|
+
function defaultScope(type) {
|
|
41
|
+
return PRIVATE_TYPES.indexOf(String(type).toUpperCase()) === -1 ? 'public' : 'private';
|
|
42
|
+
}
|
|
43
|
+
function parseFrontmatter(text) {
|
|
44
|
+
const m = text.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
45
|
+
if (!m) return { meta: {}, body: text };
|
|
46
|
+
const meta = {};
|
|
47
|
+
for (const line of m[1].split('\n')) {
|
|
48
|
+
const i = line.indexOf(':');
|
|
49
|
+
if (i === -1) continue;
|
|
50
|
+
let k = line.slice(0, i).trim();
|
|
51
|
+
let v = line.slice(i + 1).trim();
|
|
52
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
53
|
+
v = v.slice(1, -1);
|
|
54
|
+
}
|
|
55
|
+
meta[k] = v;
|
|
56
|
+
}
|
|
57
|
+
return { meta: meta, body: text.slice(m[0][0].length + m[0].length) };
|
|
58
|
+
}
|
|
59
|
+
function escapeYaml(v) {
|
|
60
|
+
return String(v).replace(/\n/g, ' ').replace(/"/g, '\\"');
|
|
61
|
+
}
|
|
62
|
+
function parseTags(v) {
|
|
63
|
+
if (Array.isArray(v)) return v.map(String);
|
|
64
|
+
if (typeof v === 'string') {
|
|
65
|
+
let s = v.trim();
|
|
66
|
+
if (s.startsWith('[') && s.endsWith(']')) s = s.slice(1, -1);
|
|
67
|
+
return s.split(',').map(function (x) { return x.trim(); }).filter(Boolean);
|
|
68
|
+
}
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
function tokenize(s) {
|
|
72
|
+
s = String(s || '').toLowerCase();
|
|
73
|
+
const toks = [];
|
|
74
|
+
const latin = s.match(/[a-z0-9_]+/g);
|
|
75
|
+
if (latin) for (const t of latin) toks.push(t);
|
|
76
|
+
const han = s.match(/[\u4e00-\u9fa5]+/g);
|
|
77
|
+
if (han) {
|
|
78
|
+
for (const seg of han) {
|
|
79
|
+
if (seg.length === 1) toks.push(seg);
|
|
80
|
+
else for (let i = 0; i < seg.length - 1; i++) toks.push(seg.slice(i, i + 2));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return toks;
|
|
84
|
+
}
|
|
85
|
+
function buildTokens(subject, statement, tags) {
|
|
86
|
+
const toks = tokenize(subject + ' ' + statement + ' ' + tags.join(' '));
|
|
87
|
+
const m = {};
|
|
88
|
+
for (const t of toks) m[t] = (m[t] || 0) + 1;
|
|
89
|
+
return m;
|
|
90
|
+
}
|
|
91
|
+
function frontmatterToText(meta, body) {
|
|
92
|
+
const lines = ['---'];
|
|
93
|
+
const ordered = FIELD_ORDER.filter(function (k) { return meta[k] !== undefined && meta[k] !== null && meta[k] !== ''; });
|
|
94
|
+
const extra = Object.keys(meta).filter(function (k) { return FIELD_ORDER.indexOf(k) === -1 && meta[k] !== undefined && meta[k] !== null && meta[k] !== ''; });
|
|
95
|
+
for (const k of ordered.concat(extra)) {
|
|
96
|
+
const v = meta[k];
|
|
97
|
+
if (Array.isArray(v)) lines.push(k + ': ' + JSON.stringify(v));
|
|
98
|
+
else if (typeof v === 'number') lines.push(k + ': ' + v);
|
|
99
|
+
else lines.push(k + ': ' + escapeYaml(v));
|
|
100
|
+
}
|
|
101
|
+
lines.push('---', '');
|
|
102
|
+
const b = body === undefined || body === null ? '' : body;
|
|
103
|
+
return lines.join('\n') + String(b).replace(/^\n+/, '') + '\n';
|
|
104
|
+
}
|
|
105
|
+
function ensureInit(root) {
|
|
106
|
+
fs.mkdirSync(path.join(root, 'facts'), { recursive: true });
|
|
107
|
+
fs.mkdirSync(path.join(root, 'prefs'), { recursive: true });
|
|
108
|
+
fs.mkdirSync(path.join(root, 'bounds'), { recursive: true });
|
|
109
|
+
fs.mkdirSync(path.join(root, 'commits'), { recursive: true });
|
|
110
|
+
fs.mkdirSync(path.join(root, ARCHIVE_DIR), { recursive: true });
|
|
111
|
+
const readme = path.join(root, 'README.md');
|
|
112
|
+
if (!fs.existsSync(readme)) {
|
|
113
|
+
fs.writeFileSync(readme, '# yotta-memory(元忆)记忆库\n\n有权限边界的文件式智能体记忆存储目录。结构:facts/ prefs/ bounds/ commits/ .archive/。\n', 'utf8');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function nextSeq(dir) {
|
|
117
|
+
let max = 0;
|
|
118
|
+
if (fs.existsSync(dir)) {
|
|
119
|
+
for (const f of fs.readdirSync(dir)) {
|
|
120
|
+
const m = f.match(/^\d{4}-\d{2}-\d{2}-(\d{4})\.md$/);
|
|
121
|
+
if (m) max = Math.max(max, parseInt(m[1], 10));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return String(max + 1).padStart(4, '0');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- 索引(index.json)----
|
|
128
|
+
function indexPath(root) { return path.join(root, INDEX_FILE); }
|
|
129
|
+
function loadIndex(root) {
|
|
130
|
+
const p = indexPath(root);
|
|
131
|
+
if (!fs.existsSync(p)) return null;
|
|
132
|
+
try {
|
|
133
|
+
const d = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
134
|
+
if (d && Array.isArray(d.entries)) return d.entries;
|
|
135
|
+
} catch (e) { /* ignore */ }
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
function getIndex(root) { return loadIndex(root) || []; }
|
|
139
|
+
function saveIndex(root, entries) {
|
|
140
|
+
const clean = entries.map(function (e) { const c = Object.assign({}, e); delete c.meta; return c; });
|
|
141
|
+
fs.writeFileSync(indexPath(root), JSON.stringify({ version: 2, updated: today(), entries: clean }, null, 2), 'utf8');
|
|
142
|
+
}
|
|
143
|
+
function buildIndex(root) {
|
|
144
|
+
const entries = [];
|
|
145
|
+
for (const t of Object.keys(TYPE_DIRS)) {
|
|
146
|
+
const dir = path.join(root, TYPE_DIRS[t]);
|
|
147
|
+
if (!fs.existsSync(dir)) continue;
|
|
148
|
+
for (const f of fs.readdirSync(dir)) {
|
|
149
|
+
const fp = path.join(dir, f);
|
|
150
|
+
if (!fs.statSync(fp).isFile()) continue;
|
|
151
|
+
entries.push(readEntry(fp, root));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
saveIndex(root, entries);
|
|
155
|
+
return entries;
|
|
156
|
+
}
|
|
157
|
+
function ensureIndex(root) {
|
|
158
|
+
const idx = loadIndex(root);
|
|
159
|
+
if (idx) return idx;
|
|
160
|
+
return buildIndex(root);
|
|
161
|
+
}
|
|
162
|
+
function upsertIndexEntry(root, entry) {
|
|
163
|
+
const idx = getIndex(root);
|
|
164
|
+
const i = idx.findIndex(function (e) { return e.file === entry.file; });
|
|
165
|
+
if (i >= 0) idx[i] = entry; else idx.push(entry);
|
|
166
|
+
saveIndex(root, idx);
|
|
167
|
+
}
|
|
168
|
+
function removeIndexEntry(root, file) {
|
|
169
|
+
const idx = getIndex(root);
|
|
170
|
+
const i = idx.findIndex(function (e) { return e.file === file; });
|
|
171
|
+
if (i >= 0) { idx.splice(i, 1); saveIndex(root, idx); }
|
|
172
|
+
}
|
|
173
|
+
function removeIndexEntries(root, files) {
|
|
174
|
+
const set = new Set(files);
|
|
175
|
+
const idx = getIndex(root);
|
|
176
|
+
const next = idx.filter(function (e) { return !set.has(e.file); });
|
|
177
|
+
if (next.length !== idx.length) saveIndex(root, next);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---- 读取 / 写入记忆文件 ----
|
|
181
|
+
function readEntry(fp, root) {
|
|
182
|
+
const parsed = parseFrontmatter(fs.readFileSync(fp, 'utf8'));
|
|
183
|
+
const meta = parsed.meta;
|
|
184
|
+
const type = (meta.type || 'FACT').toUpperCase();
|
|
185
|
+
const subject = meta.subject || '';
|
|
186
|
+
const statement = meta.statement || '';
|
|
187
|
+
const tags = parseTags(meta.tags);
|
|
188
|
+
return {
|
|
189
|
+
file: path.relative(root, fp).replace(/\\/g, '/'),
|
|
190
|
+
type: type,
|
|
191
|
+
scope: meta.scope || defaultScope(type),
|
|
192
|
+
owner: meta.owner || '',
|
|
193
|
+
subject: subject,
|
|
194
|
+
statement: statement,
|
|
195
|
+
tags: tags,
|
|
196
|
+
confidence: parseFloat(meta.confidence || 1.0),
|
|
197
|
+
created: meta.created || '',
|
|
198
|
+
updated: meta.updated || '',
|
|
199
|
+
access_count: parseInt(meta.access_count || '0', 10) || 0,
|
|
200
|
+
last_accessed: meta.last_accessed || '',
|
|
201
|
+
immutable: meta.immutable === 'true',
|
|
202
|
+
tokens: buildTokens(subject, statement, tags),
|
|
203
|
+
meta: meta,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function rewriteFrontmatter(fp, patch) {
|
|
207
|
+
const txt = fs.readFileSync(fp, 'utf8');
|
|
208
|
+
const parsed = parseFrontmatter(txt);
|
|
209
|
+
const meta = Object.assign({}, parsed.meta);
|
|
210
|
+
for (const k of Object.keys(patch)) meta[k] = patch[k];
|
|
211
|
+
fs.writeFileSync(fp, frontmatterToText(meta, parsed.body), 'utf8');
|
|
212
|
+
}
|
|
213
|
+
function bumpReadMeta(root, relFiles) {
|
|
214
|
+
const now = today();
|
|
215
|
+
for (const rel of relFiles) {
|
|
216
|
+
const fp = path.join(root, rel);
|
|
217
|
+
if (!fs.existsSync(fp)) continue;
|
|
218
|
+
const parsed = parseFrontmatter(fs.readFileSync(fp, 'utf8'));
|
|
219
|
+
const meta = parsed.meta;
|
|
220
|
+
if (meta.immutable === 'true') continue;
|
|
221
|
+
const acc = (parseInt(meta.access_count || '0', 10) || 0) + 1;
|
|
222
|
+
rewriteFrontmatter(fp, { access_count: acc, last_accessed: now });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function touchIndex(root, relFiles) {
|
|
226
|
+
const set = new Set(relFiles);
|
|
227
|
+
const idx = getIndex(root);
|
|
228
|
+
let dirty = false;
|
|
229
|
+
const now = today();
|
|
230
|
+
for (const e of idx) {
|
|
231
|
+
if (set.has(e.file)) { e.access_count = (parseInt(e.access_count, 10) || 0) + 1; e.last_accessed = now; dirty = true; }
|
|
232
|
+
}
|
|
233
|
+
if (dirty) saveIndex(root, idx);
|
|
234
|
+
}
|
|
235
|
+
function daysBetween(a, b) {
|
|
236
|
+
const da = new Date(a);
|
|
237
|
+
const db = new Date(b);
|
|
238
|
+
if (isNaN(da.getTime()) || isNaN(db.getTime())) return 99999;
|
|
239
|
+
return Math.round((db.getTime() - da.getTime()) / (24 * 60 * 60 * 1000));
|
|
240
|
+
}
|
|
241
|
+
function vitality(meta) {
|
|
242
|
+
const conf = parseFloat(meta.confidence || 1.0);
|
|
243
|
+
const acc = parseInt(meta.access_count || '0', 10) || 0;
|
|
244
|
+
const last = meta.last_accessed || '';
|
|
245
|
+
const accScore = Math.min(acc, 10) / 10;
|
|
246
|
+
let recency = 0;
|
|
247
|
+
if (last) { const d = daysBetween(last, today()); recency = Math.max(0, 1 - Math.floor(d / 30) * 0.2); }
|
|
248
|
+
return 0.4 * conf + 0.3 * accScore + 0.3 * recency;
|
|
249
|
+
}
|
|
250
|
+
function loadGrants(root) {
|
|
251
|
+
const fp = path.join(root, 'grants.json');
|
|
252
|
+
try {
|
|
253
|
+
return JSON.parse(fs.readFileSync(fp, 'utf8')) || {};
|
|
254
|
+
} catch (e) {
|
|
255
|
+
return {};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function hasGrant(userAgent, ownerAgent) {
|
|
259
|
+
if (!userAgent || !ownerAgent) return false;
|
|
260
|
+
for (const root of [projectRoot(), userRoot()]) {
|
|
261
|
+
if (!fs.existsSync(root)) continue;
|
|
262
|
+
const grants = loadGrants(root);
|
|
263
|
+
const list = grants[userAgent];
|
|
264
|
+
if (Array.isArray(list) && list.indexOf(ownerAgent) !== -1) return true;
|
|
265
|
+
}
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
// 三态读取判定:'read' | 'denied'
|
|
269
|
+
function classifyRead(entry, agent, ownerFilter, unsafe) {
|
|
270
|
+
if (entry.scope === 'public') return 'read';
|
|
271
|
+
const owner = entry.owner || '';
|
|
272
|
+
// 无归属的 private:所有 agent 视为自身私密(默认行为保留)
|
|
273
|
+
if (!owner) return 'read';
|
|
274
|
+
// 归属等于当前 agent 或(显式 --owner 等于当前 agent):自身授权
|
|
275
|
+
if (agent && owner === agent) return 'read';
|
|
276
|
+
if (ownerFilter && owner === ownerFilter && agent && ownerFilter === agent) return 'read';
|
|
277
|
+
// 越界读:需要 --unsafe / identity=user / grant 授权,否则拒绝
|
|
278
|
+
if (unsafe || String(ownerFilter).toLowerCase() === 'user' || String(agent && '' + agent).toLowerCase() === 'user') return 'read';
|
|
279
|
+
if (hasGrant(agent, owner)) return 'read';
|
|
280
|
+
return 'denied';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---- 命令 ----
|
|
284
|
+
function cmdInit(opts) {
|
|
285
|
+
const root = opts.project ? projectRoot() : userRoot();
|
|
286
|
+
ensureInit(root);
|
|
287
|
+
console.log('已初始化记忆库: ' + root);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function cmdRemember(type, subject, statement, opts) {
|
|
291
|
+
const root = userRoot();
|
|
292
|
+
ensureInit(root);
|
|
293
|
+
const t = String(type).toUpperCase();
|
|
294
|
+
const dir = path.join(root, typeDir(t));
|
|
295
|
+
const stmt = String(statement || '').trim();
|
|
296
|
+
const subj = String(subject || '').trim();
|
|
297
|
+
if (!stmt) { console.error('statement 不能为空'); process.exit(2); }
|
|
298
|
+
if (!subj) { console.error('subject 不能为空'); process.exit(2); }
|
|
299
|
+
const owner = opts.owner || currentAgent();
|
|
300
|
+
const scope = opts.scope || defaultScope(t);
|
|
301
|
+
if (fs.existsSync(dir)) {
|
|
302
|
+
for (const f of fs.readdirSync(dir)) {
|
|
303
|
+
const fp = path.join(dir, f);
|
|
304
|
+
if (!fs.statSync(fp).isFile()) continue;
|
|
305
|
+
const parsed = parseFrontmatter(fs.readFileSync(fp, 'utf8'));
|
|
306
|
+
const meta = parsed.meta;
|
|
307
|
+
if ((meta.type || '').toUpperCase() === t && meta.subject === subj && meta.statement === stmt) {
|
|
308
|
+
const patch = { updated: today() };
|
|
309
|
+
if (owner && !meta.owner) patch.owner = owner;
|
|
310
|
+
if (scope && !meta.scope) patch.scope = scope;
|
|
311
|
+
rewriteFrontmatter(fp, patch);
|
|
312
|
+
upsertIndexEntry(root, readEntry(fp, root));
|
|
313
|
+
console.log('已更新: ' + fp);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const seq = nextSeq(dir);
|
|
319
|
+
const file = path.join(dir, today() + '-' + seq + '.md');
|
|
320
|
+
const rec = {
|
|
321
|
+
type: t, subject: subj, statement: stmt,
|
|
322
|
+
confidence: 1.0, created: today(), updated: today(),
|
|
323
|
+
tags: [], immutable: false,
|
|
324
|
+
scope: scope, owner: owner, access_count: 0, last_accessed: '',
|
|
325
|
+
};
|
|
326
|
+
fs.writeFileSync(file, frontmatterToText(rec, stmt), 'utf8');
|
|
327
|
+
upsertIndexEntry(root, readEntry(file, root));
|
|
328
|
+
console.log('已记录: ' + file);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function cmdRecall(query, opts) {
|
|
332
|
+
const roots = [projectRoot(), userRoot()].filter(function (r) { return fs.existsSync(r); });
|
|
333
|
+
if (!roots.length) {
|
|
334
|
+
console.log('记忆库不存在,请先运行: yotta-memory init');
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const limit = opts.limit || 50;
|
|
338
|
+
const onlyType = opts.type ? String(opts.type).toUpperCase() : null;
|
|
339
|
+
const q = query ? String(query).toLowerCase() : '';
|
|
340
|
+
const agent = opts.agent || currentAgent();
|
|
341
|
+
const ownerFilter = opts.owner || '';
|
|
342
|
+
const allSafe = !!opts.unsafe;
|
|
343
|
+
const hits = [];
|
|
344
|
+
let deniedCount = 0;
|
|
345
|
+
for (const root of roots) {
|
|
346
|
+
const entries = ensureIndex(root);
|
|
347
|
+
for (const e of entries) {
|
|
348
|
+
if (onlyType && e.type !== onlyType) continue;
|
|
349
|
+
const r = classifyRead(e, agent, ownerFilter, allSafe);
|
|
350
|
+
if (r === 'denied') { deniedCount++; continue; }
|
|
351
|
+
let score = 0;
|
|
352
|
+
if (q) {
|
|
353
|
+
const qtoks = tokenize(q);
|
|
354
|
+
for (const tt of qtoks) { if (e.tokens && e.tokens[tt]) score += e.tokens[tt]; }
|
|
355
|
+
if (score === 0) {
|
|
356
|
+
const hay = ((e.subject || '') + ' ' + (e.statement || '') + ' ' + e.tags.join(' ')).toLowerCase();
|
|
357
|
+
if (hay.indexOf(q) !== -1) score = 1;
|
|
358
|
+
}
|
|
359
|
+
if (score === 0) continue;
|
|
360
|
+
} else {
|
|
361
|
+
score = 1;
|
|
362
|
+
}
|
|
363
|
+
hits.push({ entry: e, score: score, root: root });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
hits.sort(function (a, b) {
|
|
367
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
368
|
+
const pa = a.root === projectRoot() ? 0 : 1;
|
|
369
|
+
const pb = b.root === projectRoot() ? 0 : 1;
|
|
370
|
+
if (pa !== pb) return pa - pb;
|
|
371
|
+
return String(b.entry.created).localeCompare(String(a.entry.created));
|
|
372
|
+
});
|
|
373
|
+
const shown = hits.slice(0, limit);
|
|
374
|
+
if (!shown.length) {
|
|
375
|
+
if (deniedCount > 0) {
|
|
376
|
+
console.log('检测到 ' + deniedCount + ' 条越界访问已被拒绝。');
|
|
377
|
+
console.log('如需读取其它智能体私密记忆,请加 --unsafe(用户显式授权)或 --owner user。');
|
|
378
|
+
process.exit(3);
|
|
379
|
+
}
|
|
380
|
+
console.log('无匹配记忆。');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const touchRel = shown.filter(function (h) { return !h.entry.immutable; }).map(function (h) { return h.entry.file; });
|
|
384
|
+
if (touchRel.length) {
|
|
385
|
+
for (const root of roots) {
|
|
386
|
+
bumpReadMeta(root, touchRel);
|
|
387
|
+
touchIndex(root, touchRel);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
console.log('共 ' + shown.length + ' 条记忆(' + (hits.length > limit ? '前 ' + limit + ' 条' : '全部') + '):');
|
|
391
|
+
for (const h of shown) {
|
|
392
|
+
console.log('[' + h.entry.type + '] ' + h.entry.subject + ': ' + h.entry.statement);
|
|
393
|
+
console.log(' ' + path.join(h.root, h.entry.file));
|
|
394
|
+
}
|
|
395
|
+
if (deniedCount > 0) {
|
|
396
|
+
console.log('\n[警告] 本次检索共拒绝 ' + deniedCount + ' 条越界访问(其它智能体私密记忆,未授权不展示)。如需读取请加 --unsafe 或 --owner user。');
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function cmdForget(fileRef) {
|
|
401
|
+
const roots = [projectRoot(), userRoot()].filter(function (r) { return fs.existsSync(r); });
|
|
402
|
+
const ref = String(fileRef || '').replace(/\\/g, '/');
|
|
403
|
+
const slash = ref.indexOf('/');
|
|
404
|
+
const dynTypeDir = slash === -1 ? '' : ref.slice(0, slash);
|
|
405
|
+
const dynBase = slash === -1 ? ref : ref.slice(slash + 1);
|
|
406
|
+
let target = null, targetRoot = null, targetRel = null;
|
|
407
|
+
for (const root of roots) {
|
|
408
|
+
if (dynTypeDir) {
|
|
409
|
+
const dir = path.join(root, dynTypeDir);
|
|
410
|
+
if (!fs.existsSync(dir)) continue;
|
|
411
|
+
const cand = path.join(dir, dynBase);
|
|
412
|
+
if (fs.existsSync(cand)) {
|
|
413
|
+
target = cand; targetRoot = root;
|
|
414
|
+
targetRel = dynTypeDir + '/' + path.basename(dynBase);
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
for (const t of Object.keys(TYPE_DIRS)) {
|
|
420
|
+
const dir = path.join(root, TYPE_DIRS[t]);
|
|
421
|
+
if (!fs.existsSync(dir)) continue;
|
|
422
|
+
const cand = path.join(dir, dynBase);
|
|
423
|
+
if (fs.existsSync(cand)) {
|
|
424
|
+
target = cand; targetRoot = root;
|
|
425
|
+
targetRel = TYPE_DIRS[t] + '/' + path.basename(dynBase);
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (target) break;
|
|
430
|
+
}
|
|
431
|
+
if (!target) { console.error('未找到记忆文件: ' + fileRef); process.exit(2); }
|
|
432
|
+
fs.unlinkSync(target);
|
|
433
|
+
if (targetRoot) removeIndexEntry(targetRoot, targetRel);
|
|
434
|
+
console.log('已删除: ' + target);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function cmdArchive(opts) {
|
|
438
|
+
const days = opts.days || 180;
|
|
439
|
+
const threshold = (opts.threshold !== undefined && opts.threshold !== null) ? opts.threshold : 0.4;
|
|
440
|
+
const root = userRoot();
|
|
441
|
+
if (!fs.existsSync(root)) { console.log('记忆库不存在。'); return; }
|
|
442
|
+
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
443
|
+
let moved = 0;
|
|
444
|
+
const movedFiles = [];
|
|
445
|
+
for (const t of Object.keys(TYPE_DIRS)) {
|
|
446
|
+
const dir = path.join(root, TYPE_DIRS[t]);
|
|
447
|
+
if (!fs.existsSync(dir)) continue;
|
|
448
|
+
for (const f of fs.readdirSync(dir)) {
|
|
449
|
+
const fp = path.join(dir, f);
|
|
450
|
+
if (!fs.statSync(fp).isFile()) continue;
|
|
451
|
+
const meta = parseFrontmatter(fs.readFileSync(fp, 'utf8')).meta;
|
|
452
|
+
if (meta.immutable === 'true') continue;
|
|
453
|
+
if (!meta.created) continue;
|
|
454
|
+
const createdTs = new Date(meta.created).getTime();
|
|
455
|
+
if (isNaN(createdTs)) continue;
|
|
456
|
+
if (vitality(meta) < threshold && createdTs < cutoff) {
|
|
457
|
+
const destDir = path.join(root, ARCHIVE_DIR, TYPE_DIRS[t]);
|
|
458
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
459
|
+
fs.renameSync(fp, path.join(destDir, f));
|
|
460
|
+
movedFiles.push(TYPE_DIRS[t] + '/' + f);
|
|
461
|
+
moved++;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (movedFiles.length) removeIndexEntries(root, movedFiles);
|
|
466
|
+
console.log('已归档 ' + moved + ' 条旧记忆到 ' + path.join(root, ARCHIVE_DIR));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function cmdReindex() {
|
|
470
|
+
const roots = [projectRoot(), userRoot()].filter(function (r) { return fs.existsSync(r); });
|
|
471
|
+
if (!roots.length) { console.log('记忆库不存在。'); return; }
|
|
472
|
+
for (const root of roots) {
|
|
473
|
+
const n = buildIndex(root).length;
|
|
474
|
+
console.log('已重建索引 ' + root + '(' + n + ' 条)');
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function collectAll(root) {
|
|
479
|
+
const out = [];
|
|
480
|
+
for (const t of Object.keys(TYPE_DIRS)) {
|
|
481
|
+
const dir = path.join(root, TYPE_DIRS[t]);
|
|
482
|
+
if (!fs.existsSync(dir)) continue;
|
|
483
|
+
for (const f of fs.readdirSync(dir)) {
|
|
484
|
+
const fp = path.join(dir, f);
|
|
485
|
+
if (!fs.statSync(fp).isFile()) continue;
|
|
486
|
+
const e = readEntry(fp, root);
|
|
487
|
+
out.push({ file: e.file, meta: e.meta });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return out;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function cmdExport(outPath) {
|
|
494
|
+
const root = userRoot();
|
|
495
|
+
if (!fs.existsSync(root)) { console.log('记忆库不存在。'); return; }
|
|
496
|
+
const data = { exported: today(), version: VERSION, memories: collectAll(root) };
|
|
497
|
+
const target = outPath || 'yottamemory-export-' + today() + '.json';
|
|
498
|
+
fs.writeFileSync(target, JSON.stringify(data, null, 2), 'utf8');
|
|
499
|
+
console.log('已导出 ' + data.memories.length + ' 条记忆 -> ' + target);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function cmdImport(src) {
|
|
503
|
+
if (!src || !fs.existsSync(src)) { console.error('请提供 JSON 文件路径'); process.exit(2); }
|
|
504
|
+
const data = JSON.parse(fs.readFileSync(src, 'utf8'));
|
|
505
|
+
if (!data.memories || !Array.isArray(data.memories)) { console.error('JSON 格式不正确(缺少 memories 数组)'); process.exit(2); }
|
|
506
|
+
const root = userRoot();
|
|
507
|
+
ensureInit(root);
|
|
508
|
+
let n = 0;
|
|
509
|
+
for (const item of data.memories) {
|
|
510
|
+
const meta = item.meta || {};
|
|
511
|
+
const t = (meta.type || 'FACT').toUpperCase();
|
|
512
|
+
if (!TYPE_DIRS[t]) continue;
|
|
513
|
+
const dir = path.join(root, TYPE_DIRS[t]);
|
|
514
|
+
let seq = nextSeq(dir);
|
|
515
|
+
let file = path.join(dir, today() + '-' + seq + '.md');
|
|
516
|
+
while (fs.existsSync(file)) { seq = String(parseInt(seq, 10) + 1).padStart(4, '0'); file = path.join(dir, today() + '-' + seq + '.md'); }
|
|
517
|
+
const rec = {
|
|
518
|
+
type: t,
|
|
519
|
+
subject: meta.subject || '',
|
|
520
|
+
statement: meta.statement || '',
|
|
521
|
+
confidence: parseFloat(meta.confidence || 1.0),
|
|
522
|
+
created: meta.created || today(),
|
|
523
|
+
updated: meta.updated || today(),
|
|
524
|
+
tags: parseTags(meta.tags),
|
|
525
|
+
immutable: meta.immutable === true || meta.immutable === 'true',
|
|
526
|
+
scope: meta.scope || defaultScope(t),
|
|
527
|
+
owner: meta.owner || '',
|
|
528
|
+
access_count: parseInt(meta.access_count || '0', 10) || 0,
|
|
529
|
+
last_accessed: meta.last_accessed || '',
|
|
530
|
+
};
|
|
531
|
+
fs.writeFileSync(file, frontmatterToText(rec, rec.statement), 'utf8');
|
|
532
|
+
upsertIndexEntry(root, readEntry(file, root));
|
|
533
|
+
n++;
|
|
534
|
+
}
|
|
535
|
+
console.log('已导入 ' + n + ' 条记忆 -> ' + root);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function usage() {
|
|
539
|
+
console.log([
|
|
540
|
+
'yotta-memory v' + VERSION + ' — 元忆:有权限边界的文件式智能体记忆',
|
|
541
|
+
'',
|
|
542
|
+
'用法:',
|
|
543
|
+
' yotta-memory init [--project] 初始化记忆库(默认用户级 ~/.yottamemory)',
|
|
544
|
+
' yotta-memory remember <type> <subject> <statement> [--owner <id>] 写入记忆',
|
|
545
|
+
' yotta-memory recall [关键词] [--type T] [--limit N] [--agent <id>] [--owner <id>] [--all] [--unsafe] 检索(索引+TF,读取分区;--unsafe 允许越界读其它智能体私密)',
|
|
546
|
+
' yotta-memory forget <文件或文件名> 删除一条记忆',
|
|
547
|
+
' yotta-memory archive [--days 180] [--threshold 0.4] 归档(盖棺分+年龄)',
|
|
548
|
+
' yotta-memory reindex 重建索引(手动改文件后用)',
|
|
549
|
+
' yotta-memory export [--out 文件.json] 导出全部记忆',
|
|
550
|
+
' yotta-memory import <文件.json> 导入记忆',
|
|
551
|
+
' yotta-memory --version 版本',
|
|
552
|
+
'',
|
|
553
|
+
'类型: FACT(公共共享) / PREF(偏好) / BOUND(边界) / COMMIT(承诺)',
|
|
554
|
+
'环境变量: YOTTA_MEMORY_HOME 覆盖用户级目录; YOTTA_AGENT_ID/AGENT_ID 作为当前 agent 标识(读取分区)',
|
|
555
|
+
'',
|
|
556
|
+
].join('\n'));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function main() {
|
|
560
|
+
const args = process.argv.slice(2);
|
|
561
|
+
if (!args.length) { usage(); return; }
|
|
562
|
+
const first = args[0];
|
|
563
|
+
if (first === '--version' || first === '-v') { console.log(VERSION); return; }
|
|
564
|
+
if (first === '--help' || first === '-h') { usage(); return; }
|
|
565
|
+
const opts = {};
|
|
566
|
+
const positional = [];
|
|
567
|
+
const valueOpts = new Set(['--type', '--limit', '--days', '--out', '--owner', '--agent', '--threshold', '--scope']);
|
|
568
|
+
for (let i = 1; i < args.length; i++) {
|
|
569
|
+
const a = args[i];
|
|
570
|
+
if (a === '--project') opts.project = true;
|
|
571
|
+
else if (a === '--all') opts.all = true;
|
|
572
|
+
else if (a === '--unsafe') opts.unsafe = true;
|
|
573
|
+
else if (valueOpts.has(a)) {
|
|
574
|
+
const v = args[++i];
|
|
575
|
+
if (a === '--type') opts.type = v;
|
|
576
|
+
else if (a === '--limit') opts.limit = parseInt(v, 10) || 50;
|
|
577
|
+
else if (a === '--days') opts.days = parseInt(v, 10) || 180;
|
|
578
|
+
else if (a === '--threshold') opts.threshold = parseFloat(v);
|
|
579
|
+
else if (a === '--out') opts.out = v;
|
|
580
|
+
else if (a === '--owner') opts.owner = v;
|
|
581
|
+
else if (a === '--agent') opts.agent = v;
|
|
582
|
+
else if (a === '--scope') opts.scope = v;
|
|
583
|
+
} else if (a.startsWith('--')) {
|
|
584
|
+
console.error('未知选项: ' + a);
|
|
585
|
+
process.exit(2);
|
|
586
|
+
} else {
|
|
587
|
+
positional.push(a);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
switch (first) {
|
|
591
|
+
case 'init': cmdInit(opts); break;
|
|
592
|
+
case 'remember': cmdRemember(positional[0], positional[1], positional[2], opts); break;
|
|
593
|
+
case 'recall': cmdRecall(positional[0] || null, opts); break;
|
|
594
|
+
case 'forget': cmdForget(positional[0]); break;
|
|
595
|
+
case 'archive': cmdArchive(opts); break;
|
|
596
|
+
case 'reindex': cmdReindex(); break;
|
|
597
|
+
case 'export': cmdExport(opts.out); break;
|
|
598
|
+
case 'import': cmdImport(positional[0]); break;
|
|
599
|
+
default:
|
|
600
|
+
console.error('未知命令: ' + first);
|
|
601
|
+
usage();
|
|
602
|
+
process.exit(2);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
608
606
|
main();
|
package/package.json
CHANGED
package/references/protocol.md
CHANGED