dsh-prime-memory 0.24.2 → 0.26.0

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 (40) hide show
  1. package/CHANGELOG.en.md +28 -0
  2. package/CHANGELOG.ja.md +29 -0
  3. package/CHANGELOG.ko.md +29 -0
  4. package/CHANGELOG.md +30 -0
  5. package/dist/client.js +907 -654
  6. package/dist/config.d.ts +47 -2
  7. package/dist/config.js +30 -2
  8. package/dist/contract.d.ts +107 -1
  9. package/dist/keyword-backfill.d.ts +75 -0
  10. package/dist/keyword-backfill.js +218 -0
  11. package/dist/metadata-validators.d.ts +9 -0
  12. package/dist/metadata-validators.js +31 -0
  13. package/dist/pipeline/relabel.d.ts +11 -12
  14. package/dist/pipeline/relabel.js +54 -66
  15. package/dist/pipeline/runner.js +24 -1
  16. package/dist/projection/recall.d.ts +3 -2
  17. package/dist/projection/recall.js +26 -5
  18. package/dist/projection/slots.d.ts +11 -0
  19. package/dist/projection/slots.js +13 -3
  20. package/dist/redistill-backfill.d.ts +133 -0
  21. package/dist/redistill-backfill.js +352 -0
  22. package/dist/room-review.d.ts +12 -13
  23. package/dist/room-review.js +37 -29
  24. package/dist/settings.js +13 -0
  25. package/dist/stats.js +126 -14
  26. package/dist/store/l1.d.ts +18 -0
  27. package/dist/store/l1.js +17 -0
  28. package/dist/store/memory-backend-worker.d.ts +1 -0
  29. package/dist/store/memory-backend-worker.js +3 -0
  30. package/dist/store/memory-backend.d.ts +3 -0
  31. package/dist/store/memory-backend.js +3 -0
  32. package/dist/store/memory-md.d.ts +8 -0
  33. package/dist/store/memory-md.js +13 -2
  34. package/dist/store/memory-worker.js +3 -0
  35. package/dist/store/sqlite.d.ts +28 -0
  36. package/dist/store/sqlite.js +66 -0
  37. package/dist/wing-backfill.d.ts +16 -14
  38. package/dist/wing-backfill.js +76 -96
  39. package/dsh.plugin.json +1 -1
  40. package/package.json +1 -1
@@ -5,19 +5,18 @@ import { parseJsonLogged } from './llm.js';
5
5
  import { isWingId, normTags } from './metadata-validators.js';
6
6
  import { yieldLoop } from './util/yield.js';
7
7
  /** 单次任务处理上限默认值(可被 cfg.backfill.maxRecords 覆盖;分批触发,可重复执行)。 */
8
- const DEFAULT_MAX_RECORDS = 300;
8
+ const DEFAULT_MAX_RECORDS = 40;
9
9
  /** 墙钟预算默认值 ms(可被 cfg.backfill.timeBudgetMs 覆盖):超时剩留待再次触发。 */
10
- const DEFAULT_TIME_BUDGET_MS = 120_000;
11
- /** 每块条数(一次 LLM 调用判定的记录数)。 */
12
- const CHUNK = 20;
10
+ const DEFAULT_TIME_BUDGET_MS = 1_800_000;
11
+ /** 单次 LLM 请求默认超时(可被 cfg.backfill.callTimeoutMs 覆盖)。 */
12
+ const DEFAULT_CALL_TIMEOUT_MS = 240_000;
13
13
  const state = { running: false, updated: 0, failed: 0 };
14
14
  export function wingBackfillState() {
15
15
  return { ...state };
16
16
  }
