/** 会话采集 + transcript 解析。
 *  cmdCollect:读 shine-worklog daemon /api/sessions 映射成 shine-worklog session 写盘(hook/full 双模式)。
 *  fetchDaemonSignalsMap:读 daemon /api/signals(后台预提取的关键信号,/report AI 填空的兜底素材)。
 *  extractTranscriptSignals:daemon 不可达/老会话无预提取时,现场直读 transcript 的退化提取。
 *  buildSessionDigest:窗口 digest(/report AI 重归纳主素材)——按 auto-note 定位的窗口读整段原始对话,
 *  turn 分组渲染 markdown 落盘,供 AI 读后重写 work(auto-note 拼接的 conclusion 有噪音,2026-08-26 改造)。 */
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import * as path from "node:path";
import { DATA_DIR, PROJECT_CWD, PROJECT_DIR, SESSIONS_PATH, encodeProject, todayISO, localDateISO, lastSubmitSinceEpoch, writeJSON, writeText, num, countLines, extractText, localHHMM, gitBranchFallback } from "./shared";

// ---------- shine-worklog daemon 数据接入(合并后 collect 改读 daemon,不再挖 transcript) ----------

/** 读 shine-worklog daemon 的持久 token(复刻 src/shared/pidfile.ts readToken + paths.ts DATA_DIR,跨平台,零依赖)。 */
function readDaemonToken(): string | null {
  try {
    const pid = JSON.parse(readFileSync(path.join(DATA_DIR, "daemon.pid"), "utf8"));
    if (pid && typeof pid.token === "string" && pid.token) return pid.token;
  } catch {
    /* ignore */
  }
  try {
    const t = readFileSync(path.join(DATA_DIR, "daemon.token"), "utf8").trim();
    if (t.length >= 16) return t;
  } catch {
    /* ignore */
  }
  return null;
}

const DAEMON_BASE = "http://127.0.0.1:36666"; // config.ts BASE_URL,回环固定

/** 查 daemon /api/signals 拿当天本项目各会话的关键信号(daemon 消费者后台持续提取:
 *  每 turn 结论/用户意图/git commits/任务清单/改动文件,存 DATA_DIR/signals 按项目按日期)。
 *  返回 sessionId→signals 映射(可能为空对象);daemon 不可达/端点不存在(旧版)返回 null,调用方退化直读 transcript。 */
export async function fetchDaemonSignalsMap(cwd: string, sinceMs: number): Promise<Record<string, any> | null> {
  const token = readDaemonToken();
  if (!token) return null;
  try {
    const res = await fetch(`${DAEMON_BASE}/api/signals?cwd=${encodeURIComponent(cwd)}&since=${sinceMs}`, {
      headers: { authorization: `Bearer ${token}` },
      signal: AbortSignal.timeout(3000),
    });
    if (!res.ok) return null;
    const data = (await res.json()) as { sessions?: any[] };
    const map: Record<string, any> = {};
    for (const s of data.sessions ?? []) {
      if (s && typeof s.sessionId === "string") map[s.sessionId] = s;
    }
    return map;
  } catch {
    return null;
  }
}

/** 精查单个会话的信号(?sessionId=,daemon 端不受 since 过滤/200 上限影响,open 未闭合 turn 也并入)。
 *  auto-note 的取料口;daemon 不可达/无该会话返回 null(调用方静默跳过)。 */
export async function fetchDaemonSignals(cwd: string, sessionId: string): Promise<any | null> {
  const token = readDaemonToken();
  if (!token) return null;
  try {
    const res = await fetch(`${DAEMON_BASE}/api/signals?cwd=${encodeURIComponent(cwd)}&sessionId=${encodeURIComponent(sessionId)}`, {
      headers: { authorization: `Bearer ${token}` },
      signal: AbortSignal.timeout(3000),
    });
    if (!res.ok) return null;
    const data = (await res.json()) as { sessions?: any[] };
    const hit = (data.sessions ?? []).find((s: any) => s && s.sessionId === sessionId);
    return hit ?? null;
  } catch {
    return null;
  }
}

/** 查 daemon 自 sinceMs 以来本项目 sessions(GET /api/sessions?cwd=&since=,daemon 按 last_activity 过滤)。 */
async function fetchDaemonSessions(
  cwd: string,
  sinceMs: number,
  token: string,
): Promise<{ sessions: any[]; total: number }> {
  const url = `${DAEMON_BASE}/api/sessions?cwd=${encodeURIComponent(cwd)}&since=${sinceMs}&pageSize=1000`;
  const res = await fetch(url, {
    headers: { authorization: `Bearer ${token}` },
    signal: AbortSignal.timeout(3000),
  });
  if (!res.ok) throw new Error(`daemon HTTP ${res.status}`);
  return (await res.json()) as { sessions: any[]; total: number };
}

/** daemon ProjectSession → shine-worklog session(字段映射见合并方案 2.1)。 */
export function toZenSession(s: any, branch: string | null): any {
  const activeMs = num(s.activeMs);
  const lastActive = num(s.lastActive);
  const tt = s.tokenTotal || {};
  const lt = s.linesTotal || { added: 0, deleted: 0, modified: 0 };
  return {
    id: s.sessionId,
    cwd: s.cwd || PROJECT_CWD,
    repo: path.basename(s.cwd || PROJECT_CWD),
    branch,
    date: localDateISO(lastActive), // 会话归属日(按 lastActive;跨午夜会话归活跃日)——多天补报时 item.date/禅道 date 用
    start: localHHMM(Math.max(0, lastActive - activeMs)),
    end: localHHMM(lastActive),
    activeMinutes: Math.round(activeMs / 60000),
    tokens: { input: num(tt.input) + num(tt.cacheCreation) + num(tt.cacheRead), output: num(tt.output) },
    filesChanged: 0,
    linesAdded: num(lt.added) + num(lt.modified),
    linesRemoved: num(lt.deleted) + num(lt.modified),
    summary: typeof s.title === "string" && s.title ? s.title : "(无文本提示)",
  };
}

/** 解析 Claude transcript jsonl 成结构化信号(逐行 parse:user prompts + assistant texts + tool_use 的 files/行数/计数)。
 *  extractTranscriptSignals(prepare 热路径)用。未来 transcript 格式变更,改此处。返回 null=空/non-Claude 文件。 */
export function parseTranscriptEvents(raw: string): {
  prompts: string[];
  assistantTexts: string[];
  toolUseCounts: Record<string, number>;
  files: string[];
  added: number;
  removed: number;
} | null {
  const prompts: string[] = [];
  const assistantTexts: string[] = [];
  const toolUseCounts: Record<string, number> = {};
  const files = new Set<string>();
  let added = 0;
  let removed = 0;
  for (const line of raw.split("\n")) {
    if (!line.trim()) continue;
    let ev: any;
    try {
      ev = JSON.parse(line);
    } catch {
      continue;
    }
    const role = ev.message?.role;
    if (role === "user") {
      const text = extractText(ev.message?.content).trim();
      if (text && !text.startsWith("<") && text.length > 1 && text.length <= 300) prompts.push(text);
    } else if (role === "assistant") {
      const content = ev.message?.content;
      if (!Array.isArray(content)) continue;
      for (const b of content) {
        if (!b) continue;
        if (b.type === "text" && typeof b.text === "string" && b.text.trim()) {
          assistantTexts.push(b.text);
        } else if (b.type === "tool_use") {
          const name = b.name ?? "unknown";
          toolUseCounts[name] = (toolUseCounts[name] ?? 0) + 1;
          const input = b.input || {};
          if (typeof input.file_path === "string") files.add(input.file_path);
          if (b.name === "Edit") {
            added += countLines(input.new_string);
            removed += countLines(input.old_string);
          } else if (b.name === "Write") {
            added += countLines(input.content);
          } else if (b.name === "MultiEdit" && Array.isArray(input.edits)) {
            for (const e of input.edits) {
              added += countLines(e.new_string);
              removed += countLines(e.old_string);
            }
          }
        }
      }
    }
  }
  if (prompts.length === 0 && assistantTexts.length === 0 && Object.keys(toolUseCounts).length === 0) return null;
  return { prompts, assistantTexts, toolUseCounts, files: [...files], added, removed };
}

