@bahulam/code 0.1.23 → 0.1.25

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.
@@ -0,0 +1,207 @@
1
+ import { findShippedModel } from '../config/model-catalog.mjs';
2
+
3
+ /**
4
+ * Shared client-side context reduction contract.
5
+ *
6
+ * This mirrors the backend context contract so LocalAgent can reduce the
7
+ * same history shape at the same boundary. The transport-specific LLM call
8
+ * is supplied by the caller; this module owns policy and history rewriting.
9
+ */
10
+
11
+ export const SUMMARY_MARKER = '[Context summary — earlier conversation condensed]';
12
+ export const DISTILLATION_MARKER = '[Context distillation — active ingredients preserved]';
13
+
14
+ const PRODUCT_POLICIES = Object.freeze({
15
+ // workingSetRatio is an optimization budget, not a provider hard limit.
16
+ // It scales with the selected model's usable context window so large
17
+ // models do not grow to their full capacity before we protect cache reuse
18
+ // and attention quality.
19
+ chat: Object.freeze({ triggerRatio: 0.72, targetRatio: 0.48, workingSetRatio: 0.18, preserve: 12, prune: 'aggressive' }),
20
+ ide: Object.freeze({ triggerRatio: 0.78, targetRatio: 0.52, workingSetRatio: 0.20, preserve: 10, prune: 'conservative' }),
21
+ workspace: Object.freeze({ triggerRatio: 0.70, targetRatio: 0.45, workingSetRatio: 0.16, preserve: 14, prune: 'protected' }),
22
+ });
23
+
24
+ export function contextProduct(product = 'ide') {
25
+ const value = String(product || '').toLowerCase();
26
+ if (value.includes('chat')) return 'chat';
27
+ if (value.includes('workspace')) return 'workspace';
28
+ return 'ide';
29
+ }
30
+
31
+ export function contextPolicy(product = 'ide') {
32
+ return PRODUCT_POLICIES[contextProduct(product)];
33
+ }
34
+
35
+ export function toolPruningPolicy(product = 'ide') {
36
+ const profile = contextProduct(product);
37
+ return {
38
+ profile,
39
+ protected: [
40
+ 'write_file', 'edit_file', 'delete_file', 'validate_build',
41
+ 'lint_check', 'remember', 'todo_write', 'ask_user',
42
+ ],
43
+ eligible: profile === 'chat'
44
+ ? ['read_file', 'search_code', 'search_files', 'list_files', 'shell']
45
+ : profile === 'workspace'
46
+ ? ['read_file', 'search_code', 'search_files', 'list_files']
47
+ : ['read_file', 'search_code', 'search_files', 'list_files', 'shell'],
48
+ };
49
+ }
50
+
51
+ export function resolveContextBudget({
52
+ product = 'ide', model = null, contextLength = null, maxOutput = null,
53
+ fixedPromptTokens = 0, explicitThreshold = null,
54
+ } = {}) {
55
+ const policy = contextPolicy(product);
56
+ const catalog = findShippedModel(model);
57
+ const window = Number(contextLength || catalog?.context_length || 0);
58
+ const output = Number(maxOutput || catalog?.max_output || 0);
59
+ const explicit = Number(explicitThreshold || 0);
60
+ if (explicit > 0) {
61
+ return { ...policy, threshold: Math.max(20_000, Math.floor(explicit)), source: 'explicit' };
62
+ }
63
+ if (!Number.isFinite(window) || window <= 0) {
64
+ return { ...policy, threshold: 160_000, source: 'fallback' };
65
+ }
66
+ const reservedOutput = output > 0 ? output : Math.floor(window * 0.10);
67
+ const safety = Math.max(1024, Math.floor(window * 0.03));
68
+ const usable = Math.max(20_000, window - reservedOutput - Math.max(0, fixedPromptTokens) - safety);
69
+ const workingSet = Math.max(20_000, Math.floor(usable * policy.workingSetRatio));
70
+ return {
71
+ ...policy,
72
+ threshold: Math.max(20_000, Math.floor(workingSet * policy.triggerRatio)),
73
+ targetTokens: Math.max(10_000, Math.floor(workingSet * policy.targetRatio)),
74
+ contextLength: window,
75
+ reservedOutput,
76
+ usableTokens: usable,
77
+ workingSetTokens: workingSet,
78
+ fixedPromptTokens: Math.max(0, fixedPromptTokens),
79
+ source: 'model_catalog',
80
+ };
81
+ }
82
+
83
+ export function contextReductionConfig(env = process.env, product = 'ide') {
84
+ const profileName = contextProduct(product).toUpperCase();
85
+ const strategyRaw = String(
86
+ env[`BAHULAM_${profileName}_CONTEXT_STRATEGY`]
87
+ || env.BAHULAM_CONTEXT_STRATEGY
88
+ || env.BAHULAM_CONTEXT_REDUCTION_STRATEGY
89
+ || 'distillation',
90
+ ).trim().toLowerCase();
91
+ const threshold = Number.parseInt(
92
+ env.BAHULAM_SUMMARIZE_THRESHOLD || env.BAHULAM_CHAT_SUMMARIZE_THRESHOLD || '',
93
+ 10,
94
+ );
95
+ const preserve = Number.parseInt(
96
+ env.BAHULAM_SUMMARIZE_PRESERVE_TURNS || env.BAHULAM_CHAT_SUMMARIZE_PRESERVE_TURNS || '',
97
+ 10,
98
+ );
99
+ const sigma = Number.parseFloat(
100
+ env.BAHULAM_CONTEXT_DISTILLATION_SIGMA || env.BAHULAM_COMPACTION_SIGMA || '1.5',
101
+ );
102
+ return {
103
+ enabled: !['0', 'false', 'no', 'off'].includes(String(env.BAHULAM_SUMMARIZE || 'true').toLowerCase()),
104
+ strategy: ['summary', 'summarize', 'summarization'].includes(strategyRaw)
105
+ ? 'summarization'
106
+ : 'distillation',
107
+ threshold: Number.isFinite(threshold) && threshold > 0 ? Math.max(20_000, threshold) : null,
108
+ preserve: Number.isFinite(preserve) && preserve > 0 ? Math.max(2, preserve) : null,
109
+ sigma: Number.isFinite(sigma) ? Math.max(0.5, Math.min(4, sigma)) : 1.5,
110
+ };
111
+ }
112
+
113
+ export function estimateMessagesTokens(messages = []) {
114
+ return (Array.isArray(messages) ? messages : []).reduce((total, message) => {
115
+ let content = message?.content;
116
+ if (typeof content !== 'string') {
117
+ try { content = JSON.stringify(content ?? ''); } catch { content = String(content ?? ''); }
118
+ }
119
+ return total + Math.floor(String(content).length / 4) + 8;
120
+ }, 0);
121
+ }
122
+
123
+ function messageText(message) {
124
+ const content = message?.content;
125
+ if (typeof content === 'string') return content;
126
+ if (Array.isArray(content)) {
127
+ return content.map(block => block?.text || block?.content || block?.name || '').join(' ');
128
+ }
129
+ try { return JSON.stringify(content ?? ''); } catch { return String(content ?? ''); }
130
+ }
131
+
132
+ function toolNames(message) {
133
+ return (Array.isArray(message?.content) ? message.content : [])
134
+ .map(block => block?.name || block?.tool || block?.tool_name)
135
+ .filter(Boolean)
136
+ .map(String)
137
+ .map(name => name.toLowerCase());
138
+ }
139
+
140
+ function scoreMessage(message, index, total, preserve) {
141
+ const pos = total <= 1 ? 1 : index / (total - 1);
142
+ const edgeScore = 2 * ((Math.abs(pos - 0.5) * 2) ** 2);
143
+ const text = messageText(message);
144
+ const tools = toolNames(message);
145
+ let score = edgeScore;
146
+ const reasons = ['position'];
147
+ if (index === 0) { score += 4; reasons.push('root_intent'); }
148
+ if (index >= Math.max(0, total - preserve)) { score += 4; reasons.push('recent_tail'); }
149
+ if (message?.role === 'user') { score += 1.5; reasons.push('user_instruction'); }
150
+ if (tools.some(t => ['write', 'write_file', 'edit', 'edit_file', 'delete_file'].includes(t))) {
151
+ score += 4; reasons.push('durable_write');
152
+ }
153
+ if (tools.some(t => ['read', 'read_file', 'search_code', 'search_files', 'list_files'].includes(t))) {
154
+ score += 1.2; reasons.push('reference');
155
+ }
156
+ if (/(exit[_ -]?code|traceback|error|failed|exception|fatal)/i.test(text)) {
157
+ score += 1.2; reasons.push('failure_signal');
158
+ }
159
+ return { score, reasons, tools, text };
160
+ }
161
+
162
+ export function distillMessages(messages = [], { preserve = 10, sigma = 1.5, maxLines = 180 } = {}) {
163
+ const rows = (Array.isArray(messages) ? messages : []).filter(Boolean);
164
+ if (!rows.length) return null;
165
+ const scored = rows.map((message, index) => scoreMessage(message, index, rows.length, preserve));
166
+ const mean = scored.reduce((sum, row) => sum + row.score, 0) / scored.length;
167
+ const variance = scored.reduce((sum, row) => sum + ((row.score - mean) ** 2), 0) / scored.length;
168
+ const fullThreshold = mean + sigma * Math.sqrt(variance);
169
+ const groups = { full: [], structured: [], distilled: [] };
170
+ scored.forEach((row, index) => {
171
+ const forced = index === 0 || index >= Math.max(0, rows.length - preserve) || row.reasons.includes('durable_write');
172
+ const action = forced || row.score >= fullThreshold
173
+ ? 'full'
174
+ : row.score >= mean ? 'structured' : 'distilled';
175
+ const compact = row.text.replace(/\s+/g, ' ').trim().slice(0, action === 'full' ? 400 : 260);
176
+ groups[action].push(`- ${action}: ${row.tools.length ? `tool=${row.tools.slice(0, 3).join(',')}; ` : ''}${rows[index]?.role || 'message'}: ${compact}`);
177
+ });
178
+ return [
179
+ DISTILLATION_MARKER,
180
+ `policy=context-distillation-v1 sigma=${sigma} counts=${JSON.stringify({ keep_full: groups.full.length, keep_structured: groups.structured.length, distill: groups.distilled.length })}`,
181
+ '', 'Active ingredients kept full:', ...(groups.full.slice(0, maxLines) || ['- none']),
182
+ '', 'Structured middle evidence:', ...(groups.structured.slice(0, maxLines) || ['- none']),
183
+ '', 'Boiled-away noisy trail:', ...(groups.distilled.slice(0, Math.max(12, Math.floor(maxLines / 3))) || ['- none']),
184
+ ].join('\n');
185
+ }
186
+
187
+ export function collapseMessages(messages, summary, preserve = 10) {
188
+ const rows = Array.isArray(messages) ? messages : [];
189
+ let start = Math.max(0, rows.length - Math.max(2, preserve));
190
+ // Never hand the next provider an orphaned tool_result. If the retention
191
+ // boundary lands on a result-only user message, include the preceding
192
+ // assistant tool-use message as well.
193
+ while (start > 0 && isToolResultOnly(rows[start])) start--;
194
+ const tail = rows.slice(start);
195
+ return [
196
+ { role: 'user', content: summary },
197
+ { role: 'assistant', content: 'Understood — continuing from the summarized context above.' },
198
+ ...tail,
199
+ ];
200
+ }
201
+
202
+ function isToolResultOnly(message) {
203
+ return message?.role === 'user'
204
+ && Array.isArray(message.content)
205
+ && message.content.length > 0
206
+ && message.content.every(block => block?.type === 'tool_result');
207
+ }
@@ -29,6 +29,9 @@ import { startHttpDashboard } from '../daemon/http-dashboard.mjs';
29
29
  import { resolvePending } from '../daemon/approval-store.mjs';
