auramaxx 1.0.0-alpha.4

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 (363) hide show
  1. package/LICENSE +26 -0
  2. package/README.md +112 -0
  3. package/bin/aurawallet.js +121 -0
  4. package/docs/ADAPTERS.md +467 -0
  5. package/docs/API.md +2679 -0
  6. package/docs/APPS.md +198 -0
  7. package/docs/ARCHITECTURE.md +350 -0
  8. package/docs/AUTH.md +698 -0
  9. package/docs/BEST-PRACTICES.md +121 -0
  10. package/docs/CLI.md +61 -0
  11. package/docs/DEVELOPING-APPS.md +452 -0
  12. package/docs/EXTENSION.md +97 -0
  13. package/docs/JOBS.md +33 -0
  14. package/docs/MCP.md +76 -0
  15. package/docs/PROTOCOL.md +142 -0
  16. package/docs/SETUP.md +219 -0
  17. package/docs/WORKSPACE.md +672 -0
  18. package/docs/agent-auth.md +63 -0
  19. package/docs/aura-file.md +48 -0
  20. package/docs/credentials.md +53 -0
  21. package/docs/external/getting-started.md +65 -0
  22. package/docs/external/overview.md +45 -0
  23. package/docs/external/use-cases.md +48 -0
  24. package/docs/external/why-aura.md +35 -0
  25. package/docs/jobs/connect-agent.md +77 -0
  26. package/docs/jobs/migrate-from-dotenv.md +79 -0
  27. package/docs/jobs/recover-from-lockout.md +72 -0
  28. package/docs/jobs/secure-ci.md +63 -0
  29. package/docs/oauth2.md +42 -0
  30. package/docs/passkeys.md +60 -0
  31. package/docs/security.md +540 -0
  32. package/docs/specs/aura-open-protocol.md +61 -0
  33. package/docs/specs/aura-provider-plugin.md +24 -0
  34. package/docs/specs/aura-registry-model.md +31 -0
  35. package/docs/specs/fixtures/invalid-bad-key.aura +1 -0
  36. package/docs/specs/fixtures/invalid-bad-unicode-escape.aura +1 -0
  37. package/docs/specs/fixtures/invalid-duplicate-key.aura +2 -0
  38. package/docs/specs/fixtures/valid-basic.aura +4 -0
  39. package/docs/specs/fixtures/valid-provider-ref.aura +1 -0
  40. package/docs/specs/fixtures/valid-quoted-escapes.aura +2 -0
  41. package/docs/templates/RELEASE_NOTES_TEMPLATE.md +22 -0
  42. package/docs/totp.md +40 -0
  43. package/docs/wallet/AI.md +508 -0
  44. package/docs/wallet/DEVELOPING-STRATEGIES.md +713 -0
  45. package/docs/wallet/README.md +47 -0
  46. package/docs/wallet/STRATEGY.md +89 -0
  47. package/next.config.ts +21 -0
  48. package/package.json +151 -0
  49. package/postcss.config.mjs +8 -0
  50. package/prisma/migrations/20260214170000_baseline/migration.sql +511 -0
  51. package/prisma/migrations/20260216214537_add_passkey_model/migration.sql +18 -0
  52. package/prisma/migrations/20260217150500_add_credential_access_audit/migration.sql +31 -0
  53. package/prisma/migrations/migration_lock.toml +3 -0
  54. package/prisma/schema.prisma +447 -0
  55. package/public/logo-chevron.svg +31 -0
  56. package/public/logo-concentric.svg +31 -0
  57. package/public/logo-crosshatch.svg +39 -0
  58. package/public/logo-dashed.svg +39 -0
  59. package/public/logo-horizontal.svg +31 -0
  60. package/public/logo-m56.svg +64 -0
  61. package/public/logo.webp +0 -0
  62. package/scripts/add-app.js +245 -0
  63. package/scripts/init.sh +57 -0
  64. package/scripts/migrate-apikeys-to-credentials.ts +35 -0
  65. package/scripts/sandbox-agent-flow.sh +235 -0
  66. package/scripts/sandbox.sh +175 -0
  67. package/scripts/validate-job-docs.mjs +125 -0
  68. package/server/abi/SwapHelper.json +438 -0
  69. package/server/cli/approval.ts +447 -0
  70. package/server/cli/commands/app.ts +204 -0
  71. package/server/cli/commands/cron.ts +24 -0
  72. package/server/cli/commands/doctor.ts +1007 -0
  73. package/server/cli/commands/env.ts +456 -0
  74. package/server/cli/commands/init.ts +752 -0
  75. package/server/cli/commands/mcp.ts +125 -0
  76. package/server/cli/commands/restore.ts +314 -0
  77. package/server/cli/commands/shell-hook.ts +468 -0
  78. package/server/cli/commands/start.ts +62 -0
  79. package/server/cli/commands/status.ts +59 -0
  80. package/server/cli/commands/stop.ts +14 -0
  81. package/server/cli/commands/token.ts +180 -0
  82. package/server/cli/commands/unlock.ts +49 -0
  83. package/server/cli/commands/vault.ts +417 -0
  84. package/server/cli/index.ts +328 -0
  85. package/server/cli/lib/aura-parser.ts +64 -0
  86. package/server/cli/lib/credential-create.ts +74 -0
  87. package/server/cli/lib/credential-resolve.ts +254 -0
  88. package/server/cli/lib/dotenv-migrate.ts +116 -0
  89. package/server/cli/lib/dotenv-parser.ts +146 -0
  90. package/server/cli/lib/http.ts +91 -0
  91. package/server/cli/lib/init-steps.ts +76 -0
  92. package/server/cli/lib/local-agent-trust.ts +45 -0
  93. package/server/cli/lib/process.ts +136 -0
  94. package/server/cli/lib/prompt.ts +85 -0
  95. package/server/cli/lib/theme.ts +240 -0
  96. package/server/cli/socket.ts +570 -0
  97. package/server/cli/transport-client.ts +50 -0
  98. package/server/cron/index.ts +137 -0
  99. package/server/cron/job.ts +31 -0
  100. package/server/cron/jobs/balance-sync.ts +436 -0
  101. package/server/cron/jobs/incoming-scan.ts +506 -0
  102. package/server/cron/jobs/native-price.ts +70 -0
  103. package/server/cron/jobs/orphan-cleanup.ts +40 -0
  104. package/server/cron/jobs/strategy-runner.ts +175 -0
  105. package/server/cron/scheduler.ts +125 -0
  106. package/server/index.ts +406 -0
  107. package/server/lib/adapters/factory.ts +110 -0
  108. package/server/lib/adapters/index.ts +19 -0
  109. package/server/lib/adapters/router.ts +297 -0
  110. package/server/lib/adapters/telegram.ts +645 -0
  111. package/server/lib/adapters/types.ts +89 -0
  112. package/server/lib/adapters/webhook.ts +95 -0
  113. package/server/lib/address.ts +49 -0
  114. package/server/lib/agent-auth/contracts.ts +1194 -0
  115. package/server/lib/agent-profiles.ts +328 -0
  116. package/server/lib/ai.ts +285 -0
  117. package/server/lib/api-registry/contracts.ts +86 -0
  118. package/server/lib/api-registry/validation.ts +172 -0
  119. package/server/lib/apikey-migration.ts +189 -0
  120. package/server/lib/app-installer.ts +505 -0
  121. package/server/lib/app-tokens.ts +247 -0
  122. package/server/lib/auth.ts +314 -0
  123. package/server/lib/batch.ts +242 -0
  124. package/server/lib/cold.ts +874 -0
  125. package/server/lib/config.ts +381 -0
  126. package/server/lib/credential-access-audit.ts +85 -0
  127. package/server/lib/credential-access-policy.ts +110 -0
  128. package/server/lib/credential-health.ts +343 -0
  129. package/server/lib/credential-import.ts +487 -0
  130. package/server/lib/credential-scope.ts +87 -0
  131. package/server/lib/credential-shares.ts +190 -0
  132. package/server/lib/credential-transport.ts +342 -0
  133. package/server/lib/credential-vault.ts +77 -0
  134. package/server/lib/credentials.ts +333 -0
  135. package/server/lib/crypto.ts +8 -0
  136. package/server/lib/db.ts +15 -0
  137. package/server/lib/defaults.ts +366 -0
  138. package/server/lib/dex/index.ts +80 -0
  139. package/server/lib/dex/relay.ts +235 -0
  140. package/server/lib/dex/types.ts +59 -0
  141. package/server/lib/dex/uniswap.ts +370 -0
  142. package/server/lib/e2e-agent/artifacts.ts +36 -0
  143. package/server/lib/e2e-agent/contracts.ts +112 -0
  144. package/server/lib/e2e-agent/validation.ts +135 -0
  145. package/server/lib/encrypt.ts +128 -0
  146. package/server/lib/error.ts +20 -0
  147. package/server/lib/events.ts +205 -0
  148. package/server/lib/hot.ts +357 -0
  149. package/server/lib/key-fingerprint.ts +28 -0
  150. package/server/lib/logger.ts +331 -0
  151. package/server/lib/network.ts +137 -0
  152. package/server/lib/notifications.ts +219 -0
  153. package/server/lib/oauth2-refresh.ts +241 -0
  154. package/server/lib/oursecret.ts +54 -0
  155. package/server/lib/passkey-credential.ts +360 -0
  156. package/server/lib/passkey.ts +68 -0
  157. package/server/lib/permissions.ts +248 -0
  158. package/server/lib/pino.ts +24 -0
  159. package/server/lib/policy-preview.ts +138 -0
  160. package/server/lib/price.ts +338 -0
  161. package/server/lib/prices.ts +34 -0
  162. package/server/lib/project-scope.ts +239 -0
  163. package/server/lib/resolve-action.ts +427 -0
  164. package/server/lib/resolve.ts +36 -0
  165. package/server/lib/sessions.ts +632 -0
  166. package/server/lib/solana/connection.ts +26 -0
  167. package/server/lib/solana/jupiter.ts +128 -0
  168. package/server/lib/solana/transfer.ts +108 -0
  169. package/server/lib/solana/wallet.ts +136 -0
  170. package/server/lib/strategy/emits.ts +21 -0
  171. package/server/lib/strategy/engine.ts +1305 -0
  172. package/server/lib/strategy/executor.ts +115 -0
  173. package/server/lib/strategy/hook-context.ts +158 -0
  174. package/server/lib/strategy/hooks.ts +990 -0
  175. package/server/lib/strategy/index.ts +28 -0
  176. package/server/lib/strategy/installer.ts +305 -0
  177. package/server/lib/strategy/loader.ts +256 -0
  178. package/server/lib/strategy/message.ts +235 -0
  179. package/server/lib/strategy/repository.ts +218 -0
  180. package/server/lib/strategy/session-logger.ts +693 -0
  181. package/server/lib/strategy/sources.ts +288 -0
  182. package/server/lib/strategy/state.ts +189 -0
  183. package/server/lib/strategy/templates.ts +403 -0
  184. package/server/lib/strategy/tick.ts +404 -0
  185. package/server/lib/strategy/types.ts +230 -0
  186. package/server/lib/swap.ts +3 -0
  187. package/server/lib/temp.ts +86 -0
  188. package/server/lib/token-metadata.ts +86 -0
  189. package/server/lib/token-safety.ts +200 -0
  190. package/server/lib/token-search.ts +444 -0
  191. package/server/lib/totp.ts +194 -0
  192. package/server/lib/transactions.ts +123 -0
  193. package/server/lib/transport.ts +75 -0
  194. package/server/lib/txhistory/decoder.ts +262 -0
  195. package/server/lib/txhistory/enricher.ts +652 -0
  196. package/server/lib/txhistory/index.ts +391 -0
  197. package/server/lib/txhistory/signatures.ts +59 -0
  198. package/server/lib/verified-summary.ts +421 -0
  199. package/server/mcp/profile-policy.ts +30 -0
  200. package/server/mcp/server.ts +619 -0
  201. package/server/mcp/tools.ts +523 -0
  202. package/server/middleware/auth.ts +119 -0
  203. package/server/middleware/requestLogger.ts +84 -0
  204. package/server/routes/actions.ts +459 -0
  205. package/server/routes/adapters.ts +703 -0
  206. package/server/routes/addressbook.ts +113 -0
  207. package/server/routes/ai.ts +34 -0
  208. package/server/routes/apikeys.ts +295 -0
  209. package/server/routes/apps.ts +601 -0
  210. package/server/routes/auth.ts +457 -0
  211. package/server/routes/backup.ts +340 -0
  212. package/server/routes/batch.ts +270 -0
  213. package/server/routes/bookmarks.ts +162 -0
  214. package/server/routes/credential-shares.ts +198 -0
  215. package/server/routes/credential-vaults.ts +154 -0
  216. package/server/routes/credentials.ts +1290 -0
  217. package/server/routes/dashboard.ts +71 -0
  218. package/server/routes/defaults.ts +124 -0
  219. package/server/routes/fund.ts +229 -0
  220. package/server/routes/import.ts +352 -0
  221. package/server/routes/launch.ts +665 -0
  222. package/server/routes/lock.ts +54 -0
  223. package/server/routes/logs.ts +68 -0
  224. package/server/routes/nuke.ts +111 -0
  225. package/server/routes/passkey-credentials.ts +99 -0
  226. package/server/routes/passkey.ts +346 -0
  227. package/server/routes/portfolio.ts +217 -0
  228. package/server/routes/price.ts +63 -0
  229. package/server/routes/resolve.ts +31 -0
  230. package/server/routes/security.ts +45 -0
  231. package/server/routes/send-evm.ts +241 -0
  232. package/server/routes/send-solana.ts +281 -0
  233. package/server/routes/send.ts +178 -0
  234. package/server/routes/setup.ts +210 -0
  235. package/server/routes/strategy.ts +894 -0
  236. package/server/routes/swap-evm.ts +353 -0
  237. package/server/routes/swap-solana.ts +177 -0
  238. package/server/routes/swap.ts +356 -0
  239. package/server/routes/token.ts +247 -0
  240. package/server/routes/unlock.ts +403 -0
  241. package/server/routes/wallet-assets.ts +361 -0
  242. package/server/routes/wallet-transactions.ts +515 -0
  243. package/server/routes/wallet.ts +710 -0
  244. package/server/types.ts +146 -0
  245. package/skills/aurawallet/SKILL.md +739 -0
  246. package/skills/aurawallet-setup/SKILL.md +74 -0
  247. package/skills/security-review/SKILL.md +148 -0
  248. package/src/app/api/agent-requests/route.ts +30 -0
  249. package/src/app/api/apps/install/route.ts +126 -0
  250. package/src/app/api/apps/manifests/route.ts +16 -0
  251. package/src/app/api/apps/static/[...path]/route.ts +57 -0
  252. package/src/app/api/events/route.ts +92 -0
  253. package/src/app/api/page.tsx +212 -0
  254. package/src/app/api/workspace/[id]/apps/[wid]/route.ts +119 -0
  255. package/src/app/api/workspace/[id]/apps/route.ts +81 -0
  256. package/src/app/api/workspace/[id]/export/route.ts +67 -0
  257. package/src/app/api/workspace/[id]/route.ts +168 -0
  258. package/src/app/api/workspace/auth.ts +34 -0
  259. package/src/app/api/workspace/config/route.ts +106 -0
  260. package/src/app/api/workspace/import/route.ts +127 -0
  261. package/src/app/api/workspace/route.ts +116 -0
  262. package/src/app/app/page.tsx +2122 -0
  263. package/src/app/apple-icon.png +0 -0
  264. package/src/app/docs/page.tsx +178 -0
  265. package/src/app/favicon.ico +0 -0
  266. package/src/app/globals.css +572 -0
  267. package/src/app/health/page.tsx +5 -0
  268. package/src/app/hello/page.tsx +15 -0
  269. package/src/app/icon.png +0 -0
  270. package/src/app/layout.tsx +34 -0
  271. package/src/app/page.tsx +986 -0
  272. package/src/app/providers.tsx +90 -0
  273. package/src/app/share/[token]/page.tsx +295 -0
  274. package/src/components/ChainSelector.tsx +144 -0
  275. package/src/components/HumanActionBar.tsx +695 -0
  276. package/src/components/NotificationDrawer.tsx +129 -0
  277. package/src/components/apps/AgentKeysApp.tsx +490 -0
  278. package/src/components/apps/App.tsx +153 -0
  279. package/src/components/apps/AppGrid.tsx +15 -0
  280. package/src/components/apps/DetailedAddressDrawer.tsx +325 -0
  281. package/src/components/apps/DraggableApp.tsx +562 -0
  282. package/src/components/apps/IFrameApp.tsx +73 -0
  283. package/src/components/apps/LogsApp.tsx +360 -0
  284. package/src/components/apps/SendApp.tsx +394 -0
  285. package/src/components/apps/SetupWizardApp.tsx +1004 -0
  286. package/src/components/apps/SystemDefaultsApp.tsx +845 -0
  287. package/src/components/apps/ThirdPartyApp.tsx +428 -0
  288. package/src/components/apps/TokenApp.tsx +319 -0
  289. package/src/components/apps/TransactionsApp.tsx +438 -0
  290. package/src/components/apps/WalletDetailApp.tsx +1505 -0
  291. package/src/components/apps/index.ts +13 -0
  292. package/src/components/design-system/Button.tsx +53 -0
  293. package/src/components/design-system/ChainIndicator.tsx +65 -0
  294. package/src/components/design-system/ChainSelector.tsx +137 -0
  295. package/src/components/design-system/ConfirmationModal.tsx +106 -0
  296. package/src/components/design-system/ConfirmationPopover.tsx +81 -0
  297. package/src/components/design-system/Drawer.tsx +123 -0
  298. package/src/components/design-system/FilterDropdown.tsx +72 -0
  299. package/src/components/design-system/Modal.tsx +206 -0
  300. package/src/components/design-system/Popover.tsx +142 -0
  301. package/src/components/design-system/TextInput.tsx +85 -0
  302. package/src/components/design-system/Toggle.tsx +58 -0
  303. package/src/components/design-system/index.ts +11 -0
  304. package/src/components/docs/DocsThemeToggle.tsx +49 -0
  305. package/src/components/health/CredentialHealthDashboard.tsx +214 -0
  306. package/src/components/icons/ChainIcons.tsx +72 -0
  307. package/src/components/layout/AppStoreDrawer.tsx +369 -0
  308. package/src/components/layout/ContentArea.tsx +21 -0
  309. package/src/components/layout/TabBar.tsx +278 -0
  310. package/src/components/layout/WalletSidebar.tsx +1033 -0
  311. package/src/components/layout/index.ts +4 -0
  312. package/src/components/marketing/AuraWalletSpecOverlay.tsx +635 -0
  313. package/src/components/marketing/DeviceMorphExperience.tsx +216 -0
  314. package/src/components/vault/ApiKeysConsole.tsx +1080 -0
  315. package/src/components/vault/AuditConsole.tsx +584 -0
  316. package/src/components/vault/CredentialDetail.tsx +455 -0
  317. package/src/components/vault/CredentialEmpty.tsx +55 -0
  318. package/src/components/vault/CredentialField.tsx +361 -0
  319. package/src/components/vault/CredentialForm.tsx +1212 -0
  320. package/src/components/vault/CredentialList.tsx +165 -0
  321. package/src/components/vault/CredentialRow.tsx +97 -0
  322. package/src/components/vault/CredentialShareModal.tsx +178 -0
  323. package/src/components/vault/CredentialVault.tsx +754 -0
  324. package/src/components/vault/CredentialWalletWidget.tsx +103 -0
  325. package/src/components/vault/ImportCredentialsModal.tsx +515 -0
  326. package/src/components/vault/LargeTypeModal.tsx +64 -0
  327. package/src/components/vault/PasswordGenerator.tsx +224 -0
  328. package/src/components/vault/TOTPDisplay.tsx +123 -0
  329. package/src/components/vault/VaultSidebar.tsx +413 -0
  330. package/src/components/vault/types.ts +54 -0
  331. package/src/context/AuthContext.tsx +337 -0
  332. package/src/context/PriceContext.tsx +113 -0
  333. package/src/context/ThemeContext.tsx +164 -0
  334. package/src/context/WebSocketContext.tsx +269 -0
  335. package/src/context/WorkspaceContext.tsx +668 -0
  336. package/src/hooks/index.ts +3 -0
  337. package/src/hooks/useAgentActions.ts +368 -0
  338. package/src/hooks/useBalance.ts +103 -0
  339. package/src/hooks/useBalances.ts +129 -0
  340. package/src/instrumentation.ts +12 -0
  341. package/src/lib/api.ts +449 -0
  342. package/src/lib/app-loader.ts +148 -0
  343. package/src/lib/app-registry.ts +178 -0
  344. package/src/lib/app-sdk.ts +157 -0
  345. package/src/lib/audit-console-adapter.ts +151 -0
  346. package/src/lib/auth-client.ts +75 -0
  347. package/src/lib/config.ts +74 -0
  348. package/src/lib/crypto.ts +112 -0
  349. package/src/lib/db.ts +21 -0
  350. package/src/lib/docs.ts +390 -0
  351. package/src/lib/events.ts +361 -0
  352. package/src/lib/pino.ts +24 -0
  353. package/src/lib/theme-handlers.ts +168 -0
  354. package/src/lib/theme.ts +351 -0
  355. package/src/lib/tokenData.ts +378 -0
  356. package/src/lib/vault-crypto.ts +129 -0
  357. package/src/lib/websocket-server.ts +302 -0
  358. package/src/lib/websocket-setup.ts +79 -0
  359. package/src/lib/wordlist.ts +2050 -0
  360. package/src/lib/workspace-handlers.ts +285 -0
  361. package/start.sh +80 -0
  362. package/tailwind.config.ts +99 -0
  363. package/tsconfig.json +42 -0
