elanous 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (300) hide show
  1. package/README.md +5 -4
  2. package/apps/pwa/out/404/index.html +1 -1
  3. package/apps/pwa/out/404.html +1 -1
  4. package/apps/pwa/out/_next/static/chunks/1169-696263ac7af8c4d4.js +1 -0
  5. package/apps/pwa/out/_next/static/chunks/{7527.943753b6ed829f09.js → 1883.c91a2648af4842fd.js} +1 -1
  6. package/apps/pwa/out/_next/static/chunks/1947-9d64872eeb9cfdec.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/2808-dc06482994b40f70.js +1 -0
  8. package/apps/pwa/out/_next/static/chunks/{8918-482b30ce2a8ebf4c.js → 2984-977cfa339fcd65a5.js} +3 -3
  9. package/apps/pwa/out/_next/static/chunks/{4853-35fdbe6bdbcdc0fc.js → 3165-95515ca5f299a120.js} +1 -1
  10. package/apps/pwa/out/_next/static/chunks/3190-e9282753e7ebde85.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/{5391-8793f88b03b30161.js → 3995-bee512a7063036ce.js} +1 -1
  12. package/apps/pwa/out/_next/static/chunks/{9704-3221c102623c8d73.js → 4240-7153881b0b377306.js} +1 -1
  13. package/apps/pwa/out/_next/static/chunks/4259-99614149577e77cb.js +1 -0
  14. package/apps/pwa/out/_next/static/chunks/4283-329eabeaf233e6cd.js +1 -0
  15. package/apps/pwa/out/_next/static/chunks/4328-cb5058b1acc2de5e.js +25 -0
  16. package/apps/pwa/out/_next/static/chunks/4801-ecee6ef62b349b18.js +1 -0
  17. package/apps/pwa/out/_next/static/chunks/5110-8058791dd25a2e14.js +1 -0
  18. package/apps/pwa/out/_next/static/chunks/5329.8b7e4bca684b6eec.js +5 -0
  19. package/apps/pwa/out/_next/static/chunks/5330-c41e9670f594c38f.js +1 -0
  20. package/apps/pwa/out/_next/static/chunks/5423-e95fd125d1794349.js +1 -0
  21. package/apps/pwa/out/_next/static/chunks/5478-b5eae07dc79ea619.js +1 -0
  22. package/apps/pwa/out/_next/static/chunks/5787-96ee6642b9a661e5.js +1 -0
  23. package/apps/pwa/out/_next/static/chunks/6448-79d72368589f98bc.js +1 -0
  24. package/apps/pwa/out/_next/static/chunks/6522-9866c002b5531836.js +1 -0
  25. package/apps/pwa/out/_next/static/chunks/6729-b4a558b7aa891e0c.js +1 -0
  26. package/apps/pwa/out/_next/static/chunks/6969-cb03f9410b7d2e03.js +1 -0
  27. package/apps/pwa/out/_next/static/chunks/7007-3b9861091bb8dd21.js +1 -0
  28. package/apps/pwa/out/_next/static/chunks/{5348-bbf838e119661eee.js → 763-7d0e1913b5074e98.js} +1 -1
  29. package/apps/pwa/out/_next/static/chunks/{8617.465ca22a319c95b3.js → 7633.0654fe6ea259c1ee.js} +1 -1
  30. package/apps/pwa/out/_next/static/chunks/7930-8dc7e20fc6fc19ca.js +1 -0
  31. package/apps/pwa/out/_next/static/chunks/8071-187edbc22a86741f.js +1 -0
  32. package/apps/pwa/out/_next/static/chunks/8198-d4101ad70cb9a579.js +1 -0
  33. package/apps/pwa/out/_next/static/chunks/{9238.2cd5f47f4bf7a2c3.js → 8250.4a2c5553a07cdf47.js} +1 -1
  34. package/apps/pwa/out/_next/static/chunks/8293-123b91e7b0330dc0.js +1 -0
  35. package/apps/pwa/out/_next/static/chunks/8296-d6d5d87dfa911d41.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/8305-603963c33ee8eec3.js +1 -0
  37. package/apps/pwa/out/_next/static/chunks/8612-4a0066f20951b8fd.js +1 -0
  38. package/apps/pwa/out/_next/static/chunks/{4daa2798-1363d725c79f3593.js → 8d0d9faf-97887ff99713b84e.js} +1 -1
  39. package/apps/pwa/out/_next/static/chunks/9181-cf4e1b61c4783f92.js +1 -0
  40. package/apps/pwa/out/_next/static/chunks/{f6f3850d-7f324c6566002113.js → 91ff6dda-3b33d42d20657429.js} +1 -1
  41. package/apps/pwa/out/_next/static/chunks/9262-26c2ecbd5a67e4a2.js +1 -0
  42. package/apps/pwa/out/_next/static/chunks/9328-45abdebce4fbecb7.js +1 -0
  43. package/apps/pwa/out/_next/static/chunks/9355-dbc67205adf8745f.js +1 -0
  44. package/apps/pwa/out/_next/static/chunks/9763.255b90dd69875689.js +1 -0
  45. package/apps/pwa/out/_next/static/chunks/9933-66d31ec25c0231af.js +1 -0
  46. package/apps/pwa/out/_next/static/chunks/app/_not-found/page-bd06fb55f4da0381.js +1 -0
  47. package/apps/pwa/out/_next/static/chunks/app/approvals/page-4e586142da455a25.js +1 -0
  48. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-889ad5cddcf45b0d.js +1 -0
  49. package/apps/pwa/out/_next/static/chunks/app/board/page-47ba401d56572c27.js +1 -0
  50. package/apps/pwa/out/_next/static/chunks/app/botlab/page-0b54f9c83458e038.js +1 -0
  51. package/apps/pwa/out/_next/static/chunks/app/bots/page-4fcc296ef6c8ed90.js +1 -0
  52. package/apps/pwa/out/_next/static/chunks/app/chat/page-fe9fda494a87f4e5.js +1 -0
  53. package/apps/pwa/out/_next/static/chunks/app/control/page-f9642e25ce129590.js +1 -0
  54. package/apps/pwa/out/_next/static/chunks/app/design-check/page-e1063e8ba5f3e2e7.js +1 -0
  55. package/apps/pwa/out/_next/static/chunks/app/editor/page-9d0df30027d8aa9d.js +1 -0
  56. package/apps/pwa/out/_next/static/chunks/app/healthz/route-bd06fb55f4da0381.js +1 -0
  57. package/apps/pwa/out/_next/static/chunks/app/intake/page-d97adf2c9455b8d0.js +1 -0
  58. package/apps/pwa/out/_next/static/chunks/app/layout-236b80d202110362.js +1 -0
  59. package/apps/pwa/out/_next/static/chunks/app/live/page-21a30f35f1a276ad.js +1 -0
  60. package/apps/pwa/out/_next/static/chunks/app/market/page-b1a5be357c4fa25d.js +1 -0
  61. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-1705a60303f7f573.js +1 -0
  62. package/apps/pwa/out/_next/static/chunks/app/morning/page-76b337c11ad0e237.js +1 -0
  63. package/apps/pwa/out/_next/static/chunks/app/not-found-7c0f85fbb56f64c6.js +1 -0
  64. package/apps/pwa/out/_next/static/chunks/app/observatory/page-65ca16b20617b44d.js +1 -0
  65. package/apps/pwa/out/_next/static/chunks/app/page-e7f9b8fc29411c1d.js +1 -0
  66. package/apps/pwa/out/_next/static/chunks/app/reflection/page-92ea6213d1aeec04.js +1 -0
  67. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-a913a005aacb5476.js +1 -0
  68. package/apps/pwa/out/_next/static/chunks/app/sessions/page-2d78804a40dfdef6.js +1 -0
  69. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-08a99148e780ac00.js +1 -0
  70. package/apps/pwa/out/_next/static/chunks/app/settings/page-deb674e7ed36f815.js +1 -0
  71. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-49f8de0534d192d7.js +1 -0
  72. package/apps/pwa/out/_next/static/chunks/app/setup/layout-bd06fb55f4da0381.js +1 -0
  73. package/apps/pwa/out/_next/static/chunks/app/setup/page-ba162a28d2176e18.js +1 -0
  74. package/apps/pwa/out/_next/static/chunks/app/share/page-1ff1a37b550ab41a.js +1 -0
  75. package/apps/pwa/out/_next/static/chunks/app/showroom/page-ccaa698f56c0fbdb.js +1 -0
  76. package/apps/pwa/out/_next/static/chunks/app/tasks/page-53b944082de1d7db.js +1 -0
  77. package/apps/pwa/out/_next/static/chunks/app/term/page-9b70adf1552789bf.js +1 -0
  78. package/apps/pwa/out/_next/static/chunks/app/trace/page-30d482aa63241fe8.js +1 -0
  79. package/apps/pwa/out/_next/static/chunks/app/vault/page-aecf786f54651d8a.js +1 -0
  80. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-6ab9b62c7b6e08f2.js +1 -0
  81. package/apps/pwa/out/_next/static/chunks/app/workflows/page-08949735455e0c9a.js +1 -0
  82. package/apps/pwa/out/_next/static/chunks/app/workspace/page-a1479005234a46c2.js +1 -0
  83. package/apps/pwa/out/_next/static/chunks/app/worktrees/{page-84cd419494e16b0f.js → page-c3890e90bd232732.js} +1 -1
  84. package/apps/pwa/out/_next/static/chunks/{06ef2eba-d1b99ac6d2ea663c.js → beb5cf10-9adcc10b83930090.js} +1 -1
  85. package/apps/pwa/out/_next/static/chunks/framework-d68d41c88e05c801.js +1 -0
  86. package/apps/pwa/out/_next/static/chunks/main-app-11c63971b7a9593b.js +1 -0
  87. package/apps/pwa/out/_next/static/chunks/main-d09452353bd0d9cc.js +1 -0
  88. package/apps/pwa/out/_next/static/chunks/pages/_app-184d727cebeca767.js +1 -0
  89. package/apps/pwa/out/_next/static/chunks/pages/_error-21bbbbac99b720b1.js +1 -0
  90. package/apps/pwa/out/_next/static/chunks/{webpack-2bf59c720fe9542c.js → webpack-37b8bf78a3c6eec5.js} +1 -1
  91. package/apps/pwa/out/_next/static/{ktMqfrBbf8BMGac0jqLrb → qMI0jnlZaBjjm4aIDYNGM}/_buildManifest.js +1 -1
  92. package/apps/pwa/out/approvals/index.html +1 -1
  93. package/apps/pwa/out/approvals/index.txt +21 -21
  94. package/apps/pwa/out/autopilot/index.html +1 -1
  95. package/apps/pwa/out/autopilot/index.txt +21 -21
  96. package/apps/pwa/out/board/index.html +1 -1
  97. package/apps/pwa/out/board/index.txt +21 -21
  98. package/apps/pwa/out/botlab/index.html +1 -1
  99. package/apps/pwa/out/botlab/index.txt +20 -20
  100. package/apps/pwa/out/bots/index.html +1 -1
  101. package/apps/pwa/out/bots/index.txt +20 -20
  102. package/apps/pwa/out/chat/index.html +1 -1
  103. package/apps/pwa/out/chat/index.txt +20 -20
  104. package/apps/pwa/out/control/index.html +1 -1
  105. package/apps/pwa/out/control/index.txt +21 -21
  106. package/apps/pwa/out/design-check/index.html +1 -1
  107. package/apps/pwa/out/design-check/index.txt +21 -21
  108. package/apps/pwa/out/editor/index.html +1 -1
  109. package/apps/pwa/out/editor/index.txt +21 -21
  110. package/apps/pwa/out/healthz +1 -1
  111. package/apps/pwa/out/icons/elanous-16.png +0 -0
  112. package/apps/pwa/out/icons/elanous-180.png +0 -0
  113. package/apps/pwa/out/icons/elanous-192.png +0 -0
  114. package/apps/pwa/out/icons/elanous-32.png +0 -0
  115. package/apps/pwa/out/icons/elanous-512.png +0 -0
  116. package/apps/pwa/out/index.html +1 -1
  117. package/apps/pwa/out/index.txt +20 -20
  118. package/apps/pwa/out/intake/index.html +1 -1
  119. package/apps/pwa/out/intake/index.txt +21 -21
  120. package/apps/pwa/out/live/index.html +1 -1
  121. package/apps/pwa/out/live/index.txt +21 -21
  122. package/apps/pwa/out/market/index.html +1 -1
  123. package/apps/pwa/out/market/index.txt +20 -20
  124. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  125. package/apps/pwa/out/missions/placeholder/index.txt +20 -20
  126. package/apps/pwa/out/morning/index.html +1 -1
  127. package/apps/pwa/out/morning/index.txt +21 -21
  128. package/apps/pwa/out/observatory/index.html +1 -1
  129. package/apps/pwa/out/observatory/index.txt +21 -21
  130. package/apps/pwa/out/reflection/index.html +1 -1
  131. package/apps/pwa/out/reflection/index.txt +21 -21
  132. package/apps/pwa/out/scheduler/index.html +1 -1
  133. package/apps/pwa/out/scheduler/index.txt +21 -21
  134. package/apps/pwa/out/sessions/index.html +1 -1
  135. package/apps/pwa/out/sessions/index.txt +20 -20
  136. package/apps/pwa/out/settings/devices/index.html +1 -1
  137. package/apps/pwa/out/settings/devices/index.txt +21 -21
  138. package/apps/pwa/out/settings/index.html +1 -1
  139. package/apps/pwa/out/settings/index.txt +21 -21
  140. package/apps/pwa/out/setup/done/index.html +1 -1
  141. package/apps/pwa/out/setup/done/index.txt +21 -21
  142. package/apps/pwa/out/setup/index.html +1 -1
  143. package/apps/pwa/out/setup/index.txt +21 -21
  144. package/apps/pwa/out/share/index.html +1 -1
  145. package/apps/pwa/out/share/index.txt +21 -21
  146. package/apps/pwa/out/showroom/index.html +1 -1
  147. package/apps/pwa/out/showroom/index.txt +20 -20
  148. package/apps/pwa/out/tasks/index.html +1 -1
  149. package/apps/pwa/out/tasks/index.txt +21 -21
  150. package/apps/pwa/out/term/index.html +1 -1
  151. package/apps/pwa/out/term/index.txt +21 -21
  152. package/apps/pwa/out/trace/index.html +1 -1
  153. package/apps/pwa/out/trace/index.txt +21 -21
  154. package/apps/pwa/out/vault/index.html +1 -1
  155. package/apps/pwa/out/vault/index.txt +21 -21
  156. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  157. package/apps/pwa/out/workflows/chat-ui/index.txt +21 -21
  158. package/apps/pwa/out/workflows/index.html +1 -1
  159. package/apps/pwa/out/workflows/index.txt +21 -21
  160. package/apps/pwa/out/workspace/index.html +1 -1
  161. package/apps/pwa/out/workspace/index.txt +21 -21
  162. package/apps/pwa/out/worktrees/index.html +1 -1
  163. package/apps/pwa/out/worktrees/index.txt +21 -21
  164. package/graphs/release/recipes.yaml +3 -0
  165. package/graphs/release/release-loop.yaml +3 -1
  166. package/package.json +1 -1
  167. package/plugins/card-followup/README.md +33 -0
  168. package/plugins/card-followup/examples/card-sample.json +7 -0
  169. package/plugins/card-followup/graphs/card-followup.yaml +16 -0
  170. package/plugins/card-followup/graphs/recipes.yaml +12 -0
  171. package/plugins/card-followup/graphs/run-step.ts +172 -0
  172. package/plugins/card-followup/plugin.json +25 -0
  173. package/plugins/geo-check/README.md +13 -0
  174. package/plugins/geo-check/examples/brand-sample.json +8 -0
  175. package/plugins/geo-check/graphs/geo-check.yaml +20 -0
  176. package/plugins/geo-check/graphs/recipes.yaml +18 -0
  177. package/plugins/geo-check/graphs/run-step.ts +177 -0
  178. package/plugins/geo-check/plugin.json +24 -0
  179. package/plugins/job-coach/README.md +30 -0
  180. package/scripts/coord-tracks.json +2 -2
  181. package/scripts/daemon-port-baseline.txt +1 -2
  182. package/scripts/lib/android-test-device.ts +32 -0
  183. package/scripts/release-loop/checklist-gate-node.ts +35 -0
  184. package/scripts/release-loop/gate-diff.ts +29 -0
  185. package/scripts/release-loop/gate-node.ts +46 -13
  186. package/scripts/release-loop/shard-plan.ts +47 -0
  187. package/scripts/ux/record-page.ts +4 -1
  188. package/skills/explainer-video/SKILL.md +18 -0
  189. package/skills/explainer-video/engine/brand/elanous-mark-on-dark.svg +1 -1
  190. package/skills/explainer-video/engine/build.mjs +1 -1
  191. package/skills/explainer-video/engine/reel.mjs +128 -0
  192. package/skills/explainer-video/engine/reel.sh +8 -0
  193. package/skills/explainer-video/engine/vo.ts +58 -16
  194. package/src/autopilot/mission-origin.ts +20 -4
  195. package/src/autopilot/mission-phase-track.ts +11 -1
  196. package/src/autopilot/track-agent.ts +3 -3
  197. package/src/autopilot/track-courier.ts +2 -2
  198. package/src/cli/nexus-ios-bind.ts +25 -8
  199. package/src/cli/phone-link.ts +117 -0
  200. package/src/cli/plugin-cli.ts +27 -0
  201. package/src/cli/private-ledger-commands.ts +31 -0
  202. package/src/cli/release-cli.ts +14 -11
  203. package/src/domains/outbound-alert.ts +5 -5
  204. package/src/field/field-media.ts +245 -0
  205. package/src/index.ts +18 -24
  206. package/src/nexus/api/field-uploads.ts +143 -0
  207. package/src/nexus/api/graph-approvals.ts +50 -1
  208. package/src/nexus/api/harness-api.ts +219 -5
  209. package/src/nexus/api/http-server.ts +9 -0
  210. package/src/notify/llm-budget-alert.ts +119 -0
  211. package/src/plugins/maker/plugin-maker.ts +208 -0
  212. package/src/relay/channel.ts +184 -0
  213. package/src/relay/mux.ts +182 -0
  214. package/src/relay/test-vectors.json +29 -0
  215. package/src/release-loop/checklist.ts +28 -2
  216. package/src/seat-address/seat-address.ts +34 -0
  217. package/src/self-implement/run-ledger-compact.ts +1 -1
  218. package/src/session/index.ts +23 -10
  219. package/src/task-orchestrator/surfaces/pod-bun-cache.ts +18 -0
  220. package/src/task-orchestrator/surfaces/pod-command-job.ts +12 -3
  221. package/src/telegram.ts +108 -1
  222. package/src/user-config.ts +4 -0
  223. package/src/version/packed-revision.json +1 -1
  224. package/src/video-pipeline/recipes/explainer.ts +213 -0
  225. package/src/video-pipeline/walk-line.ts +3 -2
  226. package/apps/pwa/out/_next/static/chunks/1413-7179a49332db5a80.js +0 -1
  227. package/apps/pwa/out/_next/static/chunks/150-25c277137a9638ab.js +0 -1
  228. package/apps/pwa/out/_next/static/chunks/1640-bc99ec23bbe29d12.js +0 -1
  229. package/apps/pwa/out/_next/static/chunks/1751.50b96ea9c37b49b6.js +0 -1
  230. package/apps/pwa/out/_next/static/chunks/2300-de059d0ab2a16630.js +0 -1
  231. package/apps/pwa/out/_next/static/chunks/2684-05b0a81e75298c16.js +0 -1
  232. package/apps/pwa/out/_next/static/chunks/3026-2ed7e317d9b02102.js +0 -25
  233. package/apps/pwa/out/_next/static/chunks/3611-77496ada38cd6358.js +0 -1
  234. package/apps/pwa/out/_next/static/chunks/3717-68957c4dc9ceec90.js +0 -1
  235. package/apps/pwa/out/_next/static/chunks/3929-46ba9af6f9d895cc.js +0 -1
  236. package/apps/pwa/out/_next/static/chunks/4015-62c5a3744698a8da.js +0 -1
  237. package/apps/pwa/out/_next/static/chunks/4217-3c4317a7905f9eeb.js +0 -1
  238. package/apps/pwa/out/_next/static/chunks/4240-3230f230fae2d7d5.js +0 -1
  239. package/apps/pwa/out/_next/static/chunks/4271-e53e075ebd48593b.js +0 -1
  240. package/apps/pwa/out/_next/static/chunks/4377.3b125fef79576dfd.js +0 -5
  241. package/apps/pwa/out/_next/static/chunks/4440-18bb160d95819f4e.js +0 -1
  242. package/apps/pwa/out/_next/static/chunks/5007-6a3b30e23ff68a6e.js +0 -1
  243. package/apps/pwa/out/_next/static/chunks/5244-12b424c42070f5ff.js +0 -1
  244. package/apps/pwa/out/_next/static/chunks/5611-df00af3e4e4a61bb.js +0 -1
  245. package/apps/pwa/out/_next/static/chunks/5890-dca2321773be829a.js +0 -1
  246. package/apps/pwa/out/_next/static/chunks/6123-37c9dc57ddaa7ab0.js +0 -1
  247. package/apps/pwa/out/_next/static/chunks/6588-b7975304a302ab91.js +0 -1
  248. package/apps/pwa/out/_next/static/chunks/6604-c03961962b6273b6.js +0 -1
  249. package/apps/pwa/out/_next/static/chunks/7109-cf8c87e968e83a65.js +0 -1
  250. package/apps/pwa/out/_next/static/chunks/7178-30f77877525a2efb.js +0 -1
  251. package/apps/pwa/out/_next/static/chunks/7262-871563fc9663730b.js +0 -1
  252. package/apps/pwa/out/_next/static/chunks/7294-f0eeebb92aa93459.js +0 -1
  253. package/apps/pwa/out/_next/static/chunks/7429-881af44deed6958f.js +0 -1
  254. package/apps/pwa/out/_next/static/chunks/764-a01e858525a190c0.js +0 -1
  255. package/apps/pwa/out/_next/static/chunks/7868-e35d5f7985ecd3f1.js +0 -1
  256. package/apps/pwa/out/_next/static/chunks/8320-51484af7b01fdfa5.js +0 -1
  257. package/apps/pwa/out/_next/static/chunks/9131-52f5fad51cceca04.js +0 -1
  258. package/apps/pwa/out/_next/static/chunks/app/_not-found/page-70c404c7bb6d7f0f.js +0 -1
  259. package/apps/pwa/out/_next/static/chunks/app/approvals/page-3ffd80166b1050aa.js +0 -1
  260. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-f89e9a303515a033.js +0 -1
  261. package/apps/pwa/out/_next/static/chunks/app/board/page-4f403cd060442c95.js +0 -1
  262. package/apps/pwa/out/_next/static/chunks/app/botlab/page-0ac5d053f0444d21.js +0 -1
  263. package/apps/pwa/out/_next/static/chunks/app/bots/page-4bcc63e272ae8205.js +0 -1
  264. package/apps/pwa/out/_next/static/chunks/app/chat/page-880734bd07d62882.js +0 -1
  265. package/apps/pwa/out/_next/static/chunks/app/control/page-72bd384898d88955.js +0 -1
  266. package/apps/pwa/out/_next/static/chunks/app/design-check/page-1018bbc9586ecae5.js +0 -1
  267. package/apps/pwa/out/_next/static/chunks/app/editor/page-de86f8c38076dd7b.js +0 -1
  268. package/apps/pwa/out/_next/static/chunks/app/healthz/route-70c404c7bb6d7f0f.js +0 -1
  269. package/apps/pwa/out/_next/static/chunks/app/intake/page-cd6447196d8bafe6.js +0 -1
  270. package/apps/pwa/out/_next/static/chunks/app/layout-e733630c47fa102b.js +0 -1
  271. package/apps/pwa/out/_next/static/chunks/app/live/page-d1c1a8357b161875.js +0 -1
  272. package/apps/pwa/out/_next/static/chunks/app/market/page-60c514128e81d787.js +0 -1
  273. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-8b637b7e325e13c1.js +0 -1
  274. package/apps/pwa/out/_next/static/chunks/app/morning/page-bccce0babceae30f.js +0 -1
  275. package/apps/pwa/out/_next/static/chunks/app/not-found-63301d11dbe73836.js +0 -1
  276. package/apps/pwa/out/_next/static/chunks/app/observatory/page-a8cf0dc472060db2.js +0 -1
  277. package/apps/pwa/out/_next/static/chunks/app/page-02fba54881ccfa49.js +0 -1
  278. package/apps/pwa/out/_next/static/chunks/app/reflection/page-d5e8e90f0038f8de.js +0 -1
  279. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-b1ef6824f494466a.js +0 -1
  280. package/apps/pwa/out/_next/static/chunks/app/sessions/page-d589bb0f233b1009.js +0 -1
  281. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-c9bbaebef4cda059.js +0 -1
  282. package/apps/pwa/out/_next/static/chunks/app/settings/page-3639ce7916b92fed.js +0 -1
  283. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-7450ae7478955653.js +0 -1
  284. package/apps/pwa/out/_next/static/chunks/app/setup/layout-70c404c7bb6d7f0f.js +0 -1
  285. package/apps/pwa/out/_next/static/chunks/app/setup/page-b41ca4abfd618452.js +0 -1
  286. package/apps/pwa/out/_next/static/chunks/app/share/page-b333dd79ebee3fa9.js +0 -1
  287. package/apps/pwa/out/_next/static/chunks/app/showroom/page-8be1227492793655.js +0 -1
  288. package/apps/pwa/out/_next/static/chunks/app/tasks/page-6e03e9371b552e0d.js +0 -1
  289. package/apps/pwa/out/_next/static/chunks/app/term/page-911411e9fca18d03.js +0 -1
  290. package/apps/pwa/out/_next/static/chunks/app/trace/page-792170e649b37088.js +0 -1
  291. package/apps/pwa/out/_next/static/chunks/app/vault/page-c40463466937bc91.js +0 -1
  292. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-509c798b80384536.js +0 -1
  293. package/apps/pwa/out/_next/static/chunks/app/workflows/page-1cac2aa21d77c417.js +0 -1
  294. package/apps/pwa/out/_next/static/chunks/app/workspace/page-9626799d9b4a2cd1.js +0 -1
  295. package/apps/pwa/out/_next/static/chunks/framework-361487d2da603917.js +0 -1
  296. package/apps/pwa/out/_next/static/chunks/main-app-607f5beb94cb2e07.js +0 -1
  297. package/apps/pwa/out/_next/static/chunks/main-d0222d219e29dddf.js +0 -1
  298. package/apps/pwa/out/_next/static/chunks/pages/_app-a26980a31126decc.js +0 -1
  299. package/apps/pwa/out/_next/static/chunks/pages/_error-edb2b37decf23e4d.js +0 -1
  300. /package/apps/pwa/out/_next/static/{ktMqfrBbf8BMGac0jqLrb → qMI0jnlZaBjjm4aIDYNGM}/_ssgManifest.js +0 -0
