pi-session-peek 0.2.1 → 0.3.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/query.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { msg, type MsgKey } from "./i18n.ts";
2
- import type { PeekMsg } from "./sessions.ts";
2
+ import type { PeekMsg, PeekSession } from "./sessions.ts";
3
3
 
4
4
  // 搜索框语法:空格分隔多个词,都要命中;@7d / @24h / @2w / @1m 限定最近活动时间
5
5
 
@@ -33,6 +33,16 @@ export function parseQuery(q: string): ParsedQuery {
33
33
  return { kws, since, sinceLabel };
34
34
  }
35
35
 
36
+ // 关键词转成不区分大小写的正则,列表过滤用。正文不预存小写副本,直接在原文上匹配
37
+ export function kwRegExps(kws: string[]): RegExp[] {
38
+ return kws.map((k) => new RegExp(k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"));
39
+ }
40
+
41
+ // 每个关键词都要在会话名、cwd 或某条消息里出现
42
+ export function matchesSession(s: PeekSession, res: RegExp[]): boolean {
43
+ return res.every((re) => re.test(s.name) || re.test(s.cwd) || s.msgs.some((m) => re.test(m.text)));
44
+ }
45
+
36
46
  // 单条消息用:命中任意一个关键词就算
37
47
  export function anyKw(lower: string, kws: string[]): boolean {
38
48
  for (const k of kws) if (lower.includes(k)) return true;
package/src/sessions.ts CHANGED
@@ -1,9 +1,11 @@
1
- import { appendFileSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
1
+ import { appendFileSync, existsSync, readFileSync, rmSync } from "node:fs";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
2
3
  import { randomBytes } from "node:crypto";
3
4
  import { homedir } from "node:os";
4
5
  import { join } from "node:path";
5
6
 
6
- // 读 ~/.pi/agent/sessions 下的会话 JSONL,按 mtime 缓存;重命名和删除也在这里
7
+ // 读 ~/.pi/agent/sessions 下的会话 JSONL,按 mtime 缓存;重命名和删除也在这里。
8
+ // 搜索不预存小写全文:会话正文只在 msgs 里放一份,匹配时用不区分大小写的正则直接扫(见 query.ts 的 matchesSession)
7
9
 
8
10
  export interface PeekMsg {
9
11
  role: "user" | "assistant";
@@ -17,11 +19,11 @@ export interface PeekSession {
17
19
  name: string; // 最后一条 session_info 里的名字,没有则为空
18
20
  first: string; // 首条消息,列表里显示用
19
21
  msgs: PeekMsg[];
20
- searchText: string; // 正文 + 会话名 + cwd,已小写
21
22
  mtime: number;
22
23
  }
23
24
 
24
25
  const sessionCache = new Map<string, PeekSession>();
26
+ const READ_CONCURRENCY = 16; // 同时读的文件数,太高会把 libuv 线程池排满
25
27
 
26
28
  // 和 pi 一样认 PI_CODING_AGENT_DIR
27
29
  function sessionsDir(): string {
@@ -30,17 +32,17 @@ function sessionsDir(): string {
30
32
  return join(agent, "sessions");
31
33
  }
32
34
 
33
- function* walkJsonl(dir: string): Generator<string> {
35
+ async function walkJsonl(dir: string, out: string[]): Promise<void> {
34
36
  let entries;
35
37
  try {
36
- entries = readdirSync(dir, { withFileTypes: true });
38
+ entries = await readdir(dir, { withFileTypes: true });
37
39
  } catch {
38
40
  return;
39
41
  }
40
42
  for (const e of entries) {
41
43
  const p = join(dir, e.name);
42
- if (e.isDirectory()) yield* walkJsonl(p);
43
- else if (e.name.endsWith(".jsonl")) yield p;
44
+ if (e.isDirectory()) await walkJsonl(p, out);
45
+ else if (e.name.endsWith(".jsonl")) out.push(p);
44
46
  }
45
47
  }
46
48
 
@@ -53,13 +55,7 @@ function extractText(content: unknown): string {
53
55
  .join("\n");
54
56
  }
55
57
 
56
- function parseSession(path: string, mtime: number): PeekSession | null {
57
- let raw: string;
58
- try {
59
- raw = readFileSync(path, "utf8");
60
- } catch {
61
- return null;
62
- }
58
+ function parseSession(path: string, raw: string, mtime: number): PeekSession | null {
63
59
  let cwd = "";
64
60
  let time = "";
65
61
  let name = "";
@@ -101,36 +97,48 @@ function parseSession(path: string, mtime: number): PeekSession | null {
101
97
  msgs,
102
98
  mtime,
103
99
  first: msgs[0].text.replace(/\s+/g, " ").slice(0, 80),
104
- searchText: (msgs.map((m) => m.text).join(" ") + " " + name + " " + cwd).toLowerCase(),
105
100
  };
106
101
  }
107
102
 
108
- /** 全部会话,按最后修改时间倒序;文件没变的直接用缓存 */
109
- export function scanSessions(): PeekSession[] {
110
- const seen = new Set<string>();
111
- const out: PeekSession[] = [];
103
+ // 单个文件:mtime 没变直接用缓存,否则重新读和解析
104
+ async function loadSession(file: string): Promise<PeekSession | null> {
105
+ let mtime: number;
106
+ try {
107
+ mtime = (await stat(file)).mtimeMs;
108
+ } catch {
109
+ return null;
110
+ }
111
+ const hit = sessionCache.get(file);
112
+ if (hit && hit.mtime === mtime) return hit;
113
+ let raw: string;
114
+ try {
115
+ raw = await readFile(file, "utf8");
116
+ } catch {
117
+ return null;
118
+ }
119
+ const parsed = parseSession(file, raw, mtime);
120
+ if (parsed) sessionCache.set(file, parsed);
121
+ return parsed;
122
+ }
112
123
 
113
- for (const file of walkJsonl(sessionsDir())) {
114
- seen.add(file);
115
- let mtime = 0;
116
- try {
117
- mtime = statSync(file).mtimeMs;
118
- } catch {
119
- continue;
120
- }
121
- const hit = sessionCache.get(file);
122
- if (hit && hit.mtime === mtime) {
123
- out.push(hit);
124
- continue;
125
- }
126
- const parsed = parseSession(file, mtime);
127
- if (parsed) {
128
- sessionCache.set(file, parsed);
129
- out.push(parsed);
124
+ /** 全部会话,按最后修改时间倒序;文件没变的直接用缓存。
125
+ * 目录遍历和文件读取都走异步 I/O,几百个会话也不会把 TUI 卡住 */
126
+ export async function scanSessions(): Promise<PeekSession[]> {
127
+ const files: string[] = [];
128
+ await walkJsonl(sessionsDir(), files);
129
+
130
+ const out: PeekSession[] = [];
131
+ let next = 0;
132
+ const worker = async () => {
133
+ while (next < files.length) {
134
+ const s = await loadSession(files[next++]);
135
+ if (s) out.push(s);
130
136
  }
131
- }
137
+ };
138
+ await Promise.all(Array.from({ length: Math.min(READ_CONCURRENCY, files.length) }, worker));
132
139
 
133
140
  // 文件已经不在了的,从缓存里去掉
141
+ const seen = new Set(files);
134
142
  for (const key of [...sessionCache.keys()]) {
135
143
  if (!seen.has(key)) sessionCache.delete(key);
136
144
  }
@@ -167,19 +175,43 @@ export function renameSession(path: string, name: string): void {
167
175
  appendFileSync(path, sep + JSON.stringify(entry) + "\n", "utf8");
168
176
  }
169
177
 
170
- // 先试 trash(能恢复),不行再直接删。trashExec 由调用方传入,方便测试
178
+ export type DeleteResult = "trash" | "rm" | false;
179
+
180
+ // 各平台把文件送进回收站的命令,按顺序试。trash(trash-cli / brew trash)放最前面,装了就说明用户想用它;
181
+ // 后面是系统自带的途径:Windows 走 PowerShell 的 VisualBasic FileSystem,macOS 走 Finder,Linux 走 gio / trash-put
182
+ export function trashCommands(path: string, platform: NodeJS.Platform = process.platform): [string, string[]][] {
183
+ const cmds: [string, string[]][] = [["trash", [path]]];
184
+ if (platform === "win32") {
185
+ const script =
186
+ "Add-Type -AssemblyName Microsoft.VisualBasic; " +
187
+ `[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile('${path.replace(/'/g, "''")}', 'OnlyErrorDialogs', 'SendToRecycleBin')`;
188
+ const args = ["-NoProfile", "-NonInteractive", "-Command", script];
189
+ cmds.push(["powershell", args], ["pwsh", args]);
190
+ } else if (platform === "darwin") {
191
+ cmds.push(["osascript", ["-e", `tell application "Finder" to delete POSIX file "${path.replace(/[\\"]/g, "\\$&")}"`]]);
192
+ } else {
193
+ cmds.push(["gio", ["trash", path]], ["trash-put", [path]]);
194
+ }
195
+ return cmds;
196
+ }
197
+
198
+ // 优先进回收站(能恢复),每个途径都不行再直接删。exec 由调用方传入,方便测试;
199
+ // 命令退出码为 0 但文件还在也当失败,继续试下一个
171
200
  export async function deleteSession(
172
201
  path: string,
173
- trashExec: (cmd: string, args: string[]) => Promise<{ code: number }>,
174
- ): Promise<boolean> {
175
- try {
176
- const r = await trashExec("trash", [path]);
177
- if (r.code === 0) return true;
178
- } catch {
202
+ exec: (cmd: string, args: string[]) => Promise<{ code: number }>,
203
+ platform: NodeJS.Platform = process.platform,
204
+ ): Promise<DeleteResult> {
205
+ for (const [cmd, args] of trashCommands(path, platform)) {
206
+ try {
207
+ const r = await exec(cmd, args);
208
+ if (r.code === 0 && !existsSync(path)) return "trash";
209
+ } catch {
210
+ }
179
211
  }
180
212
  try {
181
213
  rmSync(path, { force: true });
182
- return true;
214
+ return "rm";
183
215
  } catch {
184
216
  return false;
185
217
  }