pi-auto-save-to-markdown 0.7.4 → 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 +20 -1
- package/README.zh.md +16 -1
- package/index.ts +25 -27
- package/markdown.ts +221 -0
- package/package.json +2 -1
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.
|
|
95
|
+
format_version: "1.4"
|
|
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
|
+
> **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,7 +66,7 @@ PI_SAVE_CONVERSATION_DIR=notes/ai pi
|
|
|
66
66
|
---
|
|
67
67
|
title: "修复登录重定向死循环"
|
|
68
68
|
agent: "pi"
|
|
69
|
-
format_version: "1.
|
|
69
|
+
format_version: "1.4"
|
|
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
|
+
> **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
|
@@ -54,6 +54,22 @@
|
|
|
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, 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.
|
|
57
73
|
* - Branching: each file records exactly ONE branch (the root→leaf path
|
|
58
74
|
* returned by sessionManager.getBranch()). State is persisted via
|
|
59
75
|
* `pi.appendEntry()` custom entries, which are part of the session tree
|
|
@@ -160,6 +176,7 @@ import * as fsSync from "node:fs";
|
|
|
160
176
|
import * as os from "node:os";
|
|
161
177
|
import * as path from "node:path";
|
|
162
178
|
import { debug } from "./debug.js";
|
|
179
|
+
import { callout, inlineCode, renderUserMessageText, stripInjectedBlocks } from "./markdown.js";
|
|
163
180
|
|
|
164
181
|
const CUSTOM_TYPE = "pi-claudian-auto-save-markdown";
|
|
165
182
|
const ENV_SUBDIR = "PI_SAVE_CONVERSATION_DIR";
|
|
@@ -199,7 +216,7 @@ const SAVE_STATE_SCHEMA = "1.2";
|
|
|
199
216
|
* the frontmatter and the document heading); additive frontmatter fields do
|
|
200
217
|
* NOT bump it — they are invisible to any within-major parser.
|
|
201
218
|
*/
|
|
202
|
-
const FORMAT_VERSION = "1.
|
|
219
|
+
const FORMAT_VERSION = "1.4";
|
|
203
220
|
|
|
204
221
|
/**
|
|
205
222
|
* Package version of this extension, read best-effort from the adjacent
|
|
@@ -888,7 +905,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
888
905
|
|
|
889
906
|
function firstUserText(messages: SessionMessageEntry[]): string | undefined {
|
|
890
907
|
for (const e of messages) {
|
|
891
|
-
if (e.message.role === "user")
|
|
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
|
+
}
|
|
892
914
|
}
|
|
893
915
|
return undefined;
|
|
894
916
|
}
|
|
@@ -932,19 +954,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
932
954
|
|
|
933
955
|
// ---------- markdown rendering ----------
|
|
934
956
|
|
|
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
957
|
/**
|
|
949
958
|
* Strip leading blank lines and trailing whitespace from a rendered block,
|
|
950
959
|
* so joins and separators always keep exactly one blank line around them
|
|
@@ -987,17 +996,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
987
996
|
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(", ");
|
|
988
997
|
}
|
|
989
998
|
|
|
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
999
|
/**
|
|
1002
1000
|
* Fold tool calls and their paired results into one collapsed callout.
|
|
1003
1001
|
* Argument JSON and result previews are wrapped in inline code spans, so
|
|
@@ -1074,7 +1072,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1074
1072
|
const m = e.message;
|
|
1075
1073
|
const t = dateTime(e.timestamp);
|
|
1076
1074
|
if (m.role === "user") {
|
|
1077
|
-
blocks.push(`${messageHeader("User", t)}\n\n${userText(m.content)}`);
|
|
1075
|
+
blocks.push(`${messageHeader("User", t)}\n\n${renderUserMessageText(userText(m.content))}`);
|
|
1078
1076
|
} else if (m.role === "assistant") {
|
|
1079
1077
|
blocks.push(renderAssistant(m, t, results));
|
|
1080
1078
|
} else if (m.role === "toolResult") {
|
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(/</g, "<")
|
|
107
|
+
.replace(/>/g, ">")
|
|
108
|
+
.replace(/"/g, '"')
|
|
109
|
+
.replace(/'/g, "'")
|
|
110
|
+
.replace(/&/g, "&"); // last: "&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.
|
|
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
|
],
|