dsh-prime-memory 0.22.1 → 0.24.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.
Files changed (121) hide show
  1. package/CHANGELOG.en.md +401 -0
  2. package/CHANGELOG.ja.md +401 -0
  3. package/CHANGELOG.ko.md +401 -0
  4. package/CHANGELOG.md +100 -0
  5. package/dist/client.js +1951 -1188
  6. package/dist/config.d.ts +176 -1240
  7. package/dist/config.js +113 -2
  8. package/dist/conflict-service.d.ts +12 -0
  9. package/dist/conflict-service.js +24 -0
  10. package/dist/contract.d.ts +327 -0
  11. package/dist/domain-gate.d.ts +19 -0
  12. package/dist/domain-gate.js +45 -0
  13. package/dist/graph/constraints.d.ts +4 -2
  14. package/dist/graph/constraints.js +4 -2
  15. package/dist/hooks/obsolete-notice.d.ts +26 -0
  16. package/dist/hooks/obsolete-notice.js +64 -0
  17. package/dist/hooks/recall.d.ts +41 -0
  18. package/dist/hooks/recall.js +127 -28
  19. package/dist/hooks/skill-recall.d.ts +25 -0
  20. package/dist/hooks/skill-recall.js +72 -0
  21. package/dist/index.d.ts +1 -1029
  22. package/dist/index.js +55 -2
  23. package/dist/memory-types.d.ts +69 -0
  24. package/dist/memory-types.js +165 -0
  25. package/dist/memport/format.d.ts +75 -0
  26. package/dist/memport/format.js +345 -0
  27. package/dist/memport/prefilter.d.ts +62 -0
  28. package/dist/memport/prefilter.js +132 -0
  29. package/dist/memport/scan.d.ts +13 -0
  30. package/dist/memport/scan.js +100 -0
  31. package/dist/metadata-validators.d.ts +22 -0
  32. package/dist/metadata-validators.js +45 -0
  33. package/dist/pipeline/l1.d.ts +44 -2
  34. package/dist/pipeline/l1.js +177 -9
  35. package/dist/pipeline/md-sync.d.ts +65 -0
  36. package/dist/pipeline/md-sync.js +456 -0
  37. package/dist/pipeline/ruminate.d.ts +46 -1
  38. package/dist/pipeline/ruminate.js +150 -1
  39. package/dist/pipeline/runner.d.ts +10 -0
  40. package/dist/pipeline/runner.js +7 -1
  41. package/dist/pipeline/slot-migrate.d.ts +45 -0
  42. package/dist/pipeline/slot-migrate.js +45 -0
  43. package/dist/pipeline/workspace-relink.d.ts +60 -0
  44. package/dist/pipeline/workspace-relink.js +73 -0
  45. package/dist/projection/recall.d.ts +112 -0
  46. package/dist/projection/recall.js +183 -0
  47. package/dist/projection/slots.d.ts +5 -0
  48. package/dist/prompts/examples.d.ts +73 -0
  49. package/dist/prompts/examples.js +178 -0
  50. package/dist/prompts/graph-projection.js +3 -0
  51. package/dist/prompts/l1-dedup.d.ts +9 -3
  52. package/dist/prompts/l1-dedup.js +34 -13
  53. package/dist/prompts/l1-extraction.d.ts +9 -4
  54. package/dist/prompts/l1-extraction.js +76 -8
  55. package/dist/settings.d.ts +8 -0
  56. package/dist/settings.js +15 -1
  57. package/dist/skills/crystallize.d.ts +41 -0
  58. package/dist/skills/crystallize.js +75 -0
  59. package/dist/stats.d.ts +30 -1
  60. package/dist/stats.js +504 -13
  61. package/dist/store/embedding-source.d.ts +8 -0
  62. package/dist/store/embedding-source.js +39 -2
  63. package/dist/store/file-versions.d.ts +4 -0
  64. package/dist/store/file-versions.js +4 -0
  65. package/dist/store/graph-store.d.ts +14 -0
  66. package/dist/store/graph-store.js +35 -0
  67. package/dist/store/l1-snapshot.d.ts +26 -1
  68. package/dist/store/l1-snapshot.js +53 -8
  69. package/dist/store/l1.d.ts +77 -3
  70. package/dist/store/l1.js +128 -3
  71. package/dist/store/memory-md-archive.d.ts +84 -0
  72. package/dist/store/memory-md-archive.js +155 -0
  73. package/dist/store/memory-md-disable.d.ts +23 -0
  74. package/dist/store/memory-md-disable.js +106 -0
  75. package/dist/store/memory-md-export.d.ts +50 -0
  76. package/dist/store/memory-md-export.js +200 -0
  77. package/dist/store/memory-md-ledger.d.ts +94 -0
  78. package/dist/store/memory-md-ledger.js +243 -0
  79. package/dist/store/memory-md-orphan.d.ts +27 -0
  80. package/dist/store/memory-md-orphan.js +153 -0
  81. package/dist/store/memory-md-parse.d.ts +35 -0
  82. package/dist/store/memory-md-parse.js +85 -0
  83. package/dist/store/memory-md-skeleton.d.ts +18 -0
  84. package/dist/store/memory-md-skeleton.js +61 -0
  85. package/dist/store/memory-md-types.d.ts +174 -0
  86. package/dist/store/memory-md-types.js +10 -0
  87. package/dist/store/memory-md.d.ts +108 -0
  88. package/dist/store/memory-md.js +219 -0
  89. package/dist/store/memory-sources.d.ts +59 -0
  90. package/dist/store/memory-sources.js +214 -0
  91. package/dist/store/obsolete-notices.d.ts +39 -0
  92. package/dist/store/obsolete-notices.js +153 -0
  93. package/dist/store/recall-feedback.d.ts +75 -0
  94. package/dist/store/recall-feedback.js +213 -0
  95. package/dist/store/recall-usage.d.ts +11 -0
  96. package/dist/store/recall-usage.js +29 -0
  97. package/dist/store/session-recalled.d.ts +60 -0
  98. package/dist/store/session-recalled.js +310 -0
  99. package/dist/store/skill-store.d.ts +99 -0
  100. package/dist/store/skill-store.js +330 -0
  101. package/dist/store/sqlite.d.ts +71 -0
  102. package/dist/store/sqlite.js +259 -15
  103. package/dist/store/supersede.d.ts +1 -1
  104. package/dist/store/supersede.js +1 -1
  105. package/dist/tools/index.d.ts +4 -13
  106. package/dist/tools/index.js +80 -12
  107. package/dist/tools/skills.d.ts +13 -0
  108. package/dist/tools/skills.js +130 -0
  109. package/dist/types.d.ts +30 -7
  110. package/dist/types.js +10 -2
  111. package/dist/util/maturity.d.ts +44 -0
  112. package/dist/util/maturity.js +43 -0
  113. package/dist/util/md-name.d.ts +129 -0
  114. package/dist/util/md-name.js +366 -0
  115. package/dist/util/md-normalize.d.ts +23 -0
  116. package/dist/util/md-normalize.js +131 -0
  117. package/dist/wing-backfill.js +7 -4
  118. package/dist/workspace-route.d.ts +22 -0
  119. package/dist/workspace-route.js +66 -0
  120. package/dsh.plugin.json +1 -1
  121. 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;
