@kaitranntt/ccs 7.63.1 → 7.64.0-dev.2

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 (254) hide show
  1. package/README.md +12 -3
  2. package/dist/api/services/profile-lifecycle-service.js +4 -4
  3. package/dist/api/services/profile-lifecycle-service.js.map +1 -1
  4. package/dist/api/services/profile-types.d.ts +17 -0
  5. package/dist/api/services/profile-types.d.ts.map +1 -1
  6. package/dist/api/services/profile-writer.d.ts.map +1 -1
  7. package/dist/api/services/profile-writer.js +3 -5
  8. package/dist/api/services/profile-writer.js.map +1 -1
  9. package/dist/ccs.js +82 -15
  10. package/dist/ccs.js.map +1 -1
  11. package/dist/cliproxy/accounts/email-account-identity.d.ts +12 -0
  12. package/dist/cliproxy/accounts/email-account-identity.d.ts.map +1 -0
  13. package/dist/cliproxy/accounts/email-account-identity.js +124 -0
  14. package/dist/cliproxy/accounts/email-account-identity.js.map +1 -0
  15. package/dist/cliproxy/accounts/query.d.ts.map +1 -1
  16. package/dist/cliproxy/accounts/query.js +15 -8
  17. package/dist/cliproxy/accounts/query.js.map +1 -1
  18. package/dist/cliproxy/accounts/registry.d.ts +6 -0
  19. package/dist/cliproxy/accounts/registry.d.ts.map +1 -1
  20. package/dist/cliproxy/accounts/registry.js +136 -42
  21. package/dist/cliproxy/accounts/registry.js.map +1 -1
  22. package/dist/cliproxy/auth/token-manager.d.ts.map +1 -1
  23. package/dist/cliproxy/auth/token-manager.js +45 -11
  24. package/dist/cliproxy/auth/token-manager.js.map +1 -1
  25. package/dist/cliproxy/executor/env-resolver.d.ts +27 -0
  26. package/dist/cliproxy/executor/env-resolver.d.ts.map +1 -1
  27. package/dist/cliproxy/executor/env-resolver.js +87 -3
  28. package/dist/cliproxy/executor/env-resolver.js.map +1 -1
  29. package/dist/cliproxy/executor/index.d.ts.map +1 -1
  30. package/dist/cliproxy/executor/index.js +55 -12
  31. package/dist/cliproxy/executor/index.js.map +1 -1
  32. package/dist/cliproxy/model-catalog.d.ts +6 -0
  33. package/dist/cliproxy/model-catalog.d.ts.map +1 -1
  34. package/dist/cliproxy/model-catalog.js +38 -1
  35. package/dist/cliproxy/model-catalog.js.map +1 -1
  36. package/dist/cliproxy/proxy-config-resolver.d.ts +2 -1
  37. package/dist/cliproxy/proxy-config-resolver.d.ts.map +1 -1
  38. package/dist/cliproxy/proxy-config-resolver.js +1 -0
  39. package/dist/cliproxy/proxy-config-resolver.js.map +1 -1
  40. package/dist/cliproxy/proxy-target-resolver.d.ts +2 -0
  41. package/dist/cliproxy/proxy-target-resolver.d.ts.map +1 -1
  42. package/dist/cliproxy/proxy-target-resolver.js +3 -0
  43. package/dist/cliproxy/proxy-target-resolver.js.map +1 -1
  44. package/dist/cliproxy/quota-fetcher-codex.d.ts +0 -3
  45. package/dist/cliproxy/quota-fetcher-codex.d.ts.map +1 -1
  46. package/dist/cliproxy/quota-fetcher-codex.js +46 -17
  47. package/dist/cliproxy/quota-fetcher-codex.js.map +1 -1
  48. package/dist/cliproxy/remote-auth-fetcher.d.ts.map +1 -1
  49. package/dist/cliproxy/remote-auth-fetcher.js +89 -8
  50. package/dist/cliproxy/remote-auth-fetcher.js.map +1 -1
  51. package/dist/cliproxy/services/variant-settings.d.ts.map +1 -1
  52. package/dist/cliproxy/services/variant-settings.js +23 -10
  53. package/dist/cliproxy/services/variant-settings.js.map +1 -1
  54. package/dist/cliproxy/stats-transformer.d.ts.map +1 -1
  55. package/dist/cliproxy/stats-transformer.js +26 -3
  56. package/dist/cliproxy/stats-transformer.js.map +1 -1
  57. package/dist/cliproxy/types.d.ts +2 -0
  58. package/dist/cliproxy/types.d.ts.map +1 -1
  59. package/dist/commands/cliproxy/quota-subcommand.d.ts.map +1 -1
  60. package/dist/commands/cliproxy/quota-subcommand.js +25 -22
  61. package/dist/commands/cliproxy/quota-subcommand.js.map +1 -1
  62. package/dist/commands/cliproxy/variant-subcommand.d.ts.map +1 -1
  63. package/dist/commands/cliproxy/variant-subcommand.js +14 -6
  64. package/dist/commands/cliproxy/variant-subcommand.js.map +1 -1
  65. package/dist/commands/config-image-analysis-command.d.ts.map +1 -1
  66. package/dist/commands/config-image-analysis-command.js +87 -1
  67. package/dist/commands/config-image-analysis-command.js.map +1 -1
  68. package/dist/commands/install-command.d.ts.map +1 -1
  69. package/dist/commands/install-command.js +8 -2
  70. package/dist/commands/install-command.js.map +1 -1
  71. package/dist/config/unified-config-loader.d.ts.map +1 -1
  72. package/dist/config/unified-config-loader.js +9 -4
  73. package/dist/config/unified-config-loader.js.map +1 -1
  74. package/dist/config/unified-config-types.d.ts +4 -0
  75. package/dist/config/unified-config-types.d.ts.map +1 -1
  76. package/dist/config/unified-config-types.js +4 -2
  77. package/dist/config/unified-config-types.js.map +1 -1
  78. package/dist/copilot/copilot-executor.d.ts +13 -0
  79. package/dist/copilot/copilot-executor.d.ts.map +1 -1
  80. package/dist/copilot/copilot-executor.js +63 -4
  81. package/dist/copilot/copilot-executor.js.map +1 -1
  82. package/dist/delegation/executor/result-aggregator.d.ts +2 -1
  83. package/dist/delegation/executor/result-aggregator.d.ts.map +1 -1
  84. package/dist/delegation/executor/result-aggregator.js +21 -1
  85. package/dist/delegation/executor/result-aggregator.js.map +1 -1
  86. package/dist/delegation/executor/types.d.ts +6 -0
  87. package/dist/delegation/executor/types.d.ts.map +1 -1
  88. package/dist/delegation/headless-executor.d.ts.map +1 -1
  89. package/dist/delegation/headless-executor.js +69 -4
  90. package/dist/delegation/headless-executor.js.map +1 -1
  91. package/dist/management/checks/image-analysis-check.js +1 -1
  92. package/dist/management/checks/image-analysis-check.js.map +1 -1
  93. package/dist/management/instance-manager.d.ts +1 -1
  94. package/dist/management/instance-manager.d.ts.map +1 -1
  95. package/dist/management/instance-manager.js +10 -2
  96. package/dist/management/instance-manager.js.map +1 -1
  97. package/dist/shared/compatible-cli-contracts.d.ts +4 -0
  98. package/dist/shared/compatible-cli-contracts.d.ts.map +1 -1
  99. package/dist/targets/codex-adapter.d.ts.map +1 -1
  100. package/dist/targets/codex-adapter.js +78 -3
  101. package/dist/targets/codex-adapter.js.map +1 -1
  102. package/dist/targets/codex-detector.d.ts.map +1 -1
  103. package/dist/targets/codex-detector.js +28 -7
  104. package/dist/targets/codex-detector.js.map +1 -1
  105. package/dist/types/config.d.ts +5 -0
  106. package/dist/types/config.d.ts.map +1 -1
  107. package/dist/types/config.js.map +1 -1
  108. package/dist/ui/assets/{accounts-DkxZnPJE.js → accounts-BHEYnq6b.js} +1 -1
  109. package/dist/ui/assets/{alert-dialog-CiYMglgR.js → alert-dialog-D0EFRcfB.js} +1 -1
  110. package/dist/ui/assets/api-DhM3BYXr.js +4 -0
  111. package/dist/ui/assets/{auth-section-BMaKBRA_.js → auth-section-DVp8FQGm.js} +1 -1
  112. package/dist/ui/assets/{backups-section-DOpSADoH.js → backups-section-CRo0NZkA.js} +1 -1
  113. package/dist/ui/assets/channels-uZ_9CBqO.js +1 -0
  114. package/dist/ui/assets/checkbox-32DNqW_Q.js +1 -0
  115. package/dist/ui/assets/{claude-extension-B5RngGem.js → claude-extension-BfXlz5gV.js} +1 -1
  116. package/dist/ui/assets/cliproxy-DjNY9H-U.js +3 -0
  117. package/dist/ui/assets/{cliproxy-ai-providers-DVaaS-CT.js → cliproxy-ai-providers-5SHLMHiy.js} +5 -5
  118. package/dist/ui/assets/cliproxy-control-panel-Zax_m1AC.js +1 -0
  119. package/dist/ui/assets/codex-CRUSpjsu.js +27 -0
  120. package/dist/ui/assets/{confirm-dialog-B9vRgowr.js → confirm-dialog-DVf5ZmCZ.js} +1 -1
  121. package/dist/ui/assets/copilot-BZrihl_Z.js +3 -0
  122. package/dist/ui/assets/cursor-BP4nbEk_.js +1 -0
  123. package/dist/ui/assets/{droid-DshEfT1H.js → droid-BG92rdM2.js} +2 -2
  124. package/dist/ui/assets/globalenv-section-Cf6dKgSf.js +1 -0
  125. package/dist/ui/assets/{health-CE0VQs6K.js → health-BTy1UZs3.js} +1 -1
  126. package/dist/ui/assets/icons-CeH5899d.js +1 -0
  127. package/dist/ui/assets/index-B6SrL1O-.css +1 -0
  128. package/dist/ui/assets/index-BVeN0dIB.js +1 -0
  129. package/dist/ui/assets/index-Corv1lSo.js +69 -0
  130. package/dist/ui/assets/index-DHrTq-0n.js +1 -0
  131. package/dist/ui/assets/index-DuRYaONg.js +1 -0
  132. package/dist/ui/assets/index-N2ZSJurX.js +1 -0
  133. package/dist/ui/assets/index-wg7UtkFv.js +1 -0
  134. package/dist/ui/assets/{masked-input-B2tcbvAj.js → masked-input-DX9bedLy.js} +1 -1
  135. package/dist/ui/assets/{proxy-status-widget-BnJD49TF.js → proxy-status-widget-DVDMuZK5.js} +1 -1
  136. package/dist/ui/assets/{radix-ui-Dt3edmE5.js → radix-ui-C98W0NRG.js} +1 -1
  137. package/dist/ui/assets/{raw-json-settings-editor-panel-DnUbq1__.js → raw-json-settings-editor-panel-Dkt5E6Z_.js} +1 -1
  138. package/dist/ui/assets/{searchable-select-ULayr5K1.js → searchable-select-BP3Q1-Yn.js} +1 -1
  139. package/dist/ui/assets/separator-BLGGUlh9.js +1 -0
  140. package/dist/ui/assets/shared-G0XRyLig.js +8 -0
  141. package/dist/ui/assets/{table-E5IxHhrW.js → table-B4lRrWC-.js} +1 -1
  142. package/dist/ui/assets/tanstack-CfKik0yL.js +4 -0
  143. package/dist/ui/assets/updates--A2Sdo7N.js +1 -0
  144. package/dist/ui/index.html +5 -5
  145. package/dist/utils/claude-config-path.d.ts +2 -0
  146. package/dist/utils/claude-config-path.d.ts.map +1 -1
  147. package/dist/utils/claude-config-path.js +6 -1
  148. package/dist/utils/claude-config-path.js.map +1 -1
  149. package/dist/utils/hooks/get-image-analysis-hook-env.d.ts +3 -2
  150. package/dist/utils/hooks/get-image-analysis-hook-env.d.ts.map +1 -1
  151. package/dist/utils/hooks/get-image-analysis-hook-env.js +15 -6
  152. package/dist/utils/hooks/get-image-analysis-hook-env.js.map +1 -1
  153. package/dist/utils/hooks/image-analysis-backend-resolver.d.ts +53 -0
  154. package/dist/utils/hooks/image-analysis-backend-resolver.d.ts.map +1 -0
  155. package/dist/utils/hooks/image-analysis-backend-resolver.js +376 -0
  156. package/dist/utils/hooks/image-analysis-backend-resolver.js.map +1 -0
  157. package/dist/utils/hooks/image-analysis-runtime-status.d.ts +17 -0
  158. package/dist/utils/hooks/image-analysis-runtime-status.d.ts.map +1 -0
  159. package/dist/utils/hooks/image-analysis-runtime-status.js +132 -0
  160. package/dist/utils/hooks/image-analysis-runtime-status.js.map +1 -0
  161. package/dist/utils/hooks/image-analyzer-profile-hook-injector.d.ts +6 -5
  162. package/dist/utils/hooks/image-analyzer-profile-hook-injector.d.ts.map +1 -1
  163. package/dist/utils/hooks/image-analyzer-profile-hook-injector.js +37 -17
  164. package/dist/utils/hooks/image-analyzer-profile-hook-injector.js.map +1 -1
  165. package/dist/utils/hooks/index.d.ts +2 -0
  166. package/dist/utils/hooks/index.d.ts.map +1 -1
  167. package/dist/utils/hooks/index.js +8 -1
  168. package/dist/utils/hooks/index.js.map +1 -1
  169. package/dist/utils/websearch/claude-tool-args.d.ts +5 -0
  170. package/dist/utils/websearch/claude-tool-args.d.ts.map +1 -0
  171. package/dist/utils/websearch/claude-tool-args.js +125 -0
  172. package/dist/utils/websearch/claude-tool-args.js.map +1 -0
  173. package/dist/utils/websearch/hook-env.d.ts.map +1 -1
  174. package/dist/utils/websearch/hook-env.js +8 -0
  175. package/dist/utils/websearch/hook-env.js.map +1 -1
  176. package/dist/utils/websearch/hook-installer.d.ts +3 -2
  177. package/dist/utils/websearch/hook-installer.d.ts.map +1 -1
  178. package/dist/utils/websearch/hook-installer.js +3 -2
  179. package/dist/utils/websearch/hook-installer.js.map +1 -1
  180. package/dist/utils/websearch/index.d.ts +3 -0
  181. package/dist/utils/websearch/index.d.ts.map +1 -1
  182. package/dist/utils/websearch/index.js +23 -2
  183. package/dist/utils/websearch/index.js.map +1 -1
  184. package/dist/utils/websearch/mcp-installer.d.ts +14 -0
  185. package/dist/utils/websearch/mcp-installer.d.ts.map +1 -0
  186. package/dist/utils/websearch/mcp-installer.js +351 -0
  187. package/dist/utils/websearch/mcp-installer.js.map +1 -0
  188. package/dist/utils/websearch/profile-hook-injector.d.ts +5 -3
  189. package/dist/utils/websearch/profile-hook-injector.d.ts.map +1 -1
  190. package/dist/utils/websearch/profile-hook-injector.js +5 -3
  191. package/dist/utils/websearch/profile-hook-injector.js.map +1 -1
  192. package/dist/utils/websearch/status.d.ts.map +1 -1
  193. package/dist/utils/websearch/status.js +67 -1
  194. package/dist/utils/websearch/status.js.map +1 -1
  195. package/dist/utils/websearch/trace.d.ts +23 -0
  196. package/dist/utils/websearch/trace.d.ts.map +1 -0
  197. package/dist/utils/websearch/trace.js +206 -0
  198. package/dist/utils/websearch/trace.js.map +1 -0
  199. package/dist/utils/websearch-manager.d.ts +11 -11
  200. package/dist/utils/websearch-manager.d.ts.map +1 -1
  201. package/dist/utils/websearch-manager.js +32 -17
  202. package/dist/utils/websearch-manager.js.map +1 -1
  203. package/dist/web-server/index.d.ts.map +1 -1
  204. package/dist/web-server/index.js +9 -1
  205. package/dist/web-server/index.js.map +1 -1
  206. package/dist/web-server/routes/account-routes.d.ts.map +1 -1
  207. package/dist/web-server/routes/account-routes.js +2 -1
  208. package/dist/web-server/routes/account-routes.js.map +1 -1
  209. package/dist/web-server/routes/cliproxy-auth-routes.d.ts.map +1 -1
  210. package/dist/web-server/routes/cliproxy-auth-routes.js +1 -1
  211. package/dist/web-server/routes/cliproxy-auth-routes.js.map +1 -1
  212. package/dist/web-server/routes/cliproxy-local-proxy.d.ts +20 -0
  213. package/dist/web-server/routes/cliproxy-local-proxy.d.ts.map +1 -0
  214. package/dist/web-server/routes/cliproxy-local-proxy.js +117 -0
  215. package/dist/web-server/routes/cliproxy-local-proxy.js.map +1 -0
  216. package/dist/web-server/routes/image-analysis-routes.d.ts +3 -0
  217. package/dist/web-server/routes/image-analysis-routes.d.ts.map +1 -0
  218. package/dist/web-server/routes/image-analysis-routes.js +362 -0
  219. package/dist/web-server/routes/image-analysis-routes.js.map +1 -0
  220. package/dist/web-server/routes/index.d.ts.map +1 -1
  221. package/dist/web-server/routes/index.js +2 -0
  222. package/dist/web-server/routes/index.js.map +1 -1
  223. package/dist/web-server/routes/settings-routes.d.ts.map +1 -1
  224. package/dist/web-server/routes/settings-routes.js +67 -5
  225. package/dist/web-server/routes/settings-routes.js.map +1 -1
  226. package/dist/web-server/services/codex-dashboard-service.d.ts.map +1 -1
  227. package/dist/web-server/services/codex-dashboard-service.js +27 -8
  228. package/dist/web-server/services/codex-dashboard-service.js.map +1 -1
  229. package/lib/hooks/websearch-transformer.cjs +660 -96
  230. package/lib/mcp/ccs-websearch-server.cjs +339 -0
  231. package/package.json +3 -2
  232. package/scripts/github/normalize-ai-review-output.mjs +232 -16
  233. package/scripts/github/prepare-ai-review-scope.mjs +317 -0
  234. package/dist/ui/assets/api-DaOtMRT4.js +0 -4
  235. package/dist/ui/assets/channels-zDFV-BlC.js +0 -1
  236. package/dist/ui/assets/checkbox-Cb5AZBZL.js +0 -1
  237. package/dist/ui/assets/cliproxy-VYe0Qov1.js +0 -3
  238. package/dist/ui/assets/cliproxy-control-panel-FVIQcFti.js +0 -1
  239. package/dist/ui/assets/codex-D2yIwOs4.js +0 -27
  240. package/dist/ui/assets/copilot-HvsOp6hu.js +0 -3
  241. package/dist/ui/assets/cursor-C1XOjAWS.js +0 -1
  242. package/dist/ui/assets/globalenv-section-CmcMkb6z.js +0 -1
  243. package/dist/ui/assets/icons-EMBHZkGo.js +0 -1
  244. package/dist/ui/assets/index-6dNBcNC3.js +0 -1
  245. package/dist/ui/assets/index-BAuT6yuc.css +0 -1
  246. package/dist/ui/assets/index-CesVGA6m.js +0 -1
  247. package/dist/ui/assets/index-CmKclBR1.js +0 -1
  248. package/dist/ui/assets/index-CmtSgCxo.js +0 -1
  249. package/dist/ui/assets/index-DAtuJuGe.js +0 -69
  250. package/dist/ui/assets/separator--ZH5ZM-3.js +0 -1
  251. package/dist/ui/assets/shared-qizFb9Ye.js +0 -8
  252. package/dist/ui/assets/switch-DmDIWykO.js +0 -1
  253. package/dist/ui/assets/tanstack-B8i0evp-.js +0 -4
  254. package/dist/ui/assets/updates-2Uu4Mgtg.js +0 -1
