pi-auto-save-to-markdown 0.8.0 → 0.8.2

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
 
@@ -92,7 +96,7 @@ changes never touch the filename, and manually renamed files are left alone.
92
96
  ---
93
97
  title: "Fix login redirect loop"
94
98
  agent: "pi"
95
- format_version: "1.4"
99
+ format_version: "1.5"
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"
@@ -119,7 +123,7 @@ User <span style="font-size: 0.5em; color: var(--text-faint);">2026-08-29 13:05:
119
123
  The login page redirects in a loop after the auth refactor...
120
124
 
121
125
  > [!quote]- Editor Selection
122
- > **path**: [[src/auth/middleware.ts|middleware.ts]] · **lines**: `14-22`
126
+ > [[src/auth/middleware.ts|middleware.ts]] · **lines**: `14-22`
123
127
  >
124
128
  > export function middleware(request) { … }
125
129
 
@@ -158,14 +162,21 @@ editor's active selection, attached or referenced notes, loaded skills — are
158
162
  re-rendered from their raw XML (which Obsidian cannot render and would show
159
163
  as literal angle-bracket text) into generic callouts. No block is parsed
160
164
  individually: the title is the tag name in words (`editor_selection` →
161
- Editor Selection), attribute pairs open the body as `**name**: value` lines
162
- (`path`/`location` values shaped like vault-relative note paths become
163
- Obsidian wikilinks), and the content follows, quoted in a collapsed callout.
164
- User-provided blocks (selections, note attachments) stay visible;
165
- agent-side traces (skills) render as a one-line marker with the content
166
- dropped. Unknown markup is left verbatim, so XML pasted as content is never
167
- mangled — and the fallback filename slug derives from the typed message
168
- with every known block stripped.
165
+ Editor Selection), and the body opens with vault-shaped `path`/`location`
166
+ values as bare Obsidian wikilinks (the aliased filename speaks for itself —
167
+ no `path:` label), followed by the remaining attributes as
168
+ `**name**: value` items, then the content. Every callout is preset-collapsed
169
+ — user-provided blocks (selections, note attachments) as `> [!quote]-`, even
170
+ when they carry only attributes (the client emits note references as
171
+ self-closing tags carrying just a path), agent-side skill traces as a
172
+ `> [!note]- Skill · <name>` marker (the loaded skill's name rides the title,
173
+ so the collapsed marker still says which skill; the location follows in the
174
+ body, the content is dropped) — and consecutive blocks of
175
+ the same tag (nothing but whitespace between them) merge into one callout,
176
+ so a run of note references collapses into a single list. Unknown markup is
177
+ left verbatim, so XML pasted as content is never mangled — and the fallback
178
+ filename slug derives from the typed message with every known block
179
+ stripped.
169
180
 
170
181
  Each message block opens with a setext level-1 info header (`User`,
171
182
  `Assistant`) underlined with `===` — one level above the `##` headings AI
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**——即项目的
@@ -66,7 +70,7 @@ PI_SAVE_CONVERSATION_DIR=notes/ai pi
66
70
  ---
67
71
  title: "修复登录重定向死循环"
68
72
  agent: "pi"
69
- format_version: "1.4"
73
+ format_version: "1.5"
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"
@@ -93,7 +97,7 @@ User <span style="font-size: 0.5em; color: var(--text-faint);">2026-08-29 13:05:
93
97
  auth 重构之后登录页一直重定向死循环……
94
98
 
95
99
  > [!quote]- Editor Selection
96
- > **path**: [[src/auth/middleware.ts|middleware.ts]] · **lines**: `14-22`
100
+ > [[src/auth/middleware.ts|middleware.ts]] · **lines**: `14-22`
97
101
  >
98
102
  > export function middleware(request) { … }
99
103
 
@@ -128,12 +132,17 @@ Markdown 解析。
128
132
  客户端或 agent 注入到用户消息中的提示块——编辑器当前选区、附加或引用的
129
133
  笔记、加载的 skill——会从原始 XML(Obsidian 无法渲染,只会显示成裸露的
130
134
  尖括号文本)重新渲染为通用 callout。不做任何逐块解析:标题取标记名的分词
