pi-auto-save-to-markdown 0.7.2 → 0.7.4

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.
Files changed (4) hide show
  1. package/README.md +24 -10
  2. package/README.zh.md +19 -9
  3. package/index.ts +104 -31
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -92,10 +92,10 @@ 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.3"
96
96
  session_id: "d0a4f541-976d-4d1b-8e1c-30a1f2b3c4d5"
97
97
  session_key: "c2088d77"
98
- last_entry_id: "019be3a2-1f4d-7c8a-9b01-d23e45f6a7b8"
98
+ branch_last_entry_id: "019be3a2-1f4d-7c8a-9b01-d23e45f6a7b8"
99
99
  model: "z-ai/glm-5.3"
100
100
  provider: "openrouter"
101
101
  cost: 0.023401
@@ -105,8 +105,8 @@ tokens_output: 3515
105
105
  tokens_cache_read: 0
106
106
  tokens_cache_write: 0
107
107
  messages: 8
108
- created: "2026-08-29T05:05:12.000Z"
109
- updated: "2026-08-29T05:42:10.000Z"
108
+ created: "2026-08-29T13:05:12+08:00"
109
+ updated: "2026-08-29T13:42:10+08:00"
110
110
  project_root: "/Users/me/project"
111
111
  session_file: "~/.pi/agent/sessions/--Users-me-project-20260829-050500_ab12.jsonl"
112
112
  ---
@@ -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,10 +66,10 @@ 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.3"
70
70
  session_id: "d0a4f541-976d-4d1b-8e1c-30a1f2b3c4d5"
71
71
  session_key: "c2088d77"
72
- last_entry_id: "019be3a2-1f4d-7c8a-9b01-d23e45f6a7b8"
72
+ branch_last_entry_id: "019be3a2-1f4d-7c8a-9b01-d23e45f6a7b8"
73
73
  model: "z-ai/glm-5.3"
74
74
  provider: "openrouter"
75
75
  cost: 0.023401
@@ -79,8 +79,8 @@ tokens_output: 3515
79
79
  tokens_cache_read: 0
80
80
  tokens_cache_write: 0
81
81
  messages: 8
82
- created: "2026-08-29T05:05:12.000Z"
83
- updated: "2026-08-29T05:42:10.000Z"
82
+ created: "2026-08-29T13:05:12+08:00"
83
+ updated: "2026-08-29T13:42:10+08:00"
84
84
  project_root: "/Users/me/project"
85
85
  session_file: "~/.pi/agent/sessions/--Users-me-project-20260829-050500_ab12.jsonl"
86
86
  ---
@@ -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
@@ -25,11 +25,14 @@
25
25
  * plugins for other runtimes would write their own value), format_version
26
26
  * (the document format of the last write — additive frontmatter fields
27
27
  * never bump it; see FORMAT_VERSION), session id, session key (the
28
- * filename key), last entry id (the id of the deepest message entry on the
29
- * saved branch the file's position in the session jsonl tree at the last
30
- * write), model, provider, cumulative cost and tokens (input, output,
31
- * cache read/write), message count, created/updated timestamps, project
32
- * root and session file.
28
+ * filename key), branch last entry id (field `branch_last_entry_id` the
29
+ * id of the deepest message entry on the saved branch: the file's position
30
+ * in the session jsonl tree at the last write, scoped to this file's
31
+ * branch, not the session-wide last entry), model, provider, cumulative
32
+ * cost and tokens (input, output,
33
+ * cache read/write), message count, created/updated timestamps (tz-aware
34
+ * ISO 8601 in the local timezone with its numeric UTC offset, e.g.
35
+ * "2026-08-29T13:05:12+08:00"), project root and session file.
33
36
  * - Body format: every message block opens with a setext-H1 info header
