@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,392 @@
1
+ /**
2
+ * @unipi/core — harness message provenance.
3
+ *
4
+ * Harness-generated text that reaches the model as role USER (direct
5
+ * sendUserMessage) is annotated with a namespaced `unipiHarness` metadata field
6
+ * so the UI can style it — model roles, text, delivery and admission behavior
7
+ * stay untouched.
8
+ *
9
+ * Lifecycle (single-call synchronous arm — the SDK runs `input` handlers
10
+ * synchronously inside sendUserMessage):
11
+ * 1. `sendHarnessUserMessage` ensures the observers are installed, pushes a
12
+ * single-call ARM, calls the unchanged `pi.sendUserMessage`, and pops the
13
+ * arm in `finally` — nothing lingers across calls.
14
+ * 2. The input handler run DURING that call confirms the arm (source
15
+ * `extension`, exact text). Human/rpc inputs and extension inputs without
16
+ * an active arm become human/unattributed admission records.
17
+ * 3. Idle (non-queued) harness records are dispatch-eligible only after
18
+ * `before_agent_start` carries the exact prompt (auth/model passed).
19
+ * Queued records are eligible from their confirmed delivery while active.
20
+ * 4. `message_start` selects among ELIGIBLE harness records; human/
21
+ * unattributed records participate in conflict detection but never label.
22
+ * Identical texts with conflicting origins — across deliveries — fail
23
+ * CLOSED and clear the whole matching group. The selected message is
24
+ * marked processed; `message_end` never re-selects.
25
+ * 5. `message_end` attaches `unipiHarness` only when the finalized text still
26
+ * equals the selected text (later transforms fail closed). SessionManager
27
+ * persists the whole object, so provenance survives resume/branch.
28
+ *
29
+ * Irreducible SDK gap: input events carry no per-send ID. The next input
30
+ * purges ALL unmatched direct records (harness + human/rpc) and terminal
31
+ * boundaries purge everything outstanding — the failure mode is always native
32
+ * fallback, never a human message mislabelled.
33
+ */
34
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
35
+
36
+ export type HarnessDelivery =
37
+ | "direct"
38
+ | "steer"
39
+ | "followUp"
40
+ | "nextTurn"
41
+ | "boundary"
42
+ | "before_agent_start";
43
+
44
+ export interface HarnessMessageMeta {
45
+ version: 1;
46
+ id: string;
47
+ source: string;
48
+ title: string;
49
+ synopsis?: string;
50
+ delivery: HarnessDelivery;
51
+ severity?: "warning";
52
+ }
53
+
54
+ export type HarnessMessageMetaInput = Omit<HarnessMessageMeta, "version" | "id" | "delivery">;
55
+
56
+ /** Canonical per-API state, carried on the API ROOT via a Symbol.for property.
57
+ * Proxies (withCommandEcho, the umbrella registerTool proxy) forward unknown
58
+ * property gets/sets to their target, so every wrapper observes one state. */
59
+ const STATE_SYM = Symbol.for("unipi.harnessMessages.state");
60
+
61
+ interface HarnessToken {
62
+ kind: "harness";
63
+ origin: string;
64
+ title: string;
65
+ synopsis?: string;
66
+ severity?: "warning";
67
+ text: string;
68
+ delivery: "direct" | "steer" | "followUp";
69
+ /** Direct records become eligible only at before_agent_start. */
70
+ eligible: boolean;
71
+ state: "admitted" | "consumed";
72
+ seq: number;
73
+ }
74
+
75
+ interface HarnessHumanRecord {
76
+ kind: "human" | "unattributed";
77
+ origin: string;
78
+ text: string;
79
+ delivery: "direct" | "steer" | "followUp";
80
+ state: "admitted" | "consumed";
81
+ seq: number;
82
+ }
83
+
84
+ type AdmissionRecord = HarnessToken | HarnessHumanRecord;
85
+
86
+ interface Arm {
87
+ origin: string;
88
+ title: string;
89
+ synopsis?: string;
90
+ severity?: "warning";
91
+ text: string;
92
+ delivery: "direct" | "steer" | "followUp";
93
+ seq: number;
94
+ }
95
+
96
+ export interface HarnessRegistryState {
97
+ records: AdmissionRecord[];
98
+ seq: number;
99
+ arms: Arm[];
100
+ /** message object → { meta, text } (set at message_start). */
101
+ pendingMeta: WeakMap<object, { meta: HarnessMessageMeta; text: string }>;
102
+ /** messages whose selection already happened — message_end never re-selects. */
103
+ processed: WeakSet<object>;
104
+ installed: boolean;
105
+ }
106
+
107
+ function stateFor(pi: object): HarnessRegistryState {
108
+ const existing = (pi as { [STATE_SYM]?: HarnessRegistryState })[STATE_SYM];
109
+ if (existing) return existing;
110
+ const state: HarnessRegistryState = {
111
+ records: [],
112
+ seq: 0,
113
+ arms: [],
114
+ pendingMeta: new WeakMap(),
115
+ processed: new WeakSet(),
116
+ installed: false,
117
+ };
118
+ try {
119
+ Object.defineProperty(pi, STATE_SYM, { value: state, enumerable: false, configurable: false, writable: false });
120
+ } catch {
121
+ // Frozen hosts: fall back to an instance-local property (single-proxy use).
122
+ try {
123
+ (pi as { [STATE_SYM]?: HarnessRegistryState })[STATE_SYM] = state;
124
+ } catch {
125
+ // Absolutely frozen: operate armless — sends stay UNTRACKED (native).
126
+ }
127
+ }
128
+ return (pi as { [STATE_SYM]?: HarnessRegistryState })[STATE_SYM] ?? state;
129
+ }
130
+
131
+ let idCounter = 0;
132
+ function nextId(): string {
133
+ idCounter += 1;
134
+ return `hm-${Date.now().toString(36)}-${idCounter.toString(36)}`;
135
+ }
136
+
137
+ /** Build a stable namespaced meta (no date header — ids are monotonic). */
138
+ export function harnessMetadata(meta: HarnessMessageMetaInput, delivery: HarnessDelivery): HarnessMessageMeta {
139
+ return { version: 1, id: nextId(), delivery, ...meta };
140
+ }
141
+
142
+ /** Details payload for tool results: provenance header data for annotations
143
+ * appended to the result content (fusion nudges, kanboard R1, watchdog kill). */
144
+ export interface HarnessToolAnnotation {
145
+ meta: HarnessMessageMeta;
146
+ text: string;
147
+ }
148
+
149
+ export function harnessToolResultDetails(
150
+ eventDetails: unknown,
151
+ meta: HarnessMessageMetaInput,
152
+ delivery: HarnessDelivery,
153
+ text: string,
154
+ ): Record<string, unknown> {
155
+ const existing = (eventDetails as { unipiHarnessAnnotations?: unknown } | null | undefined)?.unipiHarnessAnnotations;
156
+ const list = Array.isArray(existing) ? existing : [];
157
+ return {
158
+ ...((eventDetails as Record<string, unknown> | null | undefined) ?? {}),
159
+ unipiHarnessAnnotations: [...list, { meta: harnessMetadata(meta, delivery), text }],
160
+ };
161
+ }
162
+
163
+ /** Namespaced read of harness metadata from a message or details object. */
164
+ export function readHarnessMeta(source: unknown): HarnessMessageMeta | undefined {
165
+ const meta = (source as { unipiHarness?: unknown } | null | undefined)?.unipiHarness ?? source;
166
+ if (!meta || typeof meta !== "object") return undefined;
167
+ const m = meta as Partial<HarnessMessageMeta>;
168
+ const ALLOWED_DELIVERY: readonly string[] = ["direct", "steer", "followUp", "nextTurn", "boundary", "before_agent_start"];
169
+ if (
170
+ m.version !== 1 || typeof m.id !== "string" || typeof m.source !== "string" ||
171
+ typeof m.title !== "string" || typeof m.delivery !== "string" || !ALLOWED_DELIVERY.includes(m.delivery)
172
+ ) {
173
+ return undefined;
174
+ }
175
+ return m as HarnessMessageMeta;
176
+ }
177
+
178
+ /** Extract the exact text the SDK renders/queues for a user message. */
179
+ export function userMessageText(content: unknown): string {
180
+ if (typeof content === "string") return content;
181
+ if (!Array.isArray(content)) return "";
182
+ return content
183
+ .filter((c): c is { type: "text"; text: string } => (c as { type?: string }).type === "text")
184
+ .map((c) => (c as { text: string }).text)
185
+ .join("");
186
+ }
187
+
188
+ function userTextFromMessage(message: unknown): string {
189
+ if (!message || typeof message !== "object") return "";
190
+ const m = message as { role?: unknown; content?: unknown };
191
+ if (m.role !== "user") return "";
192
+ return userMessageText(m.content);
193
+ }
194
+
195
+ /**
196
+ * Send a harness-generated user message through the UNCHANGED
197
+ * `pi.sendUserMessage` with a synchronous single-call provenance arm. Returns
198
+ * the raw sender's value (callers may await; failures propagate unchanged).
199
+ * Provenance tracking is best-effort: if observers cannot install on this API,
200
+ * the raw send proceeds untracked (native fallback).
201
+ */
202
+ export function sendHarnessUserMessage(
203
+ pi: ExtensionAPI,
204
+ content: string,
205
+ meta: HarnessMessageMetaInput,
206
+ options?: { deliverAs?: "steer" | "followUp" },
207
+ ): ReturnType<ExtensionAPI["sendUserMessage"]> {
208
+ let arm: Arm | undefined;
209
+ try {
210
+ try {
211
+ installHarnessProvenance(pi);
212
+ const state = stateFor(pi);
213
+ state.seq += 1;
214
+ arm = {
215
+ origin: meta.source,
216
+ title: meta.title,
217
+ synopsis: meta.synopsis,
218
+ severity: meta.severity,
219
+ text: content,
220
+ delivery: options?.deliverAs ?? "direct",
221
+ seq: state.seq,
222
+ };
223
+ state.arms.push(arm);
224
+ } catch {
225
+ // Observers/state unavailable (frozen host): send untracked.
226
+ arm = undefined;
227
+ }
228
+ return pi.sendUserMessage(content, options);
229
+ } finally {
230
+ // The input handler ran synchronously inside the call (or never ran —
231
+ // compaction/auth failure). Either way the arm dies with the call: an
232
+ // unconfirmed arm leaves NO token, so nothing can mismatch later sends.
233
+ if (arm) {
234
+ const idx = stateFor(pi).arms.indexOf(arm);
235
+ if (idx !== -1) stateFor(pi).arms.splice(idx, 1);
236
+ }
237
+ }
238
+ }
239
+
240
+ const DELIVERY_ORDER: Record<string, number> = { direct: 0, steer: 1, followUp: 2 };
241
+
242
+ /** Prune consumed records immediately (bounded arrays over long runs). */
243
+ function prune(state: HarnessRegistryState): void {
244
+ if (state.records.some((r) => r.state === "consumed")) {
245
+ state.records = state.records.filter((r) => r.state !== "consumed");
246
+ }
247
+ }
248
+
249
+ /** Select the outstanding admission record for an arriving user message.
250
+ * Only ELIGIBLE harness records can label; human/unattributed records
251
+ * participate in conflict detection but never label. Identical texts with
252
+ * conflicting origins — across deliveries — fail closed and clear the whole
253
+ * matching group. */
254
+ function selectRecord(state: HarnessRegistryState, text: string): HarnessMessageMeta | undefined {
255
+ const candidates = state.records.filter(
256
+ (r) => r.state === "admitted" && r.text === text && (r.kind !== "harness" || r.eligible),
257
+ );
258
+ if (candidates.length === 0) return undefined;
259
+ const origins = new Set(candidates.map((r) => r.origin));
260
+ if (origins.size > 1) {
261
+ for (const r of candidates) r.state = "consumed";
262
+ prune(state);
263
+ return undefined;
264
+ }
265
+ candidates.sort((a, b) => (DELIVERY_ORDER[a.delivery] ?? 3) - (DELIVERY_ORDER[b.delivery] ?? 3) || a.seq - b.seq);
266
+ const chosen = candidates[0]!;
267
+ for (const r of candidates) r.state = "consumed";
268
+ prune(state); // consumed records never linger, even on normal success
269
+ if (chosen.kind !== "harness") return undefined;
270
+ return harnessMetadata(
271
+ { source: chosen.origin, title: chosen.title, synopsis: chosen.synopsis, severity: chosen.severity },
272
+ chosen.delivery,
273
+ );
274
+ }
275
+
276
+ /**
277
+ * Install the provenance observers. Idempotent per API ROOT (state travels with
278
+ * the root through proxies). Model roles/text/delivery/admission are untouched;
279
+ * the only behavior change is the namespaced `unipiHarness` field on harness
280
+ * user messages.
281
+ */
282
+ export function installHarnessProvenance(pi: ExtensionAPI): void {
283
+ const state = stateFor(pi);
284
+ if (state.installed) return;
285
+ state.installed = true;
286
+ const onAny = pi.on as unknown as (event: string, handler: (event?: unknown, ctx?: unknown) => unknown) => void;
287
+
288
+ onAny("input", (event) => {
289
+ const ev = (event ?? {}) as { text?: unknown; source?: unknown; streamingBehavior?: unknown };
290
+ const text = typeof ev.text === "string" ? ev.text : "";
291
+ const source = typeof ev.source === "string" ? ev.source : "interactive";
292
+ const streaming = ev.streamingBehavior === "steer" || ev.streamingBehavior === "followUp";
293
+ // Effective delivery: the queued value ONLY while streaming (the real SDK
294
+ // omits streamingBehavior on idle events even when the caller passed one —
295
+ // an idle followUp option is ignored and dispatches direct).
296
+ const effective = streaming ? (ev.streamingBehavior as "steer" | "followUp") : "direct";
297
+ // The next input invalidates ALL unmatched direct records (harness and
298
+ // human/rpc alike) — bounds failed/aborted sends. Queued stays.
299
+ state.records = state.records.filter(
300
+ (r) => !(r.delivery === "direct" && r.state === "admitted"),
301
+ );
302
+ state.seq += 1;
303
+ const arm = state.arms[state.arms.length - 1];
304
+ if (arm && source === "extension" && text === arm.text) {
305
+ state.records.push({
306
+ kind: "harness",
307
+ origin: arm.origin,
308
+ title: arm.title,
309
+ synopsis: arm.synopsis,
310
+ severity: arm.severity,
311
+ text: arm.text,
312
+ delivery: effective,
313
+ // Queued is eligible immediately; idle direct only at before_agent_start.
314
+ eligible: streaming,
315
+ state: "admitted",
316
+ seq: arm.seq,
317
+ });
318
+ prune(state);
319
+ return;
320
+ }
321
+ state.records.push({
322
+ kind: source === "extension" ? "unattributed" : "human",
323
+ origin: source,
324
+ text,
325
+ delivery: effective,
326
+ state: "admitted",
327
+ seq: state.seq,
328
+ });
329
+ prune(state);
330
+ });
331
+
332
+ onAny("before_agent_start", (event) => {
333
+ const prompt = (event as { prompt?: unknown } | undefined)?.prompt;
334
+ if (typeof prompt !== "string") return;
335
+ // Idle harness records matching THIS prompt become eligible; other
336
+ // unconfirmed direct records are stale and dropped. Queued untouched.
337
+ for (const r of state.records) {
338
+ if (r.kind === "harness" && r.delivery === "direct" && r.state === "admitted") {
339
+ if (r.text === prompt) r.eligible = true;
340
+ }
341
+ }
342
+ state.records = state.records.filter(
343
+ (r) => !(r.kind === "harness" && r.delivery === "direct" && r.state === "admitted" && !r.eligible),
344
+ );
345
+ prune(state);
346
+ });
347
+
348
+ onAny("message_start", (event) => {
349
+ const message = (event as { message?: unknown } | undefined)?.message;
350
+ if (!message || typeof message !== "object") return;
351
+ const text = userTextFromMessage(message);
352
+ state.processed.add(message as object);
353
+ if (!text) return;
354
+ const meta = selectRecord(state, text);
355
+ if (meta) state.pendingMeta.set(message as object, { meta, text });
356
+ });
357
+
358
+ onAny("message_end", (event) => {
359
+ const message = (event as { message?: unknown } | undefined)?.message;
360
+ if (!message || typeof message !== "object") return undefined;
361
+ if (!state.processed.has(message as object)) return undefined; // never re-select
362
+ const pending = state.pendingMeta.get(message as object);
363
+ if (!pending) return undefined;
364
+ state.pendingMeta.delete(message as object);
365
+ // Later transforms: attach only if the finalized text is still the
366
+ // selected text (original content identity preserved either way).
367
+ if (userTextFromMessage(message) !== pending.text) return undefined;
368
+ return { message: { ...(message as Record<string, unknown>), unipiHarness: pending.meta } };
369
+ });
370
+
371
+ // Terminal boundary: purge everything outstanding (queued records were
372
+ // consumed by their message_start before agent_end).
373
+ onAny("agent_settled", () => {
374
+ state.records = state.records.filter((r) => r.state === "consumed");
375
+ prune(state);
376
+ state.arms.length = 0;
377
+ });
378
+
379
+ // Fresh session context: pending state from the old tree is meaningless.
380
+ for (const evt of ["session_before_switch", "session_before_fork", "session_before_tree", "session_start", "session_shutdown"]) {
381
+ onAny(evt, () => {
382
+ state.records = [];
383
+ state.pendingMeta = new WeakMap();
384
+ state.processed = new WeakSet();
385
+ });
386
+ }
387
+ }
388
+
389
+ /** Test hook: inspect the per-API state. */
390
+ export function harnessStateForTests(pi: object): HarnessRegistryState {
391
+ return stateFor(pi);
392
+ }
@@ -14,21 +14,35 @@ export * from "./bounded-output.js";
14
14
  export * from "./spinner-line.js";
