@pi-claudian/auto-save-to-markdown 0.1.2 → 0.2.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.
Files changed (4) hide show
  1. package/README.md +15 -2
  2. package/README.zh.md +13 -2
  3. package/index.ts +121 -28
  4. package/package.json +1 -1
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,13 @@ 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
+
108
121
  ### Fragmented thinking repair
109
122
 
110
123
  Some upstream reasoning streams (observed with z-ai/GLM via OpenRouter) store
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,19 @@ 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
+
97
108
  ### 碎片化 thinking 修复
98
109
 
99
110
  部分上游推理流(在 z-ai/GLM 经 OpenRouter 的场景中观察到)会把 thinking
package/index.ts CHANGED
@@ -20,6 +20,18 @@
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.
27
+ * - Tool call/result folding: calls live in the assistant entry while their
28
+ * results are separate toolResult entries; saves pair them by toolCall id
29
+ * and fold each assistant block's calls, with a short result preview each,
30
+ * into one `<details><summary>Tool Calls</summary>` section. Previews stay
31
+ * short (500 chars): full content is one Obsidian link away, and oversized
32
+ * details bodies render unfolded in Obsidian. A result whose call was
33
+ * saved in an earlier file (mid-turn manual save) falls back to a
34
+ * standalone one-line block.
23
35
  * - Branching: each file records exactly ONE branch (the root→leaf path
24
36
  * returned by sessionManager.getBranch()). State is persisted via
25
37
  * `pi.appendEntry()` custom entries, which are part of the session tree
@@ -66,7 +78,7 @@ const NOTIFY_TAG = "[AutoSave]";
66
78
 
67
79
  const MAX_TITLE_LENGTH = 60;
68
80
  const TITLE_FALLBACK_LENGTH = 40;
69
- const TOOL_RESULT_PREVIEW = 300;
81
+ const TOOL_RESULT_PREVIEW = 500;
70
82
  const TOOL_ARGS_PREVIEW = 160;
71
83
 
72
84
  type AgentMessage = SessionMessageEntry["message"];
