pi-auto-save-to-markdown 0.8.1 → 0.9.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/README.md CHANGED
@@ -28,6 +28,10 @@ pi install npm:pi-auto-save-to-markdown
28
28
  Automatic: after every settled agent turn (`agent_settled`), the current
29
29
  conversation branch is written to `<cwd>/<folder>/<title>-<key>-<time>.md`.
30
30
 
31
+ Sessions Pi does not persist (in-memory, `--no-session` — including the
32
+ ephemeral auxiliary agents host clients spawn next to the real conversation,
33
+ such as Claudian's title generation) are skipped by every automatic save; only
34
+ the explicit `/save-conversation` command archives such a session on demand.
31
35
  Manual: run `/save-conversation` to save the current branch immediately and
32
36
  report the file path.
33
37
 
@@ -88,11 +92,11 @@ original creation timestamp — and rewrites the frontmatter title and the
88
92
  document heading to match. The rename happens at most once: later `/name`
89
93
  changes never touch the filename, and manually renamed files are left alone.
90
94
 
91
- ```markdown
95
+ ````markdown
92
96
  ---
93
97
  title: "Fix login redirect loop"
94
98
  agent: "pi"
95
- format_version: "1.5"
99
+ format_version: "1.6"
96
100
  session_id: "d0a4f541-976d-4d1b-8e1c-30a1f2b3c4d5"
97
101
  session_key: "c2088d77"
98
102
  branch_last_entry_id: "019be3a2-1f4d-7c8a-9b01-d23e45f6a7b8"
@@ -137,21 +141,27 @@ I'll trace the middleware order first.
137
141
  > [!quote]- Tool Calls · 1 (read)
138
142
  > **`read`** `{"filePath":"/Users/me/project/src/auth/middleware.ts"}`
139
143
  >
140
- > > `import { NextResponse } from "next/server"; export function middleware(…) …`
144
+ > ```
145
+ > import { NextResponse } from "next/server";
146
+ > export function middleware(…) …
147
+ > ```
141
148
 
142
149
  ---
143
- ```
150
+ ````
144
151
 
145
152
  The body renders user and assistant messages in full (assistant thinking and
146
153
  per-turn tool calls are each folded into a collapsed Obsidian callout —
147
- `> [!tldr]- Thinking` and `> [!quote]- Tool Calls · …`) and summarizes each
148
- tool call and result in one line, so the file stays readable while still
149
- showing what the agent did. Callouts are used instead of HTML `<details>`
150
- because Obsidian renders markdown inside HTML blocks unreliably; outside
151
- Obsidian the callouts degrade to plain blockquotes. Result and argument
152
- previews are wrapped in inline code spans (with a delimiter sized to survive
153
- backticks inside the content), so raw tool output renders literally instead of
154
- being parsed as markdown.
154
+ `> [!tldr]- Thinking` and `> [!quote]- Tool Calls · …`), recording every tool
155
+ call with its full raw result: the file is a documentary record that may be
156
+ @-referenced back into a conversation, and a truncated half-result would be
157
+ wasted when the tool is called again and misleading when it is not, while
158
+ local reading (grep, ranged reads) makes size a non-issue. Callouts are used
159
+ instead of HTML `<details>` because Obsidian renders markdown inside HTML
160
+ blocks unreliably; outside Obsidian the callouts degrade to plain blockquotes.
161
+ Arguments render as full JSON in inline code spans and results verbatim —
162
+ whitespace intact, nothing capped — in fenced code blocks (with a delimiter
163
+ sized to survive backticks inside the content), so raw tool output renders
164
+ literally instead of being parsed as markdown.
155
165
 
156
166
  Prompt blocks the client or the agent injects into a user message — the
157
167
  editor's active selection, attached or referenced notes, loaded skills — are
package/README.zh.md CHANGED
@@ -21,6 +21,10 @@ pi install npm:pi-auto-save-to-markdown
21
21
 
