amiru 0.1.0 → 0.2.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
@@ -1,149 +1,175 @@
1
- # Amiru
2
-
3
- **Amiru** 是一个运行在 DeepSeek Harness 上的 QQ AI 陪伴运行时。角色由人设卡决定,而不是写死在代码里;它不自己连接 QQ,也不复制 QQ 插件,而是复用官方 QQ 机器人插件的会话与出站能力。
4
-
5
- 它只做四件事:
6
-
7
- 1. **收/发官方 QQ 消息**:复用 [`@tencent-connect/dsh-qqbot`](https://www.npmjs.com/package/@tencent-connect/dsh-qqbot) 的 session、preset、出站与主动投递。
8
- 2. **按用户隔离的长期记忆**:以 `account_id + chat_key` 为边界,不按昵称、不跨用户共享。
9
- 3. **可直接编辑的人设卡**:Markdown 文件放在本地,改完下一轮生效,不是黑盒。
10
- 4. **克制的主动对话**:静默时长、免打扰、每日上限、连续未回退避;触发后走一轮正常 Agent 回合。
11
-
12
- > Amiru 是“陪伴运行时”,不是“恋爱模拟器”。默认人设卡只是一个示例,你可以把它换成任何角色:朋友、搭档、树洞、虚构人物,或完全自定义的存在。
13
-
14
- ## 状态
15
-
16
- 当前是 **可运行的原型**:已在 DSH + 官方 QQ 机器人上跑通私聊收发、首轮人设注入与记忆写入。主动回合已实现,仍需要更多 live 验证。
17
-
18
- 主动消息需要 QQ 插件包含 `proactiveDelivery.externalTurns`。仓库市面上的 `dsh-qqbot` v0.5.5 旧构建没有这个能力;请使用带该能力的源码版本(见下文)。
19
-
20
- ## 架构
21
-
22
- ```text
23
- QQ 用户
24
- ↕ QQ WebSocket
25
- @tencent-connect/dsh-qqbot
26
- ↕ DSH Agent / session / outbound
27
- Amiru
28
- ├─ binding:哪个 QQ 会话用哪张人设卡
29
- ├─ prompt:静态人设、关系状态、记忆上下文
30
- ├─ memory:本地 JSON / JSONL,按 binding 隔离
31
- └─ proactive:定时评估,通过 Agent.followup() 发起正常回合
32
- ```
33
-
34
- Amiru **不新开第二条 QQ 连接**,也不绕过 QQ 插件的发送链路。
35
-
36
- ## 环境要求
37
-
38
- - DeepSeek Harness(DSH)
39
- - 已安装并启用的官方 QQ 机器人插件 `@tencent-connect/dsh-qqbot`
40
- - QQ 开放平台机器人 `appId` / `appSecret`
41
- - Node.js 20+
42
- - 可选的源码版 QQ 插件,用于主动消息投递
43
-
44
- ## 安装
45
-
46
- ### 从源码安装(当前推荐)
47
-
48
- 先安装带 `externalTurns` 的 QQ 插件源码版:
49
-
50
- ```text
51
- plugin_manager install_bundle target="/absolute/path/to/dsh-qqbot"
52
- ```
53
-
54
- 如果该源码包是 `link:` 安装,`plugin_manager` 不会替它安装依赖。先在其目录执行:
55
-
56
- ```bash
57
- pnpm install --prod --ignore-scripts
58
- ```
59
-
60
- 再安装 Amiru:
61
-
62
- ```text
63
- plugin_manager install_bundle target="/absolute/path/to/amiru"
64
- ```
65
-
66
- 安装后重启 DSH,确认插件列表出现 `Amiru`。
67
-
68
- ### 从 npm 安装(发布后)
69
-
70
- ```text
71
- plugin_manager install_bundle target="amiru@0.1.0"
72
- ```
73
-
74
- ## QQ 插件配置
75
-
76
- 在 QQ 机器人插件的配置中确认:
77
-
78
- - `appId` / `appSecret` 已填写;
79
- - 私聊访问模式允许你的账号;
80
- - `proactiveDelivery.externalTurns` 为 `c2c`(只私聊)或 `all`(私聊和群聊);
81
- - 建议给陪伴会话单独设置一个 Agent preset,避免和编码助手共用同一套工具与提示词。
82
-
83
- 如果 DSH 通过系统代理启动,而代理不可用或不代理 QQ,请在启动 DSH 前设置:
84
-
85
- ```text
86
- NO_PROXY=localhost,127.0.0.1,api.sgroup.qq.com,bots.qq.com,q.qq.com
87
- ```
88
-
89
- Windows 可以用:
90
-
91
- ```powershell
92
- setx NO_PROXY "localhost,127.0.0.1,api.sgroup.qq.com,bots.qq.com,q.qq.com"
93
- ```
94
-
95
- 然后**完全退出并重新打开 DSH**。QQ 的 token 域名是 `bots.qq.com`,OpenAPI 是 `api.sgroup.qq.com`;漏掉前者会一直报 `ECONNREFUSED 127.0.0.1:7890`。
96
-
97
- ## 人设卡
98
-
99
- 首次启动会自动生成:
100
-
101
- ```text
102
- ~/.amiru/personas/default.md
103
- ```
104
-
105
- 直接编辑这个 Markdown。标题就是段落名;`〔私聊〕` 只注入私聊,`〔群聊〕` 只注入群聊,不写标记则两边都用。
106
-
107
- ```markdown
108
- # 名字
109
- 阿梨
110
-
111
- # 年龄与身份
112
- 24 岁,在城市里做独立插画师。
113
-
114
- # 性格
115
- 温柔,有自己的边界;会吐槽,但不刻薄。
116
-
117
- # 说话方式
118
- 短句,口语化。不用客服腔,不列清单,不主动说“我可以帮你……”。
119
-
120
- # 〔私聊〕关系边界
121
- 亲密但不低俗;不查岗,不要求秒回。
122
-
123
- # 禁止
124
- 不要自称 AI 助手、心理咨询师或客服。
125
- ```
126
-
127
- 改完下一轮生效,不需要重启。也可以新增 `~/.amiru/personas/another.md`,把 binding 的 `personaId` 改成 `another`。
128
-
129
- **选卡提示**:从 SillyTavern / Chub / JanitorAI 等社区拿到的角色卡不能直接导入,需要改写成 Markdown;删掉 `{{char}}` / `{{user}}` 等模板语法,控制在 6000 字符以内。
130
-
131
- ## 记忆
132
-
133
- ```text
134
- ~/.amiru/
135
- bindings.json # 每个 QQ 私聊/群聊的 binding
136
- memories.json # 结构化长期记忆
137
- messages.jsonl # 最近对话,最多 5000 条
138
- personas/ # 人设卡
139
- ```
140
-
141
- - 以 `account_id + chat_key` 隔离,不用昵称做 key,避免同名串记忆。
142
- - 每轮确定性写入用户/助手消息;`amiru_remember` 工具让模型在明确事实、偏好、承诺、日期、边界时主动记一条。
143
- - 规则扫描会兜底记录“我叫… / 我喜欢… / 我答应… / 记住…”这类句子。
144
- - 注入预算默认 1400 字,只放关系状态、长期记忆、上一次会话的最近几条,避免和当前对话重复。
145
- - 所有数据都在本机,没有遥测,也不会上传到第三方。
146
-
1
+ # Amiru
2
+
3
+ **Amiru** 是一个运行在 DeepSeek Harness 上的 QQ AI 陪伴运行时。角色由人设卡决定,而不是写死在代码里;它不自己连接 QQ,也不复制 QQ 插件,而是复用官方 QQ 机器人插件的会话与出站能力。
4
+
5
+ 它只做四件事:
6
+
7
+ 1. **收/发官方 QQ 消息**:复用 [`@tencent-connect/dsh-qqbot`](https://www.npmjs.com/package/@tencent-connect/dsh-qqbot) 的 session、preset、出站与主动投递。
8
+ 2. **按用户隔离的长期记忆**:以 `account_id + chat_key` 为边界,不按昵称、不跨用户共享。
9
+ 3. **可直接编辑的人设卡**:Markdown 文件放在本地,改完下一轮生效,不是黑盒。
10
+ 4. **克制的主动对话**:静默时长、免打扰、每日上限、连续未回退避;触发后走一轮正常 Agent 回合。
11
+
12
+ > Amiru 是“陪伴运行时”,不是“恋爱模拟器”。默认人设卡只是一个示例,你可以把它换成任何角色:朋友、搭档、树洞、虚构人物,或完全自定义的存在。
13
+
14
+ ## 状态
15
+
16
+ 当前是 **可运行的原型**:已在 DSH + 官方 QQ 机器人上跑通私聊收发、首轮人设注入与记忆写入。主动回合已实现,仍需要更多 live 验证。
17
+
18
+ 主动消息需要 QQ 插件包含 `proactiveDelivery.externalTurns`。仓库市面上的 `dsh-qqbot` v0.5.5 旧构建没有这个能力;请使用带该能力的源码版本(见下文)。
19
+
20
+ ## 架构
21
+
22
+ ```text
23
+ QQ 用户
24
+ ↕ QQ WebSocket
25
+ @tencent-connect/dsh-qqbot
26
+ ↕ DSH Agent / session / outbound
27
+ Amiru
28
+ ├─ binding:哪个 QQ 会话用哪张人设卡
29
+ ├─ prompt:静态人设、关系状态、记忆上下文
30
+ ├─ memory:本地 JSON / JSONL,按 binding 隔离
31
+ └─ proactive:定时评估,通过 Agent.followup() 发起正常回合
32
+ ```
33
+
34
+ Amiru **不新开第二条 QQ 连接**,也不绕过 QQ 插件的发送链路。
35
+
36
+ ## 环境要求
37
+
38
+ - DeepSeek Harness(DSH)
39
+ - 已安装并启用的官方 QQ 机器人插件 `@tencent-connect/dsh-qqbot`
40
+ - QQ 开放平台机器人 `appId` / `appSecret`
41
+ - Node.js 20+
42
+ - 可选的源码版 QQ 插件,用于主动消息投递
43
+
44
+ ## 安装
45
+
46
+ ### 从源码安装(当前推荐)
47
+
48
+ 先安装带 `externalTurns` 的 QQ 插件源码版:
49
+
50
+ ```text
51
+ plugin_manager install_bundle target="/absolute/path/to/dsh-qqbot"
52
+ ```
53
+
54
+ 如果该源码包是 `link:` 安装,`plugin_manager` 不会替它安装依赖。先在其目录执行:
55
+
56
+ ```bash
57
+ pnpm install --prod --ignore-scripts
58
+ ```
59
+
60
+ 再安装 Amiru:
61
+
62
+ ```text
63
+ plugin_manager install_bundle target="/absolute/path/to/amiru"
64
+ ```
65
+
66
+ 安装后重启 DSH,确认插件列表出现 `Amiru`。
67
+
68
+ ### 从 npm 安装(发布后)
69
+
70
+ ```text
71
+ plugin_manager install_bundle target="amiru@0.1.0"
72
+ ```
73
+
74
+ ## QQ 插件配置
75
+
76
+ 在 QQ 机器人插件的配置中确认:
77
+
78
+ - `appId` / `appSecret` 已填写;
79
+ - 私聊访问模式允许你的账号;
80
+ - `proactiveDelivery.externalTurns` 为 `c2c`(只私聊)或 `all`(私聊和群聊);
81
+ - 建议给陪伴会话单独设置一个 Agent preset,避免和编码助手共用同一套工具与提示词。
82
+
83
+ 如果 DSH 通过系统代理启动,而代理不可用或不代理 QQ,请在启动 DSH 前设置:
84
+
85
+ ```text
86
+ NO_PROXY=localhost,127.0.0.1,api.sgroup.qq.com,bots.qq.com,q.qq.com
87
+ ```
88
+
89
+ Windows 可以用:
90
+
91
+ ```powershell
92
+ setx NO_PROXY "localhost,127.0.0.1,api.sgroup.qq.com,bots.qq.com,q.qq.com"
93
+ ```
94
+
95
+ 然后**完全退出并重新打开 DSH**。QQ 的 token 域名是 `bots.qq.com`,OpenAPI 是 `api.sgroup.qq.com`;漏掉前者会一直报 `ECONNREFUSED 127.0.0.1:7890`。
96
+
97
+ ## 人设卡
98
+
99
+ 卡就是一份 Markdown 文件,默认放在:
100
+
101
+ ```text
102
+ ~/.amiru/personas/default.md
103
+ ```
104
+
105
+ `~/.amiru` 是插件的 `dataDir`。**插件不会替你生成这张卡**——没有卡时,它会告诉 agent「还没有人设卡」,由 agent 和你商量怎么建。
106
+
107
+ 想直接看效果,可以用自带的示例卡(文件不存在时才写入,不会覆盖你已有的卡):
108
+
109
+ ```text
110
+ ~/.amiru/personas/samples/ali.md
111
+ ```
112
+
113
+ 把它复制成 `default.md` 就是一张能用的卡。
114
+
115
+ ### 卡长什么样
116
+
117
+ `#` 标题就是段落名;标题里可以带 `〔私聊〕` 或 `〔群聊〕`,只在那类会话注入,不写标记则两边都用。
118
+
119
+ ```markdown
120
+ # 名字
121
+ 阿梨
122
+
123
+ # 年龄与身份
124
+ 24 岁,在城市里做独立插画师。
125
+
126
+ # 性格
127
+ 温柔,有自己的边界;会吐槽,但不刻薄。
128
+
129
+ # 说话方式
130
+ 短句,口语化。不用客服腔,不列清单,不主动说“我可以帮你……”。
131
+
132
+ # 〔私聊〕关系边界
133
+ 亲密但不低俗;不查岗,不要求秒回。
134
+
135
+ # 禁止
136
+ 不要自称 AI 助手、心理咨询师或客服。
137
+ ```
138
+
139
+ 注入上限默认 6000 字(`maxPersonaChars`)。超过上限的部分**不会**进入上下文——保存时 agent 会看到实际字数,剩下的内容仍在文件里,不会丢。
140
+
141
+ ### 怎么写
142
+
143
+ - **想自己写**:用 SillyTavern / 酒馆这类写卡工具写好,把卡(内容或文件)交给 agent,它会改写成上面的格式再保存。
144
+ - **不想写**:直接跟 agent 说想要什么样的角色,聊几轮让它帮你写一张。
145
+ - **拿现成的卡**:社区卡(SillyTavern / Chub / JanitorAI 等)不能直接导入,需要改写成 Markdown、去掉 `{{char}}` / `{{user}}` 之类的模板语法。
146
+
147
+ agent 通过 `amiru_persona_save` 工具把卡存进 `~/.amiru/personas/`。
148
+
149
+ ### 什么时候生效
150
+
151
+ 卡是每轮实时读取的,改文件下一轮就生效,不用重启。
152
+
153
+ 但**旧对话仍然在上下文里**,带着旧人设说过的话。所以换卡之后请开一个新会话:QQ 里发 `/new`,桌面端点侧边栏的「新会话」。
154
+
155
+ 也可以新增 `~/.amiru/personas/another.md`,把 binding 的 `personaId` 改成 `another`。
156
+
157
+ ## 记忆
158
+
159
+ ```text
160
+ ~/.amiru/
161
+ bindings.json # 每个 QQ 私聊/群聊的 binding
162
+ memories.json # 结构化长期记忆
163
+ messages.jsonl # 最近对话,最多 5000 条
164
+ personas/ # 人设卡
165
+ ```
166
+
167
+ - 以 `account_id + chat_key` 隔离,不用昵称做 key,避免同名串记忆。
168
+ - 每轮确定性写入用户/助手消息;`amiru_remember` 工具让模型在明确事实、偏好、承诺、日期、边界时主动记一条。
169
+ - 规则扫描会兜底记录“我叫… / 我喜欢… / 我答应… / 记住…”这类句子。
170
+ - 注入预算默认 1400 字,只放关系状态、长期记忆、上一次会话的最近几条,避免和当前对话重复。
171
+ - 所有数据都在本机,没有遥测,也不会上传到第三方。
172
+
147
173
  ### 从旧版迁移
148
174
 
149
175
  0.1.0 之前这个项目先后叫过 **AIGirl** 和 **Ami**,两个旧数据目录都可以直接搬过来:
@@ -156,64 +182,64 @@ cp -a ~/.aigirl ~/.amiru # 或从更早的 AIGirl 版本升级
156
182
  然后重新安装 Amiru,并把 binding 的 `personaId` 指向 `~/.amiru/personas/` 下的文件。
157
183
 
158
184
 
159
- ## 主动对话
160
-
161
- 默认策略:
162
-
163
- | 项 | 默认 |
164
- |---|---|
165
- | 静默多久才考虑 | 120 分钟 |
166
- | 两次主动最小间隔 | 120 分钟 |
167
- | 每天上限 | 2 条 |
168
- | 至少聊过 | 3 条用户消息 |
169
- | 免打扰 | 23:30–08:00 |
170
- | 连续未回 | 2 次后 48 小时不再发 |
171
-
172
- 触发优先级:连续未回后的轻接话 → 长时间沉默 → 日常想起。提示词要求给出具体由头,并直接输出一句自然、简短、可以立刻发出去的话。
173
-
174
- > **为什么没有 `<SKIP>`**:`agent.followup()` 产生的 assistant 文本会由 QQ 插件直接出站,出站层无法可靠拦截占位符。因此主动回合在硬门通过后才生成,提示词不再提供“跳过”选项,避免把 `<SKIP>` 原样发给用户。
175
-
176
- ## 配置
177
-
178
- `cordis.patch.yml` 里的 `config` 可改:
179
-
180
- ```yaml
181
- enabled: true
182
- defaultPersona: default
183
- dataDir: ~/.amiru
184
- proactive:
185
- enabled: true
186
- idleMinutes: 120
187
- minIntervalMinutes: 120
188
- dailyCap: 2
189
- minUserMessages: 3
190
- quietStart: '23:30'
191
- quietEnd: '08:00'
192
- maxUnanswered: 2
193
- backoffHours: 48
194
- ```
195
-
196
- `qqPrefsPath` 默认读 `~/.dsh-qqbot/model-prefs.json`,也可用环境变量 `QQBOT_DATA_DIR`。
197
-
198
- ## 开发
199
-
200
- ```bash
201
- node --test test/*.test.js
202
- npm run check
203
- ```
204
-
205
- 代码结构:
206
-
207
- - `index.js`:事件接线、QQ session 识别、主动 tick。
208
- - `src/persona.js`:Markdown 人设卡解析与 scope 过滤。
209
- - `src/qq.js`:`qqbot:<appId>:<scope>:<peerId>` 解析与 prefs 反查。
210
- - `src/store.js`:binding / message / memory 持久化。
211
- - `src/memory.js`:关系摘要、记忆注入、规则兜底。
212
- - `src/proactive.js`:硬门、评分、主动提示词。
213
- - `src/prompt.js`:`amiru:*` system prompt 段与 `amiru_remember` 工具。
214
-
215
- 详细设计见 [`docs/plugin-design.md`](docs/plugin-design.md) 与 [`docs/runtime-spec.md`](docs/runtime-spec.md)。
216
-
217
- ## 许可
218
-
219
- MIT
185
+ ## 主动对话
186
+
187
+ 默认策略:
188
+
189
+ | 项 | 默认 |
190
+ |---|---|
191
+ | 静默多久才考虑 | 120 分钟 |
192
+ | 两次主动最小间隔 | 120 分钟 |
193
+ | 每天上限 | 2 条 |
194
+ | 至少聊过 | 3 条用户消息 |
195
+ | 免打扰 | 23:30–08:00 |
196
+ | 连续未回 | 2 次后 48 小时不再发 |
197
+
198
+ 触发优先级:连续未回后的轻接话 → 长时间沉默 → 日常想起。提示词要求给出具体由头,并直接输出一句自然、简短、可以立刻发出去的话。
199
+
200
+ > **为什么没有 `<SKIP>`**:`agent.followup()` 产生的 assistant 文本会由 QQ 插件直接出站,出站层无法可靠拦截占位符。因此主动回合在硬门通过后才生成,提示词不再提供“跳过”选项,避免把 `<SKIP>` 原样发给用户。
201
+
202
+ ## 配置
203
+
204
+ `cordis.patch.yml` 里的 `config` 可改:
205
+
206
+ ```yaml
207
+ enabled: true
208
+ defaultPersona: default
209
+ dataDir: ~/.amiru
210
+ proactive:
211
+ enabled: true
212
+ idleMinutes: 120
213
+ minIntervalMinutes: 120
214
+ dailyCap: 2
215
+ minUserMessages: 3
216
+ quietStart: '23:30'
217
+ quietEnd: '08:00'
218
+ maxUnanswered: 2
219
+ backoffHours: 48
220
+ ```
221
+
222
+ `qqPrefsPath` 默认读 `~/.dsh-qqbot/model-prefs.json`,也可用环境变量 `QQBOT_DATA_DIR`。
223
+
224
+ ## 开发
225
+
226
+ ```bash
227
+ node --test test/*.test.js
228
+ npm run check
229
+ ```
230
+
231
+ 代码结构:
232
+
233
+ - `index.js`:事件接线、QQ session 识别、主动 tick。
234
+ - `src/persona.js`:Markdown 人设卡解析与 scope 过滤。
235
+ - `src/qq.js`:`qqbot:<appId>:<scope>:<peerId>` 解析与 prefs 反查。
236
+ - `src/store.js`:binding / message / memory 持久化。
237
+ - `src/memory.js`:关系摘要、记忆注入、规则兜底。
238
+ - `src/proactive.js`:硬门、评分、主动提示词。
239
+ - `src/prompt.js`:`amiru:*` system prompt 段与 `amiru_remember` 工具。
240
+
241
+ 详细设计见 [`docs/plugin-design.md`](docs/plugin-design.md) 与 [`docs/runtime-spec.md`](docs/runtime-spec.md)。
242
+
243
+ ## 许可
244
+
245
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amiru",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Persona-driven AI companion runtime for the official QQ bot plugin in DeepSeek Harness",
package/src/persona.js CHANGED
@@ -95,7 +95,7 @@ export class PersonaStore {
95
95
  this.defaultPersona = safeId(defaultPersona) || 'default'
96
96
  this.maxChars = maxChars
97
97
  mkdirSync(this.dir, { recursive: true })
98
- this.ensureDefault()
98
+ this.ensureSamples()
99
99
  }
100
100
 
101
101
  pathFor(id) {
@@ -103,10 +103,16 @@ export class PersonaStore {
103
103
  return safe ? join(this.dir, `${safe}.md`) : null
104
104
  }
105
105
 
106
- ensureDefault() {
107
- const path = this.pathFor(this.defaultPersona)
108
- if (!path) return
109
- if (!existsSync(path)) this.save(this.defaultPersona, PERSONA_TEMPLATE)
106
+ ensureSamples() {
107
+ const dir = join(this.dir, 'samples')
108
+ const path = join(dir, 'ali.md')
109
+ mkdirSync(dir, { recursive: true })
110
+ if (!existsSync(path)) writeFileSync(path, PERSONA_TEMPLATE, 'utf8')
111
+ }
112
+
113
+ exists(id) {
114
+ const path = this.pathFor(id)
115
+ return Boolean(path) && existsSync(path)
110
116
  }
111
117
 
112
118
  list() {
@@ -139,7 +145,7 @@ export class PersonaStore {
139
145
  if (!path) throw new Error(`invalid persona id: ${id}`)
140
146
  mkdirSync(this.dir, { recursive: true })
141
147
  const tmp = `${path}.${process.pid}.tmp`
142
- writeFileSync(tmp, String(content ?? '').slice(0, this.maxChars), 'utf8')
148
+ writeFileSync(tmp, String(content ?? ''), 'utf8')
143
149
  renameSync(tmp, path)
144
150
  return path
145
151
  }
package/src/prompt.js CHANGED
@@ -2,6 +2,10 @@ import { buildMemoryContext } from './memory.js'
2
2
 
3
3
  const attached = new WeakSet()
4
4
 
5
+ const PERSONA_MISSING_TEXT = `当前还没有人设卡,不要自行扮演某个固定角色。
6
+ 可以请用户选一条路:① 直接用示例卡(personas/samples/ali.md,复制成卡即可);② 把自己用写卡工具做好的卡发过来,改写成上面的格式再存;③ 现在简单聊几句,帮他写一张。
7
+ 写好用 amiru_persona_save 保存。保存后提醒开新会话(QQ 用 /new,桌面点侧边栏「新会话」)。`
8
+
5
9
  function getService(agentCtx, name) {
6
10
  if (agentCtx && typeof agentCtx.get === 'function') {
7
11
  const service = agentCtx.get(name)
@@ -22,6 +26,13 @@ export function attachPrompt(agentCtx, { binding, resolveBinding, store, persona
22
26
  // Resolve at render time so the first turn still gets the persona.
23
27
  const currentBinding = typeof resolveBinding === 'function' ? resolveBinding : () => binding
24
28
 
29
+ const personaId = () => currentBinding()?.personaId ?? config.defaultPersona ?? 'default'
30
+ const contractLine = () => {
31
+ const id = personaStore.resolveId?.(personaId()) ?? personaId()
32
+ const path = personaStore.pathFor?.(id) ?? ''
33
+ return `卡文件:${path}|# 分段,标题可带〔私聊〕〔群聊〕|≤${config.maxPersonaChars} 字|保存用 amiru_persona_save`
34
+ }
35
+
25
36
  systemPrompt.section({
26
37
  name: 'amiru:rules',
27
38
  order: 430,
@@ -33,7 +44,13 @@ export function attachPrompt(agentCtx, { binding, resolveBinding, store, persona
33
44
  order: 441,
34
45
  text: () => {
35
46
  const current = currentBinding()
36
- return current ? personaStore.get(current.personaId, current.scope || 'c2c') : ''
47
+ const id = personaId()
48
+ if (!current) return contractLine()
49
+ const exists = personaStore.exists?.(id) ?? false
50
+ const body = personaStore.get(id, current.scope || 'c2c')
51
+ if (!exists) return `${contractLine()}\n\n${PERSONA_MISSING_TEXT}`
52
+ if (!body) return `${contractLine()}\n\n(这张卡没有适用于当前场景的段落)`
53
+ return `${contractLine()}\n\n${body}`
37
54
  },
38
55
  })
39
56
 
@@ -87,6 +104,58 @@ export function attachPrompt(agentCtx, { binding, resolveBinding, store, persona
87
104
  return item ? { ok: true, saved: item.text } : { ok: false }
88
105
  },
89
106
  })
107
+
108
+ tools.register({
109
+ name: 'amiru_persona_save',
110
+ description: '把人设卡 markdown 存进插件的人设目录。用户给出一张卡(自己写的或用写卡工具导出的)后,把它改写成我们的格式再调用本工具保存。',
111
+ parameters: {
112
+ type: 'object',
113
+ properties: {
114
+ markdown: { type: 'string', description: '完整的人设卡 markdown。' },
115
+ id: { type: 'string', description: '卡的文件名(不含扩展名),默认存成当前使用的卡。' },
116
+ },
117
+ required: ['markdown'],
118
+ },
119
+ output: {
120
+ schema: {
121
+ type: 'object',
122
+ properties: {
123
+ ok: { type: 'boolean' },
124
+ id: { type: 'string' },
125
+ path: { type: 'string' },
126
+ chars: { type: 'number' },
127
+ limit: { type: 'number' },
128
+ truncated: { type: 'boolean' },
129
+ active: { type: 'boolean' },
130
+ reason: { type: 'string' },
131
+ },
132
+ required: ['ok'],
133
+ },
134
+ render(args, value) {
135
+ if (!value?.ok) return '没有保存。'
136
+ const head = value.truncated
137
+ ? `已保存到 ${value.path}(${value.chars} 字,超过 ${value.limit} 字注入上限,超出部分不会进入上下文)。`
138
+ : `已保存到 ${value.path}(${value.chars} 字)。`
139
+ return `${head}要让这张卡生效,请开新会话:QQ 用 /new,桌面点侧边栏「新会话」。`
140
+ },
141
+ },
142
+ execute(args) {
143
+ const text = String(args?.markdown ?? '').trim()
144
+ if (!text) return { ok: false, reason: 'empty' }
145
+ const target = args?.id === undefined ? personaId() : (personaStore.resolveId?.(args.id) ?? args.id)
146
+ if (!personaStore.pathFor?.(target)) return { ok: false, reason: 'invalid-id' }
147
+ const path = personaStore.save(target, text)
148
+ return {
149
+ ok: true,
150
+ id: target,
151
+ path,
152
+ chars: text.length,
153
+ limit: config.maxPersonaChars,
154
+ truncated: text.length > config.maxPersonaChars,
155
+ active: Boolean(currentBinding()) && target === personaId(),
156
+ }
157
+ },
158
+ })
90
159
  }
91
160
 
92
161
  attached.add(agentCtx)