30
30
  import { startRelayBridge } from '../daemon/relay-client.mjs';
31
31
  import { loadRemoteConfig } from '../commands/remote.mjs';
32
+ import { resolveGatewayUrl } from './backend-url.mjs';
33
+ import { DEFAULT_REASONING_MODEL } from '../config/model-defaults.mjs';
34
+ import { applyModelSelection, resolveModelSelection } from './model-selection.mjs';
32
35
  import { writeSessionMeta } from './event-log.mjs';
33
36
  import { daemonSessionDir } from './paths.mjs';
34
37
  import { publishSessionDirectory, markSessionClosed } from '../daemon/session-publisher.mjs';
@@ -49,8 +52,10 @@ import {
49
52
  * @param {number} [opts.maxCost] - abort if cost exceeds this USD amount
50
53
  * @param {boolean} [opts.verbose] - show progress on stderr
51
54
  */
52
- export async function runHeadless({ instruction, model, timeout = 300, maxCost, verbose = false, cacheReport = null, local = false, vision = [], agent = null, workflow = null }) {
55
+ export async function runHeadless({ instruction, model, timeout = 300, maxCost, verbose = false, cacheReport = null, local = false, mode = null, vision = [], agent = null, workflow = null }) {
53
56
  const startTime = Date.now();
57
+ const runtimeMode = mode || 'local';
58
+ const cliLocal = runtimeMode === 'local' || runtimeMode === 'direct';
54
59
 
55
60
  const log = (msg) => {
56
61
  if (verbose) process.stderr.write(`[headless] ${msg}\n`);
@@ -66,9 +71,11 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
66
71
  const graphTarget = agent || workflow;
67
72
  const anthKey = process.env.ANTHROPIC_API_KEY || creds.anthropicKey;
68
73
  const orKey = process.env.OPENROUTER_API_KEY || creds.openRouterKey;
74
+ const gatewayUrl = resolveGatewayUrl();
75
+ const localSessionId = `local_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`;
69
76
  // Graph runs execute locally and only need a model key; everything
70
77
  // else still requires login (the backend runs the agent loop).
71
- if (!creds.token && !(graphTarget && (anthKey || orKey))) {
78
+ if (!creds.token && runtimeMode !== 'direct' && !(graphTarget && (anthKey || orKey))) {
72
79
  emit({ type: 'error', error: 'Not logged in. Run: bahulam login' });
73
80
  process.exit(1);
74
81
  }
@@ -94,6 +101,10 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
94
101
  listLocalWorkflows: () => listLocalWorkflows(process.cwd()),
95
102
  renderEvent: (event) => emit({ type: event.type, ...event.data }),
96
103
  credentials: { apiKey: anthKey, openRouterKey: orKey },
104
+ modelTransport: runtimeMode === 'local' ? 'gateway' : 'direct',
105
+ gatewayUrl: runtimeMode === 'local' ? gatewayUrl : null,
106
+ gatewayToken: runtimeMode === 'local' ? creds.token : null,
107
+ sessionId: localSessionId,
97
108
  defaultModel: model || null,
98
109
  cwd: process.cwd(),
99
110
  });
@@ -121,6 +132,10 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
121
132
  listLocalWorkflows: () => listLocalWorkflows(process.cwd()),
122
133
  renderEvent: (event) => emit({ type: event.type, ...event.data }),
123
134
  credentials: { apiKey: anthKey, openRouterKey: orKey },
135
+ modelTransport: runtimeMode === 'local' ? 'gateway' : 'direct',
136
+ gatewayUrl: runtimeMode === 'local' ? gatewayUrl : null,
137
+ gatewayToken: runtimeMode === 'local' ? creds.token : null,
138
+ sessionId: localSessionId,
124
139
  defaultModel: model || null,
125
140
  cwd: process.cwd(),
126
141
  });
@@ -165,6 +180,10 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
165
180
  listLocalWorkflows: () => listLocalWorkflows(process.cwd()),
166
181
  renderEvent: (event) => emit({ type: event.type, ...event.data }),
167
182
  credentials: { apiKey: anthKey, openRouterKey: orKey },
183
+ modelTransport: runtimeMode === 'local' ? 'gateway' : 'direct',
184
+ gatewayUrl: runtimeMode === 'local' ? gatewayUrl : null,
185
+ gatewayToken: runtimeMode === 'local' ? creds.token : null,
186
+ sessionId: localSessionId,
168
187
  defaultModel: model || null,
169
188
  cwd: process.cwd(),
170
189
  });