15
15
  export * from "./fusion-status.js";
16
16
  export * from "./long-horizon-status.js";
17
+ export * from "./long-horizon-owner-status.js";
18
+ export * from "./kanboard-status.js";
17
19
  export * from "./plan-permission-status.js";
20
+ export * from "./command-echo.js";
21
+ export * from "./src/turn/arbiter.js";
22
+ export * from "./src/evidence.js";
18
23
 
19
24
  // v3 workspace identity + state layout (marker-file id, per-workspace roots)
20
25
  export * from "./src/workspace/identity.js";
21
26
  export * from "./src/workspace/paths.js";
22
27
  export * from "./src/package-colors.js";
23
28
  export * from "./src/tui/hub-kit.js";
29
+ export * from "./src/tui/kit.js";
30
+ export * from "./src/tui/progress.js";
24
31
  export * from "./src/jev/client.js";
25
32
  export * from "./src/jev/settings.js";
33
+ export * from "./src/jev/chatter.js";
26
34
  export * from "./src/workspace/state-migration.js";
27
35
  // v3 settings engine + migration (canonical ~/.unipi/config layout)
28
36
  export * from "./src/settings/paths.js";
29
37
  export * from "./src/settings/engine.js";
30
38
  export * from "./src/settings/schema.js";
31
39
  export { SettingsHub, type SettingsHubDeps } from "./src/settings/hub.js";
