feeders: stage beside the palace, not in ~/.cache; document Phase 1 exposure

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.
This commit is contained in:
Joakim Persson
2026-08-12 17:04:01 +02:00
parent 3626946013
commit 29e660e18f
15 changed files with 1019 additions and 66 deletions
+308 -28
View File
@@ -10,22 +10,88 @@
# Strategy (mirrors mempalace-session):
# 1. Walk ~/.pi/agent/sessions/**/*.jsonl and export each qualifying session
# to a Claude Code JSONL file (format the mempalace normalizer speaks).
# 2. Stage exports under ~/.cache/mempalace-pi-session/<wing>/.
# 2. Stage exports under $MEMPALACE_PI_STAGE/<wing> (default
# <palace-root>/pi-stage/<wing> — alongside the palace it feeds).
# 3. Run `mempalace mine --mode convos` against the staging dir.
#
# TWO PHASES (--prepare), because the palace is single-writer
# mempalace refuses a CLI mine while another process holds the palace:
# "palace ... is held by PID <n> (mempalace-mcp); wait for it to finish"
# A live pi session ALWAYS has a holder — the mempalace extension's own
# mempalace-mcp. So an unattended CLI mine only works when no session is
# live (e.g. a container-start catch-up); during a session the mine must be
# performed by the process that already holds the palace. Hence:
#
# --prepare export + stage (+ rsync in remote mode) and print
# MINE_SOURCE=<path>, without ever opening the palace.
# (default) the above, then mine it ourselves. Contention is treated as
# success-with-nothing-to-do, not failure: the holder's own
# extension will mine what we staged.
#
# The pi mempalace extension drives exactly this: it runs --prepare on
# session_shutdown and on a debounced agent_settled, then calls
# mempalace_mine on MINE_SOURCE through its existing MCP client.
#
# TRANSPORTS (--mode, default auto)
# local Mine into the local palace with the mempalace CLI.
# remote $MEMPALACE_REMOTE_URL is set, so the palace lives on another host.
# There is no remote-palace CLI — only the HTTP MCP server — and
# mempalace_mine expands its source path in the SERVER process, so
# the server cannot see this machine's staged exports. We therefore
# rsync the stage into a per-device inbox on the palace host and ask
# the server to mine its own local path. Requires
# MEMPALACE_PI_SSH_TARGET (where to rsync) and
# MEMPALACE_PI_REMOTE_PATH (what that inbox is called server-side).
#
# Labelling: every exported transcript begins with a synthetic header
# [session: <title> | <cwd> | <YYYY-MM-DD> | source: pi]
# so post-mine search results are self-identifying (pi vs opencode vs other).
#
# Dedup: mempalace convos mode keys on source_file (absolute staging path).
# Staging paths are deterministic per pi session UUID, so re-runs are
# idempotent until session content actually changes.
# Staging paths are deterministic per pi session UUID, and the export copies
# the source session's mtime onto the staged file, so re-runs are idempotent
# until session content actually changes. A GROWN session is purged and
# refiled for that source_file by the miner, so re-feeding a live session
# refreshes its drawers instead of duplicating them.
#
# Session filter: sessions with fewer than --min-messages *user+assistant*
# messages (default 3) are skipped to avoid filing single-prompt throwaways.
# Staging location: source_file dedup keys on the staged path, so if the stage
# is wiped the palace is left with drawers whose source files look deleted.
# `mempalace sync` prunes exactly those — but only within the scope it is
# given. Measured on this layout: scoped at the palace root the staged sources
# are in scope (kept 651), while a wing-only sync reports them out_of_scope and
# leaves them alone. So the data loss is conditional on how sync is invoked,
# which is far too thin a margin to rely on.
#
# The stage therefore defaults NEXT TO THE PALACE (<palace-root>/pi-stage,
# resolved the way mempalace itself resolves the palace: $MEMPALACE_PALACE_PATH
# → $MEMPAL_PALACE_PATH → ~/.mempalace/config.json → ~/.mempalace/palace).
#
# That makes the invariant structural rather than documented: the stage and the
# dedup keys that reference it share one lifetime, so the dangerous state —
# palace survives, stage does not — can no longer be reached by wiping
# something that merely looks disposable. A cache dir (the obvious choice, and
# the old default) is exactly wrong here: it persists just long enough to look
# correct, then takes the memories with it. Override with MEMPALACE_PI_STAGE
# only if the target is at least as durable as the palace.
#
# In remote mode the local stage is only a shipping buffer — dedup lives on the
# server, keyed by the server-side inbox path — so its durability is moot there.
#
# Session filter: two gates, both required.
# 1. --min-messages <N> user+assistant turns (default 4). Tool loops inflate
# assistant turns fast in pi, so a real working session clears this
# easily; a single abandoned prompt does not.
# 2. --min-assistant-chars <N> characters of assistant *text* (default 1000),
# excluding tool results. Assistant volume, not total volume: pi expands
# skills/context into the user prompt, so an abandoned session can carry a
# 13k-char "user" message answered with "Ready. What would you like to
# work on?" — total size says substantial, assistant size correctly says
# nothing happened.
#
# Usage:
# mempalace-pi-session
# mempalace-pi-session --prepare
# mempalace-pi-session --mode remote
# mempalace-pi-session --wing <name>
# mempalace-pi-session --session <uuid-prefix>
# mempalace-pi-session --since 2026-04-01
@@ -34,13 +100,16 @@
# mempalace-pi-session --help
#
# Exit codes:
# 0 success
# 0 success (including "nothing qualified", "another run holds the lock",
# and "palace held by a live session")
# 1 usage / argument error
# 2 pi sessions dir missing
# 3 mempalace CLI not installed
# 3 mempalace CLI not installed / rsync missing in remote mode
# 4 mine failed
# 5 remote transport failed (rsync or HTTP tools/call)
#
# Dependencies: bash, python3 (stdlib only), mempalace (v3.3.3+)
# Dependencies: bash, python3 (stdlib only), mempalace (v3.3.3+);
# rsync + ssh in remote mode.
set -euo pipefail
@@ -49,11 +118,50 @@ AGENT="${USER:-mempalace}"
WING="wing_conversations"
SESSION_ID=""
SINCE=""
MIN_MESSAGES=3
MIN_MESSAGES=4
MIN_ASSISTANT_CHARS=1000
DRY_RUN=0
DO_REPAIR=0
PREPARE_ONLY=0
MODE="auto"
REASON=""
PI_SESSIONS_DIR="${PI_SESSIONS_DIR:-$HOME/.pi/agent/sessions}"
# Resolve the palace ROOT (the dir holding palace/, knowledge_graph.sqlite3,
# config.json) using mempalace's own precedence, so the stage lands next to
# whichever palace this host actually feeds. Mirrors config.py:palace_path()
# (env → config.json → default) and takes the parent. Only evaluated when
# MEMPALACE_PI_STAGE is unset, so the common path costs nothing.
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
}
STAGE_ROOT="${MEMPALACE_PI_STAGE:-$(palace_root)/pi-stage}"
# Remote transport (see TRANSPORTS in the header)
REMOTE_URL="${MEMPALACE_REMOTE_URL:-}"
REMOTE_TOKEN="${MEMPALACE_REMOTE_TOKEN:-}"
SSH_TARGET="${MEMPALACE_PI_SSH_TARGET:-}"
SSH_CONFIG="${MEMPALACE_PI_SSH_CONFIG:-}"
REMOTE_PATH="${MEMPALACE_PI_REMOTE_PATH:-/data/feed}"
DEVICE="${MEMPALACE_PI_DEVICE:-$(hostname)}"
# ── Usage ────────────────────────────────────────────────────────────
usage() {
cat <<'EOF'
@@ -67,10 +175,28 @@ Options:
--session <prefix> Export one session only (match on UUID prefix)
--since <YYYY-MM-DD> Only sessions last modified on/after this date
--min-messages <N> Skip sessions with fewer than N user+assistant
turns (default: 3)
turns (default: 4)
--min-assistant-chars <N>
Skip sessions with fewer than N characters of
assistant text, tool results excluded (default: 1000).
Catches abandoned sessions whose bulk is injected
skill/context text in the user prompt.
--agent <name> Agent name recorded on drawers (default: $USER)
--sessions-dir <path> Path to pi sessions dir (default: $PI_SESSIONS_DIR
or ~/.pi/agent/sessions)
--stage <path> Staging root (default: $MEMPALACE_PI_STAGE, else
<palace-root>/pi-stage — next to the palace, so the
stage cannot be wiped independently of the dedup keys
that point at it). Exports go in <root>/<wing>.
See "Staging location" in the header before moving it.
--mode <m> auto|local|remote (default: auto — remote when
$MEMPALACE_REMOTE_URL is set)
--prepare Export + stage (+ rsync in remote mode), print
MINE_SOURCE=<path>, and stop without opening the
palace. For callers that will do the mine themselves
through a live MCP connection.
--reason <label> Label this run in its output (e.g. shutdown, tick,
container-start). Useful when triggers log to a file.
--dry-run Export + list; do not mine into palace. Each session
is tagged [NEW] or [SKIP] based on whether its
source_file is already in the palace.
@@ -102,14 +228,27 @@ Transcript shape per session:
Dedup:
- source_file = absolute staging path (deterministic per pi session UUID)
- Re-runs skip unchanged sessions. To force re-mining, delete the staging
dir: rm -rf ~/.cache/mempalace-pi-session/<wing>/
- Re-runs skip unchanged sessions; a GROWN session (mtime changed) has its
old drawers purged and is refiled, so re-feeding a live session refreshes
rather than duplicates.
- To force re-mining, delete the staging dir:
rm -rf <palace-root>/pi-stage/<wing>/
That forces a refile — but do NOT run `mempalace sync` while the stage is
missing, or the drawers mined from it get pruned instead.
Rationale:
Pi's extension ecosystem could, in principle, stream sessions into the
palace as they happen, but that requires a resident MCP connection during
every pi session. This wrapper is the batch, harness-agnostic alternative:
it reads the durable on-disk JSONL and mines it on a schedule.
Two complementary paths feed the palace from pi, and they cover different
failure modes:
- The pi mempalace bridge extension (extensions/pi/mempalace.ts) drives
this script with --prepare on session_shutdown and on a debounced
agent_settled, then mines through its own live MCP connection. That is
the primary path: it needs no scheduling and it is the only way to write
while a session holds the palace.
- Running this script directly is the batch/recovery path: a
container-start or host-level catch-up that picks up transcripts nothing
mined at the time — notably after a SIGKILL, where no pi handler runs at
all. It reads the durable on-disk JSONL, so it does not care whether the
session that produced it exited cleanly.
EOF
}
@@ -121,6 +260,11 @@ while [[ $# -gt 0 ]]; do
--session) SESSION_ID="${2:-}"; shift 2 ;;
--since) SINCE="${2:-}"; shift 2 ;;
--min-messages) MIN_MESSAGES="${2:-}"; shift 2 ;;
--min-assistant-chars) MIN_ASSISTANT_CHARS="${2:-}"; shift 2 ;;
--stage) STAGE_ROOT="${2:-}"; shift 2 ;;
--mode) MODE="${2:-}"; shift 2 ;;
--prepare) PREPARE_ONLY=1; shift ;;
--reason) REASON="${2:-}"; shift 2 ;;
--agent) AGENT="${2:-}"; shift 2 ;;
--sessions-dir) PI_SESSIONS_DIR="${2:-}"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
@@ -138,32 +282,67 @@ if [[ ! -d "$PI_SESSIONS_DIR" ]]; then
echo " override with --sessions-dir <path> or PI_SESSIONS_DIR env var" >&2
exit 2
fi
if ! command -v mempalace >/dev/null 2>&1; then
case "$MODE" in
auto) if [[ -n "$REMOTE_URL" ]]; then MODE="remote"; else MODE="local"; fi ;;
local|remote) ;;
*) echo "error: --mode must be auto|local|remote" >&2; exit 1 ;;
esac
# The mempalace CLI is only needed when WE do the mine. --prepare never opens
# the palace, and remote mode talks to the server over HTTP.
if [[ $PREPARE_ONLY -eq 0 && "$MODE" == "local" ]] && ! 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
if [[ "$MODE" == "remote" ]]; then
command -v rsync >/dev/null 2>&1 || { echo "error: rsync not found (needed for --mode remote)" >&2; exit 3; }
if [[ -z "$SSH_TARGET" ]]; then
echo "error: MEMPALACE_PI_SSH_TARGET unset (needed for --mode remote)" >&2
exit 1
fi
# The devbox generates a dedicated LAN-jump key/config; prefer it if present.
if [[ -z "$SSH_CONFIG" && -f "$HOME/.ssh-local/config" ]]; then
SSH_CONFIG="$HOME/.ssh-local/config"
fi
fi
for _n in MIN_MESSAGES MIN_ASSISTANT_CHARS; do
if ! [[ "${!_n}" =~ ^[0-9]+$ ]]; then
_flag="--$(printf '%s' "${_n,,}" | tr '_' '-')"
echo "error: $_flag must be an integer" >&2
exit 1
fi
done
# ── Staging dir ──────────────────────────────────────────────────────
CACHE_ROOT="${XDG_CACHE_HOME:-$HOME/.cache}/mempalace-pi-session"
STAGE="$CACHE_ROOT/$WING"
# Deterministic per-wing path so source_file dedup works across re-runs. See
# "Staging location" in the header for why this should not be disposable.
STAGE="${STAGE_ROOT%/}/$WING"
mkdir -p "$STAGE"
[[ -n "$REASON" ]] && echo "mempalace-pi-session [$REASON] mode=$MODE stage=$STAGE"
# ── Single-writer guard ──────────────────────────────────────────────
# Non-blocking: overlapping triggers (a session_shutdown landing on top of a
# debounced mid-session run) must not queue or race. Losing a run is harmless
# — the next one re-exports from scratch.
exec 9>"${STAGE_ROOT%/}/.lock"
if command -v flock >/dev/null 2>&1 && ! flock -n 9; then
echo "another mempalace-pi-session run holds the lock; skipping"
exit 0
fi
# ── Export sessions (Python heredoc) ────────────────────────────────
# Parses pi JSONL files and writes Claude Code JSONL per session into $STAGE.
# Also classifies each export as NEW/ALREADY FILED (by source_file lookup)
# so --dry-run reports the real mine-set size. Classification is advisory;
# `mempalace mine --mode convos` is still the authoritative dedup.
export_count=$(python3 - "$PI_SESSIONS_DIR" "$STAGE" "$SESSION_ID" "$SINCE" "$MIN_MESSAGES" <<'PY'
export_count=$(python3 - "$PI_SESSIONS_DIR" "$STAGE" "$SESSION_ID" "$SINCE" "$MIN_MESSAGES" "$MIN_ASSISTANT_CHARS" <<'PY'
import json, os, sqlite3, sys
from datetime import datetime, timezone
from pathlib import Path
sessions_dir, stage, session_filter, since, min_messages = sys.argv[1:6]
sessions_dir, stage, session_filter, since, min_messages, min_assistant_chars = sys.argv[1:7]
min_messages = int(min_messages)
min_assistant_chars = int(min_assistant_chars)
stage = Path(stage)
sessions_dir = Path(sessions_dir)
@@ -178,7 +357,15 @@ if since:
# ── Load palace's already-filed source_files (best-effort, read-only) ──
already_filed = set()
palace_path = os.environ.get("MEMPALACE_PATH", os.path.expanduser("~/.mempalace/palace"))
# 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:
@@ -282,6 +469,7 @@ if session_filter:
exported = 0
skipped_short = 0
skipped_quiet = 0
skipped_malformed = 0
skipped_already_filed = 0
@@ -318,6 +506,7 @@ for path in paths:
continue
title = derive_title(entries, fallback=session_uuid[:8])
assistant_chars = 0
out_lines = []
out_lines.append({
"type": "user",
@@ -337,6 +526,7 @@ for path in paths:
out_lines.append({"type": "user", "message": {"content": text}})
elif role == "assistant":
text, tool_uses, _ = extract_text(msg.get("content"))
assistant_chars += len(text.strip())
blocks = []
if text.strip():
blocks.append({"type": "text", "text": text})
@@ -406,6 +596,17 @@ for path in paths:
skipped_short += 1
continue
# Assistant *text* volume, tool results excluded: the signal that the
# session actually did something, independent of how much injected
# skill/context text inflated the user side.
if assistant_chars < min_assistant_chars:
skipped_quiet += 1
print(
f" [QUIET] {path.name} ({turn_count} turns, {assistant_chars} assistant chars)",
file=sys.stderr,
)
continue
out_path = stage / f"pi_{session_uuid}.jsonl"
with out_path.open("w", encoding="utf-8") as f:
for obj in out_lines:
@@ -428,6 +629,8 @@ print(f"EXPORTED {exported}")
print(f"ALREADY_FILED {skipped_already_filed}")
if skipped_short:
print(f"SKIPPED_SHORT {skipped_short}", file=sys.stderr)
if skipped_quiet:
print(f"SKIPPED_QUIET {skipped_quiet}", file=sys.stderr)
if skipped_malformed:
print(f"SKIPPED_MALFORMED {skipped_malformed}", file=sys.stderr)
PY
@@ -461,12 +664,89 @@ if [[ $DRY_RUN -eq 1 ]]; then
exit 0
fi
# ── Ship to the palace host (remote mode only) ───────────────────────
# mempalace_mine expands its source path in the SERVER process, so in remote
# mode the exports have to physically exist over there. rsync --update is the
# idempotent half; the mine is the other half.
MINE_SOURCE="$STAGE"
if [[ "$MODE" == "remote" ]]; then
ssh_cmd="ssh"
[[ -n "$SSH_CONFIG" ]] && ssh_cmd="ssh -F $SSH_CONFIG"
echo ""
echo "Shipping stage to ${SSH_TARGET%/}/$DEVICE/ ..."
if ! rsync -a --update --no-owner --no-group \
-e "$ssh_cmd" \
--include='*.jsonl' --exclude='*' \
"$STAGE/" "${SSH_TARGET%/}/$DEVICE/"; then
echo "error: rsync to ${SSH_TARGET%/}/$DEVICE/ failed" >&2
exit 5
fi
MINE_SOURCE="${REMOTE_PATH%/}/$DEVICE"
fi
# ── Phase boundary ───────────────────────────────────────────────────
# --prepare hands the source path to the caller (the pi mempalace extension),
# which mines it through the MCP client that already holds the palace.
if [[ $PREPARE_ONLY -eq 1 ]]; then
echo ""
printf 'MINE_SOURCE=%s\n' "$MINE_SOURCE"
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
if [[ "$MODE" == "remote" ]]; then
if ! python3 - "$REMOTE_URL" "$REMOTE_TOKEN" "$MINE_SOURCE" "$WING" "$AGENT" <<'PY'
import json, sys, urllib.error, urllib.request
url, token, source, wing, agent = sys.argv[1:6]
payload = json.dumps({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "mempalace_mine",
"arguments": {"source": source, "mode": "convos", "wing": wing, "agent": agent},
},
}).encode()
headers = {"Content-Type": "application/json", "Accept": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
try:
with urllib.request.urlopen(
urllib.request.Request(url, data=payload, headers=headers), timeout=900
) as resp:
body = resp.read().decode("utf-8", "replace")
except urllib.error.URLError as exc:
print(f"error: remote mine transport failed: {exc}", file=sys.stderr)
sys.exit(1)
print(body[:4000])
sys.exit(1 if '"error"' in body else 0)
PY
then
echo "error: remote mine failed" >&2
exit 5
fi
else
# Capture output so palace-level contention can be told apart from a real
# failure. A live pi session holds the palace through its own mempalace-mcp,
# and that session's extension mines what we just staged — so contention
# means "already handled", not "broken".
set +e
mine_out="$(mempalace mine "$MINE_SOURCE" --mode convos --wing "$WING" --agent "$AGENT" 2>&1)"
mine_rc=$?
set -e
printf '%s\n' "$mine_out"
if [[ $mine_rc -ne 0 ]]; then
if printf '%s' "$mine_out" | grep -q "is held by"; then
echo ""
echo "palace is held by a live session; it will mine the staged exports itself"
exit 0
fi
echo "error: mempalace mine failed" >&2
exit 4
fi
fi
# ── Repair index ─────────────────────────────────────────────────────
+53 -7
View File
@@ -10,13 +10,26 @@
# 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 ~/.cache/mempalace-session/<wing>/.
# 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 XDG_CACHE_HOME) so re-runs
# 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.
#
@@ -87,7 +100,7 @@ Idempotency:
What gets mined:
- Each qualifying session → one Claude Code JSONL file
- Staged under ~/.cache/mempalace-session/<wing>/
- Staged under <palace-root>/opencode-stage/<wing>/
- Filed via `mempalace mine --mode convos`
Transcript shape per session:
@@ -103,7 +116,9 @@ Transcript shape per session:
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 ~/.cache/mempalace-session/<wing>/
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
@@ -147,8 +162,31 @@ if ! [[ "$MIN_MESSAGES" =~ ^[0-9]+$ ]]; then
fi
# ── Staging dir ──────────────────────────────────────────────────────
# Deterministic per-wing path so source_file dedup works across re-runs.
CACHE_ROOT="${XDG_CACHE_HOME:-$HOME/.cache}/mempalace-session"
# 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"
@@ -185,7 +223,15 @@ if since:
# 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()
palace_path = os.environ.get("MEMPALACE_PATH", os.path.expanduser("~/.mempalace/palace"))
# 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: