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/index.js
CHANGED
|
@@ -36,9 +36,8 @@ import { SlotStore } from './store/slots.js';
|
|
|
36
36
|
import { registerMemoryTools } from './tools/index.js';
|
|
37
37
|
import { registerSlotTools } from './tools/slots.js';
|
|
38
38
|
import { registerSlotRecall } from './hooks/slot-recall.js';
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import { TodoRefStore } from './store/todo-ref.js';
|
|
39
|
+
import { RoomRegistryStore } from './store/rooms-registry.js';
|
|
40
|
+
import { RecallUsageStore } from './store/recall-usage.js';
|
|
42
41
|
import { registerSlotsProjection } from './projection/slots.js';
|
|
43
42
|
import { errDetail, withFileLog } from './util/filelog.js';
|
|
44
43
|
import { buildRouteChain, resolveModelRoute, invalidateEffortCache } from './llm.js';
|
|
@@ -161,6 +160,7 @@ export async function apply(ctx, config) {
|
|
|
161
160
|
}
|
|
162
161
|
}
|
|
163
162
|
const stores = {
|
|
163
|
+
usage: undefined, // 装配于下方(依赖 dataDir 就绪)
|
|
164
164
|
l0: new L0Store(dataDir, db, embed, logger),
|
|
165
165
|
l1: new L1Store(dataDir, db, embed, config.recall.strategy, logger, config.recall.decayHalfLifeDays,
|
|
166
166
|
// §D 第 3 路:图谱回链。图谱不可用时 searchNodes 自带 no-op,不影响双路。
|
|
@@ -353,30 +353,23 @@ export async function apply(ctx, config) {
|
|
|
353
353
|
backend = await createMemoryBackend({ dbPath: path.join(dataDir, 'memory.db'), dimensions: initial.dims, logger }, backend);
|
|
354
354
|
}
|
|
355
355
|
stores.backend = backend;
|
|
356
|
+
// Room 注册表(分类管理 beta.4;损坏降级为纯自生长目录)
|
|
357
|
+
const roomRegistry = new RoomRegistryStore(dataDir, logger);
|
|
358
|
+
await roomRegistry.init();
|
|
356
359
|
const ruminate = storageOk && !db.isDegraded() && ruminateFile
|
|
357
|
-
? new RuminateController(ctx, config, runner, stores, logger, live, ruminateFile)
|
|
360
|
+
? new RuminateController(ctx, config, runner, stores, logger, live, ruminateFile, roomRegistry)
|
|
358
361
|
: undefined;
|
|
359
362
|
let flushL0;
|
|
360
363
|
if (storageOk) {
|
|
361
364
|
flushL0 = registerCapture(ctx, config, runner, stores.l0, logger, live, modes);
|
|
362
365
|
}
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
registerLongTaskMonitor(ctx, {
|
|
369
|
-
cfg: config,
|
|
370
|
-
modes,
|
|
371
|
-
slots: stores.slots,
|
|
372
|
-
contextUsage,
|
|
373
|
-
todoRef,
|
|
374
|
-
logger,
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
const recall = registerRecall(ctx, config, stores, logger, live, modes, dataDir);
|
|
366
|
+
// 召回使用统计(进 topN 才算 used;热路径只碰内存,去抖落盘)
|
|
367
|
+
const recallUsage = new RecallUsageStore(dataDir, logger);
|
|
368
|
+
await recallUsage.init();
|
|
369
|
+
stores.usage = recallUsage;
|
|
370
|
+
const recall = registerRecall(ctx, config, stores, logger, live, modes, dataDir, recallUsage);
|
|
378
371
|
runner.setAfterRun(recall.invalidateProfile);
|
|
379
|
-
registerMemoryTools(ctx, config, stores, logger, modes, live, ruminate);
|
|
372
|
+
registerMemoryTools(ctx, config, stores, logger, modes, live, ruminate, storageOk ? roomRegistry : undefined);
|
|
380
373
|
// 激活槽位(active slot):工具面 + 常驻注入 + 服务端投影(均走 ctx.effect,可撤销)
|
|
381
374
|
registerSlotTools(ctx, config, stores.slots, logger, modes, live, stores.l1);
|
|
382
375
|
const slotRecall = registerSlotRecall(ctx, config, stores.slots, logger, live);
|
|
@@ -395,7 +388,7 @@ export async function apply(ctx, config) {
|
|
|
395
388
|
runnerView: (sid, mode) => runner.sessionView(sid, mode),
|
|
396
389
|
l0Count: (sid) => stores.l0.countBySession(sid),
|
|
397
390
|
capabilities: () => db.getCapabilities(),
|
|
398
|
-
}, ruminate, storageOk ?
|
|
391
|
+
}, ruminate, storageOk ? roomRegistry : undefined);
|
|
399
392
|
// bench 控制服务(config.benchControl 门控,默认关):仅基准/调试部署注册,
|
|
400
393
|
// 供同进程的 bench-runner lifecycle 赛道触发 rebuild / 设置会话档位
|
|
401
394
|
// (宿主侧 RPC 无 call(),见 bench-control.ts)
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
* 已是 string 的变量收窄成 `never`(该分支内无法再 `.slice`,已在 wing-backfill 踩过)。
|
|
11
11
|
*/
|
|
12
12
|
export declare function isWingId(v: unknown): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* 两级 Room slug(`major` 或 `major/minor`,各段满足 isTag;至多一个 slash)。
|
|
15
|
+
* Room 注册表/归类写回的 tags 域;与 rooms-registry.ts 同一事实源(就近定义避免依赖环)。
|
|
16
|
+
*/
|
|
17
|
+
export declare function isRoomSlug(v: unknown): boolean;
|
|
13
18
|
/** 同为布尔谓词(理由见 isWingId 注释)。 */
|
|
14
19
|
export declare function isTag(v: unknown): boolean;
|
|
15
20
|
/**
|
|
@@ -31,6 +31,16 @@ export function isWingId(v) {
|
|
|
31
31
|
}
|
|
32
32
|
/** 标签(Room 的构成单元)校验:小写字母数字连字符,1-32 字符。 */
|
|
33
33
|
const TAG_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
34
|
+
/**
|
|
35
|
+
* 两级 Room slug(`major` 或 `major/minor`,各段满足 isTag;至多一个 slash)。
|
|
36
|
+
* Room 注册表/归类写回的 tags 域;与 rooms-registry.ts 同一事实源(就近定义避免依赖环)。
|
|
37
|
+
*/
|
|
38
|
+
export function isRoomSlug(v) {
|
|
39
|
+
if (typeof v !== 'string')
|
|
40
|
+
return false;
|
|
41
|
+
const parts = v.split('/');
|
|
42
|
+
return parts.length <= 2 && parts.every((p) => TAG_RE.test(p));
|
|
43
|
+
}
|
|
34
44
|
/** 同为布尔谓词(理由见 isWingId 注释)。 */
|
|
35
45
|
export function isTag(v) {
|
|
36
46
|
return typeof v === 'string' && TAG_RE.test(v);
|
|
@@ -47,7 +57,7 @@ export function normTags(v, max = 3) {
|
|
|
47
57
|
if (typeof raw !== 'string')
|
|
48
58
|
continue;
|
|
49
59
|
const t = raw.trim().toLowerCase();
|
|
50
|
-
if (!isTag(t) || out.includes(t))
|
|
60
|
+
if (!(isTag(t) || isRoomSlug(t)) || out.includes(t))
|
|
51
61
|
continue;
|
|
52
62
|
out.push(t);
|
|
53
63
|
if (out.length >= max)
|
|
@@ -15,7 +15,7 @@ export async function processSceneReclusterJobs(source, scenes, logger, maxJobs
|
|
|
15
15
|
const family = job.family === 'work' ? 'work' : 'chat';
|
|
16
16
|
const store = scenes[family];
|
|
17
17
|
// 快照在 try 外声明:catch 的回滚要用它
|
|
18
|
-
|
|
18
|
+
const snapshots = [];
|
|
19
19
|
try {
|
|
20
20
|
// ① 内存快照:按场景名(文件 basename)匹配当前存在的摘要文件
|
|
21
21
|
const all = await store.list();
|
|
@@ -66,6 +66,8 @@ export declare class RuminateController {
|
|
|
66
66
|
private sessions;
|
|
67
67
|
private totalL1;
|
|
68
68
|
private pendingFile;
|
|
69
|
+
/** Room 注册表(可选;孤儿候选预标记的合并词表来源)。 */
|
|
70
|
+
private readonly roomRegistry;
|
|
69
71
|
/** 记忆后端:未注入时包 l1(进程内,行为与改造前等价)。 */
|
|
70
72
|
private backend;
|
|
71
73
|
constructor(ctx: Context, cfg: MemoryConfig, runner: Pick<MemoryRunner, 'enqueue' | 'states'>, stores: {
|
|
@@ -75,7 +77,15 @@ export declare class RuminateController {
|
|
|
75
77
|
scenes: Record<MemoryFamily, SceneStore>;
|
|
76
78
|
persona: Record<MemoryFamily, PersonaStore>;
|
|
77
79
|
state: StateStore;
|
|
78
|
-
}, logger: MemoryLogger, live: LiveSettingsHandle, pendingFile: string
|
|
80
|
+
}, logger: MemoryLogger, live: LiveSettingsHandle, pendingFile: string,
|
|
81
|
+
/** Room 注册表(可选;孤儿候选预标记的合并词表来源)。 */
|
|
82
|
+
roomRegistry?: {
|
|
83
|
+
listActive(): Array<{
|
|
84
|
+
slug: string;
|
|
85
|
+
label?: string;
|
|
86
|
+
description?: string;
|
|
87
|
+
}>;
|
|
88
|
+
});
|
|
79
89
|
/** 状态快照 */
|
|
80
90
|
getStatus(): RuminateStatus;
|
|
81
91
|
/**
|
|
@@ -4,6 +4,7 @@ import { errDetail } from '../util/filelog.js';
|
|
|
4
4
|
import { runSceneConsolidation } from './l2.js';
|
|
5
5
|
import { relabelPass } from './relabel.js';
|
|
6
6
|
import { processSceneReclusterJobs } from './recluster.js';
|
|
7
|
+
import { annotateOrphanCandidates } from '../room-review.js';
|
|
7
8
|
import { runPersona } from './l3.js';
|
|
8
9
|
const IDLE_STATUS = {
|
|
9
10
|
running: false,
|
|
@@ -45,11 +46,15 @@ export class RuminateController {
|
|
|
45
46
|
sessions = [];
|
|
46
47
|
totalL1 = 0;
|
|
47
48
|
pendingFile;
|
|
49
|
+
/** Room 注册表(可选;孤儿候选预标记的合并词表来源)。 */
|
|
50
|
+
roomRegistry;
|
|
48
51
|
/** 记忆后端:未注入时包 l1(进程内,行为与改造前等价)。 */
|
|
49
52
|
backend() {
|
|
50
53
|
return this.stores.backend ?? new InProcMemoryBackend(this.stores.l1);
|
|
51
54
|
}
|
|
52
|
-
constructor(ctx, cfg, runner, stores, logger, live, pendingFile
|
|
55
|
+
constructor(ctx, cfg, runner, stores, logger, live, pendingFile,
|
|
56
|
+
/** Room 注册表(可选;孤儿候选预标记的合并词表来源)。 */
|
|
57
|
+
roomRegistry) {
|
|
53
58
|
this.ctx = ctx;
|
|
54
59
|
this.cfg = cfg;
|
|
55
60
|
this.runner = runner;
|
|
@@ -57,6 +62,7 @@ export class RuminateController {
|
|
|
57
62
|
this.logger = logger;
|
|
58
63
|
this.live = live;
|
|
59
64
|
this.pendingFile = pendingFile;
|
|
65
|
+
this.roomRegistry = roomRegistry;
|
|
60
66
|
}
|
|
61
67
|
/** 状态快照 */
|
|
62
68
|
getStatus() {
|
|
@@ -233,6 +239,17 @@ export class RuminateController {
|
|
|
233
239
|
catch (err) {
|
|
234
240
|
this.logger.warn(`[memory] 场景重聚类调度失败(忽略): ${errDetail(err)}`);
|
|
235
241
|
}
|
|
242
|
+
// 孤儿候选预标记(长尾治理):无 Room 的活跃孤儿对照现有 Room 词表产出
|
|
243
|
+
// 候选(roomReview='pending'),由 agent 用 memory_room_review 逐个复查;
|
|
244
|
+
// 每轮至多 40 条,吞错不拖垮反刍。
|
|
245
|
+
try {
|
|
246
|
+
const r = await annotateOrphanCandidates(this.ctx, this.cfg, { l1: this.stores.l1, logger: this.logger, registry: this.roomRegistry }, 40);
|
|
247
|
+
if (r.written > 0)
|
|
248
|
+
this.status.detail = `孤儿候选预标记 ${r.written} 条(待 agent 复查,工具 memory_room_review)`;
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
this.logger.warn(`[memory] 孤儿候选预标记失败(忽略): ${errDetail(err)}`);
|
|
252
|
+
}
|
|
236
253
|
this.status.phase = 'done';
|
|
237
254
|
this.finish(null);
|
|
238
255
|
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 孤儿记忆的 Room 候选预标记 + agent 逐个复查(长尾治理)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:Room 由 metadata.tags 自生长,但 tags 只在反刍 relabel 段随 wing 补打
|
|
5
|
+
* 顺带产出(每轮 ≤40 条、且只覆盖本轮补 wing 的记录)——「有 wing 无 tags」与
|
|
6
|
+
* 「预算用尽被 deferred」的记录成为无 Room 绑定的孤儿,且标注器不参考现有
|
|
7
|
+
* Room 词表,产出碎片化新 slug(81 个 Room 大多 1 条的成因)。
|
|
8
|
+
*
|
|
9
|
+
* 两段式:
|
|
10
|
+
* 1. **预标记(annotateOrphanCandidates)**:抽取活跃孤儿,LLM 对照**现有 Room
|
|
11
|
+
* 词表**产出候选(优先挂靠现有 Room,≤2 个新 slug 兜底),写
|
|
12
|
+
* `metadata.roomCandidates` + `roomReview:'pending'`——不动 tags、不进
|
|
13
|
+
* Room、不进召回,只是"待确认的建议"。
|
|
14
|
+
* 2. **逐个复查(memory_room_review 工具)**:agent 取下一条待复查记录
|
|
15
|
+
* (带候选 + 现有 Room 词表),确认后 confirm 写入 tags(进 Room,高权限
|
|
16
|
+
* 门控),或 skip 跳过。确认/跳过都是显式动作,预标记本身零行为面。
|
|
17
|
+
*
|
|
18
|
+
* 存储即 metadata 保留键(与 relabel 写 tags 同款 patchMetadata 合并纪律,
|
|
19
|
+
* 写前重读):`roomCandidates` / `roomReview:'pending'|'confirmed'|'skipped'`。
|
|
20
|
+
* 两键不在 rooms 聚合(rooms 只读 $.tags)、不在快照哈希投影关注的治理列——
|
|
21
|
+
* 与既有 metadata 键同层,无 schema 变更。
|
|
22
|
+
*/
|
|
23
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
24
|
+
import type { MemoryConfig } from './config.js';
|
|
25
|
+
import type { MemoryLogger, MemoryRecord } from './types.js';
|
|
26
|
+
export declare const ROOM_CANDIDATES_KEY = "roomCandidates";
|
|
27
|
+
export declare const ROOM_REVIEW_KEY = "roomReview";
|
|
28
|
+
export type RoomReviewState = 'pending' | 'confirmed' | 'skipped';
|
|
29
|
+
/** 复查与候选操作对 L1 的最小依赖面(便于测试注入)。 */
|
|
30
|
+
export interface RoomReviewStore {
|
|
31
|
+
list(opts: {
|
|
32
|
+
untagged?: boolean;
|
|
33
|
+
retired?: boolean;
|
|
34
|
+
limit: number;
|
|
35
|
+
offset: number;
|
|
36
|
+
}): {
|
|
37
|
+
items: MemoryRecord[];
|
|
38
|
+
total: number;
|
|
39
|
+
};
|
|
40
|
+
getByIds(ids: readonly string[]): MemoryRecord[];
|
|
41
|
+
patchMetadata(id: string, metadata: Record<string, unknown>): boolean;
|
|
42
|
+
listRooms(): Array<{
|
|
43
|
+
room: string;
|
|
44
|
+
count: number;
|
|
45
|
+
}>;
|
|
46
|
+
/** 确认写 tags 后失效 Room 计数缓存(30s TTL)。 */
|
|
47
|
+
invalidateRooms(): void;
|
|
48
|
+
}
|
|
49
|
+
/** merge/rename 的 store 面(RoomReviewStore + 游标重写与备份;L1Store 已实现)。 */
|
|
50
|
+
export interface RoomMergeStore extends RoomReviewStore {
|
|
51
|
+
listByTagAll(tag: string, cap?: number): MemoryRecord[];
|
|
52
|
+
backupTagRecords(tag: string): {
|
|
53
|
+
file: string;
|
|
54
|
+
count: number;
|
|
55
|
+
};
|
|
56
|
+
rewriteTag(from: string, to: string, cap?: number): {
|
|
57
|
+
rewritten: number;
|
|
58
|
+
scanned: number;
|
|
59
|
+
families: string[];
|
|
60
|
+
scenes: string[];
|
|
61
|
+
hasMore: boolean;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** Room 注册表最小面(merge 收尾;RoomRegistryStore 已满足)。 */
|
|
65
|
+
export interface RoomRegistryMerge {
|
|
66
|
+
markMerged(fromSlug: string, toSlug: string): Promise<boolean>;
|
|
67
|
+
renameSlug(oldSlug: string, newSlug: string): Promise<boolean>;
|
|
68
|
+
/** rename 收尾恢复先前状态用(active 条目改名后不掉出标注词表)。 */
|
|
69
|
+
bySlug?(slug: string): {
|
|
70
|
+
status: 'active' | 'retired';
|
|
71
|
+
} | undefined;
|
|
72
|
+
setStatus?(slug: string, status: 'active' | 'retired'): Promise<boolean>;
|
|
73
|
+
}
|
|
74
|
+
export interface RoomCandidate {
|
|
75
|
+
id: string;
|
|
76
|
+
rooms: string[];
|
|
77
|
+
}
|
|
78
|
+
/** 解析标注器输出的逐条候选(容错:id 配对失败丢弃;isTag 校验;目录内优先,≤3)。 */
|
|
79
|
+
export declare function parseRoomCandidateItems(parsed: ReadonlyArray<{
|
|
80
|
+
id: string;
|
|
81
|
+
rooms: readonly string[];
|
|
82
|
+
}>, ids: ReadonlyArray<string>, roomCatalog: ReadonlyArray<{
|
|
83
|
+
room: string;
|
|
84
|
+
count: number;
|
|
85
|
+
}>): RoomCandidate[];
|
|
86
|
+
/** 候选标注系统 prompt:喂现有 Room 词表(带计数),优先挂靠、少量新建兜底。 */
|
|
87
|
+
export declare function roomCandidateSystemPrompt(roomCatalog: ReadonlyArray<{
|
|
88
|
+
room: string;
|
|
89
|
+
count: number;
|
|
90
|
+
}>): string;
|
|
91
|
+
/** 候选标注器:一批孤儿记录 → 候选 Room(优先现有词表;失败返回空,调用方零改动)。 */
|
|
92
|
+
export declare function roomCandidateChunk(ctx: Context, cfg: MemoryConfig, logger: MemoryLogger, chunk: MemoryRecord[], roomCatalog: ReadonlyArray<{
|
|
93
|
+
room: string;
|
|
94
|
+
count: number;
|
|
95
|
+
}>): Promise<Array<{
|
|
96
|
+
id: string;
|
|
97
|
+
rooms: string[];
|
|
98
|
+
}>>;
|
|
99
|
+
export interface OrphanAnnotateIO {
|
|
100
|
+
l1: RoomReviewStore;
|
|
101
|
+
logger: MemoryLogger;
|
|
102
|
+
/** Room 注册表(可选:未装配 = 词表只用自生长聚合,无注册条目)。 */
|
|
103
|
+
registry?: {
|
|
104
|
+
listActive(): Array<{
|
|
105
|
+
slug: string;
|
|
106
|
+
label?: string;
|
|
107
|
+
description?: string;
|
|
108
|
+
}>;
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 预标记消费器:活跃孤儿(≤limit 条)批量产候选并写 pending。
|
|
113
|
+
* 写前重读合并(与 relabel 同纪律);失败单条跳过、原记录零改动。
|
|
114
|
+
*/
|
|
115
|
+
export interface OrphanAnnotateOverrides {
|
|
116
|
+
/** 测试注入口:替换 LLM 候选标注器(默认走真实 callLLM 路径);第二参为合并词表。 */
|
|
117
|
+
chunkAnnotator?: (chunk: MemoryRecord[], catalog: ReadonlyArray<{
|
|
118
|
+
room: string;
|
|
119
|
+
count: number;
|
|
120
|
+
label?: string;
|
|
121
|
+
description?: string;
|
|
122
|
+
}>) => Promise<Array<{
|
|
123
|
+
id: string;
|
|
124
|
+
rooms: string[];
|
|
125
|
+
}>>;
|
|
126
|
+
}
|
|
127
|
+
export declare function annotateOrphanCandidates(ctx: Context, cfg: MemoryConfig, io: OrphanAnnotateIO, limit?: number, overrides?: OrphanAnnotateOverrides): Promise<{
|
|
128
|
+
selected: number;
|
|
129
|
+
candidates: number;
|
|
130
|
+
written: number;
|
|
131
|
+
}>;
|
|
132
|
+
export interface MergeRoomResult {
|
|
133
|
+
dryRun: boolean;
|
|
134
|
+
/** 影响条数(dryRun=预览数;实跑=实际重写数)。 */
|
|
135
|
+
affected: number;
|
|
136
|
+
preview: string[];
|
|
137
|
+
applied: number;
|
|
138
|
+
hasMore: boolean;
|
|
139
|
+
backupFile?: string;
|
|
140
|
+
/** 实跑时回传:受影响的族与场景(调用方入队 recluster 'room-merge')。 */
|
|
141
|
+
families?: string[];
|
|
142
|
+
scenes?: string[];
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Room merge/rename 编排(破坏性动作,分类管理 beta.5):
|
|
146
|
+
* - dryRun(缺省 true):只统计 affected + 前 6 条预览,**零写入**;
|
|
147
|
+
* - 实跑:执行前备份(matching rows → rooms-merge-backups/)→ 游标重写
|
|
148
|
+
* (写前重读-合并-写回,其余 metadata 键保全)→ 注册表 markMerged/renameSlug
|
|
149
|
+
* → 返回受影响 (family, scene) 供调用方入队 recluster('room-merge');
|
|
150
|
+
* - 单飞:并发 merge 直接抛错;与 relabel/ruminate 的时间错开由调用方保证。
|
|
151
|
+
* 抛错语义:非法参数/注册表冲突直接抛(调用方转 notice)。
|
|
152
|
+
*/
|
|
153
|
+
export declare function mergeRoom(io: {
|
|
154
|
+
l1: RoomMergeStore;
|
|
155
|
+
registry: RoomRegistryMerge;
|
|
156
|
+
logger: MemoryLogger;
|
|
157
|
+
}, from: string, to: string, opts?: {
|
|
158
|
+
dryRun?: boolean;
|
|
159
|
+
cap?: number;
|
|
160
|
+
}): Promise<MergeRoomResult>;
|
|
161
|
+
/** rename = merge 1:1 + 注册表改名(冲突时抛错提示走 merge)。
|
|
162
|
+
* 归类语义:改名**不是退役**——merge 编排的 markMerged 会把旧条目置 retired,
|
|
163
|
+
* 这里在改名后恢复其先前状态(active 条目改名后仍 active,不掉出标注词表)。 */
|
|
164
|
+
export declare function renameRoom(io: {
|
|
165
|
+
l1: RoomMergeStore;
|
|
166
|
+
registry: RoomRegistryMerge;
|
|
167
|
+
logger: MemoryLogger;
|
|
168
|
+
}, from: string, to: string, opts?: {
|
|
169
|
+
dryRun?: boolean;
|
|
170
|
+
cap?: number;
|
|
171
|
+
}): Promise<MergeRoomResult>;
|
|
172
|
+
/** 下一条待复查(供 agent 逐个过):记录摘要 + 候选 + 现有 Room 词表 + 剩余数。 */
|
|
173
|
+
export declare function nextReview(l1: RoomReviewStore): {
|
|
174
|
+
id: string;
|
|
175
|
+
content: string;
|
|
176
|
+
type: string;
|
|
177
|
+
sceneName: string;
|
|
178
|
+
candidates: string[];
|
|
179
|
+
roomCatalog: Array<{
|
|
180
|
+
room: string;
|
|
181
|
+
count: number;
|
|
182
|
+
}>;
|
|
183
|
+
remaining: number;
|
|
184
|
+
} | null;
|
|
185
|
+
/** 待复查剩余数(面板/工具兜底信息)。 */
|
|
186
|
+
export declare function pendingReviewCount(l1: RoomReviewStore): number;
|
|
187
|
+
/**
|
|
188
|
+
* 确认复查结果:候选(或 agent 改写的)写入 metadata.tags(**进 Room**),
|
|
189
|
+
* 清候选、标 confirmed。**高权限门控由调用方执行**(与 memory_delete 同款)。
|
|
190
|
+
* 返回写入的 tags;id 不存在/状态非法返回 null。
|
|
191
|
+
*/
|
|
192
|
+
export declare function confirmReview(l1: RoomReviewStore, id: string, rooms: readonly string[]): {
|
|
193
|
+
tags: string[];
|
|
194
|
+
} | null;
|
|
195
|
+
/** 跳过:标 skipped(不再进 pending 队列)。已 confirmed 的记录不受 skip 影响(挂了 Room 的不算长尾)。 */
|
|
196
|
+
export declare function skipReview(l1: RoomReviewStore, id: string): boolean;
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { isTag } from './metadata-validators.js';
|
|
2
|
+
import { callLLM, parseJsonLogged } from './llm.js';
|
|
3
|
+
export const ROOM_CANDIDATES_KEY = 'roomCandidates';
|
|
4
|
+
export const ROOM_REVIEW_KEY = 'roomReview';
|
|
5
|
+
/** 解析标注器输出的逐条候选(容错:id 配对失败丢弃;isTag 校验;目录内优先,≤3)。 */
|
|
6
|
+
export function parseRoomCandidateItems(parsed, ids, roomCatalog) {
|
|
7
|
+
const idSet = new Set(ids);
|
|
8
|
+
const catalogSet = new Set(roomCatalog.map((r) => r.room));
|
|
9
|
+
const byId = new Map();
|
|
10
|
+
for (const item of parsed) {
|
|
11
|
+
if (!idSet.has(item.id))
|
|
12
|
+
continue;
|
|
13
|
+
const cleaned = [];
|
|
14
|
+
for (const r of item.rooms) {
|
|
15
|
+
const slug = typeof r === 'string' ? r.trim().toLowerCase() : '';
|
|
16
|
+
if (!slug || !isTag(slug))
|
|
17
|
+
continue;
|
|
18
|
+
if (!cleaned.includes(slug))
|
|
19
|
+
cleaned.push(slug);
|
|
20
|
+
}
|
|
21
|
+
// 目录内优先排前,其余兜底;截断到 3
|
|
22
|
+
cleaned.sort((a, b) => Number(catalogSet.has(b)) - Number(catalogSet.has(a)));
|
|
23
|
+
byId.set(item.id, cleaned.slice(0, 3));
|
|
24
|
+
}
|
|
25
|
+
return [...byId.entries()].map(([id, rooms]) => ({ id, rooms }));
|
|
26
|
+
}
|
|
27
|
+
/** 候选标注系统 prompt:喂现有 Room 词表(带计数),优先挂靠、少量新建兜底。 */
|
|
28
|
+
export function roomCandidateSystemPrompt(roomCatalog) {
|
|
29
|
+
const catalog = roomCatalog.length > 0
|
|
30
|
+
? roomCatalog
|
|
31
|
+
.slice(0, 40)
|
|
32
|
+
.map((r) => `- ${r.room}(现有 ${r.count} 条)`)
|
|
33
|
+
.join('\n')
|
|
34
|
+
: '(暂无现有 Room)';
|
|
35
|
+
return ('你是记忆库的 Room 归类器。给你若干条记忆(带 id)与现有 Room 词表(带计数),' +
|
|
36
|
+
'为每条记忆选出它最可能归属的 1-3 个 Room slug。规则:\n' +
|
|
37
|
+
'1. **优先从现有 Room 词表里选**(保持分类收敛,避免碎片化);\n' +
|
|
38
|
+
'2. 词表确实没有合适主题时,才提议最多 2 个新 slug(小写字母/数字/连字符);\n' +
|
|
39
|
+
'3. 完全无法判断时返回空数组(宁缺毋滥,不硬猜)。\n' +
|
|
40
|
+
`现有 Room 词表:\n${catalog}\n\n` +
|
|
41
|
+
'输出 JSON 数组:[{"id":"<原id>","rooms":["slug1","slug2"]}]。只输出 JSON,不要多余文字。');
|
|
42
|
+
}
|
|
43
|
+
/** 候选标注器:一批孤儿记录 → 候选 Room(优先现有词表;失败返回空,调用方零改动)。 */
|
|
44
|
+
export async function roomCandidateChunk(ctx, cfg, logger, chunk, roomCatalog) {
|
|
45
|
+
const list = chunk.map((r, i) => `${i + 1}. id=${r.id}\n${r.content.slice(0, 300)}`).join('\n\n');
|
|
46
|
+
try {
|
|
47
|
+
const raw = await callLLM(ctx, cfg, {
|
|
48
|
+
system: roomCandidateSystemPrompt(roomCatalog),
|
|
49
|
+
user: `记忆列表:\n${list}`,
|
|
50
|
+
maxTokens: 4096,
|
|
51
|
+
layer: 'l1-extract',
|
|
52
|
+
logger,
|
|
53
|
+
});
|
|
54
|
+
const parsed = parseJsonLogged(raw, 'Room 候选标注', logger);
|
|
55
|
+
if (!Array.isArray(parsed))
|
|
56
|
+
return [];
|
|
57
|
+
const shape = parsed
|
|
58
|
+
.map((item) => ({
|
|
59
|
+
id: typeof item?.id === 'string' ? item.id : '',
|
|
60
|
+
rooms: Array.isArray(item?.rooms) ? item.rooms : [],
|
|
61
|
+
}))
|
|
62
|
+
.filter((x) => x.id !== '');
|
|
63
|
+
return parseRoomCandidateItems(shape, chunk.map((r) => r.id), roomCatalog);
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
logger.warn(`[memory] Room 候选标注块失败(跳过 ${chunk.length} 条,原记录未改): ${err instanceof Error ? err.message : String(err)}`);
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** 活跃孤儿的选择器:untagged + 未退场,跳过已 skipped 的。 */
|
|
71
|
+
function selectPendingOrphans(l1, limit) {
|
|
72
|
+
const out = [];
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
for (let offset = 0; offset < 2000; offset += 200) {
|
|
75
|
+
const page = l1.list({ untagged: true, retired: false, limit: 200, offset });
|
|
76
|
+
for (const r of page.items) {
|
|
77
|
+
if (seen.has(r.id))
|
|
78
|
+
continue;
|
|
79
|
+
seen.add(r.id);
|
|
80
|
+
if (r.metadata?.[ROOM_REVIEW_KEY] === 'skipped')
|
|
81
|
+
continue;
|
|
82
|
+
out.push(r);
|
|
83
|
+
if (out.length >= limit)
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
if (page.items.length < 200)
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
export async function annotateOrphanCandidates(ctx, cfg, io, limit = 40, overrides = {}) {
|
|
92
|
+
const selected = selectPendingOrphans(io.l1, limit);
|
|
93
|
+
if (selected.length === 0)
|
|
94
|
+
return { selected: 0, candidates: 0, written: 0 };
|
|
95
|
+
// 合并词表:注册表条目排前(附 description,对 LLM 归类价值最高),封顶 120;
|
|
96
|
+
// 无注册表 = 纯自生长聚合(计数降序)。整批共享一张词表(一次 LLM 调用)。
|
|
97
|
+
const grown = io.l1.listRooms();
|
|
98
|
+
const regActive = io.registry?.listActive() ?? [];
|
|
99
|
+
const regSet = new Set(regActive.map((e) => e.slug));
|
|
100
|
+
const merged = [
|
|
101
|
+
...regActive.map((e) => ({ room: e.slug, count: 0, label: e.label, description: e.description })),
|
|
102
|
+
...grown.filter((r) => !regSet.has(r.room)).map((r) => ({ room: r.room, count: r.count })),
|
|
103
|
+
].slice(0, 120);
|
|
104
|
+
const rows = overrides.chunkAnnotator !== undefined
|
|
105
|
+
? await overrides.chunkAnnotator(selected, merged)
|
|
106
|
+
: await roomCandidateChunk(ctx, cfg, io.logger, selected, merged);
|
|
107
|
+
let written = 0;
|
|
108
|
+
for (const { id, rooms: candidates } of rows) {
|
|
109
|
+
if (candidates.length === 0)
|
|
110
|
+
continue;
|
|
111
|
+
const fresh = io.l1.getByIds([id])[0];
|
|
112
|
+
if (!fresh)
|
|
113
|
+
continue;
|
|
114
|
+
const meta = { ...(fresh.metadata ?? {}) };
|
|
115
|
+
meta[ROOM_CANDIDATES_KEY] = candidates;
|
|
116
|
+
meta[ROOM_REVIEW_KEY] = 'pending';
|
|
117
|
+
if (io.l1.patchMetadata(id, meta))
|
|
118
|
+
written++;
|
|
119
|
+
}
|
|
120
|
+
io.logger.info(`[memory] 孤儿候选预标记:选中 ${selected.length},产候选 ${rows.length},写 pending ${written} 条(待 agent 复查)`);
|
|
121
|
+
return { selected: selected.length, candidates: rows.length, written };
|
|
122
|
+
}
|
|
123
|
+
/** 待复查队列:pending 且有候选的活跃孤儿,按更新时间升序(最旧的先复查)。 */
|
|
124
|
+
function pendingQueue(l1) {
|
|
125
|
+
const out = [];
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
for (let offset = 0; offset < 2000; offset += 200) {
|
|
128
|
+
const page = l1.list({ untagged: true, retired: false, limit: 200, offset });
|
|
129
|
+
for (const r of page.items) {
|
|
130
|
+
if (seen.has(r.id))
|
|
131
|
+
continue;
|
|
132
|
+
seen.add(r.id);
|
|
133
|
+
const meta = (r.metadata ?? {});
|
|
134
|
+
if (meta[ROOM_REVIEW_KEY] === 'pending' && Array.isArray(meta[ROOM_CANDIDATES_KEY]))
|
|
135
|
+
out.push(r);
|
|
136
|
+
}
|
|
137
|
+
if (page.items.length < 200)
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
return out.sort((a, b) => a.updatedAt - b.updatedAt);
|
|
141
|
+
}
|
|
142
|
+
/** merge 单飞哨兵(模块级;与 ruminate/wing-backfill 的时间错开由调用方保证)。 */
|
|
143
|
+
let roomMergeRunning = false;
|
|
144
|
+
/**
|
|
145
|
+
* Room merge/rename 编排(破坏性动作,分类管理 beta.5):
|
|
146
|
+
* - dryRun(缺省 true):只统计 affected + 前 6 条预览,**零写入**;
|
|
147
|
+
* - 实跑:执行前备份(matching rows → rooms-merge-backups/)→ 游标重写
|
|
148
|
+
* (写前重读-合并-写回,其余 metadata 键保全)→ 注册表 markMerged/renameSlug
|
|
149
|
+
* → 返回受影响 (family, scene) 供调用方入队 recluster('room-merge');
|
|
150
|
+
* - 单飞:并发 merge 直接抛错;与 relabel/ruminate 的时间错开由调用方保证。
|
|
151
|
+
* 抛错语义:非法参数/注册表冲突直接抛(调用方转 notice)。
|
|
152
|
+
*/
|
|
153
|
+
export async function mergeRoom(io, from, to, opts = {}) {
|
|
154
|
+
const dryRun = opts.dryRun !== false;
|
|
155
|
+
const cap = Math.min(Math.max(opts.cap ?? 500, 1), 2000);
|
|
156
|
+
if (from === to)
|
|
157
|
+
throw new Error('from 与 to 相同');
|
|
158
|
+
if (!from.trim())
|
|
159
|
+
throw new Error('from 不能为空');
|
|
160
|
+
if (roomMergeRunning)
|
|
161
|
+
throw new Error('Room merge 已在进行中(单飞)');
|
|
162
|
+
const previewRows = io.l1.listByTagAll(from, 7);
|
|
163
|
+
const affected = io.l1.listByTagAll(from, cap + 1).length;
|
|
164
|
+
const preview = previewRows.slice(0, 6).map((r) => r.id);
|
|
165
|
+
if (dryRun)
|
|
166
|
+
return { dryRun: true, affected, preview, applied: 0, hasMore: affected > cap };
|
|
167
|
+
if (roomMergeRunning)
|
|
168
|
+
throw new Error('Room merge 已在进行中(单飞)');
|
|
169
|
+
roomMergeRunning = true;
|
|
170
|
+
try {
|
|
171
|
+
const backup = io.l1.backupTagRecords(from);
|
|
172
|
+
io.logger.info(`[memory] Room merge 备份:${from} → ${to},${backup.count} 条 → ${backup.file}`);
|
|
173
|
+
let applied = 0;
|
|
174
|
+
const hasMore = false; // 循环到收敛,单次调用内 hasMore 恒 false(重写返回值沿用结果形状)
|
|
175
|
+
const families = new Set();
|
|
176
|
+
const scenes = new Set();
|
|
177
|
+
// 游标重写:上限 cap/次,r.hasMore 时继续(总量受 cap×轮次约束,单飞内收敛)
|
|
178
|
+
for (;;) {
|
|
179
|
+
const r = io.l1.rewriteTag(from, to, cap);
|
|
180
|
+
applied += r.rewritten;
|
|
181
|
+
for (const f of r.families)
|
|
182
|
+
families.add(f);
|
|
183
|
+
for (const sc of r.scenes)
|
|
184
|
+
scenes.add(sc);
|
|
185
|
+
if (!r.hasMore || r.rewritten === 0)
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
await io.registry.markMerged(from, to);
|
|
189
|
+
io.l1.invalidateRooms();
|
|
190
|
+
io.logger.info(`[memory] Room merge 完成:${from} → ${to},重写 ${applied} 条;受影响场景 ${scenes.size} 个`);
|
|
191
|
+
return {
|
|
192
|
+
dryRun: false,
|
|
193
|
+
affected,
|
|
194
|
+
preview,
|
|
195
|
+
applied,
|
|
196
|
+
hasMore,
|
|
197
|
+
backupFile: backup.file,
|
|
198
|
+
...(families.size > 0 || scenes.size > 0 ? { families: [...families], scenes: [...scenes] } : {}),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
roomMergeRunning = false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** rename = merge 1:1 + 注册表改名(冲突时抛错提示走 merge)。
|
|
206
|
+
* 归类语义:改名**不是退役**——merge 编排的 markMerged 会把旧条目置 retired,
|
|
207
|
+
* 这里在改名后恢复其先前状态(active 条目改名后仍 active,不掉出标注词表)。 */
|
|
208
|
+
export async function renameRoom(io, from, to, opts = {}) {
|
|
209
|
+
const priorStatus = io.registry.bySlug?.(from)?.status ?? 'active';
|
|
210
|
+
const r = await mergeRoom(io, from, to, opts);
|
|
211
|
+
if (!r.dryRun && r.applied > 0) {
|
|
212
|
+
await io.registry.renameSlug(from, to);
|
|
213
|
+
if (priorStatus === 'active')
|
|
214
|
+
await io.registry.setStatus?.(to, 'active');
|
|
215
|
+
}
|
|
216
|
+
return r;
|
|
217
|
+
}
|
|
218
|
+
/** 下一条待复查(供 agent 逐个过):记录摘要 + 候选 + 现有 Room 词表 + 剩余数。 */
|
|
219
|
+
export function nextReview(l1) {
|
|
220
|
+
const queue = pendingQueue(l1);
|
|
221
|
+
if (queue.length === 0)
|
|
222
|
+
return null;
|
|
223
|
+
const r = queue[0];
|
|
224
|
+
const meta = (r.metadata ?? {});
|
|
225
|
+
return {
|
|
226
|
+
id: r.id,
|
|
227
|
+
content: r.content.slice(0, 600),
|
|
228
|
+
type: r.type,
|
|
229
|
+
sceneName: r.scene_name,
|
|
230
|
+
candidates: meta[ROOM_CANDIDATES_KEY] ?? [],
|
|
231
|
+
roomCatalog: l1.listRooms().slice(0, 40),
|
|
232
|
+
remaining: queue.length,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/** 待复查剩余数(面板/工具兜底信息)。 */
|
|
236
|
+
export function pendingReviewCount(l1) {
|
|
237
|
+
return pendingQueue(l1).length;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* 确认复查结果:候选(或 agent 改写的)写入 metadata.tags(**进 Room**),
|
|
241
|
+
* 清候选、标 confirmed。**高权限门控由调用方执行**(与 memory_delete 同款)。
|
|
242
|
+
* 返回写入的 tags;id 不存在/状态非法返回 null。
|
|
243
|
+
*/
|
|
244
|
+
export function confirmReview(l1, id, rooms) {
|
|
245
|
+
const found = l1.getByIds([id])[0];
|
|
246
|
+
if (!found)
|
|
247
|
+
return null;
|
|
248
|
+
const meta = { ...(found.metadata ?? {}) };
|
|
249
|
+
// 已确认/已跳过的不允许重复确认(防止 agent 循环空转)
|
|
250
|
+
if (meta[ROOM_REVIEW_KEY] === 'confirmed')
|
|
251
|
+
return null;
|
|
252
|
+
const cleaned = [];
|
|
253
|
+
for (const r of rooms) {
|
|
254
|
+
if (typeof r !== 'string')
|
|
255
|
+
continue;
|
|
256
|
+
const slug = r.trim().toLowerCase();
|
|
257
|
+
if (!slug || !isTag(slug) || cleaned.includes(slug))
|
|
258
|
+
continue;
|
|
259
|
+
cleaned.push(slug);
|
|
260
|
+
}
|
|
261
|
+
if (cleaned.length === 0)
|
|
262
|
+
return null;
|
|
263
|
+
meta.tags = cleaned;
|
|
264
|
+
delete meta[ROOM_CANDIDATES_KEY];
|
|
265
|
+
meta[ROOM_REVIEW_KEY] = 'confirmed';
|
|
266
|
+
if (!l1.patchMetadata(id, meta))
|
|
267
|
+
return null;
|
|
268
|
+
l1.invalidateRooms();
|
|
269
|
+
return { tags: cleaned };
|
|
270
|
+
}
|
|
271
|
+
/** 跳过:标 skipped(不再进 pending 队列)。已 confirmed 的记录不受 skip 影响(挂了 Room 的不算长尾)。 */
|
|
272
|
+
export function skipReview(l1, id) {
|
|
273
|
+
const found = l1.getByIds([id])[0];
|
|
274
|
+
if (!found)
|
|
275
|
+
return false;
|
|
276
|
+
const meta = { ...(found.metadata ?? {}) };
|
|
277
|
+
if (meta[ROOM_REVIEW_KEY] === 'confirmed')
|
|
278
|
+
return false;
|
|
279
|
+
meta[ROOM_REVIEW_KEY] = 'skipped';
|
|
280
|
+
return l1.patchMetadata(id, meta);
|
|
281
|
+
}
|