memory-eternal 0.9.23 → 0.10.2

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 } 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,12 +524,21 @@ 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 调用(超上下文/超时)。
435
533
  const raw = extractLastTurn(events)
436
534
  const text = raw.length > CAPTURE_TEXT_MAX ? raw.slice(-CAPTURE_TEXT_MAX) : raw
437
535
  logCapture(sessionId, 'listen', `会话事件 ${events.length} 条 → 有效对话 ${text.length} 字${raw.length > text.length ? '(超长已截尾)' : ''}`, { preview: preview(text) })
536
+ // 噪声闸门(P0):extractLastTurn 已剥掉运行时注入(环境快照 / team 广播 / 工具说明),
537
+ // 这里再判一次「剥完还有没有真实内容」——只有注入残留的轮次不进 LLM、不写卡。
538
+ if (!hasUsableContent(text)) {
539
+ logCapture(sessionId, 'skip', `有效内容不足:剥离运行时注入后只剩 ${text.trim().length} 字(多为环境快照 / 广播 / 工具说明)`)
540
+ return
541
+ }
438
542
  if (text.length < (cfg.captureMinChars ?? 200)) {
439
543
  logCapture(sessionId, 'skip', `内容太短:${text.length} < ${cfg.captureMinChars ?? 200} 字(调小「捕获最小长度」可放宽)`)
440
544
  return
@@ -455,9 +559,10 @@ export function apply(ctx, config) {
455
559
  // 成本控制:distillEnabled=false 时不调 LLM,直接存原文卡(零蒸馏成本)
456
560
  const source = DSH_AGENT
457
561
  if (cfg.distillEnabled === false || !llm) {
458
- const out = await captureCard(vaultDir(), {
562
+ const rawTitle = deriveTitle(text)
563
+ const out = await captureCard(root, {
459
564
  kind: 'content',
460
- title: text.replace(/\s+/g, ' ').slice(0, 40) || '未命名记录',
565
+ title: rawTitle.length >= 6 && !looksTruncated(rawTitle) ? rawTitle : '对话原文记录',
461
566
  tags: ['raw'],
462
567
  body: text,
463
568
  source,
@@ -477,38 +582,55 @@ export function apply(ctx, config) {
477
582
  // 语义去重近邻:把已有卡片索引喂给模型,让模型决定新建 vs 追加。
478
583
  // 成本控制:dedupByLLM=false 时跳过喂 LLM 的近邻采样(纯词法去重兜底)。
479
584
  const draft = { title: '', body: text.slice(0, 400) }
480
- const neighbors = cfg.dedupByLLM === false ? [] : await pickNeighbors(vaultDir(), draft, 8)
585
+ const neighbors = cfg.dedupByLLM === false ? [] : await pickNeighbors(root, draft, 8)
481
586
  let route = routes[0]
482
587
  let result = null
483
- let failure = null
588
+ let failure = null // 主失败 = **第一个**候选的失败(通常是显式配置的那个,信息量最大)
589
+ const failures = [] // 全部候选的失败,用于汇总上报(#18 根因 2)
590
+ const maxTokens = Number(cfg.captureMaxTokens) || DEFAULT_CAPTURE_MAX_TOKENS
484
591
  for (let i = 0; i < routes.length; i++) {
485
592
  route = routes[i]
486
- 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
+ }
487
601
  if (detailed.card !== undefined) { result = detailed.card; failure = null; break }
488
602
  if (detailed.failure) {
489
- 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
490
608
  const more = i < routes.length - 1
491
- 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 重试' : ''}`)
492
610
  if (more) continue
493
611
  }
494
612
  break // skip(太短 / 不值得保存)或没有更多候选
495
613
  }
496
614
  // 真失败要亮红并进提示段:此前只有笼统的「蒸馏无输出」,真实原因(如缺凭证)完全不可见。
497
615
  if (failure) { health.fail(`蒸馏失败(${failure.provider}):${failure.code} ${failure.message}`); touchPrompt() }
616
+ // 主因 + 兜底候选失败汇总:卡片 reason 与 created 日志都用这个,避免把排查者引向错误方向。
617
+ const why = failure ? describeDistillFailure(failure, failures) : '无可用产出'
498
618
  if (!result) {
499
619
  // 蒸馏失败不能让内容白丢:退成原文卡(与「关闭蒸馏」同一条降级路径)。
500
- const raw = await captureCard(vaultDir(), {
620
+ const rawTitle = deriveTitle(text)
621
+ const raw = await captureCard(root, {
501
622
  kind: 'content',
502
- title: text.replace(/\s+/g, ' ').slice(0, 40) || '未命名记录',
503
- tags: ['raw'],
623
+ title: rawTitle.length >= 6 && !looksTruncated(rawTitle) ? rawTitle : '对话原文记录',
624
+ // 蒸馏失败产出的原文卡与被批准的噪声卡是审核中心的主要污染源(#15 评论):
625
+ // 打上独立 tag,便于按 tag 过滤 / 批量处理,不再和正常卡混作一团。
626
+ tags: ['raw', 'distill-failed'],
504
627
  body: text,
505
628
  source,
506
629
  status: resolveAuditStatus(cfg, 'content', source),
507
630
  submittedBy: source,
508
631
  severity: 'info',
509
- reason: 'AI 自动沉淀(蒸馏无输出 → 原文卡兜底)',
632
+ reason: `AI 自动沉淀(蒸馏无输出 → 原文卡兜底;主因:${why})`,
510
633
  }, { threshold: cfg.dedupThreshold })
511
- const why = failure ? `${failure.code} ${failure.message}` : '无可用产出'
512
634
  if (raw.ok) { countWrite(); logCapture(sessionId, 'created', `蒸馏失败(${why})→ 原文卡兜底`, { path: raw.path ?? raw.rel, kind: 'content', model: route.model }) }
513
635
  else if (raw.duplicate) { countWrite(); logCapture(sessionId, 'appended', '蒸馏无输出 + 与已有卡重复 → 追加更新', { path: raw.duplicate.path, model: route.model }) }
514
636
  else logCapture(sessionId, 'fail', `蒸馏无输出,且兜底原文卡也失败:${raw.reason || '未知原因'}`, { model: route.model })
@@ -517,7 +639,7 @@ export function apply(ctx, config) {
517
639
  if (result.save !== true) { logCapture(sessionId, 'skip', '模型判定不值得保存', { model: route.model }); return }
518
640
  if (result.append_to) {
519
641
  // 模型判定属于已有卡 → 追加更新记录,不新建(boujoy 语义)。
520
- await captureUpdate(vaultDir(), result.append_to, result.update, { threshold: cfg.dedupThreshold })
642
+ await captureUpdate(root, result.append_to, result.update, { threshold: cfg.dedupThreshold })
521
643
  countWrite()
522
644
  logCapture(sessionId, 'appended', '模型判定属于已有卡 → 追加更新', { path: result.append_to, model: route.model })
523
645
  return
@@ -533,7 +655,7 @@ export function apply(ctx, config) {
533
655
  severity: 'info',
534
656
  reason: 'AI 自动沉淀(蒸馏卡)',
535
657
  }
536
- const out = await captureCard(vaultDir(), card, { threshold: cfg.dedupThreshold })
658
+ const out = await captureCard(root, card, { threshold: cfg.dedupThreshold })
537
659
  if (out.ok) {
538
660
  countWrite()
539
661
  logCapture(sessionId, 'created', `新卡:${card.title}(${card.status === 'approved' ? '已入库' : '待审核'})`, { path: out.path ?? out.rel, kind: card.kind, model: route.model })
@@ -541,7 +663,7 @@ export function apply(ctx, config) {
541
663
  }
542
664
  if (out.duplicate) {
543
665
  // 词法兜底:高度相似 → 追加更新记录而不是再建一张重复卡。
544
- 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)}`, {
545
667
  threshold: cfg.dedupThreshold,
546
668
  })
547
669
  countWrite()
@@ -668,11 +790,14 @@ export function apply(ctx, config) {
668
790
  render(_a, v) { return [{ type: 'text', text: v }] },
669
791
  },
670
792
  timeoutMs: 20000,
671
- async execute(args) {
793
+ // 第二个参数是 ToolRunContext(含 caller agent)—— 用它取会话工作区,
794
+ // 让「当前库」也按项目路由,而不是永远落在进程 cwd 对应的库(#15)。
795
+ async execute(args, exec) {
672
796
  const cfg = settings.get() ?? {}
673
797
  if (!cfg.enabled) return '(记忆核心已禁用)'
674
798
  const query = String(args.query || '').trim()
675
799
  if (!query) return '(未提供检索词)'
800
+ const workspace = sessionWorkspace(exec && exec.agent)
676
801
  const cfg2 = settings.get() ?? {}
677
802
  const defLimit = Number(cfg2.recallLimit) || 5
678
803
  const defLen = Number(cfg2.recallSummaryLen) || 130
@@ -680,8 +805,8 @@ export function apply(ctx, config) {
680
805
  const limit = Math.min(Math.max(Number(args.limit) || defLimit, 1), 20)
681
806
  // 作用域(#10):留空 = 当前激活库;all = 跨库聚合;库名 = 指定 profile;路径前缀 = 命中多个库
682
807
  const scope = String(args.scope || '').trim()
683
- const roots = vaultRoots()
684
- let targets = [{ name: '', root: vaultDir() }]
808
+ const roots = vaultRoots(workspace)
809
+ let targets = [{ name: '', root: vaultDir(workspace) }]
685
810
  let scoped = false
686
811
  if (scope) {
687
812
  const want = scope.toLowerCase()
@@ -899,7 +1024,7 @@ export function apply(ctx, config) {
899
1024
  retried: !!(writeResult && writeResult.retried),
900
1025
  note: pending.length
901
1026
  ? '已写入;宿主尚未回流这些值(面板已本地生效,重启 DSH 后以配置文件为准)'
902
- : '已保存。autoWebMode/watchdogAutoSpawn 等需重启 DSH 生效',
1027
+ : '已保存。autoWebMode/watchdogAutoSpawn 等需重启 DSH 生效;若已有常驻 watchdog,关闭/改参不会自动停掉它,需 dsh-memory stop / restart(#19)',
903
1028
  })
904
1029
  } catch (e) {
905
1030
  if (e && e.code === 'SETTINGS_CONFLICT') return json(res, 409, { ok: false, error: '配置已被外部修改,请刷新后重试(revision conflict)' })
@@ -1012,10 +1137,26 @@ export function apply(ctx, config) {
1012
1137
  }
1013
1138
 
1014
1139
  // (3) 看门狗独立进程:默认不 spawn;开启时启动一个与 DSH 解耦的 node watchdog。
1140
+ const wdPort = Number(cfg0.webPort) || 7999
1015
1141
  if (cfg0.watchdogAutoSpawn === true && cfg0.autoWeb !== false) {
1016
1142
  import('./lib/watchdog.js')
1017
1143
  .then((m) => {
1018
- 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
1019
1160
  // 用 nodeBinary() 而非 process.execPath:Electron 宿主下后者是 Electron
1020
1161
  // 主程序,spawn 出来不会执行 watchdog.js(见 lib/node-bin.js)。
1021
1162
  const bin = nodeBinary()
@@ -1039,11 +1180,25 @@ export function apply(ctx, config) {
1039
1180
  console.error(`[memory-eternal] ${why}`)
1040
1181
  logCapture('system', 'fail', why)
1041
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(() => {})
1042
1195
  }
1043
1196
 
1044
1197
  return () => {
1045
1198
  if (intervalTimer) { clearInterval(intervalTimer); intervalTimer = null }
1046
- // 注意:watchdog 进程是独立的,故意不杀(7×24 用法)—— 配置改变由下次重启 DSH 时重新 spawn 替换
1199
+ // 注意:watchdog 进程是独立的,**故意不杀**(7×24 与多会话共用同一个实例)。
1200
+ // 因此同端口下的配置变更(含关闭 watchdogAutoSpawn)不会自动停掉或替换既有实例,
1201
+ // 需要显式执行 dsh-memory stop / restart(issue #19)。
1047
1202
  }
1048
1203
  }, 'memory-eternal: multi-host ensure')
1049
1204