Ctrl K
ComunidadGuías…

scripts/db_shrink_archive.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python
"""一次性瘦身 sessions.db —— 清理重复归档 + 重建 FTS + VACUUM.

背景(2026-10-03):
  * ``_context_govern`` 每个工具步都会把「已移出 LLM 视图」的 TOOL 消息重新
    归档一遍,而 ``async_archive_messages`` 此前不做去重 → 实测
    messages_archive 60,578 行里只有 3,977 行唯一(context_prune 54,879 行
    去重后仅 1,295 行,42× 重复)。
  * FTS5 只建了 AFTER INSERT 触发器,没有 DELETE → messages_fts_data 涨到
    29MB(活跃消息正文仅 4.5MB),docsize 190,411 行里 186,419 行是孤儿。
  两者叠加让 sessions.db 从「应该的 ~18MB」涨到 119.9MB。

  代码侧的修复已在本次提交完成(写入端去重 + 补 FTS DELETE 触发器 + agent 层
  只归档首次移出的消息);本脚本用于**清理已经产生的存量数据**。

用法::

    # 只看会做什么,不改任何东西(默认,推荐先跑一次)
    python scripts/db_shrink_archive.py

    # 实际执行(会自动先备份到 <db>.bak-<时间戳>)
    python scripts/db_shrink_archive.py --apply

    # 指定数据库(默认自动探测 ~/.scout/sessions.db)
    python scripts/db_shrink_archive.py --db D:/.scout/sessions.db --apply

安全:
  * ``--apply`` 前强制做整文件备份(shutil.copy2),失败即中止。
  * 全程在单个事务里做 DELETE,中断则整体回滚。
  * VACUUM 需要额外磁盘空间(约等于库大小)。
"""

from __future__ import annotations

import argparse
import os
import shutil
import sqlite3
import sys
import time
from pathlib import Path


def _human(n: float) -> str:
    for unit in ("B", "KB", "MB", "GB"):
        if n < 1024 or unit == "GB":
            return f"{n:.1f}{unit}"
        n /= 1024
    return f"{n:.1f}GB"


def default_db_path() -> Path | None:
    """探测运行时数据目录里的 sessions.db(与 launcher 的 data_dir() 一致)."""
    try:
        sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
        from scout.core.paths import data_dir  # type: ignore

        p = Path(data_dir()) / "sessions.db"
        if p.exists():
            return p
    except Exception:
        pass
    for cand in (
        Path.home() / ".scout" / "sessions.db",
        Path("D:/.scout/sessions.db"),
        Path("data/scout.db"),
    ):
        if cand.exists():
            return cand
    return None


def has_column(conn: sqlite3.Connection, table: str, col: str) -> bool:
    return col in {r[1] for r in conn.execute(f"PRAGMA table_info({table})")}


def dedup_group_expr(conn: sqlite3.Connection) -> str:
    """去重分组表达式 —— 新库用 content_hash,存量库退回 content 全文."""
    if has_column(conn, "messages_archive", "content_hash"):
        return "session_id, role, COALESCE(content_hash, content)"
    return "session_id, role, content"


def analyze(conn: sqlite3.Connection) -> dict:
    cur = conn.execute("PRAGMA page_size")
    page_size = cur.fetchone()[0]
    cur = conn.execute("PRAGMA page_count")
    page_count = cur.fetchone()[0]

    grp = dedup_group_expr(conn)
    total = conn.execute("SELECT COUNT(*) FROM messages_archive").fetchone()[0]
    uniq = conn.execute(
        "SELECT COUNT(*) FROM ("
        f"  SELECT MIN(id) FROM messages_archive GROUP BY {grp}"
        ")"
    ).fetchone()[0]
    msgs = conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
    try:
        docsize = conn.execute("SELECT COUNT(*) FROM messages_fts_docsize").fetchone()[0]
    except Exception:
        docsize = -1
    return {
        "size": page_size * page_count,
        "archive_total": total,
        "archive_uniq": uniq,
        "archive_dup": total - uniq,
        "messages": msgs,
        "fts_docsize": docsize,
    }


