@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.
- package/README.md +1 -0
- package/README.zh-CN.md +1 -0
- package/dist/commands/settings.js +26 -5
- package/dist/harness/approvals.d.ts +45 -0
- package/dist/harness/approvals.js +100 -0
- package/dist/harness/config.d.ts +21 -0
- package/dist/harness/status-line-script.d.ts +52 -0
- package/dist/harness/status-line-script.js +88 -0
- package/dist/query/tools.js +36 -0
- package/dist/renderer/cells.d.ts +6 -0
- package/dist/renderer/cells.js +48 -2
- package/dist/renderer/differ.js +18 -1
- package/dist/renderer/index.js +6 -3
- package/dist/renderer/layout-sections.js +30 -15
- package/dist/renderer/markdown.js +4 -1
- package/dist/renderer/spinner-label.d.ts +6 -0
- package/dist/renderer/spinner-label.js +24 -0
- package/dist/renderer/tool-color.d.ts +8 -0
- package/dist/renderer/tool-color.js +20 -0
- package/dist/repl.js +44 -13
- package/dist/tools/ExaSearchTool/index.d.ts +101 -0
- package/dist/tools/ExaSearchTool/index.js +165 -0
- package/dist/tools.js +2 -0
- package/dist/utils/fuzzy.d.ts +39 -0
- package/dist/utils/fuzzy.js +70 -0
- package/package.json +1 -1
|
@@ -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 =
|
|
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 %
|
|
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
|
|
145
|
-
const
|
|
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.
|
|
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.
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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({
|
|
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,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
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
//
|
|
277
|
-
|
|
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
|