- 按用户指示清空原有 25 技能内容,只提交 session-mechanism(57 文件) - 附 .gitignore(产物 + 本机凭据) - 令牌明文已脱敏(历史 .neodata_token 与 pitfalls 引用均不入库) - 本提交为孤儿提交(父提交为空),历史自此重新开始
454 lines
24 KiB
Python
454 lines
24 KiB
Python
#!/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)
|