@@ -0,0 +1,60 @@
1
+ import type { MemoryLogger } from '../types.js';
2
+ export declare class SessionRecalledStore {
3
+ private readonly logger?;
4
+ private readonly file;
5
+ private readonly entries;
6
+ private degraded;
7
+ private degradedLogged;
8
+ private lastPersisted;
9
+ private writeChain;
10
+ /** 脏检序号:每次真实变更 +1,投影 apply 据此 O(1) 判断是否重建。 */
11
+ private rev;
12
+ constructor(dataDir: string, logger?: MemoryLogger | undefined);
13
+ revision(): number;
14
+ private init;
15
+ /** 记入本 sessionKey 本轮实际注入的记录 id(写穿;插入序淘汰最旧)。 */
16
+ add(sessionKey: string, recordIds: readonly string[], injectionId?: string): void;
17
+ /**
18
+ * 标为受保护(判过时/软退场):条目留在列表里,且不受上限淘汰与压缩清理影响。
19
+ * id 不在本会话列表里时忽略(不凭这个调用新建条目)。
20
+ */
21
+ protect(sessionKey: string, recordIds: readonly string[]): void;
22
+ list(sessionKey: string): string[];
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
+ */
55
+ size(): number;
56
+ /** 等待在途持久化写完成(测试/停机)。 */
57
+ flush(): Promise<void>;
58
+ private persist;
59
+ private serialize;
60
+ }
@@ -0,0 +1,310 @@
1
+ /**
2
+ * 会话"活召回集"存储(G-live):sessionKey → 本会话已注入、尚未被压缩覆盖的 L1 记录 id。
3
+ *
4
+ * 语义(对齐"当前会话召回后对 session 有影响的条目",且**旧会话重开也有效**):
5
+ * - 每次召回**实际注入**(recall.ts 注入成功点)按 **sessionKey**(=agent.session?.id ?? agent.id,
6
+ * 与 pill 的 sessionId、投影 header.id 同源)记入;
7
+ * - 上下文压缩/清空(agent/created source=compact|clear)按 sessionKey 处理,**两者不同**:
8
+ * `clear`(用户主动清空会话)整表清;`compact`(摘要替换)**不删条目**,而是把**此刻在列表里
9
+ * 的那批**标成「正文已被摘要取代」(`supersededIds`)——它们的正文确实离开了模型上下文,
10
+ * 但**已判过时的条目是人对这条记忆的裁决凭证**,删了就没了(2026-10-13 用户口径)。
11
+ * 所以压缩只改「这条还在不在上下文里」这一维,不动「本会话召回过什么」这条历史。
12
+ * 重新被注入时取代标记应当撤销(正文又回到上下文了)。
13
+ * - **写穿持久化到 `session-recalled.json`**(独立 sidecar,与 recall-dedupe 同款:串行原子写 +
14
+ * 未知版本/损坏只读降级、失败绝不抛进召回热路径)→ 重启/旧会话重开时 `init()` 载回,卡不空。
15
+ *
16
+ * 供只读端点 `dsh-memory/session-recalled` 消费(client 浮层 SessionRecalledArea 展示)。
17
+ * 不经 memorySlots 投影:那条路会把召回渲染成常驻槽位注册表,与召回的非常驻语义错位。
18
+ */
19
+ import * as path from 'node:path';
20
+ import { errDetail } from '../util/filelog.js';
21
+ import { ensureDir, readJsonStrict, rmwJson } from '../util/io.js';
22
+ import { SESSION_RECALLED_FILE_VERSION } from './file-versions.js';
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
+ }
41
+ /** 条目过期清理(90 天,与 recall-dedupe 同量级)。 */
42
+ const PRUNE_MS = 90 * 24 * 3600_000;
43
+ export class SessionRecalledStore {
44
+ logger;
45
+ file;
46
+ entries = new Map();
47
+ degraded;
48
+ degradedLogged = false;
49
+ lastPersisted;
50
+ writeChain;
51
+ /** 脏检序号:每次真实变更 +1,投影 apply 据此 O(1) 判断是否重建。 */
52
+ rev = 0;
53
+ constructor(dataDir, logger) {
54
+ this.logger = logger;
55
+ this.file = path.join(dataDir, 'session-recalled.json');
56
+ this.writeChain = this.init();
57
+ }
58
+ revision() {
59
+ return this.rev;
60
+ }
61
+ async init() {
62
+ const r = await readJsonStrict(this.file, { expectedVersion: SESSION_RECALLED_FILE_VERSION });
63
+ let data;
64
+ if (r.ok) {
65
+ data = r.value;
66
+ }
67
+ else if (r.reason === 'missing') {
68
+ return;
69
+ }
70
+ else {
71
+ const lenient = await readJsonStrict(this.file);
72
+ if (!lenient.ok) {
73
+ this.degraded = lenient.reason;
74
+ this.logger?.warn(`[memory] 活召回集文件${lenient.reason === 'corrupt' ? '损坏' : '不可读'}(${this.file}):按空起步且不回写`);
75
+ return;
76
+ }
77
+ data = lenient.value;
78
+ this.degraded = `未知版本(${lenient.version ?? '无 version 字段'})`;
79
+ this.logger?.warn(`[memory] 活召回集文件版本未知(${lenient.version ?? '无'}):按当前形状读取并只读降级(不回写)`);
80
+ }
81
+ this.lastPersisted = JSON.stringify(data);
82
+ if (!data?.sessions || typeof data.sessions !== 'object')
83
+ return;
84
+ const now = Date.now();
85
+ for (const [sid, entry] of Object.entries(data.sessions)) {
86
+ if (!Array.isArray(entry?.recordIds))
87
+ continue;
88
+ if (now - (entry.updatedAt ?? 0) > PRUNE_MS)
89
+ continue;
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
+ });
98
+ }
99
+ }
100
+ /** 记入本 sessionKey 本轮实际注入的记录 id(写穿;插入序淘汰最旧)。 */
101
+ add(sessionKey, recordIds, injectionId) {
102
+ if (!sessionKey || recordIds.length === 0)
103
+ return;
104
+ let entry = this.entries.get(sessionKey);
105
+ if (!entry) {
106
+ entry = { ids: new Set(), protectedIds: new Set(), supersededIds: new Set(), injectedBy: new Map(), updatedAt: 0 };
107
+ this.entries.set(sessionKey, entry);
108
+ }
109
+ const stamp = typeof injectionId === 'string' && injectionId.length > 0 ? injectionId : undefined;
110
+ for (const id of recordIds) {
111
+ entry.ids.add(id);
112
+ // 重新注入 = 正文又回到模型上下文,取代标记随之撤销(否则一条记忆只能被取代一次)。
113
+ entry.supersededIds.delete(id);
114
+ // 记下这次注入落在哪条消息上:没有它,对账就无从判“这条正文还在不在”。
115
+ if (stamp !== undefined)
116
+ entry.injectedBy.set(id, stamp);
117
+ // 淘汰只能淘汰**没被判过时的**:受保护条目数本身超顶时宁可超顶,也不能把人的裁决
118
+ // 凭证挤掉(那等于“因为后面又召回了几条,所以之前那条的判定历史丢了”)。
119
+ while (entry.ids.size > RECALL_DEDUPE_IDS_CAP) {
120
+ const oldest = [...entry.ids].find((x) => !entry.protectedIds.has(x));
121
+ if (oldest === undefined)
122
+ break;
123
+ entry.ids.delete(oldest);
124
+ entry.protectedIds.delete(oldest);
125
+ entry.supersededIds.delete(oldest);
126
+ entry.injectedBy.delete(oldest);
127
+ }
128
+ }
129
+ entry.updatedAt = Date.now();
130
+ this.rev++;
131
+ this.writeChain = this.writeChain.then(() => this.persist());
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
+ }
154
+ list(sessionKey) {
155
+ const entry = this.entries.get(sessionKey);
156
+ return entry ? [...entry.ids] : [];
157
+ }
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)
188
+ return;
189
+ this.entries.delete(sessionKey);
190
+ this.rev++;
191
+ this.writeChain = this.writeChain.then(() => this.persist());
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
+ */
243
+ size() {
244
+ return this.entries.size;
245
+ }
246
+ /** 等待在途持久化写完成(测试/停机)。 */
247
+ flush() {
248
+ return this.writeChain;
249
+ }
250
+ async persist() {
251
+ if (this.degraded !== undefined) {
252
+ if (!this.degradedLogged) {
253
+ this.degradedLogged = true;
254
+ this.logger?.warn(`[memory] 活召回集处于只读降级(${this.degraded}),已停止回写`);
255
+ }
256
+ return;
257
+ }
258
+ try {
259
+ await ensureDir(path.dirname(this.file));
260
+ await rmwJson(this.file, async (cur) => {
261
+ const diskRaw = cur.ok ? JSON.stringify(cur.value) : undefined;
262
+ if (this.lastPersisted !== undefined && diskRaw !== undefined && diskRaw !== this.lastPersisted) {
263
+ throw new Error('活召回集文件已被其它进程更新,本次写入取消以避免覆盖');
264
+ }
265
+ const next = this.serialize();
266
+ this.lastPersisted = JSON.stringify(next);
267
+ return { next, result: undefined };
268
+ }, { logger: this.logger, purpose: 'session-recalled-rmw' });
269
+ }
270
+ catch (err) {
271
+ this.logger?.warn(`[memory] 活召回集持久化失败(降级内存态): ${errDetail(err)}`);
272
+ }
273
+ }
274
+ serialize() {
275
+ const now = Date.now();
276
+ for (const [sid, e] of this.entries) {
277
+ if (now - e.updatedAt > PRUNE_MS && e.updatedAt > 0)
278
+ this.entries.delete(sid);
279
+ }
280
+ while (this.entries.size > RECALL_DEDUPE_SESSION_CAP) {
281
+ let oldestKey;
282
+ let oldestAt = Infinity;
283
+ for (const [k, e] of this.entries) {
284
+ if (e.updatedAt < oldestAt) {
285
+ oldestAt = e.updatedAt;
286
+ oldestKey = k;
287
+ }
288
+ }
289
+ if (oldestKey === undefined)
290
+ break;
291
+ this.entries.delete(oldestKey);
292
+ }
293
+ const sessions = {};
294
+ for (const [sid, e] of this.entries) {
295
+ if (e.ids.size === 0)
296
+ continue;
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
+ };
307
+ }
308
+ return { version: SESSION_RECALLED_FILE_VERSION, sessions };
309
+ }
310
+ }