@a9i5k4/dsh-auto-memory 2.2.4 → 2.2.5

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/README.md CHANGED
@@ -1,7 +1,9 @@
1
- # dsh-auto-memory — She remembers, unbidden · 无问自忆
1
+ # 无问自忆 · 记忆不断线
2
+
3
+ **dsh-auto-memory** — *She remembers, unbidden.*
2
4
 
3
5
  > **EN** Now, across windows, too. Context that survives windows, sessions, and tools
4
- > **中文** 无问自忆——该想起的,自己浮现。跨窗口 · 跨会话 · 跨工具,记忆不断线
6
+ > **中文** 该想起的,自己浮现。跨窗口 · 跨会话 · 跨工具,记忆不断线
5
7
 
6
8
  <p align="center">
7
9
  <a href="https://htmlpreview.github.io/?https://github.com/Aik358/dsh-auto-memory/blob/preview/docs/landing/index.html"><strong>🌐 Landing page (full feature tour · data flow · papers · screenshots)</strong></a>
package/README.zh-CN.md CHANGED
@@ -1,4 +1,6 @@
1
- # dsh-auto-memory — 无问自忆 · She remembers, unbidden
1
+ # 无问自忆 · 记忆不断线
2
+
3
+ **dsh-auto-memory** — *She remembers, unbidden.*
2
4
 
3
5
  > **中文** 现在,换窗口也不必。跨窗口 · 跨会话 · 跨工具,记忆不断线
4
6
  > **EN** She remembers, unbidden — now, across windows, too. Context that survives windows, sessions, and tools
@@ -32,6 +32,8 @@
32
32
  - **装完必须重启 dsh web**:插件的注入面(manifest)在启动时加载;改完 host 代码同理。
33
33
  - 浏览器端更新后需**硬刷新**(Ctrl+Shift+R)才会加载新 client.js。
34
34
  - 入口:左侧栏底部 **记忆** 按钮 → 记忆面板,含页签 **概览 / 白板 / 语料精修 / 设置**。
35
+ - 面板标题栏:**图钉**(线描图标,与 ⟳ ⤾ ✕ 同画风;点一下钉住后,点桌面其他地方不会自动收起,再点取消;钉住状态会记住)、⤾ 恢复默认位置、⟳ 刷新、✕ 关闭。未钉住时点击面板外或按 Esc 即收起。
36
+ - 面板标题里的版本号与 设置 →「检查更新」显示的都是**当前安装的版本**,与 npm 上的一致(发布时自动同步)。
35
37
  - 数据全在本机:`~/.dsh/memory/`(记忆文件)、`~/.dsh/dsh-auto-memory-pre.json`(配置,发布版为 `dsh-auto-memory.json`)。
36
38
 
37
39
  ## 2. 第一次启动
@@ -132,7 +134,7 @@
132
134
  | 白板注入预算(handoffPlanChars) | 1200 | 字符硬截断 |
133
135
  | 账本注入预算(handoffLedgerChars) | 800 | 字符硬截断 |
134
136
  | 水位窗口覆盖(waterLevelWindowTokens) | 0=自动 | 0 表示自动:优先官方路由容量,其次按当前模型查 settings.yaml。**除非特殊模型,保持 0** |
135
- | 水位阈值(waterLevelThreshold) | 0.8 | 越阈值即注入交接建议并自动补写账本 |
137
+ | 水位阈值(waterLevelThreshold) | 0.75 | 越阈值即注入交接建议并自动补写账本。**默认 0.75 而非 0.8**:官方自动压缩阈值是 80%,阈值贴着 80% 常被官方先压缩掉,留 5% 余量(1M 窗口约 50K token)才来得及走完交接 |
136
138
  | 水位建议注入(waterLevelAdvisory) | 开 | 无人值守时静默 |
137
139
  | 越阈值自动写账本(waterLevelAutoHandoff) | 开 | 每会话一次,给 AI 一个骨架,正式账本仍由 AI 写 |
138
140
  | **子代理痕迹回收**(subagentGcEnabled) | 开 | 见 §4.5 |
@@ -157,11 +159,15 @@
157
159
  记忆面板 → 白板页签顶部显示:**已用 token / 窗口 token · 百分比**,后面标注两个来源:
158
160
 
159
161
  - **计量**:`官方计量(usage)` = 与聊天框 context ring 同源(token-meter 的 `totalTokens`,即**当前上下文占用**);不可用时降级为启发式估算。
160
- - **窗口**:`官方路由容量` = 取自会话日志 `request/context` 的 `contextWindow`(最权威);`自动检测: provider/model` = 按当前模型查 `settings.yaml` 的 `contextWindow`;`回退默认值` = 前两条都拿不到时的保守值(128K)——看到这个标签说明模型窗口没识别出来,可到设置页手动填 `waterLevelWindowTokens`。
162
+ - **窗口**:`官方路由容量` = 取自会话日志 `request/context` 的 `contextWindow`(最权威);`自动检测: provider/model` = 按**当前会话真实使用的模型**(会话日志 `request/header` 的 provider/model)查 `settings.yaml` 的 `contextWindow`,查不到才回退到 `agent-default-model`(新会话默认模型);`回退默认值` = 前两条都拿不到时的保守值(128K)——看到这个标签说明模型窗口没识别出来,可到设置页手动填 `waterLevelWindowTokens`。
163
+ > 你在这里看到的模型名是**这个会话正在用的模型**,不是设置里的默认模型;两者不一致时以会话为准(2026-09-08 修正,此前一律显示默认模型)。
164
+ > 卡片是**按会话**的:切换会话会自动换成该会话的数;该会话还没有实测数据(刚重启、或刚切过来还没发消息)时会标注「本会话尚未测量」,同时先把窗口与模型从该会话日志里推出来。
161
165
 
162
166
  达到阈值后:AI 收到交接建议,并自动补写一篇骨架账本(正式账本仍由 AI 写)。
163
167
 
164
168
  > **2.2.4 修复**:此前窗口解析有两条路同时失效(settings.yaml 的 flow 风格 YAML 解析不出、官方 contextWindow 事件只在会话开头出现而旧代码只扫最近 256 条),导致 1M 窗口被当成 128K、水位虚高显示 150%+。现已修复并如实标注来源。
169
+ >
170
+ > **比例如实上报**:旧版还把百分比硬截断到 150%,任何超额都显示成同一个数(例如 761,692 / 131,072 真实为 581%,却显示 150%)。现在显示真实百分比,进度条仍按 100% 封顶。注意:宿主代码不会热重载,**升级后必须重启 dsh web**(浏览器再 Ctrl+Shift+R),否则读到的仍是旧窗口解析。
165
171
 
166
172
  ### 4.2 交接白板
167
173
 
@@ -190,7 +196,7 @@
190
196
 
191
197
  ### 4.4 自动接续(免按钮,推荐)
192
198
 
193
- 白板页签 →「自动接续」卡片:开关(默认开)+ 阈值(默认 0.8)。
199
+ 白板页签 →「自动接续」卡片:开关(默认开)+ 阈值(默认 0.75,与水位阈值同步;官方自动压缩阈值是 80%,低于它才有意义)。
194
200
 
195
201
  - **触发条件**:水位 ≥ 阈值 **且** harness 权威 `running` 位在轮次边界转为 `false`(会话真的空闲了)。长工具调用不会误触发。
196
202
  - **确认卡三分支**:点「同意接续」= 立即接续;点「拒绝」= 本轮跳过,同一边界不再提示;**30–40 秒无操作** = 视为挂机,自动接续。
@@ -252,7 +258,7 @@ AI 在对话中可直接调用(你不需要记,但了解一下有好处):
252
258
 
253
259
  | 现象 | 处理 |
254
260
  |---|---|
255
- | 水位显示 `xxx / 131,072 token · 150%` 之类 | 2.2.4 已修复(窗口解析两条路都失效);升级后重启 dsh web + 硬刷新。若标签仍显示「回退默认值」,说明该模型不在 settings.yaml 里,手动填 `waterLevelWindowTokens` |
261
+ | 水位显示 `xxx / 131,072 token · 150%` 之类 | ①先确认**宿主已重启**(host 代码不热重载,只刷新页面没用)②窗口解析已在 2.2.4 修复;若标签仍是「回退默认值」,说明该模型不在 settings.yaml 里,手动填 `waterLevelWindowTokens` ③百分比已不再截断到 150%(旧版任何超额都显示 150%,真实值可能是 581%),进度条仍按 100% 封顶 |
256
262
  | 提示「词法兜底 / 未就绪」 | 设置 → 自动记忆引擎 → ⟳ 检测,按引导下载 JS 模型或装 Python |