/** 为 prepare 提取生成 work 所需的精选 transcript 信号(不重算工时/token,那些 daemon 已给)。
 *  定位 ~/.claude/projects/<encodeProject(cwd)>/<sessionId>.jsonl,提取:
 *  前5条 prompts + 最近6条 assistant 文本(各≤500字) + toolUseCounts + 去重 filesChanged(前20) + 行数。
 *  文件不存在/空内容返回 null,调用方退化用 daemonSummary+candidates。解析走 parseTranscriptEvents。 */
export function extractTranscriptSignals(sessionId: string, cwd: string): any | null {
  const filePath = path.join(homedir(), ".claude", "projects", encodeProject(cwd), sessionId + ".jsonl");
  let raw: string;
  try {
    raw = readFileSync(filePath, "utf8");
  } catch {
    return null;
  }
  const parsed = parseTranscriptEvents(raw);
  if (!parsed) return null;
  const recentAssistantTexts = parsed.assistantTexts
    .slice(-6)
    .map((t: string) => (t.length > 500 ? t.slice(0, 500) : t)); // 最近的工作汇报:生成 work 的关键素材
  const filesChanged = parsed.files
    .map((f) => {
      const rel = path.relative(cwd, f); // 尽量转相对 cwd,便于阅读
      return rel && !rel.startsWith("..") ? rel : f;
    })
    .slice(0, 20);
  return {
    path: filePath,
    prompts: parsed.prompts.slice(0, 5),
    recentAssistantTexts,
    toolUseCounts: parsed.toolUseCounts,
    filesChanged,
    linesAdded: parsed.added,
    linesRemoved: parsed.removed,
  };
}

// ---------- 窗口 digest(/report AI 重归纳主素材:整段原始对话按 turn 分组) ----------
// 规则与 src/daemon/signals.ts 同源的 skills 层内联复刻(零依赖不能 import src/;改 turn 边界/回放判重/
// commit 提取规则时两边同步)。本质差异:signals 增量只存每 turn 末条 text(conclusion),digest 全量收
// 每 turn **全部** text 块(每块截断)——AI 据此重写 work,解决 auto-note 拼接 conclusion 的噪音问题。

export const DIGEST_MAX_TURNS = 200; // 渲染保最新 N 轮(解析不丢,头部统计仍报全量轮数)
export const DIGEST_MAX_PROMPTS = 5;
export const DIGEST_PROMPT_CHARS = 200;
export const DIGEST_MAX_COMMITS = 10;
export const DIGEST_COMMIT_CHARS = 200;
export const DIGEST_MAX_TASKS = 10;
export const DIGEST_TASK_CHARS = 120;
export const DIGEST_MAX_FILES = 50; // 单段文件上限(新版会话段落可能很大,20 会截;全会话并集另计)
export const DIGEST_MAX_FILES_SESSION = 50;
export const DIGEST_TEXT_CHARS = 600; // 单个 assistant text 块截断(digest 是归纳素材非存档)
export const DIGEST_MAX_TOTAL_CHARS = 350_000; // 单会话总兜底:超限从最旧轮丢弃(普通轮约 1.6KB,200 轮约 320KB 不触发)

export interface DigestTurn {
  startMs: number;
  endMs: number;
  prompts: string[];
  texts: string[]; // 该 turn 全部 assistant text 块(时序,已截断)——与 signals 只存末条 conclusion 的本质差异
  commits: string[]; // git commit -m 首行(subject)
  taskSubjects: string[]; // TaskCreate/TaskUpdate/TodoWrite subject
  files: string[]; // Edit/Write/MultiEdit file_path(去重)
  added: number;
  removed: number;
}

