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/LICENSE +21 -0
- package/README.md +186 -0
- package/README.zh.md +186 -0
- package/debug.ts +27 -0
- package/index.ts +2192 -0
- package/markdown.ts +282 -0
- package/package.json +61 -0
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(/</g, "<")
|
|
123
|
+
.replace(/>/g, ">")
|
|
124
|
+
.replace(/"/g, '"')
|
|
125
|
+
.replace(/'/g, "'")
|
|
126
|
+
.replace(/&/g, "&"); // last: "&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
|
+
}
|