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/README.md +32 -0
- package/package.json +35 -0
- package/src/ENV_PROMPT.md +15 -0
- package/src/config.ts +77 -0
- package/src/cortico-shim.d.ts +89 -0
- package/src/definition.ts +10 -0
- package/src/embedding.ts +79 -0
- package/src/fact-store.ts +114 -0
- package/src/float16.ts +84 -0
- package/src/index.ts +12 -0
- package/src/knowledge-store.ts +169 -0
- package/src/memory.ts +181 -0
- package/src/observe.ts +60 -0
- package/src/persona-store.ts +100 -0
- package/src/profile-store.ts +102 -0
- package/src/reflection-store.ts +152 -0
- package/src/vector-store.ts +327 -0
- package/src/world.ts +336 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface KnowledgeEntry {
|
|
5
|
+
id: string;
|
|
6
|
+
topic: string;
|
|
7
|
+
keywords: string[];
|
|
8
|
+
facts: string[];
|
|
9
|
+
sources: string[];
|
|
10
|
+
learnedAt: string;
|
|
11
|
+
updated: string;
|
|
12
|
+
hits: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface KbFile {
|
|
16
|
+
entries: KnowledgeEntry[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MAX_ENTRIES = 200;
|
|
20
|
+
const MAX_FACTS_PER_ENTRY = 20;
|
|
21
|
+
const MAX_KEYWORDS = 12;
|
|
22
|
+
const MAX_SOURCES = 8;
|
|
23
|
+
|
|
24
|
+
const norm = (s: string): string => (s ?? '').replace(/\s+/g, '').toLowerCase();
|
|
25
|
+
// 召回匹配域:主题 + 关键词 + 事实全文(否则中文事实只在 fact 文本里、关键词里没有,会检索不到)。
|
|
26
|
+
const entryBlob = (e: KnowledgeEntry): string => [e.topic, ...e.keywords, ...e.facts].join(' ').toLowerCase();
|
|
27
|
+
const queryTokens = (q: string): string[] =>
|
|
28
|
+
(q.toLowerCase().match(/[a-z0-9][a-z0-9.\-_/#]*/g) ?? []).filter((t) => t.length >= 2);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 通用知识库(对齐 qq_bot knowledge_store):
|
|
32
|
+
* - 联网学到的通用知识沉淀于此(与 fact_store 事实库区分:这里是「主题化知识条目」)
|
|
33
|
+
* - add:同主题合并、事实级全库去重、超量淘汰最旧
|
|
34
|
+
* - recall:主题/关键词命中 + 全 token 命中,复用知识省去重复搜索
|
|
35
|
+
* - 原子落盘(临时文件 + rename)
|
|
36
|
+
*/
|
|
37
|
+
export class KnowledgeStore {
|
|
38
|
+
private readonly file: string;
|
|
39
|
+
private data: KbFile = { entries: [] };
|
|
40
|
+
private dirty = false;
|
|
41
|
+
private readonly flushTimer: ReturnType<typeof setInterval>;
|
|
42
|
+
|
|
43
|
+
constructor(dataDir: string, flushMs = 2000) {
|
|
44
|
+
this.file = join(dataDir, 'knowledge-base.json');
|
|
45
|
+
this.load();
|
|
46
|
+
this.flushTimer = setInterval(() => this.flush(), flushMs);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private load(): void {
|
|
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
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
this.data = { entries: [] };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private saveNow(): void {
|
|
61
|
+
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
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
flush(): void {
|
|
74
|
+
clearInterval(this.flushTimer);
|
|
75
|
+
this.saveNow();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 添加/合并一条知识(对应 add_entry):主题合并 + 事实级去重 + 超量淘汰最旧。 */
|
|
79
|
+
addEntry(topic: string, facts: string[], keywords: string[] = [], sources: string[] = []): { ok: boolean; newFacts: number; merged: boolean; total: number } {
|
|
80
|
+
const t = (topic ?? '').trim();
|
|
81
|
+
const fs = [...new Set((facts ?? []).map((f) => (f ?? '').trim()).filter(Boolean))];
|
|
82
|
+
const kws = [...new Set((keywords ?? []).map((k) => (k ?? '').trim()).filter(Boolean))];
|
|
83
|
+
const srcs = [...new Set((sources ?? []).map((s) => (s ?? '').trim()).filter(Boolean))];
|
|
84
|
+
if (!t || !fs.length) return { ok: false, newFacts: 0, merged: false, total: this.data.entries.length };
|
|
85
|
+
|
|
86
|
+
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
|
87
|
+
const date = now.slice(0, 10);
|
|
88
|
+
const knownFacts = new Set(this.data.entries.flatMap((e) => e.facts.map(norm)));
|
|
89
|
+
const newFacts = fs.filter((f) => !knownFacts.has(norm(f)));
|
|
90
|
+
|
|
91
|
+
const target = this.data.entries.find((e) => norm(e.topic) === norm(t)) ?? null;
|
|
92
|
+
const merged = target !== null;
|
|
93
|
+
if (!target) {
|
|
94
|
+
const entry: KnowledgeEntry = {
|
|
95
|
+
id: Math.random().toString(36).slice(2, 10),
|
|
96
|
+
topic: t,
|
|
97
|
+
keywords: [],
|
|
98
|
+
facts: [],
|
|
99
|
+
sources: [],
|
|
100
|
+
learnedAt: date,
|
|
101
|
+
updated: now,
|
|
102
|
+
hits: 0,
|
|
103
|
+
};
|
|
104
|
+
this.data.entries.push(entry);
|
|
105
|
+
var tgt = entry;
|
|
106
|
+
} else {
|
|
107
|
+
var tgt = target;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (newFacts.length) {
|
|
111
|
+
tgt.facts = [...tgt.facts, ...newFacts].slice(-MAX_FACTS_PER_ENTRY);
|
|
112
|
+
tgt.updated = now;
|
|
113
|
+
}
|
|
114
|
+
const existKw = new Set(tgt.keywords.map((k) => k.toLowerCase()));
|
|
115
|
+
for (const k of kws) if (!existKw.has(k.toLowerCase())) { tgt.keywords.push(k); existKw.add(k.toLowerCase()); }
|
|
116
|
+
tgt.keywords = tgt.keywords.slice(0, MAX_KEYWORDS);
|
|
117
|
+
const existSrc = new Set(tgt.sources);
|
|
118
|
+
for (const s of srcs) if (!existSrc.has(s)) { tgt.sources.push(s); existSrc.add(s); }
|
|
119
|
+
tgt.sources = tgt.sources.slice(0, MAX_SOURCES);
|
|
120
|
+
|
|
121
|
+
if (this.data.entries.length > MAX_ENTRIES) {
|
|
122
|
+
this.data.entries.sort((a, b) => a.updated.localeCompare(b.updated));
|
|
123
|
+
this.data.entries = this.data.entries.slice(-MAX_ENTRIES);
|
|
124
|
+
}
|
|
125
|
+
this.dirty = true;
|
|
126
|
+
return { ok: true, newFacts: newFacts.length, merged, total: this.data.entries.length };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 单条文本入知识库(对应 add_fact):主题取前 30 字,关键词取英文/数字词。 */
|
|
130
|
+
addFact(content: string): { ok: boolean; newFacts: number; merged: boolean; total: number } {
|
|
131
|
+
const c = (content ?? '').trim();
|
|
132
|
+
if (!c) return { ok: false, newFacts: 0, merged: false, total: this.data.entries.length };
|
|
133
|
+
const tokens = [...new Set(c.toLowerCase().match(/[a-z0-9][a-z0-9.\-_/#]*/g) ?? [])].slice(0, 6);
|
|
134
|
+
return this.addEntry(c.slice(0, 30).trim(), [c], tokens, []);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 检索知识(对应 build_recall_context 的检索部分)。 */
|
|
138
|
+
recall(query: string, limit = 2): KnowledgeEntry[] {
|
|
139
|
+
const q = (query ?? '').trim().toLowerCase();
|
|
140
|
+
if (!q) return [];
|
|
141
|
+
const qTokens = queryTokens(query);
|
|
142
|
+
const scored: { e: KnowledgeEntry; score: number }[] = [];
|
|
143
|
+
for (const e of this.data.entries) {
|
|
144
|
+
let score = 0;
|
|
145
|
+
for (const kw of [e.topic, ...e.keywords]) {
|
|
146
|
+
const k = kw.trim().toLowerCase();
|
|
147
|
+
if (k.length >= 3 && q.includes(k)) score = Math.max(score, k === e.topic.toLowerCase() ? 3 : 2);
|
|
148
|
+
}
|
|
149
|
+
// 事实文本命中(中文/长句常只在 fact 里):给基础分 1。
|
|
150
|
+
if (score === 0) {
|
|
151
|
+
for (const f of e.facts) {
|
|
152
|
+
const fk = f.trim().toLowerCase();
|
|
153
|
+
if (fk && q.includes(fk)) { score = 1; break; }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (score === 0 && qTokens.length && qTokens.every((t) => entryBlob(e).includes(t))) score = 1;
|
|
157
|
+
if (score > 0) scored.push({ e, score });
|
|
158
|
+
}
|
|
159
|
+
scored.sort((a, b) => b.score - a.score || b.e.hits - a.e.hits);
|
|
160
|
+
const hits = scored.slice(0, Math.max(1, limit)).map((x) => x.e);
|
|
161
|
+
for (const h of hits) h.hits += 1; // 命中累加(对齐 mark_used)
|
|
162
|
+
this.dirty = true;
|
|
163
|
+
return hits;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
count(): number {
|
|
167
|
+
return this.data.entries.length;
|
|
168
|
+
}
|
|
169
|
+
}
|
package/src/memory.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 三级记忆底座 —— 移植自 E:/qq_bot/memory.py 的 `Memory` 类,qq_bot 源码零改动。
|
|
3
|
+
*
|
|
4
|
+
* 对齐项:
|
|
5
|
+
* - 短期:每 scope 保留最近 maxHistory 条原始对话(cap 环形语义);
|
|
6
|
+
* - 摘要:超窗口旧历史压缩后的长期保留(这里提供存取,压缩本身由人格用工具完成);
|
|
7
|
+
* - 话题:当前话题串,供话题切换检测;
|
|
8
|
+
* - 持久化:JSON 落盘 + 2 秒防抖合并写 + close() 强制刷盘(对齐 atexit 语义);
|
|
9
|
+
* - overflow_items:取前一半旧条目出窗(与 python 版同一半数保留策略)。
|
|
10
|
+
* 差异:qq_bot 按 `user:{id}` 统一键;Cortico 侧没有 IM 用户概念,scope 由调用方给
|
|
11
|
+
* (默认 'main',可按渠道/世界分域,如 'mc'、'qq:21907')。
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
export interface ShortTermEntry {
|
|
17
|
+
role: 'user' | 'assistant' | 'system';
|
|
18
|
+
content: string;
|
|
19
|
+
ts: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SAVE_DEBOUNCE_MS = 2000;
|
|
23
|
+
|
|
24
|
+
export class MemoryBase {
|
|
25
|
+
private readonly store = new Map<string, ShortTermEntry[]>();
|
|
26
|
+
private readonly summary = new Map<string, string>();
|
|
27
|
+
private readonly topic = new Map<string, string>();
|
|
28
|
+
private readonly notes = new Map<string, Array<{ text: string; ts: number }>>();
|
|
29
|
+
private readonly file: string;
|
|
30
|
+
private dirty = false;
|
|
31
|
+
private lastSave = 0;
|
|
32
|
+
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
dataDir: string,
|
|
36
|
+
private readonly maxHistory: number,
|
|
37
|
+
) {
|
|
38
|
+
mkdirSync(dataDir, { recursive: true });
|
|
39
|
+
this.file = join(dataDir, 'memory-data.json');
|
|
40
|
+
this.load();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------- 持久化(防抖,对齐 _SAVE_DEBOUNCE) ----------------
|
|
44
|
+
private load(): void {
|
|
45
|
+
if (!existsSync(this.file)) return;
|
|
46
|
+
try {
|
|
47
|
+
const raw = JSON.parse(readFileSync(this.file, 'utf8')) as {
|
|
48
|
+
store?: Record<string, ShortTermEntry[]>;
|
|
49
|
+
summary?: Record<string, string>;
|
|
50
|
+
topic?: Record<string, string>;
|
|
51
|
+
notes?: Record<string, Array<{ text: string; ts: number }>>;
|
|
52
|
+
};
|
|
53
|
+
for (const [k, v] of Object.entries(raw.store ?? {})) if (Array.isArray(v)) this.store.set(k, v.slice(-this.maxHistory));
|
|
54
|
+
for (const [k, v] of Object.entries(raw.summary ?? {})) if (typeof v === 'string') this.summary.set(k, v);
|
|
55
|
+
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
|
+
} catch { /* 损坏按空起步 */ }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private scheduleSave(): void {
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
if (now - this.lastSave < SAVE_DEBOUNCE_MS) {
|
|
63
|
+
this.dirty = true;
|
|
64
|
+
if (!this.flushTimer) {
|
|
65
|
+
this.flushTimer = setTimeout(() => { this.flushTimer = null; this.flush(); }, SAVE_DEBOUNCE_MS);
|
|
66
|
+
this.flushTimer.unref?.();
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
this.flush();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 全量落盘(防抖到期 / stop 时 / mem_flush 工具都会调)。 */
|
|
74
|
+
flush(): void {
|
|
75
|
+
try {
|
|
76
|
+
const dump = (m: Map<string, unknown>) => Object.fromEntries([...m.entries()].filter(([, v]) => Array.isArray(v) ? (v as unknown[]).length : !!v));
|
|
77
|
+
const data = {
|
|
78
|
+
store: dump(this.store) as Record<string, ShortTermEntry[]>,
|
|
79
|
+
summary: dump(this.summary) as Record<string, string>,
|
|
80
|
+
topic: dump(this.topic) as Record<string, string>,
|
|
81
|
+
notes: dump(this.notes) as Record<string, Array<{ text: string; ts: number }>>,
|
|
82
|
+
};
|
|
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);
|
|
89
|
+
this.dirty = false;
|
|
90
|
+
this.lastSave = Date.now();
|
|
91
|
+
} catch { /* 磁盘失败不炸主流程 */ }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------- 短期 ----------------
|
|
95
|
+
add(scope: string, role: ShortTermEntry['role'], content: string): void {
|
|
96
|
+
const s = scope || 'main';
|
|
97
|
+
const list = (this.store.get(s) ?? []).slice();
|
|
98
|
+
list.push({ role, content, ts: Date.now() });
|
|
99
|
+
this.store.set(s, list.slice(-this.maxHistory));
|
|
100
|
+
this.scheduleSave();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
recent(scope: string, n?: number): ShortTermEntry[] {
|
|
104
|
+
const list = this.store.get(scope || 'main') ?? [];
|
|
105
|
+
return n && n > 0 ? list.slice(-n) : [...list];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 短期是否已满(对齐 is_full)。 */
|
|
109
|
+
isFull(scope: string): boolean {
|
|
110
|
+
return (this.store.get(scope || 'main')?.length ?? 0) >= this.maxHistory;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 取出需要压缩进摘要的旧条目(前一半),短期保留后一半。对齐 overflow_items。
|
|
115
|
+
* 返回 null = 还没满到该压缩。
|
|
116
|
+
*/
|
|
117
|
+
overflowItems(scope: string): ShortTermEntry[] | null {
|
|
118
|
+
const s = scope || 'main';
|
|
119
|
+
const items = this.store.get(s) ?? [];
|
|
120
|
+
if (items.length <= this.maxHistory / 2) return null;
|
|
121
|
+
const overflow = items.slice(0, items.length - Math.floor(this.maxHistory / 2));
|
|
122
|
+
this.store.set(s, items.slice(items.length - Math.floor(this.maxHistory / 2)));
|
|
123
|
+
this.scheduleSave();
|
|
124
|
+
return overflow;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
clearShortTerm(scope: string): void {
|
|
128
|
+
this.store.delete(scope || 'main');
|
|
129
|
+
this.scheduleSave();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------- 摘要 / 话题 ----------------
|
|
133
|
+
setSummary(scope: string, text: string): void {
|
|
134
|
+
if (text.trim()) this.summary.set(scope || 'main', text.trim());
|
|
135
|
+
else this.summary.delete(scope || 'main');
|
|
136
|
+
this.scheduleSave();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
getSummary(scope: string): string {
|
|
140
|
+
return this.summary.get(scope || 'main') ?? '';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
setTopic(scope: string, text: string): void {
|
|
144
|
+
if (text.trim()) this.topic.set(scope || 'main', text.trim());
|
|
145
|
+
else this.topic.delete(scope || 'main');
|
|
146
|
+
this.scheduleSave();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
getTopic(scope: string): string {
|
|
150
|
+
return this.topic.get(scope || 'main') ?? '';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------- 重要信息(对齐 important_notes 的存取语义) ----------------
|
|
154
|
+
addNote(scope: string, text: string): number {
|
|
155
|
+
const s = scope || 'main';
|
|
156
|
+
const list = this.notes.get(s) ?? [];
|
|
157
|
+
list.push({ text: text.trim(), ts: Date.now() });
|
|
158
|
+
this.notes.set(s, list.slice(-100));
|
|
159
|
+
this.scheduleSave();
|
|
160
|
+
return list.length - 1; // 返回新笔记的 0-based 索引(与 mem_forget_note 对齐)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
getNotes(scope: string): Array<{ text: string; ts: number }> {
|
|
164
|
+
return this.notes.get(scope || 'main') ?? [];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
removeNote(scope: string, index: number): boolean {
|
|
168
|
+
const list = this.notes.get(scope || 'main') ?? [];
|
|
169
|
+
if (index < 0 || index >= list.length) return false;
|
|
170
|
+
list.splice(index, 1);
|
|
171
|
+
this.notes.set(scope || 'main', list);
|
|
172
|
+
this.scheduleSave();
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------- 全局 ----------------
|
|
177
|
+
scopes(): string[] {
|
|
178
|
+
const set = new Set<string>([...this.store.keys(), ...this.summary.keys(), ...this.topic.keys(), ...this.notes.keys()]);
|
|
179
|
+
return [...set].sort();
|
|
180
|
+
}
|
|
181
|
+
}
|
package/src/observe.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// 把记忆状态拼成「她此刻能想起的东西」——对应 qq_bot memory_context.build_memory_messages
|
|
2
|
+
// 的注入语义,但走 Cortico 的 envPrompt 插值 + 事件推送两条通道。
|
|
3
|
+
import type { MemoryBase } from './memory.ts';
|
|
4
|
+
import type { VectorStore } from './vector-store.ts';
|
|
5
|
+
import type { ProfileStore } from './profile-store.ts';
|
|
6
|
+
|
|
7
|
+
export interface MemoryView {
|
|
8
|
+
topic: string;
|
|
9
|
+
summary: string;
|
|
10
|
+
notes: Array<{ text: string; ts: number }>;
|
|
11
|
+
scopes: string[];
|
|
12
|
+
vectorStats: { scopes: number; total: number; indexed: number; model: string };
|
|
13
|
+
profileHint: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildView(mem: MemoryBase, vectors: VectorStore, scope: string, notesInPrompt: number, profile: ProfileStore | null): MemoryView {
|
|
17
|
+
return {
|
|
18
|
+
topic: mem.getTopic(scope),
|
|
19
|
+
summary: mem.getSummary(scope),
|
|
20
|
+
notes: mem.getNotes(scope).slice(-notesInPrompt),
|
|
21
|
+
scopes: mem.scopes(),
|
|
22
|
+
vectorStats: vectors.stats(),
|
|
23
|
+
profileHint: profile ? profile.hint(scope) : '',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 环境提示词的动态变量值(envPromptVars 用;快,纯内存读)。 */
|
|
28
|
+
export function promptVars(view: MemoryView): Record<string, string> {
|
|
29
|
+
const lines: string[] = [];
|
|
30
|
+
if (view.profileHint) {
|
|
31
|
+
lines.push('【人物档案】');
|
|
32
|
+
lines.push(view.profileHint);
|
|
33
|
+
}
|
|
34
|
+
if (view.notes.length) {
|
|
35
|
+
lines.push('【重要信息】(用户/你明确要求记住的)');
|
|
36
|
+
view.notes.forEach((n, i) => lines.push(`${i + 1}. ${n.text}`));
|
|
37
|
+
}
|
|
38
|
+
if (view.summary) lines.push(`【之前的对话摘要】${view.summary}`);
|
|
39
|
+
if (view.topic) lines.push(`【当前话题】${view.topic}`);
|
|
40
|
+
const { total, indexed } = view.vectorStats;
|
|
41
|
+
lines.push(`【记忆库】长期记忆 ${total} 条(其中 ${indexed} 条已建向量索引)。`);
|
|
42
|
+
return {
|
|
43
|
+
memoryContext: lines.join('\n'),
|
|
44
|
+
memoryScopes: view.scopes.join(', ') || '(无)',
|
|
45
|
+
memoryVectors: `${indexed}/${total}`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 周期推送用的事件正文(比提示词变量更啰嗦,给人格当「记忆快照」)。 */
|
|
50
|
+
export function buildEventText(view: MemoryView): string {
|
|
51
|
+
const lines: string[] = ['# 记忆快照 (memory)'];
|
|
52
|
+
const { total, indexed, scopes } = view.vectorStats;
|
|
53
|
+
lines.push(`- 记忆域: ${view.scopes.join(', ') || '(空)'} / 向量索引: ${indexed}/${total} 条 / 库: ${scopes || '无'}`);
|
|
54
|
+
if (view.topic) lines.push(`- 当前话题: ${view.topic}`);
|
|
55
|
+
if (view.profileHint) lines.push(`- 人物档案: ${view.profileHint}`);
|
|
56
|
+
if (view.summary) lines.push(`- 对话摘要: ${view.summary}`);
|
|
57
|
+
if (view.notes.length) lines.push(`- 重要信息: ${view.notes.map((n) => n.text).join(' / ')}`);
|
|
58
|
+
lines.push(`\n要记的事用 mem_remember / mem_note 存;找旧账用 mem_recall 混合检索;没别的就用 mem_recall 或继续手头的事。`);
|
|
59
|
+
return lines.join('\n');
|
|
60
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
interface Persona {
|
|
5
|
+
traits: string[];
|
|
6
|
+
style: string;
|
|
7
|
+
updated: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface PersonaFile {
|
|
11
|
+
personas: Record<string, Persona>;
|
|
12
|
+
globalStyle: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const MAX_TRAITS_PER_USER = 20;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 人格记忆(对齐 qq_bot persona_memory,五维记忆第五维):
|
|
19
|
+
* - 与每个用户的独特相处模式(特征 traits + 风格 style),per-user 隔离
|
|
20
|
+
* - 特征合并去重 + 上限淘汰;全局风格可选
|
|
21
|
+
* - 原子落盘(临时文件 + rename)
|
|
22
|
+
*/
|
|
23
|
+
export class PersonaStore {
|
|
24
|
+
private readonly file: string;
|
|
25
|
+
private data: PersonaFile = { personas: {}, globalStyle: '' };
|
|
26
|
+
private dirty = false;
|
|
27
|
+
private readonly flushTimer: ReturnType<typeof setInterval>;
|
|
28
|
+
|
|
29
|
+
constructor(dataDir: string, flushMs = 2000) {
|
|
30
|
+
this.file = join(dataDir, 'persona-data.json');
|
|
31
|
+
this.load();
|
|
32
|
+
this.flushTimer = setInterval(() => this.flush(), flushMs);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private load(): void {
|
|
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
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
/* 损坏则重置 */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private saveNow(): void {
|
|
50
|
+
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
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
flush(): void {
|
|
63
|
+
clearInterval(this.flushTimer);
|
|
64
|
+
this.saveNow();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 更新某用户的人格记忆(特征合并去重 + 上限)。 */
|
|
68
|
+
update(userId: string, traits: string[], style = ''): { traits: number } {
|
|
69
|
+
const uid = String(userId);
|
|
70
|
+
const persona = this.data.personas[uid] ?? (this.data.personas[uid] = { traits: [], style: '', updated: 0 });
|
|
71
|
+
const existing = new Set(persona.traits);
|
|
72
|
+
for (const t of traits.map((x) => (x ?? '').trim()).filter(Boolean)) {
|
|
73
|
+
if (!existing.has(t)) existing.add(t);
|
|
74
|
+
}
|
|
75
|
+
let list = [...existing];
|
|
76
|
+
if (list.length > MAX_TRAITS_PER_USER) list = list.slice(-MAX_TRAITS_PER_USER);
|
|
77
|
+
persona.traits = list;
|
|
78
|
+
if (style && style.trim()) persona.style = style.trim();
|
|
79
|
+
persona.updated = Date.now() / 1000;
|
|
80
|
+
this.dirty = true;
|
|
81
|
+
return { traits: list.length };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
setGlobalStyle(style: string): void {
|
|
85
|
+
this.data.globalStyle = (style ?? '').trim();
|
|
86
|
+
this.dirty = true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get(userId: string): Persona | null {
|
|
90
|
+
return this.data.personas[String(userId)] ?? null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
getGlobalStyle(): string {
|
|
94
|
+
return this.data.globalStyle;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
count(): number {
|
|
98
|
+
return Object.keys(this.data.personas).length;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
interface Profile {
|
|
5
|
+
facts: string[];
|
|
6
|
+
updated: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ProfileFile {
|
|
10
|
+
profiles: Record<string, Profile>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const MAX_FACTS_PER_USER = 50;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 人物档案(对齐 qq_bot long_term_memory 的 profile 部分):
|
|
17
|
+
* - 永久记住用户关键信息(称呼/身份/关系等),常驻进环境提示词
|
|
18
|
+
* - update:合并新事实+去重+上限50(对齐 update_profile)
|
|
19
|
+
* - replace:整体替换(用于纠错,对齐 replace_profile)
|
|
20
|
+
* - 原子落盘(临时文件 + rename)
|
|
21
|
+
*/
|
|
22
|
+
export class ProfileStore {
|
|
23
|
+
private readonly file: string;
|
|
24
|
+
private data: ProfileFile = { profiles: {} };
|
|
25
|
+
private dirty = false;
|
|
26
|
+
private readonly flushTimer: ReturnType<typeof setInterval>;
|
|
27
|
+
|
|
28
|
+
constructor(dataDir: string, flushMs = 2000) {
|
|
29
|
+
this.file = join(dataDir, 'user-profiles.json');
|
|
30
|
+
this.load();
|
|
31
|
+
this.flushTimer = setInterval(() => this.flush(), flushMs);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private load(): void {
|
|
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;
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
this.data = { profiles: {} };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private saveNow(): void {
|
|
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
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
flush(): void {
|
|
59
|
+
clearInterval(this.flushTimer);
|
|
60
|
+
this.saveNow();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 合并新事实(去重 + 上限50)。 */
|
|
64
|
+
update(userId: string, facts: string[]): { total: number } {
|
|
65
|
+
const uid = String(userId);
|
|
66
|
+
const p = this.data.profiles[uid] ?? (this.data.profiles[uid] = { facts: [], updated: 0 });
|
|
67
|
+
const existing = new Set(p.facts);
|
|
68
|
+
for (const f of facts.map((x) => (x ?? '').trim()).filter(Boolean)) {
|
|
69
|
+
if (!existing.has(f)) existing.add(f);
|
|
70
|
+
}
|
|
71
|
+
let list = [...existing];
|
|
72
|
+
if (list.length > MAX_FACTS_PER_USER) list = list.slice(-MAX_FACTS_PER_USER);
|
|
73
|
+
p.facts = list;
|
|
74
|
+
p.updated = Date.now() / 1000;
|
|
75
|
+
this.dirty = true;
|
|
76
|
+
return { total: list.length };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 整体替换(纠错,对齐 replace_profile)。 */
|
|
80
|
+
replace(userId: string, facts: string[]): { total: number } {
|
|
81
|
+
const uid = String(userId);
|
|
82
|
+
const list = [...new Set(facts.map((x) => (x ?? '').trim()).filter(Boolean))].slice(-MAX_FACTS_PER_USER);
|
|
83
|
+
this.data.profiles[uid] = { facts: list, updated: Date.now() / 1000 };
|
|
84
|
+
this.dirty = true;
|
|
85
|
+
return { total: list.length };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get(userId: string): string[] {
|
|
89
|
+
return this.data.profiles[String(userId)]?.facts ?? [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 生成注入提示词用的文本(对齐 build_profile_hint)。 */
|
|
93
|
+
hint(userId: string): string {
|
|
94
|
+
const facts = this.get(userId);
|
|
95
|
+
if (!facts.length) return '';
|
|
96
|
+
return `人物档案(${userId}):\n` + facts.map((f, i) => `${i + 1}. ${f}`).join('\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
count(): number {
|
|
100
|
+
return Object.keys(this.data.profiles).length;
|
|
101
|
+
}
|
|
102
|
+
}
|