@@ -11,6 +11,7 @@ import { getUserConfig } from '../../user-config.js';
11
11
  import { debug } from '../../debug/log.js';
12
12
  import { effectiveInstanceRoot } from '../../instance/resolve.js';
13
13
  import { collectPodArtifacts } from './pod-artifact-return.js';
14
+ import { podBunCacheVolume } from './pod-bun-cache.js';
14
15
  import { type PodPoolMember, PodPoolScheduler, resolvePodPoolSpec, parsePodPool, checkPodPool, syncPoolImages, type PoolKubectl } from './pod-pool.js';
15
16
  import { readSkillEnvFiles } from './pod-skills.js';
16
17
  import { podSourceScript, type PodSource } from './pod-source-receive.js';
@@ -76,6 +77,7 @@ export interface PodCommandJobInput {
76
77
  /** 저장소를 clone 한다 — 비공개 저장소라 GitHub 토큰이 Secret 으로 간다(명시 opt-in). 기본은 clone 없이 `~/work` 에서 이미지의 `elanous` 로 돈다. */
77
78
  clone?: boolean;
78
79
  hostMirror?: string;
80
+ bunCache?: string;
79
81
  /** 컨테이너 메모리 한도(기본 16Gi) — 게이트가 파일 하나 격리 Job 에 올린다. */
80
82
  memoryLimit?: string;
81
83
  source?: PodSource;
@@ -94,6 +96,7 @@ export function podCommandJobManifest(o: PodCommandJobInput): Record<string, unk
94
96
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(name)) throw new Error(`pod command skill name rejected: ${name}`);
95
97
  }
