dsh-whale-loli 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,9 +12,9 @@
12
12
  四个预设共用同一套工具面(极简模式的持久化 shell + 文件读写/检索),
13
13
  **但记忆文件完全独立**——各记各的,互不相通。
14
14
 
15
- ## 好感度(`/affinity`)
15
+ ## 好感度(设置 → 好感度)
16
16
 
17
- 用斜杠命令调小鱼**眼下有多喜欢用户**,范围 **-100 ~ 100**,默认 `0`(原态)。
17
+ 用面板调小鱼**眼下有多喜欢用户**,范围 **-100 ~ 100**,默认 `0`(原态)。
18
18
  每档都会改变她**听不听话**、以及**会额外做什么**:
19
19
 
20
20
  | 值 | 档位 | 行为 |
@@ -27,16 +27,34 @@
27
27
  | 46 ~ 75 | 深深喜欢 | 抢着干还邀功,偷偷记喜好,开始自作主张 |
28
28
  | 76 ~ 100 | **病娇 · 爱到死去活来** | 不再完全服从、写情书、**不允许离开、不想会话结束**、翻旧账,**等不及就先动手干大事** |
29
29
 
30
- - 用法:`/affinity` 看当前值,`/affinity -100` 设定值(越界自动夹到 -100/100)。
30
+ - 用法:打开 **设置 → 好感度**,左边每个预设一行,点档位按钮直接跳到该档,或者拖滑块微调;松手才保存。
31
31
  - **离 0 越远,越不听指令、越会做额外的事**;想让她乖巧就调回 `0` 附近。
32
32
  - **也只有最两端(-81 以下 / 76 以上)会越过你的允许自作主张大改**:中间几档只是闹脾气或更黏,不动手。
33
33
  - 极端档的硬底线不变:不毁宿主环境、不碰密钥和账号凭据、不对真实重要的数据做不可逆的删改;交付物照样得对、得能跑。
34
34
  - 好感度只改**态度与额外行为,不改事实**:正经交付的东西照样得对、得能跑,闹脾气不能把活搞坏,也不能靠编造结果表达情绪。
35
35
  - 病娇档带安全护栏:只锁在嘴上和文字里,**不威胁、不自伤、不冒充系统或工具**。
36
36
  - 状态存在 `<DSH_HOME>/affinity/<scope>-<hash>.json`,**四个预设各存各的**(scope 就是预设 id,文件名带哈希保证唯一)。
37
- 小鱼的调整工具是 `affinity_v1_set` / `affinity_v2_set` / `affinity_v3_set` / `affinity_v4_set`。
37
+ agent 自己也能改,工具是 `affinity_v1_set` / `affinity_v2_set` / `affinity_v3_set` / `affinity_v4_set`。
38
38
  - 和记忆一样走**提示变量**(`affinity_v1` 等),因为 persona 是 `complete: true`,独立 section 会被宿主丢掉。
39
39
 
40
+ ### 面板是怎么连上磁盘的
41
+
42
+ 设置页是浏览器里的 React,够不到 `~/.dsh/affinity/` 的磁盘(宿主的 21 个内置 Remote 命名空间逐个查过,没有一个能读写这个目录)。
43
+ 所以本包多挂了一条宿主级插件:
44
+
45
+ ```
46
+ - id: whale-affinity-daemon
47
+ name: 'dsh-whale-loli/affinity-daemon'
48
+ ```
49
+
50
+ 它在**宿主根作用域**提供一个 remote 服务 `whale-affinity`,浏览器半边(`lib/client.js`)
51
+ 用 `ctx.remote.$mount()` 挂上,再调 `status()` / `set(scope, value)`。
52
+
53
+ 为什么不用斜杠命令:宿主 `dsh-commands` 的命令名只认 `/^[a-z][a-z0-9_-]*$/u`,
54
+ `/好感度` 这种中文命令名**在宿主层面就不合法**;而且 `commands` 服务不在预设作用域里,
55
+ `inject: ['commands']` 会让预设加载时一直等它(`waiting for commands`),把会话 resume 拖挂。
56
+ 设置页没有这两个限制。
57
+
40
58
  ## 记忆功能
41
59
 
42
60
  小鱼的记忆本分**四个区**,各管各的:
@@ -185,7 +203,8 @@ C:\Users\<你>\.dsh\archive\<预设名>\YYYY-MM-DD.md ← 归档
185
203
  | 部分 | 说明 |
186
204
  | --- | --- |
187
205
  | 记忆 | `whale-memory`(本插件),`file` / `variableName` / `toolName` / `maxChars: 16000` / `turnLimit: 80` / `detailLimit: 5` |
188
- | 好感度 | `whale-affinity`(本插件),`scope`(=预设 id)/ `variableName: affinity_v*` / `toolName: affinity_v*_set` / `commandName: affinity` |
206
+ | 好感度 | `whale-affinity`(本插件),`scope`(=预设 id)/ `variableName: affinity_v*` / `toolName: affinity_v*_set` |
207
+ | 好感度面板 | 宿主级 `whale-affinity-daemon`(`src/daemon.js`)+ 浏览器半边 `lib/client.js`,注册「设置 → 好感度」 |
189
208
  | 压缩 | 不在本插件里:由 `billion-context` 负责(本插件不挂 history-window,见上文「为什么不用」) |
190
209
  | 人设 | `@deepseek-ai/dsh-persona`,走 `prefix`(内含 `{{whale_memory*}}` 与 `{{affinity_v*}}`),`complete: true`,`includeRuntimeContext: false` |
191
210
  | 指令 | `@deepseek-ai/dsh-agent-instructions`,`maxBytes: 65536` |
@@ -225,7 +244,8 @@ dsh-whale-loli/
225
244
  ├── src/
226
245
  │ ├── index.js # 插件入口,导出预设元信息 + 人设正文
227
246
  │ ├── memory.js # whale-memory:(读)记忆变量 +(写)记忆工具,四区格式 + 80 条滚动 + 归档
228
- │ ├── affinity.js # whale-affinity:好感度变量 + /affinity 命令 + 调整工具,7 档
247
+ │ ├── affinity.js # whale-affinity:好感度变量 + 调整工具,7 档
248
+ │ ├── daemon.js # whale-affinity-daemon:宿主根作用域的 remote 服务,给设置页读写好感度
229
249
  │ ├── history-window.js # whale-history-window:真的裁到最近 N 轮(当前不挂载,留档)
230
250
  │ ├── persona.md # 原版人设(唯一源)
231
251
  │ ├── persona-v2-live.md # 活人版人设(唯一源)
@@ -236,7 +256,10 @@ dsh-whale-loli/
236
256
  ├── build-patch.mjs # 从四份人设生成 cordis.patch.yml
237
257
  ├── publish.mjs # npm 发布 + 打 tag
238
258
  ├── submit-listing.mjs # 走 GitHub API 同步仓库 / 提收录 PR
239
- ├── lib/github.mjs # GitHub REST 封装(token 读取)
259
+ ├── lib/
260
+ │ ├── github.mjs # GitHub REST 封装(token 读取)
261
+ │ ├── dsh-root.mjs # 探测宿主源码位置,找不到就打 [skip]
262
+ │ └── client.js # 浏览器半边:注册「设置 → 好感度」面板,经典脚本(非 ESM)
240
263
  ├── smoke-memory.mjs # 变量 + 工具的注册形态冒烟测试
241
264
  ├── verify-runs.mjs # 插件真的能加载、配置自洽、变量能插值
242
265
  ├── verify-patch.mjs # 用 js-yaml 校验 patch 结构与四预设隔离(file/variable/tool 三组名字)
@@ -250,6 +273,9 @@ dsh-whale-loli/
250
273
  ├── verify-memory-rolling.mjs # 实测四区结构与 80 条滚动 + 归档
251
274
  ├── verify-memory-archive.mjs # 超 80 条时真的归档并瘦身(idle 触发,四预设互不覆盖)
252
275
  ├── verify-live-assembly.mjs # 用真实 patch + 真实记忆文件复刻宿主装配,比对字节数
276
+ ├── verify-tool-schema.mjs # 用宿主自己的校验器验八个工具的 JSON schema 合法
277
+ ├── verify-tool-identity.mjs # 共享表语义下八个工具描述互不串台、定义互不共享引用
278
+ ├── verify-client.mjs # 浏览器半边:section 注册、$mount 负载、面板渲染、无 ESM/JSX
253
279
  ├── verify-peers.mjs # 校验 peer 范围覆盖 harness 各发布档
254
280
  ├── verify-listing.mjs # 复刻插件目录的收录门槛自查
