memory-eternal 0.10.0 → 0.10.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/index.js CHANGED
@@ -30,9 +30,10 @@ import { migrateFromMarkdown, setAuditConfig, backupDb } from './lib/db.js'
30
30
  import { evaluateStall, MAX_STAMPS } from './lib/stall.js'
31
31
  import { resolveVaultDir, currentWorkspace } from './lib/vault-resolve.js'
32
32
  import { createHub } from './lib/sse.js'
33
- import { summarizeTurn, summarizeTurnDetailed, routeCandidates, extractLastTurn, sliceNewEvents, sessionEvents, sessionEventApi, createCaptureHealth, resolveRoute, captureCard, captureUpdate, pickNeighbors, hasUsableContent, deriveTitle, looksTruncated } from './lib/capture.js'
33
+ import { summarizeTurn, summarizeTurnDetailed, routeCandidates, extractLastTurn, sliceNewEvents, sessionEvents, sessionEventApi, createCaptureHealth, resolveRoute, captureCard, captureUpdate, pickNeighbors, hasUsableContent, deriveTitle, looksTruncated, DEFAULT_CAPTURE_MAX_TOKENS, MAX_CAPTURE_MAX_TOKENS } from './lib/capture.js'
34
34
  import { createApi, json, encodeBody } from './lib/api.js'
35
35
  import { appendCaptureLog, readCaptureLog, rotateCaptureLog } from './lib/capture-log.js'
36
+ import { missingWebAssets, missingAssetsReason } from './lib/web-assets.js'
36
37
  import { nodeBinary, childEnv } from './lib/node-bin.js'
37
38
 
38
39
  export const name = 'memory-eternal'
@@ -65,7 +66,9 @@ export const Config = z.object({
65
66
  captureProvider: z.string().default(''),
66
67
  captureModel: z.string().default(''),
67
68
  // 蒸馏单次输出上限(token),越高越准越贵
68
- captureMaxTokens: z.number().min(100).max(4000).default(900),
69
+ // 默认值 900 → 2000(issue #18):prompt 要求 300-800 字正文,900 会让超长会话常态化撞上限,
70
+ // 输出被截断后旧代码还会把它误报成「解析失败」,最终落一张 raw 噪声卡。
71
+ captureMaxTokens: z.number().min(100).max(4000).default(DEFAULT_CAPTURE_MAX_TOKENS),
69
72
  // 召回相关性阈值(minScore),越高召回越少越精越省
70
73
  recallMinScore: z.number().min(0).max(50).default(2),
71
74
  // 注入体积可配置(召回)
@@ -121,7 +124,53 @@ function markAllVolatile(schema) {
121
124
 
122
125
  markAllVolatile(Config)
123
126
 
127
+ /**
128
+ * 取「会话自己的工作区」(issue #15 问题 1)。
129
+ *
130
+ * 宿主进程的 cwd 是**启动 dsh 时的目录**,拿它去 match.workspace 永远不命中 ——
131
+ * 于是按项目隔离的记忆库在宿主侧实际不可用。DSH 把会话目录放在 session.header.cwd,
132
+ * 这里把它取出来交给 resolveVaultDir。
133
+ *
134
+ * 优先级:MEMORY_WORKSPACE(显式覆盖 / 宿主 spawn 子进程时传入)> 会话 cwd > undefined
135
+ * (undefined 时 resolveVaultDir 会回落进程 cwd,与 CLI / MCP / hooks 的既有行为一致)。
136
+ *
137
+ * @param {{session?:{header?:{cwd?:string}}}} [agent]
138
+ * @param {NodeJS.ProcessEnv} [env]
139
+ * @returns {string|undefined}
140
+ */
141
+ export function sessionWorkspaceOf(agent, env = process.env) {
142
+ const explicit = String((env && env.MEMORY_WORKSPACE) || '').trim()
143
+ if (explicit) return explicit
144
+ const cwd = agent && agent.session && agent.session.header && agent.session.header.cwd
145
+ return typeof cwd === 'string' && cwd.trim() ? cwd.trim() : undefined
146
+ }
147
+
148
+ /**
149
+ * 汇总一次蒸馏的全部候选失败(issue #18 根因 2)。
150
+ *
151
+ * 旧实现的 failure 每轮被下一个候选覆盖,最终上报的偏偏是**最后一个兜底候选**的错误
152
+ * (例如一堆网关的 UNSUPPORTED_REASONING_EFFORT),把真正有信息量的主因埋掉。
153
+ * 这里始终以第一个候选(通常是显式配置的那个)为主因,其余按错误码计数附在后面。
154
+ *
155
+ * @param {{code:string,message:string,provider?:string,model?:string}} primary 主失败(第一个候选)
156
+ * @param {Array<object>} [all] 全部候选的失败
157
+ * @returns {string}
158
+ */
159
+ export function describeDistillFailure(primary, all = []) {
160
+ const head = `${primary.code} ${primary.message}`
161
+ const rest = Array.isArray(all) ? all.slice(1) : []
162
+ if (!rest.length) return head
163
+ const counts = new Map()
164
+ for (const f of rest) {
165
+ const key = String((f && f.code) || '未知')
166
+ counts.set(key, (counts.get(key) || 0) + 1)
167
+ }
168
+ const summary = [...counts.entries()].map(([code, n]) => (n > 1 ? `${code}×${n}` : code)).join('、')
169
+ return `${head}(另有 ${rest.length} 个兜底候选失败:${summary})`
170
+ }
171
+
124
172
  const API_PREFIX = '/memory-eternal/api'
173
+
125
174
  // DSH 宿主自动沉淀卡的署名:用可读名而非 agent 会话 id,便于在智能体筛选中归组。
126
175
  const DSH_AGENT = 'deepseek-harness'
127
176
 
@@ -259,39 +308,74 @@ export function apply(ctx, config) {
259
308
  // 首次激活:自动从 .md 文件迁移到 SQLite(幂等,已有数据则跳过)
260
309
  // vault 解析与所有独立进程共用同一套优先级(见 lib/vault-resolve.js):
261
310
  // MEMORY_VAULT_DIR → activeVault → vaultProfiles[].match.workspace → vaultDir → 默认库。
262
- const vaultDir = () => resolveVaultDir({
311
+ //
312
+ // issue #15:宿主进程的 cwd 是「启动 dsh 时的目录」,不是会话的工作区,
313
+ // 所以 match.workspace 在宿主侧永远不命中(配置能写、代码也有,就是取不到工作区)。
314
+ // DSH 把会话目录放在 session.header.cwd —— 每条会话路径把它显式传下来即可,
315
+ // CLI / MCP / hooks 仍然按各自 cwd 解析(那条路径本来就正确)。
316
+ const sessionWorkspace = (agent) => sessionWorkspaceOf(agent, process.env)
317
+ const vaultDir = (workspace) => resolveVaultDir({
263
318
  profiles: (settings.get() ?? {}).vaultProfiles,
264
319
  activeVault: (settings.get() ?? {}).activeVault,
265
320
  configured: (settings.get() ?? {}).vaultDir,
321
+ workspace,
266
322
  }).root
267
- const vaultInfo = () => resolveVaultDir({
323
+ const vaultInfo = (workspace) => resolveVaultDir({
268
324
  profiles: (settings.get() ?? {}).vaultProfiles,
269
325
  activeVault: (settings.get() ?? {}).activeVault,
270
326
  configured: (settings.get() ?? {}).vaultDir,
327
+ workspace,
271
328
  })
272
329
 
273
330
  // 自动迁移:从 .md 文件导入 SQLite(幂等,DB 有数据则跳过)
274
331
  try { migrateFromMarkdown(vaultDir()).catch(() => {}) } catch {}
275
332
  // 同步审核配置到 SQLite config 表(enforceAudit 从此表读取规则)
276
- const syncAudit = () => {
333
+ // 审核配置要写进**会话实际使用的那个库**(#15 的按项目库):enforceAudit 在 DB 层读的是
334
+ // 本库的 config 表,只同步激活库会让 profile 库退回「调用方传入的 fallback」。
335
+ const syncAudit = (root) => {
277
336
  try {
278
337
  const cfg = settings.get() ?? {}
279
- setAuditConfig(vaultDir(), { auditMode: cfg.auditMode, auditExemptAgents: cfg.auditExemptAgents, auditExemptKinds: cfg.auditExemptKinds })
338
+ setAuditConfig(root || vaultDir(), { auditMode: cfg.auditMode, auditExemptAgents: cfg.auditExemptAgents, auditExemptKinds: cfg.auditExemptKinds })
280
339
  } catch {}
281
340
  }
282
341
  syncAudit()
283
- settings.watch(syncAudit)
342
+ // 注意:watch 的回调会被传入「合并后的配置对象」,而 syncAudit 现在接受的是**库路径** ——
343
+ // 必须包一层,否则热更新时会把配置对象当成路径传进 setAuditConfig(静默失败)。
344
+ settings.watch(() => syncAudit())
284
345
  // 独立 Web 页保存的配置:它写「待应用」文件,这边应用后删除(DSH 没运行时下次启动生效)
346
+ // 待应用配置的最近一次失败(#16):以前这里 catch {} 吞掉一切 —— 独立 Web 端显示
347
+ // 「已保存」,而 settings.update 的报错、乃至重试耗尽后「改动被放弃」都无人可见。
348
+ // 现在:报错进 stderr + 自动沉淀日志,并做去重节流(同一错误 5 分钟内只记一次,
349
+ // 否则 5 秒一轮的轮询会把日志刷爆)。
350
+ let lastDrainError = ''
351
+ let lastDrainErrorAt = 0
352
+ const reportDrainError = (error) => {
353
+ const dropped = !!(error && error.dropped)
354
+ const msg = String((error && error.message) || error || '未知错误')
355
+ const now = Date.now()
356
+ if (msg === lastDrainError && now - lastDrainErrorAt < 5 * 60 * 1000) return
357
+ lastDrainError = msg
358
+ lastDrainErrorAt = now
359
+ const why = dropped
360
+ ? '独立 Web 端的配置改动连续失败已放弃(文件保留以便排查):' + msg
361
+ : '独立 Web 端的配置改动应用失败,将重试:' + msg
362
+ try { console.error('[memory-eternal] ' + why) } catch { /* 日志失败无妨 */ }
363
+ try { logCapture('system', dropped ? 'fail' : 'warn', why) } catch { /* 日志失败无妨 */ }
364
+ }
285
365
  const drainPending = async () => {
286
366
  try {
287
367
  const { drainPendingConfig } = await import('./lib/config-sync.js')
288
368
  const applied = await drainPendingConfig(process.env, (patch) => settings.update(patch, undefined))
289
369
  if (applied) {
370
+ lastDrainError = ''
290
371
  syncConfigFile()
291
372
  syncAudit()
292
373
  try { console.error('[memory-eternal] 已应用独立 Web 端的配置改动: ' + Object.keys(applied).join(',')) } catch { /* 日志失败无妨 */ }
293
374
  }
294
- } catch { /* 失败保留文件,下一轮重试 */ }
375
+ } catch (error) {
376
+ // 失败时保留文件由 config-sync.js 负责;这里只保证「失败可见」
377
+ reportDrainError(error)
378
+ }
295
379
  }
296
380
  drainPending()
297
381
  // 5 秒轮询只是兜底;真正让它「准即时」的是下面这个文件监听(毫秒级)
@@ -308,9 +392,9 @@ export function apply(ctx, config) {
308
392
  }, 'memory-eternal: pending config watch')
309
393
 
310
394
  // 所有 profile 目录(当前激活 + 其余命名的),供跨库聚合。
311
- const vaultRoots = () => {
395
+ const vaultRoots = (workspace) => {
312
396
  const cfg = settings.get() ?? {}
313
- const active = vaultDir()
397
+ const active = vaultDir(workspace)
314
398
  const roots = [{ name: '', root: active }]
315
399
  const seen = new Set([active])
316
400
  const profiles = Array.isArray(cfg.vaultProfiles) ? cfg.vaultProfiles : []
@@ -328,7 +412,10 @@ export function apply(ctx, config) {
328
412
  try {
329
413
  const cfg = settings.get() ?? {}
330
414
  const { configFilePath } = await import('./lib/capture-run.js')
331
- await (await import('node:fs')).promises.writeFile(configFilePath(process.env), JSON.stringify(cfg, null, 2), 'utf8')
415
+ const { writeFileAtomicSync } = await import('./lib/config-sync.js')
416
+ // 原子写(tmp + rename,见 lib/config-sync.js 里 writeFileAtomicSync 的说明):
417
+ // 这个文件是跨进程共享的读源,非原子写会让独立 web / MCP 读到半截内容并静默回落成默认值。
418
+ writeFileAtomicSync(configFilePath(process.env), JSON.stringify(cfg, null, 2))
332
419
  } catch { /* 静默 */ }
333
420
  }
334
421
  syncConfigFile()
@@ -379,6 +466,14 @@ export function apply(ctx, config) {
379
466
  if (captureLog.length > CAPTURE_LOG_MAX) captureLog.splice(0, captureLog.length - CAPTURE_LOG_MAX)
380
467
  }).catch(() => {})
381
468
  logCapture('system', 'boot', `记忆核心 v${versionRef || '?'} 已加载:自动沉淀 ${c.enabled !== false && c.autoCapture !== false ? '开' : '关'} · 库 ${vaultDir()}`)
469
+ // 安装完整性自检(issue #14):插件市场装出来的副本可能缺 web/ 静态资源,那时侧边栏
470
+ // 「记忆」弹窗只会是一页 ENOENT JSON。启动就写进自动沉淀日志/健康态,别等用户点开才发现。
471
+ {
472
+ const missing = missingWebAssets(PACKAGE_ROOT)
473
+ if (missing.length) {
474
+ logCapture('system', missing.includes('app.js') ? 'fail' : 'warn', missingAssetsReason(missing, PACKAGE_ROOT))
475
+ }
476
+ }
382
477
  }
383
478
  const lastCaptureAt = new Map() // sessionId -> 上次实际发起蒸馏的时间戳
384
479
 
@@ -429,6 +524,9 @@ export function apply(ctx, config) {
429
524
  try {
430
525
  const cfg = settings.get() ?? {}
431
526
  if (!cfg.enabled || !cfg.autoCapture) return
527
+ // 本会话实际使用的库(#15):会话工作区命中 vaultProfiles[].match.workspace 时写进对应项目库。
528
+ const root = vaultDir(sessionWorkspace(agent))
529
+ if (root !== vaultDir()) syncAudit(root)
432
530
  const llm = ctx.get('llm')
433
531
  // 水位从 0 开始的那一轮(重启后同一会话的第一轮)会把整段历史切进来——
434
532
  // 只取尾部,避免一次超大 LLM 调用(超上下文/超时)。
@@ -462,7 +560,7 @@ export function apply(ctx, config) {
462
560
  const source = DSH_AGENT
463
561
  if (cfg.distillEnabled === false || !llm) {
464
562
  const rawTitle = deriveTitle(text)
465
- const out = await captureCard(vaultDir(), {
563
+ const out = await captureCard(root, {
466
564
  kind: 'content',
467
565
  title: rawTitle.length >= 6 && !looksTruncated(rawTitle) ? rawTitle : '对话原文记录',
468
566
  tags: ['raw'],
@@ -484,39 +582,55 @@ export function apply(ctx, config) {
484
582
  // 语义去重近邻:把已有卡片索引喂给模型,让模型决定新建 vs 追加。
485
583
  // 成本控制:dedupByLLM=false 时跳过喂 LLM 的近邻采样(纯词法去重兜底)。
486
584
  const draft = { title: '', body: text.slice(0, 400) }
487
- const neighbors = cfg.dedupByLLM === false ? [] : await pickNeighbors(vaultDir(), draft, 8)
585
+ const neighbors = cfg.dedupByLLM === false ? [] : await pickNeighbors(root, draft, 8)
488
586
  let route = routes[0]
489
587
  let result = null
490
- let failure = null
588
+ let failure = null // 主失败 = **第一个**候选的失败(通常是显式配置的那个,信息量最大)
589
+ const failures = [] // 全部候选的失败,用于汇总上报(#18 根因 2)
590
+ const maxTokens = Number(cfg.captureMaxTokens) || DEFAULT_CAPTURE_MAX_TOKENS
491
591
  for (let i = 0; i < routes.length; i++) {
492
592
  route = routes[i]
493
- const detailed = await summarizeTurnDetailed(llm, route, text, { signal: AbortSignal.timeout(45000), existing: neighbors, maxTokens: cfg.captureMaxTokens ?? 900 })
593
+ let detailed = await summarizeTurnDetailed(llm, route, text, { signal: AbortSignal.timeout(45000), existing: neighbors, maxTokens })
594
+ // #18 建议 3:撞输出上限不是「模型坏了」,直接把上限翻倍再试一次同一候选
595
+ // (截断的产物既解析不了、也不是「不值得保存」,不该就此退成原文卡)。
596
+ if (detailed.failure && detailed.failure.code === 'MAX_TOKENS' && maxTokens < MAX_CAPTURE_MAX_TOKENS) {
597
+ const bigger = Math.min(maxTokens * 2, MAX_CAPTURE_MAX_TOKENS)
598
+ logCapture(sessionId, 'listen', `输出撞到 maxTokens=${maxTokens} 上限 → 以 ${bigger} 重试同一候选(${route.provider}/${route.model})`)
599
+ detailed = await summarizeTurnDetailed(llm, route, text, { signal: AbortSignal.timeout(45000), existing: neighbors, maxTokens: bigger })
600
+ }
494
601
  if (detailed.card !== undefined) { result = detailed.card; failure = null; break }
495
602
  if (detailed.failure) {
496
- failure = { ...detailed.failure, provider: route.provider, model: route.model }
603
+ const f = { ...detailed.failure, provider: route.provider, model: route.model }
604
+ failures.push(f)
605
+ // 只保留第一个:旧实现每轮覆盖,最终上报的是**最后一个兜底候选**的错误,
606
+ // 真正有信息量的主因被彻底丢弃(#18 根因 2)。
607
+ if (!failure) failure = f
497
608
  const more = i < routes.length - 1
498
- logCapture(sessionId, 'fail', `蒸馏调用失败(${route.provider}/${route.model}):${failure.code} ${failure.message}${more ? ' → 换下一个 provider 重试' : ''}`)
609
+ logCapture(sessionId, 'fail', `蒸馏调用失败(${route.provider}/${route.model}):${f.code} ${f.message}${more ? ' → 换下一个 provider 重试' : ''}`)
499
610
  if (more) continue
500
611
  }
501
612
  break // skip(太短 / 不值得保存)或没有更多候选
502
613
  }
503
614
  // 真失败要亮红并进提示段:此前只有笼统的「蒸馏无输出」,真实原因(如缺凭证)完全不可见。
504
615
  if (failure) { health.fail(`蒸馏失败(${failure.provider}):${failure.code} ${failure.message}`); touchPrompt() }
616
+ // 主因 + 兜底候选失败汇总:卡片 reason 与 created 日志都用这个,避免把排查者引向错误方向。
617
+ const why = failure ? describeDistillFailure(failure, failures) : '无可用产出'
505
618
  if (!result) {
506
619
  // 蒸馏失败不能让内容白丢:退成原文卡(与「关闭蒸馏」同一条降级路径)。
507
620
  const rawTitle = deriveTitle(text)
508
- const raw = await captureCard(vaultDir(), {
621
+ const raw = await captureCard(root, {
509
622
  kind: 'content',
510
623
  title: rawTitle.length >= 6 && !looksTruncated(rawTitle) ? rawTitle : '对话原文记录',
511
- tags: ['raw'],
624
+ // 蒸馏失败产出的原文卡与被批准的噪声卡是审核中心的主要污染源(#15 评论):
625
+ // 打上独立 tag,便于按 tag 过滤 / 批量处理,不再和正常卡混作一团。
626
+ tags: ['raw', 'distill-failed'],
512
627
  body: text,
513
628
  source,
514
629
  status: resolveAuditStatus(cfg, 'content', source),
515
630
  submittedBy: source,
516
631
  severity: 'info',
517
- reason: 'AI 自动沉淀(蒸馏无输出 → 原文卡兜底)',
632
+ reason: `AI 自动沉淀(蒸馏无输出 → 原文卡兜底;主因:${why})`,
518
633
  }, { threshold: cfg.dedupThreshold })
519
- const why = failure ? `${failure.code} ${failure.message}` : '无可用产出'
520
634
  if (raw.ok) { countWrite(); logCapture(sessionId, 'created', `蒸馏失败(${why})→ 原文卡兜底`, { path: raw.path ?? raw.rel, kind: 'content', model: route.model }) }
521
635
  else if (raw.duplicate) { countWrite(); logCapture(sessionId, 'appended', '蒸馏无输出 + 与已有卡重复 → 追加更新', { path: raw.duplicate.path, model: route.model }) }
522
636
  else logCapture(sessionId, 'fail', `蒸馏无输出,且兜底原文卡也失败:${raw.reason || '未知原因'}`, { model: route.model })
@@ -525,7 +639,7 @@ export function apply(ctx, config) {
525
639
  if (result.save !== true) { logCapture(sessionId, 'skip', '模型判定不值得保存', { model: route.model }); return }
526
640
  if (result.append_to) {
527
641
  // 模型判定属于已有卡 → 追加更新记录,不新建(boujoy 语义)。
528
- await captureUpdate(vaultDir(), result.append_to, result.update, { threshold: cfg.dedupThreshold })
642
+ await captureUpdate(root, result.append_to, result.update, { threshold: cfg.dedupThreshold })
529
643
  countWrite()
530
644
  logCapture(sessionId, 'appended', '模型判定属于已有卡 → 追加更新', { path: result.append_to, model: route.model })
531
645
  return
@@ -541,7 +655,7 @@ export function apply(ctx, config) {
541
655
  severity: 'info',
542
656
  reason: 'AI 自动沉淀(蒸馏卡)',
543
657
  }
544
- const out = await captureCard(vaultDir(), card, { threshold: cfg.dedupThreshold })
658
+ const out = await captureCard(root, card, { threshold: cfg.dedupThreshold })
545
659
  if (out.ok) {
546
660
  countWrite()
547
661
  logCapture(sessionId, 'created', `新卡:${card.title}(${card.status === 'approved' ? '已入库' : '待审核'})`, { path: out.path ?? out.rel, kind: card.kind, model: route.model })
@@ -549,7 +663,7 @@ export function apply(ctx, config) {
549
663
  }
550
664
  if (out.duplicate) {
551
665
  // 词法兜底:高度相似 → 追加更新记录而不是再建一张重复卡。
552
- await captureUpdate(vaultDir(), out.duplicate.path, `${result.title}:${result.body.slice(0, 400)}`, {
666
+ await captureUpdate(root, out.duplicate.path, `${result.title}:${result.body.slice(0, 400)}`, {
553
667
  threshold: cfg.dedupThreshold,
554
668
  })
555
669
  countWrite()
@@ -676,11 +790,14 @@ export function apply(ctx, config) {
676
790
  render(_a, v) { return [{ type: 'text', text: v }] },
677
791
  },
678
792
  timeoutMs: 20000,
679
- async execute(args) {
793
+ // 第二个参数是 ToolRunContext(含 caller agent)—— 用它取会话工作区,
794
+ // 让「当前库」也按项目路由,而不是永远落在进程 cwd 对应的库(#15)。
795
+ async execute(args, exec) {
680
796
  const cfg = settings.get() ?? {}
681
797
  if (!cfg.enabled) return '(记忆核心已禁用)'
682
798
  const query = String(args.query || '').trim()
683
799
  if (!query) return '(未提供检索词)'
800
+ const workspace = sessionWorkspace(exec && exec.agent)
684
801
  const cfg2 = settings.get() ?? {}
685
802
  const defLimit = Number(cfg2.recallLimit) || 5
686
803
  const defLen = Number(cfg2.recallSummaryLen) || 130
@@ -688,8 +805,8 @@ export function apply(ctx, config) {
688
805
  const limit = Math.min(Math.max(Number(args.limit) || defLimit, 1), 20)
689
806
  // 作用域(#10):留空 = 当前激活库;all = 跨库聚合;库名 = 指定 profile;路径前缀 = 命中多个库
690
807
  const scope = String(args.scope || '').trim()
691
- const roots = vaultRoots()
692
- let targets = [{ name: '', root: vaultDir() }]
808
+ const roots = vaultRoots(workspace)
809
+ let targets = [{ name: '', root: vaultDir(workspace) }]
693
810
  let scoped = false
694
811
  if (scope) {
695
812
  const want = scope.toLowerCase()
@@ -907,7 +1024,7 @@ export function apply(ctx, config) {
907
1024
  retried: !!(writeResult && writeResult.retried),
908
1025
  note: pending.length
909
1026
  ? '已写入;宿主尚未回流这些值(面板已本地生效,重启 DSH 后以配置文件为准)'
910
- : '已保存。autoWebMode/watchdogAutoSpawn 等需重启 DSH 生效',
1027
+ : '已保存。autoWebMode/watchdogAutoSpawn 等需重启 DSH 生效;若已有常驻 watchdog,关闭/改参不会自动停掉它,需 dsh-memory stop / restart(#19)',
911
1028
  })
912
1029
  } catch (e) {
913
1030
  if (e && e.code === 'SETTINGS_CONFLICT') return json(res, 409, { ok: false, error: '配置已被外部修改,请刷新后重试(revision conflict)' })
@@ -1020,10 +1137,26 @@ export function apply(ctx, config) {
1020
1137
  }
1021
1138
 
1022
1139
  // (3) 看门狗独立进程:默认不 spawn;开启时启动一个与 DSH 解耦的 node watchdog。
1140
+ const wdPort = Number(cfg0.webPort) || 7999
1023
1141
  if (cfg0.watchdogAutoSpawn === true && cfg0.autoWeb !== false) {
1024
1142
  import('./lib/watchdog.js')
1025
1143
  .then((m) => {
1026
- const port = Number(cfg0.webPort) || 7999
1144
+ // 同端口已有**活着的** watchdog 时不再 spawn(issue #19):旧实现每次都 spawn 一个、
1145
+ // 每次都打印 "spawned",而新进程会在锁上让位退出 —— 日志与实际状态完全相反,
1146
+ // 排查时必然误判。这里如实打印 delegated,并提示版本漂移与替换命令。
1147
+ const lock = m.readWatchdogLock(process.env)
1148
+ const existing = lock.watchdogs.find((w) => Number(w.port) === wdPort && m.isPidAlive(w.pid))
1149
+ if (existing) {
1150
+ const cur = m.currentPkgVersion()
1151
+ const stale = !!(existing.pkgVersion && cur && existing.pkgVersion !== cur)
1152
+ const why = stale
1153
+ ? `watchdog delegated to existing pid=${existing.pid} port=${wdPort}(常驻实例是旧版 v${existing.pkgVersion},当前 v${cur};如需换新版请执行 dsh-memory restart --port ${wdPort})`
1154
+ : `watchdog delegated to existing pid=${existing.pid} port=${wdPort}(同端口已有常驻实例,本次不再 spawn)`
1155
+ console.error(`[memory-eternal] ${why}`)
1156
+ logCapture('system', stale ? 'warn' : 'boot', why)
1157
+ return
1158
+ }
1159
+ const port = wdPort
1027
1160
  // 用 nodeBinary() 而非 process.execPath:Electron 宿主下后者是 Electron
1028
1161
  // 主程序,spawn 出来不会执行 watchdog.js(见 lib/node-bin.js)。
1029
1162
  const bin = nodeBinary()
@@ -1047,11 +1180,25 @@ export function apply(ctx, config) {
1047
1180
  console.error(`[memory-eternal] ${why}`)
1048
1181
  logCapture('system', 'fail', why)
1049
1182
  })
1183
+ } else if (cfg0.autoWeb !== false) {
1184
+ // 配置关掉了 watchdogAutoSpawn,但机器上可能仍有常驻实例(#19):**故意不自动杀**
1185
+ // (多会话共用同一个 watchdog),但必须把「它还在跑」讲清楚,否则用户以为已经关了。
1186
+ import('./lib/watchdog.js')
1187
+ .then((m) => {
1188
+ const alive = m.readWatchdogLock(process.env).watchdogs.filter((w) => Number(w.port) === wdPort && m.isPidAlive(w.pid))
1189
+ if (!alive.length) return
1190
+ const why = `watchdogAutoSpawn 已关闭,但仍有常驻 watchdog pid=${alive.map((w) => w.pid).join(',')} port=${wdPort} 在运行(配置改动不会自动停掉既有实例);如需停止请执行 dsh-memory stop --port ${wdPort}`
1191
+ console.error(`[memory-eternal] ${why}`)
1192
+ logCapture('system', 'warn', why)
1193
+ })
1194
+ .catch(() => {})
1050
1195
  }
1051
1196
 
1052
1197
  return () => {
1053
1198
  if (intervalTimer) { clearInterval(intervalTimer); intervalTimer = null }
1054
- // 注意:watchdog 进程是独立的,故意不杀(7×24 用法)—— 配置改变由下次重启 DSH 时重新 spawn 替换
1199
+ // 注意:watchdog 进程是独立的,**故意不杀**(7×24 与多会话共用同一个实例)。
1200
+ // 因此同端口下的配置变更(含关闭 watchdogAutoSpawn)不会自动停掉或替换既有实例,
1201
+ // 需要显式执行 dsh-memory stop / restart(issue #19)。
1055
1202
  }
1056
1203
  }, 'memory-eternal: multi-host ensure')
1057
1204
 
package/lib/api.js CHANGED
@@ -14,8 +14,8 @@
14
14
  import { gzipSync, brotliCompressSync, constants as zlibConstants } from 'node:zlib'
15
15
  import os from 'node:os'
16
16
  import { readFileSync } from 'node:fs'
17
- import { listCards, countCards, readCard, search, searchAll, graph, graphAll, overview, exportCards, deleteCard, writeCard, parseCard, stats, optimizeCandidates, optimizeApply, addFeedback, dailyCounts, mergeCards, ensureVault, auditQueue, recycleList, purgeExpired, setCardStatus, restoreCard } from './vault.js'
18
- import { compressExcerpt } from './capture.js'
17
+ import { listCards, countCards, readCard, search, searchAll, graph, graphAll, overview, exportCards, deleteCard, writeCard, parseCard, stats, optimizeCandidates, optimizeApply, addFeedback, dailyCounts, mergeCards, ensureVault, auditQueue, recycleList, purgeExpired, setCardStatus, restoreCard, mainStoreBodies } from './vault.js'
18
+ import { compressExcerpt, DEFAULT_CAPTURE_MAX_TOKENS } from './capture.js'
19
19
  import { buildDiagnostics } from './feedback.js'
20
20
  import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
21
21
  import { backupDb } from './db.js'
@@ -170,7 +170,12 @@ export function createApi(deps = {}) {
170
170
  logD = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.entries) ? raw.entries : [])
171
171
  } catch { logD = [] }
172
172
  const homeD = (() => { try { return os.homedir() } catch { return '' } })()
173
+ // 待应用配置的失败状态(#16):独立 Web 端保存 → pending 文件 → 宿主 drain 的路,
174
+ // 出了问题以前完全无迹可查,现在进诊断(「反馈异常」一键带出)。
175
+ let syncD = null
176
+ try { const { readPendingConfig } = await import('./config-sync.js'); syncD = readPendingConfig(process.env) } catch { syncD = null }
173
177
  const text = buildDiagnostics({
178
+ configSync: syncD,
174
179
  version: (dshD && dshD.version) || envD.MEMORY_ETERNAL_VERSION || '',
175
180
  dshVersion: (dshD && dshD.version) || '',
176
181
  host: dshD ? 'deepseek-harness' : 'standalone web',
@@ -205,7 +210,9 @@ export function createApi(deps = {}) {
205
210
  }
206
211
  case '/export': {
207
212
  await ensureVault(vaultRoot)
208
- json(res, 200, { ok: true, cards: await exportCards(vaultRoot) })
213
+ const cards = await exportCards(vaultRoot)
214
+ // 带上信封元信息:客户端可以直接把整个响应体当备份文件落下(见 /import 的兼容读取)。
215
+ json(res, 200, { ok: true, format: 'memory-eternal-vault', formatVersion: 1, exportedAt: new Date().toISOString(), count: cards.length, cards })
209
216
  return
210
217
  }
211
218
  case '/delete': {
@@ -282,19 +289,33 @@ export function createApi(deps = {}) {
282
289
  if (raw.length > 20 * 1024 * 1024) return json(res, 413, { ok: false, error: '文件过大' })
283
290
  let payload
284
291
  try { payload = JSON.parse(raw || '{}') } catch { return json(res, 400, { ok: false, error: 'JSON 解析失败' }) }
285
- const list = payload.cards || []
286
- if (!Array.isArray(list)) return json(res, 400, { ok: false, error: '缺少 cards 数组' })
287
- let imported = 0, skipped = 0
292
+ // 兼容两种备份形状:
293
+ // ① 顶层就是卡片数组 —— v0.10.0 及以前「导出JSON」写出的 memory-vault.json;
294
+ // ② 带信封的对象 { format, formatVersion, exportedAt, count, cards: [...] } —— v0.10.1 起。
295
+ // 旧实现只认 payload.cards,于是①被静默当成空备份(imported=0 / skipped=0 / ok=true,
296
+ // UI 只显示「导入完成:0」,看不出是格式不匹配)—— 这是 issue「导出 OK 导入 0 张」的根因。
297
+ const list = Array.isArray(payload) ? payload
298
+ : Array.isArray(payload?.cards) ? payload.cards
299
+ : null
300
+ if (!list) return json(res, 400, { ok: false, error: '文件格式无法识别:应为卡片数组,或含 cards 数组的备份对象(请用本插件「导出JSON」生成的文件)' })
301
+ if (!list.length) return json(res, 200, { ok: true, imported: 0, skipped: 0, quarantined: 0, total: 0, failed: [], warning: '文件里没有任何卡片' })
302
+ // 去重基线取「导入前已存在的主库卡」快照:备份里的近似卡不能在同一批里互相吞掉。
303
+ const baseline = await mainStoreBodies(vaultRoot)
304
+ let imported = 0, skipped = 0, quarantined = 0
305
+ const failed = []
288
306
  for (const c of list) {
307
+ if (!c || typeof c !== 'object') { skipped++; failed.push({ reason: '条目不是对象' }); continue }
289
308
  const text = c.text || ''
290
- let kind = c.kind || 'knowledge', title = c.title || '导入记忆', tags = [], body = text, source = '', status = c.status || 'pending'
291
- try { const p = parseCard(text); kind = p.meta.kind || kind; title = p.meta.title || title; tags = p.meta.tags || tags; body = p.body; source = p.meta.source || source; status = p.meta.status || status } catch {}
309
+ let kind = c.kind || 'knowledge', title = c.title || '导入记忆', tags = [], body = text, source = '', status = c.status || 'pending', deletedAt = c.deletedAt || ''
310
+ try { const p = parseCard(text); kind = p.meta.kind || kind; title = p.meta.title || title; tags = p.meta.tags || tags; body = p.body; source = p.meta.source || source; status = p.meta.status || status; deletedAt = p.meta.deletedAt || deletedAt } catch {}
311
+ if (!String(body || '').trim()) { skipped++; failed.push({ path: c.path || '', title: String(title).slice(0, 80), reason: '正文为空' }); continue }
292
312
  // 两套存储:导入按导出时的状态回填(备份/迁移才完整),但仍过 enforceAudit 这道闸门 ——
293
313
  // 校验不过的会被写成 pending 进隔离区,主库永远不会被导入数据绕过。
294
- const r = await writeCard(vaultRoot, { kind, title, tags, body, source, status })
295
- if (r.ok) imported++; else skipped++
314
+ const r = await writeCard(vaultRoot, { kind, title, tags, body, source, status, deletedAt }, { dedupAgainst: baseline })
315
+ if (r.ok) { imported++; if (r.store === 'quarantine') quarantined++ }
316
+ else { skipped++; failed.push({ path: c.path || '', title: String(title).slice(0, 80), reason: r.duplicate ? '已存在(内容重复)' : (r.error || '写入被拒') }) }
296
317
  }
297
- json(res, 200, { ok: true, imported, skipped })
318
+ json(res, 200, { ok: true, imported, skipped, quarantined, total: list.length, failed: failed.slice(0, 50) })
298
319
  return
299
320
  }
300
321
  case '/merge': {
@@ -405,7 +426,7 @@ export function createApi(deps = {}) {
405
426
  const safe = {
406
427
  autoCapture: cfg.autoCapture ?? true, autoRecall: cfg.autoRecall ?? true, recallLimit: cfg.recallLimit ?? 5, recallSummaryLen: cfg.recallSummaryLen ?? 130, recallIncludeBody: cfg.recallIncludeBody ?? false,
407
428
  captureMinChars: cfg.captureMinChars ?? 200, captureCooldownMs: cfg.captureCooldownMs ?? 300000, dedupThreshold: cfg.dedupThreshold ?? 0.62, maxCardsPerDay: cfg.maxCardsPerDay ?? 60,
408
- distillEnabled: cfg.distillEnabled ?? true, dedupByLLM: cfg.dedupByLLM ?? true, captureMaxTokens: cfg.captureMaxTokens ?? 900, recallMinScore: cfg.recallMinScore ?? 2,
429
+ distillEnabled: cfg.distillEnabled ?? true, dedupByLLM: cfg.dedupByLLM ?? true, captureMaxTokens: cfg.captureMaxTokens ?? DEFAULT_CAPTURE_MAX_TOKENS, recallMinScore: cfg.recallMinScore ?? 2,
409
430
  autoWeb: cfg.autoWeb ?? env.MEMORY_AUTO_WEB !== '0', autoWebMode: cfg.autoWebMode || 'init', webPort: Number(cfg.webPort) || Number(env.MEMORY_WEB_PORT) || 7999, webCheckIntervalMs: Number(cfg.webCheckIntervalMs) || 5000, webMaxRestart: Number(cfg.webMaxRestart) || 10, watchdogAutoSpawn: cfg.watchdogAutoSpawn ?? env.MEMORY_WATCHDOG === '1', autoMcpSetup: cfg.autoMcpSetup ?? false,
410
431
  auditMode: cfg.auditMode ?? 'all', auditExemptAgents: cfg.auditExemptAgents ?? [], auditExemptKinds: cfg.auditExemptKinds ?? [], recycleRetentionDays: cfg.recycleRetentionDays ?? 30,
411
432
  vaultProfiles: Array.isArray(cfg.vaultProfiles) ? cfg.vaultProfiles : [], activeVault: cfg.activeVault || '',
@@ -447,15 +468,28 @@ export function createApi(deps = {}) {
447
468
  }
448
469
  if (bad.length) return json(res, 400, { ok: false, error: '字段值不合法:' + bad.join('、'), fields: bad })
449
470
  if (!Object.keys(clean).length) return json(res, 400, { ok: false, error: '没有可写入的字段' })
450
- const { writePendingConfig } = await import('./config-sync.js')
471
+ const { writePendingConfig, readPendingConfig } = await import('./config-sync.js')
472
+ // #16 建议 4:上一份改动是否已被判定失败?是的话这次不能再报「一切正常」——
473
+ // 否则用户第 N 次点击保存,看到的仍是成功,而问题一直在(其实是 settings.update 在报错)。
474
+ const before = readPendingConfig(process.env)
451
475
  const out = writePendingConfig(process.env, clean)
452
476
  hub.broadcast('config', { at: out.at, source: 'standalone', applied: Object.keys(clean) })
477
+ const stale = before && (before.dropped || before.lastError)
453
478
  return json(res, 200, {
454
479
  ok: true, applied: Object.keys(clean), pending: Object.keys(clean), retried: false, at: out.at,
480
+ warning: stale
481
+ ? '上一次写入待应用时连续失败(' + (before.lastError || '原因未记录') + ');本次已重新写入并重试,如仍不生效请查看「反馈异常」里的诊断信息'
482
+ : undefined,
455
483
  note: '已写入待应用:DSH 端会自动同步(DSH 未运行时下次启动生效)',
456
484
  })
457
485
  }
458
- json(res, 200, { ok: true, config: safe, revision: 0, writable: true, readonly: false, viaPending: true, version: env.MEMORY_ETERNAL_VERSION || '' })
486
+ let pendingD = null
487
+ try { const { readPendingConfig } = await import('./config-sync.js'); pendingD = readPendingConfig(process.env) } catch { pendingD = null }
488
+ json(res, 200, {
489
+ ok: true, config: safe, revision: 0, writable: true, readonly: false, viaPending: true,
490
+ pendingConfig: pendingD ? { at: pendingD.at, tries: pendingD.tries, dropped: pendingD.dropped, lastError: pendingD.lastError } : null,
491
+ version: env.MEMORY_ETERNAL_VERSION || '',
492
+ })
459
493
  return
460
494
  }
461
495
  case '/compress': {