export interface DigestParsed {
  turns: DigestTurn[]; // 窗口内已闭合 turn(时序)
  openTurn: DigestTurn | null; // 文末未闭合 turn(窗口内)——/prepare 时当前会话末轮常未闭合
  skippedEmpty: number; // 无内容轮数(不进 turns)
  firstAt: number;
  lastAt: number;
  lineCount: number; // 非空行数(头部统计)
  aiTitle: string | null;
  filesChanged: string[]; // 全会话文件并集(独立于 turns,turns 丢最旧/截断时不丢;turn_duration 稀疏的大段落防单段上限丢文件)
}

/** 8 位 FNV-1a:text 回放判重键的散列成分(与 src/daemon/signals.ts hash8 同款,改动两边同步)。 */
function hash8(s: string): string {
  let h = 0x811c9dc5;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 0x01000193);
  }
  return (h >>> 0).toString(16).padStart(8, "0");
}

/** 从 Bash command 提取 git commit message 首行(与 src/daemon/signals.ts extractCommitSubject 同款,改动两边同步)。 */
function digestCommitSubject(command: string): string | null {
  if (!/git\s+commit/.test(command)) return null;
  const heredoc = /<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1/.exec(command);
  const quoted = /-m\s+"((?:[^"\\]|\\.)*)"/.exec(command) ?? /-m\s+'([^']*)'/.exec(command);
  const raw = heredoc?.[2] ?? quoted?.[1];
  if (!raw) return null;
  const first = raw
    .split("\n")
    .map((l) => l.trim())
    .find((l) => l.length > 0);
  if (!first) return null;
  return first.replace(/\\"/g, '"').slice(0, DIGEST_COMMIT_CHARS);
}

/** 有上限去重 push(与 daemon signals pushCapped 同款)。 */
function pushCapped(arr: string[], v: string, cap: number): void {
  if (arr.length < cap && !arr.includes(v)) arr.push(v);
}

function tsOf(obj: Record<string, unknown>): number {
  const v = obj.timestamp;
  const n = typeof v === "string" ? Date.parse(v) : typeof v === "number" ? v : NaN;
  return Number.isFinite(n) ? n : 0;
}

function emptyDigestTurn(ts: number): DigestTurn {
  return { startMs: ts, endMs: ts, prompts: [], texts: [], commits: [], taskSubjects: [], files: [], added: 0, removed: 0 };
}

function digestHasContent(t: DigestTurn): boolean {
  return t.prompts.length > 0 || t.texts.length > 0 || t.commits.length > 0 || t.taskSubjects.length > 0 || t.files.length > 0 || t.added + t.removed > 0;
}

/** 该段是否已有 AI 产出(texts/commits/files/行数)——合成切段的触发条件成分。
 *  新版 Claude Code 的 turn_duration 行很稀疏(2026-08-26 实测:855 行 transcript 仅 1 条),
 *  若只按 turn_duration 切,整段对话堆进一个巨型 turn → 每轮上限(prompts 5/commits 10/files 20)
 *  退化为全会话上限、素材大量丢失。故「新用户提问 && 当前段已有 AI 产出」时提前闭段(对话段落粒度,
 *  与用户对「定位到具体对话段落」的心智一致);连续多条 user 消息(中间无 AI 产出)仍并入同段。 */
function hasAiWork(t: DigestTurn): boolean {
  return t.texts.length > 0 || t.commits.length > 0 || t.taskSubjects.length > 0 || t.files.length > 0 || t.added + t.removed > 0;
}

