Skill: nowhitestar/nowhitestar-meeting-assistant (version 1.0.4)

--- name: meeting-assistant description: "Native macOS meeting automation for OpenClaw: calendar/window detection, prompt-before-recording, ScreenCaptureKit system audio + microphone recording, local whisper-cli transcription, and agent-generated meeting notes. No BlackHole or virtual audio device required. Use when you need automatic meeting detection, recording, transcription, and final summaries delivered by an OpenClaw agent." --- # Meeting Assistant Native macOS meeting automation for OpenClaw: calendar/window detection → prompt before recording → system audio + microphone recording → local transcription → agent-generated meeting notes. ## Architecture ```text Google Calendar / Window Detector ↓ schedule.json → scheduler_daemon.py ↓ meeting_daemon.py ask_record ↓ notify.py prompt: Start recording? ↓ record_audio.py → AudioDaemon.app (Unix socket) ↓ ScreenCaptureKit system audio + AVFoundation microphone ↓ WAV → transcribe.py → whisper-cli ↓ transcript.txt + summary_request queue ↓ OpenClaw heartbeat agent → final summary.md → user notification ``` ## Key Features - **No BlackHole required** — captures system audio directly with ScreenCaptureKit. - **Native AudioDaemon** — a signed macOS app bundle for stable TCC privacy permissions. - **System audio + microphone** — records remote speaker audio and local microphone input. - **Half-duplex mixing** — prioritize system audio while others speak; fade to microphone during system silence. - **Meeting detection** — detects Zoom, Tencent Meeting, Google Meet, Feishu/Lark, WeChat calls, and browser-based meetings. - **Local transcription** — uses `whisper-cli` from whisper.cpp. - **OpenClaw agent summaries** — transcription writes a `summary_request` into the agent queue; the OpenClaw agent generates the final meeting notes and notifies the user. ## Install Clone the full repository first. `setup.sh` needs repository files and should not be run via `curl | bash`. ```bash git clone https://github.com/Nowhitestar/meeting-assistant.git cd meeting-assistant bash meeting-assistant/scripts/setup.sh ``` If you are already inside the skill folder that contains `SKILL.md` and `scripts/`, you can run: ```bash bash scripts/setup.sh ``` The installer will: 1. Check macOS, Homebrew, and Python 2. Install `sox`, `ffmpeg`, `whisper-cpp`, `terminal-notifier`, `gogcli`, and `cloudflared` 3. Create `~/.config/meeting-assistant/config.json` 4. Compile the window scanner and guide Accessibility permission setup 5. Build and sign `AudioDaemon.app` 6. Guide Microphone and Screen & System Audio Recording permissions 7. Optionally configure Google Calendar 8. Install LaunchAgent background services 9. Run basic verification tests ## macOS Permissions | Component | Permission | Purpose | |---|---|---| | `AudioDaemon.app` | Microphone | Record local microphone audio | | `AudioDaemon.app` | Screen & System Audio Recording | Capture system audio via ScreenCaptureKit | | `window_scanner` | Accessibility | Read window titles to detect meetings/calls | If system audio is missing, open: System Settings → Privacy & Security → **Screen & System Audio Recording** → enable `AudioDaemon.app`. ## Google Calendar and Privacy Calendar authorization uses `gog`; refresh tokens are stored in the system Keychain. The repository and skill package must never contain real `client_secret*.json`, refresh tokens, API keys, or personal calendar IDs. Recommended setup: ```bash # 1. In Google Cloud Console, enable Calendar API and create a Desktop OAuth client. # 2. Download the JSON locally. # 3. Import credentials and authorize Calendar access. gog auth credentials ~/Downloads/client_secret_xxx.json gog auth add [REDACTED_SECRET] --services calendar gog auth list # 4. After auth succeeds, delete the Downloads copy of client_secret_xxx.json. ``` Then enable Google Calendar in `~/.config/meeting-assistant/config.json`: ```json { "calendars": [ { "type": "google", "enabled": true, "gog_account": "[REDACTED_SECRET]", "watch_calendars": ["primary"] } ] } ``` If credentials were ever posted in chat or committed publicly: delete the OAuth client, revoke the token, create a fresh client, and authorize again. ## Configuration Config path: `~/.config/meeting-assistant/config.json` Key fields: ```json { "audio": { "backend": "daemon", "output_dir": [REDACTED_SECRET], "silence_threshold": 0.01, "silence_duration_sec": 300, "half_duplex": true }, "transcription": { "whisper_cli": "whisper-cli", "local_model_path": "~/Models/whisper/ggml-medium.bin", "language": "zh" }, "llm": { "enabled": true } } ``` Notes: - `audio.backend=daemon` is the recommended default and does not require BlackHole. - `mic_device` / `system_audio_device` are only for the legacy SoX fallback path. - If no external LLM command is configured, summaries are delegated to the OpenClaw agent queue. ## Scripts All scripts live under `scripts/`: | Script | Purpose | |---|---| | `setup.sh` | Interactive installer | | `AudioDaemon.app/` | Native signed audio daemon app | | `audio_daemon.swift` | ScreenCaptureKit + AVFoundation + Unix socket | | `build_audio_daemon.sh` | Build and sign AudioDaemon | | `record_audio.py` | Recording control (`start` / `stop` / `status`) | | `meeting_daemon.py` | Recording workflow control | | `scheduler_daemon.py` | Meeting scheduler daemon | | `meeting_detector.py` | Meeting window detector | | `window_scanner.swift` | Accessibility window scanner | | `recorder_status.swift` | Floating recording status window | | `transcribe.py` | whisper transcription + agent queue summary request | | `agent_notify.py` | OpenClaw agent queue writer | | `notify.py` | macOS notifications/prompts | | `check_meetings.py` | Calendar queries | | `send_summary.py` | Optional Telegram/Zulip/Notion output | | `webhook_server.py` | Deprecated fallback Google Calendar webhook server | | `run_calendar_services.py` | Google Calendar webhook + cloudflared tunnel service | | `summary_template.md` | Meeting notes template | | `config.example.json` | Example config | | `com.meetingassistant.*.plist` | LaunchAgent definitions | ## Manual Commands ```bash # AudioDaemon status echo '{"action":"status"}' | nc -w 2 -U ~/.config/meeting-assistant/audio_daemon.sock # Manual recording python3 scripts/record_audio.py start "Test Meeting" python3 scripts/record_audio.py stop # Prompt-based recording flow python3 scripts/meeting_daemon.py ask_record "Test Meeting" "manual-test" # Transcribe WAV python3 scripts/transcribe.py /path/to/meeting-recordings/xxx.wav # Calendar python3 scripts/check_meetings.py today python3 scripts/check_meetings.py upcoming python3 scripts/check_meetings.py week --json # Window detection python3 scripts/meeting_detector.py once python3 scripts/meeting_detector.py daemon ``` ## Troubleshooting ### AudioDaemon has no system audio ```bash echo '{"action":"status"}' | nc -w 2 -U ~/.config/meeting-assistant/audio_daemon.sock ``` If `sysReady=false`: 1. Open System Settings → Privacy & Security → Screen & System Audio Recording 2. Enable `AudioDaemon.app` 3. Restart AudioDaemon: ```bash pkill -f audio_daemon open scripts/AudioDaemon.app ``` ### WAV exists but is silent Usually this is a TCC permission issue or the daemon is not ready. Newer versions refuse to start recording when `sysReady` / `micReady` is false to avoid fake silent WAVs. ### Summary is still a draft Check the queue: ```bash cat ~/.config/meeting-assistant/agent-queue.json ``` If there is a `summary_request`, the OpenClaw heartbeat agent will read the transcript and template, overwrite the final summary, and notify the user. --- # 中文说明 这是一个 macOS 原生会议助手：日历/窗口检测 → 弹窗询问 → 录制系统音频 + 麦克风 → 本地转录 → OpenClaw agent 生成会议纪要。 核心特点：不需要 BlackHole；通过 ScreenCaptureKit 直接捕获系统音频；用 whisper-cli 本地转写；通过 OpenClaw agent queue 生成最终纪要。 快速安装： ```bash git clone https://github.com/Nowhitestar/meeting-assistant.git cd meeting-assistant bash meeting-assistant/scripts/setup.sh ``` Google Calendar 授权使用 `gog`，refresh token 存在系统 Keychain。不要把 `client_secret*.json`、refresh token、API key 或个人日历 ID 提交到公开仓库。

path: scripts/agent_notify.py
content: """
Agent 通知队列：脚本把事件写入队列文件，
agent（闪电）在心跳中读取并推送到 Telegram。

用法：
  from agent_notify import notify
  notify("transcribing", title="项目周会")
  notify("transcript", title="项目周会", path="/path/to/transcript.txt")
  notify("summary_ready", title="项目周会", path="/path/to/summary.md")
"""

import json
from datetime import datetime
from pathlib import Path

QUEUE_PATH = Path.home() / ".config" / "meeting-assistant" / "agent-queue.json"


def notify(event_type: str, **kwargs):
    """添加一条通知到队列。"""
    entry = {
        "type": event_type,
        "ts": datetime.now().isoformat(),
        **kwargs,
    }
    queue = []
    if QUEUE_PATH.exists():
        try:
            queue = json.loads(QUEUE_PATH.read_text())
        except (json.JSONDecodeError, OSError):
            pass
    queue.append(entry)
    QUEUE_PATH.parent.mkdir(parents=True, exist_ok=True)
    QUEUE_PATH.write_text(json.dumps(queue, indent=2, ensure_ascii=False))


path: scripts/audio_daemon.swift
content: // audio_daemon.swift — ScreenCaptureKit 系统音频 + 麦克风 + 半双工混音
// 替代 BlackHole + SoX 的方案。
//
// 编译:
//   swiftc -o audio_daemon audio_daemon.swift \
//     -framework Cocoa -framework ScreenCaptureKit \
//     -framework AVFoundation -framework CoreMedia -framework CoreAudio

import Cocoa
import ScreenCaptureKit
import AVFoundation
import CoreMedia

let HOME = FileManager.default.homeDirectoryForCurrentUser
let CONFIG_DIR = HOME.appendingPathComponent(".config/meeting-assistant")
let SOCKET_PATH = CONFIG_DIR.appendingPathComponent("audio_daemon.sock")
let STATE_PATH = CONFIG_DIR.appendingPathComponent(".state.json")
let PID_PATH = CONFIG_DIR.appendingPathComponent(".audio_daemon.pid")
let LOG_PATH = CONFIG_DIR.appendingPathComponent("audio_daemon.log")
let SILENCE_THRESHOLD: Float = 0.01
let SYS_ACTIVE_THRESHOLD: Float = 0.001  // 系统音频常偏低，半双工判断不能用自动静音阈值
let DEFAULT_SILENCE_SEC: TimeInterval = 300
let FADE_FRAMES = Int(0.5 * 48000)
let SAMPLE_RATE: UInt32 = 48000

var SYS_READY = false
var SYS_ERROR = ""
var MIC_READY = false
var MIC_ERROR = ""
var SYS_FORMAT_LOGGED = false

func defaultRecordingDir() -> URL {
    // AudioDaemon.app lives at <repo>/meeting-assistant/scripts/AudioDaemon.app.
    // Store recordings at <repo>/meeting-recordings by default.
    let app = Bundle.main.bundleURL
    let repo = app
        .deletingLastPathComponent() // scripts
        .deletingLastPathComponent() // nested meeting-assistant
        .deletingLastPathComponent() // repo root
    return repo.appendingPathComponent("meeting-recordings")
}

func loadRecordingDir() -> URL {
    let configPath = CONFIG_DIR.appendingPathComponent("config.json")
    guard let data = try? Data(contentsOf: configPath),
          let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
          let audio = json["audio"] as? [String: Any],
          let raw = audio["output_dir"] as? String,
          !raw.isEmpty else {
        return defaultRecordingDir()
    }
    let path = raw.hasPrefix("~/") ? HOME.appendingPathComponent(String(raw.dropFirst(2))).path : raw
    return URL(fileURLWithPath: path)
}

let RECORDING_DIR = loadRecordingDir()

var logFile: FileHandle?
func log(_ msg: String) {
    let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd HH:mm:ss"
    let line = "[\(df.string(from: Date()))] \(msg)"
    print(line); fflush(stdout)
    logFile?.write(Data((line + "\n").utf8))
}

// ─── WAV 写入器 ───────────────────────────────────────

class WavWriter {
    let url: URL
    var audioData = Data()

    init?(url: URL) {
        self.url = url
    }

    func append(_ data: Data) { audioData.append(data) }

    func finalize() {
        let audioSize = UInt32(audioData.count)
        let fileSize = audioSize + 44 - 8
        var h = Data()
        h.append(contentsOf: [0x52,0x49,0x46,0x46] as [UInt8])
        var v32 = fileSize.littleEndian
        withUnsafeBytes(of: &v32) { h.append(Data($0)) }
        h.append(contentsOf: [0x57,0x41,0x56,0x45] as [UInt8])
        h.append(contentsOf: [0x66,0x6D,0x74,0x20] as [UInt8])
        v32 = UInt32(16).littleEndian
        withUnsafeBytes(of: &v32) { h.append(Data($0)) }
        var v16 = UInt16(1).littleEndian
        withUnsafeBytes(of: &v16) { h.append(Data($0)) }
        v16 = UInt16(2).littleEndian
        withUnsafeBytes(of: &v16) { h.append(Data($0)) }
        v32 = UInt32(48000).littleEndian
        withUnsafeBytes(of: &v32) { h.append(Data($0)) }
        v32 = UInt32(48000*2*2).littleEndian
        withUnsafeBytes(of: &v32) { h.append(Data($0)) }
        v16 = UInt16(4).littleEndian
        withUnsafeBytes(of: &v16) { h.append(Data($0)) }
        v16 = UInt16(16).littleEndian
        withUnsafeBytes(of: &v16) { h.append(Data($0)) }
        h.append(contentsOf: [0x64,0x61,0x74,0x61] as [UInt8])
        v32 = audioSize.littleEndian
        withUnsafeBytes(of: &v32) { h.append(Data($0)) }
        h.append(audioData)
        try? h.write(to: url)
    }
}

func normalizeToDBFS(_ samples: [Int16], targetDB: Float = -20) -> [Int16] {
    guard !samples.isEmpty else { return samples }
    var sum: Float = 0; let m = Float(Int(Int16.max) * Int(Int16.max))
    for s in samples { sum += Float(s) * Float(s) / m }
    let rms = sqrt(sum / Float(samples.count))
    guard rms > 0.0001 else { return samples }
    let gain = pow(10.0, targetDB / 20.0) / rms
    let clamped = min(max(gain, 0.1), 10.0)
    return samples.map { Int16(max(-1.0, min(1.0, Float($0) / Float(Int16.max) * clamped)) * Float(Int16.max)) }
}

// ─── 状态管理 ──────────────────────────────────────────

func writeState(recording: Bool, title: String = "", path: String = "") {
    let df = ISO8601DateFormatter()
    let d: [String: Any] = ["recording": recording, "title": title, "file_path": path,
                             "updated_at": df.string(from: Date())]
    if let data = try? JSONSerialization.data(withJSONObject: d, options: .prettyPrinted) {
        try? data.write(to: STATE_PATH)
    }
}

// ─── 音频数据管理器 + 半双工混音 ───────────────────────

class AudioRecorder {
    var writer: WavWriter?
    var isRecording = false
    var startTime: Date?
    var lastAudioTime: Date?
    var silenceTask: DispatchWorkItem?
    var silenceSeconds = DEFAULT_SILENCE_SEC
    var onStopRequest: (() -> Void)?

    // Streaming buffers
    var sysBuf: [Int16] = []
    var micBuf: [Int16] = []
    let bufLock = NSLock()
    var fadePos: Float = 0  // 半双工混音进度（跨 chunk 持久）

    func start(title: String) -> String? {
        let df = DateFormatter(); df.dateFormat = "yyyyMMdd_HHmmss"
        let fn = "\(title.components(separatedBy: .alphanumerics.inverted).joined())_\(df.string(from: Date())).wav"
        let url = RECORDING_DIR.appendingPathComponent(fn)
        try? FileManager.default.createDirectory(at: RECORDING_DIR, withIntermediateDirectories: true)
        guard let w = WavWriter(url: url) else { return nil }
        writer = w; isRecording = true; startTime = Date(); lastAudioTime = Date()
        sysBuf = []; micBuf = []; fadePos = 0
        writeState(recording: true, title: title, path: url.path)
        log("🎙 \(fn)")
        startSilenceMonitor()
        return url.path
    }

    @discardableResult
    func stop() -> (path: String?, duration: Int) {
        isRecording = false
        silenceTask?.cancel()
        let dur = startTime.map { Int(Date().timeIntervalSince($0)) } ?? 0
        // Write remaining audio
        flushBuffers()
        let p = writer?.url.path
        writer?.finalize(); writer = nil
        writeState(recording: false)
        log("⏹ \(dur)s")
        return (p, dur)
    }

    func onSysAudio(_ samples: [Int16]) {
        guard isRecording else { return }
        bufLock.lock()
        sysBuf.append(contentsOf: samples)
        bufLock.unlock()
        let rms = calcRMS(samples)
        if rms > SILENCE_THRESHOLD { lastAudioTime = Date() }
        mixAndWrite()
    }

    func onMicAudio(_ samples: [Float]) {
        guard isRecording else { return }
        let ints = samples.map { Int16(max(-1.0, min(1.0, $0)) * Float(Int16.max)) }
        bufLock.lock()
        micBuf.append(contentsOf: ints)
        bufLock.unlock()
        let rms = calcRMS(ints)
        if rms > SILENCE_THRESHOLD { lastAudioTime = Date() }
        mixAndWrite()
    }

    private func calcRMS(_ s: [Int16]) -> Float {
        guard !s.isEmpty else { return 0 }
        var sum: Float = 0; let m = Float(Int(Int16.max) * Int(Int16.max))
        for v in s { sum += Float(v) * Float(v) / m }
        return sqrt(sum / Float(s.count))
    }

    /// 流式混音：从 sysBuf/micBuf 取等量的样本，半双工混合后写出
    private func mixAndWrite() {
        guard let w = writer else { return }
        bufLock.lock()
        // 取能混的最多样本数（sys 是 stereo, mic 是 mono）
        let outLen = sysBuf.count  // sys 驱动输出速率
        guard outLen >= 1024 else { bufLock.unlock(); return }  // 至少等 10ms 的数据再写
        let sysChunk = Array(sysBuf.prefix(outLen))
        sysBuf.removeFirst(outLen)

        // 从 micBuf 取对应 mono 样本数（sys/2 因为 stereo vs mono）
        let micNeeded = outLen / 2
        let micChunk: [Int16]
        if micBuf.count >= micNeeded {
            micChunk = Array(micBuf.prefix(micNeeded))
            micBuf.removeFirst(micNeeded)
        } else {
            micChunk = micBuf + [Int16](repeating: 0, count: micNeeded - micBuf.count)
            micBuf.removeAll()
        }
        bufLock.unlock()

        let mixed = halfDuplexMix(sys: sysChunk, mic: micChunk)
        w.append(Data(bytes: mixed, count: mixed.count * 2))
    }

    private func halfDuplexMix(sys: [Int16], mic: [Int16]) -> [Int16] {
        let n = sys.count  // stereo samples
        let m = min(mic.count, n / 2)  // mono samples

        // No normalization - use raw levels to avoid distortion
        let sysNorm = sys
        let micMono = mic
        var micStereo = [Int16](repeating: 0, count: n)
        for i in 0..<n { micStereo[i] = micMono[min(i/2, m-1)] }

        // Per-frame RMS analysis (30ms frames)
        let frameSamples = Int(SAMPLE_RATE * 30 / 1000) * 2  // stereo frame
        let numFrames = max(1, n / frameSamples)
        let fadeStep: Float = 1.0 / Float(Int(SAMPLE_RATE) * 2)

        var out = [Int16](repeating: 0, count: n)

        for fi in 0..<numFrames {
            let start = fi * frameSamples
            let end = min(start + frameSamples, n)
            let sysFrame = sysNorm[start..<end]
            var sysSum: Float = 0; let mVal = Float(Int(Int16.max) * Int(Int16.max))
            for v in sysFrame { sysSum += Float(v) * Float(v) / mVal }
            let sysActive = sqrt(sysSum / Float(end - start)) > SYS_ACTIVE_THRESHOLD

            if sysActive { fadePos = 0 }
            else { fadePos = min(1.0, fadePos + fadeStep * Float(end - start)) }

            let mixRatio = 1.0 - fadePos
            for i in start..<end {
                let sv = Float(sysNorm[i]) / Float(Int16.max)
                let mv = Float(micStereo[i]) / Float(Int16.max)
                out[i] = Int16(max(-1.0, min(1.0, mixRatio * sv + fadePos * mv)) * Float(Int16.max))
            }
        }
        return out
    }

