pi-auto-save-to-markdown 0.7.2 → 0.7.3
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 +21 -7
- package/README.zh.md +16 -6
- package/index.ts +69 -18
- package/package.json +1 -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.2"
|
|
96
96
|
session_id: "d0a4f541-976d-4d1b-8e1c-30a1f2b3c4d5"
|
|
97
97
|
session_key: "c2088d77"
|
|
98
98
|
last_entry_id: "019be3a2-1f4d-7c8a-9b01-d23e45f6a7b8"
|
|
@@ -156,13 +156,21 @@ assistant messages) sits in a small faint `<span>` (`0.5em`, Obsidian's
|
|
|
156
156
|
`--text-faint` color), so the role stays visually dominant while the details
|
|
157
157
|
remain a glance away. Each block ends with a `---` separator wrapped in single
|
|
158
158
|
blank lines (extra blank lines are trimmed), so blocks are easy to tell apart
|
|
159
|
-
both when reading and when splitting the file programmatically.
|
|
159
|
+
both when reading and when splitting the file programmatically. The document
|
|
160
|
+
heading sits directly after the frontmatter with no blank line between them;
|
|
161
|
+
appends heal the blank line that older versions wrote there.
|
|
160
162
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
Tool Calls callouts collapsed
|
|
163
|
+
Two views of the same saved file rendered in Obsidian — the
|
|
164
|
+
`<title>-<key>-<time>.md` filename on top, message blocks with role headers
|
|
165
|
+
and timestamps, and the Thinking and Tool Calls callouts collapsed. First
|
|
166
|
+
with the Properties panel expanded, showing all frontmatter fields:
|
|
164
167
|
|
|
165
|
-

|
|
169
|
+
|
|
170
|
+
Then with the Properties panel folded away and the full conversation body in
|
|
171
|
+
view:
|
|
172
|
+
|
|
173
|
+

