@a9i5k4/dsh-auto-memory 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -252,6 +252,8 @@ window.__ModuleLoader__.load({
252
252
  fProjectDir: '项目记忆目录', fProjectDirHint: '相对各工作区的目录名(默认 .dsh-memory)。',
253
253
  fInject: '注入记忆上下文', fInjectHint: '每次组装提示词时自动注入 <memory_system> 块。',
254
254
  fBudget: '注入预算(字符)', fBudgetHint: '记忆块总预算,超出部分截断。默认 1600(≈400-600 token/轮);活跃会话每轮都注入这段背景,调低更省 token,调高保留更多记忆。',
255
+ fNoteCap: '项目笔记容量上限(字符)', fNoteCapHint: '项目笔记 MEMORY.md 的容量上限,默认 12000。超出时自动整理:先把较早内容交给模型折叠成要点,失败则退回整条归档(原文进 archive,不丢)——新记忆不会被堵在外面。注意与上面的「注入预算」不是一回事:这个管文件本体大小,注入预算管每轮往上下文塞多少摘要。',
256
+ fUserCap: '用户级记忆容量上限(字符)', fUserCapHint: '用户级 MEMORY.md(跨项目规则)的容量上限,默认 12000。整理方式与项目笔记相同(先折叠成要点,必要时整条归档)。',
255
257
  fSnapGap: '快照最小间隔(轮)', fSnapGapHint: '动态记忆快照内容变化后,至少隔 N 轮才重新注入,避免每轮日志微变都追加快照导致历史膨胀。默认 5;0=每轮都尝试(仍受内容变化约束)。',
256
258
  fReinjectOnCompact: '压缩后立即重注入快照', fReinjectOnCompactHint: '上下文被压缩/截断(通常伴随 contextVersion 重置)后,快照会被清掉;开启后强制立即重注入一次,确保记忆背景重建。默认开。',
257
259
  fPromptCustom: '自定义记忆注入 prompt', fPromptCustomHint: '可覆盖各层提示文案(小众功能)。支持占位符 {date} {ws} {budget} {n}。改坏了可一键恢复默认。',
@@ -401,6 +403,8 @@ window.__ModuleLoader__.load({
401
403
  fProjectDir: 'Project memory dir', fProjectDirHint: 'Directory name relative to each workspace (default .dsh-memory).',
402
404
  fInject: 'Inject memory context', fInjectHint: 'Auto-inject <memory_system> block into every prompt.',
403
405
  fBudget: 'Injection budget (chars)', fBudgetHint: 'Total budget for the memory block; excess is truncated. Default 1600 (~400-600 tokens/turn); this background is injected every turn, so lower = fewer tokens, higher = more memory retained.',
406
+ fNoteCap: 'Project note capacity (chars)', fNoteCapHint: 'Capacity cap for the project MEMORY.md, default 12000. When exceeded the framework auto-consolidates: older content is first folded into bullet points by the model, falling back to whole-record archiving (originals go to archive, never lost) — new memories are never blocked. This is NOT the injection budget above: this caps the file itself, the injection budget caps the digest added to each turn.',
407
+ fUserCap: 'User memory capacity (chars)', fUserCapHint: 'Capacity cap for the user-level MEMORY.md (cross-project rules), default 12000. Same consolidation behaviour (fold into points first, whole-record archive as fallback).',
404
408
  fSnapGap: 'Snapshot min gap (turns)', fSnapGapHint: 'After the dynamic memory snapshot changes, re-inject at least N turns later, so small per-turn log changes do not append a new snapshot every turn (history bloat). Default 5; 0 = try every turn (still change-gated).',
405
409
  fReinjectOnCompact: 'Re-inject snapshot immediately after compaction', fReinjectOnCompactHint: 'When the context is compacted/truncated (usually a contextVersion reset), the snapshot is cleared; on = force re-inject once so the memory background rebuilds. Default on.',
406
410
  fPromptCustom: 'Customize memory-injection prompt', fPromptCustomHint: 'Override any prompt layer (power feature). Placeholders: {date} {ws} {budget} {n}. One-click reset restores defaults.',
@@ -886,6 +890,7 @@ window.__ModuleLoader__.load({
886
890
  pyStatus: '/api/dsh-auto-memory/python-setup/status',
887
891
  handoffState: '/api/dsh-auto-memory/handoff-state',
888
892
  handoffContinue: '/api/dsh-auto-memory/handoff-continue',
893
+ handoffPermission: '/api/dsh-auto-memory/handoff-permission',
889
894
  autoContState: '/api/dsh-auto-memory/auto-continue-state',
890
895
  autoContDecide: '/api/dsh-auto-memory/auto-continue-decide',
891
896
  semanticDownload: '/api/dsh-auto-memory/semantic-download',
@@ -1478,7 +1483,7 @@ window.__ModuleLoader__.load({
1478
1483
  var st = p2.status === 'ok' ? t('pyWizOk') + (p2.isVenv ? ' · ' + t('pyWizVenvRec') : '') + (p2.version ? ' (' + p2.version + ')' : '') : p2.status === 'too-old' ? t('pyWizTooOld') + (p2.version ? ' (' + p2.version + ')' : '') : t('pyWizMissing')
1479
1484
  return h('div', { key: idx, style: { fontSize: 'calc(10.5px * var(--dam-scale))', opacity: p2.status === 'ok' ? 1 : 0.55, margin: '2px 0' } }, '· ' + p2.label + ' — ' + st)
1480
1485
  })
1481
- var allReady = data.venvOk && data.depsOk && data.modelReady
1486
+ var allReady = data.venvOk && data.depsOk && data.modelReady && data.configOk
1482
1487
  return h('div', { style: { border: '1px solid color-mix(in srgb, var(--dam-accent, #2456c4) 40%, transparent)', borderRadius: '10px', padding: '10px 12px', marginBottom: '8px', fontSize: 'calc(11.5px * var(--dam-scale))', lineHeight: 1.6 } },
1483
1488
  h('div', { style: { fontWeight: 700, marginBottom: '4px' } }, t('pyWizTitle')),
1484
1489
  h('div', { style: { opacity: .6, fontSize: 'calc(10.5px * var(--dam-scale))', marginBottom: '6px' } }, t('pyWizModelHint')),
@@ -2012,6 +2017,10 @@ window.__ModuleLoader__.load({
2012
2017
  // 流程:③刷新仪式(旧 Agent 先刷 PLAN+账本)→ 取材料 → 修A(workspaceId 建会话)→ 序号标题
2013
2018
  // → 修B(沿用模型+思考档位)→ 打开 → 注入材料。
2014
2019
  var continueFlowBusy = false
2020
+ // 刷新仪式认到的旧会话 id(2026-09-10):权限继承要用它反查旧会话预设。
2021
+ // 材料里的 prevSessionId 依赖宿主 _lastAgent,重启后可能为空;这份来自 handoff-state.refresh,
2022
+ // 宿主侧带磁盘回退,更可靠。
2023
+ var lastRefreshSessionId = ''
2015
2024
  function continueCreateArgs(d) {
2016
2025
  // 修A(2026-09-08):官方 session.create 只接受 workspaceId 或 cwd 之一(同传报 bad-request),
2017
2026
  // 且只有 workspaceId 分支会 workspace.attachSession()(api-session-controller/lib/index.js:566-588);
@@ -2021,18 +2030,31 @@ window.__ModuleLoader__.load({
2021
2030
  }
2022
2031
  async function refreshOldSession(onMsg) {
2023
2032
  // ③接续前刷新仪式:让旧 Agent 把 PLAN.md 与交接账本刷到最新,host 再用最新材料组装交接。
2024
- // 任何失败/超时都 fail-soft(用现有材料继续),绝不阻塞接续。
2033
+ // 2026-09-10:原先任何失败/超时都静默(fail-soft),用户看到的现象是"点了接续却什么也没发生、旧会话毫无动静"。
2034
+ // 现改为 fail-soft 但**可见** —— 跳过/失败/超时都回报一句原因,接续本身照常继续。
2025
2035
  try {
2026
2036
  var st = await apiGet(API.handoffState)
2027
2037
  var rf = st && st.refresh
2028
- if (!rf || !rf.sessionId || !rf.prompt) return { ok: false, skipped: true }
2029
- if (!remoteFace || !remoteFace.session || typeof remoteFace.session.prompt !== 'function') return { ok: false, skipped: true }
2038
+ if (!rf || !rf.sessionId || !rf.prompt) {
2039
+ if (onMsg) onMsg(locale === 'zh' ? '⚠ 刷新仪式已跳过:宿主未给出旧会话 id(刚重启、旧会话未活跃时会发生)——本次直接用现有材料接续。' : '⚠ Refresh ritual skipped: host supplied no old-session id (happens right after a restart) — continuing with current material.')
2040
+ return { ok: false, skipped: true }
2041
+ }
2042
+ if (!remoteFace || !remoteFace.session || typeof remoteFace.session.prompt !== 'function') {
2043
+ if (onMsg) onMsg(locale === 'zh' ? '⚠ 刷新仪式已跳过:harness 未提供 remote.session。' : '⚠ Refresh ritual skipped: remote.session unavailable.')
2044
+ return { ok: false, skipped: true }
2045
+ }
2030
2046
  var before = String((st.planMtime || 0)) + '|' + String((st.ledgers && st.ledgers[0] && st.ledgers[0].name) || '')
2047
+ lastRefreshSessionId = String(rf.sessionId || '')
2031
2048
  if (onMsg) onMsg(locale === 'zh' ? '刷新仪式:请旧 Agent 更新白板 PLAN 与交接账本…' : 'Refresh ritual: asking the old agent to update PLAN + ledger…')
2032
2049
  await remoteFace.session.prompt({ requestId: amReqId('amr'), sessionId: rf.sessionId, mode: 'queue', content: [{ type: 'text', text: rf.prompt }], clientTimeZone: amCtzValue() })
2033
2050
  var waited = await waitForRefresh(rf.sessionId, before)
2051
+ if (onMsg && waited !== 'updated') onMsg(locale === 'zh' ? '刷新仪式:旧会话未在超时内产出新白板/账本(' + waited + '),用现有材料继续。' : 'Refresh ritual: old session produced no new PLAN/ledger within timeout (' + waited + '); continuing with current material.')
2034
2052
  return { ok: true, waited: waited }
2035
- } catch (eRf) { return { ok: false, error: String(eRf && eRf.message ? eRf.message : eRf) } }
2053
+ } catch (eRf) {
2054
+ var msg = String(eRf && eRf.message ? eRf.message : eRf)
2055
+ if (onMsg) onMsg(locale === 'zh' ? '⚠ 刷新仪式失败:' + msg + '(用现有材料继续)' : '⚠ Refresh ritual failed: ' + msg + ' (continuing with current material)')
2056
+ return { ok: false, error: msg }
2057
+ }
2036
2058
  }
2037
2059
  async function waitForRefresh(oldId, before) {
2038
2060
  // 结束条件(任一):材料变了 / 旧会话跑完这一轮(true→false) / 超时(默认 90s,配置可调)。
@@ -2079,7 +2101,28 @@ window.__ModuleLoader__.load({
2079
2101
  try { await rf.session.selectModel({ sessionId: newId, provider: d.provider, model: d.model, reasoningEffort: d.reasoningEffort || undefined }) } catch (eMdl) {}
2080
2102
  }
2081
2103
  try { if (sessions && typeof sessions.open === 'function') sessions.open(newId) } catch (eOpen) {}
2104
+ // 权限继承(2026-09-10):官方 session.create 不收权限字段,新会话一律落 settings 的
2105
+ // permission.defaultPreset(本机默认曾=workspace-write+ask)→ 接续后每一步都要用户批准,静默运行被打断。
2106
+ // 两段式:投料前先试(命中则**首轮就已继承**),投料后再试(新会话的 agent 往往是投料时才建出来)。
2107
+ var fromSid = String((d && d.prevSessionId) || lastRefreshSessionId || '')
2108
+ var permPreset = ''
2109
+ if (fromSid) {
2110
+ try {
2111
+ var pp1 = await apiPost(API.handoffPermission, { fromSessionId: fromSid, toSessionId: newId, attempts: [0, 400] })
2112
+ if (pp1 && pp1.ok) permPreset = String(pp1.preset || '')
2113
+ } catch (ePp1) {}
2114
+ }
2082
2115
  await rf.session.prompt({ requestId: amReqId('amc'), sessionId: newId, mode: 'queue', content: [{ type: 'text', text: d.carryText }], clientTimeZone: amCtzValue() })
2116
+ if (!permPreset && fromSid) {
2117
+ try {
2118
+ var pp2 = await apiPost(API.handoffPermission, { fromSessionId: fromSid, toSessionId: newId, attempts: [0, 700, 1600] })
2119
+ if (pp2 && pp2.ok) permPreset = String(pp2.preset || '')
2120
+ else if (onMsg && pp2 && pp2.reason && pp2.reason !== 'no-permission-service') {
2121
+ onMsg(locale === 'zh' ? '⚠ 权限未继承(' + pp2.reason + '),新会话按设置里的默认权限运行。' : '⚠ Permission not inherited (' + pp2.reason + '); new session runs with the default permission.')
2122
+ }
2123
+ } catch (ePp2) {}
2124
+ }
2125
+ if (onMsg && permPreset) onMsg(locale === 'zh' ? '已继承旧会话权限:' + permPreset : 'Permission inherited: ' + permPreset)
2083
2126
  return newId
2084
2127
  }
2085
2128
  async function runContinueFlow(opts) {
@@ -2093,7 +2136,8 @@ window.__ModuleLoader__.load({
2093
2136
  var ritual = !(cfg && cfg.autoContinueRefreshRitual === false) && !(opts && opts.skipRefresh)
2094
2137
  if (ritual) { try { await refreshOldSession(onMsg) } catch (eRf) {} }
2095
2138
  onMsg(locale === 'zh' ? '正在构造交接材料(含旧会话转写)…' : 'Preparing handoff material (incl. prev-session transcript)…')
2096
- var d = await apiPost(API.handoffContinue, {})
2139
+ var fromSidForCarry = String(lastRefreshSessionId || currentSessionIdClient() || '')
2140
+ var d = await apiPost(API.handoffContinue, fromSidForCarry ? { fromSessionId: fromSidForCarry } : {})
2097
2141
  if (!d || !d.ok) throw new Error((d && d.error) || 'no handoff material')
2098
2142
  var newId = await executeContinue(d, onMsg)
2099
2143
  try { localStorage.setItem('dsh-auto-memory.autoCont.last', String(Date.now())) } catch (eL) {}
@@ -3737,6 +3781,29 @@ window.__ModuleLoader__.load({
3737
3781
  }
3738
3782
  kids.push(h('input', { key: '__manual__', 'data-dam-input': '', style: { marginTop: '6px', width: '100%' }, value: cfg.subagentModel || '', placeholder: locale === 'zh' ? '手动输入(可选,provider 跟随路由默认)' : 'manual entry (optional, provider follows routing default)', onChange: function (e) { set('subagentModel', String(e.target.value || '').trim()); set('subagentProvider', '') } }))
3739
3783
  }
3784
+ // 思考强度(DSH 0.1.5 agentOptions.reasoningEffort):与模型成对下发给子代理;留空=跟随模型默认。
3785
+ // 合法值按 DeepSeek 适配器口径 off|low|high|max(默认 high)——服务端同样做白名单过滤。
3786
+ var effortOpts = [
3787
+ ['', locale === 'zh' ? '跟随默认' : 'default'],
3788
+ ['off', 'off'],
3789
+ ['low', 'low'],
3790
+ ['high', 'high'],
3791
+ ['max', 'max'],
3792
+ ]
3793
+ var curEffort = String(cfg.subagentReasoningEffort || '').toLowerCase()
3794
+ kids.push(h('div', { key: '__effort__', style: { marginTop: '10px', paddingTop: '8px', borderTop: '1px solid color-mix(in srgb, var(--dsw-alias-border-l1, rgba(128,128,128,.25)) 45%, transparent)' } },
3795
+ h('b', { style: { display: 'block', fontSize: 'calc(12px * var(--dam-scale))' } }, locale === 'zh' ? '思考强度(子代理)' : 'Reasoning effort (subagents)'),
3796
+ h('div', { 'data-dam-row': '', style: { flexWrap: 'wrap', marginTop: '4px' } },
3797
+ effortOpts.map(function (o) {
3798
+ return h('button', {
3799
+ key: 'eff-' + (o[0] || 'def'), 'data-dam-btn': '',
3800
+ style: { padding: '3px 9px', fontWeight: curEffort === o[0] ? 700 : 400, opacity: curEffort === o[0] ? 1 : 0.72 },
3801
+ onClick: function () { set('subagentReasoningEffort', o[0]) },
3802
+ }, o[1] + (curEffort === o[0] ? ' ✓' : ''))
3803
+ })),
3804
+ h('div', { 'data-dam-hint': '' }, locale === 'zh'
3805
+ ? 'off=关闭思维链,low/high/max 递增思考深度(DeepSeek 默认 high)。只作用于本插件的子代理(时段总结/问候/自动沉淀/蒸馏),不影响主对话;留空或模型不支持时跟随模型默认。'
3806
+ : 'off disables thinking; low/high/max increase depth (DeepSeek default: high). Applies only to this plugin’s subagents, never the main conversation; empty follows the model default.')))
3740
3807
  return h('div', { style: panelStyle }, kids)
3741
3808
  }
3742
3809
  var verPair = useState(null)
@@ -3922,6 +3989,10 @@ window.__ModuleLoader__.load({
3922
3989
  h('option', { value: 'js' }, t('semJs')),
3923
3990
  h('option', { value: 'python' }, t('semPy'))),
3924
3991
  h('button', { 'data-dam-btn': '', title: locale === 'zh' ? '自动检测本机语义引擎环境(JS/Python 资产+推理库),并联动安装助手' : 'Auto-detect local semantic engine assets & runtime, with setup assistant', disabled: detBusy, onClick: function () { void runDetect() } }, detBusy ? '⏳' : '⟳ 检测'),
3992
+ // 2026-09-10:向导入口原先只在"资产未就绪"时自动弹出,已装好的用户反而进不去(想重装/换布局无处可点)。
3993
+ // 现在常驻一个显式开关,永远可达;已就绪时打开也只是复用同一套下载/校验步骤。
3994
+ h('button', { 'data-dam-btn': '', title: locale === 'zh' ? '打开/收起安装向导(按当前引擎选择:JS 模型 或 Python 引擎)' : 'Open/collapse setup wizard (follows the selected engine)', onClick: function () { setGuide(guide ? '' : ((cfg.semanticEngineMode === 'python') ? 'python' : 'js')) } },
3995
+ guide ? (locale === 'zh' ? '收起向导' : 'Hide wizard') : (locale === 'zh' ? '🧩 安装向导' : '🧩 Setup')),
3925
3996
  ]), t('semModeHint')),
3926
3997
  sem.loaded ? h('div', { 'data-dam-hint': '', style: { marginTop: '-4px', marginBottom: '8px' } },
3927
3998
  t('semResolved') + ': ' + (sem.resolvedTier === 'c2' ? t('tierC2') + ' ✓' : sem.resolvedTier === 'c3' ? t('tierC3') + ' ✓' : t('tierC1')))
@@ -4103,6 +4174,8 @@ window.__ModuleLoader__.load({
4103
4174
  : null]),
4104
4175
  section('injection', sectionLabels.injection, [field(t('fInject'), h('input', { type: 'checkbox', checked: !!cfg.injectEnabled, onChange: function (e) { set('injectEnabled', e.target.checked) } }), t('fInjectHint')),
4105
4176
  field(t('fBudget'), h('input', { 'data-dam-input': '', type: 'number', min: 400, value: cfg.injectBudgetChars, onChange: function (e) { set('injectBudgetChars', Number(e.target.value) || 2400) } }), t('fBudgetHint')),
4177
+ field(t('fNoteCap'), h('input', { 'data-dam-input': '', type: 'number', min: 500, value: cfg.noteCapacityChars === undefined ? 12000 : cfg.noteCapacityChars, onChange: function (e) { set('noteCapacityChars', Number(e.target.value) || 12000) } }), t('fNoteCapHint')),
4178
+ field(t('fUserCap'), h('input', { 'data-dam-input': '', type: 'number', min: 500, value: cfg.userCapacityChars === undefined ? 12000 : cfg.userCapacityChars, onChange: function (e) { set('userCapacityChars', Number(e.target.value) || 12000) } }), t('fUserCapHint')),
4106
4179
  field(t('fDays'), h('input', { 'data-dam-input': '', type: 'number', min: 1, max: 14, value: cfg.recentDaysInjected, onChange: function (e) { set('recentDaysInjected', Number(e.target.value) || 1) } }), t('fDaysHint')),
4107
4180
  field(t('fExtBudget'), h('input', { 'data-dam-input': '', type: 'number', min: 200, value: cfg.externalInjectionChars === undefined ? 1400 : cfg.externalInjectionChars, onChange: function (e) { set('externalInjectionChars', Number(e.target.value) || 1400) } }), t('fExtBudgetHint')),
4108
4181
  field(t('fSnapGap'), h('input', { 'data-dam-input': '', type: 'number', min: 0, max: 50, value: cfg.snapshotMinGapRounds === undefined ? 5 : cfg.snapshotMinGapRounds, onChange: function (e) { set('snapshotMinGapRounds', Number(e.target.value) || 5) } }), t('fSnapGapHint')),
@@ -4136,10 +4209,10 @@ window.__ModuleLoader__.load({
4136
4209
  field(t('fMaintScheduleTime'), h('input', { 'data-dam-input': '', placeholder: '10:00', value: cfg.maintainScheduleTime === undefined ? '10:00' : cfg.maintainScheduleTime, onChange: function (e) { set('maintainScheduleTime', String(e.target.value || '').trim()) } }), t('fMaintScheduleTimeHint')),
4137
4210
  field(t('fStyle'), h('select', { 'data-dam-select': '', value: cfg.reflectStyle, onChange: function (e) { set('reflectStyle', e.target.value) } },
4138
4211
  STYLE_IDS.map(function (id) { return h('option', { key: id, value: id }, t('style' + id.charAt(0).toUpperCase() + id.slice(1))) })), t('fStyleHint')),
4139
- field(locale === 'zh' ? '总结/问候默认模型' : 'Summary & greeting model', h('div', { 'data-dam-row': '', style: { flex: 1 } },
4140
- h('span', { style: { flex: 1, fontSize: 'calc(12px * var(--dam-scale))', wordBreak: 'break-all', opacity: cfg.subagentModel ? 1 : 0.6 } }, cfg.subagentModel || (locale === 'zh' ? '跟随路由默认' : 'routing default')),
4141
- h('button', { 'data-dam-btn': '', onClick: openModels }, locale === 'zh' ? '选择模型' : 'Pick model')),
4142
- locale === 'zh' ? '用于时段总结、问候语、自动沉淀等 subagent 功能;从检测到的模型中选择,或留空跟随路由默认。保存后生效。' : 'For scheduled summaries, greetings and auto-consolidation subagents; pick a detected model or leave empty for the routing default. Applies after saving.'),
4212
+ field(locale === 'zh' ? '子代理模型 / 思考强度' : 'Subagent model & reasoning effort', h('div', { 'data-dam-row': '', style: { flex: 1 } },
4213
+ h('span', { style: { flex: 1, fontSize: 'calc(12px * var(--dam-scale))', wordBreak: 'break-all', opacity: cfg.subagentModel ? 1 : 0.6 } }, (cfg.subagentModel || (locale === 'zh' ? '跟随路由默认' : 'routing default')) + (String(cfg.subagentReasoningEffort || '') ? ' · ' + String(cfg.subagentReasoningEffort) : '')),
4214
+ h('button', { 'data-dam-btn': '', onClick: openModels }, locale === 'zh' ? '选择模型 / 强度' : 'Pick model / effort')),
4215
+ locale === 'zh' ? '用于时段总结、问候语、自动沉淀、蒸馏等 subagent 功能;模型与思考强度(off/low/high/max)都经 DSH 的 agentOptions 下发,留空跟随路由与模型默认。保存后生效。' : 'For scheduled summaries, greetings, auto-consolidation and distillation subagents; model and reasoning effort (off/low/high/max) are delivered via DSH agentOptions. Empty follows routing and model defaults. Applies after saving.'),
4143
4216
  mdlOpen ? buildModelDrawer() : null,
4144
4217
  ]),
4145
4218
  section('context', sectionLabels.context, [field(t('fHandoff'), h('input', { type: 'checkbox', checked: cfg.handoffEnabled !== false, onChange: function (e) { set('handoffEnabled', e.target.checked) } }), t('fHandoffHint')),
@@ -29,6 +29,10 @@ import { fuseD6Pre } from './semantic-js.js'
29
29
  import { buildSourceCatalog, loadCorpusSnapshot, CorpusRegistry, canonicalize } from './m4-corpus.js'
30
30
 
31
31
  const MAX_DROPS_RING = 64
32
+ /** 同一 drop 原因的日志限流窗口(ms):窗口内只写首条,其余计数。防会话回放刷屏(2026-09-10)。 */
33
+ const DROP_LOG_WINDOW_MS = 30000
34
+ const dropLoggedAt = new Map()
35
+ const dropLoggedSkip = new Map()
32
36
 
33
37
  /** M7.5 诊断:ctx-host drop/skip 直写 harness diagnose 日志(此前 try{diag()} 静默吞 ReferenceError)。 */
34
38
  function diagCtx(msg) {
@@ -163,10 +167,24 @@ export function createContextHost(opts = {}) {
163
167
  if (!st) { st = { lastPushedContextVersion: -1, disposed: false }; states.set(runtime, st) }
164
168
  return st
165
169
  }
166
- function pushDrop(reason, contextVersion) {
170
+ function pushDrop(reason, contextVersion, runtimeKey) {
167
171
  volatileDrops.push({ at: Date.now(), reason, contextVersion })
168
172
  if (volatileDrops.length > MAX_DROPS_RING) volatileDrops.shift()
169
- diagCtx('ctx-host drop: ' + reason + ' cv=' + contextVersion)
173
+ // 回放风暴限流(2026-09-10):DSH 载入/观测一个历史会话时会对每一段历史都调一次 accept,
174
+ // 而该 runtime 从未 capturePaths → 同一原因一次性刷出上百行(实测单次回放 288 行,占诊断日志近半)。
175
+ // 同因日志按时间窗限流:窗口内只写首条,首条附 runtime key(定位是哪个会话),其余仅计数。
176
+ const _k = String(reason)
177
+ const _now = Date.now()
178
+ if (_now - (dropLoggedAt.get(_k) || 0) < DROP_LOG_WINDOW_MS) {
179
+ dropLoggedSkip.set(_k, (dropLoggedSkip.get(_k) || 0) + 1)
180
+ return
181
+ }
182
+ dropLoggedAt.set(_k, _now)
183
+ const _skip = dropLoggedSkip.get(_k) || 0
184
+ dropLoggedSkip.set(_k, 0)
185
+ diagCtx('ctx-host drop: ' + reason + ' cv=' + contextVersion
186
+ + (runtimeKey ? ' key=' + String(runtimeKey).slice(0, 48) : '')
187
+ + (_skip ? ' (+' + _skip + ' suppressed in ' + (DROP_LOG_WINDOW_MS / 1000) + 's)' : ''))
170
188
  }
171
189
  function capturePaths(runtimeKey, p) {
172
190
  pathsByKey.set(String(runtimeKey || ''), {
@@ -290,7 +308,7 @@ export function createContextHost(opts = {}) {
290
308
  // M7-8 live-parity 诊断:记录最近 Segment 的 runtime key/sessionRef(最小投影)
291
309
  lastSegmentRuntimeKey = String(runtime.key || '')
292
310
  lastSegmentSessionRef = String(runtime.sessionId || '').slice(0, 24)
293
- if (!paths) { pushDrop('no-paths-captured', seg.contextVersion); return }
311
+ if (!paths) { pushDrop('no-paths-captured', seg.contextVersion, runtime.key); return }
294
312
  const corpusSnap = loadCorpus(paths)
295
313
  // ---- cite / correction(user+assistant 可见文本;precision-first,需完整 token+provenance)----
296
314
  if ((seg.kind === 'user' || seg.kind === 'assistant') && seg.text && corpusSnap && corpusSnap.records.length) {
@@ -331,7 +349,7 @@ export function createContextHost(opts = {}) {
331
349
  void Promise.all([readyPromise, c2RankPromise]).then(([readyRes, rank]) => {
332
350
  if (!(readyRes && readyRes.ready)) {
333
351
  stats.pushesRejected++
334
- pushDrop('index-not-ready:' + ((readyRes && readyRes.reason) || 'unknown'), runtime.contextVersion)
352
+ pushDrop('index-not-ready:' + ((readyRes && readyRes.reason) || 'unknown'), runtime.contextVersion, runtime.key)
335
353
  return
336
354
  }
337
355
  if (st.disposed || runtime.disposed) return