@a9i5k4/dsh-auto-memory 2.2.4 → 2.2.6

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,28 @@ window.__ModuleLoader__.load({
542
561
 
543
562
  // ───────────────────────── 更新弹窗 / 首次指导 ─────────────────────────
544
563
  var CHANGELOG = {
564
+ '2.2.6': { zh: [
565
+ '★ 修复:交接测量终于站到官方压缩的同一条边界上——官方自动压缩挂在每个 step 边界(pre-step,阈值 80%),而插件此前只在「一轮结束」测量;某一轮把水位从阈值下推到 80% 以上时,官方会在该轮就压缩完,交接白板/账本来不及写。现在 pre-step 也做同一测量(同一会话 5 秒节流),水位一到阈值就先写交接材料,不再被官方抢跑。',
566
+ '★ 自动接续改为宿主兜底:建新会话不再依赖浏览器页面——水位达标后在宿主侧开倒计时,确认卡照常弹出(同意/拒绝都直接发给宿主);页面被后台节流、标签页关闭或人不在时,倒计时一到宿主自己完成「刷新白板/账本 → 建新会话 → 沿用模型与工作区 → 注入交接材料」,不再因浏览器休眠而断档。',
567
+ ], en: [
568
+ '★ Fix: handoff measurement now runs on the same boundary as official compaction — the official auto-compaction hook fires at every step boundary (pre-step, 80% threshold) while the plugin only measured at turn end; a single turn that pushes the level past 80% let the official compaction finish before the handoff ledger was written. The same measurement now also runs on pre-step (throttled to once per 5s per session), so the handoff material is written as soon as the threshold is crossed.',
569
+ '★ Auto-continue is now host-side: creating the new session no longer depends on the browser tab. Once the water level crosses the threshold the host starts a countdown; the confirm card still appears (Agree/Decline are sent straight to the host), and if the tab is throttled, closed, or nobody is at the keyboard, the host itself completes "refresh PLAN/ledger → create session → restore model & workspace → inject handoff material" when the countdown ends. No more missed handoffs from a sleeping browser.',
570
+ ] },
571
+ '2.2.5': { zh: [
572
+ '★ 修复:上下文水位百分比不再被硬截断到 150%——旧版 `Math.min(tokens / window, 1.5)` 让任何超额都显示成同一个数(例如 761,692 / 131,072 真实为 581%)。现在如实上报,进度条仍按 100% 封顶。',
573
+ '★ 修复:水位卡标出的模型现在是「当前会话真实使用的模型」——此前一律读 settings.yaml 的 agent-default-model(新会话默认模型),你在界面切过模型后,卡片会张冠李戴地标成另一个模型(实测会话跑 deepseek-official/deepseek-v4.1-flash,却标成 opencode-go/deepseek-v4-flash)。现改为优先取会话日志 request/header 的 provider/model,默认模型只作兜底。',
574
+ '★ 修复:水位卡改成按会话显示——此前是全机单值、只在有对话轮次时更新,切到别的会话仍显示上一个会话的数(或重启后的 0),模型名也只能拿默认模型凑。现在按 sessionId 取该会话的实测值;没有实测就现场从该会话日志推导窗口与模型,并明确标注「本会话尚未测量」。',
575
+ '★ 版本号同步:面板徽标与「检测更新」读的是开发树的 package.json,长期停在 0.1.30(而 npm 已是 2.2.x),于是界面显示旧版本、更新检查永远报「有新版本」。现在发布脚本会把版本号回写开发树,两条线永远一致。',
576
+ '★ 新增悬浮钉:记忆面板标题栏加了一个图钉按钮(线描图标,与 ⟳ ⤾ ✕ 同画风),点一下钉住——之后再点桌面或其他地方,面板不会自动收起(再点一下取消)。钉住状态会记住。',
577
+ '★ 交接阈值下调到 75%:官方自动压缩阈值是 80%,阈值贴着 80% 常常还没走完交接就被官方压缩掉;水位建议阈值与自动接续阈值默认都改为 0.75,留 5% 余量(1M 窗口约 50K token)。已存在的 0.8 配置不会被自动改写,可在设置页手动改。',
578
+ ], en: [
579
+ '★ 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%.',
580
+ '★ 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.',
581
+ '★ 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".',
582
+ '★ 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.',
583
+ '★ 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.',
584
+ '★ 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.',
585
+ ] },
545
586
  '2.2.4': { zh: [
546
587
  '★ 修复:一键接续的新会话终于落进旧会话所属工作区——官方 session.create 只有传 workspaceId 才会绑定工作区,只传 cwd 仅设工作目录(故此前恒落「未分组」);现 host 先解析旧会话的 workspaceId 再建会话,解析不到才回退 cwd。',
547
588
  '★ 修复:新会话沿用旧会话的模型与思考档位——模型信息在会话日志的 request/header.header.config(旧版查错了事件类型,恒取空 → 回落默认模型)。',
@@ -820,6 +861,8 @@ window.__ModuleLoader__.load({
820
861
  pyStatus: '/api/dsh-auto-memory/python-setup/status',
821
862
  handoffState: '/api/dsh-auto-memory/handoff-state',
822
863
  handoffContinue: '/api/dsh-auto-memory/handoff-continue',
864
+ autoContState: '/api/dsh-auto-memory/auto-continue-state',
865
+ autoContDecide: '/api/dsh-auto-memory/auto-continue-decide',
823
866
  semanticDownload: '/api/dsh-auto-memory/semantic-download',
824
867
  }
825
868
  function query(params) {
@@ -876,6 +919,7 @@ window.__ModuleLoader__.load({
876
919
  '[data-dam-resize]:hover { opacity: 1; }',
877
920
  '[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
921
  '[data-dam-btn]:hover { opacity: 1; background: color-mix(in srgb, var(--dsw-alias-label-secondary, #666) 16%, transparent); }',
922
+ '[data-dam-btn][data-pin="on"] { opacity: 1; background: color-mix(in srgb, var(--dam-accent, #4f7cff) 24%, transparent); }',
879
923
  '[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
924
  '[data-dam-tabs] { flex: 1; min-width: 0; overflow: hidden; padding: 5px 4px; scrollbar-width: none; -ms-overflow-style: none; }',
881
925
  '[data-dam-tabs]::-webkit-scrollbar { display: none; }',
@@ -1178,6 +1222,22 @@ window.__ModuleLoader__.load({
1178
1222
  var label = props && props.label ? props.label : t('loading')
1179
1223
  return h('div', { 'data-dam-loading': '' }, h('span', { 'data-dam-spinner': '', 'aria-hidden': 'true' }), h('span', null, label))
1180
1224
  }
1225
+ /** 线描图钉图标(2026-09-08):与标题栏 ⤾ ⟳ ✕ 同一画风——单色 currentColor 描边、无彩色 emoji;
1226
+ * 未钉住=描边空心,已钉住=填充(内孔用 evenodd 挖空)。 */
1227
+ function PinIcon(props) {
1228
+ var filled = !!(props && props.filled)
1229
+ return h('svg', {
1230
+ width: 13, height: 13, viewBox: '0 0 16 16', 'aria-hidden': 'true',
1231
+ style: { display: 'block', margin: '0 auto', pointerEvents: 'none' },
1232
+ }, h('path', {
1233
+ 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',
1234
+ fill: filled ? 'currentColor' : 'none',
1235
+ fillRule: 'evenodd',
1236
+ stroke: 'currentColor',
1237
+ strokeWidth: '1.1',
1238
+ strokeLinejoin: 'round',
1239
+ }))
1240
+ }
1181
1241
  function AnimatedDisclosure(props) {
1182
1242
  var open = !!props.open
1183
1243
  var shownPair = useState(open)
@@ -2012,6 +2072,10 @@ window.__ModuleLoader__.load({
2012
2072
  var dataPair = useState(null)
2013
2073
  var data = dataPair[0]
2014
2074
  var setData = dataPair[1]
2075
+ // 当前会话 id:水位卡按会话取数(切换会话即刷新,2026-09-08)
2076
+ var sidPair = useState(function () { return currentSessionIdClient() })
2077
+ var sid = sidPair[0]
2078
+ var setSid = sidPair[1]
2015
2079
  var openPair = useState(null)
2016
2080
  var open = openPair[0]
2017
2081
  var setOpen = openPair[1]
@@ -2031,7 +2095,7 @@ window.__ModuleLoader__.load({
2031
2095
  var setAutoCfg = autoPair[1]
2032
2096
  useEffect(function () {
2033
2097
  apiGet(API.config).then(function (c) {
2034
- setAutoCfg({ enabled: !(c && c.autoContinueEnabled === false), threshold: (c && Number(c.autoContinueThreshold)) || 0.8 })
2098
+ setAutoCfg({ enabled: !(c && c.autoContinueEnabled === false), threshold: (c && Number(c.autoContinueThreshold)) || 0.75 })
2035
2099
  }).catch(function () { setAutoCfg({ enabled: false, threshold: 0.7 }) })
2036
2100
  }, [])
2037
2101
  var autoSave = function (patch) {
@@ -2051,8 +2115,22 @@ window.__ModuleLoader__.load({
2051
2115
  }
2052
2116
  useEffect(function () {
2053
2117
  var alive = true
2054
- apiGet(API.handoffState).then(function (d) { if (alive) setData(d) }).catch(function () {})
2118
+ setData(null)
2119
+ apiGet(API.handoffState, { sessionId: sid }).then(function (d) { if (alive) setData(d) }).catch(function () {})
2055
2120
  return function () { alive = false }
2121
+ }, [sid])
2122
+ // 切换会话即刷新水位卡:订阅官方会话列表快照,current 变化时重取(2026-09-08)
2123
+ useEffect(function () {
2124
+ var unsub = null
2125
+ try {
2126
+ if (sessions && sessions.list && typeof sessions.list.subscribe === 'function') {
2127
+ unsub = sessions.list.subscribe(function () {
2128
+ var now = currentSessionIdClient()
2129
+ setSid(function (prev) { return prev === now ? prev : now })
2130
+ })
2131
+ }
2132
+ } catch (eSub) {}
2133
+ return function () { if (typeof unsub === 'function') unsub() }
2056
2134
  }, [])
2057
2135
  useEffect(function () {
2058
2136
  if (!open) return
@@ -2087,9 +2165,11 @@ window.__ModuleLoader__.load({
2087
2165
  (wl.tokens || 0).toLocaleString() + ' / ' + wl.window.toLocaleString() + ' token · ' + pct + '%',
2088
2166
  ' ', h('span', { 'data-dam-hint': '' }, srcLabel),
2089
2167
  ' ', h('span', { 'data-dam-hint': '' }, wl.meter ? (wl.meter.indexOf('official') === 0 ? '· ' + t('waterMeterOfficial') + '(' + String(wl.meter).slice(9) + ')' : '· ' + t('waterMeterHeuristic')) : '')),
2168
+ // 本会话尚未测量(刚重启/刚切过来还没发消息):明说,而不是显示成 0 让人以为上下文是空的
2169
+ wl.at ? null : h('div', { 'data-dam-hint': '', style: { marginBottom: '4px' } }, t('waterNotMeasured')),
2090
2170
  h('div', { style: { height: '6px', borderRadius: '3px', background: 'color-mix(in srgb, currentColor 12%, transparent)', overflow: 'hidden' } },
2091
2171
  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))),
2172
+ h('div', { 'data-dam-hint': '', style: { marginTop: '4px' } }, t('waterThresholdHint').replace('{t}', String(wl.threshold || 0.75))),
2093
2173
  ])))
2094
2174
  }
2095
2175
  if (autoCfg && wl.window > 0) {
@@ -2812,6 +2892,14 @@ window.__ModuleLoader__.load({
2812
2892
  h('strong', null, t('autoMemory')),
2813
2893
  h('span', { 'data-dam-hint': '', style: { fontSize: '10px', opacity: .55 } }, ver ? 'v' + ver + '-ui' : ''),
2814
2894
  h('span', { className: 'dam-spacer' }),
2895
+ // 悬浮钉(2026-09-08):钉住后点面板外不再自动收起;图标为线描 SVG,与 ⤾ ⟳ ✕ 同画风
2896
+ h('button', {
2897
+ 'data-dam-btn': '', 'data-pin': pinned ? 'on' : 'off',
2898
+ title: pinned ? t('unpinPanel') : t('pinPanel'),
2899
+ 'aria-pressed': pinned ? 'true' : 'false',
2900
+ style: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: '4px 6px' },
2901
+ onClick: function () { controller.togglePin() },
2902
+ }, h(PinIcon, { filled: pinned })),
2815
2903
  h('button', { 'data-dam-btn': '', title: t('resetPos'), onClick: function () { controller.resetGeom() } }, '⤾'),
2816
2904
  h('button', { 'data-dam-btn': '', title: t('refresh'), onClick: function () { setNonce(nonce + 1) } }, '⟳'),
2817
2905
  h('button', { 'data-dam-btn': '', title: t('close'), onClick: function () { controller.close() } }, '✕')),
@@ -3410,114 +3498,78 @@ window.__ModuleLoader__.load({
3410
3498
  var cfP = useState(null)
3411
3499
  var acConfirm = cfP[0]
3412
3500
  var setAcConfirm = cfP[1]
3501
+ // 2.2.6:宿主兜底状态(倒计时/执行中/结果全部由宿主权威判定;浏览器只展示与传达决定)
3502
+ var asP = useState(null)
3503
+ var acState = asP[0]
3504
+ var setAcState = asP[1]
3413
3505
  useEffect(function () {
3414
3506
  var alive = true
3415
3507
  var load = function () {
3416
3508
  apiGet(API.config).then(function (c) { if (alive) setAcCfg(c || {}) }).catch(function () {})
3509
+ apiGet(API.autoContState).then(function (s) { if (alive && s) setAcState(s) }).catch(function () {})
3417
3510
  }
3418
3511
  load()
3419
- var iv = setInterval(load, 30000)
3512
+ var iv = setInterval(load, 3000)
3420
3513
  return function () { alive = false; clearInterval(iv) }
3421
3514
  }, [])
3422
3515
  useEffect(function () {
3516
+ var alive = true
3423
3517
  var on = !(acCfg && acCfg.autoContinueEnabled === false)
3424
3518
  if (!on) return
3425
- var thr = (acCfg && Number(acCfg.autoContinueThreshold)) || 0.8
3426
- var confirmSec = Number(acCfg && acCfg.autoContinueConfirmSeconds)
3427
- if (!(confirmSec >= 10 && confirmSec <= 120)) confirmSec = 35
3428
- var cooldownMin = Number(acCfg && acCfg.autoContinueCooldownMinutes)
3429
- if (!(cooldownMin > 0)) cooldownMin = 30
3430
- var alive = true
3431
- var maybePrompt = function (edgeAt) {
3432
- if (!alive || acBusy || autoState.cdIv || autoState.promptVisible) return
3433
- if (!edgeAt || Date.now() - edgeAt > 10 * 60 * 1000) return // 边界太旧不再提示
3434
- if (Date.now() < autoState.suppressUntil) return
3435
- if (autoState.rejectedEdgeAt === edgeAt) return // ②拒绝过同一边界
3436
- apiGet(API.handoffState).then(function (d) {
3437
- if (!alive || !d || !d.enabled) return
3438
- var wl = d.waterLevel || {}
3439
- if (!wl.window || !(wl.ratio >= thr)) return
3440
- var last = 0
3441
- try { last = Number(localStorage.getItem('dsh-auto-memory.autoCont.last') || 0) } catch (eL) {}
3442
- if (Date.now() - last < cooldownMin * 60 * 1000) return
3443
- if (autoState.promptVisible || autoState.cdIv) return
3444
- setAcConfirm({ ratio: Number(wl.ratio) || 0, tokens: Number(wl.tokens) || 0, window: Number(wl.window) || 0, edgeAt: edgeAt })
3445
- setAcSt('')
3446
- autoState.promptVisible = true
3447
- var left = confirmSec
3448
- setAcCd(left)
3449
- autoState.cdIv = setInterval(function () {
3450
- left -= 1
3451
- if (left <= 0) {
3452
- clearInterval(autoState.cdIv); autoState.cdIv = null; setAcCd(0)
3453
- autoState.promptVisible = false; setAcConfirm(null)
3454
- runAuto() // ③超时 = 挂机 → 自动接续兜底
3455
- } else setAcCd(left)
3456
- }, 1000)
3457
- }).catch(function () {})
3458
- }
3459
- // 轮次边界:running true→false 边沿 + 3s 短确认
3460
- var verifyEdge = function (edgeAt) {
3461
- if (!alive || autoState.edgeAt !== edgeAt) return
3462
- var info = currentRunningInfo()
3463
- if (info && info.running) return // 短确认失败:新一轮已开始,不算边界
3464
- maybePrompt(edgeAt)
3465
- }
3466
- var armEdge = function () {
3467
- autoState.edgeAt = Date.now()
3468
- var info = currentRunningInfo()
3469
- autoState.edgeSession = info ? info.sessionId : ''
3470
- var edgeAt = autoState.edgeAt
3471
- setTimeout(function () { verifyEdge(edgeAt) }, 3000)
3472
- }
3473
- var observe = function () {
3519
+ var thr = (acCfg && Number(acCfg.autoContinueThreshold)) || 0.75
3520
+ // 渲染:armed 倒计时 → 确认卡;executing → 执行中;lastOk → 成功;error → 失败提示。
3521
+ // 触发/倒计时/超时执行全部由宿主侧驱动(2.2.6),浏览器即使被节流也不影响接续。
3522
+ var poll = function () {
3474
3523
  if (!alive) return
3475
- var info = currentRunningInfo()
3476
- if (!info) return
3477
- if (autoState.lastRunning === null) {
3478
- autoState.lastRunning = info.running
3479
- if (!info.running) armEdge() // 首次观察即空闲(挂机/刚进会话):按边界处理
3480
- return
3481
- }
3482
- if (autoState.lastRunning === true && info.running === false) armEdge()
3483
- autoState.lastRunning = info.running
3484
- }
3485
- var unsub = null
3486
- try { if (sessions && sessions.list && typeof sessions.list.subscribe === 'function') unsub = sessions.list.subscribe(observe) } catch (eSub) {}
3487
- observe()
3488
- var iv = setInterval(function () { observe(); maybePrompt(autoState.edgeAt) }, 20000) // 兜底:水位后到 / 订阅不可用
3489
- return function () {
3490
- alive = false
3491
- clearInterval(iv)
3492
- if (unsub) { try { unsub() } catch (eU) {} }
3524
+ apiGet(API.autoContState).then(function (s) {
3525
+ if (!alive || !s) return
3526
+ setAcState(s)
3527
+ if (s.executing) {
3528
+ setAcSt(locale === 'zh' ? '宿主正在自动接续:刷新白板/账本 → 建新会话 → 注入材料…' : 'Host auto-continuing: refresh PLAN/ledger → new session → inject…')
3529
+ return
3530
+ }
3531
+ if (s.lastOk) {
3532
+ setAcSt((locale === 'zh' ? '✓ 已自动接续到新会话,可直接上手' : '✓ Auto-continued into a new session') + (s.lastOk.model ? ' · ' + s.lastOk.model + (s.lastOk.reasoningEffort ? '/' + s.lastOk.reasoningEffort : '') : ''))
3533
+ return
3534
+ }
3535
+ if (s.error) { setAcSt('✗ ' + s.error); return }
3536
+ var arm = s.armed
3537
+ if (arm && arm.ratio >= thr) {
3538
+ setAcConfirm({ ratio: Number(arm.ratio) || 0, tokens: Number(arm.tokens) || 0, window: Number(arm.window) || 0, edgeAt: Number(arm.edgeAt) || 0 })
3539
+ setAcCd(Math.max(0, Math.ceil((Number(arm.expiresAt) || 0) - Date.now()) / 1000))
3540
+ } else {
3541
+ setAcConfirm(null)
3542
+ setAcCd(0)
3543
+ if (acSt && /^✗/.test(acSt)) setAcSt('')
3544
+ }
3545
+ }).catch(function () {})
3493
3546
  }
3494
- }, [acCfg && acCfg.autoContinueEnabled === false, acCfg && (Number(acCfg.autoContinueThreshold) || 0.8), acCfg && acCfg.autoContinueConfirmSeconds, acCfg && acCfg.autoContinueCooldownMinutes, acBusy])
3547
+ poll()
3548
+ var iv = setInterval(poll, 3000)
3549
+ return function () { alive = false; clearInterval(iv) }
3550
+ }, [acCfg && acCfg.autoContinueEnabled === false, acCfg && (Number(acCfg.autoContinueThreshold) || 0.75)])
3495
3551
  var cancelAc = function () {
3496
- if (autoState.cdIv) { clearInterval(autoState.cdIv); autoState.cdIv = null }
3497
- autoState.promptVisible = false
3498
3552
  setAcCd(0)
3499
3553
  }
3554
+ // 2.2.6:执行权在宿主。同意/拒绝都 POST 给宿主(浏览器只传达决定);执行/超时全部宿主侧完成。
3500
3555
  var runAuto = function () {
3501
- if (acBusy) return
3502
- cancelAc()
3503
- setAcConfirm(null)
3504
- if (!remoteFace || !remoteFace.session || typeof remoteFace.session.create !== 'function') {
3505
- setAcSt(locale === 'zh' ? '✗ remote.session 不可用——请重启 dsh web 后重试' : '✗ remote.session unavailable — restart dsh web')
3506
- return
3507
- }
3508
- setAcBusy(true)
3509
- setAcSt(locale === 'zh' ? '正在自动接续:刷新白板/账本 → 建新会话 → 注入材料…' : 'Auto-continuing: refresh PLAN/ledger → new session → inject…')
3510
- runContinueFlow({ onProgress: function (m) { setAcSt(m) } }).then(function (r) {
3511
- setAcSt((locale === 'zh' ? '✓ 已自动接续到新会话,可直接上手' : '✓ Auto-continued into a new session') + (r && r.model ? ' · ' + r.model + (r.reasoningEffort ? '/' + r.reasoningEffort : '') : ''))
3512
- setAcBusy(false)
3513
- }).catch(function (e) { setAcSt('✗ ' + String(e && e.message ? e.message : e)); setAcBusy(false) })
3556
+ var edgeAt = (acConfirm && acConfirm.edgeAt) || 0
3557
+ apiPost(API.autoContDecide, { action: 'agree', edgeAt: edgeAt }).then(function (r) {
3558
+ if (r && r.ok) {
3559
+ setAcConfirm(null)
3560
+ setAcCd(0)
3561
+ setAcSt(locale === 'zh' ? '宿主正在自动接续:刷新白板/账本 → 建新会话 → 注入材料…' : 'Host auto-continuing: refresh PLAN/ledger → new session → inject…')
3562
+ } else {
3563
+ setAcSt('✗ ' + String((r && r.error) || 'auto-continue agree failed'))
3564
+ }
3565
+ }).catch(function (e) { setAcSt('✗ ' + String(e && e.message ? e.message : e)) })
3514
3566
  }
3515
3567
  var onAgree = function () { runAuto() }
3516
3568
  var onReject = function () {
3517
- var edgeAt = (acConfirm && acConfirm.edgeAt) || autoState.edgeAt || 0
3569
+ var edgeAt = (acConfirm && acConfirm.edgeAt) || 0
3518
3570
  cancelAc()
3519
3571
  setAcConfirm(null)
3520
- autoState.rejectedEdgeAt = edgeAt
3572
+ apiPost(API.autoContDecide, { action: 'reject', edgeAt: edgeAt }).then(function () {}).catch(function () {})
3521
3573
  setAcSt(t('autoContRejected'))
3522
3574
  }
3523
3575
  if (acConfirm || acCd > 0 || acSt) {
@@ -3530,7 +3582,7 @@ window.__ModuleLoader__.load({
3530
3582
  h('button', { 'data-dam-btn': '', style: { marginLeft: '6px' }, onClick: onReject }, t('autoContReject')),
3531
3583
  acCd > 0 ? h('span', { 'data-dam-hint': '', style: { marginLeft: '8px' } }, t('autoContTimeout').replace('{s}', String(acCd))) : null),
3532
3584
  ]) : null,
3533
- !acConfirm && acCd > 0 ? h('div', null, t('autoContCountdown').replace('{s}', String(acCd)), ' ', h('button', { style: { marginLeft: '6px', border: '1px solid rgba(232,234,237,.45)', borderRadius: '8px', background: 'transparent', color: '#e8eaed', cursor: 'pointer', padding: '2px 10px' }, onClick: cancelAc }, t('autoContCancel'))) : null,
3585
+ !acConfirm && acCd > 0 ? h('div', null, t('autoContCountdown').replace('{s}', String(acCd)), ' ', h('button', { style: { marginLeft: '6px', border: '1px solid rgba(232,234,237,.45)', borderRadius: '8px', background: 'transparent', color: '#e8eaed', cursor: 'pointer', padding: '2px 10px' }, onClick: onReject }, t('autoContCancel'))) : null,
3534
3586
  acSt ? h('div', { style: (acConfirm || acCd > 0) ? { marginTop: '4px' } : {} }, acSt) : null)
3535
3587
  }
3536
3588
  return null
@@ -4060,7 +4112,7 @@ window.__ModuleLoader__.load({
4060
4112
  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
4113
  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
4114
  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')),
4115
+ 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
4116
  field(t('fWaterAdvisory'), h('input', { type: 'checkbox', checked: cfg.waterLevelAdvisory !== false, onChange: function (e) { set('waterLevelAdvisory', e.target.checked) } }), t('fWaterAdvisoryHint')),
4065
4117
  field(t('fWaterAuto'), h('input', { type: 'checkbox', checked: cfg.waterLevelAutoHandoff !== false, onChange: function (e) { set('waterLevelAutoHandoff', e.target.checked) } }), t('fWaterAutoHint')),
4066
4118
  field(locale === 'zh' ? '子代理痕迹回收' : 'Subagent trace recycle', h('input', { type: 'checkbox', checked: cfg.subagentGcEnabled !== false, onChange: function (e) { set('subagentGcEnabled', e.target.checked) } }),
@@ -4105,6 +4157,7 @@ window.__ModuleLoader__.load({
4105
4157
  try {
4106
4158
  var onDocPointerDown = function (e) {
4107
4159
  if (!panelOpen || panelClosing) return
4160
+ if (controller.isPinned()) return // 悬浮钉:钉住后点面板外不收起(2026-09-08)
4108
4161
  var el = e.target
4109
4162
  if (el && el.closest && (el.closest('[data-dam-panel]') || el.closest('[data-dam-sidebar-btn]'))) return
4110
4163
  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'
@@ -161,6 +161,8 @@ export const API = {
161
161
  'semantic-deep-detect': '/api/dsh-auto-memory/semantic-deep-detect',
162
162
  'handoff-state': '/api/dsh-auto-memory/handoff-state',
163
163
  'handoff-continue': '/api/dsh-auto-memory/handoff-continue',
164
+ 'auto-continue-state': '/api/dsh-auto-memory/auto-continue-state',
165
+ 'auto-continue-decide': '/api/dsh-auto-memory/auto-continue-decide',
164
166
  'subagent-gc': '/api/dsh-auto-memory/subagent-gc',
165
167
  'semantic-download': '/api/dsh-auto-memory/semantic-download',
166
168
  'semantic-emit': '/api/dsh-auto-memory/semantic-emit',
@@ -221,16 +223,18 @@ const DEFAULT_CONFIG = {
221
223
  handoffLedgerChars: 800,
222
224
  /** M-CM4 水位感知:窗口 token 数。0=自动——从 settings.yaml 的 agent-default-model 解析对应模型的 contextWindow(失败回退 131072);手动设值即覆盖自动检测。 */
223
225
  waterLevelWindowTokens: 0,
224
- /** 水位建议阈值(0.1-1.5):ratio 越阈值时注入交接建议。 */
225
- waterLevelThreshold: 0.8,
226
+ /** 水位建议阈值(0.1-1.5):ratio 越阈值时注入交接建议。
227
+ * 2026-09-08 由 0.8 下调到 0.75:harness 官方自动压缩阈值是 80%,阈值贴着 80% 会在交接动作完成前先被官方压缩掉,
228
+ * 留 5% 余量(约 1M 窗口的 50K token)才来得及走完「写账本 → 建新会话 → 注入材料」。 */
229
+ waterLevelThreshold: 0.75,
226
230
  /** 水位建议注入(无人值守时始终静默)。 */
227
231
  waterLevelAdvisory: true,
228
232
  /** 水位越阈时自动写一篇系统骨架账本(每会话一次;模型仍应自己写正式交接)。 */
229
233
  waterLevelAutoHandoff: true,
230
234
  /** 自动接续(默认开):水位≥阈值 且 harness 权威 running==false(轮次边界,取代旧"两轮水位持平")时弹确认卡。 */
231
235
  autoContinueEnabled: true,
232
- /** 自动接续水位阈值(0.5-0.95)。 */
233
- autoContinueThreshold: 0.8,
236
+ /** 自动接续水位阈值(0.5-0.95);2026-09-08 与 waterLevelThreshold 同步下调到 0.75(官方自动压缩阈值 80%,必须留余量)。 */
237
+ autoContinueThreshold: 0.75,
234
238
  /** 确认卡倒计时秒数(30-40s 无操作 = 挂机 → 自动接续兜底)。 */
235
239
  autoContinueConfirmSeconds: 35,
236
240
  /** 接续前刷新仪式(默认开):先让旧 Agent 刷新 PLAN.md + 交接账本,host 再用最新材料组装交接。 */
@@ -1643,16 +1647,20 @@ class MemoryEngine {
1643
1647
  }
1644
1648
 
1645
1649
  /** M-CM4·自动窗口:agent-default-model(provider+model)→ settings.yaml 对应模型的 contextWindow;失败回退保守值。60s 缓存。 */
1646
- async resolveWaterWindow() {
1650
+ /** M-CM4·自动窗口:会话真实模型(request/header 的 provider/model,优先)→ agent-default-model(兜底)→ settings.yaml 对应模型的 contextWindow;失败回退保守值。60s 缓存。 */
1651
+ async resolveWaterWindow(providerOverride = '', modelOverride = '') {
1647
1652
  try {
1648
1653
  const override = Number(this.config.waterLevelWindowTokens) || 0
1649
1654
  if (override > 0) return { window: override, source: 'manual' }
1650
1655
  const now = Date.now()
1651
- if (this._waterWindowCache && now - this._waterWindowCache.at < 60000) return this._waterWindowCache.value
1656
+ const cacheKey = String(providerOverride || '') + '/' + String(modelOverride || '')
1657
+ if (this._waterWindowCache && now - this._waterWindowCache.at < 60000 && this._waterWindowCache.key === cacheKey) return this._waterWindowCache.value
1652
1658
  const text = await this.readTextSafe(path.join(dshHome(), 'settings.yaml'))
1653
1659
  let value = 0
1654
- let model = ''
1655
- let provider = ''
1660
+ let model = String(modelOverride || '')
1661
+ let provider = String(providerOverride || '')
1662
+ let defModel = ''
1663
+ let defProvider = ''
1656
1664
  if (text) {
1657
1665
  const lines = text.split(/\r?\n/)
1658
1666
  const ai = lines.findIndex((l) => /^agent-default-model:/.test(l))
@@ -1660,47 +1668,118 @@ class MemoryEngine {
1660
1668
  for (let i = ai + 1; i < Math.min(lines.length, ai + 6); i++) {
1661
1669
  const m = lines[i].match(/^ {2}(provider|model): *(.+?) *$/)
1662
1670
  if (!m) break
1663
- if (m[1] === 'provider') provider = m[2].trim()
1664
- if (m[1] === 'model') { model = m[2].trim(); break }
1671
+ if (m[1] === 'provider') defProvider = m[2].trim()
1672
+ if (m[1] === 'model') { defModel = m[2].trim(); break }
1665
1673
  }
1666
1674
  }
1667
1675
  // 2.2.4 修复:settings.yaml 的 llm-deepseek 段是 flow 风格 YAML(`{ models: [ { id: x, contextWindow: 1000000, … } ] }`),
1668
1676
  // 旧解析器只认 block 风格 → 整段解析失败、窗口退化为 fallback。改为纯函数解析(block + flow 双支持)。
1669
- if (model) value = pickWindowPre(parseModelWindowsPre(text), provider, model)
1677
+ const windows = parseModelWindowsPre(text)
1678
+ // 2026-09-08:优先按「会话自己的模型」查窗口——用户在 GUI 切过模型后,agent-default-model 与新会话默认值
1679
+ // 并不等于当前会话实际模型(实测会话跑 deepseek-official/deepseek-v4.1-flash,默认模型却是 opencode-go/deepseek-v4-flash)。
1680
+ const lookupProvider = provider || defProvider
1681
+ const lookupModel = model || defModel
1682
+ if (lookupModel) value = pickWindowPre(windows, lookupProvider, lookupModel)
1683
+ if (value > 0) { provider = lookupProvider; model = lookupModel }
1684
+ else if (defModel && defModel !== lookupModel) {
1685
+ const alt = pickWindowPre(windows, defProvider, defModel)
1686
+ if (alt > 0) { value = alt; provider = defProvider; model = defModel }
1687
+ }
1670
1688
  }
1671
1689
  const result = value > 0
1672
1690
  ? { window: value, source: 'auto' + (provider ? ':' + provider : '') + (model ? '/' + model : ''), model: model || '' }
1673
- : { window: 131072, source: 'fallback', model: model || '' }
1674
- this._waterWindowCache = { at: now, value: result }
1691
+ : { window: 131072, source: 'fallback', model: model || defModel || '' }
1692
+ this._waterWindowCache = { at: now, key: cacheKey, value: result }
1675
1693
  return result
1676
1694
  } catch (e) { return { window: 131072, source: 'fallback', error: String(e && e.message) } }
1677
1695
  }
1678
1696
 
1697
+ /** 会话级水位记录(2026-09-08):水位此前是**全局单值**,只在有 agent 轮次时更新;
1698
+ * 于是切到别的会话时,卡片显示的是「上一个会话的数」或重启后的 0,且模型名只能拿默认模型凑。
1699
+ * 现在:①checkWaterLevel 每次测量写入 per-session 表;②查询时按 sessionId 取;
1700
+ * ③没有记录就从该会话日志(磁盘)推导窗口与模型——切会话即可刷新,不需要先发消息。 */
1701
+ rememberWaterRecord(sid, rec) {
1702
+ if (!sid || !rec) return
1703
+ if (!this._waterBySession) this._waterBySession = new Map()
1704
+ this._waterBySession.set(this.waterKey(sid), rec)
1705
+ if (this._waterBySession.size > 40) {
1706
+ const first = this._waterBySession.keys().next()
1707
+ if (!first.done) this._waterBySession.delete(first.value)
1708
+ }
1709
+ }
1710
+
1711
+ /** 会话 id 归一化:agent.session.id 与 GUI 快照 current 可能带/不带 `session-` 前缀,统一去掉后作键。 */
1712
+ waterKey(sid) {
1713
+ return String(sid || '').replace(/^session-/, '')
1714
+ }
1715
+
1716
+ /** 取该会话最近 10 分钟内的实测记录(无则 null)。 */
1717
+ waterRecordFor(sid) {
1718
+ if (!sid) return null
1719
+ const m = this._waterBySession
1720
+ const rec = m && typeof m.get === 'function' ? m.get(this.waterKey(sid)) : null
1721
+ return rec && Date.now() - (Number(rec.at) || 0) < 600000 ? rec : null
1722
+ }
1723
+
1724
+ /** 从会话日志(磁盘)推导窗口与模型:不需要 agent,切换会话即可得到正确的模型名与窗口。 */
1725
+ async waterWindowForSession(sid) {
1726
+ const empty = { window: 0, source: '', model: '', provider: '', contextWindow: 0 }
1727
+ if (!sid) return empty
1728
+ if (!this._waterDeriveCache) this._waterDeriveCache = new Map()
1729
+ const cached = this._waterDeriveCache.get(String(sid))
1730
+ if (cached && Date.now() - cached.at < 300000) return cached.value
1731
+ let value = empty
1732
+ try {
1733
+ const dir = await this.locateSessionDir(String(sid))
1734
+ if (dir) {
1735
+ const raw = await readFile(path.join(dir, 'session.jsonl.zstd')).catch(() => null)
1736
+ const events = raw ? decodeZstdFrames(raw).split('\n').map((l) => { try { return JSON.parse(l) } catch (e) { return null } }).filter(Boolean) : []
1737
+ const info = findSessionModelPre(events)
1738
+ const wi = await this.resolveWaterWindow(info.provider, info.model)
1739
+ value = {
1740
+ window: info.contextWindow > 0 ? info.contextWindow : (Number(wi.window) || 0),
1741
+ source: info.contextWindow > 0 ? 'official-context' : (wi.source || ''),
1742
+ model: info.model || wi.model || '',
1743
+ provider: info.provider,
1744
+ contextWindow: info.contextWindow,
1745
+ }
1746
+ }
1747
+ } catch (e) {}
1748
+ this._waterDeriveCache.set(String(sid), { at: Date.now(), value })
1749
+ if (this._waterDeriveCache.size > 40) {
1750
+ const first = this._waterDeriveCache.keys().next()
1751
+ if (!first.done) this._waterDeriveCache.delete(first.value)
1752
+ }
1753
+ return value
1754
+ }
1755
+
1679
1756
  /** M-CM6-A·水位 v2:计量优先级=ctx.tokenMeter.measure(官方计量,与聊天框 context ring 同源,免疫重启/压缩/chunk 洪流)→ 启发式估算(降级);
1680
1757
  * 窗口优先级=waterLevelWindowTokens 手动覆盖 > 官方 request/context 容量 > settings.yaml auto/fallback。越阈值或检测到 compaction 事件→advisory+自动写抽取性骨架账本。 */
1681
1758
  async checkWaterLevel(agent) {
1682
1759
  try {
1683
1760
  if (this.config.handoffEnabled === false) return
1684
- const winInfo = await this.resolveWaterWindow()
1761
+ // 会话真实模型 + 官方容量:同一份事件扫描结果按会话缓存 5 分钟
1762
+ // (该事件只在请求头变化时追加,实测 2999 条事件里仅 2 条且位于会话开头,旧实现只扫最近 256 条 → 长会话永远扫不到)。
1763
+ let sessModel = { provider: '', model: '', contextWindow: 0 }
1764
+ try {
1765
+ const sid = agent && agent.session && agent.session.id ? String(agent.session.id) : ''
1766
+ const cached = this._officialWindowCache
1767
+ if (cached && sid && cached.sid === sid && Date.now() - cached.at < 300000 && cached.info) {
1768
+ sessModel = cached.info
1769
+ } else {
1770
+ sessModel = findSessionModelPre(sessionEventsOf(agent && agent.session))
1771
+ this._officialWindowCache = { sid, at: Date.now(), info: sessModel }
1772
+ }
1773
+ } catch (eWin) {}
1774
+ // 窗口解析按「会话真实模型」查表(2026-09-08):此前恒用 settings.yaml 的 agent-default-model,
1775
+ // 用户切过模型后卡片会标出另一个模型的名字(实测会话跑 deepseek-official/deepseek-v4.1-flash,却标 opencode-go/deepseek-v4-flash)。
1776
+ const winInfo = await this.resolveWaterWindow(sessModel.provider, sessModel.model)
1685
1777
  let win = Number(winInfo && winInfo.window) || 0
1686
1778
  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) {}
1779
+ if (win > 0 && !/^manual/.test(winSource) && sessModel.contextWindow > 0) {
1780
+ // 官方容量:request/context 的 contextWindow(路由权威值,last-wins)
1781
+ win = sessModel.contextWindow
1782
+ winSource = 'official-context'
1704
1783
  }
1705
1784
  if (win <= 0) return
1706
1785
  const rt = this.runtimeFor(agent)
@@ -1733,7 +1812,9 @@ class MemoryEngine {
1733
1812
  }
1734
1813
  }
1735
1814
  } catch (e) {}
1736
- const ratio = Math.min(estTokens / win, 1.5)
1815
+ // 旧实现硬截断到 1.5 → 任何超额水位都显示成 150%,掩盖真实占用(用户实测 761692/131072 被显示为 150%)。
1816
+ // 现在如实上报,仅做防溢出的 99 倍上限。
1817
+ const ratio = win > 0 && Number.isFinite(estTokens) ? Math.min(estTokens / win, 99) : 0
1737
1818
  rt.waterLevel = ratio
1738
1819
  rt.waterLevelTokens = estTokens
1739
1820
  this.state.waterLevelRatio = ratio
@@ -1741,9 +1822,14 @@ class MemoryEngine {
1741
1822
  this.state.waterLevelWindow = win
1742
1823
  this.state.waterLevelSource = winSource
1743
1824
  this.state.waterLevelMeter = meterLayer
1744
- this.state.waterLevelModel = winInfo.model || ''
1825
+ this.state.waterLevelModel = sessModel.model || winInfo.model || ''
1745
1826
  this.state.waterLevelAt = Date.now()
1746
- const threshold = Math.max(Number(this.config.waterLevelThreshold) || 0.8, 0.1)
1827
+ const threshold = Math.max(Number(this.config.waterLevelThreshold) || 0.75, 0.1)
1828
+ // 会话级记录:切会话时 /handoff-state?sessionId= 直接取这份,不再显示别的会话的数
1829
+ this.rememberWaterRecord(agent && agent.session && agent.session.id ? String(agent.session.id) : '', {
1830
+ ratio, tokens: estTokens, window: win, source: winSource, meter: meterLayer,
1831
+ model: this.state.waterLevelModel || '', threshold, at: Date.now(), live: true,
1832
+ })
1747
1833
  const over = ratio >= threshold || (ratio >= 0.5 && compacted)
1748
1834
  if (!over) return
1749
1835
  if (!rt.waterLevelAdvised || compacted) {
@@ -1777,6 +1863,153 @@ class MemoryEngine {
1777
1863
  } catch (e) { diag('checkWaterLevel error: ' + (e && e.message)) }
1778
1864
  }
1779
1865
 
1866
+ /** M-CM6-A·水位 v3(2026-09-08):pre-step 边界补测 —— 官方自动压缩(compaction-basic)挂在 `agent/pre-step`,
1867
+ * 阈值 thresholdRatio=0.8 对「路由请求 token」;插件此前只在 turn-stopping 测量,一旦某一轮把水位从阈值下推到 0.8 以上,
1868
+ * 官方会在该轮的 pre-step 就压缩完,交接白板/账本根本来不及写(实测:20:11 轮末测得 768158/1000000=0.77 时,会话日志里
1869
+ * 已有 2 次 compaction/start+summary)。这里在 pre-step 也做同一测量,让交接与官方压缩站在同一条边界上。
1870
+ * 节流:同一 agent 至少间隔 minGapMs(默认 5s),避免每个 step 都扫会话事件/读 tokenMeter。 */
1871
+ checkWaterLevelAtStep(agent, minGapMs = 5000) {
1872
+ try {
1873
+ if (this.config.handoffEnabled === false) return
1874
+ if (!agent || !this.hasReliableSessionIdentity(agent)) return
1875
+ const rt = this.runtimeFor(agent)
1876
+ const now = Date.now()
1877
+ if (rt && rt.waterStepAt && now - rt.waterStepAt < minGapMs) return
1878
+ if (rt) rt.waterStepAt = now
1879
+ void this.checkWaterLevel(agent)
1880
+ } catch (e) { diag('checkWaterLevelAtStep error: ' + (e && e.message)) }
1881
+ }
1882
+
1883
+ /** M-CM6-C·宿主兜底自动接续(2026-09-08,2.2.6):浏览器端 AutoContinueHost 被后台节流/关闭时,
1884
+ * 宿主自身在倒计时到期后直接建新会话。协议:turn-stopping 且水位≥阈值 → arm 倒计时(confirmSeconds,
1885
+ * 默认 35s,写入 _autoContState)→ 浏览器轮询 GET auto-continue-state 显示确认卡(同意=POST decide
1886
+ * agree,宿主立即执行;拒绝=POST decide reject,同一边界不再触发)→ 到期无人响应 → 宿主自动执行,
1887
+ * 与官方 SessionController service(ctx.get('sessionController'))同进程直调,不依赖浏览器。 */
1888
+ armAutoContinue(agent, wl) {
1889
+ try {
1890
+ if (!agent || !this.hasReliableSessionIdentity(agent)) return
1891
+ if (this.config.handoffEnabled === false) return
1892
+ if (this.config.autoContinueEnabled === false) return
1893
+ if (!wl || !(wl.ratio >= (Number(this.config.autoContinueThreshold) || 0.75))) return
1894
+ const st = this._autoContState || (this._autoContState = {})
1895
+ const now = Date.now()
1896
+ const cooldownMin = Number(this.config.autoContinueCooldownMinutes) || 30
1897
+ if (st.lastRunAt && now - st.lastRunAt < cooldownMin * 60000) return
1898
+ const sid = String(agent.session.id || agent.session.header?.id || '')
1899
+ // 拒绝语义:时间窗 10 分钟内不再 arm(近似客户端「拒绝=跳过本边界」;窗口过后按新边界重新评估)
1900
+ if (st.rejectedEdgeAt && now - st.rejectedEdgeAt < 10 * 60 * 1000) return
1901
+ if (st.armed && Date.now() < st.armed.expiresAt) return // 已有进行中的倒计时
1902
+ if (st.executing) return
1903
+ const confirmSec = Number(this.config.autoContinueConfirmSeconds)
1904
+ const sec = confirmSec >= 10 && confirmSec <= 120 ? confirmSec : 35
1905
+ st.armed = {
1906
+ edgeAt: now,
1907
+ sessionId: sid,
1908
+ expiresAt: now + sec * 1000,
1909
+ ratio: Number(wl.ratio) || 0,
1910
+ tokens: Number(wl.tokens) || 0,
1911
+ window: Number(wl.window) || 0,
1912
+ source: String(wl.source || ''),
1913
+ }
1914
+ diag('auto-continue armed: sid=' + sid + ' ratio=' + st.armed.ratio.toFixed(2) + ' expiresAt=' + st.armed.expiresAt)
1915
+ } catch (e) { diag('armAutoContinue error: ' + (e && e.message)) }
1916
+ }
1917
+
1918
+ /** 心跳驱动:倒计时到期且无人决定 → 宿主自动执行接续。 */
1919
+ async tickAutoContinue() {
1920
+ try {
1921
+ const st = this._autoContState || (this._autoContState = {})
1922
+ if (!st.armed || st.executing) return
1923
+ if (Date.now() < st.armed.expiresAt) return
1924
+ diag('auto-continue deadline reached, executing host-side')
1925
+ await this.hostAutoContinue()
1926
+ } catch (e) { diag('tickAutoContinue error: ' + (e && e.message)) }
1927
+ }
1928
+
1929
+ /** 宿主侧执行接续:刷账本(已由水位检查写过)→ 材料 → sessionController.create/selectModel/prompt。 */
1930
+ async hostAutoContinue() {
1931
+ const st = this._autoContState || (this._autoContState = {})
1932
+ if (st.executing) return { ok: false, error: 'already executing' }
1933
+ const armed = st.armed
1934
+ st.armed = null
1935
+ st.executing = true
1936
+ st.error = ''
1937
+ try {
1938
+ const sc = this._sessionController || (() => { try { this._sessionController = this._ctxRef?.get?.('sessionController') || undefined } catch (e) {} return this._sessionController })()
1939
+ if (!sc || typeof sc.create !== 'function') throw new Error('sessionController service unavailable(宿主无法建会话;请重启 dsh web 或保持页面打开)')
1940
+ const d = await this.buildContinueCarry()
1941
+ if (!d || !d.ok) throw new Error((d && d.error) || 'no handoff material')
1942
+ const created = await sc.create({
1943
+ ...(d.workspaceId ? { workspaceId: d.workspaceId } : { cwd: d.ws }),
1944
+ ...(d.agentPreset ? { agentPreset: d.agentPreset } : {}),
1945
+ })
1946
+ const newId = String((created && (created.sessionId || created.id)) || '')
1947
+ if (!newId) throw new Error('create returned no sessionId')
1948
+ if (d.provider && d.model && typeof sc.selectModel === 'function') {
1949
+ try { await sc.selectModel({ sessionId: newId, provider: d.provider, model: d.model, ...(d.reasoningEffort ? { reasoningEffort: d.reasoningEffort } : {}) }) } catch (eM) { diag('host auto-continue selectModel: ' + (eM && eM.message)) }
1950
+ }
1951
+ if (typeof sc.prompt === 'function') {
1952
+ const signal = typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(120000) : undefined
1953
+ await sc.prompt({ sessionId: newId, content: [{ type: 'text', text: d.carryText }], mode: 'queue' }, signal)
1954
+ }
1955
+ st.lastRunAt = Date.now()
1956
+ st.lastOk = { sessionId: newId, model: d.model || '', reasoningEffort: d.reasoningEffort || '', workspaceId: d.workspaceId || '' }
1957
+ diag('auto-continue host-executed: new session ' + newId + ' model=' + (d.model || 'default'))
1958
+ return { ok: true, sessionId: newId, model: d.model || '', reasoningEffort: d.reasoningEffort || '' }
1959
+ } catch (e) {
1960
+ st.error = String(e && e.message ? e.message : e)
1961
+ diag('hostAutoContinue error: ' + st.error)
1962
+ return { ok: false, error: st.error }
1963
+ } finally {
1964
+ st.executing = false
1965
+ st.armed = null
1966
+ }
1967
+ }
1968
+
1969
+ /** 浏览器轮询视图:armed 倒计时 / 最近一次执行结果 / 拒绝边界 / 错误。 */
1970
+ autoContinueState() {
1971
+ const st = this._autoContState || (this._autoContState = {})
1972
+ const armed = st.armed ? {
1973
+ edgeAt: st.armed.edgeAt,
1974
+ sessionId: st.armed.sessionId,
1975
+ ratio: st.armed.ratio,
1976
+ tokens: st.armed.tokens,
1977
+ window: st.armed.window,
1978
+ expiresAt: st.armed.expiresAt,
1979
+ leftMs: Math.max(0, st.armed.expiresAt - Date.now()),
1980
+ } : null
1981
+ return {
1982
+ enabled: this.config.autoContinueEnabled !== false && this.config.handoffEnabled !== false,
1983
+ threshold: Number(this.config.autoContinueThreshold) || 0.75,
1984
+ armed,
1985
+ executing: !!st.executing,
1986
+ rejectedEdgeAt: st.rejectedEdgeAt || 0,
1987
+ lastRunAt: st.lastRunAt || 0,
1988
+ lastOk: st.lastOk || null,
1989
+ error: st.error || '',
1990
+ }
1991
+ }
1992
+
1993
+ /** 浏览器决定:agree=立即宿主执行;reject=标记该边界不再触发。 */
1994
+ async decideAutoContinue(action, edgeAt) {
1995
+ const st = this._autoContState || (this._autoContState = {})
1996
+ const armedEdge = st.armed && st.armed.edgeAt
1997
+ if (action === 'reject') {
1998
+ if (armedEdge !== undefined && armedEdge !== null) st.rejectedEdgeAt = armedEdge
1999
+ st.armed = null
2000
+ diag('auto-continue rejected at edge ' + String(armedEdge))
2001
+ return { ok: true, rejected: true }
2002
+ }
2003
+ if (action === 'agree') {
2004
+ if (st.executing) return { ok: false, error: 'already executing' }
2005
+ if (!st.armed) return { ok: false, error: 'no pending auto-continue' }
2006
+ edgeAt = edgeAt || st.armed.edgeAt
2007
+ if (edgeAt !== st.armed.edgeAt) return { ok: false, error: 'stale edge' }
2008
+ return this.hostAutoContinue()
2009
+ }
2010
+ return { ok: false, error: 'unknown action' }
2011
+ }
2012
+
1780
2013
  /** 官方 token-meter 公式(dsh-context 移植版):文本 ≈ ceil(字符/4)+4 角色框定;忠实于 dsh-token-meter/estimate.ts 的文本分支。 */
1781
2014
  estimateSessionTokens(messages) {
1782
2015
  let tokens = 0
@@ -1968,7 +2201,7 @@ class MemoryEngine {
1968
2201
  }
1969
2202
 
1970
2203
  /** M-CM1 白板面板数据:PLAN 当前版+归档版本列表+账本列表;fileQ 非空时返回指定文件文本(严格白名单,限 handoff 目录内)。 */
1971
- async handoffPanelData(fileQ) {
2204
+ async handoffPanelData(fileQ, sessionId) {
1972
2205
  if (this.config.handoffEnabled === false) return { enabled: false }
1973
2206
  const handoffDir = this.state.handoffDir || (await this.resolvePaths(undefined)).handoffDir
1974
2207
  const planPath = this.state.planPath || path.join(handoffDir, 'PLAN.md')
@@ -1994,13 +2227,43 @@ class MemoryEngine {
1994
2227
  if (st && st.isFile()) ledgers.push({ name: n, mtime: Number(st.mtimeMs) })
1995
2228
  }
1996
2229
  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) {}
2230
+ // 水位:优先返回「该会话」的记录(实测 → 磁盘推导);未指定 sessionId 时退回全局单值(兼容旧调用方)
2231
+ let water = null
2232
+ if (sessionId) {
2233
+ const rec = this.waterRecordFor(sessionId)
2234
+ if (rec) {
2235
+ water = {
2236
+ ratio: Number(rec.ratio) || 0, tokens: Number(rec.tokens) || 0, window: Number(rec.window) || 0,
2237
+ source: rec.source || '', meter: rec.meter || '', model: rec.model || '',
2238
+ threshold: Number(this.config.waterLevelThreshold) || 0.75, at: Number(rec.at) || 0, live: true,
2239
+ }
2240
+ } else {
2241
+ const wi = await this.waterWindowForSession(sessionId)
2242
+ water = {
2243
+ ratio: 0, tokens: 0, window: Number(wi.window) || 0, source: wi.source || '', meter: '',
2244
+ model: wi.model || '', threshold: Number(this.config.waterLevelThreshold) || 0.75, at: 0, live: false,
2245
+ }
2246
+ }
2247
+ } else {
2248
+ if (!this.state.waterLevelWindow && this.config.handoffEnabled !== false) {
2249
+ try {
2250
+ const wi = await this.resolveWaterWindow()
2251
+ this.state.waterLevelWindow = Number(wi && wi.window) || 0
2252
+ this.state.waterLevelSource = (wi && wi.source) || ''
2253
+ this.state.waterLevelModel = (wi && wi.model) || ''
2254
+ } catch (e) {}
2255
+ }
2256
+ water = {
2257
+ ratio: Number(this.state.waterLevelRatio) || 0,
2258
+ tokens: Number(this.state.waterLevelTokens) || 0,
2259
+ window: Number(this.state.waterLevelWindow) || 0,
2260
+ source: this.state.waterLevelSource || '',
2261
+ meter: this.state.waterLevelMeter || '',
2262
+ model: this.state.waterLevelModel || '',
2263
+ threshold: Number(this.config.waterLevelThreshold) || 0.75,
2264
+ at: Number(this.state.waterLevelAt) || 0,
2265
+ live: Number(this.state.waterLevelAt) > 0,
2266
+ }
2004
2267
  }
2005
2268
  const planMt = await stat(planPath).then((s) => Number(s.mtimeMs)).catch(() => 0)
2006
2269
  const sid = this.currentSessionId()
@@ -2014,16 +2277,7 @@ class MemoryEngine {
2014
2277
  refresh: this.config.autoContinueRefreshRitual === false || !sid
2015
2278
  ? { sessionId: '', prompt: '' }
2016
2279
  : { 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
- },
2280
+ waterLevel: water,
2027
2281
  }
2028
2282
  }
2029
2283
 
@@ -2782,7 +3036,7 @@ class MemoryEngine {
2782
3036
  lines.push('\n[交接续命] 当前会话未绑定工作区(项目记忆不可用)。全局最近交接账本: ' + s.globalLedgerPath + ' —— 需要续接上次任务时,直接读取该文件恢复上下文;工作区绑定后白板/账本即恢复正常注入。')
2783
3037
  }
2784
3038
  // 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) {
3039
+ if (cfg.waterLevelAdvisory !== false && !this.isUnattendedNow() && (s.waterLevelRatio || 0) >= (Number(cfg.waterLevelThreshold) || 0.75) && Date.now() - (s.waterLevelAt || 0) < 600000) {
2786
3040
  lines.push('\n' + L('snapshotWaterTitle') + '\n' + L('snapshotWaterBody', { pct: Math.round((s.waterLevelRatio || 0) * 100) }))
2787
3041
  }
2788
3042
  let used = 0
@@ -5763,7 +6017,13 @@ export function apply(ctx, config) {
5763
6017
  // 最后活动时间写入该 agent 自己的 runtime(不再落到 currentRuntime()/default)
5764
6018
  try { engine.runtimeFor(agent).lastActiveAt = Date.now() } catch (e) {}
5765
6019
  // M-CM4 水位感知:每轮测量会话消息体量,越阈值→advisory+自动骨架账本(异步,不阻塞收尾)
5766
- try { void engine.checkWaterLevel(agent) } catch (e) {}
6020
+ try { void engine.checkWaterLevel(agent).then(function () {
6021
+ // M-CM6-C 宿主兜底:轮末测量完成后若达标 → arm 倒计时(浏览器活着时由它展示确认卡;被节流/关闭则宿主到期自执行)
6022
+ try {
6023
+ const rt2 = engine.runtimeFor(agent)
6024
+ engine.armAutoContinue(agent, { ratio: rt2.waterLevel, tokens: rt2.waterLevelTokens, window: rt2.waterLevelWindow, source: rt2.waterLevelSource })
6025
+ } catch (eArm) {}
6026
+ }) } catch (e) {}
5767
6027
  // 全局活动戳同步更新(暂离检测统揽全局,见 tickTime)
5768
6028
  engine._globalLastActiveAt = Date.now()
5769
6029
  try { engine.ingestAgentLifecycle(agent, 'agent/turn-stopping', { turn: payload && payload.turn, payload: { turn: payload && payload.turn } }) } catch (e) {}
@@ -5794,6 +6054,9 @@ export function apply(ctx, config) {
5794
6054
  // M7.5:pre-step 边界先冲刷 CoT 缓冲——保证本轮 context_push 的 window 携带最新思维链
5795
6055
  try { engine.flushReasoningBuffer(rt) } catch (_) {}
5796
6056
  engine.ingestAgentLifecycle(agent, 'agent/pre-step', { turn: payload && payload.turn, step: payload && payload.step, payload: { turn: payload && payload.turn, step: payload && payload.step } })
6057
+ // M-CM6-A·水位 v3(2026-09-08):官方压缩在 pre-step 就动手,交接测量必须站同一条边界
6058
+ // (否则某轮把水位从 <阈值 推到 ≥0.8 时,官方先压缩、白板账本来不及写)。异步不阻塞放行。
6059
+ try { engine.checkWaterLevelAtStep(agent) } catch (eWL) {}
5797
6060
  // M6-3:pre-step 时序(§8)——校验 cursor/index/TTL 后 claim packet,等待渲染面消费
5798
6061
  try { if (engine._activationHost) engine._activationHost.onPreStep(agent) } catch (_) {}
5799
6062
  // 只刷新顶层会话:子代理(自动沉淀/固化的 subagent)session 无 cwd,刷新会把 state 切到错误工作区
@@ -6243,7 +6506,7 @@ export function apply(ctx, config) {
6243
6506
  if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
6244
6507
  try {
6245
6508
  const url = new URL(req.url, 'http://127.0.0.1')
6246
- return writeJson(res, 200, await engine.handoffPanelData(url.searchParams.get('file')))
6509
+ return writeJson(res, 200, await engine.handoffPanelData(url.searchParams.get('file'), url.searchParams.get('sessionId')))
6247
6510
  } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
6248
6511
  },
6249
6512
  },
@@ -6261,6 +6524,29 @@ export function apply(ctx, config) {
6261
6524
  } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
6262
6525
  },
6263
6526
  },
6527
+ {
6528
+ kind: 'exact',
6529
+ path: API['auto-continue-state'],
6530
+ handler: async (req, res) => {
6531
+ // M-CM6-C·宿主兜底状态(GET;loopback):浏览器 AutoContinueHost 轮询此端点展示确认卡/倒计时/执行结果。
6532
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
6533
+ try { return writeJson(res, 200, engine.autoContinueState()) } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
6534
+ },
6535
+ },
6536
+ {
6537
+ kind: 'exact',
6538
+ path: API['auto-continue-decide'],
6539
+ handler: async (req, res) => {
6540
+ // M-CM6-C·宿主兜底决定(POST;loopback):agree=宿主立即执行接续;reject=本边界不再触发。
6541
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
6542
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
6543
+ try {
6544
+ const body = await readJsonBody(req).catch(() => ({}))
6545
+ const r = await engine.decideAutoContinue(body && body.action, body && body.edgeAt)
6546
+ return writeJson(res, r && r.ok ? 200 : 400, r || { ok: false, error: 'no result' })
6547
+ } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
6548
+ },
6549
+ },
6264
6550
  {
6265
6551
  kind: 'exact',
6266
6552
  path: API['subagent-gc'],
@@ -7061,7 +7347,7 @@ export function apply(ctx, config) {
7061
7347
  })()
7062
7348
  }, 5 * 60 * 1000)
7063
7349
  // 心跳独立 15 秒一次(对齐 polling-lease 心跳节奏),证明轮询机制活着
7064
- const heartbeatTimer = setInterval(() => { void writeHeartbeat(); try { engine.tickTime() } catch (e) {}; try { void engine.subagentGcSweep() } catch (e) {} }, 15000)
7350
+ const heartbeatTimer = setInterval(() => { void writeHeartbeat(); try { engine.tickTime() } catch (e) {}; try { void engine.subagentGcSweep() } catch (e) {}; try { void engine.tickAutoContinue() } catch (e) {} }, 15000)
7065
7351
  void writeHeartbeat() // 立即心跳一次:重启后马上可见轮询存活
7066
7352
 
7067
7353
  // ---------- 注册与清理 ----------
@@ -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.6",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {