ANTIGRAVITY LABEN
記事一覧/Antigravity 基本
Antigravity 基本/2026-08-17上級

Antigravity CLI の会話データベースは、重複を消しても 1 バイトも縮みません

会話データベースの肥大化を、スキーマを知らないまま棚卸しする手順です。削除しただけでは縮まない理由と、freelist_count を回収可能量と読み違えたときに起きることを実測で切り分けます。

Antigravity CLI25SQLiteサブエージェント2運用設計26ディスク管理2

プレミアム記事

CLI 1.1.13 の変更点に、バックグラウンドタスクとサブエージェントの起動のたびに同じ grant と settings が追記され、会話データベースが際限なく膨らんでいたという修正が入っていました。

心当たりがありました。個人開発で壁紙アプリを何本か運用しているのですが、素材の派生画像を8セット生成し、重複と制作国を検査し、30 カテゴリへ分類するところまでを、工程ごとにバックグラウンドタスクとサブエージェントへ分けて回しています。1日の起動回数がそれなりの数になる型の運用です。「起動のたびに積まれる」という不具合は、こういう回し方をしている側から先に効きます。

ただ、バージョンを上げれば直るのは「これから積まれる分」だけです。すでに積まれたものは残ります。そこで、上げる前に自分の環境で何がどれだけ積まれているのかを数えることにしました。

その過程で、削除と回収について自分が持っていた見積もりが二重に外れていることが分かりました。以下は、その二つの外れ方と、最終的に運用へ入れた手順です。

スキーマを知らないまま数える

最初に諦めたことがあります。会話データベースのテーブル構成をバージョンごとに追いかけるのはやめました。CLI は 1.1.12 から 1.1.13 へ数日で動いていますし、テーブル名や列名は内部実装であって、こちらが依存してよい対象ではありません。

代わりに、スキーマを一切知らない前提で数えられる方法だけを使います。SQLite には sqlite_masterdbstat があるので、テーブル名を知らなくてもページ単位の使用量が取れます。

読み取り専用で開く点も重要です。file:...?mode=ro の URI で開けば、CLI を起動したまま監査できます。書き込み側がコミット直前の排他区間に入っている一瞬を除けば読めますし、WAL モードなら書き込みトランザクションを保持している最中でも読めます。手元で両方のジャーナルモードで確認しました。

週次の棚卸しに回しているのは、次のスクリプトです。

#!/usr/bin/env python3
"""会話データベースの容量を、テーブル単位と重複追記の観点で棚卸しする。
使い方: python3 agdb_audit.py /path/to/conversation.db
読み取り専用で開くため、CLI を起動したままでも実行できる。"""
import sys, sqlite3, os
 
def open_ro(path):
    # mode=ro で開く。書き込み側を止める必要がない。
    return sqlite3.connect(f"file:{path}?mode=ro", uri=True)
 
def table_bytes(cur):
    """dbstat があればテーブル別の実ページ使用量を返す。無ければ None。"""
    try:
        rows = cur.execute(
            "SELECT name, SUM(pgsize) FROM dbstat GROUP BY name ORDER BY 2 DESC"
        ).fetchall()
    except sqlite3.OperationalError:
        return None
    return [(n, b) for n, b in rows if b]
 
def table_bytes_fallback(cur):
    """dbstat 無しの環境向け。行データの実バイト長を列ごとに合算した概算。"""
    out = []
    names = [r[0] for r in cur.execute(
        "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")]
    for n in names:
        cnt = cur.execute(f'SELECT COUNT(*) FROM "{n}"').fetchone()[0]
        if cnt == 0:
            out.append((n, 0, 0))
            continue
        cols = [r[1] for r in cur.execute(f'PRAGMA table_info("{n}")')]
        expr = " + ".join(f'COALESCE(LENGTH(CAST("{c}" AS BLOB)),0)' for c in cols)
        total = cur.execute(f'SELECT SUM({expr}) FROM "{n}"').fetchone()[0] or 0
        out.append((n, total, cnt))
    return sorted(out, key=lambda r: -r[1])
 
