@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 +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 +5 -0
- package/dist/renderer/layout-sections.js +2 -2
- package/dist/renderer/markdown.js +4 -1
- package/dist/repl.js +44 -12
- 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
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
|
|
143
|
-
if (!
|
|
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
|
package/dist/harness/config.d.ts
CHANGED
|
@@ -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
|
package/dist/query/tools.js
CHANGED
|
@@ -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
|
}
|
package/dist/renderer/cells.d.ts
CHANGED
|
@@ -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
|
package/dist/renderer/cells.js
CHANGED
|
@@ -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++) {
|
package/dist/renderer/differ.js
CHANGED
|
@@ -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
|
-
//
|
|
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("");
|
package/dist/renderer/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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({
|
|
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
|
|
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)
|
|
@@ -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
|