@rexvane/piweb 0.3.16 → 0.3.17

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 (288) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +8 -11
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/prerender-manifest.json +6 -6
  5. package/.next/react-loadable-manifest.json +4 -16
  6. package/.next/required-server-files.js +1 -3
  7. package/.next/required-server-files.json +1 -3
  8. package/.next/routes-manifest.json +0 -20
  9. package/.next/server/app/_global-error/page.js +2 -2
  10. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  11. package/.next/server/app/_global-error.html +1 -1
  12. package/.next/server/app/_global-error.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  15. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/server/app/_not-found/page.js +1 -1
  17. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/server/app/_not-found.html +1 -1
  19. package/.next/server/app/_not-found.rsc +4 -7
  20. package/.next/server/app/_not-found.segments/_full.segment.rsc +4 -7
  21. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -5
  22. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -5
  23. package/.next/server/app/api/agent/[id]/events/route.js +2 -2
  24. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
  25. package/.next/server/app/api/agent/[id]/route.js +1 -1
  26. package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
  27. package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
  28. package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
  29. package/.next/server/app/api/agent/new/route.js +1 -1
  30. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  31. package/.next/server/app/api/config/route.js +1 -1
  32. package/.next/server/app/api/config/route.js.nft.json +1 -1
  33. package/.next/server/app/api/files/route.js +1 -1
  34. package/.next/server/app/api/files/route.js.nft.json +1 -1
  35. package/.next/server/app/api/git/route.js +2 -2
  36. package/.next/server/app/api/git/route.js.nft.json +1 -1
  37. package/.next/server/app/api/growth/route.js +1 -1
  38. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  39. package/.next/server/app/api/health/route.js +1 -1
  40. package/.next/server/app/api/models/route.js +1 -1
  41. package/.next/server/app/api/models/route.js.nft.json +1 -1
  42. package/.next/server/app/api/pi-settings/route.js +1 -1
  43. package/.next/server/app/api/pi-settings/route.js.nft.json +1 -1
  44. package/.next/server/app/api/plugins/route.js +1 -1
  45. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  46. package/.next/server/app/api/prompts/route.js +1 -1
  47. package/.next/server/app/api/prompts/route.js.nft.json +1 -1
  48. package/.next/server/app/api/security/route.js +1 -1
  49. package/.next/server/app/api/security/route.js.nft.json +1 -1
  50. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  51. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  52. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  53. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  54. package/.next/server/app/api/sessions/import/route.js +3 -3
  55. package/.next/server/app/api/sessions/import/route.js.nft.json +1 -1
  56. package/.next/server/app/api/sessions/route.js +1 -1
  57. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  58. package/.next/server/app/api/skills/route.js +2 -2
  59. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  60. package/.next/server/app/api/update/route.js +2 -2
  61. package/.next/server/app/api/update/route.js.nft.json +1 -1
  62. package/.next/server/app/api/version/route.js +1 -1
  63. package/.next/server/app/api/version/route.js.nft.json +1 -1
  64. package/.next/server/app/api/web-auth/route.js +1 -1
  65. package/.next/server/app/api/workspaces/route.js +1 -1
  66. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  67. package/.next/server/app/icon.svg/route.js +1 -1
  68. package/.next/server/app/index.html +1 -1
  69. package/.next/server/app/index.rsc +6 -9
  70. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -7
  71. package/.next/server/app/index.segments/_full.segment.rsc +6 -9
  72. package/.next/server/app/index.segments/_tree.segment.rsc +2 -5
  73. package/.next/server/app/login/page.js +1 -1
  74. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  75. package/.next/server/app/login.html +1 -1
  76. package/.next/server/app/login.rsc +5 -8
  77. package/.next/server/app/login.segments/_full.segment.rsc +5 -8
  78. package/.next/server/app/login.segments/_tree.segment.rsc +2 -5
  79. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -6
  80. package/.next/server/app/page.js +1 -1
  81. package/.next/server/app/page_client-reference-manifest.js +1 -1
  82. package/.next/server/app-paths-manifest.json +8 -11
  83. package/.next/server/chunks/496.js +1 -1
  84. package/.next/server/chunks/532.js +1 -1
  85. package/.next/server/chunks/686.js +4 -8
  86. package/.next/server/middleware-build-manifest.js +1 -1
  87. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  88. package/.next/server/pages/404.html +1 -1
  89. package/.next/server/pages/500.html +1 -1
  90. package/.next/server/server-reference-manifest.json +1 -1
  91. package/.next/static/chunks/2608.ec8ff1f5767c9056.js +1 -0
  92. package/.next/static/chunks/2716.93be8ad5c970c516.js +1 -0
  93. package/.next/static/chunks/4632.112d2e893d48d188.js +1 -0
  94. package/.next/static/chunks/4673.c1fb1580efc6ace7.js +1 -0
  95. package/.next/static/chunks/4686-f4a037d609cea3f2.js +1 -0
  96. package/.next/static/chunks/app/_global-error/page-f77e9ede9808b18e.js +1 -0
  97. package/.next/static/chunks/app/api/agent/[id]/events/route-f77e9ede9808b18e.js +1 -0
  98. package/.next/static/chunks/app/api/agent/[id]/route-f77e9ede9808b18e.js +1 -0
  99. package/.next/static/chunks/app/api/agent/[id]/tree/route-f77e9ede9808b18e.js +1 -0
  100. package/.next/static/chunks/app/api/agent/new/route-f77e9ede9808b18e.js +1 -0
  101. package/.next/static/chunks/app/api/config/route-f77e9ede9808b18e.js +1 -0
  102. package/.next/static/chunks/app/api/files/route-f77e9ede9808b18e.js +1 -0
  103. package/.next/static/chunks/app/api/git/route-f77e9ede9808b18e.js +1 -0
  104. package/.next/static/chunks/app/api/growth/route-f77e9ede9808b18e.js +1 -0
  105. package/.next/static/chunks/app/api/health/route-f77e9ede9808b18e.js +1 -0
  106. package/.next/static/chunks/app/api/models/route-f77e9ede9808b18e.js +1 -0
  107. package/.next/static/chunks/app/api/pi-settings/route-f77e9ede9808b18e.js +1 -0
  108. package/.next/static/chunks/app/api/plugins/route-f77e9ede9808b18e.js +1 -0
  109. package/.next/static/chunks/app/api/prompts/route-f77e9ede9808b18e.js +1 -0
  110. package/.next/static/chunks/app/api/security/route-f77e9ede9808b18e.js +1 -0
  111. package/.next/static/chunks/app/api/sessions/[id]/export/route-f77e9ede9808b18e.js +1 -0
  112. package/.next/static/chunks/app/api/sessions/[id]/route-f77e9ede9808b18e.js +1 -0
  113. package/.next/static/chunks/app/api/sessions/import/route-f77e9ede9808b18e.js +1 -0
  114. package/.next/static/chunks/app/api/sessions/route-f77e9ede9808b18e.js +1 -0
  115. package/.next/static/chunks/app/api/skills/route-f77e9ede9808b18e.js +1 -0
  116. package/.next/static/chunks/app/api/update/route-f77e9ede9808b18e.js +1 -0
  117. package/.next/static/chunks/app/api/version/route-f77e9ede9808b18e.js +1 -0
  118. package/.next/static/chunks/app/api/web-auth/route-f77e9ede9808b18e.js +1 -0
  119. package/.next/static/chunks/app/api/workspaces/route-f77e9ede9808b18e.js +1 -0
  120. package/.next/static/chunks/app/page-b51087831582ee37.js +1 -0
  121. package/.next/static/chunks/next/dist/client/components/builtin/app-error-f77e9ede9808b18e.js +1 -0
  122. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f77e9ede9808b18e.js +1 -0
  123. package/.next/static/chunks/next/dist/client/components/builtin/not-found-f77e9ede9808b18e.js +1 -0
  124. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f77e9ede9808b18e.js +1 -0
  125. package/.next/static/chunks/webpack-c31263fd83d75ce6.js +1 -0
  126. package/.next/static/css/850c2fd130ffa2fc.css +1 -0
  127. package/.next/static/zsynuO_9Kx6nrXgkTUJ04/_buildManifest.js +1 -0
  128. package/.next/types/routes.d.ts +1 -4
  129. package/.next/types/validator.ts +0 -27
  130. package/README.md +17 -29
  131. package/assets/archive.jpg +0 -0
  132. package/assets/growth-1.jpg +0 -0
  133. package/assets/growth-2.jpg +0 -0
  134. package/assets/import.jpg +0 -0
  135. package/assets/main.jpg +0 -0
  136. package/assets/outline.jpg +0 -0
  137. package/assets/prompts-1.jpg +0 -0
  138. package/assets/prompts-2.jpg +0 -0
  139. package/assets/providers-1.jpg +0 -0
  140. package/assets/providers-2.jpg +0 -0
  141. package/assets/providers-3.jpg +0 -0
  142. package/assets/trace-1.jpg +0 -0
  143. package/assets/trace-2.png +0 -0
  144. package/assets/trace-3.png +0 -0
  145. package/assets/trace-4.png +0 -0
  146. package/assets/viewer-1.jpg +0 -0
  147. package/assets/viewer-2.jpg +0 -0
  148. package/bin/pi.js +50 -0
  149. package/next.config.ts +32 -7
  150. package/package.json +4 -9
  151. package/scripts/launcher.mjs +1 -1
  152. package/scripts/pick-folder.ps1 +2 -11
  153. package/scripts/release.mjs +31 -11
  154. package/scripts/verify-package.mjs +0 -53
  155. package/src/app/api/config/route.ts +1 -4
  156. package/src/app/api/growth/route.ts +14 -14
  157. package/src/app/api/models/route.ts +2 -1
  158. package/src/app/api/security/route.ts +3 -8
  159. package/src/app/api/update/route.ts +2 -8
  160. package/src/app/globals.css +90 -777
  161. package/src/app/layout.tsx +6 -13
  162. package/src/components/AppShell.tsx +229 -521
  163. package/src/components/ChatInput.tsx +26 -153
  164. package/src/components/ChatWindow.tsx +231 -51
  165. package/src/components/ContextMeter.tsx +17 -11
  166. package/src/components/FileViewer.tsx +11 -10
  167. package/src/components/GitPanel.tsx +11 -13
  168. package/src/components/ModelSelector.tsx +6 -19
  169. package/src/components/PiMark.tsx +38 -5
  170. package/src/components/ProjectPanel.tsx +111 -95
  171. package/src/components/PromptPanel.tsx +51 -132
  172. package/src/components/SessionImportSection.tsx +37 -53
  173. package/src/components/SessionSidebar.tsx +57 -159
  174. package/src/components/SettingsPanel.tsx +193 -330
  175. package/src/components/icons.tsx +0 -23
  176. package/src/hooks/useGrowth.ts +123 -156
  177. package/src/hooks/usePiWeb.ts +15 -122
  178. package/src/i18n.tsx +42 -98
  179. package/src/lib/agent-manager.ts +21 -187
  180. package/src/lib/command-validation.ts +1 -9
  181. package/src/lib/growth-service.ts +390 -166
  182. package/src/lib/growth-tracker.ts +64 -264
  183. package/src/lib/growth-turns.ts +31 -1
  184. package/src/lib/models/model-scope.ts +15 -0
  185. package/src/lib/models-service.ts +16 -0
  186. package/src/lib/pi-settings.ts +7 -0
  187. package/src/lib/pi.ts +2 -3
  188. package/src/lib/session-import/claude.ts +2 -13
  189. package/src/lib/session-import/codex.ts +8 -44
  190. package/src/lib/session-import/files.ts +0 -14
  191. package/src/lib/session-import/grok.ts +24 -54
  192. package/src/lib/session-import/index.ts +0 -5
  193. package/src/lib/session-import/types.ts +0 -12
  194. package/src/lib/session-import/writer.ts +3 -8
  195. package/src/lib/skills-service.ts +9 -16
  196. package/src/lib/theme.ts +1 -68
  197. package/src/lib/trajectory.ts +0 -1
  198. package/src/lib/types.ts +27 -36
  199. package/tsconfig.json +2 -1
  200. package/.next/server/app/api/swarm/[id]/route.js +0 -1
  201. package/.next/server/app/api/swarm/[id]/route.js.nft.json +0 -1
  202. package/.next/server/app/api/swarm/[id]/route_client-reference-manifest.js +0 -1
  203. package/.next/server/app/api/swarm/route.js +0 -1
  204. package/.next/server/app/api/swarm/route.js.nft.json +0 -1
  205. package/.next/server/app/api/swarm/route_client-reference-manifest.js +0 -1
  206. package/.next/server/app/api/tests/route.js +0 -2
  207. package/.next/server/app/api/tests/route.js.nft.json +0 -1
  208. package/.next/server/app/api/tests/route_client-reference-manifest.js +0 -1
  209. package/.next/server/chunks/757.js +0 -1
  210. package/.next/server/chunks/775.js +0 -3
  211. package/.next/static/0t1jBUAuVaTFTXxRyIo3q/_buildManifest.js +0 -1
  212. package/.next/static/chunks/2608.43c58236c9cce8e6.js +0 -1
  213. package/.next/static/chunks/2716.4ec42df1d51354c9.js +0 -1
  214. package/.next/static/chunks/4632.62139b565b657aac.js +0 -1
  215. package/.next/static/chunks/4673.7796104395deb8d7.js +0 -1
  216. package/.next/static/chunks/4686-17f21a006739a596.js +0 -1
  217. package/.next/static/chunks/7577.10af9bb661652da8.js +0 -1
  218. package/.next/static/chunks/8866.38ec4ac4ee5a1b74.js +0 -1
  219. package/.next/static/chunks/app/_global-error/page-5148173b3f55e415.js +0 -1
  220. package/.next/static/chunks/app/api/agent/[id]/events/route-5148173b3f55e415.js +0 -1
  221. package/.next/static/chunks/app/api/agent/[id]/route-5148173b3f55e415.js +0 -1
  222. package/.next/static/chunks/app/api/agent/[id]/tree/route-5148173b3f55e415.js +0 -1
  223. package/.next/static/chunks/app/api/agent/new/route-5148173b3f55e415.js +0 -1
  224. package/.next/static/chunks/app/api/config/route-5148173b3f55e415.js +0 -1
  225. package/.next/static/chunks/app/api/files/route-5148173b3f55e415.js +0 -1
  226. package/.next/static/chunks/app/api/git/route-5148173b3f55e415.js +0 -1
  227. package/.next/static/chunks/app/api/growth/route-5148173b3f55e415.js +0 -1
  228. package/.next/static/chunks/app/api/health/route-5148173b3f55e415.js +0 -1
  229. package/.next/static/chunks/app/api/models/route-5148173b3f55e415.js +0 -1
  230. package/.next/static/chunks/app/api/pi-settings/route-5148173b3f55e415.js +0 -1
  231. package/.next/static/chunks/app/api/plugins/route-5148173b3f55e415.js +0 -1
  232. package/.next/static/chunks/app/api/prompts/route-5148173b3f55e415.js +0 -1
  233. package/.next/static/chunks/app/api/security/route-5148173b3f55e415.js +0 -1
  234. package/.next/static/chunks/app/api/sessions/[id]/export/route-5148173b3f55e415.js +0 -1
  235. package/.next/static/chunks/app/api/sessions/[id]/route-5148173b3f55e415.js +0 -1
  236. package/.next/static/chunks/app/api/sessions/import/route-5148173b3f55e415.js +0 -1
  237. package/.next/static/chunks/app/api/sessions/route-5148173b3f55e415.js +0 -1
  238. package/.next/static/chunks/app/api/skills/route-5148173b3f55e415.js +0 -1
  239. package/.next/static/chunks/app/api/swarm/[id]/route-5148173b3f55e415.js +0 -1
  240. package/.next/static/chunks/app/api/swarm/route-5148173b3f55e415.js +0 -1
  241. package/.next/static/chunks/app/api/tests/route-5148173b3f55e415.js +0 -1
  242. package/.next/static/chunks/app/api/update/route-5148173b3f55e415.js +0 -1
  243. package/.next/static/chunks/app/api/version/route-5148173b3f55e415.js +0 -1
  244. package/.next/static/chunks/app/api/web-auth/route-5148173b3f55e415.js +0 -1
  245. package/.next/static/chunks/app/api/workspaces/route-5148173b3f55e415.js +0 -1
  246. package/.next/static/chunks/app/page-14379b8060d5cf07.js +0 -1
  247. package/.next/static/chunks/next/dist/client/components/builtin/app-error-5148173b3f55e415.js +0 -1
  248. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-5148173b3f55e415.js +0 -1
  249. package/.next/static/chunks/next/dist/client/components/builtin/not-found-5148173b3f55e415.js +0 -1
  250. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-5148173b3f55e415.js +0 -1
  251. package/.next/static/chunks/webpack-d1a69ad9edb38b11.js +0 -1
  252. package/.next/static/css/d0a0162cb481abe8.css +0 -1
  253. package/.next/types/app/api/swarm/[id]/route.ts +0 -353
  254. package/.next/types/app/api/swarm/route.ts +0 -353
  255. package/.next/types/app/api/tests/route.ts +0 -353
  256. package/assets/archive.png +0 -0
  257. package/assets/growth-1.png +0 -0
  258. package/assets/growth-2.png +0 -0
  259. package/assets/import.png +0 -0
  260. package/assets/main.png +0 -0
  261. package/assets/outline.png +0 -0
  262. package/assets/prompts-1.png +0 -0
  263. package/assets/prompts-2.png +0 -0
  264. package/assets/providers-1.png +0 -0
  265. package/assets/providers-2.png +0 -0
  266. package/assets/providers-3.png +0 -0
  267. package/assets/trace-1.png +0 -0
  268. package/assets/viewer-1.png +0 -0
  269. package/assets/viewer-2.png +0 -0
  270. package/scripts/managed-niubash-assets.mjs +0 -146
  271. package/scripts/prepare-niubash-packages.mjs +0 -182
  272. package/scripts/run-test-file.mjs +0 -34
  273. package/scripts/smoke-managed-niubash.mjs +0 -62
  274. package/scripts/verify-release-config.mjs +0 -24
  275. package/src/app/api/swarm/[id]/route.ts +0 -28
  276. package/src/app/api/swarm/route.ts +0 -24
  277. package/src/app/api/tests/route.ts +0 -27
  278. package/src/components/SwarmCoordinatorPod.tsx +0 -1021
  279. package/src/components/TestInspectorPod.tsx +0 -549
  280. package/src/lib/delegation.ts +0 -93
  281. package/src/lib/growth-rounds.ts +0 -84
  282. package/src/lib/managed-niubash.ts +0 -231
  283. package/src/lib/runtime-activity.ts +0 -27
  284. package/src/lib/session-import/lock.ts +0 -25
  285. package/src/lib/swarm-service.ts +0 -371
  286. package/src/lib/test-run-service.ts +0 -122
  287. package/src/lib/workflow.ts +0 -55
  288. /package/.next/static/{0t1jBUAuVaTFTXxRyIo3q → zsynuO_9Kx6nrXgkTUJ04}/_ssgManifest.js +0 -0