/** 全量解析整份 transcript jsonl 成 turn 分组(daemon 是增量逐行,这里是 prepare 一次性读)。
 *  窗口 (fromMs, toMs]:已闭合 turn 按 endMs 严格 >fromMs 过滤(对齐增量水位「水位后新 turn」语义);
 *  fromMs=0/toMs=Infinity 即全量。回放判重键与 daemon 同款(tool_use 按自身 id、text 按 message.id+长度+散列)。
 *  无任何非空行返回 null;有行但窗口内无 turn 返回 turns=[](调用方凭轮数 0 自行退化)。 */
export function parseDigestTurns(raw: string, fromMs = 0, toMs = Infinity): DigestParsed | null {
  const seen = new Set<string>();
  let open: DigestTurn | null = null;
  let aiTitle: string | null = null;
  let firstAt = 0;
  let lastAt = 0;
  let lineCount = 0;
  let skippedEmpty = 0;
  const closed: DigestTurn[] = [];
  const filesChanged: string[] = []; // 全会话并集(窗口无关:窗口过滤只作用 turns,文件全景保留)
  const inWindow = (t: DigestTurn): boolean => t.endMs > fromMs && t.endMs <= toMs;
  for (const line of raw.split("\n")) {
    if (!line.trim()) continue;
    lineCount++;
    let ev: Record<string, unknown>;
    try {
      ev = JSON.parse(line);
    } catch {
      continue;
    }
    const ts = tsOf(ev);
    if (ts && !firstAt) firstAt = ts;
    if (ev.type === "ai-title") {
      if (typeof ev.aiTitle === "string" && ev.aiTitle) aiTitle = ev.aiTitle;
      continue;
    }
    if (ev.type === "system" && ev.subtype === "turn_duration") {
      // turn 边界:闭合 open(有内容且在窗口内才入列)
      if (open) {
        const t = open;
        t.endMs = ts || t.endMs;
        if (digestHasContent(t)) {
          if (inWindow(t)) closed.push(t);
        } else skippedEmpty++;
        open = null;
        lastAt = Math.max(lastAt, t.endMs);
      }
      continue;
    }
    const message = ev.message as Record<string, unknown> | undefined;
    if (!message) continue; // attachment/last-prompt/mode 等元数据行
    const role = message.role;
    const content = message.content;

    if (role === "user" && !ev.isMeta) {
      // 真实用户提问(与 daemon isRealUserPrompt 同款口径:非 XML wrapper/Caveat/中断标记/超长)
      let text = "";
      if (typeof content === "string") text = content;
      else if (Array.isArray(content)) {
        text = content
          .filter((b) => (b as Record<string, unknown>)?.type === "text")
          .map((b) => (b as Record<string, unknown>).text as string)
          .join("\n");
      }
      const t = text.trim();
      if (t.length > 1 && t.length <= 500 && !t.startsWith("<") && !/^Caveat:/i.test(t) && !t.startsWith("[Request interrupted")) {
        if (open && hasAiWork(open)) {
          // 二级切段(见 hasAiWork 注释):新提问到达而当前段已有 AI 产出 → 闭段开新段
          if (inWindow(open)) closed.push(open);
          open = null;
        }
        if (!open) open = emptyDigestTurn(ts);
        pushCapped(open.prompts, t.replace(/\s+/g, " ").slice(0, DIGEST_PROMPT_CHARS), DIGEST_MAX_PROMPTS);
        open.endMs = ts || open.endMs;
        lastAt = Math.max(lastAt, open.endMs);
      }
      continue;
    }

    if (role !== "assistant" || !Array.isArray(content)) continue;
    const msgId = typeof message.id === "string" ? message.id : "";
    let touched = false;
    for (const b of content) {
      const block = b as Record<string, unknown>;
      if (!block) continue;
      // 回放判重:同 daemon(transcript 常把同一 assistant 消息整块重写;不去重 text 会重复、行数翻倍)
      const replayKey =
        block.type === "tool_use"
          ? typeof block.id === "string" && block.id ? `u:${block.id}` : null
          : block.type === "text" && typeof block.text === "string" && (block.text as string).trim() && msgId
            ? `t:${msgId}|${(block.text as string).length}|${hash8(block.text as string)}`
            : null;
      if (replayKey) {
        if (seen.has(replayKey)) continue;
        seen.add(replayKey);
      }
      if (block.type === "text" && typeof block.text === "string" && block.text.trim()) {
        if (!open) open = emptyDigestTurn(ts);
        open.texts.push((block.text as string).trim().replace(/\s+/g, " ").slice(0, DIGEST_TEXT_CHARS)); // 全收,无条数上限(仅受总量兜底)
        touched = true;
      } else if (block.type === "tool_use") {
        const name = typeof block.name === "string" ? block.name : "unknown";
        const input = (block.input ?? {}) as Record<string, unknown>;
        const turn = open ?? (open = emptyDigestTurn(ts));
        touched = true;
        if (name === "Bash" && typeof input.command === "string") {
          const subject = digestCommitSubject(input.command);
          if (subject) pushCapped(turn.commits, subject, DIGEST_MAX_COMMITS);
        } else if (name === "Edit" || name === "Write" || name === "MultiEdit") {
          if (typeof input.file_path === "string") {
            pushCapped(turn.files, input.file_path, DIGEST_MAX_FILES);
            pushCapped(filesChanged, input.file_path, DIGEST_MAX_FILES_SESSION);
          }
          if (name === "Edit") {
            turn.added += countLines(input.new_string);
            turn.removed += countLines(input.old_string);
          } else if (name === "Write") {
            turn.added += countLines(input.content);
          } else if (Array.isArray(input.edits)) {
            for (const e of input.edits as Array<Record<string, unknown>>) {
              turn.added += countLines(e.new_string);
              turn.removed += countLines(e.old_string);
            }
          }
        } else if (name === "TaskCreate" || name === "TaskUpdate") {
          if (typeof input.subject === "string" && input.subject.trim()) {
            pushCapped(turn.taskSubjects, input.subject.trim().slice(0, DIGEST_TASK_CHARS), DIGEST_MAX_TASKS);
          }
        } else if (name === "TodoWrite" && Array.isArray(input.todos)) {
          for (const t of input.todos as Array<Record<string, unknown>>) {
            if (typeof t.subject === "string" && t.subject.trim()) {
              pushCapped(turn.taskSubjects, t.subject.trim().slice(0, DIGEST_TASK_CHARS), DIGEST_MAX_TASKS);
            }
          }
        }
      }
    }
    if (touched && open) {
      open.endMs = ts || open.endMs;
      lastAt = Math.max(lastAt, open.endMs);
    }
  }
  if (lineCount === 0) return null;
  let openTurn: DigestTurn | null = null;
  if (open) {
    if (digestHasContent(open)) {
      if (inWindow(open)) openTurn = open; // 未闭合 turn(文末进行中):窗口内才保留,渲染标注「未闭合」
    } else skippedEmpty++;
  }
  return { turns: closed, openTurn, skippedEmpty, firstAt, lastAt, lineCount, aiTitle, filesChanged };
}

