@pi-claudian/auto-save-to-markdown 0.1.1 → 0.1.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
@@ -78,11 +78,15 @@ session_file: "~/.pi/agent/sessions/--Users-me-project-20260829-050500_ab12.json
78
78
 
79
79
  # Fix login redirect loop
80
80
 
81
- ## User · 13:05:12
81
+ User · 13:05:12
82
+ ===
82
83
 
83
84
  The login page redirects in a loop after the auth refactor...
84
85
 
85
- ## Assistant · 13:05:40 · claude-sonnet-4-5
86
+ ---
87
+
88
+ Assistant · 13:05:40 · claude-sonnet-4-5
89
+ ===
86
90
 
87
91
  <details>
88
92
  <summary>Thinking</summary>
@@ -98,6 +102,8 @@ I'll trace the middleware order first.
98
102
  - `read` — {"filePath":"/Users/me/project/src/auth/middleware.ts"}
99
103
 
100
104
  > **Tool · read** /Users/me/project/src/auth/middleware.ts — 120 lines …
105
+
106
+ ---
101
107
  ```
102
108
 
103
109
  The body renders user and assistant messages in full (assistant thinking is
@@ -105,6 +111,23 @@ kept in a collapsible `<details>` block) and summarizes each tool call and
105
111
  result in one line, so the file stays readable while still showing what the
106
112
  agent did.
107
113
 
114
+ Each message block opens with a setext level-1 info header (`User · …`,
115
+ underlined with `===`) — one level above the `##` headings AI content
116
+ typically starts with, and distinguishable from content `#` headings when
117
+ parsing — and ends with a `---` separator wrapped in single blank lines
118
+ (extra blank lines are trimmed), so blocks are easy to tell apart both when
119
+ reading and when splitting the file programmatically.
120
+
121
+ ### Fragmented thinking repair
122
+
123
+ Some upstream reasoning streams (observed with z-ai/GLM via OpenRouter) store
124
+ thinking with every word — or every CJK character — on its own line: the
125
+ original spaces collapse into leading spaces of one-word fragments joined by
126
+ runs of newlines. The extension detects this corruption (lines starting with a
127
+ single leading space, or a majority of 1–2-character fragment lines) and
128
+ re-joins the fragments into flowing text, so saved thinking reads normally
129
+ instead of one word per line. Clean thinking blocks are written untouched.
130
+
108
131
  `cost` and the token fields cover the whole saved branch and include cached
