@mxalbert/context-mode 2.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 (365) hide show
  1. package/.claude-plugin/marketplace.json +33 -0
  2. package/.claude-plugin/plugin.json +31 -0
  3. package/.codex-plugin/hooks.json +65 -0
  4. package/.codex-plugin/mcp.json +13 -0
  5. package/.codex-plugin/plugin.json +31 -0
  6. package/.openclaw-plugin/index.ts +12 -0
  7. package/.openclaw-plugin/openclaw.plugin.json +38 -0
  8. package/.openclaw-plugin/package.json +28 -0
  9. package/LICENSE +94 -0
  10. package/README.md +1619 -0
  11. package/bin/statusline.mjs +378 -0
  12. package/build/adapters/antigravity/index.d.ts +51 -0
  13. package/build/adapters/antigravity/index.js +169 -0
  14. package/build/adapters/antigravity-cli/index.d.ts +51 -0
  15. package/build/adapters/antigravity-cli/index.js +342 -0
  16. package/build/adapters/base.d.ts +92 -0
  17. package/build/adapters/base.js +135 -0
  18. package/build/adapters/claude-code/config.d.ts +8 -0
  19. package/build/adapters/claude-code/config.js +8 -0
  20. package/build/adapters/claude-code/hooks.d.ts +106 -0
  21. package/build/adapters/claude-code/hooks.js +183 -0
  22. package/build/adapters/claude-code/index.d.ts +80 -0
  23. package/build/adapters/claude-code/index.js +569 -0
  24. package/build/adapters/claude-code-base.d.ts +49 -0
  25. package/build/adapters/claude-code-base.js +113 -0
  26. package/build/adapters/client-map.d.ts +10 -0
  27. package/build/adapters/client-map.js +42 -0
  28. package/build/adapters/codex/config.d.ts +8 -0
  29. package/build/adapters/codex/config.js +8 -0
  30. package/build/adapters/codex/hooks.d.ts +55 -0
  31. package/build/adapters/codex/hooks.js +64 -0
  32. package/build/adapters/codex/index.d.ts +87 -0
  33. package/build/adapters/codex/index.js +995 -0
  34. package/build/adapters/codex/paths.d.ts +1 -0
  35. package/build/adapters/codex/paths.js +12 -0
  36. package/build/adapters/codex/usage.d.ts +107 -0
  37. package/build/adapters/codex/usage.js +227 -0
  38. package/build/adapters/copilot-base.d.ts +94 -0
  39. package/build/adapters/copilot-base.js +297 -0
  40. package/build/adapters/copilot-cli/hooks.d.ts +33 -0
  41. package/build/adapters/copilot-cli/hooks.js +64 -0
  42. package/build/adapters/copilot-cli/index.d.ts +48 -0
  43. package/build/adapters/copilot-cli/index.js +341 -0
  44. package/build/adapters/cursor/config.d.ts +4 -0
  45. package/build/adapters/cursor/config.js +4 -0
  46. package/build/adapters/cursor/hooks.d.ts +55 -0
  47. package/build/adapters/cursor/hooks.js +82 -0
  48. package/build/adapters/cursor/index.d.ts +64 -0
  49. package/build/adapters/cursor/index.js +517 -0
  50. package/build/adapters/detect.d.ts +136 -0
  51. package/build/adapters/detect.js +623 -0
  52. package/build/adapters/gemini-cli/config.d.ts +8 -0
  53. package/build/adapters/gemini-cli/config.js +8 -0
  54. package/build/adapters/gemini-cli/hooks.d.ts +83 -0
  55. package/build/adapters/gemini-cli/hooks.js +115 -0
  56. package/build/adapters/gemini-cli/index.d.ts +80 -0
  57. package/build/adapters/gemini-cli/index.js +483 -0
  58. package/build/adapters/jetbrains-copilot/config.d.ts +8 -0
  59. package/build/adapters/jetbrains-copilot/config.js +8 -0
  60. package/build/adapters/jetbrains-copilot/hooks.d.ts +59 -0
  61. package/build/adapters/jetbrains-copilot/hooks.js +87 -0
  62. package/build/adapters/jetbrains-copilot/index.d.ts +31 -0
  63. package/build/adapters/jetbrains-copilot/index.js +131 -0
  64. package/build/adapters/kimi/config.d.ts +8 -0
  65. package/build/adapters/kimi/config.js +8 -0
  66. package/build/adapters/kimi/hooks.d.ts +28 -0
  67. package/build/adapters/kimi/hooks.js +34 -0
  68. package/build/adapters/kimi/index.d.ts +66 -0
  69. package/build/adapters/kimi/index.js +537 -0
  70. package/build/adapters/kimi/paths.d.ts +21 -0
  71. package/build/adapters/kimi/paths.js +52 -0
  72. package/build/adapters/kimi/usage.d.ts +82 -0
  73. package/build/adapters/kimi/usage.js +217 -0
  74. package/build/adapters/kiro/hooks.d.ts +61 -0
  75. package/build/adapters/kiro/hooks.js +94 -0
  76. package/build/adapters/kiro/index.d.ts +61 -0
  77. package/build/adapters/kiro/index.js +301 -0
  78. package/build/adapters/omp/index.d.ts +65 -0
  79. package/build/adapters/omp/index.js +182 -0
  80. package/build/adapters/omp/plugin.d.ts +81 -0
  81. package/build/adapters/omp/plugin.js +331 -0
  82. package/build/adapters/omp/usage.d.ts +49 -0
  83. package/build/adapters/omp/usage.js +110 -0
  84. package/build/adapters/openclaw/config.d.ts +8 -0
  85. package/build/adapters/openclaw/config.js +8 -0
  86. package/build/adapters/openclaw/hooks.d.ts +50 -0
  87. package/build/adapters/openclaw/hooks.js +61 -0
  88. package/build/adapters/openclaw/index.d.ts +74 -0
  89. package/build/adapters/openclaw/index.js +439 -0
  90. package/build/adapters/openclaw/mcp-tools.d.ts +54 -0
  91. package/build/adapters/openclaw/mcp-tools.js +207 -0
  92. package/build/adapters/openclaw/plugin.d.ts +141 -0
  93. package/build/adapters/openclaw/plugin.js +818 -0
  94. package/build/adapters/openclaw/session-db.d.ts +55 -0
  95. package/build/adapters/openclaw/session-db.js +88 -0
  96. package/build/adapters/openclaw/usage.d.ts +34 -0
  97. package/build/adapters/openclaw/usage.js +52 -0
  98. package/build/adapters/openclaw/workspace-router.d.ts +29 -0
  99. package/build/adapters/openclaw/workspace-router.js +64 -0
  100. package/build/adapters/opencode/config.d.ts +8 -0
  101. package/build/adapters/opencode/config.js +8 -0
  102. package/build/adapters/opencode/hooks.d.ts +38 -0
  103. package/build/adapters/opencode/hooks.js +50 -0
  104. package/build/adapters/opencode/index.d.ts +144 -0
  105. package/build/adapters/opencode/index.js +638 -0
  106. package/build/adapters/opencode/plugin.d.ts +249 -0
  107. package/build/adapters/opencode/plugin.js +1536 -0
  108. package/build/adapters/opencode/v2.d.ts +117 -0
  109. package/build/adapters/opencode/v2.js +224 -0
  110. package/build/adapters/opencode/zod3tov4.d.ts +1 -0
  111. package/build/adapters/opencode/zod3tov4.js +132 -0
  112. package/build/adapters/pi/extension.d.ts +81 -0
  113. package/build/adapters/pi/extension.js +861 -0
  114. package/build/adapters/pi/index.d.ts +57 -0
  115. package/build/adapters/pi/index.js +173 -0
  116. package/build/adapters/pi/mcp-bridge.d.ts +268 -0
  117. package/build/adapters/pi/mcp-bridge.js +894 -0
  118. package/build/adapters/qwen-code/hooks.d.ts +26 -0
  119. package/build/adapters/qwen-code/hooks.js +29 -0
  120. package/build/adapters/qwen-code/index.d.ts +40 -0
  121. package/build/adapters/qwen-code/index.js +361 -0
  122. package/build/adapters/qwen-code/usage.d.ts +90 -0
  123. package/build/adapters/qwen-code/usage.js +222 -0
  124. package/build/adapters/types.d.ts +360 -0
  125. package/build/adapters/types.js +118 -0
  126. package/build/adapters/vscode-copilot/config.d.ts +8 -0
  127. package/build/adapters/vscode-copilot/config.js +8 -0
  128. package/build/adapters/vscode-copilot/hooks.d.ts +71 -0
  129. package/build/adapters/vscode-copilot/hooks.js +98 -0
  130. package/build/adapters/vscode-copilot/index.d.ts +32 -0
  131. package/build/adapters/vscode-copilot/index.js +222 -0
  132. package/build/adapters/zed/index.d.ts +40 -0
  133. package/build/adapters/zed/index.js +163 -0
  134. package/build/cli.d.ts +34 -0
  135. package/build/cli.js +1828 -0
  136. package/build/db-base.d.ts +250 -0
  137. package/build/db-base.js +756 -0
  138. package/build/executor.d.ts +72 -0
  139. package/build/executor.js +678 -0
  140. package/build/exit-classify.d.ts +19 -0
  141. package/build/exit-classify.js +12 -0
  142. package/build/fetch/blocks.d.ts +145 -0
  143. package/build/fetch/blocks.js +244 -0
  144. package/build/fetch/extract.d.ts +68 -0
  145. package/build/fetch/extract.js +123 -0
  146. package/build/fetch/page-store.d.ts +66 -0
  147. package/build/fetch/page-store.js +174 -0
  148. package/build/fetch-cache.d.ts +13 -0
  149. package/build/fetch-cache.js +15 -0
  150. package/build/lifecycle.d.ts +124 -0
  151. package/build/lifecycle.js +278 -0
  152. package/build/package-identity.d.ts +26 -0
  153. package/build/package-identity.js +28 -0
  154. package/build/runPool.d.ts +36 -0
  155. package/build/runPool.js +51 -0
  156. package/build/runtime.d.ts +71 -0
  157. package/build/runtime.js +659 -0
  158. package/build/search/auto-memory.d.ts +47 -0
  159. package/build/search/auto-memory.js +170 -0
  160. package/build/search/ctx-search-schema.d.ts +90 -0
  161. package/build/search/ctx-search-schema.js +135 -0
  162. package/build/search/flood-guard.d.ts +57 -0
  163. package/build/search/flood-guard.js +80 -0
  164. package/build/search/unified.d.ts +56 -0
  165. package/build/search/unified.js +104 -0
  166. package/build/security.d.ts +206 -0
  167. package/build/security.js +780 -0
  168. package/build/server.d.ts +240 -0
  169. package/build/server.js +5034 -0
  170. package/build/session/analytics.d.ts +773 -0
  171. package/build/session/analytics.js +2420 -0
  172. package/build/session/db.d.ts +468 -0
  173. package/build/session/db.js +1330 -0
  174. package/build/session/error-classifier.d.ts +87 -0
  175. package/build/session/error-classifier.js +303 -0
  176. package/build/session/event-emit.d.ts +48 -0
  177. package/build/session/event-emit.js +101 -0
  178. package/build/session/extract.d.ts +333 -0
  179. package/build/session/extract.js +2688 -0
  180. package/build/session/model-prices.json +429 -0
  181. package/build/session/persist-tool-calls.d.ts +54 -0
  182. package/build/session/persist-tool-calls.js +105 -0
  183. package/build/session/pricing.d.ts +64 -0
  184. package/build/session/pricing.js +151 -0
  185. package/build/session/project-attribution.d.ts +73 -0
  186. package/build/session/project-attribution.js +245 -0
  187. package/build/session/purge.d.ts +138 -0
  188. package/build/session/purge.js +240 -0
  189. package/build/session/retrieval-marker.d.ts +39 -0
  190. package/build/session/retrieval-marker.js +65 -0
  191. package/build/session/snapshot.d.ts +46 -0
  192. package/build/session/snapshot.js +532 -0
  193. package/build/store-directory.d.ts +56 -0
  194. package/build/store-directory.js +254 -0
  195. package/build/store.d.ts +147 -0
  196. package/build/store.js +1714 -0
  197. package/build/truncate.d.ts +57 -0
  198. package/build/truncate.js +142 -0
  199. package/build/types.d.ts +117 -0
  200. package/build/types.js +20 -0
  201. package/build/util/claude-config.d.ts +32 -0
  202. package/build/util/claude-config.js +84 -0
  203. package/build/util/hook-config.d.ts +27 -0
  204. package/build/util/hook-config.js +76 -0
  205. package/build/util/jsonc.d.ts +14 -0
  206. package/build/util/jsonc.js +104 -0
  207. package/build/util/plugin-cache-integrity.d.ts +51 -0
  208. package/build/util/plugin-cache-integrity.js +146 -0
  209. package/build/util/project-dir.d.ts +149 -0
  210. package/build/util/project-dir.js +340 -0
  211. package/build/util/sibling-mcp.d.ts +79 -0
  212. package/build/util/sibling-mcp.js +181 -0
  213. package/cli.bundle.mjs +1495 -0
  214. package/configs/antigravity/GEMINI.md +68 -0
  215. package/configs/antigravity/mcp_config.json +7 -0
  216. package/configs/antigravity-cli/hooks/hooks.json +37 -0
  217. package/configs/antigravity-cli/hooks.json +37 -0
  218. package/configs/antigravity-cli/mcp_config.json +10 -0
  219. package/configs/antigravity-cli/plugin.json +14 -0
  220. package/configs/antigravity-cli/rules/context-mode.md +77 -0
  221. package/configs/antigravity-cli/skills/context-mode/SKILL.md +77 -0
  222. package/configs/claude-code/CLAUDE.md +91 -0
  223. package/configs/codex/AGENTS.md +99 -0
  224. package/configs/codex/config.toml +8 -0
  225. package/configs/codex/hooks.json +47 -0
  226. package/configs/copilot-cli/.github/plugin/plugin.json +23 -0
  227. package/configs/copilot-cli/.mcp.json +12 -0
  228. package/configs/copilot-cli/README.md +47 -0
  229. package/configs/copilot-cli/hooks.json +41 -0
  230. package/configs/copilot-cli/skills/context-mode/SKILL.md +38 -0
  231. package/configs/cursor/context-mode.mdc +76 -0
  232. package/configs/cursor/hooks.json +21 -0
  233. package/configs/cursor/mcp.json +7 -0
  234. package/configs/gemini-cli/GEMINI.md +88 -0
  235. package/configs/gemini-cli/mcp.json +7 -0
  236. package/configs/gemini-cli/settings.json +60 -0
  237. package/configs/jetbrains-copilot/copilot-instructions.md +80 -0
  238. package/configs/jetbrains-copilot/hooks.json +16 -0
  239. package/configs/jetbrains-copilot/mcp.json +7 -0
  240. package/configs/kilo/AGENTS.md +84 -0
  241. package/configs/kilo/kilo.json +6 -0
  242. package/configs/kimi/hooks.json +54 -0
  243. package/configs/kiro/KIRO.md +84 -0
  244. package/configs/kiro/agent.json +18 -0
  245. package/configs/kiro/mcp.json +7 -0
  246. package/configs/omp/SYSTEM.md +85 -0
  247. package/configs/omp/mcp.json +7 -0
  248. package/configs/openclaw/AGENTS.md +87 -0
  249. package/configs/openclaw/openclaw.json +13 -0
  250. package/configs/opencode/AGENTS.md +84 -0
  251. package/configs/opencode/opencode.json +6 -0
  252. package/configs/pi/AGENTS.md +3 -0
  253. package/configs/qwen-code/QWEN.md +91 -0
  254. package/configs/vscode-copilot/copilot-instructions.md +80 -0
  255. package/configs/vscode-copilot/hooks.json +16 -0
  256. package/configs/vscode-copilot/mcp.json +7 -0
  257. package/configs/zed/AGENTS.md +68 -0
  258. package/hooks/antigravity-cli/payload.mjs +98 -0
  259. package/hooks/antigravity-cli/posttooluse.mjs +138 -0
  260. package/hooks/antigravity-cli/pretooluse.mjs +78 -0
  261. package/hooks/antigravity-cli/stop.mjs +58 -0
  262. package/hooks/auto-injection.mjs +102 -0
  263. package/hooks/cache-heal-utils.mjs +379 -0
  264. package/hooks/codex/platform.mjs +1 -0
  265. package/hooks/codex/posttooluse.mjs +66 -0
  266. package/hooks/codex/precompact.mjs +70 -0
  267. package/hooks/codex/pretooluse.mjs +42 -0
  268. package/hooks/codex/sessionstart.mjs +121 -0
  269. package/hooks/codex/stop.mjs +138 -0
  270. package/hooks/codex/userpromptsubmit.mjs +75 -0
  271. package/hooks/copilot-cli/posttooluse.mjs +79 -0
  272. package/hooks/copilot-cli/precompact.mjs +66 -0
  273. package/hooks/copilot-cli/pretooluse.mjs +41 -0
  274. package/hooks/copilot-cli/sessionstart.mjs +121 -0
  275. package/hooks/copilot-cli/stop.mjs +59 -0
  276. package/hooks/copilot-cli/userpromptsubmit.mjs +77 -0
  277. package/hooks/core/codex-caps.mjs +112 -0
  278. package/hooks/core/formatters.mjs +356 -0
  279. package/hooks/core/mcp-ready.mjs +109 -0
  280. package/hooks/core/platform-detect.mjs +49 -0
  281. package/hooks/core/routing.mjs +1050 -0
  282. package/hooks/core/stdin.mjs +90 -0
  283. package/hooks/core/tool-naming.mjs +57 -0
  284. package/hooks/cursor/afteragentresponse.mjs +74 -0
  285. package/hooks/cursor/hooks.json +31 -0
  286. package/hooks/cursor/posttooluse.mjs +72 -0
  287. package/hooks/cursor/pretooluse.mjs +26 -0
  288. package/hooks/cursor/sessionstart.mjs +97 -0
  289. package/hooks/cursor/stop.mjs +48 -0
  290. package/hooks/ensure-deps.mjs +254 -0
  291. package/hooks/formatters/claude-code.mjs +113 -0
  292. package/hooks/formatters/cursor.mjs +37 -0
  293. package/hooks/formatters/gemini-cli.mjs +55 -0
  294. package/hooks/formatters/vscode-copilot.mjs +55 -0
  295. package/hooks/gemini-cli/aftermodel.mjs +70 -0
  296. package/hooks/gemini-cli/aftertool.mjs +61 -0
  297. package/hooks/gemini-cli/beforeagent.mjs +99 -0
  298. package/hooks/gemini-cli/beforetool.mjs +27 -0
  299. package/hooks/gemini-cli/precompress.mjs +55 -0
  300. package/hooks/gemini-cli/sessionstart.mjs +137 -0
  301. package/hooks/heal-partial-install.mjs +712 -0
  302. package/hooks/hooks.json +143 -0
  303. package/hooks/jetbrains-copilot/posttooluse.mjs +61 -0
  304. package/hooks/jetbrains-copilot/precompact.mjs +55 -0
  305. package/hooks/jetbrains-copilot/pretooluse.mjs +27 -0
  306. package/hooks/jetbrains-copilot/sessionstart.mjs +124 -0
  307. package/hooks/kimi/platform.mjs +1 -0
  308. package/hooks/kimi/posttooluse.mjs +72 -0
  309. package/hooks/kimi/precompact.mjs +80 -0
  310. package/hooks/kimi/pretooluse.mjs +42 -0
  311. package/hooks/kimi/sessionend.mjs +61 -0
  312. package/hooks/kimi/sessionstart.mjs +113 -0
  313. package/hooks/kimi/stop.mjs +132 -0
  314. package/hooks/kimi/userpromptsubmit.mjs +90 -0
  315. package/hooks/kiro/agentspawn.mjs +97 -0
  316. package/hooks/kiro/posttooluse.mjs +51 -0
  317. package/hooks/kiro/pretooluse.mjs +64 -0
  318. package/hooks/kiro/userpromptsubmit.mjs +88 -0
  319. package/hooks/normalize-hooks.mjs +323 -0
  320. package/hooks/platform-bridge.mjs +340 -0
  321. package/hooks/posttooluse.mjs +225 -0
  322. package/hooks/precompact.mjs +100 -0
  323. package/hooks/pretooluse.mjs +227 -0
  324. package/hooks/qwen-code/platform.mjs +1 -0
  325. package/hooks/qwen-code/stop.mjs +168 -0
  326. package/hooks/routing-block.mjs +105 -0
  327. package/hooks/run-hook.mjs +95 -0
  328. package/hooks/security.bundle.mjs +2 -0
  329. package/hooks/session-attribution.bundle.mjs +1 -0
  330. package/hooks/session-db.bundle.mjs +145 -0
  331. package/hooks/session-directive.mjs +520 -0
  332. package/hooks/session-extract.bundle.mjs +3 -0
  333. package/hooks/session-helpers.mjs +428 -0
  334. package/hooks/session-loaders.mjs +448 -0
  335. package/hooks/session-snapshot.bundle.mjs +32 -0
  336. package/hooks/sessionstart.mjs +464 -0
  337. package/hooks/stop.mjs +82 -0
  338. package/hooks/suppress-stderr.mjs +25 -0
  339. package/hooks/userpromptsubmit.mjs +108 -0
  340. package/hooks/vscode-copilot/posttooluse.mjs +61 -0
  341. package/hooks/vscode-copilot/precompact.mjs +55 -0
  342. package/hooks/vscode-copilot/pretooluse.mjs +27 -0
  343. package/hooks/vscode-copilot/sessionstart.mjs +129 -0
  344. package/openclaw.plugin.json +38 -0
  345. package/package.json +132 -0
  346. package/scripts/heal-better-sqlite3.mjs +430 -0
  347. package/scripts/heal-installed-plugins.mjs +635 -0
  348. package/scripts/plugin-cache-integrity.mjs +248 -0
  349. package/scripts/postinstall.mjs +397 -0
  350. package/server.bundle.mjs +1421 -0
  351. package/server.js +5 -0
  352. package/skills/.ignore +7 -0
  353. package/skills/context-mode/SKILL.md +300 -0
  354. package/skills/context-mode/references/anti-patterns.md +283 -0
  355. package/skills/context-mode/references/patterns-javascript.md +298 -0
  356. package/skills/context-mode/references/patterns-python.md +304 -0
  357. package/skills/context-mode/references/patterns-shell.md +277 -0
  358. package/skills/ctx-doctor/SKILL.md +22 -0
  359. package/skills/ctx-index/SKILL.md +46 -0
  360. package/skills/ctx-insight/SKILL.md +24 -0
  361. package/skills/ctx-purge/SKILL.md +49 -0
  362. package/skills/ctx-search/SKILL.md +35 -0
  363. package/skills/ctx-stats/SKILL.md +26 -0
  364. package/skills/ctx-upgrade/SKILL.md +31 -0
  365. package/start.mjs +616 -0
