dsh-prime-memory 0.18.5 → 0.18.7

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.
@@ -1,250 +0,0 @@
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
- }