@@ -1567,6 +1567,159 @@ def _cmd_audit_pinned(args: argparse.Namespace) -> int:
15671567 return 0 if report .passed else 1
15681568
15691569
1570+ def _cmd_doctor (args : argparse .Namespace ) -> int :
1571+ """Diagnose + repair common workspace drifts.
1572+
1573+ `mm doctor` (no flag) → read-only check; reports drift but does
1574+ not modify state. Add a flag to actually repair.
1575+
1576+ --rebuild-cache: copy any Postgres-only blocks into the SQLite
1577+ recall cache so both stores carry the same id set. Safe; does
1578+ not touch Postgres.
1579+
1580+ --migrate-recall-log: add the v2-schema columns (`intent_type`,
1581+ `stage_counts`) to the SQLite `retrieval_log` table for
1582+ workspaces created before 2026-04 where the auto-migrate fired
1583+ and silently skipped. No-op on fresh DBs.
1584+ """
1585+ import os as _os
1586+ import sqlite3 as _sqlite3
1587+ import json as _json
1588+
1589+ ws = _workspace ()
1590+ report : dict [str , Any ] = {"workspace" : ws , "actions" : []}
1591+ sq_path = _os .path .join (ws , ".mind-mem-index" , "recall.db" )
1592+ sq_exists = _os .path .exists (sq_path )
1593+ report ["sqlite_recall_db" ] = {"path" : sq_path , "exists" : sq_exists }
1594+
1595+ # Always: count PG vs SQLite blocks if both backends are wired.
1596+ try :
1597+ from mind_mem .storage import get_block_store
1598+
1599+ bs = get_block_store (ws )
1600+ store_class = type (bs ).__name__
1601+ report ["block_store_class" ] = store_class
1602+ except Exception as exc :
1603+ report ["block_store_error" ] = str (exc )
1604+ store_class = ""
1605+ bs = None
1606+
1607+ pg_ids : set [str ] = set ()
1608+ if store_class == "PostgresBlockStore" and bs is not None :
1609+ try :
1610+ import psycopg
1611+
1612+ with psycopg .connect (bs ._dsn ) as conn , conn .cursor () as cur :
1613+ cur .execute (f"SELECT id FROM { bs ._schema } .blocks WHERE active" )
1614+ pg_ids = {r [0 ] for r in cur .fetchall ()}
1615+ report ["postgres_active_blocks" ] = len (pg_ids )
1616+ except Exception as exc :
1617+ report ["postgres_count_error" ] = str (exc )
1618+
1619+ sq_ids : set [str ] = set ()
1620+ if sq_exists :
1621+ try :
1622+ sq = _sqlite3 .connect (sq_path )
1623+ sq .row_factory = _sqlite3 .Row
1624+ sq_ids = {r [0 ] for r in sq .execute ("SELECT id FROM blocks" ).fetchall ()}
1625+ report ["sqlite_cache_blocks" ] = len (sq_ids )
1626+ sq .close ()
1627+ except Exception as exc :
1628+ report ["sqlite_count_error" ] = str (exc )
1629+
1630+ pg_only = pg_ids - sq_ids
1631+ sq_only = sq_ids - pg_ids
1632+ report ["pg_only_count" ] = len (pg_only )
1633+ report ["sqlite_only_count" ] = len (sq_only )
1634+ report ["in_sync" ] = (len (pg_only ) == 0 and len (sq_only ) == 0 )
1635+
1636+ # --migrate-recall-log: schema-drift fix
1637+ if args .migrate_recall_log and sq_exists :
1638+ try :
1639+ sq = _sqlite3 .connect (sq_path )
1640+ cols = {r [1 ] for r in sq .execute ("PRAGMA table_info(retrieval_log)" ).fetchall ()}
1641+ added : list [str ] = []
1642+ if "intent_type" not in cols :
1643+ sq .execute ("ALTER TABLE retrieval_log ADD COLUMN intent_type TEXT DEFAULT ''" )
1644+ added .append ("intent_type" )
1645+ if "stage_counts" not in cols :
1646+ sq .execute ("ALTER TABLE retrieval_log ADD COLUMN stage_counts TEXT DEFAULT '{}'" )
1647+ added .append ("stage_counts" )
1648+ try :
1649+ sq .execute ("CREATE INDEX IF NOT EXISTS idx_rlog_intent ON retrieval_log(intent_type)" )
1650+ except _sqlite3 .OperationalError :
1651+ pass
1652+ sq .commit ()
1653+ sq .close ()
1654+ report ["actions" ].append ({"migrate_recall_log" : {"added_columns" : added }})
1655+ except Exception as exc :
1656+ report ["actions" ].append ({"migrate_recall_log" : {"error" : str (exc )}})
1657+
1658+ # --rebuild-cache: copy PG-only blocks into SQLite
1659+ if args .rebuild_cache and pg_only and bs is not None and sq_exists :
1660+ try :
1661+ import psycopg
1662+
1663+ written = 0
1664+ errors = 0
1665+ sq = _sqlite3 .connect (sq_path )
1666+ with psycopg .connect (bs ._dsn ) as conn , conn .cursor () as cur :
1667+ cur .execute (
1668+ f"SELECT id, file_path, content, metadata, created_at FROM { bs ._schema } .blocks "
1669+ f"WHERE active AND id = ANY(%s)" ,
1670+ (list (pg_only ),),
1671+ )
1672+ for bid , fpath , content , metadata , created_at in cur .fetchall ():
1673+ md = metadata or {}
1674+ if isinstance (md , str ):
1675+ try :
1676+ md = _json .loads (md )
1677+ except _json .JSONDecodeError :
1678+ md = {}
1679+ btype_raw = md .get ("type" ) or md .get ("Type" ) or (bid .split ("-" )[0 ] if "-" in bid else "block" )
1680+ btype = {"D" : "decision" , "I" : "incident" , "C" : "code" , "A" : "adr" , "P" : "perf" }.get (
1681+ btype_raw [:1 ].upper (), btype_raw .lower () or "block"
1682+ )
1683+ json_blob = _json .dumps (
1684+ {
1685+ "id" : bid ,
1686+ "content" : content or "" ,
1687+ "metadata" : md ,
1688+ "created_at" : created_at .isoformat () if created_at else "" ,
1689+ }
1690+ )
1691+ try :
1692+ sq .execute (
1693+ "INSERT OR REPLACE INTO blocks "
1694+ "(id, type, file, line, status, date, speaker, tags, dia_id, parent_id, json_blob) "
1695+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ,
1696+ (
1697+ bid ,
1698+ btype ,
1699+ fpath or "" ,
1700+ 0 ,
1701+ md .get ("Status" , md .get ("status" , "active" )) or "active" ,
1702+ created_at .strftime ("%Y-%m-%d" ) if created_at else "" ,
1703+ md .get ("Speaker" , "" ) or "" ,
1704+ _json .dumps (md .get ("Tags" , md .get ("tags" , []))) or "" ,
1705+ md .get ("DiaId" , "" ) or "" ,
1706+ md .get ("ParentId" , "" ) or "" ,
1707+ json_blob ,
1708+ ),
1709+ )
1710+ written += 1
1711+ except _sqlite3 .OperationalError :
1712+ errors += 1
1713+ sq .commit ()
1714+ sq .close ()
1715+ report ["actions" ].append ({"rebuild_cache" : {"written" : written , "errors" : errors }})
1716+ except Exception as exc :
1717+ report ["actions" ].append ({"rebuild_cache" : {"error" : str (exc )}})
1718+
1719+ print (json .dumps (report , indent = 2 , default = str ))
1720+ return 0 if report .get ("in_sync" , False ) or args .rebuild_cache or args .migrate_recall_log else 1
1721+
1722+
15701723def _cmd_verify_model (args : argparse .Namespace ) -> int :
15711724 from mind_mem .model_signing import ED25519_PUBLIC_KEY_BYTES , verify_model
15721725
@@ -1762,6 +1915,27 @@ def build_parser() -> argparse.ArgumentParser:
17621915 p_install_model .add_argument ("--dry-run" , action = "store_true" )
17631916 p_install_model .set_defaults (func = _cmd_install_model )
17641917
1918+ # doctor — diagnose + repair common workspace drifts
1919+ p_doctor = sub .add_parser (
1920+ "doctor" ,
1921+ help = (
1922+ "Diagnose workspace state (block-store parity, recall-log "
1923+ "schema drift). Add --rebuild-cache or --migrate-recall-log "
1924+ "to actually repair."
1925+ ),
1926+ )
1927+ p_doctor .add_argument (
1928+ "--rebuild-cache" ,
1929+ action = "store_true" ,
1930+ help = "Copy Postgres-only blocks into the SQLite recall cache." ,
1931+ )
1932+ p_doctor .add_argument (
1933+ "--migrate-recall-log" ,
1934+ action = "store_true" ,
1935+ help = "Add v2-schema columns (intent_type, stage_counts) to the SQLite retrieval_log." ,
1936+ )
1937+ p_doctor .set_defaults (func = _cmd_doctor )
1938+
17651939 # vault namespace
17661940 p_vault = sub .add_parser ("vault" , help = "Vault sync subcommands." )
17671941 vsub = p_vault .add_subparsers (dest = "vault_cmd" , required = True )
0 commit comments