cortico-world-memory 0.1.8 → 0.1.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-memory",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "description": "Cortico World 扩展:五层记忆(事实库/知识库/反思/人格/档案)+ 三级记忆底座 + Hybrid Recall 向量混合检索(移植自 E:/qq_bot 的记忆系统与 memory_server 向量 sidecar,qq_bot 源码零改动)。",
6
6
  "license": "MIT",
package/src/backend.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * SQLite 驱动优先级:先试 `better-sqlite3`(原生,若已安装),缺失则回退 Node 内置
11
11
  * `node:sqlite`(v24+ 自带,零安装)。两者 API 兼容(.prepare().get()/.run()、.exec())。
12
12
  */
13
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
13
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
14
14
  import { dirname, join } from 'node:path';
15
15
 
16
16
  export type BackendKind = 'json' | 'sqlite';
@@ -164,3 +164,69 @@ export async function createBackend(kind: BackendKind, dataDir: string, log?: Lo
164
164
  log?.('[memory] 存储后端=json');
165
165
  return new JsonBackend(dataDir);
166
166
  }
167
+
168
+ /**
169
+ * 只读打开 memory.db,导出 KV 行为 Map<stem, obj>。供「切回 JSON」时回填用。
170
+ * 优先 better-sqlite3,缺失则回退 Node 内置 node:sqlite(本环境走这条)。
171
+ */
172
+ export async function readSqliteData(dataDir: string, stems: readonly string[]): Promise<Map<string, unknown>> {
173
+ const dbFile = join(dataDir, 'memory.db');
174
+ if (!existsSync(dbFile)) return new Map();
175
+ let db: any;
176
+ try {
177
+ const mod: any = await import('better-sqlite3');
178
+ const Database = mod.default ?? mod;
179
+ db = new Database(dbFile, { readonly: true });
180
+ } catch {
181
+ const { DatabaseSync } = await import('node:sqlite');
182
+ db = new DatabaseSync(dbFile, { readOnly: true });
183
+ }
184
+ const out = new Map<string, unknown>();
185
+ try {
186
+ let rows: any[];
187
+ if (stems.length) {
188
+ const ph = stems.map(() => '?').join(',');
189
+ rows = db.prepare(`SELECT name, data FROM kv WHERE name IN (${ph})`).all(...stems);
190
+ } else {
191
+ rows = db.prepare('SELECT name, data FROM kv').all();
192
+ }
193
+ for (const r of rows) {
194
+ try { out.set(r.name, JSON.parse(r.data)); } catch { /* 跳过损坏行 */ }
195
+ }
196
+ } finally {
197
+ try { db.close(); } catch { /* ignore */ }
198
+ }
199
+ return out;
200
+ }
201
+
202
+ /**
203
+ * 切回 JSON 时,若 memory.db 存在且其中数据比对应 JSON 文件更新,回填到 JSON 文件,
204
+ * 消除 json↔sqlite 切换的数据不对称(此前 sqlite→json 会丢 SQLite 期间的写入)。
205
+ * 返回回填的 store 数量。仅当 JSON 缺文件或比 DB 更旧时才覆盖,避免冲掉用户在 JSON 上的后续手动编辑。
206
+ */
207
+ export async function backfillSqliteToJson(dataDir: string, log?: LogFn): Promise<number> {
208
+ const dbFile = join(dataDir, 'memory.db');
209
+ if (!existsSync(dbFile)) return 0;
210
+ let fromSqlite: Map<string, unknown>;
211
+ try {
212
+ fromSqlite = await readSqliteData(dataDir, ALL_STEMS);
213
+ } catch (e: any) {
214
+ log?.(`[memory] 读 SQLite 失败,跳过回填: ${e?.message ?? e}`);
215
+ return 0;
216
+ }
217
+ if (!fromSqlite.size) return 0;
218
+ const json = new JsonBackend(dataDir);
219
+ let filled = 0;
220
+ const dbMtime = statSync(dbFile).mtimeMs;
221
+ for (const stem of ALL_STEMS) {
222
+ if (!fromSqlite.has(stem)) continue;
223
+ const jsonFile = join(dataDir, stem + '.json');
224
+ const jsonMtime = existsSync(jsonFile) ? statSync(jsonFile).mtimeMs : 0;
225
+ if (jsonMtime === 0 || dbMtime > jsonMtime) {
226
+ json.writeJson(stem, fromSqlite.get(stem));
227
+ filled++;
228
+ }
229
+ }
230
+ if (filled) log?.(`[memory] 已从 SQLite 回填 ${filled} 个 store 到 JSON(不丢切换期间写入)`);
231
+ return filled;
232
+ }
package/src/config.ts CHANGED
@@ -98,7 +98,7 @@ export const MEMORY_CONFIG_GROUP: ConfigGroup = {
98
98
  'worlds.memory.embeddingTimeoutMs': { type: 'number', title: 'Sidecar 超时(ms)', description: 'qq_bot 向量模型首载慢,默认 3 分钟。' },
99
99
  'worlds.memory.storageBackend': {
100
100
  type: 'string', title: '存储后端', enum: ['json', 'sqlite'], 'x-hot': false,
101
- description: 'json=原生 JSON 文件(默认);sqlite=单库 KV(自动 better-sqlite3→node:sqlite 回退)。切 sqlite 首次自动迁移旧 JSON。改后须点「重启」生效。',
101
+ description: 'json=原生 JSON 文件(默认);sqlite=单库 KV(自动 better-sqlite3→node:sqlite 回退)。切 sqlite 首次自动迁移旧 JSON;切回 json 若 memory.db 数据更新会自动回填,不丢切换期间写入。改后须点「重启」生效。',
102
102
  },
103
103
  },
104
104
  },
package/src/world.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import type { World, WorldHost, ToolDef, ToolTag, EventEnvelope } from 'cortico/core/types.ts';
4
4
  import type { WorldContext } from 'cortico/world.ts';
@@ -12,11 +12,124 @@ import { KnowledgeStore } from './knowledge-store.ts';
12
12
  import { ReflectionStore } from './reflection-store.ts';
13
13
  import { PersonaStore } from './persona-store.ts';
14
14
  import { ProfileStore } from './profile-store.ts';
15
- import { createBackend, StoreBackend, ALL_STEMS } from './backend.ts';
15
+ import { createBackend, StoreBackend, ALL_STEMS, backfillSqliteToJson } from './backend.ts';
16
16
  import { buildEventText, buildView, promptVars } from './observe.ts';
17
17
 
18
18
  const ENV_PROMPT_FILE = fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url));
19
19
 
20
+ type _Dict = Record<string, any>;
21
+
22
+ /** 仅保留对象中指定 key 的条目(用于按 scope 过滤)。 */
23
+ function pickScope(obj: _Dict, key: string): _Dict {
24
+ if (obj && typeof obj === 'object' && key in obj) return { [key]: obj[key] };
25
+ return {};
26
+ }
27
+
28
+ /** 把导出 bundle 渲染为易读 Markdown。 */
29
+ function renderMemoryMarkdown(b: _Dict): string {
30
+ const L: string[] = [];
31
+ const profiles = (b.profiles ?? {}) as _Dict;
32
+ const profKeys = Object.keys(profiles);
33
+ const store = (b.memory?.store ?? {}) as _Dict;
34
+ const storeKeys = Object.keys(store);
35
+ const summary = (b.memory?.summary ?? {}) as _Dict;
36
+ const topic = (b.memory?.topic ?? {}) as _Dict;
37
+ const notes = (b.memory?.notes ?? {}) as _Dict;
38
+ const noteKeys = Object.keys(notes);
39
+ const factScopes = (b.facts?.scopes ?? {}) as _Dict;
40
+ const factKeys = Object.keys(factScopes);
41
+ const knowledge = (b.knowledge?.entries ?? []) as any[];
42
+ const reflections = (b.reflection?.reflections ?? []) as any[];
43
+ const rules = (b.reflection?.rules ?? []) as any[];
44
+ const personas = (b.persona?.personas ?? {}) as _Dict;
45
+ const personaKeys = Object.keys(personas);
46
+ const vectors = (b.vectors?.vectors ?? []) as any[];
47
+
48
+ L.push('# 记忆导出');
49
+ L.push('');
50
+ L.push(`- 导出时间:${b.exportedAt ?? ''}`);
51
+ L.push(`- 作用域:${b.scope ?? 'ALL'}`);
52
+ L.push(`- 存储后端:${b.backend ?? 'unknown'}`);
53
+ L.push('');
54
+
55
+ if (profKeys.length) {
56
+ L.push(`## 人物档案 (${profKeys.length})`);
57
+ for (const u of profKeys) {
58
+ L.push(`### ${u}`);
59
+ for (const f of profiles[u]?.facts ?? []) L.push(`- ${f}`);
60
+ }
61
+ L.push('');
62
+ }
63
+ if (storeKeys.length) {
64
+ L.push(`## 短期记忆 (${storeKeys.length} scope)`);
65
+ for (const s of storeKeys) {
66
+ L.push(`### scope: ${s}`);
67
+ for (const it of store[s] ?? []) L.push(`- [${it.role ?? ''}] ${it.content ?? ''}`);
68
+ }
69
+ L.push('');
70
+ }
71
+ const sumKeys = Object.keys(summary);
72
+ if (sumKeys.length || Object.keys(topic).length) {
73
+ L.push('## 总结与主题');
74
+ for (const s of sumKeys) { L.push(`### ${s} 总结`); L.push(summary[s] ?? ''); }
75
+ for (const s of Object.keys(topic)) { L.push(`### ${s} 主题`); L.push(topic[s] ?? ''); }
76
+ L.push('');
77
+ }
78
+ if (noteKeys.length) {
79
+ L.push(`## 笔记 (${noteKeys.length})`);
80
+ for (const s of noteKeys) {
81
+ L.push(`### ${s}`);
82
+ for (const n of notes[s] ?? []) L.push(`- ${n.text ?? ''}`);
83
+ }
84
+ L.push('');
85
+ }
86
+ if (factKeys.length) {
87
+ L.push(`## 事实库 (${factKeys.length} scope)`);
88
+ for (const s of factKeys) {
89
+ L.push(`### ${s}`);
90
+ for (const f of factScopes[s] ?? []) L.push(`- ${f.text ?? ''}`);
91
+ }
92
+ L.push('');
93
+ }
94
+ if (knowledge.length) {
95
+ L.push(`## 知识库 (${knowledge.length})`);
96
+ for (const e of knowledge) {
97
+ L.push(`### ${e.topic ?? ''}`);
98
+ if (e.keywords?.length) L.push(`关键词:${e.keywords.join(', ')}`);
99
+ for (const f of e.facts ?? []) L.push(`- ${f}`);
100
+ }
101
+ L.push('');
102
+ }
103
+ if (reflections.length || rules.length) {
104
+ L.push(`## 反思与交互规则 (${reflections.length}/${rules.length})`);
105
+ for (const r of reflections) L.push(`- [${r.type}] (${r.userId}) ${r.content ?? ''}`);
106
+ if (rules.length) {
107
+ L.push('### 交互规则');
108
+ for (const r of rules) L.push(`- (${r.userId}) ${r.rule ?? ''}`);
109
+ }
110
+ L.push('');
111
+ }
112
+ if (personaKeys.length || b.persona?.globalStyle) {
113
+ L.push(`## 人格记忆 (${personaKeys.length})`);
114
+ if (b.persona?.globalStyle) L.push(`全局风格:${b.persona.globalStyle}`);
115
+ for (const u of personaKeys) {
116
+ L.push(`### ${u}`);
117
+ L.push(`风格:${personas[u]?.style ?? ''}`);
118
+ for (const t of personas[u]?.traits ?? []) L.push(`- ${t}`);
119
+ }
120
+ L.push('');
121
+ }
122
+ if (vectors.length) {
123
+ L.push(`## 向量记忆 (${vectors.length}, 模型:${b.vectors?.model ?? '未知'})`);
124
+ for (const v of vectors) {
125
+ const txt = typeof v.text === 'string' ? v.text : JSON.stringify(v.text);
126
+ L.push(`- [${v.scope}] ${txt.slice(0, 200)}`);
127
+ }
128
+ L.push('');
129
+ }
130
+ return L.join('\n');
131
+ }
132
+
20
133
  /** 清理自动捕获的入站文本:去掉 QQ 的「【QQ群 ...】」前缀(对其它 world 无前缀,无副作用),压缩空白。 */
21
134
  function cleanCaptureText(t: string): string {
22
135
  return (t ?? '')
@@ -91,6 +204,9 @@ export class MemoryWorld implements World {
91
204
  envPromptVars(): Record<string, string> | null {
92
205
  if (!this.mem || !this.vectors) return null; // 未启动 = 本 World 这段不进前缀
93
206
  const view = buildView(this.mem, this.vectors, 'main', this.cfg.notesInPrompt, this.profile);
207
+ // 合并 QQ 笔记本('qq' scope)的要点,让智能体也能常驻记住——qq_note_* 走记忆插件时落在此域。
208
+ const qqNotes = this.mem.getNotes('qq').slice(-this.cfg.notesInPrompt);
209
+ if (qqNotes.length) view.notes = [...view.notes, ...qqNotes].slice(-this.cfg.notesInPrompt);
94
210
  return promptVars(view);
95
211
  }
96
212
 
@@ -126,8 +242,13 @@ export class MemoryWorld implements World {
126
242
  this.host = host;
127
243
  const ctxData = this.ctx.dataDir;
128
244
  const dataDir = typeof ctxData === 'string' && ctxData ? ctxData : '.';
129
- this.backend = await createBackend(this.cfg.storageBackend ?? 'json', dataDir, (m) => host.log.info(m));
130
- this.backend.migrateFromJson(ALL_STEMS); // 首次切 sqlite 自动迁移旧 JSON
245
+ const kind = this.cfg.storageBackend ?? 'json';
246
+ this.backend = await createBackend(kind, dataDir, (m) => host.log.info(m));
247
+ if (kind === 'sqlite') {
248
+ this.backend.migrateFromJson(ALL_STEMS); // json→sqlite:首次自动迁移旧 JSON
249
+ } else {
250
+ await backfillSqliteToJson(dataDir, (m) => host.log.info(m)); // sqlite→json:回填较新数据,消除切换丢写
251
+ }
131
252
  this.mem = new MemoryBase(dataDir, this.cfg.maxHistory, this.backend);
132
253
  this.vectors = new VectorStore(dataDir, this.backend);
133
254
  this.facts = new FactStore(dataDir, this.backend);
@@ -156,10 +277,19 @@ export class MemoryWorld implements World {
156
277
  // 自动捕获:从当前事件尾开始(不回灌历史),周期扫描外部入站消息沉淀进记忆。
157
278
  try { this.lastCursor = (host.store?.latestCursor?.() ?? 0) || 0; } catch { this.lastCursor = 0; }
158
279
  if (this.cfg.autoCapture) this.startCapture();
280
+ // QQ 插件笔记本桥:启用时让 qq_note_* 直接写入本插件笔记库('qq' scope),复用常驻环境提示词与混合检索。
281
+ (globalThis as Record<string, unknown>)['cortico:memory:notebook'] = {
282
+ kind: 'memory' as const,
283
+ save: (text: string) => this.mem!.addNote('qq', text),
284
+ list: () => this.mem!.getNotes('qq'),
285
+ get: (index: number) => this.mem!.getNotes('qq')[index] ?? null,
286
+ forget: (index: number) => this.mem!.removeNote('qq', index),
287
+ };
159
288
  }
160
289
 
161
290
  /** stop 必须 async 返回 Promise(框架 withDeadline 直接 work.then)。 */
162
291
  async stop(): Promise<void> {
292
+ delete (globalThis as Record<string, unknown>)['cortico:memory:notebook'];
163
293
  if (this.timer) clearInterval(this.timer);
164
294
  this.timer = null;
165
295
  if (this.captureTimer) { clearInterval(this.captureTimer); this.captureTimer = null; }
@@ -173,6 +303,103 @@ export class MemoryWorld implements World {
173
303
  this.backend?.flush(); // 最后统一关闭 SQLite(每个 store 的 flush 只写行,不关库)
174
304
  }
175
305
 
306
+ /**
307
+ * 导出全部记忆为 JSON / Markdown 文件(便于备份、迁移、阅读)。
308
+ * - format: 'json' | 'markdown' | 'both'
309
+ * - scope: 可选,按作用域/用户过滤(短期/事实/向量按 scope;反思/人格/档案按 userId;知识库为全局始终包含)
310
+ * - outDir: 导出目录,默认 <dataDir>/exports
311
+ */
312
+ private async exportMemory(format: string, scope: string, outDir: string): Promise<{ text: string }> {
313
+ const wantJson = !format || format === 'both' || format === 'json';
314
+ const wantMd = !format || format === 'both' || format === 'markdown';
315
+ const dir = (outDir && outDir.trim()) || path.join(this.dataDir, 'exports');
316
+ mkdirSync(dir, { recursive: true });
317
+
318
+ const read = async (stem: string): Promise<_Dict> => {
319
+ try { return (await this.backend!.readJson(stem)) as _Dict; } catch { return {}; }
320
+ };
321
+
322
+ const memRaw = (await read('memory-data')) ?? {};
323
+ const factRaw = (await read('fact-store')) ?? {};
324
+ const vectorRaw = (await read('vector-memory')) ?? {};
325
+ const kbRaw = (await read('knowledge-base')) ?? {};
326
+ const reflRaw = (await read('reflection-data')) ?? {};
327
+ const personaRaw = (await read('persona-data')) ?? {};
328
+ const profileRaw = (await read('user-profiles')) ?? {};
329
+
330
+ let store = (memRaw.store ?? {}) as _Dict;
331
+ let notes = (memRaw.notes ?? {}) as _Dict;
332
+ let summary = (memRaw.summary ?? {}) as _Dict;
333
+ let topic = (memRaw.topic ?? {}) as _Dict;
334
+ let factScopes = (factRaw.scopes ?? {}) as _Dict;
335
+ let vectors = (vectorRaw.vectors ?? []) as any[];
336
+ let reflections = (reflRaw.reflections ?? []) as any[];
337
+ let rules = (reflRaw.interactionRules ?? []) as any[];
338
+ let personas = (personaRaw.personas ?? {}) as _Dict;
339
+ let profiles = (profileRaw.profiles ?? {}) as _Dict;
340
+ const globalStyle = (personaRaw.globalStyle ?? '') as string;
341
+ const knowledge = (kbRaw.entries ?? []) as any[];
342
+
343
+ const filterScope = (scope || '').trim();
344
+ if (filterScope) {
345
+ store = pickScope(store, filterScope);
346
+ notes = pickScope(notes, filterScope);
347
+ summary = pickScope(summary, filterScope);
348
+ topic = pickScope(topic, filterScope);
349
+ factScopes = pickScope(factScopes, filterScope);
350
+ vectors = vectors.filter((v: _Dict) => v.scope === filterScope);
351
+ reflections = reflections.filter((r: _Dict) => String(r.userId) === filterScope);
352
+ rules = rules.filter((r: _Dict) => String(r.userId) === filterScope);
353
+ personas = pickScope(personas, filterScope);
354
+ profiles = pickScope(profiles, filterScope);
355
+ }
356
+
357
+ const bundle: _Dict = {
358
+ exportedAt: new Date().toISOString(),
359
+ plugin: 'cortico-world-memory',
360
+ scope: filterScope || 'ALL',
361
+ backend: (this.backend as unknown as { constructor?: { name?: string } })?.constructor?.name ?? 'unknown',
362
+ memory: { store, summary, topic, notes },
363
+ facts: { scopes: factScopes },
364
+ vectors: { model: vectorRaw.model_name ?? null, vectors },
365
+ knowledge: { entries: knowledge },
366
+ reflection: { reflections, rules, stats: reflRaw.stats ?? {} },
367
+ persona: { personas, globalStyle },
368
+ profiles,
369
+ };
370
+
371
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
372
+ const base = `memory-export-${stamp}`;
373
+ const written: string[] = [];
374
+
375
+ if (wantJson) {
376
+ const p = path.join(dir, `${base}.json`);
377
+ writeFileSync(p, JSON.stringify(bundle, null, 2), 'utf8');
378
+ written.push(p);
379
+ }
380
+ if (wantMd) {
381
+ const p = path.join(dir, `${base}.md`);
382
+ writeFileSync(p, renderMemoryMarkdown(bundle), 'utf8');
383
+ written.push(p);
384
+ }
385
+
386
+ const factCount = Object.values(factScopes).reduce((s: number, l: any) => s + (Array.isArray(l) ? l.length : 0), 0);
387
+ const counts = [
388
+ `短期记忆 ${Object.keys(store).length} scope`,
389
+ `笔记 ${Object.keys(notes).length}`,
390
+ `事实 ${factCount} 条`,
391
+ `向量 ${vectors.length}`,
392
+ `知识 ${knowledge.length} 条`,
393
+ `反思 ${reflections.length} / 规则 ${rules.length}`,
394
+ `人格 ${Object.keys(personas).length} 用户`,
395
+ `档案 ${Object.keys(profiles).length} 用户`,
396
+ ].join(' | ');
397
+
398
+ return {
399
+ text: `记忆导出完成(${filterScope ? 'scope=' + filterScope : '全部'})\n${counts}\n文件:\n` + written.map((w) => '- ' + w).join('\n'),
400
+ };
401
+ }
402
+
176
403
  /** 启动自动捕获轮询(尊重暂停开关)。 */
177
404
  private startCapture(): void {
178
405
  const ms = Math.max(5000, Number(this.cfg.captureIntervalMs) || 20000);
@@ -443,6 +670,24 @@ export class MemoryWorld implements World {
443
670
  }),
444
671
  mem_flush: mk('mem_flush', '把记忆立即落盘(平时自动防抖落盘,一般不用调)。', {}, [], ['act'],
445
672
  async () => { this.mem?.flush(); this.vectors?.flush(); this.facts?.flush(); this.kb?.flush(); this.reflect?.flush(); this.persona?.flush(); this.profile?.flush(); return 'OK 已落盘'; }),
673
+ mem_export: mk(
674
+ 'mem_export',
675
+ '把全部记忆导出为文件(JSON/Markdown),便于备份、迁移、阅读。知识库为全局始终包含;其他可按作用域/用户过滤。',
676
+ {
677
+ format: { type: 'string', optional: true, title: '格式', description: "json | markdown | both(默认 both)。" },
678
+ scope: { type: 'string', optional: true, title: '作用域过滤', description: '可选,按作用域/用户过滤(短期/事实/向量按 scope;反思/人格/档案按 userId)。留空导出全部。' },
679
+ outDir: { type: 'string', optional: true, title: '导出目录', description: '可选,文件保存目录,默认 <数据目录>/exports。' },
680
+ },
681
+ [],
682
+ ['act'],
683
+ async (args: _Dict) => {
684
+ const format = typeof args.format === 'string' ? args.format.trim().toLowerCase() : 'both';
685
+ const scope = typeof args.scope === 'string' ? args.scope.trim() : '';
686
+ const outDir = typeof args.outDir === 'string' ? args.outDir.trim() : '';
687
+ if (!['json', 'markdown', 'both'].includes(format)) return { text: '错误:format 只能是 json / markdown / both。' };
688
+ return await this.exportMemory(format, scope, outDir);
689
+ },
690
+ ),
446
691
  };
447
692
  }
448
693
  }