40
+ export { openSettingsHub } from "./src/settings/open.js";
41
+ export * from "./src/attach/detect.js";
32
42
  export * from "./src/settings/catalog.js";
33
43
  export * from "./command-runner.js";
44
+ export * from "./compaction-context.js";
34
45
  export * from "./src/settings/migrations.js";
46
+ export * from "./src/hints/index.js";
47
+ export * from "./src/hints/store.js";
48
+ export * from "./harness-messages.js";
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @pi-unipi/core — shared kanboard status
3
+ *
4
+ * The kanboard package owns the session's board claims and autowork flag;
5
+ * the footer package owns the glance title segment that should display them
6
+ * (`▣ UNI-30`, `▣ UNI-30 +1`, `▣ autowork`). Same Symbol.for global pattern
7
+ * as fusion-status: published by kanboard after board changes and at each
8
+ * settle, pulled by the footer every render.
9
+ */
10
+
11
+ export interface SharedKanboardStatus {
12
+ /** Ids this session holds In Progress (board order). */
13
+ claims: string[];
14
+ /** Autowork is on: the arbiter keeps offering the next ready task. */
15
+ autowork: boolean;
16
+ }
17
+
18
+ const KEY = Symbol.for("unipi.kanboard.status");
19
+
20
+ type Holder = { status?: SharedKanboardStatus | undefined };
21
+
22
+ function holder(): Holder {
23
+ const g = globalThis as { [KEY]?: Holder };
24
+ g[KEY] ??= {};
25
+ return g[KEY] as Holder;
26
+ }
27
+
28
+ /** Publish the current claims/autowork snapshot (undefined clears it). */
29
+ export function setSharedKanboardStatus(status: SharedKanboardStatus | undefined): void {
30
+ holder().status = status;
31
+ }
32
+
33
+ /** Read the current snapshot, or undefined when kanboard never published. */
34
+ export function getSharedKanboardStatus(): SharedKanboardStatus | undefined {
35
+ return holder().status;
36
+ }
37
+
38
+ /** Glance label for a snapshot: `▣ UNI-30`, `▣ UNI-30 +1`, `▣ autowork`,
39
+ * `▣ UNI-30 · autowork`. Null when there is nothing to show. */
40
+ export function kanboardGlanceLabel(status: SharedKanboardStatus | null | undefined): string | null {
41
+ if (!status) return null;
42
+ const { claims, autowork } = status;
43
+ if (claims.length === 0 && !autowork) return null;
44
+ const claimPart =
45
+ claims.length === 0 ? null : claims.length === 1 ? claims[0]! : `${claims[0]} +${String(claims.length - 1)}`;
46
+ const autoworkPart = autowork ? "autowork" : null;
47
+ const body = [claimPart, autoworkPart].filter(Boolean).join(" · ");
48
+ return `▣ ${body}`;
49
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @pi-unipi/core — shared long-horizon owner status
3
+ *
4
+ * The long-horizon package owns the automation owner lifecycle; kanboard's
5
+ * monitor (step 4) needs to defer to an active owner and to notice one that
6
+ * STOPPED this run ("goal paused — UNI-30 left In Progress"). Same
7
+ * Symbol.for global pattern as long-horizon-status: published on every owner
8
+ * transition, pulled by readers whenever they settle.
9
+ */
10
+
11
+ export interface SharedOwnerStatus {
12
+ /** Current owner, when one exists (active, or parked in the single slot). */
13
+ owner?: { kind: string; status: "active" | "parked" };
14
+ /** Last owner stop. Kanboard compares `at` against its turn-start marker. */
15
+ lastStop?: { kind: "complete" | "paused" | "budget" | "other"; at: number };
16
+ }
17
+
18
+ const KEY = Symbol.for("unipi.longHorizon.ownerStatus");
19
+
20
+ type Holder = { status?: SharedOwnerStatus | undefined };
21
+
22
+ function holder(): Holder {
23
+ const g = globalThis as { [KEY]?: Holder };
24
+ g[KEY] ??= {};
25
+ return g[KEY] as Holder;
26
+ }
27
+
28
+ /** Publish the current owner (undefined clears it — no owner active or parked). */
29
+ export function setSharedOwner(owner: SharedOwnerStatus["owner"]): void {
30
+ const h = holder();
31
+ h.status ??= {};
32
+ if (owner === undefined) delete h.status.owner;
33
+ else h.status.owner = owner;
34
+ }
35
+
36
+ /** Record that an owner stopped, with the mapped terminal kind. */
37
+ export function markSharedOwnerStopped(kind: "complete" | "paused" | "budget" | "other"): void {
38
+ const h = holder();
39
+ h.status ??= {};
40
+ h.status.lastStop = { kind, at: Date.now() };
41
+ }
42
+
43
+ /** Read the published status, or undefined before the first transition. */
44
+ export function getSharedOwnerStatus(): SharedOwnerStatus | undefined {
45
+ return holder().status;
46
+ }
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * @pi-unipi/core — Model Cache
3
3
  *
4
- * File-based model list cache at ~/.unipi/config/models-cache.json.
5
- * Allows TUI components and other packages to list available models
6
- * without needing ctx.modelRegistry at runtime.
4
+ * File-based model list cache at ~/.unipi/config/models-cache.json — the one
5
+ * catalog every unipi surface reads (settings hub pickers, kanboard, image
6
+ * pickers) without needing ctx.modelRegistry. Written by utility on every
7
+ * session start from pi's live registry (models with credentials), plus the
8
+ * image-generation models pi-ai publishes separately.
7
9
  */
8
10
 
9
11
  import * as fs from "node:fs";
@@ -18,10 +20,12 @@ function cacheDir(): string {
18
20
  }
19
21
 
20
22
  /** Resolve the model cache file path at call time. */
21
- function cacheFile(): string {
23
+ export function modelCachePath(): string {
22
24
  return path.join(cacheDir(), "models-cache.json");
23
25
  }
24
26
 
27
+ export type Modality = "text" | "image";
28
+
25
29
  /** A single cached model entry */
26
30
  export interface CachedModel {
27
31
  /** Model provider (e.g. "openai", "anthropic") */
@@ -30,6 +34,14 @@ export interface CachedModel {
30
34
  id: string;
31
35
  /** Optional display name */
32
36
  name?: string;
37
+ /** Input modalities. Absent in pre-v3 caches → treat as ["text"]. */
38
+ input?: Modality[];
39
+ /** Output modalities. Chat models → ["text"]; image generators include "image". */
40
+ output?: Modality[];
41
+ /** "chat" = pi chat registry, "images" = pi-ai images collection / generator. */
42
+ kind?: "chat" | "images";
43
+ contextWindow?: number;
44
+ reasoning?: boolean;
33
45
  }
34
46
 
35
47
  /** The full model cache structure */
@@ -40,13 +52,64 @@ export interface ModelCache {
40
52
  models: CachedModel[];
41
53
  }
42
54
 
55
+ /** `provider/id` reference for a cached model. */
56
+ export function modelRef(model: Pick<CachedModel, "provider" | "id">): string {
57
+ return `${model.provider}/${model.id}`;
58
+ }
59
+
60
+ export function modelInputs(model: CachedModel): Modality[] {
61
+ return model.input?.length ? model.input : ["text"];
62
+ }
63
+
64
+ export function modelOutputs(model: CachedModel): Modality[] {
65
+ return model.output?.length ? model.output : ["text"];
66
+ }
67
+
68
+ /**
69
+ * Filter by modality. `input` lists what the model must accept (all of them),
70
+ * `output` what it must produce (any of them).
71
+ */
72
+ export function filterModels(
73
+ models: readonly CachedModel[],
74
+ want: { input?: Modality[]; output?: Modality[] },
75
+ ): CachedModel[] {
76
+ return models.filter((m) => {
77
+ const inputs = modelInputs(m);
78
+ const outputs = modelOutputs(m);
79
+ if (want.input && !want.input.every((i) => inputs.includes(i))) return false;
80
+ if (want.output && !want.output.some((o) => outputs.includes(o))) return false;
81
+ return true;
82
+ });
83
+ }
84
+
85
+ /** Map pi registry models (chat) to cache entries. Tolerates partial shapes. */
86
+ export function chatModelsToCache(models: readonly unknown[]): CachedModel[] {
87
+ const out: CachedModel[] = [];
88
+ for (const raw of models) {
89
+ const m = raw as { provider?: unknown; id?: unknown; name?: unknown; input?: unknown; contextWindow?: unknown; reasoning?: unknown };
90
+ if (typeof m?.provider !== "string" || typeof m?.id !== "string") continue;
91
+ const input = Array.isArray(m.input) ? (m.input.filter((x) => x === "text" || x === "image") as Modality[]) : ["text" as const];
92
+ out.push({
93
+ provider: m.provider,
94
+ id: m.id,
95
+ ...(typeof m.name === "string" ? { name: m.name } : {}),
96
+ input: input.length ? input : ["text"],
97
+ output: ["text"],
98
+ kind: "chat",
99
+ ...(typeof m.contextWindow === "number" ? { contextWindow: m.contextWindow } : {}),
100
+ ...(typeof m.reasoning === "boolean" ? { reasoning: m.reasoning } : {}),
101
+ });
102
+ }
103
+ return out;
104
+ }
105
+
43
106
  /**
44
107
  * Read cached model list from disk.
45
108
  * Returns empty array if no cache file exists or it's malformed.
46
109
  */
47
110
  export function readModelCache(): CachedModel[] {
48
111
  try {
49
- const file = cacheFile();
112
+ const file = modelCachePath();
50
113
  if (!fs.existsSync(file)) return [];
51
114
  const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
52
115
  return Array.isArray(parsed.models) ? parsed.models : [];
@@ -58,6 +121,8 @@ export function readModelCache(): CachedModel[] {
58
121
  /**
59
122
  * Write model list to cache file.
60
123
  * Creates directory if needed. Best effort — silently ignores errors.
124
+ * Entries are de-duplicated by `provider/id` (first wins) and sorted, so the
125
+ * file is stable across sessions with the same catalog.
61
126
  */
62
127
  export function writeModelCache(models: CachedModel[]): void {
63
128
  try {
@@ -65,11 +130,20 @@ export function writeModelCache(models: CachedModel[]): void {
65
130
  if (!fs.existsSync(dir)) {
66
131
  fs.mkdirSync(dir, { recursive: true });
67
132
  }
133
+ const seen = new Set<string>();
134
+ const unique = models.filter((m) => {
135
+ const key = modelRef(m);
136
+ if (seen.has(key)) return false;
137
+ seen.add(key);
138
+ return true;
139
+ }).sort((a, b) => modelRef(a).localeCompare(modelRef(b)));
68
140
  const cache: ModelCache = {
69
141
  updatedAt: new Date().toISOString(),
70
- models,
142
+ models: unique,
71
143
  };
72
- fs.writeFileSync(cacheFile(), JSON.stringify(cache, null, 2) + "\n", "utf-8");
144
+ const tmp = `${modelCachePath()}.${process.pid}.tmp`;
145
+ fs.writeFileSync(tmp, JSON.stringify(cache, null, 2) + "\n", "utf-8");
146
+ fs.renameSync(tmp, modelCachePath());
73
147
  } catch {
74
148
  // Best effort — cache is optional
75
149
  }