function relFile(cwd: string, f: string): string {
  const rel = path.relative(cwd, f); // 尽量转相对 cwd,便于阅读(与 extractTranscriptSignals 同款)
  return rel && !rel.startsWith("..") ? rel : f;
}

function fmtDigestTurn(t: DigestTurn, idx: number, unclosed: boolean, cwd: string): string {
  const dur = Math.max(0, Math.round((t.endMs - t.startMs) / 60000));
  const lines: string[] = [`## Turn ${idx} · ${localHHMM(t.startMs)}—${localHHMM(t.endMs)}(${dur}min${unclosed ? " · 未闭合" : ""})`];
  if (t.prompts.length) {
    lines.push("**用户**");
    t.prompts.forEach((p, i) => lines.push(`${i + 1}. ${p}`));
  }
  if (t.texts.length) {
    lines.push("**AI**");
    for (const x of t.texts) lines.push(`- ${x}`);
  }
  if (t.commits.length) lines.push(`**commits**: ${t.commits.join(" · ")}`);
  if (t.taskSubjects.length) lines.push(`**tasks**: ${t.taskSubjects.join(" · ")}`);
  if (t.files.length) {
    const delta = t.added + t.removed > 0 ? `(+${t.added}/-${t.removed})` : "";
    lines.push(`**files**: ${t.files.map((f) => relFile(cwd, f)).join(", ")}${delta}`);
  } else if (t.added + t.removed > 0) {
    lines.push(`**lines**: +${t.added}/-${t.removed}`);
  }
  return lines.join("\n");
}

