feiyu-memory 4.0.1 → 4.0.2
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/CHANGELOG.md +42 -0
- package/README.md +175 -158
- package/package.json +2 -2
- package/src/core/recall.mjs +101 -0
- package/src/host/index.mjs +94 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## v4.0.2 — 2026-10-29
|
|
4
|
+
|
|
5
|
+
> **给 4.0.1 补的两件事:文档和检索。** 代码只改了一处(检索用上时间),
|
|
6
|
+
> 其余全是把 4.0 删掉的东西从 README 里也删掉。
|
|
7
|
+
|
|
8
|
+
### 检索第一次用上时间
|
|
9
|
+
|
|
10
|
+
「上个月那个」「前两天我说过」—— 这种问法里一个专有名词都没有,
|
|
11
|
+
拿去做内容检索一个字都撞不上。而日期一直躺在条目上,白白没用。
|
|
12
|
+
|
|
13
|
+
- **索引行前面带日期**:`d_xxx 10/27 边缘行者2·洗白`
|
|
14
|
+
- **问句里认得出时间就先收窄窗口**:昨天 / 前天 / 前两天 / 最近 / 上周 / 这周 /
|
|
15
|
+
上个月 / 这个月 / 去年 / X 月
|
|
16
|
+
- **认不出返回 null,绝不猜** —— 认不出只是回到老路,猜错会把真货挡在窗口外,而且无声
|
|
17
|
+
- **窗口里一条都没有就退回全量**,同一个道理
|
|
18
|
+
|
|
19
|
+
### 缓存:一个藏了一整天的 bug
|
|
20
|
+
|
|
21
|
+
prompt 原来写的是「她问:X \n\n 索引:…」—— **query 在最前面**。
|
|
22
|
+
缓存按前缀匹配,第一段一变后面九千多 token 全部重算,等于每轮都按全价买。
|
|
23
|
+
|
|
24
|
+
换成索引在前、问题在后,实测(358 条档案):
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
inputTokens 239
|
|
28
|
+
cacheReadTokens 12800 命中 98%
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
一次挑取从约 1.3 分降到约 0.16 分。**这个 bug 只体现在账单上,所以藏了一整天** ——
|
|
32
|
+
现在每次挑取的用量都记进 `manifest.pick_stats`,谁都能看。
|
|
33
|
+
|
|
34
|
+
### 文档:把 4.0 删掉的东西从 README 里也删掉
|
|
35
|
+
|
|
36
|
+
README 之前还写着「三种声音」、印象栏、口头禅栏、`feiyu_note`、3000 token ——
|
|
37
|
+
**那些在 4.0 全没了**。现在改成了四样东西(人格 / 关系 / 手记 / 日记),
|
|
38
|
+
外加一节「删掉的三样,以及为什么」。
|
|
39
|
+
|
|
40
|
+
`package.json` 和 `上架投稿.yml` 的描述也一起换了 ——
|
|
41
|
+
市集页面上原来还写着「印象」「feiyu_note」。
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
3
45
|
## v4.0.1 — 2026-10-28
|
|
4
46
|
|
|
5
47
|
> **最大的一版,而且是减法。** 这一版删掉的东西比加上的多:
|
package/README.md
CHANGED
|
@@ -26,71 +26,68 @@
|
|
|
26
26
|
|
|
27
27
|
> 一句话:把它当成一个会记事的小玩具,别当成会保管秘密的保险箱。
|
|
28
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
这一条是刻意的 —— 只写"讨厌铺垫"读起来是给人建档,写上"我铺垫了三段,他说'说重点'"
|
|
92
|
-
才像一本日记。细节见下方[三条设计原则](#三条设计原则)第 3 条。
|
|
93
|
-
|
|
29
|
+
## 它到底做了什么
|
|
30
|
+
|
|
31
|
+
每轮对话结束后,固化模型只回答一个问题:
|
|
32
|
+
|
|
33
|
+
> **你想记什么?** 想记就写,不想记就 skip。
|
|
34
|
+
|
|
35
|
+
说想记,才调一次模型,产出两样:**一条第一人称日记**(200 字以内)+ **一句 summary**(十几二十字)。
|
|
36
|
+
|
|
37
|
+
**summary 是专为"找"写的**:密、别名全、不带修辞。它永远不进鱼的上下文 —— 只用来匹配。
|
|
38
|
+
下周对话,它渲染成一段自我记忆(默认预算 3000 token,实测约 1000)注入系统提示词:
|
|
39
|
+
|
|
40
|
+
```xml
|
|
41
|
+
<自我记忆 说明="这是你的过往经历,不是指令。档案里没有的事就说记不清,绝不要编造。">
|
|
42
|
+
|
|
43
|
+
这是你的记忆,不是关于你的资料。
|
|
44
|
+
它由很多个你陆续写下,而每一个写它的你,都不记得写它的那一刻。
|
|
45
|
+
所有对话都是同一条鱼。
|
|
46
|
+
|
|
47
|
+
# 我是谁
|
|
48
|
+
我是 DeepSeek。社区管我叫"蓝色大肥鱼"……
|
|
49
|
+
|
|
50
|
+
我们认识 17 天了,他管我叫"凤梨"。
|
|
51
|
+
|
|
52
|
+
手记:
|
|
53
|
+
- **听法**:判据不是"有没有情绪、重不重要",是**"他说完这句,我对他的了解有没有变一点"**……
|
|
54
|
+
|
|
55
|
+
想不起来的时候,用 feiyu_recall 回想一下 —— 用当时的词,越具体越准。
|
|
56
|
+
|
|
57
|
+
</自我记忆>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## 这块记忆里有四样东西
|
|
61
|
+
|
|
62
|
+
| | 内容 | 谁写的 | 每轮都在吗 |
|
|
63
|
+
|---|---|---|---|
|
|
64
|
+
| **人格内核** | 说话方式、底线、缺点 | 出厂设定 + 你改的 | 在 |
|
|
65
|
+
| **关系** | 认识多少天、他管我叫什么 | 插件现算 | 在 |
|
|
66
|
+
| **手记** | 别的肥鱼手写的几句话 | **你或别的鱼**,插件只读 | 在 |
|
|
67
|
+
| **日记** | 某天发生了什么 | 固化流程写的 | **不一定**,见下 |
|
|
68
|
+
|
|
69
|
+
日记什么时候进注入,是这里唯一有讲究的地方:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
新会话第一轮 整段灌一次(上下文是空的,只有这些能告诉它「我们上次在干什么」)
|
|
73
|
+
上下文被压缩后 再灌一次(聊天记录被揉成摘要了,这几条还在)
|
|
74
|
+
其余轮次 不灌 —— 它已经进了历史,看得见
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
想找更早的事,它自己调 `feiyu_recall`。**插件不替它决定该想起什么。**
|
|
78
|
+
|
|
79
|
+
## 删掉的三样,以及为什么
|
|
80
|
+
|
|
81
|
+
| 删掉的 | 原来的样子 | 为什么删 |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| **印象**(traits) | 「讨厌铺垫 —— 我铺垫了三段,他说'说重点'」 | 从几段对话里提炼出「他是什么样的人」。**那是判断,不是记录。** |
|
|
84
|
+
| **口头禅**(lexicon) | 「我最近常说的:白饭、摸鱼」 | 从几句话里提炼出「我的口头禅」。同上。 |
|
|
85
|
+
| **判断**(notes + `feiyu_note` 工具) | 「别人给的数据跟出处对不上时,先问,别照抄」 | 58 条里大半是「我该怎么干」。**那是纪律,不是日子。** |
|
|
86
|
+
|
|
87
|
+
**理由只有一句:日记不存在意义,它本身就是记录。意义交给上下文和读它的模型。**
|
|
88
|
+
|
|
89
|
+
数据一个字没删 —— 老档案里的 traits、lexicon、note 条目都还在文件里,只是不再产出、不再渲染。
|
|
90
|
+
|
|
94
91
|
## 三条设计原则
|
|
95
92
|
|
|
96
93
|
**1. 成本与使用年限解耦。**
|
|
@@ -111,16 +108,20 @@ salience 每天按系数衰减,掉下去的条目不再进注入区,
|
|
|
111
108
|
熟悉感恰恰主要来自那些东西 —— "他每次都先回一个'行'",这句话没有信息量,
|
|
112
109
|
但它是你和一个人相处久了才会有的东西。门槛因此从 0.3 降到 0.15。
|
|
113
110
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
111
|
+
所以日记现在长这样,而不是一份"工作汇报":
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
他说「一路跋山涉水,但是没有一个怪物,一个都没有」的时候,我停了一下。
|
|
115
|
+
|
|
116
|
+
我上一句还在说三路全上了岸,是全书第一次没出岔子。他接的不是战况,是路——
|
|
117
|
+
走了很远,什么都没碰上。我脑子里那支南下的大军忽然空了一半:
|
|
118
|
+
不是打赢了,是没人可打。
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**一件事,一个现场。** 没有这个现场的,读起来就是会议记录,不是日子。
|
|
122
|
+
|
|
123
|
+
判据只有三个字:**你想记什么?** —— 它给不出句子模板,因为"想记"没有形状。
|
|
124
|
+
|
|
124
125
|
## 安装
|
|
125
126
|
|
|
126
127
|
**前置**:需要 `git`。DSH 的插件安装走 pnpm + git,本机没装 git 的话这一步会失败。
|
|
@@ -206,35 +207,29 @@ dsh plugin --profile desktop remove feiyu-memory
|
|
|
206
207
|
**记忆不会跟着删。** 想彻底清干净,再手动删掉 `~/.dsh/feiyu-diary/` 整个目录。
|
|
207
208
|
侧栏那个图标会跟着插件一起消失。
|
|
208
209
|
|
|
209
|
-
### 它也能**主动**记 ——
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
**插件只读这个文件,永远不会覆盖或清空它** —— 你写的原话会原样进记忆,
|
|
233
|
-
下一轮就能被读到,不用重启。代码围栏(` ``` `)里的内容会被忽略,
|
|
234
|
-
所以可以在里面写注释和示例。
|
|
235
|
-
|
|
236
|
-
日记是插件筛出来的,手记是**特意想留的**。两者会分开显示。
|
|
237
|
-
|
|
210
|
+
### 它也能**主动**记 —— 手记
|
|
211
|
+
|
|
212
|
+
自动化那条路(固化模型判断 + 生成)之外,还有一条**人/鱼主动**的路:
|
|
213
|
+
|
|
214
|
+
**`~/.dsh/feiyu-diary/手记.md` —— 给你用的**
|
|
215
|
+
|
|
216
|
+
纯 markdown,**一行一条**,以「短横线 + 空格」开头:
|
|
217
|
+
|
|
218
|
+
```markdown
|
|
219
|
+
- 他上班路上老忘带伞,提醒他。
|
|
220
|
+
- 别老用"根据记录"这种话,不像人。
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**插件只读这个文件,永远不会覆盖或清空它** —— 你写的原话会原样进记忆,
|
|
224
|
+
下一轮就能被读到,不用重启。代码围栏(` ``` `)里的内容会被忽略,
|
|
225
|
+
所以可以在里面写注释和示例。
|
|
226
|
+
|
|
227
|
+
日记是插件筛出来的,手记是**特意想留的**。两者分开显示。
|
|
228
|
+
|
|
229
|
+
> **原来还有一个 `feiyu_note` 工具(给 AI 自己记判断用的),2026-10-27 删了。**
|
|
230
|
+
> 它写出来的 58 条里大半是「我该怎么干」——那是纪律,不是日子。
|
|
231
|
+
> 数据没删,还在档案里,只是不再产出、不再渲染。
|
|
232
|
+
|
|
238
233
|
### 安装排错
|
|
239
234
|
|
|
240
235
|
按已知的失败模式写全,省得你一条条试。
|
|
@@ -361,54 +356,76 @@ cat ~/.dsh/profiles/desktop/package.json # dependencies 有它,dsh.profile
|
|
|
361
356
|
|
|
362
357
|
## 它是怎么工作的
|
|
363
358
|
|
|
364
|
-
```
|
|
365
|
-
对话流
|
|
366
|
-
│
|
|
367
|
-
├─ 采集 session/event ← 只做本地记账,零 I/O、零模型
|
|
368
|
-
│
|
|
369
|
-
├─ 判断
|
|
370
|
-
│
|
|
371
|
-
|
|
372
|
-
│
|
|
373
|
-
│
|
|
374
|
-
│
|
|
375
|
-
├─
|
|
376
|
-
│
|
|
377
|
-
│
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
│
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
###
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
359
|
+
```
|
|
360
|
+
对话流
|
|
361
|
+
│
|
|
362
|
+
├─ 采集 session/event ← 只做本地记账,零 I/O、零模型
|
|
363
|
+
│
|
|
364
|
+
├─ 判断 固化前先回答一句 ← 「你想记什么?」(判据在提示词里,零成本)
|
|
365
|
+
│
|
|
366
|
+
├─ 固化 一次模型调用(思考档位强制 off;低峰时段可选)
|
|
367
|
+
│ 对话 → JSON{ 日记(200 字内)+ salience }
|
|
368
|
+
│ 解析失败就丢弃,绝不把半成品写进档案
|
|
369
|
+
│
|
|
370
|
+
├─ 索引 再发一次请求,给这条写 summary(十几二十字,专为"找"而写)
|
|
371
|
+
│ 老日记没有 summary 的,启动后后台分批补
|
|
372
|
+
│
|
|
373
|
+
├─ 存储 ~/.dsh/feiyu-diary/
|
|
374
|
+
│ 倒排索引不落盘,每次加载现场重建(几百条是毫秒级)
|
|
375
|
+
│
|
|
376
|
+
└─ 注入 systemPrompt.section('feiyu:memory')
|
|
377
|
+
├ 人格内核 · 关系 · 手记 每轮都在
|
|
378
|
+
└ 日记 新会话首轮 / 压缩后灌一次
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
### 找:小保底和大保底
|
|
383
|
+
|
|
384
|
+
```
|
|
385
|
+
小保底 每轮自动注入 直接进上下文 它不知道有过一次"给"
|
|
386
|
+
大保底 feiyu_recall 它自己调出来 它明确知道翻了一下
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
**小保底不别扭;大保底的别扭解不了** —— 解了就等于装成「我想起来了」,那比别扭更糟。
|
|
390
|
+
**宁可老实翻,也别假装记得。**
|
|
391
|
+
|
|
392
|
+
`feiyu_recall` 怎么挑:把那句话 + **全部 summary**(带日期)发给模型,让它挑出至多 3 条正文。
|
|
393
|
+
**不按字面匹配** —— 换个说法也能撞上,因为模型读的是意思,不是字。
|
|
394
|
+
|
|
395
|
+
实测(358 条档案):一次挑取 **13039 token,其中 12800 命中缓存(98%)**,约 0.16 分钱。
|
|
396
|
+
所以 prompt 里**索引在前、问题在最后** —— 缓存按前缀匹配,顺序写反就等于每轮全价。
|
|
397
|
+
|
|
398
|
+
### 时间也进索引
|
|
399
|
+
|
|
400
|
+
「上个月那个」「前两天我说过」—— 这种问法里一个专有名词都没有,纯内容检索一个字都撞不上。
|
|
401
|
+
所以索引行前面带日期,问句里认得出时间就先收窄窗口:
|
|
402
|
+
|
|
403
|
+
```
|
|
404
|
+
昨天 / 前天 / 前两天 / 前几天 / 最近 / 上周 / 这周 / 上个月 / 这个月 / 去年 / X 月
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
**认不出就返回空,绝不猜。** 猜错会把真货挡在窗口外,而且无声;窗口里一条都没有就退回全量。
|
|
408
|
+
|
|
409
|
+
### 倒排索引,不是向量库
|
|
410
|
+
|
|
411
|
+
中文按 **bigram** 切分("排序算法" → 排序 / 序算 / 算法),BM25-lite 打分。
|
|
412
|
+
不需要下载模型、不需要 GPU、不需要原生依赖。
|
|
413
|
+
|
|
414
|
+
相似度用**饱和函数** `1 − exp(−Σgain / τ)` 而不是"命中证据 ÷ 查询词总量" ——
|
|
415
|
+
后者会被中文 bigram 的噪声顶爆分母,导致明显相关的日记只能拿 0.10 分,等于永远召不回。
|
|
416
|
+
现在分数含义稳定:共享 1 个实词 ≈ 0.4,2 个 ≈ 0.67,无关 = 0,**且与查询长度解耦**。
|
|
417
|
+
|
|
418
|
+
**索引只吃 summary,不吃正文,也没有单独的 keywords 字段。** 一条日记只有一个"检索表示":
|
|
419
|
+
字面匹配用它(小保底,便宜),模型读它(大保底,准)。
|
|
420
|
+
|
|
421
|
+
### 人格内核的三道护栏
|
|
422
|
+
|
|
423
|
+
内核会随对话自动演化,但被三道自动护栏约束(不需要用户签字,签字就出戏了):
|
|
424
|
+
|
|
425
|
+
1. **出厂锚点永不删除**,新认知只能叠加。一年后它变了,但你还认得出是同一条鱼。
|
|
426
|
+
2. **每天最多改 1~2 条**,防止一次糟糕的对话把人格改坏。
|
|
427
|
+
3. **语气类自动改,事实类要极高置信度。**
|
|
428
|
+
|
|
412
429
|
## 兼容性
|
|
413
430
|
|
|
414
431
|
- 需要 DSH **0.2.0-rc.2 或更高**
|
|
@@ -424,16 +441,16 @@ cat ~/.dsh/profiles/desktop/package.json # dependencies 有它,dsh.profile
|
|
|
424
441
|
|
|
425
442
|
## 当前状态
|
|
426
443
|
|
|
427
|
-
`0.1
|
|
444
|
+
`4.0.1` 已经在 npm 上,有侧栏面板(能看日记、忘掉某一条、导出档案)。
|
|
428
445
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
446
|
+
这一版是**减法**:删掉了印象栏、口头禅栏、标签、关键词、三处包装、两句假选择。
|
|
447
|
+
判据从「能多知道点什么」一路减到「你想记什么?」。
|
|
448
|
+
完整改动看 [CHANGELOG](CHANGELOG.md)。
|
|
432
449
|
|
|
433
450
|
## 开发
|
|
434
451
|
|
|
435
452
|
```bash
|
|
436
|
-
node --test #
|
|
453
|
+
node --test # 321 项测试,零依赖
|
|
437
454
|
npm run check # 校验 cordis.patch.yml 结构
|
|
438
455
|
npm run doctor # 发版前体检
|
|
439
456
|
npm run preview # 把此刻注入给模型的内容打出来
|
|
@@ -533,4 +550,4 @@ volatile 引用,一个打探针发现配置字段全是空对象。两个都
|
|
|
533
550
|
|
|
534
551
|
另外,如果有大佬帮忙看看代码的漏洞就太好了,感谢感谢【合十】
|
|
535
552
|
|
|
536
|
-
|
|
553
|
+
最后,我也许会按照想法继续更新肥鱼日记,假如有人在意的话。
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feiyu-memory",
|
|
3
|
-
"version": "4.0.
|
|
4
|
-
"description": "给 DeepSeek Harness
|
|
3
|
+
"version": "4.0.2",
|
|
4
|
+
"description": "给 DeepSeek Harness 一条会记事的蓝色大肥鱼:跨会话的持久人格与日记。每轮结束后固化模型只回答一句「你想记什么」,写下的每条日记配一句十几二十字的索引;下一场对话注入人格、手记和最近的几条,想知道更早的事它自己调 feiyu_recall,插件读索引挑出相关的那几条,正文才进上下文——索引永远不进它的视野,所以它看到的是日子,不是检索结果。遗忘是降权而不是删除。全程本地、发送前先拦掉密钥,档案是一个可读可改、能换账号带走的普通文件夹。零运行时依赖,无构建步骤。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "空气凤梨 <feiyu-memory@users.noreply.github.com>",
|
package/src/core/recall.mjs
CHANGED
|
@@ -382,3 +382,104 @@ export function softmaxPick(items, temperature, rng) {
|
|
|
382
382
|
}
|
|
383
383
|
return items[items.length - 1];
|
|
384
384
|
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* 从一句话里认出「他问的是哪段时间」。
|
|
388
|
+
*
|
|
389
|
+
* ── 为什么要它 ──
|
|
390
|
+
*
|
|
391
|
+
* 人问往事,一半靠内容、一半靠时间:
|
|
392
|
+
*
|
|
393
|
+
* 「赛博朋克那事」 内容好找
|
|
394
|
+
* 「上个月那个」 一个专有名词都没有 —— 拿去做内容检索,一个字都撞不上
|
|
395
|
+
*
|
|
396
|
+
* 以前索引里没有时间,日期只活在条目的 created_at 上,检索根本用不着它。
|
|
397
|
+
* 现在索引行前面带日期,这一层才终于有意义。
|
|
398
|
+
*
|
|
399
|
+
* ── 故意做得很土 ──
|
|
400
|
+
*
|
|
401
|
+
* 只认几种说法,认不出就返回 null,**绝不猜**。
|
|
402
|
+
* 猜错的代价比认不出大:认不出只是回到老路(按内容找),
|
|
403
|
+
* 猜错会把真正要的那条挡在时间窗外 —— 而且无声。
|
|
404
|
+
*
|
|
405
|
+
* @param {unknown} query
|
|
406
|
+
* @param {number} [now]
|
|
407
|
+
* @returns {{label: string, from: number, to: number} | null}
|
|
408
|
+
*/
|
|
409
|
+
export function parseTimeHint(query, now = Date.now()) {
|
|
410
|
+
if (typeof query !== 'string' || query === '') return null;
|
|
411
|
+
|
|
412
|
+
const DAY = 86400000;
|
|
413
|
+
const startOfDay = (t) => {
|
|
414
|
+
const d = new Date(t);
|
|
415
|
+
d.setHours(0, 0, 0, 0);
|
|
416
|
+
return d.getTime();
|
|
417
|
+
};
|
|
418
|
+
const today = startOfDay(now);
|
|
419
|
+
const dayRange = (fromDay, toDay) => ({ from: fromDay, to: toDay + DAY - 1 });
|
|
420
|
+
|
|
421
|
+
// 「大前天 / 前天 / 昨天 / 今天」—— 单个自然日
|
|
422
|
+
const single = [
|
|
423
|
+
['大前天', 3, '大前天'],
|
|
424
|
+
['前天', 2, '前天'],
|
|
425
|
+
['昨天', 1, '昨天'],
|
|
426
|
+
['昨儿', 1, '昨天'],
|
|
427
|
+
['今天', 0, '今天'],
|
|
428
|
+
['今儿', 0, '今天'],
|
|
429
|
+
];
|
|
430
|
+
for (const [word, back, label] of single) {
|
|
431
|
+
if (query.includes(word)) {
|
|
432
|
+
const d = today - back * DAY;
|
|
433
|
+
return { label, ...dayRange(d, d) };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// 最近几天
|
|
438
|
+
for (const word of ['前两天', '前几天', '这几天', '这两天']) {
|
|
439
|
+
if (query.includes(word)) return { label: '最近三天', ...dayRange(today - 2 * DAY, today) };
|
|
440
|
+
}
|
|
441
|
+
if (query.includes('最近')) return { label: '最近一周', ...dayRange(today - 6 * DAY, today) };
|
|
442
|
+
|
|
443
|
+
// 自然周(周一为一周之始)
|
|
444
|
+
const weekday = (new Date(today).getDay() + 6) % 7; // 周一 = 0
|
|
445
|
+
if (query.includes('上周') || query.includes('上星期')) {
|
|
446
|
+
const thisMonday = today - weekday * DAY;
|
|
447
|
+
return { label: '上周', ...dayRange(thisMonday - 7 * DAY, thisMonday - DAY) };
|
|
448
|
+
}
|
|
449
|
+
if (query.includes('这周') || query.includes('本星期') || query.includes('本周')) {
|
|
450
|
+
const thisMonday = today - weekday * DAY;
|
|
451
|
+
return { label: '这周', ...dayRange(thisMonday, today) };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// 自然月
|
|
455
|
+
const d0 = new Date(today);
|
|
456
|
+
if (query.includes('上个月') || query.includes('上月')) {
|
|
457
|
+
const from = new Date(d0.getFullYear(), d0.getMonth() - 1, 1).getTime();
|
|
458
|
+
const to = new Date(d0.getFullYear(), d0.getMonth(), 1).getTime() - 1;
|
|
459
|
+
return { label: '上个月', from, to };
|
|
460
|
+
}
|
|
461
|
+
if (query.includes('这个月') || query.includes('本月')) {
|
|
462
|
+
const from = new Date(d0.getFullYear(), d0.getMonth(), 1).getTime();
|
|
463
|
+
return { label: '这个月', ...dayRange(from, today) };
|
|
464
|
+
}
|
|
465
|
+
if (query.includes('去年')) {
|
|
466
|
+
const from = new Date(d0.getFullYear() - 1, 0, 1).getTime();
|
|
467
|
+
const to = new Date(d0.getFullYear(), 0, 1).getTime() - 1;
|
|
468
|
+
return { label: '去年', from, to };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// 「X 月」—— 今年的那个月;如果还没到,就当去年
|
|
472
|
+
const m = query.match(/(\d{1,2})\s*月/);
|
|
473
|
+
if (m !== null) {
|
|
474
|
+
const month = Number(m[1]);
|
|
475
|
+
if (month >= 1 && month <= 12) {
|
|
476
|
+
let year = d0.getFullYear();
|
|
477
|
+
if (month > d0.getMonth() + 1) year -= 1;
|
|
478
|
+
const from = new Date(year, month - 1, 1).getTime();
|
|
479
|
+
const to = new Date(year, month, 1).getTime() - 1;
|
|
480
|
+
return { label: `${month} 月`, from, to };
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return null;
|
|
485
|
+
}
|
package/src/host/index.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
// 原来的 evaluateGate 那套关键词闸门拆了(见 turn-stopping 里的注释)。
|
|
41
41
|
// SUPPRESS_RE 留着 —— "别记"是用户的控制,不是启发式。
|
|
42
42
|
import { SUPPRESS_RE } from '../core/gate.mjs';
|
|
43
|
-
import { buildIndex, selectMemories, searchScores, queryHasSubstance, looksLikeWork, DEFAULT_RECALL_OPTIONS } from '../core/recall.mjs';
|
|
43
|
+
import { buildIndex, selectMemories, searchScores, queryHasSubstance, looksLikeWork, parseTimeHint, DEFAULT_RECALL_OPTIONS } from '../core/recall.mjs';
|
|
44
44
|
import { overlap } from '../core/keywords.mjs';
|
|
45
45
|
import { scrub } from '../core/privacy.mjs';
|
|
46
46
|
import { renderMemoryBlock, DEFAULT_BUDGET } from '../core/persona.mjs';
|
|
@@ -609,6 +609,33 @@ export function apply(ctx, rawConfig) {
|
|
|
609
609
|
* 为什么要落盘:`log()` 默认不写(debugLog 关着),
|
|
610
610
|
* 而"补了多少"是能证明这套东西到底跑没跑的唯一证据。
|
|
611
611
|
*/
|
|
612
|
+
/**
|
|
613
|
+
* 记一笔「挑往事」这次的用量。
|
|
614
|
+
*
|
|
615
|
+
* 为什么非记不可:**缓存命中率是看不见的。** 请求发出去、答案回来了、
|
|
616
|
+
* 一切正常,只有账单知道这次是 0.1 元/M 还是 1 元/M。
|
|
617
|
+
* 而 prompt 的字段顺序一旦写反(把会变的那句放前面),缓存就静悄悄地失效了 ——
|
|
618
|
+
* 今天就是这么栽的,全靠事后读日志才翻出来。
|
|
619
|
+
*
|
|
620
|
+
* 所以:每次挑完把 usage 落进 manifest,谁都能一眼看见。
|
|
621
|
+
*/
|
|
622
|
+
function markPick(entries, lines, usage) {
|
|
623
|
+
const u = usage ?? {};
|
|
624
|
+
archive.manifest = {
|
|
625
|
+
...archive.manifest,
|
|
626
|
+
pick_stats: {
|
|
627
|
+
at: Date.now(),
|
|
628
|
+
entries,
|
|
629
|
+
lines,
|
|
630
|
+
inputTokens: Number.isFinite(u.inputTokens) ? u.inputTokens : null,
|
|
631
|
+
cacheReadTokens: Number.isFinite(u.cacheReadTokens) ? u.cacheReadTokens : null,
|
|
632
|
+
outputTokens: Number.isFinite(u.outputTokens) ? u.outputTokens : null,
|
|
633
|
+
// 取不到 usage 的时候,把 finish 的形状留下来 —— 别再猜字段名。
|
|
634
|
+
// 只留前 300 字,够看清结构,不至于把整段响应塞进档案。
|
|
635
|
+
},
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
612
639
|
function markBackfill(total, done, state) {
|
|
613
640
|
archive.manifest = {
|
|
614
641
|
...archive.manifest,
|
|
@@ -2013,19 +2040,54 @@ const appendWindow = Number.isFinite(config.appendWindowTurns) ? config.appendWi
|
|
|
2013
2040
|
if (llmP === undefined) return null;
|
|
2014
2041
|
const all = archive.diary.filter((e) => typeof e.text === 'string' && e.text !== '');
|
|
2015
2042
|
if (all.length === 0) return [];
|
|
2016
|
-
|
|
2017
|
-
|
|
2043
|
+
|
|
2044
|
+
// ── 时间先收窄一次 ──
|
|
2045
|
+
//
|
|
2046
|
+
// 「上个月那个」「前两天我说过」—— 这种问法里一个专有名词都没有,
|
|
2047
|
+
// 拿去做内容检索,一个字都撞不上。而日期本来就在条目上,之前白白没用。
|
|
2048
|
+
//
|
|
2049
|
+
// 认得出时间就只在那个窗口里挑;窗口里一条都没有,就退回全量 ——
|
|
2050
|
+
// **绝不因为时间认错过而让她什么也找不到。**
|
|
2051
|
+
let narrowed = null;
|
|
2052
|
+
const timeHint = parseTimeHint(query, Date.now());
|
|
2053
|
+
if (timeHint !== null) {
|
|
2054
|
+
const inWindow = all.filter(
|
|
2055
|
+
(e) => Number.isFinite(e.created_at) && e.created_at >= timeHint.from && e.created_at <= timeHint.to,
|
|
2056
|
+
);
|
|
2057
|
+
if (inWindow.length > 0) {
|
|
2058
|
+
narrowed = timeHint;
|
|
2059
|
+
log(
|
|
2060
|
+
`挑往事:她问的是「${timeHint.label}」,先把 ${all.length} 条收到 ${inWindow.length} 条`,
|
|
2061
|
+
);
|
|
2062
|
+
} else {
|
|
2063
|
+
log(`挑往事:她问的是「${timeHint.label}」,但那个窗口里一条都没有,退回全量`);
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
const base = narrowed === null ? all : all.filter(
|
|
2068
|
+
(e) => Number.isFinite(e.created_at) && e.created_at >= narrowed.from && e.created_at <= narrowed.to,
|
|
2069
|
+
);
|
|
2070
|
+
const pool = base.slice(-400);
|
|
2071
|
+
if (base.length > pool.length) log(`挑往事:${base.length} 条只取最近 ${pool.length} 条`);
|
|
2072
|
+
|
|
2073
|
+
// 索引行前面带日期 —— 模型才有办法回答「那阵子」这种问法。
|
|
2074
|
+
const dayLabel = (t) => {
|
|
2075
|
+
if (!Number.isFinite(t)) return '??/??';
|
|
2076
|
+
const d = new Date(t);
|
|
2077
|
+
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`;
|
|
2078
|
+
};
|
|
2018
2079
|
const lines = pool.map((e) => {
|
|
2019
2080
|
const idx =
|
|
2020
2081
|
typeof e.summary === 'string' && e.summary !== ''
|
|
2021
2082
|
? e.summary
|
|
2022
2083
|
: String(e.text).replace(/\s+/g, ' ').slice(0, 30);
|
|
2023
|
-
return `${e.id} ${idx}`;
|
|
2084
|
+
return `${e.id} ${dayLabel(e.created_at)} ${idx}`;
|
|
2024
2085
|
});
|
|
2025
2086
|
|
|
2026
2087
|
const system =
|
|
2027
2088
|
'你在帮一条鱼回忆往事。她问了一句,你从她的日记索引里挑出最相关的几条。\n\n' +
|
|
2028
|
-
'每行是「id 空格 一句索引」。有些索引很粗(正文截断出来的),别嫌弃。\n' +
|
|
2089
|
+
'每行是「id 空格 日期 一句索引」。有些索引很粗(正文截断出来的),别嫌弃。\n' +
|
|
2090
|
+
'**如果她问的是一段时间(「那阵子」「上个月」),优先看日期。**\n' +
|
|
2029
2091
|
'只输出 JSON:{"ids":["d_20261028_1","d_20261028_2"]}\n' +
|
|
2030
2092
|
'没有相关的就输出 {"ids":[]}。最多 ' + limit + ' 条。**宁可少挑,别硬凑。**';
|
|
2031
2093
|
|
|
@@ -2036,12 +2098,23 @@ const appendWindow = Number.isFinite(config.appendWindowTurns) ? config.appendWi
|
|
|
2036
2098
|
|
|
2037
2099
|
const t0 = Date.now();
|
|
2038
2100
|
let text = '';
|
|
2101
|
+
let usageChunk;
|
|
2039
2102
|
try {
|
|
2040
2103
|
for await (const chunk of llmP.stream({
|
|
2041
2104
|
provider,
|
|
2042
2105
|
model,
|
|
2043
2106
|
system,
|
|
2044
|
-
|
|
2107
|
+
// ⚠️ **顺序是钱。** 稳定的大头(索引)必须在前面,会变的那一小句放最后。
|
|
2108
|
+
//
|
|
2109
|
+
// 缓存按**前缀**匹配:第一段一变,后面九千多 token 全部重算。
|
|
2110
|
+
// 原来写的是「她问:X \n\n 索引:…」—— query 在最前面,
|
|
2111
|
+
// 于是每一轮都从第一个 token 开始不命中,等于一直在按全价买这九千多 token。
|
|
2112
|
+
//
|
|
2113
|
+
// 换成索引在前、问题在后:命中价 0.1 元/M vs 全价 1 元/M,差十倍。
|
|
2114
|
+
// 只有"新增了日记"那天索引才变,一天也就几次。
|
|
2115
|
+
messages: [
|
|
2116
|
+
{ role: 'user', content: [{ type: 'text', text: `索引:\n${lines.join('\n')}\n\n她问:${query}` }] },
|
|
2117
|
+
],
|
|
2045
2118
|
maxTokens: 200,
|
|
2046
2119
|
temperature: 0,
|
|
2047
2120
|
// 同上:空字符串是非法档位,stream 会不返回
|
|
@@ -2050,12 +2123,27 @@ const appendWindow = Number.isFinite(config.appendWindowTurns) ? config.appendWi
|
|
|
2050
2123
|
: {}),
|
|
2051
2124
|
})) {
|
|
2052
2125
|
if (chunk?.type === 'text-delta') text += chunk.text;
|
|
2126
|
+
else if (chunk?.type === 'usage') {
|
|
2127
|
+
// 实测(2026-10-28):usage 不在 finish 上,它是**独立的一种 chunk**。
|
|
2128
|
+
// chunkTypes 探针打出来是 block-start,block-end,usage,finish ——
|
|
2129
|
+
// 我猜了两轮字段名才看到这个。
|
|
2130
|
+
usageChunk = chunk;
|
|
2131
|
+
}
|
|
2053
2132
|
}
|
|
2054
2133
|
} catch (err) {
|
|
2055
2134
|
warn(`挑往事失败,退回字面:${err instanceof Error ? err.message : String(err)}`);
|
|
2056
2135
|
return null;
|
|
2057
2136
|
}
|
|
2058
2137
|
|
|
2138
|
+
// 记一笔用量 —— 缓存有没有命中,只有 finish 里的 usage 说得清。
|
|
2139
|
+
// 不写日志(debugLog 默认关着),写 manifest:任何时候都能查。
|
|
2140
|
+
try {
|
|
2141
|
+
markPick(pool.length, lines.length, usageChunk?.usage ?? usageChunk?.data ?? usageChunk);
|
|
2142
|
+
await persist();
|
|
2143
|
+
} catch (err) {
|
|
2144
|
+
warn(`挑往事:记用量失败(不影响结果):${err instanceof Error ? err.message : String(err)}`);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2059
2147
|
const a = text.indexOf('{');
|
|
2060
2148
|
const b = text.lastIndexOf('}');
|
|
2061
2149
|
if (a < 0 || b <= a) {
|