@nanhara/hara 0.0.1 → 0.33.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/CHANGELOG.md +431 -0
- package/CLA.md +51 -0
- package/LICENSE +201 -21
- package/README.md +203 -7
- package/dist/activity.js +30 -0
- package/dist/agent/loop.js +184 -0
- package/dist/config.js +114 -0
- package/dist/context/agents-md.js +64 -0
- package/dist/context/mentions.js +90 -0
- package/dist/diff.js +103 -0
- package/dist/fs-walk.js +103 -0
- package/dist/fuzzy.js +62 -0
- package/dist/images.js +146 -0
- package/dist/index.js +1362 -0
- package/dist/mcp/client.js +54 -0
- package/dist/md.js +52 -0
- package/dist/memory/guard.js +51 -0
- package/dist/memory/store.js +93 -0
- package/dist/org/planner.js +155 -0
- package/dist/org/roles.js +140 -0
- package/dist/org/router.js +39 -0
- package/dist/plugins/plugins.js +124 -0
- package/dist/providers/anthropic.js +83 -0
- package/dist/providers/openai.js +125 -0
- package/dist/providers/qwen-oauth.js +139 -0
- package/dist/providers/types.js +2 -0
- package/dist/recall.js +76 -0
- package/dist/sandbox.js +78 -0
- package/dist/search/embed.js +42 -0
- package/dist/search/hybrid.js +38 -0
- package/dist/search/semindex.js +141 -0
- package/dist/session/store.js +95 -0
- package/dist/skills/skills.js +141 -0
- package/dist/statusbar.js +69 -0
- package/dist/tools/agent.js +26 -0
- package/dist/tools/apply-core.js +63 -0
- package/dist/tools/builtin.js +106 -0
- package/dist/tools/codebase.js +102 -0
- package/dist/tools/computer.js +236 -0
- package/dist/tools/edit.js +62 -0
- package/dist/tools/memory.js +147 -0
- package/dist/tools/patch.js +123 -0
- package/dist/tools/registry.js +18 -0
- package/dist/tools/search.js +176 -0
- package/dist/tools/skill.js +30 -0
- package/dist/tools/web.js +73 -0
- package/dist/tui/App.js +165 -0
- package/dist/tui/InputBox.js +208 -0
- package/dist/tui/run.js +10 -0
- package/dist/tui/theme.js +11 -0
- package/dist/ui.js +17 -0
- package/dist/undo.js +40 -0
- package/dist/vision.js +81 -0
- package/package.json +33 -7
- package/bin/hara.mjs +0 -25
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// codebase_search — treat the current project as a knowledge base. Lexical relevance search over the
|
|
2
|
+
// repo's code/text (respects .gitignore via listProjectFiles), ranked by how many distinct query words a
|
|
3
|
+
// file contains, returning the densest snippet. Distinct from grep (exact pattern): this finds *related*
|
|
4
|
+
// code from a natural-language query. The interface a semantic (zvec) index slots into later.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { registerTool } from "./registry.js";
|
|
8
|
+
import { listProjectFiles, isProbablyBinary, fileSize } from "../fs-walk.js";
|
|
9
|
+
import { findProjectRoot } from "../context/agents-md.js";
|
|
10
|
+
import { loadConfig } from "../config.js";
|
|
11
|
+
import { getEmbedder } from "../search/embed.js";
|
|
12
|
+
import { queryIndex, indexExists } from "../search/semindex.js";
|
|
13
|
+
const MAX_FILE = 200_000; // skip very large files
|
|
14
|
+
const CODE_RE = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cc|cpp|hpp|cs|swift|scala|sh|bash|sql|md|mdx|json|ya?ml|toml|html|css|scss|less|vue|svelte|astro|tf|proto|graphql|gql|gradle|txt)$/i;
|
|
15
|
+
registerTool({
|
|
16
|
+
name: "codebase_search",
|
|
17
|
+
description: "Find code in THIS project relevant to a natural-language query — ranked by relevance (not exact match). " +
|
|
18
|
+
"Use it to locate similar/related code while working ('where is auth handled?', 'retry logic'); use grep " +
|
|
19
|
+
"for exact strings/regex. Returns the top files with their most relevant snippet (file:line).",
|
|
20
|
+
input_schema: {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: { query: { type: "string" }, limit: { type: "number", description: "default 6 (max 20)" } },
|
|
23
|
+
required: ["query"],
|
|
24
|
+
},
|
|
25
|
+
kind: "read",
|
|
26
|
+
async run(input, ctx) {
|
|
27
|
+
const words = [...new Set(String(input.query ?? "").toLowerCase().split(/\s+/).filter((w) => w.length > 1))];
|
|
28
|
+
if (!words.length)
|
|
29
|
+
return "(empty query)";
|
|
30
|
+
const need = Math.min(2, words.length); // require most of the query to actually appear (conceptual overlap)
|
|
31
|
+
const limit = Math.min(Number(input.limit) || 6, 20);
|
|
32
|
+
const root = findProjectRoot(ctx.cwd);
|
|
33
|
+
const hits = [];
|
|
34
|
+
for (const rel of listProjectFiles(root)) {
|
|
35
|
+
if (!CODE_RE.test(rel))
|
|
36
|
+
continue;
|
|
37
|
+
const abs = join(root, rel);
|
|
38
|
+
if (fileSize(abs) > MAX_FILE)
|
|
39
|
+
continue;
|
|
40
|
+
let buf;
|
|
41
|
+
try {
|
|
42
|
+
buf = readFileSync(abs);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (isProbablyBinary(buf))
|
|
48
|
+
continue;
|
|
49
|
+
const text = buf.toString("utf8");
|
|
50
|
+
const lower = text.toLowerCase();
|
|
51
|
+
const present = words.filter((w) => lower.includes(w));
|
|
52
|
+
if (present.length < need)
|
|
53
|
+
continue;
|
|
54
|
+
// densest line = the one matching the most distinct query words; show it with a little context
|
|
55
|
+
const lines = text.split("\n");
|
|
56
|
+
let bestLine = 0;
|
|
57
|
+
let bestHits = 0;
|
|
58
|
+
for (let i = 0; i < lines.length; i++) {
|
|
59
|
+
const ll = lines[i].toLowerCase();
|
|
60
|
+
const h = present.reduce((n, w) => (ll.includes(w) ? n + 1 : n), 0);
|
|
61
|
+
if (h > bestHits) {
|
|
62
|
+
bestHits = h;
|
|
63
|
+
bestLine = i;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const snippet = lines.slice(Math.max(0, bestLine - 2), bestLine + 4).join("\n");
|
|
67
|
+
hits.push({ file: rel, score: present.length * 100 + bestHits, line: bestLine + 1, snippet });
|
|
68
|
+
}
|
|
69
|
+
hits.sort((a, b) => b.score - a.score || a.file.length - b.file.length);
|
|
70
|
+
// Semantic layer (opt-in): if a repo index + embedder are configured, prepend the most relevant
|
|
71
|
+
// chunks (more precise than word overlap), then fill remaining slots with lexical hits. Falls back
|
|
72
|
+
// to pure lexical when no index/embedder — zero behaviour change for the default install.
|
|
73
|
+
const out = [];
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
const cfg = loadConfig();
|
|
76
|
+
const embed = getEmbedder(cfg);
|
|
77
|
+
if (embed && indexExists("repo", ctx.cwd)) {
|
|
78
|
+
try {
|
|
79
|
+
for (const s of await queryIndex("repo", String(input.query), embed, ctx.cwd, limit)) {
|
|
80
|
+
if (s.score < 0.2 || seen.has(s.file))
|
|
81
|
+
continue;
|
|
82
|
+
seen.add(s.file);
|
|
83
|
+
out.push(`${s.file} (semantic ${s.score.toFixed(2)})\n${s.text.split("\n").slice(0, 6).join("\n")}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* embedding endpoint down → degrade to lexical */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const h of hits) {
|
|
91
|
+
if (out.length >= limit)
|
|
92
|
+
break;
|
|
93
|
+
if (seen.has(h.file))
|
|
94
|
+
continue;
|
|
95
|
+
seen.add(h.file);
|
|
96
|
+
out.push(`${h.file}:${h.line}\n${h.snippet}`);
|
|
97
|
+
}
|
|
98
|
+
if (!out.length)
|
|
99
|
+
return "(no relevant code found)";
|
|
100
|
+
return out.join("\n\n---\n\n");
|
|
101
|
+
},
|
|
102
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// computer — native screen control (operate desktop software, not just the browser). Shell-out per OS, no
|
|
2
|
+
// heavy deps: mac = screencapture + cliclick · windows = PowerShell + .NET/user32 · linux = scrot + xdotool.
|
|
3
|
+
// Safety: opt-in tier (config computerUse off|read|click|full) + per-app allowlist (config computerApps:
|
|
4
|
+
// frontmost-window check before any pointer/keyboard action) + dangerous-key blocklist + a once-per-session
|
|
5
|
+
// grant (tool kind "computer" always confirms once, even in full-auto). Screenshots are read via the vision
|
|
6
|
+
// sidecar (ctx.describeImage) so a text main model can still "see" them.
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { existsSync, statSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { registerTool } from "./registry.js";
|
|
12
|
+
import { loadConfig } from "../config.js";
|
|
13
|
+
const RANK = { off: 0, read: 1, click: 2, full: 3 };
|
|
14
|
+
const ACTION_MIN = { screenshot: "read", move: "click", click: "click", type: "full", key: "full" };
|
|
15
|
+
// dangerous combos refused even at full tier (quit / close / delete / task-switch-kill)
|
|
16
|
+
const KEY_BLOCK = /(?:\b(cmd|command|ctrl|control|alt|option|win|super|meta)\b.*\+.*\b(q|w|delete|del|f4|escape|esc)\b)|ctrl\+alt\+(?:delete|del|backspace)/i;
|
|
17
|
+
/** Whether the configured tier permits the action. Exported for tests. */
|
|
18
|
+
export function actionAllowed(tier, action) {
|
|
19
|
+
return RANK[tier] >= RANK[ACTION_MIN[action] ?? "full"];
|
|
20
|
+
}
|
|
21
|
+
/** Whether a key combo is on the dangerous blocklist. Exported for tests. */
|
|
22
|
+
export function keyIsBlocked(keys) {
|
|
23
|
+
return KEY_BLOCK.test(keys);
|
|
24
|
+
}
|
|
25
|
+
function run(cmd, args) {
|
|
26
|
+
try {
|
|
27
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 15000 });
|
|
28
|
+
return { ok: r.status === 0, out: ((r.stdout || "") + (r.stderr || "")).trim() };
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
return { ok: false, out: e?.message || "spawn failed" };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function has(cmd) {
|
|
35
|
+
return (process.platform === "win32" ? run("where", [cmd]) : run("which", [cmd])).ok;
|
|
36
|
+
}
|
|
37
|
+
const ps = (script) => run("powershell", ["-NoProfile", "-Command", script]);
|
|
38
|
+
let seq = 0;
|
|
39
|
+
function tmpShot() {
|
|
40
|
+
seq += 1;
|
|
41
|
+
return join(tmpdir(), `hara-screen-${process.pid}-${Date.now()}-${seq}.png`);
|
|
42
|
+
}
|
|
43
|
+
function screenshot() {
|
|
44
|
+
const out = tmpShot();
|
|
45
|
+
if (process.platform === "darwin") {
|
|
46
|
+
if (!run("screencapture", ["-x", out]).ok)
|
|
47
|
+
return { error: "screencapture failed (grant Screen Recording permission)" };
|
|
48
|
+
}
|
|
49
|
+
else if (process.platform === "linux") {
|
|
50
|
+
if (has("scrot"))
|
|
51
|
+
run("scrot", ["-o", out]);
|
|
52
|
+
else if (has("import"))
|
|
53
|
+
run("import", ["-window", "root", out]);
|
|
54
|
+
else if (has("grim"))
|
|
55
|
+
run("grim", [out]);
|
|
56
|
+
else
|
|
57
|
+
return { error: "no screenshot tool — install scrot / imagemagick / grim" };
|
|
58
|
+
}
|
|
59
|
+
else if (process.platform === "win32") {
|
|
60
|
+
const script = `Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bmp=New-Object System.Drawing.Bitmap($b.Width,$b.Height); $g=[System.Drawing.Graphics]::FromImage($bmp); $g.CopyFromScreen($b.Location,[System.Drawing.Point]::Empty,$b.Size); $bmp.Save(${JSON.stringify(out)})`;
|
|
61
|
+
if (!ps(script).ok)
|
|
62
|
+
return { error: "PowerShell screenshot failed" };
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
return { error: `unsupported platform ${process.platform}` };
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
if (!existsSync(out) || statSync(out).size === 0)
|
|
69
|
+
return { error: "screenshot produced no file" };
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return { error: "screenshot produced no file" };
|
|
73
|
+
}
|
|
74
|
+
return { path: out };
|
|
75
|
+
}
|
|
76
|
+
/** Name of the frontmost application/window (for the allowlist check). "" if undetectable. */
|
|
77
|
+
function frontmostApp() {
|
|
78
|
+
if (process.platform === "darwin") {
|
|
79
|
+
const r = run("osascript", ["-e", 'tell application "System Events" to get name of first application process whose frontmost is true']);
|
|
80
|
+
return r.ok ? r.out : "";
|
|
81
|
+
}
|
|
82
|
+
if (process.platform === "linux") {
|
|
83
|
+
const r = run("xdotool", ["getactivewindow", "getwindowclassname"]);
|
|
84
|
+
return r.ok ? r.out : "";
|
|
85
|
+
}
|
|
86
|
+
if (process.platform === "win32") {
|
|
87
|
+
const script = `Add-Type @"
|
|
88
|
+
using System;using System.Runtime.InteropServices;public class Hw{[DllImport("user32.dll")]public static extern IntPtr GetForegroundWindow();[DllImport("user32.dll")]public static extern int GetWindowThreadProcessId(IntPtr h,out int p);}
|
|
89
|
+
"@; $p=0;[void][Hw]::GetWindowThreadProcessId([Hw]::GetForegroundWindow(),[ref]$p);(Get-Process -Id $p).ProcessName`;
|
|
90
|
+
const r = ps(script);
|
|
91
|
+
return r.ok ? r.out : "";
|
|
92
|
+
}
|
|
93
|
+
return "";
|
|
94
|
+
}
|
|
95
|
+
function pointerOrKeyboard(action, input) {
|
|
96
|
+
const x = Math.round(Number(input.x));
|
|
97
|
+
const y = Math.round(Number(input.y));
|
|
98
|
+
const mac = process.platform === "darwin";
|
|
99
|
+
const lin = process.platform === "linux";
|
|
100
|
+
const win = process.platform === "win32";
|
|
101
|
+
if (action === "click" || action === "move") {
|
|
102
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
103
|
+
return { ok: false, msg: `${action} needs x,y` };
|
|
104
|
+
if (mac) {
|
|
105
|
+
if (!has("cliclick"))
|
|
106
|
+
return { ok: false, msg: "cliclick not found — install with `brew install cliclick`" };
|
|
107
|
+
const r = run("cliclick", [`${action === "click" ? "c" : "m"}:${x},${y}`]);
|
|
108
|
+
return { ok: r.ok, msg: r.ok ? `${action} at ${x},${y}` : r.out };
|
|
109
|
+
}
|
|
110
|
+
if (lin) {
|
|
111
|
+
if (!has("xdotool"))
|
|
112
|
+
return { ok: false, msg: "xdotool not found" };
|
|
113
|
+
const r = run("xdotool", action === "click" ? ["mousemove", `${x}`, `${y}`, "click", "1"] : ["mousemove", `${x}`, `${y}`]);
|
|
114
|
+
return { ok: r.ok, msg: r.ok ? `${action} at ${x},${y}` : r.out };
|
|
115
|
+
}
|
|
116
|
+
if (win) {
|
|
117
|
+
const move = `Add-Type -AssemblyName System.Windows.Forms;[System.Windows.Forms.Cursor]::Position=New-Object System.Drawing.Point(${x},${y})`;
|
|
118
|
+
const m1 = ps(`Add-Type -AssemblyName System.Drawing;${move}`);
|
|
119
|
+
if (action === "click" && m1.ok) {
|
|
120
|
+
ps(`Add-Type @"
|
|
121
|
+
using System;using System.Runtime.InteropServices;public class Ms{[DllImport("user32.dll")]public static extern void mouse_event(int f,int x,int y,int d,int e);}
|
|
122
|
+
"@; [Ms]::mouse_event(0x2,0,0,0,0);[Ms]::mouse_event(0x4,0,0,0,0)`);
|
|
123
|
+
}
|
|
124
|
+
return { ok: m1.ok, msg: m1.ok ? `${action} at ${x},${y}` : m1.out };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (action === "type") {
|
|
128
|
+
const text = String(input.text ?? "");
|
|
129
|
+
if (!text)
|
|
130
|
+
return { ok: false, msg: "type needs text" };
|
|
131
|
+
if (mac) {
|
|
132
|
+
if (!has("cliclick"))
|
|
133
|
+
return { ok: false, msg: "cliclick not found — install with `brew install cliclick`" };
|
|
134
|
+
const r = run("cliclick", [`t:${text}`]);
|
|
135
|
+
return { ok: r.ok, msg: r.ok ? `typed ${text.length} chars` : r.out };
|
|
136
|
+
}
|
|
137
|
+
if (lin) {
|
|
138
|
+
if (!has("xdotool"))
|
|
139
|
+
return { ok: false, msg: "xdotool not found" };
|
|
140
|
+
const r = run("xdotool", ["type", "--clearmodifiers", text]);
|
|
141
|
+
return { ok: r.ok, msg: r.ok ? `typed ${text.length} chars` : r.out };
|
|
142
|
+
}
|
|
143
|
+
if (win) {
|
|
144
|
+
const r = ps(`Add-Type -AssemblyName System.Windows.Forms;[System.Windows.Forms.SendKeys]::SendWait(${JSON.stringify(text)})`);
|
|
145
|
+
return { ok: r.ok, msg: r.ok ? `typed ${text.length} chars` : r.out };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (action === "key") {
|
|
149
|
+
const keys = String(input.keys ?? "");
|
|
150
|
+
if (!keys)
|
|
151
|
+
return { ok: false, msg: "key needs a key/combo" };
|
|
152
|
+
if (keyIsBlocked(keys))
|
|
153
|
+
return { ok: false, msg: `refused dangerous key combo: ${keys}` };
|
|
154
|
+
if (mac) {
|
|
155
|
+
if (!has("cliclick"))
|
|
156
|
+
return { ok: false, msg: "cliclick not found — install with `brew install cliclick`" };
|
|
157
|
+
const r = run("cliclick", [`kp:${keys}`]);
|
|
158
|
+
return { ok: r.ok, msg: r.ok ? `pressed ${keys}` : r.out };
|
|
159
|
+
}
|
|
160
|
+
if (lin) {
|
|
161
|
+
if (!has("xdotool"))
|
|
162
|
+
return { ok: false, msg: "xdotool not found" };
|
|
163
|
+
const r = run("xdotool", ["key", keys]);
|
|
164
|
+
return { ok: r.ok, msg: r.ok ? `pressed ${keys}` : r.out };
|
|
165
|
+
}
|
|
166
|
+
if (win) {
|
|
167
|
+
const r = ps(`Add-Type -AssemblyName System.Windows.Forms;[System.Windows.Forms.SendKeys]::SendWait(${JSON.stringify(keys)})`);
|
|
168
|
+
return { ok: r.ok, msg: r.ok ? `pressed ${keys}` : r.out };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { ok: false, msg: `unknown or unsupported action '${action}' on ${process.platform}` };
|
|
172
|
+
}
|
|
173
|
+
/** Per-OS backend availability — for `hara doctor`. */
|
|
174
|
+
export function computerBackends() {
|
|
175
|
+
if (process.platform === "darwin")
|
|
176
|
+
return `screencapture ✓ · cliclick ${has("cliclick") ? "✓" : "✗ (brew install cliclick)"}`;
|
|
177
|
+
if (process.platform === "linux")
|
|
178
|
+
return `scrot ${has("scrot") ? "✓" : "✗"} · xdotool ${has("xdotool") ? "✓" : "✗"}`;
|
|
179
|
+
if (process.platform === "win32")
|
|
180
|
+
return "PowerShell (built-in)";
|
|
181
|
+
return `unsupported (${process.platform})`;
|
|
182
|
+
}
|
|
183
|
+
registerTool({
|
|
184
|
+
name: "computer",
|
|
185
|
+
description: "Control the screen to operate desktop software (not just the browser): take a screenshot, then " +
|
|
186
|
+
"click/move/type/press keys at coordinates. Workflow: screenshot → read what's on screen → act. " +
|
|
187
|
+
"Opt-in and permission-gated (tier + per-app allowlist).",
|
|
188
|
+
input_schema: {
|
|
189
|
+
type: "object",
|
|
190
|
+
properties: {
|
|
191
|
+
action: { type: "string", enum: ["screenshot", "click", "move", "type", "key"] },
|
|
192
|
+
x: { type: "number", description: "x pixel (click/move)" },
|
|
193
|
+
y: { type: "number", description: "y pixel (click/move)" },
|
|
194
|
+
text: { type: "string", description: "text to type (type)" },
|
|
195
|
+
keys: { type: "string", description: "key or combo, e.g. 'return', 'cmd+c' (key)" },
|
|
196
|
+
},
|
|
197
|
+
required: ["action"],
|
|
198
|
+
},
|
|
199
|
+
kind: "computer",
|
|
200
|
+
async run(input, ctx) {
|
|
201
|
+
const cfg = loadConfig();
|
|
202
|
+
const tier = cfg.computerUse;
|
|
203
|
+
if (tier === "off")
|
|
204
|
+
return "Screen control is off. Enable it: `hara config set computerUse read|click|full` (and `hara config set computerApps \"App Name, …\"` for the click/type allowlist).";
|
|
205
|
+
const action = String(input.action ?? "");
|
|
206
|
+
if (!actionAllowed(tier, action))
|
|
207
|
+
return `'${action}' needs a higher tier (current computerUse=${tier}). Raise it with \`hara config set computerUse …\`.`;
|
|
208
|
+
if (action !== "screenshot") {
|
|
209
|
+
// per-app allowlist: only act when an allowlisted app is frontmost (the key guard against wrong-window clicks)
|
|
210
|
+
if (!cfg.computerApps.length)
|
|
211
|
+
return "No apps allowlisted — set `hara config set computerApps \"App Name, …\"` before clicking/typing.";
|
|
212
|
+
const app = frontmostApp();
|
|
213
|
+
const allowed = cfg.computerApps.some((a) => app.toLowerCase().includes(a.toLowerCase()) || a.toLowerCase().includes(app.toLowerCase()));
|
|
214
|
+
if (!allowed)
|
|
215
|
+
return `Refused: frontmost app "${app || "unknown"}" isn't in your allowlist (${cfg.computerApps.join(", ")}). Switch to an allowed app or update computerApps.`;
|
|
216
|
+
}
|
|
217
|
+
if (action === "screenshot") {
|
|
218
|
+
const s = screenshot();
|
|
219
|
+
if (s.error)
|
|
220
|
+
return `Screenshot failed: ${s.error}`;
|
|
221
|
+
if (ctx.describeImage) {
|
|
222
|
+
try {
|
|
223
|
+
const desc = await ctx.describeImage(s.path);
|
|
224
|
+
if (desc)
|
|
225
|
+
return `Screenshot (read via vision):\n${desc}`;
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
/* fall through to path */
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return `Screenshot saved to ${s.path}. Configure a vision model so I can read it: \`hara config set visionModel <model>\`.`;
|
|
232
|
+
}
|
|
233
|
+
const r = pointerOrKeyboard(action, input);
|
|
234
|
+
return r.ok ? `✓ ${r.msg}` : `Failed: ${r.msg}`;
|
|
235
|
+
},
|
|
236
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { registerTool } from "./registry.js";
|
|
4
|
+
import { nearestPaths } from "../fs-walk.js";
|
|
5
|
+
import { emitDiff } from "../diff.js";
|
|
6
|
+
import { applyEdits } from "./apply-core.js";
|
|
7
|
+
import { recordEdit } from "../undo.js";
|
|
8
|
+
registerTool({
|
|
9
|
+
name: "edit_file",
|
|
10
|
+
description: "Edit an existing file by replacing exact strings. Provide a single `old_string`/`new_string`, " +
|
|
11
|
+
"or `edits` (an array of {old_string,new_string,replace_all?}) applied in order. Each `old_string` " +
|
|
12
|
+
"must match exactly and appear once (include surrounding context) unless `replace_all` is true. " +
|
|
13
|
+
"Quote variants (straight/curly) are matched leniently. Use write_file to create a new file, or " +
|
|
14
|
+
"apply_patch to change several files at once.",
|
|
15
|
+
input_schema: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
path: { type: "string" },
|
|
19
|
+
old_string: { type: "string", description: "exact text to replace (verbatim, incl. whitespace)" },
|
|
20
|
+
new_string: { type: "string", description: "replacement text" },
|
|
21
|
+
replace_all: { type: "boolean", description: "replace every occurrence (default false)" },
|
|
22
|
+
edits: {
|
|
23
|
+
type: "array",
|
|
24
|
+
description: "multiple edits applied in sequence (alternative to a single old/new)",
|
|
25
|
+
items: {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
old_string: { type: "string" },
|
|
29
|
+
new_string: { type: "string" },
|
|
30
|
+
replace_all: { type: "boolean" },
|
|
31
|
+
},
|
|
32
|
+
required: ["old_string", "new_string"],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
required: ["path"],
|
|
37
|
+
},
|
|
38
|
+
kind: "edit",
|
|
39
|
+
async run(input, ctx) {
|
|
40
|
+
const p = isAbsolute(input.path) ? input.path : resolve(ctx.cwd, input.path);
|
|
41
|
+
const edits = Array.isArray(input.edits) && input.edits.length
|
|
42
|
+
? input.edits
|
|
43
|
+
: [{ old_string: input.old_string, new_string: input.new_string, replace_all: input.replace_all }];
|
|
44
|
+
let text;
|
|
45
|
+
try {
|
|
46
|
+
text = await readFile(p, "utf8");
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
const near = nearestPaths(ctx.cwd, input.path);
|
|
50
|
+
return `Error: cannot read ${input.path} (use write_file to create a new file).` + (near.length ? ` Did you mean: ${near.join(", ")}?` : "");
|
|
51
|
+
}
|
|
52
|
+
const res = applyEdits(text, edits);
|
|
53
|
+
if ("error" in res)
|
|
54
|
+
return `Error: ${res.error} in ${input.path}. No changes written.`;
|
|
55
|
+
await writeFile(p, res.text, "utf8");
|
|
56
|
+
emitDiff(input.path, text, res.text, ctx.ui);
|
|
57
|
+
recordEdit([{ path: input.path, absPath: p, before: text }]);
|
|
58
|
+
const note = res.fuzzy ? " (quote-normalized)" : "";
|
|
59
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
60
|
+
return `Edited ${input.path}: ${plural(edits.length, "edit")}, ${plural(res.total, "replacement")}${note}.`;
|
|
61
|
+
},
|
|
62
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Memory tools — the agent's interface to durable memory. memory_search/get are read-only
|
|
2
|
+
// (parallel-safe, never prompt); memory_write/forget are edits (gated by the approval mode).
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, resolve, join } from "node:path";
|
|
5
|
+
import { registerTool } from "./registry.js";
|
|
6
|
+
import { searchAssets, assetSearchRoots } from "../recall.js";
|
|
7
|
+
import { searchHybrid } from "../search/hybrid.js";
|
|
8
|
+
import { memoryRoots, appendMemory, replaceMemory, forgetMemory } from "../memory/store.js";
|
|
9
|
+
import { scanMemory, redactSecrets, scrubLocal } from "../memory/guard.js";
|
|
10
|
+
import { globalSkillsDir, skillsDir, invalidateSkillsCache } from "../skills/skills.js";
|
|
11
|
+
const asTarget = (v) => (["memory", "user", "log"].includes(v) ? v : "memory");
|
|
12
|
+
const asScope = (v) => (v === "global" ? "global" : "project");
|
|
13
|
+
registerTool({
|
|
14
|
+
name: "memory_search",
|
|
15
|
+
description: "Search your durable memory (facts, decisions, user preferences, daily notes) by keywords. " +
|
|
16
|
+
"Use BEFORE answering about prior work, project conventions, or the user's preferences.",
|
|
17
|
+
input_schema: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: { query: { type: "string" }, limit: { type: "number", description: "default 5" } },
|
|
20
|
+
required: ["query"],
|
|
21
|
+
},
|
|
22
|
+
kind: "read",
|
|
23
|
+
async run(input, ctx) {
|
|
24
|
+
const hits = await searchHybrid(String(input.query ?? ""), ctx.cwd, { indexName: "memory", roots: memoryRoots(ctx.cwd), limit: Math.min(Number(input.limit) || 5, 10) });
|
|
25
|
+
if (!hits.length)
|
|
26
|
+
return "(no memory matches)";
|
|
27
|
+
return hits.map((h) => `${h.path} — ${h.title}\n${h.snippet}`).join("\n\n");
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
registerTool({
|
|
31
|
+
name: "memory_get",
|
|
32
|
+
description: "Read a memory file in full (use after memory_search to pull the exact entry).",
|
|
33
|
+
input_schema: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
|
|
34
|
+
kind: "read",
|
|
35
|
+
async run(input, ctx) {
|
|
36
|
+
const p = isAbsolute(String(input.path)) ? String(input.path) : resolve(ctx.cwd, String(input.path));
|
|
37
|
+
if (!memoryRoots(ctx.cwd).some((r) => p.startsWith(r)))
|
|
38
|
+
return `Error: ${input.path} is outside the memory store.`;
|
|
39
|
+
if (!existsSync(p))
|
|
40
|
+
return `Error: no memory file at ${p}.`;
|
|
41
|
+
try {
|
|
42
|
+
return readFileSync(p, "utf8").slice(0, 50_000);
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
return `Error: ${e.message}`;
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
registerTool({
|
|
50
|
+
name: "memory_write",
|
|
51
|
+
description: "Persist a durable fact/decision/preference to memory so future sessions recall it. Save proactively " +
|
|
52
|
+
"when you learn something worth keeping: project conventions, the user's preferences, a tricky solution.",
|
|
53
|
+
input_schema: {
|
|
54
|
+
type: "object",
|
|
55
|
+
properties: {
|
|
56
|
+
content: { type: "string" },
|
|
57
|
+
target: { type: "string", enum: ["memory", "user", "log"], description: "memory=durable facts, user=user prefs (global), log=today's note. default memory" },
|
|
58
|
+
scope: { type: "string", enum: ["project", "global"], description: "default project" },
|
|
59
|
+
mode: { type: "string", enum: ["append", "replace"], description: "default append" },
|
|
60
|
+
},
|
|
61
|
+
required: ["content"],
|
|
62
|
+
},
|
|
63
|
+
kind: "edit",
|
|
64
|
+
async run(input, ctx) {
|
|
65
|
+
const content = String(input.content ?? "").trim();
|
|
66
|
+
if (!content)
|
|
67
|
+
return "Error: empty content.";
|
|
68
|
+
const scan = scanMemory(content);
|
|
69
|
+
if (!scan.ok)
|
|
70
|
+
return `Blocked: this looks unsafe to store (${scan.hits.join(", ")}). Rephrase without secrets/injection text.`;
|
|
71
|
+
const scope = asScope(input.scope);
|
|
72
|
+
const target = asTarget(input.target);
|
|
73
|
+
const f = input.mode === "replace" ? replaceMemory(scope, target, content, ctx.cwd) : appendMemory(scope, target, content, ctx.cwd);
|
|
74
|
+
return `Saved to ${f}`;
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
registerTool({
|
|
78
|
+
name: "skill_create",
|
|
79
|
+
description: "Save a reusable skill (a how-to / capability) as a SKILL.md so you and future sessions can load it " +
|
|
80
|
+
"later via the `skill` tool. Use after solving something worth reusing. The `description` is how you'll " +
|
|
81
|
+
"recognize when to load it, so make it specific (what it does + when to use it).",
|
|
82
|
+
input_schema: {
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: {
|
|
85
|
+
name: { type: "string", description: "short kebab-case skill id" },
|
|
86
|
+
description: { type: "string", description: "one line: what it does + when to use it" },
|
|
87
|
+
body: { type: "string", description: "the instructions in Markdown (steps, code, gotchas)" },
|
|
88
|
+
scope: { type: "string", enum: ["project", "personal"], description: "project = this repo's .hara/skills; personal = ~/.hara/skills (default). Sharing to company/public is a separate, human-confirmed step." },
|
|
89
|
+
},
|
|
90
|
+
required: ["name", "description", "body"],
|
|
91
|
+
},
|
|
92
|
+
kind: "edit",
|
|
93
|
+
async run(input, ctx) {
|
|
94
|
+
const slug = String(input.name ?? "")
|
|
95
|
+
.toLowerCase()
|
|
96
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
97
|
+
.replace(/^-+|-+$/g, "")
|
|
98
|
+
.slice(0, 48);
|
|
99
|
+
if (!slug)
|
|
100
|
+
return "Error: invalid name.";
|
|
101
|
+
let description = String(input.description ?? "").replace(/\s+/g, " ").trim();
|
|
102
|
+
if (!description)
|
|
103
|
+
return "Error: a description is required (it's how the skill gets surfaced).";
|
|
104
|
+
// sanitize on capture: generalize local paths/emails, then redact secrets; block only on residue.
|
|
105
|
+
description = scrubLocal(description, ctx.cwd);
|
|
106
|
+
let body = scrubLocal(String(input.body ?? ""), ctx.cwd);
|
|
107
|
+
const rd = redactSecrets(description);
|
|
108
|
+
const rb = redactSecrets(body);
|
|
109
|
+
description = rd.text;
|
|
110
|
+
body = rb.text;
|
|
111
|
+
const redactions = [...rd.redactions, ...rb.redactions];
|
|
112
|
+
const scan = scanMemory(`${description}\n${body}`);
|
|
113
|
+
if (!scan.ok)
|
|
114
|
+
return `Blocked: content still looks unsafe (${scan.hits.join(", ")}). Remove injection/exfil text.`;
|
|
115
|
+
const scope = input.scope === "project" ? "project" : "personal";
|
|
116
|
+
const dir = join(scope === "project" ? skillsDir(ctx.cwd) : globalSkillsDir(), slug);
|
|
117
|
+
const f = join(dir, "SKILL.md");
|
|
118
|
+
// dedup: surface a near-duplicate so the agent updates instead of piling up (lexical signal, not a block)
|
|
119
|
+
const dups = searchAssets(`${slug} ${description}`, 3, assetSearchRoots(ctx.cwd)).filter((h) => h.path !== f && h.score >= 2);
|
|
120
|
+
mkdirSync(dir, { recursive: true });
|
|
121
|
+
writeFileSync(f, `---\nname: ${slug}\ndescription: ${description}\n---\n\n${body.trim()}\n`, "utf8");
|
|
122
|
+
invalidateSkillsCache();
|
|
123
|
+
const notes = [
|
|
124
|
+
redactions.length ? `redacted ${redactions.length} secret(s)` : "",
|
|
125
|
+
dups.length ? `⚠ similar already exists: ${dups.map((d) => d.path).join(", ")} — consider updating instead` : "",
|
|
126
|
+
].filter(Boolean);
|
|
127
|
+
return `Saved ${scope} skill to ${f}${notes.length ? ` (${notes.join("; ")})` : ""}`;
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
registerTool({
|
|
131
|
+
name: "memory_forget",
|
|
132
|
+
description: "Remove memory lines matching a substring (prune stale or wrong facts).",
|
|
133
|
+
input_schema: {
|
|
134
|
+
type: "object",
|
|
135
|
+
properties: {
|
|
136
|
+
match: { type: "string" },
|
|
137
|
+
target: { type: "string", enum: ["memory", "user", "log"] },
|
|
138
|
+
scope: { type: "string", enum: ["project", "global"] },
|
|
139
|
+
},
|
|
140
|
+
required: ["match"],
|
|
141
|
+
},
|
|
142
|
+
kind: "edit",
|
|
143
|
+
async run(input, ctx) {
|
|
144
|
+
const n = forgetMemory(asScope(input.scope), asTarget(input.target), String(input.match ?? ""), ctx.cwd);
|
|
145
|
+
return n ? `Removed ${n} line(s).` : "(no matching lines)";
|
|
146
|
+
},
|
|
147
|
+
});
|