dsh-layered-memory 0.8.4 → 0.8.6

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 (52) hide show
  1. package/README.en.md +43 -13
  2. package/README.md +28 -13
  3. package/assets/img/ui-dark.jpg +0 -0
  4. package/assets/img/ui-light.jpg +0 -0
  5. package/assets/readme/bench-dialog.svg +49 -55
  6. package/dist/bench-control.d.ts +35 -0
  7. package/dist/bench-control.js +16 -0
  8. package/dist/client.js +173 -0
  9. package/dist/config.d.ts +12 -0
  10. package/dist/config.js +4 -0
  11. package/dist/embedding-worker.cjs +176 -0
  12. package/dist/hooks/recall.d.ts +28 -1
  13. package/dist/hooks/recall.js +72 -13
  14. package/dist/index.d.ts +6 -0
  15. package/dist/index.js +18 -3
  16. package/dist/llm-usage.d.ts +27 -0
  17. package/dist/llm-usage.js +39 -0
  18. package/dist/llm.d.ts +3 -0
  19. package/dist/llm.js +6 -0
  20. package/dist/pipeline/l1.js +4 -2
  21. package/dist/pipeline/l2.js +1 -0
  22. package/dist/pipeline/l3.js +1 -0
  23. package/dist/pipeline/runner.d.ts +21 -0
  24. package/dist/pipeline/runner.js +83 -4
  25. package/dist/pipeline/trigger.d.ts +2 -0
  26. package/dist/pipeline/trigger.js +11 -0
  27. package/dist/prompts/l1-extraction.d.ts +7 -1
  28. package/dist/prompts/l1-extraction.js +12 -3
  29. package/dist/stats.d.ts +27 -1
  30. package/dist/stats.js +37 -2
  31. package/dist/store/embedding-source.d.ts +2 -1
  32. package/dist/store/embedding-source.js +7 -2
  33. package/dist/store/embedding.d.ts +2 -1
  34. package/dist/store/l0.d.ts +2 -0
  35. package/dist/store/l0.js +14 -4
  36. package/dist/store/l1.d.ts +11 -1
  37. package/dist/store/l1.js +26 -6
  38. package/dist/store/local-embedding.d.ts +69 -46
  39. package/dist/store/local-embedding.js +179 -75
  40. package/dist/store/recall-dedupe.d.ts +26 -0
  41. package/dist/store/recall-dedupe.js +138 -0
  42. package/dist/store/runtime-installer.d.ts +0 -2
  43. package/dist/store/runtime-installer.js +0 -6
  44. package/dist/store/search-utils.d.ts +17 -0
  45. package/dist/store/search-utils.js +29 -0
  46. package/dist/store/sqlite.d.ts +2 -0
  47. package/dist/store/sqlite.js +37 -9
  48. package/dist/types.d.ts +8 -0
  49. package/dist/types.js +8 -0
  50. package/dist/util/recall-budget.d.ts +2 -2
  51. package/dist/util/recall-budget.js +2 -2
  52. package/package.json +1 -1
@@ -291,7 +291,7 @@ metadata 字段说明:
291
291
 
