agentbox-app 0.1.6

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 (302) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +176 -0
  3. package/bin/agentbox.mjs +103 -0
  4. package/briefs/adhd-mode.md +5 -0
  5. package/briefs/message-rules.md +95 -0
  6. package/briefs/worker.md +230 -0
  7. package/briefs/writing-rules.md +336 -0
  8. package/main/account-discovery.mjs +145 -0
  9. package/main/account-tooling.mjs +261 -0
  10. package/main/agent-activity.mjs +56 -0
  11. package/main/agent-files.mjs +422 -0
  12. package/main/agent-schedule.mjs +254 -0
  13. package/main/agent-sessions.mjs +435 -0
  14. package/main/agents.mjs +1095 -0
  15. package/main/analytics.mjs +206 -0
  16. package/main/answer-modes.mjs +206 -0
  17. package/main/approval-prompt-server.mjs +160 -0
  18. package/main/approvals.mjs +487 -0
  19. package/main/artifact-path.mjs +144 -0
  20. package/main/claude-bin.mjs +311 -0
  21. package/main/claude-input.mjs +117 -0
  22. package/main/claude-models.mjs +244 -0
  23. package/main/claude-plan.mjs +148 -0
  24. package/main/claude-usage.mjs +149 -0
  25. package/main/code-change.mjs +383 -0
  26. package/main/code-file.mjs +116 -0
  27. package/main/codex-account.mjs +146 -0
  28. package/main/codex-app-server.mjs +796 -0
  29. package/main/codex-approvals.mjs +712 -0
  30. package/main/codex-bin.mjs +261 -0
  31. package/main/codex-compaction.mjs +49 -0
  32. package/main/codex-models.mjs +194 -0
  33. package/main/codex-session.mjs +797 -0
  34. package/main/codex-threads.mjs +268 -0
  35. package/main/codex-usage-file.mjs +192 -0
  36. package/main/codex-usage.mjs +149 -0
  37. package/main/codex-watch.mjs +35 -0
  38. package/main/codex.mjs +757 -0
  39. package/main/config.mjs +345 -0
  40. package/main/copy-selection.mjs +55 -0
  41. package/main/crash-report.mjs +158 -0
  42. package/main/demo.mjs +254 -0
  43. package/main/dev-window.mjs +103 -0
  44. package/main/doc-file.mjs +99 -0
  45. package/main/doc-scheme.mjs +126 -0
  46. package/main/first-run.mjs +190 -0
  47. package/main/folders.mjs +91 -0
  48. package/main/fresh-user.mjs +120 -0
  49. package/main/git-change.mjs +323 -0
  50. package/main/img-scheme.mjs +90 -0
  51. package/main/instruction-settings.mjs +205 -0
  52. package/main/ipc.mjs +1558 -0
  53. package/main/live-replies.mjs +77 -0
  54. package/main/machine.mjs +113 -0
  55. package/main/main.mjs +972 -0
  56. package/main/menu.mjs +177 -0
  57. package/main/notify.mjs +155 -0
  58. package/main/open-source-file.mjs +8 -0
  59. package/main/project-identity.mjs +149 -0
  60. package/main/provider-commands.mjs +32 -0
  61. package/main/rail-note.mjs +74 -0
  62. package/main/repeats.mjs +261 -0
  63. package/main/row-label.mjs +185 -0
  64. package/main/screen-detail.mjs +44 -0
  65. package/main/serve.mjs +262 -0
  66. package/main/settings.mjs +912 -0
  67. package/main/staleness.mjs +76 -0
  68. package/main/store/chats.mjs +188 -0
  69. package/main/store/home.mjs +383 -0
  70. package/main/store/migrate.mjs +40 -0
  71. package/main/store/paths.mjs +82 -0
  72. package/main/store/pending-writes.mjs +122 -0
  73. package/main/store/project-lock.mjs +200 -0
  74. package/main/store/project.mjs +523 -0
  75. package/main/store/queues.mjs +31 -0
  76. package/main/store/work-items.mjs +299 -0
  77. package/main/store/write-queue.mjs +14 -0
  78. package/main/store-modules.mjs +23 -0
  79. package/main/store.mjs +1269 -0
  80. package/main/supervisor.mjs +6875 -0
  81. package/main/task-commands.mjs +161 -0
  82. package/main/task-folders-offthread.mjs +84 -0
  83. package/main/task-folders-thread.mjs +13 -0
  84. package/main/task-folders.mjs +409 -0
  85. package/main/task-remote-control.mjs +65 -0
  86. package/main/task-terminals.mjs +169 -0
  87. package/main/terminal-access.mjs +6 -0
  88. package/main/updater.mjs +240 -0
  89. package/main/zoom-keys.mjs +54 -0
  90. package/main/zoom-readout.mjs +20 -0
  91. package/mcp/agentbox-mcp.sh +34 -0
  92. package/mcp/core/account.mjs +77 -0
  93. package/mcp/core/chat.mjs +61 -0
  94. package/mcp/core/documents.mjs +238 -0
  95. package/mcp/core/page.mjs +152 -0
  96. package/mcp/core/products.mjs +110 -0
  97. package/mcp/core/skills.mjs +126 -0
  98. package/mcp/core/work.mjs +377 -0
  99. package/mcp/stdio.mjs +70 -0
  100. package/mcp/tools.mjs +257 -0
  101. package/package.json +186 -0
  102. package/renderer/dist/assets/Geist-Variable-Bj2R_7yk.woff2 +0 -0
  103. package/renderer/dist/assets/GeistMono-Variable-Dispecij.woff2 +0 -0
  104. package/renderer/dist/assets/addon-fit-YJmn1quW.js +12 -0
  105. package/renderer/dist/assets/addon-serialize-CsFQTvP_.js +13 -0
  106. package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
  107. package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
  108. package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
  109. package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
  110. package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
  111. package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
  112. package/renderer/dist/assets/gouache-valley-ChSu14-t.webp +0 -0
  113. package/renderer/dist/assets/gouache-valley-d9qasI6k.webp +0 -0
  114. package/renderer/dist/assets/gouache-valley-sm-CdLjT5Ec.webp +0 -0
  115. package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
  116. package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
  117. package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
  118. package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
  119. package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
  120. package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
  121. package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
  122. package/renderer/dist/assets/index-CwiDv5bL.css +32 -0
  123. package/renderer/dist/assets/index-D9hCAVPl.js +684 -0
  124. package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
  125. package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
  126. package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
  127. package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
  128. package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
  129. package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
  130. package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
  131. package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
  132. package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
  133. package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
  134. package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
  135. package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
  136. package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
  137. package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
  138. package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
  139. package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
  140. package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
  141. package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
  142. package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
  143. package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
  144. package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
  145. package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
  146. package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
  147. package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
  148. package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
  149. package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
  150. package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
  151. package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
  152. package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
  153. package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
  154. package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
  155. package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
  156. package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
  157. package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
  158. package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
  159. package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
  160. package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
  161. package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
  162. package/renderer/dist/assets/source-sans-3-cyrillic-ext-wght-normal-DzyfIafT.woff2 +0 -0
  163. package/renderer/dist/assets/source-sans-3-cyrillic-wght-normal-BMDVbyM7.woff2 +0 -0
  164. package/renderer/dist/assets/source-sans-3-greek-ext-wght-normal-BWSLJLk6.woff2 +0 -0
  165. package/renderer/dist/assets/source-sans-3-greek-wght-normal-C9H9m1vD.woff2 +0 -0
  166. package/renderer/dist/assets/source-sans-3-latin-ext-wght-normal-C8iNium2.woff2 +0 -0
  167. package/renderer/dist/assets/source-sans-3-latin-wght-normal-BqRLTx4X.woff2 +0 -0
  168. package/renderer/dist/assets/source-sans-3-vietnamese-wght-normal-C1uRvKPU.woff2 +0 -0
  169. package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
  170. package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
  171. package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
  172. package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
  173. package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
  174. package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
  175. package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
  176. package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
  177. package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
  178. package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
  179. package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
  180. package/renderer/dist/assets/xterm-BT7tBf8A.js +27 -0
  181. package/renderer/dist/emulator/audio-rules.mjs +74 -0
  182. package/renderer/dist/emulator/data/compression/README.md +11 -0
  183. package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
  184. package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
  185. package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
  186. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  187. package/renderer/dist/emulator/data/cores/README.md +22 -0
  188. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  189. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  190. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  191. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  192. package/renderer/dist/emulator/data/cores/package.json +65 -0
  193. package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
  194. package/renderer/dist/emulator/data/emulator.css +1614 -0
  195. package/renderer/dist/emulator/data/loader.js +170 -0
  196. package/renderer/dist/emulator/data/localization/README.md +63 -0
  197. package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
  198. package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
  199. package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
  200. package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
  201. package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
  202. package/renderer/dist/emulator/data/localization/en-US.json +339 -0
  203. package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
  204. package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
  205. package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
  206. package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
  207. package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
  208. package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
  209. package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
  210. package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
  211. package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
  212. package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
  213. package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
  214. package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
  215. package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
  216. package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
  217. package/renderer/dist/emulator/data/src/GameManager.js +464 -0
  218. package/renderer/dist/emulator/data/src/compression.js +142 -0
  219. package/renderer/dist/emulator/data/src/emulator.js +6158 -0
  220. package/renderer/dist/emulator/data/src/gamepad.js +167 -0
  221. package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
  222. package/renderer/dist/emulator/data/src/shaders.js +236 -0
  223. package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
  224. package/renderer/dist/emulator/data/src/storage.js +125 -0
  225. package/renderer/dist/emulator/data/version.json +5 -0
  226. package/renderer/dist/emulator/host.js +299 -0
  227. package/renderer/dist/emulator/index.html +28 -0
  228. package/renderer/dist/emulator/provenance.json +6 -0
  229. package/renderer/dist/fixtures/onboard/designs/w-n13/the-first-row.html +1 -0
  230. package/renderer/dist/index.html +13 -0
  231. package/scripts/after-install.mjs +59 -0
  232. package/scripts/approval-server.sh +43 -0
  233. package/scripts/before-publish.mjs +133 -0
  234. package/shared/agent-import.d.mts +13 -0
  235. package/shared/agent-import.mjs +252 -0
  236. package/shared/agents.d.mts +190 -0
  237. package/shared/agents.mjs +937 -0
  238. package/shared/analytics-events.mjs +63 -0
  239. package/shared/answers.d.mts +12 -0
  240. package/shared/answers.mjs +80 -0
  241. package/shared/artifact-keys.d.mts +28 -0
  242. package/shared/artifact-keys.mjs +140 -0
  243. package/shared/bridge-map.mjs +128 -0
  244. package/shared/claude-commands.d.mts +20 -0
  245. package/shared/claude-commands.generated.mjs +149 -0
  246. package/shared/claude-commands.mjs +131 -0
  247. package/shared/claude-models.generated.d.mts +15 -0
  248. package/shared/claude-models.generated.mjs +26 -0
  249. package/shared/claude-usage.d.mts +16 -0
  250. package/shared/claude-usage.mjs +172 -0
  251. package/shared/codex-commands.d.mts +4 -0
  252. package/shared/codex-commands.mjs +17 -0
  253. package/shared/codex-import.d.mts +38 -0
  254. package/shared/codex-import.mjs +232 -0
  255. package/shared/codex-modes.mjs +159 -0
  256. package/shared/codex-usage.mjs +180 -0
  257. package/shared/contracts.mjs +412 -0
  258. package/shared/crash-scrub.mjs +182 -0
  259. package/shared/dashboard.mjs +481 -0
  260. package/shared/dead-run-trace.mjs +127 -0
  261. package/shared/demo-world.mjs +415 -0
  262. package/shared/effort-levels.d.mts +15 -0
  263. package/shared/effort-levels.mjs +76 -0
  264. package/shared/engines.d.mts +62 -0
  265. package/shared/engines.mjs +389 -0
  266. package/shared/first-run-examples.mjs +60 -0
  267. package/shared/first-run-line.mjs +149 -0
  268. package/shared/first-run-practice.d.mts +32 -0
  269. package/shared/first-run-practice.mjs +254 -0
  270. package/shared/first-run-shape.mjs +100 -0
  271. package/shared/fresh-user-home.mjs +189 -0
  272. package/shared/hook-failure.mjs +22 -0
  273. package/shared/install-id.mjs +39 -0
  274. package/shared/instruction-defaults.json +5 -0
  275. package/shared/library-folder.mjs +28 -0
  276. package/shared/notify-rules.mjs +120 -0
  277. package/shared/product-name.d.mts +6 -0
  278. package/shared/product-name.mjs +144 -0
  279. package/shared/project-folder-check.d.mts +22 -0
  280. package/shared/project-folder-check.mjs +152 -0
  281. package/shared/provider-commands.d.mts +8 -0
  282. package/shared/provider-commands.mjs +58 -0
  283. package/shared/rank.d.mts +19 -0
  284. package/shared/rank.mjs +146 -0
  285. package/shared/recovery.mjs +26 -0
  286. package/shared/referenced-files.d.mts +11 -0
  287. package/shared/referenced-files.mjs +122 -0
  288. package/shared/repeats.d.mts +45 -0
  289. package/shared/repeats.mjs +193 -0
  290. package/shared/schemes.mjs +33 -0
  291. package/shared/settings-terminal.d.mts +1 -0
  292. package/shared/settings-terminal.mjs +14 -0
  293. package/shared/side-build.mjs +57 -0
  294. package/shared/spawn-trouble.d.mts +8 -0
  295. package/shared/spawn-trouble.mjs +599 -0
  296. package/shared/terminal-state.d.mts +1 -0
  297. package/shared/terminal-state.mjs +58 -0
  298. package/shared/usage.d.mts +53 -0
  299. package/shared/usage.mjs +319 -0
  300. package/shared/work-items.mjs +561 -0
  301. package/shared/work-lines.mjs +360 -0
  302. package/worker-permissions.json +6 -0