def main() -> int:
    ap = argparse.ArgumentParser(description="清理 sessions.db 的重复归档并重建 FTS")
    ap.add_argument("--db", default=None, help="sessions.db 路径(默认自动探测)")
    ap.add_argument("--apply", action="store_true", help="实际执行;不带则只做 dry-run")
    ap.add_argument("--no-backup", action="store_true", help="跳过备份(不推荐)")
    ap.add_argument("--keep-fts", action="store_true", help="跳过 FTS 重建")
    args = ap.parse_args()

    db = Path(args.db) if args.db else default_db_path()
    if db is None or not db.exists():
        print("找不到 sessions.db,请用 --db 指定路径")
        return 1

    print(f"数据库: {db}")
    conn = sqlite3.connect(str(db))
    before = analyze(conn)
    print("\n── 当前状态 ──────────────────────────────")
    print(f"  文件体积        : {_human(before['size'])}")
    print(f"  归档总行数      : {before['archive_total']}")
    print(f"  去重后唯一行    : {before['archive_uniq']}")
    print(f"  重复行数        : {before['archive_dup']} "
          f"(占 {before['archive_dup'] / max(before['archive_total'], 1) * 100:.1f}%)")
    print(f"  活跃消息行      : {before['messages']}")
    print(f"  FTS docsize 行  : {before['fts_docsize']}")

    if not args.apply:
        dup = before["archive_dup"]
        print(f"\n[dry-run] 将删除约 {dup} 行重复归档、重建 FTS 并 VACUUM。")
        print("[dry-run] 实际执行请加 --apply(会自动备份)。")
        conn.close()
        return 0

    # ── 备份 ──────────────────────────────────────────────
    if not args.no_backup:
        bak = db.with_suffix(db.suffix + f".bak-{time.strftime('%Y%m%d-%H%M%S')}")
        print(f"\n备份到: {bak}")
        conn.close()
        try:
            shutil.copy2(db, bak)
        except Exception as e:
            print(f"备份失败,已中止: {e}")
            return 1
        conn = sqlite3.connect(str(db))
    else:
        print("\n[warn] 已跳过备份")

    # ── 清理 ──────────────────────────────────────────────
    print("\n删除重复归档 ...")
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("BEGIN")
    try:
        conn.execute(
            "DELETE FROM messages_archive WHERE id NOT IN ("
            f"  SELECT MIN(id) FROM messages_archive GROUP BY {dedup_group_expr(conn)}"
            ")"
        )
        deleted = conn.execute("SELECT changes()").fetchone()[0]
        conn.commit()
    except Exception as e:
        conn.rollback()
        print(f"删除失败,已回滚: {e}")
        conn.close()
        return 1
    print(f"  已删除 {deleted} 行")

    if not args.keep_fts:
        print("重建 FTS 索引 ...")
        try:
            conn.execute("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')")
            conn.commit()
        except Exception as e:
            print(f"  FTS 重建失败(可忽略): {e}")

    print("VACUUM ...")
    conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
    try:
        conn.execute("VACUUM")
    except Exception as e:
        print(f"  VACUUM 失败(可忽略): {e}")

    after = analyze(conn)
    conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
    conn.close()

    real_after = os.path.getsize(db)
    print("\n── 完成 ──────────────────────────────────")
    print(f"  归档行            : {before['archive_total']} → {after['archive_total']}")
    print(f"  FTS docsize 行    : {before['fts_docsize']} → {after['fts_docsize']}")
    print(f"  逻辑体积          : {_human(before['size'])} → {_human(after['size'])}")
    print(f"  实际文件          : {_human(os.path.getsize(db)) if False else _human(real_after)} "
          f"(省下 {_human(max(before['size'] - real_after, 0))})")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())