pi-auto-save-session-to-markdown 0.10.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/markdown.ts ADDED
@@ -0,0 +1,282 @@
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
+ * Fenced code block for arbitrary raw output (full tool results): the fence
32
+ * is always one backtick longer than the longest backtick run inside the
33
+ * text (and at least three), so content that itself contains backticks
34
+ * cannot break out of the block. Tool output renders literally, whitespace
35
+ * intact, instead of being parsed as markdown.
36
+ */
37
+ export function fencedCode(text: string): string {
38
+ const longest = (text.match(/`+/g) ?? []).reduce((a, r) => Math.max(a, r.length), 0);
39
+ const fence = "`".repeat(Math.max(3, longest + 1));
40
+ return `${fence}\n${text}\n${fence}`;
41
+ }
42
+
43
+ /**
44
+ * Obsidian callout (`> [!type]- title`) wrapping a markdown body: every body
45
+ * line is prefixed with `>` (empty lines become bare `>`), so the body keeps
46
+ * rendering as markdown while folding works in both Obsidian views. Outside
47
+ * Obsidian the callout degrades to a plain blockquote. An empty body renders
48
+ * the title alone.
49
+ */
50
+ export function callout(type: string, title: string, body: string, fold = true): string {
51
+ const header = `> [!${type}]${fold ? "-" : ""} ${title}`;
52
+ if (!body) return header;
53
+ const lines = body.split("\n").map((line) => (line ? `> ${line}` : ">"));
54
+ return `${header}\n${lines.join("\n")}`;
55
+ }
56
+
57
+ // ---------- client-injected prompt blocks ----------
58
+
59
+ /**
60
+ * Injected-block vocabulary, harvested from the Claudian bundle's own
61
+ * block-stripping regexes and from observed sessions. Membership decides
62
+ * visibility only — rendering itself is fully generic (no per-tag format
63
+ * logic):
64
+ *
65
+ * - Visible: user-provided material. Selections of every surface
66
+ * (editor/canvas/browser), the current note, attached files, and note
67
+ * references (linked_note / linked_content — the client's attachment
68
+ * mechanism; a typed @-mention stays as plain text in the message, these
69
+ * are the machine copy).
70
+ * - Hidden: agent-side traces (loaded skills) — rendered as a folded
71
+ * `> [!note]- Skill · <name>` marker with the name riding the title
72
+ * (which skill was loaded is the marker's whole meaning and must survive
73
+ * the collapsed view), the content dropped (a skill is re-loadable from
74
+ * its location).
75
+ *
76
+ * Unknown tags are left verbatim: this is both the safety boundary against
77
+ * mangling pasted XML and a soft failure mode for future client tags (they
78
+ * stay raw until the vocabulary is extended).
79
+ */
80
+ const VISIBLE_TAGS = [
81
+ "editor_selection",
82
+ "editor_cursor",
83
+ "current_note",
84
+ "context_files",
85
+ "canvas_selection",
86
+ "browser_selection",
87
+ "linked_note",
88
+ "linked_content",
89
+ ] as const;
90
+ const HIDDEN_TAGS = new Set<string>(["skill"]);
91
+ const ALL_TAGS = [...VISIBLE_TAGS, ...HIDDEN_TAGS];
92
+
93
+ /**
94
+ * One injected block, in the grammar the client itself uses: a self-closing
95
+ * `<tag attrs/>` or a paired `<tag attrs>content</tag>` (content may be a
96
+ * CDATA section). Attribute values never contain raw `<`, `>` or `"` — the
97
+ * client XML-escapes them — which is what the attribute group relies on.
98
+ */
99
+ const INJECTED_BLOCK_RE = new RegExp(
100
+ `<(${ALL_TAGS.join("|")})\\b((?:\\s[^<>]*?)?)(?:/>|>([\\s\\S]*?)</\\1\\s*>)`,
101
+ "g",
102
+ );
103
+
104
+ /** "editor_selection" → "Editor Selection" — the display title of a tag. */
105
+ function tagTitle(tag: string): string {
106
+ return tag
107
+ .split("_")
108
+ .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
109
+ .join(" ");
110
+ }
111
+
112
+ /** Decode the XML entities the client escapes attribute values with. */
113
+ function decodeEntities(s: string): string {
114
+ return s
115
+ .replace(/&#(\d+);/g, (m, d: string) => {
116
+ try {
117
+ return String.fromCodePoint(Number(d));
118
+ } catch {
119
+ return m;
120
+ }
121
+ })
122
+ .replace(/&lt;/g, "<")
123
+ .replace(/&gt;/g, ">")
124
+ .replace(/&quot;/g, '"')
125
+ .replace(/&apos;/g, "'")
126
+ .replace(/&amp;/g, "&"); // last: "&amp;lt;" must not double-decode
127
+ }
128
+
129
+ /** Attribute pairs of an injected block, in source order. */
130
+ function parseAttrs(attrs: string): { name: string; value: string }[] {
131
+ const out: { name: string; value: string }[] = [];
132
+ for (const m of attrs.matchAll(/([a-zA-Z_][a-zA-Z0-9_.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
133
+ out.push({ name: m[1], value: decodeEntities(m[2] ?? m[3] ?? "") });
134
+ }
135
+ return out;
136
+ }
137
+
138
+ /**
139
+ * Unwrap the client's CDATA wrapper and reverse its `]]>` split-escaping
140
+ * (the client rewrites a literal `]]>` inside a selection as
141
+ * `]]]]><![CDATA[>`). Content without a CDATA wrapper passes through
142
+ * unchanged (linked_note names, raw bodies).
143
+ */
144
+ function unwrapCDATA(content: string): string {
145
+ const m = /^\s*<!\[CDATA\[([\s\S]*)\]\]>\s*$/.exec(content);
146
+ return m ? m[1].replace(/\]\]\]\]><!\[CDATA\[>/g, "]]>") : content;
147
+ }
148
+
149
+ /**
150
+ * Whether a value is a vault-relative note path: no leading slash or home
151
+ * marker, no drive colon or wikilink-hostile characters, and shaped like a
152
+ * note (a folder or a .md extension). Values that fail — absolute
153
+ * filesystem paths, plain names, anything else — stay in code spans.
154
+ */
155
+ function isVaultNotePath(value: string): boolean {
156
+ return (
157
+ value.length > 0 &&
158
+ !/^[~/]/.test(value) &&
159
+ !/[:\r\n[\]|#^<>]/.test(value) &&
160
+ (/\.md$/i.test(value) || value.includes("/"))
161
+ );
162
+ }
163
+
164
+ /** Vault path as a wikilink, aliased to its basename when it has folders. */
165
+ function wikilinkOf(path: string): string {
166
+ const base = path.slice(path.lastIndexOf("/") + 1);
167
+ return base && base !== path ? `[[${path}|${base}]]` : `[[${path}]]`;
168
+ }
169
+
170
+ /** Whether an attribute renders as a bare wikilink (a vault-shaped path/location). */
171
+ function isWikilinkAttr(p: { name: string; value: string }): boolean {
172
+ return (p.name === "path" || p.name === "location") && isVaultNotePath(p.value);
173
+ }
174
+
175
+ /**
176
+ * Attribute pairs of one injected block as body items: a `path`/`location`
177
+ * value shaped like a vault-relative note path becomes a BARE wikilink —
178
+ * no `**name**:` prefix, because the aliased filename is self-explanatory
179
+ * (the file is user-provided material) and the label only adds noise — and
180
+ * sorts first, so the reference leads the line; every other attribute stays
181
+ * a labeled `**name**: value` item in source order.
182
+ */
183
+ function attrItems(attrs: string): string[] {
184
+ const pairs = parseAttrs(attrs);
185
+ return [
186
+ ...pairs.filter(isWikilinkAttr).map((p) => wikilinkOf(p.value)),
187
+ ...pairs.filter((p) => !isWikilinkAttr(p)).map((p) => `**${p.name}**: ${inlineCode(p.value)}`),
188
+ ];
189
+ }
190
+
191
+ /**
192
+ * Hidden block as its own self-contained marker callout: the `name`
193
+ * attribute joins the title — WHICH skill was loaded is the marker's whole
194
+ * meaning, and a collapsed `Skill` alone would say nothing — while the
195
+ * remaining attributes (the location) form the body and the content is
196
+ * dropped (a skill is re-loadable from its location). A hidden block with
197
+ * no attributes at all carries no information and is removed.
198
+ */
199
+ function hiddenCallout(tag: string, attrs: string): string {
200
+ const pairs = parseAttrs(attrs);
201
+ if (pairs.length === 0) return "";
202
+ const name = pairs.find((p) => p.name === "name")?.value;
203
+ const body = pairs
204
+ .filter((p) => p.name !== "name")
205
+ .map((p) => (isWikilinkAttr(p) ? wikilinkOf(p.value) : `**${p.name}**: ${inlineCode(p.value)}`))
206
+ .join(" · ");
207
+ return callout("note", name ? `${tagTitle(tag)} · ${name}` : tagTitle(tag), body);
208
+ }
209
+
210
+ /**
211
+ * One VISIBLE injected block's rendered body (no callout wrapper; hidden
212
+ * blocks render through hiddenCallout): the attribute items, then (after a
213
+ * blank line) the content.
214
+ *
215
+ * linked_note is the one content concession: its whole payload IS a note
216
+ * reference, so content shaped like a vault note path renders as a wikilink
217
+ * too — the same mechanical shape test, no semantics.
218
+ */
219
+ function injectedBlockBody(tag: string, attrs: string, content: string): string {
220
+ const attrLine = attrItems(attrs).join(" · ");
221
+ const text = content ? unwrapCDATA(content).trim() : "";
222
+ const body = tag === "linked_note" && isVaultNotePath(text) ? wikilinkOf(text) : text;
223
+ return attrLine ? (body ? `${attrLine}\n\n${body}` : attrLine) : body;
224
+ }
225
+
226
+ /**
227
+ * User message text for the saved body: every known injected block renders
228
+ * as a preset-collapsed callout — visible blocks as `> [!quote]-`, hidden
229
+ * blocks as a `> [!note]-` marker — and a RUN of visible same-tag blocks
230
+ * with nothing but whitespace between them merges into ONE callout (a user
231
+ * attaching five notes saves one Linked Content callout listing five
232
+ * wikilinks, not five callouts; hidden markers never merge — each names its
233
+ * own skill). Anything else stays verbatim.
234
+ */
235
+ export function renderUserMessageText(text: string): string {
236
+ const re = new RegExp(INJECTED_BLOCK_RE, "g");
237
+ let out = "";
238
+ let pos = 0;
239
+ let runTag: string | null = null;
240
+ const runBodies: string[] = [];
241
+ const flushRun = () => {
242
+ if (runTag === null) return;
243
+ out += callout("quote", tagTitle(runTag), runBodies.join("\n\n"));
244
+ runTag = null;
245
+ runBodies.length = 0;
246
+ };
247
+ let m: RegExpExecArray | null;
248
+ while ((m = re.exec(text)) !== null) {
249
+ const tag = m[1];
250
+ const gap = text.slice(pos, m.index);
251
+ const mergeable = runTag !== null && tag === runTag && gap.trim() === "";
252
+ if (!mergeable) {
253
+ // A different tag, real text between the blocks, or a hidden block
254
+ // (self-contained markers never merge) ends the run; the gap is
255
+ // emitted only then — inside a run it is whitespace.
256
+ flushRun();
257
+ out += gap;
258
+ }
259
+ if (HIDDEN_TAGS.has(tag)) {
260
+ out += hiddenCallout(tag, m[2]);
261
+ } else {
262
+ if (runTag === null) runTag = tag;
263
+ runBodies.push(injectedBlockBody(tag, m[2], m[3] ?? ""));
264
+ }
265
+ pos = re.lastIndex;
266
+ }
267
+ flushRun();
268
+ return out + text.slice(pos);
269
+ }
270
+
271
+ /**
272
+ * The plain typed message: every known injected block removed and the
273
+ * blank lines they leave behind collapsed — this is what title derivation
274
+ * (frontmatter title, document heading, fallback filename slug) reads,
275
+ * mirroring how the client strips the same blocks for its own titles.
276
+ */
277
+ export function stripInjectedBlocks(text: string): string {
278
+ return text
279
+ .replace(INJECTED_BLOCK_RE, "")
280
+ .replace(/\n{3,}/g, "\n\n")
281
+ .trim();
282
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "pi-auto-save-session-to-markdown",
3
+ "version": "0.10.0",
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
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": {
8
+ "name": "Licong Yang",
9
+ "email": "licong.yang@icloud.com",
10
+ "url": "https://github.com/licongy"
11
+ },
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/licongy/pi-claudian.git",
18
+ "directory": "packages/auto-save-session-to-markdown"
19
+ },
20
+ "homepage": "https://github.com/licongy/pi-claudian/tree/master/packages/auto-save-session-to-markdown#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/licongy/pi-claudian/issues"
23
+ },
24
+ "keywords": [
25
+ "pi",
26
+ "pi-package",
27
+ "pi-extension",
28
+ "pi-coding-agent",
29
+ "markdown",
30
+ "conversation",
31
+ "session",
32
+ "export",
33
+ "auto-save",
34
+ "archive"
35
+ ],
36
+ "pi": {
37
+ "extensions": [
38
+ "./index.ts"
39
+ ]
40
+ },
41
+ "files": [
42
+ "index.ts",
43
+ "debug.ts",
44
+ "markdown.ts",
45
+ "README.md",
46
+ "README.zh.md"
47
+ ],
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "peerDependencies": {
52
+ "@earendil-works/pi-coding-agent": ">=0.82.0"
53
+ },
54
+ "devDependencies": {
55
+ "@earendil-works/pi-coding-agent": "^0.82.1",
56
+ "@types/node": "^22.10.0"
57
+ },
58
+ "scripts": {
59
+ "typecheck": "tsc --noEmit"
60
+ }
61
+ }