#!/usr/bin/env bash # mempalace-census — RFC 002 Phase A: classify a palace by what a join could move. # # Answers "what would joining THIS palace into the primary actually move, and # what dedupes it?" before any writer exists. Read-only: opens every sqlite file # with `mode=ro` and never writes, so it is safe to run against a live palace # while `mempalace-serve` is up. # # Classification (RFC 002 §2), by descending signal strength: # DIARY metadata type='diary_entry' → replay + §7.6 suffix skip # MINED source_file set / id_recipe → RE-MINE on the target, never # replay (ids are path-derived, # so replay duplicates) # AGENT-AUTHORED neither → replay, idempotent by content id # # It also self-verifies rather than trusting the docs. For every classified # drawer it recomputes the upstream ID from reassembled content and compares to # the stored ID. That checks three things at once: the ID recipe, the chunk # reassembly order, and the classification. A mismatch rate above ~0 means one # of those assumptions is wrong for this palace — investigate before joining. # # ⚠ Recipe note: upstream's ids.py DOCSTRINGS claim the hash input is # f"{wing}|{room}|{content}", and ids.py:31 defines _DELIM = "|". Both are # misleading — _DELIM is dead code and _delimited_sha256() actually # length-prefixes each part: "".join(f"{len(p)}:{p}"). Verified empirically: # length-prefixed reproduces real IDs 5/5, pipe-joined 0/5. Diary IDs are # different again — a PLAIN sha256(entry)[:12], not length-prefixed. # # This reads a palace on LOCAL DISK. It is not a client of a remote palace and # deliberately ignores MEMPALACE_REMOTE_URL — pass --palace to point at a copy # rsynced from another machine. # # Usage: # mempalace-census # default palace, human report # mempalace-census --palace /mnt/tor-ms22/palace # mempalace-census --json > manifest.json # machine-readable, feeds Phase B/C # mempalace-census --no-verify-ids # skip recompute (faster on huge palaces) set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────────────── PALACE="${MEMPALACE_PALACE:-$HOME/.mempalace/palace}" KG="" FORMAT="text" VERIFY="1" usage() { sed -n '2,36p' "$0" | sed 's/^# \{0,1\}//' } # ── Argument parsing ────────────────────────────────────────────────────────── while [[ $# -gt 0 ]]; do case "$1" in -h|--help) usage; exit 0 ;; --palace) PALACE="${2:?--palace needs a path}"; shift 2 ;; --kg) KG="${2:?--kg needs a path}"; shift 2 ;; --json) FORMAT="json"; shift ;; --no-verify-ids) VERIFY="0"; shift ;; *) echo "mempalace-census: unknown argument '$1' (try --help)" >&2; exit 2 ;; esac done # ── Path resolution ─────────────────────────────────────────────────────────── DB="$PALACE/chroma.sqlite3" if [[ ! -f "$DB" ]]; then echo "mempalace-census: no chroma.sqlite3 under '$PALACE'" >&2 echo " pass --palace /path/to/palace (the dir CONTAINING chroma.sqlite3)" >&2 exit 2 fi # KG lives beside the palace dir, not inside it. [[ -n "$KG" ]] || KG="$(cd "$(dirname "$PALACE")" && pwd)/knowledge_graph.sqlite3" if [[ "$FORMAT" == "text" && -n "${MEMPALACE_REMOTE_URL:-}" ]]; then echo "note: MEMPALACE_REMOTE_URL is set but ignored — this tool reads local disk." >&2 echo " censusing: $DB" >&2 fi # ── Census ──────────────────────────────────────────────────────────────────── PALACE_DB="$DB" KG_DB="$KG" FMT="$FORMAT" VERIFY="$VERIFY" python3 - <<'PY' import hashlib, json, os, re, sqlite3, sys from collections import Counter, defaultdict DB, KG = os.environ["PALACE_DB"], os.environ["KG_DB"] FMT, VERIFY = os.environ["FMT"], os.environ["VERIFY"] == "1" CHUNK_RE = re.compile(r"_chunk_(\d+)$") def ro(path): return sqlite3.connect(f"file:{path}?mode=ro", uri=True) # Upstream ids.py::_delimited_sha256 — length-prefixed, NOT delimiter-joined. def drawer_hash(parts, trunc=24): key = "".join(f"{len(str(p))}:{p}" for p in parts).encode() return hashlib.sha256(key).hexdigest()[:trunc] con = ro(DB) # Both collections share one sqlite file. Filtering by collection is mandatory: # an embeddings-wide query over-counts by the closet population (~10%). counts_by_collection = dict( con.execute( "SELECT c.name, COUNT(*) FROM embeddings e " "JOIN segments s ON s.id = e.segment_id " "JOIN collections c ON c.id = s.collection GROUP BY c.name" ).fetchall() ) rows = defaultdict(dict) for eid, key, sval, ival in con.execute( "SELECT e.embedding_id, m.key, m.string_value, m.int_value " "FROM embeddings e " "JOIN segments s ON s.id = e.segment_id " "JOIN collections c ON c.id = s.collection " "JOIN embedding_metadata m ON m.id = e.id " "WHERE c.name = 'mempalace_drawers'" ): # Chroma splits metadata by type across columns — numeric values (chunk_index, # source_mtime, line_start, normalize_version) land in int_value and leave # string_value NULL. Reading only string_value silently nulls every numeric # key, which made the miner-marker cross-check below report 100% conflict. rows[eid][key] = sval if sval is not None else ival # Collapse chunk rows into parent drawers. A chunked drawer has NO parent row # (verified), so the parent is the id with the _chunk_NNNNNN suffix stripped. parents = defaultdict(lambda: {"chunks": {}, "meta": None}) for eid, meta in rows.items(): m = CHUNK_RE.search(eid) base = eid[: m.start()] if m else eid idx = int(m.group(1)) if m else (meta.get("chunk_index") or 0) p = parents[base] p["chunks"][idx] = meta.get("chroma:document") or "" # Keep the lowest-index row's metadata as canonical for the parent. if p["meta"] is None or idx == 0: p["meta"] = meta def classify(base, meta): if meta.get("type") == "diary_entry" or base.startswith("diary_"): return "diary" # id_recipe is NOT a mined-only marker — the server stamps 'v3' on every # v3 id, content-hashed ones included. Using it here misclassified all 60 # agent-authored drawers in the reference palace as mined, which is the # dangerous direction: Phase C would try to re-mine drawers that have no # source file and silently drop them. A non-empty source_file is the real # discriminator (note the miner writes '' rather than omitting the key, so # presence-of-key is not enough — it must be truthy after strip()). if (meta.get("source_file") or "").strip(): return "mined" return "agent_authored" cls = Counter() wings = Counter() months = Counter() machines = Counter() agents = Counter() verify = {"checked": 0, "match": 0, "mismatch": 0, "drift": 0, "samples": [], "drift_samples": []} manifest = {"agent_authored": [], "diary": []} signal_conflicts = [] for base, p in sorted(parents.items()): meta = p["meta"] or {} kind = classify(base, meta) cls[kind] += 1 wings[meta.get("wing") or "?"] += 1 if meta.get("filed_at"): months[str(meta["filed_at"])[:7]] += 1 if meta.get("source_machine"): machines[meta["source_machine"]] += 1 if meta.get("added_by"): agents[meta["added_by"]] += 1 # Cross-check the classification against the miner's OWN markers # (source_mtime / normalize_version are written by the miner and by nothing # else). A split here means this palace has a shape the classifier hasn't # been taught, and the counts above are soft. miner_marked = bool(meta.get("source_mtime") or meta.get("normalize_version")) if miner_marked != (kind == "mined"): signal_conflicts.append(base) if kind == "mined": continue content = "".join(p["chunks"][i] for i in sorted(p["chunks"])) wing, room = meta.get("wing") or "", meta.get("room") or "" if kind == "agent_authored": expect = f"drawer_{wing}_{room}_{drawer_hash((wing, room, content))}" drifted = expect != base if VERIFY: verify["checked"] += 1 # A mismatch here is NOT a broken recipe — update_drawer preserves the # original id while rewriting (and re-chunking) content, so an edited # drawer's content hash legitimately stops reproducing its id. Named # separately because it breaks one obvious Phase C strategy: you # cannot "recompute the content id and check whether the target has # it" — for drifted drawers that lookup misses and you duplicate. # Replay by STORED id. if drifted: verify["drift"] += 1 if len(verify["drift_samples"]) < 5: verify["drift_samples"].append({"stored": base, "recomputed": expect}) else: verify["match"] += 1 manifest["agent_authored"].append( {"id": base, "wing": wing, "room": room, "chars": len(content), "chunks": len(p["chunks"]), "filed_at": meta.get("filed_at"), "added_by": meta.get("added_by"), "content_id": expect, "edited_since_filing": drifted} ) else: # diary — id suffix is a PLAIN sha256(entry)[:12] suffix = base.rsplit("_", 1)[-1] recomputed = hashlib.sha256(content.encode()).hexdigest()[:12] if VERIFY: verify["checked"] += 1 if suffix == recomputed: verify["match"] += 1 else: verify["mismatch"] += 1 if len(verify["samples"]) < 5: verify["samples"].append({"stored": base, "recomputed_suffix": recomputed}) manifest["diary"].append( {"id": base, "wing": wing, "chars": len(content), "chunks": len(p["chunks"]), "agent": meta.get("agent"), "topic": meta.get("topic"), "date": meta.get("date"), "dedup_suffix": suffix, "suffix_verified": suffix == recomputed} ) # ── Knowledge graph ─────────────────────────────────────────────────────────── kg = {"present": os.path.isfile(KG)} if kg["present"]: k = ro(KG) try: kg["open_facts"] = k.execute("SELECT COUNT(*) FROM triples WHERE valid_to IS NULL").fetchone()[0] kg["closed_facts"] = k.execute("SELECT COUNT(*) FROM triples WHERE valid_to IS NOT NULL").fetchone()[0] kg["entities"] = k.execute("SELECT COUNT(*) FROM entities").fetchone()[0] kg["predicates"] = dict( k.execute("SELECT predicate, COUNT(*) FROM triples GROUP BY 1 ORDER BY 2 DESC LIMIT 10").fetchall() ) except sqlite3.Error as e: kg["error"] = str(e) report = { "palace": DB, "kg": KG, "rows_by_collection": counts_by_collection, "parent_drawers": sum(cls.values()), "classes": dict(cls), "replay_surface": cls["diary"] + cls["agent_authored"], "by_wing": dict(wings.most_common()), "filed_at_by_month": dict(sorted(months.items())), "source_machine": dict(machines), "added_by": dict(agents.most_common()), "id_verification": verify if VERIFY else "skipped", "signal_conflicts": len(signal_conflicts), "knowledge_graph": kg, "manifest": manifest, } if FMT == "json": print(json.dumps(report, indent=2, sort_keys=False)) sys.exit(0) # ── Human report ────────────────────────────────────────────────────────────── def bar(n, total, width=28): return "█" * max(1, round(width * n / total)) if n and total else "" print(f"\n palace : {DB}") print(f" kg : {KG}{'' if kg['present'] else ' (absent)'}") print("\n ── rows per collection ─────────────────────────────────────") for name, n in sorted(counts_by_collection.items()): note = " ← derived at mine time, NOT joinable" if "closet" in name else "" print(f" {name:<20} {n:>7}{note}") total = sum(cls.values()) print(f"\n ── parent drawers: {total} ───────────────────────────────────") labels = { "mined": "MINED re-mine on target, never replay", "diary": "DIARY replay + §7.6 suffix skip", "agent_authored": "AGENT-AUTHORED replay, idempotent by content id", } for k in ("mined", "diary", "agent_authored"): n = cls.get(k, 0) pct = 100.0 * n / total if total else 0 print(f" {n:>7} {pct:>5.1f}% {labels[k]}") print(f"\n → REPLAY SURFACE: {report['replay_surface']} records " f"({100.0 * report['replay_surface'] / total if total else 0:.1f}% of the palace)") if VERIFY: v = verify state = "OK" if v["mismatch"] == 0 else "⚠ MISMATCH" print(f"\n ── id recipe / reassembly self-check: {state} ─────────────") print(f" recomputed {v['checked']} ids — {v['match']} reproduce their stored id, " f"{v['mismatch']} unexplained") for s in v["samples"]: print(f" stored: {s.get('stored')}") print(f" recomputed: {s.get('recomputed') or s.get('recomputed_suffix')}") if v["drift"]: print(f"\n {v['drift']} agent-authored drawers EDITED SINCE FILING " f"(content hash no longer reproduces the id).") print(" update_drawer keeps the id and re-chunks, so this is expected — but it") print(" means Phase C must replay by STORED id. Recomputing the content id and") print(" probing the target for it would miss these and duplicate them.") for s in v["drift_samples"][:3]: print(f" {s['stored']}") if signal_conflicts: print(f"\n ⚠ {len(signal_conflicts)} drawers where the class disagrees with the miner's") print(" own markers (source_mtime / normalize_version) — classifier needs teaching") print("\n ── by wing ─────────────────────────────────────────────────") for w, n in wings.most_common(10): print(f" {n:>7} {w}") if months: print("\n ── filed_at spread (an MCP replay would flatten all of this) ") mx = max(months.values()) for m, n in sorted(months.items()): print(f" {m} {n:>6} {bar(n, mx)}") if machines: print("\n ── source_machine ──────────────────────────────────────────") for m, n in machines.most_common(): print(f" {n:>7} {m}") if kg["present"] and "error" not in kg: print("\n ── knowledge graph ─────────────────────────────────────────") print(f" {kg['entities']:>7} entities") print(f" {kg['open_facts']:>7} open facts (server guard dedupes → replay as-is)") print(f" {kg['closed_facts']:>7} closed facts (NO server guard → client pre-query)") elif kg.get("error"): print(f"\n ⚠ knowledge graph unreadable: {kg['error']}") print("\n next: --json > manifest.json feeds RFC 002 Phase B/C.\n") PY