@bahulam/code 2.6.17 → 2.6.18

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.17",
3
+ "version": "2.6.18",
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": {
@@ -33,7 +33,7 @@ export function parseArgs(args) {
33
33
  resume: false,
34
34
  resumeSessionId: null,
35
35
  headless: false,
36
- freeswim: false,
36
+ skipPermissions: false,
37
37
  vision: [],
38
38
  verbose: false,
39
39
  debug: false,
@@ -106,7 +106,7 @@ export function parseArgs(args) {
106
106
 
107
107
  case '--headless':
108
108
  result.headless = true;
109
- result.freeswim = true; // headless implies skip permissions
109
+ result.skipPermissions = true; // headless implies skip permissions
110
110
  break;
111
111
 
112
112
  case '--cache-report':
@@ -128,10 +128,8 @@ export function parseArgs(args) {
128
128
  break;
129
129
  }
130
130
 
131
- case '--freeswim-open-waters':
132
- case '--freeswim':
133
- case '--yes':
134
- result.freeswim = true;
131
+ case '--dangerously-skip-permissions':
132
+ result.skipPermissions = true;
135
133
  break;
136
134
 
137
135
  case '--verbose':
@@ -189,9 +187,7 @@ Options:
189
187
  --headless Non-interactive mode: auto-approve, JSONL output
190
188
  --cache-report <file> Write prompt-cache summary JSON to <file> (headless only)
191
189
  --vision <image-path> Attach image path in headless mode
192
- --freeswim-open-waters Skip all approval prompts (no boundaries)
193
- --freeswim Alias for --freeswim-open-waters
194
- --yes Alias for --freeswim-open-waters
190
+ --dangerously-skip-permissions Skip ALL approval prompts, including dangerous tiers
195
191
  --verbose, -v Verbose output
196
192
  --debug, -d Debug mode
197
193
  --version Show version
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Prose document chunker — JS port of the backend's Python chunker.
3
+ *
4
+ * Mirrors `codekepler-backend/app/agent/tools/shared/documents.py` so a
5
+ * file chunked here (CLI local path) and a file chunked server-side
6
+ * (chat upload or server `path` mode) produce byte-comparable chunks
7
+ * with the same page/chunk numbering. Callers can slice/reference
8
+ * chunks by (page_no, chunk_no) across surfaces.
9
+ *
10
+ * Constants come from Django's Phase 1 inline chunker
11
+ * (retail/chat_uploads/services.py) — the single source of truth for
12
+ * the whole platform.
13
+ *
14
+ * ─────────────────────────────────────────────────────────────────────
15
+ * Retriever interface — this module produces chunks; a retriever ranks
16
+ * them. Today the CLI has one retriever (BM25 via
17
+ * src/context/retriever.mjs). Desktop apps and richer offline setups
18
+ * will add an embedding-backed retriever. Both consume the same chunk
19
+ * shape produced here so the swap is transparent to callers:
20
+ *
21
+ * Chunk (produced by this module):
22
+ * { page: number|null, chunk_no: number, text: string, tokens: number }
23
+ *
24
+ * Retriever (any implementation):
25
+ * addSource(sourceId, chunks): void
26
+ * search(query, { topK, sources? }): Array<{
27
+ * sourceId, page, chunk_no, text, score
28
+ * }>
29
+ * removeSource(sourceId): void // invalidate on file change
30
+ *
31
+ * The `read_attachment` tool talks to the retriever interface, never
32
+ * to a specific implementation. Adding embeddings later means writing
33
+ * an `EmbeddingRetriever` that satisfies this contract — no changes to
34
+ * the chunker or the tool.
35
+ * ─────────────────────────────────────────────────────────────────────
36
+ *
37
+ * Divergences from Python (documented):
38
+ * - JS String indexing is UTF-16 code units, Python str is code points.
39
+ * For BMP-only text (nearly all documents) the boundaries match
40
+ * exactly. Non-BMP characters (emoji, some CJK) may fall a code unit
41
+ * off vs Python; not a correctness issue for retrieval.
42
+ * - TEXT_LIKE_MIMES is a JS-only superset of Python's TEXT_MIMES —
43
+ * the CLI has historically supported json/yaml/html/log/rst, and
44
+ * the chunker doesn't care about the surface syntax. Retrieval
45
+ * quality on structured files (json/yaml) will be worse than on
46
+ * prose; document that in the tool description, not here.
47
+ */
48
+
49
+ import * as fs from 'node:fs';
50
+ import * as path from 'node:path';
51
+
52
+ // Byte-exact match with documents.py:29-31.
53
+ export const CHUNK_TOKENS = 800;
54
+ export const CHUNK_OVERLAP = 100;
55
+ export const CHARS_PER_TOKEN = 4;
56
+
57
+ // Strict server-parity set — same 5 mimes as documents.py.
58
+ export const TEXT_MIMES = new Set([
59
+ 'text/plain',
60
+ 'text/markdown',
61
+ 'text/csv',
62
+ 'text/tab-separated-values',
63
+ ]);
64
+ export const PDF_MIMES = new Set(['application/pdf']);
65
+ export const DOCUMENT_MIMES = new Set([...TEXT_MIMES, ...PDF_MIMES]);
66
+
67
+ // Wider set the CLI already supports at the `read_attachment` tool layer.
68
+ // Chunking works fine on any UTF-8 text — retrieval quality on structured
69
+ // formats is up to the retriever + query.
70
+ export const TEXT_LIKE_MIMES = new Set([
71
+ ...TEXT_MIMES,
72
+ 'application/json',
73
+ 'application/x-yaml',
74
+ 'application/toml',
75
+ 'text/yaml',
76
+ 'text/html',
77
+ 'text/x-log',
78
+ 'text/x-rst',
79
+ 'text/x-restructuredtext',
80
+ ]);
81
+
82
+ // Extension → mime map. Kept small and explicit — Python's
83
+ // mimetypes.guess_type varies by OS registry; this table is stable.
84
+ const EXT_TO_MIME = new Map([
85
+ ['.txt', 'text/plain'],
86
+ ['.log', 'text/x-log'],
87
+ ['.md', 'text/markdown'],
88
+ ['.markdown', 'text/markdown'],
89
+ ['.mdx', 'text/markdown'], // MDX = markdown + JSX; treat as prose for retrieval
90
+ ['.csv', 'text/csv'],
91
+ ['.tsv', 'text/tab-separated-values'],
92
+ ['.json', 'application/json'],
93
+ ['.yaml', 'application/x-yaml'],
94
+ ['.yml', 'application/x-yaml'],
95
+ ['.toml', 'application/toml'],
96
+ ['.html', 'text/html'],
97
+ ['.htm', 'text/html'],
98
+ ['.rst', 'text/x-rst'],
99
+ ['.pdf', 'application/pdf'],
100
+ ]);
101
+
102
+ /**
103
+ * Best-effort mime for a local file (extension-only, no magic-byte sniff).
104
+ * Files with no matching extension return 'application/octet-stream',
105
+ * consistent with Python's mimetypes.
106
+ */
107
+ export function guessMime(filePath) {
108
+ const ext = path.extname(String(filePath || '')).toLowerCase();
109
+ return EXT_TO_MIME.get(ext) || 'application/octet-stream';
110
+ }
111
+
112
+ /**
113
+ * Chunk a text string with the 800/100 sliding window (character-based).
114
+ * Byte-comparable to Python's chunk_text() — see documents.py:40.
115
+ *
116
+ * @param {string} text
117
+ * @returns {Array<{chunk_no: number, text: string, tokens: number}>}
118
+ */
119
+ export function chunkText(text) {
120
+ if (!text) return [];
121
+ const stepChars = (CHUNK_TOKENS - CHUNK_OVERLAP) * CHARS_PER_TOKEN;
122
+ const window = CHUNK_TOKENS * CHARS_PER_TOKEN;
123
+ const out = [];
124
+ let i = 0;
125
+ let chunkNo = 0;
126
+ const len = text.length;
127
+ while (i < len) {
128
+ const segment = text.substring(i, i + window);
129
+ const tokens = Math.max(1, Math.floor(segment.length / CHARS_PER_TOKEN));
130
+ out.push({ chunk_no: chunkNo, text: segment, tokens });
131
+ chunkNo += 1;
132
+ i += stepChars;
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /**
138
+ * Extract per-page text from PDF bytes. Returns [{page, text}] with
139
+ * 1-indexed page numbers. Scanned/OCR-only pages come back with empty
140
+ * text — callers skip those (matches Python behavior).
141
+ *
142
+ * Uses the `pdf-parse` npm dep already in the CLI. Imported from
143
+ * `lib/pdf-parse.js` (not the default entry) to skip the debug-hook
144
+ * that opens a bundled test PDF at load time and fails in production.
145
+ */
146
+ export async function extractPdfPages(buffer) {
147
+ const { default: pdfParse } = await import('pdf-parse/lib/pdf-parse.js');
148
+ const pageTexts = [];
149
+ try {
150
+ await pdfParse(buffer, {
151
+ // pdf-parse calls this per page in page order (pageIndex is 0-based).
152
+ // We accumulate into an array indexed by pageIndex to be defensive
153
+ // against any out-of-order rendering.
154
+ pagerender: async (pageData) => {
155
+ try {
156
+ const content = await pageData.getTextContent();
157
+ const text = (content.items || [])
158
+ .map(item => (typeof item.str === 'string' ? item.str : ''))
159
+ .join(' ');
160
+ const idx = typeof pageData.pageIndex === 'number' ? pageData.pageIndex : pageTexts.length;
161
+ pageTexts[idx] = text;
162
+ return text;
163
+ } catch {
164
+ return '';
165
+ }
166
+ },
167
+ });
168
+ } catch {
169
+ return [];
170
+ }
171
+ return pageTexts.map((text, i) => ({ page: i + 1, text: text || '' }));
172
+ }
173
+
174
+ /**
175
+ * Extract chunks from raw bytes. Returns the same shape as Python's
176
+ * extract_from_bytes(): [{page, chunk_no, text, tokens}].
177
+ *
178
+ * page is null for text mimes, 1-indexed for PDF pages.
179
+ * chunk_no is 0-indexed and monotonic across the whole document
180
+ * (matches Python `global_chunk` behavior at documents.py:109-115).
181
+ * Unsupported mimes return an empty array.
182
+ *
183
+ * @param {Buffer} buffer
184
+ * @param {string} mime
185
+ * @param {{textMimes?: Set<string>}} [opts] override which mimes are
186
+ * treated as chunkable text. Defaults to TEXT_LIKE_MIMES (CLI's
187
+ * permissive set). Pass TEXT_MIMES for strict server parity.
188
+ */
189
+ export async function extractFromBytes(buffer, mime, opts = {}) {
190
+ const normalizedMime = String(mime || '').toLowerCase();
191
+ const textMimes = opts.textMimes || TEXT_LIKE_MIMES;
192
+
193
+ if (textMimes.has(normalizedMime)) {
194
+ const text = buffer.toString('utf8');
195
+ return chunkText(text).map(c => ({
196
+ page: null,
197
+ chunk_no: c.chunk_no,
198
+ text: c.text,
199
+ tokens: c.tokens,
200
+ }));
201
+ }
202
+
203
+ if (PDF_MIMES.has(normalizedMime)) {
204
+ const pages = await extractPdfPages(buffer);
205
+ const out = [];
206
+ let globalChunk = 0;
207
+ for (const { page, text } of pages) {
208
+ if (!text || !text.trim()) continue;
209
+ for (const c of chunkText(text)) {
210
+ out.push({ page, chunk_no: globalChunk, text: c.text, tokens: c.tokens });
211
+ globalChunk += 1;
212
+ }
213
+ }
214
+ return out;
215
+ }
216
+
217
+ return [];
218
+ }
219
+
220
+ /**
221
+ * Read a local file and chunk it. Returns { mime, chunks }; both empty
222
+ * on unresolvable path or unsupported mime — same contract as Python's
223
+ * extract_from_path(). Path resolution is the caller's responsibility
224
+ * (the CLI uses projectRegistry.resolvePath to enforce workspace bounds
225
+ * before calling here).
226
+ *
227
+ * @param {string} absPath absolute, already-resolved file path
228
+ * @param {{textMimes?: Set<string>}} [opts]
229
+ * @returns {Promise<{mime: string, chunks: Array<object>}>}
230
+ */
231
+ export async function extractFromPath(absPath, opts = {}) {
232
+ let stat;
233
+ try {
234
+ stat = fs.statSync(absPath);
235
+ } catch {
236
+ return { mime: '', chunks: [] };
237
+ }
238
+ if (!stat.isFile()) return { mime: '', chunks: [] };
239
+
240
+ const mime = guessMime(absPath);
241
+ const textMimes = opts.textMimes || TEXT_LIKE_MIMES;
242
+ if (!textMimes.has(mime) && !PDF_MIMES.has(mime)) {
243
+ return { mime, chunks: [] };
244
+ }
245
+
246
+ let buffer;
247
+ try {
248
+ buffer = fs.readFileSync(absPath);
249
+ } catch {
250
+ return { mime, chunks: [] };
251
+ }
252
+
253
+ const chunks = await extractFromBytes(buffer, mime, opts);
254
+ return { mime, chunks };
255
+ }
@@ -132,6 +132,123 @@ export class ContextRetriever {
132
132
  return true;
133
133
  }
134
134
 
135
+ /**
136
+ * Add already-chunked prose content (from prose-chunker.mjs) to the
137
+ * shared BM25 index — no re-read or re-chunking. Used by
138
+ * `read_attachment` to make docs discoverable by `search_code` /
139
+ * future `search_document` without a separate index.
140
+ *
141
+ * Chunk IDs are shaped `${sourceId}#c${chunk_no}` — the `#c`
142
+ * separator makes them distinguishable from code IDs (which use `:`)
143
+ * so `updateFile` for code files won't accidentally drop prose
144
+ * chunks and vice versa.
145
+ *
146
+ * Re-adding the same sourceId replaces its chunks (idempotent —
147
+ * safe to call on every `read_attachment`).
148
+ *
149
+ * @param {string} sourceId stable id, usually a project-relative path
150
+ * @param {Array<{page: (number|null), chunk_no: number, text: string, tokens?: number}>} chunks
151
+ * @returns {number} chunks indexed
152
+ */
153
+ addProseChunks(sourceId, chunks) {
154
+ if (!Array.isArray(chunks) || chunks.length === 0) return 0;
155
+ if (!sourceId || typeof sourceId !== 'string') return 0;
156
+
157
+ if (!this.index) {
158
+ if (!this.loadIndex()) {
159
+ this.index = new BM25Index();
160
+ this.chunkTexts = new Map();
161
+ }
162
+ }
163
+
164
+ // Drop prior chunks for this source (idempotent).
165
+ const sourcePrefix = `${sourceId}#c`;
166
+ const oldIds = new Set();
167
+ for (const doc of this.index.docs) {
168
+ if (doc.id.startsWith(sourcePrefix)) oldIds.add(doc.id);
169
+ }
170
+ for (const id of oldIds) this.chunkTexts.delete(id);
171
+
172
+ // Collect surviving docs — reconstruct text from stored chunkTexts
173
+ // (BM25Index only stores tf maps + lengths, not raw text).
174
+ const remaining = [];
175
+ for (const doc of this.index.docs) {
176
+ if (!oldIds.has(doc.id)) {
177
+ remaining.push({ id: doc.id, text: this.chunkTexts.get(doc.id) || '' });
178
+ }
179
+ }
180
+
181
+ // Prep new prose docs — prefix indexed text with sourceId so BM25
182
+ // gets signal from the filename (mirrors how _chunkFile embeds
183
+ // relPath). Page tag included so page-scoped queries can hit it.
184
+ const newDocs = chunks.map(c => {
185
+ const id = `${sourceId}#c${c.chunk_no}`;
186
+ const pageTag = c.page != null ? ` page:${c.page}` : '';
187
+ const indexedText = `${sourceId}${pageTag}\n${c.text}`;
188
+ return { id, text: indexedText };
189
+ });
190
+
191
+ // Rebuild — BM25 needs IDF recomputed across all docs.
192
+ this.index = new BM25Index();
193
+ this.index.buildIndex([...remaining, ...newDocs]);
194
+ for (const doc of newDocs) {
195
+ this.chunkTexts.set(doc.id, doc.text);
196
+ }
197
+
198
+ // Persist. Best-effort — an unwritable indexDir shouldn't fail
199
+ // the tool call.
200
+ try {
201
+ if (!fs.existsSync(this.indexDir)) fs.mkdirSync(this.indexDir, { recursive: true });
202
+ fs.writeFileSync(path.join(this.indexDir, 'bm25.json'), JSON.stringify(this.index.toJSON()));
203
+ fs.writeFileSync(path.join(this.indexDir, 'chunks.json'), JSON.stringify(Object.fromEntries(this.chunkTexts)));
204
+ } catch { /* best-effort persist */ }
205
+
206
+ return newDocs.length;
207
+ }
208
+
209
+ /**
210
+ * Remove all chunks belonging to a source (either code path or
211
+ * prose sourceId). Called when a file is deleted so stale hits
212
+ * don't linger in the index.
213
+ */
214
+ removeSource(sourceId) {
215
+ if (!this.index) {
216
+ if (!this.loadIndex()) return 0;
217
+ }
218
+ const oldIds = new Set();
219
+ for (const doc of this.index.docs) {
220
+ // Code IDs: exact match or `sourceId:` prefix (line/AST chunks).
221
+ // Prose IDs: `sourceId#c` prefix.
222
+ if (
223
+ doc.id === sourceId
224
+ || doc.id.startsWith(`${sourceId}:`)
225
+ || doc.id.startsWith(`${sourceId}#c`)
226
+ ) {
227
+ oldIds.add(doc.id);
228
+ }
229
+ }
230
+ if (oldIds.size === 0) return 0;
231
+ for (const id of oldIds) this.chunkTexts.delete(id);
232
+
233
+ const remaining = [];
234
+ for (const doc of this.index.docs) {
235
+ if (!oldIds.has(doc.id)) {
236
+ remaining.push({ id: doc.id, text: this.chunkTexts.get(doc.id) || '' });
237
+ }
238
+ }
239
+
240
+ this.index = new BM25Index();
241
+ this.index.buildIndex(remaining);
242
+
243
+ try {
244
+ if (!fs.existsSync(this.indexDir)) fs.mkdirSync(this.indexDir, { recursive: true });
245
+ fs.writeFileSync(path.join(this.indexDir, 'bm25.json'), JSON.stringify(this.index.toJSON()));
246
+ fs.writeFileSync(path.join(this.indexDir, 'chunks.json'), JSON.stringify(Object.fromEntries(this.chunkTexts)));
247
+ } catch { /* best-effort */ }
248
+
249
+ return oldIds.size;
250
+ }
251
+
135
252
  /** Load persisted index. */
136
253
  loadIndex() {
137
254
  const indexPath = path.join(this.indexDir, 'bm25.json');
@@ -40,6 +40,7 @@ import * as fs from 'node:fs';
40
40
  import { randomBytes } from 'node:crypto';
41
41
  import * as net from 'node:net';
42
42
  import * as http from 'node:http';
43
+ import { fileURLToPath } from 'node:url';
43
44
 
44
45
  const DEV_RUNTIME_ROOT = path.join(os.homedir(), '.bahulam', 'runtime', 'current');
45
46
  const READY_TIMEOUT_MS = 30_000;
@@ -72,7 +73,14 @@ function _runtimeRoot() {
72
73
  const arch = process.arch; // 'arm64' | 'x64' | ...
73
74
  const siblingName = `@bahulam/runtime-${plat}-${arch}`;
74
75
  try {
75
- const here = path.dirname(new URL(import.meta.url).pathname);
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));
76
84
  // Walk up looking for node_modules containing @bahulam/runtime-<plat>-<arch>
77
85
  let dir = here;
78
86
  for (let i = 0; i < 6; i++) {
@@ -250,6 +250,22 @@ export function formatAgentErrorGuidance(data = {}) {
250
250
  lines.push(`You reached the message limit for this plan.${retry}`);
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
+ } else if (
254
+ // Auth-specific gateway error → session expired / token invalid.
255
+ // Must match BEFORE the generic gateway branch below, otherwise
256
+ // 'gateway_authentication_error' falls into the retry-hint branch
257
+ // and the user sees "usually transient — retry" when the real fix
258
+ // is to re-login. The 401/token wording covers both server-shaped
259
+ // messages ("Invalid or expired token") and message-only fallbacks.
260
+ code === 'gateway_authentication_error' ||
261
+ code === 'authentication_error' ||
262
+ /401|invalid.*or.*expired.*token|invalid token|expired token|not\s*authenticated/i.test(message)
263
+ ) {
264
+ lines.push('Your session with Bahulam has expired or become invalid.');
265
+ lines.push('Re-authenticate:');
266
+ lines.push(' bahulam logout # clears saved token');
267
+ lines.push(' bahulam login # opens browser to re-auth');
268
+ lines.push('Then retry the command.');
253
269
  } else if (phase === 'gateway' || code.includes('gateway')) {
254
270
  // Generic first: on the platform route the user has NO provider key —
255
271
  // telling them to "check your DeepSeek API key" is wrong and alarming.
@@ -263,7 +279,7 @@ export function formatAgentErrorGuidance(data = {}) {
263
279
  lines.push('Using your own key (BYOK)? Verify it is saved and active in settings, then run /login so provider settings sync.');
264
280
  }
265
281
  } else if (/authentication|token/i.test(message)) {
266
- lines.push('Run /login to re-authenticate.');
282
+ lines.push('Run `bahulam login` to re-authenticate.');
267
283
  } else if (/api key|openrouter/i.test(message)) {
268
284
  lines.push('Run /config to set up or refresh your provider settings.');
269
285
  } else if (/backend|network/i.test(message)) {
@@ -147,7 +147,8 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
147
147
  }
148
148
  const execContext = {
149
149
  cwd: process.cwd(),
150
- freeswim: true,
150
+ skip_permissions: true,
151
+ freeswim: true, // legacy wire alias — drop after cloud backend 2.7 rollout
151
152
  project_resources: projectResources,
152
153
  work_scope: buildWorkScope({
153
154
  instruction,