@zhijiewang/openharness 2.22.1 → 2.24.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.
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.22.1",
3
+ "version": "2.24.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {