pi-auto-save-session-to-markdown 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts ADDED
@@ -0,0 +1,2192 @@
1
+ /**
2
+ * pi-auto-save-session-to-markdown
3
+ *
4
+ * Automatically saves the current conversation to a markdown file after every
5
+ * completed agent turn, with session metadata in a YAML frontmatter block.
6
+ *
7
+ * Behavior:
8
+ *
9
+ * - Trigger: `agent_settled` — fires once per user prompt, after the turn is
10
+ * fully done (including automatic retries and compaction), so each save
11
+ * captures a settled state of the conversation.
12
+ * - Skip rule: every automatic path (the `agent_settled` save and the batch
13
+ * command's live save) skips sessions Pi does not persist — no session
14
+ * file, i.e. an in-memory SessionManager (`--no-session`): those are
15
+ * ephemeral auxiliary agents host clients spawn next to the real
16
+ * conversation (Claudian's title generation, instruction refinement,
17
+ * inline edits) or one-shot `pi --no-session` runs, not project
18
+ * conversations, and archiving them would mint junk "User's request …"
19
+ * files. The manual /save-conversation command still saves such a session
20
+ * on explicit demand; the batch never sees them (no jsonl on disk).
21
+ * - Location: a subfolder of the session's working directory (`ctx.cwd`, the
22
+ * directory the session was started in), defaulting to `ai-conversations`.
23
+ * Override with the PI_SAVE_CONVERSATION_DIR environment variable; set it to
24
+ * "." or "" to save directly into the working directory.
25
+ * - Filename: `<title>-<key>-<time>.md`, where <title> is the session name
26
+ * (or a slug of the first user message when unnamed), <key> is the first
27
+ * 8 hex of the SHA-256 of the session id (the same value for every file
28
+ * of one session, so a session's files cluster in the archive directory
29
+ * across recoveries and resumes; when no session id exists yet — the
30
+ * degenerate fallback — the deepest message entry's id is hashed the
31
+ * same way, so the key is always an opaque 8-hex cluster key), and
32
+ * <time> is the local file-creation timestamp (YYYYMMDD-HHmmss).
33
+ * - Frontmatter: title, agent (generator identity, always "pi" here — agent
34
+ * plugins for other runtimes would write their own value), format_version
35
+ * (the document format of the last write — additive frontmatter fields
36
+ * never bump it; see FORMAT_VERSION), session id, session key (the
37
+ * filename key), branch last entry id (field `branch_last_entry_id` — the
38
+ * id of the deepest message entry on the saved branch: the file's position
39
+ * in the session jsonl tree at the last write, scoped to this file's
40
+ * branch, not the session-wide last entry), model, provider, cumulative
41
+ * cost and tokens (input, output,
42
+ * cache read/write), message count, created/updated timestamps (tz-aware
43
+ * ISO 8601 in the local timezone with its numeric UTC offset, e.g.
44
+ * "2026-08-29T13:05:12+08:00"), project root and session file.
45
+ * - Body format: every message block opens with a setext-H1 info header
46
+ * (`User <span …>YYYY-MM-DD HH:MM:SS</span>` /
47
+ * `Assistant <span …>YYYY-MM-DD HH:MM:SS · model</span>`, where the span
48
+ * renders the metadata as small faint text — Obsidian CSS variables, so it
49
+ * degrades gracefully elsewhere — underlined with `===`, distinct from the
50
+ * `#`/`##` ATX headings AI content uses) and ends with a `---` separator
51
+ * wrapped in single blank lines.
52
+ * - Tool call/result folding: calls live in the assistant entry while their
53
+ * results are separate toolResult entries; saves pair them by toolCall id
54
+ * and fold each assistant block's calls, with their FULL results, into one
55
+ * collapsed Obsidian callout (`> [!quote]- Tool Calls · …`). Thinking folds
56
+ * the same way into `> [!tldr]- Thinking`. Callouts are used instead of
57
+ * HTML `<details>` because Obsidian's views render embedded markdown
58
+ * inside HTML blocks unreliably, while callouts fold and render markdown
59
+ * in both Live Preview and Reading view. Outside Obsidian the callouts
60
+ * degrade to plain blockquotes. Arguments render as full JSON in inline
61
+ * code spans and results verbatim — whitespace intact, nothing capped —
62
+ * in fenced code blocks (delimiters sized to survive backticks inside the
63
+ * content), so raw output renders literally instead of being parsed as
64
+ * markdown. Nothing is truncated because the file is a documentary record
65
+ * that may be @-referenced back into a conversation: a half result is
66
+ * wasted when the tool is called again and misleading when it is not,
67
+ * while local reading (grep, ranged reads) makes size a non-issue. A
68
+ * result whose call was saved in an earlier file (mid-turn manual save)
69
+ * renders as a standalone block with the same full content.
70
+ * - Injected prompt blocks: the host client and the agent runtime append
71
+ * machine-readable XML to user messages — the editor's active selection
72
+ * (CDATA content), note references and attachments (linked_note /
73
+ * linked_content), loaded skills, and their kin. Raw markup is noise
74
+ * Obsidian cannot render (unknown tags are not HTML; CDATA is XML), so
75
+ * every block in a known vocabulary is re-rendered generically (see
76
+ * markdown.ts) — no per-tag formatting: the callout title is the tag name
77
+ * in words, the body opens with vault-shaped path/location values as
78
+ * bare wikilinks (the aliased filename is self-explanatory — a `path:`
79
+ * label is noise) followed by the remaining attributes as
80
+ * "**name**: value" lines, then the content (the client's `]]>`
81
+ * split-escaping reversed). Every callout is preset-collapsed: visible
82
+ * blocks (selections, note references, attachments) as `> [!quote]-`,
83
+ * whether they carry content or only attributes (the client emits note
84
+ * references as self-closing tags whose whole payload is a path
85
+ * attribute), agent-side traces (skills) as a `> [!note]- Skill · <name>`
86
+ * marker — the loaded skill's name rides the title so the collapsed
87
+ * marker still says which skill, the location follows in the body, the
88
+ * content is dropped — and consecutive visible same-tag blocks (nothing
89
+ * but whitespace between them) merge into one callout, so a run of note
90
+ * references collapses into a single list (skill markers never merge:
91
+ * each names its own skill). Title
92
+ * derivation strips every known block — the typed message is the title.
93
+ * Unknown markup is left verbatim so XML pasted as content is never
94
+ * mangled.
95
+ * - Branching: each file records exactly ONE branch (the root→leaf path
96
+ * returned by sessionManager.getBranch()). State is persisted via
97
+ * `pi.appendEntry()` custom entries, which are part of the session tree
98
+ * itself — they are not sent to the LLM and not rendered in the TUI. State
99
+ * discovery reads those entries straight from the session jsonl on disk
100
+ * (the shared append log is the single source of truth), so a warm process
101
+ * whose in-memory tree lags behind still sees states recorded by other
102
+ * runtimes; the in-memory tree is only a fallback when the file is
103
+ * unavailable. Every save ranks the recorded states whose saved position
104
+ * lies on the current path — deepest first, and among equal positions the
105
+ * state recorded LAST wins: it names the file the latest successful save
106
+ * actually wrote, older ones name superseded files. If the tree moved
107
+ * elsewhere (e.g. /tree navigation followed by a new prompt), no position
108
+ * matches and a new file is created with the full current branch, with an
109
+ * info notice naming the earlier branch's kept file so the switch stays
110
+ * visible in the archive. Continuing an existing branch appends only the
111
+ * messages that are new since the last save.
112
+ * - Recovery: candidates are validated newest-first — the target file must
113
+ * exist AND its frontmatter `messages` count must cover the messages
114
+ * already saved for this branch (a count that exceeds it is fine — a
115
+ * descendant branch extended the same file); the first candidate that
116
+ * passes is continued. If the newest candidate fails but an older one
117
+ * validates, the save downgrades to the older file and warns about it
118
+ * (converging on existing files instead of minting new ones). Only when
119
+ * every candidate fails — deleted files, or files rewritten from a
120
+ * different tree position (e.g. /tree navigation plus a save on an older
121
+ * branch), where continuing could silently strand this branch's newer
122
+ * messages — is a brand-new file with the full current branch written.
123
+ * Recoveries and downgrades are never silent: each is reported as a
124
+ * warning naming the failed target and the likely cause (something
125
+ * moving/deleting files for a missing target; a concurrent runtime or an
126
+ * older extension version for a count mismatch). A fresh file never
127
+ * overwrites an existing filename either (-1, -2 … suffixes claim a free
128
+ * one): two runtimes recovering the same lost file in the same second
129
+ * would otherwise mint the same name and silently overwrite each other.
130
+ * Every branch therefore always ends up with a complete, consistent file.
131
+ * - Rename-on-title: the first save usually happens before the session has
132
+ * its real name (Claudian generates the title only after the first reply),
133
+ * so the file is created with a slug of the first user message. Once the
134
+ * name exists, the next save renames that file exactly once, to
135
+ * "<name>-<key>-<original-timestamp>.md" — the original timestamp keeps
136
+ * the file's birthday (and makes concurrent or retried renames converge
137
+ * on one name), the frontmatter title and the document heading are
138
+ * rewritten to match, and the state entry records the new name with
139
+ * titled=true. One-way and one-time: a later user /name change never
140
+ * touches the filename, and legacy files (state entries predating the
141
+ * titled flag) are only renamed when their name segment equals the
142
+ * recomputed fallback slug, so manually organized filenames stay
143
+ * untouched. A rename never overwrites an existing target (-1, -2 …
144
+ * suffixes); a failed rename keeps the old filename — the title fields
145
+ * were already fixed — and retries on the next save.
146
+ * - Mixed versions: every state entry records its save-state schema version
147
+ * ("MAJOR.MINOR", numbered independently of the package version) plus the
148
+ * writer's package version. A warm process can outlive a package upgrade
149
+ * and keep running pre-upgrade code against the same session; when a save
150
+ * sees state entries written by a NEWER schema, it warns (once per
151
+ * session) to restart. Older or unknown schemas are ignored — they are
152
+ * indistinguishable from this session's own pre-upgrade history.
153
+ * - Compaction: files archive the ORIGINAL messages (getBranch() returns the
154
+ * raw tree path, not the compaction-aware context), so a compacted session
155
+ * still exports its complete history.
156
+ * - Thinking repair: reasoning blocks stored with the upstream
157
+ * newline-fragmentation corruption (one word per line) are detected and
158
+ * re-joined into flowing text before saving, keeping paragraph breaks
159
+ * where they survive as long separator runs after sentence ends; clean
160
+ * thinking is untouched.
161
+ *
162
+ * Manual commands:
163
+ * - `/save-conversation` saves the current branch immediately and reports
164
+ * the file path.
165
+ * - `/save-conversation-all` saves EVERY session of the current project
166
+ * (every session jsonl in the project's `~/.pi/agent/sessions` folder):
167
+ * each session through the exact same pipeline — state candidates, the
168
+ * never-overwrite guard, rename-on-title, recovery warnings — with its
169
+ * archive written under that session's own working directory. Sessions
170
+ * without an assistant reply are skipped; the current session saves
171
+ * through the normal live path first. Idempotent: re-running continues
172
+ * or reports "up to date" per session, never re-creating files. A session
173
+ * whose jsonl changes while it is being processed (it is still being
174
+ * written by its own runtime) is deferred to the next run instead of
175
+ * racing the other writer — the save only proceeds when the file is
176
+ * verified unchanged since it was read (optimistic concurrency control;
177
+ * the state line the batch appends to a foreign session jsonl is
178
+ * byte-identical to what pi's own appendCustomEntry would write).
179
+ *
180
+ * Installation:
181
+ * pi install npm:pi-auto-save-session-to-markdown
182
+ *
183
+ * Debug:
184
+ * PI_CLAUDIAN_DEBUG=1 pi
185
+ */
186
+
187
+ import type {
188
+ AgentSettledEvent,
189
+ ExtensionAPI,
190
+ ExtensionCommandContext,
191
+ ExtensionContext,
192
+ SessionEntry,
193
+ SessionMessageEntry,
194
+ } from "@earendil-works/pi-coding-agent";
195
+ import { createHash, randomUUID } from "node:crypto";
196
+ import * as fs from "node:fs/promises";
197
+ import * as fsSync from "node:fs";
198
+ import * as os from "node:os";
199
+ import * as path from "node:path";
200
+ import { debug } from "./debug.js";
201
+ import {
202
+ callout,
203
+ fencedCode,
204
+ inlineCode,
205
+ renderUserMessageText,
206
+ stripInjectedBlocks,
207
+ } from "./markdown.js";
208
+
209
+ const CUSTOM_TYPE = "pi-claudian-auto-save-markdown";
210
+ const ENV_SUBDIR = "PI_SAVE_CONVERSATION_DIR";
211
+ const DEFAULT_SUBDIR = "ai-conversations";
212
+ const COMMAND = "save-conversation";
213
+ const COMMAND_ALL = "save-conversation-all";
214
+ const NOTIFY_TAG = "[AutoSave]";
215
+ /**
216
+ * Generator identity recorded in the frontmatter. This extension only ever
217
+ * runs inside Pi, so the value is constant; the field is reserved so a future
218
+ * extension for a different agent (opencode, codex, …) can write its own.
219
+ */
220
+ const AGENT = "pi";
221
+
222
+ /**
223
+ * Schema version of the save-state entries this extension writes, formatted
224
+ * "MAJOR.MINOR" and numbered independently of the package version: MAJOR for
225
+ * incompatible state changes, MINOR for backward-compatible additions. A
226
+ * state entry written by a NEWER schema means some other runtime in this
227
+ * session is running a newer version of the extension (a warm process that
228
+ * outlived a package upgrade) — see the mixed-version warning in computePlan.
229
+ * Bump MINOR when adding optional state fields, MAJOR when changing existing
230
+ * state semantics.
231
+ */
232
+ const SAVE_STATE_SCHEMA = "1.2";
233
+
234
+ /**
235
+ * Version of the markdown document format this code writes ("MAJOR.MINOR",
236
+ * numbered independently of the save-state schema above). Semantics: the
237
+ * version of the LAST writer — an appended file is stamped with the current
238
+ * value even when blocks inside predate it, so "claimed version vs the block
239
+ * formats actually present" detects mixed-era files. Bump rules: MAJOR for
240
+ * structural breaks that change how a parser or migration tool must match
241
+ * blocks (message header structure, `---` separators, callout syntax);
242
+ * MINOR for parse-invariant tweaks and bugfixes (header styling, content
243
+ * transforms like the thinking repair, the blank line once written between
244
+ * the frontmatter and the document heading); additive frontmatter fields do
245
+ * NOT bump it — they are invisible to any within-major parser.
246
+ */
247
+ const FORMAT_VERSION = "1.6";
248
+
249
+ /**
250
+ * Package version of this extension, read best-effort from the adjacent
251
+ * package.json at load time. Recorded in state entries purely so the
252
+ * mixed-version warning can name the newer writer; never used for comparison.
253
+ */
254
+ const EXTENSION_VERSION: string | null = (() => {
255
+ try {
256
+ const pkg: unknown = JSON.parse(
257
+ fsSync.readFileSync(new URL("./package.json", import.meta.url), "utf-8"),
258
+ );
259
+ const v = (pkg as { version?: unknown }).version;
260
+ return typeof v === "string" && v ? v : null;
261
+ } catch {
262
+ return null;
263
+ }
264
+ })();
265
+
266
+ const MAX_TITLE_LENGTH = 60;
267
+ const TITLE_FALLBACK_LENGTH = 40;
268
+ type AgentMessage = SessionMessageEntry["message"];
269
+ type UserMessage = Extract<AgentMessage, { role: "user" }>;
270
+ type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
271
+ type ToolResultMessage = Extract<AgentMessage, { role: "toolResult" }>;
272
+
273
+ /**
274
+ * Per-save state persisted in the session tree via pi.appendEntry().
275
+ * `file` is the bare filename inside the target directory, so changing the
276
+ * configured directory (env var) moves future saves without breaking
277
+ * resolution — the file is simply recreated from the full branch if missing.
278
+ * `schema`/`extVersion` are absent on entries written before they existed;
279
+ * unknown extra fields on newer entries are ignored here (forward
280
+ * compatibility), so validation only covers the fields this code reads.
281
+ */
282
+ interface SaveState {
283
+ /**
284
+ * Key segment of the filename this state addresses: the first 8 hex of the
285
+ * SHA-256 of the session id — stable per session, so files of one session
286
+ * cluster together (when no session id exists yet, the deepest message
287
+ * entry's id is hashed the same way). Only ever used as the recorded
288
+ * value — files are addressed by their full recorded name, never recomputed
289
+ * from the key. Renamed from branchKey at schema 1.2: legacy entries fail
290
+ * validation and are ignored — those branches get fresh files.
291
+ */
292
+ sessionKey: string;
293
+ /**
294
+ * Id of the session tree leaf at save time (getLeafId()) — may be a custom
295
+ * state entry rather than a message; used to rank continuation candidates
296
+ * on the current path. Distinct from frontmatter `branch_last_entry_id`,
297
+ * which is the deepest message entry (an id actually present in the file).
298
+ */
299
+ lastSavedEntryId: string | null;
300
+ file: string;
301
+ /** Save-state schema version ("MAJOR.MINOR") of the writer. */
302
+ schema?: string;
303
+ /** Package version of the writer, warning text only. */
304
+ extVersion?: string;
305
+ /**
306
+ * Whether the file was created with the session's real name (true) or
307
+ * with the fallback slug (false). Absent on legacy entries written before
308
+ * the rename-on-title feature — those are judged by recomputing the
309
+ * fallback slug against the actual filename. Once true it never goes back:
310
+ * a later user /name change must not re-trigger a filename rename.
311
+ */
312
+ titled?: boolean;
313
+ }
314
+
315
+ /** Parse a "MAJOR.MINOR" schema version into numeric parts. */
316
+ function parseSchemaVersion(v: string): [number, number] | null {
317
+ const m = /^(\d+)\.(\d+)$/.exec(v.trim());
318
+ return m ? [Number(m[1]), Number(m[2])] : null;
319
+ }
320
+
321
+ /**
322
+ * Strictly-newer check, numeric per segment — never lexicographic ("1.10"
323
+ * must count as newer than "1.9"). Unparseable versions are treated as
324
+ * unknown and therefore not newer.
325
+ */
326
+ function isNewerSchemaVersion(other: string, mine: string): boolean {
327
+ const a = parseSchemaVersion(other);
328
+ const b = parseSchemaVersion(mine);
329
+ if (!a || !b) return false;
330
+ return a[0] !== b[0] ? a[0] > b[0] : a[1] > b[1];
331
+ }
332
+
333
+ function isSaveState(v: unknown): v is SaveState {
334
+ if (typeof v !== "object" || v === null) return false;
335
+ const s = v as Record<string, unknown>;
336
+ return (
337
+ typeof s.sessionKey === "string" &&
338
+ s.sessionKey.length > 0 &&
339
+ (s.lastSavedEntryId === null || typeof s.lastSavedEntryId === "string") &&
340
+ typeof s.file === "string" &&
341
+ s.file.length > 0
342
+ );
343
+ }
344
+
345
+ /**
346
+ * The save pipeline's complete view of a session: the narrow surface it needs
347
+ * from the host, so the exact same pipeline code runs for the live session
348
+ * (backed by the real ExtensionContext and pi.appendEntry) and for any other
349
+ * session of the project (backed by DiskSessionView, rebuilt from the session
350
+ * jsonl on disk, with state entries appended directly). The interface is a
351
+ * structural subset of pi's ExtensionContext/SessionManager, so the live
352
+ * adapter passes the real objects through unchanged — the batch feature is
353
+ * pure parameterization, byte-identical on the live path.
354
+ */
355
+ interface SaveContext {
356
+ /** Working directory of the session — resolves the archive folder. */
357
+ readonly cwd: string;
358
+ /** Whether a UI surface exists; notify() is a no-op without one. */
359
+ readonly hasUI: boolean;
360
+ /** Fire-and-forget notification, guarded by hasUI. */
361
+ notify(message: string, level: "info" | "warning" | "error"): void;
362
+ /** Read-only session surface (structural subset of pi's SessionManager). */
363
+ readonly session: {
364
+ getBranch(): SessionEntry[];
365
+ getEntries(): SessionEntry[];
366
+ getSessionId(): string;
367
+ getSessionFile(): string | undefined;
368
+ getSessionName(): string | undefined;
369
+ getLeafId(): string | null;
370
+ };
371
+ /**
372
+ * Save-state discovery: disk-first for the live session; for disk views,
373
+ * the states parsed from the load snapshot — a fresh re-read could see
374
+ * entries newer than the frozen snapshot the save is based on, and
375
+ * consistency beats freshness.
376
+ */
377
+ readSaveStates(): Promise<SaveState[] | null>;
378
+ /** Warn about a state entry written by a newer schema — once per session. */
379
+ warnMixedVersion(state: SaveState): void;
380
+ /**
381
+ * Concurrency guard before this save's first write. No-op for the live
382
+ * session (its writes are serialized through pi in-process). A disk view
383
+ * verifies its jsonl is unchanged since load (optimistic concurrency
384
+ * control — the file only ever grows, so the size at load is the version
385
+ * token) and throws SessionChangedError when another writer got in,
386
+ * deferring the session to the next run.
387
+ */
388
+ beforeWrite(): Promise<void>;
389
+ /**
390
+ * Persist this run's state entry: pi.appendEntry for the live session; for
391
+ * a disk view, a directly-appended jsonl line in the exact format pi's own
392
+ * appendCustomEntry/_persist writes (pi 0.82.1), parentId = the snapshot's
393
+ * last entry — the same topology an in-session save would leave.
394
+ */
395
+ appendState(state: SaveState): Promise<void>;
396
+ }
397
+
398
+ /** A foreign session jsonl changed on disk between load and write — defer. */
399
+ class SessionChangedError extends Error {}
400
+
401
+ interface SaveResult {
402
+ message: string;
403
+ /** A file was actually written (created or appended). */
404
+ wrote: boolean;
405
+ /** The write created a brand-new file (vs appending to an existing one). */
406
+ created: boolean;
407
+ file: string | null;
408
+ /** Why a planned continuation was replaced by a fresh full save, if it was. */
409
+ recovered: string | null;
410
+ /**
411
+ * Set when a fresh file was created because the tree moved to a different
412
+ * branch: the previous branch's file that stays on disk (info notice —
413
+ * the branch change is normal one-branch-one-file behavior, not a warning).
414
+ */
415
+ switchedFrom: string | null;
416
+ /** The write included a rename-on-title move of the file (info notice). */
417
+ renamed: boolean;
418
+ }
419
+
420
+ interface BranchMeta {
421
+ title: string;
422
+ /** Generator identity; preserved from the file being appended to. */
423
+ agent: string;
424
+ sessionId: string | null;
425
+ sessionFile: string | null;
426
+ /**
427
+ * The filename key, mirrored into the frontmatter so the file is
428
+ * self-describing (field name `session_key`): the first 8 hex of the
429
+ * SHA-256 of the session id, or of the deepest message entry's id when
430
+ * no session id exists yet. A display/grouping key only — never used to
431
+ * address files (states record full filenames).
432
+ */
433
+ sessionKey: string;
434
+ /**
435
+ * Id of the deepest message entry on the saved branch at the last write
436
+ * (field name `branch_last_entry_id`) — the file's exact position in the
437
+ * session jsonl tree, scoped to this file's branch rather than the
438
+ * session-wide last entry. Updated on every append, like `updated`.
439
+ */
440
+ branchLastEntryId: string;
441
+ model: string | null;
442
+ provider: string | null;
443
+ cost: number;
444
+ tokensInput: number;
445
+ tokensOutput: number;
446
+ tokensCacheRead: number;
447
+ tokensCacheWrite: number;
448
+ messages: number;
449
+ created: string;
450
+ updated: string;
451
+ projectRoot: string;
452
+ }
453
+
454
+ // ---------- thinking fragmentation repair ----------
455
+
456
+ /**
457
+ * Some upstream reasoning streams (observed with z-ai/GLM via OpenRouter) store
458
+ * thinking as one word — or one CJK character — per line: the stream splits
459
+ * tokens into fragments joined by runs of newlines, and the original spaces
460
+ * survive only as leading spaces of the fragments. The saved markdown then has
461
+ * every token on its own line, which is miserable to read and bloats storage.
462
+ *
463
+ * Detection uses two signatures validated against ~520 real thinking blocks:
464
+ * lines starting with exactly one space (a survived word separator; blank-ish
465
+ * " " lines included), and an excess of 1–2-char non-list-marker lines (CJK
466
+ * fragments carry no leading space). Clean thinking never matches either.
467
+ *
468
+ * Repair re-joins the fragments into flowing text. Word separators — the
469
+ * whitespace runs between two fragments — lose their newlines: run length
470
+ * alone carries no recoverable meaning, because the same word separator
471
+ * appears as 1, 2 or 3 newlines depending on the block. Original paragraph
472
+ * boundaries survive as a faint but strong signal: a separator run of 3+
473
+ * newlines that follows a sentence-final character (closing quotes and
474
+ * brackets skipped when looking) marks a real paragraph break 73–93% of the
475
+ * time in corrupted blocks, while plain word separators sit mid-sentence —
476
+ * so exactly those separators become blank-line paragraph breaks and
477
+ * everything else is joined. The sentence-final guard means a break is never
478
+ * inserted mid-sentence: worst case, one lands between two complete
479
+ * sentences, which still reads fine. Join spacing comes from the separator
480
+ * itself: a separator containing a surviving space joins with one space, a
481
+ * bare one (CJK fragments, attached punctuation) joins with nothing. Clean
482
+ * blocks pass through untouched.
483
+ */
484
+
485
+ /** Line whose single leading space is a survived word separator. */
486
+ function isThinkingSigLine(line: string): boolean {
487
+ return line === " " || /^ [^ *+\-\d]/.test(line);
488
+ }
489
+
490
+ /** Non-blank line of 1–2 chars that is not a standalone list marker. */
491
+ function isThinkingShortFragment(line: string): boolean {
492
+ const s = line.trim();
493
+ if (s.length === 0 || s.length > 2) return false;
494
+ return !/^([-*+]|\d+[.)])$/.test(s);
495
+ }
496
+
497
+ /** Whether a thinking block shows the newline-fragmentation corruption. */
498
+ function isFragmentedThinking(s: string): boolean {
499
+ const lines = s.split("\n");
500
+ const nonBlank = lines.filter((l) => l.trim().length > 0);
501
+ if (nonBlank.length === 0) return false;
502
+ const sig = lines.filter(isThinkingSigLine).length;
503
+ if (nonBlank.length < 8) return nonBlank.length >= 3 && sig >= 3;
504
+ if (sig / lines.length >= 0.12) return true;
505
+ return nonBlank.filter(isThinkingShortFragment).length / nonBlank.length >= 0.4;
506
+ }
507
+
508
+ /** Sentence-final characters: a long separator run after one may be a paragraph break. */
509
+ const THINKING_SENTENCE_END = /[.!?。!?…]/;
510
+ /** Closing punctuation skipped when looking for the sentence end behind it. */
511
+ const THINKING_CLOSING = /[)\]}"'”』」)】》]/;
512
+ /** Newlines a separator run needs before it can count as a paragraph break. */
513
+ const THINKING_PARAGRAPH_RUN = 3;
514
+
515
+ /** Repair newline-fragmented thinking; clean thinking is returned unchanged. */
516
+ function repairThinking(s: string): string {
517
+ if (!isFragmentedThinking(s)) return s;
518
+ debug("repairing fragmented thinking block:", s.length, "chars");
519
+ let out = "";
520
+ let i = 0;
521
+ while (i < s.length) {
522
+ let end = i;
523
+ while (end < s.length && !/\s/.test(s[end])) end++;
524
+ out += s.slice(i, end);
525
+ let next = end;
526
+ while (next < s.length && /\s/.test(s[next])) next++;
527
+ if (next >= s.length) break; // trailing whitespace: drop
528
+ const sep = s.slice(end, next);
529
+ if (!/[\n\r]/.test(sep)) {
530
+ out += " "; // plain spaces: a single word separator
531
+ } else {
532
+ const newlines = sep.match(/[\n\r]/g)!.length;
533
+ let p = out.length - 1;
534
+ while (p >= 0 && THINKING_CLOSING.test(out[p])) p--;
535
+ const sentenceEnd = p >= 0 && THINKING_SENTENCE_END.test(out[p]);
536
+ if (newlines >= THINKING_PARAGRAPH_RUN && sentenceEnd) out += "\n\n";
537
+ else if (/[ \t]/.test(sep)) out += " ";
538
+ // A bare newline separator attached CJK fragments or punctuation:
539
+ // join with nothing.
540
+ }
541
+ i = next;
542
+ }
543
+ return out.replace(/[ \t]{2,}/g, " ").trim();
544
+ }
545
+
546
+ // ---------- foreign sessions for /save-conversation-all ----------
547
+
548
+ function isMessageEntry(e: SessionEntry): e is SessionMessageEntry {
549
+ return e.type === "message";
550
+ }
551
+
552
+ /**
553
+ * pi has no extension API for enumerating or loading other sessions, so the
554
+ * batch command rebuilds each session straight from its jsonl on disk. The
555
+ * formats below were verified against pi 0.82.1's session-manager (PRD §5
556
+ * P3, F2/F10): entries are `{type, id, parentId, timestamp, …}` trees where
557
+ * the FIRST `type: "session"` header carries the session id and cwd; loading
558
+ * sets the leaf to the LAST entry in file order (there is no persisted leaf
559
+ * pointer), and the branch is the parentId chain walked from it — exactly
560
+ * what pi itself does on resume, so the batch saves the same branch the
561
+ * session would resume onto. Session names come from the latest
562
+ * `session_info` entry, also like pi.
563
+ */
564
+
565
+ /** Result of loading one session file for the batch. */
566
+ type LoadedSession =
567
+ | { kind: "view"; view: DiskSessionView }
568
+ /** The file changed while being read (still written by its runtime) — retry next run. */
569
+ | { kind: "deferred" }
570
+ /** Not eligible this run. "legacy" = pre-id/parentId entries (v1) — see loadDiskSession. */
571
+ | { kind: "skip"; reason: "no-assistant" | "legacy" };
572
+
573
+ class DiskSessionView implements SaveContext {
574
+ readonly cwd: string;
575
+ readonly hasUI = true;
576
+ readonly session: SaveContext["session"];
577
+ readonly file: string;
578
+ private readonly entries: SessionEntry[];
579
+ private readonly pathEntries: SessionEntry[];
580
+ private readonly states: SaveState[];
581
+ private readonly sizeAtLoad: number;
582
+ private readonly sessionId: string;
583
+ private readonly name: string | undefined;
584
+ private readonly leafId: string | null;
585
+ private readonly sink: (message: string, level: "info" | "warning" | "error") => void;
586
+ /** Info notices are aggregated by the batch runner instead of shown one by one. */
587
+ infoNotices = 0;
588
+
589
+ constructor(opts: {
590
+ file: string;
591
+ entries: SessionEntry[];
592
+ pathEntries: SessionEntry[];
593
+ states: SaveState[];
594
+ sizeAtLoad: number;
595
+ sessionId: string;
596
+ cwd: string;
597
+ name: string | undefined;
598
+ leafId: string | null;
599
+ sink: (message: string, level: "info" | "warning" | "error") => void;
600
+ }) {
601
+ this.file = opts.file;
602
+ this.entries = opts.entries;
603
+ this.pathEntries = opts.pathEntries;
604
+ this.states = opts.states;
605
+ this.sizeAtLoad = opts.sizeAtLoad;
606
+ this.sessionId = opts.sessionId;
607
+ this.cwd = opts.cwd;
608
+ this.name = opts.name;
609
+ this.leafId = opts.leafId;
610
+ this.sink = opts.sink;
611
+ this.session = {
612
+ getBranch: () => this.pathEntries,
613
+ getEntries: () => this.entries,
614
+ getSessionId: () => this.sessionId,
615
+ getSessionFile: () => this.file,
616
+ getSessionName: () => this.name,
617
+ getLeafId: () => this.leafId,
618
+ };
619
+ }
620
+
621
+ /** Short session identity for per-session batch notices. */
622
+ tag(): string {
623
+ return this.sessionId.slice(0, 8);
624
+ }
625
+
626
+ notify(message: string, level: "info" | "warning" | "error"): void {
627
+ if (level === "info") {
628
+ // Batch aggregates info notices into the summary count.
629
+ this.infoNotices++;
630
+ debug(this.tag(), message);
631
+ } else {
632
+ // Warnings and errors are anomalies — one per session, tagged.
633
+ this.sink(`[${this.tag()}] ${message}`, level);
634
+ }
635
+ }
636
+
637
+ readSaveStates(): Promise<SaveState[] | null> {
638
+ // The frozen load snapshot, never a fresh re-read (see SaveContext).
639
+ return Promise.resolve(this.states);
640
+ }
641
+
642
+ warnMixedVersion(state: SaveState): void {
643
+ // Once per session (PRD 0.3: warn-once is per session; the live adapter
644
+ // keeps its per-process latch for the live path).
645
+ if (warnedSessions.has(this.sessionId)) return;
646
+ warnedSessions.add(this.sessionId);
647
+ const writer = state.extVersion
648
+ ? `extension v${state.extVersion} (save-state schema ${state.schema})`
649
+ : `save-state schema ${state.schema}`;
650
+ debug(
651
+ this.tag(),
652
+ "mixed versions: state entries written by",
653
+ writer,
654
+ "— this code is",
655
+ SAVE_STATE_SCHEMA,
656
+ );
657
+ this.notify(
658
+ `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`,
659
+ "warning",
660
+ );
661
+ }
662
+
663
+ /** OCC: the file must be byte-identical to the load snapshot. */
664
+ private async assertUnchanged(): Promise<void> {
665
+ let size: number;
666
+ try {
667
+ size = (await fs.stat(this.file)).size;
668
+ } catch {
669
+ throw new SessionChangedError("session file vanished since load");
670
+ }
671
+ if (size !== this.sizeAtLoad) {
672
+ throw new SessionChangedError("session file changed since load");
673
+ }
674
+ }
675
+
676
+ beforeWrite(): Promise<void> {
677
+ return this.assertUnchanged();
678
+ }
679
+
680
+ async appendState(state: SaveState): Promise<void> {
681
+ // Last-resort OCC check immediately before the append: a session written
682
+ // to after this point cannot be helped (the µs stat→append window is
683
+ // accepted, PRD §9.6 D7), but anything earlier is caught here.
684
+ await this.assertUnchanged();
685
+ const entry = {
686
+ type: "custom" as const,
687
+ customType: CUSTOM_TYPE,
688
+ data: state,
689
+ id: this.generateId(),
690
+ parentId: this.leafId,
691
+ timestamp: new Date().toISOString(),
692
+ };
693
+ // Single small line via O_APPEND — the same single-write append pi's
694
+ // _persist does; a concurrent writer interleaves at line granularity
695
+ // at worst, and every reader skips bad lines.
696
+ await fs.appendFile(this.file, JSON.stringify(entry) + "\n", "utf-8");
697
+ debug(this.tag(), "recorded state entry — sessionKey:", state.sessionKey, "file:", state.file);
698
+ }
699
+
700
+ /** pi's generateId semantics: random 8-hex, collision-checked against the session's ids. */
701
+ private generateId(): string {
702
+ for (let i = 0; i < 100; i++) {
703
+ const id = randomUUID().slice(0, 8);
704
+ if (!this.entries.some((e) => e.id === id)) return id;
705
+ }
706
+ return randomUUID();
707
+ }
708
+ }
709
+
710
+ /** Sessions warned about mixed save-state schemas by the batch (per session). */
711
+ const warnedSessions = new Set<string>();
712
+
713
+ /**
714
+ * Load one foreign session jsonl for the batch: read it once (OCC — the size
715
+ * is captured before the read and re-checked after, so the snapshot is
716
+ * exactly the file as of that size), then rebuild the tree and its current
717
+ * branch. Skips: sessions with no assistant reply (nothing conversational to
718
+ * archive — pi does not even persist sessions until their first assistant
719
+ * entry, so this is mostly a defense against partial/corrupt files); v1-era
720
+ * files with pre-id/parentId entries — pi rewrites those on load with ids of
721
+ * its own choosing, so a state line we append (parentId pointing at OUR
722
+ * synthetic ids) would become an orphan root and steal the resume position
723
+ * once pi migrates the file, and archiving without a state entry would mint a
724
+ * duplicate file on every run. Skipping is the only safe treatment.
725
+ */
726
+ async function loadDiskSession(
727
+ file: string,
728
+ sink: (message: string, level: "info" | "warning" | "error") => void,
729
+ ): Promise<LoadedSession> {
730
+ const before = await fs.stat(file); // OCC version token S0
731
+ const text = await fs.readFile(file, "utf-8");
732
+ const after = await fs.stat(file);
733
+ if (after.size !== before.size) return { kind: "deferred" };
734
+
735
+ const raw: unknown[] = [];
736
+ for (const line of text.split("\n")) {
737
+ if (!line.trim()) continue;
738
+ try {
739
+ raw.push(JSON.parse(line));
740
+ } catch {
741
+ continue; // skip corrupt / mid-write lines (pi's own loader does the same)
742
+ }
743
+ }
744
+
745
+ const header = raw.find(
746
+ (e): e is { type: "session"; id?: unknown; cwd?: unknown } =>
747
+ typeof e === "object" && e !== null && (e as { type?: unknown }).type === "session",
748
+ );
749
+ if (
750
+ !header ||
751
+ typeof header.id !== "string" ||
752
+ !header.id ||
753
+ typeof header.cwd !== "string" ||
754
+ !header.cwd
755
+ ) {
756
+ throw new Error("no valid session header");
757
+ }
758
+
759
+ const entries: SessionEntry[] = [];
760
+ let sawLegacy = false;
761
+ for (const e of raw) {
762
+ if (typeof e !== "object" || e === null) continue;
763
+ const r = e as Record<string, unknown>;
764
+ if (r.type === "session") continue; // header — not part of the tree
765
+ // v1-era entries have no id/parentId structure (pi's migrateV1ToV2
766
+ // assigns them at load time). See the function doc for why we skip.
767
+ if (typeof r.id !== "string" || r.parentId === undefined) {
768
+ sawLegacy = true;
769
+ break;
770
+ }
771
+ if (r.type === "message" && typeof r.message !== "object") continue; // corrupt
772
+ entries.push(e as SessionEntry);
773
+ }
774
+ if (sawLegacy) return { kind: "skip", reason: "legacy" };
775
+
776
+ if (!entries.some((e) => isMessageEntry(e) && e.message.role === "assistant")) {
777
+ return { kind: "skip", reason: "no-assistant" };
778
+ }
779
+
780
+ const byId = new Map<string, SessionEntry>();
781
+ for (const e of entries) byId.set(e.id, e);
782
+ // pi's _buildIndex: leaf = last entry in file order (there is no persisted
783
+ // leaf pointer — the file's last line IS the resume position, F10).
784
+ const leafId = entries.length > 0 ? entries[entries.length - 1].id : null;
785
+
786
+ // Current branch: parentId chain walked from the leaf, exactly like
787
+ // SessionManager.getBranch. The iteration cap is paranoia against cycles.
788
+ const pathEntries: SessionEntry[] = [];
789
+ {
790
+ let current = leafId !== null ? byId.get(leafId) : undefined;
791
+ const seen = new Set<string>();
792
+ while (current && !seen.has(current.id)) {
793
+ seen.add(current.id);
794
+ pathEntries.push(current);
795
+ current = current.parentId ? byId.get(current.parentId) : undefined;
796
+ }
797
+ pathEntries.reverse();
798
+ }
799
+
800
+ // Latest session_info name, reverse-walked (SessionManager.getSessionName).
801
+ let name: string | undefined;
802
+ for (let i = entries.length - 1; i >= 0; i--) {
803
+ const e = entries[i];
804
+ if (e.type === "session_info") {
805
+ name = e.name?.trim() || undefined;
806
+ break;
807
+ }
808
+ }
809
+
810
+ const states: SaveState[] = [];
811
+ for (const e of entries) {
812
+ if (e.type === "custom" && e.customType === CUSTOM_TYPE && isSaveState(e.data)) {
813
+ states.push(e.data);
814
+ }
815
+ }
816
+
817
+ debug(
818
+ "loaded session for batch:",
819
+ path.basename(file),
820
+ "— entries:",
821
+ entries.length,
822
+ "path:",
823
+ pathEntries.length,
824
+ "states:",
825
+ states.length,
826
+ );
827
+ return {
828
+ kind: "view",
829
+ view: new DiskSessionView({
830
+ file,
831
+ entries,
832
+ pathEntries,
833
+ states,
834
+ sizeAtLoad: before.size,
835
+ sessionId: header.id,
836
+ cwd: header.cwd,
837
+ name,
838
+ leafId,
839
+ sink,
840
+ }),
841
+ };
842
+ }
843
+
844
+ /**
845
+ * The project's sessions directory: the folder holding the live session's
846
+ * jsonl. Fallback for file-less (in-memory) live sessions mirrors pi's
847
+ * encoding (session-manager v0.82.1: cwd with /, \, : turned into dashes,
848
+ * wrapped in -- … --) under the default ~/.pi/agent/sessions.
849
+ */
850
+ function defaultSessionsDir(cwd: string): string {
851
+ const safe = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
852
+ return path.join(os.homedir(), ".pi", "agent", "sessions", safe);
853
+ }
854
+
855
+ export default function (pi: ExtensionAPI) {
856
+ /**
857
+ * Resolve the target directory. The env var may hold a relative folder name
858
+ * (resolved against the session cwd), an absolute path, or "." / "" for the
859
+ * working directory itself. When unset, the default subfolder is used.
860
+ */
861
+ function targetDir(ctx: SaveContext): string {
862
+ const env = process.env[ENV_SUBDIR];
863
+ if (env === undefined) return path.join(ctx.cwd, DEFAULT_SUBDIR);
864
+ const raw = env.trim();
865
+ if (raw === "" || raw === ".") return ctx.cwd;
866
+ return path.resolve(ctx.cwd, raw);
867
+ }
868
+
869
+ // ---------- formatting helpers ----------
870
+
871
+ function pad(n: number): string {
872
+ return String(n).padStart(2, "0");
873
+ }
874
+
875
+ /** Local-time filename timestamp: YYYYMMDD-HHmmss. */
876
+ function fileTimestamp(d: Date): string {
877
+ return (
878
+ `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
879
+ `-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
880
+ );
881
+ }
882
+
883
+ /** Local-time label for a message entry: YYYY-MM-DD HH:MM:SS. */
884
+ function dateTime(iso: string): string {
885
+ const d = new Date(iso);
886
+ return (
887
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
888
+ ` ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
889
+ );
890
+ }
891
+
892
+ /**
893
+ * Wrap header metadata (timestamp, model) in a small faint span: 0.5em text
894
+ * in Obsidian's `--text-faint` color, so the role stays visually dominant.
895
+ * HTML-escaped because the model string lands inside a raw HTML span.
896
+ */
897
+ function metaSpan(text: string): string {
898
+ const safe = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
899
+ return `<span style="font-size: 0.5em; color: var(--text-faint);">${safe}</span>`;
900
+ }
901
+
902
+ /** Setext-H1 info header: `Role <span …>meta</span>` underlined with `===`. */
903
+ function messageHeader(role: string, meta: string): string {
904
+ return `${role} ${metaSpan(meta)}\n===`;
905
+ }
906
+
907
+ /** Make a string safe for use as a filename component. */
908
+ function sanitizeFilenamePart(s: string): string {
909
+ return s
910
+ .replace(/[\u0000-\u001f\u007f]/g, "")
911
+ .replace(/[\\/:*?"<>|]/g, "-")
912
+ .replace(/\s+/g, " ")
913
+ .trim()
914
+ .replace(/\s/g, "-")
915
+ .replace(/-+/g, "-")
916
+ .replace(/^[-.]+|[-.]+$/g, "")
917
+ .slice(0, MAX_TITLE_LENGTH)
918
+ .replace(/[-.]+$/g, "");
919
+ }
920
+
921
+ /** Extract the plain text of a user message content (string or blocks). */
922
+ function userText(content: UserMessage["content"]): string {
923
+ if (typeof content === "string") return content;
924
+ return content
925
+ .map((b) =>
926
+ b.type === "text" ? b.text : `_[image: ${"mimeType" in b ? b.mimeType : "unknown"}]_`,
927
+ )
928
+ .join("\n\n");
929
+ }
930
+
931
+ function firstUserText(messages: SessionMessageEntry[]): string | undefined {
932
+ for (const e of messages) {
933
+ if (e.message.role === "user") {
934
+ // Title derivation reads the plain typed message — every known
935
+ // injected block (see markdown.ts) is stripped, mirroring how the
936
+ // client strips the same blocks for its own session titles.
937
+ return stripInjectedBlocks(userText(e.message.content)) || undefined;
938
+ }
939
+ }
940
+ return undefined;
941
+ }
942
+
943
+ /** Fallback filename title: a slug of the first user message. */
944
+ function fallbackTitleForFilename(firstUser: string | undefined): string {
945
+ if (firstUser) {
946
+ const slug = sanitizeFilenamePart(firstUser.slice(0, TITLE_FALLBACK_LENGTH));
947
+ if (slug) return slug;
948
+ }
949
+ return "untitled";
950
+ }
951
+
952
+ /** Title for the filename: session name, else a slug of the first user message. */
953
+ function titleForFilename(ctx: SaveContext, firstUser: string | undefined): string {
954
+ const name = ctx.session.getSessionName()?.trim();
955
+ return name ? sanitizeFilenamePart(name) || "untitled" : fallbackTitleForFilename(firstUser);
956
+ }
957
+
958
+ /** Title for the frontmatter / document heading. */
959
+ function displayTitle(ctx: SaveContext, firstUser: string | undefined): string {
960
+ const name = ctx.session.getSessionName()?.trim();
961
+ if (name) return name;
962
+ if (firstUser) {
963
+ const snippet = firstUser.replace(/\s+/g, " ").trim().slice(0, MAX_TITLE_LENGTH);
964
+ if (snippet) return snippet;
965
+ }
966
+ return "untitled";
967
+ }
968
+
969
+ /** Full argument JSON on one line (JSON.stringify escapes newlines), never truncated. */
970
+ function renderArgs(args: unknown): string {
971
+ try {
972
+ return JSON.stringify(args) ?? "";
973
+ } catch {
974
+ return String(args);
975
+ }
976
+ }
977
+
978
+ // ---------- markdown rendering ----------
979
+
980
+ /**
981
+ * Strip leading blank lines and trailing whitespace from a rendered block,
982
+ * so joins and separators always keep exactly one blank line around them
983
+ * no matter what blank lines the content itself starts or ends with.
984
+ */
985
+ function tighten(s: string): string {
986
+ return s.replace(/^(?:[ \t]*\n)+/, "").replace(/\s+$/, "");
987
+ }
988
+
989
+ /** One tool call with its paired full result (null when no result entry exists). */
990
+ interface RenderedToolCall {
991
+ name: string;
992
+ args: string;
993
+ result: string | null;
994
+ error: boolean;
995
+ }
996
+
997
+ /**
998
+ * Full raw result text: text blocks joined with blank lines, non-text
999
+ * blocks as placeholders. Error status stays OUT of the content (it rides
1000
+ * the call's head line) so the saved text is exactly what the tool
1001
+ * returned.
1002
+ */
1003
+ function resultText(m: ToolResultMessage): string {
1004
+ const texts: string[] = [];
1005
+ for (const b of m.content) {
1006
+ if (b.type === "text") texts.push(b.text);
1007
+ else texts.push(`_[image: ${b.mimeType}]_`);
1008
+ }
1009
+ return texts.join("\n\n").trim();
1010
+ }
1011
+
1012
+ /**
1013
+ * Standalone block for a result whose call is not in this file — same full
1014
+ * content as folded results.
1015
+ */
1016
+ function renderToolResult(m: ToolResultMessage): string {
1017
+ const text = resultText(m);
1018
+ const err = m.isError ? " (error)" : "";
1019
+ return `**Tool · ${m.toolName}**${err}\n\n${text ? fencedCode(text) : "_(empty result)_"}`;
1020
+ }
1021
+
1022
+ /** "read, web_search ×2" — tool names with repeat counts, first-seen order. */
1023
+ function summarizeToolNames(names: string[]): string {
1024
+ const counts = new Map<string, number>();
1025
+ for (const n of names) counts.set(n, (counts.get(n) ?? 0) + 1);
1026
+ return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(", ");
1027
+ }
1028
+
1029
+ /**
1030
+ * Fold tool calls and their paired results into one collapsed callout.
1031
+ * Arguments render as full JSON in inline code spans and results verbatim
1032
+ * in fenced code blocks, so raw output renders literally instead of being
1033
+ * parsed as markdown.
1034
+ */
1035
+ function renderToolCallsCallout(calls: RenderedToolCall[]): string {
1036
+ const summary = summarizeToolNames(calls.map((c) => c.name));
1037
+ const items = calls.map((c) => {
1038
+ const err = c.error ? " (error)" : "";
1039
+ const head = c.args
1040
+ ? `**\`${c.name}\`**${err} ${inlineCode(c.args)}`
1041
+ : `**\`${c.name}\`**${err}`;
1042
+ const result =
1043
+ c.result === null ? "_(no result)_" : c.result ? fencedCode(c.result) : "_(empty result)_";
1044
+ return `${head}\n\n${result}`;
1045
+ });
1046
+ return callout("quote", `Tool Calls · ${calls.length} (${summary})`, items.join("\n\n"));
1047
+ }
1048
+
1049
+ function renderAssistant(
1050
+ m: AssistantMessage,
1051
+ t: string,
1052
+ results: Map<string, ToolResultMessage>,
1053
+ ): string {
1054
+ // Render blocks in their original chronological order: thinking always
1055
+ // precedes the text it produced, instead of being grouped after the fact.
1056
+ // Setext H1 (`===` underline): one level above the `##` headings AI
1057
+ // content typically starts with, and distinct from content `#` headings.
1058
+ const header = messageHeader("Assistant", [t, m.model].filter(Boolean).join(" · "));
1059
+ const parts: string[] = [];
1060
+ const thinkings: string[] = [];
1061
+ const flushThinking = () => {
1062
+ if (thinkings.length) {
1063
+ parts.push(callout("tldr", "Thinking", thinkings.join("\n\n")));
1064
+ thinkings.length = 0;
1065
+ }
1066
+ };
1067
+ const calls: RenderedToolCall[] = [];
1068
+ for (const b of m.content) {
1069
+ if (b.type === "text") {
1070
+ flushThinking();
1071
+ parts.push(b.text);
1072
+ } else if (b.type === "thinking") {
1073
+ thinkings.push(repairThinking(b.thinking));
1074
+ } else if (b.type === "toolCall") {
1075
+ flushThinking();
1076
+ const r = results.get(b.id);
1077
+ results.delete(b.id);
1078
+ calls.push({
1079
+ name: b.name,
1080
+ args: renderArgs(b.arguments),
1081
+ result: r ? resultText(r) : null,
1082
+ error: r ? r.isError : false,
1083
+ });
1084
+ }
1085
+ }
1086
+ flushThinking();
1087
+ if (calls.length) parts.push(renderToolCallsCallout(calls));
1088
+ if (m.errorMessage) parts.push(`> Error: ${m.errorMessage.replace(/\s+/g, " ").trim()}`);
1089
+ if (parts.length === 0) parts.push("_(empty response)_");
1090
+ return `${header}\n\n${parts.join("\n\n")}`;
1091
+ }
1092
+
1093
+ /** Render a chronological list of message entries as markdown blocks. */
1094
+ function renderEntries(entries: SessionMessageEntry[]): string {
1095
+ // Tool calls sit in assistant entries while their results are separate
1096
+ // toolResult entries, paired by toolCall id. Collect results first so
1097
+ // each assistant block can fold its calls together with their results;
1098
+ // results left unclaimed (their call was saved in an earlier file, e.g.
1099
+ // a mid-turn manual save) render as standalone blocks.
1100
+ const results = new Map<string, ToolResultMessage>();
1101
+ for (const e of entries) {
1102
+ if (e.message.role === "toolResult") results.set(e.message.toolCallId, e.message);
1103
+ }
1104
+
1105
+ const blocks: string[] = [];
1106
+ for (const e of entries) {
1107
+ const m = e.message;
1108
+ const t = dateTime(e.timestamp);
1109
+ if (m.role === "user") {
1110
+ blocks.push(`${messageHeader("User", t)}\n\n${renderUserMessageText(userText(m.content))}`);
1111
+ } else if (m.role === "assistant") {
1112
+ blocks.push(renderAssistant(m, t, results));
1113
+ } else if (m.role === "toolResult") {
1114
+ // Claimed results (deleted from the map by their assistant block)
1115
+ // were already folded inline; the rest have no call in this file.
1116
+ if (!results.has(m.toolCallId)) continue;
1117
+ blocks.push(renderToolResult(m));
1118
+ }
1119
+ // Other roles (custom, bashExecution, branchSummary, compactionSummary)
1120
+ // are not part of the rendered conversation record.
1121
+ }
1122
+ if (blocks.length === 0) return "";
1123
+ // Every block ends with a `---` separator wrapped in single blank lines
1124
+ // (the blank line above also keeps `---` from turning the last content
1125
+ // line into a setext H2). The trailing separator after the final block
1126
+ // makes later appends uniform: new blocks simply continue after it.
1127
+ return `${blocks.map(tighten).join("\n\n---\n\n")}\n\n---\n`;
1128
+ }
1129
+
1130
+ // ---------- frontmatter ----------
1131
+
1132
+ function yamlQuote(s: string): string {
1133
+ const safe = s.replace(/[\r\n]+/g, " ");
1134
+ return `"${safe.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
1135
+ }
1136
+
1137
+ /**
1138
+ * ISO 8601 timestamp in the machine's local timezone, with the numeric
1139
+ * UTC offset appended (e.g. "2026-08-29T13:05:12+08:00"): tz-aware, so the
1140
+ * value reads as local wall-clock time without assuming the reader's
1141
+ * timezone. Legacy files written with UTC "Z" values parse identically.
1142
+ */
1143
+ function localIsoTimestamp(date: Date): string {
1144
+ const pad = (n: number) => String(n).padStart(2, "0");
1145
+ const offsetMin = -date.getTimezoneOffset();
1146
+ const sign = offsetMin >= 0 ? "+" : "-";
1147
+ const abs = Math.abs(offsetMin);
1148
+ return (
1149
+ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
1150
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
1151
+ `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
1152
+ );
1153
+ }
1154
+
1155
+ function frontmatter(meta: BranchMeta): string {
1156
+ const lines: string[] = ["---"];
1157
+ lines.push(`title: ${yamlQuote(meta.title)}`);
1158
+ lines.push(`agent: ${yamlQuote(meta.agent)}`);
1159
+ lines.push(`format_version: ${yamlQuote(FORMAT_VERSION)}`);
1160
+ if (meta.sessionId) lines.push(`session_id: ${yamlQuote(meta.sessionId)}`);
1161
+ lines.push(`session_key: ${yamlQuote(meta.sessionKey)}`);
1162
+ lines.push(`branch_last_entry_id: ${yamlQuote(meta.branchLastEntryId)}`);
1163
+ if (meta.model) lines.push(`model: ${yamlQuote(meta.model)}`);
1164
+ if (meta.provider) lines.push(`provider: ${yamlQuote(meta.provider)}`);
1165
+ lines.push(`cost: ${meta.cost.toFixed(6)}`);
1166
+ // `tokens` counts everything billed, including cached tokens, so it is
1167
+ // comparable with provider-side token totals (e.g. OpenRouter activity).
1168
+ lines.push(
1169
+ `tokens: ${meta.tokensInput + meta.tokensOutput + meta.tokensCacheRead + meta.tokensCacheWrite}`,
1170
+ );
1171
+ lines.push(`tokens_input: ${meta.tokensInput}`);
1172
+ lines.push(`tokens_output: ${meta.tokensOutput}`);
1173
+ lines.push(`tokens_cache_read: ${meta.tokensCacheRead}`);
1174
+ lines.push(`tokens_cache_write: ${meta.tokensCacheWrite}`);
1175
+ lines.push(`messages: ${meta.messages}`);
1176
+ lines.push(`created: ${yamlQuote(meta.created)}`);
1177
+ lines.push(`updated: ${yamlQuote(meta.updated)}`);
1178
+ lines.push(`project_root: ${yamlQuote(meta.projectRoot)}`);
1179
+ if (meta.sessionFile) lines.push(`session_file: ${yamlQuote(meta.sessionFile)}`);
1180
+ lines.push("---");
1181
+ return lines.join("\n");
1182
+ }
1183
+
1184
+ /** Recover the `messages` count from an existing frontmatter block. */
1185
+ function parseMessageCount(content: string): number | null {
1186
+ const m = content.match(/^messages: (\d+)$/m);
1187
+ return m ? Number(m[1]) : null;
1188
+ }
1189
+
1190
+ /** Recover the original creation timestamp from an existing frontmatter block. */
1191
+ function parseCreated(content: string): string | undefined {
1192
+ const m = content.match(/^created: "(.*)"$/m);
1193
+ if (!m) return undefined;
1194
+ const iso = m[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\");
1195
+ return Number.isNaN(Date.parse(iso)) ? undefined : iso;
1196
+ }
1197
+
1198
+ /** Recover the generator identity from an existing frontmatter block. */
1199
+ function parseAgent(content: string): string | undefined {
1200
+ const m = content.match(/^agent: "(.*)"$/m);
1201
+ if (!m) return undefined;
1202
+ const agent = m[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\").trim();
1203
+ return agent || undefined;
1204
+ }
1205
+
1206
+ function computeMeta(
1207
+ ctx: SaveContext,
1208
+ pathMessages: SessionMessageEntry[],
1209
+ sessionKey: string,
1210
+ created: string | undefined,
1211
+ agent: string | undefined,
1212
+ ): BranchMeta {
1213
+ let model: string | null = null;
1214
+ let provider: string | null = null;
1215
+ let cost = 0;
1216
+ let tokensInput = 0;
1217
+ let tokensOutput = 0;
1218
+ let tokensCacheRead = 0;
1219
+ let tokensCacheWrite = 0;
1220
+ for (const e of pathMessages) {
1221
+ const m = e.message;
1222
+ const usage = m.role === "assistant" ? m.usage : m.role === "toolResult" ? m.usage : null;
1223
+ if (m.role === "assistant") {
1224
+ model = m.model;
1225
+ provider = m.provider;
1226
+ }
1227
+ if (usage) {
1228
+ cost += usage.cost?.total ?? 0;
1229
+ tokensInput += usage.input ?? 0;
1230
+ tokensOutput += usage.output ?? 0;
1231
+ tokensCacheRead += usage.cacheRead ?? 0;
1232
+ tokensCacheWrite += usage.cacheWrite ?? 0;
1233
+ }
1234
+ }
1235
+ const now = localIsoTimestamp(new Date());
1236
+ return {
1237
+ title: displayTitle(ctx, firstUserText(pathMessages)),
1238
+ agent: agent ?? AGENT,
1239
+ sessionId: ctx.session.getSessionId(),
1240
+ sessionFile: ctx.session.getSessionFile() ?? null,
1241
+ sessionKey,
1242
+ branchLastEntryId: pathMessages[pathMessages.length - 1].id,
1243
+ model,
1244
+ provider,
1245
+ cost,
1246
+ tokensInput,
1247
+ tokensOutput,
1248
+ tokensCacheRead,
1249
+ tokensCacheWrite,
1250
+ messages: pathMessages.length,
1251
+ created: created ?? now,
1252
+ updated: now,
1253
+ projectRoot: ctx.cwd,
1254
+ };
1255
+ }
1256
+
1257
+ // ---------- save planning ----------
1258
+
1259
+ interface SavePlan {
1260
+ dir: string;
1261
+ filename: string;
1262
+ sessionKey: string;
1263
+ /** Write the full branch content (new branch, or target file missing). */
1264
+ fullCreate: boolean;
1265
+ /** Entries to append when continuing an existing file. */
1266
+ appendEntries: SessionMessageEntry[];
1267
+ /** Full root→leaf message list of the current branch (for meta/full renders). */
1268
+ pathMessages: SessionMessageEntry[];
1269
+ /** State of the file being continued, when not a full create. */
1270
+ state: SaveState | null;
1271
+ /** Set when resolvePlan deviated from the newest state (downgrade, fresh full save). */
1272
+ recoveryReason: string | null;
1273
+ /**
1274
+ * Rename-on-title: bare filename to move the file to after this save's
1275
+ * write (the file was created with the fallback slug and the session now
1276
+ * has its real name). Null when no rename is due.
1277
+ */
1278
+ renameTo: string | null;
1279
+ /**
1280
+ * Whether the file counts as properly named — recorded in the state
1281
+ * entry so a fallback-created file is renamed at most once.
1282
+ */
1283
+ titled: boolean;
1284
+ /**
1285
+ * Branch-change notice: the previous branch's file that stays on disk,
1286
+ * when this plan is a fresh file because recorded states exist but none
1287
+ * lies on the current path (a brand-new session has no states at all).
1288
+ */
1289
+ switchedFrom: string | null;
1290
+ }
1291
+
1292
+ /** One on-path save state ranked for the continuation candidate chain. */
1293
+ interface StateCandidate {
1294
+ state: SaveState;
1295
+ /** Index of the state's lastSavedEntryId on the current path. */
1296
+ pos: number;
1297
+ /** Scan order (append order = record order); the later record wins ties. */
1298
+ order: number;
1299
+ }
1300
+
1301
+ /** Pre-resolution save decision: everything needed to rank and validate candidates. */
1302
+ interface SavePlanInput {
1303
+ dir: string;
1304
+ pathMessages: SessionMessageEntry[];
1305
+ /** entryId → index in the current path, for candidate positions and appends. */
1306
+ pos: Map<string, number>;
1307
+ /** On-path candidates ranked by position desc, then record order desc. */
1308
+ candidates: StateCandidate[];
1309
+ /**
1310
+ * Latest recorded state that is NOT on the current path, when any state
1311
+ * exists: a fresh file with no candidates is a branch change (info
1312
+ * notice naming this state's file), not a brand-new session.
1313
+ */
1314
+ offPathState: SaveState | null;
1315
+ }
1316
+
1317
+ /** Brand-new file plan for the current branch (new branch, or recovery). */
1318
+ function freshFilePlan(
1319
+ ctx: SaveContext,
1320
+ dir: string,
1321
+ pathMessages: SessionMessageEntry[],
1322
+ ): SavePlan {
1323
+ // Session-key scheme: the filename key is the first 8 hex of the SHA-256
1324
+ // of the session id — stable per session, so every file of one session
1325
+ // clusters together across recoveries and resumes. Never truncate the id
1326
+ // itself: pi session ids are uuidv7 whose leading hex is a millisecond
1327
+ // timestamp, so same-month sessions share long prefixes. When no session
1328
+ // id exists yet — the degenerate fallback — the deepest message entry's
1329
+ // id is hashed the same way, so the key is always an opaque 8-hex cluster
1330
+ // key, never a raw entry id.
1331
+ const sessionId = ctx.session.getSessionId();
1332
+ const sessionKey = createHash("sha256")
1333
+ .update(sessionId ?? pathMessages[pathMessages.length - 1].id)
1334
+ .digest("hex")
1335
+ .slice(0, 8);
1336
+ const title = titleForFilename(ctx, firstUserText(pathMessages));
1337
+ const filename = `${title}-${sessionKey}-${fileTimestamp(new Date())}.md`;
1338
+ debug("new branch file:", filename, "sessionKey:", sessionKey);
1339
+ return {
1340
+ dir,
1341
+ filename,
1342
+ sessionKey,
1343
+ fullCreate: true,
1344
+ appendEntries: [],
1345
+ pathMessages,
1346
+ state: null,
1347
+ recoveryReason: null,
1348
+ renameTo: null,
1349
+ switchedFrom: null,
1350
+ // A fresh file created under the session's real name is born titled;
1351
+ // one created under the fallback slug stays renameable until a real
1352
+ // name arrives.
1353
+ titled: Boolean(ctx.session.getSessionName()?.trim()),
1354
+ };
1355
+ }
1356
+
1357
+ /**
1358
+ * Read every save-state entry of this session straight from the session
1359
+ * jsonl on disk. The append log is the single source of truth shared by all
1360
+ * runtimes, so a warm process whose in-memory tree lags behind still sees
1361
+ * states recorded by other runtimes — the stale-tree incident's root cause.
1362
+ * The full file is read once per save and each line passes a substring
1363
+ * pre-check before JSON.parse (grep level: only the handful of matching
1364
+ * lines are parsed), and corrupt / mid-write lines are skipped so a
1365
+ * concurrent append cannot poison the scan. Returns null when the disk
1366
+ * state is unavailable (no session file yet, or unreadable) — callers then
1367
+ * fall back to the in-memory tree scan.
1368
+ */
1369
+ async function readDiskSaveStates(sessionFile: string | null): Promise<SaveState[] | null> {
1370
+ if (!sessionFile) return null;
1371
+ let text: string;
1372
+ try {
1373
+ text = await fs.readFile(sessionFile, "utf-8");
1374
+ } catch (e) {
1375
+ debug("cannot read session file for state discovery — falling back to memory:", String(e));
1376
+ return null;
1377
+ }
1378
+ const started = Date.now();
1379
+ const states: SaveState[] = [];
1380
+ for (const line of text.split("\n")) {
1381
+ if (!line.includes(CUSTOM_TYPE)) continue;
1382
+ let entry: { type?: unknown; customType?: unknown; data?: unknown };
1383
+ try {
1384
+ entry = JSON.parse(line);
1385
+ } catch {
1386
+ continue; // skip corrupt / mid-write lines
1387
+ }
1388
+ if (entry.type === "custom" && entry.customType === CUSTOM_TYPE && isSaveState(entry.data)) {
1389
+ states.push(entry.data as SaveState);
1390
+ }
1391
+ }
1392
+ debug("disk state scan:", states.length, "states in", Date.now() - started, "ms");
1393
+ return states;
1394
+ }
1395
+
1396
+ /**
1397
+ * Decide which file the current branch belongs to and what to write.
1398
+ *
1399
+ * Every save appends a custom entry recording {sessionKey, lastSavedEntryId,
1400
+ * file, schema}. Those entries live in the session tree, so a branch's own
1401
+ * latest state is always recoverable — including after resume, /tree
1402
+ * navigation, or /fork. State discovery is disk-first (see
1403
+ * readDiskSaveStates); the in-memory tree is only a fallback when the
1404
+ * session file is unavailable.
1405
+ *
1406
+ * ALL recorded states are considered — keeping only the latest state per
1407
+ * file could shadow an on-path state with one recorded on a different
1408
+ * branch, forcing needless new files. Every state whose saved position
1409
+ * still lies on the current root→leaf path becomes a continuation
1410
+ * candidate, ranked deepest position first. Ties on position (several
1411
+ * states recording the same lastSavedEntryId with different files — after
1412
+ * a loss recovery, a title rename) go to the state recorded LAST: scan
1413
+ * order is append order, and the newest record names the file the latest
1414
+ * successful save actually wrote, while older ones name superseded or dead
1415
+ * files. When the tree moved (navigation + re-ask), no position matches and
1416
+ * resolvePlan starts a new file; states existing but none matching means
1417
+ * a branch change, and the fresh file then carries the previous branch's
1418
+ * file as a notice (switchedFrom).
1419
+ *
1420
+ * Positions are resolved against THIS process's view of the current path:
1421
+ * a state recorded by another runtime on a genuinely diverged branch never
1422
+ * matches, so it correctly does not take over this branch's file (one
1423
+ * branch, one file).
1424
+ */
1425
+ async function computePlan(ctx: SaveContext): Promise<SavePlanInput | null> {
1426
+ const pathEntries = ctx.session.getBranch();
1427
+ const pathMessages = pathEntries.filter(isMessageEntry);
1428
+ if (pathMessages.length === 0) return null;
1429
+
1430
+ const pos = new Map<string, number>();
1431
+ pathEntries.forEach((e, i) => pos.set(e.id, i));
1432
+
1433
+ let states = await ctx.readSaveStates();
1434
+ if (states === null) {
1435
+ states = [];
1436
+ for (const e of ctx.session.getEntries()) {
1437
+ if (e.type !== "custom" || e.customType !== CUSTOM_TYPE || !isSaveState(e.data)) continue;
1438
+ states.push(e.data);
1439
+ }
1440
+ debug("disk state unavailable — in-memory scan found", states.length, "states");
1441
+ }
1442
+
1443
+ const candidates: StateCandidate[] = [];
1444
+ // Latest state not on the current path, in record (scan) order: when no
1445
+ // candidate matches, its file is the previous branch's file that stays on
1446
+ // disk — named in the branch-change info notice.
1447
+ let offPathState: SaveState | null = null;
1448
+ let order = 0;
1449
+ for (const st of states) {
1450
+ if (st.schema && isNewerSchemaVersion(st.schema, SAVE_STATE_SCHEMA)) {
1451
+ ctx.warnMixedVersion(st);
1452
+ }
1453
+ if (!st.lastSavedEntryId) {
1454
+ offPathState = st;
1455
+ continue;
1456
+ }
1457
+ const p = pos.get(st.lastSavedEntryId);
1458
+ if (p === undefined) {
1459
+ offPathState = st;
1460
+ continue;
1461
+ }
1462
+ candidates.push({ state: st, pos: p, order: order++ });
1463
+ }
1464
+ candidates.sort((a, b) => b.pos - a.pos || b.order - a.order);
1465
+
1466
+ return { dir: targetDir(ctx), pathMessages, pos, candidates, offPathState };
1467
+ }
1468
+
1469
+ // ---------- writing ----------
1470
+
1471
+ async function atomicWrite(file: string, content: string): Promise<void> {
1472
+ const tmp = file + ".save-tmp";
1473
+ await fs.writeFile(tmp, content, "utf-8");
1474
+ await fs.rename(tmp, file);
1475
+ }
1476
+
1477
+ /**
1478
+ * Claim a filename that does not exist yet, never overwriting: an existing
1479
+ * target falls back to -1, -2 … suffixes (POSIX fs.rename silently replaces
1480
+ * an existing file). Every name-taking write goes through this — both
1481
+ * rename-on-title targets and fresh full saves, where two runtimes
1482
+ * recovering the same lost file in the same second would otherwise mint
1483
+ * the same name and silently overwrite each other. Returns null when the
1484
+ * desired name and 99 suffixes are all taken.
1485
+ */
1486
+ async function claimFilename(dir: string, desired: string): Promise<string | null> {
1487
+ const stem = desired.replace(/\.md$/, "");
1488
+ let target = desired;
1489
+ for (let i = 1; ; i++) {
1490
+ const taken = await fs
1491
+ .access(path.join(dir, target))
1492
+ .then(() => true)
1493
+ .catch(() => false);
1494
+ if (!taken) return target;
1495
+ if (i > 99) {
1496
+ debug("cannot claim a filename — desired name and 99 suffixes exist:", desired);
1497
+ return null;
1498
+ }
1499
+ target = `${stem}-${i}.md`;
1500
+ }
1501
+ }
1502
+
1503
+ function replaceFrontmatter(existing: string, fm: string): string {
1504
+ if (/^---\r?\n[\s\S]*?\r?\n---\r?\n/.test(existing)) {
1505
+ const rest = existing.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "");
1506
+ // Heal the old layout's blank line(s) between the frontmatter and the
1507
+ // document heading (new files write the heading directly after the
1508
+ // frontmatter). Only blank lines immediately followed by a `#` heading
1509
+ // at the very start of the body are stripped — anything the user
1510
+ // reorganized stays untouched.
1511
+ const healed = rest.replace(/^(?:[ \t]*\r?\n)+(#[^\r\n]*)/, "$1");
1512
+ return fm + "\n" + healed;
1513
+ }
1514
+ return `${fm}\n\n${existing}`;
1515
+ }
1516
+
1517
+ /**
1518
+ * Rewrite the document heading (the `# title` line written at file
1519
+ * creation) to the current title. Only called on rename-on-title saves.
1520
+ * The heading is located as the first non-blank line after the frontmatter
1521
+ * closing delimiter — new files write it directly after the frontmatter
1522
+ * with no blank line, legacy files wrote exactly one blank line before it
1523
+ * (replaceFrontmatter heals that away on appends) — rather than "the
1524
+ * first # line anywhere", so a manually deleted heading (whose place
1525
+ * would otherwise be taken by some content heading further down) cannot
1526
+ * be mis-rewritten.
1527
+ */
1528
+ function rewriteDocumentHeading(content: string, title: string): string {
1529
+ const fm = /^---\r?\n[\s\S]*?\r?\n---\r?\n/.exec(content);
1530
+ if (!fm) return content;
1531
+ const rest = content.slice(fm[0].length);
1532
+ // Blank lines between the frontmatter and the heading belong to the
1533
+ // layout, not the heading — keep them, replace only the heading line.
1534
+ const replaced = rest.replace(
1535
+ /^((?:[ \t]*\r?\n)*)#[^\r\n]*/,
1536
+ (blank: string, prefix: string) => `${prefix}# ${title.replace(/[\r\n]+/g, " ")}`,
1537
+ );
1538
+ if (replaced === rest) return content; // heading deleted by hand — leave it
1539
+ return fm[0] + replaced;
1540
+ }
1541
+
1542
+ /**
1543
+ * Rename-on-title decision for a file being continued (PRD §9.4).
1544
+ *
1545
+ * A file created before the session had its real name carries the fallback
1546
+ * slug in its filename. Once the session name exists, the save renames it
1547
+ * exactly once:
1548
+ * - "Fallback-named" comes from the state entry's `titled` flag; legacy
1549
+ * entries (written before the flag existed) are judged by recomputing
1550
+ * the fallback slug from the first user message and comparing it against
1551
+ * the actual filename segment — a manually organized filename therefore
1552
+ * never matches and is left alone.
1553
+ * - The target keeps the ORIGINAL creation timestamp (`<name>-<key>-<ts>`
1554
+ * with ts parsed from the old filename): the file's birthday stays
1555
+ * honest, and the target is a deterministic function of the file's own
1556
+ * identity, so concurrent or retried renames converge on one name.
1557
+ * - No rename while the session is still unnamed, when the name sanitizes
1558
+ * to the current segment (no-op), or when the filename does not parse.
1559
+ */
1560
+ function planRenameOnTitle(
1561
+ ctx: SaveContext,
1562
+ state: SaveState,
1563
+ pathMessages: SessionMessageEntry[],
1564
+ ): { renameTo: string | null; titled: boolean } {
1565
+ const keySuffix = "-" + state.sessionKey;
1566
+ // The trailing group tolerates (and drops) the -1, -2 … suffixes that
1567
+ // claimFilename may have added, so a suffixed file keeps its rename
1568
+ // eligibility; the suffix is not carried into the deterministic target.
1569
+ const tsMatch = /-(\d{8}-\d{6})(?:-\d{1,2})?\.md$/.exec(state.file);
1570
+ const base = tsMatch !== null ? state.file.slice(0, tsMatch.index) : null;
1571
+ const hasSegments = base !== null && base.endsWith(keySuffix);
1572
+ const titleSegment = hasSegments ? base.slice(0, base.length - keySuffix.length) : null;
1573
+
1574
+ const isFallbackNamed =
1575
+ state.titled !== undefined
1576
+ ? !state.titled
1577
+ : titleSegment !== null &&
1578
+ titleSegment === fallbackTitleForFilename(firstUserText(pathMessages));
1579
+ if (!isFallbackNamed) {
1580
+ // Already properly named, or a legacy file the user organized by hand:
1581
+ // never rename, and record it as titled.
1582
+ return { renameTo: null, titled: true };
1583
+ }
1584
+
1585
+ const name = ctx.session.getSessionName()?.trim();
1586
+ if (!name) return { renameTo: null, titled: false };
1587
+
1588
+ const newTitle = sanitizeFilenamePart(name);
1589
+ const ts = tsMatch?.[1];
1590
+ if (!newTitle || !ts || titleSegment === null || newTitle === titleSegment) {
1591
+ return { renameTo: null, titled: false };
1592
+ }
1593
+ const renameTo = `${newTitle}-${state.sessionKey}-${ts}.md`;
1594
+ debug("rename-on-title planned:", state.file, "→", renameTo);
1595
+ return { renameTo, titled: false };
1596
+ }
1597
+
1598
+ /**
1599
+ * Pick the continuation from the ranked candidates: validate each
1600
+ * newest-first — the target file must exist AND its frontmatter `messages`
1601
+ * count must cover the messages already saved for this branch (path
1602
+ * messages minus the ones about to be appended; a count that exceeds it
1603
+ * is fine — a descendant branch extended the same file) — and keep the
1604
+ * FIRST candidate that passes.
1605
+ *
1606
+ * When the newest candidate fails but an older one validates, the save
1607
+ * downgrades to the older file and a recoveryReason warns about it: the
1608
+ * newest target failing is the anomaly worth surfacing (an external tool
1609
+ * moving files, or a concurrent rewrite), and silently converging onto the
1610
+ * older file would hide it. Only when EVERY candidate fails — deleted
1611
+ * files, or files rewritten from a different tree position (e.g. /tree
1612
+ * navigation plus a save on an older branch), where continuing could
1613
+ * silently strand this branch's newer messages — is a brand-new file with
1614
+ * the full current branch written, with a warning naming the newest target
1615
+ * and its failure. Both missing-target and count-mismatch failures are
1616
+ * distinguished in the warning text, since they point at different
1617
+ * causes (something moving/deleting files vs. a concurrent runtime or an
1618
+ * older extension version). No cap on the candidate walk: the usual case
1619
+ * passes on the first candidate (one read); the worst case is one read per
1620
+ * candidate, and cutting the chain short would break the
1621
+ * "fresh file only when everything failed" semantics.
1622
+ */
1623
+ async function resolvePlan(ctx: SaveContext, input: SavePlanInput): Promise<SavePlan> {
1624
+ if (input.candidates.length === 0) {
1625
+ const fresh = freshFilePlan(ctx, input.dir, input.pathMessages);
1626
+ // Recorded states exist but none lies on the current path: the tree
1627
+ // moved to a different branch (a brand-new session has no states at
1628
+ // all). Normal one-branch-one-file behavior — surfaced as info so the
1629
+ // earlier branch's kept file is not mistaken for this branch's file.
1630
+ fresh.switchedFrom = input.offPathState ? input.offPathState.file : null;
1631
+ return fresh;
1632
+ }
1633
+
1634
+ // Failure of the newest candidate, recorded once for the downgrade /
1635
+ // fresh-file warnings: the newest target is the anomaly, later failures
1636
+ // only explain why the chain kept walking.
1637
+ let topFailure: {
1638
+ kind: "missing" | "count";
1639
+ file: string;
1640
+ saved: number | null;
1641
+ expected: number;
1642
+ } | null = null;
1643
+
1644
+ for (let i = 0; i < input.candidates.length; i++) {
1645
+ const c = input.candidates[i];
1646
+ const appendEntries = input.pathMessages.filter((e) => (input.pos.get(e.id) ?? -1) > c.pos);
1647
+ const expected = input.pathMessages.length - appendEntries.length;
1648
+ const filePath = path.join(input.dir, c.state.file);
1649
+ const exists = await fs
1650
+ .access(filePath)
1651
+ .then(() => true)
1652
+ .catch(() => false);
1653
+ if (!exists) {
1654
+ debug("candidate #" + (i + 1), "missing on disk:", c.state.file);
1655
+ if (!topFailure) {
1656
+ topFailure = { kind: "missing", file: c.state.file, saved: null, expected };
1657
+ }
1658
+ continue;
1659
+ }
1660
+ const existing = await fs.readFile(filePath, "utf-8");
1661
+ const saved = parseMessageCount(existing);
1662
+ // The file holds this branch's prefix plus possibly a descendant
1663
+ // branch's extra messages (saved > expected with nothing new to
1664
+ // append): that is fine to keep. Missing messages (saved < expected,
1665
+ // or none readable), or extra messages that new appends would
1666
+ // interleave with, are not — both would silently strand messages.
1667
+ if (saved === null || saved < expected || (saved > expected && appendEntries.length > 0)) {
1668
+ debug(
1669
+ "candidate #" + (i + 1),
1670
+ "out of sync (messages:",
1671
+ saved,
1672
+ "expected:",
1673
+ expected,
1674
+ "):",
1675
+ c.state.file,
1676
+ );
1677
+ if (!topFailure) {
1678
+ topFailure = { kind: "count", file: c.state.file, saved, expected };
1679
+ }
1680
+ continue;
1681
+ }
1682
+ debug(
1683
+ "continuing branch file:",
1684
+ c.state.file,
1685
+ "saved-up-to:",
1686
+ c.state.lastSavedEntryId,
1687
+ "new entries:",
1688
+ appendEntries.length,
1689
+ "(candidate #" + (i + 1) + " of " + input.candidates.length + ")",
1690
+ );
1691
+ const rename = planRenameOnTitle(ctx, c.state, input.pathMessages);
1692
+ return {
1693
+ dir: input.dir,
1694
+ filename: c.state.file,
1695
+ sessionKey: c.state.sessionKey,
1696
+ fullCreate: false,
1697
+ appendEntries,
1698
+ pathMessages: input.pathMessages,
1699
+ state: c.state,
1700
+ renameTo: rename.renameTo,
1701
+ titled: rename.titled,
1702
+ switchedFrom: null,
1703
+ recoveryReason:
1704
+ topFailure === null
1705
+ ? null
1706
+ : topFailure.kind === "missing"
1707
+ ? `latest saved file "${topFailure.file}" is missing on disk — ` +
1708
+ `fell back to continuing "${c.state.file}" instead; ` +
1709
+ "check whether an external tool is moving or deleting files in the archive directory"
1710
+ : `latest saved file "${topFailure.file}" no longer matches this branch's saved messages ` +
1711
+ `(file holds ${topFailure.saved ?? "no readable count"}, expected ${topFailure.expected}) — ` +
1712
+ `fell back to continuing "${c.state.file}" instead; ` +
1713
+ "this usually means another runtime or an older extension version rewrote it",
1714
+ };
1715
+ }
1716
+
1717
+ // Every candidate failed: fresh full-branch file, warning names the
1718
+ // newest target and its failure (both texts kept from the single-state
1719
+ // era — same causes, same wording).
1720
+ const fresh = freshFilePlan(ctx, input.dir, input.pathMessages);
1721
+ fresh.recoveryReason =
1722
+ topFailure === null
1723
+ ? null
1724
+ : topFailure.kind === "missing"
1725
+ ? `target file "${topFailure.file}" is missing on disk — the full branch was saved to a fresh file instead; ` +
1726
+ "check whether an external tool is moving or deleting files in the archive directory"
1727
+ : `target file "${topFailure.file}" no longer matches this branch's saved messages ` +
1728
+ `(file holds ${topFailure.saved ?? "no readable count"}, expected ${topFailure.expected}) — the full branch was saved to a fresh file instead; ` +
1729
+ "this usually means another runtime or an older extension version rewrote it";
1730
+ return fresh;
1731
+ }
1732
+
1733
+ async function saveConversation(ctx: SaveContext, plan: SavePlan): Promise<SaveResult> {
1734
+ const filePath = path.join(plan.dir, plan.filename);
1735
+
1736
+ await fs.mkdir(plan.dir, { recursive: true });
1737
+
1738
+ if (plan.fullCreate) {
1739
+ // Never overwrite: two runtimes recovering the same lost file in the
1740
+ // same second mint the same name — claim a free one instead. The
1741
+ // claimed name is what the state entry records (recordState below).
1742
+ const claimed = await claimFilename(plan.dir, plan.filename);
1743
+ if (claimed === null) {
1744
+ throw new Error(
1745
+ `cannot create a fresh save — "${plan.filename}" and 99 suffixes all exist`,
1746
+ );
1747
+ }
1748
+ plan.filename = claimed;
1749
+ const target = path.join(plan.dir, plan.filename);
1750
+ const meta = computeMeta(ctx, plan.pathMessages, plan.sessionKey, undefined, undefined);
1751
+ const body = renderEntries(plan.pathMessages); // ends with the trailing separator
1752
+ const content = `${frontmatter(meta)}\n# ${meta.title}\n\n${body}`;
1753
+ await atomicWrite(target, content);
1754
+ debug("created conversation file:", target);
1755
+ return {
1756
+ message: `saved ${plan.filename} (${plan.pathMessages.length} messages)`,
1757
+ wrote: true,
1758
+ created: true,
1759
+ file: target,
1760
+ recovered: plan.recoveryReason,
1761
+ switchedFrom: plan.switchedFrom,
1762
+ renamed: false,
1763
+ };
1764
+ }
1765
+
1766
+ if (plan.appendEntries.length === 0 && !plan.renameTo) {
1767
+ debug("nothing new since last save:", plan.filename);
1768
+ return {
1769
+ message: `already up to date (${plan.filename})`,
1770
+ wrote: false,
1771
+ created: false,
1772
+ file: filePath,
1773
+ // A downgrade warning (candidate chain fell back to this file) must
1774
+ // surface even when there is nothing new to append.
1775
+ recovered: plan.recoveryReason,
1776
+ switchedFrom: null,
1777
+ renamed: false,
1778
+ };
1779
+ }
1780
+
1781
+ const existing = await fs.readFile(filePath, "utf-8");
1782
+ const meta = computeMeta(
1783
+ ctx,
1784
+ plan.pathMessages,
1785
+ plan.sessionKey,
1786
+ parseCreated(existing),
1787
+ parseAgent(existing),
1788
+ );
1789
+ let updated = replaceFrontmatter(existing, frontmatter(meta));
1790
+ // Rename-on-title: rewrite the document heading to the now-real title
1791
+ // (the frontmatter title is refreshed above) while still writing under
1792
+ // the old name — the move itself happens after this write. A rename due
1793
+ // with nothing new to append is still a write: the title fields change,
1794
+ // and the state entry must carry the new name.
1795
+ if (plan.renameTo) updated = rewriteDocumentHeading(updated, meta.title);
1796
+ if (plan.appendEntries.length > 0) {
1797
+ const appended = renderEntries(plan.appendEntries); // ends with the trailing separator
1798
+ // Collapse trailing blank lines to a single newline so the separator
1799
+ // always has exactly one blank line above it, whatever earlier saves
1800
+ // (or a manual edit) left behind.
1801
+ updated = updated.replace(/\s*$/, "\n");
1802
+ // Files written by the old format end without a `---` separator; add one
1803
+ // at the boundary so old and new content stay delimited.
1804
+ updated += updated.endsWith("---\n") ? `\n${appended}` : `\n---\n\n${appended}`;
1805
+ }
1806
+ await atomicWrite(filePath, updated);
1807
+ debug("appended", plan.appendEntries.length, "entries to:", filePath);
1808
+ return {
1809
+ message:
1810
+ plan.appendEntries.length > 0
1811
+ ? `appended ${plan.appendEntries.length} messages to ${plan.filename}`
1812
+ : `refreshed title of ${plan.filename}`,
1813
+ wrote: true,
1814
+ created: false,
1815
+ file: filePath,
1816
+ // A downgrade warning (candidate chain fell back to this file) must
1817
+ // surface on the append that performed the downgrade.
1818
+ recovered: plan.recoveryReason,
1819
+ switchedFrom: null,
1820
+ renamed: false,
1821
+ };
1822
+ }
1823
+
1824
+ function relativeForUser(ctx: SaveContext, file: string): string {
1825
+ const rel = path.relative(ctx.cwd, file);
1826
+ return rel && !rel.startsWith("..") ? rel : file;
1827
+ }
1828
+
1829
+ // Serialize saves: agent_settled and the manual command must not interleave.
1830
+ let chain: Promise<unknown> = Promise.resolve();
1831
+ function schedule<T>(fn: () => Promise<T>): Promise<T> {
1832
+ const run = chain.then(fn, fn);
1833
+ chain = run.then(
1834
+ () => undefined,
1835
+ () => undefined,
1836
+ );
1837
+ return run;
1838
+ }
1839
+
1840
+ /**
1841
+ * Move the just-written file to its rename-on-title target (PRD §9.4
1842
+ * D5'/D7'/D8'). Runs AFTER the write, so a failed write never orphans a
1843
+ * renamed file; a failed rename keeps the old filename (the title fields
1844
+ * were already fixed in the write) and leaves titled=false, so the next
1845
+ * save retries — the target is deterministic, so retries converge on one
1846
+ * name. The move never overwrites: POSIX fs.rename silently replaces an
1847
+ * existing target, so an existing one falls back to -1, -2 … suffixes.
1848
+ */
1849
+ async function applyRename(ctx: SaveContext, plan: SavePlan, result: SaveResult): Promise<void> {
1850
+ if (!plan.renameTo) return;
1851
+ const target = await claimFilename(plan.dir, plan.renameTo);
1852
+ if (target === null) {
1853
+ debug("rename-on-title gave up — target and 99 suffixes exist:", plan.renameTo);
1854
+ return;
1855
+ }
1856
+ try {
1857
+ await fs.rename(path.join(plan.dir, plan.filename), path.join(plan.dir, target));
1858
+ } catch (e) {
1859
+ debug("rename-on-title failed — kept old filename, retrying next save:", String(e));
1860
+ return;
1861
+ }
1862
+ debug("renamed branch file:", plan.filename, "→", target);
1863
+ plan.filename = target;
1864
+ plan.titled = true;
1865
+ result.file = path.join(plan.dir, target);
1866
+ result.renamed = true;
1867
+ ctx.notify(`${NOTIFY_TAG} renamed to ${relativeForUser(ctx, result.file)}`, "info");
1868
+ }
1869
+
1870
+ /** Full save cycle: write the file, then persist the branch state entry. */
1871
+ async function runSave(ctx: SaveContext): Promise<SaveResult> {
1872
+ const input = await computePlan(ctx);
1873
+ if (!input) {
1874
+ return {
1875
+ message: "no conversation content to save yet",
1876
+ wrote: false,
1877
+ created: false,
1878
+ file: null,
1879
+ recovered: null,
1880
+ switchedFrom: null,
1881
+ renamed: false,
1882
+ };
1883
+ }
1884
+ const plan = await resolvePlan(ctx, input);
1885
+ // Concurrency guard for disk-backed sessions: bail before ANY write when
1886
+ // the foreign jsonl moved since load. No-op for the live session.
1887
+ await ctx.beforeWrite();
1888
+ const result = await saveConversation(ctx, plan);
1889
+ if (result.wrote) {
1890
+ await applyRename(ctx, plan, result);
1891
+ const leafId = ctx.session.getLeafId();
1892
+ await ctx.appendState({
1893
+ sessionKey: plan.sessionKey,
1894
+ lastSavedEntryId: leafId,
1895
+ file: plan.filename,
1896
+ schema: SAVE_STATE_SCHEMA,
1897
+ extVersion: EXTENSION_VERSION ?? undefined,
1898
+ titled: plan.titled,
1899
+ });
1900
+ debug("recorded state entry — sessionKey:", plan.sessionKey, "leaf:", leafId);
1901
+ }
1902
+ return result;
1903
+ }
1904
+
1905
+ /**
1906
+ * Wrap the real host context as a SaveContext for the live session. The
1907
+ * session surface passes straight through (structural subset), state
1908
+ * entries go through pi.appendEntry, and the mixed-version warning keeps
1909
+ * its per-process latch for the live path.
1910
+ */
1911
+ function liveContext(ctx: ExtensionContext): SaveContext {
1912
+ return {
1913
+ cwd: ctx.cwd,
1914
+ hasUI: ctx.hasUI,
1915
+ notify: (message, level) => {
1916
+ if (ctx.hasUI) ctx.ui.notify(message, level);
1917
+ },
1918
+ session: ctx.sessionManager,
1919
+ readSaveStates: () => readDiskSaveStates(ctx.sessionManager.getSessionFile() ?? null),
1920
+ warnMixedVersion: (st) => {
1921
+ // One mixed-version warning per process: repeating it every turn
1922
+ // would only train the user to ignore it. Headless runs (no UI)
1923
+ // count as warned too — there is no notification surface to wait for.
1924
+ if (mixedVersionWarned) return;
1925
+ mixedVersionWarned = true;
1926
+ const writer = st.extVersion
1927
+ ? `extension v${st.extVersion} (save-state schema ${st.schema})`
1928
+ : `save-state schema ${st.schema}`;
1929
+ debug(
1930
+ "mixed versions: state entries written by",
1931
+ writer,
1932
+ "— this code is",
1933
+ SAVE_STATE_SCHEMA,
1934
+ );
1935
+ if (ctx.hasUI) {
1936
+ ctx.ui.notify(
1937
+ `${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`,
1938
+ "warning",
1939
+ );
1940
+ }
1941
+ },
1942
+ beforeWrite: async () => {},
1943
+ appendState: async (state) => {
1944
+ pi.appendEntry(CUSTOM_TYPE, state);
1945
+ },
1946
+ };
1947
+ }
1948
+
1949
+ // The live path's mixed-version latch (see liveContext).
1950
+ let mixedVersionWarned = false;
1951
+
1952
+ // 1. Automatic: save after every settled agent turn.
1953
+ pi.on("agent_settled", async (_event: AgentSettledEvent, ctx: ExtensionContext) => {
1954
+ // Non-persisted sessions (in-memory, `--no-session`) are ephemeral
1955
+ // auxiliary agents — see the skip rule in the header. The manual
1956
+ // /save-conversation command below is the explicit-demand escape hatch.
1957
+ if (!ctx.sessionManager.getSessionFile()) {
1958
+ debug(
1959
+ "agent_settled — session has no session file (in-memory / --no-session); skipping auto-save",
1960
+ );
1961
+ return;
1962
+ }
1963
+ debug("agent_settled — saving conversation");
1964
+ try {
1965
+ const live = liveContext(ctx);
1966
+ const r = await schedule(() => runSave(live));
1967
+ // A recovery replaces the routine "created" info: the anomaly is the
1968
+ // story worth telling, at a level that distinguishes it (warning).
1969
+ if (r.recovered && ctx.hasUI && r.file) {
1970
+ ctx.ui.notify(`${NOTIFY_TAG} ${r.recovered} → ${relativeForUser(live, r.file)}`, "warning");
1971
+ } else if (r.wrote && r.created && ctx.hasUI && r.file) {
1972
+ if (r.switchedFrom) {
1973
+ ctx.ui.notify(
1974
+ `${NOTIFY_TAG} branch changed — new branch file ${relativeForUser(live, r.file)}; the earlier branch file ${r.switchedFrom} is kept`,
1975
+ "info",
1976
+ );
1977
+ } else {
1978
+ ctx.ui.notify(`${NOTIFY_TAG} ${relativeForUser(live, r.file)}`, "info");
1979
+ }
1980
+ }
1981
+ } catch (e) {
1982
+ debug("auto-save failed:", String(e));
1983
+ if (ctx.hasUI) ctx.ui.notify(`${NOTIFY_TAG} save failed: ${String(e)}`, "error");
1984
+ }
1985
+ });
1986
+
1987
+ // 2. Manual: force a save now and report where it went.
1988
+ pi.registerCommand(COMMAND, {
1989
+ description:
1990
+ "Save the current conversation branch to a markdown file now (pi-auto-save-session-to-markdown)",
1991
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
1992
+ try {
1993
+ debug("manual /" + COMMAND + " invoked");
1994
+ const live = liveContext(ctx);
1995
+ const r = await schedule(() => runSave(live));
1996
+ if (ctx.hasUI) {
1997
+ const target = r.file ? relativeForUser(live, r.file) : "";
1998
+ ctx.ui.notify(`${NOTIFY_TAG} ${r.message}${target ? ` → ${target}` : ""}`, "info");
1999
+ if (r.recovered && r.file) {
2000
+ ctx.ui.notify(`${NOTIFY_TAG} ${r.recovered} → ${target}`, "warning");
2001
+ }
2002
+ if (r.switchedFrom && r.file) {
2003
+ ctx.ui.notify(
2004
+ `${NOTIFY_TAG} branch changed — new branch file ${target}; the earlier branch file ${r.switchedFrom} is kept`,
2005
+ "info",
2006
+ );
2007
+ }
2008
+ }
2009
+ } catch (e) {
2010
+ debug("/" + COMMAND + " failed:", String(e));
2011
+ if (ctx.hasUI) ctx.ui.notify(`${NOTIFY_TAG} save failed: ${String(e)}`, "error");
2012
+ }
2013
+ },
2014
+ });
2015
+
2016
+ // 3. Batch: save every session of this project (PRD §5 P3 + §9.6).
2017
+ async function saveAllSessions(ctx: ExtensionContext): Promise<void> {
2018
+ const started = Date.now();
2019
+ const live = liveContext(ctx);
2020
+ let saved = 0;
2021
+ let freshCreated = 0;
2022
+ let renamed = 0;
2023
+ let switched = 0;
2024
+ let upToDate = 0;
2025
+ let skippedEmpty = 0;
2026
+ let skippedLegacy = 0;
2027
+ let deferred = 0;
2028
+ const failed: string[] = [];
2029
+
2030
+ const count = (r: SaveResult) => {
2031
+ if (r.wrote) {
2032
+ saved++;
2033
+ if (r.created) freshCreated++;
2034
+ if (r.renamed) renamed++;
2035
+ if (r.switchedFrom) switched++;
2036
+ } else {
2037
+ upToDate++;
2038
+ }
2039
+ };
2040
+
2041
+ // The live session first, through the normal path: its in-memory tree may
2042
+ // be fresher than disk, and its state entry goes through pi.appendEntry.
2043
+ // A non-persisted current session (in-memory / --no-session) is skipped —
2044
+ // see the skip rule in the header.
2045
+ if (!ctx.sessionManager.getSessionFile()) {
2046
+ debug(
2047
+ "/" + COMMAND_ALL + " — current session not persisted (in-memory / --no-session); skipped",
2048
+ );
2049
+ } else {
2050
+ try {
2051
+ const r = await runSave(live);
2052
+ count(r);
2053
+ if (ctx.hasUI) {
2054
+ const target = r.file ? relativeForUser(live, r.file) : "";
2055
+ ctx.ui.notify(`${NOTIFY_TAG} ${r.message}${target ? ` → ${target}` : ""}`, "info");
2056
+ if (r.recovered && r.file) {
2057
+ ctx.ui.notify(`${NOTIFY_TAG} ${r.recovered} → ${target}`, "warning");
2058
+ }
2059
+ if (r.switchedFrom && r.file) {
2060
+ ctx.ui.notify(
2061
+ `${NOTIFY_TAG} branch changed — new branch file ${target}; the earlier branch file ${r.switchedFrom} is kept`,
2062
+ "info",
2063
+ );
2064
+ }
2065
+ }
2066
+ } catch (e) {
2067
+ failed.push("<current session>");
2068
+ debug("/" + COMMAND_ALL + " live save failed:", String(e));
2069
+ if (ctx.hasUI) {
2070
+ ctx.ui.notify(`${NOTIFY_TAG} save failed: ${String(e)}`, "error");
2071
+ }
2072
+ }
2073
+ }
2074
+
2075
+ // The project's sessions directory — every session jsonl lives there.
2076
+ const currentFile = ctx.sessionManager.getSessionFile();
2077
+ const dir = currentFile ? path.dirname(currentFile) : defaultSessionsDir(ctx.cwd);
2078
+ const currentId = ctx.sessionManager.getSessionId();
2079
+ const files: { file: string; mtime: number }[] = [];
2080
+ try {
2081
+ const names = (await fs.readdir(dir)).filter((f) => f.endsWith(".jsonl"));
2082
+ for (const name of names) {
2083
+ const file = path.join(dir, name);
2084
+ try {
2085
+ files.push({ file, mtime: (await fs.stat(file)).mtimeMs });
2086
+ } catch {
2087
+ // vanished between readdir and stat — the mover; ignore
2088
+ }
2089
+ }
2090
+ } catch (e) {
2091
+ debug("/" + COMMAND_ALL + " cannot list sessions dir:", String(e));
2092
+ if (ctx.hasUI) {
2093
+ ctx.ui.notify(`${NOTIFY_TAG} cannot list sessions directory: ${String(e)}`, "error");
2094
+ }
2095
+ return;
2096
+ }
2097
+ // Newest first: if the run is interrupted, the most recent sessions are done.
2098
+ files.sort((a, b) => b.mtime - a.mtime);
2099
+
2100
+ const sink = (message: string, level: "info" | "warning" | "error") => {
2101
+ if (ctx.hasUI) ctx.ui.notify(`${NOTIFY_TAG} ${message}`, level);
2102
+ };
2103
+
2104
+ for (const { file } of files) {
2105
+ if (currentFile && path.resolve(file) === path.resolve(currentFile)) continue;
2106
+ try {
2107
+ const outcome = await loadDiskSession(file, sink);
2108
+ if (outcome.kind === "deferred") {
2109
+ deferred++;
2110
+ continue;
2111
+ }
2112
+ if (outcome.kind === "skip") {
2113
+ if (outcome.reason === "legacy") skippedLegacy++;
2114
+ else skippedEmpty++;
2115
+ continue;
2116
+ }
2117
+ if (outcome.view.session.getSessionId() === currentId) continue; // same session, other file
2118
+ const view = outcome.view;
2119
+ const r = await runSave(view);
2120
+ count(r);
2121
+ // Recovery warnings are anomalies — shown per session, tagged.
2122
+ if (r.recovered && r.file && ctx.hasUI) {
2123
+ ctx.ui.notify(
2124
+ `${NOTIFY_TAG} [${view.tag()}] ${r.recovered} → ${relativeForUser(live, r.file)}`,
2125
+ "warning",
2126
+ );
2127
+ }
2128
+ } catch (e) {
2129
+ if (e instanceof SessionChangedError) {
2130
+ deferred++;
2131
+ continue;
2132
+ }
2133
+ failed.push(path.basename(file));
2134
+ debug("/" + COMMAND_ALL + " session failed:", path.basename(file), String(e));
2135
+ if (ctx.hasUI) {
2136
+ ctx.ui.notify(
2137
+ `${NOTIFY_TAG} [${path.basename(file)}] save failed: ${String(e)}`,
2138
+ "error",
2139
+ );
2140
+ }
2141
+ }
2142
+ }
2143
+
2144
+ const summaryParts = [
2145
+ `${saved} saved`,
2146
+ `${upToDate} up to date`,
2147
+ `${skippedEmpty + skippedLegacy} skipped`,
2148
+ ];
2149
+ if (saved && (freshCreated || renamed || switched)) {
2150
+ const detail = [
2151
+ freshCreated ? `${freshCreated} new` : "",
2152
+ renamed ? `${renamed} renamed` : "",
2153
+ switched ? `${switched} branch switches` : "",
2154
+ ]
2155
+ .filter(Boolean)
2156
+ .join(", ");
2157
+ if (detail) summaryParts[0] += ` (${detail})`;
2158
+ }
2159
+ if (skippedEmpty + skippedLegacy) {
2160
+ summaryParts[2] += skippedLegacy
2161
+ ? ` (${skippedEmpty} without assistant reply, ${skippedLegacy} legacy)`
2162
+ : " (no assistant reply)";
2163
+ }
2164
+ if (deferred)
2165
+ summaryParts.push(`${deferred} deferred (changed while saving — next run continues them)`);
2166
+ summaryParts.push(`${failed.length} failed`);
2167
+ if (failed.length) {
2168
+ const shown = failed.slice(0, 5).join(", ");
2169
+ summaryParts.push(`: ${shown}${failed.length > 5 ? " …" : ""}`);
2170
+ }
2171
+ debug("/" + COMMAND_ALL + " finished in", Date.now() - started, "ms");
2172
+ if (ctx.hasUI) {
2173
+ ctx.ui.notify(`${NOTIFY_TAG} /${COMMAND_ALL}: ${summaryParts.join(", ")}`, "info");
2174
+ }
2175
+ }
2176
+
2177
+ pi.registerCommand(COMMAND_ALL, {
2178
+ description:
2179
+ "Save every session of this project to markdown files now (pi-auto-save-session-to-markdown)",
2180
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
2181
+ try {
2182
+ debug("manual /" + COMMAND_ALL + " invoked");
2183
+ // The whole batch is serialized against auto-saves and the manual
2184
+ // /save-conversation: same queue, no interleaved claims.
2185
+ await schedule(() => saveAllSessions(ctx));
2186
+ } catch (e) {
2187
+ debug("/" + COMMAND_ALL + " failed:", String(e));
2188
+ if (ctx.hasUI) ctx.ui.notify(`${NOTIFY_TAG} save failed: ${String(e)}`, "error");
2189
+ }
2190
+ },
2191
+ });
2192
+ }