/** 渲染 digest markdown(纯函数)。截断两层:MAX_TURNS 保最新轮数 + MAX_TOTAL_CHARS 总量(从最旧轮丢),
 *  新鲜度优先——最新工作最重要,兜底至少保留最新 1 轮。返回 shownTurns/truncated 供头部统计。 */
export function renderDigestMarkdown(
  p: DigestParsed,
  meta: { sessionId: string; fromMs: number; toMs: number; cwd: string },
): { text: string; shownTurns: number; truncated: boolean } {
  const all = [...p.turns, ...(p.openTurn ? [p.openTurn] : [])];
  const blocks = all.slice(-DIGEST_MAX_TURNS).map((t, i) => fmtDigestTurn(t, i + 1, t === p.openTurn, meta.cwd));
  let budget = DIGEST_MAX_TOTAL_CHARS;
  const kept: string[] = [];
  for (let i = blocks.length - 1; i >= 0; i--) {
    const b = blocks[i]!;
    if (kept.length > 0 && b.length > budget) break; // 至少保最新 1 轮(kept 空时不判预算)
    budget -= b.length;
    kept.unshift(b);
  }
  const dropped = all.length - kept.length;
  const win =
    (meta.fromMs > 0 ? `自 ${localDateISO(meta.fromMs)} ${localHHMM(meta.fromMs)}` : "全量") +
    (Number.isFinite(meta.toMs) ? ` 至 ${localDateISO(meta.toMs)} ${localHHMM(meta.toMs)}` : "");
  const head = [
    `# Session Digest ${meta.sessionId}`,
    `- 标题: ${p.aiTitle ?? "无"} · 窗口: ${win}`,
    `- 轮数: ${all.length}(另跳过 ${p.skippedEmpty} 个无内容轮) · transcript 行数: ${p.lineCount} · 截断: ${dropped > 0 ? `是(${dropped} 轮未展示)` : "否"}`,
    ...(p.filesChanged.length
      ? [`- 全会话改动文件(${p.filesChanged.length}): ${p.filesChanged.map((f) => relFile(meta.cwd, f)).join(", ")}`]
      : []),
  ];
  const text = [...head, "", ...(dropped > 0 ? [`…(更早 ${dropped} 轮略)`, ""] : []), ...kept].join("\n") + "\n";
  return { text, shownTurns: kept.length, truncated: dropped > 0 };
}

/** 读会话 transcript 生成分组 digest markdown,落盘 PROJECT_DIR/digest/<sid>.md(覆盖式,可删重建)。
 *  窗口 (fromMs,toMs]:auto-note 水位定位的增量 / 全量。jsonl 不存在/无有效行返回 null
 *  (AI 退化 daemon signals 素材)。bytes/truncated 供 prepare 输出给 AI 决定 Read 分段策略。 */
export function buildSessionDigest(sessionId: string, cwd: string, fromMs = 0, toMs = Infinity): {
  path: string;
  bytes: number;
  turns: number;
  fromMs: number;
  toMs: number;
  truncated: boolean;
} | null {
  const filePath = path.join(homedir(), ".claude", "projects", encodeProject(cwd), sessionId + ".jsonl");
  let raw: string;
  try {
    raw = readFileSync(filePath, "utf8");
  } catch {
    return null;
  }
  const parsed = parseDigestTurns(raw, fromMs, toMs);
  if (!parsed) return null;
  const r = renderDigestMarkdown(parsed, { sessionId, fromMs, toMs, cwd });
  const out = path.join(PROJECT_DIR, "digest", sessionId + ".md");
  writeText(out, r.text);
  return { path: out, bytes: Buffer.byteLength(r.text, "utf8"), turns: r.shownTurns, fromMs, toMs, truncated: r.truncated };
}

async function readStdinTimed(ms = 2000): Promise<string> {
  return new Promise((resolve) => {
    let done = false;
    const finish = (v: string) => {
      if (!done) {
        done = true;
        resolve(v);
      }
    };
    const timer = setTimeout(() => finish(""), ms);
    Bun.stdin
      .text()
      .then((t) => {
        clearTimeout(timer);
        finish(t);
      })
      .catch(() => finish(""));
  });
}

export async function cmdCollect(): Promise<any> {
  // 合并后:collect 改读 shine-worklog daemon 的 /api/sessions(不再挖 transcript)。
  // hook 模式(Stop hook 触发,stdin 携带 payload)与 full 模式(/report 兜底手动跑)统一:
  //   读 daemon token → GET /api/sessions?cwd=本项目&since=自上次提交日(多天补报,≤LOOKBACK_MAX_DAYS)
  //   → 映射成 shine-worklog session(带 date 归属日)→ 写 sessions.json。
  // daemon 不可达时:hook 静默跳过(不写、不崩、不影响 hook);full 给错误提示。
  const isHook = process.stdin.isTTY !== true;
  let hookSessionId: string | null = null;
  if (isHook) {
    // hook 模式:读 stdin 仅取 session_id(日志/诊断用),不依赖其内容挖掘
    try {
      const text = await readStdinTimed();
      if (text.trim()) hookSessionId = JSON.parse(text)?.session_id ?? null;
    } catch {
      /* ignore */
    }
  }

  // 1. 读 daemon token
  const token = readDaemonToken();
  if (!token) {
    if (isHook) return { mode: "hook", skipped: "daemon token unavailable", hookSessionId };
    return { mode: "full", error: "daemon 未运行或 token 读不到(shine-worklog daemon 未启动)" };
  }

  // 2. 查 daemon 自上次提交日本项目 sessions(多天补报:漏报会话/水位后增量都拉进来;上限 LOOKBACK_MAX_DAYS)
  const sinceMs = lastSubmitSinceEpoch();
  let resp: { sessions: any[]; total: number };
  try {
    resp = await fetchDaemonSessions(PROJECT_CWD, sinceMs, token);
  } catch (e) {
    const msg = e instanceof Error ? e.message : String(e);
    if (isHook) return { mode: "hook", skipped: "daemon fetch failed", error: msg, hookSessionId };
    return { mode: "full", error: `daemon 查询失败: ${msg}` };
  }

  // 3. branch 单值探测(daemon 不提供 branch,每 collect 调一次,所有 session 共用当前分支)
  const branch = gitBranchFallback(PROJECT_CWD);

  // 4. 映射 + 写盘(hook 仅在有数据时写,防 daemon 消费者 lag 误删;full 照实写含空)。
  //    date=采集日(cmdNote 的 summary 文件名依赖,保持「今天」语义);sinceDate=本次范围起点(诊断用)。
  const sessions = resp.sessions.map((s: any) => toZenSession(s, branch));
  if (sessions.length > 0 || !isHook) {
    writeJSON(SESSIONS_PATH, { date: todayISO(), sinceDate: localDateISO(sinceMs), sessions });
  }

  return {
    mode: isHook ? "hook" : "full",
    date: todayISO(),
    sinceDate: localDateISO(sinceMs),
    count: sessions.length,
    hookSessionId,
    sessions: sessions.map((s: any) => ({ id: s.id, repo: s.repo, branch: s.branch, activeMinutes: s.activeMinutes })),
  };
}
