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/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
+ }