botholomew 0.18.6 → 0.18.7
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": "botholomew",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.7",
|
|
4
4
|
"description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sdk": "^0.95.2",
|
|
31
|
-
"@evantahler/mcpx": "0.21.
|
|
31
|
+
"@evantahler/mcpx": "0.21.8",
|
|
32
32
|
"ansis": "^4.3.0",
|
|
33
33
|
"commander": "^14.0.0",
|
|
34
34
|
"gray-matter": "^4.0.3",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"ink-spinner": "^5.0.0",
|
|
37
37
|
"ink-text-input": "^6.0.0",
|
|
38
38
|
"istextorbinary": "^9.5.0",
|
|
39
|
-
"membot": "^0.15.
|
|
39
|
+
"membot": "^0.15.4",
|
|
40
40
|
"nanospinner": "^1.2.2",
|
|
41
41
|
"react": "^19.2.6",
|
|
42
42
|
"uuid": "^14.0.0",
|
|
@@ -34,13 +34,14 @@ type MembotMethodName =
|
|
|
34
34
|
| "move"
|
|
35
35
|
| "remove"
|
|
36
36
|
| "refresh"
|
|
37
|
-
| "prune"
|
|
37
|
+
| "prune"
|
|
38
|
+
| "sources";
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
|
-
* Map an Operation's exposed name (`membot_add`, `
|
|
41
|
-
* `MembotClient` method that actually runs it.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
41
|
+
* Map an Operation's exposed name (`membot_add`, `membot_remove`, …) to the
|
|
42
|
+
* `MembotClient` method that actually runs it. Mostly 1:1 with the op name
|
|
43
|
+
* minus the `membot_` prefix; kept explicit so a renamed/added op fails
|
|
44
|
+
* loudly at registration instead of silently misrouting.
|
|
44
45
|
*/
|
|
45
46
|
const METHOD_BY_OP_NAME: Record<string, MembotMethodName> = {
|
|
46
47
|
membot_add: "add",
|
|
@@ -54,9 +55,10 @@ const METHOD_BY_OP_NAME: Record<string, MembotMethodName> = {
|
|
|
54
55
|
membot_diff: "diff",
|
|
55
56
|
membot_write: "write",
|
|
56
57
|
membot_move: "move",
|
|
57
|
-
|
|
58
|
+
membot_remove: "remove",
|
|
58
59
|
membot_refresh: "refresh",
|
|
59
60
|
membot_prune: "prune",
|
|
61
|
+
membot_sources: "sources",
|
|
60
62
|
};
|
|
61
63
|
|
|
62
64
|
/**
|
package/src/tools/membot/edit.ts
CHANGED
|
@@ -32,7 +32,7 @@ const outputSchema = z.object({
|
|
|
32
32
|
export const membotEditTool = {
|
|
33
33
|
name: "membot_edit",
|
|
34
34
|
description:
|
|
35
|
-
"[[ bash equivalent command: patch ]] Apply line-range edits to a stored file: reads the current version, applies bottom-up patches, and writes the result back as a new version. Prefer this over membot_write when you only need to change part of a file — the diff is small and the change_note travels with the new version. To replace the whole body, use membot_write. To delete the file, use
|
|
35
|
+
"[[ bash equivalent command: patch ]] Apply line-range edits to a stored file: reads the current version, applies bottom-up patches, and writes the result back as a new version. Prefer this over membot_write when you only need to change part of a file — the diff is small and the change_note travels with the new version. To replace the whole body, use membot_write. To delete the file, use membot_remove.",
|
|
36
36
|
group: "membot",
|
|
37
37
|
inputSchema,
|
|
38
38
|
outputSchema,
|
|
@@ -240,7 +240,7 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
240
240
|
? `🔍 match (score=${selectedRow.hit.score.toFixed(3)}, chunk #${selectedRow.hit.chunk_index})\n${selectedRow.hit.snippet}\n\n---\n\n`
|
|
241
241
|
: "";
|
|
242
242
|
const body = isMarkdownPath(fileContent.logical_path)
|
|
243
|
-
? renderMarkdown(fileContent.content)
|
|
243
|
+
? renderMarkdown(fileContent.content, detailWidth)
|
|
244
244
|
: fileContent.content;
|
|
245
245
|
return wrapDetailLines(snippetHeader + body, detailWidth);
|
|
246
246
|
}, [selectedRow, fileContent, detailWidth]);
|
package/src/tui/markdown.ts
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
|
-
|
|
1
|
+
import { extractTableBlocks, renderTable } from "./markdownTables.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render markdown to ANSI for a TUI detail pane. When `width` is provided,
|
|
5
|
+
* GFM tables are pulled out and rendered ourselves at that width before
|
|
6
|
+
* handing the rest off to `Bun.markdown.ansi` — Bun's renderer ignores any
|
|
7
|
+
* width hint and emits tables at their natural width, which `wrap-ansi` then
|
|
8
|
+
* shreds mid-cell.
|
|
9
|
+
*/
|
|
10
|
+
export function renderMarkdown(text: string, width?: number): string {
|
|
2
11
|
if (!text) return "";
|
|
3
|
-
|
|
12
|
+
if (width === undefined || width <= 0) {
|
|
13
|
+
return Bun.markdown.ansi(text).trimEnd();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const blocks = extractTableBlocks(text);
|
|
17
|
+
if (blocks.length === 0) {
|
|
18
|
+
return Bun.markdown.ansi(text).trimEnd();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const lines = text.split("\n");
|
|
22
|
+
const rendered: string[] = blocks.map((b) =>
|
|
23
|
+
renderTable(b.rows, b.aligns, width),
|
|
24
|
+
);
|
|
25
|
+
// Bun.markdown.ansi mangles NUL bytes (→ U+FFFD), so use a plain alphanumeric
|
|
26
|
+
// sentinel that survives the markdown pass intact. Wrap each block's
|
|
27
|
+
// line-range with a single sentinel line, then splice the pre-rendered
|
|
28
|
+
// table back in after Bun finishes styling the rest of the document.
|
|
29
|
+
const sentinel = (i: number) => `BHTBLSENTINEL${i}BHTBLEND`;
|
|
30
|
+
const out = lines.slice();
|
|
31
|
+
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
32
|
+
const b = blocks[i];
|
|
33
|
+
if (!b) continue;
|
|
34
|
+
out.splice(b.start, b.end - b.start + 1, sentinel(i));
|
|
35
|
+
}
|
|
36
|
+
const piped = Bun.markdown.ansi(out.join("\n")).trimEnd();
|
|
37
|
+
let stitched = piped;
|
|
38
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
39
|
+
// Bun wraps each paragraph with a trailing reset (`\x1b[0m`). Strip any
|
|
40
|
+
// SGR escapes that hug the sentinel so the table doesn't inherit them.
|
|
41
|
+
const re = new RegExp(
|
|
42
|
+
`(?:\\x1b\\[[0-9;]*m)*${sentinel(i)}(?:\\x1b\\[[0-9;]*m)*`,
|
|
43
|
+
);
|
|
44
|
+
stitched = stitched.replace(re, rendered[i] ?? "");
|
|
45
|
+
}
|
|
46
|
+
return stitched;
|
|
4
47
|
}
|
|
5
48
|
|
|
6
49
|
export function isMarkdownPath(path: string): boolean {
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GFM table extraction + width-aware ANSI rendering.
|
|
3
|
+
*
|
|
4
|
+
* `Bun.markdown.ansi` renders tables at their natural width and ignores the
|
|
5
|
+
* caller's column budget, so wide tables get hard-wrapped mid-cell by
|
|
6
|
+
* `wrap-ansi` in the detail pane. We pre-extract table blocks, render them
|
|
7
|
+
* ourselves at a width that fits, and let `Bun.markdown.ansi` handle the rest.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type Align = "left" | "center" | "right";
|
|
11
|
+
|
|
12
|
+
export interface TableBlock {
|
|
13
|
+
/** First line index (inclusive) of the table in the original text. */
|
|
14
|
+
start: number;
|
|
15
|
+
/** Last line index (inclusive). */
|
|
16
|
+
end: number;
|
|
17
|
+
/** First row is the header. */
|
|
18
|
+
rows: string[][];
|
|
19
|
+
aligns: Align[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DIM_ON = "\x1b[2m";
|
|
23
|
+
const BOLD_ON = "\x1b[1m";
|
|
24
|
+
const RESET = "\x1b[0m";
|
|
25
|
+
|
|
26
|
+
const SEPARATOR_CELL_RE = /^\s*:?-{1,}:?\s*$/;
|
|
27
|
+
const FENCE_RE = /^\s{0,3}(```|~~~)/;
|
|
28
|
+
|
|
29
|
+
export function extractTableBlocks(text: string): TableBlock[] {
|
|
30
|
+
const lines = text.split("\n");
|
|
31
|
+
const blocks: TableBlock[] = [];
|
|
32
|
+
let inFence = false;
|
|
33
|
+
let i = 0;
|
|
34
|
+
while (i < lines.length) {
|
|
35
|
+
const line = lines[i] ?? "";
|
|
36
|
+
if (FENCE_RE.test(line)) {
|
|
37
|
+
inFence = !inFence;
|
|
38
|
+
i++;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (inFence || !looksLikePipeRow(line)) {
|
|
42
|
+
i++;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const sep = lines[i + 1] ?? "";
|
|
46
|
+
if (!looksLikePipeRow(sep)) {
|
|
47
|
+
i++;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const sepCells = splitRow(sep);
|
|
51
|
+
if (!sepCells.every((c) => SEPARATOR_CELL_RE.test(c))) {
|
|
52
|
+
i++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const header = splitRow(line);
|
|
56
|
+
const colCount = Math.max(header.length, sepCells.length);
|
|
57
|
+
const aligns: Align[] = sepCells.slice(0, colCount).map(parseAlignCell);
|
|
58
|
+
while (aligns.length < colCount) aligns.push("left");
|
|
59
|
+
|
|
60
|
+
const rows: string[][] = [normalizeRow(header, colCount)];
|
|
61
|
+
let j = i + 2;
|
|
62
|
+
while (j < lines.length) {
|
|
63
|
+
const body = lines[j] ?? "";
|
|
64
|
+
if (!looksLikePipeRow(body)) break;
|
|
65
|
+
// A new separator (consecutive tables) terminates this one.
|
|
66
|
+
if (splitRow(body).every((c) => SEPARATOR_CELL_RE.test(c))) break;
|
|
67
|
+
rows.push(normalizeRow(splitRow(body), colCount));
|
|
68
|
+
j++;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
blocks.push({ start: i, end: j - 1, rows, aligns });
|
|
72
|
+
i = j;
|
|
73
|
+
}
|
|
74
|
+
return blocks;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderTable(
|
|
78
|
+
rows: string[][],
|
|
79
|
+
aligns: Align[],
|
|
80
|
+
width: number,
|
|
81
|
+
): string {
|
|
82
|
+
if (rows.length === 0) return "";
|
|
83
|
+
const colCount = rows[0]?.length ?? 0;
|
|
84
|
+
if (colCount === 0) return "";
|
|
85
|
+
|
|
86
|
+
const plain = rows.map((r) => r.map(stripInlineMarkdown));
|
|
87
|
+
|
|
88
|
+
// Per-column natural width (max visible width across all cells).
|
|
89
|
+
const naturalWidths: number[] = [];
|
|
90
|
+
for (let c = 0; c < colCount; c++) {
|
|
91
|
+
let w = 1;
|
|
92
|
+
for (const row of plain) {
|
|
93
|
+
const cell = row[c] ?? "";
|
|
94
|
+
if (visibleWidth(cell) > w) w = visibleWidth(cell);
|
|
95
|
+
}
|
|
96
|
+
naturalWidths.push(w);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Overhead: leading "│ " + trailing " │" + " │ " between cols.
|
|
100
|
+
const borderOverhead = colCount * 3 + 1;
|
|
101
|
+
const naturalTotal =
|
|
102
|
+
naturalWidths.reduce((a, b) => a + b, 0) + borderOverhead;
|
|
103
|
+
|
|
104
|
+
let colWidths: number[];
|
|
105
|
+
if (naturalTotal <= width || width <= 0) {
|
|
106
|
+
colWidths = naturalWidths;
|
|
107
|
+
} else {
|
|
108
|
+
colWidths = shrinkColumns(naturalWidths, width - borderOverhead);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const lines: string[] = [];
|
|
112
|
+
lines.push(borderLine("┌", "┬", "┐", colWidths));
|
|
113
|
+
for (let r = 0; r < plain.length; r++) {
|
|
114
|
+
const cells = plain[r] ?? [];
|
|
115
|
+
const isHeader = r === 0;
|
|
116
|
+
lines.push(dataLine(cells, aligns, colWidths, isHeader));
|
|
117
|
+
if (isHeader) {
|
|
118
|
+
lines.push(borderLine("├", "┼", "┤", colWidths));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
lines.push(borderLine("└", "┴", "┘", colWidths));
|
|
122
|
+
return lines.join("\n");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function looksLikePipeRow(line: string): boolean {
|
|
126
|
+
// A GFM table row contains at least one unescaped pipe and (after trimming
|
|
127
|
+
// surrounding whitespace + optional pipes) is non-empty.
|
|
128
|
+
const stripped = line.trim();
|
|
129
|
+
if (stripped === "") return false;
|
|
130
|
+
if (!stripped.includes("|")) return false;
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function splitRow(line: string): string[] {
|
|
135
|
+
let s = line.trim();
|
|
136
|
+
if (s.startsWith("|")) s = s.slice(1);
|
|
137
|
+
if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
|
|
138
|
+
const cells: string[] = [];
|
|
139
|
+
let buf = "";
|
|
140
|
+
for (let i = 0; i < s.length; i++) {
|
|
141
|
+
const ch = s[i];
|
|
142
|
+
if (ch === "\\" && s[i + 1] === "|") {
|
|
143
|
+
buf += "|";
|
|
144
|
+
i++;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (ch === "|") {
|
|
148
|
+
cells.push(buf.trim());
|
|
149
|
+
buf = "";
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
buf += ch;
|
|
153
|
+
}
|
|
154
|
+
cells.push(buf.trim());
|
|
155
|
+
return cells;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseAlignCell(cell: string): Align {
|
|
159
|
+
const c = cell.trim();
|
|
160
|
+
const left = c.startsWith(":");
|
|
161
|
+
const right = c.endsWith(":");
|
|
162
|
+
if (left && right) return "center";
|
|
163
|
+
if (right) return "right";
|
|
164
|
+
return "left";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizeRow(cells: string[], colCount: number): string[] {
|
|
168
|
+
const out = cells.slice(0, colCount);
|
|
169
|
+
while (out.length < colCount) out.push("");
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function shrinkColumns(natural: number[], budget: number): number[] {
|
|
174
|
+
const MIN = 3;
|
|
175
|
+
const n = natural.length;
|
|
176
|
+
if (budget < n * MIN) {
|
|
177
|
+
// Not enough room even for ellipsis everywhere — give each column MIN
|
|
178
|
+
// and let the caller deal with overflow. (Detail pane minimum is much
|
|
179
|
+
// wider than this in practice.)
|
|
180
|
+
return new Array(n).fill(MIN);
|
|
181
|
+
}
|
|
182
|
+
const total = natural.reduce((a, b) => a + b, 0) || 1;
|
|
183
|
+
const raw = natural.map((w) => (w * budget) / total);
|
|
184
|
+
const floored = raw.map((v) => Math.max(MIN, Math.floor(v)));
|
|
185
|
+
let used = floored.reduce((a, b) => a + b, 0);
|
|
186
|
+
// Distribute the remainder to columns with the largest fractional part.
|
|
187
|
+
const remainders = raw
|
|
188
|
+
.map((v, i) => ({ i, frac: v - Math.floor(v) }))
|
|
189
|
+
.sort((a, b) => b.frac - a.frac);
|
|
190
|
+
let k = 0;
|
|
191
|
+
while (used < budget && k < remainders.length * 4) {
|
|
192
|
+
const idx = remainders[k % remainders.length]?.i ?? 0;
|
|
193
|
+
floored[idx] = (floored[idx] ?? MIN) + 1;
|
|
194
|
+
used++;
|
|
195
|
+
k++;
|
|
196
|
+
}
|
|
197
|
+
// If we overshot due to MIN clamping, trim from the widest column(s).
|
|
198
|
+
while (used > budget) {
|
|
199
|
+
let widest = 0;
|
|
200
|
+
for (let i = 1; i < n; i++) {
|
|
201
|
+
if ((floored[i] ?? 0) > (floored[widest] ?? 0)) widest = i;
|
|
202
|
+
}
|
|
203
|
+
if ((floored[widest] ?? 0) <= MIN) break;
|
|
204
|
+
floored[widest] = (floored[widest] ?? 0) - 1;
|
|
205
|
+
used--;
|
|
206
|
+
}
|
|
207
|
+
return floored;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function borderLine(
|
|
211
|
+
left: string,
|
|
212
|
+
mid: string,
|
|
213
|
+
right: string,
|
|
214
|
+
widths: number[],
|
|
215
|
+
): string {
|
|
216
|
+
const segs = widths.map((w) => "─".repeat(w + 2));
|
|
217
|
+
return DIM_ON + left + segs.join(mid) + right + RESET;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function dataLine(
|
|
221
|
+
cells: string[],
|
|
222
|
+
aligns: Align[],
|
|
223
|
+
widths: number[],
|
|
224
|
+
bold: boolean,
|
|
225
|
+
): string {
|
|
226
|
+
const parts: string[] = [];
|
|
227
|
+
parts.push(`${DIM_ON}│${RESET}`);
|
|
228
|
+
for (let i = 0; i < widths.length; i++) {
|
|
229
|
+
const w = widths[i] ?? 0;
|
|
230
|
+
const align = aligns[i] ?? "left";
|
|
231
|
+
const raw = cells[i] ?? "";
|
|
232
|
+
const fitted = padCell(raw, w, align);
|
|
233
|
+
const styled = bold ? `${BOLD_ON}${fitted}${RESET}` : fitted;
|
|
234
|
+
parts.push(` ${styled} `);
|
|
235
|
+
parts.push(`${DIM_ON}│${RESET}`);
|
|
236
|
+
}
|
|
237
|
+
return parts.join("");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function padCell(text: string, width: number, align: Align): string {
|
|
241
|
+
const truncated = truncateToWidth(text, width);
|
|
242
|
+
const pad = width - visibleWidth(truncated);
|
|
243
|
+
if (pad <= 0) return truncated;
|
|
244
|
+
if (align === "right") return " ".repeat(pad) + truncated;
|
|
245
|
+
if (align === "center") {
|
|
246
|
+
const l = Math.floor(pad / 2);
|
|
247
|
+
const r = pad - l;
|
|
248
|
+
return " ".repeat(l) + truncated + " ".repeat(r);
|
|
249
|
+
}
|
|
250
|
+
return truncated + " ".repeat(pad);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function truncateToWidth(text: string, width: number): string {
|
|
254
|
+
if (width <= 0) return "";
|
|
255
|
+
if (visibleWidth(text) <= width) return text;
|
|
256
|
+
if (width === 1) return "…";
|
|
257
|
+
const chars = Array.from(text);
|
|
258
|
+
let out = "";
|
|
259
|
+
let used = 0;
|
|
260
|
+
for (const ch of chars) {
|
|
261
|
+
if (used + 1 > width - 1) break;
|
|
262
|
+
out += ch;
|
|
263
|
+
used++;
|
|
264
|
+
}
|
|
265
|
+
return `${out}…`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function visibleWidth(text: string): number {
|
|
269
|
+
// Cell text has no ANSI (we strip markdown markers before measuring), so
|
|
270
|
+
// codepoint count is sufficient. East-Asian double-width chars would be
|
|
271
|
+
// undercounted; out of scope for v1.
|
|
272
|
+
return Array.from(text).length;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function stripInlineMarkdown(text: string): string {
|
|
276
|
+
// Strip a small set of inline markers so cell width measurement matches what
|
|
277
|
+
// the user sees. Order matters: longer markers first.
|
|
278
|
+
let s = text;
|
|
279
|
+
s = s.replace(/`([^`]+)`/g, "$1");
|
|
280
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, "$1");
|
|
281
|
+
s = s.replace(/__([^_]+)__/g, "$1");
|
|
282
|
+
s = s.replace(/~~([^~]+)~~/g, "$1");
|
|
283
|
+
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2");
|
|
284
|
+
s = s.replace(/(^|[^_])_([^_\n]+)_/g, "$1$2");
|
|
285
|
+
// Collapse \| escapes that survived splitRow.
|
|
286
|
+
s = s.replace(/\\\|/g, "|");
|
|
287
|
+
return s;
|
|
288
|
+
}
|