sage-mem 0.9.16 → 0.9.17
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/README.md +18 -1
- package/lib/index.js +162 -31
- package/lib/memory/config.js +22 -0
- package/lib/memory/deliver.js +225 -0
- package/lib/memory/intent.js +175 -0
- package/lib/memory/naming.js +7 -0
- package/lib/memory/reader.js +289 -0
- package/lib/memory/retrieval.js +79 -11
- package/lib/memory/status.js +214 -0
- package/lib/tools/memory-tools.js +152 -0
- package/package.json +1 -1
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— 意图识别:这句话是不是在问自己的历史(纯函数,无 I/O)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不做分类器、为什么必须保守:判错的两个方向代价不对称。
|
|
5
|
+
* 漏判(该提示没提示)只是少花一点上下文,模型自己多读一次文件;
|
|
6
|
+
* 误判(不该抑制却抑制了重复正文、不该插话却插了一句)会让模型答错,
|
|
7
|
+
* 或者让这句提示本身被当成噪音彻底忽略。所以只在**明确**时才出声。
|
|
8
|
+
*
|
|
9
|
+
* 为什么不用问号当判据:「今天天气怎么样?」与「上次为什么这么定?」都带问号,
|
|
10
|
+
* 它区分不了任何东西;反过来,没有问号也完全可能是提问。任何以标点为信号的
|
|
11
|
+
* 规则都会在真实语料里翻车,所以这里一条都没有。
|
|
12
|
+
*
|
|
13
|
+
* 可解释性来源:`INTENT_RULES` **就是实现本身**。`detectIntent` 按数组顺序逐条试,
|
|
14
|
+
* 第一个命中的规则决定结果、并把命中的片段原文回报出来。规则表不是给文档看的副本 ——
|
|
15
|
+
* 改表就是改行为,不存在「表里写了一套、代码里另一套」的可能。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** 意图枚举。对外字段,必须稳定。 */
|
|
19
|
+
export const INTENTS = Object.freeze([
|
|
20
|
+
'explicit_recall', // 显式要求回看依据:「上次」「之前」「当时」「沿用」「依据」
|
|
21
|
+
'correction', // 更正:「不是…了」「改成」「不再」
|
|
22
|
+
'explain_prior', // 问历史决定的理由:「为什么当时」「当初为什么选」
|
|
23
|
+
'continue', // 续接:「继续」「接着」「然后呢」
|
|
24
|
+
'none',
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
/** 「这几类绝不能抑制重复正文」——核实、更正、问依据,用户就是要看原文。 */
|
|
28
|
+
export const NO_SUPPRESS_INTENTS = Object.freeze(['explicit_recall', 'correction', 'explain_prior'])
|
|
29
|
+
|
|
30
|
+
/** 「这几类算历史依赖」——够格触发正文补检索与缺口提示。 */
|
|
31
|
+
export const HISTORY_INTENTS = Object.freeze(['explicit_recall', 'correction', 'explain_prior', 'continue'])
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 可读的规则表(面板 / 文档要展示「凭什么这么判」)。
|
|
35
|
+
*
|
|
36
|
+
* **数组顺序就是优先级**:correction > explain_prior > explicit_recall > continue。
|
|
37
|
+
* 一句话里同时出现多个信号时(「上次那个不是改成两格了吗」),更正与问依据比
|
|
38
|
+
* 单纯「回看」更具体,所以排在前面。`none` 不是一条规则,而是「一条都没命中」。
|
|
39
|
+
*
|
|
40
|
+
* 写规则的约束:
|
|
41
|
+
* 1. 正则**不带 `g`**(带 g 的 test() 会记住 lastIndex,同一份规则跨调用行为漂移)。
|
|
42
|
+
* 2. 单个虚词不作为判据 ——「为什么」「之前」各自单独出现都不够,必须同现
|
|
43
|
+
* (否则「为什么天空是蓝的」会被当成在问历史)。
|
|
44
|
+
* 3. 匹配区间一律用 `[^。!?\n]{0,n}` 限宽,避免在长文本上回溯爆炸。
|
|
45
|
+
*/
|
|
46
|
+
export const INTENT_RULES = Object.freeze([
|
|
47
|
+
{
|
|
48
|
+
intent: 'correction',
|
|
49
|
+
patterns: Object.freeze([
|
|
50
|
+
// 「改成 / 改为 / 改掉 / 改了」:明确的变更动作。单独一个「改」不算 ——
|
|
51
|
+
//「这段代码怎么改」是求改法,不是宣告旧结论失效。
|
|
52
|
+
/改(?:成|为|掉|了)/,
|
|
53
|
+
// 「不再…了 / 不用…了 / 不要…了」:宣告旧做法失效。中间**必须真有一个对象**
|
|
54
|
+
//(「不用了,谢谢」只是客套,没有对象),并排除「不用这么客气吧」这类纯礼貌搭配 ——
|
|
55
|
+
// 否则一句客套就会让无关轮次也去翻正文。
|
|
56
|
+
/不(?:再|用|要|选|走|做)\s*(?!\s*(?:这么|那么)?(?:客气|麻烦|担心|着急|急|慌|谢))[^,。!?;\s][^,。!?;]{0,12}(?:了|吧)/,
|
|
57
|
+
// 「不是…了」:状态变了。**排除后面紧跟「吗/嘛/呢」的**——那是反问式核实
|
|
58
|
+
//(「不是已经定了吗」),它既不是更正的宣告、也不足以单独证明在问历史;
|
|
59
|
+
// 没有别的信号时就该落 none,宁可漏判也不要把反问判成更正。
|
|
60
|
+
/不是[^,。!?;\n]{0,16}了(?![吗嘛呢])/,
|
|
61
|
+
// 「换成 / 替换成」:同义改写,中文里与「改成」交替使用。
|
|
62
|
+
/(?:换|替换|替代)(?:成|为|掉|了)/,
|
|
63
|
+
// 用户自报更正:旧结论当场作废,是最强的一类信号。
|
|
64
|
+
/更正|纠正|订正|勘误|说错了|记错了|搞错了|弄错了|写错了|我改主意/,
|
|
65
|
+
]),
|
|
66
|
+
note: '更正优先于其它:用户可能同时说「上次说的那个,不是…了」——此时旧结论已作废,按回看处理会让模型引用错的那份。',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
intent: 'explain_prior',
|
|
70
|
+
patterns: Object.freeze([
|
|
71
|
+
// 「为什么 / 为啥 / 为何」+ 过去时点,**必须同现**。只靠「为什么」会把
|
|
72
|
+
//「为什么天空是蓝的」这类纯知识问题全拉进来。
|
|
73
|
+
/为(?:什么|啥|何)[^。!?\n]{0,24}(?:当时|当初|之前|先前|上次|上一次|原来|早先|那会儿)/,
|
|
74
|
+
/(?:当时|当初|之前|先前|上次|上一次|原来|早先|那会儿)[^。!?\n]{0,24}为(?:什么|啥|何)/,
|
|
75
|
+
// 「当时…的依据 / 理由 / 原因是什么」:问的是**过去那个决定凭什么**,
|
|
76
|
+
// 同属 explain_prior,前面那条只认「为什么」会漏掉这种更书的问法。
|
|
77
|
+
/(?:当时|当初|之前|先前|上次)[^。!?\n]{0,24}(?:理由|依据|原因|考虑|思路|出发点)[^。!?\n]{0,8}(?:是|有)?(?:什么|啥|哪)/,
|
|
78
|
+
]),
|
|
79
|
+
note: '问依据/理由要排在纯回看之前:用户不只想看到原文,还想知道当时为什么这么决定,正文补检索的目标段落不同。',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
intent: 'explicit_recall',
|
|
83
|
+
patterns: Object.freeze([
|
|
84
|
+
// 过去时点:用户主动把话题锚回历史。最直接的一类信号。
|
|
85
|
+
/上次|上回|上一次|之前|先前|以前|当时|当初|早先|那会儿/,
|
|
86
|
+
// 「沿用 / 照旧 / 按原来」:明确要求把旧结论继续用下去。
|
|
87
|
+
/沿用|照旧|按原(?:来|先)|按之前|按照之前|还是按/,
|
|
88
|
+
// 「依据 / 出处」:要求给出根据 → 用户要看原文,不能拿摘要糊弄。
|
|
89
|
+
/依据|出处|根据是(?:什么|啥)|哪来的(?:结论|说法|数字)/,
|
|
90
|
+
// 「还记得 / 记得吗 / 回忆 / 翻记录」:明说要回看。
|
|
91
|
+
/还记得|记得(?:吗|么)|回忆一下|回想一下|翻(?:一下)?(?:记忆|记录|历史)|查(?:一下)?(?:记忆|记录|历史)/,
|
|
92
|
+
// 「说好的 / 约定过的」:把过去的约定当判据。
|
|
93
|
+
/说好(?:的|了)|约定(?:过|好)(?:的)?|定好(?:的|了)|商量(?:过|好)(?:的)?/,
|
|
94
|
+
]),
|
|
95
|
+
note: '显式回看是「用户要核实」的信号,绝不能抑制重复正文:他要看的就是原文。',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
intent: 'continue',
|
|
99
|
+
patterns: Object.freeze([
|
|
100
|
+
// 单独的「继续 / 接着 / 往下」是典型续接指令。要求它**在句首或句尾**,
|
|
101
|
+
// 是为了放过「在继续讨论前,先解释一下依赖注入」这种夹在中间的用法。
|
|
102
|
+
/^继续|(?:继续|接着|往下)(?:吧|啊|呀|哦|呢)?$/,
|
|
103
|
+
// 「然后呢 / 然后干什么」:追问下一步。
|
|
104
|
+
/然后呢|然后呐|然后(?:怎么办|做什么|做啥|干啥)/,
|
|
105
|
+
// 「接下来 / 下一步」。
|
|
106
|
+
/接下来(?:呢|做什么|做啥|怎么办|干(?:什么|啥))|下一步(?:呢|做什么|做啥|怎么办)/,
|
|
107
|
+
// 「接着 + 动作」:「接着来 / 接着说」。要求后面真跟动作,
|
|
108
|
+
// 避免「接着这个问题」被误判成续接。
|
|
109
|
+
/^接着(?:来|做|说|写|改|跑|干|搞|讲|看|读)/,
|
|
110
|
+
// 英文口令。整句是英文续接指令时同样成立。
|
|
111
|
+
/^(?:go on|continue|keep going|resume)$/i,
|
|
112
|
+
]),
|
|
113
|
+
note: '续接是最弱的一类历史依赖:它没说要回看什么,只是把话头交回上一步,所以排在最后、且只在句首句尾这种明确位置成立。',
|
|
114
|
+
},
|
|
115
|
+
])
|
|
116
|
+
|
|
117
|
+
/** 单次判定最多回报几条命中片段 —— matched 是给人看的解释,不是全量日志。 */
|
|
118
|
+
const MAX_MATCHED = 8
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 只扫描前 N 个字符。
|
|
122
|
+
*
|
|
123
|
+
* 超长输入通常是粘贴进来的文档或代码,不是一句对话。按保守方向,宁可在长文里
|
|
124
|
+
* 漏判,也不要因为正文里恰好出现「上次」「之前」就把整段粘贴当成历史追问。
|
|
125
|
+
* 这个上限同时把最坏情况的计算量钉住。
|
|
126
|
+
*/
|
|
127
|
+
const SCAN_LIMIT = 4000
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 判定用户这句话的意图。
|
|
131
|
+
*
|
|
132
|
+
* @param {string} query
|
|
133
|
+
* @returns {{intent: string, matched: string[], historyDependent: boolean, suppressible: boolean}}
|
|
134
|
+
* matched 是**命中的规则片段原文**(可解释性的来源,别只返回一个枚举)。
|
|
135
|
+
*/
|
|
136
|
+
export function detectIntent(query) {
|
|
137
|
+
// 非字符串一律按 none 处理,不做隐式 toString:把一个对象或数字「猜」成话,
|
|
138
|
+
// 只会让误判有机会溜进来;空串同理(没有任何信号)。
|
|
139
|
+
const text = typeof query === 'string' ? query.slice(0, SCAN_LIMIT) : ''
|
|
140
|
+
if (!text.trim()) return outcome('none', [])
|
|
141
|
+
for (const rule of INTENT_RULES) {
|
|
142
|
+
const matched = matchPatterns(text, rule.patterns)
|
|
143
|
+
if (matched.length) return outcome(rule.intent, matched)
|
|
144
|
+
}
|
|
145
|
+
return outcome('none', [])
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 逐条正则收集命中的**原文片段**(去重,上限 MAX_MATCHED)。
|
|
150
|
+
*
|
|
151
|
+
* 为什么克隆一份带 `g` 的副本:规则表里的正则刻意不带 g,这样表可以被任何人
|
|
152
|
+
* 反复 test() 而不受 lastIndex 影响;而这里要拿到全部片段原文,需要 g。
|
|
153
|
+
*/
|
|
154
|
+
function matchPatterns(text, patterns) {
|
|
155
|
+
const found = []
|
|
156
|
+
for (const re of patterns) {
|
|
157
|
+
const flags = re.flags.replace(/[gy]/g, '') + 'g'
|
|
158
|
+
for (const m of text.matchAll(new RegExp(re.source, flags))) {
|
|
159
|
+
if (!m[0]) continue
|
|
160
|
+
if (!found.includes(m[0])) found.push(m[0])
|
|
161
|
+
if (found.length >= MAX_MATCHED) return found
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return found
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 组装返回值。suppressible / historyDependent 都从常量推导,避免两处各写一份。 */
|
|
168
|
+
function outcome(intent, matched) {
|
|
169
|
+
return {
|
|
170
|
+
intent,
|
|
171
|
+
matched,
|
|
172
|
+
historyDependent: HISTORY_INTENTS.includes(intent),
|
|
173
|
+
suppressible: !NO_SUPPRESS_INTENTS.includes(intent),
|
|
174
|
+
}
|
|
175
|
+
}
|
package/lib/memory/naming.js
CHANGED
|
@@ -53,6 +53,13 @@ export function safeName(raw) {
|
|
|
53
53
|
// 实测更糟:`writeFile('MEMORY.md:evil.md')` 会在 `rename` 处抛**未捕获**的 EINVAL ——
|
|
54
54
|
// 违背「拒绝一律返回 {ok:false}、不抛」这条契约。正常文件名不允许 `:`,直接拒。
|
|
55
55
|
if (raw.includes(':')) return null
|
|
56
|
+
// `\` 是 `:` 那条理由的另一半:**它是 Windows 的路径分隔符,在 Linux 上却只是普通字符**。
|
|
57
|
+
// 于是 `basename('..\\a.md')` 在 Windows 上得到 `a.md`(被上面那条判据拒掉),
|
|
58
|
+
// 在 Linux 上原样返回 `..\a.md`(整条通过)—— 同一份输入在两个平台上得出相反的合法性。
|
|
59
|
+
// 而记忆目录是会被同步 / 拷贝 / 备份还原的:**在 Linux 上合法落盘的名字,同步到 Windows
|
|
60
|
+
// 就是一个目录穿越**。判定必须与平台无关,所以无条件拒。
|
|
61
|
+
// (这条是 Linux CI 抓出来的:本机 Windows 上测试一直是绿的。)
|
|
62
|
+
if (raw.includes('\\')) return null
|
|
56
63
|
if (!/\.md$/i.test(raw)) return null
|
|
57
64
|
if (isNonEntry(raw)) return null
|
|
58
65
|
return raw
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— 统一的安全读取入口(按行分页 + revision 校验)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有这一层:0.9.17 会给模型一个 `read_memory` 工具,它必须走**同一条**读路径。
|
|
5
|
+
* 工具一旦自己拼路径、自己判「这个文件名行不行」,就多出一条**绕过闸门的读路径** ——
|
|
6
|
+
* 符号链接 / 目录联接 / 保留名的另一个名字(8.3 短名、硬链接)这些手法,注入链与设置页
|
|
7
|
+
* 都拦住了,新工具会原样把它们放行。所以这里刻意**不新写任何路径判据**:
|
|
8
|
+
* `safeName` → `shadowsReserved` → `inspectMemoryEntry`,与 `MemoryGateway.readFile`
|
|
9
|
+
* 同序同函数。顺序本身是契约的一部分:先认名字才谈路径,先认身份才谈内容。
|
|
10
|
+
*
|
|
11
|
+
* 分页只做两件事:把行号锚在**整份文件**上(与 `chunks.js` 的段落行号同一口径,中间不许再
|
|
12
|
+
* 换算一次 —— 换算是这类功能最常见的错位来源)、把窗口按 **UTF-8 字节**卡住。
|
|
13
|
+
* 为什么必须是字节:一个汉字 3 字节,按 `String.length` 卡窗口会实发三倍体积。
|
|
14
|
+
* 为什么要有 `startByte`:单行的字节数可以大于整个窗口,只按行号推进的话下一轮还是这一行
|
|
15
|
+
* —— 那就是一个**不前进的分页循环**。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { MEMORY_DIR } from './config.js'
|
|
19
|
+
import { inspectMemoryEntry, shadowsReserved } from './scan.js'
|
|
20
|
+
import { safeName } from './naming.js'
|
|
21
|
+
import { revisionOf } from './chunks.js'
|
|
22
|
+
import { readFile } from 'node:fs/promises'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
|
|
25
|
+
/** 一屏默认给多少字节。4 KB ≈ 一屏可读正文,比一次注入的 60 KB 会话预算小一个量级。 */
|
|
26
|
+
export const DEFAULT_MAX_BYTES = 4096
|
|
27
|
+
|
|
28
|
+
/** 单次上限。再大就失去「分页」的意义了(一次全量读反而更省往返)。 */
|
|
29
|
+
export const MAX_MAX_BYTES = 65536
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 下限 = 一个 UTF-8 字符最多占的字节数。
|
|
33
|
+
*
|
|
34
|
+
* 低于它就没有任何一次读取**能保证前进**:单行超长那条路要靠「取下一个字节窗口」推进,
|
|
35
|
+
* 而窗口装不下哪怕一个汉字时窗口只能是空的 —— 空窗口 + 原地行号 = 死循环。
|
|
36
|
+
* 所以归一化时把小于它的请求抬到它,而不是让调用方拿到一个必然死循环的参数。
|
|
37
|
+
*/
|
|
38
|
+
export const MIN_MAX_BYTES = 4
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 归一化请求。**不抛**:非法返回 `{error}`,由调用方转成 `{ok:false, code:'invalid'}`。
|
|
42
|
+
*
|
|
43
|
+
* `name` 直接过 `safeName`(唯一的名字校验函数):它同时挡掉路径成分、`:` 交替数据流、
|
|
44
|
+
* 非 `.md`、以及索引 / 流水这类非条目。返回的是**磁盘上的原始大小写**。
|
|
45
|
+
*
|
|
46
|
+
* @param {object} input
|
|
47
|
+
* @returns {{name:string,revision:string,startLine:number,startByte:number,maxBytes:number}|{error:string}}
|
|
48
|
+
*/
|
|
49
|
+
export function normalizeReadRequest(input = {}) {
|
|
50
|
+
const raw = input && typeof input === 'object' ? input : {}
|
|
51
|
+
const name = safeName(raw.name)
|
|
52
|
+
if (!name) return { error: 'invalid_name' }
|
|
53
|
+
/**
|
|
54
|
+
* revision 是可选的:带了就必须对得上,没带(或不是非空字符串)就照读。
|
|
55
|
+
* 传个数字 / 对象进来只当「没带」——**不新增一个错误码**,因为「你没给我 revision」
|
|
56
|
+
* 和「你给的 revision 是错的」是两回事,前者不该让一次读失败。
|
|
57
|
+
*/
|
|
58
|
+
const revision = typeof raw.revision === 'string' && raw.revision !== '' ? raw.revision : ''
|
|
59
|
+
const startLine = integerOr(raw.startLine, 1, 1)
|
|
60
|
+
if (startLine === null) return { error: 'invalid_start' }
|
|
61
|
+
const startByte = integerOr(raw.startByte, 0, 0)
|
|
62
|
+
if (startByte === null) return { error: 'invalid_start' }
|
|
63
|
+
const maxBytes = maxBytesOf(raw.maxBytes)
|
|
64
|
+
if (maxBytes === null) return { error: 'invalid_bytes' }
|
|
65
|
+
return { name, revision, startLine, startByte, maxBytes }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 严格整数:缺省给 dflt;非 number / 非整数 / 低于 min 一律 null(不静默取整)。 */
|
|
69
|
+
function integerOr(value, dflt, min) {
|
|
70
|
+
if (value === undefined || value === null || value === '') return dflt
|
|
71
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < min) return null
|
|
72
|
+
return value
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 字节窗口的收敛:≥1 的数字取整后夹在 [MIN_MAX_BYTES, MAX_MAX_BYTES]。
|
|
77
|
+
*
|
|
78
|
+
* 上限是给调用方的护栏而不是拒绝理由 —— 要得比顶格还大就按顶格给,不因为「要太多」
|
|
79
|
+
* 让整条读失败;下限见 MIN_MAX_BYTES(低于它就保证不了前进)。
|
|
80
|
+
*/
|
|
81
|
+
function maxBytesOf(value) {
|
|
82
|
+
if (value === undefined || value === null || value === '') return DEFAULT_MAX_BYTES
|
|
83
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) return null
|
|
84
|
+
return Math.max(MIN_MAX_BYTES, Math.min(Math.floor(value), MAX_MAX_BYTES))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 一个码点的 UTF-8 字节数。不调 Buffer:分页每一步都要按字符走一遍,够用且便宜。 */
|
|
88
|
+
function utf8LenOf(cp) {
|
|
89
|
+
if (cp < 0x80) return 1
|
|
90
|
+
if (cp < 0x800) return 2
|
|
91
|
+
if (cp < 0x10000) return 3
|
|
92
|
+
return 4
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 行内 UTF-8 字节偏移 → 字符下标;**不落在字符边界上返回 -1**。
|
|
97
|
+
*
|
|
98
|
+
* 越界也用 -1 表示:`startByte` 与 `startLine` 是一对(同一行内的偏移),对不上就是调用方
|
|
99
|
+
* 拿着过期游标,宁可按非法拒掉,也不猜一个「大概的位置」去读错内容。
|
|
100
|
+
*/
|
|
101
|
+
function charIndexOfByte(text, byteOff) {
|
|
102
|
+
if (byteOff === 0) return 0
|
|
103
|
+
let bytes = 0
|
|
104
|
+
let i = 0
|
|
105
|
+
for (const ch of text) {
|
|
106
|
+
bytes += utf8LenOf(ch.codePointAt(0))
|
|
107
|
+
i += ch.length
|
|
108
|
+
if (bytes === byteOff) return i
|
|
109
|
+
if (bytes > byteOff) return -1
|
|
110
|
+
}
|
|
111
|
+
return -1
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 从字符下标起,取尽可能多、但 UTF-8 字节数不超过 limit 的**字符数**(同样不切碎多字节字符)。 */
|
|
115
|
+
function charsWithinBytes(text, fromChar, limit) {
|
|
116
|
+
let bytes = 0
|
|
117
|
+
let i = fromChar
|
|
118
|
+
while (i < text.length) {
|
|
119
|
+
const cp = text.codePointAt(i)
|
|
120
|
+
const step = cp > 0xffff ? 2 : 1
|
|
121
|
+
const b = utf8LenOf(cp)
|
|
122
|
+
if (bytes + b > limit) break
|
|
123
|
+
bytes += b
|
|
124
|
+
i += step
|
|
125
|
+
}
|
|
126
|
+
return i - fromChar
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 切一屏。返回的行号一律是**整份文件**的 1-based 行号。
|
|
131
|
+
*
|
|
132
|
+
* 两条路互斥:
|
|
133
|
+
* - 首行从 startByte 起的剩余本身 > maxBytes → 单行超长:只给这一行的字节窗口,
|
|
134
|
+
* 并把 nextStartByte(仍在同一行内)交给调用方。此时 `lineTooLong: true`、
|
|
135
|
+
* `nextStartLine === startLine`。
|
|
136
|
+
* - 否则按整行累积,遇到第一行放不下的就停:`nextStartLine` = 那一行、`truncated: true`。
|
|
137
|
+
* 下一次它是首行,若它自己也超长就走上面那条路。
|
|
138
|
+
*
|
|
139
|
+
* 换行符按 1 字节记进账:不记的话多行窗口的真实字节数会把 maxBytes 顶穿(每行漏 1 字节)。
|
|
140
|
+
* 返回内容里的换行统一成 `\n`(与 chunks.js 的 `split(/\r?\n/)` 同一口径),CRLF 文件的
|
|
141
|
+
* 行号与段落划分因此和 LF 文件完全一致。
|
|
142
|
+
*/
|
|
143
|
+
function sliceOf(lines, startLine, startByte, maxBytes) {
|
|
144
|
+
const firstText = lines[startLine - 1]
|
|
145
|
+
const fromChar = startByte > 0 ? charIndexOfByte(firstText, startByte) : 0
|
|
146
|
+
if (fromChar < 0) return { error: 'invalid_start' }
|
|
147
|
+
const restBytes = Buffer.byteLength(firstText.slice(fromChar), 'utf8')
|
|
148
|
+
if (restBytes > maxBytes) {
|
|
149
|
+
const chars = charsWithinBytes(firstText, fromChar, maxBytes)
|
|
150
|
+
const text = firstText.slice(fromChar, fromChar + chars)
|
|
151
|
+
const bytes = Buffer.byteLength(text, 'utf8')
|
|
152
|
+
return {
|
|
153
|
+
startLine,
|
|
154
|
+
endLine: startLine,
|
|
155
|
+
content: text,
|
|
156
|
+
bytes,
|
|
157
|
+
truncated: true,
|
|
158
|
+
lineTooLong: true,
|
|
159
|
+
nextStartLine: startLine,
|
|
160
|
+
nextStartByte: startByte + bytes,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const parts = []
|
|
164
|
+
let bytes = 0
|
|
165
|
+
let lastLine = startLine
|
|
166
|
+
let nextStartLine = null
|
|
167
|
+
for (let i = startLine - 1; i < lines.length; i++) {
|
|
168
|
+
const isFirst = i === startLine - 1
|
|
169
|
+
const seg = isFirst && fromChar > 0 ? lines[i].slice(fromChar) : lines[i]
|
|
170
|
+
const segBytes = Buffer.byteLength(seg, 'utf8')
|
|
171
|
+
const sep = parts.length ? 1 : 0
|
|
172
|
+
// 首行在这一步必定装得下(restBytes ≤ maxBytes 且没有 sep),所以不会一上来就 break。
|
|
173
|
+
if (bytes + sep + segBytes > maxBytes) {
|
|
174
|
+
nextStartLine = i + 1
|
|
175
|
+
break
|
|
176
|
+
}
|
|
177
|
+
parts.push(seg)
|
|
178
|
+
bytes += sep + segBytes
|
|
179
|
+
lastLine = i + 1
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
startLine,
|
|
183
|
+
endLine: lastLine,
|
|
184
|
+
content: parts.join('\n'),
|
|
185
|
+
bytes,
|
|
186
|
+
truncated: nextStartLine !== null,
|
|
187
|
+
lineTooLong: false,
|
|
188
|
+
nextStartLine,
|
|
189
|
+
// 只有单行超长那条路会给出非 0 偏移;这里保持数字类型(不用 null),免得工具 schema
|
|
190
|
+
// 里出现一个 nullable 字段 —— 读到底时 nextStartLine 已经是 null,够判了。
|
|
191
|
+
nextStartByte: 0,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 归一化错误码 → 给模型看的一句话(不出路径:本仓库是公开仓库,错误串会进工具输出)。 */
|
|
196
|
+
function invalidMessage(error) {
|
|
197
|
+
if (error === 'invalid_name') return '文件名不合法:只认记忆目录下的单段 .md,且不能是索引 / 流水这类非条目'
|
|
198
|
+
if (error === 'invalid_start') return '起始位置不合法:startLine 须是 ≥1 的整数,startByte 须是 ≥0 的整数且落在字符边界上'
|
|
199
|
+
return 'maxBytes 不合法:须是 ≥1 的数字'
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 读一段。**所有失败都返回对象,绝不抛** —— 工具层不许因为一次读失败把整轮对话炸掉。
|
|
204
|
+
*
|
|
205
|
+
* @param {{name:string,revision?:string,startLine?:number,startByte?:number,maxBytes?:number,dir?:string}} req
|
|
206
|
+
* `dir` 只用于测试注入(默认 `MEMORY_DIR`);生产调用方不许传它 —— 文件名一律经 `safeName`。
|
|
207
|
+
* @returns {Promise<object>} 见文件头的接口说明;`code ∈ invalid|not_found|denied|revision_changed|io_error`
|
|
208
|
+
*/
|
|
209
|
+
export async function readSlice(req = {}) {
|
|
210
|
+
try {
|
|
211
|
+
const norm = normalizeReadRequest(req)
|
|
212
|
+
if (norm.error) return { ok: false, code: 'invalid', error: invalidMessage(norm.error) }
|
|
213
|
+
const dir = typeof req?.dir === 'string' && req.dir !== '' ? req.dir : MEMORY_DIR
|
|
214
|
+
const full = join(dir, norm.name)
|
|
215
|
+
|
|
216
|
+
// ── 闸门:与 MemoryGateway.readFile 同序同函数,一条都不新写 ──
|
|
217
|
+
if (await shadowsReserved(full, dir)) {
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
code: 'denied',
|
|
221
|
+
error: `拒绝读取 ${norm.name}:这是某个保留名(索引 / 流水)的另一个名字`,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const gate = await inspectMemoryEntry(full)
|
|
225
|
+
// 体积闸门就用 inspectMemoryEntry 的默认上限(MAX_FILE_BYTES = 512 KB),与写入路径
|
|
226
|
+
// 同一把尺子:不传 limitBytes 是有意的 —— 上限只有一个来源,工具这条路不开后门。
|
|
227
|
+
if (!gate.ok) {
|
|
228
|
+
if (gate.code === 'missing') return { ok: false, code: 'not_found', error: `没有这份记忆:${norm.name}` }
|
|
229
|
+
// stat-failed 不是「形状不对」,是读不到属性(权限 / 盘掉线)→ 归 io_error 更好排查
|
|
230
|
+
if (gate.code === 'stat-failed') return { ok: false, code: 'io_error', error: `读不到文件属性:${gate.error}` }
|
|
231
|
+
return { ok: false, code: 'denied', error: `拒绝读取 ${norm.name}:${gate.error}` }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const content = await readFile(full, 'utf8')
|
|
235
|
+
const revision = revisionOf(content)
|
|
236
|
+
/**
|
|
237
|
+
* 带了 revision 就一定校验,且**不返回任何内容 / 行号**:过期行号比「读不到」危险得多 ——
|
|
238
|
+
* 调用方会以为自己在说「那份文件的第 40 行」,实际读到的是改过之后的第 40 行。
|
|
239
|
+
*/
|
|
240
|
+
if (norm.revision && norm.revision !== revision) {
|
|
241
|
+
return {
|
|
242
|
+
ok: false,
|
|
243
|
+
code: 'revision_changed',
|
|
244
|
+
revision,
|
|
245
|
+
error: `内容已变(当前 ${revision}),请按新 revision 重新定位`,
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 行号口径 = `content.split(/\r?\n/)` 的下标 + 1,与 chunks.js 完全同一份写法。
|
|
251
|
+
* 所以以换行结尾的文件会多出一个空行 —— 这个「多出来的第 N 行」是**故意**与分段层对齐的:
|
|
252
|
+
* 两边各自立一套口径,才会出现「工具读第 40 行、段落说第 40 行、内容却不是同一行」这种错位。
|
|
253
|
+
*/
|
|
254
|
+
const lines = content.split(/\r?\n/)
|
|
255
|
+
const totalLines = lines.length
|
|
256
|
+
if (norm.startLine > totalLines) {
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
code: 'invalid',
|
|
260
|
+
error: `起始行 ${norm.startLine} 超出行数 ${totalLines}(行号是 1-based,可能拿着过期游标)`,
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const cut = sliceOf(lines, norm.startLine, norm.startByte, norm.maxBytes)
|
|
265
|
+
if (cut.error) {
|
|
266
|
+
return { ok: false, code: 'invalid', error: `起始字节 ${norm.startByte} 不落在字符边界上(或超出该行)` }
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
ok: true,
|
|
270
|
+
name: norm.name,
|
|
271
|
+
revision,
|
|
272
|
+
startLine: cut.startLine,
|
|
273
|
+
endLine: cut.endLine,
|
|
274
|
+
// 请求里的行内偏移原样回显,客户端据此接着算 nextStartByte(同一把尺子)
|
|
275
|
+
startByte: norm.startByte,
|
|
276
|
+
nextStartLine: cut.nextStartLine,
|
|
277
|
+
nextStartByte: cut.nextStartByte,
|
|
278
|
+
totalLines,
|
|
279
|
+
content: cut.content,
|
|
280
|
+
bytes: cut.bytes,
|
|
281
|
+
truncated: cut.truncated,
|
|
282
|
+
lineTooLong: cut.lineTooLong,
|
|
283
|
+
}
|
|
284
|
+
} catch (err) {
|
|
285
|
+
// 扫到读之间被删是正常状态(不是故障);其余一律出声,别让「读不到」变成无声空内容。
|
|
286
|
+
if (err?.code === 'ENOENT') return { ok: false, code: 'not_found', error: '这份记忆在读取过程中被删掉了' }
|
|
287
|
+
return { ok: false, code: 'io_error', error: `读取失败(${err?.code ?? err?.message ?? err})` }
|
|
288
|
+
}
|
|
289
|
+
}
|
package/lib/memory/retrieval.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { MAX_BASELINE, MAX_RESULTS } from './config.js'
|
|
6
|
+
import { buildStats, evidence, idfOf, queryTermsOf, termFreq, tokenize } from './bm25.js'
|
|
6
7
|
|
|
7
8
|
/** 提取消息文本,兼容 content 为字符串或 text-block 数组两种形态。 */
|
|
8
9
|
export function extractText(content) {
|
|
@@ -33,6 +34,49 @@ export function score(query, text) {
|
|
|
33
34
|
return hit / q.size
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* 摘要命中的相关性门槛:**命中的词元得带够信息量**,按语料自身的 IDF 尺度归一。
|
|
39
|
+
*
|
|
40
|
+
* 为什么单靠「覆盖率 > 0」不够(在真实记忆目录上实测):中文里「怎么」「什么」这类词元
|
|
41
|
+
* 在大量 `description` 里都有,于是一句完全无关的「今天天气怎么样」能靠一个「怎么」
|
|
42
|
+
* 命中 **14 份**记忆、一次注入近 30 KB —— 而它们全都卡在同一条分数线上
|
|
43
|
+
* (实测中位数 = 最高值 0.167),排序完全分不出来。
|
|
44
|
+
*
|
|
45
|
+
* ⚠️ **判据是按语料尺度归一的比值,不是一个绝对量。** 这是同一个坑的第三次:
|
|
46
|
+
* `idf` 随语料规模**对数增长**,绝对阈值换一个语料量级就失效。
|
|
47
|
+
* - 绝对 `sum >= 1.5` 在百份量级的目录里等于「命中一个出现在约四分之一文件里的词元就放行」——
|
|
48
|
+
* 实测一个高频虚词单靠自己 sum 就有 2.01,一整批候选全部通过,门槛形同虚设;
|
|
49
|
+
* - 同一批真命中在 2 份的小目录里又全被杀(`idf(df=1)` 只有 0.69)。
|
|
50
|
+
* 分母取 `idfOf(1, N)` = 这套语料里**单个词元的证据上限**(全库独一份的权重)——
|
|
51
|
+
* 归一之后同一套阈值在两个量级上都成立。
|
|
52
|
+
*
|
|
53
|
+
* 效果实测(归一阈值 0.6):
|
|
54
|
+
* - 真实目录:无关问题的候选数 **46 → 7**(-85%),相关题每问仍留 3–10 条
|
|
55
|
+
* - 离线评测集(端到端「摘要 ∪ 正文」):**不只是没退化,而是更好了** ——
|
|
56
|
+
* Recall@5 58/69 → 60/69、段落严格命中 29/47 → 30/47、误注入 10/71 → 4/71。
|
|
57
|
+
* 原因:被门槛清掉的弱摘要候选原先在**挤占前 5 名的名额**,把更好的正文命中挡在外面。
|
|
58
|
+
* ⚠️ 只量摘要一侧会得出「掉 8 个点」的错误结论 —— 务必量端到端。
|
|
59
|
+
*
|
|
60
|
+
* 要放宽就在调用处传 `{ minRatio: 0 }`,别改这个常量。
|
|
61
|
+
*/
|
|
62
|
+
export const MIN_SUMMARY_EVIDENCE_RATIO = 0.6
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 门槛生效所需的最小语料规模。
|
|
66
|
+
*
|
|
67
|
+
* IDF 要**够多的文档**才有分辨率。八份以下的目录里,「到处都有」与「只此一份」在数值上
|
|
68
|
+
* 几乎不可分:实测两份记忆时 `idf(df=1)` 只有 0.69,而一份语料里 4 份记忆的描述都相同
|
|
69
|
+
* (df = N = 4)时更是趋近 0 —— 门槛会把**全部真命中**一起杀掉,用户看到的是「记忆不再
|
|
70
|
+
* 注入」。小目录本来就没有多少噪音可防,所以这里直接不设门槛。
|
|
71
|
+
*/
|
|
72
|
+
export const MIN_GATE_CORPUS = 8
|
|
73
|
+
|
|
74
|
+
/** 摘要面的检索文本:`description` + `aliases` + 文件名。**打分与语料统计必须用同一份**。 */
|
|
75
|
+
export function summaryHay(f) {
|
|
76
|
+
const aliasText = Array.isArray(f.aliases) && f.aliases.length ? ' ' + f.aliases.join(' ') : ''
|
|
77
|
+
return f.description + aliasText + ' ' + f.file.replace(/\.md$/, '').replace(/[_-]/g, ' ')
|
|
78
|
+
}
|
|
79
|
+
|
|
36
80
|
/**
|
|
37
81
|
* 相关性打分只用 frontmatter 的 description + 文件名,不扫全文。
|
|
38
82
|
*
|
|
@@ -41,18 +85,42 @@ export function score(query, text) {
|
|
|
41
85
|
* 蒙中足量双字组,连寒暄问句都能注入上千 token;而真正该命中的那条反被
|
|
42
86
|
* 挤掉。description 是「一句话说清这条是什么」的精准摘要,用它当检索
|
|
43
87
|
* 信号,噪音大幅下降。
|
|
88
|
+
*
|
|
89
|
+
* @param {string} query
|
|
90
|
+
* @param {Array} files
|
|
91
|
+
* @param {{gate?: boolean, minRatio?: number}} [opts] — `gate:false` 关掉证据门槛
|
|
92
|
+
* (测试「名额上限」这类与相关性无关的用例要它)
|
|
44
93
|
*/
|
|
45
|
-
export function selectRelevant(query, files) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
94
|
+
export function selectRelevant(query, files, opts = {}) {
|
|
95
|
+
const list = Array.isArray(files) ? files : []
|
|
96
|
+
const hay = list.map(summaryHay)
|
|
97
|
+
const entries = list.map((f, i) => ({ f, i, score: score(query, hay[i]) }))
|
|
98
|
+
const hit = entries.filter(e => e.score > 0).sort((a, b) => b.score - a.score)
|
|
99
|
+
const keep = (arr) => arr.slice(0, MAX_RESULTS).map(e => ({ ...e.f, score: e.score }))
|
|
100
|
+
if (opts.gate === false || hit.length === 0) return keep(hit)
|
|
101
|
+
// 语料太小就别用 IDF 判(见 MIN_GATE_CORPUS)。
|
|
102
|
+
if (list.length < MIN_GATE_CORPUS) return keep(hit)
|
|
103
|
+
|
|
104
|
+
const terms = queryTermsOf(query)
|
|
105
|
+
if (!terms.length) return keep(hit)
|
|
106
|
+
// 语料统计建在**全部文件**的摘要面上,不是只建在命中的那些上:IDF 的分母是
|
|
107
|
+
// 「多少份记忆的摘要里有这个词元」,只拿命中集去算会把稀有词元算成随处可见。
|
|
108
|
+
const tokens = hay.map(tokenize)
|
|
109
|
+
const stats = buildStats(tokens.map(t => ({ tokens: t })))
|
|
110
|
+
const tf = tokens.map(termFreq)
|
|
111
|
+
const minRatio = Number.isFinite(opts.minRatio) ? opts.minRatio : MIN_SUMMARY_EVIDENCE_RATIO
|
|
112
|
+
// **按语料自身的 IDF 尺度归一**,而不是用一个绝对量。
|
|
113
|
+
//
|
|
114
|
+
// 绝对量的教训(三次踩同一个坑,记下来免得第四次):`idf` 随语料规模对数增长,
|
|
115
|
+
// 所以「sum >= 1.5」在百份量级的目录里等于「命中一个约四分之一文件都有的词元就放行」——
|
|
116
|
+
// 实测一个 `怎么`(df=14,idf=1.93)单靠自己就有 sum=2.01,整批 14 份候选全部通过,
|
|
117
|
+
// 门槛退化成没有。而在 2 份的小目录里 `idf(df=1)` 只有 0.69,同一批真命中又全被杀。
|
|
118
|
+
// 归一之后同一套阈值在两个量级上都成立。
|
|
119
|
+
//
|
|
120
|
+
// 分母取 `idfOf(1, N)` = 「全库独一份的词元能提供的最大权重」,也就是这套语料里
|
|
121
|
+
// 单个词元的证据上限。
|
|
122
|
+
const maxIdf = idfOf(1, list.length) || 1
|
|
123
|
+
return keep(hit.filter(e => evidence(terms, tf[e.i], stats).sum >= minRatio * maxIdf))
|
|
56
124
|
}
|
|
57
125
|
|
|
58
126
|
/**
|