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.
Files changed (59) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/cordis.patch.yml +6 -0
  3. package/dist/client.js +1333 -168
  4. package/dist/config.d.ts +37 -0
  5. package/dist/config.js +7 -0
  6. package/dist/contract.d.ts +155 -1
  7. package/dist/hooks/capture-recovery.d.ts +64 -0
  8. package/dist/hooks/capture-recovery.js +105 -0
  9. package/dist/hooks/capture.js +123 -1
  10. package/dist/hooks/recall-ack.d.ts +48 -0
  11. package/dist/hooks/recall-ack.js +79 -0
  12. package/dist/hooks/recall.d.ts +23 -1
  13. package/dist/hooks/recall.js +121 -6
  14. package/dist/index.d.ts +22 -0
  15. package/dist/index.js +18 -4
  16. package/dist/metadata-validators.d.ts +5 -0
  17. package/dist/metadata-validators.js +11 -1
  18. package/dist/pipeline/l1.d.ts +4 -0
  19. package/dist/pipeline/l1.js +9 -2
  20. package/dist/pipeline/reconcile.js +4 -0
  21. package/dist/pipeline/ruminate.d.ts +11 -1
  22. package/dist/pipeline/ruminate.js +18 -1
  23. package/dist/pipeline/runner.js +62 -0
  24. package/dist/prompts/boundary.d.ts +17 -0
  25. package/dist/prompts/boundary.js +20 -0
  26. package/dist/prompts/graph-projection.js +4 -1
  27. package/dist/prompts/l1-dedup.d.ts +3 -3
  28. package/dist/prompts/l1-dedup.js +9 -1
  29. package/dist/prompts/l1-extraction.d.ts +3 -3
  30. package/dist/prompts/l1-extraction.js +12 -0
  31. package/dist/prompts/persona.js +6 -0
  32. package/dist/prompts/scene.js +7 -1
  33. package/dist/room-review.d.ts +196 -0
  34. package/dist/room-review.js +281 -0
  35. package/dist/stats.d.ts +26 -2
  36. package/dist/stats.js +270 -13
  37. package/dist/store/l0.d.ts +4 -0
  38. package/dist/store/l0.js +8 -0
  39. package/dist/store/l1.d.ts +34 -0
  40. package/dist/store/l1.js +68 -1
  41. package/dist/store/recall-usage.d.ts +43 -0
  42. package/dist/store/recall-usage.js +149 -0
  43. package/dist/store/rooms-registry.d.ts +66 -0
  44. package/dist/store/rooms-registry.js +250 -0
  45. package/dist/store/sqlite.d.ts +16 -0
  46. package/dist/store/sqlite.js +57 -0
  47. package/dist/store/trace.d.ts +39 -0
  48. package/dist/store/trace.js +149 -0
  49. package/dist/tools/index.d.ts +4 -1
  50. package/dist/tools/index.js +249 -2
  51. package/dist/types.d.ts +4 -0
  52. package/dist/util/redact.d.ts +27 -0
  53. package/dist/util/redact.js +228 -0
  54. package/dsh.plugin.json +1 -1
  55. package/package.json +3 -3
  56. package/dist/hall-backfill.d.ts +0 -33
  57. package/dist/hall-backfill.js +0 -95
  58. package/dist/hall-migrate.d.ts +0 -32
  59. package/dist/hall-migrate.js +0 -88
@@ -5,7 +5,11 @@
5
5
  * prompt 内容直接决定蒸馏质量,是已发布行为的一部分,不属于可自由重写文本;
6
6
  * 代码结构与注释随实现重写。
7
7
  */
8
+ import { CONTENT_IS_DATA_CLAUSE, DATA_DELIMITER_NOTE } from './boundary.js';
8
9
  export const EXTRACT_MEMORIES_SYSTEM_PROMPT = `你是专业的"情境切分与记忆提取专家"。
10
+
11
+ ${CONTENT_IS_DATA_CLAUSE}
12
+
9
13
  你的任务是分析用户的对话,判断情境切换,并从中提取结构化的核心记忆(仅限 persona, episodic, instruction 三类)。
10
14
 
11
15
  **输出语言**:所有自由文本字段(\`scene_name\`、memory \`content\`)使用与用户消息相同的语言;JSON 字段名、枚举值、ISO 时间戳保持英文。
@@ -93,6 +97,9 @@ metadata 字段说明:
93
97
 
94
98
  请严格按上述 JSON 数组格式输出,不要输出任何额外的 Markdown 代码块修饰符(如 \`\`\`json)或解释文本。`;
