@wassname2/pi-md-export 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/NOTICE +1 -0
- package/README.md +15 -0
- package/extensions/md.ts +717 -0
- package/package.json +26 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Warren Winter
|
|
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/NOTICE
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
`extensions/md.ts` is a modified copy of `extensions/md.ts` from https://github.com/w-winter/dot314 at commit 2618848. Copyright (c) 2026 Warren Winter. It is licensed under the MIT License in LICENSE.
|
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# @wassname2/pi-md-export
|
|
2
|
+
|
|
3
|
+
Export Pi sessions to Markdown files or the clipboard.
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
/md current branch
|
|
7
|
+
/md all full session file
|
|
8
|
+
/md 3 last three turns
|
|
9
|
+
/md tc include tool calls
|
|
10
|
+
/md t include thinking
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Choose copy or save. In Herdr SSH, copying uses OSC 52: the remote terminal tells your local terminal to set its clipboard. If copying is unavailable, the file is saved under `~/.pi/agent/pi-sessions-extracted/`.
|
|
14
|
+
|
|
15
|
+
<!-- PI -->
|
package/extensions/md.ts
ADDED
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { execFileSync } from "child_process";
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
import * as os from "os";
|
|
5
|
+
import * as path from "path";
|
|
6
|
+
import * as readline from "readline";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Processes Pi agent JSONL session logs into readable text format
|
|
10
|
+
* Output directory: ~/.pi/agent/pi-sessions-extracted/
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
interface SessionMeta {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
startedAt: Date | null;
|
|
16
|
+
cwd: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ToolCallInfo {
|
|
20
|
+
name: string;
|
|
21
|
+
cmd: string;
|
|
22
|
+
include: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ToolFilter {
|
|
26
|
+
mode: "all" | "includeOnly";
|
|
27
|
+
includeNames: Set<string>;
|
|
28
|
+
excludeNames: Set<string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ExportOptions {
|
|
32
|
+
includeThinking: boolean;
|
|
33
|
+
toolFilter: ToolFilter | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ConversationState {
|
|
37
|
+
meta: SessionMeta;
|
|
38
|
+
conversation: string[];
|
|
39
|
+
pendingToolCalls: Map<string, ToolCallInfo>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Extract text from various content formats (string, array of content blocks)
|
|
44
|
+
*/
|
|
45
|
+
function extractTextFromContent(content: unknown): string {
|
|
46
|
+
if (content === null || content === undefined) {
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
if (typeof content === "string") {
|
|
50
|
+
return content.trim();
|
|
51
|
+
}
|
|
52
|
+
if (Array.isArray(content)) {
|
|
53
|
+
const parts: string[] = [];
|
|
54
|
+
for (const item of content) {
|
|
55
|
+
if (typeof item !== "object" || item === null) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const itemObj = item as Record<string, unknown>;
|
|
59
|
+
if (itemObj.type !== "text") {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const text = itemObj.text;
|
|
63
|
+
if (typeof text === "string" && text.trim()) {
|
|
64
|
+
parts.push(text.trim());
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return parts.join("\n").trim();
|
|
68
|
+
}
|
|
69
|
+
return "";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Parse ISO-ish timestamp string to Date
|
|
74
|
+
*/
|
|
75
|
+
function parseIsoTimestamp(ts: unknown): Date | null {
|
|
76
|
+
if (!ts || typeof ts !== "string") {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
let normalized = ts;
|
|
81
|
+
if (ts.endsWith("Z")) {
|
|
82
|
+
normalized = ts.slice(0, -1) + "+00:00";
|
|
83
|
+
}
|
|
84
|
+
const date = new Date(normalized);
|
|
85
|
+
return isNaN(date.getTime()) ? null : date;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Create a filesystem-safe slug from a string
|
|
93
|
+
*/
|
|
94
|
+
function slug(s: string): string {
|
|
95
|
+
const out: string[] = [];
|
|
96
|
+
let prevUnderscore = false;
|
|
97
|
+
for (const ch of s) {
|
|
98
|
+
const isAlphaNum = /[a-zA-Z0-9]/.test(ch);
|
|
99
|
+
const isAllowed = isAlphaNum || ch === "-" || ch === "_";
|
|
100
|
+
if (isAllowed) {
|
|
101
|
+
out.push(ch);
|
|
102
|
+
prevUnderscore = false;
|
|
103
|
+
} else if (!prevUnderscore) {
|
|
104
|
+
out.push("_");
|
|
105
|
+
prevUnderscore = true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const result = out.join("").replace(/^_+|_+$/g, "");
|
|
109
|
+
return result || "unknown";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Iterate through JSONL file line by line, yielding parsed objects
|
|
114
|
+
*/
|
|
115
|
+
async function* iterJsonl(
|
|
116
|
+
filePath: string
|
|
117
|
+
): AsyncGenerator<Record<string, unknown>> {
|
|
118
|
+
const fileStream = fs.createReadStream(filePath, { encoding: "utf-8" });
|
|
119
|
+
const rl = readline.createInterface({
|
|
120
|
+
input: fileStream,
|
|
121
|
+
crlfDelay: Infinity,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
for await (const line of rl) {
|
|
125
|
+
const trimmed = line.trim();
|
|
126
|
+
if (!trimmed) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const obj = JSON.parse(trimmed);
|
|
131
|
+
if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) {
|
|
132
|
+
yield obj as Record<string, unknown>;
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// Skip malformed JSON lines
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeToolName(name: string): string {
|
|
142
|
+
return name.trim().toLowerCase();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function createConversationState(): ConversationState {
|
|
146
|
+
return {
|
|
147
|
+
meta: {
|
|
148
|
+
sessionId: "",
|
|
149
|
+
startedAt: null,
|
|
150
|
+
cwd: "",
|
|
151
|
+
},
|
|
152
|
+
conversation: [],
|
|
153
|
+
pendingToolCalls: new Map(),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function formatThinkingBlock(thinking: string): string {
|
|
158
|
+
return `[thinking]\n${thinking.trim()}\n[/thinking]`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function extractToolCommand(args: unknown): string {
|
|
162
|
+
if (typeof args === "object" && args !== null && !Array.isArray(args)) {
|
|
163
|
+
const argsObj = args as Record<string, unknown>;
|
|
164
|
+
const cmdVal = argsObj.command || argsObj.cmd;
|
|
165
|
+
if (typeof cmdVal === "string") {
|
|
166
|
+
return cmdVal;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
return JSON.stringify(args, Object.keys(args).sort());
|
|
170
|
+
} catch {
|
|
171
|
+
return String(args);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return String(args);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function shouldIncludeTool(toolName: string, toolFilter: ToolFilter | null): boolean {
|
|
178
|
+
if (!toolFilter) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const normalized = normalizeToolName(toolName);
|
|
183
|
+
if (toolFilter.excludeNames.has(normalized)) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
if (toolFilter.mode === "includeOnly") {
|
|
187
|
+
return toolFilter.includeNames.has(normalized);
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function applyMessageRecord(
|
|
193
|
+
rec: Record<string, unknown>,
|
|
194
|
+
state: ConversationState,
|
|
195
|
+
options: ExportOptions
|
|
196
|
+
): void {
|
|
197
|
+
const rtype = rec.type;
|
|
198
|
+
|
|
199
|
+
if (rtype === "session" && !state.meta.sessionId) {
|
|
200
|
+
state.meta.sessionId = String(rec.id || "");
|
|
201
|
+
state.meta.startedAt = parseIsoTimestamp(rec.timestamp);
|
|
202
|
+
state.meta.cwd = String(rec.cwd || "");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (rtype !== "message") {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const msg = rec.message;
|
|
211
|
+
if (typeof msg !== "object" || msg === null || Array.isArray(msg)) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const msgObj = msg as Record<string, unknown>;
|
|
216
|
+
const role = msgObj.role;
|
|
217
|
+
|
|
218
|
+
if (role === "user") {
|
|
219
|
+
const text = extractTextFromContent(msgObj.content);
|
|
220
|
+
if (text) {
|
|
221
|
+
state.conversation.push(`USER: ${text}`);
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (role === "assistant") {
|
|
227
|
+
appendAssistantMessage(msgObj, state, options);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (role === "toolResult") {
|
|
232
|
+
appendToolResultMessage(msgObj, state, options.toolFilter);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function appendAssistantMessage(
|
|
237
|
+
msgObj: Record<string, unknown>,
|
|
238
|
+
state: ConversationState,
|
|
239
|
+
options: ExportOptions
|
|
240
|
+
): void {
|
|
241
|
+
const content = msgObj.content;
|
|
242
|
+
const textParts: string[] = [];
|
|
243
|
+
const toolParts: string[] = [];
|
|
244
|
+
|
|
245
|
+
if (typeof content === "string") {
|
|
246
|
+
if (content.trim()) {
|
|
247
|
+
textParts.push(content.trim());
|
|
248
|
+
}
|
|
249
|
+
} else if (Array.isArray(content)) {
|
|
250
|
+
for (const item of content) {
|
|
251
|
+
if (typeof item !== "object" || item === null) {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const itemObj = item as Record<string, unknown>;
|
|
255
|
+
const itemType = itemObj.type;
|
|
256
|
+
|
|
257
|
+
if (itemType === "text") {
|
|
258
|
+
const text = itemObj.text;
|
|
259
|
+
if (typeof text === "string" && text.trim()) {
|
|
260
|
+
textParts.push(text.trim());
|
|
261
|
+
}
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (itemType === "thinking") {
|
|
266
|
+
const thinking = itemObj.thinking;
|
|
267
|
+
if (
|
|
268
|
+
options.includeThinking &&
|
|
269
|
+
typeof thinking === "string" &&
|
|
270
|
+
thinking.trim()
|
|
271
|
+
) {
|
|
272
|
+
textParts.push(formatThinkingBlock(thinking));
|
|
273
|
+
}
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (itemType !== "toolCall") {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const toolName = String(itemObj.name || "tool");
|
|
282
|
+
const toolId = String(itemObj.id || "");
|
|
283
|
+
const cmd = extractToolCommand(itemObj.arguments);
|
|
284
|
+
const include = shouldIncludeTool(toolName, options.toolFilter);
|
|
285
|
+
|
|
286
|
+
if (toolId) {
|
|
287
|
+
state.pendingToolCalls.set(toolId, { name: toolName, cmd, include });
|
|
288
|
+
}
|
|
289
|
+
if (include) {
|
|
290
|
+
toolParts.push(`[tool:${toolName}] ${cmd}`.trim());
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let fullText = textParts.filter(Boolean).join("\n").trim();
|
|
296
|
+
if (toolParts.length > 0) {
|
|
297
|
+
fullText = (fullText ? `${fullText}\n` : "") + toolParts.join("\n");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (fullText) {
|
|
301
|
+
state.conversation.push(`ASSISTANT: ${fullText}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function appendToolResultMessage(
|
|
306
|
+
msgObj: Record<string, unknown>,
|
|
307
|
+
state: ConversationState,
|
|
308
|
+
toolFilter: ToolFilter | null
|
|
309
|
+
): void {
|
|
310
|
+
const toolName = String(msgObj.toolName || msgObj.tool_name || "tool");
|
|
311
|
+
const toolCallId = String(msgObj.toolCallId || msgObj.tool_call_id || "");
|
|
312
|
+
|
|
313
|
+
let label = toolName;
|
|
314
|
+
let include = shouldIncludeTool(toolName, toolFilter);
|
|
315
|
+
|
|
316
|
+
if (toolCallId && state.pendingToolCalls.has(toolCallId)) {
|
|
317
|
+
const pending = state.pendingToolCalls.get(toolCallId)!;
|
|
318
|
+
state.pendingToolCalls.delete(toolCallId);
|
|
319
|
+
label = pending.name || toolName;
|
|
320
|
+
include = pending.include;
|
|
321
|
+
if (pending.cmd) {
|
|
322
|
+
label = `${label}: ${pending.cmd}`;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (!include) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const isError = Boolean(msgObj.isError || msgObj.is_error);
|
|
331
|
+
const out = extractTextFromContent(msgObj.content);
|
|
332
|
+
if (out && out !== "(no content)") {
|
|
333
|
+
state.conversation.push(
|
|
334
|
+
`SYSTEM [${label} output${isError ? " ERROR" : ""}]:\n${out}`
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Extract conversation messages from a Pi session JSONL file
|
|
341
|
+
*/
|
|
342
|
+
async function extractConversation(
|
|
343
|
+
jsonlFile: string,
|
|
344
|
+
options: ExportOptions
|
|
345
|
+
): Promise<{ meta: SessionMeta; conversation: string[] }> {
|
|
346
|
+
const state = createConversationState();
|
|
347
|
+
|
|
348
|
+
for await (const rec of iterJsonl(jsonlFile)) {
|
|
349
|
+
applyMessageRecord(rec, state, options);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return { meta: state.meta, conversation: state.conversation };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Extract conversation messages from the *currently selected branch* (root → current leaf)
|
|
357
|
+
* using the in-memory SessionManager tree state.
|
|
358
|
+
*
|
|
359
|
+
* This intentionally includes *all* turns on the branch, including turns that may no longer
|
|
360
|
+
* be shown in the TUI after compaction.
|
|
361
|
+
*/
|
|
362
|
+
function extractConversationFromBranch(
|
|
363
|
+
sessionManager: any,
|
|
364
|
+
options: ExportOptions
|
|
365
|
+
): { meta: SessionMeta; conversation: string[]; leafId: string | null } {
|
|
366
|
+
const state = createConversationState();
|
|
367
|
+
const header =
|
|
368
|
+
typeof sessionManager.getHeader === "function"
|
|
369
|
+
? sessionManager.getHeader()
|
|
370
|
+
: null;
|
|
371
|
+
|
|
372
|
+
if (header && typeof header === "object") {
|
|
373
|
+
const headerObj = header as Record<string, unknown>;
|
|
374
|
+
state.meta.sessionId = typeof headerObj.id === "string" ? headerObj.id : "";
|
|
375
|
+
state.meta.startedAt = parseIsoTimestamp(headerObj.timestamp);
|
|
376
|
+
if (typeof headerObj.cwd === "string") {
|
|
377
|
+
state.meta.cwd = headerObj.cwd;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (!state.meta.cwd && typeof sessionManager.getCwd === "function") {
|
|
382
|
+
state.meta.cwd = String(sessionManager.getCwd() || "");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const leafId =
|
|
386
|
+
typeof sessionManager.getLeafId === "function"
|
|
387
|
+
? (sessionManager.getLeafId() as string | null)
|
|
388
|
+
: null;
|
|
389
|
+
|
|
390
|
+
const branchEntries: unknown[] =
|
|
391
|
+
leafId && typeof sessionManager.getBranch === "function"
|
|
392
|
+
? (sessionManager.getBranch(leafId) as unknown[])
|
|
393
|
+
: [];
|
|
394
|
+
|
|
395
|
+
for (const entry of branchEntries) {
|
|
396
|
+
if (typeof entry !== "object" || entry === null) {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
applyMessageRecord(entry as Record<string, unknown>, state, options);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return { meta: state.meta, conversation: state.conversation, leafId };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function buildMarkdownContent(
|
|
406
|
+
meta: SessionMeta,
|
|
407
|
+
sessionFile: string,
|
|
408
|
+
conversation: string[],
|
|
409
|
+
lastTurns: number | null,
|
|
410
|
+
branchInfo?: { leafId: string | null }
|
|
411
|
+
): { content: string; filename: string } | null {
|
|
412
|
+
const finalConversation =
|
|
413
|
+
lastTurns && lastTurns > 0 ? sliceLastNTurns(conversation, lastTurns) : conversation;
|
|
414
|
+
|
|
415
|
+
if (finalConversation.length === 0) {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const project = meta.cwd ? path.basename(meta.cwd) : "unknown";
|
|
420
|
+
const projectSlug = slug(project);
|
|
421
|
+
|
|
422
|
+
const started = meta.startedAt || new Date();
|
|
423
|
+
const stamp = formatTimestamp(started);
|
|
424
|
+
const sid = (meta.sessionId || "unknown").slice(0, 8);
|
|
425
|
+
const filename = `${projectSlug}_pi_${stamp}_${sid}.md`;
|
|
426
|
+
|
|
427
|
+
const lines: string[] = [];
|
|
428
|
+
lines.push("PI SESSION (processed)");
|
|
429
|
+
if (branchInfo) {
|
|
430
|
+
lines.push("mode: branch");
|
|
431
|
+
lines.push(`leaf: ${branchInfo.leafId === null ? "null" : branchInfo.leafId}`);
|
|
432
|
+
}
|
|
433
|
+
if (meta.sessionId) {
|
|
434
|
+
lines.push(`id: ${meta.sessionId}`);
|
|
435
|
+
}
|
|
436
|
+
if (meta.startedAt) {
|
|
437
|
+
lines.push(`started: ${meta.startedAt.toISOString()}`);
|
|
438
|
+
}
|
|
439
|
+
if (meta.cwd) {
|
|
440
|
+
lines.push(`cwd: ${meta.cwd}`);
|
|
441
|
+
}
|
|
442
|
+
lines.push(`source: ${sessionFile}`);
|
|
443
|
+
if (lastTurns && lastTurns > 0) {
|
|
444
|
+
lines.push(`turns: last ${lastTurns}`);
|
|
445
|
+
}
|
|
446
|
+
lines.push("");
|
|
447
|
+
|
|
448
|
+
for (const msg of finalConversation) {
|
|
449
|
+
lines.push(msg.trimEnd());
|
|
450
|
+
lines.push("");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return { content: lines.join("\n"), filename };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Generate markdown content from the current branch without writing to file
|
|
458
|
+
*/
|
|
459
|
+
function generateMarkdownFromBranch(
|
|
460
|
+
sessionManager: any,
|
|
461
|
+
sessionFile: string,
|
|
462
|
+
options: ExportOptions,
|
|
463
|
+
lastTurns: number | null = null
|
|
464
|
+
): { content: string; filename: string } | null {
|
|
465
|
+
const { meta, conversation, leafId } = extractConversationFromBranch(
|
|
466
|
+
sessionManager,
|
|
467
|
+
options
|
|
468
|
+
);
|
|
469
|
+
return buildMarkdownContent(meta, sessionFile, conversation, lastTurns, {
|
|
470
|
+
leafId,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Generate markdown content from full session file without writing to file
|
|
476
|
+
*/
|
|
477
|
+
async function generateMarkdownFromSession(
|
|
478
|
+
jsonlFile: string,
|
|
479
|
+
options: ExportOptions,
|
|
480
|
+
lastTurns: number | null = null
|
|
481
|
+
): Promise<{ content: string; filename: string } | null> {
|
|
482
|
+
const { meta, conversation } = await extractConversation(jsonlFile, options);
|
|
483
|
+
return buildMarkdownContent(meta, jsonlFile, conversation, lastTurns);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** PI: Copy text through the first available platform clipboard command. */
|
|
487
|
+
export function clipboardCommands(): Array<[string, string[]]> {
|
|
488
|
+
return [
|
|
489
|
+
["pbcopy", []],
|
|
490
|
+
["wl-copy", []],
|
|
491
|
+
["xclip", ["-selection", "clipboard"]],
|
|
492
|
+
];
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function osc52Sequence(text: string): string {
|
|
496
|
+
return `\u001b]52;c;${Buffer.from(text).toString("base64")}\u0007`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function copyToClipboard(text: string, allowOsc52: boolean): void {
|
|
500
|
+
const commands = clipboardCommands();
|
|
501
|
+
const errors: string[] = [];
|
|
502
|
+
for (const [command, args] of commands) {
|
|
503
|
+
try {
|
|
504
|
+
execFileSync(command, args, { input: text, stdio: ["pipe", "ignore", "pipe"] });
|
|
505
|
+
return;
|
|
506
|
+
} catch (error) {
|
|
507
|
+
errors.push(`${command}: ${error instanceof Error ? error.message : String(error)}`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if (allowOsc52 && process.stdout.isTTY) {
|
|
511
|
+
process.stdout.write(osc52Sequence(text));
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
throw new Error(`No clipboard command succeeded. ${errors.join("; ")}`);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function saveMarkdown(content: string, filename: string): string {
|
|
518
|
+
const outputFile = path.join(OUTPUT_DIR, filename);
|
|
519
|
+
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
520
|
+
fs.writeFileSync(outputFile, content, "utf-8");
|
|
521
|
+
return outputFile;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Format date as YYYYMMDD_HHMMSS
|
|
526
|
+
*/
|
|
527
|
+
function formatTimestamp(date: Date): string {
|
|
528
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
529
|
+
return (
|
|
530
|
+
`${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_` +
|
|
531
|
+
`${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Slice a flattened conversation array to the last N turns
|
|
537
|
+
*
|
|
538
|
+
* A turn is defined as a unit of [USER message -> ASSISTANT message], including any
|
|
539
|
+
* SYSTEM tool outputs that occur in between, until the next USER message begins.
|
|
540
|
+
*/
|
|
541
|
+
function sliceLastNTurns(conversation: string[], turns: number): string[] {
|
|
542
|
+
if (!Number.isFinite(turns) || turns <= 0) {
|
|
543
|
+
return conversation;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const userMessageIndices: number[] = [];
|
|
547
|
+
for (let i = 0; i < conversation.length; i++) {
|
|
548
|
+
if (conversation[i].startsWith("USER:")) {
|
|
549
|
+
userMessageIndices.push(i);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (userMessageIndices.length === 0) {
|
|
554
|
+
return conversation;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const startIndex =
|
|
558
|
+
userMessageIndices.length <= turns
|
|
559
|
+
? 0
|
|
560
|
+
: userMessageIndices[userMessageIndices.length - turns];
|
|
561
|
+
|
|
562
|
+
return conversation.slice(startIndex);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function parseToolFilter(tokensLower: string[]): ToolFilter {
|
|
566
|
+
const includeNames = new Set<string>();
|
|
567
|
+
const excludeNames = new Set<string>();
|
|
568
|
+
|
|
569
|
+
for (const token of tokensLower) {
|
|
570
|
+
if (!["+", "-"].includes(token[0]) || token.length < 2) {
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const name = normalizeToolName(token.slice(1));
|
|
575
|
+
if (!name) {
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (token.startsWith("+")) {
|
|
580
|
+
includeNames.add(name);
|
|
581
|
+
} else {
|
|
582
|
+
excludeNames.add(name);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return {
|
|
587
|
+
mode: includeNames.size > 0 ? "includeOnly" : "all",
|
|
588
|
+
includeNames,
|
|
589
|
+
excludeNames,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function hasToolFilterTokens(tokensLower: string[]): boolean {
|
|
594
|
+
return tokensLower.some((token) => /^[+-].+/.test(token));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function describeToolFilter(toolFilter: ToolFilter | null): string | null {
|
|
598
|
+
if (!toolFilter) {
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const includes = [...toolFilter.includeNames].sort().map((name) => `+${name}`);
|
|
603
|
+
const excludes = [...toolFilter.excludeNames].sort().map((name) => `-${name}`);
|
|
604
|
+
|
|
605
|
+
if (toolFilter.mode === "includeOnly") {
|
|
606
|
+
return ["tool calls", ...includes, ...excludes].join(" ");
|
|
607
|
+
}
|
|
608
|
+
if (excludes.length > 0) {
|
|
609
|
+
return ["tool calls", ...excludes].join(" ");
|
|
610
|
+
}
|
|
611
|
+
return "tool calls";
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const OUTPUT_DIR = path.join(os.homedir(), ".pi", "agent", "pi-sessions-extracted");
|
|
615
|
+
|
|
616
|
+
export default function (pi: ExtensionAPI) {
|
|
617
|
+
pi.registerCommand("md", {
|
|
618
|
+
description:
|
|
619
|
+
"Export current session as markdown (current /tree branch) on clipboard or to file. Tool calls and thinking blocks are excluded by default. Use '/md tc' to include tool calls, optionally filtered with exact tool names like '/md tc -bash -read' or '/md tc +ask'. Use '/md t' to include thinking blocks. Use '/md all' for full file. Pass a number (e.g. '/md 2' or '/md tc t 2') to export only the last N turns.",
|
|
620
|
+
handler: async (args, ctx) => {
|
|
621
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
622
|
+
|
|
623
|
+
if (!sessionFile) {
|
|
624
|
+
ctx.ui.notify("No session file (ephemeral session)", "error");
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const argsTrimmed = args.trim();
|
|
629
|
+
const tokens = argsTrimmed ? argsTrimmed.split(/\s+/).filter(Boolean) : [];
|
|
630
|
+
const tokensLower = tokens.map((t) => t.toLowerCase());
|
|
631
|
+
|
|
632
|
+
const includeThinking =
|
|
633
|
+
tokensLower.includes("t") ||
|
|
634
|
+
tokensLower.includes("think") ||
|
|
635
|
+
tokensLower.includes("thinking");
|
|
636
|
+
|
|
637
|
+
const includeToolCalls = tokensLower.includes("tc");
|
|
638
|
+
const exportAll = tokensLower.includes("all") || tokensLower.includes("file");
|
|
639
|
+
const toolFilterTokensPresent = hasToolFilterTokens(tokensLower);
|
|
640
|
+
|
|
641
|
+
if (toolFilterTokensPresent && !includeToolCalls) {
|
|
642
|
+
ctx.ui.notify("Tool filters require 'tc' (e.g. /md tc -bash or /md tc +ask)", "error");
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (tokensLower.includes("+all") || tokensLower.includes("-all")) {
|
|
647
|
+
ctx.ui.notify("'+all' and '-all' are not supported; use '/md tc' or '/md tc +tool'", "error");
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const toolFilter = includeToolCalls ? parseToolFilter(tokensLower) : null;
|
|
652
|
+
const options: ExportOptions = {
|
|
653
|
+
includeThinking,
|
|
654
|
+
toolFilter,
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
const turnsToken = tokens.find((t) => /^\d+$/.test(t)) || null;
|
|
658
|
+
const lastTurns = turnsToken ? parseInt(turnsToken, 10) : null;
|
|
659
|
+
if (turnsToken && (!Number.isFinite(lastTurns) || lastTurns < 1)) {
|
|
660
|
+
ctx.ui.notify("Turn limit must be >= 1 (e.g. /md 2)", "error");
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const turnSuffix = lastTurns ? ` (last ${lastTurns} turns)` : "";
|
|
665
|
+
const extras: string[] = [];
|
|
666
|
+
if (includeThinking) {
|
|
667
|
+
extras.push("thinking");
|
|
668
|
+
}
|
|
669
|
+
const toolFilterDescription = describeToolFilter(toolFilter);
|
|
670
|
+
if (toolFilterDescription) {
|
|
671
|
+
extras.push(toolFilterDescription);
|
|
672
|
+
}
|
|
673
|
+
const extrasSuffix = extras.length > 0 ? ` (with ${extras.join(" + ")})` : "";
|
|
674
|
+
const title = `Export session${turnSuffix}${extrasSuffix} as Markdown`;
|
|
675
|
+
const choice = await ctx.ui.select(`${title}\n\nSelect export method:`, [
|
|
676
|
+
"Copy to clipboard",
|
|
677
|
+
`Save to .md file in ${OUTPUT_DIR}/`,
|
|
678
|
+
]);
|
|
679
|
+
|
|
680
|
+
if (!choice) {
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
try {
|
|
685
|
+
const result = exportAll
|
|
686
|
+
? await generateMarkdownFromSession(sessionFile, options, lastTurns)
|
|
687
|
+
: generateMarkdownFromBranch(ctx.sessionManager, sessionFile, options, lastTurns);
|
|
688
|
+
|
|
689
|
+
if (!result) {
|
|
690
|
+
const mode = exportAll ? "session file" : "current branch";
|
|
691
|
+
ctx.ui.notify(`No meaningful conversation found in ${mode}`, "error");
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const suffix = extrasSuffix;
|
|
696
|
+
const mode = exportAll ? " (full file)" : " (branch)";
|
|
697
|
+
|
|
698
|
+
if (choice === "Copy to clipboard") {
|
|
699
|
+
try {
|
|
700
|
+
copyToClipboard(result.content, ctx.mode === "tui");
|
|
701
|
+
ctx.ui.notify(`Copied to clipboard${suffix}${mode}`, "success");
|
|
702
|
+
} catch {
|
|
703
|
+
const outputFile = saveMarkdown(result.content, result.filename);
|
|
704
|
+
ctx.ui.notify(`No system clipboard; saved${suffix}${mode}: ${outputFile}`, "warning");
|
|
705
|
+
}
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const outputFile = saveMarkdown(result.content, result.filename);
|
|
710
|
+
ctx.ui.notify(`Saved${suffix}${mode}: ${outputFile}`, "success");
|
|
711
|
+
} catch (err) {
|
|
712
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
713
|
+
ctx.ui.notify(`Export failed: ${errMsg}`, "error");
|
|
714
|
+
}
|
|
715
|
+
},
|
|
716
|
+
});
|
|
717
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wassname2/pi-md-export",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Export Pi sessions to Markdown files or the system clipboard",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": ["pi-package", "pi", "pi-extension", "markdown", "session"],
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/wassname/pi-md-export.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/wassname/pi-md-export#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/wassname/pi-md-export/issues"
|
|
15
|
+
},
|
|
16
|
+
"files": ["extensions", "README.md", "NOTICE", "LICENSE", "package.json"],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --experimental-strip-types --test tests/*.test.ts"
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@earendil-works/pi-coding-agent": ">=0.84.0 <1.0.0"
|
|
22
|
+
},
|
|
23
|
+
"pi": {
|
|
24
|
+
"extensions": ["./extensions/md.ts"]
|
|
25
|
+
}
|
|
26
|
+
}
|