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,741 @@
1
+ import { join } from "node:path";
2
+
3
+ import { DiffLines, type ToolDiffHunk, type ToolDiffLine } from "./DiffLines";
4
+ import { DiffPatch } from "./DiffPatch";
5
+ import { Git } from "./Git";
6
+ import type { GitMonitor } from "./GitMonitor";
7
+ import { Lines } from "./Lines";
8
+ import { Proc, type ProcResult } from "./Proc";
9
+
10
+ export type DiffBase =
11
+ | { readonly kind: "worktree" }
12
+ | { readonly kind: "unstaged" }
13
+ | { readonly kind: "staged" }
14
+ | { readonly kind: "commit"; readonly ref: string }
15
+ | { readonly kind: "branch"; readonly ref: string };
16
+
17
+ export type ChangeStatus =
18
+ | "added"
19
+ | "modified"
20
+ | "deleted"
21
+ | "renamed"
22
+ | "untracked";
23
+
24
+ /** One row of the change list; carries no hunks. */
25
+ export type ChangeSummary = {
26
+ readonly path: string;
27
+ /** Present only for a rename. */
28
+ readonly oldPath?: string;
29
+ readonly status: ChangeStatus;
30
+ readonly added: number;
31
+ readonly removed: number;
32
+ readonly binary?: boolean;
33
+ /** Blob SHAs the diff was computed against; anchors an outdated review comment. */
34
+ readonly baseSha?: string;
35
+ readonly headSha?: string;
36
+ /** Changes whenever the file's content does, so a `seen` mark keyed to it clears itself. */
37
+ readonly fingerprint: string;
38
+ };
39
+
40
+ export type ChangeList = {
41
+ readonly base: DiffBase;
42
+ readonly files: readonly ChangeSummary[];
43
+ readonly added: number;
44
+ readonly removed: number;
45
+ /** More files exist than were returned. */
46
+ readonly truncated?: boolean;
47
+ };
48
+
49
+ export type FileDiff = {
50
+ readonly path: string;
51
+ readonly hunks: readonly ToolDiffHunk[];
52
+ readonly truncated?: boolean;
53
+ readonly binary?: boolean;
54
+ /** Byte size of each side of a binary file, when git or the working tree can say. */
55
+ readonly oldBytes?: number;
56
+ readonly newBytes?: number;
57
+ /** Lines in the side context is read from; absent on a clipped diff, whose end is not the file's. */
58
+ readonly total?: number;
59
+ };
60
+
61
+ /** A stretch of one file, 1-based and inclusive, numbered on the side `readLines` reads. */
62
+ export type LineSpan = {
63
+ readonly start: number;
64
+ readonly end: number;
65
+ };
66
+
67
+ export type LineRun = {
68
+ readonly start: number;
69
+ readonly lines: readonly string[];
70
+ };
71
+
72
+ export type FileLines = {
73
+ readonly path: string;
74
+ readonly runs: readonly LineRun[];
75
+ };
76
+
77
+ const DEFAULT_CONTEXT = 3;
78
+
79
+ const FILE_LIMIT = 2000;
80
+
81
+ /** How much of one file a reader is handed at once; past either limit the rest is dropped. */
82
+ const CHANGED_LINE_LIMIT = 2000;
83
+ const PATCH_TEXT_LIMIT = 1_000_000;
84
+
85
+ /** Past this an untracked file is reported as binary rather than read. */
86
+ const UNTRACKED_BYTE_LIMIT = 1_000_000;
87
+
88
+ /** Past this a file is diffed but never read whole, so no gap of it can be opened. */
89
+ const CONTEXT_BYTE_LIMIT = 1_000_000;
90
+
91
+ /** Most lines one `readLines` hands back, however many spans asked. */
92
+ const CONTEXT_LINE_LIMIT = 2000;
93
+
94
+ const EMPTY_SHA = /^0+$/;
95
+
96
+ const BINARY_PATCH = /^(?:Binary files |GIT binary patch)/m;
97
+
98
+ /** One `git` read; `--no-optional-locks` or it takes `index.lock` and fails an agent's write beside it. */
99
+ function git(cwd: string, args: readonly string[]): Promise<ProcResult> {
100
+ return Proc.run(["git", "--no-optional-locks", ...args], { cwd });
101
+ }
102
+
103
+ async function read(
104
+ cwd: string,
105
+ args: readonly string[],
106
+ fallback: string
107
+ ): Promise<string> {
108
+ const result = await git(cwd, args);
109
+ if (result.code !== 0) {
110
+ throw new Error(Git.failure(result, fallback));
111
+ }
112
+ return result.stdout;
113
+ }
114
+
115
+ /**
116
+ * Whether the branch checked out has a commit on it. A repository whose first
117
+ * commit is still unwritten has an unborn `HEAD`: it names nothing, and every
118
+ * `git diff HEAD` of it fails outright instead of calling the tree new.
119
+ */
120
+ async function born(cwd: string): Promise<boolean> {
121
+ const head = await git(cwd, ["rev-parse", "--quiet", "--verify", "HEAD"]);
122
+ return head.code === 0;
123
+ }
124
+
125
+ /**
126
+ * The base an unborn `HEAD` is diffed against, so that every file reads as
127
+ * added. `hash-object` names git's empty tree without writing it, and names it
128
+ * right in a sha256 repository too, where the familiar `4b825dc…` is not it.
129
+ */
130
+ async function emptyTree(cwd: string): Promise<string> {
131
+ const sha = await read(
132
+ cwd,
133
+ ["hash-object", "-t", "tree", "/dev/null"],
134
+ "could not name the empty tree"
135
+ );
136
+ return sha.trim();
137
+ }
138
+
139
+ /** What every `git diff` of this base is asked about; `branch` resolves to the merge base first. */
140
+ async function baseArgs(
141
+ cwd: string,
142
+ base: DiffBase
143
+ ): Promise<readonly string[]> {
144
+ switch (base.kind) {
145
+ case "worktree":
146
+ return (await born(cwd)) ? ["HEAD"] : [await emptyTree(cwd)];
147
+ case "unstaged":
148
+ return [];
149
+ case "staged":
150
+ return ["--cached"];
151
+ case "commit":
152
+ return [base.ref];
153
+ case "branch": {
154
+ // Nothing is shared with a branch when this one has no commits at all.
155
+ if (!(await born(cwd))) {
156
+ return [await emptyTree(cwd)];
157
+ }
158
+ const merged = await read(
159
+ cwd,
160
+ ["merge-base", "HEAD", base.ref],
161
+ `no commit is shared by HEAD and ${base.ref}`
162
+ );
163
+ return [merged.trim()];
164
+ }
165
+ }
166
+ }
167
+
168
+ type Entry = {
169
+ readonly path: string;
170
+ readonly oldPath?: string;
171
+ readonly status: ChangeStatus;
172
+ readonly baseSha?: string;
173
+ readonly headSha?: string;
174
+ };
175
+
176
+ type Counts = {
177
+ readonly added: number;
178
+ readonly removed: number;
179
+ readonly binary: boolean;
180
+ };
181
+
182
+ function statusOf(code: string): ChangeStatus {
183
+ const letter = code[0];
184
+ if (letter === "A" || letter === "C") {
185
+ return "added";
186
+ }
187
+ if (letter === "D") {
188
+ return "deleted";
189
+ }
190
+ if (letter === "R") {
191
+ return "renamed";
192
+ }
193
+ return "modified";
194
+ }
195
+
196
+ function shaOf(value: string | undefined): string | undefined {
197
+ return value === undefined || value === "" || EMPTY_SHA.test(value)
198
+ ? undefined
199
+ : value;
200
+ }
201
+
202
+ /** `:<oldmode> <newmode> <oldsha> <newsha> <status>\0<path>\0`, a second path when the status is a rename or copy. */
203
+ function parseRaw(text: string): readonly Entry[] {
204
+ const fields = text.split("\0");
205
+ const entries: Entry[] = [];
206
+ let index = 0;
207
+
208
+ while (index < fields.length) {
209
+ const header = fields[index];
210
+ index += 1;
211
+ if (header === undefined || !header.startsWith(":")) {
212
+ continue;
213
+ }
214
+ const [, , baseSha, headSha, code] = header.slice(1).split(" ");
215
+ const first = fields[index];
216
+ index += 1;
217
+ if (code === undefined || first === undefined || first === "") {
218
+ continue;
219
+ }
220
+ const moved = code.startsWith("R") || code.startsWith("C");
221
+ const second = moved ? fields[index] : undefined;
222
+ if (moved) {
223
+ index += 1;
224
+ }
225
+ const path = moved ? second : first;
226
+ if (path === undefined || path === "") {
227
+ continue;
228
+ }
229
+ const from = shaOf(baseSha);
230
+ const to = shaOf(headSha);
231
+ entries.push({
232
+ path,
233
+ ...(moved ? { oldPath: first } : {}),
234
+ status: statusOf(code),
235
+ ...(from === undefined ? {} : { baseSha: from }),
236
+ ...(to === undefined ? {} : { headSha: to }),
237
+ });
238
+ }
239
+
240
+ return entries;
241
+ }
242
+
243
+ /** `<added>\t<removed>\t<path>\0`; a rename leaves the path empty and follows with the old and new ones. */
244
+ function parseNumstat(text: string): ReadonlyMap<string, Counts> {
245
+ const fields = text.split("\0");
246
+ const counts = new Map<string, Counts>();
247
+ let index = 0;
248
+
249
+ while (index < fields.length) {
250
+ const field = fields[index];
251
+ index += 1;
252
+ if (field === undefined || field === "") {
253
+ continue;
254
+ }
255
+ const firstTab = field.indexOf("\t");
256
+ const secondTab = field.indexOf("\t", firstTab + 1);
257
+ if (firstTab < 0 || secondTab < 0) {
258
+ continue;
259
+ }
260
+ const added = field.slice(0, firstTab);
261
+ const removed = field.slice(firstTab + 1, secondTab);
262
+ let path = field.slice(secondTab + 1);
263
+ if (path === "") {
264
+ index += 1;
265
+ path = fields[index] ?? "";
266
+ index += 1;
267
+ }
268
+ if (path === "") {
269
+ continue;
270
+ }
271
+ counts.set(path, {
272
+ added: Number(added) || 0,
273
+ removed: Number(removed) || 0,
274
+ binary: added === "-",
275
+ });
276
+ }
277
+
278
+ return counts;
279
+ }
280
+
281
+ /** Only the `?` entries of porcelain v2; a rename entry carries a second field that is a path, not a record. */
282
+ function parseUntracked(text: string): readonly string[] {
283
+ const fields = text.split("\0");
284
+ const paths: string[] = [];
285
+ let index = 0;
286
+
287
+ while (index < fields.length) {
288
+ const field = fields[index];
289
+ index += 1;
290
+ if (field === undefined || field === "") {
291
+ continue;
292
+ }
293
+ if (field.startsWith("2 ")) {
294
+ index += 1;
295
+ continue;
296
+ }
297
+ if (field.startsWith("? ")) {
298
+ paths.push(field.slice(2));
299
+ }
300
+ }
301
+
302
+ return paths;
303
+ }
304
+
305
+ function untrackedPaths(cwd: string): Promise<readonly string[]> {
306
+ return read(
307
+ cwd,
308
+ ["status", "--porcelain=v2", "-z", "--untracked-files=all"],
309
+ "could not read the working tree"
310
+ ).then(parseUntracked);
311
+ }
312
+
313
+ type Untracked = {
314
+ readonly binary: boolean;
315
+ readonly text: string;
316
+ readonly lines: number;
317
+ readonly mtimeMs: number;
318
+ readonly bytes: number;
319
+ };
320
+
321
+ async function readUntracked(cwd: string, path: string): Promise<Untracked> {
322
+ const file = Bun.file(join(cwd, path));
323
+ const mtimeMs = file.lastModified;
324
+ const opaque: Untracked = {
325
+ binary: true,
326
+ text: "",
327
+ lines: 0,
328
+ mtimeMs,
329
+ bytes: file.size,
330
+ };
331
+ if (file.size > UNTRACKED_BYTE_LIMIT) {
332
+ return opaque;
333
+ }
334
+ const bytes = await file.bytes().catch(() => undefined);
335
+ if (bytes === undefined) {
336
+ return opaque;
337
+ }
338
+ if (Lines.isBinaryBytes(bytes)) {
339
+ return opaque;
340
+ }
341
+ const text = new TextDecoder().decode(bytes);
342
+ return {
343
+ binary: false,
344
+ text,
345
+ lines: DiffLines.fromText(text).lines.length,
346
+ mtimeMs,
347
+ bytes: bytes.length,
348
+ };
349
+ }
350
+
351
+ function fingerprintOf(
352
+ cwd: string,
353
+ entry: Entry,
354
+ added: number,
355
+ removed: number
356
+ ): string {
357
+ if (entry.status === "deleted") {
358
+ return `deleted:${entry.baseSha ?? ""}`;
359
+ }
360
+ if (entry.headSha !== undefined) {
361
+ return entry.headSha;
362
+ }
363
+ return `${added}:${removed}:${Bun.file(join(cwd, entry.path)).lastModified}`;
364
+ }
365
+
366
+ function summaryOf(
367
+ cwd: string,
368
+ entry: Entry,
369
+ counts: Counts | undefined
370
+ ): ChangeSummary {
371
+ const added = counts?.added ?? 0;
372
+ const removed = counts?.removed ?? 0;
373
+ return {
374
+ path: entry.path,
375
+ ...(entry.oldPath === undefined ? {} : { oldPath: entry.oldPath }),
376
+ status: entry.status,
377
+ added,
378
+ removed,
379
+ ...(counts?.binary === true ? { binary: true } : {}),
380
+ ...(entry.baseSha === undefined ? {} : { baseSha: entry.baseSha }),
381
+ ...(entry.headSha === undefined ? {} : { headSha: entry.headSha }),
382
+ fingerprint: fingerprintOf(cwd, entry, added, removed),
383
+ };
384
+ }
385
+
386
+ async function untrackedSummary(
387
+ cwd: string,
388
+ path: string
389
+ ): Promise<ChangeSummary> {
390
+ const file = await readUntracked(cwd, path);
391
+ const added = file.binary ? 0 : file.lines;
392
+ return {
393
+ path,
394
+ status: "untracked",
395
+ added,
396
+ removed: 0,
397
+ ...(file.binary ? { binary: true } : {}),
398
+ fingerprint: `${added}:0:${file.mtimeMs}`,
399
+ };
400
+ }
401
+
402
+ function collect(base: DiffBase, files: readonly ChangeSummary[]): ChangeList {
403
+ const kept = files.slice(0, FILE_LIMIT);
404
+ return {
405
+ base,
406
+ files: kept,
407
+ added: kept.reduce((total, file) => total + file.added, 0),
408
+ removed: kept.reduce((total, file) => total + file.removed, 0),
409
+ ...(files.length > kept.length ? { truncated: true } : {}),
410
+ };
411
+ }
412
+
413
+ async function rawEntries(
414
+ cwd: string,
415
+ args: readonly string[]
416
+ ): Promise<readonly Entry[]> {
417
+ return parseRaw(
418
+ await read(
419
+ cwd,
420
+ ["diff", "--raw", "-z", "--no-abbrev", "--find-renames", ...args],
421
+ "could not read the change list"
422
+ )
423
+ );
424
+ }
425
+
426
+ async function untrackedDiff(
427
+ cwd: string,
428
+ path: string,
429
+ context: number
430
+ ): Promise<FileDiff> {
431
+ const file = await readUntracked(cwd, path);
432
+ if (file.binary) {
433
+ return { path, hunks: [], binary: true, newBytes: file.bytes };
434
+ }
435
+ const diff = DiffLines.buildToolDiff(
436
+ path,
437
+ DiffLines.emptySide,
438
+ DiffLines.fromText(file.text),
439
+ context
440
+ );
441
+ return clipped(path, diff?.hunks ?? [], false, file.lines);
442
+ }
443
+
444
+ type Clip = {
445
+ readonly hunks: readonly ToolDiffHunk[];
446
+ readonly truncated: boolean;
447
+ };
448
+
449
+ /** The longest prefix of a patch that is still whole hunks, once it is too much text to parse. */
450
+ function clipPatch(patch: string): {
451
+ readonly text: string;
452
+ readonly cut: boolean;
453
+ } {
454
+ if (patch.length <= PATCH_TEXT_LIMIT) {
455
+ return { text: patch, cut: false };
456
+ }
457
+ const boundary = patch.lastIndexOf("\n@@ ", PATCH_TEXT_LIMIT);
458
+ return { text: boundary < 0 ? "" : patch.slice(0, boundary + 1), cut: true };
459
+ }
460
+
461
+ function changedOf(lines: readonly ToolDiffLine[]): number {
462
+ return lines.filter((line) => line.kind !== "context").length;
463
+ }
464
+
465
+ /** A hunk cut short still has to describe itself: its spans count the lines left in it. */
466
+ function reflow(
467
+ hunk: ToolDiffHunk,
468
+ lines: readonly ToolDiffLine[]
469
+ ): ToolDiffHunk {
470
+ return {
471
+ ...hunk,
472
+ oldLines: lines.filter((line) => line.kind !== "added").length,
473
+ newLines: lines.filter((line) => line.kind !== "removed").length,
474
+ lines,
475
+ };
476
+ }
477
+
478
+ function headOf(
479
+ lines: readonly ToolDiffLine[],
480
+ budget: number
481
+ ): readonly ToolDiffLine[] {
482
+ const kept: ToolDiffLine[] = [];
483
+ let left = budget;
484
+ for (const line of lines) {
485
+ if (line.kind !== "context") {
486
+ if (left === 0) {
487
+ break;
488
+ }
489
+ left -= 1;
490
+ }
491
+ kept.push(line);
492
+ }
493
+ return kept;
494
+ }
495
+
496
+ /** Hunks while the changed-line budget lasts, the one that overruns it cut short. */
497
+ function clipHunks(hunks: readonly ToolDiffHunk[]): Clip {
498
+ let budget = CHANGED_LINE_LIMIT;
499
+ const kept: ToolDiffHunk[] = [];
500
+
501
+ for (const hunk of hunks) {
502
+ const changed = changedOf(hunk.lines);
503
+ if (changed <= budget) {
504
+ kept.push(hunk);
505
+ budget -= changed;
506
+ continue;
507
+ }
508
+ const lines = headOf(hunk.lines, budget);
509
+ if (changedOf(lines) > 0) {
510
+ kept.push(reflow(hunk, lines));
511
+ }
512
+ return { hunks: kept, truncated: true };
513
+ }
514
+
515
+ return { hunks: kept, truncated: false };
516
+ }
517
+
518
+ function clipped(
519
+ path: string,
520
+ hunks: readonly ToolDiffHunk[],
521
+ cut: boolean,
522
+ total: number | undefined
523
+ ): FileDiff {
524
+ const clip = clipHunks(hunks);
525
+ const whole = !clip.truncated && !cut;
526
+ return {
527
+ path,
528
+ hunks: clip.hunks,
529
+ ...(whole ? {} : { truncated: true }),
530
+ ...(total === undefined || !whole ? {} : { total }),
531
+ };
532
+ }
533
+
534
+ /**
535
+ * The text of the side a gap is read from: the new one, which is the index
536
+ * blob where the base has staged it and the working tree everywhere else. A
537
+ * file too big to hold, or gone from the side asked for, has no text at all.
538
+ */
539
+ async function sideText(
540
+ cwd: string,
541
+ path: string,
542
+ entry: Entry | undefined
543
+ ): Promise<string | undefined> {
544
+ if (entry?.headSha !== undefined) {
545
+ const result = await git(cwd, ["cat-file", "blob", entry.headSha]);
546
+ return result.code === 0 && result.stdout.length <= CONTEXT_BYTE_LIMIT
547
+ ? result.stdout
548
+ : undefined;
549
+ }
550
+ const file = Bun.file(join(cwd, path));
551
+ if (file.size > CONTEXT_BYTE_LIMIT || !(await file.exists())) {
552
+ return undefined;
553
+ }
554
+ return await file.text().catch(() => undefined);
555
+ }
556
+
557
+ function totalOf(text: string | undefined): number | undefined {
558
+ return text === undefined ? undefined : DiffLines.fromText(text).lines.length;
559
+ }
560
+
561
+ async function blobSize(
562
+ cwd: string,
563
+ sha: string | undefined
564
+ ): Promise<number | undefined> {
565
+ if (sha === undefined) {
566
+ return undefined;
567
+ }
568
+ const result = await git(cwd, ["cat-file", "-s", sha]);
569
+ const size = Number(result.stdout.trim());
570
+ return result.code === 0 && Number.isFinite(size) ? size : undefined;
571
+ }
572
+
573
+ async function worktreeSize(
574
+ cwd: string,
575
+ path: string
576
+ ): Promise<number | undefined> {
577
+ const file = Bun.file(join(cwd, path));
578
+ return (await file.exists()) ? file.size : undefined;
579
+ }
580
+
581
+ /** A binary file is a row, not a diff: all a reader is told is how big each side is. */
582
+ async function binaryDiff(
583
+ cwd: string,
584
+ path: string,
585
+ entry: Entry | undefined
586
+ ): Promise<FileDiff> {
587
+ const [oldBytes, newBytes] = await Promise.all([
588
+ blobSize(cwd, entry?.baseSha),
589
+ entry?.headSha === undefined
590
+ ? worktreeSize(cwd, path)
591
+ : blobSize(cwd, entry.headSha),
592
+ ]);
593
+ return {
594
+ path,
595
+ hunks: [],
596
+ binary: true,
597
+ ...(oldBytes === undefined ? {} : { oldBytes }),
598
+ ...(newBytes === undefined ? {} : { newBytes }),
599
+ };
600
+ }
601
+
602
+ /** Every changed file of one base, in git's own order and without a hunk of any of them. */
603
+ async function listChanges(
604
+ cwd: string,
605
+ base: DiffBase,
606
+ monitor: GitMonitor
607
+ ): Promise<ChangeList> {
608
+ return await serialise(monitor, cwd, async () => {
609
+ const args = await baseArgs(cwd, base);
610
+ const [entries, numstat] = await Promise.all([
611
+ rawEntries(cwd, args),
612
+ read(
613
+ cwd,
614
+ ["diff", "--numstat", "-z", "--find-renames", ...args],
615
+ "could not count the changes"
616
+ ),
617
+ ]);
618
+ const counts = parseNumstat(numstat);
619
+ const files = entries.map((entry) =>
620
+ summaryOf(cwd, entry, counts.get(entry.path))
621
+ );
622
+ if (base.kind !== "worktree") {
623
+ return collect(base, files);
624
+ }
625
+ const untracked = await Promise.all(
626
+ (await untrackedPaths(cwd)).map((path) => untrackedSummary(cwd, path))
627
+ );
628
+ return collect(base, [...files, ...untracked]);
629
+ });
630
+ }
631
+
632
+ /** One file's hunks; a renamed file is diffed against the path it came from, an untracked one against nothing. */
633
+ async function fileDiff(
634
+ cwd: string,
635
+ base: DiffBase,
636
+ path: string,
637
+ monitor: GitMonitor,
638
+ context: number = DEFAULT_CONTEXT
639
+ ): Promise<FileDiff> {
640
+ return await serialise(monitor, cwd, async () => {
641
+ const args = await baseArgs(cwd, base);
642
+ const entry = (await rawEntries(cwd, args)).find(
643
+ (candidate) => candidate.path === path
644
+ );
645
+ if (
646
+ entry === undefined &&
647
+ base.kind === "worktree" &&
648
+ (await untrackedPaths(cwd)).includes(path)
649
+ ) {
650
+ return await untrackedDiff(cwd, path, context);
651
+ }
652
+ const paths = entry?.oldPath === undefined ? [path] : [entry.oldPath, path];
653
+ const [patch, text] = await Promise.all([
654
+ read(
655
+ cwd,
656
+ [
657
+ "diff",
658
+ "--no-color",
659
+ `-U${String(context)}`,
660
+ "--find-renames",
661
+ ...args,
662
+ "--",
663
+ ...paths,
664
+ ],
665
+ `could not diff ${path}`
666
+ ),
667
+ sideText(cwd, path, entry),
668
+ ]);
669
+ if (BINARY_PATCH.test(patch)) {
670
+ return await binaryDiff(cwd, path, entry);
671
+ }
672
+ const clip = clipPatch(patch);
673
+ return clipped(
674
+ path,
675
+ DiffPatch.fromUnified(path, clip.text)?.hunks ?? [],
676
+ clip.cut,
677
+ totalOf(text)
678
+ );
679
+ });
680
+ }
681
+
682
+ /** The lines a reader asked to see of a file they are already reading a diff of, as they are. */
683
+ async function readLines(
684
+ cwd: string,
685
+ base: DiffBase,
686
+ path: string,
687
+ spans: readonly LineSpan[],
688
+ monitor: GitMonitor
689
+ ): Promise<FileLines> {
690
+ return await serialise(monitor, cwd, async () => {
691
+ const args = await baseArgs(cwd, base);
692
+ const entry = (await rawEntries(cwd, args)).find(
693
+ (candidate) => candidate.path === path
694
+ );
695
+ const text = await sideText(cwd, path, entry);
696
+ if (text === undefined) {
697
+ throw new Error(`could not read ${path}`);
698
+ }
699
+ const lines = DiffLines.fromText(text).lines;
700
+ const runs: LineRun[] = [];
701
+ let budget = CONTEXT_LINE_LIMIT;
702
+
703
+ for (const span of spans) {
704
+ const start = Math.max(1, Math.trunc(span.start));
705
+ const end = Math.min(
706
+ lines.length,
707
+ Math.trunc(span.end),
708
+ start + budget - 1
709
+ );
710
+ if (end < start) {
711
+ continue;
712
+ }
713
+ runs.push({ start, lines: lines.slice(start - 1, end) });
714
+ budget -= end - start + 1;
715
+ }
716
+
717
+ return { path, runs };
718
+ });
719
+ }
720
+
721
+ /**
722
+ * Reading is serialised against the checkouts and pulls that move the tree
723
+ * under it; a read that arrives while one runs is refused rather than answered
724
+ * from half a working tree.
725
+ */
726
+ async function serialise<T>(
727
+ monitor: GitMonitor,
728
+ cwd: string,
729
+ work: () => Promise<T>
730
+ ): Promise<T> {
731
+ const outcome = await monitor.run<T>(cwd, async () => ({
732
+ ok: true,
733
+ value: await work(),
734
+ }));
735
+ if (outcome.value === undefined) {
736
+ throw new Error(outcome.ok ? `could not read ${cwd}` : outcome.error);
737
+ }
738
+ return outcome.value;
739
+ }
740
+
741
+ export const RepoDiff = { listChanges, fileDiff, readLines };