dsh-prime-memory 0.22.2 → 0.24.1
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 +421 -0
- package/CHANGELOG.ja.md +416 -0
- package/CHANGELOG.ko.md +416 -0
- package/CHANGELOG.md +120 -0
- package/dist/client.js +1266 -845
- package/dist/config.d.ts +131 -1341
- package/dist/config.js +89 -4
- package/dist/conflict-service.d.ts +7 -1
- package/dist/conflict-service.js +7 -1
- package/dist/contract.d.ts +211 -0
- package/dist/domain-gate.d.ts +11 -0
- package/dist/domain-gate.js +15 -0
- package/dist/hooks/obsolete-notice.d.ts +26 -0
- package/dist/hooks/obsolete-notice.js +64 -0
- package/dist/hooks/recall.d.ts +39 -0
- package/dist/hooks/recall.js +110 -30
- package/dist/index.d.ts +1 -1127
- package/dist/index.js +52 -2
- package/dist/memory-types.d.ts +69 -0
- package/dist/memory-types.js +165 -0
- package/dist/memport/format.d.ts +6 -1
- package/dist/memport/format.js +42 -2
- package/dist/pipeline/l1.d.ts +44 -2
- package/dist/pipeline/l1.js +163 -9
- package/dist/pipeline/md-sync.d.ts +65 -0
- package/dist/pipeline/md-sync.js +456 -0
- package/dist/pipeline/ruminate.d.ts +46 -1
- package/dist/pipeline/ruminate.js +109 -0
- package/dist/pipeline/runner.d.ts +10 -0
- package/dist/pipeline/runner.js +13 -2
- package/dist/pipeline/workspace-relink.d.ts +60 -0
- package/dist/pipeline/workspace-relink.js +73 -0
- package/dist/projection/recall.d.ts +51 -13
- package/dist/projection/recall.js +47 -12
- package/dist/prompts/examples.d.ts +73 -0
- package/dist/prompts/examples.js +178 -0
- package/dist/prompts/l1-dedup.d.ts +9 -3
- package/dist/prompts/l1-dedup.js +34 -13
- package/dist/prompts/l1-extraction.d.ts +5 -3
- package/dist/prompts/l1-extraction.js +49 -4
- package/dist/settings.d.ts +8 -0
- package/dist/settings.js +16 -1
- package/dist/stats.d.ts +12 -0
- package/dist/stats.js +352 -17
- package/dist/store/embedding-source.d.ts +8 -0
- package/dist/store/embedding-source.js +39 -2
- package/dist/store/file-versions.d.ts +2 -0
- package/dist/store/file-versions.js +2 -0
- package/dist/store/graph-store.d.ts +14 -0
- package/dist/store/graph-store.js +35 -0
- package/dist/store/l1-snapshot.d.ts +26 -1
- package/dist/store/l1-snapshot.js +53 -8
- package/dist/store/l1.d.ts +94 -3
- package/dist/store/l1.js +169 -4
- package/dist/store/memory-md-archive.d.ts +84 -0
- package/dist/store/memory-md-archive.js +155 -0
- package/dist/store/memory-md-disable.d.ts +74 -0
- package/dist/store/memory-md-disable.js +302 -0
- package/dist/store/memory-md-export.d.ts +50 -0
- package/dist/store/memory-md-export.js +227 -0
- package/dist/store/memory-md-ledger.d.ts +94 -0
- package/dist/store/memory-md-ledger.js +243 -0
- package/dist/store/memory-md-orphan.d.ts +27 -0
- package/dist/store/memory-md-orphan.js +153 -0
- package/dist/store/memory-md-parse.d.ts +35 -0
- package/dist/store/memory-md-parse.js +85 -0
- package/dist/store/memory-md-run.d.ts +48 -0
- package/dist/store/memory-md-run.js +151 -0
- package/dist/store/memory-md-skeleton.d.ts +18 -0
- package/dist/store/memory-md-skeleton.js +61 -0
- package/dist/store/memory-md-sync.d.ts +59 -0
- package/dist/store/memory-md-sync.js +152 -0
- package/dist/store/memory-md-types.d.ts +243 -0
- package/dist/store/memory-md-types.js +10 -0
- package/dist/store/memory-md.d.ts +117 -0
- package/dist/store/memory-md.js +230 -0
- package/dist/store/obsolete-notices.d.ts +39 -0
- package/dist/store/obsolete-notices.js +153 -0
- package/dist/store/recall-feedback.d.ts +75 -0
- package/dist/store/recall-feedback.js +213 -0
- package/dist/store/recall-usage.d.ts +11 -0
- package/dist/store/recall-usage.js +29 -0
- package/dist/store/session-recalled.d.ts +38 -3
- package/dist/store/session-recalled.js +156 -9
- package/dist/store/sqlite.d.ts +66 -0
- package/dist/store/sqlite.js +233 -15
- package/dist/store/supersede.d.ts +1 -1
- package/dist/store/supersede.js +1 -1
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.js +26 -7
- package/dist/tools/slots.d.ts +2 -1
- package/dist/tools/slots.js +2 -1
- package/dist/types.d.ts +24 -7
- package/dist/types.js +10 -2
- package/dist/util/maturity.d.ts +20 -3
- package/dist/util/maturity.js +18 -1
- package/dist/util/md-name.d.ts +129 -0
- package/dist/util/md-name.js +366 -0
- package/dist/util/md-normalize.d.ts +23 -0
- package/dist/util/md-normalize.js +131 -0
- package/dist/workspace-route.d.ts +22 -0
- package/dist/workspace-route.js +66 -0
- package/dsh.plugin.json +1 -1
- package/package.json +6 -5
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 召回相关度反馈 sidecar(`recall-feedback.json`)。
|
|
3
|
+
*
|
|
4
|
+
* 口径(用户裁定):概要侧栏「本会话召回」卡上,人对**这条被召回的记忆**做主动标注:
|
|
5
|
+
* - `0..5` = 相关度评分(人给的、显式的,区别于系统算的 priority/usage/aging);
|
|
6
|
+
* - `-1` = 声明这条记忆**已过时**(配套动作由调用方执行:退场 + 会话内声明)。
|
|
7
|
+
*
|
|
8
|
+
* 为什么单开一个 sidecar 而**不写进 L1 metadata**:metadata 进 `canonicalRecords`
|
|
9
|
+
* 快照哈希(`store/l1-snapshot.ts`),往既有记录里塞新键 = 旧快照永久失配(conflicts
|
|
10
|
+
* 列事故同款)。反馈是**关于**记忆的观察值,不是记忆内容,本就该另立面。
|
|
11
|
+
*
|
|
12
|
+
* 纪律与 recall-usage 同族:缺失 = 合法空表;损坏/不可读 → 只读降级(内存态照用、
|
|
13
|
+
* 停止回写),绝不抛进读路径。差别只有一处:**打分是用户动作、低频**,所以
|
|
14
|
+
* **立即落盘**而非去抖——点完就崩不该丢这一票。
|
|
15
|
+
*/
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import { readJsonStrict, atomicWriteText, ensureDir } from '../util/io.js';
|
|
18
|
+
export const RECALL_FEEDBACK_FILE_VERSION = 1;
|
|
19
|
+
/** 合法打分:-1(声明过时)与 0..5(相关度)。 */
|
|
20
|
+
export const FEEDBACK_SCORES = [-1, 0, 1, 2, 3, 4, 5];
|
|
21
|
+
/** 条目上限(防已退场记忆的 id 无限累积):超限按 updatedAt 淘汰最旧。 */
|
|
22
|
+
const MAX_ENTRIES = 20_000;
|
|
23
|
+
/** 轨迹条数上限。 */
|
|
24
|
+
const HISTORY_CAP = 12;
|
|
25
|
+
/** 是否合法打分(供端点入参校验复用)。 */
|
|
26
|
+
export function isFeedbackScore(v) {
|
|
27
|
+
return typeof v === 'number' && FEEDBACK_SCORES.includes(v);
|
|
28
|
+
}
|
|
29
|
+
export class RecallFeedbackStore {
|
|
30
|
+
logger;
|
|
31
|
+
file;
|
|
32
|
+
entries = new Map();
|
|
33
|
+
loaded = false;
|
|
34
|
+
degraded;
|
|
35
|
+
degradedLogged = false;
|
|
36
|
+
writeChain = Promise.resolve();
|
|
37
|
+
/** 判脏序号:每次有效写入 +1(投影 view 带 rating,靠它决定要不要重建)。 */
|
|
38
|
+
rev = 0;
|
|
39
|
+
constructor(dataDir, logger) {
|
|
40
|
+
this.logger = logger;
|
|
41
|
+
this.file = path.join(dataDir, 'recall-feedback.json');
|
|
42
|
+
}
|
|
43
|
+
async init() {
|
|
44
|
+
await ensureDir(path.dirname(this.file));
|
|
45
|
+
const read = await readJsonStrict(this.file);
|
|
46
|
+
if (!read.ok) {
|
|
47
|
+
if (read.reason !== 'missing') {
|
|
48
|
+
this.degraded = `${read.reason}${'detail' in read && read.detail ? `: ${read.detail}` : ''}`;
|
|
49
|
+
this.logger?.warn(`[memory] 召回反馈只读降级: ${this.degraded}`);
|
|
50
|
+
}
|
|
51
|
+
this.loaded = true;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const raw = read.value.entries ?? {};
|
|
55
|
+
for (const [id, e] of Object.entries(raw)) {
|
|
56
|
+
const parsed = this.normalize(e);
|
|
57
|
+
if (parsed)
|
|
58
|
+
this.entries.set(id, parsed);
|
|
59
|
+
}
|
|
60
|
+
this.loaded = true;
|
|
61
|
+
}
|
|
62
|
+
/** 读侧逐条收窄:形状不符的条目丢弃,不让一条坏数据拖垮整表。 */
|
|
63
|
+
normalize(e) {
|
|
64
|
+
if (!e || typeof e !== 'object')
|
|
65
|
+
return undefined;
|
|
66
|
+
const raw = e;
|
|
67
|
+
if (!isFeedbackScore(raw.last) || typeof raw.updatedAt !== 'number')
|
|
68
|
+
return undefined;
|
|
69
|
+
const rated = typeof raw.rated === 'number' && raw.rated >= 0 ? Math.floor(raw.rated) : 0;
|
|
70
|
+
const history = Array.isArray(raw.history)
|
|
71
|
+
? raw.history
|
|
72
|
+
.filter((h) => isFeedbackScore(h.s) && typeof h.at === 'number')
|
|
73
|
+
.slice(-HISTORY_CAP)
|
|
74
|
+
: [];
|
|
75
|
+
return {
|
|
76
|
+
last: raw.last,
|
|
77
|
+
rated,
|
|
78
|
+
mean: typeof raw.mean === 'number' ? raw.mean : 0,
|
|
79
|
+
obsolete: typeof raw.obsolete === 'number' && raw.obsolete >= 0 ? Math.floor(raw.obsolete) : 0,
|
|
80
|
+
updatedAt: raw.updatedAt,
|
|
81
|
+
history,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
ensureLoaded() {
|
|
85
|
+
if (!this.loaded)
|
|
86
|
+
throw new Error('召回反馈未初始化(先 await init())');
|
|
87
|
+
}
|
|
88
|
+
/** 判脏序号(供投影 O(1) 决定要不要重建 view)。 */
|
|
89
|
+
revision() {
|
|
90
|
+
return this.rev;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 记一次打分。返回聚合后的条目(供端点回执)。
|
|
94
|
+
* 只聚合不裁决:`-1` 进 obsolete 计数、不进均值(过时不是"不相关",两回事)。
|
|
95
|
+
*/
|
|
96
|
+
rate(id, score, at = Date.now()) {
|
|
97
|
+
this.ensureLoaded();
|
|
98
|
+
const prev = this.entries.get(id);
|
|
99
|
+
const history = [...(prev?.history ?? []), { s: score, at }].slice(-HISTORY_CAP);
|
|
100
|
+
const rated = (prev?.rated ?? 0) + (score >= 0 ? 1 : 0);
|
|
101
|
+
const sum = (prev?.mean ?? 0) * (prev?.rated ?? 0) + (score >= 0 ? score : 0);
|
|
102
|
+
const next = {
|
|
103
|
+
last: score,
|
|
104
|
+
rated,
|
|
105
|
+
mean: rated > 0 ? Math.round((sum / rated) * 100) / 100 : 0,
|
|
106
|
+
obsolete: (prev?.obsolete ?? 0) + (score === -1 ? 1 : 0),
|
|
107
|
+
updatedAt: at,
|
|
108
|
+
history,
|
|
109
|
+
};
|
|
110
|
+
this.entries.set(id, next);
|
|
111
|
+
this.rev += 1;
|
|
112
|
+
this.prune();
|
|
113
|
+
this.schedulePersist();
|
|
114
|
+
return { ...next };
|
|
115
|
+
}
|
|
116
|
+
get(id) {
|
|
117
|
+
this.ensureLoaded();
|
|
118
|
+
const e = this.entries.get(id);
|
|
119
|
+
return e ? { ...e, history: [...e.history] } : undefined;
|
|
120
|
+
}
|
|
121
|
+
/** 均值(0..5);无有效评分 → undefined(缺席 ≠ 0 分)。 */
|
|
122
|
+
meanOf(id) {
|
|
123
|
+
this.ensureLoaded();
|
|
124
|
+
const e = this.entries.get(id);
|
|
125
|
+
return e && e.rated > 0 ? e.mean : undefined;
|
|
126
|
+
}
|
|
127
|
+
/** 最近一次打分(-1..5);未标注 → undefined。 */
|
|
128
|
+
lastOf(id) {
|
|
129
|
+
this.ensureLoaded();
|
|
130
|
+
return this.entries.get(id)?.last;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* 把一条记录的人工标注搬到新 id(merge 之后由管线调用,P0-d)。
|
|
134
|
+
*
|
|
135
|
+
* 不搬的后果比使用史更直接:成熟度只吃“人工最近一笔”,而 `last` 留在旧 id 上
|
|
136
|
+
* 就等于新记录**看起来从没被人打过**;更坏的是旧 id 上那一笔 `-1`(已判过时)
|
|
137
|
+
* 会跟着旧行一起沉进垃圾堆,新行反而以“未标注”的活状态回到召回里。
|
|
138
|
+
*
|
|
139
|
+
* 计数按相加、不按轨迹重算:`history` 截断到 12 条,拿它反推 `obsolete` 必然少算。
|
|
140
|
+
* 两侧都有条目时取较新一侧的 `last`,轨迹按时间归并。
|
|
141
|
+
*/
|
|
142
|
+
rekey(oldId, newId) {
|
|
143
|
+
this.ensureLoaded();
|
|
144
|
+
if (oldId === newId)
|
|
145
|
+
return false;
|
|
146
|
+
const from = this.entries.get(oldId);
|
|
147
|
+
if (from === undefined)
|
|
148
|
+
return false;
|
|
149
|
+
const to = this.entries.get(newId);
|
|
150
|
+
const ratedCount = (to?.rated ?? 0) + from.rated;
|
|
151
|
+
const ratedSum = (to?.mean ?? 0) * (to?.rated ?? 0) + from.mean * from.rated;
|
|
152
|
+
const newer = to !== undefined && to.updatedAt >= from.updatedAt ? to : from;
|
|
153
|
+
const next = {
|
|
154
|
+
last: newer.last,
|
|
155
|
+
rated: ratedCount,
|
|
156
|
+
mean: ratedCount > 0 ? Math.round((ratedSum / ratedCount) * 100) / 100 : 0,
|
|
157
|
+
obsolete: (to?.obsolete ?? 0) + from.obsolete,
|
|
158
|
+
updatedAt: Math.max(to?.updatedAt ?? 0, from.updatedAt),
|
|
159
|
+
history: [...(to?.history ?? []), ...from.history].sort((a, b) => a.at - b.at).slice(-HISTORY_CAP),
|
|
160
|
+
};
|
|
161
|
+
this.entries.set(newId, next);
|
|
162
|
+
this.entries.delete(oldId);
|
|
163
|
+
this.rev += 1;
|
|
164
|
+
this.prune();
|
|
165
|
+
this.schedulePersist();
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
summary() {
|
|
169
|
+
this.ensureLoaded();
|
|
170
|
+
let rated = 0;
|
|
171
|
+
let obsolete = 0;
|
|
172
|
+
for (const e of this.entries.values()) {
|
|
173
|
+
rated += e.rated;
|
|
174
|
+
obsolete += e.obsolete;
|
|
175
|
+
}
|
|
176
|
+
return { tracked: this.entries.size, rated, obsolete };
|
|
177
|
+
}
|
|
178
|
+
prune() {
|
|
179
|
+
if (this.entries.size <= MAX_ENTRIES)
|
|
180
|
+
return;
|
|
181
|
+
const byOldest = [...this.entries.entries()].sort((a, b) => a[1].updatedAt - b[1].updatedAt);
|
|
182
|
+
for (const [id] of byOldest.slice(0, this.entries.size - MAX_ENTRIES))
|
|
183
|
+
this.entries.delete(id);
|
|
184
|
+
}
|
|
185
|
+
/** 立即落盘(串行链上排队,不与在途写交错)。 */
|
|
186
|
+
schedulePersist() {
|
|
187
|
+
if (this.degraded)
|
|
188
|
+
return;
|
|
189
|
+
this.writeChain = this.writeChain.then(() => this.persist(), () => this.persist());
|
|
190
|
+
}
|
|
191
|
+
async persist() {
|
|
192
|
+
if (this.degraded)
|
|
193
|
+
return;
|
|
194
|
+
const body = {
|
|
195
|
+
version: RECALL_FEEDBACK_FILE_VERSION,
|
|
196
|
+
entries: Object.fromEntries(this.entries),
|
|
197
|
+
};
|
|
198
|
+
try {
|
|
199
|
+
await atomicWriteText(this.file, JSON.stringify(body, null, 2));
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
this.degraded = err instanceof Error ? err.message : String(err);
|
|
203
|
+
if (!this.degradedLogged) {
|
|
204
|
+
this.degradedLogged = true;
|
|
205
|
+
this.logger?.warn(`[memory] 召回反馈落盘失败,转只读降级: ${this.degraded}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** 等待在途写完成(测试/停机用)。 */
|
|
210
|
+
flush() {
|
|
211
|
+
return this.writeChain;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -32,6 +32,17 @@ export declare class RecallUsageStore {
|
|
|
32
32
|
* (used 不在候选内 = 防御路径,同样算一次尝试——用了就是被尝试过)。 */
|
|
33
33
|
recordAttempt(candidateIds: readonly string[], usedIds: readonly string[]): void;
|
|
34
34
|
get(id: string): RecallUsageEntry | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* 把一条记录的使用史搬到新 id(merge 之后由管线调用,P0-d)。
|
|
37
|
+
*
|
|
38
|
+
* 必须搬的理由:成熟度 = `priority + used + 老化 + 人工最近一笔` 的合成,而 merge
|
|
39
|
+
* 每次新铸 id——不搬就等于**每次合并把这条记忆的使用史清零**,它会被系统当成
|
|
40
|
+
* “没用过”而沉底,而且从面板上看不出来(统计仍挂在已退场的旧 id 上)。
|
|
41
|
+
*
|
|
42
|
+
* 两侧都有条目时按“次数相加、时刻取新”合并:这是纯计数,不存在语义冲突。
|
|
43
|
+
* 没条目(旧 id 从未被召回过)时返回 false,**不造零条目**——那会把表灌满噪声行。
|
|
44
|
+
*/
|
|
45
|
+
rekey(oldId: string, newId: string): boolean;
|
|
35
46
|
summary(): RecallUsageSummary;
|
|
36
47
|
/** 立即落盘(去抖窗口内的兜底;测试/停机用)。 */
|
|
37
48
|
flush(): Promise<void>;
|
|
@@ -89,6 +89,35 @@ export class RecallUsageStore {
|
|
|
89
89
|
const e = this.entries.get(id);
|
|
90
90
|
return e ? { ...e } : undefined;
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* 把一条记录的使用史搬到新 id(merge 之后由管线调用,P0-d)。
|
|
94
|
+
*
|
|
95
|
+
* 必须搬的理由:成熟度 = `priority + used + 老化 + 人工最近一笔` 的合成,而 merge
|
|
96
|
+
* 每次新铸 id——不搬就等于**每次合并把这条记忆的使用史清零**,它会被系统当成
|
|
97
|
+
* “没用过”而沉底,而且从面板上看不出来(统计仍挂在已退场的旧 id 上)。
|
|
98
|
+
*
|
|
99
|
+
* 两侧都有条目时按“次数相加、时刻取新”合并:这是纯计数,不存在语义冲突。
|
|
100
|
+
* 没条目(旧 id 从未被召回过)时返回 false,**不造零条目**——那会把表灌满噪声行。
|
|
101
|
+
*/
|
|
102
|
+
rekey(oldId, newId) {
|
|
103
|
+
this.ensureLoaded();
|
|
104
|
+
if (oldId === newId)
|
|
105
|
+
return false;
|
|
106
|
+
const from = this.entries.get(oldId);
|
|
107
|
+
if (from === undefined)
|
|
108
|
+
return false;
|
|
109
|
+
const to = this.entries.get(newId);
|
|
110
|
+
this.entries.set(newId, {
|
|
111
|
+
attempts: (to?.attempts ?? 0) + from.attempts,
|
|
112
|
+
used: (to?.used ?? 0) + from.used,
|
|
113
|
+
lastAttemptAt: Math.max(to?.lastAttemptAt ?? 0, from.lastAttemptAt ?? 0) || null,
|
|
114
|
+
lastUsedAt: Math.max(to?.lastUsedAt ?? 0, from.lastUsedAt ?? 0) || null,
|
|
115
|
+
});
|
|
116
|
+
this.entries.delete(oldId);
|
|
117
|
+
this.prune();
|
|
118
|
+
this.schedulePersist();
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
92
121
|
summary() {
|
|
93
122
|
this.ensureLoaded();
|
|
94
123
|
let attempted = 0;
|
|
@@ -13,10 +13,45 @@ export declare class SessionRecalledStore {
|
|
|
13
13
|
revision(): number;
|
|
14
14
|
private init;
|
|
15
15
|
/** 记入本 sessionKey 本轮实际注入的记录 id(写穿;插入序淘汰最旧)。 */
|
|
16
|
-
add(sessionKey: string, recordIds: readonly string[]): void;
|
|
16
|
+
add(sessionKey: string, recordIds: readonly string[], injectionId?: string): void;
|
|
17
|
+
/**
|
|
18
|
+
* 标为受保护(判过时/软退场):条目留在列表里,且不受上限淘汰与压缩清理影响。
|
|
19
|
+
* id 不在本会话列表里时忽略(不凭这个调用新建条目)。
|
|
20
|
+
*/
|
|
21
|
+
protect(sessionKey: string, recordIds: readonly string[]): void;
|
|
17
22
|
list(sessionKey: string): string[];
|
|
18
|
-
/**
|
|
19
|
-
|
|
23
|
+
/**
|
|
24
|
+
* 上下文事件:按原因区分。`'clear'`(用户主动清空)整表清;`'compact'`(摘要替换)**只打取代标记**。
|
|
25
|
+
*
|
|
26
|
+
* 旧实现两种都整表清,于是“压缩过一次”就把本会话召回历史(含已判过时的裁决凭证)抹平;
|
|
27
|
+
* 改成“压缩什么都不做”又让列表谎报“这些正文还在上下文里”。现在压缩只动一维:
|
|
28
|
+
* 把**此刻在列表里的**标为「已被摘要取代」(已在标记里的不再推序号)。
|
|
29
|
+
* 压缩仍然重置**去重与占用账本**(那两者关心的是“能不能重新注入”,与这张展示表无关)。
|
|
30
|
+
*/
|
|
31
|
+
reset(sessionKey: string, reason?: 'compact' | 'clear'): void;
|
|
32
|
+
/**
|
|
33
|
+
* 本会话「正文已被摘要取代」的 id 集(投影与端点用;只读共享。
|
|
34
|
+
* 未出现过取代的会话返回共享空集,不每次新建)。
|
|
35
|
+
*/
|
|
36
|
+
supersededOf(sessionKey: string): ReadonlySet<string>;
|
|
37
|
+
/**
|
|
38
|
+
* 用会话日志的可见性事实**对账**取代标记(Phase 0B)。
|
|
39
|
+
*
|
|
40
|
+
* 判据来自宿主官方服务的 `surface.nodes`(模型可见节点集),而不是“有没有收到一条压缩事件”:
|
|
41
|
+
* 事件会漏、换代形状还会漂移,而且漏一次就永久错、无法补算。本函数每轮从事实源重算,
|
|
42
|
+
* 所以既能**标上**也能**撤销**(重新注入、或上一轮粗标记误伤的,都能自愈)。
|
|
43
|
+
*
|
|
44
|
+
* @param visible 当前仍在上下文里的注入消息 id 集
|
|
45
|
+
* @param known 会话日志里出现过的本插件注入消息 id 集。**不在 known 里的 = 该注入从未落日志**
|
|
46
|
+
* (被宿主取消/覆盖),属“无从判”——不动它,绝不猜成“已被取代”。
|
|
47
|
+
* @returns 是否真的改动了状态(无改动不推 rev、不写盘)
|
|
48
|
+
*/
|
|
49
|
+
reconcileSuperseded(sessionKey: string, visible: ReadonlySet<string>, known: ReadonlySet<string>): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* 注:曾有 `remove(sessionKey, recordIds)` 供「声明过时」单条摘除,已删。
|
|
52
|
+
* 理由:用户判过时后条目得留在召回列表里显示删除线,所以**只有** `reset(…, 'clear')`
|
|
53
|
+
* 能让条目离开这张列表(判过时的还受 `protectedIds` 保护,连淘汰都走不到它)。
|
|
54
|
+
*/
|
|
20
55
|
size(): number;
|
|
21
56
|
/** 等待在途持久化写完成(测试/停机)。 */
|
|
22
57
|
flush(): Promise<void>;
|
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
* 语义(对齐"当前会话召回后对 session 有影响的条目",且**旧会话重开也有效**):
|
|
5
5
|
* - 每次召回**实际注入**(recall.ts 注入成功点)按 **sessionKey**(=agent.session?.id ?? agent.id,
|
|
6
6
|
* 与 pill 的 sessionId、投影 header.id 同源)记入;
|
|
7
|
-
* - 上下文压缩/清空(agent/created source=compact|clear)按 sessionKey
|
|
7
|
+
* - 上下文压缩/清空(agent/created source=compact|clear)按 sessionKey 处理,**两者不同**:
|
|
8
|
+
* `clear`(用户主动清空会话)整表清;`compact`(摘要替换)**不删条目**,而是把**此刻在列表里
|
|
9
|
+
* 的那批**标成「正文已被摘要取代」(`supersededIds`)——它们的正文确实离开了模型上下文,
|
|
10
|
+
* 但**已判过时的条目是人对这条记忆的裁决凭证**,删了就没了(2026-10-13 用户口径)。
|
|
11
|
+
* 所以压缩只改「这条还在不在上下文里」这一维,不动「本会话召回过什么」这条历史。
|
|
12
|
+
* 重新被注入时取代标记应当撤销(正文又回到上下文了)。
|
|
8
13
|
* - **写穿持久化到 `session-recalled.json`**(独立 sidecar,与 recall-dedupe 同款:串行原子写 +
|
|
9
14
|
* 未知版本/损坏只读降级、失败绝不抛进召回热路径)→ 重启/旧会话重开时 `init()` 载回,卡不空。
|
|
10
15
|
*
|
|
@@ -16,6 +21,23 @@ import { errDetail } from '../util/filelog.js';
|
|
|
16
21
|
import { ensureDir, readJsonStrict, rmwJson } from '../util/io.js';
|
|
17
22
|
import { SESSION_RECALLED_FILE_VERSION } from './file-versions.js';
|
|
18
23
|
import { RECALL_DEDUPE_IDS_CAP, RECALL_DEDUPE_SESSION_CAP } from './recall-dedupe.js';
|
|
24
|
+
/** 共享空集(未出现过取代的会话重复取用,不必每次新建)。 */
|
|
25
|
+
const EMPTY_SET = new Set();
|
|
26
|
+
/**
|
|
27
|
+
* 读 `injectedBy` 的宽容解析:非对象/值非字符串一律丢弃。
|
|
28
|
+
* 宁可让那条记录“无从判”(对账不动它),也不把脏值喂给可见性判定——
|
|
29
|
+
* 一个错的 message id 会把仍在本上下文里的正文误标成“已被摘要取代”。
|
|
30
|
+
*/
|
|
31
|
+
function toIdMap(raw) {
|
|
32
|
+
const out = new Map();
|
|
33
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
34
|
+
return out;
|
|
35
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
36
|
+
if (typeof v === 'string' && v.length > 0)
|
|
37
|
+
out.set(k, v);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
19
41
|
/** 条目过期清理(90 天,与 recall-dedupe 同量级)。 */
|
|
20
42
|
const PRUNE_MS = 90 * 24 * 3600_000;
|
|
21
43
|
export class SessionRecalledStore {
|
|
@@ -65,43 +87,159 @@ export class SessionRecalledStore {
|
|
|
65
87
|
continue;
|
|
66
88
|
if (now - (entry.updatedAt ?? 0) > PRUNE_MS)
|
|
67
89
|
continue;
|
|
68
|
-
this.entries.set(sid, {
|
|
90
|
+
this.entries.set(sid, {
|
|
91
|
+
ids: new Set(entry.recordIds),
|
|
92
|
+
// 旧文件没这个字段(不升版本号:升了会让老文件整体进只读降级),默认空集。
|
|
93
|
+
protectedIds: new Set(Array.isArray(entry.protectedIds) ? entry.protectedIds : []),
|
|
94
|
+
supersededIds: new Set(Array.isArray(entry.supersededIds) ? entry.supersededIds : []),
|
|
95
|
+
injectedBy: toIdMap(entry.injectedBy),
|
|
96
|
+
updatedAt: entry.updatedAt ?? now,
|
|
97
|
+
});
|
|
69
98
|
}
|
|
70
99
|
}
|
|
71
100
|
/** 记入本 sessionKey 本轮实际注入的记录 id(写穿;插入序淘汰最旧)。 */
|
|
72
|
-
add(sessionKey, recordIds) {
|
|
101
|
+
add(sessionKey, recordIds, injectionId) {
|
|
73
102
|
if (!sessionKey || recordIds.length === 0)
|
|
74
103
|
return;
|
|
75
104
|
let entry = this.entries.get(sessionKey);
|
|
76
105
|
if (!entry) {
|
|
77
|
-
entry = { ids: new Set(), updatedAt: 0 };
|
|
106
|
+
entry = { ids: new Set(), protectedIds: new Set(), supersededIds: new Set(), injectedBy: new Map(), updatedAt: 0 };
|
|
78
107
|
this.entries.set(sessionKey, entry);
|
|
79
108
|
}
|
|
109
|
+
const stamp = typeof injectionId === 'string' && injectionId.length > 0 ? injectionId : undefined;
|
|
80
110
|
for (const id of recordIds) {
|
|
81
111
|
entry.ids.add(id);
|
|
112
|
+
// 重新注入 = 正文又回到模型上下文,取代标记随之撤销(否则一条记忆只能被取代一次)。
|
|
113
|
+
entry.supersededIds.delete(id);
|
|
114
|
+
// 记下这次注入落在哪条消息上:没有它,对账就无从判“这条正文还在不在”。
|
|
115
|
+
if (stamp !== undefined)
|
|
116
|
+
entry.injectedBy.set(id, stamp);
|
|
117
|
+
// 淘汰只能淘汰**没被判过时的**:受保护条目数本身超顶时宁可超顶,也不能把人的裁决
|
|
118
|
+
// 凭证挤掉(那等于“因为后面又召回了几条,所以之前那条的判定历史丢了”)。
|
|
82
119
|
while (entry.ids.size > RECALL_DEDUPE_IDS_CAP) {
|
|
83
|
-
const oldest = entry.ids.
|
|
120
|
+
const oldest = [...entry.ids].find((x) => !entry.protectedIds.has(x));
|
|
84
121
|
if (oldest === undefined)
|
|
85
122
|
break;
|
|
86
123
|
entry.ids.delete(oldest);
|
|
124
|
+
entry.protectedIds.delete(oldest);
|
|
125
|
+
entry.supersededIds.delete(oldest);
|
|
126
|
+
entry.injectedBy.delete(oldest);
|
|
87
127
|
}
|
|
88
128
|
}
|
|
89
129
|
entry.updatedAt = Date.now();
|
|
90
130
|
this.rev++;
|
|
91
131
|
this.writeChain = this.writeChain.then(() => this.persist());
|
|
92
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* 标为受保护(判过时/软退场):条目留在列表里,且不受上限淘汰与压缩清理影响。
|
|
135
|
+
* id 不在本会话列表里时忽略(不凭这个调用新建条目)。
|
|
136
|
+
*/
|
|
137
|
+
protect(sessionKey, recordIds) {
|
|
138
|
+
const entry = this.entries.get(sessionKey);
|
|
139
|
+
if (!entry)
|
|
140
|
+
return;
|
|
141
|
+
let touched = false;
|
|
142
|
+
for (const id of recordIds) {
|
|
143
|
+
if (!entry.ids.has(id) || entry.protectedIds.has(id))
|
|
144
|
+
continue;
|
|
145
|
+
entry.protectedIds.add(id);
|
|
146
|
+
touched = true;
|
|
147
|
+
}
|
|
148
|
+
if (!touched)
|
|
149
|
+
return;
|
|
150
|
+
entry.updatedAt = Date.now();
|
|
151
|
+
this.rev++;
|
|
152
|
+
this.writeChain = this.writeChain.then(() => this.persist());
|
|
153
|
+
}
|
|
93
154
|
list(sessionKey) {
|
|
94
155
|
const entry = this.entries.get(sessionKey);
|
|
95
156
|
return entry ? [...entry.ids] : [];
|
|
96
157
|
}
|
|
97
|
-
/**
|
|
98
|
-
|
|
99
|
-
|
|
158
|
+
/**
|
|
159
|
+
* 上下文事件:按原因区分。`'clear'`(用户主动清空)整表清;`'compact'`(摘要替换)**只打取代标记**。
|
|
160
|
+
*
|
|
161
|
+
* 旧实现两种都整表清,于是“压缩过一次”就把本会话召回历史(含已判过时的裁决凭证)抹平;
|
|
162
|
+
* 改成“压缩什么都不做”又让列表谎报“这些正文还在上下文里”。现在压缩只动一维:
|
|
163
|
+
* 把**此刻在列表里的**标为「已被摘要取代」(已在标记里的不再推序号)。
|
|
164
|
+
* 压缩仍然重置**去重与占用账本**(那两者关心的是“能不能重新注入”,与这张展示表无关)。
|
|
165
|
+
*/
|
|
166
|
+
reset(sessionKey, reason = 'clear') {
|
|
167
|
+
const entry = this.entries.get(sessionKey);
|
|
168
|
+
if (reason !== 'clear') {
|
|
169
|
+
if (entry === undefined)
|
|
170
|
+
return;
|
|
171
|
+
let touched = false;
|
|
172
|
+
for (const id of entry.ids) {
|
|
173
|
+
// 正文已经离开上下文(它就是摘要替掉的那一批)。
|
|
174
|
+
if (entry.supersededIds.has(id))
|
|
175
|
+
continue;
|
|
176
|
+
entry.supersededIds.add(id);
|
|
177
|
+
touched = true;
|
|
178
|
+
}
|
|
179
|
+
// 无变更不推序号:否则每次压缩都白写一遍盘、白推一次投影。
|
|
180
|
+
if (!touched)
|
|
181
|
+
return;
|
|
182
|
+
entry.updatedAt = Date.now();
|
|
183
|
+
this.rev++;
|
|
184
|
+
this.writeChain = this.writeChain.then(() => this.persist());
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (entry === undefined)
|
|
100
188
|
return;
|
|
101
189
|
this.entries.delete(sessionKey);
|
|
102
190
|
this.rev++;
|
|
103
191
|
this.writeChain = this.writeChain.then(() => this.persist());
|
|
104
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* 本会话「正文已被摘要取代」的 id 集(投影与端点用;只读共享。
|
|
195
|
+
* 未出现过取代的会话返回共享空集,不每次新建)。
|
|
196
|
+
*/
|
|
197
|
+
supersededOf(sessionKey) {
|
|
198
|
+
return this.entries.get(sessionKey)?.supersededIds ?? EMPTY_SET;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* 用会话日志的可见性事实**对账**取代标记(Phase 0B)。
|
|
202
|
+
*
|
|
203
|
+
* 判据来自宿主官方服务的 `surface.nodes`(模型可见节点集),而不是“有没有收到一条压缩事件”:
|
|
204
|
+
* 事件会漏、换代形状还会漂移,而且漏一次就永久错、无法补算。本函数每轮从事实源重算,
|
|
205
|
+
* 所以既能**标上**也能**撤销**(重新注入、或上一轮粗标记误伤的,都能自愈)。
|
|
206
|
+
*
|
|
207
|
+
* @param visible 当前仍在上下文里的注入消息 id 集
|
|
208
|
+
* @param known 会话日志里出现过的本插件注入消息 id 集。**不在 known 里的 = 该注入从未落日志**
|
|
209
|
+
* (被宿主取消/覆盖),属“无从判”——不动它,绝不猜成“已被取代”。
|
|
210
|
+
* @returns 是否真的改动了状态(无改动不推 rev、不写盘)
|
|
211
|
+
*/
|
|
212
|
+
reconcileSuperseded(sessionKey, visible, known) {
|
|
213
|
+
const entry = this.entries.get(sessionKey);
|
|
214
|
+
if (entry === undefined || entry.injectedBy.size === 0)
|
|
215
|
+
return false;
|
|
216
|
+
let touched = false;
|
|
217
|
+
for (const [id, messageId] of entry.injectedBy) {
|
|
218
|
+
if (!entry.ids.has(id))
|
|
219
|
+
continue;
|
|
220
|
+
if (!known.has(messageId))
|
|
221
|
+
continue;
|
|
222
|
+
if (visible.has(messageId)) {
|
|
223
|
+
if (entry.supersededIds.delete(id))
|
|
224
|
+
touched = true;
|
|
225
|
+
}
|
|
226
|
+
else if (!entry.supersededIds.has(id)) {
|
|
227
|
+
entry.supersededIds.add(id);
|
|
228
|
+
touched = true;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (!touched)
|
|
232
|
+
return false;
|
|
233
|
+
entry.updatedAt = Date.now();
|
|
234
|
+
this.rev++;
|
|
235
|
+
this.writeChain = this.writeChain.then(() => this.persist());
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* 注:曾有 `remove(sessionKey, recordIds)` 供「声明过时」单条摘除,已删。
|
|
240
|
+
* 理由:用户判过时后条目得留在召回列表里显示删除线,所以**只有** `reset(…, 'clear')`
|
|
241
|
+
* 能让条目离开这张列表(判过时的还受 `protectedIds` 保护,连淘汰都走不到它)。
|
|
242
|
+
*/
|
|
105
243
|
size() {
|
|
106
244
|
return this.entries.size;
|
|
107
245
|
}
|
|
@@ -156,7 +294,16 @@ export class SessionRecalledStore {
|
|
|
156
294
|
for (const [sid, e] of this.entries) {
|
|
157
295
|
if (e.ids.size === 0)
|
|
158
296
|
continue;
|
|
159
|
-
|
|
297
|
+
// 不落这个字段 = 保护只活在内存里,重启后判过时的条目又变回可被淘汰的普通条目。
|
|
298
|
+
// 空集不写键:让没保护条目的会话与改动前的文件形状逐字一致(零漂移)。
|
|
299
|
+
// 两个可选键同口径;都为空时形状仍是 {recordIds, updatedAt}。
|
|
300
|
+
sessions[sid] = {
|
|
301
|
+
recordIds: [...e.ids],
|
|
302
|
+
...(e.protectedIds.size > 0 ? { protectedIds: [...e.protectedIds] } : {}),
|
|
303
|
+
...(e.supersededIds.size > 0 ? { supersededIds: [...e.supersededIds] } : {}),
|
|
304
|
+
...(e.injectedBy.size > 0 ? { injectedBy: Object.fromEntries(e.injectedBy) } : {}),
|
|
305
|
+
updatedAt: e.updatedAt,
|
|
306
|
+
};
|
|
160
307
|
}
|
|
161
308
|
return { version: SESSION_RECALLED_FILE_VERSION, sessions };
|
|
162
309
|
}
|
package/dist/store/sqlite.d.ts
CHANGED
|
@@ -119,6 +119,21 @@ export declare class MemoryDb {
|
|
|
119
119
|
* (三处列清单:DDL / insert 语句 / 本函数),故在此留下警示。
|
|
120
120
|
*/
|
|
121
121
|
private backfillL1Fts;
|
|
122
|
+
/**
|
|
123
|
+
* 已退场 L1 行数。原本只给回灌留痕用,现在展示面也要它:
|
|
124
|
+
* `vectorCounts` 算“待补”时得分掉退场行(它们合法地无向量),否则那个数永远归不了零。
|
|
125
|
+
* 失败返回 -1——宁可日志写“未知”也不假装 0 条。
|
|
126
|
+
*/
|
|
127
|
+
countL1Retired(): number;
|
|
128
|
+
/**
|
|
129
|
+
* 清除“已退场但仍待在检索面”的残留行(FTS + 向量)。幂等:第二次跑命中 0 行。
|
|
130
|
+
*
|
|
131
|
+
* 为什么必须存在于启动路径:口径修复只挡住**新增**的脏行,而存量脏行仍在给
|
|
132
|
+
* BM25/向量检索供货——对用户而言就是“标了失效却还能被召回”。
|
|
133
|
+
* 失败只 warn:这是个修复动作,无权把库拖进 degraded,更不能抛进启动
|
|
134
|
+
* 的 await 链(那里报错 = storageOk=false = 记忆功能整体停用)。
|
|
135
|
+
*/
|
|
136
|
+
private purgeRetiredRetrievalRows;
|
|
122
137
|
/** 重建后的 l0_fts 从 l0_conversations 全量回灌(仅 drop 重建时调用;iterate 流式)。 */
|
|
123
138
|
private backfillL0Fts;
|
|
124
139
|
private readEmbeddingMeta;
|
|
@@ -227,6 +242,57 @@ export declare class MemoryDb {
|
|
|
227
242
|
repoKeyName?: string;
|
|
228
243
|
repoKeyOwner?: string;
|
|
229
244
|
}, expectRepoKeyName?: string): number;
|
|
245
|
+
/**
|
|
246
|
+
* 批量写 `workspace_id`(内容路由 / 存量 re-link 用)。单事务,**刻意不碰 `updated_time`**。
|
|
247
|
+
*
|
|
248
|
+
* 为什么不碰 `updated_time`(三条都是硬约束):
|
|
249
|
+
* ① 它在 `canonicalRecords` 的哈希字段表里——动了就是“静默改写”,会让改动前建的快照
|
|
250
|
+
* 永久 verify 不通过(`workspace_id` 本身不在表里,所以**只**写它才是快照安全的);
|
|
251
|
+
* ② 它是时效衰减(agingWeight)与列表排序的输入,改一下归属顺手把记忆“刷陈旧”是意外副作用;
|
|
252
|
+
* ③ re-link 按固定序分页扫,不写它才不会边扫边漂。
|
|
253
|
+
*
|
|
254
|
+
* 为什么连 `l1_fts` 一起改:FTS 行**自带** `workspace_id`(UNINDEXED 随行),
|
|
255
|
+
* 而检索过滤 `(? = '' OR scope='global' OR workspace_id = ?)` 读的就是 FTS 行那一列。
|
|
256
|
+
* 只改主表 = 一旦启用围栏,关键词路按旧归属过滤、向量路按新归属过滤,两路结果不一致。
|
|
257
|
+
* 退场行不在 FTS 里,那条 UPDATE 自然命中 0 行,不是错误。
|
|
258
|
+
*/
|
|
259
|
+
setL1WorkspaceIds(items: ReadonlyArray<{
|
|
260
|
+
id: string;
|
|
261
|
+
workspaceId: string;
|
|
262
|
+
}>): number;
|
|
263
|
+
/**
|
|
264
|
+
* 改一条记录的文件身份名(N1)。三态返回,**不碰 `updated_time`**:它在快照哈希
|
|
265
|
+
* 字段表里、又是衰减与排序的输入——改名不是“内容变了”,不该顺带把这条记成刚更新过
|
|
266
|
+
* (与 `setL1WorkspaceIds` 同一个理由)。`name=''` 是合法值(= 没名字,部分索引不约束空串)。
|
|
267
|
+
*
|
|
268
|
+
* 唯一性由 `l1_records_name_uidx` 挡:撞名 ⇒ `'dup'`,由调用方决定报错还是让用户先改
|
|
269
|
+
* 另一条——**不自动加后缀**,那会静默造出语义重叠的第二条记忆。
|
|
270
|
+
* (另一个口径:机器给**本来没名字**的记录起名时必须消歧,见 `renameL1Batch` 的注释。)
|
|
271
|
+
*/
|
|
272
|
+
renameL1(id: string, name: string): 'ok' | 'dup' | 'missing' | 'failed';
|
|
273
|
+
/**
|
|
274
|
+
* 批量写 `name`(单事务)。给“机器起名”用:存量记录没名字时一次补齐。
|
|
275
|
+
*
|
|
276
|
+
* 与 `renameL1` 的分工不是性能而是**语义**:那里是**人给的**名字,撞了就报错让用户
|
|
277
|
+
* 先改另一条,绝不静默加后缀;这里是机器给**本来没名字**的记录派生名字,同正文的
|
|
278
|
+
* 多条必然撞名(真机实测 42 条挤进 17 个路径),**不消歧就是每轮导出重刷 + 反向同步
|
|
279
|
+
* 把一份内容回灌给多条**。所以消歧必须在本层之上先把名字算唯一,本层只负责落库。
|
|
280
|
+
*
|
|
281
|
+
* 与 `renameL1` 同样**不碰 `updated_time`**(起名不是“内容变了”),也不动 FTS——
|
|
282
|
+
* `name` 不在检索面里(`l1_fts` 没这一列)。
|
|
283
|
+
*
|
|
284
|
+
* @param items - `{ id, name }`,name 已由调用方保证全库唯一。
|
|
285
|
+
* @returns 实际写入的 id 集(现值已等于目标值的跳过——幂等)与失败集。
|
|
286
|
+
*/
|
|
287
|
+
renameL1Batch(items: ReadonlyArray<{
|
|
288
|
+
id: string;
|
|
289
|
+
name: string;
|
|
290
|
+
}>): {
|
|
291
|
+
done: string[];
|
|
292
|
+
failed: string[];
|
|
293
|
+
};
|
|
294
|
+
/** 按文件身份名反查 record_id(重名预检与 md 回灌用);没这条名 ⇒ undefined。*/
|
|
295
|
+
findRecordIdByName(name: string): string | undefined;
|
|
230
296
|
/** 入队一个重聚类作业(demote-to-wiki / repo 归属变化触发;source 区分来源)。 */
|
|
231
297
|
enqueueSceneRecluster(family: string, sceneNames: readonly string[], batchId: string, source?: string): string;
|
|
232
298
|
/** 取一个待处理作业(ruminate 空闲消费;按创建序)。 */
|