257
263
  | 一键接续报「harness 未提供 remote.session」 | 重启 dsh web(注入面需重启加载);仍不行检查版本 ≥ 2.2.2 |
258
264
  | 新会话落到「未分组工作区」/ 没沿用模型与思考档位 | 2.2.4 已修复(create 传 workspaceId、模型取自 request/header);升级后需**重启 dsh web + 硬刷新页面** |
package/lib/client.js CHANGED
@@ -25,6 +25,10 @@ window.__ModuleLoader__.load({
25
25
  var panelOpen = false
26
26
  var panelClosing = false
27
27
  var closeTimer = null
28
+ // 悬浮钉(2026-09-08):钉住后点击面板外不再自动收起,适合边看记忆边操作桌面
29
+ var PIN_KEY = 'dsh-auto-memory.panel.pinned'
30
+ var pinned = false
31
+ try { pinned = localStorage.getItem(PIN_KEY) === '1' } catch (ePin) {}
28
32
  // 几何状态:left/top/width/height,持久化到 localStorage(用户可拖动/缩放)
29
33
  var GEOM_KEY = 'dsh-auto-memory.panel.geom'
30
34
  var DEFAULT_W = 440
@@ -133,6 +137,12 @@ window.__ModuleLoader__.load({
133
137
  }, 170)
134
138
  },
135
139
  subscribe: function (fn) { listeners.add(fn); return function () { listeners.delete(fn) } },
140
+ isPinned: function () { return pinned },
141
+ togglePin: function () {
142
+ pinned = !pinned
143
+ try { localStorage.setItem(PIN_KEY, pinned ? '1' : '0') } catch (e) {}
144
+ emit()
145
+ },
136
146
  }
137
147
 
138
148
  // ───────────────────────── 国际化 ─────────────────────────
