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:
@@ -21,6 +21,21 @@
|
||||
* `mempalace_status` + `mempalace_diary_read` output as context so the
|
||||
* agent orients itself the way the mempalace skill describes. Skipped
|
||||
* on resume/fork (palace context is already in the thread).
|
||||
* - Feeding (auto): stage + mine this container's pi transcripts into the
|
||||
* palace on `session_shutdown` and on a debounced `agent_settled`. Needs
|
||||
* no LLM turn (pi transcripts are JSONL on disk), which is why it CAN be
|
||||
* automatic where the diary cannot. The file-side work is delegated to
|
||||
* `mempalace-pi-session --prepare` (export + threshold + staging, plus the
|
||||
* rsync to the palace host when the palace is remote); the mine itself
|
||||
* must run through THIS client, because the palace is single-writer and
|
||||
* this process is the holder — a CLI `mempalace mine` during a live
|
||||
* session dies with "palace ... is held by PID <ours>". Going through the
|
||||
* client also means it automatically targets whichever palace this bridge
|
||||
* is pointed at (local stdio or a shared remote one).
|
||||
* - MEMPALACE_FEED=0 disable feeding entirely
|
||||
* - MEMPALACE_FEED_BIN helper to run (default mempalace-pi-session)
|
||||
* - MEMPALACE_FEED_WING target wing (default wing_conversations)
|
||||
* - MEMPALACE_FEED_DEBOUNCE_MS min gap between mid-session feeds (default 600000)
|
||||
* - Wind-down (manual): `/mempalace-diary` command prompts the LLM to
|
||||
* write an AAAK-formatted diary entry. Not fully auto because pi
|
||||
* sessions are typically short/tactical and session_shutdown is too
|
||||
@@ -724,7 +739,112 @@ export default async function mempalaceExtension(pi: ExtensionAPI) {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Automatic transcript feeding ---
|
||||
//
|
||||
// Split deliberately: `mempalace-pi-session --prepare` does the palace-free
|
||||
// file work (export + quality threshold + staging, plus the rsync to the
|
||||
// palace host in remote mode) and prints the path to mine; we then mine it
|
||||
// through this client. See the header note on single-writer contention.
|
||||
const feedEnabled = (process.env.MEMPALACE_FEED ?? "1") !== "0";
|
||||
const feedBin = process.env.MEMPALACE_FEED_BIN || "mempalace-pi-session";
|
||||
const feedWing = process.env.MEMPALACE_FEED_WING ?? "wing_conversations";
|
||||
const feedDebounceMs = num(process.env.MEMPALACE_FEED_DEBOUNCE_MS, 600_000);
|
||||
const feedPrepareTimeoutMs = num(process.env.MEMPALACE_FEED_PREPARE_TIMEOUT_MS, 120_000);
|
||||
const feedMineTimeoutMs = num(process.env.MEMPALACE_FEED_MINE_TIMEOUT_MS, 30_000);
|
||||
let lastFeedAt = 0; // 0 => the first settled turn also acts as a catch-up
|
||||
let feedInFlight: Promise<void> | null = null;
|
||||
|
||||
/** Run `mempalace-feed --prepare`; resolve the path to mine, or null. */
|
||||
function prepareFeed(reason: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
// A missing helper surfaces as an async 'error' event (ENOENT), not a
|
||||
// throw, so the handler below is the fail-soft path.
|
||||
const child = spawn(feedBin, ["--prepare", "--reason", reason, "--wing", feedWing], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let out = "";
|
||||
let settled = false;
|
||||
const finish = (value: string | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
finish(null);
|
||||
}, feedPrepareTimeoutMs);
|
||||
child.stdout.on("data", (chunk) => {
|
||||
out += String(chunk);
|
||||
});
|
||||
child.stderr.on("data", () => {
|
||||
/* the script keeps its own log */
|
||||
});
|
||||
child.on("error", () => finish(null));
|
||||
child.on("exit", (code) => {
|
||||
if (code !== 0) return finish(null);
|
||||
const match = out.match(/^MINE_SOURCE=(.+)$/m);
|
||||
finish(match ? match[1].trim() : null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage + mine this container's transcripts. Never throws, and coalesces:
|
||||
* an overlapping trigger joins the in-flight run instead of racing it.
|
||||
*/
|
||||
function feedPalace(reason: string): Promise<void> {
|
||||
if (!feedEnabled || !available) return Promise.resolve();
|
||||
if (feedInFlight) return feedInFlight;
|
||||
const run = (async () => {
|
||||
try {
|
||||
const source = await prepareFeed(reason);
|
||||
if (!source) return;
|
||||
await Promise.race([
|
||||
client.callTool("mempalace_mine", {
|
||||
source,
|
||||
mode: "convos",
|
||||
wing: feedWing,
|
||||
agent: agentName,
|
||||
}),
|
||||
new Promise((_resolve, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(`mine timed out after ${feedMineTimeoutMs}ms`)),
|
||||
feedMineTimeoutMs,
|
||||
),
|
||||
),
|
||||
]);
|
||||
lastFeedAt = Date.now();
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`[mempalace ext] feed (${reason}) failed: ${(err as Error).message}\n`,
|
||||
);
|
||||
}
|
||||
})();
|
||||
feedInFlight = run.finally(() => {
|
||||
feedInFlight = null;
|
||||
});
|
||||
return feedInFlight;
|
||||
}
|
||||
|
||||
// Mid-session feed. A hard container kill runs no handler at all, so this is
|
||||
// what bounds crash loss to one debounce window instead of a whole session.
|
||||
// Re-mining a grown transcript purges and refiles that source_file, so
|
||||
// repeated ticks refresh a session's drawers rather than duplicating them.
|
||||
pi.on("agent_settled", async () => {
|
||||
if (Date.now() - lastFeedAt < feedDebounceMs) return;
|
||||
void feedPalace("tick"); // deliberately not awaited: never stall a turn
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
// Feed before stopping the client: we are the palace holder, so nothing
|
||||
// else can mine while we live. pi awaits this handler, so the mine really
|
||||
// does complete; feedMineTimeoutMs keeps a wedged palace from hanging exit.
|
||||
await feedPalace("shutdown");
|
||||
client.stop();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user