dsh-prime-memory 0.22.2 → 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 (98) 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 +1180 -845
  6. package/dist/config.d.ts +128 -1341
  7. package/dist/config.js +80 -4
  8. package/dist/conflict-service.d.ts +4 -0
  9. package/dist/conflict-service.js +6 -1
  10. package/dist/contract.d.ts +196 -0
  11. package/dist/domain-gate.d.ts +11 -0
  12. package/dist/domain-gate.js +15 -0
  13. package/dist/hooks/obsolete-notice.d.ts +26 -0
  14. package/dist/hooks/obsolete-notice.js +64 -0
  15. package/dist/hooks/recall.d.ts +39 -0
  16. package/dist/hooks/recall.js +110 -30
  17. package/dist/index.d.ts +1 -1127
  18. package/dist/index.js +30 -2
  19. package/dist/memory-types.d.ts +69 -0
  20. package/dist/memory-types.js +165 -0
  21. package/dist/memport/format.d.ts +6 -1
  22. package/dist/memport/format.js +42 -2
  23. package/dist/pipeline/l1.d.ts +44 -2
  24. package/dist/pipeline/l1.js +163 -9
  25. package/dist/pipeline/md-sync.d.ts +65 -0
  26. package/dist/pipeline/md-sync.js +456 -0
  27. package/dist/pipeline/ruminate.d.ts +43 -1
  28. package/dist/pipeline/ruminate.js +100 -0
  29. package/dist/pipeline/runner.d.ts +10 -0
  30. package/dist/pipeline/runner.js +7 -1
  31. package/dist/pipeline/workspace-relink.d.ts +60 -0
  32. package/dist/pipeline/workspace-relink.js +73 -0
  33. package/dist/projection/recall.d.ts +51 -13
  34. package/dist/projection/recall.js +47 -12
  35. package/dist/prompts/examples.d.ts +73 -0
  36. package/dist/prompts/examples.js +178 -0
  37. package/dist/prompts/l1-dedup.d.ts +9 -3
  38. package/dist/prompts/l1-dedup.js +34 -13
  39. package/dist/prompts/l1-extraction.d.ts +5 -3
  40. package/dist/prompts/l1-extraction.js +49 -4
  41. package/dist/settings.d.ts +8 -0
  42. package/dist/settings.js +12 -1
  43. package/dist/stats.d.ts +12 -0
  44. package/dist/stats.js +327 -15
  45. package/dist/store/embedding-source.d.ts +8 -0
  46. package/dist/store/embedding-source.js +39 -2
  47. package/dist/store/file-versions.d.ts +2 -0
  48. package/dist/store/file-versions.js +2 -0
  49. package/dist/store/graph-store.d.ts +14 -0
  50. package/dist/store/graph-store.js +35 -0
  51. package/dist/store/l1-snapshot.d.ts +26 -1
  52. package/dist/store/l1-snapshot.js +53 -8
  53. package/dist/store/l1.d.ts +75 -3
  54. package/dist/store/l1.js +124 -3
  55. package/dist/store/memory-md-archive.d.ts +84 -0
  56. package/dist/store/memory-md-archive.js +155 -0
  57. package/dist/store/memory-md-disable.d.ts +23 -0
  58. package/dist/store/memory-md-disable.js +106 -0
  59. package/dist/store/memory-md-export.d.ts +50 -0
  60. package/dist/store/memory-md-export.js +200 -0
  61. package/dist/store/memory-md-ledger.d.ts +94 -0
  62. package/dist/store/memory-md-ledger.js +243 -0
  63. package/dist/store/memory-md-orphan.d.ts +27 -0
  64. package/dist/store/memory-md-orphan.js +153 -0
  65. package/dist/store/memory-md-parse.d.ts +35 -0
  66. package/dist/store/memory-md-parse.js +85 -0
  67. package/dist/store/memory-md-skeleton.d.ts +18 -0
  68. package/dist/store/memory-md-skeleton.js +61 -0
  69. package/dist/store/memory-md-types.d.ts +174 -0
  70. package/dist/store/memory-md-types.js +10 -0
  71. package/dist/store/memory-md.d.ts +108 -0
  72. package/dist/store/memory-md.js +219 -0
  73. package/dist/store/obsolete-notices.d.ts +39 -0
  74. package/dist/store/obsolete-notices.js +153 -0
  75. package/dist/store/recall-feedback.d.ts +75 -0
  76. package/dist/store/recall-feedback.js +213 -0
  77. package/dist/store/recall-usage.d.ts +11 -0
  78. package/dist/store/recall-usage.js +29 -0
  79. package/dist/store/session-recalled.d.ts +38 -3
  80. package/dist/store/session-recalled.js +156 -9
  81. package/dist/store/sqlite.d.ts +66 -0
  82. package/dist/store/sqlite.js +233 -15
  83. package/dist/store/supersede.d.ts +1 -1
  84. package/dist/store/supersede.js +1 -1
  85. package/dist/tools/index.d.ts +4 -0
  86. package/dist/tools/index.js +23 -6
  87. package/dist/types.d.ts +24 -7
  88. package/dist/types.js +10 -2
  89. package/dist/util/maturity.d.ts +20 -3
  90. package/dist/util/maturity.js +18 -1
  91. package/dist/util/md-name.d.ts +129 -0
  92. package/dist/util/md-name.js +366 -0
  93. package/dist/util/md-normalize.d.ts +23 -0
  94. package/dist/util/md-normalize.js +131 -0
  95. package/dist/workspace-route.d.ts +22 -0
  96. package/dist/workspace-route.js +66 -0
  97. package/dsh.plugin.json +1 -1
  98. package/package.json +6 -5
@@ -0,0 +1,153 @@
1
+ /**
2
+ * 「记忆已声明过时」待播报队列(outbox,`obsolete-notices.json`)。
3
+ *
4
+ * 为什么需要它:用户在召回卡上打 `-1` 之后,**记忆库那一侧当场就改了**(退场),
5
+ * 但模型此刻的上下文里那条记忆还在——它得被告知"这条已由用户判定过时、别再依据它
6
+ * 作答"。这个告知是一次性的:下一轮注入即出队,不常驻(常驻是槽位的语义,见
7
+ * `hooks/slot-recall.ts`)。
8
+ *
9
+ * 落盘而非纯内存:打 `-1` 到下一轮之间若进程重启,纯内存会**静默丢掉这一票**——
10
+ * 记忆已经退场了,而模型从没被告知。所以 outbox 必须持久化(与 session-recalled
11
+ * 同款纪律:写穿 + 版本 + 损坏只读降级,绝不抛进注入热路径)。
12
+ *
13
+ * 键 = sessionKey(`agent.session?.id ?? agent.id`),与活召回集、pill 的 sessionId
14
+ * 同源;三处不同键会直接导致"播报错会话",见 store/session-recalled.ts 头注。
15
+ */
16
+ import * as path from 'node:path';
17
+ import { readJsonStrict, atomicWriteText, ensureDir } from '../util/io.js';
18
+ export const OBSOLETE_NOTICES_FILE_VERSION = 1;
19
+ /** 单会话待播报上限(超出丢最旧:模型只需知道"这些别再信",不需历史)。 */
20
+ const PER_SESSION_CAP = 8;
21
+ /** 会话数上限,按最近写入时间淘汰。 */
22
+ const SESSION_CAP = 200;
23
+ const TITLE_MAX = 120;
24
+ export class ObsoleteNoticeStore {
25
+ logger;
26
+ file;
27
+ buckets = new Map();
28
+ loaded = false;
29
+ degraded;
30
+ degradedLogged = false;
31
+ writeChain = Promise.resolve();
32
+ rev = 0;
33
+ constructor(dataDir, logger) {
34
+ this.logger = logger;
35
+ this.file = path.join(dataDir, 'obsolete-notices.json');
36
+ }
37
+ async init() {
38
+ await ensureDir(path.dirname(this.file));
39
+ const read = await readJsonStrict(this.file);
40
+ if (!read.ok) {
41
+ if (read.reason !== 'missing') {
42
+ this.degraded = `${read.reason}${'detail' in read && read.detail ? `: ${read.detail}` : ''}`;
43
+ this.logger?.warn(`[memory] 过时播报队列只读降级: ${this.degraded}`);
44
+ }
45
+ this.loaded = true;
46
+ return;
47
+ }
48
+ const raw = read.value.sessions ?? {};
49
+ for (const [sid, list] of Object.entries(raw)) {
50
+ const kept = this.normalize(list);
51
+ if (kept.length > 0)
52
+ this.buckets.set(sid, kept);
53
+ }
54
+ this.loaded = true;
55
+ }
56
+ normalize(list) {
57
+ if (!Array.isArray(list))
58
+ return [];
59
+ const out = [];
60
+ for (const raw of list) {
61
+ if (!raw || typeof raw !== 'object')
62
+ continue;
63
+ const e = raw;
64
+ if (typeof e.id !== 'string' || !e.id || typeof e.title !== 'string' || typeof e.at !== 'number')
65
+ continue;
66
+ out.push({ id: e.id, title: e.title, at: e.at });
67
+ if (out.length >= PER_SESSION_CAP)
68
+ break;
69
+ }
70
+ return out;
71
+ }
72
+ ensureLoaded() {
73
+ if (!this.loaded)
74
+ throw new Error('过时播报队列未初始化(先 await init())');
75
+ }
76
+ /** 判脏序号(测试/观测用;注入侧靠 drain 的返回值判断有无)。 */
77
+ revision() {
78
+ return this.rev;
79
+ }
80
+ /** 入队一条(幂等由调用方保证:同 id 重复入队只会多播一行,故这里去重)。 */
81
+ push(sessionKey, notice) {
82
+ this.ensureLoaded();
83
+ if (!sessionKey)
84
+ return;
85
+ const list = this.buckets.get(sessionKey) ?? [];
86
+ if (list.some((n) => n.id === notice.id))
87
+ return;
88
+ const title = notice.title.length > TITLE_MAX ? `${notice.title.slice(0, TITLE_MAX)}…` : notice.title;
89
+ const next = [...list, { ...notice, title }];
90
+ this.buckets.set(sessionKey, next.slice(-PER_SESSION_CAP));
91
+ this.rev += 1;
92
+ this.prune();
93
+ this.schedulePersist();
94
+ }
95
+ /** 出队并清空该会话的待播报(一次性语义:取走即离开 outbox)。 */
96
+ drain(sessionKey) {
97
+ this.ensureLoaded();
98
+ const list = this.buckets.get(sessionKey);
99
+ if (list === undefined || list.length === 0)
100
+ return [];
101
+ this.buckets.delete(sessionKey);
102
+ this.rev += 1;
103
+ this.schedulePersist();
104
+ return list;
105
+ }
106
+ /** 只看不取(测试/诊断)。 */
107
+ peek(sessionKey) {
108
+ this.ensureLoaded();
109
+ return [...(this.buckets.get(sessionKey) ?? [])];
110
+ }
111
+ size() {
112
+ this.ensureLoaded();
113
+ let n = 0;
114
+ for (const list of this.buckets.values())
115
+ n += list.length;
116
+ return n;
117
+ }
118
+ prune() {
119
+ if (this.buckets.size <= SESSION_CAP)
120
+ return;
121
+ const newestAt = (list) => list.reduce((m, n) => Math.max(m, n.at), 0);
122
+ const byOldest = [...this.buckets.entries()].sort((a, b) => newestAt(a[1]) - newestAt(b[1]));
123
+ for (const [sid] of byOldest.slice(0, this.buckets.size - SESSION_CAP))
124
+ this.buckets.delete(sid);
125
+ }
126
+ schedulePersist() {
127
+ if (this.degraded)
128
+ return;
129
+ this.writeChain = this.writeChain.then(() => this.persist(), () => this.persist());
130
+ }
131
+ async persist() {
132
+ if (this.degraded)
133
+ return;
134
+ const body = {
135
+ version: OBSOLETE_NOTICES_FILE_VERSION,
136
+ sessions: Object.fromEntries(this.buckets),
137
+ };
138
+ try {
139
+ await atomicWriteText(this.file, JSON.stringify(body, null, 2));
140
+ }
141
+ catch (err) {
142
+ this.degraded = err instanceof Error ? err.message : String(err);
143
+ if (!this.degradedLogged) {
144
+ this.degradedLogged = true;
145
+ this.logger?.warn(`[memory] 过时播报队列落盘失败,转只读降级: ${this.degraded}`);
146
+ }
147
+ }
148
+ }
149
+ /** 等待在途写完成(测试/停机用)。 */
150
+ flush() {
151
+ return this.writeChain;
152
+ }
153
+ }
@@ -0,0 +1,75 @@
1
+ import type { MemoryLogger } from '../types.js';
2
+ export declare const RECALL_FEEDBACK_FILE_VERSION: 1;
3
+ /** 合法打分:-1(声明过时)与 0..5(相关度)。 */
4
+ export declare const FEEDBACK_SCORES: readonly [-1, 0, 1, 2, 3, 4, 5];
5
+ export type FeedbackScore = (typeof FEEDBACK_SCORES)[number];
6
+ /** 单条记忆的反馈聚合。 */
7
+ export interface RecallFeedbackEntry {
8
+ /** 最近一次打分(-1..5)。 */
9
+ last: FeedbackScore;
10
+ /** 有效相关度评分次数(不含 -1)。 */
11
+ rated: number;
12
+ /** 0..5 评分均值(rated=0 时为 0)。 */
13
+ mean: number;
14
+ /** 被声明过时的次数(-1 计数)。 */
15
+ obsolete: number;
16
+ /** 最近一次打分时刻(epoch ms)。 */
17
+ updatedAt: number;
18
+ /** 最近若干条打分轨迹(旧→新,截断至 HISTORY_CAP)。 */
19
+ history: Array<{
20
+ s: FeedbackScore;
21
+ at: number;
22
+ }>;
23
+ }
24
+ /** 是否合法打分(供端点入参校验复用)。 */
25
+ export declare function isFeedbackScore(v: unknown): v is FeedbackScore;
26
+ export declare class RecallFeedbackStore {
27
+ private readonly logger?;
28
+ private readonly file;
29
+ private entries;
30
+ private loaded;
31
+ private degraded;
32
+ private degradedLogged;
33
+ private writeChain;
34
+ /** 判脏序号:每次有效写入 +1(投影 view 带 rating,靠它决定要不要重建)。 */
35
+ private rev;
36
+ constructor(dataDir: string, logger?: MemoryLogger | undefined);
37
+ init(): Promise<void>;
38
+ /** 读侧逐条收窄:形状不符的条目丢弃,不让一条坏数据拖垮整表。 */
39
+ private normalize;
40
+ private ensureLoaded;
41
+ /** 判脏序号(供投影 O(1) 决定要不要重建 view)。 */
42
+ revision(): number;
43
+ /**
44
+ * 记一次打分。返回聚合后的条目(供端点回执)。
45
+ * 只聚合不裁决:`-1` 进 obsolete 计数、不进均值(过时不是"不相关",两回事)。
46
+ */
47
+ rate(id: string, score: FeedbackScore, at?: number): RecallFeedbackEntry;
48
+ get(id: string): RecallFeedbackEntry | undefined;
49
+ /** 均值(0..5);无有效评分 → undefined(缺席 ≠ 0 分)。 */
50
+ meanOf(id: string): number | undefined;
51
+ /** 最近一次打分(-1..5);未标注 → undefined。 */
52
+ lastOf(id: string): FeedbackScore | undefined;
53
+ /**
54
+ * 把一条记录的人工标注搬到新 id(merge 之后由管线调用,P0-d)。
55
+ *
56
+ * 不搬的后果比使用史更直接:成熟度只吃“人工最近一笔”,而 `last` 留在旧 id 上
57
+ * 就等于新记录**看起来从没被人打过**;更坏的是旧 id 上那一笔 `-1`(已判过时)
58
+ * 会跟着旧行一起沉进垃圾堆,新行反而以“未标注”的活状态回到召回里。
59
+ *
60
+ * 计数按相加、不按轨迹重算:`history` 截断到 12 条,拿它反推 `obsolete` 必然少算。
61
+ * 两侧都有条目时取较新一侧的 `last`,轨迹按时间归并。
62
+ */
63
+ rekey(oldId: string, newId: string): boolean;
64
+ summary(): {
65
+ tracked: number;
66
+ rated: number;
67
+ obsolete: number;
68
+ };
69
+ private prune;
70
+ /** 立即落盘(串行链上排队,不与在途写交错)。 */
71
+ private schedulePersist;
72
+ private persist;
73
+ /** 等待在途写完成(测试/停机用)。 */
74
+ flush(): Promise<void>;
75
+ }
@@ -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
- reset(sessionKey: string): void;
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>;