131
- (`editor_selection` → Editor Selection),属性对以 `**属性**: 值` 行开头
132
- (形似 vault 相对笔记路径的 `path`/`location` 值会转为 Obsidian wikilink),
133
- 随后是引用内容,折叠在 callout 中。用户提供的块(选区、笔记附件)保持
134
- 可见;agent 侧痕迹(skill)渲染为单行标记,内容不再展示。未知标记原样
135
- 保留,用户粘贴的 XML 内容绝不会被误改;回退文件名 slug 也从剥离全部
136
- 已知块后的纯键入文本推导。
135
+ (`editor_selection` → Editor Selection),正文以形似 vault 相对笔记路径的
136
+ `path`/`location` 值开头——直接渲染为不带标签的 Obsidian wikilink(别名
137
+ 文件名自解释,`path:` 标签反而冗余),其余属性以 `**属性**: 值` 跟随,
138
+ 最后是引用内容。所有 callout 一律预设折叠——用户提供的块(选区、笔记附件)
139
+ 为 `> [!quote]-`(仅有属性的自闭合笔记引用同样折叠),agent 侧痕迹(skill)
140
+ 为 `> [!note]- Skill · <名称>` 标记(加载的 skill 名称直接进标题,折叠状态
141
+ 也能看到是哪个 skill;location 跟在正文,内容丢弃);连续的同标签块(中间
142
+ 只有空白)合并进同一个 callout,一串笔记引用因此收拢为一份列表(skill 标记
143
+ 不合并:各自标注各自的 skill)。未知标记原样保留,用户粘贴的
144
+ XML 内容绝不会被误改;回退文件名 slug 也从剥离全部已知块后的纯键入文本
145
+ 推导。
137
146
 
138
147
  每个消息块以 setext 一级信息头(`User`、`Assistant`,下一行以 `===` 下划)
139
148
  开头——高于 AI 内容常见的 `##` 二级标题,解析时也能与内容中的 `#` 一级标题
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
@@ -61,12 +70,21 @@
61
70
  * Obsidian cannot render (unknown tags are not HTML; CDATA is XML), so
62
71
  * every block in a known vocabulary is re-rendered generically (see
63
72
  * markdown.ts) — no per-tag formatting: the callout title is the tag name
64
- * in words, attribute pairs open the body as "**name**: value" lines
65
- * (path/location values shaped like vault-relative note paths become
66
- * wikilinks), and the content follows (the client's `]]>` split-escaping
67
- * reversed). User-provided blocks (selections, attachments) quote their
68
- * content in a collapsed `> [!quote]`; agent-side traces (skills) render
69
- * as a one-line `> [!note]` marker with the content dropped. Title
73
+ * in words, the body opens with vault-shaped path/location values as
74
+ * bare wikilinks (the aliased filename is self-explanatory — a `path:`
75
+ * label is noise) followed by the remaining attributes as
76
+ * "**name**: value" lines, then the content (the client's `]]>`
77
+ * split-escaping reversed). Every callout is preset-collapsed: visible
78
+ * blocks (selections, note references, attachments) as `> [!quote]-`,
79
+ * whether they carry content or only attributes (the client emits note
80
+ * references as self-closing tags whose whole payload is a path
81
+ * attribute), agent-side traces (skills) as a `> [!note]- Skill · <name>`
82
+ * marker — the loaded skill's name rides the title so the collapsed
83
+ * marker still says which skill, the location follows in the body, the
84
+ * content is dropped — and consecutive visible same-tag blocks (nothing
85
+ * but whitespace between them) merge into one callout, so a run of note
86
+ * references collapses into a single list (skill markers never merge:
87
+ * each names its own skill). Title
70
88
  * derivation strips every known block — the typed message is the title.
71
89
  * Unknown markup is left verbatim so XML pasted as content is never
72
90
  * mangled.
@@ -216,7 +234,7 @@ const SAVE_STATE_SCHEMA = "1.2";
216
234
  * the frontmatter and the document heading); additive frontmatter fields do
217
235
  * NOT bump it — they are invisible to any within-major parser.
218
236
  */
