@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 +1 -1
- package/src/config/cli-args.mjs +5 -9
- package/src/context/prose-chunker.mjs +255 -0
- package/src/context/retriever.mjs +117 -0
- package/src/core/bundled-runtime.mjs +9 -1
- package/src/core/error-guidance.mjs +17 -1
- package/src/core/headless.mjs +2 -1
- package/src/core/tool-executor.mjs +252 -0
- package/src/terminal/repl-ask-form.mjs +120 -0
- package/src/terminal/repl-render.mjs +14 -3
- package/src/terminal/repl.mjs +83 -9
- package/src/ui/tool-card.mjs +26 -0
- package/src/index.mjs +0 -430
package/package.json
CHANGED
package/src/config/cli-args.mjs
CHANGED
|
@@ -33,7 +33,7 @@ export function parseArgs(args) {
|
|
|
33
33
|
resume: false,
|
|
34
34
|
resumeSessionId: null,
|
|
35
35
|
headless: false,
|
|
36
|
-
|
|
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.
|
|
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 '--
|
|
132
|
-
|
|
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
|
-
--
|
|
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
|
-
|
|
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
|
|
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)) {
|
package/src/core/headless.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|