donsetch 2.1.1 → 2.2.0

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/pi-extension.ts +149 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "donsetch",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "Web fetch, search and crawl for AI agents. Zero API keys. Chrome-true TLS.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Bishesh Bhandari",
package/pi-extension.ts CHANGED
@@ -13,18 +13,39 @@
13
13
  * Auto-download: if the binary is missing (e.g. postinstall was
14
14
  * blocked by npm 10+), the extension runs install.js at session_start
15
15
  * to fetch it from GitHub Releases.
16
+ *
17
+ * Custom TUI: each tool has clean renderCall/renderResult showing
18
+ * a compact summary card — not the full raw output. The LLM still
19
+ * receives complete content; the user sees a minimal status line +
20
+ * one-line preview. Amber theme matching DonSeTch's identity.
16
21
  */
17
22
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
23
  import { Type } from "typebox";
19
24
  import { spawn, execFileSync, type ChildProcess } from "node:child_process";
20
25
  import { existsSync } from "node:fs";
21
- import { join, dirname } from "node:path";
26
+ import { join } from "node:path";
27
+ import { Text, visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
22
28
 
23
29
  // ── Constants ──
24
30
  const INIT_TIMEOUT_MS = 10_000;
25
- const CALL_TIMEOUT_MS = 120_000; // fetch/crawl can take a while
31
+ const CALL_TIMEOUT_MS = 120_000;
26
32
  const SHUTDOWN_GRACE_MS = 2_000;
27
33
 
34
+ // ── Color palette — DonSeTch amber theme ──
35
+ const C_AMBER = "\x1b[38;2;255;178;0m";
36
+ const C_GREEN = "\x1b[38;2;100;200;100m";
37
+ const C_RED = "\x1b[38;2;229;115;115m";
38
+ const C_DIM = "\x1b[38;2;130;130;140m";
39
+ const C_CREAM = "\x1b[38;2;240;230;210m";
40
+ const RESET = "\x1b[0m";
41
+
42
+ // ── Tool icons ──
43
+ const ICONS: Record<string, string> = {
44
+ web_fetch: "\u{1F310}", // 🌐
45
+ web_search: "\u{1F50E}", // 🔎
46
+ web_crawl: "\u{1F577}\u{FE0F}", // 🕷️
47
+ };
48
+
28
49
  // ── MCP client state ──
29
50
  let proc: ChildProcess | null = null;
30
51
  let nextId = 1;
@@ -47,8 +68,6 @@ function ensureBinary(): string {
47
68
  const binaryPath = getBinaryPath();
48
69
  if (existsSync(binaryPath)) return binaryPath;
49
70
 
50
- // Binary missing — postinstall was likely blocked. Run install.js
51
- // to download from GitHub Releases.
52
71
  const installScript = join(__dirname, "install.js");
53
72
  if (!existsSync(installScript)) {
54
73
  throw new Error(
@@ -129,7 +148,6 @@ function startServer(): Promise<void> {
129
148
  }
130
149
  });
131
150
 
132
- // Drain stderr to prevent pipe buffer deadlock; route to our stderr for debugging.
133
151
  proc.stderr?.on("data", (chunk: Buffer) => {
134
152
  process.stderr.write(chunk);
135
153
  });
@@ -154,7 +172,6 @@ function startServer(): Promise<void> {
154
172
  pending.clear();
155
173
  });
156
174
 
157
- // MCP handshake: initialize → notifications/initialized
158
175
  sendRequest(
159
176
  "initialize",
160
177
  {
@@ -209,9 +226,7 @@ function killServer(): void {
209
226
  proc.kill("SIGTERM");
210
227
  const p = proc;
211
228
  setTimeout(() => {
212
- try {
213
- p.kill("SIGKILL");
214
- } catch {}
229
+ try { p.kill("SIGKILL"); } catch {}
215
230
  }, SHUTDOWN_GRACE_MS);
216
231
  } catch {}
217
232
  proc = null;
@@ -229,6 +244,48 @@ function isAlive(): boolean {
229
244
  return proc !== null && !proc.killed && proc.stdin?.writable === true;
230
245
  }
231
246
 
247
+ // ── TUI helpers ──
248
+
249
+ /** Extract a clean preview line from markdown content. */
250
+ function getPreview(text: string, maxLen = 72): string {
251
+ const lines = text.split("\n");
252
+ for (const line of lines) {
253
+ let clean = line.replace(/^#+\s*/, "").replace(/\*\*([^*]+)\*\*/g, "$1").trim();
254
+ if (clean.length > 0 && !clean.startsWith("{") && !clean.startsWith("[")) {
255
+ return truncateToWidth(clean, maxLen, "\u2026");
256
+ }
257
+ }
258
+ return "";
259
+ }
260
+
261
+ /** Count numbered search results in text. */
262
+ function countSearchResults(text: string): number {
263
+ const matches = text.match(/^\d+\.\s/gm);
264
+ return matches ? matches.length : 0;
265
+ }
266
+
267
+ /** Extract first search result title. */
268
+ function getFirstResultTitle(text: string): string {
269
+ const match = text.match(/^\d+\.\s+\*\*(.+?)\*\*/m);
270
+ return match ? match[1] : "";
271
+ }
272
+
273
+ /** Count pages from crawl output (## headings or numbered pages). */
274
+ function countCrawlPages(text: string): number {
275
+ const matches = text.match(/^##\s/gm);
276
+ return matches ? matches.length : 0;
277
+ }
278
+
279
+ /** Extract domain from URL for display. */
280
+ function shortUrl(url: string): string {
281
+ try {
282
+ const u = new URL(url);
283
+ return u.hostname + (u.pathname !== "/" ? u.pathname.slice(0, 30) : "");
284
+ } catch {
285
+ return truncateToWidth(url, 50, "\u2026");
286
+ }
287
+ }
288
+
232
289
  // ── Extension ──
233
290
 
234
291
  export default function (pi: ExtensionAPI) {
@@ -261,17 +318,15 @@ export default function (pi: ExtensionAPI) {
261
318
 
262
319
  const description = mcpTool.description || mcpTool.name;
263
320
  const inputSchema = mcpTool.inputSchema || { type: "object", properties: {} };
264
-
265
- // Capture name for closure
266
321
  const toolName = name;
322
+ const icon = ICONS[toolName] ?? "\u25C6";
267
323
 
268
324
  pi.registerTool({
269
325
  name: toolName,
270
326
  label: toolName,
271
327
  description,
272
328
  parameters: Type.Unsafe(inputSchema) as any,
273
- async execute(_toolCallId, params, signal) {
274
- // Check if server is still alive, restart if dead
329
+ async execute(_toolCallId, params, _signal) {
275
330
  if (!isAlive()) {
276
331
  try {
277
332
  await startServer();
@@ -285,19 +340,97 @@ export default function (pi: ExtensionAPI) {
285
340
 
286
341
  try {
287
342
  const result = await callMcpTool(toolName, params);
343
+ const text = result?.content?.[0]?.text ?? "";
344
+ const isErr = result?.isError ?? false;
345
+
346
+ // Build details for TUI rendering
347
+ const details: any = {
348
+ mcpTool: toolName,
349
+ isError: isErr,
350
+ chars: text.length,
351
+ };
352
+
353
+ if (toolName === "web_search") {
354
+ details.results = countSearchResults(text);
355
+ details.topResult = getFirstResultTitle(text);
356
+ } else if (toolName === "web_crawl") {
357
+ details.pages = countCrawlPages(text);
358
+ }
359
+
360
+ // For errors, extract error text
361
+ if (isErr) {
362
+ details.error = getPreview(text, 60);
363
+ } else {
364
+ details.preview = getPreview(text);
365
+ }
366
+
288
367
  return {
289
368
  content: result?.content ?? [{ type: "text", text: "No output" }],
290
- details: { mcpTool: toolName, isError: result?.isError ?? false },
291
- isError: result?.isError ?? false,
369
+ details,
370
+ isError: isErr,
292
371
  };
293
372
  } catch (err: any) {
294
373
  return {
295
374
  content: [{ type: "text", text: `donsetch MCP call failed: ${err.message}` }],
296
- details: { error: err.message, mcpTool: toolName },
375
+ details: { mcpTool: toolName, isError: true, error: err.message },
297
376
  isError: true,
298
377
  };
299
378
  }
300
379
  },
380
+
381
+ renderCall(args: any, _theme: any) {
382
+ let key = "";
383
+ if (args?.url) {
384
+ key = shortUrl(args.url);
385
+ } else if (args?.query) {
386
+ key = truncateToWidth(`"${args.query}"`, 50, "\u2026");
387
+ }
388
+ return new Text(
389
+ `${C_AMBER}${icon}${RESET} ${C_CREAM}${toolName}${RESET} ${C_DIM}${key}${RESET}`,
390
+ 0, 0
391
+ );
392
+ },
393
+
394
+ renderResult(result: any, opts: any, _theme: any) {
395
+ if (opts?.isPartial) {
396
+ return new Text(`${C_AMBER}\u23F3${RESET} ${C_DIM}${toolName} working…${RESET}`, 0, 0);
397
+ }
398
+
399
+ const isErr = result?.isError || result?.details?.isError;
400
+ const d = result?.details ?? {};
401
+ const glyph = isErr ? "\u2717" : "\u2713";
402
+ const color = isErr ? C_RED : C_GREEN;
403
+
404
+ // Build metadata string per tool
405
+ let meta = "";
406
+ if (toolName === "web_fetch") {
407
+ const chars = d.chars ?? 0;
408
+ meta = `${chars.toLocaleString()} chars`;
409
+ } else if (toolName === "web_search") {
410
+ const count = d.results ?? 0;
411
+ meta = `${count} result${count !== 1 ? "s" : ""}`;
412
+ } else if (toolName === "web_crawl") {
413
+ const pages = d.pages ?? 0;
414
+ meta = `${pages} page${pages !== 1 ? "s" : ""}`;
415
+ }
416
+
417
+ // Build line 2: preview or error
418
+ let line2 = "";
419
+ if (isErr) {
420
+ line2 = d.error || "failed";
421
+ } else if (toolName === "web_search" && d.topResult) {
422
+ line2 = truncateToWidth(d.topResult, 70, "\u2026");
423
+ } else if (d.preview) {
424
+ line2 = d.preview;
425
+ }
426
+
427
+ const line1 = `${color}${glyph}${RESET} ${C_CREAM}${toolName}${RESET} ${C_DIM}\u00B7 ${meta}${RESET}`;
428
+ const output = line2
429
+ ? `${line1}\n ${C_DIM}${line2}${RESET}`
430
+ : line1;
431
+
432
+ return new Text(output, 0, 0);
433
+ },
301
434
  });
302
435
  }
303
436