219
- const FORMAT_VERSION = "1.4";
237
+ const FORMAT_VERSION = "1.5";
220
238
 
221
239
  /**
222
240
  * Package version of this extension, read best-effort from the adjacent
@@ -1916,6 +1934,15 @@ export default function (pi: ExtensionAPI) {
1916
1934
 
1917
1935
  // 1. Automatic: save after every settled agent turn.
1918
1936
  pi.on("agent_settled", async (_event: AgentSettledEvent, ctx: ExtensionContext) => {
1937
+ // Non-persisted sessions (in-memory, `--no-session`) are ephemeral
1938
+ // auxiliary agents — see the skip rule in the header. The manual
1939
+ // /save-conversation command below is the explicit-demand escape hatch.
1940
+ if (!ctx.sessionManager.getSessionFile()) {
1941
+ debug(
1942
+ "agent_settled — session has no session file (in-memory / --no-session); skipping auto-save",
1943
+ );
1944
+ return;
1945
+ }
1919
1946
  debug("agent_settled — saving conversation");
1920
1947
  try {
1921
1948
  const live = liveContext(ctx);
@@ -1996,28 +2023,36 @@ export default function (pi: ExtensionAPI) {
1996
2023
 
1997
2024
  // The live session first, through the normal path: its in-memory tree may
1998
2025
  // be fresher than disk, and its state entry goes through pi.appendEntry.
1999
- try {
2000
- const r = await runSave(live);
2001
- count(r);
2002
- if (ctx.hasUI) {
2003
- const target = r.file ? relativeForUser(live, r.file) : "";
2004
- ctx.ui.notify(`${NOTIFY_TAG} ${r.message}${target ? ` → ${target}` : ""}`, "info");
2005
- if (r.recovered && r.file) {
2006
- ctx.ui.notify(`${NOTIFY_TAG} ${r.recovered} → ${target}`, "warning");
2026
+ // A non-persisted current session (in-memory / --no-session) is skipped —
2027
+ // see the skip rule in the header.
2028
+ if (!ctx.sessionManager.getSessionFile()) {
2029
+ debug(
2030
+ "/" + COMMAND_ALL + " — current session not persisted (in-memory / --no-session); skipped",
2031
+ );
2032
+ } else {
2033
+ try {
2034
+ const r = await runSave(live);
2035
+ count(r);
2036
+ if (ctx.hasUI) {
2037
+ const target = r.file ? relativeForUser(live, r.file) : "";
2038
+ ctx.ui.notify(`${NOTIFY_TAG} ${r.message}${target ? ` → ${target}` : ""}`, "info");
2039
+ if (r.recovered && r.file) {
2040
+ ctx.ui.notify(`${NOTIFY_TAG} ${r.recovered} → ${target}`, "warning");
2041
+ }
2042
+ if (r.switchedFrom && r.file) {
2043
+ ctx.ui.notify(
2044
+ `${NOTIFY_TAG} branch changed — new branch file ${target}; the earlier branch file ${r.switchedFrom} is kept`,
2045
+ "info",
2046
+ );
2047
+ }
2007
2048
  }
2008
- if (r.switchedFrom && r.file) {
2009
- ctx.ui.notify(
2010
- `${NOTIFY_TAG} branch changed — new branch file ${target}; the earlier branch file ${r.switchedFrom} is kept`,
2011
- "info",
2012
- );
2049
+ } catch (e) {
2050
+ failed.push("<current session>");
2051
+ debug("/" + COMMAND_ALL + " live save failed:", String(e));
2052
+ if (ctx.hasUI) {
2053
+ ctx.ui.notify(`${NOTIFY_TAG} save failed: ${String(e)}`, "error");
2013
2054
  }
2014
2055
  }
2015
- } catch (e) {
2016
- failed.push("<current session>");
2017
- debug("/" + COMMAND_ALL + " live save failed:", String(e));
2018
- if (ctx.hasUI) {
2019
- ctx.ui.notify(`${NOTIFY_TAG} save failed: ${String(e)}`, "error");
2020
- }
2021
2056
  }
2022
2057
 
2023
2058
  // The project's sessions directory — every session jsonl lives there.
package/markdown.ts CHANGED
@@ -54,8 +54,11 @@ export function callout(type: string, title: string, body: string, fold = true):
54
54
  * references (linked_note / linked_content — the client's attachment
55
55
  * mechanism; a typed @-mention stays as plain text in the message, these
56
56
  * are the machine copy).
57
- * - Hidden: agent-side traces (loaded skills) — rendered as a one-line
58
- * marker, content dropped (a skill is re-loadable from its location).
57
+ * - Hidden: agent-side traces (loaded skills) — rendered as a folded
58
+ * `> [!note]- Skill · <name>` marker with the name riding the title
59
+ * (which skill was loaded is the marker's whole meaning and must survive
60
+ * the collapsed view), the content dropped (a skill is re-loadable from
61
+ * its location).
59
62
  *
60
63
  * Unknown tags are left verbatim: this is both the safety boundary against
61
64
  * mangling pasted XML and a soft failure mode for future client tags (they
@@ -151,60 +154,105 @@ function wikilinkOf(path: string): string {
151
154
  return base && base !== path ? `[[${path}|${base}]]` : `[[${path}]]`;
152
155
  }
153
156
 
157
+ /** Whether an attribute renders as a bare wikilink (a vault-shaped path/location). */
158
+ function isWikilinkAttr(p: { name: string; value: string }): boolean {
159
+ return (p.name === "path" || p.name === "location") && isVaultNotePath(p.value);
160
+ }
161
+
154
162
  /**
155
- * One attribute value for the callout body: path/location values that look
156
- * like vault-relative note paths become clickable wikilinks (the archives
157
- * live in the same vault; outside Obsidian they degrade to readable bracket
158
- * text, the same degradation callouts already accept); everything else is
159
- * an inline code span.
163
+ * Attribute pairs of one injected block as body items: a `path`/`location`
164
+ * value shaped like a vault-relative note path becomes a BARE wikilink —
165
+ * no `**name**:` prefix, because the aliased filename is self-explanatory
166
+ * (the file is user-provided material) and the label only adds noise — and
167
+ * sorts first, so the reference leads the line; every other attribute stays
168
+ * a labeled `**name**: value` item in source order.
160
169
  */