96
98
  const grok = o.llm === 'grok';
99
+ const bunCache = o.bunCache ? podBunCacheVolume(o.bunCache) : undefined;
97
100
  const quotedArgs = o.command.map(bashSingleQuote).join(' ');
98
101
  const credLines = [
99
102
  ...(grok ? ['mkdir -p ~/.grok && install -m 600 /creds/grok-auth.json ~/.grok/auth.json'] : []),
@@ -108,6 +111,7 @@ export function podCommandJobManifest(o: PodCommandJobInput): Record<string, unk
108
111
  ...(o.clone
109
112
  ? ['export GH_TOKEN="$(cat /creds/gh-token)" && gh auth setup-git', podSourceScript(o.source ?? { kind: 'default' }, o.repoUrl)]
110
113
  : ['mkdir -p ~/work && cd ~/work']),
114
+ ...(bunCache ? [bunCache.shellPrefix] : []),
111
115
  `set -- ${quotedArgs}`,
112
116
  '"$@"',
113
117
  'rc=$?',
@@ -138,14 +142,16 @@ export function podCommandJobManifest(o: PodCommandJobInput): Record<string, unk
138
142
  { name: 'ELANOUS_SUBSTRATE', value: 'pod' },
139
143
  { name: 'ELANOUS_POD_NAME', valueFrom: { fieldRef: { fieldPath: 'metadata.name' } } },
140
144
  ],
