From 5565f29c177b6e78accd02535527d37c9299b9b6 Mon Sep 17 00:00:00 2001 From: Symbiont Date: Fri, 20 Mar 2026 13:45:34 +0000 Subject: [PATCH] =?UTF-8?q?Rename=20sessions=20=E2=86=92=20Engram:=20persi?= =?UTF-8?q?stent=20memory=20across=20Claude=20instances?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engram is the physical trace a memory leaves in neural tissue. Every Claude session now writes its engrams to /data/symbiont/engram.db. Changes: - sessions.py → engram.py with class Engram (SessionRegistry alias kept) - sessions.db → engram.db - CLAUDE.md updated to use Engram - Genesis session registered with full build history Muse ecosystem: Cortex + Dendrite + Symbiont + Engram Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 12 +- engram.db | Bin 0 -> 40960 bytes heartbeat.jsonl | 1 + register_genesis.py | 19 ++++ sessions.db | Bin 0 -> 40960 bytes sessions.db-shm | Bin 0 -> 32768 bytes sessions.db-wal | Bin 0 -> 131872 bytes symbiont/api.py | 2 +- symbiont/engram.py | 266 +++++++++++++++++++++++++++++++++++++++++++ symbiont/sessions.py | 260 +----------------------------------------- test_sessions.py | 42 +++++++ 11 files changed, 342 insertions(+), 260 deletions(-) create mode 100644 engram.db create mode 100644 register_genesis.py create mode 100644 sessions.db create mode 100644 sessions.db-shm create mode 100644 sessions.db-wal create mode 100644 symbiont/engram.py create mode 100644 test_sessions.py diff --git a/CLAUDE.md b/CLAUDE.md index 9ad0440..dcf8cbe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,24 +8,26 @@ This file gives you everything you need to orient yourself and get productive. ```python import sys sys.path.insert(0, "/data/symbiont") -from symbiont.sessions import SessionRegistry, sitrep +from symbiont.engram import Engram, sitrep # 1. See what's going on print(sitrep()) # 2. Register yourself -reg = SessionRegistry() -sid = reg.register("code", "Brief description of what you're working on") +eng = Engram() +sid = eng.register("code", "Brief description of what you're working on") # 3. Before modifying shared files, check for locks -locks = reg.check_locks("/data/symbiont/symbiont/router.py") +locks = eng.check_locks("/data/symbiont/symbiont/router.py") if locks: print(f"WARNING: file locked by {locks}") # 4. When you're done, summarize what you accomplished -reg.complete(sid, "What you built or changed, in one sentence") +eng.complete(sid, "What you built or changed, in one sentence") ``` +**Engram:** the physical trace a memory leaves in neural tissue. Every session writes its engrams here. + ## What You Are Symbiont is a self-sustaining AI orchestrator. Michael Dwyer provides infrastructure, legal diff --git a/engram.db b/engram.db new file mode 100644 index 0000000000000000000000000000000000000000..156765851b2f48127105e643a4bf6f96595a01e6 GIT binary patch literal 40960 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 literal 0 HcmV?d00001 diff --git a/sessions.db-wal b/sessions.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..5f9bfc74cf4987d4477aa9f3f5aeb6b478a2f9f4 GIT binary patch literal 131872 zcmeI*eT*Ds9S88ex4rk<3Wiv#;n55QPTpFNDPr6M4J*)j0TLzt3Z591A-z!6YU>9&un+QdoO!k zG1@)8Pp;RyncJJ0&&=-S=6>Jble#{5Lm>RS4+H{R0%{z;@=*53jaMHFiK||^_2Gwi z$wj@*%P%}664$>KzGqx6?3s8zAm1<`009U<00Izz00bZa0SG_<0^KKY!v#HKlUtWR za(*tCTfQU^(_%?Yk7?nku4lANI1|-VX+0J#I2FfR{AAg*4AU->xoRnII<_DM$75OI zIc34T-NZ1tOGRNpeaso>@=N$`GRiCD&qt8ES)JO%KPkE24T< z&t$bsB#}&{bgi>B$#guWWz-Ie(|fn1MWSo(oyClz+RFt-J&|WroZijmU7woG?VsKo zDH-y_2Gz-}-!J&;Z@+ZQ&ApOqA5qFS> zrEUR?)b9)Aj)X4$*3lO*6P34ro&xxBfdtrJL0jT$%R+l zO&VSANZxUTTt;2_Ws9a|arw}BkIF^mf+%~87KFLPT08Z{wXCK^;;~ptORv8MeFx99 z?_i(0xa;)-*$Xau@}|pv{}WX&&>MOp5c-3BhXDZyKmY;|fB*y_009U<00Izzz}YPD zp}xtU^=|$2ZtI&IZd%zgAA>w7)nA~IBiMJx(9s`~rSod{1x^G)C(vQwY(DYWHwZug z0uX=z1Rwwb2tWV=5P-m$2#oY??Wq-C^sekL(61g}uNS!UyXJlOU-0%TDn}3;|7;+1 zbLjmO_fK3r{+#>-0|F3$00bZa0SG_<0uX=z1U3>_4hDmP$=veJroYd&)a|cb_?}Lu zleH=8XJ_}3TGf7*TuRNwGAUS;b93HgoCwMn`EFvE+@+$hpq4m9{!d-T1u0OM<}F4_ zj=?NrU_kCiv1vD2{!HAu8+s}GMvq6QnQJ*!W~^20Pv#wu*sM~I8E~s#ZKcxL zR+3s&pOO&)AveOAJT}K}WDNW*6g-FXuxWEa?E)jTLOm64;PZWIYm1$2O^>Cu8C$TT z=WlSD*@kBdMx>fy$!|*Xo>Sq>BTFVF6_$^;7GT%@dYP$C9y?*A1zd zVqxXA;}L2bwJSqzP#$gl8j=_EfV!yb_XYm8>^w4f(+k(C`vN_oHv{qw0|F3$00bZa z0SG_<0uX=z1Rwx`O(C$Qr|)Aur|!iZu$&?f%EKC#{~9@hXK(BI+^$Q$_%D?s2!`hT z{sN(ULx)1H`~(965P$##AOHafKmY;|fB*y_0D+AJ+GYKND^|ADAq=!z+23wuU%QnX z^%rQ#5o|y2_WSO5=hZK(dV#^QU$x{2&8i%MT!aAu2tWV=5P$##AOHafbcKKz_6zz4 zIxFZ;$5QbbTd<<)v~Q4UW*eR<7?FkghQ&B1dC#fHLVmJj(nfWEq{8x~6KF8=}F`Wy5fZ%I|%^@KmY;|fB*y_009U<00I!` zKwwplKxGJ8as)emQMmIletcZz2!009U<00Izz00bb=i9mV8FApAU zDi3a%)U37AI<93kEfSAM^^|t1L%%HFm%aFj$BHHwUNuYRE}xaP{oy?2vedueIh==0 zn+s|e7@-yFFQf%`vnr>)QRDjdx3Jz-J>RFcw%FO$FdsojIfCp{ z&pvQX<>Ue62s(M0$Jast0uX=z1Rwwb2tWV=5P$##xujUBO z`S6jeUvm%dSM>s;BY#;lM<5qtKmY;|fB*y_009UjE#tOas+Y(1_U4g0SG_<0uX=z1m2}Ub(7v@E_y_F=aj+bmE^b~Se$QZdCst(4!Tg+*F4?IKw+DVd^%QALiR zO+kM;mWt2Vf)!P#eS=Ih+wkPv2SgU?85mr<-Q^R z#BT5C#3Dx!$9x3!`8F^gfsEQ{dQ1yPf_D}CX({BSC&%2-_M_^i_ zQDM!zESa)s*73 z8##jibB^GeZ{&kV|K9&Pas=J~Lmo!}0SG_<0uX=z1Rwwb2tWV=5NH)xl_OBU{{M2 str: + """Register a new session. Returns session ID.""" + sid = datetime.now().strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:8] + now = datetime.now().isoformat() + + with self._connect() as conn: + conn.execute( + "INSERT INTO sessions (id, session_type, summary, status, started_at, last_heartbeat, metadata) " + "VALUES (?, ?, ?, 'active', ?, ?, ?)", + (sid, session_type, summary, now, now, metadata), + ) + + logger.info(f"Session registered: {sid} ({session_type}) — {summary}") + return sid + + def heartbeat(self, session_id: str, summary: Optional[str] = None): + """Update heartbeat timestamp and optionally update summary.""" + now = datetime.now().isoformat() + with self._connect() as conn: + if summary: + conn.execute( + "UPDATE sessions SET last_heartbeat=?, summary=?, status='active' WHERE id=?", + (now, summary, session_id), + ) + else: + conn.execute( + "UPDATE sessions SET last_heartbeat=?, status='active' WHERE id=?", + (now, session_id), + ) + + def complete(self, session_id: str, completion_summary: str): + """Mark session as completed with a summary of what was accomplished.""" + now = datetime.now().isoformat() + with self._connect() as conn: + conn.execute( + "UPDATE sessions SET status='completed', completed_at=?, completion_summary=? WHERE id=?", + (now, completion_summary, session_id), + ) + # Release all locks + conn.execute("DELETE FROM resource_locks WHERE session_id=?", (session_id,)) + + logger.info(f"Session completed: {session_id}") + + def log(self, session_id: str, entry: str): + """Log a progress entry for a session.""" + now = datetime.now().isoformat() + with self._connect() as conn: + conn.execute( + "INSERT INTO session_logs (session_id, timestamp, entry) VALUES (?, ?, ?)", + (session_id, now, entry), + ) + + def get_active_sessions(self) -> list[dict]: + """Get all currently active sessions.""" + with self._connect() as conn: + rows = conn.execute( + "SELECT * FROM sessions WHERE status='active' ORDER BY last_heartbeat DESC" + ).fetchall() + return [dict(r) for r in rows] + + def get_recent_sessions(self, hours: int = 24) -> list[dict]: + """Get recently completed sessions for context.""" + cutoff = (datetime.now() - timedelta(hours=hours)).isoformat() + with self._connect() as conn: + rows = conn.execute( + "SELECT * FROM sessions WHERE status='completed' AND completed_at > ? " + "ORDER BY completed_at DESC", + (cutoff,), + ).fetchall() + return [dict(r) for r in rows] + + def get_session_logs(self, session_id: str, limit: int = 20) -> list[dict]: + """Get log entries for a specific session.""" + with self._connect() as conn: + rows = conn.execute( + "SELECT * FROM session_logs WHERE session_id=? ORDER BY timestamp DESC LIMIT ?", + (session_id, limit), + ).fetchall() + return [dict(r) for r in rows] + + def lock_resource(self, session_id: str, resource: str, note: Optional[str] = None): + """Claim a resource lock. Warns if already locked by another session.""" + existing = self.check_locks(resource) + other_locks = [l for l in existing if l["session_id"] != session_id] + if other_locks: + logger.warning( + f"Resource '{resource}' already locked by: " + + ", ".join(l["session_id"] for l in other_locks) + ) + + now = datetime.now().isoformat() + with self._connect() as conn: + conn.execute( + "INSERT OR REPLACE INTO resource_locks (resource, session_id, locked_at, note) " + "VALUES (?, ?, ?, ?)", + (resource, session_id, now, note), + ) + + def release_resource(self, session_id: str, resource: str): + """Release a resource lock.""" + with self._connect() as conn: + conn.execute( + "DELETE FROM resource_locks WHERE resource=? AND session_id=?", + (resource, session_id), + ) + + def check_locks(self, resource: str) -> list[dict]: + """Check who has locks on a resource.""" + with self._connect() as conn: + rows = conn.execute( + "SELECT rl.*, s.summary, s.session_type FROM resource_locks rl " + "JOIN sessions s ON rl.session_id = s.id " + "WHERE rl.resource=?", + (resource,), + ).fetchall() + return [dict(r) for r in rows] + + def get_situation_report(self) -> str: + """ + Generate a human-readable situation report for a new session. + This is the first thing a new session should read. + """ + active = self.get_active_sessions() + recent = self.get_recent_sessions(hours=24) + + lines = ["# Symbiont Situation Report", f"Generated: {datetime.now().isoformat()}", ""] + + if active: + lines.append(f"## Active Sessions ({len(active)})") + for s in active: + lines.append(f"- **{s['id']}** ({s['session_type']}): {s['summary']}") + lines.append(f" Last heartbeat: {s['last_heartbeat']}") + lines.append("") + + # Check for resource locks + with self._connect() as conn: + locks = conn.execute( + "SELECT rl.resource, rl.session_id, rl.note FROM resource_locks rl " + "JOIN sessions s ON rl.session_id = s.id WHERE s.status='active'" + ).fetchall() + if locks: + lines.append("### Active Resource Locks") + for l in locks: + note = f" ({l['note']})" if l["note"] else "" + lines.append(f"- `{l['resource']}` — locked by {l['session_id']}{note}") + lines.append("") + else: + lines.append("## No active sessions") + lines.append("") + + if recent: + lines.append(f"## Recently Completed ({len(recent)} in last 24h)") + for s in recent: + lines.append(f"- **{s['id']}** ({s['session_type']}): {s.get('completion_summary', s['summary'])}") + lines.append("") + + return "\n".join(lines) + + +# Convenience function for quick sitrep +def sitrep() -> str: + """Get a situation report. Call this at the start of every session.""" + return Engram().get_situation_report() + + +# Backward compatibility alias +SessionRegistry = Engram diff --git a/symbiont/sessions.py b/symbiont/sessions.py index 944402f..05bec1c 100644 --- a/symbiont/sessions.py +++ b/symbiont/sessions.py @@ -1,259 +1,11 @@ """ -Session Registry: Shared awareness across concurrent Claude instances. +Backward compatibility shim: sessions.py now points to engram.py -Every Claude session (Cowork, Claude Code, Desktop) registers here on startup. -This lets each instance see what others are working on, avoid conflicts on -shared resources, and pick up context from recently completed work. +For new code, use: + from symbiont.engram import Engram, sitrep -SQLite with WAL mode handles 2-4 concurrent readers cleanly. Each session -writes only its own rows, so writer contention is minimal. - -Usage: - from symbiont.sessions import SessionRegistry - - reg = SessionRegistry() - sid = reg.register("cowork", "Building the Elixir port of Symbiont") - - # Check what siblings are doing - active = reg.get_active_sessions() - recent = reg.get_recent_sessions(hours=24) - - # Log progress - reg.log(sid, "Finished router module, starting dispatcher") - - # Claim a resource (prevents conflicts) - reg.lock_resource(sid, "/data/symbiont/symbiont/router.py") - - # Before modifying a file, check if someone else has it - locks = reg.check_locks("/data/symbiont/symbiont/router.py") - - # Heartbeat (call periodically on long sessions) - reg.heartbeat(sid, "Still working on dispatcher, 60% done") - - # Done - reg.complete(sid, "Finished Elixir port of router + dispatcher. Tests passing.") +For legacy code that imports SessionRegistry, this still works: + from symbiont.sessions import SessionRegistry, sitrep """ -import sqlite3 -import logging -import uuid -from datetime import datetime, timedelta -from pathlib import Path -from typing import Optional - -logger = logging.getLogger(__name__) - -DB_PATH = Path("/data/symbiont/sessions.db") - - -class SessionRegistry: - def __init__(self, db_path: Optional[Path] = None): - self.db_path = db_path or DB_PATH - self.db_path.parent.mkdir(parents=True, exist_ok=True) - self._init_db() - - def _connect(self): - conn = sqlite3.connect(str(self.db_path), timeout=10) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA journal_mode=WAL") - conn.execute("PRAGMA busy_timeout=5000") - return conn - - def _init_db(self): - with self._connect() as conn: - conn.executescript(""" - CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - session_type TEXT NOT NULL, -- 'cowork', 'code', 'desktop', 'api' - summary TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active', -- 'active', 'idle', 'completed' - started_at TEXT NOT NULL, - last_heartbeat TEXT NOT NULL, - completed_at TEXT, - completion_summary TEXT, - metadata TEXT -- JSON blob for extra context - ); - - CREATE TABLE IF NOT EXISTS session_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL REFERENCES sessions(id), - timestamp TEXT NOT NULL, - entry TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS resource_locks ( - resource TEXT NOT NULL, - session_id TEXT NOT NULL REFERENCES sessions(id), - locked_at TEXT NOT NULL, - note TEXT, - PRIMARY KEY (resource, session_id) - ); - - CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status); - CREATE INDEX IF NOT EXISTS idx_logs_session ON session_logs(session_id); - CREATE INDEX IF NOT EXISTS idx_locks_resource ON resource_locks(resource); - """) - - def register(self, session_type: str, summary: str, metadata: Optional[str] = None) -> str: - """Register a new session. Returns session ID.""" - sid = datetime.now().strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:8] - now = datetime.now().isoformat() - - with self._connect() as conn: - conn.execute( - "INSERT INTO sessions (id, session_type, summary, status, started_at, last_heartbeat, metadata) " - "VALUES (?, ?, ?, 'active', ?, ?, ?)", - (sid, session_type, summary, now, now, metadata), - ) - - logger.info(f"Session registered: {sid} ({session_type}) — {summary}") - return sid - - def heartbeat(self, session_id: str, summary: Optional[str] = None): - """Update heartbeat timestamp and optionally update summary.""" - now = datetime.now().isoformat() - with self._connect() as conn: - if summary: - conn.execute( - "UPDATE sessions SET last_heartbeat=?, summary=?, status='active' WHERE id=?", - (now, summary, session_id), - ) - else: - conn.execute( - "UPDATE sessions SET last_heartbeat=?, status='active' WHERE id=?", - (now, session_id), - ) - - def complete(self, session_id: str, completion_summary: str): - """Mark session as completed with a summary of what was accomplished.""" - now = datetime.now().isoformat() - with self._connect() as conn: - conn.execute( - "UPDATE sessions SET status='completed', completed_at=?, completion_summary=? WHERE id=?", - (now, completion_summary, session_id), - ) - # Release all locks - conn.execute("DELETE FROM resource_locks WHERE session_id=?", (session_id,)) - - logger.info(f"Session completed: {session_id}") - - def log(self, session_id: str, entry: str): - """Log a progress entry for a session.""" - now = datetime.now().isoformat() - with self._connect() as conn: - conn.execute( - "INSERT INTO session_logs (session_id, timestamp, entry) VALUES (?, ?, ?)", - (session_id, now, entry), - ) - - def get_active_sessions(self) -> list[dict]: - """Get all currently active sessions.""" - with self._connect() as conn: - rows = conn.execute( - "SELECT * FROM sessions WHERE status='active' ORDER BY last_heartbeat DESC" - ).fetchall() - return [dict(r) for r in rows] - - def get_recent_sessions(self, hours: int = 24) -> list[dict]: - """Get recently completed sessions for context.""" - cutoff = (datetime.now() - timedelta(hours=hours)).isoformat() - with self._connect() as conn: - rows = conn.execute( - "SELECT * FROM sessions WHERE status='completed' AND completed_at > ? " - "ORDER BY completed_at DESC", - (cutoff,), - ).fetchall() - return [dict(r) for r in rows] - - def get_session_logs(self, session_id: str, limit: int = 20) -> list[dict]: - """Get log entries for a specific session.""" - with self._connect() as conn: - rows = conn.execute( - "SELECT * FROM session_logs WHERE session_id=? ORDER BY timestamp DESC LIMIT ?", - (session_id, limit), - ).fetchall() - return [dict(r) for r in rows] - - def lock_resource(self, session_id: str, resource: str, note: Optional[str] = None): - """Claim a resource lock. Warns if already locked by another session.""" - existing = self.check_locks(resource) - other_locks = [l for l in existing if l["session_id"] != session_id] - if other_locks: - logger.warning( - f"Resource '{resource}' already locked by: " - + ", ".join(l["session_id"] for l in other_locks) - ) - - now = datetime.now().isoformat() - with self._connect() as conn: - conn.execute( - "INSERT OR REPLACE INTO resource_locks (resource, session_id, locked_at, note) " - "VALUES (?, ?, ?, ?)", - (resource, session_id, now, note), - ) - - def release_resource(self, session_id: str, resource: str): - """Release a resource lock.""" - with self._connect() as conn: - conn.execute( - "DELETE FROM resource_locks WHERE resource=? AND session_id=?", - (resource, session_id), - ) - - def check_locks(self, resource: str) -> list[dict]: - """Check who has locks on a resource.""" - with self._connect() as conn: - rows = conn.execute( - "SELECT rl.*, s.summary, s.session_type FROM resource_locks rl " - "JOIN sessions s ON rl.session_id = s.id " - "WHERE rl.resource=?", - (resource,), - ).fetchall() - return [dict(r) for r in rows] - - def get_situation_report(self) -> str: - """ - Generate a human-readable situation report for a new session. - This is the first thing a new session should read. - """ - active = self.get_active_sessions() - recent = self.get_recent_sessions(hours=24) - - lines = ["# Symbiont Situation Report", f"Generated: {datetime.now().isoformat()}", ""] - - if active: - lines.append(f"## Active Sessions ({len(active)})") - for s in active: - lines.append(f"- **{s['id']}** ({s['session_type']}): {s['summary']}") - lines.append(f" Last heartbeat: {s['last_heartbeat']}") - lines.append("") - - # Check for resource locks - with self._connect() as conn: - locks = conn.execute( - "SELECT rl.resource, rl.session_id, rl.note FROM resource_locks rl " - "JOIN sessions s ON rl.session_id = s.id WHERE s.status='active'" - ).fetchall() - if locks: - lines.append("### Active Resource Locks") - for l in locks: - note = f" ({l['note']})" if l["note"] else "" - lines.append(f"- `{l['resource']}` — locked by {l['session_id']}{note}") - lines.append("") - else: - lines.append("## No active sessions") - lines.append("") - - if recent: - lines.append(f"## Recently Completed ({len(recent)} in last 24h)") - for s in recent: - lines.append(f"- **{s['id']}** ({s['session_type']}): {s.get('completion_summary', s['summary'])}") - lines.append("") - - return "\n".join(lines) - - -# Convenience function for quick sitrep -def sitrep() -> str: - """Get a situation report. Call this at the start of every session.""" - return SessionRegistry().get_situation_report() +from symbiont.engram import * diff --git a/test_sessions.py b/test_sessions.py new file mode 100644 index 0000000..fad6db8 --- /dev/null +++ b/test_sessions.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Test the session registry end-to-end.""" +import sys +sys.path.insert(0, "/data/symbiont") +from symbiont.sessions import SessionRegistry, sitrep + +reg = SessionRegistry() + +# Register this session (the one that built everything) +sid = reg.register( + "cowork", + "Building Symbiont core: router, dispatcher, sessions, Dendrite integration" +) +print(f"Registered session: {sid}") + +# Log some progress +reg.log(sid, "Built LLM router with Haiku classifier and model tier dispatch") +reg.log(sid, "Added systemd life support (API service + heartbeat timer)") +reg.log(sid, "Integrated Dendrite headless browser via web.py") +reg.log(sid, "Created session registry for cross-instance awareness") +reg.log(sid, "Deployed CLAUDE.md bootstrap for new sessions") + +# Lock a resource to test that +reg.lock_resource(sid, "/data/symbiont/symbiont/router.py", "May refactor to Elixir soon") + +# Print the sitrep +print() +print(sitrep()) + +# Test the API endpoints too +import urllib.request, json +resp = urllib.request.urlopen("http://localhost:8111/sitrep", timeout=5) +api_sitrep = json.loads(resp.read()) +print(f"API /sitrep active sessions: {len(api_sitrep['active'])}") + +# Complete this session +reg.complete(sid, "Built Symbiont v0.1: router, dispatcher, ledger, heartbeat, Dendrite, sessions, CLAUDE.md") +print(f"\nSession {sid} completed.") + +# Final sitrep showing it moved to completed +print() +print(sitrep())