836e35b320
Review caught that the tier vocabulary was used in the report, the commit message and the docs without being defined anywhere the reader would land, and inspecting that turned up two real defects rather than just a wording gap. 1. STALE DOCSTRING. The module still described tier 3 as if it redacts, which stopped being true when the 403-hit measurement demoted it to report-only. It also credited tier 3 with resolving the 40-hex-PAT-vs-commit-sha collision — false by default, since a reporting rule resolves nothing. Corrected, with the consequence stated plainly: in the default configuration a sha-shaped PAT is caught if and only if it belongs to THIS machine, because only a known value (tier 1) or a naming key (tier 3, reporting) can separate it from a commit sha. That is an accepted gap; the alternative is redacting every sha in the palace. 2. THE VOCABULARY NEVER REACHED THE OUTPUT. The tool prints rule names (github-pat, env-value, url-credentials) and nothing printed a tier, so the docs' tier language was unconnected to what an operator actually sees. Added RULE_TIERS as the authoritative rule -> tier mapping, tier_of(), and Finding.tier; the feeder now prints "T2:github-pat=6" so what matched and how much to trust it are both visible on one line. A self-test asserts every rule that can appear in a Finding maps to a tier, so adding a rule without classifying it fails the tests instead of printing "T?". Tiers, for the record, are three kinds of EVIDENCE (not three severities): T1 known value from this process's env — near-certain, zero FP by construction; T2 known vendor shape — strong, the prefix is meaningful; T3 key name says secret — candidate only, measured FP-heavy, reported. T0 is reserved for suspicions(), which is a measured NON-detection. Docs gain worked one-line examples per tier and a "which tier fired?" section showing real output. 46 self-test cases pass.
1017 lines
46 KiB
Bash
Executable File
1017 lines
46 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# mempalace-pi-session — mine pi coding-agent session history into MemPalace
|
|
#
|
|
# Pi persists every session (verbatim user/assistant turns + tool calls + tool
|
|
# results) as newline-delimited JSONL under ~/.pi/agent/sessions/. Pi has no
|
|
# upstream MemPalace integration and mempalace-toolkit's existing wrapper
|
|
# (`mempalace-session`) only handles opencode's SQLite DB, so pi sessions are
|
|
# currently invisible to the palace.
|
|
#
|
|
# 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 $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).
|
|
#
|
|
# MEMPALACE_PI_REMOTE_PATH is the path AS THE SERVER PROCESS SEES
|
|
# IT, and the default (/data/feed) assumes a CONTAINERIZED server
|
|
# with the inbox bind-mounted there. A NATIVE server (systemd unit /
|
|
# uv tool / plain `mempalace serve`) sees host paths, so there it
|
|
# must equal the path half of MEMPALACE_PI_SSH_TARGET. Get this
|
|
# wrong and rsync still succeeds while the mine fails with
|
|
# "source directory not found" — so a mismatch between the two is
|
|
# warned about at ship time, and the mine's own failure is now
|
|
# detected properly (see classify() in run_remote_mine).
|
|
#
|
|
# 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, 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.
|
|
#
|
|
# 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
|
|
# mempalace-pi-session --min-messages 6
|
|
# mempalace-pi-session --dry-run
|
|
# mempalace-pi-session --help
|
|
#
|
|
# Exit codes:
|
|
# 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 / 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+);
|
|
# rsync + ssh in remote mode.
|
|
|
|
set -euo pipefail
|
|
|
|
# HOME can legitimately be unset: `docker run --entrypoint="" <image>` inherits
|
|
# no HOME when the image config declares none (pi-devbox's does not — HOME is
|
|
# normally set by its entrypoint, which --entrypoint="" skips), and every
|
|
# default below is HOME-anchored under `set -u`, so the script died at line 1 of
|
|
# real work with "HOME: unbound variable". Derive it from the passwd database —
|
|
# exactly what python's expanduser() falls back to — so the script, and
|
|
# especially the palace-free --self-test, runs in a bare container too.
|
|
# pi-devbox v1.8.0 lost a release to this same "the image sets HOME" assumption.
|
|
: "${HOME:=$(python3 -c 'import os, pwd; print(pwd.getpwuid(os.getuid()).pw_dir)' 2>/dev/null || echo /tmp)}"
|
|
export HOME
|
|
|
|
# ── Defaults ─────────────────────────────────────────────────────────
|
|
# Agent name defaults to "<harness>@<device>" when the device is known
|
|
# (MEMPALACE_PI_DEVICE is set on every enrolled devbox). That one string is the
|
|
# only place a palace write records BOTH which harness produced it and which
|
|
# machine it came from: mempalace core 3.7.1 stamps neither, and because every
|
|
# client shares one bearer token the server cannot tell them apart either. The
|
|
# per-device inbox path and the pi_*.jsonl filename encode the same two facts
|
|
# only incidentally, so anything filed outside the feeder had to be inferred.
|
|
# Falls back to $USER — which is what every drawer filed before this carries.
|
|
AGENT="${MEMPALACE_PI_DEVICE:+pi@${MEMPALACE_PI_DEVICE}}"
|
|
AGENT="${AGENT:-${USER:-mempalace}}"
|
|
WING="wing_conversations"
|
|
SESSION_ID=""
|
|
SINCE=""
|
|
MIN_MESSAGES=4
|
|
MIN_ASSISTANT_CHARS=1000
|
|
DRY_RUN=0
|
|
DO_REPAIR=0
|
|
PREPARE_ONLY=0
|
|
SELF_TEST=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'
|
|
mempalace-pi-session — mine pi coding-agent session history into MemPalace
|
|
|
|
Usage:
|
|
mempalace-pi-session [options]
|
|
|
|
Options:
|
|
--wing <name> Target wing (default: wing_conversations)
|
|
--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: 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. Defaults to
|
|
pi@$MEMPALACE_PI_DEVICE when that is set, else $USER.
|
|
The palace records neither the harness nor the machine
|
|
on a write, so this one string is what makes a drawer
|
|
attributable to both.
|
|
--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. In remote mode
|
|
the tag is [?]: dedup is decided by the palace host,
|
|
which this machine's local palace copy cannot answer.
|
|
--self-test Run the remote-mine response classifier against
|
|
recorded MCP responses and exit. Needs no palace, no
|
|
network and no sessions dir.
|
|
--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 writes every
|
|
qualifying session to the cache; the mine step dedups by source_file so
|
|
already-filed sessions are skipped without re-embedding.
|
|
|
|
Transcript shape per session:
|
|
- Synthetic header as first user turn:
|
|
[session: <title> | <cwd> | <YYYY-MM-DD> | source: pi]
|
|
- User/assistant messages extracted from pi JSONL `message` entries
|
|
- Assistant toolCall blocks → Claude Code `tool_use` blocks
|
|
- `toolResult` role messages → `tool_result` blocks (folded back into
|
|
the assistant turn by the normalizer)
|
|
- `bashExecution`, `custom(display=true)`, `branchSummary`,
|
|
`compactionSummary` → rendered as text annotations
|
|
- `thinking` content blocks → dropped (noise)
|
|
- Image content blocks → dropped (palace embeds text only)
|
|
|
|
Dedup:
|
|
- source_file = absolute staging path (deterministic per pi session UUID)
|
|
- 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:
|
|
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
|
|
}
|
|
|
|
# ── Remote mine over MCP ─────────────────────────────────────────────
|
|
# Usage: run_remote_mine <url> <token> <source> <wing> <agent>
|
|
# run_remote_mine --self-test
|
|
#
|
|
# WHY THIS IS A FUNCTION WITH A SELF-TEST: MCP answers a hard tool failure with
|
|
# HTTP 200 and a JSON-RPC *result* whose content[].text holds the tool's own
|
|
# JSON as an ESCAPED STRING. This code used to decide success with
|
|
# `'"error"' in body`, which can never match those bytes (they are \"error\"),
|
|
# so on 2026-08-15 a mine that failed with
|
|
# {"success": false, "error": "source directory not found: '/data/feed/...'"}
|
|
# was reported as "Done. Wing updated." and nothing was filed. A silent
|
|
# false success in a feeder is worse than a crash: the only artifact says it
|
|
# worked. The fixtures below pin that exact body so it cannot come back.
|
|
run_remote_mine() {
|
|
python3 - "$@" <<'PY'
|
|
import json, sys, urllib.error, urllib.request
|
|
|
|
|
|
def classify(body):
|
|
"""Return (ok, note) for an MCP tools/call response body.
|
|
|
|
ok=False means the mine demonstrably failed. note carries the reason, or —
|
|
when ok is True — an "unverified" caveat if the response contained no JSON
|
|
tool payload to adjudicate. Never claim more than the bytes support.
|
|
"""
|
|
try:
|
|
env = json.loads(body)
|
|
except ValueError:
|
|
return False, "response was not JSON: " + body[:200].replace("\n", " ")
|
|
if not isinstance(env, dict):
|
|
return False, "response was not a JSON object"
|
|
if env.get("error") is not None: # JSON-RPC transport-level error
|
|
return False, "JSON-RPC error: " + json.dumps(env["error"])[:300]
|
|
result = env.get("result")
|
|
if not isinstance(result, dict):
|
|
return False, "response carried no result object"
|
|
if result.get("isError"):
|
|
return False, "MCP isError set: " + json.dumps(result.get("content"))[:300]
|
|
saw_payload = False
|
|
for item in result.get("content") or []:
|
|
text = item.get("text") if isinstance(item, dict) else None
|
|
if not isinstance(text, str):
|
|
continue
|
|
try:
|
|
payload = json.loads(text) # the escaped inner JSON
|
|
except ValueError:
|
|
continue # plain prose content: nothing to judge
|
|
if not isinstance(payload, dict):
|
|
continue
|
|
saw_payload = True
|
|
if payload.get("success") is False:
|
|
return False, str(payload.get("error") or "tool reported success=false")
|
|
if payload.get("error"):
|
|
return False, str(payload["error"])
|
|
if not saw_payload:
|
|
return True, "unverified: no JSON tool payload in the response"
|
|
return True, ""
|
|
|
|
|
|
FIXTURES = [
|
|
# 1. The real 2026-08-15 failure: HTTP 200, JSON-RPC result, tool failed.
|
|
('{"jsonrpc": "2.0", "id": 1, "result": {"content": [{"type": "text", '
|
|
'"text": "{\\n \\"success\\": false,\\n \\"error\\": \\"source directory '
|
|
'not found: \'/data/feed/emb-7kj4vr4g\'\\"\\n}"}]}}', False),
|
|
# 2. A real success.
|
|
('{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":'
|
|
'"{\\"success\\": true, \\"mode\\": \\"convos\\", \\"output\\": \\"Drawers filed: 12\\"}"}]}}', True),
|
|
# 3. JSON-RPC level error (bad method, auth rejected at protocol level).
|
|
('{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}', False),
|
|
# 4. MCP tool-level isError flag.
|
|
('{"jsonrpc":"2.0","id":1,"result":{"isError":true,"content":[{"type":"text","text":"boom"}]}}', False),
|
|
# 5. Not JSON at all (proxy error page, 502 HTML).
|
|
('<html><body>502 Bad Gateway</body></html>', False),
|
|
# 6. Success-shaped envelope with prose content: cannot be called a failure.
|
|
('{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"mined 3 files"}]}}', True),
|
|
]
|
|
|
|
args = sys.argv[1:]
|
|
|
|
if args[:1] == ["--self-test"]:
|
|
failures = 0
|
|
for i, (body, want_ok) in enumerate(FIXTURES, 1):
|
|
got_ok, note = classify(body)
|
|
if got_ok != want_ok:
|
|
failures += 1
|
|
print(f" [{'ok ' if got_ok == want_ok else 'FAIL'}] fixture {i}: "
|
|
f"want_ok={want_ok} got_ok={got_ok} note={note[:70]!r}")
|
|
# Regression guard: the detector this replaced must be shown blind to #1.
|
|
old_detector_sees_it = '"error"' in FIXTURES[0][0]
|
|
if old_detector_sees_it:
|
|
failures += 1
|
|
print(f" [{'ok ' if not old_detector_sees_it else 'FAIL'}] regression guard: "
|
|
f"substring detector sees fixture 1? {old_detector_sees_it} (must be False)")
|
|
print("SELF-TEST " + ("FAILED" if failures else "PASSED"))
|
|
sys.exit(1 if failures else 0)
|
|
|
|
url, token, source, wing, agent = args[:5]
|
|
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.HTTPError as exc:
|
|
detail = exc.read().decode("utf-8", "replace")[:500] if hasattr(exc, "read") else ""
|
|
print(f"error: remote mine transport failed: HTTP {exc.code} {exc.reason} {detail}",
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
except urllib.error.URLError as exc:
|
|
print(f"error: remote mine transport failed: {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
print(body[:4000])
|
|
ok, note = classify(body)
|
|
if not ok:
|
|
print(f"error: remote mine reported failure: {note}", file=sys.stderr)
|
|
sys.exit(1)
|
|
if note:
|
|
print(f"warning: remote mine {note}", file=sys.stderr)
|
|
sys.exit(0)
|
|
PY
|
|
}
|
|
|
|
# ── 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 ;;
|
|
--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 ;;
|
|
--self-test) SELF_TEST=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
|
|
|
|
# The classifier self-test is pure logic: no palace, no network, no sessions
|
|
# dir. Dispatch before preflight so it runs anywhere, including in CI.
|
|
if [[ $SELF_TEST -eq 1 ]]; then
|
|
echo "mempalace-pi-session --self-test: remote-mine response classifier"
|
|
run_remote_mine --self-test
|
|
exit $?
|
|
fi
|
|
|
|
# ── Preflight ────────────────────────────────────────────────────────
|
|
if [[ ! -d "$PI_SESSIONS_DIR" ]]; then
|
|
echo "error: pi sessions dir not found at $PI_SESSIONS_DIR" >&2
|
|
echo " override with --sessions-dir <path> or PI_SESSIONS_DIR env var" >&2
|
|
exit 2
|
|
fi
|
|
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 [[ "$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
|
|
# Remote mode names the same inbox twice: where rsync PUTS the files, and
|
|
# what the SERVER is told to mine. They may legitimately differ
|
|
# (containerized server: host dir bind-mounted elsewhere), but when they
|
|
# differ by accident rsync still succeeds and only the mine fails — the
|
|
# 2026-08-15 /data/feed incident, where transcripts shipped for hours and
|
|
# were filed nowhere. Warn in PREFLIGHT so --dry-run and --prepare see it
|
|
# too, not just a full run that gets as far as shipping.
|
|
SHIP_PATH="$SSH_TARGET"
|
|
[[ "$SHIP_PATH" == *:* ]] && SHIP_PATH="${SHIP_PATH##*:}"
|
|
if [[ "${SHIP_PATH%/}" != "${REMOTE_PATH%/}" ]]; then
|
|
echo "note: shipping to '${SHIP_PATH%/}/$DEVICE' but asking the server to mine"
|
|
echo " '${REMOTE_PATH%/}/$DEVICE'. Correct only if the palace server sees"
|
|
echo " '${SHIP_PATH%/}' at '${REMOTE_PATH%/}' (containerized server with a bind"
|
|
echo " mount). A NATIVE server (systemd unit / uv tool) sees host paths —"
|
|
echo " then set MEMPALACE_PI_REMOTE_PATH='${SHIP_PATH%/}'."
|
|
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 ──────────────────────────────────────────────────────
|
|
# 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.
|
|
# The redactor is a sibling module, imported by the heredoc below. Exported
|
|
# rather than passed as argv so the argv unpack stays stable.
|
|
MEMPALACE_REDACT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
export MEMPALACE_REDACT_DIR
|
|
export_count=$(python3 - "$PI_SESSIONS_DIR" "$STAGE" "$SESSION_ID" "$SINCE" "$MIN_MESSAGES" "$MIN_ASSISTANT_CHARS" "$MODE" <<'PY'
|
|
import json, os, sqlite3, sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
# ── Secret scrubbing before anything is staged ───────────────────────────────
|
|
# This is the last point at which a transcript is a plain in-memory value: the
|
|
# local mine reads the staged file, and the remote path rsyncs that same file
|
|
# byte-for-byte, so scrubbing here covers BOTH transports with one hook.
|
|
#
|
|
# FAIL CLOSED. If the redactor cannot be imported, staging is refused rather
|
|
# than done unscrubbed — a missing module means a broken install, and the whole
|
|
# point of this step is that a secret must not reach a shared palace. Override
|
|
# deliberately with MEMPALACE_FEED_ALLOW_UNSCRUBBED=1 if you ever need to feed a
|
|
# machine whose toolkit is older than this file.
|
|
sys.path.insert(0, os.environ.get("MEMPALACE_REDACT_DIR", ""))
|
|
try:
|
|
import mempalace_redact as _redact
|
|
except Exception as _e: # noqa: BLE001 - any import failure is fatal by design
|
|
if os.environ.get("MEMPALACE_FEED_ALLOW_UNSCRUBBED", "").strip() in {"1", "true", "yes"}:
|
|
_redact = None
|
|
print(" [WARN] secret scrubber unavailable, staging UNSCRUBBED by request "
|
|
f"({_e})", file=sys.stderr)
|
|
else:
|
|
print(f" [FATAL] secret scrubber unavailable ({_e}); refusing to stage. "
|
|
"Set MEMPALACE_FEED_ALLOW_UNSCRUBBED=1 to override.", file=sys.stderr)
|
|
raise SystemExit(3)
|
|
|
|
_known = _redact.env_secrets() if _redact else []
|
|
_redactions = 0
|
|
_reported = 0
|
|
|
|
sessions_dir, stage, session_filter, since, min_messages, min_assistant_chars, mode = sys.argv[1:8]
|
|
min_messages = int(min_messages)
|
|
min_assistant_chars = int(min_assistant_chars)
|
|
stage = Path(stage)
|
|
sessions_dir = Path(sessions_dir)
|
|
|
|
# Convert --since YYYY-MM-DD to epoch seconds (comparing against file mtime)
|
|
since_epoch = None
|
|
if since:
|
|
try:
|
|
since_epoch = datetime.strptime(since, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()
|
|
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) ──
|
|
# already_filed is None in remote mode: unknown, not empty. The dedup that
|
|
# matters happens on the PALACE HOST and is keyed on the REMOTE inbox path, so
|
|
# this machine's local palace file cannot answer the question — and answering
|
|
# it anyway is how a preview comes to say "6 already filed" about a palace it
|
|
# is not feeding (2026-08-15). An honest "[?]" beats a confident wrong number.
|
|
already_filed = None if mode == "remote" else 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 already_filed is not None and 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 → miner will dedup
|
|
|
|
def extract_text(content):
|
|
"""Flatten a message content (string | list-of-blocks) to plain text.
|
|
|
|
Drops image + thinking blocks; keeps text + renders toolCall/toolResult
|
|
stubs inline. Returns ("", [tool_uses], [tool_results]) where tool_uses
|
|
are collected for assistant messages and tool_results for toolResult
|
|
messages.
|
|
"""
|
|
if isinstance(content, str):
|
|
return content, [], []
|
|
if not isinstance(content, list):
|
|
return "", [], []
|
|
text_parts = []
|
|
tool_uses = []
|
|
for block in content:
|
|
if not isinstance(block, dict):
|
|
continue
|
|
bt = block.get("type")
|
|
if bt == "text":
|
|
t = block.get("text", "")
|
|
if t:
|
|
text_parts.append(t)
|
|
elif bt == "thinking":
|
|
# Drop reasoning content — high-noise, low-signal for search.
|
|
continue
|
|
elif bt == "image":
|
|
# Palace is text-only.
|
|
continue
|
|
elif bt == "toolCall":
|
|
tool_uses.append({
|
|
"type": "tool_use",
|
|
"id": block.get("id") or "",
|
|
"name": block.get("name") or "tool",
|
|
"input": block.get("arguments") or {},
|
|
})
|
|
return "\n".join(text_parts), tool_uses, []
|
|
|
|
def load_session(path: Path):
|
|
"""Parse a pi JSONL session file. Returns (header, entries) or None."""
|
|
try:
|
|
with path.open("r", encoding="utf-8") as f:
|
|
lines = [ln for ln in f.read().splitlines() if ln.strip()]
|
|
except OSError:
|
|
return None
|
|
if not lines:
|
|
return None
|
|
try:
|
|
header = json.loads(lines[0])
|
|
except json.JSONDecodeError:
|
|
return None
|
|
if header.get("type") != "session":
|
|
return None
|
|
entries = []
|
|
for ln in lines[1:]:
|
|
try:
|
|
entries.append(json.loads(ln))
|
|
except json.JSONDecodeError:
|
|
continue
|
|
return header, entries
|
|
|
|
def derive_title(entries, fallback: str) -> str:
|
|
"""Prefer session_info.name; else truncated first user message."""
|
|
# session_info entries: most-recent wins
|
|
name = None
|
|
for e in entries:
|
|
if e.get("type") == "session_info" and e.get("name"):
|
|
name = e["name"]
|
|
if name:
|
|
return name[:120]
|
|
for e in entries:
|
|
if e.get("type") != "message":
|
|
continue
|
|
msg = e.get("message") or {}
|
|
if msg.get("role") != "user":
|
|
continue
|
|
text, _, _ = extract_text(msg.get("content"))
|
|
text = " ".join(text.split()) # collapse whitespace
|
|
if text:
|
|
return (text[:80] + "…") if len(text) > 80 else text
|
|
return fallback
|
|
|
|
# Discover session files
|
|
paths = sorted(sessions_dir.rglob("*.jsonl"))
|
|
if session_filter:
|
|
paths = [p for p in paths if session_filter in p.name]
|
|
|
|
exported = 0
|
|
skipped_short = 0
|
|
skipped_quiet = 0
|
|
skipped_malformed = 0
|
|
skipped_already_filed = 0
|
|
|
|
for path in paths:
|
|
try:
|
|
mtime = path.stat().st_mtime
|
|
except OSError:
|
|
continue
|
|
if since_epoch is not None and mtime < since_epoch:
|
|
continue
|
|
|
|
parsed = load_session(path)
|
|
if parsed is None:
|
|
skipped_malformed += 1
|
|
continue
|
|
header, entries = parsed
|
|
session_uuid = header.get("id") or path.stem
|
|
cwd = header.get("cwd") or "?"
|
|
header_ts = header.get("timestamp") or ""
|
|
try:
|
|
date_str = header_ts[:10] if header_ts else datetime.fromtimestamp(
|
|
mtime, tz=timezone.utc).strftime("%Y-%m-%d")
|
|
except Exception:
|
|
date_str = datetime.fromtimestamp(mtime, tz=timezone.utc).strftime("%Y-%m-%d")
|
|
|
|
# Count user+assistant message entries for the min-messages filter
|
|
turn_count = sum(
|
|
1 for e in entries
|
|
if e.get("type") == "message"
|
|
and (e.get("message") or {}).get("role") in ("user", "assistant")
|
|
)
|
|
if turn_count < min_messages:
|
|
skipped_short += 1
|
|
continue
|
|
|
|
title = derive_title(entries, fallback=session_uuid[:8])
|
|
assistant_chars = 0
|
|
out_lines = []
|
|
out_lines.append({
|
|
"type": "user",
|
|
"message": {
|
|
"content": f"[session: {title} | {cwd} | {date_str} | source: pi]"
|
|
},
|
|
})
|
|
|
|
for e in entries:
|
|
t = e.get("type")
|
|
if t == "message":
|
|
msg = e.get("message") or {}
|
|
role = msg.get("role")
|
|
if role == "user":
|
|
text, _, _ = extract_text(msg.get("content"))
|
|
if text.strip():
|
|
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})
|
|
blocks.extend(tool_uses)
|
|
if not blocks:
|
|
continue
|
|
# Simplify single-text to string (matches mempalace-session).
|
|
if len(blocks) == 1 and blocks[0].get("type") == "text":
|
|
content = blocks[0]["text"]
|
|
else:
|
|
content = blocks
|
|
out_lines.append({"type": "assistant", "message": {"content": content}})
|
|
elif role == "toolResult":
|
|
text, _, _ = extract_text(msg.get("content"))
|
|
tool_id = msg.get("toolCallId") or ""
|
|
if not tool_id:
|
|
continue
|
|
out_lines.append({
|
|
"type": "human",
|
|
"message": {
|
|
"content": [{
|
|
"type": "tool_result",
|
|
"tool_use_id": tool_id,
|
|
"content": text or "(no output)",
|
|
}],
|
|
},
|
|
})
|
|
elif role == "bashExecution":
|
|
# Rendered as a synthetic assistant annotation so the
|
|
# command + output stay associated with the surrounding turn.
|
|
cmd = msg.get("command") or ""
|
|
out = msg.get("output") or ""
|
|
exit_code = msg.get("exitCode")
|
|
note = f"[user-bash] $ {cmd}\nexit={exit_code}\n{out}".strip()
|
|
if note:
|
|
out_lines.append({"type": "user", "message": {"content": note}})
|
|
elif role == "custom":
|
|
if not msg.get("display"):
|
|
continue
|
|
text, _, _ = extract_text(msg.get("content"))
|
|
if text.strip():
|
|
ctype = msg.get("customType") or "custom"
|
|
out_lines.append({
|
|
"type": "user",
|
|
"message": {"content": f"[custom:{ctype}] {text}"},
|
|
})
|
|
elif role in ("branchSummary", "compactionSummary"):
|
|
summary = msg.get("summary") or ""
|
|
if summary.strip():
|
|
out_lines.append({
|
|
"type": "user",
|
|
"message": {"content": f"[{role}] {summary}"},
|
|
})
|
|
# thinking-only / empty messages silently dropped
|
|
elif t in (
|
|
"model_change", "thinking_level_change", "compaction",
|
|
"branch_summary", "label", "session_info", "custom",
|
|
"custom_message",
|
|
):
|
|
# Non-conversational entries: drop. (custom_message with
|
|
# display=true could be included but we already get it via the
|
|
# "custom" message role above when pi materializes one.)
|
|
continue
|
|
|
|
# Need at least 2 turns (header + one real turn) for the normalizer.
|
|
if len(out_lines) < 2:
|
|
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
|
|
|
|
# Scrub the parsed objects, not the serialized text: string VALUES get
|
|
# rewritten while keys, ids and structure are left exactly as they are.
|
|
# (Scanning raw JSONL instead would also match escape artifacts like the
|
|
# "\\t" before a field name, inventing keys such as "tapiKey".)
|
|
if _redact is not None:
|
|
_found: list = []
|
|
out_lines = [_redact.scrub_obj(obj, _known, _found)[0] for obj in out_lines]
|
|
_hard = [x for x in _found if not x.rule.endswith("-reported")]
|
|
_soft = [x for x in _found if x.rule.endswith("-reported")]
|
|
_redactions += len(_hard)
|
|
_reported += len(_soft)
|
|
if _hard:
|
|
_by = {}
|
|
for x in _hard:
|
|
# "T2:github-pat" — rule says what matched, tier says how much to
|
|
# trust it, which is what the reader of this line actually needs.
|
|
_k = f"T{x.tier}:{x.rule}"
|
|
_by[_k] = _by.get(_k, 0) + 1
|
|
print(f" [REDACTED] {path.name} "
|
|
+ ", ".join(f"{k}={v}" for k, v in sorted(_by.items()))
|
|
+ " fp=" + ",".join(sorted({x.fingerprint for x in _hard})),
|
|
file=sys.stderr)
|
|
|
|
out_path = stage / f"pi_{session_uuid}.jsonl"
|
|
with out_path.open("w", encoding="utf-8") as f:
|
|
for obj in out_lines:
|
|
f.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
|
|
|
# Preserve session mtime on the staging file for dedup stability.
|
|
try:
|
|
os.utime(out_path, (mtime, mtime))
|
|
except OSError:
|
|
pass
|
|
|
|
exported += 1
|
|
if already_filed is None:
|
|
is_filed = False # unknowable here; the palace host decides
|
|
status = "? "
|
|
else:
|
|
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} ({turn_count} turns)", file=sys.stderr)
|
|
|
|
print(f"EXPORTED {exported}")
|
|
print(f"ALREADY_FILED {-1 if already_filed is None else skipped_already_filed}")
|
|
# Report the scrub outcome even when it is zero: "0 redactions" is a measurement,
|
|
# whereas printing nothing is indistinguishable from a scrubber that never ran.
|
|
if _redact is not None:
|
|
print(f" [scrub] {_redactions} redaction(s) applied, "
|
|
f"{_reported} name-anchored candidate(s) reported only "
|
|
f"(set MEMPALACE_REDACT_STRICT=1 to redact those too)", file=sys.stderr)
|
|
|
|
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
|
|
)
|
|
|
|
# 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}"
|
|
# -1 means "unknown" (remote mode), so guard the arithmetic.
|
|
if [[ "$already_filed" -lt 0 ]]; then to_file="$count"; else to_file=$(( count - already_filed )); fi
|
|
|
|
if [[ "$count" -eq 0 ]]; then
|
|
echo "no sessions qualified for export"
|
|
exit 0
|
|
fi
|
|
|
|
echo ""
|
|
echo "Exported $count session(s) to $STAGE"
|
|
if [[ "$already_filed" -lt 0 ]]; then
|
|
# Remote mode: dedup lives on the palace host, keyed on the remote inbox
|
|
# path. Do not translate "unknown" into a number.
|
|
to_file="$count"
|
|
echo " all $count shipped → the palace host dedups by source_file (remote mode:"
|
|
echo " this machine cannot preview what it already holds)"
|
|
else
|
|
echo " $to_file new → will be filed on mine"
|
|
echo " $already_filed already filed → will be skipped (dedup by source_file)"
|
|
fi
|
|
|
|
if [[ $DRY_RUN -eq 1 ]]; then
|
|
echo ""
|
|
if [[ "$already_filed" -lt 0 ]]; then
|
|
echo "--dry-run: skipping ship+mine. A real run would ship $count session(s) to"
|
|
echo " ${SSH_TARGET%/}/$DEVICE/ and let the palace host dedup them."
|
|
elif [[ "$to_file" -eq 0 ]]; then
|
|
echo "--dry-run: no new sessions to mine. A real run would skip all $count."
|
|
else
|
|
echo "--dry-run: skipping mine step. A real run would file $to_file new session(s)."
|
|
fi
|
|
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 [[ "$MODE" == "remote" ]]; then
|
|
if ! run_remote_mine "$REMOTE_URL" "$REMOTE_TOKEN" "$MINE_SOURCE" "$WING" "$AGENT"; 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 ─────────────────────────────────────────────────────
|
|
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."
|