@pi-claudian/auto-save-to-markdown 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 licongy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # @pi-claudian/auto-save-to-markdown
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@pi-claudian/auto-save-to-markdown?style=flat&colorA=222222&colorB=CB3837)](https://www.npmjs.com/package/@pi-claudian/auto-save-to-markdown)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
5
+
6
+ [English](README.md) | [中文](README.zh.md)
7
+
8
+ A [Pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) extension
9
+ that automatically saves every completed conversation turn as a markdown file
10
+ with YAML frontmatter — one file per session-tree branch.
11
+
12
+ ## Why
13
+
14
+ Pi records sessions internally as JSONL trees, which are great for resuming but
15
+ terrible for reading, searching, or archiving. This extension mirrors the
16
+ conversation into plain markdown files as you work, so every exchange is
17
+ preserved in a format any editor, note app, or grep can consume — with the
18
+ model, cost, tokens, and session metadata right in the frontmatter.
19
+
20
+ ## Installation
21
+
22
+ ```
23
+ pi install npm:@pi-claudian/auto-save-to-markdown
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ Automatic: after every settled agent turn (`agent_settled`), the current
29
+ conversation branch is written to `<cwd>/<folder>/<title>-<tree>-<time>.md`.
30
+
31
+ Manual: run `/save-conversation` to save the current branch immediately and
32
+ report the file path.
33
+
34
+ ## Configuration
35
+
36
+ The target folder is controlled by the `PI_SAVE_CONVERSATION_DIR` environment
37
+ variable (Pi has no per-extension settings API):
38
+
39
+ | Value | Location |
40
+ | ----------- | ----------------------------------- |
41
+ | unset | `<cwd>/ai-conversations/` (default) |
42
+ | `.` or `""` | `<cwd>/` directly |
43
+ | `notes/ai` | `<cwd>/notes/ai/` |
44
+ | `/abs/path` | that absolute path |
45
+
46
+ ```bash
47
+ PI_SAVE_CONVERSATION_DIR=notes/ai pi
48
+ ```
49
+
50
+ ## File naming and frontmatter
51
+
52
+ Filename: `<title>-<tree>-<time>.md`
53
+
54
+ - `<title>` — the session name (`/name`), or a slug of the first user message
55
+ when the session is unnamed
56
+ - `<tree>` — the id of the deepest message entry at file creation (the branch key)
57
+ - `<time>` — local file-creation time, `YYYYMMDD-HHmmss`
58
+
59
+ ```markdown
60
+ ---
61
+ title: "Fix login redirect loop"
62
+ session_id: "d0a4f541-976d-4d1b-8e1c-30a1f2b3c4d5"
63
+ tree: "a1b2c3d4"
64
+ model: "claude-sonnet-4-5"
65
+ provider: "anthropic"
66
+ cost: 0.023401
67
+ tokens: 18745
68
+ tokens_input: 15230
69
+ tokens_output: 3515
70
+ messages: 8
71
+ created: "2026-08-29T05:05:12.000Z"
72
+ updated: "2026-08-29T05:42:10.000Z"
73
+ cwd: "/Users/me/project"
74
+ session_file: "~/.pi/agent/sessions/--Users-me-project-20260829-050500_ab12.jsonl"
75
+ ---
76
+
77
+ # Fix login redirect loop
78
+
79
+ ## User · 13:05:12
80
+
81
+ The login page redirects in a loop after the auth refactor...
82
+
83
+ ## Assistant · 13:05:40 · claude-sonnet-4-5
84
+
85
+ <details>
86
+ <summary>Thinking</summary>
87
+
88
+ Let me check the redirect chain...
89
+
90
+ </details>
91
+
92
+ I'll trace the middleware order first.
93
+
94
+ **Tool calls**
95
+
96
+ - `read` — {"filePath":"/Users/me/project/src/auth/middleware.ts"}
97
+
98
+ > **Tool · read** /Users/me/project/src/auth/middleware.ts — 120 lines …
99
+ ```
100
+
101
+ The body renders user and assistant messages in full (assistant thinking is
102
+ kept in a collapsible `<details>` block) and summarizes each tool call and
103
+ result in one line, so the file stays readable while still showing what the
104
+ agent did.
105
+
106
+ ## Branch behavior
107
+
108
+ Pi sessions are trees: `/tree` navigates to an earlier point and a new prompt
109
+ forks a new branch. Each markdown file records exactly **one branch** — the
110
+ root-to-leaf path that branch sees.
111
+
112
+ - **Same branch, next turn** → new messages are _appended_ to the existing
113
+ file, and the frontmatter (`cost`, `tokens`, `messages`, `updated`, title,
114
+ model) is refreshed.
115
+ - **`/tree` + new prompt (a different branch)** → a _new file_ is created
116
+ containing the full new branch (the shared prefix plus the new exchange).
117
+ - **Forking at the current tip** → the existing file continues (its content is
118
+ already an exact prefix of the new branch), so no duplicate file is created.
119
+ - **Resuming later** (restart, `/resume`, `/fork`, `/clone`) → the branch is
120
+ recognized and its file continues where it left off.
121
+
122
+ Branch identity is persisted inside the session tree itself via extension
123
+ custom entries (never sent to the LLM, not rendered in the TUI), so state
124
+ survives restarts and navigation without any sidecar files.
125
+
126
+ Compacted sessions still export their **full original history** — the archive
127
+ always contains the complete conversation, not the compacted context.
128
+
129
+ ## Debug
130
+
131
+ ```bash
132
+ PI_CLAUDIAN_DEBUG=1 pi
133
+ ```
134
+
135
+ ## License
136
+
137
+ MIT
package/README.zh.md ADDED
@@ -0,0 +1,123 @@
1
+ # @pi-claudian/auto-save-to-markdown
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@pi-claudian/auto-save-to-markdown?style=flat&colorA=222222&colorB=CB3837)](https://www.npmjs.com/package/@pi-claudian/auto-save-to-markdown)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
5
+
6
+ [English](README.md) | [中文](README.zh.md)
7
+
8
+ 一个 [Pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) 扩展:每轮对话完成后,自动把当前对话分支保存为带 YAML frontmatter 的 markdown 文件——每个会话树分支一个文件。
9
+
10
+ ## 为什么需要它
11
+
12
+ Pi 内部以 JSONL 树的形式记录会话,便于恢复却不便于阅读、检索和归档。本扩展在你工作的同时把对话镜像成普通 markdown 文件,每轮交流都以任何编辑器、笔记软件或 grep 都能处理的格式留存,且模型、费用、token 数等元数据都写在 frontmatter 里。
13
+
14
+ ## 安装
15
+
16
+ ```
17
+ pi install npm:@pi-claudian/auto-save-to-markdown
18
+ ```
19
+
20
+ ## 用法
21
+
22
+ 自动:每个 agent 轮次完全结束(`agent_settled`,含自动重试与压缩全部完成)后,当前对话分支写入 `<cwd>/<文件夹>/<标题>-<tree>-<时间>.md`。
23
+
24
+ 手动:运行 `/save-conversation` 立即保存当前分支并显示文件路径。
25
+
26
+ ## 配置
27
+
28
+ 目标文件夹由环境变量 `PI_SAVE_CONVERSATION_DIR` 控制(Pi 没有扩展设置 API):
29
+
30
+ | 取值 | 保存位置 |
31
+ | ----------- | --------------------------------- |
32
+ | 未设置 | `<cwd>/ai-conversations/`(默认) |
33
+ | `.` 或 `""` | 直接保存在 `<cwd>/` |
34
+ | `notes/ai` | `<cwd>/notes/ai/` |
35
+ | `/绝对路径` | 该绝对路径 |
36
+
37
+ ```bash
38
+ PI_SAVE_CONVERSATION_DIR=notes/ai pi
39
+ ```
40
+
41
+ ## 文件名与 frontmatter
42
+
43
+ 文件名:`<标题>-<tree>-<时间>.md`
44
+
45
+ - `<标题>` — 会话名称(`/name`);未命名时取第一条用户消息的摘要
46
+ - `<tree>` — 建文件时分支上最深一条消息的 entry id(分支标识)
47
+ - `<时间>` — 建文件的本地时间,格式 `YYYYMMDD-HHmmss`
48
+
49
+ ```markdown
50
+ ---
51
+ title: "修复登录重定向死循环"
52
+ session_id: "d0a4f541-976d-4d1b-8e1c-30a1f2b3c4d5"
53
+ tree: "a1b2c3d4"
54
+ model: "claude-sonnet-4-5"
55
+ provider: "anthropic"
56
+ cost: 0.023401
57
+ tokens: 18745
58
+ tokens_input: 15230
59
+ tokens_output: 3515
60
+ messages: 8
61
+ created: "2026-08-29T05:05:12.000Z"
62
+ updated: "2026-08-29T05:42:10.000Z"
63
+ cwd: "/Users/me/project"
64
+ session_file: "~/.pi/agent/sessions/--Users-me-project-20260829-050500_ab12.jsonl"
65
+ ---
66
+
67
+ # 修复登录重定向死循环
68
+
69
+ ## User · 13:05:12
70
+
71
+ auth 重构之后登录页一直重定向死循环……
72
+
73
+ ## Assistant · 13:05:40 · claude-sonnet-4-5
74
+
75
+ <details>
76
+ <summary>Thinking</summary>
77
+
78
+ 先看中间件的执行顺序……
79
+
80
+ </details>
81
+
82
+ 我先追踪一下中间件链。
83
+
84
+ **Tool calls**
85
+
86
+ - `read` — {"filePath":"/Users/me/project/src/auth/middleware.ts"}
87
+
88
+ > **Tool · read** /Users/me/project/src/auth/middleware.ts — 120 lines …
89
+ ```
90
+
91
+ 正文完整渲染 user / assistant 消息(assistant 的 thinking 放在可折叠的
92
+ `<details>` 块中),每个工具调用和结果各压缩成一行摘要,既可读又能看出
93
+ agent 做了什么。
94
+
95
+ ## 分支行为
96
+
97
+ Pi 会话是树:`/tree` 导航到更早的位置后再提问就分出新的分支。每个
98
+ markdown 文件只记录**一个分支**——即该分支看到的 root→leaf 完整路径。
99
+
100
+ - **同一分支继续对话** → 新消息*追加*到已有文件,frontmatter(`cost`、
101
+ `tokens`、`messages`、`updated`、标题、模型)同步刷新。
102
+ - **`/tree` 后重新提问(不同分支)** → _另存新文件_,内容为新分支的完整
103
+ 路径(共享前缀 + 新对话)。
104
+ - **在当前末端分叉** → 已有文件继续追加(其内容恰好是新分支的精确前缀),
105
+ 不会产生重复文件。
106
+ - **之后恢复会话**(重启、`/resume`、`/fork`、`/clone`)→ 分支被识别,
107
+ 对应文件从上次的位置继续。
108
+
109
+ 分支状态以扩展 custom entry 的形式持久化在会话树内部(不进 LLM 上下文、
110
+ 不在 TUI 渲染),因此无需任何辅助文件即可在重启和导航后恢复状态。
111
+
112
+ 被压缩(compaction)过的会话导出的仍是**完整原始历史**——归档永远是全量
113
+ 对话,而不是压缩后的上下文。
114
+
115
+ ## 调试
116
+
117
+ ```bash
118
+ PI_CLAUDIAN_DEBUG=1 pi
119
+ ```
120
+
121
+ ## 许可
122
+
123
+ MIT
package/debug.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Shared debug logging for all @pi-claudian extensions.
3
+ *
4
+ * Enable by setting the PI_CLAUDIAN_DEBUG environment variable to any truthy
5
+ * value (e.g. "1"). Output goes to stderr via console.error, so it never mixes
6
+ * with pi's stdout and can be captured separately:
7
+ *
8
+ * PI_CLAUDIAN_DEBUG=1 pi # show inline
9
+ * PI_CLAUDIAN_DEBUG=1 pi 2>debug.log # capture to a file
10
+ *
11
+ * This is a source-only module (Pi loads it via jiti). Each @pi-claudian
12
+ * package vendors its own copy and imports it, keeping packages independent —
13
+ * the shared contract is the PI_CLAUDIAN_DEBUG env var name, not a shared
14
+ * npm dependency.
15
+ */
16
+
17
+ const TAG = "[pi-claudian]";
18
+ const enabled = Boolean(process.env.PI_CLAUDIAN_DEBUG);
19
+
20
+ /** Log a debug message when PI_CLAUDIAN_DEBUG is set. */
21
+ export function debug(...args: unknown[]): void {
22
+ if (!enabled) return;
23
+ console.error(TAG, ...args);
24
+ }
package/index.ts ADDED
@@ -0,0 +1,599 @@
1
+ /**
2
+ * @pi-claudian/auto-save-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
+ * - Location: a subfolder of the session's working directory (`ctx.cwd`, the
13
+ * directory the session was started in), defaulting to `ai-conversations`.
14
+ * Override with the PI_SAVE_CONVERSATION_DIR environment variable; set it to
15
+ * "." or "" to save directly into the working directory.
16
+ * - Filename: `<title>-<tree>-<time>.md`, where <title> is the session name
17
+ * (or a slug of the first user message when unnamed), <tree> is the 8-hex id
18
+ * of the deepest message entry at file creation, and <time> is the local
19
+ * file-creation timestamp (YYYYMMDD-HHmmss).
20
+ * - Frontmatter: title, session id, tree (branch key), model, provider,
21
+ * cumulative cost and tokens, message count, created/updated timestamps,
22
+ * cwd and session file.
23
+ * - Branching: each file records exactly ONE branch (the root→leaf path
24
+ * returned by sessionManager.getBranch()). State is persisted via
25
+ * `pi.appendEntry()` custom entries, which are part of the session tree
26
+ * itself — they are not sent to the LLM and not rendered in the TUI. On
27
+ * every save the extension finds the file whose latest saved position is the
28
+ * deepest entry still on the current path; if the tree moved elsewhere
29
+ * (e.g. /tree navigation followed by a new prompt), no file matches and a
30
+ * new file is created with the full current branch. Continuing an existing
31
+ * branch appends only the messages that are new since the last save.
32
+ * - Compaction: files archive the ORIGINAL messages (getBranch() returns the
33
+ * raw tree path, not the compaction-aware context), so a compacted session
34
+ * still exports its complete history.
35
+ *
36
+ * Manual command: `/save-conversation` saves the current branch immediately
37
+ * and reports the file path.
38
+ *
39
+ * Installation:
40
+ * pi install npm:@pi-claudian/auto-save-to-markdown
41
+ *
42
+ * Debug:
43
+ * PI_CLAUDIAN_DEBUG=1 pi
44
+ */
45
+
46
+ import type {
47
+ AgentSettledEvent,
48
+ ExtensionAPI,
49
+ ExtensionCommandContext,
50
+ ExtensionContext,
51
+ SessionEntry,
52
+ SessionMessageEntry,
53
+ } from "@earendil-works/pi-coding-agent";
54
+ import * as fs from "node:fs/promises";
55
+ import * as path from "node:path";
56
+ import { debug } from "./debug.js";
57
+
58
+ const CUSTOM_TYPE = "pi-claudian-auto-save-markdown";
59
+ const ENV_SUBDIR = "PI_SAVE_CONVERSATION_DIR";
60
+ const DEFAULT_SUBDIR = "ai-conversations";
61
+ const COMMAND = "save-conversation";
62
+ const NOTIFY_TAG = "[AutoSave]";
63
+
64
+ const MAX_TITLE_LENGTH = 60;
65
+ const TITLE_FALLBACK_LENGTH = 40;
66
+ const TOOL_RESULT_PREVIEW = 300;
67
+ const TOOL_ARGS_PREVIEW = 160;
68
+
69
+ type AgentMessage = SessionMessageEntry["message"];
70
+ type UserMessage = Extract<AgentMessage, { role: "user" }>;
71
+ type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
72
+ type ToolResultMessage = Extract<AgentMessage, { role: "toolResult" }>;
73
+
74
+ /**
75
+ * Per-save state persisted in the session tree via pi.appendEntry().
76
+ * `file` is the bare filename inside the target directory, so changing the
77
+ * configured directory (env var) moves future saves without breaking
78
+ * resolution — the file is simply recreated from the full branch if missing.
79
+ */
80
+ interface SaveState {
81
+ branchKey: string;
82
+ lastSavedEntryId: string | null;
83
+ file: string;
84
+ }
85
+
86
+ function isSaveState(v: unknown): v is SaveState {
87
+ if (typeof v !== "object" || v === null) return false;
88
+ const s = v as Record<string, unknown>;
89
+ return (
90
+ typeof s.branchKey === "string" &&
91
+ s.branchKey.length > 0 &&
92
+ (s.lastSavedEntryId === null || typeof s.lastSavedEntryId === "string") &&
93
+ typeof s.file === "string" &&
94
+ s.file.length > 0
95
+ );
96
+ }
97
+
98
+ interface SaveResult {
99
+ message: string;
100
+ /** A file was actually written (created or appended). */
101
+ wrote: boolean;
102
+ /** The write created a brand-new file (vs appending to an existing one). */
103
+ created: boolean;
104
+ file: string | null;
105
+ }
106
+
107
+ interface BranchMeta {
108
+ title: string;
109
+ sessionId: string | null;
110
+ sessionFile: string | null;
111
+ tree: string;
112
+ model: string | null;
113
+ provider: string | null;
114
+ cost: number;
115
+ tokensInput: number;
116
+ tokensOutput: number;
117
+ messages: number;
118
+ created: string;
119
+ updated: string;
120
+ cwd: string;
121
+ }
122
+
123
+ export default function (pi: ExtensionAPI) {
124
+ /**
125
+ * Resolve the target directory. The env var may hold a relative folder name
126
+ * (resolved against the session cwd), an absolute path, or "." / "" for the
127
+ * working directory itself. When unset, the default subfolder is used.
128
+ */
129
+ function targetDir(ctx: ExtensionContext): string {
130
+ const env = process.env[ENV_SUBDIR];
131
+ if (env === undefined) return path.join(ctx.cwd, DEFAULT_SUBDIR);
132
+ const raw = env.trim();
133
+ if (raw === "" || raw === ".") return ctx.cwd;
134
+ return path.resolve(ctx.cwd, raw);
135
+ }
136
+
137
+ // ---------- formatting helpers ----------
138
+
139
+ function pad(n: number): string {
140
+ return String(n).padStart(2, "0");
141
+ }
142
+
143
+ /** Local-time filename timestamp: YYYYMMDD-HHmmss. */
144
+ function fileTimestamp(d: Date): string {
145
+ return (
146
+ `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
147
+ `-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
148
+ );
149
+ }
150
+
151
+ /** Local-time clock label for a message entry: HH:MM:SS. */
152
+ function clock(iso: string): string {
153
+ const d = new Date(iso);
154
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
155
+ }
156
+
157
+ /** Make a string safe for use as a filename component. */
158
+ function sanitizeFilenamePart(s: string): string {
159
+ return s
160
+ .replace(/[\u0000-\u001f\u007f]/g, "")
161
+ .replace(/[\\/:*?"<>|]/g, "-")
162
+ .replace(/\s+/g, " ")
163
+ .trim()
164
+ .replace(/\s/g, "-")
165
+ .replace(/-+/g, "-")
166
+ .replace(/^[-.]+|[-.]+$/g, "")
167
+ .slice(0, MAX_TITLE_LENGTH)
168
+ .replace(/[-.]+$/g, "");
169
+ }
170
+
171
+ /** Extract the plain text of a user message content (string or blocks). */
172
+ function userText(content: UserMessage["content"]): string {
173
+ if (typeof content === "string") return content;
174
+ return content
175
+ .map((b) =>
176
+ b.type === "text" ? b.text : `_[image: ${"mimeType" in b ? b.mimeType : "unknown"}]_`,
177
+ )
178
+ .join("\n\n");
179
+ }
180
+
181
+ function firstUserText(messages: SessionMessageEntry[]): string | undefined {
182
+ for (const e of messages) {
183
+ if (e.message.role === "user") return userText(e.message.content) || undefined;
184
+ }
185
+ return undefined;
186
+ }
187
+
188
+ /** Title for the filename: session name, else a slug of the first user message. */
189
+ function titleForFilename(ctx: ExtensionContext, firstUser: string | undefined): string {
190
+ const name = ctx.sessionManager.getSessionName()?.trim();
191
+ if (name) return sanitizeFilenamePart(name) || "untitled";
192
+ if (firstUser) {
193
+ const slug = sanitizeFilenamePart(firstUser.slice(0, TITLE_FALLBACK_LENGTH));
194
+ if (slug) return slug;
195
+ }
196
+ return "untitled";
197
+ }
198
+
199
+ /** Title for the frontmatter / document heading. */
200
+ function displayTitle(ctx: ExtensionContext, firstUser: string | undefined): string {
201
+ const name = ctx.sessionManager.getSessionName()?.trim();
202
+ if (name) return name;
203
+ if (firstUser) {
204
+ const snippet = firstUser.replace(/\s+/g, " ").trim().slice(0, MAX_TITLE_LENGTH);
205
+ if (snippet) return snippet;
206
+ }
207
+ return "untitled";
208
+ }
209
+
210
+ function previewArgs(args: unknown): string {
211
+ let s: string;
212
+ try {
213
+ s = JSON.stringify(args) ?? "";
214
+ } catch {
215
+ s = String(args);
216
+ }
217
+ s = s.replace(/\s+/g, " ").trim();
218
+ return s.length > TOOL_ARGS_PREVIEW ? s.slice(0, TOOL_ARGS_PREVIEW) + " …" : s;
219
+ }
220
+
221
+ // ---------- markdown rendering ----------
222
+
223
+ function renderAssistant(m: AssistantMessage, t: string): string {
224
+ const texts: string[] = [];
225
+ const thinkings: string[] = [];
226
+ const calls: string[] = [];
227
+ for (const b of m.content) {
228
+ if (b.type === "text") texts.push(b.text);
229
+ else if (b.type === "thinking") thinkings.push(b.thinking);
230
+ else if (b.type === "toolCall") calls.push(`- \`${b.name}\` — ${previewArgs(b.arguments)}`);
231
+ }
232
+
233
+ const header = `## Assistant · ${t}${m.model ? ` · ${m.model}` : ""}`;
234
+ const parts: string[] = [];
235
+ if (texts.length) parts.push(texts.join("\n\n"));
236
+ if (thinkings.length) {
237
+ parts.push(
238
+ `<details>\n<summary>Thinking</summary>\n\n${thinkings.join("\n\n")}\n\n</details>`,
239
+ );
240
+ }
241
+ if (calls.length) parts.push(`**Tool calls**\n\n${calls.join("\n")}`);
242
+ if (m.errorMessage) parts.push(`> Error: ${m.errorMessage.replace(/\s+/g, " ").trim()}`);
243
+ if (parts.length === 0) parts.push("_(empty response)_");
244
+ return `${header}\n\n${parts.join("\n\n")}`;
245
+ }
246
+
247
+ function renderToolResult(m: ToolResultMessage): string {
248
+ const texts: string[] = [];
249
+ for (const b of m.content) {
250
+ if (b.type === "text") texts.push(b.text);
251
+ else texts.push(`_[image: ${b.mimeType}]_`);
252
+ }
253
+ const flat = texts.join(" ").replace(/\s+/g, " ").trim();
254
+ const capped =
255
+ flat.length > TOOL_RESULT_PREVIEW ? flat.slice(0, TOOL_RESULT_PREVIEW) + " …" : flat;
256
+ const status = m.isError ? " (error)" : "";
257
+ const line = `> **Tool · ${m.toolName}${status}** ${capped}`.trim();
258
+ return line;
259
+ }
260
+
261
+ /** Render a chronological list of message entries as markdown blocks. */
262
+ function renderEntries(entries: SessionMessageEntry[]): string {
263
+ const blocks: string[] = [];
264
+ for (const e of entries) {
265
+ const m = e.message;
266
+ const t = clock(e.timestamp);
267
+ if (m.role === "user") {
268
+ blocks.push(`## User · ${t}\n\n${userText(m.content)}`);
269
+ } else if (m.role === "assistant") {
270
+ blocks.push(renderAssistant(m, t));
271
+ } else if (m.role === "toolResult") {
272
+ blocks.push(renderToolResult(m));
273
+ }
274
+ // Other roles (custom, bashExecution, branchSummary, compactionSummary)
275
+ // are not part of the rendered conversation record.
276
+ }
277
+ return blocks.join("\n\n");
278
+ }
279
+
280
+ // ---------- frontmatter ----------
281
+
282
+ function yamlQuote(s: string): string {
283
+ const safe = s.replace(/[\r\n]+/g, " ");
284
+ return `"${safe.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
285
+ }
286
+
287
+ function frontmatter(meta: BranchMeta): string {
288
+ const lines: string[] = ["---"];
289
+ lines.push(`title: ${yamlQuote(meta.title)}`);
290
+ if (meta.sessionId) lines.push(`session_id: ${yamlQuote(meta.sessionId)}`);
291
+ lines.push(`tree: ${yamlQuote(meta.tree)}`);
292
+ if (meta.model) lines.push(`model: ${yamlQuote(meta.model)}`);
293
+ if (meta.provider) lines.push(`provider: ${yamlQuote(meta.provider)}`);
294
+ lines.push(`cost: ${meta.cost.toFixed(6)}`);
295
+ lines.push(`tokens: ${meta.tokensInput + meta.tokensOutput}`);
296
+ lines.push(`tokens_input: ${meta.tokensInput}`);
297
+ lines.push(`tokens_output: ${meta.tokensOutput}`);
298
+ lines.push(`messages: ${meta.messages}`);
299
+ lines.push(`created: ${yamlQuote(meta.created)}`);
300
+ lines.push(`updated: ${yamlQuote(meta.updated)}`);
301
+ lines.push(`cwd: ${yamlQuote(meta.cwd)}`);
302
+ if (meta.sessionFile) lines.push(`session_file: ${yamlQuote(meta.sessionFile)}`);
303
+ lines.push("---");
304
+ return lines.join("\n");
305
+ }
306
+
307
+ /** Recover the original creation timestamp from an existing frontmatter block. */
308
+ function parseCreated(content: string): string | undefined {
309
+ const m = content.match(/^created: "(.*)"$/m);
310
+ if (!m) return undefined;
311
+ const iso = m[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\");
312
+ return Number.isNaN(Date.parse(iso)) ? undefined : iso;
313
+ }
314
+
315
+ function computeMeta(
316
+ ctx: ExtensionContext,
317
+ pathMessages: SessionMessageEntry[],
318
+ branchKey: string,
319
+ created: string | undefined,
320
+ ): BranchMeta {
321
+ let model: string | null = null;
322
+ let provider: string | null = null;
323
+ let cost = 0;
324
+ let tokensInput = 0;
325
+ let tokensOutput = 0;
326
+ for (const e of pathMessages) {
327
+ const m = e.message;
328
+ if (m.role === "assistant") {
329
+ model = m.model;
330
+ provider = m.provider;
331
+ cost += m.usage?.cost?.total ?? 0;
332
+ tokensInput += m.usage?.input ?? 0;
333
+ tokensOutput += m.usage?.output ?? 0;
334
+ } else if (m.role === "toolResult" && m.usage) {
335
+ cost += m.usage.cost?.total ?? 0;
336
+ tokensInput += m.usage.input ?? 0;
337
+ tokensOutput += m.usage.output ?? 0;
338
+ }
339
+ }
340
+ const now = new Date().toISOString();
341
+ return {
342
+ title: displayTitle(ctx, firstUserText(pathMessages)),
343
+ sessionId: ctx.sessionManager.getSessionId(),
344
+ sessionFile: ctx.sessionManager.getSessionFile() ?? null,
345
+ tree: branchKey,
346
+ model,
347
+ provider,
348
+ cost,
349
+ tokensInput,
350
+ tokensOutput,
351
+ messages: pathMessages.length,
352
+ created: created ?? now,
353
+ updated: now,
354
+ cwd: ctx.cwd,
355
+ };
356
+ }
357
+
358
+ // ---------- save planning ----------
359
+
360
+ interface SavePlan {
361
+ dir: string;
362
+ filename: string;
363
+ branchKey: string;
364
+ /** Write the full branch content (new branch, or target file missing). */
365
+ fullCreate: boolean;
366
+ /** Entries to append when continuing an existing file. */
367
+ appendEntries: SessionMessageEntry[];
368
+ /** Full root→leaf message list of the current branch (for meta/full renders). */
369
+ pathMessages: SessionMessageEntry[];
370
+ /** State of the file being continued, when not a full create. */
371
+ state: SaveState | null;
372
+ }
373
+
374
+ function isMessageEntry(e: SessionEntry): e is SessionMessageEntry {
375
+ return e.type === "message";
376
+ }
377
+
378
+ /**
379
+ * Decide which file the current branch belongs to and what to write.
380
+ *
381
+ * Every save appends a custom entry recording {branchKey, lastSavedEntryId,
382
+ * file}. Those entries live in the session tree, so a branch's own latest
383
+ * state is always recoverable — including after resume, /tree navigation,
384
+ * or /fork. The file to continue is the one whose most recent saved position
385
+ * is the deepest entry still on the current root→leaf path; when the tree
386
+ * moved (navigation + re-ask), no position matches and a new file starts.
387
+ */
388
+ function computePlan(ctx: ExtensionContext): SavePlan | null {
389
+ const pathEntries = ctx.sessionManager.getBranch();
390
+ const pathMessages = pathEntries.filter(isMessageEntry);
391
+ if (pathMessages.length === 0) return null;
392
+
393
+ const pos = new Map<string, number>();
394
+ pathEntries.forEach((e, i) => pos.set(e.id, i));
395
+
396
+ const latestForFile = new Map<string, SaveState>();
397
+ for (const e of ctx.sessionManager.getEntries()) {
398
+ if (e.type === "custom" && e.customType === CUSTOM_TYPE && isSaveState(e.data)) {
399
+ latestForFile.set(e.data.file, e.data);
400
+ }
401
+ }
402
+
403
+ let bestState: SaveState | null = null;
404
+ let bestPos = -1;
405
+ for (const st of latestForFile.values()) {
406
+ if (!st.lastSavedEntryId) continue;
407
+ const p = pos.get(st.lastSavedEntryId);
408
+ if (p === undefined) continue;
409
+ if (p > bestPos) {
410
+ bestPos = p;
411
+ bestState = st;
412
+ }
413
+ }
414
+
415
+ const dir = targetDir(ctx);
416
+ if (bestState) {
417
+ const appendEntries = pathMessages.filter((e) => (pos.get(e.id) ?? -1) > bestPos);
418
+ debug(
419
+ "continuing branch file:",
420
+ bestState.file,
421
+ "saved-up-to:",
422
+ bestState.lastSavedEntryId,
423
+ "new entries:",
424
+ appendEntries.length,
425
+ );
426
+ return {
427
+ dir,
428
+ filename: bestState.file,
429
+ branchKey: bestState.branchKey,
430
+ fullCreate: false,
431
+ appendEntries,
432
+ pathMessages,
433
+ state: bestState,
434
+ };
435
+ }
436
+
437
+ const branchKey = pathMessages[pathMessages.length - 1].id;
438
+ const title = titleForFilename(ctx, firstUserText(pathMessages));
439
+ const filename = `${title}-${branchKey}-${fileTimestamp(new Date())}.md`;
440
+ debug("new branch file:", filename, "branchKey:", branchKey);
441
+ return {
442
+ dir,
443
+ filename,
444
+ branchKey,
445
+ fullCreate: true,
446
+ appendEntries: [],
447
+ pathMessages,
448
+ state: null,
449
+ };
450
+ }
451
+
452
+ // ---------- writing ----------
453
+
454
+ async function atomicWrite(file: string, content: string): Promise<void> {
455
+ const tmp = file + ".save-tmp";
456
+ await fs.writeFile(tmp, content, "utf-8");
457
+ await fs.rename(tmp, file);
458
+ }
459
+
460
+ function replaceFrontmatter(existing: string, fm: string): string {
461
+ if (/^---\r?\n[\s\S]*?\r?\n---\r?\n/.test(existing)) {
462
+ return existing.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, fm + "\n");
463
+ }
464
+ return `${fm}\n\n${existing}`;
465
+ }
466
+
467
+ async function saveConversation(ctx: ExtensionContext, plan: SavePlan): Promise<SaveResult> {
468
+ const filePath = path.join(plan.dir, plan.filename);
469
+ let fullCreate = plan.fullCreate;
470
+ if (!fullCreate) {
471
+ const exists = await fs
472
+ .access(filePath)
473
+ .then(() => true)
474
+ .catch(() => false);
475
+ if (!exists) {
476
+ debug("target file missing — recreating from full branch:", filePath);
477
+ fullCreate = true;
478
+ }
479
+ }
480
+
481
+ await fs.mkdir(plan.dir, { recursive: true });
482
+
483
+ if (fullCreate) {
484
+ const meta = computeMeta(ctx, plan.pathMessages, plan.branchKey, undefined);
485
+ const body = renderEntries(plan.pathMessages);
486
+ const content = `${frontmatter(meta)}\n\n# ${meta.title}\n\n${body}\n`;
487
+ await atomicWrite(filePath, content);
488
+ debug("created conversation file:", filePath);
489
+ return {
490
+ message: `saved ${plan.filename} (${plan.pathMessages.length} messages)`,
491
+ wrote: true,
492
+ created: true,
493
+ file: filePath,
494
+ };
495
+ }
496
+
497
+ if (plan.appendEntries.length === 0) {
498
+ debug("nothing new since last save:", plan.filename);
499
+ return {
500
+ message: `already up to date (${plan.filename})`,
501
+ wrote: false,
502
+ created: false,
503
+ file: filePath,
504
+ };
505
+ }
506
+
507
+ const existing = await fs.readFile(filePath, "utf-8");
508
+ const meta = computeMeta(ctx, plan.pathMessages, plan.branchKey, parseCreated(existing));
509
+ const appended = renderEntries(plan.appendEntries);
510
+ let updated = replaceFrontmatter(existing, frontmatter(meta));
511
+ if (!updated.endsWith("\n")) updated += "\n";
512
+ updated += `\n${appended}\n`;
513
+ await atomicWrite(filePath, updated);
514
+ debug("appended", plan.appendEntries.length, "entries to:", filePath);
515
+ return {
516
+ message: `appended ${plan.appendEntries.length} messages to ${plan.filename}`,
517
+ wrote: true,
518
+ created: false,
519
+ file: filePath,
520
+ };
521
+ }
522
+
523
+ function recordState(plan: SavePlan, leafId: string | null): void {
524
+ pi.appendEntry(CUSTOM_TYPE, {
525
+ branchKey: plan.branchKey,
526
+ lastSavedEntryId: leafId,
527
+ file: plan.filename,
528
+ });
529
+ }
530
+
531
+ function relativeForUser(ctx: ExtensionContext, file: string): string {
532
+ const rel = path.relative(ctx.cwd, file);
533
+ return rel && !rel.startsWith("..") ? rel : file;
534
+ }
535
+
536
+ // Serialize saves: agent_settled and the manual command must not interleave.
537
+ let chain: Promise<unknown> = Promise.resolve();
538
+ function schedule<T>(fn: () => Promise<T>): Promise<T> {
539
+ const run = chain.then(fn, fn);
540
+ chain = run.then(
541
+ () => undefined,
542
+ () => undefined,
543
+ );
544
+ return run;
545
+ }
546
+
547
+ /** Full save cycle: write the file, then persist the branch state entry. */
548
+ async function runSave(ctx: ExtensionContext): Promise<SaveResult> {
549
+ const plan = computePlan(ctx);
550
+ if (!plan) {
551
+ return {
552
+ message: "no conversation content to save yet",
553
+ wrote: false,
554
+ created: false,
555
+ file: null,
556
+ };
557
+ }
558
+ const result = await saveConversation(ctx, plan);
559
+ if (result.wrote) {
560
+ const leafId = ctx.sessionManager.getLeafId();
561
+ recordState(plan, leafId);
562
+ debug("recorded state entry — branchKey:", plan.branchKey, "leaf:", leafId);
563
+ }
564
+ return result;
565
+ }
566
+
567
+ // 1. Automatic: save after every settled agent turn.
568
+ pi.on("agent_settled", async (_event: AgentSettledEvent, ctx: ExtensionContext) => {
569
+ debug("agent_settled — saving conversation");
570
+ try {
571
+ const r = await schedule(() => runSave(ctx));
572
+ if (r.wrote && r.created && ctx.hasUI && r.file) {
573
+ ctx.ui.notify(`${NOTIFY_TAG} ${relativeForUser(ctx, r.file)}`, "info");
574
+ }
575
+ } catch (e) {
576
+ debug("auto-save failed:", String(e));
577
+ if (ctx.hasUI) ctx.ui.notify(`${NOTIFY_TAG} save failed: ${String(e)}`, "error");
578
+ }
579
+ });
580
+
581
+ // 2. Manual: force a save now and report where it went.
582
+ pi.registerCommand(COMMAND, {
583
+ description:
584
+ "Save the current conversation branch to a markdown file now (auto-save-to-markdown)",
585
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
586
+ try {
587
+ debug("manual /" + COMMAND + " invoked");
588
+ const r = await schedule(() => runSave(ctx));
589
+ if (ctx.hasUI) {
590
+ const target = r.file ? relativeForUser(ctx, r.file) : "";
591
+ ctx.ui.notify(`${NOTIFY_TAG} ${r.message}${target ? ` → ${target}` : ""}`, "info");
592
+ }
593
+ } catch (e) {
594
+ debug("/" + COMMAND + " failed:", String(e));
595
+ if (ctx.hasUI) ctx.ui.notify(`${NOTIFY_TAG} save failed: ${String(e)}`, "error");
596
+ }
597
+ },
598
+ });
599
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@pi-claudian/auto-save-to-markdown",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension that automatically saves each completed conversation turn as a markdown file with YAML frontmatter, one file per session-tree branch.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": {
8
+ "name": "Licong Yang",
9
+ "email": "licong.yang@icloud.com",
10
+ "url": "https://github.com/licongy"
11
+ },
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/licongy/pi-claudian.git",
18
+ "directory": "packages/auto-save-to-markdown"
19
+ },
20
+ "homepage": "https://github.com/licongy/pi-claudian/tree/master/packages/auto-save-to-markdown#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/licongy/pi-claudian/issues"
23
+ },
24
+ "keywords": [
25
+ "pi",
26
+ "pi-package",
27
+ "pi-extension",
28
+ "pi-coding-agent",
29
+ "pi-claudian",
30
+ "markdown",
31
+ "conversation",
32
+ "session",
33
+ "export",
34
+ "auto-save",
35
+ "archive"
36
+ ],
37
+ "pi": {
38
+ "extensions": [
39
+ "./index.ts"
40
+ ]
41
+ },
42
+ "files": [
43
+ "index.ts",
44
+ "debug.ts",
45
+ "README.md",
46
+ "README.zh.md"
47
+ ],
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "peerDependencies": {
52
+ "@earendil-works/pi-coding-agent": ">=0.82.0"
53
+ },
54
+ "devDependencies": {
55
+ "@earendil-works/pi-coding-agent": "^0.82.1",
56
+ "@types/node": "^22.10.0"
57
+ },
58
+ "scripts": {
59
+ "typecheck": "tsc --noEmit"
60
+ }
61
+ }