pi-tinychat 0.1.0 → 0.1.1
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 -21
- package/README.md +139 -139
- package/bin/pi-tinychat.js +111 -111
- package/extension/plan.ts +281 -240
- package/package.json +68 -68
- package/public/app.js +3749 -3614
- package/public/favicon.svg +15 -15
- package/public/i18n.js +197 -169
- package/public/index.html +226 -226
- package/public/markdown.js +244 -244
- package/public/style.css +2814 -2762
- package/public/theme-init.js +18 -18
- package/src/compaction.js +139 -139
- package/src/errors.js +13 -13
- package/src/live.js +133 -133
- package/src/pi-path.js +70 -70
- package/src/pi-types.d.ts +85 -85
- package/src/pi.js +196 -196
- package/src/plan.js +91 -90
- package/src/server.js +910 -910
- package/src/sessions.js +256 -256
package/src/sessions.js
CHANGED
|
@@ -1,256 +1,256 @@
|
|
|
1
|
-
import { lstat, open, readdir, realpath, unlink } from "node:fs/promises";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { dirname, extname, join } from "node:path";
|
|
4
|
-
|
|
5
|
-
/** @typedef {import("./pi-types.js").PiContent} PiContent */
|
|
6
|
-
/** @typedef {import("./pi-types.js").PiSessionLine} PiSessionLine */
|
|
7
|
-
/** @typedef {import("./pi-types.js").PiTextBlock} PiTextBlock */
|
|
8
|
-
/** @typedef {{ id: string, cwd: string, created: string, name: string, preview: string }} SessionInfo */
|
|
9
|
-
|
|
10
|
-
const HEAD_BYTES = 32 * 1024;
|
|
11
|
-
const TAIL_BYTES = 8 * 1024;
|
|
12
|
-
const READ_CONCURRENCY = 8;
|
|
13
|
-
|
|
14
|
-
export function agentDir(env = process.env) {
|
|
15
|
-
return env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** Same directory naming Pi uses for sessions: --C--path-to-project-- */
|
|
19
|
-
export function sessionDirFor(cwd, env = process.env) {
|
|
20
|
-
if (env.PI_CODING_AGENT_SESSION_DIR) return env.PI_CODING_AGENT_SESSION_DIR;
|
|
21
|
-
const safe = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
|
22
|
-
return join(agentDir(env), "sessions", safe);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
async function readChunk(path, start, length) {
|
|
26
|
-
const handle = await open(path, "r");
|
|
27
|
-
try {
|
|
28
|
-
const buffer = Buffer.alloc(length);
|
|
29
|
-
const { bytesRead } = await handle.read(buffer, 0, length, start);
|
|
30
|
-
return buffer.subarray(0, bytesRead).toString("utf8");
|
|
31
|
-
} finally {
|
|
32
|
-
await handle.close();
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** @param {PiContent | undefined} content */
|
|
37
|
-
function textOf(content) {
|
|
38
|
-
if (typeof content === "string") return content;
|
|
39
|
-
if (!Array.isArray(content)) return "";
|
|
40
|
-
const texts = /** @type {PiTextBlock[]} */ (content.filter((block) => block?.type === "text"));
|
|
41
|
-
return texts.map((block) => block.text).join("\n");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* @param {string} text
|
|
46
|
-
* @param {{ skipPartialFirst?: boolean }} [options]
|
|
47
|
-
* @returns {PiSessionLine[]}
|
|
48
|
-
*/
|
|
49
|
-
function parseLines(text, { skipPartialFirst = false } = {}) {
|
|
50
|
-
let body = text;
|
|
51
|
-
if (skipPartialFirst) {
|
|
52
|
-
const firstBreak = body.indexOf("\n");
|
|
53
|
-
body = firstBreak === -1 ? "" : body.slice(firstBreak + 1);
|
|
54
|
-
}
|
|
55
|
-
const records = [];
|
|
56
|
-
for (const line of body.split("\n")) {
|
|
57
|
-
if (!line.trim()) continue;
|
|
58
|
-
try {
|
|
59
|
-
records.push(JSON.parse(line));
|
|
60
|
-
} catch {
|
|
61
|
-
/* truncated or unknown record */
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return records;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* @param {PiSessionLine[]} records
|
|
69
|
-
* @param {SessionInfo} info
|
|
70
|
-
*/
|
|
71
|
-
function summarize(records, info) {
|
|
72
|
-
for (const record of records) {
|
|
73
|
-
if (record.type === "session") {
|
|
74
|
-
info.id ||= record.id;
|
|
75
|
-
info.cwd ||= record.cwd;
|
|
76
|
-
info.created ||= record.timestamp;
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
if (record.type === "session_info" && record.name) info.name = record.name;
|
|
80
|
-
if (!info.preview && record.type === "message" && record.message?.role === "user") {
|
|
81
|
-
info.preview = textOf(record.message.content)
|
|
82
|
-
.replace(/\s+/g, " ")
|
|
83
|
-
.trim()
|
|
84
|
-
.slice(0, 160);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Read the parts of a session file needed for the history list with a single
|
|
91
|
-
* handle. Reading many files two times each made opening history unnecessarily
|
|
92
|
-
* I/O heavy.
|
|
93
|
-
*/
|
|
94
|
-
async function readRecordChunks(path, size) {
|
|
95
|
-
const handle = await open(path, "r");
|
|
96
|
-
try {
|
|
97
|
-
const records = [];
|
|
98
|
-
const headLength = Math.min(size, HEAD_BYTES);
|
|
99
|
-
if (headLength > 0) {
|
|
100
|
-
const head = Buffer.alloc(headLength);
|
|
101
|
-
const { bytesRead } = await handle.read(head, 0, headLength, 0);
|
|
102
|
-
records.push(...parseLines(head.subarray(0, bytesRead).toString("utf8")));
|
|
103
|
-
}
|
|
104
|
-
if (size > HEAD_BYTES) {
|
|
105
|
-
const tailLength = Math.min(size, TAIL_BYTES);
|
|
106
|
-
const tail = Buffer.alloc(tailLength);
|
|
107
|
-
const { bytesRead } = await handle.read(tail, 0, tailLength, size - tailLength);
|
|
108
|
-
records.push(
|
|
109
|
-
...parseLines(tail.subarray(0, bytesRead).toString("utf8"), {
|
|
110
|
-
skipPartialFirst: true,
|
|
111
|
-
}),
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
return records;
|
|
115
|
-
} finally {
|
|
116
|
-
await handle.close();
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** Run async work over many files without opening all of them at once. */
|
|
121
|
-
async function mapLimit(items, limit, mapper) {
|
|
122
|
-
const results = new Array(items.length);
|
|
123
|
-
let next = 0;
|
|
124
|
-
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
125
|
-
while (true) {
|
|
126
|
-
const index = next;
|
|
127
|
-
next += 1;
|
|
128
|
-
if (index >= items.length) return;
|
|
129
|
-
results[index] = await mapper(items[index], index);
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
|
-
await Promise.all(workers);
|
|
133
|
-
return results;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* List regular .jsonl files directly under the current project's Pi session
|
|
138
|
-
* directory. Symlinks are ignored so a delete request can never escape it.
|
|
139
|
-
*/
|
|
140
|
-
async function sessionFiles(cwd, env) {
|
|
141
|
-
const dir = sessionDirFor(cwd, env);
|
|
142
|
-
let names;
|
|
143
|
-
try {
|
|
144
|
-
names = await readdir(dir);
|
|
145
|
-
} catch {
|
|
146
|
-
return { dir, files: [] };
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const files = [];
|
|
150
|
-
for (const name of names) {
|
|
151
|
-
if (!name.endsWith(".jsonl")) continue;
|
|
152
|
-
const path = join(dir, name);
|
|
153
|
-
try {
|
|
154
|
-
const stats = await lstat(path);
|
|
155
|
-
if (!stats.isFile() || stats.isSymbolicLink()) continue;
|
|
156
|
-
files.push({ path, size: stats.size, modified: stats.mtimeMs });
|
|
157
|
-
} catch {
|
|
158
|
-
/* ignore vanished file */
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
files.sort((a, b) => b.modified - a.modified);
|
|
162
|
-
return { dir, files };
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** Read-only session listing. Never creates directories or files. */
|
|
166
|
-
export async function listSessions({ cwd, currentId, env = process.env }) {
|
|
167
|
-
const { files } = await sessionFiles(cwd, env);
|
|
168
|
-
|
|
169
|
-
const sessions = await mapLimit(files, READ_CONCURRENCY, async (candidate) => {
|
|
170
|
-
const info = { id: "", cwd: "", created: "", name: "", preview: "" };
|
|
171
|
-
try {
|
|
172
|
-
summarize(await readRecordChunks(candidate.path, candidate.size), info);
|
|
173
|
-
} catch {
|
|
174
|
-
return null;
|
|
175
|
-
}
|
|
176
|
-
return {
|
|
177
|
-
path: candidate.path,
|
|
178
|
-
id: info.id,
|
|
179
|
-
name: info.name,
|
|
180
|
-
preview: info.preview,
|
|
181
|
-
created: info.created,
|
|
182
|
-
modified: new Date(candidate.modified).toISOString(),
|
|
183
|
-
size: candidate.size,
|
|
184
|
-
current: Boolean(currentId) && info.id === currentId,
|
|
185
|
-
};
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
return sessions.filter(Boolean);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
async function readSessionId(path, size) {
|
|
192
|
-
try {
|
|
193
|
-
const head = await readChunk(path, 0, Math.min(size, HEAD_BYTES));
|
|
194
|
-
for (const record of parseLines(head)) {
|
|
195
|
-
if (record.type === "session" && typeof record.id === "string" && record.id) {
|
|
196
|
-
return record.id;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
} catch {
|
|
200
|
-
/* unreadable file */
|
|
201
|
-
}
|
|
202
|
-
return "";
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Resolve the current project's session files by ID. IDs come from the
|
|
207
|
-
* browser; paths never do. Re-enumerating here also keeps "all" correct even
|
|
208
|
-
* when the UI only displayed a subset of the history.
|
|
209
|
-
*/
|
|
210
|
-
export async function resolveSessionTargets({
|
|
211
|
-
cwd,
|
|
212
|
-
ids = null,
|
|
213
|
-
all = false,
|
|
214
|
-
env = process.env,
|
|
215
|
-
}) {
|
|
216
|
-
const { files } = await sessionFiles(cwd, env);
|
|
217
|
-
const wanted = ids ? new Set(ids.filter((id) => typeof id === "string" && id)) : null;
|
|
218
|
-
const targets = [];
|
|
219
|
-
const found = new Set();
|
|
220
|
-
|
|
221
|
-
for (const file of files) {
|
|
222
|
-
const id = await readSessionId(file.path, file.size);
|
|
223
|
-
if (!id) continue;
|
|
224
|
-
if (all || (wanted && wanted.has(id))) {
|
|
225
|
-
targets.push({
|
|
226
|
-
id,
|
|
227
|
-
path: file.path,
|
|
228
|
-
size: file.size,
|
|
229
|
-
modified: new Date(file.modified).toISOString(),
|
|
230
|
-
});
|
|
231
|
-
found.add(id);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
return {
|
|
236
|
-
targets,
|
|
237
|
-
missing: wanted ? [...wanted].filter((id) => !found.has(id)) : [],
|
|
238
|
-
};
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** Delete one .jsonl file, but only if it is directly inside this cwd's dir. */
|
|
242
|
-
export async function deleteSessionFile({ cwd, path, env = process.env }) {
|
|
243
|
-
if (typeof path !== "string" || !path) throw new Error("パスが不正です");
|
|
244
|
-
const dir = sessionDirFor(cwd, env);
|
|
245
|
-
const realDir = await realpath(dir);
|
|
246
|
-
const info = await lstat(path);
|
|
247
|
-
if (!info.isFile() || info.isSymbolicLink()) {
|
|
248
|
-
throw new Error("通常のセッションファイルではありません");
|
|
249
|
-
}
|
|
250
|
-
const realPath = await realpath(path);
|
|
251
|
-
const key = (value) => (process.platform === "win32" ? value.toLowerCase() : value);
|
|
252
|
-
if (key(dirname(realPath)) !== key(realDir) || extname(realPath) !== ".jsonl") {
|
|
253
|
-
throw new Error("セッションディレクトリ外のファイルは削除できません");
|
|
254
|
-
}
|
|
255
|
-
await unlink(realPath);
|
|
256
|
-
}
|
|
1
|
+
import { lstat, open, readdir, realpath, unlink } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, extname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** @typedef {import("./pi-types.js").PiContent} PiContent */
|
|
6
|
+
/** @typedef {import("./pi-types.js").PiSessionLine} PiSessionLine */
|
|
7
|
+
/** @typedef {import("./pi-types.js").PiTextBlock} PiTextBlock */
|
|
8
|
+
/** @typedef {{ id: string, cwd: string, created: string, name: string, preview: string }} SessionInfo */
|
|
9
|
+
|
|
10
|
+
const HEAD_BYTES = 32 * 1024;
|
|
11
|
+
const TAIL_BYTES = 8 * 1024;
|
|
12
|
+
const READ_CONCURRENCY = 8;
|
|
13
|
+
|
|
14
|
+
export function agentDir(env = process.env) {
|
|
15
|
+
return env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Same directory naming Pi uses for sessions: --C--path-to-project-- */
|
|
19
|
+
export function sessionDirFor(cwd, env = process.env) {
|
|
20
|
+
if (env.PI_CODING_AGENT_SESSION_DIR) return env.PI_CODING_AGENT_SESSION_DIR;
|
|
21
|
+
const safe = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
|
22
|
+
return join(agentDir(env), "sessions", safe);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function readChunk(path, start, length) {
|
|
26
|
+
const handle = await open(path, "r");
|
|
27
|
+
try {
|
|
28
|
+
const buffer = Buffer.alloc(length);
|
|
29
|
+
const { bytesRead } = await handle.read(buffer, 0, length, start);
|
|
30
|
+
return buffer.subarray(0, bytesRead).toString("utf8");
|
|
31
|
+
} finally {
|
|
32
|
+
await handle.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @param {PiContent | undefined} content */
|
|
37
|
+
function textOf(content) {
|
|
38
|
+
if (typeof content === "string") return content;
|
|
39
|
+
if (!Array.isArray(content)) return "";
|
|
40
|
+
const texts = /** @type {PiTextBlock[]} */ (content.filter((block) => block?.type === "text"));
|
|
41
|
+
return texts.map((block) => block.text).join("\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} text
|
|
46
|
+
* @param {{ skipPartialFirst?: boolean }} [options]
|
|
47
|
+
* @returns {PiSessionLine[]}
|
|
48
|
+
*/
|
|
49
|
+
function parseLines(text, { skipPartialFirst = false } = {}) {
|
|
50
|
+
let body = text;
|
|
51
|
+
if (skipPartialFirst) {
|
|
52
|
+
const firstBreak = body.indexOf("\n");
|
|
53
|
+
body = firstBreak === -1 ? "" : body.slice(firstBreak + 1);
|
|
54
|
+
}
|
|
55
|
+
const records = [];
|
|
56
|
+
for (const line of body.split("\n")) {
|
|
57
|
+
if (!line.trim()) continue;
|
|
58
|
+
try {
|
|
59
|
+
records.push(JSON.parse(line));
|
|
60
|
+
} catch {
|
|
61
|
+
/* truncated or unknown record */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return records;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {PiSessionLine[]} records
|
|
69
|
+
* @param {SessionInfo} info
|
|
70
|
+
*/
|
|
71
|
+
function summarize(records, info) {
|
|
72
|
+
for (const record of records) {
|
|
73
|
+
if (record.type === "session") {
|
|
74
|
+
info.id ||= record.id;
|
|
75
|
+
info.cwd ||= record.cwd;
|
|
76
|
+
info.created ||= record.timestamp;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (record.type === "session_info" && record.name) info.name = record.name;
|
|
80
|
+
if (!info.preview && record.type === "message" && record.message?.role === "user") {
|
|
81
|
+
info.preview = textOf(record.message.content)
|
|
82
|
+
.replace(/\s+/g, " ")
|
|
83
|
+
.trim()
|
|
84
|
+
.slice(0, 160);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read the parts of a session file needed for the history list with a single
|
|
91
|
+
* handle. Reading many files two times each made opening history unnecessarily
|
|
92
|
+
* I/O heavy.
|
|
93
|
+
*/
|
|
94
|
+
async function readRecordChunks(path, size) {
|
|
95
|
+
const handle = await open(path, "r");
|
|
96
|
+
try {
|
|
97
|
+
const records = [];
|
|
98
|
+
const headLength = Math.min(size, HEAD_BYTES);
|
|
99
|
+
if (headLength > 0) {
|
|
100
|
+
const head = Buffer.alloc(headLength);
|
|
101
|
+
const { bytesRead } = await handle.read(head, 0, headLength, 0);
|
|
102
|
+
records.push(...parseLines(head.subarray(0, bytesRead).toString("utf8")));
|
|
103
|
+
}
|
|
104
|
+
if (size > HEAD_BYTES) {
|
|
105
|
+
const tailLength = Math.min(size, TAIL_BYTES);
|
|
106
|
+
const tail = Buffer.alloc(tailLength);
|
|
107
|
+
const { bytesRead } = await handle.read(tail, 0, tailLength, size - tailLength);
|
|
108
|
+
records.push(
|
|
109
|
+
...parseLines(tail.subarray(0, bytesRead).toString("utf8"), {
|
|
110
|
+
skipPartialFirst: true,
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return records;
|
|
115
|
+
} finally {
|
|
116
|
+
await handle.close();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Run async work over many files without opening all of them at once. */
|
|
121
|
+
async function mapLimit(items, limit, mapper) {
|
|
122
|
+
const results = new Array(items.length);
|
|
123
|
+
let next = 0;
|
|
124
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
125
|
+
while (true) {
|
|
126
|
+
const index = next;
|
|
127
|
+
next += 1;
|
|
128
|
+
if (index >= items.length) return;
|
|
129
|
+
results[index] = await mapper(items[index], index);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
await Promise.all(workers);
|
|
133
|
+
return results;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* List regular .jsonl files directly under the current project's Pi session
|
|
138
|
+
* directory. Symlinks are ignored so a delete request can never escape it.
|
|
139
|
+
*/
|
|
140
|
+
async function sessionFiles(cwd, env) {
|
|
141
|
+
const dir = sessionDirFor(cwd, env);
|
|
142
|
+
let names;
|
|
143
|
+
try {
|
|
144
|
+
names = await readdir(dir);
|
|
145
|
+
} catch {
|
|
146
|
+
return { dir, files: [] };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const files = [];
|
|
150
|
+
for (const name of names) {
|
|
151
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
152
|
+
const path = join(dir, name);
|
|
153
|
+
try {
|
|
154
|
+
const stats = await lstat(path);
|
|
155
|
+
if (!stats.isFile() || stats.isSymbolicLink()) continue;
|
|
156
|
+
files.push({ path, size: stats.size, modified: stats.mtimeMs });
|
|
157
|
+
} catch {
|
|
158
|
+
/* ignore vanished file */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
files.sort((a, b) => b.modified - a.modified);
|
|
162
|
+
return { dir, files };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Read-only session listing. Never creates directories or files. */
|
|
166
|
+
export async function listSessions({ cwd, currentId, env = process.env }) {
|
|
167
|
+
const { files } = await sessionFiles(cwd, env);
|
|
168
|
+
|
|
169
|
+
const sessions = await mapLimit(files, READ_CONCURRENCY, async (candidate) => {
|
|
170
|
+
const info = { id: "", cwd: "", created: "", name: "", preview: "" };
|
|
171
|
+
try {
|
|
172
|
+
summarize(await readRecordChunks(candidate.path, candidate.size), info);
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
path: candidate.path,
|
|
178
|
+
id: info.id,
|
|
179
|
+
name: info.name,
|
|
180
|
+
preview: info.preview,
|
|
181
|
+
created: info.created,
|
|
182
|
+
modified: new Date(candidate.modified).toISOString(),
|
|
183
|
+
size: candidate.size,
|
|
184
|
+
current: Boolean(currentId) && info.id === currentId,
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return sessions.filter(Boolean);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function readSessionId(path, size) {
|
|
192
|
+
try {
|
|
193
|
+
const head = await readChunk(path, 0, Math.min(size, HEAD_BYTES));
|
|
194
|
+
for (const record of parseLines(head)) {
|
|
195
|
+
if (record.type === "session" && typeof record.id === "string" && record.id) {
|
|
196
|
+
return record.id;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
/* unreadable file */
|
|
201
|
+
}
|
|
202
|
+
return "";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Resolve the current project's session files by ID. IDs come from the
|
|
207
|
+
* browser; paths never do. Re-enumerating here also keeps "all" correct even
|
|
208
|
+
* when the UI only displayed a subset of the history.
|
|
209
|
+
*/
|
|
210
|
+
export async function resolveSessionTargets({
|
|
211
|
+
cwd,
|
|
212
|
+
ids = null,
|
|
213
|
+
all = false,
|
|
214
|
+
env = process.env,
|
|
215
|
+
}) {
|
|
216
|
+
const { files } = await sessionFiles(cwd, env);
|
|
217
|
+
const wanted = ids ? new Set(ids.filter((id) => typeof id === "string" && id)) : null;
|
|
218
|
+
const targets = [];
|
|
219
|
+
const found = new Set();
|
|
220
|
+
|
|
221
|
+
for (const file of files) {
|
|
222
|
+
const id = await readSessionId(file.path, file.size);
|
|
223
|
+
if (!id) continue;
|
|
224
|
+
if (all || (wanted && wanted.has(id))) {
|
|
225
|
+
targets.push({
|
|
226
|
+
id,
|
|
227
|
+
path: file.path,
|
|
228
|
+
size: file.size,
|
|
229
|
+
modified: new Date(file.modified).toISOString(),
|
|
230
|
+
});
|
|
231
|
+
found.add(id);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
targets,
|
|
237
|
+
missing: wanted ? [...wanted].filter((id) => !found.has(id)) : [],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Delete one .jsonl file, but only if it is directly inside this cwd's dir. */
|
|
242
|
+
export async function deleteSessionFile({ cwd, path, env = process.env }) {
|
|
243
|
+
if (typeof path !== "string" || !path) throw new Error("パスが不正です");
|
|
244
|
+
const dir = sessionDirFor(cwd, env);
|
|
245
|
+
const realDir = await realpath(dir);
|
|
246
|
+
const info = await lstat(path);
|
|
247
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
248
|
+
throw new Error("通常のセッションファイルではありません");
|
|
249
|
+
}
|
|
250
|
+
const realPath = await realpath(path);
|
|
251
|
+
const key = (value) => (process.platform === "win32" ? value.toLowerCase() : value);
|
|
252
|
+
if (key(dirname(realPath)) !== key(realDir) || extname(realPath) !== ".jsonl") {
|
|
253
|
+
throw new Error("セッションディレクトリ外のファイルは削除できません");
|
|
254
|
+
}
|
|
255
|
+
await unlink(realPath);
|
|
256
|
+
}
|