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/LICENSE +21 -0
- package/README.md +186 -0
- package/README.zh.md +186 -0
- package/debug.ts +27 -0
- package/index.ts +2192 -0
- package/markdown.ts +282 -0
- package/package.json +61 -0
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
}
|