@bahulam/code 0.1.1

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 (278) hide show
  1. package/README.md +93 -0
  2. package/package.json +56 -0
  3. package/pulse/app/activity/page.tsx +190 -0
  4. package/pulse/app/api/activity/route.ts +138 -0
  5. package/pulse/app/api/benchmark/route.ts +113 -0
  6. package/pulse/app/api/benchmarks/route.ts +195 -0
  7. package/pulse/app/api/costs/route.ts +88 -0
  8. package/pulse/app/api/export/route.ts +77 -0
  9. package/pulse/app/api/history/route.ts +11 -0
  10. package/pulse/app/api/import/route.ts +31 -0
  11. package/pulse/app/api/memory/route.ts +50 -0
  12. package/pulse/app/api/plans/route.ts +9 -0
  13. package/pulse/app/api/projects/[slug]/route.ts +96 -0
  14. package/pulse/app/api/projects/route.ts +121 -0
  15. package/pulse/app/api/sessions/[id]/replay/route.ts +20 -0
  16. package/pulse/app/api/sessions/[id]/route.ts +31 -0
  17. package/pulse/app/api/sessions/route.ts +112 -0
  18. package/pulse/app/api/settings/route.ts +14 -0
  19. package/pulse/app/api/stats/route.ts +143 -0
  20. package/pulse/app/api/todos/route.ts +9 -0
  21. package/pulse/app/api/tools/route.ts +160 -0
  22. package/pulse/app/benchmarks/page.tsx +224 -0
  23. package/pulse/app/costs/page.tsx +179 -0
  24. package/pulse/app/export/page.tsx +465 -0
  25. package/pulse/app/favicon.ico +0 -0
  26. package/pulse/app/globals.css +263 -0
  27. package/pulse/app/help/page.tsx +143 -0
  28. package/pulse/app/history/page.tsx +157 -0
  29. package/pulse/app/layout.tsx +46 -0
  30. package/pulse/app/memory/page.tsx +365 -0
  31. package/pulse/app/overview-client.tsx +393 -0
  32. package/pulse/app/page.tsx +14 -0
  33. package/pulse/app/plans/page.tsx +308 -0
  34. package/pulse/app/projects/[slug]/page.tsx +390 -0
  35. package/pulse/app/projects/page.tsx +110 -0
  36. package/pulse/app/sessions/[id]/page.tsx +243 -0
  37. package/pulse/app/sessions/page.tsx +39 -0
  38. package/pulse/app/settings/page.tsx +188 -0
  39. package/pulse/app/todos/page.tsx +211 -0
  40. package/pulse/app/tools/page.tsx +249 -0
  41. package/pulse/cli.js +164 -0
  42. package/pulse/components/activity/day-of-week-chart.tsx +35 -0
  43. package/pulse/components/activity/streak-card.tsx +36 -0
  44. package/pulse/components/costs/cache-efficiency-panel.tsx +76 -0
  45. package/pulse/components/costs/cost-by-project-chart.tsx +48 -0
  46. package/pulse/components/costs/cost-over-time-chart.tsx +95 -0
  47. package/pulse/components/costs/model-token-table.tsx +60 -0
  48. package/pulse/components/global-search.tsx +193 -0
  49. package/pulse/components/keyboard-nav-provider.tsx +23 -0
  50. package/pulse/components/layout/bottom-nav.tsx +53 -0
  51. package/pulse/components/layout/client-layout.tsx +31 -0
  52. package/pulse/components/layout/sidebar-context.tsx +50 -0
  53. package/pulse/components/layout/sidebar.tsx +183 -0
  54. package/pulse/components/layout/top-bar.tsx +121 -0
  55. package/pulse/components/overview/activity-heatmap.tsx +107 -0
  56. package/pulse/components/overview/conversation-table.tsx +148 -0
  57. package/pulse/components/overview/model-breakdown-donut.tsx +95 -0
  58. package/pulse/components/overview/peak-hours-chart.tsx +87 -0
  59. package/pulse/components/overview/project-activity-donut.tsx +96 -0
  60. package/pulse/components/overview/stat-card.tsx +102 -0
  61. package/pulse/components/overview/usage-over-time-chart.tsx +166 -0
  62. package/pulse/components/projects/project-card.tsx +175 -0
  63. package/pulse/components/sessions/replay/assistant-markdown.tsx +94 -0
  64. package/pulse/components/sessions/replay/compaction-card.tsx +25 -0
  65. package/pulse/components/sessions/replay/session-sidebar.tsx +231 -0
  66. package/pulse/components/sessions/replay/token-accumulation-chart.tsx +98 -0
  67. package/pulse/components/sessions/replay/tool-call-badge.tsx +127 -0
  68. package/pulse/components/sessions/replay/turn-cards.tsx +220 -0
  69. package/pulse/components/sessions/replay/user-tool-result.tsx +158 -0
  70. package/pulse/components/sessions/session-badges.tsx +49 -0
  71. package/pulse/components/sessions/session-table.tsx +299 -0
  72. package/pulse/components/theme-provider.tsx +44 -0
  73. package/pulse/components/tools/feature-adoption-table.tsx +58 -0
  74. package/pulse/components/tools/mcp-server-panel.tsx +45 -0
  75. package/pulse/components/tools/tool-ranking-chart.tsx +57 -0
  76. package/pulse/components/tools/version-history-table.tsx +32 -0
  77. package/pulse/components/ui/alert.tsx +66 -0
  78. package/pulse/components/ui/badge.tsx +48 -0
  79. package/pulse/components/ui/breadcrumb.tsx +109 -0
  80. package/pulse/components/ui/button.tsx +64 -0
  81. package/pulse/components/ui/calendar.tsx +220 -0
  82. package/pulse/components/ui/card.tsx +92 -0
  83. package/pulse/components/ui/command.tsx +158 -0
  84. package/pulse/components/ui/dialog.tsx +158 -0
  85. package/pulse/components/ui/input.tsx +21 -0
  86. package/pulse/components/ui/popover.tsx +89 -0
  87. package/pulse/components/ui/progress.tsx +31 -0
  88. package/pulse/components/ui/select.tsx +190 -0
  89. package/pulse/components/ui/separator.tsx +28 -0
  90. package/pulse/components/ui/sheet.tsx +143 -0
  91. package/pulse/components/ui/skeleton.tsx +13 -0
  92. package/pulse/components/ui/table.tsx +116 -0
  93. package/pulse/components/ui/tabs.tsx +91 -0
  94. package/pulse/components/ui/tooltip.tsx +57 -0
  95. package/pulse/components/use-global-keyboard-nav.ts +79 -0
  96. package/pulse/components.json +23 -0
  97. package/pulse/eslint.config.mjs +18 -0
  98. package/pulse/lib/bahulam-paths.ts +23 -0
  99. package/pulse/lib/claude-reader.ts +592 -0
  100. package/pulse/lib/decode.ts +129 -0
  101. package/pulse/lib/pricing.ts +102 -0
  102. package/pulse/lib/replay-parser.ts +165 -0
  103. package/pulse/lib/tool-categories.ts +127 -0
  104. package/pulse/lib/utils.ts +6 -0
  105. package/pulse/next-env.d.ts +6 -0
  106. package/pulse/next.config.ts +16 -0
  107. package/pulse/package.json +45 -0
  108. package/pulse/postcss.config.mjs +7 -0
  109. package/pulse/public/activity.png +0 -0
  110. package/pulse/public/cc-lens.png +0 -0
  111. package/pulse/public/command-k.png +0 -0
  112. package/pulse/public/costs.png +0 -0
  113. package/pulse/public/dashboard-dark.png +0 -0
  114. package/pulse/public/dashboard-white.png +0 -0
  115. package/pulse/public/export.png +0 -0
  116. package/pulse/public/file.svg +1 -0
  117. package/pulse/public/globe.svg +1 -0
  118. package/pulse/public/next.svg +1 -0
  119. package/pulse/public/projects.png +0 -0
  120. package/pulse/public/session-chat.png +0 -0
  121. package/pulse/public/todos.png +0 -0
  122. package/pulse/public/tools.png +0 -0
  123. package/pulse/public/vercel.svg +1 -0
  124. package/pulse/public/window.svg +1 -0
  125. package/pulse/tsconfig.json +34 -0
  126. package/pulse/types/claude.ts +294 -0
  127. package/src/agents/loader.mjs +94 -0
  128. package/src/agents/multi_workflow_loader.mjs +330 -0
  129. package/src/agents/parser.mjs +205 -0
  130. package/src/agents/scaffold.mjs +223 -0
  131. package/src/agents/teams.mjs +123 -0
  132. package/src/agents/workflow_loader.mjs +122 -0
  133. package/src/agents/workflow_scaffold.mjs +249 -0
  134. package/src/auth/oauth.mjs +220 -0
  135. package/src/auth/tarang-auth.mjs +312 -0
  136. package/src/commands/agent.mjs +221 -0
  137. package/src/commands/workflow.mjs +581 -0
  138. package/src/config/cli-args.mjs +202 -0
  139. package/src/config/env.mjs +263 -0
  140. package/src/config/hook-runner.mjs +100 -0
  141. package/src/config/memory-loader.mjs +32 -0
  142. package/src/config/model-catalog.mjs +57 -0
  143. package/src/config/settings-loader.mjs +45 -0
  144. package/src/config/settings.mjs +132 -0
  145. package/src/context/ast-parser.mjs +298 -0
  146. package/src/context/bm25.mjs +85 -0
  147. package/src/context/prose-chunker.mjs +255 -0
  148. package/src/context/retriever.mjs +425 -0
  149. package/src/context/skeleton.mjs +134 -0
  150. package/src/context/symbol-indexer.mjs +375 -0
  151. package/src/core/agent-history.mjs +111 -0
  152. package/src/core/agent-loop.mjs +486 -0
  153. package/src/core/approval-log.mjs +145 -0
  154. package/src/core/approval.mjs +700 -0
  155. package/src/core/attachments.mjs +666 -0
  156. package/src/core/backend-url.mjs +68 -0
  157. package/src/core/bundled-runtime.mjs +418 -0
  158. package/src/core/cache-control.mjs +92 -0
  159. package/src/core/cache.mjs +105 -0
  160. package/src/core/callback-client.mjs +180 -0
  161. package/src/core/checkpoints.mjs +142 -0
  162. package/src/core/compact-history.mjs +127 -0
  163. package/src/core/context-envelope.mjs +54 -0
  164. package/src/core/context-manager.mjs +198 -0
  165. package/src/core/error-guidance.mjs +331 -0
  166. package/src/core/file-diff.mjs +217 -0
  167. package/src/core/headless.mjs +460 -0
  168. package/src/core/hooks-manager.mjs +87 -0
  169. package/src/core/jsonl-writer.mjs +449 -0
  170. package/src/core/local-agent.mjs +538 -0
  171. package/src/core/local-store.mjs +836 -0
  172. package/src/core/mode-selector.mjs +51 -0
  173. package/src/core/output-filter.mjs +177 -0
  174. package/src/core/paths.mjs +190 -0
  175. package/src/core/policy-resolver.mjs +156 -0
  176. package/src/core/pricing.mjs +336 -0
  177. package/src/core/project-artifacts.mjs +39 -0
  178. package/src/core/project-context-loader.mjs +139 -0
  179. package/src/core/providers.mjs +219 -0
  180. package/src/core/rate-limit-display.mjs +121 -0
  181. package/src/core/rate-limiter.mjs +119 -0
  182. package/src/core/resume-mode.mjs +192 -0
  183. package/src/core/risk-tier.mjs +388 -0
  184. package/src/core/safety.mjs +260 -0
  185. package/src/core/scheduler.mjs +173 -0
  186. package/src/core/session-manager.mjs +360 -0
  187. package/src/core/session.mjs +143 -0
  188. package/src/core/settings-sync.mjs +85 -0
  189. package/src/core/stagnation.mjs +57 -0
  190. package/src/core/stream-client.mjs +957 -0
  191. package/src/core/streaming.mjs +182 -0
  192. package/src/core/system-prompt.mjs +140 -0
  193. package/src/core/tasks.mjs +196 -0
  194. package/src/core/tool-executor.mjs +2231 -0
  195. package/src/core/trust.mjs +160 -0
  196. package/src/core/work-scope.mjs +248 -0
  197. package/src/hooks/engine.mjs +162 -0
  198. package/src/mcp/client.mjs +253 -0
  199. package/src/mcp/transport-shttp.mjs +130 -0
  200. package/src/mcp/transport-sse.mjs +131 -0
  201. package/src/mcp/transport-ws.mjs +134 -0
  202. package/src/onboarding/preflight.mjs +374 -0
  203. package/src/permissions/checker.mjs +57 -0
  204. package/src/permissions/command-classifier.mjs +700 -0
  205. package/src/permissions/injection-check.mjs +60 -0
  206. package/src/permissions/path-check.mjs +102 -0
  207. package/src/permissions/prompt.mjs +73 -0
  208. package/src/permissions/sandbox.mjs +112 -0
  209. package/src/plugins/loader.mjs +138 -0
  210. package/src/skills/installer.mjs +188 -0
  211. package/src/skills/loader.mjs +252 -0
  212. package/src/skills/runner.mjs +55 -0
  213. package/src/state/orbit.mjs +263 -0
  214. package/src/state/verbosity.mjs +99 -0
  215. package/src/telemetry/index.mjs +122 -0
  216. package/src/terminal/agents.mjs +353 -0
  217. package/src/terminal/analytics.mjs +292 -0
  218. package/src/terminal/ansi.mjs +695 -0
  219. package/src/terminal/init.mjs +145 -0
  220. package/src/terminal/main.mjs +310 -0
  221. package/src/terminal/repl-ask-form.mjs +120 -0
  222. package/src/terminal/repl-explore.mjs +44 -0
  223. package/src/terminal/repl-format.mjs +317 -0
  224. package/src/terminal/repl-model-form.mjs +132 -0
  225. package/src/terminal/repl-render.mjs +833 -0
  226. package/src/terminal/repl-resume.mjs +640 -0
  227. package/src/terminal/repl-state.mjs +120 -0
  228. package/src/terminal/repl-utils.mjs +34 -0
  229. package/src/terminal/repl.mjs +5032 -0
  230. package/src/terminal/skills.mjs +54 -0
  231. package/src/terminal/tool-display.mjs +392 -0
  232. package/src/tools/agent.mjs +137 -0
  233. package/src/tools/ask-user.mjs +61 -0
  234. package/src/tools/bash.mjs +231 -0
  235. package/src/tools/cron-create.mjs +120 -0
  236. package/src/tools/cron-delete.mjs +49 -0
  237. package/src/tools/cron-list.mjs +37 -0
  238. package/src/tools/edit.mjs +82 -0
  239. package/src/tools/enter-worktree.mjs +69 -0
  240. package/src/tools/exit-worktree.mjs +57 -0
  241. package/src/tools/glob.mjs +117 -0
  242. package/src/tools/grep.mjs +129 -0
  243. package/src/tools/lint.mjs +71 -0
  244. package/src/tools/ls.mjs +58 -0
  245. package/src/tools/lsp.mjs +115 -0
  246. package/src/tools/multi-edit.mjs +94 -0
  247. package/src/tools/notebook-edit.mjs +96 -0
  248. package/src/tools/project-overview.mjs +703 -0
  249. package/src/tools/read-mcp-resource.mjs +57 -0
  250. package/src/tools/read.mjs +138 -0
  251. package/src/tools/registry.mjs +116 -0
  252. package/src/tools/remote-trigger.mjs +84 -0
  253. package/src/tools/send-message.mjs +64 -0
  254. package/src/tools/skill.mjs +52 -0
  255. package/src/tools/test-runner.mjs +49 -0
  256. package/src/tools/todo-write.mjs +68 -0
  257. package/src/tools/tool-search.mjs +77 -0
  258. package/src/tools/web-fetch.mjs +65 -0
  259. package/src/tools/web-search.mjs +89 -0
  260. package/src/tools/write.mjs +55 -0
  261. package/src/ui/approval.mjs +510 -0
  262. package/src/ui/banner.mjs +232 -0
  263. package/src/ui/commands.mjs +537 -0
  264. package/src/ui/formatter.mjs +409 -0
  265. package/src/ui/icons.mjs +170 -0
  266. package/src/ui/input-dock.mjs +772 -0
  267. package/src/ui/markdown.mjs +278 -0
  268. package/src/ui/mission-report.mjs +296 -0
  269. package/src/ui/palette.mjs +189 -0
  270. package/src/ui/render-queue.mjs +500 -0
  271. package/src/ui/slash-commands.mjs +257 -0
  272. package/src/ui/spinner.mjs +116 -0
  273. package/src/ui/sub-agent.mjs +167 -0
  274. package/src/ui/term.mjs +174 -0
  275. package/src/ui/text-layout.mjs +127 -0
  276. package/src/ui/tool-card.mjs +740 -0
  277. package/src/ui/tool-details.mjs +504 -0
  278. package/src/ui/transcript-block.mjs +20 -0
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Backend URL resolver — auto-detects the correct backend based on environment.
3
+ *
4
+ * Priority:
5
+ * 1. TARANG_BACKEND_URL env var (explicit override, for dev/admin testing)
6
+ * 2. TARANG_ENV or NODE_ENV → mapped to known URLs
7
+ * 3. Default: production
8
+ */
9
+
10
+ const BACKEND_URLS = {
11
+ // Four supported environments:
12
+ // local — Docker Compose backend on the developer's own machine.
13
+ // dev — Bahulam Cloud development (Azure Container Apps, eastus).
14
+ // production — Bahulam Cloud live (Azure Container Apps, centralus).
15
+ // bundled — CLI-local Python runtime spawned as a subprocess. URL is
16
+ // overridden by bundled-runtime.mjs at spawn time; sentinel
17
+ // value below is only used if the runtime isn't up yet.
18
+ local: 'http://127.0.0.1:8150',
19
+ dev: 'https://codekepler-backend-dev.kindisland-9034322d.eastus.azurecontainerapps.io',
20
+ production: 'https://api.bahulam.ai',
21
+ bundled: 'http://127.0.0.1:0', // sentinel — real URL comes from bundled-runtime.mjs
22
+ };
23
+
24
+ // Aliases (backwards compat + convenience)
25
+ BACKEND_URLS.prod = BACKEND_URLS.production;
26
+ BACKEND_URLS.treetop = BACKEND_URLS.dev; // legacy alias
27
+ BACKEND_URLS.docker = BACKEND_URLS.local; // convenience alias
28
+
29
+ const WEB_URLS = {
30
+ local: 'http://localhost:3100',
31
+ dev: 'https://treetop.bahulam.ai',
32
+ production: 'https://bahulam.ai',
33
+ bundled: 'http://localhost:3100', // bundled mode reuses local web if user runs it
34
+ };
35
+ WEB_URLS.prod = WEB_URLS.production;
36
+ WEB_URLS.treetop = WEB_URLS.dev; // legacy alias
37
+ WEB_URLS.docker = WEB_URLS.local;
38
+
39
+ /**
40
+ * Resolve the web dashboard URL from environment.
41
+ * @returns {string}
42
+ */
43
+ export function resolveWebUrl() {
44
+ if (process.env.TARANG_WEB_URL) {
45
+ return process.env.TARANG_WEB_URL.replace(/\/$/, '');
46
+ }
47
+ const env = (process.env.TARANG_ENV || process.env.NODE_ENV || 'production').toLowerCase();
48
+ return WEB_URLS[env] || WEB_URLS.production;
49
+ }
50
+
51
+ /**
52
+ * Resolve the backend URL from environment.
53
+ * @returns {string}
54
+ */
55
+ export function resolveBackendUrl() {
56
+ // 1. Explicit env var override (for dev/admin testing)
57
+ if (process.env.TARANG_BACKEND_URL) {
58
+ return process.env.TARANG_BACKEND_URL.replace(/\/$/, '');
59
+ }
60
+
61
+ // 2. Environment-based detection
62
+ const env = (process.env.TARANG_ENV || process.env.NODE_ENV || 'production').toLowerCase();
63
+ const url = BACKEND_URLS[env];
64
+ if (url) return url;
65
+
66
+ // 3. Fallback to production
67
+ return BACKEND_URLS.production;
68
+ }
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Bundled Python runtime adapter (PRD-091 §6.3).
3
+ *
4
+ * The Bahulam Code CLI ships with a bundled Python `agent_framework`
5
+ * runtime — the SAME code the cloud/enterprise backend runs. This module
6
+ * spawns that runtime as a subprocess, waits for READY, and provides a
7
+ * `fetch`-like helper for the rest of the CLI to talk to it over a Unix
8
+ * socket.
9
+ *
10
+ * Contract: the runtime speaks the same HTTP/SSE vocabulary the cloud
11
+ * backend does (POST /api/execute, POST /api/callback/{task_id}, POST
12
+ * /api/intervention/{task_id}, GET /healthz). So stream-client.mjs can
13
+ * swap between "local runtime" and "cloud backend" by changing the
14
+ * `baseUrl` — everything else stays identical.
15
+ *
16
+ * Lifecycle:
17
+ * 1. First call to ensureRuntimeReady() spawns bahulam-agent as a
18
+ * subprocess with a per-session Unix socket path.
19
+ * 2. Waits for /healthz to return {status:'ready'} (up to READY_TIMEOUT_MS).
20
+ * 3. Returns a `socketPath` the caller uses with runtimeFetch().
21
+ * 4. Subsequent calls reuse the warm daemon (no re-spawn cost).
22
+ * 5. Process exit tears down the subprocess cleanly.
23
+ *
24
+ * Fallbacks:
25
+ * - BAHULAM_JS_AGENT=1 → callers should bypass this module entirely
26
+ * and use the interim LocalAgent JS class.
27
+ * - BAHULAM_RUNTIME_ROOT=<path> → override the default install location
28
+ * (~/.bahulam/runtime/current) for dev/CI.
29
+ * - Windows: Unix sockets are not universal; falls back to a random
30
+ * localhost TCP port. The runtime supports --port for this reason.
31
+ *
32
+ * See PRD-090 §4a (Monetization Model) for the three-path routing that
33
+ * lives INSIDE the runtime; this adapter is transport-only.
34
+ */
35
+
36
+ import { spawn, spawnSync } from 'node:child_process';
37
+ import * as os from 'node:os';
38
+ import * as path from 'node:path';
39
+ import * as fs from 'node:fs';
40
+ import { randomBytes } from 'node:crypto';
41
+ import * as net from 'node:net';
42
+ import * as http from 'node:http';
43
+ import { fileURLToPath } from 'node:url';
44
+
45
+ const DEV_RUNTIME_ROOT = path.join(os.homedir(), '.bahulam', 'runtime', 'current');
46
+ const READY_TIMEOUT_MS = 30_000;
47
+ const READY_POLL_MS = 100;
48
+ const IS_WINDOWS = process.platform === 'win32';
49
+
50
+ // TCP is the default transport so existing fetch-based call sites in
51
+ // stream-client.mjs work unchanged (baseUrl points at http://127.0.0.1:<port>).
52
+ // Unix socket is available via BAHULAM_RUNTIME_TRANSPORT=socket for stricter
53
+ // isolation but requires an undici dispatcher path for every fetch.
54
+ const USE_UNIX_SOCKET = process.env.BAHULAM_RUNTIME_TRANSPORT === 'socket';
55
+
56
+ let _daemon = null; // { proc, socketPath, port, ready }
57
+
58
+ // Runtime resolution order (see RUNBOOK in codekepler-bahulam-runtime):
59
+ // 1. BAHULAM_RUNTIME_ROOT env — explicit override for dev/testing
60
+ // 2. Sibling optional dep — @bahulam/runtime-<platform>-<arch> installed
61
+ // alongside @bahulam/code via npm's optionalDependencies. This is the
62
+ // shipped path for end users. `os`/`cpu` fields in each runtime package
63
+ // ensure npm only installs the matching one.
64
+ // 3. Legacy dev tarball — ~/.bahulam/runtime/current/ — used by developers
65
+ // who built the runtime by hand before the npm packaging existed.
66
+ function _runtimeRoot() {
67
+ if (process.env.BAHULAM_RUNTIME_ROOT) return process.env.BAHULAM_RUNTIME_ROOT;
68
+
69
+ // Sibling optional dep: same package.json's node_modules/@bahulam/runtime-<plat>-<arch>.
70
+ // import.meta.url points at this file inside @bahulam/code, so its
71
+ // node_modules is two dirs up (@bahulam/code/src/core/ → @bahulam/code/ → @bahulam/).
72
+ const plat = process.platform; // 'darwin' | 'linux' | 'win32'
73
+ const arch = process.arch; // 'arm64' | 'x64' | ...
74
+ const siblingName = `@bahulam/runtime-${plat}-${arch}`;
75
+ try {
76
+ // fileURLToPath handles the Windows quirk where new URL(...).pathname
77
+ // returns '/C:/Users/...' with a leading slash — that broken path
78
+ // makes path.join produce '/C:/Users/.../node_modules/...' which
79
+ // fs.existsSync always returns false for, so the sibling walk fails
80
+ // silently and DEV_RUNTIME_ROOT is returned even when the runtime IS
81
+ // installed alongside. On POSIX fileURLToPath returns the plain
82
+ // pathname, so this is a no-op there.
83
+ const here = path.dirname(fileURLToPath(import.meta.url));
84
+ // Walk up looking for node_modules containing @bahulam/runtime-<plat>-<arch>
85
+ let dir = here;
86
+ for (let i = 0; i < 6; i++) {
87
+ const candidate = path.join(dir, 'node_modules', siblingName);
88
+ if (fs.existsSync(path.join(candidate, 'bin'))) {
89
+ return candidate;
90
+ }
91
+ const parent = path.dirname(dir);
92
+ if (parent === dir) break;
93
+ dir = parent;
94
+ }
95
+ } catch { /* fall through */ }
96
+
97
+ return DEV_RUNTIME_ROOT;
98
+ }
99
+
100
+ function _runtimeBin() {
101
+ const binDir = path.join(_runtimeRoot(), 'bin');
102
+ if (IS_WINDOWS) {
103
+ // Prefer whichever launcher ships in the Windows runtime package.
104
+ // Current build emits .cmd; keep .exe as a future option.
105
+ for (const name of ['bahulam-agent.cmd', 'bahulam-agent.exe', 'bahulam-agent.bat']) {
106
+ const p = path.join(binDir, name);
107
+ if (fs.existsSync(p)) return p;
108
+ }
109
+ return path.join(binDir, 'bahulam-agent.cmd');
110
+ }
111
+ return path.join(binDir, 'bahulam-agent');
112
+ }
113
+
114
+ // Read the framework LICENSE_KEY from a config file so shipped end-users
115
+ // don't have to set an env var. Precedence: env var wins if already set,
116
+ // otherwise read ~/.bahulam/license.jwt. Returns null when neither is set.
117
+ function _readLicenseKey() {
118
+ if (process.env.LICENSE_KEY) return process.env.LICENSE_KEY;
119
+ const licensePath = path.join(os.homedir(), '.bahulam', 'license.jwt');
120
+ try {
121
+ return fs.readFileSync(licensePath, 'utf8').trim() || null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ // Read the saved bahulam CLI token from ~/.bahulam/config.json so the
128
+ // bundled Python runtime authenticates to gateway.bahulam.ai as the
129
+ // logged-in user without the shell having to export BAHULAM_API_KEY.
130
+ // Precedence: shell env wins > saved config. Returns null when neither
131
+ // is present (framework then errors with an "Not logged in" style message).
132
+ function _readCliToken() {
133
+ if (process.env.BAHULAM_API_KEY) return process.env.BAHULAM_API_KEY;
134
+ if (process.env.BAHULAM_CLI_TOKEN) return process.env.BAHULAM_CLI_TOKEN;
135
+ if (process.env.B0_TOKEN) return process.env.B0_TOKEN;
136
+ const configPath = path.join(os.homedir(), '.bahulam', 'config.json');
137
+ try {
138
+ const raw = fs.readFileSync(configPath, 'utf8');
139
+ const parsed = JSON.parse(raw);
140
+ const token = (parsed && typeof parsed.token === 'string') ? parsed.token.trim() : null;
141
+ return token || null;
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ function _newSocketPath() {
148
+ const dir = path.join(os.tmpdir(), 'bahulam-code');
149
+ fs.mkdirSync(dir, { recursive: true });
150
+ return path.join(dir, `agent-${randomBytes(4).toString('hex')}.sock`);
151
+ }
152
+
153
+ // Remove the framework's license lock + keychain-shadow so activation
154
+ // re-runs fresh on the next import. All calls are best-effort — a missing
155
+ // file is fine, a locked keychain is fine.
156
+ function _clearLicenseActivationState() {
157
+ const paths = [
158
+ path.join(os.homedir(), '.agent_framework', '.license_lock'),
159
+ // macOS shadow file (see agent_framework/_lockfile.py:_shadow_path)
160
+ path.join(os.homedir(), 'Library', 'Caches', '.com.apple.dt.instruments', '.state'),
161
+ ];
162
+ for (const p of paths) {
163
+ try { fs.rmSync(p, { force: true }); } catch { /* best-effort */ }
164
+ }
165
+ if (process.platform === 'darwin') {
166
+ try {
167
+ spawnSync('security',
168
+ ['delete-generic-password', '-s', 'com.axplusb.agent.runtime', '-a', 'af_state'],
169
+ { stdio: 'ignore' });
170
+ } catch { /* best-effort */ }
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Health-probe the runtime over Unix socket or TCP.
176
+ * Returns the parsed JSON body or throws on non-2xx / non-JSON.
177
+ */
178
+ function _probeHealth({ socketPath, port }) {
179
+ return new Promise((resolve, reject) => {
180
+ const opts = { method: 'GET', path: '/healthz' };
181
+ if (socketPath) {
182
+ opts.socketPath = socketPath;
183
+ } else {
184
+ opts.host = '127.0.0.1';
185
+ opts.port = port;
186
+ }
187
+ const req = http.request(opts, (res) => {
188
+ let body = '';
189
+ res.setEncoding('utf8');
190
+ res.on('data', (chunk) => { body += chunk; });
191
+ res.on('end', () => {
192
+ if (res.statusCode !== 200) return reject(new Error(`/healthz HTTP ${res.statusCode}`));
193
+ try { resolve(JSON.parse(body)); }
194
+ catch (e) { reject(new Error(`/healthz body not JSON: ${e.message}`)); }
195
+ });
196
+ });
197
+ req.on('error', reject);
198
+ req.end();
199
+ });
200
+ }
201
+
202
+ /**
203
+ * Ensure the bundled runtime is running and healthy.
204
+ * Returns { baseUrl, socketPath, port } for the caller to construct
205
+ * fetch requests against.
206
+ */
207
+ export async function ensureRuntimeReady() {
208
+ if (_daemon && _daemon.ready) {
209
+ return _describeDaemon(_daemon);
210
+ }
211
+
212
+ const bin = _runtimeBin();
213
+ if (!fs.existsSync(bin)) {
214
+ throw new Error(
215
+ `Bahulam runtime not found at ${bin}. ` +
216
+ `Re-run \`npm install -g @bahulam/code\` to fetch the runtime, ` +
217
+ `or set BAHULAM_RUNTIME_ROOT to a valid install path.`,
218
+ );
219
+ }
220
+
221
+ // Transport: default to TCP on 127.0.0.1 so raw `fetch(baseUrl+path)`
222
+ // in stream-client.mjs works unchanged. Unix socket is available under
223
+ // BAHULAM_RUNTIME_TRANSPORT=socket, but callers must then route every
224
+ // request through runtimeFetch() (which uses an undici socket dispatcher).
225
+ // Windows has no Unix sockets so it's always TCP.
226
+ const useSocket = USE_UNIX_SOCKET && !IS_WINDOWS;
227
+ const socketPath = useSocket ? _newSocketPath() : null;
228
+ const port = useSocket ? null : await _pickFreePort();
229
+ const args = socketPath ? ['--socket', socketPath] : ['--port', String(port)];
230
+
231
+ const licenseKey = _readLicenseKey();
232
+ const cliToken = _readCliToken();
233
+ // Best-effort: clear stale license-activation state so a fresh env_id
234
+ // gets a fresh binding. Without this, restarts from a different socket
235
+ // or a bumped runtime version can trip the framework's activation guard.
236
+ _clearLicenseActivationState();
237
+ const proc = spawn(bin, args, {
238
+ stdio: ['ignore', 'pipe', 'pipe'],
239
+ env: {
240
+ ...process.env,
241
+ BAHULAM_CLI_LOCAL: '1',
242
+ BAHULAM_PRODUCT: 'bahulam',
243
+ LLM_GATEWAY: 'BahulamGateway',
244
+ BAHULAM_GATEWAY_URL: process.env.BAHULAM_GATEWAY_URL || 'https://gateway.bahulam.ai/v1',
245
+ // Framework validates LICENSE_KEY against the license portal on import.
246
+ // The portal is now embedded inside the Bahulam Gateway itself
247
+ // (see codekepler-bahulam-gateway/app/main.py mount at /portal), so
248
+ // the phone-home shares gateway uptime. Framework code (_license.py)
249
+ // reads LICENSE_PORTAL_URL and hits {url}/api/heartbeat.
250
+ LICENSE_PORTAL_URL: process.env.LICENSE_PORTAL_URL || 'https://gateway.bahulam.ai/portal',
251
+ // Framework requires LICENSE_KEY at import time. Sourced from
252
+ // ~/.bahulam/license.jwt so end users don't need to export a var.
253
+ ...(licenseKey ? { LICENSE_KEY: licenseKey } : {}),
254
+ // Bahulam CLI token from `bahulam login` (saved in
255
+ // ~/.bahulam/config.json). BahulamGateway reads it from any of
256
+ // BAHULAM_API_KEY / BAHULAM_CLI_TOKEN / BAHULAM_GATEWAY_API_KEY —
257
+ // we set both so it works whether the framework prefers one over
258
+ // the other. Shell env wins (see _readCliToken precedence).
259
+ ...(cliToken ? {
260
+ BAHULAM_API_KEY: cliToken,
261
+ BAHULAM_CLI_TOKEN: cliToken,
262
+ } : {}),
263
+ },
264
+ detached: false,
265
+ });
266
+
267
+ _daemon = { proc, socketPath, port, ready: false };
268
+
269
+ proc.on('exit', (code, signal) => {
270
+ if (_daemon && _daemon.proc === proc) {
271
+ _daemon.ready = false;
272
+ _daemon = null;
273
+ }
274
+ if (code !== 0 && code !== null) {
275
+ process.stderr.write(` ! bahulam-agent runtime exited (code ${code}, signal ${signal || 'none'})\n`);
276
+ }
277
+ });
278
+
279
+ // Surface stderr from the runtime — helps debug spawn / import errors.
280
+ proc.stderr.on('data', (chunk) => {
281
+ const text = chunk.toString('utf8').trim();
282
+ if (text && process.env.BAHULAM_RUNTIME_DEBUG) {
283
+ process.stderr.write(` [agent] ${text}\n`);
284
+ }
285
+ });
286
+
287
+ // Wait for /healthz to return ready.
288
+ const started = Date.now();
289
+ let lastErr = null;
290
+ while (Date.now() - started < READY_TIMEOUT_MS) {
291
+ if (proc.exitCode !== null) {
292
+ throw new Error(
293
+ `bahulam-agent exited before becoming ready (code ${proc.exitCode}). ` +
294
+ `Re-run with BAHULAM_RUNTIME_DEBUG=1 to see runtime stderr.`,
295
+ );
296
+ }
297
+ if (socketPath && !fs.existsSync(socketPath)) {
298
+ await _sleep(READY_POLL_MS);
299
+ continue;
300
+ }
301
+ try {
302
+ const health = await _probeHealth({ socketPath, port });
303
+ if (health && health.status === 'ready') {
304
+ _daemon.ready = true;
305
+ _daemon.frameworkVersion = health.framework;
306
+ _daemon.runtimeVersion = health.runtime_version;
307
+ return _describeDaemon(_daemon);
308
+ }
309
+ } catch (e) {
310
+ lastErr = e;
311
+ }
312
+ await _sleep(READY_POLL_MS);
313
+ }
314
+
315
+ // Timeout — kill and report.
316
+ try { proc.kill('SIGTERM'); } catch {}
317
+ _daemon = null;
318
+ throw new Error(
319
+ `bahulam-agent did not become ready within ${READY_TIMEOUT_MS / 1000}s ` +
320
+ `(last probe error: ${lastErr ? lastErr.message : 'none'}). ` +
321
+ `Try BAHULAM_RUNTIME_DEBUG=1 for runtime stderr.`,
322
+ );
323
+ }
324
+
325
+ function _describeDaemon(d) {
326
+ const baseUrl = d.socketPath ? 'http://localhost' : `http://127.0.0.1:${d.port}`;
327
+ return {
328
+ baseUrl,
329
+ socketPath: d.socketPath,
330
+ port: d.port,
331
+ frameworkVersion: d.frameworkVersion || null,
332
+ runtimeVersion: d.runtimeVersion || null,
333
+ };
334
+ }
335
+
336
+ /**
337
+ * Fetch against the running runtime. Same signature as global fetch —
338
+ * pass paths (e.g. '/api/execute') and the adapter routes to the socket
339
+ * or TCP port automatically.
340
+ */
341
+ export async function runtimeFetch(path, init = {}) {
342
+ const d = await ensureRuntimeReady();
343
+ if (d.socketPath) {
344
+ // Node's built-in fetch (undici) supports Unix sockets via dispatcher.
345
+ // Lazy-import undici so this file works even when the runtime is not
346
+ // installed (e.g., in test environments).
347
+ const { Agent } = await import('undici');
348
+ return fetch(`http://localhost${path}`, {
349
+ ...init,
350
+ // @ts-expect-error — undici extension not in the TS DOM lib.
351
+ dispatcher: new Agent({
352
+ connect: { socketPath: d.socketPath },
353
+ }),
354
+ });
355
+ }
356
+ return fetch(`${d.baseUrl}${path}`, init);
357
+ }
358
+
359
+ /** Cleanly stop the runtime subprocess. Called on process exit. */
360
+ export function shutdownRuntime() {
361
+ if (!_daemon) return;
362
+ const { proc, socketPath } = _daemon;
363
+ _daemon = null;
364
+ try { proc.kill('SIGTERM'); } catch {}
365
+ if (socketPath) {
366
+ try { fs.unlinkSync(socketPath); } catch {}
367
+ }
368
+ }
369
+
370
+ /** True when the bundled runtime binary is present. */
371
+ export function isRuntimeInstalled() {
372
+ return fs.existsSync(_runtimeBin());
373
+ }
374
+
375
+ /**
376
+ * Absolute path to the shipped model catalog snapshot inside the runtime
377
+ * bundle. Backend edits `app/services/model_catalog_snapshot.json` as the
378
+ * canonical source (admin dashboard publishes it to Supabase);
379
+ * stage-runtime-app.sh copies that same file into every runtime wheel at
380
+ * `runtime/app/services/model_catalog_snapshot.json`. The CLI reads from
381
+ * there so there is no third copy to keep in sync.
382
+ */
383
+ export function runtimeSnapshotPath() {
384
+ return path.join(_runtimeRoot(), 'runtime', 'app', 'services', 'model_catalog_snapshot.json');
385
+ }
386
+
387
+ /** Diagnostic snapshot for `bahulam doctor`. */
388
+ export function runtimeInfo() {
389
+ return {
390
+ installed: isRuntimeInstalled(),
391
+ root: _runtimeRoot(),
392
+ bin: _runtimeBin(),
393
+ running: !!(_daemon && _daemon.ready),
394
+ frameworkVersion: _daemon && _daemon.frameworkVersion,
395
+ runtimeVersion: _daemon && _daemon.runtimeVersion,
396
+ transport: _daemon && (_daemon.socketPath ? 'unix' : 'tcp'),
397
+ };
398
+ }
399
+
400
+ function _sleep(ms) {
401
+ return new Promise((resolve) => setTimeout(resolve, ms));
402
+ }
403
+
404
+ function _pickFreePort() {
405
+ return new Promise((resolve, reject) => {
406
+ const srv = net.createServer();
407
+ srv.listen(0, '127.0.0.1', () => {
408
+ const { port } = srv.address();
409
+ srv.close(() => resolve(port));
410
+ });
411
+ srv.on('error', reject);
412
+ });
413
+ }
414
+
415
+ // Clean shutdown on Node process exit.
416
+ process.on('exit', shutdownRuntime);
417
+ process.on('SIGINT', () => { shutdownRuntime(); process.exit(130); });
418
+ process.on('SIGTERM', () => { shutdownRuntime(); process.exit(143); });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * cache_control breakpoints for Anthropic prompt caching (PRD-071 Phase 2).
3
+ *
4
+ * Shared by every direct-API call site that talks to Anthropic — LocalAgent
5
+ * (Anthropic direct + OpenRouter passthrough) and agent-loop's Task sub-agents.
6
+ *
7
+ * Anthropic allows 4 breakpoints per request; we spend 3:
8
+ * 1) End of system prompt (1h TTL — persistent across long-idle sessions)
9
+ * 2) Last tool schema (1h TTL — persistent)
10
+ * 3) Second-to-last user message (5min TTL — rolls each turn, cheaper to write)
11
+ *
12
+ * The 4th slot stays reserved (attachments, future retrieval prefix).
13
+ *
14
+ * Extended 1-hour TTL is a beta — pass ANTHROPIC_BETA_HEADER on the request
15
+ * whenever any block carries ttl:'1h'.
16
+ */
17
+
18
+ export const ANTHROPIC_BETA_HEADER = 'extended-cache-ttl-2025-04-11';
19
+
20
+ const CACHE_1H = { type: 'ephemeral', ttl: '1h' };
21
+ const CACHE_5M = { type: 'ephemeral' };
22
+
23
+ /**
24
+ * Turn a `system` string into a content-block array with a cache_control
25
+ * breakpoint on the tail. If the caller already passed blocks, returns them
26
+ * unchanged. Undefined / non-string inputs pass through as-is.
27
+ */
28
+ export function cacheableSystem(systemPrompt) {
29
+ if (Array.isArray(systemPrompt)) return systemPrompt;
30
+ if (!systemPrompt || typeof systemPrompt !== 'string') return systemPrompt;
31
+ return [{ type: 'text', text: systemPrompt, cache_control: CACHE_1H }];
32
+ }
33
+
34
+ /**
35
+ * Return a copy of `tools` with cache_control on the LAST tool. Anthropic
36
+ * caches system + tools as one prefix from that breakpoint, so this single
37
+ * marker covers the whole tool schema regardless of length.
38
+ */
39
+ export function cacheableTools(tools) {
40
+ if (!Array.isArray(tools) || tools.length === 0) return tools || [];
41
+ const out = tools.slice();
42
+ const last = out[out.length - 1];
43
+ out[out.length - 1] = { ...last, cache_control: CACHE_1H };
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * Tag the SECOND-to-last user message with a 5-min cache_control breakpoint.
49
+ * Leaves the last turn write-through so the next round extends the cache
50
+ * instead of re-writing it. Returns messages unchanged when there aren't
51
+ * enough user turns yet (< 2).
52
+ *
53
+ * Handles both content shapes: string (wrapped into blocks) and block array
54
+ * (tagged on the last block).
55
+ */
56
+ export function withMessageBreakpoint(messages) {
57
+ if (!Array.isArray(messages) || messages.length < 2) return messages;
58
+ const userIdx = [];
59
+ for (let i = 0; i < messages.length; i++) {
60
+ if (messages[i].role === 'user') userIdx.push(i);
61
+ }
62
+ if (userIdx.length < 2) return messages;
63
+ const targetIdx = userIdx[userIdx.length - 2];
64
+ const msg = messages[targetIdx];
65
+
66
+ if (typeof msg.content === 'string') {
67
+ return messages.map((m, i) => i === targetIdx ? {
68
+ ...m,
69
+ content: [{ type: 'text', text: m.content, cache_control: CACHE_5M }],
70
+ } : m);
71
+ }
72
+
73
+ if (Array.isArray(msg.content) && msg.content.length > 0) {
74
+ const blocks = msg.content.slice();
75
+ const last = blocks[blocks.length - 1];
76
+ blocks[blocks.length - 1] = { ...last, cache_control: CACHE_5M };
77
+ return messages.map((m, i) => i === targetIdx ? { ...m, content: blocks } : m);
78
+ }
79
+
80
+ return messages;
81
+ }
82
+
83
+ /**
84
+ * True if the given model id needs Anthropic-style explicit cache_control
85
+ * (vs OpenAI/DeepSeek which auto-cache). Handles bare Claude ids and
86
+ * OpenRouter's `anthropic/*` prefix.
87
+ */
88
+ export function needsExplicitCacheControl(model) {
89
+ if (!model) return false;
90
+ const m = model.toLowerCase();
91
+ return m.startsWith('claude') || m.startsWith('anthropic/');
92
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Prompt Caching — implements cache_control for Anthropic API.
3
+ *
4
+ * Adds cache_control: { type: "ephemeral" } to system prompt blocks
5
+ * that are static (like CLAUDE.md content), allowing the API to
6
+ * cache them and reduce input token costs.
7
+ *
8
+ * Tracks cache_read_tokens and cache_creation_tokens.
9
+ */
10
+
11
+ export class PromptCache {
12
+ constructor() {
13
+ this.stats = {
14
+ cacheCreationTokens: 0,
15
+ cacheReadTokens: 0,
16
+ totalRequests: 0,
17
+ cacheHits: 0,
18
+ cacheMisses: 0,
19
+ };
20
+ }
21
+
22
+ /**
23
+ * Apply cache control to system prompt blocks.
24
+ * Static content (CLAUDE.md, tool definitions) gets ephemeral cache markers.
25
+ *
26
+ * @param {string|Array} systemPrompt - system prompt content
27
+ * @returns {Array} system prompt blocks with cache_control
28
+ */
29
+ applyCacheControl(systemPrompt) {
30
+ if (typeof systemPrompt === 'string') {
31
+ return [
32
+ {
33
+ type: 'text',
34
+ text: systemPrompt,
35
+ cache_control: { type: 'ephemeral' },
36
+ },
37
+ ];
38
+ }
39
+
40
+ if (Array.isArray(systemPrompt)) {
41
+ return systemPrompt.map((block, i) => {
42
+ if (typeof block === 'string') {
43
+ return {
44
+ type: 'text',
45
+ text: block,
46
+ cache_control: { type: 'ephemeral' },
47
+ };
48
+ }
49
+ // Only cache the first block (usually CLAUDE.md) and tool defs
50
+ if (i === 0 || block.cacheable) {
51
+ return { ...block, cache_control: { type: 'ephemeral' } };
52
+ }
53
+ return block;
54
+ });
55
+ }
56
+
57
+ return systemPrompt;
58
+ }
59
+
60
+ /**
61
+ * Update cache stats from API response usage data.
62
+ * @param {object} usage - API response usage object
63
+ */
64
+ updateStats(usage) {
65
+ this.stats.totalRequests++;
66
+ if (usage) {
67
+ if (usage.cache_creation_input_tokens) {
68
+ this.stats.cacheCreationTokens += usage.cache_creation_input_tokens;
69
+ this.stats.cacheMisses++;
70
+ }
71
+ if (usage.cache_read_input_tokens) {
72
+ this.stats.cacheReadTokens += usage.cache_read_input_tokens;
73
+ this.stats.cacheHits++;
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Get cache efficiency stats.
80
+ */
81
+ getStats() {
82
+ const hitRate = this.stats.totalRequests > 0
83
+ ? ((this.stats.cacheHits / this.stats.totalRequests) * 100).toFixed(1)
84
+ : '0.0';
85
+
86
+ return {
87
+ ...this.stats,
88
+ hitRate: `${hitRate}%`,
89
+ tokensSaved: this.stats.cacheReadTokens,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Reset stats.
95
+ */
96
+ reset() {
97
+ this.stats = {
98
+ cacheCreationTokens: 0,
99
+ cacheReadTokens: 0,
100
+ totalRequests: 0,
101
+ cacheHits: 0,
102
+ cacheMisses: 0,
103
+ };
104
+ }
105
+ }