pim-agent 0.0.1 → 0.9.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 (261) 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 +51 -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 +216 -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 +28 -0
  47. package/packages/core/src/extensions/system-prompt/prompt.ts +228 -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 +110 -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 +70 -0
  87. package/packages/core/src/session/AgentRuntime.ts +85 -0
  88. package/packages/core/src/session/EventLog.ts +213 -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/SessionCache.ts +68 -0
  92. package/packages/core/src/session/SessionHost.ts +876 -0
  93. package/packages/core/src/session/SessionLease.ts +279 -0
  94. package/packages/core/src/session/SessionRegistry.ts +169 -0
  95. package/packages/core/src/session/WriteMark.ts +37 -0
  96. package/packages/core/src/shared/Cli.ts +25 -0
  97. package/packages/core/src/shared/DaemonUnit.ts +13 -0
  98. package/packages/core/src/shared/DiffLines.ts +304 -0
  99. package/packages/core/src/shared/DiffRenderer.ts +283 -0
  100. package/packages/core/src/shared/DiffView.ts +29 -0
  101. package/packages/core/src/shared/Directories.ts +61 -0
  102. package/packages/core/src/shared/EditMatcher.ts +767 -0
  103. package/packages/core/src/shared/Errors.ts +9 -0
  104. package/packages/core/src/shared/ExtensionToggles.ts +130 -0
  105. package/packages/core/src/shared/FileEnumerator.ts +361 -0
  106. package/packages/core/src/shared/FileScanner.ts +57 -0
  107. package/packages/core/src/shared/FileWatch.ts +107 -0
  108. package/packages/core/src/shared/Format.ts +74 -0
  109. package/packages/core/src/shared/Fs.ts +91 -0
  110. package/packages/core/src/shared/FsErrors.ts +67 -0
  111. package/packages/core/src/shared/FuzzyMatcher.ts +71 -0
  112. package/packages/core/src/shared/Git.ts +89 -0
  113. package/packages/core/src/shared/GlobExclusions.ts +13 -0
  114. package/packages/core/src/shared/Http.ts +14 -0
  115. package/packages/core/src/shared/ImageMime.ts +36 -0
  116. package/packages/core/src/shared/Images.ts +357 -0
  117. package/packages/core/src/shared/Json.ts +19 -0
  118. package/packages/core/src/shared/Languages.ts +95 -0
  119. package/packages/core/src/shared/Levenshtein.ts +30 -0
  120. package/packages/core/src/shared/Lines.ts +79 -0
  121. package/packages/core/src/shared/McpClient.ts +444 -0
  122. package/packages/core/src/shared/OutputBudget.ts +84 -0
  123. package/packages/core/src/shared/PatchSummary.ts +40 -0
  124. package/packages/core/src/shared/Paths.ts +80 -0
  125. package/packages/core/src/shared/PimSettings.ts +128 -0
  126. package/packages/core/src/shared/PimVersion.ts +93 -0
  127. package/packages/core/src/shared/Pool.ts +26 -0
  128. package/packages/core/src/shared/Proc.ts +30 -0
  129. package/packages/core/src/shared/RateLimiter.ts +50 -0
  130. package/packages/core/src/shared/Renderer.ts +371 -0
  131. package/packages/core/src/shared/SafePath.ts +17 -0
  132. package/packages/core/src/shared/SearchRender.ts +82 -0
  133. package/packages/core/src/shared/SendFile.ts +23 -0
  134. package/packages/core/src/shared/SpillCache.ts +78 -0
  135. package/packages/core/src/shared/SubagentLogs.ts +102 -0
  136. package/packages/core/src/shared/Supervisor.ts +460 -0
  137. package/packages/core/src/shared/Sweeper.ts +32 -0
  138. package/packages/core/src/shared/Tools.ts +801 -0
  139. package/packages/core/src/shared/Updater.ts +193 -0
  140. package/packages/core/src/view/AnsiPainter.ts +227 -0
  141. package/packages/core/src/view/BodyRenderer.ts +102 -0
  142. package/packages/core/src/view/DiffBlocks.ts +52 -0
  143. package/packages/core/src/view/DiffLayout.ts +69 -0
  144. package/packages/core/src/view/MarkdownPainter.ts +314 -0
  145. package/packages/core/src/view/Painting.ts +182 -0
  146. package/packages/core/src/view/ViewBlock.ts +124 -0
  147. package/packages/daemon/src/Daemon.ts +63 -0
  148. package/packages/daemon/src/DaemonInstall.ts +115 -0
  149. package/packages/daemon/src/Surfaces.ts +66 -0
  150. package/packages/daemon/src/TelegramSurface.ts +30 -0
  151. package/packages/daemon/src/WebSurface.ts +57 -0
  152. package/packages/daemon/src/index.ts +37 -0
  153. package/packages/protocol/src/Command.ts +92 -0
  154. package/packages/protocol/src/ImageRoute.ts +23 -0
  155. package/packages/protocol/src/Protocol.ts +6 -0
  156. package/packages/protocol/src/ServerEvent.ts +251 -0
  157. package/packages/server/src/AttachmentEndpoint.ts +184 -0
  158. package/packages/server/src/ClientConnection.ts +265 -0
  159. package/packages/server/src/ImageEndpoint.ts +36 -0
  160. package/packages/server/src/ProbeClient.ts +312 -0
  161. package/packages/server/src/Reloader.ts +93 -0
  162. package/packages/server/src/SendFileTool.ts +88 -0
  163. package/packages/server/src/SessionCatalogue.ts +202 -0
  164. package/packages/server/src/SessionProjection.ts +194 -0
  165. package/packages/server/src/SessionStream.ts +601 -0
  166. package/packages/server/src/StaticClient.ts +95 -0
  167. package/packages/server/src/WebOptions.ts +72 -0
  168. package/packages/server/src/WsGateway.ts +563 -0
  169. package/packages/server/src/probe.ts +146 -0
  170. package/packages/telegram/src/Bot.ts +229 -0
  171. package/packages/telegram/src/Commands.ts +768 -0
  172. package/packages/telegram/src/Config.ts +148 -0
  173. package/packages/telegram/src/Markdown.ts +235 -0
  174. package/packages/telegram/src/Message.ts +152 -0
  175. package/packages/telegram/src/Renderer.ts +456 -0
  176. package/packages/telegram/src/SendFileSchema.ts +18 -0
  177. package/packages/telegram/src/SendFileTool.ts +65 -0
  178. package/packages/telegram/src/Session.ts +252 -0
  179. package/packages/telegram/src/SessionRegistry.ts +126 -0
  180. package/packages/telegram/src/TaskScheduler.ts +296 -0
  181. package/packages/telegram/src/TaskSchema.ts +88 -0
  182. package/packages/telegram/src/TaskStore.ts +67 -0
  183. package/packages/telegram/src/TaskTool.ts +208 -0
  184. package/packages/telegram/src/TelegramHtml.ts +288 -0
  185. package/packages/telegram/src/TypingIndicator.ts +43 -0
  186. package/packages/telegram/src/UpdateConfirm.ts +61 -0
  187. package/packages/tui/src/extensions/_init/index.ts +144 -0
  188. package/packages/tui/src/extensions/command-picker/index.ts +43 -0
  189. package/packages/tui/src/extensions/file-picker/index.ts +156 -0
  190. package/packages/tui/src/extensions/footer/index.ts +117 -0
  191. package/packages/tui/src/extensions/footer/powerline.ts +87 -0
  192. package/packages/tui/src/extensions/footer/segments.ts +202 -0
  193. package/packages/tui/src/extensions/pim/index.ts +89 -0
  194. package/packages/tui/src/extensions/session-lease/index.ts +248 -0
  195. package/packages/tui/src/extensions/tps/index.ts +118 -0
  196. package/packages/tui/src/extensions/working-indicator/index.ts +75 -0
  197. package/packages/tui/src/extensions/wrapProvider.ts +18 -0
  198. package/packages/tui/src/themes/pim-dark.json +84 -0
  199. package/packages/tui/src/themes/pim-light.json +84 -0
  200. package/packages/tui/src/themes/themeCliArgs.ts +20 -0
  201. package/packages/web/dist/client/apple-touch-icon.png +0 -0
  202. package/packages/web/dist/client/assets/bash-LmRjUKlR.js +1 -0
  203. package/packages/web/dist/client/assets/c-DBVbJFpC.js +1 -0
  204. package/packages/web/dist/client/assets/clojure-CR7dvLJ4.js +1 -0
  205. package/packages/web/dist/client/assets/cmake-CgP6gB4W.js +1 -0
  206. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
  207. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
  208. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
  209. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
  210. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
  211. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
  212. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
  213. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
  214. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
  215. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
  216. package/packages/web/dist/client/assets/core-CGdQx9la.js +3 -0
  217. package/packages/web/dist/client/assets/cpp-N2d8M7Nc.js +1 -0
  218. package/packages/web/dist/client/assets/csharp-yYt0Ikks.js +1 -0
  219. package/packages/web/dist/client/assets/css-D8M8GYaw.js +1 -0
  220. package/packages/web/dist/client/assets/diff-BhdZizse.js +1 -0
  221. package/packages/web/dist/client/assets/dockerfile-C0zufGsU.js +1 -0
  222. package/packages/web/dist/client/assets/elixir-DXIHlGsR.js +1 -0
  223. package/packages/web/dist/client/assets/erlang-BjYI8dhz.js +1 -0
  224. package/packages/web/dist/client/assets/go-CYstVVJV.js +1 -0
  225. package/packages/web/dist/client/assets/graphql-BHHhacaR.js +1 -0
  226. package/packages/web/dist/client/assets/haskell-DUkhXIda.js +1 -0
  227. package/packages/web/dist/client/assets/index-BMNwleWO.js +40 -0
  228. package/packages/web/dist/client/assets/index-CqjB2RPr.css +1 -0
  229. package/packages/web/dist/client/assets/ini-B1CroMsf.js +1 -0
  230. package/packages/web/dist/client/assets/java-Dde-Wnb4.js +1 -0
  231. package/packages/web/dist/client/assets/javascript-8AmHuH5O.js +1 -0
  232. package/packages/web/dist/client/assets/json-PmyKWHmm.js +1 -0
  233. package/packages/web/dist/client/assets/kotlin-DgSK8bu-.js +2 -0
  234. package/packages/web/dist/client/assets/less-Bp0mgmpG.js +1 -0
  235. package/packages/web/dist/client/assets/lua-1dXYaL-H.js +1 -0
  236. package/packages/web/dist/client/assets/makefile-BX-o3EIz.js +1 -0
  237. package/packages/web/dist/client/assets/markdown-D6FrnlqJ.js +1 -0
  238. package/packages/web/dist/client/assets/ocaml-B-P1Uyq8.js +1 -0
  239. package/packages/web/dist/client/assets/perl-CvqMZYuf.js +1 -0
  240. package/packages/web/dist/client/assets/php-DuZl5e-L.js +2 -0
  241. package/packages/web/dist/client/assets/powershell-BUe16zE4.js +1 -0
  242. package/packages/web/dist/client/assets/protobuf-DGOW1qT8.js +1 -0
  243. package/packages/web/dist/client/assets/python-gHmu6VPG.js +1 -0
  244. package/packages/web/dist/client/assets/r-DNWPCnjT.js +1 -0
  245. package/packages/web/dist/client/assets/ruby-BGpZ3p4_.js +1 -0
  246. package/packages/web/dist/client/assets/rust-BIKPVoi6.js +1 -0
  247. package/packages/web/dist/client/assets/scala-C9lP4FWY.js +1 -0
  248. package/packages/web/dist/client/assets/scss-gOyOKiXd.js +1 -0
  249. package/packages/web/dist/client/assets/sql-DWRKyvJ-.js +1 -0
  250. package/packages/web/dist/client/assets/swift-BTn9__hL.js +1 -0
  251. package/packages/web/dist/client/assets/typescript-CxkPLmSc.js +1 -0
  252. package/packages/web/dist/client/assets/vim-Dhhcn_CA.js +1 -0
  253. package/packages/web/dist/client/assets/xml-COl5yFSC.js +1 -0
  254. package/packages/web/dist/client/assets/yaml-DjOhhVLG.js +1 -0
  255. package/packages/web/dist/client/favicon.svg +1 -0
  256. package/packages/web/dist/client/icon-192.png +0 -0
  257. package/packages/web/dist/client/icon-512.png +0 -0
  258. package/packages/web/dist/client/icon-maskable.png +0 -0
  259. package/packages/web/dist/client/index.html +24 -0
  260. package/packages/web/dist/client/manifest.webmanifest +21 -0
  261. package/packages/web/dist/client/wordmark.svg +1 -0
