dsh-layered-memory 0.8.2 → 0.8.3

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/store/l0.js CHANGED
@@ -90,9 +90,14 @@ export class L0Store {
90
90
  for (const [day, list] of byDay) {
91
91
  await appendJsonl(path.join(this.dir, `${day}.jsonl`), list);
92
92
  }
93
- // 检索引擎:DB + 向量(嵌入失败只跳过向量,不影响元数据/FTS,backfill 补齐)
93
+ // 检索引擎:DB + 向量(嵌入失败只跳过向量,不影响元数据/FTS,backfill 补齐)。
94
+ // 双写失败闭环:JSONL 事实源已先行追加,DB 缺行 = 这些消息检索不可见
95
+ // (conversation_search / 蒸馏背景参考都查不到)——升 error 并给自愈指引。
94
96
  const vecs = await this.helper.batch(records.map((r) => r.content));
95
- this.db.upsertL0Batch(records, vecs);
97
+ if (!this.db.upsertL0Batch(records, vecs)) {
98
+ this.logger?.error(`[memory] L0 检索库批量写入失败(${records.length} 条,JSONL 事实源完好),` +
99
+ '这些消息暂不可检索;可在设置页运行「重建记忆」修复');
100
+ }
96
101
  }
97
102
  /** 今日已捕获消息数(SQL 计数,不再读整文件)。 */