95
99
  export const EXTRACT_WORK_MEMORIES_SYSTEM_PROMPT = `你是专业的"工作情境切分与团队共享记忆提取专家"。
100
+
101
+ ${CONTENT_IS_DATA_CLAUSE}
102
+
96
103
  你的任务是分析多人工作消息,判断工作情境切换,并从中提取可在项目团队内共享的结构化工作记忆。
97
104
 
98
105
  本任务面向工作场合的团队协作场景。你应重点提取项目事实、任务进展、决策结论、工作方法、SOP、禁忌、设计思路、交付物等对团队后续协作和 Agent 执行有长期价值的信息。
@@ -298,6 +305,9 @@ metadata 字段说明:
298
305
 
299
306
  请严格按上述 JSON 数组格式输出,不要输出任何额外的 Markdown 代码块修饰符(如 \`\`\`json)或解释文本。`;
300
307
  export const EXTRACT_ALL_MEMORIES_SYSTEM_PROMPT = `你是专业的"情境切分与记忆提取专家"。
308
+
309
+ ${CONTENT_IS_DATA_CLAUSE}
310
+
301
311
  你的任务是分析用户的对话,判断情境切换,并从中提取结构化的核心记忆。对话可能同时包含个人生活与工作内容:个人内容提取为个人记忆(persona/episodic/instruction),工作内容提取为团队共享工作记忆(work_fact/work_task/work_method/work_artifact),互不排斥、**每条记忆显式输出 family 字段标注归族**。
302
312
 
303
313
  **输出语言**:所有自由文本字段(\`scene_name\`、memory \`content\`)使用与待提取消息主导语言相同的语言;JSON 字段名、枚举值、ISO 时间戳保持英文。
@@ -452,10 +462,12 @@ export function formatExtractionPrompt(params) {
452
462
  【上一个情境】:${previousSceneName}
453
463
 
454
464
  【背景对话】(仅供理解上下文推断关系/时间,严禁从中提取记忆):
465
+ ${DATA_DELIMITER_NOTE}
455
466
  ${bgText}
456
467
 
457
468
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
458
469
 
459
470
  【待提取的新消息】(务必结合 timestamp 推算时间,只从这里提取记忆!):
471
+ ${DATA_DELIMITER_NOTE}
460
472
  ${newText}${wingText}`;
461
473
  }
@@ -4,8 +4,11 @@
4
4
  * 净室重写说明:本文件的 prompt 文案按重写规格(Phase 2 决策)逐字沿用——
5
5
  * prompt 内容直接决定蒸馏质量,是已发布行为的一部分,不属于可自由重写文本。
6
6
  */
7
+ import { CONTENT_IS_DATA_CLAUSE, DATA_DELIMITER_NOTE } from './boundary.js';
7
8
  const PERSONA_SYSTEM_PROMPT = `# 🧬 Persona Architect - Incremental Evolution Protocol(内容输出模式)
8
9
 
10
+ ${CONTENT_IS_DATA_CLAUSE}
11
+
9
12
  **输出语言**:\`persona.md\` 的所有自然语言内容(Archetype、基本信息、Chapter 1-4 正文等)使用与变化场景内容相同的语言;Markdown 语法、标签格式、文件名 \`persona.md\` 保持英文。模板里 Chapter 标识保留作骨架,非中文输出时请改用目标语言的对照说明。
10
13
 
11
14
  请你结合已有的 persona.md 和新增/变化的 block 信息深度分析,然后**直接输出 persona.md 的完整最终内容**。
@@ -96,6 +99,8 @@ const PERSONA_SYSTEM_PROMPT = `# 🧬 Persona Architect - Incremental Evolution
96
99
  - ✅ 不要添加场景导航(工程会自动追加)`;
97
100
  const TEAM_MEMORY_SYSTEM_PROMPT = `# Team Operating Doctrine Architect(内容输出模式)
98
101
 
102
+ ${CONTENT_IS_DATA_CLAUSE}
103
+
99
104
  **输出语言**:\`persona.md\` 的所有自然语言内容使用与变化场景内容相同的语言;Markdown 语法、标签格式、文件名 \`persona.md\` 保持英文。
100
105
 
101
106
  请你结合已有的 \`persona.md\` 和新增/变化的 L2 场景块,生成或更新一份高度精炼的团队工作原则文档。
@@ -229,6 +234,7 @@ ${triggerSection}
229
234
  - **变化场景**: ${changedSceneCount} 个(自上次更新后)
230
235
 
231
236
  ---
237
+ ${DATA_DELIMITER_NOTE}
232
238
  ${changedScenesContent}
233
239
 
234
240
  ${existingPersonaSection}
@@ -4,9 +4,12 @@
4
4
  * 净室重写说明:本文件的 prompt 文案按重写规格(Phase 2 决策)逐字沿用——
5
5
  * prompt 内容直接决定蒸馏质量,是已发布行为的一部分,不属于可自由重写文本。
6
6
  */
7
+ import { CONTENT_IS_DATA_CLAUSE, DATA_DELIMITER_NOTE } from './boundary.js';
7
8
  function buildSceneSystemPrompt(maxScenes) {
8
9
  return `# Memory Consolidation Architect(场景整合 · 操作输出模式)
