dsh-prime-memory 0.11.0 → 0.12.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/CHANGELOG.en.md +36 -1
- package/CHANGELOG.ja.md +36 -1
- package/CHANGELOG.ko.md +36 -1
- package/CHANGELOG.md +40 -0
- package/README.en.md +28 -0
- package/README.ja.md +25 -0
- package/README.ko.md +25 -0
- package/README.md +22 -0
- package/dist/client.js +54 -30
- package/dist/config.d.ts +39 -1
- package/dist/config.js +16 -0
- package/dist/conflict-service.d.ts +38 -0
- package/dist/conflict-service.js +65 -0
- package/dist/graph/search.d.ts +15 -0
- package/dist/graph/search.js +54 -1
- package/dist/hooks/recall.js +4 -0
- package/dist/index.d.ts +28 -1
- package/dist/index.js +12 -3
- package/dist/pipeline/l1.d.ts +7 -1
- package/dist/pipeline/l1.js +165 -20
- package/dist/pipeline/runner.js +6 -1
- package/dist/prompts/l1-dedup.d.ts +22 -1
- package/dist/prompts/l1-dedup.js +61 -4
- package/dist/stats.d.ts +12 -0
- package/dist/stats.js +222 -33
- package/dist/store/conflicts.d.ts +62 -0
- package/dist/store/conflicts.js +69 -0
- package/dist/store/graph-store.d.ts +72 -1
- package/dist/store/graph-store.js +165 -1
- package/dist/store/l1.d.ts +97 -4
- package/dist/store/l1.js +163 -21
- package/dist/store/receipts.d.ts +157 -0
- package/dist/store/receipts.js +139 -0
- package/dist/store/search-utils.d.ts +15 -0
- package/dist/store/search-utils.js +20 -0
- package/dist/store/session-modes.d.ts +7 -0
- package/dist/store/session-modes.js +9 -0
- package/dist/store/sqlite.d.ts +116 -8
- package/dist/store/sqlite.js +366 -46
- package/dist/store/vec-utils.d.ts +16 -0
- package/dist/store/vec-utils.js +24 -0
- package/dist/tools/index.js +204 -31
- package/dist/types.d.ts +48 -0
- package/dist/types.js +40 -0
- package/dist/workspace.d.ts +46 -0
- package/dist/workspace.js +105 -0
- package/package.json +9 -3
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/** 摘要格式版本。进入 canonical 串,使算法演进时**不会静默**让新旧凭证看起来可比。 */
|
|
2
|
+
export declare const DIGEST_FORMAT = "l1-receipts/v1";
|
|
3
|
+
/**
|
|
4
|
+
* 候选池**有序序列** → 稳定摘要(sha256 hex)。
|
|
5
|
+
*
|
|
6
|
+
* 语义:`searchCandidates` 返回的是**已排序**的候选池,「当时看到哪些候选、按什么序」
|
|
7
|
+
* 就是去重决策的输入。故本函数**顺序敏感**——换个序即不同摘要,而不是把候选当集合。
|
|
8
|
+
*
|
|
9
|
+
* 三项刻意的设计:
|
|
10
|
+
* - **顺序敏感**:见上。集合语义会丢掉"哪条候选排在最前"这一信息。
|
|
11
|
+
* - **重复不折叠**:`['r1','r1']` 与 `['r1']` 摘要不同。候选池里的重复本身是事实
|
|
12
|
+
* (例如同一记录被两条通路召回),折叠掉等于篡改输入。
|
|
13
|
+
* - **长度前缀编码**:逐项写 `${len}:${id}\n` 而非直接拼接。否则 `['a|b']` 与
|
|
14
|
+
* `['a','b']`(以及 `['a\nb']` 与 `['a','b']`)会碰撞——分隔符出现在 id 里时,
|
|
15
|
+
* 序列化就不再是单射,摘要也就失去了作为输入指纹的意义。
|
|
16
|
+
*
|
|
17
|
+
* 纯函数:无 I/O、无时间、无随机。同输入**恒**同输出,这是凭证可复算的前提。
|
|
18
|
+
*/
|
|
19
|
+
export declare function inputDigest(candidateIds: readonly string[]): string;
|
|
20
|
+
/**
|
|
21
|
+
* 凭证保留上限:**最多保留多少个 run**。
|
|
22
|
+
*
|
|
23
|
+
* 为什么按 **run 数**而不是按天数(§B task_18):
|
|
24
|
+
* 本策略要防的是「`memory.db` 无限增长」。时间窗口**给不出这个保证**——
|
|
25
|
+
* 阈值(比如 90 天)与写入速率无关,一个高频用户 90 天能写进任意多行,
|
|
26
|
+
* 无界增长只是被推迟,不是被消除。run 数上限则**直接给出行的上界**
|
|
27
|
+
* (`maxRuns × 每 run 记录数`),这是"有界"与"看起来有界"的区别。
|
|
28
|
+
* 代价是:一个几乎不用的用户,历史可能只剩最近 1000 次蒸馏——
|
|
29
|
+
* 但凭证是**诊断设施**,诊断需要的是"最近发生了什么",不是考古。
|
|
30
|
+
*
|
|
31
|
+
* 1000 这个量级的依据:一轮 L1 蒸馏通常落 5~20 条凭证,故上限约 1~2 万行
|
|
32
|
+
* (数 MB 量级,在同为单文件的 `memory.db` 里不构成压力);而 L1 蒸馏本身要
|
|
33
|
+
* 调一次 LLM,现实中每小时至多几次——1000 轮 ≈ 活跃使用数月。
|
|
34
|
+
*
|
|
35
|
+
* **为什么是常量而不是配置项**:配置项要接 schema、契约与设置页,为一个
|
|
36
|
+
* 诊断表的容量引入用户可见面,收益与面积不成比例(YAGNI)。真需要调,
|
|
37
|
+
* 改这里即可,且 `recordReceipts(rows, { maxRuns })` 已留出显式注入口。
|
|
38
|
+
*/
|
|
39
|
+
export declare const RECEIPTS_MAX_RUNS = 1000;
|
|
40
|
+
/** 裁剪策略的可选入参。省略时用 {@link RECEIPTS_MAX_RUNS}。 */
|
|
41
|
+
export interface ReceiptRetentionOptions {
|
|
42
|
+
/** 最多保留的 run 数;<=0 或非有限值视为"不裁剪"。 */
|
|
43
|
+
maxRuns?: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 凭证的决策类型。
|
|
47
|
+
* - 前四项是 LLM 明确给出的动作;
|
|
48
|
+
* - `skip_missing` 表示**根本没拿到可用决策**(模型没返回 / 返回了非法动作)。
|
|
49
|
+
* 刻意与 `skip` 分开:回溯"这条为什么没进记忆"时,「模型决定不存」与
|
|
50
|
+
* 「模型没答」是完全不同的两件事,合并会让 §B 失去一半诊断力。
|
|
51
|
+
* - `conflict`(§C 矛盾冻结)同样是**明确决策**,只是结论为"我判不了,交给人"。
|
|
52
|
+
* 它必须与 `skip_missing` 分开:漏登记会把这轮记成"模型没答",
|
|
53
|
+
* 而审计链据此得出的结论(这轮没决策)与事实(模型明确说了判不了)**正好相反**。
|
|
54
|
+
*/
|
|
55
|
+
export type L1ReceiptKind = 'store' | 'update' | 'merge' | 'skip' | 'conflict' | 'skip_missing';
|
|
56
|
+
/** 一条凭证(与 `l1_receipts` 表一行同形)。 */
|
|
57
|
+
export interface L1Receipt {
|
|
58
|
+
receiptId: string;
|
|
59
|
+
runId: string;
|
|
60
|
+
recordId: string;
|
|
61
|
+
kind: L1ReceiptKind;
|
|
62
|
+
inputDigest: string;
|
|
63
|
+
decidedAt: string;
|
|
64
|
+
}
|
|
65
|
+
/** 构建凭证所需的单条输入(与 `runExtraction` 的 `matches` 逐项对应)。 */
|
|
66
|
+
export interface ReceiptInput {
|
|
67
|
+
recordId: string;
|
|
68
|
+
/** 该次判定召回的候选池(有序,顺序进入摘要)。 */
|
|
69
|
+
candidateIds: readonly string[];
|
|
70
|
+
/** 模型给出的动作;缺失/非法时传 undefined。 */
|
|
71
|
+
action: string | undefined;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* §B 双维回溯的查询入参(task_19)。
|
|
75
|
+
*
|
|
76
|
+
* 两个维度对应两类**互不替代**的问题,故同为可选、同给时为 **AND**:
|
|
77
|
+
* - 只给 `recordId` →「这条记忆**出自哪一轮**,当时看到什么候选池、被判成了什么」
|
|
78
|
+
* - 只给 `runId` →「上一轮蒸馏都判了什么」(那一批的全部决策,跨多条记录)
|
|
79
|
+
* - 两维同给 →「这条记录在那一轮里被判成了什么」(唯一一条)
|
|
80
|
+
*
|
|
81
|
+
* **`recordId` 维度的取值面必须如实说清**:`pipeline/l1.ts` 对每条抽取结果执行
|
|
82
|
+
* `record_id: newId('mem')`——**每轮新铸 id,从不复用**。故一个 `record_id`
|
|
83
|
+
* 在现实中只可能属于**一轮** run,该维度目前**恒返回 0 或 1 行**。
|
|
84
|
+
* 它回答的是「出自哪」,不是「历次变更」;查询层按可多行实现(一旦将来复用 id,
|
|
85
|
+
* 或模型给的 id 被沿用,无需改这里),但**不要拿"判定史"这种说法承诺现状**——
|
|
86
|
+
* 这是本任务执行期发现并修正的一处**说过头**。
|
|
87
|
+
*
|
|
88
|
+
* **两维都不给不是"查全部"**:那会让一次误调用变成全库判定史导出。调用方
|
|
89
|
+
* (工具层 / 端点层)必须先拒绝这种用法;数据层在此再兜一层,返回空而非全表。
|
|
90
|
+
*/
|
|
91
|
+
export interface ReceiptQuery {
|
|
92
|
+
recordId?: string;
|
|
93
|
+
runId?: string;
|
|
94
|
+
}
|
|
95
|
+
/** 二维回溯命中的是哪个维度(供调用方与用户确认"我查的是哪一类问题")。 */
|
|
96
|
+
export type ReceiptDimension = 'record' | 'run' | 'both' | 'none';
|
|
97
|
+
export declare function dimensionOf(q: ReceiptQuery): ReceiptDimension;
|
|
98
|
+
/**
|
|
99
|
+
* 回放视图的一行。**snake_case 且与 DB 列同形**——凭证是给人核对的原始证据,
|
|
100
|
+
* 中间再套一层 camelCase 命名只会让"表里写的"与"工具吐的"对不上,
|
|
101
|
+
* 核对时多一次心算就是多一次出错机会。
|
|
102
|
+
*/
|
|
103
|
+
export interface ReceiptView {
|
|
104
|
+
receipt_id: string;
|
|
105
|
+
run_id: string;
|
|
106
|
+
record_id: string;
|
|
107
|
+
kind: L1ReceiptKind;
|
|
108
|
+
input_digest: string;
|
|
109
|
+
decided_at: string;
|
|
110
|
+
}
|
|
111
|
+
/** 工具层与 RPC 层**共用**的回溯结果形状(一个事实源,两处消费)。 */
|
|
112
|
+
export interface ReceiptsView {
|
|
113
|
+
dimension: ReceiptDimension;
|
|
114
|
+
items: ReceiptView[];
|
|
115
|
+
/** 该维度命中的总条数(不受 `limit` 影响;`items` 才是分页窗口)。 */
|
|
116
|
+
total: number;
|
|
117
|
+
/** 非结果的状态提示(工具层拒答时用;端点层以报错代替)。 */
|
|
118
|
+
notice?: string;
|
|
119
|
+
}
|
|
120
|
+
export declare function toReceiptView(r: L1Receipt): ReceiptView;
|
|
121
|
+
/** 回溯结果条数上限(与 `list-records` 同量级;超出窗口由 `total` 提示还有多少)。 */
|
|
122
|
+
export declare const RECEIPTS_QUERY_LIMIT_MAX = 200;
|
|
123
|
+
/**
|
|
124
|
+
* 一次蒸馏执行的 run 标识。
|
|
125
|
+
*
|
|
126
|
+
* **为什么不是"由输入切片推导出的确定性值"**——v1 曾这么设计,实测后被推翻:
|
|
127
|
+
* `pipeline/l1.ts` 对每条抽取结果执行 `record_id: newId('mem')`,
|
|
128
|
+
* **模型给的 record_id 被丢弃,记录 id 是运行时生成的**。因此"同一批消息重跑"
|
|
129
|
+
* 必然产生**不同的 record_id**,也就必然产生不同的凭证。把 run_id 做成切片的函数
|
|
130
|
+
* 换不来任何跨次幂等,只会让两次**互不相干的执行**挤在同一个 run_id 下,
|
|
131
|
+
* 反而破坏「一个 run = 一次执行」的归因。
|
|
132
|
+
*
|
|
133
|
+
* 于是幂等的真正边界是:**同一次 run 内,同一条记录只留一条凭证**
|
|
134
|
+
* (`receipt_id = f(run, record)` + `INSERT OR IGNORE`)。
|
|
135
|
+
* 这才是"同一决策不重复落盘"在本代码库里能成立的那个含义。
|
|
136
|
+
*
|
|
137
|
+
* 命名沿用仓库既有惯例(`newId('mem')` / `newJobId()` → `前缀_时间戳_随机`)。
|
|
138
|
+
*/
|
|
139
|
+
export declare function newRunId(): string;
|
|
140
|
+
/** receipt_id = f(run, record) —— 幂等的落点(同 run 同记录只留一条)。 */
|
|
141
|
+
export declare function receiptIdFor(runId: string, recordId: string): string;
|
|
142
|
+
/** 动作归一:只认词表内的合法动作,其余一律记 `skip_missing`(不采信模型的非法输出)。 */
|
|
143
|
+
export declare function normalizeKind(action: string | undefined): L1ReceiptKind;
|
|
144
|
+
/** 纯映射:决策集 → 凭证行。无 I/O,便于单测与复算。 */
|
|
145
|
+
export declare function buildReceipts(runId: string, decidedAt: string, items: readonly ReceiptInput[]): L1Receipt[];
|
|
146
|
+
/** 凭证落盘的最小写入缝(与 `L1Store.recordReceipts` 同形)。 */
|
|
147
|
+
export type ReceiptWriter = (rows: readonly L1Receipt[]) => number;
|
|
148
|
+
/**
|
|
149
|
+
* **失败隔离**:凭证是旁路观测设施,绝不是主链路的单点。
|
|
150
|
+
*
|
|
151
|
+
* L1 蒸馏已经把新记忆写进事实源了;此时若凭证写失败还向上抛,等于让一个
|
|
152
|
+
* 「记录决策」的附属动作把「产生记忆」的主流程打死——代价与收益完全不成比例。
|
|
153
|
+
* 故此处只记 warn,永不抛出。
|
|
154
|
+
*/
|
|
155
|
+
export declare function persistReceiptsSafely(write: ReceiptWriter, rows: readonly L1Receipt[], logger?: {
|
|
156
|
+
warn: (msg: string) => void;
|
|
157
|
+
}): void;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §B L1 决策凭证链(可追溯性基础设施)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要:`memory.db` 里每条 L1 记录都是**去重决策的结果**(store / update /
|
|
5
|
+
* merge / skip),但决策本身不留痕——事后只能看到"结果长这样",看不到"当时基于什么
|
|
6
|
+
* 做的判断"。而判「这次 merge 是否正确」需要**当时的输入快照**,快照无法事后补录。
|
|
7
|
+
* 这就是 §B 必须**前置**存在、不能等症状触发的原因(findings.md §9)。
|
|
8
|
+
*
|
|
9
|
+
* 本文件承载契约中最纯的那一半:`input_digest`。
|
|
10
|
+
*/
|
|
11
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
12
|
+
/** 摘要格式版本。进入 canonical 串,使算法演进时**不会静默**让新旧凭证看起来可比。 */
|
|
13
|
+
export const DIGEST_FORMAT = 'l1-receipts/v1';
|
|
14
|
+
/**
|
|
15
|
+
* 候选池**有序序列** → 稳定摘要(sha256 hex)。
|
|
16
|
+
*
|
|
17
|
+
* 语义:`searchCandidates` 返回的是**已排序**的候选池,「当时看到哪些候选、按什么序」
|
|
18
|
+
* 就是去重决策的输入。故本函数**顺序敏感**——换个序即不同摘要,而不是把候选当集合。
|
|
19
|
+
*
|
|
20
|
+
* 三项刻意的设计:
|
|
21
|
+
* - **顺序敏感**:见上。集合语义会丢掉"哪条候选排在最前"这一信息。
|
|
22
|
+
* - **重复不折叠**:`['r1','r1']` 与 `['r1']` 摘要不同。候选池里的重复本身是事实
|
|
23
|
+
* (例如同一记录被两条通路召回),折叠掉等于篡改输入。
|
|
24
|
+
* - **长度前缀编码**:逐项写 `${len}:${id}\n` 而非直接拼接。否则 `['a|b']` 与
|
|
25
|
+
* `['a','b']`(以及 `['a\nb']` 与 `['a','b']`)会碰撞——分隔符出现在 id 里时,
|
|
26
|
+
* 序列化就不再是单射,摘要也就失去了作为输入指纹的意义。
|
|
27
|
+
*
|
|
28
|
+
* 纯函数:无 I/O、无时间、无随机。同输入**恒**同输出,这是凭证可复算的前提。
|
|
29
|
+
*/
|
|
30
|
+
export function inputDigest(candidateIds) {
|
|
31
|
+
const hash = createHash('sha256');
|
|
32
|
+
hash.update(DIGEST_FORMAT);
|
|
33
|
+
hash.update('\n');
|
|
34
|
+
for (const id of candidateIds) {
|
|
35
|
+
hash.update(`${id.length}:`);
|
|
36
|
+
hash.update(id);
|
|
37
|
+
hash.update('\n');
|
|
38
|
+
}
|
|
39
|
+
return hash.digest('hex');
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 凭证保留上限:**最多保留多少个 run**。
|
|
43
|
+
*
|
|
44
|
+
* 为什么按 **run 数**而不是按天数(§B task_18):
|
|
45
|
+
* 本策略要防的是「`memory.db` 无限增长」。时间窗口**给不出这个保证**——
|
|
46
|
+
* 阈值(比如 90 天)与写入速率无关,一个高频用户 90 天能写进任意多行,
|
|
47
|
+
* 无界增长只是被推迟,不是被消除。run 数上限则**直接给出行的上界**
|
|
48
|
+
* (`maxRuns × 每 run 记录数`),这是"有界"与"看起来有界"的区别。
|
|
49
|
+
* 代价是:一个几乎不用的用户,历史可能只剩最近 1000 次蒸馏——
|
|
50
|
+
* 但凭证是**诊断设施**,诊断需要的是"最近发生了什么",不是考古。
|
|
51
|
+
*
|
|
52
|
+
* 1000 这个量级的依据:一轮 L1 蒸馏通常落 5~20 条凭证,故上限约 1~2 万行
|
|
53
|
+
* (数 MB 量级,在同为单文件的 `memory.db` 里不构成压力);而 L1 蒸馏本身要
|
|
54
|
+
* 调一次 LLM,现实中每小时至多几次——1000 轮 ≈ 活跃使用数月。
|
|
55
|
+
*
|
|
56
|
+
* **为什么是常量而不是配置项**:配置项要接 schema、契约与设置页,为一个
|
|
57
|
+
* 诊断表的容量引入用户可见面,收益与面积不成比例(YAGNI)。真需要调,
|
|
58
|
+
* 改这里即可,且 `recordReceipts(rows, { maxRuns })` 已留出显式注入口。
|
|
59
|
+
*/
|
|
60
|
+
export const RECEIPTS_MAX_RUNS = 1000;
|
|
61
|
+
export function dimensionOf(q) {
|
|
62
|
+
const hasRecord = typeof q.recordId === 'string' && q.recordId.length > 0;
|
|
63
|
+
const hasRun = typeof q.runId === 'string' && q.runId.length > 0;
|
|
64
|
+
if (hasRecord && hasRun)
|
|
65
|
+
return 'both';
|
|
66
|
+
if (hasRecord)
|
|
67
|
+
return 'record';
|
|
68
|
+
if (hasRun)
|
|
69
|
+
return 'run';
|
|
70
|
+
return 'none';
|
|
71
|
+
}
|
|
72
|
+
export function toReceiptView(r) {
|
|
73
|
+
return {
|
|
74
|
+
receipt_id: r.receiptId,
|
|
75
|
+
run_id: r.runId,
|
|
76
|
+
record_id: r.recordId,
|
|
77
|
+
kind: r.kind,
|
|
78
|
+
input_digest: r.inputDigest,
|
|
79
|
+
decided_at: r.decidedAt,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** 回溯结果条数上限(与 `list-records` 同量级;超出窗口由 `total` 提示还有多少)。 */
|
|
83
|
+
export const RECEIPTS_QUERY_LIMIT_MAX = 200;
|
|
84
|
+
const KNOWN_ACTIONS = ['store', 'update', 'merge', 'skip', 'conflict'];
|
|
85
|
+
/**
|
|
86
|
+
* 一次蒸馏执行的 run 标识。
|
|
87
|
+
*
|
|
88
|
+
* **为什么不是"由输入切片推导出的确定性值"**——v1 曾这么设计,实测后被推翻:
|
|
89
|
+
* `pipeline/l1.ts` 对每条抽取结果执行 `record_id: newId('mem')`,
|
|
90
|
+
* **模型给的 record_id 被丢弃,记录 id 是运行时生成的**。因此"同一批消息重跑"
|
|
91
|
+
* 必然产生**不同的 record_id**,也就必然产生不同的凭证。把 run_id 做成切片的函数
|
|
92
|
+
* 换不来任何跨次幂等,只会让两次**互不相干的执行**挤在同一个 run_id 下,
|
|
93
|
+
* 反而破坏「一个 run = 一次执行」的归因。
|
|
94
|
+
*
|
|
95
|
+
* 于是幂等的真正边界是:**同一次 run 内,同一条记录只留一条凭证**
|
|
96
|
+
* (`receipt_id = f(run, record)` + `INSERT OR IGNORE`)。
|
|
97
|
+
* 这才是"同一决策不重复落盘"在本代码库里能成立的那个含义。
|
|
98
|
+
*
|
|
99
|
+
* 命名沿用仓库既有惯例(`newId('mem')` / `newJobId()` → `前缀_时间戳_随机`)。
|
|
100
|
+
*/
|
|
101
|
+
export function newRunId() {
|
|
102
|
+
return `run_${Date.now()}_${randomBytes(3).toString('hex')}`;
|
|
103
|
+
}
|
|
104
|
+
/** receipt_id = f(run, record) —— 幂等的落点(同 run 同记录只留一条)。 */
|
|
105
|
+
export function receiptIdFor(runId, recordId) {
|
|
106
|
+
return inputDigest([runId, recordId]);
|
|
107
|
+
}
|
|
108
|
+
/** 动作归一:只认词表内的合法动作,其余一律记 `skip_missing`(不采信模型的非法输出)。 */
|
|
109
|
+
export function normalizeKind(action) {
|
|
110
|
+
return KNOWN_ACTIONS.includes(action) ? action : 'skip_missing';
|
|
111
|
+
}
|
|
112
|
+
/** 纯映射:决策集 → 凭证行。无 I/O,便于单测与复算。 */
|
|
113
|
+
export function buildReceipts(runId, decidedAt, items) {
|
|
114
|
+
return items.map((it) => ({
|
|
115
|
+
receiptId: receiptIdFor(runId, it.recordId),
|
|
116
|
+
runId,
|
|
117
|
+
recordId: it.recordId,
|
|
118
|
+
kind: normalizeKind(it.action),
|
|
119
|
+
inputDigest: inputDigest(it.candidateIds),
|
|
120
|
+
decidedAt,
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* **失败隔离**:凭证是旁路观测设施,绝不是主链路的单点。
|
|
125
|
+
*
|
|
126
|
+
* L1 蒸馏已经把新记忆写进事实源了;此时若凭证写失败还向上抛,等于让一个
|
|
127
|
+
* 「记录决策」的附属动作把「产生记忆」的主流程打死——代价与收益完全不成比例。
|
|
128
|
+
* 故此处只记 warn,永不抛出。
|
|
129
|
+
*/
|
|
130
|
+
export function persistReceiptsSafely(write, rows, logger) {
|
|
131
|
+
if (rows.length === 0)
|
|
132
|
+
return;
|
|
133
|
+
try {
|
|
134
|
+
write(rows);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
logger?.warn(`[memory] L1 决策凭证落盘失败(${rows.length} 条),本次判定不留痕但**不影响蒸馏结果**: ${err instanceof Error ? err.message : String(err)}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -23,6 +23,21 @@ export declare function applyDecayWeight<T extends {
|
|
|
23
23
|
export declare function rrfMerge<T>(lists: T[][], getId: (item: T) => string, k?: number): Array<T & {
|
|
24
24
|
rrfScore: number;
|
|
25
25
|
}>;
|
|
26
|
+
/**
|
|
27
|
+
* RRF 原始分归一化到 0~1(hybrid 检索的展示/比较分)。
|
|
28
|
+
*
|
|
29
|
+
* n 条列表融合时,单项最高原始分为 n/(k+1)(各列表 rank1 全中),故按**实际路数**
|
|
30
|
+
* lanes 归一:`rrfScore × (k+1) / lanes`,满分恰好 1.0,天然不越界。
|
|
31
|
+
* 旧实现把分母硬编码为 2,只对「FTS + 向量」双路成立;扩至 3/4 路后满分分别
|
|
32
|
+
* 达到 1.5 / 2.0,越出契约。2 路时本式与旧式逐字等价(无行为漂移)。
|
|
33
|
+
*
|
|
34
|
+
* 按「传入的路数」而非「非空路数」归一是有意的:向量源不可用时调用方仍传两条
|
|
35
|
+
* 列表(其一为空),旧实现给 FTS rank1 的分数是 0.5;按非空路数会变成 1.0,
|
|
36
|
+
* 反而破坏 2 路无漂移判据。
|
|
37
|
+
*
|
|
38
|
+
* 守卫:lanes 非正或非有限 → 0(退化输入不产生 NaN/Infinity)。
|
|
39
|
+
*/
|
|
40
|
+
export declare function normalizeRrf(rrfScore: number, lanes: number): number;
|
|
26
41
|
/** FTS5 bm25 rank(负值=更相关)转 0~1 分数。 */
|
|
27
42
|
export declare function bm25RankToScore(rank: number): number;
|
|
28
43
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 检索工具:
|
|
3
3
|
* - rrfMerge:RRF(Reciprocal Rank Fusion,k=60)多路结果融合,hybrid 检索用;
|
|
4
|
+
* - normalizeRrf:RRF 原始分按实际路数归一化到 0~1(hybrid 展示分);
|
|
4
5
|
* - bm25RankToScore:FTS5 bm25 rank(负值=更相关)转 0~1 分数;
|
|
5
6
|
* - applyDecayWeight:#29 时效衰减加权(读路径专用);
|
|
6
7
|
* - buildFtsQuery / tokenizeForFts:FTS5 查询构造与写入侧分词。
|
|
@@ -62,6 +63,25 @@ export function rrfMerge(lists, getId, k = RRF_K) {
|
|
|
62
63
|
.sort((a, b) => b.rrfScore - a.rrfScore)
|
|
63
64
|
.map(({ item, rrfScore }) => ({ ...item, rrfScore }));
|
|
64
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* RRF 原始分归一化到 0~1(hybrid 检索的展示/比较分)。
|
|
68
|
+
*
|
|
69
|
+
* n 条列表融合时,单项最高原始分为 n/(k+1)(各列表 rank1 全中),故按**实际路数**
|
|
70
|
+
* lanes 归一:`rrfScore × (k+1) / lanes`,满分恰好 1.0,天然不越界。
|
|
71
|
+
* 旧实现把分母硬编码为 2,只对「FTS + 向量」双路成立;扩至 3/4 路后满分分别
|
|
72
|
+
* 达到 1.5 / 2.0,越出契约。2 路时本式与旧式逐字等价(无行为漂移)。
|
|
73
|
+
*
|
|
74
|
+
* 按「传入的路数」而非「非空路数」归一是有意的:向量源不可用时调用方仍传两条
|
|
75
|
+
* 列表(其一为空),旧实现给 FTS rank1 的分数是 0.5;按非空路数会变成 1.0,
|
|
76
|
+
* 反而破坏 2 路无漂移判据。
|
|
77
|
+
*
|
|
78
|
+
* 守卫:lanes 非正或非有限 → 0(退化输入不产生 NaN/Infinity)。
|
|
79
|
+
*/
|
|
80
|
+
export function normalizeRrf(rrfScore, lanes) {
|
|
81
|
+
if (!Number.isFinite(lanes) || lanes <= 0)
|
|
82
|
+
return 0;
|
|
83
|
+
return (rrfScore * (RRF_K + 1)) / lanes;
|
|
84
|
+
}
|
|
65
85
|
/** FTS5 bm25 rank(负值=更相关)转 0~1 分数。 */
|
|
66
86
|
export function bm25RankToScore(rank) {
|
|
67
87
|
if (!Number.isFinite(rank))
|
|
@@ -17,6 +17,13 @@ export declare class SessionModeStore {
|
|
|
17
17
|
get default(): MemoryMode;
|
|
18
18
|
/** 同步读取:未设置过的会话返回默认档。 */
|
|
19
19
|
get(sessionId: string): MemoryMode;
|
|
20
|
+
/**
|
|
21
|
+
* 该会话是否有**显式**档位条目(区别于 `get()` 的默认档回落)。
|
|
22
|
+
*
|
|
23
|
+
* 子代理档位继承(§A)靠它判断"这一层是否设过":未设过则继续沿父链上溯,
|
|
24
|
+
* 而不是立刻吃默认档——后者正是"用户显式 off 被绕过"的成因。
|
|
25
|
+
*/
|
|
26
|
+
hasEntry(sessionId: string): boolean;
|
|
20
27
|
/** 会话级注入覆盖原始值:undefined = 未覆盖,跟随全局。 */
|
|
21
28
|
getRecall(sessionId: string): boolean | undefined;
|
|
22
29
|
/** 解析后的注入开关:会话覆盖 ?? 全局运行时开关(部署级 cfg.recall.enabled
|
|
@@ -60,6 +60,15 @@ export class SessionModeStore {
|
|
|
60
60
|
get(sessionId) {
|
|
61
61
|
return this.entries.get(sessionId)?.mode ?? this.loaded;
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* 该会话是否有**显式**档位条目(区别于 `get()` 的默认档回落)。
|
|
65
|
+
*
|
|
66
|
+
* 子代理档位继承(§A)靠它判断"这一层是否设过":未设过则继续沿父链上溯,
|
|
67
|
+
* 而不是立刻吃默认档——后者正是"用户显式 off 被绕过"的成因。
|
|
68
|
+
*/
|
|
69
|
+
hasEntry(sessionId) {
|
|
70
|
+
return this.entries.has(sessionId);
|
|
71
|
+
}
|
|
63
72
|
/** 会话级注入覆盖原始值:undefined = 未覆盖,跟随全局。 */
|
|
64
73
|
getRecall(sessionId) {
|
|
65
74
|
return this.entries.get(sessionId)?.recall;
|
package/dist/store/sqlite.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EmbeddingProviderInfo } from './embedding.js';
|
|
2
2
|
import type { L0MessageRecord, MemoryFamily, MemoryLogger, MemoryRecord } from '../types.js';
|
|
3
|
+
import { isZeroVector, vecToBuffer } from './vec-utils.js';
|
|
3
4
|
export interface StoreInitResult {
|
|
4
5
|
/** embedding 配置(provider/model/维度)变化,需要后台全量重嵌入。 */
|
|
5
6
|
needsReindex: boolean;
|
|
@@ -14,6 +15,8 @@ import type { BucketRow, CostAggregate, CostByLayer } from './cost-ledger.js';
|
|
|
14
15
|
export type { BucketRow, CostAggregate, CostByLayer } from './cost-ledger.js';
|
|
15
16
|
import type { CostByModel } from '../contract.js';
|
|
16
17
|
import { GraphStore } from './graph-store.js';
|
|
18
|
+
import type { L1Receipt, ReceiptQuery, ReceiptRetentionOptions } from './receipts.js';
|
|
19
|
+
import type { ConflictPair, ConflictResolution } from './conflicts.js';
|
|
17
20
|
/** L1 检索命中(含 BM25/余弦归一分数)。 */
|
|
18
21
|
export interface L1SearchHit {
|
|
19
22
|
id: string;
|
|
@@ -106,7 +109,14 @@ export declare class MemoryDb {
|
|
|
106
109
|
* 图谱时间锚仍读 metadata,列由新写入路径填充。
|
|
107
110
|
*/
|
|
108
111
|
private ensureTemporalColumns;
|
|
109
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* 重建后的 l1_fts 从 l1_records 全量回灌(仅在 drop 重建时调用;iterate 流式防大库内存峰值)。
|
|
114
|
+
*
|
|
115
|
+
* ⚠️ 本函数的参数列表**必须与 `stmtL1FtsInsert` 逐位对齐**。不对齐时 node:sqlite 会在这里抛错,
|
|
116
|
+
* 而下面的 `catch` 是**逐行吞掉**的——症状是 `count` 停在 0、索引静默变空,
|
|
117
|
+
* 全库记录从此全文检索不可见却没有任何错误日志。§E 加列时正是这个位置最容易漏
|
|
118
|
+
* (三处列清单:DDL / insert 语句 / 本函数),故在此留下警示。
|
|
119
|
+
*/
|
|
110
120
|
private backfillL1Fts;
|
|
111
121
|
/** 重建后的 l0_fts 从 l0_conversations 全量回灌(仅 drop 重建时调用;iterate 流式)。 */
|
|
112
122
|
private backfillL0Fts;
|
|
@@ -148,12 +158,107 @@ export declare class MemoryDb {
|
|
|
148
158
|
/** 全量读取(调试/迁移/重嵌入用;检索请走 FTS/向量)。 */
|
|
149
159
|
getAllL1(): MemoryRecord[];
|
|
150
160
|
getL1ByIds(ids: string[]): MemoryRecord[];
|
|
151
|
-
/**
|
|
161
|
+
/**
|
|
162
|
+
* §B 决策凭证批量落盘。`INSERT OR IGNORE` + 确定性 `receipt_id`
|
|
163
|
+
* (见 `receipts.ts` 的 `receiptIdFor`)→ 同一次 run 重放不产生重复行。
|
|
164
|
+
* 返回实际新增条数(被忽略的重复不计)。
|
|
165
|
+
*
|
|
166
|
+
* 刻意**不开事务**:凭证是旁路观测数据,单条独立、重放幂等,部分写入无害;
|
|
167
|
+
* 为它引入事务只会把失败面扩大。调用方另有 `persistReceiptsSafely` 兜底不抛。
|
|
168
|
+
*
|
|
169
|
+
* 写入后**顺带执行保留策略**(task_18)。把裁剪挂在这里而不是交给调用方,
|
|
170
|
+
* 是为了让"有界"成为**结构性保证**:任何写路径都不可能忘记裁剪,
|
|
171
|
+
* 因而表容量不可能随使用时间无界增长。裁剪自身失败只 warn——
|
|
172
|
+
* 它是省空间的动作,失败了最坏是这次没省下来,绝不能因此弄丢刚落盘的凭证
|
|
173
|
+
* (故裁剪在写入**之后**,且包在 try 里)。
|
|
174
|
+
*/
|
|
175
|
+
recordReceipts(rows: readonly L1Receipt[], opts?: ReceiptRetentionOptions): number;
|
|
176
|
+
/**
|
|
177
|
+
* §C 矛盾冻结(task_22):落盘待裁决冲突对。
|
|
178
|
+
*
|
|
179
|
+
* `INSERT OR IGNORE`——幂等来自 **pair_id 主键**而非调用方自觉:
|
|
180
|
+
* `conflictPairId(runId, winner, loser)` 对同一三元组恒等,故一轮蒸馏重复落盘
|
|
181
|
+
* 只会得到一行。与 §B 凭证同一手法(那边是 `receipt_id` 主键)。
|
|
182
|
+
*
|
|
183
|
+
* 与凭证不同,这里**不做保留裁剪**:待裁决对是**欠人的债**,不是观测数据。
|
|
184
|
+
* 裁剪它等于把用户还没看的裁决请求悄悄删掉,那是丢工作而不是省空间。
|
|
185
|
+
* 有界性交给 task_24 的队列上限(超限不再停放、回落自动裁决),语义是
|
|
186
|
+
* 「**不收新的**」而非「**偷偷删旧的**」。
|
|
187
|
+
*
|
|
188
|
+
* @returns 实际新插入的行数。
|
|
189
|
+
*/
|
|
190
|
+
recordConflictPending(rows: readonly ConflictPair[]): number;
|
|
191
|
+
/** §C 冻结:把图谱 `disputed` 状态同步到给定冲突集(薄缝,便于单测替换)。 */
|
|
192
|
+
syncGraphDisputed(disputedRecordIds: readonly string[]): {
|
|
193
|
+
marked: number;
|
|
194
|
+
cleared: number;
|
|
195
|
+
};
|
|
196
|
+
/**
|
|
197
|
+
* §C 冻结队列的**未裁决**条数(task_24 队列上限判据)。
|
|
198
|
+
* 走 `idx_conflict_pending_unresolved` 偏索引,不是全表扫描。
|
|
199
|
+
*/
|
|
200
|
+
countConflictPendingUnresolved(): number;
|
|
201
|
+
/**
|
|
202
|
+
* §C 取未裁决冲突对(task_24 超时扫描 / task_25 裁决工具)。
|
|
203
|
+
*
|
|
204
|
+
* `createdBefore` 为**排他上界**(ISO 串):只取该时刻之前创建的,用于超时判定。
|
|
205
|
+
* 定序 `created_at ASC, pair_id ASC`——先来先服务,且同一毫秒内仍**确定可复现**。
|
|
206
|
+
*/
|
|
207
|
+
listConflictPending(opts?: {
|
|
208
|
+
createdBefore?: string;
|
|
209
|
+
limit?: number;
|
|
210
|
+
}): ConflictPair[];
|
|
211
|
+
/**
|
|
212
|
+
* §C 打上裁决结论。
|
|
213
|
+
*
|
|
214
|
+
* `WHERE resolved_at = ''` 使**已裁决的不会被覆盖**:裁决是一次性的判定行为,
|
|
215
|
+
* 重复调用不该把第一次的结论改写掉(人工裁决与自动了结的次序因此不可逆)。
|
|
216
|
+
*
|
|
217
|
+
* @returns 受影响行数(0 = 该对被裁决过或不存在)。
|
|
218
|
+
*/
|
|
219
|
+
resolveConflictPending(pairId: string, resolution: ConflictResolution, resolvedAt: string): number;
|
|
220
|
+
/**
|
|
221
|
+
* §B 凭证保留策略(task_18):只保留**最新**的 `maxRuns` 个 run,更老的整批删除。
|
|
222
|
+
* 返回被删除的行数。
|
|
223
|
+
*
|
|
224
|
+
* 两条刻意的约束:
|
|
225
|
+
* - **粒度是 run,不是行**。按行裁剪会切出"半截批次",而 task_19 的按 run 回溯
|
|
226
|
+
* 正是要回答"这一轮蒸馏都判了什么"——一个少了尾巴的批次会给出**看似完整、
|
|
227
|
+
* 实则遗漏**的结论,比查不到更糟。整批留、整批删,回溯的原子性才有保证。
|
|
228
|
+
* - **只碰 `l1_receipts`,绝不碰 `l1_records`**。前者是可再生/可丢弃的观测数据,
|
|
229
|
+
* 后者是用户的事实源。为省几 MB 而波及记忆本体,是把容量优化做成了数据丢失。
|
|
230
|
+
*
|
|
231
|
+
* `maxRuns <= 0` 或非有限值一律**不裁剪**——"传 0 即清空"是个太容易被误触的
|
|
232
|
+
* 语义,宁可把它定义为无效输入。
|
|
233
|
+
*
|
|
234
|
+
* 定序取每 run 的 `MAX(decided_at)`(凭证的 decided_at 在一批内恒定)并以
|
|
235
|
+
* `run_id` 兜底,使同一时刻产生的多个 run 也有**确定**的相对序,裁剪结果可复现。
|
|
236
|
+
*/
|
|
237
|
+
trimReceipts(maxRuns: number): number;
|
|
238
|
+
/**
|
|
239
|
+
* §B 双维回溯(task_19):按 `record_id` / `run_id` 查判定史,两维同给为 **AND**。
|
|
240
|
+
*
|
|
241
|
+
* 两条刻意的行为:
|
|
242
|
+
* - **两维都不给返回空,而不是全表**。「查全部凭证」不是本能力的目标;把缺参
|
|
243
|
+
* 兜成全表,会让一次误调用变成全库判定史导出。调用方本就该先拒绝这种用法
|
|
244
|
+
* (工具层给提示、端点层直接报错),这里是第二道,方向一致。
|
|
245
|
+
* - **定序确定**:`decided_at DESC, run_id DESC`。回溯的价值在于可复现——
|
|
246
|
+
* 同一问题两次问出不同顺序,核对时就会怀疑是不是数据变了。`run_id` 兜底
|
|
247
|
+
* 同一毫秒内的多批(L1 蒸馏是 LLM 调用,同刻两批罕见但非不可能)。
|
|
248
|
+
* 新的在前,与 `listL1` 的倒序口径一致。
|
|
249
|
+
*/
|
|
250
|
+
listReceipts(opts: ReceiptQuery & {
|
|
251
|
+
limit: number;
|
|
252
|
+
}): L1Receipt[];
|
|
253
|
+
/** 同维度命中的**总条数**(不受 limit 影响,供"还有多少条没显示"提示)。 */
|
|
254
|
+
countReceipts(opts: ReceiptQuery): number;
|
|
255
|
+
/** 浏览列表(UI 用):按更新时间倒序,支持类型/场景/族/Hall/可见范围过滤与分页。失败返回空。 */
|
|
152
256
|
listL1(opts: {
|
|
153
257
|
type?: string;
|
|
154
258
|
scene?: string;
|
|
155
259
|
family?: string;
|
|
156
260
|
hall?: string;
|
|
261
|
+
workspaceId?: string;
|
|
157
262
|
limit: number;
|
|
158
263
|
offset: number;
|
|
159
264
|
}): {
|
|
@@ -162,10 +267,14 @@ export declare class MemoryDb {
|
|
|
162
267
|
};
|
|
163
268
|
/** 场景名去重列表(UI 筛选器数据源)。失败返回空。 */
|
|
164
269
|
distinctL1Scenes(): string[];
|
|
165
|
-
/** FTS5 BM25 检索(family 缺省不过滤)。失败返回空数组(调用方降级)。 */
|
|
166
|
-
searchL1Fts(query: string, limit: number, family?: string): L1SearchHit[];
|
|
167
|
-
/**
|
|
168
|
-
|
|
270
|
+
/** FTS5 BM25 检索(family / workspaceId 缺省不过滤)。失败返回空数组(调用方降级)。 */
|
|
271
|
+
searchL1Fts(query: string, limit: number, family?: string, workspaceId?: string): L1SearchHit[];
|
|
272
|
+
/**
|
|
273
|
+
* vec0 余弦 KNN 检索(score = 1 - cosine distance)。失败返回空数组。
|
|
274
|
+
* family / workspaceId 过滤走**过度召回 + 回查过滤**(vec0 无法 WHERE)。
|
|
275
|
+
* 放大倍数对两条轴**相乘**:两轴各自丢弃行,单独放大任一条都不够。
|
|
276
|
+
*/
|
|
277
|
+
searchL1Vector(embedding: Float32Array, topK: number, family?: string, workspaceId?: string): L1SearchHit[];
|
|
169
278
|
/** 批量 upsert L0 消息(元数据 + FTS;embeddings 与 records 等长,可省略)。 */
|
|
170
279
|
upsertL0Batch(records: L0MessageRecord[], embeddings?: Array<Float32Array | undefined>): boolean;
|
|
171
280
|
/** 记录一次蒸馏调用成本(委托 cost-ledger;语义见 CostLedger.insertCostCall)。 */
|
|
@@ -242,5 +351,4 @@ export declare class MemoryDb {
|
|
|
242
351
|
}>, recordedAt: string): number;
|
|
243
352
|
close(): void;
|
|
244
353
|
}
|
|
245
|
-
|
|
246
|
-
export declare function isZeroVector(vec: Float32Array): boolean;
|
|
354
|
+
export { isZeroVector, vecToBuffer };
|