@zhijiewang/openharness 2.23.0 → 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.
package/README.md CHANGED
@@ -164,6 +164,7 @@ Available variables: `{model}`, `{tokens}` (input↑ output↓), `{cost}` ($X.XX
164
164
  | **Web** | | |
165
165
  | WebFetch | medium | Fetch URL content (SSRF-protected) |
166
166
  | WebSearch | medium | Search the web |
167
+ | ExaSearch | medium | Neural web search via Exa (requires `EXA_API_KEY`) |
167
168
  | RemoteTrigger | high | HTTP requests to webhooks/APIs |
168
169
  | **Tasks** | | |
169
170
  | TaskCreate | low | Create structured tasks |
package/README.zh-CN.md CHANGED
@@ -164,6 +164,7 @@ statusLineFormat: '{model} │ {tokens} │ {cost} │ {ctx}'
164
164
  | **Web** | | |
165
165
  | WebFetch | 中 | 获取 URL 内容(防 SSRF) |
166
166
  | WebSearch | 中 | 网络搜索 |
167
+ | ExaSearch | 中 | 通过 Exa 进行神经网络搜索(需要 `EXA_API_KEY`) |
167
168
  | RemoteTrigger | 高 | 向 webhook/API 发送 HTTP 请求 |
168
169
  | **任务** | | |
169
170
  | TaskCreate | 低 | 创建结构化任务 |
@@ -5,6 +5,7 @@ import { spawn } from "node:child_process";
5
5
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
6
6
  import { homedir, platform } from "node:os";
7
7
  import { dirname, join } from "node:path";
8
+ import { readApprovalLog } from "../harness/approvals.js";
8
9
  import { readOhConfig } from "../harness/config.js";
9
10
  import { loadKeybindings } from "../harness/keybindings.js";
10
11
  import { isTrusted, listTrusted, trust } from "../harness/trust.js";
@@ -138,17 +139,37 @@ export function registerSettingsCommands(register) {
138
139
  const { sandboxStatus } = require("../harness/sandbox.js");
139
140
  return { output: `${sandboxStatus()}\n\nConfigure in .oh/config.yaml under sandbox:`, handled: true };
140
141
  });