34
37
  * (`User <span …>YYYY-MM-DD HH:MM:SS</span>` /
35
38
  * `Assistant <span …>YYYY-MM-DD HH:MM:SS · model</span>`, where the span
@@ -114,7 +117,9 @@
114
117
  * still exports its complete history.
115
118
  * - Thinking repair: reasoning blocks stored with the upstream
116
119
  * newline-fragmentation corruption (one word per line) are detected and
117
- * re-joined into flowing text before saving; clean thinking is untouched.
120
+ * re-joined into flowing text before saving, keeping paragraph breaks
121
+ * where they survive as long separator runs after sentence ends; clean
122
+ * thinking is untouched.
118
123
  *
119
124
  * Manual commands:
120
125
  * - `/save-conversation` saves the current branch immediately and reports
@@ -190,10 +195,11 @@ const SAVE_STATE_SCHEMA = "1.2";
190
195
  * structural breaks that change how a parser or migration tool must match
191
196
  * blocks (message header structure, `---` separators, callout syntax);
192
197
  * 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.
198
+ * transforms like the thinking repair, the blank line once written between
199
+ * the frontmatter and the document heading); additive frontmatter fields do
200
+ * NOT bump it — they are invisible to any within-major parser.
195
201
  */
196
- const FORMAT_VERSION = "1.1";
202
+ const FORMAT_VERSION = "1.3";
197
203
 
198
204
  /**
199
205
  * Package version of this extension, read best-effort from the adjacent
@@ -245,8 +251,8 @@ interface SaveState {
245
251
  /**
246
252
  * Id of the session tree leaf at save time (getLeafId()) — may be a custom
247
253
  * state entry rather than a message; used to rank continuation candidates
248
- * on the current path. Distinct from frontmatter `last_entry_id`, which is
249
- * the deepest message entry (an id actually present in the file).
254
+ * on the current path. Distinct from frontmatter `branch_last_entry_id`,
255
+ * which is the deepest message entry (an id actually present in the file).
250
256
  */
251
257
  lastSavedEntryId: string | null;
252
258
  file: string;
@@ -385,10 +391,11 @@ interface BranchMeta {
385
391
  sessionKey: string;
386
392
  /**
387
393
  * Id of the deepest message entry on the saved branch at the last write
388
- * (field name `last_entry_id`) — the file's exact position in the
389
- * session jsonl tree. Updated on every append, like `updated`.
394
+ * (field name `branch_last_entry_id`) — the file's exact position in the
395
+ * session jsonl tree, scoped to this file's branch rather than the
396
+ * session-wide last entry. Updated on every append, like `updated`.
390
397
  */
391
- lastEntryId: string;
398
+ branchLastEntryId: string;
392
399
  model: string | null;
393
400
  provider: string | null;
394
401
  cost: number;
@@ -416,10 +423,21 @@ interface BranchMeta {
416
423
  * " " lines included), and an excess of 1–2-char non-list-marker lines (CJK
417
424
  * fragments carry no leading space). Clean thinking never matches either.
418
425
  *
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.
426
+ * Repair re-joins the fragments into flowing text. Word separators the
427
+ * whitespace runs between two fragments lose their newlines: run length
428
+ * alone carries no recoverable meaning, because the same word separator
429
+ * appears as 1, 2 or 3 newlines depending on the block. Original paragraph
430
+ * boundaries survive as a faint but strong signal: a separator run of 3+
431
+ * newlines that follows a sentence-final character (closing quotes and
432
+ * brackets skipped when looking) marks a real paragraph break 73–93% of the
433
+ * time in corrupted blocks, while plain word separators sit mid-sentence —
434
+ * so exactly those separators become blank-line paragraph breaks and
435
+ * everything else is joined. The sentence-final guard means a break is never
436
+ * inserted mid-sentence: worst case, one lands between two complete
437
+ * sentences, which still reads fine. Join spacing comes from the separator
438
+ * itself: a separator containing a surviving space joins with one space, a
439
+ * bare one (CJK fragments, attached punctuation) joins with nothing. Clean
440
+ * blocks pass through untouched.
423
441
  */
424
442
 
425
443
  /** Line whose single leading space is a survived word separator. */
@@ -445,14 +463,42 @@ function isFragmentedThinking(s: string): boolean {
445
463
  return nonBlank.filter(isThinkingShortFragment).length / nonBlank.length >= 0.4;
446
464
  }
447
465
 
466
+ /** Sentence-final characters: a long separator run after one may be a paragraph break. */
467
+ const THINKING_SENTENCE_END = /[.!?。!?…]/;
468
+ /** Closing punctuation skipped when looking for the sentence end behind it. */
469
+ const THINKING_CLOSING = /[)\]}"'”』」)】》]/;
470
+ /** Newlines a separator run needs before it can count as a paragraph break. */
471
+ const THINKING_PARAGRAPH_RUN = 3;
472
+
448
473
  /** Repair newline-fragmented thinking; clean thinking is returned unchanged. */