def duplicate_report(cur, min_repeat=3, top=8):
    """主キー列を除く全列が一致する行を数える。
    起動のたびに同じ内容が積まれる型の膨張は、ここに露出する。"""
    result = []
    names = [r[0] for r in cur.execute(
        "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")]
    for n in names:
        info = list(cur.execute(f'PRAGMA table_info("{n}")'))
        cols = [r[1] for r in info if not r[5]]        # r[5] が 1 なら主キー列
        if not cols:
            continue
        key = ", ".join(f'"{c}"' for c in cols)
        total = cur.execute(f'SELECT COUNT(*) FROM "{n}"').fetchone()[0]
        if total == 0:
            continue
        distinct = cur.execute(
            f'SELECT COUNT(*) FROM (SELECT DISTINCT {key} FROM "{n}")').fetchone()[0]
        dup = total - distinct
        if dup <= 0:
            continue
        worst = cur.execute(
            f'SELECT COUNT(*) AS c, {key} FROM "{n}" GROUP BY {key} '
            f'HAVING c >= ? ORDER BY c DESC LIMIT ?', (min_repeat, top)).fetchall()
        result.append((n, total, distinct, dup, worst))
    return sorted(result, key=lambda r: -r[3])
 
def main():
    if len(sys.argv) < 2:
        print(__doc__)
        sys.exit(2)
    path = sys.argv[1]
    if not os.path.exists(path):
        print(f"ファイルがありません: {path}")
        sys.exit(1)
    con = open_ro(path)
    cur = con.cursor()
    psize = cur.execute("PRAGMA page_size").fetchone()[0]
    pages = cur.execute("PRAGMA page_count").fetchone()[0]
    free = cur.execute("PRAGMA freelist_count").fetchone()[0]
    size = os.path.getsize(path)
    print(f"[file] {size:,} bytes / page_size={psize} / page_count={pages:,}")
    print(f"[file] freelist={free:,} ページ = {free * psize:,} bytes")
 
    print("\n[容量の内訳]")
    tb = table_bytes(cur)
    if tb:
        for n, b in tb[:12]:
            print(f"  {b:>12,} bytes  {b * 100.0 / size:5.1f}%  {n}")
    else:
        print("  dbstat が無いため概算(行データの合計長)で表示します")
        for n, b, cnt in table_bytes_fallback(cur)[:12]:
            print(f"  {b:>12,} bytes  {cnt:>9,} rows  {n}")
 
    print("\n[重複追記の検出]")
    rep = duplicate_report(cur)
    if not rep:
        print("  完全一致の重複行はありません")
    for n, total, distinct, dup, worst in rep[:6]:
        print(f"  {n}: {total:,} 行中 {dup:,} 行が重複"
              f"(実質 {distinct:,} 種類 / 重複率 {dup * 100.0 / total:.1f}%)")
        for row in worst[:3]:
            vals = " | ".join(str(v)[:40] for v in row[1:])
            print(f"      x{row[0]:<5} {vals}")
    con.close()
 
if __name__ == "__main__":
    main()

dbstat が使える環境と使えない環境

dbstat は仮想テーブルで、SQLITE_ENABLE_DBSTAT_VTAB を有効にしてビルドされた SQLite でのみ使えます。手元の Python 3 に同梱されている SQLite 3.37.2 では使えました。使えない環境では黙って落ちるのではなく、行データの実バイト長を列ごとに合算する概算へ切り替わるようにしてあります。

概算はページのオーバーヘッドとインデックスを含まないので、実ファイルサイズより小さく出ます。それでも「どのテーブルが支配的か」の順序は変わらないので、判断材料としては足ります。

実行結果の読み方

起動ごとの重複追記を再現したデータベース(会話 400 本ぶんに相当)に対して実行すると、こう出ます。

[file] 4,046,848 bytes / page_size=4096 / page_count=988
[file] freelist=0 ページ = 0 bytes

[容量の内訳]
     2,469,888 bytes   61.0%  messages
       925,696 bytes   22.9%  settings
       618,496 bytes   15.3%  grants
        16,384 bytes    0.4%  conversations