161
- function formatAttrValue(name: string, value: string): string {
162
- return (name === "path" || name === "location") && isVaultNotePath(value)
163
- ? wikilinkOf(value)
164
- : inlineCode(value);
170
+ function attrItems(attrs: string): string[] {
171
+ const pairs = parseAttrs(attrs);
172
+ return [
173
+ ...pairs.filter(isWikilinkAttr).map((p) => wikilinkOf(p.value)),
174
+ ...pairs.filter((p) => !isWikilinkAttr(p)).map((p) => `**${p.name}**: ${inlineCode(p.value)}`),
175
+ ];
165
176
  }
166
177
 
167
178
  /**
168
- * One injected block as a callout, fully generic:
169
- *
170
- * - title — the tag name in words;
171
- * - body — attribute pairs as `**name**: value` lines joined by ` · `, then
172
- * (after a blank line) the content;
173
- * - visible blocks quote their content in a folded `> [!quote]-` callout;
174
- * blocks without content stay unfolded (nothing to hide behind the fold);
175
- * - hidden (agent-side) blocks render as an unfolded `> [!note]` marker
176
- * line with the content dropped.
179
+ * Hidden block as its own self-contained marker callout: the `name`
180
+ * attribute joins the title — WHICH skill was loaded is the marker's whole
181
+ * meaning, and a collapsed `Skill` alone would say nothing — while the
182
+ * remaining attributes (the location) form the body and the content is
183
+ * dropped (a skill is re-loadable from its location). A hidden block with
184
+ * no attributes at all carries no information and is removed.
185
+ */
186
+ function hiddenCallout(tag: string, attrs: string): string {
187
+ const pairs = parseAttrs(attrs);
188
+ if (pairs.length === 0) return "";
189
+ const name = pairs.find((p) => p.name === "name")?.value;
190
+ const body = pairs
191
+ .filter((p) => p.name !== "name")
192
+ .map((p) => (isWikilinkAttr(p) ? wikilinkOf(p.value) : `**${p.name}**: ${inlineCode(p.value)}`))
193
+ .join(" · ");
194
+ return callout("note", name ? `${tagTitle(tag)} · ${name}` : tagTitle(tag), body);
195
+ }
196
+
197
+ /**
198
+ * One VISIBLE injected block's rendered body (no callout wrapper; hidden
199
+ * blocks render through hiddenCallout): the attribute items, then (after a
200
+ * blank line) the content.
177
201
  *
178
202
  * linked_note is the one content concession: its whole payload IS a note
179
203
  * reference, so content shaped like a vault note path renders as a wikilink
180
204
  * too — the same mechanical shape test, no semantics.
181
205
  */
182
- function renderInjectedBlock(tag: string, attrs: string, content: string): string {
183
- const attrLine = parseAttrs(attrs)
184
- .map((p) => `**${p.name}**: ${formatAttrValue(p.name, p.value)}`)
185
- .join(" · ");
186
- const title = tagTitle(tag);
187
- if (HIDDEN_TAGS.has(tag)) return callout("note", title, attrLine, false);
206
+ function injectedBlockBody(tag: string, attrs: string, content: string): string {
207
+ const attrLine = attrItems(attrs).join(" · ");
188
208
  const text = content ? unwrapCDATA(content).trim() : "";
189
209
  const body = tag === "linked_note" && isVaultNotePath(text) ? wikilinkOf(text) : text;
190
- return callout(
191
- "quote",
192
- title,
193
- attrLine ? (body ? `${attrLine}\n\n${body}` : attrLine) : body,
194
- Boolean(body),
195
- );
210
+ return attrLine ? (body ? `${attrLine}\n\n${body}` : attrLine) : body;
196
211
  }
197
212
 
198
213
  /**
199
214
  * User message text for the saved body: every known injected block renders
200
- * as a callout; anything else stays verbatim.
215
+ * as a preset-collapsed callout — visible blocks as `> [!quote]-`, hidden
216
+ * blocks as a `> [!note]-` marker — and a RUN of visible same-tag blocks
217
+ * with nothing but whitespace between them merges into ONE callout (a user
218
+ * attaching five notes saves one Linked Content callout listing five
219
+ * wikilinks, not five callouts; hidden markers never merge — each names its
220
+ * own skill). Anything else stays verbatim.
201
221
  */
202
222
  export function renderUserMessageText(text: string): string {
203
- return text.replace(
204
- INJECTED_BLOCK_RE,
205
- (_s: string, tag: string, attrs: string, content?: string) =>
206
- renderInjectedBlock(tag, attrs, content ?? ""),
207
- );
223
+ const re = new RegExp(INJECTED_BLOCK_RE, "g");
224
+ let out = "";
225
+ let pos = 0;
226
+ let runTag: string | null = null;
227
+ const runBodies: string[] = [];
228
+ const flushRun = () => {
229
+ if (runTag === null) return;
230
+ out += callout("quote", tagTitle(runTag), runBodies.join("\n\n"));
231
+ runTag = null;
232
+ runBodies.length = 0;
233
+ };
234
+ let m: RegExpExecArray | null;
235
+ while ((m = re.exec(text)) !== null) {
236
+ const tag = m[1];
237
+ const gap = text.slice(pos, m.index);
238
+ const mergeable = runTag !== null && tag === runTag && gap.trim() === "";
239
+ if (!mergeable) {
240
+ // A different tag, real text between the blocks, or a hidden block
241
+ // (self-contained markers never merge) ends the run; the gap is
242
+ // emitted only then — inside a run it is whitespace.
243
+ flushRun();
244
+ out += gap;
245
+ }
246
+ if (HIDDEN_TAGS.has(tag)) {
247
+ out += hiddenCallout(tag, m[2]);
248
+ } else {
249
+ if (runTag === null) runTag = tag;
250
+ runBodies.push(injectedBlockBody(tag, m[2], m[3] ?? ""));
251
+ }
252
+ pos = re.lastIndex;
253
+ }
254
+ flushRun();
255
+ return out + text.slice(pos);
208
256
  }
209
257
 
210
258
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-auto-save-to-markdown",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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",