17
- const SYSTEM_PROMPT = '你是记忆库的 Wing 标注器。给你若干条记忆(带 id),为每条判断其内容属于哪个 Wing,输出 JSON 数组:' +
18
- '[{"id":"<原id>","wing":"<域id>"}]。wing 只能从给定候选列表中选,且必须是列表里的原文 id;' +
19
- '列表外的值一律无效(会被丢弃)。横跨多域或确实无法归入任何角时选 general。' +
20
- '注意 id 必须原样照抄,不要改写、截断或改用序号。只输出 JSON,不要多余文字。';
17
+ const SYSTEM_PROMPT = '你是记忆库的 Wing 标注器。给你一条记忆,判断其内容属于哪个 Wing,输出 JSON 对象:{"wing":"<域id>"}。' +
18
+ 'wing 只能从给定候选列表中选,且必须是列表里的原文 id;列表外的值一律无效(会被丢弃)。' +
19
+ '横跨多域或确实无法归入任何角时选 general。只输出 JSON,不要多余文字。';
21
20
  /** 启动后台回填;已在运行返回 false(单飞)。 */
22
21
  export function startWingBackfill(deps) {
23
22
  if (state.running)
@@ -35,12 +34,13 @@ async function run(deps) {
35
34
  const { ctx, cfg, backend, logger } = deps;
36
35
  const MAX_RECORDS = cfg.backfill?.maxRecords ?? DEFAULT_MAX_RECORDS;
37
36
  const TIME_BUDGET_MS = cfg.backfill?.timeBudgetMs ?? DEFAULT_TIME_BUDGET_MS;
37
+ const callTimeoutMs = cfg.backfill?.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
38
38
  const halls = normWingEnabled(cfg.hall?.enabled);
39
39
  if (halls.length === 0) {
40
40
  logger.warn('[memory] wing 回填跳过:wing.enabled 为空(打标功能已关闭)');
41
41
  return;
42
42
  }
43
- // 墙钟预算(cfg.backfill.timeBudgetMs,默认 120s):超时即停,剩余留待再次触发(任务本身设计为可重复执行)。
43
+ // 墙钟预算(cfg.backfill.timeBudgetMs):超时即停,剩余留待再次触发(任务本身设计为可重复执行)。
44
44
  // 保证后台回填不长时间占据事件循环,设置面板的状态 RPC 始终优先。
45
45
  const startedAt = Date.now();
46
46
  const candidates = [...halls, WING_FALLBACK].join(' / ');
@@ -66,125 +66,105 @@ async function run(deps) {
66
66
  }
67
67
  await yieldLoop(); // 扫描批次间让位:面板状态 RPC 优先
68
68
  }
69
- let budget = Math.min(pendingIds.length, MAX_RECORDS);
69
+ const budget = Math.min(pendingIds.length, MAX_RECORDS);
70
70
  logger.info(`[memory] wing 回填启动:未打标 ${pendingIds.length} 条,本次上限 ${budget} 条(候选 ${candidates})`);
71
- for (let offset = 0; offset < pendingIds.length && budget > 0; offset += CHUNK) {
71
+ // 逐条串行:取一条 → 送标 → 当场写回。不攒批、不合并(爆炸半径锁在 1 条)。
72
+ const ids = pendingIds.slice(0, budget);
73
+ for (let i = 0; i < ids.length; i++) {
72
74
  if (Date.now() - startedAt > TIME_BUDGET_MS) {
73
- logger.info(`[memory] wing 回填:墙钟预算(${TIME_BUDGET_MS / 1000}s)用尽,剩余 ${pendingIds.length - offset} 条留待再次触发`);
75
+ logger.info(`[memory] wing 回填:墙钟预算(${TIME_BUDGET_MS / 1000}s)用尽,剩余 ${ids.length - i} 条留待再次触发`);
74
76
  break;
75
77
  }
76
- // 标注器需要 content 才能构造提示词,而扫描阶段只取了轻量投影 →
77
- // 按 id 精确补水(主键索引),每批 20 条,不做全量回填。
78
- const hydrated = await backend.getByIds(pendingIds.slice(offset, offset + CHUNK));
79
- const chunk = hydrated.filter(pickChunk);
80
- if (chunk.length === 0)
78
+ // 标注器需要 content 才能构造提示词,而扫描阶段只取了轻量投影 → 按 id 精确补水(主键索引)。
79
+ const [rec] = await backend.getByIds([ids[i]]);
80
+ if (!rec) {
81
+ state.failed++;
81
82
  continue;
82
- budget -= chunk.length;
83
- const labeled = await labelWingChunk(ctx, cfg, logger, chunk, candidates);
84
- for (const { record, wing } of labeled) {
85
- // 最小写回:只改 metadata(hall 是磁盘兼容键,不改名)。
86
- // 这里**必须**用 patchMetadata 而非 upsert——批量巡检路径不持有正文,
87
- // 用 upsert 会把 content 写成空(数据静默丢失,已由 l1-paging 测试钉死)。
88
- const meta = { ...(record.metadata ?? {}), hall: wing };
89
- if (await backend.patchMetadata(record.id, meta))
90
- state.updated++;
91
- else
92
- state.failed++;
93
- // 写回间让位:面板状态 RPC 优先于后台批次
94
- await yieldLoop();
95
83
  }
84
+ const wing = await labelWingOne(ctx, cfg, logger, rec, candidates, callTimeoutMs);
85
+ if (wing === null) {
86
+ state.failed++;
87
+ continue;
88
+ }
89
+ // 最小写回:只改 metadata(hall 是磁盘兼容键,不改名)。
90
+ // 这里**必须**用 patchMetadata 而非 upsert——巡检路径不持有正文,
91
+ // 用 upsert 会把 content 写成空(数据静默丢失,已由 l1-paging 测试钉死)。
92
+ const meta = { ...(rec.metadata ?? {}), hall: wing };
93
+ if (await backend.patchMetadata(rec.id, meta))
94
+ state.updated++;
95
+ else
96
+ state.failed++;
97
+ await yieldLoop(); // 写回间让位:面板状态 RPC 优先于后台逐条
96
98
  }
97
99
  }
98
- /** 回填只处理活跃记录可安全加速,但 retired 行的 metadata_json 同样保留且 restore 后可见——
99
- * 写回走 `patchMetadata`(只改 metadata_json),对 retired 行与活跃行行为一致(不复活、不动正文),
100
- * 因此这里不做活性过滤,口径与迁移一致(全量)。 */
101
- function pickChunk(_r) {
102
- return true;
103
- }
104
- /** Wing 标注器(导出):一键回填与反刍重标定共用同一 LLM 路径与措辞。 */
105
- export async function labelWingChunk(ctx, cfg, logger, chunk, candidates) {
106
- const list = chunk.map((r, i) => `${i + 1}. id=${r.id}\n${r.content.slice(0, 300)}`).join('\n\n');
107
- const user = `候选 Wing:${candidates}\n\n记忆列表:\n${list}`;
100
+ /**
101
+ * Wing 标注器(导出):一键回填与反刍重标定共用同一 LLM 路径与措辞。
102
+ *
103
+ * **一个请求只装一条**(旧名 `labelWingChunk`,多条一次,已废)。返回该条的 wing,
104
+ * 失败/字段缺失/值非法返回 null(记账归调用方,本函数不动全局状态)。
105
+ */
106
+ export async function labelWingOne(ctx, cfg, logger, rec, candidates, callTimeoutMs = DEFAULT_CALL_TIMEOUT_MS) {
107
+ const user = `候选 Wing:${candidates}\n\n记忆:\n${rec.content.slice(0, 600)}`;
108
108
  try {
109
109
  const raw = await callLLM(ctx, cfg, {
110
110
  system: SYSTEM_PROMPT,
111
111
  user,
112
- maxTokens: 4096,
112
+ maxTokens: 1024,
113
113
  layer: 'l1-extract',
114
114
  logger,
115
+ signal: AbortSignal.timeout(callTimeoutMs > 0 ? callTimeoutMs : DEFAULT_CALL_TIMEOUT_MS),
115
116
  });
116
117
  const parsed = parseJsonLogged(raw, 'wing 回填', logger);
117
- if (!Array.isArray(parsed))
118
- return [];
119
- const byId = new Map(chunk.map((r) => [r.id, r]));
120
- const out = [];
121
- for (const item of parsed) {
122
- // 字段名必须与提示词一致读 `wing`。历史上此处误读 `item.hall`(提示词早已是 wing),
123
- // 导致整批静默丢弃——日志只增长 llmSkipped 而 wingLabeled 恒为 0,排查代价极高。
124
- const rawWing = typeof item?.wing === 'string' ? item.wing.trim() : '';
125
- const record = typeof item?.id === 'string' ? byId.get(item.id) : undefined;
126
- if (!record) {
127
- logger.warn(`[memory] wing 回填:id 配对失败,已丢弃(id=${String(item?.id).slice(0, 40)})`);
128
- continue;
129
- }
130
- // 字段缺失与值非法分开报:否则模型若又换了字段名,日志只会显示"非法值「」",
131
- // 看不出真实原因是**读不到字段**——这正是上次事故难以定位的原因。
132
- if (rawWing === '') {
133
- logger.warn(`[memory] wing 回填:返回项缺少 wing 字段,已丢弃(id=${record.id};实际键=${Object.keys(item ?? {}).join('|')})`);
134
- continue;
135
- }
136
- // 枚举校验:非法值不写入(含认知 hall 值如 facts/events —— 两者值域不相交)
137
- if (!isWingId(rawWing)) {
138
- logger.warn(`[memory] wing 回填:非法 wing 值「${rawWing.slice(0, 32)}」已丢弃(id=${record.id})`);
139
- continue;
140
- }
141
- out.push({ record, wing: rawWing });
118
+ // 通吃两种形状:模型偶尔无视“只输出一个对象”而回单元素数组(或带 id 的旧形状)。
119
+ const obj = Array.isArray(parsed) ? parsed[0] : parsed;
120
+ // 字段名必须与提示词一致读 `wing`。历史上此处误读 `item.hall`(提示词早已是 wing),
121
+ // 导致整批静默丢弃——日志只增长 llmSkipped 而 wingLabeled 恒为 0,排查代价极高。
122
+ const rawWing = typeof obj?.wing === 'string' ? obj.wing.trim() : '';
123
+ // 字段缺失与值非法分开报:否则模型若又换了字段名,日志只会显示"非法值「」",
124
+ // 看不出真实原因是**读不到字段**——这正是上次事故难以定位的原因。
125
+ if (rawWing === '') {
126
+ logger.warn(`[memory] wing 回填:返回缺少 wing 字段,已丢弃(id=${rec.id};实际键=${Object.keys(obj ?? {}).join('|')})`);
127
+ return null;
128
+ }
129
+ // 枚举校验:非法值不写入(含认知 hall 值如 facts/events —— 两者值域不相交)
130
+ if (!isWingId(rawWing)) {
131
+ logger.warn(`[memory] wing 回填:非法 wing 值「${rawWing.slice(0, 32)}」已丢弃(id=${rec.id})`);
132
+ return null;
142
133
  }
143
- return out;
134
+ return rawWing;
144
135
  }
145
136
  catch (err) {
146
- logger.warn(`[memory] wing 回填块失败(跳过 ${chunk.length} 条,原记录未改): ${err instanceof Error ? err.message : String(err)}`);
147
- state.failed += chunk.length;
148
- return [];
137
+ logger.warn(`[memory] wing 回填单条失败(id=${rec.id},原记录未改): ${err instanceof Error ? err.message : String(err)}`);
138
+ return null;
149
139
  }
150
140
  }
151
- const TAGGER_SYSTEM_PROMPT = '你是记忆库的标签标注器。给你若干条记忆(带 id),为每条提炼 1-3 个能概括其主题的英文 slug 标签' +
141
+ const TAGGER_SYSTEM_PROMPT = '你是记忆库的标签标注器。给你一条记忆,为它提炼 1-3 个能概括其主题的英文 slug 标签' +
152
142
  '(小写字母/数字/连字符,如 graphql-switch、riley-college-apps)。标签从内容中涌现,不从预定义列表选;' +
153
- '输出 JSON 数组:[{"id":"<原id>","tags":["slug1","slug2"]}]。只输出 JSON,不要多余文字。';
143
+ '输出 JSON 对象:{"tags":["slug1","slug2"]}。只输出 JSON,不要多余文字。';
154
144
  /**
155
- * 涌现标签标注器(导出):为一批记录提炼 slug 标签(tags)——Room 的前身。
156
- * 与 Wing 标注共用 LLM 路由(layer='l1-extract');失败返回空数组,调用方零改动。
145
+ * 涌现标签标注器(导出):为**一条**记录提炼 slug 标签(tags)——Room 的前身。
146
+ * 一个请求只装一条(旧名 `tagChunk`,多条一次,已废)。失败返回 null;合法但为空返回 []。
157
147
  */
158
- export async function tagChunk(ctx, cfg, logger, chunk) {
159
- const list = chunk.map((r, i) => `${i + 1}. id=${r.id}\n${r.content.slice(0, 300)}`).join('\n\n');
148
+ export async function tagOne(ctx, cfg, logger, rec, callTimeoutMs = DEFAULT_CALL_TIMEOUT_MS) {
149
+ const user = `记忆:\n${rec.content.slice(0, 600)}`;
160
150
  try {
161
151
  const raw = await callLLM(ctx, cfg, {
162
152
  system: TAGGER_SYSTEM_PROMPT,
163
- user: `记忆列表:\n${list}`,
164
- maxTokens: 4096,
153
+ user,
154
+ maxTokens: 1024,
165
155
  layer: 'l1-extract',
166
156
  logger,
157
+ signal: AbortSignal.timeout(callTimeoutMs > 0 ? callTimeoutMs : DEFAULT_CALL_TIMEOUT_MS),
167
158
  });
168
159
  const parsed = parseJsonLogged(raw, 'tags 标注', logger);
169
- if (!Array.isArray(parsed))
170
- return [];
171
- const byId = new Map(chunk.map((r) => [r.id, r]));
172
- const out = [];
173
- for (const item of parsed) {
174
- const record = typeof item?.id === 'string' ? byId.get(item.id) : undefined;
175
- if (!record) {
176
- logger.warn(`[memory] tags 标注:id 配对失败,已丢弃(id=${String(item?.id).slice(0, 40)})`);
177
- continue;
178
- }
179
- // 归一 + 校验在标注器内部就做一遍(写回点仍会再过一次,双保险)
180
- const tags = normTags(item?.tags);
181
- if (tags.length > 0)
182
- out.push({ record, tags });
183
- }
184
- return out;
160
+ const obj = Array.isArray(parsed) ? parsed[0] : parsed;
161
+ if (!obj || typeof obj !== 'object')
162
+ return null;
163
+ // 归一 + 校验在标注器内部就做一遍(写回点仍会再过一次,双保险)
164
+ return normTags(obj.tags);
185
165
  }
186
166
  catch (err) {
187
- logger.warn(`[memory] tags 标注块失败(跳过 ${chunk.length} 条,原记录未改): ${err instanceof Error ? err.message : String(err)}`);
188
- return [];
167
+ logger.warn(`[memory] tags 标注单条失败(id=${rec.id},原记录未改): ${err instanceof Error ? err.message : String(err)}`);
168
+ return null;
189
169
  }
190
170
  }
package/dsh.plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "dsh-prime-memory",
3
3
  "name": "dsh-prime-memory",
4
- "version": "0.24.2",
4
+ "version": "0.26.0",
5
5
  "description": {
6
6
  "en": "Layered distilled memory plugin (L0 capture → L1 atomic memory → L2 scenes → L3 persona) with automatic recall injection before each model step.",
7
7
  "zh": "L0~L3 分层蒸馏记忆插件:自动捕获对话、抽取原子记忆、整合场景块、蒸馏核心画像,并在模型步骤前自动召回注入。"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-prime-memory",
3
- "version": "0.24.2",
3
+ "version": "0.26.0",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public",