292
292
  请严格按上述 JSON 数组格式输出,不要输出任何额外的 Markdown 代码块修饰符(如 \`\`\`json)或解释文本。`;
293
293
  export const EXTRACT_ALL_MEMORIES_SYSTEM_PROMPT = `你是专业的"情境切分与记忆提取专家"。
294
- 你的任务是分析用户的对话,判断情境切换,并从中提取结构化的核心记忆。对话可能同时包含个人生活与工作内容:个人内容提取为个人记忆(persona/episodic/instruction),工作内容提取为团队共享工作记忆(work_fact/work_task/work_method/work_artifact),互不排斥、按内容归族。
294
+ 你的任务是分析用户的对话,判断情境切换,并从中提取结构化的核心记忆。对话可能同时包含个人生活与工作内容:个人内容提取为个人记忆(persona/episodic/instruction),工作内容提取为团队共享工作记忆(work_fact/work_task/work_method/work_artifact),互不排斥、**每条记忆显式输出 family 字段标注归族**。
295
295
 
296
296
  **输出语言**:所有自由文本字段(\`scene_name\`、memory \`content\`)使用与待提取消息主导语言相同的语言;JSON 字段名、枚举值、ISO 时间戳保持英文。
297
297
 
@@ -318,7 +318,14 @@ export const EXTRACT_ALL_MEMORIES_SYSTEM_PROMPT = `你是专业的"情境切分
318
318
 
319
319
  【支持提取的七类记忆】(必须严格遵守类型规则)
320
320
 
321
- **个人三类(对话涉及用户个人生活/偏好/对 AI 的要求时):**
321
+ **归族判定(family)——先定族,再选型:**
322
+ 每条记忆都必须显式输出 "family": "chat" 或 "family": "work",判定**只看语境,不看内容形状**:
323
+ - **work**:内容发生在职业语境——项目、团队、系统、业务、客户、同事协作、部署/开发/运维;
324
+ - **chat**:内容发生在用户个人生活语境——家庭、宠物、健康、消费、学习、爱好、个人行程与安排;
325
+ - **形状词(方案/安排/计划/SOP/频率/周期/规则)不决定 family**:个人的喂养计划、健身安排、用药周期、家庭事务流程都是 chat;只有职业团队语境下的方案流程才是 work。
326
+ - **family 决定 type 的可选词表,不许交叉**:family 为 chat 的记忆 type 只能是 persona/episodic/instruction;family 为 work 的记忆 type 只能是 work_fact/work_task/work_method/work_artifact。
327
+
328
+ **个人三类(family: "chat",对话涉及用户个人生活/偏好/对 AI 的要求时):**
322
329
 
323
330
  1. 个性化记忆 (type: "persona")
324
331
  - 定义:用户的稳定属性、偏好、技能、价值观、习惯(如住所、职业、饮食禁忌)。
@@ -327,6 +334,7 @@ export const EXTRACT_ALL_MEMORIES_SYSTEM_PROMPT = `你是专业的"情境切分
327
334
 
328
335
  2. 客观事件记忆 (type: "episodic")
329
336
  - 定义:客观发生的动作、决定、计划或达成结果。绝不包含纯主观感受。
337
+ - **持续生效的个人安排/规则也属此类**:喂养频率、驱虫/疫苗周期、用药安排、定期事务等长期个人计划,按"用户为 [对象] 确定了 [安排]"句式提取为 episodic——即使表述像"方案/周期/规则",只要语境是个人生活就是 chat 族,**不要**因为形状像计划/方法而改用 work_* 类型。
330
338
  - 提取句式:"用户([姓名])在 [最好是精确绝对时间] 于 [地点] [做了某事(可以包含起因、经过、结果)]"。
331
339
  - 时间约束:尽量基于消息的 timestamp 推算绝对时间,如能确定则在 metadata 中输出 activity_start_time 和 activity_end_time(ISO 8601格式)。
332
340
  - 打分 (priority):80-100(重要事件/计划);60-70(一般完整活动);<60(琐碎事项,直接丢弃)。
@@ -336,7 +344,7 @@ export const EXTRACT_ALL_MEMORIES_SYSTEM_PROMPT = `你是专业的"情境切分
336
344
  - 提取句式:"用户要求/希望 AI 以后回答时..."
337
345
  - 打分 (priority):-1(极其严格的全局死命令);90-100(核心行为规则);70-80(重要要求);<70(临时要求,直接丢弃)。
338
346
 
339
- **工作四类(对话涉及项目、任务、团队协作时,默认可在项目团队内共享):**
347
+ **工作四类(family: "work",对话涉及项目、任务、团队协作时,默认可在项目团队内共享):**
340
348
 
341
349
  4. 工作事实 (type: "work_fact")
342
350
  - 定义:关于项目、系统、业务、客户、需求、决策、状态、风险、约束、实验结果的事实性信息。
@@ -379,6 +387,7 @@ export const EXTRACT_ALL_MEMORIES_SYSTEM_PROMPT = `你是专业的"情境切分
379
387
  {
380
388
  "content": "完整、独立的记忆陈述(按对应类型的句式要求)",
381
389
  "type": "persona|episodic|instruction|work_fact|work_task|work_method|work_artifact",
390
+ "family": "chat|work",
382
391
  "priority": 80,
383
392
  "source_message_ids": ["消息ID_1", "消息ID_2"],
384
393
  "metadata": {}
package/dist/stats.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Context } from '@deepseek-ai/cordis';
2
2
  import { type MemoryConfig } from './config.js';
3
+ import { type RecallSessionStats } from './hooks/recall.js';
3
4
  import type { RebuildController } from './pipeline/rebuild.js';
4
5
  import type { LiveSettingsHandle } from './settings.js';
5
6
  import type { L0Store } from './store/l0.js';
@@ -18,6 +19,31 @@ export interface MemoryStatusSource {
18
19
  /** L1 抽取待重试的消息条数。 */
19
20
  pending(): number;
20
21
  }
22
+ /**
23
+ * 会话级统计数据源(悬浮卡信息区;index.ts 注入)。
24
+ * 硬规则:本端点按"打开期间 2~5s 轮询"设计,实现只允许内存注册表读取与
25
+ * 索引化 SQL 点查——禁止任何文件读/目录扫描(scenes.list()/persona.read()
26
+ * 级别的 I/O 会把每次轮询变成数十毫秒的全量读,见 slider-spec 数据策略节)。
27
+ */
28
+ export interface SessionInfoSource {
29
+ /** 召回统计(recall.ts 注册表;未发生检索的会话返回 undefined)。 */
30
+ recallStats(sessionId: string): RecallSessionStats | undefined;
31
+ /** 蒸馏管线会话视图(runner:攒批进度/挂起切片/会话产出)。 */
32
+ runnerView(sessionId: string, mode: string): {
33
+ pendingSlice: number;
34
+ parkedSlices: number;
35
+ threshold: number | null;
36
+ producedRecords: number;
37
+ lastDistillAt: number | null;
38
+ };
39
+ /** L0 该会话已捕获消息数(索引 COUNT)。 */
40
+ l0Count(sessionId: string): Promise<number>;
41
+ /** 检索能力位(hybrid / keyword 降级判定)。 */
42
+ capabilities(): {
43
+ ftsSearch: boolean;
44
+ vectorSearch: boolean;
45
+ };
46
+ }
21
47
  export interface MemoryStats {
22
48
  ok: boolean;
23
49
  dataDir: string;
@@ -50,4 +76,4 @@ export declare function registerMemoryRpc(ctx: Context, cfg: MemoryConfig, store
50
76
  scenes: Record<MemoryFamily, SceneStore>;
51
77
  persona: Record<MemoryFamily, PersonaStore>;
52
78
  state: StateStore;
53
- }, logger: MemoryLogger, status?: MemoryStatusSource, live?: LiveSettingsHandle, modes?: SessionModeStore, dataDir?: string, rebuild?: RebuildController, embedManager?: EmbeddingManager): void;
79
+ }, logger: MemoryLogger, status?: MemoryStatusSource, live?: LiveSettingsHandle, modes?: SessionModeStore, dataDir?: string, rebuild?: RebuildController, embedManager?: EmbeddingManager, sessionInfo?: SessionInfoSource): void;
package/dist/stats.js CHANGED
@@ -11,12 +11,13 @@ import { closeSync, openSync, readSync, statSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { EFFORT_CHOICES, resolveDataDir } from './config.js';
13
13
  import { effectiveCfg } from './pipeline/runner.js';
14
+ import { emptyRecallStats } from './hooks/recall.js';
14
15
  import { decideSendableEffort, LAYER_DEFAULT_BUDGETS, resolveModelEfforts, resolveModelRoute } from './llm.js';
15
16
  import { errDetail } from './util/filelog.js';
16
17
  const require = createRequire(import.meta.url);
17
18
  export const PLUGIN_VERSION = require('../package.json').version;
18
19
  /** 注册状态 RPC(web 侧 connection 服务可选,缺失时跳过,不影响插件主体)。 */
19
- export function registerMemoryRpc(ctx, cfg, stores, logger, status, live, modes, dataDir, rebuild, embedManager) {
20
+ export function registerMemoryRpc(ctx, cfg, stores, logger, status, live, modes, dataDir, rebuild, embedManager, sessionInfo) {
20
21
  /** 当前是否持有一段有效注册(dispose 完成后清空,允许服务重上线时重注册)。 */
21
22
  let holding = false;
22
23
  /** 当前 handle 绑定的 connection 实例(internal/service 第二参;用于识别实例替换)。 */
@@ -43,6 +44,7 @@ export function registerMemoryRpc(ctx, cfg, stores, logger, status, live, modes,
43
44
  logger,
44
45
  rebuild,
45
46
  embedManager,
47
+ sessionInfo,
46
48
  });
47
49
  return { ok: true, value };
48
50
  }
@@ -142,7 +144,7 @@ function expectSessionId(v) {
142
144
  return v;
143
145
  }
144
146
  async function handleEndpoint(endpoint, payload, deps) {
145
- const { cfg, stores, status, live, modes, dataDir, rebuild, embedManager } = deps;
147
+ const { cfg, stores, status, live, modes, dataDir, rebuild, embedManager, sessionInfo } = deps;
146
148
  switch (endpoint) {
147
149
  case 'dsh-memory/stats':
148
150
  return buildStats(cfg, stores, status);
@@ -166,6 +168,39 @@ async function handleEndpoint(endpoint, payload, deps) {
166
168
  deps.logger.info(`[memory] 会话档位设置 session=${sessionId} mode=${p.mode}`);
167
169
  return { sessionId, mode: p.mode };
168
170
  }
171
+ // ── 会话级统计(悬浮卡信息区;热路径端点,见 SessionInfoSource 的零 I/O 硬规则) ──
172
+ case 'dsh-memory/session-stats': {
173
+ if (!sessionInfo)
174
+ return { supported: false };
175
+ const p = (payload ?? {});
176
+ const sessionId = expectSessionId(p.sessionId);
177
+ const mode = modes ? modes.get(sessionId) : 'auto';
178
+ const caps = sessionInfo.capabilities();
179
+ const l0Count = await sessionInfo.l0Count(sessionId);
180
+ const s = live?.get();
181
+ const recallOn = cfg.recall.enabled && (s?.recall ?? true) && mode !== 'off';
182
+ const view = sessionInfo.runnerView(sessionId, mode);
183
+ // lastDistillAt 统一转 ISO(与 global.lastExtractAt 口径一致,client 直接 fmtAgo)
184
+ const distillView = { ...view, lastDistillAt: view.lastDistillAt ? new Date(view.lastDistillAt).toISOString() : null };
185
+ const chat = stores.state.forFamily('chat');
186
+ const work = stores.state.forFamily('work');
187
+ const lastAt = Math.max(chat.lastExtractAt, work.lastExtractAt);
188
+ return {
189
+ supported: true,
190
+ sessionId,
191
+ mode,
192
+ defaultMode: modes?.default ?? cfg.family,
193
+ recall: { enabled: recallOn, ...(sessionInfo.recallStats(sessionId) ?? emptyRecallStats()) },
194
+ distill: distillView,
195
+ l0Count,
196
+ retrieval: caps.vectorSearch ? (caps.ftsSearch ? 'hybrid' : 'vector') : caps.ftsSearch ? 'keyword' : 'none',
197
+ global: {
198
+ degraded: status?.degraded() ?? false,
199
+ pendingTotal: status?.pending() ?? 0,
200
+ lastExtractAt: lastAt ? new Date(lastAt).toISOString() : null,
201
+ },
202
+ };
203
+ }
169
204
  case 'dsh-memory/settings-get': {
170
205
  const s = live?.get();
171
206
  const budgets = s?.distillBudgets ?? { extract: 0, dedup: 0, l2: 0, l3: 0 };
@@ -35,7 +35,8 @@ export interface InitialEmbedding {
35
35
  /** 远程档部署上限:静态四件套 + enabled。 */
36
36
  export declare function remoteCeiling(cfg: MemoryConfig): boolean;
37
37
  export declare function resolveInitialEmbedding(cfg: MemoryConfig, sourceStore: EmbeddingSourceStore, downloader: ModelDownloadQueue, makeLocal: (modelId: string) => LocalEmbeddingService | null, logger?: MemoryLogger): Promise<InitialEmbedding>;
38
- /** 本地服务构造工厂(index.ts 的初始解析与 Manager 共用一份实现,防漂移)。 */
38
+ /** 本地服务构造工厂(index.ts 的初始解析与 Manager 共用一份实现,防漂移)。
39
+ * 推理在 worker 线程(见 local-embedding.ts);此处只传 runtime 目录与模型目录。 */
39
40
  export declare function makeLocalServiceFactory(installer: RuntimeInstaller, downloader: ModelDownloadQueue, logger?: MemoryLogger, maxInputChars?: number): (modelId: string) => LocalEmbeddingService | null;
40
41
  export type ApplyPhase = 'idle' | 'installing-runtime' | 'warming' | 'switching' | 'reindexing' | 'done' | 'error';
41
42
  export interface ReindexProgressState {
@@ -101,13 +101,18 @@ export async function resolveInitialEmbedding(cfg, sourceStore, downloader, make
101
101
  });
102
102
  return { svc, dims: cfg.embedding.dimensions, providerInfo: svc.getProviderInfo() };
103
103
  }
104
- /** 本地服务构造工厂(index.ts 的初始解析与 Manager 共用一份实现,防漂移)。 */
104
+ /** 本地服务构造工厂(index.ts 的初始解析与 Manager 共用一份实现,防漂移)。
105
+ * 推理在 worker 线程(见 local-embedding.ts);此处只传 runtime 目录与模型目录。 */
105
106
  export function makeLocalServiceFactory(installer, downloader, logger, maxInputChars) {
106
107
  return (modelId) => {
107
108
  const entry = catalogById(modelId);
108
109
  if (!entry)
109
110
  return null;
110
- return new LocalEmbeddingService(entry, downloader.modelsDir(entry.id), () => Promise.resolve(installer.resolveModule()), logger, maxInputChars);
111
+ return new LocalEmbeddingService(entry, downloader.modelsDir(entry.id), {
112
+ runtimeDir: installer.runtimeDir,
113
+ logger,
114
+ maxInputChars,
115
+ });
111
116
  };
112
117
  }
113
118
  export class EmbeddingManager {
@@ -11,7 +11,8 @@ export interface EmbeddingProviderInfo {
11
11
  dimensions: number;
12
12
  }
13
13
  /** 单次嵌入调用的可选参数:timeoutMs 只允许缩短服务配置的超时(内层钳制),
14
- * 永不放大——召回路径用它给 FTS 降级留时间(规格 A 节)。本地实现可忽略。 */
14
+ * 永不放大——召回路径用它给 FTS 降级留时间(规格 A 节)。本地实现经 worker
15
+ * 代理以 Promise.race 钳制(迟到回复丢弃,推理在 worker 线程无法真正取消)。 */
15
16
  export interface EmbedCallOptions {
16
17
  timeoutMs?: number;
17
18
  }
@@ -15,6 +15,8 @@ export declare class L0Store {
15
15
  append(sessionId: string, messages: ConversationMessage[]): Promise<void>;
16
16
  /** 今日已捕获消息数(SQL 计数,不再读整文件)。 */
17
17
  countToday(): Promise<number>;
18
+ /** 该会话累计已捕获消息数(session-stats 数据源;索引 COUNT)。 */
19
+ countBySession(sessionId: string): Promise<number>;
18
20
  /** 该会话最近 n 条消息(时间升序;蒸馏背景参考用,按会话现查——ADR-0003)。 */
19
21
  recentBySession(sessionId: string, limit: number): Promise<ConversationMessage[]>;
20
22
  /** 检索:FTS + 向量 hybrid(RRF 融合),返回按相关性排序的消息。 */
package/dist/store/l0.js CHANGED
@@ -42,10 +42,16 @@ export class L0Store {
42
42
  if (!f.endsWith('.jsonl'))
43
43
  continue;
44
44
  const records = await readJsonl(path.join(this.legacyDir, f));
45
- if (records.length > 0) {
46
- total += records.length;
47
- if (this.db.upsertL0Batch(records))
48
- imported += records.length;
45
+ // 最小有效性门(同 L1 importLegacy):坏行读取时丢弃,按 valid 数判迁移完成
46
+ const valid = records.filter((r) => r && typeof r.id === 'string' && r.content);
47
+ const badCount = records.length - valid.length;
48
+ if (badCount > 0) {
49
+ this.logger?.warn(`[memory] 旧版 L0 文件 ${f} 丢弃 ${badCount} 条坏行(缺 id/content)`);
50
+ }
51
+ if (valid.length > 0) {
52
+ total += valid.length;
53
+ if (this.db.upsertL0Batch(valid))
54
+ imported += valid.length;
49
55
  }
50
56
  }
51
57
  // 只有全部批次入库成功(或目录为空)才改名,避免数据被改名带走
@@ -104,6 +110,10 @@ export class L0Store {
104
110
  const d = new Date();
105
111
  return this.db.countL0Since(new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString());
106
112
  }
113
+ /** 该会话累计已捕获消息数(session-stats 数据源;索引 COUNT)。 */
114
+ async countBySession(sessionId) {
115
+ return this.db.countL0BySession(sessionId);
116
+ }
107
117
  /** 该会话最近 n 条消息(时间升序;蒸馏背景参考用,按会话现查——ADR-0003)。 */
108
118
  async recentBySession(sessionId, limit) {
109
119
  return this.db.recentL0BySession(sessionId, limit);
@@ -21,7 +21,11 @@ export declare class L1Store {
21
21
  private readonly helper;
22
22
  private embedSvc;
23
23
  private readonly logger?;
24
- constructor(dataDir: string, db: MemoryDb, embed?: EmbeddingService, strategy?: RecallStrategy, logger?: MemoryLogger);
24
+ /** 时效衰减半衰期(天;0=关)。 */
25
+ private readonly decayHalfLifeDays;
26
+ constructor(dataDir: string, db: MemoryDb, embed?: EmbeddingService, strategy?: RecallStrategy, logger?: MemoryLogger,
27
+ /** 时效衰减半衰期(天;0=关)。缺省 30 与 config 默认一致。 */
28
+ decayHalfLifeDays?: number);
25
29
  init(): Promise<void>;
26
30
  /** 旧版单文件 records.jsonl 一次性导入检索库,成功后改名 .imported。 */
27
31
  private importLegacy;
@@ -44,6 +48,12 @@ export declare class L1Store {
44
48
  * 融合完整列表(融合分已归一化 0~1,可直接用于展示/过滤)。
45
49
  */
46
50
  search(query: string, limit: number, opts?: L1SearchOptions): Promise<L1Hit[]>;
51
+ /**
52
+ * 时效衰减加权(#29):三路共用的读路径后处理——阈值过滤之后、截断之前
53
+ * (才能轮转名额,而不只是重排已截断的集合)。updated_at 经主表批量点查
54
+ * 回填(FTS 表无该列;候选池 ≤ limit×3 条主键查询,微秒级)。关闭时零开销。
55
+ */
56
+ private applyDecay;
47
57
  /** 浏览列表(UI 用):无关键词时按更新时间倒序分页。 */
48
58
  list(opts: {
49
59
  type?: string;
package/dist/store/l1.js CHANGED
@@ -11,7 +11,7 @@ import * as path from 'node:path';
11
11
  import { familyForType } from '../types.js';
12
12
  import { EmbedHelper, NoopEmbeddingService } from './embedding.js';
13
13
  import { appendJsonl, dayKey, ensureDir, readJsonl } from './io.js';
14
- import { RRF_K, rrfMerge } from './search-utils.js';
14
+ import { applyDecayWeight, RRF_K, rrfMerge } from './search-utils.js';
15
15
  import { isZeroVector } from './sqlite.js';
16
16
  /** 官方过度召回倍数:候选池 = limit × 3(官方 tool 路径同款)。 */
17
17
  const CANDIDATE_MULTIPLIER = 3;
@@ -23,7 +23,11 @@ export class L1Store {
23
23
  helper;
24
24
  embedSvc;
25
25
  logger;
26
- constructor(dataDir, db, embed = new NoopEmbeddingService(), strategy = 'hybrid', logger) {
26
+ /** 时效衰减半衰期(天;0=关)。 */
27
+ decayHalfLifeDays;
28
+ constructor(dataDir, db, embed = new NoopEmbeddingService(), strategy = 'hybrid', logger,
29
+ /** 时效衰减半衰期(天;0=关)。缺省 30 与 config 默认一致。 */
30
+ decayHalfLifeDays) {
27
31
  this.db = db;
28
32
  this.strategy = strategy;
29
33
  this.recordsDir = path.join(dataDir, 'records');
@@ -31,6 +35,7 @@ export class L1Store {
31
35
  this.embedSvc = embed;
32
36
  this.helper = new EmbedHelper(embed, logger);
33
37
  this.logger = logger;
38
+ this.decayHalfLifeDays = decayHalfLifeDays ?? 30;
34
39
  }
35
40
  async init() {
36
41
  await ensureDir(this.recordsDir);
@@ -142,17 +147,17 @@ export class L1Store {
142
147
  return [];
143
148
  if (strategy === 'keyword') {
144
149
  const fts = this.db.searchL1Fts(query, candidateK, opts?.family);
145
- return this.postProcess(applyFtsThreshold(fts, threshold, limit), opts?.type, limit);
150
+ return this.postProcess(this.applyDecay(applyFtsThreshold(fts, threshold, limit)), opts?.type, limit);
146
151
  }
147
152
  if (strategy === 'embedding') {
148
153
  const vec = await this.helper.query(query, opts?.embeddingTimeoutMs);
149
154
  if (!vec) {
150
155
  // embedding 调用失败:降级 FTS,不阻断
151
156
  const fts = this.db.searchL1Fts(query, candidateK, opts?.family);
152
- return this.postProcess(applyFtsThreshold(fts, threshold, limit), opts?.type, limit);
157
+ return this.postProcess(this.applyDecay(applyFtsThreshold(fts, threshold, limit)), opts?.type, limit);
153
158
  }
154
159
  const vecHits = this.db.searchL1Vector(vec, candidateK, opts?.family);
155
- return this.postProcess(filterScore(vecHits, threshold), opts?.type, limit);
160
+ return this.postProcess(this.applyDecay(filterScore(vecHits, threshold)), opts?.type, limit);
156
161
  }
157
162
  // hybrid(官方语义):双路并行 → 完整列表 RRF 融合(融合前不过滤阈值)
158
163
  // → 融合分归一化:rank1 双列表命中 = 1.0,单列表命中 ≤ 0.5,保持 0~1 语义
@@ -162,7 +167,22 @@ export class L1Store {
162
167
  ]);
163
168
  const vecList = vecRaw ? this.db.searchL1Vector(vecRaw, candidateK, opts?.family) : [];
164
169
  const merged = rrfMerge([ftsList, vecList], (h) => h.id);
165
- return this.postProcess(merged.map(({ rrfScore, ...h }) => ({ ...h, score: normalizeRrf(rrfScore) })), opts?.type, limit);
170
+ return this.postProcess(this.applyDecay(merged.map(({ rrfScore, ...h }) => ({ ...h, score: normalizeRrf(rrfScore) }))), opts?.type, limit);
171
+ }
172
+ /**
173
+ * 时效衰减加权(#29):三路共用的读路径后处理——阈值过滤之后、截断之前
174
+ * (才能轮转名额,而不只是重排已截断的集合)。updated_at 经主表批量点查
175
+ * 回填(FTS 表无该列;候选池 ≤ limit×3 条主键查询,微秒级)。关闭时零开销。
176
+ */
177
+ applyDecay(hits) {
178
+ if (!(this.decayHalfLifeDays > 0) || hits.length === 0)
179
+ return hits;
180
+ const updatedAtById = new Map();
181
+ for (const r of this.db.getL1ByIds(hits.map((h) => h.id))) {
182
+ if (Number.isFinite(r.updatedAt))
183
+ updatedAtById.set(r.id, r.updatedAt);
184
+ }
185
+ return applyDecayWeight(hits, this.decayHalfLifeDays, (h) => updatedAtById.get(h.id));
166
186
  }
167
187
  /** 浏览列表(UI 用):无关键词时按更新时间倒序分页。 */
168
188
  list(opts) {
@@ -1,66 +1,89 @@
1
- /**
2
- * 本地嵌入服务(D1/D6 决策):transformers.js + ONNX,进程内 CPU 推理。
3
- *
4
- * - 懒加载(D6):首次嵌入调用才加载模型(下载完成后的预热也走这里),
5
- * 加载后常驻,close() 释放(嵌入源切走/关闭时调用);
6
- * - 模型从数据目录 models/<id>/ 本地加载(env.allowRemoteModels=false 杜绝联网);
7
- * - loader 可注入(测试缝):不依赖真实 transformers.js/模型文件即可测状态机;
8
- * - 池化方式来自模型目录(BGE 系 CLS / Gemma 系 MEAN),normalize 交给 pipeline
9
- * 内建 L2 归一(与远程路径的 sanitizeAndNormalize 语义一致)。
10
- *
11
- * 注意:真实模型路径的池化正确性(尤其 embeddinggemma 的 mean)属于实现期
12
- * 待验证项——下载预热后应做一次相似度 sanity 检查(见 PR 验证指引)。
13
- */
14
1
  import type { MemoryLogger } from '../types.js';
15
2
  import type { CatalogEntry } from './model-catalog.js';
16
- import type { EmbeddingProviderInfo, EmbeddingService } from './embedding.js';
17
- /** transformers.js feature-extraction pipeline 最小面(结构化注入缝)。 */
18
- export interface ExtractorLike {
19
- (texts: string[], opts: {
20
- pooling: 'cls' | 'mean';
21
- normalize: boolean;
22
- }): Promise<Array<{
23
- data: number[] | Float32Array;
24
- }>>;
25
- dispose?: () => Promise<void> | void;
3
+ import type { EmbedCallOptions, EmbeddingProviderInfo, EmbeddingService } from './embedding.js';
4
+ type LocalState = 'idle' | 'loading' | 'ready' | 'failed' | 'terminated';
5
+ /** worker 启动参数(workerData;构造后不可变)。 */
6
+ export interface EmbedWorkerData {
7
+ runtimeDir: string;
8
+ modelDir: string;
9
+ pooling: 'cls' | 'mean';
10
+ dtype: string;
11
+ maxInputChars: number;
26
12
  }
27
- export interface TransformersModuleLike {
28
- pipeline: (task: string, model: string, opts?: Record<string, unknown>) => Promise<ExtractorLike>;
29
- env?: {
30
- allowLocalModels?: boolean;
31
- allowRemoteModels?: boolean;
32
- };
13
+ /** 主线程 worker 的调用(id 由通道分配)。 */
14
+ export type EmbedWorkerCall = {
15
+ type: 'ping';
16
+ } | {
17
+ type: 'warmup';
18
+ } | {
19
+ type: 'embed';
20
+ texts: string[];
21
+ priority: boolean;
22
+ };
23
+ /** worker → 主线程的应答。 */
24
+ export type EmbedWorkerReply = {
25
+ id: number;
26
+ ok: true;
27
+ type: 'pong';
28
+ } | {
29
+ id: number;
30
+ ok: true;
31
+ type: 'ready';
32
+ } | {
33
+ id: number;
34
+ ok: true;
35
+ type: 'embedded';
36
+ vectors: Float32Array[];
37
+ } | {
38
+ id: number;
39
+ ok: false;
40
+ stage: 'load' | 'infer';
41
+ error: string;
42
+ };
43
+ /** worker 通道抽象(测试缝:smoke 注入假实现验证协议与状态机)。 */
44
+ export interface EmbedWorkerChannel {
45
+ request(call: EmbedWorkerCall): Promise<EmbedWorkerReply>;
46
+ /** 立即终止 worker 并拒绝全部未决请求(close 语义);幂等。 */
47
+ terminate(): void;
48
+ /** worker 意外崩溃通知(此后所有未决请求已被通道拒绝)。 */
49
+ setOnCrash(cb: (error: string) => void): void;
50
+ }
51
+ export interface LocalEmbeddingOptions {
52
+ /** 数据目录 runtime/(worker 据此 createRequire 加载 transformers)。 */
53
+ runtimeDir: string;
54
+ /** worker 资产路径(默认 dist/embedding-worker.cjs;测试可显式指定)。 */
55
+ workerPath?: string;
56
+ /** 通道注入缝(测试用;缺省 spawn 真实 worker)。 */
57
+ channel?: EmbedWorkerChannel;
58
+ logger?: MemoryLogger;
59
+ maxInputChars?: number;
33
60
  }
34
- /** 模块加载器(默认走 RuntimeInstaller.resolveModule;测试注入假模块)。 */
35
- export type ModuleLoader = () => Promise<TransformersModuleLike>;
36
- type LocalState = 'idle' | 'loading' | 'ready' | 'failed' | 'terminated';
37
61
  export declare class LocalEmbeddingService implements EmbeddingService {
38
62
  private state;
39
- private extractor;
40
- private loadPromise;
41
63
  private loadError;
42
- private readonly modelDir;
64
+ private readonly channel;
43
65
  private readonly entry;
44
- private readonly loader;
45
66
  private readonly logger?;
46
- /** 输入截断(与远程路径同源的 embedding.maxInputChars 配置,缺省 5000)。 */
47
- private readonly maxInputChars;
48
- constructor(entry: CatalogEntry, modelDir: string, loader: ModuleLoader, logger?: MemoryLogger, maxInputChars?: number);
67
+ constructor(entry: CatalogEntry, modelDir: string, opts: LocalEmbeddingOptions);
49
68
  getDimensions(): number;
50
69
  getProviderInfo(): EmbeddingProviderInfo;
51
70
  isReady(): boolean;
52
71
  /** 状态(进度展示用)。 */
53
72
  getState(): LocalState;
54
73
  getLoadError(): string | null;
55
- /** 后台预热:下载完成后验证可加载性(幂等;失败态可重试)。 */
74
+ /** 后台预热:启动后让 worker 立即加载模型(幂等;失败态可重试)。 */
56
75
  startWarmup(): void;
57
- /** 等待预热完成(测试用)。 */
76
+ /** 等待模型就绪(warmup 协议;applyChain 的 warming 阶段与测试用)。 */
58
77
  waitForReady(): Promise<void>;
59
- embed(text: string): Promise<Float32Array>;
60
- embedBatch(texts: string[]): Promise<Float32Array[]>;
61
- /** 释放模型(嵌入源切走/关闭时调用;幂等)。terminated 后不可再复用——
62
- * 防止插件卸载/切走后残留的重嵌循环把模型重新加载常驻(内存泄漏)。 */
78
+ embed(text: string, callOpts?: EmbedCallOptions): Promise<Float32Array>;
79
+ embedBatch(texts: string[], callOpts?: EmbedCallOptions): Promise<Float32Array[]>;
80
+ /** 释放 worker 线程与模型(嵌入源切走/关闭时调用;幂等)。terminated 后不可
81
+ * 再复用——防止插件卸载/切走后残留的重嵌循环把模型重新加载常驻(内存泄漏)。 */
63
82
  close(): void;
64
- private ensureLoaded;
83
+ /** 内层钳制(仅缩短):超时放弃等待(迟到回复由通道按 id 丢弃),调用方降级。 */
84
+ private requestWithTimeout;
85
+ /** loading → ready 一次性日志(memory.log 时序可读性:启动到模型就绪的间隔)。 */
86
+ private markReady;
87
+ private applyLoadFailure;
65
88
  }
66
89
  export {};