@@ -185,27 +204,70 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
185
204
  // just added to _callClaude / _callOpenRouter. Model comes from the
186
205
  // --model flag (which overrides settings dynamically for benchmarking).
187
206
  let client;
188
- if (local) {
207
+ if (runtimeMode === 'local') {
189
208
  const { LocalAgent } = await import('./local-agent.mjs');
190
- const localModel = model || creds.models?.local || 'anthropic/claude-sonnet-4';
191
- const orKey = process.env.OPENROUTER_API_KEY || creds.openRouterKey;
192
- const anthKey = process.env.ANTHROPIC_API_KEY || creds.anthropicKey;
193
- if (!orKey && !anthKey) {
194
- emit({ type: 'error', error: '--local requires OPENROUTER_API_KEY or ANTHROPIC_API_KEY' });
209
+ const localSelection = resolveModelSelection({
210
+ explicitModel: model,
211
+ modelOverrides: creds.modelConfig,
212
+ modelMode: creds.modelMode,
213
+ modelRoute: creds.routePreference,
214
+ profileModels: creds.models,
215
+ modeModels: { fast: creds.models?.fast },
216
+ fallbackModel: DEFAULT_REASONING_MODEL,
217
+ });
218
+ const localModel = localSelection.model;
219
+ const gatewayToken = creds.token;
220
+ if (!gatewayToken) {
221
+ emit({ type: 'error', error: '--local requires an authenticated Bahulam session' });
195
222
  process.exit(1);
196
223
  }
224
+ const pluginSchemas = toolExecutor.listPluginToolSchemas?.() || [];
197
225
  client = {
198
226
  execute: (instr, ctx) => new LocalAgent({
199
- apiKey: anthKey,
200
- openRouterKey: orKey,
201
227
  model: localModel,
202
228
  toolExecutor,
203
229
  verbose,
204
230
  cwd: process.cwd(),
205
231
  maxTurns: 50,
232
+ gatewayUrl,
233
+ gatewayToken,
234
+ sessionId: localSessionId,
235
+ extraToolSchemas: pluginSchemas,
236
+ }).execute(instr, ctx),
237
+ };
238
+ log(`Local mode via Bahulam Gateway: ${localModel}`);
239
+ } else if (runtimeMode === 'direct') {
240
+ const { LocalAgent } = await import('./local-agent.mjs');
241
+ const directSelection = resolveModelSelection({
242
+ explicitModel: model,
243
+ modelOverrides: creds.modelConfig,
244
+ modelMode: creds.modelMode,
245
+ modelRoute: creds.routePreference,
246
+ profileModels: creds.models,
247
+ modeModels: { fast: creds.models?.fast },
248
+ fallbackModel: DEFAULT_REASONING_MODEL,
249
+ });
250
+ const directModel = directSelection.model;
251
+ const directOpenRouterKey = process.env.OPENROUTER_API_KEY || creds.openRouterKey;
252
+ const directAnthropicKey = process.env.ANTHROPIC_API_KEY || creds.anthropicKey;
253
+ if (!directOpenRouterKey && !directAnthropicKey) {
254
+ emit({ type: 'error', error: '--direct requires OPENROUTER_API_KEY or ANTHROPIC_API_KEY' });
255
+ process.exit(1);
256
+ }
257
+ const pluginSchemas = toolExecutor.listPluginToolSchemas?.() || [];
258
+ client = {
259
+ execute: (instr, ctx) => new LocalAgent({
260
+ apiKey: directAnthropicKey,
261
+ openRouterKey: directOpenRouterKey,
262
+ model: directModel,
263
+ toolExecutor,
264
+ verbose,
265
+ cwd: process.cwd(),
266
+ maxTurns: 50,
267
+ extraToolSchemas: pluginSchemas,
206
268
  }).execute(instr, ctx),
207
269
  };
208
- log(`Local mode: ${localModel}`);
270
+ log(`Direct mode: ${directModel}`);
209
271
  } else {
210
272
  client = new BahulamStreamClient({
211
273
  baseUrl: creds.backendUrl,
@@ -213,6 +275,7 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
213
275
  toolExecutor,
214
276
  approvalManager: approval,
215
277
  pluginRegistry,
278
+ mode: runtimeMode === 'bundled' ? 'bundled' : 'remote',
216
279
  });
217
280
  }
218
281
 
@@ -225,7 +288,7 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
225
288
  }, timeoutMs);
226
289
 
227
290
  // ── Vision analysis preflight ──
228
- if (!local) {
291
+ if (!cliLocal) {
229
292
  try {
230
293
  const prepared = prepareImageAttachments(instruction, {
231
294
  cwd: process.cwd(),
@@ -280,7 +343,16 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
280
343
  }),
281
344
  agent_context: toolExecutor.getAgentContext(),
282
345
  };
283
- if (model) execContext.model_override = model;
346
+ const modelSelection = resolveModelSelection({
347
+ explicitModel: model,
348
+ modelOverrides: creds.modelConfig,
349
+ modelMode: creds.modelMode,
350
+ modelRoute: creds.routePreference,
351
+ profileModels: creds.models,
352
+ modeModels: { fast: creds.models?.fast },
353
+ fallbackModel: DEFAULT_REASONING_MODEL,
354
+ });
355
+ Object.assign(execContext, applyModelSelection({}, modelSelection));
284
356
 
285
357
  let primaryToolCount = 0;
286
358
  let subAgentForwardedToolCount = 0;