109
132
  tokens (priced at the provider's cache rates), so the totals are comparable
110
133
  with provider-side accounting (e.g. OpenRouter activity). Requests that never
package/README.zh.md CHANGED
@@ -68,11 +68,15 @@ session_file: "~/.pi/agent/sessions/--Users-me-project-20260829-050500_ab12.json
68
68
 
69
69
  # 修复登录重定向死循环
70
70
 
71
- ## User · 13:05:12
71
+ User · 13:05:12
72
+ ===
72
73
 
73
74
  auth 重构之后登录页一直重定向死循环……
74
75
 
75
- ## Assistant · 13:05:40 · claude-sonnet-4-5
76
+ ---
77
+
78
+ Assistant · 13:05:40 · claude-sonnet-4-5
79
+ ===
76
80
 
77
81
  <details>
78
82
  <summary>Thinking</summary>
@@ -88,12 +92,27 @@ auth 重构之后登录页一直重定向死循环……
88
92
  - `read` — {"filePath":"/Users/me/project/src/auth/middleware.ts"}
89
93
 
90
94
  > **Tool · read** /Users/me/project/src/auth/middleware.ts — 120 lines …
95
+
96
+ ---
91
97
  ```
92
98
 
93
99
  正文完整渲染 user / assistant 消息(assistant 的 thinking 放在可折叠的
94
100
  `<details>` 块中),每个工具调用和结果各压缩成一行摘要,既可读又能看出
95
101
  agent 做了什么。
96
102
 
103
+ 每个消息块以 setext 一级信息头(`User · …`,下一行以 `===` 下划)开头——
104
+ 高于 AI 内容常见的 `##` 二级标题,解析时也能与内容中的 `#` 一级标题区分
105
+ 开——并以"上下各一个空行"包裹的 `---` 分隔线结尾(多余空行会被裁剪),
106
+ 无论是阅读还是程序化切分,都能清楚地区分每个消息块。
107
+
108
+ ### 碎片化 thinking 修复
109
+
110
+ 部分上游推理流(在 z-ai/GLM 经 OpenRouter 的场景中观察到)会把 thinking
111
+ 存成一词一行、甚至一字一行:原始空格塌缩成碎片行开头的单个空格,碎片之间
112
+ 被成串的换行拼接。扩展会检测这种损坏(依据带单个前导空格的行、或大量
113
+ 1–2 字符碎片行),把碎片重新接回通顺的文本,保存的 thinking 不再一行
114
+ 一词。正常的 thinking 块原样保存,不做任何改动。
115
+
97
116
  `cost` 和 token 字段统计整条已保存分支,且包含缓存 token(按供应商缓存
98
117
  价格计费),因此总计可与供应商侧账单(如 OpenRouter Activity)对照。未
99
118
  进入会话树的请求(失败重试、共用同一 API key 的其他会话)不在其中。
package/index.ts CHANGED
@@ -20,6 +20,10 @@
20
20
  * - Frontmatter: title, session id, tree (branch key), model, provider,
21
21
  * cumulative cost and tokens (input, output, cache read/write), message
22
22
  * count, created/updated timestamps, project root and session file.
23
+ * - Body format: every message block opens with a setext-H1 info header
24
+ * (`User · HH:MM:SS` / `Assistant · HH:MM:SS · model`, underlined with
25
+ * `===`, distinct from the `#`/`##` ATX headings AI content uses) and
26
+ * ends with a `---` separator wrapped in single blank lines.
23
27
  * - Branching: each file records exactly ONE branch (the root→leaf path
24
28
  * returned by sessionManager.getBranch()). State is persisted via
25
29
  * `pi.appendEntry()` custom entries, which are part of the session tree
@@ -32,6 +36,9 @@
32
36
  * - Compaction: files archive the ORIGINAL messages (getBranch() returns the
33
37
  * raw tree path, not the compaction-aware context), so a compacted session
34
38
  * still exports its complete history.
39
+ * - Thinking repair: reasoning blocks stored with the upstream
40
+ * newline-fragmentation corruption (one word per line) are detected and
41
+ * re-joined into flowing text before saving; clean thinking is untouched.
35
42
  *
36
43
  * Manual command: `/save-conversation` saves the current branch immediately
37
44
  * and reports the file path.
@@ -122,6 +129,59 @@ interface BranchMeta {
122
129
  projectRoot: string;
123
130
  }
124
131
 
132
+ // ---------- thinking fragmentation repair ----------
133
+
134
+ /**
135
+ * Some upstream reasoning streams (observed with z-ai/GLM via OpenRouter) store
136
+ * thinking as one word — or one CJK character — per line: the stream splits
137
+ * tokens into fragments joined by runs of newlines, and the original spaces
138
+ * survive only as leading spaces of the fragments. The saved markdown then has
139
+ * every token on its own line, which is miserable to read and bloats storage.
140
+ *
141
+ * Detection uses two signatures validated against ~520 real thinking blocks:
142
+ * lines starting with exactly one space (a survived word separator; blank-ish
143
+ * " " lines included), and an excess of 1–2-char non-list-marker lines (CJK
144
+ * fragments carry no leading space). Clean thinking never matches either.
145
+ *
146
+ * Repair strips all newlines — run length carries no recoverable meaning (the
147
+ * same paragraph boundary appears as 1, 2 or 3 newlines, while 4–7 can sit
148
+ * mid-sentence) — and collapses the doubled spaces left by lone-space
149
+ * fragments. Clean blocks pass through untouched.
150
+ */
151
+
152
+ /** Line whose single leading space is a survived word separator. */
153
+ function isThinkingSigLine(line: string): boolean {
154
+ return line === " " || /^ [^ *+\-\d]/.test(line);
155
+ }
156
+
157
+ /** Non-blank line of 1–2 chars that is not a standalone list marker. */
158
+ function isThinkingShortFragment(line: string): boolean {
159
+ const s = line.trim();
160
+ if (s.length === 0 || s.length > 2) return false;
161
+ return !/^([-*+]|\d+[.)])$/.test(s);
162
+ }
163
+
164
+ /** Whether a thinking block shows the newline-fragmentation corruption. */
165
+ function isFragmentedThinking(s: string): boolean {
166
+ const lines = s.split("\n");
167
+ const nonBlank = lines.filter((l) => l.trim().length > 0);
168
+ if (nonBlank.length === 0) return false;
169
+ const sig = lines.filter(isThinkingSigLine).length;
170
+ if (nonBlank.length < 8) return nonBlank.length >= 3 && sig >= 3;
171
+ if (sig / lines.length >= 0.12) return true;
172
+ return nonBlank.filter(isThinkingShortFragment).length / nonBlank.length >= 0.4;
173
+ }
174
+
175
+ /** Repair newline-fragmented thinking; clean thinking is returned unchanged. */
176
+ function repairThinking(s: string): string {
177
+ if (!isFragmentedThinking(s)) return s;
178
+ debug("repairing fragmented thinking block:", s.length, "chars");
179
+ return s
180
+ .replace(/\n+/g, "")
181
+ .replace(/[ \t]{2,}/g, " ")
182
+ .trim();
183
+ }
184
+
125
185
  export default function (pi: ExtensionAPI) {
126
186
  /**
127
187
  * Resolve the target directory. The env var may hold a relative folder name
@@ -222,10 +282,21 @@ export default function (pi: ExtensionAPI) {
222
282
 
223
283
  // ---------- markdown rendering ----------
224
284
 
285
+ /**
286
+ * Strip leading blank lines and trailing whitespace from a rendered block,
287
+ * so joins and separators always keep exactly one blank line around them
288
+ * no matter what blank lines the content itself starts or ends with.
289
+ */
290
+ function tighten(s: string): string {
291
+ return s.replace(/^(?:[ \t]*\n)+/, "").replace(/\s+$/, "");
292
+ }
293
+
225
294
  function renderAssistant(m: AssistantMessage, t: string): string {
226
295
  // Render blocks in their original chronological order: thinking always
227
296
  // precedes the text it produced, instead of being grouped after the fact.
228
- const header = `## Assistant · ${t}${m.model ? ` · ${m.model}` : ""}`;
297
+ // Setext H1 (`===` underline): one level above the `##` headings AI
298
+ // content typically starts with, and distinct from content `#` headings.
299
+ const header = `Assistant · ${t}${m.model ? ` · ${m.model}` : ""}\n===`;
229
300
  const parts: string[] = [];
230
301
  const thinkings: string[] = [];
231
302
  const flushThinking = () => {
@@ -242,7 +313,7 @@ export default function (pi: ExtensionAPI) {
242
313
  flushThinking();
243
314
  parts.push(b.text);
244
315
  } else if (b.type === "thinking") {
245
- thinkings.push(b.thinking);
316
+ thinkings.push(repairThinking(b.thinking));
246
317
  } else if (b.type === "toolCall") {
247
318
  flushThinking();
248
319
  calls.push(`- \`${b.name}\` — ${previewArgs(b.arguments)}`);
@@ -276,7 +347,7 @@ export default function (pi: ExtensionAPI) {
276
347
  const m = e.message;
277
348
  const t = clock(e.timestamp);
278
349
  if (m.role === "user") {
279
- blocks.push(`## User · ${t}\n\n${userText(m.content)}`);
350
+ blocks.push(`User · ${t}\n===\n\n${userText(m.content)}`);
280
351
  } else if (m.role === "assistant") {
281
352
  blocks.push(renderAssistant(m, t));
282
353
  } else if (m.role === "toolResult") {
@@ -285,7 +356,12 @@ export default function (pi: ExtensionAPI) {
285
356
  // Other roles (custom, bashExecution, branchSummary, compactionSummary)
286
357
  // are not part of the rendered conversation record.
287
358
  }
288
- return blocks.join("\n\n");
359
+ if (blocks.length === 0) return "";
360
+ // Every block ends with a `---` separator wrapped in single blank lines
361
+ // (the blank line above also keeps `---` from turning the last content
362
+ // line into a setext H2). The trailing separator after the final block
363
+ // makes later appends uniform: new blocks simply continue after it.
364
+ return `${blocks.map(tighten).join("\n\n---\n\n")}\n\n---\n`;
289
365
  }
290
366
 
291
367
  // ---------- frontmatter ----------
@@ -504,8 +580,8 @@ export default function (pi: ExtensionAPI) {
504
580
 
505
581
  if (fullCreate) {
506
582
  const meta = computeMeta(ctx, plan.pathMessages, plan.branchKey, undefined);
507
- const body = renderEntries(plan.pathMessages);
508
- const content = `${frontmatter(meta)}\n\n# ${meta.title}\n\n${body}\n`;
583
+ const body = renderEntries(plan.pathMessages); // ends with the trailing separator
584
+ const content = `${frontmatter(meta)}\n\n# ${meta.title}\n\n${body}`;
509
585
  await atomicWrite(filePath, content);
510
586
  debug("created conversation file:", filePath);
511
587
  return {
@@ -528,10 +604,15 @@ export default function (pi: ExtensionAPI) {
528
604
 
529
605
  const existing = await fs.readFile(filePath, "utf-8");
530
606
  const meta = computeMeta(ctx, plan.pathMessages, plan.branchKey, parseCreated(existing));
531
- const appended = renderEntries(plan.appendEntries);
607
+ const appended = renderEntries(plan.appendEntries); // ends with the trailing separator
532
608
  let updated = replaceFrontmatter(existing, frontmatter(meta));
533
- if (!updated.endsWith("\n")) updated += "\n";
534
- updated += `\n${appended}\n`;
609
+ // Collapse trailing blank lines to a single newline so the separator
610
+ // always has exactly one blank line above it, whatever earlier saves
611
+ // (or a manual edit) left behind.
612
+ updated = updated.replace(/\s*$/, "\n");
613
+ // Files written by the old format end without a `---` separator; add one
614
+ // at the boundary so old and new content stay delimited.
615
+ updated += updated.endsWith("---\n") ? `\n${appended}` : `\n---\n\n${appended}`;
535
616
  await atomicWrite(filePath, updated);
536
617
  debug("appended", plan.appendEntries.length, "entries to:", filePath);
537
618
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-claudian/auto-save-to-markdown",
3
- "version": "0.1.1",
3
+ "version": "0.1.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",