@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
|
@@ -41,6 +41,7 @@ export function createToolExecutor({
|
|
|
41
41
|
skillInstaller = null,
|
|
42
42
|
checkpoints = null,
|
|
43
43
|
hookRunner = null,
|
|
44
|
+
interactionHandler = null,
|
|
44
45
|
} = {}) {
|
|
45
46
|
const occRegistry = createToolRegistry();
|
|
46
47
|
const skillTool = occRegistry.get('Skill');
|
|
@@ -49,6 +50,23 @@ export function createToolExecutor({
|
|
|
49
50
|
cwd: process.cwd(),
|
|
50
51
|
homeDir: skillsLoader.homeDir || os.homedir(),
|
|
51
52
|
});
|
|
53
|
+
|
|
54
|
+
// ── Auto-register the current working directory as a project ──
|
|
55
|
+
// Without this, shell / list_files / read_attachment fail with
|
|
56
|
+
// "No projects registered. Call get_project_overview first." on
|
|
57
|
+
// any fresh folder — including a legitimate user CWD they just
|
|
58
|
+
// cd'd into to start work. The model then has to spend a turn
|
|
59
|
+
// registering before it can do anything, which is a poor first-run
|
|
60
|
+
// UX. Fire-and-forget: if registration fails (permissions, weird
|
|
61
|
+
// FS), the model can still call get_project_overview explicitly.
|
|
62
|
+
// bypassProjectMarkers=true because we don't require a .git etc.
|
|
63
|
+
// for the current directory to be usable — the user chose to be here.
|
|
64
|
+
// Opt out via BAHULAM_SKIP_AUTO_REGISTER=true for tests or headless
|
|
65
|
+
// scripts that want a truly empty registry.
|
|
66
|
+
if (process.env.BAHULAM_SKIP_AUTO_REGISTER !== 'true') {
|
|
67
|
+
projectRegistry.register(process.cwd(), { bypassProjectMarkers: true })
|
|
68
|
+
.catch(() => { /* silent — model can register explicitly */ });
|
|
69
|
+
}
|
|
52
70
|
let _searchCodeUsed = false; // tracks if search_code was called (for read_file nudge)
|
|
53
71
|
let _readOnlyCacheGeneration = 0;
|
|
54
72
|
const readOnlyResultCache = new Map();
|
|
@@ -595,6 +613,240 @@ export function createToolExecutor({
|
|
|
595
613
|
// ── Tool mapping table ──────────────────────────────────────
|
|
596
614
|
|
|
597
615
|
const toolMap = {
|
|
616
|
+
// 0. ask_user → interactive direction question (client-executed).
|
|
617
|
+
// The UI form is injected by the REPL via `interactionHandler`;
|
|
618
|
+
// headless/piped sessions have none and get the best-judgment
|
|
619
|
+
// fallback so the agent is never blocked on a missing human.
|
|
620
|
+
ask_user: async (args, options = {}) => {
|
|
621
|
+
throwIfAborted(options.signal);
|
|
622
|
+
const question = String(args?.question || '').trim();
|
|
623
|
+
const choices = Array.isArray(args?.options)
|
|
624
|
+
? args.options.map(o => String(o || '').trim()).filter(Boolean)
|
|
625
|
+
: [];
|
|
626
|
+
if (!question) {
|
|
627
|
+
return { success: false, output: 'ask_user requires a question.', _tool: 'ask_user' };
|
|
628
|
+
}
|
|
629
|
+
if (choices.length < 2 || choices.length > 4) {
|
|
630
|
+
return { success: false, output: 'ask_user requires 2-4 options.', _tool: 'ask_user' };
|
|
631
|
+
}
|
|
632
|
+
if (!interactionHandler || !process.stdin.isTTY) {
|
|
633
|
+
return {
|
|
634
|
+
success: true,
|
|
635
|
+
output: 'No interactive user is available in this session. Proceed with your best judgment and state the assumption you made.',
|
|
636
|
+
_tool: 'ask_user',
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
const res = await interactionHandler({ question, options: choices, context: args?.context });
|
|
640
|
+
throwIfAborted(options.signal);
|
|
641
|
+
if (!res || !res.answer) {
|
|
642
|
+
return {
|
|
643
|
+
success: true,
|
|
644
|
+
output: 'The user declined to answer. Proceed with your best judgment and state the assumption you made.',
|
|
645
|
+
_tool: 'ask_user',
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
return {
|
|
649
|
+
success: true,
|
|
650
|
+
output: `User answered: ${res.answer}${res.source === 'free_text' ? ' (typed answer, not one of the offered options)' : ''}`,
|
|
651
|
+
_tool: 'ask_user',
|
|
652
|
+
};
|
|
653
|
+
},
|
|
654
|
+
|
|
655
|
+
// 0b. read_attachment → chunked text extraction from a local document.
|
|
656
|
+
// Backend registers the schema; execution happens client-side because
|
|
657
|
+
// only the CLI has the user's filesystem. Supports the `path` mode
|
|
658
|
+
// (local file); `upload_id` is chat-only and returns a redirect
|
|
659
|
+
// error. Uses the shared prose-chunker so chunk boundaries and
|
|
660
|
+
// page/chunk numbering match the server-side path executor byte-
|
|
661
|
+
// for-byte (documents.py). Supports total_chunks metadata mode,
|
|
662
|
+
// chunk_range/chunk_no selection, page filtering (PDFs), and
|
|
663
|
+
// case-insensitive query substring filtering.
|
|
664
|
+
read_attachment: async (args, options = {}) => {
|
|
665
|
+
throwIfAborted(options.signal);
|
|
666
|
+
const uploadId = String(args?.upload_id || '').trim();
|
|
667
|
+
const rawPath = String(args?.path || '').trim();
|
|
668
|
+
if (uploadId && !rawPath) {
|
|
669
|
+
return {
|
|
670
|
+
success: false,
|
|
671
|
+
output: 'upload_id is a chat-only mode. In the CLI, pass path=<local path> to read a file the user has on disk.',
|
|
672
|
+
_tool: 'read_attachment',
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
if (!rawPath) {
|
|
676
|
+
return {
|
|
677
|
+
success: false,
|
|
678
|
+
output: 'read_attachment requires path=<local path> (upload_id is chat-only).',
|
|
679
|
+
_tool: 'read_attachment',
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
// Route through projectRegistry.resolvePath — the same helper
|
|
683
|
+
// read_file/edit_file use. This handles shell-escape unescaping,
|
|
684
|
+
// LLM-quoting normalization, and external-file registration for
|
|
685
|
+
// paths outside registered project roots (attachments in
|
|
686
|
+
// ~/Downloads, /tmp, etc. are legitimate).
|
|
687
|
+
let abs;
|
|
688
|
+
try {
|
|
689
|
+
abs = await resolvePath(rawPath, args, { allowExternalFileRead: true });
|
|
690
|
+
} catch (err) {
|
|
691
|
+
return { success: false, output: String(err?.message || err), _tool: 'read_attachment' };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const { extractFromPath } = await import('../context/prose-chunker.mjs');
|
|
695
|
+
let mime, chunks;
|
|
696
|
+
try {
|
|
697
|
+
({ mime, chunks } = await extractFromPath(abs));
|
|
698
|
+
} catch (err) {
|
|
699
|
+
return { success: false, output: `Failed to read ${abs}: ${err?.message || err}`, _tool: 'read_attachment' };
|
|
700
|
+
}
|
|
701
|
+
if (!mime) {
|
|
702
|
+
return { success: false, output: `File not found or not a regular file: ${abs}`, _tool: 'read_attachment' };
|
|
703
|
+
}
|
|
704
|
+
if (!chunks.length) {
|
|
705
|
+
return {
|
|
706
|
+
success: false,
|
|
707
|
+
output: `Unsupported or empty file (mime=${mime}). Supported: pdf, txt, md/mdx, csv, tsv, json, yaml, toml, html, log, rst. For CSV/Excel analysis use read_table; for images use analyze_image.`,
|
|
708
|
+
_tool: 'read_attachment',
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Ingest into the project's BM25 index so subsequent search_code
|
|
713
|
+
// (and future search_document) calls surface this doc's chunks.
|
|
714
|
+
// Best-effort: skip if the file isn't inside a registered project
|
|
715
|
+
// (external attachment like ~/Downloads/foo.pdf), and never let
|
|
716
|
+
// an index write fail the tool.
|
|
717
|
+
try {
|
|
718
|
+
const owningProject = projectRegistry.projectForPath(abs);
|
|
719
|
+
if (owningProject?.retriever?.addProseChunks) {
|
|
720
|
+
const rel = path.relative(owningProject.resource.root, abs);
|
|
721
|
+
owningProject.retriever.addProseChunks(rel, chunks);
|
|
722
|
+
}
|
|
723
|
+
} catch { /* best-effort */ }
|
|
724
|
+
|
|
725
|
+
const totalChunks = chunks.length;
|
|
726
|
+
const totalPages = new Set(chunks.map(c => c.page).filter(p => p != null)).size;
|
|
727
|
+
const totalChars = chunks.reduce((s, c) => s + c.text.length, 0);
|
|
728
|
+
|
|
729
|
+
// total_chunks metadata mode — size-before-read for large docs.
|
|
730
|
+
if (args?.total_chunks) {
|
|
731
|
+
const previewLen = Math.min(400, chunks[0].text.length);
|
|
732
|
+
const preview = chunks[0].text.slice(0, previewLen);
|
|
733
|
+
const previewSuffix = previewLen < chunks[0].text.length ? '…' : '';
|
|
734
|
+
const pagesLine = totalPages ? ` · ${totalPages} pages` : '';
|
|
735
|
+
return {
|
|
736
|
+
success: true,
|
|
737
|
+
output: `📄 ${path.basename(abs)} · ${totalChars} chars · ${totalChunks} chunks (0-${totalChunks - 1})${pagesLine}\n\nFirst chunk preview:\n${preview}${previewSuffix}\n\nUse chunk_range='N-M' or chunk_no=N to read specific chunks.`,
|
|
738
|
+
_tool: 'read_attachment',
|
|
739
|
+
_path: abs,
|
|
740
|
+
_mime: mime,
|
|
741
|
+
_total_chunks: totalChunks,
|
|
742
|
+
_total_pages: totalPages,
|
|
743
|
+
_total_chars: totalChars,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// chunk_range / chunk_no selection.
|
|
748
|
+
let selected = chunks;
|
|
749
|
+
const rangeStr = String(args?.chunk_range ?? '').trim()
|
|
750
|
+
|| (args?.chunk_no !== undefined && args?.chunk_no !== null
|
|
751
|
+
? String(args.chunk_no).trim()
|
|
752
|
+
: '');
|
|
753
|
+
if (rangeStr) {
|
|
754
|
+
const m = rangeStr.match(/^(\d+)(?:\s*-\s*(\d+))?$/);
|
|
755
|
+
if (!m) {
|
|
756
|
+
return {
|
|
757
|
+
success: false,
|
|
758
|
+
output: `Invalid chunk_range: '${rangeStr}'. Use 'N' for a single chunk or 'N-M' for an inclusive range.`,
|
|
759
|
+
_tool: 'read_attachment',
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
const start = parseInt(m[1], 10);
|
|
763
|
+
const end = m[2] != null ? parseInt(m[2], 10) : start;
|
|
764
|
+
if (end < start) {
|
|
765
|
+
return {
|
|
766
|
+
success: false,
|
|
767
|
+
output: `Invalid chunk_range '${rangeStr}': end (${end}) is before start (${start}).`,
|
|
768
|
+
_tool: 'read_attachment',
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
selected = chunks.filter(c => c.chunk_no >= start && c.chunk_no <= end);
|
|
772
|
+
if (!selected.length) {
|
|
773
|
+
return {
|
|
774
|
+
success: false,
|
|
775
|
+
output: `No chunks in range ${start}-${end}. Doc has ${totalChunks} chunks (0-${totalChunks - 1}).`,
|
|
776
|
+
_tool: 'read_attachment',
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// page filter (PDF only — no-op on text docs where page is null).
|
|
782
|
+
if (args?.page !== undefined && args?.page !== null) {
|
|
783
|
+
const p = parseInt(args.page, 10);
|
|
784
|
+
if (Number.isFinite(p)) {
|
|
785
|
+
selected = selected.filter(c => c.page === p);
|
|
786
|
+
if (!selected.length) {
|
|
787
|
+
return {
|
|
788
|
+
success: false,
|
|
789
|
+
output: `No chunks on page ${p}. Doc has ${totalPages} pages.`,
|
|
790
|
+
_tool: 'read_attachment',
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// query substring filter (case-insensitive, per-chunk).
|
|
797
|
+
const query = String(args?.query || '').trim();
|
|
798
|
+
if (query) {
|
|
799
|
+
const q = query.toLowerCase();
|
|
800
|
+
selected = selected.filter(c => c.text.toLowerCase().includes(q));
|
|
801
|
+
if (!selected.length) {
|
|
802
|
+
return {
|
|
803
|
+
success: true,
|
|
804
|
+
output: `(No chunks matched query="${query}".)`,
|
|
805
|
+
_tool: 'read_attachment',
|
|
806
|
+
_path: abs,
|
|
807
|
+
_total_chunks: totalChunks,
|
|
808
|
+
_returned_chunks: 0,
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Render chunks — matches server _render_chunks format:
|
|
814
|
+
// [page N, chunk M]\n<text>\n\n
|
|
815
|
+
const maxChars = Math.max(1000, Number(args?.max_chars) || 100_000);
|
|
816
|
+
const lines = [];
|
|
817
|
+
let total = 0;
|
|
818
|
+
let truncated = false;
|
|
819
|
+
let renderedCount = 0;
|
|
820
|
+
for (const c of selected) {
|
|
821
|
+
const headerBits = [];
|
|
822
|
+
if (c.page != null) headerBits.push(`page ${c.page}`);
|
|
823
|
+
headerBits.push(`chunk ${c.chunk_no}`);
|
|
824
|
+
const block = `[${headerBits.join(', ')}]\n${c.text}`;
|
|
825
|
+
if (total + block.length + 2 > maxChars) {
|
|
826
|
+
lines.push(`... [truncated at chunk ${c.chunk_no} to fit max_chars=${maxChars}. Use chunk_range='${c.chunk_no}-${selected[selected.length - 1].chunk_no}' to read the rest.]`);
|
|
827
|
+
truncated = true;
|
|
828
|
+
break;
|
|
829
|
+
}
|
|
830
|
+
lines.push(block);
|
|
831
|
+
total += block.length + 2;
|
|
832
|
+
renderedCount += 1;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const pagesLine = totalPages ? ` · ${totalPages} pages` : '';
|
|
836
|
+
const truncNote = truncated ? ' (truncated)' : '';
|
|
837
|
+
const header = `📄 ${path.basename(abs)} · ${totalChunks} chunks total${pagesLine} · showing ${renderedCount}${truncNote}\n\n`;
|
|
838
|
+
return {
|
|
839
|
+
success: true,
|
|
840
|
+
output: header + lines.join('\n\n'),
|
|
841
|
+
_tool: 'read_attachment',
|
|
842
|
+
_path: abs,
|
|
843
|
+
_mime: mime,
|
|
844
|
+
_total_chunks: totalChunks,
|
|
845
|
+
_returned_chunks: renderedCount,
|
|
846
|
+
_truncated: truncated,
|
|
847
|
+
};
|
|
848
|
+
},
|
|
849
|
+
|
|
598
850
|
// 1. shell → Bash + classification + smart output filtering
|
|
599
851
|
shell: async (args, options = {}) => {
|
|
600
852
|
throwIfAborted(options.signal);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive ask_user form — rendered when the agent calls the ask_user
|
|
3
|
+
* tool mid-turn to get direction (architecture choice, design decision,
|
|
4
|
+
* ambiguous next step, conflicting instructions, …).
|
|
5
|
+
*
|
|
6
|
+
* Same raw-stdin overlay pattern as repl-model-form.mjs: pause readline,
|
|
7
|
+
* raw mode on, redraw in place, restore on exit. Two input modes:
|
|
8
|
+
* list — ↑↓ move across options (+ an "Other" row), Enter selects,
|
|
9
|
+
* Esc declines (agent proceeds with its own judgment)
|
|
10
|
+
* text — the "Other" row opens a free-text line; Enter submits,
|
|
11
|
+
* Esc returns to the list
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { c } from './ansi.mjs';
|
|
15
|
+
import { fitAnsiLine, writeOverlayFrame, eraseOverlayFrame } from './repl-format.mjs';
|
|
16
|
+
|
|
17
|
+
const OTHER_SENTINEL = '__other__';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} opts
|
|
21
|
+
* @param {object|null} opts.rl readline instance to pause/resume
|
|
22
|
+
* @param {string} opts.question the question to display
|
|
23
|
+
* @param {string[]} opts.options 2-4 option labels from the agent
|
|
24
|
+
* @param {string} [opts.context] optional one-line context above the question
|
|
25
|
+
* @returns {Promise<{answer: string, source: 'option'|'free_text'}|null>}
|
|
26
|
+
* null = user declined (Esc) — the agent should proceed on its own
|
|
27
|
+
*/
|
|
28
|
+
export async function askUserForm({ rl, question, options, context }) {
|
|
29
|
+
if (!process.stdin.isTTY) return null;
|
|
30
|
+
if (rl) rl.pause();
|
|
31
|
+
|
|
32
|
+
const optionRows = (options || []).map(o => String(o || '').trim()).filter(Boolean);
|
|
33
|
+
const rows = [...optionRows, OTHER_SENTINEL];
|
|
34
|
+
|
|
35
|
+
return await new Promise((resolve) => {
|
|
36
|
+
const wasRaw = process.stdin.isRaw;
|
|
37
|
+
let cursor = 0;
|
|
38
|
+
let renderedLines = 0;
|
|
39
|
+
let mode = 'list'; // 'list' | 'text'
|
|
40
|
+
let freeText = '';
|
|
41
|
+
|
|
42
|
+
const render = () => {
|
|
43
|
+
const cols = Math.max(60, process.stderr.columns || 120);
|
|
44
|
+
const lines = [];
|
|
45
|
+
lines.push(` ${c.bold('Agent question')} ${c.dim('· pick an option or type your own · Esc to let the agent decide')}`);
|
|
46
|
+
if (context) {
|
|
47
|
+
lines.push(fitAnsiLine(` ${c.dim(String(context))}`, cols - 1));
|
|
48
|
+
}
|
|
49
|
+
lines.push('');
|
|
50
|
+
lines.push(fitAnsiLine(` ${c.brand('?')} ${c.bold(String(question || ''))}`, cols - 1));
|
|
51
|
+
lines.push('');
|
|
52
|
+
rows.forEach((row, i) => {
|
|
53
|
+
const active = i === cursor;
|
|
54
|
+
const marker = active ? c.brand('▸') : ' ';
|
|
55
|
+
if (row === OTHER_SENTINEL) {
|
|
56
|
+
if (mode === 'text' && active) {
|
|
57
|
+
lines.push(fitAnsiLine(` ${marker} ${c.brand('Other:')} ${freeText}${c.brand('▎')}`, cols - 1));
|
|
58
|
+
} else {
|
|
59
|
+
lines.push(fitAnsiLine(` ${marker} ${active ? c.brand('Other — type your own answer') : c.dim('Other — type your own answer')}`, cols - 1));
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
lines.push(fitAnsiLine(` ${marker} ${active ? c.brand(row) : row}`, cols - 1));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
lines.push('');
|
|
66
|
+
lines.push(fitAnsiLine(
|
|
67
|
+
mode === 'text'
|
|
68
|
+
? ` ${c.dim('type your answer · Enter submit · Esc back to options')}`
|
|
69
|
+
: ` ${c.dim('↑↓ move · Enter select · Esc decline (agent decides)')}`,
|
|
70
|
+
cols - 1,
|
|
71
|
+
));
|
|
72
|
+
writeOverlayFrame(renderedLines, lines);
|
|
73
|
+
renderedLines = lines.length;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const cleanup = (value) => {
|
|
77
|
+
process.stdin.removeListener('data', onData);
|
|
78
|
+
process.stdin.setRawMode(wasRaw || false);
|
|
79
|
+
eraseOverlayFrame(renderedLines);
|
|
80
|
+
if (rl) rl.resume();
|
|
81
|
+
resolve(value);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const onData = (data) => {
|
|
85
|
+
const key = data.toString('utf8');
|
|
86
|
+
|
|
87
|
+
if (mode === 'text') {
|
|
88
|
+
if (key === '\x1b') { mode = 'list'; freeText = ''; render(); return; }
|
|
89
|
+
if (key === '\x03') { cleanup(null); return; }
|
|
90
|
+
if (key === '\r' || key === '\n') {
|
|
91
|
+
const answer = freeText.trim();
|
|
92
|
+
if (answer) { cleanup({ answer, source: 'free_text' }); }
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (key === '\x7f' || key === '\b') { freeText = freeText.slice(0, -1); render(); return; }
|
|
96
|
+
// Ignore other escape sequences (arrows etc.) while typing.
|
|
97
|
+
if (key.startsWith('\x1b')) return;
|
|
98
|
+
const printable = [...key].filter(ch => ch >= ' ').join('');
|
|
99
|
+
if (printable) { freeText += printable; render(); }
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// list mode
|
|
104
|
+
if (key === '\x1b' || key === '\x03' || key === 'q') { cleanup(null); return; }
|
|
105
|
+
if (key === '\r' || key === '\n') {
|
|
106
|
+
const row = rows[cursor];
|
|
107
|
+
if (row === OTHER_SENTINEL) { mode = 'text'; freeText = ''; render(); return; }
|
|
108
|
+
cleanup({ answer: row, source: 'option' });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (key === '\x1b[A') { cursor = Math.max(0, cursor - 1); render(); return; }
|
|
112
|
+
if (key === '\x1b[B') { cursor = Math.min(rows.length - 1, cursor + 1); render(); return; }
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
process.stdin.setRawMode(true);
|
|
116
|
+
process.stdin.resume();
|
|
117
|
+
process.stdin.on('data', onData);
|
|
118
|
+
render();
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -385,8 +385,11 @@ export function renderToolResult(data, eventType = 'tool_result') {
|
|
|
385
385
|
// ── Single-line combined emit ──
|
|
386
386
|
// If the head for this call is still buffered (no interleaving content
|
|
387
387
|
// landed), and the combined line fits the terminal width, emit ONE line
|
|
388
|
-
// and skip the gutter entirely.
|
|
389
|
-
|
|
388
|
+
// and skip the gutter entirely. Multi-line result text (shell preview
|
|
389
|
+
// with rows + "+ N more" tail) skips this path — a wrapped multi-line
|
|
390
|
+
// block needs its own real estate.
|
|
391
|
+
const outcomeIsMultiLine = outcome.includes('\n');
|
|
392
|
+
if (runtime.pendingHead && runtime.pendingHead.callId === callId && !hasLint && !runtime.pendingHead.head.includes('\n') && !outcomeIsMultiLine) {
|
|
390
393
|
const cols = process.stderr.columns || 120;
|
|
391
394
|
const combined = `${runtime.pendingHead.head} ${outcome}`;
|
|
392
395
|
if (stripAnsi(combined).length <= cols) {
|
|
@@ -421,7 +424,15 @@ export function renderToolResult(data, eventType = 'tool_result') {
|
|
|
421
424
|
}
|
|
422
425
|
|
|
423
426
|
// Two-line shape: gutter under the (already-printed or just-flushed) head.
|
|
424
|
-
|
|
427
|
+
// Multi-line outcome (shell preview with rows + "+ N more" tail): prepend
|
|
428
|
+
// the gutter to each line so the block stays aligned instead of ragged.
|
|
429
|
+
if (outcomeIsMultiLine) {
|
|
430
|
+
for (const line of outcome.split('\n')) {
|
|
431
|
+
process.stderr.write(`${gutter}${line}\n`);
|
|
432
|
+
}
|
|
433
|
+
} else {
|
|
434
|
+
process.stderr.write(`${gutter}${outcome}\n`);
|
|
435
|
+
}
|
|
425
436
|
if (diffPreview) {
|
|
426
437
|
process.stderr.write(`${diffPreview}\n`);
|
|
427
438
|
rememberFileDiffPreview(data);
|
package/src/terminal/repl.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { buildWorkScope, promptProjectRoots } from '../core/work-scope.mjs';
|
|
|
30
30
|
import { CheckpointManager } from '../core/checkpoints.mjs';
|
|
31
31
|
import { HookRunner } from '../config/hook-runner.mjs';
|
|
32
32
|
import { readShippedCatalog } from '../config/model-catalog.mjs';
|
|
33
|
+
import { askUserForm } from './repl-ask-form.mjs';
|
|
33
34
|
import { runPreflight } from '../onboarding/preflight.mjs';
|
|
34
35
|
import { printBanner as printBrandedBanner } from '../ui/banner.mjs';
|
|
35
36
|
import { renderMissionReport, saveReport, toMarkdown as missionMarkdown } from '../ui/mission-report.mjs';
|
|
@@ -1437,6 +1438,33 @@ function renderEvent(event) {
|
|
|
1437
1438
|
break;
|
|
1438
1439
|
}
|
|
1439
1440
|
|
|
1441
|
+
case 'summarize': {
|
|
1442
|
+
// Backend collapsed older history into a summary. Two phases:
|
|
1443
|
+
// pre_turn — fires at the start of a new turn before hydration
|
|
1444
|
+
// mid_turn — fires between iterations during a long autonomous run
|
|
1445
|
+
// Both share the same env threshold (BAHULAM_SUMMARIZE_THRESHOLD,
|
|
1446
|
+
// default 160k est tokens). One cache miss per fire; each fires at
|
|
1447
|
+
// most once per turn.
|
|
1448
|
+
stopSpinner();
|
|
1449
|
+
flushContent();
|
|
1450
|
+
flushPendingHead();
|
|
1451
|
+
renderBlockBoundary('status', { compactSame: true });
|
|
1452
|
+
const phase = String(data?.phase || 'pre_turn');
|
|
1453
|
+
const phaseTag = phase === 'mid_turn' ? 'mid-turn' : 'pre-turn';
|
|
1454
|
+
const collapsed = Number(data?.collapsed_messages || 0);
|
|
1455
|
+
const kept = Number(data?.kept_recent || 0);
|
|
1456
|
+
const before = Number(data?.before_est_tokens || 0);
|
|
1457
|
+
const beforeK = before ? ` · ~${Math.round(before / 1000)}k est tokens` : '';
|
|
1458
|
+
const keptPart = kept ? ` · kept last ${kept}` : '';
|
|
1459
|
+
const preview = String(data?.summary_preview || '').trim();
|
|
1460
|
+
process.stderr.write(` ${c.brand('✎')} ${c.dim(`context summarized (${phaseTag}) · ${collapsed} older message${collapsed === 1 ? '' : 's'} collapsed${keptPart}${beforeK}`)}\n`);
|
|
1461
|
+
if (preview) {
|
|
1462
|
+
process.stderr.write(` ${c.dim('› ' + preview)}\n`);
|
|
1463
|
+
}
|
|
1464
|
+
runtime.lastRenderedBlock = 'status';
|
|
1465
|
+
break;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1440
1468
|
case 'reconnecting': {
|
|
1441
1469
|
stopSpinner();
|
|
1442
1470
|
flushContent();
|
|
@@ -1686,6 +1714,23 @@ function renderEvent(event) {
|
|
|
1686
1714
|
// diverts for explore-category tools and folded verbosity modes.
|
|
1687
1715
|
// Dedup-consecutive inside the push keeps repeat tools quiet.
|
|
1688
1716
|
pushSubAgentWindowLine(`→ ${tool}`);
|
|
1717
|
+
// Rich-mode fallback: when the render queue isn't active (bare TTY,
|
|
1718
|
+
// --plain, or before the input dock mounts), the live window under
|
|
1719
|
+
// the spinner is invisible — pushSubAgentWindowLine feeds into a
|
|
1720
|
+
// status block that never renders. Guarantee sub-agent tool activity
|
|
1721
|
+
// is visible by emitting a small dim transcript line too, dedup'd
|
|
1722
|
+
// per (agentType, tool). Queue-active mode keeps its clean spinner
|
|
1723
|
+
// + window; the fallback line is only for the "otherwise blind"
|
|
1724
|
+
// case that surfaced in 2.6.17 reports.
|
|
1725
|
+
if (!rqueue.isActive()) {
|
|
1726
|
+
const label = data?.label || '';
|
|
1727
|
+
const hint = label ? ` · ${label}` : '';
|
|
1728
|
+
const key = `${agentType}:${tool}:${label}`;
|
|
1729
|
+
if (session._lastSubAgentInlineKey !== key) {
|
|
1730
|
+
session._lastSubAgentInlineKey = key;
|
|
1731
|
+
process.stderr.write(` ${c.dim(`→ ${agentType} · ${tool}${hint}`)}\n`);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1689
1734
|
// Don't clobber an active explore-run spinner. "exploring · 5 read ·
|
|
1690
1735
|
// 2 searched" is more informative than "explore → search_code", and
|
|
1691
1736
|
// sub_agent_tool fires on every step of a sub-agent — otherwise the
|
|
@@ -2877,8 +2922,9 @@ async function handleCommand(input, ctx) {
|
|
|
2877
2922
|
// Session autopilot for long-running jobs: auto-approve routine
|
|
2878
2923
|
// writes/shell while STILL prompting for dangerous tiers (rm,
|
|
2879
2924
|
// force-push, command substitution, …) and never overriding hard
|
|
2880
|
-
// safety blocks. Distinct from the launch-time
|
|
2881
|
-
// which approves everything
|
|
2925
|
+
// safety blocks. Distinct from the launch-time
|
|
2926
|
+
// --dangerously-skip-permissions flag, which approves everything
|
|
2927
|
+
// including dangerous tiers.
|
|
2882
2928
|
const sub = (rest || '').trim().toLowerCase();
|
|
2883
2929
|
if (sub === 'off') {
|
|
2884
2930
|
ctx.approval.approveAll = false;
|
|
@@ -3182,8 +3228,30 @@ export async function startTerminalRepl() {
|
|
|
3182
3228
|
let latestProjectContext = null;
|
|
3183
3229
|
let latestEnvelope = null;
|
|
3184
3230
|
let hookRunner = new HookRunner({ cwd: safeCwd() });
|
|
3185
|
-
|
|
3186
|
-
|
|
3231
|
+
|
|
3232
|
+
// ask_user tool → interactive overlay form (repl-ask-form.mjs). `ctx` is
|
|
3233
|
+
// declared below; the arrow only dereferences it at call time (mid-turn),
|
|
3234
|
+
// so the lazy reference is safe. Stop the spinner and flush streamed
|
|
3235
|
+
// content first so the overlay doesn't fight the render queue.
|
|
3236
|
+
const askUserInteraction = async (req) => {
|
|
3237
|
+
stopSpinner();
|
|
3238
|
+
flushContent();
|
|
3239
|
+
const res = await askUserForm({
|
|
3240
|
+
rl: ctx?._rl || null,
|
|
3241
|
+
question: req?.question || '',
|
|
3242
|
+
options: Array.isArray(req?.options) ? req.options : [],
|
|
3243
|
+
context: req?.context || '',
|
|
3244
|
+
});
|
|
3245
|
+
if (res?.answer) {
|
|
3246
|
+
process.stderr.write(` ${c.green('✓')} ${c.dim('answered:')} ${c.brand(res.answer)}\n`);
|
|
3247
|
+
} else {
|
|
3248
|
+
process.stderr.write(` ${c.dim('Question declined — agent proceeds with its own judgment.')}\n`);
|
|
3249
|
+
}
|
|
3250
|
+
return res;
|
|
3251
|
+
};
|
|
3252
|
+
|
|
3253
|
+
let toolExecutor = createToolExecutor({ checkpoints, hookRunner, interactionHandler: askUserInteraction });
|
|
3254
|
+
const skipPerms = cliArgs.skipPermissions;
|
|
3187
3255
|
let approval = new ApprovalManager({ autoApprove: skipPerms, cwd: safeCwd(), policy: effectivePolicy.policy });
|
|
3188
3256
|
|
|
3189
3257
|
// Session manager — persists conversation messages to .bahulam/conversations/
|
|
@@ -3276,7 +3344,7 @@ export async function startTerminalRepl() {
|
|
|
3276
3344
|
checkpoints = new CheckpointManager(safeCwd());
|
|
3277
3345
|
effectivePolicy = loadEffectivePolicy({ cwd: safeCwd() });
|
|
3278
3346
|
hookRunner = new HookRunner({ cwd: safeCwd() });
|
|
3279
|
-
toolExecutor = createToolExecutor({ checkpoints, hookRunner });
|
|
3347
|
+
toolExecutor = createToolExecutor({ checkpoints, hookRunner, interactionHandler: askUserInteraction });
|
|
3280
3348
|
approval = new ApprovalManager({ autoApprove: skipPerms, cwd: safeCwd(), policy: effectivePolicy.policy });
|
|
3281
3349
|
if (ctx._rl) approval.setReadline(ctx._rl);
|
|
3282
3350
|
sessionMgr = new SessionManager(safeCwd());
|
|
@@ -3456,7 +3524,7 @@ export async function startTerminalRepl() {
|
|
|
3456
3524
|
checkpoints = new CheckpointManager(safeCwd());
|
|
3457
3525
|
effectivePolicy = loadEffectivePolicy({ cwd: safeCwd() });
|
|
3458
3526
|
hookRunner = new HookRunner({ cwd: safeCwd(), sessionId });
|
|
3459
|
-
toolExecutor = createToolExecutor({ checkpoints, hookRunner });
|
|
3527
|
+
toolExecutor = createToolExecutor({ checkpoints, hookRunner, interactionHandler: askUserInteraction });
|
|
3460
3528
|
approval = new ApprovalManager({ autoApprove: skipPerms, cwd: safeCwd(), policy: effectivePolicy.policy });
|
|
3461
3529
|
if (ctx._rl) approval.setReadline(ctx._rl);
|
|
3462
3530
|
sessionMgr = new SessionManager(safeCwd());
|
|
@@ -3576,7 +3644,7 @@ export async function startTerminalRepl() {
|
|
|
3576
3644
|
|
|
3577
3645
|
// Preflight diagnostic (PRD-055 §9). Non-blocking; opt-out via
|
|
3578
3646
|
// KEPLER_NO_PREFLIGHT=1 (used by tests / scripted runs).
|
|
3579
|
-
if (process.env.KEPLER_NO_PREFLIGHT !== '1' && !cliArgs.
|
|
3647
|
+
if (process.env.KEPLER_NO_PREFLIGHT !== '1' && !cliArgs.skipPermissions) {
|
|
3580
3648
|
try { await runPreflight({ auth, cwd: safeCwd(), version: VERSION }); }
|
|
3581
3649
|
catch { /* preflight is best-effort */ }
|
|
3582
3650
|
}
|
|
@@ -4714,7 +4782,10 @@ export async function startTerminalRepl() {
|
|
|
4714
4782
|
startSpinner('thinking…');
|
|
4715
4783
|
|
|
4716
4784
|
const execContext = { cwd: safeCwd() };
|
|
4717
|
-
if (skipPerms)
|
|
4785
|
+
if (skipPerms) {
|
|
4786
|
+
execContext.skip_permissions = true;
|
|
4787
|
+
execContext.freeswim = true; // legacy wire alias — drop after cloud backend 2.7 rollout
|
|
4788
|
+
}
|
|
4718
4789
|
effectivePolicy = loadEffectivePolicy({ cwd: safeCwd() });
|
|
4719
4790
|
approval.policy = effectivePolicy.policy;
|
|
4720
4791
|
if (approval.trustStore) approval.trustStore.policy = effectivePolicy.policy;
|
|
@@ -4754,7 +4825,10 @@ export async function startTerminalRepl() {
|
|
|
4754
4825
|
ctx.latestProjectContext = latestProjectContext;
|
|
4755
4826
|
ctx.latestEnvelope = latestEnvelope;
|
|
4756
4827
|
Object.assign(execContext, latestEnvelope);
|
|
4757
|
-
if (skipPerms)
|
|
4828
|
+
if (skipPerms) {
|
|
4829
|
+
execContext.skip_permissions = true;
|
|
4830
|
+
execContext.freeswim = true; // legacy wire alias — drop after cloud backend 2.7 rollout
|
|
4831
|
+
}
|
|
4758
4832
|
const modelOverrides = Object.fromEntries(sessionModelOverrideEntries());
|
|
4759
4833
|
if (Object.keys(modelOverrides).length > 0) {
|
|
4760
4834
|
execContext.model_overrides = modelOverrides;
|
package/src/ui/tool-card.mjs
CHANGED
|
@@ -156,6 +156,14 @@ export function summarizeResult(tool, data) {
|
|
|
156
156
|
if (tool === 'shell') {
|
|
157
157
|
const structured = structuredOutputSummary(data.output_preview || data.output);
|
|
158
158
|
if (structured) return structured;
|
|
159
|
+
// Multi-row preview: first N rows + a "+ M more" tail so long
|
|
160
|
+
// outputs (e.g. `ls`) surface their scale instead of collapsing
|
|
161
|
+
// to a single first line with no hint that more exists.
|
|
162
|
+
const { preview, remaining } = outputPreviewRows(data, shellPreviewRows());
|
|
163
|
+
if (!preview) return { text: 'ok', tone: 'success' };
|
|
164
|
+
if (remaining === 0) return { text: preview, tone: 'success' };
|
|
165
|
+
const tail = paint.text.dim(`+ ${remaining} more row${remaining === 1 ? '' : 's'}`);
|
|
166
|
+
return { text: `${preview}\n${tail}`, tone: 'success' };
|
|
159
167
|
}
|
|
160
168
|
const head = firstOutputLine(data).slice(0, 100);
|
|
161
169
|
return { text: head || 'ok', tone: 'success' };
|
|
@@ -413,6 +421,24 @@ function firstOutputLine(data) {
|
|
|
413
421
|
return String(o).split('\n').map(l => l.trim()).find(Boolean) || '';
|
|
414
422
|
}
|
|
415
423
|
|
|
424
|
+
// Preview the first N non-empty output rows, joined by \n. Returns
|
|
425
|
+
// { preview, remaining, total } — remaining is how many non-empty rows
|
|
426
|
+
// were dropped past N. Long individual rows get clipped to `perRow` chars.
|
|
427
|
+
function outputPreviewRows(data, n, perRow = 200) {
|
|
428
|
+
const o = data?.output_preview ?? data?.output ?? data?.message ?? '';
|
|
429
|
+
const rows = String(o).split('\n').map(l => l.trim()).filter(Boolean);
|
|
430
|
+
const shown = rows.slice(0, n).map(l => l.length > perRow ? l.slice(0, perRow - 1) + '…' : l);
|
|
431
|
+
return { preview: shown.join('\n'), remaining: Math.max(0, rows.length - n), total: rows.length };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Default rows shown in the shell result preview. Overridable via env for
|
|
435
|
+
// power users; keep it small — the result line rides above every command
|
|
436
|
+
// and eats vertical space in a long session.
|
|
437
|
+
function shellPreviewRows() {
|
|
438
|
+
const raw = parseInt(process.env.BAHULAM_SHELL_PREVIEW_ROWS ?? '', 10);
|
|
439
|
+
return Number.isFinite(raw) && raw >= 1 ? raw : 2;
|
|
440
|
+
}
|
|
441
|
+
|
|
416
442
|
function lineCount(s) {
|
|
417
443
|
if (!s) return 0;
|
|
418
444
|
return String(s).split('\n').filter(Boolean).length;
|