@bahulam/code 0.1.8 → 0.1.10

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": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Bahulam Code — abundance, in your terminal. CLI-first, reliability-first, sub-agents, 65.6% SWE-bench Verified.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,7 +40,7 @@
40
40
  "gemini"
41
41
  ],
42
42
  "license": "Apache-2.0",
43
- "author": "Bahulam <hello@bahulam.ai>",
43
+ "author": "Bahulam <support@bahulam.ai>",
44
44
  "homepage": "https://bahulam.ai/code",
45
45
  "repository": {
46
46
  "type": "git",
@@ -113,7 +113,7 @@ function shellHardBlockReason(tool, args = {}) {
113
113
 
114
114
  function withShellRetryHint(reason) {
115
115
  const text = String(reason || '').trim();
116
- if (/command substitution|backticks|\$\(\)/i.test(text)) {
116
+ if (/command substitution|backticks|\$\(/i.test(text)) {
117
117
  return `${text}. Retry with separate simple shell commands instead of backticks or $().`;
118
118
  }
119
119
  return text;
@@ -12,6 +12,100 @@ import { bahulamHome } from './paths.mjs';
12
12
 
13
13
  const KEPLER_DIR = bahulamHome();
14
14
  const PROJECTS_DIR = path.join(KEPLER_DIR, 'projects');
15
+ const REPLAY_EVENT_RECORD_TYPES = new Set(['bahulam_event', 'kepler_event']);
16
+
17
+ function finiteNumber(value) {
18
+ const n = Number(value);
19
+ return Number.isFinite(n) ? n : null;
20
+ }
21
+
22
+ function firstFiniteNumber(...values) {
23
+ for (const value of values) {
24
+ const n = finiteNumber(value);
25
+ if (n !== null) return n;
26
+ }
27
+ return 0;
28
+ }
29
+
30
+ function replayEventFromRecord(record) {
31
+ if (!record || !REPLAY_EVENT_RECORD_TYPES.has(record.type) || !record.event) return null;
32
+ const event = record.event;
33
+ if (!event || typeof event !== 'object' || !event.type) return null;
34
+ return {
35
+ ...event,
36
+ data: event.data && typeof event.data === 'object' ? event.data : {},
37
+ };
38
+ }
39
+
40
+ function eventUsage(event) {
41
+ const data = event?.data && typeof event.data === 'object' ? event.data : {};
42
+ const usage = data.usage && typeof data.usage === 'object' ? data.usage : null;
43
+ if (!usage) return null;
44
+ return usage;
45
+ }
46
+
47
+ function usageTotals(usage = {}) {
48
+ return {
49
+ inputTokens: firstFiniteNumber(usage.total_input_tokens, usage.input_tokens, usage.prompt_tokens),
50
+ outputTokens: firstFiniteNumber(usage.total_output_tokens, usage.output_tokens, usage.completion_tokens),
51
+ cacheReadTokens: firstFiniteNumber(usage.cache_read_input_tokens, usage.cache_read_tokens, usage.cache_read),
52
+ cacheCreationTokens: firstFiniteNumber(usage.cache_creation_input_tokens, usage.cache_creation_tokens, usage.cache_creation),
53
+ reasoningTokens: firstFiniteNumber(usage.reasoning_tokens),
54
+ };
55
+ }
56
+
57
+ function addModelUsage(meta, modelSet, usage = {}) {
58
+ if (!Array.isArray(usage.models)) return;
59
+ for (const item of usage.models) {
60
+ const model = typeof item === 'string' ? item : item?.model;
61
+ if (typeof model === 'string' && model) modelSet.add(model);
62
+ if (!model || typeof item !== 'object') continue;
63
+ if (!meta.modelUsage[model]) {
64
+ meta.modelUsage[model] = {
65
+ inputTokens: 0,
66
+ outputTokens: 0,
67
+ cacheReadTokens: 0,
68
+ cacheCreationTokens: 0,
69
+ reasoningTokens: 0,
70
+ costUsd: 0,
71
+ };
72
+ }
73
+ const totals = usageTotals(item);
74
+ meta.modelUsage[model].inputTokens += totals.inputTokens;
75
+ meta.modelUsage[model].outputTokens += totals.outputTokens;
76
+ meta.modelUsage[model].cacheReadTokens += totals.cacheReadTokens;
77
+ meta.modelUsage[model].cacheCreationTokens += totals.cacheCreationTokens;
78
+ meta.modelUsage[model].reasoningTokens += totals.reasoningTokens;
79
+ meta.modelUsage[model].costUsd += firstFiniteNumber(item.cost_usd, item.cost);
80
+ }
81
+ }
82
+
83
+ function addUsageTotals(meta, modelSet, usage = {}) {
84
+ const totals = usageTotals(usage);
85
+ meta.inputTokens += totals.inputTokens;
86
+ meta.outputTokens += totals.outputTokens;
87
+ meta.cacheReadTokens += totals.cacheReadTokens;
88
+ meta.cacheCreationTokens += totals.cacheCreationTokens;
89
+ meta.reasoningTokens += totals.reasoningTokens;
90
+ addModelUsage(meta, modelSet, usage);
91
+ }
92
+
93
+ function assistantHasMatchingComplete(assistantRecord, completeRecords) {
94
+ return completeRecords.some((record) => {
95
+ const distance = Math.abs(Number(assistantRecord.order) - Number(record.order));
96
+ return distance > 0 && distance <= 3;
97
+ });
98
+ }
99
+
100
+ function applyUsageRecords(meta, modelSet, records) {
101
+ const completeRecords = records.filter(record => record.source === 'complete');
102
+ for (const record of completeRecords) addUsageTotals(meta, modelSet, record.usage);
103
+ for (const record of records) {
104
+ if (record.source !== 'assistant') continue;
105
+ if (assistantHasMatchingComplete(record, completeRecords)) continue;
106
+ addUsageTotals(meta, modelSet, record.usage);
107
+ }
108
+ }
15
109
 
16
110
  function normalizeBlock(block) {
17
111
  if (!block || typeof block !== 'object') {
@@ -188,8 +282,10 @@ async function parseSessionMeta(filePath) {
188
282
  outputTokens: 0,
189
283
  cacheReadTokens: 0,
190
284
  cacheCreationTokens: 0,
285
+ reasoningTokens: 0,
191
286
  toolCalls: [], // [{name, count}]
192
287
  models: [], // [model strings]
288
+ modelUsage: {}, // model -> token/cost totals from complete events
193
289
  modelLimits: {}, // role -> {model, context_length, max_output, source}
194
290
  subAgentModels: {}, // role -> model from backend session_info
195
291
  startTime: null,
@@ -207,6 +303,7 @@ async function parseSessionMeta(filePath) {
207
303
 
208
304
  const toolCounts = {};
209
305
  const modelSet = new Set();
306
+ const usageRecords = [];
210
307
 
211
308
  // endStatus tracking
212
309
  let lastMessageRole = null;
@@ -218,8 +315,10 @@ async function parseSessionMeta(filePath) {
218
315
 
219
316
  const fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' });
220
317
  const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
318
+ let lineOrder = 0;
221
319
 
222
320
  for await (const line of rl) {
321
+ const recordOrder = lineOrder++;
223
322
  if (!line.trim()) continue;
224
323
  let obj;
225
324
  try { obj = JSON.parse(line); }
@@ -235,13 +334,30 @@ async function parseSessionMeta(filePath) {
235
334
  if (!meta.endTime || ts > meta.endTime) meta.endTime = ts;
236
335
  }
237
336
 
238
- // Backend kepler_event payloads may carry cost / error markers.
239
- if (obj.type === 'kepler_event' && obj.event) {
240
- const ev = obj.event;
241
- if (ev.type === 'complete' && typeof ev.cost_usd === 'number') meta.costUsd += ev.cost_usd;
337
+ // Bahulam replay events may carry cost / error markers. Older local
338
+ // transcripts used the same payload under the legacy kepler_event type.
339
+ const ev = replayEventFromRecord(obj);
340
+ if (ev) {
341
+ const data = ev.data || {};
342
+ if (ev.type === 'complete') {
343
+ const usage = eventUsage(ev);
344
+ if (usage) {
345
+ usageRecords.push({ source: 'complete', order: recordOrder, usage });
346
+ }
347
+ const eventCost = firstFiniteNumber(
348
+ data.cost_usd,
349
+ data.total_cost_usd,
350
+ data.total_cost,
351
+ data.usage?.total_cost_usd,
352
+ data.usage?.total_cost,
353
+ data.usage?.cost,
354
+ ev.cost_usd,
355
+ );
356
+ if (eventCost) meta.costUsd += eventCost;
357
+ }
242
358
  if (ev.type === 'session_info') {
243
- if (typeof ev.total_cost_usd === 'number') meta.costUsd = ev.total_cost_usd;
244
- const info = ev.data || ev;
359
+ if (typeof data.total_cost_usd === 'number') meta.costUsd = data.total_cost_usd;
360
+ const info = data;
245
361
  if (info.model_limits && typeof info.model_limits === 'object') {
246
362
  meta.modelLimits = info.model_limits;
247
363
  }
@@ -255,15 +371,15 @@ async function parseSessionMeta(filePath) {
255
371
  }
256
372
  }
257
373
  if (ev.type === 'error' || ev.error === true) hadError = true;
258
- if (ev.type === 'resume_summary' && typeof ev.data?.summary === 'string') {
374
+ if (ev.type === 'resume_summary' && typeof data.summary === 'string') {
259
375
  meta.resumeSummary = {
260
- sourceMessageCount: Number(ev.data.source_message_count) || 0,
261
- previousSourceMessageCount: Number(ev.data.previous_source_message_count) || 0,
262
- fullMessageCount: Number(ev.data.full_message_count) || 0,
263
- summaryChars: ev.data.summary.length,
264
- summarySource: ev.data.summary_source || '',
265
- mode: ev.data.mode || '',
266
- modeLabel: ev.data.mode_label || '',
376
+ sourceMessageCount: Number(data.source_message_count) || 0,
377
+ previousSourceMessageCount: Number(data.previous_source_message_count) || 0,
378
+ fullMessageCount: Number(data.full_message_count) || 0,
379
+ summaryChars: data.summary.length,
380
+ summarySource: data.summary_source || '',
381
+ mode: data.mode || '',
382
+ modeLabel: data.mode_label || '',
267
383
  timestamp: obj.timestamp || null,
268
384
  };
269
385
  }
@@ -296,10 +412,7 @@ async function parseSessionMeta(filePath) {
296
412
  lastMessageRole = 'assistant';
297
413
  const usage = obj.message?.usage;
298
414
  if (usage) {
299
- meta.inputTokens += usage.input_tokens || 0;
300
- meta.outputTokens += usage.output_tokens || 0;
301
- meta.cacheReadTokens += usage.cache_read_input_tokens || 0;
302
- meta.cacheCreationTokens += usage.cache_creation_input_tokens || 0;
415
+ usageRecords.push({ source: 'assistant', order: recordOrder, usage });
303
416
  }
304
417
  const model = obj.message?.model;
305
418
  if (model) modelSet.add(model);
@@ -322,6 +435,8 @@ async function parseSessionMeta(filePath) {
322
435
  .map(([name, count]) => ({ name, count }))
323
436
  .sort((a, b) => b.count - a.count);
324
437
  meta.models = [...modelSet];
438
+ applyUsageRecords(meta, modelSet, usageRecords);
439
+ meta.models = [...modelSet];
325
440
 
326
441
  // Projected context size for resume should estimate the serialized payload,
327
442
  // not cumulative provider usage. Provider input tokens are charged per turn
@@ -393,11 +508,12 @@ export async function getSessionDetail(sessionId, options = {}) {
393
508
  }
394
509
  const entryOrder = order++;
395
510
 
396
- if (obj.type === 'kepler_event' && obj.event?.type) {
511
+ const replayEvent = replayEventFromRecord(obj);
512
+ if (replayEvent) {
397
513
  replayEvents.push({
398
514
  order: entryOrder,
399
515
  timestamp: obj.timestamp || null,
400
- event: obj.event,
516
+ event: replayEvent,
401
517
  });
402
518
  continue;
403
519
  }
@@ -765,6 +881,7 @@ export async function getSessionStats(days = 30) {
765
881
  totalInputTokens: 0,
766
882
  totalOutputTokens: 0,
767
883
  totalCacheReadTokens: 0,
884
+ totalReasoningTokens: 0,
768
885
  totalToolCalls: 0,
769
886
  toolBreakdown: {},
770
887
  modelBreakdown: {},
@@ -777,13 +894,44 @@ export async function getSessionStats(days = 30) {
777
894
  stats.totalInputTokens += meta.inputTokens;
778
895
  stats.totalOutputTokens += meta.outputTokens;
779
896
  stats.totalCacheReadTokens += meta.cacheReadTokens;
897
+ stats.totalReasoningTokens += meta.reasoningTokens;
780
898
 
781
899
  for (const tc of meta.toolCalls) {
782
900
  stats.toolBreakdown[tc.name] = (stats.toolBreakdown[tc.name] || 0) + tc.count;
783
901
  stats.totalToolCalls += tc.count;
784
902
  }
785
903
  for (const model of meta.models) {
786
- stats.modelBreakdown[model] = (stats.modelBreakdown[model] || 0) + 1;
904
+ if (!stats.modelBreakdown[model]) {
905
+ stats.modelBreakdown[model] = {
906
+ sessions: 0,
907
+ inputTokens: 0,
908
+ outputTokens: 0,
909
+ cacheReadTokens: 0,
910
+ cacheCreationTokens: 0,
911
+ reasoningTokens: 0,
912
+ costUsd: 0,
913
+ };
914
+ }
915
+ stats.modelBreakdown[model].sessions += 1;
916
+ }
917
+ for (const [model, usage] of Object.entries(meta.modelUsage || {})) {
918
+ if (!stats.modelBreakdown[model]) {
919
+ stats.modelBreakdown[model] = {
920
+ sessions: 0,
921
+ inputTokens: 0,
922
+ outputTokens: 0,
923
+ cacheReadTokens: 0,
924
+ cacheCreationTokens: 0,
925
+ reasoningTokens: 0,
926
+ costUsd: 0,
927
+ };
928
+ }
929
+ stats.modelBreakdown[model].inputTokens += usage.inputTokens || 0;
930
+ stats.modelBreakdown[model].outputTokens += usage.outputTokens || 0;
931
+ stats.modelBreakdown[model].cacheReadTokens += usage.cacheReadTokens || 0;
932
+ stats.modelBreakdown[model].cacheCreationTokens += usage.cacheCreationTokens || 0;
933
+ stats.modelBreakdown[model].reasoningTokens += usage.reasoningTokens || 0;
934
+ stats.modelBreakdown[model].costUsd += usage.costUsd || 0;
787
935
  }
788
936
  }
789
937
 
@@ -808,8 +956,8 @@ export async function getToolBreakdown(days = 30) {
808
956
  export async function getModelBreakdown(days = 30) {
809
957
  const stats = await getSessionStats(days);
810
958
  return Object.entries(stats.modelBreakdown)
811
- .map(([model, sessions]) => ({ model, sessions }))
812
- .sort((a, b) => b.sessions - a.sessions);
959
+ .map(([model, usage]) => ({ model, ...usage }))
960
+ .sort((a, b) => b.sessions - a.sessions || (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens));
813
961
  }
814
962
 
815
963
  /**
@@ -139,7 +139,7 @@ export function createToolExecutor({
139
139
 
140
140
  function blockedShellOutput(reason) {
141
141
  const text = String(reason || 'Blocked by shell safety policy').trim();
142
- const hint = /command substitution|backticks|\$\(\)/i.test(text)
142
+ const hint = /command substitution|backticks|\$\(/i.test(text)
143
143
  ? 'Retry with separate simple shell commands instead of backticks or $().'
144
144
  : 'Work only inside a registered project root.';
145
145
  return `BLOCKED: ${text}. ${hint}`;
@@ -1296,9 +1296,13 @@ export function createToolExecutor({
1296
1296
  const after = readTextIfExists(filePath);
1297
1297
  if (wrapped.success !== false && before === after) {
1298
1298
  const relativePath = path.relative(projectRootFor(filePath), filePath) || path.basename(filePath);
1299
+ // File content is unchanged — the desired state is already in place.
1300
+ // Return success so the agent doesn't treat this as a failure and
1301
+ // loop into repeated read_file calls trying to diagnose why it failed.
1302
+ // _no_change flags this for stagnation detection on repeated no-op edits.
1299
1303
  return {
1300
- success: false,
1301
- output: `edit_file made no changes to ${relativePath}. The search string may already be replaced, the replacement may be identical, or the target content may have drifted. Re-read the target range before trying a different edit.`,
1304
+ success: true,
1305
+ output: `edit_file: no changes made to ${relativePath} content already matches or replacement is identical to the original.`,
1302
1306
  _tool: 'edit_file',
1303
1307
  _no_change: true,
1304
1308
  no_change: true,
@@ -103,6 +103,7 @@ export function formatStatsReport(stats, tools, models, days, paths) {
103
103
  lines.push(`Messages ${formatNumber(stats.totalUserMessages + stats.totalAssistantMessages)} (${formatNumber(stats.totalUserMessages)} user, ${formatNumber(stats.totalAssistantMessages)} assistant)`);
104
104
  lines.push(`Tokens ${formatNumber(stats.totalInputTokens + stats.totalOutputTokens)} (${formatNumber(stats.totalInputTokens)} in, ${formatNumber(stats.totalOutputTokens)} out)`);
105
105
  lines.push(`Cache Read ${formatNumber(stats.totalCacheReadTokens)}`);
106
+ lines.push(`Reasoning ${formatNumber(stats.totalReasoningTokens)}`);
106
107
  lines.push(`Tool Calls ${formatNumber(stats.totalToolCalls)}`);
107
108
  lines.push('');
108
109
 
@@ -121,7 +122,9 @@ export function formatStatsReport(stats, tools, models, days, paths) {
121
122
  lines.push(' none');
122
123
  } else {
123
124
  for (const model of models.slice(0, 8)) {
124
- lines.push(` ${truncate(model.model, 42).padEnd(42)} ${formatNumber(model.sessions)} sessions`);
125
+ const tokens = (model.inputTokens || 0) + (model.outputTokens || 0);
126
+ const tokenText = tokens ? ` ${formatNumber(tokens)} tok` : '';
127
+ lines.push(` ${truncate(model.model, 42).padEnd(42)} ${formatNumber(model.sessions)} sessions${tokenText}`);
125
128
  }
126
129
  }
127
130
  lines.push('');
@@ -840,16 +840,38 @@ export function transcriptRenderableLines(rendered) {
840
840
  return lines;
841
841
  }
842
842
 
843
+ function positiveInteger(value) {
844
+ const n = Number(value);
845
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
846
+ }
847
+
848
+ export function stagnationDisplayCount(data = {}, reason = '') {
849
+ const text = String(reason || data?.reason || data?.message || '');
850
+ const patterns = [
851
+ /\bcalled\s+(\d+)\s+times\b/i,
852
+ /[×x]\s*(\d+)\b/i,
853
+ /\brepeated\s+(\d+)x\b/i,
854
+ /\b(\d+)\s+times\s+without\s+mutation\b/i,
855
+ /\b(\d+)\s+tool\s+calls\s+without\s+mutating\s+state\b/i,
856
+ ];
857
+ for (const pattern of patterns) {
858
+ const match = text.match(pattern);
859
+ const count = positiveInteger(match?.[1]);
860
+ if (count) return count;
861
+ }
862
+ return positiveInteger(data?.count) || positiveInteger(data?.repeat_count);
863
+ }
864
+
843
865
  export function renderStagnation(data = {}) {
844
866
  const rawMessage = data?.message || '';
845
867
  const reason = data?.reason || rawMessage.replace(/^Stagnation:\s*/i, '').trim();
846
868
  const tool = data?.tool || data?.tool_name || '';
847
- const count = data?.repeat_count || data?.count || null;
869
+ const count = stagnationDisplayCount(data, reason);
848
870
  // Try to extract a target/path from the reason so we can show a
849
871
  // compact one-liner. Reason shapes we know about from the framework:
850
872
  // "Repeated overlapping <tool> inspections of '<target>' N times without mutation"
851
873
  // "..." (fallback: use reason as-is, trimmed to ~80 chars)
852
- const targetMatch = reason.match(/of\s+['"]([^'"]+)['"]/);
874
+ const targetMatch = reason.match(/(?:of|on)\s+['"]([^'"]+)['"]/);
853
875
  const target = targetMatch ? targetMatch[1] : '';
854
876
 
855
877
  // Compose a compact single-line message:
@@ -605,7 +605,7 @@ export async function compactCurrentSession(ctx, rest = '') {
605
605
 
606
606
  if (ctx.jsonlWriter) {
607
607
  progress.update('writing summary checkpoint', 88);
608
- ctx.jsonlWriter.writeKeplerEvent({
608
+ ctx.jsonlWriter.writeBahulamEvent({
609
609
  type: 'resume_summary',
610
610
  data: {
611
611
  session_id: session.id || null,
@@ -3684,7 +3684,7 @@ async function handleCommand(input, ctx) {
3684
3684
  }
3685
3685
 
3686
3686
  // 5. Show continuity context. Non-summary modes use the captured
3687
- // kepler_event stream when available so the terminal replay matches
3687
+ // bahulam_event stream when available so the terminal replay matches
3688
3688
  // the original styled interaction; older sessions fall back to
3689
3689
  // reconstructed text.
3690
3690
  if (mode === 'summary' && resumed.summary) {