@pi-unipi/unipi 3.0.0-alpha.2 → 3.0.0-alpha.21

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 (359) hide show
  1. package/CHANGELOG.md +292 -0
  2. package/README.md +8 -11
  3. package/docs/prefix-cache-architecture.md +14 -0
  4. package/package.json +22 -24
  5. package/packages/ask-user/README.md +55 -107
  6. package/packages/ask-user/ask-ui.ts +306 -732
  7. package/packages/ask-user/clipboard.ts +50 -0
  8. package/packages/ask-user/config.ts +74 -78
  9. package/packages/ask-user/package.json +2 -2
  10. package/packages/ask-user/questions.ts +151 -0
  11. package/packages/ask-user/skills/ask-user/SKILL.md +59 -133
  12. package/packages/ask-user/tools.ts +198 -432
  13. package/packages/autocomplete/package.json +1 -1
  14. package/packages/autocomplete/src/constants.ts +38 -43
  15. package/packages/autocomplete/src/sorting.ts +8 -0
  16. package/packages/background-tasks/package.json +2 -2
  17. package/packages/background-tasks/src/config.ts +0 -73
  18. package/packages/background-tasks/src/index.ts +20 -16
  19. package/packages/background-tasks/src/registry.ts +1 -171
  20. package/packages/background-tasks/src/types.ts +0 -32
  21. package/packages/btw/README.md +13 -56
  22. package/packages/btw/extensions/btw.ts +403 -1796
  23. package/packages/btw/package.json +5 -2
  24. package/packages/btw/skills/btw/SKILL.md +7 -143
  25. package/packages/compactor/README.md +56 -287
  26. package/packages/compactor/package.json +5 -9
  27. package/packages/compactor/skills/compactor/SKILL.md +13 -20
  28. package/packages/compactor/src/card.ts +90 -0
  29. package/packages/compactor/src/commands/index.ts +73 -130
  30. package/packages/compactor/src/compaction/compact-args.ts +5 -3
  31. package/packages/compactor/src/compaction/filter-noise.ts +1 -1
  32. package/packages/compactor/src/compaction/hooks.ts +487 -311
  33. package/packages/compactor/src/compaction/source.ts +178 -0
  34. package/packages/compactor/src/compaction/summarize.ts +586 -52
  35. package/packages/compactor/src/config/manager.ts +130 -127
  36. package/packages/compactor/src/config/schema.ts +32 -48
  37. package/packages/compactor/src/index.ts +40 -431
  38. package/packages/compactor/src/info-screen.ts +44 -190
  39. package/packages/compactor/src/session/recall-blocks.ts +1 -1
  40. package/packages/compactor/src/stats.ts +76 -0
  41. package/packages/compactor/src/tools/context-budget.ts +17 -34
  42. package/packages/compactor/src/tools/ctx-doctor.ts +45 -36
  43. package/packages/compactor/src/tools/register.ts +29 -269
  44. package/packages/compactor/src/types.ts +47 -186
  45. package/packages/core/README.md +25 -0
  46. package/packages/core/child-agent.ts +1 -0
  47. package/packages/core/command-echo.ts +63 -0
  48. package/packages/core/compaction-context.ts +41 -0
  49. package/packages/core/constants.ts +8 -38
  50. package/packages/core/events.ts +0 -53
  51. package/packages/core/fusion-status.ts +4 -0
  52. package/packages/core/global.d.ts +10 -0
  53. package/packages/core/harness-messages.ts +392 -0
  54. package/packages/core/index.ts +14 -0
  55. package/packages/core/kanboard-status.ts +49 -0
  56. package/packages/core/long-horizon-owner-status.ts +46 -0
  57. package/packages/core/model-cache.ts +81 -7
  58. package/packages/core/package.json +6 -3
  59. package/packages/core/src/attach/detect.ts +163 -0
  60. package/packages/core/src/child-agent/child-args.ts +31 -0
  61. package/packages/core/src/child-agent/delivery.ts +41 -0
  62. package/packages/core/src/child-agent/index.ts +5 -0
  63. package/packages/core/src/child-agent/readers.ts +203 -0
  64. package/packages/core/src/child-agent/runtime.ts +584 -0
  65. package/packages/core/src/evidence.ts +78 -0
  66. package/packages/core/src/hints/browser.ts +278 -0
  67. package/packages/core/src/hints/crab-data.ts +19 -0
  68. package/packages/core/src/hints/index.ts +865 -0
  69. package/packages/core/src/hints/lines.ts +607 -0
  70. package/packages/core/src/hints/store.ts +120 -0
  71. package/packages/core/src/jev/chatter.ts +24 -0
  72. package/packages/core/src/jev/settings.ts +177 -14
  73. package/packages/core/src/package-colors.ts +2 -0
  74. package/packages/core/src/settings/catalog.ts +39 -15
  75. package/packages/core/src/settings/engine.ts +48 -2
  76. package/packages/core/src/settings/hub.ts +83 -20
  77. package/packages/core/src/settings/open.ts +49 -0
  78. package/packages/core/src/settings/schema.ts +27 -7
  79. package/packages/core/src/tui/hub-kit.ts +18 -5
  80. package/packages/core/src/tui/kit.ts +198 -0
  81. package/packages/core/src/tui/progress.ts +73 -0
  82. package/packages/core/src/turn/arbiter.ts +290 -0
  83. package/packages/footer/README.md +4 -6
  84. package/packages/footer/package.json +3 -3
  85. package/packages/footer/src/glance-editor.ts +19 -11
  86. package/packages/footer/src/help.ts +1 -1
  87. package/packages/footer/src/index.ts +36 -3
  88. package/packages/footer/src/presets.ts +3 -4
  89. package/packages/footer/src/rendering/icons.ts +0 -20
  90. package/packages/footer/src/segments/compactor.ts +52 -142
  91. package/packages/footer/src/segments/status-ext.ts +0 -1
  92. package/packages/fusion/README.md +14 -4
  93. package/packages/fusion/package.json +4 -3
  94. package/packages/fusion/src/child-args.ts +1 -0
  95. package/packages/fusion/src/index.ts +161 -8
  96. package/packages/fusion/src/pi-settings.ts +57 -0
  97. package/packages/fusion/src/picker.ts +186 -51
  98. package/packages/fusion/src/preset.ts +56 -2
  99. package/packages/fusion/src/prompts.ts +1 -1
  100. package/packages/fusion/src/sidekick-runtime.ts +1 -420
  101. package/packages/fusion/src/tools.ts +107 -128
  102. package/packages/fusion/src/transcript.ts +27 -85
  103. package/packages/info-screen/package.json +2 -2
  104. package/packages/input-shortcuts/README.md +18 -30
  105. package/packages/input-shortcuts/package.json +2 -2
  106. package/packages/input-shortcuts/src/burst.ts +88 -0
  107. package/packages/input-shortcuts/src/chord-overlay.ts +20 -70
  108. package/packages/input-shortcuts/src/cleared-input.ts +97 -0
  109. package/packages/input-shortcuts/src/index.ts +99 -193
  110. package/packages/input-shortcuts/src/last-response.ts +40 -0
  111. package/packages/input-shortcuts/src/registers.ts +8 -23
  112. package/packages/input-shortcuts/src/types.ts +4 -16
  113. package/packages/input-shortcuts/src/undo-redo.ts +59 -47
  114. package/packages/kanboard/README.md +51 -47
  115. package/packages/kanboard/index.ts +293 -55
  116. package/packages/kanboard/package.json +8 -8
  117. package/packages/kanboard/skills/kanboard/SKILL.md +61 -27
  118. package/packages/kanboard/src/badges.ts +93 -0
  119. package/packages/kanboard/src/bin.ts +4 -2
  120. package/packages/kanboard/src/commands.ts +218 -84
  121. package/packages/kanboard/src/debug.ts +20 -0
  122. package/packages/kanboard/src/guard.ts +267 -109
  123. package/packages/kanboard/src/monitor.ts +330 -0
  124. package/packages/kanboard/src/notice-buffer.ts +74 -0
  125. package/packages/kanboard/src/progress.ts +27 -0
  126. package/packages/kanboard/src/reminders.ts +234 -0
  127. package/packages/kanboard/src/settings.ts +69 -15
  128. package/packages/kanboard/src/shapes.ts +5 -0
  129. package/packages/long-horizon/index.ts +190 -13
  130. package/packages/long-horizon/package.json +3 -3
  131. package/packages/long-horizon/src/commands.ts +103 -7
  132. package/packages/long-horizon/src/compaction-brief.ts +35 -0
  133. package/packages/long-horizon/src/engine/continuation.ts +84 -15
  134. package/packages/long-horizon/src/engine/goal-state.ts +64 -13
  135. package/packages/long-horizon/src/engine/nudge-stash.ts +71 -0
  136. package/packages/long-horizon/src/engine/ralph.ts +3 -2
  137. package/packages/long-horizon/src/engine/runaway.ts +68 -11
  138. package/packages/long-horizon/src/engine/verifier.ts +19 -0
  139. package/packages/long-horizon/src/gate.ts +37 -12
  140. package/packages/long-horizon/src/judge/resolve.ts +1 -0
  141. package/packages/long-horizon/src/owner.ts +35 -2
  142. package/packages/long-horizon/src/progress.ts +87 -0
  143. package/packages/long-horizon/src/prompts/goal.ts +1 -1
  144. package/packages/long-horizon/src/runtime.ts +172 -18
  145. package/packages/long-horizon/src/settings.ts +54 -39
  146. package/packages/long-horizon/src/tools/goal.ts +6 -0
  147. package/packages/long-horizon/src/tools/graph.ts +1 -1
  148. package/packages/long-horizon/src/tools/swarm.ts +3 -3
  149. package/packages/mcp/package.json +2 -2
  150. package/packages/memory/commands.ts +7 -2
  151. package/packages/memory/index.ts +210 -32
  152. package/packages/memory/package.json +3 -3
  153. package/packages/memory/save-session.ts +264 -0
  154. package/packages/memory/settings.ts +17 -0
  155. package/packages/memory/tools.ts +253 -203
  156. package/packages/notify/package.json +2 -2
  157. package/packages/skill-registry/README.md +53 -16
  158. package/packages/skill-registry/index.ts +230 -0
  159. package/packages/skill-registry/package.json +19 -8
  160. package/packages/skill-registry/src/editor.ts +206 -0
  161. package/packages/skill-registry/src/judge.ts +289 -0
  162. package/packages/skill-registry/src/registry.ts +87 -0
  163. package/packages/skill-registry/src/settings.ts +259 -0
  164. package/packages/skill-registry/src/vault.ts +80 -0
  165. package/packages/subagents/README.md +39 -119
  166. package/packages/subagents/package.json +6 -13
  167. package/packages/subagents/skills/subagents/SKILL.md +18 -42
  168. package/packages/subagents/src/agents.ts +213 -0
  169. package/packages/subagents/src/cards.ts +182 -0
  170. package/packages/subagents/src/index.ts +609 -1084
  171. package/packages/subagents/src/manager.ts +429 -0
  172. package/packages/subagents/src/profiles.ts +130 -0
  173. package/packages/subagents/src/transcript.ts +154 -0
  174. package/packages/subagents/src/ui.ts +389 -0
  175. package/packages/unipi/bundled.js +39756 -45569
  176. package/packages/unipi/index.ts +55 -10
  177. package/packages/updater/package.json +2 -2
  178. package/packages/utility/README.md +76 -69
  179. package/packages/utility/package.json +2 -2
  180. package/packages/utility/skills/image/SKILL.md +37 -0
  181. package/packages/utility/skills/utility/SKILL.md +17 -60
  182. package/packages/utility/src/answer/extract.ts +128 -0
  183. package/packages/utility/src/answer/index.ts +213 -0
  184. package/packages/utility/src/answer/reply.ts +133 -0
  185. package/packages/utility/src/answer/web.ts +167 -0
  186. package/packages/utility/src/attach/index.ts +205 -0
  187. package/packages/utility/src/commands.ts +38 -142
  188. package/packages/utility/src/diagnostics/engine.ts +50 -5
  189. package/packages/utility/src/image/index.ts +33 -0
  190. package/packages/utility/src/image/models.ts +221 -0
  191. package/packages/{image/src → utility/src/image}/recognize.ts +2 -2
  192. package/packages/utility/src/image/settings.ts +178 -0
  193. package/packages/{image/src/image-source.ts → utility/src/image/source.ts} +1 -1
  194. package/packages/utility/src/image/tools.ts +168 -0
  195. package/packages/utility/src/image/transports.ts +186 -0
  196. package/packages/utility/src/index.ts +68 -389
  197. package/packages/utility/src/lifecycle/cleanup.ts +116 -311
  198. package/packages/utility/src/rename/gate.ts +102 -0
  199. package/packages/utility/src/rename/index.ts +191 -0
  200. package/packages/utility/src/rename/session.ts +170 -0
  201. package/packages/utility/src/render/delegated.ts +285 -0
  202. package/packages/utility/src/render/harness.ts +683 -0
  203. package/packages/utility/src/render/parse.ts +156 -0
  204. package/packages/utility/src/render/pi-settings.ts +38 -0
  205. package/packages/utility/src/render/reply-bg.ts +307 -0
  206. package/packages/utility/src/render/simple.ts +724 -0
  207. package/packages/utility/src/render/spacing.ts +235 -0
  208. package/packages/utility/src/render/styled.ts +251 -0
  209. package/packages/utility/src/render/tools.ts +353 -0
  210. package/packages/utility/src/settings.ts +166 -233
  211. package/packages/utility/src/types.ts +1 -222
  212. package/packages/watchdog/index.ts +17 -6
  213. package/packages/watchdog/package.json +3 -3
  214. package/packages/watchdog/src/config.ts +3 -2
  215. package/packages/web-api/package.json +2 -2
  216. package/packages/workflow/README.md +3 -0
  217. package/packages/workflow/index.ts +0 -2
  218. package/packages/workflow/package.json +2 -2
  219. package/packages/workflow/src/permission/decide.ts +6 -4
  220. package/packages/workflow/src/permission/index.ts +17 -7
  221. package/packages/workflow/src/permission/settings.ts +39 -4
  222. package/packages/workflow/src/plan/index.ts +19 -4
  223. package/packages/background-tasks/extensions/delegate-child.ts +0 -1
  224. package/packages/background-tasks/src/context-parent-snapshot.ts +0 -142
  225. package/packages/background-tasks/src/context-token-budget.ts +0 -903
  226. package/packages/background-tasks/src/context-visible-conversation-v2.ts +0 -551
  227. package/packages/background-tasks/src/delegate/artifacts.ts +0 -487
  228. package/packages/background-tasks/src/delegate/budget.ts +0 -415
  229. package/packages/background-tasks/src/delegate/hook-contract-evidence.json +0 -18
  230. package/packages/background-tasks/src/delegate/hook-contract.ts +0 -154
  231. package/packages/background-tasks/src/delegate/launch.ts +0 -484
  232. package/packages/background-tasks/src/delegate/result-package.ts +0 -459
  233. package/packages/background-tasks/src/delegate/runner.ts +0 -430
  234. package/packages/background-tasks/src/delegate/seed.ts +0 -423
  235. package/packages/background-tasks/src/delegate/types.ts +0 -323
  236. package/packages/background-tasks/src/delegate-child-extension.ts +0 -978
  237. package/packages/background-tasks/src/delegate-extension.ts +0 -654
  238. package/packages/background-tasks/src/fixtures/delegate-context-incident.json +0 -17
  239. package/packages/compactor/skills/compactor-detail/SKILL.md +0 -116
  240. package/packages/compactor/skills/compactor-doctor/SKILL.md +0 -71
  241. package/packages/compactor/skills/compactor-stats/SKILL.md +0 -51
  242. package/packages/compactor/src/compaction/build-sections.ts +0 -76
  243. package/packages/compactor/src/compaction/extract/goals.ts +0 -36
  244. package/packages/compactor/src/compaction/extract/preferences.ts +0 -40
  245. package/packages/compactor/src/compaction/format.ts +0 -53
  246. package/packages/compactor/src/compaction/merge.ts +0 -143
  247. package/packages/compactor/src/config/presets.ts +0 -160
  248. package/packages/compactor/src/executor/executor.ts +0 -233
  249. package/packages/compactor/src/executor/runtime.ts +0 -115
  250. package/packages/compactor/src/security/evaluator.ts +0 -89
  251. package/packages/compactor/src/security/policy.ts +0 -96
  252. package/packages/compactor/src/security/scanner.ts +0 -65
  253. package/packages/compactor/src/session/auto-inject.ts +0 -60
  254. package/packages/compactor/src/session/db.ts +0 -325
  255. package/packages/compactor/src/session/extract.ts +0 -144
  256. package/packages/compactor/src/session/resume-inject.ts +0 -64
  257. package/packages/compactor/src/session/snapshot.ts +0 -327
  258. package/packages/compactor/src/tools/ctx-batch-execute.ts +0 -41
  259. package/packages/compactor/src/tools/ctx-execute-file.ts +0 -23
  260. package/packages/compactor/src/tools/ctx-execute.ts +0 -23
  261. package/packages/compactor/src/tools/ctx-stats.ts +0 -96
  262. package/packages/image/README.md +0 -99
  263. package/packages/image/package.json +0 -61
  264. package/packages/image/skills/image/SKILL.md +0 -76
  265. package/packages/image/src/generate.ts +0 -256
  266. package/packages/image/src/index.ts +0 -135
  267. package/packages/image/src/models.ts +0 -519
  268. package/packages/image/src/openai-images-api.ts +0 -282
  269. package/packages/image/src/register-providers.ts +0 -209
  270. package/packages/image/src/settings.ts +0 -173
  271. package/packages/image/src/tools.ts +0 -326
  272. package/packages/input-shortcuts/src/clipboard.ts +0 -119
  273. package/packages/kanboard/src/runner.ts +0 -817
  274. package/packages/skill-registry/skills/auto/SKILL.md +0 -465
  275. package/packages/skill-registry/skills/brainstorm/SKILL.md +0 -219
  276. package/packages/skill-registry/skills/chore-create/SKILL.md +0 -315
  277. package/packages/skill-registry/skills/chore-execute/SKILL.md +0 -314
  278. package/packages/skill-registry/skills/consolidate/SKILL.md +0 -142
  279. package/packages/skill-registry/skills/consultant/SKILL.md +0 -97
  280. package/packages/skill-registry/skills/debug/SKILL.md +0 -228
  281. package/packages/skill-registry/skills/document/SKILL.md +0 -120
  282. package/packages/skill-registry/skills/fix/SKILL.md +0 -212
  283. package/packages/skill-registry/skills/gather-context/SKILL.md +0 -145
  284. package/packages/skill-registry/skills/plan/SKILL.md +0 -231
  285. package/packages/skill-registry/skills/quick-fix/SKILL.md +0 -135
  286. package/packages/skill-registry/skills/quick-work/SKILL.md +0 -110
  287. package/packages/skill-registry/skills/research/SKILL.md +0 -251
  288. package/packages/skill-registry/skills/review-work/SKILL.md +0 -187
  289. package/packages/skill-registry/skills/scan-issues/SKILL.md +0 -195
  290. package/packages/skill-registry/skills/work/SKILL.md +0 -218
  291. package/packages/skill-registry/skills/worktree-create/SKILL.md +0 -69
  292. package/packages/skill-registry/skills/worktree-list/SKILL.md +0 -78
  293. package/packages/skill-registry/skills/worktree-merge/SKILL.md +0 -89
  294. package/packages/subagents/agents/delegate.md +0 -14
  295. package/packages/subagents/agents/oracle.md +0 -78
  296. package/packages/subagents/agents/researcher.md +0 -52
  297. package/packages/subagents/agents/reviewer.md +0 -80
  298. package/packages/subagents/agents/scout.md +0 -50
  299. package/packages/subagents/agents/worker.md +0 -59
  300. package/packages/subagents/prompts/council.md +0 -48
  301. package/packages/subagents/prompts/gather-context-and-clarify.md +0 -13
  302. package/packages/subagents/prompts/parallel-cleanup.md +0 -59
  303. package/packages/subagents/prompts/parallel-research.md +0 -50
  304. package/packages/subagents/prompts/parallel-review.md +0 -54
  305. package/packages/subagents/prompts/review-loop.md +0 -43
  306. package/packages/subagents/skills/council-mode/SKILL.md +0 -230
  307. package/packages/subagents/skills/subagents/references/constraints-and-recipes.md +0 -259
  308. package/packages/subagents/skills/subagents/references/execution-controls.md +0 -454
  309. package/packages/subagents/skills/subagents/references/management-authoring-rpc.md +0 -161
  310. package/packages/subagents/skills/subagents/references/multi-lane-orchestration.md +0 -39
  311. package/packages/subagents/skills/subagents/references/prompting-and-roles.md +0 -267
  312. package/packages/subagents/src/acceptance.ts +0 -331
  313. package/packages/subagents/src/agent-manager.ts +0 -405
  314. package/packages/subagents/src/agent-memory.ts +0 -300
  315. package/packages/subagents/src/agent-overrides.ts +0 -183
  316. package/packages/subagents/src/agent-runner.ts +0 -271
  317. package/packages/subagents/src/async-runner.ts +0 -422
  318. package/packages/subagents/src/authority-policy.ts +0 -47
  319. package/packages/subagents/src/budgets.ts +0 -285
  320. package/packages/subagents/src/child-safety.ts +0 -140
  321. package/packages/subagents/src/config.ts +0 -249
  322. package/packages/subagents/src/conversation-viewer.ts +0 -281
  323. package/packages/subagents/src/core-compat.ts +0 -94
  324. package/packages/subagents/src/custom-agents.ts +0 -343
  325. package/packages/subagents/src/file-system-retry.ts +0 -50
  326. package/packages/subagents/src/fleet-data.ts +0 -105
  327. package/packages/subagents/src/fleet-view.ts +0 -322
  328. package/packages/subagents/src/foreground-detach.ts +0 -59
  329. package/packages/subagents/src/fork-context.ts +0 -285
  330. package/packages/subagents/src/global.d.ts +0 -7
  331. package/packages/subagents/src/guide.ts +0 -129
  332. package/packages/subagents/src/mission-state.ts +0 -133
  333. package/packages/subagents/src/mission-store.ts +0 -436
  334. package/packages/subagents/src/output-limits.ts +0 -142
  335. package/packages/subagents/src/parity-types.ts +0 -315
  336. package/packages/subagents/src/pi-args.ts +0 -167
  337. package/packages/subagents/src/result-files.ts +0 -232
  338. package/packages/subagents/src/result-watcher.ts +0 -221
  339. package/packages/subagents/src/retained-children.ts +0 -176
  340. package/packages/subagents/src/run-fanout-budget.ts +0 -356
  341. package/packages/subagents/src/scheduled-runs.ts +0 -349
  342. package/packages/subagents/src/schemas.ts +0 -263
  343. package/packages/subagents/src/slash-commands.ts +0 -80
  344. package/packages/subagents/src/supervisor-channel.ts +0 -249
  345. package/packages/subagents/src/tool-handler.ts +0 -1144
  346. package/packages/subagents/src/types.ts +0 -238
  347. package/packages/subagents/src/widget.ts +0 -457
  348. package/packages/subagents/src/workflow-script.ts +0 -737
  349. package/packages/subagents/src/workflow-worker.ts +0 -384
  350. package/packages/subagents/src/worktree.ts +0 -614
  351. package/packages/utility/src/analytics/collector.ts +0 -293
  352. package/packages/utility/src/info-screen.ts +0 -80
  353. package/packages/utility/src/lifecycle/process.ts +0 -162
  354. package/packages/utility/src/prefix-cache.ts +0 -263
  355. package/packages/utility/src/skill-discovery.ts +0 -542
  356. package/packages/utility/src/tools/env.ts +0 -113
  357. package/packages/utility/src/tui/name-badge-state.ts +0 -315
  358. package/packages/utility/src/tui/name-badge.ts +0 -109
  359. /package/packages/{subagents/src → core/src/child-agent}/pi-spawn.ts +0 -0