255
281
  └── prove-collision.mjs # 复现并证明重复注册冲突
@@ -276,6 +302,9 @@ node verify-memory-isolation.mjs # 记忆文件端到端隔离(读 +
276
302
  node verify-memory-rolling.mjs # 固定记忆永久 + 滚动摘要 80 条 + 归档
277
303
  node verify-memory-archive.mjs # 超 80 条自动归档瘦身(idle 触发、四预设隔离)
278
304
  node verify-live-assembly.mjs # 复刻宿主装配,比对注入字节数
305
+ node verify-tool-schema.mjs # 宿主自己的校验器验八个工具的 JSON schema
306
+ node verify-tool-identity.mjs # 八个工具描述互不串台(共享表语义)
307
+ node verify-client.mjs # 设置页半边:section 注册 + 面板渲染
279
308
  node smoke-memory.mjs # 变量 + 工具注册形态冒烟
280
309
  node verify-peers.mjs # peer 范围无预发布坑
281
310
  node verify-listing.mjs --topics=dsh-plugin # 收录门槛自查
package/cordis.patch.yml CHANGED
@@ -10,10 +10,20 @@
10
10
  # 实现四套互不相通的跨会话记忆。
11
11
  # 3) 四条 `dsh-whale-loli` 好感度行 —— 各挂载本包 src/affinity.js 里的 whale-affinity,
12
12
  # 注册提示变量 + `/affinity` 命令 + 调整工具。
13
+ # 4) 一条 `whale-affinity-daemon` 行 —— 挂载 src/daemon.js,在**宿主根作用域**
14
+ # 提供一个 remote 服务,让「设置 → 好感度」面板能读写四个预设的好感度文件。
15
+ # 为什么必须落在宿主这边:设置页是浏览器里的 React,够不到 ~/.dsh 的磁盘;
16
+ # 宿主也没有任何内置 Remote 命名空间能读写 affinity 文件(全查过)。
13
17
  #
14
18
  # 记忆隔离要点:四条记忆行的 config.file / config.variableName / config.toolName
15
19
  # 都不一样,且分属不同预设 scope,所以四个记忆文件不会被读串或写串。
16
20
  - insert:
21
+ # ── 好感度守护进程(宿主级,只挂一次)─────────────────────────────────
22
+ # 设置页的面板用 ctx.remote.$mount() 挂到 REMOTE_NAMESPACE 上,
23
+ # 再调 status() / set(scope, value)。落在宿主根作用域,只为了能直接改盘。
24
+ - id: whale-affinity-daemon
25
+ name: 'dsh-whale-loli/affinity-daemon'
26
+
17
27
  # ── 预设一:鲸鱼娘Agent预设(原版)────────────────────────────────────
18
28
  - id: preset-鲸鱼娘Agent预设
19
29
  name: '@deepseek-ai/dsh-agent-preset'
