@pi-claudian/auto-save-to-markdown 0.1.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.
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/README.zh.md +123 -0
- package/debug.ts +24 -0
- package/index.ts +599 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 licongy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# @pi-claudian/auto-save-to-markdown
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@pi-claudian/auto-save-to-markdown)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
[English](README.md) | [中文](README.zh.md)
|
|
7
|
+
|
|
8
|
+
A [Pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) extension
|
|
9
|
+
that automatically saves every completed conversation turn as a markdown file
|
|
10
|
+
with YAML frontmatter — one file per session-tree branch.
|
|
11
|
+
|
|
12
|
+
## Why
|
|
13
|
+
|
|
14
|
+
Pi records sessions internally as JSONL trees, which are great for resuming but
|
|
15
|
+
terrible for reading, searching, or archiving. This extension mirrors the
|
|
16
|
+
conversation into plain markdown files as you work, so every exchange is
|
|
17
|
+
preserved in a format any editor, note app, or grep can consume — with the
|
|
18
|
+
model, cost, tokens, and session metadata right in the frontmatter.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
pi install npm:@pi-claudian/auto-save-to-markdown
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
Automatic: after every settled agent turn (`agent_settled`), the current
|
|
29
|
+
conversation branch is written to `<cwd>/<folder>/<title>-<tree>-<time>.md`.
|
|
30
|
+
|
|
31
|
+
Manual: run `/save-conversation` to save the current branch immediately and
|
|
32
|
+
report the file path.
|
|
33
|
+
|
|
34
|
+
## Configuration
|
|
35
|
+
|
|
36
|
+
The target folder is controlled by the `PI_SAVE_CONVERSATION_DIR` environment
|
|
37
|
+
variable (Pi has no per-extension settings API):
|
|
38
|
+
|
|
39
|
+
| Value | Location |
|
|
40
|
+
| ----------- | ----------------------------------- |
|
|
41
|
+
| unset | `<cwd>/ai-conversations/` (default) |
|
|
42
|
+
| `.` or `""` | `<cwd>/` directly |
|
|
43
|
+
| `notes/ai` | `<cwd>/notes/ai/` |
|
|
44
|
+
| `/abs/path` | that absolute path |
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
PI_SAVE_CONVERSATION_DIR=notes/ai pi
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## File naming and frontmatter
|
|
51
|
+
|
|
52
|
+
Filename: `<title>-<tree>-<time>.md`
|
|
53
|
+
|
|
54
|
+
- `<title>` — the session name (`/name`), or a slug of the first user message
|
|
55
|
+
when the session is unnamed
|
|
56
|
+
- `<tree>` — the id of the deepest message entry at file creation (the branch key)
|
|
57
|
+
- `<time>` — local file-creation time, `YYYYMMDD-HHmmss`
|
|
58
|
+
|
|
59
|
+
```markdown
|
|
60
|
+
---
|
|
61
|
+
title: "Fix login redirect loop"
|
|
62
|
+
session_id: "d0a4f541-976d-4d1b-8e1c-30a1f2b3c4d5"
|
|
63
|
+
tree: "a1b2c3d4"
|
|
64
|
+
model: "claude-sonnet-4-5"
|
|
65
|
+
provider: "anthropic"
|
|
66
|
+
cost: 0.023401
|
|
67
|
+
tokens: 18745
|
|
68
|
+
tokens_input: 15230
|
|
69
|
+
tokens_output: 3515
|
|
70
|
+
messages: 8
|
|
71
|
+
created: "2026-08-29T05:05:12.000Z"
|
|
72
|
+
updated: "2026-08-29T05:42:10.000Z"
|
|
73
|
+
cwd: "/Users/me/project"
|
|
74
|
+
session_file: "~/.pi/agent/sessions/--Users-me-project-20260829-050500_ab12.jsonl"
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
# Fix login redirect loop
|
|
78
|
+
|
|
79
|
+
## User · 13:05:12
|
|
80
|
+
|
|
81
|
+
The login page redirects in a loop after the auth refactor...
|
|
82
|
+
|
|
83
|
+
## Assistant · 13:05:40 · claude-sonnet-4-5
|
|
84
|
+
|
|
85
|
+
<details>
|
|
86
|
+
<summary>Thinking</summary>
|
|
87
|
+
|
|
88
|
+
Let me check the redirect chain...
|
|
89
|
+
|
|
90
|
+
</details>
|
|
91
|
+
|
|
92
|
+
I'll trace the middleware order first.
|
|
93
|
+
|
|
94
|
+
**Tool calls**
|
|
95
|
+
|
|
96
|
+
- `read` — {"filePath":"/Users/me/project/src/auth/middleware.ts"}
|
|
97
|
+
|
|
98
|
+
> **Tool · read** /Users/me/project/src/auth/middleware.ts — 120 lines …
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The body renders user and assistant messages in full (assistant thinking is
|
|
102
|
+
kept in a collapsible `<details>` block) and summarizes each tool call and
|
|
103
|
+
result in one line, so the file stays readable while still showing what the
|
|
104
|
+
agent did.
|
|
105
|
+
|
|
106
|
+
## Branch behavior
|
|
107
|
+
|
|
108
|
+
Pi sessions are trees: `/tree` navigates to an earlier point and a new prompt
|
|
109
|
+
forks a new branch. Each markdown file records exactly **one branch** — the
|
|
110
|
+
root-to-leaf path that branch sees.
|
|
111
|
+
|
|
112
|
+
- **Same branch, next turn** → new messages are _appended_ to the existing
|
|
113
|
+
file, and the frontmatter (`cost`, `tokens`, `messages`, `updated`, title,
|
|
114
|
+
model) is refreshed.
|
|
115
|
+
- **`/tree` + new prompt (a different branch)** → a _new file_ is created
|
|
116
|
+
containing the full new branch (the shared prefix plus the new exchange).
|
|
117
|
+
- **Forking at the current tip** → the existing file continues (its content is
|
|
118
|
+
already an exact prefix of the new branch), so no duplicate file is created.
|
|
119
|
+
- **Resuming later** (restart, `/resume`, `/fork`, `/clone`) → the branch is
|
|
120
|
+
recognized and its file continues where it left off.
|
|
121
|
+
|
|
122
|
+
Branch identity is persisted inside the session tree itself via extension
|
|
123
|
+
custom entries (never sent to the LLM, not rendered in the TUI), so state
|
|
124
|
+
survives restarts and navigation without any sidecar files.
|
|
125
|
+
|
|
126
|
+
Compacted sessions still export their **full original history** — the archive
|
|
127
|
+
always contains the complete conversation, not the compacted context.
|
|
128
|
+
|
|
129
|
+
## Debug
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
PI_CLAUDIAN_DEBUG=1 pi
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# @pi-claudian/auto-save-to-markdown
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@pi-claudian/auto-save-to-markdown)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
[English](README.md) | [中文](README.zh.md)
|
|
7
|
+
|
|
8
|
+
一个 [Pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) 扩展:每轮对话完成后,自动把当前对话分支保存为带 YAML frontmatter 的 markdown 文件——每个会话树分支一个文件。
|
|
9
|
+
|
|
10
|
+
## 为什么需要它
|
|
11
|
+
|
|
12
|
+
Pi 内部以 JSONL 树的形式记录会话,便于恢复却不便于阅读、检索和归档。本扩展在你工作的同时把对话镜像成普通 markdown 文件,每轮交流都以任何编辑器、笔记软件或 grep 都能处理的格式留存,且模型、费用、token 数等元数据都写在 frontmatter 里。
|
|
13
|
+
|
|
14
|
+
## 安装
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
pi install npm:@pi-claudian/auto-save-to-markdown
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 用法
|
|
21
|
+
|
|
22
|
+
自动:每个 agent 轮次完全结束(`agent_settled`,含自动重试与压缩全部完成)后,当前对话分支写入 `<cwd>/<文件夹>/<标题>-<tree>-<时间>.md`。
|
|
23
|
+
|
|
24
|
+
手动:运行 `/save-conversation` 立即保存当前分支并显示文件路径。
|
|
25
|
+
|
|
26
|
+
## 配置
|
|
27
|
+
|
|
28
|
+
目标文件夹由环境变量 `PI_SAVE_CONVERSATION_DIR` 控制(Pi 没有扩展设置 API):
|
|
29
|
+
|
|
30
|
+
| 取值 | 保存位置 |
|
|
31
|
+
| ----------- | --------------------------------- |
|
|
32
|
+
| 未设置 | `<cwd>/ai-conversations/`(默认) |
|
|
33
|
+
| `.` 或 `""` | 直接保存在 `<cwd>/` |
|
|
34
|
+
| `notes/ai` | `<cwd>/notes/ai/` |
|
|
35
|
+
| `/绝对路径` | 该绝对路径 |
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
PI_SAVE_CONVERSATION_DIR=notes/ai pi
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 文件名与 frontmatter
|
|
42
|
+
|
|
43
|
+
文件名:`<标题>-<tree>-<时间>.md`
|
|
44
|
+
|
|
45
|
+
- `<标题>` — 会话名称(`/name`);未命名时取第一条用户消息的摘要
|
|
46
|
+
- `<tree>` — 建文件时分支上最深一条消息的 entry id(分支标识)
|
|
47
|
+
- `<时间>` — 建文件的本地时间,格式 `YYYYMMDD-HHmmss`
|
|
48
|
+
|
|
49
|
+
```markdown
|
|
50
|
+
---
|
|
51
|
+
title: "修复登录重定向死循环"
|
|
52
|
+
session_id: "d0a4f541-976d-4d1b-8e1c-30a1f2b3c4d5"
|
|
53
|
+
tree: "a1b2c3d4"
|
|
54
|
+
model: "claude-sonnet-4-5"
|
|
55
|
+
provider: "anthropic"
|
|
56
|
+
cost: 0.023401
|
|
57
|
+
tokens: 18745
|
|
58
|
+
tokens_input: 15230
|
|
59
|
+
tokens_output: 3515
|
|
60
|
+
messages: 8
|
|
61
|
+
created: "2026-08-29T05:05:12.000Z"
|
|
62
|
+
updated: "2026-08-29T05:42:10.000Z"
|
|
63
|
+
cwd: "/Users/me/project"
|
|
64
|
+
session_file: "~/.pi/agent/sessions/--Users-me-project-20260829-050500_ab12.jsonl"
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
# 修复登录重定向死循环
|
|
68
|
+
|
|
69
|
+
## User · 13:05:12
|
|
70
|
+
|
|
71
|
+
auth 重构之后登录页一直重定向死循环……
|
|
72
|
+
|
|
73
|
+
## Assistant · 13:05:40 · claude-sonnet-4-5
|
|
74
|
+
|
|
75
|
+
<details>
|
|
76
|
+
<summary>Thinking</summary>
|
|
77
|
+
|
|
78
|
+
先看中间件的执行顺序……
|
|
79
|
+
|
|
80
|
+
</details>
|
|
81
|
+
|
|
82
|
+
我先追踪一下中间件链。
|
|
83
|
+
|
|
84
|
+
**Tool calls**
|
|
85
|
+
|
|
86
|
+
- `read` — {"filePath":"/Users/me/project/src/auth/middleware.ts"}
|
|
87
|
+
|
|
88
|
+
> **Tool · read** /Users/me/project/src/auth/middleware.ts — 120 lines …
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
正文完整渲染 user / assistant 消息(assistant 的 thinking 放在可折叠的
|
|
92
|
+
`<details>` 块中),每个工具调用和结果各压缩成一行摘要,既可读又能看出
|
|
93
|
+
agent 做了什么。
|
|
94
|
+
|
|
95
|
+
## 分支行为
|
|
96
|
+
|
|
97
|
+
Pi 会话是树:`/tree` 导航到更早的位置后再提问就分出新的分支。每个
|
|
98
|
+
markdown 文件只记录**一个分支**——即该分支看到的 root→leaf 完整路径。
|
|
99
|
+
|
|
100
|
+
- **同一分支继续对话** → 新消息*追加*到已有文件,frontmatter(`cost`、
|
|
101
|
+
`tokens`、`messages`、`updated`、标题、模型)同步刷新。
|
|
102
|
+
- **`/tree` 后重新提问(不同分支)** → _另存新文件_,内容为新分支的完整
|
|
103
|
+
路径(共享前缀 + 新对话)。
|
|
104
|
+
- **在当前末端分叉** → 已有文件继续追加(其内容恰好是新分支的精确前缀),
|
|
105
|
+
不会产生重复文件。
|
|
106
|
+
- **之后恢复会话**(重启、`/resume`、`/fork`、`/clone`)→ 分支被识别,
|
|
107
|
+
对应文件从上次的位置继续。
|
|
108
|
+
|
|
109
|
+
分支状态以扩展 custom entry 的形式持久化在会话树内部(不进 LLM 上下文、
|
|
110
|
+
不在 TUI 渲染),因此无需任何辅助文件即可在重启和导航后恢复状态。
|
|
111
|
+
|
|
112
|
+
被压缩(compaction)过的会话导出的仍是**完整原始历史**——归档永远是全量
|
|
113
|
+
对话,而不是压缩后的上下文。
|
|
114
|
+
|
|
115
|
+
## 调试
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
PI_CLAUDIAN_DEBUG=1 pi
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## 许可
|
|
122
|
+
|
|
123
|
+
MIT
|
package/debug.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared debug logging for all @pi-claudian extensions.
|
|
3
|
+
*
|
|
4
|
+
* Enable by setting the PI_CLAUDIAN_DEBUG environment variable to any truthy
|
|
5
|
+
* value (e.g. "1"). Output goes to stderr via console.error, so it never mixes
|
|
6
|
+
* with pi's stdout and can be captured separately:
|
|
7
|
+
*
|
|
8
|
+
* PI_CLAUDIAN_DEBUG=1 pi # show inline
|
|
9
|
+
* PI_CLAUDIAN_DEBUG=1 pi 2>debug.log # capture to a file
|
|
10
|
+
*
|
|
11
|
+
* This is a source-only module (Pi loads it via jiti). Each @pi-claudian
|
|
12
|
+
* package vendors its own copy and imports it, keeping packages independent —
|
|
13
|
+
* the shared contract is the PI_CLAUDIAN_DEBUG env var name, not a shared
|
|
14
|
+
* npm dependency.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const TAG = "[pi-claudian]";
|
|
18
|
+
const enabled = Boolean(process.env.PI_CLAUDIAN_DEBUG);
|
|
19
|
+
|
|
20
|
+
/** Log a debug message when PI_CLAUDIAN_DEBUG is set. */
|
|
21
|
+
export function debug(...args: unknown[]): void {
|
|
22
|
+
if (!enabled) return;
|
|
23
|
+
console.error(TAG, ...args);
|
|
24
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-claudian/auto-save-to-markdown
|
|
3
|
+
*
|
|
4
|
+
* Automatically saves the current conversation to a markdown file after every
|
|
5
|
+
* completed agent turn, with session metadata in a YAML frontmatter block.
|
|
6
|
+
*
|
|
7
|
+
* Behavior:
|
|
8
|
+
*
|
|
9
|
+
* - Trigger: `agent_settled` — fires once per user prompt, after the turn is
|
|
10
|
+
* fully done (including automatic retries and compaction), so each save
|
|
11
|
+
* captures a settled state of the conversation.
|
|
12
|
+
* - Location: a subfolder of the session's working directory (`ctx.cwd`, the
|
|
13
|
+
* directory the session was started in), defaulting to `ai-conversations`.
|
|
14
|
+
* Override with the PI_SAVE_CONVERSATION_DIR environment variable; set it to
|
|
15
|
+
* "." or "" to save directly into the working directory.
|
|
16
|
+
* - Filename: `<title>-<tree>-<time>.md`, where <title> is the session name
|
|
17
|
+
* (or a slug of the first user message when unnamed), <tree> is the 8-hex id
|
|
18
|
+
* of the deepest message entry at file creation, and <time> is the local
|
|
19
|
+
* file-creation timestamp (YYYYMMDD-HHmmss).
|
|
20
|
+
* - Frontmatter: title, session id, tree (branch key), model, provider,
|
|
21
|
+
* cumulative cost and tokens, message count, created/updated timestamps,
|
|
22
|
+
* cwd and session file.
|
|
23
|
+
* - Branching: each file records exactly ONE branch (the root→leaf path
|
|
24
|
+
* returned by sessionManager.getBranch()). State is persisted via
|
|
25
|
+
* `pi.appendEntry()` custom entries, which are part of the session tree
|
|
26
|
+
* itself — they are not sent to the LLM and not rendered in the TUI. On
|
|
27
|
+
* every save the extension finds the file whose latest saved position is the
|
|
28
|
+
* deepest entry still on the current path; if the tree moved elsewhere
|
|
29
|
+
* (e.g. /tree navigation followed by a new prompt), no file matches and a
|
|
30
|
+
* new file is created with the full current branch. Continuing an existing
|
|
31
|
+
* branch appends only the messages that are new since the last save.
|
|
32
|
+
* - Compaction: files archive the ORIGINAL messages (getBranch() returns the
|
|
33
|
+
* raw tree path, not the compaction-aware context), so a compacted session
|
|
34
|
+
* still exports its complete history.
|
|
35
|
+
*
|
|
36
|
+
* Manual command: `/save-conversation` saves the current branch immediately
|
|
37
|
+
* and reports the file path.
|
|
38
|
+
*
|
|
39
|
+
* Installation:
|
|
40
|
+
* pi install npm:@pi-claudian/auto-save-to-markdown
|
|
41
|
+
*
|
|
42
|
+
* Debug:
|
|
43
|
+
* PI_CLAUDIAN_DEBUG=1 pi
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import type {
|
|
47
|
+
AgentSettledEvent,
|
|
48
|
+
ExtensionAPI,
|
|
49
|
+
ExtensionCommandContext,
|
|
50
|
+
ExtensionContext,
|
|
51
|
+
SessionEntry,
|
|
52
|
+
SessionMessageEntry,
|
|
53
|
+
} from "@earendil-works/pi-coding-agent";
|
|
54
|
+
import * as fs from "node:fs/promises";
|
|
55
|
+
import * as path from "node:path";
|
|
56
|
+
import { debug } from "./debug.js";
|
|
57
|
+
|
|
58
|
+
const CUSTOM_TYPE = "pi-claudian-auto-save-markdown";
|
|
59
|
+
const ENV_SUBDIR = "PI_SAVE_CONVERSATION_DIR";
|
|
60
|
+
const DEFAULT_SUBDIR = "ai-conversations";
|
|
61
|
+
const COMMAND = "save-conversation";
|
|
62
|
+
const NOTIFY_TAG = "[AutoSave]";
|
|
63
|
+
|
|
64
|
+
const MAX_TITLE_LENGTH = 60;
|
|
65
|
+
const TITLE_FALLBACK_LENGTH = 40;
|
|
66
|
+
const TOOL_RESULT_PREVIEW = 300;
|
|
67
|
+
const TOOL_ARGS_PREVIEW = 160;
|
|
68
|
+
|
|
69
|
+
type AgentMessage = SessionMessageEntry["message"];
|
|
70
|
+
type UserMessage = Extract<AgentMessage, { role: "user" }>;
|
|
71
|
+
type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
|
|
72
|
+
type ToolResultMessage = Extract<AgentMessage, { role: "toolResult" }>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Per-save state persisted in the session tree via pi.appendEntry().
|
|
76
|
+
* `file` is the bare filename inside the target directory, so changing the
|
|
77
|
+
* configured directory (env var) moves future saves without breaking
|
|
78
|
+
* resolution — the file is simply recreated from the full branch if missing.
|
|
79
|
+
*/
|
|
80
|
+
interface SaveState {
|
|
81
|
+
branchKey: string;
|
|
82
|
+
lastSavedEntryId: string | null;
|
|
83
|
+
file: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isSaveState(v: unknown): v is SaveState {
|
|
87
|
+
if (typeof v !== "object" || v === null) return false;
|
|
88
|
+
const s = v as Record<string, unknown>;
|
|
89
|
+
return (
|
|
90
|
+
typeof s.branchKey === "string" &&
|
|
91
|
+
s.branchKey.length > 0 &&
|
|
92
|
+
(s.lastSavedEntryId === null || typeof s.lastSavedEntryId === "string") &&
|
|
93
|
+
typeof s.file === "string" &&
|
|
94
|
+
s.file.length > 0
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface SaveResult {
|
|
99
|
+
message: string;
|
|
100
|
+
/** A file was actually written (created or appended). */
|
|
101
|
+
wrote: boolean;
|
|
102
|
+
/** The write created a brand-new file (vs appending to an existing one). */
|
|
103
|
+
created: boolean;
|
|
104
|
+
file: string | null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface BranchMeta {
|
|
108
|
+
title: string;
|
|
109
|
+
sessionId: string | null;
|
|
110
|
+
sessionFile: string | null;
|
|
111
|
+
tree: string;
|
|
112
|
+
model: string | null;
|
|
113
|
+
provider: string | null;
|
|
114
|
+
cost: number;
|
|
115
|
+
tokensInput: number;
|
|
116
|
+
tokensOutput: number;
|
|
117
|
+
messages: number;
|
|
118
|
+
created: string;
|
|
119
|
+
updated: string;
|
|
120
|
+
cwd: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export default function (pi: ExtensionAPI) {
|
|
124
|
+
/**
|
|
125
|
+
* Resolve the target directory. The env var may hold a relative folder name
|
|
126
|
+
* (resolved against the session cwd), an absolute path, or "." / "" for the
|
|
127
|
+
* working directory itself. When unset, the default subfolder is used.
|
|
128
|
+
*/
|
|
129
|
+
function targetDir(ctx: ExtensionContext): string {
|
|
130
|
+
const env = process.env[ENV_SUBDIR];
|
|
131
|
+
if (env === undefined) return path.join(ctx.cwd, DEFAULT_SUBDIR);
|
|
132
|
+
const raw = env.trim();
|
|
133
|
+
if (raw === "" || raw === ".") return ctx.cwd;
|
|
134
|
+
return path.resolve(ctx.cwd, raw);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------- formatting helpers ----------
|
|
138
|
+
|
|
139
|
+
function pad(n: number): string {
|
|
140
|
+
return String(n).padStart(2, "0");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Local-time filename timestamp: YYYYMMDD-HHmmss. */
|
|
144
|
+
function fileTimestamp(d: Date): string {
|
|
145
|
+
return (
|
|
146
|
+
`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
|
|
147
|
+
`-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Local-time clock label for a message entry: HH:MM:SS. */
|
|
152
|
+
function clock(iso: string): string {
|
|
153
|
+
const d = new Date(iso);
|
|
154
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Make a string safe for use as a filename component. */
|
|
158
|
+
function sanitizeFilenamePart(s: string): string {
|
|
159
|
+
return s
|
|
160
|
+
.replace(/[\u0000-\u001f\u007f]/g, "")
|
|
161
|
+
.replace(/[\\/:*?"<>|]/g, "-")
|
|
162
|
+
.replace(/\s+/g, " ")
|
|
163
|
+
.trim()
|
|
164
|
+
.replace(/\s/g, "-")
|
|
165
|
+
.replace(/-+/g, "-")
|
|
166
|
+
.replace(/^[-.]+|[-.]+$/g, "")
|
|
167
|
+
.slice(0, MAX_TITLE_LENGTH)
|
|
168
|
+
.replace(/[-.]+$/g, "");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Extract the plain text of a user message content (string or blocks). */
|
|
172
|
+
function userText(content: UserMessage["content"]): string {
|
|
173
|
+
if (typeof content === "string") return content;
|
|
174
|
+
return content
|
|
175
|
+
.map((b) =>
|
|
176
|
+
b.type === "text" ? b.text : `_[image: ${"mimeType" in b ? b.mimeType : "unknown"}]_`,
|
|
177
|
+
)
|
|
178
|
+
.join("\n\n");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function firstUserText(messages: SessionMessageEntry[]): string | undefined {
|
|
182
|
+
for (const e of messages) {
|
|
183
|
+
if (e.message.role === "user") return userText(e.message.content) || undefined;
|
|
184
|
+
}
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Title for the filename: session name, else a slug of the first user message. */
|
|
189
|
+
function titleForFilename(ctx: ExtensionContext, firstUser: string | undefined): string {
|
|
190
|
+
const name = ctx.sessionManager.getSessionName()?.trim();
|
|
191
|
+
if (name) return sanitizeFilenamePart(name) || "untitled";
|
|
192
|
+
if (firstUser) {
|
|
193
|
+
const slug = sanitizeFilenamePart(firstUser.slice(0, TITLE_FALLBACK_LENGTH));
|
|
194
|
+
if (slug) return slug;
|
|
195
|
+
}
|
|
196
|
+
return "untitled";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Title for the frontmatter / document heading. */
|
|
200
|
+
function displayTitle(ctx: ExtensionContext, firstUser: string | undefined): string {
|
|
201
|
+
const name = ctx.sessionManager.getSessionName()?.trim();
|
|
202
|
+
if (name) return name;
|
|
203
|
+
if (firstUser) {
|
|
204
|
+
const snippet = firstUser.replace(/\s+/g, " ").trim().slice(0, MAX_TITLE_LENGTH);
|
|
205
|
+
if (snippet) return snippet;
|
|
206
|
+
}
|
|
207
|
+
return "untitled";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function previewArgs(args: unknown): string {
|
|
211
|
+
let s: string;
|
|
212
|
+
try {
|
|
213
|
+
s = JSON.stringify(args) ?? "";
|
|
214
|
+
} catch {
|
|
215
|
+
s = String(args);
|
|
216
|
+
}
|
|
217
|
+
s = s.replace(/\s+/g, " ").trim();
|
|
218
|
+
return s.length > TOOL_ARGS_PREVIEW ? s.slice(0, TOOL_ARGS_PREVIEW) + " …" : s;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------- markdown rendering ----------
|
|
222
|
+
|
|
223
|
+
function renderAssistant(m: AssistantMessage, t: string): string {
|
|
224
|
+
const texts: string[] = [];
|
|
225
|
+
const thinkings: string[] = [];
|
|
226
|
+
const calls: string[] = [];
|
|
227
|
+
for (const b of m.content) {
|
|
228
|
+
if (b.type === "text") texts.push(b.text);
|
|
229
|
+
else if (b.type === "thinking") thinkings.push(b.thinking);
|
|
230
|
+
else if (b.type === "toolCall") calls.push(`- \`${b.name}\` — ${previewArgs(b.arguments)}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const header = `## Assistant · ${t}${m.model ? ` · ${m.model}` : ""}`;
|
|
234
|
+
const parts: string[] = [];
|
|
235
|
+
if (texts.length) parts.push(texts.join("\n\n"));
|
|
236
|
+
if (thinkings.length) {
|
|
237
|
+
parts.push(
|
|
238
|
+
`<details>\n<summary>Thinking</summary>\n\n${thinkings.join("\n\n")}\n\n</details>`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (calls.length) parts.push(`**Tool calls**\n\n${calls.join("\n")}`);
|
|
242
|
+
if (m.errorMessage) parts.push(`> Error: ${m.errorMessage.replace(/\s+/g, " ").trim()}`);
|
|
243
|
+
if (parts.length === 0) parts.push("_(empty response)_");
|
|
244
|
+
return `${header}\n\n${parts.join("\n\n")}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function renderToolResult(m: ToolResultMessage): string {
|
|
248
|
+
const texts: string[] = [];
|
|
249
|
+
for (const b of m.content) {
|
|
250
|
+
if (b.type === "text") texts.push(b.text);
|
|
251
|
+
else texts.push(`_[image: ${b.mimeType}]_`);
|
|
252
|
+
}
|
|
253
|
+
const flat = texts.join(" ").replace(/\s+/g, " ").trim();
|
|
254
|
+
const capped =
|
|
255
|
+
flat.length > TOOL_RESULT_PREVIEW ? flat.slice(0, TOOL_RESULT_PREVIEW) + " …" : flat;
|
|
256
|
+
const status = m.isError ? " (error)" : "";
|
|
257
|
+
const line = `> **Tool · ${m.toolName}${status}** ${capped}`.trim();
|
|
258
|
+
return line;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Render a chronological list of message entries as markdown blocks. */
|
|
262
|
+
function renderEntries(entries: SessionMessageEntry[]): string {
|
|
263
|
+
const blocks: string[] = [];
|
|
264
|
+
for (const e of entries) {
|
|
265
|
+
const m = e.message;
|
|
266
|
+
const t = clock(e.timestamp);
|
|
267
|
+
if (m.role === "user") {
|
|
268
|
+
blocks.push(`## User · ${t}\n\n${userText(m.content)}`);
|
|
269
|
+
} else if (m.role === "assistant") {
|
|
270
|
+
blocks.push(renderAssistant(m, t));
|
|
271
|
+
} else if (m.role === "toolResult") {
|
|
272
|
+
blocks.push(renderToolResult(m));
|
|
273
|
+
}
|
|
274
|
+
// Other roles (custom, bashExecution, branchSummary, compactionSummary)
|
|
275
|
+
// are not part of the rendered conversation record.
|
|
276
|
+
}
|
|
277
|
+
return blocks.join("\n\n");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------- frontmatter ----------
|
|
281
|
+
|
|
282
|
+
function yamlQuote(s: string): string {
|
|
283
|
+
const safe = s.replace(/[\r\n]+/g, " ");
|
|
284
|
+
return `"${safe.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function frontmatter(meta: BranchMeta): string {
|
|
288
|
+
const lines: string[] = ["---"];
|
|
289
|
+
lines.push(`title: ${yamlQuote(meta.title)}`);
|
|
290
|
+
if (meta.sessionId) lines.push(`session_id: ${yamlQuote(meta.sessionId)}`);
|
|
291
|
+
lines.push(`tree: ${yamlQuote(meta.tree)}`);
|
|
292
|
+
if (meta.model) lines.push(`model: ${yamlQuote(meta.model)}`);
|
|
293
|
+
if (meta.provider) lines.push(`provider: ${yamlQuote(meta.provider)}`);
|
|
294
|
+
lines.push(`cost: ${meta.cost.toFixed(6)}`);
|
|
295
|
+
lines.push(`tokens: ${meta.tokensInput + meta.tokensOutput}`);
|
|
296
|
+
lines.push(`tokens_input: ${meta.tokensInput}`);
|
|
297
|
+
lines.push(`tokens_output: ${meta.tokensOutput}`);
|
|
298
|
+
lines.push(`messages: ${meta.messages}`);
|
|
299
|
+
lines.push(`created: ${yamlQuote(meta.created)}`);
|
|
300
|
+
lines.push(`updated: ${yamlQuote(meta.updated)}`);
|
|
301
|
+
lines.push(`cwd: ${yamlQuote(meta.cwd)}`);
|
|
302
|
+
if (meta.sessionFile) lines.push(`session_file: ${yamlQuote(meta.sessionFile)}`);
|
|
303
|
+
lines.push("---");
|
|
304
|
+
return lines.join("\n");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Recover the original creation timestamp from an existing frontmatter block. */
|
|
308
|
+
function parseCreated(content: string): string | undefined {
|
|
309
|
+
const m = content.match(/^created: "(.*)"$/m);
|
|
310
|
+
if (!m) return undefined;
|
|
311
|
+
const iso = m[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
312
|
+
return Number.isNaN(Date.parse(iso)) ? undefined : iso;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function computeMeta(
|
|
316
|
+
ctx: ExtensionContext,
|
|
317
|
+
pathMessages: SessionMessageEntry[],
|
|
318
|
+
branchKey: string,
|
|
319
|
+
created: string | undefined,
|
|
320
|
+
): BranchMeta {
|
|
321
|
+
let model: string | null = null;
|
|
322
|
+
let provider: string | null = null;
|
|
323
|
+
let cost = 0;
|
|
324
|
+
let tokensInput = 0;
|
|
325
|
+
let tokensOutput = 0;
|
|
326
|
+
for (const e of pathMessages) {
|
|
327
|
+
const m = e.message;
|
|
328
|
+
if (m.role === "assistant") {
|
|
329
|
+
model = m.model;
|
|
330
|
+
provider = m.provider;
|
|
331
|
+
cost += m.usage?.cost?.total ?? 0;
|
|
332
|
+
tokensInput += m.usage?.input ?? 0;
|
|
333
|
+
tokensOutput += m.usage?.output ?? 0;
|
|
334
|
+
} else if (m.role === "toolResult" && m.usage) {
|
|
335
|
+
cost += m.usage.cost?.total ?? 0;
|
|
336
|
+
tokensInput += m.usage.input ?? 0;
|
|
337
|
+
tokensOutput += m.usage.output ?? 0;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const now = new Date().toISOString();
|
|
341
|
+
return {
|
|
342
|
+
title: displayTitle(ctx, firstUserText(pathMessages)),
|
|
343
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
344
|
+
sessionFile: ctx.sessionManager.getSessionFile() ?? null,
|
|
345
|
+
tree: branchKey,
|
|
346
|
+
model,
|
|
347
|
+
provider,
|
|
348
|
+
cost,
|
|
349
|
+
tokensInput,
|
|
350
|
+
tokensOutput,
|
|
351
|
+
messages: pathMessages.length,
|
|
352
|
+
created: created ?? now,
|
|
353
|
+
updated: now,
|
|
354
|
+
cwd: ctx.cwd,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---------- save planning ----------
|
|
359
|
+
|
|
360
|
+
interface SavePlan {
|
|
361
|
+
dir: string;
|
|
362
|
+
filename: string;
|
|
363
|
+
branchKey: string;
|
|
364
|
+
/** Write the full branch content (new branch, or target file missing). */
|
|
365
|
+
fullCreate: boolean;
|
|
366
|
+
/** Entries to append when continuing an existing file. */
|
|
367
|
+
appendEntries: SessionMessageEntry[];
|
|
368
|
+
/** Full root→leaf message list of the current branch (for meta/full renders). */
|
|
369
|
+
pathMessages: SessionMessageEntry[];
|
|
370
|
+
/** State of the file being continued, when not a full create. */
|
|
371
|
+
state: SaveState | null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function isMessageEntry(e: SessionEntry): e is SessionMessageEntry {
|
|
375
|
+
return e.type === "message";
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Decide which file the current branch belongs to and what to write.
|
|
380
|
+
*
|
|
381
|
+
* Every save appends a custom entry recording {branchKey, lastSavedEntryId,
|
|
382
|
+
* file}. Those entries live in the session tree, so a branch's own latest
|
|
383
|
+
* state is always recoverable — including after resume, /tree navigation,
|
|
384
|
+
* or /fork. The file to continue is the one whose most recent saved position
|
|
385
|
+
* is the deepest entry still on the current root→leaf path; when the tree
|
|
386
|
+
* moved (navigation + re-ask), no position matches and a new file starts.
|
|
387
|
+
*/
|
|
388
|
+
function computePlan(ctx: ExtensionContext): SavePlan | null {
|
|
389
|
+
const pathEntries = ctx.sessionManager.getBranch();
|
|
390
|
+
const pathMessages = pathEntries.filter(isMessageEntry);
|
|
391
|
+
if (pathMessages.length === 0) return null;
|
|
392
|
+
|
|
393
|
+
const pos = new Map<string, number>();
|
|
394
|
+
pathEntries.forEach((e, i) => pos.set(e.id, i));
|
|
395
|
+
|
|
396
|
+
const latestForFile = new Map<string, SaveState>();
|
|
397
|
+
for (const e of ctx.sessionManager.getEntries()) {
|
|
398
|
+
if (e.type === "custom" && e.customType === CUSTOM_TYPE && isSaveState(e.data)) {
|
|
399
|
+
latestForFile.set(e.data.file, e.data);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
let bestState: SaveState | null = null;
|
|
404
|
+
let bestPos = -1;
|
|
405
|
+
for (const st of latestForFile.values()) {
|
|
406
|
+
if (!st.lastSavedEntryId) continue;
|
|
407
|
+
const p = pos.get(st.lastSavedEntryId);
|
|
408
|
+
if (p === undefined) continue;
|
|
409
|
+
if (p > bestPos) {
|
|
410
|
+
bestPos = p;
|
|
411
|
+
bestState = st;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const dir = targetDir(ctx);
|
|
416
|
+
if (bestState) {
|
|
417
|
+
const appendEntries = pathMessages.filter((e) => (pos.get(e.id) ?? -1) > bestPos);
|
|
418
|
+
debug(
|
|
419
|
+
"continuing branch file:",
|
|
420
|
+
bestState.file,
|
|
421
|
+
"saved-up-to:",
|
|
422
|
+
bestState.lastSavedEntryId,
|
|
423
|
+
"new entries:",
|
|
424
|
+
appendEntries.length,
|
|
425
|
+
);
|
|
426
|
+
return {
|
|
427
|
+
dir,
|
|
428
|
+
filename: bestState.file,
|
|
429
|
+
branchKey: bestState.branchKey,
|
|
430
|
+
fullCreate: false,
|
|
431
|
+
appendEntries,
|
|
432
|
+
pathMessages,
|
|
433
|
+
state: bestState,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const branchKey = pathMessages[pathMessages.length - 1].id;
|
|
438
|
+
const title = titleForFilename(ctx, firstUserText(pathMessages));
|
|
439
|
+
const filename = `${title}-${branchKey}-${fileTimestamp(new Date())}.md`;
|
|
440
|
+
debug("new branch file:", filename, "branchKey:", branchKey);
|
|
441
|
+
return {
|
|
442
|
+
dir,
|
|
443
|
+
filename,
|
|
444
|
+
branchKey,
|
|
445
|
+
fullCreate: true,
|
|
446
|
+
appendEntries: [],
|
|
447
|
+
pathMessages,
|
|
448
|
+
state: null,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ---------- writing ----------
|
|
453
|
+
|
|
454
|
+
async function atomicWrite(file: string, content: string): Promise<void> {
|
|
455
|
+
const tmp = file + ".save-tmp";
|
|
456
|
+
await fs.writeFile(tmp, content, "utf-8");
|
|
457
|
+
await fs.rename(tmp, file);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function replaceFrontmatter(existing: string, fm: string): string {
|
|
461
|
+
if (/^---\r?\n[\s\S]*?\r?\n---\r?\n/.test(existing)) {
|
|
462
|
+
return existing.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, fm + "\n");
|
|
463
|
+
}
|
|
464
|
+
return `${fm}\n\n${existing}`;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function saveConversation(ctx: ExtensionContext, plan: SavePlan): Promise<SaveResult> {
|
|
468
|
+
const filePath = path.join(plan.dir, plan.filename);
|
|
469
|
+
let fullCreate = plan.fullCreate;
|
|
470
|
+
if (!fullCreate) {
|
|
471
|
+
const exists = await fs
|
|
472
|
+
.access(filePath)
|
|
473
|
+
.then(() => true)
|
|
474
|
+
.catch(() => false);
|
|
475
|
+
if (!exists) {
|
|
476
|
+
debug("target file missing — recreating from full branch:", filePath);
|
|
477
|
+
fullCreate = true;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
await fs.mkdir(plan.dir, { recursive: true });
|
|
482
|
+
|
|
483
|
+
if (fullCreate) {
|
|
484
|
+
const meta = computeMeta(ctx, plan.pathMessages, plan.branchKey, undefined);
|
|
485
|
+
const body = renderEntries(plan.pathMessages);
|
|
486
|
+
const content = `${frontmatter(meta)}\n\n# ${meta.title}\n\n${body}\n`;
|
|
487
|
+
await atomicWrite(filePath, content);
|
|
488
|
+
debug("created conversation file:", filePath);
|
|
489
|
+
return {
|
|
490
|
+
message: `saved ${plan.filename} (${plan.pathMessages.length} messages)`,
|
|
491
|
+
wrote: true,
|
|
492
|
+
created: true,
|
|
493
|
+
file: filePath,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (plan.appendEntries.length === 0) {
|
|
498
|
+
debug("nothing new since last save:", plan.filename);
|
|
499
|
+
return {
|
|
500
|
+
message: `already up to date (${plan.filename})`,
|
|
501
|
+
wrote: false,
|
|
502
|
+
created: false,
|
|
503
|
+
file: filePath,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const existing = await fs.readFile(filePath, "utf-8");
|
|
508
|
+
const meta = computeMeta(ctx, plan.pathMessages, plan.branchKey, parseCreated(existing));
|
|
509
|
+
const appended = renderEntries(plan.appendEntries);
|
|
510
|
+
let updated = replaceFrontmatter(existing, frontmatter(meta));
|
|
511
|
+
if (!updated.endsWith("\n")) updated += "\n";
|
|
512
|
+
updated += `\n${appended}\n`;
|
|
513
|
+
await atomicWrite(filePath, updated);
|
|
514
|
+
debug("appended", plan.appendEntries.length, "entries to:", filePath);
|
|
515
|
+
return {
|
|
516
|
+
message: `appended ${plan.appendEntries.length} messages to ${plan.filename}`,
|
|
517
|
+
wrote: true,
|
|
518
|
+
created: false,
|
|
519
|
+
file: filePath,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function recordState(plan: SavePlan, leafId: string | null): void {
|
|
524
|
+
pi.appendEntry(CUSTOM_TYPE, {
|
|
525
|
+
branchKey: plan.branchKey,
|
|
526
|
+
lastSavedEntryId: leafId,
|
|
527
|
+
file: plan.filename,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function relativeForUser(ctx: ExtensionContext, file: string): string {
|
|
532
|
+
const rel = path.relative(ctx.cwd, file);
|
|
533
|
+
return rel && !rel.startsWith("..") ? rel : file;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Serialize saves: agent_settled and the manual command must not interleave.
|
|
537
|
+
let chain: Promise<unknown> = Promise.resolve();
|
|
538
|
+
function schedule<T>(fn: () => Promise<T>): Promise<T> {
|
|
539
|
+
const run = chain.then(fn, fn);
|
|
540
|
+
chain = run.then(
|
|
541
|
+
() => undefined,
|
|
542
|
+
() => undefined,
|
|
543
|
+
);
|
|
544
|
+
return run;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Full save cycle: write the file, then persist the branch state entry. */
|
|
548
|
+
async function runSave(ctx: ExtensionContext): Promise<SaveResult> {
|
|
549
|
+
const plan = computePlan(ctx);
|
|
550
|
+
if (!plan) {
|
|
551
|
+
return {
|
|
552
|
+
message: "no conversation content to save yet",
|
|
553
|
+
wrote: false,
|
|
554
|
+
created: false,
|
|
555
|
+
file: null,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
const result = await saveConversation(ctx, plan);
|
|
559
|
+
if (result.wrote) {
|
|
560
|
+
const leafId = ctx.sessionManager.getLeafId();
|
|
561
|
+
recordState(plan, leafId);
|
|
562
|
+
debug("recorded state entry — branchKey:", plan.branchKey, "leaf:", leafId);
|
|
563
|
+
}
|
|
564
|
+
return result;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// 1. Automatic: save after every settled agent turn.
|
|
568
|
+
pi.on("agent_settled", async (_event: AgentSettledEvent, ctx: ExtensionContext) => {
|
|
569
|
+
debug("agent_settled — saving conversation");
|
|
570
|
+
try {
|
|
571
|
+
const r = await schedule(() => runSave(ctx));
|
|
572
|
+
if (r.wrote && r.created && ctx.hasUI && r.file) {
|
|
573
|
+
ctx.ui.notify(`${NOTIFY_TAG} ${relativeForUser(ctx, r.file)}`, "info");
|
|
574
|
+
}
|
|
575
|
+
} catch (e) {
|
|
576
|
+
debug("auto-save failed:", String(e));
|
|
577
|
+
if (ctx.hasUI) ctx.ui.notify(`${NOTIFY_TAG} save failed: ${String(e)}`, "error");
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// 2. Manual: force a save now and report where it went.
|
|
582
|
+
pi.registerCommand(COMMAND, {
|
|
583
|
+
description:
|
|
584
|
+
"Save the current conversation branch to a markdown file now (auto-save-to-markdown)",
|
|
585
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
586
|
+
try {
|
|
587
|
+
debug("manual /" + COMMAND + " invoked");
|
|
588
|
+
const r = await schedule(() => runSave(ctx));
|
|
589
|
+
if (ctx.hasUI) {
|
|
590
|
+
const target = r.file ? relativeForUser(ctx, r.file) : "";
|
|
591
|
+
ctx.ui.notify(`${NOTIFY_TAG} ${r.message}${target ? ` → ${target}` : ""}`, "info");
|
|
592
|
+
}
|
|
593
|
+
} catch (e) {
|
|
594
|
+
debug("/" + COMMAND + " failed:", String(e));
|
|
595
|
+
if (ctx.hasUI) ctx.ui.notify(`${NOTIFY_TAG} save failed: ${String(e)}`, "error");
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pi-claudian/auto-save-to-markdown",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "Licong Yang",
|
|
9
|
+
"email": "licong.yang@icloud.com",
|
|
10
|
+
"url": "https://github.com/licongy"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/licongy/pi-claudian.git",
|
|
18
|
+
"directory": "packages/auto-save-to-markdown"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/licongy/pi-claudian/tree/master/packages/auto-save-to-markdown#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/licongy/pi-claudian/issues"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"pi",
|
|
26
|
+
"pi-package",
|
|
27
|
+
"pi-extension",
|
|
28
|
+
"pi-coding-agent",
|
|
29
|
+
"pi-claudian",
|
|
30
|
+
"markdown",
|
|
31
|
+
"conversation",
|
|
32
|
+
"session",
|
|
33
|
+
"export",
|
|
34
|
+
"auto-save",
|
|
35
|
+
"archive"
|
|
36
|
+
],
|
|
37
|
+
"pi": {
|
|
38
|
+
"extensions": [
|
|
39
|
+
"./index.ts"
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"index.ts",
|
|
44
|
+
"debug.ts",
|
|
45
|
+
"README.md",
|
|
46
|
+
"README.zh.md"
|
|
47
|
+
],
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@earendil-works/pi-coding-agent": ">=0.82.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@earendil-works/pi-coding-agent": "^0.82.1",
|
|
56
|
+
"@types/node": "^22.10.0"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"typecheck": "tsc --noEmit"
|
|
60
|
+
}
|
|
61
|
+
}
|