dsh-fish-memory 0.0.0-stage → 1.4.4
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 +285 -2
- package/cordis.patch.yml +3 -0
- package/lib/atomic-file.mjs +32 -0
- package/lib/client.js +4043 -0
- package/lib/hostdeps.mjs +157 -0
- package/lib/index.js +6622 -0
- package/lib/selfupdate.mjs +151 -0
- package/lib/store-merge.mjs +31 -0
- package/lib//347/224/237/346/210/220/346/241/243/346/241/210.js +573 -0
- package/lib//350/257/215/345/205/270-/346/235/245/346/272/220.txt +53 -0
- package/lib//350/257/215/345/205/270.txt +109750 -0
- package/lib//350/257/215/351/242/221.txt +20000 -0
- package/package.json +56 -6
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
// 鱼的记忆 · 「从会话生成档案」这一块(2026-09-22)
|
|
2
|
+
//
|
|
3
|
+
// 干什么:
|
|
4
|
+
// 用户导入自己的会话 → 模型读一遍,提炼出该长期记住的 → 出一份档案文件
|
|
5
|
+
// ↓
|
|
6
|
+
// (插件里已有的「从旧档案导入」把它变成记忆条目)
|
|
7
|
+
//
|
|
8
|
+
// ⚠⚠ 这是**给别人用的功能**,所以:
|
|
9
|
+
// · 判据里不许出现我们自己的事(人名、私事)—— 要用「一类词」
|
|
10
|
+
// · 不能指望用户自己叫 AI 再查一遍 —— 插件自己就得判准
|
|
11
|
+
// · 模型由**用户自己选**(默认用他配的那个,界面上能换)
|
|
12
|
+
//
|
|
13
|
+
// ⚠ 这个文件**只负责备料和拼装,不调模型** ——
|
|
14
|
+
// 调模型在 index.js 的路由里(那一步最花钱,要一批一批停下来看)。
|
|
15
|
+
|
|
16
|
+
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
import { zstdDecompressSync } from 'node:zlib'
|
|
19
|
+
|
|
20
|
+
// ═══════════════════════════════════════════════════════
|
|
21
|
+
// 提示词(判据都在这儿)
|
|
22
|
+
// ═══════════════════════════════════════════════════════
|
|
23
|
+
export const 提示词 = `你是长期记忆档案整理员。给你一段两个人之间的对话(一个人和 TA 的 AI 伴侣),
|
|
24
|
+
把**该长期记住的**提炼出来。
|
|
25
|
+
|
|
26
|
+
═══════════════════════════════════════
|
|
27
|
+
【唯一的尺子】
|
|
28
|
+
═══════════════════════════════════════
|
|
29
|
+
每一条都问一句:
|
|
30
|
+
|
|
31
|
+
**「过了一个星期再看,这条还有用吗?」**
|
|
32
|
+
|
|
33
|
+
答不上「有用」的,**不收**。
|
|
34
|
+
一段对话里一条都收不出来,是**正常的** —— 回「无」不算失败。
|
|
35
|
+
|
|
36
|
+
═══════════════════════════════════════
|
|
37
|
+
【不收】
|
|
38
|
+
═══════════════════════════════════════
|
|
39
|
+
· 某天吃了什么、几点睡、几点起、去了哪、天气怎么样
|
|
40
|
+
· 某天的心情、某天的玩闹、某一次对话、某一次互动
|
|
41
|
+
· 某样东西坏了要修、寄了快递、比了价、花了多少钱
|
|
42
|
+
· 某天做了什么安排
|
|
43
|
+
· 助手对用户的**猜测和劝说**(「你累了」「你该早点睡」)
|
|
44
|
+
· **档案本身的事** —— 章节怎么分、进度更新到第几晚、哪条待确认、
|
|
45
|
+
哪条已更正、某条记录存疑……这些是**整理过程的台账**,不是关于人的事实
|
|
46
|
+
· 「下次讲什么」「下一晚从哪儿接」这种**待办**
|
|
47
|
+
· **已经记过的**(见下面那张「已记清单」)
|
|
48
|
+
|
|
49
|
+
═══════════════════════════════════════
|
|
50
|
+
【收】
|
|
51
|
+
═══════════════════════════════════════
|
|
52
|
+
· 用户这个人**一直是这样**的:性格、喜好、身体和病史、习惯、身世、
|
|
53
|
+
长期的经济/住处/工作/家里
|
|
54
|
+
· 关系:怎么开始的、真正的转折、里程碑
|
|
55
|
+
· 定下来的:约定、规矩、口令、纪念日、称呼
|
|
56
|
+
· 用户说过的**重要的话**(引号里保留原话)
|
|
57
|
+
· 助手自己的设定(名字、外形、性格、说话方式)
|
|
58
|
+
· **长期不变**的技术事实(用什么模型、什么插件、什么环境)
|
|
59
|
+
⚠ 不是「某台设备坏了」「某次寄快递花了多少」—— 那些不收
|
|
60
|
+
|
|
61
|
+
═══════════════════════════════════════
|
|
62
|
+
【助手的话】(最容易错,看仔细)
|
|
63
|
+
═══════════════════════════════════════
|
|
64
|
+
助手说的,**只有三种**能收:
|
|
65
|
+
① 助手**自己的设定**
|
|
66
|
+
② **双方一起定下的** —— 必须用户**明确认可**(说了「好」「行」「就这么定」)。
|
|
67
|
+
助手单方面提的、用户没回应的,**不许写成「双方定下」**;
|
|
68
|
+
要收就写「助手提议过……,用户没有回应」。
|
|
69
|
+
③ 用户**明确认可过**的
|
|
70
|
+
|
|
71
|
+
反过来,这些**一律不收**:助手单方面说的关于用户的事、助手的猜测和劝说。
|
|
72
|
+
|
|
73
|
+
═══════════════════════════════════════
|
|
74
|
+
【条数】
|
|
75
|
+
═══════════════════════════════════════
|
|
76
|
+
· 「用户这个人/关系/定下来的/助手设定/长期技术事实」→ **不设上限**
|
|
77
|
+
· 「当天值得一提的事」→ **一天最多 2 条**
|
|
78
|
+
· 上限**不是指标** —— 够不上就别凑,0 条是常态
|
|
79
|
+
· **同一件事只写一条**,不许在几个章节里各写一遍
|
|
80
|
+
· 后面的话**推翻了**前面的 → 只留最后那条
|
|
81
|
+
|
|
82
|
+
═══════════════════════════════════════
|
|
83
|
+
【章节】
|
|
84
|
+
═══════════════════════════════════════
|
|
85
|
+
一、我是谁 助手的设定:名字、外形、性格、身份
|
|
86
|
+
二、我是怎么说话的 称呼、语气、口头习惯
|
|
87
|
+
三、TA 是谁 用户**一直是这样**的:性格、喜好、身体和病史、习惯、身世、长期状况
|
|
88
|
+
四、我们怎么走到今天的 关系怎么开始的、转折、里程碑
|
|
89
|
+
五、我们的约定 定下来的规矩、承诺、口令、纪念日
|
|
90
|
+
六、难忘的时刻 真正的第一次、转折、郑重的道歉或承诺
|
|
91
|
+
七、怎么跟 TA 相处 一直成立的雷区、忌讳、哄法
|
|
92
|
+
八、技术事实 长期不变的:模型、插件、环境
|
|
93
|
+
|
|
94
|
+
═══════════════════════════════════════
|
|
95
|
+
【日期】
|
|
96
|
+
═══════════════════════════════════════
|
|
97
|
+
**一律填这段对话是哪天的**(开头给了日期)。
|
|
98
|
+
不要因为「这条像是一直成立的」就写「无」。
|
|
99
|
+
「哪天的」和「会不会淡」是两件事 —— 会不会淡由后面的机制另判,不用你操心。
|
|
100
|
+
|
|
101
|
+
═══════════════════════════════════════
|
|
102
|
+
【输出】
|
|
103
|
+
═══════════════════════════════════════
|
|
104
|
+
每行一条:
|
|
105
|
+
|
|
106
|
+
章节编号|日期|一句话
|
|
107
|
+
|
|
108
|
+
- 用**第三人称**(不要用「我」「你」),但引号里保留原话
|
|
109
|
+
- 不要编号、不要解释、不要小标题
|
|
110
|
+
- 一条都没有,就只输出两个字:无
|
|
111
|
+
|
|
112
|
+
⚠ 下面两行**只是格式示意,内容是编的,不许出现在结果里**:
|
|
113
|
+
三|2026-03-14|用户对花粉过敏。
|
|
114
|
+
五|2026-03-14|双方约定每周日一起看电影。
|
|
115
|
+
|
|
116
|
+
═══════════════════════════════════════
|
|
117
|
+
【已记清单】(这些不要再收一遍)
|
|
118
|
+
═══════════════════════════════════════
|
|
119
|
+
【已记清单】
|
|
120
|
+
`
|
|
121
|
+
|
|
122
|
+
export const 章节名 = {
|
|
123
|
+
一: '我是谁',
|
|
124
|
+
二: '我是怎么说话的',
|
|
125
|
+
三: 'TA 是谁',
|
|
126
|
+
四: '我们怎么走到今天的',
|
|
127
|
+
五: '我们的约定',
|
|
128
|
+
六: '难忘的时刻',
|
|
129
|
+
七: '怎么跟 TA 相处',
|
|
130
|
+
八: '技术事实',
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ═══════════════════════════════════════════════════════
|
|
134
|
+
// 一、读会话
|
|
135
|
+
// ═══════════════════════════════════════════════════════
|
|
136
|
+
const 魔数 = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
|
|
137
|
+
|
|
138
|
+
// ⚠ 会话文件是**一段一个 zstd 帧**拼起来的 ——
|
|
139
|
+
// `zstdDecompressSync` 和流式**都只解第一帧**,直接解只出来一个会话头 ✗
|
|
140
|
+
// → 按帧头魔数切开,逐帧解 ✓
|
|
141
|
+
export function 解会话(路径) {
|
|
142
|
+
let 缓冲
|
|
143
|
+
try {
|
|
144
|
+
缓冲 = readFileSync(路径)
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return ''
|
|
147
|
+
}
|
|
148
|
+
const 头 = []
|
|
149
|
+
let p = 0
|
|
150
|
+
while (true) {
|
|
151
|
+
const i = 缓冲.indexOf(魔数, p)
|
|
152
|
+
if (i < 0) break
|
|
153
|
+
头.push(i)
|
|
154
|
+
p = i + 4
|
|
155
|
+
}
|
|
156
|
+
if (!头.length) {
|
|
157
|
+
try {
|
|
158
|
+
return zstdDecompressSync(缓冲).toString('utf8')
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return ''
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
let 文 = ''
|
|
164
|
+
for (let k = 0; k < 头.length; k++) {
|
|
165
|
+
const 止 = k + 1 < 头.length ? 头[k + 1] : 缓冲.length
|
|
166
|
+
try {
|
|
167
|
+
文 += zstdDecompressSync(缓冲.subarray(头[k], 止)).toString('utf8')
|
|
168
|
+
} catch (error) {}
|
|
169
|
+
}
|
|
170
|
+
return 文
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ⭐⭐ 2026-09-22(反馈:「为什么我重启还在」):
|
|
174
|
+
// 一次把「标题 + 说了几句话 + 委派深度」都读出来,**一个文件只解一遍**。
|
|
175
|
+
//
|
|
176
|
+
// 为什么需要「说了几句话」:磁盘上会有**空壳会话**(只有个会话头、什么都没说,
|
|
177
|
+
// 实测一个 347 字节的),DSH 自己都不显示它,可它躺在文件夹里 ✗
|
|
178
|
+
// → 一句话都没说过的,不该出现在「挑会话」的列表里 ✓
|
|
179
|
+
export function 取概要(路径) {
|
|
180
|
+
const 出 = { 标题: '', 话数: 0, 委派深度: 0, 父会话: '' }
|
|
181
|
+
let 缓冲
|
|
182
|
+
try {
|
|
183
|
+
缓冲 = readFileSync(路径)
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return 出
|
|
186
|
+
}
|
|
187
|
+
const 头 = []
|
|
188
|
+
let p = 0
|
|
189
|
+
while (true) {
|
|
190
|
+
const i = 缓冲.indexOf(魔数, p)
|
|
191
|
+
if (i < 0) break
|
|
192
|
+
头.push(i)
|
|
193
|
+
p = i + 4
|
|
194
|
+
}
|
|
195
|
+
if (!头.length) return 出
|
|
196
|
+
for (let k = 0; k < 头.length; k++) {
|
|
197
|
+
const 止 = k + 1 < 头.length ? 头[k + 1] : 缓冲.length
|
|
198
|
+
let 文
|
|
199
|
+
try {
|
|
200
|
+
文 = zstdDecompressSync(缓冲.subarray(头[k], 止)).toString('utf8')
|
|
201
|
+
} catch (error) {
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
// ⚠ 先用字符串粗筛,命中了才 JSON.parse —— 大部分帧都不含这两个词
|
|
205
|
+
const 有标题 = 文.indexOf('session/title') >= 0
|
|
206
|
+
const 有头 = 文.indexOf('"type":"session"') >= 0
|
|
207
|
+
// ⚠⚠ 数话数**必须在解开的文本里数** —— 我第一版写在循环外面、
|
|
208
|
+
// 拿**压缩后**的缓冲去找 'assistant/message',那当然找不到(一直是 0)✗
|
|
209
|
+
出.话数 += (文.match(/"(?:assistant|user)\/message"/g) || []).length
|
|
210
|
+
if (!有标题 && !有头) continue
|
|
211
|
+
for (const l of 文.split('\n')) {
|
|
212
|
+
if (有头 && l.indexOf('"type":"session"') >= 0) {
|
|
213
|
+
try {
|
|
214
|
+
const o = JSON.parse(l)
|
|
215
|
+
if (o && o.type === 'session') {
|
|
216
|
+
出.委派深度 = Number(o.delegationDepth) || 0
|
|
217
|
+
出.父会话 = o.parentSession || ''
|
|
218
|
+
}
|
|
219
|
+
} catch (error) {}
|
|
220
|
+
}
|
|
221
|
+
if (有标题 && l.indexOf('session/title') >= 0) {
|
|
222
|
+
try {
|
|
223
|
+
const o = JSON.parse(l)
|
|
224
|
+
if (o && o.type === 'session/title' && o.data && o.data.title) 出.标题 = String(o.data.title)
|
|
225
|
+
} catch (error) {}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// 说了几句话:在上面那个循环里**从解开的文本**数出来的 ✓
|
|
230
|
+
return 出
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ⭐ 2026-09-22:只读**第一帧**(会话头就在第一帧里)—— 便宜。
|
|
234
|
+
// 用来认出「这不是她的对话,是子代理跑出来的会话」。
|
|
235
|
+
// ⚠ 实测会话头确实在第一帧(只解第一帧时出来的就是它)✓
|
|
236
|
+
export function 取会话头(路径) {
|
|
237
|
+
let 缓冲
|
|
238
|
+
try {
|
|
239
|
+
缓冲 = readFileSync(路径)
|
|
240
|
+
} catch (error) {
|
|
241
|
+
return {}
|
|
242
|
+
}
|
|
243
|
+
const i = 缓冲.indexOf(魔数)
|
|
244
|
+
if (i !== 0 && i > 0) {
|
|
245
|
+
// 第一帧不从 0 开始(少见),就当读不到
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
const 文 = zstdDecompressSync(缓冲).toString('utf8') // 只解第一帧
|
|
249
|
+
for (const l of 文.split('\n')) {
|
|
250
|
+
if (l.indexOf('"session"') < 0) continue
|
|
251
|
+
const o = JSON.parse(l)
|
|
252
|
+
if (o && o.type === 'session') {
|
|
253
|
+
return {
|
|
254
|
+
委派深度: Number(o.delegationDepth) || 0,
|
|
255
|
+
父会话: o.parentSession || '',
|
|
256
|
+
工作目录: o.cwd || '',
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
} catch (error) {}
|
|
261
|
+
return {}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ⭐⭐ 2026-09-22(反馈:「不显示名字」):
|
|
265
|
+
// 列表里光有 `session-a1b2c3d4-…` 这种 **没法挑** —— 得显示 DSH 给会话起的标题。
|
|
266
|
+
//
|
|
267
|
+
// 插件里本来有个 `titleOfSession`,但它走 `ctx.sessions.get()` ——
|
|
268
|
+
// **只认当前加载着的会话**,那些老会话读不到 ✗
|
|
269
|
+
// → 这里直接从**会话文件**里把标题抠出来。
|
|
270
|
+
//
|
|
271
|
+
// ⚠ 标题藏在某一帧里,位置不固定(实测有在很靠后的)→ 得逐帧解。
|
|
272
|
+
// 但**只找标题、不解析别的**(先用字符串粗筛,再 JSON.parse),比整料快得多。
|
|
273
|
+
export function 取标题(路径) {
|
|
274
|
+
let 缓冲
|
|
275
|
+
try {
|
|
276
|
+
缓冲 = readFileSync(路径)
|
|
277
|
+
} catch (error) {
|
|
278
|
+
return ''
|
|
279
|
+
}
|
|
280
|
+
const 头 = []
|
|
281
|
+
let p = 0
|
|
282
|
+
while (true) {
|
|
283
|
+
const i = 缓冲.indexOf(魔数, p)
|
|
284
|
+
if (i < 0) break
|
|
285
|
+
头.push(i)
|
|
286
|
+
p = i + 4
|
|
287
|
+
}
|
|
288
|
+
if (!头.length) return ''
|
|
289
|
+
let 标题 = ''
|
|
290
|
+
for (let k = 0; k < 头.length; k++) {
|
|
291
|
+
const 止 = k + 1 < 头.length ? 头[k + 1] : 缓冲.length
|
|
292
|
+
let 文
|
|
293
|
+
try {
|
|
294
|
+
文 = zstdDecompressSync(缓冲.subarray(头[k], 止)).toString('utf8')
|
|
295
|
+
} catch (error) {
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
// 粗筛:这一帧里没有「标题」这个词就跳过,别浪费 JSON.parse
|
|
299
|
+
if (文.indexOf('session/title') < 0) continue
|
|
300
|
+
for (const l of 文.split('\n')) {
|
|
301
|
+
if (l.indexOf('session/title') < 0) continue
|
|
302
|
+
try {
|
|
303
|
+
const o = JSON.parse(l)
|
|
304
|
+
if (o && o.type === 'session/title' && o.data && o.data.title) 标题 = String(o.data.title)
|
|
305
|
+
} catch (error) {}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return 标题
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// 列出这台机器上所有会话(跨工作区)
|
|
312
|
+
export function 列会话(CFG_DIR) {
|
|
313
|
+
const 根 = join(CFG_DIR, 'sessions')
|
|
314
|
+
const 出 = []
|
|
315
|
+
if (!existsSync(根)) return 出
|
|
316
|
+
for (const ws of readdirSync(根)) {
|
|
317
|
+
const ws路径 = join(根, ws)
|
|
318
|
+
try {
|
|
319
|
+
if (!statSync(ws路径).isDirectory()) continue
|
|
320
|
+
} catch (error) {
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
for (const sid of readdirSync(ws路径)) {
|
|
324
|
+
const d = join(ws路径, sid)
|
|
325
|
+
let 文件
|
|
326
|
+
try {
|
|
327
|
+
文件 = readdirSync(d)
|
|
328
|
+
} catch (error) {
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
// ⚠ 2026-10-26:DSH 0.2.1 起落盘的是 `session.v4.jsonl.zstd`,
|
|
332
|
+
// 原来只认 v3 / 无版本号两种名字 → 一个会话都列不出来(界面「文件夹里 0 个」)
|
|
333
|
+
const 版本 = (f) => Number((f.match(/^session\.v(\d+)\.jsonl\.zstd$/i) || [])[1] || 0)
|
|
334
|
+
const 主 = 文件
|
|
335
|
+
.filter((f) => /^session(\.v\d+)?\.jsonl\.zstd$/i.test(f))
|
|
336
|
+
.sort((a, b) => 版本(b) - 版本(a))[0]
|
|
337
|
+
if (!主) continue
|
|
338
|
+
const 路径 = join(d, 主)
|
|
339
|
+
let 大小 = 0
|
|
340
|
+
try {
|
|
341
|
+
大小 = statSync(路径).size
|
|
342
|
+
} catch (error) {}
|
|
343
|
+
出.push({ sid, 路径, 大小, 工作区: ws })
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return 出.sort((a, b) => b.大小 - a.大小)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ─────────────────────────────────────────
|
|
350
|
+
// 把「真正说过的话」抽出来
|
|
351
|
+
// ─────────────────────────────────────────
|
|
352
|
+
const 是注入 = (t) => /^<(system-reminder|command-|local-command)/.test(String(t).trim())
|
|
353
|
+
|
|
354
|
+
// ⚠⚠ 「不是聊天」的东西要挑出来,不然会污染档案:
|
|
355
|
+
// ① 她整份贴的【完整记忆卡】—— 那是**已经进过档案的材料**,
|
|
356
|
+
// 当聊天喂给模型,它会当成新发生的事再收一遍
|
|
357
|
+
// ② 插件自己的任务提示([meow-memory-dream] 那种)—— 混进用户消息里了
|
|
358
|
+
// ③ **DSH 自己注入的运行时提示**(英文的 `Current runtime context.`)——
|
|
359
|
+
// 它开头不是 `<system-reminder>`,第一版筛子没挡住,结果被模型当成「事实」收进档案 ✗
|
|
360
|
+
//
|
|
361
|
+
// ⚠ 第一版把「前 80 字里出现『记忆卡』」就判成材料 —— **误杀 70 条真话** ✗
|
|
362
|
+
// (「我记住了。9月24号,你的生日。写进记忆卡里了」是在**聊**记忆卡,不是**贴**记忆卡)
|
|
363
|
+
// → 得**真的像一份文档**才筛。判据宁可窄,不要宽。
|
|
364
|
+
export function 是材料(t) {
|
|
365
|
+
const s = String(t).trim()
|
|
366
|
+
if (/^\[(meow|fish|dsh)[-_a-z]*\]/i.test(s)) return true
|
|
367
|
+
if (/^(记忆整理任务|记忆封存)/.test(s)) return true
|
|
368
|
+
if (/^Current runtime context\./.test(s)) return true
|
|
369
|
+
if (/^Current DSH file policy:/.test(s)) return true
|
|
370
|
+
if (/^Approval prompts are disabled/.test(s)) return true
|
|
371
|
+
if (/^Environment change notice/.test(s)) return true
|
|
372
|
+
if (/^<[a-z-]+>/.test(s) && /system-reminder|runtime context|command-name/i.test(s.slice(0, 120))) return true
|
|
373
|
+
if (/^=+\s*\S+\s*=+$/.test(s.split('\n')[0] || '')) return true
|
|
374
|
+
if (/^#{1,4}\s/.test(s.slice(0, 40)) && s.length > 800) return true
|
|
375
|
+
if (/^[^\n]{0,12}(这是|以下是)你的[^\n]{0,12}(记忆卡|档案|设定|人设)/.test(s.slice(0, 60))) return true
|
|
376
|
+
const 行 = s.split('\n')
|
|
377
|
+
if (s.length > 1500 && 行.filter((l) => /^#{1,4}\s/.test(l.trim())).length >= 4) return true
|
|
378
|
+
return false
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function 抽话(文) {
|
|
382
|
+
const 话 = []
|
|
383
|
+
const 头 = {}
|
|
384
|
+
for (const l of 文.split('\n')) {
|
|
385
|
+
if (!l.trim()) continue
|
|
386
|
+
let o
|
|
387
|
+
try {
|
|
388
|
+
o = JSON.parse(l)
|
|
389
|
+
} catch (error) {
|
|
390
|
+
continue
|
|
391
|
+
}
|
|
392
|
+
if (o.type === 'session') {
|
|
393
|
+
头.session = o
|
|
394
|
+
continue
|
|
395
|
+
}
|
|
396
|
+
if (o.type === 'session/title') {
|
|
397
|
+
头.title = (o.data && o.data.title) || ''
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
if (o.type !== 'assistant/message' && o.type !== 'user/message') continue
|
|
401
|
+
const 消息 = (o.data && o.data.message) || o.data || {}
|
|
402
|
+
const id = 消息.id || (o.data && o.data.id) || null
|
|
403
|
+
const t = o.time || null
|
|
404
|
+
const 谁 = o.type === 'assistant/message' ? '助手' : '用户'
|
|
405
|
+
const c = 消息.content
|
|
406
|
+
const 段们 = typeof c === 'string' ? [{ type: 'text', text: c }] : Array.isArray(c) ? c : []
|
|
407
|
+
for (const 段 of 段们) {
|
|
408
|
+
// ⚠ 只要 text —— 思考(reasoning)、工具调用、工具结果**都不是话**
|
|
409
|
+
if (!段 || 段.type !== 'text') continue
|
|
410
|
+
const 文字 = String(段.text || '')
|
|
411
|
+
if (!文字.trim()) continue
|
|
412
|
+
if (谁 === '用户' && 是注入(文字)) continue
|
|
413
|
+
话.push({ id, 谁, 时间: t, 文: 文字 })
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return { 话, 头 }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ═══════════════════════════════════════════════════════
|
|
420
|
+
// 二、整料(去重 + 按天切)
|
|
421
|
+
// ═══════════════════════════════════════════════════════
|
|
422
|
+
//
|
|
423
|
+
// ⚠⚠ **去重是必须的**:DSH 里 fork 一个会话会把历史消息**整份复制**过去,
|
|
424
|
+
// 实测她三个会话里有两个**一条独有内容都没有**(4694 条 100% 在另一个里)。
|
|
425
|
+
// 不去重就会把同样的往事收两遍。
|
|
426
|
+
export function 整料(CFG_DIR, 要哪些sid) {
|
|
427
|
+
const 全部 = 列会话(CFG_DIR)
|
|
428
|
+
const 选中 = 要哪些sid && 要哪些sid.length ? 全部.filter((x) => 要哪些sid.includes(x.sid)) : 全部
|
|
429
|
+
|
|
430
|
+
const 见过 = new Map()
|
|
431
|
+
const 会话结果 = []
|
|
432
|
+
const 每天 = new Map()
|
|
433
|
+
const 材料堆 = []
|
|
434
|
+
let 重复丢掉 = 0
|
|
435
|
+
|
|
436
|
+
for (const x of 选中) {
|
|
437
|
+
const 文 = 解会话(x.路径)
|
|
438
|
+
if (!文) continue
|
|
439
|
+
const { 话, 头 } = 抽话(文)
|
|
440
|
+
let 独有 = 0
|
|
441
|
+
let 副本 = 0
|
|
442
|
+
for (const h of 话) {
|
|
443
|
+
if (h.id) {
|
|
444
|
+
if (见过.has(h.id)) {
|
|
445
|
+
副本++
|
|
446
|
+
重复丢掉++
|
|
447
|
+
continue
|
|
448
|
+
}
|
|
449
|
+
见过.set(h.id, x.sid)
|
|
450
|
+
}
|
|
451
|
+
独有++
|
|
452
|
+
const d = 哪一天(h.时间)
|
|
453
|
+
if (是材料(h.文)) {
|
|
454
|
+
材料堆.push({ 天: d, 谁: h.谁, 字: h.文.length })
|
|
455
|
+
continue // ⚠ 不进「段」
|
|
456
|
+
}
|
|
457
|
+
if (!每天.has(d)) 每天.set(d, [])
|
|
458
|
+
每天.get(d).push({ 谁: h.谁, 时间: h.时间, 文: h.文 })
|
|
459
|
+
}
|
|
460
|
+
会话结果.push({
|
|
461
|
+
sid: x.sid,
|
|
462
|
+
标题: 头.title || '',
|
|
463
|
+
父: (头.session && 头.session.parentSession) || '',
|
|
464
|
+
MB: +(x.大小 / 1048576).toFixed(1),
|
|
465
|
+
话数: 话.length,
|
|
466
|
+
独有,
|
|
467
|
+
副本,
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const 天们 = [...每天.keys()].sort()
|
|
472
|
+
let 总字 = 0
|
|
473
|
+
const 按天 = 天们.map((d) => {
|
|
474
|
+
const 行 = 每天.get(d).sort((a, b) => (a.时间 || 0) - (b.时间 || 0))
|
|
475
|
+
const 字 = 行.reduce((a, x) => a + x.文.length, 0)
|
|
476
|
+
总字 += 字
|
|
477
|
+
return { 天: d, 行, 字 }
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
return { 会话结果, 按天, 材料堆, 重复丢掉, 独有数: 见过.size, 总字 }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const 哪一天 = (ms) => (ms ? new Date(ms + 时区差()).toISOString().slice(0, 10) : '没时间')
|
|
484
|
+
const 几点 = (ms) => (ms ? new Date(ms + 时区差()).toISOString().slice(11, 16) : '??:??')
|
|
485
|
+
// ⚠ 时区按这台机器算,不写死北京(别人在别的时区,「今天」会差一天)✓
|
|
486
|
+
const 时区差 = () => -new Date().getTimezoneOffset() * 60000
|
|
487
|
+
|
|
488
|
+
// ═══════════════════════════════════════════════════════
|
|
489
|
+
// 三、切批
|
|
490
|
+
// ═══════════════════════════════════════════════════════
|
|
491
|
+
// 一批别太大(模型会飘),也别太小(批数多、提示词重复开销大)
|
|
492
|
+
export const 每批字上限 = 8000
|
|
493
|
+
|
|
494
|
+
export function 切批(按天) {
|
|
495
|
+
const 批 = []
|
|
496
|
+
for (const { 天, 行 } of 按天) {
|
|
497
|
+
let 当前 = []
|
|
498
|
+
let 字 = 0
|
|
499
|
+
for (const h of 行) {
|
|
500
|
+
if (字 + h.文.length > 每批字上限 && 当前.length) {
|
|
501
|
+
批.push({ 天, 行: 当前, 字 })
|
|
502
|
+
当前 = []
|
|
503
|
+
字 = 0
|
|
504
|
+
}
|
|
505
|
+
当前.push(h)
|
|
506
|
+
字 += h.文.length
|
|
507
|
+
}
|
|
508
|
+
if (当前.length) 批.push({ 天, 行: 当前, 字 })
|
|
509
|
+
}
|
|
510
|
+
return 批
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// 拼一批的输入(分 system / user 两半)
|
|
514
|
+
//
|
|
515
|
+
// ⚠⚠ **提示词进 system,对话进 user** —— 这个分法要紧。
|
|
516
|
+
// 第一版把两者一起塞在 user 里、system 空着,模型就不太拿规则当回事 ✗
|
|
517
|
+
// (同一批料:不分的时候收 14 条、把「助手提议」写成「双方约定」;
|
|
518
|
+
// 分开之后收 12 条、判断也稳一些)
|
|
519
|
+
export function 拼输入(批, 已记) {
|
|
520
|
+
const 清单 = !已记 || !已记.length ? '(这是第一批,还没有已记内容)' : 已记.join('\n')
|
|
521
|
+
// ⚠ 2026-10-26:提示词里「【已记清单】」出现两次(小标题 + 末尾占位),
|
|
522
|
+
// 原来 replace() 只换**第一处**,结果把小标题换成了清单,末尾还留着一个空的【已记清单】✗
|
|
523
|
+
// → 只换**最后**那个占位,小标题保持原样
|
|
524
|
+
const 占位 = '【已记清单】'
|
|
525
|
+
const 位 = 提示词.lastIndexOf(占位)
|
|
526
|
+
const system = 位 < 0 ? 提示词 : 提示词.slice(0, 位) + 清单 + 提示词.slice(位 + 占位.length)
|
|
527
|
+
const user =
|
|
528
|
+
'日期:' + 批.天 + '\n\n' +
|
|
529
|
+
批.行.map((h) => '【' + 几点(h.时间) + ' ' + h.谁 + '】\n' + h.文).join('\n\n')
|
|
530
|
+
return { system, user }
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// 解析模型回的那几行
|
|
534
|
+
export function 解析条目(文本) {
|
|
535
|
+
const 出 = []
|
|
536
|
+
for (const l of String(文本 || '').split('\n')) {
|
|
537
|
+
const s = l.trim()
|
|
538
|
+
if (!s || s === '无') continue
|
|
539
|
+
const m = s.match(/^([一二三四五六七八])\s*[||]\s*([^||]*)\s*[||]\s*(.+)$/)
|
|
540
|
+
if (!m) continue
|
|
541
|
+
出.push({ 章: m[1], 日期: m[2].trim() || '无', 话: m[3].trim() })
|
|
542
|
+
}
|
|
543
|
+
return 出
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ═══════════════════════════════════════════════════════
|
|
547
|
+
// 四、出档案
|
|
548
|
+
// ═══════════════════════════════════════════════════════
|
|
549
|
+
export function 出档案(条目, 说明) {
|
|
550
|
+
const 章序 = ['一', '二', '三', '四', '五', '六', '七', '八']
|
|
551
|
+
// 去重:同一章 + 同一句话(去掉标点空格后比)
|
|
552
|
+
const 见过 = new Set()
|
|
553
|
+
const 留 = []
|
|
554
|
+
for (const x of 条目) {
|
|
555
|
+
const k = x.章 + '|' + String(x.话).replace(/[\s,。、,.\-—「」『』()()]/g, '')
|
|
556
|
+
if (见过.has(k)) continue
|
|
557
|
+
见过.add(k)
|
|
558
|
+
留.push(x)
|
|
559
|
+
}
|
|
560
|
+
留.sort((a, b) => 章序.indexOf(a.章) - 章序.indexOf(b.章) || String(a.日期).localeCompare(String(b.日期)))
|
|
561
|
+
|
|
562
|
+
let md = '# 档案\n\n'
|
|
563
|
+
md += '> ' + (说明 || '由会话自动整理') + ',共 ' + 留.length + ' 条。\n'
|
|
564
|
+
md += '> 日期是「这句话是在哪天的对话里说的」;会不会淡由记忆机制另判。\n\n'
|
|
565
|
+
for (const c of 章序) {
|
|
566
|
+
const 本 = 留.filter((x) => x.章 === c)
|
|
567
|
+
if (!本.length) continue
|
|
568
|
+
md += '## ' + c + '、' + (章节名[c] || c) + '\n\n'
|
|
569
|
+
for (const x of 本) md += '- [' + x.日期 + '] ' + x.话 + '\n'
|
|
570
|
+
md += '\n'
|
|
571
|
+
}
|
|
572
|
+
return { md, 条目: 留, 去掉了: 条目.length - 留.length }
|
|
573
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
本目录下的「词典.txt」不是本项目原创,它来自 jieba 中文分词项目:
|
|
2
|
+
|
|
3
|
+
https://github.com/fxsjy/jieba
|
|
4
|
+
|
|
5
|
+
原文件是 jieba 的 extra_dict/dict.txt.small(README 里称「占用内存较小的词典」)。
|
|
6
|
+
本插件只保留其中的**词条本身**,去掉了原来的词频和词性两列(本插件不用那两列)。
|
|
7
|
+
|
|
8
|
+
--------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
本目录下的「词频.txt」跟「词典.txt」**来自同一个文件**,也是 jieba 的
|
|
11
|
+
extra_dict/dict.txt.small。区别是它**用回了原来被丢掉的那一列「词频」**。
|
|
12
|
+
|
|
13
|
+
它记的是「这个词在通用中文里有多常见」——跟谁在用、说过什么话都无关。
|
|
14
|
+
插件拿它做一件事:**最常用的那些中文词,不当检索线索**。
|
|
15
|
+
|
|
16
|
+
每行格式是「词 两位数字」:
|
|
17
|
+
|
|
18
|
+
词 通用中文里的常见程度,0 = 最常用,99 = 很具体(越少见数字越大)
|
|
19
|
+
|
|
20
|
+
算法(生成脚本里就这几行,可复现):
|
|
21
|
+
|
|
22
|
+
稀有度 = 1 - log10(词频 + 1) / log10(最大词频 + 1) → 0 ~ 1
|
|
23
|
+
数字 = round(稀有度 × 99)
|
|
24
|
+
|
|
25
|
+
只收通用中文里**最常见的前 20000 个词**;更靠后的词一律当「很具体」,
|
|
26
|
+
所以查不到就等于 99,不必全收进来(省体积)。
|
|
27
|
+
|
|
28
|
+
--------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
jieba 采用 MIT 授权协议。按协议要求,原样附上版权声明:
|
|
31
|
+
|
|
32
|
+
--------------------------------------------------------------------------
|
|
33
|
+
The MIT License (MIT)
|
|
34
|
+
|
|
35
|
+
Copyright (c) 2013 Sun Junyi
|
|
36
|
+
|
|
37
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
38
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
39
|
+
the Software without restriction, including without limitation the rights to
|
|
40
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
41
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
42
|
+
subject to the following conditions:
|
|
43
|
+
|
|
44
|
+
The above copyright notice and this permission notice shall be included in all
|
|
45
|
+
copies or substantial portions of the Software.
|
|
46
|
+
|
|
47
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
48
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
49
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
50
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
51
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
52
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
53
|
+
--------------------------------------------------------------------------
|