@@ -0,0 +1,221 @@
1
+ /**
2
+ * @pi-unipi/utility — image model catalog, routing and keys
3
+ *
4
+ * Catalog (merged into the shared model cache with real modalities):
5
+ * - OpenRouter's image models (public /models?output_modalities=image),
6
+ * refreshed at most daily into ~/.unipi/config/image-models-cache.json
7
+ * - a short list of fal models (listed when a fal key is available)
8
+ * - pi's chat models already in the cache (vision = input includes image)
9
+ *
10
+ * Routing a "provider/model-id" reference:
11
+ * openrouter/… → OpenRouter chat+modalities key: settings → pi login → OPENROUTER_API_KEY
12
+ * fal/… → fal.run key: settings → FAL_KEY / FAL_API_KEY
13
+ * <other>/… → that pi provider's baseUrl, OpenAI images shape, pi's key for it
14
+ * custom endpoint set in settings → that endpoint, model id as typed, its key
15
+ */
16
+
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+ import { readModelCache, type CachedModel, type Modality } from "@pi-unipi/core";
20
+ import type { EndpointSettings, ImageApi, ImageConfig } from "./settings.js";
21
+
22
+ export interface ModelRegistryLike {
23
+ find(provider: string, id: string): unknown;
24
+ getAll(): unknown[];
25
+ getAvailable?(): unknown[];
26
+ getApiKeyForProvider?(provider: string): Promise<string | undefined>;
27
+ }
28
+
29
+ export function splitRef(ref: string): { provider: string; id: string } | null {
30
+ const t = ref.trim();
31
+ const slash = t.indexOf("/");
32
+ return slash > 0 && slash < t.length - 1 ? { provider: t.slice(0, slash), id: t.slice(slash + 1) } : null;
33
+ }
34
+
35
+ // ─── catalog ──────────────────────────────────────────────────────────────
36
+
37
+ export const FAL_MODELS: CachedModel[] = [
38
+ { provider: "fal", id: "fal-ai/flux-2/klein/4b", name: "FLUX.2 [klein] 4B", input: ["text"], output: ["image"], kind: "images" },
39
+ { provider: "fal", id: "fal-ai/flux-2/klein/4b/edit", name: "FLUX.2 [klein] 4B edit", input: ["text", "image"], output: ["image"], kind: "images" },
40
+ { provider: "fal", id: "fal-ai/flux-2", name: "FLUX.2 [dev]", input: ["text"], output: ["image"], kind: "images" },
41
+ { provider: "fal", id: "fal-ai/flux-2/edit", name: "FLUX.2 [dev] edit", input: ["text", "image"], output: ["image"], kind: "images" },
42
+ { provider: "fal", id: "fal-ai/nano-banana-pro", name: "Nano Banana Pro", input: ["text"], output: ["image"], kind: "images" },
43
+ { provider: "fal", id: "fal-ai/nano-banana-pro/edit", name: "Nano Banana Pro edit", input: ["text", "image"], output: ["image"], kind: "images" },
44
+ ];
45
+
46
+ const IMAGE_CACHE_TTL_MS = 24 * 3600_000;
47
+
48
+ function imageCachePath(): string {
49
+ return path.join(process.env.HOME ?? process.env.USERPROFILE ?? "~", ".unipi", "config", "image-models-cache.json");
50
+ }
51
+
52
+ export function readImageModelCache(): { updatedAt: number; models: CachedModel[] } {
53
+ try {
54
+ const raw = JSON.parse(fs.readFileSync(imageCachePath(), "utf-8")) as { updatedAt?: number; models?: CachedModel[] };
55
+ return { updatedAt: raw.updatedAt ?? 0, models: Array.isArray(raw.models) ? raw.models : [] };
56
+ } catch {
57
+ return { updatedAt: 0, models: [] };
58
+ }
59
+ }
60
+
61
+ const modalities = (list: unknown): Modality[] =>
62
+ Array.isArray(list) ? (list.filter((m) => m === "text" || m === "image") as Modality[]) : [];
63
+
64
+ /** OpenRouter /models (image output) → cache entries. */
65
+ export function parseOpenRouterImageModels(json: unknown): CachedModel[] {
66
+ const data = (json as { data?: unknown[] })?.data;
67
+ if (!Array.isArray(data)) return [];
68
+ const out: CachedModel[] = [];
69
+ for (const raw of data) {
70
+ const m = raw as { id?: unknown; name?: unknown; architecture?: { input_modalities?: unknown; output_modalities?: unknown } };
71
+ if (typeof m.id !== "string" || m.id.startsWith("openrouter/")) continue;
72
+ const output = modalities(m.architecture?.output_modalities);
73
+ if (!output.includes("image")) continue;
74
+ const input = modalities(m.architecture?.input_modalities);
75
+ out.push({ provider: "openrouter", id: m.id, ...(typeof m.name === "string" ? { name: m.name } : {}), input: input.length ? input : ["text"], output, kind: "images" });
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /** Refresh the OpenRouter image list when stale; never throws. */
81
+ export async function refreshImageModelCache(fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<boolean> {
82
+ const current = readImageModelCache();
83
+ if (current.models.length > 0 && now - current.updatedAt < IMAGE_CACHE_TTL_MS) return false;
84
+ try {
85
+ const res = await fetchImpl("https://openrouter.ai/api/v1/models?output_modalities=image", { signal: AbortSignal.timeout(15_000) });
86
+ if (!res.ok) return false;
87
+ const models = parseOpenRouterImageModels(await res.json());
88
+ if (models.length === 0) return false;
89
+ fs.mkdirSync(path.dirname(imageCachePath()), { recursive: true });
90
+ fs.writeFileSync(imageCachePath(), JSON.stringify({ updatedAt: now, models }, null, 2));
91
+ return true;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ export function falKey(config: ImageConfig, env: NodeJS.ProcessEnv = process.env): string | undefined {
98
+ return config.keys.fal || env.FAL_KEY || env.FAL_API_KEY || undefined;
99
+ }
100
+
101
+ /** Image entries to merge into the shared model cache (first wins on dupes). */
102
+ export function imageCatalogEntries(config: ImageConfig, env: NodeJS.ProcessEnv = process.env): CachedModel[] {
103
+ return [...readImageModelCache().models, ...(falKey(config, env) ? FAL_MODELS : [])];
104
+ }
105
+
106
+ /** Modalities of a reference, from the shared cache (undefined = unknown). */
107
+ export function lookupModel(ref: string): CachedModel | undefined {
108
+ return readModelCache().find((m) => `${m.provider}/${m.id}` === ref);
109
+ }
110
+
111
+ // ─── routing ──────────────────────────────────────────────────────────────
112
+
113
+ export interface Route {
114
+ api: ImageApi;
115
+ baseUrl: string;
116
+ apiKey: string;
117
+ modelId: string;
118
+ label: string;
119
+ output?: string[];
120
+ }
121
+
122
+ function providerBaseUrl(registry: ModelRegistryLike | undefined, provider: string): string | undefined {
123
+ try {
124
+ const models = (registry?.getAvailable?.() ?? registry?.getAll() ?? []) as Array<{ provider?: string; baseUrl?: string }>;
125
+ return models.find((m) => m.provider === provider && m.baseUrl)?.baseUrl;
126
+ } catch {
127
+ return undefined;
128
+ }
129
+ }
130
+
131
+ async function registryKey(registry: ModelRegistryLike | undefined, provider: string, env: NodeJS.ProcessEnv): Promise<string | undefined> {
132
+ try {
133
+ const key = await registry?.getApiKeyForProvider?.(provider);
134
+ if (key) return key;
135
+ } catch {
136
+ // fall through
137
+ }
138
+ return env[`${provider.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`] || undefined;
139
+ }
140
+
141
+ /**
142
+ * Where and how to send a generate/edit request. Throws a message that says
143
+ * exactly what to set when a key or endpoint is missing.
144
+ */
145
+ export async function resolveRoute(
146
+ ref: string,
147
+ endpoint: EndpointSettings,
148
+ config: ImageConfig,
149
+ registry: ModelRegistryLike | undefined,
150
+ env: NodeJS.ProcessEnv = process.env,
151
+ ): Promise<Route> {
152
+ const model = ref.trim();
153
+ if (!model) throw new Error("No image model configured — pick one in /unipi:settings → Image.");
154
+ if (endpoint.baseUrl.trim()) {
155
+ if (!endpoint.apiKey) throw new Error(`The custom image endpoint ${endpoint.baseUrl} has no key — set it in /unipi:settings → Image.`);
156
+ return { api: endpoint.api, baseUrl: endpoint.baseUrl.trim(), apiKey: endpoint.apiKey, modelId: model, label: `${model} @ ${endpoint.baseUrl.trim()}` };
157
+ }
158
+ const parts = splitRef(model);
159
+ if (!parts) throw new Error(`"${model}" is not a provider/model-id reference.`);
160
+ const output = lookupModel(model)?.output;
161
+ if (parts.provider === "openrouter") {
162
+ const apiKey = config.keys.openrouter || (await registryKey(registry, "openrouter", env));
163
+ if (!apiKey) throw new Error("No OpenRouter key. Set Keys → OpenRouter key in /unipi:settings → Image, log in with /login, or export OPENROUTER_API_KEY.");
164
+ return { api: "openrouter", baseUrl: "", apiKey, modelId: parts.id, label: model, ...(output ? { output } : {}) };
165
+ }
166
+ if (parts.provider === "fal") {
167
+ const apiKey = falKey(config, env);
168
+ if (!apiKey) throw new Error("No fal key. Set Keys → fal key in /unipi:settings → Image, or export FAL_KEY.");
169
+ return { api: "fal", baseUrl: "", apiKey, modelId: parts.id, label: model };
170
+ }
171
+ const baseUrl = providerBaseUrl(registry, parts.provider);
172
+ if (!baseUrl) throw new Error(`Provider "${parts.provider}" is not configured in pi, so there is no endpoint for ${model}. Pick an openrouter/ or fal/ model, or set a custom endpoint.`);
173
+ const apiKey = await registryKey(registry, parts.provider, env);
174
+ if (!apiKey) throw new Error(`No key for provider "${parts.provider}". Log in with /login or set a custom endpoint key.`);
175
+ return { api: "openai-images", baseUrl, apiKey, modelId: parts.id, label: model };
176
+ }
177
+
178
+ // ─── vision (recognize) ───────────────────────────────────────────────────
179
+
180
+ export interface VisionModel {
181
+ id: string;
182
+ provider: string;
183
+ name?: string;
184
+ input?: string[];
185
+ baseUrl?: string;
186
+ api?: string;
187
+ }
188
+
189
+ export function isVisionModel(model: unknown): model is VisionModel {
190
+ const m = model as Partial<VisionModel> | null;
191
+ return !!m && typeof m.id === "string" && typeof m.provider === "string" && Array.isArray(m.input) && m.input.includes("image");
192
+ }
193
+
194
+ /** Active tools with image_recognize hidden for vision models, restored for text-only ones. */
195
+ export function applyRecognizeGating(active: string[], model: unknown, tool: string): string[] {
196
+ const vision = isVisionModel(model);
197
+ const present = active.includes(tool);
198
+ if (vision !== present) return active;
199
+ return vision ? active.filter((n) => n !== tool) : [...active, tool];
200
+ }
201
+
202
+ /** A registry vision model by reference (exact, then id, then substring). */
203
+ export function resolveVisionModel(ref: string, registry: ModelRegistryLike): VisionModel | string {
204
+ let all: unknown[] = [];
205
+ try {
206
+ all = registry.getAvailable?.() ?? registry.getAll();
207
+ } catch {
208
+ all = [];
209
+ }
210
+ const vision = all.filter(isVisionModel);
211
+ const q = ref.trim().toLowerCase();
212
+ const full = (m: VisionModel) => `${m.provider}/${m.id}`.toLowerCase();
213
+ const hit = vision.find((m) => full(m) === q) ?? vision.find((m) => m.id.toLowerCase() === q) ?? vision.find((m) => full(m).includes(q));
214
+ if (hit) return hit;
215
+ const known = all.find((m) => {
216
+ const c = m as Partial<VisionModel>;
217
+ return typeof c.id === "string" && typeof c.provider === "string" && (`${c.provider}/${c.id}`.toLowerCase() === q || c.id.toLowerCase() === q);
218
+ });
219
+ if (known) return `Model "${ref}" does not accept image input. Vision models: ${vision.slice(0, 5).map((m) => `${m.provider}/${m.id}`).join(", ")}`;
220
+ return `Unknown vision model "${ref}". Vision models: ${vision.slice(0, 5).map((m) => `${m.provider}/${m.id}`).join(", ") || "none configured"}`;
221
+ }
@@ -1,12 +1,12 @@
1
1
  /**
2
- * @pi-unipi/image — Image recognition
2
+ * @pi-unipi/utility — image: Image recognition
3
3
  *
4
4
  * Sends an image plus a question to a vision-capable chat model. Providers
5
5
  * differ in how image parts are encoded, so the request is built per API
6
6
  * family (mirroring `packages/notify/summarize.ts`).
7
7
  */
8
8
 
9
- import type { LoadedImage } from "./image-source.js";
9
+ import type { LoadedImage } from "./source.js";
10
10
 
11
11
  /** How long to wait for a vision response. Images are slow. */
12
12
  const DEFAULT_TIMEOUT_MS = 120_000;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * @pi-unipi/utility — image settings (namespace `image`, unchanged from the
3
+ * former @pi-unipi/image package, so existing configs keep working)
4
+ *
5
+ * generate — text → image (models whose output includes image)
6
+ * edit — image+text → image (models that take an image and output one)
7
+ * recognize — image → text (vision chat models; hidden while the
8
+ * session model can see images itself)
9
+ * keys — OpenRouter / fal keys (empty = pi's login or the environment)
10
+ * *.baseUrl — optional custom endpoint: when set, the model id is sent to
11
+ * that endpoint as typed, with its own key.
12
+ */
13
+
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+ import { getSettings, registerSettings } from "@pi-unipi/core";
17
+
18
+ export const DEFAULT_RECOGNIZE_SYSTEM_PROMPT =
19
+ "You are a precise image analyst assisting a software engineer. " +
20
+ "Describe what is actually visible — never speculate about what is not shown. " +
21
+ "For screenshots, transcribe visible text, UI structure, and any errors verbatim. " +
22
+ "For diagrams, describe the components and their relationships. " +
23
+ "For photographs, describe the subject, setting, and notable detail. " +
24
+ "Be specific and concise; lead with the single most important observation.";
25
+
26
+ /** Cheap, fast defaults (FLUX.2 klein 4B does both text→image and edits). */
27
+ export const DEFAULT_GENERATE_MODEL = "openrouter/black-forest-labs/flux.2-klein-4b";
28
+ export const DEFAULT_EDIT_MODEL = "openrouter/black-forest-labs/flux.2-klein-4b";
29
+
30
+ export type ImageApi = "openai-images" | "openrouter" | "fal";
31
+
32
+ export interface EndpointSettings {
33
+ /** Custom endpoint; "" = route by the model's provider. */
34
+ baseUrl: string;
35
+ apiKey: string;
36
+ api: ImageApi;
37
+ }
38
+
39
+ export interface GenerateSettings extends EndpointSettings {
40
+ enabled: boolean;
41
+ model: string;
42
+ outputDir: string;
43
+ saveToDisk: boolean;
44
+ }
45
+
46
+ export interface EditSettings extends EndpointSettings {
47
+ enabled: boolean;
48
+ model: string;
49
+ }
50
+
51
+ export interface RecognizeSettings {
52
+ enabled: boolean;
53
+ /** "" = the session model. */
54
+ model: string;
55
+ systemPrompt: string;
56
+ /** Custom OpenAI-compatible chat endpoint; "" = the model's provider. */
57
+ baseUrl: string;
58
+ apiKey: string;
59
+ }
60
+
61
+ export interface ImageConfig {
62
+ generate: GenerateSettings;
63
+ edit: EditSettings;
64
+ recognize: RecognizeSettings;
65
+ keys: { openrouter: string; fal: string };
66
+ }
67
+
68
+ const NO_ENDPOINT: EndpointSettings = { baseUrl: "", apiKey: "", api: "openai-images" };
69
+
70
+ export const DEFAULT_CONFIG: ImageConfig = {
71
+ generate: { enabled: true, model: DEFAULT_GENERATE_MODEL, outputDir: "~/.unipi/images", saveToDisk: true, ...NO_ENDPOINT },
72
+ edit: { enabled: true, model: DEFAULT_EDIT_MODEL, ...NO_ENDPOINT },
73
+ recognize: { enabled: true, model: "", systemPrompt: DEFAULT_RECOGNIZE_SYSTEM_PROMPT, baseUrl: "", apiKey: "" },
74
+ keys: { openrouter: "", fal: "" },
75
+ };
76
+
77
+ const API_OPTIONS = [
78
+ { value: "openai-images", label: "OpenAI images (/images/generations)" },
79
+ { value: "openrouter", label: "OpenRouter-style chat (modalities)" },
80
+ { value: "fal", label: "fal (fal.run/<model>)" },
81
+ ];
82
+
83
+ registerSettings({
84
+ namespace: "image",
85
+ label: "Image",
86
+ defaults: DEFAULT_CONFIG as unknown as Record<string, unknown>,
87
+ schema: [
88
+ {
89
+ title: "Generate",
90
+ description: "Text → image (image_generate)",
91
+ fields: [
92
+ { key: "generate.enabled", type: "boolean", label: "Enabled", description: "Give the agent the image_generate tool" },
93
+ { key: "generate.model", type: "model", label: "Model", capability: "image-output", description: "Models whose output includes images" },
94
+ { key: "generate.outputDir", type: "string", label: "Save to folder", description: "~ is expanded" },
95
+ { key: "generate.saveToDisk", type: "boolean", label: "Save to disk" },
96
+ { key: "generate.baseUrl", type: "string", label: "Custom endpoint", emptyLabel: "none (use the model's provider)", description: "Send the model id as typed to this base URL instead" },
97
+ { key: "generate.apiKey", type: "secret", label: "Custom endpoint key", emptyLabel: "none" },
98
+ { key: "generate.api", type: "enum", label: "Custom endpoint API", options: API_OPTIONS },
99
+ ],
100
+ },
101
+ {
102
+ title: "Edit",
103
+ description: "Image + text → image (image_edit)",
104
+ fields: [
105
+ { key: "edit.enabled", type: "boolean", label: "Enabled", description: "Give the agent the image_edit tool" },
106
+ { key: "edit.model", type: "model", label: "Model", capability: "image-edit", description: "Models that take an image and output an image" },
107
+ { key: "edit.baseUrl", type: "string", label: "Custom endpoint", emptyLabel: "none (use the model's provider)" },
108
+ { key: "edit.apiKey", type: "secret", label: "Custom endpoint key", emptyLabel: "none" },
109
+ { key: "edit.api", type: "enum", label: "Custom endpoint API", options: API_OPTIONS },
110
+ ],
111
+ },
112
+ {
113
+ title: "Recognize",
114
+ description: "Image → text (image_recognize; hidden while the session model can see images)",
115
+ fields: [
116
+ { key: "recognize.enabled", type: "boolean", label: "Enabled", description: "Give text-only models the image_recognize tool" },
117
+ { key: "recognize.model", type: "model", label: "Model", capability: "image-input", emptyLabel: "inherit (session model)", emptyOption: "inherit (session model)" },
118
+ { key: "recognize.systemPrompt", type: "string", label: "System prompt", emptyLabel: "built-in analyst prompt" },
119
+ { key: "recognize.baseUrl", type: "string", label: "Custom endpoint", emptyLabel: "none (use the model's provider)", description: "OpenAI-compatible chat endpoint" },
120
+ { key: "recognize.apiKey", type: "secret", label: "Custom endpoint key", emptyLabel: "none" },
121
+ ],
122
+ },
123
+ {
124
+ title: "Keys",
125
+ description: "Used when the model's provider is OpenRouter or fal",
126
+ advanced: true,
127
+ fields: [
128
+ { key: "keys.openrouter", type: "secret", label: "OpenRouter key", emptyLabel: "pi login or OPENROUTER_API_KEY" },
129
+ { key: "keys.fal", type: "secret", label: "fal key", emptyLabel: "FAL_KEY / FAL_API_KEY" },
130
+ ],
131
+ },
132
+ ],
133
+ });
134
+
135
+ function isRecord(value: unknown): value is Record<string, unknown> {
136
+ return value !== null && typeof value === "object" && !Array.isArray(value);
137
+ }
138
+
139
+ /** Merge a loaded section over its defaults, ignoring wrong-typed fields. */
140
+ function mergeSection<T extends object>(defaults: T, loaded: unknown): T {
141
+ const merged: T = { ...defaults };
142
+ if (!isRecord(loaded)) return merged;
143
+ for (const key of Object.keys(defaults) as Array<keyof T & string>) {
144
+ const value = loaded[key];
145
+ if (value !== undefined && value !== null && typeof value === typeof defaults[key]) merged[key] = value as T[keyof T & string];
146
+ }
147
+ return merged;
148
+ }
149
+
150
+ export function loadConfig(cwd: string = process.cwd()): ImageConfig {
151
+ try {
152
+ const parsed = getSettings("image", cwd);
153
+ if (!isRecord(parsed)) return structuredClone(DEFAULT_CONFIG);
154
+ const config: ImageConfig = {
155
+ generate: mergeSection(DEFAULT_CONFIG.generate, parsed.generate),
156
+ edit: mergeSection(DEFAULT_CONFIG.edit, parsed.edit),
157
+ recognize: mergeSection(DEFAULT_CONFIG.recognize, parsed.recognize),
158
+ keys: mergeSection(DEFAULT_CONFIG.keys, parsed.keys),
159
+ };
160
+ for (const section of [config.generate, config.edit]) {
161
+ if (!["openai-images", "openrouter", "fal"].includes(section.api)) section.api = "openai-images";
162
+ }
163
+ return config;
164
+ } catch {
165
+ return structuredClone(DEFAULT_CONFIG);
166
+ }
167
+ }
168
+
169
+ export function expandHome(target: string): string {
170
+ if (target === "~") return os.homedir();
171
+ if (target.startsWith("~/") || target.startsWith("~\\")) return path.join(os.homedir(), target.slice(2));
172
+ return target;
173
+ }
174
+
175
+ export function getOutputDir(config: ImageConfig = loadConfig()): string {
176
+ const dir = config.generate.outputDir?.trim();
177
+ return expandHome(dir && dir.length > 0 ? dir : DEFAULT_CONFIG.generate.outputDir);
178
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @pi-unipi/image — Image input handling
2
+ * @pi-unipi/utility — image: Image input handling
3
3
  *
4
4
  * Accepts a local file path, a data: URL, or a raw base64 string and
5
5
  * normalizes it to the `{ data, mimeType }` shape pi-ai expects.
@@ -0,0 +1,168 @@
1
+ /**
2
+ * @pi-unipi/utility — image tools: image_generate, image_edit, image_recognize
3
+ *
4
+ * Registered once at load from the enabled flags (static schemas keep the
5
+ * provider prefix cache intact). Failures THROW so pi records them as failed
6
+ * tool results — returning `isError` is ignored by pi.
7
+ */
8
+
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import { Type } from "typebox";
12
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import { IMAGE_TOOLS } from "@pi-unipi/core";
14
+ import { loadImage } from "./source.js";
15
+ import { recognizeImage } from "./recognize.js";
16
+ import { generate, type ImagePayload } from "./transports.js";
17
+ import { resolveRoute, resolveVisionModel, type ModelRegistryLike } from "./models.js";
18
+ import { getOutputDir, loadConfig, type EndpointSettings } from "./settings.js";
19
+
20
+ const EXT: Record<string, string> = { "image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif" };
21
+
22
+ export function slugify(prompt: string, max = 40): string {
23
+ return prompt.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/g, "") || "image";
24
+ }
25
+
26
+ /** Save images; a failed write never loses the generated image. */
27
+ export function saveImages(dir: string, prompt: string, images: ImagePayload[], now = new Date()): string[] {
28
+ const stamp = now.toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19);
29
+ const saved: string[] = [];
30
+ images.forEach((img, i) => {
31
+ try {
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ const file = path.join(dir, `${stamp}-${slugify(prompt)}${i ? `-${i + 1}` : ""}${EXT[img.mimeType] ?? ".png"}`);
34
+ fs.writeFileSync(file, Buffer.from(img.data, "base64"));
35
+ saved.push(file);
36
+ } catch {
37
+ // keep going — the image is still returned inline
38
+ }
39
+ });
40
+ return saved;
41
+ }
42
+
43
+ function registry(ctx: ExtensionContext): ModelRegistryLike | undefined {
44
+ return (ctx as unknown as { modelRegistry?: ModelRegistryLike }).modelRegistry;
45
+ }
46
+
47
+ async function run(
48
+ ctx: ExtensionContext,
49
+ kind: "generate" | "edit",
50
+ prompt: string,
51
+ modelOverride: string | undefined,
52
+ endpoint: EndpointSettings & { model: string },
53
+ source: ImagePayload[] | undefined,
54
+ signal: AbortSignal | undefined,
55
+ ) {
56
+ const config = loadConfig(ctx.cwd);
57
+ const route = await resolveRoute(modelOverride?.trim() || endpoint.model, modelOverride?.trim() ? { ...endpoint, baseUrl: "" } : endpoint, config, registry(ctx));
58
+ const out = await generate({ ...route, prompt, ...(source ? { images: source } : {}), ...(signal ? { signal } : {}) });
59
+ const saved = config.generate.saveToDisk ? saveImages(getOutputDir(config), prompt, out.images) : [];
60
+ const verb = kind === "edit" ? "Edited" : "Generated";
61
+ const summary = [
62
+ `${verb} ${out.images.length} image${out.images.length === 1 ? "" : "s"} with ${route.label}.`,
63
+ saved.length ? `Saved to:\n${saved.map((p) => ` ${p}`).join("\n")}` : config.generate.saveToDisk ? "Could not save to disk; the image is inline only." : "",
64
+ out.text,
65
+ ].filter(Boolean).join("\n");
66
+ return {
67
+ content: [{ type: "text" as const, text: summary }, ...out.images.map((i) => ({ type: "image" as const, data: i.data, mimeType: i.mimeType }))],
68
+ details: { model: route.label, count: out.images.length, paths: saved },
69
+ };
70
+ }
71
+
72
+ export function registerImageTools(pi: ExtensionAPI): void {
73
+ const config = loadConfig();
74
+
75
+ if (config.generate.enabled) {
76
+ pi.registerTool({
77
+ name: IMAGE_TOOLS.GENERATE,
78
+ label: "Generate Image",
79
+ description: "Generate an image from a text prompt. Returned inline and saved to disk.",
80
+ promptSnippet: "Generate an image from a text prompt.",
81
+ promptGuidelines: [
82
+ "Write a detailed prompt — subject, style, composition and lighting all help.",
83
+ "Describe what you DO want; negation is unreliable in image models.",
84
+ "To change an existing image use image_edit instead.",
85
+ "Omit model to use the one configured in /unipi:settings. Images cost money; do not regenerate unasked.",
86
+ ],
87
+ parameters: Type.Object({
88
+ prompt: Type.String({ description: "Description of the image. Be specific." }),
89
+ model: Type.Optional(Type.String({ description: 'Override as "provider/model-id", e.g. "openrouter/black-forest-labs/flux.2-klein-4b" or "fal/fal-ai/flux-2/klein/4b".' })),
90
+ }),
91
+ async execute(_id, params, signal, _onUpdate, ctx) {
92
+ const c = loadConfig(ctx.cwd);
93
+ return run(ctx, "generate", params.prompt, params.model, c.generate, undefined, signal);
94
+ },
95
+ });
96
+ }
97
+
98
+ if (config.edit.enabled) {
99
+ pi.registerTool({
100
+ name: IMAGE_TOOLS.EDIT,
101
+ label: "Edit Image",
102
+ description: "Edit an existing image with a text instruction. Returned inline and saved to disk.",
103
+ promptSnippet: "Edit an existing image with a text instruction.",
104
+ promptGuidelines: [
105
+ "Pass the source image as a file path (preferred), data: URL or base64.",
106
+ "Say what to change and what to keep; unmentioned details may change.",
107
+ ],
108
+ parameters: Type.Object({
109
+ prompt: Type.String({ description: "What to change." }),
110
+ image: Type.String({ description: "Source image: local file path, data: URL, or base64." }),
111
+ model: Type.Optional(Type.String({ description: 'Override as "provider/model-id" (must accept an image and output one).' })),
112
+ }),
113
+ async execute(_id, params, signal, _onUpdate, ctx) {
114
+ const c = loadConfig(ctx.cwd);
115
+ const src = loadImage(params.image, ctx.cwd ?? process.cwd());
116
+ return run(ctx, "edit", params.prompt, params.model, c.edit, [{ data: src.data, mimeType: src.mimeType }], signal);
117
+ },
118
+ });
119
+ }
120
+
121
+ if (config.recognize.enabled) {
122
+ pi.registerTool({
123
+ name: IMAGE_TOOLS.RECOGNIZE,
124
+ label: "Recognize Image",
125
+ description: "Analyze an image and answer questions about it using a vision model.",
126
+ promptSnippet: "Analyze an image and answer questions about it.",
127
+ promptGuidelines: [
128
+ "Use image_recognize to read screenshots, diagrams, mockups and photos.",
129
+ "Pass a local file path when possible — cheaper than inlining base64.",
130
+ "Ask a specific question in `prompt` to focus the analysis.",
131
+ ],
132
+ parameters: Type.Object({
133
+ image: Type.String({ description: "Local file path, data: URL, or base64 (PNG, JPEG, GIF, WebP)." }),
134
+ prompt: Type.Optional(Type.String({ description: "What to ask about the image. Defaults to a general description." })),
135
+ model: Type.Optional(Type.String({ description: "Vision model override (must accept image input)." })),
136
+ }),
137
+ async execute(_id, params, signal, _onUpdate, ctx) {
138
+ const c = loadConfig(ctx.cwd).recognize;
139
+ const image = loadImage(params.image, ctx.cwd ?? process.cwd());
140
+ const prompt = params.prompt?.trim() || "Describe this image in detail.";
141
+ let target: { baseUrl: string; apiKey: string; api: string; modelId: string; label: string };
142
+ const requested = params.model?.trim() || c.model.trim();
143
+ if (c.baseUrl.trim() && !params.model?.trim()) {
144
+ if (!requested) throw new Error("Set a model id for the custom recognition endpoint in /unipi:settings → Image.");
145
+ target = { baseUrl: c.baseUrl.trim(), apiKey: c.apiKey, api: "openai-completions", modelId: requested, label: `${requested} @ ${c.baseUrl.trim()}` };
146
+ } else {
147
+ const reg = registry(ctx);
148
+ if (!reg) throw new Error("Model registry unavailable — image_recognize needs an active session.");
149
+ const session = (ctx as unknown as { model?: { provider?: string; id?: string } }).model;
150
+ const ref = requested || (session?.provider && session.id ? `${session.provider}/${session.id}` : "");
151
+ if (!ref) throw new Error("No vision model configured — choose one in /unipi:settings → Image.");
152
+ const model = resolveVisionModel(ref, reg);
153
+ if (typeof model === "string") throw new Error(model);
154
+ const full = reg.find(model.provider, model.id) as { baseUrl?: string; api?: string } | undefined;
155
+ if (!full?.baseUrl) throw new Error(`No endpoint known for ${model.provider}/${model.id}.`);
156
+ const apiKey = (await reg.getApiKeyForProvider?.(model.provider).catch(() => undefined)) ?? "";
157
+ if (!apiKey) throw new Error(`No key for provider "${model.provider}". Log in with /login.`);
158
+ target = { baseUrl: full.baseUrl, apiKey, api: full.api ?? "openai-completions", modelId: model.id, label: `${model.provider}/${model.id}` };
159
+ }
160
+ const result = await recognizeImage({ image, prompt, systemPrompt: c.systemPrompt, apiKey: target.apiKey, baseUrl: target.baseUrl, api: target.api, modelId: target.modelId, ...(signal ? { signal } : {}) });
161
+ return {
162
+ content: [{ type: "text" as const, text: `${result.text}\n\n— analyzed with ${target.label}${image.path ? ` (${image.path})` : ""}` }],
163
+ details: { model: target.label, mimeType: image.mimeType, ...(image.path ? { path: image.path } : {}) },
164
+ };
165
+ },
166
+ });
167
+ }
168
+ }