Skip to content

Commit fa99610

Browse files
redacted-aiclaude
andcommitted
refactor(chan): soul_manager on SoulStore, and re-plumb the standalone stack
Fourth and last, and the only one that is not really "this repo". The live chan is a self-contained fork at /home/umbrel/redacted-chan: its own compose file, its own Dockerfile that installed nothing but requirements.txt, its own real (non-shim) swarm_inbox.py, and SQLCipher-encrypted databases on a bind mount. apps/chan and the running chan had already drifted into different things. Repo side: soul_manager delegates to SoulStore with the same context_provider hook smolting uses; update_soul stays local; Dockerfile installs swarm-agent-base; aiohttp unpinned from ==3.9.1. Box side, on the standalone stack: packages/ synced in, Dockerfile rewritten to install swarm-core / swarm-tg / swarm-agent-base ahead of requirements.txt, aiohttp unpinned. Without that the soul shim would have been the builder trap all over again — a file sync that kills the container at import. Deliberately NOT done: chan's forked 540-line swarm_inbox.py is untouched. It works, and swapping a live message bus is a separate change needing its own chan-hermes round-trip verification. Left as an open thread. Verified before the container was touched: the new image, run against chan's real /data read-only, reproduced v599 / len 74780 / prompt 61040 exactly, and an import-walk of all 95 app modules failed zero. After the swap: same three numbers, 599 history files intact, 7 encrypted DBs intact and decrypting (962 recent exchanges readable), heartbeat flowing, restarts 0, 0 errors. Backups taken first at /home/umbrel/backups/chan-{code,data}-20260903T043920Z.tar.gz. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 2f2c7a5 commit fa99610

3 files changed

Lines changed: 55 additions & 261 deletions

File tree

‎apps/chan/Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ WORKDIR /app
77
# Shared packages first — they change less often than service code, so this
88
# layer stays cached across service-only edits.
99
COPY packages/ ./packages/
10-
RUN pip install --no-cache-dir ./packages/swarm-core ./packages/swarm-tg
10+
RUN pip install --no-cache-dir ./packages/swarm-core ./packages/swarm-tg ./packages/swarm-agent-base
1111

1212
COPY apps/chan/requirements.txt .
1313
RUN pip install --no-cache-dir -r requirements.txt

‎apps/chan/requirements.txt‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
python-telegram-bot[webhooks,job-queue]==21.3
2-
aiohttp==3.9.1
2+
# >=3.10: the current `openai` vendors httpx_aiohttp, which needs
3+
# aiohttp.SocketTimeoutError. Pinning 3.9.1 downgrades what the shared
4+
# packages install and breaks their import — see fd36242.
5+
aiohttp>=3.10
36
requests==2.31.0
47
python-dotenv==1.0.0
58
pyyaml>=6.0

‎apps/chan/soul_manager.py‎

Lines changed: 50 additions & 259 deletions
Original file line numberDiff line numberDiff line change
@@ -1,236 +1,91 @@
11
"""
2-
soul_manager.py — persistent, evolving identity layer for smolting.
2+
soul_manager.py — redacted-chan's identity layer, on the shared SoulStore.
33
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.
77
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.
1211
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.
1721
"""
22+
from __future__ import annotations
1823

1924
import json
2025
import logging
21-
import os
2226
import re
23-
import shutil
24-
from datetime import datetime, timezone
2527
from pathlib import Path
2628

29+
from swarm_agent_base.soul import SoulStore
30+
2731
try:
2832
from sanitizer import text_for_llm as _sanitize
2933
except ImportError:
3034
def _sanitize(t): return t # type: ignore
3135

3236
logger = logging.getLogger(__name__)
3337

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.
3638
_REPO_SOUL = Path(__file__).resolve().parent / "SOUL.md"
3739
_DATA_DIR = Path("/data") if Path("/data").exists() else Path(__file__).resolve().parent / "fs"
3840
_MEMORY_DIR = _DATA_DIR
3941
SOUL_FILE = _MEMORY_DIR / "SOUL.md"
4042

4143
_UPDATE_INTERVAL_HOURS = 2
42-
_MIN_FACTS_FOR_UPDATE = 3
44+
_MIN_FACTS_FOR_UPDATE = 3
4345

4446
# Sections that reflect lived experience (injected into prompts)
4547
_EVOLVING_SECTIONS = ["Evolving Beliefs", "Community Lore", "Notable Events", "Voice Notes"]
4648

4749

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.
5652
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.
7755
"""
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
10457

105-
# ── Read ──────────────────────────────────────────────────────────────────────
58+
return [f"- {d['fact']}" for d in cm.get_facts_by_resonance(n=5, context=context)]
10659

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 ""
12060

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+
)
12168

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 ──────────────────────────────────
16870

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
17086

17187

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 ────────────────────────────────────────────────────
23489

23590
async def update_soul(llm_client) -> bool:
23691
"""
@@ -396,67 +251,3 @@ def _fmt(items: list) -> str:
396251

397252

398253
# ── 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

Comments
 (0)