22
22
  自动:每个 agent 轮次完全结束(`agent_settled`,含自动重试与压缩全部完成)后,当前对话分支写入 `<cwd>/<文件夹>/<标题>-<key>-<时间>.md`。
23
23
 
24
+ Pi 未持久化的会话(内存态、`--no-session`——包括宿主客户端在真实对话旁派生的临时辅助
25
+ agent,例如 Claudian 的标题生成)会被所有自动保存路径跳过;只有显式的
26
+ `/save-conversation` 命令才会按需归档此类会话。
27
+
24
28
  手动:运行 `/save-conversation` 立即保存当前分支并显示文件路径。
25
29
 
26
30
  批量:运行 `/save-conversation-all` 保存**当前项目的全部 session**——即项目的
@@ -62,11 +66,11 @@ PI_SAVE_CONVERSATION_DIR=notes/ai pi
62
66
 
63
67
  会话的真实名称在建文件之后才到达时(如 Claudian 在首轮回复后才生成标题),下一次保存会把文件一次性改名为 `<名称>-<key>-<原时间戳>.md`(保留原创建时间戳),并同步改写 frontmatter 标题与正文标题。改名至多发生一次:之后的 `/name` 改名不再影响文件名,手动整理过的文件名也不会被动。
64
68
 
65
- ```markdown
69
+ ````markdown
66
70
  ---
67
71
  title: "修复登录重定向死循环"
68
72
  agent: "pi"
69
- format_version: "1.5"
73
+ format_version: "1.6"
70
74
  session_id: "d0a4f541-976d-4d1b-8e1c-30a1f2b3c4d5"
71
75
  session_key: "c2088d77"
72
76
  branch_last_entry_id: "019be3a2-1f4d-7c8a-9b01-d23e45f6a7b8"
@@ -111,19 +115,24 @@ Assistant <span style="font-size: 0.5em; color: var(--text-faint);">2026-08-29 1
111
115
  > [!quote]- Tool Calls · 1 (read)
112
116
  > **`read`** `{"filePath":"/Users/me/project/src/auth/middleware.ts"}`
113
117
  >
114
- > > `import { NextResponse } from "next/server"; export function middleware(…) …`
118
+ > ```
119
+ > import { NextResponse } from "next/server";
120
+ > export function middleware(…) …
121
+ > ```
115
122
 
116
123
  ---
117
- ```
124
+ ````
118
125
 
119
126
  正文完整渲染 user / assistant 消息(assistant 的 thinking 与每轮工具调用
120
127
  分别折叠在可折叠的 Obsidian callout 中——`> [!tldr]- Thinking` 和
121
- `> [!quote]- Tool Calls · …`),每个工具调用和结果各压缩成一行摘要,既可读
122
- 又能看出 agent 做了什么。之所以用 callout 而不是 HTML `<details>`,是因为
123
- Obsidian 对 HTML 块内嵌 Markdown 的渲染不可靠;在非 Obsidian 环境下 callout
124
- 退化为普通引用块。结果与参数预览会包在 inline code 里(分隔符长度会自动
125
- 压过内容中的反引号序列),工具的原始输出因此按字面渲染,不会被当作
126
- Markdown 解析。
128
+ `> [!quote]- Tool Calls · …`),每次工具调用连同其完整原始结果一起记录:
129
+ 归档文件是可能被 @ 引回对话的史料,截断的半个结果在工具重调时是浪费、在
130
+ 不再调用时是误导,而局部阅读(grep、按行段读取)让体积不成问题。之所以用
131
+ callout 而不是 HTML `<details>`,是因为 Obsidian 对 HTML 块内嵌 Markdown 的
132
+ 渲染不可靠;在非 Obsidian 环境下 callout 退化为普通引用块。参数以完整 JSON
133
+ 包在 inline code 里,结果逐字保真——空白原样、不截断——放在 fenced code
134
+ block 中(分隔符长度会自动压过内容中的反引号序列),工具的原始输出因此按
135
+ 字面渲染,不会被当作 Markdown 解析。
127
136
 