|
|
166
174
|
|
|
167
175
|
### Fragmented thinking repair
|
|
168
176
|
|
|
@@ -172,7 +180,13 @@ original spaces collapse into leading spaces of one-word fragments joined by
|
|
|
172
180
|
runs of newlines. The extension detects this corruption (lines starting with a
|
|
173
181
|
single leading space, or a majority of 1–2-character fragment lines) and
|
|
174
182
|
re-joins the fragments into flowing text, so saved thinking reads normally
|
|
175
|
-
instead of one word per line.
|
|
183
|
+
instead of one word per line. Paragraph breaks survive the repair: a separator
|
|
184
|
+
run of 3+ newlines that follows a sentence-final character is a real paragraph
|
|
185
|
+
break about three times out of four in corrupted blocks, so exactly those
|
|
186
|
+
separators are restored to blank-line paragraphs while every other separator
|
|
187
|
+
joins — a break is never inserted mid-sentence; worst case, one lands between
|
|
188
|
+
two complete sentences, which still reads fine. Clean thinking blocks are
|
|
189
|
+
written untouched.
|
|
176
190
|
|
|
177
191
|
`cost` and the token fields cover the whole saved branch and include cached
|
|
178
192
|
tokens (priced at the provider's cache rates), so the totals are comparable
|
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.2"
|
|
70
70
|
session_id: "d0a4f541-976d-4d1b-8e1c-30a1f2b3c4d5"
|
|
71
71
|
session_key: "c2088d77"
|
|
72
72
|
last_entry_id: "019be3a2-1f4d-7c8a-9b01-d23e45f6a7b8"
|
|
@@ -126,12 +126,19 @@ Markdown 解析。
|
|
|
126
126
|
浅色 `<span>` 中(`0.5em`,Obsidian 的 `--text-faint` 颜色),角色名因此保持
|
|
127
127
|
醒目,细节又触手可及。每个消息块以"上下各一个空行"包裹的 `---` 分隔线结尾
|
|
128
128
|
(多余空行会被裁剪),无论是阅读还是程序化切分,都能清楚地区分每个消息块。
|
|
129
|
+
文档标题紧跟在 frontmatter 之后,中间没有空行;追加保存时会顺带修复旧版本
|
|
130
|
+
在两者之间写下的空行。
|
|
129
131
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
两个 callout
|
|
132
|
+
同一个保存文件在 Obsidian 中的两种渲染视图——顶部为
|
|
133
|
+
`<标题>-<key>-<时间>.md` 文件名,消息块带角色信息头和时间戳,Thinking
|
|
134
|
+
与 Tool Calls 两个 callout 处于折叠状态。首先是 Properties 面板展开、
|
|
135
|
+
展示全部 frontmatter 字段的效果:
|
|
133
136
|
|
|
134
|
-

|
|
138
|
+
|
|
139
|
+
然后是 Properties 面板折叠、完整对话正文的效果:
|
|
140
|
+
|
|
141
|
+

|
|
135
142
|
|
|
136
143
|
### 碎片化 thinking 修复
|
|
137
144
|
|
|
@@ -139,7 +146,10 @@ Markdown 解析。
|
|
|
139
146
|
存成一词一行、甚至一字一行:原始空格塌缩成碎片行开头的单个空格,碎片之间
|
|
140
147
|
被成串的换行拼接。扩展会检测这种损坏(依据带单个前导空格的行、或大量
|
|
141
148
|
1–2 字符碎片行),把碎片重新接回通顺的文本,保存的 thinking 不再一行
|
|
142
|
-
|
|
149
|
+
一词。段落分隔在修复后得以保留:句末标点之后紧跟 3 个以上换行的分隔串,
|
|
150
|
+
在损坏块中约四分之三是真实的段落边界,因此恰好这类分隔被还原成空行段落,
|
|
151
|
+
其余全部拼接——断行永远不会插进句子中间,最坏也只是落在两个完整句子
|
|
152
|
+
之间,阅读不受影响。正常的 thinking 块原样保存,不做任何改动。
|
|
143
153
|
|
|
144
154
|
`cost` 和 token 字段统计整条已保存分支,且包含缓存 token(按供应商缓存
|
|
145
155
|
价格计费),因此总计可与供应商侧账单(如 OpenRouter Activity)对照。未
|
package/index.ts
CHANGED
|
@@ -114,7 +114,9 @@
|
|
|
114
114
|
* still exports its complete history.
|
|
115
115
|
* - Thinking repair: reasoning blocks stored with the upstream
|
|
116
116
|
* newline-fragmentation corruption (one word per line) are detected and
|
|
117
|
-
* re-joined into flowing text before saving
|
|
117
|
+
* re-joined into flowing text before saving, keeping paragraph breaks
|
|
118
|
+
* where they survive as long separator runs after sentence ends; clean
|
|
119
|
+
* thinking is untouched.
|
|
118
120
|
*
|
|
119
121
|
* Manual commands:
|
|
120
122
|
* - `/save-conversation` saves the current branch immediately and reports
|
|
@@ -190,10 +192,11 @@ const SAVE_STATE_SCHEMA = "1.2";
|
|
|
190
192
|
* structural breaks that change how a parser or migration tool must match
|
|
191
193
|
* blocks (message header structure, `---` separators, callout syntax);
|
|
192
194
|
* MINOR for parse-invariant tweaks and bugfixes (header styling, content
|
|
193
|
-
* transforms like the thinking repair
|
|
194
|
-
*
|
|
195
|
+
* transforms like the thinking repair, the blank line once written between
|
|
196
|
+
* the frontmatter and the document heading); additive frontmatter fields do
|
|
197
|
+
* NOT bump it — they are invisible to any within-major parser.
|
|
195
198
|
*/
|
|
196
|
-
const FORMAT_VERSION = "1.
|
|
199
|
+
const FORMAT_VERSION = "1.2";
|
|
197
200
|
|
|
198
201
|
/**
|
|
199
202
|
* Package version of this extension, read best-effort from the adjacent
|
|
@@ -416,10 +419,21 @@ interface BranchMeta {
|
|
|
416
419
|
* " " lines included), and an excess of 1–2-char non-list-marker lines (CJK
|
|
417
420
|
* fragments carry no leading space). Clean thinking never matches either.
|
|
418
421
|
*
|
|
419
|
-
* Repair
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
422
|
+
* Repair re-joins the fragments into flowing text. Word separators — the
|
|
423
|
+
* whitespace runs between two fragments — lose their newlines: run length
|
|
424
|
+
* alone carries no recoverable meaning, because the same word separator
|
|
425
|
+
* appears as 1, 2 or 3 newlines depending on the block. Original paragraph
|
|
426
|
+
* boundaries survive as a faint but strong signal: a separator run of 3+
|
|
427
|
+
* newlines that follows a sentence-final character (closing quotes and
|
|
428
|
+
* brackets skipped when looking) marks a real paragraph break 73–93% of the
|
|
429
|
+
* time in corrupted blocks, while plain word separators sit mid-sentence —
|
|
430
|
+
* so exactly those separators become blank-line paragraph breaks and
|
|
431
|
+
* everything else is joined. The sentence-final guard means a break is never
|
|
432
|
+
* inserted mid-sentence: worst case, one lands between two complete
|
|
433
|
+
* sentences, which still reads fine. Join spacing comes from the separator
|
|
434
|
+
* itself: a separator containing a surviving space joins with one space, a
|
|
435
|
+
* bare one (CJK fragments, attached punctuation) joins with nothing. Clean
|
|
436
|
+
* blocks pass through untouched.
|
|
423
437
|
*/
|
|
424
438
|
|
|
425
439
|
/** Line whose single leading space is a survived word separator. */
|
|
@@ -445,14 +459,42 @@ function isFragmentedThinking(s: string): boolean {
|
|
|
445
459
|
return nonBlank.filter(isThinkingShortFragment).length / nonBlank.length >= 0.4;
|
|
446
460
|
}
|
|
447
461
|
|
|
462
|
+
/** Sentence-final characters: a long separator run after one may be a paragraph break. */
|
|
463
|
+
const THINKING_SENTENCE_END = /[.!?。!?…]/;
|
|
464
|
+
/** Closing punctuation skipped when looking for the sentence end behind it. */
|
|
465
|
+
const THINKING_CLOSING = /[)\]}"'”』」)】》]/;
|
|
466
|
+
/** Newlines a separator run needs before it can count as a paragraph break. */
|
|
467
|
+
const THINKING_PARAGRAPH_RUN = 3;
|
|
468
|
+
|
|
448
469
|
/** Repair newline-fragmented thinking; clean thinking is returned unchanged. */
|
|
449
470
|
function repairThinking(s: string): string {
|
|
450
471
|
if (!isFragmentedThinking(s)) return s;
|
|
451
472
|
debug("repairing fragmented thinking block:", s.length, "chars");
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
473
|
+
let out = "";
|
|
474
|
+
let i = 0;
|
|
475
|
+
while (i < s.length) {
|
|
476
|
+
let end = i;
|
|
477
|
+
while (end < s.length && !/\s/.test(s[end])) end++;
|
|
478
|
+
out += s.slice(i, end);
|
|
479
|
+
let next = end;
|
|
480
|
+
while (next < s.length && /\s/.test(s[next])) next++;
|
|
481
|
+
if (next >= s.length) break; // trailing whitespace: drop
|
|
482
|
+
const sep = s.slice(end, next);
|
|
483
|
+
if (!/[\n\r]/.test(sep)) {
|
|
484
|
+
out += " "; // plain spaces: a single word separator
|
|
485
|
+
} else {
|
|
486
|
+
const newlines = sep.match(/[\n\r]/g)!.length;
|
|
487
|
+
let p = out.length - 1;
|
|
488
|
+
while (p >= 0 && THINKING_CLOSING.test(out[p])) p--;
|
|
489
|
+
const sentenceEnd = p >= 0 && THINKING_SENTENCE_END.test(out[p]);
|
|
490
|
+
if (newlines >= THINKING_PARAGRAPH_RUN && sentenceEnd) out += "\n\n";
|
|
491
|
+
else if (/[ \t]/.test(sep)) out += " ";
|
|
492
|
+
// A bare newline separator attached CJK fragments or punctuation:
|
|
493
|
+
// join with nothing.
|
|
494
|
+
}
|
|
495
|
+
i = next;
|
|
496
|
+
}
|
|
497
|
+
return out.replace(/[ \t]{2,}/g, " ").trim();
|
|
456
498
|
}
|
|
457
499
|
|
|
458
500
|
// ---------- foreign sessions for /save-conversation-all ----------
|
|
@@ -1405,7 +1447,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
1405
1447
|
|
|
1406
1448
|
function replaceFrontmatter(existing: string, fm: string): string {
|
|
1407
1449
|
if (/^---\r?\n[\s\S]*?\r?\n---\r?\n/.test(existing)) {
|
|
1408
|
-
|
|
1450
|
+
const rest = existing.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "");
|
|
1451
|
+
// Heal the old layout's blank line(s) between the frontmatter and the
|
|
1452
|
+
// document heading (new files write the heading directly after the
|
|
1453
|
+
// frontmatter). Only blank lines immediately followed by a `#` heading
|
|
1454
|
+
// at the very start of the body are stripped — anything the user
|
|
1455
|
+
// reorganized stays untouched.
|
|
1456
|
+
const healed = rest.replace(/^(?:[ \t]*\r?\n)+(#[^\r\n]*)/, "$1");
|
|
1457
|
+
return fm + "\n" + healed;
|
|
1409
1458
|
}
|
|
1410
1459
|
return `${fm}\n\n${existing}`;
|
|
1411
1460
|
}
|
|
@@ -1414,10 +1463,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1414
1463
|
* Rewrite the document heading (the `# title` line written at file
|
|
1415
1464
|
* creation) to the current title. Only called on rename-on-title saves.
|
|
1416
1465
|
* The heading is located as the first non-blank line after the frontmatter
|
|
1417
|
-
* closing delimiter
|
|
1418
|
-
*
|
|
1419
|
-
*
|
|
1420
|
-
*
|
|
1466
|
+
* closing delimiter — new files write it directly after the frontmatter
|
|
1467
|
+
* with no blank line, legacy files wrote exactly one blank line before it
|
|
1468
|
+
* (replaceFrontmatter heals that away on appends) — rather than "the
|
|
1469
|
+
* first # line anywhere", so a manually deleted heading (whose place
|
|
1470
|
+
* would otherwise be taken by some content heading further down) cannot
|
|
1471
|
+
* be mis-rewritten.
|
|
1421
1472
|
*/
|
|
1422
1473
|
function rewriteDocumentHeading(content: string, title: string): string {
|
|
1423
1474
|
const fm = /^---\r?\n[\s\S]*?\r?\n---\r?\n/.exec(content);
|
|
@@ -1643,7 +1694,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1643
1694
|
const target = path.join(plan.dir, plan.filename);
|
|
1644
1695
|
const meta = computeMeta(ctx, plan.pathMessages, plan.sessionKey, undefined, undefined);
|
|
1645
1696
|
const body = renderEntries(plan.pathMessages); // ends with the trailing separator
|
|
1646
|
-
const content = `${frontmatter(meta)}\n
|
|
1697
|
+
const content = `${frontmatter(meta)}\n# ${meta.title}\n\n${body}`;
|
|
1647
1698
|
await atomicWrite(target, content);
|
|
1648
1699
|
debug("created conversation file:", target);
|
|
1649
1700
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-auto-save-to-markdown",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
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",
|