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

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
@@ -76,8 +76,9 @@ Filename: `<title>-<key>-<time>.md`
76
76
  when the session is unnamed
77
77
  - `<key>` — the first 8 hex of the SHA-256 of the session id: every file of
78
78
  one session shares it, so a session's files cluster in the directory across
79
- recoveries and resumes (files created by older versions carry the id of the
80
- deepest message entry at file creation instead)
79
+ recoveries and resumes (when no session id exists yet the degenerate
80
+ fallback — the deepest message entry's id is hashed the same way, so the
81
+ key is always an opaque 8-hex cluster key)
81
82
  - `<time>` — local file-creation time, `YYYYMMDD-HHmmss`
82
83
 
83
84
  When the session's real name arrives after the file was created (e.g.
@@ -91,9 +92,10 @@ changes never touch the filename, and manually renamed files are left alone.
91
92
  ---
92
93
  title: "Fix login redirect loop"
93
94
  agent: "pi"
94
- format_version: "1.0"
95
+ format_version: "1.1"
95
96
  session_id: "d0a4f541-976d-4d1b-8e1c-30a1f2b3c4d5"
96
- tree: "c2088d77"
97
+ session_key: "c2088d77"
98
+ last_entry_id: "019be3a2-1f4d-7c8a-9b01-d23e45f6a7b8"
97
99
  model: "z-ai/glm-5.3"
98
100
  provider: "openrouter"
99
101
  cost: 0.023401
@@ -156,6 +158,12 @@ remain a glance away. Each block ends with a `---` separator wrapped in single
156
158
  blank lines (extra blank lines are trimmed), so blocks are easy to tell apart
157
159
  both when reading and when splitting the file programmatically.
158
160
 
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:
164
+
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)
166
+
159
167
  ### Fragmented thinking repair
160
168
 
161
169
  Some upstream reasoning streams (observed with z-ai/GLM via OpenRouter) store
package/README.zh.md CHANGED
@@ -57,7 +57,7 @@ PI_SAVE_CONVERSATION_DIR=notes/ai pi
57
57
  文件名:`<标题>-<key>-<时间>.md`
58
58
 
59
59
  - `<标题>` — 会话名称(`/name`);未命名时取第一条用户消息的摘要