    private func flushBuffers() {
        guard let w = writer else { return }
        bufLock.lock()
        if !sysBuf.isEmpty || !micBuf.isEmpty {
            let outLen = sysBuf.isEmpty ? micBuf.count * 2 : sysBuf.count
            let sys: [Int16] = sysBuf.isEmpty ? [Int16](repeating: 0, count: outLen) : sysBuf
            let mic: [Int16] = micBuf.isEmpty ? [Int16](repeating: 0, count: outLen/2) : micBuf
            sysBuf.removeAll(); micBuf.removeAll()
            bufLock.unlock()
            w.append(Data(bytes: halfDuplexMix(sys: sys, mic: Array(mic.prefix(outLen/2))), count: outLen * 2))
        } else { bufLock.unlock() }
    }

    private func startSilenceMonitor() {
        silenceTask?.cancel()
        let task = DispatchWorkItem { [weak self] in
            guard let self = self, self.isRecording else { return }
            if let last = self.lastAudioTime, Date().timeIntervalSince(last) >= self.silenceSeconds {
                log("🔇 silence \(Int(self.silenceSeconds))s — auto stop")
                self.onStopRequest?()
            }
        }
        silenceTask = task
        DispatchQueue.main.asyncAfter(deadline: .now() + silenceSeconds, execute: task)
    }
}

class MicCapture {
    let recorder: AudioRecorder
    var engine: AVAudioEngine?

    init(recorder: AudioRecorder) { self.recorder = recorder }

    func start() {
        let engine = AVAudioEngine()
        let input = engine.inputNode
        let fmt = input.outputFormat(forBus: 0)

        input.installTap(onBus: 0, bufferSize: 4096, format: fmt) { [weak self] buf, _ in
            guard let self = self, self.recorder.isRecording else { return }
            guard let chData = buf.floatChannelData else { return }
            let len = Int(buf.frameLength)
            let samples = Array(UnsafeBufferPointer(start: chData[0], count: len))
            self.recorder.onMicAudio(samples)
        }

        do { try engine.start(); self.engine = engine; MIC_READY = true; MIC_ERROR = ""; log("🎤 Mic capture started") }
        catch { MIC_READY = false; MIC_ERROR = "\(error)"; log("Mic start failed: \(error)") }
    }

    func stop() { engine?.stop(); engine = nil }
}

// ─── SCStream 输出处理器 ──────────────────────────────

@available(macOS 12.3, *)
class SysAudioOutput: NSObject, SCStreamOutput {
    unowned let recorder: AudioRecorder
    init(_ r: AudioRecorder) { recorder = r }

    func stream(_ s: SCStream, didOutputSampleBuffer buf: CMSampleBuffer, of type: SCStreamOutputType) {
        guard type == .audio, recorder.isRecording else { return }
        let asbd = buf.formatDescription.flatMap { CMAudioFormatDescriptionGetStreamBasicDescription($0)?.pointee }
        let flags = asbd?.mFormatFlags ?? 0
        let channels = Int(asbd?.mChannelsPerFrame ?? 2)
        let isFloat = (flags & kAudioFormatFlagIsFloat) != 0
        let isInt = (flags & kAudioFormatFlagIsSignedInteger) != 0
        let nonInterleaved = (flags & kAudioFormatFlagIsNonInterleaved) != 0
        if !SYS_FORMAT_LOGGED, let a = asbd {
            SYS_FORMAT_LOGGED = true
            log("SC audio ASBD: sr=\(a.mSampleRate) ch=\(a.mChannelsPerFrame) bits=\(a.mBitsPerChannel) bytesFrame=\(a.mBytesPerFrame) bytesPacket=\(a.mBytesPerPacket) framesPacket=\(a.mFramesPerPacket) float=\(isFloat) int=\(isInt) nonInterleaved=\(nonInterleaved) flags=0x\(String(flags, radix:16))")
        }
        guard let db = buf.dataBuffer else { return }
        var ptr: UnsafeMutablePointer<Int8>?; var len: Int = 0
        guard CMBlockBufferGetDataPointer(db, atOffset: 0, lengthAtOffsetOut: &len, totalLengthOut: nil, dataPointerOut: &ptr) == noErr,
              let p = ptr, len > 0 else { return }
        let cnt = len / MemoryLayout<Float>.size
        guard cnt > 0 else { return }

        let raw = UnsafeMutableRawPointer(p)
        let floats = [Float](UnsafeBufferPointer(start: raw.assumingMemoryBound(to: Float.self), count: cnt))
        let stereoFloats: [Float]
        if isFloat && nonInterleaved && channels >= 2 {
            // ScreenCaptureKit reports planar Float32. In CMBlockBuffer this is
            // laid out as all left samples followed by all right samples.
            let frames = cnt / channels
            var interleaved = [Float](); interleaved.reserveCapacity(frames * 2)
            for i in 0..<frames {
                interleaved.append(floats[i])
                interleaved.append(floats[frames + i])
            }
            stereoFloats = interleaved
        } else if isFloat && channels == 1 {
            var stereo = [Float](); stereo.reserveCapacity(cnt * 2)
            for v in floats { stereo.append(v); stereo.append(v) }
            stereoFloats = stereo
        } else {
            stereoFloats = floats
        }
        guard !stereoFloats.isEmpty else { return }
        let int16s = stereoFloats.map { Int16(max(-1.0, min(1.0, $0)) * Float(Int16.max)) }
        recorder.onSysAudio(int16s)
    }
}

// ─── 屏幕捕获管理器 ───────────────────────────────────

@available(macOS 12.3, *)
class AudioCapture {
    let recorder: AudioRecorder
    let output: SysAudioOutput
    var stream: SCStream?

    init(recorder: AudioRecorder) {
        self.recorder = recorder
        self.output = SysAudioOutput(recorder)
    }

    func startCapture() {
        Task {
            do {
                let content = try await SCShareableContent.current
                guard let d = content.displays.first else { log("No display"); return }
                let filter = SCContentFilter(display: d, excludingWindows: [])
                let config = SCStreamConfiguration()
                config.capturesAudio = true
                let s = SCStream(filter: filter, configuration: config, delegate: nil)
                try await s.startCapture()
                try s.addStreamOutput(output, type: .audio, sampleHandlerQueue: .global())
                self.stream = s
                SYS_READY = true; SYS_ERROR = ""
                log("🔊 Sys capture started (display \(d.displayID))")
            } catch {
                SYS_READY = false; SYS_ERROR = (error as NSError).localizedDescription
                log("Sys capture failed: \(SYS_ERROR)")
            }
        }
    }

    func stopCapture() { Task { try? await stream?.stopCapture() }; stream = nil }
}

// ─── Socket 服务器 ────────────────────────────────────

class SocketServer {
    let recorder: AudioRecorder
    var sock: Int32 = -1

    init(_ r: AudioRecorder) { recorder = r }

    func stop() {
        if sock >= 0 { close(sock); sock = -1 }
    }

    func start() {
        try? FileManager.default.removeItem(at: SOCKET_PATH)
        sock = Darwin.socket(AF_UNIX, SOCK_STREAM, 0)
        guard sock >= 0 else { log("Socket: create failed"); return }
        var addr = sockaddr_un()
        addr.sun_family = sa_family_t(AF_UNIX)
        _ = SOCKET_PATH.path.withCString { strncpy(&addr.sun_path.0, $0, min(SOCKET_PATH.path.utf8.count, 103)) }
        let ok = withUnsafePointer(to: &addr) {
            $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.bind(sock, $0, socklen_t(MemoryLayout<sockaddr_un>.size)) }
        }
        guard ok == 0 else { log("Socket: bind \(ok)"); close(sock); sock = -1; return }
        Darwin.listen(sock, 5); chmod(SOCKET_PATH.path, 0o666)
        log("Socket ready")
        DispatchQueue.global(qos: .background).async { [weak self] in
            while true {
                let c = Darwin.accept(self?.sock ?? -1, nil, nil)
                if c >= 0 { self?.handle(c); close(c) }
            }
        }
    }

    private func handle(_ c: Int32) {
        var data = Data()
        var buf = [UInt8](repeating: 0, count: 4096)
        while true { let n = read(c, &buf, 4096); if n <= 0 { break }; data.append(buf, count: n) }
        guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let action = json["action"] as? String else { send(c, ["error":"invalid"]); return }
        var resp: [String: Any]
        switch action {
        case "windows":
            resp = self.scanWindows()
        case "start":
            if !SYS_READY {
                resp = ["error":"sys_capture_not_ready", "sysReady": SYS_READY, "sysError": SYS_ERROR, "micReady": MIC_READY, "micError": MIC_ERROR]
            } else if !MIC_READY {
                resp = ["error":"mic_capture_not_ready", "sysReady": SYS_READY, "sysError": SYS_ERROR, "micReady": MIC_READY, "micError": MIC_ERROR]
            } else if let p = recorder.start(title: json["title"] as? String ?? "meeting") { resp = ["status":"recording", "file":p] }
            else { resp = ["error":"start_failed"] }
        case "stop":
            let (p, d) = recorder.stop(); resp = ["status":"stopped", "file": p ?? "", "duration": d]
        case "status":
            resp = ["recording": recorder.isRecording, "file": recorder.writer?.url.path ?? "", "sysReady": SYS_READY, "sysError": SYS_ERROR, "micReady": MIC_READY, "micError": MIC_ERROR]
        case "quit": resp = ["status":"bye"]; send(c, resp)
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { NSApp.terminate(nil) }; return
        default: resp = ["error":"unknown: \(action)"]
        }
        send(c, resp)
    }

    private func send(_ c: Int32, _ d: [String: Any]) {
        guard let data = try? JSONSerialization.data(withJSONObject: d) else { return }
        data.withUnsafeBytes { if let b = $0.baseAddress { _ = write(c, b, data.count) } }
    }

    func scanWindows() -> [String: Any] {
        let workspace = NSWorkspace.shared
        var results: [[String: String]] = []
        let apps = workspace.runningApplications
        for app in apps where app.activationPolicy == .regular {
            let appElem = AXUIElementCreateApplication(app.processIdentifier)
            var winList: CFTypeRef?
            guard AXUIElementCopyAttributeValue(appElem, kAXWindowsAttribute as CFString, &winList) == .success,
                  let wins = winList as? [AXUIElement] else { continue }
            for w in wins {
                var title: CFTypeRef?
                AXUIElementCopyAttributeValue(w, kAXTitleAttribute as CFString, &title)
                if let t = title as? String, !t.isEmpty {
                    results.append(["app": app.localizedName ?? "", "title": t])
                }
            }
        }
        return ["windows": results]
    }
}

// ─── App Delegate ──────────────────────────────────────

class AppDelegate: NSObject, NSApplicationDelegate {
    var recorder: AudioRecorder?
    var micCapture: MicCapture?
    var audioCapture: AudioCapture?
    var socketServer: SocketServer?

    func applicationDidFinishLaunching(_ n: Notification) {
        try? FileManager.default.createDirectory(at: CONFIG_DIR, withIntermediateDirectories: true)
        FileManager.default.createFile(atPath: LOG_PATH.path, contents: nil)
        logFile = try? FileHandle(forWritingTo: LOG_PATH)
        try? "\(ProcessInfo.processInfo.processIdentifier)".write(to: PID_PATH, atomically: true, encoding: .utf8)
        log("🎧 Audio Daemon (pid=\(ProcessInfo.processInfo.processIdentifier))")

        let rec = AudioRecorder(); recorder = rec
        rec.onStopRequest = { [weak self] in self?.recorder?.stop() }

        if #available(macOS 12.3, *) {
            let ac = AudioCapture(recorder: rec); audioCapture = ac
            DispatchQueue.main.asyncAfter(deadline: .now() + 1) { ac.startCapture() }
        }
        let mic = MicCapture(recorder: rec); micCapture = mic
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) { mic.start() }

        let ss = SocketServer(rec); socketServer = ss; ss.start()
        log("Ready")
    }

    func applicationWillTerminate(_ n: Notification) {
        recorder?.stop(); socketServer?.stop(); audioCapture?.stopCapture(); micCapture?.stop()
        try? FileManager.default.removeItem(at: PID_PATH)
        try? FileManager.default.removeItem(at: SOCKET_PATH)
        try? logFile?.close()
    }
}

// ─── 入口 ──────────────────────────────────────────────

let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.accessory)
app.run()


path: scripts/build_audio_daemon.sh
content: #!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP="$SCRIPT_DIR/AudioDaemon.app"
BIN="$SCRIPT_DIR/audio_daemon"
APP_BIN="$APP/Contents/MacOS/audio_daemon"
INFO="$APP/Contents/Info.plist"

cd "$SCRIPT_DIR"

swiftc -o "$BIN" audio_daemon.swift \
  -framework Cocoa \
  -framework ScreenCaptureKit \
  -framework AVFoundation \
  -framework CoreMedia \
  -framework CoreAudio

mkdir -p "$APP/Contents/MacOS"
cp "$BIN" "$APP_BIN"
chmod +x "$APP_BIN"

# Keep usage strings stable for TCC prompts.
/usr/libexec/PlistBuddy -c "Set :NSMicrophoneUsageDescription Meeting Assistant records microphone audio for meeting notes." "$INFO" >/dev/null 2>&1 || \
/usr/libexec/PlistBuddy -c "Add :NSMicrophoneUsageDescription string Meeting Assistant records microphone audio for meeting notes." "$INFO" >/dev/null 2>&1 || true
/usr/libexec/PlistBuddy -c "Set :NSScreenCaptureUsageDescription Meeting Assistant captures system audio for meeting notes." "$INFO" >/dev/null 2>&1 || \
/usr/libexec/PlistBuddy -c "Add :NSScreenCaptureUsageDescription string Meeting Assistant captures system audio for meeting notes." "$INFO" >/dev/null 2>&1 || true

IDENTITY="${MEETING_ASSISTANT_CODESIGN_IDENTITY:-}"
if [[ -z "$IDENTITY" ]]; then
  IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null | awk -F'"' '/Apple Development: [REDACTED_SECRET]/ {print $2; exit}')"
fi
if [[ -z "$IDENTITY" ]]; then
  IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null | awk -F'"' '/Developer ID Application|Apple Development|Mac Developer|PolyMeet Dev/ {print $2; exit}')"
fi
if [[ -z "$IDENTITY" ]]; then
  echo "⚠️ No fixed code-signing identity found; falling back to ad-hoc." >&2
  IDENTITY="-"
fi

codesign --force --deep --timestamp=none --sign "$IDENTITY" "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"

echo "✅ Built and signed AudioDaemon.app"
echo "   identity: $IDENTITY"
codesign -dvvv "$APP" 2>&1 | grep -E 'Identifier|Authority|TeamIdentifier|CDHash|Signature' || true


path: scripts/check_meetings.py
content: #!/usr/bin/env python3
"""
查询日历会议。

支持：飞书日历、Google Calendar（需要配置）。

用法：
  check_meetings.py today       # 获取今天所有会议
  check_meetings.py upcoming    # 获取未来24小时会议
  check_meetings.py json        # 输出 JSON 格式（供其他脚本使用）
"""

import json
import os
import subprocess
import sys
from datetime import datetime, timedelta
from pathlib import Path

CONFIG_PATH = Path.home() / ".config" / "meeting-assistant" / "config.json"


def load_config():
    if not CONFIG_PATH.exists():
        print(f"Config not found at {CONFIG_PATH}", file=sys.stderr)
        sys.exit(1)
    with open(CONFIG_PATH) as f:
        return json.load(f)


def fetch_meetings(start, end, config=None):
    """获取指定时间范围内的会议。"""
    if config is None:
        config = load_config()
    
    all_meetings = []
    for cal in config.get("calendars", []):
        if not cal.get("enabled", False):
            continue
        if cal["type"] == "feishu":
            all_meetings.extend(_fetch_feishu(cal, start, end))
        elif cal["type"] == "google":
            all_meetings.extend(_fetch_google(cal, start, end))
    
    all_meetings.sort(key=lambda m: m["start"])
    return all_meetings


def _fetch_feishu(config, start, end):
    """
    通过飞书 API 获取会议列表。
    
    需要配置：
    - feishu_app_id / feishu_app_secret（环境变量或 config）
    
    TODO: 实现具体 API 调用
    """
    # 示例返回格式：
    # return [
    #     {
    #         "id": "event_001",
    #         "title": "项目周会",
    #         "start": "2026-04-29T10:00:00",
    #         "end": "2026-04-29T11:00:00",
    #         "link": "https://meetings.feishu.cn/...",
    #         "attendees": ["张三", "李四"],
    #     }
    # ]
    return []


def _fetch_google(config, start, end):
    """
    通过 gog CLI 获取 Google Calendar 会议列表。
    不需要 Google API Python 库，gog 处理了 OAuth。
    """
    import subprocess

    account = config.get("gog_account")
    if not account:
        print("⚠️ Google Calendar: 未配置 gog_account", file=sys.stderr)
        return []

    fmt_start = start.strftime("%Y-%m-%dT%H:%M:%S%z") if hasattr(start, 'strftime') else str(start)
    fmt_end = end.strftime("%Y-%m-%dT%H:%M:%S%z") if hasattr(end, 'strftime') else str(end)

    cmd = [
        "gog", "calendar", "events", account,
        "--from", fmt_start,
        "--to", fmt_end,
        "--json",
    ]
    env = {**os.environ, "GOG_ACCOUNT": account}

    try:
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=env)
        if r.returncode != 0:
            print(f"⚠️ gog error: {r.stderr.strip()}", file=sys.stderr)
            return []
        data = json.loads(r.stdout)
    except Exception as e:
        print(f"⚠️ Google Calendar 获取失败: {e}", file=sys.stderr)
        return []

    meetings = []
    for ev in data.get("events", []):
        start_raw = ev.get("start", {}).get("dateTime") or ev.get("start", {}).get("date")
        end_raw = ev.get("end", {}).get("dateTime") or ev.get("end", {}).get("date")
        if not start_raw:
            continue

        # Google Meet 链接
        link = ev.get("hangoutLink") or ""
        if not link and ev.get("conferenceData"):
            for ep in ev["conferenceData"].get("entryPoints", []):
                if ep.get("entryPointType") == "video":
                    link = ep.get("uri", "")
                    break

        # 参会人
        attendees = []
        for a in ev.get("attendees", []):
            name = a.get("displayName") or a.get("email", "")
            if name:
                attendees.append(name)

        meetings.append({
            "id": ev.get("id", ""),
            "title": ev.get("summary", "(无标题)"),
            "start": start_raw,
            "end": end_raw,
            "link": link,
            "attendees": attendees,
            "description": ev.get("description", ""),
            "source": "google",
        })

    return meetings


def print_meetings(meetings):
    """打印会议列表。"""
    if not meetings:
        print("没有找到会议。")
        return
    
    print(f"\n📅 找到 {len(meetings)} 个会议:\n")
    for m in meetings:
        start = datetime.fromisoformat(m["start"].replace("Z", "+00:00"))
        print(f"  • {m['title']}")
        print(f"    时间: {start.strftime('%Y-%m-%d %H:%M')}")
        if m.get("link"):
            print(f"    链接: {m['link']}")
        print()


def main():
    args = sys.argv[1:]
    use_json = "--json" in args or "-j" in args
    args = [a for a in args if a not in ("--json", "-j")]

    cmd = args[0] if args else "today"

    now = datetime.now().astimezone()

    if cmd == "today":
        start = now.replace(hour=0, minute=0, second=0, microsecond=0)
        end = start + timedelta(days=1)
    elif cmd == "tomorrow":
        start = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
        end = start + timedelta(days=1)
    elif cmd == "yesterday":
        start = (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
        end = start + timedelta(days=1)
    elif cmd == "upcoming":
        start = now
        end = now + timedelta(hours=24)
    elif cmd == "week":
        start = now.replace(hour=0, minute=0, second=0, microsecond=0)
        end = start + timedelta(days=7)
    elif cmd == "json":
        start = now.replace(hour=0, minute=0, second=0, microsecond=0)
        end = start + timedelta(days=1)
        meetings = fetch_meetings(start, end)
        print(json.dumps(meetings, ensure_ascii=False, indent=2))
        return
    else:
        print(f"Unknown command: {cmd}", file=sys.stderr)
        sys.exit(1)

    meetings = fetch_meetings(start, end)
    if use_json:
        print(json.dumps(meetings, ensure_ascii=False, indent=2))
    else:
        print_meetings(meetings)


if __name__ == "__main__":
    main()


path: scripts/config.example.json
content: {
  "calendars": [
    {
      "type": "feishu",
      "enabled": false,
      "app_id_env": "FEISHU_APP_ID",
      "app_secret_env": "FEISHU_APP_SECRET"
    },
    {
      "type": "google",
      "enabled": false,
      "gog_account": "[REDACTED_SECRET]",
      "watch_calendars": ["primary"]
    }
  ],
  "audio": {
    "backend": "daemon",
    "mic_device": ":0",
    "system_audio_device": ":1",
    "output_dir": "/path/to/meeting-assistant/meeting-recordings",
    "format": "wav",
    "silence_threshold": 0.01,
    "silence_duration_sec": 300,
    "half_duplex": true,
    "note": "backend=daemon 使用原生 ScreenCaptureKit + AVFoundation，不需要 BlackHole；mic/system device 仅 SoX fallback 使用"
  },
  "transcription": {
    "mode": "local",
    "api_key_env": "OPENAI_API_KEY",
    "language": "zh",
    "model": "whisper-1",
    "local_model_path": "~/Models/whisper/ggml-medium.bin",
    "whisper_cli": "whisper-cli"
  },
  "llm": {
    "enabled": false,
    "provider": "openai",
    "model": "gpt-4",
    "api_key_env": "OPENAI_API_KEY"
  },
  "output": {
    "channel": "file",
    "zulip": {
      "stream": "meetings",
      "topic": "会议纪要"
    },
    "notion": {
      "api_key_env": "NOTION_API_KEY",
      "database_id": "your-database-id"
    },
    "telegram": {
      "chat_id": ""
    }
  },
  "meeting_detection": {
    "enabled": true,
    "interval_sec": 10,
    "stable_sec": 15,
    "prompt_cooldown_sec": 1800,
    "window_keywords": [
      "Zoom Meeting", "Zoom 会议", "zoom.us", "Zoom Workplace",
      "腾讯会议", "Tencent Meeting", "VooV Meeting", "WeMeet",
      "Google Meet", "meet.google.com", "Meet -",
      "飞书会议", "Feishu Meeting", "Lark Meeting", "视频会议",
      "微信通话", "微信电话", "语音通话", "视频通话", "Voice Call",
      "WeChat Call", "企业微信", "WeCom",
      "正在通话", "正在会议", "加入会议", "会议中", "通话中"
    ],
    "app_name_hints": [
      "zoom.us", "Zoom", "腾讯会议", "TencentMeeting", "VooV", "WeMeet",
      "Feishu", "Lark", "飞书", "WeChat", "微信", "企业微信", "WeCom",
      "Google Chrome", "Arc", "Safari", "Microsoft Edge", "Firefox"
    ],
    "target_app_names": [
      "zoom.us", "Zoom", "腾讯会议", "TencentMeeting", "VooV Meeting", "WeMeet",
      "Feishu", "Lark", "飞书", "WeChat", "微信", "企业微信", "WeCom",
      "Google Chrome", "Arc", "Safari", "Microsoft Edge", "Firefox"
    ],
    "dedicated_meeting_apps": [
      "zoom.us", "Zoom", "腾讯会议", "TencentMeeting", "VooV Meeting", "WeMeet"
    ],
    "ignore_window_keywords": [
      "Calendar", "日历", "Gmail", "Inbox", "Settings", "Preferences",
      "聊天", "通讯录", "朋友圈", "文件传输助手",
      "PolyMeet"
    ]
  }
}


path: scripts/echo_cancel.py
content: """
简单高效的回声消除方案：半双工切换 + 音量归一化。

原理：
- 扬声器（BlackHole）有声音 → 麦克风有回声 → 只取扬声器
- 扬声器（BlackHole）无声 → 麦克风录到人声 → 只取麦克风
- 两者音量归一化到同一级别

优点：O(n) 线性时间，再长的录音也是秒级完成。
"""

import sys
import wave
from pathlib import Path

import numpy as np


def read_wav(path):
    with wave.open(str(path), 'rb') as w:
        sr = w.getframerate()
        raw = w.readframes(w.getnframes())
        samples = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
    return sr, samples


def write_wav(path, sr, samples):
    with wave.open(str(path), 'wb') as w:
        w.setnchannels(1)
        w.setsampwidth(2)
        w.setframerate(sr)
        w.writeframes((samples * 32767).astype(np.int16).tobytes())


def rms_energy(x):
    """计算 RMS 能量 (dB)。"""
    return 20 * np.log10(np.sqrt(np.mean(x ** 2)) + 1e-10)


def normalize_to_target(x, target_rms_db=-20):
    """将信号 RMS 归一化到目标电平。"""
    current_db = rms_energy(x)
    gain = 10 ** ((target_rms_db - current_db) / 20)
    return np.clip(x * gain, -0.99, 0.99)


def echo_cancel(sys_path, mic_path, output_path, frame_ms=30):
    """
    半双工回声消除 + 音量归一化。
    
    策略：逐帧分析系统音频能量，决定当前帧用哪个源。
    - frame_ms: 分析帧长（毫秒），默认 30ms
    """
    sr, sys_audio = read_wav(sys_path)
    sr2, mic_audio = read_wav(mic_path)
    
    if sr != sr2:
        sr = min(sr, sr2)
    
    min_len = min(len(sys_audio), len(mic_audio))
    sys_audio = sys_audio[:min_len]
    mic_audio = mic_audio[:min_len]
    
    # 帧参数
    frame_size = max(1, int(sr * frame_ms / 1000))
    n_frames = max(1, (min_len + frame_size - 1) // frame_size)

    # 用原始系统音频做门限判断，避免 normalize 把 BlackHole 底噪放大后误判为“系统有声”。
    sys_frame_db = np.array([
        rms_energy(sys_audio[i * frame_size:min((i + 1) * frame_size, min_len)])
        for i in range(n_frames)
    ])
    noise_floor = float(np.percentile(sys_frame_db, 20))
    sys_threshold = max(-50.0, min(-30.0, noise_floor + 12.0))
    sys_active_flags = sys_frame_db > sys_threshold

    # 音量归一化到同一目标
    print("🎚️ 音量归一化...")
    sys_norm = normalize_to_target(sys_audio, -20)
    mic_norm = normalize_to_target(mic_audio, -20)
    
    # 逐帧处理：用平滑的 mic_ratio 做交叉混合
    # sys 有声 → mic_ratio=0（100% 系统音频）
    # sys 无声 → mic_ratio 0→1 渐变过渡（0.5s 从系统切到麦克风）
    # sys 又响 → mic_ratio 立即归 0（切回系统音频）
    output = np.zeros(min_len, dtype=np.float32)
    fade_samples = int(sr * 0.5)  # 0.5s 渐变长度
    attack = 1.0 / fade_samples  # 每采样点增量
    
    print(f"🔇 半双工混合 (帧长={frame_ms}ms, 噪声底={noise_floor:.1f}dB, 阈值={sys_threshold:.1f}dB, 渐变=0.5s)...")
    
    mic_ratio = 0.0  # 0=纯系统音频, 1=纯麦克风
    sys_active = False  # 系统当前是否有声
    
    for i in range(min_len):
        frame_idx = i // frame_size
        frame_start = frame_idx * frame_size
        
        # 每帧计算一次系统音频能量
        if i == frame_start:
            sys_active = bool(sys_active_flags[min(frame_idx, n_frames - 1)])
        
        if sys_active:
            # 系统有声音：mic_ratio 立即清零（切回系统音频）
            mic_ratio = 0.0
        else:
            # 系统无声：mic_ratio 渐增（麦克风慢慢变强）
            mic_ratio = min(1.0, mic_ratio + attack)
        
        # 交叉混合
        output[i] = (1.0 - mic_ratio) * sys_norm[i] + mic_ratio * mic_norm[i]
    
    # 统计
    sys_cnt = int(np.count_nonzero(sys_active_flags))
    print(f"📊 组成: 系统音频 {sys_cnt/n_frames*100:.0f}% | 麦克风 {(1-sys_cnt/n_frames)*100:.0f}%")
    
    # 最终音量提升
    output = np.clip(output * 1.5, -0.99, 0.99)
    
    write_wav(output_path, sr, output)
    size_mb = Path(output_path).stat().st_size / 1024 / 1024
    print(f"✅ 完成: {output_path} ({size_mb:.1f}MB)")
    return str(output_path)


if __name__ == "__main__":
    if len(sys.argv) < 3:
        print("Usage: echo_cancel.py <sys_audio.wav> <mic_audio.wav> [output.wav]", file=sys.stderr)
        sys.exit(1)
    sys_path = sys.argv[1]
    mic_path = sys.argv[2]
    output_path = sys.argv[3] if len(sys.argv) > 3 else \
        str(Path(mic_path).with_suffix(".mic_clean.wav"))
    echo_cancel(sys_path, mic_path, output_path)


path: scripts/meeting_daemon.py
content: #!/usr/bin/env python3
"""
会议助手主控脚本（事件驱动版本，无轮询）。

调度由常驻的 scheduler_daemon.py 负责（LaunchAgent 启动），
本脚本只负责：扫日历写 schedule.json、手动添加会议、录制控制、纪要生成。

命令：
  schedule                                 扫描今日日历，重写 schedule.json，通知调度器
  add <title> <start_iso> [duration_min]   手动添加一场会议（默认 60 分钟）
  test [title]                             添加一条 30 秒后的测试会议（默认"测试会议"）
  list                                     显示当前调度
  remove <meeting_id>                      移除某场会议
  ask_record <title> <meeting_id>          会议开始时弹窗询问是否录制（由调度器 fire）
  auto_stop                                录制超时弹窗询问是否停止（由调度器 fire）
  stop                                     立即停止录制并生成纪要
  detect [once|daemon]                     检测当前是否处于会议/通话场景
"""

import json
import os
import signal
import subprocess
import sys
import uuid
from datetime import datetime, timedelta
from pathlib import Path

CONFIG_DIR = Path.home() / ".config" / "meeting-assistant"
CONFIG_PATH = CONFIG_DIR / "config.json"
SCHEDULE_PATH = CONFIG_DIR / "schedule.json"
STATE_PATH = CONFIG_DIR / ".state.json"
SCHEDULER_PID = CONFIG_DIR / ".scheduler.pid"
SCRIPT_DIR = Path(__file__).resolve().parent

DEFAULT_DURATION_MIN = 60


# ───────────────────────────────────────────────
# IO helpers
# ───────────────────────────────────────────────

def load_config():
    if not CONFIG_PATH.exists():
        print(f"Config not found at {CONFIG_PATH}", file=sys.stderr)
        sys.exit(1)
    with open(CONFIG_PATH) as f:
        return json.load(f)


def load_schedule():
    if not SCHEDULE_PATH.exists():
        return {"events": [], "meetings": []}
    with open(SCHEDULE_PATH) as f:
        data = json.load(f)
    data.setdefault("events", [])
    data.setdefault("meetings", [])
    return data


def _prune_past_events(data, grace_sec=15):
    """清理已过期事件/会议，避免 schedule reload 后旧测试/ask_stop 重复触发。"""
    now_ts = datetime.now().timestamp()

    kept_events = []
    for ev in data.get("events", []):
        try:
            at_ts = parse_iso(ev["at"]).timestamp()
        except Exception:
            continue
        if at_ts + grace_sec >= now_ts:
            kept_events.append(ev)
    data["events"] = kept_events

    kept_meetings = []
    for meeting in data.get("meetings", []):
        try:
            start = parse_iso(meeting["start"])
            duration = int(meeting.get("duration_min", DEFAULT_DURATION_MIN))
            end_ts = (start + timedelta(minutes=duration)).timestamp()
        except Exception:
            continue
        if end_ts + grace_sec >= now_ts:
            kept_meetings.append(meeting)
    data["meetings"] = kept_meetings
    return data


def save_schedule(data):
    SCHEDULE_PATH.parent.mkdir(parents=True, exist_ok=True)
    data = _prune_past_events(data)
    with open(SCHEDULE_PATH, "w") as f:
        json.dump(data, f, indent=2, ensure_ascii=False, default=str)
    notify_scheduler()


def load_state():
    if not STATE_PATH.exists():
        return {"recording": {}}
    with open(STATE_PATH) as f:
        return json.load(f)


def save_state(state):
    STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
    with open(STATE_PATH, "w") as f:
        json.dump(state, f, indent=2, ensure_ascii=False, default=str)


def notify_scheduler():
    """给 scheduler_daemon 发 SIGHUP 让它重读 schedule。"""
    if not SCHEDULER_PID.exists():
        print("⚠️ scheduler_daemon 未运行，已写入 schedule.json 但未通知调度器")
        return
    try:
        pid = int(SCHEDULER_PID.read_text().strip())
        os.kill(pid, signal.SIGHUP)
        print(f"✅ 已通知调度器 (pid={pid})")
    except (ValueError, ProcessLookupError) as e:
        print(f"⚠️ 调度器 PID 无效或已退出: {e}")


def parse_iso(s):
    return datetime.fromisoformat(s.replace("Z", "+00:00"))


# ───────────────────────────────────────────────
# 日历获取（占位符，等真正接入再补）
# ───────────────────────────────────────────────

def fetch_today_meetings():
    config = load_config()
    now = datetime.now()
    start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
    end_of_day = start_of_day + timedelta(days=1)

    out = []
    for cal in config.get("calendars", []):
        if not cal.get("enabled"):
            continue
        kind = cal.get("type")
        if kind == "feishu":
            out.extend(_fetch_feishu(cal, start_of_day, end_of_day))
        elif kind == "google":
            out.extend(_fetch_google(cal, start_of_day, end_of_day))
    out.sort(key=lambda m: m["start"])
    return out


def _fetch_feishu(cfg, start, end):
    return []  # TODO


def _fetch_google(cfg, start, end):
    return []  # TODO


# ───────────────────────────────────────────────
# 调度命令：scan / add / list / remove
# ───────────────────────────────────────────────

def _build_events_for_meeting(meeting):
    """把一场 meeting 展开成 scheduler 用的事件列表。"""
    start = parse_iso(meeting["start"])
    remind_at = start - timedelta(minutes=5)
    return [
        {
            "id": f"{meeting['id']}::remind",
            "kind": "remind",
            "at": remind_at.isoformat(),
            "meeting_id": meeting["id"],
            "title": meeting["title"],
        },
        {
            "id": f"{meeting['id']}::ask_record",
            "kind": "ask_record",
            "at": start.isoformat(),
            "meeting_id": meeting["id"],
            "title": meeting["title"],
        },
    ]


def cmd_schedule():
    print("📅 扫描今天日历...")
    meetings = fetch_today_meetings()
    if not meetings:
        print("今天没有会议（或日历未接入）。")

    sched = {"events": [], "meetings": []}
    for m in meetings:
        m.setdefault("id", str(uuid.uuid4())[:8])
        m.setdefault("duration_min", DEFAULT_DURATION_MIN)
        sched["meetings"].append(m)
        sched["events"].extend(_build_events_for_meeting(m))
        print(f"  • {m['title']} @ {parse_iso(m['start']).strftime('%H:%M')}")

    save_schedule(sched)
    print(f"✅ 已写入 {len(sched['meetings'])} 场会议、{len(sched['events'])} 个事件")


def cmd_add(args):
    if len(args) < 2:
        print("Usage: meeting_daemon.py add <title> <start_iso> [duration_min]", file=sys.stderr)
        sys.exit(1)
    title = args[0]
    start_iso = args[1]
    duration = int(args[2]) if len(args) > 2 else DEFAULT_DURATION_MIN

    try:
        start = parse_iso(start_iso)
    except Exception as e:
        print(f"start_iso 解析失败: {e}", file=sys.stderr)
        sys.exit(1)

    meeting = {
        "id": str(uuid.uuid4())[:8],
        "title": title,
        "start": start.isoformat(),
        "duration_min": duration,
    }
    data = load_schedule()
    data["meetings"].append(meeting)
    data["events"].extend(_build_events_for_meeting(meeting))
    save_schedule(data)
    print(f"✅ 已添加: [{meeting['id']}] {title} @ {start.strftime('%Y-%m-%d %H:%M')} ({duration}min)")


def cmd_list():
    data = load_schedule()
    meetings = data.get("meetings", [])
    if not meetings:
        print("（无）")
        return
    now = datetime.now()
    for m in meetings:
        start = parse_iso(m["start"])
        marker = "📌" if start > now else "✓ "
        print(f"  {marker} [{m['id']}] {m['title']} @ {start.strftime('%Y-%m-%d %H:%M')} ({m.get('duration_min', '?')}min)")
    print()
    print("事件队列:")
    for ev in data.get("events", []):
        print(f"  - {ev['kind']:<11} @ {ev['at']}  ({ev.get('title','')})")


def cmd_remove(args):
    if not args:
        print("Usage: meeting_daemon.py remove <meeting_id>", file=sys.stderr)
        sys.exit(1)
    mid = args[0]
    data = load_schedule()
    before_m = len(data.get("meetings", []))
    data["meetings"] = [m for m in data.get("meetings", []) if m.get("id") != mid]
    data["events"] = [e for e in data.get("events", []) if e.get("meeting_id") != mid]
    save_schedule(data)
    print(f"✅ 已移除 {before_m - len(data['meetings'])} 场会议")


def cmd_test(args):
    """添加一条60秒后的测试会议。"""
    title = args[0] if args else "测试会议"
    start = datetime.now() + timedelta(seconds=60)
    meeting = {
        "id": "__test__",
        "title": title,
        "start": start.isoformat(),
        "duration_min": 5,
    }
    data = load_schedule()
    # 清理旧测试事件
    data["events"] = [e for e in data.get("events", []) if "__test__" not in e.get("id", "")]
    data["meetings"] = [m for m in data.get("meetings", []) if m.get("id") != "__test__"]
    data["meetings"].append(meeting)
    data["events"].extend(_build_events_for_meeting(meeting))
    save_schedule(data)
    print(f"🧪 测试会议「{title}」 @ {start.strftime('%H:%M:%S')} (60秒后)")


# ───────────────────────────────────────────────
# 录制控制（由调度器 fire 或用户手动）
# ───────────────────────────────────────────────

def cmd_ask_record(args):
    """会议开始时弹窗，用户决定是否录制。"""
    if len(args) < 1:
        print("Usage: meeting_daemon.py ask_record <title> [meeting_id]", file=sys.stderr)
        sys.exit(1)
    title = args[0]
    meeting_id = args[1] if len(args) > 1 else ""

    notify = SCRIPT_DIR / "notify.py"
    result = subprocess.run(
        [sys.executable, str(notify), "ask_record", title],
        capture_output=True, text=True,
    )
    choice = result.stdout.strip()
    print(f"User choice: {choice}")

    # 移除本条 ask_record 事件，防止调度器重载后重复触发
    _remove_ask_record_event(meeting_id)

    if choice == "开始录制":
        _start_recording(title, meeting_id)
    else:
        print("用户跳过录制")


def _remove_ask_record_event(meeting_id):
    """从 schedule 中移除已触发的 ask_record 事件。"""
    if not meeting_id:
        return
    data = load_schedule()
    before = len(data.get("events", []))
    data["events"] = [
        e for e in data.get("events", [])
        if not (e.get("kind") == "ask_record" and e.get("meeting_id") == meeting_id)
    ]
    if len(data.get("events", [])) < before:
        save_schedule(data)


def _start_recording(title, meeting_id=""):
    print(f"🎙️ 开始录制: {title}")
    record = SCRIPT_DIR / "record_audio.py"
    result = subprocess.run(
        [sys.executable, str(record), "start", title],
        capture_output=True, text=True,
    )
    audio_path = None
    for line in result.stdout.split("\n"):
        # 新 record_audio.py 日志格式
        if "/meeting-recordings/" in line and ".wav" in line:
            import re
            m = re.search(r'(/Users/[^\s]+\.wav)', line)
            if m:
                audio_path = m.group(1)
        # 旧格式兼容
        if line.startswith("Output:"):
            audio_path = line.split("Output:", 1)[1].strip()
    if not audio_path:
        print(f"❌ 录制启动失败\nstdout={result.stdout}\nstderr={result.stderr}", file=sys.stderr)
        return

    state = load_state()
    state["recording"] = {
        "title": title,
        "meeting_id": meeting_id,
        "audio_path": audio_path,
        "start_time": datetime.now().isoformat(),
    }
    save_state(state)
    print(f"✅ 录制中: {audio_path}")

    # 启动状态浮窗
    _launch_status_window(title)

    # 注册"录制超时询问停止"事件：会议结束时间触发
    duration_min = _meeting_duration(meeting_id)
    end_at = datetime.now() + timedelta(minutes=duration_min)
    _add_runtime_event({
        "id": f"recording-{meeting_id or 'manual'}::ask_stop",
        "kind": "ask_stop",
        "at": end_at.isoformat(),
        "meeting_id": meeting_id,
        "title": title,
    })
    print(f"📅 已注册超时停止询问 @ {end_at.strftime('%H:%M')}")


def _meeting_duration(meeting_id):
    if not meeting_id:
        return DEFAULT_DURATION_MIN
    data = load_schedule()
    for m in data.get("meetings", []):
        if m.get("id") == meeting_id:
            return int(m.get("duration_min", DEFAULT_DURATION_MIN))
    return DEFAULT_DURATION_MIN


def _add_runtime_event(ev):
    """运行时给 schedule 加一个事件并通知调度器。"""
    data = load_schedule()
    data["events"].append(ev)
    save_schedule(data)


def cmd_auto_stop():
    state = load_state()
    rec = state.get("recording") or {}
    if not rec:
        print("没有正在进行的录制")
        return
    title = rec.get("title", "")
    notify = SCRIPT_DIR / "notify.py"
    result = subprocess.run(
        [sys.executable, str(notify), "ask_stop", title],
        capture_output=True, text=True,
    )
    choice = result.stdout.strip()
    print(f"Stop choice: {choice}")

    if choice in ("停止录制", "停止", "timeout"):
        _stop_and_process()
    else:
        # 用户选继续：再延 30 分钟问一次。save_schedule 会顺手清理已过期 ask_stop。
        end_at = datetime.now() + timedelta(minutes=30)
        _add_runtime_event({
            "id": f"recording-{rec.get('meeting_id','manual')}::ask_stop_extended",
            "kind": "ask_stop",
            "at": end_at.isoformat(),
            "meeting_id": rec.get("meeting_id", ""),
            "title": title,
        })
        print(f"⏭ 继续录制，{end_at.strftime('%H:%M')} 再次询问")


def cmd_stop():
    print("🛑 手动停止录制")
    _stop_and_process()


def _launch_status_window(title):
    """启动状态浮窗（先杀掉旧的）。"""
    _kill_status_window()
    status_bin = SCRIPT_DIR / "recorder_status"
    if not status_bin.exists():
        print("⚠️ recorder_status 未编译，跳过浮窗")
        return
    proc = subprocess.Popen(
        [str(status_bin), title, str(STATE_PATH)],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    state = load_state()
    state["_status_pid"] = proc.pid
    save_state(state)
    print(f"🪟 状态浮窗已启动 (pid={proc.pid})")


def _kill_status_window():
    state = load_state()
    pid = state.pop("_status_pid", None)
    save_state(state)
    if pid:
        try:
            os.kill(pid, signal.SIGTERM)
            # 也杀所有同名进程（防残留）
            subprocess.run(
                ["pkill", "-f", "recorder_status"],
                capture_output=True,
            )
        except ProcessLookupError:
            pass


def _stop_and_process():
    # 先关状态浮窗
    _kill_status_window()

    state = load_state()
    rec = state.get("recording") or {}
    if not rec:
        print("没有正在进行的录制", file=sys.stderr)
        return

    title = rec.get("title", "meeting")
    audio_path = rec.get("audio_path")

    # 1. 停录制
    record = SCRIPT_DIR / "record_audio.py"
    subprocess.run([sys.executable, str(record), "stop"], capture_output=True)

    notify = SCRIPT_DIR / "notify.py"
    subprocess.Popen([sys.executable, str(notify), "notify_stop", title],
                     stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

    # 2. 通知 agent 开始转录
    try:
        from agent_notify import notify
        notify("transcribing", title=title)
    except Exception:
        pass

    # 3. 转录 + 摘要
    print("📝 转录 + 摘要...")
    transcribe = SCRIPT_DIR / "transcribe.py"
    result = subprocess.run(
        [sys.executable, str(transcribe), audio_path],
        capture_output=True, text=True,
    )
    if result.returncode != 0:
        print(f"❌ 转录失败:\n{result.stderr}", file=sys.stderr)
        return
    print(result.stdout)

    # 4. 通知 agent 转录完成
    transcript_path = None
    summary_path = None
    summary_pending_agent = False
    for line in result.stdout.split("\n"):
        if line.startswith("Transcript saved:"):
            transcript_path = line.split("Transcript saved:", 1)[1].strip()
        if line.startswith("Summary saved:"):
            summary_path = line.split("Summary saved:", 1)[1].strip()
        if line.startswith("Summary status:") and "draft_agent_pending" in line:
            summary_pending_agent = True

    try:
        notify("transcript", title=title, path=transcript_path or "")
    except Exception:
        pass

    if not summary_path:
        print("❌ 找不到 summary 路径", file=sys.stderr)
        return

    # 3. 发送。若 summary 等待 agent 最终生成，则不发送草稿，由 heartbeat 的 summary_request 负责覆盖并推送。
    if not summary_pending_agent:
        send = SCRIPT_DIR / "send_summary.py"
        subprocess.run([sys.executable, str(send), summary_path], capture_output=True)

        config = load_config()
        channel = config.get("output", {}).get("channel", "file")
        subprocess.Popen(
            [sys.executable, str(notify), "notify_sent", title, channel],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
        )
    else:
        print("⏳ Summary 草稿已保存，等待 agent 按模板生成最终版")

    state["recording"] = {}
    save_state(state)

    # 清理当前录制相关的过期 ask_stop 事件，避免测试/重载后残留。
    try:
        data = load_schedule()
        save_schedule(data)
    except Exception:
        pass

    print(f"✅ 完成: {summary_path}")


# ───────────────────────────────────────────────
# 主入口
# ───────────────────────────────────────────────

def main():
    if len(sys.argv) < 2:
        print(__doc__)
        sys.exit(1)
    cmd = sys.argv[1]
    args = sys.argv[2:]
    handlers = {
        "schedule": lambda: cmd_schedule(),
        "add": lambda: cmd_add(args),
        "test": lambda: cmd_test(args),
        "list": lambda: cmd_list(),
        "remove": lambda: cmd_remove(args),
        "ask_record": lambda: cmd_ask_record(args),
        "auto_stop": lambda: cmd_auto_stop(),
        "stop": lambda: cmd_stop(),
        "detect": lambda: subprocess.run([
            sys.executable,
            str(SCRIPT_DIR / "meeting_detector.py"),
            args[0] if args else "once",
        ]),
    }
    handler = handlers.get(cmd)
    if not handler:
        print(f"Unknown command: {cmd}\n", file=sys.stderr)
        print(__doc__, file=sys.stderr)
        sys.exit(1)
    handler()


if __name__ == "__main__":
    main()


path: scripts/meeting_detector.py
content: #!/usr/bin/env python3
"""
会议检测守护进程：检测常见会议/通话窗口，触发录制询问。

目标：不依赖日历，发现微信语音/视频电话、腾讯会议、Google Meet、飞书/Lark、Zoom 等会议场景。

策略：
- 优先读取 macOS 前台应用/窗口标题（System Events）
- 用窗口标题关键词判定会议状态，避免仅因微信/浏览器常驻而误报
- 检测持续 stable_sec 后才弹窗
- 同一 meeting signature 有 cooldown，避免重复询问
- 已在录制时不弹窗
"""

import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path

CONFIG_DIR = Path.home() / ".config" / "meeting-assistant"
CONFIG_PATH = CONFIG_DIR / "config.json"
STATE_PATH = CONFIG_DIR / ".detector_state.json"
RECORDING_STATE_PATH = CONFIG_DIR / ".state.json"
PID_PATH = CONFIG_DIR / ".detector.pid"
LOG_PATH = CONFIG_DIR / "detector.log"
SCRIPT_DIR = Path(__file__).resolve().parent

DEFAULT_CONFIG = {
    "enabled": True,
    "interval_sec": 10,
    "stable_sec": 15,
    "prompt_cooldown_sec": 1800,
    "window_keywords": [
        # Zoom
        "Zoom Meeting", "Zoom 会议", "zoom.us", "Zoom Workplace",
        # Tencent Meeting / VooV
        "腾讯会议", "Tencent Meeting", "VooV Meeting", "WeMeet",
        # Google Meet (browser windows)
        "Google Meet", "meet.google.com", "Meet -",
        # Feishu / Lark
        "飞书会议", "Feishu Meeting", "Lark Meeting", "Lark | Meeting", "视频会议",
        # WeChat / WeCom calls
        "微信通话", "微信电话", "语音通话", "视频通话", "Voice Call", "Video Call",
        "WeChat Call", "WeChat Video", "企业微信", "WeCom",
        # Generic browser/app meeting hints
        "正在通话", "正在会议", "加入会议", "会议中", "通话中",
    ],
    "app_name_hints": [
        "zoom.us", "Zoom", "腾讯会议", "TencentMeeting", "VooV", "WeMeet",
        "Feishu", "Lark", "飞书", "WeChat", "微信", "企业微信", "WeCom",
        "Google Chrome", "Arc", "Safari", "Microsoft Edge", "Firefox",
    ],
    "target_app_names": [
        "zoom.us", "Zoom", "腾讯会议", "TencentMeeting", "VooV Meeting", "WeMeet",
        "Feishu", "Lark", "飞书", "WeChat", "微信", "企业微信", "WeCom",
        "Google Chrome", "Arc", "Safari", "Microsoft Edge", "Firefox"
    ],
    "dedicated_meeting_apps": [
        "zoom.us", "Zoom", "腾讯会议", "TencentMeeting", "VooV Meeting", "WeMeet"
    ],
    "ignore_window_keywords": [
        "Calendar", "日历", "Gmail", "Inbox", "Settings", "Preferences",
        "聊天", "通讯录", "朋友圈", "文件传输助手",
        "PolyMeet",
    ],
}


def log(msg):
    ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    line = f"[{ts}] {msg}"
    print(line, flush=True)


AUDIO_DAEMON_SOCKET = Path.home() / ".config" / "meeting-assistant" / "audio_daemon.sock"
WINDOW_SCANNER = SCRIPT_DIR / "window_scanner"


def _query_audio_daemon():
    """通过 AudioDaemon 的 socket 获取窗口列表。"""
    if not AUDIO_DAEMON_SOCKET.exists():
        return None
    try:
        import socket
        sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        sock.settimeout(3)
        sock.connect(str(AUDIO_DAEMON_SOCKET))
        sock.sendall(b'{"action":"windows"}')
        sock.shutdown(socket.SHUT_WR)
        data = b""
        while True:
            chunk = sock.recv(4096)
            if not chunk: break
            data += chunk
        sock.close()
        resp = json.loads(data.decode())
        return resp.get("windows", [])
    except Exception:
        return None


def _has_permission():
    """Quick check: can we read window titles?"""
    # Try AudioDaemon first (merged scanner)
    wins = _query_audio_daemon()
    if wins is not None:
        return True
    # Fallback: standalone window_scanner
    try:
        r = subprocess.run(
            [str(WINDOW_SCANNER)],
            capture_output=True, text=True, timeout=5,
        )
        return r.returncode == 0 and len(r.stdout.strip()) > 0
    except Exception:
        return False


def collect_windows(target_app_names=None):
    """返回 [{app, title}] 或 None。
    优先用 AudioDaemon 的 socket（如运行中），否则用 window_scanner。"""
    windows = _query_audio_daemon()
    if windows is None:
        try:
            r = subprocess.run(
                [str(WINDOW_SCANNER)],
                capture_output=True, text=True, timeout=5,
            )
            if r.returncode != 0:
                return None
            windows = json.loads(r.stdout)
        except Exception:
            return None

    if not windows:
        return []

    target_app_names = target_app_names or DEFAULT_CONFIG["target_app_names"]
    target_patterns = _compile_patterns(target_app_names)

    rows = []
    for w in windows:
        app = w.get("app", "")
        title = w.get("title", "")
        if not app or not title:
            continue
        if not any(p.search(app) for p in target_patterns):
            continue
        rows.append({"app": app.strip(), "title": title.strip()})
    return rows


def load_config():
    if not CONFIG_PATH.exists():
        return DEFAULT_CONFIG.copy()
    try:
        with CONFIG_PATH.open() as f:
            full = json.load(f)
    except Exception:
        return DEFAULT_CONFIG.copy()
    cfg = DEFAULT_CONFIG.copy()
    cfg.update(full.get("meeting_detection", {}))
    return cfg


def load_state():
    if not STATE_PATH.exists():
        return {"prompted": {}}
    try:
        return json.loads(STATE_PATH.read_text())
    except Exception:
        return {"prompted": {}}


def save_state(state):
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    STATE_PATH.write_text(json.dumps(state, indent=2, ensure_ascii=False))


def is_recording_active():
    try:
        state = json.loads(RECORDING_STATE_PATH.read_text())
        if state.get("recording"):
            return True
    except Exception:
        pass
    return (CONFIG_DIR / ".recording_pid").exists()


def _osascript(script, timeout=3):
    return subprocess.run(
        ["osascript", "-e", script],
        capture_output=True,
        text=True,
        timeout=timeout,
    )


def collect_visible_apps():
    """无需辅助功能权限的兜底：只能看到可见 app 名，不能看到窗口标题。"""
    result = subprocess.run(["lsappinfo", "visibleProcessList"], capture_output=True, text=True, timeout=5)
    apps = re.findall(r'\-"([^"]+)"', result.stdout)
    return [a.replace("_", " ") for a in apps]


def _compile_patterns(words):
    return [re.compile(re.escape(w), re.I) for w in words if w]


def detect_meeting(cfg):
    """返回检测结果或 None。"""
    window_patterns = _compile_patterns(cfg.get("window_keywords", []))
    ignore_patterns = _compile_patterns(cfg.get("ignore_window_keywords", []))
    app_hints = _compile_patterns(cfg.get("app_name_hints", []))

    windows = collect_windows(cfg.get("target_app_names"))
    if windows is None:
        # 无辅助功能权限时用可见 app 名做简易检测
        try:
            visible_apps = collect_visible_apps()
            # 只检查你的真实会议 app
            dedicated = _compile_patterns(cfg.get("dedicated_meeting_apps", []))
            for app in visible_apps:
                if any(p.search(app) for p in dedicated):
                    return {
                        "active": True,
                        "title": app,
                        "app": app,
                        "window": "",
                        "signature": signature(app, "visible-app"),
                        "fallback": "visible_app",
                    }
            # 也用窗口关键词匹配 visible app 名（仅当 app 名包含会议词汇才触发）
            for app in visible_apps:
                if any(p.search(app) for p in ignore_patterns):
                    continue
                if any(p.search(app) for p in window_patterns):
                    return {
                        "active": True,
                        "title": app,
                        "app": app,
                        "window": "",
                        "signature": signature(app, "visible-keyword"),
                        "fallback": "visible_keyword",
                    }
        except Exception:
            pass
        return {
            "active": False,
            "permission_hint": "系统设置 → 隐私与安全性 → 辅助功能，添加 Python.app（/opt/homebrew/Cellar/python@3.14/.../Python.app）以获取完整窗口标题检测。",
        }

    matches = []
    for w in windows:
        haystack = f"{w['app']} {w['title']}"
        if any(p.search(haystack) for p in ignore_patterns):
            continue
        app_interesting = any(p.search(w["app"]) for p in app_hints)
        title_match = any(p.search(w["title"]) or p.search(haystack) for p in window_patterns)
        if title_match and app_interesting:
            matches.append(w)

    if matches:
        best = sorted(matches, key=lambda x: len(x.get("title", "")), reverse=True)[0]
        title = best.get("title") or best.get("app") or "检测到会议"
        return {
            "active": True,
            "title": normalize_title(title),
            "app": best.get("app", ""),
            "window": best.get("title", ""),
            "signature": signature(best.get("app", ""), title),
            "matches": matches[:5],
        }

    return {"active": False, "windows": windows[:10]}


def normalize_title(title):
    title = re.sub(r"\s+", " ", title).strip()
    title = title[:80]
    return title or "检测到会议"


def signature(app, title):
    raw = f"{app}|{title}".encode("utf-8", errors="ignore")
    return hashlib.sha1(raw).hexdigest()[:12]


def recently_prompted(state, sig, cooldown_sec):
    prompted = state.setdefault("prompted", {})
    now = time.time()
    # 顺手清理过期记录
    for key, ts in list(prompted.items()):
        if now - float(ts) > cooldown_sec * 2:
            prompted.pop(key, None)
    return sig in prompted and now - float(prompted[sig]) < cooldown_sec


def mark_prompted(state, sig):
    state.setdefault("prompted", {})[sig] = time.time()
    save_state(state)


def prompt_recording(title):
    daemon = SCRIPT_DIR / "meeting_daemon.py"
    subprocess.Popen(
        [sys.executable, str(daemon), "ask_record", title, f"detected::{signature('', title)}"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        start_new_session=True,
    )


def run_once(args):
    cfg = load_config()
    res = detect_meeting(cfg)
    print(json.dumps(res, indent=2, ensure_ascii=False))
    return 0 if res.get("active") else 1


def run_daemon(args):
    cfg = load_config()
    if not cfg.get("enabled", True):
        log("meeting_detection disabled")
        return

    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    PID_PATH.write_text(str(os.getpid()))
    interval = int(cfg.get("interval_sec", 10))
    stable_sec = int(cfg.get("stable_sec", 15))
    cooldown = int(cfg.get("prompt_cooldown_sec", 1800))

    state = load_state()
    active_since = None
    active_sig = None
    last_permission_error = 0

    log(f"meeting detector started (interval={interval}s stable={stable_sec}s cooldown={cooldown}s)")
    try:
        while True:
            cfg = load_config()
            if not cfg.get("enabled", True):
                time.sleep(interval)
                continue

            res = detect_meeting(cfg)
            now = time.time()

            hint = res.get("permission_hint")
            if hint and now - last_permission_error > 300:
                log(f"⚠️ {hint}")
                last_permission_error = now

            if not res.get("active"):
                active_since = None
                active_sig = None
                time.sleep(interval)
                continue

            sig = res.get("signature")
            if sig != active_sig:
                active_sig = sig
                active_since = now
                log(f"👀 detected candidate: {res.get('app')} | {res.get('window')}")
                time.sleep(interval)
                continue

            if now - (active_since or now) < stable_sec:
                time.sleep(interval)
                continue

            if is_recording_active():
                time.sleep(interval)
                continue

            state = load_state()
            if recently_prompted(state, sig, cooldown):
                time.sleep(interval)
                continue

            title = f"检测到会议：{res.get('title', '会议')}"
            mark_prompted(state, sig)
            log(f"🔔 prompt recording: {title}")
            prompt_recording(title)
            time.sleep(interval)
    finally:
        try:
            PID_PATH.unlink(missing_ok=True)
        except Exception:
            pass


def main():
    parser = argparse.ArgumentParser(description="Detect active meeting/call windows and prompt recording")
    parser.add_argument("command", nargs="?", default="daemon", choices=["daemon", "once"])
    args = parser.parse_args()
    if args.command == "once":
        raise SystemExit(run_once(args))
    run_daemon(args)


if __name__ == "__main__":
    main()


path: scripts/notify.py
content: #!/usr/bin/env python3
"""
macOS 系统通知封装。

支持：
- 纯提醒通知（T-5min）
- 交互式通知（T-0min，带按钮）
- 确认对话框（自动停止提示）

依赖：
- terminal-notifier: brew install terminal-notifier
"""

import subprocess
import sys
from pathlib import Path


def _has_terminal_notifier():
    return subprocess.run(
        ["which", "terminal-notifier"],
        capture_output=True,
    ).returncode == 0


def _fallback_notify(title, message, subtitle=""):
    """fallback 到 osascript display notification。"""
    script = f'display notification "{message}" with title "{title}"'
    if subtitle:
        script += f' subtitle "{subtitle}"'
    subprocess.run(["osascript", "-e", script], capture_output=True)


def _fallback_dialog(message, buttons, default_button, timeout=60):
    """fallback 到 osascript display dialog。"""
    btn_str = ",".join(f'"{b}"' for b in buttons)
    script = (
        f'display dialog "{message}" '
        f'buttons {{{btn_str}}} '
        f'default button "{default_button}" '
        f'giving up after {timeout}'
    )
    result = subprocess.run(
        ["osascript", "-e", script],
        capture_output=True,
        text=True,
    )
    # 解析返回: button returned:xxx, gave up:false
    for line in result.stdout.split(","):
        if "button returned:" in line:
            return line.split(":")[1].strip()
        if "gave up:true" in line:
            return "timeout"
    return ""


def remind(title, message, subtitle=""):
    """发送纯提醒通知（会议前5分钟）。"""
    if _has_terminal_notifier():
        cmd = [
            "terminal-notifier",
            "-title", title,
            "-message", message,
            "-sound", "Glass",
        ]
        if subtitle:
            cmd += ["-subtitle", subtitle]
        subprocess.run(cmd, capture_output=True)
    else:
        _fallback_notify(title, message, subtitle)


def ask_record(meeting_title, timeout=60):
    """
    会议开始时询问是否录制。
    使用 osascript display dialog（真正的弹窗，按钮清晰可见）。
    返回: "开始录制" | "忽略" | "timeout"
    """
    return _fallback_dialog(
        f"会议「{meeting_title}」开始了\n\n是否开始录制音频？",
        buttons=["忽略", "开始录制"],
        default_button="开始录制",
        timeout=timeout,
    )


def ask_stop(meeting_title, timeout=60):
    """
    检测到静默时询问是否停止录制。
    使用 osascript display dialog（真正的弹窗，按钮清晰可见）。
    返回: "停止" | "继续" | "timeout"
    """
    return _fallback_dialog(
        f"会议「{meeting_title}」\n已连续 5 分钟无声音\n\n是否停止录制？",
        buttons=["继续录制", "停止录制"],
        default_button="停止录制",
        timeout=timeout,
    )


def notify_stop_auto(meeting_title):
    """自动停止录制后的通知。"""
    message = f"会议「{meeting_title}」\n已自动停止录制，正在生成纪要..."
    remind("Meeting Assistant", message, subtitle="自动停止")


def notify_summary_sent(meeting_title, channel):
    """纪要发送后的通知。"""
    message = f"会议「{meeting_title}」\n纪要已发送到 {channel}"
    remind("Meeting Assistant", message, subtitle="纪要完成")


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: notify.py <remind|ask_record|ask_stop|notify_stop|notify_sent> ...")
        sys.exit(1)
    
    action = sys.argv[1]
    if action == "remind":
        remind(sys.argv[2], sys.argv[3], sys.argv[4] if len(sys.argv) > 4 else "")
    elif action == "ask_record":
        print(ask_record(sys.argv[2]))
    elif action == "ask_stop":
        print(ask_stop(sys.argv[2]))
    elif action == "notify_stop":
        notify_stop_auto(sys.argv[2])
    elif action == "notify_sent":
        notify_summary_sent(sys.argv[2], sys.argv[3])
    else:
        print(f"Unknown action: {action}")


path: scripts/record_audio.py
content: #!/usr/bin/env python3
"""
音频录制控制器 — 支持两种后端：
  - daemon: ScreenCaptureKit 原生捕获（macOS 12.3+，推荐）
  - sox: BlackHole + SoX 传统方案

用法和原来一样：
  record_audio.py start "会议标题"
  record_audio.py stop
  record_audio.py status
"""

import json
import os
import socket
import struct
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path

CONFIG_DIR = Path.home() / ".config" / "meeting-assistant"
CONFIG_PATH = CONFIG_DIR / "config.json"
SOCKET_PATH = CONFIG_DIR / "audio_daemon.sock"
STATE_PATH = CONFIG_DIR / ".state.json"
SCRIPT_DIR = Path(__file__).resolve().parent


def log(msg):
    ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    print(f"[{ts}] {msg}", flush=True)


def load_config():
    cfg = {}
    if CONFIG_PATH.exists():
        try:
            cfg = json.loads(CONFIG_PATH.read_text())
        except Exception:
            pass
    audio_cfg = cfg.get("audio", {})
    audio_cfg.setdefault("mic_device", ":0")
    audio_cfg.setdefault("system_audio_device", ":1")
    audio_cfg.setdefault("output_dir", str(SCRIPT_DIR.parent.parent / "meeting-recordings"))
    audio_cfg.setdefault("silence_threshold", 0.01)
    audio_cfg.setdefault("silence_duration_sec", 300)
    audio_cfg.setdefault("backend", "daemon")  # "daemon" or "sox"
    return audio_cfg


def socket_send(cmd):
    """发送命令到 audio_daemon Unix socket。"""
    if not SOCKET_PATH.exists():
        return None
    try:
        sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        sock.settimeout(5)
        sock.connect(str(SOCKET_PATH))
        sock.sendall(json.dumps(cmd).encode())
        sock.shutdown(socket.SHUT_WR)
        data = b""
        while True:
            chunk = sock.recv(4096)
            if not chunk:
                break
            data += chunk
        sock.close()
        return json.loads(data.decode()) if data else None
    except Exception as e:
        log(f"Socket error: {e}")
        return None


# ─── 后端: audio_daemon ──────────────────────────────

def daemon_start(title):
    cfg = load_config()
    resp = socket_send({"action": "start", "title": title})
    if resp and resp.get("status") == "recording":
        log(f"🎙 Recording started: {resp.get('file')}")
        return True
    log(f"❌ Start failed: {resp}")
    return False


def daemon_stop():
    resp = socket_send({"action": "stop"})
    if resp and resp.get("status") == "stopped":
        dur = resp.get("duration", 0)
        path = resp.get("file", "")
        log(f"⏹ Recording stopped: {dur}s → {path}")
        return {"path": path, "duration": dur}
    log(f"❌ Stop failed: {resp}")
    return None


def daemon_status():
    resp = socket_send({"action": "status"})
    return resp or {"recording": False}


def daemon_ensure_running():
    """如果 daemon 没在运行，尝试启动它。"""
    resp = socket_send({"action": "status"})
    if resp is not None:
        return True

    # 查找已安装的 AudioDaemon.app
    app_paths = [
        SCRIPT_DIR / "AudioDaemon.app",
        Path.home() / "Applications" / "Meeting Assistant.app",
        Path("/Applications/Meeting Assistant.app"),
    ]
    for app in app_paths:
        binary = app / "Contents/MacOS/audio_daemon"
        if binary.exists():
            log("🚀 启动 AudioDaemon...")
            subprocess.Popen(["open", str(app)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            time.sleep(3)
            resp = socket_send({"action": "status"})
            if resp is not None:
                log("✅ AudioDaemon 已启动")
                return True
            break

    log("⚠️ AudioDaemon 未运行")
    return False


# ─── 后端: SoX + BlackHole ──────────────────────────

def sox_start(title):
    """SoX 双路录制（向后兼容）。"""
    cfg = load_config()
    output_dir = Path(cfg["output_dir"]).expanduser()
    output_dir.mkdir(parents=True, exist_ok=True)

    safe = title.replace("/", "_").replace(" ", "_")
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    output = output_dir / f"{safe}_{timestamp}.wav"

    state = {
        "recording": True,
        "title": title,
        "file_path": str(output),
        "started_at": datetime.now().isoformat(),
    }
    STATE_PATH.write_text(json.dumps(state, indent=2))

    listen_cmd = [
        "sox", "-q",
        "-t", "coreaudio", cfg["system_audio_device"],
        "-t", "coreaudio", cfg["mic_device"],
        "-t", "wav", str(output),
        "remix", "1,2",
        "gain", "-3",
    ]

    log(f"🎙 录制中: {output}")
    log(f"   后端: SoX (mic={cfg['mic_device']}, sys={cfg['system_audio_device']})")

    proc = subprocess.Popen(
        listen_cmd,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        start_new_session=True,
    )
    (CONFIG_DIR / ".recording_pid").write_text(str(proc.pid))
    return True


def sox_stop():
    pid_path = CONFIG_DIR / ".recording_pid"
    if pid_path.exists():
        try:
            pid = int(pid_path.read_text().strip())
            os.kill(pid, 15)
            time.sleep(0.5)
            pid_path.unlink(missing_ok=True)
        except Exception:
            pass

    state = {"recording": False, "title": "", "file_path": ""}
    STATE_PATH.write_text(json.dumps(state, indent=2))

    log("⏹ 录制已停止")
    return {"path": "", "duration": 0}


def sox_status():
    state = {"recording": False, "title": ""}
    if STATE_PATH.exists():
        try:
            state = json.loads(STATE_PATH.read_text())
        except Exception:
            pass
    if (CONFIG_DIR / ".recording_pid").exists():
        state["recording"] = True
    return state


# ─── 主入口 ──────────────────────────────────────────

def main():
    if len(sys.argv) < 2:
        print(f"用法: {sys.argv[0]} <start|stop|status> [标题]")
        sys.exit(1)

    cmd = sys.argv[1]
    cfg = load_config()
    backend = cfg.get("backend", "daemon")

    if backend == "daemon":
        if cmd == "start":
            title = sys.argv[2] if len(sys.argv) > 2 else "未命名会议"
            if daemon_ensure_running():
                daemon_start(title)
            else:
                log("⚠️ AudioDaemon 未运行，切换到 SoX 后端")
                sox_start(title)
        elif cmd == "stop":
            result = daemon_stop()
            if not result:
                sox_stop()
        elif cmd == "status":
            result = daemon_status()
            print(json.dumps(result, indent=2, ensure_ascii=False))
        else:
            print(f"未知命令: {cmd}")
    else:
        if cmd == "start":
            title = sys.argv[2] if len(sys.argv) > 2 else "未命名会议"
            sox_start(title)
        elif cmd == "stop":
            sox_stop()
        elif cmd == "status":
            result = sox_status()
            print(json.dumps(result, indent=2, ensure_ascii=False))
        else:
            print(f"未知命令: {cmd}")


if __name__ == "__main__":
    main()


path: scripts/recorder_status.swift
content: import Cocoa

/// 右侧悬浮录制状态标签
/// 编译：swiftc -o recorder_status recorder_status.swift
/// 使用：./recorder_status <标题> [状态文件路径]

let COLLAPSED_W: CGFloat = 44
let EXPANDED_W: CGFloat = 280
let PANEL_H: CGFloat = 76
let ANIM_DUR: TimeInterval = 0.2

// MARK: - 红点图标
class RecordDotView: NSView {
    override func draw(_ dirtyRect: NSRect) {
        guard let ctx = NSGraphicsContext.current?.cgContext else { return }
        let c = NSPoint(x: bounds.midX, y: bounds.midY), r: CGFloat = 5
        ctx.setStrokeColor(NSColor.red.withAlphaComponent(0.6).cgColor)
        ctx.setLineWidth(2)
        ctx.addArc(center: c, radius: r, startAngle: 0, endAngle: .pi * 2, clockwise: false)
        ctx.strokePath()
        ctx.setFillColor(NSColor.red.cgColor)
        ctx.addArc(center: c, radius: r - 2.5, startAngle: 0, endAngle: .pi * 2, clockwise: false)
        ctx.fillPath()
        // 脉冲动画
        let a = CABasicAnimation(keyPath: "opacity")
        a.fromValue = 1.0; a.toValue = 0.3; a.duration = 1.0
        a.autoreverses = true; a.repeatCount = .infinity
        layer?.add(a, forKey: "pulse")
    }
}

// MARK: - 主控制器
class AppDel: NSObject, NSApplicationDelegate {
    var win: NSWindow!
    var panel: NSView!
    var dot: RecordDotView!
    var titleLbl: NSTextField!
    var timeLbl: NSTextField!
    var stopBtn: NSButton!
    var expanded = false

    let meetingTitle: String
    let startTime = Date()
    let statePath: String
    var checkTimer: Timer?
    var aliveCheck: Timer?

    init(title: String, path: String) {
        self.meetingTitle = title; self.statePath = path
        super.init()
    }

    // ─────────────── 窗口创建 ───────────────

    func applicationDidFinishLaunching(_ n: Notification) {
        makeWin()
        makeUI()
        setExpanded(true); DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
            guard let s = self, s.expanded else { return }; s.setExpanded(false)
        }
        startTimers()
    }

    func makeWin() {
        let r = NSRect(x: 0, y: 0, width: EXPANDED_W, height: PANEL_H)
        win = NSWindow(contentRect: r, styleMask: [.titled, .fullSizeContentView],
                       backing: .buffered, defer: false)
        win.title = ""; win.titlebarAppearsTransparent = true; win.titleVisibility = .hidden
        win.isMovableByWindowBackground = false; win.level = .floating
        win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
        win.isOpaque = false; win.backgroundColor = .clear
        [.closeButton, .miniaturizeButton, .zoomButton].forEach {
            win.standardWindowButton($0)?.isHidden = true
        }
        win.makeKeyAndOrderFront(nil)
        NSApp.activate(ignoringOtherApps: true)
        pos(expanded: true, anim: false)
    }

    func makeUI() {
        guard let cv = win.contentView else { return }
        // 背景
        panel = NSView(frame: cv.bounds); panel.wantsLayer = true
        panel.layer?.backgroundColor = NSColor(white: 0.12, alpha: 0.92).cgColor
        panel.layer?.cornerRadius = 12; panel.autoresizingMask = [.width, .height]
        cv.addSubview(panel)

        // 红点
        dot = RecordDotView(frame: NSRect(x: 0, y: 0, width: 20, height: 20))
        dot.wantsLayer = true; panel.addSubview(dot)

        // 标题
        titleLbl = NSTextField(labelWithString: meetingTitle)
        titleLbl.font = .systemFont(ofSize: 12, weight: .medium)
        titleLbl.textColor = .white; titleLbl.lineBreakMode = .byTruncatingTail
        panel.addSubview(titleLbl)

        // 时间
        timeLbl = NSTextField(labelWithString: "00:00:00")
        timeLbl.font = .monospacedDigitSystemFont(ofSize: 19, weight: .regular)
        timeLbl.textColor = NSColor(white: 0.8, alpha: 1)
        panel.addSubview(timeLbl)

        // Stop 按钮
        stopBtn = NSButton(title: "■", target: self, action: #selector(doStop))
        stopBtn.bezelStyle = .rounded
        stopBtn.font = .systemFont(ofSize: 13, weight: .bold)
        stopBtn.contentTintColor = .white
        stopBtn.toolTip = "Stop Recording"
        stopBtn.wantsLayer = true
        stopBtn.layer?.backgroundColor = NSColor(red: 0.85, green: 0.15, blue: 0.15, alpha: 1).cgColor
        stopBtn.layer?.cornerRadius = 6
        panel.addSubview(stopBtn)

        // 折叠/展开切换按钮（透明，覆盖红点区域）
        let toggleBtn = NSButton(title: "", target: self, action: #selector(toggle))
        toggleBtn.isBordered = false
        toggleBtn.wantsLayer = true
        toggleBtn.layer?.backgroundColor = NSColor.clear.cgColor
        toggleBtn.frame = NSRect(x: 0, y: 0, width: COLLAPSED_W, height: PANEL_H)
        toggleBtn.autoresizingMask = [.maxXMargin]
        panel.addSubview(toggleBtn, positioned: .above, relativeTo: dot)

        setExpanded(false)
    }

    // ─────────────── 布局 ───────────────

    func pos(expanded e: Bool, anim: Bool) {
        guard let s = NSScreen.main else { return }
        let v = s.visibleFrame
        let w: CGFloat = e ? EXPANDED_W : COLLAPSED_W
        let x: CGFloat = e ? v.maxX - EXPANDED_W + 4 : v.maxX - COLLAPSED_W
        let y = v.minY + (v.height - PANEL_H) / 2
        win.setFrame(NSRect(x: x, y: y, width: w, height: PANEL_H), display: true, animate: anim)
    }

    func setExpanded(_ e: Bool) {
        expanded = e
        pos(expanded: e, anim: true)
        let my = PANEL_H / 2
        dot.frame = NSRect(x: COLLAPSED_W / 2 - 10, y: my - 10, width: 20, height: 20)
        if e {
            titleLbl.isHidden = false; titleLbl.frame = NSRect(x: 40, y: my + 6, width: EXPANDED_W - 110, height: 18)
            timeLbl.isHidden = false; timeLbl.frame = NSRect(x: 40, y: my - 26, width: 120, height: 24)
            stopBtn.isHidden = false; stopBtn.frame = NSRect(x: EXPANDED_W - 52, y: my - 17, width: 38, height: 34)
        } else {
            titleLbl.isHidden = true; timeLbl.isHidden = true; stopBtn.isHidden = true
        }
    }

    @objc func toggle() { setExpanded(!expanded) }

    // ─────────────── 计时 ───────────────

    func startTimers() {
        Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(tick),
                             userInfo: nil, repeats: true)
            .common()
        // 状态检查：每 5 秒，宽松版
        checkTimer = Timer.scheduledTimer(timeInterval: 5, target: self,
                                          selector: #selector(check), userInfo: nil, repeats: true)
        checkTimer?.common()
    }

    @objc func tick() {
        let e = Date().timeIntervalSince(startTime)
        timeLbl.stringValue = String(format: "%02d:%02d:%02d", Int(e)/3600, (Int(e)%3600)/60, Int(e)%60)
    }

    @objc func check() {
        // 不传递 statePath → 不检查，窗口永远保持（直到 Stop 被点击或外部 kill）
        guard !statePath.isEmpty, FileManager.default.fileExists(atPath: statePath) else { return }
        guard let d = try? Data(contentsOf: URL(fileURLWithPath: statePath)),
              let j = try? JSONSerialization.jsonObject(with: d) as? [String: Any] else { return }
        // 只有在 recording 明确为 {} 或 missing 且 startup 超过 15 秒才退出
        guard Date().timeIntervalSince(startTime) > 15 else { return }
        if let r = j["recording"] as? [String: Any], r.isEmpty {
            DispatchQueue.main.asyncAfter(deadline: .now() + 2) { NSApp.terminate(nil) }
        } else if j["recording"] == nil {
            DispatchQueue.main.asyncAfter(deadline: .now() + 2) { NSApp.terminate(nil) }
        }
    }

    @objc func doStop() {
        let dir = (CommandLine.arguments[0] as NSString).deletingLastPathComponent
        let p = Process()
        p.executableURL = URL(fileURLWithPath: "/usr/bin/env")
        p.arguments = ["python3", "\(dir)/meeting_daemon.py", "stop"]
        try? p.run()
        NSApp.terminate(nil)
    }
}

// MARK: - 入口
let args = CommandLine.arguments
guard args.count >= 2 else { print("Usage: recorder_status <title> [state_file]"); exit(1) }
let d = AppDel(title: args[1], path: args.count >= 3 ? args[2] : "")
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
app.delegate = d
app.run()

// MARK: - Timer 扩展
extension Timer {
    func common() { RunLoop.current.add(self, forMode: .common) }
}


path: scripts/run_calendar_services.py
content: #!/usr/bin/env python3
"""
日历服务启动器：webhook 服务器 + cloudflared tunnel + Google Calendar watch 注册。

工作流：
1. 启动 HTTP webhook 服务器（后台线程）
2. 启动 cloudflared quick tunnel 到 webhook 端口
3. 解析 cloudflared 输出的公网 URL
4. 注册/重新注册 Google Calendar push notification channels
5. 持续监控，tunnel 重启后自动重新注册

作为 LaunchAgent 常驻运行。
"""

import json
import os
import re
import signal
import subprocess
import sys
import threading
import time
import uuid
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path

CONFIG_DIR = Path.home() / ".config" / "meeting-assistant"
LOG_PATH = CONFIG_DIR / "calendar_services.log"
STATE_PATH = CONFIG_DIR / ".watch_state.json"
SCRIPT_DIR = Path(__file__).resolve().parent

WEBHOOK_PORT = 8899

# Google OAuth
# 从 gog 的 keychain 读取 OAuth 配置
# 或从环境变量读取

# 要 watch 的日历列表（从 config.json 加载）
CALENDARS_TO_WATCH = []
GOG_ACCOUNT = ""

LOCK = threading.Lock()
LAST_SYNC = 0
SYNC_COOLDOWN = 10  # 防止通知风暴每分钟只同步一次


def log(msg):
    ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    line = f"[{ts}] {msg}"
    print(line, flush=True)
    # print 由 LaunchAgent 的 StandardOutPath 捕获到日志文件，不再重复写


def _get_gog_credentials():
    """从 gog 的 keychain 读取 OAuth 凭证。
    用户需先运行：
      gog auth credentials ~/path/to/client_secret.json
      gog auth add <email> --services calendar
    也可通过环境变量手动指定。
    """
    env_id = os.environ.get("GOOGLE_CLIENT_ID")
    env_secret = os.environ.get("GOOGLE_CLIENT_SECRET")
    env_token = os.environ.get("GOG_REFRESH_TOKEN")
    if env_id and env_secret and env_token:
        return env_id, env_secret, env_token

    try:
        r = subprocess.run(
            ["security", "find-generic-password", "-s", "gogcli",
             "-a", f"[REDACTED_SECRET]", "-w"],
            capture_output=True, text=True, timeout=5,
        )
        if r.returncode == 0:
            data = json.loads(r.stdout)
            return data.get("client_id",""), data.get("client_secret",""), data.get("refresh_token","")
    except Exception:
        pass

    # 也可以尝试读取 gog 的 credentials.json
    cred_path = Path.home() / "Library" / "Application Support" / "gogcli" / "credentials.json"
    if cred_path.exists():
        creds = json.loads(cred_path.read_text())
        return creds.get("client_id",""), creds.get("client_secret",""), ""

    return "", "", ""


def _get_access_token():
    """用 refresh token 换取 access token。"""
    cid, secret, [REDACTED_SECRET])
    if not token and secret:
        # 只有 client credentials，没有 refresh token——换一种方式
        log("⚠️ 未配置 Google OAuth refresh token")
        log("   请运行: gog auth add <email> --services calendar")
        log("   或设置环境变量: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOG_REFRESH_TOKEN")
        raise RuntimeError("No OAuth refresh token available")

    data = urllib.parse.urlencode({
        "grant_type": "refresh_token",
        "client_id": cid,
        "client_secret": secret,
        "refresh_token": token,
    }).encode()
    req = urllib.request.Request(
        "https://oauth2.googleapis.com/token",
        data=data,
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=10) as resp:
        body = json.loads(resp.read())
    return body["access_token"]


def register_watch_channels(public_url):
    """为所有日历注册 Google Calendar push notification。"""
    [REDACTED_SECRET])
    channel_id = str(uuid.uuid4())
    results = []

    for cal in CALENDARS_TO_WATCH:
        cal_id = urllib.parse.quote(cal["id"], safe="")
        url = f"https://www.googleapis.com/calendar/v3/calendars/{cal_id}/events/watch"
        body = json.dumps({
            "id": channel_id,
            "type": "web_hook",
            "address": f"{public_url.rstrip('/')}/calendar-webhook",
        }).encode()
        req = urllib.request.Request(
            url, data=body,
            headers={
                "Authorization": f"Bearer {token}",
                "Content-Type": "application/json",
            },
            method="POST",
        )
        try:
            with urllib.request.urlopen(req, timeout=15) as resp:
                data = json.loads(resp.read())
            expires = datetime.fromtimestamp(int(data["expiration"]) / 1000, tz=timezone.utc)
            results.append({
                "calendar_id": cal["id"],
                "label": cal["label"],
                "channel_id": channel_id,
                "resource_id": data.get("resourceId", ""),
                "expires": expires.isoformat(),
            })
            log(f"✅ 已注册 watch: {cal['label']} → 过期 {expires.isoformat()}")
        except urllib.error.HTTPError as e:
            err = e.read().decode()
            log(f"❌ 注册 watch 失败 ({cal['label']}): {e.code} {err}")
        except Exception as e:
            log(f"❌ 注册 watch 异常 ({cal['label']}): {e}")

    state = {
        "public_url": public_url,
        "channels": results,
        "registered_at": datetime.now(timezone.utc).isoformat(),
    }
    STATE_PATH.write_text(json.dumps(state, indent=2, ensure_ascii=False))
    return results


def sync_calendar_to_schedule():
    """读取日历事件 → 写入 schedule.json → SIGHUP scheduler。"""
    global LAST_SYNC
    now = time.time()
    if now - LAST_SYNC < SYNC_COOLDOWN:
        return
    LAST_SYNC = now

    try:
        r = subprocess.run(
            [sys.executable, str(SCRIPT_DIR / "check_meetings.py"), "week", "--json"],
            capture_output=True, text=True, timeout=30,
        )
        if r.returncode != 0:
            log(f"⚠️ check_meetings 失败: {r.stderr}")
            return
        meetings = json.loads(r.stdout)
        log(f"📅 获取到 {len(meetings)} 个会议")

        events = []
        now_local = datetime.now(timezone.utc).astimezone()
        for m in meetings:
            try:
                start = datetime.fromisoformat(m["start"].replace("Z", "+00:00"))
                end = datetime.fromisoformat(m["end"].replace("Z", "+00:00"))
            except (ValueError, KeyError):
                continue
            if end < now_local:
                continue

            title = m["title"]
            meeting_id = m.get("id", str(uuid.uuid4()))

            remind_at = start - timedelta(minutes=5)
            if remind_at > now_local:
                events.append({
                    "kind": "remind", "at": remind_at.isoformat(),
                    "title": title, "meeting_id": meeting_id,
                })
            if start > now_local:
                events.append({
                    "kind": "ask_record", "at": start.isoformat(),
                    "title": title, "meeting_id": meeting_id,
                })

        schedule = {"events": events, "meetings": meetings}
        (CONFIG_DIR / "schedule.json").write_text(
            json.dumps(schedule, indent=2, ensure_ascii=False))
        log(f"✅ schedule.json 已更新，{len(events)} 个事件")

        try:
            r = subprocess.run(
                ["pgrep", "-f", "scheduler_daemon.py"],
                capture_output=True, text=True, timeout=5,
            )
            for pid in r.stdout.strip().splitlines():
                pid = pid.strip()
                if pid:
                    os.kill(int(pid), signal.SIGHUP)
                    log(f"📡 SIGHUP → scheduler (pid={pid})")
        except Exception as e:
            log(f"⚠️ SIGHUP 失败: {e}")
    except Exception as e:
        log(f"❌ 同步异常: {e}")


# ─── HTTP webhook server ───────────────────────────────

class WebhookHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/":
            self.send_response(200)
            self.send_header("Content-Type", "text/plain")
            self.end_headers()
            self.wfile.write(b"Meeting Assistant Calendar Webhook\n")
            return
        if self.path == "/health":
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.end_headers()
            self.wfile.write(json.dumps({"status": "ok"}).encode())
            return
        self.send_response(404)
        self.end_headers()

    def do_POST(self):
        if self.path == "/calendar-webhook":
            resource_state = self.headers.get("X-Goog-Resource-State", "")
            log(f"📩 推送: state={resource_state}")

            if resource_state == "sync":
                self.send_response(200)
                self.end_headers()
                self.wfile.write(b"OK")
                return

            if resource_state in ("update", "exists"):
                threading.Thread(target=sync_calendar_to_schedule, daemon=True).start()

            self.send_response(200)
            self.end_headers()
            self.wfile.write(b"OK")
            return

        self.send_response(404)
        self.end_headers()

    def log_message(self, fmt, *args):
        pass


def run_webhook_server():
    server = HTTPServer(("0.0.0.0", WEBHOOK_PORT), WebhookHandler)
    log(f"🌐 Webhook 已启动: http://0.0.0.0:{WEBHOOK_PORT}")
    server.serve_forever()


# ─── Cloudflare tunnel orchestrator ───────────────────

def parse_cloudflared_url(line):
    """从 cloudflared 日志行提取 URL。"""
    m = re.search(r'https://[a-zA-Z0-9-]+\.trycloudflare\.com', line)
    return m.group(0) if m else None


def run_tunnel_and_register():
    """启动 cloudflared，获取 URL，注册 watch。"""
    log("🚇 启动 cloudflared tunnel...")
    proc = subprocess.Popen(
        ["cloudflared", "tunnel", "--url", f"http://localhost:{WEBHOOK_PORT}",
         "--no-autoupdate"],
        stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
        text=True, bufsize=1,
    )

    current_url = None
    registered_once = False

    def _signal_handler(signum, frame):
        log(f"收到 signal {signum}，关闭 cloudflared...")
        proc.terminate()

    signal.signal(signal.SIGTERM, _signal_handler)
    signal.signal(signal.SIGINT, _signal_handler)

    try:
        for line in iter(proc.stdout.readline, ""):
            line = line.rstrip()
            if not line:
                continue

            url = parse_cloudflared_url(line)
            if url and url != current_url:
                current_url = url
                log(f"🔗 Tunnel URL: {url}")

                if registered_once:
                    log("🔄 Tunnel 重启，重新注册 watch...")
                register_watch_channels(current_url)

                if not registered_once:
                    log("🔄 执行初始日历同步...")
                    sync_calendar_to_schedule()

                registered_once = True

    except Exception as e:
        log(f"❌ Tunnel 异常: {e}")
    finally:
        proc.terminate()
        proc.wait()


def main():
    # PID 文件阻止重复启动
    PID_PATH = CONFIG_DIR / ".calendar_services.pid"
    if PID_PATH.exists():
        try:
            old_pid = int(PID_PATH.read_text().strip())
            # macOS 用 kill -0 检查进程是否存活
            os.kill(old_pid, 0)
            log(f"⏭ 已有实例运行 (pid={old_pid})，跳过")
            return
        except (ValueError, OSError, ProcessLookupError):
            pass
    PID_PATH.write_text(str(os.getpid()))

    # 从 config.json 加载日历配置
    global GOG_ACCOUNT, CALENDARS_TO_WATCH
    try:
        cfg = json.loads((CONFIG_DIR / "config.json").read_text())
        for cal in cfg.get("calendars", []):
            if cal.get("type") == "google" and cal.get("enabled", False):
                GOG_ACCOUNT = cal.get("gog_account", "") or os.environ.get("GOG_ACCOUNT", "")
                extra_calendars = cal.get("watch_calendars", ["primary"])
                CALENDARS_TO_WATCH = [
                    {"id": cid, "label": cid} for cid in extra_calendars
                ]
                break
    except Exception as e:
        log(f"⚠️ 读取 config.json 失败: {e}")
        log("   请先根据 config.example.json 创建 ~/.config/meeting-assistant/config.json")

    if not GOG_ACCOUNT:
        GOG_ACCOUNT = os.environ.get("GOG_ACCOUNT", "")

    log("⚡ Meeting Assistant Calendar Services 启动")
    log(f"    webhook port: {WEBHOOK_PORT}")
    if GOG_ACCOUNT:
        log(f"    gog account: {GOG_ACCOUNT}")
        log(f"    watch calendars: {[c['label'] for c in CALENDARS_TO_WATCH]}")
    else:
        log("⚠️ 未配置 gog_account，日历推送功能不可用")
        log("   请在 config.json 中设置 calendar.google.gog_account")

    # 后台线程运行 webhook 服务器
    webhook_thread = threading.Thread(target=run_webhook_server, daemon=True)
    webhook_thread.start()
    time.sleep(0.5)  # 确保 webhook 就绪

    # 兜底轮询线程：每 60 分钟同步一次，防推送丢失
    def poll_loop():
        while True:
            time.sleep(3600)
            log("⏰ 兜底轮询：同步日历...")
            sync_calendar_to_schedule()
    polling_thread = threading.Thread(target=poll_loop, daemon=True)
    polling_thread.start()

    # 主线程运行 tunnel + watch 注册
    try:
        run_tunnel_and_register()
    finally:
        try:
            PID_PATH.unlink(missing_ok=True)
        except Exception:
            pass


if __name__ == "__main__":
    main()


path: scripts/scheduler_daemon.py
content: #!/usr/bin/env python3
"""
事件驱动的会议提醒调度器（替代每分钟 cron 轮询）。

工作原理：
- 由 LaunchAgent 启动并保活，常驻后台
- 读 ~/.config/meeting-assistant/schedule.json，把所有未来事件挂到内部堆
- 主循环 wait 到"下一个事件时间"再唤醒，CPU 占用 0
- 收到 SIGHUP 时重新加载 schedule.json（schedule 命令写完文件后发送）
- 事件触发时 fork 子进程跑 notify.py / meeting_daemon.py，daemon 不阻塞

支持的事件 kind：
  remind        会议前 5 分钟系统通知
  ask_record    会议开始时询问是否录制
  ask_stop      录制超时后询问是否停止录制
"""

import heapq
import json
import os
import signal
import subprocess
import sys
import threading
from datetime import datetime
from pathlib import Path

CONFIG_DIR = Path.home() / ".config" / "meeting-assistant"
SCHEDULE_PATH = CONFIG_DIR / "schedule.json"
PID_PATH = CONFIG_DIR / ".scheduler.pid"
LOG_PATH = CONFIG_DIR / "scheduler.log"
SCRIPT_DIR = Path(__file__).resolve().parent


def log(msg):
    ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    line = f"[{ts}] {msg}"
    print(line, flush=True)


def parse_iso(s):
    return datetime.fromisoformat(s.replace("Z", "+00:00"))


class Scheduler:
    def __init__(self):
        self.heap = []          # [(epoch, seq, event_dict)]
        self.seq = 0
        self.lock = threading.Lock()
        self.wake = threading.Event()
        self.stopping = False

    def load(self):
        """从 schedule.json 重建事件堆。"""
        with self.lock:
            self.heap = []
            self.seq = 0
            if not SCHEDULE_PATH.exists():
                log("schedule.json 不存在，事件队列为空")
                return
            try:
                with open(SCHEDULE_PATH) as f:
                    data = json.load(f)
            except Exception as e:
                log(f"读取 schedule.json 失败: {e}")
                return

            now = datetime.now().timestamp()
            for ev in data.get("events", []):
                try:
                    at = parse_iso(ev["at"]).timestamp()
                except Exception as e:
                    log(f"跳过非法事件: {ev} ({e})")
                    continue
                if at + 15 < now:  # 15秒宽容（重载调度需要时间）
                    log(f"跳过已过期事件: {ev.get('kind')} @ {ev.get('at')}")
                    continue
                self.seq += 1
                heapq.heappush(self.heap, (at, self.seq, ev))
            log(f"已加载 {len(self.heap)} 个事件")

    def add_runtime(self, ev):
        """运行时动态添加事件（如录制开始后注册 ask_stop）。不写 schedule.json。"""
        try:
            at = parse_iso(ev["at"]).timestamp()
        except Exception as e:
            log(f"add_runtime 解析失败: {e}")
            return
        with self.lock:
            self.seq += 1
            heapq.heappush(self.heap, (at, self.seq, ev))
        self.wake.set()
        log(f"运行时事件已添加: {ev.get('kind')} @ {ev.get('at')}")

    def _next_delay(self):
        with self.lock:
            if not self.heap:
                return None
            return max(0.0, self.heap[0][0] - datetime.now().timestamp())

    def _drain_due(self):
        now = datetime.now().timestamp()
        due = []
        with self.lock:
            while self.heap and self.heap[0][0] <= now:
                _, _, ev = heapq.heappop(self.heap)
                due.append(ev)
        return due

    def run(self):
        while not self.stopping:
            self.wake.clear()
            delay = self._next_delay()
            if delay is None:
                # 没有事件，无限等待直到 SIGHUP / 信号
                self.wake.wait()
            else:
                self.wake.wait(timeout=delay)

            for ev in self._drain_due():
                self._fire(ev)

    def _fire(self, ev):
        kind = ev.get("kind")
        log(f"🔔 触发: {kind} | meeting_id={ev.get('meeting_id')} | title={ev.get('title')}")
        try:
            if kind == "remind":
                self._spawn([
                    sys.executable, str(SCRIPT_DIR / "notify.py"),
                    "remind", "Meeting Assistant",
                    f"会议「{ev.get('title','')}」 5 分钟后开始",
                    ev.get("title", ""),
                ])
            elif kind == "ask_record":
                self._spawn([
                    sys.executable, str(SCRIPT_DIR / "meeting_daemon.py"),
                    "ask_record", ev.get("title", ""), ev.get("meeting_id", ""),
                ])
            elif kind == "ask_stop":
                self._spawn([
                    sys.executable, str(SCRIPT_DIR / "meeting_daemon.py"),
                    "auto_stop",
                ])
            else:
                log(f"⚠️ 未知事件类型: {kind}")
        except Exception as e:
            log(f"事件处理异常: {e}")

    def _spawn(self, cmd):
        subprocess.Popen(
            cmd,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            start_new_session=True,
        )


def main():
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    PID_PATH.write_text(str(os.getpid()))
    log(f"📡 scheduler daemon 启动 (pid={os.getpid()})")

    sched = Scheduler()
    sched.load()

    def on_hup(signum, frame):
        log("收到 SIGHUP，重新加载 schedule.json")
        sched.load()
        sched.wake.set()

    def on_term(signum, frame):
        log(f"收到 signal={signum}，退出")
        sched.stopping = True
        sched.wake.set()

    signal.signal(signal.SIGHUP, on_hup)
    signal.signal(signal.SIGTERM, on_term)
    signal.signal(signal.SIGINT, on_term)

    try:
        sched.run()
    finally:
        try:
            PID_PATH.unlink(missing_ok=True)
        except Exception:
            pass
        log("scheduler 已退出")


if __name__ == "__main__":
    main()


path: scripts/send_summary.py
content: #!/usr/bin/env python3
"""
发送会议纪要到指定频道。

支持：
- file    — 仅保存到本地（默认）
- zulip   — 发送到 Zulip 频道
- notion  — 创建 Notion 页面
- telegram — 发送 Telegram 消息

配置：
{
  "output": {
    "channel": "file",  # "file" | "zulip" | "notion" | "telegram"
    "zulip": {
      "stream": "meetings",
      "topic": "会议纪要"
    },
    "notion": {
      "api_key_env": "NOTION_API_KEY",
      "database_id": "your-database-id"
    },
    "telegram": {
      "chat_id": "your-chat-id"
    }
  }
}
"""

import json
import os
import sys
from pathlib import Path

CONFIG_PATH = Path.home() / ".config" / "meeting-assistant" / "config.json"


def load_config():
    if not CONFIG_PATH.exists():
        print(f"Config not found at {CONFIG_PATH}", file=sys.stderr)
        sys.exit(1)
    with open(CONFIG_PATH) as f:
        return json.load(f)


def send_to_file(summary_path):
    """仅保存到本地（默认）。"""
    print(f"📄 纪要已保存到本地: {summary_path}")
    return True


def send_to_zulip(summary_path, config):
    """发送到 Zulip 频道。"""
    try:
        with open(summary_path, "r", encoding="utf-8") as f:
            content = f.read()
    except Exception as e:
        print(f"Failed to read summary: {e}", file=sys.stderr)
        return False

    stream = config.get("stream", "general")
    topic = config.get("topic", "会议纪要")

    print(f"📤 发送到 Zulip")
    print(f"   Stream: {stream}")
    print(f"   Topic: {topic}")
    print(f"   内容长度: {len(content)} 字符")

    # TODO: 实现 Zulip API 调用
    # 需要配置 zuliprc 文件或 API key
    # 示例：
    # import zulip
    # client = zulip.Client(config_file="~/.zuliprc")
    # client.send_message({"type": "stream", "to": stream, "topic": topic, "content": content})

    print("   ⚠️ Zulip 发送待实现，请先配置 API 凭证")
    return True


def send_to_notion(summary_path, config):
    """创建 Notion 页面。"""
    try:
        from notion_client import Client
    except ImportError:
        print("notion-client not installed. Run: pip install notion-client", file=sys.stderr)
        return False

    [REDACTED_SECRET]"api_key_env", "NOTION_API_KEY"))
    if not [REDACTED_SECRET]"NOTION_API_KEY not set", file=sys.stderr)
        return False

    database_id = config.get("database_id")
    if not database_id:
        print("Notion database_id not configured", file=sys.stderr)
        return False

    try:
        with open(summary_path, "r", encoding="utf-8") as f:
            content = f.read()
    except Exception as e:
        print(f"Failed to read summary: {e}", file=sys.stderr)
        return False

    # 从文件名提取标题
    summary_name = Path(summary_path).stem
    parts = summary_name.rsplit("_", 1)
    title = parts[0].replace("_", " ")
    date_str = parts[1] if len(parts) > 1 else ""

    notion = Client(auth=api_key)

    try:
        page = notion.pages.create(
            parent={"database_id": database_id},
            properties={
                "Name": {"title": [{"text": {"content": title}}]},
                "Status": {"select": {"name": "已完成"}},
            },
            children=[
                {
                    "object": "block",
                    "type": "paragraph",
                    "paragraph": {
                        "rich_text": [{"type": "text", "text": {"content": content}}]
                    },
                }
            ],
        )
        print(f"📤 Notion 页面已创建: {page['url']}")
        return True
    except Exception as e:
        print(f"Failed to create Notion page: {e}", file=sys.stderr)
        return False


def send_to_telegram(summary_path, config):
    """通过 OpenClaw message 工具发送到 Telegram。"""
    try:
        with open(summary_path, "r", encoding="utf-8") as f:
            content = f.read()
    except Exception as e:
        print(f"Failed to read summary: {e}", file=sys.stderr)
        return False

    chat_id = config.get("chat_id", "")
    if not chat_id:
        print("Telegram chat_id not configured", file=sys.stderr)
        return False

    # 通过 OpenClaw 的 message 工具发送
    # 这里输出内容，由调用者处理
    print(f"📤 发送到 Telegram chat: {chat_id}")
    print(f"   内容长度: {len(content)} 字符")
    print("\n=== TELEGRAM MESSAGE ===")
    print(content[:2000])  # 限制长度
    if len(content) > 2000:
        print("... (内容已截断)")
    print("=== END ===")
    return True


def send_summary(summary_path):
    config = load_config()
    output_config = config.get("output", {})
    channel = output_config.get("channel", "file")

    channel_config = output_config.get(channel, {})

    if channel == "file":
        return send_to_file(summary_path)
    elif channel == "zulip":
        return send_to_zulip(summary_path, channel_config)
    elif channel == "notion":
        return send_to_notion(summary_path, channel_config)
    elif channel == "telegram":
        return send_to_telegram(summary_path, channel_config)
    else:
        print(f"Unknown output channel: {channel}", file=sys.stderr)
        return False


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: send_summary.py <summary_file_path>", file=sys.stderr)
        sys.exit(1)

    summary_file = sys.argv[1]
    success = send_summary(summary_file)
    sys.exit(0 if success else 1)


path: scripts/setup.sh
content: #!/usr/bin/env bash
#
# Meeting Assistant - 交互式安装脚本
# Usage: bash meeting-assistant/scripts/setup.sh
#

set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
REPO_DIR="$(cd "$SKILL_DIR/.." && pwd)"
CONFIG_DIR="$HOME/.config/meeting-assistant"
GCP_DIR="$HOME/.config/gcp"
RECORDING_DIR="$REPO_DIR/meeting-recordings"
LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents"
PYTHON_BIN="$(command -v python3 || echo /usr/bin/python3)"

# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color

info()  { echo -e "${BLUE}ℹ️${NC} $1"; }
ok()    { echo -e "${GREEN}✅${NC} $1"; }
warn()  { echo -e "${YELLOW}⚠️${NC} $1"; }
err()   { echo -e "${RED}❌${NC} $1"; }
header(){ echo; echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"; echo -e "${BLUE}  $1${NC}"; echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"; }
prompt(){ echo -ne "${YELLOW}➡️${NC} $1 "; }

check_repo_layout() {
    if [[ ! -f "$SCRIPT_DIR/record_audio.py" || ! -f "$SCRIPT_DIR/audio_daemon.swift" ]]; then
        err "setup.sh 必须在完整仓库中运行，不能直接 curl | bash。"
        echo "请使用："
        echo "  git clone https://github.com/Nowhitestar/meeting-assistant.git"
        echo "  cd meeting-assistant"
        echo "  bash meeting-assistant/scripts/setup.sh"
        exit 1
    fi
}

# ─── Step 0: Check system ────────────────────────────

check_system() {
    header "系统检查"

    if [[ "$(uname)" != "Darwin" ]]; then
        err "此脚本仅支持 macOS"
        exit 1
    fi
    ok "macOS $(sw_vers -productVersion)"

    if ! command -v brew &>/dev/null; then
        warn "Homebrew 未安装，正在安装..."
        /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
        ok "Homebrew 已安装"
    else
        ok "Homebrew $(brew --version | head -1)"
    fi

    if command -v python3 &>/dev/null; then
        ok "Python $(python3 --version)"
    else
        err "Python 3 未安装，请先安装: brew install python@3.14"
        exit 1
    fi
}

# ─── Step 1: Install system deps ─────────────────────

install_deps() {
    header "安装系统依赖"

    local pkgs=()
    local optional=()

    echo "  将安装以下软件包："
    echo "    - ffmpeg / sox       (音频检查与备用处理)"
    echo "    - whisper-cpp        (本地转录 whisper-cli)"
    echo "    - terminal-notifier  (系统通知)"
    echo "    - steipete/tap/gogcli (Google 日历 CLI)"
    echo "    - cloudflared        (日历 webhook 隧道)"
    echo

    prompt "继续安装？[Y/n]"
    read -r ans
    if [[ "$ans" =~ ^[nN] ]]; then
        warn "跳过依赖安装"
        return
    fi

    brew install sox ffmpeg whisper-cpp terminal-notifier 2>&1 | tail -1
    ok "音频/转录/通知工具安装完成"

    brew install steipete/tap/gogcli 2>&1 | tail -1
    ok "gog CLI 安装完成"

    brew install cloudflared 2>&1 | tail -1
    ok "cloudflared 安装完成"
}

# ─── Step 2: Audio setup ─────────────────────────────

setup_audio() {
    header "音频配置"

    echo "  Meeting Assistant 默认使用原生 macOS ScreenCaptureKit + AVFoundation。"
    echo "  不需要 BlackHole、多输出设备或虚拟声卡。"
    echo
    echo "  首次使用 AudioDaemon.app 时，请授权："
    echo "    - 麦克风"
    echo "    - 屏幕与系统音频录制"
    echo

    MIC_DEVICE=":0"
    SYS_DEVICE=":1"  # 仅 SoX fallback 使用；daemon 后端会忽略

    ok "音频后端: daemon (ScreenCaptureKit 系统音频 + AVFoundation 麦克风)"
}

# ─── Step 3: Create config ───────────────────────────

create_config() {
    header "创建配置文件"

    if [[ -f "$CONFIG_DIR/config.json" ]]; then
        warn "配置文件已存在: $CONFIG_DIR/config.json"
        prompt "覆盖？[y/N]"
        read -r ans
        if [[ ! "$ans" =~ ^[yY] ]]; then
            info "保留现有配置"
            return
        fi
    fi

    mkdir -p "$CONFIG_DIR"

    cat > "$CONFIG_DIR/config.json" <<CONFIG
{
  "calendars": [
    {
      "type": "google",
      "enabled": false,
      "gog_account": "",
      "watch_calendars": ["primary"]
    }
  ],
  "audio": {
    "backend": "daemon",
    "mic_device": "$MIC_DEVICE",
    "system_audio_device": "$SYS_DEVICE",
    "output_dir": "$RECORDING_DIR",
    "format": "wav",
    "silence_threshold": 0.01,
    "silence_duration_sec": 300,
    "half_duplex": true
  },
  "transcription": {
    "mode": "local",
    "language": "zh",
    "local_model_path": "",
    "whisper_cli": "whisper-cli"
  },
  "llm": {
    "enabled": false
  },
  "output": {
    "channel": "file"
  },
  "meeting_detection": {
    "enabled": true,
    "interval_sec": 10,
    "stable_sec": 15,
    "prompt_cooldown_sec": 1800
  }
}
CONFIG

    mkdir -p "$RECORDING_DIR"
    ok "配置文件已创建: $CONFIG_DIR/config.json"
    ok "录制目录已创建: $RECORDING_DIR"
}

# ─── Step 4: Compile native helpers ──────────────────

compile_scanner() {
    header "编译窗口扫描工具"

    info "window_scanner 用于在无需辅助功能权限的情况下读取窗口标题"
    echo

    if command -v swiftc &>/dev/null; then
        swiftc -o "$SCRIPT_DIR/window_scanner" \
               "$SCRIPT_DIR/window_scanner.swift" \
               -framework Cocoa 2>&1 | tail -1
        chmod +x "$SCRIPT_DIR/window_scanner"
        ok "window_scanner 编译成功"

        echo
        echo "  首次使用需要授权辅助功能权限。即将打开 window_scanner..."
        echo "  当系统弹出对话框时，请点击「允许」或「好」。"
        echo
        prompt "准备好了吗？按回车继续..."
        read -r

        open "$SCRIPT_DIR/window_scanner"
        sleep 2
        ok "权限对话框已弹出，请点击允许"
        prompt "点完允许后按回车继续..."
        read -r

        # Verify
        local result
        result=$("$SCRIPT_DIR/window_scanner" 2>&1)
        if [[ "$result" == "[]" ]]; then
            warn "window_scanner 未检测到窗口，可能权限未授权"
            warn "请手动添加: 系统设置 → 隐私与安全性 → 辅助功能"
            warn "路径: $SCRIPT_DIR/window_scanner"
            prompt "继续？[Y/n]"
            read -r ans
            if [[ "$ans" =~ ^[nN] ]]; then exit 1; fi
        else
            local count
            count=$(echo "$result" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null)
            ok "window_scanner 工作正常，检测到 $count 个窗口"
        fi
    else
        warn "Swift 编译器未安装（swiftc 不可用），跳过编译"
        warn "后续可以手动编译: xcode-select --install"
    fi
}

compile_audio_daemon() {
    header "编译并签名 AudioDaemon"

    local build_script="$SCRIPT_DIR/build_audio_daemon.sh"
    if [[ ! -x "$build_script" ]]; then
        warn "AudioDaemon build script 不存在或不可执行，跳过"
        return
    fi

    "$build_script"
    ok "AudioDaemon.app 已使用固定 codesign identity 签名"

    echo
    echo "  AudioDaemon 负责捕获系统音频和麦克风。"
    echo "  首次使用需要授权：系统设置 → 隐私与安全性 → 屏幕与系统音频录制 / 麦克风。"
    echo "  如果系统弹出权限对话框，请点击「允许」。"

    open "$SCRIPT_DIR/AudioDaemon.app"
    sleep 4
    local status
    status=$(echo '{"action":"status"}' | nc -w 2 -U "$HOME/.config/meeting-assistant/audio_daemon.sock" 2>/dev/null || true)
    if echo "$status" | grep -q '"sysReady":true' && echo "$status" | grep -q '"micReady":true'; then
        ok "AudioDaemon 捕获权限正常"
    else
        warn "AudioDaemon 尚未 ready: $status"
        warn "请在系统设置中授权 AudioDaemon.app，然后重新运行测试"
    fi
}

# ─── Step 5: Google Calendar setup ───────────────────

setup_calendar() {
    header "Google 日历配置（可选）"

    echo "  Meeting Assistant 可以读取 Google 日历来自动提醒和录制会议。"
    echo "  跳过此步骤也可以使用，只是不会有日历自动同步功能。"
    echo

    prompt "配置 Google 日历？[y/N]"
    read -r ans
    if [[ ! "$ans" =~ ^[yY] ]]; then
        warn "跳过日历配置"
        return
    fi

    # Check gog
    if ! command -v gog &>/dev/null; then
        err "gog 未安装，请先运行安装步骤"
        return
    fi

    # OAuth credentials
    echo
    echo "  需要 Google Cloud OAuth 凭据："
    echo "    1. 打开 https://console.cloud.google.com/"
    echo "    2. 创建项目或选择已有项目"
    echo "    3. 启用 Google Calendar API"
    echo "    4. 凭据 → 创建凭据 → OAuth 客户端 ID → 桌面应用"
    echo "    5. 下载 JSON 文件"
    echo

    prompt "请输入 client_secret.json 的路径（或拖入终端）:"
    read -r cred_path
    cred_path="${cred_path/#\~/$HOME}"
    cred_path="$(eval echo "$cred_path")"

    if [[ ! -f "$cred_path" ]]; then
        err "文件不存在: $cred_path"
        return
    fi

    mkdir -p "$GCP_DIR"
    cp "$cred_path" "$GCP_DIR/client_secret.json"
    ok "凭据已保存到 $GCP_DIR/client_secret.json"

    # gog auth
    echo
    gog auth credentials "$GCP_DIR/client_secret.json"
    ok "gog 凭据已注册"

    prompt "请输入你的 Google 邮箱 (如 [REDACTED_SECRET]):"
    read -r email

    echo
    info "即将打开浏览器进行 OAuth 授权..."
    echo "  授权后浏览器可能会显示「无法连接」，这是正常的。"
    echo "  切换到终端，gog 会自动完成授权。"
    echo

    gog auth add "$email" --services calendar

    # Verify
    info "验证日历访问..."
    gog auth list
    ok "gog 授权完成"

    # Update config
    local tmp
    tmp=$(mktemp)
    python3 -c "
import json
cfg = json.load(open('$CONFIG_DIR/config.json'))
for cal in cfg.get('calendars', []):
    if cal.get('type') == 'google':
        cal['enabled'] = True
        cal['gog_account'] = '$email'
json.dump(cfg, open('$tmp', 'w'), indent=2, ensure_ascii=False)
" 2>/dev/null
    mv "$tmp" "$CONFIG_DIR/config.json"
    ok "配置文件已更新"

    # Test
    echo
    info "测试日历读取..."
    gog calendar events "$email" --from "$(date -u +%Y-%m-%dT00:00:00Z)" --to "$(date -u -v+1d +%Y-%m-%dT00:00:00Z)" 2>&1
    ok "日历读取成功"
}

# ─── Step 6: Install LaunchAgents ────────────────────

install_launchagents() {
    header "安装 LaunchAgent 常驻服务"

    mkdir -p "$LAUNCH_AGENTS_DIR"

    # Helper function to fix paths in plist
    install_plist() {
        local src="$1"
        local name="$2"
        local dest="$LAUNCH_AGENTS_DIR/$name"

        if [[ -f "$dest" ]]; then
            launchctl unload "$dest" 2>/dev/null || true
        fi

        cp "$src" "$dest"

        # Replace placeholder paths with real paths
        sed -i '' \
            -e "s|__PYTHON__|$PYTHON_BIN|g" \
            -e "s|__HOME__|$HOME|g" \
            -e "s|__SCRIPT_DIR__|$SCRIPT_DIR|g" \
            -e "s|__PATH__|/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin|g" \
            "$dest" 2>/dev/null || true

        # If plist has hardcoded paths, update if needed
        if grep -q "$HOME" "$dest"; then
            ok "$name: 已复制"
        else
            # For plists with absolute paths, just copy
            ok "$name: 已复制"
        fi
    }

    local plist_dir="$SCRIPT_DIR"

    # AudioDaemon (native system audio + mic capture)
    if [[ -f "$plist_dir/com.meetingassistant.audiodaemon.plist" ]]; then
        install_plist "$plist_dir/com.meetingassistant.audiodaemon.plist" "com.meetingassistant.audiodaemon.plist"
        launchctl load "$LAUNCH_AGENTS_DIR/com.meetingassistant.audiodaemon.plist" 2>/dev/null || true
        ok "audiodaemon 已加载"
    fi

    # Scheduler
    if [[ -f "$plist_dir/com.meetingassistant.scheduler.plist" ]]; then
        install_plist "$plist_dir/com.meetingassistant.scheduler.plist" "com.meetingassistant.scheduler.plist"
        launchctl load "$LAUNCH_AGENTS_DIR/com.meetingassistant.scheduler.plist" 2>/dev/null || true
        ok "scheduler 已加载"
    fi

    # Detector
    if [[ -f "$plist_dir/com.meetingassistant.detector.plist" ]]; then
        install_plist "$plist_dir/com.meetingassistant.detector.plist" "com.meetingassistant.detector.plist"
        launchctl load "$LAUNCH_AGENTS_DIR/com.meetingassistant.detector.plist" 2>/dev/null || true
        ok "detector 已加载"
    fi

    # Calendar service (optional, only if gog configured)
    if [[ -f "$plist_dir/com.meetingassistant.calendar.plist" ]]; then
        prompt "安装日历推送服务（需要 Google 日历）？[y/N]"
        read -r ans
        if [[ "$ans" =~ ^[yY] ]]; then
            install_plist "$plist_dir/com.meetingassistant.calendar.plist" "com.meetingassistant.calendar.plist"
            launchctl load "$LAUNCH_AGENTS_DIR/com.meetingassistant.calendar.plist" 2>/dev/null || true
            ok "calendar 已加载"
        fi
    fi

    echo
    info "正在等待服务启动..."
    sleep 3
    launchctl list | grep com.meetingassistant
    ok "服务已安装"
}

# ─── Step 7: Test ────────────────────────────────────

run_tests() {
    header "功能验证"

    echo "  1/3 检测器测试"
    local detect_result
    detect_result=$(python3 "$SCRIPT_DIR/meeting_detector.py" once 2>&1)
    if echo "$detect_result" | grep -q "active"; then
        ok "检测器运行正常"
    else
        warn "检测器异常: $detect_result"
    fi

    echo "  2/3 日历测试"
    if python3 "$SCRIPT_DIR/check_meetings.py" today 2>&1; then
        ok "日历读取正常"
    else
        warn "日历读取异常（如果未配置日历则正常）"
    fi

    echo "  3/4 AudioDaemon 测试"
    local audio_status
    audio_status=$(echo '{"action":"status"}' | nc -w 2 -U "$HOME/.config/meeting-assistant/audio_daemon.sock" 2>/dev/null || true)
    if echo "$audio_status" | grep -q '"sysReady":true' && echo "$audio_status" | grep -q '"micReady":true'; then
        ok "AudioDaemon 运行正常"
    else
        warn "AudioDaemon 异常: $audio_status"
    fi

    echo "  4/4 通知测试"
    if command -v terminal-notifier &>/dev/null; then
        terminal-notifier -title "Meeting Assistant" -message "安装完成！" -sound default 2>/dev/null || true
        ok "通知测试通过"
    fi

    echo
    ok "安装验证完成！"
}

# ─── Summary ─────────────────────────────────────────

show_summary() {
    header "安装完成 🎉"

    echo "  Meeting Assistant 已安装并运行："
    echo
    echo "  📁 配置目录: $CONFIG_DIR"
    echo "  📁 录制目录: $RECORDING_DIR"
    echo "  📁 项目路径: $REPO_DIR"
    echo
    echo "  ⚡ 已运行的服务："
    launchctl list | grep com.meetingassistant 2>/dev/null | while IFS= read -r line; do
        pid=$(echo "$line" | awk '{print $1}')
        name=$(echo "$line" | awk '{print $3}')
        if [[ "$pid" != "-" ]]; then
            echo "     ✅ $name (pid=$pid)"
        else
            echo "     ❌ $name (未运行)"
        fi
    done
    echo
    echo "  📖 使用指南："
    echo "    bash meeting-assistant/scripts/setup.sh  # 重新运行此安装脚本"
    echo "    check_meetings.py today   # 查看今天会议"
    echo "    meeting_detector.py once  # 手动检测会议"
    echo
    echo "  🛠️  管理命令："
    echo "    launchctl list | grep com.meetingassistant  # 查看服务状态"
    echo "    launchctl unload ~/Library/LaunchAgents/com.meetingassistant.XXX.plist  # 停止服务"
    echo "    launchctl load   ~/Library/LaunchAgents/com.meetingassistant.XXX.plist  # 启动服务"
    echo
    echo "  ❓ 需要帮助？"
    echo "    README.md 中有完整文档"
    echo "    https://github.com/Nowhitestar/meeting-assistant"
}

# ─── Main ────────────────────────────────────────────

clear
echo -e "${BLUE}"
echo "  ╔══════════════════════════════════════════╗"
echo "  ║         Meeting Assistant 安装脚本       ║"
echo "  ╚══════════════════════════════════════════╝"
echo -e "${NC}"
echo "  本脚本将引导你完成 Meeting Assistant 的安装和配置。"
echo "  全程大约需要 10-15 分钟。"
echo

prompt "开始安装？[Y/n]"
read -r ans
if [[ "$ans" =~ ^[nN] ]]; then
    echo "安装已取消"
    exit 0
fi

check_repo_layout
check_system
install_deps
setup_audio
create_config
compile_scanner
compile_audio_daemon
setup_calendar
install_launchagents
run_tests
show_summary

echo
info "安装完成！有任何问题请查看 README.md 或提交 GitHub Issue。"


path: scripts/summary_template.md
content: ---
name: Default Meeting Summary
description: General-purpose meeting summary
language: zh-en
---

You are my meeting assistant. Below is a transcript of a meeting with
speaker labels (SPEAKER_00, SPEAKER_01, ...). First, use the attendee list
in the meeting metadata and conversational cues to map SPEAKER_XX to real
names where you are confident (leave SPEAKER_XX if ambiguous). Then output
the sections below, in Chinese-English mixed style (Chinese primary):

## TL;DR
（3 句话以内概括会议）

## Discussion Points
- 按话题分条，每条后面附主要发言人（真名优先，否则 SPEAKER_XX）

## Action Items
- [ ] @负责人 - 具体事项 - 截止日期（如有）

## Open Questions / Blockers
- ...

## Decisions Made
- ...

path: scripts/transcribe.py
content: #!/usr/bin/env python3
"""
转录音频并生成会议纪要。

转录: whisper.cpp (whisper-cli)
摘要: 默认交给 OpenClaw agent（闪电）根据模板生成；也可配置外部命令

依赖：
  brew install whisper-cpp        # whisper-cli
  npm i -g @anthropic-ai/claude-code  # claude-cli (OpenClaw 默认已装)

模型：
  下载 ggml-medium.bin / ggml-large-v3.bin 到 ~/Models/whisper/

配置（config.json）：
{
  "transcription": {
    "command": ["whisper-cli", "-m", "/Users/x/Models/whisper/ggml-medium.bin",
                "-l", "zh", "-otxt", "-of", "{{output_stem}}", "{{input}}"]
  },
  "llm": {
    "enabled": true,
    "command": ["claude", "--print", "--model", "claude-opus-4-7"]
  }
}

输出：
  <meeting>.transcript.txt  — 原始转录
  <meeting>.summary.md      — 结构化纪要
"""

import json
import os
import shlex
import subprocess
import sys
from datetime import datetime
from pathlib import Path

CONFIG_PATH = Path.home() / ".config" / "meeting-assistant" / "config.json"

DEFAULT_TRANSCRIBE_CMD = [
    "whisper-cli",
    "-m", str(Path.home() / "Models/whisper/ggml-medium.bin"),
    "-l", "zh",
    "-otxt",
    "-of", "{{output_stem}}",
    "{{input}}",
]

DEFAULT_LLM_CMD = []  # 默认空 = 由 agent (闪电) 处理

NOTIFY_SCRIPT = Path(__file__).parent / "notify.py"

SUMMARY_PROMPT = """请将以下会议转录整理成结构化会议纪要。

会议主题：{title}

要求：
1. 列出会议基本信息（主题、时间）
2. 按议题分类讨论要点，每个议题下列出关键发言和结论
3. 提取所有 Action Items（待办事项），标注负责人和截止日期（如能从内容推断）
4. 提取关键决策结论
5. 使用中文，Markdown 格式输出，不要任何额外说明文字

会议转录：
---
{transcript}
---
"""


def load_config():
    if not CONFIG_PATH.exists():
        print(f"Config not found at {CONFIG_PATH}", file=sys.stderr)
        sys.exit(1)
    with open(CONFIG_PATH) as f:
        return json.load(f)


def render_cmd(template, **vars):
    """把命令模板里的 {{key}} 替换成实际值。"""
    rendered = []
    for tok in template:
        for k, v in vars.items():
            tok = tok.replace(f"{{{{{k}}}}}", v)
        rendered.append(tok)
    return rendered


def transcribe(audio_path, config):
    """用 whisper-cli 转录。返回转录文本。"""
    audio_path = Path(audio_path).resolve()
    cmd_template = config.get("command") or DEFAULT_TRANSCRIBE_CMD

    # whisper-cli 的 -otxt -of <stem> 会写到 <stem>.txt
    output_stem = audio_path.with_suffix("").as_posix() + ".whisper"
    output_txt = Path(f"{output_stem}.txt")

    cmd = render_cmd(cmd_template, input=str(audio_path), output_stem=output_stem)
    print(f"🎙️ whisper-cli: {shlex.join(cmd)}")

    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        print(f"whisper-cli failed: {result.stderr}", file=sys.stderr)
        sys.exit(1)

    if not output_txt.exists():
        print(f"Transcript file not written: {output_txt}", file=sys.stderr)
        sys.exit(1)

    transcript = output_txt.read_text(encoding="utf-8").strip()
    output_txt.unlink()  # 清理 whisper 中间文件
    return transcript


def summarize(transcript, meeting_title, config):
    """生成结构化纪要。

    如果显式配置了外部命令则调用；默认不使用 claude-cli，交给 OpenClaw agent 队列处理。
    返回 markdown 字符串；None 表示需要 agent 后续生成。
    """
    cmd_template = config.get("command") or DEFAULT_LLM_CMD

    if not cmd_template:
        print("🤖 未配置外部 LLM，先生成本地草稿并请求 OpenClaw agent 生成最终摘要...")
        return None

    cmd = render_cmd(cmd_template)
    prompt = SUMMARY_PROMPT.format(title=meeting_title, transcript=transcript)
    print(f"🤖 LLM: {shlex.join(cmd)}")
    try:
        result = subprocess.run(
            cmd,
            input=prompt,
            capture_output=True,
            text=True,
            timeout=180,
        )
        if result.returncode == 0:
            return result.stdout.strip()
        print(f"LLM failed: {result.stderr}", file=sys.stderr)
    except Exception as e:
        print(f"LLM error: {e}", file=sys.stderr)
    return None


def _send_agent_notification(title):
    """发系统通知告知转录完成，等待 agent 总结。"""
    notify = NOTIFY_SCRIPT
    subprocess.Popen(
        [sys.executable, str(notify), "remind",
         "Meeting Assistant",
         f"「{title}」转录完成，找我出纪要",
         "待总结"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )


def _notify_agent(event_type, **kw):
    try:
        from agent_notify import notify
        notify(event_type, **kw)
    except Exception:
        pass


def fallback_summary(transcript, meeting_title):
    """无外部 LLM 时生成可直接使用的本地摘要，不再输出占位文字。"""
    import re

    lines = [line.strip() for line in transcript.splitlines() if line.strip()]
    text = " ".join(lines)
    tldr = text[:220] + ("…" if len(text) > 220 else "")

    points = []
    for line in lines:
        if len(line) >= 4 and line not in points:
            points.append(line)
        if len(points) >= 8:
            break

    action_lines = [
        line for line in lines
        if re.search(r"(需要|要做|负责|跟进|安排|确认|明天|下周|todo|action)", line, re.I)
    ]
    question_lines = [
        line for line in lines
        if "?" in line or "？" in line or re.search(r"(问题|疑问|阻塞|不确定|block)", line, re.I)
    ]
    decision_lines = [
        line for line in lines
        if re.search(r"(决定|确认|结论|同意|采用|最终)", line)
    ]

    def bullets(items, empty="无明确内容"):
        return "\n".join(f"- {x}" for x in items[:8]) if items else f"- {empty}"

    def todos(items):
        return "\n".join(f"- [ ] {x}" for x in items[:8]) if items else "- [ ] 无明确待办"

    return (
        f"# {meeting_title}\n\n"
        f"## TL;DR\n"
        f"{tldr or '转录为空，无法生成摘要。'}\n\n"
        f"## Discussion Points\n"
        f"{bullets(points)}\n\n"
        f"## Action Items\n"
        f"{todos(action_lines)}\n\n"
        f"## Open Questions / Blockers\n"
        f"{bullets(question_lines)}\n\n"
        f"## Decisions Made\n"
        f"{bullets(decision_lines, '无明确决策')}\n\n"
        f"---\n"
        f"## 原始转录\n\n{transcript}\n"
    )


def request_agent_summary(meeting_title, transcript_path, summary_path):
    """通知 OpenClaw agent 按模板生成最终 summary。"""
    template_path = Path(__file__).parent / "summary_template.md"
    _notify_agent(
        "summary_request",
        title=meeting_title,
        transcript_path=str(transcript_path),
        summary_path=str(summary_path),
        template_path=str(template_path),
    )


def process_audio(audio_path):
    config = load_config()
    trans_cfg = config.get("transcription", {})
    llm_cfg = config.get("llm", {})

    audio_path = Path(audio_path)
    if not audio_path.exists():
        print(f"Audio file not found: {audio_path}", file=sys.stderr)
        sys.exit(1)

    meeting_title = audio_path.stem.rsplit("_", 1)[0].replace("_", " ")
    print(f"📁 处理: {audio_path.name}（标题: {meeting_title}）")

    # 1. 转录
    transcript = transcribe(audio_path, trans_cfg)

    # 2. 保存转录
    transcript_path = audio_path.with_suffix(".transcript.txt")
    transcript_path.write_text(transcript, encoding="utf-8")
    print(f"✅ 转录已保存: {transcript_path}")
    print(f"Transcript saved: {transcript_path}")

    # 3. 摘要
    summary = None
    if llm_cfg.get("enabled", True):
        summary = summarize(transcript, meeting_title, llm_cfg)
    agent_should_finalize = False
    if summary is None:
        # 先写一份本地可读草稿作为兜底，但不发送；随后交给 OpenClaw agent 覆盖为最终版。
        summary = fallback_summary(transcript, meeting_title)
        agent_should_finalize = True

    # 4. 保存摘要
    summary_path = audio_path.with_suffix(".summary.md")
    summary_path.write_text(summary, encoding="utf-8")
    print(f"✅ 纪要已保存: {summary_path}")
    print(f"Summary saved: {summary_path}")
    if agent_should_finalize:
        print("Summary status: draft_agent_pending")
        request_agent_summary(meeting_title, transcript_path, summary_path)
    else:
        print("Summary status: final")
        _notify_agent("summary_ready", title=meeting_title, path=str(summary_path))

    return str(transcript_path), str(summary_path)


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: transcribe.py <audio_file_path>", file=sys.stderr)
        sys.exit(1)
    process_audio(sys.argv[1])


path: scripts/webhook_server.py
content: #!/usr/bin/env python3
# DEPRECATED: 此文件已合并到 run_calendar_services.py。
# 请使用 run_calendar_services.py 一站式启动 webhook + tunnel + watch。
# 保留此文件仅作参考。

"""
Google Calendar Push Notification webhook 服务器。

接收 Google Calendar 的推送通知 → 触发日历同步 → 更新 schedule.json → SIGHUP scheduler。

用法：
  webhook_server.py <port>
  webhook_server.py <port> --ngrok-url <url>   # 首次启动时注册 watch 频道
"""

import argparse
import json
import os
import signal
import subprocess
import sys
import threading
import time
import uuid
from datetime import datetime, timedelta, timezone
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path

CONFIG_DIR = Path.home() / ".config" / "meeting-assistant"
STATE_PATH = CONFIG_DIR / ".watch_state.json"
LOG_PATH = CONFIG_DIR / "webhook.log"
SCRIPT_DIR = Path(__file__).resolve().parent

GOG_ACCOUNT = os.environ.get("GOG_ACCOUNT", "default")
GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "")
GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "")
REFRESH_TOKEN = os.environ.get("GOG_REFRESH_TOKEN", "")

# Deprecated fallback server: keep defaults generic and load real calendar IDs
# from environment when needed. Prefer run_calendar_services.py.
CALENDARS = [
    {"id": os.environ.get("GOOGLE_CALENDAR_ID", "primary"), "label": "primary"},
]

LOCK = threading.Lock()
LAST_SYNC = 0
SYNC_COOLDOWN = 10  # 秒，防止通知风暴重复同步


def log(msg):
    ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    line = f"[{ts}] {msg}"
    print(line, flush=True)
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    with LOG_PATH.open("a", encoding="utf-8") as f:
        f.write(line + "\n")


def _get_access_token():
    """用 refresh token 换取 access token。"""
    import urllib.parse
    import urllib.request

    if not (GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET and REFRESH_TOKEN):
        raise RuntimeError(
            "Missing Google OAuth environment variables: "
            "GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOG_REFRESH_TOKEN"
        )

    data = urllib.parse.urlencode({
        "grant_type": "refresh_token",
        "client_id": GOOGLE_CLIENT_ID,
        "client_secret": GOOGLE_CLIENT_SECRET,
        "refresh_token": REFRESH_TOKEN,
    }).encode()
    req = urllib.request.Request(
        "https://oauth2.googleapis.com/token",
        data=data,
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=10) as resp:
        body = json.loads(resp.read())
    return body["access_token"]


def do_sync():
    """执行日历同步：check_meetings → schedule.json → SIGHUP scheduler。"""
    global LAST_SYNC
    now = time.time()
    if now - LAST_SYNC < SYNC_COOLDOWN:
        log("⏭ 同步冷却中，跳过")
        return False
    LAST_SYNC = now

    try:
        # 运行 check_meetings.py 获取本周会议
        r = subprocess.run(
            [sys.executable, str(SCRIPT_DIR / "check_meetings.py"), "week", "--json"],
            capture_output=True, text=True, timeout=30,
        )
        if r.returncode != 0:
            log(f"⚠️ check_meetings 失败: {r.stderr}")
            return False

        meetings = json.loads(r.stdout)
        log(f"📅 获取到 {len(meetings)} 个会议")

        # 构建 schedule 事件
        events = []
        now_local = datetime.now()
        for m in meetings:
            try:
                start = datetime.fromisoformat(m["start"].replace("Z", "+00:00"))
                end = datetime.fromisoformat(m["end"].replace("Z", "+00:00"))
            except (ValueError, KeyError):
                continue

            # 跳过已过期和在录制中的
            if end < now_local:
                continue

            title = m["title"]
            meeting_id = m.get("id", str(uuid.uuid4()))

            # 提前 5 分钟提醒
            remind_at = start - timedelta(minutes=5)
            if remind_at > now_local:
                events.append({
                    "kind": "remind",
                    "at": remind_at.isoformat(),
                    "title": title,
                    "meeting_id": meeting_id,
                })

            # 开始时间弹录制
            if start > now_local:
                events.append({
                    "kind": "ask_record",
                    "at": start.isoformat(),
                    "title": title,
                    "meeting_id": meeting_id,
                })

        schedule = {"events": events, "meetings": meetings}
        SCHEDULE_PATH = CONFIG_DIR / "schedule.json"
        SCHEDULE_PATH.write_text(json.dumps(schedule, indent=2, ensure_ascii=False))
        log(f"✅ schedule.json 已更新，{len(events)} 个事件")

        # SIGHUP scheduler 重载
        try:
            r = subprocess.run(
                ["pgrep", "-f", "scheduler_daemon.py"],
                capture_output=True, text=True, timeout=5,
            )
            for pid in r.stdout.strip().splitlines():
                pid = pid.strip()
                if pid:
                    os.kill(int(pid), signal.SIGHUP)
                    log(f"📡 SIGHUP 已发送到 scheduler (pid={pid})")
        except Exception as e:
            log(f"⚠️ SIGHUP 失败: {e}")

        return True
    except Exception as e:
        log(f"❌ 同步异常: {e}")
        return False


def register_watch(ngrok_url):
    """为所有日历注册 Google Calendar push notification channel。"""
    from urllib.request import Request, urlopen
    import urllib.parse
    import urllib.error

    [REDACTED_SECRET])
    channel_id = str(uuid.uuid4())

    results = []
    for cal in CALENDARS:
        cal_id = urllib.parse.quote(cal["id"], safe="")
        url = f"https://www.googleapis.com/calendar/v3/calendars/{cal_id}/events/watch"
        body = json.dumps({
            "id": channel_id,
            "type": "web_hook",
            "address": f"{ngrok_url.rstrip('/')}/calendar-webhook",
        }).encode()

        req = Request(
            url, data=body,
            headers={
                "Authorization": f"Bearer {token}",
                "Content-Type": "application/json",
            },
            method="POST",
        )
        try:
            with urlopen(req, timeout=15) as resp:
                data = json.loads(resp.read())
            # 7 天后过期
            expires = datetime.fromtimestamp(int(data["expiration"]) / 1000, tz=timezone.utc)
            results.append({
                "calendar_id": cal["id"],
                "label": cal["label"],
                "channel_id": channel_id,
                "resource_id": data.get("resourceId", ""),
                "expires": expires.isoformat(),
            })
            log(f"✅ 已注册 watch: {cal['label']} → 过期 {expires.isoformat()}")
        except urllib.error.HTTPError as e:
            err = e.read().decode()
            log(f"❌ 注册 watch 失败 ({cal['label']}): {e.code} {err}")

    # 保存状态
    state = {
        "ngrok_url": ngrok_url,
        "channels": results,
        "registered_at": datetime.now(timezone.utc).isoformat(),
    }
    STATE_PATH.write_text(json.dumps(state, indent=2, ensure_ascii=False))
    return results


class WebhookHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        """GET / → 健康检查"""
        if self.path == "/":
            self.send_response(200)
            self.send_header("Content-Type", "text/plain")
            self.end_headers()
            self.wfile.write(b"Meeting Assistant Calendar Webhook")
            return
        self.send_response(404)
        self.end_headers()

    def do_POST(self):
        """POST /calendar-webhook → Google Calendar 推送通知"""
        if self.path == "/calendar-webhook":
            resource_state = self.headers.get("X-Goog-Resource-State", "")
            channel_id = self.headers.get("X-Goog-Channel-ID", "")
            resource_id = self.headers.get("X-Goog-Resource-ID", "")

            log(f"📩 收到推送: state={resource_state} channel={channel_id[:8]}...")

            if resource_state == "sync":
                # 初始同步通知（频道创建时）
                self.send_response(200)
                self.end_headers()
                self.wfile.write(b"OK")
                return

            # 非初始同步 → 触发日历同步
            if resource_state in ("update", "exists"):
                threading.Thread(target=do_sync, daemon=True).start()

            self.send_response(200)
            self.end_headers()
            self.wfile.write(b"OK")
            return

        self.send_response(404)
        self.end_headers()

    def log_message(self, fmt, *args):
        """静的 access log 不打印"""
        pass


def run_server(port):
    server = HTTPServer(("0.0.0.0", port), WebhookHandler)
    log(f"🌐 Webhook 服务器启动: http://0.0.0.0:{port}")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        server.shutdown()


def main():
    parser = argparse.ArgumentParser(description="Calendar webhook server")
    parser.add_argument("port", type=int, nargs="?", default=8899, help="本地端口")
    parser.add_argument("--ngrok-url", help="ngrok 公网 URL（首次启动时注册 watch 频道）")
    args = parser.parse_args()

    # 首次启动：注册 watch + 初始同步
    if args.ngrok_url:
        log("🔄 首次启动：注册 watch 频道...")
        register_watch(args.ngrok_url)
        log("🔄 执行初始同步...")
        do_sync()
    else:
        # 后续启动：加载已注册的频道信息
        if STATE_PATH.exists():
            state = json.loads(STATE_PATH.read_text())
            log(f"📋 已注册 {len(state.get('channels', []))} 个 watch 频道")
            # 检查是否过期（7 天）
            for ch in state.get("channels", []):
                expires = datetime.fromisoformat(ch["expires"])
                if expires < datetime.now(timezone.utc):
                    log(f"⚠️ 频道过期: {ch['label']} (于 {ch['expires']})")
                    log("   ngrok URL 未变的话重启 webhook 时加 --ngrok-url 重新注册")
        else:
            log("⚠️ 未注册 watch 频道，需要 --ngrok-url 参数首次启动")

    run_server(args.port)


if __name__ == "__main__":
    main()


path: scripts/window_scanner.swift
content: import Cocoa
import ApplicationServices

func getAllWindowTitles() -> [[String: String]] {
    var results: [[String: String]] = []
    let apps = NSRunningApplication.runningApplications(withBundleIdentifier: "")
    
    for app in NSWorkspace.shared.runningApplications {
        guard app.activationPolicy == .regular else { continue }
        let appElem = AXUIElementCreateApplication(app.processIdentifier)
        
        var windowList: CFTypeRef?
        let result = AXUIElementCopyAttributeValue(appElem, kAXWindowsAttribute as CFString, &windowList)
        guard result == .success, let windows = windowList as? [AXUIElement] else { continue }
        
        for win in windows {
            var titleRef: CFTypeRef?
            AXUIElementCopyAttributeValue(win, kAXTitleAttribute as CFString, &titleRef)
            let title = (titleRef as? String) ?? ""
            if !title.isEmpty {
                results.append([
                    "app": app.localizedName ?? "Unknown",
                    "title": title
                ])
            }
        }
    }
    return results
}

// Request permission if needed
let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString: true]
let trusted = AXIsProcessTrustedWithOptions(options)

if trusted {
    let windows = getAllWindowTitles()
    if let data = try? JSONSerialization.data(withJSONObject: windows, options: []),
       let str = String(data: data, encoding: .utf8) {
        print(str)
    }
} else {
    print("[]")
}


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)