@@ -194,7 +204,7 @@ window.__ModuleLoader__.load({
194
204
  kvMem: '记忆文件', kvMemUser: '用户', kvMemNotes: '笔记', kvMemLog: '日志', kvMemMissing: '缺失',
195
205
  kvWs: '当前工作区', kvWsUnknown: '(未取到)', kvApi: 'API 连通', unknown: '(未知)',
196
206
  updateTitle: 'dsh-auto-memory 已更新', updateSub: '本次更新内容:', gotIt: '知道了', noticeOpen: '了解更多',
197
- refresh: '刷新', refreshing: '刷新中…', close: '关闭', dragMove: '拖动移动', dragResize: '拖动调整大小', resetPos: '恢复默认位置',
207
+ refresh: '刷新', refreshing: '刷新中…', close: '关闭', dragMove: '拖动移动', dragResize: '拖动调整大小', resetPos: '恢复默认位置', pinPanel: '钉住面板(点桌面其他地方不收起)', unpinPanel: '取消钉住(点面板外自动收起)',
198
208
  generated: '已生成', failed: '失败: ',
199
209
  generatedAt: '生成于 ',
200
210
  pointsCount: ' 项', summarizing: 'AI 总结生成中…', summaryFailed: 'AI 总结生成失败,点 ⟳ 重试',
@@ -262,7 +272,7 @@ window.__ModuleLoader__.load({
262
272
  fWaterWindow: '水位估计窗口(token)', fWaterWindowHint: '会话消息按官方公式(4 字符≈1 token)估算达到该值即视为上下文将满;0=关闭。默认 65536(128K 窗口的保守半量),按所用模型调整。',
263
273
  fWaterAdvisory: '水位交接建议', fWaterAdvisoryHint: '水位越阈时在动态快照注入交接建议(写账本/刷新白板/建议开新窗);无人值守时静默。',
264
274
  fWaterAuto: '水位自动骨架账本', fWaterAutoHint: '越阈时自动写一篇系统骨架账本(每会话一次),防止模型忽视建议时交接材料缺失。',
265
- fWaterThreshold: '水位建议阈值', fWaterThresholdHint: '水位比例超过该值(0.1-1.5)即触发交接建议/骨架账本;默认 0.8。',
275
+ fWaterThreshold: '水位建议阈值', fWaterThresholdHint: '水位比例超过该值(0.1-1.5)即触发交接建议/骨架账本;默认 0.75——官方自动压缩阈值是 80%,必须留出余量才来得及走完交接。',
266
276
  fHandoffPlan: '白板注入预算(字符)', fHandoffPlanHint: 'PLAN.md 全貌注入动态快照的硬截断预算;全文可经 memory_read 或面板白板页查看。默认 1200。',
267
277
  fHandoffLedger: '账本注入预算(字符)', fHandoffLedgerHint: '最新一篇交接账本注入动态快照的硬截断预算。默认 800。',
268
278
  fConsSchedule: '定时做梦式固化', fConsScheduleHint: '每天到点自动读最近日志发散提炼长期要点,写入项目笔记/用户级记忆(等价 memory_consolidate)。',
@@ -270,7 +280,7 @@ window.__ModuleLoader__.load({
270
280
  fConsScheduleDays: '固化回看天数', fConsScheduleDaysHint: '定时固化读取最近 N 天日志;默认 7。',
271
281
  fMaintSchedule: '定时 30 天蒸馏', fMaintScheduleHint: '每天到点自动把超过 30 天的旧日志蒸馏归档(等价 memory_maintain);无旧日志时零成本跳过。',
272
282
  fMaintScheduleTime: '蒸馏触发时间', fMaintScheduleTimeHint: 'HH:MM,默认 10:00(与固化时间错开)。',
273
- waterCardTitle: '上下文水位', waterAutoSrc: '自动检测', waterManualSrc: '手动设定', waterOfficialSrc: '官方路由容量', waterFallbackSrc: '回退默认值', waterMeterOfficial: '官方计量', waterMeterHeuristic: '启发式降级', waterThresholdHint: '水位达到 {t} 时注入交接建议并自动补写账本;计量优先用官方 token-meter(与聊天框 context ring 同源),不可用时降级启发式估算;窗口优先取官方路由容量(request/context 的 contextWindow),其次按当前模型查 settings.yaml contextWindow,也可在下方手动覆盖。',
283
+ waterCardTitle: '上下文水位', waterAutoSrc: '自动检测', waterManualSrc: '手动设定', waterOfficialSrc: '官方路由容量', waterFallbackSrc: '回退默认值', waterNotMeasured: '本会话尚未测量(发送一轮后自动更新)', waterMeterOfficial: '官方计量', waterMeterHeuristic: '启发式降级', waterThresholdHint: '水位达到 {t} 时注入交接建议并自动补写账本;计量优先用官方 token-meter(与聊天框 context ring 同源),不可用时降级启发式估算;窗口优先取官方路由容量(request/context 的 contextWindow),其次按当前模型查 settings.yaml contextWindow,也可在下方手动覆盖。',
274
284
  autoContTitle: '自动接续', autoContHint: '开启后:水位达到阈值、且旧会话已空闲(harness 权威 running 位在轮次边界转为 false,取代旧「两轮水位持平」)时,弹出确认卡——同意=立即接续,拒绝=本轮跳过(同一边界不再提示),30-40 秒无操作视为挂机自动接续;接续前先让旧 Agent 刷新白板 PLAN 与交接账本,再建新会话(沿用工作区/模型/思考档位)并注入分层交接材料;触发后默认 30 分钟内不重复。', autoContOn: '自动接续:开', autoContOff: '自动接续:关', autoContThreshold: '阈值', autoContCountdown: '⏳ {s} 秒后自动接续…', autoContCancel: '取消', autoContConfirm: '本会话水位已达 {p}%,旧会话已空闲(轮次边界)——现在接续到新会话?', autoContAgree: '同意接续', autoContReject: '拒绝', autoContTimeout: '{s} 秒后自动接续(视为挂机)', autoContRejected: '已跳过本次接续(下一个轮次边界再提醒)',
275
285
  continueCardTitle: '一键接续', continueCardHint: '写好交接账本→创建新会话→把交接材料作为首条消息预载,并自动切换到新会话继续任务(接续材料含白板节选+最新账本;当前工作区无账本时自动取全局最近一篇)。', continueBtn: '一键接续到新会话', continueBusy: '处理中…',
276
286
  fStyle: '反思风格', fStyleHint: '生活化 / 专业性 / 由内容决定。',
@@ -342,7 +352,7 @@ window.__ModuleLoader__.load({
342
352
  kvMem: 'Memory files', kvMemUser: 'user', kvMemNotes: 'notes', kvMemLog: 'log', kvMemMissing: 'missing',
343
353
  kvWs: 'Current workspace', kvWsUnknown: '(unknown)', kvApi: 'API probes', unknown: '(unknown)',
344
354
  updateTitle: 'dsh-auto-memory Updated', updateSub: 'What\'s new in this update:', gotIt: 'Got it', noticeOpen: 'Learn more',
345
- refresh: 'Refresh', refreshing: 'Refreshing…', close: 'Close', dragMove: 'Drag to move', dragResize: 'Drag to resize', resetPos: 'Reset position',
355
+ refresh: 'Refresh', refreshing: 'Refreshing…', close: 'Close', dragMove: 'Drag to move', dragResize: 'Drag to resize', resetPos: 'Reset position', pinPanel: 'Pin panel (stays open when clicking elsewhere)', unpinPanel: 'Unpin (auto-hide on outside click)',
346
356
  generated: 'Generated', failed: 'Failed: ',
347
357
  generatedAt: 'Generated ',
348
358
  pointsCount: ' items', summarizing: 'AI summary generating…', summaryFailed: 'Summary failed, press refresh to retry',
@@ -410,7 +420,7 @@ window.__ModuleLoader__.load({
410
420
  fWaterWindow: 'Water-level window (tokens)', fWaterWindowHint: 'Session messages priced with the official meter (4 chars ≈ 1 token); past this, context is considered nearly full; 0 disables. Default 65536 — a conservative half of a 128K window.',
411
421
  fWaterAdvisory: 'Water-level handoff advisory', fWaterAdvisoryHint: 'Injects a handoff advisory past the threshold (write ledger / refresh plan / suggest a new window); silent in unattended mode.',
412
422
  fWaterAuto: 'Auto skeleton ledger', fWaterAutoHint: 'Past the threshold, auto-writes one system skeleton ledger per session as a safety net if the model ignores the advisory.',
413
- fWaterThreshold: 'Water-level threshold', fWaterThresholdHint: 'Inject the handoff advisory / skeleton ledger once the ratio passes this value (0.1-1.5); default 0.8.',
423
+ fWaterThreshold: 'Water-level threshold', fWaterThresholdHint: 'Inject the handoff advisory / skeleton ledger once the ratio passes this value (0.1-1.5); default 0.75 — the official auto-compaction threshold is 80%, so a 5% margin is needed to finish the handoff first.',
414
424
  fHandoffPlan: 'Plan injection budget (chars)', fHandoffPlanHint: 'Hard truncation budget for injecting the PLAN.md snapshot into the dynamic memory; full text via memory_read or the Whiteboard tab. Default 1200.',
415
425
  fHandoffLedger: 'Ledger injection budget (chars)', fHandoffLedgerHint: 'Hard truncation budget for injecting the latest handoff ledger. Default 800.',
416
426
  fConsSchedule: 'Scheduled deep consolidation', fConsScheduleHint: 'Once a day at the set time, read recent logs and distill long-term value into project notes / user memory (same flow as memory_consolidate).',
@@ -418,7 +428,7 @@ window.__ModuleLoader__.load({
418
428
  fConsScheduleDays: 'Consolidation lookback (days)', fConsScheduleDaysHint: 'How many recent days of logs the scheduled consolidation reads; default 7.',
419
429
  fMaintSchedule: 'Scheduled 30-day distill', fMaintScheduleHint: 'Once a day at the set time, distill & archive logs older than 30 days (same flow as memory_maintain); zero cost when nothing to archive.',
420
430
  fMaintScheduleTime: 'Distill time', fMaintScheduleTimeHint: 'HH:MM, default 10:00 (staggered from the consolidation time).',
421
- waterCardTitle: 'Context water level', waterAutoSrc: 'auto-detected', waterManualSrc: 'manual', waterOfficialSrc: 'route capacity', waterFallbackSrc: 'fallback default', waterMeterOfficial: 'official meter', waterMeterHeuristic: 'heuristic fallback', waterThresholdHint: 'Past {t} of the window, the handoff advisory is injected and the skeleton ledger is backfilled; metering prefers the official token-meter (same source as the chat context ring), falling back to the heuristic when unavailable; the window prefers the official route capacity (request/context contextWindow), then the active model in settings.yaml, or your manual override below.',
431
+ waterCardTitle: 'Context water level', waterAutoSrc: 'auto-detected', waterManualSrc: 'manual', waterOfficialSrc: 'route capacity', waterFallbackSrc: 'fallback default', waterNotMeasured: 'Not measured for this session yet (updates after the next turn)', waterMeterOfficial: 'official meter', waterMeterHeuristic: 'heuristic fallback', waterThresholdHint: 'Past {t} of the window, the handoff advisory is injected and the skeleton ledger is backfilled; metering prefers the official token-meter (same source as the chat context ring), falling back to the heuristic when unavailable; the window prefers the official route capacity (request/context contextWindow), then the active model in settings.yaml, or your manual override below.',
422
432
  autoContTitle: 'Auto continue', autoContHint: 'When on: once the water level passes the threshold AND the old session is idle (the authoritative harness running bit flipped to false at a turn boundary, replacing the old "level unchanged across two polls"), a confirm card appears — Agree = continue now, Decline = skip this boundary (no re-prompt for it), no answer for 30-40s = unattended, continue automatically. Before continuing, the old agent refreshes PLAN + ledger; the new session keeps workspace/model/reasoning effort and receives layered handoff material; suppressed for 30 minutes after each run.', autoContOn: 'Auto continue: on', autoContOff: 'Auto continue: off', autoContThreshold: 'Threshold', autoContCountdown: '⏳ Auto-continue in {s}s…', autoContCancel: 'Cancel', autoContConfirm: 'Water level at {p}% and the old session is idle (turn boundary) — continue into a new session now?', autoContAgree: 'Continue', autoContReject: 'Decline', autoContTimeout: 'auto-continue in {s}s (unattended)', autoContRejected: 'Skipped this handoff (will ask again at the next turn boundary)',
423
433
  continueCardTitle: 'One-click continue', continueCardHint: 'Writes the handoff ledger → creates a new session → injects the handoff material as the first message → switches to the new session automatically (material = PLAN excerpt + latest ledger; falls back to the most recent global ledger when this workspace has none).', continueBtn: 'Continue in a new session', continueBusy: 'Working…',
424
434
  fStyle: 'Reflection style', fStyleHint: 'Life-style / Professional / Auto.',
@@ -492,6 +502,15 @@ window.__ModuleLoader__.load({
492
502
  return s ? !!s.running : null
493
503
  } catch (e) { return null }
494
504
  }
505
+ // 当前会话 id(跟随 GUI 切换会话):水位卡按会话取数,切换即刷新(2026-09-08)
506
+ function currentSessionIdClient() {
507
+ try {
508
+ var snap = sessions && sessions.list && sessions.list.getSnapshot()
509
+ if (!snap) return ''
510
+ var id = snap.current
511
+ return (id === undefined || id === null) ? '' : String(id)
512
+ } catch (e) { return '' }
513
+ }
495
514
  function amCtzValue() {
496
515
  return (typeof Intl !== 'undefined' && typeof Intl.DateTimeFormat === 'function') ? (Intl.DateTimeFormat().resolvedOptions().timeZone || undefined) : undefined
497
516
  }
@@ -542,6 +561,21 @@ window.__ModuleLoader__.load({
542
561
 
543
562
  // ───────────────────────── 更新弹窗 / 首次指导 ─────────────────────────
544
563
  var CHANGELOG = {
564
+ '2.2.5': { zh: [
565
+ '★ 修复:上下文水位百分比不再被硬截断到 150%——旧版 `Math.min(tokens / window, 1.5)` 让任何超额都显示成同一个数(例如 761,692 / 131,072 真实为 581%)。现在如实上报,进度条仍按 100% 封顶。',
566
+ '★ 修复:水位卡标出的模型现在是「当前会话真实使用的模型」——此前一律读 settings.yaml 的 agent-default-model(新会话默认模型),你在界面切过模型后,卡片会张冠李戴地标成另一个模型(实测会话跑 deepseek-official/deepseek-v4.1-flash,却标成 opencode-go/deepseek-v4-flash)。现改为优先取会话日志 request/header 的 provider/model,默认模型只作兜底。',
567
+ '★ 修复:水位卡改成按会话显示——此前是全机单值、只在有对话轮次时更新,切到别的会话仍显示上一个会话的数(或重启后的 0),模型名也只能拿默认模型凑。现在按 sessionId 取该会话的实测值;没有实测就现场从该会话日志推导窗口与模型,并明确标注「本会话尚未测量」。',
568
+ '★ 版本号同步:面板徽标与「检测更新」读的是开发树的 package.json,长期停在 0.1.30(而 npm 已是 2.2.x),于是界面显示旧版本、更新检查永远报「有新版本」。现在发布脚本会把版本号回写开发树,两条线永远一致。',
569
+ '★ 新增悬浮钉:记忆面板标题栏加了一个图钉按钮(线描图标,与 ⟳ ⤾ ✕ 同画风),点一下钉住——之后再点桌面或其他地方,面板不会自动收起(再点一下取消)。钉住状态会记住。',
570
+ '★ 交接阈值下调到 75%:官方自动压缩阈值是 80%,阈值贴着 80% 常常还没走完交接就被官方压缩掉;水位建议阈值与自动接续阈值默认都改为 0.75,留 5% 余量(1M 窗口约 50K token)。已存在的 0.8 配置不会被自动改写,可在设置页手动改。',
571
+ ], en: [
572
+ '★ Fix: the context water-level percentage is no longer clamped to 150% — the old `Math.min(tokens / window, 1.5)` made every over-window reading look identical (761,692 / 131,072 is really 581%). It now reports the true value; the progress bar still caps at 100%.',
573
+ '★ Fix: the model shown on the water-level card is now the model the session is actually using — it used to always read agent-default-model from settings.yaml, so after switching models in the UI the card named a different model (observed: session running deepseek-official/deepseek-v4.1-flash but the card said opencode-go/deepseek-v4-flash). It now prefers provider/model from the session log\'s request/header, with the default model only as a fallback.',
574
+ '★ Fix: the water-level card is now per-session — it used to be a single global value updated only on turns, so switching conversations kept showing another session\'s numbers (or 0 after a restart) with the default model name. It now reads the measured value for the session id, and when there is none it derives the window/model from that session log and says "not measured for this session yet".',
575
+ '★ Version sync: the panel badge and "Check for updates" read the dev tree package.json, which sat at 0.1.30 while npm was already 2.2.x — the UI showed an old version and the update check always reported "update available". The release script now writes the version back to the dev tree so both lines stay in lockstep.',
576
+ '★ New pin button: the panel header now has a line-art pin toggle matching the ⟳ ⤾ ✕ icons — pin it and clicking elsewhere on the desktop no longer auto-hides the panel (click again to unpin). The pinned state is remembered.',
577
+ '★ Handoff threshold lowered to 75%: the official auto-compaction threshold is 80%, so a threshold sitting at 80% often gets compacted before the handoff finishes. Both the water-level advisory and the auto-continue threshold now default to 0.75, leaving 5% headroom (~50K tokens on a 1M window). An existing 0.8 in your config is not rewritten automatically — change it in Settings if you want the new default.',
578
+ ] },
545
579
  '2.2.4': { zh: [
546
580
  '★ 修复:一键接续的新会话终于落进旧会话所属工作区——官方 session.create 只有传 workspaceId 才会绑定工作区,只传 cwd 仅设工作目录(故此前恒落「未分组」);现 host 先解析旧会话的 workspaceId 再建会话,解析不到才回退 cwd。',
547
581
  '★ 修复:新会话沿用旧会话的模型与思考档位——模型信息在会话日志的 request/header.header.config(旧版查错了事件类型,恒取空 → 回落默认模型)。',
@@ -876,6 +910,7 @@ window.__ModuleLoader__.load({
876
910
  '[data-dam-resize]:hover { opacity: 1; }',
877
911
  '[data-dam-btn] { border: none; background: transparent; cursor: pointer; color: inherit; opacity: .75; font-size: calc(13px * var(--dam-scale)); padding: 4px 8px; border-radius: 6px; }',
878
912
  '[data-dam-btn]:hover { opacity: 1; background: color-mix(in srgb, var(--dsw-alias-label-secondary, #666) 16%, transparent); }',
913
+ '[data-dam-btn][data-pin="on"] { opacity: 1; background: color-mix(in srgb, var(--dam-accent, #4f7cff) 24%, transparent); }',
879
914
  '[data-dam-tabs-wrap] { display: flex; align-items: center; min-width: 0; border-bottom: 1px solid color-mix(in srgb, var(--dsw-alias-border-l1, rgba(128,128,128,.2)) 55%, transparent); }',
880
915
  '[data-dam-tabs] { flex: 1; min-width: 0; overflow: hidden; padding: 5px 4px; scrollbar-width: none; -ms-overflow-style: none; }',
881
916
  '[data-dam-tabs]::-webkit-scrollbar { display: none; }',
@@ -1178,6 +1213,22 @@ window.__ModuleLoader__.load({
1178
1213
  var label = props && props.label ? props.label : t('loading')
1179
1214
  return h('div', { 'data-dam-loading': '' }, h('span', { 'data-dam-spinner': '', 'aria-hidden': 'true' }), h('span', null, label))
1180
1215
  }
1216
+ /** 线描图钉图标(2026-09-08):与标题栏 ⤾ ⟳ ✕ 同一画风——单色 currentColor 描边、无彩色 emoji;
1217
+ * 未钉住=描边空心,已钉住=填充(内孔用 evenodd 挖空)。 */
1218
+ function PinIcon(props) {
1219
+ var filled = !!(props && props.filled)
1220
+ return h('svg', {
1221
+ width: 13, height: 13, viewBox: '0 0 16 16', 'aria-hidden': 'true',
1222
+ style: { display: 'block', margin: '0 auto', pointerEvents: 'none' },
1223
+ }, h('path', {
1224
+ d: 'M8 1.7c2.2 0 4 1.8 4 4 0 2.9-4 8.6-4 8.6S4 8.6 4 5.7c0-2.2 1.8-4 4-4zM8 4.2a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z',
1225
+ fill: filled ? 'currentColor' : 'none',
1226
+ fillRule: 'evenodd',
1227
+ stroke: 'currentColor',
1228
+ strokeWidth: '1.1',
1229
+ strokeLinejoin: 'round',
1230
+ }))
1231
+ }
1181
1232
  function AnimatedDisclosure(props) {
1182
1233
  var open = !!props.open
1183
1234
  var shownPair = useState(open)
@@ -2012,6 +2063,10 @@ window.__ModuleLoader__.load({
2012
2063
  var dataPair = useState(null)
2013
2064
  var data = dataPair[0]
2014
2065
  var setData = dataPair[1]
2066
+ // 当前会话 id:水位卡按会话取数(切换会话即刷新,2026-09-08)
2067
+ var sidPair = useState(function () { return currentSessionIdClient() })
2068
+ var sid = sidPair[0]
2069
+ var setSid = sidPair[1]
2015
2070
  var openPair = useState(null)
2016
2071
  var open = openPair[0]
2017
2072
  var setOpen = openPair[1]
@@ -2031,7 +2086,7 @@ window.__ModuleLoader__.load({
2031
2086
  var setAutoCfg = autoPair[1]
2032
2087
  useEffect(function () {
2033
2088
  apiGet(API.config).then(function (c) {
2034
- setAutoCfg({ enabled: !(c && c.autoContinueEnabled === false), threshold: (c && Number(c.autoContinueThreshold)) || 0.8 })
2089
+ setAutoCfg({ enabled: !(c && c.autoContinueEnabled === false), threshold: (c && Number(c.autoContinueThreshold)) || 0.75 })
2035
2090
  }).catch(function () { setAutoCfg({ enabled: false, threshold: 0.7 }) })
2036
2091
  }, [])
2037
2092
  var autoSave = function (patch) {
@@ -2051,8 +2106,22 @@ window.__ModuleLoader__.load({
2051
2106
  }
2052
2107
  useEffect(function () {
2053
2108
  var alive = true
2054
- apiGet(API.handoffState).then(function (d) { if (alive) setData(d) }).catch(function () {})
2109
+ setData(null)
2110
+ apiGet(API.handoffState, { sessionId: sid }).then(function (d) { if (alive) setData(d) }).catch(function () {})
2055
2111
  return function () { alive = false }
2112
+ }, [sid])
2113
+ // 切换会话即刷新水位卡:订阅官方会话列表快照,current 变化时重取(2026-09-08)
2114
+ useEffect(function () {
2115
+ var unsub = null
2116
+ try {
2117
+ if (sessions && sessions.list && typeof sessions.list.subscribe === 'function') {
2118
+ unsub = sessions.list.subscribe(function () {
2119
+ var now = currentSessionIdClient()
2120
+ setSid(function (prev) { return prev === now ? prev : now })
2121
+ })
2122
+ }
2123
+ } catch (eSub) {}
2124
+ return function () { if (typeof unsub === 'function') unsub() }
2056
2125
  }, [])
2057
2126
  useEffect(function () {
2058
2127
  if (!open) return
@@ -2087,9 +2156,11 @@ window.__ModuleLoader__.load({
2087
2156
  (wl.tokens || 0).toLocaleString() + ' / ' + wl.window.toLocaleString() + ' token · ' + pct + '%',
2088
2157
  ' ', h('span', { 'data-dam-hint': '' }, srcLabel),
2089
2158
  ' ', h('span', { 'data-dam-hint': '' }, wl.meter ? (wl.meter.indexOf('official') === 0 ? '· ' + t('waterMeterOfficial') + '(' + String(wl.meter).slice(9) + ')' : '· ' + t('waterMeterHeuristic')) : '')),
2159
+ // 本会话尚未测量(刚重启/刚切过来还没发消息):明说,而不是显示成 0 让人以为上下文是空的
2160
+ wl.at ? null : h('div', { 'data-dam-hint': '', style: { marginBottom: '4px' } }, t('waterNotMeasured')),
2090
2161
  h('div', { style: { height: '6px', borderRadius: '3px', background: 'color-mix(in srgb, currentColor 12%, transparent)', overflow: 'hidden' } },
2091
2162
  h('div', { style: { height: '100%', width: Math.min(pct, 100) + '%', background: barColor, borderRadius: '3px' } })),
2092
- h('div', { 'data-dam-hint': '', style: { marginTop: '4px' } }, t('waterThresholdHint').replace('{t}', String(wl.threshold || 0.8))),
2163
+ h('div', { 'data-dam-hint': '', style: { marginTop: '4px' } }, t('waterThresholdHint').replace('{t}', String(wl.threshold || 0.75))),
2093
2164
  ])))
2094
2165
  }
2095
2166
  if (autoCfg && wl.window > 0) {
@@ -2812,6 +2883,14 @@ window.__ModuleLoader__.load({
2812
2883
  h('strong', null, t('autoMemory')),
2813
2884
  h('span', { 'data-dam-hint': '', style: { fontSize: '10px', opacity: .55 } }, ver ? 'v' + ver + '-ui' : ''),
2814
2885
  h('span', { className: 'dam-spacer' }),
2886
+ // 悬浮钉(2026-09-08):钉住后点面板外不再自动收起;图标为线描 SVG,与 ⤾ ⟳ ✕ 同画风
2887
+ h('button', {
2888
+ 'data-dam-btn': '', 'data-pin': pinned ? 'on' : 'off',
2889
+ title: pinned ? t('unpinPanel') : t('pinPanel'),
2890
+ 'aria-pressed': pinned ? 'true' : 'false',
2891
+ style: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: '4px 6px' },
2892
+ onClick: function () { controller.togglePin() },
2893
+ }, h(PinIcon, { filled: pinned })),
2815
2894
  h('button', { 'data-dam-btn': '', title: t('resetPos'), onClick: function () { controller.resetGeom() } }, '⤾'),
2816
2895
  h('button', { 'data-dam-btn': '', title: t('refresh'), onClick: function () { setNonce(nonce + 1) } }, '⟳'),
2817
2896
  h('button', { 'data-dam-btn': '', title: t('close'), onClick: function () { controller.close() } }, '✕')),
@@ -3422,7 +3501,7 @@ window.__ModuleLoader__.load({
3422
3501
  useEffect(function () {
3423
3502
  var on = !(acCfg && acCfg.autoContinueEnabled === false)
3424
3503
  if (!on) return
3425
- var thr = (acCfg && Number(acCfg.autoContinueThreshold)) || 0.8
3504
+ var thr = (acCfg && Number(acCfg.autoContinueThreshold)) || 0.75
3426
3505
  var confirmSec = Number(acCfg && acCfg.autoContinueConfirmSeconds)
3427
3506
  if (!(confirmSec >= 10 && confirmSec <= 120)) confirmSec = 35
3428
3507
  var cooldownMin = Number(acCfg && acCfg.autoContinueCooldownMinutes)
@@ -3433,7 +3512,7 @@ window.__ModuleLoader__.load({
3433
3512
  if (!edgeAt || Date.now() - edgeAt > 10 * 60 * 1000) return // 边界太旧不再提示
3434
3513
  if (Date.now() < autoState.suppressUntil) return
3435
3514
  if (autoState.rejectedEdgeAt === edgeAt) return // ②拒绝过同一边界
3436
- apiGet(API.handoffState).then(function (d) {
3515
+ apiGet(API.handoffState, { sessionId: currentSessionIdClient() }).then(function (d) {
3437
3516
  if (!alive || !d || !d.enabled) return
3438
3517
  var wl = d.waterLevel || {}
3439
3518
  if (!wl.window || !(wl.ratio >= thr)) return
@@ -3491,7 +3570,7 @@ window.__ModuleLoader__.load({
3491
3570
  clearInterval(iv)
3492
3571
  if (unsub) { try { unsub() } catch (eU) {} }
3493
3572
  }
3494
- }, [acCfg && acCfg.autoContinueEnabled === false, acCfg && (Number(acCfg.autoContinueThreshold) || 0.8), acCfg && acCfg.autoContinueConfirmSeconds, acCfg && acCfg.autoContinueCooldownMinutes, acBusy])
3573
+ }, [acCfg && acCfg.autoContinueEnabled === false, acCfg && (Number(acCfg.autoContinueThreshold) || 0.75), acCfg && acCfg.autoContinueConfirmSeconds, acCfg && acCfg.autoContinueCooldownMinutes, acBusy])
3495
3574
  var cancelAc = function () {
3496
3575
  if (autoState.cdIv) { clearInterval(autoState.cdIv); autoState.cdIv = null }
3497
3576
  autoState.promptVisible = false
@@ -4060,7 +4139,7 @@ window.__ModuleLoader__.load({
4060
4139
  field(t('fHandoffPlan'), h('input', { 'data-dam-input': '', type: 'number', min: 200, value: cfg.handoffPlanChars === undefined ? 1200 : cfg.handoffPlanChars, onChange: function (e) { set('handoffPlanChars', Number(e.target.value) || 1200) } }), t('fHandoffPlanHint')),
4061
4140
  field(t('fHandoffLedger'), h('input', { 'data-dam-input': '', type: 'number', min: 100, value: cfg.handoffLedgerChars === undefined ? 800 : cfg.handoffLedgerChars, onChange: function (e) { set('handoffLedgerChars', Number(e.target.value) || 800) } }), t('fHandoffLedgerHint')),
4062
4141
  field(t('fWaterWindow'), h('input', { 'data-dam-input': '', type: 'number', min: 0, value: cfg.waterLevelWindowTokens === undefined ? 65536 : cfg.waterLevelWindowTokens, onChange: function (e) { set('waterLevelWindowTokens', Number(e.target.value) || 0) } }), t('fWaterWindowHint')),
4063
- field(t('fWaterThreshold'), h('input', { 'data-dam-input': '', type: 'number', min: 0.1, max: 1.5, step: 0.05, value: cfg.waterLevelThreshold === undefined ? 0.8 : cfg.waterLevelThreshold, onChange: function (e) { set('waterLevelThreshold', Number(e.target.value) || 0.8) } }), t('fWaterThresholdHint')),
4142
+ field(t('fWaterThreshold'), h('input', { 'data-dam-input': '', type: 'number', min: 0.1, max: 1.5, step: 0.05, value: cfg.waterLevelThreshold === undefined ? 0.75 : cfg.waterLevelThreshold, onChange: function (e) { set('waterLevelThreshold', Number(e.target.value) || 0.75) } }), t('fWaterThresholdHint')),
4064
4143
  field(t('fWaterAdvisory'), h('input', { type: 'checkbox', checked: cfg.waterLevelAdvisory !== false, onChange: function (e) { set('waterLevelAdvisory', e.target.checked) } }), t('fWaterAdvisoryHint')),
4065
4144
  field(t('fWaterAuto'), h('input', { type: 'checkbox', checked: cfg.waterLevelAutoHandoff !== false, onChange: function (e) { set('waterLevelAutoHandoff', e.target.checked) } }), t('fWaterAutoHint')),
4066
4145
  field(locale === 'zh' ? '子代理痕迹回收' : 'Subagent trace recycle', h('input', { type: 'checkbox', checked: cfg.subagentGcEnabled !== false, onChange: function (e) { set('subagentGcEnabled', e.target.checked) } }),
@@ -4105,6 +4184,7 @@ window.__ModuleLoader__.load({
4105
4184
  try {
4106
4185
  var onDocPointerDown = function (e) {
4107
4186
  if (!panelOpen || panelClosing) return
4187
+ if (controller.isPinned()) return // 悬浮钉:钉住后点面板外不收起(2026-09-08)
4108
4188
  var el = e.target
4109
4189
  if (el && el.closest && (el.closest('[data-dam-panel]') || el.closest('[data-dam-sidebar-btn]'))) return
4110
4190
  controller.close()
package/lib/index.js CHANGED
@@ -43,8 +43,8 @@ import { createMemoryHubPre } from './memory-hub.js'
43
43
  import { pickConsolidationTextPre } from './intent-clean.js'
44
44
  import { createStorageManagerPre } from './storage-manage.js'
45
45
  import { rankWorkspacesByMemoryRecencyPre } from './ws-overview-rank.js'
46
- import { scanPluginSubagentSessions, recycleSessions, PLUGIN_LABEL_PREFIX } from './subagent-gc.js'
47
- import { parseModelWindowsPre, pickWindowPre, findOfficialContextWindowPre } from './water-window.js'
46
+ import { scanPluginSubagentSessions, recycleSessions, PLUGIN_LABEL_PREFIX, decodeZstdFrames } from './subagent-gc.js'
47
+ import { parseModelWindowsPre, pickWindowPre, findOfficialContextWindowPre, findSessionModelPre } from './water-window.js'
48
48
  import { buildSourceCatalog, CorpusRegistry, canonicalize } from './m4-corpus.js'
49
49
  import * as nodeZlib from 'node:zlib'
50
50
  import { AsyncLocalStorage } from 'node:async_hooks'
@@ -221,16 +221,18 @@ const DEFAULT_CONFIG = {
221
221
  handoffLedgerChars: 800,
222
222
  /** M-CM4 水位感知:窗口 token 数。0=自动——从 settings.yaml 的 agent-default-model 解析对应模型的 contextWindow(失败回退 131072);手动设值即覆盖自动检测。 */
223
223
  waterLevelWindowTokens: 0,
224
- /** 水位建议阈值(0.1-1.5):ratio 越阈值时注入交接建议。 */
225
- waterLevelThreshold: 0.8,
224
+ /** 水位建议阈值(0.1-1.5):ratio 越阈值时注入交接建议。
225
+ * 2026-09-08 由 0.8 下调到 0.75:harness 官方自动压缩阈值是 80%,阈值贴着 80% 会在交接动作完成前先被官方压缩掉,
226
+ * 留 5% 余量(约 1M 窗口的 50K token)才来得及走完「写账本 → 建新会话 → 注入材料」。 */
227
+ waterLevelThreshold: 0.75,
226
228
  /** 水位建议注入(无人值守时始终静默)。 */
227
229
  waterLevelAdvisory: true,
228
230
  /** 水位越阈时自动写一篇系统骨架账本(每会话一次;模型仍应自己写正式交接)。 */
229
231
  waterLevelAutoHandoff: true,
230
232
  /** 自动接续(默认开):水位≥阈值 且 harness 权威 running==false(轮次边界,取代旧"两轮水位持平")时弹确认卡。 */
231
233
  autoContinueEnabled: true,
232
- /** 自动接续水位阈值(0.5-0.95)。 */
233
- autoContinueThreshold: 0.8,
234
+ /** 自动接续水位阈值(0.5-0.95);2026-09-08 与 waterLevelThreshold 同步下调到 0.75(官方自动压缩阈值 80%,必须留余量)。 */
235
+ autoContinueThreshold: 0.75,
234
236
  /** 确认卡倒计时秒数(30-40s 无操作 = 挂机 → 自动接续兜底)。 */
235
237
  autoContinueConfirmSeconds: 35,
236
238
  /** 接续前刷新仪式(默认开):先让旧 Agent 刷新 PLAN.md + 交接账本,host 再用最新材料组装交接。 */
@@ -1643,16 +1645,20 @@ class MemoryEngine {
1643
1645
  }
1644
1646
 
1645
1647
  /** M-CM4·自动窗口:agent-default-model(provider+model)→ settings.yaml 对应模型的 contextWindow;失败回退保守值。60s 缓存。 */
1646
- async resolveWaterWindow() {
1648
+ /** M-CM4·自动窗口:会话真实模型(request/header 的 provider/model,优先)→ agent-default-model(兜底)→ settings.yaml 对应模型的 contextWindow;失败回退保守值。60s 缓存。 */
1649
+ async resolveWaterWindow(providerOverride = '', modelOverride = '') {
1647
1650
  try {
1648
1651
  const override = Number(this.config.waterLevelWindowTokens) || 0
1649
1652
  if (override > 0) return { window: override, source: 'manual' }
1650
1653
  const now = Date.now()
1651
- if (this._waterWindowCache && now - this._waterWindowCache.at < 60000) return this._waterWindowCache.value
1654
+ const cacheKey = String(providerOverride || '') + '/' + String(modelOverride || '')
1655
+ if (this._waterWindowCache && now - this._waterWindowCache.at < 60000 && this._waterWindowCache.key === cacheKey) return this._waterWindowCache.value
1652
1656
  const text = await this.readTextSafe(path.join(dshHome(), 'settings.yaml'))
1653
1657
  let value = 0
1654
- let model = ''
1655
- let provider = ''
1658
+ let model = String(modelOverride || '')
1659
+ let provider = String(providerOverride || '')
1660
+ let defModel = ''
1661
+ let defProvider = ''
1656
1662
  if (text) {
1657
1663
  const lines = text.split(/\r?\n/)
1658
1664
  const ai = lines.findIndex((l) => /^agent-default-model:/.test(l))
@@ -1660,47 +1666,118 @@ class MemoryEngine {
1660
1666
  for (let i = ai + 1; i < Math.min(lines.length, ai + 6); i++) {
1661
1667
  const m = lines[i].match(/^ {2}(provider|model): *(.+?) *$/)
1662
1668
  if (!m) break
1663
- if (m[1] === 'provider') provider = m[2].trim()
1664
- if (m[1] === 'model') { model = m[2].trim(); break }
1669
+ if (m[1] === 'provider') defProvider = m[2].trim()
1670
+ if (m[1] === 'model') { defModel = m[2].trim(); break }
1665
1671
  }
1666
1672
  }
1667
1673
  // 2.2.4 修复:settings.yaml 的 llm-deepseek 段是 flow 风格 YAML(`{ models: [ { id: x, contextWindow: 1000000, … } ] }`),
1668
1674
  // 旧解析器只认 block 风格 → 整段解析失败、窗口退化为 fallback。改为纯函数解析(block + flow 双支持)。
1669
- if (model) value = pickWindowPre(parseModelWindowsPre(text), provider, model)
1675
+ const windows = parseModelWindowsPre(text)
1676
+ // 2026-09-08:优先按「会话自己的模型」查窗口——用户在 GUI 切过模型后,agent-default-model 与新会话默认值
1677
+ // 并不等于当前会话实际模型(实测会话跑 deepseek-official/deepseek-v4.1-flash,默认模型却是 opencode-go/deepseek-v4-flash)。
1678
+ const lookupProvider = provider || defProvider
1679
+ const lookupModel = model || defModel
1680
+ if (lookupModel) value = pickWindowPre(windows, lookupProvider, lookupModel)
1681
+ if (value > 0) { provider = lookupProvider; model = lookupModel }
1682
+ else if (defModel && defModel !== lookupModel) {
1683
+ const alt = pickWindowPre(windows, defProvider, defModel)
1684
+ if (alt > 0) { value = alt; provider = defProvider; model = defModel }
1685
+ }
1670
1686
  }
1671
1687
  const result = value > 0
1672
1688
  ? { window: value, source: 'auto' + (provider ? ':' + provider : '') + (model ? '/' + model : ''), model: model || '' }
1673
- : { window: 131072, source: 'fallback', model: model || '' }
1674
- this._waterWindowCache = { at: now, value: result }
1689
+ : { window: 131072, source: 'fallback', model: model || defModel || '' }
1690
+ this._waterWindowCache = { at: now, key: cacheKey, value: result }
1675
1691
  return result
1676
1692
  } catch (e) { return { window: 131072, source: 'fallback', error: String(e && e.message) } }
1677
1693
  }
1678
1694
 
1695
+ /** 会话级水位记录(2026-09-08):水位此前是**全局单值**,只在有 agent 轮次时更新;
1696
+ * 于是切到别的会话时,卡片显示的是「上一个会话的数」或重启后的 0,且模型名只能拿默认模型凑。
1697
+ * 现在:①checkWaterLevel 每次测量写入 per-session 表;②查询时按 sessionId 取;
1698
+ * ③没有记录就从该会话日志(磁盘)推导窗口与模型——切会话即可刷新,不需要先发消息。 */
1699
+ rememberWaterRecord(sid, rec) {
1700
+ if (!sid || !rec) return
1701
+ if (!this._waterBySession) this._waterBySession = new Map()
1702
+ this._waterBySession.set(this.waterKey(sid), rec)
1703
+ if (this._waterBySession.size > 40) {
1704
+ const first = this._waterBySession.keys().next()
1705
+ if (!first.done) this._waterBySession.delete(first.value)
1706
+ }
1707
+ }
1708
+
1709
+ /** 会话 id 归一化:agent.session.id 与 GUI 快照 current 可能带/不带 `session-` 前缀,统一去掉后作键。 */
1710
+ waterKey(sid) {
1711
+ return String(sid || '').replace(/^session-/, '')
1712
+ }
1713
+
1714
+ /** 取该会话最近 10 分钟内的实测记录(无则 null)。 */
1715
+ waterRecordFor(sid) {
1716
+ if (!sid) return null
1717
+ const m = this._waterBySession
1718
+ const rec = m && typeof m.get === 'function' ? m.get(this.waterKey(sid)) : null
1719
+ return rec && Date.now() - (Number(rec.at) || 0) < 600000 ? rec : null
1720
+ }
1721
+
1722
+ /** 从会话日志(磁盘)推导窗口与模型:不需要 agent,切换会话即可得到正确的模型名与窗口。 */
1723
+ async waterWindowForSession(sid) {
1724
+ const empty = { window: 0, source: '', model: '', provider: '', contextWindow: 0 }
1725
+ if (!sid) return empty
1726
+ if (!this._waterDeriveCache) this._waterDeriveCache = new Map()
1727
+ const cached = this._waterDeriveCache.get(String(sid))
1728
+ if (cached && Date.now() - cached.at < 300000) return cached.value
1729
+ let value = empty
1730
+ try {
1731
+ const dir = await this.locateSessionDir(String(sid))
1732
+ if (dir) {
1733
+ const raw = await readFile(path.join(dir, 'session.jsonl.zstd')).catch(() => null)
1734
+ const events = raw ? decodeZstdFrames(raw).split('\n').map((l) => { try { return JSON.parse(l) } catch (e) { return null } }).filter(Boolean) : []
1735
+ const info = findSessionModelPre(events)
1736
+ const wi = await this.resolveWaterWindow(info.provider, info.model)
1737
+ value = {
1738
+ window: info.contextWindow > 0 ? info.contextWindow : (Number(wi.window) || 0),
1739
+ source: info.contextWindow > 0 ? 'official-context' : (wi.source || ''),
1740
+ model: info.model || wi.model || '',
1741
+ provider: info.provider,
1742
+ contextWindow: info.contextWindow,
1743
+ }
1744
+ }
1745
+ } catch (e) {}
1746
+ this._waterDeriveCache.set(String(sid), { at: Date.now(), value })
1747
+ if (this._waterDeriveCache.size > 40) {
1748
+ const first = this._waterDeriveCache.keys().next()
1749
+ if (!first.done) this._waterDeriveCache.delete(first.value)
1750
+ }
1751
+ return value
1752
+ }
1753
+
1679
1754
  /** M-CM6-A·水位 v2:计量优先级=ctx.tokenMeter.measure(官方计量,与聊天框 context ring 同源,免疫重启/压缩/chunk 洪流)→ 启发式估算(降级);
1680
1755
  * 窗口优先级=waterLevelWindowTokens 手动覆盖 > 官方 request/context 容量 > settings.yaml auto/fallback。越阈值或检测到 compaction 事件→advisory+自动写抽取性骨架账本。 */
1681
1756
  async checkWaterLevel(agent) {
1682
1757
  try {
1683
1758
  if (this.config.handoffEnabled === false) return
1684
- const winInfo = await this.resolveWaterWindow()
1759
+ // 会话真实模型 + 官方容量:同一份事件扫描结果按会话缓存 5 分钟
1760
+ // (该事件只在请求头变化时追加,实测 2999 条事件里仅 2 条且位于会话开头,旧实现只扫最近 256 条 → 长会话永远扫不到)。
1761
+ let sessModel = { provider: '', model: '', contextWindow: 0 }
1762
+ try {
1763
+ const sid = agent && agent.session && agent.session.id ? String(agent.session.id) : ''
1764
+ const cached = this._officialWindowCache
1765
+ if (cached && sid && cached.sid === sid && Date.now() - cached.at < 300000 && cached.info) {
1766
+ sessModel = cached.info
1767
+ } else {
1768
+ sessModel = findSessionModelPre(sessionEventsOf(agent && agent.session))
1769
+ this._officialWindowCache = { sid, at: Date.now(), info: sessModel }
1770
+ }
1771
+ } catch (eWin) {}
1772
+ // 窗口解析按「会话真实模型」查表(2026-09-08):此前恒用 settings.yaml 的 agent-default-model,
1773
+ // 用户切过模型后卡片会标出另一个模型的名字(实测会话跑 deepseek-official/deepseek-v4.1-flash,却标 opencode-go/deepseek-v4-flash)。
1774
+ const winInfo = await this.resolveWaterWindow(sessModel.provider, sessModel.model)
1685
1775
  let win = Number(winInfo && winInfo.window) || 0
1686
1776
  let winSource = (winInfo && winInfo.source) || ''
1687
- if (win > 0 && !/^manual/.test(winSource)) {
1688
- // 官方容量:request/context 事件的 contextWindow(路由权威值,last-wins)。
1689
- // 2.2.4 修复:该事件只在请求头部变化时追加(实测 2999 条事件里仅 2 条,且位于会话开头),
1690
- // 旧实现只扫最近 256 条 → 长会话永远扫不到,窗口退化为 fallback 131072(水位虚高到 150%+)。
1691
- // 现在全量向前扫 + 按会话 id 缓存 5 分钟,避免每轮重复遍历。
1692
- try {
1693
- const sid = agent && agent.session && agent.session.id ? String(agent.session.id) : ''
1694
- const cached = this._officialWindowCache
1695
- let cw = 0
1696
- if (cached && sid && cached.sid === sid && Date.now() - cached.at < 300000) {
1697
- cw = cached.window
1698
- } else {
1699
- cw = findOfficialContextWindowPre(sessionEventsOf(agent && agent.session))
1700
- this._officialWindowCache = { sid, at: Date.now(), window: cw }
1701
- }
1702
- if (cw > 0) { win = cw; winSource = 'official-context' }
1703
- } catch (eWin) {}
1777
+ if (win > 0 && !/^manual/.test(winSource) && sessModel.contextWindow > 0) {
1778
+ // 官方容量:request/context 的 contextWindow(路由权威值,last-wins)
1779
+ win = sessModel.contextWindow
1780
+ winSource = 'official-context'
1704
1781
  }
1705
1782
  if (win <= 0) return
1706
1783
  const rt = this.runtimeFor(agent)
@@ -1733,7 +1810,9 @@ class MemoryEngine {
1733
1810
  }
1734
1811
  }
1735
1812
  } catch (e) {}
1736
- const ratio = Math.min(estTokens / win, 1.5)
1813
+ // 旧实现硬截断到 1.5 → 任何超额水位都显示成 150%,掩盖真实占用(用户实测 761692/131072 被显示为 150%)。
1814
+ // 现在如实上报,仅做防溢出的 99 倍上限。
1815
+ const ratio = win > 0 && Number.isFinite(estTokens) ? Math.min(estTokens / win, 99) : 0
1737
1816
  rt.waterLevel = ratio
1738
1817
  rt.waterLevelTokens = estTokens
1739
1818
  this.state.waterLevelRatio = ratio
@@ -1741,9 +1820,14 @@ class MemoryEngine {
1741
1820
  this.state.waterLevelWindow = win
1742
1821
  this.state.waterLevelSource = winSource
1743
1822
  this.state.waterLevelMeter = meterLayer
1744
- this.state.waterLevelModel = winInfo.model || ''
1823
+ this.state.waterLevelModel = sessModel.model || winInfo.model || ''
1745
1824
  this.state.waterLevelAt = Date.now()
1746
- const threshold = Math.max(Number(this.config.waterLevelThreshold) || 0.8, 0.1)
1825
+ const threshold = Math.max(Number(this.config.waterLevelThreshold) || 0.75, 0.1)
1826
+ // 会话级记录:切会话时 /handoff-state?sessionId= 直接取这份,不再显示别的会话的数
1827
+ this.rememberWaterRecord(agent && agent.session && agent.session.id ? String(agent.session.id) : '', {
1828
+ ratio, tokens: estTokens, window: win, source: winSource, meter: meterLayer,
1829
+ model: this.state.waterLevelModel || '', threshold, at: Date.now(), live: true,
1830
+ })
1747
1831
  const over = ratio >= threshold || (ratio >= 0.5 && compacted)
1748
1832
  if (!over) return
1749
1833
  if (!rt.waterLevelAdvised || compacted) {
@@ -1968,7 +2052,7 @@ class MemoryEngine {
1968
2052
  }
1969
2053
 
1970
2054
  /** M-CM1 白板面板数据:PLAN 当前版+归档版本列表+账本列表;fileQ 非空时返回指定文件文本(严格白名单,限 handoff 目录内)。 */
1971
- async handoffPanelData(fileQ) {
2055
+ async handoffPanelData(fileQ, sessionId) {
1972
2056
  if (this.config.handoffEnabled === false) return { enabled: false }
1973
2057
  const handoffDir = this.state.handoffDir || (await this.resolvePaths(undefined)).handoffDir
1974
2058
  const planPath = this.state.planPath || path.join(handoffDir, 'PLAN.md')
@@ -1994,13 +2078,43 @@ class MemoryEngine {
1994
2078
  if (st && st.isFile()) ledgers.push({ name: n, mtime: Number(st.mtimeMs) })
1995
2079
  }
1996
2080
  ledgers.sort((a, b) => b.mtime - a.mtime)
1997
- if (!this.state.waterLevelWindow && this.config.handoffEnabled !== false) {
1998
- try {
1999
- const wi = await this.resolveWaterWindow()
2000
- this.state.waterLevelWindow = Number(wi && wi.window) || 0
2001
- this.state.waterLevelSource = (wi && wi.source) || ''
2002
- this.state.waterLevelModel = (wi && wi.model) || ''
2003
- } catch (e) {}
2081
+ // 水位:优先返回「该会话」的记录(实测 → 磁盘推导);未指定 sessionId 时退回全局单值(兼容旧调用方)
2082
+ let water = null
2083
+ if (sessionId) {
2084
+ const rec = this.waterRecordFor(sessionId)
2085
+ if (rec) {
2086
+ water = {
2087
+ ratio: Number(rec.ratio) || 0, tokens: Number(rec.tokens) || 0, window: Number(rec.window) || 0,
2088
+ source: rec.source || '', meter: rec.meter || '', model: rec.model || '',
2089
+ threshold: Number(this.config.waterLevelThreshold) || 0.75, at: Number(rec.at) || 0, live: true,
2090
+ }
2091
+ } else {
2092
+ const wi = await this.waterWindowForSession(sessionId)
2093
+ water = {
2094
+ ratio: 0, tokens: 0, window: Number(wi.window) || 0, source: wi.source || '', meter: '',
2095
+ model: wi.model || '', threshold: Number(this.config.waterLevelThreshold) || 0.75, at: 0, live: false,
2096
+ }
2097
+ }
2098
+ } else {
2099
+ if (!this.state.waterLevelWindow && this.config.handoffEnabled !== false) {
2100
+ try {
2101
+ const wi = await this.resolveWaterWindow()
2102
+ this.state.waterLevelWindow = Number(wi && wi.window) || 0
2103
+ this.state.waterLevelSource = (wi && wi.source) || ''
2104
+ this.state.waterLevelModel = (wi && wi.model) || ''
2105
+ } catch (e) {}
2106
+ }
2107
+ water = {
2108
+ ratio: Number(this.state.waterLevelRatio) || 0,
2109
+ tokens: Number(this.state.waterLevelTokens) || 0,
2110
+ window: Number(this.state.waterLevelWindow) || 0,
2111
+ source: this.state.waterLevelSource || '',
2112
+ meter: this.state.waterLevelMeter || '',
2113
+ model: this.state.waterLevelModel || '',
2114
+ threshold: Number(this.config.waterLevelThreshold) || 0.75,
2115
+ at: Number(this.state.waterLevelAt) || 0,
2116
+ live: Number(this.state.waterLevelAt) > 0,
2117
+ }
2004
2118
  }
2005
2119
  const planMt = await stat(planPath).then((s) => Number(s.mtimeMs)).catch(() => 0)
2006
2120
  const sid = this.currentSessionId()
@@ -2014,16 +2128,7 @@ class MemoryEngine {
2014
2128
  refresh: this.config.autoContinueRefreshRitual === false || !sid
2015
2129
  ? { sessionId: '', prompt: '' }
2016
2130
  : { sessionId: sid, prompt: this.refreshRitualPrompt() },
2017
- waterLevel: {
2018
- ratio: Number(this.state.waterLevelRatio) || 0,
2019
- tokens: Number(this.state.waterLevelTokens) || 0,
2020
- window: Number(this.state.waterLevelWindow) || 0,
2021
- source: this.state.waterLevelSource || '',
2022
- meter: this.state.waterLevelMeter || '',
2023
- model: this.state.waterLevelModel || '',
2024
- threshold: Number(this.config.waterLevelThreshold) || 0.8,
2025
- at: Number(this.state.waterLevelAt) || 0,
2026
- },
2131
+ waterLevel: water,
2027
2132
  }
2028
2133
  }
2029
2134
 
@@ -2782,7 +2887,7 @@ class MemoryEngine {
2782
2887
  lines.push('\n[交接续命] 当前会话未绑定工作区(项目记忆不可用)。全局最近交接账本: ' + s.globalLedgerPath + ' —— 需要续接上次任务时,直接读取该文件恢复上下文;工作区绑定后白板/账本即恢复正常注入。')
2783
2888
  }
2784
2889
  // M-CM4/M-CM5 水位建议:越阈注入交接+派子代理建议(advisory;10 分钟新鲜度;无人值守静默)
2785
- if (cfg.waterLevelAdvisory !== false && !this.isUnattendedNow() && (s.waterLevelRatio || 0) >= (Number(cfg.waterLevelThreshold) || 0.8) && Date.now() - (s.waterLevelAt || 0) < 600000) {
2890
+ if (cfg.waterLevelAdvisory !== false && !this.isUnattendedNow() && (s.waterLevelRatio || 0) >= (Number(cfg.waterLevelThreshold) || 0.75) && Date.now() - (s.waterLevelAt || 0) < 600000) {
2786
2891
  lines.push('\n' + L('snapshotWaterTitle') + '\n' + L('snapshotWaterBody', { pct: Math.round((s.waterLevelRatio || 0) * 100) }))
2787
2892
  }
2788
2893
  let used = 0
@@ -6243,7 +6348,7 @@ export function apply(ctx, config) {
6243
6348
  if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
6244
6349
  try {
6245
6350
  const url = new URL(req.url, 'http://127.0.0.1')
6246
- return writeJson(res, 200, await engine.handoffPanelData(url.searchParams.get('file')))
6351
+ return writeJson(res, 200, await engine.handoffPanelData(url.searchParams.get('file'), url.searchParams.get('sessionId')))
6247
6352
  } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
6248
6353
  },
6249
6354
  },
@@ -67,6 +67,38 @@ export function pickWindowPre(windows, provider, model) {
67
67
  return 0
68
68
  }
69
69
 
70
+ /**
71
+ * 从会话事件里取最近一条 request/header|request/context 的真实 provider/model(+ 顺带 contextWindow)。
72
+ *
73
+ * 背景(2026-09-08 用户实测):水位卡此前把「窗口来源」标成 settings.yaml 的 agent-default-model(新会话默认模型),
74
+ * 用户在 GUI 里切过默认模型后,当前会话实际跑的模型与卡片显示不符
75
+ * (实测:会话日志 request/header = deepseek-official/deepseek-v4.1-flash-expires-on-0910,卡片却写 opencode-go/deepseek-v4-flash)。
76
+ * 会话自己的请求头才是「这个会话正在用哪个模型」的权威来源;默认模型只作兜底。
77
+ *
78
+ * @param {object[]} events - 会话事件数组。
79
+ * @param {number} maxScan - 最多向前扫多少条(默认全部)。
80
+ * @returns {{provider:string,model:string,contextWindow:number}} 未找到时字段为空/0。
81
+ */
82
+ export function findSessionModelPre(events, maxScan = 0) {
83
+ const out = { provider: '', model: '', contextWindow: 0 }
84
+ if (!Array.isArray(events) || !events.length) return out
85
+ const floor = maxScan > 0 ? Math.max(0, events.length - maxScan) : 0
86
+ for (let i = events.length - 1; i >= floor; i--) {
87
+ const ev = events[i]
88
+ const t = ev && ev.type
89
+ if (t !== 'request/header' && t !== 'request/context') continue
90
+ const d = (ev && ev.data) || {}
91
+ const cfg = (d.header && d.header.config) || d.config || {}
92
+ const provider = String(cfg.provider || d.provider || '')
93
+ const model = String(cfg.model || d.model || '')
94
+ const cw = Number(d.contextWindow) || 0
95
+ if (!out.model && model) { out.model = model; out.provider = provider }
96
+ if (!out.contextWindow && cw > 0) out.contextWindow = cw
97
+ if (out.model && out.contextWindow) break
98
+ }
99
+ return out
100
+ }
101
+
70
102
  /**
71
103
  * 从会话事件里取最近一条 request/context 的 contextWindow(路由权威值)。
72
104
  * 不限制扫描窗口:该事件只在请求头部变化时追加,长会话里往往只存在于开头。
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.2.4",
4
+ "version": "2.2.5",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {