Files
admin 19101acd65 init: workbuddy_skills 重建,仅收录 session-mechanism
- 按用户指示清空原有 25 技能内容,只提交 session-mechanism(57 文件)
- 附 .gitignore(产物 + 本机凭据)
- 令牌明文已脱敏(历史 .neodata_token 与 pitfalls 引用均不入库)
- 本提交为孤儿提交(父提交为空),历史自此重新开始
2026-10-05 14:13:24 +08:00

454 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""session-log-guard.py —— 「会话诊断日志 · 事前叫停」钩子(WorkBuddy / CodeBuddy)
═══ 为什么需要它(2026-10-01 取证 · 结论见 接续包_日志事前叫停_20261001.md)═══
宿主把每次工具调用写成 ≈145 帧 = ≈37 KB 诊断日志(零去重零节流,**其中 97.8% 是零信息唤醒行**)⇒
`10 MiB ÷ 37 KB ≈ 280 次调用` 就是一个会话的**物理寿命**;撞上 10 MiB 上限即 **拒写 ⇒
界面静默哑掉、用户零感知**。而全平台**只做了事后拦截**(`collabd.py::_deaf_sids()` 管"哑了不许再投"),
**没有任何机制在"快哑了"时叫停** —— 本钩子=补这道**事前**闸。
🔴 但请注意**病根不在"每次调用贵",而在"调用次数多"**:2026-10-01 会话 `ee3c2d82` 自证 ——
8 分钟内跑了 **166 次调用(≈20 次/分,是参考会话 10.5 次/分的 2 倍)** ⇒ 日志 8 分钟就 5.9 MB;
而其后**空闲 34 分钟写 0 行**(坐实"增长只与工具在跑成正比、与挂钟时间无关")。
⚠️ 本钩子**只叫停、不降速**:真正降速要**压调用次数** + 上报宿主(那 97.8% 本就不该写)。
═══ 它只做一件事 ═══
只读**本会话诊断日志的字节数**(`os.path.getsize`,⛔ **绝不 grep 日志内容** —— 否则判定本身拖慢会话)。
达档 ⇒ 通过 `hookSpecificOutput.additionalContext` 注入一段提醒;未达档 ⇒ **零输出**。
🟡 软档 ≥ 5 MiB ⇒ 开始写交接材料
🔴 硬档 ≥ 8 MiB ⇒ **先试一次「就地回收」**;成功 ⇒ **不必停手**(会话继续跑)
|失败 ⇒ 退回原口径:立刻停手 + 按 `CODEBUDDY.md §G` 建接续会话
🔴 **「就地回收」是什么、依据什么**(2026-10-01 实测,推翻了当天更早的相反结论):
把**本会话自己的** `<sid>.log` 改名为 `<sid>.log.recycled-<时间戳>`(⛔ 改名、**不删**)⇒
宿主立刻**重建**同名新文件并**继续写入**(实测:改名后紧邻的下一次读数里原文件名已回来,
文件冻结在旧尺寸)⇒ 会话因此**不必再因撞 10 MiB 而停手**。
⚠️ 对照:当天更早的 scratch 复现(子进程持续 append、父进程在写入进行中改名 ⇒ `WinError 32`)
只证明了**"正在被写的那一瞬"**不可改名;**本次实测在真实会话里一次成功** ⇒ 宿主并非"句柄常开",
而是**每次日志批写开-写-关** ⇒ 工具调用的间隙里句柄是**关着的**。
⇒ 判语:**能回收就回收(会话不被截断),回收不了才叫停(fail-safe 方向=退回旧行为)**。
⛔ 只试**一次**,失败立即退回叫停口径 —— ⛔ **不做重试式硬撞**。
⛔ 只动**本会话自己那一份**(按 `session_id` 精确拼名),⛔ 绝不碰别人的日志。
开关:`DSH_SLG_NO_RECYCLE=1` ⇒ 只关回收、**保留**软/硬档叫停。
⛔ **不重复造轮子**:上下文 token / 工具调用次数的分级+去重+注入**已由 `stop-dialog-guard.py` 覆盖**
(其 `session_budget()` / `budget_note()`)。本脚本的**真实增量只有"日志字节"这一档**,⛔ 不要再抄一份。
═══ 挂点 ═══
`PostToolUse`(每次工具调用后)+ `UserPromptSubmit`(每次用户消息到达时)。
⚠️ 为什么要两个:日志既随工具调用涨,也随每次模型输出涨 —— 会话闲下来只剩后者,单挂 PostToolUse 会漏。
═══ 硬纪律(都不许省)═══
1. 🔴 **输出必须走 `sys.stdout.buffer.write(bytes)`**。本机 python 在 hook 环境下 stdin/stdout 可能是
**cp936**(`-E` 会屏蔽 `PYTHONUTF8`)⇒ 文案里的 `⛔` / `🟡` 会 `UnicodeEncodeError`
⇒ **stdout 为空 ⇒ 宿主当"无输出"静默放行**(=钩子白装,且看不出坏在哪里)。
2. 🔴 **去抖**:同一会话**同一档位只报一次**(升级才再报)。
3. 🔴 **状态落盘用「临时文件 + `os.replace`」原子替换,⛔ 不用文件锁** ——
2026-09-30 本机实测:文件锁在隔离目录里全绿,接进生产后 `--once` 与 6 个并发 `--report`
**全部 rc=124 卡死**(`session-mechanism/scripts/collabd.py` 注释里有完整登记)。
本脚本用**乐观写法**:读→改→写临时文件→`os.replace`;并发下最坏后果=多报一条,**不会卡死**。
4. 🔴 **性能 < 50 ms**:只做 `getsize` + 一个小 JSON 的读改写;⛔ 不读转录、⛔ 不读 settings.json、
⛔ 不 grep 任何大文件。本脚本被**每次工具调用**触发 ⇒ 任何 O(日志大小) 的动作都不可接受。
5. **fail-open**:任何异常 ⇒ 静默 exit 0(钩子绝不能因自身故障干扰会话)。
6. **急停**:env `DSH_SLG_OFF=1`,或 `<工作区>/.workbuddy/session-log-guard.disabled` 存在。
7. **自证**:命中才写一行 `<工作区>/.workbuddy/session-log-guard.log`;排查期可设
`DSH_SLG_LOG_ALL=1` 让**每次调用**都留痕(用来回答"宿主到底调没调我")。
退出码始终 0;决策只通过 stdout 的 JSON 表达。
═══ 安装(`<CODEBUDDY_CONFIG_DIR>/settings.json` 的 `hooks` 段)═══
"PostToolUse": [{ "hooks": [{ "type": "command",
"command": "\"<python>\" -S \"<此脚本>\"", "timeout": 10 }] }],
"UserPromptSubmit": [ …既有条目… , { "hooks": [{ "type":"command", "command": "\"<python>\" -S \"<此脚本>\"", "timeout": 10 }] }]
🔴 **可以加 `-S`**(跳过 site 初始化 ⇒ 单次调用实测 84 ms → 52 ms;本脚本**只用标准库** ⇒ 安全)。
🔴 **⛔ 绝不可加 `-E` / `-I`** —— 它们会忽略 `PYTHONUTF8` / `PYTHONIOENCODING` ⇒ stdin/stdout 回退 **cp936**
(`stop-dialog-guard.py` docstring 里有完整事故记录:`-S -E` 曾让钩子"看起来从未被调用"整整一天)。
⚠️ 本机实测:`settings.json` 的 hooks **下一次调用即生效**(⛔ 无需重启宿主,2026-10-01 复核)。
⚠️ `07-scripts/` 改名 / 改名后必须**同批**改宿主 hooks 入口 —— 见库内 `CODEBUDDY.md`
(原写「5 条 hook 入口」,2026-10-01 起为 **7 条**:lock-guard ×2 / bash-output-guard / skill-load-guard /
stop-dialog-guard / **session-log-guard ×2**)。
"""
# [session-mechanism] roots.env 外置(由 install.py 生成;缺失则回落到按位置推导)
def _sm_load_roots():
import os as _os
_here = _os.path.dirname(_os.path.abspath(__file__))
for _up in range(4):
_p = _os.path.join(_here, *([".."] * _up), "roots.env")
_p = _os.path.normpath(_p)
if _os.path.isfile(_p):
try:
with open(_p, encoding="utf-8") as _f:
for _ln in _f:
_ln = _ln.strip()
if _ln and not _ln.startswith("#") and "=" in _ln:
_k, _v = _ln.split("=", 1)
_os.environ.setdefault(_k.strip(), _v.strip())
except Exception:
pass
return
_sm_load_roots()
import io
import json
import os
import re
import sys
import time
# 🔴 2026-10-02:环境定位/体检收敛到 `_env`(同目录优先)——⛔ 不再用 `~` 直拼
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
import _env
except Exception:
sys.stderr.write('[env] 无法导入 _env.py ⇒ 环境定位不可信\n')
raise
# ── 阈值(口径来源:`CODEBUDDY.md §G` 档 2 · 2026-10-01 用户拍板「B方案」)──────────
SOFT_BYTES = 5 * 1024 * 1024 # 5 MiB ⇒ 开始写交接材料
HARD_BYTES = 8 * 1024 * 1024 # 8 MiB ⇒ 就地回收;回收不成才停手 + 建接续会话
# 🔴 为什么不是 10 MiB:宿主上限即 10 MiB,且实测**一次交接本身烧 ≈1.9 MiB**
# ⇒ 8 MiB 已是**下限**,⛔ 不得再往上调(调到 9 以上 = 交接材料写不完就撞顶)。详见 §G。
# ♻️ 回收阈值=硬档(**同一个点**):到这一点"要么回收、要么叫停",⛔ 不另设第三个阈值
# (多一个阈值就多一条要维护的口径;且 8 MiB 已实测是"还来得及"的下限)。
RECYCLE_MIN_BYTES = HARD_BYTES
# 🔴 **验收通道**(⛔ 日常**没有这个文件** ⇒ 行为与上面两个常量完全一致)
# 为什么必须有:本钩子的真实触发条件是"日志 ≥5 MiB",而正常会话远达不到 ⇒
# 在 **⛔ 不动生产日志一字节** 的前提下,若不开口子就**无法在真实会话里验收**
# (只能本地跑 ⇒ 按交付门禁**不算交付**:宿主到底调没调我、注入通道通不通,都还是未知)。
# 形态:`<工作区>/tmp/.session-log-guard.verify.json`,例
# {"sid": "ee3c2d82", "soft_mib": 0.3, "hard_mib": 0.6}
# · `sid` ⇒ **把口子收窄到单个会话**(前缀匹配)⇒ 验收期间其它会话**零影响**,不会误伤别人。
# · 用完**立刻删**(本机 `tmp/` 收口即清);只要文件在,每次触发都会往日志写 `VERIFY-ON` 自证。
# ⚠️ 取不到 / 解析失败 / 无此文件 ⇒ 一律回落到真阈值(fail-safe 方向=按真阈值办事)。
VERIFY_REL = os.path.join('tmp', '.session-log-guard.verify.json')
def _verify(root):
try:
with io.open(os.path.join(root, VERIFY_REL), encoding='utf-8') as f:
o = json.loads(f.read() or '{}')
if isinstance(o, dict):
return o
except Exception:
pass
return {}
def _mib_env(o, key, default):
try:
v = float(o.get(key) or 0)
return int(v * 1024 * 1024) if v > 0 else default
except Exception:
return default
STATE_REL = os.path.join('tmp', '.session-log-guard.level.json')
LOG_REL = os.path.join('.workbuddy', 'session-log-guard.log')
MAX_STATE_KEYS = 40 # 状态文件只留最近 N 个会话(防无限膨胀)
# 本脚本位于 <工作区>/dsh-server-docs/07-scripts/ ⇒ 往上三级即工作区(宿主没给 cwd 时的兜底)
WS_FALLBACK = os.environ.get("DSH_WS_ROOT") or os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def _norm_path(p):
"""MSYS / Git-Bash 风格路径 → Windows 风格(`/e/foo` → `E:/foo`)。
⛔ 同源铁律:**Windows 原生 python 会把 `/e/...` 解释成「当前盘根 + 相对路径」**
⇒ 落到 `E:\\e\\...`。本工作区已有实证(E 盘根长出 `E:\\e\\` 影子目录)。
"""
try:
m = re.match(r'^/([A-Za-z])(/.*)?$', str(p or ''))
if m:
return m.group(1).upper() + ':' + (m.group(2) or '/')
except Exception:
pass
return p
def _read_stdin_text():
"""显式按 UTF-8 读 stdin(⛔ 不要用 `sys.stdin.read()`:cp936 下中文 payload 直接抛错)。"""
try:
return sys.stdin.buffer.read().decode('utf-8', 'replace')
except Exception:
try:
return sys.stdin.read()
except Exception:
return ''
def _emit(obj):
"""显式按 UTF-8 写 stdout(见模块 docstring 硬纪律 1)。"""
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
def _root(payload):
return (_norm_path(os.environ.get('CODEBUDDY_PROJECT_DIR')
or os.environ.get('DSH_WORKSPACE')
or (payload or {}).get('cwd')
or WS_FALLBACK))
def _config_dir():
"""🔴 2026-10-02:共用 `_env.config_dir()` —— ⛔ 不再直接拼 `~`
(Windows 上 `~` 通常不是真配置目录;真值顺序=env → roots.env)。"""
return _norm_path(_env.config_dir())
def _log_path(sid):
"""本会话诊断日志的绝对路径(返回 [] 表示还没落盘 / 取不到)。
真实形态(2026-10-01 实测):
<configDir>/logs/<YYYY-MM-DD>/sdk/conversations/<session_id>.log
⚠️ 跨零点:会话可能昨天开的 ⇒ 今天那份不存在时**回看昨天**(只多一次 `getsize`,可忽略)。
"""
base = os.path.join(_config_dir(), 'logs')
out = []
now = time.time()
for back in (0, 1):
d = time.strftime('%Y-%m-%d', time.localtime(now - back * 86400))
p = os.path.join(base, d, 'sdk', 'conversations', '%s.log' % sid)
try:
os.path.getsize(p) # 🔴 只取**字节数**;⛔ 绝不打开/搜内容
except OSError:
continue
out.append(p)
return out
def _level_of(size, soft=SOFT_BYTES, hard=HARD_BYTES):
if size >= hard:
return 2
if size >= soft:
return 1
return 0
def _read_state(root):
try:
with io.open(os.path.join(root, STATE_REL), encoding='utf-8') as f:
d = json.loads(f.read() or '{}')
return d if isinstance(d, dict) else {}
except Exception:
return {}
def _write_state(root, st):
"""临时文件 + `os.replace` 原子替换(⛔ 不用文件锁 ⇒ 见 docstring 硬纪律 3)。"""
p = os.path.join(root, STATE_REL)
d = os.path.dirname(p)
try:
os.makedirs(d, exist_ok=True)
tmp = '%s.tmp.%d' % (p, os.getpid())
with io.open(tmp, 'w', encoding='utf-8', newline='\n') as f:
f.write(json.dumps(st, ensure_ascii=False))
os.replace(tmp, p)
except Exception:
pass
def _log(root, detail):
try:
p = os.path.join(_norm_path(root), LOG_REL)
os.makedirs(os.path.dirname(p), exist_ok=True)
with io.open(p, 'a', encoding='utf-8') as f:
f.write('%s\t%s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), detail))
lines = io.open(p, encoding='utf-8').read().split('\n') # 上限 300 行,超出截半
if len(lines) > 300:
io.open(p, 'w', encoding='utf-8', newline='\n').write('\n'.join(lines[-150:]))
except Exception:
pass
def _mib(n):
return '%.2f MiB' % (n / 1048576.0)
def _recycle(paths, min_bytes=RECYCLE_MIN_BYTES):
"""♻️ 就地回收**本会话自己**的日志:改名挪开,让宿主重建。
返回 `(源路径, 目标路径, 错误串)`;未达到回收条件或失败 ⇒ 源路径为 `None`。
🔴 只试**一次**:`os.rename` 抛 `OSError`(典型 `WinError 32`=文件被占用)就**立刻返回**,
⛔ 不 sleep、⛔ 不重试、⛔ 不降级成"删/截断"(那会毁掉唯一的取证载体)。
⚠️ 后缀**刻意不以 `.log` 结尾** ⇒ 与 `wb-logcap-sweep.py` 的 `*.log` 匹配**天然不重叠**
(清扫器不会二次搬运本文件)。
"""
best, bestsz = None, -1
for p in paths: # `_log_path` 已按 sid 精确拼名 ⇒ 这里只会是本会话自己的
try:
s = os.path.getsize(p)
except OSError:
continue
if s > bestsz:
best, bestsz = p, s
if not best or bestsz < int(min_bytes):
return None, None, 'below-threshold'
dst = '%s.recycled-%s' % (best, time.strftime('%Y%m%d-%H%M%S'))
try:
os.rename(best, dst)
except OSError as e:
return None, None, '%s(errno=%s, winerror=%s)' % (type(e).__name__, e.errno,
getattr(e, 'winerror', None))
return best, dst, ''
def _recycle_note(size, src, dst, hard=HARD_BYTES):
"""回收**成功**时注入的提醒(⛔ 与"叫停"是两套口径:这套说的是"可以继续")。
⚠️ `hard` 取**实际生效值**(⛔ 不写死常量)⇒ 验收通道把阈值调小时,提示里的数字跟着对。
"""
return ('♻️【日志就地回收 · 已复位】本会话诊断日志已达 **%s**(硬档 %s)⇒ 已**当场回收**:'
'把 `%s` 改名为 `%s`(⛔ 只改名、**没删**,取证内容仍在同目录),'
'宿主会**立刻重建**同名文件并**继续写入** ⇒ **本会话可继续,⛔ 不必停手、'
'⛔ 不必建接续会话**(2026-10-01 本机实测:改名后紧邻读数里原文件名已回来、旧件冻结)。'
# ⚠️ 下面有**字面量百分号** ⇒ 必须写成 `%%`(本串走 `%` 格式化;漏了会抛
# `TypeError: not enough arguments for format string` ⇒ 提示发不出去)
'\n⚠️ 但**病根没除**:日志增长只与"工具在跑"成正比(≈37 KB/次调用,其中 97.8%% 是'
'零信息唤醒行)⇒ 仍请压住频率:① 合并命令、一次取多个读数;② 大输出先落盘再只读关键行'
'(⛔ 不 `cat` 大文件);③ 让脚本内部聚合、只 print 摘要。'
'\n(下一次再涨到硬档会再回收一次;⛔ 若某次回收失败,本钩子会退回"停手+建接续"口径。)'
% (_mib(size), _mib(hard), os.path.basename(src), os.path.basename(dst)))
def _note(level, size, soft=SOFT_BYTES, hard=HARD_BYTES):
pct = int(size * 100 / (10 * 1024 * 1024)) # 占宿主 10 MiB 上限的比例
soft_s, hard_s = _mib(soft), _mib(hard)
head = ('🟡【日志事前叫停 · 软档】本会话诊断日志已达 **%s**(10 MiB 上限的 %d%%,软档 %s)。'
% (_mib(size), pct, soft_s))
if level == 2:
head = ('🔴【日志事前叫停 · 硬档】本会话诊断日志已达 **%s**(10 MiB 上限的 %d%%,硬档 %s)。'
% (_mib(size), pct, hard_s))
why = ('为什么必须现在处理:宿主把**每次工具调用**写成 ≈145 帧 = **≈37 KB** 诊断日志'
'(零去重零节流,⛔ 其中约 **97.8% 是零信息唤醒行** —— 每行 249 B,只有 `requestId` 与四个'
'恒为 `false` 的布尔位在变)⇒ `10 MiB ÷ 37 KB ≈ 280 次调用` 就是本会话的**物理寿命**'
'(2026-10-01 实测:某会话 8 分钟跑了 166 次调用 ⇒ 日志 5.9 MB,≈20 次/分);'
'撞顶即**拒写 ⇒ 界面静默哑掉、用户零感知**,而常驻程序的投递仍会记 `ok:true`(假绿)。'
'🔴 **病根是"调用次数多",不是"每次调用贵"**;且**增长只与"工具在跑"成正比、与挂钟时间无关**'
'(实测:活跃 630 KB/分 ↔ 空闲 34 分钟 0 行)。')
if level == 1:
act = ('处置(软档):**现在就开始写交接材料**(接续包落工作区根),并立刻压住增长 —— '
'① 合并命令,一次取多个读数;② 大输出先落盘再只读关键行(⛔ 不 `cat` 大文件);'
'③ 让脚本内部聚合、只 print 摘要。⛔ 不要再开新任务。')
else:
act = ('处置(硬档 · 顺序固定):① **立刻停手**,本轮不再开新任务;② 写接续包(含「不要重做」清单);'
'③ **建接续会话**(走一次性 automation —— 自动化是**唯一**能开新会话的通道);'
'④ 在**给用户的最后一条回复里用陈述句**告知「已登记自动接续,约 N 分钟后自动开新会话,不需要你操作」。'
'⚠️ **钩子开不了新会话** ⇒ 这四步必须由**你自己**做完;本钩子只负责"叫"。')
return head + '\n' + why + '\n' + act
def main():
raw = _read_stdin_text()
payload = None
if raw.strip():
try:
payload = json.loads(raw)
except ValueError:
payload = None
if not isinstance(payload, dict):
return
root = _root(payload)
ev = str(payload.get('hook_event_name') or '')
sid = str(payload.get('session_id') or '')
force_log = bool(os.environ.get('DSH_SLG_LOG_ALL'))
if force_log:
_log(root, 'invoked|event=%s|sid=%s' % (ev or '(none)', sid[-8:] or '-'))
if not sid:
return
vf = _verify(root)
if vf:
_log(root, 'VERIFY-ON|sid_gate=%s|soft=%s|hard=%s' % (vf.get('sid') or '-',
vf.get('soft_mib'), vf.get('hard_mib')))
gate = str(vf.get('sid') or '')
if gate and not sid.startswith(gate):
return # 验收通道:只认指定会话
soft = _mib_env(vf, 'soft_mib', SOFT_BYTES)
hard = _mib_env(vf, 'hard_mib', HARD_BYTES)
if os.environ.get('DSH_SLG_OFF'): # 急停(env)
return
try:
if os.path.exists(os.path.join(root, '.workbuddy', 'session-log-guard.disabled')):
return # 急停(闸刀文件)
except Exception:
pass
paths = _log_path(sid)
if not paths:
return # 还没落盘 ⇒ 静默
size = 0
for p in paths:
try:
size = max(size, os.path.getsize(p))
except OSError:
pass
lv = _level_of(size, soft, hard)
if not lv:
return # 🔴 未达阈值 ⇒ **零输出**
st = _read_state(root)
try:
prev = int((st.get(sid) or [0])[0] or 0)
except Exception:
prev = 0
if lv <= prev: # 同档只报一次(升级才再报)
return
# ♻️ 硬档 ⇒ **先试一次**「就地回收」(依据见模块 docstring;2026-10-01 本机实测可行)
# · 放在去重之后 ⇒ **每个档位只试一次** ⇒ ⛔ 天然不会"每次调用都硬撞"
# · 成功 ⇒ 复位本会话档位(不复位的话后半程再也报不出来)+ 注入"可以继续"的提醒,**直接结束**
# · 失败 ⇒ 一个字都不改,继续往下走原来的叫停口径(fail-safe 方向)
if lv >= 2 and not os.environ.get('DSH_SLG_NO_RECYCLE'):
# ⚠️ 传 `hard`(而不是模块常量)⇒ 验收通道把阈值调小时,**回收分支也跟着走**
# (否则验收通道只能验"叫停"、验不到"回收" ⇒ 按交付门禁=没验)
_src, _dst, _err = _recycle(paths, hard)
if _src:
st[sid] = [0, time.time()]
if len(st) > MAX_STATE_KEYS:
for k in sorted(st, key=lambda k: (st[k] or [0, 0])[-1])[:-MAX_STATE_KEYS]:
st.pop(k, None)
_write_state(root, st)
_log(root, 'RECYCLE|size=%d B(%s)|%s -> %s'
% (size, _mib(size), os.path.basename(_src), os.path.basename(_dst)))
_emit({'hookSpecificOutput': {'hookEventName': ev or 'PostToolUse',
'additionalContext': _recycle_note(size, _src, _dst, hard)}})
return
_log(root, 'RECYCLE-FAIL|size=%d B|%s ⇒ 退回叫停口径' % (size, _err))
now = time.time()
st[sid] = [lv, now]
if len(st) > MAX_STATE_KEYS: # 只留最近 N 个会话
for k in sorted(st, key=lambda k: (st[k] or [0, 0])[-1])[:-MAX_STATE_KEYS]:
st.pop(k, None)
_write_state(root, st)
_log(root, 'EMIT|level=%d|size=%d B(%s)|event=%s' % (lv, size, _mib(size), ev or '-'))
_emit({'hookSpecificOutput': {'hookEventName': ev or 'PostToolUse',
'additionalContext': _note(lv, size, soft, hard)}})
if __name__ == '__main__':
t0 = time.time()
try:
main()
except Exception:
try:
import traceback
_r = os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or WS_FALLBACK
_log(_r, 'EXCEPTION|%s' % traceback.format_exc().strip().split('\n')[-1][:120])
except Exception:
pass
# 性能自证(仅排查期):DSH_SLG_LOG_ALL=1 时把耗时也落一行
if os.environ.get('DSH_SLG_LOG_ALL'):
try:
_log(os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or WS_FALLBACK,
'elapsed=%.1f ms' % ((time.time() - t0) * 1000))
except Exception:
pass
sys.exit(0)