pim-agent 0.0.1 → 0.10.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.
Files changed (275) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +293 -7
  3. package/bin/pim.ts +151 -0
  4. package/package.json +82 -4
  5. package/packages/core/src/attachments/AttachmentStore.ts +99 -0
  6. package/packages/core/src/attachments/Attachments.ts +69 -0
  7. package/packages/core/src/extensions/CoreExtensions.ts +54 -0
  8. package/packages/core/src/extensions/apply-patch/coordinator.ts +53 -0
  9. package/packages/core/src/extensions/apply-patch/executor.ts +550 -0
  10. package/packages/core/src/extensions/apply-patch/index.ts +69 -0
  11. package/packages/core/src/extensions/apply-patch/matcher.ts +47 -0
  12. package/packages/core/src/extensions/apply-patch/model.ts +39 -0
  13. package/packages/core/src/extensions/apply-patch/parser.ts +379 -0
  14. package/packages/core/src/extensions/apply-patch/render.ts +177 -0
  15. package/packages/core/src/extensions/apply-patch/schema.ts +37 -0
  16. package/packages/core/src/extensions/apply-patch/types.ts +30 -0
  17. package/packages/core/src/extensions/bash/capture.ts +86 -0
  18. package/packages/core/src/extensions/bash/format.ts +110 -0
  19. package/packages/core/src/extensions/bash/image.ts +31 -0
  20. package/packages/core/src/extensions/bash/index.ts +88 -0
  21. package/packages/core/src/extensions/bash/render.ts +31 -0
  22. package/packages/core/src/extensions/bash/run.ts +221 -0
  23. package/packages/core/src/extensions/bash/schema.ts +64 -0
  24. package/packages/core/src/extensions/edit/edit.ts +366 -0
  25. package/packages/core/src/extensions/edit/index.ts +36 -0
  26. package/packages/core/src/extensions/edit/render.ts +16 -0
  27. package/packages/core/src/extensions/edit/schema.ts +37 -0
  28. package/packages/core/src/extensions/glob/glob.ts +34 -0
  29. package/packages/core/src/extensions/glob/index.ts +83 -0
  30. package/packages/core/src/extensions/glob/render.ts +72 -0
  31. package/packages/core/src/extensions/glob/schema.ts +66 -0
  32. package/packages/core/src/extensions/grep/grep.ts +256 -0
  33. package/packages/core/src/extensions/grep/index.ts +114 -0
  34. package/packages/core/src/extensions/grep/render.ts +271 -0
  35. package/packages/core/src/extensions/grep/schema.ts +92 -0
  36. package/packages/core/src/extensions/read/ImageMemory.ts +42 -0
  37. package/packages/core/src/extensions/read/image.ts +57 -0
  38. package/packages/core/src/extensions/read/index.ts +88 -0
  39. package/packages/core/src/extensions/read/read.ts +191 -0
  40. package/packages/core/src/extensions/read/render.ts +58 -0
  41. package/packages/core/src/extensions/read/schema.ts +49 -0
  42. package/packages/core/src/extensions/subagent/index.ts +33 -0
  43. package/packages/core/src/extensions/subagent/render.ts +127 -0
  44. package/packages/core/src/extensions/subagent/schema.ts +9 -0
  45. package/packages/core/src/extensions/subagent/subagent.ts +419 -0
  46. package/packages/core/src/extensions/system-prompt/index.ts +36 -0
  47. package/packages/core/src/extensions/system-prompt/prompt.ts +232 -0
  48. package/packages/core/src/extensions/todo/index.ts +123 -0
  49. package/packages/core/src/extensions/todo/render.ts +156 -0
  50. package/packages/core/src/extensions/todo/schema.ts +24 -0
  51. package/packages/core/src/extensions/todo/todo.ts +185 -0
  52. package/packages/core/src/extensions/web-fetch/JinaReaderClient.ts +187 -0
  53. package/packages/core/src/extensions/web-fetch/WebViewFetchClient.ts +183 -0
  54. package/packages/core/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +512 -0
  55. package/packages/core/src/extensions/web-fetch/fetch.ts +265 -0
  56. package/packages/core/src/extensions/web-fetch/image.ts +165 -0
  57. package/packages/core/src/extensions/web-fetch/index.ts +83 -0
  58. package/packages/core/src/extensions/web-fetch/render.ts +100 -0
  59. package/packages/core/src/extensions/web-fetch/schema.ts +46 -0
  60. package/packages/core/src/extensions/web-search/ExaMcpClient.ts +251 -0
  61. package/packages/core/src/extensions/web-search/SearchBreaker.ts +147 -0
  62. package/packages/core/src/extensions/web-search/SearchChain.ts +77 -0
  63. package/packages/core/src/extensions/web-search/index.ts +90 -0
  64. package/packages/core/src/extensions/web-search/providers/DuckDuckGoProvider.ts +195 -0
  65. package/packages/core/src/extensions/web-search/providers/ExaProvider.ts +54 -0
  66. package/packages/core/src/extensions/web-search/providers/FirecrawlProvider.ts +144 -0
  67. package/packages/core/src/extensions/web-search/providers/SearchProvider.ts +118 -0
  68. package/packages/core/src/extensions/web-search/render.ts +49 -0
  69. package/packages/core/src/extensions/web-search/schema.ts +29 -0
  70. package/packages/core/src/extensions/web-search/search.ts +23 -0
  71. package/packages/core/src/extensions/write/index.ts +58 -0
  72. package/packages/core/src/extensions/write/render.ts +16 -0
  73. package/packages/core/src/extensions/write/schema.ts +14 -0
  74. package/packages/core/src/extensions/write/write.ts +87 -0
  75. package/packages/core/src/picker/FilePickerSuggestionEngine.ts +14 -0
  76. package/packages/core/src/picker/InProcessFilePickerSuggestionEngine.ts +52 -0
  77. package/packages/core/src/picker/PickerItem.ts +6 -0
  78. package/packages/core/src/picker/PickerService.ts +132 -0
  79. package/packages/core/src/picker/RemoteFilePickerSuggestionEngine.ts +56 -0
  80. package/packages/core/src/picker/WorkerFilePickerSuggestionEngine.ts +268 -0
  81. package/packages/core/src/picker/catalog.ts +220 -0
  82. package/packages/core/src/picker/commandRanker.ts +17 -0
  83. package/packages/core/src/picker/filePickerWorker.ts +66 -0
  84. package/packages/core/src/picker/filePickerWorkerMessages.ts +39 -0
  85. package/packages/core/src/picker/ranker.ts +245 -0
  86. package/packages/core/src/picker/token.ts +77 -0
  87. package/packages/core/src/session/AgentRuntime.ts +85 -0
  88. package/packages/core/src/session/EventLog.ts +156 -0
  89. package/packages/core/src/session/MessageText.ts +21 -0
  90. package/packages/core/src/session/ReadCursors.ts +94 -0
  91. package/packages/core/src/session/SearchIndex.ts +752 -0
  92. package/packages/core/src/session/SearchTokens.ts +97 -0
  93. package/packages/core/src/session/SessionCache.ts +68 -0
  94. package/packages/core/src/session/SessionDigest.ts +161 -0
  95. package/packages/core/src/session/SessionHost.ts +892 -0
  96. package/packages/core/src/session/SessionLease.ts +279 -0
  97. package/packages/core/src/session/SessionMeta.ts +267 -0
  98. package/packages/core/src/session/SessionName.ts +18 -0
  99. package/packages/core/src/session/SessionRegistry.ts +244 -0
  100. package/packages/core/src/session/WriteMark.ts +51 -0
  101. package/packages/core/src/shared/Cli.ts +25 -0
  102. package/packages/core/src/shared/DaemonUnit.ts +13 -0
  103. package/packages/core/src/shared/DiffLines.ts +304 -0
  104. package/packages/core/src/shared/DiffPatch.ts +70 -0
  105. package/packages/core/src/shared/DiffRenderer.ts +283 -0
  106. package/packages/core/src/shared/DiffView.ts +29 -0
  107. package/packages/core/src/shared/Directories.ts +61 -0
  108. package/packages/core/src/shared/EditMatcher.ts +767 -0
  109. package/packages/core/src/shared/Errors.ts +9 -0
  110. package/packages/core/src/shared/ExtensionToggles.ts +130 -0
  111. package/packages/core/src/shared/FileEnumerator.ts +361 -0
  112. package/packages/core/src/shared/FileScanner.ts +57 -0
  113. package/packages/core/src/shared/FileWatch.ts +121 -0
  114. package/packages/core/src/shared/Format.ts +74 -0
  115. package/packages/core/src/shared/Fs.ts +91 -0
  116. package/packages/core/src/shared/FsErrors.ts +67 -0
  117. package/packages/core/src/shared/FuzzyMatcher.ts +71 -0
  118. package/packages/core/src/shared/Git.ts +530 -0
  119. package/packages/core/src/shared/GitMonitor.ts +221 -0
  120. package/packages/core/src/shared/GlobExclusions.ts +13 -0
  121. package/packages/core/src/shared/Http.ts +14 -0
  122. package/packages/core/src/shared/ImageMime.ts +36 -0
  123. package/packages/core/src/shared/Images.ts +357 -0
  124. package/packages/core/src/shared/Json.ts +19 -0
  125. package/packages/core/src/shared/Languages.ts +95 -0
  126. package/packages/core/src/shared/Levenshtein.ts +83 -0
  127. package/packages/core/src/shared/Lines.ts +79 -0
  128. package/packages/core/src/shared/McpClient.ts +444 -0
  129. package/packages/core/src/shared/OutputBudget.ts +84 -0
  130. package/packages/core/src/shared/PatchSummary.ts +40 -0
  131. package/packages/core/src/shared/Paths.ts +80 -0
  132. package/packages/core/src/shared/PimSettings.ts +128 -0
  133. package/packages/core/src/shared/PimVersion.ts +93 -0
  134. package/packages/core/src/shared/Pool.ts +26 -0
  135. package/packages/core/src/shared/Proc.ts +50 -0
  136. package/packages/core/src/shared/RateLimiter.ts +50 -0
  137. package/packages/core/src/shared/Renderer.ts +371 -0
  138. package/packages/core/src/shared/RepoDiff.ts +741 -0
  139. package/packages/core/src/shared/SafePath.ts +17 -0
  140. package/packages/core/src/shared/SearchRender.ts +82 -0
  141. package/packages/core/src/shared/SendFile.ts +23 -0
  142. package/packages/core/src/shared/SpillCache.ts +78 -0
  143. package/packages/core/src/shared/SubagentLogs.ts +102 -0
  144. package/packages/core/src/shared/Supervisor.ts +460 -0
  145. package/packages/core/src/shared/Surface.ts +2 -0
  146. package/packages/core/src/shared/Sweeper.ts +32 -0
  147. package/packages/core/src/shared/Tools.ts +801 -0
  148. package/packages/core/src/shared/Updater.ts +209 -0
  149. package/packages/core/src/view/AnsiPainter.ts +227 -0
  150. package/packages/core/src/view/BodyRenderer.ts +102 -0
  151. package/packages/core/src/view/DiffBlocks.ts +52 -0
  152. package/packages/core/src/view/DiffExpand.ts +214 -0
  153. package/packages/core/src/view/DiffLayout.ts +69 -0
  154. package/packages/core/src/view/DiffPairs.ts +47 -0
  155. package/packages/core/src/view/MarkdownPainter.ts +314 -0
  156. package/packages/core/src/view/MovePath.ts +70 -0
  157. package/packages/core/src/view/Painting.ts +182 -0
  158. package/packages/core/src/view/ViewBlock.ts +124 -0
  159. package/packages/daemon/src/Daemon.ts +63 -0
  160. package/packages/daemon/src/DaemonInstall.ts +115 -0
  161. package/packages/daemon/src/Surfaces.ts +66 -0
  162. package/packages/daemon/src/TelegramSurface.ts +30 -0
  163. package/packages/daemon/src/WebSurface.ts +56 -0
  164. package/packages/daemon/src/index.ts +37 -0
  165. package/packages/protocol/src/Command.ts +219 -0
  166. package/packages/protocol/src/Diff.ts +15 -0
  167. package/packages/protocol/src/ImageRoute.ts +23 -0
  168. package/packages/protocol/src/ServerEvent.ts +406 -0
  169. package/packages/server/src/AttachmentEndpoint.ts +184 -0
  170. package/packages/server/src/ClientConnection.ts +277 -0
  171. package/packages/server/src/ImageEndpoint.ts +36 -0
  172. package/packages/server/src/ProbeClient.ts +374 -0
  173. package/packages/server/src/Reloader.ts +93 -0
  174. package/packages/server/src/SendFileTool.ts +88 -0
  175. package/packages/server/src/SessionCatalogue.ts +456 -0
  176. package/packages/server/src/SessionProjection.ts +194 -0
  177. package/packages/server/src/SessionStream.ts +627 -0
  178. package/packages/server/src/StaticClient.ts +178 -0
  179. package/packages/server/src/WebOptions.ts +72 -0
  180. package/packages/server/src/WsGateway.ts +798 -0
  181. package/packages/server/src/probe.ts +200 -0
  182. package/packages/telegram/src/Bot.ts +229 -0
  183. package/packages/telegram/src/Commands.ts +768 -0
  184. package/packages/telegram/src/Config.ts +148 -0
  185. package/packages/telegram/src/Markdown.ts +235 -0
  186. package/packages/telegram/src/Message.ts +152 -0
  187. package/packages/telegram/src/Renderer.ts +456 -0
  188. package/packages/telegram/src/SendFileSchema.ts +18 -0
  189. package/packages/telegram/src/SendFileTool.ts +65 -0
  190. package/packages/telegram/src/Session.ts +253 -0
  191. package/packages/telegram/src/SessionRegistry.ts +126 -0
  192. package/packages/telegram/src/TaskScheduler.ts +296 -0
  193. package/packages/telegram/src/TaskSchema.ts +88 -0
  194. package/packages/telegram/src/TaskStore.ts +67 -0
  195. package/packages/telegram/src/TaskTool.ts +208 -0
  196. package/packages/telegram/src/TelegramHtml.ts +288 -0
  197. package/packages/telegram/src/TypingIndicator.ts +43 -0
  198. package/packages/telegram/src/UpdateConfirm.ts +61 -0
  199. package/packages/tui/src/extensions/_init/index.ts +144 -0
  200. package/packages/tui/src/extensions/command-picker/index.ts +75 -0
  201. package/packages/tui/src/extensions/file-picker/index.ts +156 -0
  202. package/packages/tui/src/extensions/footer/index.ts +93 -0
  203. package/packages/tui/src/extensions/footer/powerline.ts +87 -0
  204. package/packages/tui/src/extensions/footer/segments.ts +202 -0
  205. package/packages/tui/src/extensions/pim/index.ts +89 -0
  206. package/packages/tui/src/extensions/session-lease/index.ts +253 -0
  207. package/packages/tui/src/extensions/tps/index.ts +118 -0
  208. package/packages/tui/src/extensions/working-indicator/index.ts +75 -0
  209. package/packages/tui/src/extensions/wrapProvider.ts +18 -0
  210. package/packages/tui/src/themes/pim-dark.json +84 -0
  211. package/packages/tui/src/themes/pim-light.json +84 -0
  212. package/packages/tui/src/themes/themeCliArgs.ts +20 -0
  213. package/packages/web/dist/client/apple-touch-icon.png +0 -0
  214. package/packages/web/dist/client/assets/bash-LmRjUKlR.js +1 -0
  215. package/packages/web/dist/client/assets/c-DBVbJFpC.js +1 -0
  216. package/packages/web/dist/client/assets/clojure-CR7dvLJ4.js +1 -0
  217. package/packages/web/dist/client/assets/cmake-CgP6gB4W.js +1 -0
  218. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
  219. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
  220. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-B-iV2FbL.woff2 +0 -0
  221. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-kM0OTZCv.woff +0 -0
  222. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
  223. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
  224. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
  225. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
  226. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
  227. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
  228. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
  229. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
  230. package/packages/web/dist/client/assets/core-CA0aSzPu.js +3 -0
  231. package/packages/web/dist/client/assets/cpp-N2d8M7Nc.js +1 -0
  232. package/packages/web/dist/client/assets/csharp-yYt0Ikks.js +1 -0
  233. package/packages/web/dist/client/assets/css-D8M8GYaw.js +1 -0
  234. package/packages/web/dist/client/assets/diff-BhdZizse.js +1 -0
  235. package/packages/web/dist/client/assets/dockerfile-C0zufGsU.js +1 -0
  236. package/packages/web/dist/client/assets/elixir-DXIHlGsR.js +1 -0
  237. package/packages/web/dist/client/assets/erlang-BjYI8dhz.js +1 -0
  238. package/packages/web/dist/client/assets/go-CYstVVJV.js +1 -0
  239. package/packages/web/dist/client/assets/graphql-BHHhacaR.js +1 -0
  240. package/packages/web/dist/client/assets/haskell-DUkhXIda.js +1 -0
  241. package/packages/web/dist/client/assets/index-B9be-i9d.css +1 -0
  242. package/packages/web/dist/client/assets/index-CpinM5NK.js +45 -0
  243. package/packages/web/dist/client/assets/ini-B1CroMsf.js +1 -0
  244. package/packages/web/dist/client/assets/java-Dde-Wnb4.js +1 -0
  245. package/packages/web/dist/client/assets/javascript-8AmHuH5O.js +1 -0
  246. package/packages/web/dist/client/assets/json-PmyKWHmm.js +1 -0
  247. package/packages/web/dist/client/assets/kotlin-DgSK8bu-.js +2 -0
  248. package/packages/web/dist/client/assets/less-Bp0mgmpG.js +1 -0
  249. package/packages/web/dist/client/assets/lua-1dXYaL-H.js +1 -0
  250. package/packages/web/dist/client/assets/makefile-BX-o3EIz.js +1 -0
  251. package/packages/web/dist/client/assets/markdown-D6FrnlqJ.js +1 -0
  252. package/packages/web/dist/client/assets/ocaml-B-P1Uyq8.js +1 -0
  253. package/packages/web/dist/client/assets/perl-CvqMZYuf.js +1 -0
  254. package/packages/web/dist/client/assets/php-DuZl5e-L.js +2 -0
  255. package/packages/web/dist/client/assets/powershell-BUe16zE4.js +1 -0
  256. package/packages/web/dist/client/assets/protobuf-DGOW1qT8.js +1 -0
  257. package/packages/web/dist/client/assets/python-gHmu6VPG.js +1 -0
  258. package/packages/web/dist/client/assets/r-DNWPCnjT.js +1 -0
  259. package/packages/web/dist/client/assets/ruby-BGpZ3p4_.js +1 -0
  260. package/packages/web/dist/client/assets/rust-BIKPVoi6.js +1 -0
  261. package/packages/web/dist/client/assets/scala-C9lP4FWY.js +1 -0
  262. package/packages/web/dist/client/assets/scss-gOyOKiXd.js +1 -0
  263. package/packages/web/dist/client/assets/sql-DWRKyvJ-.js +1 -0
  264. package/packages/web/dist/client/assets/swift-BTn9__hL.js +1 -0
  265. package/packages/web/dist/client/assets/typescript-CxkPLmSc.js +1 -0
  266. package/packages/web/dist/client/assets/vim-Dhhcn_CA.js +1 -0
  267. package/packages/web/dist/client/assets/xml-COl5yFSC.js +1 -0
  268. package/packages/web/dist/client/assets/yaml-DjOhhVLG.js +1 -0
  269. package/packages/web/dist/client/favicon.svg +1 -0
  270. package/packages/web/dist/client/icon-192.png +0 -0
  271. package/packages/web/dist/client/icon-512.png +0 -0
  272. package/packages/web/dist/client/icon-maskable.png +0 -0
  273. package/packages/web/dist/client/index.html +24 -0
  274. package/packages/web/dist/client/manifest.webmanifest +21 -0
  275. package/packages/web/dist/client/wordmark.svg +1 -0
