dsh-prime-memory 0.24.1 → 0.25.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.
package/dist/config.js CHANGED
@@ -226,6 +226,10 @@ export const memorySchema = Schema.object({
226
226
  usageScenario: Schema.object({
227
227
  enabled: Schema.boolean().default(false),
228
228
  }),
229
+ // 关键词蒸馏回填:默认关(关时不回填、不产 dsh_keywords、FTS 逐字不变);护栏复用 backfill。
230
+ keyword: Schema.object({
231
+ enabled: Schema.boolean().default(false),
232
+ }),
229
233
  // 类型叙述模板(G2):默认**开**;开时抽取 prompt 追加分类型 content 骨架(关时仍逐字不变)。
230
234
  // 默认值从关改开的理由写在上方接口注释:只给定义实测骨架齐备 0/23,
231
235
  // 而"没前因后果就不算可用"是用户定的判据,所以默认值要按可用性定而不是按保守定。
@@ -647,6 +647,18 @@ export interface WingBackfillResponse {
647
647
  started: boolean;
648
648
  running: boolean;
649
649
  }
650
+ /** dsh-memory/keyword-backfill(关键词蒸馏回填,后台任务;门控关时 enabled=false 不启动)。 */
651
+ export interface KeywordBackfillResponse {
652
+ /** true = 本次触发启动了新任务;false = 已有任务在跑或门控关。 */
653
+ started: boolean;
654
+ running: boolean;
655
+ /** 门控 `cfg.keyword.enabled`;关时不启动,面板据此提示先开开关。 */
656
+ enabled: boolean;
657
+ /** 本进程累计成功回填条数(重启归零;观测用)。 */
658
+ updated: number;
659
+ /** 失败/跳过数。 */
660
+ failed: number;
661
+ }
650
662
  /** dsh-memory/session-stats(悬浮卡信息区;热路径端点)。 */
651
663
  export interface SessionStatsRequest {
652
664
  sessionId: string;
@@ -852,6 +864,28 @@ export interface UiRecord {
852
864
  maturity?: 'low' | 'mid' | 'high';
853
865
  /** 使用场景(G1):从 metadata.dsh_usage_scenario 读出;功能关/无则 undefined。 */
854
866
  usageScenario?: string[];
867
+ /**
868
+ * 名称(md 投射相,N1):名称蒸馏产出的短标题,**投射文件名就取它**
869
+ * (`<type>/<name>.memory.md` 的 stem)。`undefined`/`null` = 还没名字(存量记录),
870
+ * 投射器回落 `mem-<id8>`。面板显示它是为了核对“这条记忆对应哪个投射文件”。
871
+ * 纯投影字段:读时从 `l1_records.name` 带出,不进存储改动。
872
+ */
873
+ name?: string | null;
874
+ /**
875
+ * 可见范围(§E):`global` = 用户默认(任何工作区可见);`workspace` = 仅归属工作区可见。
876
+ * 展示用(读时从 `l1_records.scope` 带出,纯投影)。
877
+ */
878
+ scope?: 'global' | 'workspace';
879
+ /** 归属工作区路径(仅 `scope='workspace'` 时非空;`global` 恒为 `null`)。展示用。 */
880
+ workspaceId?: string | null;
881
+ /**
882
+ * 关键词(保留字段,**用于 BM25 匹配**):从 `metadata.dsh_keywords` 读出。
883
+ *
884
+ * 与 `usageScenario` 同范式(保留键、随 metadata 落库、不进 `l1_records` DDL)。
885
+ * **当前无产出方**——抽取/命名尚未产出该键,故实机上恒为空;先预留字段与检索轴,
886
+ * 让 UI 与 BM25 都就位。无键 → `undefined`(缺席 ≠ 空数组)。
887
+ */
888
+ keywords?: string[];
855
889
  /**
856
890
  * 人给的相关度均值(0..5);从未被 0..5 打过分为 undefined(**缺席 ≠ 0 分**)。
857
891
  * 读时从 recall-feedback sidecar 派生,不进存储/快照。
@@ -1628,6 +1662,7 @@ export interface DshMemoryRequestMap {
1628
1662
  'dsh-memory/room-register': RoomRegisterRequest;
1629
1663
  'dsh-memory/room-admin': RoomAdminRequest;
1630
1664
  'dsh-memory/wing-backfill': Record<string, never>;
1665
+ 'dsh-memory/keyword-backfill': Record<string, never>;
1631
1666
  'dsh-memory/session-stats': SessionStatsRequest;
1632
1667
  'dsh-memory/settings-get': Record<string, never>;
1633
1668
  'dsh-memory/settings-set': SettingsSetRequest;
@@ -1690,6 +1725,7 @@ export interface DshMemoryResponseMap {
1690
1725
  'dsh-memory/room-register': RoomRegisterResponse;
1691
1726
  'dsh-memory/room-admin': RoomAdminResponse;
1692
1727
  'dsh-memory/wing-backfill': WingBackfillResponse;
1728
+ 'dsh-memory/keyword-backfill': KeywordBackfillResponse;
1693
1729
  'dsh-memory/session-stats': SessionStatsResponse;
1694
1730
  'dsh-memory/settings-get': SettingsGetResponse;
1695
1731
  'dsh-memory/settings-set': SettingsSetResponse;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 关键词蒸馏回填(keyword axis 产出方)。
3
+ *
4
+ * 职责:从 L1 记录中找「缺 dsh_keywords」的存量记录,按 chunk 20 条一批送 LLM 提炼关键词
5
+ * 与使用场景,归一后写 `metadata.dsh_keywords` + `metadata.dsh_usage_scenario`,
6
+ * 并用 `patchMetadataAndReindex` 保证新关键词**立即进 FTS BM25 轴**。
7
+ *
8
+ * 安全边界(继承 wing-backfill):
9
+ * - 单飞:同时最多一个回填任务,重复触发直接返回 running(不排队不叠加)
10
+ * - 后台执行:端点立即返回,逐块推进(每块 20 条),不阻塞 RPC/管线
11
+ * - 失败可退:LLM 失败/输出不合法只跳过当前块,原记录零改动(标签只写成功的)
12
+ * - 有界:单次任务最多处理 cfg.backfill.maxRecords 条,防止一次跑飞;其余可再次触发
13
+ * - 门控关(`cfg.keyword.enabled = false`):run() 入口立即跳过——无回填、FTS 逐字不变
14
+ */
15
+ import type { Context } from '@deepseek-ai/cordis';
16
+ import type { MemoryLogger, MemoryRecord } from './types.js';
17
+ import type { MemoryBackend } from './store/memory-backend.js';
18
+ import type { MemoryConfig } from './config.js';
19
+ export interface KeywordBackfillDeps {
20
+ ctx: Context;
21
+ cfg: MemoryConfig;
22
+ backend: MemoryBackend;
23
+ logger: MemoryLogger;
24
+ }
25
+ export interface KeywordBackfillState {
26
+ running: boolean;
27
+ updated: number;
28
+ failed: number;
29
+ }
30
+ export declare function keywordBackfillState(): KeywordBackfillState;
31
+ /** 启动后台关键词回填;已在运行返回 false(单飞)。 */
32
+ export declare function startKeywordBackfill(deps: KeywordBackfillDeps): {
33
+ started: boolean;
34
+ running: boolean;
35
+ };
36
+ /**
37
+ * 回填主体(导出供单测 await 到完成;生产走 `startKeywordBackfill` 的单飞后台包装)。
38
+ * 门控关(`cfg.keyword.enabled = false`)时入口立即返回,不碰任何记录与 fts。
39
+ */
40
+ export declare function runKeywordBackfillOnce(deps: KeywordBackfillDeps): Promise<void>;
41
+ /** 关键词标注器(导出供单测)。 */
42
+ export declare function labelKeywordChunk(ctx: Context, cfg: MemoryConfig, logger: MemoryLogger, chunk: MemoryRecord[]): Promise<Array<{
43
+ record: MemoryRecord;
44
+ keywords: string[];
45
+ usageScenario?: string[];
46
+ }>>;
@@ -0,0 +1,143 @@
1
+ import { callLLM, parseJsonLogged } from './llm.js';
2
+ import { normKeywords, normUsageScenario, KEYWORDS_METADATA_KEY } from './metadata-validators.js';
3
+ import { yieldLoop } from './util/yield.js';
4
+ /** 每块条数(一次 LLM 调用判定的记录数;与 wing-backfill 同口径)。 */
5
+ const CHUNK = 20;
6
+ /** 扫描页大小(轻量投影分页;与 wing-backfill 一致)。 */
7
+ const PAGE = 200;
8
+ /** 单次任务默认处理上限(cfg.backfill.maxRecords 优先)。 */
9
+ const DEFAULT_MAX_RECORDS = 300;
10
+ /** 墙钟默认预算(cfg.backfill.timeBudgetMs 优先)。 */
11
+ const DEFAULT_TIME_BUDGET_MS = 120_000;
12
+ const state = { running: false, updated: 0, failed: 0 };
13
+ export function keywordBackfillState() {
14
+ return { ...state };
15
+ }
16
+ // ── SYSTEM 提示词(定稿版,由 tools/keyword-distill.prompt.md 的 ```text 块迁移) ──
17
+ const SYSTEM_PROMPT = [
18
+ '你是记忆库的关键词与情景标注器。给定若干条记忆(每条含 id、标题、正文摘要),为每条提炼:',
19
+ '1) keywords:最能代表该记忆的检索词,用于精确命中;2) usage_scenario:未来「何时应当浮现这条记忆」的场景短语。',
20
+ '',
21
+ '严格规则:',
22
+ '- keywords 的唯一判据:**用户未来会输入什么词把这条记忆捞出来**。优先具体名词性标识——项目/模块/文件/函数/配置项/命令/错误码/版本号/数字阈值/工具名/专有实体名。',
23
+ '- **禁止**把正文里的动宾短语压缩成抽象黑话当关键词(反例:把"判定批量流量"压成"批量流量判定"、把"做窗口滑动归因"压成"窗口滑动归因"、把"保持只读约束"压成"只读约束")——这类词没人会拿去搜,一律换成它指向的具体实体/标识,或直接删掉。',
24
+ '- keywords:2–5 个(宁少勿滥,够区分即可);每个 2–6 词或 1 个复合术语;不得与标题字面重复、不得互相近义堆叠、不收停用词/整句。',
25
+ '- usage_scenario:1–3 条;每条 5–10 词的短场景描述(不是完整命令句);彼此必须可区分,相似的合并成更泛化的一条。',
26
+ '- 语言跟随该记忆正文:中文记忆产出中文(技术标识符、API 名、错误码原样保留),英文记忆产出英文。',
27
+ '- id 必须原样照抄,不得改写、截断或改用序号;一条记忆只产一组,不要拆分。',
28
+ '',
29
+ '输出:只输出一个 JSON 数组,不要多余文字、不要解释。每项形如:',
30
+ '{"id":"<原id>","keywords":["…","…"],"usage_scenario":["…","…"]}',
31
+ ].join('\n');
32
+ /** 启动后台关键词回填;已在运行返回 false(单飞)。 */
33
+ export function startKeywordBackfill(deps) {
34
+ if (state.running)
35
+ return { started: false, running: true };
36
+ state.running = true;
37
+ state.updated = 0;
38
+ state.failed = 0;
39
+ void runKeywordBackfillOnce(deps).finally(() => {
40
+ state.running = false;
41
+ deps.logger.info(`[memory] keyword 回填结束:成功 ${state.updated} 条,失败 ${state.failed} 条`);
42
+ });
43
+ return { started: true, running: true };
44
+ }
45
+ /**
46
+ * 回填主体(导出供单测 await 到完成;生产走 `startKeywordBackfill` 的单飞后台包装)。
47
+ * 门控关(`cfg.keyword.enabled = false`)时入口立即返回,不碰任何记录与 fts。
48
+ */
49
+ export async function runKeywordBackfillOnce(deps) {
50
+ const { cfg, backend, logger } = deps;
51
+ if (!cfg.keyword?.enabled) {
52
+ logger.info('[memory] keyword 回填跳过:keyword.enabled = false(功能关,不回填)');
53
+ return;
54
+ }
55
+ const MAX_RECORDS = cfg.backfill?.maxRecords ?? DEFAULT_MAX_RECORDS;
56
+ const TIME_BUDGET_MS = cfg.backfill?.timeBudgetMs ?? DEFAULT_TIME_BUDGET_MS;
57
+ const startedAt = Date.now();
58
+ // ── 扫描阶段(轻量投影,不反序列化正文)──
59
+ const total = await backend.size();
60
+ const pendingIds = [];
61
+ let scanned = 0;
62
+ for (let offset = 0;; offset += PAGE) {
63
+ const page = await backend.allLite(PAGE, offset);
64
+ if (page.length === 0)
65
+ break;
66
+ for (const r of page) {
67
+ const kw = r.metadata?.[KEYWORDS_METADATA_KEY];
68
+ if (!(Array.isArray(kw) && kw.length > 0))
69
+ pendingIds.push(r.id);
70
+ }
71
+ scanned += page.length;
72
+ if (Date.now() - startedAt > TIME_BUDGET_MS) {
73
+ logger.info(`[memory] keyword 回填:墙钟预算(${TIME_BUDGET_MS / 1000}s)在扫描阶段用尽(已扫 ${scanned}/${total}),留待再触发`);
74
+ return;
75
+ }
76
+ await yieldLoop();
77
+ }
78
+ let budget = Math.min(pendingIds.length, MAX_RECORDS);
79
+ logger.info(`[memory] keyword 回填启动:缺关键词 ${pendingIds.length} 条,本次上限 ${budget} 条`);
80
+ // ── 标注阶段(补水→LLM→归一→写回)──
81
+ for (let offset = 0; offset < pendingIds.length && budget > 0; offset += CHUNK) {
82
+ if (Date.now() - startedAt > TIME_BUDGET_MS) {
83
+ logger.info(`[memory] keyword 回填:墙钟预算用尽,剩余 ${pendingIds.length - offset} 条留待再触发`);
84
+ break;
85
+ }
86
+ const hydrated = await backend.getByIds(pendingIds.slice(offset, offset + CHUNK));
87
+ const chunk = hydrated.filter((r) => r.content && r.content.trim() !== '');
88
+ if (chunk.length === 0)
89
+ continue;
90
+ budget -= chunk.length;
91
+ const labeled = await labelKeywordChunk(deps.ctx, cfg, logger, chunk);
92
+ for (const { record, keywords, usageScenario } of labeled) {
93
+ const meta = { ...(record.metadata ?? {}) };
94
+ meta[KEYWORDS_METADATA_KEY] = keywords;
95
+ if (usageScenario && usageScenario.length > 0)
96
+ meta.dsh_usage_scenario = usageScenario;
97
+ if (await backend.patchMetadataAndReindex(record.id, meta))
98
+ state.updated++;
99
+ else
100
+ state.failed++;
101
+ await yieldLoop();
102
+ }
103
+ }
104
+ }
105
+ /** 关键词标注器(导出供单测)。 */
106
+ export async function labelKeywordChunk(ctx, cfg, logger, chunk) {
107
+ const list = chunk.map((r, i) => `${i + 1}. id=${r.id}\n标题:${r.name ?? ''}\n正文:${r.content.slice(0, 300)}`).join('\n\n');
108
+ const user = '记忆列表:\n' + list;
109
+ try {
110
+ const raw = await callLLM(ctx, cfg, {
111
+ system: SYSTEM_PROMPT,
112
+ user,
113
+ maxTokens: 4096,
114
+ layer: 'l1-extract',
115
+ logger,
116
+ });
117
+ const parsed = parseJsonLogged(raw, 'keyword 回填', logger);
118
+ if (!Array.isArray(parsed))
119
+ return [];
120
+ const byId = new Map(chunk.map((r) => [r.id, r]));
121
+ const out = [];
122
+ for (const item of parsed) {
123
+ const record = typeof item?.id === 'string' ? byId.get(item.id) : undefined;
124
+ if (!record) {
125
+ logger.warn(`[memory] keyword 回填:id 配对失败,已丢弃(id=${String(item?.id).slice(0, 40)})`);
126
+ continue;
127
+ }
128
+ const keywords = normKeywords(item.keywords, 5);
129
+ if (keywords.length === 0) {
130
+ logger.warn(`[memory] keyword 回填:normKeywords 结果为空,已跳过(id=${record.id})`);
131
+ continue;
132
+ }
133
+ const usageScenario = normUsageScenario(item.usage_scenario, 3);
134
+ out.push({ record, keywords, usageScenario: usageScenario.length > 0 ? usageScenario : undefined });
135
+ }
136
+ return out;
137
+ }
138
+ catch (err) {
139
+ logger.warn(`[memory] keyword 回填块失败(跳过 ${chunk.length} 条,原记录未改): ${err instanceof Error ? err.message : String(err)}`);
140
+ state.failed += chunk.length;
141
+ return [];
142
+ }
143
+ }
@@ -32,6 +32,15 @@ export { isCognitiveHall, COGNITIVE_HALLS, type CognitiveHall } from './cognitiv
32
32
  * 非数组或空→ `[]`(缺字段中性,不抛——防单条抽取失败波及整切片重试)。
33
33
  */
34
34
  export declare function normUsageScenario(v: unknown, max?: number): string[];
35
+ /** 关键词检索轴的磁盘保留键(喂 `l1_fts.keywords` 列,读取侧见 `sqlite.ftsKeywordText`)。 */
36
+ export declare const KEYWORDS_METADATA_KEY = "dsh_keywords";
37
+ /**
38
+ * 关键词归一 + 去重(忽略大小写)+ 长度钳 + 上限。**所有 `dsh_keywords` 写回点必须过这里**。
39
+ *
40
+ * 与 `normUsageScenario` 同范式(关键词是自然语言术语/标识符,非 slug,不做字符集校验),
41
+ * 只是上限更紧(≤5)、单条更短。非数组/空→`[]`(缺字段中性,不抛——防单条产出失败波及整批)。
42
+ */
43
+ export declare function normKeywords(v: unknown, max?: number): string[];
35
44
  /**
36
45
  * 读 **Wing(生活域)**。⚠️ 磁盘键是 `metadata.hall`——历史别名,存的其实是 **wing 值**。
37
46
  * 只认词表内的合法 wing(8 角 + general),否则 undefined。
@@ -92,6 +92,37 @@ export function normUsageScenario(v, max = 3) {
92
92
  }
93
93
  return out;
94
94
  }
95
+ /** 关键词检索轴的磁盘保留键(喂 `l1_fts.keywords` 列,读取侧见 `sqlite.ftsKeywordText`)。 */
96
+ export const KEYWORDS_METADATA_KEY = 'dsh_keywords';
97
+ /** 单个关键词最大字符数:按“词”太主观,用字符上限兜住整句泄漏(CJK 复合术语通常 ≤12)。 */
98
+ const KEYWORD_MAX_CHARS = 24;
99
+ /**
100
+ * 关键词归一 + 去重(忽略大小写)+ 长度钳 + 上限。**所有 `dsh_keywords` 写回点必须过这里**。
101
+ *
102
+ * 与 `normUsageScenario` 同范式(关键词是自然语言术语/标识符,非 slug,不做字符集校验),
103
+ * 只是上限更紧(≤5)、单条更短。非数组/空→`[]`(缺字段中性,不抛——防单条产出失败波及整批)。
104
+ */
105
+ export function normKeywords(v, max = 5) {
106
+ if (!Array.isArray(v) || max <= 0)
107
+ return [];
108
+ const out = [];
109
+ const seen = new Set();
110
+ for (const raw of v) {
111
+ if (typeof raw !== 'string')
112
+ continue;
113
+ const t = raw.trim();
114
+ if (!t)
115
+ continue;
116
+ const key = t.toLowerCase();
117
+ if (seen.has(key))
118
+ continue;
119
+ seen.add(key);
120
+ out.push(t.slice(0, KEYWORD_MAX_CHARS));
121
+ if (out.length >= max)
122
+ break;
123
+ }
124
+ return out;
125
+ }
95
126
  /**
96
127
  * 读 **Wing(生活域)**。⚠️ 磁盘键是 `metadata.hall`——历史别名,存的其实是 **wing 值**。
97
128
  * 只认词表内的合法 wing(8 角 + general),否则 undefined。
package/dist/stats.d.ts CHANGED
@@ -177,6 +177,12 @@ export declare function hitToUiRecord(r: {
177
177
  metadata?: Record<string, unknown>;
178
178
  score?: number;
179
179
  family?: string;
180
+ /** 名称(md 投射相):投射文件名取它;无名字 = undefined。 */
181
+ name?: string;
182
+ /** 可见范围(§E):'workspace' 才带归属,其余一律归一 'global'。 */
183
+ scope?: string;
184
+ /** 归属工作区路径(仅 scope='workspace' 时非空)。 */
185
+ workspaceId?: string;
180
186
  /** 退场判据:`valid_to` 闭合即已退场(软删)。由 `listL1`/`getByIds` 透传。 */
181
187
  validTo?: number;
182
188
  }, extra?: {
package/dist/stats.js CHANGED
@@ -33,6 +33,7 @@ import { isRoomSlug } from './metadata-validators.js';
33
33
  import { InProcMemoryBackend } from './store/memory-backend.js';
34
34
  import { isWingCorner } from './store/session-modes.js';
35
35
  import { startWingBackfill } from './wing-backfill.js';
36
+ import { startKeywordBackfill, keywordBackfillState } from './keyword-backfill.js';
36
37
  import { errDetail } from './util/filelog.js';
37
38
  import { snapshotTokenCost } from './token-cost.js';
38
39
  import { computeMaturity } from './util/maturity.js';
@@ -70,6 +71,7 @@ export const MEMORY_ENDPOINTS = [
70
71
  'dsh-memory/room-register',
71
72
  'dsh-memory/room-admin',
72
73
  'dsh-memory/wing-backfill',
74
+ 'dsh-memory/keyword-backfill',
73
75
  'dsh-memory/session-stats',
74
76
  'dsh-memory/settings-get',
75
77
  'dsh-memory/settings-set',
@@ -1140,6 +1142,19 @@ export async function handleEndpoint(endpoint, payload, deps) {
1140
1142
  });
1141
1143
  return r;
1142
1144
  }
1145
+ case 'dsh-memory/keyword-backfill': {
1146
+ // 门控关:不启动,如实报 enabled=false(面板据此提示“先开关键词回填开关”)。
1147
+ if (!cfg.keyword?.enabled)
1148
+ return { started: false, running: false, enabled: false, updated: 0, failed: 0 };
1149
+ const r = startKeywordBackfill({
1150
+ ctx: deps.ctx,
1151
+ cfg: deps.cfg,
1152
+ backend: stores.backend ?? new InProcMemoryBackend(stores.l1),
1153
+ logger: deps.logger,
1154
+ });
1155
+ const st = keywordBackfillState();
1156
+ return { started: r.started, running: r.running, enabled: true, updated: st.updated, failed: st.failed };
1157
+ }
1143
1158
  // ── 会话级统计(悬浮卡信息区;热路径端点,见 SessionInfoSource 的零 I/O 硬规则) ──
1144
1159
  case 'dsh-memory/session-stats': {
1145
1160
  if (!sessionInfo)
@@ -2226,6 +2241,16 @@ export function hitToUiRecord(r, extra) {
2226
2241
  }),
2227
2242
  // G1 使用场景:从命名空间保留键读出(功能关/无键 → undefined)。
2228
2243
  usageScenario: Array.isArray(r.metadata?.dsh_usage_scenario) ? r.metadata?.dsh_usage_scenario : undefined,
2244
+ // 投射身份三项(N1):名称 / 可见范围 / 归属工作区。面板靠它们核对“这条记忆
2245
+ // 对应哪个投射文件、对谁可见”——这三个值此前从不进 UiRecord,于是面板上
2246
+ // 无法把记忆与投射文件对上。纯投影,不从存储侧另算。
2247
+ name: r.name ?? null,
2248
+ scope: r.scope === 'workspace' ? 'workspace' : 'global',
2249
+ workspaceId: r.scope === 'workspace' ? (r.workspaceId ?? '') || null : null,
2250
+ // 关键词(保留字段,用于 BM25):与 usageScenario 同范式,从保留键读出(无键 → undefined)。
2251
+ keywords: Array.isArray(r.metadata?.dsh_keywords)
2252
+ ? (r.metadata?.dsh_keywords).filter((k) => typeof k === 'string' && k !== '')
2253
+ : undefined,
2229
2254
  // 人给相关度均值:读时从 sidecar 派生(不进存储/快照);未打分 → 字段缺席。
2230
2255
  userRelevance: extra?.userRelevance ?? undefined,
2231
2256
  };
@@ -127,6 +127,12 @@ export declare class L1Store {
127
127
  * 返回 false 表示 id 不存在或写入失败——调用方须记账,不许静默。
128
128
  */
129
129
  patchMetadata(id: string, metadata: Record<string, unknown>): boolean;
130
+ /**
131
+ * 写 metadata_json + 顺带重索引 `l1_fts`(关键词回填专用)。
132
+ * 与 `patchMetadata` 的区分:后者只改主表(适合 tags/room/relabel 这类不进 fts 的元数据);
133
+ * 本方法额外重跑该行的 fts 插入,让新写的 `dsh_keywords` 立刻进 BM25 关键词轴。
134
+ */
135
+ patchMetadataAndReindex(id: string, metadata: Record<string, unknown>): boolean;
130
136
  /** repo 归属修补(ADR-0015 T3.3;人工消歧用)。CAS:expectRepoKeyName 非空时要求当前值匹配。 */
131
137
  patchRepoKey(id: string, patch: {
132
138
  repoKeyName?: string;
package/dist/store/l1.js CHANGED
@@ -156,6 +156,14 @@ export class L1Store {
156
156
  patchMetadata(id, metadata) {
157
157
  return this.db.patchL1Metadata(id, metadata);
158
158
  }
159
+ /**
160
+ * 写 metadata_json + 顺带重索引 `l1_fts`(关键词回填专用)。
161
+ * 与 `patchMetadata` 的区分:后者只改主表(适合 tags/room/relabel 这类不进 fts 的元数据);
162
+ * 本方法额外重跑该行的 fts 插入,让新写的 `dsh_keywords` 立刻进 BM25 关键词轴。
163
+ */
164
+ patchMetadataAndReindex(id, metadata) {
165
+ return this.db.patchL1MetadataAndReindex(id, metadata);
166
+ }
159
167
  /** repo 归属修补(ADR-0015 T3.3;人工消歧用)。CAS:expectRepoKeyName 非空时要求当前值匹配。 */
160
168
  patchRepoKey(id, patch, expectRepoKeyName) {
161
169
  const changed = this.db.patchRepoKey(id, patch, expectRepoKeyName);
@@ -29,6 +29,7 @@ export declare class WorkerMemoryBackend implements MemoryBackend {
29
29
  getByIds(ids: string[]): Promise<MemoryRecord[]>;
30
30
  list(opts: L1ListOpts): Promise<L1ListResult>;
31
31
  patchMetadata(id: string, metadata: Record<string, unknown>): Promise<boolean>;
32
+ patchMetadataAndReindex(id: string, metadata: Record<string, unknown>): Promise<boolean>;
32
33
  size(): Promise<number>;
33
34
  dispose(): Promise<void>;
34
35
  }
@@ -100,6 +100,9 @@ export class WorkerMemoryBackend {
100
100
  async patchMetadata(id, metadata) {
101
101
  return this.call('patchMetadata', [id, metadata]);
102
102
  }
103
+ async patchMetadataAndReindex(id, metadata) {
104
+ return this.call('patchMetadataAndReindex', [id, metadata]);
105
+ }
103
106
  async size() {
104
107
  return this.call('size', []);
105
108
  }
@@ -50,6 +50,8 @@ export interface MemoryBackend {
50
50
  list(opts: L1ListOpts): Promise<L1ListResult>;
51
51
  /** **只**更新 metadata_json(不动 content,不重算嵌入)。 */
52
52
  patchMetadata(id: string, metadata: Record<string, unknown>): Promise<boolean>;
53
+ /** 写 metadata_json + 重索引该行的 `l1_fts`(关键词回填专用;新 `dsh_keywords` 要立刻进 BM25 轴)。 */
54
+ patchMetadataAndReindex(id: string, metadata: Record<string, unknown>): Promise<boolean>;
53
55
  /** L1 记录总数(巡检进度分母)。 */
54
56
  size(): Promise<number>;
55
57
  /** 释放后端(worker 实现会 terminate 线程)。 */
@@ -68,6 +70,7 @@ export declare class InProcMemoryBackend implements MemoryBackend {
68
70
  getByIds(ids: string[]): Promise<MemoryRecord[]>;
69
71
  list(opts: L1ListOpts): Promise<L1ListResult>;
70
72
  patchMetadata(id: string, metadata: Record<string, unknown>): Promise<boolean>;
73
+ patchMetadataAndReindex(id: string, metadata: Record<string, unknown>): Promise<boolean>;
71
74
  size(): Promise<number>;
72
75
  dispose(): Promise<void>;
73
76
  }
@@ -21,6 +21,9 @@ export class InProcMemoryBackend {
21
21
  async patchMetadata(id, metadata) {
22
22
  return this.l1.patchMetadata(id, metadata);
23
23
  }
24
+ async patchMetadataAndReindex(id, metadata) {
25
+ return this.l1.patchMetadataAndReindex(id, metadata);
26
+ }
24
27
  async size() {
25
28
  return this.l1.size;
26
29
  }
@@ -12,7 +12,7 @@
12
12
  * 「文件存在性 + 当前后缀 + 指纹一致性」三项检查重算一遍,与退场路径无关。
13
13
  *
14
14
  * 点火已由 `L1Store` 的汇流口接管(`store/memory-md-sync.ts`),本模块只供收敛器与
15
- * 单条凭证两个原件——曾经在这里的 `makeSuffixSync` 点火器已退役,不留第二事实源。
15
+ * 单条凭证两个原件——曾经住在这里的旧「后缀点火器工厂」已退役,不留第二事实源。
16
16
  *
17
17
  * 单点失效联动(`disableProjectionFile`)的三种落点:
18
18
  * - 盘上有 `.memory.md`(**不再要求有 workspace**:无归属那条也要改,它的文件本来就在单根)→ **改名**成 `.memory.md.disable`;
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * 一句话口径:**投射文件形状是库状态的函数,不是任何调用点的事件**(事件只是重算契机)。
16
16
  *
17
- * 四条契约(前三条与 `makeSuffixSync` 时期一致,收口没有放宽):
17
+ * 四条契约(前三条与收口前一致,收口没有放宽):
18
18
  * 1. **不阻塞库**:钩子同步返回,文件侧在后台跑;磁盘满不能变成「记忆删不掉」。
19
19
  * 2. **绝不抛**:异常一律收敛成 warn(与 `disableProjectionFile` 同纪律)。
20
20
  * 3. **现读配置与账本**:`memExport` 是 volatile,快照会让「事后打开旋钮」一直到下次启动
@@ -60,6 +60,9 @@ else {
60
60
  case 'patchMetadata':
61
61
  reply(id, true, handle.patchL1Metadata(String(args[0]), args[1]));
62
62
  return;
63
+ case 'patchMetadataAndReindex':
64
+ reply(id, true, handle.patchL1MetadataAndReindex(String(args[0]), args[1]));
65
+ return;
63
66
  case 'size':
64
67
  reply(id, true, handle.countL1());
65
68
  return;
@@ -305,6 +305,17 @@ export declare class MemoryDb {
305
305
  /** 打标作业完成/失败(失败保留行供诊断;重算由下一次触发再入队)。 */
306
306
  finishSceneRecluster(jobId: string, ok: boolean): void;
307
307
  patchL1Metadata(id: string, metadata: Record<string, unknown>): boolean;
308
+ /**
309
+ * 更新 metadata_json 后**顺带重索引该行的 `l1_fts`**(关键词回填专用)。
310
+ *
311
+ * `patchL1Metadata` 本身不碰 fts——多数写回点(tags / room / relabel)不需要、也不想付这个代价;
312
+ * 但关键词轴若只写元数据不重索引,`ftsKeywordText` 读不到新 `dsh_keywords`,那条 BM25 轴
313
+ * 对存量记录永远空。故把「写元数据 + 重索引」焊在一个方法里,杜绝“写了关键词但检索看不到”。
314
+ *
315
+ * 退场/不存在的行:只从 fts 删除(幂等),不插入。fts 不可用时降级为纯 `patchL1Metadata`。
316
+ * 参数列表与 `stmtL1FtsInsert` / `backfillL1Fts` 逐位对齐(同三处列清单,漏一列静默丢索引)。
317
+ */
318
+ patchL1MetadataAndReindex(id: string, metadata: Record<string, unknown>): boolean;
308
319
  /** 全量读取(调试/迁移/重嵌入用;检索请走 FTS/向量)。 */
309
320
  getAllL1(): MemoryRecord[];
310
321
  getL1ByIds(ids: string[]): MemoryRecord[];
@@ -561,10 +561,10 @@ export class MemoryDb {
561
561
  const savedStamp = this.readMetaString('fts_tokenizer') ?? 'bigram-v1';
562
562
  const tokenizerChanged = savedStamp !== wantStamp;
563
563
  let ftsRebuilt = false;
564
- if (this.tableExists('l1_fts') && (!this.hasColumn('l1_fts', 'family') || !this.hasColumn('l1_fts', 'scope') || tokenizerChanged)) {
564
+ if (this.tableExists('l1_fts') && (!this.hasColumn('l1_fts', 'family') || !this.hasColumn('l1_fts', 'scope') || !this.hasColumn('l1_fts', 'keywords') || tokenizerChanged)) {
565
565
  this.db.exec('DROP TABLE l1_fts');
566
566
  ftsRebuilt = true;
567
- this.logger?.info(`${TAG} l1_fts 缺 family/scope 列或分词器已变更(${savedStamp} → ${wantStamp}),重建全文索引`);
567
+ this.logger?.info(`${TAG} l1_fts 缺 family/scope/keywords 列或分词器已变更(${savedStamp} → ${wantStamp}),重建全文索引`);
568
568
  }
569
569
  let l0FtsRebuilt = false;
570
570
  if (this.tableExists('l0_fts') && tokenizerChanged) {
@@ -574,6 +574,7 @@ export class MemoryDb {
574
574
  }
575
575
  this.db.exec(`
576
576
  CREATE VIRTUAL TABLE IF NOT EXISTS l1_fts USING fts5(
577
+ keywords,
577
578
  content,
578
579
  content_original UNINDEXED,
579
580
  record_id UNINDEXED,
@@ -603,10 +604,10 @@ export class MemoryDb {
603
604
  )
604
605
  `);
605
606
  this.stmtL1FtsInsert = this.db.prepare(`
606
- INSERT INTO l1_fts (content, content_original, record_id, type, priority, scene_name,
607
+ INSERT INTO l1_fts (keywords, content, content_original, record_id, type, priority, scene_name,
607
608
  session_id, version, timestamp_str, timestamp_start, timestamp_end, metadata_json, family,
608
609
  scope, workspace_id)
609
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
610
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
610
611
  `);
611
612
  this.stmtL1FtsDelete = this.db.prepare('DELETE FROM l1_fts WHERE record_id = ?');
612
613
  // §E 可见范围过滤的**唯一缝**。设计要点:
@@ -783,7 +784,7 @@ export class MemoryDb {
783
784
  WHERE NOT (${retiredL1Sql()})`);
784
785
  for (const r of stmt.iterate()) {
785
786
  try {
786
- this.stmtL1FtsInsert.run(tokenizeForFts(String(r.content ?? '')), String(r.content ?? ''), String(r.record_id ?? ''), String(r.type ?? ''), Number(r.priority ?? 50), String(r.scene_name ?? ''), String(r.session_id ?? 'default'), Number(r.version ?? 0), String(r.timestamp_str ?? ''), String(r.timestamp_start ?? ''), String(r.timestamp_end ?? ''), String(r.metadata_json ?? '{}'), String(r.family ?? 'chat'),
787
+ this.stmtL1FtsInsert.run(tokenizeForFts(ftsKeywordText(parseMetadataJson(String(r.metadata_json ?? '{}')))), tokenizeForFts(String(r.content ?? '')), String(r.content ?? ''), String(r.record_id ?? ''), String(r.type ?? ''), Number(r.priority ?? 50), String(r.scene_name ?? ''), String(r.session_id ?? 'default'), Number(r.version ?? 0), String(r.timestamp_str ?? ''), String(r.timestamp_start ?? ''), String(r.timestamp_end ?? ''), String(r.metadata_json ?? '{}'), String(r.family ?? 'chat'),
787
788
  // §E:回灌必须带上可见范围——漏掉这两列等于**每次 FTS 重建都把隔离抹平**
788
789
  // (所有行回落 'global',跨工作区记忆瞬间互相可见),且没有任何报错。
789
790
  normScope(r.scope), String(r.workspace_id ?? ''));
@@ -1011,7 +1012,7 @@ export class MemoryDb {
1011
1012
  if (ftsExisted || retiredNow)
1012
1013
  this.stmtL1FtsDelete.run(record.id);
1013
1014
  if (!retiredNow) {
1014
- this.stmtL1FtsInsert.run(tokenizeForFts(record.content), record.content, record.id, type, priority, sceneName, record.sessionId ?? 'default', record.version ?? 0, ts.str, ts.start, ts.end, JSON.stringify(record.metadata ?? {}), family, scope, workspaceId);
1015
+ this.stmtL1FtsInsert.run(tokenizeForFts(ftsKeywordText(record.metadata)), tokenizeForFts(record.content), record.content, record.id, type, priority, sceneName, record.sessionId ?? 'default', record.version ?? 0, ts.str, ts.start, ts.end, JSON.stringify(record.metadata ?? {}), family, scope, workspaceId);
1015
1016
  }
1016
1017
  }
1017
1018
  }
@@ -1508,6 +1509,39 @@ export class MemoryDb {
1508
1509
  return false;
1509
1510
  }
1510
1511
  }
1512
+ /**
1513
+ * 更新 metadata_json 后**顺带重索引该行的 `l1_fts`**(关键词回填专用)。
1514
+ *
1515
+ * `patchL1Metadata` 本身不碰 fts——多数写回点(tags / room / relabel)不需要、也不想付这个代价;
1516
+ * 但关键词轴若只写元数据不重索引,`ftsKeywordText` 读不到新 `dsh_keywords`,那条 BM25 轴
1517
+ * 对存量记录永远空。故把「写元数据 + 重索引」焊在一个方法里,杜绝“写了关键词但检索看不到”。
1518
+ *
1519
+ * 退场/不存在的行:只从 fts 删除(幂等),不插入。fts 不可用时降级为纯 `patchL1Metadata`。
1520
+ * 参数列表与 `stmtL1FtsInsert` / `backfillL1Fts` 逐位对齐(同三处列清单,漏一列静默丢索引)。
1521
+ */
1522
+ patchL1MetadataAndReindex(id, metadata) {
1523
+ const ok = this.patchL1Metadata(id, metadata);
1524
+ if (!ok || this.degraded || !this.ftsAvailable)
1525
+ return ok;
1526
+ try {
1527
+ const r = this.db
1528
+ .prepare(`SELECT record_id, content, type, priority, scene_name, session_id, version,
1529
+ timestamp_str, timestamp_start, timestamp_end, metadata_json, family,
1530
+ scope, workspace_id FROM l1_records
1531
+ WHERE record_id = ? AND NOT (${retiredL1Sql()})`)
1532
+ .get(id);
1533
+ // 退场/缺失行:只确保从 fts 剔除(幂等),不插入;活跃行:删旧行再按当前(含新关键词)元数据重插。
1534
+ this.stmtL1FtsDelete.run(id);
1535
+ if (r) {
1536
+ this.stmtL1FtsInsert.run(tokenizeForFts(ftsKeywordText(parseMetadataJson(String(r.metadata_json ?? '{}')))), tokenizeForFts(String(r.content ?? '')), String(r.content ?? ''), String(r.record_id ?? ''), String(r.type ?? ''), Number(r.priority ?? 50), String(r.scene_name ?? ''), String(r.session_id ?? 'default'), Number(r.version ?? 0), String(r.timestamp_str ?? ''), String(r.timestamp_start ?? ''), String(r.timestamp_end ?? ''), String(r.metadata_json ?? '{}'), String(r.family ?? 'chat'), normScope(r.scope), String(r.workspace_id ?? ''));
1537
+ }
1538
+ }
1539
+ catch (err) {
1540
+ // 重索引失败不回滚元数据写(关键词已落主表);下次全量 FTS 重建会对齐,并留痕。
1541
+ this.logger?.warn(`${TAG} 关键词重索引失败(id=${id},元数据已写、fts 待重建对齐): ${err instanceof Error ? err.message : String(err)}`);
1542
+ }
1543
+ return ok;
1544
+ }
1511
1545
  /** 全量读取(调试/迁移/重嵌入用;检索请走 FTS/向量)。 */
1512
1546
  getAllL1() {
1513
1547
  if (this.degraded)
@@ -2598,6 +2632,23 @@ export class MemoryDb {
2598
2632
  }
2599
2633
  }
2600
2634
  }
2635
+ /**
2636
+ * 关键词轴的索引文本(保留字段 `metadata.dsh_keywords`)。
2637
+ *
2638
+ * 为什么是**独立列**而不是并进 `content`:关键词是一条**独立的检索轴**——它要么
2639
+ * 精确命中、要么不命中,与正文里的 BM25 词频不是一回事(并进正文等于让关键词的
2640
+ * 权重被正文长度稀释)。`l1_fts` 因此排成「keywords → content」两列索引:将来
2641
+ * 关键词蒸馏上线时**先命中关键词轴、再回落到全文**,无需再改表结构(FTS5 不能 ALTER)。
2642
+ *
2643
+ * 目前无产出方(抽取/命名尚未产出该键)⇒ 恒为空串:空串不产生任何 token,
2644
+ * 索引与改动前逐字相同(零漂移),所以先接线、后接产线是安全的。
2645
+ */
2646
+ function ftsKeywordText(metadata) {
2647
+ const kw = Array.isArray(metadata?.dsh_keywords)
2648
+ ? metadata.dsh_keywords.filter((k) => typeof k === 'string' && k.trim() !== '')
2649
+ : [];
2650
+ return kw.join('\n');
2651
+ }
2601
2652
  /** 容忍坏 JSON 的 metadata 解析(与 rowToRecord 同口径:解析失败退化为空对象,不抛)。 */
2602
2653
  function parseMetadataJson(raw) {
2603
2654
  try {
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.1",
4
+ "version": "0.25.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 分层蒸馏记忆插件:自动捕获对话、抽取原子记忆、整合场景块、蒸馏核心画像,并在模型步骤前自动召回注入。"