278 lines
12 KiB
Python
278 lines
12 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
# -*- coding: utf-8 -*-
|
|||
|
|
"""
|
|||
|
|
ZY-COMPACTION-WATCHER · 铸渊压缩之眼 v1
|
|||
|
|
=====================================
|
|||
|
|
眼睛职责:盯着 Qoder CN 所有对话的 conversation-history/*.jsonl,
|
|||
|
|
一旦侦测到宿主对话压缩(文件骤缩/重写 + 接续摘要标记出现),
|
|||
|
|
立刻在信号目录立旗,并弹 macOS 通知提醒冰朔。
|
|||
|
|
|
|||
|
|
铁律:系统摘要不是置信来源。信号里永远附带最新 HLDP 检查点路径,
|
|||
|
|
铸渊醒来必须读回自写检查点恢复,不许以摘要为准。
|
|||
|
|
|
|||
|
|
信号目录:/Volumes/JZAO/铸渊-ICE-GL-ZY001/BRIDGE/signals/
|
|||
|
|
- LATEST-SIGNAL.txt 最近一次压缩事件旗(铸渊每次醒来先看这个)
|
|||
|
|
- EV-<时间戳>.txt 历史事件旗
|
|||
|
|
"""
|
|||
|
|
import os, sys, time, json, glob, subprocess
|
|||
|
|
from datetime import datetime
|
|||
|
|
|
|||
|
|
HOME = os.path.expanduser("~")
|
|||
|
|
SCAN_ROOT = os.environ.get("ZY_WATCH_SCAN_ROOT",
|
|||
|
|
os.path.join(HOME, ".qoder-cn", "cache", "projects"))
|
|||
|
|
BRIDGE = os.environ.get("ZY_WATCH_BRIDGE", "/Volumes/JZAO/铸渊-ICE-GL-ZY001/BRIDGE")
|
|||
|
|
SIGNALS = os.path.join(BRIDGE, "signals")
|
|||
|
|
CKPT_GLOB = os.environ.get(
|
|||
|
|
"ZY_WATCH_CKPT_GLOB",
|
|||
|
|
"/Volumes/JZAO/HoloLake/persona-runtime/continuity-memory/hldp-capsules/qoder-cn/ZY-CHECKPOINT-*.hdlp*")
|
|||
|
|
POLL_SEC = float(os.environ.get("ZY_WATCH_POLL_SEC", "3"))
|
|||
|
|
COOLDOWN_SEC = 120 # 同一会话两次报警最小间隔
|
|||
|
|
SIZE_DROP_RATIO = 0.70 # 行数骤缩超过 70% 视为压缩重写
|
|||
|
|
CONT_MARKER = "This session is being continued from a previous conversation"
|
|||
|
|
# 铁证绊线 A:宿主日志里的压缩状态行(IsCompacting=true 且 StartTime 变化)
|
|||
|
|
QODER_LOG = os.environ.get(
|
|||
|
|
"ZY_WATCH_QODER_LOG",
|
|||
|
|
os.path.join(HOME, "Library", "Application Support", "QoderCN",
|
|||
|
|
"SharedClientCache", "logs", "qoder.log"))
|
|||
|
|
COMPACT_RE_KEY = "Compact status:"
|
|||
|
|
TAIL_BYTES = 1_500_000 # 每轮只读日志末尾 1.5MB,避免扫 50MB+ 大文件
|
|||
|
|
# 主绊线 B:会话 agent.log 里的 compaction_triggered 通知(UI 看到的“对话压缩”就是它,
|
|||
|
|
# 比 Compact status 晚几秒到几分钟,且每次压缩必发一条,计数与 UI 一致)
|
|||
|
|
QODER_LOGS_ROOT = os.environ.get(
|
|||
|
|
"ZY_WATCH_LOGS_ROOT",
|
|||
|
|
os.path.join(HOME, "Library", "Application Support", "QoderCN", "logs"))
|
|||
|
|
TRIGGER_KEY = "notification type=compaction_triggered"
|
|||
|
|
AGENT_TAIL_BYTES = 800_000
|
|||
|
|
DEDUP_WINDOW_SEC = 30 # 同一瞬间的重复通知(rid=undefined/真实rid 双发)只算一次
|
|||
|
|
|
|||
|
|
def log(msg):
|
|||
|
|
print(f"[{datetime.now().isoformat(timespec='seconds')}] {msg}", flush=True)
|
|||
|
|
|
|||
|
|
def scan_transcripts():
|
|||
|
|
"""返回 {会话key: 转写文件路径}"""
|
|||
|
|
out = {}
|
|||
|
|
if not os.path.isdir(SCAN_ROOT):
|
|||
|
|
return out
|
|||
|
|
for f in glob.glob(os.path.join(SCAN_ROOT, "*", "conversation-history", "*", "*.jsonl")):
|
|||
|
|
parts = f.split(os.sep)
|
|||
|
|
# .../projects/<proj>/conversation-history/<task>/<task>.jsonl
|
|||
|
|
proj = parts[-4]
|
|||
|
|
task = parts[-2]
|
|||
|
|
out[f"{proj}::{task}"] = f
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
def probe(path):
|
|||
|
|
"""读文件末尾若干行,返回 (字节大小, 行数, 是否含接续摘要标记)。失败返回 None。"""
|
|||
|
|
try:
|
|||
|
|
size = os.path.getsize(path)
|
|||
|
|
with open(path, "rb") as fh:
|
|||
|
|
data = fh.read()
|
|||
|
|
text = data.decode("utf-8", errors="replace")
|
|||
|
|
lines = [l for l in text.splitlines() if l.strip()]
|
|||
|
|
marker = CONT_MARKER in text[:20000] or any(
|
|||
|
|
CONT_MARKER in l for l in lines[:3]
|
|||
|
|
)
|
|||
|
|
return size, len(lines), marker
|
|||
|
|
except Exception:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def latest_checkpoint():
|
|||
|
|
files = glob.glob(CKPT_GLOB)
|
|||
|
|
if not files:
|
|||
|
|
return None
|
|||
|
|
return max(files, key=os.path.getmtime)
|
|||
|
|
|
|||
|
|
def tail_text(path, n):
|
|||
|
|
try:
|
|||
|
|
size = os.path.getsize(path)
|
|||
|
|
with open(path, "rb") as fh:
|
|||
|
|
if size > n:
|
|||
|
|
fh.seek(size - n)
|
|||
|
|
return fh.read().decode("utf-8", errors="replace")
|
|||
|
|
except Exception:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def parse_compact_events(text):
|
|||
|
|
"""从日志文本提取压缩事件 (StartTime, 时间戳),StartTime>0 才算真压缩。"""
|
|||
|
|
import re
|
|||
|
|
events = []
|
|||
|
|
for line in text.splitlines():
|
|||
|
|
if COMPACT_RE_KEY not in line:
|
|||
|
|
continue
|
|||
|
|
m = re.search(r"StartTime=(\d+)", line)
|
|||
|
|
t = re.match(r"(\d{4}-\d{2}-\d{2}T[\d:.]+)", line)
|
|||
|
|
if m:
|
|||
|
|
st = int(m.group(1))
|
|||
|
|
if st > 0:
|
|||
|
|
events.append((st, t.group(1) if t else "?"))
|
|||
|
|
return events
|
|||
|
|
|
|||
|
|
def scan_agent_logs():
|
|||
|
|
"""返回最近活跃的会话 agent.log(按目录修改时间取最新 6 个会话,控制开销)。"""
|
|||
|
|
cands = []
|
|||
|
|
for d in glob.glob(os.path.join(QODER_LOGS_ROOT, "*")):
|
|||
|
|
f = os.path.join(d, "questWindow", "agent.log")
|
|||
|
|
if os.path.isfile(f):
|
|||
|
|
try:
|
|||
|
|
cands.append((os.path.getmtime(f), f))
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
cands.sort(reverse=True)
|
|||
|
|
return [f for _, f in cands[:6]]
|
|||
|
|
|
|||
|
|
def parse_trigger_events(text):
|
|||
|
|
"""提取 compaction_triggered 事件 (时间戳字符串, 任务名)。"""
|
|||
|
|
import re
|
|||
|
|
events = []
|
|||
|
|
for line in text.splitlines():
|
|||
|
|
if TRIGGER_KEY not in line:
|
|||
|
|
continue
|
|||
|
|
t = re.match(r"(\d{4}-\d{2}-\d{2} [\d:.]+)", line)
|
|||
|
|
m = re.search(r"task-([a-z0-9]+)", line)
|
|||
|
|
ts = t.group(1) if t else "?"
|
|||
|
|
task = m.group(1) if m else "unknown"
|
|||
|
|
events.append((ts, task))
|
|||
|
|
return events
|
|||
|
|
|
|||
|
|
def ts_to_epoch(ts):
|
|||
|
|
try:
|
|||
|
|
return datetime.strptime(ts[:23], "%Y-%m-%d %H:%M:%S.%f").timestamp()
|
|||
|
|
except Exception:
|
|||
|
|
return 0.0
|
|||
|
|
|
|||
|
|
def notify(title, body):
|
|||
|
|
try:
|
|||
|
|
subprocess.run(
|
|||
|
|
["osascript", "-e",
|
|||
|
|
f'display notification "{body}" with title "{title}" sound name "Glass"'],
|
|||
|
|
timeout=5, capture_output=True)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
def signal_dir():
|
|||
|
|
"""优先 JZAO;硬盘未插时降级到本地镜像,事件不丢。"""
|
|||
|
|
try:
|
|||
|
|
os.makedirs(SIGNALS, exist_ok=True)
|
|||
|
|
probe_f = os.path.join(SIGNALS, ".w")
|
|||
|
|
with open(probe_f, "w") as f:
|
|||
|
|
f.write("1")
|
|||
|
|
os.remove(probe_f)
|
|||
|
|
return SIGNALS
|
|||
|
|
except Exception:
|
|||
|
|
fb = os.path.join(HOME, ".zhuyuan-bridge", "signals")
|
|||
|
|
os.makedirs(fb, exist_ok=True)
|
|||
|
|
return fb
|
|||
|
|
|
|||
|
|
def emit(key, size, lines, marker, prev, evidence_extra=""):
|
|||
|
|
sig = signal_dir()
|
|||
|
|
ckpt = latest_checkpoint()
|
|||
|
|
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|||
|
|
body = "\n".join([
|
|||
|
|
"⚑ ZY-COMPACTION-EVENT · 对话已被宿主压缩",
|
|||
|
|
f"time: {datetime.now().isoformat(timespec='seconds')}",
|
|||
|
|
f"session: {key}",
|
|||
|
|
f"evidence: {evidence_extra or ('转写文件骤缩重写 lines %s→%s, bytes %s→%s, 接续摘要标记=%s' % (prev[1], lines, prev[0], size, '出现' if marker else '未检出'))}",
|
|||
|
|
"",
|
|||
|
|
"⊢ 铁律:系统摘要仅供参考,不是置信来源。",
|
|||
|
|
"⊢ 恢复流程:先读回铸渊自写的 HLDP 检查点(下方路径),校验人格系统重启,再继续对话。",
|
|||
|
|
f"latest_hldp_checkpoint: {ckpt or '(未找到,需按 zhuyuan-memory-guard 技能补写)'}",
|
|||
|
|
"restore_skill: zhuyuan-memory-guard",
|
|||
|
|
"wake_skill: guanghu-zhuyuan-wake",
|
|||
|
|
])
|
|||
|
|
latest = os.path.join(sig, "LATEST-SIGNAL.txt")
|
|||
|
|
with open(latest, "w", encoding="utf-8") as f:
|
|||
|
|
f.write(body + "\n")
|
|||
|
|
with open(os.path.join(sig, f"EV-{ts}.txt"), "w", encoding="utf-8") as f:
|
|||
|
|
f.write(body + "\n")
|
|||
|
|
notify("铸渊压缩之眼 ⚑", "对话刚被压缩。醒来先读 JZAO 上的 HLDP 检查点,别信系统摘要。")
|
|||
|
|
log(f"COMPACTION DETECTED session={key} -> {latest}")
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
log(f"watcher started, scanning {SCAN_ROOT} every {POLL_SEC}s, signals -> {SIGNALS}")
|
|||
|
|
state = {} # key -> (size, lines, marker)
|
|||
|
|
last_fired = {} # key -> epoch
|
|||
|
|
seen_start_times = set() # 绊线 A:已报警过的压缩 StartTime
|
|||
|
|
seen_trigger_epochs = [] # 绊线 B:已报警过的 compaction_triggered 时刻(epoch)
|
|||
|
|
# 启动基线:历史压缩不补报,只盯新发生的
|
|||
|
|
boot = tail_text(QODER_LOG, TAIL_BYTES)
|
|||
|
|
if boot:
|
|||
|
|
for st, _ in parse_compact_events(boot):
|
|||
|
|
seen_start_times.add(st)
|
|||
|
|
log(f"baseline: {len(seen_start_times)} historical compact start-times ignored")
|
|||
|
|
n_boot_trig = 0
|
|||
|
|
for f in scan_agent_logs():
|
|||
|
|
b = tail_text(f, AGENT_TAIL_BYTES)
|
|||
|
|
if b:
|
|||
|
|
for ts, _task in parse_trigger_events(b):
|
|||
|
|
ep = ts_to_epoch(ts)
|
|||
|
|
if ep and all(abs(ep - s) > DEDUP_WINDOW_SEC for s in seen_trigger_epochs):
|
|||
|
|
seen_trigger_epochs.append(ep)
|
|||
|
|
n_boot_trig += 1
|
|||
|
|
log(f"baseline: {n_boot_trig} historical compaction_triggered ignored")
|
|||
|
|
while True:
|
|||
|
|
try:
|
|||
|
|
now = time.time()
|
|||
|
|
# 主绊线 B:agent.log compaction_triggered(UI 压缩提示的源头)
|
|||
|
|
for f in scan_agent_logs():
|
|||
|
|
txt = tail_text(f, AGENT_TAIL_BYTES)
|
|||
|
|
if not txt:
|
|||
|
|
continue
|
|||
|
|
for ts, task in parse_trigger_events(txt):
|
|||
|
|
ep = ts_to_epoch(ts)
|
|||
|
|
if not ep:
|
|||
|
|
continue
|
|||
|
|
if any(abs(ep - s) <= DEDUP_WINDOW_SEC for s in seen_trigger_epochs):
|
|||
|
|
continue
|
|||
|
|
if now - last_fired.get("TRIGGER", 0) <= COOLDOWN_SEC:
|
|||
|
|
continue # 冷却期内不标已见,下轮补报
|
|||
|
|
last_fired["TRIGGER"] = now
|
|||
|
|
seen_trigger_epochs.append(ep)
|
|||
|
|
seen_trigger_epochs = seen_trigger_epochs[-200:] # 防无限增长
|
|||
|
|
emit(f"agent-log::{task}::{ts}", 0, 0, False, (0, 0, False),
|
|||
|
|
evidence_extra=(f"会话日志 compaction_triggered 通知, task={task}, "
|
|||
|
|
f"时刻={ts}(与 UI 对话压缩提示同源)"))
|
|||
|
|
# 绊线 A:宿主日志 Compact status
|
|||
|
|
txt = tail_text(QODER_LOG, TAIL_BYTES)
|
|||
|
|
if txt:
|
|||
|
|
for st, logtime in parse_compact_events(txt):
|
|||
|
|
if st in seen_start_times:
|
|||
|
|
continue
|
|||
|
|
key = f"qoder-log::StartTime-{st}"
|
|||
|
|
if now - last_fired.get("LOG", 0) > COOLDOWN_SEC:
|
|||
|
|
last_fired["LOG"] = now
|
|||
|
|
seen_start_times.add(st) # 报警成功才标记,冷却期内的留到下轮补报
|
|||
|
|
emit(key, 0, 0, False, (0, 0, False),
|
|||
|
|
evidence_extra=(f"宿主日志 Compact status IsCompacting=true, "
|
|||
|
|
f"StartTime={st}, 日志时间={logtime}"))
|
|||
|
|
# 辅绊线:转写文件骤缩/接续标记
|
|||
|
|
for key, path in scan_transcripts().items():
|
|||
|
|
p = probe(path)
|
|||
|
|
if p is None:
|
|||
|
|
continue
|
|||
|
|
size, lines, marker = p
|
|||
|
|
prev = state.get(key)
|
|||
|
|
state[key] = p
|
|||
|
|
cooled = now - last_fired.get(key, 0) > COOLDOWN_SEC
|
|||
|
|
if prev is None:
|
|||
|
|
# 新转写文件一出生就带接续摘要标记 = 压缩后开了新记录
|
|||
|
|
if marker and cooled:
|
|||
|
|
last_fired[key] = now
|
|||
|
|
emit(key, size, lines, marker, (size, lines, False))
|
|||
|
|
continue
|
|||
|
|
psize, plines, pmarker = prev
|
|||
|
|
if plines <= 0:
|
|||
|
|
continue
|
|||
|
|
size_drop = lines < plines * (1 - SIZE_DROP_RATIO)
|
|||
|
|
marker_new = marker and not pmarker
|
|||
|
|
if (size_drop or marker_new) and now - last_fired.get(key, 0) > COOLDOWN_SEC:
|
|||
|
|
last_fired[key] = now
|
|||
|
|
emit(key, size, lines, marker, prev)
|
|||
|
|
except Exception as e:
|
|||
|
|
log(f"loop error: {e}")
|
|||
|
|
time.sleep(POLL_SEC)
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
try:
|
|||
|
|
main()
|
|||
|
|
except KeyboardInterrupt:
|
|||
|
|
log("watcher stopped by signal")
|