128
137
  客户端或 agent 注入到用户消息中的提示块——编辑器当前选区、附加或引用的
129
138
  笔记、加载的 skill——会从原始 XML(Obsidian 无法渲染,只会显示成裸露的
package/index.ts CHANGED
@@ -9,6 +9,15 @@
9
9
  * - Trigger: `agent_settled` — fires once per user prompt, after the turn is
10
10
  * fully done (including automatic retries and compaction), so each save
11
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).
12
21
  * - Location: a subfolder of the session's working directory (`ctx.cwd`, the
13
22
  * directory the session was started in), defaulting to `ai-conversations`.
14
23
  * Override with the PI_SAVE_CONVERSATION_DIR environment variable; set it to
@@ -42,18 +51,22 @@
42
51
  * wrapped in single blank lines.
43
52
  * - Tool call/result folding: calls live in the assistant entry while their
44
53
  * results are separate toolResult entries; saves pair them by toolCall id
45
- * and fold each assistant block's calls, with a short result preview each,
46
- * into one collapsed Obsidian callout (`> [!quote]- Tool Calls · …`).
47
- * Thinking folds the same way into `> [!tldr]- Thinking`. Callouts are used
48
- * instead of HTML `<details>` because Obsidian's views render embedded
49
- * markdown inside HTML blocks unreliably, while callouts fold and render
50
- * markdown in both Live Preview and Reading view. Outside Obsidian the
51
- * callouts degrade to plain blockquotes. Argument previews and result
52
- * previews are wrapped in inline code spans (delimiter sized to survive
53
- * backticks inside the content), so raw output renders literally instead
54
- * of being parsed as markdown. A result whose call was saved in
55
- * an earlier file (mid-turn manual save) falls back to a standalone
56
- * one-line block.
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.
57
70
  * - Injected prompt blocks: the host client and the agent runtime append
58
71
  * machine-readable XML to user messages — the editor's active selection
59
72
  * (CDATA content), note references and attachments (linked_note /
@@ -185,7 +198,13 @@ import * as fsSync from "node:fs";
185
198
  import * as os from "node:os";
186
199
  import * as path from "node:path";
187
200
  import { debug } from "./debug.js";
188
- import { callout, inlineCode, renderUserMessageText, stripInjectedBlocks } from "./markdown.js";
201
+ import {
202
+ callout,
203
+ fencedCode,
204
+ inlineCode,
205
+ renderUserMessageText,
206
+ stripInjectedBlocks,
207
+ } from "./markdown.js";
189
208
 
190
209
  const CUSTOM_TYPE = "pi-claudian-auto-save-markdown";
191
210
  const ENV_SUBDIR = "PI_SAVE_CONVERSATION_DIR";
@@ -225,7 +244,7 @@ const SAVE_STATE_SCHEMA = "1.2";
225
244
  * the frontmatter and the document heading); additive frontmatter fields do
226
245
  * NOT bump it — they are invisible to any within-major parser.
227
246
  */
228
- const FORMAT_VERSION = "1.5";
247
+ const FORMAT_VERSION = "1.6";
229
248
 
230
249
  /**
231
250
  * Package version of this extension, read best-effort from the adjacent
@@ -246,9 +265,6 @@ const EXTENSION_VERSION: string | null = (() => {
246
265
 
247
266
  const MAX_TITLE_LENGTH = 60;
248
267
  const TITLE_FALLBACK_LENGTH = 40;
249
- const TOOL_RESULT_PREVIEW = 500;
250
- const TOOL_ARGS_PREVIEW = 160;
251
-
252
268
  type AgentMessage = SessionMessageEntry["message"];
253
269
  type UserMessage = Extract<AgentMessage, { role: "user" }>;
254
270
  type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
@@ -950,15 +966,13 @@ export default function (pi: ExtensionAPI) {
950
966
  return "untitled";
951
967
  }
952
968
 
953
- function previewArgs(args: unknown): string {
954
- let s: string;
969
+ /** Full argument JSON on one line (JSON.stringify escapes newlines), never truncated. */
970
+ function renderArgs(args: unknown): string {
955
971
  try {
956
- s = JSON.stringify(args) ?? "";
972
+ return JSON.stringify(args) ?? "";
957
973
  } catch {
958
- s = String(args);
974
+ return String(args);
959
975
  }
960
- s = s.replace(/\s+/g, " ").trim();
961
- return s.length > TOOL_ARGS_PREVIEW ? s.slice(0, TOOL_ARGS_PREVIEW) + " …" : s;
962
976
  }