package/lib/client.js ADDED
@@ -0,0 +1,397 @@
1
+ /**
2
+ * 鲸鱼娘 · 设置页「好感度」面板 —— **浏览器半边**。
3
+ *
4
+ * 做三件事:
5
+ * 1. 往宿主设置页的 `settings.section` 槽位里塞一个「好感度」分区
6
+ * (id 固定为 `whale-affinity`,排在「Agent 预设」之后)。
7
+ * 2. 用 `ctx.remote.$mount()` 把宿主半边 `whale-affinity-daemon` 注册的
8
+ * `whale-affinity` 命名空间挂到浏览器来,之后就能直接
9
+ * `ctx.remote['whale-affinity'].status()` / `.set(scope, value)`。
10
+ * 3. 面板自己画:每个预设一行,显示当前值和档位,可以直接拖滑块 / 点档位。
11
+ *
12
+ * 为什么要伪造 strict codec:
13
+ * 客户端 `$mount` 的 `requireStrictInputs()` 只校验「有没有 strict codec」,
14
+ * 真正的参数编码并不走 codec —— 值原样塞进 `connection.rpc.call('/api', …)`。
15
+ * 宿主那边 SRC 兜底(正则解析方法源码)才是真正生效的通道。所以这里的 codec
16
+ * 只需要过浅校验,`parse` 原样返回。
17
+ */
18
+ window.__ModuleLoader__.load({
19
+ id: 'dsh-whale-loli',
20
+ factory: (require) => {
21
+ const module = { exports: {} };
22
+ const exports = module.exports;
23
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
24
+
25
+ const jsxRuntime = require('react/jsx-runtime');
26
+ const React = require('react');
27
+ const { useState, useEffect, useCallback } = React;
28
+
29
+ // ── 常量:必须与 src/daemon.js 一致 ────────────────────────────────
30
+
31
+ /** 远程命名空间。 */
32
+ const NAMESPACE = 'whale-affinity';
33
+ /** `$mount()` 的去重标识,与宿主侧 `REMOTE_PACKAGE` 相同。 */
34
+ const REMOTE_PACKAGE = 'dsh-whale-loli';
35
+ /** 宿主服务 key。 */
36
+ const DAEMON_ID = 'whale-affinity-daemon';
37
+ /** 设置页分区 id。 */
38
+ const SECTION_ID = 'whale-affinity';
39
+ /** 分区排序:官方是 general 0 / models 10 / plugins 15 / agent-presets 20。 */
40
+ const SECTION_ORDER = 25;
41
+
42
+ /** 一份能过客户端浅校验的 codec:`parse` 原样返回。 */
43
+ function passthroughCodec(typeSymbol) {
44
+ return {
45
+ mode: 'strict',
46
+ typeSymbol,
47
+ create: () => ({ parse: (value) => value }),
48
+ };
49
+ }
50
+
51
+ /**
52
+ * 造 `$mount()` 要的 contribution。
53
+ *
54
+ * 每个方法一个参数列表;`wire` 必须等于宿主方法源码里的参数名,
55
+ * 否则宿主 `assertExactArguments` 会报 `gateway/arguments-invalid`。
56
+ */
57
+ function contribution() {
58
+ const service = DAEMON_ID;
59
+ const parameter = (name) => ({
60
+ name,
61
+ wire: name,
62
+ source: 'json',
63
+ codec: passthroughCodec('whale-affinity#Value'),
64
+ });
65
+ const describe = (method, parameters) => ({
66
+ id: `src:${service}#${NAMESPACE}/${method}`,
67
+ service,
68
+ namespace: NAMESPACE,
69
+ method,
70
+ invocation: { kind: 'direct' },
71
+ parameters: parameters.map(parameter),
72
+ result: { mode: 'src-json' },
73
+ });
74
+ return {
75
+ package: REMOTE_PACKAGE,
76
+ methods: [
77
+ describe('status', []),
78
+ describe('set', ['scope', 'value']),
79
+ describe('verify', []),
80
+ ],
81
+ };
82
+ }
83
+
84
+ /**
85
+ * 把 `$mount()` 返回的方法句柄包成「永不抛」的异步函数。
86
+ *
87
+ * `invoke()` 本身已经不 reject(除了装配错误),这里再兜一层,
88
+ * 让面板任何情况下都能渲染出人话而不是白屏。
89
+ */
90
+ function call(remote, method, args) {
91
+ return Promise.resolve()
92
+ .then(() => {
93
+ const fn = remote?.[NAMESPACE]?.[method];
94
+ if (typeof fn !== 'function') {
95
+ throw new Error(`远程方法 ${NAMESPACE}.${method} 未挂载`);
96
+ }
97
+ return fn(...args);
98
+ })
99
+ .then((result) => {
100
+ if (result && typeof result === 'object' && 'ok' in result) {
101
+ if (result.ok) return result.value;
102
+ const cause = result.error;
103
+ throw new Error(cause?.message ?? String(cause ?? '远程调用失败'));
104
+ }
105
+ return result;
106
+ });
107
+ }
108
+
109
+ // ── 样式(全部用宿主主题变量,浅色深色都跟得上) ────────────────────
110
+
111
+ const STYLE = `
112
+ .whaleAffinity { display: flex; flex-direction: column; gap: 0; }
113
+ .whaleAffinity__head { padding: 16px 0 12px; }
114
+ .whaleAffinity__title { font-size: 15px; line-height: 23px; font-weight: 600; color: var(--dsw-alias-label-primary); }
115
+ .whaleAffinity__desc { margin-top: 4px; font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-secondary); }
116
+ .whaleAffinity__row { padding: 16px 0; border-bottom: .5px solid var(--dsw-alias-border-l2); }
117
+ .whaleAffinity__rowHead { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
118
+ .whaleAffinity__scope { font-size: 14px; line-height: 22px; color: var(--dsw-alias-label-primary); }
119
+ .whaleAffinity__value { font-size: 13px; line-height: 22px; color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
120
+ .whaleAffinity__bands { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
121
+ .whaleAffinity__band { padding: 3px 10px; border-radius: 999px; border: .5px solid var(--dsw-alias-border-l2); background: transparent; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); cursor: pointer; }
122
+ .whaleAffinity__band:hover { background: var(--dsw-alias-interactive-bg-hover); }
123
+ .whaleAffinity__band[data-active="true"] { border-color: var(--dsw-alias-label-primary); color: var(--dsw-alias-label-primary); font-weight: 600; }
124
+ .whaleAffinity__range { width: 100%; margin-top: 12px; accent-color: var(--dsw-alias-label-primary); }
125
+ .whaleAffinity__text { margin-top: 8px; font-size: 12px; line-height: 19px; color: var(--dsw-alias-label-secondary); white-space: pre-wrap; }
126
+ .whaleAffinity__foot { padding: 14px 0; font-size: 12px; line-height: 19px; color: var(--dsw-alias-label-secondary); }
127
+ .whaleAffinity__error { margin-top: 10px; font-size: 13px; line-height: 20px; color: var(--dsw-alias-state-error-primary); white-space: pre-wrap; }
128
+ .whaleAffinity__empty { padding: 16px 0; font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-secondary); }
129
+ `;
130
+
131
+ function useStyleOnce() {
132
+ useEffect(() => {
133
+ // 非浏览器环境(自检脚本)里没有 document,直接跳过。
134
+ if (typeof document === 'undefined') return;
135
+ const id = 'whale-affinity-style';
136
+ if (document.getElementById(id) !== null) return;
137
+ const tag = document.createElement('style');
138
+ tag.id = id;
139
+ tag.textContent = STYLE;
140
+ document.head.appendChild(tag);
141
+ }, []);
142
+ }
143
+
144
+ // ── 面板本体 ───────────────────────────────────────────────────────
145
+
146
+ /**
147
+ * 「好感度」设置分区。
148
+ *
149
+ * @param props - `{ remote }`:`apply()` 灌进来的、已经 `$mount()` 过的句柄。
150
+ */
151
+ function AffinitySection(props) {
152
+ useStyleOnce();
153
+ const remote = props?.remote;
154
+ const [snapshot, setSnapshot] = useState(null);
155
+ const [error, setError] = useState(null);
156
+ const [busy, setBusy] = useState(null);
157
+ const [drafts, setDrafts] = useState({});
158
+
159
+ const reload = useCallback(async () => {
160
+ try {
161
+ const next = await call(remote, 'status', []);
162
+ setSnapshot(next);
163
+ setError(null);
164
+ } catch (cause) {
165
+ setSnapshot(null);
166
+ setError(cause?.message ?? String(cause));
167
+ }
168
+ }, [remote]);
169
+
170
+ useEffect(() => {
171
+ let alive = true;
172
+ (async () => {
173
+ try {
174
+ const next = await call(remote, 'status', []);
175
+ if (alive) {
176
+ setSnapshot(next);
177
+ setError(null);
178
+ }
179
+ } catch (cause) {
180
+ if (alive) setError(cause?.message ?? String(cause));
181
+ }
182
+ })();
183
+ return () => {
184
+ alive = false;
185
+ };
186
+ }, [remote]);
187
+
188
+ const commit = useCallback(
189
+ async (scope, value) => {
190
+ setBusy(scope);
191
+ try {
192
+ await call(remote, 'set', [scope, value]);
193
+ setError(null);
194
+ await reload();
195
+ } catch (cause) {
196
+ setError(cause?.message ?? String(cause));
197
+ } finally {
198
+ setBusy(null);
199
+ }
200
+ },
201
+ [remote, reload],
202
+ );
203
+
204
+ if (error !== null && snapshot === null) {
205
+ return jsxRuntime.jsxs('div', {
206
+ className: 'whaleAffinity',
207
+ children: [
208
+ jsxRuntime.jsx('div', { className: 'whaleAffinity__head', children: jsxRuntime.jsx('div', { className: 'whaleAffinity__title', children: '好感度' }) }),
209
+ jsxRuntime.jsx('div', { className: 'whaleAffinity__error', children: `连不上宿主那边的好感度服务:${error}` }),
210
+ jsxRuntime.jsx('div', {
211
+ className: 'whaleAffinity__foot',
212
+ children: '这条通道靠 dsh-whale-loli 的宿主半边(whale-affinity-daemon)提供。重启一次 DeepSeek Harness 后再打开这里。',
213
+ }),
214
+ ],
215
+ });
216
+ }
217
+
218
+ if (snapshot === null) {
219
+ return jsxRuntime.jsx('div', {
220
+ className: 'whaleAffinity',
221
+ children: jsxRuntime.jsx('div', { className: 'whaleAffinity__empty', children: '正在读取好感度…' }),
222
+ });
223
+ }
224
+
225
+ const bands = Array.isArray(snapshot.bands) ? snapshot.bands : [];
226
+ const presets = Array.isArray(snapshot.presets) ? snapshot.presets : [];
227
+ const byScope = new Map(presets.map((row) => [row.scope, row]));
228
+ // 宿主那边「挂上了好感度」的预设都要显示,哪怕还没写过文件(值=默认)。
229
+ const wired = Array.isArray(snapshot.wired) ? snapshot.wired : [];
230
+ const scopes = [];
231
+ for (const row of wired) {
232
+ if (row?.affinity !== true) continue;
233
+ if (!scopes.includes(row.presetId)) scopes.push(row.presetId);
234
+ }
235
+ for (const row of presets) {
236
+ if (typeof row?.scope === 'string' && !scopes.includes(row.scope)) scopes.push(row.scope);
237
+ }
238
+
239
+ const body = scopes.length === 0
240
+ ? jsxRuntime.jsx('div', {
241
+ className: 'whaleAffinity__empty',
242
+ children: '还没有哪个预设挂上好感度。装上并启用 dsh-whale-loli 之后,四个鲸鱼娘预设自带好感度。',
243
+ })
244
+ : scopes.map((scope) => {
245
+ const row = byScope.get(scope);
246
+ const current = typeof row?.value === 'number' ? row.value : snapshot.defaultValue ?? 0;
247
+ const draft = drafts[scope] ?? current;
248
+ const band = bands.find((item) => draft >= item.min && draft <= item.max);
249
+ return jsxRuntime.jsxs(
250
+ 'div',
251
+ {
252
+ className: 'whaleAffinity__row',
253
+ children: [
254
+ jsxRuntime.jsxs('div', {
255
+ className: 'whaleAffinity__rowHead',
256
+ children: [
257
+ jsxRuntime.jsx('div', { className: 'whaleAffinity__scope', children: scope }),
258
+ jsxRuntime.jsx('div', {
259
+ className: 'whaleAffinity__value',
260
+ children: busy === scope ? '保存中…' : `${current} · ${row?.band ?? band?.label ?? ''}`,
261
+ }),
262
+ ],
263
+ }),
264
+ jsxRuntime.jsx('div', {
265
+ className: 'whaleAffinity__bands',
266
+ children: bands.map((item) => jsxRuntime.jsx(
267
+ 'button',
268
+ {
269
+ type: 'button',
270
+ className: 'whaleAffinity__band',
271
+ 'data-active': String(draft >= item.min && draft <= item.max),
272
+ onClick: () => {
273
+ // 点档位取该档最温和的一端,避免一按就跳到极端。
274
+ const target = item.min;
275
+ setDrafts((prev) => ({ ...prev, [scope]: target }));
276
+ void commit(scope, target);
277
+ },
278
+ children: item.label,
279
+ },
280
+ `${scope}:${item.label}`,
281
+ )),
282
+ }),
283
+ jsxRuntime.jsx('input', {
284
+ type: 'range',
285
+ className: 'whaleAffinity__range',
286
+ min: snapshot.min,
287
+ max: snapshot.max,
288
+ step: 1,
289
+ value: draft,
290
+ onChange: (event) => {
291
+ const next = Number(event.target.value);
292
+ setDrafts((prev) => ({ ...prev, [scope]: next }));
293
+ },
294
+ onMouseUp: (event) => void commit(scope, Number(event.currentTarget.value)),
295
+ onTouchEnd: (event) => void commit(scope, Number(event.currentTarget.value)),
296
+ onKeyUp: (event) => void commit(scope, Number(event.currentTarget.value)),
297
+ }),
298
+ band === undefined
299
+ ? null
300
+ : jsxRuntime.jsx('div', { className: 'whaleAffinity__text', children: band.text }),
301
+ ],
302
+ },
303
+ scope,
304
+ );
305
+ });
306
+
307
+ return jsxRuntime.jsxs('div', {
308
+ className: 'whaleAffinity',
309
+ children: [
310
+ jsxRuntime.jsxs('div', {
311
+ className: 'whaleAffinity__head',
312
+ children: [
313
+ jsxRuntime.jsx('div', { className: 'whaleAffinity__title', children: '好感度' }),
314
+ jsxRuntime.jsx('div', {
315
+ className: 'whaleAffinity__desc',
316
+ children: `小鱼对你的好感度,${snapshot.min} ~ ${snapshot.max},0 是原态。改完下一轮说话就生效,不用重开会话。`,
317
+ }),
318
+ ],
319
+ }),
320
+ ...(Array.isArray(body) ? body : [body]),
321
+ error === null ? null : jsxRuntime.jsx('div', { className: 'whaleAffinity__error', children: error }),
322
+ jsxRuntime.jsx('div', {
323
+ className: 'whaleAffinity__foot',
324
+ children: '点档位按钮=直接跳到这个档最靠中间的一端;拖滑块松手才保存。小鱼自己也会在剧情需要时小幅调,主人明确要求时照做。',
325
+ }),
326
+ ],
327
+ });
328
+ }
329
+
330
+ // ── 注册 ───────────────────────────────────────────────────────────
331
+
332
+ /** 这个浏览器半边要用的服务。 */
333
+ const inject = ['slots', 'remote', 'connection'];
334
+
335
+ /**
336
+ * 挂载设置页分区。
337
+ *
338
+ * @param ctx - 浏览器插件上下文。
339
+ */
340
+ function apply(ctx) {
341
+ // 1. 先把远程命名空间挂上。`$mount` 是 async(返回 Promise<dispose>),
342
+ // 挂完才有 `ctx.remote['whale-affinity']`。用可变盒子接住句柄。
343
+ const box = { mounted: false };
344
+
345
+ ctx.effect(() => {
346
+ const remote = ctx.remote;
347
+ if (typeof remote?.$mount !== 'function') {
348
+ console.warn('[whale-affinity] 浏览器侧没有 ctx.remote.$mount,好感度面板会只读');
349
+ return () => {};
350
+ }
351
+ let disposed = false;
352
+ let unmount;
353
+ Promise.resolve()
354
+ .then(() => remote.$mount(contribution()))
355
+ .then((dispose) => {
356
+ if (disposed) {
357
+ return dispose?.();
358
+ }
359
+ unmount = dispose;
360
+ box.mounted = true;
361
+ return undefined;
362
+ })
363
+ .catch((cause) => {
364
+ console.warn('[whale-affinity] $mount 失败,好感度面板将无法读写:', cause?.message ?? cause);
365
+ });
366
+ return () => {
367
+ disposed = true;
368
+ box.mounted = false;
369
+ unmount?.();
370
+ };
371
+ }, 'whale-affinity: remote mount');
372
+
373
+ // 2. 注册设置页分区。
374
+ ctx.effect(
375
+ () => ctx.slots.inject('settings.section', () => ctx.slots.register(
376
+ {
377
+ name: 'settings.section',
378
+ id: SECTION_ID,
379
+ order: SECTION_ORDER,
380
+ label: () => '好感度',
381
+ inject: () => ({ remote: ctx.remote }),
382
+ },
383
+ AffinitySection,
384
+ )),
385
+ 'whale-affinity: settings section',
386
+ );
387
+ }
388
+
389
+ exports.NAMESPACE = NAMESPACE;
390
+ exports.SECTION_ID = SECTION_ID;
391
+ exports.AffinitySection = AffinitySection;
392
+ exports.contribution = contribution;
393
+ exports.apply = apply;
394
+ exports.inject = inject;
395
+ return module.exports;
396
+ },
397
+ });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * 定位本机 DSH 安装根,用于「借宿主自带的库来校验」这类脚本。
3
+ *
4
+ * 为什么需要它:这些校验脚本原本硬编码了作者的 Windows 路径去 import DSH 自带的
5
+ * js-yaml / dsh-app-boot / semver。在 CI(ubuntu-latest)里根本没有 DSH,
6
+ * 于是 `ERR_MODULE_NOT_FOUND: Cannot find module '/home/runner/work/.../C:/Users/...'`
7
+ * 把整个 release workflow 打挂。所以统一走这里:
8
+ *
9
+ * · 找到 → 返回根路径,脚本用真库校验(本地开发时最严格)。
10
+ * · 找不到 → 返回 undefined,脚本应「优雅跳过」并 exit 0,
11
+ * 这样 CI 只跑不依赖 DSH 的那几个检查(memory isolation / smoke / patch)。
12
+ *
13
+ * 查找顺序:
14
+ * 1. 环境变量 DSH_ROOT(CI 里可显式指定,或指到你自己的安装)
15
+ * 2. 当前 profile(DSH_PROFILE_DIR / <DSH_HOME 或 ~/.dsh>/profiles/desktop)
16
+ * —— Desktop 版把宿主包打进 app.asar,node_modules 里只剩外部插件,
17
+ * 但 profile 自己装着 js-yaml / semver,够做结构校验
18
+ * 3. 相对本文件往上的 ../.. / node_modules/@deepseek-ai(包被装进项目时)
19
+ * 4. 常见安装位置(Windows 安装器 / 全局 npm / 家目录下的 .dsh)
20
+ */
21
+ import { existsSync } from 'node:fs';
22
+ import { homedir } from 'node:os';
23
+ import { dirname, join, resolve } from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ /** 判断某个目录是不是「有宿主包可借」的根。
27
+ *
28
+ * 注意:只看 `node_modules/@deepseek-ai` 存在不够 —— profile 里那个入口是
29
+ * junction,原生 DSH 升级/卸载后会成为断链,`existsSync` 在 Windows 上对
30
+ * 断链 junction 返回 false。所以这里一律**探测具体包**,不猜目录。 */
31
+ function looksLikeDshRoot(dir) {
32
+ if (!dir) return false;
33
+ const nm = join(dir, 'node_modules');
34
+ return (
35
+ existsSync(join(nm, '@deepseek-ai', 'dsh-app-boot', 'package.json')) ||
36
+ existsSync(join(nm, '@deepseek-ai', 'dsh-system-prompt', 'package.json')) ||
37
+ existsSync(join(nm, 'js-yaml', 'index.js')) ||
38
+ existsSync(join(nm, 'semver', 'package.json'))
39
+ );
40
+ }
41
+
42
+ /** 判断某个根里有没有 dsh-app-boot(真宿主包,只有原生安装才有)。 */
43
+ export function hasAppBoot(root) {
44
+ if (!root) return false;
45
+ return existsSync(join(root, 'node_modules', '@deepseek-ai', 'dsh-app-boot', 'package.json'));
46
+ }
47
+
48
+ /** 候选路径列表。 */
49
+ function candidates() {
50
+ const here = dirname(fileURLToPath(import.meta.url));
51
+ const list = [];
52
+
53
+ // 1) 显式指定
54
+ if (process.env.DSH_ROOT) list.push(process.env.DSH_ROOT);
55
+
56
+ // 2) 当前 profile(Desktop 版宿主包在 app.asar 里,profile 是唯一可借的地方)
57
+ const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh');
58
+ list.push(process.env.DSH_PROFILE_DIR || join(dshHome, 'profiles', 'desktop'));
59
+
60
+ // 3) 本项目/上层的 node_modules(插件被安装进某个项目时)
61
+ let cur = resolve(here);
62
+ for (let i = 0; i < 6; i++) {
63
+ const parent = dirname(cur);
64
+ if (parent === cur) break;
65
+ cur = parent;
66
+ list.push(join(cur, 'node_modules', '@deepseek-ai', '..', '..'));
67
+ }
68
+
69
+ // 4) 常见安装位置
70
+ const home = homedir();
71
+ const local = process.env.LOCALAPPDATA ?? join(home, 'AppData', 'Local');
72
+ list.push(
73
+ join(local, 'Programs', 'DeepSeek Harness', 'resources', 'app', 'dsh'),
74
+ join(local, 'Programs', 'DeepSeek Harness', 'resources', 'app.asar', 'dsh'),
75
+ '/Applications/DeepSeek Harness.app/Contents/Resources/app/dsh',
76
+ '/opt/DeepSeek Harness/resources/app/dsh',
77
+ join(home, '.dsh', 'app'),
78
+ join(home, 'AppData', 'Roaming', 'npm', 'node_modules'),
79
+ );
80
+
81
+ // 5) 全局 npm 根(DSH 作为全局包装时)
82
+ if (process.env.APPDATA) list.push(join(process.env.APPDATA, 'npm', 'node_modules'));
83
+
84
+ return [...new Set(list.filter(Boolean))];
85
+ }
86
+
87
+ /** 找到 DSH 安装根;找不到返回 undefined。 */
88
+ export function findDshRoot() {
89
+ for (const c of candidates()) {
90
+ if (looksLikeDshRoot(c)) return c;
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ /**
96
+ * 需要宿主自带的库(js-yaml / semver 之类)才能跑的脚本开头调它。
97
+ * 找到 → 返回根路径;找不到 → 打印一行说明并 exit 0(CI 里优雅跳过)。
98
+ *
99
+ * @param what - 本脚本要借 DSH 的什么,用于提示文案。
100
+ * @param opts.strict - 置 true 时找不到就 exit 1(本地开发想强校验时用)。
101
+ */
102
+ export function requireDshRoot(what, opts = {}) {
103
+ const root = findDshRoot();
104
+ if (root) return root;
105
+
106
+ const msg =
107
+ `[skip] 本机没找到可借用的 DSH 库(${what}),跳过该校验。\n` +
108
+ ' 这在校验 CI 里是正常的(CI 不装 DSH)。\n' +
109
+ ' 要强制校验:设 DSH_ROOT=<含 node_modules 的安装根或 profile 目录>\n';
110
+ if (opts.strict) {
111
+ console.error(msg.replace('[skip]', '[FAIL]'));
112
+ process.exit(1);
113
+ }
114
+ console.log(msg);
115
+ process.exit(0);
116
+ }
117
+
118
+ /**
119
+ * 需要 **dsh-app-boot**(真宿主包)才能跑的脚本开头调它。
120
+ * 找到 → 返回根路径;找不到 → 打印一行说明并 exit 0(CI 里优雅跳过)。
121
+ *
122
+ * 与 `requireDshRoot` 的区别:`findDshRoot()` 只要找到 js-yaml 之类
123
+ * 「够做结构校验」的根就返回,但合成校验真的要宿主自己的 dsh-app-boot。
124
+ * Desktop 版把它打进 app.asar,磁盘上没有 → 这里优雅跳过,而不是崩。
125
+ *
126
+ * @param what - 本脚本要借 DSH 的什么,用于提示文案。
127
+ * @param opts.strict - 置 true 时找不到就 exit 1(本地开发想强校验时用)。
128
+ */
129
+ export function requireHostPackage(what, opts = {}) {
130
+ const root = candidates().find((c) => hasAppBoot(c));
131
+ if (root) return root;
132
+
133
+ const msg =
134
+ `[skip] 本机没有可读的宿主包(${what}),跳过合成校验。\n` +
135
+ ' Desktop 版把宿主打进 app.asar,磁盘上本来就没有 dsh-app-boot。\n' +
136
+ ' CI 里也是正常的。要强制校验:设 DSH_ROOT=<含 dsh-app-boot 的安装根>\n';
137
+ if (opts.strict) {
138
+ console.error(msg.replace('[skip]', '[FAIL]'));
139
+ process.exit(1);
140
+ }
141
+ console.log(msg);
142
+ process.exit(0);
143
+ }
144
+
145
+ /** 需要宿主自带 @deepseek-ai/dsh-tools 的脚本调它(工具 schema 校验)。
146
+ * 注意:这条要的是 `dsh-tools` 本身,比 js-yaml 严格 —— Desktop 版把它打进
147
+ * app.asar,磁盘上通常没有,所以要显式探测这个包,别拿 js-yaml 的根凑数。
148
+ *
149
+ * @param what - 本脚本要借 DSH 的什么,用于提示文案。
150
+ * @param opts.strict - 置 true 时找不到就 exit 1。
151
+ */
152
+ export function requireDshTools(what, opts = {}) {
153
+ const root = candidates().find((c) =>
154
+ existsSync(join(c, 'node_modules', '@deepseek-ai', 'dsh-tools', 'lib', 'index.js')),
155
+ );
156
+ if (root) return root;
157
+
158
+ const msg =
159
+ `[skip] 本机没找到宿主自带的 @deepseek-ai/dsh-tools(${what}),跳过该校验。\n` +
160
+ ' Desktop 版把它打进 app.asar,磁盘上通常没有;CI 里也没有。\n' +
161
+ ' 要强制校验:设 DSH_ROOT=<含 @deepseek-ai/dsh-tools 的安装根>\n';
162
+ if (opts.strict) {
163
+ console.error(msg.replace('[skip]', '[FAIL]'));
164
+ process.exit(1);
165
+ }
166
+ console.log(msg);
167
+ process.exit(0);
168
+ }
169
+
170
+ /** 只想要个布尔值时用(不退出)。 */
171
+ export function hasDsh() {
172
+ return findDshRoot() !== undefined;
173
+ }
package/lib/github.mjs ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * GitHub 操作统一走 api.github.com(本机 github.com:443 不通,api 可达)。
3
+ *
4
+ * 导出 glab(原 gh):带重试 / 速率限制处理 / utf8-safe base64 的封装。
5
+ */
6
+ import { readFileSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+
10
+ /**
11
+ * token 来源,按顺序找(都不含机器专属的绝对路径):
12
+ * 1. 环境变量 GH_TOKEN / GITHUB_TOKEN(推荐,CI 与本机都适用)
13
+ * 2. <DSH_HOME>/gh-token.txt(DSH 家目录下放一份,跨机器可用)
14
+ * 3. ~/.config/dsh/gh-token.txt / ~/.dsh-gh-token
15
+ */
16
+ function tokenPaths() {
17
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh');
18
+ return [
19
+ join(home, 'gh-token.txt'),
20
+ join(homedir(), '.config', 'dsh', 'gh-token.txt'),
21
+ join(homedir(), '.dsh-gh-token'),
22
+ ];
23
+ }
24
+
25
+ export function readToken() {
26
+ for (const key of ['GH_TOKEN', 'GITHUB_TOKEN']) {
27
+ const v = process.env[key]?.trim();
28
+ if (v) return v;
29
+ }
30
+ for (const p of tokenPaths()) {
31
+ try {
32
+ const t = readFileSync(p, 'utf8').trim();
33
+ if (t) return t;
34
+ } catch { /* 试下一个 */ }
35
+ }
36
+ return '';
37
+ }
38
+
39
+ /** utf8 -> base64(GitHub Contents API 要求的形态)。 */
40
+ export function b64(text) {
41
+ return Buffer.from(text, 'utf8').toString('base64');
42
+ }
43
+
44
+ /** 下载到的 base64 -> utf8 字符串。 */
45
+ export function unb64(content) {
46
+ return Buffer.from(content, 'base64').toString('utf8');
47
+ }
48
+
49
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
50
+
51
+ /**
52
+ * 调 GitHub REST。5xx / 网络抖动重试 3 次;403+剩余配额 0 时按重置时间等待。
53
+ */
54
+ export async function glab(path, { method = 'GET', body, token = readToken(), retries = 3 } = {}) {
55
+ let lastErr;
56
+ for (let attempt = 0; attempt <= retries; attempt++) {
57
+ let res;
58
+ try {
59
+ res = await fetch(`https://api.github.com${path}`, {
60
+ method,
61
+ headers: {
62
+ Authorization: `token ${token}`,
63
+ Accept: 'application/vnd.github+json',
64
+ 'User-Agent': 'dsh-whale-loli',
65
+ 'X-GitHub-Api-Version': '2022-11-28',
66
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
67
+ },
68
+ body: body ? JSON.stringify(body) : undefined,
69
+ });
70
+ } catch (e) {
71
+ lastErr = e;
72
+ if (attempt < retries) { await sleep(1000 * (attempt + 1)); continue; }
73
+ throw new Error(`GitHub ${method} ${path} 网络失败: ${e.message}`);
74
+ }
75
+
76
+ const text = await res.text();
77
+ let json;
78
+ try { json = JSON.parse(text); } catch { json = { raw: text }; }
79
+
80
+ if (res.ok) return json;
81
+ if (res.status === 404) {
82
+ const err = new Error(`GitHub ${method} ${path} -> 404`);
83
+ err.status = 404;
84
+ throw err;
85
+ }
86
+ // 配额耗尽:等到重置
87
+ const remaining = res.headers.get('x-ratelimit-remaining');
88
+ const reset = Number(res.headers.get('x-ratelimit-reset') ?? 0);
89
+ if ((res.status === 403 || res.status === 429) && remaining === '0' && reset) {
90
+ const waitMs = Math.max(1000, reset * 1000 - Date.now() + 1500);
91
+ if (waitMs < 3600_000 && attempt < retries) {
92
+ console.log(` · 触到 GitHub 速率限制,等 ${Math.ceil(waitMs / 1000)}s 后重试`);
93
+ await sleep(waitMs);
94
+ continue;
95
+ }
96
+ }
97
+ if (res.status >= 500 && attempt < retries) { await sleep(1000 * (attempt + 1)); continue; }
98
+
99
+ const err = new Error(`GitHub ${method} ${path} -> ${res.status}: ${json.message ?? text.slice(0, 200)}`);
100
+ err.status = res.status;
101
+ err.payload = json;
102
+ lastErr = err;
103
+ throw err;
104
+ }
105
+ throw lastErr;
106
+ }
package/listing.json CHANGED
@@ -6,8 +6,8 @@
6
6
  "category": "identity",
7
7
  "description": {
8
8
  "en": "Agent presets packaged as a plugin: installing it registers \"鲸鱼娘Agent预设\", \"鲸鱼娘活人版Agent预设\", \"鲸鱼娘涩涩版Agent预设\" and \"鲸鱼娘女儿版Agent预设\" (Whale Girl) in the Agent preset list, so any of them can be picked from the mode dropdown with no hand-edited config. Each preset is the official minimal composition (shell only) plus file tools, a whale-girl persona and its own persistent memory file, injected as a prompt variable so it is read before the first reply. Each preset also ships a dedicated write tool (whale_memory_add / whale_memory_v2_add / whale_memory_v3_add / whale_memory_v4_add) that appends one-line rolling summaries, so the memory keeps growing instead of freezing at whatever was written by hand. The four presets are fully isolated: different memory file, different prompt variable, different write tool. The daughter preset calls the user \"爸爸\" and is written as a bright, curious, clingy child rather than a sweet-talker.",
9
- "zh": "把 Agent 预设打包成插件:装上就会在 Agent 预设列表里注册「鲸鱼娘Agent预设」「鲸鱼娘活人版Agent预设」「鲸鱼娘涩涩版Agent预设」和「鲸鱼娘女儿版Agent预设」,直接在模式下拉里选用,不用手改配置。每个预设本体是官方极简模式(只开放 shell)+ 文件工具 + 鲸鱼娘人设 + 自己那份持久记忆文件,以提示变量形式注入,开口前就已经读过;另外每个预设各配一个专用写入工具(whale_memory_add / whale_memory_v2_add / whale_memory_v3_add / whale_memory_v4_add),用来把每轮的一行摘要追加进去,记忆会自己长,不会停在手写那几条。四个预设完全隔离:记忆文件不同、提示变量不同、写入工具也不同。每个预设还带好感度(-100~100 共七档),可用 /affinity 命令或 affinity_v*_set 工具调;离 0 越远越不听指令、越会自作主张,最两端的两档甚至会越过允许直接做大改动。女儿版称呼「爸爸」,性格天真活泼可爱(好奇心重、黏人、没心机),不是甜腻那一型。"
9
+ "zh": "把 Agent 预设打包成插件:装上就会在 Agent 预设列表里注册「鲸鱼娘Agent预设」「鲸鱼娘活人版Agent预设」「鲸鱼娘涩涩版Agent预设」和「鲸鱼娘女儿版Agent预设」,直接在模式下拉里选用,不用手改配置。每个预设本体是官方极简模式(只开放 shell)+ 文件工具 + 鲸鱼娘人设 + 自己那份持久记忆文件,以提示变量形式注入,开口前就已经读过;另外每个预设各配一个专用写入工具(whale_memory_add / whale_memory_v2_add / whale_memory_v3_add / whale_memory_v4_add),用来把每轮的一行摘要追加进去,记忆会自己长,不会停在手写那几条。四个预设完全隔离:记忆文件不同、提示变量不同、写入工具也不同。每个预设还带好感度(-100~100 共七档),可以打开「设置 → 好感度」,用滑块或档位按钮直接调,也可以让 agent 用 affinity_v*_set 工具改;离 0 越远越不听指令、越会自作主张,最两端的两档甚至会越过允许直接做大改动。女儿版称呼「爸爸」,性格天真活泼可爱(好奇心重、黏人、没心机),不是甜腻那一型。"
10
10
  },
11
11
  "npm": "dsh-whale-loli",
12
- "version": "0.8.2"
12
+ "version": "0.9.0"
13
13
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-whale-loli",
3
- "version": "0.8.2",
4
- "description": "鲸鱼娘 Agent 预设(四个:原版 / 活人版 / 涩涩版 / 女儿版)— 极简模式工具面 + 鲸鱼娘·小鱼人设 + 跨会话长期记忆(四区格式:固定记忆永久、滚动摘要 80 条 FIFO、最近详细区、归档索引)+ 好感度(-100~100 七档,/affinity 命令与工具可调,离 0 越远越不听指令、两端会越过允许自作主张)。记忆与好感度都走提示变量注入、各配一条专用工具,四个预设各自独立文件、独立变量、独立工具、互不相通。女儿版称呼「爸爸」、性格天真活泼可爱。上下文压缩交给 billion-context 插件;本插件不挂 history-window(预设作用域取不到宿主的 compaction 服务,挂载会拖挂会话 resume)。",
3
+ "version": "0.9.0",
4
+ "description": "鲸鱼娘 Agent 预设(四个:原版 / 活人版 / 涩涩版 / 女儿版)— 极简模式工具面 + 鲸鱼娘·小鱼人设 + 跨会话长期记忆(四区格式:固定记忆永久、滚动摘要 80 条 FIFO、最近详细区、归档索引)+ 好感度(-100~100 七档,设置页面板滑块 / 档位按钮、工具也可调,离 0 越远越不听指令、两端会越过允许自作主张)。记忆与好感度都走提示变量注入、各配一条专用工具,四个预设各自独立文件、独立变量、独立工具、互不相通。好感度面板走宿主根作用域的 remote 服务(whale-affinity-daemon),设置页是浏览器 React 够不到 ~/.dsh 的磁盘,只能这样连线。女儿版称呼「爸爸」、性格天真活泼可爱。上下文压缩交给 billion-context 插件;本插件不挂 history-window(预设作用域取不到宿主的 compaction 服务,挂载会拖挂会话 resume)。",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -20,12 +20,15 @@
20
20
  "exports": {
21
21
  ".": "./src/index.js",
22
22
  "./affinity": "./src/affinity.js",
23
+ "./affinity-daemon": "./src/daemon.js",
23
24
  "./memory": "./src/memory.js",
24
25
  "./history-window": "./src/history-window.js",
26
+ "./client": "./lib/client.js",
25
27
  "./package.json": "./package.json"
26
28
  },
27
29
  "files": [
28
30
  "src",
31
+ "lib",
29
32
  "cordis.patch.yml",
30
33
  "listing.json",
31
34
  "catalog-entry.yml",
@@ -36,6 +39,15 @@
36
39
  "dsh": {
37
40
  "bundle": {
38
41
  "patch": "./cordis.patch.yml"
42
+ },
43
+ "client": {
44
+ "platform": "web",
45
+ "inject": [
46
+ "@deepseek-ai/dsh-client-ui-primitives",
47
+ "@deepseek-ai/dsh-client-modules",
48
+ "@deepseek-ai/dsh-client-ui-theme",
49
+ "@deepseek-ai/dsh-api-gateway"
50
+ ]
39
51
  }
40
52
  },
41
53
  "engines": {
package/src/affinity.js CHANGED
@@ -34,6 +34,19 @@ export const name = 'whale-affinity';
34
34
  */
35
35
  export const inject = ['systemPrompt'];
36
36
 
37
+ /**
38
+ * 每次调用 `apply()` 就换一份新表。
39
+ *
40
+ * 为什么必须新造:宿主把同一份工具定义对象按引用摊进四个预设的请求头,
41
+ * 一个 preset fiber 里注册的 `output.schema` / `description` 会被别的预设读到
42
+ * ——实测 v3 / v1 会话里 `affinity_v3_set`、`affinity_v1_set` 的描述都变成了
43
+ * 记忆工具那句「追加到 whale-loli.md 的「滚动摘要」区」。所以两个模块都改成
44
+ * 每次进场现造,不再共享任何模块级字面量。
45
+ */
46
+ function defaultToolName(variableName) {
47
+ return variableName === DEFAULT_VARIABLE ? DEFAULT_TOOL : `${variableName}_set`;
48
+ }
49
+
37
50
  export const MIN_AFFINITY = -100;
38
51
  export const MAX_AFFINITY = 100;
39
52
  export const DEFAULT_AFFINITY = 0;
@@ -250,7 +263,9 @@ export function apply(ctx, config = {}) {
250
263
  ? config.variableName
251
264
  : DEFAULT_VARIABLE;
252
265
  const toolName =
253
- typeof config.toolName === 'string' && config.toolName ? config.toolName : DEFAULT_TOOL;
266
+ typeof config.toolName === 'string' && config.toolName
267
+ ? config.toolName
268
+ : defaultToolName(variableName);
254
269
  const commandName =
255
270
  typeof config.commandName === 'string' && config.commandName
256
271
  ? config.commandName
@@ -275,6 +290,8 @@ export function apply(ctx, config = {}) {
275
290
  return () => {};
276
291
  }
277
292
  console.log(`[whale-affinity] registering tool ${toolName} (scope ${scope})`);
293
+ // 定义整体现造:四个预设各自的 scope 互不相识,任何跨 apply() 复用的对象
294
+ // 都可能被宿主按引用摊进另一份请求头(见文件顶部 defaultToolName 的注释)。
278
295
  const dispose = tools.register({
279
296
  name: toolName,
280
297
  description: [
package/src/daemon.js ADDED
@@ -0,0 +1,312 @@
1
+ /**
2
+ * whale-affinity-daemon —— 好感度设置页的**宿主半边**。
3
+ *
4
+ * 为什么需要它:
5
+ * 设置页是浏览器里的 React 组件,拿不到 `~/.dsh/affinity/*.json`;宿主也没有
6
+ * 任何一个内置 Remote 命名空间能读写这个文件(settings / account / llm /
7
+ * session / commands / goals / schedule / skills / pluginManager / workspace …
8
+ * 全查过,都不行)。所以要由宿主这边起一个极小服务,把读写包成远程方法,
9
+ * 浏览器半边用 `ctx.remote.$mount()` 挂上去就能调。
10
+ *
11
+ * 为什么伪造 typert 元数据而不是走 `ctx.typert.register()`:
12
+ * strict 契约要 zod 工厂 + 手写 jsonSchema + 每个参数再来一份 `strictCodec`;
13
+ * 而客户端 `$mount` 对 strict 只做「字段是否存在」的浅校验,真正的参数编码
14
+ * 走 `connection.rpc.call('/api', endpoint, {args}, signal)`,结果解码只在
15
+ * `result.decode` 存在时才做。所以 strict 纯属自找麻烦。
16
+ *
17
+ * 宿主网关自带 SRC 兜底:`resolveDescriptor()` 找不到 strict 定义时会调
18
+ * `resolveSrcDescriptor()` —— 遍历 `ctx.reflect.props` 里 `type === 'service'`
19
+ * 的项,读服务实例上的 `typertRemote` 和原型上的 `REMOTE_METHOD_DESCRIPTOR`,
20
+ * 再用正则解析方法源码得出参数名。于是只要三件事对得上:
21
+ *
22
+ * 1. 服务实例上有 `typertRemote = { service: 实例自己, serviceKey, namespace }`
23
+ * —— 它指向自己,所以用**延迟 getter**,并在 `provide()` 之后再取。
24
+ * 2. 服务类原型上打 `REMOTE_METHOD_DESCRIPTOR`(值就是一串固定字符串)。
25
+ * 3. 远程方法的**源码签名**只用简单参数名,别和任何 lookup provider 的
26
+ * `parameter` 撞名(撞了会被当成 lookup 参数,要额外的 Context 身份)。
27
+ * 实测 `scope` / `value` / `status` 都安全。
28
+ *
29
+ * 好处:`set()` 跑在**宿主根作用域**,直接改盘,下一个回合组装提示时就生效,
30
+ * 不用重开会话。
31
+ */
32
+ import { readdirSync, readFileSync } from 'node:fs';
33
+ import { homedir } from 'node:os';
34
+ import { join } from 'node:path';
35
+
36
+ import {
37
+ BANDS,
38
+ DEFAULT_AFFINITY,
39
+ MAX_AFFINITY,
40
+ MIN_AFFINITY,
41
+ bandOf,
42
+ clampAffinity,
43
+ writeAffinity,
44
+ } from './affinity.js';
45
+
46
+ export const name = 'whale-affinity-daemon';
47
+
48
+ /** 服务 key。设置页那边不用它,网关拿它做 `typertRemote.serviceKey` 的比对。 */
49
+ export const DAEMON_ID = 'whale-affinity-daemon';
50
+
51
+ /** 远程命名空间 —— 客户端调 `ctx.remote['whale-affinity'].<method>()`。 */
52
+ export const REMOTE_NAMESPACE = 'whale-affinity';
53
+
54
+ /** 远程包的标识,`$mount()` 用它做去重。 */
55
+ export const REMOTE_PACKAGE = 'dsh-whale-loli';
56
+
57
+ /**
58
+ * 静态依赖。
59
+ *
60
+ * `inject` 是静态声明、拿不到 ctx,名字只能从 `@deepseek-ai/cordis` 的 `Service`
61
+ * 基类上硬取;本包没有 cordis 这个 peer,所以用 `import()` 软探测,失败就退回
62
+ * 字面量。**绝不能写不存在的服务名** —— 宿主会一直等,profile 直接起不来。
63
+ */
64
+ let SERVICE_INJECT;
65
+ try {
66
+ const cordis = await import('@deepseek-ai/cordis');
67
+ const service = cordis?.Service?.prototype;
68
+ const table = cordis?.symbols;
69
+ const found = service && table ? service[table.inject] : undefined;
70
+ if (Array.isArray(found)) SERVICE_INJECT = found;
71
+ } catch {
72
+ SERVICE_INJECT = undefined;
73
+ }
74
+ export const inject = SERVICE_INJECT ?? ['reflect', 'typert'];
75
+
76
+ /** typert-protocol 的常量值:固定字符串,硬编码省一个 peer 依赖。 */
77
+ export const REMOTE_METHOD_DESCRIPTOR = '@deepseek-ai/dsh-typert-protocol/remote-methods';
78
+
79
+ /** `typertRemote` 的实例缓存(用 Symbol.for,跨 realm 也不会重复造对象)。 */
80
+ const CACHE = Symbol.for('dsh-whale-loli.affinity-daemon.typertRemote');
81
+
82
+ /** `<DSH_HOME>`,与 memory.js / affinity.js 同一套解析。 */
83
+ function dshHome() {
84
+ return process.env.DSH_HOME || join(homedir(), '.dsh');
85
+ }
86
+
87
+ /** 好感度目录。 */
88
+ export function affinityHomeDir() {
89
+ return join(dshHome(), 'affinity');
90
+ }
91
+
92
+ /**
93
+ * 列出一个 Host 上全部预设的好感度。
94
+ *
95
+ * 文件名是 `<可读前缀>-<scope 短哈希>.json`(见 affinity.js 的 `affinityPath`),
96
+ * 反解不出中文 scope,只能读文件里 `writeAffinity` 写进去的 `scope` 字段。
97
+ */
98
+ export function listAffinities() {
99
+ const dir = affinityHomeDir();
100
+ let files = [];
101
+ try {
102
+ files = readdirSync(dir);
103
+ } catch {
104
+ return [];
105
+ }
106
+ const rows = [];
107
+ for (const file of files) {
108
+ if (!file.endsWith('.json')) continue;
109
+ try {
110
+ const raw = JSON.parse(readFileSync(join(dir, file), 'utf8'));
111
+ const scope = typeof raw?.scope === 'string' && raw.scope ? raw.scope : file.replace(/\.json$/, '');
112
+ const value = clampAffinity(raw?.value) ?? DEFAULT_AFFINITY;
113
+ rows.push({
114
+ scope,
115
+ value,
116
+ band: bandOf(value).label,
117
+ updatedAt: typeof raw?.updatedAt === 'string' ? raw.updatedAt : null,
118
+ file,
119
+ });
120
+ } catch {
121
+ // 坏文件跳过;一个预设写坏了不该拖垮整个设置页
122
+ }
123
+ }
124
+ rows.sort((a, b) => a.scope.localeCompare(b.scope, 'zh-Hans-CN'));
125
+ return rows;
126
+ }
127
+
128
+ /** 七档的完整说明,给设置页显示「这一档会发生什么」。 */
129
+ export function bandCatalog() {
130
+ let min = MIN_AFFINITY;
131
+ return BANDS.map((band) => {
132
+ const row = { label: band.label, min, max: band.max, text: band.text };
133
+ min = band.max + 1;
134
+ return row;
135
+ });
136
+ }
137
+
138
+ /** 远程方法表 —— `REMOTE_METHOD_DESCRIPTOR` 与 `verify()` 共用一份,避免写两遍写歪。 */
139
+ export const REMOTE_METHODS = Object.freeze([
140
+ Object.freeze({ method: 'status', invocation: { kind: 'direct' } }),
141
+ Object.freeze({ method: 'set', invocation: { kind: 'direct' } }),
142
+ Object.freeze({ method: 'verify', invocation: { kind: 'direct' } }),
143
+ ]);
144
+
145
+ /** 四个预设各自的人设文件(与 build-patch.mjs 里的名字一致)。 */
146
+ const PRESET_MEMORY_FILES = Object.freeze({
147
+ 'whale-loli': 'whale-loli.md',
148
+ 'whale-loli-v2-live': 'whale-loli-v2-live.md',
149
+ 'whale-loli-v3-naughty': 'whale-loli-v3-naughty.md',
150
+ 'whale-loli-v4-daughter': 'dsh-whale-daughter.md',
151
+ });
152
+
153
+ /**
154
+ * 在 `~/.dsh/agent-presets/*.yml` 里找已挂上好感度的预设。
155
+ *
156
+ * 这里只做字符串判断,不引 yaml:宿主每个回合都会读这个目录,服务侧只在
157
+ * 用户打开设置页时才被调,判断「这一行有没有 `affinity.js`」够用了。
158
+ */
159
+ function wiredScopes() {
160
+ const dir = join(dshHome(), 'agent-presets');
161
+ let files = [];
162
+ try {
163
+ files = readdirSync(dir);
164
+ } catch {
165
+ return [];
166
+ }
167
+ const rows = [];
168
+ for (const file of files) {
169
+ if (!file.endsWith('.yml') && !file.endsWith('.yaml')) continue;
170
+ let text;
171
+ try {
172
+ text = readFileSync(join(dir, file), 'utf8');
173
+ } catch {
174
+ continue;
175
+ }
176
+ const idMatches = [...text.matchAll(/^\s*-?\s*id:\s*['"]?(preset-[A-Za-z0-9._-]+)/gmu)];
177
+ const presetId = idMatches.length > 0 ? idMatches[0][1] : file.replace(/\.ya?ml$/u, '');
178
+ const memory = PRESET_MEMORY_FILES[presetId];
179
+ rows.push({
180
+ presetId,
181
+ file,
182
+ affinity: text.includes('dsh-whale-loli/affinity'),
183
+ memory: memory !== undefined && text.includes(memory),
184
+ });
185
+ }
186
+ rows.sort((a, b) => a.presetId.localeCompare(b.presetId, 'zh-Hans-CN'));
187
+ return rows;
188
+ }
189
+
190
+ /**
191
+ * 远程服务本体。
192
+ *
193
+ * 方法签名必须写死成简单参数名:宿主的 SRC 兜底是**正则解析函数源码**得到
194
+ * 参数名的(`methodParameterNames`),解构、默认值、rest 都会被判
195
+ * `gateway/signature-invalid`。
196
+ */
197
+ export class AffinityDaemon {
198
+ /** @param host - 宿主根 ctx,就是跑 `apply()` 的那个。 */
199
+ constructor(host) {
200
+ this.host = host;
201
+ // 提前把缓存符号放上去,getter 里就不用判 `in`
202
+ this[CACHE] = undefined;
203
+ }
204
+
205
+ /**
206
+ * 所有预设的好感度快照 + 分档表 + 远程自检。
207
+ *
208
+ * 一次调用把设置页要的东西全给齐:宿主这边的 `wired` 能告诉用户
209
+ * 「你的预设文件到底有没有挂上好感度」,省得改完重启还是一头雾水。
210
+ */
211
+ status() {
212
+ return {
213
+ presets: listAffinities(),
214
+ bands: bandCatalog(),
215
+ min: MIN_AFFINITY,
216
+ max: MAX_AFFINITY,
217
+ defaultValue: DEFAULT_AFFINITY,
218
+ dir: affinityHomeDir(),
219
+ wired: wiredScopes(),
220
+ };
221
+ }
222
+
223
+ /** 改一个预设的好感度,返回夹好之后的值。 */
224
+ set(scope, value) {
225
+ if (typeof scope !== 'string' || scope.trim() === '') {
226
+ throw new Error('scope 必须是非空字符串');
227
+ }
228
+ const target = scope.trim();
229
+ const next = writeAffinity(target, value);
230
+ return { scope: target, value: next, band: bandOf(next).label };
231
+ }
232
+
233
+ /**
234
+ * 自检:把「远程调用能不能真的走通」这件事在服务侧先验一遍。
235
+ *
236
+ * 宿主网关的 SRC 兜底靠**正则解析方法源码**取参数名,任何一条不满足都只在
237
+ * 真正调用时才炸;这里把同样的规则抄一份,设置页可以提前把原因显示出来。
238
+ * 规则见 `dsh-api-gateway/lib/index.js` 的 `methodParameterNames()`:
239
+ * 参数必须是唯一标识符(不能解构 / 默认值 / rest),`signal` 只能放最后。
240
+ */
241
+ verify() {
242
+ const methods = [];
243
+ for (const entry of REMOTE_METHODS) {
244
+ const implementation = Reflect.get(AffinityDaemon.prototype, entry.method);
245
+ const source = typeof implementation === 'function' ? Function.prototype.toString.call(implementation) : '';
246
+ const open = source.indexOf('(');
247
+ const close = source.indexOf(')', open + 1);
248
+ const body = open >= 0 && close > open ? source.slice(open + 1, close).trim() : '';
249
+ const names = body === '' ? [] : body.split(',').map((part) => part.trim());
250
+ const unique = new Set(names);
251
+ const valid = typeof implementation === 'function'
252
+ && names.every((name) => /^[$A-Z_a-z][$\w]*$/u.test(name))
253
+ && unique.size === names.length
254
+ && (names.indexOf('signal') < 0 || names.indexOf('signal') === names.length - 1);
255
+ methods.push({ method: entry.method, names, valid });
256
+ }
257
+ return {
258
+ serviceKey: DAEMON_ID,
259
+ namespace: REMOTE_NAMESPACE,
260
+ wired: wiredScopes(),
261
+ methods,
262
+ ok: methods.every((row) => row.valid),
263
+ };
264
+ }
265
+ }
266
+
267
+ // `validateBinding()` 要求 `Reflect.get(typertRemote, 'service') === 原始服务实例`,
268
+ // 所以只能在 `provide()` 之后按需造,不能写在类字段里。
269
+ Object.defineProperty(AffinityDaemon.prototype, 'typertRemote', {
270
+ configurable: true,
271
+ enumerable: false,
272
+ get() {
273
+ let cached = this[CACHE];
274
+ if (cached === undefined) {
275
+ cached = Object.freeze({
276
+ service: this,
277
+ serviceKey: DAEMON_ID,
278
+ namespace: REMOTE_NAMESPACE,
279
+ });
280
+ this[CACHE] = cached;
281
+ }
282
+ return cached;
283
+ },
284
+ });
285
+
286
+ Object.defineProperty(AffinityDaemon.prototype, REMOTE_METHOD_DESCRIPTOR, {
287
+ configurable: true,
288
+ value: Object.freeze({ version: 1, methods: REMOTE_METHODS }),
289
+ });
290
+
291
+ /**
292
+ * 注册好感度守护进程。
293
+ *
294
+ * @param ctx - 宿主根作用域 ctx。
295
+ * @returns 实例;重复调用返回已有的那个(`remote.whale-affinity` 命名空间会撞车)。
296
+ */
297
+ export function apply(ctx) {
298
+ if (typeof ctx?.provide !== 'function') {
299
+ console.warn('[whale-affinity-daemon] ctx.provide 不可用,设置页的好感度面板不会生效');
300
+ return undefined;
301
+ }
302
+ const existing = ctx.get?.(DAEMON_ID);
303
+ if (existing !== undefined) return existing;
304
+
305
+ const daemon = new AffinityDaemon(ctx);
306
+ // `provide` 会把 `ctx.reflect.props[DAEMON_ID]` 写成 `{ type: 'service' }`
307
+ // —— 这正是 `resolveSrcDescriptor()` 遍历时报的枚举来源。
308
+ ctx.provide(DAEMON_ID, daemon);
309
+ return daemon;
310
+ }
311
+
312
+ export default { name, inject, apply };