Skip to content

Commit 20b1c2b

Browse files
Kodaxadevclaude
andcommitted
docs: add reproducible README demo GIF
scripts/make_demo_gif.py runs the real engine (remember -> update -> recall -> query_at -> consolidate) against a throwaway database and renders the actual outputs into docs/assets/demo.gif, so the demo can never drift from real behavior. Regenerate with: pip install pillow && python scripts/make_demo_gif.py Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b9c0297 commit 20b1c2b

3 files changed

Lines changed: 217 additions & 0 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@
66
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
77
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
88

9+
<p align="center">
10+
<img src="docs/assets/demo.gif" alt="Chronos demo: remember a fact, update it, recall the present, time-travel to what was true in the past, then run a consolidation pass" width="760">
11+
</p>
12+
13+
<sup>Every output in this demo is real engine output — it's generated by
14+
[`scripts/make_demo_gif.py`](scripts/make_demo_gif.py) running the actual
15+
server code against a throwaway database.</sup>
16+
917
Claude forgets everything when you start a new chat. Most memory servers fix that by
1018
hoarding: everything saved forever, every recall dumped into context, no notion of
1119
whether a memory is still true. Chronos takes the opposite stance — memory that
@@ -188,6 +196,8 @@ ruff check .
188196
CI runs the suite on Linux and Windows across Python 3.10/3.12/3.14.
189197
Architecture notes live in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md);
190198
the v3.3 → v4.0 redesign rationale is in [CHANGELOG.md](CHANGELOG.md).
199+
The README demo regenerates with `python scripts/make_demo_gif.py`
200+
(needs `pip install pillow`) so it can never drift from real behavior.
191201

192202
## License
193203

docs/assets/demo.gif

133 KB
Loading

scripts/make_demo_gif.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
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

Comments
 (0)