@baique/pi-web-sky 0.1.35 → 0.1.36

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.
Files changed (294) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +16 -16
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/react-loadable-manifest.json +1 -1
  5. package/.next/required-server-files.js +1 -1
  6. package/.next/required-server-files.json +1 -1
  7. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/.next/server/app/_not-found.html +1 -1
  15. package/.next/server/app/_not-found.rsc +3 -3
  16. package/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  17. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +3 -3
  18. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  19. package/.next/server/app/api/agent/[id]/bash-output/route.js +1 -1
  20. package/.next/server/app/api/agent/[id]/events/route.js +1 -1
  21. package/.next/server/app/api/agent/[id]/route.js +1 -1
  22. package/.next/server/app/api/app-update/route.js +1 -1
  23. package/.next/server/app/api/auth/login/[provider]/route.js +1 -1
  24. package/.next/server/app/api/cwd/validate/route.js +1 -1
  25. package/.next/server/app/api/default-cwd/route.js +1 -1
  26. package/.next/server/app/api/file-index/route.js +1 -1
  27. package/.next/server/app/api/files/[...path]/route.js +1 -1
  28. package/.next/server/app/api/mcp/route.js +1 -1
  29. package/.next/server/app/api/models/route.js +1 -1
  30. package/.next/server/app/api/plugins/route.js +1 -1
  31. package/.next/server/app/api/project-trust/route.js +1 -1
  32. package/.next/server/app/api/search/route.js +1 -1
  33. package/.next/server/app/api/sessions/[id]/auto-name/route.js +1 -1
  34. package/.next/server/app/api/sessions/[id]/context/route.js +1 -1
  35. package/.next/server/app/api/sessions/[id]/entries/[entryId]/thinking/route.js +1 -1
  36. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  37. package/.next/server/app/api/sessions/[id]/state/route.js +1 -1
  38. package/.next/server/app/api/sessions/[id]/todos/route.js +1 -1
  39. package/.next/server/app/api/sessions/route.js +1 -1
  40. package/.next/server/app/api/skills/check/route.js +1 -1
  41. package/.next/server/app/api/skills/check/route.js.nft.json +1 -1
  42. package/.next/server/app/api/skills/install/route.js +1 -1
  43. package/.next/server/app/api/skills/route.js +2 -31
  44. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  45. package/.next/server/app/api/skills/update/route.js +1 -1
  46. package/.next/server/app/api/skills/update/route.js.nft.json +1 -1
  47. package/.next/server/app/api/worktrees/route.js +1 -1
  48. package/.next/server/app/index.html +1 -1
  49. package/.next/server/app/index.rsc +5 -5
  50. package/.next/server/app/index.segments/__PAGE__.segment.rsc +5 -5
  51. package/.next/server/app/index.segments/_full.segment.rsc +5 -5
  52. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  53. package/.next/server/app/page.js +2 -2
  54. package/.next/server/app/page_client-reference-manifest.js +1 -1
  55. package/.next/server/app-paths-manifest.json +16 -16
  56. package/.next/server/chunks/2404.js +1 -1
  57. package/.next/server/chunks/3500.js +30 -0
  58. package/.next/server/chunks/4226.js +2 -2
  59. package/.next/server/chunks/542.js +10 -5
  60. package/.next/server/chunks/5794.js +1 -1
  61. package/.next/server/chunks/6429.js +1 -1
  62. package/.next/server/chunks/898.js +1 -1
  63. package/.next/server/middleware-build-manifest.js +1 -1
  64. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  65. package/.next/server/pages/404.html +1 -1
  66. package/.next/server/pages/500.html +1 -1
  67. package/.next/static/chunks/5315.7e72d26a3ad3bd9a.js +4 -0
  68. package/.next/static/chunks/app/{layout-1d9a7a44fbc92d2e.js → layout-ebbc0d287267b07b.js} +1 -1
  69. package/.next/static/chunks/app/{page-6d6779a81a0a7639.js → page-e568623a44c67606.js} +2 -2
  70. package/.next/static/chunks/{webpack-fa4f40085b1ec1ac.js → webpack-2e4e348867a555cd.js} +1 -1
  71. package/.next/static/css/422c7dc0faa04d89.css +1 -0
  72. package/.next/trace +8 -9
  73. package/.next/trace-build +1 -1
  74. package/bin/pi-web-sky.js +15 -20
  75. package/lib/agent-client.test.mjs +64 -0
  76. package/lib/agent-client.ts +53 -0
  77. package/lib/agent-event-connection.test.mjs +193 -0
  78. package/lib/agent-event-connection.ts +202 -0
  79. package/lib/agent-event-stream.test.mjs +155 -0
  80. package/lib/agent-event-stream.ts +132 -0
  81. package/lib/agent-event-wire.test.mjs +278 -0
  82. package/lib/agent-event-wire.ts +107 -0
  83. package/lib/agent-phase.ts +24 -0
  84. package/lib/allowed-roots.ts +31 -0
  85. package/lib/ansi.test.mjs +52 -0
  86. package/lib/ansi.ts +211 -0
  87. package/lib/api-types.ts +139 -0
  88. package/lib/app-update.test.mjs +27 -0
  89. package/lib/app-update.ts +28 -0
  90. package/lib/atomic-file.test.mjs +37 -0
  91. package/lib/atomic-file.ts +34 -0
  92. package/lib/audit-session.ts +235 -0
  93. package/lib/bash-output.test.mjs +63 -0
  94. package/lib/bash-output.ts +53 -0
  95. package/lib/bg-image.ts +129 -0
  96. package/lib/board-events.ts +83 -0
  97. package/lib/board-purge.test.mjs +66 -0
  98. package/lib/board-purge.ts +65 -0
  99. package/lib/board-search.test.mjs +110 -0
  100. package/lib/board-search.ts +63 -0
  101. package/lib/board-store.test.mjs +542 -0
  102. package/lib/board-store.ts +794 -0
  103. package/lib/board-types.ts +96 -0
  104. package/lib/board-utils.ts +17 -0
  105. package/lib/bounded-form-data.test.mjs +54 -0
  106. package/lib/bounded-form-data.ts +49 -0
  107. package/lib/browser-notifications.test.mjs +167 -0
  108. package/lib/browser-notifications.ts +104 -0
  109. package/lib/chat-lazy-load.test.mjs +140 -0
  110. package/lib/chat-lazy-load.ts +64 -0
  111. package/lib/clipboard-paths.test.mjs +140 -0
  112. package/lib/clipboard-paths.ts +192 -0
  113. package/lib/clipboard.ts +18 -0
  114. package/lib/compaction-summary.test.mjs +44 -0
  115. package/lib/compaction-summary.ts +34 -0
  116. package/lib/custom-ui-terminal.test.mjs +32 -0
  117. package/lib/custom-ui-terminal.ts +27 -0
  118. package/lib/deepseek-pricing.test.mjs +120 -0
  119. package/lib/deepseek-pricing.ts +71 -0
  120. package/lib/directory-browser.test.mjs +60 -0
  121. package/lib/directory-browser.ts +83 -0
  122. package/lib/draft-stash.test.mjs +91 -0
  123. package/lib/draft-stash.ts +102 -0
  124. package/lib/draft-store.ts +105 -0
  125. package/lib/dropdown-direction.ts +27 -0
  126. package/lib/extension-command.test.mjs +63 -0
  127. package/lib/extension-command.ts +81 -0
  128. package/lib/file-access.test.mjs +31 -0
  129. package/lib/file-access.ts +59 -0
  130. package/lib/file-dirent.test.mjs +56 -0
  131. package/lib/file-dirent.ts +17 -0
  132. package/lib/file-explorer-state.test.mjs +63 -0
  133. package/lib/file-explorer-state.ts +36 -0
  134. package/lib/file-fuzzy.test.mjs +29 -0
  135. package/lib/file-fuzzy.ts +189 -0
  136. package/lib/file-links.test.mjs +92 -0
  137. package/lib/file-links.ts +148 -0
  138. package/lib/file-paths.ts +43 -0
  139. package/lib/file-types.test.mjs +34 -0
  140. package/lib/file-types.ts +73 -0
  141. package/lib/file-upload.test.mjs +70 -0
  142. package/lib/file-upload.ts +59 -0
  143. package/lib/file-viewer-state.test.mjs +20 -0
  144. package/lib/file-viewer-state.ts +15 -0
  145. package/lib/frontmatter.test.mjs +55 -0
  146. package/lib/frontmatter.ts +87 -0
  147. package/lib/git-changes.test.mjs +52 -0
  148. package/lib/git-changes.ts +232 -0
  149. package/lib/git-status.ts +47 -0
  150. package/lib/git-types.ts +29 -0
  151. package/lib/http-dispatcher.test.mjs +83 -0
  152. package/lib/http-dispatcher.ts +86 -0
  153. package/lib/i18n/format.test.mjs +19 -0
  154. package/lib/i18n/format.ts +60 -0
  155. package/lib/i18n/messages/en.ts +662 -0
  156. package/lib/i18n/messages/zh-CN.ts +661 -0
  157. package/lib/i18n/registry.test.mjs +32 -0
  158. package/lib/i18n/registry.ts +43 -0
  159. package/lib/i18n/types.ts +15 -0
  160. package/lib/image-attachments.test.mjs +27 -0
  161. package/lib/image-attachments.ts +56 -0
  162. package/lib/initial-navigation.test.mjs +65 -0
  163. package/lib/initial-navigation.ts +15 -0
  164. package/lib/markdown.test.mjs +104 -0
  165. package/lib/markdown.ts +347 -0
  166. package/lib/message-display.test.mjs +130 -0
  167. package/lib/message-display.ts +47 -0
  168. package/lib/model-catalog.test.mjs +327 -0
  169. package/lib/model-catalog.ts +414 -0
  170. package/lib/model-discovery-auth.ts +58 -0
  171. package/lib/model-discovery.test.mjs +50 -0
  172. package/lib/model-discovery.ts +75 -0
  173. package/lib/model-id-preference.ts +46 -0
  174. package/lib/model-scope.test.mjs +193 -0
  175. package/lib/model-scope.ts +198 -0
  176. package/lib/models-cache.test.mjs +122 -0
  177. package/lib/models-cache.ts +90 -0
  178. package/lib/models-config-store.test.mjs +200 -0
  179. package/lib/models-config-store.ts +84 -0
  180. package/lib/next-config-esm.test.mjs +14 -0
  181. package/lib/next-config.test.mjs +13 -0
  182. package/lib/node-version.test.mjs +33 -0
  183. package/lib/normalize.test.mjs +54 -0
  184. package/lib/normalize.ts +63 -0
  185. package/lib/npx.ts +62 -0
  186. package/lib/panel-layout.test.mjs +56 -0
  187. package/lib/panel-layout.ts +52 -0
  188. package/lib/patch.test.mjs +110 -0
  189. package/lib/patch.ts +134 -0
  190. package/lib/path-security.ts +42 -0
  191. package/lib/paths.test.mjs +58 -0
  192. package/lib/paths.ts +71 -0
  193. package/lib/pi-types.ts +183 -0
  194. package/lib/pi-web-options.test.mjs +63 -0
  195. package/lib/process-lifecycle.test.mjs +83 -0
  196. package/lib/project-command-env.test.mjs +298 -0
  197. package/lib/project-command-env.ts +129 -0
  198. package/lib/project-groups.test.mjs +60 -0
  199. package/lib/project-groups.ts +53 -0
  200. package/lib/project-identity.test.mjs +37 -0
  201. package/lib/project-identity.ts +26 -0
  202. package/lib/project-tree.test.mjs +115 -0
  203. package/lib/project-tree.ts +176 -0
  204. package/lib/project-trust.test.mjs +117 -0
  205. package/lib/project-trust.ts +48 -0
  206. package/lib/prompt-recovery.test.mjs +33 -0
  207. package/lib/prompt-recovery.ts +47 -0
  208. package/lib/provider-api-key-route.test.mjs +12 -0
  209. package/lib/provider-credential-store.test.mjs +126 -0
  210. package/lib/provider-credential-store.ts +114 -0
  211. package/lib/provider-listing-runtime.ts +37 -0
  212. package/lib/provider-listing.test.mjs +114 -0
  213. package/lib/provider-listing.ts +118 -0
  214. package/lib/provider-services.ts +64 -0
  215. package/lib/request-security.test.mjs +256 -0
  216. package/lib/request-security.ts +136 -0
  217. package/lib/rpc-manager-shutdown.test.mjs +372 -0
  218. package/lib/rpc-manager-widgets.test.mjs +342 -0
  219. package/lib/rpc-manager.test.mjs +139 -0
  220. package/lib/rpc-manager.ts +1703 -0
  221. package/lib/rpc-session-info.test.mjs +93 -0
  222. package/lib/session-delete.test.mjs +53 -0
  223. package/lib/session-delete.ts +86 -0
  224. package/lib/session-file-references-core.ts +82 -0
  225. package/lib/session-file-references.test.mjs +91 -0
  226. package/lib/session-file-references.ts +29 -0
  227. package/lib/session-path.test.mjs +24 -0
  228. package/lib/session-path.ts +11 -0
  229. package/lib/session-reader.pagination.test.mjs +115 -0
  230. package/lib/session-reader.test.mjs +446 -0
  231. package/lib/session-reader.ts +548 -0
  232. package/lib/session-resolve.test.mjs +65 -0
  233. package/lib/session-row-context-menu.test.mjs +46 -0
  234. package/lib/session-row-context-menu.ts +29 -0
  235. package/lib/session-scanner.test.mjs +151 -0
  236. package/lib/session-scanner.ts +238 -0
  237. package/lib/session-search.test.mjs +173 -0
  238. package/lib/session-search.ts +284 -0
  239. package/lib/session-stats.ts +26 -0
  240. package/lib/session-timing.test.mjs +107 -0
  241. package/lib/session-timing.ts +45 -0
  242. package/lib/session-title.test.mjs +284 -0
  243. package/lib/session-title.ts +255 -0
  244. package/lib/skill-frontmatter.test.mjs +75 -0
  245. package/lib/skill-frontmatter.ts +40 -0
  246. package/lib/skill-lock.test.mjs +182 -0
  247. package/lib/skill-lock.ts +146 -0
  248. package/lib/skill-updates.test.mjs +182 -0
  249. package/lib/skill-updates.ts +263 -0
  250. package/lib/skills-service.ts +16 -0
  251. package/lib/slash-display.test.mjs +122 -0
  252. package/lib/slash-display.ts +30 -0
  253. package/lib/sqlite-db.test.mjs +140 -0
  254. package/lib/sqlite-db.ts +205 -0
  255. package/lib/startup-preferences.test.mjs +130 -0
  256. package/lib/startup-preferences.ts +65 -0
  257. package/lib/streaming-message.test.mjs +212 -0
  258. package/lib/streaming-message.ts +151 -0
  259. package/lib/subagent-widget.test.mjs +87 -0
  260. package/lib/subagent-widget.ts +109 -0
  261. package/lib/sync-room-server.mjs +137 -0
  262. package/lib/task-card-store.test.mjs +309 -0
  263. package/lib/task-card-store.ts +559 -0
  264. package/lib/task-scheduler.ts +498 -0
  265. package/lib/task-store.test.mjs +216 -0
  266. package/lib/task-store.ts +271 -0
  267. package/lib/terminal-input.test.mjs +46 -0
  268. package/lib/terminal-input.ts +63 -0
  269. package/lib/terminal-manager.test.mjs +71 -0
  270. package/lib/terminal-manager.ts +245 -0
  271. package/lib/tool-execution-progress.test.mjs +32 -0
  272. package/lib/tool-execution-progress.ts +29 -0
  273. package/lib/tool-names.ts +25 -0
  274. package/lib/tool-preset-preference.test.mjs +72 -0
  275. package/lib/tool-preset-preference.ts +41 -0
  276. package/lib/tool-presets.test.mjs +56 -0
  277. package/lib/tool-presets.ts +42 -0
  278. package/lib/turn-written-files.test.mjs +137 -0
  279. package/lib/turn-written-files.ts +60 -0
  280. package/lib/types.ts +349 -0
  281. package/lib/wallpaper-settings.ts +191 -0
  282. package/lib/web-auth.test.mjs +50 -0
  283. package/lib/web-auth.ts +45 -0
  284. package/lib/workspace-memory.test.mjs +129 -0
  285. package/lib/workspace-memory.ts +98 -0
  286. package/lib/worktree.test.mjs +50 -0
  287. package/lib/worktree.ts +250 -0
  288. package/package.json +3 -1
  289. package/server.mjs +34 -0
  290. package/.next/server/chunks/7513.js +0 -30
  291. package/.next/static/chunks/5315.03de3c6abd8b283e.js +0 -4
  292. package/.next/static/css/cf25164cb7c04d95.css +0 -1
  293. /package/.next/static/{Ud6Y2ONkGuPVFbo1xZQvk → EoBw3VYlygpr1EqlVOa5b}/_buildManifest.js +0 -0
  294. /package/.next/static/{Ud6Y2ONkGuPVFbo1xZQvk → EoBw3VYlygpr1EqlVOa5b}/_ssgManifest.js +0 -0