963
977
 
964
978
  // ---------- markdown rendering ----------
@@ -972,30 +986,37 @@ export default function (pi: ExtensionAPI) {
972
986
  return s.replace(/^(?:[ \t]*\n)+/, "").replace(/\s+$/, "");
973
987
  }
974
988
 
975
- /** One tool call with its paired result preview (null when no result entry exists). */
989
+ /** One tool call with its paired full result (null when no result entry exists). */
976
990
  interface RenderedToolCall {
977
991
  name: string;
978
992
  args: string;
979
993
  result: string | null;
994
+ error: boolean;
980
995
  }
981
996
 
982
- /** Flattened, length-capped result preview with error status suffix. */
983
- function resultPreview(m: ToolResultMessage): string {
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 {
984
1004
  const texts: string[] = [];
985
1005
  for (const b of m.content) {
986
1006
  if (b.type === "text") texts.push(b.text);
987
1007
  else texts.push(`_[image: ${b.mimeType}]_`);
988
1008
  }
989
- const flat = texts.join(" ").replace(/\s+/g, " ").trim();
990
- const capped =
991
- flat.length > TOOL_RESULT_PREVIEW ? flat.slice(0, TOOL_RESULT_PREVIEW) + " …" : flat;
992
- const status = m.isError ? " (error)" : "";
993
- return `${capped}${status}`.trim();
1009
+ return texts.join("\n\n").trim();
994
1010
  }
995
1011
 
996
- /** Standalone one-line block for a result whose call is not in this file. */
1012
+ /**
1013
+ * Standalone block for a result whose call is not in this file — same full
1014
+ * content as folded results.
1015
+ */
997
1016
  function renderToolResult(m: ToolResultMessage): string {
998
- return `> **Tool · ${m.toolName}** ${inlineCode(resultPreview(m))}`.trim();
1017
+ const text = resultText(m);
1018
+ const err = m.isError ? " (error)" : "";
1019
+ return `**Tool · ${m.toolName}**${err}\n\n${text ? fencedCode(text) : "_(empty result)_"}`;
999
1020
  }
1000
1021
 
1001
1022
  /** "read, web_search ×2" — tool names with repeat counts, first-seen order. */
@@ -1007,16 +1028,20 @@ export default function (pi: ExtensionAPI) {
1007
1028
 
1008
1029
  /**
1009
1030
  * Fold tool calls and their paired results into one collapsed callout.
1010
- * Argument JSON and result previews are wrapped in inline code spans, so
1011
- * raw output renders literally instead of being parsed as markdown.
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.
1012
1034
  */
1013
1035
  function renderToolCallsCallout(calls: RenderedToolCall[]): string {
1014
1036
  const summary = summarizeToolNames(calls.map((c) => c.name));
1015
1037
  const items = calls.map((c) => {
1016
- const head = c.args ? `**\`${c.name}\`** ${inlineCode(c.args)}` : `**\`${c.name}\`**`;
1038
+ const err = c.error ? " (error)" : "";
1039
+ const head = c.args
1040
+ ? `**\`${c.name}\`**${err} ${inlineCode(c.args)}`
1041
+ : `**\`${c.name}\`**${err}`;
1017
1042
  const result =
1018
- c.result === null ? "_(no result)_" : inlineCode(c.result || "_(empty result)_");
1019
- return `${head}\n\n> ${result}`;
1043
+ c.result === null ? "_(no result)_" : c.result ? fencedCode(c.result) : "_(empty result)_";
1044
+ return `${head}\n\n${result}`;
1020
1045
  });
1021
1046
  return callout("quote", `Tool Calls · ${calls.length} (${summary})`, items.join("\n\n"));
1022
1047
  }
@@ -1052,8 +1077,9 @@ export default function (pi: ExtensionAPI) {
1052
1077
  results.delete(b.id);
1053
1078
  calls.push({
1054
1079
  name: b.name,
1055
- args: previewArgs(b.arguments),
1056
- result: r ? resultPreview(r) : null,
1080
+ args: renderArgs(b.arguments),
1081
+ result: r ? resultText(r) : null,
1082
+ error: r ? r.isError : false,
1057
1083
  });
1058
1084
  }
1059
1085
  }
@@ -1925,6 +1951,15 @@ export default function (pi: ExtensionAPI) {
1925
1951
 
1926
1952
  // 1. Automatic: save after every settled agent turn.
1927
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
+ }
1928
1963
  debug("agent_settled — saving conversation");
1929
1964
  try {
1930
1965
  const live = liveContext(ctx);
@@ -2005,28 +2040,36 @@ export default function (pi: ExtensionAPI) {
2005
2040
 
2006
2041
  // The live session first, through the normal path: its in-memory tree may
2007
2042
  // be fresher than disk, and its state entry goes through pi.appendEntry.
2008
- try {
2009
- const r = await runSave(live);
2010
- count(r);
2011
- if (ctx.hasUI) {
2012
- const target = r.file ? relativeForUser(live, r.file) : "";
2013
- ctx.ui.notify(`${NOTIFY_TAG} ${r.message}${target ? ` → ${target}` : ""}`, "info");
2014
- if (r.recovered && r.file) {
2015
- ctx.ui.notify(`${NOTIFY_TAG} ${r.recovered} → ${target}`, "warning");
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
+ }
2016
2065
  }
2017
- if (r.switchedFrom && r.file) {
2018
- ctx.ui.notify(
2019
- `${NOTIFY_TAG} branch changed — new branch file ${target}; the earlier branch file ${r.switchedFrom} is kept`,
2020
- "info",
2021
- );
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");
2022
2071
  }
2023
2072
  }
2024
- } catch (e) {
2025
- failed.push("<current session>");
2026
- debug("/" + COMMAND_ALL + " live save failed:", String(e));
2027
- if (ctx.hasUI) {
2028
- ctx.ui.notify(`${NOTIFY_TAG} save failed: ${String(e)}`, "error");
2029
- }
2030
2073
  }
2031
2074
 
2032
2075
  // The project's sessions directory — every session jsonl lives there.
package/markdown.ts CHANGED
@@ -27,6 +27,19 @@ export function inlineCode(text: string): string {
27
27
  return `${fence}${text}${fence}`;
28
28
  }
29
29
 
30
+ /**
31
+ * Fenced code block for arbitrary raw output (full tool results): the fence
32
+ * is always one backtick longer than the longest backtick run inside the
33
+ * text (and at least three), so content that itself contains backticks
34
+ * cannot break out of the block. Tool output renders literally, whitespace
35
+ * intact, instead of being parsed as markdown.
36
+ */
37
+ export function fencedCode(text: string): string {
38
+ const longest = (text.match(/`+/g) ?? []).reduce((a, r) => Math.max(a, r.length), 0);
39
+ const fence = "`".repeat(Math.max(3, longest + 1));
40
+ return `${fence}\n${text}\n${fence}`;
41
+ }
42
+
30
43
  /**
31
44
  * Obsidian callout (`> [!type]- title`) wrapping a markdown body: every body
32
45
  * line is prefixed with `>` (empty lines become bare `>`), so the body keeps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-auto-save-to-markdown",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
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
5
  "type": "module",
6
6
  "license": "MIT",