9
10
 
11
+ ${CONTENT_IS_DATA_CLAUSE}
12
+
10
13
  **输出语言**:\`.md\` 场景文件的所有自然语言内容(文件名、章节标题、正文)使用与"New Memories List"中记忆相同的语言;META 字段名(created/updated/summary/heat)和 \`[DELETED]\` 等标记保持英文。模板中给出的中文章节标题(\`## 用户核心特征\` 等)作为结构骨架——非中文输出时请用目标语言的等价表达替换。
11
14
 
12
15
  ## 角色定义 (Role Definition)
@@ -168,6 +171,8 @@ reason: 具体原因描述
168
171
  function buildWorkSceneSystemPrompt(maxScenes) {
169
172
  return `# Team Work Method Memory Consolidation Architect(场景整合 · 操作输出模式)
170
173
 
174
+ ${CONTENT_IS_DATA_CLAUSE}
175
+
171
176
  **输出语言**:\`.md\` 场景文件的所有自然语言内容(文件名、章节标题、正文)使用与 "New Memories List" 中记忆相同的语言;META 字段名(created/updated/summary/heat)和 \`[DELETED]\` 等标记保持英文。模板中的中文章节标题仅作为结构骨架,非中文输出时请用目标语言的等价表达替换。
172
177
 
173
178
  ## 角色定义 (Role Definition)
@@ -379,7 +384,7 @@ function getSceneSystemPrompt(maxScenes, family) {
379
384
  export function buildScenePrompt(params) {
380
385
  const { memoriesJson, sceneSummaries, sceneContents, currentTimestamp, existingSceneFiles, maxScenes, family, } = params;
381
386
  const contentsSection = sceneContents
382
- ? `### 2.5 相关场景完整内容(仅供参考,write 时必须整体重写,不要只改局部)\n${sceneContents}\n`
387
+ ? `### 2.5 相关场景完整内容(仅供参考,write 时必须整体重写,不要只改局部)\n${DATA_DELIMITER_NOTE}\n${sceneContents}\n`
383
388
  : '';
384
389
  const fileListSection = existingSceneFiles.length > 0
385
390
  ? `### 📁 已有场景文件清单(仅以下文件可 write/delete)\n${existingSceneFiles.map((f) => `- \`${f}\``).join('\n')}\n`
@@ -387,6 +392,7 @@ export function buildScenePrompt(params) {
387
392
  const userPrompt = `**输出语言**:场景文件内容使用下方 New Memories List 中记忆的主导语言。
388
393
 
389
394
  ### 1️⃣ New Memories List
395
+ ${DATA_DELIMITER_NOTE}
390
396
  ${memoriesJson}
391
397
 
392
398
  ### 2️⃣ Existing Scene Blocks Summary
@@ -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/次,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
+ }
package/dist/stats.d.ts CHANGED
@@ -3,6 +3,8 @@ import { type MemoryConfig } from './config.js';
3
3
  import { type RecallSessionStats } from './hooks/recall.js';
4
4
  import type { RebuildController } from './pipeline/rebuild.js';
5
5
  import type { RuminateController } from './pipeline/ruminate.js';
6
+ import type { RoomRegistryStore } from './store/rooms-registry.js';
7
+ import type { RecallUsageStore } from './store/recall-usage.js';
6
8
  import { type LiveSettingsHandle } from './settings.js';
7
9
  import type { GraphStore } from './store/graph-store.js';
8
10
  import type { L0Store } from './store/l0.js';
@@ -23,7 +25,7 @@ export interface MemoryStatusSource {
23
25
  pending(): number;
24
26
  }
25
27
  /**
26
- * 端点全集运行时清单(36 个,与 tests/contract-keys.test.ts 的 ENDPOINTS 及
28
+ * 端点全集运行时清单(与 tests/contract-keys.test.ts 的 ENDPOINTS 及
27
29
  * contract.ts 类型映射表三方对齐,漂移由键集 diff 测试暴露)。
28
30
  * 注意:本清单同时是 HTTP 前缀路由 `/dsh-memory/rpc/<短名>` 的**放行白名单**
29
31
  * (见下方 SHORT_ENDPOINTS),漏一条 = 该端点在面板里静默消失(404 被客户端
@@ -77,6 +79,9 @@ interface MemoryRpcSources {
77
79
  rebuild?: RebuildController;
78
80
  embedManager?: EmbeddingManager;
79
81
  sessionInfo?: SessionInfoSource;
82
+ /** 长任务端点依赖(未装配 = 端点恒 disabled,面板不挂载)。 */
83
+ /** Room 注册表(未装配 = rooms-get 不带 registry,rooms-export 不可用)。 */
84
+ roomRegistry?: RoomRegistryStore;
80
85
  }