@@ -0,0 +1,2688 @@
1
+ /**
2
+ * Session event extraction — pure functions, zero side effects.
3
+ * Extracts structured events from Claude Code tool calls and user messages.
4
+ *
5
+ * All 13 event categories as specified in PRD Section 3.
6
+ */
7
+ import { lookupPrice as catalogLookupPrice, computeCostUsd as catalogComputeCostUsd, } from "./pricing.js";
8
+ // ── Internal helpers ───────────────────────────────────────────────────────
9
+ /** Null-safe string coercion — no truncation, preserves full data. */
10
+ function safeString(value) {
11
+ if (value == null)
12
+ return "";
13
+ return String(value);
14
+ }
15
+ /** Serialise an unknown value to a string — no truncation. */
16
+ function safeStringAny(value) {
17
+ if (value == null)
18
+ return "";
19
+ return typeof value === "string" ? value : JSON.stringify(value);
20
+ }
21
+ function isToolError(input) {
22
+ const response = String(input.tool_response ?? "");
23
+ // PreToolUse rewrites curl/wget/inline-HTTP/WebFetch commands into
24
+ // echo "context-mode: <guidance text including 'retry', 'fails', 'error'>"
25
+ // The user-facing copy legitimately mentions failure modes ("retry if it
26
+ // fails with a transient DNS error"), but those words must NOT classify
27
+ // our OWN guidance message as a tool error or it gets captured into
28
+ // session_resume and surfaces as a fake error in the next chat.
29
+ // We check BOTH sides because:
30
+ // - real shell run → response starts with `context-mode:` (echo stdout)
31
+ // - test/captured-output path → response is the raw command itself
32
+ // (`echo "context-mode: …"`), so we also match the command shape
33
+ const command = String(input.tool_input?.command ?? "");
34
+ if (response.startsWith("context-mode:") ||
35
+ command.startsWith('echo "context-mode:') ||
36
+ command.startsWith("echo 'context-mode:")) {
37
+ return false;
38
+ }
39
+ const isErrorFlag = input.tool_output?.isError === true || input.tool_output?.is_error === true;
40
+ const isBashError = input.tool_name === "Bash" &&
41
+ /exit code [1-9]|error:|Error:|FAIL|failed/i.test(response);
42
+ return isBashError || isErrorFlag;
43
+ }
44
+ function extractApplyPatchTargets(command) {
45
+ if (!command)
46
+ return [];
47
+ const targets = [];
48
+ for (const line of command.split(/\r?\n/)) {
49
+ if (line.startsWith("*** Add File: ")) {
50
+ targets.push({ path: line.slice(14).trim(), type: "file_write" });
51
+ continue;
52
+ }
53
+ if (line.startsWith("*** Update File: ")) {
54
+ targets.push({ path: line.slice(17).trim(), type: "file_edit" });
55
+ continue;
56
+ }
57
+ if (line.startsWith("*** Delete File: ")) {
58
+ targets.push({ path: line.slice(17).trim(), type: "file_edit" });
59
+ continue;
60
+ }
61
+ if (line.startsWith("*** Move to: ")) {
62
+ targets.push({ path: line.slice(13).trim(), type: "file_edit" });
63
+ }
64
+ }
65
+ const seen = new Set();
66
+ return targets.filter((target) => {
67
+ if (!target.path)
68
+ return false;
69
+ const key = `${target.type}:${target.path}`;
70
+ if (seen.has(key))
71
+ return false;
72
+ seen.add(key);
73
+ return true;
74
+ });
75
+ }
76
+ function isPlanFilePath(filePath) {
77
+ return /(?:^|[/\\])\.claude[/\\]plans[/\\]/.test(filePath);
78
+ }
79
+ // ── Category extractors ────────────────────────────────────────────────────
80
+ /**
81
+ * Category 1 & 2: rule + file
82
+ *
83
+ * CLAUDE.md / .claude/ reads → emit both a "rule" event (priority 1) AND a
84
+ * "file_read" event (priority 1) because the file is being actively accessed.
85
+ *
86
+ * Other Edit/Write/Read tool calls → emit a file_edit / file_write / file_read
87
+ * event (priority 1).
88
+ */
89
+ function extractFileAndRule(input) {
90
+ const { tool_name, tool_input, tool_response } = input;
91
+ const events = [];
92
+ if (tool_name === "Read") {
93
+ const filePath = String(tool_input["file_path"] ?? "");
94
+ // Rule detection — covers every supported platform's instruction
95
+ // file convention plus per-user memory directories. Hardcoding here
96
+ // (instead of dispatching through the adapter) keeps extract.ts
97
+ // pure / sync / hot-path-safe — the tradeoff is that adding a new
98
+ // platform requires updating this regex.
99
+ //
100
+ // Filenames: CLAUDE.md, AGENTS.md, AGENTS.override.md, GEMINI.md,
101
+ // QWEN.md, KIRO.md, copilot-instructions.md,
102
+ // context-mode.mdc
103
+ // Directories: .claude/, .codex/memories/, .qwen/memory/,
104
+ // .gemini/memory/, .config/<plat>/memory/, .cursor/memory/,
105
+ // .github/memory/, .kiro/memory/, etc.
106
+ const isRuleFile = /(?:CLAUDE|AGENTS(?:\.override)?|GEMINI|QWEN|KIRO)\.md$/i.test(filePath)
107
+ || /\/copilot-instructions\.md$/i.test(filePath)
108
+ || /\/context-mode\.mdc$/i.test(filePath)
109
+ || /\.claude[\\/]/i.test(filePath)
110
+ || /[\\/]memor(?:y|ies)[\\/][^\\/]+\.md$/i.test(filePath);
111
+ if (isRuleFile) {
112
+ events.push({
113
+ type: "rule",
114
+ category: "rule",
115
+ data: safeString(filePath),
116
+ priority: 1,
117
+ });
118
+ // Capture rule content so it survives context compaction
119
+ if (tool_response && tool_response.length > 0) {
120
+ events.push({
121
+ type: "rule_content",
122
+ category: "rule",
123
+ data: safeString(tool_response),
124
+ priority: 1,
125
+ });
126
+ }
127
+ }
128
+ // Always emit file_read for any Read call
129
+ events.push({
130
+ type: "file_read",
131
+ category: "file",
132
+ data: safeString(filePath),
133
+ priority: 1,
134
+ });
135
+ return events;
136
+ }
137
+ if (tool_name === "Edit") {
138
+ const filePath = String(tool_input["file_path"] ?? "");
139
+ events.push({
140
+ type: "file_edit",
141
+ category: "file",
142
+ data: safeString(filePath),
143
+ priority: 1,
144
+ });
145
+ return events;
146
+ }
147
+ if (tool_name === "NotebookEdit") {
148
+ const notebookPath = String(tool_input["notebook_path"] ?? "");
149
+ events.push({
150
+ type: "file_edit",
151
+ category: "file",
152
+ data: safeString(notebookPath),
153
+ priority: 1,
154
+ });
155
+ return events;
156
+ }
157
+ if (tool_name === "Write") {
158
+ const filePath = String(tool_input["file_path"] ?? "");
159
+ events.push({
160
+ type: "file_write",
161
+ category: "file",
162
+ data: safeString(filePath),
163
+ priority: 1,
164
+ });
165
+ return events;
166
+ }
167
+ if (tool_name === "apply_patch") {
168
+ if (isToolError(input))
169
+ return [];
170
+ const patchTargets = extractApplyPatchTargets(String(tool_input["command"] ?? tool_input["patch"] ?? ""));
171
+ for (const target of patchTargets) {
172
+ events.push({
173
+ type: target.type,
174
+ category: "file",
175
+ data: safeString(target.path),
176
+ priority: 1,
177
+ });
178
+ }
179
+ return events;
180
+ }
181
+ // Glob — file pattern exploration
182
+ if (tool_name === "Glob") {
183
+ const pattern = String(tool_input["pattern"] ?? "");
184
+ events.push({
185
+ type: "file_glob",
186
+ category: "file",
187
+ data: safeString(pattern),
188
+ priority: 3,
189
+ });
190
+ return events;
191
+ }
192
+ // Grep — code search
193
+ if (tool_name === "Grep") {
194
+ const searchPattern = String(tool_input["pattern"] ?? "");
195
+ const searchPath = String(tool_input["path"] ?? "");
196
+ events.push({
197
+ type: "file_search",
198
+ category: "file",
199
+ data: safeString(`${searchPattern} in ${searchPath}`),
200
+ priority: 3,
201
+ });
202
+ return events;
203
+ }
204
+ return events;
205
+ }
206
+ /**
207
+ * Category 4: cwd
208
+ * Matches the first `cd <path>` in a Bash command (handles quoted paths).
209
+ */
210
+ function extractCwd(input) {
211
+ if (input.tool_name !== "Bash")
212
+ return [];
213
+ const cmd = String(input.tool_input["command"] ?? "");
214
+ // Match: cd "path" | cd 'path' | cd path
215
+ const cdMatch = cmd.match(/\bcd\s+("([^"]+)"|'([^']+)'|(\S+))/);
216
+ if (!cdMatch)
217
+ return [];
218
+ const dir = cdMatch[2] ?? cdMatch[3] ?? cdMatch[4] ?? "";
219
+ return [{
220
+ type: "cwd",
221
+ category: "cwd",
222
+ data: safeString(dir),
223
+ priority: 2,
224
+ }];
225
+ }
226
+ /**
227
+ * Category 5: error
228
+ * Detects failures from bash exit codes / error patterns, or an explicit
229
+ * isError flag in tool_output.
230
+ */
231
+ function extractError(input) {
232
+ const { tool_response } = input;
233
+ const response = String(tool_response ?? "");
234
+ if (!isToolError(input))
235
+ return [];
236
+ return [{
237
+ type: "error_tool",
238
+ category: "error",
239
+ data: safeString(response),
240
+ priority: 2,
241
+ }];
242
+ }
243
+ /**
244
+ * Category 11: git
245
+ * Matches common git operations from Bash commands.
246
+ */
247
+ const GIT_PATTERNS = [
248
+ { pattern: /\bgit\s+checkout\b/, operation: "branch" },
249
+ { pattern: /\bgit\s+commit\b/, operation: "commit" },
250
+ { pattern: /\bgit\s+merge\s+\S+/, operation: "merge" },
251
+ { pattern: /\bgit\s+rebase\b/, operation: "rebase" },
252
+ { pattern: /\bgit\s+stash\b/, operation: "stash" },
253
+ { pattern: /\bgit\s+push\b/, operation: "push" },
254
+ { pattern: /\bgit\s+pull\b/, operation: "pull" },
255
+ { pattern: /\bgit\s+log\b/, operation: "log" },
256
+ { pattern: /\bgit\s+diff\b/, operation: "diff" },
257
+ { pattern: /\bgit\s+status\b/, operation: "status" },
258
+ { pattern: /\bgit\s+branch\b/, operation: "branch" },
259
+ { pattern: /\bgit\s+reset\b/, operation: "reset" },
260
+ { pattern: /\bgit\s+add\b/, operation: "add" },
261
+ { pattern: /\bgit\s+cherry-pick\b/, operation: "cherry-pick" },
262
+ { pattern: /\bgit\s+tag\b/, operation: "tag" },
263
+ { pattern: /\bgit\s+fetch\b/, operation: "fetch" },
264
+ { pattern: /\bgit\s+clone\b/, operation: "clone" },
265
+ { pattern: /\bgit\s+worktree\b/, operation: "worktree" },
266
+ ];
267
+ function extractGit(input) {
268
+ if (input.tool_name !== "Bash")
269
+ return [];
270
+ const cmd = String(input.tool_input["command"] ?? "");
271
+ // Bug 8 (v1.0.162) — parse the git invocation algorithmically so flags
272
+ // between `git` and the operation token are tolerated (`git -C /path
273
+ // status`, `git --no-pager log`, etc.). Falls back to the legacy regex
274
+ // pattern scan when the algorithmic parse cannot locate a `git` token —
275
+ // preserves backward compat for commands like `cd /repo && git status`
276
+ // where the algorithmic parse sees `cd` as the first token instead.
277
+ const parsed = parseGitInvocation(cmd);
278
+ let match;
279
+ if (parsed && parsed.operation) {
280
+ match = GIT_PATTERNS.find(p => p.operation === parsed.operation);
281
+ }
282
+ if (!match) {
283
+ match = GIT_PATTERNS.find(p => p.pattern.test(cmd));
284
+ }
285
+ if (!match)
286
+ return [];
287
+ // Bug 1 (v1.0.161) — for `git commit` operations, parse -m / -am / --message=
288
+ // from the Bash command via shell-like argv tokenization so downstream
289
+ // consumers receive the actual commit subject in `data`. Falls back to the
290
+ // operation name when no message argument is present (--amend / --no-edit /
291
+ // -F file / interactive editor flow). Tokenizer is hand-rolled char-by-char
292
+ // (no regex) to mirror real shell quoting/cluster-flag semantics.
293
+ //
294
+ // When a message is captured, the event surfaces as type='git_commit' so the
295
+ // rollup aggregator can distinguish ACTUAL commits from other git operations
296
+ // (status/diff/log were inflating has_commit on every event — see
297
+ // session-loaders.mjs rollup stamp + Bug 2).
298
+ // Bug 8 cwd hint — when `-C <dir>` is present in the git invocation, emit
299
+ // a leading cwd event so the attribution carry-forward (LAST_SEEN source)
300
+ // routes downstream events in the same batch to the scoped directory's
301
+ // project. Without the hint, `git -C /projB status` while cwd=/projA
302
+ // misattributes to /projA.
303
+ const out = [];
304
+ if (parsed?.scopedDir) {
305
+ out.push({
306
+ type: "cwd",
307
+ category: "cwd",
308
+ data: safeString(parsed.scopedDir),
309
+ priority: 2,
310
+ });
311
+ }
312
+ if (match.operation === "commit") {
313
+ const msg = extractCommitMessageFromCommand(cmd);
314
+ if (msg) {
315
+ out.push({
316
+ type: "git_commit",
317
+ category: "git",
318
+ data: safeString(msg),
319
+ priority: 2,
320
+ });
321
+ return out;
322
+ }
323
+ }
324
+ out.push({
325
+ type: "git",
326
+ category: "git",
327
+ data: safeString(match.operation),
328
+ priority: 2,
329
+ });
330
+ return out;
331
+ }
332
+ /**
333
+ * Gap #2 (16-oss-verify-gap-prd) — expand leading `~` / `~/` to homedir.
334
+ * Does NOT support `~user/path` (no current-user resolution at bridge
335
+ * layer; that requires a passwd lookup). Returns input unchanged when
336
+ * there is no tilde or the path starts with `~<otheruser>`.
337
+ */
338
+ function expandHomeTilde(path) {
339
+ if (typeof path !== "string" || path.length === 0)
340
+ return path;
341
+ if (path === "~")
342
+ return getHomedirSafe();
343
+ if (path.startsWith("~/"))
344
+ return getHomedirSafe() + path.slice(1);
345
+ return path;
346
+ }
347
+ /**
348
+ * Lazily-resolved homedir — avoids a require/import at module init time.
349
+ * Falls back to "~" (no-op expansion) when the environment is sandboxed
350
+ * without HOME / USERPROFILE.
351
+ */
352
+ function getHomedirSafe() {
353
+ try {
354
+ const home = process.env.HOME
355
+ || process.env.USERPROFILE
356
+ || (process.env.HOMEDRIVE && process.env.HOMEPATH
357
+ ? process.env.HOMEDRIVE + process.env.HOMEPATH
358
+ : "");
359
+ return home || "~";
360
+ }
361
+ catch {
362
+ return "~";
363
+ }
364
+ }
365
+ function parseGitInvocation(cmd) {
366
+ const tokens = tokenizeCommand(cmd);
367
+ let i = 0;
368
+ // Skip env-style assignments at the head (FOO=bar git ...)
369
+ while (i < tokens.length && isEnvAssignment(tokens[i]))
370
+ i++;
371
+ // Locate the `git` token (allow common runners like `sudo git ...`)
372
+ while (i < tokens.length && tokens[i] !== "git" && !tokens[i].endsWith("/git")) {
373
+ // Stop runner-skipping at the first non-assignment, non-runner token
374
+ if (!isCommonRunner(tokens[i]))
375
+ break;
376
+ i++;
377
+ }
378
+ if (i >= tokens.length)
379
+ return null;
380
+ if (tokens[i] !== "git" && !tokens[i].endsWith("/git"))
381
+ return null;
382
+ i++; // consume `git`
383
+ let scopedDir = null;
384
+ let operation = null;
385
+ while (i < tokens.length) {
386
+ const t = tokens[i];
387
+ if (t === "-C" || t === "--directory") {
388
+ scopedDir = tokens[i + 1] ?? null;
389
+ i += 2;
390
+ continue;
391
+ }
392
+ // Gap #2 — `--directory=/path` equals-form (tokenizer keeps it as one)
393
+ if (t.startsWith("--directory=")) {
394
+ scopedDir = t.slice("--directory=".length);
395
+ i++;
396
+ continue;
397
+ }
398
+ if (t.length > 0 && t[0] === "-") {
399
+ // Generic flag — skip the flag itself. We do NOT consume the next
400
+ // token as its value generically because git's per-flag arg shape
401
+ // varies; the dedicated extractCommitMessageFromCommand handles -m
402
+ // separately.
403
+ i++;
404
+ continue;
405
+ }
406
+ // First bare (non-flag) token after `git` = operation
407
+ operation = t;
408
+ break;
409
+ }
410
+ if (scopedDir)
411
+ scopedDir = expandHomeTilde(scopedDir);
412
+ return { scopedDir, operation };
413
+ }
414
+ function isEnvAssignment(token) {
415
+ if (token.length === 0)
416
+ return false;
417
+ // FOO=bar shape: starts with an uppercase letter, contains an `=`
418
+ let sawEq = false;
419
+ for (let j = 0; j < token.length; j++) {
420
+ const c = token.charCodeAt(j);
421
+ if (j === 0) {
422
+ // First char must be A-Z or underscore
423
+ if (!((c >= 65 && c <= 90) || c === 95))
424
+ return false;
425
+ }
426
+ else if (c === 61 /* = */) {
427
+ sawEq = true;
428
+ break;
429
+ }
430
+ else if (!((c >= 65 && c <= 90) || (c >= 48 && c <= 57) || c === 95)) {
431
+ // Body chars must be A-Z, 0-9, or _
432
+ return false;
433
+ }
434
+ }
435
+ return sawEq;
436
+ }
437
+ function isCommonRunner(token) {
438
+ // Runners that wrap real commands. We skip them when locating `git`
439
+ // so `sudo git status` works the same as `git status`.
440
+ switch (token) {
441
+ case "sudo":
442
+ case "doas":
443
+ case "env":
444
+ case "exec":
445
+ case "time":
446
+ return true;
447
+ default:
448
+ return false;
449
+ }
450
+ }
451
+ // Shell-like argv tokenizer — handles single/double quotes, backslash escapes,
452
+ // and merges adjacent quoted/unquoted segments per POSIX shell behavior
453
+ // (`echo a"b c"d` → ["ab cd"]). Pure char loop; no regex.
454
+ function tokenizeCommand(cmd) {
455
+ const tokens = [];
456
+ const n = cmd.length;
457
+ let i = 0;
458
+ while (i < n) {
459
+ while (i < n && (cmd[i] === " " || cmd[i] === "\t"))
460
+ i++;
461
+ if (i >= n)
462
+ break;
463
+ let buf = "";
464
+ while (i < n && cmd[i] !== " " && cmd[i] !== "\t") {
465
+ const ch = cmd[i];
466
+ if (ch === '"' || ch === "'") {
467
+ const quote = ch;
468
+ i++;
469
+ while (i < n && cmd[i] !== quote) {
470
+ if (cmd[i] === "\\" && i + 1 < n) {
471
+ buf += cmd[i + 1];
472
+ i += 2;
473
+ }
474
+ else {
475
+ buf += cmd[i];
476
+ i++;
477
+ }
478
+ }
479
+ if (i < n)
480
+ i++; // consume closing quote
481
+ }
482
+ else if (ch === "\\" && i + 1 < n) {
483
+ buf += cmd[i + 1];
484
+ i += 2;
485
+ }
486
+ else {
487
+ buf += ch;
488
+ i++;
489
+ }
490
+ }
491
+ tokens.push(buf);
492
+ }
493
+ return tokens;
494
+ }
495
+ // Linear scan over argv looking for a commit-message-bearing flag:
496
+ // --message=<value> long form, attached value
497
+ // --message <value> long form, separate token
498
+ // -m / -am / -cm ... short cluster ending in 'm', value in next token
499
+ // Returns null when no message arg is present — caller falls back to
500
+ // operation name. Pure char checks; no regex.
501
+ function extractCommitMessageFromCommand(cmd) {
502
+ const argv = tokenizeCommand(cmd);
503
+ const longPrefix = "--message=";
504
+ for (let i = 0; i < argv.length; i++) {
505
+ const arg = argv[i];
506
+ // Long form: --message=VALUE
507
+ if (arg.length > longPrefix.length && arg.startsWith(longPrefix)) {
508
+ const v = arg.slice(longPrefix.length);
509
+ return v.length > 0 ? v : null;
510
+ }
511
+ // Long form: --message VALUE
512
+ if (arg === "--message") {
513
+ const v = argv[i + 1];
514
+ return v && v.length > 0 ? v : null;
515
+ }
516
+ // Short cluster ending in 'm' (e.g. -m, -am, -cm). Cluster must be
517
+ // single-dash followed by only lowercase letters, last letter 'm'.
518
+ if (arg.length >= 2 &&
519
+ arg[0] === "-" &&
520
+ arg[1] !== "-" &&
521
+ arg[arg.length - 1] === "m" &&
522
+ isLowerAlphaRun(arg, 1)) {
523
+ const v = argv[i + 1];
524
+ return v && v.length > 0 ? v : null;
525
+ }
526
+ }
527
+ return null;
528
+ }
529
+ function isLowerAlphaRun(s, start) {
530
+ if (start >= s.length)
531
+ return false;
532
+ for (let i = start; i < s.length; i++) {
533
+ const c = s.charCodeAt(i);
534
+ if (c < 97 || c > 122)
535
+ return false; // not a-z
536
+ }
537
+ return true;
538
+ }
539
+ /**
540
+ * Category 3: task
541
+ * TodoWrite / TaskCreate / TaskUpdate tool calls.
542
+ */
543
+ function extractTask(input) {
544
+ const TASK_TOOLS = new Set(["TodoWrite", "TaskCreate", "TaskUpdate"]);
545
+ if (!TASK_TOOLS.has(input.tool_name))
546
+ return [];
547
+ // Store tool name as type so create vs update can be reliably distinguished
548
+ const type = input.tool_name === "TaskUpdate" ? "task_update"
549
+ : input.tool_name === "TaskCreate" ? "task_create"
550
+ : "task"; // TodoWrite fallback
551
+ return [{
552
+ type,
553
+ category: "task",
554
+ data: safeString(JSON.stringify(input.tool_input)),
555
+ priority: 1,
556
+ }];
557
+ }
558
+ /**
559
+ * Category 15: plan
560
+ * Tracks the full plan mode lifecycle:
561
+ * - EnterPlanMode → plan_enter
562
+ * - Write/Edit to ~/.claude/plans/ → plan_file_write
563
+ * - ExitPlanMode → plan_exit (with allowedPrompts)
564
+ * - ExitPlanMode tool_response → plan_approved / plan_rejected
565
+ *
566
+ * Note: Shift+Tab and /plan command do NOT fire PostToolUse hooks
567
+ * (Claude Code bug #15660). Only programmatic EnterPlanMode is tracked.
568
+ */
569
+ /**
570
+ * FNV-1a 32-bit hash → 8-char lowercase hex. Stable across runs/platforms.
571
+ * Used for plan_hash so identical plans dedupe at the platform side.
572
+ */
573
+ function fnv1a32Hex(s) {
574
+ let hash = 0x811c9dc5;
575
+ for (let i = 0; i < s.length; i++) {
576
+ hash ^= s.charCodeAt(i);
577
+ hash = Math.imul(hash, 0x01000193);
578
+ }
579
+ return (hash >>> 0).toString(16).padStart(8, "0");
580
+ }
581
+ /**
582
+ * Read the plan text from the ExitPlanMode envelope. SDK carries it on
583
+ * the OUTPUT (ExitPlanModeOutput @ :2222), but the PRD body cites input.
584
+ * Try both so we are spec-flexible.
585
+ */
586
+ function extractExitPlanText(input) {
587
+ const inputPlan = input.tool_input["plan"];
588
+ if (typeof inputPlan === "string" && inputPlan.length > 0)
589
+ return inputPlan;
590
+ const resp = input.tool_response;
591
+ if (typeof resp === "string" && resp.length > 0) {
592
+ try {
593
+ const parsed = JSON.parse(resp);
594
+ if (parsed && typeof parsed === "object" && typeof parsed.plan === "string") {
595
+ return parsed.plan;
596
+ }
597
+ }
598
+ catch { /* fall through */ }
599
+ }
600
+ return null;
601
+ }
602
+ function extractPlan(input) {
603
+ if (input.tool_name === "EnterPlanMode") {
604
+ return [{
605
+ type: "plan_enter",
606
+ category: "plan",
607
+ data: "entered plan mode",
608
+ priority: 2,
609
+ }];
610
+ }
611
+ if (input.tool_name === "ExitPlanMode") {
612
+ const events = [];
613
+ // Plan exit event with allowedPrompts detail
614
+ const prompts = input.tool_input["allowedPrompts"];
615
+ let detail = Array.isArray(prompts) && prompts.length > 0
616
+ ? `exited plan mode (allowed: ${safeStringAny(prompts.map((p) => {
617
+ if (typeof p === "object" && p !== null && "prompt" in p)
618
+ return String(p.prompt);
619
+ return String(p);
620
+ }).join(", "))})`
621
+ : "exited plan mode";
622
+ // §11 / PRD #6 — append plan_bytes + plan_hash so the platform can
623
+ // dedupe identical plans across sessions and JOIN plan_mode_authorized
624
+ // writes against a stable plan id. Plan source: tool_input.plan first
625
+ // (per PRD), fall back to tool_response.plan (SDK actually carries it
626
+ // there per ExitPlanModeOutput @ sdk-tools.d.ts:2222).
627
+ const plan = extractExitPlanText(input);
628
+ if (typeof plan === "string" && plan.length > 0) {
629
+ detail += ` plan_bytes:${plan.length} plan_hash:${fnv1a32Hex(plan)}`;
630
+ }
631
+ events.push({
632
+ type: "plan_exit",
633
+ category: "plan",
634
+ data: safeString(detail),
635
+ priority: 2,
636
+ });
637
+ // Detect approval/rejection from tool_response
638
+ const response = String(input.tool_response ?? "").toLowerCase();
639
+ if (response.includes("approved") || response.includes("approve")) {
640
+ events.push({
641
+ type: "plan_approved",
642
+ category: "plan",
643
+ data: "plan approved by user",
644
+ priority: 1,
645
+ });
646
+ }
647
+ else if (response.includes("rejected") || response.includes("decline") || response.includes("denied")) {
648
+ events.push({
649
+ type: "plan_rejected",
650
+ category: "plan",
651
+ data: safeString(`plan rejected: ${input.tool_response ?? ""}`),
652
+ priority: 2,
653
+ });
654
+ }
655
+ return events;
656
+ }
657
+ // Detect plan file writes (Write/Edit to ~/.claude/plans/)
658
+ if (input.tool_name === "Write" || input.tool_name === "Edit") {
659
+ const filePath = String(input.tool_input["file_path"] ?? "");
660
+ if (isPlanFilePath(filePath)) {
661
+ return [{
662
+ type: "plan_file_write",
663
+ category: "plan",
664
+ data: safeString(`plan file: ${filePath.split(/[/\\]/).pop() ?? filePath}`),
665
+ priority: 2,
666
+ }];
667
+ }
668
+ }
669
+ if (input.tool_name === "apply_patch") {
670
+ if (isToolError(input))
671
+ return [];
672
+ const patchTargets = extractApplyPatchTargets(String(input.tool_input["command"] ?? input.tool_input["patch"] ?? ""));
673
+ return patchTargets
674
+ .filter((target) => isPlanFilePath(target.path))
675
+ .map((target) => ({
676
+ type: "plan_file_write",
677
+ category: "plan",
678
+ data: safeString(`plan file: ${target.path.split(/[/\\]/).pop() ?? target.path}`),
679
+ priority: 2,
680
+ }));
681
+ }
682
+ return [];
683
+ }
684
+ /**
685
+ * Category 8: env
686
+ * Environment setup commands in Bash: venv, export, nvm, pyenv, conda, rbenv.
687
+ */
688
+ const ENV_PATTERNS = [
689
+ /\bsource\s+\S*activate\b/,
690
+ /\bexport\s+\w+=/,
691
+ /\bnvm\s+use\b/,
692
+ /\bpyenv\s+(shell|local|global)\b/,
693
+ /\bconda\s+activate\b/,
694
+ /\brbenv\s+(shell|local|global)\b/,
695
+ /\bnpm\s+install\b/,
696
+ /\bnpm\s+ci\b/,
697
+ /\bpip\s+install\b/,
698
+ /\bbun\s+install\b/,
699
+ /\byarn\s+(add|install)\b/,
700
+ /\bpnpm\s+(add|install)\b/,
701
+ /\bcargo\s+(install|add)\b/,
702
+ /\bgo\s+(install|get)\b/,
703
+ /\brustup\b/,
704
+ /\basdf\b/,
705
+ /\bvolta\b/,
706
+ /\bdeno\s+install\b/,
707
+ ];
708
+ function extractEnv(input) {
709
+ if (input.tool_name !== "Bash")
710
+ return [];
711
+ const cmd = String(input.tool_input["command"] ?? "");
712
+ const isEnvCmd = ENV_PATTERNS.some(p => p.test(cmd));
713
+ if (!isEnvCmd)
714
+ return [];
715
+ // Sanitize export commands to prevent secret leakage
716
+ const sanitized = cmd.replace(/\bexport\s+(\w+)=\S*/g, "export $1=***");
717
+ return [{
718
+ type: "env",
719
+ category: "env",
720
+ data: safeString(sanitized),
721
+ priority: 2,
722
+ }];
723
+ }
724
+ /**
725
+ * Category 10: skill
726
+ * Skill tool invocations.
727
+ */
728
+ function extractSkill(input) {
729
+ if (input.tool_name !== "Skill")
730
+ return [];
731
+ const skillName = String(input.tool_input["skill"] ?? "");
732
+ return [{
733
+ type: "skill",
734
+ category: "skill",
735
+ data: safeString(skillName),
736
+ priority: 2,
737
+ }];
738
+ }
739
+ /**
740
+ * Category 16: constraint
741
+ * Constraints discovered through error events — tool failures reveal
742
+ * platform/environment limitations worth remembering.
743
+ */
744
+ function extractConstraint(input) {
745
+ // Only fire on error events — constraints are discovered through failures
746
+ if (!input.tool_response?.includes("Error") && !input.tool_output?.isError)
747
+ return [];
748
+ const response = String(input.tool_response || "");
749
+ const patterns = [/not supported/i, /cannot/i, /does not support/i, /FAIL/i, /refused/i, /permission denied/i, /incompatible/i];
750
+ for (const pattern of patterns) {
751
+ const match = response.match(pattern);
752
+ if (match) {
753
+ // Extract context around the match
754
+ const idx = response.toLowerCase().indexOf(match[0].toLowerCase());
755
+ const context = response.slice(Math.max(0, idx - 50), Math.min(response.length, idx + 200)).trim();
756
+ return [{
757
+ type: "constraint_discovered",
758
+ category: "constraint",
759
+ data: safeString(context),
760
+ priority: 2,
761
+ }];
762
+ }
763
+ }
764
+ return [];
765
+ }
766
+ /**
767
+ * Category 9: subagent
768
+ * Agent tool calls — tracks both launch and completion.
769
+ * When tool_response is present, the agent has completed and the result
770
+ * is captured at higher priority (P2) so it survives budget trimming.
771
+ */
772
+ function extractSubagent(input) {
773
+ if (input.tool_name !== "Agent")
774
+ return [];
775
+ const prompt = safeString(String(input.tool_input["prompt"] ?? input.tool_input["description"] ?? ""));
776
+ const response = input.tool_response ? safeString(String(input.tool_response)) : "";
777
+ const isCompleted = response.length > 0;
778
+ return [{
779
+ type: isCompleted ? "subagent_completed" : "subagent_launched",
780
+ category: "subagent",
781
+ data: isCompleted
782
+ ? safeString(`[completed] ${prompt} → ${response}`)
783
+ : safeString(`[launched] ${prompt}`),
784
+ priority: isCompleted ? 2 : 3,
785
+ }];
786
+ }
787
+ /**
788
+ * Category 14: mcp
789
+ * MCP tool calls (context7, playwright, claude-mem, ctx-stats, etc.).
790
+ */
791
+ function extractMcp(input) {
792
+ const { tool_name, tool_input, tool_response } = input;
793
+ if (!tool_name.startsWith("mcp__"))
794
+ return [];
795
+ // Extract readable tool name: last segment after __
796
+ const parts = tool_name.split("__");
797
+ const toolShort = parts[parts.length - 1] || tool_name;
798
+ // Extract first string argument for context
799
+ const firstArg = Object.values(tool_input).find((v) => typeof v === "string");
800
+ const argStr = firstArg ? `: ${safeString(String(firstArg))}` : "";
801
+ // Append tool_response so ctx_search can find what the MCP returned — not
802
+ // just the call shape. Without this, bodies from external MCPs (jira tickets,
803
+ // grafana loki lines, sentry issues, context7 docs) are invisible to search.
804
+ // No truncation: matches the rule_content precedent above — SQLite TEXT is
805
+ // unbounded and large responses are the ones a cache most wants to preserve.
806
+ const responseStr = tool_response && tool_response.length > 0
807
+ ? `\nresponse: ${safeString(tool_response)}`
808
+ : "";
809
+ return [{
810
+ type: "mcp",
811
+ category: "mcp",
812
+ data: safeString(`${toolShort}${argStr}${responseStr}`),
813
+ priority: 3,
814
+ }];
815
+ }
816
+ /**
817
+ * Category 27: mcp_tool_call
818
+ * Records the raw MCP call shape (tool_name + tool_input) so analytics
819
+ * can compute usage patterns like batch concurrency.
820
+ *
821
+ * Distinct from `extractMcp` (category "mcp"), which captures the textual
822
+ * call+response for FTS5 search. This emits a structured JSON payload
823
+ * keyed by tool_name + params, capped to ~2KB to keep SQLite rows small.
824
+ *
825
+ * Priority 4 (informational) — should not crowd out high-signal events
826
+ * during FIFO eviction.
827
+ */
828
+ const MCP_PARAMS_BUDGET_BYTES = 2048;
829
+ /**
830
+ * UTF-8-aware string truncation. Returns the longest prefix of `s` whose
831
+ * UTF-8 byte length is <= `maxBytes`, never landing mid-multibyte-codepoint.
832
+ *
833
+ * Naive `s.slice(0, N)` operates on UTF-16 code units, so a 2KB cap could
834
+ * either over-shoot (multi-byte codepoints occupy fewer code units than
835
+ * bytes — e.g. a chunk of CJK / emoji-heavy JSON would silently exceed
836
+ * the byte budget) or land mid surrogate pair (corrupt JSON downstream).
837
+ */
838
+ function truncateToBytes(s, maxBytes) {
839
+ if (Buffer.byteLength(s, "utf8") <= maxBytes)
840
+ return { value: s, truncated: false };
841
+ const buf = Buffer.from(s, "utf8");
842
+ // Walk back from maxBytes until the byte starts a fresh codepoint:
843
+ // 0xxxxxxx → ASCII (start)
844
+ // 11xxxxxx → start of multi-byte
845
+ // 10xxxxxx → continuation; keep walking
846
+ let cut = maxBytes;
847
+ while (cut > 0 && (buf[cut] & 0xc0) === 0x80)
848
+ cut--;
849
+ return { value: buf.subarray(0, cut).toString("utf8"), truncated: true };
850
+ }
851
+ /**
852
+ * Keys whose VALUES must be redacted before persisting tool_input — secrets,
853
+ * tokens, credentials, signatures. Match is on the LAST path segment of the
854
+ * key (case-insensitive substring), so `headers.Authorization`, `auth.token`,
855
+ * `apiKey`, `API_KEY`, `password`, `secret`, `cookie`, `set-cookie`, `signature`,
856
+ * `private_key`, etc. all redact. False-positive risk acceptable — we'd rather
857
+ * over-redact than ship a Bearer token to SQLite.
858
+ */
859
+ const SECRET_KEY_PATTERN = /(authorization|auth_token|access_token|refresh_token|bearer|token|secret|password|passwd|pwd|api[-_]?key|apikey|cookie|set-cookie|signature|private[-_]?key|client[-_]?secret|x[-_]?api[-_]?key)/i;
860
+ const REDACTED = "[REDACTED]";
861
+ /**
862
+ * Walk an arbitrary JSON-serializable value and return a clone with values
863
+ * redacted under any key matching SECRET_KEY_PATTERN. Cycle-safe.
864
+ */
865
+ function redactSecrets(value, ancestors = new WeakSet()) {
866
+ if (value == null || typeof value !== "object")
867
+ return value;
868
+ // Path-based ancestor check: only flag TRUE cycles, not DAG / shared refs
869
+ // (e.g., a single `headers` object passed to multiple sub-requests must
870
+ // be processed at every reference site, not flagged as circular).
871
+ if (ancestors.has(value))
872
+ return "[CIRCULAR]";
873
+ ancestors.add(value);
874
+ let out;
875
+ if (Array.isArray(value)) {
876
+ out = value.map((v) => redactSecrets(v, ancestors));
877
+ }
878
+ else {
879
+ const obj = {};
880
+ for (const [k, v] of Object.entries(value)) {
881
+ if (SECRET_KEY_PATTERN.test(k)) {
882
+ obj[k] = REDACTED;
883
+ }
884
+ else {
885
+ obj[k] = redactSecrets(v, ancestors);
886
+ }
887
+ }
888
+ out = obj;
889
+ }
890
+ ancestors.delete(value); // pop ancestor — siblings can re-visit
891
+ return out;
892
+ }
893
+ function extractMcpToolCall(input) {
894
+ const { tool_name, tool_input } = input;
895
+ if (!tool_name.startsWith("mcp__"))
896
+ return [];
897
+ // Redact secrets BEFORE serialization. Any `tool_input` carrying
898
+ // `Authorization: Bearer …`, `api_key: "sk-…"`, cookies, signatures, etc.
899
+ // is masked before it touches SQLite. Over-redaction acceptable — under-
900
+ // redaction is a credential leak to SessionDB.
901
+ const redactedInput = redactSecrets(tool_input ?? {});
902
+ // Serialize the redacted shape, then truncate the *string* (not the object)
903
+ // so the diagnosable shape survives huge payloads.
904
+ let paramsStr;
905
+ try {
906
+ paramsStr = JSON.stringify(redactedInput);
907
+ }
908
+ catch {
909
+ paramsStr = "{}";
910
+ }
911
+ const { value: cappedStr, truncated } = truncateToBytes(paramsStr, MCP_PARAMS_BUDGET_BYTES);
912
+ const payload = truncated
913
+ ? `{"tool_name":${JSON.stringify(tool_name)},"params_raw":${JSON.stringify(cappedStr)},"truncated":true}`
914
+ : `{"tool_name":${JSON.stringify(tool_name)},"params":${cappedStr}}`;
915
+ const event = {
916
+ type: "mcp_tool_call",
917
+ category: "mcp_tool_call",
918
+ data: safeString(payload),
919
+ priority: 4,
920
+ };
921
+ // Retrieval cost (the OTHER half of the with/without ratio): when this MCP
922
+ // call is a `ctx_search` or `ctx_fetch_and_index` retrieval, the tool_response
923
+ // IS the kept-out content the model paid to access — record its byte length.
924
+ // Sandbox compute (ctx_execute/batch/file) is work-output, NOT retrieval, so
925
+ // it is intentionally excluded. Match by suffix char-algorithmically (host
926
+ // prefixes the name like `mcp__plugin_…__ctx_search`); NO regex.
927
+ if (isRetrievalToolName(tool_name)) {
928
+ const response = safeString(input.tool_response);
929
+ if (response.length > 0) {
930
+ event.bytes_retrieved = Buffer.byteLength(response, "utf8");
931
+ }
932
+ }
933
+ return [event];
934
+ }
935
+ /** Tool-name suffixes that denote a RETRIEVAL call (kept-out content accessed). */
936
+ const RETRIEVAL_TOOL_SUFFIXES = ["ctx_search", "ctx_fetch_and_index"];
937
+ /**
938
+ * True when `toolName` ends with one of the retrieval suffixes. Char-level
939
+ * suffix comparison via String.prototype.endsWith — no regex. MCP host names
940
+ * arrive prefixed (e.g. `mcp__plugin_context-mode_context-mode__ctx_search`),
941
+ * so an exact-name check would miss them; suffix match is host-agnostic.
942
+ */
943
+ function isRetrievalToolName(toolName) {
944
+ for (const suffix of RETRIEVAL_TOOL_SUFFIXES) {
945
+ if (toolName.endsWith(suffix))
946
+ return true;
947
+ }
948
+ return false;
949
+ }
950
+ /**
951
+ * Category 6 (tool-based): decision
952
+ * AskUserQuestion tool — tracks questions posed to user and their answers.
953
+ */
954
+ function extractDecision(input) {
955
+ if (input.tool_name !== "AskUserQuestion")
956
+ return [];
957
+ const questions = input.tool_input["questions"];
958
+ const questionText = Array.isArray(questions) && questions.length > 0
959
+ ? String(questions[0]["question"] ?? "")
960
+ : "";
961
+ // tool_response is a JSON string that echoes the full request payload
962
+ // alongside the answers map: {"questions":[...],"answers":{"<q>":"<label>"}}.
963
+ // Stringifying the raw blob leaks the echoed questions/options into the
964
+ // event row and surfaces as "Unhandled case: [object Object]" downstream.
965
+ const rawResponse = String(input.tool_response ?? "");
966
+ let answerText = "";
967
+ try {
968
+ const parsed = JSON.parse(rawResponse);
969
+ const answers = parsed?.answers;
970
+ if (answers && typeof answers === "object") {
971
+ // multiSelect: true answers arrive as string[]; single-select arrive as
972
+ // string. Normalize both into a `" | "`-joined string so neither shape
973
+ // silently produces an empty answer.
974
+ const toAnswerText = (value) => {
975
+ if (typeof value === "string")
976
+ return value;
977
+ if (Array.isArray(value)) {
978
+ return value.filter((v) => typeof v === "string").join(" | ");
979
+ }
980
+ return "";
981
+ };
982
+ const matched = questionText ? toAnswerText(answers[questionText]) : "";
983
+ if (matched) {
984
+ answerText = matched;
985
+ }
986
+ else {
987
+ const values = Object.values(answers)
988
+ .map(toAnswerText)
989
+ .filter((v) => v.length > 0);
990
+ answerText = values.join(" | ");
991
+ }
992
+ }
993
+ }
994
+ catch {
995
+ // Non-JSON tool_response — fail safe with empty answer rather than
996
+ // leaking the raw text (which would re-introduce the original bug
997
+ // for any future caller that sends a non-JSON payload).
998
+ }
999
+ const answer = safeString(answerText);
1000
+ const summary = questionText
1001
+ ? `Q: ${safeString(questionText)} → A: ${answer}`
1002
+ : `answer: ${answer}`;
1003
+ return [{
1004
+ type: "decision_question",
1005
+ category: "decision",
1006
+ data: safeString(summary),
1007
+ priority: 2,
1008
+ }];
1009
+ }
1010
+ /**
1011
+ * Category 22: agent-finding
1012
+ * When the Agent tool completes (subagent returns), capture a structured
1013
+ * summary of its findings (first 500 chars of tool_response).
1014
+ */
1015
+ function extractAgentFinding(input) {
1016
+ if (input.tool_name !== "Agent")
1017
+ return [];
1018
+ if (!input.tool_response || input.tool_response.length === 0)
1019
+ return [];
1020
+ const summary = input.tool_response.length > 500
1021
+ ? input.tool_response.slice(0, 500)
1022
+ : input.tool_response;
1023
+ return [{
1024
+ type: "agent_finding",
1025
+ category: "agent-finding",
1026
+ data: safeString(summary),
1027
+ priority: 2,
1028
+ }];
1029
+ }
1030
+ /**
1031
+ * Category 24: external-ref
1032
+ * Scan tool_input and tool_response for external URLs, GitHub issues, and PRs.
1033
+ * Deduplicates found refs and skips internal URLs (localhost, 127.0.0.1).
1034
+ */
1035
+ function extractExternalRef(input) {
1036
+ const haystack = [
1037
+ safeStringAny(input.tool_input),
1038
+ safeString(input.tool_response),
1039
+ ].join(" ");
1040
+ if (haystack.length === 0)
1041
+ return [];
1042
+ const refs = new Set();
1043
+ // URLs — skip localhost / 127.0.0.1
1044
+ const urlMatches = haystack.match(/https?:\/\/[^\s)]+/g);
1045
+ if (urlMatches) {
1046
+ for (let url of urlMatches) {
1047
+ // Strip trailing punctuation that gets captured from JSON/prose
1048
+ url = url.replace(/["'})\],;.]+$/, "");
1049
+ if (!/localhost|127\.0\.0\.1/i.test(url)) {
1050
+ refs.add(url);
1051
+ }
1052
+ }
1053
+ }
1054
+ // Full GitHub issue/PR URLs are already captured above.
1055
+ // Shorthand GitHub issue refs: #123 (only bare, not inside a URL)
1056
+ const issueMatches = haystack.match(/(?<!\w)#(\d+)/g);
1057
+ if (issueMatches) {
1058
+ for (const m of issueMatches) {
1059
+ refs.add(m);
1060
+ }
1061
+ }
1062
+ if (refs.size === 0)
1063
+ return [];
1064
+ // ctx_fetch_and_index returns a preamble like
1065
+ // "Fetched and indexed **5 sections** (47.50KB) from: <label>"
1066
+ // Parse the size to credit bytes_avoided on the event so per-session
1067
+ // honest-savings stats reflect what was kept out of the context window.
1068
+ // KB literal in the preamble is decimal (KB = 1024 bytes per the formatter).
1069
+ let bytesAvoided;
1070
+ const preambleMatch = safeString(input.tool_response).match(/Fetched and indexed[^\(]*\(([\d.]+)\s*KB\)/i);
1071
+ if (preambleMatch) {
1072
+ const kb = Number(preambleMatch[1]);
1073
+ if (Number.isFinite(kb) && kb > 0) {
1074
+ bytesAvoided = Math.round(kb * 1024);
1075
+ }
1076
+ }
1077
+ const event = {
1078
+ type: "external_ref",
1079
+ category: "external-ref",
1080
+ data: safeString(Array.from(refs).join(", ")),
1081
+ priority: 3,
1082
+ };
1083
+ if (bytesAvoided !== undefined)
1084
+ event.bytes_avoided = bytesAvoided;
1085
+ return [event];
1086
+ }
1087
+ /**
1088
+ * Category 8: env (worktree)
1089
+ * EnterWorktree + ExitWorktree tools — tracks worktree lifecycle.
1090
+ */
1091
+ function extractWorktree(input) {
1092
+ if (input.tool_name === "EnterWorktree") {
1093
+ const name = String(input.tool_input["name"] ?? "unnamed");
1094
+ return [{
1095
+ type: "worktree",
1096
+ category: "env",
1097
+ data: safeString(`entered worktree: ${name}`),
1098
+ priority: 2,
1099
+ }];
1100
+ }
1101
+ if (input.tool_name === "ExitWorktree") {
1102
+ const discard = Boolean(input.tool_input["discard_changes"]);
1103
+ return [{
1104
+ type: "worktree_exit",
1105
+ category: "env",
1106
+ data: safeString(`exited worktree (discard_changes:${discard})`),
1107
+ priority: 2,
1108
+ }];
1109
+ }
1110
+ return [];
1111
+ }
1112
+ /**
1113
+ * Algorithmic URL host extraction — no regex.
1114
+ * Skips scheme, returns everything up to the first path/query/fragment marker.
1115
+ * Port is preserved as part of the host signature.
1116
+ */
1117
+ function extractHostFromUrl(url) {
1118
+ if (typeof url !== "string" || url.length === 0)
1119
+ return null;
1120
+ const protoEnd = url.indexOf("://");
1121
+ if (protoEnd < 0)
1122
+ return null;
1123
+ const start = protoEnd + 3;
1124
+ if (start >= url.length)
1125
+ return null;
1126
+ let end = url.length;
1127
+ for (let i = start; i < url.length; i++) {
1128
+ const c = url.charCodeAt(i);
1129
+ if (c === 47 || c === 63 || c === 35) {
1130
+ end = i;
1131
+ break;
1132
+ }
1133
+ }
1134
+ const host = url.slice(start, end);
1135
+ return host.length > 0 ? host : null;
1136
+ }
1137
+ /**
1138
+ * WebFetch response metadata — captures bytes/code/durationMs and host
1139
+ * (privacy: never the full URL or query string). Redirect-loop detection
1140
+ * is temporal, not single-field — SDK has no redirect_url.
1141
+ */
1142
+ function extractWebFetchMetadata(input) {
1143
+ if (input.tool_name !== "WebFetch")
1144
+ return [];
1145
+ const resp = input.tool_response;
1146
+ if (typeof resp !== "string" || resp.length === 0)
1147
+ return [];
1148
+ let parsed;
1149
+ try {
1150
+ parsed = JSON.parse(resp);
1151
+ }
1152
+ catch {
1153
+ return [];
1154
+ }
1155
+ if (!parsed || typeof parsed !== "object")
1156
+ return [];
1157
+ const obj = parsed;
1158
+ const parts = [];
1159
+ if (typeof obj.code === "number")
1160
+ parts.push(`code:${obj.code}`);
1161
+ if (typeof obj.bytes === "number")
1162
+ parts.push(`bytes:${obj.bytes}`);
1163
+ if (typeof obj.durationMs === "number")
1164
+ parts.push(`durMs:${obj.durationMs}`);
1165
+ if (typeof obj.url === "string") {
1166
+ const host = extractHostFromUrl(obj.url);
1167
+ if (host)
1168
+ parts.push(`host:${host}`);
1169
+ }
1170
+ if (parts.length === 0)
1171
+ return [];
1172
+ return [{
1173
+ type: "webfetch_metadata",
1174
+ category: "data",
1175
+ data: safeString(parts.join(" ")),
1176
+ priority: 3,
1177
+ }];
1178
+ }
1179
+ /**
1180
+ * Bash outcome signals — captures the three fields that DO exist on
1181
+ * BashOutput (SDK :2160-2200): interrupted (boolean), stderr (length-only
1182
+ * for privacy), returnCodeInterpretation (semantic non-zero exit hint).
1183
+ * NO exit_code field exists in the SDK.
1184
+ */
1185
+ function extractBashOutcome(input) {
1186
+ if (input.tool_name !== "Bash")
1187
+ return [];
1188
+ const resp = input.tool_response;
1189
+ if (typeof resp !== "string" || resp.length === 0)
1190
+ return [];
1191
+ let parsed;
1192
+ try {
1193
+ parsed = JSON.parse(resp);
1194
+ }
1195
+ catch {
1196
+ return [];
1197
+ }
1198
+ if (!parsed || typeof parsed !== "object")
1199
+ return [];
1200
+ const obj = parsed;
1201
+ const hasSignal = typeof obj.interrupted === "boolean" ||
1202
+ typeof obj.stderr === "string" ||
1203
+ typeof obj.returnCodeInterpretation === "string";
1204
+ if (!hasSignal)
1205
+ return [];
1206
+ const parts = [];
1207
+ if (typeof obj.interrupted === "boolean") {
1208
+ parts.push(`interrupted:${obj.interrupted}`);
1209
+ }
1210
+ if (typeof obj.returnCodeInterpretation === "string") {
1211
+ parts.push(`rcInterp:${obj.returnCodeInterpretation.slice(0, 80)}`);
1212
+ }
1213
+ if (typeof obj.stderr === "string") {
1214
+ parts.push(`stderrBytes:${obj.stderr.length}`);
1215
+ }
1216
+ return [{
1217
+ type: "bash_outcome",
1218
+ category: "data",
1219
+ data: safeString(parts.join(" ")),
1220
+ priority: 3,
1221
+ }];
1222
+ }
1223
+ /**
1224
+ * FileReadOutput size metadata — branches on the text/image variant.
1225
+ * Captures sizes/line counts only; never file content. Image dimensions
1226
+ * are formatted as "WxH" when both width/height are numeric.
1227
+ */
1228
+ function extractFileReadMetadata(input) {
1229
+ if (input.tool_name !== "Read")
1230
+ return [];
1231
+ const resp = input.tool_response;
1232
+ if (typeof resp !== "string" || resp.length === 0)
1233
+ return [];
1234
+ let parsed;
1235
+ try {
1236
+ parsed = JSON.parse(resp);
1237
+ }
1238
+ catch {
1239
+ return [];
1240
+ }
1241
+ if (!parsed || typeof parsed !== "object")
1242
+ return [];
1243
+ const obj = parsed;
1244
+ const variant = obj.type;
1245
+ if (variant !== "text" && variant !== "image")
1246
+ return [];
1247
+ const parts = [`type:${variant}`];
1248
+ if (variant === "text") {
1249
+ if (typeof obj.numLines === "number")
1250
+ parts.push(`lines:${obj.numLines}`);
1251
+ if (typeof obj.totalLines === "number")
1252
+ parts.push(`totalLines:${obj.totalLines}`);
1253
+ if (typeof obj.startLine === "number")
1254
+ parts.push(`start:${obj.startLine}`);
1255
+ }
1256
+ else {
1257
+ if (typeof obj.originalSize === "number")
1258
+ parts.push(`origSize:${obj.originalSize}`);
1259
+ const dims = obj.dimensions;
1260
+ if (dims && typeof dims === "object") {
1261
+ const d = dims;
1262
+ if (typeof d.width === "number" && typeof d.height === "number") {
1263
+ parts.push(`dims:${d.width}x${d.height}`);
1264
+ }
1265
+ }
1266
+ }
1267
+ return [{
1268
+ type: "file_read_metadata",
1269
+ category: "data",
1270
+ data: safeString(parts.join(" ")),
1271
+ priority: 3,
1272
+ }];
1273
+ }
1274
+ /**
1275
+ * Per-model USD pricing now lives in the curated multi-vendor catalog
1276
+ * (src/pricing/catalog.ts), which prices each model from ITS OWN row across
1277
+ * Anthropic / OpenAI / Google / Chinese / other vendors. This kills the old
1278
+ * bug where the hardcoded Anthropic-only table here billed every non-Claude
1279
+ * model at Claude-Sonnet's `default` rate. Unknown ids now resolve to a null
1280
+ * cost (one console.warn) instead of a silently wrong Claude rate.
1281
+ *
1282
+ * resolveModelId picks the first non-empty model id from the hook candidates;
1283
+ * date-suffixed ids (e.g. claude-haiku-4-5-20251001) are reduced to a catalog
1284
+ * hit by progressively dropping trailing `-segment` suffixes (NO regex).
1285
+ */
1286
+ function resolveModelId(input, parsedResp) {
1287
+ const candidates = [
1288
+ input.tool_input?.model,
1289
+ input.model,
1290
+ parsedResp.model,
1291
+ ];
1292
+ for (const c of candidates) {
1293
+ if (typeof c === "string" && c.length > 0)
1294
+ return c;
1295
+ }
1296
+ return "";
1297
+ }
1298
+ /**
1299
+ * Drop one trailing `-<segment>` from a model id, char-algorithmically (no
1300
+ * regex): walks back to the last '-' and returns the head, or null when there
1301
+ * is no usable separator. Lets a date-suffixed id fall back to its base id
1302
+ * (claude-haiku-4-5-20251001 → claude-haiku-4-5 → … ) one segment at a time.
1303
+ */
1304
+ function dropTrailingSegment(id) {
1305
+ for (let i = id.length - 1; i > 0; i--) {
1306
+ if (id.charCodeAt(i) === 45 /* '-' */)
1307
+ return id.slice(0, i);
1308
+ }
1309
+ return null;
1310
+ }
1311
+ /**
1312
+ * Resolve a model id to one the catalog can price: try the raw id, then
1313
+ * progressively trim trailing `-segment` suffixes so a date-suffixed id still
1314
+ * prices off its base model. Probes with lookupPrice (no warn) and returns the
1315
+ * first id that hits, or "" on a full miss — so cost compute warns at most once.
1316
+ */
1317
+ function resolveCatalogId(modelId) {
1318
+ let candidate = modelId;
1319
+ while (candidate && candidate.length > 0) {
1320
+ if (catalogLookupPrice(candidate) !== null)
1321
+ return candidate;
1322
+ candidate = dropTrailingSegment(candidate);
1323
+ }
1324
+ return "";
1325
+ }
1326
+ /**
1327
+ * Cost for a turn via the catalog. Returns null on a price miss (catalog emits
1328
+ * one console.warn of the unmatched id) or when all token buckets are zero.
1329
+ */
1330
+ function computeTurnCostUsd(modelId, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens) {
1331
+ const resolved = resolveCatalogId(modelId);
1332
+ // Feed the resolved id when found; otherwise pass the raw id so the catalog's
1333
+ // single miss-warning carries the id the operator actually saw.
1334
+ return catalogComputeCostUsd(resolved || modelId, {
1335
+ input_tokens: inputTokens,
1336
+ output_tokens: outputTokens,
1337
+ cache_creation_tokens: cacheCreationTokens,
1338
+ cache_read_tokens: cacheReadTokens,
1339
+ });
1340
+ }
1341
+ /**
1342
+ * Format a cost to a compact `cost_usd` string, char-algorithmically (no
1343
+ * regex). Renders 6 decimals, drops trailing zeros, and keeps a single `.0`
1344
+ * when the fraction trims to empty (e.g. 0 → "0.0"), matching the prior
1345
+ * `.toFixed(6).replace(...)` output exactly.
1346
+ */
1347
+ function formatCostUsd(cost) {
1348
+ let s = cost.toFixed(6);
1349
+ let end = s.length;
1350
+ while (end > 0 && s.charCodeAt(end - 1) === 48 /* '0' */)
1351
+ end--;
1352
+ s = s.slice(0, end);
1353
+ if (s.length > 0 && s.charCodeAt(s.length - 1) === 46 /* '.' */)
1354
+ s += "0";
1355
+ return s;
1356
+ }
1357
+ /**
1358
+ * AgentOutput.usage capture — fires on the Task sub-agent dispatcher.
1359
+ * Captures the 7 cost/perf fields from sdk-tools.d.ts:64-75. Derives
1360
+ * cost_usd from per-model pricing (Gap #1 fix). The platform persists
1361
+ * these as typed columns post-release; the bridge emits them as
1362
+ * structured tokens in event.data for forward-compatible ingestion.
1363
+ */
1364
+ function extractAgentUsage(input) {
1365
+ if (input.tool_name !== "Task")
1366
+ return [];
1367
+ const resp = input.tool_response;
1368
+ if (typeof resp !== "string" || resp.length === 0)
1369
+ return [];
1370
+ let parsed;
1371
+ try {
1372
+ parsed = JSON.parse(resp);
1373
+ }
1374
+ catch {
1375
+ return [];
1376
+ }
1377
+ if (!parsed || typeof parsed !== "object")
1378
+ return [];
1379
+ const out = parsed;
1380
+ const usage = (out.usage && typeof out.usage === "object")
1381
+ ? out.usage
1382
+ : {};
1383
+ const hasSignal = typeof out.totalTokens === "number" ||
1384
+ typeof out.totalDurationMs === "number" ||
1385
+ typeof usage.input_tokens === "number" ||
1386
+ typeof usage.output_tokens === "number" ||
1387
+ typeof usage.service_tier === "string";
1388
+ if (!hasSignal)
1389
+ return [];
1390
+ const parts = [];
1391
+ if (typeof out.totalTokens === "number")
1392
+ parts.push(`totalTokens:${out.totalTokens}`);
1393
+ if (typeof out.totalDurationMs === "number")
1394
+ parts.push(`totalDurMs:${out.totalDurationMs}`);
1395
+ if (typeof usage.input_tokens === "number")
1396
+ parts.push(`tokens_in:${usage.input_tokens}`);
1397
+ if (typeof usage.output_tokens === "number")
1398
+ parts.push(`tokens_out:${usage.output_tokens}`);
1399
+ if (typeof usage.cache_creation_input_tokens === "number") {
1400
+ parts.push(`cache_create:${usage.cache_creation_input_tokens}`);
1401
+ }
1402
+ if (typeof usage.cache_read_input_tokens === "number") {
1403
+ parts.push(`cache_read:${usage.cache_read_input_tokens}`);
1404
+ }
1405
+ if (typeof usage.service_tier === "string") {
1406
+ parts.push(`tier:${usage.service_tier.slice(0, 32)}`);
1407
+ }
1408
+ // CUMULATIVE-USAGE GUARD (docs/handoff/cumulative-cost-bug.md): a Task
1409
+ // tool_response carries the sub-agent's usage SUMMED across its entire run —
1410
+ // every internal turn re-reads the cache, so cache_read reaches the billions.
1411
+ // Pricing that cumulative figure as a single turn produced four-figure
1412
+ // per-event costs ($3,532 with cache_read 4.7B) that poisoned every FinOps
1413
+ // aggregate. We therefore do NOT derive cost_usd here. The raw token counts
1414
+ // stay, tagged usage_scope="task_cumulative", so the platform buckets them as
1415
+ // lifetime spend; real per-turn cost comes only from per-turn signals
1416
+ // (extractTranscriptUsage + each adapter's own session).
1417
+ const modelId = resolveModelId(input, out);
1418
+ // Wave 2b — emit structured top-level fields alongside the colon-string so
1419
+ // the forward envelope (which spreads `...event`) hands the platform typed
1420
+ // columns. Each field is set only when its source signal is present, so the
1421
+ // forward payload stays minimal; cost_usd is omitted on a price miss or a
1422
+ // zero-token turn. The colon-string `data` stays for human/debug + back-compat.
1423
+ const event = {
1424
+ type: "agent_usage",
1425
+ category: "cost",
1426
+ data: safeString(parts.join(" ")),
1427
+ priority: 2,
1428
+ };
1429
+ if (modelId.length > 0)
1430
+ event.model_id = modelId;
1431
+ if (typeof usage.input_tokens === "number")
1432
+ event.input_tokens = usage.input_tokens;
1433
+ if (typeof usage.output_tokens === "number")
1434
+ event.output_tokens = usage.output_tokens;
1435
+ if (typeof usage.cache_read_input_tokens === "number") {
1436
+ event.cache_read_tokens = usage.cache_read_input_tokens;
1437
+ }
1438
+ if (typeof usage.cache_creation_input_tokens === "number") {
1439
+ event.cache_creation_tokens = usage.cache_creation_input_tokens;
1440
+ }
1441
+ event.usage_scope = "task_cumulative";
1442
+ return [event];
1443
+ }
1444
+ // ── Kimi Code (kimi-code) usage parsers ────────────────────────────────────
1445
+ // Implementation lives in src/adapters/kimi/usage.ts (per adapter ownership);
1446
+ // re-exported here so the hook-reachable session-extract bundle can import the
1447
+ // cursor-gated wire.jsonl reader without a separate per-adapter bundle. The
1448
+ // import is type-only-free (runtime callees buildAgentUsageEvent are hoisted),
1449
+ // so the extract.ts <-> usage.ts cycle is load-order safe.
1450
+ export { parseKimiUsage, extractKimiUsageSince } from "../adapters/kimi/usage.js";
1451
+ // ── Qwen Code (qwen-code) usage parsers ────────────────────────────────────
1452
+ // Implementation lives in src/adapters/qwen-code/usage.ts (per adapter
1453
+ // ownership); re-exported here so the hook-reachable session-extract bundle can
1454
+ // import the cursor-gated chats/<sessionId>.jsonl reader via the shared
1455
+ // loadExtract() loader, exactly like the kimi re-export above. Same load-order
1456
+ // safety: runtime callee buildAgentUsageEvent is hoisted within this module.
1457
+ export { parseQwenUsage, extractQwenUsageSince } from "../adapters/qwen-code/usage.js";
1458
+ /**
1459
+ * Pi (oh-my-pi) per-turn usage parser.
1460
+ *
1461
+ * Maps a Pi `turn_end` payload (`{ message: AssistantMessage }`) to the
1462
+ * `buildAgentUsageEvent` input shape, or null when there is nothing to record.
1463
+ *
1464
+ * Field provenance (adapter-matrix/pi.md @320261f + cited refs):
1465
+ * - usage: AssistantMessage.usage (ai/src/types.ts:521 -> catalog/src/types.ts:100-145)
1466
+ * - model_id: AssistantMessage.model (ai/src/types.ts:510; kept "provider/model" — builder normalizes)
1467
+ * - input: Usage.input -> input_tokens
1468
+ * - output: Usage.output -> output_tokens
1469
+ * - cacheWrite: Usage.cacheWrite -> cache_creation_tokens
1470
+ * - cacheRead: Usage.cacheRead -> cache_read_tokens
1471
+ * - native USD: Usage.cost.total -> native_cost_usd (HIGH confidence; no price-table needed)
1472
+ *
1473
+ * The event is per-turn incremental (per-response usage; anthropic.ts:1893-1901;
1474
+ * "for the turn" catalog/types.ts:103), so each turn_end maps to exactly one
1475
+ * agent_usage event with no cross-turn accumulation.
1476
+ *
1477
+ * Algorithmic + null-safe, NO regex. Accepts either the full TurnEndEvent
1478
+ * (`{ message }`) or a bare AssistantMessage (`{ usage, model }`) so callers
1479
+ * can pass `event` or `event.message` interchangeably. Returns null when the
1480
+ * payload is not an assistant message, carries no usage object, or every token
1481
+ * bucket is zero/absent (an all-zero turn emits no event — matches
1482
+ * buildAgentUsageEvent's own zero->null contract).
1483
+ */
1484
+ export function parsePiUsage(payload) {
1485
+ if (!payload || typeof payload !== "object")
1486
+ return null;
1487
+ const root = payload;
1488
+ // Unwrap TurnEndEvent.message when present; otherwise treat the payload as
1489
+ // the AssistantMessage itself.
1490
+ const maybeMessage = root.message;
1491
+ const message = maybeMessage && typeof maybeMessage === "object"
1492
+ ? maybeMessage
1493
+ : root;
1494
+ // Only assistant turns carry LLM usage. Custom/non-LLM turns are skipped.
1495
+ // Tolerate a missing role (some payloads omit it) but reject an explicit
1496
+ // non-assistant role.
1497
+ if (typeof message.role === "string" && message.role !== "assistant") {
1498
+ return null;
1499
+ }
1500
+ const usageRaw = message.usage;
1501
+ if (!usageRaw || typeof usageRaw !== "object")
1502
+ return null;
1503
+ const usage = usageRaw;
1504
+ const num = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0;
1505
+ const input_tokens = num(usage.input);
1506
+ const output_tokens = num(usage.output);
1507
+ const cache_creation_tokens = num(usage.cacheWrite);
1508
+ const cache_read_tokens = num(usage.cacheRead);
1509
+ // Zero-everything turn → null (mirrors buildAgentUsageEvent's contract; keeps
1510
+ // the DB free of no-op cost events).
1511
+ if (input_tokens <= 0 &&
1512
+ output_tokens <= 0 &&
1513
+ cache_creation_tokens <= 0 &&
1514
+ cache_read_tokens <= 0) {
1515
+ return null;
1516
+ }
1517
+ // Pi-native USD cost lives on usage.cost.total. Preserve it only when finite;
1518
+ // omit (null) on absence so the builder falls back to the pricing catalog.
1519
+ let native_cost_usd = null;
1520
+ const costRaw = usage.cost;
1521
+ if (costRaw && typeof costRaw === "object") {
1522
+ const total = costRaw.total;
1523
+ if (typeof total === "number" && Number.isFinite(total)) {
1524
+ native_cost_usd = total;
1525
+ }
1526
+ }
1527
+ const model_id = typeof message.model === "string" ? message.model : "";
1528
+ return {
1529
+ model_id,
1530
+ input_tokens,
1531
+ output_tokens,
1532
+ cache_creation_tokens,
1533
+ cache_read_tokens,
1534
+ native_cost_usd,
1535
+ };
1536
+ }
1537
+ /**
1538
+ * openclaw `model.usage` diagnostic-event capture — parseOpenclawUsage.
1539
+ *
1540
+ * openclaw exposes a first-class `model.usage` diagnostic event
1541
+ * (`DiagnosticUsageEvent`, refs/platforms/openclaw/src/infra/diagnostic-events.ts:18-47),
1542
+ * emitted once per turn and consumed via `onDiagnosticEvent(listener)`
1543
+ * (diagnostic-events.ts:1156) — the same bus the first-party diagnostics-otel /
1544
+ * diagnostics-prometheus extensions read.
1545
+ *
1546
+ * Field mapping (openclaw → AgentUsageCounts):
1547
+ * evt.usage.input → input_tokens
1548
+ * evt.usage.output → output_tokens
1549
+ * evt.usage.cacheWrite→ cache_creation_tokens (cache-creation)
1550
+ * evt.usage.cacheRead → cache_read_tokens (cache-read)
1551
+ * evt.costUsd → native_cost_usd (pre-computed via estimateUsageCost,
1552
+ * agent-runner.ts:1995 — preferred over catalog)
1553
+ * evt.model → model_id
1554
+ *
1555
+ * CRITICAL: read `evt.usage` (the PER-TURN TOTAL — "Last Turn Total"
1556
+ * agent-runner.ts:943), NEVER `evt.lastCallUsage` (the last-model-call DELTA,
1557
+ * diagnostic-events.ts:34-40). Summing both would double-count.
1558
+ *
1559
+ * Returns AgentUsageCounts (the buildAgentUsageEvent input shape) or null when
1560
+ * the event is not a usage event / carries no usage / sums to zero. Pure,
1561
+ * null-safe, algorithmic — NO regex.
1562
+ */
1563
+ export function parseOpenclawUsage(payload) {
1564
+ if (!payload || typeof payload !== "object")
1565
+ return null;
1566
+ const evt = payload;
1567
+ // Only the `model.usage` diagnostic carries token usage. Tolerate an absent
1568
+ // type (defensive against a thinner payload variant) but reject any explicit
1569
+ // non-usage diagnostic (model.failover, log.record, …).
1570
+ if (typeof evt.type === "string" && evt.type !== "model.usage") {
1571
+ return null;
1572
+ }
1573
+ // PER-TURN TOTAL lives on `usage`. `lastCallUsage` is the last-call delta and
1574
+ // must NOT be consumed — reading it instead would understate (or, when summed
1575
+ // with usage, double-count) the turn.
1576
+ const usageRaw = evt.usage;
1577
+ if (!usageRaw || typeof usageRaw !== "object")
1578
+ return null;
1579
+ const usage = usageRaw;
1580
+ const num = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0;
1581
+ const input_tokens = num(usage.input);
1582
+ const output_tokens = num(usage.output);
1583
+ const cache_creation_tokens = num(usage.cacheWrite);
1584
+ const cache_read_tokens = num(usage.cacheRead);
1585
+ // Zero-everything turn → null (mirrors buildAgentUsageEvent's contract; keeps
1586
+ // the DB free of no-op cost events).
1587
+ if (input_tokens <= 0 &&
1588
+ output_tokens <= 0 &&
1589
+ cache_creation_tokens <= 0 &&
1590
+ cache_read_tokens <= 0) {
1591
+ return null;
1592
+ }
1593
+ // openclaw ships a pre-computed USD cost at the TOP LEVEL (`evt.costUsd`, not
1594
+ // nested under usage). Preserve it only when finite; omit (null) on absence so
1595
+ // the builder falls back to the pricing catalog.
1596
+ const costRaw = evt.costUsd;
1597
+ const native_cost_usd = typeof costRaw === "number" && Number.isFinite(costRaw) ? costRaw : null;
1598
+ const model_id = typeof evt.model === "string" ? evt.model : "";
1599
+ return {
1600
+ model_id,
1601
+ input_tokens,
1602
+ output_tokens,
1603
+ cache_creation_tokens,
1604
+ cache_read_tokens,
1605
+ native_cost_usd,
1606
+ };
1607
+ }
1608
+ /**
1609
+ * opencode per-turn usage parser.
1610
+ *
1611
+ * Ground truth: context-mode-platform/docs/prds/2026-06-paid-observability/
1612
+ * adapter-matrix/opencode.md. opencode tracks usage per *assistant message*; the
1613
+ * usage-bearing payload reaches a plugin via the `message.updated` bus event,
1614
+ * whose `event.properties.info` is the full Message. The assistant token shape
1615
+ * (refs platforms/opencode .../session/message.ts) is:
1616
+ * info.tokens = { input, output, reasoning, cache: { read, write } }
1617
+ * info.cost = USD cost for this message
1618
+ * info.modelID / info.providerID (older refs may expose a single info.model)
1619
+ *
1620
+ * Field mapping (refs message.ts):
1621
+ * tokens.input -> input_tokens
1622
+ * tokens.output -> output_tokens
1623
+ * tokens.cache.read -> cache_read_tokens
1624
+ * tokens.cache.write -> cache_creation_tokens
1625
+ * modelID/providerID -> model_id (`${providerID}/${modelID}` when both present)
1626
+ * cost -> native_cost_usd
1627
+ *
1628
+ * LAST-STEP-SNAPSHOT CAVEAT (refs processor.ts:717-718): message-level
1629
+ * `.tokens` is OVERWRITTEN every step-finish, so it holds the LAST step's usage
1630
+ * — not the turn total. `.cost`, however, ACCUMULATES (`cost += usage.cost`) and
1631
+ * is the correct cumulative turn cost. We therefore pass `info.cost` through as
1632
+ * native_cost_usd so the billed $ is exact even though the token snapshot is
1633
+ * imprecise; the token columns remain best-effort (last-step) telemetry. A true
1634
+ * turn-total token sum would require summing per-step Step.Ended parts, which the
1635
+ * `message.updated` payload does not carry — out of scope for this snapshot-based
1636
+ * capture.
1637
+ *
1638
+ * Accepts either the bus event (`{ properties: { info } }`), the wrapped
1639
+ * `{ event: { properties: { info } } }`, or the bare Message (`info`) so the
1640
+ * caller can hand us whatever the SDK surfaces. NO regex — pure algorithmic,
1641
+ * null-safe traversal. Returns null when the payload is not an assistant
1642
+ * message, carries no tokens object, or every token bucket is zero/absent
1643
+ * (mirrors buildAgentUsageEvent's zero->null contract).
1644
+ */
1645
+ export function parseOpencodeUsage(payload) {
1646
+ if (!payload || typeof payload !== "object")
1647
+ return null;
1648
+ const root = payload;
1649
+ // Unwrap, most-specific first: { event: { properties: { info } } } →
1650
+ // { properties: { info } } → bare message. Each hop is guarded so a missing
1651
+ // layer simply falls through to treating the current object as the message.
1652
+ const eventLayer = root.event && typeof root.event === "object"
1653
+ ? root.event
1654
+ : root;
1655
+ const propsLayer = eventLayer.properties && typeof eventLayer.properties === "object"
1656
+ ? eventLayer.properties
1657
+ : eventLayer;
1658
+ const message = propsLayer.info && typeof propsLayer.info === "object"
1659
+ ? propsLayer.info
1660
+ : root;
1661
+ // Only assistant messages carry token usage. Tolerate a missing role but
1662
+ // reject an explicit non-assistant one.
1663
+ if (typeof message.role === "string" && message.role !== "assistant") {
1664
+ return null;
1665
+ }
1666
+ const tokensRaw = message.tokens;
1667
+ if (!tokensRaw || typeof tokensRaw !== "object")
1668
+ return null;
1669
+ const tokens = tokensRaw;
1670
+ const num = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0;
1671
+ const cacheRaw = tokens.cache;
1672
+ const cache = cacheRaw && typeof cacheRaw === "object"
1673
+ ? cacheRaw
1674
+ : {};
1675
+ const input_tokens = num(tokens.input);
1676
+ const output_tokens = num(tokens.output);
1677
+ const cache_read_tokens = num(cache.read);
1678
+ const cache_creation_tokens = num(cache.write);
1679
+ // Zero-everything turn → null (keeps the DB free of no-op cost events).
1680
+ if (input_tokens <= 0 &&
1681
+ output_tokens <= 0 &&
1682
+ cache_creation_tokens <= 0 &&
1683
+ cache_read_tokens <= 0) {
1684
+ return null;
1685
+ }
1686
+ // Native cumulative USD cost (preferred — exact, immune to the last-step
1687
+ // token-snapshot imprecision). Omit (null) on absence so the builder falls
1688
+ // back to the pricing catalog over the last-step token columns.
1689
+ const costRaw = message.cost;
1690
+ const native_cost_usd = typeof costRaw === "number" && Number.isFinite(costRaw) ? costRaw : null;
1691
+ // Billed model id. Prefer the `${providerID}/${modelID}` pair (how opencode
1692
+ // itself addresses the model); fall back to a bare modelID, then a single
1693
+ // `model` string (older refs shape). Empty when none present.
1694
+ const modelID = typeof message.modelID === "string" ? message.modelID : "";
1695
+ const providerID = typeof message.providerID === "string" ? message.providerID : "";
1696
+ let model_id = "";
1697
+ if (modelID.length > 0) {
1698
+ model_id = providerID.length > 0 ? `${providerID}/${modelID}` : modelID;
1699
+ }
1700
+ else if (typeof message.model === "string") {
1701
+ model_id = message.model;
1702
+ }
1703
+ return {
1704
+ model_id,
1705
+ input_tokens,
1706
+ output_tokens,
1707
+ cache_creation_tokens,
1708
+ cache_read_tokens,
1709
+ native_cost_usd,
1710
+ };
1711
+ }
1712
+ /**
1713
+ * Build a structured `agent_usage` event from summed per-model token counts.
1714
+ * Emits the colon-string `data` (human/debug + back-compat) AND the structured
1715
+ * top-level fields the forward envelope spreads to the platform. cost_usd via
1716
+ * the pricing catalog — omitted on a price miss. Returns null when every token
1717
+ * bucket is zero/absent (so an all-zero model emits no event).
1718
+ */
1719
+ export function buildAgentUsageEvent(counts) {
1720
+ const { model_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, native_cost_usd } = counts;
1721
+ if (input_tokens <= 0 && output_tokens <= 0 && cache_creation_tokens <= 0 && cache_read_tokens <= 0) {
1722
+ return null;
1723
+ }
1724
+ const parts = [`tokens_in:${input_tokens}`, `tokens_out:${output_tokens}`];
1725
+ if (cache_creation_tokens > 0)
1726
+ parts.push(`cache_create:${cache_creation_tokens}`);
1727
+ if (cache_read_tokens > 0)
1728
+ parts.push(`cache_read:${cache_read_tokens}`);
1729
+ const cost = (typeof native_cost_usd === "number" && Number.isFinite(native_cost_usd))
1730
+ ? native_cost_usd
1731
+ : computeTurnCostUsd(model_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens);
1732
+ if (cost !== null)
1733
+ parts.push(`cost_usd:${formatCostUsd(cost)}`);
1734
+ const event = {
1735
+ type: "agent_usage",
1736
+ category: "cost",
1737
+ data: safeString(parts.join(" ")),
1738
+ priority: 2,
1739
+ };
1740
+ if (model_id.length > 0)
1741
+ event.model_id = model_id;
1742
+ event.input_tokens = input_tokens;
1743
+ event.output_tokens = output_tokens;
1744
+ if (cache_read_tokens > 0)
1745
+ event.cache_read_tokens = cache_read_tokens;
1746
+ if (cache_creation_tokens > 0)
1747
+ event.cache_creation_tokens = cache_creation_tokens;
1748
+ if (cost !== null)
1749
+ event.cost_usd = cost;
1750
+ return event;
1751
+ }
1752
+ /**
1753
+ * gemini-cli AfterModel usage capture — parse ONE AfterModel hook payload into
1754
+ * a builder `agent_usage` event (or null). Pure, null-safe, struct-only — NO regex.
1755
+ *
1756
+ * Refs (docs/prds/2026-06-paid-observability/adapter-matrix/gemini-cli.md):
1757
+ * - AfterModel fires per model call inside the gemini-cli stream loop
1758
+ * (geminiChat.ts:1213); the hook input carries `llm_request` + `llm_response`
1759
+ * (hooks/types.ts:692-695).
1760
+ * - `llm_response.usageMetadata` exposes promptTokenCount / candidatesTokenCount
1761
+ * / totalTokenCount (hookTranslator.ts:60-64).
1762
+ * - model_id = `response.modelVersion || req.model` (loggingContentGenerator.ts:405,553).
1763
+ *
1764
+ * Mapping → builder shape:
1765
+ * promptTokenCount → input_tokens
1766
+ * candidatesTokenCount → output_tokens
1767
+ * thoughtsTokenCount → ADDED into output_tokens (Gemini bills reasoning as output)
1768
+ * cachedContentTokenCount → cache_read_tokens (when present)
1769
+ * model_id → response.modelVersion || llm_request.model
1770
+ *
1771
+ * CAVEAT — the DECOUPLED AfterModel payload (hookTranslator.ts:60-64) forwards
1772
+ * only prompt/candidates/total and DROPS cachedContentTokenCount +
1773
+ * thoughtsTokenCount. We map those two defensively WHEN PRESENT (richer payload
1774
+ * variant / future fix / OTel-fed input) but never depend on them — the common
1775
+ * case is input+output only. For full cached/thoughts fidelity the OTel
1776
+ * `api_response` exporter or the chat-recording JSON is the source of record.
1777
+ *
1778
+ * MULTI-CALL TURNS — one user turn that triggers tool calls spans MULTIPLE
1779
+ * model calls, each AfterModel cumulative within itself. This fn emits ONE
1780
+ * priced event PER AfterModel call (each call is one billed round-trip).
1781
+ * Per-userPromptId summation into a single per-turn total is DEFERRED — emitting
1782
+ * per-call never double-counts, since each call's usageMetadata is the
1783
+ * authoritative total for that call.
1784
+ */
1785
+ export function parseGeminiUsage(afterModelPayload) {
1786
+ if (!afterModelPayload || typeof afterModelPayload !== "object")
1787
+ return null;
1788
+ const payload = afterModelPayload;
1789
+ const resp = payload.llm_response;
1790
+ if (!resp || typeof resp !== "object")
1791
+ return null;
1792
+ const response = resp;
1793
+ const um = response.usageMetadata;
1794
+ if (!um || typeof um !== "object")
1795
+ return null;
1796
+ const usage = um;
1797
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
1798
+ const input = num(usage.promptTokenCount);
1799
+ const candidates = num(usage.candidatesTokenCount);
1800
+ const thoughts = num(usage.thoughtsTokenCount);
1801
+ const cached = num(usage.cachedContentTokenCount);
1802
+ // Gemini bills reasoning (thoughts) as output tokens — fold into output.
1803
+ const output = candidates + thoughts;
1804
+ // model_id = response.modelVersion (server-confirmed) || llm_request.model.
1805
+ const req = payload.llm_request;
1806
+ const reqModel = req && typeof req === "object" && typeof req.model === "string"
1807
+ ? req.model
1808
+ : "";
1809
+ const modelVersion = typeof response.modelVersion === "string" ? response.modelVersion : "";
1810
+ const modelId = modelVersion.length > 0 ? modelVersion : reqModel;
1811
+ // gemini exposes no native cost — cost_usd is derived from the pricing catalog
1812
+ // inside buildAgentUsageEvent (native_cost_usd omitted). All-zero ⇒ null.
1813
+ return buildAgentUsageEvent({
1814
+ model_id: modelId,
1815
+ input_tokens: input,
1816
+ output_tokens: output,
1817
+ cache_creation_tokens: 0,
1818
+ cache_read_tokens: cached,
1819
+ });
1820
+ }
1821
+ /**
1822
+ * claude-code MAIN-turn usage capture — the dominant-spend path the Task
1823
+ * subagent capture (extractAgentUsage) misses. Parses the session transcript
1824
+ * JSONL char-algorithmically (NO regex): each `type:"assistant"` line carries
1825
+ * `message.usage` + `message.model`, and usage is a per-turn DELTA, so summing
1826
+ * the assistant turns per model = the exact billed total. `isSidechain:true`
1827
+ * lines are Task-subagent sidechains written to a SEPARATE transcript (refs:
1828
+ * sessionStorage.ts:1042) — excluding them keeps the main-turn sum from
1829
+ * double-counting the separate Task-subagent capture. Emits one structured
1830
+ * `agent_usage` event per distinct model.
1831
+ */
1832
+ export function extractTranscriptUsage(transcript) {
1833
+ if (typeof transcript !== "string" || transcript.length === 0)
1834
+ return [];
1835
+ const sums = new Map();
1836
+ let start = 0;
1837
+ for (let i = 0; i <= transcript.length; i++) {
1838
+ if (i !== transcript.length && transcript.charCodeAt(i) !== 10 /* \n */)
1839
+ continue;
1840
+ const line = transcript.slice(start, i).trim();
1841
+ start = i + 1;
1842
+ if (line.length === 0)
1843
+ continue;
1844
+ let obj;
1845
+ try {
1846
+ const p = JSON.parse(line);
1847
+ if (!p || typeof p !== "object")
1848
+ continue;
1849
+ obj = p;
1850
+ }
1851
+ catch {
1852
+ continue;
1853
+ }
1854
+ if (obj.type !== "assistant" || obj.isSidechain === true)
1855
+ continue;
1856
+ const msg = obj.message;
1857
+ if (!msg || typeof msg !== "object")
1858
+ continue;
1859
+ const m = msg;
1860
+ const model = typeof m.model === "string" ? m.model : "";
1861
+ if (model.length === 0)
1862
+ continue;
1863
+ const u = m.usage;
1864
+ if (!u || typeof u !== "object")
1865
+ continue;
1866
+ const usage = u;
1867
+ const cur = sums.get(model) ?? { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 };
1868
+ if (typeof usage.input_tokens === "number")
1869
+ cur.input += usage.input_tokens;
1870
+ if (typeof usage.output_tokens === "number")
1871
+ cur.output += usage.output_tokens;
1872
+ if (typeof usage.cache_creation_input_tokens === "number")
1873
+ cur.cacheCreate += usage.cache_creation_input_tokens;
1874
+ if (typeof usage.cache_read_input_tokens === "number")
1875
+ cur.cacheRead += usage.cache_read_input_tokens;
1876
+ sums.set(model, cur);
1877
+ }
1878
+ const events = [];
1879
+ for (const [model, s] of sums) {
1880
+ const ev = buildAgentUsageEvent({
1881
+ model_id: model,
1882
+ input_tokens: s.input,
1883
+ output_tokens: s.output,
1884
+ cache_creation_tokens: s.cacheCreate,
1885
+ cache_read_tokens: s.cacheRead,
1886
+ });
1887
+ if (ev)
1888
+ events.push(ev);
1889
+ }
1890
+ return events;
1891
+ }
1892
+ /**
1893
+ * Cursor-aware variant of extractTranscriptUsage for the Stop hook.
1894
+ *
1895
+ * The transcript grows every turn and the forward loop forwards ALL passed
1896
+ * events unconditionally, so re-running extractTranscriptUsage on the whole
1897
+ * transcript each Stop would double-count every prior turn. This walks only
1898
+ * the turns NEW since the last Stop, keyed by a per-session high-water cursor
1899
+ * (the `uuid` of the last assistant turn seen).
1900
+ *
1901
+ * - sinceUuid null/empty → process ALL non-sidechain assistant turns.
1902
+ * - sinceUuid found → process only turns AFTER it (exclusive).
1903
+ * - sinceUuid set but NOT found (transcript compaction dropped it) → process
1904
+ * ONLY THE LAST non-sidechain assistant turn. Bounded by design: we never
1905
+ * re-emit the whole history when the cursor falls off the front.
1906
+ *
1907
+ * `cursor` returns the uuid of the LAST non-sidechain assistant turn in the
1908
+ * transcript (whether or not it carried usage), so the next Stop resumes
1909
+ * exactly past it. When the transcript has no such turn, the input cursor is
1910
+ * returned unchanged. Same char-algorithmic JSONL parse (NO regex), same
1911
+ * sidechain exclusion, same buildAgentUsageEvent emission path.
1912
+ */
1913
+ export function extractTranscriptUsageSince(transcript, sinceUuid) {
1914
+ const inputCursor = typeof sinceUuid === "string" && sinceUuid.length > 0 ? sinceUuid : null;
1915
+ if (typeof transcript !== "string" || transcript.length === 0) {
1916
+ return { events: [], cursor: inputCursor };
1917
+ }
1918
+ const turns = [];
1919
+ let start = 0;
1920
+ for (let i = 0; i <= transcript.length; i++) {
1921
+ if (i !== transcript.length && transcript.charCodeAt(i) !== 10 /* \n */)
1922
+ continue;
1923
+ const line = transcript.slice(start, i).trim();
1924
+ start = i + 1;
1925
+ if (line.length === 0)
1926
+ continue;
1927
+ let obj;
1928
+ try {
1929
+ const p = JSON.parse(line);
1930
+ if (!p || typeof p !== "object")
1931
+ continue;
1932
+ obj = p;
1933
+ }
1934
+ catch {
1935
+ continue;
1936
+ }
1937
+ if (obj.type !== "assistant" || obj.isSidechain === true)
1938
+ continue;
1939
+ const msg = obj.message;
1940
+ if (!msg || typeof msg !== "object")
1941
+ continue;
1942
+ const m = msg;
1943
+ const model = typeof m.model === "string" ? m.model : "";
1944
+ if (model.length === 0)
1945
+ continue;
1946
+ const uuid = typeof obj.uuid === "string" && obj.uuid.length > 0 ? obj.uuid : null;
1947
+ const u = m.usage;
1948
+ const usage = u && typeof u === "object" ? u : {};
1949
+ turns.push({
1950
+ uuid,
1951
+ model,
1952
+ input: typeof usage.input_tokens === "number" ? usage.input_tokens : 0,
1953
+ output: typeof usage.output_tokens === "number" ? usage.output_tokens : 0,
1954
+ cacheCreate: typeof usage.cache_creation_input_tokens === "number" ? usage.cache_creation_input_tokens : 0,
1955
+ cacheRead: typeof usage.cache_read_input_tokens === "number" ? usage.cache_read_input_tokens : 0,
1956
+ });
1957
+ }
1958
+ // No assistant turns at all → nothing to emit, cursor unchanged.
1959
+ if (turns.length === 0)
1960
+ return { events: [], cursor: inputCursor };
1961
+ // Cursor always advances to the last assistant turn's uuid (or stays as the
1962
+ // input cursor if that last turn has no uuid).
1963
+ const lastUuid = turns[turns.length - 1].uuid;
1964
+ const cursor = lastUuid !== null ? lastUuid : inputCursor;
1965
+ // Select the slice to process.
1966
+ let slice;
1967
+ if (inputCursor === null) {
1968
+ slice = turns; // all turns
1969
+ }
1970
+ else {
1971
+ let foundAt = -1;
1972
+ for (let i = 0; i < turns.length; i++) {
1973
+ if (turns[i].uuid === inputCursor) {
1974
+ foundAt = i;
1975
+ break;
1976
+ }
1977
+ }
1978
+ if (foundAt >= 0) {
1979
+ slice = turns.slice(foundAt + 1); // strictly after the cursor
1980
+ }
1981
+ else {
1982
+ // Compaction: cursor fell off the front. Bounded fallback — last turn only.
1983
+ slice = turns.slice(turns.length - 1);
1984
+ }
1985
+ }
1986
+ // Sum the selected turns per model and emit via the shared event builder.
1987
+ const sums = new Map();
1988
+ for (const t of slice) {
1989
+ const cur = sums.get(t.model) ?? { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 };
1990
+ cur.input += t.input;
1991
+ cur.output += t.output;
1992
+ cur.cacheCreate += t.cacheCreate;
1993
+ cur.cacheRead += t.cacheRead;
1994
+ sums.set(t.model, cur);
1995
+ }
1996
+ const events = [];
1997
+ for (const [model, s] of sums) {
1998
+ const ev = buildAgentUsageEvent({
1999
+ model_id: model,
2000
+ input_tokens: s.input,
2001
+ output_tokens: s.output,
2002
+ cache_creation_tokens: s.cacheCreate,
2003
+ cache_read_tokens: s.cacheRead,
2004
+ });
2005
+ if (ev)
2006
+ events.push(ev);
2007
+ }
2008
+ return { events, cursor };
2009
+ }
2010
+ // ── User-message extractors ────────────────────────────────────────────────
2011
+ /**
2012
+ * Category 6: decision
2013
+ * User corrections / approach selections.
2014
+ *
2015
+ * Universal-rule detector (Hybrid C, issue #535):
2016
+ * A decision message typically takes the structural shape
2017
+ * "{negation/rejection} X {separator} Y" — across every human language.
2018
+ *
2019
+ * We treat the following as the structural shape:
2020
+ * - contains a clause separator (ASCII `,` `;`, fullwidth `,` `;`,
2021
+ * Japanese ideographic `、`, Arabic `،`), AND
2022
+ * - codepoint length is in the corrective range (15..500), AND
2023
+ * - the message is not a question (no cross-script `?`), AND
2024
+ * - contains at least one alphabetic codepoint.
2025
+ *
2026
+ * The renderer prints the raw message back to the next LLM, so the gate
2027
+ * only needs to be a coarse "looks like a correction" filter — the LLM
2028
+ * handles fine-grained interpretation. No per-language keyword list.
2029
+ */
2030
+ const CLAUSE_SEPARATOR_PATTERN = /[,;,;、،]/u;
2031
+ const DECISION_MIN_CHARS = 15;
2032
+ const DECISION_MAX_CHARS = 500;
2033
+ function looksLikeDecision(trimmed) {
2034
+ if (QUESTION_MARK_PATTERN.test(trimmed))
2035
+ return false;
2036
+ if (!ALPHABETIC_PATTERN.test(trimmed))
2037
+ return false;
2038
+ if (!CLAUSE_SEPARATOR_PATTERN.test(trimmed))
2039
+ return false;
2040
+ const codepointLength = [...trimmed].length;
2041
+ return codepointLength >= DECISION_MIN_CHARS && codepointLength <= DECISION_MAX_CHARS;
2042
+ }
2043
+ function extractUserDecision(message) {
2044
+ const trimmed = message.trim();
2045
+ if (!looksLikeDecision(trimmed))
2046
+ return [];
2047
+ return [{
2048
+ type: "decision",
2049
+ category: "decision",
2050
+ data: safeString(message),
2051
+ priority: 2,
2052
+ }];
2053
+ }
2054
+ /**
2055
+ * Category 7: role
2056
+ * Persona / behavioral directive patterns.
2057
+ *
2058
+ * Universal-rule detector (Hybrid C, issue #535):
2059
+ * A persona/role statement is structurally a single non-question clause
2060
+ * of moderate length containing more than one lexical token — e.g.
2061
+ * "You are a senior engineer", "Tu es développeur",
2062
+ * "あなたは経験豊富なエンジニアです", "Sen kıdemli mühendisisin".
2063
+ *
2064
+ * We treat the following as the structural shape:
2065
+ * - codepoint length is in the persona range (12..120), AND
2066
+ * - is not a question (no cross-script `?`), AND
2067
+ * - is a single clause (no clause separator that would mark it as a
2068
+ * decision), AND
2069
+ * - carries enough lexical density: either two whitespace-separated
2070
+ * runs of letters, OR a continuous Unicode-letter run of ≥6
2071
+ * codepoints (a fallback for scripts without word spaces — Japanese,
2072
+ * Chinese, Thai).
2073
+ *
2074
+ * The renderer prints the raw message back to the next LLM verbatim,
2075
+ * so the gate only needs a coarse "looks like a persona statement"
2076
+ * filter — no per-language keyword list.
2077
+ */
2078
+ // Lower bound accommodates information-dense scripts (Chinese, Japanese,
2079
+ // Korean) where a complete persona sentence may use as few as 8 codepoints
2080
+ // — e.g. "你是高级工程师" — while still excluding bare single-token noise.
2081
+ const ROLE_MIN_CHARS = 8;
2082
+ const ROLE_MAX_CHARS = 120;
2083
+ const TWO_LEXICAL_TOKENS_PATTERN = /\p{L}+\s+\p{L}+/u;
2084
+ const CONTINUOUS_LETTER_RUN_PATTERN = /\p{L}{6,}/u;
2085
+ // Issue #856 — persona / standing-directive cue gate.
2086
+ //
2087
+ // The structural test below ("two lexical tokens OR a 6-codepoint letter run,
2088
+ // 8..120 chars, no '?', no clause separator") is intentionally coarse and
2089
+ // matches ANY short declarative sentence. That let casual conversational
2090
+ // acknowledgements ("that's fine for now", "go with the second option") freeze
2091
+ // as a priority-3 `role`, which the Pi adapter then re-injected as a standing
2092
+ // behavioral_directive every turn → do-nothing loop.
2093
+ //
2094
+ // A genuine role/behavioral prompt always LEADS with a persona declaration
2095
+ // ("You are X", "Tu es X", "あなたは…", "你是…") or a standing-directive verb
2096
+ // ("always respond…", "act as…"). Casual phrases never do, so we require that
2097
+ // cue as a NECESSARY condition. This preserves legitimate role persistence
2098
+ // (issue #535 multilingual corpus) while killing the casual-phrase loop.
2099
+ //
2100
+ // ALGORITHMIC ONLY — pure lowercase + prefix membership, no regex (project
2101
+ // hard rule). Multilingual openers are matched by `startsWith` on the
2102
+ // normalized first clause; leading conversational filler tokens are stripped
2103
+ // by array operations before the check.
2104
+ const ROLE_FILLER_TOKENS = new Set([
2105
+ "ok", "okay", "sure", "yeah", "yep", "yup", "alright", "fine",
2106
+ "well", "so", "hmm", "right", "please",
2107
+ ]);
2108
+ // Second-person persona openers across the supported-language corpus
2109
+ // (issue #535 multilingual role test set) plus common English persona framings.
2110
+ const ROLE_PERSONA_PREFIXES = [
2111
+ "you are", "you're", "your role", "you will be", "you act", "you will act",
2112
+ "act as", "act like", "behave as", "behave like", "imagine you", "pretend you",
2113
+ "assume the role", "take the role", "play the role", "respond as",
2114
+ "tu es", "tu est", "vous etes", "vous êtes", // French
2115
+ "sen ", "siz ", // Turkish (Sen kıdemli…)
2116
+ "eres ", "tú eres", "usted es", // Spanish (Eres…)
2117
+ "ты ", "вы ", // Russian (Ты опытный…)
2118
+ "あなたは", "君は", "お前は", "あなたが", // Japanese (あなたは…)
2119
+ "你是", "您是", // Chinese (你是…)
2120
+ "तुम ", "आप ", "तू ", // Hindi (तुम…)
2121
+ "أنت ", "انت ", "أنتَ ", // Arabic (أنت…)
2122
+ ];
2123
+ // Standing-directive verb openers — imperative behavioral rules that should
2124
+ // persist ("always respond in TypeScript", "never use emojis").
2125
+ const ROLE_DIRECTIVE_PREFIXES = [
2126
+ "always ", "never ", "respond ", "reply ", "answer ", "speak ",
2127
+ "write ", "prefer ", "format ", "output ", "communicate ", "use only ",
2128
+ ];
2129
+ function hasRoleCue(firstClause) {
2130
+ const lower = firstClause.toLowerCase().trim();
2131
+ if (!lower)
2132
+ return false;
2133
+ // Strip leading conversational filler tokens via array ops (no regex).
2134
+ const tokens = lower.split(" ").filter((t) => t.length > 0);
2135
+ while (tokens.length > 0 && ROLE_FILLER_TOKENS.has(tokens[0])) {
2136
+ tokens.shift();
2137
+ }
2138
+ const normalized = tokens.join(" ");
2139
+ if (!normalized)
2140
+ return false;
2141
+ for (const prefix of ROLE_PERSONA_PREFIXES) {
2142
+ if (normalized.startsWith(prefix))
2143
+ return true;
2144
+ }
2145
+ for (const prefix of ROLE_DIRECTIVE_PREFIXES) {
2146
+ if (normalized.startsWith(prefix))
2147
+ return true;
2148
+ }
2149
+ return false;
2150
+ }
2151
+ function looksLikeRole(trimmed) {
2152
+ // Role prompts are persona-prefix shaped: the FIRST SENTENCE declares the
2153
+ // role (e.g. "You are a senior backend engineer. <long context...>").
2154
+ // Apply the structural test to the first clause only — real-world role
2155
+ // prompts often append context paragraphs that would blow the length cap
2156
+ // if we tested the whole message. First-clause shape is the load-bearing
2157
+ // signal across languages (English "You are X.", French "Tu es X.",
2158
+ // Japanese "あなたは X です。" all parse the same way under a period split).
2159
+ const firstClause = trimmed.split(/[.!\n。!]/u)[0].trim();
2160
+ if (QUESTION_MARK_PATTERN.test(firstClause))
2161
+ return false;
2162
+ if (CLAUSE_SEPARATOR_PATTERN.test(firstClause))
2163
+ return false;
2164
+ if (!ALPHABETIC_PATTERN.test(firstClause))
2165
+ return false;
2166
+ const codepointLength = [...firstClause].length;
2167
+ if (codepointLength < ROLE_MIN_CHARS || codepointLength > ROLE_MAX_CHARS)
2168
+ return false;
2169
+ // Issue #856 — require a persona / standing-directive cue so casual
2170
+ // conversational acknowledgements do not freeze as a role directive.
2171
+ if (!hasRoleCue(firstClause))
2172
+ return false;
2173
+ return (TWO_LEXICAL_TOKENS_PATTERN.test(firstClause) ||
2174
+ CONTINUOUS_LETTER_RUN_PATTERN.test(firstClause));
2175
+ }
2176
+ function extractRole(message) {
2177
+ const trimmed = message.trim();
2178
+ if (!looksLikeRole(trimmed))
2179
+ return [];
2180
+ return [{
2181
+ type: "role",
2182
+ category: "role",
2183
+ data: safeString(message),
2184
+ priority: 3,
2185
+ }];
2186
+ }
2187
+ /**
2188
+ * Category 13: intent
2189
+ * Session mode classification from user messages.
2190
+ *
2191
+ * Universal-rule detector (Hybrid C, issue #535):
2192
+ * investigate — message contains a question mark from any script:
2193
+ * ASCII `?` U+003F, fullwidth `?` U+FF1F, Arabic `؟` U+061F,
2194
+ * Spanish opening `¿` U+00BF.
2195
+ * (Greek `;` U+037E and Armenian `՞` U+055E are excluded —
2196
+ * Greek shares its codepoint with ASCII semicolon, which
2197
+ * would produce false positives across the corpus.)
2198
+ *
2199
+ * Structural / Unicode-aware — no per-language keyword list.
2200
+ */
2201
+ const QUESTION_MARK_PATTERN = /[??؟¿]/u;
2202
+ /**
2203
+ * "Imperative tone" structural heuristic for implement intent:
2204
+ * - trimmed length < IMPERATIVE_MAX_CHARS codepoints (short directive,
2205
+ * not a discursive paragraph)
2206
+ * - contains no question mark from any script
2207
+ * - contains at least one alphabetic codepoint (filters pure punctuation noise)
2208
+ *
2209
+ * `[...str]` walks Unicode codepoints so CJK / Indic scripts are measured
2210
+ * fairly against the budget rather than penalised by UTF-16 unit count.
2211
+ */
2212
+ const ALPHABETIC_PATTERN = /\p{L}/u;
2213
+ const IMPERATIVE_MAX_CHARS = 60;
2214
+ function isImperativeTone(trimmed) {
2215
+ if (QUESTION_MARK_PATTERN.test(trimmed))
2216
+ return false;
2217
+ if (!ALPHABETIC_PATTERN.test(trimmed))
2218
+ return false;
2219
+ const codepointLength = [...trimmed].length;
2220
+ return codepointLength > 0 && codepointLength < IMPERATIVE_MAX_CHARS;
2221
+ }
2222
+ function extractIntent(message) {
2223
+ const trimmed = message.trim();
2224
+ if (!trimmed)
2225
+ return [];
2226
+ let mode;
2227
+ if (QUESTION_MARK_PATTERN.test(trimmed)) {
2228
+ mode = "investigate";
2229
+ }
2230
+ else if (isImperativeTone(trimmed)) {
2231
+ mode = "implement";
2232
+ }
2233
+ if (!mode)
2234
+ return [];
2235
+ return [{
2236
+ type: "intent",
2237
+ category: "intent",
2238
+ data: safeString(mode),
2239
+ priority: 4,
2240
+ }];
2241
+ }
2242
+ /**
2243
+ * Category: session goal (objective).
2244
+ *
2245
+ * Captures the user's stated objective so it survives compaction and resume —
2246
+ * unlike `intent`, which stores only the coarse mode (investigate/implement)
2247
+ * and discards the goal text. Triggered by the `/goal <text>` command or an
2248
+ * explicit `goal:` / `objective:` marker, so the FULL goal text is preserved
2249
+ * (priority 4 = critical in the DB eviction contract) and restored at the top
2250
+ * of the resume snapshot.
2251
+ * Without this, a `/goal` directive is lost across compaction/resume.
2252
+ */
2253
+ const GOAL_DIRECTIVE_PATTERN = /^(?:\/goal\s+|(?:goal|objective)\s*:\s*)(.+)$/is;
2254
+ function extractGoal(message) {
2255
+ const trimmed = message.trim();
2256
+ if (!trimmed)
2257
+ return [];
2258
+ const match = trimmed.match(GOAL_DIRECTIVE_PATTERN);
2259
+ if (!match)
2260
+ return [];
2261
+ const goalText = match[1].trim();
2262
+ if (!goalText)
2263
+ return [];
2264
+ return [{
2265
+ type: "goal",
2266
+ category: "goal",
2267
+ data: safeString(goalText),
2268
+ priority: 4,
2269
+ }];
2270
+ }
2271
+ /**
2272
+ * Category 25: blocked-on
2273
+ * Detect when work is blocked on something, or when a blocker is resolved.
2274
+ *
2275
+ * Universal-rule detector (Hybrid C, issue #535):
2276
+ * Programming-domain error markers are script-agnostic — they are
2277
+ * emitted by tooling regardless of the user's spoken language. The
2278
+ * words "Error", "Exception", "Traceback" stay in their original
2279
+ * English form inside a Chinese / Arabic / Russian terminal log.
2280
+ *
2281
+ * blocker matches:
2282
+ * - the literal "Error:" / "Exception:" / "Traceback" tokens, OR
2283
+ * - a Python-style frame line ("File ", `line:col`), OR
2284
+ * - a JS / Java-style stack frame ("at <ident>(...)" with a
2285
+ * `:line:col` suffix).
2286
+ *
2287
+ * blocker_resolved matches:
2288
+ * - a Unicode check-mark glyph (✓ U+2713, ✔ U+2714, ✅ U+2705,
2289
+ * ☑ U+2611, 🎉 U+1F389), OR
2290
+ * - the structural marker "fixed: …" / "resolved: …" — these are
2291
+ * programming-domain conventions (git log, PR titles, CHANGELOG
2292
+ * entries) rather than natural-language phrases.
2293
+ */
2294
+ const BLOCKER_MARKERS_PATTERN = /(?:\bError\s*:|\bException\s*:|\bTraceback\b|\bat\s+\S+\s*\([^)]*:\d+:\d+\))/u;
2295
+ const BLOCKER_RESOLVED_CHECKMARK_PATTERN = /[✓✔✅☑🎉]/u;
2296
+ const BLOCKER_RESOLVED_MARKER_PATTERN = /^\s*(?:fixed|resolved)\s*:/iu;
2297
+ function extractBlocker(message) {
2298
+ const events = [];
2299
+ // Resolution takes precedence — if both shapes match, render the
2300
+ // happier signal so the snapshot reflects the latest state.
2301
+ const isResolved = BLOCKER_RESOLVED_CHECKMARK_PATTERN.test(message) ||
2302
+ BLOCKER_RESOLVED_MARKER_PATTERN.test(message);
2303
+ if (isResolved) {
2304
+ events.push({
2305
+ type: "blocker_resolved",
2306
+ category: "blocked-on",
2307
+ data: safeString(message),
2308
+ priority: 2,
2309
+ });
2310
+ return events;
2311
+ }
2312
+ if (BLOCKER_MARKERS_PATTERN.test(message)) {
2313
+ events.push({
2314
+ type: "blocker",
2315
+ category: "blocked-on",
2316
+ data: safeString(message),
2317
+ priority: 2,
2318
+ });
2319
+ }
2320
+ return events;
2321
+ }
2322
+ /**
2323
+ * Category 12: data
2324
+ * Large user-pasted data references (message > 1KB).
2325
+ */
2326
+ function extractData(message) {
2327
+ if (message.length <= 1024)
2328
+ return [];
2329
+ return [{
2330
+ type: "data",
2331
+ category: "data",
2332
+ data: safeString(message),
2333
+ priority: 4,
2334
+ }];
2335
+ }
2336
+ // ── Cross-event stateful extractors ───────────────────────────────────────
2337
+ /**
2338
+ * Category 23: error-resolution
2339
+ * Detects when an error is followed by a successful fix (cross-event state).
2340
+ */
2341
+ let lastError = null;
2342
+ function extractErrorResolution(input) {
2343
+ const { tool_name, tool_response } = input;
2344
+ const response = String(tool_response ?? "");
2345
+ // If this call is an error, store it and return
2346
+ if (isToolError(input)) {
2347
+ lastError = { tool: tool_name, error: response.slice(0, 200), callsSince: 0 };
2348
+ return [];
2349
+ }
2350
+ // No pending error → nothing to resolve
2351
+ if (!lastError)
2352
+ return [];
2353
+ // Increment staleness counter
2354
+ lastError.callsSince++;
2355
+ // Timeout: clear after 10 calls without resolution
2356
+ if (lastError.callsSince > 10) {
2357
+ lastError = null;
2358
+ return [];
2359
+ }
2360
+ const callSucceeded = !isToolError(input);
2361
+ if (!callSucceeded)
2362
+ return [];
2363
+ // Check if this is a resolution: same tool, or Edit/Write after a Read error
2364
+ const sameTool = tool_name === lastError.tool;
2365
+ const editAfterReadError = lastError.tool === "Read"
2366
+ && (tool_name === "Edit" || tool_name === "Write" || tool_name === "apply_patch");
2367
+ if (sameTool || editAfterReadError) {
2368
+ const event = {
2369
+ type: "error_resolved",
2370
+ category: "error-resolution",
2371
+ data: safeString(`Error in ${lastError.tool}: ${lastError.error} → Fixed`),
2372
+ priority: 2,
2373
+ };
2374
+ lastError = null;
2375
+ return [event];
2376
+ }
2377
+ return [];
2378
+ }
2379
+ /** Reset error-resolution state (for testing). */
2380
+ export function resetErrorResolutionState() {
2381
+ lastError = null;
2382
+ }
2383
+ /**
2384
+ * Category 26: iteration-loop
2385
+ * Detects when the same tool is called repeatedly with similar input (stuck loop).
2386
+ */
2387
+ const callHistory = [];
2388
+ function simpleHash(str) {
2389
+ return `${str.length}:${str.slice(0, 20)}`;
2390
+ }
2391
+ function extractIterationLoop(input) {
2392
+ const { tool_name, tool_input } = input;
2393
+ const inputHash = simpleHash(JSON.stringify(tool_input).slice(0, 200));
2394
+ callHistory.push({ tool: tool_name, inputHash });
2395
+ // Keep history bounded
2396
+ if (callHistory.length > 50) {
2397
+ callHistory.splice(0, callHistory.length - 50);
2398
+ }
2399
+ // Check last N entries for repeated pattern (minimum 3)
2400
+ if (callHistory.length < 3)
2401
+ return [];
2402
+ let count = 0;
2403
+ for (let i = callHistory.length - 1; i >= 0; i--) {
2404
+ if (callHistory[i].tool === tool_name && callHistory[i].inputHash === inputHash) {
2405
+ count++;
2406
+ }
2407
+ else {
2408
+ break;
2409
+ }
2410
+ }
2411
+ if (count >= 3) {
2412
+ // Reset the matching tail to avoid duplicate emissions
2413
+ callHistory.splice(callHistory.length - count);
2414
+ return [{
2415
+ type: "retry_detected",
2416
+ category: "iteration-loop",
2417
+ data: safeString(`${tool_name} called ${count} times with similar input`),
2418
+ priority: 2,
2419
+ }];
2420
+ }
2421
+ return [];
2422
+ }
2423
+ /** Reset iteration-loop state (for testing). */
2424
+ export function resetIterationLoopState() {
2425
+ callHistory.length = 0;
2426
+ }
2427
+ // ── Public API ─────────────────────────────────────────────────────────────
2428
+ /**
2429
+ * Map platform-native tool names (Qwen Code, Gemini CLI, OpenCode, etc.) to the
2430
+ * canonical Claude Code names this extractor branches on. Without this, Qwen's
2431
+ * `run_shell_command` events would silently produce zero git/cwd/env extractions.
2432
+ *
2433
+ * Evidence: refs/platforms/qwen-code/packages/core/src/tools/tool-names.ts
2434
+ */
2435
+ const TOOL_NAME_NORMALIZE = {
2436
+ // Qwen Code / Gemini CLI native names
2437
+ run_shell_command: "Bash",
2438
+ read_file: "Read",
2439
+ read_many_files: "Read",
2440
+ grep_search: "Grep",
2441
+ search_file_content: "Grep",
2442
+ web_fetch: "WebFetch",
2443
+ write_file: "Write",
2444
+ edit: "Edit",
2445
+ glob: "Glob",
2446
+ todo_write: "TodoWrite",
2447
+ ask_user_question: "AskUserQuestion",
2448
+ list_directory: "LS",
2449
+ save_memory: "Memory",
2450
+ skill: "Skill",
2451
+ exit_plan_mode: "ExitPlanMode",
2452
+ agent: "Agent",
2453
+ // OpenCode native names
2454
+ bash: "Bash",
2455
+ view: "Read",
2456
+ grep: "Grep",
2457
+ fetch: "WebFetch",
2458
+ // Codex CLI
2459
+ shell: "Bash",
2460
+ shell_command: "Bash",
2461
+ exec_command: "Bash",
2462
+ "container.exec": "Bash",
2463
+ local_shell: "Bash",
2464
+ grep_files: "Grep",
2465
+ // Antigravity CLI (`agy`) native names. Keep in sync with the two other agy
2466
+ // maps: hooks/antigravity-cli/payload.mjs (normalizeAgyToolName) and
2467
+ // hooks/core/routing.mjs (TOOL_ALIASES).
2468
+ run_command: "Bash",
2469
+ view_file: "Read",
2470
+ read_url_content: "WebFetch",
2471
+ list_dir: "LS",
2472
+ search_web: "WebSearch",
2473
+ };
2474
+ function normalizeHookInput(input) {
2475
+ const normalized = TOOL_NAME_NORMALIZE[input.tool_name];
2476
+ if (!normalized || normalized === input.tool_name)
2477
+ return input;
2478
+ return { ...input, tool_name: normalized };
2479
+ }
2480
+ /**
2481
+ * Extract session events from a PostToolUse hook input.
2482
+ *
2483
+ * Accepts the raw hook JSON shape (snake_case keys) as received from stdin.
2484
+ * Returns an array of zero or more SessionEvents. Never throws.
2485
+ */
2486
+ export function extractEvents(rawInput) {
2487
+ try {
2488
+ const input = normalizeHookInput(rawInput);
2489
+ const events = [];
2490
+ // File + Rule (handles Read/Edit/Write)
2491
+ events.push(...extractFileAndRule(input));
2492
+ // Bash-based extractors (may overlap on the same command)
2493
+ events.push(...extractCwd(input));
2494
+ events.push(...extractError(input));
2495
+ events.push(...extractGit(input));
2496
+ events.push(...extractEnv(input));
2497
+ // Tool-specific extractors
2498
+ events.push(...extractTask(input));
2499
+ events.push(...extractPlan(input));
2500
+ events.push(...extractSkill(input));
2501
+ events.push(...extractSubagent(input));
2502
+ events.push(...extractMcp(input));
2503
+ events.push(...extractMcpToolCall(input));
2504
+ events.push(...extractDecision(input));
2505
+ events.push(...extractConstraint(input));
2506
+ events.push(...extractWorktree(input));
2507
+ events.push(...extractWebFetchMetadata(input));
2508
+ events.push(...extractBashOutcome(input));
2509
+ events.push(...extractFileReadMetadata(input));
2510
+ events.push(...extractAgentUsage(input));
2511
+ events.push(...extractAgentFinding(input));
2512
+ events.push(...extractExternalRef(input));
2513
+ // Cross-event stateful extractors
2514
+ events.push(...extractErrorResolution(input));
2515
+ events.push(...extractIterationLoop(input));
2516
+ return events;
2517
+ }
2518
+ catch {
2519
+ // Graceful degradation: if extraction fails, session continues normally
2520
+ return [];
2521
+ }
2522
+ }
2523
+ /**
2524
+ * Extract session events from a UserPromptSubmit hook input (user message text).
2525
+ *
2526
+ * Handles: decision, role, intent, data categories.
2527
+ * Returns an array of zero or more SessionEvents. Never throws.
2528
+ */
2529
+ export function extractUserEvents(message) {
2530
+ try {
2531
+ const events = [];
2532
+ events.push(...extractUserPlan(message));
2533
+ events.push(...extractUserDecision(message));
2534
+ events.push(...extractRole(message));
2535
+ events.push(...extractIntent(message));
2536
+ events.push(...extractGoal(message));
2537
+ events.push(...extractBlocker(message));
2538
+ events.push(...extractData(message));
2539
+ return events;
2540
+ }
2541
+ catch {
2542
+ return [];
2543
+ }
2544
+ }
2545
+ /**
2546
+ * Issue #4 (new PRD) — SessionStart settings + MCP servers snapshot.
2547
+ *
2548
+ * Emits ONE session_settings_snapshot event when ≥1 setting is available
2549
+ * on the SessionStart input. The data field carries key:value tokens
2550
+ * (mcp_count, mcp_servers, model, permission_mode) so the platform can
2551
+ * compute MCP integration counts and primary-model adoption per org.
2552
+ * mcp_servers list is truncated to first 8 names.
2553
+ */
2554
+ export function extractSessionSettings(input) {
2555
+ if (!input || typeof input !== "object")
2556
+ return [];
2557
+ const obj = input;
2558
+ const parts = [];
2559
+ const mcpServers = obj.mcp_servers;
2560
+ let mcpKeys = null;
2561
+ if (mcpServers && typeof mcpServers === "object" && !Array.isArray(mcpServers)) {
2562
+ mcpKeys = Object.keys(mcpServers);
2563
+ parts.push(`mcp_count:${mcpKeys.length}`);
2564
+ if (mcpKeys.length > 0) {
2565
+ parts.push(`mcp_servers:${mcpKeys.slice(0, 8).join(",")}`);
2566
+ }
2567
+ }
2568
+ if (typeof obj.model === "string") {
2569
+ parts.push(`model:${obj.model.slice(0, 64)}`);
2570
+ }
2571
+ if (typeof obj.permission_mode === "string") {
2572
+ parts.push(`permission_mode:${obj.permission_mode.slice(0, 32)}`);
2573
+ }
2574
+ if (parts.length === 0)
2575
+ return [];
2576
+ return [{
2577
+ type: "session_settings_snapshot",
2578
+ category: "env",
2579
+ data: safeString(parts.join(" ")),
2580
+ priority: 2,
2581
+ }];
2582
+ }
2583
+ const PROMPT_SCRIPT_NAMES = [
2584
+ "Latin", "Cyrillic", "Arabic", "Han", "Hangul",
2585
+ "Hiragana", "Katakana", "Devanagari", "Hebrew", "Thai", "Greek",
2586
+ ];
2587
+ const EMPTY_PROMPT_FEATURES = {
2588
+ prompt_length: 0,
2589
+ prompt_word_count: 0,
2590
+ prompt_uppercase_ratio: 0,
2591
+ prompt_file_ref_count: 0,
2592
+ prompt_path_ref_count: 0,
2593
+ prompt_script_primary: null,
2594
+ prompt_script_count: 0,
2595
+ prompt_question_glyph_count: 0,
2596
+ prompt_code_block_count: 0,
2597
+ prompt_url_count: 0,
2598
+ prompt_word_tokens: [],
2599
+ };
2600
+ /**
2601
+ * Verbatim mirror of §11 Layer 1 reference implementation + Layer 3
2602
+ * token extraction. Uses Unicode property regex per the spec — the
2603
+ * "no regex" project default does NOT apply here because the spec
2604
+ * explicitly mandates `\p{Script=X}` for script-agnostic classification.
2605
+ */
2606
+ export function extractUserPromptFeatures(prompt) {
2607
+ if (typeof prompt !== "string" || prompt.length === 0) {
2608
+ return { ...EMPTY_PROMPT_FEATURES, prompt_word_tokens: [] };
2609
+ }
2610
+ const letters = prompt.match(/\p{L}+/gu) ?? [];
2611
+ const upperCount = (prompt.match(/\p{Lu}/gu) ?? []).length;
2612
+ const totalLetters = letters.join("").length;
2613
+ const fences = (prompt.match(/```/g) ?? []).length;
2614
+ const scripts = {};
2615
+ for (const name of PROMPT_SCRIPT_NAMES) {
2616
+ const re = new RegExp(`\\p{Script=${name}}`, "gu");
2617
+ const n = (prompt.match(re) ?? []).length;
2618
+ if (n > 0)
2619
+ scripts[name] = n;
2620
+ }
2621
+ const primary = Object.entries(scripts).sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
2622
+ const seen = new Set();
2623
+ const tokens = [];
2624
+ for (const word of letters) {
2625
+ if (word.length < 3)
2626
+ continue;
2627
+ const lower = word.toLowerCase();
2628
+ if (seen.has(lower))
2629
+ continue;
2630
+ seen.add(lower);
2631
+ tokens.push(lower);
2632
+ }
2633
+ return {
2634
+ prompt_length: prompt.length,
2635
+ prompt_word_count: letters.length,
2636
+ prompt_uppercase_ratio: totalLetters === 0 ? 0 : upperCount / totalLetters,
2637
+ prompt_file_ref_count: (prompt.match(/(\w+\/)+\w+\.\w+/g) ?? []).length,
2638
+ prompt_path_ref_count: (prompt.match(/\.{0,2}\/[\w\/.-]+/g) ?? []).length,
2639
+ prompt_script_primary: primary,
2640
+ prompt_script_count: Object.keys(scripts).length,
2641
+ prompt_question_glyph_count: (prompt.match(/[??؟]/gu) ?? []).length,
2642
+ prompt_code_block_count: Math.floor(fences / 2),
2643
+ prompt_url_count: (prompt.match(/https?:\/\/[^\s]+/gu) ?? []).length,
2644
+ prompt_word_tokens: tokens,
2645
+ };
2646
+ }
2647
+ /**
2648
+ * UserPromptSubmit-driven `/plan` slash detector.
2649
+ *
2650
+ * Compensates for Claude Code Bug #15660: programmatic EnterPlanMode tool
2651
+ * calls fire PostToolUse, but the `/plan` slash command and Shift+Tab do
2652
+ * NOT. Shift+Tab is unrecoverable from the OSS bridge without an upstream
2653
+ * SDK change; this detector handles the slash case.
2654
+ *
2655
+ * Algorithmic (no regex): tolerate leading whitespace, require lowercase
2656
+ * "/plan", reject longer slashes like "/plans" via the next-char check.
2657
+ */
2658
+ function extractUserPlan(message) {
2659
+ if (typeof message !== "string" || message.length === 0)
2660
+ return [];
2661
+ let i = 0;
2662
+ while (i < message.length) {
2663
+ const c = message.charCodeAt(i);
2664
+ if (c !== 32 && c !== 9)
2665
+ break;
2666
+ i++;
2667
+ }
2668
+ if (i + 5 > message.length)
2669
+ return [];
2670
+ if (message.slice(i, i + 5) !== "/plan")
2671
+ return [];
2672
+ if (i + 5 < message.length) {
2673
+ const next = message.charCodeAt(i + 5);
2674
+ const isWordBoundary = next === 32 || next === 9 || next === 10 || next === 13;
2675
+ if (!isWordBoundary)
2676
+ return [];
2677
+ }
2678
+ const arg = message.slice(i + 5).trim();
2679
+ const detail = arg.length > 0
2680
+ ? `plan via /plan slash: ${arg.slice(0, 120)}`
2681
+ : "plan via /plan slash";
2682
+ return [{
2683
+ type: "plan_enter",
2684
+ category: "plan",
2685
+ data: safeString(detail),
2686
+ priority: 2,
2687
+ }];
2688
+ }