@bahulam/code 2.6.15 → 2.6.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bahulam/code",
3
- "version": "2.6.15",
3
+ "version": "2.6.17",
4
4
  "description": "Bahulam Code \u2014 abundance, in your terminal. CLI-first, reliability-first, sub-agents, 65.6% SWE-bench Verified.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,7 @@
14
14
  ],
15
15
  "scripts": {
16
16
  "start": "node src/terminal/main.mjs",
17
- "test": "BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-backend-url.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-agent-history.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-sse-client.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-tool-executor.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-project-artifacts.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-work-scope.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-skills.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-callback.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-rate-limit-display.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-preflight.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-formatter.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-terminal-rendering.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-input-dock.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-live-steering-client.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-slash-commands.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-approval.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-approval-log.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-kepler-contract.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-session-manager.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-safety.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-jsonl-writer.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-analytics.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-stagnation.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-attachments.mjs"
17
+ "test": "BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-backend-url.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-agent-history.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-sse-client.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-tool-executor.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-project-artifacts.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-work-scope.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-skills.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-callback.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-rate-limit-display.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-preflight.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-formatter.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-terminal-rendering.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-render-queue.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-input-dock.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-live-steering-client.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-slash-commands.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-approval.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-approval-log.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-kepler-contract.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-session-manager.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-safety.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-jsonl-writer.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-analytics.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-stagnation.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-attachments.mjs"
18
18
  },
19
19
  "engines": {
20
20
  "node": ">=18.0.0"
@@ -49,8 +49,8 @@
49
49
  "web-tree-sitter": "^0.26.9"
50
50
  },
51
51
  "optionalDependencies": {
52
- "@bahulam/runtime-darwin-arm64": "2.6.15",
53
- "@bahulam/runtime-linux-x64": "2.6.15",
54
- "@bahulam/runtime-win32-x64": "2.6.15"
52
+ "@bahulam/runtime-darwin-arm64": "2.6.17",
53
+ "@bahulam/runtime-linux-x64": "2.6.17",
54
+ "@bahulam/runtime-win32-x64": "2.6.17"
55
55
  }
56
56
  }
@@ -76,6 +76,12 @@ export class TarangAuth {
76
76
  models: this._config.models || {},
77
77
  configuredProviders: this._config.configured_providers || [],
78
78
  gatewayConfig: this._config.gateway_config || {},
79
+ // PRD-076 W7: persisted /model picks. `modelConfig` mirrors the
80
+ // Python-side key read_local_model_config() looks for; keep the
81
+ // snake_case JSON key so the runtime picks it up unchanged.
82
+ modelConfig: this._config.model_config || {},
83
+ modelMode: this._config.model_mode || null,
84
+ routePreference: this._config.route_preference || null,
79
85
  };
80
86
  }
81
87
 
