dsh-layered-memory 0.5.4 → 0.6.1

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.
@@ -1,16 +1,41 @@
1
+ import { resolveDataDir } from '../config.js';
2
+ import { emptyPending, loadPending, PENDING_MODES, pendingPathFor, savePending } from '../store/pending.js';
3
+ import { errDetail } from '../util/filelog.js';
1
4
  import { runExtraction } from './l1.js';
2
5
  import { runSceneConsolidation } from './l2.js';
3
6
  import { runPersona } from './l3.js';
4
- import { errDetail } from '../util/filelog.js';
7
+ /** 选取下一个要执行的任务下标:最早的 live 优先,否则队首(rebuild 分块让位)。 */
8
+ export function pickNextTaskIndex(tasks) {
9
+ for (let i = 0; i < tasks.length; i++) {
10
+ if (tasks[i].kind === 'live')
11
+ return i;
12
+ }
13
+ return 0;
14
+ }
15
+ /**
16
+ * 运行时调参视图:UI 选择器可临时覆盖蒸馏思考档位(空串回退静态 config 默认)。
17
+ * 浅拷贝只覆盖 llm 一层,其余键与原 cfg 共享只读引用;pipeline 全链继续收 cfg,无需感知。
18
+ */
19
+ export function effectiveCfg(cfg, live) {
20
+ const eff = live.get().reasoningEffort;
21
+ return eff ? { ...cfg, llm: { ...cfg.llm, reasoningEffort: eff } } : cfg;
22
+ }
23
+ /** 单桶堆积上限(防无限堆积;重建分块不受限——历史会话需全量入桶蒸馏)。 */
24
+ const PENDING_BUCKET_CAP = 200;
25
+ /** 启动补跑延迟:避开宿主启动期忙乱。 */
26
+ const STARTUP_RETRY_DELAY_MS = 20_000;
5
27
  export class MemoryRunner {
6
28
  ctx;
7
29
  cfg;
8
30
  stores;
9
31
  logger;
10
32
  live;
11
- queue = Promise.resolve();
12
- pending = { auto: [], chat: [], work: [] };
33
+ tasks = [];
34
+ draining = false;
35
+ pending = emptyPending();
36
+ pendingFile;
13
37
  background = [];
38
+ /** 分族 checkpoint(init 后可用;重建收尾也从这里读活引用)。 */
14
39
  states;
15
40
  afterRun;
16
41
  constructor(ctx, cfg, stores, logger, live) {
@@ -19,6 +44,7 @@ export class MemoryRunner {
19
44
  this.stores = stores;
20
45
  this.logger = logger;
21
46
  this.live = live;
47
+ this.pendingFile = pendingPathFor(resolveDataDir(cfg));
22
48
  }
23
49
  async init() {
24
50
  await this.stores.state.load();
@@ -30,6 +56,33 @@ export class MemoryRunner {
30
56
  this.logger.info('[memory] state.json 已迁移为 v2 分族格式(旧数据归 chat 桶)');
31
57
  await this.stores.state.save();
32
58
  }
59
+ // 恢复未蒸馏缓冲(上次进程退出前未蒸馏的消息,含失败待重试与攒阈值中途的)
60
+ try {
61
+ const loaded = await loadPending(this.pendingFile, this.logger);
62
+ for (const key of PENDING_MODES) {
63
+ if (loaded[key].length > PENDING_BUCKET_CAP)
64
+ loaded[key] = loaded[key].slice(-PENDING_BUCKET_CAP);
65
+ }
66
+ this.pending = loaded;
67
+ if (this.pendingCount > 0) {
68
+ this.logger.info(`[memory] 未蒸馏缓冲已恢复 ${this.pendingCount} 条(auto=${this.pending.auto.length}/chat=${this.pending.chat.length}/work=${this.pending.work.length}),${STARTUP_RETRY_DELAY_MS / 1000}s 后自动补跑`);
69
+ this.scheduleStartupRetry();
70
+ }
71
+ }
72
+ catch (err) {
73
+ this.logger.warn(`[memory] 未蒸馏缓冲恢复失败(空桶起步): ${errDetail(err)}`);
74
+ }
75
+ }
76
+ /** 启动补跑:对每个非空桶入队一次蒸馏尝试(受 live 开关与阈值约束,失败不无限重试)。 */
77
+ scheduleStartupRetry() {
78
+ const modes = PENDING_MODES.filter((m) => this.pending[m].length > 0);
79
+ this.ctx.effect(() => {
80
+ const timer = setTimeout(() => {
81
+ for (const mode of modes)
82
+ this.enqueue('startup-retry', [], mode);
83
+ }, STARTUP_RETRY_DELAY_MS);
84
+ return () => clearTimeout(timer);
85
+ });
33
86
  }
34
87
  /** L1 抽取待重试的消息条数(状态面板用)。 */
35
88
  get pendingCount() {
@@ -39,41 +92,82 @@ export class MemoryRunner {
39
92
  setAfterRun(fn) {
40
93
  this.afterRun = fn;
41
94
  }
42
- /** 一轮对话结束后入队(L0 落盘 + 蒸馏触发判定)。 */
95
+ /** 一轮对话结束后入队(L0 落盘由 capture 在 turn/end 即时完成,不排蒸馏队列)。 */
43
96
  enqueue(sessionId, messages, mode) {
44
- this.queue = this.queue
45
- .then(() => this.runTurn(sessionId, messages, mode))
46
- .catch((err) => {
47
- this.logger.warn(`[memory] 管线失败(已兜底): ${errDetail(err)}`);
48
- });
97
+ this.pushTask({ kind: 'live', run: () => this.runTurn(sessionId, messages, mode) });
98
+ }
99
+ /** 重建任务入队(低优先级:让位于正常轮次;由 RebuildController 分块驱动)。 */
100
+ enqueueRebuildTask(run) {
101
+ this.pushTask({ kind: 'rebuild', run });
102
+ }
103
+ /** 重建蒸馏轮:统一 auto 档,不受缓冲 200 上限(历史会话全量入桶,由 char 预算分块)。 */
104
+ runRebuildTurn(sessionId, messages) {
105
+ return this.runTurn(sessionId, messages, 'auto', { noBufferCap: true });
49
106
  }
50
- async runTurn(sessionId, messages, mode) {
107
+ pushTask(task) {
108
+ this.tasks.push(task);
109
+ void this.drain();
110
+ }
111
+ async drain() {
112
+ if (this.draining)
113
+ return;
114
+ this.draining = true;
115
+ try {
116
+ while (this.tasks.length > 0) {
117
+ const [task] = this.tasks.splice(pickNextTaskIndex(this.tasks), 1);
118
+ try {
119
+ await task.run();
120
+ }
121
+ catch (err) {
122
+ this.logger.warn(`[memory] 管线失败(已兜底): ${errDetail(err)}`);
123
+ }
124
+ }
125
+ }
126
+ finally {
127
+ this.draining = false;
128
+ }
129
+ }
130
+ /** 缓冲落盘(每次蒸馏尝试后调用;失败只告警不阻断管线)。 */
131
+ async persistPending() {
132
+ try {
133
+ await savePending(this.pendingFile, this.pending);
134
+ }
135
+ catch (err) {
136
+ this.logger.warn(`[memory] 未蒸馏缓冲落盘失败: ${errDetail(err)}`);
137
+ }
138
+ }
139
+ async runTurn(sessionId, messages, mode, opts) {
51
140
  const turnStart = Date.now();
52
141
  this.logger.info(`[memory] 蒸馏管线开始(session=${sessionId},mode=${mode},本轮 ${messages.length} 条消息,待重试 ${this.pendingCount} 条)`);
53
142
  // ── L0:原始对话已由 capture 在 turn/end 即时落盘(不排蒸馏队列,防慢 LLM 阻塞/退出丢消息) ──
143
+ const cfg = effectiveCfg(this.cfg, this.live);
54
144
  // ── L1:抽取 + 去重(按档分桶,失败按桶保留待重试) ──
55
145
  let newRecords = [];
56
- const distillOn = this.live.get().enabled && this.live.get().distill;
57
- if (this.cfg.extract.enabled && distillOn) {
146
+ const liveNow = this.live.get();
147
+ const distillOn = liveNow.enabled && liveNow.distill;
148
+ if (cfg.extract.enabled && distillOn) {
58
149
  const bucket = this.pending[mode];
59
150
  bucket.push(...messages);
60
- if (bucket.length > 200)
61
- bucket.splice(0, bucket.length - 200);
151
+ if (!opts?.noBufferCap && bucket.length > PENDING_BUCKET_CAP) {
152
+ bucket.splice(0, bucket.length - PENDING_BUCKET_CAP);
153
+ }
62
154
  try {
63
155
  const t = Date.now();
64
- const result = await runExtraction(this.ctx, this.cfg, this.stores.l1, this.states, bucket, this.background, this.logger, mode);
156
+ const result = await runExtraction(this.ctx, cfg, this.stores.l1, this.states, bucket, this.background, this.logger, mode);
65
157
  if (!result.skipped)
66
158
  this.pending[mode] = [];
67
- this.background = [...this.background.slice(-this.cfg.extract.backgroundMessages), ...messages];
159
+ this.background = [...this.background.slice(-cfg.extract.backgroundMessages), ...messages];
68
160
  newRecords = result.newRecords;
69
161
  this.logger.info(`[memory] L1 阶段完成(${Date.now() - t}ms)`);
70
162
  }
71
163
  catch (err) {
72
- // 保留 pending,下次重试;但防止无限堆积
73
- if (this.pending[mode].length > 200)
164
+ // 保留 pending,下次重试;但防止无限堆积(重建轮不限,量被会话规模天然约束)
165
+ if (!opts?.noBufferCap && this.pending[mode].length > PENDING_BUCKET_CAP)
74
166
  this.pending[mode] = [];
75
167
  this.logger.warn(`[memory] L1 抽取失败(mode=${mode},pending=${this.pending[mode].length}): ${errDetail(err)}`);
76
168
  }
169
+ // 缓冲每次尝试后立即落盘:进程中途退出不丢待重试/攒阈值状态
170
+ await this.persistPending();
77
171
  // L1 计数推进后立即落盘:L2/L3 失败或进程中途退出不得回滚阈值进度
78
172
  // (记录已入库但计数丢失会让该族 L2 永远差一截,state 与 DB 脱节)
79
173
  try {
@@ -84,16 +178,16 @@ export class MemoryRunner {
84
178
  }
85
179
  }
86
180
  // ── L2/L3:按记录族各自判定与执行 ──
87
- if (this.cfg.l2.enabled && distillOn) {
181
+ if (cfg.l2.enabled && distillOn) {
88
182
  for (const family of ['chat', 'work']) {
89
183
  const familyRecords = newRecords.filter((r) => (r.family ?? 'chat') === family);
90
184
  if (familyRecords.length === 0)
91
185
  continue;
92
186
  const fstate = this.states[family];
93
- if (fstate.newMemoriesSinceL2 >= this.cfg.l2.minNewMemories) {
187
+ if (fstate.newMemoriesSinceL2 >= cfg.l2.minNewMemories) {
94
188
  try {
95
189
  const t = Date.now();
96
- const result = await runSceneConsolidation(this.ctx, this.cfg, this.stores.scenes[family], familyRecords, this.logger, family);
190
+ const result = await runSceneConsolidation(this.ctx, cfg, this.stores.scenes[family], familyRecords, this.logger, family);
97
191
  fstate.lastL2At = Date.now();
98
192
  fstate.newMemoriesSinceL2 = 0;
99
193
  if (result.personaRequestedReason)
@@ -105,14 +199,14 @@ export class MemoryRunner {
105
199
  }
106
200
  }
107
201
  else {
108
- this.logger.debug?.(`[memory] L2 跳过(family=${family},本族新增 ${familyRecords.length} 条,累计未整合 ${fstate.newMemoriesSinceL2}/${this.cfg.l2.minNewMemories})`);
202
+ this.logger.debug?.(`[memory] L2 跳过(family=${family},本族新增 ${familyRecords.length} 条,累计未整合 ${fstate.newMemoriesSinceL2}/${cfg.l2.minNewMemories})`);
109
203
  }
110
204
  }
111
205
  }
112
- if (this.cfg.l3.enabled && distillOn) {
206
+ if (cfg.l3.enabled && distillOn) {
113
207
  for (const family of ['chat', 'work']) {
114
208
  try {
115
- await runPersona(this.ctx, this.cfg, this.stores.scenes[family], this.stores.persona[family], this.states[family], this.logger, family);
209
+ await runPersona(this.ctx, cfg, this.stores.scenes[family], this.stores.persona[family], this.states[family], this.logger, family);
116
210
  }
117
211
  catch (err) {
118
212
  this.logger.warn(`[memory] L3 画像蒸馏失败(family=${family}): ${errDetail(err)}`);
@@ -128,5 +222,6 @@ export class MemoryRunner {
128
222
  }
129
223
  this.logger.info(`[memory] 蒸馏管线结束(本轮新增 ${newRecords.length} 条,总耗时 ${Date.now() - turnStart}ms)`);
130
224
  this.afterRun?.();
225
+ return newRecords.length;
131
226
  }
132
227
  }
@@ -6,6 +6,8 @@
6
6
  import type { Context } from '@deepseek-ai/cordis';
7
7
  import Schema from '@deepseek-ai/schemastery';
8
8
  import type { MemoryLogger } from './types.js';
9
+ /** 蒸馏思考档位可选项:'' = 跟随静态 config(部署默认)。 */
10
+ export type EffortChoice = '' | 'off' | 'high' | 'max';
9
11
  export interface MemoryLiveSettings {
10
12
  /** 总开关:关 = 捕获/蒸馏/召回注入全停(数据保留) */
11
13
  enabled: boolean;
@@ -15,6 +17,8 @@ export interface MemoryLiveSettings {
15
17
  distill: boolean;
16
18
  /** 召回注入(画像/记忆上下文) */
17
19
  recall: boolean;
20
+ /** 蒸馏思考档位运行时覆盖:'' = 跟随静态 config(llm.reasoningEffort) */
21
+ reasoningEffort: EffortChoice;
18
22
  }
19
23
  export interface LiveSettingsHandle {
20
24
  /** settings 服务是否可用(不可用时 UI 侧隐藏开关面板) */
package/dist/settings.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import Schema from '@deepseek-ai/schemastery';
2
2
  import { settingsNamespace } from '@deepseek-ai/dsh-settings';
3
3
  const NS = settingsNamespace('dsh-memory');
4
- const ALWAYS_ON = { enabled: true, capture: true, distill: true, recall: true };
4
+ const ALWAYS_ON = { enabled: true, capture: true, distill: true, recall: true, reasoningEffort: '' };
5
5
  export function liveSettingsSchema() {
6
6
  return Schema.object({
7
7
  enabled: Schema.boolean().default(true),
8
8
  capture: Schema.boolean().default(true),
9
9
  distill: Schema.boolean().default(true),
10
10
  recall: Schema.boolean().default(true),
11
+ reasoningEffort: Schema.union(['', 'off', 'high', 'max']).default(''),
11
12
  });
12
13
  }
13
14
  export function registerLiveSettings(ctx, logger) {
@@ -27,7 +28,8 @@ export function registerLiveSettings(ctx, logger) {
27
28
  scope.watch((next) => {
28
29
  const prev = current;
29
30
  current = resolveSettings(next);
30
- logger.info(`[memory] 记忆模式开关更新:总=${current.enabled} 捕获=${current.capture} 蒸馏=${current.distill} 召回=${current.recall}(此前 总=${prev.enabled})`);
31
+ logger.info(`[memory] 记忆模式开关更新:总=${current.enabled} 捕获=${current.capture} 蒸馏=${current.distill} 召回=${current.recall}` +
32
+ `,蒸馏思考=${current.reasoningEffort || '跟随配置'}(此前 总=${prev.enabled})`);
31
33
  });
32
34
  inner = {
33
35
  supported: true,
@@ -36,7 +38,8 @@ export function registerLiveSettings(ctx, logger) {
36
38
  await scope.update(patch);
37
39
  },
38
40
  };
39
- logger.info(`[memory] 记忆模式开关就绪(settings 命名空间 dsh-memory,当前:总=${current.enabled} 捕获=${current.capture} 蒸馏=${current.distill} 召回=${current.recall})`);
41
+ logger.info(`[memory] 记忆模式开关就绪(settings 命名空间 dsh-memory,当前:总=${current.enabled} 捕获=${current.capture} 蒸馏=${current.distill} 召回=${current.recall}` +
42
+ `,蒸馏思考=${current.reasoningEffort || '跟随配置'})`);
40
43
  return true;
41
44
  }
42
45
  catch (err) {
@@ -64,10 +67,14 @@ function resolveSettings(value) {
64
67
  if (!value || typeof value !== 'object')
65
68
  return { ...ALWAYS_ON };
66
69
  const v = value;
70
+ const efforts = ['', 'off', 'high', 'max'];
67
71
  return {
68
72
  enabled: v.enabled !== false,
69
73
  capture: v.capture !== false,
70
74
  distill: v.distill !== false,
71
75
  recall: v.recall !== false,
76
+ reasoningEffort: typeof v.reasoningEffort === 'string' && efforts.includes(v.reasoningEffort)
77
+ ? v.reasoningEffort
78
+ : '',
72
79
  };
73
80
  }
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 { RebuildController } from './pipeline/rebuild.js';
3
4
  import type { LiveSettingsHandle } from './settings.js';
4
5
  import type { L0Store } from './store/l0.js';
5
6
  import type { L1Store } from './store/l1.js';
@@ -43,4 +44,4 @@ export declare function registerMemoryRpc(ctx: Context, cfg: MemoryConfig, store
43
44
  scenes: Record<MemoryFamily, SceneStore>;
44
45
  persona: Record<MemoryFamily, PersonaStore>;
45
46
  state: StateStore;
46
- }, logger: MemoryLogger, status?: MemoryStatusSource, live?: LiveSettingsHandle, modes?: SessionModeStore, dataDir?: string): void;
47
+ }, logger: MemoryLogger, status?: MemoryStatusSource, live?: LiveSettingsHandle, modes?: SessionModeStore, dataDir?: string, rebuild?: RebuildController): void;
package/dist/stats.js CHANGED
@@ -12,7 +12,7 @@ import { resolveDataDir } from './config.js';
12
12
  const require = createRequire(import.meta.url);
13
13
  export const PLUGIN_VERSION = require('../package.json').version;
14
14
  /** 注册状态 RPC(web 侧 connection 服务可选,缺失时跳过,不影响插件主体)。 */
15
- export function registerMemoryRpc(ctx, cfg, stores, logger, status, live, modes, dataDir) {
15
+ export function registerMemoryRpc(ctx, cfg, stores, logger, status, live, modes, dataDir, rebuild) {
16
16
  /** 当前是否持有一段有效注册(dispose 完成后清空,允许服务重上线时重注册)。 */
17
17
  let holding = false;
18
18
  const tryRegister = () => {
@@ -34,6 +34,7 @@ export function registerMemoryRpc(ctx, cfg, stores, logger, status, live, modes,
34
34
  modes,
35
35
  dataDir: dataDir ?? resolveDataDir(cfg),
36
36
  logger,
37
+ rebuild,
37
38
  });
38
39
  return { ok: true, value };
39
40
  }
@@ -104,7 +105,7 @@ async function buildStats(cfg, stores, status) {
104
105
  };
105
106
  }
106
107
  async function handleEndpoint(endpoint, payload, deps) {
107
- const { cfg, stores, status, live, modes, dataDir } = deps;
108
+ const { cfg, stores, status, live, modes, dataDir, rebuild } = deps;
108
109
  switch (endpoint) {
109
110
  case 'dsh-memory/stats':
110
111
  return buildStats(cfg, stores, status);
@@ -130,26 +131,41 @@ async function handleEndpoint(endpoint, payload, deps) {
130
131
  deps.logger.info(`[memory] 会话档位设置 session=${p.sessionId} mode=${p.mode}`);
131
132
  return { sessionId: p.sessionId, mode: p.mode };
132
133
  }
133
- case 'dsh-memory/settings-get':
134
+ case 'dsh-memory/settings-get': {
135
+ const s = live?.get();
134
136
  return {
135
137
  supported: live?.supported ?? false,
136
- settings: live?.get() ?? { enabled: true, capture: true, distill: true, recall: true },
138
+ settings: s ?? { enabled: true, capture: true, distill: true, recall: true, reasoningEffort: '' },
137
139
  // 静态部署上限(cordis.patch.yml):运行时开关与它取 AND
138
140
  ceilings: { capture: cfg.capture.enabled, distill: cfg.extract.enabled, recall: cfg.recall.enabled },
141
+ // 蒸馏思考档位:current 是运行时覆盖('' = 跟随配置),effective 是实际生效值
142
+ effort: {
143
+ current: s?.reasoningEffort ?? '',
144
+ effective: s?.reasoningEffort || cfg.llm.reasoningEffort,
145
+ fallback: cfg.llm.reasoningEffort,
146
+ },
139
147
  };
148
+ }
140
149
  case 'dsh-memory/settings-set': {
141
150
  if (!live)
142
151
  throw new Error('开关通道未初始化');
143
152
  const patch = (payload ?? {});
144
- const allowed = ['enabled', 'capture', 'distill', 'recall'];
145
153
  const clean = {};
146
- for (const key of allowed) {
154
+ for (const key of ['enabled', 'capture', 'distill', 'recall']) {
147
155
  if (typeof patch[key] === 'boolean')
148
156
  clean[key] = patch[key];
149
157
  }
158
+ if (patch.reasoningEffort !== undefined) {
159
+ const v = String(patch.reasoningEffort);
160
+ if (!['', 'off', 'high', 'max'].includes(v)) {
161
+ throw new Error(`非法思考档位: ${v}(允许 ''/off/high/max)`);
162
+ }
163
+ clean.reasoningEffort = v;
164
+ }
150
165
  if (Object.keys(clean).length === 0)
151
166
  throw new Error('开关更新载荷为空');
152
167
  await live.update(clean);
168
+ deps.logger.info(`[memory] 设置更新:${JSON.stringify(clean)}`);
153
169
  return { ok: true, settings: live.get() };
154
170
  }
155
171
  case 'dsh-memory/list-records': {
@@ -200,6 +216,30 @@ async function handleEndpoint(endpoint, payload, deps) {
200
216
  const p = (payload ?? {});
201
217
  return { lines: readLogTail(join(dataDir, 'memory.log'), Math.min(Math.max(Number(p.lines) || 200, 1), 1000)) };
202
218
  }
219
+ case 'dsh-memory/rebuild-status': {
220
+ if (!rebuild)
221
+ return { supported: false, running: false, phase: 'idle' };
222
+ return rebuild.getStatus();
223
+ }
224
+ case 'dsh-memory/rebuild-start': {
225
+ if (!rebuild)
226
+ throw new Error('重建控制器未初始化(存储不可用)');
227
+ if (status?.degraded())
228
+ throw new Error('存储处于降级状态,无法重建');
229
+ const s = live?.get();
230
+ if (s && (!s.enabled || !s.distill))
231
+ throw new Error('蒸馏开关已关闭,请先开启蒸馏再重建');
232
+ if (!cfg.extract.enabled)
233
+ throw new Error('部署配置已停用蒸馏(extract.enabled=false),无法重建');
234
+ const result = rebuild.start();
235
+ deps.logger.info('[memory] 收到重建指令(设置页按钮)');
236
+ return result;
237
+ }
238
+ case 'dsh-memory/rebuild-cancel': {
239
+ if (!rebuild)
240
+ throw new Error('重建控制器未初始化');
241
+ return rebuild.requestCancel();
242
+ }
203
243
  default:
204
244
  throw new Error(`unknown endpoint: ${endpoint}`);
205
245
  }
@@ -0,0 +1,15 @@
1
+ import type { ConversationMessage, ExtractMode, MemoryLogger } from '../types.js';
2
+ /** 三档蒸馏缓冲桶(off 在捕获侧已被拦截,永远不到这里)。 */
3
+ export interface PendingBuckets {
4
+ auto: ConversationMessage[];
5
+ chat: ConversationMessage[];
6
+ work: ConversationMessage[];
7
+ }
8
+ export declare function emptyPending(): PendingBuckets;
9
+ /** 读取缓冲文件:文件缺失/损坏 → 空桶(不抛出——丢了缓冲 L0 事实源仍在)。 */
10
+ export declare function loadPending(file: string, logger?: MemoryLogger): Promise<PendingBuckets>;
11
+ /** 全量原子落盘(每次蒸馏尝试后调用;桶有上限,量级为百条级)。 */
12
+ export declare function savePending(file: string, buckets: PendingBuckets): Promise<void>;
13
+ export declare function pendingPathFor(dataDir: string): string;
14
+ /** 三档 key(调度/遍历用)。 */
15
+ export declare const PENDING_MODES: readonly ExtractMode[];
@@ -0,0 +1,56 @@
1
+ /**
2
+ * 未蒸馏缓冲持久化(pending.json):
3
+ * 按档分桶的"已捕获但尚未成功蒸馏"消息——既包括抽取失败的待重试消息,
4
+ * 也包括攒够触发阈值之前的消息。跨重启不丢(CONTEXT.md「未蒸馏缓冲」)。
5
+ *
6
+ * 与 state.json / session-modes.json 同款原子写;读取宽容(坏行丢弃、坏文件空桶起步)。
7
+ */
8
+ import * as path from 'node:path';
9
+ import { atomicWriteJson, readJsonIfExists } from './io.js';
10
+ export function emptyPending() {
11
+ return { auto: [], chat: [], work: [] };
12
+ }
13
+ function isMessage(m) {
14
+ if (!m || typeof m !== 'object')
15
+ return false;
16
+ const r = m;
17
+ return typeof r.id === 'string' && typeof r.content === 'string' && (r.role === 'user' || r.role === 'assistant');
18
+ }
19
+ /** 读取缓冲文件:文件缺失/损坏 → 空桶(不抛出——丢了缓冲 L0 事实源仍在)。 */
20
+ export async function loadPending(file, logger) {
21
+ const out = emptyPending();
22
+ let raw;
23
+ try {
24
+ raw = await readJsonIfExists(file);
25
+ }
26
+ catch {
27
+ raw = undefined;
28
+ }
29
+ if (!raw || typeof raw !== 'object' || !raw.buckets || typeof raw.buckets !== 'object')
30
+ return out;
31
+ let dropped = 0;
32
+ for (const key of ['auto', 'chat', 'work']) {
33
+ const arr = raw.buckets[key];
34
+ if (!Array.isArray(arr))
35
+ continue;
36
+ for (const m of arr) {
37
+ if (isMessage(m))
38
+ out[key].push(m);
39
+ else
40
+ dropped++;
41
+ }
42
+ }
43
+ if (dropped > 0)
44
+ logger?.warn(`[memory] 未蒸馏缓冲文件含 ${dropped} 条坏记录,已丢弃`);
45
+ return out;
46
+ }
47
+ /** 全量原子落盘(每次蒸馏尝试后调用;桶有上限,量级为百条级)。 */
48
+ export async function savePending(file, buckets) {
49
+ const payload = { version: 1, buckets };
50
+ await atomicWriteJson(file, payload);
51
+ }
52
+ export function pendingPathFor(dataDir) {
53
+ return path.join(dataDir, 'pending.json');
54
+ }
55
+ /** 三档 key(调度/遍历用)。 */
56
+ export const PENDING_MODES = ['auto', 'chat', 'work'];
@@ -75,6 +75,13 @@ export declare class MemoryDb {
75
75
  upsertL1(record: MemoryRecord, embedding?: Float32Array): boolean;
76
76
  /** 批量删除 L1(元数据 + 向量 + FTS),返回删除条数。 */
77
77
  deleteL1Batch(ids: string[]): number;
78
+ /**
79
+ * 清空 L1 检索库全部数据(重建用)。records/FTS 直接 DELETE;
80
+ * 向量表走 DROP + 重建(vec0 的全表 DELETE 语义不可靠,dropVectorTables
81
+ * 会连 l0_vec 一起删——L0 向量必须保留——故此处单独处理 l1_vec)。
82
+ * L0 表与 embedding_meta 不动:backfill 的行数比对天然重新一致。
83
+ */
84
+ clearL1(): boolean;
78
85
  countL1(): number;
79
86
  /** 全量读取(调试/迁移/重嵌入用;检索请走 FTS/向量)。 */
80
87
  getAllL1(): MemoryRecord[];
@@ -101,6 +108,14 @@ export declare class MemoryDb {
101
108
  countL0(): number;
102
109
  /** 统计 recorded_at >= iso 的消息数(状态面板"今日捕获"用)。 */
103
110
  countL0Since(iso: string): number;
111
+ /** L0 全量列举(重建快照用;按时间升序,事务一致性避开 JSONL 追加竞态)。 */
112
+ listL0All(): L0MessageRecord[];
113
+ /** 重建成本预估(一次全表聚合:会话数 / 消息数 / 字符量)。 */
114
+ l0RebuildEstimate(): {
115
+ sessions: number;
116
+ messages: number;
117
+ chars: number;
118
+ };
104
119
  /** 向量表行数(backfill 判据:与元数据行数的差值即缺失向量数;不可用时返回 -1)。 */
105
120
  countL1Vec(): number;
106
121
  countL0Vec(): number;
@@ -518,6 +518,46 @@ export class MemoryDb {
518
518
  return 0;
519
519
  }
520
520
  }
521
+ /**
522
+ * 清空 L1 检索库全部数据(重建用)。records/FTS 直接 DELETE;
523
+ * 向量表走 DROP + 重建(vec0 的全表 DELETE 语义不可靠,dropVectorTables
524
+ * 会连 l0_vec 一起删——L0 向量必须保留——故此处单独处理 l1_vec)。
525
+ * L0 表与 embedding_meta 不动:backfill 的行数比对天然重新一致。
526
+ */
527
+ clearL1() {
528
+ if (this.degraded)
529
+ return false;
530
+ try {
531
+ this.db.exec('BEGIN');
532
+ try {
533
+ this.db.exec('DELETE FROM l1_records');
534
+ if (this.ftsAvailable)
535
+ this.db.exec('DELETE FROM l1_fts');
536
+ this.db.exec('COMMIT');
537
+ }
538
+ catch (err) {
539
+ try {
540
+ this.db.exec('ROLLBACK');
541
+ }
542
+ catch {
543
+ /* ignore */
544
+ }
545
+ throw err;
546
+ }
547
+ // vec0 全表 DELETE 语义不可靠 → DROP + 重建空表;放事务外(vtab DDL 事务性弱)。
548
+ // 中间态:向量行短暂孤儿——检索侧对缺 meta 的向量本就跳过(searchL1Vector 回查过滤)。
549
+ if (this.stmtDeleteL1Vec) {
550
+ this.db.exec('DROP TABLE IF EXISTS l1_vec');
551
+ this.prepareL1VecStatements();
552
+ }
553
+ this.logger?.info(`${TAG} L1 检索库已清空(重建)`);
554
+ return true;
555
+ }
556
+ catch (err) {
557
+ this.logger?.warn(`${TAG} L1 清空失败: ${err instanceof Error ? err.message : String(err)}`);
558
+ return false;
559
+ }
560
+ }
521
561
  countL1() {
522
562
  if (this.degraded)
523
563
  return 0;
@@ -724,6 +764,42 @@ export class MemoryDb {
724
764
  return 0;
725
765
  }
726
766
  }
767
+ /** L0 全量列举(重建快照用;按时间升序,事务一致性避开 JSONL 追加竞态)。 */
768
+ listL0All() {
769
+ if (this.degraded)
770
+ return [];
771
+ try {
772
+ const rows = this.db
773
+ .prepare('SELECT record_id, session_id, role, message_text, recorded_at, timestamp FROM l0_conversations ORDER BY timestamp ASC')
774
+ .all();
775
+ return rows.map((r) => ({
776
+ sessionId: r.session_id,
777
+ recordedAt: r.recorded_at,
778
+ id: r.record_id,
779
+ role: r.role,
780
+ content: r.message_text,
781
+ timestamp: r.timestamp ?? 0,
782
+ }));
783
+ }
784
+ catch (err) {
785
+ this.logger?.warn(`${TAG} L0 全量列举失败(返回空): ${err instanceof Error ? err.message : String(err)}`);
786
+ return [];
787
+ }
788
+ }
789
+ /** 重建成本预估(一次全表聚合:会话数 / 消息数 / 字符量)。 */
790
+ l0RebuildEstimate() {
791
+ if (this.degraded)
792
+ return { sessions: 0, messages: 0, chars: 0 };
793
+ try {
794
+ const row = this.db
795
+ .prepare('SELECT COUNT(DISTINCT session_id) AS s, COUNT(*) AS n, COALESCE(SUM(LENGTH(message_text)), 0) AS c FROM l0_conversations')
796
+ .get();
797
+ return { sessions: row?.s ?? 0, messages: row?.n ?? 0, chars: row?.c ?? 0 };
798
+ }
799
+ catch {
800
+ return { sessions: 0, messages: 0, chars: 0 };
801
+ }
802
+ }
727
803
  /** 向量表行数(backfill 判据:与元数据行数的差值即缺失向量数;不可用时返回 -1)。 */
728
804
  countL1Vec() {
729
805
  if (this.degraded || !this.stmtSearchL1Vec)
@@ -30,6 +30,12 @@ export declare class StateStore {
30
30
  get didMigrate(): boolean;
31
31
  /** 取某族的 checkpoint(活引用——改字段后 save 生效)。 */
32
32
  forFamily(family: MemoryFamily): MemoryState;
33
+ /**
34
+ * 重建用:两族 checkpoint 重置为默认值。
35
+ * 必须原地突变(Object.assign)——runner.states 等处持有桶对象的活引用,
36
+ * 换新对象会让引用指向已废弃的桶,后续计数写到内存孤儿上。
37
+ */
38
+ reset(): void;
33
39
  save(): Promise<void>;
34
40
  static pathFor(dataDir: string): string;
35
41
  }
@@ -52,6 +52,17 @@ export class StateStore {
52
52
  forFamily(family) {
53
53
  return this.buckets[family];
54
54
  }
55
+ /**
56
+ * 重建用:两族 checkpoint 重置为默认值。
57
+ * 必须原地突变(Object.assign)——runner.states 等处持有桶对象的活引用,
58
+ * 换新对象会让引用指向已废弃的桶,后续计数写到内存孤儿上。
59
+ */
60
+ reset() {
61
+ for (const family of ['chat', 'work']) {
62
+ Object.assign(this.buckets[family], defaultState());
63
+ this.buckets[family].personaRequestedReason = undefined;
64
+ }
65
+ }
55
66
  async save() {
56
67
  const file = { version: 2, families: this.buckets };
57
68
  await atomicWriteJson(this.file, file);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-layered-memory",
3
- "version": "0.5.4",
3
+ "version": "0.6.1",
4
4
  "description": "L0~L3 分层蒸馏记忆插件 for DeepSeek Harness:自动捕获对话(L0)、抽取原子记忆(L1)、整合场景块(L2)、蒸馏核心画像/团队方法论(L3),并在模型步骤前自动召回注入。移植自 MemoryCore (TencentDB Agent Memory) 的管线设计。",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",