elanous 0.2.11 → 0.2.12

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 (246) hide show
  1. package/apps/pwa/out/404/index.html +1 -1
  2. package/apps/pwa/out/404.html +1 -1
  3. package/apps/pwa/out/_next/static/chunks/1147-466ec15afacc1a65.js +1 -0
  4. package/apps/pwa/out/_next/static/chunks/{1394-556de6d71bbdb0fb.js → 1394-ee3795b112907122.js} +1 -1
  5. package/apps/pwa/out/_next/static/chunks/3869-3cd46526879f454c.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/7277-0b2fc7c16ba2d64b.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/7758-0b018a9e4fc56200.js +1 -0
  8. package/apps/pwa/out/_next/static/chunks/{8605-ea5bee9dfae512c7.js → 8605-0259994816604c49.js} +1 -1
  9. package/apps/pwa/out/_next/static/chunks/9920-5cfdc5e8fd209825.js +1 -0
  10. package/apps/pwa/out/_next/static/chunks/app/approvals/page-cb169620945d1952.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-6a061bd97f0b9398.js → page-cefe8169e36cf62a.js} +1 -1
  12. package/apps/pwa/out/_next/static/chunks/app/board/{page-cb95a7432c9ee744.js → page-d2e35228bc497119.js} +1 -1
  13. package/apps/pwa/out/_next/static/chunks/app/botlab/{page-b12b6d2f8d2d1aea.js → page-5c05b0da4bc1392c.js} +1 -1
  14. package/apps/pwa/out/_next/static/chunks/app/bots/{page-b9efe868bfd36255.js → page-e0ccdb576812b523.js} +1 -1
  15. package/apps/pwa/out/_next/static/chunks/app/chat/{page-7c97dcdfe2e37a60.js → page-813c4a52c9d05ca8.js} +1 -1
  16. package/apps/pwa/out/_next/static/chunks/app/consult/page-4731f2c215358937.js +1 -0
  17. package/apps/pwa/out/_next/static/chunks/app/control/{page-5721537ca8a3598c.js → page-a022b257950f39e6.js} +1 -1
  18. package/apps/pwa/out/_next/static/chunks/app/design-check/{page-b654240a572e4c48.js → page-a2ffc717e04b8b0a.js} +1 -1
  19. package/apps/pwa/out/_next/static/chunks/app/editor/{page-b9750abc897508c3.js → page-b59ee4e70fc18fcf.js} +1 -1
  20. package/apps/pwa/out/_next/static/chunks/app/exec/page-a67e11ba962337b5.js +1 -0
  21. package/apps/pwa/out/_next/static/chunks/app/field/page-c172dabd796eb7a3.js +1 -0
  22. package/apps/pwa/out/_next/static/chunks/app/inside/page-1efd0cb9e0ed9d13.js +1 -0
  23. package/apps/pwa/out/_next/static/chunks/app/intake/{page-c8b1ccda6ba7ae98.js → page-d5dc7ff69a37b4cf.js} +1 -1
  24. package/apps/pwa/out/_next/static/chunks/app/layout-e71c84189e8c683e.js +1 -0
  25. package/apps/pwa/out/_next/static/chunks/app/live/{page-2a9932ddb62d76bd.js → page-1d6a533ce5a6efcb.js} +1 -1
  26. package/apps/pwa/out/_next/static/chunks/app/market/{page-9688f5b4f1ff950f.js → page-dfca9b7d2de0b135.js} +1 -1
  27. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-793fa536b3a6ef37.js +1 -0
  28. package/apps/pwa/out/_next/static/chunks/app/morning/{page-35414e0a007b4082.js → page-6dd3bfbbde2a5c2c.js} +1 -1
  29. package/apps/pwa/out/_next/static/chunks/app/{not-found-e7a529e0507ac86c.js → not-found-490653e8d4cc8852.js} +1 -1
  30. package/apps/pwa/out/_next/static/chunks/app/observatory/{page-e001823c35c9dd67.js → page-77447356baad75d2.js} +1 -1
  31. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-29e961191f6057df.js +1 -0
  32. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-c822f55b884d9104.js +1 -0
  33. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-fa4ccfc432424e52.js +1 -0
  34. package/apps/pwa/out/_next/static/chunks/app/outputs/{page-3a24072b35470157.js → page-6d604227e1664b71.js} +1 -1
  35. package/apps/pwa/out/_next/static/chunks/app/page-9c7e8f744a765daf.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/app/reflection/{page-0be8d4b2ced75ba0.js → page-4048543b4b7ad5e0.js} +1 -1
  37. package/apps/pwa/out/_next/static/chunks/app/scheduler/{page-d37cb0d52f9b6bd5.js → page-4c3a3126abed551a.js} +1 -1
  38. package/apps/pwa/out/_next/static/chunks/app/sessions/{page-686da0df9bf23e5c.js → page-64b0aecb1ca75e75.js} +1 -1
  39. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-4292d0149020e8a7.js +1 -0
  40. package/apps/pwa/out/_next/static/chunks/app/settings/{page-53f80edc6b8dc07c.js → page-db14d0be7f1c460c.js} +1 -1
  41. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-e5d8072cd7cfef73.js +1 -0
  42. package/apps/pwa/out/_next/static/chunks/app/setup/{page-239c17b911b84e72.js → page-9547b5cec7cf76bf.js} +1 -1
  43. package/apps/pwa/out/_next/static/chunks/app/share/page-e6dcb83265530636.js +1 -0
  44. package/apps/pwa/out/_next/static/chunks/app/showroom/page-45248f08d6aecae5.js +1 -0
  45. package/apps/pwa/out/_next/static/chunks/app/tasks/{page-1218753c82d585f6.js → page-2cddeb9e2aeedc55.js} +1 -1
  46. package/apps/pwa/out/_next/static/chunks/app/term/{page-4b5935e67531efb5.js → page-e297866051595d81.js} +1 -1
  47. package/apps/pwa/out/_next/static/chunks/app/today/{page-3285bc87caf1eaa5.js → page-baba2e047872fb0f.js} +1 -1
  48. package/apps/pwa/out/_next/static/chunks/app/trace/{page-ad553b0e06505ecd.js → page-e895da4ec9cac345.js} +1 -1
  49. package/apps/pwa/out/_next/static/chunks/app/vault/page-4344faf20216233b.js +1 -0
  50. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-698bb29f11864b89.js +1 -0
  51. package/apps/pwa/out/_next/static/chunks/app/workflows/page-bd6a6dc1ddc273f2.js +1 -0
  52. package/apps/pwa/out/_next/static/chunks/app/workspace/page-2e9e2fb01c9da1c9.js +1 -0
  53. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-d62ceb13411ebba4.js +1 -0
  54. package/apps/pwa/out/_next/static/css/128fb7fb670b2fbf.css +1 -0
  55. package/apps/pwa/out/approvals/index.html +1 -1
  56. package/apps/pwa/out/approvals/index.txt +14 -14
  57. package/apps/pwa/out/autopilot/index.html +1 -1
  58. package/apps/pwa/out/autopilot/index.txt +14 -14
  59. package/apps/pwa/out/board/index.html +1 -1
  60. package/apps/pwa/out/board/index.txt +14 -14
  61. package/apps/pwa/out/botlab/index.html +1 -1
  62. package/apps/pwa/out/botlab/index.txt +14 -14
  63. package/apps/pwa/out/bots/index.html +1 -1
  64. package/apps/pwa/out/bots/index.txt +14 -14
  65. package/apps/pwa/out/chat/index.html +1 -1
  66. package/apps/pwa/out/chat/index.txt +14 -14
  67. package/apps/pwa/out/consult/index.html +1 -1
  68. package/apps/pwa/out/consult/index.txt +14 -14
  69. package/apps/pwa/out/control/index.html +1 -1
  70. package/apps/pwa/out/control/index.txt +14 -14
  71. package/apps/pwa/out/design-check/index.html +1 -1
  72. package/apps/pwa/out/design-check/index.txt +14 -14
  73. package/apps/pwa/out/editor/index.html +1 -1
  74. package/apps/pwa/out/editor/index.txt +14 -14
  75. package/apps/pwa/out/exec/index.html +1 -1
  76. package/apps/pwa/out/exec/index.txt +14 -14
  77. package/apps/pwa/out/field/index.html +1 -1
  78. package/apps/pwa/out/field/index.txt +14 -14
  79. package/apps/pwa/out/healthz +1 -1
  80. package/apps/pwa/out/index.html +1 -1
  81. package/apps/pwa/out/index.txt +14 -14
  82. package/apps/pwa/out/inside/index.html +1 -1
  83. package/apps/pwa/out/inside/index.txt +14 -14
  84. package/apps/pwa/out/intake/index.html +1 -1
  85. package/apps/pwa/out/intake/index.txt +14 -14
  86. package/apps/pwa/out/live/index.html +1 -1
  87. package/apps/pwa/out/live/index.txt +14 -14
  88. package/apps/pwa/out/market/index.html +1 -1
  89. package/apps/pwa/out/market/index.txt +14 -14
  90. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  91. package/apps/pwa/out/missions/placeholder/index.txt +14 -14
  92. package/apps/pwa/out/morning/index.html +1 -1
  93. package/apps/pwa/out/morning/index.txt +14 -14
  94. package/apps/pwa/out/observatory/index.html +1 -1
  95. package/apps/pwa/out/observatory/index.txt +14 -14
  96. package/apps/pwa/out/ops/checklist/index.html +1 -1
  97. package/apps/pwa/out/ops/checklist/index.txt +14 -14
  98. package/apps/pwa/out/ops/release/index.html +1 -1
  99. package/apps/pwa/out/ops/release/index.txt +14 -14
  100. package/apps/pwa/out/ops/seats/index.html +1 -1
  101. package/apps/pwa/out/ops/seats/index.txt +14 -14
  102. package/apps/pwa/out/outputs/index.html +1 -1
  103. package/apps/pwa/out/outputs/index.txt +14 -14
  104. package/apps/pwa/out/reflection/index.html +1 -1
  105. package/apps/pwa/out/reflection/index.txt +15 -15
  106. package/apps/pwa/out/scheduler/index.html +1 -1
  107. package/apps/pwa/out/scheduler/index.txt +14 -14
  108. package/apps/pwa/out/sessions/index.html +1 -1
  109. package/apps/pwa/out/sessions/index.txt +14 -14
  110. package/apps/pwa/out/settings/devices/index.html +1 -1
  111. package/apps/pwa/out/settings/devices/index.txt +14 -14
  112. package/apps/pwa/out/settings/index.html +1 -1
  113. package/apps/pwa/out/settings/index.txt +14 -14
  114. package/apps/pwa/out/setup/done/index.html +1 -1
  115. package/apps/pwa/out/setup/done/index.txt +14 -14
  116. package/apps/pwa/out/setup/index.html +1 -1
  117. package/apps/pwa/out/setup/index.txt +14 -14
  118. package/apps/pwa/out/share/index.html +1 -1
  119. package/apps/pwa/out/share/index.txt +14 -14
  120. package/apps/pwa/out/showroom/index.html +1 -1
  121. package/apps/pwa/out/showroom/index.txt +14 -14
  122. package/apps/pwa/out/tasks/index.html +1 -1
  123. package/apps/pwa/out/tasks/index.txt +14 -14
  124. package/apps/pwa/out/term/index.html +1 -1
  125. package/apps/pwa/out/term/index.txt +14 -14
  126. package/apps/pwa/out/today/index.html +1 -1
  127. package/apps/pwa/out/today/index.txt +14 -14
  128. package/apps/pwa/out/trace/index.html +1 -1
  129. package/apps/pwa/out/trace/index.txt +14 -14
  130. package/apps/pwa/out/vault/index.html +1 -1
  131. package/apps/pwa/out/vault/index.txt +14 -14
  132. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  133. package/apps/pwa/out/workflows/chat-ui/index.txt +14 -14
  134. package/apps/pwa/out/workflows/index.html +1 -1
  135. package/apps/pwa/out/workflows/index.txt +14 -14
  136. package/apps/pwa/out/workspace/index.html +1 -1
  137. package/apps/pwa/out/workspace/index.txt +14 -14
  138. package/apps/pwa/out/worktrees/index.html +1 -1
  139. package/apps/pwa/out/worktrees/index.txt +14 -14
  140. package/graphs/context/nightly-condense.yaml +21 -0
  141. package/graphs/context/recipes.yaml +12 -0
  142. package/graphs/demo/inside-seed.yaml +20 -0
  143. package/graphs/demo/recipes.yaml +12 -0
  144. package/package.json +1 -1
  145. package/plugins/card-followup/examples/sample-card.png +0 -0
  146. package/scripts/lib/telegram-inject-args.ts +31 -0
  147. package/scripts/measure-pod-memory-by-goal.ts +130 -0
  148. package/scripts/release-loop/auto-approve-node.ts +1 -1
  149. package/scripts/release-loop/checklist-gate-node.ts +2 -1
  150. package/scripts/release-loop/gate-node.ts +8 -2
  151. package/scripts/release-loop/ops-upgrade-node.ts +47 -6
  152. package/scripts/release-loop/publish-node.ts +4 -1
  153. package/scripts/release-loop/version-node.ts +13 -3
  154. package/scripts/telegram-inject.ts +14 -6
  155. package/scripts/test-diet/remeasure-list.ts +112 -0
  156. package/scripts/ux/demo-rehearsal.ts +17 -13
  157. package/scripts/ux/demo-seed.ts +108 -0
  158. package/scripts/ux/lib/rehearsal-verdict.ts +12 -5
  159. package/src/agent-mission/driver.ts +138 -16
  160. package/src/agent-mission/pty-decision.ts +1 -1
  161. package/src/autopilot/pty-control-loop.ts +4 -1
  162. package/src/chat/index.ts +1 -0
  163. package/src/cli/persona-cli.ts +17 -14
  164. package/src/cli/pod-cli.ts +17 -0
  165. package/src/cli/pod-memory-advice.ts +35 -0
  166. package/src/cli/release-cli.ts +46 -6
  167. package/src/context-bus/context-now-surfaces.ts +17 -0
  168. package/src/context-bus/context-now.ts +2 -2
  169. package/src/context-bus/coord-events.ts +2 -2
  170. package/src/context-bus/nightly-condense.ts +153 -0
  171. package/src/context-bus/session-events.ts +35 -0
  172. package/src/dashboard/dashboard-status-lines.ts +2 -0
  173. package/src/dashboard/index.ts +8 -0
  174. package/src/dashboard/slash-runtime/dashboard-handlers.ts +24 -0
  175. package/src/domains/outbound-alert.ts +99 -31
  176. package/src/domains/outbound-log-probe.ts +8 -0
  177. package/src/execution-loop/launch-gate.ts +17 -8
  178. package/src/field-note/telegram.ts +142 -0
  179. package/src/graph-runner/graph-grow-llm.ts +23 -13
  180. package/src/graph-runner/graph-grow.ts +5 -2
  181. package/src/graph-runner/runner.ts +12 -8
  182. package/src/harness/harness-cli-command.ts +138 -16
  183. package/src/hooks/consult-intake.ts +153 -0
  184. package/src/hooks/receiver.ts +78 -1
  185. package/src/index.ts +33 -9
  186. package/src/intake-plane/discord-seat-work.ts +28 -2
  187. package/src/intake-plane/wish-card.ts +33 -0
  188. package/src/llm/difficulty.ts +1 -1
  189. package/src/maturity/feature-maturity.ts +2 -1
  190. package/src/nexus/api/http-server.ts +31 -8
  191. package/src/nexus/api/outbound-report.ts +40 -7
  192. package/src/nexus/api/personas.ts +95 -53
  193. package/src/nexus/api/task-cards-api.ts +19 -0
  194. package/src/nexus/api/workflow-history.ts +88 -0
  195. package/src/nexus/api/workflows.ts +52 -0
  196. package/src/nexus/outbound/delivery-ledger.ts +17 -8
  197. package/src/nexus/outbound/router.ts +8 -3
  198. package/src/persona/presets.ts +8 -1
  199. package/src/plugins/maker/market-bundle.ts +2 -1
  200. package/src/release-loop/checklist.ts +15 -3
  201. package/src/release-loop/feature-store.ts +24 -10
  202. package/src/release-loop/landed-but-yellow.ts +48 -0
  203. package/src/seat-loop/seat-loop.ts +168 -7
  204. package/src/self-dev/draft-sweep.ts +44 -10
  205. package/src/self-dev/draft-triage-rules.ts +54 -2
  206. package/src/self-implement/budget-gate.ts +29 -12
  207. package/src/self-implement/gate-candidate-ranking.ts +60 -0
  208. package/src/self-implement/gate-scope.ts +9 -8
  209. package/src/self-implement/headless-elanous-driver.ts +7 -2
  210. package/src/self-implement/orchestrator.ts +15 -0
  211. package/src/self-implement/test-seams.ts +1 -0
  212. package/src/task-orchestrator/surfaces/pod-command-job.ts +14 -10
  213. package/src/task-orchestrator/surfaces/self-implement-pod.ts +8 -0
  214. package/src/telegram-commands.ts +21 -0
  215. package/src/telegram-project-command.ts +78 -4
  216. package/src/telegram.ts +32 -5
  217. package/src/user-config.ts +8 -4
  218. package/src/version/packed-revision.json +1 -1
  219. package/apps/pwa/out/_next/static/chunks/2310-7a59f043f6db207e.js +0 -1
  220. package/apps/pwa/out/_next/static/chunks/2548-6e316138d347cefe.js +0 -1
  221. package/apps/pwa/out/_next/static/chunks/3869-a39d1d9921e10d43.js +0 -1
  222. package/apps/pwa/out/_next/static/chunks/507-5acd98bd6dab2d8a.js +0 -1
  223. package/apps/pwa/out/_next/static/chunks/5445-745eb795c5963d79.js +0 -1
  224. package/apps/pwa/out/_next/static/chunks/app/approvals/page-1dcdc58fc2de2454.js +0 -1
  225. package/apps/pwa/out/_next/static/chunks/app/consult/page-8fd36984f549270a.js +0 -1
  226. package/apps/pwa/out/_next/static/chunks/app/exec/page-4cd1de371f8ad048.js +0 -1
  227. package/apps/pwa/out/_next/static/chunks/app/field/page-d99f89657828421c.js +0 -1
  228. package/apps/pwa/out/_next/static/chunks/app/inside/page-50cb2a0eb15e6c83.js +0 -1
  229. package/apps/pwa/out/_next/static/chunks/app/layout-d6b1d156fb95e74b.js +0 -1
  230. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-6b15dd6456c5df09.js +0 -1
  231. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-371423d1a573d64b.js +0 -1
  232. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-4b764b125339ac17.js +0 -1
  233. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-cdf8e4b01067bcf1.js +0 -1
  234. package/apps/pwa/out/_next/static/chunks/app/page-94c82eb0ae5abb32.js +0 -1
  235. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-4dc614f3ce043e63.js +0 -1
  236. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-d02c782fc792e2af.js +0 -1
  237. package/apps/pwa/out/_next/static/chunks/app/share/page-dc6a79fb003f746c.js +0 -1
  238. package/apps/pwa/out/_next/static/chunks/app/showroom/page-0fffb84cafdc9da6.js +0 -1
  239. package/apps/pwa/out/_next/static/chunks/app/vault/page-fca096e3e860306f.js +0 -1
  240. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-9ebfa78f0d9885c3.js +0 -1
  241. package/apps/pwa/out/_next/static/chunks/app/workflows/page-3dc5f21f7e39622d.js +0 -1
  242. package/apps/pwa/out/_next/static/chunks/app/workspace/page-c6da2b468454532d.js +0 -1
  243. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-93bc70e36f595414.js +0 -1
  244. package/apps/pwa/out/_next/static/css/543f56bcb1f79fca.css +0 -1
  245. /package/apps/pwa/out/_next/static/{Y4zKzL62sakG5momz3SoQ → -6XrAoD3eU7NyMHAE4xPS}/_buildManifest.js +0 -0
  246. /package/apps/pwa/out/_next/static/{Y4zKzL62sakG5momz3SoQ → -6XrAoD3eU7NyMHAE4xPS}/_ssgManifest.js +0 -0