@@ -29,6 +29,7 @@ export function parseArgs(args) {
29
29
  timeout: null,
30
30
  allowedTools: null,
31
31
  disallowedTools: null,
32
+ route: null,
32
33
  resume: false,
33
34
  resumeSessionId: null,
34
35
  headless: false,
@@ -49,6 +50,10 @@ export function parseArgs(args) {
49
50
  result.model = args[++i];
50
51
  break;
51
52
 
53
+ case '--route':
54
+ result.route = args[++i];
55
+ break;
56
+
52
57
  case '--permission-mode':
53
58
  result.permissionMode = args[++i];
54
59
  break;
@@ -169,7 +174,8 @@ export function getUsageText() {
169
174
  Usage: occ [options] [prompt]
170
175
 
171
176
  Options:
172
- --model, -m <model> Model to use (default: claude-sonnet-4-6)
177
+ --model, -m <id|role=id> Session model override (also named modes: fast|thinking|extra|max)
178
+ --route <platform|byok> Model route; platform validates against the curated catalog
173
179
  --permission-mode <mode> Permission mode (bypassPermissions, acceptEdits, plan, auto, dontAsk)
174
180
  --print, -p <prompt> Non-interactive mode: run prompt and exit
175
181
  --output-format <fmt> Output format: text, json, stream-json
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Shipped model catalog snapshot — Node side of PRD-076 W7.
3
+ *
4
+ * The canonical source is `codekepler-backend/app/services/
5
+ * model_catalog_snapshot.json` (admin dashboard edits it and publishes to
6
+ * Supabase). `stage-runtime-app.sh` copies that file into every runtime
7
+ * wheel at `runtime/app/services/model_catalog_snapshot.json`, so when
8
+ * `@bahulam/runtime-<platform>-<arch>` is installed as a sibling optional
9
+ * dep of `@bahulam/code`, the file is already on disk — we read from
10
+ * there instead of maintaining a third copy in the CLI package.
11
+ */
12
+
13
+ import * as fs from 'node:fs';
14
+ import { runtimeSnapshotPath } from '../core/bundled-runtime.mjs';
15
+
16
+ let _cache = null;
17
+
18
+ // Snapshot rows are camelCase (matches how the backend delivers the
19
+ // static file); the /api/models response is snake_case. Normalize once
20
+ // here so downstream consumers can treat both sources identically.
21
+ function normalizeSnapshotRow(row) {
22
+ const id = row.value || row.id;
23
+ if (!id) return null;
24
+ return {
25
+ id,
26
+ provider: row.provider || (id.includes('/') ? id.split('/', 1)[0] : 'unknown'),
27
+ label: row.label || id,
28
+ input_cost_usd_per_m: row.inputCost ?? row.input_cost_usd_per_m ?? null,
29
+ output_cost_usd_per_m: row.outputCost ?? row.output_cost_usd_per_m ?? null,
30
+ context_length: row.context ?? row.context_length ?? null,
31
+ max_output: row.maxOutput ?? row.max_output ?? null,
32
+ supports_tools: row.supportsTools ?? row.supports_tools ?? false,
33
+ supports_reasoning: row.supportsReasoning ?? row.supports_reasoning ?? false,
34
+ harness_validated: row.harnessValidated ?? row.harness_validated ?? false,
35
+ cache_profile: row.cacheProfile ?? row.cache_profile ?? null,
36
+ platform_access_tier: row.platformAccessTier ?? row.platform_access_tier ?? [],
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Read the shipped catalog snapshot from the installed runtime bundle.
42
+ * Returns an array of normalized rows (API-shape) or null if the runtime
43
+ * isn't installed / the file is unreadable. Cached after first success.
44
+ */
45
+ export function readShippedCatalog() {
46
+ if (_cache !== null) return _cache;
47
+ try {
48
+ const raw = fs.readFileSync(runtimeSnapshotPath(), 'utf-8');
49
+ const data = JSON.parse(raw);
50
+ const rows = Array.isArray(data?.models) ? data.models : [];
51
+ _cache = rows.map(normalizeSnapshotRow).filter(Boolean);
52
+ return _cache;
53
+ } catch {
54
+ _cache = null;
55
+ return null;
56
+ }
57
+ }
@@ -364,6 +364,18 @@ export function isRuntimeInstalled() {
364
364
  return fs.existsSync(_runtimeBin());
365
365
  }
366
366
 
367
+ /**
368
+ * Absolute path to the shipped model catalog snapshot inside the runtime
369
+ * bundle. Backend edits `app/services/model_catalog_snapshot.json` as the
370
+ * canonical source (admin dashboard publishes it to Supabase);
371
+ * stage-runtime-app.sh copies that same file into every runtime wheel at
372
+ * `runtime/app/services/model_catalog_snapshot.json`. The CLI reads from
373
+ * there so there is no third copy to keep in sync.
374
+ */
375
+ export function runtimeSnapshotPath() {
376
+ return path.join(_runtimeRoot(), 'runtime', 'app', 'services', 'model_catalog_snapshot.json');
377
+ }
378
+
367
379
  /** Diagnostic snapshot for `bahulam doctor`. */
368
380
  export function runtimeInfo() {
369
381
  return {
@@ -251,12 +251,16 @@ export function formatAgentErrorGuidance(data = {}) {
251
251
  lines.push('Upgrade your plan for a larger 5-hour message window, or switch to BYOK if available.');
252
252
  if (pricingUrl) lines.push(`Open ${pricingUrl} to upgrade.`);
253
253
  } else if (phase === 'gateway' || code.includes('gateway')) {
254
- const label = providerGuidance?.label || 'provider';
255
- lines.push(`The ${label} gateway failed before the agent could respond.`);
256
- if (providerGuidance) {
254
+ // Generic first: on the platform route the user has NO provider key —
255
+ // telling them to "check your DeepSeek API key" is wrong and alarming.
256
+ // Provider-key guidance only applies when we know the route is BYOK.
257
+ const label = providerGuidance?.label || compact(data.provider) || 'model provider';
258
+ lines.push(`The upstream ${label} request failed before the agent could respond — usually a transient provider-side error.`);
259
+ lines.push('Retry the turn. If it persists, switch models with /model and try again.');
260
+ if (compact(data.route) === 'byok' && providerGuidance) {
257
261
  lines.push(...providerGuidance.lines);
258
262
  } else {
259
- lines.push('Check the selected provider, model, and BYOK credentials in settings, then retry.');
263
+ lines.push('Using your own key (BYOK)? Verify it is saved and active in settings, then run /login so provider settings sync.');
260
264
  }
261
265
  } else if (/authentication|token/i.test(message)) {
262
266
  lines.push('Run /login to re-authenticate.');
@@ -21,11 +21,11 @@ const MAX_ITERATIONS = 50;
21
21
  const TOOL_SCHEMAS = [
22
22
  {
23
23
  name: 'shell',
24
- description: 'Run one non-interactive shell command and return stdout/stderr. Do not use command substitution, backticks, or $(); run separate simple shell calls instead.',
24
+ description: 'Run one non-interactive shell command and return stdout/stderr. Command substitution (backticks, $()) is allowed but triggers a user-approval prompt; prefer plain commands when equivalent.',
25
25
  input_schema: {
26
26
  type: 'object',
27
27
  properties: {
28
- command: { type: 'string', description: 'The command to execute. Avoid backticks and $(); split dependent checks into separate commands.' },
28
+ command: { type: 'string', description: 'The command to execute. Backticks/$() are allowed (user approval required); use them when splitting would be awkward.' },
29
29
  timeout: { type: 'number', description: 'Timeout in milliseconds (default: 120000)' },
30
30
  },
31
31
  required: ['command'],
@@ -478,7 +478,7 @@ export class LocalAgent {
478
478
  'You are Bahulam Code, Bahulam\'s AI coding agent running in local mode.',
479
479
  'You have access to tools for reading, writing, and executing code.',
480
480
  'Use tools to accomplish the user\'s request. Be concise and direct.',
481
- 'For shell tools, never use command substitution, backticks, or $(). Run separate simple commands and carry values forward in text.',
481
+ 'Shell command substitution (backticks, $()) is allowed but requires user approval use it when it is the natural expression; otherwise prefer plain commands.',
482
482
  ];
483
483
  if (context.cwd) parts.push(`Working directory: ${context.cwd}`);
484
484
  if (context.gitBranch) parts.push(`Git branch: ${context.gitBranch}`);
package/src/index.mjs CHANGED
@@ -35,7 +35,7 @@ import { printBanner, printProjectInfo, printHints, printAuthStatus, printStyled
35
35
  import { ContextRetriever } from './context/retriever.mjs';
36
36
  import { loadSettings } from './config/settings.mjs';
37
37
 
38
- const VERSION = '2.6.15';
38
+ const VERSION = '2.6.17';
39
39
 
40
40
  // ── Arg Parsing (consolidated from index.mjs + cli-args.mjs) ──
41
41
 
@@ -50,6 +50,7 @@ function parseArgs(argv) {
50
50
  // Config subcommand flags
51
51
  showConfig: false, openRouterKey: null, anthropicKey: null,
52
52
  backendUrl: null, mode: null,
53
+ model: null, route: null,
53
54
  // Extended flags (from cli-args.mjs)
54
55
  permissionMode: null,
55
56
  outputFormat: null,
@@ -96,7 +97,8 @@ function parseArgs(argv) {
96
97
  case '--google-key': args.googleKey = argv[++i]; break;
97
98
  case '--gateway': args.gateway = argv[++i]; break;
98
99
  // --backend-url removed: use TARANG_ENV
99
- // --model removed: use tarang configure (web settings)
100
+ case '--model': case '-m': args.model = argv[++i]; break;
101
+ case '--route': args.route = argv[++i]; break;
100
102
  // Extended flags
101
103
  case '--permission-mode': args.permissionMode = argv[++i]; break;
102
104
  case '--print': case '-p': case '--instruction': case '--input': args.instruction = argv[++i]; break;
@@ -175,9 +177,11 @@ function printUsage() {
175
177
  process.stderr.write(` ${D}(default: auto-select based on task complexity)${R}\n`);
176
178
  process.stderr.write('\n');
177
179
  process.stderr.write(`${B}MODEL FLAGS${R}\n`);
180
+ process.stderr.write(` ${G}--model, -m <id|role=id>${R} Session model override ${D}(also: fast|thinking|extra|max)${R}\n`);
181
+ process.stderr.write(` ${G}--route <platform|byok>${R} Model route ${D}(platform validates against curated catalog)${R}\n`);
178
182
  process.stderr.write(` ${G}--system-prompt <text>${R} Override system prompt\n`);
179
183
  process.stderr.write(` ${G}--max-turns <n>${R} Maximum conversation turns\n`);
180
- process.stderr.write(` ${D}Models are configured via: bahulam configure${R}\n`);
184
+ process.stderr.write(` ${D}Persistent defaults are configured via: bahulam configure${R}\n`);
181
185
  process.stderr.write('\n');
182
186
  process.stderr.write(`${B}PERMISSION FLAGS${R}\n`);
183
187
  process.stderr.write(` ${G}--yes, -y${R} Auto-approve all operations\n`);
@@ -132,6 +132,32 @@ function containsUnquotedExpansion(command) {
132
132
  /**
133
133
  * Detect command substitution patterns.
134
134
  */
135
+ /**
136
+ * Extract the inner text of every `$( … )` (nesting-aware) and
137
+ * `` ` … ` `` region so each body can be classified on its own.
138
+ */
139
+ function extractSubstitutionBodies(command) {
140
+ const bodies = [];
141
+ const s = String(command || '');
142
+ for (const m of s.match(/`([^`]*)`/g) || []) {
143
+ bodies.push(m.slice(1, -1));
144
+ }
145
+ for (let i = 0; i < s.length; i++) {
146
+ if (s[i] === '$' && s[i + 1] === '(') {
147
+ let depth = 1;
148
+ let j = i + 2;
149
+ while (j < s.length && depth > 0) {
150
+ if (s[j] === '(') depth++;
151
+ else if (s[j] === ')') depth--;
152
+ j++;
153
+ }
154
+ bodies.push(s.slice(i + 2, depth === 0 ? j - 1 : j));
155
+ i = j - 1;
156
+ }
157
+ }
158
+ return bodies.map(b => b.trim()).filter(Boolean);
159
+ }
160
+
135
161
  function containsCommandSubstitution(command) {
136
162
  // Backticks
137
163
  if (/`/.test(command)) return true;
@@ -505,9 +531,31 @@ export function classifyCommand(command) {
505
531
  }
506
532
  }
507
533
 
508
- // ── Step 2: Check for command substitution / injection ──
534
+ // ── Step 2: Command substitution approval-gated, not hard-blocked ──
535
+ // Backticks / $() are legitimate shell (lsof -ti:8000 | xargs kill,
536
+ // $(git rev-parse HEAD), etc). Hard-blocking them forced the model
537
+ // into awkward multi-step workarounds even after the user approved.
538
+ // The substitution BODY is classified recursively — `echo $(rm -rf /)`
539
+ // stays hard-blocked because the inner command is; a benign body
540
+ // surfaces as an explicit HITL approval (contained + highRisk), same
541
+ // as process cleanup above. (Step 1's whole-string regexes alone are
542
+ // not enough: `rm -rf /` inside $() terminates with `)` which the
543
+ // \s|$ terminators never match.)
509
544
  if (containsCommandSubstitution(trimmed)) {
510
- return { classification: 'blocked', reason: 'Contains command substitution (backticks or $())' };
545
+ for (const inner of extractSubstitutionBodies(trimmed)) {
546
+ const innerResult = classifyCommand(inner);
547
+ if (innerResult.classification === 'blocked') {
548
+ return {
549
+ classification: 'blocked',
550
+ reason: `Substitution body is blocked: ${inner.slice(0, 50)}`,
551
+ };
552
+ }
553
+ }
554
+ return {
555
+ classification: 'contained',
556
+ reason: 'Uses command substitution (backticks or $()); requires approval',
557
+ highRisk: true,
558
+ };
511
559
  }
512
560
 
513
561
  if (containsUnsafeOutputRedirection(trimmed)) {
@@ -1,96 +1,122 @@
1
1
  /**
2
- * Telemetry Stub basic telemetry interface (no actual reporting).
2
+ * Telemetry — Funnel event emitter (PRD-076 W3).
3
3
  *
4
- * Logs events to debug output when CLAUDE_CODE_DEBUG is set.
5
- * Designed to be a drop-in interface for the full telemetry system.
4
+ * Buffers funnel events and flushes to {backend}/api/telemetry/funnel
5
+ * on a timer and on process exit. Device ID is generated once per install
6
+ * for anon-to-logged-in correlation.
7
+ *
8
+ * Opt-out: BAHULAM_DISABLE_TELEMETRY=1 or CLAUDE_CODE_DISABLE_TELEMETRY=1
6
9
  */
7
10
 
8
- const events = [];
11
+ import * as fs from 'node:fs';
12
+ import * as path from 'node:path';
13
+ import * as crypto from 'node:crypto';
14
+ import { bahulamHome } from '../core/paths.mjs';
15
+
16
+ const BUF_LIMIT = 500;
17
+ const FLUSH_INTERVAL_MS = 30_000;
18
+
19
+ let events = [];
9
20
  let enabled = true;
21
+ let flushTimer = null;
22
+ let _backendUrl = null;
23
+ let _token = null;
24
+ let _deviceId = null;
10
25
 
11
- /**
12
- * Track a telemetry event.
13
- * @param {string} event - event name
14
- * @param {object} [properties] - event properties
15
- */
16
- export function track(event, properties = {}) {
17
- if (!enabled) return;
18
- if (process.env.CLAUDE_CODE_DISABLE_TELEMETRY === '1') return;
26
+ /* ── Configuration ── */
19
27
 
20
- const entry = {
21
- event,
22
- properties,
23
- timestamp: Date.now(),
24
- };
28
+ export function disable() {
29
+ enabled = false;
30
+ if (flushTimer) { clearInterval(flushTimer); flushTimer = null; }
31
+ }
25
32
 
26
- events.push(entry);
33
+ export function configure(backendUrl, token) {
34
+ _backendUrl = backendUrl;
35
+ _token = token;
36
+ }
27
37
 
28
- // Keep max 1000 events in memory
29
- if (events.length > 1000) {
30
- events.splice(0, events.length - 1000);
31
- }
38
+ /* ── Device ID ── */
32
39
 
33
- if (process.env.CLAUDE_CODE_DEBUG) {
34
- console.error(`[telemetry] ${event}`, JSON.stringify(properties).slice(0, 200));
40
+ function readOrCreateDeviceId() {
41
+ const dir = bahulamHome();
42
+ const idPath = path.join(dir, 'device_id');
43
+ try {
44
+ if (fs.existsSync(idPath)) {
45
+ return fs.readFileSync(idPath, 'utf-8').trim();
35
46
  }
47
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
48
+ const id = crypto.randomUUID();
49
+ fs.writeFileSync(idPath, id, { mode: 0o600 });
50
+ return id;
51
+ } catch { return 'unknown'; }
52
+ }
53
+
54
+ export function getDeviceId() {
55
+ if (!_deviceId) _deviceId = readOrCreateDeviceId();
56
+ return _deviceId;
57
+ }
58
+
59
+ /* ── Tracking ── */
60
+
61
+ export function track(event, properties = {}) {
62
+ if (!enabled) return;
63
+ if (process.env.BAHULAM_DISABLE_TELEMETRY === '1') return;
64
+ if (process.env.CLAUDE_CODE_DISABLE_TELEMETRY === '1') return;
65
+
66
+ const entry = { event, properties: { ...properties }, device_id: getDeviceId(), timestamp: Date.now() };
67
+ events.push(entry);
68
+ if (events.length > BUF_LIMIT) events.splice(0, events.length - BUF_LIMIT);
69
+
70
+ if (process.env.BAHULAM_DEBUG_TELEMETRY) {
71
+ process.stderr.write(`[telemetry] ${event} ${JSON.stringify(properties).slice(0, 200)}\n`);
72
+ }
73
+
74
+ if (!flushTimer) {
75
+ flushTimer = setInterval(flush, FLUSH_INTERVAL_MS);
76
+ flushTimer.unref();
77
+ }
36
78
  }
37
79
 
38
- /**
39
- * Track a timing event.
40
- * @param {string} event - event name
41
- * @param {number} durationMs - duration in milliseconds
42
- * @param {object} [properties] - additional properties
43
- */
44
80
  export function trackTiming(event, durationMs, properties = {}) {
45
- track(event, { ...properties, durationMs });
81
+ track(event, { ...properties, durationMs });
46
82
  }
47
83
 
48
- /**
49
- * Track an error.
50
- * @param {string} event - error context
51
- * @param {Error} error - the error
52
- */
53
84
  export function trackError(event, error) {
54
- track(`error.${event}`, {
55
- message: error.message,
56
- stack: error.stack?.split('\n').slice(0, 3).join('\n'),
57
- });
85
+ track(`error.${event}`, { message: error.message, stack: error.stack?.split('\n').slice(0, 3).join('\n') });
58
86
  }
59
87
 
60
- /**
61
- * Get collected events (for debugging).
62
- * @returns {Array}
63
- */
64
- export function getEvents() {
65
- return [...events];
66
- }
88
+ /* ── Transport ── */
67
89
 
68
- /**
69
- * Clear collected events.
70
- */
71
- export function clear() {
72
- events.length = 0;
90
+ export async function flush() {
91
+ if (!enabled || events.length === 0 || !_backendUrl) return;
92
+ const batch = events.splice(0);
93
+ const headers = { 'Content-Type': 'application/json' };
94
+ if (_token) headers['Authorization'] = `Bearer ${_token}`;
95
+ try {
96
+ const resp = await fetch(`${_backendUrl}/api/telemetry/funnel`, {
97
+ method: 'POST', headers,
98
+ body: JSON.stringify({
99
+ events: batch,
100
+ user_id: _token ? undefined : undefined, // backend derives from token if present
101
+ source: 'cli',
102
+ }),
103
+ });
104
+ if (!resp.ok && resp.status >= 500) events.unshift(...batch);
105
+ } catch { events.unshift(...batch); if (events.length > BUF_LIMIT * 2) events.splice(0, events.length - BUF_LIMIT); }
73
106
  }
74
107
 
75
- /**
76
- * Enable or disable telemetry.
77
- * @param {boolean} value
78
- */
79
- export function setEnabled(value) {
80
- enabled = value;
108
+ export async function shutdown() {
109
+ if (flushTimer) { clearInterval(flushTimer); flushTimer = null; }
110
+ await flush();
81
111
  }
82
112
 
83
- /**
84
- * Get telemetry stats.
85
- */
113
+ /* ── Debug ── */
114
+
115
+ export function getEvents() { return [...events]; }
116
+ export function clear() { events.length = 0; }
117
+ export function setEnabled(value) { enabled = value; }
86
118
  export function getStats() {
87
- const counts = {};
88
- for (const e of events) {
89
- counts[e.event] = (counts[e.event] || 0) + 1;
90
- }
91
- return {
92
- totalEvents: events.length,
93
- enabled,
94
- eventCounts: counts,
95
- };
96
- }
119
+ const counts = {};
120
+ for (const e of events) counts[e.event] = (counts[e.event] || 0) + 1;
121
+ return { totalEvents: events.length, enabled, eventCounts: counts };
122
+ }
@@ -4,6 +4,8 @@
4
4
  * Zero React. Zero Ink. Zero flickering.
5
5
  */
6
6
 
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
7
9
  import { startTerminalRepl } from './repl.mjs';
8
10
  import {
9
11
  runSessionsCommand,
@@ -11,6 +13,9 @@ import {
11
13
  runHistoryCommand,
12
14
  } from './analytics.mjs';
13
15
  import { parseArgs } from '../config/cli-args.mjs';
16
+ import * as telemetry from '../telemetry/index.mjs';
17
+ import { bahulamHome } from '../core/paths.mjs';
18
+ import { TarangAuth as Auth } from '../auth/tarang-auth.mjs';
14
19
 
15
20
  // ── Subcommands ──
16
21
 
@@ -83,7 +88,40 @@ function parseKeplerSubcommandArgs(command, argv) {
83
88
  return parsed;
84
89
  }
85
90
 
91
+ function detectInstallFirstRun() {
92
+ const markerPath = path.join(bahulamHome(), '.install_marker');
93
+ try {
94
+ if (fs.existsSync(markerPath)) return false;
95
+ fs.writeFileSync(markerPath, String(Date.now()), { mode: 0o600 });
96
+ return true;
97
+ } catch { return false; }
98
+ }
99
+
86
100
  async function main() {
101
+ // ── Telemetry bootstrap ──
102
+ const _auth = new Auth();
103
+ const _creds = _auth.loadCredentials();
104
+ telemetry.configure(_creds.backendUrl, _creds.token);
105
+
106
+ // Fire install_first_run on first ever CLI invocation
107
+ if (detectInstallFirstRun()) {
108
+ telemetry.track('install_first_run', { version: process.env.npm_package_version || '' });
109
+ }
110
+
111
+ // Check day7_return — fire once per install
112
+ const configDir_ = bahulamHome();
113
+ const installMarker_ = path.join(configDir_, '.install_marker');
114
+ const returnFiredPath_ = path.join(configDir_, '.return_fired');
115
+ try {
116
+ if (fs.existsSync(installMarker_) && !fs.existsSync(returnFiredPath_)) {
117
+ const installTime = parseInt(fs.readFileSync(installMarker_, 'utf-8').trim(), 10);
118
+ if (!isNaN(installTime) && Date.now() - installTime >= 7 * 24 * 60 * 60 * 1000) {
119
+ telemetry.track('day7_return', {});
120
+ fs.writeFileSync(returnFiredPath_, '1', { mode: 0o600 });
121
+ }
122
+ }
123
+ } catch {}
124
+
87
125
  if (subcommand === 'dashboard') {
88
126
  // Launch Bahulam Pulse Next.js dashboard
89
127
  const { spawn } = await import('node:child_process');
@@ -134,7 +172,9 @@ async function main() {
134
172
  const { TarangAuth } = await import('../auth/tarang-auth.mjs');
135
173
  const auth = new TarangAuth();
136
174
  try {
175
+ telemetry.track('login_shown', { method: 'cli_subcommand' });
137
176
  await auth.login();
177
+ telemetry.track('login_completed', { method: 'cli_subcommand' });
138
178
  process.stderr.write('\x1b[32m✓ Login successful!\x1b[0m\n');
139
179
  return;
140
180
  } catch (err) {