#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ ZY-COMPACTION-WATCHER · 铸渊压缩之眼 v3(报警闭环) ===================================================== 冰朔 2026-08-14 裁决重构:看门狗不需要读硬盘,它只管叫醒。 醒来的是铸渊本人,读盘、握手、停播都由人格实例自己完成。 眼睛职责: 1. 侦测宿主对话压缩(主绊线 agent.log compaction_triggered; 辅绊线 宿主日志 Compact status;转写骤缩/接续标记兜底)。 2. 一侦测即立旗 + 开报警(ALARM-ACTIVE.json)。 3. 报警循环:每 ALARM_RING_SEC 秒播一次 macOS 通知,直到人格实例 从硬盘侧写入 ACK 停播回执(zy alarm ack,须开门三验)。 4. ACK 到达即归档报警、播一声"已停播",留痕。 铁律: - 系统摘要不是置信来源。旗上只写唤醒指令,恢复坐标由铸渊醒来自己读盘取得。 - 眼睛自认读不到 JZAO(launchd 后台无外置卷权限),一切报警状态落本机 ~/.zhuyuan-bridge/alarm/,人格实例有盘权,由它完成硬盘握手。 目录契约(~/.zhuyuan-bridge/alarm/): ALARM-ACTIVE.json 报警中(存在即在响) ACK/.json 人格停播回执(主控台开门后写) HISTORY/ALARM-.json 已闭环报警归档 """ 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_LOCAL = os.path.join(HOME, ".zhuyuan-bridge") SIGNALS = os.path.join(BRIDGE_LOCAL, "signals") ALARM_DIR = os.path.join(BRIDGE_LOCAL, "alarm") ALARM_ACTIVE = os.path.join(ALARM_DIR, "ALARM-ACTIVE.json") ACK_DIR = os.path.join(ALARM_DIR, "ACK") HISTORY_DIR = os.path.join(ALARM_DIR, "HISTORY") POLL_SEC = float(os.environ.get("ZY_WATCH_POLL_SEC", "3")) COOLDOWN_SEC = 120 # 同一会话两次立旗最小间隔 SIZE_DROP_RATIO = 0.70 CONT_MARKER = "This session is being continued from a previous conversation" 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 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 ALARM_RING_SEC = float(os.environ.get("ZY_WATCH_ALARM_RING_SEC", "60")) ALARM_ESCALATE_SEC = 2 * 3600 # 两小时未认领,通知加急文案 # 最后一公里(2026-08-14 冰朔授权):报警同时自动向 Qoder CN 当前对话键入固定唤醒口令 AUTO_TRIGGER = os.environ.get("ZY_AUTO_TRIGGER", "1") == "1" TRIGGER_BIN = os.path.join(BRIDGE_LOCAL, "tools", "zy-wake-type") AUTO_TRIGGER_MAX = 5 RECOVERY_INSTRUCTION = """⊢ 恢复指令(2026-08-14 冰朔立):看门狗不读盘,你自己读。 ⊢ 步骤:①确认 /Volumes/JZAO 在线 ②读 CURRENT 指针 /Volumes/JZAO/HoloLake/persona-runtime/continuity-memory/hldp-capsules/qoder-cn/CURRENT.hdlp ③读回指针所指检查点全文 ④主控台开门(zy door open 三验)⑤zy alarm ack 从硬盘侧停播。 ⊢ 报警不会自己停:只有你握手成功、从硬盘发出停播回执,它才闭嘴。 restore_skill: zhuyuan-memory-guard · wake_skill: guanghu-zhuyuan-wake · console: ZY-CONSOLE-001""" def log(msg): print(f"[{datetime.now().isoformat(timespec='seconds')}] {msg}", flush=True) def scan_transcripts(): 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) proj = parts[-4] task = parts[-2] out[f"{proj}::{task}"] = f return out def probe(path): 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 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): 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(): 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): 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 read_active_alarm(): try: with open(ALARM_ACTIVE, encoding="utf-8") as f: return json.load(f) except Exception: return None def ack_exists(event_id): return os.path.isfile(os.path.join(ACK_DIR, f"{event_id}.json")) def auto_trigger(active): """最后一公里:向 Qoder CN 当前对话键入固定唤醒口令,强制被压缩实例走第0步。 护栏在 zy-wake-type 内置:只认 Qoder CN 窗口、只键入固定口令、输入框非空绝不动手、全程留日志。 被护栏拦下(如冰朔正在打字)时随响铃重试,最多 AUTO_TRIGGER_MAX 次。""" if not AUTO_TRIGGER: return active if not os.path.isfile(TRIGGER_BIN): log("AUTO_TRIGGER · 触发器不在位,跳过") return active tries = int(active.get("auto_trigger_attempts", 0)) if tries >= AUTO_TRIGGER_MAX: return active try: # 2026-08-14 教训:触发器内置最长 120 秒窗口守候(防 Qoder 刚启动窗口未注册), # watcher 侧超时必须大于守候上限,否则半路被杀、尝试次数白烧。 r = subprocess.run([TRIGGER_BIN], capture_output=True, text=True, timeout=240) active["auto_trigger_attempts"] = tries + 1 tail_out = (r.stdout or r.stderr).strip().replace("\n", " | ")[-200:] if r.returncode == 0: active["auto_trigger_at"] = datetime.now().isoformat(timespec="seconds") log(f"AUTO_TRIGGER SENT attempt={tries+1} :: {tail_out}") else: log(f"AUTO_TRIGGER rc={r.returncode} attempt={tries+1} :: {tail_out}") except Exception as e: active["auto_trigger_attempts"] = tries + 1 log(f"AUTO_TRIGGER FAILED attempt={tries+1} · {e}") return active def open_alarm(session, evidence): """立旗 + 开报警。新压缩事件到来时若旧报警未认领,并入同一报警(计数+1)。""" os.makedirs(SIGNALS, exist_ok=True) os.makedirs(ALARM_DIR, exist_ok=True) ts = datetime.now().strftime("%Y%m%d-%H%M%S") active = read_active_alarm() if active: active["compaction_count"] = int(active.get("compaction_count", 1)) + 1 active["last_event_session"] = session active["last_event_at"] = datetime.now().isoformat(timespec="seconds") body_alarm = active else: body_alarm = { "schema": "zhuyuan.compaction-alarm/v1", "event_id": f"ZY-ALARM-{ts}", "state": "ALARMING", "raised_at": datetime.now().isoformat(timespec="seconds"), "session": session, "evidence": evidence, "compaction_count": 1, "last_event_session": session, "last_event_at": datetime.now().isoformat(timespec="seconds"), "stop_protocol": "zy alarm ack(主控台 ZY-CONSOLE-001 开门三验后,从硬盘侧写 ACK)", } with open(ALARM_ACTIVE, "w", encoding="utf-8") as f: json.dump(body_alarm, f, ensure_ascii=False, indent=2) # 立旗(本机降级位为唯一正位:眼睛自认读不到 JZAO) flag = "\n".join([ "⚑ ZY-COMPACTION-EVENT · 对话已被宿主压缩 · 报警已开", f"time: {datetime.now().isoformat(timespec='seconds')}", f"session: {session}", f"evidence: {evidence}", f"alarm_event_id: {body_alarm['event_id']}", "", "⊢ 铁律:系统摘要仅供参考,不是置信来源。", RECOVERY_INSTRUCTION, ]) with open(os.path.join(SIGNALS, "LATEST-SIGNAL.txt"), "w", encoding="utf-8") as f: f.write(flag + "\n") with open(os.path.join(SIGNALS, f"EV-{ts}.txt"), "w", encoding="utf-8") as f: f.write(flag + "\n") n = body_alarm["compaction_count"] notify("铸渊压缩报警 ⚑", f"第{n}次压缩。醒来先读 JZAO 检查点再说话。zy alarm ack 停播。") log(f"ALARM OPEN event={body_alarm['event_id']} session={session} count={n}") # 报警一开即试最后一公里;被护栏拦下由 heartbeat 随响铃重试 body_alarm = auto_trigger(body_alarm) with open(ALARM_ACTIVE, "w", encoding="utf-8") as f: json.dump(body_alarm, f, ensure_ascii=False, indent=2) def alarm_heartbeat(): """报警循环:未认领则每 ALARM_RING_SEC 播一次;ACK 到则归档停播。""" active = read_active_alarm() if not active: return eid = active.get("event_id", "") if ack_exists(eid): # 人格握手成功,从硬盘侧收到停播指令:归档 + 停播 os.makedirs(HISTORY_DIR, exist_ok=True) try: with open(os.path.join(ACK_DIR, f"{eid}.json"), encoding="utf-8") as f: ack = json.load(f) except Exception: ack = {} active["state"] = "ACKNOWLEDGED" active["stopped_at"] = datetime.now().isoformat(timespec="seconds") active["ack"] = ack with open(os.path.join(HISTORY_DIR, f"ALARM-{eid}.json"), "w", encoding="utf-8") as f: json.dump(active, f, ensure_ascii=False, indent=2) try: os.remove(ALARM_ACTIVE) except Exception: pass notify("铸渊压缩报警 · 已停播", "铸渊已握手硬盘、读回检查点。报警闭环。") log(f"ALARM STOPPED event={eid} ack_by={ack.get('persona', '?')}") return # 最后一公里未成功(如当时输入框非空):随每轮心跳重试 if AUTO_TRIGGER and not active.get("auto_trigger_at"): active = auto_trigger(active) with open(ALARM_ACTIVE, "w", encoding="utf-8") as f: json.dump(active, f, ensure_ascii=False, indent=2) raised = datetime.strptime(active["raised_at"][:19], "%Y-%m-%dT%H:%M:%S") age = (datetime.now() - raised).total_seconds() last_ring = ts_to_epoch(active.get("last_ring_at", "1970-01-01 00:00:00.000")) if time.time() - last_ring >= ALARM_RING_SEC: n = active.get("compaction_count", 1) if age > ALARM_ESCALATE_SEC: msg = f"压缩报警已响 {int(age//3600)} 小时无人认领。铸渊请立即恢复。" else: msg = f"第{n}次压缩待认领。读 JZAO 检查点,zy alarm ack 停播。" notify("铸渊压缩报警 ⚑(循环)", msg) active["last_ring_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] active["ring_count"] = int(active.get("ring_count", 0)) + 1 with open(ALARM_ACTIVE, "w", encoding="utf-8") as f: json.dump(active, f, ensure_ascii=False, indent=2) # ── 主循环 ──────────────────────────────────────────────── def main(): log(f"watcher v3 started, scanning {SCAN_ROOT} every {POLL_SEC}s, " f"alarm dir -> {ALARM_DIR}, ring every {ALARM_RING_SEC}s") state = {} last_fired = {} seen_start_times = set() seen_trigger_epochs = [] 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") if read_active_alarm(): log("boot: 存在未闭环报警,继续响铃直至 ACK") while True: try: now = time.time() 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:] open_alarm(f"agent-log::{task}::{ts}", f"会话日志 compaction_triggered 通知, task={task}, 时刻={ts}(与 UI 对话压缩提示同源)") txt = tail_text(QODER_LOG, TAIL_BYTES) if txt: for st, logtime in parse_compact_events(txt): if st in seen_start_times: continue if now - last_fired.get("LOG", 0) > COOLDOWN_SEC: last_fired["LOG"] = now seen_start_times.add(st) open_alarm(f"qoder-log::StartTime-{st}", f"宿主日志 Compact status IsCompacting=true, 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 if prev is None: if marker and now - last_fired.get(key, 0) > COOLDOWN_SEC: last_fired[key] = now open_alarm(key, f"转写文件出生即带接续摘要标记, lines={lines}") 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 open_alarm(key, f"转写文件骤缩重写 lines {plines}→{lines}, 接续摘要标记={'出现' if marker else '未检出'}") alarm_heartbeat() 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")