pi-auto-save-to-markdown 0.7.4 → 0.8.1

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
@@ -92,7 +92,7 @@ changes never touch the filename, and manually renamed files are left alone.
92
92
  ---
93
93
  title: "Fix login redirect loop"
94
94
  agent: "pi"
95
- format_version: "1.3"
95
+ format_version: "1.5"
96
96
  session_id: "d0a4f541-976d-4d1b-8e1c-30a1f2b3c4d5"
97
97
  session_key: "c2088d77"
98
98
  branch_last_entry_id: "019be3a2-1f4d-7c8a-9b01-d23e45f6a7b8"
@@ -118,6 +118,11 @@ User <span style="font-size: 0.5em; color: var(--text-faint);">2026-08-29 13:05:
118
118
 
119
119
  The login page redirects in a loop after the auth refactor...
120
120
 
121
+ > [!quote]- Editor Selection
122
+ > [[src/auth/middleware.ts|middleware.ts]] · **lines**: `14-22`
123
+ >
124
+ > export function middleware(request) { … }
125
+
121
126
  ---
122
127
 
123
128
  Assistant <span style="font-size: 0.5em; color: var(--text-faint);">2026-08-29 13:05:40 · claude-sonnet-4-5</span>
@@ -148,6 +153,27 @@ previews are wrapped in inline code spans (with a delimiter sized to survive
148
153
  backticks inside the content), so raw tool output renders literally instead of
149
154
  being parsed as markdown.
150
155
 
156
+ Prompt blocks the client or the agent injects into a user message — the
157
+ editor's active selection, attached or referenced notes, loaded skills — are
158
+ re-rendered from their raw XML (which Obsidian cannot render and would show
159
+ as literal angle-bracket text) into generic callouts. No block is parsed
160
+ individually: the title is the tag name in words (`editor_selection` →
161
+ Editor Selection), and the body opens with vault-shaped `path`/`location`
162
+ values as bare Obsidian wikilinks (the aliased filename speaks for itself —
163
+ no `path:` label), followed by the remaining attributes as
164
+ `**name**: value` items, then the content. Every callout is preset-collapsed
165
+ — user-provided blocks (selections, note attachments) as `> [!quote]-`, even
166
+ when they carry only attributes (the client emits note references as
167
+ self-closing tags carrying just a path), agent-side skill traces as a
168
+ `> [!note]- Skill · <name>` marker (the loaded skill's name rides the title,
169
+ so the collapsed marker still says which skill; the location follows in the
170
+ body, the content is dropped) — and consecutive blocks of
171
+ the same tag (nothing but whitespace between them) merge into one callout,
172
+ so a run of note references collapses into a single list. Unknown markup is
173
+ left verbatim, so XML pasted as content is never mangled — and the fallback
174
+ filename slug derives from the typed message with every known block
175
+ stripped.
176
+
151
177
  Each message block opens with a setext level-1 info header (`User`,
152
178
  `Assistant`) underlined with `===` — one level above the `##` headings AI
153
179
  content typically starts with, and distinguishable from content `#` headings
package/README.zh.md CHANGED
@@ -66,7 +66,7 @@ PI_SAVE_CONVERSATION_DIR=notes/ai pi
66
66
  ---
67
67
  title: "修复登录重定向死循环"
68
68
  agent: "pi"
69
- format_version: "1.3"
69
+ format_version: "1.5"
70
70
  session_id: "d0a4f541-976d-4d1b-8e1c-30a1f2b3c4d5"
71
71
  session_key: "c2088d77"
72
72
  branch_last_entry_id: "019be3a2-1f4d-7c8a-9b01-d23e45f6a7b8"
@@ -92,6 +92,11 @@ User <span style="font-size: 0.5em; color: var(--text-faint);">2026-08-29 13:05:
92
92
 
93
93
  auth 重构之后登录页一直重定向死循环……
94
94
 
95
+ > [!quote]- Editor Selection
96
+ > [[src/auth/middleware.ts|middleware.ts]] · **lines**: `14-22`
97
+ >
98
+ > export function middleware(request) { … }
99
+
95
100
  ---
96
101
 
97
102
  Assistant <span style="font-size: 0.5em; color: var(--text-faint);">2026-08-29 13:05:40 · claude-sonnet-4-5</span>
@@ -120,6 +125,21 @@ Obsidian 对 HTML 块内嵌 Markdown 的渲染不可靠;在非 Obsidian 环境
120
125
  压过内容中的反引号序列),工具的原始输出因此按字面渲染,不会被当作
121
126
  Markdown 解析。
122
127
 
128
+ 客户端或 agent 注入到用户消息中的提示块——编辑器当前选区、附加或引用的
129
+ 笔记、加载的 skill——会从原始 XML(Obsidian 无法渲染,只会显示成裸露的
130
+ 尖括号文本)重新渲染为通用 callout。不做任何逐块解析:标题取标记名的分词
131
+ (`editor_selection` → Editor Selection),正文以形似 vault 相对笔记路径的
132
+ `path`/`location` 值开头——直接渲染为不带标签的 Obsidian wikilink(别名
133
+ 文件名自解释,`path:` 标签反而冗余),其余属性以 `**属性**: 值` 跟随,
134
+ 最后是引用内容。所有 callout 一律预设折叠——用户提供的块(选区、笔记附件)
135
+ 为 `> [!quote]-`(仅有属性的自闭合笔记引用同样折叠),agent 侧痕迹(skill)
136
+ 为 `> [!note]- Skill · <名称>` 标记(加载的 skill 名称直接进标题,折叠状态
137
+ 也能看到是哪个 skill;location 跟在正文,内容丢弃);连续的同标签块(中间
138
+ 只有空白)合并进同一个 callout,一串笔记引用因此收拢为一份列表(skill 标记
139
+ 不合并:各自标注各自的 skill)。未知标记原样保留,用户粘贴的
140
+ XML 内容绝不会被误改;回退文件名 slug 也从剥离全部已知块后的纯键入文本
141
+ 推导。
142
+
123
143
  每个消息块以 setext 一级信息头(`User`、`Assistant`,下一行以 `===` 下划)
124
144
  开头——高于 AI 内容常见的 `##` 二级标题,解析时也能与内容中的 `#` 一级标题
125
145
  区分开。信息头的元数据(本地日期时间,assistant 消息还带模型名)放在一个小号
package/index.ts CHANGED
@@ -54,6 +54,31 @@
54
54
  * of being parsed as markdown. A result whose call was saved in
55
55
  * an earlier file (mid-turn manual save) falls back to a standalone
56
56
  * one-line block.
57
+ * - Injected prompt blocks: the host client and the agent runtime append
58
+ * machine-readable XML to user messages — the editor's active selection
59
+ * (CDATA content), note references and attachments (linked_note /
60
+ * linked_content), loaded skills, and their kin. Raw markup is noise
61
+ * Obsidian cannot render (unknown tags are not HTML; CDATA is XML), so
62
+ * every block in a known vocabulary is re-rendered generically (see
63
+ * markdown.ts) — no per-tag formatting: the callout title is the tag name
64
+ * in words, the body opens with vault-shaped path/location values as
65
+ * bare wikilinks (the aliased filename is self-explanatory — a `path:`
66
+ * label is noise) followed by the remaining attributes as
67
+ * "**name**: value" lines, then the content (the client's `]]>`
68
+ * split-escaping reversed). Every callout is preset-collapsed: visible
69
+ * blocks (selections, note references, attachments) as `> [!quote]-`,
70
+ * whether they carry content or only attributes (the client emits note
71
+ * references as self-closing tags whose whole payload is a path
72
+ * attribute), agent-side traces (skills) as a `> [!note]- Skill · <name>`
73
+ * marker — the loaded skill's name rides the title so the collapsed
74
+ * marker still says which skill, the location follows in the body, the
75
+ * content is dropped — and consecutive visible same-tag blocks (nothing
76
+ * but whitespace between them) merge into one callout, so a run of note
77
+ * references collapses into a single list (skill markers never merge:
78
+ * each names its own skill). Title
79
+ * derivation strips every known block — the typed message is the title.
80
+ * Unknown markup is left verbatim so XML pasted as content is never
81
+ * mangled.
57
82
  * - Branching: each file records exactly ONE branch (the root→leaf path
58
83
  * returned by sessionManager.getBranch()). State is persisted via
59
84
  * `pi.appendEntry()` custom entries, which are part of the session tree
@@ -160,6 +185,7 @@ import * as fsSync from "node:fs";
160
185
  import * as os from "node:os";
161
186
  import * as path from "node:path";
162
187
  import { debug } from "./debug.js";
188
+ import { callout, inlineCode, renderUserMessageText, stripInjectedBlocks } from "./markdown.js";
163
189
 
164
190
  const CUSTOM_TYPE = "pi-claudian-auto-save-markdown";
165
191
  const ENV_SUBDIR = "PI_SAVE_CONVERSATION_DIR";
@@ -199,7 +225,7 @@ const SAVE_STATE_SCHEMA = "1.2";
199
225
  * the frontmatter and the document heading); additive frontmatter fields do
200
226
  * NOT bump it — they are invisible to any within-major parser.
201
227
  */
202
- const FORMAT_VERSION = "1.3";
228
+ const FORMAT_VERSION = "1.5";
203
229
 
204
230
  /**
205
231
  * Package version of this extension, read best-effort from the adjacent
@@ -888,7 +914,12 @@ export default function (pi: ExtensionAPI) {
888
914
 
889
915
  function firstUserText(messages: SessionMessageEntry[]): string | undefined {
890
916
  for (const e of messages) {
891
- if (e.message.role === "user") return userText(e.message.content) || undefined;
917
+ if (e.message.role === "user") {
918
+ // Title derivation reads the plain typed message — every known
919
+ // injected block (see markdown.ts) is stripped, mirroring how the
920
+ // client strips the same blocks for its own session titles.
921
+ return stripInjectedBlocks(userText(e.message.content)) || undefined;
922
+ }
892
923
  }
893
924
  return undefined;
894
925
  }
@@ -932,19 +963,6 @@ export default function (pi: ExtensionAPI) {
932
963
 
933
964
  // ---------- markdown rendering ----------
934
965
 
935
- /**
936
- * Inline code span for arbitrary raw output (tool results, argument JSON):
937
- * the delimiter is always one backtick longer than the longest backtick run
938
- * inside the text, so content that itself contains backticks cannot break
939
- * the span. Tool output renders literally instead of being parsed as
940
- * markdown (headings, bold, wiki links …).
941
- */
942
- function inlineCode(text: string): string {
943
- const longest = (text.match(/`+/g) ?? []).reduce((a, r) => Math.max(a, r.length), 0);
944
- const fence = "`".repeat(longest + 1);
945
- return `${fence}${text}${fence}`;
946
- }
947
-
948
966
  /**
949
967
  * Strip leading blank lines and trailing whitespace from a rendered block,
950
968
  * so joins and separators always keep exactly one blank line around them
@@ -987,17 +1005,6 @@ export default function (pi: ExtensionAPI) {
987
1005
  return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(", ");
988
1006
  }
989
1007
 
990
- /**
991
- * Collapsed Obsidian callout (`> [!type]- title`) wrapping a markdown body:
992
- * every body line is prefixed with `>` (empty lines become bare `>`), so the
993
- * body keeps rendering as markdown while folding works in both Obsidian
994
- * views. Outside Obsidian the callout degrades to a plain blockquote.
995
- */
996
- function callout(type: string, title: string, body: string): string {
997
- const lines = body.split("\n").map((line) => (line ? `> ${line}` : ">"));
998
- return `> [!${type}]- ${title}\n${lines.join("\n")}`;
999
- }
1000
-
1001
1008
  /**
1002
1009
  * Fold tool calls and their paired results into one collapsed callout.
1003
1010
  * Argument JSON and result previews are wrapped in inline code spans, so
@@ -1074,7 +1081,7 @@ export default function (pi: ExtensionAPI) {
1074
1081
  const m = e.message;
1075
1082
  const t = dateTime(e.timestamp);
1076
1083
  if (m.role === "user") {
1077
- blocks.push(`${messageHeader("User", t)}\n\n${userText(m.content)}`);
1084
+ blocks.push(`${messageHeader("User", t)}\n\n${renderUserMessageText(userText(m.content))}`);
1078
1085
  } else if (m.role === "assistant") {
1079
1086
  blocks.push(renderAssistant(m, t, results));
1080
1087
  } else if (m.role === "toolResult") {
package/markdown.ts ADDED
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Shared markdown rendering helpers for the saved conversation format, plus
3
+ * the transformer for prompt blocks the host injects into user messages.
4
+ *
5
+ * The code-span and callout helpers are the document format's building
6
+ * blocks (see index.ts's header comment for how the saved file is
7
+ * assembled). The injected-block transformer below re-renders the
8
+ * machine-readable XML the Claudian client and the agent runtime append to
9
+ * user message text: raw, that markup is noise notes apps cannot render —
10
+ * unknown tags are not HTML and CDATA is XML — so every block in a known
11
+ * vocabulary becomes a readable callout, and anything unknown stays verbatim
12
+ * so XML a user pasted as content is never mangled.
13
+ */
14
+
15
+ // ---------- code spans & callouts ----------
16
+
17
+ /**
18
+ * Inline code span for arbitrary raw output (tool results, argument JSON):
19
+ * the delimiter is always one backtick longer than the longest backtick run
20
+ * inside the text, so content that itself contains backticks cannot break
21
+ * the span. Tool output renders literally instead of being parsed as
22
+ * markdown (headings, bold, wiki links …).
23
+ */
24
+ export function inlineCode(text: string): string {
25
+ const longest = (text.match(/`+/g) ?? []).reduce((a, r) => Math.max(a, r.length), 0);
26
+ const fence = "`".repeat(longest + 1);
27
+ return `${fence}${text}${fence}`;
28
+ }
29
+
30
+ /**
31
+ * Obsidian callout (`> [!type]- title`) wrapping a markdown body: every body
32
+ * line is prefixed with `>` (empty lines become bare `>`), so the body keeps
33
+ * rendering as markdown while folding works in both Obsidian views. Outside
34
+ * Obsidian the callout degrades to a plain blockquote. An empty body renders
35
+ * the title alone.
36
+ */
37
+ export function callout(type: string, title: string, body: string, fold = true): string {
38
+ const header = `> [!${type}]${fold ? "-" : ""} ${title}`;
39
+ if (!body) return header;
40
+ const lines = body.split("\n").map((line) => (line ? `> ${line}` : ">"));
41
+ return `${header}\n${lines.join("\n")}`;
42
+ }
43
+
44
+ // ---------- client-injected prompt blocks ----------
45
+
46
+ /**
47
+ * Injected-block vocabulary, harvested from the Claudian bundle's own
48
+ * block-stripping regexes and from observed sessions. Membership decides
49
+ * visibility only — rendering itself is fully generic (no per-tag format
50
+ * logic):
51
+ *
52
+ * - Visible: user-provided material. Selections of every surface
53
+ * (editor/canvas/browser), the current note, attached files, and note
54
+ * references (linked_note / linked_content — the client's attachment
55
+ * mechanism; a typed @-mention stays as plain text in the message, these
56
+ * are the machine copy).
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).
62
+ *
63
+ * Unknown tags are left verbatim: this is both the safety boundary against
64
+ * mangling pasted XML and a soft failure mode for future client tags (they
65
+ * stay raw until the vocabulary is extended).
66
+ */
67
+ const VISIBLE_TAGS = [
68
+ "editor_selection",
69
+ "editor_cursor",
70
+ "current_note",
71
+ "context_files",
72
+ "canvas_selection",
73
+ "browser_selection",
74
+ "linked_note",
75
+ "linked_content",
76
+ ] as const;
77
+ const HIDDEN_TAGS = new Set<string>(["skill"]);
78
+ const ALL_TAGS = [...VISIBLE_TAGS, ...HIDDEN_TAGS];
79
+
80
+ /**
81
+ * One injected block, in the grammar the client itself uses: a self-closing
82
+ * `<tag attrs/>` or a paired `<tag attrs>content</tag>` (content may be a
83
+ * CDATA section). Attribute values never contain raw `<`, `>` or `"` — the
84
+ * client XML-escapes them — which is what the attribute group relies on.
85
+ */
86
+ const INJECTED_BLOCK_RE = new RegExp(
87
+ `<(${ALL_TAGS.join("|")})\\b((?:\\s[^<>]*?)?)(?:/>|>([\\s\\S]*?)</\\1\\s*>)`,
88
+ "g",
89
+ );
90
+
91
+ /** "editor_selection" → "Editor Selection" — the display title of a tag. */
92
+ function tagTitle(tag: string): string {
93
+ return tag
94
+ .split("_")
95
+ .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
96
+ .join(" ");
97
+ }
98
+
99
+ /** Decode the XML entities the client escapes attribute values with. */
100
+ function decodeEntities(s: string): string {
101
+ return s
102
+ .replace(/&#(\d+);/g, (m, d: string) => {
103
+ try {
104
+ return String.fromCodePoint(Number(d));
105
+ } catch {
106
+ return m;
107
+ }
108
+ })
109
+ .replace(/&lt;/g, "<")
110
+ .replace(/&gt;/g, ">")
111
+ .replace(/&quot;/g, '"')
112
+ .replace(/&apos;/g, "'")
113
+ .replace(/&amp;/g, "&"); // last: "&amp;lt;" must not double-decode
114
+ }
115
+
116
+ /** Attribute pairs of an injected block, in source order. */
117
+ function parseAttrs(attrs: string): { name: string; value: string }[] {
118
+ const out: { name: string; value: string }[] = [];
119
+ for (const m of attrs.matchAll(/([a-zA-Z_][a-zA-Z0-9_.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
120
+ out.push({ name: m[1], value: decodeEntities(m[2] ?? m[3] ?? "") });
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /**
126
+ * Unwrap the client's CDATA wrapper and reverse its `]]>` split-escaping
127
+ * (the client rewrites a literal `]]>` inside a selection as
128
+ * `]]]]><![CDATA[>`). Content without a CDATA wrapper passes through
129
+ * unchanged (linked_note names, raw bodies).
130
+ */
131
+ function unwrapCDATA(content: string): string {
132
+ const m = /^\s*<!\[CDATA\[([\s\S]*)\]\]>\s*$/.exec(content);
133
+ return m ? m[1].replace(/\]\]\]\]><!\[CDATA\[>/g, "]]>") : content;
134
+ }
135
+
136
+ /**
137
+ * Whether a value is a vault-relative note path: no leading slash or home
138
+ * marker, no drive colon or wikilink-hostile characters, and shaped like a
139
+ * note (a folder or a .md extension). Values that fail — absolute
140
+ * filesystem paths, plain names, anything else — stay in code spans.
141
+ */
142
+ function isVaultNotePath(value: string): boolean {
143
+ return (
144
+ value.length > 0 &&
145
+ !/^[~/]/.test(value) &&
146
+ !/[:\r\n[\]|#^<>]/.test(value) &&
147
+ (/\.md$/i.test(value) || value.includes("/"))
148
+ );
149
+ }
150
+
151
+ /** Vault path as a wikilink, aliased to its basename when it has folders. */
152
+ function wikilinkOf(path: string): string {
153
+ const base = path.slice(path.lastIndexOf("/") + 1);
154
+ return base && base !== path ? `[[${path}|${base}]]` : `[[${path}]]`;
155
+ }
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
+
162
+ /**
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.
169
+ */
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
+ ];
176
+ }
177
+
178
+ /**
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.
201
+ *
202
+ * linked_note is the one content concession: its whole payload IS a note
203
+ * reference, so content shaped like a vault note path renders as a wikilink
204
+ * too — the same mechanical shape test, no semantics.
205
+ */
206
+ function injectedBlockBody(tag: string, attrs: string, content: string): string {
207
+ const attrLine = attrItems(attrs).join(" · ");
208
+ const text = content ? unwrapCDATA(content).trim() : "";
209
+ const body = tag === "linked_note" && isVaultNotePath(text) ? wikilinkOf(text) : text;
210
+ return attrLine ? (body ? `${attrLine}\n\n${body}` : attrLine) : body;
211
+ }
212
+
213
+ /**
214
+ * User message text for the saved body: every known injected block renders
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.
221
+ */
222
+ export function renderUserMessageText(text: string): string {
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);
256
+ }
257
+
258
+ /**
259
+ * The plain typed message: every known injected block removed and the
260
+ * blank lines they leave behind collapsed — this is what title derivation
261
+ * (frontmatter title, document heading, fallback filename slug) reads,
262
+ * mirroring how the client strips the same blocks for its own titles.
263
+ */
264
+ export function stripInjectedBlocks(text: string): string {
265
+ return text
266
+ .replace(INJECTED_BLOCK_RE, "")
267
+ .replace(/\n{3,}/g, "\n\n")
268
+ .trim();
269
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-auto-save-to-markdown",
3
- "version": "0.7.4",
3
+ "version": "0.8.1",
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",
@@ -41,6 +41,7 @@
41
41
  "files": [
42
42
  "index.ts",
43
43
  "debug.ts",
44
+ "markdown.ts",
44
45
  "README.md",
45
46
  "README.zh.md"
46
47
  ],