@nanhara/hara 0.0.2 → 0.48.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 +582 -0
- package/CLA.md +1 -1
- package/README.md +207 -10
- 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 +1589 -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 +174 -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 +192 -0
- package/dist/session/store.js +109 -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 +376 -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 +200 -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 +130 -0
- package/package.json +34 -9
- package/plugins/browser/.hara-plugin/plugin.json +9 -0
- package/plugins/browser/skills/web/SKILL.md +27 -0
- package/plugins/chrome/.hara-plugin/plugin.json +9 -0
- package/plugins/chrome/skills/chrome/SKILL.md +26 -0
- package/LICENSE-MIT +0 -21
- package/bin/hara.mjs +0 -25
- /package/{LICENSE-APACHE → LICENSE} +0 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// apply_patch — change MULTIPLE files atomically (all-or-nothing). Everything is validated and
|
|
2
|
+
// computed in memory first; nothing is written unless every change applies cleanly.
|
|
3
|
+
import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, resolve, dirname } from "node:path";
|
|
5
|
+
import { registerTool } from "./registry.js";
|
|
6
|
+
import { applyEdits } from "./apply-core.js";
|
|
7
|
+
import { emitDiff } from "../diff.js";
|
|
8
|
+
import { recordEdit } from "../undo.js";
|
|
9
|
+
registerTool({
|
|
10
|
+
name: "apply_patch",
|
|
11
|
+
description: "Change SEVERAL files in one atomic step (all-or-nothing). `changes` is an array of " +
|
|
12
|
+
"{path, type:'update'|'create'|'delete', edits?:[{old_string,new_string,replace_all?}], content?}. " +
|
|
13
|
+
"update applies edits (or replaces the whole file with content); create writes a new file; delete removes it. " +
|
|
14
|
+
"If ANY change fails to apply, nothing is written. Prefer this over multiple edit_file calls for multi-file changes.",
|
|
15
|
+
input_schema: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
changes: {
|
|
19
|
+
type: "array",
|
|
20
|
+
description: "the file changes to apply together",
|
|
21
|
+
items: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
path: { type: "string" },
|
|
25
|
+
type: { type: "string", enum: ["update", "create", "delete"] },
|
|
26
|
+
content: { type: "string", description: "full file content (for create, or whole-file update)" },
|
|
27
|
+
edits: {
|
|
28
|
+
type: "array",
|
|
29
|
+
items: {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: {
|
|
32
|
+
old_string: { type: "string" },
|
|
33
|
+
new_string: { type: "string" },
|
|
34
|
+
replace_all: { type: "boolean" },
|
|
35
|
+
},
|
|
36
|
+
required: ["old_string", "new_string"],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
required: ["path"],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
required: ["changes"],
|
|
45
|
+
},
|
|
46
|
+
kind: "edit",
|
|
47
|
+
async run(input, ctx) {
|
|
48
|
+
const changes = Array.isArray(input.changes) ? input.changes : [];
|
|
49
|
+
if (!changes.length)
|
|
50
|
+
return "Error: apply_patch needs a non-empty `changes` array.";
|
|
51
|
+
const abs = (pth) => (isAbsolute(pth) ? pth : resolve(ctx.cwd, pth));
|
|
52
|
+
// PHASE 1 — validate + compute every change in memory; bail before writing anything.
|
|
53
|
+
const plans = [];
|
|
54
|
+
for (let i = 0; i < changes.length; i++) {
|
|
55
|
+
const ch = changes[i];
|
|
56
|
+
const tag = `change ${i + 1}/${changes.length}`;
|
|
57
|
+
if (typeof ch.path !== "string" || !ch.path)
|
|
58
|
+
return `Error: ${tag} is missing a path. Nothing written.`;
|
|
59
|
+
const p = abs(ch.path);
|
|
60
|
+
const type = ch.type ?? (ch.edits ? "update" : "create");
|
|
61
|
+
if (type === "delete") {
|
|
62
|
+
let before;
|
|
63
|
+
try {
|
|
64
|
+
before = await readFile(p, "utf8");
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return `Error: ${tag} delete ${ch.path}: file not found. Nothing written.`;
|
|
68
|
+
}
|
|
69
|
+
plans.push({ path: ch.path, abs: p, type, before, after: null, existed: true });
|
|
70
|
+
}
|
|
71
|
+
else if (type === "create") {
|
|
72
|
+
if (typeof ch.content !== "string")
|
|
73
|
+
return `Error: ${tag} create ${ch.path} needs \`content\`. Nothing written.`;
|
|
74
|
+
let before = "";
|
|
75
|
+
let existed = false;
|
|
76
|
+
try {
|
|
77
|
+
before = await readFile(p, "utf8");
|
|
78
|
+
existed = true;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* new file */
|
|
82
|
+
}
|
|
83
|
+
plans.push({ path: ch.path, abs: p, type, before, after: ch.content, existed });
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
// update
|
|
87
|
+
let before;
|
|
88
|
+
try {
|
|
89
|
+
before = await readFile(p, "utf8");
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return `Error: ${tag} update ${ch.path}: cannot read (use type:create for a new file). Nothing written.`;
|
|
93
|
+
}
|
|
94
|
+
if (typeof ch.content === "string" && !ch.edits) {
|
|
95
|
+
plans.push({ path: ch.path, abs: p, type, before, after: ch.content, existed: true });
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
const res = applyEdits(before, ch.edits ?? []);
|
|
99
|
+
if ("error" in res)
|
|
100
|
+
return `Error: ${tag} ${ch.path} — ${res.error}. Nothing written.`;
|
|
101
|
+
plans.push({ path: ch.path, abs: p, type, before, after: res.text, existed: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// PHASE 2 — commit all changes + show each diff.
|
|
106
|
+
const summary = [];
|
|
107
|
+
for (const pl of plans) {
|
|
108
|
+
if (pl.type === "delete") {
|
|
109
|
+
await unlink(pl.abs);
|
|
110
|
+
emitDiff(pl.path, pl.before, "", ctx.ui);
|
|
111
|
+
summary.push(`deleted ${pl.path}`);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
await mkdir(dirname(pl.abs), { recursive: true });
|
|
115
|
+
await writeFile(pl.abs, pl.after, "utf8");
|
|
116
|
+
emitDiff(pl.path, pl.before, pl.after, ctx.ui);
|
|
117
|
+
summary.push(`${pl.type === "create" ? "created" : "updated"} ${pl.path}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
recordEdit(plans.map((pl) => ({ path: pl.path, absPath: pl.abs, before: pl.existed ? pl.before : null })));
|
|
121
|
+
return `apply_patch: ${plans.length} file(s) — ${summary.join("; ")}.`;
|
|
122
|
+
},
|
|
123
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const registry = new Map();
|
|
2
|
+
export function registerTool(t) {
|
|
3
|
+
registry.set(t.name, t);
|
|
4
|
+
}
|
|
5
|
+
export function getTool(name) {
|
|
6
|
+
return registry.get(name);
|
|
7
|
+
}
|
|
8
|
+
export function getTools() {
|
|
9
|
+
return [...registry.values()];
|
|
10
|
+
}
|
|
11
|
+
/** Provider-neutral tool specs derived from the registry. */
|
|
12
|
+
export function toolSpecs() {
|
|
13
|
+
return getTools().map((t) => ({
|
|
14
|
+
name: t.name,
|
|
15
|
+
description: t.description,
|
|
16
|
+
input_schema: t.input_schema,
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Search/listing tools — grep (regex across files), glob (path patterns), ls (one directory).
|
|
2
|
+
// All read-only (kind: "read"), so they never hit the approval gate and run in parallel.
|
|
3
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, resolve, join, relative, sep } from "node:path";
|
|
5
|
+
import { registerTool } from "./registry.js";
|
|
6
|
+
import { walkFiles, isProbablyBinary } from "../fs-walk.js";
|
|
7
|
+
const MAX_OUT = 60_000;
|
|
8
|
+
const MAX_MATCHES = 300;
|
|
9
|
+
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
10
|
+
const toPosix = (p) => (sep === "/" ? p : p.split(sep).join("/"));
|
|
11
|
+
const absOf = (p, cwd) => (p ? (isAbsolute(p) ? p : resolve(cwd, p)) : cwd);
|
|
12
|
+
/** Convert a glob (supports **, *, ?) to an anchored RegExp over POSIX paths. */
|
|
13
|
+
function globToRegExp(glob) {
|
|
14
|
+
let re = "";
|
|
15
|
+
for (let i = 0; i < glob.length; i++) {
|
|
16
|
+
const ch = glob[i];
|
|
17
|
+
if (ch === "*") {
|
|
18
|
+
if (glob[i + 1] === "*") {
|
|
19
|
+
// ** matches across path separators (optionally swallow a trailing slash)
|
|
20
|
+
i++;
|
|
21
|
+
if (glob[i + 1] === "/")
|
|
22
|
+
i++;
|
|
23
|
+
re += "(?:.*/)?";
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
re += "[^/]*";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
else if (ch === "?")
|
|
30
|
+
re += "[^/]";
|
|
31
|
+
else if (".+^${}()|[]\\".includes(ch))
|
|
32
|
+
re += "\\" + ch;
|
|
33
|
+
else
|
|
34
|
+
re += ch;
|
|
35
|
+
}
|
|
36
|
+
return new RegExp("^" + re + "$");
|
|
37
|
+
}
|
|
38
|
+
registerTool({
|
|
39
|
+
name: "grep",
|
|
40
|
+
description: "Search file contents by regular expression. Returns matching `path:line: text`. " +
|
|
41
|
+
"Scopes to `path` (dir or file, default cwd); optional `glob` filters which files; `ignore_case`.",
|
|
42
|
+
input_schema: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {
|
|
45
|
+
pattern: { type: "string", description: "JavaScript regular expression" },
|
|
46
|
+
path: { type: "string", description: "directory or file to search (default: cwd)" },
|
|
47
|
+
glob: { type: "string", description: "only search files whose path matches this glob (e.g. **/*.ts)" },
|
|
48
|
+
ignore_case: { type: "boolean" },
|
|
49
|
+
},
|
|
50
|
+
required: ["pattern"],
|
|
51
|
+
},
|
|
52
|
+
kind: "read",
|
|
53
|
+
async run(input, ctx) {
|
|
54
|
+
let re;
|
|
55
|
+
try {
|
|
56
|
+
re = new RegExp(input.pattern, input.ignore_case ? "i" : "");
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
return `Error: invalid regex: ${e.message}`;
|
|
60
|
+
}
|
|
61
|
+
const root = absOf(input.path, ctx.cwd);
|
|
62
|
+
let isFile = false;
|
|
63
|
+
try {
|
|
64
|
+
isFile = statSync(root).isFile();
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return `Error: no such path: ${input.path ?? "."}`;
|
|
68
|
+
}
|
|
69
|
+
const rel = (abs) => toPosix(relative(ctx.cwd, abs)) || toPosix(relative(root, abs));
|
|
70
|
+
const files = isFile ? [root] : walkFiles(root).map((f) => join(root, f));
|
|
71
|
+
const globRe = input.glob ? globToRegExp(input.glob) : null;
|
|
72
|
+
const lines = [];
|
|
73
|
+
let matches = 0;
|
|
74
|
+
let scanned = 0;
|
|
75
|
+
for (const abs of files) {
|
|
76
|
+
if (matches >= MAX_MATCHES)
|
|
77
|
+
break;
|
|
78
|
+
const r = toPosix(relative(ctx.cwd, abs));
|
|
79
|
+
if (globRe && !globRe.test(toPosix(relative(isFile ? ctx.cwd : root, abs))))
|
|
80
|
+
continue;
|
|
81
|
+
let buf;
|
|
82
|
+
try {
|
|
83
|
+
if (statSync(abs).size > MAX_FILE_BYTES)
|
|
84
|
+
continue;
|
|
85
|
+
buf = readFileSync(abs);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (isProbablyBinary(buf))
|
|
91
|
+
continue;
|
|
92
|
+
scanned++;
|
|
93
|
+
const text = buf.toString("utf8");
|
|
94
|
+
const fileLines = text.split("\n");
|
|
95
|
+
for (let i = 0; i < fileLines.length; i++) {
|
|
96
|
+
if (re.test(fileLines[i])) {
|
|
97
|
+
lines.push(`${r}:${i + 1}: ${fileLines[i].trim().slice(0, 300)}`);
|
|
98
|
+
if (++matches >= MAX_MATCHES)
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (!lines.length)
|
|
104
|
+
return `No matches for /${input.pattern}/ (scanned ${scanned} files).`;
|
|
105
|
+
let body = lines.join("\n");
|
|
106
|
+
if (body.length > MAX_OUT)
|
|
107
|
+
body = body.slice(0, MAX_OUT) + "\n…[truncated]";
|
|
108
|
+
const head = matches >= MAX_MATCHES ? `(showing first ${MAX_MATCHES} matches)\n` : "";
|
|
109
|
+
return head + body;
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
registerTool({
|
|
113
|
+
name: "glob",
|
|
114
|
+
description: "List files whose path matches a glob pattern (supports **, *, ?). Scopes to `path` (default cwd).",
|
|
115
|
+
input_schema: {
|
|
116
|
+
type: "object",
|
|
117
|
+
properties: {
|
|
118
|
+
pattern: { type: "string", description: "e.g. **/*.ts, src/**/index.*" },
|
|
119
|
+
path: { type: "string", description: "base directory (default: cwd)" },
|
|
120
|
+
},
|
|
121
|
+
required: ["pattern"],
|
|
122
|
+
},
|
|
123
|
+
kind: "read",
|
|
124
|
+
async run(input, ctx) {
|
|
125
|
+
const root = absOf(input.path, ctx.cwd);
|
|
126
|
+
const re = globToRegExp(input.pattern);
|
|
127
|
+
const hits = walkFiles(root).filter((f) => re.test(f));
|
|
128
|
+
if (!hits.length)
|
|
129
|
+
return `No files match ${input.pattern}.`;
|
|
130
|
+
const shown = hits.slice(0, 400);
|
|
131
|
+
const head = hits.length > shown.length ? `(${hits.length} matches, showing 400)\n` : "";
|
|
132
|
+
return head + shown.join("\n");
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
registerTool({
|
|
136
|
+
name: "ls",
|
|
137
|
+
description: "List the entries of one directory (name, type, size). Non-recursive; use glob/grep to search deeper.",
|
|
138
|
+
input_schema: {
|
|
139
|
+
type: "object",
|
|
140
|
+
properties: { path: { type: "string", description: "directory (default: cwd)" } },
|
|
141
|
+
},
|
|
142
|
+
kind: "read",
|
|
143
|
+
async run(input, ctx) {
|
|
144
|
+
const dir = absOf(input.path, ctx.cwd);
|
|
145
|
+
let entries;
|
|
146
|
+
try {
|
|
147
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
return `Error: cannot list ${input.path ?? "."}: ${e.message}`;
|
|
151
|
+
}
|
|
152
|
+
const rows = entries
|
|
153
|
+
.filter((e) => !(e.isDirectory() && e.name === ".git"))
|
|
154
|
+
.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
|
|
155
|
+
.map((e) => {
|
|
156
|
+
if (e.isDirectory())
|
|
157
|
+
return ` ${e.name}/`;
|
|
158
|
+
let size = 0;
|
|
159
|
+
try {
|
|
160
|
+
size = statSync(join(dir, e.name)).size;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
/* ignore */
|
|
164
|
+
}
|
|
165
|
+
return ` ${e.name} ${c_size(size)}`;
|
|
166
|
+
});
|
|
167
|
+
return rows.length ? rows.join("\n") : "(empty directory)";
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
function c_size(n) {
|
|
171
|
+
if (n < 1024)
|
|
172
|
+
return `${n}B`;
|
|
173
|
+
if (n < 1024 * 1024)
|
|
174
|
+
return `${(n / 1024).toFixed(1)}K`;
|
|
175
|
+
return `${(n / 1024 / 1024).toFixed(1)}M`;
|
|
176
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The `skill` tool — load a skill's full instructions on demand. The system prompt lists available
|
|
2
|
+
// skills (id + description); the model calls this to pull the body before doing a task the skill covers.
|
|
3
|
+
// Returning the body as a tool RESULT (not editing the system prompt) keeps the cached prefix stable.
|
|
4
|
+
import { registerTool } from "./registry.js";
|
|
5
|
+
import { loadSkillIndex, loadSkillBody } from "../skills/skills.js";
|
|
6
|
+
import { scanMemory } from "../memory/guard.js";
|
|
7
|
+
registerTool({
|
|
8
|
+
name: "skill",
|
|
9
|
+
description: "Load the full instructions for a skill by id. The system prompt's Skills list shows what's available; " +
|
|
10
|
+
"call this to get a skill's steps before performing a task it covers, then follow them.",
|
|
11
|
+
input_schema: { type: "object", properties: { id: { type: "string", description: "the skill id from the Skills list" } }, required: ["id"] },
|
|
12
|
+
kind: "read",
|
|
13
|
+
async run(input, ctx) {
|
|
14
|
+
const id = String(input.id ?? "").trim();
|
|
15
|
+
const sk = loadSkillIndex(ctx.cwd).find((s) => s.id === id);
|
|
16
|
+
if (!sk)
|
|
17
|
+
return `No skill '${id}'. See the Skills list in the system prompt for available ids.`;
|
|
18
|
+
const body = loadSkillBody(sk);
|
|
19
|
+
if (!body)
|
|
20
|
+
return `Skill '${id}' has no instructions.`;
|
|
21
|
+
const scan = scanMemory(body); // skills may come from plugins (untrusted) — guard at load time
|
|
22
|
+
if (!scan.ok)
|
|
23
|
+
return `Skill '${id}' blocked: its content looks unsafe (${scan.hits.join(", ")}).`;
|
|
24
|
+
if (sk.context === "fork" && ctx.spawn) {
|
|
25
|
+
// fork: run the skill as a delegated sub-agent rather than inlining it into this turn
|
|
26
|
+
return await ctx.spawn(`Follow this skill to complete the current task:\n\n${body}`);
|
|
27
|
+
}
|
|
28
|
+
return body; // inline (default): the body enters the conversation as this tool's result
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// web_fetch — fetch an http(s) URL and return readable text (HTML reduced to text). Read-only.
|
|
2
|
+
// Uses Node's global fetch (Node >=20). NOT sandboxed (network egress is in-process, not via bash).
|
|
3
|
+
import { registerTool } from "./registry.js";
|
|
4
|
+
const MAX = 60_000;
|
|
5
|
+
/** Strip HTML to a readable-ish plain-text approximation (no dependency). */
|
|
6
|
+
export function htmlToText(html) {
|
|
7
|
+
return html
|
|
8
|
+
.replace(/<head[\s\S]*?<\/head>/gi, " ")
|
|
9
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
10
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
11
|
+
.replace(/<!--[\s\S]*?-->/g, " ")
|
|
12
|
+
.replace(/<li[^>]*>/gi, "\n- ")
|
|
13
|
+
.replace(/<\/(p|div|h[1-6]|li|tr|section|article|header|footer|ul|ol|blockquote)>/gi, "\n")
|
|
14
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
15
|
+
.replace(/<[^>]+>/g, " ")
|
|
16
|
+
.replace(/ /g, " ")
|
|
17
|
+
.replace(/&/g, "&")
|
|
18
|
+
.replace(/</g, "<")
|
|
19
|
+
.replace(/>/g, ">")
|
|
20
|
+
.replace(/"/g, '"')
|
|
21
|
+
.replace(/'/g, "'")
|
|
22
|
+
.replace(/[ \t]+/g, " ")
|
|
23
|
+
.replace(/ *\n */g, "\n")
|
|
24
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
25
|
+
.trim();
|
|
26
|
+
}
|
|
27
|
+
registerTool({
|
|
28
|
+
name: "web_fetch",
|
|
29
|
+
description: "Fetch an http(s) URL and return its text content (HTML is reduced to readable text). Read-only. " +
|
|
30
|
+
"Use for docs, references, or pages the user mentions. Not sandboxed.",
|
|
31
|
+
input_schema: {
|
|
32
|
+
type: "object",
|
|
33
|
+
properties: {
|
|
34
|
+
url: { type: "string", description: "http:// or https:// URL" },
|
|
35
|
+
max_chars: { type: "number", description: "cap on returned text (default 60000)" },
|
|
36
|
+
},
|
|
37
|
+
required: ["url"],
|
|
38
|
+
},
|
|
39
|
+
kind: "read",
|
|
40
|
+
async run(input) {
|
|
41
|
+
let url;
|
|
42
|
+
try {
|
|
43
|
+
url = new URL(input.url);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return `Error: invalid URL: ${input.url}`;
|
|
47
|
+
}
|
|
48
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
49
|
+
return "Error: only http/https URLs are supported.";
|
|
50
|
+
const cap = Math.min(Math.max(1000, input.max_chars ?? MAX), 200_000);
|
|
51
|
+
const ctrl = new AbortController();
|
|
52
|
+
const timer = setTimeout(() => ctrl.abort(), 30_000);
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch(url, {
|
|
55
|
+
signal: ctrl.signal,
|
|
56
|
+
redirect: "follow",
|
|
57
|
+
headers: { "user-agent": "hara-cli", accept: "text/html,text/plain,application/json,*/*" },
|
|
58
|
+
});
|
|
59
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
60
|
+
const raw = await res.text();
|
|
61
|
+
let text = /html/i.test(ct) ? htmlToText(raw) : raw;
|
|
62
|
+
if (text.length > cap)
|
|
63
|
+
text = text.slice(0, cap) + `\n…[truncated ${text.length - cap} chars]`;
|
|
64
|
+
return `# ${url.href} (HTTP ${res.status})\n\n${text || "(empty body)"}`;
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
return `Error fetching ${url.href}: ${e?.name === "AbortError" ? "timed out (30s)" : (e?.message ?? e)}`;
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
});
|