449
474
  function repairThinking(s: string): string {
450
475
  if (!isFragmentedThinking(s)) return s;
451
476
  debug("repairing fragmented thinking block:", s.length, "chars");
452
- return s
453
- .replace(/\n+/g, "")
454
- .replace(/[ \t]{2,}/g, " ")
455
- .trim();
477
+ let out = "";
478
+ let i = 0;
479
+ while (i < s.length) {
480
+ let end = i;
481
+ while (end < s.length && !/\s/.test(s[end])) end++;
482
+ out += s.slice(i, end);
483
+ let next = end;
484
+ while (next < s.length && /\s/.test(s[next])) next++;
485
+ if (next >= s.length) break; // trailing whitespace: drop
486
+ const sep = s.slice(end, next);
487
+ if (!/[\n\r]/.test(sep)) {
488
+ out += " "; // plain spaces: a single word separator
489
+ } else {
490
+ const newlines = sep.match(/[\n\r]/g)!.length;
491
+ let p = out.length - 1;
492
+ while (p >= 0 && THINKING_CLOSING.test(out[p])) p--;
493
+ const sentenceEnd = p >= 0 && THINKING_SENTENCE_END.test(out[p]);
494
+ if (newlines >= THINKING_PARAGRAPH_RUN && sentenceEnd) out += "\n\n";
495
+ else if (/[ \t]/.test(sep)) out += " ";
496
+ // A bare newline separator attached CJK fragments or punctuation:
497
+ // join with nothing.
498
+ }
499
+ i = next;
500
+ }
501
+ return out.replace(/[ \t]{2,}/g, " ").trim();
456
502
  }
457
503
 
458
504
  // ---------- foreign sessions for /save-conversation-all ----------