@@ -1,11 +1,17 @@
1
1
  /**
2
- * growth-service:项目生长快照引擎(影子仓库)。
2
+ * growth-service:项目生长 = 每轮一个 git commit(工作区自带 git,无影子仓库、无旁路账本)。
3
3
  *
4
- * 每个工作区在 ~/.pi/agent/web-growth/<key>/ 下有一个裸 git 仓库(repo/)和一份账本(ledger.jsonl)。
5
- * 快照 = 以工作区为 work-tree 往影子索引里 update-index,再 write-tree 得到 tree 哈希;
6
- * 两步之间用 diff-tree 得增删改清单,任意两步任意文件用 git diff 得行级 patch,cat-file 取任意步全文。
7
- * 每步顺手 commit-tree 到 refs/piweb/growth,让所有快照可达、不会被 gc 清掉。
8
- * 用户自己的 .git 零改动;非 git 目录同样可用;工作区的 .gitignore 与这里的 info/exclude 都生效。
4
+ * commit 直接落在工作区自己的 git 仓库里:非 git 目录在首次提交前自动 `git init`;
5
+ * 工作区若只是别的仓库的子目录,则在自身根目录嵌套 init,保证记录范围恰好等于工作区。
6
+ * 每轮结束 = 往 PiWeb 独立暂存区 <gitdir>/piweb/index(GIT_INDEX_FILE 传入)add 全部改动,
7
+ * write-tree + commit-tree,挂到专用引用 refs/piweb/rounds/<key>:不碰 HEAD、不碰用户暂存区、不占分支。
8
+ * 排除规则 <gitdir>/piweb/exclude 只叠加、绝不改写用户的 info/exclude。
9
+ *
10
+ * 元信息全部在 commit 里:标题是本轮提问首行,Piweb-Kind / Session / Prompts / Status 写成 trailers。
11
+ * 时间轴 = 一次 `git log --raw --numstat` 读回(每轮相对上一轮的增删改与行数);
12
+ * 任意两轮任意文件用 git diff 得行级 patch,cat-file 取任意一轮的全文。
13
+ * 工作区的 .gitignore / info/exclude / 全局 excludesFile 与生成的排除文件都生效;
14
+ * 没有 git、目录不可写或项目过大时本会话停用跟踪。
9
15
  */
