@a9i5k4/dsh-auto-memory 3.1.1 → 3.1.4

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/lib/index.js CHANGED
@@ -22,7 +22,7 @@ import { createReadStream, existsSync, readdirSync, readFileSync, writeFileSync,
22
22
  import { exec as cpExec } from 'node:child_process'
23
23
  import { promisify } from 'node:util'
24
24
  const execP = promisify(cpExec)
25
- import { homedir } from 'node:os'
25
+ import { homedir, hostname as hostnameOf } from 'node:os'
26
26
  import { randomUUID } from 'node:crypto'
27
27
  import path from 'node:path'
28
28
  import { fileURLToPath } from 'node:url'
@@ -59,7 +59,7 @@ import { pickConsolidationTextPre } from './intent-clean.js'
59
59
  // 必须独占一行(本仓契约守卫以字面量断言既有 import 行,禁止为排版合并)。
60
60
  import { stripRuntimeIntentPre, looksRuntimeResiduePre } from './intent-clean-safe.js'
61
61
  import { createStorageManagerPre } from './storage-manage.js'
62
- import { workspaceSlugPre as migrateSlugPre, buildPackPre, validatePackPre, planImportPre, mergeSummaryRecordPre, MIGRATE_PACK_FORMAT_PRE } from './migrate-pack.js'
62
+ import { workspaceSlugPre as migrateSlugPre, buildPackPre, validatePackPre, planImportPre, mergeSummaryRecordPre, calendarMergePre, MIGRATE_PACK_FORMAT_PRE } from './migrate-pack.js'
63
63
  import { rankWorkspacesByMemoryRecencyPre } from './ws-overview-rank.js'
64
64
  import { scanPluginSubagentSessions, recycleSessions, PLUGIN_LABEL_PREFIX, decodeZstdFrames, decodeZstdFramesHead } from './subagent-gc.js'
65
65
  import { parseModelWindowsPre, pickWindowPre, findOfficialContextWindowPre, findSessionModelPre, scanPressureSignalsPre, reusableWindowCachePre, shouldArmAutoContinuePre } from './water-window.js'
@@ -254,6 +254,8 @@ export const API = {
254
254
  'semantic-deep-detect': '/api/dsh-auto-memory/semantic-deep-detect',
255
255
  'handoff-state': '/api/dsh-auto-memory/handoff-state',
256
256
  'kanban-board': '/api/dsh-auto-memory/kanban-board',
257
+ // ★v3.1.2:卡片全文按需取(看板载荷不再内联 full ⇒ 展开全文时单取一张)。
258
+ 'kanban-card': '/api/dsh-auto-memory/kanban-card',
257
259
  'handoff-continue': '/api/dsh-auto-memory/handoff-continue',
258
260
  'handoff-permission': '/api/dsh-auto-memory/handoff-permission',
259
261
  'auto-continue-state': '/api/dsh-auto-memory/auto-continue-state',
@@ -601,6 +603,8 @@ const DEFAULT_CONFIG = {
601
603
  */
602
604
  l0IndexEnabled: true,
603
605
  /** pre-step 软注入。 */
606
+ /** ★v3.1.3 审计标注:本键当前**无消费者**(全 lib/ 零读取点、设置页零控件)——
607
+ * 属保留字段(不删,避免历史配置反序列化告警)。若将来启用,请同时补设置页控件与守卫。 */
604
608
  softInjectionEnabled: false,
605
609
  /** M7.5:分支/子代理会话是否也纳入上下文观测(shadow 观测零注入风险;2026-08-26 裁定默认开:开源模型为主,思维链/分支是主要观测面)。 */
606
610
  contextBridgeObserveChildSessions: true,
@@ -2451,16 +2455,22 @@ class MemoryEngine {
2451
2455
  } catch (e) { return '' }
2452
2456
  }
2453
2457
 
2454
- /** 最新一篇四段式交接账本(按 mtime 取最新;同秒 -b 后缀文件也参与竞争)。 */
2458
+ /** 最新一篇四段式交接账本(按 mtime 取最新;mtime 相同用文件名时间戳 + 同秒后缀稳定决胜)。 */
2455
2459
  async readLatestHandoff(handoffDir) {
2456
2460
  try {
2457
2461
  const names = (await readdir(handoffDir)).filter((n) => /^handoff-\d{8}-\d{6}(-[a-z])?\.md$/.test(n))
2458
2462
  if (!names.length) return ''
2459
- let best = '', bestMt = -1
2463
+ let best = '', bestMt = -1, bestOrder = ''
2460
2464
  for (const n of names) {
2461
2465
  const st = await stat(path.join(handoffDir, n)).catch(() => null)
2462
2466
  const mt = st ? Number(st.mtimeMs) : 0
2463
- if (mt >= bestMt) { bestMt = mt; best = n }
2467
+ const m = n.match(/^handoff-(\d{8}-\d{6})(?:-([a-z]))?\.md$/)
2468
+ const order = m ? (m[1] + '-' + (m[2] || 'a')) : n
2469
+ if (mt > bestMt || (mt === bestMt && order > bestOrder)) {
2470
+ bestMt = mt
2471
+ bestOrder = order
2472
+ best = n
2473
+ }
2464
2474
  }
2465
2475
  return best ? ((await this.readTextSafe(path.join(handoffDir, best))) || '') : ''
2466
2476
  } catch (e) { return '' }
@@ -2552,8 +2562,12 @@ class MemoryEngine {
2552
2562
  projectDir,
2553
2563
  relPath: 'handoff/PLAN.md',
2554
2564
  skipCriteria: !!(opts && opts.skipCriteria),
2565
+ // ★v3.1.4 批 J:显式归档 id 贯穿到保护门(此前这一层从不传 ⇒ 报错里那句是假出路)
2566
+ archivedIds: Array.isArray(opts && opts.archivedIds) ? opts.archivedIds : [],
2555
2567
  })
2556
2568
  if (!gate.ok) return { ok: false, error: gate.error, gate: gate.gate, report: gate.report }
2569
+ // ★v3.1.4 批 J:M1 降级警示(见 checkMutationPre)——一路透传到工具层
2570
+ const m1Warn = (gate && gate.m1Downgraded) ? String(gate.error || '') : ''
2557
2571
  let archived = ''
2558
2572
  if (existing && existing.trim() && existing.trim() !== incoming.trim()) {
2559
2573
  const archDir = path.join(dir, 'archive')
@@ -2615,7 +2629,7 @@ class MemoryEngine {
2615
2629
  if ((String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
2616
2630
  await this.appendSidecarEventPre(projectDir, { actor: 'engine', event: 'written', target: 'PLAN.md', details: { chars: written.length, movedHistory: movedHistory || 0 } })
2617
2631
  }
2618
- return { ok: true, path: planPath, archived, movedHistory: movedHistory || undefined, historyPath: historyPath || undefined, final: written }
2632
+ return { ok: true, path: planPath, archived, movedHistory: movedHistory || undefined, historyPath: historyPath || undefined, final: written, warn: m1Warn }
2619
2633
  } catch (e) {
2620
2634
  return { ok: false, error: String((e && e.message) || e) }
2621
2635
  }
@@ -2869,6 +2883,39 @@ class MemoryEngine {
2869
2883
  * (见 buildContinueCarry 头注释: 关时既不写、也不读) ⇒ 它管写入/取材; **看板是渲染**, 不该被它拦。
2870
2884
  * 保持者: boardMode 门(下方那条) —— 那是渲染形态开关, 且 legacy 须与旧行为逐字节相同(:3673 纪律)。
2871
2885
  */
2886
+ /**
2887
+ * ★v3.1.2:单卡全文(看板「展开全文」按需取)。
2888
+ * 为什么单独开:看板载荷不再内联 full(1178 卡 × ≤6000 字符 ≈ 0.85 MB,且 lanes+matrix 双份),
2889
+ * 前端默认只渲染 preview,全文仅点开时才需要 ⇒ 按 id 单取更省。
2890
+ * 复用与看板同一读盘口径(_collectWhiteboardDocsPre + buildSectionCardsPre),保证 id 可命中。
2891
+ */
2892
+ async kanbanCardBody(sessionId, cardId, opts = {}) {
2893
+ const want = String(cardId || '').trim()
2894
+ if (!want) return { ok: false, error: 'missing-card-id' }
2895
+ if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
2896
+ try {
2897
+ let p, wsBound = true
2898
+ if (sessionId) {
2899
+ p = await this.resolvePathsForSession(sessionId)
2900
+ wsBound = p.wsBound !== false
2901
+ } else {
2902
+ p = (this.state.handoffDir || this.state.planPath)
2903
+ ? { handoffDir: this.state.handoffDir || path.dirname(this.state.planPath), planPath: this.state.planPath, ws: this.state.ws, projectDir: this.state.projectDir }
2904
+ : await this.resolvePaths(undefined)
2905
+ }
2906
+ if (!wsBound) return { ok: false, error: 'unbound-ws' }
2907
+ const projectDir = p.projectDir || path.dirname(p.handoffDir)
2908
+ // 展开全文要能找到**任意**一张卡(含归档),故这里 includeArchive: true。
2909
+ const docs = await this._collectWhiteboardDocsPre(projectDir, { includeArchive: true })
2910
+ const cards = buildSectionCardsPre(this.wbWsKeyPre(projectDir), docs)
2911
+ const hit = cards.find((c) => String(c.id || '') === want)
2912
+ if (!hit) return { ok: false, error: 'card-not-found' }
2913
+ return { ok: true, id: want, full: String(hit.body || hit.preview || '').slice(0, 6000) }
2914
+ } catch (e) {
2915
+ return { ok: false, error: String(e && e.message ? e.message : e) }
2916
+ }
2917
+ }
2918
+
2872
2919
  async kanbanBoardData(sessionId, opts = {}) {
2873
2920
  if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
2874
2921
  if (!(String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
@@ -2890,7 +2937,8 @@ class MemoryEngine {
2890
2937
  // v1 用文件级条目 ⇒ 一篇账本压成一根卡(实测 92 文件仅 92 条, 且 sectionOfPre 无 break
2891
2938
  // 导致整篇归一到最后一个 ## 标题, 三条泳道恒空)。改为小节级后实测 92 → 417 条,
2892
2939
  // 目标/进行中/失败与弯路/进度 四条泳道全部有内容。
2893
- const docs = await this._collectWhiteboardDocsPre(projectDir)
2940
+ // ★v3.1.2 性能:看板默认不含 archive(用户拍板 C)——归档只做历史留档,不进看板投影。
2941
+ const docs = await this._collectWhiteboardDocsPre(projectDir, { includeArchive: false })
2894
2942
  const cards = buildSectionCardsPre(this.wbWsKeyPre(projectDir), docs)
2895
2943
  const index = await this._loadSidecarIndexPre(projectDir)
2896
2944
  const kb = buildKanbanPre(index, { now: new Date().toISOString(), cards, perLaneCap: Number(opts.perLaneCap) || 0 })
@@ -2909,7 +2957,10 @@ class MemoryEngine {
2909
2957
  * 读取白板/账本文档清单(看板与 sidecar 重建的**唯一读盘口径**)。
2910
2958
  * 返回 [{relPath, text, title, kind, mtime, ts}] —— 供小节切分与文件级重建共用。
2911
2959
  */
2912
- async _collectWhiteboardDocsPre(projectDir) {
2960
+ // ★v3.1.2 性能(用户拍板 C):归档默认不进**看板**投影。
2961
+ // 实测 handoff/ 活跃账本 157 个 / archive 99 个,每次 /kanban-board 全量读盘+切分;
2962
+ // 归档属于历史留档,看板默认只看 活跃账本 + PLAN(白板/账本本身仍全量可读,不受影响)。
2963
+ async _collectWhiteboardDocsPre(projectDir, opts = {}) {
2913
2964
  const sideDir = path.join(projectDir, 'handoff')
2914
2965
  const docs = []
2915
2966
  const planText = await this.readTextSafe(path.join(sideDir, 'PLAN.md'))
@@ -2924,7 +2975,7 @@ class MemoryEngine {
2924
2975
  kind: 'ledger', mtime: st ? Number(st.mtimeMs) : 0,
2925
2976
  })
2926
2977
  }
2927
- const archNames = await readdir(path.join(sideDir, 'archive')).catch(() => [])
2978
+ const archNames = (opts && opts.includeArchive) ? await readdir(path.join(sideDir, 'archive')).catch(() => []) : []
2928
2979
  for (const f of archNames.filter((x) => /\.md$/.test(x)).sort()) {
2929
2980
  const st = await stat(path.join(sideDir, 'archive', f)).catch(() => null)
2930
2981
  docs.push({
@@ -2935,6 +2986,55 @@ class MemoryEngine {
2935
2986
  return docs
2936
2987
  }
2937
2988
 
2989
+ /**
2990
+ * ★v3.1.4 批 I:从**写入前原文**里切出「即将消失的卡片」原文块,供拒绝文案回吐。
2991
+ *
2992
+ * 为什么需要它:M1 保护门拒绝时若只回吐 mem_id,模型只知道"少了哪张"、不知道内容是什么,
2993
+ * 只能再去 read 一次整份白板原文(本轮 v3.1.3 实测:22KB / 130 行)——在"改过 lib 就要重写白板"
2994
+ * 的收尾纪律下,这个代价每个批次都要付一次。把原文块直接放在错误里即可一轮补齐。
2995
+ *
2996
+ * 切块口径:卡片标题行 → 下一张卡片标题行之前(含锚点行与用户备注区)。
2997
+ * 上限:单卡 1200 字符、合计 6000 字符(尾部标「…(已截断)」),防上下文膨胀。
2998
+ * fail-soft:任何解析异常返回空串,绝不阻塞拒绝路径本身。
2999
+ *
3000
+ * @param {string} beforeText 写入前的 PLAN.md 原文
3001
+ * @param {{hard?:Array}} res validateMutationBoundaryPre 的报告
3002
+ * @returns {string} 形如「\n\n为便于一轮补齐,以下是消失卡片的原文块:…」;无缺失返回 ''
3003
+ */
3004
+ missingCardsExcerptPre(beforeText, res) {
3005
+ const missing = []
3006
+ for (const h of (Array.isArray(res && res.hard) ? res.hard : [])) {
3007
+ if (h && h.pass === false && Array.isArray(h.missing)) for (const id of h.missing) if (typeof id === 'string' && id) missing.push(id)
3008
+ }
3009
+ if (!missing.length) return ''
3010
+ const lines = String(beforeText == null ? '' : beforeText).split(/\r?\n/)
3011
+ // 卡片边界:**与解析器同口径**(wb-contract.js 的 isCardTitle = /^#{3,}\s+\S/,即 ### 起步;
3012
+ // ⚠️ 本轮实测教训:首版按直觉写成 \s{0,3}###,而真实白板的「## 节 + ### 卡」结构里
3013
+ // 卡片是 ### 、## 只是节标题 ⇒ 取样口径必须回抄解析器,不得自造。)
3014
+ const starts = []
3015
+ for (let i = 0; i < lines.length; i++) if (/^#{3,}\s+\S/.test(lines[i].replace(/\r$/, ''))) starts.push(i)
3016
+ if (!starts.length) return ''
3017
+ let total = 0
3018
+ const parts = []
3019
+ for (const id of missing) {
3020
+ const si = starts.findIndex((ln, k) => {
3021
+ const end = k + 1 < starts.length ? starts[k + 1] : lines.length
3022
+ for (let j = ln; j < end; j++) if (lines[j].includes(id)) return true
3023
+ return false
3024
+ })
3025
+ if (si < 0) continue
3026
+ const from = starts[si]
3027
+ const to = si + 1 < starts.length ? starts[si + 1] : lines.length
3028
+ let block = lines.slice(from, to).join('\n').replace(/\s+$/, '')
3029
+ if (block.length > 1200) block = block.slice(0, 1200) + '\n…(本卡已截断)'
3030
+ if (total + block.length > 6000) { parts.push('…(其余卡片略:合计超上限)'); break }
3031
+ parts.push(block)
3032
+ total += block.length
3033
+ }
3034
+ if (!parts.length) return ''
3035
+ return '\n\n为便于一轮补齐,以下是消失卡片的原文块(请原样带回,含其用户备注区):\n\n' + parts.join('\n\n')
3036
+ }
3037
+
2938
3038
  checkMutationPre(input = {}) {
2939
3039
  const o = input && typeof input === 'object' ? input : {}
2940
3040
  const target = String(o.target || 'other')
@@ -2982,10 +3082,36 @@ class MemoryEngine {
2982
3082
  archivedIds: Array.isArray(o.archivedIds) ? o.archivedIds : [],
2983
3083
  strict: this.config.criteriaGate !== false,
2984
3084
  })
2985
- if (!res.ok) {
2986
- return { ok: false, gate: 'mutation', error: mutationRefusalTextPre(res), report: res }
3085
+ // ★v3.1.4 批 J(用户裁定 · 折中档 A):**白板 M1 丢卡保护降级为警示**。
3086
+ // 用户原话:「protection 的范围收窄……改成在动语义模型的时候需要保护一下」。
3087
+ // 为何降级而非删除:M1 拦过真实事故(白板被整篇覆盖成骨架),完全放开会重开事故面;
3088
+ // 折中 = 照写 + 显式警示行(同项目既有先例:硬判据失败照写 + 警示行)。
3089
+ // ⚠️ 三处边界:(a) 纯函数 validateMutationBoundaryPre 语义不动(t0-8 守卫仍钉它);
3090
+ // (b) 仅 target==='plan';(c) 只有"仅 M1 不合格"才降级 —— M2/M3 任一不合格仍硬拒。
3091
+ // 取证:语义模型资产(~/.dsh/models/js-semantic)写入**不经过本门**(全仓仅此处与账本调用),
3092
+ // 它另由下载器 sha256 + 加载自检保护 ⇒ 「动语义模型才保护」与本门天然独立,无需额外接线。
3093
+ let planDowngradeWarn = ''
3094
+ if (!res.ok && target === 'plan' && res.hard.every((h) => h.pass || h.id === 'M1')) {
3095
+ const unarch = Array.isArray(res.report && res.report.unarchived) ? res.report.unarchived : []
3096
+ let ex = ''
3097
+ try { ex = missingCardsExcerptPre(beforeText, res) } catch (_) { ex = '' }
3098
+ planDowngradeWarn = '⚠ 【白板丢卡警示 · 已照写】本次重写丢了 ' + unarch.length + ' 张卡片且未显式归档:'
3099
+ + unarch.slice(0, 8).join('、') + (unarch.length > 8 ? ' 等' : '')
3100
+ + '。写入已放行(v3.1.4 起白板丢卡降级为警示),但这些内容已从白板消失——'
3101
+ + '如需找回,请从 handoff/archive/ 的历史版本恢复并原样带回。' + ex
3102
+ res.ok = true
3103
+ res.m1Downgraded = true
2987
3104
  }
2988
- return { ok: true, report: res }
3105
+ if (!res.ok) {
3106
+ // ★v3.1.4 批 I(用户报障"每次都重试"):拒绝时把**缺失卡片的原文块**一并回吐,
3107
+ // 而不是只给 mem_id —— 模型据此可直接补回,不必再拉一次 22KB 白板原文。
3108
+ // 切块来源是本函数**已经解析好**的 projB(含行号),零额外 IO;按上限截断防上下文膨胀。
3109
+ let excerpt = ''
3110
+ try { excerpt = missingCardsExcerptPre(beforeText, res) } catch (_) { excerpt = '' }
3111
+ return { ok: false, gate: 'mutation', error: mutationRefusalTextPre(res) + excerpt, report: res }
3112
+ }
3113
+ // ★v3.1.4 批 J:把降级信息带出(error 字段复用为警示文案;ok=true 表示已放行)
3114
+ return { ok: true, report: res, m1Downgraded: !!res.m1Downgraded, error: planDowngradeWarn || '' }
2989
3115
  } catch (e) {
2990
3116
  // fail-soft:保护门自身出错时**放行**会让保护形同虚设,但硬拒会把白板彻底锁死。
2991
3117
  // 取安全侧 = **拒写并显式报错**(宁可让用户看到一次失败,也不静默丢卡)。
@@ -3187,10 +3313,58 @@ class MemoryEngine {
3187
3313
  return rec && Date.now() - (Number(rec.at) || 0) < 600000 ? rec : null
3188
3314
  }
3189
3315
 
3316
+ /**
3317
+ * ★v3.1.3:从**目录列出的文件名**里挑出会话日志文件(纯函数,便于单测)。
3318
+ *
3319
+ * 为什么不再用固定候选表:实盘同时存在 `session.jsonl.zstd` 与 `session.v3.jsonl.zstd` 两代,
3320
+ * 代码注释自述该名已随宿主改过一次、靠人工追加候选名跟上。若宿主改名(如 v4),
3321
+ * 固定表会**返回空**并让五条功能链同时静默降级。这里改成按**结构**识别:
3322
+ * · 前缀 `session.`(或 `session-`)
3323
+ * · 含 `.jsonl`(可带 `.zstd`)
3324
+ * · 排除明显的备份/调试产物(`.broken-backup-` / `.dup-backup-` / 以 `_` 开头)
3325
+ * 排序优先级:**版本号高者优先**(v4 > v3 > 无版本)> 非压缩优先于压缩 > mtime 新者优先。
3326
+ * 这样既兼容未来命名,又不会把备份文件误当主文件。
3327
+ *
3328
+ * @param {string[]} names 目录里的文件名
3329
+ * @param {Record<string, number>} mtimes name → mtimeMs(缺省视为 0)
3330
+ * @returns {string} 选中的文件名;无可选返回 ''
3331
+ */
3332
+ pickSessionFileNamePre(names, mtimes) {
3333
+ const list = Array.isArray(names) ? names : []
3334
+ const mt = mtimes && typeof mtimes === 'object' ? mtimes : {}
3335
+ const cands = []
3336
+ for (const n of list) {
3337
+ if (typeof n !== 'string' || !n) continue
3338
+ if (n.startsWith('_')) continue // _debug_child.jsonl 之类
3339
+ if (/backup|corrupt|\.tmp$|\.dam-tmp$/i.test(n)) continue
3340
+ if (!/^session[.-]/.test(n)) continue // session. / session-
3341
+ if (!/\.jsonl(\.zstd)?$/i.test(n)) continue // 只认 jsonl[.zstd]
3342
+ const vm = n.match(/session\.v(\d+)\./i)
3343
+ cands.push({ name: n, ver: vm ? Number(vm[1]) : 0, zstd: /\.zstd$/i.test(n) ? 1 : 0, m: Number(mt[n]) || 0 })
3344
+ }
3345
+ if (!cands.length) return ''
3346
+ cands.sort((a, b) => (b.ver - a.ver) || (a.zstd - b.zstd) || (b.m - a.m) || a.name.localeCompare(b.name))
3347
+ return cands[0].name
3348
+ }
3349
+
3190
3350
  /** 2.3.1:解析会话日志文件路径 —— DSH 0.1.5 起会话格式迁移到 `session.v3.jsonl.zstd`
3191
3351
  * (旧 `session.jsonl.zstd` 保留但停止更新);按 mtime 取最新存在的文件,兼容两代与未来命名。 */
3192
3352
  async resolveSessionFile(dir) {
3193
3353
  try {
3354
+ // ★v3.1.3:先扫目录(兼容任意 session.vN / 未来命名),目录读不到才回退固定表。
3355
+ let names = []
3356
+ try { names = await readdir(dir) } catch (_) { names = [] }
3357
+ let picked = ''
3358
+ if (names.length) {
3359
+ const mtimes = {}
3360
+ for (const n of names) {
3361
+ if (!/^session[.-].*\.jsonl(\.zstd)?$/i.test(n)) continue
3362
+ try { const st = await stat(path.join(dir, n)); if (st.isFile()) mtimes[n] = st.mtimeMs } catch (_) {}
3363
+ }
3364
+ picked = this.pickSessionFileNamePre(Object.keys(mtimes), mtimes)
3365
+ }
3366
+ if (picked) return path.join(dir, picked)
3367
+ // 回退(目录不可读时):旧的固定候选表,行为与历史一致
3194
3368
  const cands = ['session.v3.jsonl.zstd', 'session.jsonl.zstd', 'session.v3.jsonl', 'session.jsonl']
3195
3369
  let best = null
3196
3370
  for (const name of cands) {
@@ -3969,9 +4143,18 @@ class MemoryEngine {
3969
4143
  for (const ent of entries) {
3970
4144
  if (!ent.isDirectory()) continue
3971
4145
  if (!/^session-/.test(ent.name)) continue
4146
+ // ★v3.1.3:遍历该会话目录里**全部** session.* 文件(不再固定表),取最大 mtime。
3972
4147
  let ms = 0
3973
- for (const f of ['session.v3.jsonl.zstd', 'session.jsonl.zstd']) {
3974
- try { const st = statSync(path.join(wsPath, ent.name, f)); if (st.mtimeMs > ms) ms = st.mtimeMs } catch (_) {}
4148
+ try {
4149
+ for (const f of readdirSync(path.join(wsPath, ent.name))) {
4150
+ if (!/^session[.-].*\.jsonl(\.zstd)?$/i.test(f)) continue
4151
+ if (/backup|corrupt/i.test(f)) continue
4152
+ try { const st = statSync(path.join(wsPath, ent.name, f)); if (st.mtimeMs > ms) ms = st.mtimeMs } catch (_) {}
4153
+ }
4154
+ } catch (_) {
4155
+ for (const f of ['session.v3.jsonl.zstd', 'session.jsonl.zstd']) {
4156
+ try { const st = statSync(path.join(wsPath, ent.name, f)); if (st.mtimeMs > ms) ms = st.mtimeMs } catch (_) {}
4157
+ }
3975
4158
  }
3976
4159
  if (ms > bestMs) { bestMs = ms; best = ent.name }
3977
4160
  }
@@ -4679,7 +4862,10 @@ class MemoryEngine {
4679
4862
  /** 对比本地版本与 npm registry 最新版;结果缓存 12 小时(启动/设置页打开都走缓存,不重复查网)。 */
4680
4863
  async checkUpdate(force) {
4681
4864
  const cacheFile = path.join(dshHome(), 'memory', 'update-check.json')
4682
- const base = { current: '', installKind: 'unknown' }
4865
+ // ★v3.1.2:dev-link(profile 以 link: 挂载开发树)时,current 读的是**开发树自己的版本**,
4866
+ // 与 registry latest 恒等 ⇒ upToDate 永远 true,用户感知不到线上新版。故把开发树版本
4867
+ // 单独标成 devVersion,让前端能呈现「开发树 vX ↔ 线上 vY」而不是假装已是最新。
4868
+ const base = { current: '', installKind: 'unknown', devVersion: '' }
4683
4869
  try {
4684
4870
  const indexPath = fileURLToPath(import.meta.url)
4685
4871
  const pkgPath = path.join(path.dirname(indexPath), '..', 'package.json')
@@ -4688,6 +4874,7 @@ class MemoryEngine {
4688
4874
  } catch (e) {}
4689
4875
  const prof = await this.findProfileDir()
4690
4876
  if (prof) base.installKind = prof.installKind
4877
+ if (base.installKind === 'dev-link') base.devVersion = base.current
4691
4878
  if (!force) {
4692
4879
  try {
4693
4880
  const cached = await this.readTextSafe(cacheFile)
@@ -7006,11 +7193,19 @@ _wsSummaryFile() { return path.join(dshHome(), 'memory', 'workspaces-summary.jso
7006
7193
  }
7007
7194
  }
7008
7195
  await walk(dir, '')
7196
+ // ★v3.1.3:用户级文件(日历)也进包 —— 用户要求「把同步的范围扩大到日历」。
7197
+ // CALENDAR.md 在 userDir(跨工作区共享),故键用「相对 userDir 的路径」;
7198
+ // 导入侧按 merge 处理(见 _applyUserFilesPre / calendarMergePre),绝不整篇覆盖。
7199
+ const userFiles = {}
7200
+ try {
7201
+ const calRaw = await this.readTextSafe(path.join(this.userDirOf(), 'CALENDAR.md'))
7202
+ if (calRaw && String(calRaw).trim()) userFiles['CALENDAR.md'] = calRaw
7203
+ } catch (_) {}
7009
7204
  const built = buildPackPre({
7010
- ws, files,
7205
+ ws, files, userFiles,
7011
7206
  summaryRecord: await this._wsSummaryRecord(ws),
7012
7207
  pluginVersion: (this.manifest && this.manifest.version) || '',
7013
- sourceHost: os.hostname(),
7208
+ sourceHost: hostnameOf(),
7014
7209
  })
7015
7210
  if (!built.ok) return { ok: false, error: built.error, skipped }
7016
7211
  const json = JSON.stringify(built.pack)
@@ -7031,6 +7226,32 @@ _wsSummaryFile() { return path.join(dshHome(), 'memory', 'workspaces-summary.jso
7031
7226
  }
7032
7227
  }
7033
7228
 
7229
+ /**
7230
+ * ★v3.1.3:把包内的用户级文件落地(当前只有 CALENDAR.md)。**合并而非覆盖**。
7231
+ * 为什么单独一个方法:日历在用户级目录、跨工作区共享,落盘口径与 files 那支完全不同。
7232
+ * 返回 { applied:[], skipped:[] } 供调用方汇总 —— 绝不静默。
7233
+ */
7234
+ async _applyUserFilesPre(pack) {
7235
+ const applied = []
7236
+ const skipped = []
7237
+ const uf = (pack && pack.userFiles && typeof pack.userFiles === 'object') ? pack.userFiles : {}
7238
+ for (const rel of Object.keys(uf)) {
7239
+ if (rel !== 'CALENDAR.md') { skipped.push('user:' + rel + '(unsupported)'); continue }
7240
+ try {
7241
+ const target = path.join(this.userDirOf(), rel)
7242
+ const existing = await this.readTextSafe(target)
7243
+ const merged = calendarMergePre(existing, uf[rel])
7244
+ if (!merged.ok) { skipped.push('user:' + rel + '(merge-failed)'); continue }
7245
+ // 无新增且本机已有内容 ⇒ 不写盘(避免无谓 mtime 变动)
7246
+ if (merged.added === 0 && String(existing || '').trim()) { applied.push({ rel, added: 0, action: 'noop' }); continue }
7247
+ await this.writeFullRaw(target, merged.text)
7248
+ this.state.calendarText = merged.text; this.state.loadedAt = Date.now()
7249
+ applied.push({ rel, added: merged.added, action: 'merged' })
7250
+ } catch (e) { skipped.push('user:' + rel + '(error:' + String(e && e.message ? e.message : e) + ')') }
7251
+ }
7252
+ return { applied, skipped }
7253
+ }
7254
+
7034
7255
  /** 读包(自动识别 gzip:gzip 魔数 1f 8b)。★坏包在 validate 阶段即拒,不写任何文件。 */
7035
7256
  async _readPack(packPath) {
7036
7257
  const buf = await readFile(packPath).catch(() => null)
@@ -7071,7 +7292,7 @@ _wsSummaryFile() { return path.join(dshHome(), 'memory', 'workspaces-summary.jso
7071
7292
  }
7072
7293
  }
7073
7294
 
7074
- /** 导入:执行。★顺序 = 重算计划 → 备份 → 落盘 → 合并 summary(任一步失败即中止并回报)。 */
7295
+ /** 导入:执行。★顺序 = 重算计划 → 备份 → 落盘 → 合并 summary → 合并用户级文件(任一步失败即中止并回报)。 */
7075
7296
  async migrateImport(opts) {
7076
7297
  const o = opts || {}
7077
7298
  const r = await this._readPack(String(o.packPath || ''))
@@ -7121,6 +7342,12 @@ _wsSummaryFile() { return path.join(dshHome(), 'memory', 'workspaces-summary.jso
7121
7342
  }
7122
7343
  summaryAction = merged.action
7123
7344
  } catch (e) { summaryError = String(e && e.message || e) }
7345
+ // ★v3.1.3:用户级文件(日历)落地 —— **merge 不覆盖**,与 summary 同一条纪律。
7346
+ // 放在 summary 之后、refresh 之前:日历改动要进 state.calendarText(_applyUserFilesPre 内已同步),
7347
+ // 失败不阻断导入主流程(计入 userFilesError,绝不静默)。
7348
+ let userFilesResult = { applied: [], skipped: [] }
7349
+ let userFilesError = null
7350
+ try { userFilesResult = await this._applyUserFilesPre(r.pack) } catch (e) { userFilesError = String(e && e.message || e) }
7124
7351
  // 索引失效:语义索引按工作区缓存,路径/内容变了必须让它重建(S5:索引本身不随包走)
7125
7352
  try { this._semanticIndexStaleByWorkspace = this._semanticIndexStaleByWorkspace || new Set(); this._semanticIndexStaleByWorkspace.add(targetWs) } catch (_) {}
7126
7353
  try { if (this.state) this.state.loadedAt = 0; await this.refresh(undefined) } catch (_) {}
@@ -7131,6 +7358,7 @@ _wsSummaryFile() { return path.join(dshHome(), 'memory', 'workspaces-summary.jso
7131
7358
  rewrite: { totalHits: plan.rewrite.totalHits, fileCount: plan.rewrite.fileCount, files: plan.rewrite.files.slice(0, 50) },
7132
7359
  additions: plan.additions.length, overwrites: plan.overwrites.length,
7133
7360
  summaryAction, summaryError,
7361
+ userFiles: userFilesResult.applied, userFilesSkipped: userFilesResult.skipped, userFilesError,
7134
7362
  }
7135
7363
  }
7136
7364
  /** 工作区总览:每个工作区一个小总结 + 区内细分总结;结果缓存到用户级 ~/.dsh/memory/workspaces-summary.json(跨工作区全局)。 */
@@ -7565,6 +7793,44 @@ _wsSummaryFile() { return path.join(dshHome(), 'memory', 'workspaces-summary.jso
7565
7793
  return '已保存反思 ' + file
7566
7794
  }
7567
7795
 
7796
+ /**
7797
+ * ★v3.1.3:**同步目录(云盘/远端)探测** —— 用户要求「增加一个在云盘或者远端同步的接口,
7798
+ * 尝试看看能不能做好兼容性」。
7799
+ *
7800
+ * 定位:**不发明私有同步协议、不做后台自动同步**(延续用户既有拍板)。本方法只做两件事:
7801
+ * ① 判断这个目录现在能不能用(存在 / 可写);
7802
+ * ② 给出人话诊断,帮用户把「导出目录指向云盘」这一步走对。
7803
+ *
7804
+ * 兼容性做法:**只认为它是个普通目录** —— OneDrive / 坚果云 / Dropbox / iCloud / 网络盘 / U 盘 /
7805
+ * 本地路径一律同等对待:不嗅探客户端、不依赖专有 API、不假设路径形态、不要求特定盘符。
7806
+ *
7807
+ * @param {string} dir 待检测目录(空 ⇒ 用配置里的 syncDir)
7808
+ * @returns {{ok:boolean, dir:string, exists:boolean, writable:boolean, looksSynced:boolean, hint:string}}
7809
+ */
7810
+ async syncDirProbe(dir) {
7811
+ if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
7812
+ const want = String(dir || (this.config && this.config.syncDir) || '').trim()
7813
+ if (!want) {
7814
+ return { ok: false, dir: '', exists: false, writable: false, looksSynced: false,
7815
+ hint: '未配置同步目录。填写你的云盘本地目录(如 OneDrive/坚果云 的同步文件夹)即可把导出包自动带过去;也可留空走默认导出目录。' }
7816
+ }
7817
+ const expanded = this.expandUserPath(want) || want
7818
+ let exists = false, writable = false
7819
+ try { const st = await stat(expanded); exists = !!st.isDirectory() } catch (_) { exists = false }
7820
+ if (exists) {
7821
+ const probe = path.join(expanded, '.dam-sync-probe-' + Date.now())
7822
+ try { await writeFile(probe, 'ok'); await unlink(probe); writable = true } catch (_) { writable = false }
7823
+ }
7824
+ // 「像同步盘」只是**提示**,不作门槛:命中常见客户端名就给一句更贴心的说明。
7825
+ const looksSynced = /onedrive|dropbox|nutstore|坚果云|icloud|googledrive|google drive|baidunetdisk|百度网盘/i.test(expanded)
7826
+ let hint
7827
+ if (!exists) hint = '该目录不存在。请确认路径拼写;若是网络盘,先在文件管理器里连上再试。'
7828
+ else if (!writable) hint = '目录存在但**不可写**。若是云盘,可能还在首次同步或被占用;换个位置或稍后重试。'
7829
+ else if (looksSynced) hint = '目录可用,且看起来是同步盘。导出到这里的包会被云盘自动同步到另一台机器;到那边用「导入包」显式导入即可(靠包内校验和对齐,不做后台自动同步)。'
7830
+ else hint = '目录可用。若不是同步盘,这个包不会自动到另一台机器——请手动拷贝(网盘上传/邮件/U 盘均可)。'
7831
+ return { ok: exists && writable, dir: expanded, exists, writable, looksSynced, hint }
7832
+ }
7833
+
7568
7834
  // ---------- 日历/日程(用户级 CALENDAR.md) ----------
7569
7835
  /** 解析 CALENDAR.md 为条目数组。 */
7570
7836
  parseCalendar(text) {
@@ -10592,6 +10858,14 @@ engine._recallStats = createRecallStatsPre({
10592
10858
  try {
10593
10859
  const agent = context && context.agent
10594
10860
  if (!agent) return ''
10861
+ // ★v3.1.3(用户拍板「接线」):`injectEnabled` 此前是**死开关** —— 键白名单内有、
10862
+ // UI 能翻面能落盘,但全 lib/ 零读取点 ⇒ 用户关掉后记忆照旧每轮注入(界面骗人)。
10863
+ // 现在接入本回调最前面,作为**动态快照总闸**:关 = 真不注入。
10864
+ // ⚠️解耦(用户硬性要求「单一开关不得顺带改变其他功能」):只关这**一条**通路,不动
10865
+ // · 静态规则段 `renderMemoryStatic()`(systemPrompt.section,下方 :~10900,缓存锚 + 属另一功能)
10866
+ // · M6 tail surface(下方 :~10910)
10867
+ // · 记忆写入(memory_log / 自动沉淀)与检索(memory_recall)—— 关注入 ≠ 关记忆
10868
+ if (engine.config && engine.config.injectEnabled === false) return ''
10595
10869
  // M0/M1: 绑定到该 agent 的 runtime 再读取(state 是 per-session getter,避免落到 default runtime 读到空)
10596
10870
  return engine.withAgent(agent, () => {
10597
10871
  const st = engine.stateFor(agent)
@@ -10762,7 +11036,10 @@ engine._recallStats = createRecallStatsPre({
10762
11036
  defineTool('memory_note', '更新当前项目长期笔记 .dsh-memory/MEMORY.md(本项目专属的约定、决策、架构要点),或写交接白板。kind=note(默认):action=append 追加一段(自动带日期标题)/action=replace 整体替换(需先基于注入内容或 memory_recall 结果给出完整新内容),本项目笔记有**容量上限**(字符,设置项 noteCapacityChars,默认 24000);**超出时的行为**(你不需要自己控制长度,写就是):①先把较早内容交给 AI 折叠成要点(同一层 10 分钟内只整理一次);②整理失败则**整条原文**归档到 `.dsh-memory/archive/`(信息不丢);③整理后仍超才拒绝(正常不会发生)。kind=handoff:写一篇四段式交接账本(任务状态/目标/已试方案与失败原因/进度与下一步)到 handoff/,阶段产出或方向变化时用,给下一个上下文窗口续命;**账本/白板内容要落进面板看板泳道,须在标题或正文写 tag**:type:goal / type:state / type:dead-end / type:progress。kind=plan:整体写入/重写白板 PLAN.md(人能读的项目全貌规划图)。**首建亦可**——工作区若还没有白板,宿主已自动落了一版骨架,你理解全貌后直接整体重写成真内容即可;对项目全貌的理解发生实质变化时同样重写。旧版自动归档。**别把骨架留在那里当白板**。**结论失效时的两个通道(不要混用)**:supersedes=被更新结论取代(有后继);retract=**当时就做错了、直接撤回**(无后继,本身即教训)。**调用后必须在本轮回复正文中向用户转述:更新了什么**。', {
10763
11037
  content: { type: 'string', required: true, description: '笔记内容。kind=plan 时给完整新全貌(不是增量)。' },
10764
11038
  action: { type: 'string', enum: ['append', 'replace'], description: '仅 kind=note 时有效:append=追加, replace=整体替换。' },
10765
- kind: { type: 'string', enum: ['note', 'handoff', 'plan'], description: 'note=项目笔记(默认), handoff=四段式交接账本(新篇), plan=白板全貌重写。' },
11039
+ kind: { type: 'string', enum: ['note', 'handoff', 'plan'], description: 'note=项目笔记(默认), handoff=四段式交接账本(新篇), plan=白板全貌重写。'
11040
+ + ' ★plan 重写前必须先 read 磁盘 handoff/PLAN.md 的**原文**并逐条抄回全部 <!--memory:mem_*--> 锚点:'
11041
+ + '注入快照会被截断(显示「…(截断,全文见 handoff/ 白板与账本)」),不足以据此重写;'
11042
+ + '缺锚点会被保护门拒绝(它会逐条比对前后卡片集合)。每张卡的原文与其用户备注区必须原样带回。' },
10766
11043
  // ★G3(2026-09-19):结论层状态写入 —— **显式可选参数**,不传时行为与从前逐字节相同(零自动行为、零误判)。
10767
11044
  // 设计依据:用户 2026-09-18 裁定「只认显式声明」(门槛 = 结构化参数 = 最强的显式)。
10768
11045
  // 为什么不做"自动比对同主题旧条目":误判代价不对称(漏判=维持现状;误判=有效结论被标作废)。
@@ -10771,6 +11048,10 @@ engine._recallStats = createRecallStatsPre({
10771
11048
  retract: { type: 'array', items: { type: 'string' }, description: '(可选)要标为 **retracted(撤回)** 的旧条目 memoryId 列表(mem_<32hex>)。**与 supersedes 的分工**:supersedes = 被**更新的结论取代**(有后继结论,可追 mem_id);**retract = 当时就做错了、直接撤回**(无后继,"错误本身"就是教训)。用户裁定「retracted 不是垃圾,是教训,不过滤只备注」⇒ 检索仍会返回它并标 ⚠已撤回。**强烈建议同时传 retractReason 说明错在哪**。仅在你确知标错的是哪条时传。' },
10772
11049
  retractReason: { type: 'string', description: '(可选,配合 retract)撤回原因,一行内说明**错在哪**,上限 120 字符。会写成 `reason="…"` 附在状态行上,供检索时显示。' },
10773
11050
  restore: { type: 'array', items: { type: 'string' }, description: '(可选)**撤销通道**:把指定 memoryId 的状态改回 current(移除状态行)。用于纠正标错的 superseded/retracted。' },
11051
+ // ★v3.1.4 批 J(用户拍板「补 archivedIds 真入口」):此前底层 API 认这个键、
11052
+ // 但工具参数表没暴露 ⇒ 报错里那句「显式写入归档集合」对模型不可达(假出路)。
11053
+ // 现补齐:白板重写时可显式声明"这些卡我有意归档",丢卡检查据此放行并留痕。
11054
+ archivedIds: { type: 'array', items: { type: 'string' }, description: '(可选,仅 kind=plan 有意义)要**显式归档**的卡片 id 列表(mem_<32hex>)。用于白板重写时有意移除某张卡:列进来即视为"合法消失(有留痕)",不会被丢卡检查拦下。**只在你确实要移除该卡时传**;顺手漏抄卡片不要用它掩盖——那属于内容丢失,应改回原样带回。' },
10774
11055
  }, async (args, exec) => {
10775
11056
  const p = await engine.resolvePaths(exec.agent)
10776
11057
  const content = String(args.content || '').trim()
@@ -10780,8 +11061,10 @@ engine._recallStats = createRecallStatsPre({
10780
11061
  const gateH = sanitizeForWrite(content, { maxEntryChars: args.kind === 'plan' ? 200000 : 8000 })
10781
11062
  if (!gateH.ok) return 'memory_note: 写入被记忆卫生闸门拦截(' + WRITE_GATE_REASON[gateH.reason] + '),未写入。请改写为客观陈述后重试。'
10782
11063
  const writeH = gateH.clean || content
11064
+ // ★v3.1.4 批 J:把 archivedIds 真的喂进写盘口(此前链路断开:参数表没有 → 门不被喂 → writePlanSnapshot 只传 4 字段)
11065
+ const archIds = Array.isArray(args.archivedIds) ? args.archivedIds.filter((x) => typeof x === 'string' && x) : []
10783
11066
  const r = args.kind === 'plan'
10784
- ? await engine.writePlanSnapshot(p.projectDir, writeH)
11067
+ ? await engine.writePlanSnapshot(p.projectDir, writeH, { archivedIds: archIds })
10785
11068
  : await engine.writeHandoffLedger(p.projectDir, writeH)
10786
11069
  // P0(2026-09-14):写入被保护门/判据门拦下时,**原样把可执行改写指引交给模型**
10787
11070
  // (不是"白板写入失败(未知)"——模型拿那四个字是修不回来的)。
@@ -10794,8 +11077,10 @@ engine._recallStats = createRecallStatsPre({
10794
11077
  }
10795
11078
  if (args.kind === 'plan') { engine.state.planText = (r && r.final) || writeH; engine.state.loadedAt = Date.now() }
10796
11079
  else { engine.state.latestHandoffText = '# 交接账本 · ' + engine.memToday() + ' ' + nowHm() + '\n\n' + ((r && r.clean) || writeH); engine.state.loadedAt = Date.now() }
11080
+ // ★v3.1.4 批 J:M1 降级警示随成功回执一起给模型(放行但不静默)
11081
+ const warnLine = (r && r.warn) ? ('\n' + r.warn) : ''
10797
11082
  return (args.kind === 'plan'
10798
- ? '白板 PLAN.md 已更新' + (r.archived ? '(旧版已归档: ' + r.archived + ')' : '(首建)') + ': ' + r.path
11083
+ ? '白板 PLAN.md 已更新' + (r.archived ? '(旧版已归档: ' + r.archived + ')' : '(首建)') + ': ' + r.path + warnLine
10799
11084
  : '交接账本已写入: ' + r.path)
10800
11085
  // ★ 2026-09-21 bugfix(A-2):handoff 此前**静默截断** —— sanitizeForWrite 超限时是
10801
11086
  // 「截断 + truncated:true」返回 ok(:8625-8628),而本分支接住了 gateH 却从不外显该标志
@@ -11270,6 +11555,18 @@ engine._recallStats = createRecallStatsPre({
11270
11555
  } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
11271
11556
  },
11272
11557
  },
11558
+ {
11559
+ kind: 'exact',
11560
+ path: API['kanban-card'],
11561
+ handler: async (req, res) => {
11562
+ // ★v3.1.2:单卡全文(GET 只读;loopback)。看板载荷不再内联 full,展开时按 id 单取。
11563
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
11564
+ try {
11565
+ const url = new URL(req.url, 'http://127.0.0.1')
11566
+ return writeJson(res, 200, await engine.kanbanCardBody(url.searchParams.get('sessionId'), url.searchParams.get('id')))
11567
+ } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
11568
+ },
11569
+ },
11273
11570
  {
11274
11571
  kind: 'exact',
11275
11572
  path: API['kanban-board'],
@@ -11832,7 +12129,12 @@ engine._recallStats = createRecallStatsPre({
11832
12129
  if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
11833
12130
  const body = await readJsonBody(req).catch(() => ({}))
11834
12131
  try {
11835
- const ws = String((body && body.ws) || (engine._lastAgent && engine._lastAgent.cwd) || '')
12132
+ // ★v3.1.2 修:此前回退段用的是 `_lastAgent.cwd`,而该字段不存在 ——
12133
+ // 全库权威口径是 session.header.cwd(见 :2324 / :5781 / :10628),本文件 :2799 注释亦明写
12134
+ // 「不再用不存在的 agent.cwd」⇒ 回退段恒空,前端不传 ws 即 missing-ws(用户实测)。
12135
+ const _la = engine._lastAgent
12136
+ const _lacwd = (_la && _la.session && _la.session.header && _la.session.header.cwd) || ''
12137
+ const ws = String((body && body.ws) || _lacwd || '')
11836
12138
  writeJson(res, 200, await engine.migrateExport({ ws, outPath: body && body.outPath, compress: !(body && body.compress === false) }))
11837
12139
  } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
11838
12140
  },
@@ -11922,8 +12224,22 @@ engine._recallStats = createRecallStatsPre({
11922
12224
  handler: async (req, res) => {
11923
12225
  if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
11924
12226
  if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
11925
- // 调用系统的文件夹选择器(native 后端,由 dsh 的 directory-picker-auto 按主机情况挂载);不可用则返回 native:false,前端回退内嵌浏览
12227
+ // 调用系统的文件夹选择器。★v3.1.2 修:此前只试 `ctx.directoryPicker.capability()` 一种形态,
12228
+ // 与 DSH 内核标准契约不符 ⇒ 恒返回 native:false(用户实测「选择器不可用,请手动填写路径」)。
12229
+ // 内核形态(@linxin666/dsh-web-all / dsh-client-ui-skin-center 两处同源):
12230
+ // const r = await ctx.remote.directoryPicker.pick(); if (!r.ok) throw ...; return r.value
12231
+ // 这里做**双路径兼容**:内核契约优先 → 旧 capability 形态回退 → 都不可用才 native:false。
11926
12232
  try {
12233
+ // 路径①:内核标准契约(远程/桌面形态下挂在 ctx.remote 上)
12234
+ let remotePicker = undefined
12235
+ try { remotePicker = ctx.remote && ctx.remote.directoryPicker } catch (e) {}
12236
+ if (remotePicker && typeof remotePicker.pick === 'function') {
12237
+ const r = await remotePicker.pick()
12238
+ if (r && r.ok === false) return writeJson(res, 200, { native: true, dir: null, error: String((r.error && r.error.message) || r.error || 'picker failed') })
12239
+ const v = r && (r.value !== undefined ? r.value : r)
12240
+ return writeJson(res, 200, { native: true, dir: (typeof v === 'string' ? v : (v && v.path) || null) })
12241
+ }
12242
+ // 路径②:本机既有形态(native 后端由 directory-picker-auto 按主机情况挂载)
11927
12243
  let picker = undefined
11928
12244
  try { picker = ctx.directoryPicker } catch (e) {}
11929
12245
  if (!picker || typeof picker.capability !== 'function') return writeJson(res, 200, { native: false })
@@ -11985,12 +12301,40 @@ engine._recallStats = createRecallStatsPre({
11985
12301
  if (!prof) return writeJson(res, 200, { ok: false, message: '未找到 dsh profile 目录(未安装插件或已删除)。' })
11986
12302
  if (prof.installKind === 'dev-link') return writeJson(res, 200, { ok: false, message: '当前为本地开发链接(link:)安装,不适用 npm 更新,请直接同步开发源码。' })
11987
12303
  if (prof.installKind !== 'registry') return writeJson(res, 200, { ok: false, message: '未检测到 registry 安装方式,无法自动更新。' })
11988
- const cmd = prof.usesPnpm ? 'pnpm up @a9i5k4/dsh-auto-memory' : 'npm install @a9i5k4/dsh-auto-memory@latest'
12304
+ // ★v3.1.2 修:pnpm 11 默认开启「1 天 Minimum Release Age」,对发布不足 24h 的版本**静默跳过**
12305
+ // (3.1.1 当天发布即中招)⇒ 旧命令 exit 0 但什么都没装,路由还报「成功」。
12306
+ // 改为:钉死目标版本(latest 也解析成具体版本号)+ 显式关闸门 + 装后复核(见下)。
12307
+ const target = String((await engine.checkUpdate(true)).latest || '').trim()
12308
+ const spec = '@a9i5k4/dsh-auto-memory' + (target ? '@' + target : '@latest')
12309
+ const cmd = prof.usesPnpm
12310
+ ? 'pnpm add ' + spec + ' --config.minimumReleaseAge=0'
12311
+ : 'npm install ' + spec + ' --min-release-age=0'
11989
12312
  const out = await execP(cmd, { cwd: prof.dir, timeout: 120000, windowsHide: true })
11990
12313
  const tail = ((out.stdout || '') + (out.stderr || '')).trim().slice(-1200)
12314
+ // ★v3.1.2:装后复核 —— 旧实现无条件报成功,供应链闸门静默跳过时用户被谎报「安装成功」。
12315
+ // 读 profile 里该包的真实 version;与目标不符即 ok:false(并把命令输出带回去供排查)。
12316
+ let landed = ''
12317
+ try {
12318
+ const pj = await this.readTextSafe(path.join(prof.dir, 'node_modules', '@a9i5k4', 'dsh-auto-memory', 'package.json'))
12319
+ if (pj) { try { landed = String(JSON.parse(pj).version || '') } catch (_) {} }
12320
+ } catch (_) {}
12321
+ const want = target || landed
12322
+ if (want && landed !== want) {
12323
+ return writeJson(res, 200, {
12324
+ ok: false,
12325
+ message: (landed ? ('安装后版本仍为 ' + landed + ',目标 ' + want) : '未检测到已安装的包版本')
12326
+ + '(若为 pnpm 11 供应链闸门所致,命令已带 --config.minimumReleaseAge=0,请把下方输出反馈给作者)',
12327
+ output: tail || '(无输出)',
12328
+ landedVersion: landed,
12329
+ targetVersion: want,
12330
+ installKind: prof.installKind,
12331
+ })
12332
+ }
11991
12333
  writeJson(res, 200, {
11992
12334
  ok: true,
11993
12335
  output: tail || '(无输出)',
12336
+ landedVersion: landed,
12337
+ targetVersion: want,
11994
12338
  restartHint: '更新完成,请重启 dsh web 生效。',
11995
12339
  installKind: prof.installKind,
11996
12340
  })