@@ -17,6 +17,8 @@ import { getUserConfig, type UserConfig } from '../user-config.js';
17
17
  import { resolveChannelBotToken } from '../channel-bot-token.js';
18
18
  import { isOperationalKind, kindRouteTarget, logKindRouteFallback, mainHomeTarget } from './telegram-kind-route.js';
19
19
  import { debug } from '../debug/log.js';
20
+ import { registerLogStoreSink, setLogInstanceName } from '../mss/logging/log-store.js';
21
+ import type { LogSink } from '../mss/logging/sink.js';
20
22
  import { resolveDaemonEndpoint } from '../nexus/daemon-endpoint.js';
21
23
  import { effectiveInstanceRoot, prodInstanceRoot } from '../instance/resolve.js';
22
24
  // ★ origin 되돌림(대표 2026-07-12) — 미션 알림을 발신 채널(메인 Q&A 봇)로 되돌린다. type-only
@@ -36,6 +38,29 @@ function nexusUrl(): string | null {
36
38
  const ACP_TOKEN_PATH = join(getElanousConfigDir(), 'acp-token');
37
39
  const DEFERRED_PATH = conatusPath('outbound_deferred.jsonl');
38
40
 
41
+ // CLI/cron do not inherit the daemon's StoreSink. Register once before the first
42
+ // send, but never attach a second sink to a daemon which already owns one.
43
+ let outboundLogSink: (() => void) | null = null;
44
+ function ensureOutboundLogSink(): void {
45
+ if (outboundLogSink || inProcessOutbound || process.env.NODE_ENV === 'test') return;
46
+ try {
47
+ const logs = getUserConfig().logs;
48
+ setLogInstanceName(logs.instanceName);
49
+ outboundLogSink = registerLogStoreSink((s: LogSink) => debug.registerSink(s), 'outbound', logs.retention);
50
+ if (outboundLogSink) process.once('exit', outboundLogSink);
51
+ } catch { /* observations must not prevent delivery */ }
52
+ }
53
+
54
+ function botLabel(token: string): string {
55
+ // Telegram bot IDs are public numeric IDs; never log the token's secret suffix.
56
+ const id = token.split(':', 1)[0];
57
+ return /^\d+$/.test(id ?? '') ? `telegram:${id}` : 'telegram:configured';
58
+ }
59
+
60
+ function safeObservationLabel(value: string): string {
61
+ return /^[a-zA-Z0-9_./-]{1,160}$/.test(value) ? value : 'unknown';
62
+ }
63
+
39
64
  // ── 발송 관측 (대표 지시 2026-07-15): 발송 시각·mode·밀림(burst/lag) 를 logs.db 에 남겨
40
65
  // "실시간인지 밀린 것인지" 를 `elanous logs --category outbound.send` 로 판단 가능하게. ──
41
66
  /** 밀림(버스트) 판정 — 최근 창 내 이 수 이상 발송이 몰리면 밀려 나가는 중으로 본다. */
@@ -61,7 +86,7 @@ function logSend(
61
86
  extra: Record<string, unknown> = {},
62
87
  opts?: { level?: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'critical' },
63
88
  ): void {
64
- try { debug.log('outbound.send', mode, { kind, ...extra }, opts); } catch { /* fail-open */ }
89
+ try { debug.log('outbound.send', mode, { kind: safeObservationLabel(kind), ...extra }, opts); } catch { /* fail-open */ }
65
90
  }
66
91
 
67
92
  /** 밀림 경고 임계 env 이름 — 비교 값은 여기서만 읽는다(호출부에 리터럴을 박지 않는다). */
@@ -143,12 +168,13 @@ function resolveBotToken(botId?: string): string | null {
143
168
  }
144
169
 
145
170
  /** origin(발신 채널)으로 직접 발송. 실패하면 호출자가 report 폴백/재시도한다. */
146
- function deliverToOrigin(origin: MissionOrigin, text: string): boolean {
171
+ function deliverToOrigin(origin: MissionOrigin, text: string, onBot?: (bot: string) => void): boolean {
147
172
  if (origin.channel === 'discord') {
148
173
  if (!origin.channelId) return false;
149
174
  try {
150
175
  const token = resolveChannelBotToken('discord', getUserConfig())?.token;
151
176
  if (!token) return false;
177
+ onBot?.('discord:configured');
152
178
  const channelId = origin.discordThreadId || origin.channelId;
153
179
  const out = spillLongContent(text).text;
154
180
  for (let start = 0; start < out.length;) {
@@ -168,19 +194,21 @@ function deliverToOrigin(origin: MissionOrigin, text: string): boolean {
168
194
  if (origin.channel !== 'telegram' || origin.chatId == null) return false;
169
195
  const token = resolveBotToken(origin.botId);
170
196
  if (!token) return false;
197
+ onBot?.(botLabel(token));
171
198
  try { return sendTelegramRaw(token, origin.chatId, text, origin.threadId); } catch { return false; }
172
199
  }
173
200
 
174
201
  /** 보류분 일괄 발송 — 묶음 1건(아침 폭주 방지). 발송 건수 반환.
175
202
  * 무음 창 밖 첫 sendOutbound 가 자동 호출 + 06:31 플러시 크론이 보장. */
176
203
  export function flushDeferred(path = DEFERRED_PATH): number {
204
+ ensureOutboundLogSink();
177
205
  const observeFlush = (count: number, lagMin: number, kinds: string[]): void => {
178
206
  const lagWarnMin = flushLagWarnMin();
179
207
  const over = count > 0 && lagMin > lagWarnMin;
180
208
  logSend(
181
209
  'flush',
182
210
  'deferred-batch',
183
- { count, lagMin, kinds, path, lagWarnMin },
211
+ { count, lagMin, kinds: kinds.map(safeObservationLabel), path, lagWarnMin },
184
212
  over ? { level: 'warn' } : undefined,
185
213
  );
186
214
  };
@@ -209,14 +237,28 @@ export function flushDeferred(path = DEFERRED_PATH): number {
209
237
  ...list.map(i => `\n── [${kst(i.ts)} · ${i.kind}] ──\n${i.text}`),
210
238
  ].join('\n');
211
239
  observeFlush(items.length, lagMin, [...new Set(items.map(i => i.kind))]);
240
+ const sendBatch = (text: string, kind: string, origin?: MissionOrigin): boolean => {
241
+ let bot = 'unknown';
242
+ const path = origin
243
+ ? (deliverToOrigin(origin, text, label => { bot = label; }) ? 'origin' : false)
244
+ : deliver(text, kind, label => { bot = label; }, 'deferred-flush');
245
+ if (path === 'daemon') return true;
246
+ try {
247
+ debug.log('outbound.send', path ? 'sent' : 'failed', {
248
+ kind: safeObservationLabel(kind), source: 'deferred-flush', bot,
249
+ chars: text.length, path: path || 'undeliverable',
250
+ });
251
+ } catch { /* observation must not change delivery */ }
252
+ return path !== false;
253
+ };
212
254
  let delivered = 0;
213
255
  // origin 없는 매매 알림만 report 묶음. 그 외는 kind 별로 운영 봇 경계를 유지한다.
214
256
  const noOrigin = items.filter(i => !i.origin);
215
257
  const tradingBatch = noOrigin.filter(i => !isOperationalKind(i.kind));
216
- if (tradingBatch.length && deliver(fmt(tradingBatch), 'report')) delivered += tradingBatch.length;
258
+ if (tradingBatch.length && sendBatch(fmt(tradingBatch), 'report')) delivered += tradingBatch.length;
217
259
  for (const kind of new Set(noOrigin.filter(i => isOperationalKind(i.kind)).map(i => i.kind))) {
218
260
  const batch = noOrigin.filter(i => i.kind === kind);
219
- if (deliver(fmt(batch), kind)) delivered += batch.length;
261
+ if (sendBatch(fmt(batch), kind)) delivered += batch.length;
220
262
  }
221
263
  // origin 있는 것 = 발신 채널·스레드별로 묶어 서로 다른 수신자에게 섞이지 않게 발송.
222
264
  const groups = new Map<string, { origin: MissionOrigin; list: Item[] }>();
@@ -228,7 +270,10 @@ export function flushDeferred(path = DEFERRED_PATH): number {
228
270
  if (!groups.has(key)) groups.set(key, { origin: it.origin, list: [] });
229
271
  groups.get(key)!.list.push(it);
230
272
  }
231
- for (const g of groups.values()) { if (deliverToOrigin(g.origin, fmt(g.list))) delivered += g.list.length; }
273
+ for (const g of groups.values()) {
274
+ const kinds = new Set(g.list.map(i => i.kind));
275
+ if (sendBatch(fmt(g.list), kinds.size === 1 ? g.list[0]!.kind : 'mixed', g.origin)) delivered += g.list.length;
276
+ }
232
277
  // 전량 배달 성공 시에만 파일 제거(부분 실패는 다음 flush 재시도 — 성공분 중복은 드문 엣지 수용).
233
278
  if (delivered >= items.length) { try { unlinkSync(path); } catch { /* */ } }
234
279
  return delivered;
@@ -268,17 +313,19 @@ export function sendOutbound(text: string, kind = 'alert', origin?: (MissionOrig
268
313
  const source = origin?.surface || (callerPath
269
314
  ? relative(repoRoot, callerPath.startsWith('file://') ? fileURLToPath(callerPath) : callerPath).replace(/\\/g, '/')
270
315
  : 'unknown');
271
- const logOutcome = (event: 'sent' | 'deferred' | 'failed'): void => {
272
- try { debug.log('outbound.send', event, { kind, source, chars: text.length }); } catch { /* fail-open */ }
316
+ ensureOutboundLogSink();
317
+ let bot = origin?.channel === 'discord' ? 'discord:configured' : 'unknown';
318
+ const logOutcome = (event: 'sent' | 'deferred' | 'failed', path?: string): void => {
319
+ try { debug.log('outbound.send', event, { kind: safeObservationLabel(kind), source: safeObservationLabel(source), chars: text.length, bot, ...(path ? { path } : {}) }); } catch { /* fail-open */ }
273
320
  };
274
321
  // ★ 무음 우회(대표 2026-07-14) — 사용자가 최근(기본 30분) genuine 인텐트(타이핑/버튼탭)를 냈으면
275
322
  // 깨어있으므로 야간 무음이어도 즉시 발송(사용자 발원 흐름의 결과물이 아침까지 묶이지 않게).
276
323
  // 우회 시 flushDeferred 로 그간 보류분도 함께 전달(사용자가 지금 볼 수 있음).
277
324
  if (inQuietHours() && !userRecentlyActive()) {
278
325
  const queued = deferOutbound(text, kind, origin);
279
- recordOutbound(text, kind); // 보류도 논리적 발송 — 회상 대상(데몬 미경유라 클라 기록)
280
- logOutcome(queued ? 'deferred' : 'failed');
281
- return true; // 보류 시 수락 — 적재 오류에서도 기존 호출측 재시도 계약 유지
326
+ if (queued) recordOutbound(text, kind); // 성공한 보류만 회상 대상
327
+ logOutcome(queued ? 'deferred' : 'failed', queued ? 'deferred' : 'queue-failed');
328
+ return queued; // 적재가 성공했을 때만 보류 수락
282
329
  }
283
330
  const bypass = inQuietHours();
284
331
  if (bypass) console.error('[outbound] 야간 무음 우회 — 최근 사용자 활동(깨어있음) → 즉시 발송 + 보류분 flush');
@@ -288,16 +335,15 @@ export function sendOutbound(text: string, kind = 'alert', origin?: (MissionOrig
288
335
  const { recentCount, burst } = recentSendBurst();
289
336
  logSend(bypass ? 'quiet-bypass' : 'realtime', kind, { burst, recentCount, ...(burst ? { backlog: true } : {}) });
290
337
  // ★ origin 되돌림(무음 밖) — 발신 채널로 직접 발송. 성공 시 종료, 실패면 report 폴백.
291
- if (origin && deliverToOrigin(origin, text)) {
338
+ if (origin && deliverToOrigin(origin, text, (label) => { bot = label; })) {
292
339
  recordOutbound(text, kind);
293
- logOutcome('sent');
340
+ logOutcome('sent', 'origin');
294
341
  return true;
295
342
  }
296
- const path = deliver(text, kind);
297
- // 원장 기록은 정확히 1회. 데몬 경유(daemon)면 /v1/outbound 핸들러(outbound-report.ts)
298
- // 가 기록하므로 클라는 중복 금지 — 직접 폴백(direct·데몬 다운)만 클라가 기록.
343
+ const path = deliver(text, kind, (label) => { bot = label; }, source);
344
+ // 데몬 경유는 실제 라우터의 최종 결과만 기록한다. 직접 폴백과 미전달만 여기서 기록.
299
345
  if (path === 'direct') recordOutbound(text, kind);
300
- logOutcome(path === false ? 'failed' : 'sent');
346
+ if (path !== 'daemon') logOutcome(path === false ? 'failed' : 'sent', path === false ? 'undeliverable' : path);
301
347
  return path !== false;
302
348
  }
303
349
 
@@ -318,7 +364,14 @@ export function classifyDaemonResponse(j: unknown): DaemonPathClass {
318
364
  /** 데몬 경로를 못 쓴 이유를 남긴다. 관측 실패가 발송을 막지 않음(recordOutbound 과 같은 fail-soft).
319
365
  * 비-ok 분류는 크론 운영자가 읽는 표준 출력에도 한 줄 — 싱크 미등록 스크립트에서도 즉시 보이게. */
320
366
  function logDaemonPath(classification: DaemonPathClass, kind: string, extra: Record<string, unknown> = {}): void {
321
- try { debug.log('outbound.send', 'daemon-path', { classification, kind, ...extra }); } catch { /* fail-soft */ }
367
+ ensureOutboundLogSink();
368
+ try {
369
+ debug.log('outbound.send', 'daemon-path', {
370
+ classification, kind: safeObservationLabel(kind),
371
+ ...('hasToken' in extra ? { hasToken: extra.hasToken === true } : {}),
372
+ ...('inProcess' in extra ? { inProcess: extra.inProcess === true } : {}),
373
+ });
374
+ } catch { /* fail-soft */ }
322
375
  if (classification === 'ok') return;
323
376
  try { console.error(`[outbound] daemon-path ${classification}`); } catch { /* fail-soft */ }
324
377
  }
@@ -326,23 +379,33 @@ function logDaemonPath(classification: DaemonPathClass, kind: string, extra: Rec
326
379
  /** OB8 — the daemon registers its own in-process sender at startup. Inside the daemon, deliver() must never curl
327
380
  * its own `/v1/outbound`: the curl is synchronous, the event loop that would answer it is the one it blocks, and the
328
381
  * whole daemon freezes until the curl times out (~25 s · `unreachable`) — the message is lost (CS1 · 10-01). */
329
- export type InProcessOutbound = (text: string, kind: string) => Promise<boolean>;
382
+ export type InProcessOutbound = (text: string, kind: string, source?: string, deferFailureObservation?: boolean) => Promise<boolean>;
330
383
  let inProcessOutbound: InProcessOutbound | null = null;
331
384
  export function setInProcessOutbound(send: InProcessOutbound | null): void { inProcessOutbound = send; }
332
385
 
333
386
  /** elanous `/v1/outbound` 우선 → 실패 시 텔레그램 직접. 성공 경로 반환(원장 중복방지용). */
334
- export function deliver(text: string, kind = 'alert'): 'daemon' | 'direct' | false {
387
+ export function deliver(text: string, kind = 'alert', onBot?: (bot: string) => void, source?: string): 'daemon' | 'direct' | false {
335
388
  // 0) inside the daemon — route in-process, asynchronously; the caller's synchronous answer is «accepted».
336
389
  if (inProcessOutbound && process.env.SEND_VIA_ELANOUS !== '0') {
390
+ onBot?.('daemon:configured');
337
391
  const send = inProcessOutbound;
338
- void send(text, kind)
392
+ const fallback = (): void => {
393
+ let bot = 'unknown';
394
+ const ok = sendTelegramDirect(text, kind, {}, label => { bot = label; });
395
+ if (ok) recordOutbound(text, kind);
396
+ try { debug.log('outbound.send', ok ? 'sent' : 'failed', {
397
+ kind: safeObservationLabel(kind), source: safeObservationLabel(source ?? 'daemon-fallback'),
398
+ bot, path: ok ? 'direct' : 'undeliverable', chars: text.length,
399
+ }); } catch { /* fail-open */ }
400
+ };
401
+ void send(text, kind, source, true)
339
402
  .then((ok) => {
340
403
  logDaemonPath(ok ? 'ok' : 'rejected', kind, { inProcess: true });
341
- if (!ok && sendTelegramDirect(text, kind)) recordOutbound(text, kind);
404
+ if (!ok) fallback();
342
405
  })
343
- .catch((error: unknown) => {
344
- logDaemonPath('rejected', kind, { inProcess: true, error: error instanceof Error ? error.message : String(error) });
345
- if (sendTelegramDirect(text, kind)) recordOutbound(text, kind);
406
+ .catch(() => {
407
+ logDaemonPath('rejected', kind, { inProcess: true });
408
+ fallback();
346
409
  });
347
410
  return 'daemon';
348
411
  }
@@ -351,20 +414,22 @@ export function deliver(text: string, kind = 'alert'): 'daemon' | 'direct' | fal
351
414
  if (process.env.SEND_VIA_ELANOUS !== '0') {
352
415
  let token = '';
353
416
  try { if (existsSync(ACP_TOKEN_PATH)) token = readFileSync(ACP_TOKEN_PATH, 'utf-8').trim(); } catch { /* no token */ }
354
- const headers = ['Content-Type: application/json', ...(token ? [`Authorization: Bearer ${token}`] : [])];
417
+ const headers = ['Content-Type: application/json', 'X-Elanous-Client-Fallback: direct',
418
+ ...(source ? [`X-Elanous-Outbound-Source: ${safeObservationLabel(source)}`] : []),
419
+ ...(token ? [`Authorization: Bearer ${token}`] : [])];
355
420
  const nexus = nexusUrl();
356
421
  const j = nexus
357
422
  ? curlPost(`${nexus}/v1/outbound`, JSON.stringify({ text, markdown: false, kind }), headers)
358
423
  : null;
359
424
  const classification = classifyDaemonResponse(j);
360
- if (classification === 'ok') return 'daemon';
425
+ if (classification === 'ok') { onBot?.('daemon:configured'); return 'daemon'; }
361
426
  daemonPath = nexus ? classification : 'not-found';
362
427
  const extra: Record<string, unknown> = { hasToken: token.length > 0 };
363
428
  if (j && typeof j === 'object' && 'error' in (j as object)) extra.error = (j as { error?: unknown }).error;
364
429
  logDaemonPath(classification, kind, extra);
365
430
  }
366
431
  // 2) fallback: 텔레그램 sendMessage 직접(3900자 분할) — 데몬 미경유라 클라가 원장 기록.
367
- if (sendTelegramDirect(text, kind)) return 'direct';
432
+ if (sendTelegramDirect(text, kind, {}, onBot)) return 'direct';
368
433
  reportUndeliverable(kind, daemonPath);
369
434
  return false;
370
435
  }
@@ -379,7 +444,8 @@ function reportUndeliverable(kind: string, daemonPath: DaemonPathClass | 'not-fo
379
444
  root = effectiveInstanceRoot();
380
445
  universe = root === prodInstanceRoot() ? 'prod' : 'test';
381
446
  } catch { /* the report still goes out */ }
382
- try { debug.log('outbound.send', 'undeliverable', { kind, daemonPath, universe, root }); } catch { /* fail-soft */ }
447
+ ensureOutboundLogSink();
448
+ try { debug.log('outbound.send', 'undeliverable', { kind: safeObservationLabel(kind), daemonPath, universe, root }); } catch { /* fail-soft */ }
383
449
  const hint = universe === 'prod'
384
450
  ? '운영 데몬이 떠 있는지 확인: elanous nexus show'
385
451
  : '운영으로 보내려면 설치본 elanous 로 실행하거나 ELANOUS_STATE_DIR=~/.elanous ELANOUS_CONFIG_DIR=~/.elanous 를 준다';
@@ -411,6 +477,7 @@ function sendTelegramRaw(token: string, chatId: string | number, text: string, t
411
477
  export function sendTelegramDirect(
412
478
  text: string, kind?: string,
413
479
  deps: { config?: UserConfig; sendRaw?: typeof sendTelegramRaw; legacyEnv?: typeof conatusEnv } = {},
480
+ onBot?: (bot: string) => void,
414
481
  ): boolean {
415
482
  const sendRaw = deps.sendRaw ?? sendTelegramRaw;
416
483
  let cfg: UserConfig | undefined;
@@ -418,7 +485,7 @@ export function sendTelegramDirect(
418
485
  if (cfg) {
419
486
  try {
420
487
  const target = kindRouteTarget(cfg, kind);
421
- if (target) return sendRaw(target.botToken, String(target.chatId), text);
488
+ if (target) { onBot?.(botLabel(target.botToken)); return sendRaw(target.botToken, String(target.chatId), text); }
422
489
  } catch { /* unavailable channel routing: fall through */ }
423
490
  // A declared table is authoritative: missing roles cannot escape to an env bot.
424
491
  if (cfg.telegram.channels?.length) {
@@ -437,7 +504,7 @@ export function sendTelegramDirect(
437
504
  } catch { /* unavailable main bot */ }
438
505
  const rcToken = cfg?.telegram.reportChannel?.botToken;
439
506
  logKindRouteFallback(kind, home ? 'main-home' : 'none', !!mainToken && !!cfg?.telegram.reportChannel && (!rcToken || rcToken === mainToken));
440
- if (home) return sendRaw(home.botToken, String(home.chatId), text);
507
+ if (home) { onBot?.(botLabel(home.botToken)); return sendRaw(home.botToken, String(home.chatId), text); }
441
508
  console.error('[outbound] 운영 kind 메인 봇/홈 미설정 — 콘솔 출력\n' + text);
442
509
  return false;
443
510
  }
@@ -450,5 +517,6 @@ export function sendTelegramDirect(
450
517
  }
451
518
  logKindRouteFallback(kind, tok && chat ? 'conatus-env' : 'none', false);
452
519
  if (!tok || !chat) { console.error('[outbound] 토큰/chat 미설정 — 콘솔 출력\n' + text); return false; }
520
+ onBot?.(botLabel(tok));
453
521
  return sendRaw(tok, chat, text);
454
522
  }
@@ -0,0 +1,8 @@
1
+ import { sendOutbound, setInProcessOutbound } from './outbound-alert.js';
2
+ import { setUserConfigOverlay } from '../user-config.js';
3
+
4
+ // Focused subprocess fixture: isolated state/config roots and a fake curl on PATH.
5
+ setUserConfigOverlay(cfg => ({ ...cfg, telegram: { ...cfg.telegram, botToken: '123456:private-secret', homeChannel: 98765 } }));
6
+ process.env.SEND_VIA_ELANOUS = '0';
7
+ setInProcessOutbound(null);
8
+ if (!sendOutbound('SECRET-BODY-DO-NOT-LOG', 'ops-alert', { channel: 'cli', surface: process.env.OUTBOUND_PROBE_SOURCE ?? 'outbound-log-probe' })) process.exitCode = 1;
@@ -26,17 +26,20 @@ export function activeRunsForGoal(goalId: string, deps: ActiveRunsForGoalDeps =
26
26
  const loadLedger = deps.loadLedger ?? loadRunLedger;
27
27
  const readGoalDocument = deps.readGoalDocument ?? ((path: string) => readFileSync(path, 'utf8'));
28
28
  let unknown = observation.completeness !== 'complete' || observation.pty.unreadable.length > 0;
29
+ const unknownReasons = new Set<string>();
30
+ if (observation.completeness !== 'complete') unknownReasons.add(`ledger observation ${observation.completeness}`);
31
+ for (const root of observation.pty.unreadable) unknownReasons.add(`unreadable PTY: ${root}`);
29
32
  const active = new Set<string>();
30
33
  for (const run of observation.entries) {
31
34
  if (run.status === 'ended-unclosed') continue;
32
- if (run.ledgerDirectories.length === 0) { unknown = true; continue; }
35
+ if (run.ledgerDirectories.length === 0) { unknown = true; unknownReasons.add(`${run.runId}: ledger directory absent`); continue; }
33
36
  let identified = false;
34
37
  for (const directory of run.ledgerDirectories) {
35
38
  try {
36
39
  const ledger = loadLedger(run.runId, directory);
37
- if (ledger === null) { unknown = true; continue; }
40
+ if (ledger === null) { unknown = true; unknownReasons.add(`${run.runId}: ledger unavailable`); continue; }
38
41
  const ids = new Set(ledger.flatMap((entry) => entry.goalId ? [entry.goalId] : []));
39
- if (ids.size !== 1) { unknown = true; continue; }
42
+ if (ids.size !== 1) { unknown = true; unknownReasons.add(`${run.runId}: expected one goalId, found ${ids.size}`); continue; }
40
43
  identified = true;
41
44
  let matches = ids.has(goalId);
42
45
  if (!matches && goalId.startsWith('request-') && run.status === 'running' && run.ptyRefs.length > 0) {
@@ -45,20 +48,26 @@ export function activeRunsForGoal(goalId: string, deps: ActiveRunsForGoalDeps =
45
48
  try {
46
49
  const ask = extractVerbatimOriginalAsk(readGoalDocument(document));
47
50
  matches = ask?.range !== undefined && launchRequestId(ask.ask) === goalId;
48
- } catch { unknown = true; }
51
+ } catch { unknown = true; unknownReasons.add(`${run.runId}: goal document unreadable`); }
49
52
  }
50
53
  }
51
54
  if (matches) {
52
55
  if (run.status === 'running' && run.ptyRefs.length > 0) active.add(run.runId);
53
- else unknown = true;
56
+ else { unknown = true; unknownReasons.add(`${run.runId}: matching ledger without live PTY`); }
54
57
  }
55
- } catch { unknown = true; }
58
+ } catch { unknown = true; unknownReasons.add(`${run.runId}: ledger read failed`); }
56
59
  }
57
60
  if (!identified) unknown = true;
58
61
  }
59
62
  // Positive evidence wins even if a different process or store is unreadable.
60
- return active.size > 0 ? [...active].sort() : unknown ? 'unknown' : [];
61
- } catch {
63
+ if (active.size > 0) return [...active].sort();
64
+ if (unknown) {
65
+ try { debug.log('execution-loop.launch-gate', 'active-runs-unknown', { goalId, reasons: [...unknownReasons] }); } catch { /* observation is fail-soft */ }
66
+ return 'unknown';
67
+ }
68
+ return [];
69
+ } catch (error) {
70
+ try { debug.log('execution-loop.launch-gate', 'active-runs-unknown', { goalId, reasons: [error instanceof Error ? error.message : String(error)] }); } catch { /* observation is fail-soft */ }
62
71
  return 'unknown';
63
72
  }
64
73
  }
@@ -0,0 +1,142 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { generateFieldNote, type FieldPhoto, type TranscriptEntry } from './index.js';
6
+ import { transcribeFieldAudio, type FieldAudio, type TranscriptionEngine } from './transcribe.js';
7
+ import type { TgIncoming, TgIncomingAttachment } from '../telegram.js';
8
+
9
+ interface SavedMedia { kind: 'photo' | 'voice' | 'audio'; file: string; receivedAt: string; messageId: number }
10
+ interface NoteWindow { title: string; start: string; end: string; status: 'open' | 'done'; openedBy: number; media: SavedMedia[]; notePath?: string }
11
+
12
+ export interface TelegramFieldNoteDeps {
13
+ rootDir: string;
14
+ download: (fileId: string) => Promise<{ localPath: string }>;
15
+ reply: (ctx: TgIncoming, text: string) => Promise<void>;
16
+ engine?: TranscriptionEngine;
17
+ decodeAudio?: (file: string, recordedAt: string) => FieldAudio;
18
+ now?: () => Date;
19
+ }
20
+
21
+ /** Decode Telegram's Ogg/Opus (or audio file) into NOTE3a's 16kHz mono float PCM. */
22
+ export function decodeFieldAudio(file: string, recordedAt: string): FieldAudio {
23
+ const result = spawnSync('ffmpeg', ['-v', 'error', '-i', file, '-f', 'f32le', '-ac', '1', '-ar', '16000', 'pipe:1'], { maxBuffer: 1024 * 1024 * 1024 });
24
+ if (result.status !== 0) throw new Error(`음성 디코딩 실패: ${result.stderr.toString().trim() || result.error?.message || 'ffmpeg'}`);
25
+ if (result.stdout.length % 4) throw new Error('음성 디코딩 실패: 잘못된 PCM 길이');
26
+ const bytes = result.stdout;
27
+ const samples = new Float32Array(bytes.length / 4);
28
+ for (let i = 0; i < samples.length; i++) samples[i] = bytes.readFloatLE(i * 4);
29
+ return { samples, sampleRate: 16000, recordedAt };
30
+ }
31
+
32
+ function windowPath(ctx: TgIncoming, root: string): string {
33
+ const bot = (ctx.botId ?? 'bot').replace(/[^a-zA-Z0-9_-]/g, '_');
34
+ return join(root, 'field-notes', 'telegram', bot, String(ctx.chatId), String(ctx.threadId ?? 0), String(ctx.userId), 'window.json');
35
+ }
36
+
37
+ function readWindow(path: string): NoteWindow | null {
38
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) as NoteWindow : null;
39
+ }
40
+
41
+ function saveWindow(path: string, window: NoteWindow): void {
42
+ mkdirSync(join(path, '..'), { recursive: true });
43
+ const temp = `${path}.${randomUUID()}.tmp`;
44
+ try {
45
+ writeFileSync(temp, JSON.stringify(window, null, 2) + '\n');
46
+ renameSync(temp, path);
47
+ } finally {
48
+ if (existsSync(temp)) unlinkSync(temp);
49
+ }
50
+ }
51
+
52
+ function parseWindow(text: string, now: Date): { title: string; start: string; end: string } | null {
53
+ const match = /^노트:\s*(.+?)\s+(\d{2}):(\d{2})\s*[~~]\s*(\d{2}):(\d{2})$/.exec(text.trim());
54
+ if (!match) return null;
55
+ const [, title, h1, m1, h2, m2] = match;
56
+ if (!Number.isFinite(now.getTime()) || +h1! > 23 || +h2! > 23 || +m1! > 59 || +m2! > 59) return null;
57
+ const local = new Date(now.getTime() + 9 * 60 * 60_000).toISOString().slice(0, 10);
58
+ const start = new Date(`${local}T${h1}:${m1}:00+09:00`);
59
+ const end = new Date(`${local}T${h2}:${m2}:00+09:00`);
60
+ if (end <= start) return null;
61
+ return { title: title!.trim(), start: start.toISOString(), end: end.toISOString() };
62
+ }
63
+
64
+ export async function handleTelegramFieldNote(ctx: TgIncoming, deps: TelegramFieldNoteDeps): Promise<boolean> {
65
+ const text = ctx.text.trim();
66
+ const path = windowPath(ctx, deps.rootDir);
67
+ if (text.startsWith('노트:')) {
68
+ const parsed = parseWindow(text, ctx.receivedAt ? new Date(ctx.receivedAt) : (deps.now ?? (() => new Date()))());
69
+ if (!parsed) await deps.reply(ctx, '노트 시간 형식: 노트: 세미나 18:00~20:00');
70
+ else {
71
+ const previous = readWindow(path);
72
+ if (previous?.status === 'open' && previous.media.length) {
73
+ await deps.reply(ctx, '열린 노트 창이 있습니다 — 먼저 노트 만들기를 완료하세요.');
74
+ return true;
75
+ }
76
+ saveWindow(path, { ...parsed, status: 'open', openedBy: ctx.messageId, media: [] });
77
+ await deps.reply(ctx, `노트 창 열림 · ${parsed.title} · ${text.slice(text.lastIndexOf(' ') + 1)}`);
78
+ }
79
+ return true;
80
+ }
81
+ if (text !== '노트 만들기' && !ctx.attachments.some((a) => a.kind === 'photo' || a.kind === 'voice' || a.kind === 'audio')) return false;
82
+ const window = readWindow(path);
83
+ if (text === '노트 만들기') {
84
+ if (!window || window.status !== 'open') {
85
+ await deps.reply(ctx, '열린 노트 창 없음');
86
+ return true;
87
+ }
88
+ if (window.media.some((item) => item.kind !== 'photo') && !deps.engine) {
89
+ await deps.reply(ctx, '전사 엔진 없음 — NOTE3a 전사 엔진을 설정해 주세요. 모은 파일은 유지됩니다.');
90
+ return true;
91
+ }
92
+ try {
93
+ const photos: FieldPhoto[] = window.media.filter((item) => item.kind === 'photo').map((item) => ({ file: item.file, exifTakenAt: item.receivedAt }));
94
+ const transcript: TranscriptEntry[] = [];
95
+ for (const item of window.media.filter((m) => m.kind !== 'photo')) {
96
+ const audio = (deps.decodeAudio ?? decodeFieldAudio)(item.file, item.receivedAt);
97
+ transcript.push(...await transcribeFieldAudio(audio, deps.engine!));
98
+ }
99
+ const markdown = await generateFieldNote(photos, transcript, {
100
+ summarize: async (entries) => entries.map((entry) => `${entry.timestamp} · ${entry.speaker}: ${entry.text}`).join('\n'),
101
+ });
102
+ const notePath = join(path, '..', `note-${window.openedBy}.md`);
103
+ const noteTemp = `${notePath}.${randomUUID()}.tmp`;
104
+ try {
105
+ writeFileSync(noteTemp, `# ${window.title}\n\n시간 창: ${window.start} ~ ${window.end}\n\n${markdown}`);
106
+ renameSync(noteTemp, notePath);
107
+ } finally {
108
+ if (existsSync(noteTemp)) unlinkSync(noteTemp);
109
+ }
110
+ saveWindow(path, { ...window, status: 'done', notePath });
111
+ await deps.reply(ctx, `노트 생성 · ${notePath}`);
112
+ } catch (error) {
113
+ await deps.reply(ctx, `노트 생성 실패 · ${error instanceof Error ? error.message : String(error)}`);
114
+ }
115
+ return true;
116
+ }
117
+ if (!window || window.status !== 'open') return false;
118
+ const media = ctx.attachments.filter((a): a is TgIncomingAttachment & { kind: 'photo' | 'voice' | 'audio' } =>
119
+ a.kind === 'photo' || a.kind === 'voice' || a.kind === 'audio');
120
+ if (!media.length) return false;
121
+ const receivedAt = ctx.receivedAt ?? (deps.now ?? (() => new Date()))().toISOString();
122
+ if (ctx.messageId <= window.openedBy || receivedAt < window.start || receivedAt > window.end) return false;
123
+ const dir = join(path, '..', 'media');
124
+ mkdirSync(dir, { recursive: true });
125
+ try {
126
+ for (const [index, item] of media.entries()) {
127
+ if (window.media.some((saved) => saved.messageId === ctx.messageId && saved.kind === item.kind)) continue;
128
+ const downloaded = await deps.download(item.fileId);
129
+ const extension = item.kind === 'photo' ? 'jpg' : item.kind === 'voice' ? 'ogg'
130
+ : item.mimeType === 'audio/mpeg' ? 'mp3' : item.mimeType === 'audio/ogg' ? 'ogg' : 'audio';
131
+ const file = join(dir, `${ctx.messageId}-${index}.${extension}`);
132
+ try { copyFileSync(downloaded.localPath, file); }
133
+ finally { try { unlinkSync(downloaded.localPath); } catch { /* download may be reused by the caller */ } }
134
+ window.media.push({ kind: item.kind, file, receivedAt, messageId: ctx.messageId });
135
+ saveWindow(path, window);
136
+ }
137
+ await deps.reply(ctx, `노트 파일 모음 · 사진 ${window.media.filter((m) => m.kind === 'photo').length}장 · 음성 ${window.media.filter((m) => m.kind !== 'photo').length}개`);
138
+ } catch (error) {
139
+ await deps.reply(ctx, `노트 파일 저장 실패 · ${error instanceof Error ? error.message : String(error)}`);
140
+ }
141
+ return true;
142
+ }
@@ -1,25 +1,35 @@
1
- import { streamLLM } from '../llm.js';
1
+ import { PROVIDERS, streamLLM } from '../llm.js';
2
+ import { getUserConfig, resolveRoleLlm, type UserConfig } from '../user-config.js';
2
3
  import type { GrowthProposal, GrowthProposer } from './graph-grow.js';
3
4
 
4
5
  type GrowthLLM = (prompt: string) => Promise<string>;
5
- const OUTPUT_TAIL_LENGTH = 4000;
6
+ const OUTPUT_TAIL_LENGTH = 2000;
7
+
8
+ export function graphGrowthLlmOptions(config: UserConfig): { model?: string; provider?: string } {
9
+ const role = config.roleLlm?.['graph-grow'];
10
+ if (!role || (!role.provider && !role.model && !role.tier)) return {};
11
+ const selected = resolveRoleLlm('graph-grow', { config });
12
+ return { model: selected.model, provider: selected.provider };
13
+ }
14
+
15
+ async function defaultGrowthLLM(prompt: string): Promise<string> {
16
+ const selected = graphGrowthLlmOptions(getUserConfig());
17
+ return streamLLM([{ role: 'user', content: prompt }], () => {}, {
18
+ ...(selected.model ? { model: selected.model, provider: PROVIDERS[selected.provider!] } : {}),
19
+ reasoningEffort: 'low',
20
+ });
21
+ }
6
22
 
7
23
  /** Model output is untrusted: structural and side-effect validation belongs to proposeGrowth. */
8
- export function createLLMGrowthProposer(callLLM: GrowthLLM = (prompt) =>
9
- streamLLM([{ role: 'user', content: prompt }], () => {}, { reasoningEffort: 'low' })): GrowthProposer {
24
+ export function createLLMGrowthProposer(callLLM: GrowthLLM = defaultGrowthLLM): GrowthProposer {
10
25
  return async (input: Parameters<GrowthProposer>[0]) => {
11
26
  const serializedOutput = typeof input.output === 'string' ? input.output : input.output === undefined ? '' : JSON.stringify(input.output);
12
- const graphSummary = {
13
- graphId: input.graph.graphId,
14
- nodes: input.graph.nodes.map(({ nodeId, kind, recipe }) => ({ nodeId, kind, recipe })),
15
- edges: input.graph.edges.map(({ from, to, on, map, fallback }) => ({ from, ...(to === undefined ? {} : { to }),
16
- ...(on === undefined ? {} : { on }), ...(map === undefined ? {} : { map }), ...(fallback === undefined ? {} : { fallback }) })),
17
- };
18
27
  const prompt = [
19
- 'Propose exactly one new contracted graph node to handle the unseen outcome, then return to an existing node.',
20
- 'Reply with only JSON: {"node":{"nodeId":"...","kind":"...","recipe":"none","maxVisits":1,"contract":{"inputs":[],"tools":"read-only","outputs":[]}},"returnTo":"existing-node-id","reason":"..."}.',
28
+ 'Propose exactly one new contracted graph node to handle the unseen outcome and one return edge to an existing node.',
29
+ 'Reply with only JSON: {"node":{"nodeId":"...","kind":"agent","recipe":"none","maxVisits":1,"contract":{"inputs":[],"tools":"read-only","outputs":[]}},"returnTo":"existing-node-id","reason":"..."}.',
30
+ 'Use only an existing recipe type (none, an existing cmd:/approval: recipe ID, or a catalog role); never invent commands. The missing-outcome edge and the return edge are built from node and returnTo after validation.',
21
31
  'Do not alter existing nodes or edges. The proposal will be validated independently; do not assume a recipe is safe.',
22
- `Graph summary: ${JSON.stringify(graphSummary)}`,
32
+ `Graph snapshot: ${JSON.stringify(input.graph)}`,
23
33
  `Blocked node ID: ${JSON.stringify(input.nodeId)}`,
24
34
  `Unseen outcome: ${JSON.stringify(input.outcome)}`,
25
35
  `Blocked node output tail: ${JSON.stringify(serializedOutput.slice(-OUTPUT_TAIL_LENGTH))}`,
@@ -25,8 +25,11 @@ export async function proposeGrowth(input: Parameters<GrowthProposer>[0],
25
25
  catch (error) { return { ok: false, reason: `proposer failed: ${String(error)}` }; }
26
26
  if (!proposal) return { ok: false, reason: 'no proposal' };
27
27
  if (!proposal.node || typeof proposal.reason !== 'string' || !proposal.reason.trim()) return { ok: false, reason: 'proposal requires a node and reason' };
28
- const result = createGraphVariant({ template: input.graph, goal: `g_${input.runId.replace(/[^a-z0-9_-]/gi, '_')}`,
29
- plan: { growth: { node: proposal.node, from: input.nodeId, outcome: input.outcome, returnTo: proposal.returnTo } } });
28
+ let result: ReturnType<typeof createGraphVariant>;
29
+ try {
30
+ result = createGraphVariant({ template: input.graph, goal: `g_${input.runId.replace(/[^a-z0-9_-]/gi, '_')}`,
31
+ plan: { growth: { node: proposal.node, from: input.nodeId, outcome: input.outcome, returnTo: proposal.returnTo } } });
32
+ } catch (error) { return { ok: false, reason: `growth rejected: ${String(error)}` }; }
30
33
  if (!result.ok) return { ok: false, reason: `growth rejected: ${JSON.stringify(result.rejections)}` };
31
34
  // Neither a recipe id nor its declared tools certify arbitrary executable code.
32
35
  // Only a trusted classification of the resolved recipe may authorize execution.