cortico-world-memory 0.1.0

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/src/world.ts ADDED
@@ -0,0 +1,336 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import type { World, WorldHost, ToolDef, ToolTag } from 'cortico/core/types.ts';
4
+ import type { WorldContext } from 'cortico/world.ts';
5
+ import type { MemoryConfig } from './config.ts';
6
+ import { MemoryBase } from './memory.ts';
7
+ import { EmbeddingClient } from './embedding.ts';
8
+ import { VectorStore } from './vector-store.ts';
9
+ import { FactStore } from './fact-store.ts';
10
+ import { KnowledgeStore } from './knowledge-store.ts';
11
+ import { ReflectionStore } from './reflection-store.ts';
12
+ import { PersonaStore } from './persona-store.ts';
13
+ import { ProfileStore } from './profile-store.ts';
14
+ import { buildEventText, buildView, promptVars } from './observe.ts';
15
+
16
+ const ENV_PROMPT_FILE = fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url));
17
+
18
+ export class MemoryWorld implements World {
19
+ readonly id = 'memory';
20
+ private host: WorldHost | null = null;
21
+ private timer: ReturnType<typeof setInterval> | null = null;
22
+ private seq = 0;
23
+ private mem: MemoryBase | null = null;
24
+ private vectors: VectorStore | null = null;
25
+ private facts: FactStore | null = null;
26
+ private kb: KnowledgeStore | null = null;
27
+ private reflect: ReflectionStore | null = null;
28
+ private persona: PersonaStore | null = null;
29
+ private profile: ProfileStore | null = null;
30
+ private embedder: EmbeddingClient | null = null;
31
+
32
+ constructor(private readonly ctx: WorldContext<MemoryConfig>) {}
33
+
34
+ /** 环境提示词变量:要点/摘要/话题/统计,每轮上下文构建时现取(纯内存读)。 */
35
+ envPromptVars(): Record<string, string> | null {
36
+ if (!this.mem || !this.vectors) return null; // 未启动 = 本 World 这段不进前缀
37
+ const view = buildView(this.mem, this.vectors, 'main', this.cfg.notesInPrompt, this.profile);
38
+ return promptVars(view);
39
+ }
40
+
41
+ private get cfg(): MemoryConfig {
42
+ return this.ctx.cfg;
43
+ }
44
+
45
+ tools(): ToolDef[] {
46
+ return Object.values(this.buildTools());
47
+ }
48
+
49
+ console() {
50
+ return {
51
+ config: [], // 旋钮由部署的 worlds/memory.json 提供,此处不重复
52
+ promptDocs: [
53
+ {
54
+ key: 'worlds.memory.envPrompt',
55
+ title: '记忆系统 · 环境提示词',
56
+ description: '进 system 前缀的那一段:记忆库的使用纪律与动态记忆上下文。',
57
+ path: ENV_PROMPT_FILE,
58
+ role: 'envPrompt',
59
+ vars: [
60
+ { name: 'memoryContext', description: '重要信息/摘要/话题/统计 的拼接文本' },
61
+ { name: 'memoryScopes', description: '现存记忆域列表' },
62
+ { name: 'memoryVectors', description: '向量索引覆盖数/总数' },
63
+ ],
64
+ },
65
+ ],
66
+ };
67
+ }
68
+
69
+ async start(host: WorldHost): Promise<void> {
70
+ this.host = host;
71
+ const ctxData = this.ctx.dataDir;
72
+ const dataDir = typeof ctxData === 'string' && ctxData ? ctxData : '.';
73
+ this.mem = new MemoryBase(dataDir, this.cfg.maxHistory);
74
+ this.vectors = new VectorStore(dataDir);
75
+ this.facts = new FactStore(dataDir);
76
+ this.kb = new KnowledgeStore(dataDir);
77
+ this.reflect = new ReflectionStore(dataDir);
78
+ this.persona = new PersonaStore(dataDir);
79
+ this.profile = new ProfileStore(dataDir);
80
+ this.embedder = new EmbeddingClient(this.cfg);
81
+
82
+ host.log.info('[memory] 启动', {
83
+ dataDir,
84
+ maxHistory: this.cfg.maxHistory,
85
+ embedding: this.cfg.embeddingProvider,
86
+ });
87
+ if (this.cfg.embeddingProvider === 'sidecar') {
88
+ const ok = await this.embedder.sidecarHealthy();
89
+ host.log.info('[memory] sidecar 探测', ok === true ? '在线' : '不在线(自动只用 BM25)');
90
+ }
91
+
92
+ this.timer = setInterval(() => {
93
+ this.push('memory.context', buildEventText(this.view()));
94
+ }, Math.max(10_000, this.cfg.observeIntervalMs));
95
+
96
+ this.push('memory.context', buildEventText(this.view()));
97
+ }
98
+
99
+ /** stop 必须 async 返回 Promise(框架 withDeadline 直接 work.then)。 */
100
+ async stop(): Promise<void> {
101
+ if (this.timer) clearInterval(this.timer);
102
+ this.timer = null;
103
+ this.mem?.flush();
104
+ this.vectors?.flush(); // 防抖写盘强制落盘
105
+ this.facts?.flush();
106
+ this.kb?.flush();
107
+ this.reflect?.flush();
108
+ this.persona?.flush();
109
+ this.profile?.flush();
110
+ }
111
+
112
+ private view() {
113
+ return buildView(this.mem!, this.vectors!, 'main', this.cfg.notesInPrompt, this.profile);
114
+ }
115
+
116
+ private push(type: string, text: string) {
117
+ if (!this.host) return;
118
+ this.seq += 1;
119
+ // 内部事件+piggyback:记忆快照进会话历史但不唤醒回合,否则每个周期会触发生成让 bot 自言自语。
120
+ this.host.pushEvent({
121
+ type,
122
+ ts: new Date().toISOString(),
123
+ source: this.id,
124
+ origin: 'internal',
125
+ text,
126
+ }, { trigger: 'piggyback' }).catch(() => {});
127
+ }
128
+
129
+ // ---- 工具表:mem_ 前缀(框架内工具名全局唯一,避开 mc_*/其它 World) ----
130
+ private buildTools(): Record<string, ToolDef> {
131
+ const str = (title: string) => ({ type: 'string', title });
132
+ const num = (title: string) => ({ type: 'number', title });
133
+ const mk = (
134
+ name: string,
135
+ description: string,
136
+ properties: Record<string, unknown>,
137
+ required: string[],
138
+ tags: ToolTag[],
139
+ run: (a: Record<string, any>) => Promise<string>,
140
+ extra: Partial<ToolDef> = {},
141
+ ): ToolDef => ({
142
+ name,
143
+ description,
144
+ parameters: { type: 'object', additionalProperties: false, properties, required },
145
+ tags,
146
+ handler: async (args) => {
147
+ try { return await run(args); } catch (e: any) { return `⚠ 调用失败: ${e?.message ?? e}`; }
148
+ },
149
+ ...extra,
150
+ });
151
+
152
+ const scopeOf = (a: Record<string, any>) => (typeof a.scope === 'string' && a.scope.trim() ? a.scope.trim() : 'main');
153
+
154
+ return {
155
+ mem_remember: mk('mem_remember', '把一件值得长期记住的事写进记忆库(同时落「事实库」做去重重排 + 建向量索引,之后 mem_recall 可按语义找回)。', { text: str('内容'), scope: str('记忆域') }, ['text'], ['write'],
156
+ async (a) => {
157
+ const scope = scopeOf(a);
158
+ const text = String(a.text);
159
+ const added = this.facts!.add(scope, [text]); // 事实库:SHA-256 去重 + 原子落盘
160
+ this.vectors!.add(scope, text, this.cfg.embeddingProvider === 'off' ? null : await this.embedder!.embed(text), this.cfg.maxVectorsPerScope);
161
+ return added ? `OK 已记住(${scope}): ${text.slice(0, 80)}` : `OK 已存在,跳过重复(${scope})`;
162
+ }),
163
+ mem_recall: mk('mem_recall', '按语义+关键词混合检索长期记忆(事实库全文 + BM25+向量+RRF 融合),找旧账/回忆约定时用。', { query: str('查询'), top_k: num('条数'), scope: str('记忆域') }, ['query'], ['read'],
164
+ async (a) => {
165
+ const scope = scopeOf(a);
166
+ const topK = Math.min(20, Number(a.top_k) || this.cfg.vectorTopK);
167
+ const query = String(a.query);
168
+ const vector = this.cfg.embeddingProvider === 'off' ? null : await this.embedder!.embed(query);
169
+ const hits = this.vectors!.hybridSearch(scope, query, vector, topK);
170
+ const facts = this.facts!.recall(scope, query, topK);
171
+ // 合并两路结果,按 text 去重,事实库命中的优先(更精确)。
172
+ const seen = new Set<string>();
173
+ const out: string[] = [];
174
+ for (const f of facts) if (!seen.has(f.text)) { seen.add(f.text); out.push(f.text); }
175
+ for (const h of hits) if (!seen.has(h.text)) { seen.add(h.text); out.push(h.text); }
176
+ if (!out.length) return `OK ${scope} 里没有匹配「${query}」的记忆`;
177
+ return `OK ${out.slice(0, topK).map((t, i) => `${i + 1}. ${t}`).join('\n')}`;
178
+ }),
179
+ mem_recent: mk('mem_recent', '回看本域最近的短期对话(最多 maxHistory 条)。', { n: num('条数'), scope: str('记忆域') }, [], ['read', 'snapshot'],
180
+ async (a) => {
181
+ const items = this.mem!.recent(scopeOf(a), Number(a.n) || 10);
182
+ if (!items.length) return 'OK 短期记忆是空的';
183
+ return `OK\n${items.map((it) => `${it.role}: ${it.content.slice(0, 120)}`).join('\n')}`;
184
+ }),
185
+ mem_overflow: mk('mem_overflow', '短期记忆快满时,取出最旧的一半条目(出窗),让它们被压缩进 mem_summary。', { scope: str('记忆域') }, [], ['act'],
186
+ async (a) => {
187
+ const items = this.mem!.overflowItems(scopeOf(a));
188
+ if (!items) return 'OK 短期记忆还没满,无需压缩';
189
+ return `OK 取出 ${items.length} 条:\n${items.map((it) => `${it.role}: ${it.content.slice(0, 120)}`).join('\n')}`;
190
+ }),
191
+ mem_summary: mk('mem_summary', '读或写本域的对话摘要。带 text 为写(建议先把 mem_overflow 取出的旧条目浓缩成一段);不带为读。', { text: str('摘要内容'), scope: str('记忆域') }, [], ['read', 'write'],
192
+ async (a) => {
193
+ const scope = scopeOf(a);
194
+ if (typeof a.text === 'string' && a.text.trim()) {
195
+ this.mem!.setSummary(scope, String(a.text));
196
+ return `OK 摘要已更新(${scope})`;
197
+ }
198
+ const s = this.mem!.getSummary(scope);
199
+ return s ? `OK 当前摘要: ${s}` : `OK ${scope} 还没有摘要`;
200
+ }),
201
+ mem_topic: mk('mem_topic', '读或写当前话题(话题切换时更新它,帮助上下文连贯)。', { text: str('话题内容'), scope: str('记忆域') }, [], ['read', 'write'],
202
+ async (a) => {
203
+ const scope = scopeOf(a);
204
+ if (typeof a.text === 'string' && a.text.trim()) {
205
+ this.mem!.setTopic(scope, String(a.text));
206
+ return `OK 话题已更新(${scope})`;
207
+ }
208
+ const t = this.mem!.getTopic(scope);
209
+ return t ? `OK 当前话题: ${t}` : `OK ${scope} 还没有设定话题`;
210
+ }),
211
+ mem_note: mk('mem_note', '记一条「重要信息」(用户明确要求记住的事/硬约束),它会常驻进环境提示词。', { text: str('内容'), scope: str('记忆域') }, ['text'], ['write'],
212
+ async (a) => {
213
+ const n = this.mem!.addNote(scopeOf(a), String(a.text));
214
+ return `OK 重要信息 #${n} 已记录`;
215
+ }),
216
+ mem_notes: mk('mem_notes', '列出本域的全部「重要信息」及编号(编号用于 mem_forget_note)。', { scope: str('记忆域') }, [], ['read'],
217
+ async (a) => {
218
+ const notes = this.mem!.getNotes(scopeOf(a));
219
+ if (!notes.length) return 'OK 还没有重要信息';
220
+ return `OK\n${notes.map((n, i) => `${i + 1}. ${n.text}`).join('\n')}`;
221
+ }),
222
+ mem_forget_note: mk('mem_forget_note', '按编号(1 起)删掉一条「重要信息」。先 mem_notes 确认编号再用。', { index: num('编号'), scope: str('记忆域') }, ['index'], ['write'],
223
+ async (a) => {
224
+ const ok = this.mem!.removeNote(scopeOf(a), Number(a.index) - 1);
225
+ return ok ? 'OK 已删除' : `FAIL 没有第 ${Number(a.index)} 条`;
226
+ }),
227
+ mem_knowledge: mk('mem_knowledge', '沉淀一条「通用知识」(联网学到的、主题化的知识条目,区别于 mem_remember 的零散事实)。同主题会合并,事实级去重,超量淘汰最旧。', { topic: str('主题'), facts: str('知识要点(多条用换行分隔)'), keywords: str('关键词(逗号分隔,可选)'), sources: str('来源(逗号分隔,可选)') }, ['topic', 'facts'], ['write'],
228
+ async (a) => {
229
+ const topic = String(a.topic ?? '').trim();
230
+ const facts = String(a.facts ?? '').split('\n').map((s) => s.trim()).filter(Boolean);
231
+ const keywords = (typeof a.keywords === 'string' ? a.keywords : '').split(/[,,]/).map((s) => s.trim()).filter(Boolean);
232
+ const sources = (typeof a.sources === 'string' ? a.sources : '').split(/[,,]/).map((s) => s.trim()).filter(Boolean);
233
+ const r = this.kb!.addEntry(topic, facts, keywords, sources);
234
+ if (!r.ok) return 'FAIL 主题和要点不能为空';
235
+ return `OK ${r.merged ? '已并入已有主题' : '新主题'}「${topic}」,新增 ${r.newFacts} 条事实,知识库共 ${r.total} 条`;
236
+ }),
237
+ mem_knowledge_fact: mk('mem_knowledge_fact', '把单条知识文本直接记进知识库(主题取前 30 字,自动提炼关键词)。', { content: str('知识内容') }, ['content'], ['write'],
238
+ async (a) => {
239
+ const r = this.kb!.addFact(String(a.content ?? ''));
240
+ return r.ok ? `OK 已沉淀(${r.merged ? '并入已有主题' : '新主题'}),知识库共 ${r.total} 条` : 'FAIL 内容为空';
241
+ }),
242
+ mem_knowledge_recall: mk('mem_knowledge_recall', '回答问题前先查知识库,命中就直接复用(省去重复搜索)。返回最相关主题及要点。', { query: str('查询/问题'), top_k: num('条数') }, ['query'], ['read'],
243
+ async (a) => {
244
+ const hits = this.kb!.recall(String(a.query ?? ''), Math.min(10, Number(a.top_k) || 2));
245
+ if (!hits.length) return 'OK 知识库里没有相关条目';
246
+ return `OK 命中 ${hits.length} 条:\n` + hits.map((e) => {
247
+ const lines = e.facts.map((f, i) => `${i + 1}. ${f}`).join('\n');
248
+ return `◆ ${e.topic}(学习日期 ${e.learnedAt})\n${lines}`;
249
+ }).join('\n');
250
+ }),
251
+ mem_knowledge_list: mk('mem_knowledge_list', '列出知识库全部主题(及命中数),便于总览已沉淀的知识。', { limit: num('条数') }, [], ['read'],
252
+ async (a) => {
253
+ const entries = this.kb!.count();
254
+ if (!entries) return 'OK 知识库还是空的';
255
+ return `OK 知识库共 ${entries} 条主题`;
256
+ }),
257
+ mem_reflect: mk('mem_reflect', '沉淀一条「反思/交互偏好/纠错」(五维记忆之反思维):用户偏好(preference)、交互风格(style)、用户洞察(insight)、AI纠错(correction)、对话模式(pattern)。可执行的会被提炼为常驻交互规则。', { content: str('反思内容(一句话)'), type: str('类型:preference/style/insight/correction/pattern'), evidence: str('支撑证据/对话片段(可选)'), user_id: str('用户ID(默认 main)'), confidence: num('置信度0-1'), actionable: num('是否可执行(1/0)') }, ['content', 'type'], ['write'],
258
+ async (a) => {
259
+ const type = String(a.type ?? 'insight');
260
+ if (!['preference', 'style', 'insight', 'correction', 'pattern'].includes(type)) return 'FAIL 类型必须是 preference/style/insight/correction/pattern 之一';
261
+ const r = this.reflect!.reflect(
262
+ String(a.content ?? ''),
263
+ type as any,
264
+ String(a.user_id ?? 'main'),
265
+ typeof a.evidence === 'string' ? a.evidence : undefined,
266
+ typeof a.confidence === 'number' ? Math.max(0, Math.min(1, a.confidence)) : 0.6,
267
+ Number(a.actionable ?? 1) >= 1,
268
+ );
269
+ return r.added ? `OK 反思已沉淀(${type});可执行的已提炼为交互规则` : 'FAIL 内容为空或重复';
270
+ }),
271
+ mem_reflect_rules: mk('mem_reflect_rules', '列出已沉淀的交互规则(从可执行反思提炼,常驻指导后续交互)。', { user_id: str('用户ID(默认 main)') }, [], ['read'],
272
+ async (a) => {
273
+ const { rules } = this.reflect!.getFor(String(a.user_id ?? 'main'));
274
+ if (!rules.length) return 'OK 还没有交互规则';
275
+ return `OK 交互规则(${rules.length}):\n` + rules.map((r, i) => `${i + 1}. [${r.type}] ${r.rule} (置信 ${(r.confidence ?? 0).toFixed(2)})`).join('\n');
276
+ }),
277
+ mem_persona: mk('mem_persona', '记录与某用户的独特相处模式(五维记忆之人格维):特征列表 + 风格描述,per-user 隔离、特征去重、上限20。', { user_id: str('用户ID(默认 main)'), traits: str('相处特征(多条用换行或逗号分隔)'), style: str('整体风格描述(可选)') }, ['user_id', 'traits'], ['write'],
278
+ async (a) => {
279
+ const uid = String(a.user_id ?? 'main');
280
+ const traits = String(a.traits ?? '').split(/[\n,,]/).map((s) => s.trim()).filter(Boolean);
281
+ const r = this.persona!.update(uid, traits, typeof a.style === 'string' ? a.style : '');
282
+ return `OK 已更新 ${uid} 的人格记忆,当前特征 ${r.traits} 条`;
283
+ }),
284
+ mem_persona_get: mk('mem_persona_get', '读取某用户的人格记忆(特征+风格),不存在则提示。', { user_id: str('用户ID(默认 main)') }, [], ['read'],
285
+ async (a) => {
286
+ const p = this.persona!.get(String(a.user_id ?? 'main'));
287
+ if (!p) return 'OK 还没有这个人设记忆';
288
+ const traits = p.traits.length ? p.traits.map((t, i) => `${i + 1}. ${t}`).join('\n') : '(无特征)';
289
+ return `OK 人格记忆:\n特征:\n${traits}${p.style ? `\n风格: ${p.style}` : ''}`;
290
+ }),
291
+ mem_persona_global: mk('mem_persona_global', '设置/读取全局风格(跨用户统一的人格基调,可选)。', { style: str('全局风格(留空为读)') }, [], ['read', 'write'],
292
+ async (a) => {
293
+ if (typeof a.style === 'string' && a.style.trim()) {
294
+ this.persona!.setGlobalStyle(a.style);
295
+ return 'OK 全局风格已更新';
296
+ }
297
+ const g = this.persona!.getGlobalStyle();
298
+ return g ? `OK 全局风格: ${g}` : 'OK 还没有设置全局风格';
299
+ }),
300
+ mem_profile: mk('mem_profile', '记/更新某用户的「人物档案」(永久记住的关键信息:称呼/身份/关系等),常驻进环境提示词。合并新事实+去重+上限50。', { user_id: str('用户ID(默认 main)'), facts: str('人物事实(多条用换行或逗号分隔)') }, ['user_id', 'facts'], ['write'],
301
+ async (a) => {
302
+ const uid = String(a.user_id ?? 'main');
303
+ const facts = String(a.facts ?? '').split(/[\n,,]/).map((s) => s.trim()).filter(Boolean);
304
+ const r = this.profile!.update(uid, facts);
305
+ return `OK 已更新 ${uid} 人物档案,当前 ${r.total} 条`;
306
+ }),
307
+ mem_profile_replace: mk('mem_profile_replace', '整体替换某用户人物档案(用于纠错,例如用户说「别再叫我XX」)。', { user_id: str('用户ID(默认 main)'), facts: str('正确的人物事实(多条用换行或逗号分隔)') }, ['user_id', 'facts'], ['write'],
308
+ async (a) => {
309
+ const uid = String(a.user_id ?? 'main');
310
+ const facts = String(a.facts ?? '').split(/[\n,,]/).map((s) => s.trim()).filter(Boolean);
311
+ const r = this.profile!.replace(uid, facts);
312
+ return `OK 已替换 ${uid} 人物档案,当前 ${r.total} 条`;
313
+ }),
314
+ mem_profile_get: mk('mem_profile_get', '读取某用户人物档案。', { user_id: str('用户ID(默认 main)') }, [], ['read'],
315
+ async (a) => {
316
+ const facts = this.profile!.get(String(a.user_id ?? 'main'));
317
+ return facts.length ? `OK 人物档案:\n${facts.map((f, i) => `${i + 1}. ${f}`).join('\n')}` : 'OK 还没有人物档案';
318
+ }),
319
+ mem_stats: mk('mem_stats', '记忆库统计:记忆域/条数/向量索引覆盖/当前配置的向量来源。', {}, [], ['snapshot', 'read'],
320
+ async () => {
321
+ const stats = this.vectors!.stats();
322
+ const sidecar = await this.embedder!.sidecarHealthy();
323
+ return `OK ${JSON.stringify({
324
+ scopes: stats.scopes,
325
+ total: stats.total,
326
+ indexed: stats.indexed,
327
+ embedding: this.cfg.embeddingProvider,
328
+ ...(sidecar !== null ? { sidecarOnline: sidecar } : {}),
329
+ maxHistory: this.cfg.maxHistory,
330
+ })}`;
331
+ }),
332
+ mem_flush: mk('mem_flush', '把记忆立即落盘(平时自动防抖落盘,一般不用调)。', {}, [], ['act'],
333
+ async () => { this.mem?.flush(); this.vectors?.flush(); this.facts?.flush(); this.kb?.flush(); this.reflect?.flush(); this.persona?.flush(); this.profile?.flush(); return 'OK 已落盘'; }),
334
+ };
335
+ }
336
+ }