@@ -0,0 +1,151 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { createJiti } from "jiti";
7
+
8
+ const jiti = createJiti(import.meta.url);
9
+ const { scanSessionFiles } = await jiti.import("./session-scanner.ts");
10
+
11
+ function makeRoot() {
12
+ const root = mkdtempSync(join(tmpdir(), "pi-scan-"));
13
+ const projDir = join(root, "--home-wa-project-demo--");
14
+ mkdirSync(projDir, { recursive: true });
15
+ return { root, projDir };
16
+ }
17
+
18
+ test("scanSessionFiles:读取 header + 首条用户消息,忽略正文", async () => {
19
+ const { root, projDir } = makeRoot();
20
+ try {
21
+ const file = join(projDir, "2026-01-01T00-00-00-000Z_aaa.jsonl");
22
+ writeFileSync(file, [
23
+ '{"type":"session","version":3,"id":"aaa","timestamp":"2026-01-01T00:00:00.000Z","cwd":"/home/wa/project/demo","parentSession":"/x/bbb.jsonl"}',
24
+ '{"type":"model_change","id":"m1","parentId":null,"provider":"p","modelId":"m","timestamp":"2026-01-01T00:00:01.000Z"}',
25
+ '{"type":"message","id":"u1","parentId":null,"message":{"role":"user","content":"你好,请帮我看看这个项目"}}',
26
+ '{"type":"message","id":"a1","parentId":"u1","message":{"role":"assistant","content":[{"type":"text","text":"好的,我看看"}]}}',
27
+ '{"type":"message","id":"u2","parentId":"a1","message":{"role":"user","content":"再帮我优化一下"}}',
28
+ ].join("\n") + "\n");
29
+
30
+ const result = await scanSessionFiles(root);
31
+ assert.equal(result.length, 1);
32
+ const s = result[0];
33
+ assert.equal(s.id, "aaa");
34
+ assert.equal(s.cwd, "/home/wa/project/demo");
35
+ assert.equal(s.firstMessage, "你好,请帮我看看这个项目");
36
+ assert.equal(s.parentSessionPath, "/x/bbb.jsonl");
37
+ assert.equal(s.created.toISOString(), "2026-01-01T00:00:00.000Z");
38
+ assert.ok(s.modified.getTime() > 0);
39
+ assert.equal(typeof s.name, "undefined");
40
+ } finally {
41
+ rmSync(root, { recursive: true, force: true });
42
+ }
43
+ });
44
+
45
+ test("scanSessionFiles:扫描读取最后一个 session_info 的自定义名", async () => {
46
+ const { root, projDir } = makeRoot();
47
+ try {
48
+ const file = join(projDir, "2026-01-01T00-00-00-000Z_bbb.jsonl");
49
+ writeFileSync(file, [
50
+ '{"type":"session","version":3,"id":"bbb","timestamp":"2026-01-01T00:00:00.000Z","cwd":"/home/wa/project/demo"}',
51
+ '{"type":"message","id":"u1","parentId":null,"message":{"role":"user","content":"第一条消息"}}',
52
+ '{"type":"session_info","id":"s1","parentId":"a1","name":"旧名字","timestamp":"2026-01-01T00:00:05.000Z"}',
53
+ '{"type":"session_info","id":"s2","parentId":"a1","name":"最终名字","timestamp":"2026-01-01T00:00:06.000Z"}',
54
+ ].join("\n") + "\n");
55
+
56
+ const result = await scanSessionFiles(root);
57
+ assert.equal(result.length, 1);
58
+ // 扫描直接读名字(反向分块),取最后一个 session_info。
59
+ assert.equal(result[0].name, "最终名字");
60
+ } finally {
61
+ rmSync(root, { recursive: true, force: true });
62
+ }
63
+ });
64
+
65
+ test("scanSessionFiles:改名后大量新消息把 session_info 推远也能读到(旧 bug 场景)", async () => {
66
+ const { root, projDir } = makeRoot();
67
+ try {
68
+ const file = join(projDir, "2026-01-01T00-00-00-000Z_far.jsonl");
69
+ const lines = [
70
+ '{"type":"session","version":3,"id":"far","timestamp":"2026-01-01T00:00:00.000Z","cwd":"/home/wa/project/demo"}',
71
+ '{"type":"session_info","id":"s1","parentId":"a1","name":"深藏的名字","timestamp":"2026-01-01T00:00:05.000Z"}',
72
+ ];
73
+ const filler = "x".repeat(1024);
74
+ for (let i = 0; i < 200; i++) {
75
+ lines.push(`{"type":"message","id":"m${i}","parentId":"p","message":{"role":"assistant","content":"${filler}"}}`);
76
+ }
77
+ writeFileSync(file, lines.join("\n") + "\n");
78
+
79
+ const result = await scanSessionFiles(root);
80
+ assert.equal(result.length, 1);
81
+ assert.equal(result[0].name, "深藏的名字");
82
+ } finally {
83
+ rmSync(root, { recursive: true, force: true });
84
+ }
85
+ });
86
+
87
+ test("scanSessionFiles:非会话文件(首行不是 session)跳过", async () => {
88
+ const { root, projDir } = makeRoot();
89
+ try {
90
+ writeFileSync(join(projDir, "junk.jsonl"), '{"type":"message",...}\n');
91
+ writeFileSync(join(projDir, "empty.jsonl"), "");
92
+ const result = await scanSessionFiles(root);
93
+ assert.equal(result.length, 0);
94
+ } finally {
95
+ rmSync(root, { recursive: true, force: true });
96
+ }
97
+ });
98
+
99
+ test("scanSessionFiles:超大正文不影响扫描,只取首部简述", async () => {
100
+ const { root, projDir } = makeRoot();
101
+ try {
102
+ const file = join(projDir, "2026-01-01T00-00-00-000Z_ccc.jsonl");
103
+ // 中间故意放一个巨大的 assistant 消息(模拟大会话文件)。
104
+ const hugeText = "x".repeat(1024 * 1024);
105
+ writeFileSync(file, [
106
+ '{"type":"session","version":3,"id":"ccc","timestamp":"2026-01-01T00:00:00.000Z","cwd":"/home/wa/project/demo"}',
107
+ '{"type":"message","id":"u1","parentId":null,"message":{"role":"user","content":"帮我写一篇文章"}}',
108
+ `{"type":"message","id":"a1","parentId":"u1","message":{"role":"assistant","content":[{"type":"text","text":"${hugeText}"}]}}`,
109
+ '{"type":"message","id":"u2","parentId":"a1","message":{"role":"user","content":"再改改"}}',
110
+ ].join("\n") + "\n");
111
+
112
+ const t0 = Date.now();
113
+ const result = await scanSessionFiles(root);
114
+ const elapsed = Date.now() - t0;
115
+ assert.equal(result.length, 1);
116
+ assert.equal(result[0].id, "ccc");
117
+ assert.equal(result[0].firstMessage, "帮我写一篇文章");
118
+ assert.ok(elapsed < 2000, `大文件扫描应远快于读全量(实际 ${elapsed}ms)`);
119
+ } finally {
120
+ rmSync(root, { recursive: true, force: true });
121
+ }
122
+ });
123
+
124
+ test("scanSessionFiles:按 modified(文件 mtime)降序排序", async () => {
125
+ const { root, projDir } = makeRoot();
126
+ try {
127
+ const old = join(projDir, "2026-01-01T00-00-00-000Z_old.jsonl");
128
+ const fresh = join(projDir, "2026-01-02T00-00-00-000Z_new.jsonl");
129
+ writeFileSync(old, '{"type":"session","version":3,"id":"old","timestamp":"2026-01-01T00:00:00.000Z","cwd":"/home/wa/project/demo"}\n');
130
+ // 新文件 mtime 更新。
131
+ await new Promise((resolve) => setTimeout(resolve, 30));
132
+ writeFileSync(fresh, '{"type":"session","version":3,"id":"new","timestamp":"2026-01-02T00:00:00.000Z","cwd":"/home/wa/project/demo"}\n');
133
+
134
+ const result = await scanSessionFiles(root);
135
+ assert.deepEqual(result.map((s) => s.id), ["new", "old"]);
136
+ } finally {
137
+ rmSync(root, { recursive: true, force: true });
138
+ }
139
+ });
140
+
141
+ test("scanSessionFiles:缺失 timestamp 的 header 用 mtime 兜底", async () => {
142
+ const { root, projDir } = makeRoot();
143
+ try {
144
+ writeFileSync(join(projDir, "2026-01-01T00-00-00-000Z_noTs.jsonl"), '{"type":"session","version":3,"id":"noTs","cwd":"/home/wa/project/demo"}\n');
145
+ const result = await scanSessionFiles(root);
146
+ assert.equal(result.length, 1);
147
+ assert.ok(Number.isFinite(result[0].created.getTime()), "created 不可为 Invalid Date");
148
+ } finally {
149
+ rmSync(root, { recursive: true, force: true });
150
+ }
151
+ });
@@ -0,0 +1,238 @@
1
+ import { closeSync, openSync, readSync, statSync } from "fs";
2
+ import { readdir } from "fs/promises";
3
+ import { join as joinPath } from "path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+
6
+ /**
7
+ * 轻量会话列表扫描:只读取每个 jsonl 的「头部」(session header + 首条用户
8
+ * 消息简述)和「尾部」(自定义名 session_info,反向分块读),中间内容一律
9
+ * 不读,最后活动时间直接用文件 mtime。列表展示只需要标题 + 时间 + 名字。
10
+ */
11
+
12
+ interface HeaderEntry {
13
+ type: "session";
14
+ id?: unknown;
15
+ cwd?: unknown;
16
+ timestamp?: unknown;
17
+ parentSession?: unknown;
18
+ }
19
+
20
+ export interface SessionScanResult {
21
+ path: string;
22
+ id: string;
23
+ cwd: string;
24
+ name?: string;
25
+ created: Date;
26
+ modified: Date;
27
+ firstMessage: string;
28
+ /** 最后一条 assistant 回复文本(截断),无则为空串 */
29
+ lastReply: string;
30
+ parentSessionPath?: string;
31
+ }
32
+
33
+ // 初始读块:前几条 entry(header + 首条用户消息)通常远小于此;
34
+ // 不足时再按需扩读,直到拿到首条消息或达到上限。
35
+ const HEAD_INITIAL_BYTES = 4 * 1024;
36
+ const HEAD_MAX_BYTES = 48 * 1024;
37
+ // 列表里的首条消息只做简述展示(sidebar 也只截取前 50 字符)。
38
+ const FIRST_MESSAGE_PREVIEW_LENGTH = 300;
39
+
40
+ function parseLine(line: string): unknown {
41
+ if (!line.trim()) return null;
42
+ try {
43
+ return JSON.parse(line);
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ function extractTextContent(message: { content?: unknown }): string {
50
+ const content = message.content;
51
+ if (typeof content === "string") return content;
52
+ if (!Array.isArray(content)) return "";
53
+ return content
54
+ .filter((block) => block && block.type === "text" && typeof block.text === "string")
55
+ .map((block) => block.text)
56
+ .join(" ");
57
+ }
58
+
59
+ /**
60
+ * 读取文件头部:拿 header + 首条用户消息。
61
+ * 策略:先读小块(4KB),解析完整行;若首条消息已拿到则停;若块尾行
62
+ * 不完整(可能被截断),或还没拿到首条消息,则扩大读取继续。
63
+ * 首行必须是 session 头,否则视为非本应用会话文件。
64
+ */
65
+ function scanHead(path: string, size: number): { header: HeaderEntry; firstMessage: string } | null {
66
+ let readLen = Math.min(HEAD_INITIAL_BYTES, size);
67
+ let text = "";
68
+ const fd = openSync(path, "r");
69
+ try {
70
+ let header: HeaderEntry | null = null;
71
+ let firstMessage = "";
72
+
73
+ while (true) {
74
+ const buf = Buffer.alloc(readLen);
75
+ readSync(fd, buf, 0, readLen, 0);
76
+ const chunk = buf.toString("utf8");
77
+ // 块尾可能是半行:丢到最后完整换行处,避免解析半行。
78
+ const lastNl = chunk.lastIndexOf("\n");
79
+ const end = lastNl === -1 ? chunk.length : lastNl + 1;
80
+ text += chunk.slice(0, end);
81
+
82
+ for (const line of text.split("\n")) {
83
+ const entry = parseLine(line) as
84
+ | HeaderEntry
85
+ | { type?: unknown; message?: { role?: unknown; content?: unknown } }
86
+ | null;
87
+ if (!entry) continue;
88
+ if (!header) {
89
+ // 首条 entry 必须是 session 头,否则不是本应用的会话文件。
90
+ if (entry.type !== "session") return null;
91
+ header = entry as HeaderEntry;
92
+ continue;
93
+ }
94
+ if (entry.type === "message" && entry.message?.role === "user") {
95
+ const msgText = extractTextContent(entry.message).trim();
96
+ if (msgText) {
97
+ firstMessage = msgText.slice(0, FIRST_MESSAGE_PREVIEW_LENGTH);
98
+ break;
99
+ }
100
+ }
101
+ }
102
+
103
+ if (!header) return null;
104
+ if (firstMessage) break; // 已拿到首条消息,收工。
105
+
106
+ // 还没拿到首条消息:已读满上限或已到文件尾则放弃;否则扩读。
107
+ if (readLen >= HEAD_MAX_BYTES || readLen >= size) break;
108
+ readLen = Math.min(readLen * 2, HEAD_MAX_BYTES, size);
109
+ if (readLen <= 0) break;
110
+ }
111
+ return { header, firstMessage };
112
+ } finally {
113
+ closeSync(fd);
114
+ }
115
+ }
116
+
117
+ // 从文件末尾向前翻的块大小与上限。反向分块:session_info 是 append 写入的,
118
+ // 改名后继续聊会被推到更靠前的位置;从尾部向前找最后一个 session_info
119
+ // 与 SDK getSessionName 语义一致,且大多数会话第一块就命中(改名后没怎么聊)。
120
+ const NAME_BLOCK_BYTES = 64 * 1024;
121
+ const NAME_MAX_BLOCKS = 256; // 约 16MB,超出视为无名字
122
+
123
+ /** 读取文件尾部:最后一个 session_info 的自定义名 + 最后一条 assistant 回复。
124
+ * 同一趟反向分块里同时取两样:名字(显式清空 → undefined)、
125
+ * lastReply(最后一个 text 块,完整不截断)。返回 { name, lastReply }。 */
126
+ function scanTail(
127
+ path: string,
128
+ size: number,
129
+ ): { name: string | undefined; lastReply: string } {
130
+ const result: { name: string | undefined; lastReply: string } = { name: undefined, lastReply: "" };
131
+ if (size <= 0) return result;
132
+ const fd = openSync(path, "r");
133
+ try {
134
+ let offset = size;
135
+ for (let block = 0; block < NAME_MAX_BLOCKS && offset > 0; block++) {
136
+ const start = Math.max(0, offset - NAME_BLOCK_BYTES);
137
+ const len = offset - start;
138
+ const buf = Buffer.alloc(len);
139
+ readSync(fd, buf, 0, len, start);
140
+ const text = buf.toString("utf8");
141
+ const lines = text.split("\n");
142
+ // 块首可能截断一行(前一行的后半),跳过第一行;块尾对齐 offset 完整。
143
+ // 从后往前找本块内最后一个 session_info 与最后一条 assistant 回复。
144
+ for (let i = lines.length - 1; i >= 1; i--) {
145
+ if (result.name !== undefined && result.lastReply) continue;
146
+ const entry = parseLine(lines[i]) as
147
+ | { type?: string; name?: unknown; message?: { role?: unknown; content?: unknown } }
148
+ | null;
149
+ if (!entry) continue;
150
+ if (result.name === undefined && entry.type === "session_info" && typeof entry.name === "string") {
151
+ const name = entry.name.trim();
152
+ if (name) result.name = name; // 显式清空 → undefined
153
+ } else if (!result.lastReply && entry.type === "message" && entry.message?.role === "assistant") {
154
+ const reply = extractTextContent(entry.message).trim();
155
+ // 不截断:收合卡完整展示最后一条消息(跨 64KB 块的超大回复仍可能缺块首,极端场景)
156
+ if (reply) result.lastReply = reply;
157
+ }
158
+ if (result.name !== undefined && result.lastReply) break; // 两个都拿到,收工
159
+ }
160
+ offset = start;
161
+ }
162
+ return result;
163
+ } finally {
164
+ closeSync(fd);
165
+ }
166
+ }
167
+
168
+ function scanOneSessionFile(path: string): SessionScanResult | null {
169
+ let stat;
170
+ try {
171
+ stat = statSync(path);
172
+ } catch {
173
+ return null;
174
+ }
175
+ if (!stat.isFile() || stat.size === 0) return null;
176
+
177
+ const scanned = scanHead(path, stat.size);
178
+ if (!scanned) return null;
179
+ const { header, firstMessage } = scanned;
180
+
181
+ const id = typeof header.id === "string" ? header.id : "";
182
+ if (!id) return null;
183
+ const created = new Date(typeof header.timestamp === "string" ? header.timestamp : "");
184
+ if (Number.isNaN(created.getTime())) created.setTime(stat.mtime.getTime());
185
+
186
+ const tail = scanTail(path, stat.size);
187
+
188
+ return {
189
+ path,
190
+ id,
191
+ cwd: typeof header.cwd === "string" ? header.cwd : "",
192
+ name: tail.name,
193
+ created,
194
+ modified: stat.mtime,
195
+ firstMessage: firstMessage || "(no messages)",
196
+ lastReply: tail.lastReply,
197
+ parentSessionPath: typeof header.parentSession === "string" ? header.parentSession : undefined,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * 扫描 <agentDir>/sessions 下所有 jsonl。同步小 IO 串行即可(单文件只读
203
+ * 头部小块 + 尾部反向分块),`sessionsDir` 仅在测试时注入。
204
+ */
205
+ export async function scanSessionFiles(sessionsDir?: string): Promise<SessionScanResult[]> {
206
+ const root = sessionsDir ?? joinPath(getAgentDir(), "sessions");
207
+ let projectDirs: string[];
208
+ try {
209
+ const entries = await readdir(root, { withFileTypes: true });
210
+ projectDirs = entries
211
+ .filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
212
+ .map((entry) => joinPath(root, entry.name));
213
+ } catch {
214
+ return [];
215
+ }
216
+
217
+ const files: string[] = [];
218
+ for (const dir of projectDirs) {
219
+ try {
220
+ for (const name of await readdir(dir)) {
221
+ if (name.endsWith(".jsonl")) files.push(joinPath(dir, name));
222
+ }
223
+ } catch {
224
+ // 单目录不可读则跳过,不影响其它目录。
225
+ }
226
+ }
227
+
228
+ const results: SessionScanResult[] = [];
229
+ for (const file of files) {
230
+ const scanned = scanOneSessionFile(file);
231
+ if (scanned) results.push(scanned);
232
+ }
233
+ results.sort((a, b) => b.modified.getTime() - a.modified.getTime());
234
+ return results;
235
+ }
236
+
237
+ // 以对象形式暴露扫描入口,测试可替换实现(ESM 的模块绑定本身只读)。
238
+ export const sessionScanner = { scan: scanSessionFiles };
@@ -0,0 +1,173 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { DatabaseSync } from "node:sqlite";
7
+ import { createJiti } from "jiti";
8
+
9
+ const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false });
10
+ const { setDbForTesting } = await jiti.import("./sqlite-db.ts");
11
+ const { extractSessionText, ensureSearchIndex, searchSessions, fuzzyQueryForTerm } =
12
+ await jiti.import("./session-search.ts");
13
+
14
+ function freshDb() {
15
+ setDbForTesting(new DatabaseSync(":memory:"));
16
+ }
17
+
18
+ function makeSession(id, path, opts = {}) {
19
+ return {
20
+ path,
21
+ id,
22
+ cwd: "/tmp/proj",
23
+ name: opts.name,
24
+ created: "2026-08-27T00:00:00.000Z",
25
+ modified: opts.modified ?? "2026-08-27T01:00:00.000Z",
26
+ messageCount: 1,
27
+ firstMessage: opts.firstMessage ?? "",
28
+ };
29
+ }
30
+
31
+ const SAMPLE_LINES = [
32
+ '{"type":"session","version":3,"id":"s1","timestamp":"2026-08-27T00:00:00.000Z","cwd":"/tmp/proj"}',
33
+ '{"type":"message","id":"u1","parentId":null,"timestamp":"2026-08-27T00:00:01.000Z","message":{"role":"user","content":"修复登录接口的 bug"}}',
34
+ '{"type":"message","id":"a1","parentId":"u1","timestamp":"2026-08-27T00:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"我先改了 Token 逻辑"},{"type":"toolCall","id":"tc1","toolName":"read","input":{"path":"a.ts"}}]}}',
35
+ '{"type":"message","id":"r1","parentId":"a1","timestamp":"2026-08-27T00:00:03.000Z","message":{"role":"toolResult","toolCallId":"tc1","content":[{"type":"text","text":"文件内容 ok"},{"type":"image","source":{"type":"base64","data":"QUFBQUFBQUFB"}}]}}',
36
+ '{"type":"message","id":"b1","parentId":"r1","timestamp":"2026-08-27T00:00:04.000Z","message":{"role":"bashExecution","command":"npm test","output":"12 passed","excludeFromContext":true}}',
37
+ '{"type":"compaction","id":"c1","parentId":"b1","timestamp":"2026-08-27T00:00:05.000Z","summary":"跳过早期讨论","firstKeptEntryId":"u1","tokensBefore":100}',
38
+ ];
39
+
40
+ function fixtureDir(files) {
41
+ const dir = mkdtempSync(join(tmpdir(), "pi-search-test-"));
42
+ for (const [name, lines] of Object.entries(files)) {
43
+ writeFileSync(join(dir, name), lines.join("\n"));
44
+ }
45
+ return dir;
46
+ }
47
+
48
+ test("extractSessionText pulls title, messages, tools, bash, compaction; skips base64", () => {
49
+ const dir = fixtureDir({ "s1.jsonl": SAMPLE_LINES });
50
+ try {
51
+ const { title, body } = extractSessionText(makeSession("s1", join(dir, "s1.jsonl"), { name: "登录重构" }));
52
+ assert.equal(title, "登录重构");
53
+ assert.ok(body.includes("修复登录接口"), "user message");
54
+ assert.ok(body.includes("Token 逻辑"), "assistant text");
55
+ assert.ok(body.includes("[tool:read]"), "tool call name");
56
+ assert.ok(body.includes('"path":"a.ts"'), "tool call input");
57
+ assert.ok(body.includes("npm test"), "bash command");
58
+ assert.ok(body.includes("12 passed"), "bash output");
59
+ assert.ok(body.includes("跳过早期讨论"), "compaction summary");
60
+ assert.ok(!body.includes("QUFBQUFBQUFB"), "base64 image data must be dropped");
61
+ assert.ok(!body.includes("[image]"), "toolResult images must be omitted");
62
+ } finally {
63
+ rmSync(dir, { recursive: true, force: true });
64
+ }
65
+ });
66
+
67
+ test("ensureSearchIndex re-indexes on mtime change, drops vanished sessions", async () => {
68
+ freshDb();
69
+ const dir = fixtureDir({ "s1.jsonl": SAMPLE_LINES });
70
+ try {
71
+ const session = makeSession("s1", join(dir, "s1.jsonl"), { name: "登录重构" });
72
+ const first = await ensureSearchIndex([session]);
73
+ assert.equal(first.indexing, true);
74
+ const again = await ensureSearchIndex([session]);
75
+ assert.equal(again.indexing, false, "unchanged mtime must be a no-op");
76
+
77
+ const modified = { ...session, modified: "2026-08-27T02:00:00.000Z" };
78
+ const reindexed = await ensureSearchIndex([modified]);
79
+ assert.equal(reindexed.indexing, true, "mtime change must re-index");
80
+
81
+ const dropped = await ensureSearchIndex([]);
82
+ assert.equal(dropped.indexing, true);
83
+ const { results } = await searchSessions("登录接口", 10, []);
84
+ assert.equal(results.length, 0, "index must drop vanished sessions");
85
+ } finally {
86
+ rmSync(dir, { recursive: true, force: true });
87
+ }
88
+ });
89
+
90
+ test("searchSessions matches Chinese substring, 2-char LIKE fallback, English, titleMatch", async () => {
91
+ freshDb();
92
+ const dir = fixtureDir({ "s1.jsonl": SAMPLE_LINES });
93
+ try {
94
+ const s1 = makeSession("s1", join(dir, "s1.jsonl"), { name: "登录重构" });
95
+ const s2 = {
96
+ ...makeSession("s2", join(dir, "missing.jsonl"), {}),
97
+ name: "SQLite FTS index",
98
+ };
99
+ const sessions = [s1, s2];
100
+
101
+ // 4-char Chinese substring in body — title "登录重构" does NOT contain it,
102
+ // so this is a pure body hit.
103
+ const { results: m1 } = await searchSessions("登录接口", 10, sessions);
104
+ assert.deepEqual(m1.map((r) => r.session.id), ["s1"]);
105
+ assert.equal(m1[0].titleMatch, false);
106
+ assert.ok(m1[0].snippet.length > 0, "snippet should carry highlight markers");
107
+
108
+ // Title-hitting 4-char query sets titleMatch.
109
+ const { results: mTitle } = await searchSessions("登录重构", 10, sessions);
110
+ assert.deepEqual(mTitle.map((r) => r.session.id), ["s1"]);
111
+ assert.equal(mTitle[0].titleMatch, true);
112
+
113
+ // 2-char Chinese query — MATCH misses, LIKE fallback hits.
114
+ const { results: m2 } = await searchSessions("登录", 10, sessions);
115
+ assert.deepEqual(m2.map((r) => r.session.id), ["s1"]);
116
+
117
+ // English word.
118
+ const { results: m3 } = await searchSessions("fts", 10, sessions);
119
+ assert.deepEqual(m3.map((r) => r.session.id), ["s2"]);
120
+ assert.equal(m3[0].titleMatch, true);
121
+
122
+ // No results.
123
+ const { results: none } = await searchSessions("不存在的词xyz", 10, sessions);
124
+ assert.equal(none.length, 0);
125
+
126
+ // Quoted/special chars must not throw.
127
+ const { results: safe } = await searchSessions('a"b', 10, sessions);
128
+ assert.equal(safe.length, 0);
129
+ } finally {
130
+ rmSync(dir, { recursive: true, force: true });
131
+ }
132
+ });
133
+
134
+ test("searchSessions 多关键字空格分隔 AND(FTS 与 LIKE 回退)", async () => {
135
+ freshDb();
136
+ const dir = fixtureDir({ "s1.jsonl": SAMPLE_LINES });
137
+ try {
138
+ const s1 = makeSession("s1", join(dir, "s1.jsonl"), { name: "登录重构" });
139
+ const s2 = {
140
+ ...makeSession("s2", join(dir, "missing.jsonl"), {}),
141
+ name: "SQLite FTS index",
142
+ };
143
+ const sessions = [s1, s2];
144
+
145
+ // FTS 路径(两词均 ≥3 字符,且都在 s1 body 命中):"登录接口 token" → s1
146
+ const { results: fts } = await searchSessions("登录接口 token", 10, sessions);
147
+ assert.deepEqual(fts.map((r) => r.session.id), ["s1"], "FTS 多词 AND");
148
+
149
+ // LIKE 回退(含 2 字符词)title 命中:"登录 重构" → s1(title="登录重构")
150
+ const { results: likeTitle } = await searchSessions("登录 重构", 10, sessions);
151
+ assert.deepEqual(likeTitle.map((r) => r.session.id), ["s1"], "LIKE 回退多词 AND title");
152
+ assert.equal(likeTitle[0].titleMatch, true);
153
+
154
+ // LIKE 回退 body 场景:"登录 token" → s1(body 含两词)
155
+ const { results: likeBody } = await searchSessions("登录 token", 10, sessions);
156
+ assert.deepEqual(likeBody.map((r) => r.session.id), ["s1"], "LIKE 回退多词 AND body");
157
+
158
+ // 缺任一词不命中:s1 无 "不存在词xyz"
159
+ const { results: miss } = await searchSessions("登录接口 不存在词xyz", 10, sessions);
160
+ assert.equal(miss.length, 0, "缺一词不命中");
161
+
162
+ // 多空格/首尾空格容忍
163
+ const { results: spaced } = await searchSessions(" 登录 token ", 10, sessions);
164
+ assert.deepEqual(spaced.map((r) => r.session.id), ["s1"], "多空格容忍");
165
+ } finally {
166
+ rmSync(dir, { recursive: true, force: true });
167
+ }
168
+ });
169
+
170
+ test("fuzzyQueryForTerm escapes quotes into an FTS phrase", () => {
171
+ assert.equal(fuzzyQueryForTerm('a"b'), '"a""b"');
172
+ assert.equal(fuzzyQueryForTerm("登录"), '"登录"');
173
+ });