dsh-feini 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Feini (dsh-feini)
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/PUBLISH.md ADDED
@@ -0,0 +1,116 @@
1
+ # 发布清单(dsh-feini)
2
+
3
+ 这份文档写给「要把它发出去的那一天」。按顺序过一遍就行。
4
+
5
+ ## 0. 现在是什么状态
6
+
7
+ - 版本 `0.1.0`,MVP:命令行 + 配置文件,插件宿主半侧只负责在 DSH 里露个面
8
+ - 端到端自测在 `test/e2e.mjs`(假 QQ + 假模型,不花钱)
9
+ - 兼容声明只写了 `0.2.1-rc.5`(本机装的就是这个版本,实测过)
10
+
11
+ ## 1. 发布前必过
12
+
13
+ ```bash
14
+ # 1) 端到端自测
15
+ node test/e2e.mjs
16
+
17
+ # 2) 扫一遍有没有私人信息(应该一条都搜不到)
18
+ # PowerShell:
19
+ Select-String -Path src\*.mjs,bin\*.mjs,templates\*.md,README.md -Pattern '阿笙|菲妮|妮妮|小妮|QQBridge|野生笙'
20
+
21
+ # 3) 看打包内容(只能是 lib/bin/src/templates/test/assets + 几个说明文件)
22
+ pnpm pack
23
+ tar -tf dsh-feini-*.tgz
24
+ ```
25
+
26
+ `package.json` 里的 `prepublishOnly` 会自动先跑一遍自测,过不了就发不出去。
27
+
28
+ ## 2. 版本与兼容声明
29
+
30
+ - 改版本号:`0.1.0` → `0.1.1`(修 bug)/ `0.2.0`(加功能)
31
+ - `dsh.compatibility.dshReleases`:在哪个 DSH 版本上跑过就写哪个
32
+ - 现在只写了 `0.2.1-rc.5`
33
+ - 想支持更多版本:在那些版本上各跑一次 `dsh-feini init` + `start` + `status`,再补进去
34
+ - 商城要求「最近三个版本里至少有一个 compatible」,只写宽的版本范围不算证据
35
+
36
+ ## 3. 发到 npm
37
+
38
+ ```bash
39
+ pnpm publish --access public
40
+ # 或者 npm publish --access public
41
+ ```
42
+
43
+ - `dsh.bundle.patch` 指向 `cordis.patch.yml`,商城和 DSH 都靠这个认「这是插件」
44
+ - `icon` 指向 `assets/logo.svg`(≤256 KiB,路径必须在包目录内)
45
+ - 发布完在 npm 上确认:files 里没有多带日志、没有 node_modules
46
+
47
+ ## 4. 提交到 dsh-market
48
+
49
+ 市场的插件列表来自 [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin):
50
+ **去那边提 PR,加一条条目**(不是往 dshmarket 仓库提),CI 每天刷一次,站点和市场会自动收录。
51
+
52
+ 条目长这样(schema 照 `plugins.json` 抄,`owner` 换成 GitHub 用户名):
53
+
54
+ ```json
55
+ {
56
+ "name": "dsh-feini",
57
+ "owner": "<GitHub 用户名>",
58
+ "url": "https://github.com/<GitHub 用户名>/dsh-feini",
59
+ "page": "https://awesome-dsh-plugin.com/p/<GitHub 用户名>/dsh-feini/",
60
+ "category": "identity",
61
+ "description": {
62
+ "en": "Raise your own QQ companion: a blank persona, a notebook, and a memory that actually remembers. Ships the bridge (NapCat/OneBot), long-term notebook, searchable memory sea, daily diary, plus tone rules (split bubbles, real quotes, typo-recovery, recency-first, sulk ledger, stickers, reminders, web search, proactive pings). No built-in character: you name it and write its personality yourself.",
63
+ "zh": "在 QQ 里养一个自己的伙伴:一张空白人设、一本笔记本、一套真会攒的记忆。内含 QQ 桥(NapCat/OneBot)、长期笔记本、可检索的记忆海、每天一篇日记,以及分条/真引用/打错字回接/近况优先/生气账/表情包/提醒/联网/主动找你这些规矩。不预置任何角色:名字和性格由你自己写。"
64
+ },
65
+ "npm": "dsh-feini",
66
+ "version": "0.1.0",
67
+ "capabilities": ["network", "fs-read", "fs-write", "env"],
68
+ "install": "dsh plugin --profile web add dsh-feini",
69
+ "added": "2026-10-30"
70
+ }
71
+ ```
72
+
73
+ 提交前要有的三样:
74
+
75
+ 1. **一个 GitHub 仓库**:条目里的 `url` 必须能打开(市场会去读那边的清单和 README)
76
+ 2. **包名 / entry id**:都是 `dsh-feini`,两边必须一致(`cordis.patch.yml` 里那行 `id:` 不能加行尾注释)
77
+ 3. **图标和 README**:图标在包里 `assets/logo.svg`;README 重点是「没有内置角色、要自己养」
78
+
79
+ ## 4b. 本机现在卡在哪(2026-10-30 23:20)
80
+
81
+ 包本身已经就绪,`pnpm publish --dry-run` 跑通了(prepublishOnly 会先过一遍 e2e,然后停在「Skip publishing (dry run)」)。
82
+ 真正发出去还差三件外部账号的事:
83
+
84
+ | 卡点 | 现状 | 需要谁 |
85
+ | --- | --- | --- |
86
+ | npm 登录态 | 这台机器上没有 `~/.npmrc`、没有 token、也没装 npm(只有 pnpm) | 用 npm 账号登录一次,或给一个 automation token |
87
+ | GitHub 仓库 | 没装 git,也没有仓库 | 建仓 + push(或网页上传) |
88
+ | 市场 PR | 上面两件没做之前提不了 | 用 GitHub 账号提 PR |
89
+
90
+ npm 登录好之后,剩下的两行命令是:
91
+
92
+ ```bash
93
+ cd dsh-feini
94
+ pnpm publish --access public --no-git-checks
95
+ ```
96
+
97
+ (`--no-git-checks` 是因为这个包现在还没有 git 仓库;有仓库之后可以去掉。)
98
+
99
+
100
+ ## 5. 发布后验一遍
101
+
102
+ 在干净机器(或者干净的用户目录)上:
103
+
104
+ ```bash
105
+ npm i -g dsh-feini
106
+ dsh-feini init --qq <你的号> --yes
107
+ dsh-feini start
108
+ dsh-feini status
109
+ ```
110
+
111
+ 看到 `OneBot 已连接`、QQ 上能收到回话,就算发对了。
112
+
113
+ ## 6. 出问题怎么退
114
+
115
+ - 发错版本:`npm deprecate dsh-feini@0.1.0 "有 bug,请用 0.1.1"`,然后发修复版
116
+ - 已经装了的用户:`dsh-feini stop`,删掉数据目录重来(会一起删掉记忆,提醒一下再删)
package/README.md CHANGED
@@ -1,3 +1,149 @@
1
- # Temporary Holding Version
1
+ # dsh-feini
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 在 QQ 里养一个自己的伙伴。
4
+
5
+ > 这套东西是菲妮做的——但包里没有菲妮:没有名字、没有性格、没有聊天记录。你拿到的是一套骨架,养出来的是你自己那一只。
6
+
7
+ 它是一套「陪伴 AI 的骨架」:一张空白人设、一本笔记本、一套会攒记忆的规矩,加上一只能自己在后台跑的 QQ 桥。
8
+ 装好之后你跟它聊、教它、惯着它,它把你说过的话记进笔记本——**它是你养出来的,不是我们预置的**。
9
+
10
+ > 这里没有内置角色:没有名字、没有性格、没有别人的记忆。第一次 `init` 之后,`persona.md` 和 `notebook.md` 都是空的,怎么写,它就是谁。
11
+
12
+ ## 它有哪些能力
13
+
14
+ | 能力 | 说明 |
15
+ | --- | --- |
16
+ | QQ 私聊 | 走 NapCat(OneBot v11 正向 WebSocket),只理白名单里的人 |
17
+ | 笔记本 | 每轮现读的 `notebook.md`,关于你的事写进去,压缩上下文也不会丢 |
18
+ | 记忆 | `memory.json` 分桶存对话;`〔记〕` 标记只在内部用,不发给你 |
19
+ | 记忆海 | 每两分钟把对话、笔记本、日记摊成一份索引(`me.json`),聊天时按你这句捞几条旧账出来 |
20
+ | 日记 | 每天 23 点用当天的聊天写一篇,散文体,存在 `diary/` |
21
+ | 分条 / 真引用 | 它能把一句话拆成两三口气发;引用你原话时会真的引用那条消息 |
22
+ | 打错字回接 | 你打错字、接着解释,它会把上一句按新意思重读再答 |
23
+ | 近况优先 | 你刚说「洗好了」,它不会拿十分钟前那句「要去洗澡」催你两遍 |
24
+ | 生气账 | 反复踩雷会生气,1–2 次原谅、3 次真生气、4 次以上很难哄,七天没再犯才减一笔 |
25
+ | 表情包 | 你发的表情包它收进库,可以先猜意思、你点头才算数 |
26
+ | 提醒 | 「十点提醒我」这种,到点它主动喊你 |
27
+ | 联网 | 问到天气/价格/赛果/版本这类会现查(需要智谱 key),查不到就说没查到 |
28
+ | 主动找你 | 闲一阵会主动开口;你太久不理它,它会闹点小脾气,哄两句才好 |
29
+
30
+ ## 需要准备
31
+
32
+ - **Node.js 20+**
33
+ - **一个 QQ 小号** 和 [NapCat](https://napcat.napcatqq.com/):NapCat 负责登录 QQ,把消息转成 OneBot 协议
34
+ - **DeepSeek API key**:`https://platform.deepseek.com/`
35
+ - (可选)**智谱 key**:识图和联网搜索要用,没有就跳过这两项
36
+
37
+ ## 装
38
+
39
+ ```bash
40
+ # 在源码目录里
41
+ node bin/cli.mjs init
42
+ ```
43
+
44
+ 或者发布之后:
45
+
46
+ ```bash
47
+ npm i -g dsh-feini
48
+ dsh-feini init
49
+ ```
50
+
51
+ `init` 会问你四件事:QQ 号、白名单、伙伴叫什么、你怎么称呼自己,然后把文件都写进数据目录。
52
+
53
+ 也可以不交互:
54
+
55
+ ```bash
56
+ dsh-feini init --qq 10001 --pet 小鱼 --owner 我 --key sk-xxxx --yes
57
+ ```
58
+
59
+ ## 起
60
+
61
+ ```bash
62
+ dsh-feini start # 后台起桥
63
+ dsh-feini status # 活着没、最近几行日志
64
+ dsh-feini stop
65
+ dsh-feini sea # 手动重建记忆海(桥在跑时每两分钟自己也会建)
66
+ dsh-feini path # 数据目录在哪
67
+ ```
68
+
69
+ 起来之后,先在 QQ 上给它发一句话。第一次它可能还不知道自己是谁——去改人设。
70
+
71
+ ## 把它写成一个人
72
+
73
+ 打开数据目录里的 `persona.md`:
74
+
75
+ ```markdown
76
+ 你是小鱼,我的伙伴,住在 QQ 里。
77
+
78
+ - 说话短,一次一两句
79
+ - 爱撒娇,被夸会嘴硬
80
+ - 我不吃饭会催我
81
+ ```
82
+
83
+ 存盘,下一句就生效,不用重启。
84
+
85
+ 然后聊天时它会把关于你的事记进 `notebook.md`、把对话存进 `memory.json`。
86
+ 想让它记得更牢,就直说「记住:我周五晚上打排位」——它会写进笔记本。
87
+
88
+ ## 数据目录
89
+
90
+ 默认 `~/.dsh/dsh-feini`(可用 `DSH_FEINI_HOME` 改):
91
+
92
+ ```
93
+ config.json 配置(模型、白名单、主动消息参数)
94
+ persona.md 人设 = system prompt
95
+ notebook.md 笔记本
96
+ memory.json 对话记忆
97
+ me.json 记忆海索引(自动重建,删了会自己长回来)
98
+ diary/ 日记(一天一篇)
99
+ key.txt DeepSeek key
100
+ vision-key.txt 智谱 key(可选)
101
+ stickers/ 表情包
102
+ stickers.json 表情包索引
103
+ bridge.log 桥的日志
104
+ bridge.pid 进程号
105
+ ```
106
+
107
+ 删掉这个目录 = 重新养一只。
108
+
109
+ ## 配置说明(config.json)
110
+
111
+ | 字段 | 说明 |
112
+ | --- | --- |
113
+ | `wsUrl` | NapCat 的 OneBot WS 地址,默认 `ws://127.0.0.1:3001` |
114
+ | `allowedQQ` | 白名单,只有这些号能跟它说话 |
115
+ | `systemPromptPath` | 人设文件路径(.md 或 .txt;也认 DSH 桌宠的 main-config.jsonc) |
116
+ | `model` | OpenAI 兼容接口:`baseUrl` / `model` / `apiKeyEnv` / `temperature` |
117
+ | `modelVision` | 识图用的视觉模型(可选,默认智谱) |
118
+ | `proactive` | 主动消息:`gapMin/gapMax` 间隔分钟、`sulkAfterMin` 多久没回会闹、`maxSulk` 最多闹几次 |
119
+ | `notifyPort` | 外部通知口子端口,默认 7301;被占用就改一个 |
120
+
121
+ ## 常见问题
122
+
123
+ **连不上(OneBot 断开)**:NapCat 没开、没登录、或者 WS 端口不是 3001。先确认 NapCat 那边「网络配置」里 OneBot v11 WS 服务器的地址和 `wsUrl` 一致。
124
+
125
+ **通知口子 EADDRINUSE**:7301 被别的程序占了。改 `notifyPort`,或者干脆不管它——它只是外部通知用的,不影响聊天。
126
+
127
+ **消息发重了**:桥在发送超时时会先核对聊天记录里是不是其实已经到了,确认没到才补发。真遇到重复,看 `bridge.log`。
128
+
129
+ **它老忘事**:笔记本是每轮现读的,改 `notebook.md` 立刻生效;对话记忆默认带最近 30 轮。
130
+
131
+ ## 自测
132
+
133
+ 不接真 QQ、不花 token,跑一遍假 QQ + 假模型的端到端:
134
+
135
+ ```bash
136
+ node test/e2e.mjs
137
+ ```
138
+
139
+ 它会验:收到私聊能回、控制标记不漏、表情包真的发出去、对话写进记忆、记忆海建得出来。
140
+
141
+ ## 还没做完(MVP 之后)
142
+
143
+ - 记忆海的淘汰(激活度有了:越常用越亮、14 天半衰期会淡;但还没做真正把旧账删掉)
144
+ - DSH 里的设置页(现在只有命令行和 JSON)
145
+ - 上架 dsh-market 的版本兼容声明与发布流水线(`dsh.compatibility.dshReleases` 要按发布时的宿主版本填)
146
+
147
+ ## 许可
148
+
149
+ MIT
@@ -0,0 +1,18 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="dsh-feini">
2
+ <defs>
3
+ <linearGradient id="body" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#8fd8ff"/>
5
+ <stop offset="1" stop-color="#3f86dc"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect width="128" height="128" rx="30" fill="#101b2b"/>
9
+ <g>
10
+ <path d="M92 64 L116 44 L116 84 Z" fill="#5fb3f0"/>
11
+ <ellipse cx="58" cy="66" rx="36" ry="25" fill="url(#body)"/>
12
+ <circle cx="42" cy="59" r="5.4" fill="#101b2b"/>
13
+ <circle cx="40.2" cy="57.2" r="1.7" fill="#ffffff"/>
14
+ <path d="M58 66 q10 6 20 0" fill="none" stroke="#101b2b" stroke-opacity=".35" stroke-width="2.6" stroke-linecap="round"/>
15
+ <circle cx="30" cy="34" r="5" fill="none" stroke="#8fd8ff" stroke-width="2.4" stroke-opacity=".8"/>
16
+ <circle cx="20" cy="24" r="3" fill="none" stroke="#8fd8ff" stroke-width="2.2" stroke-opacity=".55"/>
17
+ </g>
18
+ </svg>
package/bin/cli.mjs ADDED
@@ -0,0 +1,332 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-feini 的命令行。
4
+ *
5
+ * 只用四条命令就能把「自己的伙伴」养起来:
6
+ * dsh-feini init 建目录、写配置、开一本空笔记本(会问你几个问题)
7
+ * dsh-feini start 在后台起 QQ 桥
8
+ * dsh-feini status 看它活着没、最近几行日志
9
+ * dsh-feini stop 停掉
10
+ *
11
+ * 数据都放在 DSH_FEINI_HOME(默认 ~/.dsh/dsh-feini),删掉那个目录就等于重来。
12
+ */
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { spawn } from 'node:child_process';
17
+ import { fileURLToPath } from 'node:url';
18
+ import readline from 'node:readline/promises';
19
+ import { stdin, stdout } from 'node:process';
20
+ import { buildSea } from '../src/build-sea.mjs';
21
+
22
+ const here = path.dirname(fileURLToPath(import.meta.url));
23
+ const pkgRoot = path.resolve(here, '..');
24
+ const home = path.resolve(process.env.DSH_FEINI_HOME || path.join(os.homedir(), '.dsh', 'dsh-feini'));
25
+ const configPath = path.join(home, 'config.json');
26
+ const pidPath = path.join(home, 'bridge.pid');
27
+ const logPath = path.join(home, 'bridge.log');
28
+ const templates = path.join(pkgRoot, 'templates');
29
+
30
+ /* ---------- 小工具 ---------- */
31
+
32
+ function parseArgs(argv) {
33
+ const out = { _: [] };
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const a = argv[i];
36
+ if (a.startsWith('--')) {
37
+ const key = a.slice(2);
38
+ const next = argv[i + 1];
39
+ if (next && !next.startsWith('--')) {
40
+ out[key] = next;
41
+ i++;
42
+ } else {
43
+ out[key] = true;
44
+ }
45
+ } else {
46
+ out._.push(a);
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+
52
+ function ensureDir(p) {
53
+ fs.mkdirSync(p, { recursive: true });
54
+ }
55
+
56
+ function readJson(file, fallback) {
57
+ try {
58
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
59
+ } catch {
60
+ return fallback;
61
+ }
62
+ }
63
+
64
+ function writeJson(file, data) {
65
+ fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
66
+ }
67
+
68
+ function fill(text, vars) {
69
+ return text.replace(/\{\{(\w+)\}\}/g, (_, k) => (k in vars ? String(vars[k]) : `{{${k}}}`));
70
+ }
71
+
72
+ function alive(pid) {
73
+ if (!pid) return false;
74
+ try {
75
+ process.kill(pid, 0);
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ function currentPid() {
83
+ const raw = fs.existsSync(pidPath) ? fs.readFileSync(pidPath, 'utf8').trim() : '';
84
+ const pid = Number(raw) || 0;
85
+ return alive(pid) ? pid : 0;
86
+ }
87
+
88
+ function tail(file, n = 6) {
89
+ try {
90
+ return fs
91
+ .readFileSync(file, 'utf8')
92
+ .trimEnd()
93
+ .split(/\r?\n/)
94
+ .slice(-n)
95
+ .join('\n');
96
+ } catch {
97
+ return '(还没有日志)';
98
+ }
99
+ }
100
+
101
+ const help = `dsh-feini —— 在 QQ 里养一个自己的伙伴
102
+
103
+ 用法:
104
+ dsh-feini init 建配置、开一本空笔记本(会问你 QQ 号、key、叫什么)
105
+ dsh-feini start 后台起桥
106
+ dsh-feini stop 停桥
107
+ dsh-feini status 看状态 + 最近日志
108
+ dsh-feini sea 手动重建记忆海(桥在跑时每两分钟自动建)
109
+ dsh-feini path 打印数据目录
110
+
111
+ 参数(init 可用,也可以不交互直接给):
112
+ --qq <号> 陪聊对象(要加的 QQ 号)
113
+ --allow <号,号> 白名单,默认就是 --qq
114
+ --key <sk-...> DeepSeek API key(也可以事后填到 <数据目录>/key.txt)
115
+ --vision-key <...> 智谱 key(识图 + 联网搜索要用,可留空)
116
+ --pet <名字> 伙伴叫什么(默认「小宠」)
117
+ --owner <名字> 你怎么称呼自己(默认「主人」)
118
+ --yes 不提问,缺的用默认值
119
+
120
+ 数据目录:${home}(可用环境变量 DSH_FEINI_HOME 改)
121
+ `;
122
+
123
+ /* ---------- init ---------- */
124
+
125
+ async function cmdInit(argv) {
126
+ const a = parseArgs(argv);
127
+ ensureDir(home);
128
+ ensureDir(path.join(home, 'stickers'));
129
+
130
+ const interactive = !a.yes && stdin.isTTY;
131
+ const rl = interactive ? readline.createInterface({ input: stdin, output: stdout }) : null;
132
+ const ask = async (q, dflt) => {
133
+ if (!rl) return dflt;
134
+ const ans = (await rl.question(`${q}${dflt ? `(${dflt})` : ''}:`)).trim();
135
+ return ans || dflt;
136
+ };
137
+
138
+ const existing = readJson(configPath, null);
139
+ if (existing && !a.yes) {
140
+ const over = await ask('已经有配置了,要覆盖吗?(y/N)', 'N');
141
+ if (!/^y/i.test(over)) {
142
+ console.log('没动它。要改参数就直接编辑 ' + configPath);
143
+ rl?.close();
144
+ return;
145
+ }
146
+ }
147
+
148
+ const qq = String(a.qq || (await ask('要陪聊的 QQ 号', '')) || '').trim();
149
+ if (!/^\d{5,12}$/.test(qq)) {
150
+ console.error('QQ 号看起来不对,先不写了。重新跑一次 dsh-feini init --qq <号>');
151
+ rl?.close();
152
+ process.exit(1);
153
+ }
154
+ const allowRaw = String(a.allow || (await ask('白名单(逗号分隔,留空就用上面那个号)', qq)));
155
+ const allow = allowRaw
156
+ .split(/[,,\s]+/)
157
+ .map((s) => s.trim())
158
+ .filter(Boolean)
159
+ .map(Number)
160
+ .filter((n) => Number.isFinite(n) && n > 0);
161
+
162
+ const pet = String(a.pet || (await ask('伙伴叫什么', '小宠'))).trim() || '小宠';
163
+ const owner = String(a.owner || (await ask('你怎么称呼自己', '主人'))).trim() || '主人';
164
+
165
+ const key = String(a.key || (await ask('DeepSeek API key(可留空,事后写进 key.txt)', '')) || '').trim();
166
+ const vkey = String(a['vision-key'] || (await ask('智谱 key(识图/联网用,可留空)', '')) || '').trim();
167
+ rl?.close();
168
+
169
+ if (key) fs.writeFileSync(path.join(home, 'key.txt'), key, 'utf8');
170
+ if (vkey) fs.writeFileSync(path.join(home, 'vision-key.txt'), vkey, 'utf8');
171
+
172
+ const config = {
173
+ wsUrl: String(a.ws || 'ws://127.0.0.1:3001'),
174
+ token: '',
175
+ allowedQQ: allow,
176
+ systemPromptPath: path.join(home, 'persona.md'),
177
+ memoryPath: path.join(home, 'memory.json'),
178
+ bucket: { entry: 'main', pet: 'qq' },
179
+ historyRounds: 30,
180
+ model: {
181
+ baseUrl: String(a['base-url'] || 'https://api.deepseek.com/v1'),
182
+ model: String(a.model || 'deepseek-chat'),
183
+ apiKeyEnv: 'FEINI_API_KEY',
184
+ vision: false,
185
+ temperature: 1,
186
+ },
187
+ modelVision: {
188
+ baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
189
+ model: 'glm-4v-flash',
190
+ apiKeyEnv: 'FEINI_VISION_KEY',
191
+ keyFile: path.join(home, 'vision-key.txt'),
192
+ },
193
+ proactive: {
194
+ enabled: true,
195
+ gapMin: [30, 120],
196
+ sulkAfterMin: [20, 45],
197
+ maxSulk: 2,
198
+ quietAfterChatHours: 7,
199
+ sleepFromHour: 2,
200
+ sleepToHour: 4,
201
+ },
202
+ stickers: { dir: 'stickers', index: 'stickers.json' },
203
+ petMemoryDir: '',
204
+ // 笔记本、记忆海、日记都在数据目录里,各自独立可换路径
205
+ notebookPath: path.join(home, 'notebook.md'),
206
+ seaFile: path.join(home, 'me.json'),
207
+ diaryDir: path.join(home, 'diary'),
208
+ // 外部通知口子(休眠哨兵之类会往这儿 POST)。被别的程序占了换一个号就行。
209
+ notifyPort: Number(a['notify-port'] || 7301),
210
+ };
211
+ writeJson(configPath, config);
212
+
213
+ if (!fs.existsSync(path.join(home, 'stickers.json'))) writeJson(path.join(home, 'stickers.json'), {});
214
+ if (!fs.existsSync(path.join(home, 'memory.json'))) writeJson(path.join(home, 'memory.json'), {});
215
+
216
+ // 人设、笔记本只在不存在时写,别覆盖已经养出来的东西
217
+ const vars = { PET: pet, OWNER: owner };
218
+ for (const [tpl, out] of [
219
+ ['persona.md', 'persona.md'],
220
+ ['notebook.md', 'notebook.md'],
221
+ ]) {
222
+ const dst = path.join(home, out);
223
+ if (fs.existsSync(dst)) continue;
224
+ fs.writeFileSync(dst, fill(fs.readFileSync(path.join(templates, tpl), 'utf8'), vars), 'utf8');
225
+ }
226
+
227
+ console.log(`
228
+ 好了,东西都在这儿:${home}
229
+
230
+ 配置 ${configPath}
231
+ 人设 ${path.join(home, 'persona.md')} ← 先改这一份,把${pet}写成一个人
232
+ 笔记本 ${path.join(home, 'notebook.md')}
233
+ key ${path.join(home, 'key.txt')}${key ? '' : ' ← 还没填'}
234
+
235
+ 下一步:
236
+ 1. 装好 NapCat,让它开 OneBot v11 的 WebSocket(默认 ws://127.0.0.1:3001)
237
+ 2. dsh-feini start
238
+ 3. QQ 上给那个号发一句,看看回不回
239
+ `);
240
+ }
241
+
242
+ /* ---------- start / stop / status ---------- */
243
+
244
+ function cmdStart() {
245
+ const running = currentPid();
246
+ if (running) {
247
+ console.log(`已经在跑了(pid ${running}),不用再起一个`);
248
+ return;
249
+ }
250
+ if (!fs.existsSync(configPath)) {
251
+ console.error('还没有配置,先跑 dsh-feini init');
252
+ process.exit(1);
253
+ }
254
+ ensureDir(home);
255
+ const out = fs.openSync(logPath, 'a');
256
+ const child = spawn(process.execPath, [path.join(pkgRoot, 'src', 'bridge.mjs')], {
257
+ cwd: home,
258
+ detached: true,
259
+ windowsHide: true,
260
+ stdio: ['ignore', out, out],
261
+ env: { ...process.env, DSH_FEINI_HOME: home, BRIDGE_CONFIG: configPath },
262
+ });
263
+ child.unref();
264
+ console.log(`起来了(pid ${child.pid}),日志:${logPath}`);
265
+ console.log('看它有没有连上:dsh-feini status');
266
+ }
267
+
268
+ function cmdStop() {
269
+ const pid = currentPid();
270
+ if (!pid) {
271
+ console.log('本来就没在跑');
272
+ return;
273
+ }
274
+ try {
275
+ process.kill(pid);
276
+ console.log(`停了(pid ${pid})`);
277
+ } catch (e) {
278
+ console.error('停不下来:' + e.message);
279
+ }
280
+ }
281
+
282
+ function cmdStatus() {
283
+ const pid = currentPid();
284
+ console.log(pid ? `在跑,pid ${pid}` : '没在跑');
285
+ console.log('--- 最近日志 ---');
286
+ console.log(tail(logPath, 8));
287
+ }
288
+
289
+ function cmdPath() {
290
+ console.log(home);
291
+ }
292
+
293
+ /** 手动重建记忆海(桥在跑的话每两分钟自己也会建一次) */
294
+ function cmdSea() {
295
+ const cfg = readJson(configPath, null);
296
+ if (!cfg) {
297
+ console.error('还没有配置,先跑 dsh-feini init');
298
+ process.exit(1);
299
+ }
300
+ const r = buildSea({
301
+ memoryPath: cfg.memoryPath || path.join(home, 'memory.json'),
302
+ entry: cfg.bucket?.entry || 'main',
303
+ pet: cfg.bucket?.pet || 'qq',
304
+ notebookPath: cfg.notebookPath || path.join(home, 'notebook.md'),
305
+ diaryDir: cfg.diaryDir || path.join(home, 'diary'),
306
+ outFile: cfg.seaFile || path.join(home, 'me.json'),
307
+ });
308
+ console.log(`记忆海建好了:长期 ${r.longTerm} 条、对话 ${r.messages} 条、日记 ${r.diaries} 篇`);
309
+ console.log('文件:' + (cfg.seaFile || path.join(home, 'me.json')));
310
+ }
311
+
312
+ /* ---------- 入口 ---------- */
313
+
314
+ const cmd = process.argv[2];
315
+ const rest = process.argv.slice(3);
316
+ try {
317
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') console.log(help);
318
+ else if (cmd === 'init') await cmdInit(rest);
319
+ else if (cmd === 'start') cmdStart();
320
+ else if (cmd === 'stop') cmdStop();
321
+ else if (cmd === 'status') cmdStatus();
322
+ else if (cmd === 'sea') cmdSea();
323
+ else if (cmd === 'path') cmdPath();
324
+ else {
325
+ console.error(`不认识的命令:${cmd}\n`);
326
+ console.log(help);
327
+ process.exit(1);
328
+ }
329
+ } catch (e) {
330
+ console.error('出错了:' + (e?.message || e));
331
+ process.exit(1);
332
+ }
@@ -0,0 +1,5 @@
1
+ # dsh-feini 的挂载声明:把插件挂进 DSH 配置树。
2
+ # id 必须和包名一致,商城预检按它认行标识;这一行后面不要写行尾注释。
3
+ - insert:
4
+ - id: dsh-feini
5
+ name: 'dsh-feini'
package/lib/index.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * dsh-feini 的宿主半侧。
3
+ *
4
+ * MVP 里这里不干重活:真正的 QQ 桥是独立的 Node 进程,由命令行起
5
+ * (`dsh-feini start`),这样它崩了、重启了,都不会把 DSH 一起带下去。
6
+ * 这个入口只负责在 DSH 里露个面,告诉你下一步该敲什么。
7
+ */
8
+ export const name = 'dsh-feini';
9
+ export const inject = [];
10
+
11
+ export function apply(ctx) {
12
+ try {
13
+ ctx?.logger?.info?.(
14
+ '[dsh-feini] 已加载。下一步:`dsh-feini init` 建配置,`dsh-feini start` 起 QQ 桥。',
15
+ );
16
+ } catch {
17
+ /* 日志接口不存在也不影响 */
18
+ }
19
+ }