dsh-bookmemory 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/INSTALL.md ADDED
@@ -0,0 +1,85 @@
1
+ # 安装说明
2
+
3
+ ## 你需要什么
4
+
5
+ | 条件 | 说明 |
6
+ |---|---|
7
+ | **DSH** | 0.2.x(设置契约在这一版是「插件声明 `Config`」) |
8
+ | **Node.js** | ≥ 18(DSH 自己就要求,通常已有) |
9
+ | 网络 | 装 `@node-rs/jieba` 要能访问 npm |
10
+
11
+ ## 三条路
12
+
13
+ ### 路 A:脚本(Windows,推荐)
14
+
15
+ ```powershell
16
+ powershell -ExecutionPolicy Bypass -File tools\install.ps1
17
+ powershell -ExecutionPolicy Bypass -File tools\install.ps1 -ProfileName desktop # 桌面版
18
+ ```
19
+
20
+ 它做三件事,**不碰 package.json、不跑 pnpm**:
21
+
22
+ 1. 在 `<profile>\node_modules\dsh-bookmemory` 建目录链接
23
+ 2. 在 `<profile>\cordis.patch.yml` 追加一条 insert 行
24
+ 3. 检查 `@deepseek-ai/schemastery`(设置卡片需要,低于 3.18.4 会警告)
25
+
26
+ 改之前自动备份 patch 文件到 `~/.dsh/.dsh-rollback-dsh-bookmemory-<时间戳>\`。
27
+
28
+ ### 路 B:官方命令
29
+
30
+ ```sh
31
+ dsh plugin --profile web add <插件目录>
32
+ ```
33
+
34
+ 装完重启一次 DSH。
35
+
36
+ ### 路 C:手动
37
+
38
+ ```powershell
39
+ # 1) 装依赖(注意要用 cmd,PowerShell 的执行策略会挡住 npm.ps1)
40
+ cmd /c "npm install --omit=dev"
41
+
42
+ # 2) 建链接
43
+ New-Item -ItemType Junction -Path "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-bookmemory" `
44
+ -Target "<插件目录>"
45
+
46
+ # 3) 在 cordis.patch.yml 末尾追加
47
+ # - insert:
48
+ # - id: bookmemory
49
+ # name: dsh-bookmemory
50
+ ```
51
+
52
+ ## 坑(都会真实遇到)
53
+
54
+ ### 1. npm 被 PowerShell 执行策略挡住
55
+
56
+ ```
57
+ npm.ps1 ... 因为在此系统上禁止运行脚本
58
+ ```
59
+
60
+ 必须走 `cmd /c "npm install ..."`。
61
+
62
+ ### 2. 宿主包版本必须对齐
63
+
64
+ 插件的 `devDependencies` 里 `@deepseek-ai/dsh-tools` 的版本要和 DSH 内置的一致,
65
+ 否则会报:
66
+
67
+ ```
68
+ failed to import: SyntaxError: The requested module '@deepseek-ai/dsh-llm'
69
+ does not provide an export named 'CallId'
70
+ ```
71
+
72
+ **怎么查宿主版本**:从 `app.asar` 里读
73
+ `node_modules/@deepseek-ai/dsh-tools/package.json` 的 `version`。
74
+ 本机实测宿主是 `0.2.1-rc.2`,而 npm 的 `latest` 却是 `0.0.1-rc.1` —— 差一个大版本。
75
+
76
+ ### 3. 插件只在 patch 文件变化时重载
77
+
78
+ 改 `entry.js` 不会热重载,要重启 DSH,或者 touch 一下 `cordis.patch.yml`。
79
+
80
+ ## 卸掉
81
+
82
+ ```powershell
83
+ Remove-Item "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-bookmemory" -Force
84
+ # 再把备份里的 cordis.patch.yml 拷回去
85
+ ```
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 s1770
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/MANUAL.md ADDED
@@ -0,0 +1,60 @@
1
+ # 使用说明
2
+
3
+ ## 它做了什么
4
+
5
+ 三类东西,都在 `~/.dsh-bookmemory/` 里:**书**(对话原文)、**目录**(关键词索引)、**锚点**(提炼过的记忆)。
6
+
7
+ 每次模型请求前,插件拿你这句话去查目录,把命中的旧对话和锚点拼成一段上下文塞进系统提示词。
8
+ 对话结束后(下一次请求时)自动补记一本。
9
+
10
+ ## 数据文件
11
+
12
+ | 文件 | 是什么 | 能不能手动改 |
13
+ |---|---|---|
14
+ | `书.jsonl` | 每次对话一行,永不覆盖 | 能,删一行就忘一件事 |
15
+ | `目录.json` | 关键词 → 书号 | 别改,重建成本低但容易写坏 |
16
+ | `锚点.jsonl` | 「值得记」的事 | 能,这是最该手写的地方 |
17
+ | `策略.json` | 压缩规则 | 能,改完下次压缩生效 |
18
+
19
+ 整个目录拷走就是备份;删掉目录就是失忆。
20
+
21
+ ## 两个工具
22
+
23
+ 模型会自己判断什么时候调用:
24
+
25
+ - **`bookmemory_recall`** — 你说「之前」「上次」的时候,它会去查
26
+ - **`bookmemory_remember`** — 有值得留的事,它会记一条锚点
27
+
28
+ ## 设置页
29
+
30
+ | 项 | 默认 | 说明 |
31
+ |---|---|---|
32
+ | `dataDir` | `~/.dsh-bookmemory` | 数据目录 |
33
+ | `oldFraction` | 0.2 | 时间轴前 20% 算「久远」 |
34
+ | `clusterMinShared` | 2 | 共享 2 个关键词才算内容相近 |
35
+ | `digestMaxChars` | 220 | 一本浓缩书的字数上限 |
36
+ | `compactEveryMinutes` | 30 | 自动压缩间隔,0 = 只手动 |
37
+ | `injectEnabled` | true | 每次请求前自动检索 |
38
+ | `injectMaxChars` | 1500 | 注入上限,防止撑爆提示词 |
39
+
40
+ ## 压缩是怎么工作的
41
+
42
+ ```
43
+ 久远 = 从最早一本书到现在的时间跨度 × oldFraction
44
+ ```
45
+
46
+ 时间轴越长,久远区越大:10 天取前两天,30 天取前 6 天。
47
+
48
+ 久远区里内容相近的书(共享 ≥2 个关键词)合成一本浓缩书;
49
+ **原书不删**,标记 `archived` 留在文件里当证据,但退出检索。
50
+
51
+ ## 常见问题
52
+
53
+ **记不住东西?**
54
+ 看 `书.jsonl` 有没有增长。没增长说明插件没加载 —— 查 DSH 日志里有没有 `[bookmemory] 已加载`。
55
+
56
+ **记了一堆废话?**
57
+ 调小 `injectMaxChars` 没用,那是注入预算。真正的杠杆是锚点判据 —— 现在只要出现「决定/以后/记住/名字/定稿」这类词才落锚,觉得太松就自己清 `锚点.jsonl`。
58
+
59
+ **搜不到?**
60
+ 先确认那句话里的词确实在两本书里出现过。分词对专有名词友好(牵丝戏、纳西妲),对人名和生僻词一般。
package/README.en.md ADDED
@@ -0,0 +1,74 @@
1
+ # dsh-bookmemory
2
+
3
+ Long-term memory for DSH: **every conversation becomes a book, keywords become the catalog,
4
+ and older books are automatically condensed into one.**
5
+
6
+ ## What problem it solves
7
+
8
+ A model has no memory. Start a new session and everything before it is gone.
9
+
10
+ This plugin builds a library on disk:
11
+
12
+ ```
13
+ 书.jsonl every exchange, one line each, never overwritten
14
+ 目录.json keyword -> book id (inverted index)
15
+ 锚点.jsonl distilled "worth remembering" facts
16
+ 策略.json compaction rules, editable
17
+ ```
18
+
19
+ Before each model request the plugin searches the catalog with the user's latest message
20
+ and injects the matching old conversations and anchors into the system prompt.
21
+ After the exchange it writes a new book — automatically, not because the model asked.
22
+
23
+ ## Why it is worth it
24
+
25
+ - **No retraining** — memory is retrieved, not baked into weights
26
+ - **Readable and editable** — plain text; delete a line and that memory is gone
27
+ - **Self-shortening** — distant books are condensed by topic; originals are archived, not deleted,
28
+ so an old library never slows retrieval down
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ # If PowerShell's execution policy blocks npm.ps1, go through cmd:
34
+ cmd /c "npm install -g dsh-bookmemory"
35
+
36
+ # or add it to a profile
37
+ dsh plugin --profile web add dsh-bookmemory
38
+ ```
39
+
40
+ Windows alternative (creates a directory junction + one patch row, backs up the patch first):
41
+
42
+ ```powershell
43
+ powershell -ExecutionPolicy Bypass -File tools\install.ps1
44
+ powershell -ExecutionPolicy Bypass -File tools\install.ps1 -ProfileName desktop
45
+ ```
46
+
47
+ ## Settings
48
+
49
+ | Key | Default | Meaning |
50
+ |---|---|---|
51
+ | `dataDir` | `~/.dsh-bookmemory` | where the library lives |
52
+ | `oldFraction` | `0.2` | the first 20% of the time span counts as "distant" |
53
+ | `clusterMinShared` | `2` | books sharing 2+ keywords are considered related |
54
+ | `digestMaxChars` | `220` | length cap for one condensed book |
55
+ | `compactEveryMinutes` | `30` | auto-compaction interval, 0 disables |
56
+ | `injectEnabled` | `true` | search and inject before each request |
57
+ | `injectMaxChars` | `1500` | injection budget |
58
+
59
+ ## Two tools
60
+
61
+ - `bookmemory_recall` — search old conversations and anchors
62
+ - `bookmemory_remember` — write one anchor worth keeping
63
+
64
+ ## Chinese tokenization
65
+
66
+ Uses `@node-rs/jieba` (prebuilt binary — no compiler, no Python, works on `npm install`).
67
+
68
+ > Node's built-in `Intl.Segmenter` was tried first and rejected: it splits Chinese into
69
+ > **single characters** (`牵丝戏` → `牵/丝/戏`). In a 12-sentence test, 50 keywords collapsed
70
+ > to 3 and all 10 proper nouns were lost.
71
+
72
+ ## License
73
+
74
+ MIT
package/README.md CHANGED
@@ -1,3 +1,57 @@
1
- # Temporary Holding Version
1
+ # dsh-bookmemory
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
+ 给 DSH 的长期记忆:**把每一次对话存成一本书,用关键词做目录;久远的书自动浓缩成一本。**
4
+
5
+ ## 它解决什么
6
+
7
+ 模型没有记忆。每开一个新会话,之前发生的一切都归零。
8
+
9
+ 这个插件在磁盘上建一个图书馆:
10
+
11
+ ```
12
+ 书.jsonl 每次对话的原文,一条一行,永不覆盖
13
+ 目录.json 关键词 -> 书号(倒排索引)
14
+ 锚点.jsonl 提炼过的「值得记」的事,少而重
15
+ 策略.json 压缩规则,可改
16
+ ```
17
+
18
+ 每次对话前拿用户那句话去查目录,命中的旧对话和锚点一起塞进上下文;
19
+ 对话结束后自动入库,抽关键词,必要时落一个锚点。
20
+
21
+ ## 为什么值得
22
+
23
+ - **不用模型重训**:记忆是检索出来的,不是塞进权重的
24
+ - **可读可改**:全是纯文本,用记事本打开就能看,删一个文件就忘记一件事
25
+ - **会自己变短**:久远的书按主题浓缩成一本,原书归档不删——时间轴越长也不会拖慢检索
26
+
27
+ ## 安装
28
+
29
+ ```bash
30
+ # npm 被 PowerShell 执行策略挡着的时候,走 cmd
31
+ cmd /c "npm install dsh-bookmemory"
32
+ ```
33
+
34
+ 装完在 DSH 设置页里能看到 `bookmemory` 的卡片,改数据目录、久远比例这些。
35
+
36
+ ## 配置项
37
+
38
+ | 项 | 默认 | 说明 |
39
+ |---|---|---|
40
+ | `dataDir` | `~/.dsh-bookmemory` | 数据目录,整个拷走就是备份 |
41
+ | `oldFraction` | `0.2` | 时间轴前 20% 算「久远」 |
42
+ | `clusterMinShared` | `2` | 共享 2 个关键词才算内容相近 |
43
+ | `digestMaxChars` | `220` | 一本浓缩书的字数上限 |
44
+ | `compactEveryMinutes` | `30` | 自动压缩的检查间隔,0 = 只手动 |
45
+ | `injectEnabled` | `true` | 每次对话前自动检索 |
46
+ | `injectMaxChars` | `1500` | 注入的上限,防止撑爆提示词 |
47
+
48
+ ## 中文分词
49
+
50
+ 用 `@node-rs/jieba`(预编译二进制,装完即用,不需要编译环境和 Python)。
51
+
52
+ > 试过 Node 内置的 `Intl.Segmenter`:它把中文按**单字**切(牵/丝/戏),
53
+ > 12 个测试句子里 50 个关键词只剩 3 个,10 个专有名词全丢。不可用。
54
+
55
+ ## 许可
56
+
57
+ MIT
package/entry.js ADDED
@@ -0,0 +1,327 @@
1
+ // dsh-bookmemory — Host half.
2
+ //
3
+ // 把每一次对话存成一本「书」,用关键词做倒排目录;久远的书按主题浓缩成一本,
4
+ // 原书归档不删。每次模型请求前,拿用户这句话去查目录,把命中的旧对话和锚点
5
+ // 拼成一段上下文注入系统提示词。
6
+ //
7
+ // 三个扩展点(都从 DSH 内部包的源码里确认过):
8
+ // ctx.systemPrompt.variable(name, provider) provider 每次组装都被调用,参数是 { agent, scope, signal }
9
+ // ctx.systemPrompt.section({ name, order, text }) 静态小节,可用 {{变量}}
10
+ // ctx.tools.register(defineTool({ ... })) 模型可调用的工具
11
+ //
12
+ // 写回不靠事件:每次注入时扫一遍会话历史,把「上一条没记过的问答」补记进去。
13
+ // 这样不依赖任何事件 API 的时序,最坏情况也只是晚一轮入库。
14
+
15
+ import z from '@deepseek-ai/schemastery'
16
+ import { defineTool } from '@deepseek-ai/dsh-tools'
17
+ import { homedir } from 'node:os'
18
+ import { join } from 'node:path'
19
+ import { Store } from './lib/store.js'
20
+
21
+ const DEFAULT_DIR = join(homedir(), '.dsh-bookmemory')
22
+
23
+ // 只有标了 .volatile() 的字段才会出现在设置页
24
+ const LIVE_CAPABLE = typeof z.boolean().volatile === 'function'
25
+ const live = (schema) => (LIVE_CAPABLE ? schema.volatile() : schema)
26
+
27
+ export const Config = z.object({
28
+ enabled: live(z.boolean().default(true)),
29
+ dataDir: live(z.string().default(DEFAULT_DIR)),
30
+ oldFraction: live(z.number().min(0.05).max(0.5).default(0.2)),
31
+ clusterMinShared: live(z.number().min(1).max(10).default(2)),
32
+ digestMaxChars: live(z.number().min(60).max(800).default(220)),
33
+ compactEveryMinutes: live(z.number().min(0).max(1440).default(30)),
34
+ injectEnabled: live(z.boolean().default(true)),
35
+ injectMaxChars: live(z.number().min(200).max(8000).default(1500)),
36
+ })
37
+
38
+ export const name = 'bookmemory'
39
+ export const inject = ['systemPrompt', 'tools']
40
+
41
+ // ───────────────────────── 读配置 ─────────────────────────
42
+ //
43
+ // `.volatile()` 不产出值,产出的是一个稳定引用:只有标了 volatile 的字段才会
44
+ // 出现在设置页上,而设置页改完之后要能立刻生效——引用不变、值在变。
45
+ // 所以必须用 get() 读,直接 `config.dataDir` 拿到的是引用对象不是字符串。
46
+ // (载入时没走 schema 解析的配置是普通值,两种形状都要认。)
47
+
48
+ const VOLATILE_REF = Symbol.for('cosmokit.volatile.write')
49
+
50
+ function isLive(value) {
51
+ return typeof value === 'object' && value !== null && VOLATILE_REF in value
52
+ }
53
+
54
+ function readField(config, key, fallback) {
55
+ const raw = config?.[key]
56
+ const value = isLive(raw) && typeof raw.get === 'function' ? raw.get() : raw
57
+ return value === undefined || value === null ? fallback : value
58
+ }
59
+
60
+ // ───────────────────────── 会话历史里取文字 ─────────────────────────
61
+
62
+ // harness 会把运行时上下文(时间戳、工具说明、系统提醒)当成 user 消息注入,
63
+ // 这些不是他说的话,不能记进图书馆,也不能拿去检索。
64
+ const INJECTED_PREFIX = [
65
+ 'Time sampled while preparing',
66
+ 'Current runtime context',
67
+ 'Tokens prefixed with @',
68
+ 'The runtime context names',
69
+ '<system-reminder>',
70
+ '### Updated Tool Schemas',
71
+ '[SCHEDULE REMINDER]',
72
+ ]
73
+
74
+ function looksInjected(text) {
75
+ const head = String(text || '').trimStart().slice(0, 60)
76
+ return INJECTED_PREFIX.some((p) => head.startsWith(p) || head.includes(p))
77
+ }
78
+
79
+ /** 从消息对象里抽纯文本。消息的 content 是分段数组,只取 text 段。 */
80
+ function messageText(msg) {
81
+ if (!msg) return ''
82
+ if (typeof msg.text === 'string') return msg.text.trim()
83
+ const parts = Array.isArray(msg.content) ? msg.content : []
84
+ return parts
85
+ .filter((p) => p && typeof p.text === 'string')
86
+ .map((p) => p.text)
87
+ .join('\n')
88
+ .trim()
89
+ }
90
+
91
+ /** 会话里最近的「用户 + 助手」一对,没有助手回复就返回 null。 */
92
+ function lastPair(agent) {
93
+ let messages = []
94
+ try {
95
+ messages = agent?.session?.deriveMessages?.() ?? []
96
+ } catch {
97
+ return null
98
+ }
99
+ for (let i = messages.length - 1; i > 0; i--) {
100
+ const m = messages[i]
101
+ if (m?.role !== 'assistant') continue
102
+ const reply = messageText(m)
103
+ if (!reply || looksInjected(reply)) continue
104
+ for (let j = i - 1; j >= 0; j--) {
105
+ if (messages[j]?.role !== 'user') continue
106
+ const user = messageText(messages[j])
107
+ if (user && !looksInjected(user)) return { user, reply }
108
+ break
109
+ }
110
+ }
111
+ return null
112
+ }
113
+
114
+ /** 会话里最后一句真正的用户消息(用于检索)。 */
115
+ function lastUserText(agent) {
116
+ let messages = []
117
+ try {
118
+ messages = agent?.session?.deriveMessages?.() ?? []
119
+ } catch {
120
+ return ''
121
+ }
122
+ for (let i = messages.length - 1; i >= 0; i--) {
123
+ if (messages[i]?.role !== 'user') continue
124
+ const t = messageText(messages[i])
125
+ if (t && !looksInjected(t)) return t
126
+ }
127
+ return ''
128
+ }
129
+
130
+ export function apply(ctx, config) {
131
+ if (readField(config, 'enabled', true) === false) return
132
+
133
+ const store = new Store(readField(config, 'dataDir', DEFAULT_DIR))
134
+
135
+ // 策略随时可能被设置页改,所以每次压缩前重新读一遍
136
+ const readPolicy = () => ({
137
+ oldFraction: readField(config, 'oldFraction', 0.2),
138
+ clusterMinShared: readField(config, 'clusterMinShared', 2),
139
+ digestMaxChars: readField(config, 'digestMaxChars', 220),
140
+ })
141
+
142
+ // 已入库的「会话 -> 已记到的问答」标记,防止重复入库
143
+ const recorded = new WeakMap()
144
+
145
+ /** 把上一条没记过的问答补进图书馆。 */
146
+ const flushPending = async (agent) => {
147
+ const session = agent?.session
148
+ if (!session) return
149
+ const pair = lastPair(agent)
150
+ if (!pair) return
151
+ const stamp = pair.user.slice(0, 80) + '|' + pair.reply.slice(0, 80)
152
+ if (recorded.get(session) === stamp) return
153
+ recorded.set(session, stamp)
154
+ await store.addBook({ text: pair.user, reply: pair.reply })
155
+ }
156
+
157
+ // ── 1) 注入:每次组系统提示词时,拿最后一句用户消息去查 ──
158
+ ctx.systemPrompt.variable('bookmemory', async (assemblyCtx) => {
159
+ if (readField(config, 'injectEnabled', true) === false) return ''
160
+ const agent = assemblyCtx?.agent
161
+ const query = lastUserText(agent)
162
+ if (!query) return ''
163
+
164
+ // 顺便把上一轮补记(不阻塞:失败了下次再记)
165
+ flushPending(agent).catch(() => {})
166
+
167
+ const [hits, anchors] = await Promise.all([
168
+ store.search(query, 5).catch(() => []),
169
+ store.relatedAnchors(query, 3).catch(() => []),
170
+ ])
171
+ if (!hits.length && !anchors.length) return ''
172
+
173
+ const lines = ['【记忆·图书馆】']
174
+ if (anchors.length) {
175
+ lines.push('相关锚点:')
176
+ for (const a of anchors) lines.push(`- ${a.date} ${a.title}:${a.what}`)
177
+ }
178
+ if (hits.length) {
179
+ lines.push('相关的旧对话:')
180
+ for (const h of hits) {
181
+ const who = h.book.role === '我' ? '我说' : '他说'
182
+ const snippet = String(h.book.text || '').replace(/\s+/g, ' ').slice(0, 60)
183
+ lines.push(`- ${h.book.date} ${who}:${snippet}`)
184
+ }
185
+ }
186
+ lines.push('(这些是检索出来的旧记录,不是他刚说的话。)')
187
+
188
+ const text = lines.join('\n')
189
+ const maxChars = readField(config, 'injectMaxChars', 1500)
190
+ return text.length > maxChars ? text.slice(0, maxChars) + '…' : text
191
+ })
192
+
193
+ ctx.systemPrompt.section({
194
+ name: 'bookmemory',
195
+ order: 900,
196
+ text: '{{bookmemory}}',
197
+ })
198
+
199
+ // ── 2) 工具:模型可以主动查和记 ──
200
+ ctx.tools.register(
201
+ defineTool({
202
+ name: 'bookmemory_recall',
203
+ description:
204
+ '在长期记忆里检索旧对话和锚点。当用户提到"之前""上次""以前说过"这类指代,' +
205
+ '或者需要确认历史事实时调用。返回命中的记录摘要。',
206
+ parameters: {
207
+ query: { type: 'string', required: true, description: '要检索的内容,用用户的原话或关键词' },
208
+ limit: { type: 'number', description: '返回几条,默认 5' },
209
+ },
210
+ output: {
211
+ schema: {
212
+ type: 'object',
213
+ additionalProperties: false,
214
+ properties: {
215
+ found: { type: 'number', required: true },
216
+ entries: {
217
+ type: 'array',
218
+ required: true,
219
+ items: {
220
+ type: 'object',
221
+ additionalProperties: false,
222
+ properties: {
223
+ date: { type: 'string', required: true },
224
+ text: { type: 'string', required: true },
225
+ reply: { type: 'string', required: true },
226
+ },
227
+ },
228
+ },
229
+ anchors: {
230
+ type: 'array',
231
+ required: true,
232
+ items: {
233
+ type: 'object',
234
+ additionalProperties: false,
235
+ properties: {
236
+ date: { type: 'string', required: true },
237
+ title: { type: 'string', required: true },
238
+ what: { type: 'string', required: true },
239
+ },
240
+ },
241
+ },
242
+ },
243
+ },
244
+ // 界面上怎么显示这次调用的结果(必填,缺了会在渲染阶段报 userRender is not a function)
245
+ render: (_args, value) => [
246
+ {
247
+ type: 'text',
248
+ text:
249
+ `图书馆检索:命中 ${value.found} 条` +
250
+ (value.entries.length ? `,其中旧对话 ${value.entries.length} 条` : '') +
251
+ (value.anchors.length ? `,锚点 ${value.anchors.length} 条` : ''),
252
+ },
253
+ ],
254
+ },
255
+ async execute(args) {
256
+ const limit = Math.min(Number(args.limit) || 5, 20)
257
+ const [hits, anchors] = await Promise.all([
258
+ store.search(args.query, limit),
259
+ store.relatedAnchors(args.query, 5),
260
+ ])
261
+ return {
262
+ found: hits.length + anchors.length,
263
+ entries: hits.map((h) => ({
264
+ date: h.book.date,
265
+ text: String(h.book.text || '').slice(0, 300),
266
+ reply: String(h.book.reply || '').slice(0, 300),
267
+ })),
268
+ anchors: anchors.map((a) => ({
269
+ date: a.date,
270
+ title: a.title,
271
+ what: String(a.what || '').slice(0, 300),
272
+ })),
273
+ }
274
+ },
275
+ }),
276
+ )
277
+
278
+ ctx.tools.register(
279
+ defineTool({
280
+ name: 'bookmemory_remember',
281
+ description:
282
+ '把一个值得长期记住的事实、决定或约定写进图书馆。只记确实需要以后查的东西,' +
283
+ '不要用它记日常寒暄。',
284
+ parameters: {
285
+ text: { type: 'string', required: true, description: '要记住的内容,一句话说清' },
286
+ why: { type: 'string', description: '为什么值得记' },
287
+ },
288
+ output: {
289
+ schema: {
290
+ type: 'object',
291
+ additionalProperties: false,
292
+ properties: {
293
+ id: { type: 'string', required: true },
294
+ },
295
+ },
296
+ render: (args, value) => [
297
+ { type: 'text', text: `已记进图书馆(${value.id}):${String(args.text || '').slice(0, 60)}` },
298
+ ],
299
+ },
300
+ async execute(args) {
301
+ const anchor = await store.addAnchor({
302
+ title: String(args.text).slice(0, 24),
303
+ what: args.text,
304
+ why: args.why || '',
305
+ })
306
+ return { id: anchor.id }
307
+ },
308
+ }),
309
+ )
310
+
311
+ // ── 3) 定时压缩 ──
312
+ const compactEvery = readField(config, 'compactEveryMinutes', 30)
313
+ if (compactEvery > 0) {
314
+ const ms = compactEvery * 60 * 1000
315
+ const timer = setInterval(() => {
316
+ store
317
+ .compact(readPolicy())
318
+ .then((made) => {
319
+ for (const m of made) ctx.logger?.info?.('[bookmemory] 浓缩 %d 本 -> %s', m.ids.length, m.book.id)
320
+ })
321
+ .catch((err) => ctx.logger?.warn?.('[bookmemory] 压缩失败: %s', err?.message || err))
322
+ }, ms)
323
+ timer.unref?.()
324
+ }
325
+
326
+ ctx.logger?.info?.('[bookmemory] 已加载,数据目录 %s', store.dir)
327
+ }
package/lib/store.js ADDED
@@ -0,0 +1,334 @@
1
+ // 图书馆:书 / 目录 / 锚点 / 压缩。
2
+ //
3
+ // 数据全在 dataDir 里,四个纯文本文件:
4
+ // 书.jsonl 对话原文,一条一行,永不覆盖;归档的加 "archived": true
5
+ // 目录.json 关键词 -> 书号(倒排索引),归档书不进目录
6
+ // 锚点.jsonl 提炼过的「值得记」的事
7
+ // 策略.json 压缩规则
8
+ //
9
+ // 检索打分:Σ 1/(1+0.2*该词条下的书数) —— 越少见的词命中越值钱。
10
+
11
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
12
+ import { existsSync } from 'node:fs'
13
+ import { join } from 'node:path'
14
+ import { tokenize } from './tokenize.js'
15
+
16
+ const DEFAULT_POLICY = {
17
+ oldFraction: 0.2,
18
+ clusterMinShared: 2,
19
+ digestMaxChars: 220,
20
+ }
21
+
22
+ function now() {
23
+ const d = new Date()
24
+ const p = (n) => String(n).padStart(2, '0')
25
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
26
+ }
27
+
28
+ function today() {
29
+ return now().slice(0, 10)
30
+ }
31
+
32
+ function bookId() {
33
+ return 'bk_' + Math.random().toString(36).slice(2, 10).toUpperCase()
34
+ }
35
+
36
+ function anchorId() {
37
+ return 'an_' + Math.random().toString(36).slice(2, 12).toUpperCase()
38
+ }
39
+
40
+ export class Store {
41
+ /** @param {string} dir */
42
+ constructor(dir) {
43
+ this.dir = dir
44
+ this.booksPath = join(dir, '书.jsonl')
45
+ this.catalogPath = join(dir, '目录.json')
46
+ this.anchorsPath = join(dir, '锚点.jsonl')
47
+ this.policyPath = join(dir, '策略.json')
48
+ this._catalog = null
49
+ }
50
+
51
+ async ensure() {
52
+ await mkdir(this.dir, { recursive: true })
53
+ if (!existsSync(this.policyPath)) {
54
+ await writeFile(this.policyPath, JSON.stringify(DEFAULT_POLICY, null, 2), 'utf8')
55
+ }
56
+ }
57
+
58
+ // ───────── 基础读写 ─────────
59
+
60
+ async _readJsonl(path) {
61
+ if (!existsSync(path)) return []
62
+ const text = await readFile(path, 'utf8')
63
+ const out = []
64
+ for (const line of text.split('\n')) {
65
+ const t = line.trim()
66
+ if (!t) continue
67
+ try { out.push(JSON.parse(t)) } catch { /* 坏行跳过,不让一条脏数据毁掉整个图书馆 */ }
68
+ }
69
+ return out
70
+ }
71
+
72
+ /** 原子写:先写 .tmp 再 rename,断电也不会写坏半个文件。 */
73
+ async _atomicWrite(path, text) {
74
+ const tmp = path + '.tmp'
75
+ await writeFile(tmp, text, 'utf8')
76
+ await rename(tmp, path)
77
+ }
78
+
79
+ async loadBooks() {
80
+ return this._readJsonl(this.booksPath)
81
+ }
82
+
83
+ async loadAnchors() {
84
+ return this._readJsonl(this.anchorsPath)
85
+ }
86
+
87
+ async loadPolicy() {
88
+ try {
89
+ const raw = await readFile(this.policyPath, 'utf8')
90
+ return { ...DEFAULT_POLICY, ...JSON.parse(raw) }
91
+ } catch {
92
+ return { ...DEFAULT_POLICY }
93
+ }
94
+ }
95
+
96
+ async loadCatalog() {
97
+ if (this._catalog) return this._catalog
98
+ try {
99
+ this._catalog = JSON.parse(await readFile(this.catalogPath, 'utf8'))
100
+ } catch {
101
+ this._catalog = {}
102
+ }
103
+ return this._catalog
104
+ }
105
+
106
+ async _saveCatalog(catalog) {
107
+ this._catalog = catalog
108
+ await this._atomicWrite(this.catalogPath, JSON.stringify(catalog, null, 1))
109
+ }
110
+
111
+ async _appendJsonl(path, obj) {
112
+ const line = JSON.stringify(obj) + '\n'
113
+ const { appendFile } = await import('node:fs/promises')
114
+ await appendFile(path, line, 'utf8')
115
+ }
116
+
117
+ // ───────── 写书 ─────────
118
+
119
+ /**
120
+ * 存一次对话。
121
+ * @param {{text: string, reply?: string, role?: string, tags?: string[]}} input
122
+ */
123
+ async addBook({ text, reply = '', role = '他', tags = [] }) {
124
+ await this.ensure()
125
+ const book = {
126
+ id: bookId(),
127
+ date: today(),
128
+ at: now(),
129
+ role,
130
+ text: String(text || ''),
131
+ reply: String(reply || ''),
132
+ tags,
133
+ keys: tokenize(`${text || ''} ${reply || ''}`),
134
+ }
135
+ await this._appendJsonl(this.booksPath, book)
136
+
137
+ const catalog = await this.loadCatalog()
138
+ for (const k of book.keys) {
139
+ if (!catalog[k]) catalog[k] = []
140
+ if (!catalog[k].includes(book.id)) catalog[k].push(book.id)
141
+ }
142
+ await this._saveCatalog(catalog)
143
+ return book
144
+ }
145
+
146
+ async addAnchor({ title, what, why = '', tags = [] }) {
147
+ await this.ensure()
148
+ const anchor = {
149
+ id: anchorId(),
150
+ date: today(),
151
+ at: now(),
152
+ title: String(title || '').slice(0, 40),
153
+ what: String(what || '').slice(0, 400),
154
+ why: String(why || '').slice(0, 400),
155
+ tags,
156
+ }
157
+ await this._appendJsonl(this.anchorsPath, anchor)
158
+ return anchor
159
+ }
160
+
161
+ // ───────── 检索 ─────────
162
+
163
+ /**
164
+ * 给一句话,返回最相关的几本书(归档的不参与)。
165
+ * @param {string} query
166
+ * @param {number} limit
167
+ */
168
+ async search(query, limit = 6) {
169
+ const catalog = await this.loadCatalog()
170
+ const books = await this.loadBooks()
171
+ const byId = new Map(books.filter((b) => !b.archived).map((b) => [b.id, b]))
172
+ const qkeys = tokenize(query)
173
+
174
+ const score = new Map()
175
+ const hits = new Map()
176
+ for (const k of qkeys) {
177
+ const ids = catalog[k]
178
+ if (!ids || !ids.length) continue
179
+ const weight = 1 / (1 + 0.2 * ids.length)
180
+ for (const id of ids) {
181
+ if (!byId.has(id)) continue
182
+ score.set(id, (score.get(id) || 0) + weight)
183
+ if (!hits.has(id)) hits.set(id, [])
184
+ hits.get(id).push(k)
185
+ }
186
+ }
187
+
188
+ return [...score.entries()]
189
+ .sort((a, b) => b[1] - a[1])
190
+ .slice(0, limit)
191
+ .map(([id, s]) => ({ book: byId.get(id), score: Number(s.toFixed(2)), hit: hits.get(id) || [] }))
192
+ }
193
+
194
+ /** 顺带查锚点:锚点更浓缩,命中一两句就够。 */
195
+ async relatedAnchors(query, limit = 5) {
196
+ const anchors = await this.loadAnchors()
197
+ const qkeys = new Set(tokenize(query))
198
+ const scored = []
199
+ for (const a of anchors) {
200
+ const blob = new Set(tokenize(`${a.title} ${a.what} ${a.why}`))
201
+ let common = 0
202
+ for (const k of qkeys) if (blob.has(k)) common++
203
+ if (common > 0) scored.push([common, a])
204
+ }
205
+ scored.sort((x, y) => y[0] - x[0])
206
+ return scored.slice(0, limit).map(([, a]) => a)
207
+ }
208
+
209
+ // ───────── 压缩 ─────────
210
+
211
+ /**
212
+ * 把「久远且相近」的书合成浓缩书,原书标 archived(不删,留作证据)。
213
+ * @param {{oldFraction:number, clusterMinShared:number, digestMaxChars:number}} policy
214
+ * @param {boolean} dry 只算不写
215
+ */
216
+ async compact(policy, dry = false) {
217
+ const pol = { ...DEFAULT_POLICY, ...(policy || {}) }
218
+ const all = await this.loadBooks()
219
+ const live = all.filter((b) => !b.archived && b.kind !== 'condensed')
220
+ if (live.length < 4) return []
221
+
222
+ const ts = (b) => {
223
+ const t = Date.parse((b.at || '').replace(' ', 'T'))
224
+ return Number.isFinite(t) ? t : Date.now()
225
+ }
226
+ const stamps = live.map(ts)
227
+ const t0 = Math.min(...stamps)
228
+ const t1 = Date.now()
229
+ if (t1 - t0 < 10 * 60 * 1000) return [] // 跨度不到 10 分钟,谈不上久远
230
+
231
+ const cutoff = t0 + (t1 - t0) * pol.oldFraction
232
+ const old = live.filter((b) => ts(b) <= cutoff)
233
+ if (old.length < 2) return []
234
+
235
+ // 贪心聚类:每本找共享词最多的那堆,够不上就自己开一堆;再两两合并
236
+ const clusters = []
237
+ for (const b of old.sort((x, y) => ts(x) - ts(y))) {
238
+ const keys = new Set(b.keys || [])
239
+ let best = null
240
+ let bestShared = 0
241
+ for (const c of clusters) {
242
+ let shared = 0
243
+ for (const k of keys) if (c.keys.has(k)) shared++
244
+ if (shared > bestShared) { best = c; bestShared = shared }
245
+ }
246
+ if (best && bestShared >= pol.clusterMinShared) {
247
+ best.items.push(b)
248
+ for (const k of keys) best.keys.add(k)
249
+ } else {
250
+ clusters.push({ keys: new Set(keys), items: [b] })
251
+ }
252
+ }
253
+ let merged = true
254
+ while (merged) {
255
+ merged = false
256
+ outer: for (let i = 0; i < clusters.length; i++) {
257
+ for (let j = i + 1; j < clusters.length; j++) {
258
+ let shared = 0
259
+ for (const k of clusters[i].keys) if (clusters[j].keys.has(k)) shared++
260
+ if (shared >= pol.clusterMinShared) {
261
+ clusters[i].items.push(...clusters[j].items)
262
+ for (const k of clusters[j].keys) clusters[i].keys.add(k)
263
+ clusters.splice(j, 1)
264
+ merged = true
265
+ break outer
266
+ }
267
+ }
268
+ }
269
+ }
270
+
271
+ const made = []
272
+ for (const c of clusters) {
273
+ if (c.items.length < 2) continue
274
+ const ids = c.items.map((b) => b.id)
275
+ const dates = [...new Set(c.items.map((b) => b.date))].sort()
276
+ const oneLine = (b) => {
277
+ let t = String(b.text || '').replace(/https?:\/\/\S+/g, '(链接)').replace(/\s+/g, ' ').trim()
278
+ return t.length > 28 ? t.slice(0, 28) + '…' : t
279
+ }
280
+ let summary = c.items.map(oneLine).join(';')
281
+ if (summary.length > pol.digestMaxChars) summary = summary.slice(0, pol.digestMaxChars) + '…'
282
+ made.push({
283
+ id: bookId(),
284
+ date: dates.length === 1 ? dates[0] : `${dates[0]}~${dates[dates.length - 1]}`,
285
+ at: now(),
286
+ role: '浓缩',
287
+ kind: 'condensed',
288
+ covers: ids,
289
+ text: `(${c.items.length} 次对话浓缩)${summary}`,
290
+ reply: '',
291
+ tags: ['浓缩'],
292
+ keys: [...c.keys].slice(0, 24),
293
+ })
294
+ }
295
+
296
+ if (!made.length || dry) return made.map((b) => ({ book: b, ids: b.covers }))
297
+
298
+ // 写新书
299
+ for (const b of made) await this._appendJsonl(this.booksPath, b)
300
+
301
+ // 标记归档:重写整个文件
302
+ const archivedIds = new Set(made.flatMap((b) => b.covers))
303
+ const rows = []
304
+ for (const b of await this.loadBooks()) {
305
+ rows.push(JSON.stringify(archivedIds.has(b.id) ? { ...b, archived: true } : b))
306
+ }
307
+ await this._atomicWrite(this.booksPath, rows.join('\n') + '\n')
308
+
309
+ // 重建目录:归档书不进目录
310
+ const catalog = {}
311
+ for (const b of await this.loadBooks()) {
312
+ if (b.archived) continue
313
+ for (const k of b.keys || []) {
314
+ if (!catalog[k]) catalog[k] = []
315
+ if (!catalog[k].includes(b.id)) catalog[k].push(b.id)
316
+ }
317
+ }
318
+ await this._saveCatalog(catalog)
319
+
320
+ return made.map((b) => ({ book: b, ids: b.covers }))
321
+ }
322
+
323
+ async stats() {
324
+ const books = await this.loadBooks()
325
+ const catalog = await this.loadCatalog()
326
+ const anchors = await this.loadAnchors()
327
+ return {
328
+ books: books.filter((b) => !b.archived).length,
329
+ archived: books.filter((b) => b.archived).length,
330
+ keywords: Object.keys(catalog).length,
331
+ anchors: anchors.length,
332
+ }
333
+ }
334
+ }
@@ -0,0 +1,42 @@
1
+ // 中文分词:@node-rs/jieba(预编译二进制,装完即用,不需要编译环境和 Python)
2
+ //
3
+ // 为什么不用 Node 内置的 Intl.Segmenter:它把中文按单字切。
4
+ // 实测:'牵丝戏' → 牵/丝/戏,12 个句子里 50 个关键词只剩 3 个,10 个专有名词全丢。
5
+ //
6
+ // 注意这个包是 CommonJS,只能默认导入:
7
+ // import jieba from '@node-rs/jieba' ✅ 自带你需要的内置词典
8
+ // import { Jieba } from '@node-rs/jieba' ❌ 具名导入在 ESM 下会报错
9
+
10
+ import jieba from '@node-rs/jieba'
11
+
12
+ // 高频虚词、代词、万能动词——做目录没有区分度
13
+ const STOP = new Set(`
14
+ 的 了 是 在 我 你 他 她 它 们 这 那 有 和 与 就 都 也 还 又 把 被 让 给 从 到 对 为 以
15
+ 一个 一下 什么 怎么 为什么 可以 现在 然后 但是 因为 所以 如果 就是 不是 没有 已经
16
+ 说 做 去 来 要 会 能 想 看 听 好 不 太 很 更 最 再 才 只 些 个 上 下 里 外 时
17
+ 啊 吧 呢 吗 呀 哦 嗯 哈 么 着 过 地 得 我们 你们 他们 自己 这个 那个 这样 那样
18
+ `.trim().split(/\s+/))
19
+
20
+ const URL_RE = /https?:\/\/\S+/g
21
+ // 只丢「纯标点 / 纯符号」。不能写成 [\W_]:JS 的 \W 不认中文,会把汉字全过滤掉。
22
+ const NON_WORD_RE = /^[\p{P}\p{S}\p{Z}\s]+$/u
23
+
24
+ /**
25
+ * 把一段话切成关键词:去 URL、切词、丢停用词和单字、去重。
26
+ * @param {string} text
27
+ * @returns {string[]}
28
+ */
29
+ export function tokenize(text) {
30
+ if (!text) return []
31
+ const clean = String(text).replace(URL_RE, ' ')
32
+ const words = jieba.cut(clean, true) // true = 精确模式
33
+ const out = []
34
+ const seen = new Set()
35
+ for (const raw of words) {
36
+ const w = String(raw).trim()
37
+ if (w.length < 2 || STOP.has(w) || seen.has(w) || NON_WORD_RE.test(w)) continue
38
+ seen.add(w)
39
+ out.push(w)
40
+ }
41
+ return out
42
+ }
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
- {
2
- "name": "dsh-bookmemory",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "dsh-bookmemory",
3
+ "version": "0.1.0",
4
+ "description": "把每次对话存成一本「书」,用关键词做目录;久远的书自动浓缩成一本。给 DSH 的长期记忆插件。",
5
+ "license": "MIT",
6
+ "author": "s1770",
7
+ "type": "module",
8
+ "main": "entry.js",
9
+ "exports": {
10
+ ".": "./entry.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "entry.js",
15
+ "lib/",
16
+ "tools/install.ps1",
17
+ "tools/clean_noise.py",
18
+ "README.md",
19
+ "README.en.md",
20
+ "MANUAL.md",
21
+ "INSTALL.md",
22
+ "LICENSE"
23
+ ],
24
+ "keywords": [
25
+ "dsh",
26
+ "dsh-plugin",
27
+ "memory",
28
+ "rag",
29
+ "chinese"
30
+ ],
31
+ "engines": {
32
+ "node": "\u003e=18"
33
+ },
34
+ "dependencies": {
35
+ "@node-rs/jieba": "^1.10.0"
36
+ },
37
+ "peerDependencies": {
38
+ "@deepseek-ai/schemastery": "*"
39
+ },
40
+ "scripts": {
41
+ "check": "node --check entry.js",
42
+ "test": "node test/smoke.mjs"
43
+ },
44
+ "devDependencies": {
45
+ "@deepseek-ai/dsh-tools": "^0.2.1-rc.2",
46
+ "@deepseek-ai/schemastery": "^3.18.5"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/s17700958289-ship-it/dsh-bookmemory.git"
51
+ },
52
+ "homepage": "https://github.com/s17700958289-ship-it/dsh-bookmemory#readme",
53
+ "bugs": {
54
+ "url": "https://github.com/s17700958289-ship-it/dsh-bookmemory/issues"
55
+ }
56
+ }
@@ -0,0 +1,72 @@
1
+ """清掉被 harness 注入上下文污染的旧书,并重建目录。
2
+
3
+ 装插件初期「注入的运行时上下文」被当成了用户消息记进书里,
4
+ 这些记录没有价值,而且它们的英文词会污染关键词目录。
5
+ """
6
+
7
+ import json
8
+ import os
9
+
10
+ DIR = r"C:\Users\s1770\.dsh-bookmemory"
11
+ BOOKS = os.path.join(DIR, "书.jsonl")
12
+ CATALOG = os.path.join(DIR, "目录.json")
13
+
14
+ BAD_PREFIX = (
15
+ "Time sampled while preparing",
16
+ "Current runtime context",
17
+ "Tokens prefixed with @",
18
+ "The runtime context names",
19
+ "<system-reminder>",
20
+ "### Updated Tool Schemas",
21
+ "[SCHEDULE REMINDER]",
22
+ )
23
+
24
+
25
+ def is_bad(text):
26
+ t = (text or "").lstrip()
27
+ return any(t.startswith(p) for p in BAD_PREFIX)
28
+
29
+
30
+ def main():
31
+ if not os.path.isfile(BOOKS):
32
+ print("没有书")
33
+ return
34
+
35
+ kept, dropped = [], 0
36
+ for line in open(BOOKS, encoding="utf-8"):
37
+ line = line.strip()
38
+ if not line:
39
+ continue
40
+ try:
41
+ b = json.loads(line)
42
+ except json.JSONDecodeError:
43
+ continue
44
+ if is_bad(b.get("text")):
45
+ dropped += 1
46
+ continue
47
+ kept.append(b)
48
+
49
+ with open(BOOKS, "w", encoding="utf-8") as f:
50
+ for b in kept:
51
+ f.write(json.dumps(b, ensure_ascii=False) + "\n")
52
+
53
+ # 重建目录:归档书不进目录
54
+ catalog = {}
55
+ for b in kept:
56
+ if b.get("archived"):
57
+ continue
58
+ for k in b.get("keys") or []:
59
+ if not k:
60
+ continue
61
+ catalog.setdefault(k, [])
62
+ if b["id"] not in catalog[k]:
63
+ catalog[k].append(b["id"])
64
+
65
+ with open(CATALOG, "w", encoding="utf-8") as f:
66
+ json.dump(catalog, f, ensure_ascii=False, indent=1)
67
+
68
+ print(f"删掉 {dropped} 条噪音,留下 {len(kept)} 本书,目录 {len(catalog)} 个词")
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()
@@ -0,0 +1,124 @@
1
+ <#
2
+ .SYNOPSIS
3
+ 把 dsh-bookmemory 装进一个 DSH profile:建目录链接 + 追加一行 patch。
4
+
5
+ .DESCRIPTION
6
+ 做两件事,都不碰 package.json、不跑 pnpm:
7
+ 1. 在 <profile>\node_modules\dsh-bookmemory 建一个目录链接(Junction)
8
+ 2. 在 <profile>\cordis.patch.yml 末尾追加一条 insert 行
9
+
10
+ 改之前先备份 patch 文件到 ~/.dsh/.dsh-rollback-dsh-bookmemory-<时间戳>\。
11
+
12
+ .PARAMETER ProfileName
13
+ 装到哪个 profile。默认 web(`dsh web` 用的那个)。
14
+ 桌面版(DSH Desktop)的 profile 叫 desktop。
15
+
16
+ .PARAMETER PackageDir
17
+ 插件目录,默认脚本所在的上一级。
18
+
19
+ .EXAMPLE
20
+ powershell -ExecutionPolicy Bypass -File tools\install.ps1
21
+ powershell -ExecutionPolicy Bypass -File tools\install.ps1 -ProfileName desktop
22
+ #>
23
+ param(
24
+ [string]$ProfileName = 'web',
25
+ [string]$PackageDir = (Split-Path -Parent $PSScriptRoot)
26
+ )
27
+
28
+ $ErrorActionPreference = 'Stop'
29
+ $PluginName = 'dsh-bookmemory'
30
+
31
+ function Say($msg) { Write-Host $msg }
32
+ function Fail($msg) { Write-Host "错误:$msg" -ForegroundColor Red; exit 1 }
33
+
34
+ # ── 1/5 找 DSH 和 profile ──
35
+ Say '=== 1/5 找 DSH 和 profile ==='
36
+ $DshHome = if ($env:DSH_HOME) { $env:DSH_HOME } else { Join-Path $env:USERPROFILE '.dsh' }
37
+ if (-not (Test-Path $DshHome)) { Fail "找不到 DSH 主目录:$DshHome(可以设环境变量 DSH_HOME 指定)" }
38
+
39
+ $Profile = Join-Path (Join-Path $DshHome 'profiles') $ProfileName
40
+ if (-not (Test-Path $Profile)) {
41
+ $found = @(Get-ChildItem (Join-Path $DshHome 'profiles') -Directory -ErrorAction SilentlyContinue |
42
+ ForEach-Object { $_.Name })
43
+ Fail "没有叫 '$ProfileName' 的 profile。现有的:$($found -join ', ')"
44
+ }
45
+ $PatchFile = Join-Path $Profile 'cordis.patch.yml'
46
+ if (-not (Test-Path $PatchFile)) { Fail "profile 里没有 cordis.patch.yml:$PatchFile" }
47
+ Say " profile = $Profile"
48
+
49
+ if (-not (Test-Path (Join-Path $PackageDir 'entry.js'))) {
50
+ Fail "PackageDir 里没有 entry.js:$PackageDir"
51
+ }
52
+ Say " package = $PackageDir"
53
+
54
+ # ── 2/5 备份 ──
55
+ Say '=== 2/5 备份 patch 文件 ==='
56
+ $Stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
57
+ $Backup = Join-Path $DshHome ".dsh-rollback-$PluginName-$Stamp"
58
+ New-Item -ItemType Directory -Force -Path $Backup | Out-Null
59
+ Copy-Item $PatchFile (Join-Path $Backup 'cordis.patch.yml') -Force
60
+ "hadNodeModulesLink=$(Test-Path (Join-Path $Profile "node_modules\$PluginName"))" |
61
+ Set-Content (Join-Path $Backup 'MANIFEST.txt') -Encoding UTF8
62
+ Say " 备份 = $Backup"
63
+
64
+ # ── 3/5 目录链接 ──
65
+ Say '=== 3/5 建目录链接 ==='
66
+ $LinkDir = Join-Path $Profile 'node_modules'
67
+ New-Item -ItemType Directory -Force -Path $LinkDir | Out-Null
68
+ $LinkPath = Join-Path $LinkDir $PluginName
69
+
70
+ if (Test-Path $LinkPath) {
71
+ $item = Get-Item $LinkPath -Force
72
+ if ($item.LinkType -eq 'Junction') {
73
+ Say " 链接已存在,指向 $($item.Target)"
74
+ if ($item.Target -ne $PackageDir) {
75
+ Say ' 但指向的不是这个目录,重建...'
76
+ Remove-Item $LinkPath -Force
77
+ New-Item -ItemType Junction -Path $LinkPath -Target $PackageDir | Out-Null
78
+ Say " 已重建 -> $PackageDir"
79
+ }
80
+ } else {
81
+ Fail "$LinkPath 已存在且不是目录链接,不敢动它。请手动检查。"
82
+ }
83
+ } else {
84
+ New-Item -ItemType Junction -Path $LinkPath -Target $PackageDir | Out-Null
85
+ Say " $LinkPath -> $PackageDir"
86
+ }
87
+
88
+ # ── 4/5 patch 行 ──
89
+ Say '=== 4/5 追加 patch 行 ==='
90
+ $existing = Get-Content $PatchFile -Raw -Encoding UTF8
91
+ if ($existing -match "id:\s*bookmemory") {
92
+ Say ' patch 里已有 bookmemory 行,跳过'
93
+ } else {
94
+ $row = @"
95
+
96
+ # dsh-bookmemory:对话存成书、关键词做目录、久远的自动浓缩
97
+ - insert:
98
+ - id: bookmemory
99
+ name: dsh-bookmemory
100
+ "@
101
+ # 该文件是无 BOM 的 UTF-8,且常带中文,必须保持无 BOM 追加
102
+ $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
103
+ [System.IO.File]::AppendAllText($PatchFile, $row, $utf8NoBom)
104
+ Say ' 已追加'
105
+ }
106
+
107
+ # ── 5/5 检查依赖 ──
108
+ Say '=== 5/5 检查设置卡片需要的依赖 ==='
109
+ $schema = Join-Path (Join-Path $PackageDir 'node_modules') '@deepseek-ai\schemastery'
110
+ if (Test-Path $schema) {
111
+ $ver = (Get-Content (Join-Path $schema 'package.json') -Raw | ConvertFrom-Json).version
112
+ Say " @deepseek-ai/schemastery $ver"
113
+ if ([version]$ver -lt [version]'3.18.4') {
114
+ Say ' 警告:低于 3.18.4,设置卡片可能不出现(插件功能仍然正常)'
115
+ }
116
+ } else {
117
+ Say ' 警告:没找到 @deepseek-ai/schemastery,设置卡片可能不出现'
118
+ Say ' 运行 cmd /c "npm install --save-dev @deepseek-ai/schemastery" 补上'
119
+ }
120
+
121
+ Say ''
122
+ Say '装完了。'
123
+ Say '大多数 profile 会热加载 cordis.patch.yml(几秒内生效);没热加载的重启一次 DSH。'
124
+ Say "撤销:删掉 $LinkPath,再把 $Backup\cordis.patch.yml 拷回去。"