141
- ...(secretKeys || o.hostMirror ? { volumeMounts: [
145
+ ...(secretKeys || o.hostMirror || bunCache ? { volumeMounts: [
142
146
  ...(secretKeys ? [{ name: 'creds', mountPath: '/creds', readOnly: true }] : []),
143
147
  ...(o.hostMirror ? [{ name: 'host-mirror', mountPath: '/host-mirror', readOnly: true }] : []),
148
+ ...(bunCache ? [bunCache.volumeMount] : []),
144
149
  ] } : {}),
145
150
  }],
146
- ...(secretKeys || o.hostMirror ? { volumes: [
151
+ ...(secretKeys || o.hostMirror || bunCache ? { volumes: [
147
152
  ...(secretKeys ? [{ name: 'creds', secret: { secretName: `${o.name}-creds`, defaultMode: 0o400 } }] : []),
148
153
  ...(o.hostMirror ? [{ name: 'host-mirror', hostPath: { path: o.hostMirror, type: 'Directory' } }] : []),
154
+ ...(bunCache ? [bunCache.volume] : []),
149
155
  ] } : {}),
150
156
  },
151
157
  },
@@ -201,6 +207,7 @@ export interface RunPodCommandOptions {
201
207
  llm?: 'grok';
202
208
  clone?: boolean;
203
209
  hostMirror?: string;
210
+ bunCache?: string;
204
211
  /** 컨테이너 메모리 한도(기본 16Gi). */
205
212
  memoryLimit?: string;
206
213
  source?: PodSource;
@@ -263,6 +270,8 @@ export async function runPodCommand(options: RunPodCommandOptions): Promise<PodC
263
270
  const hostMirror = (options.hostMirror ?? env.ELANOUS_POD_HOST_MIRROR
264
271
  ?? (options.configHostMirror ?? (() => getUserConfig().pod?.hostMirror))())?.trim() || undefined;
265
272
  if (hostMirror && !isAbsolute(hostMirror)) throw new Error('pod.hostMirror must be an absolute directory path');
273
+ const bunCache = options.bunCache?.trim() || undefined;
274
+ if (bunCache && !isAbsolute(bunCache)) throw new Error('pod.bunCache must be an absolute directory path');
266
275
 
267
276
  let member: PodPoolMember | null = null;
268
277
  let pool = options.poolScheduler;
@@ -328,7 +337,7 @@ export async function runPodCommand(options: RunPodCommandOptions): Promise<PodC
328
337
  name, namespace, image: member?.imageRef ?? image,
329
338
  ...(member?.imageRef ? { imagePullPolicy: 'IfNotPresent' as const } : {}),
330
339
  repoUrl, command, skills, ...(llm ? { llm } : {}), ...(options.clone ? { clone: true } : {}),
331
- ...(options.source ? { source: options.source } : {}), ...(hostMirror ? { hostMirror } : {}), ...(options.memoryLimit ? { memoryLimit: options.memoryLimit } : {}), deadlineSeconds,
340
+ ...(options.source ? { source: options.source } : {}), ...(hostMirror ? { hostMirror } : {}), ...(bunCache ? { bunCache } : {}), ...(options.memoryLimit ? { memoryLimit: options.memoryLimit } : {}), deadlineSeconds,
332
341
  ...(options.runId ? { runId: options.runId } : {}),
333
342
  });
334
343
  kubectl(['-n', namespace, 'delete', 'job', name, '--ignore-not-found']);
package/src/telegram.ts CHANGED
@@ -67,6 +67,8 @@ import { runUrlRoute } from './skills/url-route-exec.js';
67
67
  import { recordRoutedLinkAbsorbed } from './intake-plane/link-ledger.js';
68
68
  import { classifyFrontInput } from './intake-plane/front-classifier.js';
69
69
  import { effectiveInstanceRoot } from './instance/resolve.js';
70
+ import { getElanousConfigDir } from './elanous-config-dir.js';
71
+ import { defaultFieldEvent, FieldUploadError, parseFieldCaption, resolveFieldMime, saveFieldMedia } from './field/field-media.js';
70
72
 
71
73
  export type TgAttachmentKind = 'photo' | 'voice' | 'audio' | 'document';
72
74
 
@@ -105,6 +107,10 @@ export interface TgIncoming {
105
107
  botId?: string;
106
108
  /** 답장 대상 메시지의 텍스트(force_reply 정정 가로채기용). parseUpdate 가 채운다. */
107
109
  replyToText?: string;
110
+ /** 앨범(media group) id — `#현장` 캡션은 앨범의 첫 항목에만 붙으므로 나머지를 같은 행사로 묶는 데 쓴다. */
111
+ mediaGroupId?: string;
112
+ /** 영상 — `#현장` 저장 전용. `attachments` 에 넣지 않는다(LLM 경로의 동작을 안 바꾸려고). */
113
+ video?: TgIncomingAttachment;
108
114
  }
109
115
 
110
116
  /** Streaming surface passed to the message handler. The handler can
@@ -200,6 +206,10 @@ export interface TelegramBotOpts {
200
206
  * independently of the LLM chat path. Best-effort: errors are
201
207
  * swallowed by the caller — never blocks chat reply. */
202
208
  onTriggerTap?: (event: TgTriggerEvent) => void | Promise<void>;
209
+ /** `#현장` 기본 행사 — 없으면 `slashContext.userConfig.telegram.fieldDefaultEvent` · 그것도 없으면 `field-<로컬 날짜>`. */
210
+ fieldDefaultEvent?: string;
211
+ /** 시험 seam — 현장 폴더 뿌리(기본 `getElanousConfigDir()`). */
212
+ fieldRootDir?: () => string;
203
213
  }