98
103
  async countToday() {
package/dist/store/l1.js CHANGED
@@ -43,23 +43,25 @@ export class L1Store {
43
43
  try {
44
44
  const records = await readJsonl(this.legacyFile);
45
45
  const valid = records.filter((r) => r && typeof r.id === 'string' && r.content);
46
+ const badCount = records.length - valid.length;
46
47
  let n = 0;
47
48
  if (valid.length > 0 && this.db.upsertL1Batch(valid))
48
49
  n = valid.length;
49
- // 只有确实导入成功(或文件为空)才改名,避免把未入库的数据改名带走
50
- if (n === records.length) {
50
+ // 只有确实导入成功才改名,避免把未入库的数据改名带走;判据按 valid 数——
51
+ // 坏行已在读取时过滤,按 records.length 判会让混入坏行的文件迁移永不完成
52
+ if (n === valid.length) {
51
53
  const renamed = await fs
52
54
  .rename(this.legacyFile, `${this.legacyFile}.imported`)
53
55
  .then(() => true, () => false);
54
56
  if (renamed) {
55
- this.logger?.info(`[memory] 旧版 L1 数据已导入检索库 ${n} 条(l1/records.jsonl → .imported)`);
57
+ this.logger?.info(`[memory] 旧版 L1 数据已导入检索库 ${n} 条${badCount > 0 ? `(另丢弃 ${badCount} 条坏行)` : ''}(l1/records.jsonl → .imported)`);
56
58
  }
57
59
  else {
58
60
  this.logger?.warn('[memory] 旧版 L1 导入完成但改名失败,下次启动会重复导入(upsert 幂等,无害)');
59
61
  }
60
62
  }
61
63
  else {
62
- this.logger?.warn(`[memory] 旧版 L1 导入不完整(${n}/${records.length}),保留原文件下次重试`);
64
+ this.logger?.warn(`[memory] 旧版 L1 导入不完整(${n}/${valid.length}),保留原文件下次重试`);
63
65
  }
64
66
  }
65
67
  catch (err) {
@@ -96,15 +98,23 @@ export class L1Store {
96
98
  await appendJsonl(path.join(this.recordsDir, `${day}.jsonl`), list);
97
99
  }
98
100
  const vecs = await this.helper.batch(records.map((r) => r.content));
99
- // 单事务批量写:逐条开事务在 WAL FULL 下每条一次 fsync
100
- this.db.upsertL1Batch(records, vecs);
101
+ // 单事务批量写:逐条开事务在 WAL FULL 下每条一次 fsync
102
+ // 双写失败闭环:JSONL 事实源已先行追加,DB 缺行 = 这批记忆检索不可见、
103
+ // 去重候选缺失(重复记忆会累积)。upsert 内部已有逐条 warn,这里升 error
104
+ // 并给出自愈指引——检索库可由"重建记忆"从事实源全量重导修复。
105
+ if (!this.db.upsertL1Batch(records, vecs)) {
106
+ this.logger?.error(`[memory] L1 检索库批量写入失败(${records.length} 条,JSONL 事实源完好),` +
107
+ '这批记忆暂不可检索;可在设置页运行「重建记忆」修复');
108
+ }
101
109
  }
102
110
  /** 去重 update/merge 产出的记录:只更新检索库(JSONL 事实源不改写,官方语义)。 */
103
111
  async upsert(record) {
104
112
  if (!record.family)
105
113
  record.family = familyForType(record.type);
106
114
  const vec = (await this.helper.batch([record.content]))[0];
107
- this.db.upsertL1(record, vec);
115
+ if (!this.db.upsertL1(record, vec)) {
116
+ this.logger?.error(`[memory] L1 检索库写入失败 id=${record.id}(JSONL 事实源完好),该记忆暂不可检索,重建可修复`);
117
+ }
108
118
  }
109
119
  /** 活切换嵌入源:同步换底层服务(嵌入源三态切换用)。 */
110
120
  setEmbeddingService(svc) {
@@ -43,7 +43,9 @@ export declare class LocalEmbeddingService implements EmbeddingService {
43
43
  private readonly entry;
44
44
  private readonly loader;
45
45
  private readonly logger?;
46
- constructor(entry: CatalogEntry, modelDir: string, loader: ModuleLoader, logger?: MemoryLogger);
46
+ /** 输入截断(与远程路径同源的 embedding.maxInputChars 配置,缺省 5000)。 */
47
+ private readonly maxInputChars;
48
+ constructor(entry: CatalogEntry, modelDir: string, loader: ModuleLoader, logger?: MemoryLogger, maxInputChars?: number);
47
49
  getDimensions(): number;
48
50
  getProviderInfo(): EmbeddingProviderInfo;
49
51
  isReady(): boolean;
@@ -7,11 +7,14 @@ export class LocalEmbeddingService {
7
7
  entry;
8
8
  loader;
9
9
  logger;
10
- constructor(entry, modelDir, loader, logger) {
10
+ /** 输入截断(与远程路径同源的 embedding.maxInputChars 配置,缺省 5000)。 */
11
+ maxInputChars;
12
+ constructor(entry, modelDir, loader, logger, maxInputChars) {
11
13
  this.entry = entry;
12
14
  this.modelDir = modelDir;
13
15
  this.loader = loader;
14
16
  this.logger = logger;
17
+ this.maxInputChars = maxInputChars && maxInputChars > 0 ? maxInputChars : 5000;
15
18
  }
16
19
  getDimensions() {
17
20
  return this.entry.dims;
@@ -59,7 +62,7 @@ export class LocalEmbeddingService {
59
62
  const out = [];
60
63
  // CPU 推理逐条最稳(ORT session 内部并行),量级为本插件写入/重嵌批(≤16)没问题
61
64
  for (const text of texts) {
62
- const result = await this.extractor([text.slice(0, 5000)], { pooling: this.entry.pooling, normalize: true });
65
+ const result = await this.extractor([text.slice(0, this.maxInputChars)], { pooling: this.entry.pooling, normalize: true });
63
66
  out.push(new Float32Array(result[0].data));
64
67
  }
65
68
  return out;
@@ -54,12 +54,19 @@ export declare class RuntimeInstaller {
54
54
  * 返回是否就绪(失败/取消返回 false 并在 progress.error 说明原因)。
55
55
  */
56
56
  ensure(): Promise<boolean>;
57
- /** 取消安装(kill 子进程;node_modules 残留无害,npm 幂等重装)。 */
57
+ /**
58
+ * 取消安装(kill 子进程;node_modules 残留无害,npm 幂等重装)。
59
+ * 间隙兼容:ci 退出到回退 install 起跑之间 child 为 null——此时也置取消态,
60
+ * runNpm 起跑前复查即不再起新进程(否则回退的 npm 会跑到自然结束且无法再取消)。
61
+ */
58
62
  cancel(): boolean;
59
63
  /** 从 runtime 目录解析已安装的 transformers 模块(LocalEmbeddingService 用)。 */
60
64
  resolveModule(): unknown;
61
65
  private pushLine;
62
66
  /** 跑一次 npm 子进程(采集尾行 + 超时 kill),返回退出码(null = 被杀死/启动失败)。 */
63
67
  private runNpm;
68
+ /** 取消态判定。独立方法而非内联比较:cancel() 在 await 期间跨方法置位
69
+ * phase,TS 的属性流分析不跟踪这种突变,内联比较会被窄化误报"无重叠"。 */
70
+ private wasCancelled;
64
71
  private installOnce;
65
72
  }
@@ -54,7 +54,17 @@ export class RuntimeInstaller {
54
54
  onStderr(cb) {
55
55
  child.stderr?.on('data', (d) => String(d).split(/\r?\n/).forEach((l) => l && cb(l)));
56
56
  },
57
- kill: () => child.kill(),
57
+ // Windows shell:true child 只是 cmd.exe,npm/node 孙进程不随 child.kill()
58
+ // 终止(超时与取消都会"表面停止")——taskkill /T 按进程树杀;启动失败回退裸 kill
59
+ kill: () => {
60
+ if (process.platform === 'win32' && child.pid !== undefined) {
61
+ const tk = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true });
62
+ tk.on('error', () => child.kill());
63
+ }
64
+ else {
65
+ child.kill();
66
+ }
67
+ },
58
68
  // 'error'(如 ENOENT:PATH 无 npm)只发 error 不发 close——不监听会永挂
59
69
  exited: new Promise((resolve) => {
60
70
  child.on('close', (code) => resolve(code));
@@ -117,12 +127,16 @@ export class RuntimeInstaller {
117
127
  this.current = null;
118
128
  }
119
129
  }
120
- /** 取消安装(kill 子进程;node_modules 残留无害,npm 幂等重装)。 */
130
+ /**
131
+ * 取消安装(kill 子进程;node_modules 残留无害,npm 幂等重装)。
132
+ * 间隙兼容:ci 退出到回退 install 起跑之间 child 为 null——此时也置取消态,
133
+ * runNpm 起跑前复查即不再起新进程(否则回退的 npm 会跑到自然结束且无法再取消)。
134
+ */
121
135
  cancel() {
122
- if (this.progress.phase !== 'installing' || !this.child)
136
+ if (this.progress.phase !== 'installing')
123
137
  return false;
124
138
  this.progress.phase = 'cancelled';
125
- this.child.kill();
139
+ this.child?.kill();
126
140
  return true;
127
141
  }
128
142
  /** 从 runtime 目录解析已安装的 transformers 模块(LocalEmbeddingService 用)。 */
@@ -138,6 +152,9 @@ export class RuntimeInstaller {
138
152
  }
139
153
  /** 跑一次 npm 子进程(采集尾行 + 超时 kill),返回退出码(null = 被杀死/启动失败)。 */
140
154
  async runNpm(args) {
155
+ // 起跑前复查取消:cancel() 在上一进程退出与本进程 spawn 之间的间隙置位时,不再起新进程
156
+ if (this.progress.phase === 'cancelled')
157
+ return null;
141
158
  const child = this.spawnImpl('npm', args, this.runtimeDir);
142
159
  this.child = child;
143
160
  child.onStdout((l) => this.pushLine(l));
@@ -151,6 +168,11 @@ export class RuntimeInstaller {
151
168
  this.child = null;
152
169
  return code;
153
170
  }
171
+ /** 取消态判定。独立方法而非内联比较:cancel() 在 await 期间跨方法置位
172
+ * phase,TS 的属性流分析不跟踪这种突变,内联比较会被窄化误报"无重叠"。 */
173
+ wasCancelled() {
174
+ return this.progress.phase === 'cancelled';
175
+ }
154
176
  async installOnce() {
155
177
  // 锚定 package.json(带精确依赖):没有它 npm 会向上层目录找最近的 package.json 安装(逃逸事故);
156
178
  // npm ci 还要求它与 lockfile 根条目一致——每次安装都写规范化形状,覆盖历史遗留/被 npm 改写的副本。
@@ -184,6 +206,13 @@ export class RuntimeInstaller {
184
206
  catch {
185
207
  /* 无随包 lockfile,直接走 install 回退 */
186
208
  }
209
+ // ci 阶段被取消(退出码 null)不得落入回退分支——那是"ci 失败"语义,会让
210
+ // 取消后再白跑一次最长 10 分钟的 install
211
+ if (this.wasCancelled()) {
212
+ this.progress.elapsedMs = Date.now() - this.progress.startedAt;
213
+ this.logger?.warn('[memory] 运行时安装已取消(残留无害,重装幂等)');
214
+ return false;
215
+ }
187
216
  if (!usedCi || code !== 0) {
188
217
  if (usedCi) {
189
218
  this.pushLine('npm ci 失败(lockfile 与钉死版本漂移?),回退 npm install');
@@ -203,7 +232,7 @@ export class RuntimeInstaller {
203
232
  this.progress.elapsedMs = Date.now() - this.progress.startedAt;
204
233
  const version = await this.installedVersion();
205
234
  this.progress.installedVersion = version;
206
- if (this.progress.phase === 'cancelled') {
235
+ if (this.wasCancelled()) {
207
236
  this.logger?.warn('[memory] 运行时安装已取消(残留无害,重装幂等)');
208
237
  return false;
209
238
  }
@@ -19,5 +19,6 @@ export declare class SceneStore {
19
19
  /** 场景导航索引(召回注入用)。 */
20
20
  navigation(): Promise<string>;
21
21
  }
22
- /** 文件名归一化:只允许字母数字 CJK - _ .,以 .md 结尾,去空格/标点。 */
22
+ /** 文件名归一化:只允许字母数字 CJK - _ .,以 .md 结尾,去空格/标点。
23
+ * 超长名截断到 120 字符(ENAMETOOLONG 防御);Windows 保留设备名加前缀 _ 避让。 */
23
24
  export declare function sanitizeFilename(name: string): string;
@@ -134,7 +134,10 @@ function parseMeta(content, name) {
134
134
  }
135
135
  return s;
136
136
  }
137
- /** 文件名归一化:只允许字母数字 CJK - _ .,以 .md 结尾,去空格/标点。 */
137
+ /** Windows 保留设备名(CON.md 等带扩展形态同样命中设备语义,须整体避开)。 */
138
+ const RESERVED_NAME_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
139
+ /** 文件名归一化:只允许字母数字 CJK - _ .,以 .md 结尾,去空格/标点。
140
+ * 超长名截断到 120 字符(ENAMETOOLONG 防御);Windows 保留设备名加前缀 _ 避让。 */
138
141
  export function sanitizeFilename(name) {
139
142
  let n = name.trim();
140
143
  if (!n.toLowerCase().endsWith('.md'))
@@ -144,6 +147,12 @@ export function sanitizeFilename(name) {
144
147
  .replace(/-{2,}/g, '-')
145
148
  .replace(/-+\.md$/i, '.md')
146
149
  .replace(/^-+|-+$/g, '');
150
+ let stem = n.slice(0, -3);
151
+ if (stem.length > 120)
152
+ stem = stem.slice(0, 120).replace(/[-._]+$/, '');
153
+ if (RESERVED_NAME_RE.test(stem))
154
+ stem = `_${stem}`;
155
+ n = `${stem}.md`;
147
156
  if (!n || !/^[\w\u3400-\u9fff\uf900-\ufaff.\-_]+\.md$/i.test(n))
148
157
  return '';
149
158
  return n;
@@ -864,7 +864,7 @@ export class MemoryDb {
864
864
  // ============================
865
865
  /** FTS5 BM25 检索(family 缺省不过滤)。失败返回空数组(调用方降级)。 */
866
866
  searchL1Fts(query, limit, family) {
867
- if (this.degraded || !this.ftsAvailable)
867
+ if (this.degraded || !this.ftsAvailable || limit <= 0)
868
868
  return [];
869
869
  const ftsQuery = buildFtsQuery(query);
870
870
  if (!ftsQuery)
@@ -890,7 +890,7 @@ export class MemoryDb {
890
890
  }
891
891
  /** vec0 余弦 KNN 检索(score = 1 - cosine distance;family 过滤走过度召回 + 回查过滤,vec0 无法 WHERE)。失败返回空数组。 */
892
892
  searchL1Vector(embedding, topK, family) {
893
- if (this.degraded || !this.stmtSearchL1Vec)
893
+ if (this.degraded || !this.stmtSearchL1Vec || topK <= 0)
894
894
  return [];
895
895
  try {
896
896
  // 过度召回补偿遗留零向量;带族过滤时再放大(不命中本族的行会被丢弃)
@@ -1077,7 +1077,7 @@ export class MemoryDb {
1077
1077
  // L0 检索
1078
1078
  // ============================
1079
1079
  searchL0Fts(query, limit) {
1080
- if (this.degraded || !this.ftsAvailable)
1080
+ if (this.degraded || !this.ftsAvailable || limit <= 0)
1081
1081
  return [];
1082
1082
  const ftsQuery = buildFtsQuery(query);
1083
1083
  if (!ftsQuery)
@@ -3,10 +3,20 @@ const OFF_NOTICE = '本会话的记忆档位为"关闭":该会话对记忆系
3
3
  export function registerMemoryTools(ctx, cfg, stores, logger, modes) {
4
4
  if (!cfg.tools)
5
5
  return;
6
- /** 调用会话的检索族(auto → undefined 不过滤;off → null 表示整体禁用)。 */
6
+ /**
7
+ * 调用会话的检索族(auto → undefined 不过滤;off → null 表示整体禁用)。
8
+ * fail-open:exec.agent 缺失(宿主调用路径未带 agent 标识)按全族检索放行——
9
+ * 档位隔离依赖宿主正确传递 exec.agent.id,缺失只告警一次不拒绝工具调用。
10
+ */
11
+ let warnedNoAgent = false;
7
12
  const familyOfCaller = (agentId) => {
8
- if (agentId === undefined)
13
+ if (agentId === undefined) {
14
+ if (!warnedNoAgent) {
15
+ warnedNoAgent = true;
16
+ logger.warn('[memory] 工具调用缺少 agent 标识(exec.agent 未传递),档位过滤退化为全族检索');
17
+ }
9
18
  return undefined;
19
+ }
10
20
  const mode = modes.get(agentId);
11
21
  if (mode === 'off')
12
22
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-layered-memory",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
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",