amiru 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +218 -2
- package/cordis.patch.yml +16 -0
- package/icon.svg +13 -0
- package/index.js +191 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +53 -4
- package/src/config.js +64 -0
- package/src/memory.js +122 -0
- package/src/persona.js +155 -0
- package/src/proactive.js +101 -0
- package/src/prompt.js +94 -0
- package/src/qq.js +87 -0
- package/src/session-events.js +35 -0
- package/src/store.js +251 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 huabuyu233
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,219 @@
|
|
|
1
|
-
#
|
|
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
|
+
|
|
147
|
+
### 从旧版迁移
|
|
2
148
|
|
|
3
|
-
|
|
149
|
+
0.1.0 之前这个项目先后叫过 **AIGirl** 和 **Ami**,两个旧数据目录都可以直接搬过来:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
cp -a ~/.ami ~/.amiru # 从 Ami 版本升级
|
|
153
|
+
cp -a ~/.aigirl ~/.amiru # 或从更早的 AIGirl 版本升级
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
然后重新安装 Amiru,并把 binding 的 `personaId` 指向 `~/.amiru/personas/` 下的文件。
|
|
157
|
+
|
|
158
|
+
|
|
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
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
- insert:
|
|
2
|
+
- id: amiru
|
|
3
|
+
name: 'amiru'
|
|
4
|
+
config:
|
|
5
|
+
enabled: true
|
|
6
|
+
defaultPersona: default
|
|
7
|
+
proactive:
|
|
8
|
+
enabled: true
|
|
9
|
+
idleMinutes: 120
|
|
10
|
+
minIntervalMinutes: 120
|
|
11
|
+
dailyCap: 2
|
|
12
|
+
minUserMessages: 3
|
|
13
|
+
quietStart: '23:30'
|
|
14
|
+
quietEnd: '08:00'
|
|
15
|
+
maxUnanswered: 2
|
|
16
|
+
backoffHours: 48
|
package/icon.svg
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="AI companion">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#ff8fb1"/>
|
|
5
|
+
<stop offset="1" stop-color="#8f7bff"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect width="64" height="64" rx="16" fill="url(#g)"/>
|
|
9
|
+
<path d="M20 24c0-6 5-10 12-10s12 4 12 10v9c0 7-5 12-12 12s-12-5-12-12z" fill="#fff" opacity=".94"/>
|
|
10
|
+
<circle cx="27" cy="29" r="2" fill="#5b4b8a"/>
|
|
11
|
+
<circle cx="37" cy="29" r="2" fill="#5b4b8a"/>
|
|
12
|
+
<path d="M27 37c3 2 7 2 10 0" fill="none" stroke="#5b4b8a" stroke-width="2" stroke-linecap="round"/>
|
|
13
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { normalizeConfig } from './src/config.js'
|
|
2
|
+
import { AmiruStore } from './src/store.js'
|
|
3
|
+
import { PersonaStore } from './src/persona.js'
|
|
4
|
+
import { QQSessionIndex } from './src/qq.js'
|
|
5
|
+
import { buildRecallHint, maybeAutoRemember } from './src/memory.js'
|
|
6
|
+
import { buildProactivePrompt, dayKey, evaluateProactive } from './src/proactive.js'
|
|
7
|
+
import { attachPrompt } from './src/prompt.js'
|
|
8
|
+
import { eventIdentity, extractEventText, isProactiveUserMessage, isUserMessageEvent } from './src/session-events.js'
|
|
9
|
+
|
|
10
|
+
export const name = 'amiru'
|
|
11
|
+
export const inject = ['agents']
|
|
12
|
+
|
|
13
|
+
function safeLog(ctx, level, message) {
|
|
14
|
+
try {
|
|
15
|
+
ctx?.logger?.[level]?.(`[amiru] ${message}`)
|
|
16
|
+
} catch {
|
|
17
|
+
// Logging must never break a turn.
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function apply(ctx, rawConfig = {}) {
|
|
22
|
+
const config = normalizeConfig(rawConfig)
|
|
23
|
+
if (!config.enabled) return
|
|
24
|
+
|
|
25
|
+
const store = new AmiruStore(config.dataDir)
|
|
26
|
+
const personas = new PersonaStore(config.personaDir, {
|
|
27
|
+
defaultPersona: config.defaultPersona,
|
|
28
|
+
maxChars: config.maxPersonaChars,
|
|
29
|
+
})
|
|
30
|
+
const qq = new QQSessionIndex(config.qqPrefsPath)
|
|
31
|
+
const retryTimers = new Set()
|
|
32
|
+
const inFlightProactive = new Set()
|
|
33
|
+
const seenEvents = new Set()
|
|
34
|
+
|
|
35
|
+
function rememberSeen(key) {
|
|
36
|
+
seenEvents.add(key)
|
|
37
|
+
if (seenEvents.size > 4000) {
|
|
38
|
+
const first = seenEvents.values().next().value
|
|
39
|
+
seenEvents.delete(first)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function ensureBinding(sessionId) {
|
|
44
|
+
const info = qq.resolve(sessionId)
|
|
45
|
+
if (!info) return null
|
|
46
|
+
return store.ensureBinding({
|
|
47
|
+
accountId: info.accountId,
|
|
48
|
+
chatKey: info.chatKey,
|
|
49
|
+
scope: info.scope,
|
|
50
|
+
peerId: info.peerId,
|
|
51
|
+
sessionId,
|
|
52
|
+
appId: info.appId,
|
|
53
|
+
personaId: config.defaultPersona,
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function attachFor(sessionId, agent) {
|
|
58
|
+
const live = agent ?? ctx.agents.get(sessionId)
|
|
59
|
+
const agentCtx = live?.ctx
|
|
60
|
+
if (!agentCtx) return false
|
|
61
|
+
const ok = attachPrompt(agentCtx, {
|
|
62
|
+
resolveBinding: () => ensureBinding(sessionId),
|
|
63
|
+
store,
|
|
64
|
+
personaStore: personas,
|
|
65
|
+
config,
|
|
66
|
+
})
|
|
67
|
+
if (ok) safeLog(ctx, 'info', `attached session=${sessionId}`)
|
|
68
|
+
return ok
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function attachWithRetry(sessionId, agent) {
|
|
72
|
+
if (attachFor(sessionId, agent)) return
|
|
73
|
+
// The QQ plugin persists its sessionId after agents.create/resume returns.
|
|
74
|
+
// A short retry closes that ordering gap without blocking agent creation.
|
|
75
|
+
for (const delay of [0, 25, 100, 300, 1000]) {
|
|
76
|
+
if (delay === 0) continue
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
retryTimers.delete(timer)
|
|
79
|
+
if (attachFor(sessionId, agent)) {
|
|
80
|
+
for (const other of retryTimers) clearTimeout(other)
|
|
81
|
+
retryTimers.clear()
|
|
82
|
+
}
|
|
83
|
+
}, delay)
|
|
84
|
+
timer.unref?.()
|
|
85
|
+
retryTimers.add(timer)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
ctx.on('agent/created', ({ agent } = {}) => {
|
|
90
|
+
const sessionId = agent?.id
|
|
91
|
+
if (!sessionId) return
|
|
92
|
+
attachWithRetry(sessionId, agent)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
ctx.on('session/event', (session, event) => {
|
|
96
|
+
const sessionId = session?.id
|
|
97
|
+
if (!sessionId || !event?.type) return
|
|
98
|
+
const eventKey = eventIdentity(sessionId, event)
|
|
99
|
+
if (seenEvents.has(eventKey)) return
|
|
100
|
+
rememberSeen(eventKey)
|
|
101
|
+
|
|
102
|
+
const binding = ensureBinding(sessionId)
|
|
103
|
+
if (!binding) return
|
|
104
|
+
|
|
105
|
+
if (event.type === 'user/message') {
|
|
106
|
+
if (!isUserMessageEvent(event)) return
|
|
107
|
+
const text = extractEventText(event)
|
|
108
|
+
if (!text || isProactiveUserMessage(event)) return
|
|
109
|
+
store.appendMessage({ bindingId: binding.bindingId, sessionId, role: 'user', message: event.data, time: event.time })
|
|
110
|
+
store.recordUserMessage(binding.bindingId, event.time ?? Date.now())
|
|
111
|
+
maybeAutoRemember(store, binding, text)
|
|
112
|
+
attachFor(sessionId)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (event.type === 'assistant/message') {
|
|
117
|
+
const message = event.data?.message
|
|
118
|
+
const text = extractEventText(event)
|
|
119
|
+
if (!text) return
|
|
120
|
+
store.appendMessage({ bindingId: binding.bindingId, sessionId, role: 'assistant', message, time: event.time })
|
|
121
|
+
store.recordAssistantMessage(binding.bindingId, event.time ?? Date.now())
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
async function runProactiveFor(binding) {
|
|
126
|
+
if (inFlightProactive.has(binding.bindingId)) return
|
|
127
|
+
const decision = evaluateProactive({ binding, config })
|
|
128
|
+
if (!decision.allowed) return
|
|
129
|
+
inFlightProactive.add(binding.bindingId)
|
|
130
|
+
try {
|
|
131
|
+
let agent = ctx.agents.get(binding.sessionId)
|
|
132
|
+
if (!agent) {
|
|
133
|
+
try {
|
|
134
|
+
const handle = await ctx.agents.resume({
|
|
135
|
+
resumeSessionId: binding.sessionId,
|
|
136
|
+
setup: (agentCtx) => {
|
|
137
|
+
attachPrompt(agentCtx, {
|
|
138
|
+
resolveBinding: () => store.getBinding(binding.bindingId) || binding,
|
|
139
|
+
store,
|
|
140
|
+
personaStore: personas,
|
|
141
|
+
config,
|
|
142
|
+
})
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
agent = handle.agent
|
|
146
|
+
} catch (error) {
|
|
147
|
+
safeLog(ctx, 'warn', `resume failed session=${binding.sessionId}: ${error?.message ?? error}`)
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const recallHint = buildRecallHint({ store, binding, config })
|
|
153
|
+
const prompt = buildProactivePrompt({
|
|
154
|
+
binding,
|
|
155
|
+
recallHint,
|
|
156
|
+
trigger: decision.trigger,
|
|
157
|
+
})
|
|
158
|
+
await agent.followup({
|
|
159
|
+
content: [{ type: 'text', text: prompt }],
|
|
160
|
+
source: { kind: 'user' },
|
|
161
|
+
})
|
|
162
|
+
store.recordProactive(binding.bindingId, dayKey(), Date.now())
|
|
163
|
+
safeLog(ctx, 'info', `proactive sent session=${binding.sessionId} trigger=${decision.trigger}`)
|
|
164
|
+
} catch (error) {
|
|
165
|
+
safeLog(ctx, 'warn', `proactive failed: ${error?.message ?? error}`)
|
|
166
|
+
} finally {
|
|
167
|
+
inFlightProactive.delete(binding.bindingId)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function proactiveTick() {
|
|
172
|
+
if (!config.proactive.enabled) return
|
|
173
|
+
for (const binding of store.listBindings()) {
|
|
174
|
+
await runProactiveFor(binding)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
ctx.effect(() => {
|
|
179
|
+
const timer = setInterval(() => {
|
|
180
|
+
proactiveTick().catch((error) => safeLog(ctx, 'warn', `tick failed: ${error?.message ?? error}`))
|
|
181
|
+
}, config.proactive.tickSeconds * 1000)
|
|
182
|
+
timer.unref?.()
|
|
183
|
+
return () => {
|
|
184
|
+
clearInterval(timer)
|
|
185
|
+
for (const pending of retryTimers) clearTimeout(pending)
|
|
186
|
+
retryTimers.clear()
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
safeLog(ctx, 'info', `ready data=${config.dataDir} personas=${config.personaDir}`)
|
|
191
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amiru",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Persona-driven AI companion runtime for the official QQ bot plugin in DeepSeek Harness",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "huabuyu233",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/huabuyu233/amiru.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/huabuyu233/amiru#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/huabuyu233/amiru/issues"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./index.js",
|
|
22
|
+
"./locale/*.json": "./locale/*.json",
|
|
23
|
+
"./icon": "./icon.svg"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"index.js",
|
|
27
|
+
"src/*.js",
|
|
28
|
+
"locale/*.json",
|
|
29
|
+
"icon.svg",
|
|
30
|
+
"cordis.patch.yml",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"dsh": {
|
|
35
|
+
"bundle": {
|
|
36
|
+
"patch": "./cordis.patch.yml"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "node --test test/*.test.js",
|
|
41
|
+
"check": "node --check index.js && node --check src/config.js && node --check src/store.js && node --check src/persona.js && node --check src/memory.js && node --check src/proactive.js && node --check src/qq.js && node --check src/prompt.js && node --check src/session-events.js"
|
|
42
|
+
},
|
|
43
|
+
"keywords": [
|
|
44
|
+
"dsh",
|
|
45
|
+
"dsh-plugin",
|
|
46
|
+
"qqbot",
|
|
47
|
+
"ai-companion",
|
|
48
|
+
"persona",
|
|
49
|
+
"memory",
|
|
50
|
+
"proactive"
|
|
51
|
+
],
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=20"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
function pick(value, fallback) {
|
|
5
|
+
return value === undefined || value === null || value === '' ? fallback : value
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function positive(value, fallback) {
|
|
9
|
+
const n = Number(value)
|
|
10
|
+
return Number.isFinite(n) && n > 0 ? n : fallback
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function nonNegative(value, fallback) {
|
|
14
|
+
const n = Number(value)
|
|
15
|
+
return Number.isFinite(n) && n >= 0 ? n : fallback
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function timeString(value, fallback) {
|
|
19
|
+
const s = String(value ?? '')
|
|
20
|
+
return /^\d{2}:\d{2}$/.test(s) ? s : fallback
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveDataDir(raw) {
|
|
24
|
+
const value = String(raw ?? '').trim()
|
|
25
|
+
if (!value) return resolve(homedir(), '.amiru')
|
|
26
|
+
return isAbsolute(value) ? resolve(value) : resolve(process.cwd(), value)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveQQPrefsPath(raw) {
|
|
30
|
+
const value = String(raw ?? '').trim()
|
|
31
|
+
if (value) return isAbsolute(value) ? resolve(value) : resolve(process.cwd(), value)
|
|
32
|
+
const qqHome = String(process.env.QQBOT_DATA_DIR ?? '').trim()
|
|
33
|
+
if (qqHome) return resolve(qqHome, 'model-prefs.json')
|
|
34
|
+
return resolve(homedir(), '.dsh-qqbot', 'model-prefs.json')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function normalizeConfig(raw = {}) {
|
|
38
|
+
const dataDir = resolveDataDir(raw.dataDir)
|
|
39
|
+
const proactive = raw.proactive && typeof raw.proactive === 'object' ? raw.proactive : {}
|
|
40
|
+
return {
|
|
41
|
+
enabled: raw.enabled !== false,
|
|
42
|
+
dataDir,
|
|
43
|
+
personaDir: raw.personaDir ? resolveDataDir(raw.personaDir) : resolve(dataDir, 'personas'),
|
|
44
|
+
defaultPersona: String(pick(raw.defaultPersona, 'default')).replace(/[^a-zA-Z0-9_-]/g, '') || 'default',
|
|
45
|
+
maxPersonaChars: positive(raw.maxPersonaChars, 6000),
|
|
46
|
+
memoryMessageLimit: positive(raw.memoryMessageLimit, 10),
|
|
47
|
+
memoryItemLimit: positive(raw.memoryItemLimit, 8),
|
|
48
|
+
memoryMaxChars: positive(raw.memoryMaxChars, 1400),
|
|
49
|
+
qqPrefsPath: resolveQQPrefsPath(raw.qqPrefsPath),
|
|
50
|
+
toolsEnabled: raw.toolsEnabled !== false,
|
|
51
|
+
proactive: {
|
|
52
|
+
enabled: proactive.enabled !== false,
|
|
53
|
+
idleMinutes: positive(proactive.idleMinutes, 120),
|
|
54
|
+
minIntervalMinutes: positive(proactive.minIntervalMinutes, 120),
|
|
55
|
+
dailyCap: nonNegative(proactive.dailyCap, 2),
|
|
56
|
+
minUserMessages: nonNegative(proactive.minUserMessages, 3),
|
|
57
|
+
quietStart: timeString(proactive.quietStart, '23:30'),
|
|
58
|
+
quietEnd: timeString(proactive.quietEnd, '08:00'),
|
|
59
|
+
maxUnanswered: nonNegative(proactive.maxUnanswered, 2),
|
|
60
|
+
backoffHours: positive(proactive.backoffHours, 48),
|
|
61
|
+
tickSeconds: positive(proactive.tickSeconds, 60),
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|