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 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.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
- A saved file rendered in Obsidian — the `<title>-<key>-<time>.md` filename on
162
- top, the frontmatter folded into the Properties panel, and the Thinking and
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
- ![A saved conversation file rendered in Obsidian: filename in the title-key-time pattern, frontmatter folded into the Properties panel, message blocks with role headers and timestamps, and collapsed Thinking and Tool Calls callouts](https://raw.githubusercontent.com/licongy/pi-claudian/master/packages/auto-save-to-markdown/screenshot.png)
168
+ ![A saved conversation file rendered in Obsidian with the Properties panel expanded: filename in the title-key-time pattern, all frontmatter fields visible as properties (title, agent, format version, session id, cost, tokens, timestamps, project root, session file), and the beginning of the message body](https://raw.githubusercontent.com/licongy/pi-claudian/master/packages/auto-save-to-markdown/screenshot-1.png)
169
+
170
+ Then with the Properties panel folded away and the full conversation body in
171
+ view:
172
+
173
+ ![A saved conversation file rendered in Obsidian: filename in the title-key-time pattern, frontmatter folded into the Properties panel, message blocks with role headers and timestamps, and collapsed Thinking and Tool Calls callouts](https://raw.githubusercontent.com/licongy/pi-claudian/master/packages/auto-save-to-markdown/screenshot-2.png)
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. Clean thinking blocks are written untouched.
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.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
- 保存的文件在 Obsidian 中的渲染效果——顶部为 `<标题>-<key>-<时间>.md`
131
- 文件名,frontmatter 折叠在 Properties 面板中,Thinking 与 Tool Calls
132
- 两个 callout 处于折叠状态:
132
+ 同一个保存文件在 Obsidian 中的两种渲染视图——顶部为
133
+ `<标题>-<key>-<时间>.md` 文件名,消息块带角色信息头和时间戳,Thinking
134
+ 与 Tool Calls 两个 callout 处于折叠状态。首先是 Properties 面板展开、
135
+ 展示全部 frontmatter 字段的效果:
133
136
 
134
- ![保存的对话文件在 Obsidian 中的渲染效果:文件名呈"标题-key-时间"格式,frontmatter 折叠在 Properties 面板中,消息块带角色信息头和时间戳,Thinking 与 Tool Calls callout 处于折叠状态](https://raw.githubusercontent.com/licongy/pi-claudian/master/packages/auto-save-to-markdown/screenshot.png)
137
+ ![保存的对话文件在 Obsidian 中渲染、Properties 面板展开的效果:文件名呈"标题-key-时间"格式,全部 frontmatter 字段以属性形式可见(title、agent、format_version、session_id、cost、tokens、时间戳、project_root、session_file),下方为消息正文开头](https://raw.githubusercontent.com/licongy/pi-claudian/master/packages/auto-save-to-markdown/screenshot-1.png)
138
+
139
+ 然后是 Properties 面板折叠、完整对话正文的效果:
140
+
141
+ ![保存的对话文件在 Obsidian 中的渲染效果:文件名呈"标题-key-时间"格式,frontmatter 折叠在 Properties 面板中,消息块带角色信息头和时间戳,Thinking 与 Tool Calls callout 处于折叠状态](https://raw.githubusercontent.com/licongy/pi-claudian/master/packages/auto-save-to-markdown/screenshot-2.png)
135
142
 
136
143
  ### 碎片化 thinking 修复
137
144
 
@@ -139,7 +146,10 @@ Markdown 解析。
139
146
  存成一词一行、甚至一字一行:原始空格塌缩成碎片行开头的单个空格,碎片之间
140
147
  被成串的换行拼接。扩展会检测这种损坏(依据带单个前导空格的行、或大量
141
148
  1–2 字符碎片行),把碎片重新接回通顺的文本,保存的 thinking 不再一行
142
- 一词。正常的 thinking 块原样保存,不做任何改动。
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; clean thinking is untouched.
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); additive frontmatter fields do NOT
194
- * bump it they are invisible to any within-major parser.
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.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 strips all newlines run length carries no recoverable meaning (the
420
- * same paragraph boundary appears as 1, 2 or 3 newlines, while 4–7 can sit
421
- * mid-sentence) and collapses the doubled spaces left by lone-space
422
- * fragments. Clean blocks pass through untouched.
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
- return s
453
- .replace(/\n+/g, "")
454
- .replace(/[ \t]{2,}/g, " ")
455
- .trim();
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
- return existing.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, fm + "\n");
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 (file creation writes exactly one blank line before
1418
- * it) rather than "the first # line anywhere", so a manually deleted
1419
- * heading (whose place would otherwise be taken by some content heading
1420
- * further down) cannot be mis-rewritten.
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\n# ${meta.title}\n\n${body}`;
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.2",
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",