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 +12 -4
- package/README.zh.md +10 -3
- package/index.ts +66 -39
- package/package.json +1 -1
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 (
|
|
80
|
-
deepest message entry
|
|
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.
|
|
95
|
+
format_version: "1.1"
|
|
95
96
|
session_id: "d0a4f541-976d-4d1b-8e1c-30a1f2b3c4d5"
|
|
96
|
-
|
|
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
|
+

|
|
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
|
|
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.
|
|
69
|
+
format_version: "1.1"
|
|
70
70
|
session_id: "d0a4f541-976d-4d1b-8e1c-30a1f2b3c4d5"
|
|
71
|
-
|
|
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
|
+

|
|
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;
|
|
21
|
-
*
|
|
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,
|
|
27
|
-
* key
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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.
|
|
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.
|
|
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
|
|
235
|
-
* the session
|
|
236
|
-
*
|
|
237
|
-
*
|
|
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.
|
|
281
|
-
s.
|
|
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
|
-
|
|
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 —
|
|
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(`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1207
|
-
//
|
|
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
|
|
1210
|
-
|
|
1211
|
-
|
|
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}-${
|
|
1214
|
-
debug("new branch file:", filename, "
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 —
|
|
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.
|
|
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",
|