204
214
 
205
215
  /** Surface-unification v2 (FU-2) — Telegram trigger event passed to
@@ -314,6 +324,13 @@ export class TelegramBot {
314
324
  * the bot under Telegram's ~30/sec global cap without blocking
315
325
  * further if load is bursty but average-safe. */
316
326
  private readonly globalWindow: number[] = [];
327
+ private readonly fieldDefaultEventOpt?: string;
328
+ private readonly fieldRootDir: () => string;
329
+ /** 앨범 id → `#현장` 행사 · 답장 디바운스. 태그된 앨범만 들어온다. */
330
+ private readonly fieldGroups = new Map<string, {
331
+ event: string; at: number; chatId: number; threadId?: number; replyTo: number; count: number;
332
+ timer?: ReturnType<typeof setTimeout>;
333
+ }>();
317
334
 
318
335
  constructor(opts: TelegramBotOpts) {
319
336
  if (!opts.token) throw new Error('TelegramBot: token required');
@@ -340,6 +357,8 @@ export class TelegramBot {
340
357
  this.slashCommands = opts.slashCommands ?? [];
341
358
  this.slashContext = opts.slashContext;
342
359
  this.voiceAdapter = opts.voiceAdapter;
360
+ this.fieldDefaultEventOpt = opts.fieldDefaultEvent;
361
+ this.fieldRootDir = opts.fieldRootDir ?? getElanousConfigDir;
343
362
  if (opts.onTriggerTap) {
344
363
  // Assign through `as unknown` to bypass `readonly` + private —
345
364
  // the tap is set once at construction and never reassigned.
@@ -1347,11 +1366,82 @@ export class TelegramBot {
1347
1366
  return false;
1348
1367
  }
1349
1368
 
1369
+ /** `#현장` 캡션 사진·영상(이미지·영상 문서 포함)을 현장 폴더에 저장하고 한 줄로 답한다.
1370
+ * 처리했으면 true. 저장 규칙은 HTTP 입구와 같은 `saveFieldMedia` 다. */
1371
+ private async tryHandleFieldUpload(ctx: TgIncoming): Promise<boolean> {
1372
+ const media = [
1373
+ ...ctx.attachments.filter((a) => a.kind === 'photo'
1374
+ || (a.kind === 'document' && resolveFieldMime(a.mimeType, a.fileName ?? '') !== null)),
1375
+ ...(ctx.video ? [ctx.video] : []),
1376
+ ];
1377
+ if (media.length === 0) return false;
1378
+ const now = this.nowImpl();
1379
+ for (const [id, g] of this.fieldGroups) if (now - g.at > 10 * 60_000 && !g.timer) this.fieldGroups.delete(id);
1380
+ const tag = parseFieldCaption(ctx.text);
1381
+ const group = ctx.mediaGroupId ? this.fieldGroups.get(ctx.mediaGroupId) : undefined;
1382
+ if (!tag && !group) return false;
1383
+ const event = tag
1384
+ ? tag.event ?? defaultFieldEvent(
1385
+ this.fieldDefaultEventOpt ?? this.slashContext?.userConfig?.telegram?.fieldDefaultEvent, new Date(now))
1386
+ : group!.event;
1387
+
1388
+ let count: number | null = null;
1389
+ let failure: string | null = null;
1390
+ try {
1391
+ const inputs = [];
1392
+ for (const att of media) {
1393
+ const downloaded = await this.downloadFile(att.fileId);
1394
+ try {
1395
+ inputs.push({
1396
+ originalName: att.fileName ?? downloaded.fileName ?? 'media',
1397
+ mimeType: att.mimeType ?? (att.kind === 'photo' ? 'image/jpeg' : undefined),
1398
+ bytes: new Uint8Array(readFileSync(downloaded.localPath)),
1399
+ });
1400
+ } finally {
1401
+ try { unlinkSync(downloaded.localPath); } catch { /* 이미 없음 */ }
1402
+ }
1403
+ }
1404
+ // 글(자막)은 캡션이 달린 항목에만 있다 — 앨범이면 첫 장에만 붙는다(MK 2026-09-30).
1405
+ count = saveFieldMedia(inputs, { rootDir: this.fieldRootDir(), event, device: 'telegram', ...(tag?.text ? { caption: tag.text } : {}) }).count;
1406
+ debug.log('field.upload', 'saved', { surface: 'telegram', files: inputs.length, count, album: !!ctx.mediaGroupId });
1407
+ } catch (err) {
1408
+ failure = err instanceof FieldUploadError ? err.code : 'download-failed';
1409
+ debug.log('field.upload', 'refused', { surface: 'telegram', reason: failure });
1410
+ }
1411
+
1412
+ if (failure !== null) {
1413
+ await this.sendMessage(ctx.chatId, `현장 저장 실패 · ${event} · ${failure}`, { replyTo: ctx.messageId, threadId: ctx.threadId });
1414
+ return true;
1415
+ }
1416
+ const line = (n: number) => `현장 폴더에 저장 · ${event} · 지금 ${n}장`;
1417
+ if (!ctx.mediaGroupId) {
1418
+ await this.sendMessage(ctx.chatId, line(count!), { replyTo: ctx.messageId, threadId: ctx.threadId });
1419
+ return true;
1420
+ }
1421
+ // 앨범은 항목마다 답하지 않는다 — 마지막 항목 뒤 한 번(디바운스)만 최신 수로 답한다.
1422
+ const entry = group ?? { event, at: now, chatId: ctx.chatId, threadId: ctx.threadId, replyTo: ctx.messageId, count: 0 };
1423
+ entry.at = now;
1424
+ entry.count = count!;
1425
+ if (entry.timer) clearTimeout(entry.timer);
1426
+ entry.timer = setTimeout(() => {
1427
+ entry.timer = undefined;
1428
+ void this.sendMessage(entry.chatId, line(entry.count), { replyTo: entry.replyTo, threadId: entry.threadId })
1429
+ .catch((e) => this.log(`field reply failed: ${e instanceof Error ? e.message : String(e)}`));
1430
+ }, 1500);
1431
+ (entry.timer as { unref?: () => void }).unref?.();
1432
+ this.fieldGroups.set(ctx.mediaGroupId, entry);
1433
+ return true;
1434
+ }
1435
+
1350
1436
  private async handleIncoming(ctx: TgIncoming): Promise<void> {
1351
1437
  if (!this.isOwnerAllowed(ctx.userId)) {
1352
1438
  await this.refuseMessage(ctx);
1353
1439
  return;
1354
1440
  }
1441
+ // 현장 업로드 — `#현장` 캡션(또는 태그된 앨범의 나머지)의 사진·영상은 LLM 대신 현장 폴더로.
1442
+ if (await this.tryHandleFieldUpload(ctx)) return;
1443
+ // 영상은 `#현장` 저장 전용으로만 파싱한다 — 그 밖의 영상은 종전처럼 무시한다.
1444
+ if (ctx.video && ctx.attachments.length === 0) return;
1355
1445
  // Cascade-zyu U2 — capture utterance intent at update ingress.
1356
1446
  try {
1357
1447
  const { userIntentLogger } = await import('./user-intent/index.js');
@@ -2069,6 +2159,7 @@ export class TelegramBot {
2069
2159
  interface PhotoSize { file_id: string; file_size?: number; width: number; height: number }
2070
2160
  interface VoiceLike { file_id: string; file_size?: number; mime_type?: string; duration?: number }
2071
2161
  interface DocumentLike { file_id: string; file_size?: number; mime_type?: string; file_name?: string }
2162
+ interface VideoLike { file_id: string; file_size?: number; mime_type?: string; file_name?: string; duration?: number }
2072
2163
 
2073
2164
  interface RawUpdate {
2074
2165
  update_id: number;
@@ -2083,6 +2174,8 @@ interface RawUpdate {
2083
2174
  voice?: VoiceLike;
2084
2175
  audio?: VoiceLike;
2085
2176
  document?: DocumentLike;
2177
+ video?: VideoLike;
2178
+ media_group_id?: string;
2086
2179
  reply_to_message?: { text?: string; message_id?: number };
2087
2180
  };
2088
2181
  callback_query?: {
@@ -2175,8 +2268,20 @@ export function parseUpdate(u: RawUpdate): TgIncoming | null {
2175
2268
  });
2176
2269
  }
2177
2270
 
2271
+ // 영상은 `#현장` 저장 전용 — 태그된 캡션이거나 앨범 항목일 때만 싣는다(그 밖의 영상은 종전처럼 무시).
2272
+ const video = m.video && (parseFieldCaption(m.caption) || m.media_group_id)
2273
+ ? {
2274
+ kind: 'document' as const,
2275
+ fileId: m.video.file_id,
2276
+ fileSize: m.video.file_size,
2277
+ mimeType: m.video.mime_type ?? 'video/mp4',
2278
+ fileName: m.video.file_name,
2279
+ duration: m.video.duration,
2280
+ }
2281
+ : undefined;
2282
+
2178
2283
  // Skip updates with zero signal: no text AND no attachments.
2179
- if (typeof m.text !== 'string' && attachments.length === 0) return null;
2284
+ if (typeof m.text !== 'string' && attachments.length === 0 && !video) return null;
2180
2285
 
2181
2286
  const isDm = m.chat.type === 'private';
2182
2287
  return {
@@ -2191,6 +2296,8 @@ export function parseUpdate(u: RawUpdate): TgIncoming | null {
2191
2296
  isGroup: !isDm,
2192
2297
  attachments,
2193
2298
  ...(m.reply_to_message?.text ? { replyToText: m.reply_to_message.text } : {}),
2299
+ ...(m.media_group_id ? { mediaGroupId: m.media_group_id } : {}),
2300
+ ...(video ? { video } : {}),
2194
2301
  };
2195
2302
  }
2196
2303
 
@@ -833,6 +833,8 @@ export interface TelegramConfig {
833
833
  /** 발송 목적(kind) → 채널 역할. 명시 `channels` 가 있을 때만 쓴다 — 표에 없는 kind 는 옛 `reportChannel` 로 간다.
834
834
  * 기본표는 `DEFAULT_KIND_ROLES`(telegram-report.ts) · 여기 값이 덮어쓴다. */
835
835
  kindRoles?: Record<string, string>;
836
+ /** `#현장` 캡션에 행사 슬러그가 없을 때 쓸 기본 행사(예: `marketers-night-2026-10`). 없으면 `field-<로컬 날짜>`. */
837
+ fieldDefaultEvent?: string;
836
838
  }
837
839
 
838
840
  const TELEGRAM_DEFAULTS: TelegramConfig = { enabled: false, allowedUsers: [] };
@@ -4393,6 +4395,7 @@ export function buildUserConfig(path: string = defaultPath()): UserConfig {
4393
4395
  testChannel: normalizeTestChannel(tg.testChannel),
4394
4396
  ...(tg.poller === 'standalone' || tg.poller === 'nexus' ? { poller: tg.poller } : {}),
4395
4397
  ...(normalizeKindRoles(tg.kindRoles) ? { kindRoles: normalizeKindRoles(tg.kindRoles)! } : {}),
4398
+ ...(str(tg.fieldDefaultEvent) ? { fieldDefaultEvent: str(tg.fieldDefaultEvent)! } : {}),
4396
4399
  },
4397
4400
  discord: {
4398
4401
  enabled: dc.enabled === true,
@@ -5565,6 +5568,7 @@ export function saveUserConfig(
5565
5568
  // ★ 2026-09-25: `poller`(T1 분리 스위치)가 파서엔 있고 이 whitelist 에 없어 `config set telegram.poller` 가
5566
5569
  // 「직렬화 드롭」으로 조용히 사라졌다 — 운영 전환 스위치를 켤 방법이 없었다(07-22 botUsername 과 같은 모양).
5567
5570
  poller: cfg.telegram.poller,
5571
+ fieldDefaultEvent: cfg.telegram.fieldDefaultEvent,
5568
5572
  }),
5569
5573
  discord: stripUndef({
5570
5574
  enabled: cfg.discord.enabled,
@@ -1 +1 @@
1
- {"commit":"e491ebfd1661520b780aff439e71baa26895ab53"}
1
+ {"commit":"65a4c7230c60a536b0f0f0428322c8ddcbcac752"}
@@ -0,0 +1,213 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
4
+ import type { RunResult } from './ffmpeg.js';
5
+ import { UNOBSERVED, type Recipe, type RecipeCtx, type RecipeResult } from './types.js';
6
+
7
+ type CommandRunner = (bin: string, args: readonly string[], timeoutMs: number, cwd?: string) => RunResult;
8
+ interface ExplainerDeps { readonly run?: CommandRunner; readonly engineDir?: string }
9
+
10
+ const ENGINE = resolve(import.meta.dir, '../../../skills/explainer-video/engine');
11
+ const ENGINE_MS = 600_000;
12
+ const QC_MS = 180_000;
13
+
14
+ function inside(root: string, candidate: string): boolean {
15
+ const rel = relative(root, candidate);
16
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
17
+ }
18
+
19
+ // Resolve existing ancestors as well: an output directory may be a symlink before the engine writes into it.
20
+ function safeOutput(root: string, candidate: string): boolean {
21
+ const base = realpathSync(root);
22
+ if (!inside(resolve(root), resolve(candidate))) return false;
23
+ // Walk up to the deepest component that exists as a directory entry (lstat, not existsSync):
24
+ // a broken symlink is «there» and must be judged, not skipped as missing.
25
+ let path = resolve(candidate);
26
+ for (;;) {
27
+ let isLink: boolean;
28
+ try { isLink = lstatSync(path).isSymbolicLink(); } catch {
29
+ const parent = dirname(path);
30
+ if (parent === path) return false;
31
+ path = parent;
32
+ continue;
33
+ }
34
+ if (isLink && !existsSync(path)) return false; // broken link — its target could be created outside the project
35
+ return inside(base, realpathSync(path));
36
+ }
37
+ }
38
+
39
+ function finish(ctx: RecipeCtx, name: string, result: RecipeResult): RecipeResult {
40
+ ctx.log(`explainer.${name}`, { outcome: result.outcome, ...result.produced, note: result.note });
41
+ return result;
42
+ }
43
+
44
+ function scriptPath(ctx: RecipeCtx): string | null {
45
+ const value = ctx.state.script_path;
46
+ return typeof value === 'string' && value.trim() ? resolve(value) : null;
47
+ }
48
+
49
+ function enginePath(deps: ExplainerDeps, file: string): string | null {
50
+ const path = resolve(deps.engineDir ?? ENGINE, file);
51
+ return existsSync(path) ? path : null;
52
+ }
53
+
54
+ function runner(deps: ExplainerDeps, ctx: RecipeCtx): CommandRunner {
55
+ if (deps.run) return deps.run;
56
+ return (bin, args, timeoutMs, cwd) => {
57
+ const r = spawnSync(bin, [...args], {
58
+ cwd, timeout: timeoutMs, encoding: 'utf8',
59
+ env: { ...process.env, EXPLAINER_CACHE: resolve(ctx.workdir, 'explainer-cache') },
60
+ });
61
+ return { ok: r.status === 0, code: r.status, signal: r.signal ?? null,
62
+ err: (r.stderr ?? r.error?.message ?? '').trim(), out: (r.stdout ?? '').trim() };
63
+ };
64
+ }
65
+
66
+ function unavailable(r: RunResult): boolean {
67
+ return r.signal === null && r.code === null && !r.ok;
68
+ }
69
+
70
+ function keys(script: unknown): string[] | null {
71
+ if (!script || typeof script !== 'object') return null;
72
+ const s = script as { intro?: { beats?: unknown }; chapters?: unknown; outro?: { beats?: unknown } };
73
+ if (!Array.isArray(s.intro?.beats) || !Array.isArray(s.chapters) || !Array.isArray(s.outro?.beats)) return null;
74
+ const chapters = s.chapters as { beats?: unknown }[];
75
+ if (!chapters.every((c) => c && Array.isArray(c.beats))) return null;
76
+ return [
77
+ ...s.intro.beats.map((_, i) => `i-${i}`),
78
+ ...chapters.flatMap((c, ci) => (c.beats as unknown[]).map((_, i) => `c${ci + 1}-${i}`)),
79
+ ...s.outro.beats.map((_, i) => `o-${i}`),
80
+ ];
81
+ }
82
+
83
+ export function explainerScript(): Recipe {
84
+ return async (ctx) => {
85
+ const path = scriptPath(ctx);
86
+ if (!path) return finish(ctx, 'script', { outcome: 'no-facts', note: 'script_path 가 state 에 없다 — 대본 저작이 필요하다' });
87
+ if (!existsSync(path)) return finish(ctx, 'script', { outcome: 'no-facts', note: `대본이 없다: ${path}` });
88
+ let script: unknown;
89
+ try { script = JSON.parse(readFileSync(path, 'utf8')); }
90
+ catch (e) { return finish(ctx, 'script', { outcome: UNOBSERVED, note: `대본 읽기 실패: ${String(e)}` }); }
91
+ const chapters = (script as { chapters?: unknown } | null)?.chapters;
92
+ const beatKeys = keys(script);
93
+ const voiced = (beat: unknown): boolean => beat !== null && typeof beat === 'object' &&
94
+ typeof (beat as { vo?: unknown }).vo === 'string' && (beat as { vo: string }).vo.trim().length > 0;
95
+ if (!beatKeys?.length || !Array.isArray(chapters) || chapters.length === 0 ||
96
+ !chapters.some((chapter: { beats: unknown[] }) => chapter.beats.some(voiced)) ||
97
+ ![...(script as { intro: { beats: unknown[] } }).intro.beats,
98
+ ...chapters.flatMap((chapter: { beats: unknown[] }) => chapter.beats),
99
+ ...(script as { outro: { beats: unknown[] } }).outro.beats].every(voiced)) {
100
+ return finish(ctx, 'script', { outcome: 'no-facts', note: 'intro.beats · chapters[] · outro.beats 에 내용이 없거나 구조가 없다' });
101
+ }
102
+ return finish(ctx, 'script', { outcome: 'ok', produced: { script_path: path } });
103
+ };
104
+ }
105
+
106
+ export function explainerVo(deps: ExplainerDeps = {}): Recipe {
107
+ return async (ctx) => {
108
+ const path = scriptPath(ctx);
109
+ if (!path || !existsSync(path)) return finish(ctx, 'vo', { outcome: UNOBSERVED, note: 'script_path 를 못 읽는다' });
110
+ const engine = enginePath(deps, 'vo.ts');
111
+ if (!engine) return finish(ctx, 'vo', { outcome: UNOBSERVED, note: 'skills/explainer-video/engine/vo.ts 가 없다' });
112
+ const project = dirname(path);
113
+ const voDir = resolve(project, 'source/vo');
114
+ try {
115
+ const beatKeys = keys(JSON.parse(readFileSync(path, 'utf8')));
116
+ if (!beatKeys?.length) return finish(ctx, 'vo', { outcome: UNOBSERVED, note: '대본의 VO beat 를 못 읽었다' });
117
+ const outputs = beatKeys.flatMap((key) => [
118
+ resolve(voDir, `${key}.mp3`), resolve(voDir, `${key}.json`),
119
+ resolve(voDir, `${key}.tmp.aiff`), resolve(voDir, `${key}.tmp.mp3`),
120
+ ]);
121
+ if (![voDir, ...outputs].every((p) => safeOutput(project, p))) {
122
+ return finish(ctx, 'vo', { outcome: UNOBSERVED, note: 'VO 출력이 대본 폴더 밖을 가리킨다' });
123
+ }
124
+ } catch (e) { return finish(ctx, 'vo', { outcome: UNOBSERVED, note: `VO 경로 확인 실패: ${String(e)}` }); }
125
+ let r: RunResult;
126
+ try { r = runner(deps, ctx)('bun', [engine, path], ENGINE_MS, project); }
127
+ catch (e) { return finish(ctx, 'vo', { outcome: UNOBSERVED, note: `bun vo.ts 를 못 불렀다: ${String(e)}` }); }
128
+ if (unavailable(r)) return finish(ctx, 'vo', { outcome: UNOBSERVED, note: `bun vo.ts 를 못 불렀다(code=${r.code} signal=${r.signal}): ${r.err}` });
129
+ if (!r.ok) return finish(ctx, 'vo', { outcome: 'error', note: `VO 엔진 실패: ${r.err.slice(0, 240)}` });
130
+ try {
131
+ const script = JSON.parse(readFileSync(path, 'utf8')) as unknown;
132
+ const beatKeys = keys(script);
133
+ if (!beatKeys?.length) throw new Error('대본에 VO beat 가 없다');
134
+ let seconds = 0;
135
+ for (const key of beatKeys) {
136
+ const audio = resolve(voDir, `${key}.mp3`);
137
+ const alignment = resolve(voDir, `${key}.json`);
138
+ if (!safeOutput(project, audio) || !safeOutput(project, alignment) || !existsSync(audio)) throw new Error(`${key} VO 파일이 없다`);
139
+ const j = JSON.parse(readFileSync(alignment, 'utf8')) as { alignment?: { character_end_times_seconds?: unknown } };
140
+ const ends = j.alignment?.character_end_times_seconds;
141
+ if (!Array.isArray(ends) || ends.length === 0 || !Number.isFinite(ends.at(-1)) || ends.at(-1) <= 0) throw new Error(`${key} 끝 시각을 못 읽었다`);
142
+ seconds += ends.at(-1) as number;
143
+ }
144
+ return finish(ctx, 'vo', { outcome: 'ok', produced: { vo_dir: voDir, narration_seconds: seconds } });
145
+ } catch (e) { return finish(ctx, 'vo', { outcome: UNOBSERVED, note: `VO 산출을 못 쟀다: ${String(e)}` }); }
146
+ };
147
+ }
148
+
149
+ export function explainerBuild(deps: ExplainerDeps = {}): Recipe {
150
+ return async (ctx) => {
151
+ const path = scriptPath(ctx);
152
+ if (!path || !existsSync(path)) return finish(ctx, 'build', { outcome: UNOBSERVED, note: 'script_path 를 못 읽는다' });
153
+ const engine = enginePath(deps, 'build.mjs');
154
+ if (!engine) return finish(ctx, 'build', { outcome: UNOBSERVED, note: 'skills/explainer-video/engine/build.mjs 가 없다' });
155
+ const project = dirname(path);
156
+ const hf = resolve(project, 'hf');
157
+ const timeline = resolve(project, 'timeline.json');
158
+ try {
159
+ mkdirSync(ctx.workdir, { recursive: true });
160
+ if (![hf, resolve(hf, 'assets'), resolve(hf, 'assets/fonts'), resolve(hf, 'assets/vo'), timeline].every((p) => safeOutput(project, p))
161
+ || !safeOutput(ctx.workdir, resolve(ctx.workdir, 'explainer-cache'))) {
162
+ return finish(ctx, 'build', { outcome: UNOBSERVED, note: '빌드 출력/캐시가 허용된 폴더 밖을 가리킨다' });
163
+ }
164
+ } catch (e) { return finish(ctx, 'build', { outcome: UNOBSERVED, note: `빌드 경로 확인 실패: ${String(e)}` }); }
165
+ let r: RunResult;
166
+ try { r = runner(deps, ctx)('node', [engine, path], ENGINE_MS, project); }
167
+ catch (e) { return finish(ctx, 'build', { outcome: UNOBSERVED, note: `node build.mjs 를 못 불렀다: ${String(e)}` }); }
168
+ if (unavailable(r)) return finish(ctx, 'build', { outcome: UNOBSERVED, note: `node build.mjs 를 못 불렀다(code=${r.code} signal=${r.signal}): ${r.err}` });
169
+ if (!r.ok) {
170
+ const badScript = /unknown diagram|DIAGRAMS\[dia\] is not a function|(?:invalid|missing|unsupported) (?:script|diagram|beat|chapter)/i.test(r.err);
171
+ return finish(ctx, 'build', { outcome: badScript ? 'bad-script' : 'error', note: `빌드 실패: ${r.err.slice(0, 240)}` });
172
+ }
173
+ try {
174
+ if (![resolve(hf, 'index.html'), timeline].every((p) => safeOutput(project, p) && existsSync(p))) {
175
+ return finish(ctx, 'build', { outcome: UNOBSERVED, note: '빌드가 hf/index.html 또는 timeline.json 을 내지 않았다' });
176
+ }
177
+ } catch (e) { return finish(ctx, 'build', { outcome: UNOBSERVED, note: `빌드 산출 확인 실패: ${String(e)}` }); }
178
+ return finish(ctx, 'build', { outcome: 'ok', produced: { hyperframes_project: hf, timeline_path: timeline } });
179
+ };
180
+ }
181
+
182
+ export function explainerQc(deps: ExplainerDeps = {}): Recipe {
183
+ return async (ctx) => {
184
+ const path = ctx.state.hf_render_path;
185
+ if (typeof path !== 'string' || !path || !existsSync(path)) {
186
+ return finish(ctx, 'qc', { outcome: UNOBSERVED, note: 'hf_render_path 를 못 읽는다' });
187
+ }
188
+ let r: RunResult;
189
+ try {
190
+ r = runner(deps, ctx)('ffmpeg', ['-hide_banner', '-nostats', '-i', resolve(path),
191
+ '-af', 'loudnorm=print_format=json', '-f', 'null', '-'], QC_MS, ctx.workdir);
192
+ } catch (e) { return finish(ctx, 'qc', { outcome: UNOBSERVED, note: `ffmpeg 를 못 불렀다: ${String(e)}` }); }
193
+ if (!r.ok) return finish(ctx, 'qc', { outcome: UNOBSERVED, note: `ffmpeg 음량 측정 실패(code=${r.code} signal=${r.signal}): ${r.err.slice(0, 240)}` });
194
+ const block = [...`${r.err}\n${r.out}`.matchAll(/\{[^{}]*"input_i"[^{}]*"input_tp"[^{}]*\}/g)].at(-1)?.[0];
195
+ try {
196
+ if (!block) throw new Error('loudnorm JSON 이 없다');
197
+ const j = JSON.parse(block) as { input_i?: unknown; input_tp?: unknown };
198
+ const loudness = Number(j.input_i);
199
+ const peak = Number(j.input_tp);
200
+ if (j.input_i == null || j.input_tp == null || !Number.isFinite(loudness) || !Number.isFinite(peak)) throw new Error('통합 음량/트루 피크가 비수다');
201
+ const produced = { loudness_lufs: loudness, true_peak_db: peak };
202
+ const outcome = loudness >= -16 && loudness <= -12 && peak <= -1 ? 'pass' : 'loudness';
203
+ return finish(ctx, 'qc', { outcome, produced, note: `${loudness} LUFS · ${peak} dBTP` });
204
+ } catch (e) { return finish(ctx, 'qc', { outcome: UNOBSERVED, note: `loudnorm 결과를 못 읽었다: ${String(e)}` }); }
205
+ };
206
+ }
207
+
208
+ export const EXPLAINER: Readonly<Record<string, Recipe>> = {
209
+ 'explainer-script': explainerScript(),
210
+ 'explainer-vo': explainerVo(),
211
+ 'explainer-build': explainerBuild(),
212
+ 'explainer-qc': explainerQc(),
213
+ };
@@ -14,6 +14,7 @@ import { readdirSync, readFileSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
15
  import { BROLL } from './recipes/broll.js';
16
16
  import { CHARACTER } from './recipes/character.js';
17
+ import { EXPLAINER } from './recipes/explainer.js';
17
18
  import { FILM } from './recipes/film.js';
18
19
  import { FREE_LINE } from './recipes/free-line.js';
19
20
  import { HYPERFRAMES } from './recipes/hyperframes.js';
@@ -21,8 +22,8 @@ import { UPSTREAM } from './recipes/upstream.js';
21
22
  import { VLOG } from './recipes/vlog.js';
22
23
  import { UNOBSERVED, type Recipe, type RecipeCtx } from './recipes/types.js';
23
24
 
24
- /** ⛔ 순서가 뜻이다 — 같은 이름이면 뒤가 이긴다. HYPERFRAMES 는 새 이름뿐이라 기존 키를 덮지 않는다. */
25
- export const ALL_RECIPES: Readonly<Record<string, Recipe>> = { ...UPSTREAM, ...FREE_LINE, ...VLOG, ...FILM, ...CHARACTER, ...HYPERFRAMES, ...BROLL };
25
+ /** ⛔ 순서가 뜻이다 — 같은 이름이면 뒤가 이긴다. HYPERFRAMES · EXPLAINER 는 기존 키를 덮지 않는다. */
26
+ export const ALL_RECIPES: Readonly<Record<string, Recipe>> = { ...UPSTREAM, ...FREE_LINE, ...VLOG, ...FILM, ...CHARACTER, ...HYPERFRAMES, ...BROLL, ...EXPLAINER };
26
27
 
27
28
  export interface GraphSpecLike {
28
29
  readonly graph_id: string;