@@ -0,0 +1,85 @@
1
+ import type { Api as ModelApi, Model } from "@earendil-works/pi-ai";
2
+ import {
3
+ getAgentDir,
4
+ ModelRegistry,
5
+ ModelRuntime,
6
+ SettingsManager,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { join } from "node:path";
9
+
10
+ /** One model a session can be switched to, named the way `setModel` takes it. */
11
+ export type ModelChoice = {
12
+ readonly id: string;
13
+ readonly label: string;
14
+ /** Who serves it, for a client that groups or tags the catalogue. */
15
+ readonly provider: string;
16
+ };
17
+
18
+ /**
19
+ * Pi's installation as one process sees it: a single `ModelRuntime` over a
20
+ * single `auth.json`, and one `SettingsManager` per cwd. Every surface in a
21
+ * process shares one of these — two runtimes over one auth file race each
22
+ * other's token refresh.
23
+ */
24
+ export class AgentRuntime {
25
+ public readonly agentDir: string;
26
+ private readonly settingsManagers = new Map<string, SettingsManager>();
27
+ private runtime: ModelRuntime | undefined;
28
+ private registry: ModelRegistry | undefined;
29
+ private booting: Promise<void> | undefined;
30
+
31
+ public constructor(agentDir?: string) {
32
+ this.agentDir = agentDir ?? getAgentDir();
33
+ }
34
+
35
+ /** Idempotent, and shared by every caller: the second surface to ask waits on the first. */
36
+ public init(): Promise<void> {
37
+ this.booting ??= this.bootstrap().catch((err: unknown) => {
38
+ this.booting = undefined;
39
+ throw err;
40
+ });
41
+ return this.booting;
42
+ }
43
+
44
+ public get modelRuntime(): ModelRuntime {
45
+ return this.required(this.runtime);
46
+ }
47
+
48
+ public get modelRegistry(): ModelRegistry {
49
+ return this.required(this.registry);
50
+ }
51
+
52
+ /** Every model this machine has credentials for, qualified as `setModel` takes them. */
53
+ public models(): readonly ModelChoice[] {
54
+ return this.modelRegistry.getAvailable().map((model: Model<ModelApi>) => ({
55
+ id: `${model.provider}/${model.id}`,
56
+ label: model.name,
57
+ provider: model.provider,
58
+ }));
59
+ }
60
+
61
+ public settingsManagerFor(cwd: string): SettingsManager {
62
+ const existing = this.settingsManagers.get(cwd);
63
+ if (existing) {
64
+ return existing;
65
+ }
66
+ const settingsManager = SettingsManager.create(cwd, this.agentDir);
67
+ this.settingsManagers.set(cwd, settingsManager);
68
+ return settingsManager;
69
+ }
70
+
71
+ private async bootstrap(): Promise<void> {
72
+ this.runtime = await ModelRuntime.create({
73
+ authPath: join(this.agentDir, "auth.json"),
74
+ modelsPath: join(this.agentDir, "models.json"),
75
+ });
76
+ this.registry = new ModelRegistry(this.runtime);
77
+ }
78
+
79
+ private required<T>(value: T | undefined): T {
80
+ if (value === undefined) {
81
+ throw new Error("AgentRuntime.init() must complete before use");
82
+ }
83
+ return value;
84
+ }
85
+ }
@@ -0,0 +1,213 @@
1
+ import type { BunFile } from "bun";
2
+
3
+ import type { FileEntry, SessionHeader } from "@earendil-works/pi-coding-agent";
4
+ import { parseSessionEntries } from "@earendil-works/pi-coding-agent";
5
+
6
+ import { Attachments } from "../attachments/Attachments";
7
+ import { MessageText } from "./MessageText";
8
+
9
+ async function ifPresent<T>(read: () => Promise<T>, absent: T): Promise<T> {
10
+ try {
11
+ return await read();
12
+ } catch (err) {
13
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
14
+ return absent;
15
+ }
16
+ throw err;
17
+ }
18
+ }
19
+
20
+ /** One durable line of a session file; the header is line 1, so entries start at `seq` 2. */
21
+ export type LoggedEntry = {
22
+ readonly seq: number;
23
+ readonly entry: FileEntry;
24
+ };
25
+
26
+ type Cursor = {
27
+ readonly offset: number;
28
+ readonly seq: number;
29
+ };
30
+
31
+ const PROBE_LINES = 16;
32
+
33
+ const PROBE_LIMIT = 16 * 1024 * 1024;
34
+
35
+ const PROBE_CHUNK = 8 * 1024;
36
+ const PROBE_CHUNK_MAX = 256 * 1024;
37
+
38
+ const EMPTY = new Uint8Array();
39
+
40
+ const TAIL_BYTES = 64 * 1024;
41
+ const TAIL_LINES = 10;
42
+
43
+ const TITLE_LIMIT = 120;
44
+
45
+ /** What the session catalogue shows for one file without opening a session. */
46
+ export type SessionDigest = {
47
+ /** The first user message, trimmed; absent when the session has none yet. */
48
+ readonly title?: string;
49
+ /** When the agent last wrote; it moves with an in-flight turn, so read it before starting one. */
50
+ readonly settledAt?: number;
51
+ };
52
+
53
+ function isHeader(entry: FileEntry): entry is SessionHeader {
54
+ return entry.type === "session";
55
+ }
56
+
57
+ /** A reader over one pi session file: append-only JSONL, so a line's `seq` never changes. */
58
+ export class EventLog {
59
+ public readonly path: string;
60
+ private cursor: Cursor = { offset: 0, seq: 0 };
61
+
62
+ public constructor(path: string) {
63
+ this.path = path;
64
+ }
65
+
66
+ /** Durable entries with `seq > afterSeq`, oldest first. */
67
+ public async read(afterSeq = 0): Promise<readonly LoggedEntry[]> {
68
+ const start =
69
+ this.cursor.seq <= afterSeq ? this.cursor : { offset: 0, seq: 0 };
70
+ const file = Bun.file(this.path);
71
+ const text = await ifPresent(() => file.slice(start.offset).text(), "");
72
+
73
+ // A trailing fragment is a write in progress: stop short of it or reads lose entries.
74
+ const lastBreak = text.lastIndexOf("\n");
75
+ if (lastBreak === -1) {
76
+ return [];
77
+ }
78
+ const complete = text.slice(0, lastBreak + 1);
79
+ const lines = complete.split("\n");
80
+ lines.pop();
81
+
82
+ const entries: LoggedEntry[] = [];
83
+ for (const [index, line] of lines.entries()) {
84
+ const seq = start.seq + index + 1;
85
+ if (seq <= afterSeq) {
86
+ continue;
87
+ }
88
+ const parsed = parseSessionEntries(line)[0];
89
+ if (parsed) {
90
+ entries.push({ seq, entry: parsed });
91
+ }
92
+ }
93
+
94
+ this.cursor = {
95
+ offset: start.offset + Buffer.byteLength(complete, "utf8"),
96
+ seq: start.seq + lines.length,
97
+ };
98
+ return entries;
99
+ }
100
+
101
+ public async header(): Promise<SessionHeader | undefined> {
102
+ for await (const line of headLines(Bun.file(this.path), 1)) {
103
+ const entry = parseSessionEntries(line)[0];
104
+ return entry && isHeader(entry) ? entry : undefined;
105
+ }
106
+ return undefined;
107
+ }
108
+
109
+ /** The `seq` of the last complete line, without parsing any of them. */
110
+ public async head(): Promise<number> {
111
+ await this.read(Number.MAX_SAFE_INTEGER);
112
+ return this.cursor.seq;
113
+ }
114
+
115
+ /** Title and settle time for the catalogue, read from the two ends of the file. */
116
+ public async digest(): Promise<SessionDigest> {
117
+ const file = Bun.file(this.path);
118
+ const [title, settledAt] = await Promise.all([
119
+ firstUserMessage(file),
120
+ settleTime(file),
121
+ ]);
122
+ return {
123
+ ...(title === undefined ? {} : { title }),
124
+ ...(settledAt === undefined ? {} : { settledAt }),
125
+ };
126
+ }
127
+ }
128
+
129
+ async function* headLines(
130
+ file: BunFile,
131
+ count: number
132
+ ): AsyncGenerator<string> {
133
+ // Decode with `stream: true`: a chunk boundary can split a character.
134
+ const decoder = new TextDecoder();
135
+ let pending = "";
136
+ let yielded = 0;
137
+ let offset = 0;
138
+ let ask = PROBE_CHUNK;
139
+ while (offset < PROBE_LIMIT) {
140
+ const chunk = await ifPresent(
141
+ () => file.slice(offset, offset + ask).bytes(),
142
+ EMPTY
143
+ );
144
+ if (chunk.length === 0) {
145
+ return;
146
+ }
147
+ offset += chunk.length;
148
+ ask = Math.min(ask * 2, PROBE_CHUNK_MAX);
149
+ pending += decoder.decode(chunk, { stream: true });
150
+ const lines = pending.split("\n");
151
+ pending = lines.pop() ?? "";
152
+ for (const line of lines) {
153
+ yield line;
154
+ if (++yielded >= count) {
155
+ return;
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ async function settleTime(file: BunFile): Promise<number | undefined> {
162
+ const near = Math.max(0, file.size - TAIL_BYTES);
163
+ const lines = await tailLines(file, near);
164
+ const found = lastAgentTime(lines);
165
+ if (found !== undefined || near === 0 || lines.length >= TAIL_LINES) {
166
+ return found;
167
+ }
168
+ return lastAgentTime(await tailLines(file, 0));
169
+ }
170
+
171
+ async function tailLines(file: BunFile, from: number): Promise<string[]> {
172
+ const text = await ifPresent(() => file.slice(from).text(), "");
173
+ const lines = text.split("\n");
174
+ lines.pop();
175
+ if (from > 0) {
176
+ lines.shift();
177
+ }
178
+ return lines.slice(-TAIL_LINES).reverse();
179
+ }
180
+
181
+ function lastAgentTime(lines: readonly string[]): number | undefined {
182
+ for (const line of lines) {
183
+ const entry = parseSessionEntries(line)[0];
184
+ if (entry?.type !== "message") {
185
+ continue;
186
+ }
187
+ const role = entry.message.role;
188
+ if (role === "assistant" || role === "toolResult") {
189
+ const at = Date.parse(entry.timestamp);
190
+ return Number.isNaN(at) ? undefined : at;
191
+ }
192
+ }
193
+ return undefined;
194
+ }
195
+
196
+ async function firstUserMessage(file: BunFile): Promise<string | undefined> {
197
+ for await (const line of headLines(file, PROBE_LINES)) {
198
+ const entry = parseSessionEntries(line)[0];
199
+ if (entry?.type !== "message" || entry.message.role !== "user") {
200
+ continue;
201
+ }
202
+ const said = Attachments.parse(MessageText.textOf(entry.message.content));
203
+ const text =
204
+ said.text.trim() ||
205
+ said.files.map((file) => Attachments.nameOf(file.path)).join(", ");
206
+ if (text !== "") {
207
+ return text.length > TITLE_LIMIT
208
+ ? `${text.slice(0, TITLE_LIMIT).trimEnd()}…`
209
+ : text;
210
+ }
211
+ }
212
+ return undefined;
213
+ }
@@ -0,0 +1,21 @@
1
+ import type { AssistantMessage, UserMessage } from "@earendil-works/pi-ai";
2
+
3
+ function textOf(
4
+ content: UserMessage["content"] | AssistantMessage["content"],
5
+ kind: "text" | "thinking" = "text"
6
+ ): string {
7
+ if (typeof content === "string") {
8
+ return kind === "text" ? content : "";
9
+ }
10
+ let out = "";
11
+ for (const part of content) {
12
+ if (kind === "text" && part.type === "text") {
13
+ out += part.text;
14
+ } else if (kind === "thinking" && part.type === "thinking") {
15
+ out += part.thinking;
16
+ }
17
+ }
18
+ return out;
19
+ }
20
+
21
+ export const MessageText = { textOf };
@@ -0,0 +1,94 @@
1
+ import { join } from "node:path";
2
+
3
+ import { Fs } from "../shared/Fs";
4
+ import { Paths } from "../shared/Paths";
5
+
6
+ type Stored = {
7
+ readonly baseline: number;
8
+ readonly sessions: Record<string, number>;
9
+ };
10
+
11
+ /** When each session was last read, shared by every client of a machine. */
12
+ export class ReadCursors {
13
+ private readonly file: string;
14
+ private readonly ready: Promise<void>;
15
+ private readonly cursors = new Map<string, number>();
16
+ private baseline = 0;
17
+ private readonly writes = Fs.serialised();
18
+
19
+ /** Defaults to `~/.pim/read.json`. */
20
+ public constructor(path?: string) {
21
+ this.file = path ?? join(Paths.pimHomeDir(), "read.json");
22
+ this.ready = this.load();
23
+ }
24
+
25
+ /** Whether a session has been answered since anything last read it. */
26
+ public async isUnread(
27
+ sessionId: string,
28
+ answeredAt: number | undefined
29
+ ): Promise<boolean> {
30
+ await this.ready;
31
+ return (
32
+ answeredAt !== undefined &&
33
+ answeredAt > (this.cursors.get(sessionId) ?? this.baseline)
34
+ );
35
+ }
36
+
37
+ /** Moves a session's cursor forward; the write behind it is not waited on. */
38
+ public async mark(sessionId: string, at: number = Date.now()): Promise<void> {
39
+ await this.ready;
40
+ if ((this.cursors.get(sessionId) ?? this.baseline) >= at) {
41
+ return;
42
+ }
43
+ this.cursors.set(sessionId, at);
44
+ this.persist();
45
+ }
46
+
47
+ public async prune(alive: ReadonlySet<string>): Promise<void> {
48
+ await this.ready;
49
+ let dropped = false;
50
+ for (const sessionId of this.cursors.keys()) {
51
+ if (!alive.has(sessionId)) {
52
+ this.cursors.delete(sessionId);
53
+ dropped = true;
54
+ }
55
+ }
56
+ if (dropped) {
57
+ this.persist();
58
+ }
59
+ }
60
+
61
+ /** Settles the writes behind the marks taken so far, for a clean stop. */
62
+ public async flush(): Promise<void> {
63
+ await this.ready;
64
+ await this.writes.run(async () => undefined);
65
+ }
66
+
67
+ private async load(): Promise<void> {
68
+ const stored = (await Bun.file(this.file)
69
+ .json()
70
+ .catch(() => undefined)) as Partial<Stored> | undefined;
71
+ if (typeof stored?.baseline !== "number") {
72
+ // Persist the baseline now: recomputing it on each start clears every unread session.
73
+ this.baseline = Date.now();
74
+ this.persist();
75
+ return;
76
+ }
77
+ this.baseline = stored.baseline;
78
+ for (const [sessionId, at] of Object.entries(stored.sessions ?? {})) {
79
+ if (typeof at === "number") {
80
+ this.cursors.set(sessionId, at);
81
+ }
82
+ }
83
+ }
84
+
85
+ private persist(): void {
86
+ void this.writes.run(async () => {
87
+ const stored: Stored = {
88
+ baseline: this.baseline,
89
+ sessions: Object.fromEntries(this.cursors),
90
+ };
91
+ await Fs.writeJson(this.file, stored).catch(() => undefined);
92
+ });
93
+ }
94
+ }
@@ -0,0 +1,68 @@
1
+ /** What a cache entry has to offer: a recency stamp and a way to let go. */
2
+ export type CachedSession = {
3
+ lastUsed: number;
4
+ dispose(): Promise<void>;
5
+ };
6
+
7
+ const CAPACITY = 16;
8
+
9
+ /** Live sessions, keyed however the frontend keys them, capped by least-recently-used. */
10
+ export class SessionCache<T extends CachedSession> {
11
+ private readonly capacity: number;
12
+ private readonly entries = new Map<string, T>();
13
+
14
+ public constructor(capacity: number = CAPACITY) {
15
+ this.capacity = capacity;
16
+ }
17
+
18
+ public get size(): number {
19
+ return this.entries.size;
20
+ }
21
+
22
+ /** The live entry, if there is one, without counting as a use. */
23
+ public peek(key: string): T | undefined {
24
+ return this.entries.get(key);
25
+ }
26
+
27
+ /** The live entry, marked used now so eviction passes over it. */
28
+ public touch(key: string): T | undefined {
29
+ const entry = this.entries.get(key);
30
+ if (entry) {
31
+ entry.lastUsed = Date.now();
32
+ }
33
+ return entry;
34
+ }
35
+
36
+ /** Store `value`, disposing the least recently used entry first when full. */
37
+ public adopt(key: string, value: T): T {
38
+ this.evictIfNeeded();
39
+ this.entries.set(key, value);
40
+ return value;
41
+ }
42
+
43
+ public async disposeAll(): Promise<void> {
44
+ const live = [...this.entries.values()];
45
+ this.entries.clear();
46
+ await Promise.all(live.map((entry) => entry.dispose()));
47
+ }
48
+
49
+ private evictIfNeeded(): void {
50
+ if (this.entries.size < this.capacity) {
51
+ return;
52
+ }
53
+ let oldestKey: string | undefined;
54
+ let oldest = Infinity;
55
+ for (const [key, entry] of this.entries) {
56
+ if (entry.lastUsed < oldest) {
57
+ oldest = entry.lastUsed;
58
+ oldestKey = key;
59
+ }
60
+ }
61
+ if (oldestKey === undefined) {
62
+ return;
63
+ }
64
+ const evicted = this.entries.get(oldestKey)!;
65
+ this.entries.delete(oldestKey);
66
+ void evicted.dispose();
67
+ }
68
+ }