tankslab.com/recall/source
bin/legacy_store.py
197 lines, exactly as they ship in the download.
#!/usr/bin/env python3
"""
legacy_store.py - read a previous memory tool's database into Recall.
Named for what it DOES, not for whose it is. This is one row in sources.py; adding
another tool means another row, not another file with somebody's name on it.
Identified by its TABLES, never by its filename - a folder name proves nothing, and
guessing from one is how a tool ends up touching a file it never understood.
Opened READ-ONLY. Importing reads; whether that store then goes is the owner's
decision and is handled separately, after the import has been verified.
Everything crosses the redactor on the way in. That matters more here than anywhere
else in the package: stores like this have been found holding credentials in clear
text, and importing without scrubbing would carry them straight into the new store.
"""
from __future__ import annotations
import argparse
import os
import sqlite3
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import store # noqa: E402
from redact import scrub, selftest # noqa: E402
# No default path here on purpose. sources.py owns WHERE a legacy store lives;
# a second copy of that path would be a second place to keep in step, and it is
# the only reason a vendor name would appear in this file at all.
SRC = os.environ.get("RECALL_IMPORT_DB", "")
def _clean(row: sqlite3.Row, fields: list[str]) -> tuple[dict, int]:
out, hits = {}, 0
for f in fields:
v = row[f] if f in row.keys() else None
if isinstance(v, str) and v:
v, n, _ = scrub(v)
hits += n
out[f] = v
return out, hits
def _run(args) -> dict:
# A migration that runs with a broken redactor writes cleartext secrets
# into the new store. Refuse rather than "probably fine".
if selftest(verbose=False) != 0:
print("ABORT: redaction selftest failed - refusing to migrate", file=sys.stderr)
selftest(verbose=True)
# RAISE, do not return a status code. This function is declared -> dict and
# every caller treats it as one. Returning 2 here meant the refusal travelled
# as a truthy value that looked like a result: `recall import` printed no
# failure at all, and `main()` below still exited 0. A refusal that reports
# success is the same defect as a check that cannot fail.
raise RuntimeError("redaction selftest failed - refusing to migrate")
src = sqlite3.connect(f"file:{args.src}?mode=ro&immutable=1", uri=True)
src.row_factory = sqlite3.Row
dst = store.connect()
# WHICH TABLES THIS PARTICULAR DATABASE ACTUALLY HAS.
#
# Detection only requires the two core tables; session_summaries and
# user_prompts are optional and older stores do not have them. This code read
# them unconditionally anyway, so such a store passed detection, imported its
# observations in 5000-row batches, and THEN raised on a missing table - leaving
# a half-imported store behind. Ask the database what it has instead of assuming.
present = {r[0] for r in src.execute(
"SELECT name FROM sqlite_master WHERE type='table'")}
stats: dict[str, int] = {}
redactions = 0
# ---- sessions -----------------------------------------------------------
n = 0
for r in src.execute("SELECT * FROM sdk_sessions"):
uid = r["memory_session_id"] or f"legacy-{r['id']}"
c, h = _clean(r, ["user_prompt", "custom_title"])
redactions += h
dst.execute("""INSERT OR IGNORE INTO sessions
(session_uid,content_session_id,project,platform,title,opening_prompt,
started_at,started_at_epoch,ended_at,ended_at_epoch,status,prompt_count,legacy_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(uid, r["content_session_id"], r["project"], r["platform_source"],
c["custom_title"], c["user_prompt"], r["started_at"], r["started_at_epoch"],
r["completed_at"], r["completed_at_epoch"], r["status"],
r["prompt_counter"], r["id"]))
n += 1
stats["sessions"] = n
# ---- observations -------------------------------------------------------
TXT = ["text", "title", "subtitle", "facts", "narrative", "concepts"]
n = 0
for r in src.execute("SELECT * FROM observations"):
c, h = _clean(r, TXT)
redactions += h
dst.execute("""INSERT OR IGNORE INTO observations
(session_uid,project,type,title,subtitle,body,facts,narrative,concepts,
files_read,files_modified,prompt_number,model,agent_type,
created_at,created_at_epoch,redactions,source,legacy_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'legacy-import',?)""",
(r["memory_session_id"], r["project"], r["type"], c["title"], c["subtitle"],
c["text"], c["facts"], c["narrative"], c["concepts"],
r["files_read"], r["files_modified"], r["prompt_number"],
r["generated_by_model"], r["agent_type"],
r["created_at"], r["created_at_epoch"], h, r["id"]))
n += 1
if n % 5000 == 0:
dst.commit()
print(f" observations {n}...", flush=True)
stats["observations"] = n
# ---- summaries (optional table) -----------------------------------------
TXT = ["request", "investigated", "learned", "completed", "next_steps", "notes"]
n = 0
for r in (src.execute("SELECT * FROM session_summaries")
if "session_summaries" in present else ()):
c, h = _clean(r, TXT)
redactions += h
dst.execute("""INSERT OR IGNORE INTO summaries
(session_uid,project,request,investigated,learned,completed,next_steps,
files_read,files_edited,notes,prompt_number,created_at,created_at_epoch,
redactions,legacy_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(r["memory_session_id"], r["project"], c["request"], c["investigated"],
c["learned"], c["completed"], c["next_steps"], r["files_read"],
r["files_edited"], c["notes"], r["prompt_number"],
r["created_at"], r["created_at_epoch"], h, r["id"]))
n += 1
stats["summaries"] = n
# ---- prompts (optional table) -------------------------------------------
n = 0
for r in (src.execute("""SELECT p.*, s.memory_session_id, s.project
FROM user_prompts p LEFT JOIN sdk_sessions s
ON p.session_db_id = s.id""")
if "user_prompts" in present else ()):
c, h = _clean(r, ["prompt_text"])
redactions += h
dst.execute("""INSERT OR IGNORE INTO prompts
(session_uid,project,prompt_number,body,created_at,created_at_epoch,
redactions,source,legacy_id)
VALUES (?,?,?,?,?,?,?,'legacy-import',?)""",
(r["memory_session_id"], r["project"], r["prompt_number"],
c["prompt_text"], r["created_at"], r["created_at_epoch"], h, r["id"]))
n += 1
stats["prompts"] = n
if args.dry_run:
dst.rollback()
print("DRY RUN - rolled back")
else:
dst.execute("INSERT OR REPLACE INTO meta(k,v) VALUES('migrated_from',?)", (args.src,))
dst.commit()
dst.execute("INSERT INTO observations_fts(observations_fts) VALUES('optimize')")
dst.execute("INSERT INTO summaries_fts(summaries_fts) VALUES('optimize')")
dst.execute("INSERT INTO prompts_fts(prompts_fts) VALUES('optimize')")
dst.commit()
print("\n--- migrated ---")
for k, v in stats.items():
print(f" {k:14} {v}")
print(f" {'redactions':14} {redactions}")
return {**stats, "redactions": redactions}
def import_from(db) -> dict:
"""Entry point used by sources.py. Returns what was stored.
Defined ABOVE the __main__ guard on purpose: below it, `sys.exit(main())` means
this name is never bound when the file runs as a script, and a reader checking
whether the entry point exists has to know that to get the right answer.
"""
import argparse as _a
return _run(_a.Namespace(src=str(db), dry_run=False))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--src", default=SRC)
ap.add_argument("--dry-run", action="store_true")
a = ap.parse_args()
try:
_run(a)
except Exception as exc:
# Previously this returned 0 whatever happened, including the refusal above.
print(f"migration failed: {exc}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
sys.exit(main())