diff --git a/dsh-server-docs/scripts/bash-output-guard.py b/dsh-server-docs/scripts/bash-output-guard.py index 68a23ba..7fa7747 100644 --- a/dsh-server-docs/scripts/bash-output-guard.py +++ b/dsh-server-docs/scripts/bash-output-guard.py @@ -152,6 +152,79 @@ def segments(cmd): return [s.strip() for s in re.split(r'(?:&&|\|\||;|\n|\|)', cmd) if s.strip()] +# ── 批量活检测(2026-09-15 用户选 A)───────────────────────────── +# 要打断的是:**"一句话需求 → 上百次工具调用"** —— 每次调用都把「命令 + 结果」永久留在上下文里。 +# 实测(会话 7057685c):逐处改注释 ⇒ Edit 142 次、痕迹 13.4 万字符;若改用一个脚本 ⇒ 约 8 千字符(省 94%)。 +# 实现:`PreToolUse` 里给 Edit/Write/Bash 记账;到档位且**同质** ⇒ deny 一刀并给出替代做法(我改路子)。 +BATCH_N = 20 +BATCH_TOOLS = ('Edit', 'Write', 'Bash') + + +def _batch_state(root): + p = os.path.join(root, '.workbuddy', 'batch-guard.json') + try: + d = json.load(io.open(p, encoding='utf-8')) + except Exception: + d = {} + return p, d + + +def _homogeneous(tool, st): + """同质性:Edit ⇒ 最近 20 次里同一文件出现 ≥3 次(= 同一文件反复改);Bash ⇒ 命令都很短。""" + if tool == 'Edit': + paths = st.get('paths') or [] + if not paths: + return False + c = {} + for x in paths[-20:]: + c[x] = c.get(x, 0) + 1 + return max(c.values()) >= 3 + if tool == 'Bash': + lens = st.get('lens') or [] + return bool(lens) and (sum(lens[-20:]) / max(len(lens[-20:]), 1)) < 300 + return True + + +def batch_check(root, payload, log_fn): + """到档位 + 同质 ⇒ 返回提示串(并记账,每档只提示一次);否则 ''。""" + sid = str(payload.get('session_id') or '') + tool = payload.get('tool_name') or '' + if not sid or tool not in BATCH_TOOLS: + return '' + p, d = _batch_state(root) + st = d.setdefault(sid, {}) + st[tool] = int(st.get(tool, 0)) + 1 + n = st[tool] + ti = payload.get('tool_input') or {} + if tool == 'Edit': + fp = str(ti.get('file_path') or '') + if fp: + st['paths'] = (st.get('paths') or [])[-40:] + [fp] + elif tool == 'Bash': + st['lens'] = (st.get('lens') or [])[-40:] + [len(str(ti.get('command') or ''))] + fired = st.setdefault('fired', []) + hit = '' + if n in (BATCH_N, BATCH_N * 2, BATCH_N * 4) and ('%s@%d' % (tool, n)) not in fired and _homogeneous(tool, st): + fired.append('%s@%d' % (tool, n)) + files = len(set(st.get('paths') or [])) + hit = ( + '🛠【批量活提示】本会话 `%s` 已调用 **%d 次**%s ⇒ 这更像**批量活**。\n' + '✅ 改用**一个脚本一次做完**,只回「改了 N 处 / 失败 M 处」—— **别逐处调用**:' + '逐次调用会把 N 份「命令 + 结果」**永久留在会话上下文里、之后每轮全量重发**。\n' + 'ℹ️ 实测参照:某会话逐处改 142 次 ⇒ 痕迹 **13.4 万字符**;改用一个脚本 ⇒ 约 **8 千字符(省 94%%)**。' + '改完**统一验一次**即可,不必改一处验一处。' + % (tool, n, ('(改到的文件只有 %d 个)' % files) if (tool == 'Edit' and files) else '')) + log_fn(str(root), 'DENY|批量活提示 %s@%d' % (tool, n)) + try: + if len(d) > 30: + for k in list(d)[:-30]: + d.pop(k, None) + io.open(p, 'w', encoding='utf-8', newline='\n').write(json.dumps(d, ensure_ascii=False)) + except Exception: + pass + return hit + + def reason_for(cmd, hard=False): segs = segments(cmd) for core, pat, why, fix in RULES: @@ -186,7 +259,7 @@ def main(): if (payload.get('hook_event_name') or '') != 'PreToolUse': return tool = payload.get('tool_name') or '' - if tool not in ('Bash', 'Read'): # 覆盖**所有**会灌大输出的工具(2026-09-15 由用户点出补 Read) + if tool not in ('Bash', 'Read', 'Edit', 'Write'): # Bash/Read=拦大输出;Edit/Write=批量活记账 return if os.environ.get('DSH_OUTPUT_GUARD_OFF'): return @@ -195,6 +268,13 @@ def main(): return except Exception: pass + if tool in ('Edit', 'Write'): # 批量活检测:deny 一刀,换掉"上百次逐处调用" + chk = batch_check(str(root), payload, log) + if chk: + _emit({'hookSpecificOutput': {'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': chk}}) + return if tool == 'Read': if read_too_big(payload): fp = str(((payload.get('tool_input') or {}).get('file_path')) or '') diff --git a/dsh-server-docs/scripts/lock-guard-hook.py b/dsh-server-docs/scripts/lock-guard-hook.py index de6f0a4..f4c4b98 100644 --- a/dsh-server-docs/scripts/lock-guard-hook.py +++ b/dsh-server-docs/scripts/lock-guard-hook.py @@ -78,8 +78,26 @@ def lock_owner() -> str: def out(obj: dict) -> None: - sys.stdout.write(json.dumps(obj, ensure_ascii=False)) - sys.stdout.flush() + # ⚠️ 2026-09-16 修复(**根因**):必须走 **`sys.stdout.buffer` 写 bytes**,⛔ 不能走文本层 + # (`sys.stdout.write(str)`)。 + # 实证:宿主 spawn 本脚本时 stdout 编码**不保证是 UTF-8**(本机 `PYTHONUTF8=1` 在 hook 环境下 + # 不保证生效),而 deny 文案里含 `⛔` / `🔐` 等字符 ⇒ 文本层写抛 `UnicodeEncodeError` ⇒ + # 异常冒泡、**stdout 为空** ⇒ 宿主**收不到 deny** ⇒ **静默放行**。 + # 而 `hook_log()` 已经把 `PreToolUse-deny` 写进日志 ⇒ 表现成 **「日志里有 DENY,但拦不住」** + # (2026-09-16 受控自测:无锁时 Write 文档库 ⇒ 文件真被创建)。 + # 对照:`bash-output-guard.py` 用 `sys.stdout.buffer.write(bytes)` ⇒ **实测拦得住**。 + # 📌 教训:本脚本的 `stdin` 早前已按 buffer 加固(见 main 顶部注释),**stdout 漏了** —— + # 「读写要同时加固」,只修一半等于没修。 + data = json.dumps(obj, ensure_ascii=False).encode('utf-8') + try: + sys.stdout.buffer.write(data) + sys.stdout.buffer.flush() + except Exception: + try: + sys.stdout.write(data.decode('utf-8', 'replace')) + sys.stdout.flush() + except Exception: + pass LOG_FILE = os.environ.get( @@ -120,6 +138,15 @@ def main() -> None: tool = payload.get("tool_name") event = payload.get("hook_event_name") or payload.get("hook_event") or "" + # ★ 入口即留痕(2026-09-16 加)—— 与 stop-dialog-guard.py / skill-load-guard.py 同款。 + # 为什么不只记 SessionStart 与 deny:2026-09-16 实测 —— 重启后本钩子**一行都没写**, + # 当时无法区分"没被宿主调用"与"被静默放行",只能靠猜(最后查明:SessionStart 的 + # `matcher: "startup"` 不匹配"恢复会话",钩子压根没被执行)。 + # 留痕里带上**实际收到的字段名**(keys):下次宿主字段一变,日志里立刻能看出来, + # 而不是等某天发现守卫早已失效。低频:每次调用一行。 + hook_log("entry", "event=%s|tool=%s|keys=%s" % ( + event or "?", tool or "-", ",".join(sorted(payload.keys()))[:220])) + # ── SessionStart:只提示(该事件的输出只给用户看,不会进 Agent 上下文)── if event == "SessionStart" or (tool is None and "source" in payload): src = payload.get("source", "?") diff --git a/dsh-server-docs/scripts/skill-load-guard.py b/dsh-server-docs/scripts/skill-load-guard.py new file mode 100644 index 0000000..3c9f50e --- /dev/null +++ b/dsh-server-docs/scripts/skill-load-guard.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""skill-load-guard.py —— 「用户点名了方法 ⇒ 强制加载技能」的 UserPromptSubmit 钩子 + +为什么需要它 +──────────── +2026-09-16 复盘会话 `ddea70b7`(「确认guest用户数据迁移到106服务器」): +用户 U6 明确说「D1–D6 先做哪些 **按照你的规划执行**,中间有问题**参考决策方法**」, +但该会话 265 次工具调用里 **`Skill` 计数 = 0** —— 那份方法论**从未进入上下文**。 +AI 仍按常驻判据把两个**非门禁**问题("106 旧控制面要不要停" / "D1–D6 先做哪些")上抛给用户, +而用户随后 45 秒 / 58 秒内自己给出了答案(Q1 的答案还超出了 AI 给的选项空间)。 + +⇒ 与 `stop-dialog-guard.py` 同族问题:**规则写了,但没有任何机制保证它在正确的时机被取用**。 + (那个钩子处理"回复收尾时的征询句",本钩子处理"用户点名时的方法加载",两者互补。) + +本钩子 = 覆盖"用户点名"这条面:**每次用户提交时**扫输入,命中点名关键词 ⇒ +通过 `additionalContext` 注入一条**紧邻用户消息**的强制指令 +(位置比静态规则文件显著得多 —— 长会话里 CODEBUDDY.md 的硬要求容易被"稀释")。 + +安全设计(都不许省) +──────────────────── +1. **自作用域**:只在 cwd 落在本工作区(`aliyun-dsh-server`)时生效,其他项目一律放行。 +2. **绝不添乱**:任何异常 → 静默放行(exit 0);本钩子**从不拦用户**,只在命中时**追加一段提醒**。 +3. **防跑飞**:同一会话 300 秒内最多注入一次。 +4. **急停双闸**(无需卸载/重启):env `DSH_SKILL_GUARD_OFF=1`,或 `<工作区>/.workbuddy/skill-guard.disabled`。 +5. **低频自证日志**:命中才写一行到 `<工作区>/.workbuddy/skill-load-guard.log` —— 用来回答"到底有没有触发"。 +6. **显式 UTF-8**:读写都走 `buffer`,不依赖 `PYTHONUTF8`(`-E` 会屏蔽它 ⇒ cp936 ⇒ 含中文静默炸)。 + +退出码:始终 0;决策通过 stdout 的 JSON 表达。 +安装(`settings.json` 的 hooks 段 · 与既有钩子同族,**并列挂在同一个 UserPromptSubmit 的 hooks 数组里**): + "UserPromptSubmit": [{ "hooks": [ + { "type": "command", "command": "\"\" \"<此脚本>\"", "timeout": 10 }, + { "type": "command", "command": "\"\" -S -E \"\"", "timeout": 10 } + ]}] +⚠️ hooks 是**应用启动时快照** ⇒ 装完必须**完全重启 WorkBuddy**(关窗 ≠ 退出)才加载。 +""" +import io +import json +import os +import re +import sys +import time + +SCOPE = 'aliyun-dsh-server' # 只对本工作区生效 +LOG_REL = os.path.join('.workbuddy', 'skill-load-guard.log') +STATE_REL = os.path.join('.workbuddy', '.skill-load-guard.state') +DISABLE_REL = os.path.join('.workbuddy', 'skill-guard.disabled') +COOLDOWN = 300 # 同会话注入冷却(秒) + +# 用户「点名方法」的词表 —— 命中任一即注入。 +# 「决策方法」是最稳的一条(覆盖"参考/按/用决策方法")。 +TRIGGERS = ( + '决策方法', + '自行决策', '自主决策', '自己决策', '自己拿主意', + '别问我', '不要问我', '不用问我', '按你的规划', '按你的判断', + '参考决策', '决策方法论', +) + + +def _read_stdin(): + try: + raw = sys.stdin.buffer.read() + except Exception: + return {} + if not raw: + return {} + for enc in ('utf-8', 'utf-8-sig', 'gbk'): + try: + return json.loads(raw.decode(enc)) + except Exception: + continue + return {} + + +def _emit(obj): + try: + sys.stdout.buffer.write(json.dumps(obj, ensure_ascii=False).encode('utf-8')) + sys.stdout.buffer.flush() + except Exception: + pass + + +def _workdir(obj): + for k in ('cwd', 'project_dir', 'workspace', 'projectDir'): + v = obj.get(k) + if isinstance(v, str) and v: + return v + for k in ('CODEBUDDY_PROJECT_DIR', 'WORKSPACE_FOLDER', 'PWD'): + v = os.environ.get(k) + if v: + return v + # 兜底:本脚本位于 <工作区>/dsh-server-docs/scripts/ + try: + return os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..')) + except Exception: + return '' + + +def _log(workdir, line): + try: + p = os.path.join(workdir, LOG_REL) + with io.open(p, 'a', encoding='utf-8', newline='\n') as f: + f.write('%s %s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), line)) + except Exception: + pass + + +def _cooling(workdir, sid): + """同一会话 COOLDOWN 秒内不重复注入。""" + try: + p = os.path.join(workdir, STATE_REL) + now = time.time() + last_sid, last_ts = '', 0.0 + if os.path.exists(p): + try: + with io.open(p, 'r', encoding='utf-8') as f: + parts = f.read().strip().split('\t') + last_sid, last_ts = parts[0], float(parts[1] or 0) + except Exception: + pass + if last_sid == sid and (now - last_ts) < COOLDOWN: + return True + with io.open(p, 'w', encoding='utf-8', newline='\n') as f: + f.write('%s\t%.3f\n' % (sid, now)) + except Exception: + pass + return False + + +def main(): + if os.environ.get('DSH_SKILL_GUARD_OFF') == '1': + return + obj = _read_stdin() + if not obj: + return + + workdir = _workdir(obj) + if not workdir or SCOPE not in workdir: + return # 自作用域:别的工作区一律放行 + if os.path.exists(os.path.join(workdir, DISABLE_REL)): + return + + prompt = '' + for k in ('prompt', 'user_prompt', 'userPrompt', 'message', 'text'): + v = obj.get(k) + if isinstance(v, str) and v.strip(): + prompt = v + break + + sid = str(obj.get('session_id') or obj.get('sessionId') or 'unknown') + # ★ 入口即留痕(2026-09-16 加)—— 否则"没命中就没日志",永远无法回答 + # "它到底有没有被宿主调用"。与 stop-dialog-guard.py 同款设计;每次用户提交一行。 + _log(workdir, 'entry|event=UserPromptSubmit|in_scope=True|sid=%s|prompt_len=%d' + % (sid[:8], len(prompt))) + if not prompt: + return + + hits = [w for w in TRIGGERS if w in prompt] + if not hits: + return + + if _cooling(workdir, sid): + return + + seg = re.sub(r'\s+', ' ', prompt) + pos = min(seg.find(w) for w in hits) + excerpt = seg[max(0, pos - 40): pos + 80].strip() + + ctx = ( + '【技能加载闸门 · 机制层强制】检测到用户本轮**点名了方法**(命中:%s)。\n' + '原文片段:「…%s…」\n\n' + '⛔ **不要凭记忆代替、也不要以"我已经知道判据"为由跳过**:\n' + ' **先调用 Skill 工具加载 `dsh-decision-method`**(决策方法论 · 含 §4.5 规则冲突裁决顺序 + 上抛前三问),\n' + ' 然后才开始作答。若本轮涉及功能需求判类型 / 该不该上抛,**同时加载 `dsh-feature-first`**。\n\n' + '为什么强制:2026-09-16 实测(会话 ddea70b7)—— 用户点名「参考决策方法」后,' + 'AI 全程 `Skill` 调用 **0 次**,仍按旧判据把两个**非门禁**问题上抛给用户,' + '用户 45 / 58 秒后自己给出了答案。**规则写在文件里 ≠ 会在正确的时机被取用。**\n' + '(若判定本轮确实与决策无关,可在作答中一句话说明后继续 —— 但**不要静默跳过加载**。)' + ) % ('、'.join(hits), excerpt) + + _emit({'hookSpecificOutput': { + 'hookEventName': 'UserPromptSubmit', + 'additionalContext': ctx, + }}) + _log(workdir, 'HIT sid=%s hits=%s' % (sid[:8], ','.join(hits))) + + +if __name__ == '__main__': + try: + main() + except Exception: + pass + sys.exit(0) diff --git a/dsh-server-docs/scripts/stop-dialog-guard.py b/dsh-server-docs/scripts/stop-dialog-guard.py index 9cb5a75..aa396c4 100644 --- a/dsh-server-docs/scripts/stop-dialog-guard.py +++ b/dsh-server-docs/scripts/stop-dialog-guard.py @@ -245,14 +245,77 @@ CONTEXT = ( # ───────────────────────────────────────────────────────────── -# 会话预算(2026-09-15 用户选 A 案):到 ~15 万 token / ~120 次工具调用 ⇒ 提醒开新会话 +# 会话预算(2026-09-15 用户选 A 案 · 提交 03c8363):到 **12 万 token / 80 次工具调用** ⇒ 提醒开新会话 +# ⚠️ 2026-09-16 更正:本行原写「~15 万 token / ~120 次工具调用」,与代码值(`BUDGET_TOKENS=120000` / +# `BUDGET_TOOLS=80`)**不符** —— 提交 `03c8363` 的 message 里就写的是 `120000/80`,代码从未用过 15 万/120 +# ⇒ 判定为注释笔误,已按代码更正(避免后人据注释误判告警点)。 +# 数值本身由 AI 在实现层自定(属 §1「性能与资源调参」= 边界内);**方案(A 案四招)才是用户 09-15 拍板的**。 # 为什么加进本脚本、而不新装一个 hook:`settings.json` 的 hooks 是**应用启动时快照**(新增条目要重启), # 而**脚本内容每次调用现读** ⇒ 改这里即刻生效。数据源 = 转录 `type=function_call` → `message.usage.input_tokens` # (最近一条 usage 即"当前上下文体量",精确,不靠估算)。 # 依据(实测某会话):上下文 5.2 万 → 59.2 万;累计 input 1.93 亿 / output 51.9 万(**371:1**); # 其中 33 次缓存失效,每次都把 ~50 万 token **按全价**重算 ⇒ 会话越长,单次失效越贵。 -BUDGET_TOKENS = 120000 +# ★ 2026-09-16 改为**分级 + 去重**(原为单一阈值 120000/80,超了之后每轮都报 ⇒ 变成"狼来了") +# 设计要点:**同一级别只报一次** ⇒ 跨级才再提醒,既不麻木也不失警。 +BUDGET_TOKENS = 120000 # 一级:轻提示(保留原名,兼容既有引用) +BUDGET_STRONG = 200000 # 二级:建议收口 +BUDGET_FORCE = 300000 # 三级:**强制收口**(先落盘、出接续包,再开新会话) BUDGET_TOOLS = 80 +ALERT_LEVEL_REL = os.path.join('.workbuddy', '.budget-alert-level') + +# 为什么三级不是"自动开新会话"(2026-09-16 与用户讨论后定): +# ① **技术上做不到** —— hook 只有 `additionalContext`(注入)与 `permissionDecision`(拦工具)两种输出, +# 事件只有 SessionStart / PreToolUse / UserPromptSubmit / Stop,**没有"创建/切换会话"的能力**; +# AI 自身也只能在会话内行动。⇒ "自动开"这一半无法实现。 +# ② **设计上不该做** —— 新会话 = 上下文清零 ⇒ **在途状态全丢**;而"该带走什么"只有 AI 判断得了 +# ⇒ 顺序必须是 **先收口(落盘 + 接续包)→ 再由用户开新会话**,反了就是"突然失忆"。 +# ③ 因此本级**不做"自动开"**,做**"强制收口"**:把状态固化成文档,让下一个会话能无损接上。 +LV_PREFIX = { + 1: '', + 2: '⚠️ 上下文已过 **20 万**,本轮收口后建议开新会话。\n', + 3: ('🔴 **已过 30 万 ⇒ 进入强制收口模式**(先落盘、再接续):\n' + ' ① 把在途状态写进 `.workbuddy/memory/`(今日日志 + 必要的 MEMORY.md 条目);\n' + ' ② 产出**接续包**:目标 / 已完成 / 在途 / 未完成 / 下一步 / 关键决定 / 回滚点;\n' + ' ③ 然后明确告知用户「请开新会话,接续点在 X」,**由用户开**(钩子无法自动创建会话)。\n' + ' ⛔ 不要在本会话继续开新任务 —— 每多跑一轮,成本按当前水位线性放大。\n'), +} + + +def _alert_level(tokens, ncalls): + """0=未超 1=轻 2=建议收口 3=强制收口""" + t = tokens or 0 + if t >= BUDGET_FORCE: + return 3 + if t >= BUDGET_STRONG: + return 2 + if t >= BUDGET_TOKENS: + return 1 + if ncalls is not None and ncalls >= BUDGET_TOOLS: + return 1 + return 0 + + +def _last_level(root, sid): + """读「本会话」已报到的级别。**按 sid 区分** —— 否则新会话会被上一会话的状态挡住 ⇒ **漏报**。""" + try: + with io.open(os.path.join(root, ALERT_LEVEL_REL), encoding='utf-8') as f: + s = (f.read() or '').strip() + ps = s.split('\t') + if len(ps) == 2 and ps[0] == sid: + return int(ps[1] or 0) + except Exception: + pass + return 0 + + +def _set_level(root, sid, lv): + try: + p = os.path.join(root, ALERT_LEVEL_REL) + os.makedirs(os.path.dirname(p), exist_ok=True) + with io.open(p, 'w', encoding='utf-8', newline='\n') as f: + f.write('%s\t%d' % (sid, lv)) + except Exception: + pass def session_budget(path): @@ -286,8 +349,15 @@ def session_budget(path): return last_in, n_calls, prev_in -def budget_note(tokens, ncalls, prev=None): - """超预算 ⇒ 返回注入用的提醒串;未超 ⇒ 但增量异常也提醒(<1 万 token 的增量不打扰)。""" +def budget_note(tokens, ncalls, prev=None, root='', sid=''): + """分级提醒;**同一级别只注入一次**(跨级才再报)—— 治"超阈值后每轮都报"导致的麻木。 + 未超阈值时,单轮增量异常(≥4 万 token)仍提醒。""" + # ★ 分级 + 去重(2026-09-16):本级已报过 ⇒ 静默(**按会话 id 区分**,新会话重新计数) + lv = _alert_level(tokens, ncalls) + if lv and lv <= _last_level(root, sid): + return '' + if lv: + _set_level(root, sid, lv) over_t = tokens is not None and tokens >= BUDGET_TOKENS over_n = ncalls is not None and ncalls >= BUDGET_TOOLS grew = (tokens is not None and prev is not None and tokens - prev >= 40000) if not (over_t or over_n) else False @@ -296,7 +366,8 @@ def budget_note(tokens, ncalls, prev=None): which = ('上下文 %s token' % tokens) if over_t else ( '工具调用 %s 次' % ncalls) if over_n else ('**上一轮新增 %s token**' % (tokens - prev)) return ( - '💰 【会话预算告警】本会话已到 **%s**(阈值 %s token / %s 次工具调用)。' + LV_PREFIX.get(lv, '') + + '💰 【会话预算告警】本会话已到 **%s**(一级阈值 %s token / %s 次工具调用)。' '代价机制(2026-09-15 实测):对话历史是**追加式**的 —— 一次工具调用的输出(`function_call_result`)' '**永久留在历史里、每轮全量重发**(某会话 553 轮 × 平均 42 万 = **2.33 亿 input**,output 仅 0.35%%)。' '**处置:本轮收口后开新会话**(新会话起点约 5 万 ⇒ 每轮降到 1/12);' @@ -404,13 +475,17 @@ def user_prompt_mode(payload): tail = tail_lines(text, 1) if text else '' hit = bool(tail) and not RE_QUOTE.search(tail) and bool(RE_BAN.search(tail)) toks, ncalls, prev = session_budget(tp) - note = budget_note(toks, ncalls, prev) + note = budget_note(toks, ncalls, prev, root0 or '.', str(payload.get('session_id') or '')) gh = guard_health(root0 or '.') ph = path_health(root0 or '.') log(root0 or '.', 'invoked(user-prompt)|mode=%s|上轮收尾=征询句:%s|上下文=%s tok(+%s)|工具=%s 次|预算告警=%s|门禁自检=%s|路径自检=%s|%s' % (mode, hit, toks, (toks - prev) if (toks and prev) else '-', ncalls, bool(note), bool(gh), bool(ph), (tail.replace('\n', ' ')[:60] if tail else '(取不到上一轮文本)'))) - if mode == 'inject' and (hit or note or gh or ph): + # ⚠️ 2026-09-15 用户选 C:**取消**「会话预算」注入 —— 当时的症结是"每轮都报、太吵"。 + # ★ 2026-09-16 恢复**分级注入**:`budget_note` 已改为「分级 + 同会话跨级才报一次」(见其 docstring), + # 噪音症结已消解;且用户当日提出「上下文超 30 万要不要自动收口」⇒ + # **AI 必须先知道水位才谈得上收口** ⇒ 恢复注入(一级静默无关、二级建议、三级强制收口)。 + if mode == 'inject' and (hit or gh or ph or note): ctx = '' for seg in (CONTEXT if hit else '', note, gh, ph): if seg: