dsh-prime-memory 0.20.0-beta.2 → 0.20.0-beta.21
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.md +192 -3
- package/ENGINEERING-NOTES.md +17 -0
- package/dist/client.js +1158 -405
- package/dist/config.d.ts +0 -120
- package/dist/config.js +1 -21
- package/dist/contract.d.ts +111 -56
- package/dist/hooks/recall.d.ts +4 -1
- package/dist/hooks/recall.js +7 -1
- package/dist/index.d.ts +0 -78
- package/dist/index.js +14 -21
- package/dist/metadata-validators.d.ts +5 -0
- package/dist/metadata-validators.js +11 -1
- package/dist/pipeline/recluster.js +1 -1
- package/dist/pipeline/ruminate.d.ts +11 -1
- package/dist/pipeline/ruminate.js +18 -1
- package/dist/room-review.d.ts +196 -0
- package/dist/room-review.js +281 -0
- package/dist/stats.d.ts +23 -34
- package/dist/stats.js +268 -201
- package/dist/store/l1.d.ts +34 -0
- package/dist/store/l1.js +68 -1
- package/dist/store/recall-usage.d.ts +43 -0
- package/dist/store/recall-usage.js +149 -0
- package/dist/store/rooms-registry.d.ts +66 -0
- package/dist/store/rooms-registry.js +250 -0
- package/dist/store/session-modes.d.ts +0 -8
- package/dist/store/session-modes.js +0 -52
- package/dist/store/sqlite.d.ts +4 -0
- package/dist/store/sqlite.js +20 -0
- package/dist/tools/index.d.ts +4 -1
- package/dist/tools/index.js +232 -1
- package/dist/types.d.ts +4 -0
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
- package/dist/hooks/long-task.d.ts +0 -31
- package/dist/hooks/long-task.js +0 -93
- package/dist/store/context-usage.d.ts +0 -28
- package/dist/store/context-usage.js +0 -47
- package/dist/store/todo-ref.d.ts +0 -41
- package/dist/store/todo-ref.js +0 -65
package/dist/store/l1.d.ts
CHANGED
|
@@ -61,6 +61,8 @@ export declare class L1Store {
|
|
|
61
61
|
private readonly decayHalfLifeDays;
|
|
62
62
|
/** §D 第 3 路(图谱回链);缺省 = 不接,恰为 2 路。 */
|
|
63
63
|
private readonly graphLaneProvider?;
|
|
64
|
+
/** 孤儿记忆计数(没有任何 Room 的记录;rooms-get 面板「无绑定」chip 用)。 */
|
|
65
|
+
untaggedL1Count(): number;
|
|
64
66
|
/** Room 计数缓存(见 `listRooms()`:tags 仅随反刍变化,不必每次敲库)。 */
|
|
65
67
|
private roomCache;
|
|
66
68
|
constructor(dataDir: string, db: MemoryDb, embed?: EmbeddingService, strategy?: RecallStrategy, logger?: MemoryLogger,
|
|
@@ -68,6 +70,8 @@ export declare class L1Store {
|
|
|
68
70
|
decayHalfLifeDays?: number,
|
|
69
71
|
/** 图谱路提供者(§D 第 3 路);不传则该路不存在,融合退回双路。 */
|
|
70
72
|
graphLane?: GraphLaneProvider);
|
|
73
|
+
/** 读路径时效半衰期(天;0=关)。老化权重展示与召回衰减同一事实源。 */
|
|
74
|
+
get decayHalfLife(): number;
|
|
71
75
|
init(): Promise<void>;
|
|
72
76
|
/** 旧版单文件 records.jsonl 一次性导入检索库,成功后改名 .imported。 */
|
|
73
77
|
private importLegacy;
|
|
@@ -108,6 +112,34 @@ export declare class L1Store {
|
|
|
108
112
|
source: string;
|
|
109
113
|
} | null;
|
|
110
114
|
finishSceneRecluster(jobId: string, ok: boolean): void;
|
|
115
|
+
/**
|
|
116
|
+
* 收集某 Room 的全部匹配记录(含已退场行,口径与 Room 计数一致)。
|
|
117
|
+
* 内部分页拉取,上限 cap 防跑飞;返回记录数组供备份与干跑预览。
|
|
118
|
+
*/
|
|
119
|
+
listByTagAll(tag: string, cap?: number): MemoryRecord[];
|
|
120
|
+
/**
|
|
121
|
+
* 备份某 Room 的匹配记录到 `<dataDir>/rooms-merge-backups/<ts>-<tag>.json`
|
|
122
|
+
* (执行前快照;破坏性重写前的唯一回滚物料)。返回备份文件路径与条数。
|
|
123
|
+
*/
|
|
124
|
+
backupTagRecords(tag: string): {
|
|
125
|
+
file: string;
|
|
126
|
+
count: number;
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* 游标重写:把 metadata.tags 里的 `from` 全部替换为 `to`(**含已退场行**,
|
|
130
|
+
* 口径与 Room 计数一致)。写前重读-合并-写回(relabel 同款):只动 tags,
|
|
131
|
+
* roomCandidates/roomReview/hall/cogHall/sourceAnchors 等其余键原样保全。
|
|
132
|
+
*
|
|
133
|
+
* 单次上限 cap(500)可续跑:再次调用同一 from 即继续处理剩余行。
|
|
134
|
+
* 返回重写条数 + 受影响的 (family, scene) 集(供 recluster 'room-merge' 入队)。
|
|
135
|
+
*/
|
|
136
|
+
rewriteTag(from: string, to: string, cap?: number): {
|
|
137
|
+
rewritten: number;
|
|
138
|
+
scanned: number;
|
|
139
|
+
families: string[];
|
|
140
|
+
scenes: string[];
|
|
141
|
+
hasMore: boolean;
|
|
142
|
+
};
|
|
111
143
|
/** 按 id 精确取记录(去重决策的版本号查询用,避免全表扫描)。 */
|
|
112
144
|
getByIds(ids: string[]): MemoryRecord[];
|
|
113
145
|
/**
|
|
@@ -311,6 +343,8 @@ export declare class L1Store {
|
|
|
311
343
|
halls?: readonly string[];
|
|
312
344
|
/** Room 过滤(metadata.tags 含该 slug)。 */
|
|
313
345
|
tag?: string;
|
|
346
|
+
/** 孤儿记忆筛选(true = 只列没有 Room 的记录;与 tag 互斥,tag 优先)。 */
|
|
347
|
+
untagged?: boolean;
|
|
314
348
|
workspaceId?: string;
|
|
315
349
|
/** 退场筛查:三态(`undefined` 全部 / `false` 仅活跃 / `true` 仅已退场)。 */
|
|
316
350
|
retired?: boolean;
|
package/dist/store/l1.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* 去重/合并的更新记录走 upsert(新 record id + 版本递增),不再全量重写文件。
|
|
8
8
|
*/
|
|
9
|
-
import { existsSync, promises as fs } from 'node:fs';
|
|
9
|
+
import { existsSync, mkdirSync, promises as fs, writeFileSync } from 'node:fs';
|
|
10
10
|
import * as path from 'node:path';
|
|
11
11
|
import { familyForType, isScopeVisible } from '../types.js';
|
|
12
12
|
import { graphHitRecordIds } from '../graph/search.js';
|
|
@@ -34,6 +34,10 @@ export class L1Store {
|
|
|
34
34
|
decayHalfLifeDays;
|
|
35
35
|
/** §D 第 3 路(图谱回链);缺省 = 不接,恰为 2 路。 */
|
|
36
36
|
graphLaneProvider;
|
|
37
|
+
/** 孤儿记忆计数(没有任何 Room 的记录;rooms-get 面板「无绑定」chip 用)。 */
|
|
38
|
+
untaggedL1Count() {
|
|
39
|
+
return this.db.untaggedL1Count();
|
|
40
|
+
}
|
|
37
41
|
/** Room 计数缓存(见 `listRooms()`:tags 仅随反刍变化,不必每次敲库)。 */
|
|
38
42
|
roomCache = null;
|
|
39
43
|
constructor(dataDir, db, embed = new NoopEmbeddingService(), strategy = 'hybrid', logger,
|
|
@@ -52,6 +56,10 @@ export class L1Store {
|
|
|
52
56
|
this.decayHalfLifeDays = decayHalfLifeDays ?? 30;
|
|
53
57
|
this.graphLaneProvider = graphLane;
|
|
54
58
|
}
|
|
59
|
+
/** 读路径时效半衰期(天;0=关)。老化权重展示与召回衰减同一事实源。 */
|
|
60
|
+
get decayHalfLife() {
|
|
61
|
+
return this.decayHalfLifeDays;
|
|
62
|
+
}
|
|
55
63
|
async init() {
|
|
56
64
|
await ensureDir(this.recordsDir);
|
|
57
65
|
await this.importLegacy();
|
|
@@ -158,6 +166,65 @@ export class L1Store {
|
|
|
158
166
|
finishSceneRecluster(jobId, ok) {
|
|
159
167
|
this.db.finishSceneRecluster(jobId, ok);
|
|
160
168
|
}
|
|
169
|
+
// ── Room merge/rename 的游标重写(分类管理 beta.5;破坏性面)──────────────
|
|
170
|
+
/**
|
|
171
|
+
* 收集某 Room 的全部匹配记录(含已退场行,口径与 Room 计数一致)。
|
|
172
|
+
* 内部分页拉取,上限 cap 防跑飞;返回记录数组供备份与干跑预览。
|
|
173
|
+
*/
|
|
174
|
+
listByTagAll(tag, cap = 500) {
|
|
175
|
+
const out = [];
|
|
176
|
+
for (let offset = 0; offset < cap; offset += 200) {
|
|
177
|
+
const page = this.list({ tag, retired: undefined, limit: Math.min(200, cap - out.length), offset });
|
|
178
|
+
out.push(...page.items);
|
|
179
|
+
if (page.items.length < 200 || out.length >= cap)
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* 备份某 Room 的匹配记录到 `<dataDir>/rooms-merge-backups/<ts>-<tag>.json`
|
|
186
|
+
* (执行前快照;破坏性重写前的唯一回滚物料)。返回备份文件路径与条数。
|
|
187
|
+
*/
|
|
188
|
+
backupTagRecords(tag) {
|
|
189
|
+
const records = this.listByTagAll(tag, 2000);
|
|
190
|
+
const backupDir = path.join(this.dataDir, 'rooms-merge-backups');
|
|
191
|
+
if (!existsSync(backupDir))
|
|
192
|
+
mkdirSync(backupDir, { recursive: true });
|
|
193
|
+
const file = path.join(backupDir, `${Date.now()}-${tag.replace(/[^a-z0-9-]/gi, '_')}.json`);
|
|
194
|
+
writeFileSync(file, JSON.stringify({ tag, exportedAt: new Date().toISOString(), records }, null, 2), 'utf8');
|
|
195
|
+
return { file, count: records.length };
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* 游标重写:把 metadata.tags 里的 `from` 全部替换为 `to`(**含已退场行**,
|
|
199
|
+
* 口径与 Room 计数一致)。写前重读-合并-写回(relabel 同款):只动 tags,
|
|
200
|
+
* roomCandidates/roomReview/hall/cogHall/sourceAnchors 等其余键原样保全。
|
|
201
|
+
*
|
|
202
|
+
* 单次上限 cap(500)可续跑:再次调用同一 from 即继续处理剩余行。
|
|
203
|
+
* 返回重写条数 + 受影响的 (family, scene) 集(供 recluster 'room-merge' 入队)。
|
|
204
|
+
*/
|
|
205
|
+
rewriteTag(from, to, cap = 500) {
|
|
206
|
+
const rows = this.listByTagAll(from, cap + 1);
|
|
207
|
+
const hasMore = rows.length > cap;
|
|
208
|
+
const batch = rows.slice(0, cap);
|
|
209
|
+
let rewritten = 0;
|
|
210
|
+
const families = new Set();
|
|
211
|
+
const scenes = new Set();
|
|
212
|
+
for (const r of batch) {
|
|
213
|
+
const meta = { ...(r.metadata ?? {}) };
|
|
214
|
+
const tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
|
|
215
|
+
if (!tags.includes(from))
|
|
216
|
+
continue; // 命中是 json_each 判等,数组缺失即无可改
|
|
217
|
+
const next = tags.map((t) => (t === from ? to : t));
|
|
218
|
+
meta.tags = [...new Set(next)];
|
|
219
|
+
if (this.patchMetadata(r.id, meta)) {
|
|
220
|
+
rewritten++;
|
|
221
|
+
families.add(r.family ?? 'chat');
|
|
222
|
+
if (r.scene_name)
|
|
223
|
+
scenes.add(r.scene_name);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { rewritten, scanned: batch.length, families: [...families], scenes: [...scenes], hasMore };
|
|
227
|
+
}
|
|
161
228
|
/** 按 id 精确取记录(去重决策的版本号查询用,避免全表扫描)。 */
|
|
162
229
|
getByIds(ids) {
|
|
163
230
|
return this.db.getL1ByIds(ids);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { MemoryLogger } from '../types.js';
|
|
2
|
+
export declare const RECALL_USAGE_FILE_VERSION: 1;
|
|
3
|
+
export interface RecallUsageEntry {
|
|
4
|
+
attempts: number;
|
|
5
|
+
used: number;
|
|
6
|
+
lastAttemptAt: number | null;
|
|
7
|
+
lastUsedAt: number | null;
|
|
8
|
+
}
|
|
9
|
+
export interface RecallUsageSummary {
|
|
10
|
+
/** 有统计的记忆条数。 */
|
|
11
|
+
tracked: number;
|
|
12
|
+
/** 尝试总次数(Σ attempts)。 */
|
|
13
|
+
attempted: number;
|
|
14
|
+
/** 使用总次数(Σ used)。 */
|
|
15
|
+
used: number;
|
|
16
|
+
/** 使用率(百分比整数;attempted=0 时 0)。 */
|
|
17
|
+
rate: number;
|
|
18
|
+
}
|
|
19
|
+
export declare class RecallUsageStore {
|
|
20
|
+
private readonly logger?;
|
|
21
|
+
private readonly file;
|
|
22
|
+
private entries;
|
|
23
|
+
private loaded;
|
|
24
|
+
private degraded;
|
|
25
|
+
private degradedLogged;
|
|
26
|
+
private writeTimer;
|
|
27
|
+
private writeChain;
|
|
28
|
+
constructor(dataDir: string, logger?: MemoryLogger | undefined);
|
|
29
|
+
init(): Promise<void>;
|
|
30
|
+
private ensureLoaded;
|
|
31
|
+
/** 一轮召回:候选集 attempts++/lastAttemptAt=now;used 子集额外 used++/lastUsedAt=now
|
|
32
|
+
* (used 不在候选内 = 防御路径,同样算一次尝试——用了就是被尝试过)。 */
|
|
33
|
+
recordAttempt(candidateIds: readonly string[], usedIds: readonly string[]): void;
|
|
34
|
+
get(id: string): RecallUsageEntry | undefined;
|
|
35
|
+
summary(): RecallUsageSummary;
|
|
36
|
+
/** 立即落盘(去抖窗口内的兜底;测试/停机用)。 */
|
|
37
|
+
flush(): Promise<void>;
|
|
38
|
+
/** 同步等待在途写(测试用;无在途写时立即返回)。 */
|
|
39
|
+
flushSync(): void;
|
|
40
|
+
private prune;
|
|
41
|
+
private schedulePersist;
|
|
42
|
+
private persist;
|
|
43
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 召回使用统计 sidecar(老化权重效果统计的数据面)。
|
|
3
|
+
*
|
|
4
|
+
* 口径(用户裁定):**进 topN(被注入上下文)才算被使用**;参与尝试未进 = 只有
|
|
5
|
+
* attempts。每轮召回 recordAttempt(候选集, 注入集)——内存即时、去抖原子持久化
|
|
6
|
+
* (召回是热路径,统计只碰内存 Map)。
|
|
7
|
+
*
|
|
8
|
+
* 存储:`recall-usage.json` slots 式 sidecar,与 rooms-registry/session-modes 同族
|
|
9
|
+
* 纪律:缺失 = 合法空表;损坏/不可读 → 只读降级(内存态照常,停止回写),绝不阻塞
|
|
10
|
+
* 召回读路径(R2 红线)。
|
|
11
|
+
*/
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import { readJsonStrict, atomicWriteText, ensureDir } from '../util/io.js';
|
|
14
|
+
export const RECALL_USAGE_FILE_VERSION = 1;
|
|
15
|
+
/** 条目上限(防已退场/被取代记忆的 id 无限累积):超限按 lastAttemptAt 淘汰最旧。 */
|
|
16
|
+
const MAX_ENTRIES = 50_000;
|
|
17
|
+
/** 去抖持久化间隔(召回热路径;窗口内断电最多丢最近统计,不影响记忆本体)。 */
|
|
18
|
+
const PERSIST_DEBOUNCE_MS = 5_000;
|
|
19
|
+
export class RecallUsageStore {
|
|
20
|
+
logger;
|
|
21
|
+
file;
|
|
22
|
+
entries = new Map();
|
|
23
|
+
loaded = false;
|
|
24
|
+
degraded;
|
|
25
|
+
degradedLogged = false;
|
|
26
|
+
writeTimer;
|
|
27
|
+
writeChain = Promise.resolve();
|
|
28
|
+
constructor(dataDir, logger) {
|
|
29
|
+
this.logger = logger;
|
|
30
|
+
this.file = path.join(dataDir, 'recall-usage.json');
|
|
31
|
+
}
|
|
32
|
+
async init() {
|
|
33
|
+
await ensureDir(path.dirname(this.file));
|
|
34
|
+
const read = await readJsonStrict(this.file);
|
|
35
|
+
if (!read.ok) {
|
|
36
|
+
if (read.reason !== 'missing') {
|
|
37
|
+
this.degraded = `${read.reason}${'detail' in read && read.detail ? `: ${read.detail}` : ''}`;
|
|
38
|
+
this.logger?.warn(`[memory] 召回使用统计只读降级: ${this.degraded}`);
|
|
39
|
+
}
|
|
40
|
+
this.loaded = true;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const raw = read.value.entries ?? {};
|
|
44
|
+
for (const [id, e] of Object.entries(raw)) {
|
|
45
|
+
if (!e || typeof e.attempts !== 'number')
|
|
46
|
+
continue;
|
|
47
|
+
this.entries.set(id, {
|
|
48
|
+
attempts: e.attempts,
|
|
49
|
+
used: typeof e.used === 'number' ? e.used : 0,
|
|
50
|
+
lastAttemptAt: typeof e.lastAttemptAt === 'number' ? e.lastAttemptAt : null,
|
|
51
|
+
lastUsedAt: typeof e.lastUsedAt === 'number' ? e.lastUsedAt : null,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
this.loaded = true;
|
|
55
|
+
}
|
|
56
|
+
ensureLoaded() {
|
|
57
|
+
if (!this.loaded)
|
|
58
|
+
throw new Error('召回使用统计未初始化(先 await init())');
|
|
59
|
+
}
|
|
60
|
+
/** 一轮召回:候选集 attempts++/lastAttemptAt=now;used 子集额外 used++/lastUsedAt=now
|
|
61
|
+
* (used 不在候选内 = 防御路径,同样算一次尝试——用了就是被尝试过)。 */
|
|
62
|
+
recordAttempt(candidateIds, usedIds) {
|
|
63
|
+
this.ensureLoaded();
|
|
64
|
+
if (candidateIds.length === 0 && usedIds.length === 0)
|
|
65
|
+
return;
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
const cand = new Set(candidateIds);
|
|
68
|
+
for (const id of cand) {
|
|
69
|
+
const e = this.entries.get(id) ?? { attempts: 0, used: 0, lastAttemptAt: null, lastUsedAt: null };
|
|
70
|
+
e.attempts += 1;
|
|
71
|
+
e.lastAttemptAt = now;
|
|
72
|
+
this.entries.set(id, e);
|
|
73
|
+
}
|
|
74
|
+
for (const id of usedIds) {
|
|
75
|
+
const e = this.entries.get(id) ?? { attempts: 0, used: 0, lastAttemptAt: null, lastUsedAt: null };
|
|
76
|
+
e.used += 1;
|
|
77
|
+
if (!cand.has(id)) {
|
|
78
|
+
e.attempts += 1;
|
|
79
|
+
e.lastAttemptAt = now;
|
|
80
|
+
}
|
|
81
|
+
e.lastUsedAt = now;
|
|
82
|
+
this.entries.set(id, e);
|
|
83
|
+
}
|
|
84
|
+
this.prune();
|
|
85
|
+
this.schedulePersist();
|
|
86
|
+
}
|
|
87
|
+
get(id) {
|
|
88
|
+
this.ensureLoaded();
|
|
89
|
+
const e = this.entries.get(id);
|
|
90
|
+
return e ? { ...e } : undefined;
|
|
91
|
+
}
|
|
92
|
+
summary() {
|
|
93
|
+
this.ensureLoaded();
|
|
94
|
+
let attempted = 0;
|
|
95
|
+
let used = 0;
|
|
96
|
+
for (const e of this.entries.values()) {
|
|
97
|
+
attempted += e.attempts;
|
|
98
|
+
used += e.used;
|
|
99
|
+
}
|
|
100
|
+
return { tracked: this.entries.size, attempted, used, rate: attempted > 0 ? Math.round((used / attempted) * 100) : 0 };
|
|
101
|
+
}
|
|
102
|
+
/** 立即落盘(去抖窗口内的兜底;测试/停机用)。 */
|
|
103
|
+
flush() {
|
|
104
|
+
if (this.writeTimer) {
|
|
105
|
+
clearTimeout(this.writeTimer);
|
|
106
|
+
this.writeTimer = undefined;
|
|
107
|
+
}
|
|
108
|
+
return this.persist();
|
|
109
|
+
}
|
|
110
|
+
/** 同步等待在途写(测试用;无在途写时立即返回)。 */
|
|
111
|
+
flushSync() {
|
|
112
|
+
void this.flush();
|
|
113
|
+
}
|
|
114
|
+
prune() {
|
|
115
|
+
if (this.entries.size <= MAX_ENTRIES)
|
|
116
|
+
return;
|
|
117
|
+
const byOldest = [...this.entries.entries()].sort((a, b) => (a[1].lastAttemptAt ?? 0) - (b[1].lastAttemptAt ?? 0));
|
|
118
|
+
for (const [id] of byOldest.slice(0, this.entries.size - MAX_ENTRIES))
|
|
119
|
+
this.entries.delete(id);
|
|
120
|
+
}
|
|
121
|
+
schedulePersist() {
|
|
122
|
+
if (this.degraded || this.writeTimer)
|
|
123
|
+
return;
|
|
124
|
+
this.writeTimer = setTimeout(() => {
|
|
125
|
+
this.writeTimer = undefined;
|
|
126
|
+
void this.persist();
|
|
127
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
128
|
+
this.writeTimer.unref?.(); // 不为统计写hold进程(停机即丢窗口内统计,可接受)
|
|
129
|
+
}
|
|
130
|
+
async persist() {
|
|
131
|
+
if (this.degraded)
|
|
132
|
+
return;
|
|
133
|
+
this.writeChain = this.writeChain.then(async () => {
|
|
134
|
+
try {
|
|
135
|
+
await ensureDir(path.dirname(this.file));
|
|
136
|
+
const data = { version: RECALL_USAGE_FILE_VERSION, entries: Object.fromEntries(this.entries) };
|
|
137
|
+
await atomicWriteText(this.file, JSON.stringify(data));
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
this.degraded = err instanceof Error ? err.message : String(err);
|
|
141
|
+
if (!this.degradedLogged) {
|
|
142
|
+
this.degradedLogged = true;
|
|
143
|
+
this.logger?.warn(`[memory] 召回使用统计写入失败,只读降级: ${this.degraded}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
await this.writeChain;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { MemoryLogger } from '../types.js';
|
|
2
|
+
/** 版本常量(file-versions 同款集中纪律;registry 为独立 sidecar 文件)。 */
|
|
3
|
+
export declare const ROOMS_REGISTRY_FILE_VERSION: 1;
|
|
4
|
+
export interface RoomRegistryEntry {
|
|
5
|
+
/** Room slug(小写字母数字连字符;与自生长 tags 同域,isTag 校验)。 */
|
|
6
|
+
slug: string;
|
|
7
|
+
/** 人类可读名(面板/标注器提示;可中文)。 */
|
|
8
|
+
label?: string;
|
|
9
|
+
/** 归类说明(喂标注器,对 LLM 归类价值最高)。 */
|
|
10
|
+
description?: string;
|
|
11
|
+
/** 来源:pre-registered(预注册)| grown(从自生长 slug 收编)。 */
|
|
12
|
+
source: 'pre-registered' | 'grown';
|
|
13
|
+
/** 合并/改名时的旧 slug(标注器与导出消歧;不参与 list({tag}) 查询)。 */
|
|
14
|
+
aliases?: string[];
|
|
15
|
+
status: 'active' | 'retired';
|
|
16
|
+
createdAt: string;
|
|
17
|
+
updatedAt: string;
|
|
18
|
+
}
|
|
19
|
+
export type RoomRegistryInput = Pick<RoomRegistryEntry, 'slug'> & Partial<Pick<RoomRegistryEntry, 'label' | 'description' | 'source' | 'aliases'>>;
|
|
20
|
+
/**
|
|
21
|
+
* 两级 Room slug(`major` 或 `major/minor`):事实源在 metadata-validators.ts
|
|
22
|
+
* (normTags 写回闸同源放行),此处 re-export 兼容既有导入。
|
|
23
|
+
*/
|
|
24
|
+
export { isRoomSlug } from '../metadata-validators.js';
|
|
25
|
+
/** 大类归属:`major/minor` 取 major;平级 slug 自身即大类。 */
|
|
26
|
+
export declare function majorOf(slug: string): string;
|
|
27
|
+
export declare class RoomRegistryStore {
|
|
28
|
+
private readonly logger?;
|
|
29
|
+
private readonly file;
|
|
30
|
+
private entries;
|
|
31
|
+
private rev;
|
|
32
|
+
private loaded;
|
|
33
|
+
private degraded;
|
|
34
|
+
private degradedLogged;
|
|
35
|
+
private writeChain;
|
|
36
|
+
constructor(dataDir: string, logger?: MemoryLogger | undefined);
|
|
37
|
+
/** 载入(启动时 await 一次)。缺失 = 合法空表;损坏/不可读 = 只读降级(回退自生长目录)。 */
|
|
38
|
+
init(): Promise<void>;
|
|
39
|
+
private ensureLoaded;
|
|
40
|
+
/** 全量条目(含 retired;副本返回,活引用纪律)。 */
|
|
41
|
+
list(): RoomRegistryEntry[];
|
|
42
|
+
/** active 条目(rooms-get 合并与标注器词表来源)。 */
|
|
43
|
+
listActive(): RoomRegistryEntry[];
|
|
44
|
+
/** 精确 slug 查找(含 retired;含别名命中)。 */
|
|
45
|
+
bySlug(slug: string): RoomRegistryEntry | undefined;
|
|
46
|
+
/** 注册(幂等:已存在——含别名命中——时原地补全 label/description,不新建)。上限 200 防刷。 */
|
|
47
|
+
register(input: RoomRegistryInput): Promise<{
|
|
48
|
+
entry: RoomRegistryEntry;
|
|
49
|
+
created: boolean;
|
|
50
|
+
}>;
|
|
51
|
+
/** 编辑已有条目的 label/description(注册后可重编辑;未提供的字段不动;未找到=false)。 */
|
|
52
|
+
update(slug: string, patch: {
|
|
53
|
+
label?: string;
|
|
54
|
+
description?: string;
|
|
55
|
+
}): Promise<boolean>;
|
|
56
|
+
/** 改状态(active↔retired);无变化返回 false。 */
|
|
57
|
+
setStatus(slug: string, status: 'active' | 'retired'): Promise<boolean>;
|
|
58
|
+
/** 改名(merge 1:1 收尾):slug 换名 + 旧 slug 进 aliases。新名与既有条目冲突时走 merge。
|
|
59
|
+
* 目标用两级制词表(isRoomSlug)——把 standalone room 归类到 hall 下(改名 major/minor)
|
|
60
|
+
* 走的就是这条路;isTag 会误拒带 slash 的目标。 */
|
|
61
|
+
renameSlug(oldSlug: string, newSlug: string): Promise<boolean>;
|
|
62
|
+
/** 合并收尾:fromSlug 条目转 retired + toSlug 进 aliases(记录重写由调用方游标执行)。 */
|
|
63
|
+
markMerged(fromSlug: string, toSlug: string): Promise<boolean>;
|
|
64
|
+
/** 写穿持久化(串行链;失败降级内存态并告警一次)。 */
|
|
65
|
+
private persist;
|
|
66
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Room 注册表(预注册词表,分类管理 beta.4)。
|
|
3
|
+
*
|
|
4
|
+
* 动机:Room 此前纯自生长(metadata.tags 涌现),tags 标注器不参考词表——
|
|
5
|
+
* 「81 个 Room 大多 1 条」的碎片化即来源于此。注册表让「策划好的分类」先行:
|
|
6
|
+
* 候选标注器与 tags 标注器优先挂靠注册表 slug;零计数注册条目也进词表。
|
|
7
|
+
*
|
|
8
|
+
* 存储:slots 式 sidecar(`rooms-registry.json`),全量载入内存 + 写穿原子持久化,
|
|
9
|
+
* `rev` 乐观观察计数。**读侧分类**(occupancy 同款):文件缺失 = 合法空表;
|
|
10
|
+
* 损坏/不可读 → 告警 + 只读降级(内存态照常生效,停止回写)——降级即**回退纯
|
|
11
|
+
* 自生长目录**,绝不阻塞 rooms-get / 标注器(R2 红线)。
|
|
12
|
+
*/
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import { isRoomSlug } from '../metadata-validators.js';
|
|
15
|
+
import { readJsonStrict, atomicWriteText, ensureDir } from '../util/io.js';
|
|
16
|
+
/** 版本常量(file-versions 同款集中纪律;registry 为独立 sidecar 文件)。 */
|
|
17
|
+
export const ROOMS_REGISTRY_FILE_VERSION = 1;
|
|
18
|
+
/** 大类(顶层)注册上限(防刷)。小类(`major/minor`)不占大类额度,另有总量保险。 */
|
|
19
|
+
const REGISTRY_MAJOR_CAP = 200;
|
|
20
|
+
/** 全量保险上限(大类+小类总和;防极端滥用)。 */
|
|
21
|
+
const REGISTRY_TOTAL_CAP = 2000;
|
|
22
|
+
/**
|
|
23
|
+
* 两级 Room slug(`major` 或 `major/minor`):事实源在 metadata-validators.ts
|
|
24
|
+
* (normTags 写回闸同源放行),此处 re-export 兼容既有导入。
|
|
25
|
+
*/
|
|
26
|
+
export { isRoomSlug } from '../metadata-validators.js';
|
|
27
|
+
/** 大类归属:`major/minor` 取 major;平级 slug 自身即大类。 */
|
|
28
|
+
export function majorOf(slug) {
|
|
29
|
+
const i = slug.indexOf('/');
|
|
30
|
+
return i === -1 ? slug : slug.slice(0, i);
|
|
31
|
+
}
|
|
32
|
+
/** aliases 上限(合并/改名历史)。 */
|
|
33
|
+
const ALIASES_CAP = 16;
|
|
34
|
+
export class RoomRegistryStore {
|
|
35
|
+
logger;
|
|
36
|
+
file;
|
|
37
|
+
entries = [];
|
|
38
|
+
rev = 0;
|
|
39
|
+
loaded = false;
|
|
40
|
+
degraded;
|
|
41
|
+
degradedLogged = false;
|
|
42
|
+
writeChain = Promise.resolve();
|
|
43
|
+
constructor(dataDir, logger) {
|
|
44
|
+
this.logger = logger;
|
|
45
|
+
this.file = path.join(dataDir, 'rooms-registry.json');
|
|
46
|
+
}
|
|
47
|
+
/** 载入(启动时 await 一次)。缺失 = 合法空表;损坏/不可读 = 只读降级(回退自生长目录)。 */
|
|
48
|
+
async init() {
|
|
49
|
+
await ensureDir(path.dirname(this.file));
|
|
50
|
+
const read = await readJsonStrict(this.file);
|
|
51
|
+
if (!read.ok) {
|
|
52
|
+
if (read.reason !== 'missing') {
|
|
53
|
+
this.degraded = `${read.reason}${'detail' in read && read.detail ? `: ${read.detail}` : ''}`;
|
|
54
|
+
this.logger?.warn(`[memory] Room 注册表只读降级(回退纯自生长目录): ${this.degraded}`);
|
|
55
|
+
}
|
|
56
|
+
this.loaded = true;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (read.value.version !== ROOMS_REGISTRY_FILE_VERSION) {
|
|
60
|
+
this.logger?.warn(`[memory] Room 注册表版本 ${String(read.value.version)} 未知,按当前形状读取`);
|
|
61
|
+
}
|
|
62
|
+
const rooms = Array.isArray(read.value.rooms) ? read.value.rooms : [];
|
|
63
|
+
for (const r of rooms) {
|
|
64
|
+
if (!r || typeof r.slug !== 'string' || !isRoomSlug(r.slug))
|
|
65
|
+
continue; // 非法 slug 丢弃——两级制,isTag 会误丢 hall/minor(beta.17 修)
|
|
66
|
+
this.entries.push({
|
|
67
|
+
slug: r.slug,
|
|
68
|
+
label: typeof r.label === 'string' ? r.label : undefined,
|
|
69
|
+
description: typeof r.description === 'string' ? r.description : undefined,
|
|
70
|
+
source: r.source === 'grown' ? 'grown' : 'pre-registered',
|
|
71
|
+
aliases: Array.isArray(r.aliases) ? r.aliases.filter((a) => typeof a === 'string' && a !== '') : undefined,
|
|
72
|
+
status: r.status === 'retired' ? 'retired' : 'active',
|
|
73
|
+
createdAt: typeof r.createdAt === 'string' ? r.createdAt : '',
|
|
74
|
+
updatedAt: typeof r.updatedAt === 'string' ? r.updatedAt : '',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
this.rev = typeof read.value.rev === 'number' ? read.value.rev : 0;
|
|
78
|
+
this.loaded = true;
|
|
79
|
+
}
|
|
80
|
+
ensureLoaded() {
|
|
81
|
+
if (!this.loaded)
|
|
82
|
+
throw new Error('Room 注册表未初始化(先 await init())');
|
|
83
|
+
}
|
|
84
|
+
/** 全量条目(含 retired;副本返回,活引用纪律)。 */
|
|
85
|
+
list() {
|
|
86
|
+
this.ensureLoaded();
|
|
87
|
+
return this.entries.map((e) => ({ ...e }));
|
|
88
|
+
}
|
|
89
|
+
/** active 条目(rooms-get 合并与标注器词表来源)。 */
|
|
90
|
+
listActive() {
|
|
91
|
+
return this.list().filter((e) => e.status === 'active');
|
|
92
|
+
}
|
|
93
|
+
/** 精确 slug 查找(含 retired;含别名命中)。 */
|
|
94
|
+
bySlug(slug) {
|
|
95
|
+
this.ensureLoaded();
|
|
96
|
+
const found = this.entries.find((e) => e.slug === slug || (e.aliases ?? []).includes(slug));
|
|
97
|
+
return found ? { ...found } : undefined;
|
|
98
|
+
}
|
|
99
|
+
/** 注册(幂等:已存在——含别名命中——时原地补全 label/description,不新建)。上限 200 防刷。 */
|
|
100
|
+
async register(input) {
|
|
101
|
+
this.ensureLoaded();
|
|
102
|
+
const slug = String(input.slug ?? '').trim().toLowerCase();
|
|
103
|
+
if (!slug || !isRoomSlug(slug)) {
|
|
104
|
+
throw new Error(`非法 Room slug: ${String(input.slug)}(两级制:major 或 major/minor,各段小写字母数字连字符)`);
|
|
105
|
+
}
|
|
106
|
+
const found = this.entries.find((e) => e.slug === slug || (e.aliases ?? []).includes(slug));
|
|
107
|
+
if (found) {
|
|
108
|
+
let changed = false;
|
|
109
|
+
if (!found.label && typeof input.label === 'string' && input.label.trim()) {
|
|
110
|
+
found.label = input.label.trim().slice(0, 60);
|
|
111
|
+
changed = true;
|
|
112
|
+
}
|
|
113
|
+
if (!found.description && typeof input.description === 'string' && input.description.trim()) {
|
|
114
|
+
found.description = input.description.trim().slice(0, 300);
|
|
115
|
+
changed = true;
|
|
116
|
+
}
|
|
117
|
+
if (changed) {
|
|
118
|
+
found.updatedAt = new Date().toISOString();
|
|
119
|
+
this.rev++;
|
|
120
|
+
await this.persist();
|
|
121
|
+
}
|
|
122
|
+
return { entry: { ...found }, created: false };
|
|
123
|
+
}
|
|
124
|
+
// 额度两级制(用户裁定:200 上限只管大类;小类细分不占大类额度):
|
|
125
|
+
// 大类 = slug 无 '/' 的条目 ∪ 已用 major 前缀;小类只受总量保险约束。
|
|
126
|
+
const isMinor = slug.includes('/');
|
|
127
|
+
if (isMinor && this.entries.length >= REGISTRY_TOTAL_CAP) {
|
|
128
|
+
throw new Error(`Room 注册表总量已达上限(${REGISTRY_TOTAL_CAP}),请先合并/退役部分条目`);
|
|
129
|
+
}
|
|
130
|
+
if (!isMinor) {
|
|
131
|
+
const majors = new Set(this.entries.map((e) => majorOf(e.slug)));
|
|
132
|
+
if (!majors.has(slug) && majors.size >= REGISTRY_MAJOR_CAP) {
|
|
133
|
+
throw new Error(`大类已达上限(${REGISTRY_MAJOR_CAP}):请用「大类/小类」细分(如 ${slug}/子项),或合并/退役部分大类`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const now = new Date().toISOString();
|
|
137
|
+
const entry = {
|
|
138
|
+
slug,
|
|
139
|
+
label: typeof input.label === 'string' && input.label.trim() ? input.label.trim().slice(0, 60) : undefined,
|
|
140
|
+
description: typeof input.description === 'string' && input.description.trim()
|
|
141
|
+
? input.description.trim().slice(0, 300)
|
|
142
|
+
: undefined,
|
|
143
|
+
source: input.source === 'grown' ? 'grown' : 'pre-registered',
|
|
144
|
+
aliases: Array.isArray(input.aliases) && input.aliases.length > 0 ? input.aliases.slice(0, ALIASES_CAP) : undefined,
|
|
145
|
+
status: 'active',
|
|
146
|
+
createdAt: now,
|
|
147
|
+
updatedAt: now,
|
|
148
|
+
};
|
|
149
|
+
this.entries.push(entry);
|
|
150
|
+
this.rev++;
|
|
151
|
+
await this.persist();
|
|
152
|
+
return { entry: { ...entry }, created: true };
|
|
153
|
+
}
|
|
154
|
+
/** 编辑已有条目的 label/description(注册后可重编辑;未提供的字段不动;未找到=false)。 */
|
|
155
|
+
async update(slug, patch) {
|
|
156
|
+
this.ensureLoaded();
|
|
157
|
+
const found = this.entries.find((e) => e.slug === slug);
|
|
158
|
+
if (!found)
|
|
159
|
+
return false;
|
|
160
|
+
let changed = false;
|
|
161
|
+
if (typeof patch.label === 'string' && patch.label.trim()) {
|
|
162
|
+
found.label = patch.label.trim().slice(0, 60);
|
|
163
|
+
changed = true;
|
|
164
|
+
}
|
|
165
|
+
if (typeof patch.description === 'string' && patch.description.trim()) {
|
|
166
|
+
found.description = patch.description.trim().slice(0, 300);
|
|
167
|
+
changed = true;
|
|
168
|
+
}
|
|
169
|
+
if (!changed)
|
|
170
|
+
return false;
|
|
171
|
+
found.updatedAt = new Date().toISOString();
|
|
172
|
+
this.rev++;
|
|
173
|
+
await this.persist();
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
/** 改状态(active↔retired);无变化返回 false。 */
|
|
177
|
+
async setStatus(slug, status) {
|
|
178
|
+
this.ensureLoaded();
|
|
179
|
+
const found = this.entries.find((e) => e.slug === slug);
|
|
180
|
+
if (!found || found.status === status)
|
|
181
|
+
return false;
|
|
182
|
+
found.status = status;
|
|
183
|
+
found.updatedAt = new Date().toISOString();
|
|
184
|
+
this.rev++;
|
|
185
|
+
await this.persist();
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
/** 改名(merge 1:1 收尾):slug 换名 + 旧 slug 进 aliases。新名与既有条目冲突时走 merge。
|
|
189
|
+
* 目标用两级制词表(isRoomSlug)——把 standalone room 归类到 hall 下(改名 major/minor)
|
|
190
|
+
* 走的就是这条路;isTag 会误拒带 slash 的目标。 */
|
|
191
|
+
async renameSlug(oldSlug, newSlug) {
|
|
192
|
+
this.ensureLoaded();
|
|
193
|
+
if (!isRoomSlug(newSlug))
|
|
194
|
+
throw new Error(`非法 Room slug: ${newSlug}(两级制:major 或 major/minor)`);
|
|
195
|
+
const found = this.entries.find((e) => e.slug === oldSlug);
|
|
196
|
+
if (!found)
|
|
197
|
+
return false;
|
|
198
|
+
if (this.entries.some((e) => e.slug === newSlug)) {
|
|
199
|
+
throw new Error(`目标 slug 已存在: ${newSlug}(应走 merge 而非 rename)`);
|
|
200
|
+
}
|
|
201
|
+
found.slug = newSlug;
|
|
202
|
+
found.aliases = [...new Set([...(found.aliases ?? []), oldSlug])].slice(0, ALIASES_CAP);
|
|
203
|
+
found.updatedAt = new Date().toISOString();
|
|
204
|
+
this.rev++;
|
|
205
|
+
await this.persist();
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
/** 合并收尾:fromSlug 条目转 retired + toSlug 进 aliases(记录重写由调用方游标执行)。 */
|
|
209
|
+
async markMerged(fromSlug, toSlug) {
|
|
210
|
+
this.ensureLoaded();
|
|
211
|
+
const found = this.entries.find((e) => e.slug === fromSlug);
|
|
212
|
+
if (!found)
|
|
213
|
+
return false;
|
|
214
|
+
found.status = 'retired';
|
|
215
|
+
const target = this.entries.find((e) => e.slug === toSlug);
|
|
216
|
+
if (target) {
|
|
217
|
+
target.aliases = [...new Set([...(target.aliases ?? []), fromSlug])].slice(0, ALIASES_CAP);
|
|
218
|
+
target.updatedAt = new Date().toISOString();
|
|
219
|
+
}
|
|
220
|
+
found.updatedAt = new Date().toISOString();
|
|
221
|
+
this.rev++;
|
|
222
|
+
await this.persist();
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
/** 写穿持久化(串行链;失败降级内存态并告警一次)。 */
|
|
226
|
+
async persist() {
|
|
227
|
+
if (this.degraded)
|
|
228
|
+
return;
|
|
229
|
+
this.writeChain = this.writeChain.then(async () => {
|
|
230
|
+
const data = {
|
|
231
|
+
version: ROOMS_REGISTRY_FILE_VERSION,
|
|
232
|
+
rev: this.rev + 1,
|
|
233
|
+
rooms: this.entries,
|
|
234
|
+
};
|
|
235
|
+
try {
|
|
236
|
+
await ensureDir(path.dirname(this.file));
|
|
237
|
+
await atomicWriteText(this.file, JSON.stringify(data, null, 2));
|
|
238
|
+
this.rev = data.rev;
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
this.degraded = err instanceof Error ? err.message : String(err);
|
|
242
|
+
if (!this.degradedLogged) {
|
|
243
|
+
this.degradedLogged = true;
|
|
244
|
+
this.logger?.warn(`[memory] Room 注册表写入失败,只读降级: ${this.degraded}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
await this.writeChain;
|
|
249
|
+
}
|
|
250
|
+
}
|