@@ -1055,6 +1101,24 @@ export default function (pi: ExtensionAPI) {
1055
1101
  return `"${safe.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
1056
1102
  }
1057
1103
 
1104
+ /**
1105
+ * ISO 8601 timestamp in the machine's local timezone, with the numeric
1106
+ * UTC offset appended (e.g. "2026-08-29T13:05:12+08:00"): tz-aware, so the
1107
+ * value reads as local wall-clock time without assuming the reader's
1108
+ * timezone. Legacy files written with UTC "Z" values parse identically.
1109
+ */
1110
+ function localIsoTimestamp(date: Date): string {
1111
+ const pad = (n: number) => String(n).padStart(2, "0");
1112
+ const offsetMin = -date.getTimezoneOffset();
1113
+ const sign = offsetMin >= 0 ? "+" : "-";
1114
+ const abs = Math.abs(offsetMin);
1115
+ return (
1116
+ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
1117
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
1118
+ `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
1119
+ );
1120
+ }
1121
+
1058
1122
  function frontmatter(meta: BranchMeta): string {
1059
1123
  const lines: string[] = ["---"];
1060
1124
  lines.push(`title: ${yamlQuote(meta.title)}`);
@@ -1062,7 +1126,7 @@ export default function (pi: ExtensionAPI) {
1062
1126
  lines.push(`format_version: ${yamlQuote(FORMAT_VERSION)}`);
1063
1127
  if (meta.sessionId) lines.push(`session_id: ${yamlQuote(meta.sessionId)}`);
1064
1128
  lines.push(`session_key: ${yamlQuote(meta.sessionKey)}`);
1065
- lines.push(`last_entry_id: ${yamlQuote(meta.lastEntryId)}`);
1129
+ lines.push(`branch_last_entry_id: ${yamlQuote(meta.branchLastEntryId)}`);
1066
1130
  if (meta.model) lines.push(`model: ${yamlQuote(meta.model)}`);
1067
1131
  if (meta.provider) lines.push(`provider: ${yamlQuote(meta.provider)}`);
1068
1132
  lines.push(`cost: ${meta.cost.toFixed(6)}`);
@@ -1135,14 +1199,14 @@ export default function (pi: ExtensionAPI) {
1135
1199
  tokensCacheWrite += usage.cacheWrite ?? 0;
1136
1200
  }
1137
1201
  }
1138
- const now = new Date().toISOString();
1202
+ const now = localIsoTimestamp(new Date());
1139
1203
  return {
1140
1204
  title: displayTitle(ctx, firstUserText(pathMessages)),
1141
1205
  agent: agent ?? AGENT,
1142
1206
  sessionId: ctx.session.getSessionId(),
1143
1207
  sessionFile: ctx.session.getSessionFile() ?? null,
1144
1208
  sessionKey,
1145
- lastEntryId: pathMessages[pathMessages.length - 1].id,
1209
+ branchLastEntryId: pathMessages[pathMessages.length - 1].id,
1146
1210
  model,
1147
1211
  provider,
1148
1212
  cost,
@@ -1405,7 +1469,14 @@ export default function (pi: ExtensionAPI) {
1405
1469
 
1406
1470
  function replaceFrontmatter(existing: string, fm: string): string {
1407
1471
  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");
1472
+ const rest = existing.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "");
1473
+ // Heal the old layout's blank line(s) between the frontmatter and the
1474
+ // document heading (new files write the heading directly after the
1475
+ // frontmatter). Only blank lines immediately followed by a `#` heading
1476
+ // at the very start of the body are stripped — anything the user
1477
+ // reorganized stays untouched.
1478
+ const healed = rest.replace(/^(?:[ \t]*\r?\n)+(#[^\r\n]*)/, "$1");
1479
+ return fm + "\n" + healed;
1409
1480
  }
1410
1481
  return `${fm}\n\n${existing}`;
1411
1482
  }
@@ -1414,10 +1485,12 @@ export default function (pi: ExtensionAPI) {
1414
1485
  * Rewrite the document heading (the `# title` line written at file
1415
1486
  * creation) to the current title. Only called on rename-on-title saves.
1416
1487
  * 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.
1488
+ * closing delimiter new files write it directly after the frontmatter
1489
+ * with no blank line, legacy files wrote exactly one blank line before it
1490
+ * (replaceFrontmatter heals that away on appends) rather than "the
1491
+ * first # line anywhere", so a manually deleted heading (whose place
1492
+ * would otherwise be taken by some content heading further down) cannot
1493
+ * be mis-rewritten.
1421
1494
  */
1422
1495
  function rewriteDocumentHeading(content: string, title: string): string {
1423
1496
  const fm = /^---\r?\n[\s\S]*?\r?\n---\r?\n/.exec(content);
@@ -1643,7 +1716,7 @@ export default function (pi: ExtensionAPI) {
1643
1716
  const target = path.join(plan.dir, plan.filename);
1644
1717
  const meta = computeMeta(ctx, plan.pathMessages, plan.sessionKey, undefined, undefined);
1645
1718
  const body = renderEntries(plan.pathMessages); // ends with the trailing separator
1646
- const content = `${frontmatter(meta)}\n\n# ${meta.title}\n\n${body}`;
1719
+ const content = `${frontmatter(meta)}\n# ${meta.title}\n\n${body}`;
1647
1720
  await atomicWrite(target, content);
1648
1721
  debug("created conversation file:", target);
1649
1722
  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.4",
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",