10
16
  import { spawn } from "node:child_process";
11
17
  import { createHash } from "node:crypto";
@@ -13,25 +19,27 @@ import { existsSync, realpathSync } from "node:fs";
13
19
  import fs from "node:fs/promises";
14
20
  import path from "node:path";
15
21
  import { GROWTH_EXCLUDE_DIRS } from "./growth-tree";
16
- import { getAgentDir } from "./pi";
17
- import type { GrowthChange, GrowthStep, GrowthStepKind } from "./types";
22
+ import type { GrowthChange, GrowthRound, GrowthRoundKind, GrowthRoundStatus } from "./types";
18
23
 
19
- /** 单文件快照上限:超过的不进影子仓库(树里也就看不到它) */
24
+ /** 单文件快照上限:超过的不进快照(树里也就看不到它) */
20
25
  export const MAX_FILE_BYTES = 1024 * 1024;
21
26
  /** 单次 patch 输出上限 */
22
27
  const MAX_PATCH_BYTES = 4 * 1024 * 1024;
23
28
  /** 单文件全文预览上限 */
24
29
  const MAX_CONTENT_BYTES = 256 * 1024;
25
- /** 防止意外把依赖/缓存仓库完整哈希入库,导致工作区长时间卡住。 */
30
+ /** 防止意外把依赖/缓存目录完整哈希入库,导致工作区长时间卡住。 */
26
31
  const MAX_CANDIDATES = 50_000;