@@ -0,0 +1,990 @@
1
+ /**
2
+ * Strategy Hook Caller
3
+ * ====================
4
+ * Sends hook instructions + context to an AI model.
5
+ * Supports four providers: Claude CLI, Claude API, Codex CLI, OpenAI API.
6
+ * AI client logic (auth, model resolution) lives in ../ai.ts.
7
+ */
8
+
9
+ import { execFile } from 'child_process';
10
+ import { writeFileSync, appendFileSync, mkdirSync, unlinkSync, existsSync } from 'fs';
11
+ import { tmpdir, homedir } from 'os';
12
+ import { join } from 'path';
13
+ import Anthropic from '@anthropic-ai/sdk';
14
+ import {
15
+ getAnthropicClient,
16
+ getOpenAiClient,
17
+ resolveModelId,
18
+ selectModelTier,
19
+ MODEL_TIERS,
20
+ __resetCachedClient,
21
+ AiProviderMode,
22
+ type ModelTier,
23
+ } from '../ai';
24
+ import { validateToken } from '../auth';
25
+ import { StrategyManifest, HookResult, HookMeta } from './types';
26
+ import { getHookSystemContext } from './hook-context';
27
+ import { toAnthropicTools, toOpenAITools, executeTool } from '../../mcp/tools';
28
+ import { getDefaultSync } from '../defaults';
29
+ import { log } from '../pino';
30
+ import { getErrorMessage } from '../error';
31
+
32
+ // Re-export for backward compatibility (tests, other consumers)
33
+ export { getAnthropicClient, __resetCachedClient };
34
+
35
+ /* ── Audit logging ────────────────────────────────────────────────────
36
+ * Writes JSONL to ~/.aurawallet/logs/hooks-YYYY-MM-DD.jsonl
37
+ * Each line is a complete hook invocation: prompt in, tool calls, response out.
38
+ * Disabled during tests (WALLET_DATA_DIR override or NODE_ENV=test).
39
+ */
40
+
41
+ interface AuditToolCall {
42
+ tool: string;
43
+ input: Record<string, unknown>;
44
+ result: string;
45
+ }
46
+
47
+ interface AuditEntry {
48
+ ts: string;
49
+ strategyId: string;
50
+ hook: string;
51
+ model: string;
52
+ via: AiProviderMode;
53
+ prompt: {
54
+ system: string;
55
+ instructions: string;
56
+ context: unknown;
57
+ };
58
+ toolCalls: AuditToolCall[];
59
+ response: {
60
+ raw: string;
61
+ parsed: HookResult;
62
+ };
63
+ durationMs: number;
64
+ }
65
+
66
+ function getAuditLogDir(): string {
67
+ const dataDir = process.env.WALLET_DATA_DIR || join(homedir(), '.aurawallet');
68
+ return join(dataDir, 'logs');
69
+ }
70
+
71
+ function writeAuditEntry(entry: AuditEntry): void {
72
+ // Skip in tests
73
+ if (process.env.NODE_ENV === 'test' || process.env.VITEST) return;
74
+
75
+ try {
76
+ const logDir = getAuditLogDir();
77
+ mkdirSync(logDir, { recursive: true });
78
+ const date = new Date().toISOString().slice(0, 10);
79
+ const logPath = join(logDir, `hooks-${date}.jsonl`);
80
+ appendFileSync(logPath, JSON.stringify(entry) + '\n');
81
+ } catch (err) {
82
+ log.warn({ err }, 'audit log write failed');
83
+ }
84
+ }
85
+
86
+ /** CLI session IDs per strategy — enables conversation memory across ticks */
87
+ const cliSessions = new Map<string, string>();
88
+
89
+ /** Persistent MCP config files per strategy — written once, reused across hooks */
90
+ const mcpConfigPaths = new Map<string, string>();
91
+
92
+ /** Pre-resolved model tier per strategy — set at enable time, avoids per-hook token decode */
93
+ const resolvedTiers = new Map<string, 'fast' | 'standard' | 'powerful'>();
94
+
95
+ /**
96
+ * Resolve token permissions for model-tier selection.
97
+ *
98
+ * In cron-owned mode, tokens are minted by the wallet server process and signed
99
+ * with a different in-memory key, so local signature verification can fail.
100
+ * For tiering only (non-auth), fall back to decoding the JWT-like payload.
101
+ */
102
+ function getTokenPermissionsForTier(token?: string): string[] {
103
+ if (!token) return [];
104
+
105
+ const validated = validateToken(token);
106
+ if (validated?.permissions?.length) return validated.permissions;
107
+
108
+ try {
109
+ const [payloadPart] = token.split('.');
110
+ if (!payloadPart) return [];
111
+ const payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString('utf-8')) as {
112
+ permissions?: unknown;
113
+ };
114
+ if (!Array.isArray(payload.permissions)) return [];
115
+ return payload.permissions.filter((perm): perm is string => typeof perm === 'string');
116
+ } catch {
117
+ return [];
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Pre-resolve and cache the model tier for a strategy based on its token permissions.
123
+ * Called once at enable time — the tier stays constant until disable since permissions don't change.
124
+ */
125
+ export function cacheModelTier(strategyId: string, token?: string): void {
126
+ const permissions = getTokenPermissionsForTier(token);
127
+ // Use 'tick' as a neutral non-lifecycle hook name to get the operational tier
128
+ resolvedTiers.set(strategyId, selectModelTier('tick', permissions));
129
+ }
130
+
131
+ /** Clear the cached model tier for a strategy */
132
+ export function clearModelTier(strategyId: string): void {
133
+ resolvedTiers.delete(strategyId);
134
+ }
135
+
136
+ /** Resolve the tsx binary path — direct bin path avoids npx package resolution overhead */
137
+ function getTsxBinPath(): string {
138
+ const direct = join(__dirname, '..', '..', '..', 'node_modules', '.bin', 'tsx');
139
+ if (existsSync(direct)) return direct;
140
+ return 'npx'; // fallback
141
+ }
142
+
143
+ /** Get or create the MCP config file for a strategy */
144
+ function getOrCreateMcpConfig(strategyId: string): string {
145
+ const existing = mcpConfigPaths.get(strategyId);
146
+ if (existing && existsSync(existing)) return existing;
147
+
148
+ const mcpDir = join(tmpdir(), 'aurawallet-mcp');
149
+ mkdirSync(mcpDir, { recursive: true });
150
+ const configPath = join(mcpDir, `${strategyId}.json`);
151
+
152
+ const tsxBin = getTsxBinPath();
153
+ const mcpServerPath = join(__dirname, '..', '..', 'mcp', 'server.ts');
154
+ const mcpConfig = {
155
+ mcpServers: {
156
+ aurawallet: {
157
+ command: tsxBin,
158
+ args: tsxBin === 'npx' ? ['tsx', mcpServerPath] : [mcpServerPath],
159
+ env: {
160
+ ...(process.env.WALLET_SERVER_URL ? { WALLET_SERVER_URL: process.env.WALLET_SERVER_URL } : {}),
161
+ },
162
+ },
163
+ },
164
+ };
165
+ writeFileSync(configPath, JSON.stringify(mcpConfig));
166
+ mcpConfigPaths.set(strategyId, configPath);
167
+ return configPath;
168
+ }
169
+
170
+ /** Remove the cached MCP config file for a strategy */
171
+ function removeMcpConfig(strategyId: string): void {
172
+ const configPath = mcpConfigPaths.get(strategyId);
173
+ if (configPath) {
174
+ try { unlinkSync(configPath); } catch {}
175
+ mcpConfigPaths.delete(strategyId);
176
+ }
177
+ }
178
+
179
+ /** Clear CLI session for a strategy (e.g. on disable/reset) */
180
+ export function clearCliSession(strategyId: string): void {
181
+ cliSessions.delete(strategyId);
182
+ cliSessions.delete(`${strategyId}:message`);
183
+ removeMcpConfig(strategyId);
184
+ clearModelTier(strategyId);
185
+ }
186
+
187
+ /** Clear all CLI sessions (e.g. on engine shutdown) */
188
+ export function clearAllCliSessions(): void {
189
+ cliSessions.clear();
190
+ for (const [id] of mcpConfigPaths) removeMcpConfig(id);
191
+ mcpConfigPaths.clear();
192
+ resolvedTiers.clear();
193
+ }
194
+
195
+ /**
196
+ * Parse a hook response from Claude into structured intents + state.
197
+ * Handles raw JSON, JSON wrapped in markdown code blocks, or JSON
198
+ * buried in reasoning text (extracts the last code block or first { ... }).
199
+ */
200
+ export function parseHookResponse(text: string): HookResult {
201
+ let cleaned = text.trim();
202
+
203
+ // 1. Try: entire response is a code block
204
+ const fullFence = cleaned.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?\s*```$/);
205
+ if (fullFence) {
206
+ cleaned = fullFence[1].trim();
207
+ }
208
+
209
+ // 2. Try: parse as-is (raw JSON or extracted from step 1)
210
+ const result = tryParseJson(cleaned);
211
+ if (result) return result;
212
+
213
+ // 3. Try: find a JSON code block anywhere in the text (model added reasoning around it)
214
+ const embeddedFence = text.match(/```(?:json)?\s*\n([\s\S]*?)\n\s*```/);
215
+ if (embeddedFence) {
216
+ const inner = embeddedFence[1].trim();
217
+ const fenceResult = tryParseJson(inner);
218
+ if (fenceResult) return fenceResult;
219
+ }
220
+
221
+ // 4. Try: find first { ... } blob that looks like our response format
222
+ // Only accept it if the parsed JSON has expected keys (intents, state, reply, log)
223
+ // to avoid misinterpreting error messages or conversational text containing JSON.
224
+ const braceStart = text.indexOf('{');
225
+ const braceEnd = text.lastIndexOf('}');
226
+ if (braceStart !== -1 && braceEnd > braceStart) {
227
+ const blob = text.slice(braceStart, braceEnd + 1);
228
+ const blobResult = tryParseJson(blob);
229
+ if (blobResult && hasHookKeys(blob)) return blobResult;
230
+ }
231
+
232
+ // 5. Give up — return the raw text as a reply
233
+ if (cleaned.length > 0) {
234
+ return { intents: [], state: {}, reply: cleaned };
235
+ }
236
+ return { intents: [], state: {} };
237
+ }
238
+
239
+ /** Check if raw JSON text contains at least one expected hook response key */
240
+ function hasHookKeys(text: string): boolean {
241
+ return /\b(intents|state|reply|log|emit)\b/.test(text);
242
+ }
243
+
244
+ /** Try to parse JSON text into a HookResult, return null on failure */
245
+ function tryParseJson(text: string): HookResult | null {
246
+ try {
247
+ const parsed = JSON.parse(text);
248
+ if (typeof parsed !== 'object' || parsed === null) return null;
249
+
250
+ // Normalize emit to undefined or array
251
+ let emit: HookResult['emit'];
252
+ if (parsed.emit) {
253
+ if (Array.isArray(parsed.emit)) {
254
+ emit = parsed.emit.filter((e: any) => e && typeof e.channel === 'string');
255
+ if ((emit as any[]).length === 0) emit = undefined;
256
+ } else if (typeof parsed.emit === 'object' && typeof parsed.emit.channel === 'string') {
257
+ emit = parsed.emit;
258
+ }
259
+ }
260
+
261
+ return {
262
+ intents: Array.isArray(parsed.intents) ? parsed.intents : [],
263
+ state: parsed.state && typeof parsed.state === 'object' ? parsed.state : {},
264
+ log: typeof parsed.log === 'string' ? parsed.log : undefined,
265
+ reply: typeof parsed.reply === 'string' ? parsed.reply : undefined,
266
+ emit,
267
+ };
268
+ } catch {
269
+ return null;
270
+ }
271
+ }
272
+
273
+ /** Max tool calls per hook invocation to prevent runaway loops */
274
+ function getMaxToolCalls(): number {
275
+ return getDefaultSync<number>('ai.max_tool_calls', 10);
276
+ }
277
+
278
+ /** Pull the user message out of message-hook context payloads. */
279
+ function getContextMessage(context: unknown): string {
280
+ if (!context || typeof context !== 'object') return '';
281
+ const maybeMessage = (context as { message?: unknown }).message;
282
+ return typeof maybeMessage === 'string' ? maybeMessage : '';
283
+ }
284
+
285
+ /**
286
+ * Promote message-hook model tier for token research and deeper analysis prompts.
287
+ * This avoids cheap-model drift on queries that need tool use and disambiguation.
288
+ */
289
+ function promoteMessageTier(
290
+ hookLabel: string,
291
+ context: unknown,
292
+ currentTier: ModelTier,
293
+ ): ModelTier {
294
+ if (hookLabel !== 'message') return currentTier;
295
+
296
+ const text = getContextMessage(context).toLowerCase();
297
+ if (!text) return currentTier;
298
+
299
+ const hasTicker = /\$[a-z0-9]{2,}/i.test(text);
300
+ const isTokenResearch = hasTicker || /\b(market cap|mcap|price|liquidity|volume|fdv|contract|token|ticker)\b/.test(text);
301
+ const isChainScoped = /\b(on|in)\s+(base|ethereum|solana|arbitrum|optimism|polygon|bsc)\b/.test(text);
302
+ const isDeepAnalysis = /\b(who(?:'s| is)|dump(?:ing)?|whale|holders?|transactions?|analy[sz]e|audit|risk|due diligence|compare)\b/.test(text);
303
+
304
+ if (currentTier !== 'powerful' && isDeepAnalysis) {
305
+ return 'powerful';
306
+ }
307
+ if (currentTier === 'fast' && (isTokenResearch || isChainScoped)) {
308
+ return 'standard';
309
+ }
310
+
311
+ return currentTier;
312
+ }
313
+
314
+ /** Build the stdin prompt content for CLI-based providers */
315
+ function buildCliPrompt(
316
+ hookMode: 'intent' | 'tool-call',
317
+ systemContext: string,
318
+ instructions: string,
319
+ contextStr: string,
320
+ ): string {
321
+ const formatSuffix = hookMode === 'tool-call'
322
+ ? `\n\nUse the wallet_api and request_human_action tools to perform actions. For token ticker/name queries without a contract address, you MUST call wallet_api GET /token/search?q=<token>&chain=<chain> before asking for an address or sending the user to external websites. After all tool calls are complete, respond with a JSON object containing reply and state.`
323
+ : `\n\nRespond with ONLY a raw JSON object. No markdown, no code fences, no explanation. Start with { and end with }:`;
324
+ return `${systemContext}\n\n---\n\nApp instructions:\n${instructions}\n\nContext:\n${contextStr}${formatSuffix}`;
325
+ }
326
+
327
+ // ─── Claude CLI ────────────────────────────────────────────────────
328
+
329
+ /**
330
+ * Call a strategy hook via the `claude` CLI in print mode.
331
+ * Uses persistent sessions per strategy — the CLI stores conversation history
332
+ * automatically, so the model has memory across ticks.
333
+ */
334
+ async function callHookViaCli(
335
+ manifest: StrategyManifest,
336
+ hookName: string,
337
+ instructions: string,
338
+ contextStr: string,
339
+ model: string,
340
+ token?: string,
341
+ ): Promise<HookResult> {
342
+ const sessionKey = hookName === 'message' ? `${manifest.id}:message` : manifest.id;
343
+ const sessionId = cliSessions.get(sessionKey);
344
+ const startTime = Date.now();
345
+
346
+ const hookMode = hookName === 'message' ? 'tool-call' : 'intent' as const;
347
+ const systemContext = getHookSystemContext(hookMode, 'cli');
348
+ const stdinContent = buildCliPrompt(hookMode, systemContext, instructions, contextStr);
349
+
350
+ // Reuse persistent MCP config file (created once per strategy, cleaned up on disable/shutdown).
351
+ // Always provide tools — some endpoints are public (token search, etc.)
352
+ // and the AI needs tools to look up data even without a wallet token.
353
+ let mcpConfigPath: string | undefined;
354
+ try {
355
+ mcpConfigPath = getOrCreateMcpConfig(manifest.id);
356
+ } catch (err) {
357
+ log.warn({ err, strategyId: manifest.id }, 'mcp config write failed');
358
+ mcpConfigPath = undefined;
359
+ }
360
+
361
+ const tag = `[strategy:${manifest.id}]`;
362
+ console.log(`${tag} hook:${hookName} ▸ stdin (${stdinContent.length} chars):`);
363
+ console.log(`${tag} hook:${hookName} ▸ system context: ${systemContext.length} chars (mode=${hookMode})`);
364
+ console.log(`${tag} hook:${hookName} ▸ instructions: ${instructions.slice(0, 200)}${instructions.length > 200 ? '...' : ''}`);
365
+ console.log(`${tag} hook:${hookName} ▸ context: ${contextStr.slice(0, 300)}${contextStr.length > 300 ? '...' : ''}`);
366
+ console.log(`${tag} hook:${hookName} ▸ MCP config: ${mcpConfigPath ? 'yes' : 'no'}, token: ${token ? 'yes' : 'no'}`);
367
+
368
+ return new Promise((resolve) => {
369
+ const args = [
370
+ '-p',
371
+ '--model', model,
372
+ '--output-format', 'json',
373
+ ];
374
+
375
+ if (mcpConfigPath) {
376
+ args.push('--mcp-config', mcpConfigPath);
377
+ args.push('--allowedTools', 'mcp__aurawallet__wallet_api,mcp__aurawallet__request_human_action');
378
+ } else {
379
+ args.push('--tools', '');
380
+ }
381
+
382
+ if (sessionId) {
383
+ args.push('--resume', sessionId);
384
+ }
385
+
386
+ console.log(`${tag} hook:${hookName} ▸ CLI args: claude ${args.join(' ')}`);
387
+
388
+ const child = execFile('claude', args, {
389
+ timeout: 180_000,
390
+ maxBuffer: 1024 * 1024,
391
+ env: { ...process.env, ...(token ? { AURA_TOKEN: token } : {}) },
392
+ }, (err, stdout, stderr) => {
393
+ if (err) {
394
+ console.error(`${tag} hook:${hookName} CLI error: ${err.message}`);
395
+ if (stderr) console.error(`${tag} hook:${hookName} stderr: ${stderr.slice(0, 500)}`);
396
+ if (sessionId) {
397
+ console.log(`${tag} clearing stale session ${sessionId}`);
398
+ cliSessions.delete(sessionKey);
399
+ }
400
+ resolve({ intents: [], state: {} });
401
+ return;
402
+ }
403
+
404
+ try {
405
+ const result = JSON.parse(stdout);
406
+ const text = result.result || '';
407
+ const cost = result.total_cost_usd;
408
+ const usage = result.usage;
409
+ const numTurns = result.num_turns;
410
+
411
+ if (result.session_id && !cliSessions.has(sessionKey)) {
412
+ cliSessions.set(sessionKey, result.session_id);
413
+ console.log(`${tag} hook:${hookName} session created: ${result.session_id}`);
414
+ }
415
+
416
+ if (usage) {
417
+ console.log(`${tag} hook:${hookName} ← tokens in=${usage.input_tokens} out=${usage.output_tokens}${usage.cache_read_input_tokens ? ` cached=${usage.cache_read_input_tokens}` : ''} cost=$${cost?.toFixed(4) || '?'} turns=${numTurns || '?'}`);
418
+ }
419
+
420
+ if (numTurns && numTurns > 1) {
421
+ console.log(`${tag} hook:${hookName} ← ${numTurns} turns (tool calls detected)`);
422
+ } else if (hookMode === 'tool-call') {
423
+ console.warn(`${tag} hook:${hookName} ⚠ tool-call mode but only ${numTurns || 1} turn — AI may not have called tools`);
424
+ }
425
+
426
+ console.log(`${tag} hook:${hookName} ← response: ${text.slice(0, 300)}${text.length > 300 ? '...' : ''}`);
427
+
428
+ const durationMs = Date.now() - startTime;
429
+ const parsed = parseHookResponse(text);
430
+ parsed._meta = {
431
+ model: result.model || model,
432
+ provider: 'claude-cli',
433
+ tokens: {
434
+ input: usage?.input_tokens || 0,
435
+ output: usage?.output_tokens || 0,
436
+ cacheRead: usage?.cache_read_input_tokens || undefined,
437
+ },
438
+ durationMs,
439
+ costUsd: cost || undefined,
440
+ toolCallCount: numTurns ? Math.max(0, numTurns - 1) : 0,
441
+ };
442
+ writeAuditEntry({
443
+ ts: new Date().toISOString(),
444
+ strategyId: manifest.id,
445
+ hook: hookName,
446
+ model,
447
+ via: 'claude-cli',
448
+ prompt: { system: systemContext, instructions, context: safeJsonParse(contextStr) },
449
+ toolCalls: [],
450
+ response: { raw: text, parsed },
451
+ durationMs,
452
+ });
453
+ resolve(parsed);
454
+ } catch {
455
+ const durationMs = Date.now() - startTime;
456
+ console.log(`${tag} hook:${hookName} ← raw: ${stdout.slice(0, 500)}`);
457
+ const parsed = parseHookResponse(stdout);
458
+ parsed._meta = {
459
+ model,
460
+ provider: 'claude-cli',
461
+ tokens: { input: 0, output: 0 },
462
+ durationMs,
463
+ toolCallCount: 0,
464
+ };
465
+ writeAuditEntry({
466
+ ts: new Date().toISOString(),
467
+ strategyId: manifest.id,
468
+ hook: hookName,
469
+ model,
470
+ via: 'claude-cli',
471
+ prompt: { system: systemContext, instructions, context: safeJsonParse(contextStr) },
472
+ toolCalls: [],
473
+ response: { raw: stdout, parsed },
474
+ durationMs,
475
+ });
476
+ resolve(parsed);
477
+ }
478
+ });
479
+
480
+ if (child.stdin) {
481
+ child.stdin.write(stdinContent);
482
+ child.stdin.end();
483
+ }
484
+ });
485
+ }
486
+
487
+ // ─── Claude API (Anthropic SDK) ────────────────────────────────────
488
+
489
+ /**
490
+ * Call a strategy hook via the Anthropic SDK (requires real API key).
491
+ * Includes tool-use loop: if the model calls wallet_api, execute it and continue.
492
+ */
493
+ async function callHookViaSdk(
494
+ manifest: StrategyManifest,
495
+ hookName: string,
496
+ instructions: string,
497
+ contextStr: string,
498
+ model: string,
499
+ token?: string,
500
+ onProgress?: (status: string) => void,
501
+ onToolCall?: (entry: { name: string; input: Record<string, unknown>; result: string; durationMs: number }) => void,
502
+ ): Promise<HookResult> {
503
+ const client = await getAnthropicClient();
504
+ const tag = `[strategy:${manifest.id}]`;
505
+ const startTime = Date.now();
506
+ const auditToolCalls: AuditToolCall[] = [];
507
+
508
+ const hookMode = hookName === 'message' ? 'tool-call' : 'intent' as const;
509
+ const systemContext = getHookSystemContext(hookMode, 'sdk');
510
+ const system: Anthropic.TextBlockParam[] = [
511
+ {
512
+ type: 'text',
513
+ text: systemContext,
514
+ cache_control: { type: 'ephemeral' },
515
+ },
516
+ {
517
+ type: 'text',
518
+ text: instructions,
519
+ },
520
+ ];
521
+
522
+ const tools = toAnthropicTools();
523
+
524
+ console.log(`${tag} hook:${hookName} ▸ SDK request:`);
525
+ console.log(`${tag} hook:${hookName} ▸ system: ${systemContext.length} chars (mode=${hookMode})`);
526
+ console.log(`${tag} hook:${hookName} ▸ instructions: ${instructions.slice(0, 200)}${instructions.length > 200 ? '...' : ''}`);
527
+ console.log(`${tag} hook:${hookName} ▸ context: ${contextStr.slice(0, 300)}${contextStr.length > 300 ? '...' : ''}`);
528
+ console.log(`${tag} hook:${hookName} ▸ tools: [${tools.map(t => t.name).join(', ')}] (${tools.length} tools), token: ${token ? 'yes' : 'no'}`);
529
+
530
+ const messages: Anthropic.MessageParam[] = [
531
+ { role: 'user', content: contextStr },
532
+ ];
533
+
534
+ let toolCallCount = 0;
535
+ let totalInputTokens = 0;
536
+ let totalOutputTokens = 0;
537
+ let totalCacheRead = 0;
538
+
539
+ const finalize = (rawText: string): HookResult => {
540
+ const durationMs = Date.now() - startTime;
541
+ const parsed = parseHookResponse(rawText);
542
+ writeAuditEntry({
543
+ ts: new Date().toISOString(),
544
+ strategyId: manifest.id,
545
+ hook: hookName,
546
+ model,
547
+ via: 'claude-api',
548
+ prompt: { system: systemContext, instructions, context: safeJsonParse(contextStr) },
549
+ toolCalls: auditToolCalls,
550
+ response: { raw: rawText, parsed },
551
+ durationMs,
552
+ });
553
+ parsed._meta = {
554
+ model,
555
+ provider: 'claude-api',
556
+ tokens: { input: totalInputTokens, output: totalOutputTokens, cacheRead: totalCacheRead || undefined },
557
+ durationMs,
558
+ toolCallCount,
559
+ };
560
+ return parsed;
561
+ };
562
+
563
+ while (true) {
564
+ const response = await client.messages.create({
565
+ model,
566
+ max_tokens: 2048,
567
+ system,
568
+ tools: tools.length > 0 ? tools : undefined,
569
+ messages,
570
+ });
571
+
572
+ const usage = (response as any).usage;
573
+ if (usage) {
574
+ totalInputTokens += usage.input_tokens || 0;
575
+ totalOutputTokens += usage.output_tokens || 0;
576
+ totalCacheRead += usage.cache_read_input_tokens || 0;
577
+ console.log(`${tag} hook:${hookName} ← tokens in=${usage.input_tokens} out=${usage.output_tokens}${usage.cache_read_input_tokens ? ` cached=${usage.cache_read_input_tokens}` : ''}`);
578
+ }
579
+
580
+ if (response.stop_reason === 'tool_use') {
581
+ const toolBlocks = response.content.filter(
582
+ (block): block is Anthropic.ToolUseBlock => block.type === 'tool_use',
583
+ );
584
+
585
+ const maxToolCalls = getMaxToolCalls();
586
+ if (toolBlocks.length === 0 || toolCallCount >= maxToolCalls) {
587
+ if (toolCallCount >= maxToolCalls) {
588
+ console.warn(`${tag} hook:${hookName} hit max tool calls (${maxToolCalls}), stopping`);
589
+ }
590
+ const text = response.content
591
+ .filter((block): block is Anthropic.TextBlock => block.type === 'text')
592
+ .map((block) => block.text)
593
+ .join('');
594
+ console.log(`${tag} hook:${hookName} ← response: ${text.slice(0, 300)}${text.length > 300 ? '...' : ''}`);
595
+ return finalize(text);
596
+ }
597
+
598
+ messages.push({ role: 'assistant', content: response.content });
599
+
600
+ const toolResults: Anthropic.ToolResultBlockParam[] = [];
601
+ for (const toolBlock of toolBlocks) {
602
+ toolCallCount++;
603
+ const input = toolBlock.input as Record<string, unknown>;
604
+ console.log(`${tag} hook:${hookName} tool: ${input.method || '?'} ${input.endpoint || '?'}`);
605
+
606
+ if (onProgress) {
607
+ const status = toolCallToStatus(toolBlock.name, input);
608
+ onProgress(status || '');
609
+ }
610
+
611
+ const toolStart = Date.now();
612
+ const result = await executeTool(toolBlock.name, input, token);
613
+ if (onToolCall) onToolCall({ name: toolBlock.name, input, result, durationMs: Date.now() - toolStart });
614
+ auditToolCalls.push({ tool: toolBlock.name, input, result });
615
+ toolResults.push({
616
+ type: 'tool_result',
617
+ tool_use_id: toolBlock.id,
618
+ content: result,
619
+ });
620
+ }
621
+
622
+ messages.push({ role: 'user', content: toolResults });
623
+ continue;
624
+ }
625
+
626
+ const text = response.content
627
+ .filter((block): block is Anthropic.TextBlock => block.type === 'text')
628
+ .map((block) => block.text)
629
+ .join('');
630
+
631
+ console.log(`${tag} hook:${hookName} ← response: ${text.slice(0, 300)}${text.length > 300 ? '...' : ''}`);
632
+ return finalize(text);
633
+ }
634
+ }
635
+
636
+ // ─── Codex CLI ─────────────────────────────────────────────────────
637
+
638
+ /**
639
+ * Call a strategy hook via the `codex` CLI.
640
+ * Uses `codex exec --json --model <model> --ephemeral -` with stdin prompt.
641
+ * No session persistence — each invocation is ephemeral.
642
+ * No native MCP support — tool instructions are embedded in the prompt text.
643
+ */
644
+ async function callHookViaCodexCli(
645
+ manifest: StrategyManifest,
646
+ hookName: string,
647
+ instructions: string,
648
+ contextStr: string,
649
+ model: string,
650
+ _token?: string,
651
+ ): Promise<HookResult> {
652
+ const startTime = Date.now();
653
+ const tag = `[strategy:${manifest.id}]`;
654
+
655
+ const hookMode = hookName === 'message' ? 'tool-call' : 'intent' as const;
656
+ const systemContext = getHookSystemContext(hookMode, 'sdk');
657
+ const stdinContent = buildCliPrompt(hookMode, systemContext, instructions, contextStr);
658
+
659
+ console.log(`${tag} hook:${hookName} ▸ codex exec stdin (${stdinContent.length} chars)`);
660
+ console.log(`${tag} hook:${hookName} ▸ model: ${model}`);
661
+
662
+ return new Promise((resolve) => {
663
+ const args = [
664
+ 'exec',
665
+ '--json',
666
+ '--model', model,
667
+ '--ephemeral',
668
+ '-', // read from stdin
669
+ ];
670
+
671
+ console.log(`${tag} hook:${hookName} ▸ CLI args: codex ${args.join(' ')}`);
672
+
673
+ const child = execFile('codex', args, {
674
+ timeout: 180_000,
675
+ maxBuffer: 1024 * 1024,
676
+ env: { ...process.env },
677
+ }, (err, stdout, stderr) => {
678
+ if (err) {
679
+ console.error(`${tag} hook:${hookName} Codex CLI error: ${err.message}`);
680
+ if (stderr) console.error(`${tag} hook:${hookName} stderr: ${stderr.slice(0, 500)}`);
681
+ resolve({ intents: [], state: {} });
682
+ return;
683
+ }
684
+
685
+ // Codex outputs JSONL — extract text from the last assistant message
686
+ const text = extractCodexResponse(stdout);
687
+ console.log(`${tag} hook:${hookName} ← response: ${text.slice(0, 300)}${text.length > 300 ? '...' : ''}`);
688
+
689
+ const durationMs = Date.now() - startTime;
690
+ const parsed = parseHookResponse(text);
691
+ parsed._meta = {
692
+ model,
693
+ provider: 'codex-cli',
694
+ tokens: { input: 0, output: 0 },
695
+ durationMs,
696
+ toolCallCount: 0,
697
+ };
698
+ writeAuditEntry({
699
+ ts: new Date().toISOString(),
700
+ strategyId: manifest.id,
701
+ hook: hookName,
702
+ model,
703
+ via: 'codex-cli',
704
+ prompt: { system: systemContext, instructions, context: safeJsonParse(contextStr) },
705
+ toolCalls: [],
706
+ response: { raw: text, parsed },
707
+ durationMs,
708
+ });
709
+ resolve(parsed);
710
+ });
711
+
712
+ if (child.stdin) {
713
+ child.stdin.write(stdinContent);
714
+ child.stdin.end();
715
+ }
716
+ });
717
+ }
718
+
719
+ /**
720
+ * Extract the final assistant message text from Codex CLI JSONL output.
721
+ * Codex outputs one JSON object per line. We look for the last message with role=assistant.
722
+ */
723
+ function extractCodexResponse(stdout: string): string {
724
+ const lines = stdout.trim().split('\n');
725
+
726
+ // Try parsing as single JSON first (codex exec --json may output a single object)
727
+ try {
728
+ const single = JSON.parse(stdout);
729
+ if (single.result) return single.result;
730
+ if (single.message?.content) return single.message.content;
731
+ if (typeof single.content === 'string') return single.content;
732
+ } catch {
733
+ // Not single JSON — try JSONL
734
+ }
735
+
736
+ // Parse JSONL, find last assistant message
737
+ let lastText = '';
738
+ for (const line of lines) {
739
+ try {
740
+ const obj = JSON.parse(line);
741
+ if (obj.role === 'assistant' && obj.content) {
742
+ lastText = typeof obj.content === 'string'
743
+ ? obj.content
744
+ : Array.isArray(obj.content)
745
+ ? obj.content.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('')
746
+ : '';
747
+ }
748
+ // Also handle { type: 'message', message: { content: '...' } } format
749
+ if (obj.type === 'message' && obj.message?.role === 'assistant') {
750
+ const content = obj.message.content;
751
+ if (typeof content === 'string') lastText = content;
752
+ else if (Array.isArray(content)) {
753
+ lastText = content.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('');
754
+ }
755
+ }
756
+ } catch {
757
+ // Skip non-JSON lines
758
+ }
759
+ }
760
+
761
+ return lastText || stdout;
762
+ }
763
+
764
+ // ─── OpenAI API (OpenAI SDK) ───────────────────────────────────────
765
+
766
+ /**
767
+ * Call a strategy hook via the OpenAI SDK.
768
+ * Includes tool-use loop: if the model calls tools, execute them and continue.
769
+ * Uses existing toOpenAITools() and executeTool() from mcp/tools.ts.
770
+ */
771
+ async function callHookViaOpenAiSdk(
772
+ manifest: StrategyManifest,
773
+ hookName: string,
774
+ instructions: string,
775
+ contextStr: string,
776
+ model: string,
777
+ token?: string,
778
+ onProgress?: (status: string) => void,
779
+ onToolCall?: (entry: { name: string; input: Record<string, unknown>; result: string; durationMs: number }) => void,
780
+ ): Promise<HookResult> {
781
+ const client = await getOpenAiClient();
782
+ const tag = `[strategy:${manifest.id}]`;
783
+ const startTime = Date.now();
784
+ const auditToolCalls: AuditToolCall[] = [];
785
+
786
+ const hookMode = hookName === 'message' ? 'tool-call' : 'intent' as const;
787
+ const systemContext = getHookSystemContext(hookMode, 'sdk');
788
+
789
+ const tools = toOpenAITools();
790
+
791
+ console.log(`${tag} hook:${hookName} ▸ OpenAI SDK request:`);
792
+ console.log(`${tag} hook:${hookName} ▸ system: ${systemContext.length} chars (mode=${hookMode})`);
793
+ console.log(`${tag} hook:${hookName} ▸ instructions: ${instructions.slice(0, 200)}${instructions.length > 200 ? '...' : ''}`);
794
+ console.log(`${tag} hook:${hookName} ▸ context: ${contextStr.slice(0, 300)}${contextStr.length > 300 ? '...' : ''}`);
795
+ console.log(`${tag} hook:${hookName} ▸ tools: [${tools.map(t => t.function.name).join(', ')}] (${tools.length} tools), token: ${token ? 'yes' : 'no'}`);
796
+
797
+ const messages: Array<{
798
+ role: 'system' | 'user' | 'assistant' | 'tool';
799
+ content: string | null;
800
+ tool_calls?: any[];
801
+ tool_call_id?: string;
802
+ }> = [
803
+ { role: 'system', content: `${systemContext}\n\n${instructions}` },
804
+ { role: 'user', content: contextStr },
805
+ ];
806
+
807
+ let toolCallCount = 0;
808
+ let totalInputTokens = 0;
809
+ let totalOutputTokens = 0;
810
+
811
+ const finalize = (rawText: string): HookResult => {
812
+ const durationMs = Date.now() - startTime;
813
+ const parsed = parseHookResponse(rawText);
814
+ writeAuditEntry({
815
+ ts: new Date().toISOString(),
816
+ strategyId: manifest.id,
817
+ hook: hookName,
818
+ model,
819
+ via: 'openai-api',
820
+ prompt: { system: systemContext, instructions, context: safeJsonParse(contextStr) },
821
+ toolCalls: auditToolCalls,
822
+ response: { raw: rawText, parsed },
823
+ durationMs,
824
+ });
825
+ parsed._meta = {
826
+ model,
827
+ provider: 'openai-api',
828
+ tokens: { input: totalInputTokens, output: totalOutputTokens },
829
+ durationMs,
830
+ toolCallCount,
831
+ };
832
+ return parsed;
833
+ };
834
+
835
+ while (true) {
836
+ const response = await client.chat.completions.create({
837
+ model,
838
+ max_tokens: 2048,
839
+ messages: messages as any,
840
+ tools: tools.length > 0 ? tools : undefined,
841
+ });
842
+
843
+ const choice = response.choices[0];
844
+ if (!choice) {
845
+ console.warn(`${tag} hook:${hookName} ← no choices returned`);
846
+ return finalize('');
847
+ }
848
+
849
+ const usage = response.usage;
850
+ if (usage) {
851
+ totalInputTokens += usage.prompt_tokens || 0;
852
+ totalOutputTokens += usage.completion_tokens || 0;
853
+ console.log(`${tag} hook:${hookName} ← tokens in=${usage.prompt_tokens} out=${usage.completion_tokens}`);
854
+ }
855
+
856
+ // Check if the model wants to use tools
857
+ if (choice.finish_reason === 'tool_calls' && choice.message.tool_calls) {
858
+ const maxToolCalls = getMaxToolCalls();
859
+ if (toolCallCount >= maxToolCalls) {
860
+ console.warn(`${tag} hook:${hookName} hit max tool calls (${maxToolCalls}), stopping`);
861
+ const text = choice.message.content || '';
862
+ console.log(`${tag} hook:${hookName} ← response: ${text.slice(0, 300)}${text.length > 300 ? '...' : ''}`);
863
+ return finalize(text);
864
+ }
865
+
866
+ // Append assistant message with tool_calls
867
+ messages.push({
868
+ role: 'assistant',
869
+ content: choice.message.content,
870
+ tool_calls: choice.message.tool_calls,
871
+ });
872
+
873
+ // Execute each tool call and feed results back
874
+ for (const toolCall of choice.message.tool_calls) {
875
+ toolCallCount++;
876
+ const fnName = toolCall.function.name;
877
+ let fnArgs: Record<string, unknown> = {};
878
+ try {
879
+ fnArgs = JSON.parse(toolCall.function.arguments);
880
+ } catch {
881
+ fnArgs = {};
882
+ }
883
+
884
+ console.log(`${tag} hook:${hookName} tool: ${fnArgs.method || '?'} ${fnArgs.endpoint || '?'}`);
885
+
886
+ if (onProgress) {
887
+ const status = toolCallToStatus(fnName, fnArgs);
888
+ onProgress(status || '');
889
+ }
890
+
891
+ const toolStart = Date.now();
892
+ const result = await executeTool(fnName, fnArgs, token);
893
+ if (onToolCall) onToolCall({ name: fnName, input: fnArgs, result, durationMs: Date.now() - toolStart });
894
+ auditToolCalls.push({ tool: fnName, input: fnArgs, result });
895
+ messages.push({
896
+ role: 'tool',
897
+ tool_call_id: toolCall.id,
898
+ content: result,
899
+ });
900
+ }
901
+
902
+ continue;
903
+ }
904
+
905
+ // No tool use — extract text and return
906
+ const text = choice.message.content || '';
907
+ console.log(`${tag} hook:${hookName} ← response: ${text.slice(0, 300)}${text.length > 300 ? '...' : ''}`);
908
+ return finalize(text);
909
+ }
910
+ }
911
+
912
+ // ─── Helpers ───────────────────────────────────────────────────────
913
+
914
+ /** Safe parse — returns object if valid JSON, raw string otherwise */
915
+ function safeJsonParse(str: string): unknown {
916
+ try { return JSON.parse(str); } catch { return str; }
917
+ }
918
+
919
+ // ─── Progress Helpers ──────────────────────────────────────────────
920
+
921
+ /**
922
+ * Map a tool call name + input to a human-readable progress status.
923
+ * Returns null when no specific status applies (caller uses flavor text).
924
+ */
925
+ export function toolCallToStatus(name: string, input: Record<string, unknown>): string | null {
926
+ if (name === 'request_human_action') return 'requesting approval...';
927
+ const endpoint = String(input.endpoint || '');
928
+ const method = String(input.method || 'GET');
929
+ if (endpoint === '/wallets') return 'checking your wallets...';
930
+ if (endpoint.startsWith('/wallet/') && endpoint.includes('/assets')) return 'looking up assets...';
931
+ if (endpoint === '/swap') return method === 'POST' ? 'preparing the swap...' : 'checking swap routes...';
932
+ if (endpoint === '/send') return 'preparing the transfer...';
933
+ if (endpoint === '/fund') return 'funding the wallet...';
934
+ if (endpoint === '/launch') return 'launching the token...';
935
+ if (endpoint.startsWith('/token/')) return 'looking up token info...';
936
+ return null;
937
+ }
938
+
939
+ // ─── Entry Point ───────────────────────────────────────────────────
940
+
941
+ /**
942
+ * Call a strategy hook by sending instructions + context to an AI model.
943
+ * Dispatches to the correct provider based on system defaults.
944
+ * Returns parsed intents and state, or empty defaults on error/missing hook.
945
+ * Optional onProgress callback fires on each tool call with a human-readable status.
946
+ */
947
+ export async function callHook(
948
+ manifest: StrategyManifest,
949
+ hookName: keyof StrategyManifest['hooks'],
950
+ context: unknown,
951
+ token?: string,
952
+ onProgress?: (status: string) => void,
953
+ onToolCall?: (entry: { name: string; input: Record<string, unknown>; result: string; durationMs: number }) => void,
954
+ ): Promise<HookResult> {
955
+ const instructions = manifest.hooks[hookName];
956
+ if (!instructions) {
957
+ return { intents: [], state: {} };
958
+ }
959
+
960
+ const contextStr = JSON.stringify(context);
961
+ const provider = getDefaultSync<AiProviderMode>('ai.provider', 'claude-cli');
962
+ const hookLabel = String(hookName);
963
+
964
+ // Fast path: use cached tier from resolvedTiers map when available.
965
+ // Lifecycle hooks always override to 'fast' regardless of cached tier.
966
+ const isLifecycle = hookLabel === 'init' || hookLabel === 'shutdown';
967
+ const cachedTier = !isLifecycle ? resolvedTiers.get(manifest.id) : undefined;
968
+ const baseTier = cachedTier ?? selectModelTier(hookLabel, getTokenPermissionsForTier(token));
969
+ const tier = promoteMessageTier(hookLabel, context, baseTier);
970
+ const model = resolveModelId(MODEL_TIERS[provider][tier], provider);
971
+
972
+ console.log(`[strategy:${manifest.id}] hook:${hookLabel} → model=${model}, tier=${tier}, provider=${provider}, context=${contextStr.length} chars, token=${token ? 'yes' : 'NO'}`);
973
+
974
+ try {
975
+ switch (provider) {
976
+ case 'claude-cli':
977
+ return await callHookViaCli(manifest, hookLabel, instructions, contextStr, model, token);
978
+ case 'claude-api':
979
+ return await callHookViaSdk(manifest, hookLabel, instructions, contextStr, model, token, onProgress, onToolCall);
980
+ case 'codex-cli':
981
+ return await callHookViaCodexCli(manifest, hookLabel, instructions, contextStr, model, token);
982
+ case 'openai-api':
983
+ return await callHookViaOpenAiSdk(manifest, hookLabel, instructions, contextStr, model, token, onProgress, onToolCall);
984
+ }
985
+ } catch (err) {
986
+ const errMsg = getErrorMessage(err);
987
+ console.error(`[strategy:${manifest.id}] hook:${hookLabel} FAILED: ${errMsg}`);
988
+ return { intents: [], state: {} };
989
+ }
990
+ }