|
| 1 | +# scripts/make_demo_gif.py |
| 2 | +# Generates docs/assets/demo.gif — the README demo animation. |
| 3 | +# |
| 4 | +# The outputs shown in the GIF are REAL: this script runs the actual Chronos |
| 5 | +# engine (remember / update / recall / query_at / consolidate) against a |
| 6 | +# temporary database, backdates a few rows so the time-travel beat has |
| 7 | +# history to reconstruct, and renders whatever the engine actually returned. |
| 8 | +# Regenerate after any API change so the demo never lies: |
| 9 | +# |
| 10 | +# pip install pillow (dev-only; not a runtime dependency) |
| 11 | +# python scripts/make_demo_gif.py |
| 12 | +# |
| 13 | +# Output: docs/assets/demo.gif (~800x500, dark GitHub theme) |
| 14 | + |
| 15 | +import os |
| 16 | +import sys |
| 17 | +import tempfile |
| 18 | +from datetime import datetime, timedelta |
| 19 | + |
| 20 | +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) |
| 21 | +os.environ["CHRONOS_DB_PATH"] = os.path.join( |
| 22 | + tempfile.mkdtemp(prefix="chronos_demo_"), "demo.db" |
| 23 | +) |
| 24 | + |
| 25 | +from PIL import Image, ImageDraw, ImageFont # noqa: E402 |
| 26 | + |
| 27 | +from chronos.beliefs import BeliefEngine # noqa: E402 |
| 28 | +from chronos.consolidation import ConsolidationEngine # noqa: E402 |
| 29 | +from chronos.db import get_db, init_db # noqa: E402 |
| 30 | +from chronos.memory import MemoryStore # noqa: E402 |
| 31 | + |
| 32 | +# --- theme ------------------------------------------------------------------ |
| 33 | + |
| 34 | +W, H, PAD, LINE_H = 800, 640, 22, 24 |
| 35 | +BG = "#0d1117" |
| 36 | +FG = "#e6edf3" |
| 37 | +DIM = "#8b949e" |
| 38 | +GREEN = "#7ee787" |
| 39 | +CYAN = "#79c0ff" |
| 40 | +ORANGE = "#ffa657" |
| 41 | +COMMENT = "#6e7681" |
| 42 | + |
| 43 | +FONT_PATH = r"C:\Windows\Fonts\consola.ttf" |
| 44 | +FONT_BOLD = r"C:\Windows\Fonts\consolab.ttf" |
| 45 | + |
| 46 | + |
| 47 | +def _font(path, size=15): |
| 48 | + try: |
| 49 | + return ImageFont.truetype(path, size) |
| 50 | + except OSError: |
| 51 | + return ImageFont.load_default() |
| 52 | + |
| 53 | + |
| 54 | +FONT = _font(FONT_PATH) |
| 55 | +BOLD = _font(FONT_BOLD) |
| 56 | + |
| 57 | + |
| 58 | +def _backdate(memory_id: str, days: int) -> str: |
| 59 | + """Shift a memory's created_at/updated_at into the past. Returns the ISO ts.""" |
| 60 | + past = (datetime.now() - timedelta(days=days)).isoformat() |
| 61 | + with get_db() as db: |
| 62 | + db.execute( |
| 63 | + "UPDATE memories SET created_at = ?, updated_at = ? WHERE id = ?", |
| 64 | + (past, past, memory_id), |
| 65 | + ) |
| 66 | + db.commit() |
| 67 | + return past |
| 68 | + |
| 69 | + |
| 70 | +# --- run the real engine ---------------------------------------------------- |
| 71 | + |
| 72 | +def run_scenario() -> list: |
| 73 | + """Execute real Chronos calls; return display steps [(cmd, [out lines])].""" |
| 74 | + init_db() |
| 75 | + store, beliefs = MemoryStore(), BeliefEngine() |
| 76 | + consolidation = ConsolidationEngine(beliefs) |
| 77 | + |
| 78 | + # Beat 1: remember, ~115 days ago |
| 79 | + m = store.remember("API rate limit is 100 requests/min", project="api") |
| 80 | + _backdate(m["id"], days=115) |
| 81 | + short_id = m["id"][:8] |
| 82 | + |
| 83 | + # Off-screen corpus (stored before recall so BM25 IDF is meaningful — |
| 84 | + # a single-document corpus scores every term ~0) + consolidation fodder. |
| 85 | + store.remember("Deploy pipeline runs on GitHub Actions runners") |
| 86 | + dup = store.remember("Deploy pipeline runs on GitHub Actions runners") |
| 87 | + doomed = store.remember("Old note nobody trusts anymore") |
| 88 | + beliefs.set_confidence(doomed["id"], 0.05, "unverified") |
| 89 | + _backdate(doomed["id"], days=90) |
| 90 | + _backdate(dup["id"], days=1) |
| 91 | + |
| 92 | + # Beat 2: the fact changed — update (old content snapshotted) |
| 93 | + store.update(m["id"], "API rate limit is 500 requests/min") |
| 94 | + |
| 95 | + # Beat 3: recall sees the present |
| 96 | + now_hit = store.recall("rate limit", recency_weight=0.0)["results"][0] |
| 97 | + |
| 98 | + # Beat 4: time-travel sees the past |
| 99 | + as_of = (datetime.now() - timedelta(days=100)).isoformat(timespec="seconds") |
| 100 | + past_hit = store.query_at("rate limit", timestamp=as_of)["results"][0] |
| 101 | + |
| 102 | + # Beat 5: consolidation over everything stored above |
| 103 | + report = consolidation.consolidate(auto_merge=True) |
| 104 | + |
| 105 | + orient = report["orient"] |
| 106 | + gathered = report["gather"]["duplicates_found"] |
| 107 | + merged = report["consolidate"]["duplicates_merged"] |
| 108 | + decayed = report["consolidate"]["memories_decayed"] |
| 109 | + prune = report["prune"] |
| 110 | + |
| 111 | + return [ |
| 112 | + ('remember("API rate limit is 100 requests/min", project="api")', |
| 113 | + [(f" [ok] stored id={short_id}... ({m['token_estimate']} tokens)", GREEN)]), |
| 114 | + ("# ... months pass, the limit changes ...", None), |
| 115 | + (f'update_memory("{short_id}...", "API rate limit is 500 requests/min")', |
| 116 | + [(" [ok] updated -- previous version snapshotted automatically", GREEN)]), |
| 117 | + ('recall("rate limit")', |
| 118 | + [(f' 1. "{now_hit["content"]}"', FG), |
| 119 | + (f' score {now_hit["score"]:.2f} confidence ' |
| 120 | + f'{now_hit.get("confidence", 0.5):.2f} source {now_hit["source"]}', DIM)]), |
| 121 | + (f'query_at("rate limit", "{as_of}")', |
| 122 | + [(f' 1. "{past_hit["content"]}"', ORANGE), |
| 123 | + (f" reconstructed as of {as_of[:10]} -- time-travel", DIM)]), |
| 124 | + ("# ... a few more memories accumulate over the weeks ...", None), |
| 125 | + ("consolidate_memories(auto_merge=True)", |
| 126 | + [(f" orient {orient['total_active']} active · " |
| 127 | + f"avg confidence {orient['avg_confidence']:.2f} · " |
| 128 | + f"{orient['stale_count']} stale", CYAN), |
| 129 | + (f" gather {gathered} duplicate pair -> merged {merged}, " |
| 130 | + "survivor confidence boosted", CYAN), |
| 131 | + (f" decay {decayed} unreviewed memories lost confidence", CYAN), |
| 132 | + (f" prune {prune['prune_candidates']} candidate " |
| 133 | + f"(confidence {prune['prune_details'][0]['confidence']:.2f}, " |
| 134 | + f"retention {prune['prune_details'][0]['retention']:.2f}) -- dry run", CYAN)]), |
| 135 | + ] |
| 136 | + |
| 137 | + |
| 138 | +# --- render ----------------------------------------------------------------- |
| 139 | + |
| 140 | +def draw_frame(lines, caret=False): |
| 141 | + img = Image.new("RGB", (W, H), BG) |
| 142 | + d = ImageDraw.Draw(img) |
| 143 | + # window chrome |
| 144 | + d.rounded_rectangle([6, 6, W - 6, H - 6], radius=10, outline="#30363d", width=1) |
| 145 | + for i, c in enumerate(("#ff5f57", "#febc2e", "#28c840")): |
| 146 | + d.ellipse([PAD + i * 22, 16, PAD + 12 + i * 22, 28], fill=c) |
| 147 | + d.text((W // 2 - 90, 14), "chronos -- temporal memory", font=FONT, fill=COMMENT) |
| 148 | + |
| 149 | + y = 52 |
| 150 | + for text, color in lines: |
| 151 | + d.text((PAD, y), text, font=FONT, fill=color) |
| 152 | + y += LINE_H |
| 153 | + if caret and lines: |
| 154 | + last_text = lines[-1][0] |
| 155 | + x = PAD + d.textlength(last_text, font=FONT) |
| 156 | + d.rectangle([x + 3, y - LINE_H + 3, x + 12, y - 5], fill=FG) |
| 157 | + return img |
| 158 | + |
| 159 | + |
| 160 | +def build_frames(steps): |
| 161 | + frames, durations, shown = [], [], [] |
| 162 | + |
| 163 | + def emit(img, ms): |
| 164 | + frames.append(img) |
| 165 | + durations.append(ms) |
| 166 | + |
| 167 | + for cmd, out in steps: |
| 168 | + if out is None: # comment beat |
| 169 | + shown.append((cmd, COMMENT)) |
| 170 | + emit(draw_frame(shown), 1100) |
| 171 | + shown.append(("", FG)) |
| 172 | + continue |
| 173 | + # type the command in three increments |
| 174 | + for frac in (0.45, 1.0): |
| 175 | + partial = cmd[: max(1, int(len(cmd) * frac))] |
| 176 | + emit(draw_frame(shown + [("> " + partial, FG)], caret=True), 260) |
| 177 | + shown.append(("> " + cmd, FG)) |
| 178 | + emit(draw_frame(shown, caret=True), 350) |
| 179 | + for line in out: |
| 180 | + shown.append(line) |
| 181 | + emit(draw_frame(shown), 1700) |
| 182 | + shown.append(("", FG)) |
| 183 | + |
| 184 | + emit(draw_frame(shown), 4500) # hold the ending |
| 185 | + return frames, durations |
| 186 | + |
| 187 | + |
| 188 | +def main(): |
| 189 | + steps = run_scenario() |
| 190 | + frames, durations = build_frames(steps) |
| 191 | + out_dir = os.path.join(os.path.dirname(__file__), "..", "docs", "assets") |
| 192 | + os.makedirs(out_dir, exist_ok=True) |
| 193 | + out_path = os.path.abspath(os.path.join(out_dir, "demo.gif")) |
| 194 | + frames[0].save( |
| 195 | + out_path, |
| 196 | + save_all=True, |
| 197 | + append_images=frames[1:], |
| 198 | + duration=durations, |
| 199 | + loop=0, |
| 200 | + optimize=True, |
| 201 | + ) |
| 202 | + size_kb = os.path.getsize(out_path) // 1024 |
| 203 | + print(f"wrote {out_path} ({len(frames)} frames, {size_kb} KB)") |
| 204 | + |
| 205 | + |
| 206 | +if __name__ == "__main__": |
| 207 | + main() |
0 commit comments