@@ -0,0 +1,456 @@
1
+ import { EventLog } from "#core/session/EventLog";
2
+ import type { SessionDigest } from "#core/session/EventLog";
3
+ import type { ReadCursors } from "#core/session/ReadCursors";
4
+ import { SearchIndex } from "#core/session/SearchIndex";
5
+ import type { SearchHit } from "#core/session/SearchIndex";
6
+ import type {
7
+ Pinning,
8
+ SessionEntry,
9
+ SessionMeta,
10
+ } from "#core/session/SessionMeta";
11
+ import type {
12
+ SessionRegistry,
13
+ SessionSummary,
14
+ } from "#core/session/SessionRegistry";
15
+ import { FileWatch } from "#core/shared/FileWatch";
16
+ import { Pool } from "#core/shared/Pool";
17
+ import type { Command } from "#protocol/Command";
18
+ import type {
19
+ ProjectView,
20
+ SearchHitView,
21
+ ServerEvent,
22
+ SessionListing,
23
+ SessionSearch,
24
+ SessionStatus,
25
+ SessionSummaryView,
26
+ } from "#protocol/ServerEvent";
27
+
28
+ export type SessionCatalogueDeps = {
29
+ readonly registry: SessionRegistry;
30
+ /** Which sessions have been read, shared by every client. */
31
+ readonly cursors: ReadCursors;
32
+ /** Archived, held-unread and pinned, shared by every client. */
33
+ readonly meta: SessionMeta;
34
+ readonly liveStatus: (sessionId: string) => SessionStatus | undefined;
35
+ readonly liveSessionIds: () => Iterable<string>;
36
+ readonly isBeingRead: (sessionId: string) => boolean;
37
+ readonly announce: (event: ServerEvent) => void;
38
+ };
39
+
40
+ const DEFAULT_SESSION_LIMIT = 50;
41
+
42
+ /** A digest is a whole-file read; a page of them at once is a page of files in memory at once. */
43
+ const DIGEST_READS = 16;
44
+
45
+ /** Long enough that a turn's entries are one announcement, short enough that a new session appears while the user is still looking. */
46
+ const ANNOUNCE_MS = 500;
47
+
48
+ type CachedDigest = SessionDigest & { readonly modifiedAt: number };
49
+
50
+ type Overrides = ReadonlyMap<string, SessionEntry>;
51
+
52
+ type PageScope = {
53
+ readonly overrides: Overrides;
54
+ /** True while listing the archived sessions rather than the live ones. */
55
+ readonly archived: boolean;
56
+ readonly limit: number;
57
+ readonly perProject: number;
58
+ };
59
+
60
+ export class SessionCatalogue {
61
+ private readonly deps: SessionCatalogueDeps;
62
+ private readonly digests = new Map<string, CachedDigest>();
63
+ private readonly activity = new Map<string, SessionStatus>();
64
+ private readonly settled = new Map<string, number>();
65
+ private readonly watches = new Map<string, () => void>();
66
+ private index: SearchIndex;
67
+ private watching = false;
68
+ private announcing: ReturnType<typeof setTimeout> | undefined;
69
+
70
+ public constructor(deps: SessionCatalogueDeps) {
71
+ this.deps = deps;
72
+ this.index = this.newIndex();
73
+ }
74
+
75
+ /**
76
+ * Follow the sessions tree while anyone is connected. A session the terminal
77
+ * or a vanilla `pi` starts is a file this process never hears about
78
+ * otherwise, and a list nobody asked to re-fetch is a list that never learns
79
+ * about it.
80
+ */
81
+ public watch(active: boolean): void {
82
+ if (active === this.watching) {
83
+ return;
84
+ }
85
+ this.watching = active;
86
+ if (!active) {
87
+ for (const stop of this.watches.values()) {
88
+ stop();
89
+ }
90
+ this.watches.clear();
91
+ clearTimeout(this.announcing);
92
+ this.announcing = undefined;
93
+ return;
94
+ }
95
+ this.follow();
96
+ }
97
+
98
+ public async list(
99
+ command: Command & { readonly type: "list_sessions" }
100
+ ): Promise<SessionListing> {
101
+ const [summaries, overrides, pinning] = await Promise.all([
102
+ this.deps.registry.list(command.cwd),
103
+ this.deps.meta.sessions(),
104
+ this.deps.meta.pinning(),
105
+ ]);
106
+ // Prune only on an unfiltered listing: a cwd-filtered one would forget every other directory.
107
+ if (command.cwd === undefined) {
108
+ const alive = new Set([
109
+ ...summaries.map((summary) => summary.sessionId),
110
+ ...this.deps.liveSessionIds(),
111
+ ]);
112
+ await Promise.all([
113
+ this.deps.cursors.prune(alive),
114
+ this.deps.meta.prune(alive),
115
+ ]);
116
+ }
117
+ const scope = command.archived === true;
118
+ // Before the cut: an archived row that ate the page budget would push a live one off the end.
119
+ const inScope = summaries.filter(
120
+ (summary) =>
121
+ (overrides.get(summary.sessionId)?.archived === true) === scope
122
+ );
123
+ const limit = command.limit ?? DEFAULT_SESSION_LIMIT;
124
+ return {
125
+ sessions: await this.page(inScope, {
126
+ overrides,
127
+ archived: scope,
128
+ limit,
129
+ // Absent, a project may fill the page; it is the page that bounds it either way.
130
+ perProject: command.perProject ?? limit,
131
+ }),
132
+ // Counted before the cut, and over the whole scope: a collapsed group says how many it holds, and a row the cut dropped is one a client can still ask for.
133
+ projects: projectsOf(inScope, pinning),
134
+ };
135
+ }
136
+
137
+ /**
138
+ * The index cannot see the sidecar, so the archived scope is a predicate it
139
+ * calls before its own limit, and the badge is a `Map.get` over the one read
140
+ * of `sessions.json` this query makes.
141
+ */
142
+ public async search(
143
+ command: Command & { readonly type: "search_sessions" }
144
+ ): Promise<SessionSearch> {
145
+ const overrides = await this.deps.meta.sessions();
146
+ const archivedOf = (sessionId: string): boolean =>
147
+ overrides.get(sessionId)?.archived === true;
148
+ const { hits, dropped, scanned } = await this.index.search(command.query, {
149
+ ...(command.limit === undefined ? {} : { limit: command.limit }),
150
+ ...(command.cwd === undefined ? {} : { cwd: command.cwd }),
151
+ ...(command.archived === undefined
152
+ ? {}
153
+ : {
154
+ accept: (sessionId: string) =>
155
+ archivedOf(sessionId) === command.archived,
156
+ }),
157
+ });
158
+ return {
159
+ hits: hits.map((hit) => hitOf(hit, archivedOf(hit.sessionId))),
160
+ dropped,
161
+ scanned,
162
+ };
163
+ }
164
+
165
+ /** Announces a status edge to every client; the announcement must precede the read mark. */
166
+ public onStatus(sessionId: string, status: SessionStatus): void {
167
+ if (this.activity.get(sessionId) === status) {
168
+ return;
169
+ }
170
+ this.activity.set(sessionId, status);
171
+ this.deps.announce({ type: "session_activity", sessionId, status });
172
+ if (status === "idle" && this.deps.isBeingRead(sessionId)) {
173
+ void this.markRead(sessionId);
174
+ }
175
+ }
176
+
177
+ public track(sessionId: string, status: SessionStatus): void {
178
+ this.activity.set(sessionId, status);
179
+ }
180
+
181
+ /** Moves a session's read cursor to now, drops the mark a reader left on it by hand, and says so to every client. */
182
+ public async markRead(sessionId: string): Promise<void> {
183
+ await this.deps.cursors.mark(sessionId);
184
+ // The only place the sticky flag is cleared; a listing must never clear it.
185
+ if ((await this.deps.meta.of(sessionId)).unread === true) {
186
+ await this.deps.meta.setUnread(sessionId, false);
187
+ }
188
+ this.deps.announce({ type: "session_read", sessionId });
189
+ }
190
+
191
+ public async flush(): Promise<void> {
192
+ await Promise.all([this.deps.cursors.flush(), this.deps.meta.flush()]);
193
+ }
194
+
195
+ public clear(): void {
196
+ this.digests.clear();
197
+ this.activity.clear();
198
+ this.settled.clear();
199
+ this.index = this.newIndex();
200
+ }
201
+
202
+ /** One index per catalogue, and so one per session root; the registry's listing is the tree it follows. */
203
+ private newIndex(): SearchIndex {
204
+ return new SearchIndex({ list: () => this.deps.registry.list() });
205
+ }
206
+
207
+ /**
208
+ * Selects by modified time under a per-project budget and digests only what
209
+ * it selects: ordering by settle time would read every session on disk. A
210
+ * session with nothing to call itself is not a row, and the budget it spent
211
+ * goes back to its project, so a directory of abandoned files still shows
212
+ * the sessions underneath them.
213
+ */
214
+ private async page(
215
+ candidates: readonly SessionSummary[],
216
+ scope: PageScope
217
+ ): Promise<readonly SessionSummaryView[]> {
218
+ const rows: SessionSummaryView[] = [];
219
+ const taken = new Map<string, number>();
220
+ const consumed = new Set<SessionSummary>();
221
+ for (;;) {
222
+ const batch = choose(candidates, consumed, taken, {
223
+ room: scope.limit - rows.length,
224
+ perProject: scope.perProject,
225
+ });
226
+ if (batch.length === 0) {
227
+ break;
228
+ }
229
+ const built = await Pool.mapPooled(batch, DIGEST_READS, (summary) =>
230
+ this.row(summary, scope)
231
+ );
232
+ for (const [index, row] of built.entries()) {
233
+ if (row === undefined) {
234
+ const { cwd } = batch[index]!;
235
+ taken.set(cwd, (taken.get(cwd) ?? 1) - 1);
236
+ continue;
237
+ }
238
+ rows.push(row);
239
+ }
240
+ }
241
+ return rows.sort((a, b) => b.settledAt - a.settledAt);
242
+ }
243
+
244
+ /** Nothing for a session that never asked anything and was never named: the row it would draw is a truncated UUID. */
245
+ private async row(
246
+ { sessionId, cwd, path, createdAt, modifiedAt }: SessionSummary,
247
+ { overrides, archived }: PageScope
248
+ ): Promise<SessionSummaryView | undefined> {
249
+ const status = this.deps.liveStatus(sessionId);
250
+ const { title, named, settledAt } = await this.digestOf(
251
+ sessionId,
252
+ path,
253
+ modifiedAt,
254
+ status
255
+ );
256
+ if (title === undefined) {
257
+ return undefined;
258
+ }
259
+ const answeredAt = this.answerTime(sessionId, status, settledAt);
260
+ const unread =
261
+ overrides.get(sessionId)?.unread === true ||
262
+ (await this.deps.cursors.isUnread(sessionId, answeredAt));
263
+ return {
264
+ sessionId,
265
+ cwd,
266
+ createdAt,
267
+ settledAt: answeredAt ?? settledAt ?? createdAt,
268
+ title,
269
+ ...(named === true ? { named } : {}),
270
+ ...(archived ? { archived: true as const } : {}),
271
+ ...(status === undefined || status === "idle" ? {} : { status }),
272
+ ...(unread ? { unread: true } : {}),
273
+ };
274
+ }
275
+
276
+ // One watch per directory, never a recursive one: `fs.watch` recursion is unsupported on some platforms and silent on others.
277
+ private follow(): void {
278
+ const root = this.deps.registry.sessionsRoot;
279
+ const wanted = new Set([root, ...FileWatch.subdirectories(root)]);
280
+ for (const [path, stop] of this.watches) {
281
+ if (!wanted.has(path)) {
282
+ stop();
283
+ this.watches.delete(path);
284
+ }
285
+ }
286
+ for (const path of wanted) {
287
+ if (!this.watches.has(path)) {
288
+ this.watches.set(
289
+ path,
290
+ FileWatch.directory(path, () => {
291
+ this.onTreeChange();
292
+ })
293
+ );
294
+ }
295
+ }
296
+ }
297
+
298
+ // Debounced: a turn writes a dozen entries, and re-listing is a read of every session header.
299
+ private onTreeChange(): void {
300
+ if (this.announcing !== undefined || !this.watching) {
301
+ return;
302
+ }
303
+ this.announcing = setTimeout(() => {
304
+ this.announcing = undefined;
305
+ this.follow();
306
+ this.deps.announce({ type: "sessions_changed" });
307
+ }, ANNOUNCE_MS);
308
+ this.announcing.unref?.();
309
+ }
310
+
311
+ // A running session must be answered for by its last idle reading: mid-turn the file's time is the line just written.
312
+ private answerTime(
313
+ sessionId: string,
314
+ status: SessionStatus | undefined,
315
+ fromFile: number | undefined
316
+ ): number | undefined {
317
+ if (status === undefined) {
318
+ return fromFile;
319
+ }
320
+ if (status !== "idle") {
321
+ return this.settled.get(sessionId);
322
+ }
323
+ if (fromFile !== undefined) {
324
+ this.settled.set(sessionId, fromFile);
325
+ }
326
+ return fromFile;
327
+ }
328
+
329
+ private async digestOf(
330
+ sessionId: string,
331
+ path: string,
332
+ modifiedAt: number,
333
+ status: SessionStatus | undefined
334
+ ): Promise<SessionDigest> {
335
+ const name = this.liveName(sessionId);
336
+ const digest =
337
+ this.reusable(path, modifiedAt, status, name) ??
338
+ (await this.readDigest(path, modifiedAt));
339
+ return renamed(digest, name);
340
+ }
341
+
342
+ /**
343
+ * Mid-turn the file is appended to between listings, so its digest never hits
344
+ * the cache and the whole-file read lands on the busiest session there is.
345
+ * Nothing it answers can have moved: the opening message was written long
346
+ * ago, the settle time is deliberately not read from the file while a turn
347
+ * runs, and the name is the live session's own to say.
348
+ */
349
+ private reusable(
350
+ path: string,
351
+ modifiedAt: number,
352
+ status: SessionStatus | undefined,
353
+ name: string | null | undefined
354
+ ): CachedDigest | undefined {
355
+ const cached = this.digests.get(path);
356
+ if (cached === undefined) {
357
+ return undefined;
358
+ }
359
+ // Except a name the live session has since cleared: the opening message a
360
+ // nameless row falls back to is in the file and nowhere else.
361
+ if (name === null && cached.named === true) {
362
+ return undefined;
363
+ }
364
+ const working = status !== undefined && status !== "idle";
365
+ return cached.modifiedAt === modifiedAt || working ? cached : undefined;
366
+ }
367
+
368
+ private async readDigest(
369
+ path: string,
370
+ modifiedAt: number
371
+ ): Promise<SessionDigest> {
372
+ const digest = await new EventLog(path).digest();
373
+ this.digests.set(path, { ...digest, modifiedAt });
374
+ return digest;
375
+ }
376
+
377
+ /** What a live session calls itself, without reading its file; `null` is live and unnamed. */
378
+ private liveName(sessionId: string): string | null | undefined {
379
+ const agent = this.deps.registry.peek(sessionId)?.agentSession;
380
+ return agent === undefined
381
+ ? undefined
382
+ : (agent.sessionManager.getSessionName() ?? null);
383
+ }
384
+ }
385
+
386
+ /** The row the wire carries: the index's hit, plus the one thing about it only the sidecar knows. */
387
+ function hitOf(hit: SearchHit, archived: boolean): SearchHitView {
388
+ return {
389
+ sessionId: hit.sessionId,
390
+ cwd: hit.cwd,
391
+ ...(hit.title === undefined ? {} : { title: hit.title }),
392
+ titleRanges: hit.titleRanges,
393
+ ...(hit.opening === undefined ? {} : { opening: hit.opening }),
394
+ settledAt: hit.settledAt,
395
+ ...(archived ? { archived: true as const } : {}),
396
+ snippets: hit.snippets,
397
+ total: hit.total,
398
+ };
399
+ }
400
+
401
+ /** A live session's own name wins over the file's: a rename lands on the row before the digest behind it expires. */
402
+ function renamed(
403
+ digest: SessionDigest,
404
+ name: string | null | undefined
405
+ ): SessionDigest {
406
+ return typeof name === "string"
407
+ ? { ...digest, title: name, named: true }
408
+ : digest;
409
+ }
410
+
411
+ /** The next sessions to digest, newest first, skipping the projects that have spent their budget and the candidates an earlier round already took. */
412
+ function choose(
413
+ candidates: readonly SessionSummary[],
414
+ consumed: Set<SessionSummary>,
415
+ taken: Map<string, number>,
416
+ budget: { readonly room: number; readonly perProject: number }
417
+ ): readonly SessionSummary[] {
418
+ const batch: SessionSummary[] = [];
419
+ for (const summary of candidates) {
420
+ if (batch.length >= budget.room) {
421
+ break;
422
+ }
423
+ const used = taken.get(summary.cwd) ?? 0;
424
+ if (consumed.has(summary) || used >= budget.perProject) {
425
+ continue;
426
+ }
427
+ taken.set(summary.cwd, used + 1);
428
+ consumed.add(summary);
429
+ batch.push(summary);
430
+ }
431
+ return batch;
432
+ }
433
+
434
+ /** Every working directory the scope holds sessions in, newest first; the count is the header scan's, so it owes nothing to the page. */
435
+ function projectsOf(
436
+ summaries: readonly SessionSummary[],
437
+ { projects, order }: Pinning
438
+ ): readonly ProjectView[] {
439
+ const counts = new Map<string, number>();
440
+ for (const { cwd } of summaries) {
441
+ counts.set(cwd, (counts.get(cwd) ?? 0) + 1);
442
+ }
443
+ const ranks = new Map(order.map((cwd, rank) => [cwd, rank]));
444
+ return [...counts].map(([cwd, count]) => {
445
+ const entry = projects.get(cwd);
446
+ return {
447
+ cwd,
448
+ count,
449
+ ...(entry?.pinned === true
450
+ ? { pinned: true as const, pinRank: ranks.get(cwd) ?? 0 }
451
+ : {}),
452
+ ...(entry?.expanded === true ? { expanded: true as const } : {}),
453
+ ...(entry?.label === undefined ? {} : { label: entry.label }),
454
+ };
455
+ });
456
+ }
@@ -0,0 +1,194 @@
1
+ import type {
2
+ AgentMessage,
3
+ AgentToolResult,
4
+ } from "@earendil-works/pi-agent-core";
5
+ import type { FileEntry } from "@earendil-works/pi-coding-agent";
6
+
7
+ import { Attachments } from "#core/attachments/Attachments";
8
+ import { EventLog, type LoggedEntry } from "#core/session/EventLog";
9
+ import { MessageText } from "#core/session/MessageText";
10
+ import { Tools } from "#core/shared/Tools";
11
+ import type {
12
+ AttachmentView,
13
+ DurableEvent,
14
+ ToolCallView,
15
+ } from "#protocol/ServerEvent";
16
+ import { attachmentUrl } from "./AttachmentEndpoint";
17
+
18
+ type PendingCall = {
19
+ readonly name: string;
20
+ readonly args: unknown;
21
+ };
22
+
23
+ /** Projects pi's session file into durable events: one line in, at most one `DurableEvent` out, tagged with that line's ordinal. */
24
+ export class SessionProjection {
25
+ private readonly log: EventLog;
26
+ private readonly cwd: () => string;
27
+ private readonly events: DurableEvent[] = [];
28
+ private readonly calls = new Map<string, PendingCall>();
29
+ private draining: Promise<void> = Promise.resolve();
30
+ private highest = 0;
31
+
32
+ public constructor(path: string, cwd: () => string) {
33
+ this.log = new EventLog(path);
34
+ this.cwd = cwd;
35
+ }
36
+
37
+ /** Highest line ordinal projected, whether or not it emitted. */
38
+ public get head(): number {
39
+ return this.highest;
40
+ }
41
+
42
+ public since(fromSeq: number): readonly DurableEvent[] {
43
+ let low = 0;
44
+ let high = this.events.length;
45
+ while (low < high) {
46
+ const mid = (low + high) >>> 1;
47
+ if (this.events[mid]!.seq > fromSeq) {
48
+ high = mid;
49
+ } else {
50
+ low = mid + 1;
51
+ }
52
+ }
53
+ return this.events.slice(low);
54
+ }
55
+
56
+ /** Read whatever pi has appended since the last drain; serialized, as overlapping reads double-project lines. */
57
+ public async drain(): Promise<readonly DurableEvent[]> {
58
+ const before = this.events.length;
59
+ const done = this.draining.then(async () => {
60
+ for (const logged of await this.log.read(this.highest)) {
61
+ this.highest = logged.seq;
62
+ const event = this.project(logged);
63
+ if (event) {
64
+ this.events.push(event);
65
+ }
66
+ }
67
+ });
68
+ this.draining = done.catch(() => {});
69
+ await done;
70
+ return this.events.slice(before);
71
+ }
72
+
73
+ private project(logged: LoggedEntry): DurableEvent | undefined {
74
+ const { seq, entry } = logged;
75
+ if (entry.type === "compaction") {
76
+ return {
77
+ seq,
78
+ type: "notice",
79
+ severity: "info",
80
+ text: `Context compacted (${entry.tokensBefore} tokens before).`,
81
+ };
82
+ }
83
+ if (!isMessageEntry(entry)) {
84
+ return undefined;
85
+ }
86
+ const message = entry.message;
87
+ const timestamp = Date.parse(entry.timestamp);
88
+ switch (message.role) {
89
+ case "user": {
90
+ const said = Attachments.parse(MessageText.textOf(message.content));
91
+ const attachments: readonly AttachmentView[] = said.files.map(
92
+ (file) => ({
93
+ name: Attachments.nameOf(file.path),
94
+ url: attachmentUrl(file.path),
95
+ isImage: file.isImage,
96
+ })
97
+ );
98
+ return {
99
+ seq,
100
+ type: "message",
101
+ messageId: entry.id,
102
+ role: "user",
103
+ text: said.text,
104
+ timestamp,
105
+ ...(attachments.length === 0 ? {} : { attachments }),
106
+ };
107
+ }
108
+ case "assistant": {
109
+ const toolCalls: ToolCallView[] = [];
110
+ for (const part of message.content) {
111
+ if (part.type !== "toolCall") {
112
+ continue;
113
+ }
114
+ this.calls.set(part.id, { name: part.name, args: part.arguments });
115
+ toolCalls.push({
116
+ callId: part.id,
117
+ name: part.name,
118
+ view: Tools.viewOf({
119
+ name: part.name,
120
+ args: part.arguments,
121
+ isPartial: true,
122
+ cwd: this.cwd(),
123
+ }),
124
+ });
125
+ }
126
+ const thinking = MessageText.textOf(message.content, "thinking");
127
+ const error =
128
+ message.stopReason === "error"
129
+ ? (message.errorMessage ?? "The model call failed.")
130
+ : undefined;
131
+ return {
132
+ seq,
133
+ type: "message",
134
+ messageId: entry.id,
135
+ role: "assistant",
136
+ text: MessageText.textOf(message.content),
137
+ timestamp,
138
+ ...(thinking ? { thinking } : {}),
139
+ ...(toolCalls.length > 0 ? { toolCalls } : {}),
140
+ ...(error === undefined ? {} : { error }),
141
+ };
142
+ }
143
+ case "toolResult": {
144
+ const call = this.calls.get(message.toolCallId);
145
+ return {
146
+ seq,
147
+ type: "tool_result",
148
+ callId: message.toolCallId,
149
+ name: message.toolName,
150
+ isError: message.isError,
151
+ view: Tools.viewOf({
152
+ name: message.toolName,
153
+ args: call?.args ?? {},
154
+ isError: message.isError,
155
+ result: {
156
+ content: withoutImages(message.content),
157
+ details: message.details,
158
+ } as AgentToolResult<unknown>,
159
+ isPartial: false,
160
+ cwd: this.cwd(),
161
+ }),
162
+ };
163
+ }
164
+ default:
165
+ return undefined;
166
+ }
167
+ }
168
+ }
169
+
170
+ type MessageEntry = {
171
+ readonly id: string;
172
+ readonly timestamp: string;
173
+ readonly message: AgentMessage;
174
+ };
175
+
176
+ type ToolResultContent = Extract<
177
+ AgentMessage,
178
+ { role: "toolResult" }
179
+ >["content"];
180
+
181
+ /** No base64 crosses the websocket: a view reaches its picture through `details.sha256` instead. */
182
+ function withoutImages(content: ToolResultContent): ToolResultContent {
183
+ return content.some((part) => part.type === "image")
184
+ ? content.map((part) =>
185
+ part.type === "image"
186
+ ? { type: "text" as const, text: "[image]" }
187
+ : part
188
+ )
189
+ : content;
190
+ }
191
+
192
+ function isMessageEntry(entry: FileEntry): entry is FileEntry & MessageEntry {
193
+ return entry.type === "message";
194
+ }