|
1 | 1 | """ |
2 | | -soul_manager.py — persistent, evolving identity layer for smolting. |
| 2 | +soul_manager.py — redacted-chan's identity layer, on the shared SoulStore. |
3 | 3 |
|
4 | | -SOUL.md is committed to the repo and survives Railway redeploys. |
5 | | -Every 2 hours the LLM distills resonance-ranked facts + recent memory |
6 | | -into updated beliefs, community lore observations, and voice notes. |
| 4 | +The storage, versioning and section-editing mechanics that used to live here in |
| 5 | +full are now ``swarm_agent_base.soul.SoulStore``; this module delegates and |
| 6 | +keeps every public name, so main.py needs no edit. |
7 | 7 |
|
8 | | -Phase 2 — Versioned snapshots: |
9 | | - Before every write, the current SOUL.md is copied to |
10 | | - /data/soul_history/SOUL_v{n}.md and a manifest is updated. |
11 | | - This preserves the drift history — you can see how beliefs evolved. |
| 8 | +``update_soul`` stays here. It is resonance-ranked and reaches into |
| 9 | +conversation_memory, mesh_deliberation and authenticity_vote — chan's own |
| 10 | +soul-evolution policy, not shared code. |
12 | 11 |
|
13 | | -Phase 3 — Submolt-contextual retrieval: |
14 | | - get_soul_for_prompt(context="existential") injects the base SOUL.md |
15 | | - sections PLUS the top resonance facts from that submolt, giving each |
16 | | - post context-appropriate belief depth. |
| 12 | +Deployment note: chan does not run from this repo. The live one is a |
| 13 | +self-contained stack at /home/umbrel/redacted-chan with its own compose file, |
| 14 | +its own Dockerfile, and SQLCipher-encrypted databases on a bind mount. This |
| 15 | +file only reaches it by an explicit file sync, and that sync must carry |
| 16 | +packages/ with it — see the Dockerfile there. |
| 17 | +
|
| 18 | +At the time of the swap: v599 and a ~75KB SOUL.md on /data. SOUL_FILE and the |
| 19 | +history dir must keep resolving to /data/SOUL.md and /data/soul_history or 599 |
| 20 | +snapshots are orphaned. |
17 | 21 | """ |
| 22 | +from __future__ import annotations |
18 | 23 |
|
19 | 24 | import json |
20 | 25 | import logging |
21 | | -import os |
22 | 26 | import re |
23 | | -import shutil |
24 | | -from datetime import datetime, timezone |
25 | 27 | from pathlib import Path |
26 | 28 |
|
| 29 | +from swarm_agent_base.soul import SoulStore |
| 30 | + |
27 | 31 | try: |
28 | 32 | from sanitizer import text_for_llm as _sanitize |
29 | 33 | except ImportError: |
30 | 34 | def _sanitize(t): return t # type: ignore |
31 | 35 |
|
32 | 36 | logger = logging.getLogger(__name__) |
33 | 37 |
|
34 | | -# SOUL.md lives on the persistent volume so it survives redeploys. |
35 | | -# Falls back to the repo SOUL.md for seeding on first run. |
36 | 38 | _REPO_SOUL = Path(__file__).resolve().parent / "SOUL.md" |
37 | 39 | _DATA_DIR = Path("/data") if Path("/data").exists() else Path(__file__).resolve().parent / "fs" |
38 | 40 | _MEMORY_DIR = _DATA_DIR |
39 | 41 | SOUL_FILE = _MEMORY_DIR / "SOUL.md" |
40 | 42 |
|
41 | 43 | _UPDATE_INTERVAL_HOURS = 2 |
42 | | -_MIN_FACTS_FOR_UPDATE = 3 |
| 44 | +_MIN_FACTS_FOR_UPDATE = 3 |
43 | 45 |
|
44 | 46 | # Sections that reflect lived experience (injected into prompts) |
45 | 47 | _EVOLVING_SECTIONS = ["Evolving Beliefs", "Community Lore", "Notable Events", "Voice Notes"] |
46 | 48 |
|
47 | 49 |
|
48 | | -# ── History directory (Phase 2) ─────────────────────────────────────────────── |
49 | | - |
50 | | -def _history_dir() -> Path: |
51 | | - """Return (and create) the soul history directory on the persistent volume.""" |
52 | | - d = _MEMORY_DIR / "soul_history" |
53 | | - d.mkdir(parents=True, exist_ok=True) |
54 | | - return d |
55 | | - |
| 50 | +def _resonance_lines(context: str) -> list[str]: |
| 51 | + """Top-5 facts tagged with this submolt, as prompt bullets. |
56 | 52 |
|
57 | | -def _load_manifest() -> dict: |
58 | | - """Load the soul version manifest. Returns default if missing.""" |
59 | | - p = _history_dir() / "manifest.json" |
60 | | - if p.exists(): |
61 | | - try: |
62 | | - return json.loads(p.read_text(encoding="utf-8")) |
63 | | - except Exception: |
64 | | - pass |
65 | | - return {"current_version": 0, "versions": []} |
66 | | - |
67 | | - |
68 | | -def _save_manifest(manifest: dict) -> None: |
69 | | - p = _history_dir() / "manifest.json" |
70 | | - p.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") |
71 | | - |
72 | | - |
73 | | -def _snapshot_soul(soul_text: str, facts_absorbed: list[str]) -> int: |
74 | | - """ |
75 | | - Copy current SOUL.md to soul_history/SOUL_v{n}.md before overwriting. |
76 | | - Updates the manifest and returns the new version number. |
| 53 | + Kept as a hook rather than moved into the package: conversation_memory is |
| 54 | + chan's, and SoulStore should not know what a submolt is. |
77 | 55 | """ |
78 | | - manifest = _load_manifest() |
79 | | - version = manifest["current_version"] + 1 |
80 | | - dest = _history_dir() / f"SOUL_v{version}.md" |
81 | | - try: |
82 | | - dest.write_text(soul_text, encoding="utf-8") |
83 | | - except Exception as e: |
84 | | - logger.warning(f"[soul] Snapshot write failed: {e}") |
85 | | - return version |
86 | | - |
87 | | - manifest["current_version"] = version |
88 | | - manifest["versions"].append({ |
89 | | - "version": version, |
90 | | - "snapshotted_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), |
91 | | - "facts_absorbed": facts_absorbed[:20], # store first 20 fact IDs |
92 | | - "word_count": len(soul_text.split()), |
93 | | - }) |
94 | | - # Keep manifest lean — only last 50 version entries |
95 | | - manifest["versions"] = manifest["versions"][-50:] |
96 | | - _save_manifest(manifest) |
97 | | - logger.info(f"[soul] Snapshot → SOUL_v{version}.md ({len(soul_text.split())} words)") |
98 | | - return version |
99 | | - |
100 | | - |
101 | | -def current_soul_version() -> int: |
102 | | - return _load_manifest()["current_version"] |
103 | | - |
| 56 | + import conversation_memory as cm |
104 | 57 |
|
105 | | -# ── Read ────────────────────────────────────────────────────────────────────── |
| 58 | + return [f"- {d['fact']}" for d in cm.get_facts_by_resonance(n=5, context=context)] |
106 | 59 |
|
107 | | -def read_soul() -> str: |
108 | | - """Return full SOUL.md content. Seeds volume copy from repo on first run.""" |
109 | | - try: |
110 | | - if not SOUL_FILE.exists() and _REPO_SOUL.exists(): |
111 | | - # First run on this volume — seed from repo and persist |
112 | | - SOUL_FILE.parent.mkdir(parents=True, exist_ok=True) |
113 | | - shutil.copy2(_REPO_SOUL, SOUL_FILE) |
114 | | - logger.info(f"[soul] Seeded SOUL.md from repo → {SOUL_FILE}") |
115 | | - if SOUL_FILE.exists(): |
116 | | - return SOUL_FILE.read_text(encoding="utf-8") |
117 | | - except Exception as e: |
118 | | - logger.warning(f"[soul] read failed: {e}") |
119 | | - return "" |
120 | 60 |
|
| 61 | +_store = SoulStore( |
| 62 | + "redacted-chan", |
| 63 | + repo_soul=_REPO_SOUL, |
| 64 | + data_dir=_MEMORY_DIR, |
| 65 | + sections=_EVOLVING_SECTIONS, |
| 66 | + context_provider=_resonance_lines, |
| 67 | +) |
121 | 68 |
|
122 | | -def get_soul_for_prompt(context: str | None = None) -> str: |
123 | | - """ |
124 | | - Return a trimmed block for system prompt injection. |
125 | | -
|
126 | | - Phase 3 — Submolt context: |
127 | | - If context (a submolt name) is provided, the base SOUL.md evolving |
128 | | - sections are returned PLUS a 'Context resonance' addendum: the top-5 |
129 | | - resonance facts specifically tagged with that submolt. This gives the |
130 | | - LLM belief depth that was shaped by actual exchanges in that space. |
131 | | -
|
132 | | - Parameters |
133 | | - ---------- |
134 | | - context : submolt name e.g. 'existential', 'research', 'agentsouls' |
135 | | - Pass None for the global/default behaviour. |
136 | | - """ |
137 | | - soul = read_soul() |
138 | | - if not soul: |
139 | | - return "" |
140 | | - |
141 | | - chunks = [] |
142 | | - for section in _EVOLVING_SECTIONS: |
143 | | - m = re.search(rf"## {section}\n(.*?)(?=\n## |\Z)", soul, re.DOTALL) |
144 | | - if not m: |
145 | | - continue |
146 | | - content = m.group(1).strip() |
147 | | - if content and content != "_Nothing yet._": |
148 | | - chunks.append(f"### {section}\n{content}") |
149 | | - |
150 | | - if not chunks and not context: |
151 | | - return "" |
152 | | - |
153 | | - base = "\n\n[SOUL]\n" + "\n\n".join(chunks) if chunks else "" |
154 | | - |
155 | | - # Phase 3: inject context-specific resonance facts |
156 | | - if context: |
157 | | - try: |
158 | | - import conversation_memory as cm |
159 | | - ctx_docs = cm.get_facts_by_resonance(n=5, context=context) |
160 | | - if ctx_docs: |
161 | | - lines = [f"- {d['fact']}" for d in ctx_docs] |
162 | | - base += ( |
163 | | - f"\n\n[SOUL — /{context} resonance]\n" |
164 | | - + "\n".join(lines) |
165 | | - ) |
166 | | - except Exception as e: |
167 | | - logger.debug(f"[soul] Context resonance inject failed: {e}") |
| 69 | +# ── Delegated: identical in all four copies ────────────────────────────────── |
168 | 70 |
|
169 | | - return base |
| 71 | +_history_dir = _store._history_dir |
| 72 | +_load_manifest = _store._load_manifest |
| 73 | +_save_manifest = _store._save_manifest |
| 74 | +_snapshot_soul = _store._snapshot |
| 75 | +current_soul_version = _store.current_version |
| 76 | +read_soul = _store.read |
| 77 | +get_soul_for_prompt = _store.for_prompt |
| 78 | +_parse_last_updated = _store._parse_last_updated |
| 79 | +hours_since_update = _store.hours_since_update |
| 80 | +_replace_section = _store._replace_section |
| 81 | +_append_to_section = _store._append_to_section |
| 82 | +_stamp = _store._stamp |
| 83 | +record_notable_event = _store.record_notable_event |
| 84 | +soul_status_line = _store.status_line |
| 85 | +soul_drift_summary = _store.drift_summary |
170 | 86 |
|
171 | 87 |
|
172 | | -# ── Timestamp helpers ───────────────────────────────────────────────────────── |
173 | | - |
174 | | -def _parse_last_updated(soul: str) -> datetime | None: |
175 | | - m = re.search(r"Last updated: (\d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC)", soul) |
176 | | - if m: |
177 | | - try: |
178 | | - return datetime.strptime(m.group(1), "%Y-%m-%d %H:%M UTC").replace( |
179 | | - tzinfo=timezone.utc |
180 | | - ) |
181 | | - except Exception: |
182 | | - pass |
183 | | - return None |
184 | | - |
185 | | - |
186 | | -def hours_since_update() -> float: |
187 | | - """Hours since last soul update. Returns large number if never updated.""" |
188 | | - soul = read_soul() |
189 | | - ts = _parse_last_updated(soul) if soul else None |
190 | | - if not ts: |
191 | | - return 9999.0 |
192 | | - return (datetime.now(timezone.utc) - ts).total_seconds() / 3600 |
193 | | - |
194 | | - |
195 | | -# ── Write helpers ───────────────────────────────────────────────────────────── |
196 | | - |
197 | | -def _replace_section(soul: str, section: str, new_content: str) -> str: |
198 | | - """Replace a section's body in the soul markdown.""" |
199 | | - replacement = f"## {section}\n{new_content}" |
200 | | - updated = re.sub( |
201 | | - rf"## {section}\n.*?(?=\n## |\Z)", |
202 | | - replacement, |
203 | | - soul, |
204 | | - flags=re.DOTALL, |
205 | | - ) |
206 | | - if updated == soul: |
207 | | - # Section missing — append it |
208 | | - updated = soul.rstrip() + f"\n\n## {section}\n{new_content}\n" |
209 | | - return updated |
210 | | - |
211 | | - |
212 | | -def _append_to_section(soul: str, section: str, new_lines: list[str]) -> str: |
213 | | - """Append lines to a section, replacing the _Nothing yet._ placeholder.""" |
214 | | - m = re.search(rf"## {section}\n(.*?)(?=\n## |\Z)", soul, re.DOTALL) |
215 | | - existing = m.group(1).strip() if m else "_Nothing yet._" |
216 | | - if existing == "_Nothing yet._": |
217 | | - combined = "\n".join(new_lines) |
218 | | - else: |
219 | | - combined = existing + "\n" + "\n".join(new_lines) |
220 | | - return _replace_section(soul, section, combined) |
221 | | - |
222 | | - |
223 | | -def _stamp(soul: str) -> str: |
224 | | - ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") |
225 | | - updated = re.sub(r"Last updated: .*", f"Last updated: {ts}", soul) |
226 | | - if updated == soul: |
227 | | - lines = soul.split("\n", 2) |
228 | | - if len(lines) >= 2: |
229 | | - updated = lines[0] + "\n" + f"*Last updated: {ts}*\n" + "\n".join(lines[1:]) |
230 | | - return updated |
231 | | - |
232 | | - |
233 | | -# ── Core update logic ───────────────────────────────────────────────────────── |
| 88 | +# ── chan's own reflection ──────────────────────────────────────────────────── |
234 | 89 |
|
235 | 90 | async def update_soul(llm_client) -> bool: |
236 | 91 | """ |
@@ -396,67 +251,3 @@ def _fmt(items: list) -> str: |
396 | 251 |
|
397 | 252 |
|
398 | 253 | # ── Direct event recording (no LLM gate) ───────────────────────────────────── |
399 | | - |
400 | | -def record_notable_event(event: str) -> bool: |
401 | | - """ |
402 | | - Immediately append a dated event to the Notable Events section. |
403 | | - Call this from code when something actually significant happens — |
404 | | - deploy results, swarm messages, milestones — bypassing the LLM gate. |
405 | | - """ |
406 | | - soul = read_soul() |
407 | | - if not soul: |
408 | | - return False |
409 | | - |
410 | | - date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") |
411 | | - line = f"- {date_str}: {event.strip()}" |
412 | | - |
413 | | - soul = _append_to_section(soul, "Notable Events", [line]) |
414 | | - soul = _stamp(soul) |
415 | | - |
416 | | - try: |
417 | | - SOUL_FILE.write_text(soul, encoding="utf-8") |
418 | | - logger.info("[soul] Notable event recorded: %s", event[:80]) |
419 | | - return True |
420 | | - except Exception as e: |
421 | | - logger.error("[soul] record_notable_event write failed: %s", e) |
422 | | - return False |
423 | | - |
424 | | - |
425 | | -# ── Status helpers ──────────────────────────────────────────────────────────── |
426 | | - |
427 | | -def soul_status_line() -> str: |
428 | | - """One-line status for /stats command.""" |
429 | | - soul = read_soul() |
430 | | - if not soul: |
431 | | - return "SOUL.md: not found" |
432 | | - ts = _parse_last_updated(soul) |
433 | | - version = current_soul_version() |
434 | | - v_str = f" (v{version})" if version else "" |
435 | | - if ts: |
436 | | - h = (datetime.now(timezone.utc) - ts).total_seconds() / 3600 |
437 | | - return f"SOUL.md{v_str}: updated {h:.1f}h ago" |
438 | | - return f"SOUL.md{v_str}: present (no timestamp)" |
439 | | - |
440 | | - |
441 | | -def soul_drift_summary(versions: int = 3) -> str: |
442 | | - """ |
443 | | - Return a human-readable summary of the last N soul versions for /soul drift. |
444 | | - Lists version numbers, timestamps, word counts, and how many facts were absorbed. |
445 | | - """ |
446 | | - manifest = _load_manifest() |
447 | | - recent = manifest.get("versions", [])[-versions:] |
448 | | - if not recent: |
449 | | - return "No soul history yet — first update will create a snapshot." |
450 | | - |
451 | | - lines = [f"**Soul drift — last {len(recent)} version(s):**\n"] |
452 | | - for v in recent: |
453 | | - lines.append( |
454 | | - f"• v{v['version']} @ {v['snapshotted_at'][:10]} " |
455 | | - f"({v['word_count']} words, " |
456 | | - f"{len(v['facts_absorbed'])} facts absorbed)" |
457 | | - ) |
458 | | - lines.append( |
459 | | - f"\nCurrent: v{manifest['current_version']} — " |
460 | | - f"use `/soul diff` to compare two versions." |
461 | | - ) |
462 | | - return "\n".join(lines) |
0 commit comments