@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.
- package/README.md +93 -0
- package/package.json +56 -0
- package/pulse/app/activity/page.tsx +190 -0
- package/pulse/app/api/activity/route.ts +138 -0
- package/pulse/app/api/benchmark/route.ts +113 -0
- package/pulse/app/api/benchmarks/route.ts +195 -0
- package/pulse/app/api/costs/route.ts +88 -0
- package/pulse/app/api/export/route.ts +77 -0
- package/pulse/app/api/history/route.ts +11 -0
- package/pulse/app/api/import/route.ts +31 -0
- package/pulse/app/api/memory/route.ts +50 -0
- package/pulse/app/api/plans/route.ts +9 -0
- package/pulse/app/api/projects/[slug]/route.ts +96 -0
- package/pulse/app/api/projects/route.ts +121 -0
- package/pulse/app/api/sessions/[id]/replay/route.ts +20 -0
- package/pulse/app/api/sessions/[id]/route.ts +31 -0
- package/pulse/app/api/sessions/route.ts +112 -0
- package/pulse/app/api/settings/route.ts +14 -0
- package/pulse/app/api/stats/route.ts +143 -0
- package/pulse/app/api/todos/route.ts +9 -0
- package/pulse/app/api/tools/route.ts +160 -0
- package/pulse/app/benchmarks/page.tsx +224 -0
- package/pulse/app/costs/page.tsx +179 -0
- package/pulse/app/export/page.tsx +465 -0
- package/pulse/app/favicon.ico +0 -0
- package/pulse/app/globals.css +263 -0
- package/pulse/app/help/page.tsx +143 -0
- package/pulse/app/history/page.tsx +157 -0
- package/pulse/app/layout.tsx +46 -0
- package/pulse/app/memory/page.tsx +365 -0
- package/pulse/app/overview-client.tsx +393 -0
- package/pulse/app/page.tsx +14 -0
- package/pulse/app/plans/page.tsx +308 -0
- package/pulse/app/projects/[slug]/page.tsx +390 -0
- package/pulse/app/projects/page.tsx +110 -0
- package/pulse/app/sessions/[id]/page.tsx +243 -0
- package/pulse/app/sessions/page.tsx +39 -0
- package/pulse/app/settings/page.tsx +188 -0
- package/pulse/app/todos/page.tsx +211 -0
- package/pulse/app/tools/page.tsx +249 -0
- package/pulse/cli.js +164 -0
- package/pulse/components/activity/day-of-week-chart.tsx +35 -0
- package/pulse/components/activity/streak-card.tsx +36 -0
- package/pulse/components/costs/cache-efficiency-panel.tsx +76 -0
- package/pulse/components/costs/cost-by-project-chart.tsx +48 -0
- package/pulse/components/costs/cost-over-time-chart.tsx +95 -0
- package/pulse/components/costs/model-token-table.tsx +60 -0
- package/pulse/components/global-search.tsx +193 -0
- package/pulse/components/keyboard-nav-provider.tsx +23 -0
- package/pulse/components/layout/bottom-nav.tsx +53 -0
- package/pulse/components/layout/client-layout.tsx +31 -0
- package/pulse/components/layout/sidebar-context.tsx +50 -0
- package/pulse/components/layout/sidebar.tsx +183 -0
- package/pulse/components/layout/top-bar.tsx +121 -0
- package/pulse/components/overview/activity-heatmap.tsx +107 -0
- package/pulse/components/overview/conversation-table.tsx +148 -0
- package/pulse/components/overview/model-breakdown-donut.tsx +95 -0
- package/pulse/components/overview/peak-hours-chart.tsx +87 -0
- package/pulse/components/overview/project-activity-donut.tsx +96 -0
- package/pulse/components/overview/stat-card.tsx +102 -0
- package/pulse/components/overview/usage-over-time-chart.tsx +166 -0
- package/pulse/components/projects/project-card.tsx +175 -0
- package/pulse/components/sessions/replay/assistant-markdown.tsx +94 -0
- package/pulse/components/sessions/replay/compaction-card.tsx +25 -0
- package/pulse/components/sessions/replay/session-sidebar.tsx +231 -0
- package/pulse/components/sessions/replay/token-accumulation-chart.tsx +98 -0
- package/pulse/components/sessions/replay/tool-call-badge.tsx +127 -0
- package/pulse/components/sessions/replay/turn-cards.tsx +220 -0
- package/pulse/components/sessions/replay/user-tool-result.tsx +158 -0
- package/pulse/components/sessions/session-badges.tsx +49 -0
- package/pulse/components/sessions/session-table.tsx +299 -0
- package/pulse/components/theme-provider.tsx +44 -0
- package/pulse/components/tools/feature-adoption-table.tsx +58 -0
- package/pulse/components/tools/mcp-server-panel.tsx +45 -0
- package/pulse/components/tools/tool-ranking-chart.tsx +57 -0
- package/pulse/components/tools/version-history-table.tsx +32 -0
- package/pulse/components/ui/alert.tsx +66 -0
- package/pulse/components/ui/badge.tsx +48 -0
- package/pulse/components/ui/breadcrumb.tsx +109 -0
- package/pulse/components/ui/button.tsx +64 -0
- package/pulse/components/ui/calendar.tsx +220 -0
- package/pulse/components/ui/card.tsx +92 -0
- package/pulse/components/ui/command.tsx +158 -0
- package/pulse/components/ui/dialog.tsx +158 -0
- package/pulse/components/ui/input.tsx +21 -0
- package/pulse/components/ui/popover.tsx +89 -0
- package/pulse/components/ui/progress.tsx +31 -0
- package/pulse/components/ui/select.tsx +190 -0
- package/pulse/components/ui/separator.tsx +28 -0
- package/pulse/components/ui/sheet.tsx +143 -0
- package/pulse/components/ui/skeleton.tsx +13 -0
- package/pulse/components/ui/table.tsx +116 -0
- package/pulse/components/ui/tabs.tsx +91 -0
- package/pulse/components/ui/tooltip.tsx +57 -0
- package/pulse/components/use-global-keyboard-nav.ts +79 -0
- package/pulse/components.json +23 -0
- package/pulse/eslint.config.mjs +18 -0
- package/pulse/lib/bahulam-paths.ts +23 -0
- package/pulse/lib/claude-reader.ts +592 -0
- package/pulse/lib/decode.ts +129 -0
- package/pulse/lib/pricing.ts +102 -0
- package/pulse/lib/replay-parser.ts +165 -0
- package/pulse/lib/tool-categories.ts +127 -0
- package/pulse/lib/utils.ts +6 -0
- package/pulse/next-env.d.ts +6 -0
- package/pulse/next.config.ts +16 -0
- package/pulse/package.json +45 -0
- package/pulse/postcss.config.mjs +7 -0
- package/pulse/public/activity.png +0 -0
- package/pulse/public/cc-lens.png +0 -0
- package/pulse/public/command-k.png +0 -0
- package/pulse/public/costs.png +0 -0
- package/pulse/public/dashboard-dark.png +0 -0
- package/pulse/public/dashboard-white.png +0 -0
- package/pulse/public/export.png +0 -0
- package/pulse/public/file.svg +1 -0
- package/pulse/public/globe.svg +1 -0
- package/pulse/public/next.svg +1 -0
- package/pulse/public/projects.png +0 -0
- package/pulse/public/session-chat.png +0 -0
- package/pulse/public/todos.png +0 -0
- package/pulse/public/tools.png +0 -0
- package/pulse/public/vercel.svg +1 -0
- package/pulse/public/window.svg +1 -0
- package/pulse/tsconfig.json +34 -0
- package/pulse/types/claude.ts +294 -0
- package/src/agents/loader.mjs +94 -0
- package/src/agents/multi_workflow_loader.mjs +330 -0
- package/src/agents/parser.mjs +205 -0
- package/src/agents/scaffold.mjs +223 -0
- package/src/agents/teams.mjs +123 -0
- package/src/agents/workflow_loader.mjs +122 -0
- package/src/agents/workflow_scaffold.mjs +249 -0
- package/src/auth/oauth.mjs +220 -0
- package/src/auth/tarang-auth.mjs +312 -0
- package/src/commands/agent.mjs +221 -0
- package/src/commands/workflow.mjs +581 -0
- package/src/config/cli-args.mjs +202 -0
- package/src/config/env.mjs +263 -0
- package/src/config/hook-runner.mjs +100 -0
- package/src/config/memory-loader.mjs +32 -0
- package/src/config/model-catalog.mjs +57 -0
- package/src/config/settings-loader.mjs +45 -0
- package/src/config/settings.mjs +132 -0
- package/src/context/ast-parser.mjs +298 -0
- package/src/context/bm25.mjs +85 -0
- package/src/context/prose-chunker.mjs +255 -0
- package/src/context/retriever.mjs +425 -0
- package/src/context/skeleton.mjs +134 -0
- package/src/context/symbol-indexer.mjs +375 -0
- package/src/core/agent-history.mjs +111 -0
- package/src/core/agent-loop.mjs +486 -0
- package/src/core/approval-log.mjs +145 -0
- package/src/core/approval.mjs +700 -0
- package/src/core/attachments.mjs +666 -0
- package/src/core/backend-url.mjs +68 -0
- package/src/core/bundled-runtime.mjs +418 -0
- package/src/core/cache-control.mjs +92 -0
- package/src/core/cache.mjs +105 -0
- package/src/core/callback-client.mjs +180 -0
- package/src/core/checkpoints.mjs +142 -0
- package/src/core/compact-history.mjs +127 -0
- package/src/core/context-envelope.mjs +54 -0
- package/src/core/context-manager.mjs +198 -0
- package/src/core/error-guidance.mjs +331 -0
- package/src/core/file-diff.mjs +217 -0
- package/src/core/headless.mjs +460 -0
- package/src/core/hooks-manager.mjs +87 -0
- package/src/core/jsonl-writer.mjs +449 -0
- package/src/core/local-agent.mjs +538 -0
- package/src/core/local-store.mjs +836 -0
- package/src/core/mode-selector.mjs +51 -0
- package/src/core/output-filter.mjs +177 -0
- package/src/core/paths.mjs +190 -0
- package/src/core/policy-resolver.mjs +156 -0
- package/src/core/pricing.mjs +336 -0
- package/src/core/project-artifacts.mjs +39 -0
- package/src/core/project-context-loader.mjs +139 -0
- package/src/core/providers.mjs +219 -0
- package/src/core/rate-limit-display.mjs +121 -0
- package/src/core/rate-limiter.mjs +119 -0
- package/src/core/resume-mode.mjs +192 -0
- package/src/core/risk-tier.mjs +388 -0
- package/src/core/safety.mjs +260 -0
- package/src/core/scheduler.mjs +173 -0
- package/src/core/session-manager.mjs +360 -0
- package/src/core/session.mjs +143 -0
- package/src/core/settings-sync.mjs +85 -0
- package/src/core/stagnation.mjs +57 -0
- package/src/core/stream-client.mjs +957 -0
- package/src/core/streaming.mjs +182 -0
- package/src/core/system-prompt.mjs +140 -0
- package/src/core/tasks.mjs +196 -0
- package/src/core/tool-executor.mjs +2231 -0
- package/src/core/trust.mjs +160 -0
- package/src/core/work-scope.mjs +248 -0
- package/src/hooks/engine.mjs +162 -0
- package/src/mcp/client.mjs +253 -0
- package/src/mcp/transport-shttp.mjs +130 -0
- package/src/mcp/transport-sse.mjs +131 -0
- package/src/mcp/transport-ws.mjs +134 -0
- package/src/onboarding/preflight.mjs +374 -0
- package/src/permissions/checker.mjs +57 -0
- package/src/permissions/command-classifier.mjs +700 -0
- package/src/permissions/injection-check.mjs +60 -0
- package/src/permissions/path-check.mjs +102 -0
- package/src/permissions/prompt.mjs +73 -0
- package/src/permissions/sandbox.mjs +112 -0
- package/src/plugins/loader.mjs +138 -0
- package/src/skills/installer.mjs +188 -0
- package/src/skills/loader.mjs +252 -0
- package/src/skills/runner.mjs +55 -0
- package/src/state/orbit.mjs +263 -0
- package/src/state/verbosity.mjs +99 -0
- package/src/telemetry/index.mjs +122 -0
- package/src/terminal/agents.mjs +353 -0
- package/src/terminal/analytics.mjs +292 -0
- package/src/terminal/ansi.mjs +695 -0
- package/src/terminal/init.mjs +145 -0
- package/src/terminal/main.mjs +310 -0
- package/src/terminal/repl-ask-form.mjs +120 -0
- package/src/terminal/repl-explore.mjs +44 -0
- package/src/terminal/repl-format.mjs +317 -0
- package/src/terminal/repl-model-form.mjs +132 -0
- package/src/terminal/repl-render.mjs +833 -0
- package/src/terminal/repl-resume.mjs +640 -0
- package/src/terminal/repl-state.mjs +120 -0
- package/src/terminal/repl-utils.mjs +34 -0
- package/src/terminal/repl.mjs +5032 -0
- package/src/terminal/skills.mjs +54 -0
- package/src/terminal/tool-display.mjs +392 -0
- package/src/tools/agent.mjs +137 -0
- package/src/tools/ask-user.mjs +61 -0
- package/src/tools/bash.mjs +231 -0
- package/src/tools/cron-create.mjs +120 -0
- package/src/tools/cron-delete.mjs +49 -0
- package/src/tools/cron-list.mjs +37 -0
- package/src/tools/edit.mjs +82 -0
- package/src/tools/enter-worktree.mjs +69 -0
- package/src/tools/exit-worktree.mjs +57 -0
- package/src/tools/glob.mjs +117 -0
- package/src/tools/grep.mjs +129 -0
- package/src/tools/lint.mjs +71 -0
- package/src/tools/ls.mjs +58 -0
- package/src/tools/lsp.mjs +115 -0
- package/src/tools/multi-edit.mjs +94 -0
- package/src/tools/notebook-edit.mjs +96 -0
- package/src/tools/project-overview.mjs +703 -0
- package/src/tools/read-mcp-resource.mjs +57 -0
- package/src/tools/read.mjs +138 -0
- package/src/tools/registry.mjs +116 -0
- package/src/tools/remote-trigger.mjs +84 -0
- package/src/tools/send-message.mjs +64 -0
- package/src/tools/skill.mjs +52 -0
- package/src/tools/test-runner.mjs +49 -0
- package/src/tools/todo-write.mjs +68 -0
- package/src/tools/tool-search.mjs +77 -0
- package/src/tools/web-fetch.mjs +65 -0
- package/src/tools/web-search.mjs +89 -0
- package/src/tools/write.mjs +55 -0
- package/src/ui/approval.mjs +510 -0
- package/src/ui/banner.mjs +232 -0
- package/src/ui/commands.mjs +537 -0
- package/src/ui/formatter.mjs +409 -0
- package/src/ui/icons.mjs +170 -0
- package/src/ui/input-dock.mjs +772 -0
- package/src/ui/markdown.mjs +278 -0
- package/src/ui/mission-report.mjs +296 -0
- package/src/ui/palette.mjs +189 -0
- package/src/ui/render-queue.mjs +500 -0
- package/src/ui/slash-commands.mjs +257 -0
- package/src/ui/spinner.mjs +116 -0
- package/src/ui/sub-agent.mjs +167 -0
- package/src/ui/term.mjs +174 -0
- package/src/ui/text-layout.mjs +127 -0
- package/src/ui/tool-card.mjs +740 -0
- package/src/ui/tool-details.mjs +504 -0
- 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
|
+
}
|