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,1010 @@
1
+ /**
2
+ * The DSH shell: opens her session as soon as DSH's agent services are ready,
3
+ * serves the body protocol, starts the body, and exposes `/her`. Everything
4
+ * hangs off `ctx.effect`, so disabling or uninstalling the plugin closes her
5
+ * session handle and the server (the body stays up and waits, asleep).
6
+ *
7
+ * Every reply of her session, whoever prompted it (the body's input box or
8
+ * microphone, the DSH page, `/her`), goes to connected bodies as `speak.*`
9
+ * sentences, each with its synthesized audio (or, without a voice, timed by
10
+ * reading speed). Replies are live only: a body that connects later never gets
11
+ * earlier ones replayed.
12
+ *
13
+ * Listening: a body sends what you said as audio; it is recognized here and
14
+ * goes to her like typed text. When a body cuts her off (you started talking),
15
+ * it reports how far she got; her next turn is told what you actually heard.
16
+ *
17
+ * Performing: the connected body's pack decides what she can do. Her prompt
18
+ * lists it; inline tags in her reply become cues on the sentence they are in,
19
+ * and longer performances go through her `play_action` tool, which waits for
20
+ * the body's `act.done`.
21
+ *
22
+ * Memory: files under `<home>/characters/<cid>/memory/` (src/memory). She
23
+ * writes with `remember`, reads with `recall`, and her prompt carries what she
24
+ * knows about you, her last diary days and what has a date today or tomorrow.
25
+ * A minute after each talk, a short note picks out of the new turns what is
26
+ * worth keeping (as candidates), so it shows on the "她记得的事" page at once.
27
+ * Once a day (30 minutes after you last talked, or from 23:30) a digest reads
28
+ * the day back from her session and writes her diary page and candidates.
29
+ *
30
+ * Her replies are also logged with timings to `<home>/logs/stream.jsonl`.
31
+ */
32
+ import { spawn } from 'node:child_process';
33
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
34
+ import { join } from 'node:path';
35
+ import { buildSystemPrompt, extractCues, herPaths, invalidCharacter, judgeBargeIn, loadCharacter, memoryContext, performanceSection, readingMs, saveCharacter, serviceOf, speakable } from "../core/index.js";
36
+ import { digestPrompt, notePrompt, parseDigest, transcriptOf } from "../memory/digest.js";
37
+ import { dayOf, FileMemory } from "../memory/store.js";
38
+ import { PROTOCOL_VERSION } from "../protocol/index.js";
39
+ import { BrainServer } from "../server/brain-server.js";
40
+ import { API_BASE, apiHandlers, handle } from "./api.js";
41
+ import { bodyRunning, findDevBody, launchBody } from "./body-launcher.js";
42
+ import { compareVersions, fetchManifest, install, installedBody, installedPlan } from "./body-install.js";
43
+ import { IDLE_IMPORT } from "./import-status.js";
44
+ import { DESK_DIR, newProject, sceneDir } from "../core/workshop.js";
45
+ import { exampleFile, examples, exportProject, findProject, importProject, listProjects, prepareImport, projectStatus, saveProject } from "./workshop.js";
46
+ import { HerSession } from "./her-session.js";
47
+ import { heardText, SpeechQueue } from "../voice/speech-queue.js";
48
+ import { Voice } from "../voice/voice.js";
49
+ const SERVICES = ['agents', 'agentDefaultModel', 'workspaceRegistry', 'sessionTitle', 'sessionQuery', 'llm'];
50
+ /** The digest runs this long after you last talked, or from DIGEST_HOUR:DIGEST_MINUTE. */
51
+ const DIGEST_IDLE_MS = 30 * 60_000;
52
+ const DIGEST_HOUR = 23;
53
+ const DIGEST_MINUTE = 30;
54
+ const DIGEST_CHECK_MS = 5 * 60_000;
55
+ /** A note is taken once you have been quiet this long and her reply is over. */
56
+ const NOTE_QUIET_MS = 60_000;
57
+ const NOTE_CHECK_MS = 15_000;
58
+ const STOP_WORDS = new Set(['停', '停下', 'stop']);
59
+ const MEMORY_WORDS = new Set(['记忆', 'memory']);
60
+ const INTERRUPT_CHECK = '测打断';
61
+ const DIAGNOSE = '诊断';
62
+ /** How long after DSH starts to wait for a running body before starting one. */
63
+ const LAUNCH_AFTER_MS = 3000;
64
+ /** The installed body checks the release for a newer version this soon after start, then this often. */
65
+ const UPDATE_FIRST_MS = 60_000;
66
+ const UPDATE_CHECK_MS = 6 * 60 * 60_000;
67
+ /**
68
+ * A turn that has not started this long after you spoke is stuck. Seen in M4:
69
+ * an effort id the route does not accept (`off`) left her session silent with
70
+ * no error at all.
71
+ */
72
+ const STUCK_AFTER_MS = 20_000;
73
+ export function apply(ctx, config) {
74
+ const paths = herPaths(config.home);
75
+ const cid = config.character.id;
76
+ const dir = join(paths.characters, cid);
77
+ let services;
78
+ let session;
79
+ let server;
80
+ let sentAt;
81
+ let current;
82
+ const finished = [];
83
+ /** Resolves on the first sentence of the next reply. */
84
+ let onFirstSentence;
85
+ let voice;
86
+ let speech;
87
+ let nextStream = 1;
88
+ /** What you heard of her last cut-off reply; told to her before your next turn. */
89
+ let cutNote;
90
+ let stuckTimer;
91
+ const memory = new FileMemory(join(dir, 'memory'));
92
+ /** Who she is: the "她" page's saved settings over the plugin config's defaults. */
93
+ let character = loadCharacter(dir, {
94
+ name: config.character.name,
95
+ persona: config.character.persona,
96
+ callYou: '',
97
+ pronoun: '他',
98
+ voice: { id: config.voice.voice, speed: config.voice.speed },
99
+ onboarded: false,
100
+ });
101
+ /** When you last talked to her, and the day of it (for the digest). */
102
+ let lastTalkAt = 0;
103
+ let digesting;
104
+ /** Write her diary page for `day` and keep what is worth keeping; a short report. */
105
+ const digest = (day) => {
106
+ digesting ??= runDigest(day).finally(() => { digesting = undefined; });
107
+ return digesting;
108
+ };
109
+ /** One model call outside her session (digest, persona drafts): the whole answer as text. */
110
+ const complete = async (system, user, maxTokens) => {
111
+ const llm = services?.llm;
112
+ const target = route();
113
+ if (llm === undefined || target === undefined)
114
+ throw new Error('dsh 的模型服务还没准备好');
115
+ let answer = '';
116
+ for await (const chunk of llm.stream({
117
+ provider: target.provider, model: target.model, ...(target.reasoningEffort === undefined ? {} : { reasoningEffort: target.reasoningEffort }),
118
+ system, messages: [{ role: 'user', content: [{ type: 'text', text: user }] }], maxTokens,
119
+ })) {
120
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string')
121
+ answer += chunk.text;
122
+ }
123
+ return answer.trim();
124
+ };
125
+ const runDigest = async (day) => {
126
+ const id = session?.sessionId;
127
+ const query = services?.sessionQuery;
128
+ if (id === undefined || query === undefined)
129
+ return '整理不了:dsh 的服务还没准备好';
130
+ const [y, m, d] = day.split('-').map(Number);
131
+ const start = new Date(y, m - 1, d).getTime();
132
+ const lines = transcriptOf((await query.readSession(id)).events, start, start + 86_400_000);
133
+ if (lines.length === 0) {
134
+ memory.applyDigest(day, { diary: '', facts: [], episodes: [] }, new Date());
135
+ return `${day} 没有聊天,不用整理`;
136
+ }
137
+ const { facts, episodes } = memory.list();
138
+ const prompt = digestPrompt(character.name, day, lines, facts.map(f => f.text), episodes.map(e => (e.due === undefined ? e.text : `${e.text}(${e.due})`)), character.pronoun);
139
+ const answer = await complete(prompt.system, prompt.user, 2000);
140
+ const parsed = parseDigest(answer);
141
+ if (parsed === undefined) {
142
+ log({ event: 'digest', day, error: 'unparsable', answer: answer.slice(0, 400) });
143
+ return `${day} 整理失败:模型没有给出能读懂的结果`;
144
+ }
145
+ const added = memory.applyDigest(day, parsed, new Date());
146
+ log({ event: 'digest', day, lines: lines.length, ...added });
147
+ return `${day} 整理好了:日记 1 篇,新的候选事实 ${added.facts} 条,事件 ${added.episodes} 条`;
148
+ };
149
+ /** Turns up to here have been noted (the daily digest still reads the whole day). */
150
+ let notedUntil = Date.now();
151
+ let noting;
152
+ /** Note what in the turns since the last note is worth keeping; never throws. */
153
+ const runNote = async () => {
154
+ const id = session?.sessionId;
155
+ const query = services?.sessionQuery;
156
+ if (id === undefined || query === undefined)
157
+ return;
158
+ const from = notedUntil;
159
+ const to = Date.now();
160
+ notedUntil = to;
161
+ const lines = transcriptOf((await query.readSession(id)).events, from, to);
162
+ if (!lines.some(l => l.who === 'you'))
163
+ return;
164
+ const now = new Date();
165
+ const { facts, episodes } = memory.list();
166
+ const prompt = notePrompt(character.name, dayOf(now), lines, facts.map(f => f.text), episodes.map(e => (e.due === undefined ? e.text : `${e.text}(${e.due})`)), character.pronoun);
167
+ const parsed = parseDigest(await complete(prompt.system, prompt.user, 800));
168
+ if (parsed === undefined) {
169
+ log({ event: 'note', error: 'unparsable' });
170
+ return;
171
+ }
172
+ log({ event: 'note', lines: lines.length, ...memory.keep(parsed, now) });
173
+ };
174
+ const noteTick = () => {
175
+ if (noting !== undefined || current !== undefined || lastTalkAt <= notedUntil || Date.now() - lastTalkAt < NOTE_QUIET_MS)
176
+ return;
177
+ noting = runNote()
178
+ .catch((error) => ctx.logger.warn(`dsh-her: note failed (${error.message})`))
179
+ .finally(() => { noting = undefined; });
180
+ };
181
+ /** Every few minutes: settle old candidates, and digest a day that has ended its talking. */
182
+ const digestTick = () => {
183
+ const now = new Date();
184
+ memory.settle(now);
185
+ const done = memory.lastDigestDay();
186
+ if (lastTalkAt === 0) {
187
+ // DSH restarted since you last talked: catch up on yesterday, if it was never digested.
188
+ const yesterday = dayOf(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1));
189
+ if (done === undefined || done < yesterday)
190
+ void digest(yesterday).then(report => ctx.logger.info(`dsh-her: ${report}`), () => { });
191
+ return;
192
+ }
193
+ const talkedDay = dayOf(new Date(lastTalkAt));
194
+ if (done !== undefined && done >= talkedDay)
195
+ return;
196
+ const late = now.getHours() > DIGEST_HOUR || (now.getHours() === DIGEST_HOUR && now.getMinutes() >= DIGEST_MINUTE);
197
+ const quiet = now.getTime() - lastTalkAt >= DIGEST_IDLE_MS;
198
+ const nextDay = dayOf(now) !== talkedDay;
199
+ if (quiet || late || nextDay)
200
+ void digest(talkedDay).then(report => ctx.logger.info(`dsh-her: ${report}`), (error) => ctx.logger.warn(`dsh-her: digest failed (${error.message})`));
201
+ };
202
+ const aboutYou = () => {
203
+ const now = new Date();
204
+ return memoryContext({ profile: memory.profile(), recent: memory.recent(now), due: memory.due(now) });
205
+ };
206
+ /** The last barge-in, to check the utterance that follows it for echo. */
207
+ let lastCut;
208
+ /** The pack of the most recently connected body: what she can perform. */
209
+ let bodyPack;
210
+ /** What the body reports about itself, for the "她" page. */
211
+ let bodyPacks = [];
212
+ let activePack;
213
+ let bodyLook;
214
+ let bodyMode;
215
+ let importStatus = IDLE_IMPORT;
216
+ let lastHeard;
217
+ let discovery;
218
+ /** Actions sent to a body, waiting for its `act.done`. */
219
+ /** The installed body: download, check, install, update (body-install.ts). */
220
+ const devBody = () => (config.body.useDev ? findDevBody() : { missing: 'useDev is off' });
221
+ let installState = { state: 'idle', dev: !('missing' in devBody()) };
222
+ /** The body to start: the development one in the repository when there is one, else the installed app. */
223
+ const bodyPlan = () => {
224
+ const dev = devBody();
225
+ if (!('missing' in dev))
226
+ return dev;
227
+ return installedPlan(paths.root) ?? { missing: '还没有安装,在「她」页面点"下载安装身体"' };
228
+ };
229
+ /**
230
+ * Install the release's body when there is none, or a newer one (`asked`:
231
+ * the user pressed the button, so a first install starts the body too).
232
+ * A development setup never updates itself.
233
+ */
234
+ const installLatest = async (asked) => {
235
+ if (installState.dev && !asked)
236
+ return;
237
+ const before = installedBody(paths.root);
238
+ if (!asked && before === undefined)
239
+ return;
240
+ installState = { ...installState, state: 'checking' };
241
+ try {
242
+ const manifest = await fetchManifest(config.body.releases);
243
+ installState = { ...installState, latest: manifest.version };
244
+ if (before !== undefined && compareVersions(manifest.version, before.version) <= 0) {
245
+ installState = { ...installState, state: 'idle' };
246
+ return;
247
+ }
248
+ installState = { ...installState, state: 'downloading', done: 0, total: 0 };
249
+ const installed = await install(paths.root, config.body.releases, manifest, (done, total) => {
250
+ installState = { ...installState, state: done < total ? 'downloading' : 'installing', done, total };
251
+ });
252
+ installState = { ...installState, state: 'idle', installed: installed.version, restart: before !== undefined && (server?.bodies.length ?? 0) > 0 };
253
+ log({ event: 'body-installed', version: installed.version });
254
+ if (before === undefined && discovery !== undefined && (server?.bodies.length ?? 0) === 0 && !bodyRunning(paths.root)) {
255
+ const plan = bodyPlan();
256
+ if (!('missing' in plan))
257
+ launchBody(plan, { url: discovery.url, token: discovery.token, home: paths.root, pack: config.body.pack });
258
+ }
259
+ }
260
+ catch (error) {
261
+ installState = { ...installState, state: 'failed', error: error.message };
262
+ ctx.logger.warn(`dsh-her: body install/update failed (${error.message})`);
263
+ }
264
+ };
265
+ /** Stop the running body (it writes its pid to body.json) and wait for it to go. */
266
+ const stopBody = async () => {
267
+ let pid;
268
+ try {
269
+ pid = JSON.parse(readFileSync(join(paths.root, 'body.json'), 'utf8')).pid;
270
+ }
271
+ catch {
272
+ return;
273
+ }
274
+ if (typeof pid !== 'number')
275
+ return;
276
+ try {
277
+ process.kill(pid);
278
+ }
279
+ catch {
280
+ return;
281
+ }
282
+ for (let i = 0; i < 50 && bodyRunning(paths.root); i++)
283
+ await new Promise(r => setTimeout(r, 100));
284
+ };
285
+ /** Folder dialogs open on the body, waiting for the user's choice. */
286
+ const folderPicks = new Map();
287
+ /** Ask the body to show a folder dialog; undefined when cancelled. */
288
+ const pickFolder = (title, button, file) => {
289
+ const body = server?.bodies.at(-1);
290
+ if (body === undefined)
291
+ return Promise.reject(new Error('身体还没连上:选文件夹的窗口要在身体那边弹出来'));
292
+ const id = `f${Date.now().toString(36)}`;
293
+ return new Promise(resolve => {
294
+ const timer = setTimeout(() => { folderPicks.delete(id); resolve(undefined); }, 10 * 60_000);
295
+ folderPicks.set(id, path => { clearTimeout(timer); resolve(path); });
296
+ body.send({ t: 'folder.pick', id, title, button, ...(file === undefined ? {} : { file }) });
297
+ });
298
+ };
299
+ /** Actions sent to a body and not done yet, with the body doing each. */
300
+ const pendingActs = new Map();
301
+ let nextAct = 1;
302
+ /** Have the body perform an action and wait (at most 90 s) for it to finish. */
303
+ const playAction = (action) => {
304
+ // The latest body that shows her (has packs), not a text-only client that connected after it.
305
+ const bodies = server?.bodies ?? [];
306
+ const body = [...bodies].reverse().find(b => b.hello.packs.length > 0) ?? bodies.at(-1);
307
+ log({ event: 'play-action', action, body: body?.id ?? null, packActions: bodyPack?.actions.map(a => a.id) ?? null });
308
+ if (body === undefined)
309
+ return Promise.resolve({ ok: false, reason: 'no body' });
310
+ const known = bodyPack?.actions.find(a => a.id === action);
311
+ if (known === undefined)
312
+ return Promise.resolve({ ok: false, reason: 'unsupported' });
313
+ const id = `a${nextAct++}`;
314
+ return new Promise(resolve => {
315
+ const timer = setTimeout(() => {
316
+ pendingActs.delete(id);
317
+ resolve({ ok: false, reason: 'timeout' });
318
+ }, 90_000);
319
+ pendingActs.set(id, {
320
+ body: body.id,
321
+ settle: result => {
322
+ clearTimeout(timer);
323
+ log({ event: 'act', action, ...result });
324
+ resolve(known.transcript === undefined || !result.ok ? result : { ...result, transcript: known.transcript });
325
+ },
326
+ });
327
+ body.send({ t: 'act', cid, id, action });
328
+ });
329
+ };
330
+ const log = (entry) => {
331
+ try {
332
+ appendFileSync(join(paths.logs, 'stream.jsonl'), `${JSON.stringify({ at: new Date().toISOString(), cid, ...entry })}\n`);
333
+ }
334
+ catch (error) {
335
+ ctx.logger.warn(`dsh-her: stream log failed (${error.message})`);
336
+ }
337
+ };
338
+ const broadcast = (message) => server?.broadcast(message);
339
+ /** A reply with no words: record what the turn did instead (tool calls, reasoning only, errors). */
340
+ const logQuietTurn = async () => {
341
+ const id = session?.sessionId;
342
+ const query = services?.sessionQuery;
343
+ if (id === undefined || query === undefined)
344
+ return;
345
+ try {
346
+ const events = (await query.readSession(id)).events.slice(-12);
347
+ log({
348
+ event: 'quiet-turn',
349
+ events: events.map(e => {
350
+ const data = e.data;
351
+ if (e.type === 'tool/call')
352
+ return `tool/call ${data.name} ${data.arguments ?? ''}`;
353
+ if (e.type === 'assistant/message')
354
+ return `assistant/message [${data.message?.content?.map(b => `${b.type}${b.text === undefined ? '' : `:${b.text.slice(0, 40)}`}`).join(', ') ?? ''}]`;
355
+ if (e.type === 'turn/end')
356
+ return `turn/end ${JSON.stringify(data.reason).slice(0, 120)}`;
357
+ return e.type;
358
+ }),
359
+ });
360
+ }
361
+ catch (error) {
362
+ log({ event: 'quiet-turn', error: error.message });
363
+ }
364
+ };
365
+ /** Her model: the "她" page's choice, else the plugin config's, else DSH's default. */
366
+ const route = () => {
367
+ const chosen = character.model;
368
+ if (chosen !== undefined)
369
+ return { provider: chosen.provider, model: chosen.model, ...(chosen.effort ? { reasoningEffort: chosen.effort } : {}) };
370
+ const effort = config.reasoningEffort === '' ? {} : { reasoningEffort: config.reasoningEffort };
371
+ if (config.provider !== '' && config.model !== '')
372
+ return { provider: config.provider, model: config.model, ...effort };
373
+ const selected = services?.agentDefaultModel?.currentSelection();
374
+ return selected === undefined ? undefined : { provider: selected.provider, model: selected.model, ...effort };
375
+ };
376
+ const open = () => {
377
+ session?.ensure().catch(error => ctx.logger.warn(`dsh-her: her session not ready (${error.message})`));
378
+ };
379
+ /** Stop her current reply: generation, synthesis and every body's playback. */
380
+ const cut = () => {
381
+ if (current === undefined)
382
+ return;
383
+ session?.interrupt();
384
+ speech?.cancel(current.attemptId);
385
+ broadcast({ t: 'speak.cancel', cid, uid: current.attemptId });
386
+ };
387
+ /** You said something (typed or spoken): cut her off if she is talking, then pass it on. */
388
+ const hear = (text, via) => {
389
+ if (session === undefined)
390
+ return;
391
+ cut();
392
+ sentAt = Date.now();
393
+ lastTalkAt = sentAt;
394
+ broadcast({ t: 'state', cid, state: 'think' });
395
+ clearTimeout(stuckTimer);
396
+ stuckTimer = setTimeout(() => {
397
+ log({ event: 'stuck', text, waitedMs: STUCK_AFTER_MS });
398
+ void logQuietTurn();
399
+ broadcast({ t: 'state', cid, state: 'idle' });
400
+ broadcast({ t: 'bubble', cid, text: '(我好像卡住了,没能开始回答。看看 dsh 里她的会话和模型设置?)', ttlMs: 8000 });
401
+ }, STUCK_AFTER_MS);
402
+ stuckTimer.unref();
403
+ const her = session;
404
+ const note = cutNote;
405
+ cutNote = undefined;
406
+ (note === undefined ? Promise.resolve() : her.note(note))
407
+ .then(() => her.say(text, via))
408
+ .catch((error) => {
409
+ broadcast({ t: 'state', cid, state: 'idle' });
410
+ broadcast({ t: 'bubble', cid, text: `(我还没准备好:${error.message})`, ttlMs: 6000 });
411
+ });
412
+ };
413
+ /** A body cut her off at `seq`, `playedMs` into it: remember what you heard and stop the rest. */
414
+ const interrupted = (uid, seq, playedMs) => {
415
+ const heard = heardText(speech?.sentOf(uid) ?? [], seq, playedMs);
416
+ cutNote = heard === ''
417
+ ? '(备注:你上一段话刚开口就被对方打断了,对方什么都没听到。)'
418
+ : `(备注:你上一段话只说到"${heard}"就被对方打断了,后面的部分对方没有听到。)`;
419
+ log({ event: 'interrupted', uid, seq, playedMs, heard });
420
+ lastCut = { uid, at: Date.now() };
421
+ if (current?.attemptId === uid)
422
+ session?.interrupt();
423
+ speech?.cancel(uid);
424
+ };
425
+ const onBodyMessage = (message, audio) => {
426
+ switch (message.t) {
427
+ case 'user.text': {
428
+ voice?.warm();
429
+ const text = message.text.trim();
430
+ if (text !== '')
431
+ hear(text, 'text');
432
+ break;
433
+ }
434
+ case 'user.utterance': {
435
+ voice?.warm();
436
+ if (message.text !== undefined) {
437
+ broadcast({ t: 'heard', cid, uttId: message.uttId, text: message.text });
438
+ if (message.text.trim() !== '')
439
+ hear(message.text.trim(), 'voice');
440
+ break;
441
+ }
442
+ if (audio === undefined || voice === undefined)
443
+ break;
444
+ const rate = Number(/@(\d+)k/.exec(message.format)?.[1] ?? 16) * 1000;
445
+ const endedAt = Date.now();
446
+ voice.recognize(audio, rate).then(({ text, ms }) => {
447
+ // Right after a barge-in: was it you, or her own voice through the speakers?
448
+ const barge = lastCut !== undefined && endedAt - lastCut.at < 10_000 ? lastCut : undefined;
449
+ lastCut = undefined;
450
+ const verdict = barge === undefined ? 'you' : judgeBargeIn(text, (speech?.sentOf(barge.uid) ?? []).map(s => s.text).join(''));
451
+ log({ event: 'heard', uttId: message.uttId, text, asrMs: ms, audioMs: message.durationMs, verdict });
452
+ if (verdict !== 'you') {
453
+ cutNote = undefined;
454
+ broadcast({ t: 'heard', cid, uttId: message.uttId, text: '', ignored: verdict });
455
+ return;
456
+ }
457
+ broadcast({ t: 'heard', cid, uttId: message.uttId, text });
458
+ lastHeard = { text, at: Date.now() };
459
+ if (text !== '')
460
+ hear(text, 'voice');
461
+ sentAt = endedAt;
462
+ }, (error) => {
463
+ broadcast({ t: 'heard', cid, uttId: message.uttId, text: '' });
464
+ broadcast({ t: 'bubble', cid, text: `(我没听清:${error.message})`, ttlMs: 5000 });
465
+ });
466
+ break;
467
+ }
468
+ case 'interrupt':
469
+ interrupted(message.uid, message.seq, message.playedMs);
470
+ break;
471
+ case 'act.done': {
472
+ const settle = pendingActs.get(message.id)?.settle;
473
+ pendingActs.delete(message.id);
474
+ settle?.({ ok: message.ok, ...(message.reason === undefined ? {} : { reason: message.reason }) });
475
+ break;
476
+ }
477
+ case 'pack.list':
478
+ bodyPacks = message.packs;
479
+ activePack = message.active;
480
+ bodyPack = message.packs.find(p => p.packId === message.active) ?? bodyPack;
481
+ break;
482
+ case 'mode.changed':
483
+ bodyMode = message.mode;
484
+ break;
485
+ case 'folder.picked':
486
+ folderPicks.get(message.id)?.(message.path);
487
+ folderPicks.delete(message.id);
488
+ break;
489
+ case 'look.state':
490
+ bodyLook = message.look ?? undefined;
491
+ break;
492
+ case 'pack.progress':
493
+ importStatus = { ...importStatus, state: 'running', done: message.done, total: message.total, step: message.step };
494
+ break;
495
+ case 'pack.report':
496
+ importStatus = { ...importStatus, state: message.ok ? 'done' : 'failed', lines: message.lines };
497
+ break;
498
+ case 'panel.get':
499
+ broadcast(panel());
500
+ break;
501
+ case 'panel.set': {
502
+ let error;
503
+ try {
504
+ if (message.character !== undefined)
505
+ api.saveCharacter(message.character);
506
+ if (message.forget !== undefined)
507
+ api.memoryRemove(message.forget);
508
+ if (message.confirm !== undefined)
509
+ api.memoryConfirm(message.confirm);
510
+ }
511
+ catch (failure) {
512
+ error = failure.message;
513
+ }
514
+ broadcast(panel(error));
515
+ break;
516
+ }
517
+ }
518
+ };
519
+ /** The body's main screen in device pixels, for photo sizes in the prompts. */
520
+ const mainScreen = () => {
521
+ const first = server?.bodies.at(-1)?.hello.caps.screens[0];
522
+ return first === undefined ? undefined : { width: Math.round(first.w * first.scale), height: Math.round(first.h * first.scale) };
523
+ };
524
+ /** Who she is and what she remembers, for the body's settings window. */
525
+ const panel = (error) => {
526
+ const { facts, episodes, diary } = memory.list();
527
+ const { name, persona, callYou, pronoun } = character;
528
+ return { t: 'panel', cid, character: { name, persona, callYou, pronoun }, memory: { facts, episodes, diary }, ...(error === undefined ? {} : { error }) };
529
+ };
530
+ /** The "她" page's view of everything, and what it can change. */
531
+ const api = {
532
+ state: () => {
533
+ const body = server?.bodies.at(-1);
534
+ const target = route();
535
+ const { facts, episodes, diary } = memory.list();
536
+ return {
537
+ platform: process.platform,
538
+ character,
539
+ model: target === undefined ? null : { provider: target.provider, model: target.model, ...(target.reasoningEffort === undefined ? {} : { effort: target.reasoningEffort }), chosen: character.model !== undefined },
540
+ voice: { status: voice?.status ?? 'off', speaking: voice?.speaking ?? { status: 'off', service: serviceOf(character.voice.id) }, id: character.voice.id, speed: character.voice.speed },
541
+ body: {
542
+ connected: body !== undefined,
543
+ running: body !== undefined || bodyRunning(paths.root),
544
+ canStart: !('missing' in bodyPlan()),
545
+ install: { ...installState, ...(installedBody(paths.root) === undefined ? {} : { installed: installedBody(paths.root).version }) },
546
+ ...(body === undefined ? {} : { info: { kind: body.hello.body.kind, version: body.hello.body.version, os: body.hello.body.os } }),
547
+ ...(bodyPack === undefined ? {} : { pack: bodyPack }),
548
+ packs: bodyPacks,
549
+ ...(activePack === undefined ? {} : { activePack }),
550
+ ...(bodyMode === undefined ? {} : { mode: bodyMode }),
551
+ ...(bodyLook === undefined ? {} : { look: bodyLook }),
552
+ ...(body === undefined ? {} : { screens: body.hello.caps.screens.map(({ w, h, scale }) => ({ w, h, scale })) }),
553
+ },
554
+ import: importStatus,
555
+ ...(lastHeard === undefined ? {} : { heard: lastHeard }),
556
+ memory: { facts, episodes, diary, lastDigestDay: memory.lastDigestDay() ?? null },
557
+ };
558
+ },
559
+ saveCharacter: patch => {
560
+ const next = { ...character, ...patch, voice: { ...character.voice, ...patch.voice } };
561
+ if ('model' in patch && patch.model === undefined)
562
+ delete next.model;
563
+ const problem = invalidCharacter(next);
564
+ if (problem !== undefined)
565
+ throw new Error(problem);
566
+ const modelChanged = JSON.stringify(next.model) !== JSON.stringify(character.model);
567
+ saveCharacter(dir, next);
568
+ character = next;
569
+ if (modelChanged) {
570
+ // The model is fixed when her session is opened: reopen it on the new one (her history stays).
571
+ cut();
572
+ void session?.dispose().then(open);
573
+ log({ event: 'model', ...(route() ?? {}) });
574
+ }
575
+ voice?.setVoice(next.voice.id, next.voice.speed);
576
+ log({ event: 'character', name: next.name, voice: next.voice.id, onboarded: next.onboarded });
577
+ return next;
578
+ },
579
+ expandPersona: async ({ name, idea, callYou, pronoun }) => complete([
580
+ '你在帮用户写一个桌面陪伴角色的人设。她住在用户电脑桌面上,陪用户聊天,会记得用户说过的事。',
581
+ '根据用户的一句话描述,写成完整人设:150 到 300 字,用第二人称"你是……"开头。',
582
+ '写清楚:年龄感和性格、说话方式(口头禅、语气)、自己的喜好和小习惯、和对方的关系。',
583
+ `对方的称呼是"${callYou || '对方'}",提到对方时用"${pronoun}"。`,
584
+ '不要写她能做什么、不能做什么(这部分另有规定);不要成人内容;只输出人设正文。',
585
+ ].join('\n'), `名字:${name}\n用户的描述:${idea}`, 800),
586
+ tryPersona: async ({ name, persona, callYou, pronoun, history, message }) => complete(buildSystemPrompt({ name, persona, you: { call: callYou, pronoun }, now: new Date() }), [...history.map(h => `${h.who === 'you' ? '对方' : '你'}:${h.text}`), `对方:${message}`, '(只回复你接下来说的话,不要带"你:"前缀。)'].join('\n'), 300),
587
+ previewVoice: async (id, speed, text) => {
588
+ if (voice === undefined)
589
+ throw new Error('她的声音还没准备好');
590
+ const audio = await voice.preview(text, id, speed);
591
+ return { audio: Buffer.from(audio.audio).toString('base64'), format: audio.format, durationMs: audio.durationMs };
592
+ },
593
+ startBody: () => {
594
+ if (server?.bodies.length)
595
+ return '身体已经连上了';
596
+ if (bodyRunning(paths.root))
597
+ return '身体已经在运行,正在连接';
598
+ const plan = bodyPlan();
599
+ if ('missing' in plan)
600
+ return `找不到身体程序:${plan.missing}`;
601
+ if (discovery === undefined)
602
+ return '大脑还没准备好';
603
+ launchBody(plan, { url: discovery.url, token: discovery.token, home: paths.root, pack: config.body.pack });
604
+ return '已经启动,几秒钟后会连上';
605
+ },
606
+ installBody: () => {
607
+ if (installState.state === 'downloading' || installState.state === 'installing' || installState.state === 'checking')
608
+ return;
609
+ void installLatest(true);
610
+ },
611
+ restartBody: async () => {
612
+ if (discovery === undefined)
613
+ throw new Error('大脑还没准备好');
614
+ await stopBody();
615
+ const plan = bodyPlan();
616
+ if ('missing' in plan)
617
+ throw new Error(`找不到身体程序:${plan.missing}`);
618
+ launchBody(plan, { url: discovery.url, token: discovery.token, home: paths.root, pack: config.body.pack });
619
+ installState = { ...installState, restart: false };
620
+ },
621
+ setMode: mode => broadcast({ t: 'mode', cid, mode }),
622
+ pickPack: () => {
623
+ const body = server?.bodies.at(-1);
624
+ if (body === undefined)
625
+ throw new Error('身体还没连上,导入素材要在身体所在的电脑上选文件夹');
626
+ importStatus = { ...IDLE_IMPORT, state: 'picking' };
627
+ body.send({ t: 'pack.pick', id: `i${Date.now()}` });
628
+ },
629
+ usePack: packId => {
630
+ if (!bodyPacks.some(p => p.packId === packId))
631
+ throw new Error('没有这个外形包');
632
+ broadcast({ t: 'pack.use', packId });
633
+ },
634
+ workshopList: () => listProjects(),
635
+ workshopNew: name => {
636
+ if (name === '')
637
+ throw new Error('先给形象起个名字');
638
+ return saveProject(newProject(`p${Date.now().toString(36)}`, name), mainScreen());
639
+ },
640
+ workshopSave: project => saveProject(project, mainScreen()),
641
+ workshopExport: async (id, media) => {
642
+ const dest = await pickFolder('导出到哪个文件夹', '导出到这里');
643
+ return dest === undefined ? undefined : exportProject(id, dest, media);
644
+ },
645
+ workshopImportProject: async () => {
646
+ const path = await pickFolder('选一个形象项目(文件夹,或里面的 形象.json)', '导入', '形象.json');
647
+ return path === undefined ? undefined : importProject(path, mainScreen());
648
+ },
649
+ workshopExamples: () => examples(paths.root),
650
+ workshopOpen: id => {
651
+ const found = findProject(id);
652
+ if (found === undefined)
653
+ throw new Error('找不到这个项目');
654
+ const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
655
+ spawn(opener, [found.dir], { detached: true, stdio: 'ignore' }).unref();
656
+ },
657
+ workshopFrame: (id, sceneId) => {
658
+ const found = findProject(id);
659
+ const scene = found?.project.scenes.find(s => s.id === sceneId);
660
+ if (found === undefined || scene === undefined)
661
+ throw new Error('找不到这个场景');
662
+ const body = server?.bodies.at(-1);
663
+ if (body === undefined)
664
+ throw new Error('身体还没连上:框人物区要在身体那边的窗口里做');
665
+ body.send({ t: 'scene.frame', dir: join(found.dir, sceneDir(scene)), name: scene.name });
666
+ },
667
+ workshopImport: id => {
668
+ const found = findProject(id);
669
+ if (found === undefined)
670
+ throw new Error('找不到这个项目');
671
+ const body = server?.bodies.at(-1);
672
+ if (body === undefined)
673
+ throw new Error('身体还没连上,导入要在身体所在的电脑上做');
674
+ const status = projectStatus(found.project, found.dir);
675
+ if (!status.importable)
676
+ throw new Error('还没有可以导入的部分:至少要有参考图和桌宠的 3 段,或者一个框好人物区的场景和它的 3 段');
677
+ prepareImport(found.project, found.dir);
678
+ const ready = (folder) => status.parts.find(p => p.folder === folder)?.enough === true;
679
+ const { project } = found;
680
+ importStatus = { ...IDLE_IMPORT, state: 'running' };
681
+ body.send({
682
+ t: 'look.import', id: project.id, name: project.name, dir: found.dir,
683
+ ...(project.desk.on && ready(DESK_DIR) ? { desk: DESK_DIR } : {}),
684
+ ...(project.default === undefined ? {} : { default: project.default }),
685
+ scenes: project.scenes.filter(s => ready(sceneDir(s)) && status.parts.find(p => p.folder === sceneDir(s))?.framed === true).map(s => ({
686
+ id: s.id, name: s.name, folder: sceneDir(s),
687
+ ...(s.hours !== undefined || s.user !== undefined ? { when: { ...(s.hours === undefined ? {} : { hours: s.hours }), ...(s.user === undefined ? {} : { user: s.user }) } } : {}),
688
+ })),
689
+ });
690
+ },
691
+ setLook: change => {
692
+ if (bodyLook === undefined)
693
+ throw new Error('现在用的不是多场景的形象');
694
+ if (change.go !== undefined && !bodyLook.scenes.some(s => s.id === change.go))
695
+ throw new Error('没有这个场景');
696
+ broadcast({ t: 'look.set', ...change });
697
+ },
698
+ memoryRemove: ids => memory.remove(ids),
699
+ memoryConfirm: ids => memory.confirm(ids),
700
+ digest: () => digest(dayOf(new Date())),
701
+ exportMemory: () => memory.exportMarkdown(),
702
+ };
703
+ ctx.effect(() => {
704
+ mkdirSync(dir, { recursive: true });
705
+ mkdirSync(paths.logs, { recursive: true });
706
+ session = new HerSession({
707
+ cid,
708
+ character: () => character,
709
+ performance: () => performanceSection(bodyPack),
710
+ playAction,
711
+ aboutYou,
712
+ memory,
713
+ dir,
714
+ get route() { return route(); },
715
+ services: () => services,
716
+ logger: ctx.logger,
717
+ events: {
718
+ start(attemptId) {
719
+ clearTimeout(stuckTimer);
720
+ current = { attemptId, startedAt: Date.now(), sentences: [] };
721
+ log({ event: 'start', attemptId, sinceSendMs: sentAt === undefined ? null : current.startedAt - sentAt });
722
+ broadcast({ t: 'speak.begin', cid, uid: attemptId });
723
+ },
724
+ sentence(attemptId, text) {
725
+ const now = Date.now();
726
+ let seq = 0;
727
+ if (current?.attemptId === attemptId) {
728
+ current.firstSentenceAt ??= now;
729
+ current.sentences.push(text);
730
+ seq = current.sentences.length - 1;
731
+ }
732
+ onFirstSentence?.();
733
+ onFirstSentence = undefined;
734
+ log({ event: 'sentence', attemptId, text, sinceSendMs: sentAt === undefined ? null : now - sentAt });
735
+ const performed = extractCues(text, bodyPack);
736
+ if (performed.text !== '' || performed.cues.length > 0)
737
+ speech?.say(attemptId, seq, performed.text, performed.cues);
738
+ },
739
+ end(attemptId, outcome) {
740
+ log({ event: 'end', attemptId, outcome, sinceSendMs: sentAt === undefined ? null : Date.now() - sentAt });
741
+ if (current?.attemptId === attemptId) {
742
+ current.outcome = outcome;
743
+ if (current.sentences.length === 0 && outcome !== 'committed') {
744
+ broadcast({ t: 'bubble', cid, text: '(我刚才没说出话来,模型可能出错了)', ttlMs: 6000 });
745
+ }
746
+ if (current.sentences.length === 0)
747
+ void logQuietTurn();
748
+ finished.push(current);
749
+ if (finished.length > 20)
750
+ finished.shift();
751
+ }
752
+ speech?.end(attemptId);
753
+ current = undefined;
754
+ },
755
+ },
756
+ });
757
+ if (services !== undefined)
758
+ open();
759
+ voice = new Voice(paths.models, join(paths.root, 'edge-tts'), { ...config.voice, voice: character.voice.id, speed: character.voice.speed }, ctx.logger);
760
+ const herVoice = voice;
761
+ void herVoice.start();
762
+ speech = new SpeechQueue(text => herVoice.synthesize(speakable(text)), {
763
+ sentence(uid, seq, text, audio, cues) {
764
+ const withCues = cues.length === 0 ? {} : { cues };
765
+ if (audio === undefined) {
766
+ broadcast({ t: 'speak.seg', cid, uid, seq, text, durationMs: text === '' ? 300 : readingMs(text), ...withCues });
767
+ return;
768
+ }
769
+ const stream = nextStream++;
770
+ if (seq === 0)
771
+ log({ event: 'first-audio', uid, sinceSendMs: sentAt === undefined ? null : Date.now() - sentAt, durationMs: audio.durationMs });
772
+ server?.broadcastWithAudio({ t: 'speak.seg', cid, uid, seq, text, durationMs: audio.durationMs, format: audio.format, stream, ...withCues }, stream, audio.audio);
773
+ },
774
+ end(uid) {
775
+ broadcast({ t: 'speak.end', cid, uid });
776
+ },
777
+ });
778
+ const brain = new BrainServer({
779
+ tokenFile: join(paths.root, 'token'),
780
+ discoveryFile: paths.discovery,
781
+ preferredPort: config.port,
782
+ logger: ctx.logger,
783
+ welcome: client => {
784
+ bodyPack = client.hello.packs[0] ?? bodyPack;
785
+ return {
786
+ t: 'welcome',
787
+ v: PROTOCOL_VERSION,
788
+ sessionId: session?.sessionId ?? '',
789
+ characters: [{ cid, name: character.name, mode: 'overlay' }],
790
+ config: { mic: config.voice.mic, bargeIn: true },
791
+ };
792
+ },
793
+ message: (_client, message, audio) => onBodyMessage(message, audio),
794
+ closed: client => {
795
+ bodyPack = server?.bodies.at(-1)?.hello.packs[0];
796
+ // Only what that body was doing is lost; another body (or a text-only client) leaving changes nothing.
797
+ for (const [id, pending] of pendingActs) {
798
+ if (pending.body !== client.id)
799
+ continue;
800
+ pendingActs.delete(id);
801
+ pending.settle({ ok: false, reason: 'no body' });
802
+ }
803
+ },
804
+ });
805
+ let launchTimer;
806
+ brain.start().then(started => {
807
+ server = brain;
808
+ discovery = started;
809
+ if (config.body.launch !== 'auto')
810
+ return;
811
+ launchTimer = setTimeout(() => {
812
+ if (brain.bodies.length > 0 || bodyRunning(paths.root))
813
+ return;
814
+ const plan = bodyPlan();
815
+ if ('missing' in plan) {
816
+ ctx.logger.info(`dsh-her: no body to start yet (${plan.missing}); the "她" page offers to install it`);
817
+ return;
818
+ }
819
+ const pid = launchBody(plan, { url: started.url, token: started.token, home: paths.root, pack: config.body.pack });
820
+ ctx.logger.info(`dsh-her: started the body (pid ${pid})`);
821
+ }, LAUNCH_AFTER_MS);
822
+ launchTimer.unref();
823
+ }, (error) => ctx.logger.warn(`dsh-her: body server failed to start (${error.message})`));
824
+ // The installed body keeps itself up to date: a check soon after start, then every few hours.
825
+ const updateFirst = setTimeout(() => void installLatest(false), UPDATE_FIRST_MS);
826
+ const updateTimer = setInterval(() => void installLatest(false), UPDATE_CHECK_MS);
827
+ updateFirst.unref();
828
+ updateTimer.unref();
829
+ const digestTimer = setInterval(digestTick, DIGEST_CHECK_MS);
830
+ const noteTimer = setInterval(noteTick, NOTE_CHECK_MS);
831
+ noteTimer.unref();
832
+ digestTimer.unref();
833
+ return async () => {
834
+ clearTimeout(launchTimer);
835
+ clearTimeout(updateFirst);
836
+ clearInterval(updateTimer);
837
+ clearInterval(digestTimer);
838
+ clearInterval(noteTimer);
839
+ clearTimeout(stuckTimer);
840
+ herVoice.stop();
841
+ voice = undefined;
842
+ speech = undefined;
843
+ const closing = session;
844
+ session = undefined;
845
+ server = undefined;
846
+ closing?.interrupt();
847
+ await Promise.all([closing?.dispose(), brain.stop()]);
848
+ };
849
+ });
850
+ // The "她" page's API, on DSH's own authenticated `/api` channel.
851
+ ctx.inject(['connection'], (webCtx) => {
852
+ const connection = webCtx.connection;
853
+ for (const [path, handler] of Object.entries(apiHandlers(api))) {
854
+ webCtx.effect(() => connection.fetch.register({ path: `${API_BASE}${path}`, methods: ['POST'], requestBody: 'buffered', fetch: request => handle(handler, request) }));
855
+ }
856
+ // 形象工坊's example pictures and clips, as files (`?f=reference.png`); only the named slots.
857
+ webCtx.effect(() => connection.fetch.register({
858
+ path: `${API_BASE}/example`, methods: ['GET'], requestBody: 'buffered',
859
+ fetch: async (request) => {
860
+ const file = exampleFile(paths.root, new URL(request.url).searchParams.get('f') ?? '');
861
+ if (file === undefined)
862
+ return new Response('not found', { status: 404 });
863
+ const type = file.endsWith('.mp4') ? 'video/mp4' : file.endsWith('.png') ? 'image/png' : 'application/octet-stream';
864
+ return new Response(readFileSync(file), { headers: { 'content-type': type, 'cache-control': 'no-cache' } });
865
+ },
866
+ }));
867
+ });
868
+ ctx.inject([...SERVICES], (serviceCtx) => {
869
+ serviceCtx.effect(() => {
870
+ services = serviceCtx;
871
+ open();
872
+ return () => { services = undefined; };
873
+ });
874
+ });
875
+ /**
876
+ * M0 spike: send a prompt, interrupt right after her first sentence, then
877
+ * read her session back and report what DSH recorded for the cut reply.
878
+ */
879
+ const checkInterrupt = async (her, prompt) => {
880
+ const query = services?.sessionQuery;
881
+ const id = her.sessionId;
882
+ const before = query === undefined || id === undefined ? -1 : Math.max(-1, ...(await query.readSession(id)).events.map(e => e.seq));
883
+ const first = new Promise(resolve => { onFirstSentence = resolve; });
884
+ sentAt = Date.now();
885
+ const agent = await her.say(prompt, 'command');
886
+ const timeout = new Promise(resolve => setTimeout(() => resolve('timeout'), 60_000).unref());
887
+ if (await Promise.race([first, timeout]) === 'timeout')
888
+ return { kind: 'error', text: '60 秒内没有等到第一句' };
889
+ const cutAt = Date.now();
890
+ her.interrupt();
891
+ await agent.whenIdle();
892
+ const idleMs = Date.now() - cutAt;
893
+ const events = query === undefined || her.sessionId === undefined ? [] : (await query.readSession(her.sessionId)).events.filter(e => e.seq > before);
894
+ const message = events.filter(e => e.type === 'assistant/message').at(-1)?.data;
895
+ const kept = message?.message?.content?.filter(b => b.type === 'text').map(b => b.text ?? '').join('') ?? '';
896
+ const end = events.filter(e => e.type === 'turn/end').at(-1)?.data;
897
+ log({ event: 'interrupt-check', idleMs, turnEnd: end?.reason?.kind ?? null, interrupted: message?.interrupted ?? null, kept, usage: message?.usage ?? null, eventTypes: events.map(e => e.type) });
898
+ return {
899
+ kind: 'success',
900
+ text: [
901
+ `第一句后 ${idleMs} ms 停稳`,
902
+ `turn/end:${end?.reason?.kind ?? '(无)'}`,
903
+ `assistant/message.interrupted:${message?.interrupted === true ? 'true' : String(message?.interrupted)}`,
904
+ `DSH 记下的前半段(${[...kept].length} 字):${kept.slice(0, 120)}${[...kept].length > 120 ? '…' : ''}`,
905
+ ].join('\n'),
906
+ };
907
+ };
908
+ /** M0 spike: what her last model request carried (tools, system prompt) and what it cost. */
909
+ const diagnose = async (her) => {
910
+ const query = services?.sessionQuery;
911
+ const id = her.sessionId;
912
+ if (query === undefined || id === undefined)
913
+ return { kind: 'error', text: '会话还没建好' };
914
+ const events = (await query.readSession(id)).events;
915
+ const header = events.filter(e => e.type === 'request/header').at(-1)?.data;
916
+ const usage = events.filter(e => e.type === 'assistant/message' && e.data.usage !== undefined).at(-1)?.data;
917
+ const system = events.filter(e => e.type === 'system/message').at(-1)?.data;
918
+ const result = {
919
+ headerKeys: header === undefined ? null : Object.keys(header),
920
+ systemChars: system === undefined ? null : JSON.stringify(system.message).length,
921
+ usage: usage?.usage ?? null,
922
+ bodies: server?.bodies.map(b => `${b.hello.body.kind} ${b.hello.body.version} ${b.hello.body.os}`) ?? [],
923
+ voice: voice?.status ?? null,
924
+ server: server?.url ?? null,
925
+ };
926
+ log({ event: 'diagnose', ...result });
927
+ return { kind: 'success', text: JSON.stringify(result, null, 2) };
928
+ };
929
+ /**
930
+ * `/her 记忆`: what she remembers, with ids. `/her 确认 f3 f4`, `/her 忘掉 f3 e2`,
931
+ * `/her 整理` (digest today now), `/her 导出记忆` (a Markdown file).
932
+ */
933
+ const memoryCommand = (text) => {
934
+ const [word = '', ...rest] = text.split(/\s+/);
935
+ const ids = rest.map(id => id.replace(/^#/, '')).filter(Boolean);
936
+ if (MEMORY_WORDS.has(word)) {
937
+ const { facts, episodes, diary } = memory.list();
938
+ const lines = [
939
+ `关于你(${facts.length}):`,
940
+ ...facts.map(f => ` ${f.id} ${f.text}${f.status === 'candidate' ? '(待确认)' : ''}${f.sensitive ? '(敏感)' : ''}`),
941
+ `事件(${episodes.length}):`,
942
+ ...episodes.map(e => ` ${e.id} ${e.at.slice(0, 10)} ${e.text}${e.due === undefined ? '' : `(${e.due})`}`),
943
+ `日记:${diary.length === 0 ? '还没有' : diary.join('、')}`,
944
+ `文件在 ${join(dir, 'memory')}`,
945
+ '确认:/her 确认 f3 | 删除:/her 忘掉 f3 e2 | 现在整理今天:/her 整理 | 导出:/her 导出记忆',
946
+ ];
947
+ return Promise.resolve({ kind: 'success', text: lines.join('\n') });
948
+ }
949
+ if (word === '确认')
950
+ return Promise.resolve({ kind: 'success', text: `确认了 ${memory.confirm(ids)} 条。` });
951
+ if (word === '忘掉' || word === 'forget') {
952
+ if (ids.length === 0)
953
+ return Promise.resolve({ kind: 'error', text: '用法:/her 忘掉 f3 e2(编号见 /her 记忆)' });
954
+ return Promise.resolve({ kind: 'success', text: `删掉了 ${memory.remove(ids)} 条。` });
955
+ }
956
+ if (word === '整理')
957
+ return digest(dayOf(new Date())).then(report => ({ kind: 'success', text: report }));
958
+ if (word === '导出记忆') {
959
+ const file = join(dir, 'memory', `她记得的事-${dayOf(new Date())}.md`);
960
+ writeFileSync(file, memory.exportMarkdown());
961
+ return Promise.resolve({ kind: 'success', text: `导出到 ${file}` });
962
+ }
963
+ return undefined;
964
+ };
965
+ ctx.inject(['commands'], (commandCtx) => {
966
+ const commands = commandCtx.commands;
967
+ commandCtx.effect(() => commands.register({
968
+ name: 'her',
969
+ description: 'dsh-her: talk to her (`/her 你好`), stop her (`/her 停`), or show her session',
970
+ input: { hint: '[想对她说的话 | 停]' },
971
+ async handler(invocation) {
972
+ if (session === undefined)
973
+ return { kind: 'error', text: 'dsh-her:还没有启动' };
974
+ const text = invocation.rawInput.trim();
975
+ if (text === '') {
976
+ const id = session.sessionId;
977
+ const bodies = server?.bodies.length ?? 0;
978
+ return {
979
+ kind: 'success',
980
+ text: `${session.title}\n会话:${id ?? '还没建好'}\n身体:${bodies} 个已连接(${server?.url ?? '服务未启动'})\n日志:${join(paths.logs, 'stream.jsonl')}`,
981
+ };
982
+ }
983
+ if (STOP_WORDS.has(text.toLowerCase())) {
984
+ cut();
985
+ return { kind: 'success', text: '已让她停下。' };
986
+ }
987
+ if (text.startsWith(INTERRUPT_CHECK))
988
+ return checkInterrupt(session, text.slice(INTERRUPT_CHECK.length).trim() || '给我讲一个很长很长的睡前故事,至少讲十句。');
989
+ if (text === DIAGNOSE)
990
+ return diagnose(session);
991
+ const memoryReply = memoryCommand(text);
992
+ if (memoryReply !== undefined)
993
+ return memoryReply;
994
+ sentAt = Date.now();
995
+ broadcast({ t: 'state', cid, state: 'think' });
996
+ const before = finished.length;
997
+ const agent = await session.say(text, 'command');
998
+ await agent.whenIdle();
999
+ const reply = finished.slice(before).at(-1);
1000
+ if (reply === undefined)
1001
+ return { kind: 'error', text: '她没有回复(看 DSH 日志里的模型错误)' };
1002
+ const firstMs = reply.firstSentenceAt === undefined ? '—' : `${reply.firstSentenceAt - sentAt} ms`;
1003
+ return {
1004
+ kind: 'success',
1005
+ text: `${reply.sentences.map((s, i) => `${i + 1}. ${s}`).join('\n')}\n\n(开始生成 ${reply.startedAt - sentAt} ms · 第一句 ${firstMs} · ${reply.outcome})`,
1006
+ };
1007
+ },
1008
+ }));
1009
+ });
1010
+ }