/**
* pi-auto-save-to-markdown
*
* Automatically saves the current conversation to a markdown file after every
* completed agent turn, with session metadata in a YAML frontmatter block.
*
* Behavior:
*
* - Trigger: `agent_settled` — fires once per user prompt, after the turn is
* fully done (including automatic retries and compaction), so each save
* captures a settled state of the conversation.
* - Skip rule: every automatic path (the `agent_settled` save and the batch
* command's live save) skips sessions Pi does not persist — no session
* file, i.e. an in-memory SessionManager (`--no-session`): those are
* ephemeral auxiliary agents host clients spawn next to the real
* conversation (Claudian's title generation, instruction refinement,
* inline edits) or one-shot `pi --no-session` runs, not project
* conversations, and archiving them would mint junk "User's request …"
* files. The manual /save-conversation command still saves such a session
* on explicit demand; the batch never sees them (no jsonl on disk).
* - Location: a subfolder of the session's working directory (`ctx.cwd`, the
* directory the session was started in), defaulting to `ai-conversations`.
* Override with the PI_SAVE_CONVERSATION_DIR environment variable; set it to
* "." or "" to save directly into the working directory.
* - Filename: `
--.md`, where is the session name
* (or a slug of the first user message when unnamed), is the first
* 8 hex of the SHA-256 of the session id (the same value for every file
* of one session, so a session's files cluster in the archive directory
* across recoveries and resumes; when no session id exists yet — the
* degenerate fallback — the deepest message entry's id is hashed the
* same way, so the key is always an opaque 8-hex cluster key), and
* is the local file-creation timestamp (YYYYMMDD-HHmmss).
* - Frontmatter: title, agent (generator identity, always "pi" here — agent
* plugins for other runtimes would write their own value), format_version
* (the document format of the last write — additive frontmatter fields
* never bump it; see FORMAT_VERSION), session id, session key (the
* filename key), branch last entry id (field `branch_last_entry_id` — the
* id of the deepest message entry on the saved branch: the file's position
* in the session jsonl tree at the last write, scoped to this file's
* branch, not the session-wide last entry), model, provider, cumulative
* cost and tokens (input, output,
* cache read/write), message count, created/updated timestamps (tz-aware
* ISO 8601 in the local timezone with its numeric UTC offset, e.g.
* "2026-08-29T13:05:12+08:00"), project root and session file.
* - Body format: every message block opens with a setext-H1 info header
* (`User YYYY-MM-DD HH:MM:SS ` /
* `Assistant YYYY-MM-DD HH:MM:SS · model `, where the span
* renders the metadata as small faint text — Obsidian CSS variables, so it
* degrades gracefully elsewhere — underlined with `===`, distinct from the
* `#`/`##` ATX headings AI content uses) and ends with a `---` separator
* wrapped in single blank lines.
* - Tool call/result folding: calls live in the assistant entry while their
* results are separate toolResult entries; saves pair them by toolCall id
* and fold each assistant block's calls, with their FULL results, into one
* collapsed Obsidian callout (`> [!quote]- Tool Calls · …`). Thinking folds
* the same way into `> [!tldr]- Thinking`. Callouts are used instead of
* HTML `` because Obsidian's views render embedded markdown
* inside HTML blocks unreliably, while callouts fold and render markdown
* in both Live Preview and Reading view. Outside Obsidian the callouts
* degrade to plain blockquotes. Arguments render as full JSON in inline
* code spans and results verbatim — whitespace intact, nothing capped —
* in fenced code blocks (delimiters sized to survive backticks inside the
* content), so raw output renders literally instead of being parsed as
* markdown. Nothing is truncated because the file is a documentary record
* that may be @-referenced back into a conversation: a half result is
* wasted when the tool is called again and misleading when it is not,
* while local reading (grep, ranged reads) makes size a non-issue. A
* result whose call was saved in an earlier file (mid-turn manual save)
* renders as a standalone block with the same full content.
* - Injected prompt blocks: the host client and the agent runtime append
* machine-readable XML to user messages — the editor's active selection
* (CDATA content), note references and attachments (linked_note /
* linked_content), loaded skills, and their kin. Raw markup is noise
* Obsidian cannot render (unknown tags are not HTML; CDATA is XML), so
* every block in a known vocabulary is re-rendered generically (see
* markdown.ts) — no per-tag formatting: the callout title is the tag name
* in words, the body opens with vault-shaped path/location values as
* bare wikilinks (the aliased filename is self-explanatory — a `path:`
* label is noise) followed by the remaining attributes as
* "**name**: value" lines, then the content (the client's `]]>`
* split-escaping reversed). Every callout is preset-collapsed: visible
* blocks (selections, note references, attachments) as `> [!quote]-`,
* whether they carry content or only attributes (the client emits note
* references as self-closing tags whose whole payload is a path
* attribute), agent-side traces (skills) as a `> [!note]- Skill · `
* marker — the loaded skill's name rides the title so the collapsed
* marker still says which skill, the location follows in the body, the
* content is dropped — and consecutive visible same-tag blocks (nothing
* but whitespace between them) merge into one callout, so a run of note
* references collapses into a single list (skill markers never merge:
* each names its own skill). Title
* derivation strips every known block — the typed message is the title.
* Unknown markup is left verbatim so XML pasted as content is never
* mangled.
* - Branching: each file records exactly ONE branch (the root→leaf path
* returned by sessionManager.getBranch()). State is persisted via
* `pi.appendEntry()` custom entries, which are part of the session tree
* itself — they are not sent to the LLM and not rendered in the TUI. State
* discovery reads those entries straight from the session jsonl on disk
* (the shared append log is the single source of truth), so a warm process
* whose in-memory tree lags behind still sees states recorded by other
* runtimes; the in-memory tree is only a fallback when the file is
* unavailable. Every save ranks the recorded states whose saved position
* lies on the current path — deepest first, and among equal positions the
* state recorded LAST wins: it names the file the latest successful save
* actually wrote, older ones name superseded files. If the tree moved
* elsewhere (e.g. /tree navigation followed by a new prompt), no position
* matches and a new file is created with the full current branch, with an
* info notice naming the earlier branch's kept file so the switch stays
* visible in the archive. Continuing an existing branch appends only the
* messages that are new since the last save.
* - Recovery: candidates are validated newest-first — the target file must
* exist AND its frontmatter `messages` count must cover the messages
* already saved for this branch (a count that exceeds it is fine — a
* descendant branch extended the same file); the first candidate that
* passes is continued. If the newest candidate fails but an older one
* validates, the save downgrades to the older file and warns about it
* (converging on existing files instead of minting new ones). Only when
* every candidate fails — deleted files, or files rewritten from a
* different tree position (e.g. /tree navigation plus a save on an older
* branch), where continuing could silently strand this branch's newer
* messages — is a brand-new file with the full current branch written.
* Recoveries and downgrades are never silent: each is reported as a
* warning naming the failed target and the likely cause (something
* moving/deleting files for a missing target; a concurrent runtime or an
* older extension version for a count mismatch). A fresh file never
* overwrites an existing filename either (-1, -2 … suffixes claim a free
* one): two runtimes recovering the same lost file in the same second
* would otherwise mint the same name and silently overwrite each other.
* Every branch therefore always ends up with a complete, consistent file.
* - Rename-on-title: the first save usually happens before the session has
* its real name (Claudian generates the title only after the first reply),
* so the file is created with a slug of the first user message. Once the
* name exists, the next save renames that file exactly once, to
* "--.md" — the original timestamp keeps
* the file's birthday (and makes concurrent or retried renames converge
* on one name), the frontmatter title and the document heading are
* rewritten to match, and the state entry records the new name with
* titled=true. One-way and one-time: a later user /name change never
* touches the filename, and legacy files (state entries predating the
* titled flag) are only renamed when their name segment equals the
* recomputed fallback slug, so manually organized filenames stay
* untouched. A rename never overwrites an existing target (-1, -2 …
* suffixes); a failed rename keeps the old filename — the title fields
* were already fixed — and retries on the next save.
* - Mixed versions: every state entry records its save-state schema version
* ("MAJOR.MINOR", numbered independently of the package version) plus the
* writer's package version. A warm process can outlive a package upgrade
* and keep running pre-upgrade code against the same session; when a save
* sees state entries written by a NEWER schema, it warns (once per
* session) to restart. Older or unknown schemas are ignored — they are
* indistinguishable from this session's own pre-upgrade history.
* - Compaction: files archive the ORIGINAL messages (getBranch() returns the
* raw tree path, not the compaction-aware context), so a compacted session
* still exports its complete history.
* - Thinking repair: reasoning blocks stored with the upstream
* newline-fragmentation corruption (one word per line) are detected and
* re-joined into flowing text before saving, keeping paragraph breaks
* where they survive as long separator runs after sentence ends; clean
* thinking is untouched.
*
* Manual commands:
* - `/save-conversation` saves the current branch immediately and reports
* the file path.
* - `/save-conversation-all` saves EVERY session of the current project
* (every session jsonl in the project's `~/.pi/agent/sessions` folder):
* each session through the exact same pipeline — state candidates, the
* never-overwrite guard, rename-on-title, recovery warnings — with its
* archive written under that session's own working directory. Sessions
* without an assistant reply are skipped; the current session saves
* through the normal live path first. Idempotent: re-running continues
* or reports "up to date" per session, never re-creating files. A session
* whose jsonl changes while it is being processed (it is still being
* written by its own runtime) is deferred to the next run instead of
* racing the other writer — the save only proceeds when the file is
* verified unchanged since it was read (optimistic concurrency control;
* the state line the batch appends to a foreign session jsonl is
* byte-identical to what pi's own appendCustomEntry would write).
*
* Installation:
* pi install npm:pi-auto-save-to-markdown
*
* Debug:
* PI_CLAUDIAN_DEBUG=1 pi
*/
import type {
AgentSettledEvent,
ExtensionAPI,
ExtensionCommandContext,
ExtensionContext,
SessionEntry,
SessionMessageEntry,
} from "@earendil-works/pi-coding-agent";
import { createHash, randomUUID } from "node:crypto";
import * as fs from "node:fs/promises";
import * as fsSync from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { debug } from "./debug.js";
import {
callout,
fencedCode,
inlineCode,
renderUserMessageText,
stripInjectedBlocks,
} from "./markdown.js";
const CUSTOM_TYPE = "pi-claudian-auto-save-markdown";
const ENV_SUBDIR = "PI_SAVE_CONVERSATION_DIR";
const DEFAULT_SUBDIR = "ai-conversations";
const COMMAND = "save-conversation";
const COMMAND_ALL = "save-conversation-all";
const NOTIFY_TAG = "[AutoSave]";
/**
* Generator identity recorded in the frontmatter. This extension only ever
* runs inside Pi, so the value is constant; the field is reserved so a future
* extension for a different agent (opencode, codex, …) can write its own.
*/
const AGENT = "pi";
/**
* Schema version of the save-state entries this extension writes, formatted
* "MAJOR.MINOR" and numbered independently of the package version: MAJOR for
* incompatible state changes, MINOR for backward-compatible additions. A
* state entry written by a NEWER schema means some other runtime in this
* session is running a newer version of the extension (a warm process that
* outlived a package upgrade) — see the mixed-version warning in computePlan.
* Bump MINOR when adding optional state fields, MAJOR when changing existing
* state semantics.
*/
const SAVE_STATE_SCHEMA = "1.2";
/**
* Version of the markdown document format this code writes ("MAJOR.MINOR",
* numbered independently of the save-state schema above). Semantics: the
* version of the LAST writer — an appended file is stamped with the current
* value even when blocks inside predate it, so "claimed version vs the block
* formats actually present" detects mixed-era files. Bump rules: MAJOR for
* structural breaks that change how a parser or migration tool must match
* blocks (message header structure, `---` separators, callout syntax);
* MINOR for parse-invariant tweaks and bugfixes (header styling, content
* transforms like the thinking repair, the blank line once written between
* the frontmatter and the document heading); additive frontmatter fields do
* NOT bump it — they are invisible to any within-major parser.
*/
const FORMAT_VERSION = "1.6";
/**
* Package version of this extension, read best-effort from the adjacent
* package.json at load time. Recorded in state entries purely so the
* mixed-version warning can name the newer writer; never used for comparison.
*/
const EXTENSION_VERSION: string | null = (() => {
try {
const pkg: unknown = JSON.parse(
fsSync.readFileSync(new URL("./package.json", import.meta.url), "utf-8"),
);
const v = (pkg as { version?: unknown }).version;
return typeof v === "string" && v ? v : null;
} catch {
return null;
}
})();
const MAX_TITLE_LENGTH = 60;
const TITLE_FALLBACK_LENGTH = 40;
type AgentMessage = SessionMessageEntry["message"];
type UserMessage = Extract;
type AssistantMessage = Extract;
type ToolResultMessage = Extract;
/**
* Per-save state persisted in the session tree via pi.appendEntry().
* `file` is the bare filename inside the target directory, so changing the
* configured directory (env var) moves future saves without breaking
* resolution — the file is simply recreated from the full branch if missing.
* `schema`/`extVersion` are absent on entries written before they existed;
* unknown extra fields on newer entries are ignored here (forward
* compatibility), so validation only covers the fields this code reads.
*/
interface SaveState {
/**
* Key segment of the filename this state addresses: the first 8 hex of the
* SHA-256 of the session id — stable per session, so files of one session
* cluster together (when no session id exists yet, the deepest message
* entry's id is hashed the same way). Only ever used as the recorded
* value — files are addressed by their full recorded name, never recomputed
* from the key. Renamed from branchKey at schema 1.2: legacy entries fail
* validation and are ignored — those branches get fresh files.
*/
sessionKey: string;
/**
* Id of the session tree leaf at save time (getLeafId()) — may be a custom
* state entry rather than a message; used to rank continuation candidates
* on the current path. Distinct from frontmatter `branch_last_entry_id`,
* which is the deepest message entry (an id actually present in the file).
*/
lastSavedEntryId: string | null;
file: string;
/** Save-state schema version ("MAJOR.MINOR") of the writer. */
schema?: string;
/** Package version of the writer, warning text only. */
extVersion?: string;
/**
* Whether the file was created with the session's real name (true) or
* with the fallback slug (false). Absent on legacy entries written before
* the rename-on-title feature — those are judged by recomputing the
* fallback slug against the actual filename. Once true it never goes back:
* a later user /name change must not re-trigger a filename rename.
*/
titled?: boolean;
}
/** Parse a "MAJOR.MINOR" schema version into numeric parts. */
function parseSchemaVersion(v: string): [number, number] | null {
const m = /^(\d+)\.(\d+)$/.exec(v.trim());
return m ? [Number(m[1]), Number(m[2])] : null;
}
/**
* Strictly-newer check, numeric per segment — never lexicographic ("1.10"
* must count as newer than "1.9"). Unparseable versions are treated as
* unknown and therefore not newer.
*/
function isNewerSchemaVersion(other: string, mine: string): boolean {
const a = parseSchemaVersion(other);
const b = parseSchemaVersion(mine);
if (!a || !b) return false;
return a[0] !== b[0] ? a[0] > b[0] : a[1] > b[1];
}
function isSaveState(v: unknown): v is SaveState {
if (typeof v !== "object" || v === null) return false;
const s = v as Record;
return (
typeof s.sessionKey === "string" &&
s.sessionKey.length > 0 &&
(s.lastSavedEntryId === null || typeof s.lastSavedEntryId === "string") &&
typeof s.file === "string" &&
s.file.length > 0
);
}
/**
* The save pipeline's complete view of a session: the narrow surface it needs
* from the host, so the exact same pipeline code runs for the live session
* (backed by the real ExtensionContext and pi.appendEntry) and for any other
* session of the project (backed by DiskSessionView, rebuilt from the session
* jsonl on disk, with state entries appended directly). The interface is a
* structural subset of pi's ExtensionContext/SessionManager, so the live
* adapter passes the real objects through unchanged — the batch feature is
* pure parameterization, byte-identical on the live path.
*/
interface SaveContext {
/** Working directory of the session — resolves the archive folder. */
readonly cwd: string;
/** Whether a UI surface exists; notify() is a no-op without one. */
readonly hasUI: boolean;
/** Fire-and-forget notification, guarded by hasUI. */
notify(message: string, level: "info" | "warning" | "error"): void;
/** Read-only session surface (structural subset of pi's SessionManager). */
readonly session: {
getBranch(): SessionEntry[];
getEntries(): SessionEntry[];
getSessionId(): string;
getSessionFile(): string | undefined;
getSessionName(): string | undefined;
getLeafId(): string | null;
};
/**
* Save-state discovery: disk-first for the live session; for disk views,
* the states parsed from the load snapshot — a fresh re-read could see
* entries newer than the frozen snapshot the save is based on, and
* consistency beats freshness.
*/
readSaveStates(): Promise;
/** Warn about a state entry written by a newer schema — once per session. */
warnMixedVersion(state: SaveState): void;
/**
* Concurrency guard before this save's first write. No-op for the live
* session (its writes are serialized through pi in-process). A disk view
* verifies its jsonl is unchanged since load (optimistic concurrency
* control — the file only ever grows, so the size at load is the version
* token) and throws SessionChangedError when another writer got in,
* deferring the session to the next run.
*/
beforeWrite(): Promise;
/**
* Persist this run's state entry: pi.appendEntry for the live session; for
* a disk view, a directly-appended jsonl line in the exact format pi's own
* appendCustomEntry/_persist writes (pi 0.82.1), parentId = the snapshot's
* last entry — the same topology an in-session save would leave.
*/
appendState(state: SaveState): Promise;
}
/** A foreign session jsonl changed on disk between load and write — defer. */
class SessionChangedError extends Error {}
interface SaveResult {
message: string;
/** A file was actually written (created or appended). */
wrote: boolean;
/** The write created a brand-new file (vs appending to an existing one). */
created: boolean;
file: string | null;
/** Why a planned continuation was replaced by a fresh full save, if it was. */
recovered: string | null;
/**
* Set when a fresh file was created because the tree moved to a different
* branch: the previous branch's file that stays on disk (info notice —
* the branch change is normal one-branch-one-file behavior, not a warning).
*/
switchedFrom: string | null;
/** The write included a rename-on-title move of the file (info notice). */
renamed: boolean;
}
interface BranchMeta {
title: string;
/** Generator identity; preserved from the file being appended to. */
agent: string;
sessionId: string | null;
sessionFile: string | null;
/**
* The filename key, mirrored into the frontmatter so the file is
* self-describing (field name `session_key`): the first 8 hex of the
* SHA-256 of the session id, or of the deepest message entry's id when
* no session id exists yet. A display/grouping key only — never used to
* address files (states record full filenames).
*/
sessionKey: string;
/**
* Id of the deepest message entry on the saved branch at the last write
* (field name `branch_last_entry_id`) — the file's exact position in the
* session jsonl tree, scoped to this file's branch rather than the
* session-wide last entry. Updated on every append, like `updated`.
*/
branchLastEntryId: string;
model: string | null;
provider: string | null;
cost: number;
tokensInput: number;
tokensOutput: number;
tokensCacheRead: number;
tokensCacheWrite: number;
messages: number;
created: string;
updated: string;
projectRoot: string;
}
// ---------- thinking fragmentation repair ----------
/**
* Some upstream reasoning streams (observed with z-ai/GLM via OpenRouter) store
* thinking as one word — or one CJK character — per line: the stream splits
* tokens into fragments joined by runs of newlines, and the original spaces
* survive only as leading spaces of the fragments. The saved markdown then has
* every token on its own line, which is miserable to read and bloats storage.
*
* Detection uses two signatures validated against ~520 real thinking blocks:
* lines starting with exactly one space (a survived word separator; blank-ish
* " " lines included), and an excess of 1–2-char non-list-marker lines (CJK
* fragments carry no leading space). Clean thinking never matches either.
*
* Repair re-joins the fragments into flowing text. Word separators — the
* whitespace runs between two fragments — lose their newlines: run length
* alone carries no recoverable meaning, because the same word separator
* appears as 1, 2 or 3 newlines depending on the block. Original paragraph
* boundaries survive as a faint but strong signal: a separator run of 3+
* newlines that follows a sentence-final character (closing quotes and
* brackets skipped when looking) marks a real paragraph break 73–93% of the
* time in corrupted blocks, while plain word separators sit mid-sentence —
* so exactly those separators become blank-line paragraph breaks and
* everything else is joined. The sentence-final guard means a break is never
* inserted mid-sentence: worst case, one lands between two complete
* sentences, which still reads fine. Join spacing comes from the separator
* itself: a separator containing a surviving space joins with one space, a
* bare one (CJK fragments, attached punctuation) joins with nothing. Clean
* blocks pass through untouched.
*/
/** Line whose single leading space is a survived word separator. */
function isThinkingSigLine(line: string): boolean {
return line === " " || /^ [^ *+\-\d]/.test(line);
}
/** Non-blank line of 1–2 chars that is not a standalone list marker. */
function isThinkingShortFragment(line: string): boolean {
const s = line.trim();
if (s.length === 0 || s.length > 2) return false;
return !/^([-*+]|\d+[.)])$/.test(s);
}
/** Whether a thinking block shows the newline-fragmentation corruption. */
function isFragmentedThinking(s: string): boolean {
const lines = s.split("\n");
const nonBlank = lines.filter((l) => l.trim().length > 0);
if (nonBlank.length === 0) return false;
const sig = lines.filter(isThinkingSigLine).length;
if (nonBlank.length < 8) return nonBlank.length >= 3 && sig >= 3;
if (sig / lines.length >= 0.12) return true;
return nonBlank.filter(isThinkingShortFragment).length / nonBlank.length >= 0.4;
}
/** Sentence-final characters: a long separator run after one may be a paragraph break. */
const THINKING_SENTENCE_END = /[.!?。!?…]/;
/** Closing punctuation skipped when looking for the sentence end behind it. */
const THINKING_CLOSING = /[)\]}"'”』」)】》]/;
/** Newlines a separator run needs before it can count as a paragraph break. */
const THINKING_PARAGRAPH_RUN = 3;
/** Repair newline-fragmented thinking; clean thinking is returned unchanged. */
function repairThinking(s: string): string {
if (!isFragmentedThinking(s)) return s;
debug("repairing fragmented thinking block:", s.length, "chars");
let out = "";
let i = 0;
while (i < s.length) {
let end = i;
while (end < s.length && !/\s/.test(s[end])) end++;
out += s.slice(i, end);
let next = end;
while (next < s.length && /\s/.test(s[next])) next++;
if (next >= s.length) break; // trailing whitespace: drop
const sep = s.slice(end, next);
if (!/[\n\r]/.test(sep)) {
out += " "; // plain spaces: a single word separator
} else {
const newlines = sep.match(/[\n\r]/g)!.length;
let p = out.length - 1;
while (p >= 0 && THINKING_CLOSING.test(out[p])) p--;
const sentenceEnd = p >= 0 && THINKING_SENTENCE_END.test(out[p]);
if (newlines >= THINKING_PARAGRAPH_RUN && sentenceEnd) out += "\n\n";
else if (/[ \t]/.test(sep)) out += " ";
// A bare newline separator attached CJK fragments or punctuation:
// join with nothing.
}
i = next;
}
return out.replace(/[ \t]{2,}/g, " ").trim();
}
// ---------- foreign sessions for /save-conversation-all ----------
function isMessageEntry(e: SessionEntry): e is SessionMessageEntry {
return e.type === "message";
}
/**
* pi has no extension API for enumerating or loading other sessions, so the
* batch command rebuilds each session straight from its jsonl on disk. The
* formats below were verified against pi 0.82.1's session-manager (PRD §5
* P3, F2/F10): entries are `{type, id, parentId, timestamp, …}` trees where
* the FIRST `type: "session"` header carries the session id and cwd; loading
* sets the leaf to the LAST entry in file order (there is no persisted leaf
* pointer), and the branch is the parentId chain walked from it — exactly
* what pi itself does on resume, so the batch saves the same branch the
* session would resume onto. Session names come from the latest
* `session_info` entry, also like pi.
*/
/** Result of loading one session file for the batch. */
type LoadedSession =
| { kind: "view"; view: DiskSessionView }
/** The file changed while being read (still written by its runtime) — retry next run. */
| { kind: "deferred" }
/** Not eligible this run. "legacy" = pre-id/parentId entries (v1) — see loadDiskSession. */
| { kind: "skip"; reason: "no-assistant" | "legacy" };
class DiskSessionView implements SaveContext {
readonly cwd: string;
readonly hasUI = true;
readonly session: SaveContext["session"];
readonly file: string;
private readonly entries: SessionEntry[];
private readonly pathEntries: SessionEntry[];
private readonly states: SaveState[];
private readonly sizeAtLoad: number;
private readonly sessionId: string;
private readonly name: string | undefined;
private readonly leafId: string | null;
private readonly sink: (message: string, level: "info" | "warning" | "error") => void;
/** Info notices are aggregated by the batch runner instead of shown one by one. */
infoNotices = 0;
constructor(opts: {
file: string;
entries: SessionEntry[];
pathEntries: SessionEntry[];
states: SaveState[];
sizeAtLoad: number;
sessionId: string;
cwd: string;
name: string | undefined;
leafId: string | null;
sink: (message: string, level: "info" | "warning" | "error") => void;
}) {
this.file = opts.file;
this.entries = opts.entries;
this.pathEntries = opts.pathEntries;
this.states = opts.states;
this.sizeAtLoad = opts.sizeAtLoad;
this.sessionId = opts.sessionId;
this.cwd = opts.cwd;
this.name = opts.name;
this.leafId = opts.leafId;
this.sink = opts.sink;
this.session = {
getBranch: () => this.pathEntries,
getEntries: () => this.entries,
getSessionId: () => this.sessionId,
getSessionFile: () => this.file,
getSessionName: () => this.name,
getLeafId: () => this.leafId,
};
}
/** Short session identity for per-session batch notices. */
tag(): string {
return this.sessionId.slice(0, 8);
}
notify(message: string, level: "info" | "warning" | "error"): void {
if (level === "info") {
// Batch aggregates info notices into the summary count.
this.infoNotices++;
debug(this.tag(), message);
} else {
// Warnings and errors are anomalies — one per session, tagged.
this.sink(`[${this.tag()}] ${message}`, level);
}
}
readSaveStates(): Promise {
// The frozen load snapshot, never a fresh re-read (see SaveContext).
return Promise.resolve(this.states);
}
warnMixedVersion(state: SaveState): void {
// Once per session (PRD 0.3: warn-once is per session; the live adapter
// keeps its per-process latch for the live path).
if (warnedSessions.has(this.sessionId)) return;
warnedSessions.add(this.sessionId);
const writer = state.extVersion
? `extension v${state.extVersion} (save-state schema ${state.schema})`
: `save-state schema ${state.schema}`;
debug(
this.tag(),
"mixed versions: state entries written by",
writer,
"— this code is",
SAVE_STATE_SCHEMA,
);
this.notify(
`this session was written to by a newer ${writer}, while this process runs older code — restart the session / reload Pi to load the new version`,
"warning",
);
}
/** OCC: the file must be byte-identical to the load snapshot. */
private async assertUnchanged(): Promise {
let size: number;
try {
size = (await fs.stat(this.file)).size;
} catch {
throw new SessionChangedError("session file vanished since load");
}
if (size !== this.sizeAtLoad) {
throw new SessionChangedError("session file changed since load");
}
}
beforeWrite(): Promise {
return this.assertUnchanged();
}
async appendState(state: SaveState): Promise {
// Last-resort OCC check immediately before the append: a session written
// to after this point cannot be helped (the µs stat→append window is
// accepted, PRD §9.6 D7), but anything earlier is caught here.
await this.assertUnchanged();
const entry = {
type: "custom" as const,
customType: CUSTOM_TYPE,
data: state,
id: this.generateId(),
parentId: this.leafId,
timestamp: new Date().toISOString(),
};
// Single small line via O_APPEND — the same single-write append pi's
// _persist does; a concurrent writer interleaves at line granularity
// at worst, and every reader skips bad lines.
await fs.appendFile(this.file, JSON.stringify(entry) + "\n", "utf-8");
debug(this.tag(), "recorded state entry — sessionKey:", state.sessionKey, "file:", state.file);
}
/** pi's generateId semantics: random 8-hex, collision-checked against the session's ids. */
private generateId(): string {
for (let i = 0; i < 100; i++) {
const id = randomUUID().slice(0, 8);
if (!this.entries.some((e) => e.id === id)) return id;
}
return randomUUID();
}
}
/** Sessions warned about mixed save-state schemas by the batch (per session). */
const warnedSessions = new Set();
/**
* Load one foreign session jsonl for the batch: read it once (OCC — the size
* is captured before the read and re-checked after, so the snapshot is
* exactly the file as of that size), then rebuild the tree and its current
* branch. Skips: sessions with no assistant reply (nothing conversational to
* archive — pi does not even persist sessions until their first assistant
* entry, so this is mostly a defense against partial/corrupt files); v1-era
* files with pre-id/parentId entries — pi rewrites those on load with ids of
* its own choosing, so a state line we append (parentId pointing at OUR
* synthetic ids) would become an orphan root and steal the resume position
* once pi migrates the file, and archiving without a state entry would mint a
* duplicate file on every run. Skipping is the only safe treatment.
*/
async function loadDiskSession(
file: string,
sink: (message: string, level: "info" | "warning" | "error") => void,
): Promise {
const before = await fs.stat(file); // OCC version token S0
const text = await fs.readFile(file, "utf-8");
const after = await fs.stat(file);
if (after.size !== before.size) return { kind: "deferred" };
const raw: unknown[] = [];
for (const line of text.split("\n")) {
if (!line.trim()) continue;
try {
raw.push(JSON.parse(line));
} catch {
continue; // skip corrupt / mid-write lines (pi's own loader does the same)
}
}
const header = raw.find(
(e): e is { type: "session"; id?: unknown; cwd?: unknown } =>
typeof e === "object" && e !== null && (e as { type?: unknown }).type === "session",
);
if (
!header ||
typeof header.id !== "string" ||
!header.id ||
typeof header.cwd !== "string" ||
!header.cwd
) {
throw new Error("no valid session header");
}
const entries: SessionEntry[] = [];
let sawLegacy = false;
for (const e of raw) {
if (typeof e !== "object" || e === null) continue;
const r = e as Record;
if (r.type === "session") continue; // header — not part of the tree
// v1-era entries have no id/parentId structure (pi's migrateV1ToV2
// assigns them at load time). See the function doc for why we skip.
if (typeof r.id !== "string" || r.parentId === undefined) {
sawLegacy = true;
break;
}
if (r.type === "message" && typeof r.message !== "object") continue; // corrupt
entries.push(e as SessionEntry);
}
if (sawLegacy) return { kind: "skip", reason: "legacy" };
if (!entries.some((e) => isMessageEntry(e) && e.message.role === "assistant")) {
return { kind: "skip", reason: "no-assistant" };
}
const byId = new Map();
for (const e of entries) byId.set(e.id, e);
// pi's _buildIndex: leaf = last entry in file order (there is no persisted
// leaf pointer — the file's last line IS the resume position, F10).
const leafId = entries.length > 0 ? entries[entries.length - 1].id : null;
// Current branch: parentId chain walked from the leaf, exactly like
// SessionManager.getBranch. The iteration cap is paranoia against cycles.
const pathEntries: SessionEntry[] = [];
{
let current = leafId !== null ? byId.get(leafId) : undefined;
const seen = new Set();
while (current && !seen.has(current.id)) {
seen.add(current.id);
pathEntries.push(current);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
pathEntries.reverse();
}
// Latest session_info name, reverse-walked (SessionManager.getSessionName).
let name: string | undefined;
for (let i = entries.length - 1; i >= 0; i--) {
const e = entries[i];
if (e.type === "session_info") {
name = e.name?.trim() || undefined;
break;
}
}
const states: SaveState[] = [];
for (const e of entries) {
if (e.type === "custom" && e.customType === CUSTOM_TYPE && isSaveState(e.data)) {
states.push(e.data);
}
}
debug(
"loaded session for batch:",
path.basename(file),
"— entries:",
entries.length,
"path:",
pathEntries.length,
"states:",
states.length,
);
return {
kind: "view",
view: new DiskSessionView({
file,
entries,
pathEntries,
states,
sizeAtLoad: before.size,
sessionId: header.id,
cwd: header.cwd,
name,
leafId,
sink,
}),
};
}
/**
* The project's sessions directory: the folder holding the live session's
* jsonl. Fallback for file-less (in-memory) live sessions mirrors pi's
* encoding (session-manager v0.82.1: cwd with /, \, : turned into dashes,
* wrapped in -- … --) under the default ~/.pi/agent/sessions.
*/
function defaultSessionsDir(cwd: string): string {
const safe = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
return path.join(os.homedir(), ".pi", "agent", "sessions", safe);
}
export default function (pi: ExtensionAPI) {
/**
* Resolve the target directory. The env var may hold a relative folder name
* (resolved against the session cwd), an absolute path, or "." / "" for the
* working directory itself. When unset, the default subfolder is used.
*/
function targetDir(ctx: SaveContext): string {
const env = process.env[ENV_SUBDIR];
if (env === undefined) return path.join(ctx.cwd, DEFAULT_SUBDIR);
const raw = env.trim();
if (raw === "" || raw === ".") return ctx.cwd;
return path.resolve(ctx.cwd, raw);
}
// ---------- formatting helpers ----------
function pad(n: number): string {
return String(n).padStart(2, "0");
}
/** Local-time filename timestamp: YYYYMMDD-HHmmss. */
function fileTimestamp(d: Date): string {
return (
`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
`-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
);
}
/** Local-time label for a message entry: YYYY-MM-DD HH:MM:SS. */
function dateTime(iso: string): string {
const d = new Date(iso);
return (
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
` ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
);
}
/**
* Wrap header metadata (timestamp, model) in a small faint span: 0.5em text
* in Obsidian's `--text-faint` color, so the role stays visually dominant.
* HTML-escaped because the model string lands inside a raw HTML span.
*/
function metaSpan(text: string): string {
const safe = text.replace(/&/g, "&").replace(//g, ">");
return `${safe} `;
}
/** Setext-H1 info header: `Role meta ` underlined with `===`. */
function messageHeader(role: string, meta: string): string {
return `${role} ${metaSpan(meta)}\n===`;
}
/** Make a string safe for use as a filename component. */
function sanitizeFilenamePart(s: string): string {
return s
.replace(/[\u0000-\u001f\u007f]/g, "")
.replace(/[\\/:*?"<>|]/g, "-")
.replace(/\s+/g, " ")
.trim()
.replace(/\s/g, "-")
.replace(/-+/g, "-")
.replace(/^[-.]+|[-.]+$/g, "")
.slice(0, MAX_TITLE_LENGTH)
.replace(/[-.]+$/g, "");
}
/** Extract the plain text of a user message content (string or blocks). */
function userText(content: UserMessage["content"]): string {
if (typeof content === "string") return content;
return content
.map((b) =>
b.type === "text" ? b.text : `_[image: ${"mimeType" in b ? b.mimeType : "unknown"}]_`,
)
.join("\n\n");
}
function firstUserText(messages: SessionMessageEntry[]): string | undefined {
for (const e of messages) {
if (e.message.role === "user") {
// Title derivation reads the plain typed message — every known
// injected block (see markdown.ts) is stripped, mirroring how the
// client strips the same blocks for its own session titles. A first
// message that is only injected blocks (an attachment with no typed
// text) is skipped, so a later message's typed text still titles the
// session instead of everything falling back to "untitled".
const text = stripInjectedBlocks(userText(e.message.content));
if (text) return text;
}
}
return undefined;
}
/** Fallback filename title: a slug of the first user message. */
function fallbackTitleForFilename(firstUser: string | undefined): string {
if (firstUser) {
const slug = sanitizeFilenamePart(firstUser.slice(0, TITLE_FALLBACK_LENGTH));
if (slug) return slug;
}
return "untitled";
}
/** Title for the filename: session name, else a slug of the first user message. */
function titleForFilename(ctx: SaveContext, firstUser: string | undefined): string {
const name = ctx.session.getSessionName()?.trim();
return name ? sanitizeFilenamePart(name) || "untitled" : fallbackTitleForFilename(firstUser);
}
/** Title for the frontmatter / document heading. */
function displayTitle(ctx: SaveContext, firstUser: string | undefined): string {
const name = ctx.session.getSessionName()?.trim();
if (name) return name;
if (firstUser) {
const snippet = firstUser.replace(/\s+/g, " ").trim().slice(0, MAX_TITLE_LENGTH);
if (snippet) return snippet;
}
return "untitled";
}
/** Full argument JSON on one line (JSON.stringify escapes newlines), never truncated. */
function renderArgs(args: unknown): string {
try {
return JSON.stringify(args) ?? "";
} catch {
return String(args);
}
}
// ---------- markdown rendering ----------
/**
* Strip leading blank lines and trailing whitespace from a rendered block,
* so joins and separators always keep exactly one blank line around them
* no matter what blank lines the content itself starts or ends with.
*/
function tighten(s: string): string {
return s.replace(/^(?:[ \t]*\n)+/, "").replace(/\s+$/, "");
}
/** One tool call with its paired full result (null when no result entry exists). */
interface RenderedToolCall {
name: string;
args: string;
result: string | null;
error: boolean;
}
/**
* Full raw result text: text blocks joined with blank lines, non-text
* blocks as placeholders. Error status stays OUT of the content (it rides
* the call's head line) so the saved text is exactly what the tool
* returned.
*/
function resultText(m: ToolResultMessage): string {
const texts: string[] = [];
for (const b of m.content) {
if (b.type === "text") texts.push(b.text);
else texts.push(`_[image: ${b.mimeType}]_`);
}
return texts.join("\n\n").trim();
}
/**
* Standalone block for a result whose call is not in this file — same full
* content as folded results.
*/
function renderToolResult(m: ToolResultMessage): string {
const text = resultText(m);
const err = m.isError ? " (error)" : "";
return `**Tool · ${m.toolName}**${err}\n\n${text ? fencedCode(text) : "_(empty result)_"}`;
}
/** "read, web_search ×2" — tool names with repeat counts, first-seen order. */
function summarizeToolNames(names: string[]): string {
const counts = new Map();
for (const n of names) counts.set(n, (counts.get(n) ?? 0) + 1);
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(", ");
}
/**
* Fold tool calls and their paired results into one collapsed callout.
* Arguments render as full JSON in inline code spans and results verbatim
* in fenced code blocks, so raw output renders literally instead of being
* parsed as markdown.
*/
function renderToolCallsCallout(calls: RenderedToolCall[]): string {
const summary = summarizeToolNames(calls.map((c) => c.name));
const items = calls.map((c) => {
const err = c.error ? " (error)" : "";
const head = c.args
? `**\`${c.name}\`**${err} ${inlineCode(c.args)}`
: `**\`${c.name}\`**${err}`;
const result =
c.result === null ? "_(no result)_" : c.result ? fencedCode(c.result) : "_(empty result)_";
return `${head}\n\n${result}`;
});
return callout("quote", `Tool Calls · ${calls.length} (${summary})`, items.join("\n\n"));
}
function renderAssistant(
m: AssistantMessage,
t: string,
results: Map,
): string {
// Render blocks in their original chronological order: thinking always
// precedes the text it produced, instead of being grouped after the fact.
// Setext H1 (`===` underline): one level above the `##` headings AI
// content typically starts with, and distinct from content `#` headings.
const header = messageHeader("Assistant", [t, m.model].filter(Boolean).join(" · "));
const parts: string[] = [];
const thinkings: string[] = [];
const flushThinking = () => {
if (thinkings.length) {
parts.push(callout("tldr", "Thinking", thinkings.join("\n\n")));
thinkings.length = 0;
}
};
const calls: RenderedToolCall[] = [];
for (const b of m.content) {
if (b.type === "text") {
flushThinking();
parts.push(b.text);
} else if (b.type === "thinking") {
thinkings.push(repairThinking(b.thinking));
} else if (b.type === "toolCall") {
flushThinking();
const r = results.get(b.id);
results.delete(b.id);
calls.push({
name: b.name,
args: renderArgs(b.arguments),
result: r ? resultText(r) : null,
error: r ? r.isError : false,
});
}
}
flushThinking();
if (calls.length) parts.push(renderToolCallsCallout(calls));
if (m.errorMessage) parts.push(`> Error: ${m.errorMessage.replace(/\s+/g, " ").trim()}`);
if (parts.length === 0) parts.push("_(empty response)_");
return `${header}\n\n${parts.join("\n\n")}`;
}
/** Render a chronological list of message entries as markdown blocks. */
function renderEntries(entries: SessionMessageEntry[]): string {
// Tool calls sit in assistant entries while their results are separate
// toolResult entries, paired by toolCall id. Collect results first so
// each assistant block can fold its calls together with their results;
// results left unclaimed (their call was saved in an earlier file, e.g.
// a mid-turn manual save) render as standalone blocks.
const results = new Map();
for (const e of entries) {
if (e.message.role === "toolResult") results.set(e.message.toolCallId, e.message);
}
const blocks: string[] = [];
for (const e of entries) {
const m = e.message;
const t = dateTime(e.timestamp);
if (m.role === "user") {
blocks.push(`${messageHeader("User", t)}\n\n${renderUserMessageText(userText(m.content))}`);
} else if (m.role === "assistant") {
blocks.push(renderAssistant(m, t, results));
} else if (m.role === "toolResult") {
// Claimed results (deleted from the map by their assistant block)
// were already folded inline; the rest have no call in this file.
if (!results.has(m.toolCallId)) continue;
blocks.push(renderToolResult(m));
}
// Other roles (custom, bashExecution, branchSummary, compactionSummary)
// are not part of the rendered conversation record.
}
if (blocks.length === 0) return "";
// Every block ends with a `---` separator wrapped in single blank lines
// (the blank line above also keeps `---` from turning the last content
// line into a setext H2). The trailing separator after the final block
// makes later appends uniform: new blocks simply continue after it.
return `${blocks.map(tighten).join("\n\n---\n\n")}\n\n---\n`;
}
// ---------- frontmatter ----------
function yamlQuote(s: string): string {
const safe = s.replace(/[\r\n]+/g, " ");
return `"${safe.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
/**
* ISO 8601 timestamp in the machine's local timezone, with the numeric
* UTC offset appended (e.g. "2026-08-29T13:05:12+08:00"): tz-aware, so the
* value reads as local wall-clock time without assuming the reader's
* timezone. Legacy files written with UTC "Z" values parse identically.
*/
function localIsoTimestamp(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
const offsetMin = -date.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const abs = Math.abs(offsetMin);
return (
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
`${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
);
}
function frontmatter(meta: BranchMeta): string {
const lines: string[] = ["---"];
lines.push(`title: ${yamlQuote(meta.title)}`);
lines.push(`agent: ${yamlQuote(meta.agent)}`);
lines.push(`format_version: ${yamlQuote(FORMAT_VERSION)}`);
if (meta.sessionId) lines.push(`session_id: ${yamlQuote(meta.sessionId)}`);
lines.push(`session_key: ${yamlQuote(meta.sessionKey)}`);
lines.push(`branch_last_entry_id: ${yamlQuote(meta.branchLastEntryId)}`);
if (meta.model) lines.push(`model: ${yamlQuote(meta.model)}`);
if (meta.provider) lines.push(`provider: ${yamlQuote(meta.provider)}`);
lines.push(`cost: ${meta.cost.toFixed(6)}`);
// `tokens` counts everything billed, including cached tokens, so it is
// comparable with provider-side token totals (e.g. OpenRouter activity).
lines.push(
`tokens: ${meta.tokensInput + meta.tokensOutput + meta.tokensCacheRead + meta.tokensCacheWrite}`,
);
lines.push(`tokens_input: ${meta.tokensInput}`);
lines.push(`tokens_output: ${meta.tokensOutput}`);
lines.push(`tokens_cache_read: ${meta.tokensCacheRead}`);
lines.push(`tokens_cache_write: ${meta.tokensCacheWrite}`);
lines.push(`messages: ${meta.messages}`);
lines.push(`created: ${yamlQuote(meta.created)}`);
lines.push(`updated: ${yamlQuote(meta.updated)}`);
lines.push(`project_root: ${yamlQuote(meta.projectRoot)}`);
if (meta.sessionFile) lines.push(`session_file: ${yamlQuote(meta.sessionFile)}`);
lines.push("---");
return lines.join("\n");
}
/** Recover the `messages` count from an existing frontmatter block. */
function parseMessageCount(content: string): number | null {
const m = content.match(/^messages: (\d+)$/m);
return m ? Number(m[1]) : null;
}
/** Recover the original creation timestamp from an existing frontmatter block. */
function parseCreated(content: string): string | undefined {
const m = content.match(/^created: "(.*)"$/m);
if (!m) return undefined;
const iso = m[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\");
return Number.isNaN(Date.parse(iso)) ? undefined : iso;
}
/** Recover the generator identity from an existing frontmatter block. */
function parseAgent(content: string): string | undefined {
const m = content.match(/^agent: "(.*)"$/m);
if (!m) return undefined;
const agent = m[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\").trim();
return agent || undefined;
}
function computeMeta(
ctx: SaveContext,
pathMessages: SessionMessageEntry[],
sessionKey: string,
created: string | undefined,
agent: string | undefined,
): BranchMeta {
let model: string | null = null;
let provider: string | null = null;
let cost = 0;
let tokensInput = 0;
let tokensOutput = 0;
let tokensCacheRead = 0;
let tokensCacheWrite = 0;
for (const e of pathMessages) {
const m = e.message;
const usage = m.role === "assistant" ? m.usage : m.role === "toolResult" ? m.usage : null;
if (m.role === "assistant") {
model = m.model;
provider = m.provider;
}
if (usage) {
cost += usage.cost?.total ?? 0;
tokensInput += usage.input ?? 0;
tokensOutput += usage.output ?? 0;
tokensCacheRead += usage.cacheRead ?? 0;
tokensCacheWrite += usage.cacheWrite ?? 0;
}
}
const now = localIsoTimestamp(new Date());
return {
title: displayTitle(ctx, firstUserText(pathMessages)),
agent: agent ?? AGENT,
sessionId: ctx.session.getSessionId(),
sessionFile: ctx.session.getSessionFile() ?? null,
sessionKey,
branchLastEntryId: pathMessages[pathMessages.length - 1].id,
model,
provider,
cost,
tokensInput,
tokensOutput,
tokensCacheRead,
tokensCacheWrite,
messages: pathMessages.length,
created: created ?? now,
updated: now,
projectRoot: ctx.cwd,
};
}
// ---------- save planning ----------
interface SavePlan {
dir: string;
filename: string;
sessionKey: string;
/** Write the full branch content (new branch, or target file missing). */
fullCreate: boolean;
/** Entries to append when continuing an existing file. */
appendEntries: SessionMessageEntry[];
/** Full root→leaf message list of the current branch (for meta/full renders). */
pathMessages: SessionMessageEntry[];
/** State of the file being continued, when not a full create. */
state: SaveState | null;
/** Set when resolvePlan deviated from the newest state (downgrade, fresh full save). */
recoveryReason: string | null;
/**
* Rename-on-title: bare filename to move the file to after this save's
* write (the file was created with the fallback slug and the session now
* has its real name). Null when no rename is due.
*/
renameTo: string | null;
/**
* Whether the file counts as properly named — recorded in the state
* entry so a fallback-created file is renamed at most once.
*/
titled: boolean;
/**
* Branch-change notice: the previous branch's file that stays on disk,
* when this plan is a fresh file because recorded states exist but none
* lies on the current path (a brand-new session has no states at all).
*/
switchedFrom: string | null;
}
/** One on-path save state ranked for the continuation candidate chain. */
interface StateCandidate {
state: SaveState;
/** Index of the state's lastSavedEntryId on the current path. */
pos: number;
/** Scan order (append order = record order); the later record wins ties. */
order: number;
}
/** Pre-resolution save decision: everything needed to rank and validate candidates. */
interface SavePlanInput {
dir: string;
pathMessages: SessionMessageEntry[];
/** entryId → index in the current path, for candidate positions and appends. */
pos: Map;
/** On-path candidates ranked by position desc, then record order desc. */
candidates: StateCandidate[];
/**
* Latest recorded state that is NOT on the current path, when any state
* exists: a fresh file with no candidates is a branch change (info
* notice naming this state's file), not a brand-new session.
*/
offPathState: SaveState | null;
}
/** Brand-new file plan for the current branch (new branch, or recovery). */
function freshFilePlan(
ctx: SaveContext,
dir: string,
pathMessages: SessionMessageEntry[],
): SavePlan {
// Session-key scheme: the filename key is the first 8 hex of the SHA-256
// of the session id — stable per session, so every file of one session
// clusters together across recoveries and resumes. Never truncate the id
// itself: pi session ids are uuidv7 whose leading hex is a millisecond
// timestamp, so same-month sessions share long prefixes. When no session
// id exists yet — the degenerate fallback — the deepest message entry's
// id is hashed the same way, so the key is always an opaque 8-hex cluster
// key, never a raw entry id.
const sessionId = ctx.session.getSessionId();
const sessionKey = createHash("sha256")
.update(sessionId ?? pathMessages[pathMessages.length - 1].id)
.digest("hex")
.slice(0, 8);
const title = titleForFilename(ctx, firstUserText(pathMessages));
const filename = `${title}-${sessionKey}-${fileTimestamp(new Date())}.md`;
debug("new branch file:", filename, "sessionKey:", sessionKey);
return {
dir,
filename,
sessionKey,
fullCreate: true,
appendEntries: [],
pathMessages,
state: null,
recoveryReason: null,
renameTo: null,
switchedFrom: null,
// A fresh file created under the session's real name is born titled;
// one created under the fallback slug stays renameable until a real
// name arrives.
titled: Boolean(ctx.session.getSessionName()?.trim()),
};
}
/**
* Read every save-state entry of this session straight from the session
* jsonl on disk. The append log is the single source of truth shared by all
* runtimes, so a warm process whose in-memory tree lags behind still sees
* states recorded by other runtimes — the stale-tree incident's root cause.
* The full file is read once per save and each line passes a substring
* pre-check before JSON.parse (grep level: only the handful of matching
* lines are parsed), and corrupt / mid-write lines are skipped so a
* concurrent append cannot poison the scan. Returns null when the disk
* state is unavailable (no session file yet, or unreadable) — callers then
* fall back to the in-memory tree scan.
*/
async function readDiskSaveStates(sessionFile: string | null): Promise {
if (!sessionFile) return null;
let text: string;
try {
text = await fs.readFile(sessionFile, "utf-8");
} catch (e) {
debug("cannot read session file for state discovery — falling back to memory:", String(e));
return null;
}
const started = Date.now();
const states: SaveState[] = [];
for (const line of text.split("\n")) {
if (!line.includes(CUSTOM_TYPE)) continue;
let entry: { type?: unknown; customType?: unknown; data?: unknown };
try {
entry = JSON.parse(line);
} catch {
continue; // skip corrupt / mid-write lines
}
if (entry.type === "custom" && entry.customType === CUSTOM_TYPE && isSaveState(entry.data)) {
states.push(entry.data as SaveState);
}
}
debug("disk state scan:", states.length, "states in", Date.now() - started, "ms");
return states;
}
/**
* Decide which file the current branch belongs to and what to write.
*
* Every save appends a custom entry recording {sessionKey, lastSavedEntryId,
* file, schema}. Those entries live in the session tree, so a branch's own
* latest state is always recoverable — including after resume, /tree
* navigation, or /fork. State discovery is disk-first (see
* readDiskSaveStates); the in-memory tree is only a fallback when the
* session file is unavailable.
*
* ALL recorded states are considered — keeping only the latest state per
* file could shadow an on-path state with one recorded on a different
* branch, forcing needless new files. Every state whose saved position
* still lies on the current root→leaf path becomes a continuation
* candidate, ranked deepest position first. Ties on position (several
* states recording the same lastSavedEntryId with different files — after
* a loss recovery, a title rename) go to the state recorded LAST: scan
* order is append order, and the newest record names the file the latest
* successful save actually wrote, while older ones name superseded or dead
* files. When the tree moved (navigation + re-ask), no position matches and
* resolvePlan starts a new file; states existing but none matching means
* a branch change, and the fresh file then carries the previous branch's
* file as a notice (switchedFrom).
*
* Positions are resolved against THIS process's view of the current path:
* a state recorded by another runtime on a genuinely diverged branch never
* matches, so it correctly does not take over this branch's file (one
* branch, one file).
*/
async function computePlan(ctx: SaveContext): Promise {
const pathEntries = ctx.session.getBranch();
const pathMessages = pathEntries.filter(isMessageEntry);
if (pathMessages.length === 0) return null;
const pos = new Map();
pathEntries.forEach((e, i) => pos.set(e.id, i));
let states = await ctx.readSaveStates();
if (states === null) {
states = [];
for (const e of ctx.session.getEntries()) {
if (e.type !== "custom" || e.customType !== CUSTOM_TYPE || !isSaveState(e.data)) continue;
states.push(e.data);
}
debug("disk state unavailable — in-memory scan found", states.length, "states");
}
const candidates: StateCandidate[] = [];
// Latest state not on the current path, in record (scan) order: when no
// candidate matches, its file is the previous branch's file that stays on
// disk — named in the branch-change info notice.
let offPathState: SaveState | null = null;
let order = 0;
for (const st of states) {
if (st.schema && isNewerSchemaVersion(st.schema, SAVE_STATE_SCHEMA)) {
ctx.warnMixedVersion(st);
}
if (!st.lastSavedEntryId) {
offPathState = st;
continue;
}
const p = pos.get(st.lastSavedEntryId);
if (p === undefined) {
offPathState = st;
continue;
}
candidates.push({ state: st, pos: p, order: order++ });
}
candidates.sort((a, b) => b.pos - a.pos || b.order - a.order);
return { dir: targetDir(ctx), pathMessages, pos, candidates, offPathState };
}
// ---------- writing ----------
async function atomicWrite(file: string, content: string): Promise {
const tmp = file + ".save-tmp";
await fs.writeFile(tmp, content, "utf-8");
await fs.rename(tmp, file);
}
/**
* Claim a filename that does not exist yet, never overwriting: an existing
* target falls back to -1, -2 … suffixes (POSIX fs.rename silently replaces
* an existing file). Every name-taking write goes through this — both
* rename-on-title targets and fresh full saves, where two runtimes
* recovering the same lost file in the same second would otherwise mint
* the same name and silently overwrite each other. Returns null when the
* desired name and 99 suffixes are all taken.
*/
async function claimFilename(dir: string, desired: string): Promise {
const stem = desired.replace(/\.md$/, "");
let target = desired;
for (let i = 1; ; i++) {
const taken = await fs
.access(path.join(dir, target))
.then(() => true)
.catch(() => false);
if (!taken) return target;
if (i > 99) {
debug("cannot claim a filename — desired name and 99 suffixes exist:", desired);
return null;
}
target = `${stem}-${i}.md`;
}
}
function replaceFrontmatter(existing: string, fm: string): string {
if (/^---\r?\n[\s\S]*?\r?\n---\r?\n/.test(existing)) {
const rest = existing.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "");
// Heal the old layout's blank line(s) between the frontmatter and the
// document heading (new files write the heading directly after the
// frontmatter). Only blank lines immediately followed by a `#` heading
// at the very start of the body are stripped — anything the user
// reorganized stays untouched.
const healed = rest.replace(/^(?:[ \t]*\r?\n)+(#[^\r\n]*)/, "$1");
return fm + "\n" + healed;
}
return `${fm}\n\n${existing}`;
}
/**
* Rewrite the document heading (the `# title` line written at file
* creation) to the current title. Only called on rename-on-title saves.
* The heading is located as the first non-blank line after the frontmatter
* closing delimiter — new files write it directly after the frontmatter
* with no blank line, legacy files wrote exactly one blank line before it
* (replaceFrontmatter heals that away on appends) — rather than "the
* first # line anywhere", so a manually deleted heading (whose place
* would otherwise be taken by some content heading further down) cannot
* be mis-rewritten.
*/
function rewriteDocumentHeading(content: string, title: string): string {
const fm = /^---\r?\n[\s\S]*?\r?\n---\r?\n/.exec(content);
if (!fm) return content;
const rest = content.slice(fm[0].length);
// Blank lines between the frontmatter and the heading belong to the
// layout, not the heading — keep them, replace only the heading line.
const replaced = rest.replace(
/^((?:[ \t]*\r?\n)*)#[^\r\n]*/,
(blank: string, prefix: string) => `${prefix}# ${title.replace(/[\r\n]+/g, " ")}`,
);
if (replaced === rest) return content; // heading deleted by hand — leave it
return fm[0] + replaced;
}
/**
* Rename-on-title decision for a file being continued (PRD §9.4).
*
* A file created before the session had its real name carries the fallback
* slug in its filename. Once the session name exists, the save renames it
* exactly once:
* - "Fallback-named" comes from the state entry's `titled` flag; legacy
* entries (written before the flag existed) are judged by recomputing
* the fallback slug from the first user message and comparing it against
* the actual filename segment — a manually organized filename therefore
* never matches and is left alone.
* - The target keeps the ORIGINAL creation timestamp (`--`
* with ts parsed from the old filename): the file's birthday stays
* honest, and the target is a deterministic function of the file's own
* identity, so concurrent or retried renames converge on one name.
* - No rename while the session is still unnamed, when the name sanitizes
* to the current segment (no-op), or when the filename does not parse.
*/
function planRenameOnTitle(
ctx: SaveContext,
state: SaveState,
pathMessages: SessionMessageEntry[],
): { renameTo: string | null; titled: boolean } {
const keySuffix = "-" + state.sessionKey;
// The trailing group tolerates (and drops) the -1, -2 … suffixes that
// claimFilename may have added, so a suffixed file keeps its rename
// eligibility; the suffix is not carried into the deterministic target.
const tsMatch = /-(\d{8}-\d{6})(?:-\d{1,2})?\.md$/.exec(state.file);
const base = tsMatch !== null ? state.file.slice(0, tsMatch.index) : null;
const hasSegments = base !== null && base.endsWith(keySuffix);
const titleSegment = hasSegments ? base.slice(0, base.length - keySuffix.length) : null;
const isFallbackNamed =
state.titled !== undefined
? !state.titled
: titleSegment !== null &&
titleSegment === fallbackTitleForFilename(firstUserText(pathMessages));
if (!isFallbackNamed) {
// Already properly named, or a legacy file the user organized by hand:
// never rename, and record it as titled.
return { renameTo: null, titled: true };
}
const name = ctx.session.getSessionName()?.trim();
if (!name) return { renameTo: null, titled: false };
const newTitle = sanitizeFilenamePart(name);
const ts = tsMatch?.[1];
if (!newTitle || !ts || titleSegment === null || newTitle === titleSegment) {
return { renameTo: null, titled: false };
}
const renameTo = `${newTitle}-${state.sessionKey}-${ts}.md`;
debug("rename-on-title planned:", state.file, "→", renameTo);
return { renameTo, titled: false };
}
/**
* Pick the continuation from the ranked candidates: validate each
* newest-first — the target file must exist AND its frontmatter `messages`
* count must cover the messages already saved for this branch (path
* messages minus the ones about to be appended; a count that exceeds it
* is fine — a descendant branch extended the same file) — and keep the
* FIRST candidate that passes.
*
* When the newest candidate fails but an older one validates, the save
* downgrades to the older file and a recoveryReason warns about it: the
* newest target failing is the anomaly worth surfacing (an external tool
* moving files, or a concurrent rewrite), and silently converging onto the
* older file would hide it. Only when EVERY candidate fails — deleted
* files, or files rewritten from a different tree position (e.g. /tree
* navigation plus a save on an older branch), where continuing could
* silently strand this branch's newer messages — is a brand-new file with
* the full current branch written, with a warning naming the newest target
* and its failure. Both missing-target and count-mismatch failures are
* distinguished in the warning text, since they point at different
* causes (something moving/deleting files vs. a concurrent runtime or an
* older extension version). No cap on the candidate walk: the usual case
* passes on the first candidate (one read); the worst case is one read per
* candidate, and cutting the chain short would break the
* "fresh file only when everything failed" semantics.
*/
async function resolvePlan(ctx: SaveContext, input: SavePlanInput): Promise {
if (input.candidates.length === 0) {
const fresh = freshFilePlan(ctx, input.dir, input.pathMessages);
// Recorded states exist but none lies on the current path: the tree
// moved to a different branch (a brand-new session has no states at
// all). Normal one-branch-one-file behavior — surfaced as info so the
// earlier branch's kept file is not mistaken for this branch's file.
fresh.switchedFrom = input.offPathState ? input.offPathState.file : null;
return fresh;
}
// Failure of the newest candidate, recorded once for the downgrade /
// fresh-file warnings: the newest target is the anomaly, later failures
// only explain why the chain kept walking.
let topFailure: {
kind: "missing" | "count";
file: string;
saved: number | null;
expected: number;
} | null = null;
for (let i = 0; i < input.candidates.length; i++) {
const c = input.candidates[i];
const appendEntries = input.pathMessages.filter((e) => (input.pos.get(e.id) ?? -1) > c.pos);
const expected = input.pathMessages.length - appendEntries.length;
const filePath = path.join(input.dir, c.state.file);
const exists = await fs
.access(filePath)
.then(() => true)
.catch(() => false);
if (!exists) {
debug("candidate #" + (i + 1), "missing on disk:", c.state.file);
if (!topFailure) {
topFailure = { kind: "missing", file: c.state.file, saved: null, expected };
}
continue;
}
const existing = await fs.readFile(filePath, "utf-8");
const saved = parseMessageCount(existing);
// The file holds this branch's prefix plus possibly a descendant
// branch's extra messages (saved > expected with nothing new to
// append): that is fine to keep. Missing messages (saved < expected,
// or none readable), or extra messages that new appends would
// interleave with, are not — both would silently strand messages.
if (saved === null || saved < expected || (saved > expected && appendEntries.length > 0)) {
debug(
"candidate #" + (i + 1),
"out of sync (messages:",
saved,
"expected:",
expected,
"):",
c.state.file,
);
if (!topFailure) {
topFailure = { kind: "count", file: c.state.file, saved, expected };
}
continue;
}
debug(
"continuing branch file:",
c.state.file,
"saved-up-to:",
c.state.lastSavedEntryId,
"new entries:",
appendEntries.length,
"(candidate #" + (i + 1) + " of " + input.candidates.length + ")",
);
const rename = planRenameOnTitle(ctx, c.state, input.pathMessages);
return {
dir: input.dir,
filename: c.state.file,
sessionKey: c.state.sessionKey,
fullCreate: false,
appendEntries,
pathMessages: input.pathMessages,
state: c.state,
renameTo: rename.renameTo,
titled: rename.titled,
switchedFrom: null,
recoveryReason:
topFailure === null
? null
: topFailure.kind === "missing"
? `latest saved file "${topFailure.file}" is missing on disk — ` +
`fell back to continuing "${c.state.file}" instead; ` +
"check whether an external tool is moving or deleting files in the archive directory"
: `latest saved file "${topFailure.file}" no longer matches this branch's saved messages ` +
`(file holds ${topFailure.saved ?? "no readable count"}, expected ${topFailure.expected}) — ` +
`fell back to continuing "${c.state.file}" instead; ` +
"this usually means another runtime or an older extension version rewrote it",
};
}
// Every candidate failed: fresh full-branch file, warning names the
// newest target and its failure (both texts kept from the single-state
// era — same causes, same wording).
const fresh = freshFilePlan(ctx, input.dir, input.pathMessages);
fresh.recoveryReason =
topFailure === null
? null
: topFailure.kind === "missing"
? `target file "${topFailure.file}" is missing on disk — the full branch was saved to a fresh file instead; ` +
"check whether an external tool is moving or deleting files in the archive directory"
: `target file "${topFailure.file}" no longer matches this branch's saved messages ` +
`(file holds ${topFailure.saved ?? "no readable count"}, expected ${topFailure.expected}) — the full branch was saved to a fresh file instead; ` +
"this usually means another runtime or an older extension version rewrote it";
return fresh;
}
async function saveConversation(ctx: SaveContext, plan: SavePlan): Promise {
const filePath = path.join(plan.dir, plan.filename);
await fs.mkdir(plan.dir, { recursive: true });
if (plan.fullCreate) {
// Never overwrite: two runtimes recovering the same lost file in the
// same second mint the same name — claim a free one instead. The
// claimed name is what the state entry records (recordState below).
const claimed = await claimFilename(plan.dir, plan.filename);
if (claimed === null) {
throw new Error(
`cannot create a fresh save — "${plan.filename}" and 99 suffixes all exist`,
);
}
plan.filename = claimed;
const target = path.join(plan.dir, plan.filename);
const meta = computeMeta(ctx, plan.pathMessages, plan.sessionKey, undefined, undefined);
const body = renderEntries(plan.pathMessages); // ends with the trailing separator
const content = `${frontmatter(meta)}\n# ${meta.title}\n\n${body}`;
await atomicWrite(target, content);
debug("created conversation file:", target);
return {
message: `saved ${plan.filename} (${plan.pathMessages.length} messages)`,
wrote: true,
created: true,
file: target,
recovered: plan.recoveryReason,
switchedFrom: plan.switchedFrom,
renamed: false,
};
}
if (plan.appendEntries.length === 0 && !plan.renameTo) {
debug("nothing new since last save:", plan.filename);
return {
message: `already up to date (${plan.filename})`,
wrote: false,
created: false,
file: filePath,
// A downgrade warning (candidate chain fell back to this file) must
// surface even when there is nothing new to append.
recovered: plan.recoveryReason,
switchedFrom: null,
renamed: false,
};
}
const existing = await fs.readFile(filePath, "utf-8");
const meta = computeMeta(
ctx,
plan.pathMessages,
plan.sessionKey,
parseCreated(existing),
parseAgent(existing),
);
let updated = replaceFrontmatter(existing, frontmatter(meta));
// Rename-on-title: rewrite the document heading to the now-real title
// (the frontmatter title is refreshed above) while still writing under
// the old name — the move itself happens after this write. A rename due
// with nothing new to append is still a write: the title fields change,
// and the state entry must carry the new name.
if (plan.renameTo) updated = rewriteDocumentHeading(updated, meta.title);
if (plan.appendEntries.length > 0) {
const appended = renderEntries(plan.appendEntries); // ends with the trailing separator
// Collapse trailing blank lines to a single newline so the separator
// always has exactly one blank line above it, whatever earlier saves
// (or a manual edit) left behind.
updated = updated.replace(/\s*$/, "\n");
// Files written by the old format end without a `---` separator; add one
// at the boundary so old and new content stay delimited.
updated += updated.endsWith("---\n") ? `\n${appended}` : `\n---\n\n${appended}`;
}
await atomicWrite(filePath, updated);
debug("appended", plan.appendEntries.length, "entries to:", filePath);
return {
message:
plan.appendEntries.length > 0
? `appended ${plan.appendEntries.length} messages to ${plan.filename}`
: `refreshed title of ${plan.filename}`,
wrote: true,
created: false,
file: filePath,
// A downgrade warning (candidate chain fell back to this file) must
// surface on the append that performed the downgrade.
recovered: plan.recoveryReason,
switchedFrom: null,
renamed: false,
};
}
function relativeForUser(ctx: SaveContext, file: string): string {
const rel = path.relative(ctx.cwd, file);
return rel && !rel.startsWith("..") ? rel : file;
}
// Serialize saves: agent_settled and the manual command must not interleave.
let chain: Promise = Promise.resolve();
function schedule(fn: () => Promise): Promise {
const run = chain.then(fn, fn);
chain = run.then(
() => undefined,
() => undefined,
);
return run;
}
/**
* Move the just-written file to its rename-on-title target (PRD §9.4
* D5'/D7'/D8'). Runs AFTER the write, so a failed write never orphans a
* renamed file; a failed rename keeps the old filename (the title fields
* were already fixed in the write) and leaves titled=false, so the next
* save retries — the target is deterministic, so retries converge on one
* name. The move never overwrites: POSIX fs.rename silently replaces an
* existing target, so an existing one falls back to -1, -2 … suffixes.
*/
async function applyRename(ctx: SaveContext, plan: SavePlan, result: SaveResult): Promise {
if (!plan.renameTo) return;
const target = await claimFilename(plan.dir, plan.renameTo);
if (target === null) {
debug("rename-on-title gave up — target and 99 suffixes exist:", plan.renameTo);
return;
}
try {
await fs.rename(path.join(plan.dir, plan.filename), path.join(plan.dir, target));
} catch (e) {
debug("rename-on-title failed — kept old filename, retrying next save:", String(e));
return;
}
debug("renamed branch file:", plan.filename, "→", target);
plan.filename = target;
plan.titled = true;
result.file = path.join(plan.dir, target);
result.renamed = true;
ctx.notify(`${NOTIFY_TAG} renamed to ${relativeForUser(ctx, result.file)}`, "info");
}
/** Full save cycle: write the file, then persist the branch state entry. */
async function runSave(ctx: SaveContext): Promise {
const input = await computePlan(ctx);
if (!input) {
return {
message: "no conversation content to save yet",
wrote: false,
created: false,
file: null,
recovered: null,
switchedFrom: null,
renamed: false,
};
}
const plan = await resolvePlan(ctx, input);
// Concurrency guard for disk-backed sessions: bail before ANY write when
// the foreign jsonl moved since load. No-op for the live session.
await ctx.beforeWrite();
const result = await saveConversation(ctx, plan);
if (result.wrote) {
await applyRename(ctx, plan, result);
const leafId = ctx.session.getLeafId();
await ctx.appendState({
sessionKey: plan.sessionKey,
lastSavedEntryId: leafId,
file: plan.filename,
schema: SAVE_STATE_SCHEMA,
extVersion: EXTENSION_VERSION ?? undefined,
titled: plan.titled,
});
debug("recorded state entry — sessionKey:", plan.sessionKey, "leaf:", leafId);
}
return result;
}
/**
* Wrap the real host context as a SaveContext for the live session. The
* session surface passes straight through (structural subset), state
* entries go through pi.appendEntry, and the mixed-version warning keeps
* its per-process latch for the live path.
*/
function liveContext(ctx: ExtensionContext): SaveContext {
return {
cwd: ctx.cwd,
hasUI: ctx.hasUI,
notify: (message, level) => {
if (ctx.hasUI) ctx.ui.notify(message, level);
},
session: ctx.sessionManager,
readSaveStates: () => readDiskSaveStates(ctx.sessionManager.getSessionFile() ?? null),
warnMixedVersion: (st) => {
// One mixed-version warning per process: repeating it every turn
// would only train the user to ignore it. Headless runs (no UI)
// count as warned too — there is no notification surface to wait for.
if (mixedVersionWarned) return;
mixedVersionWarned = true;
const writer = st.extVersion
? `extension v${st.extVersion} (save-state schema ${st.schema})`
: `save-state schema ${st.schema}`;
debug(
"mixed versions: state entries written by",
writer,
"— this code is",
SAVE_STATE_SCHEMA,
);
if (ctx.hasUI) {
ctx.ui.notify(
`${NOTIFY_TAG} this session was written to by a newer ${writer}, while this process runs older code — restart the session / reload Pi to load the new version`,
"warning",
);
}
},
beforeWrite: async () => {},
appendState: async (state) => {
pi.appendEntry(CUSTOM_TYPE, state);
},
};
}
// The live path's mixed-version latch (see liveContext).
let mixedVersionWarned = false;
// 1. Automatic: save after every settled agent turn.
pi.on("agent_settled", async (_event: AgentSettledEvent, ctx: ExtensionContext) => {
// Non-persisted sessions (in-memory, `--no-session`) are ephemeral
// auxiliary agents — see the skip rule in the header. The manual
// /save-conversation command below is the explicit-demand escape hatch.
if (!ctx.sessionManager.getSessionFile()) {
debug(
"agent_settled — session has no session file (in-memory / --no-session); skipping auto-save",
);
return;
}
debug("agent_settled — saving conversation");
try {
const live = liveContext(ctx);
const r = await schedule(() => runSave(live));
// A recovery replaces the routine "created" info: the anomaly is the
// story worth telling, at a level that distinguishes it (warning).
if (r.recovered && ctx.hasUI && r.file) {
ctx.ui.notify(`${NOTIFY_TAG} ${r.recovered} → ${relativeForUser(live, r.file)}`, "warning");
} else if (r.wrote && r.created && ctx.hasUI && r.file) {
if (r.switchedFrom) {
ctx.ui.notify(
`${NOTIFY_TAG} branch changed — new branch file ${relativeForUser(live, r.file)}; the earlier branch file ${r.switchedFrom} is kept`,
"info",
);
} else {
ctx.ui.notify(`${NOTIFY_TAG} ${relativeForUser(live, r.file)}`, "info");
}
}
} catch (e) {
debug("auto-save failed:", String(e));
if (ctx.hasUI) ctx.ui.notify(`${NOTIFY_TAG} save failed: ${String(e)}`, "error");
}
});
// 2. Manual: force a save now and report where it went.
pi.registerCommand(COMMAND, {
description:
"Save the current conversation branch to a markdown file now (pi-auto-save-to-markdown)",
handler: async (_args: string, ctx: ExtensionCommandContext) => {
try {
debug("manual /" + COMMAND + " invoked");
const live = liveContext(ctx);
const r = await schedule(() => runSave(live));
if (ctx.hasUI) {
const target = r.file ? relativeForUser(live, r.file) : "";
ctx.ui.notify(`${NOTIFY_TAG} ${r.message}${target ? ` → ${target}` : ""}`, "info");
if (r.recovered && r.file) {
ctx.ui.notify(`${NOTIFY_TAG} ${r.recovered} → ${target}`, "warning");
}
if (r.switchedFrom && r.file) {
ctx.ui.notify(
`${NOTIFY_TAG} branch changed — new branch file ${target}; the earlier branch file ${r.switchedFrom} is kept`,
"info",
);
}
}
} catch (e) {
debug("/" + COMMAND + " failed:", String(e));
if (ctx.hasUI) ctx.ui.notify(`${NOTIFY_TAG} save failed: ${String(e)}`, "error");
}
},
});
// 3. Batch: save every session of this project (PRD §5 P3 + §9.6).
async function saveAllSessions(ctx: ExtensionContext): Promise {
const started = Date.now();
const live = liveContext(ctx);
let saved = 0;
let freshCreated = 0;
let renamed = 0;
let switched = 0;
let upToDate = 0;
let skippedEmpty = 0;
let skippedLegacy = 0;
let deferred = 0;
const failed: string[] = [];
const count = (r: SaveResult) => {
if (r.wrote) {
saved++;
if (r.created) freshCreated++;
if (r.renamed) renamed++;
if (r.switchedFrom) switched++;
} else {
upToDate++;
}
};
// The live session first, through the normal path: its in-memory tree may
// be fresher than disk, and its state entry goes through pi.appendEntry.
// A non-persisted current session (in-memory / --no-session) is skipped —
// see the skip rule in the header.
if (!ctx.sessionManager.getSessionFile()) {
debug(
"/" + COMMAND_ALL + " — current session not persisted (in-memory / --no-session); skipped",
);
} else {
try {
const r = await runSave(live);
count(r);
if (ctx.hasUI) {
const target = r.file ? relativeForUser(live, r.file) : "";
ctx.ui.notify(`${NOTIFY_TAG} ${r.message}${target ? ` → ${target}` : ""}`, "info");
if (r.recovered && r.file) {
ctx.ui.notify(`${NOTIFY_TAG} ${r.recovered} → ${target}`, "warning");
}
if (r.switchedFrom && r.file) {
ctx.ui.notify(
`${NOTIFY_TAG} branch changed — new branch file ${target}; the earlier branch file ${r.switchedFrom} is kept`,
"info",
);
}
}
} catch (e) {
failed.push("");
debug("/" + COMMAND_ALL + " live save failed:", String(e));
if (ctx.hasUI) {
ctx.ui.notify(`${NOTIFY_TAG} save failed: ${String(e)}`, "error");
}
}
}
// The project's sessions directory — every session jsonl lives there.
const currentFile = ctx.sessionManager.getSessionFile();
const dir = currentFile ? path.dirname(currentFile) : defaultSessionsDir(ctx.cwd);
const currentId = ctx.sessionManager.getSessionId();
const files: { file: string; mtime: number }[] = [];
try {
const names = (await fs.readdir(dir)).filter((f) => f.endsWith(".jsonl"));
for (const name of names) {
const file = path.join(dir, name);
try {
files.push({ file, mtime: (await fs.stat(file)).mtimeMs });
} catch {
// vanished between readdir and stat — the mover; ignore
}
}
} catch (e) {
debug("/" + COMMAND_ALL + " cannot list sessions dir:", String(e));
if (ctx.hasUI) {
ctx.ui.notify(`${NOTIFY_TAG} cannot list sessions directory: ${String(e)}`, "error");
}
return;
}
// Newest first: if the run is interrupted, the most recent sessions are done.
files.sort((a, b) => b.mtime - a.mtime);
const sink = (message: string, level: "info" | "warning" | "error") => {
if (ctx.hasUI) ctx.ui.notify(`${NOTIFY_TAG} ${message}`, level);
};
for (const { file } of files) {
if (currentFile && path.resolve(file) === path.resolve(currentFile)) continue;
try {
const outcome = await loadDiskSession(file, sink);
if (outcome.kind === "deferred") {
deferred++;
continue;
}
if (outcome.kind === "skip") {
if (outcome.reason === "legacy") skippedLegacy++;
else skippedEmpty++;
continue;
}
if (outcome.view.session.getSessionId() === currentId) continue; // same session, other file
const view = outcome.view;
const r = await runSave(view);
count(r);
// Recovery warnings are anomalies — shown per session, tagged.
if (r.recovered && r.file && ctx.hasUI) {
ctx.ui.notify(
`${NOTIFY_TAG} [${view.tag()}] ${r.recovered} → ${relativeForUser(live, r.file)}`,
"warning",
);
}
} catch (e) {
if (e instanceof SessionChangedError) {
deferred++;
continue;
}
failed.push(path.basename(file));
debug("/" + COMMAND_ALL + " session failed:", path.basename(file), String(e));
if (ctx.hasUI) {
ctx.ui.notify(
`${NOTIFY_TAG} [${path.basename(file)}] save failed: ${String(e)}`,
"error",
);
}
}
}
const summaryParts = [
`${saved} saved`,
`${upToDate} up to date`,
`${skippedEmpty + skippedLegacy} skipped`,
];
if (saved && (freshCreated || renamed || switched)) {
const detail = [
freshCreated ? `${freshCreated} new` : "",
renamed ? `${renamed} renamed` : "",
switched ? `${switched} branch switches` : "",
]
.filter(Boolean)
.join(", ");
if (detail) summaryParts[0] += ` (${detail})`;
}
if (skippedEmpty + skippedLegacy) {
summaryParts[2] += skippedLegacy
? ` (${skippedEmpty} without assistant reply, ${skippedLegacy} legacy)`
: " (no assistant reply)";
}
if (deferred)
summaryParts.push(`${deferred} deferred (changed while saving — next run continues them)`);
summaryParts.push(`${failed.length} failed`);
if (failed.length) {
const shown = failed.slice(0, 5).join(", ");
summaryParts.push(`: ${shown}${failed.length > 5 ? " …" : ""}`);
}
debug("/" + COMMAND_ALL + " finished in", Date.now() - started, "ms");
if (ctx.hasUI) {
ctx.ui.notify(`${NOTIFY_TAG} /${COMMAND_ALL}: ${summaryParts.join(", ")}`, "info");
}
}
pi.registerCommand(COMMAND_ALL, {
description:
"Save every session of this project to markdown files now (pi-auto-save-to-markdown)",
handler: async (_args: string, ctx: ExtensionCommandContext) => {
try {
debug("manual /" + COMMAND_ALL + " invoked");
// The whole batch is serialized against auto-saves and the manual
// /save-conversation: same queue, no interleaved claims.
await schedule(() => saveAllSessions(ctx));
} catch (e) {
debug("/" + COMMAND_ALL + " failed:", String(e));
if (ctx.hasUI) ctx.ui.notify(`${NOTIFY_TAG} save failed: ${String(e)}`, "error");
}
},
});
}