@a9i5k4/dsh-auto-memory 2.5.1 → 2.5.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/lib/index.js CHANGED
@@ -1550,19 +1550,40 @@ class MemoryEngine {
1550
1550
  // 审查修复轮2:路径解析前必须保证配置已加载 —— 否则首个工具调用会按默认 '~'(真实 homedir)
1551
1551
  // 解析集中记忆根,造成测试/早期调用写穿真实用户记忆(本次 smoke 污染的根因)。
1552
1552
  if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
1553
- let ws
1553
+ let ws, wsBound = false
1554
1554
  try { ws = agent && agent.session && agent.session.header && agent.session.header.cwd } catch (e) {}
1555
+ if (ws) wsBound = true
1556
+ const sid = agent && agent.session && agent.session.id ? String(agent.session.id) : ''
1557
+ // C 类回退(2026-09-13,工作区切换 bug·终端用户实证):header.cwd 缺失(如经 workspaceId 创建的
1558
+ // 会话)时按会话身份解析,而不是掉到全局 state.ws / process.cwd()。workspaceRegistry 的 sessionIds
1559
+ // 反查正是 workspaceId 绑定的权威解;持久化会话头为最后手段(带 per-sid 缓存,热路径零重复 IO)。
1560
+ if (!ws && sid) {
1561
+ const fb = await this.sessionWorkspaceFallback(sid)
1562
+ if (fb) { ws = fb; wsBound = true }
1563
+ }
1555
1564
  if (!ws) ws = this.state.ws || process.cwd()
1556
- // 无人值守模式(2026-08-26,issue 修复):锁定工作区——一旦本次会话已确定工作区,后续 refresh
1557
- // 若发现 agent cwd 漂移(如 subagent/工具改了 cwd),忽略新 cwd 沿用已锁定工作区,保持上下文路径稳定。
1558
- // 无人值守批量任务中,路径漂移会导致工具调用失败/重读文件,必须杜绝。手动或自动(夜间/托管)均生效。
1559
- if (this.isUnattendedNow() && this.state.ws && String(ws) !== String(this.state.ws)) {
1560
- ws = this.state.ws
1565
+ // 无人值守模式(2026-08-26,issue 修复):锁定工作区防 cwd 漂移(如 subagent/工具改了 cwd),
1566
+ // 保持上下文路径稳定;路径漂移会导致工具调用失败/重读文件,必须杜绝。手动或自动(夜间/托管)均生效。
1567
+ // 2026-09-13 修正锁粒度:旧实现**全局**钉死 state.ws,一次定终身——多工作区人工切换场景下
1568
+ // 整个解析被冻结(终端用户实证:面板恒主目录、切换无效)。新语义=**按会话锁定**:每个会话
1569
+ // 首次解析出有会话身份依据(wsBound)的工作区时记入 rt.wsLocked,其后仅防**本会话内**漂移;
1570
+ // 未绑定(全局回退值)不锁,等本会话身份明朗;无会话身份的解析(全局刷新)沿用旧全局锁。
1571
+ if (this.isUnattendedNow()) {
1572
+ if (sid) {
1573
+ const rt = this.runtimeFor(agent)
1574
+ if (rt) {
1575
+ if (rt.wsLocked) { if (String(ws) !== String(rt.wsLocked)) ws = rt.wsLocked }
1576
+ else if (wsBound) rt.wsLocked = ws
1577
+ }
1578
+ } else if (this.state.ws && String(ws) !== String(this.state.ws)) {
1579
+ ws = this.state.ws
1580
+ }
1561
1581
  }
1562
1582
  const userDir = this.userDirOf()
1563
1583
  const projectDir = this.projectDirOf(ws)
1564
1584
  return {
1565
1585
  ws,
1586
+ wsBound,
1566
1587
  userDir,
1567
1588
  userFile: path.join(userDir, 'MEMORY.md'),
1568
1589
  calendarPath: path.join(userDir, 'CALENDAR.md'),
@@ -1578,6 +1599,67 @@ class MemoryEngine {
1578
1599
  }
1579
1600
  }
1580
1601
 
1602
+ /** C 类会话的工作区回退解析(2026-09-13,工作区切换 bug):header.cwd 缺失时按会话身份找工作区——
1603
+ * ①workspaceRegistry 反查(sessionIds 命中 → path;workspaceId 创建会话的权威解,接续流程同源);
1604
+ * ②持久化会话日志首行的 cwd(与 buildPrevSessionPack 的折叠来源一致;头帧解码防 Node 22 崩溃)。
1605
+ * 结果按 sid 记忆(命中长期/未命中 5 分钟),resolvePaths 是热路径,禁止每轮重复 IO。 */
1606
+ async sessionWorkspaceFallback(sid) {
1607
+ const id = String(sid || '')
1608
+ if (!id) return ''
1609
+ if (!this._wsFallbackCache) this._wsFallbackCache = new Map()
1610
+ const hit = this._wsFallbackCache.get(id)
1611
+ if (hit && (hit.v || Date.now() - hit.at < 300000)) return hit.v
1612
+ let out = ''
1613
+ try {
1614
+ const reg = this._ctxRef && typeof this._ctxRef.get === 'function' ? this._ctxRef.get('workspaceRegistry') : null
1615
+ if (reg && typeof reg.list === 'function') {
1616
+ const found = (reg.list() || []).find((w) => w && Array.isArray(w.sessionIds) && w.sessionIds.includes(id))
1617
+ if (found && found.path) out = String(found.path)
1618
+ }
1619
+ } catch (e) {}
1620
+ if (!out) {
1621
+ try {
1622
+ const dir = await this.locateSessionDir(id)
1623
+ const file = dir ? await this.resolveSessionFile(dir) : null
1624
+ if (file) {
1625
+ const buf = await readFile(file).catch(() => null)
1626
+ const text = buf ? (file.endsWith('.zstd') ? decodeZstdFramesHead(buf) : buf.toString('utf8')) : ''
1627
+ const firstLine = text.split('\n').find((l) => l.trim()) || ''
1628
+ try { const head = JSON.parse(firstLine); if (head && typeof head.cwd === 'string' && head.cwd) out = head.cwd } catch (e) {}
1629
+ }
1630
+ } catch (e) {}
1631
+ }
1632
+ if (this._wsFallbackCache.size > 200) this._wsFallbackCache.clear()
1633
+ this._wsFallbackCache.set(id, { v: out, at: Date.now() })
1634
+ return out
1635
+ }
1636
+
1637
+ /** 按会话解析路径(2026-09-13,工作区切换 bug):面板等「查看特定会话」的场景专用——
1638
+ * 活 agent 走 resolvePaths(其内部含 C 类回退与无人值守按会话锁);无活 agent(宿主重启/冷会话)
1639
+ * 时 registry/持久化头可直接给出工作区,不再回退到「最近活跃会话」的全局值。
1640
+ * wsBound=false = 本会话身份解析不出工作区(调用方应显示未取到,不得拿全局值冒充)。 */
1641
+ async resolvePathsForSession(sessionId) {
1642
+ const id = String(sessionId || '')
1643
+ if (!id) return { ...(await this.resolvePaths(undefined)), wsBound: true }
1644
+ const agent = this.agentForSessionId(id)
1645
+ if (agent) {
1646
+ const p = await this.resolvePaths(agent)
1647
+ return { ...p, wsBound: p.wsBound !== false }
1648
+ }
1649
+ const fb = await this.sessionWorkspaceFallback(id)
1650
+ if (fb) {
1651
+ const projectDir = this.projectDirOf(fb)
1652
+ return {
1653
+ ws: fb, wsBound: true, userDir: this.userDirOf(), projectDir,
1654
+ notesPath: path.join(projectDir, 'MEMORY.md'),
1655
+ logPath: path.join(projectDir, `${this.memToday()}.md`),
1656
+ handoffDir: path.join(projectDir, 'handoff'),
1657
+ planPath: path.join(projectDir, 'handoff', 'PLAN.md'),
1658
+ }
1659
+ }
1660
+ return { ...(await this.resolvePaths(undefined)), wsBound: false }
1661
+ }
1662
+
1581
1663
  // ---------- M-CM1 交接白板(PLAN.md 全貌快照 + 四段式交接账本) ----------
1582
1664
 
1583
1665
  /** 全局最近交接账本(跨工作区,新bug修复①):扫描各工作区记忆根的 handoff 目录下 handoff-*.md,取 mtime 最新一篇的绝对路径;找不到返回 ''。 */
@@ -2793,31 +2875,24 @@ class MemoryEngine {
2793
2875
 
2794
2876
  /** M-CM1 白板面板数据:PLAN 当前版+归档版本列表+账本列表;fileQ 非空时返回指定文件文本(严格白名单,限 handoff 目录内)。 */
2795
2877
  async handoffPanelData(fileQ, sessionId) {
2878
+ // 配置加载守卫(2026-09-13):handoffEnabled 出厂默认 false,若宿主刚重启、面板先于任何会话活动
2879
+ // 打开,旧实现按未加载的默认值误报「白板未启用」——与 resolvePaths 同款守卫。
2880
+ if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
2796
2881
  if (this.config.handoffEnabled === false) return { enabled: false }
2797
- const handoffDir = this.state.handoffDir || (await this.resolvePaths(undefined)).handoffDir
2798
- const planPath = this.state.planPath || path.join(handoffDir, 'PLAN.md')
2799
- if (fileQ) {
2800
- const m = String(fileQ).match(/^(?:PLAN\.md|handoff-\d{8}-\d{6}(?:-[a-z])?\.md|archive\/PLAN-\d{8}-\d{6}\.md)$/)
2801
- if (!m) return { enabled: true, error: 'bad file name' }
2802
- const target = path.join(handoffDir, ...String(fileQ).split('/'))
2803
- return { enabled: true, file: fileQ, text: await this.readTextSafe(target) }
2804
- }
2805
- const versions = []
2806
- const archNames = await readdir(path.join(handoffDir, 'archive')).catch(() => [])
2807
- for (const n of archNames) {
2808
- if (!/^PLAN-\d{8}-\d{6}\.md$/.test(n)) continue
2809
- const st = await stat(path.join(handoffDir, 'archive', n)).catch(() => null)
2810
- if (st && st.isFile()) versions.push({ name: n, mtime: Number(st.mtimeMs) })
2811
- }
2812
- versions.sort((a, b) => b.mtime - a.mtime)
2813
- const ledgers = []
2814
- const names = await readdir(handoffDir).catch(() => [])
2815
- for (const n of names) {
2816
- if (!/^handoff-\d{8}-\d{6}(-[a-z])?\.md$/.test(n)) continue
2817
- const st = await stat(path.join(handoffDir, n)).catch(() => null)
2818
- if (st && st.isFile()) ledgers.push({ name: n, mtime: Number(st.mtimeMs) })
2882
+ // 2026-09-13(工作区切换 bug·终端用户实证):有 sessionId 时按会话解析路径 —— 旧实现白板/账本
2883
+ // 恒用全局单值 state.*(最近活跃会话),查看非活跃会话时张冠李戴;会话解析不出身份时如实返回
2884
+ // wsBound:false,不再拿 dsh 启动目录(process.cwd() 回退)冒充工作区。
2885
+ let p, wsBound = true
2886
+ if (sessionId) {
2887
+ p = await this.resolvePathsForSession(sessionId)
2888
+ wsBound = p.wsBound !== false
2889
+ } else {
2890
+ p = (this.state.handoffDir || this.state.planPath)
2891
+ ? { handoffDir: this.state.handoffDir || path.dirname(this.state.planPath), planPath: this.state.planPath, ws: this.state.ws }
2892
+ : await this.resolvePaths(undefined)
2819
2893
  }
2820
- ledgers.sort((a, b) => b.mtime - a.mtime)
2894
+ const handoffDir = p.handoffDir
2895
+ const planPath = p.planPath || path.join(handoffDir, 'PLAN.md')
2821
2896
  // 水位:优先返回「该会话」的记录(实测 → 磁盘推导);未指定 sessionId 时退回全局单值(兼容旧调用方)
2822
2897
  let water = null
2823
2898
  if (sessionId) {
@@ -2856,11 +2931,44 @@ class MemoryEngine {
2856
2931
  live: Number(this.state.waterLevelAt) > 0,
2857
2932
  }
2858
2933
  }
2934
+ if (!wsBound) {
2935
+ // 会话身份解析不出工作区:白板/账本/归档一律不返回(避免跨工作区误读),水位照常(本就按会话取数);
2936
+ // 客户端据 wsBound:false 显示「未绑定」提示,而不是展示错误路径下的空内容。
2937
+ return {
2938
+ enabled: true, ws: '', wsBound: false, plan: '', planPath: '', planMtime: 0,
2939
+ planVersions: [], ledgers: [],
2940
+ refresh: { sessionId: '', prompt: '' },
2941
+ waterLevel: water,
2942
+ }
2943
+ }
2944
+ if (fileQ) {
2945
+ const m = String(fileQ).match(/^(?:PLAN\.md|handoff-\d{8}-\d{6}(?:-[a-z])?\.md|archive\/PLAN-\d{8}-\d{6}\.md)$/)
2946
+ if (!m) return { enabled: true, error: 'bad file name' }
2947
+ const target = path.join(handoffDir, ...String(fileQ).split('/'))
2948
+ return { enabled: true, file: fileQ, text: await this.readTextSafe(target) }
2949
+ }
2950
+ const versions = []
2951
+ const archNames = await readdir(path.join(handoffDir, 'archive')).catch(() => [])
2952
+ for (const n of archNames) {
2953
+ if (!/^PLAN-\d{8}-\d{6}\.md$/.test(n)) continue
2954
+ const st = await stat(path.join(handoffDir, 'archive', n)).catch(() => null)
2955
+ if (st && st.isFile()) versions.push({ name: n, mtime: Number(st.mtimeMs) })
2956
+ }
2957
+ versions.sort((a, b) => b.mtime - a.mtime)
2958
+ const ledgers = []
2959
+ const names = await readdir(handoffDir).catch(() => [])
2960
+ for (const n of names) {
2961
+ if (!/^handoff-\d{8}-\d{6}(-[a-z])?\.md$/.test(n)) continue
2962
+ const st = await stat(path.join(handoffDir, n)).catch(() => null)
2963
+ if (st && st.isFile()) ledgers.push({ name: n, mtime: Number(st.mtimeMs) })
2964
+ }
2965
+ ledgers.sort((a, b) => b.mtime - a.mtime)
2859
2966
  const planMt = await stat(planPath).then((s) => Number(s.mtimeMs)).catch(() => 0)
2860
2967
  // 刷新仪式的会话 id:内存态为空时走磁盘回退(宿主重启后 _lastAgent 未重建的窗口期)
2861
2968
  const sid = this.currentSessionId() || this.recentSessionIdFallback()
2862
2969
  return {
2863
- enabled: true, planPath,
2970
+ enabled: true, ws: p.ws || '', wsBound,
2971
+ planPath,
2864
2972
  plan: await this.readTextSafe(planPath),
2865
2973
  planMtime: planMt,
2866
2974
  planVersions: versions.slice(0, 30),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@a9i5k4/dsh-auto-memory",
3
3
  "description": "Proactive associative memory for DSH: zero-prompt recall injected before the model speaks, three-layer auto-consolidation, skill crystallization, and Astra-style context management - handoff ledgers, PLAN whiteboard, water-level sensing. Local-first, model-agnostic, zero deps. 主动联想记忆+Astra 式上下文管理:自动唤回/自动沉淀/技能固化/交接账本与白板跨窗口续命/水位感知。",
4
- "version": "2.5.1",
4
+ "version": "2.5.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {