pi-auto-save-to-markdown 0.7.3 → 0.8.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
@@ -92,10 +92,10 @@ 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.2"
95
+ format_version: "1.4"
96
96
  session_id: "d0a4f541-976d-4d1b-8e1c-30a1f2b3c4d5"
97
97
  session_key: "c2088d77"
98
- last_entry_id: "019be3a2-1f4d-7c8a-9b01-d23e45f6a7b8"
98
+ branch_last_entry_id: "019be3a2-1f4d-7c8a-9b01-d23e45f6a7b8"
99
99
  model: "z-ai/glm-5.3"
100
100
  provider: "openrouter"
101
101
  cost: 0.023401
@@ -105,8 +105,8 @@ tokens_output: 3515
105
105
  tokens_cache_read: 0
106
106
  tokens_cache_write: 0
107
107
  messages: 8
108
- created: "2026-08-29T05:05:12.000Z"
109
- updated: "2026-08-29T05:42:10.000Z"
108
+ created: "2026-08-29T13:05:12+08:00"
109
+ updated: "2026-08-29T13:42:10+08:00"
110
110
  project_root: "/Users/me/project"
111
111
  session_file: "~/.pi/agent/sessions/--Users-me-project-20260829-050500_ab12.jsonl"
112
112
  ---
@@ -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
+ > **path**: [[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,20 @@ 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), 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.
169
+
151
170
  Each message block opens with a setext level-1 info header (`User`,
152
171
  `Assistant`) underlined with `===` — one level above the `##` headings AI
153
172
  content typically starts with, and distinguishable from content `#` headings
package/README.zh.md CHANGED
@@ -66,10 +66,10 @@ PI_SAVE_CONVERSATION_DIR=notes/ai pi
66
66
  ---
67
67
  title: "修复登录重定向死循环"
68
68
  agent: "pi"
69
- format_version: "1.2"
69
+ format_version: "1.4"
70
70
  session_id: "d0a4f541-976d-4d1b-8e1c-30a1f2b3c4d5"
71
71
  session_key: "c2088d77"
72
- last_entry_id: "019be3a2-1f4d-7c8a-9b01-d23e45f6a7b8"
72
+ branch_last_entry_id: "019be3a2-1f4d-7c8a-9b01-d23e45f6a7b8"
73
73
  model: "z-ai/glm-5.3"
74
74
  provider: "openrouter"
75
75
  cost: 0.023401
@@ -79,8 +79,8 @@ tokens_output: 3515
79
79
  tokens_cache_read: 0
80
80
  tokens_cache_write: 0
81
81
  messages: 8
82
- created: "2026-08-29T05:05:12.000Z"
83
- updated: "2026-08-29T05:42:10.000Z"
82
+ created: "2026-08-29T13:05:12+08:00"
83
+ updated: "2026-08-29T13:42:10+08:00"
84
84
  project_root: "/Users/me/project"
85
85
  session_file: "~/.pi/agent/sessions/--Users-me-project-20260829-050500_ab12.jsonl"
86
86
  ---
@@ -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
+ > **path**: [[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,16 @@ Obsidian 对 HTML 块内嵌 Markdown 的渲染不可靠;在非 Obsidian 环境
120
125
  压过内容中的反引号序列),工具的原始输出因此按字面渲染,不会被当作
121
126
  Markdown 解析。
122
127
 
128
+ 客户端或 agent 注入到用户消息中的提示块——编辑器当前选区、附加或引用的
129
+ 笔记、加载的 skill——会从原始 XML(Obsidian 无法渲染,只会显示成裸露的
130
+ 尖括号文本)重新渲染为通用 callout。不做任何逐块解析:标题取标记名的分词
131
+ (`editor_selection` → Editor Selection),属性对以 `**属性**: 值` 行开头
132
+ (形似 vault 相对笔记路径的 `path`/`location` 值会转为 Obsidian wikilink),
133
+ 随后是引用内容,折叠在 callout 中。用户提供的块(选区、笔记附件)保持
134
+ 可见;agent 侧痕迹(skill)渲染为单行标记,内容不再展示。未知标记原样
135
+ 保留,用户粘贴的 XML 内容绝不会被误改;回退文件名 slug 也从剥离全部
136
+ 已知块后的纯键入文本推导。
137
+
123
138
  每个消息块以 setext 一级信息头(`User`、`Assistant`,下一行以 `===` 下划)
124
139
  开头——高于 AI 内容常见的 `##` 二级标题,解析时也能与内容中的 `#` 一级标题
125
140
  区分开。信息头的元数据(本地日期时间,assistant 消息还带模型名)放在一个小号
package/index.ts CHANGED
@@ -25,11 +25,14 @@
25
25
  * plugins for other runtimes would write their own value), format_version
26
26
  * (the document format of the last write — additive frontmatter fields
27
27
  * never bump it; see FORMAT_VERSION), session id, session key (the
28
- * filename key), last entry id (the id of the deepest message entry on the
29
- * saved branch — the file's position in the session jsonl tree at the last
30
- * write), model, provider, cumulative cost and tokens (input, output,
31
- * cache read/write), message count, created/updated timestamps, project
32
- * root and session file.
28
+ * filename key), branch last entry id (field `branch_last_entry_id` — the
29
+ * id of the deepest message entry on the saved branch: the file's position
30
+ * in the session jsonl tree at the last write, scoped to this file's
31
+ * branch, not the session-wide last entry), model, provider, cumulative
32
+ * cost and tokens (input, output,
33
+ * cache read/write), message count, created/updated timestamps (tz-aware
34
+ * ISO 8601 in the local timezone with its numeric UTC offset, e.g.
35
+ * "2026-08-29T13:05:12+08:00"), project root and session file.
33
36
  * - Body format: every message block opens with a setext-H1 info header
34
37
  * (`User <span …>YYYY-MM-DD HH:MM:SS</span>` /
35
38
  * `Assistant <span …>YYYY-MM-DD HH:MM:SS · model</span>`, where the span
@@ -51,6 +54,22 @@
51
54
  * of being parsed as markdown. A result whose call was saved in
52
55
  * an earlier file (mid-turn manual save) falls back to a standalone
53
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, 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
70
+ * derivation strips every known block — the typed message is the title.
71
+ * Unknown markup is left verbatim so XML pasted as content is never
72
+ * mangled.
54
73
  * - Branching: each file records exactly ONE branch (the root→leaf path
55
74
  * returned by sessionManager.getBranch()). State is persisted via
56
75
  * `pi.appendEntry()` custom entries, which are part of the session tree
@@ -157,6 +176,7 @@ import * as fsSync from "node:fs";
157
176
  import * as os from "node:os";
158
177
  import * as path from "node:path";
159
178
  import { debug } from "./debug.js";
179
+ import { callout, inlineCode, renderUserMessageText, stripInjectedBlocks } from "./markdown.js";
160
180
 
161
181
  const CUSTOM_TYPE = "pi-claudian-auto-save-markdown";
162
182
  const ENV_SUBDIR = "PI_SAVE_CONVERSATION_DIR";
@@ -196,7 +216,7 @@ const SAVE_STATE_SCHEMA = "1.2";
196
216
  * the frontmatter and the document heading); additive frontmatter fields do
197
217
  * NOT bump it — they are invisible to any within-major parser.
198
218
  */
199
- const FORMAT_VERSION = "1.2";
219
+ const FORMAT_VERSION = "1.4";
200
220
 
201
221
  /**
202
222
  * Package version of this extension, read best-effort from the adjacent
@@ -248,8 +268,8 @@ interface SaveState {
248
268
  /**
249
269
  * Id of the session tree leaf at save time (getLeafId()) — may be a custom
250
270
  * state entry rather than a message; used to rank continuation candidates
251
- * on the current path. Distinct from frontmatter `last_entry_id`, which is
252
- * the deepest message entry (an id actually present in the file).
271
+ * on the current path. Distinct from frontmatter `branch_last_entry_id`,
272
+ * which is the deepest message entry (an id actually present in the file).
253
273
  */
254
274
  lastSavedEntryId: string | null;
255
275
  file: string;
@@ -388,10 +408,11 @@ interface BranchMeta {
388
408
  sessionKey: string;
389
409
  /**
390
410
  * Id of the deepest message entry on the saved branch at the last write
391
- * (field name `last_entry_id`) — the file's exact position in the
392
- * session jsonl tree. Updated on every append, like `updated`.
411
+ * (field name `branch_last_entry_id`) — the file's exact position in the
412
+ * session jsonl tree, scoped to this file's branch rather than the
413
+ * session-wide last entry. Updated on every append, like `updated`.
393
414
  */
394
- lastEntryId: string;
415
+ branchLastEntryId: string;
395
416
  model: string | null;
396
417
  provider: string | null;
397
418
  cost: number;
@@ -884,7 +905,12 @@ export default function (pi: ExtensionAPI) {
884
905
 
885
906
  function firstUserText(messages: SessionMessageEntry[]): string | undefined {
886
907
  for (const e of messages) {
887
- if (e.message.role === "user") return userText(e.message.content) || undefined;
908
+ if (e.message.role === "user") {
909
+ // Title derivation reads the plain typed message — every known
910
+ // injected block (see markdown.ts) is stripped, mirroring how the
911
+ // client strips the same blocks for its own session titles.
912
+ return stripInjectedBlocks(userText(e.message.content)) || undefined;
913
+ }
888
914
  }
889
915
  return undefined;
890
916
  }
@@ -928,19 +954,6 @@ export default function (pi: ExtensionAPI) {
928
954
 
929
955
  // ---------- markdown rendering ----------
930
956
 
931
- /**
932
- * Inline code span for arbitrary raw output (tool results, argument JSON):
933
- * the delimiter is always one backtick longer than the longest backtick run
934
- * inside the text, so content that itself contains backticks cannot break
935
- * the span. Tool output renders literally instead of being parsed as
936
- * markdown (headings, bold, wiki links …).
937
- */
938
- function inlineCode(text: string): string {
939
- const longest = (text.match(/`+/g) ?? []).reduce((a, r) => Math.max(a, r.length), 0);
940
- const fence = "`".repeat(longest + 1);
941
- return `${fence}${text}${fence}`;
942
- }
943
-
944
957
  /**
945
958
  * Strip leading blank lines and trailing whitespace from a rendered block,
946
959
  * so joins and separators always keep exactly one blank line around them
@@ -983,17 +996,6 @@ export default function (pi: ExtensionAPI) {
983
996
  return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(", ");
984
997
  }
985
998
 
986
- /**
987
- * Collapsed Obsidian callout (`> [!type]- title`) wrapping a markdown body:
988
- * every body line is prefixed with `>` (empty lines become bare `>`), so the
989
- * body keeps rendering as markdown while folding works in both Obsidian
990
- * views. Outside Obsidian the callout degrades to a plain blockquote.
991
- */
992
- function callout(type: string, title: string, body: string): string {
993
- const lines = body.split("\n").map((line) => (line ? `> ${line}` : ">"));
994
- return `> [!${type}]- ${title}\n${lines.join("\n")}`;
995
- }
996
-
997
999
  /**
998
1000
  * Fold tool calls and their paired results into one collapsed callout.
999
1001
  * Argument JSON and result previews are wrapped in inline code spans, so
@@ -1070,7 +1072,7 @@ export default function (pi: ExtensionAPI) {
1070
1072
  const m = e.message;
1071
1073
  const t = dateTime(e.timestamp);
1072
1074
  if (m.role === "user") {
1073
- blocks.push(`${messageHeader("User", t)}\n\n${userText(m.content)}`);
1075
+ blocks.push(`${messageHeader("User", t)}\n\n${renderUserMessageText(userText(m.content))}`);
1074
1076
  } else if (m.role === "assistant") {
1075
1077
  blocks.push(renderAssistant(m, t, results));
1076
1078
  } else if (m.role === "toolResult") {
@@ -1097,6 +1099,24 @@ export default function (pi: ExtensionAPI) {
1097
1099
  return `"${safe.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
1098
1100
  }
1099
1101
 
1102
+ /**
1103
+ * ISO 8601 timestamp in the machine's local timezone, with the numeric
1104
+ * UTC offset appended (e.g. "2026-08-29T13:05:12+08:00"): tz-aware, so the
1105
+ * value reads as local wall-clock time without assuming the reader's
1106
+ * timezone. Legacy files written with UTC "Z" values parse identically.
1107
+ */
1108
+ function localIsoTimestamp(date: Date): string {
1109
+ const pad = (n: number) => String(n).padStart(2, "0");
1110
+ const offsetMin = -date.getTimezoneOffset();
1111
+ const sign = offsetMin >= 0 ? "+" : "-";
1112
+ const abs = Math.abs(offsetMin);
1113
+ return (
1114
+ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
1115
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
1116
+ `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
1117
+ );
1118
+ }
1119
+
1100
1120
  function frontmatter(meta: BranchMeta): string {
1101
1121
  const lines: string[] = ["---"];
1102
1122
  lines.push(`title: ${yamlQuote(meta.title)}`);
@@ -1104,7 +1124,7 @@ export default function (pi: ExtensionAPI) {
1104
1124
  lines.push(`format_version: ${yamlQuote(FORMAT_VERSION)}`);
1105
1125
  if (meta.sessionId) lines.push(`session_id: ${yamlQuote(meta.sessionId)}`);
1106
1126
  lines.push(`session_key: ${yamlQuote(meta.sessionKey)}`);
1107
- lines.push(`last_entry_id: ${yamlQuote(meta.lastEntryId)}`);
1127
+ lines.push(`branch_last_entry_id: ${yamlQuote(meta.branchLastEntryId)}`);
1108
1128
  if (meta.model) lines.push(`model: ${yamlQuote(meta.model)}`);
1109
1129
  if (meta.provider) lines.push(`provider: ${yamlQuote(meta.provider)}`);
1110
1130
  lines.push(`cost: ${meta.cost.toFixed(6)}`);
@@ -1177,14 +1197,14 @@ export default function (pi: ExtensionAPI) {
1177
1197
  tokensCacheWrite += usage.cacheWrite ?? 0;
1178
1198
  }
1179
1199
  }
1180
- const now = new Date().toISOString();
1200
+ const now = localIsoTimestamp(new Date());
1181
1201
  return {
1182
1202
  title: displayTitle(ctx, firstUserText(pathMessages)),
1183
1203
  agent: agent ?? AGENT,
1184
1204
  sessionId: ctx.session.getSessionId(),
1185
1205
  sessionFile: ctx.session.getSessionFile() ?? null,
1186
1206
  sessionKey,
1187
- lastEntryId: pathMessages[pathMessages.length - 1].id,
1207
+ branchLastEntryId: pathMessages[pathMessages.length - 1].id,
1188
1208
  model,
1189
1209
  provider,
1190
1210
  cost,
package/markdown.ts ADDED
@@ -0,0 +1,221 @@
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 one-line
58
+ * marker, content dropped (a skill is re-loadable from its location).
59
+ *
60
+ * Unknown tags are left verbatim: this is both the safety boundary against
61
+ * mangling pasted XML and a soft failure mode for future client tags (they
62
+ * stay raw until the vocabulary is extended).
63
+ */
64
+ const VISIBLE_TAGS = [
65
+ "editor_selection",
66
+ "editor_cursor",
67
+ "current_note",
68
+ "context_files",
69
+ "canvas_selection",
70
+ "browser_selection",
71
+ "linked_note",
72
+ "linked_content",
73
+ ] as const;
74
+ const HIDDEN_TAGS = new Set<string>(["skill"]);
75
+ const ALL_TAGS = [...VISIBLE_TAGS, ...HIDDEN_TAGS];
76
+
77
+ /**
78
+ * One injected block, in the grammar the client itself uses: a self-closing
79
+ * `<tag attrs/>` or a paired `<tag attrs>content</tag>` (content may be a
80
+ * CDATA section). Attribute values never contain raw `<`, `>` or `"` — the
81
+ * client XML-escapes them — which is what the attribute group relies on.
82
+ */
83
+ const INJECTED_BLOCK_RE = new RegExp(
84
+ `<(${ALL_TAGS.join("|")})\\b((?:\\s[^<>]*?)?)(?:/>|>([\\s\\S]*?)</\\1\\s*>)`,
85
+ "g",
86
+ );
87
+
88
+ /** "editor_selection" → "Editor Selection" — the display title of a tag. */
89
+ function tagTitle(tag: string): string {
90
+ return tag
91
+ .split("_")
92
+ .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
93
+ .join(" ");
94
+ }
95
+
96
+ /** Decode the XML entities the client escapes attribute values with. */
97
+ function decodeEntities(s: string): string {
98
+ return s
99
+ .replace(/&#(\d+);/g, (m, d: string) => {
100
+ try {
101
+ return String.fromCodePoint(Number(d));
102
+ } catch {
103
+ return m;
104
+ }
105
+ })
106
+ .replace(/&lt;/g, "<")
107
+ .replace(/&gt;/g, ">")
108
+ .replace(/&quot;/g, '"')
109
+ .replace(/&apos;/g, "'")
110
+ .replace(/&amp;/g, "&"); // last: "&amp;lt;" must not double-decode
111
+ }
112
+
113
+ /** Attribute pairs of an injected block, in source order. */
114
+ function parseAttrs(attrs: string): { name: string; value: string }[] {
115
+ const out: { name: string; value: string }[] = [];
116
+ for (const m of attrs.matchAll(/([a-zA-Z_][a-zA-Z0-9_.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
117
+ out.push({ name: m[1], value: decodeEntities(m[2] ?? m[3] ?? "") });
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /**
123
+ * Unwrap the client's CDATA wrapper and reverse its `]]>` split-escaping
124
+ * (the client rewrites a literal `]]>` inside a selection as
125
+ * `]]]]><![CDATA[>`). Content without a CDATA wrapper passes through
126
+ * unchanged (linked_note names, raw bodies).
127
+ */
128
+ function unwrapCDATA(content: string): string {
129
+ const m = /^\s*<!\[CDATA\[([\s\S]*)\]\]>\s*$/.exec(content);
130
+ return m ? m[1].replace(/\]\]\]\]><!\[CDATA\[>/g, "]]>") : content;
131
+ }
132
+
133
+ /**
134
+ * Whether a value is a vault-relative note path: no leading slash or home
135
+ * marker, no drive colon or wikilink-hostile characters, and shaped like a
136
+ * note (a folder or a .md extension). Values that fail — absolute
137
+ * filesystem paths, plain names, anything else — stay in code spans.
138
+ */
139
+ function isVaultNotePath(value: string): boolean {
140
+ return (
141
+ value.length > 0 &&
142
+ !/^[~/]/.test(value) &&
143
+ !/[:\r\n[\]|#^<>]/.test(value) &&
144
+ (/\.md$/i.test(value) || value.includes("/"))
145
+ );
146
+ }
147
+
148
+ /** Vault path as a wikilink, aliased to its basename when it has folders. */
149
+ function wikilinkOf(path: string): string {
150
+ const base = path.slice(path.lastIndexOf("/") + 1);
151
+ return base && base !== path ? `[[${path}|${base}]]` : `[[${path}]]`;
152
+ }
153
+
154
+ /**
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.
160
+ */
161
+ function formatAttrValue(name: string, value: string): string {
162
+ return (name === "path" || name === "location") && isVaultNotePath(value)
163
+ ? wikilinkOf(value)
164
+ : inlineCode(value);
165
+ }
166
+
167
+ /**
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.
177
+ *
178
+ * linked_note is the one content concession: its whole payload IS a note
179
+ * reference, so content shaped like a vault note path renders as a wikilink
180
+ * too — the same mechanical shape test, no semantics.
181
+ */
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);
188
+ const text = content ? unwrapCDATA(content).trim() : "";
189
+ 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
+ );
196
+ }
197
+
198
+ /**
199
+ * User message text for the saved body: every known injected block renders
200
+ * as a callout; anything else stays verbatim.
201
+ */
202
+ 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
+ );
208
+ }
209
+
210
+ /**
211
+ * The plain typed message: every known injected block removed and the
212
+ * blank lines they leave behind collapsed — this is what title derivation
213
+ * (frontmatter title, document heading, fallback filename slug) reads,
214
+ * mirroring how the client strips the same blocks for its own titles.
215
+ */
216
+ export function stripInjectedBlocks(text: string): string {
217
+ return text
218
+ .replace(INJECTED_BLOCK_RE, "")
219
+ .replace(/\n{3,}/g, "\n\n")
220
+ .trim();
221
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-auto-save-to-markdown",
3
- "version": "0.7.3",
3
+ "version": "0.8.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",
@@ -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
  ],