@@ -0,0 +1,238 @@
1
+ // Documents: everything the store holds that is not code.
2
+ //
3
+ // Three functions cover the ICP, personas, transcripts, findings, reports,
4
+ // notes, and designs, because on disk all of them already ARE the same thing: a
5
+ // file at a path with an envelope in index.json. A read_persona / read_report /
6
+ // read_transcript surface would be three more things to maintain that a path
7
+ // already distinguishes, and it would need a fourth the first time the founder
8
+ // invents a kind of document nobody anticipated.
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import crypto from 'node:crypto';
13
+ import { readIndex, readCreationContent, saveCreation, setCreationStatus, CREATION_STATUSES } from '../../main/store/project.mjs';
14
+ import { runForProject } from '../../main/store/queues.mjs';
15
+ import { assertInside, deniesGitOrEnvPath } from '../../main/store/paths.mjs';
16
+ import { AGENT_CREATION_KINDS, SCHEMA_VERSION } from '../../shared/contracts.mjs';
17
+ import { libraryFolderOf } from '../../shared/library-folder.mjs';
18
+ import { resolveProduct } from './account.mjs';
19
+
20
+ const MAX_CONTENT_BYTES = 2 * 1024 * 1024;
21
+
22
+ // NOT EVERY DOCUMENT IS IN THE CATALOG, and pretending otherwise is the fastest
23
+ // way to make this server lie. The `onboard` product on disk right now has eight
24
+ // persona files and exactly one catalog entry: skills write files directly, sync
25
+ // brings files in, and the founder edits by hand. A reader that consulted only
26
+ // index.json would report that seven of her personas do not exist, which is
27
+ // worse than useless, because it reads as an authoritative answer.
28
+ //
29
+ // So the catalog is treated as what it is, an INDEX OVER the files rather than
30
+ // the set of them, and the disk is walked alongside it. The walk is narrow on
31
+ // purpose: prose only, and never into the directories that hold code, run
32
+ // artifacts, or the store's own bookkeeping, which are large, uninteresting to a
33
+ // reader, and served by better tools elsewhere.
34
+ const READABLE_EXT = new Set(['.md', '.html', '.txt', '.csv']);
35
+ const SKIP_DIRS = new Set([
36
+ 'creations', 'runs', 'workspace', 'records', 'chats', 'databases', 'deployments',
37
+ 'node_modules', '.git', 'dist', 'build', '.next',
38
+ ]);
39
+ const MAX_WALK_ENTRIES = 2000;
40
+
41
+ function walkFiles(root) {
42
+ const out = [];
43
+ const stack = [''];
44
+ while (stack.length && out.length < MAX_WALK_ENTRIES) {
45
+ const rel = stack.pop();
46
+ let entries = [];
47
+ try { entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true }); } catch { continue; }
48
+ for (const e of entries) {
49
+ if (e.name.startsWith('.')) continue;
50
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
51
+ if (e.isDirectory()) {
52
+ if (!SKIP_DIRS.has(e.name)) stack.push(childRel);
53
+ continue;
54
+ }
55
+ if (!e.isFile() || !READABLE_EXT.has(path.extname(e.name).toLowerCase())) continue;
56
+ out.push(childRel);
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+
62
+ // What a path implies about how it should be read. One rule, used both for
63
+ // files found on disk and for documents written through this server, so the two
64
+ // can never disagree about the same filename.
65
+ function kindForPath(rel) {
66
+ return /\.html?$/i.test(rel || '') ? 'design' : 'markdown';
67
+ }
68
+
69
+ function fileView(root, rel) {
70
+ let updatedAt = null;
71
+ try { updatedAt = new Date(fs.statSync(path.join(root, rel)).mtimeMs).toISOString(); } catch { /* raced a delete */ }
72
+ return {
73
+ id: null,
74
+ title: path.basename(rel, path.extname(rel)),
75
+ kind: kindForPath(rel),
76
+ path: rel,
77
+ dir: null,
78
+ folder: rel.includes('/') ? rel.split('/')[0] : null,
79
+ status: 'current',
80
+ replacedBy: null,
81
+ updatedAt,
82
+ cataloged: false,
83
+ };
84
+ }
85
+
86
+ function envelopeView(c) {
87
+ return {
88
+ id: c.id,
89
+ title: c.title,
90
+ kind: c.kind,
91
+ path: c.path ?? null,
92
+ dir: c.dir ?? null,
93
+ folder: libraryFolderOf(c) || null,
94
+ status: c.status ?? 'current',
95
+ replacedBy: c.replacedBy ?? null,
96
+ updatedAt: c.updatedAt ?? (c.ts ? new Date(c.ts).toISOString() : null),
97
+ cataloged: true,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Everything readable in a product: catalog entries, plus prose files on disk
103
+ * that no catalog entry points at. Never opens a document.
104
+ *
105
+ * A catalog entry always wins over the bare file it describes, since it carries
106
+ * the title and status a person chose.
107
+ */
108
+ export function listDocuments(ref, { folder = null, kind = null, status = null, pathPrefix = null, query = null, catalogedOnly = false } = {}) {
109
+ const project = resolveProduct(ref);
110
+ const idx = readIndex(project.dir);
111
+ const wanted = query ? String(query).toLowerCase() : null;
112
+
113
+ const cataloged = (idx.creations ?? []).filter((c) => !c.deleted).map(envelopeView);
114
+ const seen = new Set(cataloged.map((c) => c.path).filter(Boolean));
115
+
116
+ const loose = catalogedOnly ? [] : walkFiles(project.dir)
117
+ .filter((rel) => !seen.has(rel))
118
+ .map((rel) => fileView(project.dir, rel));
119
+
120
+ return [...cataloged, ...loose]
121
+ .filter((c) => (!folder || c.folder === folder)
122
+ && (!kind || c.kind === kind)
123
+ && (!status || c.status === status)
124
+ && (!pathPrefix || (c.path ?? '').startsWith(pathPrefix))
125
+ && (!wanted || c.title.toLowerCase().includes(wanted) || (c.path ?? '').toLowerCase().includes(wanted)))
126
+ .sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? '')));
127
+ }
128
+
129
+ /** One document, by id or by path. */
130
+ export function readDocument(ref, { id = null, path: relPath = null } = {}) {
131
+ const project = resolveProduct(ref);
132
+ if (!id && !relPath) throw new Error('read_document needs either an id or a path');
133
+ const idx = readIndex(project.dir);
134
+
135
+ const envelope = (idx.creations ?? []).find((c) => !c.deleted && (id ? c.id === id : c.path === relPath));
136
+ if (!envelope) {
137
+ // An id is a catalog concept, so a miss there is genuinely a miss. A PATH is
138
+ // not: the file may be sitting right there uncataloged (see the note above
139
+ // walkFiles), and refusing to read a file that exists because an index does
140
+ // not mention it would be an obviously wrong answer to an obviously fair
141
+ // question. Containment and the .git/.env deny-list still apply, since they
142
+ // are what make an agent-supplied path safe to open at all.
143
+ if (id) throw new Error(`no document with id ${id} in ${project.name}`);
144
+ if (deniesGitOrEnvPath(relPath)) throw new Error(`refusing to read a .git/.env path: ${relPath}`);
145
+ let content;
146
+ try {
147
+ content = fs.readFileSync(assertInside(project.dir, relPath), 'utf8');
148
+ } catch {
149
+ throw new Error(`no document at path ${relPath} in ${project.name} (list_documents shows what exists)`);
150
+ }
151
+ return { ...fileView(project.dir, relPath), content };
152
+ }
153
+ if (envelope.dir) {
154
+ throw new Error(`"${envelope.title}" is folder-backed, which this server does not serve; its files live at ${path.join(project.dir, envelope.dir)}`);
155
+ }
156
+
157
+ return { ...envelopeView(envelope), content: readCreationContent(project.dir, envelope) };
158
+ }
159
+
160
+ /**
161
+ * Create, update, retitle, move, or restatus a document.
162
+ *
163
+ * One function rather than five, and content is OPTIONAL: omitting it means a
164
+ * metadata-only change, which is how a rename or a status change happens without
165
+ * having to read a document back and write it out unchanged (the version of that
166
+ * which loses a document to a race is exactly the thing this whole design is
167
+ * trying to stop happening).
168
+ */
169
+ export async function writeDocument(ref, { id = null, path: relPath = null, title = null, kind = null, content = null, status = null } = {}) {
170
+ const project = resolveProduct(ref);
171
+
172
+ return runForProject(project.id, async () => {
173
+ const idx = readIndex(project.dir);
174
+ const existing = id
175
+ ? (idx.creations ?? []).find((c) => !c.deleted && c.id === id)
176
+ : (idx.creations ?? []).find((c) => !c.deleted && c.path === relPath);
177
+
178
+ if (id && !existing) throw new Error(`no document with id ${id} in ${project.name}`);
179
+ if (existing?.dir) throw new Error(`"${existing.title}" is folder-backed; this server does not write into folder-backed artifacts`);
180
+
181
+ const nextPath = relPath ?? existing?.path;
182
+ if (!nextPath) throw new Error('a new document needs a path, e.g. "reports/first-run.md" or "personas/marcus.md"');
183
+ if (deniesGitOrEnvPath(nextPath)) throw new Error(`refusing to write a .git/.env path: ${nextPath}`);
184
+
185
+ // THE PATH ALREADY SAYS WHAT THIS IS, so a caller that did not name a kind
186
+ // gets the one its extension implies rather than a blanket 'markdown'. Two
187
+ // real failures made this worth deriving:
188
+ //
189
+ // A document written to a .html path and catalogued as markdown renders as
190
+ // escaped source, because the app decides by kind (`kind === 'design' ||
191
+ // kind === 'canvas'`), so the founder who asked for html got a page of
192
+ // literal tags. That punished obeying her, which is the worst kind of trap:
193
+ // the rule looks followed and the result is worse than ignoring it.
194
+ //
195
+ // And the old signature default meant kind was NEVER absent, so the
196
+ // `existing?.kind` fallback below could not fire: renaming a persona or a
197
+ // transcript silently demoted it to markdown.
198
+ //
199
+ // Uncataloged files on disk are already read this way (fileView above), so
200
+ // this makes a written document and a found one agree.
201
+ const nextKind = kind ?? existing?.kind ?? kindForPath(nextPath);
202
+ if (!AGENT_CREATION_KINDS.includes(nextKind)) {
203
+ throw new Error(`kind must be one of ${AGENT_CREATION_KINDS.join(', ')} (got "${nextKind}")`);
204
+ }
205
+ const nextTitle = title ?? existing?.title;
206
+ if (!nextTitle) throw new Error('a new document needs a title');
207
+
208
+ if (content != null) {
209
+ if (typeof content !== 'string') throw new Error('content must be a string');
210
+ const bytes = Buffer.byteLength(content, 'utf8');
211
+ if (bytes > MAX_CONTENT_BYTES) throw new Error(`document is ${bytes} bytes (max ${MAX_CONTENT_BYTES})`);
212
+ }
213
+ // A metadata-only write on a document that does not exist yet would create a
214
+ // catalog entry pointing at nothing, which reads as a document until someone
215
+ // opens it. Refuse instead.
216
+ if (content == null && !existing) throw new Error('a new document needs content');
217
+
218
+ const envelope = {
219
+ ...(existing ?? {}),
220
+ id: existing?.id ?? `a-${crypto.randomBytes(4).toString('hex')}`,
221
+ kind: nextKind,
222
+ title: nextTitle,
223
+ path: nextPath,
224
+ ts: existing?.ts ?? Date.now(),
225
+ updatedAt: new Date().toISOString(),
226
+ schemaVersion: SCHEMA_VERSION,
227
+ };
228
+ // A move is a path change; the old file is deliberately left where it is
229
+ // rather than deleted, because losing a document to a typo'd move is a worse
230
+ // outcome than an orphan file the founder can see and remove.
231
+ const moved = existing && existing.path !== nextPath;
232
+
233
+ saveCreation(project.dir, envelope, content);
234
+ if (status && CREATION_STATUSES.includes(status)) setCreationStatus(project.dir, envelope.id, { status });
235
+
236
+ return { ...envelopeView(readIndex(project.dir).creations.find((c) => c.id === envelope.id)), created: !existing, moved: moved ? existing.path : null };
237
+ });
238
+ }
@@ -0,0 +1,152 @@
1
+ // LOOKING AT A PAGE A WORKER JUST BUILT, AND ONLY AT A LOCAL ONE.
2
+ //
3
+ // Where this comes from (w-5ebf7bf7bb, MP-09). The terminal clients this app is
4
+ // keeping parity with mostly ship a browser now. Hers, 2026-09-23: a worker
5
+ // driving a browser of OURS is good and should happen; a worker driving the
6
+ // browser she is signed into is not, and she never wants that ability to exist,
7
+ // because that browser holds her logins, her cookies and everything else. That
8
+ // half is a rule in CLAUDE.md with a test behind it.
9
+ //
10
+ // The browser she meant is gone. She had the whole rest screen and the browser
11
+ // inside the app deleted the day before (w-030026f226, for the open source cut),
12
+ // so there is no pane left to drive. This is the smallest honest thing that
13
+ // still delivers what a worker actually needs: start your app, look at it, show
14
+ // her the picture. No window, no logins, nothing of hers.
15
+ //
16
+ // LOCAL ONLY, AND THE RULE IS THE POINT RATHER THAN A SETTING. A worker that can
17
+ // fetch any address is a worker that can post anything it has read to any
18
+ // address. Loopback cannot leave the machine, so the capability is bounded by
19
+ // what it can reach rather than by whoever remembers to review a prompt. If
20
+ // this is ever widened it is a permission design and hers to approve, which is
21
+ // what she said in the same breath as approving the rest of it.
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { spawn } from 'node:child_process';
25
+ import { resolveAccount, resolveProduct } from './account.mjs';
26
+
27
+ /** Where a captured picture lands, beside everything else she is shown. */
28
+ const SHOTS = 'attachments';
29
+
30
+ /** Only loopback. Anything else is refused with the reason. */
31
+ export function localTarget(url) {
32
+ let parsed;
33
+ try { parsed = new URL(String(url ?? '')); } catch { throw Error(`Not a URL: ${url}`); }
34
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
35
+ throw Error(`Only http and https can be opened, not ${parsed.protocol.replace(':', '')}.`);
36
+ }
37
+ const host = parsed.hostname.replace(/^\[|\]$/g, '');
38
+ const local = host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0.0.0.0';
39
+ if (!local) {
40
+ throw Error(`Only a local address can be opened, and ${parsed.hostname} is not one. This exists so a worker can look at the app it just started; anything wider is a permission decision and not this tool's to make.`);
41
+ }
42
+ return parsed.href;
43
+ }
44
+
45
+ /** Chrome, wherever this Mac keeps it, or null when it has none. */
46
+ export function findChrome(candidates = [
47
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
48
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
49
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
50
+ ]) {
51
+ for (const bin of candidates) { try { if (fs.statSync(bin).isFile()) return bin; } catch { /* next */ } }
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Open the page headlessly, photograph it, and read its words back.
57
+ *
58
+ * `capture` is injected so this is testable without a browser on the machine:
59
+ * the real one below drives Chrome over the devtools protocol, which is the
60
+ * same mechanism the design rounds in this repo already use.
61
+ */
62
+ export async function lookAtPage({ product, url, width = 1280, height = 900, capture = captureWithChrome, now = Date.now } = {}) {
63
+ const target = localTarget(url);
64
+ // The account scope has to be set before a product can be found, and this
65
+ // module is reachable without going through the tool surface that does it.
66
+ resolveAccount();
67
+ const found = resolveProduct(product);
68
+ const dir = found.dir;
69
+ const slug = found.id ?? path.basename(found.dir);
70
+ const shot = await capture({ url: target, width, height });
71
+ if (!shot?.png) throw Error(shot?.error ?? 'The page could not be opened.');
72
+
73
+ const name = `page-${new Date(now()).toISOString().replace(/[:.]/g, '-')}.png`;
74
+ const rel = path.join(SHOTS, name);
75
+ fs.mkdirSync(path.join(dir, SHOTS), { recursive: true });
76
+ fs.writeFileSync(path.join(dir, rel), shot.png);
77
+ return {
78
+ product: slug,
79
+ url: target,
80
+ picture: rel,
81
+ title: shot.title ?? '',
82
+ text: (shot.text ?? '').slice(0, 20_000),
83
+ };
84
+ }
85
+
86
+ /**
87
+ * The real capture. Headless Chrome over the devtools protocol: navigate, wait
88
+ * for the load event, read the rendered text, take the picture. Nothing is
89
+ * stored in the browser, and it is thrown away with the profile directory.
90
+ */
91
+ export async function captureWithChrome({ url, width, height, chrome = findChrome(), timeoutMs = 20_000 }) {
92
+ if (!chrome) return { error: 'No Chrome, Chromium or Edge on this Mac, so there is nothing to open the page with.' };
93
+ const profile = fs.mkdtempSync(path.join(process.env.TMPDIR ?? '/tmp', 'look-at-page-'));
94
+ const child = spawn(chrome, [
95
+ '--headless=new', '--remote-debugging-port=0', `--user-data-dir=${profile}`,
96
+ '--no-first-run', '--no-default-browser-check', '--hide-scrollbars',
97
+ `--window-size=${width},${height}`, 'about:blank',
98
+ ], { stdio: ['ignore', 'ignore', 'pipe'] });
99
+ const done = () => { try { child.kill('SIGKILL'); } catch { /* already gone */ } try { fs.rmSync(profile, { recursive: true, force: true }); } catch { /* best effort */ } };
100
+
101
+ try {
102
+ const wsUrl = await new Promise((resolve, reject) => {
103
+ let buf = '';
104
+ const timer = setTimeout(() => reject(Error('the browser never started')), timeoutMs);
105
+ child.stderr.on('data', (d) => {
106
+ buf += d.toString();
107
+ const m = buf.match(/ws:\/\/[^\s]+/);
108
+ if (m) { clearTimeout(timer); resolve(m[0]); }
109
+ });
110
+ child.once('exit', () => { clearTimeout(timer); reject(Error('the browser exited before it was ready')); });
111
+ });
112
+ const ws = new WebSocket(wsUrl);
113
+ await new Promise((r, j) => { ws.onopen = r; ws.onerror = () => j(Error('could not talk to the browser')); });
114
+ let id = 1;
115
+ const waiting = new Map();
116
+ ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && waiting.has(m.id)) { waiting.get(m.id)(m); waiting.delete(m.id); } };
117
+ const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => {
118
+ const mine = id++;
119
+ waiting.set(mine, (m) => (m.error ? reject(Error(`${method}: ${m.error.message}`)) : resolve(m.result)));
120
+ ws.send(JSON.stringify({ id: mine, method, params, sessionId }));
121
+ });
122
+ const { targetId } = await send('Target.createTarget', { url: 'about:blank' });
123
+ const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
124
+ const call = (m, p) => send(m, p, sessionId);
125
+ await call('Page.enable');
126
+ await call('Runtime.enable');
127
+ await call('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 2, mobile: false });
128
+ await call('Page.navigate', { url });
129
+ // The load event, or the timeout, whichever comes first: a page that never
130
+ // finishes loading is still worth photographing.
131
+ await new Promise((resolve) => {
132
+ const stop = setTimeout(resolve, Math.min(timeoutMs, 10_000));
133
+ const onMsg = (e) => {
134
+ const m = JSON.parse(e.data);
135
+ if (m.method === 'Page.loadEventFired' && m.sessionId === sessionId) { clearTimeout(stop); ws.removeEventListener('message', onMsg); resolve(); }
136
+ };
137
+ ws.addEventListener('message', onMsg);
138
+ });
139
+ await new Promise((r) => setTimeout(r, 400));
140
+ const read = await call('Runtime.evaluate', {
141
+ expression: '({ title: document.title, text: document.body ? document.body.innerText : "" })',
142
+ returnByValue: true,
143
+ });
144
+ const { data } = await call('Page.captureScreenshot', { format: 'png' });
145
+ ws.close();
146
+ return { png: Buffer.from(data, 'base64'), ...(read?.result?.value ?? {}) };
147
+ } catch (error) {
148
+ return { error: `The page could not be opened: ${error.message}` };
149
+ } finally {
150
+ done();
151
+ }
152
+ }
@@ -0,0 +1,110 @@
1
+ // Products: one per idea the founder is raising. A product is a project
2
+ // directory in the store, which is what the app has always called it.
3
+ //
4
+ // The one genuinely new field here is repoPath. Product CODE lives in an
5
+ // ordinary git checkout outside the store, written by the calling agent with
6
+ // its own editing tools, so a product record needs to say where. The store
7
+ // holds the thinking; the repo holds the software.
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { listProjects, createProject, loadProjectAt, patchProjectAt, readIndex, readJson, starterNotesDoc, saveCreation } from '../../main/store/project.mjs';
12
+ import { readWorkItems } from '../../main/store/work-items.mjs';
13
+ import { resolveProduct } from './account.mjs';
14
+ import { SCHEMA_VERSION } from '../../shared/contracts.mjs';
15
+
16
+ // Fields the MCP may set on project.json beyond what the app already writes.
17
+ const PRODUCT_FIELDS = ['name', 'oneLiner', 'repoPath', 'archived'];
18
+
19
+ function describe(project, { now = Date.now() } = {}) {
20
+ const idx = readIndex(project.dir);
21
+ const meta = readJson(path.join(project.dir, 'project.json'), {}) ?? {};
22
+ let work = [];
23
+ try { work = readWorkItems(project.dir, now); } catch { /* a product with no ledger yet */ }
24
+
25
+ const live = work.filter((w) => w.status !== 'done');
26
+ return {
27
+ id: project.id,
28
+ name: project.name,
29
+ dir: project.dir,
30
+ oneLiner: meta.oneLiner ?? null,
31
+ repoPath: meta.repoPath ?? null,
32
+ archived: meta.archived === true,
33
+ documents: (idx.creations ?? []).filter((c) => !c.deleted).length,
34
+ workItems: { open: live.filter((w) => w.status === 'open').length, claimed: live.filter((w) => w.status === 'claimed').length, blocked: live.filter((w) => w.status === 'blocked').length },
35
+ createdAt: project.createdAt ?? null,
36
+ updatedAt: project.updatedAt ?? null,
37
+ };
38
+ }
39
+
40
+ /** Every product in one call: the cross-product view. */
41
+ export function listProducts({ includeArchived = false, now = Date.now() } = {}) {
42
+ return listProjects()
43
+ .map((p) => describe(p, { now }))
44
+ .filter((p) => includeArchived || !p.archived);
45
+ }
46
+
47
+ export function getProduct(ref, { now = Date.now() } = {}) {
48
+ return describe(resolveProduct(ref), { now });
49
+ }
50
+
51
+ /**
52
+ * Start a new product. Creates the project directory the app already
53
+ * understands, plus the starter notes doc, so a product made from Claude Code is
54
+ * indistinguishable from one made in the app.
55
+ */
56
+ export function createProduct(name, { oneLiner = null, repoPath = null } = {}) {
57
+ if (!name || !String(name).trim()) throw new Error('a product needs a name');
58
+ const project = createProject(String(name).trim());
59
+
60
+ if (oneLiner || repoPath) patchProjectAt(project.dir, cleanPatch({ oneLiner, repoPath }));
61
+
62
+ // The notes doc is the founder's living map, and the app expects one. Written
63
+ // through saveCreation so it lands in the catalog rather than sitting on disk
64
+ // as a file nothing knows about.
65
+ saveCreation(
66
+ project.dir,
67
+ { id: 'a-notes', kind: 'markdown', title: `${project.name} notes`, path: 'notes.md', ts: Date.now(), schemaVersion: SCHEMA_VERSION },
68
+ starterNotesDoc(project.name),
69
+ );
70
+
71
+ return describe({ ...project, dir: project.dir });
72
+ }
73
+
74
+ // A write goes to the product the read found. resolveProduct answers by looking
75
+ // through the records; deriving the folder again from the resolved id asks a
76
+ // DIFFERENT question, and the two answers only agree while every product's id
77
+ // matches its own folder name. That invariant is false in the real store: one
78
+ // product sits in a folder carrying a DIFFERENT product's id, beside the
79
+ // product whose folder really has that id, so every update aimed at the first
80
+ // silently edited the second and reported the second's record back as if it
81
+ // were the one just changed. Nothing errored. Keeping the resolved directory is
82
+ // what makes the read and the write name the same product, and it is the only
83
+ // lookup that still works when two products share an id.
84
+ export function updateProduct(ref, patch = {}) {
85
+ const project = resolveProduct(ref);
86
+ const clean = cleanPatch(patch);
87
+ if (!Object.keys(clean).length) throw new Error(`nothing to update (recognized fields: ${PRODUCT_FIELDS.join(', ')})`);
88
+ patchProjectAt(project.dir, clean);
89
+ return describe({ ...project, ...loadProjectAt(project.dir) });
90
+ }
91
+
92
+ function cleanPatch(patch) {
93
+ const out = {};
94
+ for (const [k, v] of Object.entries(patch ?? {})) {
95
+ if (!PRODUCT_FIELDS.includes(k) || v == null) continue;
96
+ if (k === 'archived') { out.archived = v === true; continue; }
97
+ if (k === 'repoPath') {
98
+ // Stored as given, but checked: a repo path that does not exist is almost
99
+ // always a typo, and finding out later means an agent looking for code in
100
+ // a directory that was never there.
101
+ const p = path.resolve(String(v).replace(/^~/, process.env.HOME ?? '~'));
102
+ if (!fs.existsSync(p)) throw new Error(`no such directory: ${p} (repoPath should point at the product's git checkout)`);
103
+ out.repoPath = p;
104
+ continue;
105
+ }
106
+ const s = String(v).trim();
107
+ if (s) out[k] = s;
108
+ }
109
+ return out;
110
+ }
@@ -0,0 +1,126 @@
1
+ // Skills: the house method, served to whoever is doing the thinking.
2
+ //
3
+ // These are the SAME files the in-app agent runs on (skills/<name>/SKILL.md),
4
+ // served verbatim. Not a fork, deliberately. A second set written for an external
5
+ // brain would read a little better and then drift, and skill drift has already
6
+ // cost this project real work; one source that improves for both surfaces beats
7
+ // two that agree only on the day they were written.
8
+ //
9
+ // What the calling agent needs on top is not a rewrite, it is one paragraph of
10
+ // orientation: these instructions were written for an agent with tools this
11
+ // server does not have. That paragraph is GENERATED from the live tool list
12
+ // below, so it cannot claim a capability that was removed or miss one that was
13
+ // added.
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
20
+ const SKILLS_DIR = path.resolve(HERE, '../../skills');
21
+
22
+ const NAME_RE = /^[a-z0-9][a-z0-9-]{0,60}$/;
23
+
24
+ // Front matter is `---\nname: x\ndescription: y\n---`. Parsed loosely on
25
+ // purpose: a skill with a malformed header should still be readable, because
26
+ // the prose is the part that matters.
27
+ function frontMatter(text) {
28
+ const match = /^---\n([\s\S]*?)\n---/.exec(text);
29
+ if (!match) return {};
30
+ const out = {};
31
+ for (const line of match[1].split('\n')) {
32
+ const kv = /^([a-zA-Z-]+):\s*(.*)$/.exec(line.trim());
33
+ if (kv) out[kv[1]] = kv[2].trim();
34
+ }
35
+ return out;
36
+ }
37
+
38
+ export function listSkills() {
39
+ let entries = [];
40
+ try {
41
+ entries = fs.readdirSync(SKILLS_DIR, { withFileTypes: true }).filter((e) => e.isDirectory());
42
+ } catch {
43
+ return [];
44
+ }
45
+ return entries.map((e) => {
46
+ const file = path.join(SKILLS_DIR, e.name, 'SKILL.md');
47
+ let text = '';
48
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return null; }
49
+ const fm = frontMatter(text);
50
+ return {
51
+ name: e.name,
52
+ description: fm.description ?? firstLine(text),
53
+ // Enough for an agent to judge whether it wants the whole thing before
54
+ // spending the tokens on it.
55
+ bytes: Buffer.byteLength(text, 'utf8'),
56
+ };
57
+ }).filter(Boolean).sort((a, b) => a.name.localeCompare(b.name));
58
+ }
59
+
60
+ function firstLine(text) {
61
+ const line = text.split('\n').find((l) => l.trim() && !l.startsWith('---') && !l.startsWith('#'));
62
+ return (line ?? '').trim().slice(0, 200);
63
+ }
64
+
65
+ /**
66
+ * One skill, verbatim, with a generated header.
67
+ *
68
+ * `capabilities` is the live list of what this server actually exposes, passed
69
+ * in by the tool layer rather than hard-coded here, so the header and the tool
70
+ * list are the same fact stated once.
71
+ */
72
+ export function fetchSkill(name, { capabilities = [], missing = [] } = {}) {
73
+ if (!NAME_RE.test(String(name ?? ''))) throw new Error(`invalid skill name: ${name}`);
74
+ const file = path.join(SKILLS_DIR, name, 'SKILL.md');
75
+ let text;
76
+ try {
77
+ text = fs.readFileSync(file, 'utf8');
78
+ } catch {
79
+ const names = listSkills().map((s) => s.name).join(', ');
80
+ throw new Error(`no skill called "${name}". Available: ${names}`);
81
+ }
82
+
83
+ const refs = listReferences(name);
84
+ return { name, header: header({ capabilities, missing, references: refs }), content: text, references: refs };
85
+ }
86
+
87
+ // Some skills carry reference files beside SKILL.md (the design skill does).
88
+ // Listed rather than inlined: they are large, and the agent should choose.
89
+ function listReferences(name) {
90
+ const dir = path.join(SKILLS_DIR, name, 'references');
91
+ try {
92
+ return fs.readdirSync(dir).filter((f) => f.endsWith('.md')).map((f) => `${name}/references/${f}`);
93
+ } catch {
94
+ return [];
95
+ }
96
+ }
97
+
98
+ export function fetchReference(rel) {
99
+ const parts = String(rel ?? '').split('/');
100
+ if (parts.length !== 3 || parts[1] !== 'references' || !NAME_RE.test(parts[0]) || !/^[a-zA-Z0-9._-]+\.md$/.test(parts[2])) {
101
+ throw new Error(`invalid reference path: ${rel} (expected "<skill>/references/<file>.md")`);
102
+ }
103
+ const file = path.join(SKILLS_DIR, parts[0], 'references', parts[2]);
104
+ try { return fs.readFileSync(file, 'utf8'); } catch { throw new Error(`no such reference: ${rel}`); }
105
+ }
106
+
107
+ function header({ capabilities, missing, references }) {
108
+ const lines = [
109
+ 'This skill was written for the agent inside the app, which has a larger tool set than this server exposes. The method is what matters and it still applies; the tool names sometimes do not.',
110
+ '',
111
+ `Through this server you have: ${capabilities.join(', ')}.`,
112
+ ];
113
+ if (missing.length) {
114
+ lines.push(
115
+ '',
116
+ `The skill may tell you to use ${missing.join(', ')}. Those are not here. Where it says to provision a database, deploy, push to GitHub, set up analytics, or drive a browser, do it yourself with your own tools and your own accounts, then record what you did as a document.`,
117
+ 'Where it says open_creation, use write_document. Where it says read_creation, use read_document.',
118
+ );
119
+ }
120
+ lines.push(
121
+ '',
122
+ 'The founder is reachable through whatever interface you are running in, not through this server; there is no ask_user here.',
123
+ );
124
+ if (references.length) lines.push('', `Reference files available via fetch_reference: ${references.join(', ')}.`);
125
+ return lines.join('\n');
126
+ }