@@ -15,6 +15,10 @@
15
15
  */
16
16
 
17
17
  const { spawnSync } = require('child_process');
18
+ const { createHash } = require('crypto');
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
18
22
 
19
23
  const isWindows = process.platform === 'win32';
20
24
  const DEFAULT_TIMEOUT_SEC = 55;
@@ -26,6 +30,13 @@ const DDG_URL = 'https://html.duckduckgo.com/html/';
26
30
  const BRAVE_URL = 'https://api.search.brave.com/res/v1/web/search';
27
31
  const USER_AGENT =
28
32
  'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
33
+ const PROVIDER_STATE_FILE = 'websearch-provider-state.json';
34
+ const SHORT_RETRY_AFTER_MAX_SEC = 3;
35
+ const TRANSIENT_RETRY_DELAY_MS = 750;
36
+ const TRANSIENT_RETRY_ATTEMPTS = 1;
37
+ const DEFAULT_RATE_LIMIT_COOLDOWN_SEC = 120;
38
+ const DEFAULT_QUOTA_COOLDOWN_SEC = 900;
39
+ const MAX_PROVIDER_COOLDOWN_SEC = 60 * 60;
29
40
 
30
41
  const SHARED_INSTRUCTIONS = `Instructions:
31
42
  1. Search the web for current, up-to-date information
@@ -64,12 +75,246 @@ function debug(message) {
64
75
  }
65
76
  }
66
77
 
67
- function shouldSkipHook() {
68
- if (process.env.CCS_WEBSEARCH_SKIP === '1') return true;
78
+ function getCcsDirPath() {
79
+ if ((process.env.CCS_DIR || '').trim()) {
80
+ return path.resolve(process.env.CCS_DIR.trim());
81
+ }
82
+
83
+ if ((process.env.CCS_HOME || '').trim()) {
84
+ return path.join(path.resolve(process.env.CCS_HOME.trim()), '.ccs');
85
+ }
86
+
87
+ const home = (process.env.HOME || process.env.USERPROFILE || '').trim();
88
+ if (home) {
89
+ return path.join(home, '.ccs');
90
+ }
91
+
92
+ return path.join(process.cwd(), '.ccs');
93
+ }
94
+
95
+ function isTraceEnabled() {
96
+ return process.env.CCS_WEBSEARCH_TRACE === '1' || process.env.CCS_DEBUG === '1';
97
+ }
98
+
99
+ function normalizeSafePrefix(inputPath) {
100
+ return `${path.resolve(inputPath)}${path.sep}`;
101
+ }
102
+
103
+ function getSafeTracePrefixes() {
104
+ return [
105
+ normalizeSafePrefix(path.join(getCcsDirPath(), 'logs')),
106
+ normalizeSafePrefix(os.tmpdir()),
107
+ normalizeSafePrefix('/var/log'),
108
+ ];
109
+ }
110
+
111
+ function getProviderStatePath() {
112
+ return path.join(getCcsDirPath(), 'cache', PROVIDER_STATE_FILE);
113
+ }
114
+
115
+ function readProviderState() {
116
+ try {
117
+ const statePath = getProviderStatePath();
118
+ if (!fs.existsSync(statePath)) {
119
+ return { cooldowns: {} };
120
+ }
121
+
122
+ const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
123
+ const cooldowns =
124
+ parsed && typeof parsed === 'object' && parsed.cooldowns && typeof parsed.cooldowns === 'object'
125
+ ? parsed.cooldowns
126
+ : {};
127
+ return { cooldowns };
128
+ } catch {
129
+ return { cooldowns: {} };
130
+ }
131
+ }
132
+
133
+ function writeProviderState(state) {
134
+ try {
135
+ const statePath = getProviderStatePath();
136
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
137
+ const tempPath = `${statePath}.${process.pid}.${Date.now()}.tmp`;
138
+ fs.writeFileSync(tempPath, JSON.stringify(state, null, 2) + '\n', 'utf8');
139
+ fs.renameSync(tempPath, statePath);
140
+ } catch {
141
+ // Best-effort only.
142
+ }
143
+ }
144
+
145
+ function sanitizeProviderState(state) {
146
+ const now = Date.now();
147
+ const nextCooldowns = {};
148
+ let changed = false;
149
+
150
+ for (const [providerId, entry] of Object.entries(state.cooldowns || {})) {
151
+ if (!entry || typeof entry !== 'object') {
152
+ changed = true;
153
+ continue;
154
+ }
155
+
156
+ const until = Number.parseInt(String(entry.until || ''), 10);
157
+ if (!Number.isFinite(until) || until <= now) {
158
+ changed = true;
159
+ continue;
160
+ }
161
+
162
+ nextCooldowns[providerId] = {
163
+ until,
164
+ reason: typeof entry.reason === 'string' ? entry.reason : 'rate_limited',
165
+ updatedAt: Number.parseInt(String(entry.updatedAt || ''), 10) || now,
166
+ sourceError: typeof entry.sourceError === 'string' ? entry.sourceError : '',
167
+ };
168
+ }
169
+
170
+ return {
171
+ state: { cooldowns: nextCooldowns },
172
+ changed,
173
+ };
174
+ }
175
+
176
+ function getProviderCooldown(providerId) {
177
+ const { state, changed } = sanitizeProviderState(readProviderState());
178
+ if (changed) {
179
+ writeProviderState(state);
180
+ }
181
+
182
+ return state.cooldowns[providerId] || null;
183
+ }
184
+
185
+ function clearProviderCooldown(providerId) {
186
+ const { state } = sanitizeProviderState(readProviderState());
187
+ if (!(providerId in state.cooldowns)) {
188
+ return;
189
+ }
190
+
191
+ delete state.cooldowns[providerId];
192
+ writeProviderState(state);
193
+ }
194
+
195
+ function applyProviderCooldown(providerId, cooldownSec, reason, sourceError) {
196
+ const clampedCooldownSec = Math.max(
197
+ 1,
198
+ Math.min(MAX_PROVIDER_COOLDOWN_SEC, Math.floor(cooldownSec))
199
+ );
200
+ const { state } = sanitizeProviderState(readProviderState());
201
+ const until = Date.now() + clampedCooldownSec * 1000;
202
+ state.cooldowns[providerId] = {
203
+ until,
204
+ reason,
205
+ updatedAt: Date.now(),
206
+ sourceError: sourceError || '',
207
+ };
208
+ writeProviderState(state);
209
+ return until;
210
+ }
211
+
212
+ function sleep(ms) {
213
+ return new Promise((resolve) => setTimeout(resolve, ms));
214
+ }
215
+
216
+ function getAllowedTraceFileOverride() {
217
+ const configured = (process.env.CCS_WEBSEARCH_TRACE_FILE || '').trim();
218
+ if (!configured) {
219
+ return null;
220
+ }
221
+
222
+ const resolved = path.resolve(configured);
223
+ if (getSafeTracePrefixes().some((prefix) => resolved.startsWith(prefix))) {
224
+ return resolved;
225
+ }
226
+
227
+ return null;
228
+ }
229
+
230
+ function getTraceFilePath() {
231
+ const fallback = path.join(getCcsDirPath(), 'logs', 'websearch-trace.jsonl');
232
+ return getAllowedTraceFileOverride() || fallback;
233
+ }
234
+
235
+ function traceWebSearchEvent(event, payload = {}) {
236
+ if (!isTraceEnabled()) {
237
+ return;
238
+ }
239
+
240
+ try {
241
+ const traceFilePath = getTraceFilePath();
242
+ fs.mkdirSync(path.dirname(traceFilePath), { recursive: true });
243
+ fs.appendFileSync(
244
+ traceFilePath,
245
+ JSON.stringify({
246
+ at: new Date().toISOString(),
247
+ event,
248
+ launchId: process.env.CCS_WEBSEARCH_TRACE_LAUNCH_ID || null,
249
+ launcher: process.env.CCS_WEBSEARCH_TRACE_LAUNCHER || null,
250
+ profileType: process.env.CCS_PROFILE_TYPE || null,
251
+ pid: process.pid,
252
+ ...payload,
253
+ }) + '\n',
254
+ 'utf8'
255
+ );
256
+ } catch {
257
+ // Best-effort only.
258
+ }
259
+ }
260
+
261
+ function readHeaderValue(headers, headerName) {
262
+ if (!headers) {
263
+ return '';
264
+ }
265
+
266
+ if (typeof headers.get === 'function') {
267
+ return headers.get(headerName) || '';
268
+ }
269
+
270
+ const direct = headers[headerName] ?? headers[String(headerName).toLowerCase()];
271
+ if (Array.isArray(direct)) {
272
+ return direct[0] || '';
273
+ }
274
+ return typeof direct === 'string' ? direct : '';
275
+ }
276
+
277
+ function parseRetryAfterSeconds(rawValue) {
278
+ const value = String(rawValue || '').trim();
279
+ if (!value) {
280
+ return null;
281
+ }
282
+
283
+ const asSeconds = Number.parseInt(value, 10);
284
+ if (Number.isFinite(asSeconds) && asSeconds > 0) {
285
+ return asSeconds;
286
+ }
287
+
288
+ const asDate = Date.parse(value);
289
+ if (Number.isFinite(asDate)) {
290
+ const deltaSec = Math.ceil((asDate - Date.now()) / 1000);
291
+ return deltaSec > 0 ? deltaSec : null;
292
+ }
293
+
294
+ return null;
295
+ }
296
+
297
+ function getQueryFingerprint(query) {
298
+ const normalizedQuery = typeof query === 'string' ? query.trim() : '';
299
+ return {
300
+ queryHash: normalizedQuery
301
+ ? createHash('sha256').update(normalizedQuery).digest('hex').slice(0, 16)
302
+ : null,
303
+ queryLength: normalizedQuery.length,
304
+ };
305
+ }
306
+
307
+ function getSkipReason() {
308
+ if (process.env.CCS_WEBSEARCH_SKIP === '1') return 'skip_flag';
69
309
  const profileType = process.env.CCS_PROFILE_TYPE;
70
- if (profileType === 'account' || profileType === 'default') return true;
71
- if (process.env.CCS_WEBSEARCH_ENABLED === '0') return true;
72
- return false;
310
+ if (profileType === 'account') return 'native_account_profile';
311
+ if (profileType === 'default') return 'native_default_profile';
312
+ if (process.env.CCS_WEBSEARCH_ENABLED === '0') return 'disabled';
313
+ return null;
314
+ }
315
+
316
+ function shouldSkipHook() {
317
+ return getSkipReason() !== null;
73
318
  }
74
319
 
75
320
  function isCliAvailable(cmd) {
@@ -183,23 +428,58 @@ function extractDuckDuckGoResults(html, count) {
183
428
  }
184
429
 
185
430
  function formatStructuredSearchResults(query, providerName, results) {
431
+ const lines = [
432
+ 'CCS local WebSearch evidence',
433
+ `Provider: ${providerName}`,
434
+ `Query: "${query}"`,
435
+ `Result count: ${results.length}`,
436
+ '',
437
+ ];
438
+
186
439
  if (!results.length) {
187
- return `No search results found for "${query}" via ${providerName}.`;
440
+ lines.push('No results found.');
441
+ return lines.join('\n');
188
442
  }
189
443
 
190
- const lines = [`Search results for "${query}" via ${providerName}:`, ''];
191
444
  for (const [index, result] of results.entries()) {
192
445
  lines.push(`${index + 1}. ${result.title}`);
193
- lines.push(` ${result.url}`);
446
+ lines.push(` URL: ${result.url}`);
194
447
  if (result.description) {
195
- lines.push(` ${result.description}`);
448
+ lines.push(` Snippet: ${result.description}`);
196
449
  }
197
450
  lines.push('');
198
451
  }
199
- lines.push('Use these results to answer the user directly.');
200
452
  return lines.join('\n');
201
453
  }
202
454
 
455
+ function buildSuccessHookOutput(query, providerName, content) {
456
+ return {
457
+ hookSpecificOutput: {
458
+ hookEventName: 'PreToolUse',
459
+ permissionDecision: 'deny',
460
+ permissionDecisionReason: `CCS already retrieved WebSearch results locally via ${providerName}. Use the provided context instead of calling native WebSearch for "${query}".`,
461
+ additionalContext: content,
462
+ },
463
+ };
464
+ }
465
+
466
+ function buildFailureHookOutput(query, errors) {
467
+ const detail = errors.map((entry) => `${entry.provider}: ${entry.error}`).join(' | ');
468
+ return {
469
+ hookSpecificOutput: {
470
+ hookEventName: 'PreToolUse',
471
+ permissionDecision: 'deny',
472
+ permissionDecisionReason: `CCS could not complete local WebSearch for "${query}". Native WebSearch is unavailable for this profile.`,
473
+ additionalContext: `CCS local WebSearch failed for "${query}". Attempted providers: ${detail}`,
474
+ },
475
+ };
476
+ }
477
+
478
+ function emitHookOutput(output) {
479
+ console.log(JSON.stringify(output));
480
+ process.exit(0);
481
+ }
482
+
203
483
  async function fetchWithTimeout(url, options, timeoutMs) {
204
484
  const controller = new AbortController();
205
485
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -239,6 +519,8 @@ async function tryBraveSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
239
519
  return {
240
520
  success: false,
241
521
  error: `Brave Search returned ${response.status}: ${body.slice(0, 160)}`,
522
+ statusCode: response.status,
523
+ retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
242
524
  };
243
525
  }
244
526
 
@@ -290,7 +572,12 @@ async function tryExaSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
290
572
 
291
573
  if (!response.ok) {
292
574
  const body = await response.text();
293
- return { success: false, error: `Exa returned ${response.status}: ${body.slice(0, 160)}` };
575
+ return {
576
+ success: false,
577
+ error: `Exa returned ${response.status}: ${body.slice(0, 160)}`,
578
+ statusCode: response.status,
579
+ retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
580
+ };
294
581
  }
295
582
 
296
583
  const body = await response.json();
@@ -342,7 +629,12 @@ async function tryTavilySearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
342
629
 
343
630
  if (!response.ok) {
344
631
  const body = await response.text();
345
- return { success: false, error: `Tavily returned ${response.status}: ${body.slice(0, 160)}` };
632
+ return {
633
+ success: false,
634
+ error: `Tavily returned ${response.status}: ${body.slice(0, 160)}`,
635
+ statusCode: response.status,
636
+ retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
637
+ };
346
638
  }
347
639
 
348
640
  const body = await response.json();
@@ -379,7 +671,12 @@ async function tryDuckDuckGoSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
379
671
  );
380
672
 
381
673
  if (!response.ok) {
382
- return { success: false, error: `DuckDuckGo returned ${response.status}` };
674
+ return {
675
+ success: false,
676
+ error: `DuckDuckGo returned ${response.status}`,
677
+ statusCode: response.status,
678
+ retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
679
+ };
383
680
  }
384
681
 
385
682
  const html = await response.text();
@@ -534,39 +831,325 @@ function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
534
831
  }
535
832
 
536
833
  function outputSuccess(query, content, providerName) {
537
- const output = {
538
- decision: 'block',
539
- reason: `WebSearch handled via ${providerName}`,
540
- hookSpecificOutput: {
541
- hookEventName: 'PreToolUse',
542
- permissionDecision: 'deny',
543
- permissionDecisionReason: `[WebSearch Result via ${providerName}]\n\nQuery: "${query}"\n\n${content}`,
544
- },
545
- };
546
-
547
- console.log(JSON.stringify(output));
548
- process.exit(2);
834
+ emitHookOutput(buildSuccessHookOutput(query, providerName, content));
549
835
  }
550
836
 
551
837
  function outputAllFailedMessage(query, errors) {
552
- const detail = errors.map((entry) => `${entry.provider}: ${entry.error}`).join(' | ');
553
- const output = {
554
- decision: 'block',
555
- reason: 'WebSearch fallback failed',
556
- hookSpecificOutput: {
557
- hookEventName: 'PreToolUse',
558
- permissionDecision: 'deny',
559
- permissionDecisionReason: `WebSearch could not be completed for "${query}". ${detail}`,
838
+ emitHookOutput(buildFailureHookOutput(query, errors));
839
+ }
840
+
841
+ function getConfiguredProviders() {
842
+ return [
843
+ {
844
+ name: 'Exa',
845
+ id: 'exa',
846
+ available: () => isProviderEnabled('exa') && Boolean(getProviderApiKey('exa')),
847
+ fn: tryExaSearch,
848
+ },
849
+ {
850
+ name: 'Tavily',
851
+ id: 'tavily',
852
+ available: () => isProviderEnabled('tavily') && Boolean(getProviderApiKey('tavily')),
853
+ fn: tryTavilySearch,
854
+ },
855
+ {
856
+ name: 'Brave Search',
857
+ id: 'brave',
858
+ available: () => isProviderEnabled('brave') && Boolean(getProviderApiKey('brave')),
859
+ fn: tryBraveSearch,
860
+ },
861
+ {
862
+ name: 'DuckDuckGo',
863
+ id: 'duckduckgo',
864
+ available: () => isProviderEnabled('duckduckgo'),
865
+ fn: tryDuckDuckGoSearch,
866
+ },
867
+ {
868
+ name: 'Gemini CLI',
869
+ id: 'gemini',
870
+ available: () => isProviderEnabled('gemini') && isCliAvailable('gemini'),
871
+ fn: tryGeminiSearch,
872
+ },
873
+ {
874
+ name: 'OpenCode',
875
+ id: 'opencode',
876
+ available: () => isProviderEnabled('opencode') && isCliAvailable('opencode'),
877
+ fn: tryOpenCodeSearch,
878
+ },
879
+ {
880
+ name: 'Grok CLI',
881
+ id: 'grok',
882
+ available: () => isProviderEnabled('grok') && isCliAvailable('grok'),
883
+ fn: tryGrokSearch,
560
884
  },
885
+ ];
886
+ }
887
+
888
+ function looksLikeQuotaExhaustion(errorMessage) {
889
+ const lower = String(errorMessage || '').toLowerCase();
890
+ return (
891
+ (lower.includes('quota') &&
892
+ (lower.includes('exceed') ||
893
+ lower.includes('exhaust') ||
894
+ lower.includes('deplet') ||
895
+ lower.includes('limit') ||
896
+ lower.includes('used up'))) ||
897
+ lower.includes('insufficient credits') ||
898
+ lower.includes('credit balance') ||
899
+ lower.includes('out of credits') ||
900
+ lower.includes('billing hard limit') ||
901
+ lower.includes('monthly usage cap')
902
+ );
903
+ }
904
+
905
+ function looksLikeTransientFailure(errorMessage) {
906
+ const lower = String(errorMessage || '').toLowerCase();
907
+ return (
908
+ lower.includes('timed out') ||
909
+ lower.includes('timeout') ||
910
+ lower.includes('temporarily unavailable') ||
911
+ lower.includes('service unavailable') ||
912
+ lower.includes('bad gateway') ||
913
+ lower.includes('gateway timeout') ||
914
+ lower.includes('socket hang up') ||
915
+ lower.includes('econnreset') ||
916
+ lower.includes('fetch failed') ||
917
+ lower.includes('network')
918
+ );
919
+ }
920
+
921
+ function classifyProviderFailure(result) {
922
+ const errorMessage = String(result.error || '');
923
+ const statusCode =
924
+ Number.isFinite(result.statusCode) && result.statusCode > 0 ? result.statusCode : null;
925
+ const retryAfterSec = Number.isFinite(result.retryAfterSec) ? result.retryAfterSec : null;
926
+
927
+ if (looksLikeQuotaExhaustion(errorMessage)) {
928
+ return {
929
+ kind: 'cooldown',
930
+ reason: 'quota_exhausted',
931
+ cooldownSec: retryAfterSec || DEFAULT_QUOTA_COOLDOWN_SEC,
932
+ retryAfterSec,
933
+ };
934
+ }
935
+
936
+ if (statusCode === 429 || /too many requests|rate limit/i.test(errorMessage)) {
937
+ if (retryAfterSec && retryAfterSec <= SHORT_RETRY_AFTER_MAX_SEC) {
938
+ return {
939
+ kind: 'retry',
940
+ delayMs: retryAfterSec * 1000,
941
+ reason: 'rate_limited_short_backoff',
942
+ retryAfterSec,
943
+ };
944
+ }
945
+
946
+ return {
947
+ kind: 'cooldown',
948
+ reason: 'rate_limited',
949
+ cooldownSec: retryAfterSec || DEFAULT_RATE_LIMIT_COOLDOWN_SEC,
950
+ retryAfterSec,
951
+ };
952
+ }
953
+
954
+ if (
955
+ (statusCode && [502, 503, 504].includes(statusCode)) ||
956
+ looksLikeTransientFailure(errorMessage)
957
+ ) {
958
+ return {
959
+ kind: 'retry',
960
+ delayMs: TRANSIENT_RETRY_DELAY_MS,
961
+ reason: 'transient_failure',
962
+ retryAfterSec,
963
+ };
964
+ }
965
+
966
+ return {
967
+ kind: 'fail',
968
+ reason: 'non_retryable',
969
+ retryAfterSec,
561
970
  };
971
+ }
562
972
 
563
- console.log(JSON.stringify(output));
564
- process.exit(2);
973
+ async function runProviderWithPolicy(provider, query, timeoutSec, fingerprint) {
974
+ for (let attempt = 0; attempt <= TRANSIENT_RETRY_ATTEMPTS; attempt += 1) {
975
+ traceWebSearchEvent('websearch_provider_attempt', {
976
+ source: 'provider',
977
+ providerId: provider.id,
978
+ providerName: provider.name,
979
+ attempt: attempt + 1,
980
+ ...fingerprint,
981
+ });
982
+
983
+ const result = await provider.fn(query, timeoutSec);
984
+ if (result.success) {
985
+ clearProviderCooldown(provider.id);
986
+ return result;
987
+ }
988
+
989
+ const policy = classifyProviderFailure(result);
990
+ if (policy.kind === 'retry' && attempt < TRANSIENT_RETRY_ATTEMPTS) {
991
+ traceWebSearchEvent('websearch_provider_retry_scheduled', {
992
+ source: 'provider',
993
+ providerId: provider.id,
994
+ providerName: provider.name,
995
+ attempt: attempt + 1,
996
+ delayMs: policy.delayMs,
997
+ reason: policy.reason,
998
+ retryAfterSec: policy.retryAfterSec,
999
+ ...fingerprint,
1000
+ });
1001
+ await sleep(policy.delayMs);
1002
+ continue;
1003
+ }
1004
+
1005
+ if (policy.kind === 'retry' && policy.reason === 'rate_limited_short_backoff') {
1006
+ const cooldownSec = policy.retryAfterSec || DEFAULT_RATE_LIMIT_COOLDOWN_SEC;
1007
+ const until = applyProviderCooldown(provider.id, cooldownSec, 'rate_limited', result.error);
1008
+ traceWebSearchEvent('websearch_provider_cooldown_applied', {
1009
+ source: 'provider',
1010
+ providerId: provider.id,
1011
+ providerName: provider.name,
1012
+ cooldownUntil: until,
1013
+ cooldownSec,
1014
+ reason: 'rate_limited',
1015
+ retryAfterSec: policy.retryAfterSec,
1016
+ afterRetryExhausted: true,
1017
+ ...fingerprint,
1018
+ });
1019
+ return {
1020
+ ...result,
1021
+ error: `${result.error} (cooldown ${cooldownSec}s)`,
1022
+ };
1023
+ }
1024
+
1025
+ if (policy.kind === 'cooldown') {
1026
+ const until = applyProviderCooldown(
1027
+ provider.id,
1028
+ policy.cooldownSec,
1029
+ policy.reason,
1030
+ result.error
1031
+ );
1032
+ traceWebSearchEvent('websearch_provider_cooldown_applied', {
1033
+ source: 'provider',
1034
+ providerId: provider.id,
1035
+ providerName: provider.name,
1036
+ cooldownUntil: until,
1037
+ cooldownSec: policy.cooldownSec,
1038
+ reason: policy.reason,
1039
+ retryAfterSec: policy.retryAfterSec,
1040
+ ...fingerprint,
1041
+ });
1042
+ return {
1043
+ ...result,
1044
+ error: `${result.error} (cooldown ${policy.cooldownSec}s)`,
1045
+ };
1046
+ }
1047
+
1048
+ return result;
1049
+ }
1050
+
1051
+ return { success: false, error: 'Provider retry policy exhausted' };
1052
+ }
1053
+
1054
+ function getActiveProviders() {
1055
+ return getConfiguredProviders().filter((provider) => !getProviderCooldown(provider.id) && provider.available());
1056
+ }
1057
+
1058
+ function getActiveProviderIds() {
1059
+ return getActiveProviders().map((provider) => provider.id);
1060
+ }
1061
+
1062
+ function hasAnyActiveProviders() {
1063
+ return getActiveProviders().length > 0;
1064
+ }
1065
+
1066
+ async function runLocalWebSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
1067
+ const fingerprint = getQueryFingerprint(query);
1068
+ const configuredProviders = getConfiguredProviders();
1069
+ const activeProviders = [];
1070
+
1071
+ for (const provider of configuredProviders) {
1072
+ const cooldown = getProviderCooldown(provider.id);
1073
+ if (cooldown) {
1074
+ traceWebSearchEvent('websearch_provider_cooldown_skip', {
1075
+ source: 'provider',
1076
+ providerId: provider.id,
1077
+ providerName: provider.name,
1078
+ cooldownUntil: cooldown.until,
1079
+ cooldownReason: cooldown.reason,
1080
+ remainingMs: Math.max(0, cooldown.until - Date.now()),
1081
+ ...fingerprint,
1082
+ });
1083
+ continue;
1084
+ }
1085
+
1086
+ if (provider.available()) {
1087
+ activeProviders.push(provider);
1088
+ }
1089
+ }
1090
+
1091
+ debug(
1092
+ `Enabled providers: ${activeProviders.map((provider) => provider.name).join(', ') || 'none'}`
1093
+ );
1094
+ traceWebSearchEvent('websearch_provider_run_started', {
1095
+ source: 'provider',
1096
+ activeProviderIds: activeProviders.map((provider) => provider.id),
1097
+ ...fingerprint,
1098
+ });
1099
+
1100
+ if (activeProviders.length === 0) {
1101
+ traceWebSearchEvent('websearch_provider_run_unavailable', {
1102
+ source: 'provider',
1103
+ activeProviderIds: [],
1104
+ ...fingerprint,
1105
+ });
1106
+ return { success: false, noActiveProviders: true, errors: [] };
1107
+ }
1108
+
1109
+ const errors = [];
1110
+ for (const provider of activeProviders) {
1111
+ debug(`Trying ${provider.name}`);
1112
+ const result = await runProviderWithPolicy(provider, query, timeoutSec, fingerprint);
1113
+ if (result.success) {
1114
+ traceWebSearchEvent('websearch_provider_success', {
1115
+ source: 'provider',
1116
+ providerId: provider.id,
1117
+ providerName: provider.name,
1118
+ ...fingerprint,
1119
+ });
1120
+ return {
1121
+ success: true,
1122
+ providerId: provider.id,
1123
+ providerName: provider.name,
1124
+ content: result.content,
1125
+ };
1126
+ }
1127
+ traceWebSearchEvent('websearch_provider_failure', {
1128
+ source: 'provider',
1129
+ providerId: provider.id,
1130
+ providerName: provider.name,
1131
+ error: result.error,
1132
+ ...fingerprint,
1133
+ });
1134
+ errors.push({ provider: provider.name, error: result.error });
1135
+ }
1136
+
1137
+ traceWebSearchEvent('websearch_provider_run_failed', {
1138
+ source: 'provider',
1139
+ errorCount: errors.length,
1140
+ activeProviderIds: activeProviders.map((provider) => provider.id),
1141
+ ...fingerprint,
1142
+ });
1143
+ return { success: false, noActiveProviders: false, errors };
565
1144
  }
566
1145
 
567
1146
  async function processHook(input) {
568
1147
  try {
569
1148
  if (shouldSkipHook()) {
1149
+ traceWebSearchEvent('websearch_hook_skipped', {
1150
+ source: 'hook',
1151
+ reason: getSkipReason(),
1152
+ });
570
1153
  process.exit(0);
571
1154
  }
572
1155
 
@@ -580,78 +1163,47 @@ async function processHook(input) {
580
1163
  process.exit(0);
581
1164
  }
582
1165
 
1166
+ traceWebSearchEvent('websearch_hook_invoked', {
1167
+ source: 'hook',
1168
+ ...getQueryFingerprint(query),
1169
+ });
1170
+
583
1171
  const timeout = Number.parseInt(
584
1172
  process.env.CCS_WEBSEARCH_TIMEOUT || `${DEFAULT_TIMEOUT_SEC}`,
585
1173
  10
586
1174
  );
587
- const providers = [
588
- {
589
- name: 'Exa',
590
- id: 'exa',
591
- available: () => isProviderEnabled('exa') && Boolean(getProviderApiKey('exa')),
592
- fn: tryExaSearch,
593
- },
594
- {
595
- name: 'Tavily',
596
- id: 'tavily',
597
- available: () => isProviderEnabled('tavily') && Boolean(getProviderApiKey('tavily')),
598
- fn: tryTavilySearch,
599
- },
600
- {
601
- name: 'Brave Search',
602
- id: 'brave',
603
- available: () => isProviderEnabled('brave') && Boolean(getProviderApiKey('brave')),
604
- fn: tryBraveSearch,
605
- },
606
- {
607
- name: 'DuckDuckGo',
608
- id: 'duckduckgo',
609
- available: () => isProviderEnabled('duckduckgo'),
610
- fn: tryDuckDuckGoSearch,
611
- },
612
- {
613
- name: 'Gemini CLI',
614
- id: 'gemini',
615
- available: () => isProviderEnabled('gemini') && isCliAvailable('gemini'),
616
- fn: tryGeminiSearch,
617
- },
618
- {
619
- name: 'OpenCode',
620
- id: 'opencode',
621
- available: () => isProviderEnabled('opencode') && isCliAvailable('opencode'),
622
- fn: tryOpenCodeSearch,
623
- },
624
- {
625
- name: 'Grok CLI',
626
- id: 'grok',
627
- available: () => isProviderEnabled('grok') && isCliAvailable('grok'),
628
- fn: tryGrokSearch,
629
- },
630
- ];
631
-
632
- const activeProviders = providers.filter((provider) => provider.available());
633
- debug(
634
- `Enabled providers: ${activeProviders.map((provider) => provider.name).join(', ') || 'none'}`
635
- );
636
-
637
- if (activeProviders.length === 0) {
1175
+ const result = await runLocalWebSearch(query, timeout);
1176
+ if (result.noActiveProviders) {
1177
+ traceWebSearchEvent('websearch_hook_no_active_providers', {
1178
+ source: 'hook',
1179
+ ...getQueryFingerprint(query),
1180
+ });
638
1181
  process.exit(0);
639
1182
  }
640
1183
 
641
- const errors = [];
642
- for (const provider of activeProviders) {
643
- debug(`Trying ${provider.name}`);
644
- const result = await provider.fn(query, timeout);
645
- if (result.success) {
646
- outputSuccess(query, result.content, provider.name);
647
- return;
648
- }
649
- errors.push({ provider: provider.name, error: result.error });
1184
+ if (result.success) {
1185
+ traceWebSearchEvent('websearch_hook_success', {
1186
+ source: 'hook',
1187
+ providerId: result.providerId,
1188
+ providerName: result.providerName,
1189
+ ...getQueryFingerprint(query),
1190
+ });
1191
+ outputSuccess(query, result.content, result.providerName);
1192
+ return;
650
1193
  }
651
1194
 
652
- outputAllFailedMessage(query, errors);
1195
+ traceWebSearchEvent('websearch_hook_failure', {
1196
+ source: 'hook',
1197
+ errorCount: result.errors.length,
1198
+ ...getQueryFingerprint(query),
1199
+ });
1200
+ outputAllFailedMessage(query, result.errors);
653
1201
  } catch (error) {
654
1202
  debug(`Hook error: ${error.message}`);
1203
+ traceWebSearchEvent('websearch_hook_error', {
1204
+ source: 'hook',
1205
+ error: error.message,
1206
+ });
655
1207
  process.exit(0);
656
1208
  }
657
1209
  }
@@ -675,8 +1227,20 @@ if (require.main === module) {
675
1227
  }
676
1228
 
677
1229
  module.exports = {
1230
+ buildFailureHookOutput,
1231
+ buildSuccessHookOutput,
678
1232
  extractDuckDuckGoResults,
679
1233
  formatStructuredSearchResults,
1234
+ getActiveProviders,
1235
+ hasAnyActiveProviders,
1236
+ runLocalWebSearch,
1237
+ shouldSkipHook,
1238
+ getActiveProviderIds,
1239
+ classifyProviderFailure,
1240
+ getQueryFingerprint,
1241
+ getSkipReason,
1242
+ parseRetryAfterSeconds,
1243
+ traceWebSearchEvent,
680
1244
  tryExaSearch,
681
1245
  tryTavilySearch,
682
1246
  tryDuckDuckGoSearch,