dsh-her 0.0.1 → 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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +113 -0
  3. package/cordis.patch.yml +5 -0
  4. package/examples/workshop/README.md +20 -0
  5. package/lib/client.js +1602 -0
  6. package/lib/core/character.js +50 -0
  7. package/lib/core/cues.js +62 -0
  8. package/lib/core/echo.js +27 -0
  9. package/lib/core/index.js +10 -0
  10. package/lib/core/paths.js +14 -0
  11. package/lib/core/prompt.js +63 -0
  12. package/lib/core/sentences.js +115 -0
  13. package/lib/core/speakable.js +26 -0
  14. package/lib/core/voices.js +43 -0
  15. package/lib/core/wav.js +71 -0
  16. package/lib/core/workshop.js +330 -0
  17. package/lib/dsh/api.js +156 -0
  18. package/lib/dsh/body-install.js +137 -0
  19. package/lib/dsh/body-launcher.js +48 -0
  20. package/lib/dsh/config.js +27 -0
  21. package/lib/dsh/her-session.js +282 -0
  22. package/lib/dsh/import-status.js +6 -0
  23. package/lib/dsh/plugin.js +1010 -0
  24. package/lib/dsh/workshop.js +222 -0
  25. package/lib/index.js +11 -0
  26. package/lib/memory/digest.js +119 -0
  27. package/lib/memory/search.js +52 -0
  28. package/lib/memory/store.js +267 -0
  29. package/lib/protocol/index.js +39 -0
  30. package/lib/server/brain-server.js +219 -0
  31. package/lib/types/core/character.d.ts +32 -0
  32. package/lib/types/core/cues.d.ts +20 -0
  33. package/lib/types/core/echo.d.ts +10 -0
  34. package/lib/types/core/index.d.ts +10 -0
  35. package/lib/types/core/paths.d.ts +11 -0
  36. package/lib/types/core/prompt.d.ts +39 -0
  37. package/lib/types/core/sentences.d.ts +40 -0
  38. package/lib/types/core/speakable.d.ts +6 -0
  39. package/lib/types/core/voices.d.ts +23 -0
  40. package/lib/types/core/wav.d.ts +9 -0
  41. package/lib/types/core/workshop.d.ts +156 -0
  42. package/lib/types/dsh/api.d.ts +163 -0
  43. package/lib/types/dsh/body-install.d.ts +58 -0
  44. package/lib/types/dsh/body-launcher.d.ts +28 -0
  45. package/lib/types/dsh/config.d.ts +34 -0
  46. package/lib/types/dsh/her-session.d.ts +215 -0
  47. package/lib/types/dsh/import-status.d.ts +17 -0
  48. package/lib/types/dsh/plugin.d.ts +34 -0
  49. package/lib/types/dsh/workshop.d.ts +94 -0
  50. package/lib/types/index.d.ts +11 -0
  51. package/lib/types/memory/digest.d.ts +33 -0
  52. package/lib/types/memory/search.d.ts +17 -0
  53. package/lib/types/memory/store.d.ts +132 -0
  54. package/lib/types/protocol/index.d.ts +434 -0
  55. package/lib/types/server/brain-server.d.ts +71 -0
  56. package/lib/types/voice/cloud-key.d.ts +10 -0
  57. package/lib/types/voice/cosyvoice.d.ts +47 -0
  58. package/lib/types/voice/edge.d.ts +52 -0
  59. package/lib/types/voice/engine.d.ts +30 -0
  60. package/lib/types/voice/models.d.ts +21 -0
  61. package/lib/types/voice/speech-queue.d.ts +55 -0
  62. package/lib/types/voice/voice.d.ts +75 -0
  63. package/lib/types/voice/worker.d.ts +37 -0
  64. package/lib/voice/cloud-key.js +23 -0
  65. package/lib/voice/cosyvoice.js +194 -0
  66. package/lib/voice/edge.js +199 -0
  67. package/lib/voice/engine.js +101 -0
  68. package/lib/voice/models.js +69 -0
  69. package/lib/voice/speech-queue.js +118 -0
  70. package/lib/voice/voice.js +171 -0
  71. package/lib/voice/worker.js +47 -0
  72. package/package.json +61 -4
