Files
Skald-Circle/scripts/honcho_backfill.py
T
2026-07-10 15:02:09 +01:00

360 lines
14 KiB
Python

#!/usr/bin/env python3
"""
Honcho backfill script.
Deletes the existing Honcho workspace, recreates it with the correct peer
config (observe_me=true for the user peer), and re-uploads all interactive
non-ephemeral chat history from the SQLite database.
Usage:
# Reads config from the SQLite plugins table automatically.
python3 scripts/honcho_backfill.py
# Or pass overrides:
python3 scripts/honcho_backfill.py \
--db ./database.db \
--base-url http://localhost:8000 \
--workspace personal-agent \
--dry-run
"""
import argparse
import json
import sqlite3
import sys
import time
from dataclasses import dataclass
from typing import Optional
import requests
# ── Honcho API helpers ────────────────────────────────────────────────────────
class HonchoClient:
def __init__(self, base_url: str, api_key: str = ""):
self.base = base_url.rstrip("/")
self.session = requests.Session()
if api_key:
self.session.headers["Authorization"] = f"Bearer {api_key}"
self.session.headers["Content-Type"] = "application/json"
def _url(self, path: str) -> str:
return f"{self.base}{path}"
def list_session_ids(self, workspace_id: str) -> list[str]:
ids = []
page = 1
while True:
r = self.session.post(
self._url(f"/v3/workspaces/{workspace_id}/sessions/list"),
params={"page": page, "size": 100},
json={},
)
if r.status_code == 404:
break
r.raise_for_status()
data = r.json()
items = data.get("items", [])
ids.extend(item["id"] for item in items)
if page >= data.get("pages", 1):
break
page += 1
return ids
def delete_all_sessions(self, workspace_id: str):
ids = self.list_session_ids(workspace_id)
print(f" deleting {len(ids)} existing session(s) …")
for sid in ids:
r = self.session.delete(self._url(f"/v3/workspaces/{workspace_id}/sessions/{sid}"))
if r.status_code not in (200, 202, 204, 404):
print(f" WARNING: could not delete session {sid}: {r.status_code}")
def delete_workspace(self, workspace_id: str):
self.delete_all_sessions(workspace_id)
r = self.session.delete(self._url(f"/v3/workspaces/{workspace_id}"))
if r.status_code in (200, 202, 204, 404):
print(f" workspace '{workspace_id}' deleted (or did not exist)")
else:
print(f" WARNING: DELETE workspace returned {r.status_code} — continuing anyway")
def create_workspace(self, workspace_id: str, retries: int = 6, delay: float = 2.0):
for attempt in range(1, retries + 1):
r = self.session.post(self._url("/v3/workspaces"), json={"id": workspace_id})
if r.status_code in (200, 201):
print(f" workspace '{workspace_id}' created")
return
# 409 = already exists (fine for --skip-delete path)
if r.status_code == 409:
print(f" workspace '{workspace_id}' already exists — reusing")
return
print(f" create workspace attempt {attempt}/{retries}: {r.status_code} — retrying in {delay}s …")
time.sleep(delay)
raise RuntimeError(f"POST workspace failed after {retries} attempts: {r.status_code} {r.text}")
def create_peer(self, workspace_id: str, peer_id: str):
r = self.session.post(
self._url(f"/v3/workspaces/{workspace_id}/peers"),
json={"id": peer_id},
)
if r.status_code in (200, 201):
print(f" peer '{peer_id}' created")
elif r.status_code == 409:
print(f" peer '{peer_id}' already exists — reusing")
else:
raise RuntimeError(f"POST peer failed: {r.status_code} {r.text}")
PEER_CONFIG = {
"user": {"observe_me": True},
"assistant": {"observe_me": True},
}
def _add_peers(self, workspace_id: str, session_id: str):
"""Add peer config to a session via POST (separate from session creation)."""
r = self.session.post(
self._url(f"/v3/workspaces/{workspace_id}/sessions/{session_id}/peers"),
json=self.PEER_CONFIG,
)
if r.status_code not in (200, 201, 409):
print(f" WARNING: add peers returned {r.status_code}: {r.text}")
def create_session(self, workspace_id: str, session_id: str, local_id: int) -> str:
body = {
"id": session_id,
"metadata": {"local_session_id": local_id},
}
r = self.session.post(
self._url(f"/v3/workspaces/{workspace_id}/sessions"),
json=body,
)
if r.status_code in (200, 201):
self._add_peers(workspace_id, session_id)
return session_id
if r.status_code == 409:
print(f" (session existed — adding peers)")
self._add_peers(workspace_id, session_id)
return session_id
raise RuntimeError(f"POST session failed: {r.status_code} {r.text}")
def fix_all_session_peers(self, workspace_id: str):
"""Add correct peer config to all existing sessions in the workspace."""
ids = self.list_session_ids(workspace_id)
print(f"Fixing peers on {len(ids)} session(s) …")
for sid in ids:
self._add_peers(workspace_id, sid)
print(f" {sid}", end="\r")
print(f"\nDone — {len(ids)} session(s) updated.")
def add_message(
self,
workspace_id: str,
session_id: str,
peer_id: str,
content: str,
local_message_id: int,
created_at: str,
):
body = {
"messages": [
{
"peer_id": peer_id,
"content": content,
"metadata": {"local_message_id": local_message_id},
"created_at": created_at,
}
]
}
r = self.session.post(
self._url(f"/v3/workspaces/{workspace_id}/sessions/{session_id}/messages"),
json=body,
)
if r.status_code not in (200, 201, 409):
raise RuntimeError(
f"POST message failed (session={session_id}): {r.status_code} {r.text}"
)
# ── DB helpers ────────────────────────────────────────────────────────────────
@dataclass
class Session:
id: int
source: str
@dataclass
class Message:
id: int
role: str
content: str
created_at: str
def load_plugin_config(db_path: str) -> Optional[dict]:
"""Read honcho plugin config from the plugins table."""
try:
con = sqlite3.connect(db_path)
row = con.execute(
"SELECT enabled, config FROM plugins WHERE id = 'honcho'"
).fetchone()
con.close()
if row is None:
return None
enabled, config_json = row
if not enabled:
print("WARNING: honcho plugin is disabled in DB; proceeding anyway")
return json.loads(config_json)
except Exception as e:
print(f"WARNING: could not read plugin config from DB: {e}")
return None
def load_sessions(db_path: str) -> list[Session]:
con = sqlite3.connect(db_path)
rows = con.execute(
"""
SELECT id, source
FROM chat_sessions
WHERE is_interactive = 1
AND is_ephemeral = 0
AND source NOT IN ('tic', 'cron')
ORDER BY id
"""
).fetchall()
con.close()
return [Session(id=r[0], source=r[1]) for r in rows]
def load_messages(db_path: str, session_id: int) -> list[Message]:
"""
Load all user/assistant messages for a session, ordered chronologically.
Excludes: sub-agent messages (role='agent'), failed, synthetic, empty.
"""
con = sqlite3.connect(db_path)
rows = con.execute(
"""
SELECT h.id, h.role, h.content, h.created_at
FROM chat_history h
JOIN chat_sessions_stack s ON s.id = h.session_stack_id
WHERE s.session_id = ?
AND h.role IN ('user', 'assistant')
AND h.status = 'ok'
AND h.is_synthetic = 0
AND h.content != ''
ORDER BY h.id
""",
(session_id,),
).fetchall()
con.close()
return [Message(id=r[0], role=r[1], content=r[2], created_at=r[3]) for r in rows]
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Backfill Honcho from local SQLite DB")
parser.add_argument("--db", default="./database.db", help="Path to SQLite DB")
parser.add_argument("--base-url", default=None, help="Honcho base URL (overrides DB config)")
parser.add_argument("--workspace", default=None, help="Honcho workspace ID (overrides DB config)")
parser.add_argument("--api-key", default="", help="Honcho API key")
parser.add_argument("--dry-run", action="store_true", help="Print plan without touching Honcho")
parser.add_argument("--delay-ms", type=int, default=50, help="Delay between message uploads (ms)")
parser.add_argument("--skip-delete", action="store_true", help="Skip workspace deletion (add to existing)")
parser.add_argument("--fix-peers", action="store_true", help="Only fix peer config on existing sessions, then exit")
args = parser.parse_args()
# ── Resolve config ────────────────────────────────────────────────────────
plugin_cfg = load_plugin_config(args.db)
base_url = args.base_url or (plugin_cfg or {}).get("base_url", "http://localhost:8000")
workspace_id = args.workspace or (plugin_cfg or {}).get("workspace_id", "personal-agent")
api_key = args.api_key or (plugin_cfg or {}).get("api_key", "")
print(f"Honcho base URL : {base_url}")
print(f"Workspace ID : {workspace_id}")
print(f"DB : {args.db}")
print()
client = HonchoClient(base_url, api_key)
# ── Fix-peers only mode ───────────────────────────────────────────────────
if args.fix_peers:
client.fix_all_session_peers(workspace_id)
return
# ── Load sessions ─────────────────────────────────────────────────────────
sessions = load_sessions(args.db)
print(f"Found {len(sessions)} interactive non-ephemeral session(s)")
total_msgs = 0
plan = []
for sess in sessions:
msgs = load_messages(args.db, sess.id)
if not msgs:
continue
honcho_id = f"{workspace_id}-{sess.id}"
plan.append((sess, msgs, honcho_id))
total_msgs += len(msgs)
print(f" session {sess.id:4d} ({sess.source:10s}) {len(msgs):4d} msgs → {honcho_id}")
print(f"\nTotal messages to upload: {total_msgs}")
if args.dry_run:
print("\n[dry-run] No changes made.")
return
if not plan:
print("Nothing to upload.")
return
confirm = input("\nProceed? This will DELETE and recreate the Honcho workspace. [y/N] ")
if confirm.strip().lower() != "y":
print("Aborted.")
sys.exit(0)
delay_s = args.delay_ms / 1000.0
# ── Reset workspace ───────────────────────────────────────────────────────
if not args.skip_delete:
print("\n[1/3] Deleting existing workspace …")
client.delete_workspace(workspace_id)
time.sleep(1)
print("\n[2/3] Creating workspace and peers …")
client.create_workspace(workspace_id)
client.create_peer(workspace_id, "user")
client.create_peer(workspace_id, "assistant")
# ── Upload messages ───────────────────────────────────────────────────────
print(f"\n[3/3] Uploading {total_msgs} messages …")
for sess, msgs, honcho_id in plan:
print(f"\n session {sess.id}{honcho_id} ({len(msgs)} messages)")
client.create_session(workspace_id, honcho_id, sess.id)
for i, msg in enumerate(msgs, 1):
peer_id = "user" if msg.role == "user" else "assistant"
try:
client.add_message(
workspace_id=workspace_id,
session_id=honcho_id,
peer_id=peer_id,
content=msg.content,
local_message_id=msg.id,
created_at=msg.created_at,
)
print(f" [{i:4d}/{len(msgs)}] {peer_id:9s} id={msg.id}", end="\r")
except RuntimeError as e:
print(f"\n ERROR on msg {msg.id}: {e} — skipping")
if delay_s > 0:
time.sleep(delay_s)
print(f" [{len(msgs):4d}/{len(msgs)}] done ")
print("\nBackfill complete.")
print("Honcho deriver will process messages in the background.")
print("Restart personal-agent to reconnect the plugin.")
if __name__ == "__main__":
main()