[重複追記の検出]
  settings: 24,000 行中 12,000 行が重複(実質 12,000 種類 / 重複率 50.0%)
  grants: 9,600 行中 8,800 行が重複(実質 800 種類 / 重複率 91.7%)
      x12    conv-0000 | tool.run_command | {"allow": ["git status", "npm test"]}
      x12    conv-0000 | tool.write_file | {"allow": ["src/**"]}

重複率 91.7% という数字が目に入ります。ここから手を付けたくなりますし、私も最初はそうしました。

なお、この記事に載せている数値は、公開されている修正内容と同じ追記パターンを手元で再現したデータベースを測ったものです。製品側のデータベースそのものを計測した値ではありません。挙動の仕組みを確かめるための再現であって、実機の実測値として読まないでください。

削除しただけでは、ファイルは 1 バイトも縮みません

重複行を消して、ファイルサイズを見ました。変わっていませんでした。

3 通りの削除を、それぞれ同じ元データベースの複製に対して試しています。

削除の内容削除前削除直後freelist の増加
grants の重複のみ削除4,046,848 bytes4,046,848 bytes135 ページ(552,960 bytes)
settings の重複のみ削除4,046,848 bytes4,046,848 bytes1 ページ(4,096 bytes)
古い会話を半数削除(本文ごと)4,046,848 bytes4,046,848 bytes305 ページ(1,249,280 bytes)

SQLite の DELETE は、空いたページをフリーリストへ返すだけで、ファイル自体は切り詰めません。次に行が増えたときに再利用するためです。単一プロセスのアプリケーションとしては合理的な設計ですが、「ディスクが減っているので消した」という目的からすると、削除は何も達成していないことになります。

ここが一つ目の外れでした。掃除した気になって、ディスクの空きはまったく増えていません。

ここまでお読みいただきありがとうございます。

この記事の続きを読む

この先には、実装コードやベンチマーク結果など、実務でお役に立てる内容をご用意しています。このサイトは広告を掲載しておらず、サーバーや開発にかかる費用はメンバーの皆様のご支援で成り立っています。もしお役に立てていましたら、ご支援いただけますと大変ありがたいです。

この記事で得られること
CLI を止めないまま、会話データベースのどのテーブルが容量を占めているかを自分で割り出せるようになります
freelist が 1 ページしか増えていない状態でも 11.3% を回収できた実測から、回収可能量の見積もりを誤らなくなります
重複率の高いテーブルから手を付けて回収量が伸びない、という遠回りを事前に避けられるようになります
Stripe による安全な決済 · いつでもキャンセル可能

この記事を購入する

この先の内容をすべてお読みいただけます。一度のご購入で、いつでも何度でもアクセスできます。このサイトは広告を掲載しておらず、皆さまのご支援がサーバー費用などの運営を支えています。

または
メンバーシップなら全記事が読み放題 →
シェア

お読みいただきありがとうございます

Antigravity Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

関連記事

Antigravity 基本2026-07-02
並列エージェントは生成物も並列に増える — 中間生成物の寿命と掃除の設計
ディスク残量警告の犯人は、並列エージェントが残した worktree などの中間生成物でした。何がどこに残るのかの棚卸し、消してよいかを疑い深く判定する安全ガード、週次 dry-run と月次実行で回す掃除タスクの設計を、実測とともにまとめます。
Antigravity 基本2026-06-12
Gemini CLI 終了まで残り6日 — 自動化スクリプトの依存を棚卸しして Antigravity CLI へ移行する
6月18日の Gemini CLI 提供終了を前に、cron・CI・シェルスクリプトに潜む gemini コマンド依存を洗い出し、Antigravity CLI へ移行して検証するまでの実務手順を整理しました。
Antigravity 基本2026-08-16
Antigravity CLI 1.1.13 の GEMINI_API_KEY 直接認証をサインイン不可の環境で使う
ブラウザのない環境に agy を入れたらサインインで止まりました。8月14日の CLI 1.1.13 で入った GEMINI_API_KEY 直接認証の設定手順と、確認方法、そしてアプリで使っているキーを流用してはいけない理由をまとめました。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →