29e660e18f
Staging default moves out of ~/.cache to <palace-root>/pi-stage (pi) and <palace-root>/opencode-stage (opencode), resolved with mempalace's own palace-path precedence ($MEMPALACE_PALACE_PATH -> $MEMPAL_PALACE_PATH -> ~/.mempalace/config.json -> ~/.mempalace/palace), then dirname. Why: the convos miner keys dedup on the *staged* path, so a wiped stage plus a sync scoped to include it prunes the drawers mined from those sources -- deleting memories, not a cache. Under ~/.cache that state was reachable by anything treating a cache as disposable. Staging inside the palace makes the coupling structural: the stage cannot be wiped without touching the palace itself. Overrides ($MEMPALACE_PI_STAGE / $MEMPALACE_SESSION_STAGE, --stage) are unchanged. Note the old default had never been created on any host, so this closed a latent hazard, not a live one. Measured, and the docs now claim only this much: sync prunes only within the scope it is given -- wing-only, 1299 scanned / 1299 out of scope / 0 removed; scoped at the palace root, 651 kept / 648 out of scope. The previous blanket "sync prunes every drawer" wording overstated it, which is a liability: the next reader disproves the overstatement and discards the real constraint with it. Also in this change: - cron log dir ~/.cache/mempalace-session -> ~/.cache/mempalace-logs. The stage left that namespace, so the old name now read as "the stage". - AGENTS.md: the convos miner *does* check mtime (verified against upstream convo_miner.py); the previous "no mtime check" claim was wrong. - smoke-test assertions use `mktemp -d` for --sessions-dir. One pointed at /tmp, which still held earlier synthetic transcripts, so a --dry-run exported a fake session into the real stage: --dry-run skips the mine, not the export. docs/phase-1-exposure-runbook.md -- the newt/DNS/auth step that RFC 001 and the synlig runbook leave open (runbook section 4, items 2 and 5). Port 8765 at /mcp, newt targets 172.17.0.1, and the authentication is the single shared bearer token (RFC 6.2, decided 2026-08-09) rather than per-device proxy users. The latter cannot work today: mempalace validates exactly one token, and Pangolin's SSO/PIN/password are browser-shaped while every client here is a headless JSON-RPC POST -- enabling that protection breaks the clients it protects. The per-device axis that *does* exist is the feeder's SSH key + per-device inbox. New finding recorded there: a loopback bind does not merely 403 behind a tunnel (already known, runbook 2.4) -- it also silently starts the server with no token at all, because auto-minting is gated on the bind being non-loopback. extensions/pi/README.md: the HTTP transport IS authenticated as of mempalace 3.6.0; the "sessionless and unauthenticated" note dated from the v1.3.0 era. Closes the RFC section 8 Phase-0 hygiene item.
459 lines
18 KiB
Bash
Executable File
459 lines
18 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# mempalace-session — mine opencode session history into MemPalace
|
|
#
|
|
# Opencode persists every session (verbatim user/assistant turns + tool calls)
|
|
# in a local SQLite DB at ~/.local/share/opencode/opencode.db. There is
|
|
# currently no opencode session-stopping hook upstream, so the diary-based
|
|
# auto-save is best-effort; this wrapper closes the gap by mining the SQLite
|
|
# directly.
|
|
#
|
|
# Strategy:
|
|
# 1. Read opencode.db and export each qualifying session to a Claude Code
|
|
# JSONL file (format the mempalace normalizer already understands).
|
|
# 2. Stage exports under <palace-root>/opencode-stage/<wing>/ (override with
|
|
# MEMPALACE_SESSION_STAGE).
|
|
# 3. Run `mempalace mine --mode convos` against the staging dir.
|
|
#
|
|
# Dedup: mempalace convos mode keys on source_file (absolute staging path).
|
|
# The staging path is deterministic (per-wing under the palace root) so re-runs
|
|
# are idempotent as long as session content hasn't changed.
|
|
#
|
|
# Staging location: because dedup keys on the staged path, wiping the stage
|
|
# leaves the palace holding drawers whose source files look deleted, and
|
|
# `mempalace sync` prunes exactly those when they fall inside the scope it is
|
|
# given. The stage therefore lives next to the palace it feeds (resolved via
|
|
# $MEMPALACE_PALACE_PATH → $MEMPAL_PALACE_PATH → ~/.mempalace/config.json →
|
|
# ~/.mempalace/palace), so stage and dedup keys share one lifetime and the
|
|
# dangerous state — palace survives, stage does not — cannot be reached by
|
|
# wiping something that merely looks disposable. It used to default under
|
|
# ~/.cache, which is disposable on exactly the hosts where this runs
|
|
# unattended. Override with MEMPALACE_SESSION_STAGE only if the target is at
|
|
# least as durable as the palace.
|
|
#
|
|
# Session filter: sessions with fewer than --min-messages messages (default 3)
|
|
# are skipped to avoid filing throwaway /exit'd sessions.
|
|
#
|
|
# Usage:
|
|
# mempalace-session
|
|
# mempalace-session --wing <name>
|
|
# mempalace-session --session <id>
|
|
# mempalace-session --since 2026-04-01
|
|
# mempalace-session --min-messages 6
|
|
# mempalace-session --dry-run
|
|
# mempalace-session --help
|
|
#
|
|
# Exit codes:
|
|
# 0 success
|
|
# 1 usage / argument error
|
|
# 2 opencode.db missing or unreadable
|
|
# 3 mempalace CLI not installed
|
|
# 4 mine failed
|
|
#
|
|
# Dependencies: bash, python3 (stdlib sqlite3), mempalace (v3.3.3+)
|
|
|
|
set -euo pipefail
|
|
|
|
# ── Defaults ─────────────────────────────────────────────────────────
|
|
AGENT="${USER:-mempalace}"
|
|
WING="wing_conversations"
|
|
SESSION_ID=""
|
|
SINCE=""
|
|
MIN_MESSAGES=3
|
|
DRY_RUN=0
|
|
DO_REPAIR=0
|
|
OPENCODE_DB="${OPENCODE_DB:-$HOME/.local/share/opencode/opencode.db}"
|
|
|
|
# ── Usage ────────────────────────────────────────────────────────────
|
|
usage() {
|
|
cat <<'EOF'
|
|
mempalace-session — mine opencode session history into MemPalace
|
|
|
|
Usage:
|
|
mempalace-session [options]
|
|
|
|
Options:
|
|
--wing <name> Target wing (default: wing_conversations)
|
|
--session <id> Export one session only (default: all qualifying)
|
|
--since <YYYY-MM-DD> Only sessions with time_updated on/after this date
|
|
--min-messages <N> Skip sessions with fewer than N messages (default: 3)
|
|
--agent <name> Agent name recorded on drawers (default: $USER)
|
|
--db <path> Path to opencode.db (default: $OPENCODE_DB or
|
|
~/.local/share/opencode/opencode.db)
|
|
--dry-run Export + list; do not mine into palace. Each session
|
|
is tagged [NEW] or [SKIP] based on whether its
|
|
source_file is already present in the palace.
|
|
--repair Run `mempalace repair` after mining (opt-in).
|
|
WARNING: repair does a destructive in-place HNSW
|
|
rebuild. If it races a live MCP connection or
|
|
crashes mid-rebuild, it can wipe the collection.
|
|
Only pass this from a quiet, interactive context.
|
|
Not safe for unattended cron/launchd schedules.
|
|
--no-repair (Deprecated; no-repair is now the default.)
|
|
-h, --help Show this help
|
|
|
|
Idempotency:
|
|
Re-running on the same corpus is safe. The export step always writes every
|
|
qualifying session to the cache, but the mine step dedups on source_file
|
|
path — already-filed sessions are skipped without re-embedding. A --dry-run
|
|
summary shows exactly how many of the exported files are new vs already
|
|
filed, so you can see in advance what a real run would do.
|
|
|
|
What gets mined:
|
|
- Each qualifying session → one Claude Code JSONL file
|
|
- Staged under <palace-root>/opencode-stage/<wing>/
|
|
- Filed via `mempalace mine --mode convos`
|
|
|
|
Transcript shape per session:
|
|
- Synthetic header as first user turn:
|
|
[session: <title> | <directory> | <YYYY-MM-DD> | source: opencode]
|
|
- User/assistant messages extracted from message.data + part.data
|
|
- Tool calls → Claude Code `tool_use` blocks
|
|
- Tool outputs → `tool_result` blocks (folded into the assistant turn by the
|
|
mempalace normalizer)
|
|
- `step-start` / `step-finish` parts are dropped (noise)
|
|
- `reasoning` parts prefixed with `[reasoning]` and kept as text
|
|
|
|
Dedup:
|
|
- source_file = absolute staging path (deterministic per session ID)
|
|
- Re-runs skip unchanged sessions. To force re-mining, delete the staging
|
|
dir: rm -rf <palace-root>/opencode-stage/<wing>/
|
|
(that only forces a refile; do NOT run `mempalace sync` while the stage is
|
|
missing, or the drawers mined from it get pruned instead)
|
|
|
|
Rationale:
|
|
Opencode lacks a session-stopping hook (upstream PRs #16598, #16769 still
|
|
open). Until that lands + mempalace hooks_cli.py gains an opencode harness,
|
|
this wrapper is how we get automatic session capture.
|
|
EOF
|
|
}
|
|
|
|
# ── Parse args ───────────────────────────────────────────────────────
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-h|--help) usage; exit 0 ;;
|
|
--wing) WING="${2:-}"; shift 2 ;;
|
|
--session) SESSION_ID="${2:-}"; shift 2 ;;
|
|
--since) SINCE="${2:-}"; shift 2 ;;
|
|
--min-messages) MIN_MESSAGES="${2:-}"; shift 2 ;;
|
|
--agent) AGENT="${2:-}"; shift 2 ;;
|
|
--db) OPENCODE_DB="${2:-}"; shift 2 ;;
|
|
--dry-run) DRY_RUN=1; shift ;;
|
|
--repair) DO_REPAIR=1; shift ;;
|
|
--no-repair) shift ;; # deprecated alias; no-repair is the default
|
|
--) shift; break ;;
|
|
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 1 ;;
|
|
*) echo "error: unexpected arg: $1" >&2; exit 1 ;;
|
|
esac
|
|
done
|
|
|
|
# ── Preflight ────────────────────────────────────────────────────────
|
|
if [[ ! -f "$OPENCODE_DB" ]]; then
|
|
echo "error: opencode.db not found at $OPENCODE_DB" >&2
|
|
echo " override with --db <path> or OPENCODE_DB env var" >&2
|
|
exit 2
|
|
fi
|
|
if ! command -v mempalace >/dev/null 2>&1; then
|
|
echo "error: mempalace CLI not found in PATH" >&2
|
|
exit 3
|
|
fi
|
|
if ! [[ "$MIN_MESSAGES" =~ ^[0-9]+$ ]]; then
|
|
echo "error: --min-messages must be an integer" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# ── Staging dir ──────────────────────────────────────────────────────
|
|
# Deterministic per-wing path so source_file dedup works across re-runs, and
|
|
# anchored to the palace root so the stage cannot be wiped independently of the
|
|
# dedup keys that reference it (see "Staging location" in the header).
|
|
# Mirrors mempalace config.py:palace_path() precedence, then takes the parent.
|
|
palace_root() {
|
|
python3 - <<'PY' 2>/dev/null || echo "$HOME/.mempalace"
|
|
import json, os
|
|
p = os.environ.get("MEMPALACE_PALACE_PATH") or os.environ.get("MEMPAL_PALACE_PATH")
|
|
if p:
|
|
p = os.path.abspath(os.path.expanduser(p))
|
|
else:
|
|
cfg = os.path.expanduser("~/.mempalace/config.json")
|
|
p = None
|
|
if os.path.exists(cfg):
|
|
try:
|
|
with open(cfg) as fh:
|
|
v = json.load(fh).get("palace_path")
|
|
p = os.path.expanduser(v) if v else None
|
|
except Exception:
|
|
p = None
|
|
p = p or os.path.expanduser("~/.mempalace/palace")
|
|
print(os.path.dirname(p.rstrip("/")))
|
|
PY
|
|
}
|
|
CACHE_ROOT="${MEMPALACE_SESSION_STAGE:-$(palace_root)/opencode-stage}"
|
|
STAGE="$CACHE_ROOT/$WING"
|
|
mkdir -p "$STAGE"
|
|
|
|
# ── Export sessions (Python heredoc) ────────────────────────────────
|
|
# Writes one JSONL file per qualifying session into $STAGE.
|
|
# Prints: EXPORTED <count> on stdout, plus per-session lines.
|
|
#
|
|
# If the palace is reachable, also classifies each export as NEW or ALREADY
|
|
# FILED (matching by source_file path) so --dry-run can report the true
|
|
# mine-set size, not just the export-set size. Classification is advisory
|
|
# only — the real mine step delegates dedup to `mempalace mine --mode convos`,
|
|
# which is the authoritative source of truth.
|
|
export_count=$(python3 - "$OPENCODE_DB" "$STAGE" "$SESSION_ID" "$SINCE" "$MIN_MESSAGES" <<'PY'
|
|
import sqlite3, json, sys, os
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
db_path, stage, session_filter, since, min_messages = sys.argv[1:6]
|
|
min_messages = int(min_messages)
|
|
stage = Path(stage)
|
|
|
|
# Convert --since YYYY-MM-DD to epoch ms (opencode uses ms timestamps)
|
|
since_ms = None
|
|
if since:
|
|
try:
|
|
since_ms = int(datetime.strptime(since, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() * 1000)
|
|
except ValueError:
|
|
print(f"error: --since must be YYYY-MM-DD, got {since!r}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# ── Load palace's already-filed source_files (best-effort, read-only) ──
|
|
# Key the dedup check on absolute staging path. The palace stores these in
|
|
# chroma.sqlite3 under embedding_metadata.key='source_file'. If the palace
|
|
# isn't reachable (first install, moved, permission-denied), we fall through
|
|
# to "everything is new" — the mine step will do the real dedup anyway.
|
|
already_filed = set()
|
|
# Mirror mempalace's own resolution order (config.py): MEMPALACE_PALACE_PATH,
|
|
# then the legacy MEMPAL_PALACE_PATH, then the default. NOT "MEMPALACE_PATH" —
|
|
# that name is not a mempalace concept, and reading it silently degraded this
|
|
# NEW/SKIP preview to "everything is new" wherever some other tool had set it.
|
|
palace_path = (
|
|
os.environ.get("MEMPALACE_PALACE_PATH")
|
|
or os.environ.get("MEMPAL_PALACE_PATH")
|
|
or os.path.expanduser("~/.mempalace/palace")
|
|
)
|
|
chroma_db = Path(palace_path) / "chroma.sqlite3"
|
|
if chroma_db.is_file():
|
|
try:
|
|
pcon = sqlite3.connect(f"file:{chroma_db}?mode=ro", uri=True)
|
|
for (sf,) in pcon.execute(
|
|
"SELECT DISTINCT string_value FROM embedding_metadata "
|
|
"WHERE key='source_file' AND string_value LIKE ?",
|
|
(f"{stage}%",),
|
|
):
|
|
if sf:
|
|
already_filed.add(sf)
|
|
pcon.close()
|
|
except sqlite3.Error:
|
|
pass # palace unreachable → treat all exports as new (miner will dedup)
|
|
|
|
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
|
conn.row_factory = sqlite3.Row
|
|
cur = conn.cursor()
|
|
|
|
# Select sessions
|
|
q = "SELECT * FROM session WHERE 1=1"
|
|
params = []
|
|
if session_filter:
|
|
q += " AND id = ?"
|
|
params.append(session_filter)
|
|
if since_ms is not None:
|
|
q += " AND time_updated >= ?"
|
|
params.append(since_ms)
|
|
q += " ORDER BY time_updated"
|
|
cur.execute(q, params)
|
|
sessions = [dict(r) for r in cur.fetchall()]
|
|
|
|
if not sessions:
|
|
print("EXPORTED 0")
|
|
sys.exit(0)
|
|
|
|
# Prefetch messages + parts for qualifying sessions
|
|
exported = 0
|
|
skipped_short = 0
|
|
skipped_already_filed = 0
|
|
for sess in sessions:
|
|
sid = sess["id"]
|
|
cur.execute("SELECT COUNT(*) FROM message WHERE session_id=?", (sid,))
|
|
msg_count = cur.fetchone()[0]
|
|
if msg_count < min_messages:
|
|
skipped_short += 1
|
|
continue
|
|
|
|
cur.execute(
|
|
"SELECT * FROM message WHERE session_id=? ORDER BY time_created", (sid,)
|
|
)
|
|
messages = [dict(r) for r in cur.fetchall()]
|
|
cur.execute(
|
|
"SELECT * FROM part WHERE session_id=? ORDER BY time_created", (sid,)
|
|
)
|
|
parts_by_msg: dict[str, list] = {}
|
|
for r in cur.fetchall():
|
|
d = dict(r)
|
|
parts_by_msg.setdefault(d["message_id"], []).append(d)
|
|
|
|
# Build JSONL lines
|
|
out_lines: list[dict] = []
|
|
|
|
# Synthetic header as first user turn — injects title/directory/date
|
|
# into the transcript so semantic search can find sessions by topic,
|
|
# not just by session-id filename.
|
|
title = sess.get("title") or "(untitled)"
|
|
directory = sess.get("directory") or "?"
|
|
date_str = datetime.fromtimestamp(
|
|
sess["time_created"] / 1000, tz=timezone.utc
|
|
).strftime("%Y-%m-%d")
|
|
header = f"[session: {title} | {directory} | {date_str} | source: opencode]"
|
|
out_lines.append({"type": "user", "message": {"content": header}})
|
|
|
|
for msg in messages:
|
|
mdata = json.loads(msg["data"])
|
|
role = mdata.get("role")
|
|
if role not in ("user", "assistant"):
|
|
continue
|
|
parts = parts_by_msg.get(msg["id"], [])
|
|
|
|
blocks = []
|
|
tool_results = []
|
|
for p in parts:
|
|
try:
|
|
pd = json.loads(p["data"])
|
|
except json.JSONDecodeError:
|
|
continue
|
|
t = pd.get("type")
|
|
if t == "text":
|
|
txt = (pd.get("text") or "").strip()
|
|
if txt:
|
|
blocks.append({"type": "text", "text": txt})
|
|
elif t == "tool":
|
|
# opencode tool part → tool_use block + deferred tool_result
|
|
state = pd.get("state") or {}
|
|
tool_name = pd.get("tool") or "Unknown"
|
|
call_id = pd.get("callID") or p["id"]
|
|
tool_input = state.get("input") or {}
|
|
tool_output = state.get("output")
|
|
blocks.append({
|
|
"type": "tool_use",
|
|
"id": call_id,
|
|
"name": tool_name,
|
|
"input": tool_input,
|
|
})
|
|
if tool_output:
|
|
tool_results.append({
|
|
"type": "tool_result",
|
|
"tool_use_id": call_id,
|
|
"content": str(tool_output),
|
|
})
|
|
elif t in ("step-start", "step-finish"):
|
|
continue
|
|
elif t == "reasoning":
|
|
rtext = (pd.get("text") or "").strip()
|
|
if rtext:
|
|
blocks.append({"type": "text", "text": f"[reasoning] {rtext}"})
|
|
|
|
if not blocks:
|
|
continue
|
|
|
|
# Simplify single-text-block messages to a bare string (more tolerant
|
|
# of normalizer edge cases; mempalace accepts either shape).
|
|
if len(blocks) == 1 and blocks[0]["type"] == "text":
|
|
content = blocks[0]["text"]
|
|
else:
|
|
content = blocks
|
|
|
|
out_lines.append({
|
|
"type": role,
|
|
"message": {"content": content},
|
|
})
|
|
|
|
# For assistants, follow up with a synthetic human tool_result message
|
|
# per tool call. The mempalace normalizer's `is_tool_only` branch
|
|
# folds these back into the assistant turn (see normalize.py:211-214).
|
|
if role == "assistant" and tool_results:
|
|
out_lines.append({
|
|
"type": "human",
|
|
"message": {"content": tool_results},
|
|
})
|
|
|
|
# Must have at least 2 turns for the normalizer to accept the file
|
|
if len(out_lines) < 2:
|
|
skipped_short += 1
|
|
continue
|
|
|
|
slug = sess.get("slug") or "session"
|
|
out_path = stage / f"{slug}_{sid}.jsonl"
|
|
with open(out_path, "w", encoding="utf-8") as f:
|
|
for obj in out_lines:
|
|
f.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
|
|
|
# Set mtime to session time_updated so dedup sees a stable value.
|
|
try:
|
|
ts = sess["time_updated"] / 1000
|
|
os.utime(out_path, (ts, ts))
|
|
except Exception:
|
|
pass
|
|
|
|
exported += 1
|
|
is_filed = str(out_path) in already_filed
|
|
if is_filed:
|
|
skipped_already_filed += 1
|
|
status = "SKIP" if is_filed else "NEW "
|
|
print(f" [{status}] {out_path.name} ({msg_count} msgs, {len(out_lines)} turns)",
|
|
file=sys.stderr)
|
|
|
|
print(f"EXPORTED {exported}")
|
|
print(f"ALREADY_FILED {skipped_already_filed}")
|
|
if skipped_short:
|
|
print(f"SKIPPED_SHORT {skipped_short}", file=sys.stderr)
|
|
PY
|
|
)
|
|
|
|
# Parse counts from stdout
|
|
count="$(printf '%s\n' "$export_count" | awk '/^EXPORTED / { print $2 }')"
|
|
count="${count:-0}"
|
|
already_filed="$(printf '%s\n' "$export_count" | awk '/^ALREADY_FILED / { print $2 }')"
|
|
already_filed="${already_filed:-0}"
|
|
to_file=$(( count - already_filed ))
|
|
|
|
if [[ "$count" -eq 0 ]]; then
|
|
echo "no sessions qualified for export"
|
|
exit 0
|
|
fi
|
|
|
|
echo ""
|
|
echo "Exported $count session(s) to $STAGE"
|
|
echo " $to_file new → will be filed on mine"
|
|
echo " $already_filed already filed → will be skipped (dedup by source_file)"
|
|
|
|
if [[ $DRY_RUN -eq 1 ]]; then
|
|
if [[ "$to_file" -eq 0 ]]; then
|
|
echo ""
|
|
echo "--dry-run: no new sessions to mine. A real run would skip all $count."
|
|
else
|
|
echo ""
|
|
echo "--dry-run: skipping mine step. A real run would file $to_file new session(s)."
|
|
fi
|
|
exit 0
|
|
fi
|
|
|
|
# ── Run the mine ─────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "Mining into wing '$WING'..."
|
|
if ! mempalace mine "$STAGE" --mode convos --wing "$WING" --agent "$AGENT"; then
|
|
echo "error: mempalace mine failed" >&2
|
|
exit 4
|
|
fi
|
|
|
|
# ── Repair index ─────────────────────────────────────────────────────
|
|
if [[ $DO_REPAIR -eq 1 ]]; then
|
|
echo ""
|
|
echo "WARNING: --repair runs an in-place HNSW rebuild that has wiped"
|
|
echo " live palaces on past runs. Proceeding in 3 seconds..."
|
|
sleep 3
|
|
echo "Rebuilding HNSW index..."
|
|
mempalace repair --yes
|
|
fi
|
|
|
|
echo ""
|
|
echo "Done. Wing '$WING' updated. Remember to reconnect any live MCP sessions."
|