27
- /** 每步账本里最多记多少条变更;更多的由前端按 tree 对再取 */
28
- const MAX_CHANGES_PER_STEP = 2000;
29
- const GROWTH_REF = "refs/piweb/growth";
30
-
31
- /** 不进快照的目录 / 文件(写进影子仓库的 info/exclude);工作区自己的 .gitignore 同样生效 */
32
+ /** 每轮最多列多少条变更;更多的由前端按 tree 对再取 */
33
+ const MAX_CHANGES_PER_ROUND = 2000;
34
+ /** 每轮 commit 链的专用引用前缀:每个工作区一条 refs/piweb/rounds/<key>,多 worktree 互不覆盖 */
35
+ const GROWTH_REF_PREFIX = "refs/piweb/rounds";
36
+ /** 时间轴读取的记录分隔符(git log --format 里的 %x1e / %x1f) */
37
+ const RECORD_SEP = "\x1e";
38
+ const FIELD_SEP = "\x1f";
39
+
40
+ /** 不进快照的目录 / 文件(写进 <gitdir>/piweb/exclude);工作区自己的 .gitignore 等同样生效 */
32
41
  export const EXCLUDE_DIRS = GROWTH_EXCLUDE_DIRS;
33
42
  const EXCLUDE_FILES = [".DS_Store", "Thumbs.db", "*.log", ".env", ".env.*", "!.env.example", "*.pem", "*.key", "*.p12", "*.pfx", "id_rsa*", "id_ed25519*"];
34
- const EXCLUDED_DIR_SET = new Set(EXCLUDE_DIRS.map((d) => d.toLowerCase()));
35
43
  const EXCLUDE_CONTENT = `# PiWeb growth snapshot exclusions (generated, do not edit)\n${[...EXCLUDE_DIRS.map((d) => `${d}/`), ...EXCLUDE_FILES].join("\n")}\n`;
36
44
 
