@zhijiewang/openharness 2.23.0 → 2.25.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.
@@ -5,6 +5,8 @@
5
5
  import { getTheme } from "../utils/theme-data.js";
6
6
  import { renderDiff } from "./diff.js";
7
7
  import { isImageOutput, renderImageInline } from "./image.js";
8
+ import { deriveSpinnerLabel } from "./spinner-label.js";
9
+ import { toolColor } from "./tool-color.js";
8
10
  // ── Style constants ──
9
11
  const s = (fg, bold = false, dim = false) => ({ fg, bg: null, bold, dim, underline: false });
10
12
  export const S_TEXT = s(null);
@@ -93,14 +95,14 @@ export function renderThinkingSummarySection(state, grid, r, limit) {
93
95
  export function renderSpinnerSection(state, grid, r, limit) {
94
96
  if (!state.loading || state.streamingText || state.thinkingText || r >= limit)
95
97
  return r;
96
- const thinkText = "Thinking";
98
+ const thinkText = deriveSpinnerLabel(state.toolCalls);
97
99
  const elapsed = state.thinkingStartedAt ? Math.floor((Date.now() - state.thinkingStartedAt) / 1000) : 0;
98
100
  const t = getTheme();
99
101
  const baseColor = elapsed > 60 ? t.error : elapsed > 30 ? t.stall : t.primary;
100
102
  const shimmerColor = elapsed > 60 ? t.stallShimmer : elapsed > 30 ? t.warning : t.primaryShimmer;
101
103
  const baseStyle = { fg: baseColor, bg: null, bold: false, dim: false, underline: false };
102
104
  grid.writeText(r, 0, "◆ ", { ...baseStyle, bold: true });
103
- const shimmerPos = state.spinnerFrame % (thinkText.length + 6);
105
+ const shimmerPos = state.spinnerFrame % 20;
104
106
  const shimmerStyle = { fg: shimmerColor, bg: null, bold: true, dim: false, underline: false };
105
107
  for (let ci = 0; ci < thinkText.length; ci++) {
106
108
  grid.setCell(r, 2 + ci, thinkText[ci], Math.abs(ci - shimmerPos) <= 1 ? shimmerStyle : baseStyle);
@@ -141,8 +143,9 @@ export function renderToolCallsSection(state, grid, r, limit, opts) {
141
143
  : tc.status === "done"
142
144
  ? "✓"
143
145
  : "✗";
144
- const statusStyle = tc.status === "error" ? S_ERROR : tc.status === "done" ? S_GREEN : isAgent ? S_AGENT : S_YELLOW;
145
- const nameStyle = isAgent ? S_AGENT : { ...S_YELLOW, bold: true };
146
+ const toolStyle = { fg: toolColor(tc.toolName), bg: null, bold: false, dim: false, underline: false };
147
+ const statusStyle = tc.status === "error" ? S_ERROR : tc.status === "done" ? S_GREEN : isAgent ? S_AGENT : toolStyle;
148
+ const nameStyle = isAgent ? S_AGENT : { ...toolStyle, bold: true };
146
149
  const isExpanded = state.expandedToolCalls.has(callId);
147
150
  const canExpand = tc.status !== "running" && tc.output;
148
151
  if (canExpand) {
@@ -187,7 +190,7 @@ export function renderToolCallsSection(state, grid, r, limit, opts) {
187
190
  for (const line of visible) {
188
191
  if (r >= limit)
189
192
  break;
190
- grid.writeText(r, 6, line.slice(0, w - 8), S_DIM);
193
+ grid.writeTextWithLinks(r, 6, line.slice(0, w - 8), S_DIM, w - 2);
191
194
  r++;
192
195
  }
193
196
  }
@@ -205,7 +208,7 @@ export function renderToolCallsSection(state, grid, r, limit, opts) {
205
208
  if (r >= limit)
206
209
  break;
207
210
  const lineStyle = tc.status === "error" ? S_ERROR : S_DIM;
208
- grid.writeText(r, 6, line.slice(0, w - 8), lineStyle);
211
+ grid.writeTextWithLinks(r, 6, line.slice(0, w - 8), lineStyle, w - 2);
209
212
  r++;
210
213
  }
211
214
  if (outLines.length > maxOut && r < limit) {
@@ -427,21 +430,33 @@ export function renderInputSection(state, grid, inputRow, limit, promptText, pro
427
430
  const inputStart = promptWidth;
428
431
  const inputLines = state.inputText.split("\n");
429
432
  const maxInputLines = Math.min(inputLines.length, 5);
433
+ // Pre-compute the [N lines] suffix column on row 0 (if multi-line) so the
434
+ // wrap glyph for line 0 can yield to the suffix when they would collide.
435
+ // The +1 reserves the glyph column so the suffix starts one column after
436
+ // it (produces "text↵ [N lines]"); a +0 would let the suffix overwrite the
437
+ // glyph on narrow terminals.
438
+ let lineCountCol = -1;
439
+ if (inputLines.length > 1) {
440
+ const lineCountStr = ` [${inputLines.length} lines]`;
441
+ lineCountCol = Math.min(inputStart + (inputLines[0]?.length ?? 0) + 1, grid.width - lineCountStr.length - 1);
442
+ }
430
443
  for (let li = 0; li < maxInputLines; li++) {
431
444
  if (inputRow + li >= limit)
432
445
  break;
433
- if (li === 0) {
434
- grid.writeText(inputRow, inputStart, inputLines[0], S_TEXT);
435
- }
436
- else {
437
- grid.writeText(inputRow + li, inputStart, inputLines[li], S_TEXT);
446
+ const lineText = inputLines[li];
447
+ grid.writeText(inputRow + li, inputStart, lineText, S_TEXT);
448
+ // Audit U-C2: append a dim ↵ continuation glyph to every non-last line.
449
+ if (li < inputLines.length - 1) {
450
+ const glyphCol = inputStart + lineText.length;
451
+ const wouldCollideWithLineCount = li === 0 && lineCountCol > inputStart && glyphCol >= lineCountCol;
452
+ if (glyphCol < grid.width && !wouldCollideWithLineCount) {
453
+ grid.writeText(inputRow + li, glyphCol, "↵", S_DIM);
454
+ }
438
455
  }
439
456
  }
440
- if (inputLines.length > 1) {
457
+ if (inputLines.length > 1 && lineCountCol > inputStart) {
441
458
  const lineCountStr = ` [${inputLines.length} lines]`;
442
- const lineCountCol = Math.min(inputStart + (inputLines[0]?.length ?? 0) + 1, grid.width - lineCountStr.length - 1);
443
- if (lineCountCol > inputStart)
444
- grid.writeText(inputRow, lineCountCol, lineCountStr, S_DIM);
459
+ grid.writeText(inputRow, lineCountCol, lineCountStr, S_DIM);
445
460
  }
446
461
  const hintsRow = inputRow + maxInputLines;
447
462
  if (hintsRow < limit) {
@@ -241,7 +241,10 @@ function parseInline(text, baseStyle) {
241
241
  // Link: [text](url)
242
242
  const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
243
243
  if (linkMatch) {
244
- segments.push({ text: linkMatch[1], style: { ...baseStyle, underline: true, fg: "cyan" } });
244
+ segments.push({
245
+ text: linkMatch[1],
246
+ style: { ...baseStyle, underline: true, fg: "cyan", hyperlink: linkMatch[2] },
247
+ });
245
248
  segments.push({ text: ` (${linkMatch[2]})`, style: { ...baseStyle, dim: true } });
246
249
  remaining = remaining.slice(linkMatch[0].length);
247
250
  continue;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Derives the spinner section label from the live tool-call map.
3
+ */
4
+ import type { ToolCallInfo } from "./layout.js";
5
+ export declare function deriveSpinnerLabel(toolCalls: Map<string, ToolCallInfo>): string;
6
+ //# sourceMappingURL=spinner-label.d.ts.map
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Derives the spinner section label from the live tool-call map.
3
+ */
4
+ export function deriveSpinnerLabel(toolCalls) {
5
+ const running = [];
6
+ for (const tc of toolCalls.values()) {
7
+ if (tc.status === "running")
8
+ running.push(tc);
9
+ }
10
+ if (running.length === 0)
11
+ return "Thinking";
12
+ if (running.length === 1) {
13
+ const name = running[0].toolName;
14
+ if (name.startsWith("mcp__")) {
15
+ const rest = name.slice("mcp__".length);
16
+ const idx = rest.indexOf("__");
17
+ if (idx > 0)
18
+ return `Calling ${rest.slice(0, idx)}:${rest.slice(idx + 2)}`;
19
+ }
20
+ return `Running ${name}`;
21
+ }
22
+ return `Running ${running.length} tools`;
23
+ }
24
+ //# sourceMappingURL=spinner-label.js.map
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Maps a tool name to a display color for the tool-call section.
3
+ * Read-class tools → cyan, Mutate-class → yellow, Exec-class → magenta,
4
+ * MCP tools (mcp__ prefix) → green, everything else → yellow fallback.
5
+ */
6
+ export type ToolColor = "cyan" | "yellow" | "magenta" | "green";
7
+ export declare function toolColor(toolName: string): ToolColor;
8
+ //# sourceMappingURL=tool-color.d.ts.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Maps a tool name to a display color for the tool-call section.
3
+ * Read-class tools → cyan, Mutate-class → yellow, Exec-class → magenta,
4
+ * MCP tools (mcp__ prefix) → green, everything else → yellow fallback.
5
+ */
6
+ const READ = new Set(["Read", "Glob", "Grep", "WebFetch", "WebSearch", "ExaSearch"]);
7
+ const MUTATE = new Set(["Edit", "Write", "NotebookEdit"]);
8
+ const EXEC = new Set(["Bash", "PowerShell"]);
9
+ export function toolColor(toolName) {
10
+ if (READ.has(toolName))
11
+ return "cyan";
12
+ if (MUTATE.has(toolName))
13
+ return "yellow";
14
+ if (EXEC.has(toolName))
15
+ return "magenta";
16
+ if (toolName.startsWith("mcp__"))
17
+ return "green";
18
+ return "yellow";
19
+ }
20
+ //# sourceMappingURL=tool-color.js.map
package/dist/repl.js CHANGED
@@ -14,8 +14,10 @@ import { readOhConfig, writeOhConfig } from "./harness/config.js";
14
14
  import { estimateMessageTokens, getContextWarning } from "./harness/context-warning.js";
15
15
  import { CostTracker, estimateCost, getContextWindow } from "./harness/cost.js";
16
16
  import { createSession, loadSession, saveSession } from "./harness/session.js";
17
+ import { runStatusLineScript } from "./harness/status-line-script.js";
17
18
  import { createStore } from "./harness/store.js";
18
19
  import { handleUserInput } from "./harness/submit-handler.js";
20
+ import { isTrusted, trustSystemActive } from "./harness/trust.js";
19
21
  import { query } from "./query/index.js";
20
22
  import { resetDiffStyleCache } from "./renderer/diff.js";
21
23
  import { TerminalRenderer } from "./renderer/index.js";
@@ -23,6 +25,7 @@ import { resetStyleCache } from "./renderer/layout.js";
23
25
  import { resetMdStyleCache } from "./renderer/markdown.js";
24
26
  import { createAssistantMessage, createInfoMessage, createMessage } from "./types/message.js";
25
27
  import { formatTokenCount } from "./utils/format.js";
28
+ import { fuzzyFilter } from "./utils/fuzzy.js";
26
29
  import { setActiveTheme } from "./utils/theme-data.js";
27
30
  import { formatToolArgs, summarizeToolOutput } from "./utils/tool-summary.js";
28
31
  export async function startREPL(config) {
@@ -131,16 +134,15 @@ export async function startREPL(config) {
131
134
  function updateAutocomplete() {
132
135
  acIsPath = false;
133
136
  if (inputText.startsWith("/") && inputText.length > 1 && !inputText.includes(" ")) {
134
- // Slash command autocomplete entries arrive in registration order
135
- // (Session Git Info Settings AI Skills → MCP), so categories
136
- // are naturally contiguous after a startsWith filter (audit U-A3).
137
- const prefix = inputText.slice(1).toLowerCase();
138
- const entries = getCommandEntries()
139
- .filter((e) => e.name.startsWith(prefix))
140
- .slice(0, 8);
141
- acSuggestions = entries.map((e) => e.name);
142
- acDescriptions = entries.map((e) => e.description);
143
- acCategories = entries.map((e) => e.category);
137
+ // Slash command autocomplete (audit U-B3): subsequence-match scoring,
138
+ // not a startsWith filter. Prefix matches still rank first via the
139
+ // bonus in `fuzzyScore`, but the user can type "gst" to surface
140
+ // "/git-status" or "perm" to surface "/permissions".
141
+ const query = inputText.slice(1);
142
+ const ranked = fuzzyFilter(query, getCommandEntries()).slice(0, 8);
143
+ acSuggestions = ranked.map((r) => r.entry.name);
144
+ acDescriptions = ranked.map((r) => r.entry.description);
145
+ acCategories = ranked.map((r) => r.entry.category);
144
146
  acTokenStart = 0;
145
147
  acIndex = -1;
146
148
  }
@@ -273,8 +275,38 @@ export async function startREPL(config) {
273
275
  const pct = Math.max(1, Math.ceil(usage * 100));
274
276
  ctxStr = `ctx [${bar}] ${pct}%`;
275
277
  }
276
- // Use template if configured, otherwise default format
277
- if (cachedConfig?.statusLineFormat) {
278
+ // Resolution priority: script (audit U-B1) → template → default.
279
+ //
280
+ // Script path: spawn user-configured shell with a JSON envelope on
281
+ // stdin; gated through the workspace-trust system from audit U-A4 so
282
+ // a hostile project can't auto-execute on first launch. Cached for
283
+ // `refreshMs` (default 1s) inside `status-line-script.ts` so the
284
+ // script doesn't run on every keypress. Failure → fall through to
285
+ // the template / default below.
286
+ let scriptLine = null;
287
+ const sl = cachedConfig?.statusLine;
288
+ if (sl?.command) {
289
+ const cwd = process.cwd();
290
+ if (trustSystemActive() && !isTrusted(cwd)) {
291
+ scriptLine = null; // untrusted — silently skip; user can /trust
292
+ }
293
+ else {
294
+ const ctxPct = ctxWindow > 0 && estimatedTokenCount > 0 ? estimatedTokenCount / ctxWindow : 0;
295
+ scriptLine = runStatusLineScript({
296
+ model: currentModel || "",
297
+ tokens: { input: inTok, output: outTok },
298
+ cost: totalCostVal,
299
+ contextPercent: ctxPct,
300
+ sessionId: session.id,
301
+ cwd,
302
+ gitBranch: session.gitBranch,
303
+ }, sl);
304
+ }
305
+ }
306
+ if (scriptLine !== null) {
307
+ renderer.setStatusLine(scriptLine);
308
+ }
309
+ else if (cachedConfig?.statusLineFormat) {
278
310
  const line = cachedConfig.statusLineFormat
279
311
  .replace("{model}", currentModel || "")
280
312
  .replace("{tokens}", tokensStr)
@@ -945,7 +977,6 @@ export async function startREPL(config) {
945
977
  case "tool_call_end": {
946
978
  const toolName = callIdToToolName.get(event.callId) ?? event.callId;
947
979
  const prevTc = renderer.getToolCall(event.callId);
948
- const _elapsed = prevTc?.startedAt ? Math.floor((Date.now() - prevTc.startedAt) / 1000) : 0;
949
980
  renderer.setToolCall(event.callId, {
950
981
  toolName,
951
982
  status: event.isError ? "error" : "done",
@@ -0,0 +1,101 @@
1
+ import { z } from "zod";
2
+ import type { Tool } from "../../Tool.js";
3
+ declare const SEARCH_TYPES: readonly ["auto", "neural", "fast", "keyword"];
4
+ declare const CATEGORIES: readonly ["company", "research paper", "news", "personal site", "financial report", "people"];
5
+ declare const inputSchema: z.ZodObject<{
6
+ query: z.ZodString;
7
+ num_results: z.ZodOptional<z.ZodNumber>;
8
+ type: z.ZodOptional<z.ZodEnum<["auto", "neural", "fast", "keyword"]>>;
9
+ category: z.ZodOptional<z.ZodEnum<["company", "research paper", "news", "personal site", "financial report", "people"]>>;
10
+ include_domains: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
11
+ exclude_domains: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
12
+ include_text: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
13
+ exclude_text: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
14
+ start_published_date: z.ZodOptional<z.ZodString>;
15
+ end_published_date: z.ZodOptional<z.ZodString>;
16
+ user_location: z.ZodOptional<z.ZodString>;
17
+ text: z.ZodOptional<z.ZodBoolean>;
18
+ highlights: z.ZodOptional<z.ZodBoolean>;
19
+ summary: z.ZodOptional<z.ZodBoolean>;
20
+ summary_query: z.ZodOptional<z.ZodString>;
21
+ max_text_chars: z.ZodOptional<z.ZodNumber>;
22
+ }, "strip", z.ZodTypeAny, {
23
+ query: string;
24
+ type?: "auto" | "fast" | "neural" | "keyword" | undefined;
25
+ text?: boolean | undefined;
26
+ summary?: boolean | undefined;
27
+ num_results?: number | undefined;
28
+ category?: "company" | "research paper" | "news" | "personal site" | "financial report" | "people" | undefined;
29
+ include_domains?: string[] | undefined;
30
+ exclude_domains?: string[] | undefined;
31
+ include_text?: string[] | undefined;
32
+ exclude_text?: string[] | undefined;
33
+ start_published_date?: string | undefined;
34
+ end_published_date?: string | undefined;
35
+ user_location?: string | undefined;
36
+ highlights?: boolean | undefined;
37
+ summary_query?: string | undefined;
38
+ max_text_chars?: number | undefined;
39
+ }, {
40
+ query: string;
41
+ type?: "auto" | "fast" | "neural" | "keyword" | undefined;
42
+ text?: boolean | undefined;
43
+ summary?: boolean | undefined;
44
+ num_results?: number | undefined;
45
+ category?: "company" | "research paper" | "news" | "personal site" | "financial report" | "people" | undefined;
46
+ include_domains?: string[] | undefined;
47
+ exclude_domains?: string[] | undefined;
48
+ include_text?: string[] | undefined;
49
+ exclude_text?: string[] | undefined;
50
+ start_published_date?: string | undefined;
51
+ end_published_date?: string | undefined;
52
+ user_location?: string | undefined;
53
+ highlights?: boolean | undefined;
54
+ summary_query?: string | undefined;
55
+ max_text_chars?: number | undefined;
56
+ }>;
57
+ type ExaContents = {
58
+ text?: boolean | {
59
+ maxCharacters?: number;
60
+ };
61
+ highlights?: boolean | {
62
+ maxCharacters?: number;
63
+ };
64
+ summary?: boolean | {
65
+ query?: string;
66
+ };
67
+ };
68
+ type ExaRequest = {
69
+ query: string;
70
+ numResults: number;
71
+ type?: (typeof SEARCH_TYPES)[number];
72
+ category?: (typeof CATEGORIES)[number];
73
+ includeDomains?: string[];
74
+ excludeDomains?: string[];
75
+ includeText?: string[];
76
+ excludeText?: string[];
77
+ startPublishedDate?: string;
78
+ endPublishedDate?: string;
79
+ userLocation?: string;
80
+ contents?: ExaContents;
81
+ };
82
+ type ExaResultItem = {
83
+ id?: string;
84
+ url: string;
85
+ title?: string | null;
86
+ publishedDate?: string;
87
+ author?: string | null;
88
+ text?: string;
89
+ highlights?: string[];
90
+ summary?: string;
91
+ };
92
+ type ExaResponse = {
93
+ results: ExaResultItem[];
94
+ requestId?: string;
95
+ };
96
+ export declare function buildRequestBody(input: z.infer<typeof inputSchema>): ExaRequest;
97
+ export declare function extractSnippet(item: ExaResultItem): string;
98
+ export declare function formatResults(response: ExaResponse): string;
99
+ export declare const ExaSearchTool: Tool<typeof inputSchema>;
100
+ export {};
101
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,165 @@
1
+ import { z } from "zod";
2
+ const SEARCH_TYPES = ["auto", "neural", "fast", "keyword"];
3
+ const CATEGORIES = ["company", "research paper", "news", "personal site", "financial report", "people"];
4
+ const inputSchema = z.object({
5
+ query: z.string(),
6
+ num_results: z.number().optional(),
7
+ type: z.enum(SEARCH_TYPES).optional(),
8
+ category: z.enum(CATEGORIES).optional(),
9
+ include_domains: z.array(z.string()).optional(),
10
+ exclude_domains: z.array(z.string()).optional(),
11
+ include_text: z.array(z.string()).optional(),
12
+ exclude_text: z.array(z.string()).optional(),
13
+ start_published_date: z.string().optional(),
14
+ end_published_date: z.string().optional(),
15
+ user_location: z.string().optional(),
16
+ text: z.boolean().optional(),
17
+ highlights: z.boolean().optional(),
18
+ summary: z.boolean().optional(),
19
+ summary_query: z.string().optional(),
20
+ max_text_chars: z.number().optional(),
21
+ });
22
+ const DEFAULT_NUM_RESULTS = 5;
23
+ const MAX_TEXT_CHARS = 1500;
24
+ const ENDPOINT = "https://api.exa.ai/search";
25
+ const INTEGRATION_HEADER = "openharness";
26
+ export function buildRequestBody(input) {
27
+ const body = {
28
+ query: input.query,
29
+ numResults: input.num_results ?? DEFAULT_NUM_RESULTS,
30
+ };
31
+ if (input.type)
32
+ body.type = input.type;
33
+ if (input.category)
34
+ body.category = input.category;
35
+ if (input.include_domains?.length)
36
+ body.includeDomains = input.include_domains;
37
+ if (input.exclude_domains?.length)
38
+ body.excludeDomains = input.exclude_domains;
39
+ if (input.include_text?.length)
40
+ body.includeText = input.include_text;
41
+ if (input.exclude_text?.length)
42
+ body.excludeText = input.exclude_text;
43
+ if (input.start_published_date)
44
+ body.startPublishedDate = input.start_published_date;
45
+ if (input.end_published_date)
46
+ body.endPublishedDate = input.end_published_date;
47
+ if (input.user_location)
48
+ body.userLocation = input.user_location;
49
+ const wantText = input.text ?? true;
50
+ const wantHighlights = input.highlights ?? true;
51
+ const wantSummary = input.summary ?? false;
52
+ const contents = {};
53
+ if (wantText) {
54
+ contents.text = { maxCharacters: input.max_text_chars ?? MAX_TEXT_CHARS };
55
+ }
56
+ if (wantHighlights) {
57
+ contents.highlights = true;
58
+ }
59
+ if (wantSummary) {
60
+ contents.summary = input.summary_query ? { query: input.summary_query } : true;
61
+ }
62
+ if (Object.keys(contents).length > 0) {
63
+ body.contents = contents;
64
+ }
65
+ return body;
66
+ }
67
+ export function extractSnippet(item) {
68
+ if (item.highlights && item.highlights.length > 0) {
69
+ return item.highlights.join(" … ");
70
+ }
71
+ if (item.summary)
72
+ return item.summary;
73
+ if (item.text) {
74
+ const trimmed = item.text.replace(/\s+/g, " ").trim();
75
+ return trimmed.length > 300 ? `${trimmed.slice(0, 300)}…` : trimmed;
76
+ }
77
+ return "";
78
+ }
79
+ export function formatResults(response) {
80
+ if (!response.results || response.results.length === 0) {
81
+ return "No results found.";
82
+ }
83
+ return response.results
84
+ .map((r, i) => {
85
+ const title = r.title?.trim() || "(untitled)";
86
+ const snippet = extractSnippet(r);
87
+ const meta = [];
88
+ if (r.author)
89
+ meta.push(`by ${r.author}`);
90
+ if (r.publishedDate)
91
+ meta.push(r.publishedDate.slice(0, 10));
92
+ const metaLine = meta.length ? ` ${meta.join(" · ")}\n` : "";
93
+ const snippetLine = snippet ? ` ${snippet}\n` : "";
94
+ return `${i + 1}. ${title}\n ${r.url}\n${metaLine}${snippetLine}`.trimEnd();
95
+ })
96
+ .join("\n\n");
97
+ }
98
+ export const ExaSearchTool = {
99
+ name: "ExaSearch",
100
+ description: "Search the web with Exa — neural/fast/auto search with content retrieval, domain and date filters, and category targeting.",
101
+ inputSchema,
102
+ riskLevel: "medium",
103
+ isReadOnly() {
104
+ return true;
105
+ },
106
+ isConcurrencySafe() {
107
+ return true;
108
+ },
109
+ async call(input, _context) {
110
+ const apiKey = process.env.EXA_API_KEY;
111
+ if (!apiKey) {
112
+ return {
113
+ output: "Error: EXA_API_KEY environment variable is not set.",
114
+ isError: true,
115
+ };
116
+ }
117
+ const body = buildRequestBody(input);
118
+ try {
119
+ const response = await fetch(ENDPOINT, {
120
+ method: "POST",
121
+ headers: {
122
+ "Content-Type": "application/json",
123
+ "x-api-key": apiKey,
124
+ "x-exa-integration": INTEGRATION_HEADER,
125
+ "User-Agent": "OpenHarness/1.0",
126
+ },
127
+ body: JSON.stringify(body),
128
+ signal: AbortSignal.timeout(30_000),
129
+ });
130
+ if (!response.ok) {
131
+ const errText = await response.text().catch(() => "");
132
+ const detail = errText ? `: ${errText.slice(0, 500)}` : "";
133
+ return {
134
+ output: `Error: Exa API returned ${response.status} ${response.statusText}${detail}`,
135
+ isError: true,
136
+ };
137
+ }
138
+ const json = (await response.json());
139
+ return { output: formatResults(json), isError: false };
140
+ }
141
+ catch (err) {
142
+ const message = err instanceof Error ? err.message : String(err);
143
+ return { output: `Error performing Exa search: ${message}`, isError: true };
144
+ }
145
+ },
146
+ prompt() {
147
+ return `Search the web using Exa's neural search engine and return ranked results with snippets. Requires EXA_API_KEY env var. Parameters:
148
+ - query (string, required): The search query.
149
+ - num_results (number, optional): Max results to return (default 5).
150
+ - type (string, optional): "auto" (default), "neural", "fast", or "keyword".
151
+ - category (string, optional): One of "company", "research paper", "news", "personal site", "financial report", "people".
152
+ - include_domains (string[], optional): Restrict to these domains.
153
+ - exclude_domains (string[], optional): Skip these domains.
154
+ - include_text (string[], optional): Results must contain these phrases.
155
+ - exclude_text (string[], optional): Skip results containing these phrases.
156
+ - start_published_date / end_published_date (string, optional): ISO 8601 publication date filters.
157
+ - user_location (string, optional): Two-letter ISO country code.
158
+ - text (boolean, optional): Include page text (default true).
159
+ - highlights (boolean, optional): Include highlight snippets (default true).
160
+ - summary (boolean, optional): Include AI-generated summary (default false).
161
+ - summary_query (string, optional): Custom summarization query.
162
+ - max_text_chars (number, optional): Cap text length per result (default 1500).`;
163
+ },
164
+ };
165
+ //# sourceMappingURL=index.js.map
package/dist/tools.js CHANGED
@@ -14,6 +14,7 @@ import { CronCreateTool, CronDeleteTool, CronListTool } from "./tools/CronTool/i
14
14
  import { DiagnosticsTool } from "./tools/DiagnosticsTool/index.js";
15
15
  import { EnterPlanModeTool } from "./tools/EnterPlanModeTool/index.js";
16
16
  import { EnterWorktreeTool } from "./tools/EnterWorktreeTool/index.js";
17
+ import { ExaSearchTool } from "./tools/ExaSearchTool/index.js";
17
18
  import { ExitPlanModeTool } from "./tools/ExitPlanModeTool/index.js";
18
19
  import { ExitWorktreeTool } from "./tools/ExitWorktreeTool/index.js";
19
20
  import { FileEditTool } from "./tools/FileEditTool/index.js";
@@ -87,6 +88,7 @@ export function getAllTools() {
87
88
  const extended = [
88
89
  WebFetchTool,
89
90
  WebSearchTool,
91
+ ExaSearchTool,
90
92
  TaskGetTool,
91
93
  TaskStopTool,
92
94
  TaskOutputTool,
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Subsequence-match scoring for slash-command and similar pickers.
3
+ *
4
+ * Given a query, returns matched candidates in best-first order with all
5
+ * non-matches dropped. A candidate matches when every query character appears
6
+ * in the candidate name in order (not necessarily contiguous). Score rewards
7
+ * contiguous runs, prefix matches, and word-boundary hits so "git" still
8
+ * surfaces "/git" before "/login" when typing "g".
9
+ *
10
+ * Audit U-B3: replaces the prior `startsWith` filter in `src/repl.ts`.
11
+ */
12
+ export type FuzzyEntry<T> = T & {
13
+ name: string;
14
+ };
15
+ export type FuzzyResult<T> = {
16
+ entry: FuzzyEntry<T>;
17
+ score: number;
18
+ };
19
+ /**
20
+ * Score a single candidate against the query. Returns `null` when query is not
21
+ * a subsequence of name. Higher scores are better.
22
+ *
23
+ * Scoring rubric (additive):
24
+ * +100 candidate name starts with query (still earns subsequence bonus on top)
25
+ * +50 per character of contiguous run (max once per matched char pair)
26
+ * +20 match on a word-boundary char (after `-`, `_`, ` `, ':' or at start)
27
+ * +1 base per matched character
28
+ * -1 per skipped (unmatched) character before final query char
29
+ */
30
+ export declare function fuzzyScore(query: string, name: string): number | null;
31
+ /**
32
+ * Filter and rank entries by `entry.name` against `query`. Stable for ties:
33
+ * preserves the original input order so registration-order categories stay
34
+ * naturally contiguous when scores are equal.
35
+ */
36
+ export declare function fuzzyFilter<T extends {
37
+ name: string;
38
+ }>(query: string, entries: T[]): FuzzyResult<T>[];
39
+ //# sourceMappingURL=fuzzy.d.ts.map
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Subsequence-match scoring for slash-command and similar pickers.
3
+ *
4
+ * Given a query, returns matched candidates in best-first order with all
5
+ * non-matches dropped. A candidate matches when every query character appears
6
+ * in the candidate name in order (not necessarily contiguous). Score rewards
7
+ * contiguous runs, prefix matches, and word-boundary hits so "git" still
8
+ * surfaces "/git" before "/login" when typing "g".
9
+ *
10
+ * Audit U-B3: replaces the prior `startsWith` filter in `src/repl.ts`.
11
+ */
12
+ /**
13
+ * Score a single candidate against the query. Returns `null` when query is not
14
+ * a subsequence of name. Higher scores are better.
15
+ *
16
+ * Scoring rubric (additive):
17
+ * +100 candidate name starts with query (still earns subsequence bonus on top)
18
+ * +50 per character of contiguous run (max once per matched char pair)
19
+ * +20 match on a word-boundary char (after `-`, `_`, ` `, ':' or at start)
20
+ * +1 base per matched character
21
+ * -1 per skipped (unmatched) character before final query char
22
+ */
23
+ export function fuzzyScore(query, name) {
24
+ if (query.length === 0)
25
+ return 0;
26
+ const q = query.toLowerCase();
27
+ const n = name.toLowerCase();
28
+ let qi = 0;
29
+ let score = 0;
30
+ let lastMatchIdx = -2;
31
+ for (let i = 0; i < n.length && qi < q.length; i++) {
32
+ if (n[i] === q[qi]) {
33
+ score += 1;
34
+ const isBoundary = i === 0 || /[-_ :./]/.test(n[i - 1]);
35
+ if (isBoundary)
36
+ score += 20;
37
+ if (i === lastMatchIdx + 1)
38
+ score += 50;
39
+ lastMatchIdx = i;
40
+ qi++;
41
+ }
42
+ }
43
+ if (qi < q.length)
44
+ return null;
45
+ // Prefix bonus: candidate begins with the full query verbatim.
46
+ if (n.startsWith(q))
47
+ score += 100;
48
+ // Penalty for skipped chars between first and last match.
49
+ const span = lastMatchIdx - (n.indexOf(q[0]) ?? 0);
50
+ if (span > q.length)
51
+ score -= span - q.length;
52
+ return score;
53
+ }
54
+ /**
55
+ * Filter and rank entries by `entry.name` against `query`. Stable for ties:
56
+ * preserves the original input order so registration-order categories stay
57
+ * naturally contiguous when scores are equal.
58
+ */
59
+ export function fuzzyFilter(query, entries) {
60
+ const out = [];
61
+ for (const entry of entries) {
62
+ const score = fuzzyScore(query, entry.name);
63
+ if (score === null)
64
+ continue;
65
+ out.push({ entry: entry, score });
66
+ }
67
+ out.sort((a, b) => b.score - a.score);
68
+ return out;
69
+ }
70
+ //# sourceMappingURL=fuzzy.js.map