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.
- package/CHANGELOG.en.md +401 -0
- package/CHANGELOG.ja.md +401 -0
- package/CHANGELOG.ko.md +401 -0
- package/CHANGELOG.md +100 -0
- package/dist/client.js +1951 -1188
- package/dist/config.d.ts +176 -1240
- package/dist/config.js +113 -2
- package/dist/conflict-service.d.ts +12 -0
- package/dist/conflict-service.js +24 -0
- package/dist/contract.d.ts +327 -0
- package/dist/domain-gate.d.ts +19 -0
- package/dist/domain-gate.js +45 -0
- package/dist/graph/constraints.d.ts +4 -2
- package/dist/graph/constraints.js +4 -2
- package/dist/hooks/obsolete-notice.d.ts +26 -0
- package/dist/hooks/obsolete-notice.js +64 -0
- package/dist/hooks/recall.d.ts +41 -0
- package/dist/hooks/recall.js +127 -28
- package/dist/hooks/skill-recall.d.ts +25 -0
- package/dist/hooks/skill-recall.js +72 -0
- package/dist/index.d.ts +1 -1029
- package/dist/index.js +55 -2
- package/dist/memory-types.d.ts +69 -0
- package/dist/memory-types.js +165 -0
- package/dist/memport/format.d.ts +75 -0
- package/dist/memport/format.js +345 -0
- package/dist/memport/prefilter.d.ts +62 -0
- package/dist/memport/prefilter.js +132 -0
- package/dist/memport/scan.d.ts +13 -0
- package/dist/memport/scan.js +100 -0
- package/dist/metadata-validators.d.ts +22 -0
- package/dist/metadata-validators.js +45 -0
- package/dist/pipeline/l1.d.ts +44 -2
- package/dist/pipeline/l1.js +177 -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 +150 -1
- package/dist/pipeline/runner.d.ts +10 -0
- package/dist/pipeline/runner.js +7 -1
- package/dist/pipeline/slot-migrate.d.ts +45 -0
- package/dist/pipeline/slot-migrate.js +45 -0
- package/dist/pipeline/workspace-relink.d.ts +60 -0
- package/dist/pipeline/workspace-relink.js +73 -0
- package/dist/projection/recall.d.ts +112 -0
- package/dist/projection/recall.js +183 -0
- package/dist/projection/slots.d.ts +5 -0
- package/dist/prompts/examples.d.ts +73 -0
- package/dist/prompts/examples.js +178 -0
- package/dist/prompts/graph-projection.js +3 -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 +9 -4
- package/dist/prompts/l1-extraction.js +76 -8
- package/dist/settings.d.ts +8 -0
- package/dist/settings.js +15 -1
- package/dist/skills/crystallize.d.ts +41 -0
- package/dist/skills/crystallize.js +75 -0
- package/dist/stats.d.ts +30 -1
- package/dist/stats.js +504 -13
- package/dist/store/embedding-source.d.ts +8 -0
- package/dist/store/embedding-source.js +39 -2
- package/dist/store/file-versions.d.ts +4 -0
- package/dist/store/file-versions.js +4 -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 +77 -3
- package/dist/store/l1.js +128 -3
- 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 +23 -0
- package/dist/store/memory-md-disable.js +106 -0
- package/dist/store/memory-md-export.d.ts +50 -0
- package/dist/store/memory-md-export.js +200 -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-skeleton.d.ts +18 -0
- package/dist/store/memory-md-skeleton.js +61 -0
- package/dist/store/memory-md-types.d.ts +174 -0
- package/dist/store/memory-md-types.js +10 -0
- package/dist/store/memory-md.d.ts +108 -0
- package/dist/store/memory-md.js +219 -0
- package/dist/store/memory-sources.d.ts +59 -0
- package/dist/store/memory-sources.js +214 -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 +60 -0
- package/dist/store/session-recalled.js +310 -0
- package/dist/store/skill-store.d.ts +99 -0
- package/dist/store/skill-store.js +330 -0
- package/dist/store/sqlite.d.ts +71 -0
- package/dist/store/sqlite.js +259 -15
- package/dist/store/supersede.d.ts +1 -1
- package/dist/store/supersede.js +1 -1
- package/dist/tools/index.d.ts +4 -13
- package/dist/tools/index.js +80 -12
- package/dist/tools/skills.d.ts +13 -0
- package/dist/tools/skills.js +130 -0
- package/dist/types.d.ts +30 -7
- package/dist/types.js +10 -2
- package/dist/util/maturity.d.ts +44 -0
- package/dist/util/maturity.js +43 -0
- 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/wing-backfill.js +7 -4
- 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,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 外部记忆来源注册表(轨3 Connect 路径的一等实体)。
|
|
3
|
+
*
|
|
4
|
+
* 动机:此前"其他 agent → 记忆库"的取内容路径只装配到 `memory_import` 这最后一寸,
|
|
5
|
+
* 前面十一寸(发现来源、记录来源、按来源管理)全外包给 skill 文本 + 用户手填路径。
|
|
6
|
+
* 本注册表把"来源"升为可持久、可枚举、可按来源撤销的一等实体。
|
|
7
|
+
*
|
|
8
|
+
* **路径指针不复制**(Connect 语义,援引 `slots.refs` 先例):每条只存 `path` + 摘要,
|
|
9
|
+
* 正文**永不落进插件目录**——既是"移动房子家具先消毒"的卫生边界,也让带 ext_key 的
|
|
10
|
+
* 外部记忆"来源仍在、可重放"(可重建性显式化)。
|
|
11
|
+
*
|
|
12
|
+
* 存储:rooms-registry 同款 sidecar(`memory-sources.json`),全量载入内存 + 写穿原子持久化,
|
|
13
|
+
* `rev` 乐观计数。文件缺失 = 合法空表;损坏/不可读 → 告警 + 只读降级(内存态照常、停止回写),
|
|
14
|
+
* **绝不阻塞扫描/导入/面板**(降级即回退"无注册来源",非破坏性)。上限防刷。
|
|
15
|
+
*/
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import { readJsonStrict, atomicWriteText, ensureDir } from '../util/io.js';
|
|
18
|
+
/** sidecar 版本常量(file-versions 同款集中纪律)。 */
|
|
19
|
+
export const MEMORY_SOURCES_FILE_VERSION = 1;
|
|
20
|
+
/** 已知外部 agent 来源类别(与 memport foreign-agent.md 对齐);'file' = 任意路径。 */
|
|
21
|
+
export const SOURCE_KINDS = ['trae', 'qoder', 'codex', 'claude', 'workbuddy', 'golutra', 'cursor', 'file'];
|
|
22
|
+
/** 来源上限(防刷;一个工作区通常 <20 个外部工具记忆落点)。 */
|
|
23
|
+
const SOURCES_CAP = 100;
|
|
24
|
+
const PATH_MAX = 500;
|
|
25
|
+
const LABEL_MAX = 80;
|
|
26
|
+
function isSourceId(s) {
|
|
27
|
+
return /^[a-z0-9][a-z0-9-]{0,63}$/.test(s);
|
|
28
|
+
}
|
|
29
|
+
function normKind(k) {
|
|
30
|
+
return SOURCE_KINDS.includes(String(k)) ? k : 'file';
|
|
31
|
+
}
|
|
32
|
+
export class MemorySourceStore {
|
|
33
|
+
logger;
|
|
34
|
+
file;
|
|
35
|
+
entries = [];
|
|
36
|
+
rev = 0;
|
|
37
|
+
loaded = false;
|
|
38
|
+
degraded;
|
|
39
|
+
degradedLogged = false;
|
|
40
|
+
writeChain = Promise.resolve();
|
|
41
|
+
constructor(dataDir, logger) {
|
|
42
|
+
this.logger = logger;
|
|
43
|
+
this.file = path.join(dataDir, 'memory-sources.json');
|
|
44
|
+
}
|
|
45
|
+
/** 载入(启动 await 一次)。缺失 = 合法空表;损坏 = 只读降级(回退"无来源")。 */
|
|
46
|
+
async init() {
|
|
47
|
+
await ensureDir(path.dirname(this.file));
|
|
48
|
+
const read = await readJsonStrict(this.file);
|
|
49
|
+
if (!read.ok) {
|
|
50
|
+
if (read.reason !== 'missing') {
|
|
51
|
+
this.degraded = `${read.reason}${'detail' in read && read.detail ? `: ${read.detail}` : ''}`;
|
|
52
|
+
this.logger?.warn(`[memory] 外部来源注册表只读降级: ${this.degraded}`);
|
|
53
|
+
}
|
|
54
|
+
this.loaded = true;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (read.value.version !== MEMORY_SOURCES_FILE_VERSION) {
|
|
58
|
+
this.logger?.warn(`[memory] 外部来源注册表版本 ${String(read.value.version)} 未知,按当前形状读取`);
|
|
59
|
+
}
|
|
60
|
+
const sources = Array.isArray(read.value.sources) ? read.value.sources : [];
|
|
61
|
+
for (const s of sources) {
|
|
62
|
+
if (!s || typeof s.id !== 'string' || !isSourceId(s.id))
|
|
63
|
+
continue; // 非法 id 丢弃
|
|
64
|
+
if (typeof s.path !== 'string' || !s.path)
|
|
65
|
+
continue; // 无路径指针的来源无意义,丢弃
|
|
66
|
+
this.entries.push({
|
|
67
|
+
id: s.id,
|
|
68
|
+
kind: normKind(s.kind),
|
|
69
|
+
path: s.path.slice(0, PATH_MAX),
|
|
70
|
+
label: typeof s.label === 'string' ? s.label : undefined,
|
|
71
|
+
lastScanAt: typeof s.lastScanAt === 'string' ? s.lastScanAt : '',
|
|
72
|
+
lastImportAt: typeof s.lastImportAt === 'string' ? s.lastImportAt : '',
|
|
73
|
+
disabled: s.disabled === true,
|
|
74
|
+
status: s.status === 'retired' ? 'retired' : 'active',
|
|
75
|
+
createdAt: typeof s.createdAt === 'string' ? s.createdAt : '',
|
|
76
|
+
updatedAt: typeof s.updatedAt === 'string' ? s.updatedAt : '',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
this.rev = typeof read.value.rev === 'number' ? read.value.rev : 0;
|
|
80
|
+
this.loaded = true;
|
|
81
|
+
}
|
|
82
|
+
ensureLoaded() {
|
|
83
|
+
if (!this.loaded)
|
|
84
|
+
throw new Error('外部来源注册表未初始化(先 await init())');
|
|
85
|
+
}
|
|
86
|
+
/** 全量条目(含 retired;副本返回,活引用纪律)。 */
|
|
87
|
+
list() {
|
|
88
|
+
this.ensureLoaded();
|
|
89
|
+
return this.entries.map((e) => ({ ...e }));
|
|
90
|
+
}
|
|
91
|
+
/** active 且未停用的来源(扫描/导入遍历用)。 */
|
|
92
|
+
listEnabled() {
|
|
93
|
+
return this.list().filter((e) => e.status === 'active' && !e.disabled);
|
|
94
|
+
}
|
|
95
|
+
byId(id) {
|
|
96
|
+
this.ensureLoaded();
|
|
97
|
+
const found = this.entries.find((e) => e.id === id);
|
|
98
|
+
return found ? { ...found } : undefined;
|
|
99
|
+
}
|
|
100
|
+
/** 登记来源(幂等:同 id 已存在则原地补全 kind/path/label,不新建)。上限防刷。 */
|
|
101
|
+
async register(input) {
|
|
102
|
+
this.ensureLoaded();
|
|
103
|
+
const id = String(input.id ?? '').trim().toLowerCase();
|
|
104
|
+
if (!isSourceId(id))
|
|
105
|
+
throw new Error(`非法来源 id: ${String(input.id)}(小写字母数字连字符,≤64)`);
|
|
106
|
+
const p = String(input.path ?? '').trim();
|
|
107
|
+
if (!p)
|
|
108
|
+
throw new Error('来源 path 不能为空(路径指针不复制,但必须指向真实落点)');
|
|
109
|
+
if (p.length > PATH_MAX)
|
|
110
|
+
throw new Error(`来源 path 过长(>${PATH_MAX})`);
|
|
111
|
+
const found = this.entries.find((e) => e.id === id);
|
|
112
|
+
if (found) {
|
|
113
|
+
let changed = false;
|
|
114
|
+
const kind = normKind(input.kind);
|
|
115
|
+
if (found.kind !== kind) {
|
|
116
|
+
found.kind = kind;
|
|
117
|
+
changed = true;
|
|
118
|
+
}
|
|
119
|
+
if (found.path !== p) {
|
|
120
|
+
found.path = p;
|
|
121
|
+
changed = true;
|
|
122
|
+
}
|
|
123
|
+
if (typeof input.label === 'string' && input.label.trim() && found.label !== input.label.trim().slice(0, LABEL_MAX)) {
|
|
124
|
+
found.label = input.label.trim().slice(0, LABEL_MAX);
|
|
125
|
+
changed = true;
|
|
126
|
+
}
|
|
127
|
+
if (found.status === 'retired') {
|
|
128
|
+
found.status = 'active';
|
|
129
|
+
changed = true;
|
|
130
|
+
}
|
|
131
|
+
if (changed) {
|
|
132
|
+
found.updatedAt = new Date().toISOString();
|
|
133
|
+
this.rev++;
|
|
134
|
+
await this.persist();
|
|
135
|
+
}
|
|
136
|
+
return { entry: { ...found }, created: false };
|
|
137
|
+
}
|
|
138
|
+
if (this.entries.length >= SOURCES_CAP)
|
|
139
|
+
throw new Error(`外部来源已达上限(${SOURCES_CAP}),请先退役部分来源`);
|
|
140
|
+
const now = new Date().toISOString();
|
|
141
|
+
const entry = {
|
|
142
|
+
id,
|
|
143
|
+
kind: normKind(input.kind),
|
|
144
|
+
path: p.slice(0, PATH_MAX),
|
|
145
|
+
label: typeof input.label === 'string' && input.label.trim() ? input.label.trim().slice(0, LABEL_MAX) : undefined,
|
|
146
|
+
lastScanAt: '',
|
|
147
|
+
lastImportAt: '',
|
|
148
|
+
disabled: false,
|
|
149
|
+
status: 'active',
|
|
150
|
+
createdAt: now,
|
|
151
|
+
updatedAt: now,
|
|
152
|
+
};
|
|
153
|
+
this.entries.push(entry);
|
|
154
|
+
this.rev++;
|
|
155
|
+
await this.persist();
|
|
156
|
+
return { entry: { ...entry }, created: true };
|
|
157
|
+
}
|
|
158
|
+
/** 记一次扫描时间。 */
|
|
159
|
+
async markScanned(id, at) {
|
|
160
|
+
return this.patch(id, (e) => {
|
|
161
|
+
e.lastScanAt = at;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/** 记一次导入时间。 */
|
|
165
|
+
async markImported(id, at) {
|
|
166
|
+
return this.patch(id, (e) => {
|
|
167
|
+
e.lastImportAt = at;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/** 启用/停用(停用保留条目、只是不参与自动扫描)。 */
|
|
171
|
+
async setDisabled(id, disabled) {
|
|
172
|
+
return this.patch(id, (e) => {
|
|
173
|
+
e.disabled = disabled;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/** 退役/恢复(status 迁移)。 */
|
|
177
|
+
async setStatus(id, status) {
|
|
178
|
+
return this.patch(id, (e) => {
|
|
179
|
+
e.status = status;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
async patch(id, mut) {
|
|
183
|
+
this.ensureLoaded();
|
|
184
|
+
const found = this.entries.find((e) => e.id === id);
|
|
185
|
+
if (!found)
|
|
186
|
+
return false;
|
|
187
|
+
mut(found);
|
|
188
|
+
found.updatedAt = new Date().toISOString();
|
|
189
|
+
this.rev++;
|
|
190
|
+
await this.persist();
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
/** 写穿持久化(串行链;失败降级内存态并告警一次,绝不抛出阻塞调用方)。 */
|
|
194
|
+
async persist() {
|
|
195
|
+
if (this.degraded)
|
|
196
|
+
return;
|
|
197
|
+
this.writeChain = this.writeChain.then(async () => {
|
|
198
|
+
const data = { version: MEMORY_SOURCES_FILE_VERSION, rev: this.rev + 1, sources: this.entries };
|
|
199
|
+
try {
|
|
200
|
+
await ensureDir(path.dirname(this.file));
|
|
201
|
+
await atomicWriteText(this.file, JSON.stringify(data, null, 2));
|
|
202
|
+
this.rev = data.rev;
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
this.degraded = err instanceof Error ? err.message : String(err);
|
|
206
|
+
if (!this.degradedLogged) {
|
|
207
|
+
this.degradedLogged = true;
|
|
208
|
+
this.logger?.warn(`[memory] 外部来源注册表写入失败,只读降级: ${this.degraded}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
await this.writeChain;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { MemoryLogger } from '../types.js';
|
|
2
|
+
export declare const OBSOLETE_NOTICES_FILE_VERSION: 1;
|
|
3
|
+
/** 一条待播报的过时声明。 */
|
|
4
|
+
export interface ObsoleteNotice {
|
|
5
|
+
/** 被退场的 L1 记录 id。 */
|
|
6
|
+
id: string;
|
|
7
|
+
/** 正文首行(注入时供模型定位是哪条;截断至 TITLE_MAX)。 */
|
|
8
|
+
title: string;
|
|
9
|
+
/** 用户声明时刻(epoch ms)。 */
|
|
10
|
+
at: number;
|
|
11
|
+
}
|
|
12
|
+
export declare class ObsoleteNoticeStore {
|
|
13
|
+
private readonly logger?;
|
|
14
|
+
private readonly file;
|
|
15
|
+
private buckets;
|
|
16
|
+
private loaded;
|
|
17
|
+
private degraded;
|
|
18
|
+
private degradedLogged;
|
|
19
|
+
private writeChain;
|
|
20
|
+
private rev;
|
|
21
|
+
constructor(dataDir: string, logger?: MemoryLogger | undefined);
|
|
22
|
+
init(): Promise<void>;
|
|
23
|
+
private normalize;
|
|
24
|
+
private ensureLoaded;
|
|
25
|
+
/** 判脏序号(测试/观测用;注入侧靠 drain 的返回值判断有无)。 */
|
|
26
|
+
revision(): number;
|
|
27
|
+
/** 入队一条(幂等由调用方保证:同 id 重复入队只会多播一行,故这里去重)。 */
|
|
28
|
+
push(sessionKey: string, notice: ObsoleteNotice): void;
|
|
29
|
+
/** 出队并清空该会话的待播报(一次性语义:取走即离开 outbox)。 */
|
|
30
|
+
drain(sessionKey: string): ObsoleteNotice[];
|
|
31
|
+
/** 只看不取(测试/诊断)。 */
|
|
32
|
+
peek(sessionKey: string): ObsoleteNotice[];
|
|
33
|
+
size(): number;
|
|
34
|
+
private prune;
|
|
35
|
+
private schedulePersist;
|
|
36
|
+
private persist;
|
|
37
|
+
/** 等待在途写完成(测试/停机用)。 */
|
|
38
|
+
flush(): Promise<void>;
|
|
39
|
+
}
|
|
@@ -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
|
+
}
|