141
- register("permissions", "View or change permission mode", (args, ctx) => {
142
- const mode = args.trim().toLowerCase();
143
- if (!mode) {
142
+ register("permissions", "View or change permission mode (or 'log' for approval history)", (args, ctx) => {
143
+ const trimmed = args.trim();
144
+ if (!trimmed) {
144
145
  return {
145
- output: `Current permission mode: ${ctx.permissionMode}\n\nAvailable modes:\n ask Prompt for medium/high risk (default)\n trust Auto-approve everything\n deny Only low-risk read-only\n acceptEdits Auto-approve file edits\n plan Read-only mode\n auto Auto-approve, block dangerous bash\n bypassPermissions CI/CD only`,
146
+ output: `Current permission mode: ${ctx.permissionMode}\n\nAvailable modes:\n ask Prompt for medium/high risk (default)\n trust Auto-approve everything\n deny Only low-risk read-only\n acceptEdits Auto-approve file edits\n plan Read-only mode\n auto Auto-approve, block dangerous bash\n bypassPermissions CI/CD only\n\nApproval history:\n /permissions log [n] Show last n approval decisions (default 50)`,
146
147
  handled: true,
147
148
  };
148
149
  }
150
+ // Audit U-B5: /permissions log [n] — show approval history from
151
+ // ~/.oh/approvals.log. Subcommand check happens before the mode-name
152
+ // validation so "log" doesn't collide with the mode list.
153
+ const [head, ...tail] = trimmed.split(/\s+/);
154
+ if (head?.toLowerCase() === "log") {
155
+ const n = Math.max(1, Math.min(500, Number.parseInt(tail[0] ?? "50", 10) || 50));
156
+ const records = readApprovalLog(n);
157
+ if (records.length === 0) {
158
+ return { output: "No approval decisions logged yet.", handled: true };
159
+ }
160
+ const lines = records.map((r) => {
161
+ const time = r.ts.slice(11, 19); // HH:MM:SS from ISO
162
+ const date = r.ts.slice(0, 10);
163
+ const decision = r.decision === "allow" ? "✓" : r.decision === "always" ? "★" : "✗";
164
+ const reason = r.reason ? ` (${r.reason})` : "";
165
+ return `${date} ${time} ${decision} ${r.decision.padEnd(7)} ${r.tool.padEnd(14)} ${r.source}${reason}`;
166
+ });
167
+ return { output: `Last ${records.length} approval decisions:\n${lines.join("\n")}`, handled: true };
168
+ }
169
+ const mode = trimmed.toLowerCase();
149
170
  const valid = ["ask", "trust", "deny", "acceptedits", "plan", "auto", "bypasspermissions"];
150
171
  if (!valid.includes(mode)) {
151
- return { output: `Unknown mode: ${mode}. Valid: ${valid.join(", ")}`, handled: true };
172
+ return { output: `Unknown mode: ${mode}. Valid: ${valid.join(", ")} | log`, handled: true };
152
173
  }
153
174
  return {
154
175
  output: `Permission mode set to: ${mode}\n(Note: takes effect for new tool calls in this session)`,
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Approval log (audit U-B5).
3
+ *
4
+ * Append-only JSONL at `~/.oh/approvals.log` recording every permission
5
+ * resolution OH makes during a session — the source (user / hook / rule /
6
+ * permission-prompt-tool / headless / policy), the tool name, the decision
7
+ * (allow / deny / always), a redacted args preview, and a timestamp.
8
+ *
9
+ * Rotated to a `.1` sibling once the file exceeds ~2 MiB so the log doesn't
10
+ * grow unbounded. The slash command `/permissions log` reads the tail.
11
+ *
12
+ * Mirrors Claude Code's session approval log. Genuinely new — no prior art
13
+ * in OH (grep-verified during the audit refresh).
14
+ */
15
+ export type ApprovalSource = "user" | "hook" | "rule" | "permission-prompt-tool" | "policy" | "headless";
16
+ export type ApprovalDecision = "allow" | "deny" | "always";
17
+ export interface ApprovalRecord {
18
+ ts: string;
19
+ tool: string;
20
+ decision: ApprovalDecision;
21
+ source: ApprovalSource;
22
+ /** Tool args as JSON, truncated to ~500 chars to keep the log compact. */
23
+ argsPreview?: string;
24
+ /** Optional human-readable reason (hook reason, headless reason, etc.). */
25
+ reason?: string;
26
+ cwd?: string;
27
+ }
28
+ /**
29
+ * Override the log file path for tests, or `null` to silence the writer.
30
+ * Calling without arguments resets to the real `~/.oh/approvals.log`.
31
+ */
32
+ export declare function setApprovalLogPathForTests(path: string | null | undefined): void;
33
+ /**
34
+ * Append a single approval decision to the log. Errors are swallowed: a
35
+ * disk-full or permission error must not block the agent loop.
36
+ */
37
+ export declare function recordApproval(rec: Omit<ApprovalRecord, "ts">): void;
38
+ /**
39
+ * Read the most recent `n` records from the log. Skips malformed lines.
40
+ * Used by the `/permissions log` slash command.
41
+ */
42
+ export declare function readApprovalLog(n?: number): ApprovalRecord[];
43
+ /** Truncate an args string to roughly N chars without breaking JSON brackets. */
44
+ export declare function previewArgs(argsJson: string, max?: number): string;
45
+ //# sourceMappingURL=approvals.d.ts.map
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Approval log (audit U-B5).
3
+ *
4
+ * Append-only JSONL at `~/.oh/approvals.log` recording every permission
5
+ * resolution OH makes during a session — the source (user / hook / rule /
6
+ * permission-prompt-tool / headless / policy), the tool name, the decision
7
+ * (allow / deny / always), a redacted args preview, and a timestamp.
8
+ *
9
+ * Rotated to a `.1` sibling once the file exceeds ~2 MiB so the log doesn't
10
+ * grow unbounded. The slash command `/permissions log` reads the tail.
11
+ *
12
+ * Mirrors Claude Code's session approval log. Genuinely new — no prior art
13
+ * in OH (grep-verified during the audit refresh).
14
+ */
15
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { dirname, join } from "node:path";
18
+ const LOG_FILE = join(homedir(), ".oh", "approvals.log");
19
+ const ROTATE_BYTES = 2 * 1024 * 1024;
20
+ /** Test seam — flip to `null` to disable logging in test runs. */
21
+ let _logFileOverride;
22
+ /**
23
+ * Override the log file path for tests, or `null` to silence the writer.
24
+ * Calling without arguments resets to the real `~/.oh/approvals.log`.
25
+ */
26
+ export function setApprovalLogPathForTests(path) {
27
+ _logFileOverride = path;
28
+ }
29
+ function logPath() {
30
+ if (_logFileOverride === null)
31
+ return null;
32
+ return _logFileOverride ?? LOG_FILE;
33
+ }
34
+ function rotateIfNeeded(file) {
35
+ try {
36
+ const st = statSync(file);
37
+ if (st.size >= ROTATE_BYTES) {
38
+ renameSync(file, `${file}.1`);
39
+ }
40
+ }
41
+ catch {
42
+ /* file does not exist yet — no rotate needed */
43
+ }
44
+ }
45
+ /**
46
+ * Append a single approval decision to the log. Errors are swallowed: a
47
+ * disk-full or permission error must not block the agent loop.
48
+ */
49
+ export function recordApproval(rec) {
50
+ const file = logPath();
51
+ if (!file)
52
+ return;
53
+ try {
54
+ mkdirSync(dirname(file), { recursive: true });
55
+ rotateIfNeeded(file);
56
+ const line = `${JSON.stringify({ ts: new Date().toISOString(), ...rec })}\n`;
57
+ appendFileSync(file, line, "utf8");
58
+ }
59
+ catch {
60
+ /* logging must not throw into caller */
61
+ }
62
+ }
63
+ /**
64
+ * Read the most recent `n` records from the log. Skips malformed lines.
65
+ * Used by the `/permissions log` slash command.
66
+ */
67
+ export function readApprovalLog(n = 50) {
68
+ const file = logPath();
69
+ if (!file || !existsSync(file))
70
+ return [];
71
+ let raw;
72
+ try {
73
+ raw = readFileSync(file, "utf8");
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ const lines = raw.split("\n").filter((l) => l.length > 0);
79
+ const tail = lines.slice(Math.max(0, lines.length - n));
80
+ const out = [];
81
+ for (const line of tail) {
82
+ try {
83
+ const obj = JSON.parse(line);
84
+ if (obj && typeof obj.tool === "string" && typeof obj.decision === "string") {
85
+ out.push(obj);
86
+ }
87
+ }
88
+ catch {
89
+ /* skip malformed line */
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+ /** Truncate an args string to roughly N chars without breaking JSON brackets. */
95
+ export function previewArgs(argsJson, max = 500) {
96
+ if (argsJson.length <= max)
97
+ return argsJson;
98
+ return `${argsJson.slice(0, max)}…`;
99
+ }
100
+ //# sourceMappingURL=approvals.js.map
@@ -151,6 +151,27 @@ export type OhConfig = {
151
151
  apiKeyHelper?: string;
152
152
  toolPermissions?: ToolPermissionRule[];
153
153
  statusLineFormat?: string;
154
+ /**
155
+ * JSON-envelope status line script (audit U-B1). When set, OH spawns
156
+ * `command` through the user's shell on each refresh, pipes a JSON
157
+ * envelope `{ model, tokens, cost, ctx, sessionId, cwd, gitBranch }` to
158
+ * stdin, and uses the trimmed stdout as the status line. Mirrors Claude
159
+ * Code's `statusLine` config. Gated through the workspace-trust system —
160
+ * scripts only run in trusted dirs.
161
+ *
162
+ * Output is cached for `refreshMs` (default 1000) so the script doesn't
163
+ * re-spawn on every keypress. Multi-line output is truncated to the
164
+ * first line.
165
+ *
166
+ * Coexists with `statusLineFormat` — when both are set, the script wins.
167
+ */
168
+ statusLine?: {
169
+ command: string;
170
+ /** Cache window in ms. Default: 1000. Min: 100. */
171
+ refreshMs?: number;
172
+ /** Spawn timeout in ms. Default: 2000. */
173
+ timeoutMs?: number;
174
+ };
154
175
  /** Verification loops — auto-run lint/typecheck after file edits */
155
176
  verification?: {
156
177
  enabled?: boolean;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * JSON-envelope status line script runner (audit U-B1).
3
+ *
4
+ * Mirrors Claude Code's `statusLine` config. The user configures a shell
5
+ * command in `.oh/config.yaml`:
6
+ *
7
+ * statusLine:
8
+ * command: "~/scripts/oh-status.sh"
9
+ * refreshMs: 2000
10
+ *
11
+ * On each REPL refresh, OH:
12
+ * 1. Builds a JSON envelope of session state (model, tokens, cost, etc.)
13
+ * 2. If the cache window hasn't expired AND the envelope hasn't changed,
14
+ * returns the cached stdout — no spawn cost on every keypress.
15
+ * 3. Otherwise spawns the command through the shell, pipes the envelope
16
+ * on stdin, captures stdout (timeout: 2s), trims to the first line.
17
+ *
18
+ * Caller is responsible for the workspace-trust gate (`isTrusted(cwd)`);
19
+ * this module just runs the command. Failures (non-zero exit, timeout,
20
+ * spawn error) return null so the caller can fall back to template /
21
+ * default rendering.
22
+ *
23
+ * Synchronous spawn used so the renderer doesn't need an async path —
24
+ * keeps the keypress loop hot. Trade-off: a slow script blocks the render
25
+ * up to `timeoutMs`; the cache makes this rare.
26
+ */
27
+ export interface StatusLineEnvelope {
28
+ model: string;
29
+ tokens: {
30
+ input: number;
31
+ output: number;
32
+ };
33
+ cost: number;
34
+ contextPercent: number;
35
+ sessionId: string;
36
+ cwd: string;
37
+ gitBranch?: string;
38
+ }
39
+ export interface StatusLineConfig {
40
+ command: string;
41
+ refreshMs?: number;
42
+ timeoutMs?: number;
43
+ }
44
+ /**
45
+ * Run the status line script with the given envelope. Returns the trimmed
46
+ * first line of stdout, or null on failure / empty output. Caches results
47
+ * for `refreshMs`.
48
+ */
49
+ export declare function runStatusLineScript(env: StatusLineEnvelope, cfg: StatusLineConfig): string | null;
50
+ /** @internal Test-only reset. */
51
+ export declare function _resetStatusLineCacheForTest(): void;
52
+ //# sourceMappingURL=status-line-script.d.ts.map
@@ -0,0 +1,88 @@
1
+ /**
2
+ * JSON-envelope status line script runner (audit U-B1).
3
+ *
4
+ * Mirrors Claude Code's `statusLine` config. The user configures a shell
5
+ * command in `.oh/config.yaml`:
6
+ *
7
+ * statusLine:
8
+ * command: "~/scripts/oh-status.sh"
9
+ * refreshMs: 2000
10
+ *
11
+ * On each REPL refresh, OH:
12
+ * 1. Builds a JSON envelope of session state (model, tokens, cost, etc.)
13
+ * 2. If the cache window hasn't expired AND the envelope hasn't changed,
14
+ * returns the cached stdout — no spawn cost on every keypress.
15
+ * 3. Otherwise spawns the command through the shell, pipes the envelope
16
+ * on stdin, captures stdout (timeout: 2s), trims to the first line.
17
+ *
18
+ * Caller is responsible for the workspace-trust gate (`isTrusted(cwd)`);
19
+ * this module just runs the command. Failures (non-zero exit, timeout,
20
+ * spawn error) return null so the caller can fall back to template /
21
+ * default rendering.
22
+ *
23
+ * Synchronous spawn used so the renderer doesn't need an async path —
24
+ * keeps the keypress loop hot. Trade-off: a slow script blocks the render
25
+ * up to `timeoutMs`; the cache makes this rare.
26
+ */
27
+ import { spawnSync } from "node:child_process";
28
+ let cache = null;
29
+ const DEFAULT_REFRESH_MS = 1000;
30
+ const MIN_REFRESH_MS = 100;
31
+ const DEFAULT_TIMEOUT_MS = 2000;
32
+ /**
33
+ * Stable cache key from the envelope. Excludes timestamps / sessionId-y
34
+ * things that don't actually change the script's output (the script reads
35
+ * the envelope it gets — if the input is the same, the output should be
36
+ * too).
37
+ */
38
+ function envelopeKey(env) {
39
+ return JSON.stringify({
40
+ model: env.model,
41
+ tokens: env.tokens,
42
+ cost: env.cost,
43
+ contextPercent: env.contextPercent,
44
+ cwd: env.cwd,
45
+ gitBranch: env.gitBranch,
46
+ });
47
+ }
48
+ /**
49
+ * Run the status line script with the given envelope. Returns the trimmed
50
+ * first line of stdout, or null on failure / empty output. Caches results
51
+ * for `refreshMs`.
52
+ */
53
+ export function runStatusLineScript(env, cfg) {
54
+ const refresh = Math.max(MIN_REFRESH_MS, cfg.refreshMs ?? DEFAULT_REFRESH_MS);
55
+ const timeout = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS;
56
+ const key = envelopeKey(env);
57
+ const now = Date.now();
58
+ if (cache && cache.envelopeKey === key && now - cache.timestamp < refresh) {
59
+ return cache.output;
60
+ }
61
+ try {
62
+ const result = spawnSync(cfg.command, {
63
+ shell: true,
64
+ timeout,
65
+ stdio: ["pipe", "pipe", "pipe"],
66
+ input: JSON.stringify(env),
67
+ encoding: "utf8",
68
+ });
69
+ if (result.error || result.status !== 0) {
70
+ return null;
71
+ }
72
+ const out = (result.stdout ?? "").toString().trim();
73
+ if (!out)
74
+ return null;
75
+ // Truncate to first line — multi-line output would corrupt the status row.
76
+ const firstLine = out.split(/\r?\n/)[0];
77
+ cache = { envelopeKey: key, output: firstLine, timestamp: now };
78
+ return firstLine;
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ /** @internal Test-only reset. */
85
+ export function _resetStatusLineCacheForTest() {
86
+ cache = null;
87
+ }
88
+ //# sourceMappingURL=status-line-script.js.map
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Tool execution — permission checking, batching, output capping.
3
3
  */
4
+ import { previewArgs, recordApproval } from "../harness/approvals.js";
4
5
  import { createCheckpoint, getAffectedFiles } from "../harness/checkpoints.js";
5
6
  import { emitHook, emitHookWithOutcome } from "../harness/hooks.js";
6
7
  import { findToolByName } from "../Tool.js";
@@ -91,6 +92,7 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
91
92
  permissionMode,
92
93
  permissionAction: "ask",
93
94
  });
95
+ const argsPreview = previewArgs(JSON.stringify(toolCall.arguments));
94
96
  const denyAndEmit = (source, reason, output) => {
95
97
  emitHook("permissionDenied", {
96
98
  toolName: tool.name,
@@ -99,10 +101,31 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
99
101
  denySource: source,
100
102
  denyReason: reason,
101
103
  });
104
+ // Audit U-B5: persist denial to ~/.oh/approvals.log so /permissions log
105
+ // can replay the session's approval history. The cast is safe — the
106
+ // four call sites below pass exact string literals matching ApprovalSource.
107
+ recordApproval({
108
+ tool: tool.name,
109
+ decision: "deny",
110
+ source: source,
111
+ argsPreview,
112
+ reason,
113
+ cwd: process.cwd(),
114
+ });
102
115
  return { output, isError: true };
103
116
  };
117
+ const recordAllow = (source) => {
118
+ recordApproval({
119
+ tool: tool.name,
120
+ decision: "allow",
121
+ source,
122
+ argsPreview,
123
+ cwd: process.cwd(),
124
+ });
125
+ };
104
126
  if (hookOutcome.permissionDecision === "allow") {
105
127
  // Hook granted permission — proceed to execution.
128
+ recordAllow("hook");
106
129
  }
107
130
  else if (hookOutcome.permissionDecision === "deny" || !hookOutcome.allowed) {
108
131
  const reason = hookOutcome.reason ? `: ${hookOutcome.reason}` : "";
@@ -118,6 +141,7 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
118
141
  const promptDecision = await callPermissionPromptTool(permissionPromptTool, tools, context, tool.name, parsed.data);
119
142
  if (promptDecision.behavior === "allow") {
120
143
  // Permission tool granted — proceed.
144
+ recordAllow("permission-prompt-tool");
121
145
  }
122
146
  else if (promptDecision.behavior === "deny") {
123
147
  return denyAndEmit("permission-prompt-tool", promptDecision.message ?? "denied", `Permission denied by ${permissionPromptTool}${promptDecision.message ? `: ${promptDecision.message}` : ""}`);
@@ -131,6 +155,7 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
131
155
  if (!allowed) {
132
156
  return denyAndEmit("user", "user declined", "Permission denied by user.");
133
157
  }
158
+ recordAllow("user");
134
159
  }
135
160
  else {
136
161
  return denyAndEmit("headless", "permission-prompt-tool unavailable and no interactive prompt", `Permission denied: ${permissionPromptTool} did not produce a usable decision and no interactive prompt is available.`);
@@ -144,6 +169,7 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
144
169
  if (!allowed) {
145
170
  return denyAndEmit("user", "user declined", "Permission denied by user.");
146
171
  }
172
+ recordAllow("user");
147
173
  }
148
174
  else {
149
175
  // Headless mode with no hook decision and no interactive prompt:
@@ -161,6 +187,16 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
161
187
  denySource: "policy",
162
188
  denyReason: perm.reason,
163
189
  });
190
+ // Audit U-B5: a `tool-rule-deny` reason came from an explicit
191
+ // `toolPermissions` rule the user wrote; everything else is policy.
192
+ recordApproval({
193
+ tool: tool.name,
194
+ decision: "deny",
195
+ source: perm.reason === "tool-rule-deny" ? "rule" : "policy",
196
+ argsPreview: previewArgs(JSON.stringify(toolCall.arguments)),
197
+ reason: perm.reason,
198
+ cwd: process.cwd(),
199
+ });
164
200
  return { output: `Permission denied: ${perm.reason}`, isError: true };
165
201
  }
166
202
  }
@@ -7,6 +7,7 @@ export type Style = {
7
7
  bold: boolean;
8
8
  dim: boolean;
9
9
  underline: boolean;
10
+ hyperlink?: string | null;
10
11
  };
11
12
  export type Cell = {
12
13
  char: string;
@@ -32,6 +33,11 @@ export declare class CellGrid {
32
33
  * Returns the number of rows consumed.
33
34
  */
34
35
  writeWrapped(row: number, col: number, text: string, style: Style, wrapWidth: number, maxRow?: number): number;
36
+ /**
37
+ * Write text on a single row (no wrapping), tagging http(s):// and file:// runs
38
+ * with the OSC 8 hyperlink attribute (cyan + underline). Stops at maxCol or grid width.
39
+ */
40
+ writeTextWithLinks(row: number, col: number, text: string, baseStyle: Style, maxCol?: number): void;
35
41
  clone(): CellGrid;
36
42
  }
37
43
  //# sourceMappingURL=cells.d.ts.map
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Cell grid — 2D array of styled characters for terminal rendering.
3
3
  */
4
- export const EMPTY_STYLE = { fg: null, bg: null, bold: false, dim: false, underline: false };
4
+ export const EMPTY_STYLE = { fg: null, bg: null, bold: false, dim: false, underline: false, hyperlink: null };
5
5
  export const EMPTY_CELL = { char: " ", style: { ...EMPTY_STYLE } };
6
6
  export function cellsEqual(a, b) {
7
7
  return (a.char === b.char &&
@@ -9,7 +9,8 @@ export function cellsEqual(a, b) {
9
9
  a.style.bg === b.style.bg &&
10
10
  a.style.bold === b.style.bold &&
11
11
  a.style.dim === b.style.dim &&
12
- a.style.underline === b.style.underline);
12
+ a.style.underline === b.style.underline &&
13
+ (a.style.hyperlink ?? null) === (b.style.hyperlink ?? null));
13
14
  }
14
15
  export class CellGrid {
15
16
  width;
@@ -37,6 +38,7 @@ export class CellGrid {
37
38
  cell.style.bold = false;
38
39
  cell.style.dim = false;
39
40
  cell.style.underline = false;
41
+ cell.style.hyperlink = null;
40
42
  }
41
43
  }
42
44
  }
@@ -51,6 +53,7 @@ export class CellGrid {
51
53
  cell.style.bold = s.bold;
52
54
  cell.style.dim = s.dim;
53
55
  cell.style.underline = s.underline;
56
+ cell.style.hyperlink = s.hyperlink ?? null;
54
57
  }
55
58
  /**
56
59
  * Write a string into the grid at (row, col). Handles \n for line breaks.
@@ -122,6 +125,49 @@ export class CellGrid {
122
125
  }
123
126
  return r - row;
124
127
  }
128
+ /**
129
+ * Write text on a single row (no wrapping), tagging http(s):// and file:// runs
130
+ * with the OSC 8 hyperlink attribute (cyan + underline). Stops at maxCol or grid width.
131
+ */
132
+ writeTextWithLinks(row, col, text, baseStyle, maxCol) {
133
+ const limit = Math.min(maxCol ?? this.width, this.width);
134
+ if (row < 0 || row >= this.height)
135
+ return;
136
+ const linkRegex = /(https?:\/\/[^\s)\]'"<>]+|file:\/\/[^\s)\]'"<>]+)/g;
137
+ let cursor = 0;
138
+ let c = col;
139
+ while (true) {
140
+ const m = linkRegex.exec(text);
141
+ if (m === null)
142
+ break;
143
+ const before = text.slice(cursor, m.index);
144
+ for (let i = 0; i < before.length && c < limit; i++) {
145
+ this.setCell(row, c, before[i], baseStyle);
146
+ c++;
147
+ }
148
+ if (c >= limit)
149
+ return;
150
+ // Strip trailing punctuation that's almost never part of the URL.
151
+ let url = m[0];
152
+ while (url.length > 0 && /[.,;:!?]/.test(url[url.length - 1])) {
153
+ url = url.slice(0, -1);
154
+ }
155
+ if (url.length === 0) {
156
+ cursor = m.index + m[0].length;
157
+ continue;
158
+ }
159
+ const linkStyle = { ...baseStyle, fg: "cyan", underline: true, hyperlink: url };
160
+ for (let i = 0; i < url.length && c < limit; i++) {
161
+ this.setCell(row, c, url[i], linkStyle);
162
+ c++;
163
+ }
164
+ cursor = m.index + url.length;
165
+ }
166
+ for (let i = cursor; i < text.length && c < limit; i++) {
167
+ this.setCell(row, c, text[i], baseStyle);
168
+ c++;
169
+ }
170
+ }
125
171
  clone() {
126
172
  const g = new CellGrid(this.width, this.height);
127
173
  for (let r = 0; r < this.height; r++) {
@@ -18,6 +18,11 @@ export function styleToSGR(style) {
18
18
  codes.push(BG_CODES[style.bg]);
19
19
  return `\x1b[${codes.join(";")}m`;
20
20
  }
21
+ /** OSC 8 hyperlink open/close sequences (ST = ESC \). */
22
+ function osc8Open(url) {
23
+ return `\x1b]8;;${url}\x1b\\`;
24
+ }
25
+ const OSC8_CLOSE = "\x1b]8;;\x1b\\";
21
26
  /**
22
27
  * Compare two grids and return the ANSI string that transforms prev into next.
23
28
  * Only emits escape sequences for changed cells.
@@ -25,6 +30,7 @@ export function styleToSGR(style) {
25
30
  export function diff(prev, next, rowOffset = 0) {
26
31
  const parts = [];
27
32
  let lastStyle = null;
33
+ let lastHyperlink = null;
28
34
  let expectedRow = -1;
29
35
  let expectedCol = -1;
30
36
  for (let r = 0; r < next.height; r++) {
@@ -43,13 +49,24 @@ export function diff(prev, next, rowOffset = 0) {
43
49
  parts.push(sgr);
44
50
  lastStyle = sgr;
45
51
  }
52
+ // Apply OSC 8 hyperlink transitions independently of SGR
53
+ const nextHyperlink = nextCell.style.hyperlink ?? null;
54
+ if (nextHyperlink !== lastHyperlink) {
55
+ if (lastHyperlink !== null)
56
+ parts.push(OSC8_CLOSE);
57
+ if (nextHyperlink !== null)
58
+ parts.push(osc8Open(nextHyperlink));
59
+ lastHyperlink = nextHyperlink;
60
+ }
46
61
  parts.push(nextCell.char);
47
62
  expectedRow = r;
48
63
  expectedCol = c + 1;
49
64
  }
50
65
  }
51
- // Reset style at end
66
+ // Close any open hyperlink and reset style at end
52
67
  if (parts.length > 0) {
68
+ if (lastHyperlink !== null)
69
+ parts.push(OSC8_CLOSE);
53
70
  parts.push("\x1b[0m");
54
71
  }
55
72
  return parts.join("");
@@ -3,6 +3,7 @@
3
3
  * Flushed messages flow to scrollback; live area is rewritten in-place
4
4
  * right after the scrollback content each frame (no absolute positioning gap).
5
5
  */
6
+ import { recordApproval } from "../harness/approvals.js";
6
7
  import { getTheme } from "../utils/theme-data.js";
7
8
  import { summarizeToolArgs } from "../utils/tool-summary.js";
8
9
  import { CellGrid } from "./cells.js";
@@ -403,6 +404,10 @@ export class TerminalRenderer {
403
404
  catch {
404
405
  /* persistence failure must not block the agent */
405
406
  }
407
+ // Audit U-B5: log the "always allow this tool" rule promotion as a
408
+ // supplementary record so /permissions log shows the user upgraded
409
+ // from a one-shot allow to a persistent rule.
410
+ recordApproval({ tool: toolName, decision: "always", source: "user", cwd: process.cwd() });
406
411
  }
407
412
  this.scheduleRender();
408
413
  resolve(k === "y" || k === "a");
@@ -187,7 +187,7 @@ export function renderToolCallsSection(state, grid, r, limit, opts) {
187
187
  for (const line of visible) {
188
188
  if (r >= limit)
189
189
  break;
190
- grid.writeText(r, 6, line.slice(0, w - 8), S_DIM);
190
+ grid.writeTextWithLinks(r, 6, line.slice(0, w - 8), S_DIM, w - 2);
191
191
  r++;
192
192
  }
193
193
  }
@@ -205,7 +205,7 @@ export function renderToolCallsSection(state, grid, r, limit, opts) {
205
205
  if (r >= limit)
206
206
  break;
207
207
  const lineStyle = tc.status === "error" ? S_ERROR : S_DIM;
208
- grid.writeText(r, 6, line.slice(0, w - 8), lineStyle);
208
+ grid.writeTextWithLinks(r, 6, line.slice(0, w - 8), lineStyle, w - 2);
209
209
  r++;
210
210
  }
211
211
  if (outLines.length > maxOut && r < 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;
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)
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.23.0",
3
+ "version": "2.24.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {