cortico-world-memory 0.1.5 → 0.1.7

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.5",
3
+ "version": "0.1.7",
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 ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * 存储后端抽象 —— 让 7 个记忆 store 的落盘方式可在「JSON 文件」与「SQLite」间切换,
3
+ * store 内部逻辑零改动(仍是内存对象,只把 load/save 委托给 backend.readJson/writeJson)。
4
+ *
5
+ * - JsonBackend(默认):保持原行为,每 stem 一个 `*.json` 文件,原子提交(tmp+rename)。
6
+ * - SqliteBackend:单库 `memory.db` 的 KV 表 `kv(name TEXT PRIMARY KEY, data TEXT)`,
7
+ * 每个 stem 的完整文件对象作为一行 JSON 存储。首次启动若检测到旧 JSON 文件,
8
+ * 自动迁移进 SQLite(一次性,不丢历史)。
9
+ *
10
+ * SQLite 驱动优先级:先试 `better-sqlite3`(原生,若已安装),缺失则回退 Node 内置
11
+ * `node:sqlite`(v24+ 自带,零安装)。两者 API 兼容(.prepare().get()/.run()、.exec())。
12
+ */
13
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
14
+ import { dirname, join } from 'node:path';
15
+
16
+ export type BackendKind = 'json' | 'sqlite';
17
+
18
+ /** 全部记忆存储的 stem(文件名去掉 .json)。迁移与校验用。 */
19
+ export const ALL_STEMS = [
20
+ 'memory-data',
21
+ 'fact-store',
22
+ 'knowledge-base',
23
+ 'reflection-data',
24
+ 'persona-data',
25
+ 'user-profiles',
26
+ 'vector-memory',
27
+ ] as const;
28
+
29
+ export interface StoreBackend {
30
+ readonly kind: BackendKind;
31
+ /** 读取某 stem 的完整文件对象;不存在或损坏返回 null。 */
32
+ readJson(stem: string): unknown | null;
33
+ /** 写入某 stem 的完整文件对象(原子/即时)。 */
34
+ writeJson(stem: string, obj: unknown): void;
35
+ /** 删除某 stem(切回 JSON 等场景用)。 */
36
+ removeJson(stem: string): void;
37
+ /** 强制落盘/关闭(仅由 world.stop 调一次)。 */
38
+ flush(): void;
39
+ /** 一次性把旧 JSON 文件导入本后端(若本 stem 尚未存在)。 */
40
+ migrateFromJson(stems: readonly string[]): void;
41
+ }
42
+
43
+ // ---------------- JSON 后端(默认,保持原行为) ----------------
44
+ export class JsonBackend implements StoreBackend {
45
+ readonly kind = 'json' as const;
46
+ constructor(private readonly dataDir: string) {}
47
+ readJson(stem: string): unknown | null {
48
+ const f = join(this.dataDir, stem + '.json');
49
+ if (!existsSync(f)) return null;
50
+ try {
51
+ return JSON.parse(readFileSync(f, 'utf8'));
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+ writeJson(stem: string, obj: unknown): void {
57
+ const f = join(this.dataDir, stem + '.json');
58
+ try {
59
+ mkdirSync(dirname(f), { recursive: true });
60
+ const tmp = f + '.tmp';
61
+ writeFileSync(tmp, JSON.stringify(obj, null, 2), 'utf8');
62
+ renameSync(tmp, f);
63
+ } catch {
64
+ /* 落盘失败不影响内存态,下次 flush 重试 */
65
+ }
66
+ }
67
+ removeJson(stem: string): void {
68
+ const f = join(this.dataDir, stem + '.json');
69
+ try {
70
+ if (existsSync(f)) renameSync(f, f + '.bak');
71
+ } catch {
72
+ /* ignore */
73
+ }
74
+ }
75
+ flush(): void {}
76
+ migrateFromJson(): void {
77
+ /* JSON 后端本就以文件为准,无需迁移 */
78
+ }
79
+ }
80
+
81
+ // ---------------- SQLite 后端(KV 封装) ----------------
82
+ class SqliteBackend implements StoreBackend {
83
+ readonly kind = 'sqlite' as const;
84
+ readonly driver: string;
85
+ private constructor(
86
+ private readonly dataDir: string,
87
+ private readonly db: any,
88
+ driver: string,
89
+ ) {
90
+ db.exec('CREATE TABLE IF NOT EXISTS kv (name TEXT PRIMARY KEY, data TEXT)');
91
+ this.driver = driver;
92
+ }
93
+
94
+ static async create(dataDir: string): Promise<SqliteBackend> {
95
+ mkdirSync(dataDir, { recursive: true });
96
+ let db: any;
97
+ let driver: string;
98
+ try {
99
+ const mod: any = await import('better-sqlite3');
100
+ const Database = mod.default ?? mod;
101
+ db = new Database(join(dataDir, 'memory.db'));
102
+ driver = 'better-sqlite3';
103
+ } catch {
104
+ const { DatabaseSync } = await import('node:sqlite');
105
+ db = new DatabaseSync(join(dataDir, 'memory.db'));
106
+ driver = 'node:sqlite';
107
+ }
108
+ return new SqliteBackend(dataDir, db, driver);
109
+ }
110
+
111
+ readJson(stem: string): unknown | null {
112
+ const row = this.db.prepare('SELECT data FROM kv WHERE name = ?').get(stem);
113
+ if (!row) return null;
114
+ try {
115
+ return JSON.parse(row.data);
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ writeJson(stem: string, obj: unknown): void {
122
+ this.db.prepare('INSERT OR REPLACE INTO kv (name, data) VALUES (?, ?)').run(stem, JSON.stringify(obj));
123
+ }
124
+
125
+ removeJson(stem: string): void {
126
+ this.db.prepare('DELETE FROM kv WHERE name = ?').run(stem);
127
+ }
128
+
129
+ flush(): void {
130
+ try {
131
+ this.db.close();
132
+ } catch {
133
+ /* ignore */
134
+ }
135
+ }
136
+
137
+ migrateFromJson(stems: readonly string[]): void {
138
+ for (const stem of stems) {
139
+ if (this.readJson(stem) !== null) continue; // 已迁移过则跳过
140
+ const f = join(this.dataDir, stem + '.json');
141
+ if (!existsSync(f)) continue;
142
+ try {
143
+ this.writeJson(stem, JSON.parse(readFileSync(f, 'utf8')));
144
+ } catch {
145
+ /* 损坏文件忽略 */
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ export type LogFn = (msg: string) => void;
152
+
153
+ /** 按配置创建后端。sqlite 初始化失败时回退 JSON 并打日志。 */
154
+ export async function createBackend(kind: BackendKind, dataDir: string, log?: LogFn): Promise<StoreBackend> {
155
+ if (kind === 'sqlite') {
156
+ try {
157
+ const b = await SqliteBackend.create(dataDir);
158
+ log?.(`[memory] 存储后端=sqlite (driver=${b.driver})`);
159
+ return b;
160
+ } catch (e: any) {
161
+ log?.(`[memory] sqlite 初始化失败,回退 JSON: ${e?.message ?? e}`);
162
+ }
163
+ }
164
+ log?.('[memory] 存储后端=json');
165
+ return new JsonBackend(dataDir);
166
+ }
package/src/config.ts CHANGED
@@ -44,6 +44,13 @@ export interface MemoryConfig {
44
44
  embeddingSidecarUrl: string;
45
45
  /** `sidecar` 模式单次调用的超时(毫秒)。qq_bot 侧模型首载可达数分钟,别调太小。 */
46
46
  embeddingTimeoutMs: number;
47
+ /**
48
+ * 持久化后端:
49
+ * - `json` 原生 JSON 文件(每 store 一个 `*.json`,默认);
50
+ * - `sqlite` 单库 `memory.db` 的 KV 封装(自动尝试 better-sqlite3,缺失回退 Node 内置 node:sqlite)。
51
+ * 首次切到 sqlite 会自动把已有 JSON 迁移进库;切回 json 以 JSON 文件为准(可能落后 sqlite 的后续写入)。
52
+ */
53
+ storageBackend: 'json' | 'sqlite';
47
54
  }
48
55
 
49
56
  export const MEMORY_DEFAULTS: MemoryConfig = {
@@ -61,6 +68,7 @@ export const MEMORY_DEFAULTS: MemoryConfig = {
61
68
  embeddingModel: '',
62
69
  embeddingSidecarUrl: 'http://127.0.0.1:8766',
63
70
  embeddingTimeoutMs: 180_000,
71
+ storageBackend: 'json',
64
72
  };
65
73
 
66
74
  /** 控制台可调旋钮。一个属性一条 JSON Schema,没有手写表单。 */
@@ -88,6 +96,10 @@ export const MEMORY_CONFIG_GROUP: ConfigGroup = {
88
96
  'worlds.memory.embeddingModel': { type: 'string', title: 'Embeddings 模型', description: 'http 模式用,如 nomic-embed-text。' },
89
97
  'worlds.memory.embeddingSidecarUrl': { type: 'string', title: 'Sidecar 地址', description: 'sidecar 模式用,qq_bot memory_server(默认 http://127.0.0.1:8766)。' },
90
98
  'worlds.memory.embeddingTimeoutMs': { type: 'number', title: 'Sidecar 超时(ms)', description: 'qq_bot 向量模型首载慢,默认 3 分钟。' },
99
+ 'worlds.memory.storageBackend': {
100
+ type: 'string', title: '存储后端', enum: ['json', 'sqlite'],
101
+ description: 'json=原生 JSON 文件(默认);sqlite=单库 KV(自动 better-sqlite3→node:sqlite 回退)。切 sqlite 首次自动迁移旧 JSON。',
102
+ },
91
103
  },
92
104
  },
93
105
  };
package/src/fact-store.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
2
+ import { StoreBackend, JsonBackend } from './backend.ts';
4
3
 
5
4
  export interface Fact {
6
5
  hash: string;
@@ -20,22 +19,23 @@ export interface Fact {
20
19
  * 纯 JS 实现,不引入 better-sqlite3 原生依赖(world 扩展装原生模块有风险)。
21
20
  */
22
21
  export class FactStore {
23
- private readonly file: string;
22
+ private readonly stem = 'fact-store';
23
+ private readonly backend: StoreBackend;
24
24
  private data: Record<string, Fact[]> = {};
25
25
  private dirty = false;
26
26
  private readonly flushTimer: ReturnType<typeof setInterval>;
27
27
 
28
- constructor(dataDir: string, flushMs = 2000) {
29
- this.file = join(dataDir, 'fact-store.json');
28
+ constructor(dataDir: string, backend?: StoreBackend, flushMs = 2000) {
29
+ this.backend = backend ?? new JsonBackend(dataDir);
30
30
  this.load();
31
31
  this.flushTimer = setInterval(() => this.flush(), flushMs);
32
32
  }
33
33
 
34
34
  private load(): void {
35
35
  try {
36
- if (existsSync(this.file)) {
37
- const raw = JSON.parse(readFileSync(this.file, 'utf8'));
38
- if (raw && typeof raw === 'object' && raw.scopes) this.data = raw.scopes;
36
+ const raw = this.backend.readJson(this.stem);
37
+ if (raw && typeof raw === 'object' && (raw as { scopes?: unknown }).scopes) {
38
+ this.data = (raw as { scopes: Record<string, Fact[]> }).scopes;
39
39
  }
40
40
  } catch {
41
41
  this.data = {};
@@ -44,15 +44,8 @@ export class FactStore {
44
44
 
45
45
  private saveNow(): void {
46
46
  if (!this.dirty) return;
47
- try {
48
- mkdirSync(dirname(this.file), { recursive: true });
49
- const tmp = this.file + '.tmp';
50
- writeFileSync(tmp, JSON.stringify({ scopes: this.data }, null, 2), 'utf8');
51
- renameSync(tmp, this.file);
52
- this.dirty = false;
53
- } catch {
54
- /* 落盘失败不影响内存态,下次 flush 重试 */
55
- }
47
+ this.backend.writeJson(this.stem, { scopes: this.data });
48
+ this.dirty = false;
56
49
  }
57
50
 
58
51
  /** 强制落盘(供 stop 调用)。 */
@@ -1,5 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
1
+ import { StoreBackend, JsonBackend } from './backend.ts';
3
2
 
4
3
  export interface KnowledgeEntry {
5
4
  id: string;
@@ -35,23 +34,22 @@ const queryTokens = (q: string): string[] =>
35
34
  * - 原子落盘(临时文件 + rename)
36
35
  */
37
36
  export class KnowledgeStore {
38
- private readonly file: string;
37
+ private readonly stem = 'knowledge-base';
38
+ private readonly backend: StoreBackend;
39
39
  private data: KbFile = { entries: [] };
40
40
  private dirty = false;
41
41
  private readonly flushTimer: ReturnType<typeof setInterval>;
42
42
 
43
- constructor(dataDir: string, flushMs = 2000) {
44
- this.file = join(dataDir, 'knowledge-base.json');
43
+ constructor(dataDir: string, backend?: StoreBackend, flushMs = 2000) {
44
+ this.backend = backend ?? new JsonBackend(dataDir);
45
45
  this.load();
46
46
  this.flushTimer = setInterval(() => this.flush(), flushMs);
47
47
  }
48
48
 
49
49
  private load(): void {
50
50
  try {
51
- if (existsSync(this.file)) {
52
- const raw = JSON.parse(readFileSync(this.file, 'utf8'));
53
- if (raw && Array.isArray(raw.entries)) this.data.entries = raw.entries;
54
- }
51
+ const raw = this.backend.readJson(this.stem);
52
+ if (raw && Array.isArray((raw as KbFile).entries)) this.data.entries = (raw as KbFile).entries;
55
53
  } catch {
56
54
  this.data = { entries: [] };
57
55
  }
@@ -59,15 +57,8 @@ export class KnowledgeStore {
59
57
 
60
58
  private saveNow(): void {
61
59
  if (!this.dirty) return;
62
- try {
63
- mkdirSync(dirname(this.file), { recursive: true });
64
- const tmp = this.file + '.tmp';
65
- writeFileSync(tmp, JSON.stringify(this.data, null, 2), 'utf8');
66
- renameSync(tmp, this.file);
67
- this.dirty = false;
68
- } catch {
69
- /* 落盘失败不影响内存态 */
70
- }
60
+ this.backend.writeJson(this.stem, this.data);
61
+ this.dirty = false;
71
62
  }
72
63
 
73
64
  flush(): void {
package/src/memory.ts CHANGED
@@ -10,8 +10,7 @@
10
10
  * 差异:qq_bot 按 `user:{id}` 统一键;Cortico 侧没有 IM 用户概念,scope 由调用方给
11
11
  * (默认 'main',可按渠道/世界分域,如 'mc'、'qq:21907')。
12
12
  */
13
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
14
- import { dirname, join } from 'node:path';
13
+ import { StoreBackend, JsonBackend } from './backend.ts';
15
14
 
16
15
  export interface ShortTermEntry {
17
16
  role: 'user' | 'assistant' | 'system';
@@ -26,7 +25,8 @@ export class MemoryBase {
26
25
  private readonly summary = new Map<string, string>();
27
26
  private readonly topic = new Map<string, string>();
28
27
  private readonly notes = new Map<string, Array<{ text: string; ts: number }>>();
29
- private readonly file: string;
28
+ private readonly stem = 'memory-data';
29
+ private readonly backend: StoreBackend;
30
30
  private dirty = false;
31
31
  private lastSave = 0;
32
32
  private flushTimer: ReturnType<typeof setTimeout> | null = null;
@@ -34,17 +34,18 @@ export class MemoryBase {
34
34
  constructor(
35
35
  dataDir: string,
36
36
  private readonly maxHistory: number,
37
+ backend?: StoreBackend,
37
38
  ) {
38
- mkdirSync(dataDir, { recursive: true });
39
- this.file = join(dataDir, 'memory-data.json');
39
+ this.backend = backend ?? new JsonBackend(dataDir);
40
40
  this.load();
41
41
  }
42
42
 
43
43
  // ---------------- 持久化(防抖,对齐 _SAVE_DEBOUNCE) ----------------
44
44
  private load(): void {
45
- if (!existsSync(this.file)) return;
45
+ const raw = this.backend.readJson(this.stem);
46
+ if (!raw) return;
46
47
  try {
47
- const raw = JSON.parse(readFileSync(this.file, 'utf8')) as {
48
+ const r = raw as {
48
49
  store?: Record<string, ShortTermEntry[]>;
49
50
  summary?: Record<string, string>;
50
51
  topic?: Record<string, string>;
@@ -53,7 +54,7 @@ export class MemoryBase {
53
54
  for (const [k, v] of Object.entries(raw.store ?? {})) if (Array.isArray(v)) this.store.set(k, v.slice(-this.maxHistory));
54
55
  for (const [k, v] of Object.entries(raw.summary ?? {})) if (typeof v === 'string') this.summary.set(k, v);
55
56
  for (const [k, v] of Object.entries(raw.topic ?? {})) if (typeof v === 'string') this.topic.set(k, v);
56
- for (const [k, v] of Object.entries(raw.notes ?? {})) if (Array.isArray(v)) this.notes.set(k, v);
57
+ for (const [k, v] of Object.entries(r.notes ?? {})) if (Array.isArray(v)) this.notes.set(k, v);
57
58
  } catch { /* 损坏按空起步 */ }
58
59
  }
59
60
 
@@ -80,12 +81,7 @@ export class MemoryBase {
80
81
  topic: dump(this.topic) as Record<string, string>,
81
82
  notes: dump(this.notes) as Record<string, Array<{ text: string; ts: number }>>,
82
83
  };
83
- mkdirSync(dirname(this.file), { recursive: true });
84
- // 原子提交(archive-first 单文件等价):先写临时文件再 rename 到目标,
85
- // 同卷 rename 原子,避免写到一半崩溃损坏整个 memory-data.json(对齐 qq_bot fact_store)。
86
- const tmp = this.file + '.tmp';
87
- writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
88
- renameSync(tmp, this.file);
84
+ this.backend.writeJson(this.stem, data);
89
85
  this.dirty = false;
90
86
  this.lastSave = Date.now();
91
87
  } catch { /* 磁盘失败不炸主流程 */ }
@@ -1,5 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
1
+ import { StoreBackend, JsonBackend } from './backend.ts';
3
2
 
4
3
  interface Persona {
5
4
  traits: string[];
@@ -21,25 +20,25 @@ const MAX_TRAITS_PER_USER = 20;
21
20
  * - 原子落盘(临时文件 + rename)
22
21
  */
23
22
  export class PersonaStore {
24
- private readonly file: string;
23
+ private readonly stem = 'persona-data';
24
+ private readonly backend: StoreBackend;
25
25
  private data: PersonaFile = { personas: {}, globalStyle: '' };
26
26
  private dirty = false;
27
27
  private readonly flushTimer: ReturnType<typeof setInterval>;
28
28
 
29
- constructor(dataDir: string, flushMs = 2000) {
30
- this.file = join(dataDir, 'persona-data.json');
29
+ constructor(dataDir: string, backend?: StoreBackend, flushMs = 2000) {
30
+ this.backend = backend ?? new JsonBackend(dataDir);
31
31
  this.load();
32
32
  this.flushTimer = setInterval(() => this.flush(), flushMs);
33
33
  }
34
34
 
35
35
  private load(): void {
36
36
  try {
37
- if (existsSync(this.file)) {
38
- const raw = JSON.parse(readFileSync(this.file, 'utf8'));
39
- if (raw && typeof raw === 'object') {
40
- this.data.personas = raw.personas ?? {};
41
- this.data.globalStyle = raw.globalStyle ?? '';
42
- }
37
+ const raw = this.backend.readJson(this.stem);
38
+ if (raw && typeof raw === 'object') {
39
+ const r = raw as Partial<PersonaFile>;
40
+ this.data.personas = r.personas ?? {};
41
+ this.data.globalStyle = r.globalStyle ?? '';
43
42
  }
44
43
  } catch {
45
44
  /* 损坏则重置 */
@@ -48,15 +47,8 @@ export class PersonaStore {
48
47
 
49
48
  private saveNow(): void {
50
49
  if (!this.dirty) return;
51
- try {
52
- mkdirSync(dirname(this.file), { recursive: true });
53
- const tmp = this.file + '.tmp';
54
- writeFileSync(tmp, JSON.stringify(this.data, null, 2), 'utf8');
55
- renameSync(tmp, this.file);
56
- this.dirty = false;
57
- } catch {
58
- /* 落盘失败不影响内存态 */
59
- }
50
+ this.backend.writeJson(this.stem, this.data);
51
+ this.dirty = false;
60
52
  }
61
53
 
62
54
  flush(): void {
@@ -1,5 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
1
+ import { StoreBackend, JsonBackend } from './backend.ts';
3
2
 
4
3
  interface Profile {
5
4
  facts: string[];
@@ -20,22 +19,23 @@ const MAX_FACTS_PER_USER = 50;
20
19
  * - 原子落盘(临时文件 + rename)
21
20
  */
22
21
  export class ProfileStore {
23
- private readonly file: string;
22
+ private readonly stem = 'user-profiles';
23
+ private readonly backend: StoreBackend;
24
24
  private data: ProfileFile = { profiles: {} };
25
25
  private dirty = false;
26
26
  private readonly flushTimer: ReturnType<typeof setInterval>;
27
27
 
28
- constructor(dataDir: string, flushMs = 2000) {
29
- this.file = join(dataDir, 'user-profiles.json');
28
+ constructor(dataDir: string, backend?: StoreBackend, flushMs = 2000) {
29
+ this.backend = backend ?? new JsonBackend(dataDir);
30
30
  this.load();
31
31
  this.flushTimer = setInterval(() => this.flush(), flushMs);
32
32
  }
33
33
 
34
34
  private load(): void {
35
35
  try {
36
- if (existsSync(this.file)) {
37
- const raw = JSON.parse(readFileSync(this.file, 'utf8'));
38
- if (raw && typeof raw === 'object' && raw.profiles) this.data.profiles = raw.profiles;
36
+ const raw = this.backend.readJson(this.stem);
37
+ if (raw && typeof raw === 'object' && (raw as ProfileFile).profiles) {
38
+ this.data.profiles = (raw as ProfileFile).profiles;
39
39
  }
40
40
  } catch {
41
41
  this.data = { profiles: {} };
@@ -44,15 +44,8 @@ export class ProfileStore {
44
44
 
45
45
  private saveNow(): void {
46
46
  if (!this.dirty) return;
47
- try {
48
- mkdirSync(dirname(this.file), { recursive: true });
49
- const tmp = this.file + '.tmp';
50
- writeFileSync(tmp, JSON.stringify(this.data, null, 2), 'utf8');
51
- renameSync(tmp, this.file);
52
- this.dirty = false;
53
- } catch {
54
- /* 落盘失败不影响内存态 */
55
- }
47
+ this.backend.writeJson(this.stem, this.data);
48
+ this.dirty = false;
56
49
  }
57
50
 
58
51
  flush(): void {
@@ -1,5 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
1
+ import { StoreBackend, JsonBackend } from './backend.ts';
3
2
 
4
3
  export type ReflectionType = 'preference' | 'style' | 'insight' | 'correction' | 'pattern';
5
4
 
@@ -38,26 +37,26 @@ const MAX_RULES = 50;
38
37
  * - 原子落盘(临时文件 + rename)
39
38
  */
40
39
  export class ReflectionStore {
41
- private readonly file: string;
40
+ private readonly stem = 'reflection-data';
41
+ private readonly backend: StoreBackend;
42
42
  private data: ReflectFile = { reflections: [], interactionRules: [], stats: { totalConversations: 0, totalReflections: 0, lastReflectionTs: 0 } };
43
43
  private dirty = false;
44
44
  private readonly flushTimer: ReturnType<typeof setInterval>;
45
45
 
46
- constructor(dataDir: string, flushMs = 2000) {
47
- this.file = join(dataDir, 'reflection-data.json');
46
+ constructor(dataDir: string, backend?: StoreBackend, flushMs = 2000) {
47
+ this.backend = backend ?? new JsonBackend(dataDir);
48
48
  this.load();
49
49
  this.flushTimer = setInterval(() => this.flush(), flushMs);
50
50
  }
51
51
 
52
52
  private load(): void {
53
53
  try {
54
- if (existsSync(this.file)) {
55
- const raw = JSON.parse(readFileSync(this.file, 'utf8'));
56
- if (raw && typeof raw === 'object') {
57
- this.data.reflections = Array.isArray(raw.reflections) ? raw.reflections : [];
58
- this.data.interactionRules = Array.isArray(raw.interactionRules) ? raw.interactionRules : [];
59
- if (raw.stats) this.data.stats = raw.stats;
60
- }
54
+ const raw = this.backend.readJson(this.stem);
55
+ if (raw && typeof raw === 'object') {
56
+ const r = raw as Partial<ReflectFile>;
57
+ this.data.reflections = Array.isArray(r.reflections) ? r.reflections : [];
58
+ this.data.interactionRules = Array.isArray(r.interactionRules) ? r.interactionRules : [];
59
+ if (r.stats) this.data.stats = r.stats;
61
60
  }
62
61
  } catch {
63
62
  /* 损坏则重置 */
@@ -66,15 +65,8 @@ export class ReflectionStore {
66
65
 
67
66
  private saveNow(): void {
68
67
  if (!this.dirty) return;
69
- try {
70
- mkdirSync(dirname(this.file), { recursive: true });
71
- const tmp = this.file + '.tmp';
72
- writeFileSync(tmp, JSON.stringify(this.data, null, 2), 'utf8');
73
- renameSync(tmp, this.file);
74
- this.dirty = false;
75
- } catch {
76
- /* 落盘失败不影响内存态 */
77
- }
68
+ this.backend.writeJson(this.stem, this.data);
69
+ this.dirty = false;
78
70
  }
79
71
 
80
72
  flush(): void {
@@ -10,9 +10,8 @@
10
10
  * - 降级:无向量记录 / 维度不齐 / embedding 服务不在,都自动退化为纯 BM25——
11
11
  * 与 qq_bot memory_client「sidecar 掉线降级进程内、功能永不中断」同一思路。
12
12
  */
13
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
14
- import { dirname, join } from 'node:path';
15
13
  import { decodeVectorFp16, encodeVectorFp16 } from './float16.ts';
14
+ import { StoreBackend, JsonBackend } from './backend.ts';
16
15
 
17
16
  const BM25_K1 = 1.5;
18
17
  const BM25_B = 0.75;
@@ -121,7 +120,8 @@ function rrfFusion(rankedLists: Array<Array<{ idx: number }>>): Map<number, numb
121
120
  /** 一个 scope 的向量库(磁盘持久化 + 内存缓存 + 矩阵缓存 + 防抖写盘)。 */
122
121
  export class VectorStore {
123
122
  private data: VectorData = { vectors: {} };
124
- private file: string;
123
+ private readonly stem = 'vector-memory';
124
+ private readonly backend: StoreBackend;
125
125
  private matrices = new Map<string, { version: number; m: MatrixCache | null }>();
126
126
  private versions = new Map<string, number>;
127
127
  private dirty = false;
@@ -130,31 +130,23 @@ export class VectorStore {
130
130
 
131
131
  static readonly SAVE_DEBOUNCE_MS = 2000;
132
132
 
133
- constructor(dataDir: string) {
134
- mkdirSync(dataDir, { recursive: true });
135
- this.file = join(dataDir, 'vector-memory.json');
133
+ constructor(dataDir: string, backend?: StoreBackend) {
134
+ this.backend = backend ?? new JsonBackend(dataDir);
136
135
  this.load();
137
136
  }
138
137
 
139
138
  private load(): void {
140
- if (!existsSync(this.file)) return;
139
+ const raw = this.backend.readJson(this.stem) as VectorData | null;
140
+ if (!raw || typeof raw !== 'object' || !raw.vectors || typeof raw.vectors !== 'object') return;
141
141
  try {
142
- const raw = JSON.parse(readFileSync(this.file, 'utf8')) as VectorData;
143
- if (raw && typeof raw === 'object' && raw.vectors && typeof raw.vectors === 'object') {
144
- this.data = { vectors: raw.vectors, ...(raw.model_name ? { model_name: raw.model_name } : {}) };
145
- }
142
+ this.data = { vectors: raw.vectors, ...(raw.model_name ? { model_name: raw.model_name } : {}) };
146
143
  } catch { /* 损坏文件按空库起步,与 python 版同策略 */ }
147
144
  }
148
145
 
149
146
  /** 同步全量写(JSON 体量 = 条数×维度/2 字节,500 条×384 维约 1.5MB,可接受)。 */
150
147
  private saveNow(): void {
151
148
  try {
152
- mkdirSync(dirname(this.file), { recursive: true });
153
- // 原子提交(archive-first 单文件等价):先写临时文件再 rename,同卷 rename 原子,
154
- // 避免写到一半崩溃损坏整个 vector-memory.json(对齐 qq_bot fact_store crash-safe)。
155
- const tmp = this.file + '.tmp';
156
- writeFileSync(tmp, JSON.stringify(this.data), 'utf8');
157
- renameSync(tmp, this.file);
149
+ this.backend.writeJson(this.stem, this.data);
158
150
  this.dirty = false;
159
151
  this.lastSave = Date.now();
160
152
  } catch { /* 磁盘失败不打断主流程;下次写入再试 */ }
package/src/world.ts CHANGED
@@ -11,6 +11,7 @@ import { KnowledgeStore } from './knowledge-store.ts';
11
11
  import { ReflectionStore } from './reflection-store.ts';
12
12
  import { PersonaStore } from './persona-store.ts';
13
13
  import { ProfileStore } from './profile-store.ts';
14
+ import { createBackend, StoreBackend, ALL_STEMS } from './backend.ts';
14
15
  import { buildEventText, buildView, promptVars } from './observe.ts';
15
16
 
16
17
  const ENV_PROMPT_FILE = fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url));
@@ -79,6 +80,7 @@ export class MemoryWorld implements World {
79
80
  private persona: PersonaStore | null = null;
80
81
  private profile: ProfileStore | null = null;
81
82
  private embedder: EmbeddingClient | null = null;
83
+ private backend: StoreBackend | null = null; // 存储后端(json/sqlite),stop 时统一 flush 关闭
82
84
  private lastCursor = 0; // 自动捕获已扫到的事件游标(启动=当前尾,不回灌历史)
83
85
  private captureTimer: ReturnType<typeof setInterval> | null = null;
84
86
 
@@ -123,13 +125,15 @@ export class MemoryWorld implements World {
123
125
  this.host = host;
124
126
  const ctxData = this.ctx.dataDir;
125
127
  const dataDir = typeof ctxData === 'string' && ctxData ? ctxData : '.';
126
- this.mem = new MemoryBase(dataDir, this.cfg.maxHistory);
127
- this.vectors = new VectorStore(dataDir);
128
- this.facts = new FactStore(dataDir);
129
- this.kb = new KnowledgeStore(dataDir);
130
- this.reflect = new ReflectionStore(dataDir);
131
- this.persona = new PersonaStore(dataDir);
132
- this.profile = new ProfileStore(dataDir);
128
+ this.backend = await createBackend(this.cfg.storageBackend ?? 'json', dataDir, (m) => host.log.info(m));
129
+ this.backend.migrateFromJson(ALL_STEMS); // 首次切 sqlite 自动迁移旧 JSON
130
+ this.mem = new MemoryBase(dataDir, this.cfg.maxHistory, this.backend);
131
+ this.vectors = new VectorStore(dataDir, this.backend);
132
+ this.facts = new FactStore(dataDir, this.backend);
133
+ this.kb = new KnowledgeStore(dataDir, this.backend);
134
+ this.reflect = new ReflectionStore(dataDir, this.backend);
135
+ this.persona = new PersonaStore(dataDir, this.backend);
136
+ this.profile = new ProfileStore(dataDir, this.backend);
133
137
  this.embedder = new EmbeddingClient(this.cfg);
134
138
 
135
139
  host.log.info('[memory] 启动', {
@@ -165,6 +169,7 @@ export class MemoryWorld implements World {
165
169
  this.reflect?.flush();
166
170
  this.persona?.flush();
167
171
  this.profile?.flush();
172
+ this.backend?.flush(); // 最后统一关闭 SQLite(每个 store 的 flush 只写行,不关库)
168
173
  }
169
174
 
170
175
  /** 启动自动捕获轮询(尊重暂停开关)。 */