@@ -278,10 +290,76 @@ export default function (pi: ExtensionAPI) {
278
290
 
279
291
  // ---------- markdown rendering ----------
280
292
 
281
- function renderAssistant(m: AssistantMessage, t: string): string {
293
+ /**
294
+ * Strip leading blank lines and trailing whitespace from a rendered block,
295
+ * so joins and separators always keep exactly one blank line around them
296
+ * no matter what blank lines the content itself starts or ends with.
297
+ */
298
+ function tighten(s: string): string {
299
+ return s.replace(/^(?:[ \t]*\n)+/, "").replace(/\s+$/, "");
300
+ }
301
+
302
+ /** One tool call with its paired result preview (null when no result entry exists). */
303
+ interface RenderedToolCall {
304
+ name: string;
305
+ args: string;
306
+ result: string | null;
307
+ }
308
+
309
+ /** Flattened, length-capped result preview with error status suffix. */
310
+ function resultPreview(m: ToolResultMessage): string {
311
+ const texts: string[] = [];
312
+ for (const b of m.content) {
313
+ if (b.type === "text") texts.push(b.text);
314
+ else texts.push(`_[image: ${b.mimeType}]_`);
315
+ }
316
+ const flat = texts.join(" ").replace(/\s+/g, " ").trim();
317
+ const capped =
318
+ flat.length > TOOL_RESULT_PREVIEW ? flat.slice(0, TOOL_RESULT_PREVIEW) + " …" : flat;
319
+ const status = m.isError ? " (error)" : "";
320
+ return `${capped}${status}`.trim();
321
+ }
322
+
323
+ /** Standalone one-line block for a result whose call is not in this file. */
324
+ function renderToolResult(m: ToolResultMessage): string {
325
+ return `> **Tool · ${m.toolName}** ${resultPreview(m)}`.trim();
326
+ }
327
+
328
+ /** "read, web_search ×2" — tool names with repeat counts, first-seen order. */
329
+ function summarizeToolNames(names: string[]): string {
330
+ const counts = new Map<string, number>();
331
+ for (const n of names) counts.set(n, (counts.get(n) ?? 0) + 1);
332
+ return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(", ");
333
+ }
334
+
335
+ /**
336
+ * Fold tool calls and their paired results into one collapsible section.
337
+ * Deliberately compact: Obsidian only collapses a details body it can
338
+ * render in view, so long previews make the section render unfolded.
339
+ */
340
+ function renderToolCallsDetails(calls: RenderedToolCall[]): string {
341
+ const summary = summarizeToolNames(calls.map((c) => c.name));
342
+ const items = calls.map((c) => {
343
+ const head = c.args ? `**\`${c.name}\`** \`${c.args}\`` : `**\`${c.name}\`**`;
344
+ const result = c.result === null ? "_(no result)_" : c.result || "_(empty result)_";
345
+ return `${head}\n\n> ${result}`;
346
+ });
347
+ return (
348
+ `<details>\n<summary>Tool Calls · ${calls.length} (${summary})</summary>\n\n` +
349
+ `${items.join("\n\n")}\n\n</details>`
350
+ );
351
+ }
352
+
353
+ function renderAssistant(
354
+ m: AssistantMessage,
355
+ t: string,
356
+ results: Map<string, ToolResultMessage>,
357
+ ): string {
282
358
  // Render blocks in their original chronological order: thinking always
283
359
  // precedes the text it produced, instead of being grouped after the fact.
284
- const header = `## Assistant · ${t}${m.model ? ` · ${m.model}` : ""}`;
360
+ // Setext H1 (`===` underline): one level above the `##` headings AI
361
+ // content typically starts with, and distinct from content `#` headings.
362
+ const header = `Assistant · ${t}${m.model ? ` · ${m.model}` : ""}\n===`;
285
363
  const parts: string[] = [];
286
364
  const thinkings: string[] = [];
287
365
  const flushThinking = () => {
@@ -292,7 +370,7 @@ export default function (pi: ExtensionAPI) {
292
370
  thinkings.length = 0;
293
371
  }
294
372
  };
295
- const calls: string[] = [];
373
+ const calls: RenderedToolCall[] = [];
296
374
  for (const b of m.content) {
297
375
  if (b.type === "text") {
298
376
  flushThinking();
@@ -301,47 +379,57 @@ export default function (pi: ExtensionAPI) {
301
379
  thinkings.push(repairThinking(b.thinking));
302
380
  } else if (b.type === "toolCall") {
303
381
  flushThinking();
304
- calls.push(`- \`${b.name}\` ${previewArgs(b.arguments)}`);
382
+ const r = results.get(b.id);
383
+ results.delete(b.id);
384
+ calls.push({
385
+ name: b.name,
386
+ args: previewArgs(b.arguments),
387
+ result: r ? resultPreview(r) : null,
388
+ });
305
389
  }
306
390
  }
307
391
  flushThinking();
308
- if (calls.length) parts.push(`**Tool calls**\n\n${calls.join("\n")}`);
392
+ if (calls.length) parts.push(renderToolCallsDetails(calls));
309
393
  if (m.errorMessage) parts.push(`> Error: ${m.errorMessage.replace(/\s+/g, " ").trim()}`);
310
394
  if (parts.length === 0) parts.push("_(empty response)_");
311
395
  return `${header}\n\n${parts.join("\n\n")}`;
312
396
  }
313
397
 
314
- function renderToolResult(m: ToolResultMessage): string {
315
- const texts: string[] = [];
316
- for (const b of m.content) {
317
- if (b.type === "text") texts.push(b.text);
318
- else texts.push(`_[image: ${b.mimeType}]_`);
319
- }
320
- const flat = texts.join(" ").replace(/\s+/g, " ").trim();
321
- const capped =
322
- flat.length > TOOL_RESULT_PREVIEW ? flat.slice(0, TOOL_RESULT_PREVIEW) + " …" : flat;
323
- const status = m.isError ? " (error)" : "";
324
- const line = `> **Tool · ${m.toolName}${status}** ${capped}`.trim();
325
- return line;
326
- }
327
-
328
398
  /** Render a chronological list of message entries as markdown blocks. */
329
399
  function renderEntries(entries: SessionMessageEntry[]): string {
400
+ // Tool calls sit in assistant entries while their results are separate
401
+ // toolResult entries, paired by toolCall id. Collect results first so
402
+ // each assistant block can fold its calls together with their results;
403
+ // results left unclaimed (their call was saved in an earlier file, e.g.
404
+ // a mid-turn manual save) render as standalone blocks.
405
+ const results = new Map<string, ToolResultMessage>();
406
+ for (const e of entries) {
407
+ if (e.message.role === "toolResult") results.set(e.message.toolCallId, e.message);
408
+ }
409
+
330
410
  const blocks: string[] = [];
331
411
  for (const e of entries) {
332
412
  const m = e.message;
333
413
  const t = clock(e.timestamp);
334
414
  if (m.role === "user") {
335
- blocks.push(`## User · ${t}\n\n${userText(m.content)}`);
415
+ blocks.push(`User · ${t}\n===\n\n${userText(m.content)}`);
336
416
  } else if (m.role === "assistant") {
337
- blocks.push(renderAssistant(m, t));
417
+ blocks.push(renderAssistant(m, t, results));
338
418
  } else if (m.role === "toolResult") {
419
+ // Claimed results (deleted from the map by their assistant block)
420
+ // were already folded inline; the rest have no call in this file.
421
+ if (!results.has(m.toolCallId)) continue;
339
422
  blocks.push(renderToolResult(m));
340
423
  }
341
424
  // Other roles (custom, bashExecution, branchSummary, compactionSummary)
342
425
  // are not part of the rendered conversation record.
343
426
  }
344
- return blocks.join("\n\n");
427
+ if (blocks.length === 0) return "";
428
+ // Every block ends with a `---` separator wrapped in single blank lines
429
+ // (the blank line above also keeps `---` from turning the last content
430
+ // line into a setext H2). The trailing separator after the final block
431
+ // makes later appends uniform: new blocks simply continue after it.
432
+ return `${blocks.map(tighten).join("\n\n---\n\n")}\n\n---\n`;
345
433
  }
346
434
 
347
435
  // ---------- frontmatter ----------
@@ -560,8 +648,8 @@ export default function (pi: ExtensionAPI) {
560
648
 
561
649
  if (fullCreate) {
562
650
  const meta = computeMeta(ctx, plan.pathMessages, plan.branchKey, undefined);
563
- const body = renderEntries(plan.pathMessages);
564
- const content = `${frontmatter(meta)}\n\n# ${meta.title}\n\n${body}\n`;
651
+ const body = renderEntries(plan.pathMessages); // ends with the trailing separator
652
+ const content = `${frontmatter(meta)}\n\n# ${meta.title}\n\n${body}`;
565
653
  await atomicWrite(filePath, content);
566
654
  debug("created conversation file:", filePath);
567
655
  return {
@@ -584,10 +672,15 @@ export default function (pi: ExtensionAPI) {
584
672
 
585
673
  const existing = await fs.readFile(filePath, "utf-8");
586
674
  const meta = computeMeta(ctx, plan.pathMessages, plan.branchKey, parseCreated(existing));
587
- const appended = renderEntries(plan.appendEntries);
675
+ const appended = renderEntries(plan.appendEntries); // ends with the trailing separator
588
676
  let updated = replaceFrontmatter(existing, frontmatter(meta));
589
- if (!updated.endsWith("\n")) updated += "\n";
590
- updated += `\n${appended}\n`;
677
+ // Collapse trailing blank lines to a single newline so the separator
678
+ // always has exactly one blank line above it, whatever earlier saves
679
+ // (or a manual edit) left behind.
680
+ updated = updated.replace(/\s*$/, "\n");
681
+ // Files written by the old format end without a `---` separator; add one
682
+ // at the boundary so old and new content stay delimited.
683
+ updated += updated.endsWith("---\n") ? `\n${appended}` : `\n---\n\n${appended}`;
591
684
  await atomicWrite(filePath, updated);
592
685
  debug("appended", plan.appendEntries.length, "entries to:", filePath);
593
686
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-claudian/auto-save-to-markdown",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
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",