@axplusb/kepler 2.0.3 → 2.0.5

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": "@axplusb/kepler",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "Kepler — AI coding agent with operating brief, preflight planning, and sub-agents. SWE-bench Lite evaluated.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -67,7 +67,7 @@ async function checkAuthAndBackend(auth, { timeoutMs = 2500 } = {}) {
67
67
  if (resp.ok) {
68
68
  const user = await resp.json().catch(() => null);
69
69
  const who = user?.github_username || user?.email || 'user';
70
- return { status: 'ok', label: `Signed in as ${who} · connected` };
70
+ return { status: 'ok', label: `Signed in as ${who} · connected`, user };
71
71
  }
72
72
  if (resp.status === 401 || resp.status === 403) {
73
73
  return { status: 'warn', label: 'Token expired · connected', hint: '/login again to refresh' };
@@ -78,6 +78,51 @@ async function checkAuthAndBackend(auth, { timeoutMs = 2500 } = {}) {
78
78
  }
79
79
  }
80
80
 
81
+ /**
82
+ * Fetch subscription tier + remaining credits from /api/billing/balance.
83
+ * Skipped when not signed in or when the backend is offline.
84
+ *
85
+ * Returns one of:
86
+ * { status, label } — shown as a preflight row
87
+ * null — silent (e.g. BYOK or no signal)
88
+ */
89
+ async function checkCreditsAndPlan(auth, { timeoutMs = 2000 } = {}) {
90
+ const creds = auth.loadCredentials();
91
+ if (!creds.token || !creds.backendUrl) return null;
92
+ try {
93
+ const ctrl = new AbortController();
94
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
95
+ let resp;
96
+ try {
97
+ resp = await fetch(`${creds.backendUrl}/api/billing/balance`, {
98
+ headers: { 'Authorization': `Bearer ${creds.token}` },
99
+ signal: ctrl.signal,
100
+ });
101
+ } finally { clearTimeout(t); }
102
+ if (!resp.ok) return null;
103
+ const data = await resp.json().catch(() => null);
104
+ if (!data) return null;
105
+
106
+ if (data.byok_enabled) {
107
+ return { status: 'ok', label: `Plan: ${(data.tier || 'byok').toUpperCase()} · billed by your provider` };
108
+ }
109
+ const tier = (data.tier || 'free').toUpperCase();
110
+ const remaining = data.balance?.total;
111
+ if (typeof remaining !== 'number') {
112
+ return { status: 'ok', label: `Plan: ${tier}` };
113
+ }
114
+ if (remaining <= 0) {
115
+ return { status: 'fail', label: `Plan: ${tier} · 0 credits remaining`, hint: 'codekepler.ai/pricing to purchase or upgrade' };
116
+ }
117
+ if (remaining < 25) {
118
+ return { status: 'warn', label: `Plan: ${tier} · ${remaining} credits remaining`, hint: 'low balance — codekepler.ai/pricing' };
119
+ }
120
+ return { status: 'ok', label: `Plan: ${tier} · ${remaining} credits remaining` };
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
81
126
  function checkGit(cwd) {
82
127
  if (!hasGitDir(cwd)) return { status: 'warn', label: 'Not a git repository', hint: '`git init` to enable diff / checkpoints' };
83
128
  try {
@@ -272,7 +317,14 @@ export async function runPreflight({ auth, cwd, version, silent = false } = {})
272
317
  write('\n' + header + '\n\n');
273
318
 
274
319
  const checks = [];
275
- checks.push(await checkAuthAndBackend(auth));
320
+ const authCheck = await checkAuthAndBackend(auth);
321
+ checks.push(authCheck);
322
+ // Only ask the backend for plan + credits when the auth row is OK; no point
323
+ // hitting /balance if we are not signed in or the backend is offline.
324
+ if (authCheck.status === 'ok') {
325
+ const plan = await checkCreditsAndPlan(auth);
326
+ if (plan) checks.push(plan);
327
+ }
276
328
  checks.push(checkGit(cwd));
277
329
  checks.push(checkLinters(cwd));
278
330
  checks.push(checkProjectMap(cwd));
@@ -123,6 +123,15 @@ const session = {
123
123
  totalCost: 0, // accumulated session cost (USD)
124
124
  costAccurate: false, // true if backend provides per-model breakdown
125
125
  isByok: false, // set from session_info; hides cost + credits when true
126
+ // ── Subscription / credit state (server-authoritative; set from
127
+ // session_info + complete events) ──
128
+ subscriptionTier: null, // 'free' | 'cli' | 'pro' | 'pro_plus' | 'enterprise'
129
+ creditsTotal: null, // remaining credits (included + purchased)
130
+ creditsIncluded: null, // remaining included credits this period
131
+ creditsPurchased: null, // remaining purchased credits
132
+ creditsLimit: null, // per-period included credits limit
133
+ creditsCharged: 0, // session-cumulative server-reported charges
134
+ creditsLowWarned: false, // emit the low-balance hint only once per turn
126
135
  };
127
136
 
128
137
  // ── Commands ──
@@ -206,9 +215,14 @@ function buildContextStrip() {
206
215
  const elapsed = formatElapsed(session.startTime);
207
216
 
208
217
  // BYOK: user pays the provider directly, suppress credits entirely.
218
+ // Otherwise prefer the server-authoritative session counter, falling back
219
+ // to the local estimate when the backend hasn't pushed any number yet.
220
+ const usedCr = session.creditsCharged > 0
221
+ ? session.creditsCharged
222
+ : costToCredits(session.totalCost);
209
223
  const right = [
210
224
  c.dim(`${formatTokens(totalTokens)} tok`),
211
- ...(session.isByok ? [] : [c.dim(formatCredits(costToCredits(session.totalCost)))]),
225
+ ...(session.isByok ? [] : [c.dim(formatCredits(usedCr))]),
212
226
  c.dim(elapsed),
213
227
  ].join(c.dim(' · '));
214
228
 
@@ -767,6 +781,15 @@ function renderEvent(event) {
767
781
  // BYOK users pay their model provider directly; the platform does not
768
782
  // charge them credits. Hide cost + credits when this flag is set.
769
783
  if (typeof data?.is_byok === 'boolean') session.isByok = data.is_byok;
784
+ // Subscription tier + credit balance — backend is authoritative.
785
+ if (data?.subscription_tier) session.subscriptionTier = data.subscription_tier;
786
+ if (typeof data?.credits_included_limit === 'number') session.creditsLimit = data.credits_included_limit;
787
+ const bal = data?.credits_balance;
788
+ if (bal && typeof bal === 'object') {
789
+ if (typeof bal.total === 'number') session.creditsTotal = bal.total;
790
+ if (typeof bal.included === 'number') session.creditsIncluded = bal.included;
791
+ if (typeof bal.purchased === 'number') session.creditsPurchased = bal.purchased;
792
+ }
770
793
  break;
771
794
  }
772
795
 
@@ -826,6 +849,33 @@ function renderEvent(event) {
826
849
 
827
850
  session.lastTurnDuration = data?.duration_s || 0;
828
851
 
852
+ // ── Server-authoritative credits ──
853
+ // Backend sends usage.credits_charged (this turn) + balance (remaining)
854
+ // in the complete event. CLI uses these instead of the local
855
+ // costToCredits estimate so /status and /cost match the dashboard.
856
+ if (!session.isByok) {
857
+ const charged = data?.usage?.credits_charged;
858
+ if (typeof charged === 'number') session.creditsCharged += charged;
859
+ const bal = data?.balance;
860
+ if (bal && typeof bal === 'object') {
861
+ if (typeof bal.total === 'number') session.creditsTotal = bal.total;
862
+ if (typeof bal.included === 'number') session.creditsIncluded = bal.included;
863
+ if (typeof bal.purchased === 'number') session.creditsPurchased = bal.purchased;
864
+ }
865
+ // Warn once per turn when the remaining credits drop below 20% of the
866
+ // tier's included limit (or below 10 absolute for tiny tiers).
867
+ if (!session.creditsLowWarned && typeof session.creditsTotal === 'number' && session.creditsLimit) {
868
+ const threshold = Math.max(10, Math.floor(session.creditsLimit * 0.2));
869
+ if (session.creditsTotal <= threshold && session.creditsTotal > 0) {
870
+ process.stderr.write(`\n ${c.yellow('⚠')} ${c.dim(`${session.creditsTotal} of ${session.creditsLimit} credits remaining on the ${session.subscriptionTier || 'free'} plan. Upgrade at codekepler.ai/pricing.`)}\n`);
871
+ session.creditsLowWarned = true;
872
+ } else if (session.creditsTotal <= 0) {
873
+ process.stderr.write(`\n ${c.red('✗')} ${c.dim(`Credit balance exhausted on the ${session.subscriptionTier || 'free'} plan. Purchase credits at codekepler.ai/pricing or switch to BYOK.`)}\n`);
874
+ session.creditsLowWarned = true;
875
+ }
876
+ }
877
+ }
878
+
829
879
  // Sync cumulative session cost into the orbit (status bar shows it).
830
880
  if (_orbit) _orbit.onCost(session.totalCost);
831
881
 
@@ -948,7 +998,18 @@ async function handleCommand(input, ctx) {
948
998
  if (session.isByok) {
949
999
  process.stderr.write(` ${c.dim('Billing')} ${c.green('BYOK')} ${c.dim('(provider-billed)')}\n`);
950
1000
  } else {
951
- process.stderr.write(` ${c.dim('Credits')} ${formatCredits(costToCredits(session.totalCost))}${session.costAccurate ? '' : c.dim(' (est)')}\n`);
1001
+ // Server-authoritative remaining balance; fall back to the per-session
1002
+ // charged tally when balance hasn't been pushed yet.
1003
+ if (session.subscriptionTier) {
1004
+ process.stderr.write(` ${c.dim('Plan')} ${c.brand(session.subscriptionTier.toUpperCase())}\n`);
1005
+ }
1006
+ if (typeof session.creditsTotal === 'number') {
1007
+ const limit = session.creditsLimit ? ` ${c.dim('/ ' + formatCredits(session.creditsLimit))}` : '';
1008
+ const used = session.creditsCharged ? ` ${c.dim(`(${formatCredits(session.creditsCharged)} used this session)`)}` : '';
1009
+ process.stderr.write(` ${c.dim('Credits')} ${formatCredits(session.creditsTotal)}${limit}${used}\n`);
1010
+ } else if (session.creditsCharged) {
1011
+ process.stderr.write(` ${c.dim('Credits')} ${formatCredits(session.creditsCharged)} ${c.dim('(used this session)')}\n`);
1012
+ }
952
1013
  }
953
1014
  process.stderr.write(` ${c.dim('CWD')} ${safeCwd()}\n`);
954
1015
 
@@ -1031,11 +1092,17 @@ async function handleCommand(input, ctx) {
1031
1092
  process.stderr.write(`\n ${c.bold('Billing')} ${c.green('BYOK')} ${c.dim('— you pay your model provider directly. Kepler does not charge credits for BYOK usage.')}\n\n`);
1032
1093
  return;
1033
1094
  }
1034
- process.stderr.write(`\n ${c.bold('Session Credits')} ${c.brand(formatCredits(costToCredits(session.totalCost)))}`);
1035
- if (!session.costAccurate) {
1036
- process.stderr.write(` ${c.yellow('(estimated)')}`);
1037
- }
1095
+ // Prefer server-authoritative numbers when available.
1096
+ const used = session.creditsCharged || 0;
1097
+ const usedLabel = formatCredits(used);
1098
+ process.stderr.write(`\n ${c.bold('Session Credits')} ${c.brand(usedLabel)}`);
1099
+ if (used > 0 && !session.creditsCharged) process.stderr.write(` ${c.yellow('(estimated)')}`);
1038
1100
  process.stderr.write('\n');
1101
+ if (session.subscriptionTier && typeof session.creditsTotal === 'number') {
1102
+ const remaining = formatCredits(session.creditsTotal);
1103
+ const limit = session.creditsLimit ? ` / ${formatCredits(session.creditsLimit)}` : '';
1104
+ process.stderr.write(` ${c.dim('Plan')} ${c.brand(session.subscriptionTier.toUpperCase())} ${c.dim('· remaining')} ${c.brand(remaining)}${c.dim(limit)}\n`);
1105
+ }
1039
1106
  process.stderr.write(` ${c.dim('─'.repeat(70))}\n`);
1040
1107
 
1041
1108
  if (session.costBreakdown.length > 0) {
@@ -1602,6 +1669,7 @@ export async function startTerminalRepl() {
1602
1669
  session.subAgentCounts = {};
1603
1670
  session.savedUsd = 0;
1604
1671
  session._lastEmittedThinking = '';
1672
+ session.creditsLowWarned = false;
1605
1673
 
1606
1674
  // Tell the orbit a new turn started — switches to DISCOVERY and updates
1607
1675
  // task / turn counters in the status bar.