@pi-claudian/auto-save-to-markdown 0.1.0 → 0.1.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 +19 -1
- package/README.zh.md +15 -1
- package/index.ts +104 -26
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,10 +67,12 @@ cost: 0.023401
|
|
|
67
67
|
tokens: 18745
|
|
68
68
|
tokens_input: 15230
|
|
69
69
|
tokens_output: 3515
|
|
70
|
+
tokens_cache_read: 0
|
|
71
|
+
tokens_cache_write: 0
|
|
70
72
|
messages: 8
|
|
71
73
|
created: "2026-08-29T05:05:12.000Z"
|
|
72
74
|
updated: "2026-08-29T05:42:10.000Z"
|
|
73
|
-
|
|
75
|
+
project_root: "/Users/me/project"
|
|
74
76
|
session_file: "~/.pi/agent/sessions/--Users-me-project-20260829-050500_ab12.jsonl"
|
|
75
77
|
---
|
|
76
78
|
|
|
@@ -103,6 +105,22 @@ kept in a collapsible `<details>` block) and summarizes each tool call and
|
|
|
103
105
|
result in one line, so the file stays readable while still showing what the
|
|
104
106
|
agent did.
|
|
105
107
|
|
|
108
|
+
### Fragmented thinking repair
|
|
109
|
+
|
|
110
|
+
Some upstream reasoning streams (observed with z-ai/GLM via OpenRouter) store
|
|
111
|
+
thinking with every word — or every CJK character — on its own line: the
|
|
112
|
+
original spaces collapse into leading spaces of one-word fragments joined by
|
|
113
|
+
runs of newlines. The extension detects this corruption (lines starting with a
|
|
114
|
+
single leading space, or a majority of 1–2-character fragment lines) and
|
|
115
|
+
re-joins the fragments into flowing text, so saved thinking reads normally
|
|
116
|
+
instead of one word per line. Clean thinking blocks are written untouched.
|
|
117
|
+
|
|
118
|
+
`cost` and the token fields cover the whole saved branch and include cached
|
|
119
|
+
tokens (priced at the provider's cache rates), so the totals are comparable
|
|
120
|
+
with provider-side accounting (e.g. OpenRouter activity). Requests that never
|
|
121
|
+
landed in the session tree (failed retries, other sessions sharing the same
|
|
122
|
+
API key) are necessarily excluded.
|
|
123
|
+
|
|
106
124
|
## Branch behavior
|
|
107
125
|
|
|
108
126
|
Pi sessions are trees: `/tree` navigates to an earlier point and a new prompt
|
package/README.zh.md
CHANGED
|
@@ -57,10 +57,12 @@ cost: 0.023401
|
|
|
57
57
|
tokens: 18745
|
|
58
58
|
tokens_input: 15230
|
|
59
59
|
tokens_output: 3515
|
|
60
|
+
tokens_cache_read: 0
|
|
61
|
+
tokens_cache_write: 0
|
|
60
62
|
messages: 8
|
|
61
63
|
created: "2026-08-29T05:05:12.000Z"
|
|
62
64
|
updated: "2026-08-29T05:42:10.000Z"
|
|
63
|
-
|
|
65
|
+
project_root: "/Users/me/project"
|
|
64
66
|
session_file: "~/.pi/agent/sessions/--Users-me-project-20260829-050500_ab12.jsonl"
|
|
65
67
|
---
|
|
66
68
|
|
|
@@ -92,6 +94,18 @@ auth 重构之后登录页一直重定向死循环……
|
|
|
92
94
|
`<details>` 块中),每个工具调用和结果各压缩成一行摘要,既可读又能看出
|
|
93
95
|
agent 做了什么。
|
|
94
96
|
|
|
97
|
+
### 碎片化 thinking 修复
|
|
98
|
+
|
|
99
|
+
部分上游推理流(在 z-ai/GLM 经 OpenRouter 的场景中观察到)会把 thinking
|
|
100
|
+
存成一词一行、甚至一字一行:原始空格塌缩成碎片行开头的单个空格,碎片之间
|
|
101
|
+
被成串的换行拼接。扩展会检测这种损坏(依据带单个前导空格的行、或大量
|
|
102
|
+
1–2 字符碎片行),把碎片重新接回通顺的文本,保存的 thinking 不再一行
|
|
103
|
+
一词。正常的 thinking 块原样保存,不做任何改动。
|
|
104
|
+
|
|
105
|
+
`cost` 和 token 字段统计整条已保存分支,且包含缓存 token(按供应商缓存
|
|
106
|
+
价格计费),因此总计可与供应商侧账单(如 OpenRouter Activity)对照。未
|
|
107
|
+
进入会话树的请求(失败重试、共用同一 API key 的其他会话)不在其中。
|
|
108
|
+
|
|
95
109
|
## 分支行为
|
|
96
110
|
|
|
97
111
|
Pi 会话是树:`/tree` 导航到更早的位置后再提问就分出新的分支。每个
|
package/index.ts
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* of the deepest message entry at file creation, and <time> is the local
|
|
19
19
|
* file-creation timestamp (YYYYMMDD-HHmmss).
|
|
20
20
|
* - Frontmatter: title, session id, tree (branch key), model, provider,
|
|
21
|
-
* cumulative cost and tokens,
|
|
22
|
-
*
|
|
21
|
+
* cumulative cost and tokens (input, output, cache read/write), message
|
|
22
|
+
* count, created/updated timestamps, project root and session file.
|
|
23
23
|
* - Branching: each file records exactly ONE branch (the root→leaf path
|
|
24
24
|
* returned by sessionManager.getBranch()). State is persisted via
|
|
25
25
|
* `pi.appendEntry()` custom entries, which are part of the session tree
|
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
* - Compaction: files archive the ORIGINAL messages (getBranch() returns the
|
|
33
33
|
* raw tree path, not the compaction-aware context), so a compacted session
|
|
34
34
|
* still exports its complete history.
|
|
35
|
+
* - Thinking repair: reasoning blocks stored with the upstream
|
|
36
|
+
* newline-fragmentation corruption (one word per line) are detected and
|
|
37
|
+
* re-joined into flowing text before saving; clean thinking is untouched.
|
|
35
38
|
*
|
|
36
39
|
* Manual command: `/save-conversation` saves the current branch immediately
|
|
37
40
|
* and reports the file path.
|
|
@@ -114,10 +117,65 @@ interface BranchMeta {
|
|
|
114
117
|
cost: number;
|
|
115
118
|
tokensInput: number;
|
|
116
119
|
tokensOutput: number;
|
|
120
|
+
tokensCacheRead: number;
|
|
121
|
+
tokensCacheWrite: number;
|
|
117
122
|
messages: number;
|
|
118
123
|
created: string;
|
|
119
124
|
updated: string;
|
|
120
|
-
|
|
125
|
+
projectRoot: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------- thinking fragmentation repair ----------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Some upstream reasoning streams (observed with z-ai/GLM via OpenRouter) store
|
|
132
|
+
* thinking as one word — or one CJK character — per line: the stream splits
|
|
133
|
+
* tokens into fragments joined by runs of newlines, and the original spaces
|
|
134
|
+
* survive only as leading spaces of the fragments. The saved markdown then has
|
|
135
|
+
* every token on its own line, which is miserable to read and bloats storage.
|
|
136
|
+
*
|
|
137
|
+
* Detection uses two signatures validated against ~520 real thinking blocks:
|
|
138
|
+
* lines starting with exactly one space (a survived word separator; blank-ish
|
|
139
|
+
* " " lines included), and an excess of 1–2-char non-list-marker lines (CJK
|
|
140
|
+
* fragments carry no leading space). Clean thinking never matches either.
|
|
141
|
+
*
|
|
142
|
+
* Repair strips all newlines — run length carries no recoverable meaning (the
|
|
143
|
+
* same paragraph boundary appears as 1, 2 or 3 newlines, while 4–7 can sit
|
|
144
|
+
* mid-sentence) — and collapses the doubled spaces left by lone-space
|
|
145
|
+
* fragments. Clean blocks pass through untouched.
|
|
146
|
+
*/
|
|
147
|
+
|
|
148
|
+
/** Line whose single leading space is a survived word separator. */
|
|
149
|
+
function isThinkingSigLine(line: string): boolean {
|
|
150
|
+
return line === " " || /^ [^ *+\-\d]/.test(line);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Non-blank line of 1–2 chars that is not a standalone list marker. */
|
|
154
|
+
function isThinkingShortFragment(line: string): boolean {
|
|
155
|
+
const s = line.trim();
|
|
156
|
+
if (s.length === 0 || s.length > 2) return false;
|
|
157
|
+
return !/^([-*+]|\d+[.)])$/.test(s);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Whether a thinking block shows the newline-fragmentation corruption. */
|
|
161
|
+
function isFragmentedThinking(s: string): boolean {
|
|
162
|
+
const lines = s.split("\n");
|
|
163
|
+
const nonBlank = lines.filter((l) => l.trim().length > 0);
|
|
164
|
+
if (nonBlank.length === 0) return false;
|
|
165
|
+
const sig = lines.filter(isThinkingSigLine).length;
|
|
166
|
+
if (nonBlank.length < 8) return nonBlank.length >= 3 && sig >= 3;
|
|
167
|
+
if (sig / lines.length >= 0.12) return true;
|
|
168
|
+
return nonBlank.filter(isThinkingShortFragment).length / nonBlank.length >= 0.4;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Repair newline-fragmented thinking; clean thinking is returned unchanged. */
|
|
172
|
+
function repairThinking(s: string): string {
|
|
173
|
+
if (!isFragmentedThinking(s)) return s;
|
|
174
|
+
debug("repairing fragmented thinking block:", s.length, "chars");
|
|
175
|
+
return s
|
|
176
|
+
.replace(/\n+/g, "")
|
|
177
|
+
.replace(/[ \t]{2,}/g, " ")
|
|
178
|
+
.trim();
|
|
121
179
|
}
|
|
122
180
|
|
|
123
181
|
export default function (pi: ExtensionAPI) {
|
|
@@ -221,23 +279,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
221
279
|
// ---------- markdown rendering ----------
|
|
222
280
|
|
|
223
281
|
function renderAssistant(m: AssistantMessage, t: string): string {
|
|
224
|
-
|
|
282
|
+
// Render blocks in their original chronological order: thinking always
|
|
283
|
+
// precedes the text it produced, instead of being grouped after the fact.
|
|
284
|
+
const header = `## Assistant · ${t}${m.model ? ` · ${m.model}` : ""}`;
|
|
285
|
+
const parts: string[] = [];
|
|
225
286
|
const thinkings: string[] = [];
|
|
287
|
+
const flushThinking = () => {
|
|
288
|
+
if (thinkings.length) {
|
|
289
|
+
parts.push(
|
|
290
|
+
`<details>\n<summary>Thinking</summary>\n\n${thinkings.join("\n\n")}\n\n</details>`,
|
|
291
|
+
);
|
|
292
|
+
thinkings.length = 0;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
226
295
|
const calls: string[] = [];
|
|
227
296
|
for (const b of m.content) {
|
|
228
|
-
if (b.type === "text")
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
parts.push(
|
|
238
|
-
`<details>\n<summary>Thinking</summary>\n\n${thinkings.join("\n\n")}\n\n</details>`,
|
|
239
|
-
);
|
|
297
|
+
if (b.type === "text") {
|
|
298
|
+
flushThinking();
|
|
299
|
+
parts.push(b.text);
|
|
300
|
+
} else if (b.type === "thinking") {
|
|
301
|
+
thinkings.push(repairThinking(b.thinking));
|
|
302
|
+
} else if (b.type === "toolCall") {
|
|
303
|
+
flushThinking();
|
|
304
|
+
calls.push(`- \`${b.name}\` — ${previewArgs(b.arguments)}`);
|
|
305
|
+
}
|
|
240
306
|
}
|
|
307
|
+
flushThinking();
|
|
241
308
|
if (calls.length) parts.push(`**Tool calls**\n\n${calls.join("\n")}`);
|
|
242
309
|
if (m.errorMessage) parts.push(`> Error: ${m.errorMessage.replace(/\s+/g, " ").trim()}`);
|
|
243
310
|
if (parts.length === 0) parts.push("_(empty response)_");
|
|
@@ -292,13 +359,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
292
359
|
if (meta.model) lines.push(`model: ${yamlQuote(meta.model)}`);
|
|
293
360
|
if (meta.provider) lines.push(`provider: ${yamlQuote(meta.provider)}`);
|
|
294
361
|
lines.push(`cost: ${meta.cost.toFixed(6)}`);
|
|
295
|
-
|
|
362
|
+
// `tokens` counts everything billed, including cached tokens, so it is
|
|
363
|
+
// comparable with provider-side token totals (e.g. OpenRouter activity).
|
|
364
|
+
lines.push(
|
|
365
|
+
`tokens: ${meta.tokensInput + meta.tokensOutput + meta.tokensCacheRead + meta.tokensCacheWrite}`,
|
|
366
|
+
);
|
|
296
367
|
lines.push(`tokens_input: ${meta.tokensInput}`);
|
|
297
368
|
lines.push(`tokens_output: ${meta.tokensOutput}`);
|
|
369
|
+
lines.push(`tokens_cache_read: ${meta.tokensCacheRead}`);
|
|
370
|
+
lines.push(`tokens_cache_write: ${meta.tokensCacheWrite}`);
|
|
298
371
|
lines.push(`messages: ${meta.messages}`);
|
|
299
372
|
lines.push(`created: ${yamlQuote(meta.created)}`);
|
|
300
373
|
lines.push(`updated: ${yamlQuote(meta.updated)}`);
|
|
301
|
-
lines.push(`
|
|
374
|
+
lines.push(`project_root: ${yamlQuote(meta.projectRoot)}`);
|
|
302
375
|
if (meta.sessionFile) lines.push(`session_file: ${yamlQuote(meta.sessionFile)}`);
|
|
303
376
|
lines.push("---");
|
|
304
377
|
return lines.join("\n");
|
|
@@ -323,18 +396,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
323
396
|
let cost = 0;
|
|
324
397
|
let tokensInput = 0;
|
|
325
398
|
let tokensOutput = 0;
|
|
399
|
+
let tokensCacheRead = 0;
|
|
400
|
+
let tokensCacheWrite = 0;
|
|
326
401
|
for (const e of pathMessages) {
|
|
327
402
|
const m = e.message;
|
|
403
|
+
const usage = m.role === "assistant" ? m.usage : m.role === "toolResult" ? m.usage : null;
|
|
328
404
|
if (m.role === "assistant") {
|
|
329
405
|
model = m.model;
|
|
330
406
|
provider = m.provider;
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
407
|
+
}
|
|
408
|
+
if (usage) {
|
|
409
|
+
cost += usage.cost?.total ?? 0;
|
|
410
|
+
tokensInput += usage.input ?? 0;
|
|
411
|
+
tokensOutput += usage.output ?? 0;
|
|
412
|
+
tokensCacheRead += usage.cacheRead ?? 0;
|
|
413
|
+
tokensCacheWrite += usage.cacheWrite ?? 0;
|
|
338
414
|
}
|
|
339
415
|
}
|
|
340
416
|
const now = new Date().toISOString();
|
|
@@ -348,10 +424,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
348
424
|
cost,
|
|
349
425
|
tokensInput,
|
|
350
426
|
tokensOutput,
|
|
427
|
+
tokensCacheRead,
|
|
428
|
+
tokensCacheWrite,
|
|
351
429
|
messages: pathMessages.length,
|
|
352
430
|
created: created ?? now,
|
|
353
431
|
updated: now,
|
|
354
|
-
|
|
432
|
+
projectRoot: ctx.cwd,
|
|
355
433
|
};
|
|
356
434
|
}
|
|
357
435
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-claudian/auto-save-to-markdown",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.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",
|