@geraldmaron/construct 1.0.0

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 (352) hide show
  1. package/.env.example +69 -0
  2. package/LICENSE +21 -0
  3. package/README.md +344 -0
  4. package/agents/prompts/cx-accessibility.md +29 -0
  5. package/agents/prompts/cx-ai-engineer.md +31 -0
  6. package/agents/prompts/cx-architect.md +40 -0
  7. package/agents/prompts/cx-business-strategist.md +25 -0
  8. package/agents/prompts/cx-data-analyst.md +30 -0
  9. package/agents/prompts/cx-data-engineer.md +32 -0
  10. package/agents/prompts/cx-debugger.md +27 -0
  11. package/agents/prompts/cx-designer.md +33 -0
  12. package/agents/prompts/cx-devil-advocate.md +36 -0
  13. package/agents/prompts/cx-docs-keeper.md +50 -0
  14. package/agents/prompts/cx-engineer.md +36 -0
  15. package/agents/prompts/cx-evaluator.md +26 -0
  16. package/agents/prompts/cx-explorer.md +38 -0
  17. package/agents/prompts/cx-legal-compliance.md +25 -0
  18. package/agents/prompts/cx-operations.md +28 -0
  19. package/agents/prompts/cx-orchestrator.md +30 -0
  20. package/agents/prompts/cx-platform-engineer.md +25 -0
  21. package/agents/prompts/cx-product-manager.md +30 -0
  22. package/agents/prompts/cx-qa.md +42 -0
  23. package/agents/prompts/cx-rd-lead.md +25 -0
  24. package/agents/prompts/cx-release-manager.md +31 -0
  25. package/agents/prompts/cx-researcher.md +31 -0
  26. package/agents/prompts/cx-reviewer.md +36 -0
  27. package/agents/prompts/cx-security.md +37 -0
  28. package/agents/prompts/cx-sre.md +26 -0
  29. package/agents/prompts/cx-test-automation.md +35 -0
  30. package/agents/prompts/cx-trace-reviewer.md +76 -0
  31. package/agents/prompts/cx-ux-researcher.md +30 -0
  32. package/agents/registry.json +879 -0
  33. package/agents/teams.json +94 -0
  34. package/bin/construct +613 -0
  35. package/commands/build/feature.md +21 -0
  36. package/commands/build/fix.md +20 -0
  37. package/commands/design/access.md +19 -0
  38. package/commands/design/flow.md +16 -0
  39. package/commands/design/ui.md +18 -0
  40. package/commands/measure/experiment.md +18 -0
  41. package/commands/measure/metrics.md +18 -0
  42. package/commands/measure/results.md +18 -0
  43. package/commands/plan/api.md +19 -0
  44. package/commands/plan/challenge.md +18 -0
  45. package/commands/plan/decide.md +20 -0
  46. package/commands/plan/feature.md +49 -0
  47. package/commands/plan/requirements.md +24 -0
  48. package/commands/remember/context.md +26 -0
  49. package/commands/remember/handoff.md +19 -0
  50. package/commands/remember/runbook.md +20 -0
  51. package/commands/review/code.md +21 -0
  52. package/commands/review/quality.md +19 -0
  53. package/commands/review/security.md +16 -0
  54. package/commands/ship/ready.md +19 -0
  55. package/commands/ship/release.md +19 -0
  56. package/commands/ship/status.md +18 -0
  57. package/commands/understand/docs.md +22 -0
  58. package/commands/understand/research.md +22 -0
  59. package/commands/understand/this.md +23 -0
  60. package/commands/understand/why.md +18 -0
  61. package/commands/work/clean.md +32 -0
  62. package/commands/work/drive.md +69 -0
  63. package/commands/work/optimize-prompts.md +46 -0
  64. package/commands/work/parallel-review.md +40 -0
  65. package/db/migrations/001_init.sql +38 -0
  66. package/langfuse/docker-compose.yml +82 -0
  67. package/lib/audit-skills.mjs +118 -0
  68. package/lib/auto-docs.mjs +293 -0
  69. package/lib/cli-commands.mjs +409 -0
  70. package/lib/codex-config.mjs +114 -0
  71. package/lib/comment-lint.mjs +191 -0
  72. package/lib/completions.mjs +170 -0
  73. package/lib/context-state.mjs +99 -0
  74. package/lib/cost.mjs +213 -0
  75. package/lib/distill.mjs +403 -0
  76. package/lib/efficiency.mjs +139 -0
  77. package/lib/env-config.mjs +53 -0
  78. package/lib/eval-harness.mjs +59 -0
  79. package/lib/features.mjs +209 -0
  80. package/lib/headhunt.mjs +597 -0
  81. package/lib/hooks/adaptive-lint.mjs +112 -0
  82. package/lib/hooks/agent-tracker.mjs +50 -0
  83. package/lib/hooks/bootstrap-guard.mjs +90 -0
  84. package/lib/hooks/comment-lint.mjs +26 -0
  85. package/lib/hooks/config-protection.mjs +52 -0
  86. package/lib/hooks/console-warn.mjs +43 -0
  87. package/lib/hooks/context-window-recovery.mjs +90 -0
  88. package/lib/hooks/continuation-enforcer.mjs +72 -0
  89. package/lib/hooks/dep-audit.mjs +155 -0
  90. package/lib/hooks/drive-guard.mjs +89 -0
  91. package/lib/hooks/edit-accumulator.mjs +36 -0
  92. package/lib/hooks/edit-error-recovery.mjs +46 -0
  93. package/lib/hooks/edit-guard.mjs +109 -0
  94. package/lib/hooks/env-check.mjs +80 -0
  95. package/lib/hooks/guard-bash.mjs +83 -0
  96. package/lib/hooks/mcp-audit.mjs +57 -0
  97. package/lib/hooks/mcp-health-check.mjs +51 -0
  98. package/lib/hooks/mcp-task-scope.mjs +47 -0
  99. package/lib/hooks/model-fallback.mjs +105 -0
  100. package/lib/hooks/pre-compact.mjs +212 -0
  101. package/lib/hooks/pre-push-gate.mjs +129 -0
  102. package/lib/hooks/read-tracker.mjs +129 -0
  103. package/lib/hooks/registry-sync.mjs +23 -0
  104. package/lib/hooks/scan-secrets.mjs +76 -0
  105. package/lib/hooks/session-start.mjs +95 -0
  106. package/lib/hooks/stop-notify.mjs +191 -0
  107. package/lib/hooks/stop-typecheck.mjs +83 -0
  108. package/lib/hooks/task-completed-guard.mjs +43 -0
  109. package/lib/hooks/teammate-idle-guard.mjs +54 -0
  110. package/lib/hooks/workflow-guard.mjs +62 -0
  111. package/lib/host-capabilities.mjs +123 -0
  112. package/lib/init-docs.mjs +382 -0
  113. package/lib/mcp/server.mjs +1123 -0
  114. package/lib/mcp-catalog.json +243 -0
  115. package/lib/mcp-manager.mjs +433 -0
  116. package/lib/mcp-platform-config.mjs +104 -0
  117. package/lib/model-router.mjs +810 -0
  118. package/lib/opencode-config.mjs +44 -0
  119. package/lib/opencode-runtime-plugin.mjs +909 -0
  120. package/lib/opencode-telemetry.mjs +433 -0
  121. package/lib/orchestration-policy.mjs +258 -0
  122. package/lib/prompt-composer.mjs +196 -0
  123. package/lib/prompt-metadata.mjs +68 -0
  124. package/lib/review.mjs +429 -0
  125. package/lib/role-preload.mjs +52 -0
  126. package/lib/schemas/decision.json +50 -0
  127. package/lib/schemas/implementation.json +51 -0
  128. package/lib/schemas/review-report.json +32 -0
  129. package/lib/schemas/test-report.json +43 -0
  130. package/lib/server/index.mjs +334 -0
  131. package/lib/server/static/app.js +841 -0
  132. package/lib/server/static/index.html +820 -0
  133. package/lib/service-manager.mjs +225 -0
  134. package/lib/setup.mjs +319 -0
  135. package/lib/status.mjs +707 -0
  136. package/lib/storage/backend.mjs +44 -0
  137. package/lib/storage/embeddings.mjs +70 -0
  138. package/lib/storage/hybrid-query.mjs +184 -0
  139. package/lib/storage/sql-store.mjs +55 -0
  140. package/lib/storage/state-source.mjs +101 -0
  141. package/lib/storage/sync.mjs +164 -0
  142. package/lib/storage/vector-store.mjs +59 -0
  143. package/lib/telemetry/backends/langfuse.mjs +58 -0
  144. package/lib/telemetry/backends/noop.mjs +16 -0
  145. package/lib/telemetry/langfuse-ingest.mjs +108 -0
  146. package/lib/telemetry/langfuse-model-sync.mjs +270 -0
  147. package/lib/telemetry/team-rollup.mjs +143 -0
  148. package/lib/toolkit-env.mjs +30 -0
  149. package/lib/validator.mjs +181 -0
  150. package/lib/workflow-state.mjs +794 -0
  151. package/package.json +53 -0
  152. package/personas/construct.md +94 -0
  153. package/platforms/claude/CLAUDE.md +30 -0
  154. package/platforms/claude/settings.template.json +472 -0
  155. package/platforms/opencode/config.template.json +65 -0
  156. package/platforms/opencode/plugins/construct-fallback.js +5 -0
  157. package/platforms/opencode/sync-config.mjs +69 -0
  158. package/rules/common/agents.md +55 -0
  159. package/rules/common/code-review.md +129 -0
  160. package/rules/common/coding-style.md +95 -0
  161. package/rules/common/comments.md +139 -0
  162. package/rules/common/cx-agent-routing.md +61 -0
  163. package/rules/common/cx-skill-routing.md +11 -0
  164. package/rules/common/development-workflow.md +49 -0
  165. package/rules/common/git-workflow.md +29 -0
  166. package/rules/common/hooks.md +35 -0
  167. package/rules/common/patterns.md +36 -0
  168. package/rules/common/performance.md +71 -0
  169. package/rules/common/security.md +34 -0
  170. package/rules/common/testing.md +62 -0
  171. package/rules/golang/coding-style.md +37 -0
  172. package/rules/golang/hooks.md +22 -0
  173. package/rules/golang/patterns.md +50 -0
  174. package/rules/golang/security.md +39 -0
  175. package/rules/golang/testing.md +36 -0
  176. package/rules/python/coding-style.md +47 -0
  177. package/rules/python/hooks.md +24 -0
  178. package/rules/python/patterns.md +44 -0
  179. package/rules/python/security.md +35 -0
  180. package/rules/python/testing.md +43 -0
  181. package/rules/swift/coding-style.md +52 -0
  182. package/rules/swift/hooks.md +25 -0
  183. package/rules/swift/patterns.md +71 -0
  184. package/rules/swift/security.md +38 -0
  185. package/rules/swift/testing.md +50 -0
  186. package/rules/typescript/coding-style.md +204 -0
  187. package/rules/typescript/hooks.md +27 -0
  188. package/rules/typescript/patterns.md +57 -0
  189. package/rules/typescript/security.md +33 -0
  190. package/rules/typescript/testing.md +23 -0
  191. package/rules/web/coding-style.md +101 -0
  192. package/rules/web/design-quality.md +68 -0
  193. package/rules/web/hooks.md +125 -0
  194. package/rules/web/patterns.md +84 -0
  195. package/rules/web/performance.md +69 -0
  196. package/rules/web/security.md +62 -0
  197. package/rules/web/testing.md +60 -0
  198. package/rules/zh/README.md +113 -0
  199. package/rules/zh/agents.md +55 -0
  200. package/rules/zh/code-review.md +129 -0
  201. package/rules/zh/coding-style.md +53 -0
  202. package/rules/zh/development-workflow.md +49 -0
  203. package/rules/zh/git-workflow.md +29 -0
  204. package/rules/zh/hooks.md +35 -0
  205. package/rules/zh/patterns.md +36 -0
  206. package/rules/zh/performance.md +60 -0
  207. package/rules/zh/security.md +34 -0
  208. package/rules/zh/testing.md +34 -0
  209. package/skills/ai/agent-dev.md +102 -0
  210. package/skills/ai/llm-security.md +105 -0
  211. package/skills/ai/ml-ops.md +169 -0
  212. package/skills/ai/orchestration-workflow.md +87 -0
  213. package/skills/ai/prompt-and-eval.md +114 -0
  214. package/skills/ai/prompt-optimizer.md +114 -0
  215. package/skills/ai/rag-system.md +104 -0
  216. package/skills/architecture/api-design.md +100 -0
  217. package/skills/architecture/caching.md +101 -0
  218. package/skills/architecture/cloud-native.md +97 -0
  219. package/skills/architecture/message-queue.md +100 -0
  220. package/skills/architecture/security-arch.md +105 -0
  221. package/skills/development/cpp.md +127 -0
  222. package/skills/development/go.md +129 -0
  223. package/skills/development/java.md +134 -0
  224. package/skills/development/kotlin.md +140 -0
  225. package/skills/development/mobile-crossplatform.md +144 -0
  226. package/skills/development/python.md +109 -0
  227. package/skills/development/rust.md +127 -0
  228. package/skills/development/shell.md +127 -0
  229. package/skills/development/swift.md +129 -0
  230. package/skills/development/typescript.md +129 -0
  231. package/skills/devops/ci-cd.md +108 -0
  232. package/skills/devops/containerization.md +106 -0
  233. package/skills/devops/cost-optimization.md +105 -0
  234. package/skills/devops/data-engineering.md +181 -0
  235. package/skills/devops/database.md +95 -0
  236. package/skills/devops/dependency-management.md +91 -0
  237. package/skills/devops/devsecops.md +96 -0
  238. package/skills/devops/git-workflow.md +100 -0
  239. package/skills/devops/incident-response.md +167 -0
  240. package/skills/devops/monorepo.md +87 -0
  241. package/skills/devops/observability.md +103 -0
  242. package/skills/devops/performance.md +104 -0
  243. package/skills/devops/testing.md +104 -0
  244. package/skills/docs/adr-workflow.md +32 -0
  245. package/skills/docs/backlog-proposal-workflow.md +19 -0
  246. package/skills/docs/customer-profile-workflow.md +22 -0
  247. package/skills/docs/evidence-ingest-workflow.md +23 -0
  248. package/skills/docs/init-docs.md +160 -0
  249. package/skills/docs/init-project.md +50 -0
  250. package/skills/docs/prd-workflow.md +57 -0
  251. package/skills/docs/prfaq-workflow.md +25 -0
  252. package/skills/docs/product-intelligence-review.md +22 -0
  253. package/skills/docs/product-intelligence-workflow.md +62 -0
  254. package/skills/docs/product-signal-workflow.md +27 -0
  255. package/skills/docs/research-workflow.md +28 -0
  256. package/skills/docs/runbook-workflow.md +24 -0
  257. package/skills/exploration/repo-map.md +297 -0
  258. package/skills/frameworks/django.md +159 -0
  259. package/skills/frameworks/nextjs.md +148 -0
  260. package/skills/frameworks/react.md +132 -0
  261. package/skills/frameworks/spring-boot.md +165 -0
  262. package/skills/frontend-design/accessibility.md +153 -0
  263. package/skills/frontend-design/component-patterns.md +111 -0
  264. package/skills/frontend-design/engineering.md +122 -0
  265. package/skills/frontend-design/state-management.md +118 -0
  266. package/skills/frontend-design/ui-aesthetics.md +102 -0
  267. package/skills/frontend-design/ux-principles.md +126 -0
  268. package/skills/quality-gates/review-work.md +90 -0
  269. package/skills/quality-gates/verify-change.md +94 -0
  270. package/skills/quality-gates/verify-module.md +96 -0
  271. package/skills/quality-gates/verify-quality.md +93 -0
  272. package/skills/quality-gates/verify-security.md +95 -0
  273. package/skills/roles/architect.ai-systems.md +37 -0
  274. package/skills/roles/architect.data.md +37 -0
  275. package/skills/roles/architect.enterprise.md +37 -0
  276. package/skills/roles/architect.integration.md +37 -0
  277. package/skills/roles/architect.md +68 -0
  278. package/skills/roles/architect.platform.md +37 -0
  279. package/skills/roles/data-analyst.experiment.md +37 -0
  280. package/skills/roles/data-analyst.md +68 -0
  281. package/skills/roles/data-analyst.product-intelligence.md +37 -0
  282. package/skills/roles/data-analyst.product.md +37 -0
  283. package/skills/roles/data-analyst.telemetry.md +37 -0
  284. package/skills/roles/data-engineer.pipeline.md +37 -0
  285. package/skills/roles/data-engineer.vector-retrieval.md +37 -0
  286. package/skills/roles/data-engineer.warehouse.md +37 -0
  287. package/skills/roles/debugger.md +68 -0
  288. package/skills/roles/designer.accessibility.md +43 -0
  289. package/skills/roles/designer.md +68 -0
  290. package/skills/roles/engineer.ai.md +49 -0
  291. package/skills/roles/engineer.data.md +49 -0
  292. package/skills/roles/engineer.md +85 -0
  293. package/skills/roles/engineer.platform.md +49 -0
  294. package/skills/roles/operator.docs.md +43 -0
  295. package/skills/roles/operator.md +68 -0
  296. package/skills/roles/operator.release.md +43 -0
  297. package/skills/roles/operator.sre.md +43 -0
  298. package/skills/roles/orchestrator.md +66 -0
  299. package/skills/roles/product-manager.ai-product.md +37 -0
  300. package/skills/roles/product-manager.business-strategy.md +43 -0
  301. package/skills/roles/product-manager.enterprise.md +37 -0
  302. package/skills/roles/product-manager.growth.md +37 -0
  303. package/skills/roles/product-manager.md +67 -0
  304. package/skills/roles/product-manager.platform.md +37 -0
  305. package/skills/roles/product-manager.product.md +37 -0
  306. package/skills/roles/qa.ai-eval.md +37 -0
  307. package/skills/roles/qa.api-contract.md +37 -0
  308. package/skills/roles/qa.data-pipeline.md +37 -0
  309. package/skills/roles/qa.md +68 -0
  310. package/skills/roles/qa.test-automation.md +49 -0
  311. package/skills/roles/qa.web-ui.md +37 -0
  312. package/skills/roles/researcher.explorer.md +43 -0
  313. package/skills/roles/researcher.md +68 -0
  314. package/skills/roles/researcher.ux.md +43 -0
  315. package/skills/roles/reviewer.devil-advocate.md +43 -0
  316. package/skills/roles/reviewer.evaluator.md +43 -0
  317. package/skills/roles/reviewer.md +68 -0
  318. package/skills/roles/reviewer.trace.md +43 -0
  319. package/skills/roles/security.ai.md +37 -0
  320. package/skills/roles/security.appsec.md +37 -0
  321. package/skills/roles/security.cloud.md +37 -0
  322. package/skills/roles/security.legal-compliance.md +49 -0
  323. package/skills/roles/security.md +68 -0
  324. package/skills/roles/security.privacy.md +37 -0
  325. package/skills/roles/security.supply-chain.md +37 -0
  326. package/skills/routing.md +139 -0
  327. package/skills/security/blue-team.md +83 -0
  328. package/skills/security/code-audit.md +94 -0
  329. package/skills/security/pentest.md +84 -0
  330. package/skills/security/red-team.md +81 -0
  331. package/skills/security/threat-intel.md +89 -0
  332. package/skills/security/vuln-research.md +87 -0
  333. package/skills/utility/clean-code.md +165 -0
  334. package/sync-agents.mjs +899 -0
  335. package/templates/docs/adr.md +27 -0
  336. package/templates/docs/backlog-proposal.md +26 -0
  337. package/templates/docs/customer-profile.md +34 -0
  338. package/templates/docs/evidence-brief.md +37 -0
  339. package/templates/docs/incident-report.md +46 -0
  340. package/templates/docs/memo.md +27 -0
  341. package/templates/docs/meta-prd.md +56 -0
  342. package/templates/docs/one-pager.md +24 -0
  343. package/templates/docs/prd-business.md +61 -0
  344. package/templates/docs/prd-platform.md +62 -0
  345. package/templates/docs/prd.md +47 -0
  346. package/templates/docs/prfaq.md +31 -0
  347. package/templates/docs/product-intelligence-report.md +31 -0
  348. package/templates/docs/research-brief.md +29 -0
  349. package/templates/docs/rfc-platform.md +60 -0
  350. package/templates/docs/rfc.md +46 -0
  351. package/templates/docs/runbook.md +33 -0
  352. package/templates/docs/signal-brief.md +25 -0
