@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,38 @@
|
|
|
1
|
+
// Hybrid search — lexical (always) blended with semantic (when an index + embedder are configured).
|
|
2
|
+
// One entry point for `recall` and `memory_search`: semantic hits lead (more relevant), lexical fills the
|
|
3
|
+
// rest, deduped by path. With no index/embedder it's exactly the lexical result — zero behaviour change.
|
|
4
|
+
import { searchAssets, titleOf } from "../recall.js";
|
|
5
|
+
import { loadConfig } from "../config.js";
|
|
6
|
+
import { getEmbedder } from "./embed.js";
|
|
7
|
+
import { queryIndex, indexExists } from "./semindex.js";
|
|
8
|
+
export async function searchHybrid(query, cwd, opts) {
|
|
9
|
+
const limit = opts.limit ?? 5;
|
|
10
|
+
const lex = searchAssets(query, limit, opts.roots);
|
|
11
|
+
const embed = getEmbedder(loadConfig());
|
|
12
|
+
if (!embed || !indexExists(opts.indexName, cwd))
|
|
13
|
+
return lex;
|
|
14
|
+
let sem;
|
|
15
|
+
try {
|
|
16
|
+
sem = await queryIndex(opts.indexName, query, embed, cwd, limit);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return lex; // embedding endpoint down → degrade to lexical
|
|
20
|
+
}
|
|
21
|
+
const out = [];
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
for (const s of sem) {
|
|
24
|
+
if (s.score < 0.2 || seen.has(s.file))
|
|
25
|
+
continue;
|
|
26
|
+
seen.add(s.file);
|
|
27
|
+
out.push({ path: s.file, title: titleOf(s.text, s.file), snippet: s.text.slice(0, 800), score: s.score });
|
|
28
|
+
}
|
|
29
|
+
for (const h of lex) {
|
|
30
|
+
if (out.length >= limit)
|
|
31
|
+
break;
|
|
32
|
+
if (seen.has(h.path))
|
|
33
|
+
continue;
|
|
34
|
+
seen.add(h.path);
|
|
35
|
+
out.push(h);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Semantic index — a zero-dependency, JSON-backed vector store with brute-force cosine. Fine for the
|
|
2
|
+
// code-asset / repo / knowledge-base scale (hundreds–low-thousands of chunks); the optional zvec adapter is
|
|
3
|
+
// the scale-up path later. Markdown/code stays the SSOT; this index is a derived, rebuildable, gitignored
|
|
4
|
+
// artifact. The embedder is injected (see embed.ts) so the store + chunking are testable without a model.
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join, dirname } from "node:path";
|
|
8
|
+
import { findProjectRoot } from "../context/agents-md.js";
|
|
9
|
+
import { listProjectFiles, walkFiles, isProbablyBinary, fileSize } from "../fs-walk.js";
|
|
10
|
+
// Same code/text extensions codebase_search ranks lexically — keep the two walks in sync.
|
|
11
|
+
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;
|
|
12
|
+
/** Index location — repo index lives in the project (gitignore it); the rest are global. Derived/rebuildable. */
|
|
13
|
+
export function indexPath(name, cwd) {
|
|
14
|
+
if (name === "repo")
|
|
15
|
+
return join(findProjectRoot(cwd), ".hara", "index", "repo.json");
|
|
16
|
+
return join(homedir(), ".hara", "index", `${name}.json`);
|
|
17
|
+
}
|
|
18
|
+
/** Split a file into chunks: Markdown by `#` headings, code by ~40-line windows. Heuristic, zero-dep —
|
|
19
|
+
* also the substrate embeddings reuse. */
|
|
20
|
+
export function chunkText(text, file, source) {
|
|
21
|
+
const out = [];
|
|
22
|
+
const push = (body, n) => {
|
|
23
|
+
const t = body.trim();
|
|
24
|
+
if (t.length >= 12)
|
|
25
|
+
out.push({ id: `${file}#${n}`, text: t.slice(0, 2000), file, source });
|
|
26
|
+
};
|
|
27
|
+
if (/\.(md|mdx)$/i.test(file)) {
|
|
28
|
+
const parts = text.split(/^(?=#{1,6}\s)/m);
|
|
29
|
+
parts.forEach((p, i) => push(p, i));
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
const lines = text.split("\n");
|
|
33
|
+
const W = 40;
|
|
34
|
+
const STEP = 30; // overlap so a function spanning a boundary still lands in one chunk
|
|
35
|
+
for (let i = 0, n = 0; i < lines.length; i += STEP, n++)
|
|
36
|
+
push(lines.slice(i, i + W).join("\n"), n);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
function cosine(a, b) {
|
|
41
|
+
let dot = 0;
|
|
42
|
+
let na = 0;
|
|
43
|
+
let nb = 0;
|
|
44
|
+
const n = Math.min(a.length, b.length);
|
|
45
|
+
for (let i = 0; i < n; i++) {
|
|
46
|
+
dot += a[i] * b[i];
|
|
47
|
+
na += a[i] * a[i];
|
|
48
|
+
nb += b[i] * b[i];
|
|
49
|
+
}
|
|
50
|
+
return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
|
|
51
|
+
}
|
|
52
|
+
/** Embed all chunks and write the index file. Returns the count written. */
|
|
53
|
+
export async function buildIndex(name, chunks, embed, cwd, model = "embed") {
|
|
54
|
+
const items = [];
|
|
55
|
+
const B = 64;
|
|
56
|
+
for (let i = 0; i < chunks.length; i += B) {
|
|
57
|
+
const batch = chunks.slice(i, i + B);
|
|
58
|
+
const vecs = await embed(batch.map((c) => c.text));
|
|
59
|
+
batch.forEach((c, j) => vecs[j] && items.push({ ...c, vec: vecs[j] }));
|
|
60
|
+
}
|
|
61
|
+
const p = indexPath(name, cwd);
|
|
62
|
+
const dir = dirname(p);
|
|
63
|
+
mkdirSync(dir, { recursive: true });
|
|
64
|
+
// The index is derived + rebuildable (and may embed file contents) — never let it be committed.
|
|
65
|
+
if (!existsSync(join(dir, ".gitignore")))
|
|
66
|
+
writeFileSync(join(dir, ".gitignore"), "*\n", "utf8");
|
|
67
|
+
writeFileSync(p, JSON.stringify({ model, items }), "utf8");
|
|
68
|
+
return items.length;
|
|
69
|
+
}
|
|
70
|
+
export function indexExists(name, cwd) {
|
|
71
|
+
return existsSync(indexPath(name, cwd));
|
|
72
|
+
}
|
|
73
|
+
/** Walk one knowledge directory (code-assets / skills / memory) and chunk its files. Files come back as
|
|
74
|
+
* absolute paths so recall/memory_search can read or open them directly. */
|
|
75
|
+
export function collectDirChunks(dir, source) {
|
|
76
|
+
if (!existsSync(dir))
|
|
77
|
+
return [];
|
|
78
|
+
const chunks = [];
|
|
79
|
+
for (const rel of walkFiles(dir)) {
|
|
80
|
+
if (!CODE_RE.test(rel))
|
|
81
|
+
continue;
|
|
82
|
+
const abs = join(dir, rel);
|
|
83
|
+
if (fileSize(abs) > 200_000)
|
|
84
|
+
continue;
|
|
85
|
+
let buf;
|
|
86
|
+
try {
|
|
87
|
+
buf = readFileSync(abs);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (isProbablyBinary(buf))
|
|
93
|
+
continue;
|
|
94
|
+
chunks.push(...chunkText(buf.toString("utf8"), abs, source));
|
|
95
|
+
}
|
|
96
|
+
return chunks;
|
|
97
|
+
}
|
|
98
|
+
/** Walk the repo (respecting .gitignore) and chunk every code/text file — the corpus `hara index` embeds. */
|
|
99
|
+
export function collectRepoChunks(root) {
|
|
100
|
+
const chunks = [];
|
|
101
|
+
for (const rel of listProjectFiles(root)) {
|
|
102
|
+
if (!CODE_RE.test(rel))
|
|
103
|
+
continue;
|
|
104
|
+
const abs = join(root, rel);
|
|
105
|
+
if (fileSize(abs) > 200_000)
|
|
106
|
+
continue;
|
|
107
|
+
let buf;
|
|
108
|
+
try {
|
|
109
|
+
buf = readFileSync(abs);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (isProbablyBinary(buf))
|
|
115
|
+
continue;
|
|
116
|
+
chunks.push(...chunkText(buf.toString("utf8"), rel, "repo"));
|
|
117
|
+
}
|
|
118
|
+
return chunks;
|
|
119
|
+
}
|
|
120
|
+
/** Cosine-rank the index against the query embedding. Returns top-k hits (empty if no index). */
|
|
121
|
+
export async function queryIndex(name, query, embed, cwd, k = 6) {
|
|
122
|
+
const p = indexPath(name, cwd);
|
|
123
|
+
if (!existsSync(p))
|
|
124
|
+
return [];
|
|
125
|
+
let idx;
|
|
126
|
+
try {
|
|
127
|
+
idx = JSON.parse(readFileSync(p, "utf8"));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
if (!idx.items?.length)
|
|
133
|
+
return [];
|
|
134
|
+
const [qv] = await embed([query]);
|
|
135
|
+
if (!qv)
|
|
136
|
+
return [];
|
|
137
|
+
return idx.items
|
|
138
|
+
.map((it) => ({ file: it.file, source: it.source, text: it.text, score: cosine(qv, it.vec) }))
|
|
139
|
+
.sort((a, b) => b.score - a.score)
|
|
140
|
+
.slice(0, k);
|
|
141
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Session persistence — conversations saved as JSON under ~/.hara/sessions, resumable.
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
function sessionsDir() {
|
|
7
|
+
const d = join(homedir(), ".hara", "sessions");
|
|
8
|
+
mkdirSync(d, { recursive: true });
|
|
9
|
+
return d;
|
|
10
|
+
}
|
|
11
|
+
const sessionFile = (id) => join(sessionsDir(), `${id}.json`);
|
|
12
|
+
/** A full UUID per session (the stable identity). */
|
|
13
|
+
export const newSessionId = () => randomUUID();
|
|
14
|
+
/** First segment of the UUID — a compact label for the status bar / `/sessions`. */
|
|
15
|
+
export const shortId = (id) => id.slice(0, 8);
|
|
16
|
+
/** Resolve a full id OR a unique prefix (e.g. the short id) to a session id, for `--resume`. */
|
|
17
|
+
export function resolveSessionId(idOrPrefix) {
|
|
18
|
+
if (existsSync(sessionFile(idOrPrefix)))
|
|
19
|
+
return idOrPrefix;
|
|
20
|
+
const hit = listSessions().find((m) => m.id.startsWith(idOrPrefix));
|
|
21
|
+
return hit ? hit.id : null;
|
|
22
|
+
}
|
|
23
|
+
const STOP = new Set("the a an to of for and or with in on at my our your this that it is please can could you help me we add fix make do run create update change implement".split(" "));
|
|
24
|
+
const WORDS = "amber basalt cedar delta ember flint grove harbor indigo jade kelp larch maple onyx quartz river slate terra umber vale willow zephyr".split(" ");
|
|
25
|
+
/** A short, ASCII, few-word session name from the first message — no CJK or garbled chars. For
|
|
26
|
+
* all-CJK / empty input, a stable word derived from the text. Keeps the status bar tidy. */
|
|
27
|
+
export function cleanSessionName(raw) {
|
|
28
|
+
const words = raw
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.replace(/[^a-z0-9\s-]+/g, " ")
|
|
31
|
+
.split(/\s+/)
|
|
32
|
+
.filter((w) => w.length > 1 && !STOP.has(w));
|
|
33
|
+
const slug = words.slice(0, 3).join("-").slice(0, 24).replace(/^-+|-+$/g, "");
|
|
34
|
+
if (slug)
|
|
35
|
+
return slug;
|
|
36
|
+
let h = 0;
|
|
37
|
+
for (const ch of raw)
|
|
38
|
+
h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
|
39
|
+
return WORDS[h % WORDS.length] ?? "session";
|
|
40
|
+
}
|
|
41
|
+
/** A concise, human session name auto-summarized from the first message. Language-agnostic — keeps CJK
|
|
42
|
+
* (unlike the ASCII-slug `cleanSessionName`), trims code/whitespace, caps length. Empty for blank input
|
|
43
|
+
* (callers fall back to the short id, never "new session"). */
|
|
44
|
+
export function deriveTitle(text) {
|
|
45
|
+
const t = text
|
|
46
|
+
.replace(/^\/\S+\s*/, "") // drop a leading slash-command
|
|
47
|
+
.replace(/```[\s\S]*?```/g, " ") // drop fenced code blocks
|
|
48
|
+
.replace(/\s+/g, " ")
|
|
49
|
+
.trim();
|
|
50
|
+
if (!t)
|
|
51
|
+
return "";
|
|
52
|
+
const max = 40;
|
|
53
|
+
return t.length <= max ? t : t.slice(0, max).replace(/\s+\S*$/, "").trim() + "…";
|
|
54
|
+
}
|
|
55
|
+
export function titleFrom(history) {
|
|
56
|
+
const firstUser = history.find((h) => h.role === "user");
|
|
57
|
+
return deriveTitle(firstUser && firstUser.role === "user" ? firstUser.content : "");
|
|
58
|
+
}
|
|
59
|
+
export function saveSession(meta, history) {
|
|
60
|
+
meta.updatedAt = new Date().toISOString();
|
|
61
|
+
const data = { meta, history };
|
|
62
|
+
writeFileSync(sessionFile(meta.id), JSON.stringify(data, null, 2), "utf8");
|
|
63
|
+
}
|
|
64
|
+
export function loadSession(id) {
|
|
65
|
+
const p = sessionFile(id);
|
|
66
|
+
if (!existsSync(p))
|
|
67
|
+
return null;
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Session metas, newest first; optionally filtered to a cwd. */
|
|
76
|
+
export function listSessions(cwd) {
|
|
77
|
+
let metas = [];
|
|
78
|
+
for (const f of readdirSync(sessionsDir())) {
|
|
79
|
+
if (!f.endsWith(".json"))
|
|
80
|
+
continue;
|
|
81
|
+
try {
|
|
82
|
+
metas.push(JSON.parse(readFileSync(join(sessionsDir(), f), "utf8")).meta);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
/* skip corrupt */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (cwd)
|
|
89
|
+
metas = metas.filter((m) => m.cwd === cwd);
|
|
90
|
+
return metas.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
91
|
+
}
|
|
92
|
+
export function latestForCwd(cwd) {
|
|
93
|
+
const [m] = listSessions(cwd);
|
|
94
|
+
return m ? loadSession(m.id) : null;
|
|
95
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Skills — agentskills.io-standard capabilities at <project>/.hara/skills/<name>/SKILL.md (+ global
|
|
2
|
+
// ~/.hara/skills). Frontmatter: name, description (required) + when_to_use / allowed-tools /
|
|
3
|
+
// context inline|fork / model / paths / user-invocable / disable-model-invocation. The body is the
|
|
4
|
+
// instructions, loaded ON DEMAND (progressive disclosure) — only the frontmatter index sits in context.
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { findProjectRoot } from "../context/agents-md.js";
|
|
9
|
+
import { scanMemory } from "../memory/guard.js";
|
|
10
|
+
import { pluginSkillDirs } from "../plugins/plugins.js";
|
|
11
|
+
export function skillsDir(cwd) {
|
|
12
|
+
return join(findProjectRoot(cwd), ".hara", "skills");
|
|
13
|
+
}
|
|
14
|
+
export function globalSkillsDir() {
|
|
15
|
+
return join(homedir(), ".hara", "skills");
|
|
16
|
+
}
|
|
17
|
+
/** Search roots, lowest→highest precedence (later wins on id clash): plugins < global < project. */
|
|
18
|
+
export function skillsDirs(cwd) {
|
|
19
|
+
return [...pluginSkillDirs(), globalSkillsDir(), skillsDir(cwd)];
|
|
20
|
+
}
|
|
21
|
+
function listVal(v) {
|
|
22
|
+
if (Array.isArray(v))
|
|
23
|
+
return v;
|
|
24
|
+
if (typeof v === "string" && v.trim())
|
|
25
|
+
return v.split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const isFalse = (v) => v === "false" || v === false;
|
|
29
|
+
const isTrue = (v) => v === "true" || v === true;
|
|
30
|
+
/** Parse YAML-ish frontmatter (keys may contain hyphens, unlike roles.ts). Only the head is needed for the index. */
|
|
31
|
+
function parseFrontmatter(text) {
|
|
32
|
+
const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text);
|
|
33
|
+
if (!m)
|
|
34
|
+
return { fm: {}, body: text.trim() };
|
|
35
|
+
const fm = {};
|
|
36
|
+
for (const raw of m[1].split("\n")) {
|
|
37
|
+
const kv = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(raw.trim());
|
|
38
|
+
if (!kv)
|
|
39
|
+
continue;
|
|
40
|
+
const val = kv[2].trim();
|
|
41
|
+
if (val.startsWith("[") && val.endsWith("]")) {
|
|
42
|
+
fm[kv[1]] = val.slice(1, -1).split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
fm[kv[1]] = val.replace(/^["']|["']$/g, "");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { fm, body: m[2].trim() };
|
|
49
|
+
}
|
|
50
|
+
/** All skills' frontmatter (the index) — cheap; the body is loaded separately by loadSkillBody. */
|
|
51
|
+
export function loadSkillIndex(cwd) {
|
|
52
|
+
const byId = new Map();
|
|
53
|
+
const gdir = globalSkillsDir();
|
|
54
|
+
const pdir = skillsDir(cwd);
|
|
55
|
+
for (const dir of skillsDirs(cwd)) {
|
|
56
|
+
if (!existsSync(dir))
|
|
57
|
+
continue;
|
|
58
|
+
for (const entry of readdirSync(dir)) {
|
|
59
|
+
const file = join(dir, entry, "SKILL.md"); // agentskills layout: <name>/SKILL.md
|
|
60
|
+
if (!existsSync(file))
|
|
61
|
+
continue;
|
|
62
|
+
try {
|
|
63
|
+
const { fm } = parseFrontmatter(readFileSync(file, "utf8"));
|
|
64
|
+
const id = fm.name || entry;
|
|
65
|
+
byId.set(id, {
|
|
66
|
+
id,
|
|
67
|
+
description: fm.description || "",
|
|
68
|
+
whenToUse: fm.when_to_use || undefined,
|
|
69
|
+
allowedTools: listVal(fm["allowed-tools"]),
|
|
70
|
+
context: fm.context === "fork" ? "fork" : "inline",
|
|
71
|
+
model: fm.model || undefined,
|
|
72
|
+
paths: listVal(fm.paths),
|
|
73
|
+
userInvocable: !isFalse(fm["user-invocable"]),
|
|
74
|
+
modelInvocable: !isTrue(fm["disable-model-invocation"]),
|
|
75
|
+
file,
|
|
76
|
+
source: dir === gdir ? "global" : dir === pdir ? "project" : "plugin",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* skip bad skill */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return [...byId.values()];
|
|
85
|
+
}
|
|
86
|
+
/** Read a skill's instruction body (progressive disclosure — only when the model/user opens it). */
|
|
87
|
+
export function loadSkillBody(skill) {
|
|
88
|
+
try {
|
|
89
|
+
return parseFrontmatter(readFileSync(skill.file, "utf8")).body;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return "";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const DIGEST_CAP = 4000;
|
|
96
|
+
let _digestCache = new Map();
|
|
97
|
+
/** Compact, frozen-per-session index injected into the system prompt (name + description, one line each).
|
|
98
|
+
* Drops model-hidden skills and any whose description fails the guard (plugin skills may be untrusted). */
|
|
99
|
+
export function skillsDigest(cwd) {
|
|
100
|
+
if (_digestCache.has(cwd))
|
|
101
|
+
return _digestCache.get(cwd);
|
|
102
|
+
const lines = [];
|
|
103
|
+
for (const s of loadSkillIndex(cwd)) {
|
|
104
|
+
if (!s.modelInvocable || !s.description || !scanMemory(s.description).ok)
|
|
105
|
+
continue;
|
|
106
|
+
lines.push(`- ${s.id}: ${s.description}${s.whenToUse ? ` — ${s.whenToUse}` : ""}`);
|
|
107
|
+
}
|
|
108
|
+
let digest = lines.join("\n");
|
|
109
|
+
if (digest.length > DIGEST_CAP)
|
|
110
|
+
digest = digest.slice(0, DIGEST_CAP) + "\n…";
|
|
111
|
+
_digestCache.set(cwd, digest);
|
|
112
|
+
return digest;
|
|
113
|
+
}
|
|
114
|
+
/** Drop the cached digest (call after skill_create so a new skill surfaces next turn). */
|
|
115
|
+
export function invalidateSkillsCache() {
|
|
116
|
+
_digestCache.clear();
|
|
117
|
+
}
|
|
118
|
+
const SCAFFOLD = `---
|
|
119
|
+
name: verify-change
|
|
120
|
+
description: Verify a code change does what it should by building and running the tests.
|
|
121
|
+
when_to_use: after editing code, before declaring a task done
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
# Verify a change
|
|
125
|
+
|
|
126
|
+
1. Identify how this project builds and tests (check AGENTS.md / package.json scripts).
|
|
127
|
+
2. Run the build (e.g. \`tsc\` / \`npm run build\`) and report any errors.
|
|
128
|
+
3. Run the relevant tests; if none exist for the change, note that.
|
|
129
|
+
4. Summarize: what you ran, pass/fail, and anything still unverified.
|
|
130
|
+
`;
|
|
131
|
+
/** Create ~/.hara/skills/verify-change/SKILL.md as a starter example. Returns the paths written. */
|
|
132
|
+
export function scaffoldSkills(cwd) {
|
|
133
|
+
const dir = join(skillsDir(cwd), "verify-change");
|
|
134
|
+
mkdirSync(dir, { recursive: true });
|
|
135
|
+
const p = join(dir, "SKILL.md");
|
|
136
|
+
if (existsSync(p))
|
|
137
|
+
return [];
|
|
138
|
+
writeFileSync(p, SCAFFOLD, "utf8");
|
|
139
|
+
invalidateSkillsCache();
|
|
140
|
+
return [p];
|
|
141
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// REPL input frame — a border line above and below the prompt, session name in the top-right,
|
|
2
|
+
// mode/tokens/concurrency in the bottom border. Drawn around the readline prompt (plain printed
|
|
3
|
+
// lines — no scroll region, works everywhere). `borderTop`/`borderBottom` are pure, for tests.
|
|
4
|
+
import { stdout } from "node:process";
|
|
5
|
+
import { c } from "./ui.js";
|
|
6
|
+
import { activity } from "./activity.js";
|
|
7
|
+
export const MODES = ["suggest", "auto-edit", "full-auto"];
|
|
8
|
+
let state = { sessionName: "new session", model: "", approval: "suggest", input: 0, output: 0, ctxPct: 0 };
|
|
9
|
+
let active = false;
|
|
10
|
+
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
11
|
+
const vlen = (s) => stripAnsi(s).length;
|
|
12
|
+
const fmtTok = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
|
|
13
|
+
const truncate = (s, max) => (s.length <= max ? s : s.slice(0, Math.max(0, max - 1)) + "…");
|
|
14
|
+
const rule = (n) => c.dim("─".repeat(Math.max(0, n)));
|
|
15
|
+
export function contextWindow(model) {
|
|
16
|
+
const m = model.toLowerCase();
|
|
17
|
+
if (/haiku/.test(m))
|
|
18
|
+
return 200_000;
|
|
19
|
+
if (/(opus|sonnet|fable)|claude-4|qwen3|glm-[45]|max-2026|coder|1m/.test(m))
|
|
20
|
+
return 1_000_000;
|
|
21
|
+
return 200_000;
|
|
22
|
+
}
|
|
23
|
+
export const ctxPctFor = (model, lastInput) => lastInput > 0 ? Math.min(99, Math.round((lastInput / contextWindow(model)) * 100)) : 0;
|
|
24
|
+
/** Top border with the session name in the right corner: `────────── ⏺ session ─` */
|
|
25
|
+
export function borderTop(s, cols) {
|
|
26
|
+
const name = truncate(s.sessionName || "new session", Math.max(8, cols - 14));
|
|
27
|
+
const label = `${c.cyan("⏺")} ${c.bold(name)}`;
|
|
28
|
+
return rule(cols - vlen(label) - 3) + " " + label + " " + rule(1);
|
|
29
|
+
}
|
|
30
|
+
/** Bottom border carrying mode · tokens · concurrency: `── ◆suggest auto-edit full-auto · ↑0 ↓0 · ⛁ ──` */
|
|
31
|
+
export function borderBottom(s, cols, agents) {
|
|
32
|
+
const sel = MODES.map((m) => (m === s.approval ? c.green(`◆${m}`) : c.dim(m))).join(" ");
|
|
33
|
+
const ctx = s.ctxPct > 0 ? ` · ctx ${s.ctxPct}%` : "";
|
|
34
|
+
const ag = agents > 0 ? c.yellow(`⛁${agents}`) : c.dim("⛁");
|
|
35
|
+
const info = `${sel} ${c.dim("·")} ${c.dim(`↑${fmtTok(s.input)} ↓${fmtTok(s.output)}${ctx}`)} ${c.dim("·")} ${ag}`;
|
|
36
|
+
return rule(2) + " " + info + " " + rule(cols - vlen(info) - 4);
|
|
37
|
+
}
|
|
38
|
+
export function install(initial) {
|
|
39
|
+
state = { ...state, ...initial };
|
|
40
|
+
active = process.env.HARA_FOOTER !== "0" && !!stdout.isTTY;
|
|
41
|
+
}
|
|
42
|
+
export function update(partial) {
|
|
43
|
+
state = { ...state, ...partial };
|
|
44
|
+
}
|
|
45
|
+
const w = (s) => {
|
|
46
|
+
try {
|
|
47
|
+
stdout.write(s);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* ignore */
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
/** Top border — call right before the prompt. */
|
|
54
|
+
export function renderTop() {
|
|
55
|
+
if (active)
|
|
56
|
+
w(borderTop(state, stdout.columns ?? 80) + "\n");
|
|
57
|
+
}
|
|
58
|
+
/** Bottom border — call right after the prompt is submitted. */
|
|
59
|
+
export function renderBottom() {
|
|
60
|
+
if (active)
|
|
61
|
+
w(borderBottom(state, stdout.columns ?? 80, activity.running) + "\n");
|
|
62
|
+
}
|
|
63
|
+
export const isActive = () => active;
|
|
64
|
+
export function uninstall() {
|
|
65
|
+
/* no-op (border model — nothing pinned) */
|
|
66
|
+
}
|
|
67
|
+
export function nextMode(m) {
|
|
68
|
+
return MODES[(MODES.indexOf(m) + 1) % MODES.length];
|
|
69
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// agent — delegate a self-contained sub-task to a fresh sub-agent. Several `agent` calls in one
|
|
2
|
+
// turn run in PARALLEL (kind "read" → concurrent), making the footer's ⛁ count real. Sub-agents are
|
|
3
|
+
// read-only by default (safe to parallelize); the actual spawn is provided via ctx.spawn.
|
|
4
|
+
import { registerTool } from "./registry.js";
|
|
5
|
+
registerTool({
|
|
6
|
+
name: "agent",
|
|
7
|
+
description: "Delegate an independent sub-task to a fresh sub-agent and get its result. Spawn SEVERAL in one " +
|
|
8
|
+
"turn to run them in parallel (e.g. analyze/search/review N things at once). Sub-agents are " +
|
|
9
|
+
"read-only by default; pass a `role` id to use that role's persona + tools. Not for edits.",
|
|
10
|
+
input_schema: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: {
|
|
13
|
+
task: { type: "string", description: "the self-contained sub-task to delegate" },
|
|
14
|
+
role: { type: "string", description: "optional role id (uses its persona + tool subset)" },
|
|
15
|
+
},
|
|
16
|
+
required: ["task"],
|
|
17
|
+
},
|
|
18
|
+
kind: "read", // parallel-safe: multiple agent() calls in a turn run concurrently
|
|
19
|
+
async run(input, ctx) {
|
|
20
|
+
if (!ctx.spawn)
|
|
21
|
+
return "Error: sub-agents are not available in this context.";
|
|
22
|
+
if (typeof input.task !== "string" || !input.task.trim())
|
|
23
|
+
return "Error: agent needs a `task`.";
|
|
24
|
+
return await ctx.spawn(input.task, input.role ? String(input.role) : undefined);
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Shared edit-application core — applies old→new string edits to file text with a
|
|
2
|
+
// quote-insensitive fallback. Used by edit_file (single file) and apply_patch (multi-file).
|
|
3
|
+
// Quote variants — models often emit curly quotes where the file has straight ones (or vice versa).
|
|
4
|
+
const SINGLE = "'‘’‚‛";
|
|
5
|
+
const DOUBLE = '"“”„‟';
|
|
6
|
+
const reEscape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7
|
+
/** RegExp source for `s` where any quote char matches any of its typographic variants. */
|
|
8
|
+
function quoteFlexSource(s) {
|
|
9
|
+
let out = "";
|
|
10
|
+
for (const ch of s) {
|
|
11
|
+
if (SINGLE.includes(ch))
|
|
12
|
+
out += `[${SINGLE}]`;
|
|
13
|
+
else if (DOUBLE.includes(ch))
|
|
14
|
+
out += `[${DOUBLE}]`;
|
|
15
|
+
else
|
|
16
|
+
out += reEscape(ch);
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
/** Apply one old→new replacement to `src`. Returns an error reason on not-found/ambiguous. */
|
|
21
|
+
function applyOne(src, oldStr, newStr, replaceAll) {
|
|
22
|
+
// 1) exact
|
|
23
|
+
let count = src.split(oldStr).length - 1;
|
|
24
|
+
if (count > 0) {
|
|
25
|
+
if (count > 1 && !replaceAll)
|
|
26
|
+
return { error: `appears ${count}×; add context or set replace_all` };
|
|
27
|
+
const text = replaceAll ? src.split(oldStr).join(newStr) : src.replace(oldStr, () => newStr);
|
|
28
|
+
return { text, count, fuzzy: false };
|
|
29
|
+
}
|
|
30
|
+
// 2) quote-flexible fallback
|
|
31
|
+
const re = new RegExp(quoteFlexSource(oldStr), "g");
|
|
32
|
+
const matches = src.match(re);
|
|
33
|
+
count = matches ? matches.length : 0;
|
|
34
|
+
if (count === 0)
|
|
35
|
+
return { error: "not found" };
|
|
36
|
+
if (count > 1 && !replaceAll)
|
|
37
|
+
return { error: `appears ${count}× (quote-insensitive); add context or set replace_all` };
|
|
38
|
+
const text = src.replace(re, () => newStr);
|
|
39
|
+
return { text, count, fuzzy: true };
|
|
40
|
+
}
|
|
41
|
+
/** Apply a sequence of edits to `text`. All-or-nothing: returns an error (no partial result). */
|
|
42
|
+
export function applyEdits(text, edits) {
|
|
43
|
+
if (!edits.length)
|
|
44
|
+
return { error: "no edits provided" };
|
|
45
|
+
for (const e of edits) {
|
|
46
|
+
if (typeof e.old_string !== "string" || typeof e.new_string !== "string")
|
|
47
|
+
return { error: "each edit needs string old_string and new_string" };
|
|
48
|
+
if (e.old_string === e.new_string)
|
|
49
|
+
return { error: "an edit has identical old_string and new_string" };
|
|
50
|
+
}
|
|
51
|
+
let out = text;
|
|
52
|
+
let total = 0;
|
|
53
|
+
let fuzzy = false;
|
|
54
|
+
for (let i = 0; i < edits.length; i++) {
|
|
55
|
+
const r = applyOne(out, edits[i].old_string, edits[i].new_string, !!edits[i].replace_all);
|
|
56
|
+
if ("error" in r)
|
|
57
|
+
return { error: `edit ${i + 1}/${edits.length} — old_string ${r.error}` };
|
|
58
|
+
out = r.text;
|
|
59
|
+
total += r.count;
|
|
60
|
+
fuzzy = fuzzy || r.fuzzy;
|
|
61
|
+
}
|
|
62
|
+
return { text: out, total, fuzzy };
|
|
63
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve, isAbsolute } from "node:path";
|
|
3
|
+
import { stdout as procOut } from "node:process";
|
|
4
|
+
import { registerTool } from "./registry.js";
|
|
5
|
+
import { runShell } from "../sandbox.js";
|
|
6
|
+
import { nearestPaths } from "../fs-walk.js";
|
|
7
|
+
import { emitDiff } from "../diff.js";
|
|
8
|
+
import { recordEdit } from "../undo.js";
|
|
9
|
+
const MAX = 100_000;
|
|
10
|
+
function abs(p, cwd) {
|
|
11
|
+
return isAbsolute(p) ? p : resolve(cwd, p);
|
|
12
|
+
}
|
|
13
|
+
function cap(s) {
|
|
14
|
+
return s.length > MAX ? s.slice(0, MAX) + `\n…[truncated ${s.length - MAX} chars]` : s;
|
|
15
|
+
}
|
|
16
|
+
registerTool({
|
|
17
|
+
name: "read_file",
|
|
18
|
+
description: "Read a UTF-8 text file and return its contents.",
|
|
19
|
+
input_schema: {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
path: { type: "string", description: "File path, relative to cwd or absolute" },
|
|
23
|
+
},
|
|
24
|
+
required: ["path"],
|
|
25
|
+
},
|
|
26
|
+
kind: "read",
|
|
27
|
+
async run(input, ctx) {
|
|
28
|
+
try {
|
|
29
|
+
return cap(await readFile(abs(input.path, ctx.cwd), "utf8"));
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
const near = nearestPaths(ctx.cwd, input.path);
|
|
33
|
+
return `Error: cannot read ${input.path}: ${e.code ?? e.message}.` + (near.length ? ` Did you mean: ${near.join(", ")}?` : "");
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
registerTool({
|
|
38
|
+
name: "write_file",
|
|
39
|
+
description: "Create or overwrite a UTF-8 text file (creates parent directories).",
|
|
40
|
+
input_schema: {
|
|
41
|
+
type: "object",
|
|
42
|
+
properties: {
|
|
43
|
+
path: { type: "string" },
|
|
44
|
+
content: { type: "string" },
|
|
45
|
+
},
|
|
46
|
+
required: ["path", "content"],
|
|
47
|
+
},
|
|
48
|
+
kind: "edit",
|
|
49
|
+
async run(input, ctx) {
|
|
50
|
+
const p = abs(input.path, ctx.cwd);
|
|
51
|
+
let prev = null;
|
|
52
|
+
try {
|
|
53
|
+
prev = await readFile(p, "utf8");
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* new file */
|
|
57
|
+
}
|
|
58
|
+
await mkdir(dirname(p), { recursive: true });
|
|
59
|
+
await writeFile(p, input.content, "utf8");
|
|
60
|
+
emitDiff(input.path, prev ?? "", input.content, ctx.ui);
|
|
61
|
+
recordEdit([{ path: input.path, absPath: p, before: prev }]);
|
|
62
|
+
return `Wrote ${String(input.content).length} chars to ${p}`;
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
registerTool({
|
|
66
|
+
name: "bash",
|
|
67
|
+
description: "Run a shell command in the working directory; returns combined stdout/stderr.",
|
|
68
|
+
input_schema: {
|
|
69
|
+
type: "object",
|
|
70
|
+
properties: {
|
|
71
|
+
command: { type: "string" },
|
|
72
|
+
timeout_ms: { type: "number", description: "default 120000" },
|
|
73
|
+
},
|
|
74
|
+
required: ["command"],
|
|
75
|
+
},
|
|
76
|
+
kind: "exec",
|
|
77
|
+
async run(input, ctx) {
|
|
78
|
+
let buf = ""; // TUI: line-buffer live output into the sink (one notice per line)
|
|
79
|
+
const live = ctx.ui
|
|
80
|
+
? (s) => {
|
|
81
|
+
buf += s;
|
|
82
|
+
let i;
|
|
83
|
+
while ((i = buf.indexOf("\n")) >= 0) {
|
|
84
|
+
ctx.ui.notice(buf.slice(0, i));
|
|
85
|
+
buf = buf.slice(i + 1);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
: procOut.isTTY
|
|
89
|
+
? (s) => procOut.write(s) // stream output in a plain terminal
|
|
90
|
+
: undefined;
|
|
91
|
+
try {
|
|
92
|
+
const { stdout, stderr } = await runShell(input.command, ctx.cwd, ctx.sandbox ?? "off", {
|
|
93
|
+
timeout: input.timeout_ms ?? 120_000,
|
|
94
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
95
|
+
onData: live,
|
|
96
|
+
});
|
|
97
|
+
if (ctx.ui && buf)
|
|
98
|
+
ctx.ui.notice(buf); // flush trailing partial line
|
|
99
|
+
const combined = (stdout || "") + (stderr ? `\n[stderr]\n${stderr}` : "");
|
|
100
|
+
return cap(combined.trim() || "(no output)");
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
return cap(`Command failed: ${e.message}\n${e.stdout || ""}${e.stderr || ""}`);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
});
|