37
45
  export class GrowthError extends Error {
@@ -47,15 +55,23 @@ export class GrowthError extends Error {
47
55
  interface WorkspaceState {
48
56
  key: string;
49
57
  cwd: string;
50
- dir: string;
51
- repo: string;
52
- ledger: string;
53
- steps: GrowthStep[];
54
- lastTree?: string;
58
+ /** 工作区自己的 git-dir(rev-parse --absolute-git-dir 解析,兼容 worktree 的 gitfile) */
59
+ gitDir: string;
60
+ /** PiWeb 专用暂存区(GIT_INDEX_FILE 传入):用户自己的 index 永不触碰 */
61
+ indexFile: string;
62
+ /** PiWeb 专用排除文件(--exclude-from 传入):不改写用户仓库的 info/exclude */
63
+ excludeFile: string;
64
+ /** 本工作区 commit 链的专用引用:refs/piweb/rounds/<key> */
65
+ ref: string;
66
+ /** 链头(最近一次提交);没有提交过为 undefined */
55
67
  lastCommit?: string;
68
+ lastTree?: string;
69
+ /** 已从 git log 读回的整条链(时间正序)与读回时的链头;commit 不可变,链头没变就直接用 */
70
+ rounds: GrowthRound[] | null;
71
+ loadedHead?: string;
56
72
  emptyTree: string;
57
73
  excludeRules?: string;
58
- /** 同一工作区的快照串行执行(共用一个影子索引) */
74
+ /** 同一工作区的快照串行执行(共用同一个 PiWeb 暂存区) */
59
75
  lock: Promise<unknown>;
60
76
  }
61
77
 
@@ -67,8 +83,9 @@ interface Registry {
67
83
  /** 解析后的 git 可执行文件(macOS GUI PATH 兜底) */
68
84
  gitBin?: string;
69
85
  }
70
- const globalForGrowth = globalThis as typeof globalThis & { __piWebGrowth?: Registry };
71
- const registry: Registry = (globalForGrowth.__piWebGrowth ??= { workspaces: new Map() });
86
+ // 键名带 Rounds:dev 热更新后不复用旧版(快照 + 账本)结构的工作区状态
87
+ const globalForGrowth = globalThis as typeof globalThis & { __piWebGrowthRounds?: Registry };
88
+ const registry: Registry = (globalForGrowth.__piWebGrowthRounds ??= { workspaces: new Map() });
72
89
 
73
90
  // ---------- 基础工具 ----------
74
91
 
@@ -77,10 +94,10 @@ function comparablePath(p: string): string {
77
94
  return process.platform === "win32" ? norm.toLowerCase() : norm;
78
95
  }
79
96
 
80
- /** 工作区目录名:路径 slug + 8 位哈希(Windows 大小写不敏感) */
97
+ /** 工作区目录名:路径 slug + 8 位哈希(Windows 大小写不敏感);用作引用后缀与注册表键 */
81
98
  export function workspaceKey(cwd: string): string {
82
99
  // Canonicalize first: Windows may report an 8.3 short path (RUNNER~1) in one call and
83
- // the long path in another, and two spellings must not split one workspace's ledger.
100
+ // the long path in another, and two spellings must not split one workspace's history.
84
101
  let resolved = path.resolve(cwd);
85
102
  try {
86
103
  resolved = realpathSync.native(resolved);
@@ -92,16 +109,6 @@ export function workspaceKey(cwd: string): string {
92
109
  return `${slug || "root"}-${createHash("sha1").update(cmp).digest("hex").slice(0, 8)}`;
93
110
  }
94
111
 
95
- /** 目录监听 / 树渲染共用:这条相对路径是否落在排除目录里 */
96
- export function isExcludedRelPath(rel: string): boolean {
97
- const parts = rel.replace(/\\/g, "/").split("/").filter(Boolean);
98
- return parts.some((seg) => EXCLUDED_DIR_SET.has(seg.toLowerCase()));
99
- }
100
-
101
- function sameSession(a: string, b: string): boolean {
102
- return comparablePath(a) === comparablePath(b);
103
- }
104
-
105
112
  interface RunResult {
106
113
  stdout: Buffer;
107
114
  stderr: string;
@@ -208,68 +215,126 @@ async function gitOk(): Promise<boolean> {
208
215
  return check;
209
216
  }
210
217
 
211
- /** 影子仓库命令:固定 git-dir / work-tree,关掉换行转换与路径转义,输出按字节原样 */
218
+ /**
219
+ * 快照命令基础配置:关掉换行转换与路径转义,输出按字节原样。
220
+ * LFS 类 clean 过滤必须禁用:快照要存文件原始字节,而不是指针文本
221
+ * (用户仓库的 .gitattributes + 全局 filter.lfs.* 配置会同时命中工作区仓库)。
222
+ */
223
+ const BASE_CONFIG = [
224
+ "-c", "core.autocrlf=false",
225
+ "-c", "core.safecrlf=false",
226
+ "-c", "core.quotepath=false",
227
+ "-c", "core.fsmonitor=false",
228
+ "-c", "core.untrackedCache=false",
229
+ "-c", "filter.lfs.clean=",
230
+ "-c", "filter.lfs.smudge=",
231
+ "-c", "filter.lfs.process=",
232
+ "-c", "filter.lfs.required=false",
233
+ ];
234
+
235
+ /**
236
+ * 工作区仓库命令:固定 git-dir / work-tree,一律使用 PiWeb 专用暂存区。
237
+ * GIT_INDEX_FILE 对纯对象读取(cat-file/diff-tree…)无副作用,统一带上避免漏传。
238
+ */
212
239
  async function shadow(ws: WorkspaceState, args: string[], opts: { input?: Buffer | string; maxBuffer?: number; allowFail?: boolean; env?: Env } = {}): Promise<RunResult> {
213
- const base = [
214
- "--git-dir", ws.repo,
215
- "--work-tree", ws.cwd,
216
- "-c", "core.autocrlf=false",
217
- "-c", "core.safecrlf=false",
218
- "-c", "core.quotepath=false",
219
- "-c", "core.fsmonitor=false",
220
- "-c", "core.untrackedCache=false",
221
- ];
222
- const r = await runGit([...base, ...args], { cwd: ws.cwd, input: opts.input, maxBuffer: opts.maxBuffer, env: opts.env });
240
+ const base = ["--git-dir", ws.gitDir, "--work-tree", ws.cwd, ...BASE_CONFIG];
241
+ const env: Env = { GIT_INDEX_FILE: ws.indexFile, ...opts.env };
242
+ const r = await runGit([...base, ...args], { cwd: ws.cwd, input: opts.input, maxBuffer: opts.maxBuffer, env });
223
243
  if (r.code !== 0 && !opts.allowFail) {
224
244
  throw new GrowthError(`git ${args[0]} failed: ${r.stderr.trim().split("\n")[0] || `exit ${r.code}`}`);
225
245
  }
226
246
  return r;
227
247
  }
228
248
 
229
- function growthRoot(): string {
230
- return path.join(getAgentDir(), "web-growth");
231
- }
232
-
233
- function parseStep(line: string): GrowthStep | null {
249
+ function realpathSafe(p: string): string {
234
250
  try {
235
- const s = JSON.parse(line) as GrowthStep;
236
- if (typeof s?.tree !== "string" || typeof s.seq !== "number") return null;
237
- return s;
251
+ return realpathSync.native(p);
238
252
  } catch {
239
- return null;
253
+ return p;
254
+ }
255
+ }
256
+
257
+ /**
258
+ * 不启动 git 进程解析工作区的 git-dir(只读快速路径):
259
+ * `.git` 是目录直接用,是文件则按 worktree/submodule 的 gitfile 解析。
260
+ * 记录只可能写在「工作区自身仓库」里,父级仓库不在考虑范围。
261
+ */
262
+ async function resolveGitDirFast(cwd: string): Promise<string | null> {
263
+ const dotgit = path.join(cwd, ".git");
264
+ const stat = await fs.stat(dotgit).catch(() => null);
265
+ if (stat?.isDirectory()) return dotgit;
266
+ if (stat?.isFile()) {
267
+ const text = await fs.readFile(dotgit, "utf8").catch(() => "");
268
+ const match = /^gitdir:[ \t]*(.+)$/m.exec(text);
269
+ if (match) return path.resolve(cwd, match[1].trim());
240
270
  }
271
+ return null;
272
+ }
273
+
274
+ /**
275
+ * 不启动 git 进程判断专用引用是否存在:refs/ 之下的引用存在共享目录(linked worktree 的 commondir),
276
+ * 可能是松散文件,也可能已被 git gc 打包进 packed-refs。
277
+ */
278
+ async function refExistsFast(gitDir: string, ref: string): Promise<boolean> {
279
+ const commondir = (await fs.readFile(path.join(gitDir, "commondir"), "utf8").catch(() => "")).trim();
280
+ const common = commondir ? path.resolve(gitDir, commondir) : gitDir;
281
+ if (await fs.stat(path.join(common, ...ref.split("/"))).then((s) => s.isFile(), () => false)) return true;
282
+ const packed = await fs.readFile(path.join(common, "packed-refs"), "utf8").catch(() => "");
283
+ return packed.split("\n").some((line) => line.endsWith(` ${ref}`));
284
+ }
285
+
286
+ /**
287
+ * 解析工作区可用的 git-dir:工作区已是某仓库的工作树根就直接复用;
288
+ * 否则(非 git 目录、或只是别的仓库的子目录)在工作区根 `git init`。
289
+ * init 失败(目录不可写等)按终态处理,本会话停用生长跟踪。
290
+ */
291
+ async function ensureGitDir(cwd: string): Promise<string> {
292
+ const usable = async (): Promise<string | null> => {
293
+ const r = await runGit(["rev-parse", "--absolute-git-dir", "--show-toplevel", "--is-bare-repository"], { cwd });
294
+ if (r.code !== 0) return null;
295
+ const [gitDir = "", top = "", bare = ""] = r.stdout.toString("utf8").split("\n").map((s) => s.trim());
296
+ if (!gitDir || bare === "true" || !top) return null;
297
+ // 快照范围必须恰好等于工作区:落在别的仓库的子目录里时,宁可嵌套 init 自己的仓库
298
+ if (comparablePath(realpathSafe(top)) !== comparablePath(realpathSafe(cwd))) return null;
299
+ return gitDir;
300
+ };
301
+ const existing = await usable();
302
+ if (existing) return existing;
303
+ const r = await runGit(["init", "-q"], { cwd });
304
+ if (r.code !== 0) throw new GrowthError(`git init failed: ${r.stderr.trim().split("\n")[0] || `exit ${r.code}`}`, "unavailable");
305
+ const created = await usable();
306
+ if (!created) throw new GrowthError("workspace cannot be used as a git repository", "unavailable");
307
+ return created;
308
+ }
309
+
310
+ /** 链头的 commit 与 tree;引用不存在返回 null(for-each-ref 对不存在的引用输出空、退出码 0) */
311
+ async function readHead(ws: WorkspaceState): Promise<{ commit: string; tree: string } | null> {
312
+ const r = await shadow(ws, ["for-each-ref", "--format=%(objectname) %(tree)", ws.ref]);
313
+ const [commit = "", tree = ""] = r.stdout.toString("utf8").trim().split(" ");
314
+ return HASH_RE.test(commit) && HASH_RE.test(tree) ? { commit, tree } : null;
241
315
  }
242
316
 
243
317
  async function createWorkspace(cwd: string): Promise<WorkspaceState> {
244
318
  if (!(await gitOk())) throw new GrowthError("git is not installed", "unavailable");
245
319
  const key = workspaceKey(cwd);
246
- const dir = path.join(growthRoot(), key);
247
- const repo = path.join(dir, "repo");
248
- const ledger = path.join(dir, "ledger.jsonl");
249
- await fs.mkdir(dir, { recursive: true });
250
- const initialized = await fs.stat(path.join(repo, "HEAD")).then(() => true, () => false);
251
- if (!initialized) {
252
- const r = await runGit(["init", "-q", "--bare", repo]);
253
- if (r.code !== 0) throw new GrowthError(`git init failed: ${r.stderr.trim()}`);
254
- }
255
- await fs.mkdir(path.join(repo, "info"), { recursive: true });
256
- await fs.writeFile(path.join(repo, "info", "exclude"), EXCLUDE_CONTENT, "utf8");
257
- await fs.writeFile(path.join(dir, "meta.json"), JSON.stringify({ cwd, key, createdAt: new Date().toISOString() }, null, 2), "utf8");
258
-
259
- const steps: GrowthStep[] = [];
260
- try {
261
- for (const line of (await fs.readFile(ledger, "utf8")).split("\n")) {
262
- if (!line.trim()) continue;
263
- const s = parseStep(line);
264
- if (s) steps.push(s);
265
- }
266
- } catch (error) {
267
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
268
- }
269
- const ws: WorkspaceState = { key, cwd, dir, repo, ledger, steps, emptyTree: "", excludeRules: EXCLUDE_CONTENT, lock: Promise.resolve() };
270
- const last = steps[steps.length - 1];
271
- ws.lastTree = last?.tree;
272
- ws.lastCommit = last?.commit;
320
+ const gitDir = await ensureGitDir(cwd);
321
+ const piwebDir = path.join(gitDir, "piweb");
322
+ await fs.mkdir(piwebDir, { recursive: true });
323
+ const ws: WorkspaceState = {
324
+ key,
325
+ cwd,
326
+ gitDir,
327
+ indexFile: path.join(piwebDir, "index"),
328
+ excludeFile: path.join(piwebDir, "exclude"),
329
+ ref: `${GROWTH_REF_PREFIX}/${key}`,
330
+ rounds: null,
331
+ emptyTree: "",
332
+ lock: Promise.resolve(),
333
+ };
334
+ await fs.writeFile(ws.excludeFile, EXCLUDE_CONTENT, "utf8");
335
+ const head = await readHead(ws);
336
+ ws.lastCommit = head?.commit;
337
+ ws.lastTree = head?.tree;
273
338
  const empty = await shadow(ws, ["hash-object", "-t", "tree", "--stdin"], { input: "" });
274
339
  ws.emptyTree = empty.stdout.toString("utf8").trim();
275
340
  return ws;
@@ -280,10 +345,10 @@ async function getWorkspace(cwdValue: string): Promise<WorkspaceState> {
280
345
  const key = workspaceKey(cwd);
281
346
  let pending = registry.workspaces.get(key);
282
347
  if (pending) {
283
- // 影子仓库可能在运行期间被用户清理掉(~/.pi/agent/web-growth/<key>/):缓存里的状态就指向不存在的 git-dir,
284
- // 之后每次快照都报 not a git repository。发现 HEAD 不在就丢掉缓存重建(账本也一并从头开始)
348
+ // 工作区的 .git 可能在运行期间被用户删掉重建:缓存里的状态就指向不存在的 git-dir,
349
+ // 之后每次提交都报 not a git repository。发现 HEAD 不在就丢掉缓存重建(链头从新仓库重新读)
285
350
  const ws = await pending.catch(() => null);
286
- const alive = ws ? await fs.stat(path.join(ws.repo, "HEAD")).then(() => true, () => false) : false;
351
+ const alive = ws ? await fs.stat(path.join(ws.gitDir, "HEAD")).then(() => true, () => false) : false;
287
352
  if (!alive) {
288
353
  registry.workspaces.delete(key);
289
354
  pending = undefined;
@@ -385,7 +450,119 @@ export function parseLsTree(raw: string): TreeFile[] {
385
450
  return out;
386
451
  }
387
452
 
388
- function summarize(changes: GrowthChange[]): GrowthStep["stats"] {
453
+ /** 把 numstat 的行数并进 name-status 的变更清单(重命名按新路径对齐),按路径排序 */
454
+ function withStats(changes: GrowthChange[], stats: Map<string, { add: number; del: number; binary: boolean }>): GrowthChange[] {
455
+ for (const c of changes) {
456
+ const s = stats.get(c.path);
457
+ if (!s) continue;
458
+ c.add = s.add;
459
+ c.del = s.del;
460
+ if (s.binary) c.binary = true;
461
+ }
462
+ return changes.sort((a, b) => a.path.localeCompare(b.path));
463
+ }
464
+
465
+ export interface LogCommit {
466
+ commit: string;
467
+ tree: string;
468
+ parent: string | null;
469
+ /** 提交时间(毫秒) */
470
+ ts: number;
471
+ message: string;
472
+ /** 相对第一个父 commit 的变更(根 commit 为空:读取时关掉了 log.showRoot) */
473
+ changes: GrowthChange[];
474
+ }
475
+
476
+ /**
477
+ * `git log -z --raw --numstat -M --format=%x1e%H%x1f%T%x1f%P%x1f%ct%x1f%B%x1f` 输出 → 每个 commit 的元信息与变更。
478
+ * 每条记录以 \x1e 开头;头部字段以 \x1f 分隔、以 "\x1f\0" 结束;之后(可选的 "\n")是 NUL 分隔的
479
+ * --raw 条目(":模式 模式 哈希 哈希 状态" 后跟路径,R/C 跟两个路径)与 --numstat 条目
480
+ * ("增\t删\t路径";重命名是 "增\t删\t" 后跟旧、新两个路径)。空 commit 没有条目。
481
+ */
482
+ export function parseRoundLog(raw: string): LogCommit[] {
483
+ const out: LogCommit[] = [];
484
+ for (const record of raw.split(RECORD_SEP)) {
485
+ if (!record) continue;
486
+ const end = record.indexOf(`${FIELD_SEP}\0`);
487
+ const header = end >= 0 ? record.slice(0, end) : record.replace(/[\x1f\0\n]+$/, "");
488
+ const fields = header.split(FIELD_SEP);
489
+ if (fields.length < 5) continue;
490
+ const [commit, tree, parents, ct] = fields;
491
+ const names: string[] = [];
492
+ const nums: string[] = [];
493
+ const tokens = end >= 0 ? record.slice(end + 2).replace(/^\n/, "").split("\0") : [];
494
+ for (let i = 0; i < tokens.length; i += 1) {
495
+ const token = tokens[i];
496
+ if (!token) continue;
497
+ if (token.startsWith(":")) {
498
+ const status = token.slice(token.lastIndexOf(" ") + 1);
499
+ const span = status[0] === "R" || status[0] === "C" ? 2 : 1;
500
+ names.push(status, ...tokens.slice(i + 1, i + 1 + span));
501
+ i += span;
502
+ } else {
503
+ const parts = token.split("\t");
504
+ const span = parts.length === 3 && parts[2] === "" ? 2 : 0;
505
+ nums.push(token, ...tokens.slice(i + 1, i + 1 + span));
506
+ i += span;
507
+ }
508
+ }
509
+ out.push({
510
+ commit,
511
+ tree,
512
+ parent: parents.split(" ").filter(Boolean)[0] ?? null,
513
+ ts: Number(ct) * 1000,
514
+ message: fields.slice(4).join(FIELD_SEP),
515
+ changes: withStats(parseNameStatus(`${names.join("\0")}\0`), parseNumstat(`${nums.join("\0")}\0`)),
516
+ });
517
+ }
518
+ return out;
519
+ }
520
+
521
+ const ROUND_KINDS: readonly GrowthRoundKind[] = ["round", "user", "baseline"];
522
+ const ROUND_STATUSES: readonly GrowthRoundStatus[] = ["done", "aborted", "error"];
523
+ /** 没有提问文字(纯图片)或非 pi 轮次时的 commit 标题;读回时还原成空标题,界面用自己的文案 */
524
+ const DEFAULT_TITLES: Record<GrowthRoundKind, string> = { round: "piweb: round", user: "piweb: workspace changes", baseline: "piweb: baseline" };
525
+
526
+ /** commit message → 标题 + Piweb-* trailers */
527
+ export function parseRoundMessage(message: string): Pick<GrowthRound, "kind" | "title" | "session" | "promptIds" | "status"> {
528
+ const lines = message.split("\n");
529
+ const trailers = new Map<string, string>();
530
+ for (const line of lines) {
531
+ const m = /^Piweb-([A-Za-z]+):[ \t]*(.*)$/.exec(line.trim());
532
+ if (m) trailers.set(m[1].toLowerCase(), m[2].trim());
533
+ }
534
+ const kind = ROUND_KINDS.find((k) => k === trailers.get("kind")) ?? "round";
535
+ const status = ROUND_STATUSES.find((s) => s === trailers.get("status")) ?? "done";
536
+ const first = (lines[0] ?? "").trim();
537
+ return {
538
+ kind,
539
+ title: kind === "round" && first !== DEFAULT_TITLES.round ? first : "",
540
+ session: trailers.get("session") ?? "",
541
+ promptIds: (trailers.get("prompts") ?? "").split(",").map((id) => id.trim()).filter(Boolean),
542
+ status,
543
+ };
544
+ }
545
+
546
+ function cleanLine(value: string): string {
547
+ return value.replace(/[\x00-\x1f\x7f]+/g, " ").replace(/\s+/g, " ").trim();
548
+ }
549
+
550
+ function roundMessage(meta: RoundMeta, kind: GrowthRoundKind): string {
551
+ // 按码点截断:不能把 emoji 的代理对切成半个
552
+ const title = Array.from(cleanLine(meta.title ?? "")).slice(0, 72).join("") || DEFAULT_TITLES[kind];
553
+ const lines = [title, "", `Piweb-Kind: ${kind}`, `Piweb-Session: ${cleanLine(sessionName(meta.session))}`];
554
+ const ids = (meta.promptIds ?? []).filter((id) => /^[\w.:-]{1,128}$/.test(id));
555
+ if (ids.length) lines.push(`Piweb-Prompts: ${ids.join(",")}`);
556
+ lines.push(`Piweb-Status: ${meta.status ?? "done"}`);
557
+ return `${lines.join("\n")}\n`;
558
+ }
559
+
560
+ /** 会话只记 JSONL 文件名:不把用户目录结构写进 commit */
561
+ function sessionName(sessionPath: string): string {
562
+ return path.basename(sessionPath.replace(/\\/g, "/"));
563
+ }
564
+
565
+ function summarize(changes: GrowthChange[]): GrowthRound["stats"] {
389
566
  const stats = { added: 0, modified: 0, deleted: 0, renamed: 0, add: 0, del: 0 };
390
567
  for (const c of changes) {
391
568
  if (c.status === "A") stats.added += 1;
@@ -405,22 +582,15 @@ async function diffTreesRaw(ws: WorkspaceState, from: string, to: string): Promi
405
582
  shadow(ws, ["diff-tree", "-r", "-M", "--name-status", "-z", from, to]),
406
583
  shadow(ws, ["diff-tree", "-r", "-M", "--numstat", "-z", from, to]),
407
584
  ]);
408
- const changes = parseNameStatus(names.stdout.toString("utf8"));
409
- const stats = parseNumstat(nums.stdout.toString("utf8"));
410
- for (const c of changes) {
411
- const s = stats.get(c.path);
412
- if (!s) continue;
413
- c.add = s.add;
414
- c.del = s.del;
415
- if (s.binary) c.binary = true;
416
- }
417
- changes.sort((a, b) => a.path.localeCompare(b.path));
418
- return changes;
585
+ return withStats(parseNameStatus(names.stdout.toString("utf8")), parseNumstat(nums.stdout.toString("utf8")));
419
586
  }
420
587
 
421
- /** 候选路径 → 更新影子索引;返回本次处理的文件数 */
588
+ /** 候选路径 → 更新 PiWeb 专用索引;返回本次处理的文件数 */
422
589
  async function refreshIndex(ws: WorkspaceState): Promise<number> {
423
- const listed = await shadow(ws, ["ls-files", "-z", "--others", "--modified", "--deleted", "--exclude-standard"]);
590
+ // --exclude-standard 让工作区的 .gitignore / info/exclude / 全局 excludesFile 生效;
591
+ // --exclude-from 叠加 PiWeb 自己的排除文件(用户仓库的 info/exclude 一个字节都不改)
592
+ const excludeFrom = ws.excludeFile.replace(/\\/g, "/");
593
+ const listed = await shadow(ws, ["ls-files", "-z", "--others", "--modified", "--deleted", "--exclude-standard", `--exclude-from=${excludeFrom}`]);
424
594
  const candidates = Array.from(new Set(listed.stdout.toString("utf8").split("\0").filter((p) => p && !p.endsWith("/"))));
425
595
  if (candidates.length > MAX_CANDIDATES) {
426
596
  const counts = new Map<string, number>();
@@ -476,109 +646,163 @@ async function refreshIndex(ws: WorkspaceState): Promise<number> {
476
646
  }
477
647
  for (let i = 0; i < adds.length; i += INDEX_BATCH) {
478
648
  // Git 的 --replace 只移除与待添加项冲突的索引项,兜底扫描期间的类型转换;
479
- // --remove 同时容忍 lstat 后刚被删掉的文件。这些命令始终只操作影子 git-dir。
649
+ // --remove 同时容忍 lstat 后刚被删掉的文件。这些命令始终只操作 PiWeb 专用索引。
480
650
  await shadow(ws, ["update-index", "--add", "--remove", "--replace", "-z", "--stdin"], { input: `${adds.slice(i, i + INDEX_BATCH).join("\0")}\0` });
481
651
  }
482
652
  return candidates.length;
483
653
  }
484
654
 
485
- export interface SnapshotMeta {
486
- kind: GrowthStepKind;
487
- label: string;
655
+ export interface RoundMeta {
656
+ /** user 在工作区还没有任何提交时自动记成 baseline */
657
+ kind: GrowthRoundKind;
658
+ /** 会话 JSONL 路径(commit 里只记文件名) */
488
659
  session: string;
489
- toolCallId?: string;
490
- toolName?: string;
491
- /** tree 没变也记一步(会话基线用) */
492
- force?: boolean;
493
- /**
494
- * 步的归属时刻(默认落账时刻)。轮次内的工具/turn 快照传轮次开始时间:
495
- * 基线竞速放行后快照可能晚落账,按落账时间切轮次窗口会把改动算给下一轮。
496
- */
497
- ts?: number;
660
+ /** round:本轮提问首行 */
661
+ title?: string;
662
+ promptIds?: string[];
663
+ status?: GrowthRoundStatus;
498
664
  }
499
665
 
500
666
  /**
501
- * 给工作区拍一张快照。tree 与上一步相同且未 force 时返回 null(不记步)。
667
+ * 提交一个 commit 到 refs/piweb/rounds/<key>(做法等同 git add -A + git commit,但只用 PiWeb 独立暂存区)。
668
+ * pi 的一轮无论有没有改动都提交(空 commit 也算一轮,轮号与对话一一对应);
669
+ * 用户修改 / 基线只在工作区相对上一个 commit 有变化时提交,否则返回 null。
502
670
  */
503
- export async function snapshot(cwdValue: string, meta: SnapshotMeta): Promise<GrowthStep | null> {
671
+ export async function commitRound(cwdValue: string, meta: RoundMeta): Promise<GrowthRound | null> {
504
672
  const ws = await getWorkspace(cwdValue);
505
673
  return withLock(ws, async () => {
506
674
  if (ws.excludeRules !== EXCLUDE_CONTENT) {
507
- await fs.writeFile(path.join(ws.repo, "info", "exclude"), EXCLUDE_CONTENT, "utf8");
675
+ await fs.writeFile(ws.excludeFile, EXCLUDE_CONTENT, "utf8");
508
676
  ws.excludeRules = EXCLUDE_CONTENT;
509
677
  }
510
678
  await refreshIndex(ws);
511
679
  const tree = (await shadow(ws, ["write-tree"])).stdout.toString("utf8").trim();
512
680
  assertHash(tree, "tree");
513
- const parent = ws.lastTree ?? ws.emptyTree;
514
- if (tree === parent && !meta.force) return null;
515
-
516
- const initial = ws.lastTree === undefined;
517
- // 工作区第一张快照把所有文件都算“新增”,账本里不存这份清单(前端把基线当中性起点)
518
- const all = initial ? [] : await diffTreesRaw(ws, parent, tree);
519
- const changes = all.slice(0, MAX_CHANGES_PER_STEP);
520
-
521
681
  const env = {
522
682
  GIT_AUTHOR_NAME: "PiWeb", GIT_AUTHOR_EMAIL: "piweb@localhost",
523
683
  GIT_COMMITTER_NAME: "PiWeb", GIT_COMMITTER_EMAIL: "piweb@localhost",
524
684
  };
525
- const commitArgs = ["commit-tree", tree, "-m", `${meta.kind}: ${meta.label || "snapshot"}`];
526
- if (ws.lastCommit) commitArgs.push("-p", ws.lastCommit);
527
- const commit = (await shadow(ws, commitArgs, { env })).stdout.toString("utf8").trim();
528
- await shadow(ws, ["update-ref", GROWTH_REF, commit]);
529
-
530
- const step: GrowthStep = {
531
- seq: (ws.steps[ws.steps.length - 1]?.seq ?? 0) + 1,
532
- ts: meta.ts ?? Date.now(),
533
- session: meta.session,
534
- kind: meta.kind,
535
- label: meta.label.slice(0, 200),
536
- tree,
537
- parent,
538
- commit,
539
- toolCallId: meta.toolCallId,
540
- toolName: meta.toolName,
541
- changes,
542
- truncated: all.length > changes.length || undefined,
543
- initial: initial || undefined,
544
- stats: summarize(all),
545
- };
546
- await fs.appendFile(ws.ledger, `${JSON.stringify(step)}\n`, "utf8");
547
- ws.steps.push(step);
548
- ws.lastTree = tree;
549
- ws.lastCommit = commit;
550
- if (step.seq % 100 === 0) {
551
- // 维护命令同样串行化,避免与下一张快照同时操作影子仓库。
552
- void withLock(ws, async () => {
553
- await shadow(ws, ["gc", "--auto", "-q"], { allowFail: true });
554
- }).catch(() => undefined);
685
+ for (let attempt = 0; ; attempt += 1) {
686
+ const parent = ws.lastCommit;
687
+ const kind: GrowthRoundKind = meta.kind === "user" && !parent ? "baseline" : meta.kind;
688
+ if (kind !== "round" && parent && tree === ws.lastTree) return null;
689
+ const commit = (await shadow(ws, ["commit-tree", tree, ...(parent ? ["-p", parent] : []), "-F", "-"], { env, input: roundMessage(meta, kind) })).stdout.toString("utf8").trim();
690
+ assertHash(commit, "commit");
691
+ // 带旧值校验:另一个 PiWeb 进程抢先提交时不覆盖它,按新的链头重挂
692
+ const updated = await shadow(ws, ["update-ref", ws.ref, commit, parent ?? ""], { allowFail: true });
693
+ if (updated.code !== 0) {
694
+ const head = await readHead(ws);
695
+ if (attempt >= 2 || head?.commit === parent) {
696
+ throw new GrowthError(`git update-ref failed: ${updated.stderr.trim().split("\n")[0] || `exit ${updated.code}`}`);
697
+ }
698
+ ws.lastCommit = head?.commit;
699
+ ws.lastTree = head?.tree;
700
+ continue;
701
+ }
702
+ const parentTree = parent ? ws.lastTree ?? ws.emptyTree : ws.emptyTree;
703
+ // 链上第一个 commit 没有上一版可比:不把整个工作区算成「新增」
704
+ const all = parent ? await diffTreesRaw(ws, parentTree, tree) : [];
705
+ const changes = all.slice(0, MAX_CHANGES_PER_ROUND);
706
+ const round: GrowthRound = {
707
+ commit,
708
+ parent: parent ?? null,
709
+ tree,
710
+ parentTree,
711
+ ts: Date.now(),
712
+ ...parseRoundMessage(roundMessage(meta, kind)),
713
+ changes,
714
+ truncated: all.length > changes.length || undefined,
715
+ initial: parent ? undefined : true,
716
+ stats: summarize(all),
717
+ };
718
+ if (ws.rounds && ws.loadedHead === parent) {
719
+ ws.rounds.push(round);
720
+ ws.loadedHead = commit;
721
+ }
722
+ ws.lastCommit = commit;
723
+ ws.lastTree = tree;
724
+ // 不在用户仓库里主动 gc:对象由专用引用保活,仓库维护交还用户
725
+ return round;
555
726
  }
556
- return step;
557
727
  });
558
728
  }
559
729
 
730
+ /** 发 prompt 前调用:把两轮之间用户自己的修改单独记一笔(工作区还没有任何提交时记成基线),没改动不提交 */
731
+ export function recordWorkspaceChanges(cwdValue: string, session: string): Promise<GrowthRound | null> {
732
+ return commitRound(cwdValue, { kind: "user", session });
733
+ }
734
+
560
735
  // ---------- 读取 ----------
561
736
 
562
737
  export async function isAvailable(): Promise<boolean> {
563
738
  return gitOk();
564
739
  }
565
740
 
566
- /** 账本里的步:默认只取某个会话的;省略 session 取工作区全部。只读:还没建过影子仓库的工作区直接返回空 */
567
- export async function readSteps(cwdValue: string, session?: string): Promise<GrowthStep[]> {
741
+ /** 一次 git log 读回链上的 commit(时间正序);since 给出时只读它之后新增的 */
742
+ async function logRounds(ws: WorkspaceState, head: string, since: string | undefined, knownTrees: Map<string, string>): Promise<GrowthRound[]> {
743
+ const r = await shadow(ws, [
744
+ // 根 commit 不展开成「全部新增」;签名 / relative 等用户配置不能混进输出
745
+ "-c", "log.showRoot=false", "-c", "log.showSignature=false", "-c", "diff.relative=false",
746
+ "log", "-z", "--topo-order", "--reverse", "--raw", "--numstat", "-M", "--no-abbrev", "--no-color",
747
+ "--format=%x1e%H%x1f%T%x1f%P%x1f%ct%x1f%B%x1f",
748
+ head, ...(since ? [`^${since}`] : []), "--",
749
+ ]);
750
+ const rounds: GrowthRound[] = [];
751
+ for (const c of parseRoundLog(r.stdout.toString("utf8"))) {
752
+ const changes = c.changes.slice(0, MAX_CHANGES_PER_ROUND);
753
+ rounds.push({
754
+ commit: c.commit,
755
+ parent: c.parent,
756
+ tree: c.tree,
757
+ parentTree: (c.parent && knownTrees.get(c.parent)) || ws.emptyTree,
758
+ ts: c.ts,
759
+ ...parseRoundMessage(c.message),
760
+ changes,
761
+ truncated: c.changes.length > changes.length || undefined,
762
+ initial: c.parent ? undefined : true,
763
+ stats: summarize(c.changes),
764
+ });
765
+ knownTrees.set(c.commit, c.tree);
766
+ }
767
+ return rounds;
768
+ }
769
+
770
+ /** 整条链(缓存):链头没变直接用;只追加了新 commit 就增量读;链被改写(理论上不会)整条重读 */
771
+ async function loadRounds(ws: WorkspaceState): Promise<GrowthRound[]> {
772
+ const head = await readHead(ws);
773
+ if (!head) {
774
+ ws.rounds = [];
775
+ ws.loadedHead = undefined;
776
+ return ws.rounds;
777
+ }
778
+ if (ws.rounds && ws.loadedHead === head.commit) return ws.rounds;
779
+ const known = ws.rounds && ws.loadedHead ? ws.rounds : null;
780
+ const trees = new Map((known ?? []).map((round) => [round.commit, round.tree]));
781
+ let rounds = await logRounds(ws, head.commit, known ? ws.loadedHead : undefined, trees);
782
+ if (known && rounds.length && rounds[0].parent !== ws.loadedHead) rounds = await logRounds(ws, head.commit, undefined, new Map());
783
+ else if (known) rounds = [...known, ...rounds];
784
+ ws.rounds = rounds;
785
+ ws.loadedHead = head.commit;
786
+ return rounds;
787
+ }
788
+
789
+ function sameSessionName(a: string, b: string): boolean {
790
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
791
+ }
792
+
793
+ /** 时间轴:默认只取某个会话的 commit;省略 session 取工作区全部。只读:还没提交过的工作区直接返回空(不创建 .git) */
794
+ export async function readRounds(cwdValue: string, session?: string): Promise<GrowthRound[]> {
568
795
  const cwd = await fs.realpath(path.resolve(cwdValue)).catch(() => path.resolve(cwdValue));
569
796
  const key = workspaceKey(cwd);
570
797
  if (!registry.workspaces.has(key)) {
571
- const exists = await fs.stat(path.join(growthRoot(), key, "ledger.jsonl")).then(() => true, () => false);
572
- if (!exists) return [];
798
+ const gitDir = await resolveGitDirFast(cwd);
799
+ if (!gitDir || !(await refExistsFast(gitDir, `${GROWTH_REF_PREFIX}/${key}`))) return [];
573
800
  }
574
801
  const ws = await getWorkspace(cwd);
575
- if (!session) return [...ws.steps];
576
- return ws.steps.filter((s) => sameSession(s.session, session));
577
- }
578
-
579
- export async function hasSessionSteps(cwdValue: string, session: string): Promise<boolean> {
580
- const ws = await getWorkspace(cwdValue);
581
- return ws.steps.some((s) => sameSession(s.session, session));
802
+ const rounds = await loadRounds(ws);
803
+ if (!session) return [...rounds];
804
+ const name = sessionName(session);
805
+ return rounds.filter((round) => sameSessionName(round.session, name));
582
806
  }
583
807
 
584
808
  /** 某个 tree 的文件清单 */