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,48 @@
1
+ /**
2
+ * Start the body app when no body is connected: the development body (the
3
+ * repository's `body/` next to this package, built with `npm run build`
4
+ * there) when there is one, else the installed app (body-install.ts).
5
+ *
6
+ * The Electron binary is found through `node_modules/electron/path.txt`, not
7
+ * `require('electron')`: inside the DSH desktop app that require returns
8
+ * Electron's API instead of the binary path. The body is detached so it
9
+ * outlives DSH (she sleeps until DSH is back), and ELECTRON_RUN_AS_NODE is
10
+ * removed or the body would start as plain Node.
11
+ */
12
+ import { spawn } from 'node:child_process';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ /** The development body next to this package, if it is built. */
17
+ export function findDevBody(packageRoot = fileURLToPath(new URL('../../', import.meta.url))) {
18
+ const bodyDir = join(packageRoot, 'body');
19
+ const pathFile = join(bodyDir, 'node_modules', 'electron', 'path.txt');
20
+ if (!existsSync(join(bodyDir, 'dist', 'main.cjs')))
21
+ return { missing: `${bodyDir}/dist(在 body/ 里运行 npm run build)` };
22
+ if (!existsSync(pathFile))
23
+ return { missing: `${pathFile}(在 body/ 里运行 npm install)` };
24
+ const binary = join(bodyDir, 'node_modules', 'electron', 'dist', readFileSync(pathFile, 'utf8').trim());
25
+ return { binary, args: [bodyDir], cwd: bodyDir };
26
+ }
27
+ /** A body that is running but not connected yet (still backing off) has its pid in `<home>/body.json`. */
28
+ export function bodyRunning(home) {
29
+ try {
30
+ const { pid } = JSON.parse(readFileSync(join(home, 'body.json'), 'utf8'));
31
+ if (typeof pid !== 'number')
32
+ return false;
33
+ process.kill(pid, 0);
34
+ return true;
35
+ }
36
+ catch (error) {
37
+ // EPERM: alive, owned by someone else.
38
+ return error.code === 'EPERM';
39
+ }
40
+ }
41
+ export function launchBody(plan, env) {
42
+ const childEnv = { ...process.env, DSH_HER_URL: env.url, DSH_HER_TOKEN: env.token, DSH_HER_HOME: env.home };
43
+ delete childEnv.ELECTRON_RUN_AS_NODE;
44
+ const args = env.pack === '' ? plan.args : [...plan.args, '--pack', env.pack];
45
+ const child = spawn(plan.binary, args, { cwd: plan.cwd, env: childEnv, detached: true, stdio: 'ignore' });
46
+ child.unref();
47
+ return child.pid;
48
+ }
@@ -0,0 +1,27 @@
1
+ import Schema from '@deepseek-ai/schemastery';
2
+ import { DEFAULT_VOICE, SAMPLE_PERSONA } from "../core/index.js";
3
+ export const Config = Schema.object({
4
+ home: Schema.string().default('~/.dsh-her').description('dsh-her home (characters, packs, voice models)'),
5
+ character: Schema.object({
6
+ id: Schema.string().default('sample').description('Character id (her session and memory are keyed by it)'),
7
+ name: Schema.string().default('小禾').description('Her name'),
8
+ persona: Schema.string().role('textarea').default(SAMPLE_PERSONA).description('Who she is and how she talks'),
9
+ }).default({}),
10
+ provider: Schema.string().default('').description('Model provider for her session (empty = DSH default)'),
11
+ model: Schema.string().default('').description('Model for her session (empty = DSH default)'),
12
+ reasoningEffort: Schema.string().default('').description('Reasoning effort for her model (empty = route default; lower answers sooner)'),
13
+ port: Schema.natural().max(65535).default(38100).description('Preferred port for the body (next free one if taken)'),
14
+ body: Schema.object({
15
+ launch: Schema.union(['auto', 'off']).default('auto').description('Start the body app with DSH'),
16
+ pack: Schema.string().default('').description('Appearance pack directory (empty = body default)'),
17
+ releases: Schema.string().default('https://github.com/AIcivilization/dsh-her/releases/latest/download/').description('Where the body app is downloaded from (the folder with body-latest.json)'),
18
+ useDev: Schema.boolean().default(true).description('Use the development body in the repository when there is one (off: like a release install)'),
19
+ }).default({}),
20
+ voice: Schema.object({
21
+ enabled: Schema.boolean().default(true).description('Her voice; off = she only writes'),
22
+ voice: Schema.string().default(DEFAULT_VOICE).description('Her first voice: a Microsoft Edge voice (free, via edge-tts) or an Alibaba Cloud CosyVoice voice (key in DASHSCOPE_API_KEY or the macOS keychain); the "她" page can change it'),
23
+ speed: Schema.number().min(0.5).max(2).default(1).description('Speaking speed'),
24
+ threads: Schema.natural().min(1).max(16).default(4).description('CPU threads for local speech recognition'),
25
+ mic: Schema.union(['push-to-talk', 'always']).default('push-to-talk').description('Microphone: one utterance per shortcut press, or always listening'),
26
+ }).default({}),
27
+ });
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Her own session in DSH ("她 · <name>"): one ordinary persisted session per
3
+ * character. Created once, resumed after every restart; its id is kept under
4
+ * the dsh-her home, not in DSH. The creation follows dsh-vael's SelfSession.
5
+ *
6
+ * Inside her agent's scoped context she gets her own system prompt (DSH's
7
+ * coding-agent prompt and runtime chatter are replaced) and a listener on her
8
+ * live assistant stream, which turns text deltas into speakable sentences.
9
+ * Reasoning and tool-call deltas are never spoken.
10
+ *
11
+ * Her prompt is rebuilt at every model request, so its performance section
12
+ * always matches the body that is connected and what she remembers is current.
13
+ * Her tools: `play_action` (a longer performance by the body) and `remember`,
14
+ * `recall`, `forget` (her memory).
15
+ */
16
+ import { randomUUID } from 'node:crypto';
17
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { buildSystemPrompt, SentenceSplitter } from "../core/index.js";
20
+ export class HerSession {
21
+ handle;
22
+ opening;
23
+ splitter = new SentenceSplitter();
24
+ /** Set by interrupt(): the rest of the current reply is not spoken. */
25
+ dropping = false;
26
+ /** The reply being streamed, unique across turns. */
27
+ replyId;
28
+ deps;
29
+ constructor(deps) {
30
+ this.deps = deps;
31
+ }
32
+ get title() {
33
+ return `她 · ${this.deps.character().name}`;
34
+ }
35
+ get sessionId() {
36
+ return this.handle?.agent.session.id ?? this.recorded();
37
+ }
38
+ /** Her live agent: reuse, resume the recorded session, or create it the first time. */
39
+ ensure() {
40
+ if (this.handle !== undefined)
41
+ return Promise.resolve(this.handle);
42
+ this.opening ??= this.open().finally(() => { this.opening = undefined; });
43
+ return this.opening;
44
+ }
45
+ /** Send what you said (typed or recognized) as a new turn. */
46
+ async say(text, via) {
47
+ const { agent } = await this.ensure();
48
+ agent.followup({ id: randomUUID(), role: 'user', content: [{ type: 'text', text }], source: { kind: 'dsh-her', via } });
49
+ return agent;
50
+ }
51
+ /** Tell her something for her next turn without making her answer (what was heard of a cut-off reply, …). */
52
+ async note(text) {
53
+ const { agent } = await this.ensure();
54
+ agent.inject({ id: randomUUID(), role: 'user', content: [{ type: 'text', text }], source: { kind: 'dsh-her-context' } });
55
+ }
56
+ /** Stop the reply in progress (barge-in). DSH keeps the delivered prefix marked `interrupted`. */
57
+ interrupt() {
58
+ this.splitter.reset();
59
+ this.dropping = true;
60
+ try {
61
+ this.handle?.agent.cancel(new Error('dsh-her: interrupted'));
62
+ }
63
+ catch {
64
+ // nothing in progress
65
+ }
66
+ }
67
+ async dispose() {
68
+ const handle = this.handle;
69
+ this.handle = undefined;
70
+ try {
71
+ await handle?.dispose();
72
+ }
73
+ catch {
74
+ // the owning scope may already have torn it down
75
+ }
76
+ }
77
+ recordFile() {
78
+ return join(this.deps.dir, 'session.json');
79
+ }
80
+ recorded() {
81
+ try {
82
+ const value = JSON.parse(readFileSync(this.recordFile(), 'utf8'));
83
+ return typeof value.sessionId === 'string' ? value.sessionId : undefined;
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ async open() {
90
+ const services = this.deps.services();
91
+ if (services === undefined)
92
+ throw new Error('DSH 的 agents 服务还不可用');
93
+ const { logger, route } = this.deps;
94
+ const setup = (agentCtx) => this.composeScope(agentCtx);
95
+ const recorded = this.recorded();
96
+ if (recorded !== undefined) {
97
+ try {
98
+ this.handle = await services.agents.resume({ resumeSessionId: recorded, agentOptions: route, setup });
99
+ logger.info(`dsh-her: resumed ${this.title} (${recorded})`);
100
+ return this.handle;
101
+ }
102
+ catch (error) {
103
+ logger.warn(`dsh-her: could not resume ${recorded} (${error.message}); creating a new session`);
104
+ }
105
+ }
106
+ const sessionId = `dsh-her-${this.deps.cid}-${randomUUID()}`;
107
+ const room = join(this.deps.dir, 'room');
108
+ mkdirSync(room, { recursive: true });
109
+ const workspace = await services.workspaceRegistry?.create(room, this.title);
110
+ const handle = await services.agents.create({ sessionId, meta: { cwd: room }, agentOptions: route, setup });
111
+ try {
112
+ await workspace?.attachSession(sessionId);
113
+ services.sessionTitle?.rename(handle.agent.session, this.title);
114
+ }
115
+ catch (error) {
116
+ logger.warn(`dsh-her: session created but not attached/titled (${error.message})`);
117
+ }
118
+ writeFileSync(this.recordFile(), JSON.stringify({ sessionId, previous: recorded ?? null, createdAt: new Date().toISOString() }, null, 2));
119
+ logger.info(`dsh-her: created ${this.title} (${sessionId})`);
120
+ this.handle = handle;
121
+ return handle;
122
+ }
123
+ async composeScope(agentCtx) {
124
+ agentCtx.systemPrompt?.section({
125
+ name: 'dsh-her:system', order: 0, complete: true, interpolate: false,
126
+ // A failing part is left out, never the prompt: a throw here leaves her silent with no error (M4).
127
+ text: () => {
128
+ const { name, persona, callYou, pronoun } = this.deps.character();
129
+ return buildSystemPrompt({
130
+ name, persona, you: { call: callYou, pronoun },
131
+ performance: this.safe('performance', this.deps.performance), aboutYou: this.safe('aboutYou', this.deps.aboutYou), now: new Date(),
132
+ });
133
+ },
134
+ });
135
+ agentCtx.systemPrompt?.suppressRuntimeContext?.();
136
+ this.hideGlobalTools(agentCtx);
137
+ // Without her tools she can still talk; never let them keep her session closed.
138
+ await this.registerTools(agentCtx).catch((error) => this.deps.logger.warn(`dsh-her: play_action not registered (${error.message})`));
139
+ agentCtx.on('agent/assistant-stream', ({ frame }) => this.onFrame(frame));
140
+ }
141
+ /**
142
+ * Her tools, in DSH's native tool shape (plain JSON Schema). Not through
143
+ * `defineTool`: a plugin linked from outside the profile cannot import
144
+ * dsh-tools, and an unconverted schema makes the model provider reject the
145
+ * whole request.
146
+ */
147
+ safe(part, make) {
148
+ try {
149
+ return make();
150
+ }
151
+ catch (error) {
152
+ this.deps.logger.warn(`dsh-her: prompt part ${part} failed (${error.message})`);
153
+ return '';
154
+ }
155
+ }
156
+ async registerTools(agentCtx) {
157
+ const playAction = this.deps.playAction;
158
+ agentCtx.tools.register({
159
+ name: 'play_action',
160
+ description: '让你在桌面上的身体做一个较长的表演(离开画面换衣服、说准备好的台词等),做完才返回。能做哪些见系统提示词的"表演"一节。',
161
+ parameters: {
162
+ type: 'object',
163
+ properties: { action: { type: 'string', description: '表演的名字,比如 outfit_jk' } },
164
+ required: ['action'],
165
+ additionalProperties: false,
166
+ },
167
+ output: {
168
+ schema: { type: 'object', properties: { ok: { type: 'boolean' }, text: { type: 'string' } }, required: ['ok', 'text'] },
169
+ render: (_args, value) => [{ type: 'text', text: value.text }],
170
+ },
171
+ execute: async (args) => {
172
+ const action = String(args.action ?? '').trim();
173
+ const result = await playAction(action);
174
+ if (result.ok)
175
+ return { ok: true, text: result.transcript === undefined ? `已经做完:${action}` : `已经做完:${action}(你说了"${result.transcript}")` };
176
+ const why = { unsupported: '身体不会这个表演', interrupted: '做到一半被打断了', 'no body': '桌面上没有连着身体', timeout: '等了太久没做完' }[result.reason ?? ''] ?? result.reason ?? '没做成';
177
+ return { ok: false, text: `没做成:${action}(${why})` };
178
+ },
179
+ });
180
+ const memory = this.deps.memory;
181
+ const textOutput = {
182
+ schema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
183
+ render: (_args, value) => [{ type: 'text', text: value.text }],
184
+ };
185
+ agentCtx.tools.register({
186
+ name: 'remember',
187
+ description: '记下一件关于对方的事。fact:长期成立的事实(称呼、喜好、忌讳、重要的人、生日);event:具体的事,有日期就写 date。',
188
+ parameters: {
189
+ type: 'object',
190
+ properties: {
191
+ text: { type: 'string', description: '一句话,比如"他不吃香菜"、"他下周三去面试"' },
192
+ kind: { type: 'string', enum: ['fact', 'event'] },
193
+ date: { type: 'string', description: 'event 发生的日期 YYYY-MM-DD(把"明天""下周三"换算好);没有就不填' },
194
+ },
195
+ required: ['text', 'kind'],
196
+ additionalProperties: false,
197
+ },
198
+ output: textOutput,
199
+ execute: async (args) => {
200
+ const hit = memory.remember({ kind: args.kind === 'event' ? 'episode' : 'fact', text: String(args.text ?? ''), ...(args.date === undefined ? {} : { due: args.date }) }, new Date());
201
+ return { text: `记下了(${hit.id}):${hit.text}` };
202
+ },
203
+ });
204
+ agentCtx.tools.register({
205
+ name: 'recall',
206
+ description: '在你的记忆里找和这个话题有关的事(关于对方的事实、发生过的事、你的日记)。',
207
+ parameters: {
208
+ type: 'object',
209
+ properties: { query: { type: 'string', description: '要找的话题,比如"面试"、"猫"' } },
210
+ required: ['query'],
211
+ additionalProperties: false,
212
+ },
213
+ output: textOutput,
214
+ execute: async (args) => {
215
+ const hits = memory.recall(String(args.query ?? ''), new Date());
216
+ if (hits.length === 0)
217
+ return { text: '没找到相关的记忆。' };
218
+ return { text: hits.map(h => `[${h.id}] ${h.at.slice(0, 10)} ${h.text}`).join('\n') };
219
+ },
220
+ });
221
+ agentCtx.tools.register({
222
+ name: 'forget',
223
+ description: '删掉记忆里的几条(id 来自 recall 的结果)。只在对方明确让你忘掉、并且确认过是哪条之后用。',
224
+ parameters: {
225
+ type: 'object',
226
+ properties: { ids: { type: 'array', items: { type: 'string' }, description: '要删的 id,比如 ["f3", "e2"]' } },
227
+ required: ['ids'],
228
+ additionalProperties: false,
229
+ },
230
+ output: textOutput,
231
+ execute: async (args) => {
232
+ const removed = memory.remove(Array.isArray(args.ids) ? args.ids.map(String) : []);
233
+ return { text: removed === 0 ? '没有删掉任何东西(id 不对?)' : `删掉了 ${removed} 条。` };
234
+ },
235
+ });
236
+ }
237
+ /**
238
+ * P1: she only keeps you company, so DSH's work tools (shell, files, web…)
239
+ * are hidden from her session. Their schemas would also cost thousands of
240
+ * tokens on every turn. Tools she gets later are registered in her own scope,
241
+ * which a restriction never hides.
242
+ */
243
+ hideGlobalTools(agentCtx) {
244
+ try {
245
+ const names = agentCtx.tools.schemas().map(schema => schema.name).filter(name => name !== 'run_code');
246
+ if (names.length > 0)
247
+ agentCtx.tools.restrict({ deny: names });
248
+ }
249
+ catch (error) {
250
+ this.deps.logger.warn(`dsh-her: could not hide DSH tools from her session (${error.message})`);
251
+ }
252
+ }
253
+ onFrame(frame) {
254
+ const { events } = this.deps;
255
+ if (frame.type === 'start') {
256
+ this.splitter.reset();
257
+ this.dropping = false;
258
+ // DSH's attempt id repeats across turns (it ends in the agent's revision), so the turn and step make it unique.
259
+ this.replyId = `${frame.attemptId}@${frame.turn}.${frame.step}`;
260
+ events.start(this.replyId);
261
+ return;
262
+ }
263
+ const id = this.replyId;
264
+ if (id === undefined)
265
+ return;
266
+ if (frame.type === 'chunk') {
267
+ if (this.dropping || frame.chunk.type !== 'text-delta' || typeof frame.chunk.text !== 'string')
268
+ return;
269
+ for (const sentence of this.splitter.push(frame.chunk.text))
270
+ events.sentence(id, sentence);
271
+ }
272
+ else {
273
+ if (!this.dropping && frame.outcome.kind !== 'abandoned') {
274
+ for (const sentence of this.splitter.flush())
275
+ events.sentence(id, sentence);
276
+ }
277
+ this.splitter.reset();
278
+ this.replyId = undefined;
279
+ events.end(id, frame.outcome.kind);
280
+ }
281
+ }
282
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Where a pack import started from the "她" page stands. The import itself
3
+ * runs in the body (it reads your folder and has ffmpeg); the body reports
4
+ * progress and a report, which the page shows by polling the brain's state.
5
+ */
6
+ export const IDLE_IMPORT = { state: 'idle', done: 0, total: 0, step: '', lines: [] };