dsh-prime-memory 0.16.2 → 0.16.4
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 +44 -0
- package/cordis.patch.yml +6 -0
- package/dist/client.js +1333 -168
- package/dist/config.d.ts +37 -0
- package/dist/config.js +7 -0
- package/dist/contract.d.ts +155 -1
- package/dist/hooks/capture-recovery.d.ts +64 -0
- package/dist/hooks/capture-recovery.js +105 -0
- package/dist/hooks/capture.js +123 -1
- package/dist/hooks/recall-ack.d.ts +48 -0
- package/dist/hooks/recall-ack.js +79 -0
- package/dist/hooks/recall.d.ts +23 -1
- package/dist/hooks/recall.js +121 -6
- package/dist/index.d.ts +22 -0
- package/dist/index.js +18 -4
- package/dist/metadata-validators.d.ts +5 -0
- package/dist/metadata-validators.js +11 -1
- package/dist/pipeline/l1.d.ts +4 -0
- package/dist/pipeline/l1.js +9 -2
- package/dist/pipeline/reconcile.js +4 -0
- package/dist/pipeline/ruminate.d.ts +11 -1
- package/dist/pipeline/ruminate.js +18 -1
- package/dist/pipeline/runner.js +62 -0
- package/dist/prompts/boundary.d.ts +17 -0
- package/dist/prompts/boundary.js +20 -0
- package/dist/prompts/graph-projection.js +4 -1
- package/dist/prompts/l1-dedup.d.ts +3 -3
- package/dist/prompts/l1-dedup.js +9 -1
- package/dist/prompts/l1-extraction.d.ts +3 -3
- package/dist/prompts/l1-extraction.js +12 -0
- package/dist/prompts/persona.js +6 -0
- package/dist/prompts/scene.js +7 -1
- package/dist/room-review.d.ts +196 -0
- package/dist/room-review.js +281 -0
- package/dist/stats.d.ts +26 -2
- package/dist/stats.js +270 -13
- package/dist/store/l0.d.ts +4 -0
- package/dist/store/l0.js +8 -0
- 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/sqlite.d.ts +16 -0
- package/dist/store/sqlite.js +57 -0
- package/dist/store/trace.d.ts +39 -0
- package/dist/store/trace.js +149 -0
- package/dist/tools/index.d.ts +4 -1
- package/dist/tools/index.js +249 -2
- package/dist/types.d.ts +4 -0
- package/dist/util/redact.d.ts +27 -0
- package/dist/util/redact.js +228 -0
- package/dsh.plugin.json +1 -1
- package/package.json +3 -3
- package/dist/hall-backfill.d.ts +0 -33
- package/dist/hall-backfill.js +0 -95
- package/dist/hall-migrate.d.ts +0 -32
- package/dist/hall-migrate.js +0 -88
|
@@ -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
|
+
}
|
package/dist/store/sqlite.d.ts
CHANGED
|
@@ -384,6 +384,8 @@ export declare class MemoryDb {
|
|
|
384
384
|
halls?: readonly string[];
|
|
385
385
|
/** Room 过滤(metadata.tags 含该 slug)。 */
|
|
386
386
|
tag?: string;
|
|
387
|
+
/** 孤儿记忆筛选(true = 只列没有 Room 的记录;与 tag 互斥,tag 优先)。 */
|
|
388
|
+
untagged?: boolean;
|
|
387
389
|
workspaceId?: string;
|
|
388
390
|
/**
|
|
389
391
|
* 退场(软删)筛查:三态。
|
|
@@ -429,6 +431,8 @@ export declare class MemoryDb {
|
|
|
429
431
|
room: string;
|
|
430
432
|
count: number;
|
|
431
433
|
}>;
|
|
434
|
+
/** 孤儿记忆计数:没有任何 Room(tags 为空/缺失)的记录条数(含已退场,与 l1RoomCounts 同口径)。 */
|
|
435
|
+
untaggedL1Count(): number;
|
|
432
436
|
/** Hall 域计数(八边形角数据源):按 metadata.hall 分组计数 + 未打标行数。失败返回空。 */
|
|
433
437
|
wingL1Counts(): {
|
|
434
438
|
counts: Record<string, number>;
|
|
@@ -460,6 +464,18 @@ export declare class MemoryDb {
|
|
|
460
464
|
countL0Since(iso: string): number;
|
|
461
465
|
/** 统计某会话已捕获消息数(session-stats 数据源;idx_l0_session_id 索引点查)。 */
|
|
462
466
|
countL0BySession(sessionId: string): number;
|
|
467
|
+
/**
|
|
468
|
+
* §A 崩溃恢复水位线(memorax-absorb):该会话 L0 已落盘的最大 turn。
|
|
469
|
+
* L0 为追加式事实源、从不裁剪 → 水位线崩溃安全;turn 空洞是常态
|
|
470
|
+
* (off 档/无消息轮),MAX 语义不受影响。空会话返回 undefined。
|
|
471
|
+
*/
|
|
472
|
+
maxCapturedTurn(sessionId: string): number | undefined;
|
|
473
|
+
/**
|
|
474
|
+
* §A 幂等检查:该 (session, turn) 是否已有任意 L0 行。
|
|
475
|
+
* 水位线 MAX(turn) 只判"整轮无"、判不了"部分有"——恢复窗口内逐 turn
|
|
476
|
+
* 做存在性检查,命中即整轮跳过(重放 record id 非确定,upsert 不能去重)。
|
|
477
|
+
*/
|
|
478
|
+
hasAnyL0Message(sessionId: string, turn: number): boolean;
|
|
463
479
|
/** 按会话取最近消息(时间升序返回;走 idx_l0_session_id 索引)。
|
|
464
480
|
* 蒸馏背景参考专用——按会话现查替代全局内存数组(ADR-0003)。 */
|
|
465
481
|
recentL0BySession(sessionId: string, limit: number): L0MessageRecord[];
|
package/dist/store/sqlite.js
CHANGED
|
@@ -1486,6 +1486,11 @@ export class MemoryDb {
|
|
|
1486
1486
|
where.push(`EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid(metadata_json) THEN metadata_json ELSE '{}' END, '$.tags') j WHERE j.value = ?)`);
|
|
1487
1487
|
params.push(opts.tag);
|
|
1488
1488
|
}
|
|
1489
|
+
else if (opts.untagged) {
|
|
1490
|
+
// 孤儿记忆:没有任何 Room(tags 数组缺失/为空/字段非法)。与 room 计数同一
|
|
1491
|
+
// json_valid 守卫口径;与 l1RoomCounts() 计数互补(rooms 计数 + 孤儿数 = 全量)。
|
|
1492
|
+
where.push(`NOT EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid(metadata_json) THEN metadata_json ELSE '{}' END, '$.tags'))`);
|
|
1493
|
+
}
|
|
1489
1494
|
// 退场筛查(三态,见 opts.retired 注释):与 listRetiredL1 同一判据,保证
|
|
1490
1495
|
// 「仅退场」视图与「已退场」区看到同一批行。
|
|
1491
1496
|
if (opts.retired === false) {
|
|
@@ -1601,6 +1606,21 @@ export class MemoryDb {
|
|
|
1601
1606
|
return [];
|
|
1602
1607
|
}
|
|
1603
1608
|
}
|
|
1609
|
+
/** 孤儿记忆计数:没有任何 Room(tags 为空/缺失)的记录条数(含已退场,与 l1RoomCounts 同口径)。 */
|
|
1610
|
+
untaggedL1Count() {
|
|
1611
|
+
if (this.degraded)
|
|
1612
|
+
return 0;
|
|
1613
|
+
try {
|
|
1614
|
+
const row = this.db
|
|
1615
|
+
.prepare(`SELECT COUNT(*) AS n FROM l1_records r
|
|
1616
|
+
WHERE NOT EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid(r.metadata_json) THEN r.metadata_json ELSE '{}' END, '$.tags'))`)
|
|
1617
|
+
.get();
|
|
1618
|
+
return Number(row?.n ?? 0);
|
|
1619
|
+
}
|
|
1620
|
+
catch {
|
|
1621
|
+
return 0;
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1604
1624
|
/** Hall 域计数(八边形角数据源):按 metadata.hall 分组计数 + 未打标行数。失败返回空。 */
|
|
1605
1625
|
wingL1Counts() {
|
|
1606
1626
|
if (this.degraded)
|
|
@@ -1797,6 +1817,43 @@ export class MemoryDb {
|
|
|
1797
1817
|
return 0;
|
|
1798
1818
|
}
|
|
1799
1819
|
}
|
|
1820
|
+
/**
|
|
1821
|
+
* §A 崩溃恢复水位线(memorax-absorb):该会话 L0 已落盘的最大 turn。
|
|
1822
|
+
* L0 为追加式事实源、从不裁剪 → 水位线崩溃安全;turn 空洞是常态
|
|
1823
|
+
* (off 档/无消息轮),MAX 语义不受影响。空会话返回 undefined。
|
|
1824
|
+
*/
|
|
1825
|
+
maxCapturedTurn(sessionId) {
|
|
1826
|
+
if (this.degraded)
|
|
1827
|
+
return undefined;
|
|
1828
|
+
try {
|
|
1829
|
+
const row = this.db
|
|
1830
|
+
.prepare('SELECT MAX(turn) AS max_turn FROM l0_conversations WHERE session_id = ?')
|
|
1831
|
+
.get(sessionId);
|
|
1832
|
+
return typeof row?.max_turn === 'number' ? row.max_turn : undefined;
|
|
1833
|
+
}
|
|
1834
|
+
catch (err) {
|
|
1835
|
+
this.logger?.warn(`[memory] L0 水位线查询失败(按空处理): ${err instanceof Error ? err.message : String(err)}`);
|
|
1836
|
+
return undefined;
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
/**
|
|
1840
|
+
* §A 幂等检查:该 (session, turn) 是否已有任意 L0 行。
|
|
1841
|
+
* 水位线 MAX(turn) 只判"整轮无"、判不了"部分有"——恢复窗口内逐 turn
|
|
1842
|
+
* 做存在性检查,命中即整轮跳过(重放 record id 非确定,upsert 不能去重)。
|
|
1843
|
+
*/
|
|
1844
|
+
hasAnyL0Message(sessionId, turn) {
|
|
1845
|
+
if (this.degraded || !Number.isFinite(turn))
|
|
1846
|
+
return false;
|
|
1847
|
+
try {
|
|
1848
|
+
const row = this.db
|
|
1849
|
+
.prepare('SELECT 1 FROM l0_conversations WHERE session_id = ? AND turn = ? LIMIT 1')
|
|
1850
|
+
.get(sessionId, turn);
|
|
1851
|
+
return row !== undefined;
|
|
1852
|
+
}
|
|
1853
|
+
catch {
|
|
1854
|
+
return false;
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1800
1857
|
/** 按会话取最近消息(时间升序返回;走 idx_l0_session_id 索引)。
|
|
1801
1858
|
* 蒸馏背景参考专用——按会话现查替代全局内存数组(ADR-0003)。 */
|
|
1802
1859
|
recentL0BySession(sessionId, limit) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { TraceEvent } from '../contract.js';
|
|
2
|
+
export interface TraceStoreOptions {
|
|
3
|
+
/** 保留天数;0 = 永久保留。 */
|
|
4
|
+
retentionDays: number;
|
|
5
|
+
/** 单文件字节上限(超过停写 + marker)。 */
|
|
6
|
+
maxFileBytes?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare class TraceStore {
|
|
9
|
+
private readonly dataDir;
|
|
10
|
+
private readonly opts;
|
|
11
|
+
/** 已停写的日期(5MB 上限触发;当日内后续事件丢弃,次日自动恢复)。 */
|
|
12
|
+
private readonly truncatedDays;
|
|
13
|
+
private lastPruneDay;
|
|
14
|
+
private chain;
|
|
15
|
+
constructor(dataDir: string, opts: TraceStoreOptions);
|
|
16
|
+
/** 追加事件(异步串行链,失败静默)。 */
|
|
17
|
+
append(event: TraceEvent): void;
|
|
18
|
+
/** 等待挂起的写入排空(dispose 序用)。 */
|
|
19
|
+
flush(): Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* 尾部读取:从最近的日期文件倒序收集,按 kind 过滤,返回至多 `lines` 条
|
|
22
|
+
* (按写入顺序 = 时间升序)。坏行跳过(readJsonl 同款)。
|
|
23
|
+
*/
|
|
24
|
+
tail(lines: number, kind?: TraceEvent['kind']): Promise<TraceEvent[]>;
|
|
25
|
+
private dir;
|
|
26
|
+
private fileFor;
|
|
27
|
+
private recentFiles;
|
|
28
|
+
private writeOne;
|
|
29
|
+
/** 删除超过保留期的旧日文件(retentionDays 0 = 永久)。 */
|
|
30
|
+
private pruneOldDays;
|
|
31
|
+
}
|
|
32
|
+
/** 插件启动时注入(index.ts 调用);enabled=false 或 0 天保留不影响初始化本身。 */
|
|
33
|
+
export declare function initTraceStore(dataDir: string, opts: TraceStoreOptions): void;
|
|
34
|
+
/** 测试/卸载:清空单例。 */
|
|
35
|
+
export declare function resetTraceStore(): void;
|
|
36
|
+
/** 埋点入口:未初始化 = no-op。fire-and-forget,永不抛。 */
|
|
37
|
+
export declare function trace(event: TraceEvent): void;
|
|
38
|
+
/** RPC tail 数据源;未初始化返回 undefined(端点回空集)。 */
|
|
39
|
+
export declare function getTraceStore(): TraceStore | null;
|