@@ -0,0 +1,222 @@
1
+ /**
2
+ * 形象工坊 on disk: each project is a folder under `~/Documents/dsh-her 素材`
3
+ * (where the user can see it and drop files in), with
4
+ *
5
+ * 形象.json the project (character card, scenes, clips)
6
+ * 参考图提示词.txt how to make 参考图.png, her "ID photo"
7
+ * 参考图.png (the user's) — also the desk pet's anchor
8
+ * 生成清单.md every clip: first frame, file, prompt
9
+ * MiniMax生成任务.md the same, as a task for MiniMax Code (when that is the tool)
10
+ * ComfyUI生成清单.json the same, for a ComfyUI script (when that is the tool)
11
+ * 桌宠/提示词.txt + the desk pet's clips
12
+ * 场景-卧室/提示词.txt + 照片提示词.txt, plate.png, scene.json, crop.png, clips
13
+ *
14
+ * The prompts are rewritten on every save; the user's pictures and clips are
15
+ * never touched. Importing hands the folders to the body (same computer).
16
+ */
17
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { basename, dirname, extname, join, resolve } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { clipList, comfyuiList, DESK_DIR, EXAMPLE_FILES, EXAMPLE_SLOTS, minimaxTask, PROJECT_FILE, promptsFile, REFERENCE_FILE, referencePrompt, safeName, sceneDir, scenePhotoPrompt, validateProject, } from "../core/workshop.js";
22
+ export const MATERIALS_DIR = join(homedir(), 'Documents', 'dsh-her 素材');
23
+ function readProject(dir) {
24
+ try {
25
+ return validateProject(JSON.parse(readFileSync(join(dir, PROJECT_FILE), 'utf8')));
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ }
31
+ /** Every project in the materials folder, newest first. */
32
+ export function listProjects(root = MATERIALS_DIR) {
33
+ if (!existsSync(root))
34
+ return [];
35
+ const out = [];
36
+ for (const entry of readdirSync(root)) {
37
+ if (entry.startsWith('.'))
38
+ continue;
39
+ const dir = join(root, entry);
40
+ const project = readProject(dir);
41
+ if (project !== undefined)
42
+ out.push({ project, status: projectStatus(project, dir) });
43
+ }
44
+ return out.sort((a, b) => b.project.createdAt.localeCompare(a.project.createdAt));
45
+ }
46
+ export function findProject(id, root = MATERIALS_DIR) {
47
+ const found = listProjects(root).find(p => p.project.id === id);
48
+ return found === undefined ? undefined : { project: found.project, dir: found.status.dir };
49
+ }
50
+ const has = (dir, file) => existsSync(join(dir, file));
51
+ export function projectStatus(project, dir) {
52
+ const parts = [];
53
+ const count = (folder, clips) => {
54
+ const planned = clips.filter(c => c.off !== true);
55
+ const made = planned.filter(c => has(join(dir, folder), `${c.file}.mp4`));
56
+ const enough = clips.filter(c => c.required === true).every(c => has(join(dir, folder), `${c.file}.mp4`));
57
+ return { planned: planned.length, made: made.length, enough };
58
+ };
59
+ if (project.desk.on)
60
+ parts.push({ name: '桌宠', folder: DESK_DIR, ...count(DESK_DIR, project.desk.clips) });
61
+ for (const scene of project.scenes) {
62
+ const folder = sceneDir(scene);
63
+ const photo = ['plate.png', 'plate.jpg'].some(f => has(join(dir, folder), f));
64
+ const framed = has(join(dir, folder), 'scene.json') && has(join(dir, folder), 'crop.png');
65
+ parts.push({ name: scene.name, folder, ...count(folder, scene.clips), photo, framed });
66
+ }
67
+ const reference = has(dir, REFERENCE_FILE);
68
+ return { dir, reference, parts, importable: parts.some(p => p.enough && p.framed !== false && (p.folder !== DESK_DIR || reference || has(join(dir, DESK_DIR), 'anchor_stand.png'))) };
69
+ }
70
+ /**
71
+ * Write a project: its folder (made on first save, named after it), 形象.json
72
+ * and every prompt file. Pictures and clips already there stay as they are.
73
+ */
74
+ export function saveProject(input, screen, root = MATERIALS_DIR) {
75
+ const project = validateProject(input);
76
+ const existing = findProject(project.id, root);
77
+ let dir = existing?.dir;
78
+ if (dir === undefined) {
79
+ const base = safeName(project.name);
80
+ dir = join(root, existsSync(join(root, base)) ? `${base}-${project.id}` : base);
81
+ }
82
+ mkdirSync(dir, { recursive: true });
83
+ const write = (file, text) => {
84
+ writeFileSync(`${file}.tmp`, text);
85
+ renameSync(`${file}.tmp`, file);
86
+ };
87
+ write(join(dir, PROJECT_FILE), `${JSON.stringify(project, null, 2)}\n`);
88
+ write(join(dir, '参考图提示词.txt'), [
89
+ `${project.name} · 参考图(她的"身份证")`,
90
+ '',
91
+ `用文生图工具(豆包、即梦等)生成,存成 ${REFERENCE_FILE} 放在这个文件夹里。之后每个场景的照片都要把它当参考图,她才是同一个人。`,
92
+ '',
93
+ referencePrompt(project),
94
+ '',
95
+ ].join('\n'));
96
+ write(join(dir, '生成清单.md'), clipList(project, dir));
97
+ // The tool's own task file; another tool's, left from before, goes.
98
+ const taskFiles = [['MiniMax生成任务.md', 'minimax', minimaxTask], ['ComfyUI生成清单.json', 'comfyui', comfyuiList]];
99
+ for (const [file, generator, make] of taskFiles) {
100
+ if (project.generator === generator)
101
+ write(join(dir, file), make(project, dir));
102
+ else
103
+ rmSync(join(dir, file), { force: true });
104
+ }
105
+ if (project.desk.on) {
106
+ mkdirSync(join(dir, DESK_DIR), { recursive: true });
107
+ write(join(dir, DESK_DIR, '提示词.txt'), promptsFile(project, 'desk'));
108
+ }
109
+ for (const scene of project.scenes) {
110
+ const folder = join(dir, sceneDir(scene));
111
+ mkdirSync(folder, { recursive: true });
112
+ write(join(folder, '照片提示词.txt'), [
113
+ `${project.name} · ${scene.name} · 全屏照片`,
114
+ '',
115
+ `用文生图工具生成,把上一层的 ${REFERENCE_FILE} 放进"参考图 / 保持人物一致"。存成 plate.png 放在这个文件夹,然后在「她」页面的形象工坊里点"框人物区"。`,
116
+ '',
117
+ scenePhotoPrompt(project, scene, screen),
118
+ '',
119
+ ].join('\n'));
120
+ write(join(folder, '提示词.txt'), promptsFile(project, scene));
121
+ }
122
+ return { project, status: projectStatus(project, dir) };
123
+ }
124
+ /** Before importing: the desk pet's anchor is the reference picture. */
125
+ export function prepareImport(project, dir) {
126
+ if (project.desk.on && has(dir, REFERENCE_FILE) && !has(join(dir, DESK_DIR), 'anchor_stand.png')) {
127
+ copyFileSync(join(dir, REFERENCE_FILE), join(dir, DESK_DIR, 'anchor_stand.png'));
128
+ }
129
+ }
130
+ /** The settings of a project: what export copies without "pictures and videos". */
131
+ const SETTINGS = new Set(['.json', '.txt', '.md']);
132
+ /** Every file of a folder (relative paths), skipping MiniMax's scratch and hidden files. */
133
+ function filesOf(dir, prefix = '') {
134
+ const out = [];
135
+ for (const entry of readdirSync(dir)) {
136
+ if (entry.startsWith('.'))
137
+ continue;
138
+ const path = join(dir, entry);
139
+ if (statSync(path).isDirectory())
140
+ out.push(...filesOf(path, `${prefix}${entry}/`));
141
+ else
142
+ out.push(prefix + entry);
143
+ }
144
+ return out;
145
+ }
146
+ /** A folder name in `parent` that is not taken yet: name, name-2, name-3 … */
147
+ function freeDir(parent, name) {
148
+ let dir = join(parent, name);
149
+ for (let n = 2; existsSync(dir); n++)
150
+ dir = join(parent, `${name}-${n}`);
151
+ return dir;
152
+ }
153
+ /**
154
+ * Copy a project to another folder (`dest`/<its name>): its settings
155
+ * (形象.json, the prompts, scene.json), and with `media` its pictures and
156
+ * clips too. The copy can be imported anywhere with importProject.
157
+ */
158
+ export function exportProject(id, dest, media, root = MATERIALS_DIR) {
159
+ const found = findProject(id, root);
160
+ if (found === undefined)
161
+ throw new Error('找不到这个项目');
162
+ if (resolve(dest).startsWith(resolve(found.dir)))
163
+ throw new Error('不能导出到项目自己的文件夹里');
164
+ const dir = freeDir(dest, basename(found.dir));
165
+ let files = 0;
166
+ for (const file of filesOf(found.dir)) {
167
+ if (!media && !SETTINGS.has(extname(file).toLowerCase()))
168
+ continue;
169
+ mkdirSync(dirname(join(dir, file)), { recursive: true });
170
+ copyFileSync(join(found.dir, file), join(dir, file));
171
+ files++;
172
+ }
173
+ return { dir, files };
174
+ }
175
+ /**
176
+ * Bring a project in from elsewhere: a folder with 形象.json in it, or the
177
+ * 形象.json itself. It becomes a new project in the materials folder (a new
178
+ * id if one with its id is there already), with whatever pictures and clips
179
+ * came along; the prompts are written afresh.
180
+ */
181
+ export function importProject(path, screen, root = MATERIALS_DIR) {
182
+ const source = statSync(path).isDirectory() ? path : dirname(path);
183
+ const file = statSync(path).isDirectory() ? join(path, PROJECT_FILE) : path;
184
+ if (!existsSync(file))
185
+ throw new Error(`这个文件夹里没有 ${PROJECT_FILE}`);
186
+ let project;
187
+ try {
188
+ project = validateProject(JSON.parse(readFileSync(file, 'utf8')));
189
+ }
190
+ catch (error) {
191
+ throw new Error(`${basename(file)} 读不懂:${error.message}`);
192
+ }
193
+ const existing = findProject(project.id, root);
194
+ if (existing !== undefined && resolve(existing.dir) === resolve(source))
195
+ throw new Error('这个项目已经在形象工坊里了');
196
+ if (existing !== undefined)
197
+ project = { ...project, id: `p${Date.now().toString(36)}` };
198
+ mkdirSync(root, { recursive: true });
199
+ const dir = freeDir(root, safeName(project.name));
200
+ for (const name of filesOf(source)) {
201
+ if (name === PROJECT_FILE)
202
+ continue;
203
+ mkdirSync(dirname(join(dir, name)), { recursive: true });
204
+ copyFileSync(join(source, name), join(dir, name));
205
+ }
206
+ writeFileSync(join(dir, PROJECT_FILE), `${JSON.stringify(project, null, 2)}\n`);
207
+ return saveProject(project, screen, root);
208
+ }
209
+ /** Where examples are looked for: the user's own first, then the plugin's. */
210
+ export function exampleDirs(home) {
211
+ return [join(home, 'examples', 'workshop'), fileURLToPath(new URL('../../examples/workshop/', import.meta.url))];
212
+ }
213
+ /** The file to serve for an example slot, if it has been put in place. */
214
+ export function exampleFile(home, file) {
215
+ if (!EXAMPLE_FILES.has(file))
216
+ return undefined;
217
+ return exampleDirs(home).map(d => join(d, file)).find(f => existsSync(f));
218
+ }
219
+ /** Every card's example slots and whether each is there yet. */
220
+ export function examples(home) {
221
+ return Object.entries(EXAMPLE_SLOTS).flatMap(([card, slots]) => slots.map(slot => ({ card, ...slot, ready: exampleFile(home, slot.file) !== undefined })));
222
+ }
package/lib/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * dsh-her — a desktop companion for DSH.
3
+ *
4
+ * The brain lives here: her own DSH session, voice, memory, and the body
5
+ * protocol server. The body (the Electron desktop app) lives in `body/`.
6
+ * `src/core` never imports DSH; `src/dsh` is the only layer that does.
7
+ */
8
+ export const name = 'dsh-her';
9
+ export const usage = 'dsh-her: a desktop companion who lives on your real desktop, talks by voice and remembers you. /her talks to her.';
10
+ export { Config } from "./dsh/config.js";
11
+ export { apply } from "./dsh/plugin.js";
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The daily digest: once a day, the day's conversation is read back from her
3
+ * DSH session and a model writes her diary page and picks out what is worth
4
+ * keeping. This file is the pure part: the transcript, the prompt, and a
5
+ * forgiving parser for the model's JSON.
6
+ */
7
+ const MAX_CHARS = 12_000;
8
+ /** Events of her session within one local day, as "you" / "her" lines. Context notes and tags are left out. */
9
+ export function transcriptOf(events, dayStart, dayEnd) {
10
+ const lines = [];
11
+ for (const event of events) {
12
+ const time = event.time ?? 0;
13
+ if (time < dayStart || time >= dayEnd)
14
+ continue;
15
+ if (event.type === 'user/message') {
16
+ const data = event.data;
17
+ // Barge-in notes and other context she was given are not things you said.
18
+ if (data.source?.kind === 'dsh-her-context')
19
+ continue;
20
+ const text = (data.content ?? []).filter(b => b.type === 'text').map(b => b.text ?? '').join('').trim();
21
+ if (text !== '')
22
+ lines.push({ who: 'you', text, time });
23
+ }
24
+ else if (event.type === 'assistant/message') {
25
+ const data = event.data;
26
+ const text = (data.message?.content ?? []).filter(b => b.type === 'text').map(b => b.text ?? '').join('').replace(/〔[^〕]*〕/g, '').trim();
27
+ if (text !== '')
28
+ lines.push({ who: 'her', text, time });
29
+ }
30
+ }
31
+ // Keep the end of a long day.
32
+ let total = 0;
33
+ const kept = [];
34
+ for (const line of [...lines].reverse()) {
35
+ total += line.text.length + 4;
36
+ if (total > MAX_CHARS)
37
+ break;
38
+ kept.unshift(line);
39
+ }
40
+ return kept;
41
+ }
42
+ export function digestPrompt(name, day, transcript, knownFacts, knownEvents = [], pronoun = '他') {
43
+ const p = pronoun;
44
+ const system = [
45
+ `你是"${name}",正在睡前整理今天和对方的聊天,写日记,并记下以后用得上的事。提到对方时用"${p}"。`,
46
+ '只输出一个 JSON 对象,不要任何别的文字:',
47
+ '{"diary": "第一人称、你自己的口吻,写今天和对方之间发生了什么、你的感受,100 到 200 字",',
48
+ ` "facts": [{"text": "关于对方的新事实,一句话,比如「${p}不吃香菜」", "sensitive": false}],`,
49
+ ' "episodes": [{"text": "以后值得提起的具体事情,一句话", "due": "YYYY-MM-DD,有具体日期才写"}]}',
50
+ '规则:',
51
+ '- facts 只写关于对方的、长期成立的事(称呼、喜好、忌讳、重要的人、生日);已经知道的不要重复;没有就给空数组。',
52
+ '- 涉及健康、钱、证件、他人隐私的事实,sensitive 写 true。',
53
+ `- episodes 写具体的事(比如"${p}下周三去面试"),把"明天""下周三"换算成具体日期;已经记下的不要重复;没有就给空数组。`,
54
+ '- 只根据聊天记录,不要编造。',
55
+ ].join('\n');
56
+ const user = [
57
+ `今天是 ${day}。`,
58
+ knownFacts.length === 0 ? '已经知道的关于对方的事:(还没有)' : `已经知道的关于对方的事:\n${knownFacts.map(f => `- ${f}`).join('\n')}`,
59
+ knownEvents.length === 0 ? '' : `已经记下的事件:\n${knownEvents.map(e => `- ${e}`).join('\n')}`,
60
+ '',
61
+ '今天的聊天:',
62
+ ...transcript.map(l => `${l.who === 'you' ? '对方' : '我'}:${l.text}`),
63
+ ].join('\n');
64
+ return { system, user };
65
+ }
66
+ /**
67
+ * Right after a talk: what in these few turns is worth keeping, so it shows
68
+ * on the "她记得的事" page at once (the diary still waits for the day's digest).
69
+ * Same answer shape as the digest, without the diary; parseDigest reads it.
70
+ */
71
+ export function notePrompt(name, day, transcript, knownFacts, knownEvents = [], pronoun = '他') {
72
+ const p = pronoun;
73
+ const system = [
74
+ `你是"${name}",刚和对方聊完几句,正在把以后用得上的事记到小本子上。提到对方时用"${p}"。`,
75
+ '只输出一个 JSON 对象,不要任何别的文字:',
76
+ ` {"facts": [{"text": "关于对方的新事实,一句话,比如「${p}不吃香菜」", "sensitive": false}],`,
77
+ ' "episodes": [{"text": "以后值得提起的具体事情,一句话", "due": "YYYY-MM-DD,有具体日期才写"}]}',
78
+ '规则:',
79
+ '- facts 只写关于对方的、长期成立的事(称呼、喜好、忌讳、工作和生活、重要的人、生日);已经知道的不要重复;没有就给空数组。',
80
+ '- 涉及健康、钱、证件、他人隐私的事实,sensitive 写 true。',
81
+ `- episodes 写具体的事(比如"${p}下周三去面试""${p}今晚吃了番茄炒蛋"),把"明天""下周三"换算成具体日期;已经记下的不要重复;没有就给空数组。`,
82
+ '- 客套话、闲聊里没有信息的不记。只根据这段聊天,不要编造。',
83
+ ].join('\n');
84
+ const user = [
85
+ `今天是 ${day}。`,
86
+ knownFacts.length === 0 ? '已经知道的关于对方的事:(还没有)' : `已经知道的关于对方的事:\n${knownFacts.map(f => `- ${f}`).join('\n')}`,
87
+ knownEvents.length === 0 ? '' : `已经记下的事件:\n${knownEvents.map(e => `- ${e}`).join('\n')}`,
88
+ '',
89
+ '刚才的聊天:',
90
+ ...transcript.map(l => `${l.who === 'you' ? '对方' : '我'}:${l.text}`),
91
+ ].join('\n');
92
+ return { system, user };
93
+ }
94
+ /** The first JSON object in the model's answer, checked and cleaned; undefined if there is none. */
95
+ export function parseDigest(answer) {
96
+ const start = answer.indexOf('{');
97
+ const end = answer.lastIndexOf('}');
98
+ if (start < 0 || end <= start)
99
+ return undefined;
100
+ let value;
101
+ try {
102
+ value = JSON.parse(answer.slice(start, end + 1));
103
+ }
104
+ catch {
105
+ return undefined;
106
+ }
107
+ if (typeof value !== 'object' || value === null)
108
+ return undefined;
109
+ const v = value;
110
+ const list = (x) => (Array.isArray(x) ? x.filter((i) => typeof i === 'object' && i !== null && typeof i.text === 'string') : []);
111
+ return {
112
+ diary: typeof v.diary === 'string' ? v.diary : '',
113
+ facts: list(v.facts).map(f => ({ text: String(f.text).trim(), sensitive: f.sensitive === true })),
114
+ episodes: list(v.episodes).map(e => ({
115
+ text: String(e.text).trim(),
116
+ ...(typeof e.due === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(e.due) ? { due: e.due } : {}),
117
+ })),
118
+ };
119
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Recall without embeddings (docs/内置记忆.md §3): Chinese is cut into
3
+ * character bigrams (single characters for one-character runs), Latin text
4
+ * into lowercase words, scored with BM25, and newer memories weigh a little
5
+ * more. DeepSeek offers no embedding API and a local embedding model would
6
+ * add hundreds of MB, so this is enough for one person's memories.
7
+ */
8
+ const K1 = 1.2;
9
+ const B = 0.75;
10
+ /** A memory this many days old counts half as much as one from today. */
11
+ const HALF_LIFE_DAYS = 60;
12
+ export function tokens(text) {
13
+ const out = [];
14
+ for (const run of text.toLowerCase().match(/[\p{Script=Han}]+|[\p{L}\p{N}]+/gu) ?? []) {
15
+ if (/^\p{Script=Han}+$/u.test(run)) {
16
+ const chars = [...run];
17
+ if (chars.length === 1)
18
+ out.push(run);
19
+ for (let i = 0; i + 1 < chars.length; i++)
20
+ out.push(chars[i] + chars[i + 1]);
21
+ }
22
+ else {
23
+ out.push(run);
24
+ }
25
+ }
26
+ return out;
27
+ }
28
+ export function search(docs, query, now, limit = 5) {
29
+ const terms = [...new Set(tokens(query))];
30
+ if (terms.length === 0 || docs.length === 0)
31
+ return [];
32
+ const docTokens = docs.map(d => tokens(d.text));
33
+ const avg = docTokens.reduce((sum, t) => sum + t.length, 0) / docs.length || 1;
34
+ const df = new Map(terms.map(t => [t, docTokens.filter(toks => toks.includes(t)).length]));
35
+ const scored = docs.map((doc, i) => {
36
+ const toks = docTokens[i];
37
+ let score = 0;
38
+ for (const term of terms) {
39
+ const n = df.get(term);
40
+ if (n === 0)
41
+ continue;
42
+ const tf = toks.filter(t => t === term).length;
43
+ if (tf === 0)
44
+ continue;
45
+ const idf = Math.log(1 + (docs.length - n + 0.5) / (n + 0.5));
46
+ score += idf * ((tf * (K1 + 1)) / (tf + K1 * (1 - B + (B * toks.length) / avg)));
47
+ }
48
+ const ageDays = Math.max(0, (now - doc.at) / 86_400_000);
49
+ return { ...doc, score: score * (0.5 + 0.5 * 2 ** (-ageDays / HALF_LIFE_DAYS)) };
50
+ });
51
+ return scored.filter(d => d.score > 0).sort((a, b) => b.score - a.score).slice(0, limit);
52
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Her built-in memory (docs/内置记忆.md): plain files under
3
+ * `<home>/characters/<cid>/memory/`, readable and editable by hand.
4
+ *
5
+ * - facts.json about you: name, likes, dislikes, people, dates. Facts she
6
+ * was told to remember are confirmed at once; facts found by
7
+ * the daily digest are candidates, confirmed by you or after
8
+ * 7 days, except sensitive ones (health, money, others'
9
+ * privacy), which wait for you.
10
+ * - episodes.jsonl things worth bringing up later, some with a date: on that
11
+ * day and the day before, they are put in front of her.
12
+ * - diary/<day>.md her diary, one page a day, written by the digest.
13
+ *
14
+ * `MemoryProvider` is the seam where the soul engine (dsh-vael) can replace
15
+ * all of this later. Writes go to a temporary file first and are renamed.
16
+ */
17
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { search, tokens } from "./search.js";
20
+ const PROFILE_CHARS = 300;
21
+ const RECENT_CHARS = 160;
22
+ const CANDIDATE_DAYS = 7;
23
+ /** Words that make a fact sensitive whatever the model said. */
24
+ /** Two events on the same day sharing this much of their wording are the same event. */
25
+ const SAME_EVENT = 0.5;
26
+ /** A diary sentence holding this much of a forgotten memory's wording goes with it. */
27
+ const REDACT = 0.4;
28
+ function overlap(a, b) {
29
+ const mine = new Set(tokens(a));
30
+ const theirs = tokens(b);
31
+ return theirs.length === 0 ? 0 : theirs.filter(t => mine.has(t)).length / theirs.length;
32
+ }
33
+ const SENSITIVE = /病|药|医院|诊断|抑郁|焦虑|怀孕|工资|收入|存款|贷款|欠|负债|密码|身份证|银行卡|手机号|住址|出轨|离婚/;
34
+ export function dayOf(date) {
35
+ const y = date.getFullYear();
36
+ const m = String(date.getMonth() + 1).padStart(2, '0');
37
+ const d = String(date.getDate()).padStart(2, '0');
38
+ return `${y}-${m}-${d}`;
39
+ }
40
+ function addDays(day, n) {
41
+ const [y, m, d] = day.split('-').map(Number);
42
+ return dayOf(new Date(y, m - 1, d + n));
43
+ }
44
+ export class FileMemory {
45
+ dir;
46
+ constructor(dir) {
47
+ this.dir = dir;
48
+ mkdirSync(join(dir, 'diary'), { recursive: true });
49
+ }
50
+ read(file, fallback) {
51
+ try {
52
+ return JSON.parse(readFileSync(join(this.dir, file), 'utf8'));
53
+ }
54
+ catch {
55
+ return fallback;
56
+ }
57
+ }
58
+ write(file, text) {
59
+ // The folder may have been cleared by hand while she runs.
60
+ mkdirSync(join(this.dir, 'diary'), { recursive: true });
61
+ const path = join(this.dir, file);
62
+ writeFileSync(`${path}.tmp`, text);
63
+ renameSync(`${path}.tmp`, path);
64
+ }
65
+ facts() {
66
+ return this.read('facts.json', []);
67
+ }
68
+ saveFacts(facts) {
69
+ this.write('facts.json', `${JSON.stringify(facts, null, 2)}\n`);
70
+ }
71
+ episodes() {
72
+ try {
73
+ return readFileSync(join(this.dir, 'episodes.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ }
79
+ saveEpisodes(episodes) {
80
+ this.write('episodes.jsonl', episodes.map(e => JSON.stringify(e)).join('\n') + (episodes.length > 0 ? '\n' : ''));
81
+ }
82
+ diaryDays() {
83
+ try {
84
+ return readdirSync(join(this.dir, 'diary')).filter(f => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)).map(f => f.slice(0, 10)).sort();
85
+ }
86
+ catch {
87
+ return [];
88
+ }
89
+ }
90
+ nextId(prefix) {
91
+ const used = [...this.facts(), ...this.episodes()].map(x => x.id).filter(id => id.startsWith(prefix)).map(id => Number(id.slice(1)) || 0);
92
+ return `${prefix}${Math.max(0, ...used) + 1}`;
93
+ }
94
+ profile() {
95
+ let out = '';
96
+ for (const fact of this.facts().filter(f => f.status === 'confirmed').reverse()) {
97
+ const line = `- ${fact.text}\n`;
98
+ if (out.length + line.length > PROFILE_CHARS)
99
+ break;
100
+ out += line;
101
+ }
102
+ return out.trim();
103
+ }
104
+ recent(now) {
105
+ const today = dayOf(now);
106
+ return this.diaryDays().filter(d => d < today).slice(-2).map(day => {
107
+ const text = readFileSync(join(this.dir, 'diary', `${day}.md`), 'utf8').replace(/^#.*\n/, '').trim();
108
+ return `${day}:${text.length > RECENT_CHARS ? `${text.slice(0, RECENT_CHARS)}…` : text}`;
109
+ }).join('\n');
110
+ }
111
+ due(now) {
112
+ const today = dayOf(now);
113
+ const tomorrow = addDays(today, 1);
114
+ return this.episodes().filter(e => e.due !== undefined && e.due >= today && e.due <= tomorrow);
115
+ }
116
+ remember(item, now) {
117
+ const text = item.text.trim();
118
+ if (text === '')
119
+ throw new Error('要记住的内容是空的');
120
+ if (item.kind === 'fact') {
121
+ const facts = this.facts();
122
+ const same = facts.find(f => f.text === text);
123
+ if (same !== undefined)
124
+ return { id: same.id, kind: 'fact', text, at: same.createdAt };
125
+ const source = item.source ?? 'remember';
126
+ const fact = {
127
+ id: this.nextId('f'), text,
128
+ // Told to remember: confirmed. Found by the digest: a candidate.
129
+ status: source === 'remember' ? 'confirmed' : 'candidate',
130
+ sensitive: item.sensitive === true || SENSITIVE.test(text),
131
+ source, createdAt: now.toISOString(),
132
+ };
133
+ this.saveFacts([...facts, fact]);
134
+ return { id: fact.id, kind: 'fact', text, at: fact.createdAt };
135
+ }
136
+ const due = item.due !== undefined && /^\d{4}-\d{2}-\d{2}$/.test(item.due) ? item.due : undefined;
137
+ const episode = { id: this.nextId('e'), text, at: now.toISOString(), ...(due === undefined ? {} : { due }) };
138
+ this.saveEpisodes([...this.episodes(), episode]);
139
+ return { id: episode.id, kind: 'episode', text, at: episode.at };
140
+ }
141
+ recall(query, now, limit = 5) {
142
+ const docs = [
143
+ ...this.facts().map(f => ({ id: f.id, kind: 'fact', text: f.text, at: f.createdAt })),
144
+ ...this.episodes().map(e => ({ id: e.id, kind: 'episode', text: e.due === undefined ? e.text : `${e.text}(${e.due})`, at: e.at })),
145
+ ...this.diaryDays().map(day => ({
146
+ id: `diary:${day}`, kind: 'diary',
147
+ text: readFileSync(join(this.dir, 'diary', `${day}.md`), 'utf8').replace(/^#.*\n/, '').trim(),
148
+ at: `${day}T12:00:00`,
149
+ })),
150
+ ];
151
+ const byId = new Map(docs.map(d => [d.id, d]));
152
+ return search(docs.map(d => ({ id: d.id, text: d.text, at: Date.parse(d.at) })), query, now.getTime(), limit).map(hit => byId.get(hit.id));
153
+ }
154
+ list() {
155
+ return { facts: this.facts(), episodes: this.episodes(), diary: this.diaryDays() };
156
+ }
157
+ remove(ids) {
158
+ const wanted = new Set(ids);
159
+ const facts = this.facts();
160
+ const episodes = this.episodes();
161
+ const keptFacts = facts.filter(f => !wanted.has(f.id));
162
+ const keptEpisodes = episodes.filter(e => !wanted.has(e.id));
163
+ let removed = facts.length - keptFacts.length + episodes.length - keptEpisodes.length;
164
+ this.saveFacts(keptFacts);
165
+ this.saveEpisodes(keptEpisodes);
166
+ // Forgotten means forgotten: her diary loses the sentences about it too.
167
+ const gone = [...facts.filter(f => wanted.has(f.id)), ...episodes.filter(e => wanted.has(e.id))].map(x => x.text);
168
+ if (gone.length > 0)
169
+ this.redactDiary(gone);
170
+ for (const id of wanted) {
171
+ const day = /^diary:(\d{4}-\d{2}-\d{2})$/.exec(id)?.[1];
172
+ if (day !== undefined && existsSync(join(this.dir, 'diary', `${day}.md`))) {
173
+ rmSync(join(this.dir, 'diary', `${day}.md`));
174
+ removed++;
175
+ }
176
+ }
177
+ return removed;
178
+ }
179
+ /** Drop diary sentences that mostly repeat any of these texts. */
180
+ redactDiary(texts) {
181
+ for (const day of this.diaryDays()) {
182
+ const file = join('diary', `${day}.md`);
183
+ const page = readFileSync(join(this.dir, file), 'utf8');
184
+ const [head, ...body] = page.split('\n');
185
+ const sentences = body.join('\n').match(/[^。!?!?\n]+[。!?!?]*|\n/g) ?? [];
186
+ const kept = sentences.filter(sentence => !texts.some(text => overlap(sentence, text) >= REDACT));
187
+ if (kept.length !== sentences.length)
188
+ this.write(file, `${head}\n${kept.join('').replace(/\n{3,}/g, '\n\n')}`);
189
+ }
190
+ }
191
+ confirm(ids) {
192
+ const wanted = new Set(ids);
193
+ let changed = 0;
194
+ const facts = this.facts().map(f => {
195
+ if (!wanted.has(f.id) || f.status === 'confirmed')
196
+ return f;
197
+ changed++;
198
+ return { ...f, status: 'confirmed' };
199
+ });
200
+ this.saveFacts(facts);
201
+ return changed;
202
+ }
203
+ settle(now) {
204
+ let changed = 0;
205
+ const limit = now.getTime() - CANDIDATE_DAYS * 86_400_000;
206
+ const facts = this.facts().map(f => {
207
+ if (f.status !== 'candidate' || f.sensitive || Date.parse(f.createdAt) > limit)
208
+ return f;
209
+ changed++;
210
+ return { ...f, status: 'confirmed' };
211
+ });
212
+ if (changed > 0)
213
+ this.saveFacts(facts);
214
+ return changed;
215
+ }
216
+ applyDigest(day, digest, now) {
217
+ if (digest.diary.trim() !== '')
218
+ this.write(join('diary', `${day}.md`), `# ${day}\n\n${digest.diary.trim()}\n`);
219
+ const added = this.keep(digest, now);
220
+ this.write('meta.json', `${JSON.stringify({ lastDigestDay: day, at: now.toISOString() })}\n`);
221
+ return added;
222
+ }
223
+ /** Keep what a digest or a note found: new facts as candidates, events not already written down. */
224
+ keep(found, now) {
225
+ let facts = 0;
226
+ let episodes = 0;
227
+ const known = new Set(this.facts().map(f => f.text));
228
+ for (const fact of found.facts) {
229
+ if (fact.text.trim() === '' || known.has(fact.text.trim()))
230
+ continue;
231
+ this.remember({ kind: 'fact', text: fact.text, sensitive: fact.sensitive === true, source: 'digest' }, now);
232
+ facts++;
233
+ }
234
+ for (const episode of found.episodes) {
235
+ if (episode.text.trim() === '')
236
+ continue;
237
+ // Already remembered (she wrote it down during the day): same day, mostly the same words.
238
+ if (this.episodes().some(e => e.due === episode.due && overlap(e.text, episode.text) >= SAME_EVENT))
239
+ continue;
240
+ this.remember({ kind: 'episode', text: episode.text, ...(episode.due === undefined ? {} : { due: episode.due }) }, now);
241
+ episodes++;
242
+ }
243
+ return { facts, episodes };
244
+ }
245
+ lastDigestDay() {
246
+ return this.read('meta.json', {}).lastDigestDay;
247
+ }
248
+ exportMarkdown() {
249
+ const { facts, episodes, diary } = this.list();
250
+ const lines = ['# 她记得的事', '', '## 关于你', ''];
251
+ for (const f of facts)
252
+ lines.push(`- ${f.text}${f.status === 'candidate' ? '(待确认)' : ''}${f.sensitive ? '(敏感)' : ''}`);
253
+ lines.push('', '## 事件', '');
254
+ for (const e of episodes)
255
+ lines.push(`- ${e.at.slice(0, 10)} ${e.text}${e.due === undefined ? '' : `(${e.due})`}`);
256
+ lines.push('', '## 日记', '');
257
+ for (const day of diary)
258
+ lines.push(readFileSync(join(this.dir, 'diary', `${day}.md`), 'utf8').replace(/^# /, '### ').trim(), '');
259
+ return `${lines.join('\n').trim()}\n`;
260
+ }
261
+ clear() {
262
+ for (const file of ['facts.json', 'episodes.jsonl', 'meta.json'])
263
+ rmSync(join(this.dir, file), { force: true });
264
+ rmSync(join(this.dir, 'diary'), { recursive: true, force: true });
265
+ mkdirSync(join(this.dir, 'diary'), { recursive: true });
266
+ }
267
+ }