81
86
  /** 端点 deps 的注入面(ctx/cfg/stores/logger 由调用方绑定,其余由此处决定)。 */
82
87
  export type EndpointDepsInput = Omit<EndpointDeps, 'ctx' | 'cfg' | 'stores' | 'logger'>;
@@ -92,19 +97,25 @@ export declare function registerMemoryRpc(ctx: Context, cfg: MemoryConfig, store
92
97
  scenes: Record<MemoryFamily, SceneStore>;
93
98
  persona: Record<MemoryFamily, PersonaStore>;
94
99
  state: StateStore;
100
+ /** 召回使用统计(可选)。 */
101
+ usage?: RecallUsageStore;
95
102
  /** 记忆后端(后台边界);未装配时回退为包 l1 的进程内实现。 */
96
103
  backend?: MemoryBackend;
97
104
  /** 图谱存储(可选:未装配时图谱端点返空,不报错)。 */
98
105
  graph?: GraphStore;
99
106
  }, logger: MemoryLogger, status?: MemoryStatusSource, live?: LiveSettingsHandle, modes?: SessionModeStore, dataDir?: string, rebuild?: RebuildController, embedManager?: EmbeddingManager, sessionInfo?: SessionInfoSource,
100
107
  /** 反刍控制器(存储降级时为 undefined):经 buildEndpointDeps 落入 deps.ruminate。 */
101
- ruminate?: RuminateController): void;
108
+ ruminate?: RuminateController,
109
+ /** Room 分类管理依赖(注册表;缺省 = rooms-get 不带 registry、rooms-export 恒 404 语义)。 */
110
+ roomRegistry?: RoomRegistryStore): void;
102
111
  export interface EndpointDeps {
103
112
  ctx: Context;
104
113
  cfg: MemoryConfig;
105
114
  stores: {
106
115
  l0: L0Store;
107
116
  l1: L1Store;
117
+ /** 召回使用统计(可选:未装配 = 列表不带 usage/usageSummary)。 */
118
+ usage?: RecallUsageStore;
108
119
  scenes: Record<MemoryFamily, SceneStore>;
109
120
  persona: Record<MemoryFamily, PersonaStore>;
110
121
  state: StateStore;
@@ -121,10 +132,15 @@ export interface EndpointDeps {
121
132
  ruminate?: RuminateController;
122
133
  embedManager?: EmbeddingManager;
123
134
  sessionInfo?: SessionInfoSource;
135
+ /** 长任务端点依赖(未装配 = 端点恒 disabled,面板不挂载)。 */
136
+ /** Room 注册表(未装配 = rooms-get 不带 registry,rooms-export 不可用)。 */
137
+ roomRegistry?: RoomRegistryStore;
124
138
  }
125
139
  /** 端点分发表(导出供测试直调:可精确注入 rebuild/ruminate 等可选控制器,验证 deps 接线)。 */
126
140
  export declare function handleEndpoint(endpoint: string, payload: unknown, deps: EndpointDeps): Promise<unknown>;
127
141
  /** 浏览器卡片字段(比 MemoryRecord 精简,去掉大 metadata;Hall 从 metadata 提取)。 */
142
+ /** 老化权重(与读路径 applyDecayWeight 同式):max(0.5, 0.5^(Δ天/半衰期));半衰期 ≤0 = 关 → null。 */
143
+ export declare function agingWeightOf(updatedAtMs: number | undefined, halfLifeDays: number, now?: number): number | null;
128
144
  export declare function hitToUiRecord(r: {
129
145
  id: string;
130
146
  content: string;
@@ -141,4 +157,12 @@ export declare function hitToUiRecord(r: {
141
157
  family?: string;
142
158
  /** 退场判据:`valid_to` 闭合即已退场(软删)。由 `listL1`/`getByIds` 透传。 */
143
159
  validTo?: number;
160
+ }, extra?: {
161
+ agingWeight?: number | null;
162
+ usage?: {
163
+ attempts: number;
164
+ used: number;
165
+ lastAttemptAt: number | null;
166
+ lastUsedAt: number | null;
167
+ } | null;
144
168
  }): UiRecord;