pi-resume 1.0.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 +68 -0
- package/extensions/index.ts +158 -0
- package/package.json +47 -0
- package/src/config.ts +51 -0
- package/src/format.ts +34 -0
- package/src/scanner.ts +167 -0
- package/src/session-dir.ts +15 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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,68 @@
|
|
|
1
|
+
# pi-fast-resume
|
|
2
|
+
|
|
3
|
+
Fast session resume for [pi coding agent](https://pi.dev) without reading all `.jsonl` files.
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
Built-in `/resume` reads and parses **every line** of every session file to build the picker. With hundreds of sessions this is slow.
|
|
8
|
+
|
|
9
|
+
## Solution
|
|
10
|
+
|
|
11
|
+
Two commands that use `stat()` + lazy partial reads:
|
|
12
|
+
|
|
13
|
+
| Command | What it does | Speed |
|
|
14
|
+
|---------|-------------|-------|
|
|
15
|
+
| `/r2` | Instantly switch to the most recent session | <50ms (stat-only) |
|
|
16
|
+
| `/rs` | Paginated picker: last 20, with tier navigation | <200ms first page |
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pi install npm:pi-fast-resume
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Commands
|
|
25
|
+
|
|
26
|
+
### `/r2` — Instant Resume
|
|
27
|
+
|
|
28
|
+
Switches to the most recent session (by mtime) in one step. No picker, no parsing.
|
|
29
|
+
|
|
30
|
+
### `/rs` — Smart Resume
|
|
31
|
+
|
|
32
|
+
Shows a paginated list of recent sessions:
|
|
33
|
+
|
|
34
|
+
- **Relative time** (e.g., "2h ago"), **file size**, **session name or first message**
|
|
35
|
+
- **`▼ Load more...`** — next page within current filter
|
|
36
|
+
- **`▼ Show 14d`** — expand to 14 days
|
|
37
|
+
- **`▼ Show all`** — remove day filter entirely
|
|
38
|
+
|
|
39
|
+
Auto-escalates: if 7d is empty, jumps to 14d, then all.
|
|
40
|
+
|
|
41
|
+
### Configuration
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
/rs set page 30 # Sessions per page (1-50, default: 20)
|
|
45
|
+
/rs set days 14 # Day filter for first tier (0-30, 0 = no filter, default: 7)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Config is stored in `~/.pi/agent/extensions/pi-fast-resume/config.json`.
|
|
49
|
+
|
|
50
|
+
## How it works
|
|
51
|
+
|
|
52
|
+
1. **`/r2`**: `readdir` → `stat` each `.jsonl` → sort by mtime → `switchSession(newest)`
|
|
53
|
+
2. **`/rs`**: Same stat scan, then read only the **first ~50 lines** of each file on the current page to extract session name and first user message
|
|
54
|
+
|
|
55
|
+
No full file parsing. No `buildSessionInfo()`. No reading message content beyond the first user message.
|
|
56
|
+
|
|
57
|
+
## Development
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
git clone https://github.com/spex66/pi-fast-resume.git
|
|
61
|
+
cd pi-fast-resume
|
|
62
|
+
npm install
|
|
63
|
+
npm test
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-fast-resume — fast session resume without reading all .jsonl files.
|
|
3
|
+
*
|
|
4
|
+
* Commands:
|
|
5
|
+
* /r2 — instantly switch to the most recent session (stat-only)
|
|
6
|
+
* /rs — paginated session picker (last 20, "Load more", tier filter)
|
|
7
|
+
* /rs set page N — set page size (1-50)
|
|
8
|
+
* /rs set days N — set maxDays filter (0-30, 0 = no limit)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { statScan, scanPage, readSessionMeta } from "../src/scanner.ts";
|
|
13
|
+
import { formatEntry, truncate, sessionLabel } from "../src/format.ts";
|
|
14
|
+
import { loadConfig, saveConfig } from "../src/config.ts";
|
|
15
|
+
import { getSessionDir } from "../src/session-dir.ts";
|
|
16
|
+
|
|
17
|
+
const DAY_TIERS = [7, 14, 0] as const;
|
|
18
|
+
|
|
19
|
+
export default function (pi: ExtensionAPI) {
|
|
20
|
+
pi.registerCommand("r2", {
|
|
21
|
+
description: "Instantly resume the most recent session",
|
|
22
|
+
handler: async (_args: string, ctx: any) => {
|
|
23
|
+
const sessionDir = getSessionDir(ctx.cwd);
|
|
24
|
+
const files = await statScan(sessionDir);
|
|
25
|
+
|
|
26
|
+
if (files.length === 0) {
|
|
27
|
+
ctx.ui.notify("No sessions found", "error");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const currentFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
|
32
|
+
const target = files.find((f: { file: string }) => f.file !== currentFile);
|
|
33
|
+
|
|
34
|
+
if (!target) {
|
|
35
|
+
ctx.ui.notify("No other sessions to resume", "info");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const meta = await readSessionMeta(target.file, target);
|
|
40
|
+
await ctx.switchSession(target.file, {
|
|
41
|
+
withSession: async (newCtx: any) => {
|
|
42
|
+
newCtx.ui.notify(`Resumed: ${truncate(sessionLabel(meta), 50)}`, "info");
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
pi.registerCommand("rs", {
|
|
49
|
+
description: "Smart resume: paginated session picker (last 20, Load more, tier filter)",
|
|
50
|
+
handler: async (args: string, ctx: any) => {
|
|
51
|
+
const parts = (args || "").trim().split(/\s+/);
|
|
52
|
+
const cfg = loadConfig();
|
|
53
|
+
|
|
54
|
+
// /rs set page N | /rs set days N
|
|
55
|
+
if (parts[0] === "set") {
|
|
56
|
+
const key = parts[1];
|
|
57
|
+
const val = parseInt(parts[2], 10);
|
|
58
|
+
|
|
59
|
+
if (key === "page" && !isNaN(val) && val >= 1 && val <= 50) {
|
|
60
|
+
cfg.pageSize = val;
|
|
61
|
+
saveConfig(cfg);
|
|
62
|
+
ctx.ui.notify(`Page size set to ${val}`, "info");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (key === "days" && !isNaN(val) && val >= 0 && val <= 30) {
|
|
66
|
+
cfg.maxDays = val;
|
|
67
|
+
saveConfig(cfg);
|
|
68
|
+
ctx.ui.notify(val === 0 ? "Day filter disabled" : `Max days set to ${val}`, "info");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
ctx.ui.notify("Usage: /rs set page N (1-50) | /rs set days N (0-30)", "error");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const sessionDir = getSessionDir(ctx.cwd);
|
|
77
|
+
const currentFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
|
78
|
+
|
|
79
|
+
let tierIndex = 0;
|
|
80
|
+
let offset = 0;
|
|
81
|
+
|
|
82
|
+
while (true) {
|
|
83
|
+
const currentDays = DAY_TIERS[tierIndex] ?? 0;
|
|
84
|
+
const nextTierDays = DAY_TIERS[tierIndex + 1];
|
|
85
|
+
|
|
86
|
+
const { entries, total, hasMore } = await scanPage(
|
|
87
|
+
sessionDir,
|
|
88
|
+
offset,
|
|
89
|
+
cfg.pageSize,
|
|
90
|
+
currentDays > 0 ? currentDays : undefined,
|
|
91
|
+
currentFile,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (entries.length === 0 && offset === 0) {
|
|
95
|
+
if (tierIndex < DAY_TIERS.length - 1) {
|
|
96
|
+
tierIndex++;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
ctx.ui.notify("No sessions found", "info");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const items: string[] = entries.map((e) => formatEntry(e));
|
|
104
|
+
|
|
105
|
+
if (hasMore) {
|
|
106
|
+
const remaining = total - offset - entries.length;
|
|
107
|
+
items.push(`▼ Load more... (${remaining} remaining)`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (nextTierDays !== undefined) {
|
|
111
|
+
const tierLabel = nextTierDays > 0 ? `${nextTierDays}d` : "all";
|
|
112
|
+
items.push(`▼ Show ${tierLabel}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const filterLabel = currentDays > 0 ? ` (last ${currentDays}d)` : "";
|
|
116
|
+
const rangeLabel =
|
|
117
|
+
offset === 0
|
|
118
|
+
? `Sessions 1-${entries.length} of ${total}${filterLabel}`
|
|
119
|
+
: `Sessions ${offset + 1}-${offset + entries.length} of ${total}${filterLabel}`;
|
|
120
|
+
|
|
121
|
+
const choice = await ctx.ui.select(rangeLabel, items);
|
|
122
|
+
|
|
123
|
+
if (choice === undefined || choice === null) return;
|
|
124
|
+
|
|
125
|
+
const choiceIndex = items.indexOf(choice);
|
|
126
|
+
|
|
127
|
+
if (hasMore && choiceIndex === entries.length) {
|
|
128
|
+
offset += cfg.pageSize;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (choice.startsWith("▼ Show ") && nextTierDays !== undefined) {
|
|
133
|
+
tierIndex++;
|
|
134
|
+
offset = 0;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (choiceIndex >= 0 && choiceIndex < entries.length) {
|
|
139
|
+
const selected = entries[choiceIndex];
|
|
140
|
+
if (!selected) return;
|
|
141
|
+
|
|
142
|
+
const result = await ctx.switchSession(selected.file, {
|
|
143
|
+
withSession: async (newCtx: any) => {
|
|
144
|
+
newCtx.ui.notify(`Resumed: ${truncate(sessionLabel(selected), 50)}`, "info");
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (result.cancelled) {
|
|
149
|
+
ctx.ui.notify("Session switch was cancelled", "info");
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-resume",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Fast session resume for pi coding agent — /r2 instant resume, /rs paginated picker",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi-extension",
|
|
10
|
+
"pi",
|
|
11
|
+
"session",
|
|
12
|
+
"resume"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/spex66/pi-fast-resume.git"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"extensions",
|
|
20
|
+
"src",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"pi": {
|
|
25
|
+
"extensions": [
|
|
26
|
+
"./extensions/index.ts"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18.0.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
34
|
+
"@earendil-works/pi-tui": "*"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
38
|
+
"@earendil-works/pi-tui": "*",
|
|
39
|
+
"typescript": "^5.0.0"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"test": "node --experimental-vm-modules --test tests/*.test.ts",
|
|
44
|
+
"check": "npm run typecheck && npm pack --dry-run",
|
|
45
|
+
"prepublishOnly": "npm run check"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config management. Stores user prefs in ~/.pi/agent/extensions/pi-fast-resume/config.json.
|
|
3
|
+
* Creates defaults at runtime — nothing shipped in the package.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
const CONFIG_DIR = join(
|
|
10
|
+
process.env.PI_CODING_AGENT_DIR || join(process.env.HOME || "~", ".pi", "agent"),
|
|
11
|
+
"extensions",
|
|
12
|
+
"pi-fast-resume",
|
|
13
|
+
);
|
|
14
|
+
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
15
|
+
|
|
16
|
+
export interface Config {
|
|
17
|
+
pageSize: number;
|
|
18
|
+
maxDays: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULTS: Config = { pageSize: 20, maxDays: 7 };
|
|
22
|
+
const MIN_PAGE = 1;
|
|
23
|
+
const MAX_PAGE = 50;
|
|
24
|
+
const MIN_DAYS = 0;
|
|
25
|
+
const MAX_DAYS = 30;
|
|
26
|
+
|
|
27
|
+
export function clampPage(n: number): number {
|
|
28
|
+
return Math.min(MAX_PAGE, Math.max(MIN_PAGE, n));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function clampDays(n: number): number {
|
|
32
|
+
return Math.min(MAX_DAYS, Math.max(MIN_DAYS, n));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function loadConfig(): Config {
|
|
36
|
+
try {
|
|
37
|
+
if (existsSync(CONFIG_PATH)) {
|
|
38
|
+
const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
39
|
+
return {
|
|
40
|
+
pageSize: clampPage(Number(raw.pageSize) || DEFAULTS.pageSize),
|
|
41
|
+
maxDays: clampDays(Number(raw.maxDays) || DEFAULTS.maxDays),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
} catch {}
|
|
45
|
+
return { ...DEFAULTS };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function saveConfig(cfg: Config): void {
|
|
49
|
+
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
|
|
50
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
|
|
51
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure formatting functions. No pi SDK dependency.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { SessionEntry } from "./scanner.ts";
|
|
6
|
+
|
|
7
|
+
export function formatSize(bytes: number): string {
|
|
8
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
9
|
+
if (bytes < 1048576) return `${(bytes / 1024).toFixed(0)}KB`;
|
|
10
|
+
return `${(bytes / 1048576).toFixed(1)}MB`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatAge(mtime: Date): string {
|
|
14
|
+
const diff = Date.now() - mtime.getTime();
|
|
15
|
+
const mins = Math.floor(diff / 60000);
|
|
16
|
+
if (mins < 1) return "just now";
|
|
17
|
+
if (mins < 60) return `${mins}m ago`;
|
|
18
|
+
const hours = Math.floor(mins / 60);
|
|
19
|
+
if (hours < 24) return `${hours}h ago`;
|
|
20
|
+
const days = Math.floor(hours / 24);
|
|
21
|
+
return `${days}d ago`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function truncate(text: string, max: number): string {
|
|
25
|
+
return text.length > max ? text.slice(0, max - 3) + "..." : text;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function sessionLabel(e: SessionEntry): string {
|
|
29
|
+
return e.name || e.firstMessage || e.id || "untitled";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatEntry(e: SessionEntry): string {
|
|
33
|
+
return `${formatAge(e.mtime).padEnd(10)} ${formatSize(e.size).padEnd(8)} ${truncate(sessionLabel(e), 60)}`;
|
|
34
|
+
}
|
package/src/scanner.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fast session scanner using stat + lazy header parse.
|
|
3
|
+
*
|
|
4
|
+
* Key principle: never read full .jsonl files.
|
|
5
|
+
* - stat() for mtime/size sorting
|
|
6
|
+
* - First ~50 lines for header, session_info, first user message
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readdir, stat } from "node:fs/promises";
|
|
10
|
+
import { createReadStream } from "node:fs";
|
|
11
|
+
import { createInterface } from "node:readline";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
export interface SessionEntry {
|
|
15
|
+
file: string;
|
|
16
|
+
mtime: Date;
|
|
17
|
+
size: number;
|
|
18
|
+
id?: string;
|
|
19
|
+
timestamp?: string;
|
|
20
|
+
cwd?: string;
|
|
21
|
+
name?: string;
|
|
22
|
+
firstMessage?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface StatResult {
|
|
26
|
+
file: string;
|
|
27
|
+
mtime: Date;
|
|
28
|
+
size: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Fast stat-only scan: readdir + stat, sorted by mtime desc.
|
|
33
|
+
* No file content is read.
|
|
34
|
+
*/
|
|
35
|
+
export async function statScan(sessionDir: string): Promise<StatResult[]> {
|
|
36
|
+
let entries: string[];
|
|
37
|
+
try {
|
|
38
|
+
entries = await readdir(sessionDir);
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const jsonlFiles = entries.filter((f) => f.endsWith(".jsonl"));
|
|
44
|
+
|
|
45
|
+
const results = await Promise.all(
|
|
46
|
+
jsonlFiles.map(async (f) => {
|
|
47
|
+
const fullPath = join(sessionDir, f);
|
|
48
|
+
try {
|
|
49
|
+
const s = await stat(fullPath);
|
|
50
|
+
if (!s.isFile()) return null;
|
|
51
|
+
return { file: fullPath, mtime: s.mtime, size: s.size };
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const valid = results.filter((r): r is StatResult => r !== null);
|
|
59
|
+
valid.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
60
|
+
return valid;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read session metadata from first ~50 lines of a .jsonl file.
|
|
65
|
+
* Caller provides mtime/size from prior stat() to avoid double-stat.
|
|
66
|
+
*/
|
|
67
|
+
export async function readSessionMeta(
|
|
68
|
+
filePath: string,
|
|
69
|
+
known?: { mtime: Date; size: number },
|
|
70
|
+
): Promise<SessionEntry> {
|
|
71
|
+
const entry: SessionEntry = {
|
|
72
|
+
file: filePath,
|
|
73
|
+
mtime: known?.mtime ?? new Date(0),
|
|
74
|
+
size: known?.size ?? 0,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (!known) {
|
|
78
|
+
try {
|
|
79
|
+
const s = await stat(filePath);
|
|
80
|
+
entry.mtime = s.mtime;
|
|
81
|
+
entry.size = s.size;
|
|
82
|
+
} catch {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const MAX_LINES = 50;
|
|
86
|
+
let lineCount = 0;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const rl = createInterface({
|
|
90
|
+
input: createReadStream(filePath, { encoding: "utf8" }),
|
|
91
|
+
crlfDelay: Infinity,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
for await (const line of rl) {
|
|
95
|
+
lineCount++;
|
|
96
|
+
if (lineCount > MAX_LINES) break;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(line);
|
|
100
|
+
|
|
101
|
+
if (parsed.type === "session" && !entry.id) {
|
|
102
|
+
entry.id = parsed.id;
|
|
103
|
+
entry.timestamp = parsed.timestamp;
|
|
104
|
+
entry.cwd = parsed.cwd;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (parsed.type === "session_info" && parsed.name) {
|
|
109
|
+
entry.name = parsed.name.trim();
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!entry.firstMessage && parsed.type === "message") {
|
|
114
|
+
const msg = parsed.message;
|
|
115
|
+
if (msg?.role === "user" && Array.isArray(msg.content)) {
|
|
116
|
+
for (const block of msg.content) {
|
|
117
|
+
if (block.type === "text" && block.text) {
|
|
118
|
+
entry.firstMessage = block.text.slice(0, 80).replace(/\n/g, " ");
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// skip unparseable lines
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (entry.id && entry.firstMessage) break;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
rl.close();
|
|
132
|
+
} catch {}
|
|
133
|
+
|
|
134
|
+
return entry;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Scan a page of sessions with metadata.
|
|
139
|
+
* Pass known mtime/size from statScan to avoid double stat().
|
|
140
|
+
*/
|
|
141
|
+
export async function scanPage(
|
|
142
|
+
sessionDir: string,
|
|
143
|
+
offset: number,
|
|
144
|
+
limit: number,
|
|
145
|
+
maxDays?: number,
|
|
146
|
+
excludeFile?: string,
|
|
147
|
+
): Promise<{ entries: SessionEntry[]; total: number; hasMore: boolean }> {
|
|
148
|
+
const all = await statScan(sessionDir);
|
|
149
|
+
|
|
150
|
+
let filtered = all;
|
|
151
|
+
if (maxDays && maxDays > 0) {
|
|
152
|
+
const cutoff = Date.now() - maxDays * 86400_000;
|
|
153
|
+
filtered = all.filter((f) => f.mtime.getTime() > cutoff);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (excludeFile) {
|
|
157
|
+
filtered = filtered.filter((f) => f.file !== excludeFile);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const total = filtered.length;
|
|
161
|
+
const page = filtered.slice(offset, offset + limit);
|
|
162
|
+
const hasMore = offset + limit < total;
|
|
163
|
+
|
|
164
|
+
const entries = await Promise.all(page.map((f) => readSessionMeta(f.file, f)));
|
|
165
|
+
|
|
166
|
+
return { entries, total, hasMore };
|
|
167
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the pi session directory for a given cwd.
|
|
3
|
+
* Mirrors pi's internal encoding: --<path-with-dashes>--
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
export function getSessionDir(cwd: string): string {
|
|
9
|
+
const resolved = cwd.replace(/^\//, "").replace(/[/\\:]/g, "-");
|
|
10
|
+
return join(
|
|
11
|
+
process.env.PI_CODING_AGENT_DIR || join(process.env.HOME || "~", ".pi", "agent"),
|
|
12
|
+
"sessions",
|
|
13
|
+
`--${resolved}--`,
|
|
14
|
+
);
|
|
15
|
+
}
|