@@ -0,0 +1,909 @@
1
+ /**
2
+ * lib/opencode-runtime-plugin.mjs — OpenCode plugin: Langfuse telemetry + model fallback.
3
+ *
4
+ * Intercepts OpenCode session events (session.created, session.idle, session.error),
5
+ * records each as a Langfuse trace, and applies model fallback on rate-limit errors.
6
+ */
7
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { dirname, join, resolve } from "node:path";
10
+ import { spawnSync } from "node:child_process";
11
+ import { randomUUID } from "node:crypto";
12
+ import { findOpenCodeConfigPath } from "./opencode-config.mjs";
13
+ import { classifyProviderFailure, readCurrentModels, resolveExecutionContractModelMetadata, resolveFallbackAction, selectModelTierForWorkCategory } from "./model-router.mjs";
14
+ import { resolveRuntimePromptMetadata } from "./prompt-composer.mjs";
15
+ import { enrichMetadataWithPrompt } from "./prompt-metadata.mjs";
16
+ import { loadToolkitEnv } from "./toolkit-env.mjs";
17
+ import { routeRequest } from "./orchestration-policy.mjs";
18
+ import {
19
+ onBusEvent,
20
+ onChatMessage,
21
+ onChatParams,
22
+ onToolBefore,
23
+ onToolAfter,
24
+ onPermissionAsk,
25
+ onCommandBefore,
26
+ getIngestClient,
27
+ } from "./opencode-telemetry.mjs";
28
+ import { syncModelPricing } from "./telemetry/langfuse-model-sync.mjs";
29
+ import { estimateUsageCost } from "./telemetry/langfuse-model-sync.mjs";
30
+
31
+ const EFFICIENCY_SESSION_IDLE_RESET_MS = 2 * 60 * 60 * 1000;
32
+ const EFFICIENCY_REPEATED_READ_THRESHOLD = 5;
33
+ const EFFICIENCY_LARGE_READ_THRESHOLD = 3;
34
+ const EFFICIENCY_TOTAL_BYTES_THRESHOLD = 750_000;
35
+ const EFFICIENCY_LARGE_READ_LIMIT = 400;
36
+
37
+ const RATE_LIMIT_PATTERNS = [
38
+ /\b429\b/i,
39
+ /rate limit/i,
40
+ /usage limits?/i,
41
+ /specified API usage limits?/i,
42
+ /regain access/i,
43
+ /weekly limit/i,
44
+ /monthly limit/i,
45
+ /daily limit/i,
46
+ /too many requests/i,
47
+ /quota exceeded/i,
48
+ /model unavailable/i,
49
+ /model.*overloaded/i,
50
+ /timeout/i,
51
+ /timed out/i,
52
+ /ETIMEDOUT/i,
53
+ /ECONNRESET/i,
54
+ /ProviderModelNotFoundError/i,
55
+ /model.*not found/i,
56
+ /no such model/i,
57
+ ];
58
+
59
+ const RUNTIME_TRACE_EVENTS = new Set([
60
+ "session.created",
61
+ "session.idle",
62
+ "session.error",
63
+ "message.updated",
64
+ ]);
65
+
66
+ const COOLDOWN_MS = 10 * 60 * 1000;
67
+
68
+ function getStatePath(env = process.env) {
69
+ const home = env.HOME || homedir();
70
+ return join(home, ".cx", "construct-opencode-fallback.json");
71
+ }
72
+
73
+ function flatten(value) {
74
+ if (value === null || value === undefined) return "";
75
+ if (typeof value === "string") return value;
76
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
77
+ if (Array.isArray(value)) return value.map(flatten).join("\n");
78
+ if (typeof value === "object") return Object.values(value).map(flatten).join("\n");
79
+ return "";
80
+ }
81
+
82
+ function stripPlaceholder(value) {
83
+ if (typeof value !== "string") return "";
84
+ const trimmed = value.trim();
85
+ return trimmed && !trimmed.includes("__") ? trimmed : "";
86
+ }
87
+
88
+ function positiveNumber(...values) {
89
+ for (const value of values) {
90
+ const number = Number(value);
91
+ if (Number.isFinite(number) && number > 0) return number;
92
+ }
93
+ return 0;
94
+ }
95
+
96
+ function getSessionId(event) {
97
+ return stripPlaceholder(
98
+ event?.properties?.info?.sessionID
99
+ || event?.session?.id
100
+ || event?.sessionId
101
+ || event?.sessionID
102
+ || event?.properties?.sessionId
103
+ || event?.data?.sessionId,
104
+ );
105
+ }
106
+
107
+ function getAgentName(event) {
108
+ return stripPlaceholder(
109
+ event?.properties?.info?.agent
110
+ || event?.session?.agent
111
+ || event?.agent
112
+ || event?.properties?.agent
113
+ || event?.data?.agent,
114
+ );
115
+ }
116
+
117
+ function getEventTimestamp(event) {
118
+ const raw = event?.timestamp || event?.time || event?.createdAt;
119
+ const parsed = typeof raw === "string" || typeof raw === "number" ? new Date(raw) : null;
120
+ return parsed && !Number.isNaN(parsed.getTime()) ? parsed.toISOString() : new Date().toISOString();
121
+ }
122
+
123
+ function classifyErrorText(text) {
124
+ if (!text) return undefined;
125
+ if (RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(text))) return "rate_limit_or_timeout";
126
+ return "session_error";
127
+ }
128
+
129
+ function sanitizeErrorMetadata(event) {
130
+ if (event?.type !== "session.error") return {};
131
+ const error = event?.error && typeof event.error === "object" ? event.error : {};
132
+ const text = flatten(error || event?.message);
133
+ return {
134
+ errorCategory: classifyErrorText(text),
135
+ errorCode: stripPlaceholder(error.code || error.errorCode || event?.code),
136
+ errorName: stripPlaceholder(error.name || event?.name),
137
+ errorStatus: stripPlaceholder(String(error.status || error.statusCode || event?.status || "")),
138
+ provider: stripPlaceholder(error.provider || event?.provider || event?.model?.provider),
139
+ };
140
+ }
141
+
142
+ function sanitizeUsage(event) {
143
+ const info = event?.properties?.info && typeof event.properties.info === 'object'
144
+ ? event.properties.info
145
+ : null;
146
+ const infoTokens = info?.tokens && typeof info.tokens === 'object' ? info.tokens : null;
147
+
148
+ const legacyUsage = event?.usage && typeof event.usage === 'object'
149
+ ? event.usage
150
+ : event?.session?.usage && typeof event.session.usage === 'object'
151
+ ? event.session.usage
152
+ : event?.data?.usage && typeof event.data.usage === 'object'
153
+ ? event.data.usage
154
+ : null;
155
+
156
+ const inputTokens = positiveNumber(
157
+ infoTokens?.input,
158
+ legacyUsage?.input_tokens,
159
+ legacyUsage?.inputTokens,
160
+ legacyUsage?.prompt_tokens,
161
+ legacyUsage?.promptTokens,
162
+ );
163
+ const outputTokens = positiveNumber(
164
+ infoTokens?.output,
165
+ legacyUsage?.output_tokens,
166
+ legacyUsage?.outputTokens,
167
+ legacyUsage?.completion_tokens,
168
+ legacyUsage?.completionTokens,
169
+ );
170
+ const reasoningTokens = positiveNumber(
171
+ infoTokens?.reasoning,
172
+ legacyUsage?.reasoning_tokens,
173
+ legacyUsage?.reasoningTokens,
174
+ legacyUsage?.completion_tokens_details?.reasoning_tokens,
175
+ );
176
+ const cacheReadInputTokens = positiveNumber(
177
+ infoTokens?.cache?.read,
178
+ legacyUsage?.cache_read_input_tokens,
179
+ legacyUsage?.cacheReadInputTokens,
180
+ legacyUsage?.prompt_tokens_details?.cached_tokens,
181
+ );
182
+ const cacheCreationInputTokens = positiveNumber(
183
+ infoTokens?.cache?.write,
184
+ legacyUsage?.cache_creation_input_tokens,
185
+ legacyUsage?.cacheCreationInputTokens,
186
+ (legacyUsage?.cache_creation?.ephemeral_5m_input_tokens ?? 0) +
187
+ (legacyUsage?.cache_creation?.ephemeral_1h_input_tokens ?? 0),
188
+ );
189
+ const totalTokens = positiveNumber(
190
+ legacyUsage?.total_tokens,
191
+ legacyUsage?.totalTokens,
192
+ inputTokens + outputTokens + reasoningTokens,
193
+ );
194
+ const cost = positiveNumber(info?.cost);
195
+
196
+ if (!inputTokens && !outputTokens && !totalTokens && !reasoningTokens) return {};
197
+
198
+ return {
199
+ inputTokens,
200
+ outputTokens,
201
+ totalTokens,
202
+ ...(cacheReadInputTokens ? { cacheReadInputTokens } : {}),
203
+ ...(cacheCreationInputTokens ? { cacheCreationInputTokens } : {}),
204
+ ...(reasoningTokens ? { reasoningTokens } : {}),
205
+ ...(cost ? { costUsd: cost, costSource: "event" } : {}),
206
+ };
207
+ }
208
+
209
+ function getModelInfo(event) {
210
+ const info = event?.properties?.info;
211
+ const model = event?.model || event?.session?.model || event?.data?.model || event?.properties?.model;
212
+ const modelName = info?.modelID
213
+ || (typeof model === 'string' ? model : model?.id || model?.name || model?.model)
214
+ || event?.modelName
215
+ || event?.model_name;
216
+ const provider = info?.providerID
217
+ || (typeof model === 'object' && model?.provider)
218
+ || event?.provider
219
+ || event?.session?.provider
220
+ || event?.data?.provider
221
+ || event?.properties?.provider;
222
+
223
+ return {
224
+ modelName: stripPlaceholder(modelName),
225
+ provider: stripPlaceholder(provider),
226
+ };
227
+ }
228
+
229
+ function extractRequestText(event) {
230
+ if (event?.type !== "message.updated") return "";
231
+ const info = event?.properties?.info;
232
+ if (!info || info.role !== "assistant") return "";
233
+ if (!Array.isArray(info.parts)) return "";
234
+
235
+ const toolInputs = info.parts
236
+ .filter((part) => part && typeof part === "object")
237
+ .map((part) => part?.state?.input || part?.input || part?.args)
238
+ .filter((value) => value && typeof value === "object")
239
+ .map((value) => flatten(value))
240
+ .filter(Boolean);
241
+
242
+ return toolInputs.join("\n").trim();
243
+ }
244
+
245
+ function summarizeAssistantParts(parts = []) {
246
+ const summary = {
247
+ textSegments: 0,
248
+ toolCalls: 0,
249
+ toolInvocationCalls: 0,
250
+ textLength: 0,
251
+ toolNames: [],
252
+ };
253
+
254
+ for (const part of parts) {
255
+ if (!part || typeof part !== "object") continue;
256
+ if (part.type === "text" && typeof part.text === "string") {
257
+ summary.textSegments += 1;
258
+ summary.textLength += part.text.length;
259
+ continue;
260
+ }
261
+ if (part.type === "tool" || part.type === "tool-call" || part.type === "tool_call") {
262
+ summary.toolCalls += 1;
263
+ const toolName = part.tool || part.toolName || part.name;
264
+ if (typeof toolName === "string" && toolName && !summary.toolNames.includes(toolName)) summary.toolNames.push(toolName);
265
+ continue;
266
+ }
267
+ if (part.type === "tool-invocation" || part.type === "tool_invocation") {
268
+ summary.toolInvocationCalls += 1;
269
+ const ti = part.toolInvocation || part;
270
+ const toolName = ti.toolName || ti.tool || ti.name;
271
+ if (typeof toolName === "string" && toolName && !summary.toolNames.includes(toolName)) summary.toolNames.push(toolName);
272
+ }
273
+ }
274
+
275
+ return summary;
276
+ }
277
+
278
+ function buildStructuredTraceOutput(event, { agent, modelInfo, route, executionContractModel, usage, errorMeta, outputText }) {
279
+ if (event?.type === "message.updated") {
280
+ const parts = Array.isArray(event?.properties?.info?.parts) ? event.properties.info.parts : [];
281
+ const partSummary = summarizeAssistantParts(parts);
282
+ return {
283
+ kind: "assistant_message",
284
+ eventType: event.type,
285
+ hasText: Boolean(outputText),
286
+ text: outputText || undefined,
287
+ partSummary,
288
+ agent: agent || undefined,
289
+ model: modelInfo.modelName || undefined,
290
+ provider: modelInfo.provider || undefined,
291
+ route: route ? {
292
+ intent: route.intent,
293
+ track: route.track,
294
+ workCategory: route.workCategory ?? null,
295
+ specialists: route.specialists ?? [],
296
+ } : undefined,
297
+ decision: route ? {
298
+ intent: route.intent,
299
+ track: route.track,
300
+ workCategory: route.workCategory ?? null,
301
+ specialists: route.specialists ?? [],
302
+ } : undefined,
303
+ fallbackAction: errorMeta?.fallbackAction || undefined,
304
+ observedFailureClass: errorMeta?.errorCategory || undefined,
305
+ traceQualityFlags: {
306
+ hasText: Boolean(outputText),
307
+ hasToolCalls: Boolean(Array.isArray(parts) && parts.some((part) => part && typeof part === "object" && (part.type === "tool" || part.type === "tool-call" || part.type === "tool_call" || part.type === "tool-invocation" || part.type === "tool_invocation"))),
308
+ hasUsage: Boolean(usage && Object.keys(usage).length),
309
+ },
310
+ executionContractModel,
311
+ usage: Object.keys(usage || {}).length ? usage : undefined,
312
+ };
313
+ }
314
+
315
+ if (event?.type === "session.error") {
316
+ return {
317
+ kind: "session_error",
318
+ eventType: event.type,
319
+ agent: agent || undefined,
320
+ model: modelInfo.modelName || undefined,
321
+ provider: modelInfo.provider || errorMeta.provider || undefined,
322
+ error: errorMeta,
323
+ decision: {
324
+ intent: route?.intent ?? null,
325
+ track: route?.track ?? null,
326
+ workCategory: route?.workCategory ?? null,
327
+ specialists: route?.specialists ?? [],
328
+ },
329
+ fallbackAction: errorMeta?.fallbackAction || undefined,
330
+ observedFailureClass: errorMeta?.errorCategory || undefined,
331
+ traceQualityFlags: {
332
+ hasText: Boolean(outputText),
333
+ hasUsage: Boolean(usage && Object.keys(usage).length),
334
+ hasError: true,
335
+ },
336
+ executionContractModel,
337
+ usage: Object.keys(usage || {}).length ? usage : undefined,
338
+ };
339
+ }
340
+
341
+ return {
342
+ kind: "runtime_event",
343
+ eventType: event.type,
344
+ agent: agent || undefined,
345
+ model: modelInfo.modelName || undefined,
346
+ provider: modelInfo.provider || undefined,
347
+ status: stripPlaceholder(event?.status || event?.session?.status || event?.data?.status) || undefined,
348
+ decision: route ? {
349
+ intent: route.intent,
350
+ track: route.track,
351
+ workCategory: route.workCategory ?? null,
352
+ specialists: route.specialists ?? [],
353
+ } : undefined,
354
+ observedFailureClass: errorMeta?.errorCategory || undefined,
355
+ traceQualityFlags: {
356
+ hasText: Boolean(outputText),
357
+ hasUsage: Boolean(usage && Object.keys(usage).length),
358
+ hasError: Boolean(Object.keys(errorMeta || {}).length),
359
+ },
360
+ executionContractModel,
361
+ usage: Object.keys(usage || {}).length ? usage : undefined,
362
+ error: Object.keys(errorMeta || {}).length ? errorMeta : undefined,
363
+ };
364
+ }
365
+
366
+ function buildHostConstraints() {
367
+ return {
368
+ runtime: "opencode",
369
+ providerAgnostic: true,
370
+ telemetryBackend: "langfuse",
371
+ };
372
+ }
373
+
374
+ function readRegistryModels(rootDir) {
375
+ try {
376
+ const registryPath = join(rootDir, "agents", "registry.json");
377
+ if (!existsSync(registryPath)) return {};
378
+ const registry = JSON.parse(readFileSync(registryPath, "utf8"));
379
+ return registry?.models ?? {};
380
+ } catch {
381
+ return {};
382
+ }
383
+ }
384
+
385
+ export function buildRuntimeTracePayload(event, { env = process.env } = {}) {
386
+ if (!RUNTIME_TRACE_EVENTS.has(event?.type)) return null;
387
+ if (event?.type === "message.updated") {
388
+ const info = event?.properties?.info;
389
+ if (!info || info.role !== "assistant") return null;
390
+ if (!info.time?.completed) return null;
391
+ if (!info.tokens || !(info.tokens.input || info.tokens.output)) return null;
392
+ }
393
+ const timestamp = getEventTimestamp(event);
394
+ const sessionId = getSessionId(event);
395
+ const agent = getAgentName(event);
396
+ const messageId = stripPlaceholder(event?.properties?.info?.id);
397
+ const traceId = messageId
398
+ ? ["opencode", sessionId || "session", event.type, messageId].join(":")
399
+ : ["opencode", sessionId || "session", event.type, timestamp].join(":");
400
+ const errorMeta = sanitizeErrorMetadata(event);
401
+ const usage = sanitizeUsage(event);
402
+ const modelInfo = getModelInfo(event);
403
+ const rootDir = env.CX_TOOLKIT_DIR || process.cwd();
404
+ const request = extractRequestText(event);
405
+ const route = request ? routeRequest({ request }) : null;
406
+ const registryModels = readRegistryModels(rootDir);
407
+ const currentModels = readCurrentModels(join(rootDir, ".env"), registryModels, env);
408
+ const pricing = estimateUsageCost(modelInfo.modelName || currentModels?.standard || currentModels?.reasoning || currentModels?.fast || null, usage);
409
+ const executionContractModel = resolveExecutionContractModelMetadata({
410
+ envValues: currentModels,
411
+ registryModels,
412
+ requestedTier: modelInfo.modelName ? null : selectModelTierForWorkCategory(route?.workCategory),
413
+ workCategory: route?.workCategory || null,
414
+ });
415
+ const runtimePromptMetadata = resolveRuntimePromptMetadata(agent, {
416
+ rootDir,
417
+ request,
418
+ route,
419
+ registryModels,
420
+ envValues: currentModels,
421
+ executionContractModel,
422
+ hostConstraints: buildHostConstraints(),
423
+ });
424
+ const metadata = enrichMetadataWithPrompt(agent, {
425
+ source: "opencode-plugin",
426
+ eventType: event.type,
427
+ agent: agent || undefined,
428
+ status: stripPlaceholder(event?.status || event?.session?.status || event?.data?.status),
429
+ ...usage,
430
+ ...(pricing.costUsd ? { costUsd: pricing.costUsd, costSource: pricing.costSource, costModel: pricing.modelName } : { costUsd: 0, costSource: pricing.costSource, costModel: pricing.modelName }),
431
+ ...(modelInfo.modelName ? { modelName: modelInfo.modelName } : {}),
432
+ ...(modelInfo.provider ? { provider: modelInfo.provider } : {}),
433
+ ...errorMeta,
434
+ ...runtimePromptMetadata,
435
+ }, { rootDir });
436
+
437
+ // Extract assistant response text for message.updated events so the trace output is populated.
438
+ let outputText;
439
+ if (event?.type === "message.updated") {
440
+ const parts = event?.properties?.info?.parts;
441
+ if (Array.isArray(parts)) {
442
+ const text = parts
443
+ .filter((p) => p?.type === "text" && typeof p.text === "string")
444
+ .map((p) => p.text)
445
+ .join("\n");
446
+ outputText = text || undefined;
447
+ }
448
+ }
449
+
450
+ const structuredOutput = buildStructuredTraceOutput(event, {
451
+ agent,
452
+ modelInfo,
453
+ route,
454
+ executionContractModel,
455
+ usage,
456
+ errorMeta,
457
+ outputText,
458
+ });
459
+
460
+ return {
461
+ id: traceId,
462
+ name: `opencode.${event.type}`,
463
+ userId: stripPlaceholder(env.USER || env.USERNAME),
464
+ sessionId: sessionId || undefined,
465
+ timestamp,
466
+ tags: ["opencode", "runtime", event.type.replace(/\./g, "-")],
467
+ input: {
468
+ eventType: event?.type,
469
+ agent,
470
+ status: event?.status || event?.session?.status || event?.data?.status,
471
+ usage,
472
+ pricing,
473
+ },
474
+ output: structuredOutput,
475
+ metadata,
476
+ };
477
+ }
478
+
479
+ function efficiencyStorePath(env = process.env) {
480
+ const home = env.HOME || homedir();
481
+ return join(home, ".cx", "session-efficiency.json");
482
+ }
483
+
484
+ function loadEfficiencyStats(nowIso, env = process.env) {
485
+ const fresh = {
486
+ sessionStartedAt: nowIso,
487
+ lastUpdatedAt: nowIso,
488
+ readCount: 0,
489
+ uniqueFileCount: 0,
490
+ repeatedReadCount: 0,
491
+ largeReadCount: 0,
492
+ totalBytesRead: 0,
493
+ warnings: {},
494
+ files: {},
495
+ seenToolCallIds: {},
496
+ };
497
+ try {
498
+ const existing = JSON.parse(readFileSync(efficiencyStorePath(env), "utf8"));
499
+ const lastUpdated = new Date(existing.lastUpdatedAt || 0).getTime();
500
+ if (!lastUpdated || Date.now() - lastUpdated > EFFICIENCY_SESSION_IDLE_RESET_MS) return fresh;
501
+ return {
502
+ ...fresh,
503
+ ...existing,
504
+ warnings: existing.warnings || {},
505
+ files: existing.files || {},
506
+ seenToolCallIds: existing.seenToolCallIds || {},
507
+ };
508
+ } catch {
509
+ return fresh;
510
+ }
511
+ }
512
+
513
+ function saveEfficiencyStats(stats, env = process.env) {
514
+ try {
515
+ const p = efficiencyStorePath(env);
516
+ mkdirSync(dirname(p), { recursive: true });
517
+ writeFileSync(p, `${JSON.stringify(stats, null, 2)}\n`);
518
+ } catch { /* best effort */ }
519
+ }
520
+
521
+ function isReadToolName(name) {
522
+ if (typeof name !== "string") return false;
523
+ return name.toLowerCase() === "read";
524
+ }
525
+
526
+ export function extractReadToolCalls(info) {
527
+ const parts = Array.isArray(info?.parts) ? info.parts : [];
528
+ const calls = [];
529
+ for (const part of parts) {
530
+ if (!part || typeof part !== "object") continue;
531
+ const typ = typeof part.type === "string" ? part.type.toLowerCase() : "";
532
+ let toolName;
533
+ let input;
534
+ let callId;
535
+ let state;
536
+ if (typ === "tool" || typ === "tool-call" || typ === "tool_call") {
537
+ toolName = part.tool || part.toolName || part.name;
538
+ input = part.state?.input || part.input || part.args;
539
+ callId = part.callID || part.callId || part.id;
540
+ state = part.state?.status || part.state;
541
+ } else if (typ === "tool-invocation" || typ === "tool_invocation") {
542
+ const ti = part.toolInvocation || part;
543
+ toolName = ti.toolName || ti.tool || ti.name;
544
+ input = ti.args || ti.input;
545
+ callId = ti.toolCallId || ti.callId || ti.id;
546
+ state = ti.state;
547
+ } else if (part.tool && (part.input || part.args || part.state)) {
548
+ toolName = part.tool;
549
+ input = part.state?.input || part.input || part.args;
550
+ callId = part.callID || part.callId || part.id;
551
+ state = part.state?.status || part.state;
552
+ } else {
553
+ continue;
554
+ }
555
+ if (!isReadToolName(toolName)) continue;
556
+ if (state && typeof state === "string" && state !== "completed" && state !== "result") continue;
557
+ const filePath = input?.filePath || input?.file_path || input?.path;
558
+ if (typeof filePath !== "string" || !filePath) continue;
559
+ const limit = Number(input?.limit || 0);
560
+ calls.push({
561
+ callId: typeof callId === "string" && callId ? callId : `${filePath}:${part.id || ""}`,
562
+ filePath,
563
+ limit: Number.isFinite(limit) && limit > 0 ? limit : 0,
564
+ });
565
+ }
566
+ return calls;
567
+ }
568
+
569
+ function sizeOf(filePath, cwd) {
570
+ try {
571
+ const abs = filePath.startsWith("/") ? filePath : resolve(cwd || process.cwd(), filePath);
572
+ return statSync(abs).size;
573
+ } catch {
574
+ return 0;
575
+ }
576
+ }
577
+
578
+ export function trackReadEfficiencyFromMessage(event, { env = process.env, cwd = process.cwd() } = {}) {
579
+ if (event?.type !== "message.updated") return { warnings: [] };
580
+ const info = event?.properties?.info;
581
+ if (!info || info.role !== "assistant" || !info.time?.completed) return { warnings: [] };
582
+ const calls = extractReadToolCalls(info);
583
+ if (!calls.length) return { warnings: [] };
584
+
585
+ const nowIso = new Date().toISOString();
586
+ const stats = loadEfficiencyStats(nowIso, env);
587
+ const warnings = [];
588
+
589
+ for (const call of calls) {
590
+ if (stats.seenToolCallIds[call.callId]) continue;
591
+ stats.seenToolCallIds[call.callId] = nowIso;
592
+
593
+ const size = sizeOf(call.filePath, cwd);
594
+ const existingFile = stats.files[call.filePath];
595
+ const effectiveLimit = call.limit > 0 ? call.limit : 2000;
596
+ const isLargeRead = effectiveLimit > EFFICIENCY_LARGE_READ_LIMIT;
597
+
598
+ stats.readCount += 1;
599
+ stats.totalBytesRead += size;
600
+ if (isLargeRead) stats.largeReadCount += 1;
601
+ if (existingFile) stats.repeatedReadCount += 1;
602
+ else stats.uniqueFileCount += 1;
603
+
604
+ stats.files[call.filePath] = {
605
+ count: (existingFile?.count || 0) + 1,
606
+ size,
607
+ lastReadAt: nowIso,
608
+ lastRequestedLimit: effectiveLimit,
609
+ };
610
+ }
611
+
612
+ if (stats.repeatedReadCount >= EFFICIENCY_REPEATED_READ_THRESHOLD && !stats.warnings.repeatedReads) {
613
+ const top = Object.entries(stats.files)
614
+ .map(([p, v]) => ({ filePath: p, count: Number(v?.count || 0) }))
615
+ .filter((e) => e.count > 1)
616
+ .sort((a, b) => b.count - a.count || a.filePath.localeCompare(b.filePath))[0];
617
+ const topNote = top ? ` Top repeat: ${top.filePath} (${top.count}x).` : "";
618
+ warnings.push(`Efficiency: ${stats.repeatedReadCount} repeated reads this session.${topNote} Use rg or construct distill before re-reading more files.`);
619
+ stats.warnings.repeatedReads = nowIso;
620
+ }
621
+ if (stats.largeReadCount >= EFFICIENCY_LARGE_READ_THRESHOLD && !stats.warnings.largeReads) {
622
+ warnings.push(`Efficiency: ${stats.largeReadCount} large reads this session — prefer rg/glob plus targeted reads under 400 lines.`);
623
+ stats.warnings.largeReads = nowIso;
624
+ }
625
+ if (stats.totalBytesRead >= EFFICIENCY_TOTAL_BYTES_THRESHOLD && !stats.warnings.totalBytes) {
626
+ warnings.push(`Efficiency: ${Math.round(stats.totalBytesRead / 1024)} KB read this session — consider distill/query-focused retrieval or compact context before continuing.`);
627
+ stats.warnings.totalBytes = nowIso;
628
+ }
629
+
630
+ stats.lastUpdatedAt = nowIso;
631
+ saveEfficiencyStats(stats, env);
632
+ return { warnings };
633
+ }
634
+
635
+ function langfuseAvailable(env = process.env) {
636
+ return Boolean(env.LANGFUSE_PUBLIC_KEY && env.LANGFUSE_SECRET_KEY);
637
+ }
638
+
639
+ function langfuseHeaders(env = process.env) {
640
+ const key = env.LANGFUSE_PUBLIC_KEY;
641
+ const secret = env.LANGFUSE_SECRET_KEY;
642
+ return {
643
+ Authorization: `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}`,
644
+ 'Content-Type': 'application/json',
645
+ };
646
+ }
647
+
648
+ function langfuseBaseUrl(env = process.env) {
649
+ return (env.LANGFUSE_BASEURL ?? 'https://cloud.langfuse.com').replace(/\/$/, '');
650
+ }
651
+
652
+ async function emitRuntimeTrace(event, { client, env = process.env }) {
653
+ const payload = buildRuntimeTracePayload(event, { env });
654
+ if (!payload) return;
655
+ const ingest = getIngestClient(env);
656
+ if (!ingest.available) return;
657
+
658
+ try {
659
+ ingest.trace({
660
+ id: payload.id,
661
+ name: payload.name,
662
+ userId: payload.userId,
663
+ sessionId: payload.sessionId,
664
+ timestamp: payload.timestamp,
665
+ tags: payload.tags,
666
+ input: payload.input,
667
+ output: payload.output,
668
+ metadata: payload.metadata,
669
+ });
670
+
671
+ if (event.type === "message.updated") {
672
+ const info = event?.properties?.info;
673
+ const usage = sanitizeUsage(event);
674
+ const { modelName, provider } = getModelInfo(event);
675
+ const model = provider && modelName ? `${provider}/${modelName}` : (modelName || undefined);
676
+ const pricing = estimateUsageCost(model, usage);
677
+ const outputText = (() => {
678
+ if (!Array.isArray(info?.parts)) return undefined;
679
+ const text = info.parts
680
+ .filter((p) => p?.type === "text" && typeof p.text === "string")
681
+ .map((p) => p.text)
682
+ .join("\n");
683
+ return text || undefined;
684
+ })();
685
+ const toolCalls = extractReadToolCalls(info);
686
+ const langfuseUsage = usage.inputTokens ? {
687
+ input: usage.inputTokens,
688
+ output: usage.outputTokens,
689
+ total: usage.totalTokens,
690
+ unit: "TOKENS",
691
+ ...(usage.costUsd ? { totalCost: usage.costUsd } : {}),
692
+ } : undefined;
693
+ ingest.generation({
694
+ id: randomUUID(),
695
+ traceId: payload.id,
696
+ name: `llm.${payload.metadata?.agent || "chat"}`,
697
+ startTime: info?.time?.created ? new Date(info.time.created).toISOString() : payload.timestamp,
698
+ endTime: info?.time?.completed ? new Date(info.time.completed).toISOString() : new Date().toISOString(),
699
+ model,
700
+ usage: langfuseUsage,
701
+ output: {
702
+ kind: "assistant_generation",
703
+ agent: payload.metadata?.agent || undefined,
704
+ model,
705
+ provider,
706
+ hasText: Boolean(outputText),
707
+ text: outputText || undefined,
708
+ costUsd: pricing.costUsd,
709
+ costSource: pricing.costSource,
710
+ costModel: pricing.modelName,
711
+ toolCalls: toolCalls.length ? toolCalls.map((call) => ({ filePath: call.filePath, limit: call.limit, callId: call.callId })) : undefined,
712
+ usage: langfuseUsage ? {
713
+ input: langfuseUsage.input,
714
+ output: langfuseUsage.output,
715
+ total: langfuseUsage.total,
716
+ unit: langfuseUsage.unit,
717
+ ...(langfuseUsage.totalCost ? { totalCost: langfuseUsage.totalCost } : {}),
718
+ } : undefined,
719
+ },
720
+ metadata: {
721
+ agent: payload.metadata?.agent,
722
+ provider,
723
+ costUsd: pricing.costUsd,
724
+ costSource: pricing.costSource,
725
+ costModel: pricing.modelName,
726
+ ...(usage.reasoningTokens ? { reasoningTokens: usage.reasoningTokens } : {}),
727
+ ...(usage.cacheReadInputTokens ? { cacheReadInputTokens: usage.cacheReadInputTokens } : {}),
728
+ },
729
+ });
730
+ await ingest.flush();
731
+ }
732
+ } catch {
733
+ await client?.app?.log?.({
734
+ body: {
735
+ service: "construct",
736
+ level: "warn",
737
+ message: "OpenCode runtime telemetry failed before delivery.",
738
+ },
739
+ });
740
+ }
741
+ }
742
+
743
+ function recentlyApplied(now, env = process.env) {
744
+ try {
745
+ const state = JSON.parse(readFileSync(getStatePath(env), "utf8"));
746
+ return typeof state.lastAppliedAt === "number" && now - state.lastAppliedAt < COOLDOWN_MS;
747
+ } catch {
748
+ return false;
749
+ }
750
+ }
751
+
752
+ function writeState(payload, env = process.env) {
753
+ const statePath = getStatePath(env);
754
+ mkdirSync(dirname(statePath), { recursive: true });
755
+ writeFileSync(statePath, JSON.stringify(payload, null, 2));
756
+ }
757
+
758
+ function readOpenRouterApiKey(configPath = findOpenCodeConfigPath(), env = process.env) {
759
+ if (env.OPENROUTER_API_KEY) return env.OPENROUTER_API_KEY;
760
+ try {
761
+ const config = JSON.parse(readFileSync(configPath, "utf8"));
762
+ const auth = config?.provider?.openrouter?.options?.headers?.Authorization;
763
+ if (typeof auth !== "string") return "";
764
+ const key = auth.replace(/^Bearer\s+/i, "").trim();
765
+ if (!key || key.includes("__OPENROUTER_API_KEY__")) return "";
766
+ return key;
767
+ } catch {
768
+ return "";
769
+ }
770
+ }
771
+
772
+ async function maybeApplyModelFallback(event, { client, env, toolkitDir, configPath }) {
773
+ if (event?.type !== "session.error") return;
774
+ const classifiedFailure = classifyProviderFailure(event);
775
+ const haystack = flatten(event);
776
+ if (!classifiedFailure && !RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(haystack))) return;
777
+
778
+ const constructBin = join(toolkitDir, "bin", "construct");
779
+ if (!existsSync(constructBin)) return;
780
+
781
+ const openRouterApiKey = readOpenRouterApiKey(configPath, env);
782
+ if (!openRouterApiKey) return;
783
+
784
+ const registryModels = readRegistryModels(toolkitDir);
785
+ const currentModels = readCurrentModels(join(toolkitDir, ".env"), registryModels, env);
786
+ const fallbackAction = resolveFallbackAction({
787
+ failure: classifiedFailure ?? { kind: "rate_limit", retryable: true },
788
+ requestedTier: null,
789
+ workCategory: null,
790
+ currentModels,
791
+ registryModels,
792
+ });
793
+
794
+ const now = Date.now();
795
+ if (recentlyApplied(now, env)) return;
796
+
797
+ if (!fallbackAction?.targetModel) {
798
+ try {
799
+ await client?.app?.log?.({
800
+ body: {
801
+ service: "construct",
802
+ level: "warn",
803
+ message: "Provider usage limit detected; no safe fallback target was available.",
804
+ },
805
+ });
806
+ } catch {
807
+ // best effort: fallback must continue even if logging fails
808
+ }
809
+ return;
810
+ }
811
+
812
+ try {
813
+ await client?.app?.log?.({
814
+ body: {
815
+ service: "construct",
816
+ level: "warn",
817
+ message: fallbackAction?.targetModel
818
+ ? `Provider usage limit detected; applying model fallback toward ${fallbackAction.targetModel}.`
819
+ : "Provider usage limit detected; applying model fallback.",
820
+ },
821
+ });
822
+ } catch {
823
+ // best effort: fallback must continue even if logging fails
824
+ }
825
+
826
+ const command = fallbackAction?.targetModel
827
+ ? ["models", `--tier=${fallbackAction.tier}`, `--set=${fallbackAction.targetModel}`]
828
+ : ["models", "--apply"];
829
+
830
+ const result = spawnSync(constructBin, command, {
831
+ cwd: toolkitDir,
832
+ stdio: "ignore",
833
+ env: {
834
+ ...env,
835
+ CX_TOOLKIT_DIR: toolkitDir,
836
+ OPENROUTER_API_KEY: openRouterApiKey,
837
+ },
838
+ timeout: 120_000,
839
+ });
840
+
841
+ if (result.status === 0) {
842
+ writeState({
843
+ lastAppliedAt: now,
844
+ ok: true,
845
+ status: result.status,
846
+ targetModel: fallbackAction.targetModel,
847
+ targetTier: fallbackAction.tier,
848
+ reason: "opencode-session-error",
849
+ }, env);
850
+ }
851
+ }
852
+
853
+ export function createConstructOpenCodePlugin({
854
+ toolkitDir = process.env.CX_TOOLKIT_DIR,
855
+ env = process.env,
856
+ configPath = findOpenCodeConfigPath(),
857
+ } = {}) {
858
+ loadToolkitEnv(toolkitDir, env);
859
+ // Fire-and-forget: sync model pricing into Langfuse on plugin load.
860
+ void syncModelPricing({
861
+ baseUrl: env.LANGFUSE_BASEURL,
862
+ publicKey: env.LANGFUSE_PUBLIC_KEY,
863
+ secretKey: env.LANGFUSE_SECRET_KEY,
864
+ }).catch(() => {});
865
+ const safe = async (fn, label, client) => {
866
+ try {
867
+ await fn();
868
+ } catch (err) {
869
+ await client?.app?.log?.({
870
+ body: {
871
+ service: "construct",
872
+ level: "warn",
873
+ message: `telemetry hook ${label} failed: ${err?.message || err}`,
874
+ },
875
+ }).catch(() => {});
876
+ }
877
+ };
878
+ return async ({ client }) => ({
879
+ event: async ({ event }) => {
880
+ await safe(() => onBusEvent(event, { env }), "event", client);
881
+ await safe(() => emitRuntimeTrace(event, { client, env }), "legacy-trace", client);
882
+ await maybeApplyModelFallback(event, { client, env, toolkitDir, configPath });
883
+ try {
884
+ const { warnings } = trackReadEfficiencyFromMessage(event, { env, cwd: toolkitDir || process.cwd() });
885
+ for (const message of warnings) {
886
+ await client?.app?.log?.({ body: { service: "construct", level: "warn", message } });
887
+ }
888
+ } catch { /* best effort */ }
889
+ },
890
+ "chat.message": async (input, output) => {
891
+ await safe(() => onChatMessage(input, output, { env }), "chat.message", client);
892
+ },
893
+ "chat.params": async (input, output) => {
894
+ await safe(() => onChatParams(input, output, { env }), "chat.params", client);
895
+ },
896
+ "tool.execute.before": async (input, output) => {
897
+ await safe(() => onToolBefore(input, output, { env }), "tool.execute.before", client);
898
+ },
899
+ "tool.execute.after": async (input, output) => {
900
+ await safe(() => onToolAfter(input, output, { env }), "tool.execute.after", client);
901
+ },
902
+ "permission.ask": async (input, output) => {
903
+ await safe(() => onPermissionAsk(input, output, { env }), "permission.ask", client);
904
+ },
905
+ "command.execute.before": async (input, output) => {
906
+ await safe(() => onCommandBefore(input, output, { env }), "command.execute.before", client);
907
+ },
908
+ });
909
+ }