60
- - `<key>` — session id 的 SHA-256 前 8 位十六进制:同一会话的所有文件相同,恢复、重启后仍天然聚簇(旧版创建的文件为建文件时分支上最深一条消息的 entry id
60
+ - `<key>` — session id 的 SHA-256 前 8 位十六进制:同一会话的所有文件相同,恢复、重启后仍天然聚簇(session id 尚不存在时,改以同样方式哈希分支上最深一条消息的 entry id——key 始终是 8 位十六进制的不透明聚簇键)
61
61
  - `<时间>` — 建文件的本地时间,格式 `YYYYMMDD-HHmmss`
62
62
 
63
63
  会话的真实名称在建文件之后才到达时(如 Claudian 在首轮回复后才生成标题),下一次保存会把文件一次性改名为 `<名称>-<key>-<原时间戳>.md`(保留原创建时间戳),并同步改写 frontmatter 标题与正文标题。改名至多发生一次:之后的 `/name` 改名不再影响文件名,手动整理过的文件名也不会被动。
@@ -66,9 +66,10 @@ PI_SAVE_CONVERSATION_DIR=notes/ai pi
66
66
  ---
67
67
  title: "修复登录重定向死循环"
68
68
  agent: "pi"
69
- format_version: "1.0"
69
+ format_version: "1.1"
70
70
  session_id: "d0a4f541-976d-4d1b-8e1c-30a1f2b3c4d5"
71
- tree: "c2088d77"
71
+ session_key: "c2088d77"
72
+ last_entry_id: "019be3a2-1f4d-7c8a-9b01-d23e45f6a7b8"
72
73
  model: "z-ai/glm-5.3"
73
74
  provider: "openrouter"
74
75
  cost: 0.023401
@@ -126,6 +127,12 @@ Markdown 解析。
126
127
  醒目,细节又触手可及。每个消息块以"上下各一个空行"包裹的 `---` 分隔线结尾
127
128
  (多余空行会被裁剪),无论是阅读还是程序化切分,都能清楚地区分每个消息块。
128
129
 
130
+ 保存的文件在 Obsidian 中的渲染效果——顶部为 `<标题>-<key>-<时间>.md`
131
+ 文件名,frontmatter 折叠在 Properties 面板中,Thinking 与 Tool Calls
132
+ 两个 callout 处于折叠状态:
133
+
134
+ ![保存的对话文件在 Obsidian 中的渲染效果:文件名呈"标题-key-时间"格式,frontmatter 折叠在 Properties 面板中,消息块带角色信息头和时间戳,Thinking 与 Tool Calls callout 处于折叠状态](https://raw.githubusercontent.com/licongy/pi-claudian/master/packages/auto-save-to-markdown/screenshot.png)
135
+
129
136
  ### 碎片化 thinking 修复
130
137
 
131
138
  部分上游推理流(在 z-ai/GLM 经 OpenRouter 的场景中观察到)会把 thinking
package/index.ts CHANGED
@@ -17,17 +17,19 @@
17
17
  * (or a slug of the first user message when unnamed), <key> is the first
18
18
  * 8 hex of the SHA-256 of the session id (the same value for every file
19
19
  * of one session, so a session's files cluster in the archive directory
20
- * across recoveries and resumes; files created before this scheme carry
21
- * the 8-hex id of the deepest message entry at their creation), and
20
+ * across recoveries and resumes; when no session id exists yet — the
21
+ * degenerate fallback the deepest message entry's id is hashed the
22
+ * same way, so the key is always an opaque 8-hex cluster key), and
22
23
  * <time> is the local file-creation timestamp (YYYYMMDD-HHmmss).
23
24
  * - Frontmatter: title, agent (generator identity, always "pi" here — agent
24
25
  * plugins for other runtimes would write their own value), format_version
25
26
  * (the document format of the last write — additive frontmatter fields
26
- * never bump it; see FORMAT_VERSION), session id, tree (the filename
27
- * key the session-id hash for new files, the legacy branch-tip id for
28
- * files created before it), model, provider, cumulative cost and tokens
29
- * (input, output, cache read/write), message count, created/updated
30
- * timestamps, project root and session file.
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.
31
33
  * - Body format: every message block opens with a setext-H1 info header
32
34
  * (`User <span …>YYYY-MM-DD HH:MM:SS</span>` /
33
35
  * `Assistant <span …>YYYY-MM-DD HH:MM:SS · model</span>`, where the span
@@ -177,7 +179,7 @@ const AGENT = "pi";
177
179
  * Bump MINOR when adding optional state fields, MAJOR when changing existing
178
180
  * state semantics.
179
181
  */
180
- const SAVE_STATE_SCHEMA = "1.1";
182
+ const SAVE_STATE_SCHEMA = "1.2";
181
183
 
182
184
  /**
183
185
  * Version of the markdown document format this code writes ("MAJOR.MINOR",
@@ -191,7 +193,7 @@ const SAVE_STATE_SCHEMA = "1.1";
191
193
  * transforms like the thinking repair); additive frontmatter fields do NOT
192
194
  * bump it — they are invisible to any within-major parser.
193
195
  */
194
- const FORMAT_VERSION = "1.0";
196
+ const FORMAT_VERSION = "1.1";
195
197
 
196
198
  /**
197
199
  * Package version of this extension, read best-effort from the adjacent
@@ -231,14 +233,21 @@ type ToolResultMessage = Extract<AgentMessage, { role: "toolResult" }>;
231
233
  */
232
234
  interface SaveState {
233
235
  /**
234
- * Key segment of the filename this state addresses. Entries written since
235
- * the session-hash scheme record the first 8 hex of the SHA-256 of the
236
- * session id (stable per session); legacy entries carry the 8-hex id of the
237
- * deepest message entry at file creation. Only ever used as the recorded
236
+ * Key segment of the filename this state addresses: the first 8 hex of the
237
+ * SHA-256 of the session id stable per session, so files of one session
238
+ * cluster together (when no session id exists yet, the deepest message
239
+ * entry's id is hashed the same way). Only ever used as the recorded
238
240
  * value — files are addressed by their full recorded name, never recomputed
239
- * from the key.
241
+ * from the key. Renamed from branchKey at schema 1.2: legacy entries fail
242
+ * validation and are ignored — those branches get fresh files.
243
+ */
244
+ sessionKey: string;
245
+ /**
246
+ * Id of the session tree leaf at save time (getLeafId()) — may be a custom
247
+ * 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).
240
250
  */
241
- branchKey: string;
242
251
  lastSavedEntryId: string | null;
243
252
  file: string;
244
253
  /** Save-state schema version ("MAJOR.MINOR") of the writer. */
@@ -277,8 +286,8 @@ function isSaveState(v: unknown): v is SaveState {
277
286
  if (typeof v !== "object" || v === null) return false;
278
287
  const s = v as Record<string, unknown>;
279
288
  return (
280
- typeof s.branchKey === "string" &&
281
- s.branchKey.length > 0 &&
289
+ typeof s.sessionKey === "string" &&
290
+ s.sessionKey.length > 0 &&
282
291
  (s.lastSavedEntryId === null || typeof s.lastSavedEntryId === "string") &&
283
292
  typeof s.file === "string" &&
284
293
  s.file.length > 0
@@ -366,7 +375,20 @@ interface BranchMeta {
366
375
  agent: string;
367
376
  sessionId: string | null;
368
377
  sessionFile: string | null;
369
- tree: string;
378
+ /**
379
+ * The filename key, mirrored into the frontmatter so the file is
380
+ * self-describing (field name `session_key`): the first 8 hex of the
381
+ * SHA-256 of the session id, or of the deepest message entry's id when
382
+ * no session id exists yet. A display/grouping key only — never used to
383
+ * address files (states record full filenames).
384
+ */
385
+ sessionKey: string;
386
+ /**
387
+ * 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`.
390
+ */
391
+ lastEntryId: string;
370
392
  model: string | null;
371
393
  provider: string | null;
372
394
  cost: number;
@@ -584,7 +606,7 @@ class DiskSessionView implements SaveContext {
584
606
  // _persist does; a concurrent writer interleaves at line granularity
585
607
  // at worst, and every reader skips bad lines.
586
608
  await fs.appendFile(this.file, JSON.stringify(entry) + "\n", "utf-8");
587
- debug(this.tag(), "recorded state entry — branchKey:", state.branchKey, "file:", state.file);
609
+ debug(this.tag(), "recorded state entry — sessionKey:", state.sessionKey, "file:", state.file);
588
610
  }
589
611
 
590
612
  /** pi's generateId semantics: random 8-hex, collision-checked against the session's ids. */
@@ -1039,7 +1061,8 @@ export default function (pi: ExtensionAPI) {
1039
1061
  lines.push(`agent: ${yamlQuote(meta.agent)}`);
1040
1062
  lines.push(`format_version: ${yamlQuote(FORMAT_VERSION)}`);
1041
1063
  if (meta.sessionId) lines.push(`session_id: ${yamlQuote(meta.sessionId)}`);
1042
- lines.push(`tree: ${yamlQuote(meta.tree)}`);
1064
+ lines.push(`session_key: ${yamlQuote(meta.sessionKey)}`);
1065
+ lines.push(`last_entry_id: ${yamlQuote(meta.lastEntryId)}`);
1043
1066
  if (meta.model) lines.push(`model: ${yamlQuote(meta.model)}`);
1044
1067
  if (meta.provider) lines.push(`provider: ${yamlQuote(meta.provider)}`);
1045
1068
  lines.push(`cost: ${meta.cost.toFixed(6)}`);
@@ -1086,7 +1109,7 @@ export default function (pi: ExtensionAPI) {
1086
1109
  function computeMeta(
1087
1110
  ctx: SaveContext,
1088
1111
  pathMessages: SessionMessageEntry[],
1089
- branchKey: string,
1112
+ sessionKey: string,
1090
1113
  created: string | undefined,
1091
1114
  agent: string | undefined,
1092
1115
  ): BranchMeta {
@@ -1118,7 +1141,8 @@ export default function (pi: ExtensionAPI) {
1118
1141
  agent: agent ?? AGENT,
1119
1142
  sessionId: ctx.session.getSessionId(),
1120
1143
  sessionFile: ctx.session.getSessionFile() ?? null,
1121
- tree: branchKey,
1144
+ sessionKey,
1145
+ lastEntryId: pathMessages[pathMessages.length - 1].id,
1122
1146
  model,
1123
1147
  provider,
1124
1148
  cost,
@@ -1138,7 +1162,7 @@ export default function (pi: ExtensionAPI) {
1138
1162
  interface SavePlan {
1139
1163
  dir: string;
1140
1164
  filename: string;
1141
- branchKey: string;
1165
+ sessionKey: string;
1142
1166
  /** Write the full branch content (new branch, or target file missing). */
1143
1167
  fullCreate: boolean;
1144
1168
  /** Entries to append when continuing an existing file. */
@@ -1203,19 +1227,22 @@ export default function (pi: ExtensionAPI) {
1203
1227
  // of the session id — stable per session, so every file of one session
1204
1228
  // clusters together across recoveries and resumes. Never truncate the id
1205
1229
  // itself: pi session ids are uuidv7 whose leading hex is a millisecond
1206
- // timestamp, so same-month sessions share long prefixes. The tip snapshot
1207
- // is only a degenerate fallback (no session id yet).
1230
+ // timestamp, so same-month sessions share long prefixes. When no session
1231
+ // id exists yet — the degenerate fallback the deepest message entry's
1232
+ // id is hashed the same way, so the key is always an opaque 8-hex cluster
1233
+ // key, never a raw entry id.
1208
1234
  const sessionId = ctx.session.getSessionId();
1209
- const branchKey = sessionId
1210
- ? createHash("sha256").update(sessionId).digest("hex").slice(0, 8)
1211
- : pathMessages[pathMessages.length - 1].id;
1235
+ const sessionKey = createHash("sha256")
1236
+ .update(sessionId ?? pathMessages[pathMessages.length - 1].id)
1237
+ .digest("hex")
1238
+ .slice(0, 8);
1212
1239
  const title = titleForFilename(ctx, firstUserText(pathMessages));
1213
- const filename = `${title}-${branchKey}-${fileTimestamp(new Date())}.md`;
1214
- debug("new branch file:", filename, "branchKey:", branchKey);
1240
+ const filename = `${title}-${sessionKey}-${fileTimestamp(new Date())}.md`;
1241
+ debug("new branch file:", filename, "sessionKey:", sessionKey);
1215
1242
  return {
1216
1243
  dir,
1217
1244
  filename,
1218
- branchKey,
1245
+ sessionKey,
1219
1246
  fullCreate: true,
1220
1247
  appendEntries: [],
1221
1248
  pathMessages,
@@ -1272,7 +1299,7 @@ export default function (pi: ExtensionAPI) {
1272
1299
  /**
1273
1300
  * Decide which file the current branch belongs to and what to write.
1274
1301
  *
1275
- * Every save appends a custom entry recording {branchKey, lastSavedEntryId,
1302
+ * Every save appends a custom entry recording {sessionKey, lastSavedEntryId,
1276
1303
  * file, schema}. Those entries live in the session tree, so a branch's own
1277
1304
  * latest state is always recoverable — including after resume, /tree
1278
1305
  * navigation, or /fork. State discovery is disk-first (see
@@ -1429,7 +1456,7 @@ export default function (pi: ExtensionAPI) {
1429
1456
  state: SaveState,
1430
1457
  pathMessages: SessionMessageEntry[],
1431
1458
  ): { renameTo: string | null; titled: boolean } {
1432
- const keySuffix = "-" + state.branchKey;
1459
+ const keySuffix = "-" + state.sessionKey;
1433
1460
  // The trailing group tolerates (and drops) the -1, -2 … suffixes that
1434
1461
  // claimFilename may have added, so a suffixed file keeps its rename
1435
1462
  // eligibility; the suffix is not carried into the deterministic target.
@@ -1457,7 +1484,7 @@ export default function (pi: ExtensionAPI) {
1457
1484
  if (!newTitle || !ts || titleSegment === null || newTitle === titleSegment) {
1458
1485
  return { renameTo: null, titled: false };
1459
1486
  }
1460
- const renameTo = `${newTitle}-${state.branchKey}-${ts}.md`;
1487
+ const renameTo = `${newTitle}-${state.sessionKey}-${ts}.md`;
1461
1488
  debug("rename-on-title planned:", state.file, "→", renameTo);
1462
1489
  return { renameTo, titled: false };
1463
1490
  }
@@ -1559,7 +1586,7 @@ export default function (pi: ExtensionAPI) {
1559
1586
  return {
1560
1587
  dir: input.dir,
1561
1588
  filename: c.state.file,
1562
- branchKey: c.state.branchKey,
1589
+ sessionKey: c.state.sessionKey,
1563
1590
  fullCreate: false,
1564
1591
  appendEntries,
1565
1592
  pathMessages: input.pathMessages,
@@ -1614,7 +1641,7 @@ export default function (pi: ExtensionAPI) {
1614
1641
  }
1615
1642
  plan.filename = claimed;
1616
1643
  const target = path.join(plan.dir, plan.filename);
1617
- const meta = computeMeta(ctx, plan.pathMessages, plan.branchKey, undefined, undefined);
1644
+ const meta = computeMeta(ctx, plan.pathMessages, plan.sessionKey, undefined, undefined);
1618
1645
  const body = renderEntries(plan.pathMessages); // ends with the trailing separator
1619
1646
  const content = `${frontmatter(meta)}\n\n# ${meta.title}\n\n${body}`;
1620
1647
  await atomicWrite(target, content);
@@ -1649,7 +1676,7 @@ export default function (pi: ExtensionAPI) {
1649
1676
  const meta = computeMeta(
1650
1677
  ctx,
1651
1678
  plan.pathMessages,
1652
- plan.branchKey,
1679
+ plan.sessionKey,
1653
1680
  parseCreated(existing),
1654
1681
  parseAgent(existing),
1655
1682
  );
@@ -1757,14 +1784,14 @@ export default function (pi: ExtensionAPI) {
1757
1784
  await applyRename(ctx, plan, result);
1758
1785
  const leafId = ctx.session.getLeafId();
1759
1786
  await ctx.appendState({
1760
- branchKey: plan.branchKey,
1787
+ sessionKey: plan.sessionKey,
1761
1788
  lastSavedEntryId: leafId,
1762
1789
  file: plan.filename,
1763
1790
  schema: SAVE_STATE_SCHEMA,
1764
1791
  extVersion: EXTENSION_VERSION ?? undefined,
1765
1792
  titled: plan.titled,
1766
1793
  });
1767
- debug("recorded state entry — branchKey:", plan.branchKey, "leaf:", leafId);
1794
+ debug("recorded state entry — sessionKey:", plan.sessionKey, "leaf:", leafId);
1768
1795
  }
1769
1796
  return result;
1770
1797
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-auto-save-to-markdown",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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",