@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
package/dist/config.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, dirname, resolve } from "node:path";
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
4
|
+
const PROVIDER_DEFAULTS = {
|
|
5
|
+
anthropic: { model: "claude-opus-4-8", envKey: "ANTHROPIC_API_KEY" },
|
|
6
|
+
qwen: {
|
|
7
|
+
model: "qwen-plus",
|
|
8
|
+
baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
9
|
+
envKey: "DASHSCOPE_API_KEY",
|
|
10
|
+
},
|
|
11
|
+
"qwen-oauth": { model: "coder-model", envKey: "QWEN_OAUTH_TOKEN" },
|
|
12
|
+
openai: { model: "gpt-4o-mini", envKey: "OPENAI_API_KEY" },
|
|
13
|
+
};
|
|
14
|
+
export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey"];
|
|
15
|
+
export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
|
|
16
|
+
export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
|
|
17
|
+
const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
|
|
18
|
+
export function configPath() {
|
|
19
|
+
return join(homedir(), ".hara", "config.json");
|
|
20
|
+
}
|
|
21
|
+
export function readRawConfig() {
|
|
22
|
+
const p = configPath();
|
|
23
|
+
if (!existsSync(p))
|
|
24
|
+
return {};
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Nearest project override `.hara/config.json`, searching cwd up to the repo root. */
|
|
33
|
+
function readProjectConfig(cwd) {
|
|
34
|
+
let dir = resolve(cwd);
|
|
35
|
+
for (;;) {
|
|
36
|
+
const p = join(dir, ".hara", "config.json");
|
|
37
|
+
if (existsSync(p)) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (PROJECT_ROOT_MARKERS.some((m) => existsSync(join(dir, m))))
|
|
46
|
+
break; // stop at repo root
|
|
47
|
+
const parent = dirname(dir);
|
|
48
|
+
if (parent === dir)
|
|
49
|
+
break;
|
|
50
|
+
dir = parent;
|
|
51
|
+
}
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
export function writeConfigValue(key, value) {
|
|
55
|
+
const p = configPath();
|
|
56
|
+
const cfg = readRawConfig();
|
|
57
|
+
cfg[key] = value;
|
|
58
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
59
|
+
writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
60
|
+
}
|
|
61
|
+
/** Record (or clear, with cap=null) a confirmed per-model vision capability in `modelVision`. */
|
|
62
|
+
export function setModelVisionOverride(model, cap) {
|
|
63
|
+
const p = configPath();
|
|
64
|
+
const cfg = readRawConfig();
|
|
65
|
+
const map = cfg.modelVision && typeof cfg.modelVision === "object" ? cfg.modelVision : {};
|
|
66
|
+
if (cap === null)
|
|
67
|
+
delete map[model];
|
|
68
|
+
else
|
|
69
|
+
map[model] = cap;
|
|
70
|
+
cfg.modelVision = map;
|
|
71
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
72
|
+
writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Effective config. Precedence (high→low): env vars > selected profile >
|
|
76
|
+
* project `.hara/config.json` > global `~/.hara/config.json` > provider defaults.
|
|
77
|
+
*/
|
|
78
|
+
export function loadConfig(opts = {}) {
|
|
79
|
+
const global = readRawConfig();
|
|
80
|
+
const { profiles, ...globalBase } = global;
|
|
81
|
+
const project = readProjectConfig(process.cwd());
|
|
82
|
+
const profileName = process.env.HARA_PROFILE ?? opts.profile;
|
|
83
|
+
const profile = profileName && profiles && profiles[profileName] ? profiles[profileName] : {};
|
|
84
|
+
const merged = { ...globalBase, ...project, ...profile };
|
|
85
|
+
const provider = (process.env.HARA_PROVIDER ?? merged.provider ?? "anthropic");
|
|
86
|
+
const d = PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic;
|
|
87
|
+
const model = process.env.HARA_MODEL ?? merged.model ?? d.model;
|
|
88
|
+
const baseURL = process.env.HARA_BASE_URL ?? merged.baseURL ?? d.baseURL;
|
|
89
|
+
const apiKey = process.env.HARA_API_KEY ?? process.env[d.envKey] ?? merged.apiKey;
|
|
90
|
+
const approval = (process.env.HARA_APPROVAL ?? merged.approval ?? "suggest");
|
|
91
|
+
const sandbox = (process.env.HARA_SANDBOX ?? merged.sandbox ?? "off");
|
|
92
|
+
const theme = (process.env.HARA_THEME ?? merged.theme ?? "dark");
|
|
93
|
+
const evolve = (process.env.HARA_EVOLVE ?? merged.evolve ?? "proactive");
|
|
94
|
+
const assetCapture = (process.env.HARA_ASSET_CAPTURE ?? merged.assetCapture ?? "ask");
|
|
95
|
+
const computerUse = (process.env.HARA_COMPUTER_USE ?? merged.computerUse ?? "off");
|
|
96
|
+
const computerApps = String(process.env.HARA_COMPUTER_APPS ?? merged.computerApps ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
97
|
+
const visionModel = process.env.HARA_VISION_MODEL ?? merged.visionModel;
|
|
98
|
+
const visionBaseURL = process.env.HARA_VISION_BASE_URL ?? merged.visionBaseURL;
|
|
99
|
+
const visionApiKey = process.env.HARA_VISION_API_KEY ?? merged.visionApiKey;
|
|
100
|
+
const modelVision = merged.modelVision && typeof merged.modelVision === "object" ? merged.modelVision : {};
|
|
101
|
+
const embedProvider = (process.env.HARA_EMBED_PROVIDER ?? merged.embedProvider ?? "off");
|
|
102
|
+
const embedModel = process.env.HARA_EMBED_MODEL ?? merged.embedModel;
|
|
103
|
+
const embedBaseURL = process.env.HARA_EMBED_BASE_URL ?? merged.embedBaseURL;
|
|
104
|
+
const embedApiKey = process.env.HARA_EMBED_API_KEY ?? merged.embedApiKey;
|
|
105
|
+
const mcpServers = {
|
|
106
|
+
...(globalBase.mcpServers ?? {}),
|
|
107
|
+
...(project.mcpServers ?? {}),
|
|
108
|
+
...(profile.mcpServers ?? {}),
|
|
109
|
+
};
|
|
110
|
+
return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, mcpServers, cwd: process.cwd() };
|
|
111
|
+
}
|
|
112
|
+
export function providerEnvKey(provider) {
|
|
113
|
+
return (PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic).envKey;
|
|
114
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Project-context loading (AGENTS.md) — the cross-tool standard read by Codex/Claude Code/OpenClaw.
|
|
2
|
+
// Walks up from cwd to the project root, concatenates AGENTS.md files, caps total size.
|
|
3
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
4
|
+
import { join, dirname, resolve } from "node:path";
|
|
5
|
+
const FILENAMES = ["AGENTS.override.md", "AGENTS.md"];
|
|
6
|
+
const ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
|
|
7
|
+
const MAX_BYTES = 32 * 1024;
|
|
8
|
+
export function findProjectRoot(cwd) {
|
|
9
|
+
let dir = resolve(cwd);
|
|
10
|
+
for (;;) {
|
|
11
|
+
if (ROOT_MARKERS.some((m) => existsSync(join(dir, m))))
|
|
12
|
+
return dir;
|
|
13
|
+
const parent = dirname(dir);
|
|
14
|
+
if (parent === dir)
|
|
15
|
+
return resolve(cwd); // no marker found → treat cwd as root
|
|
16
|
+
dir = parent;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Concatenate AGENTS.md files from project root down to cwd (root first), capped at 32 KiB. */
|
|
20
|
+
export function loadAgentsMd(cwd) {
|
|
21
|
+
const root = findProjectRoot(cwd);
|
|
22
|
+
const chain = [];
|
|
23
|
+
let dir = resolve(cwd);
|
|
24
|
+
for (;;) {
|
|
25
|
+
chain.unshift(dir);
|
|
26
|
+
if (dir === root)
|
|
27
|
+
break;
|
|
28
|
+
const parent = dirname(dir);
|
|
29
|
+
if (parent === dir)
|
|
30
|
+
break;
|
|
31
|
+
dir = parent;
|
|
32
|
+
}
|
|
33
|
+
const parts = [];
|
|
34
|
+
for (const d of chain) {
|
|
35
|
+
for (const name of FILENAMES) {
|
|
36
|
+
const p = join(d, name);
|
|
37
|
+
if (existsSync(p)) {
|
|
38
|
+
try {
|
|
39
|
+
const txt = readFileSync(p, "utf8").trim();
|
|
40
|
+
if (txt)
|
|
41
|
+
parts.push(`<!-- ${name} @ ${d} -->\n${txt}`);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
/* ignore unreadable */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
let combined = parts.join("\n\n--- project-doc ---\n\n");
|
|
50
|
+
if (Buffer.byteLength(combined, "utf8") > MAX_BYTES) {
|
|
51
|
+
combined = Buffer.from(combined, "utf8").subarray(0, MAX_BYTES).toString("utf8") + "\n…[truncated]";
|
|
52
|
+
}
|
|
53
|
+
return combined;
|
|
54
|
+
}
|
|
55
|
+
export function hasAgentsMd(cwd) {
|
|
56
|
+
const root = findProjectRoot(cwd);
|
|
57
|
+
return FILENAMES.some((n) => existsSync(join(root, n)));
|
|
58
|
+
}
|
|
59
|
+
/** Prompt hara runs against itself to analyze the repo and write AGENTS.md. */
|
|
60
|
+
export const INIT_PROMPT = "Explore this repository to understand it, then write a concise AGENTS.md at the project root.\n" +
|
|
61
|
+
"Use read_file, bash (e.g. `ls`, `git ls-files`, `cat`), and write_file.\n" +
|
|
62
|
+
"AGENTS.md should cover: what the project is (1-2 sentences), the directory structure, key commands " +
|
|
63
|
+
"(build / test / run / lint), and important conventions. Keep it under ~150 lines.\n" +
|
|
64
|
+
"Create it with write_file at path 'AGENTS.md', then reply with a one-line confirmation.";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// @file mentions — expand `@path` references in user input into appended file contents,
|
|
2
|
+
// and provide fuzzy file candidates for REPL tab-completion.
|
|
3
|
+
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, resolve } from "node:path";
|
|
5
|
+
import { listProjectFiles, dirPrefixes, walkFiles } from "../fs-walk.js";
|
|
6
|
+
import { fuzzyRank } from "../fuzzy.js";
|
|
7
|
+
import { mediaTypeFor } from "../images.js";
|
|
8
|
+
const MAX_FILE = 50_000;
|
|
9
|
+
// @ at start-of-string or after whitespace; capture a path with no spaces/@ (avoids emails like a@b.com)
|
|
10
|
+
const MENTION_RE = /(?:^|\s)@([^\s@]+)/g;
|
|
11
|
+
/** Append the contents of any @mentioned files to the input as fenced blocks. */
|
|
12
|
+
export function expandMentions(input, cwd) {
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
const blocks = [];
|
|
15
|
+
let m;
|
|
16
|
+
MENTION_RE.lastIndex = 0;
|
|
17
|
+
while ((m = MENTION_RE.exec(input)) !== null) {
|
|
18
|
+
const ref = m[1];
|
|
19
|
+
if (seen.has(ref))
|
|
20
|
+
continue;
|
|
21
|
+
seen.add(ref);
|
|
22
|
+
const abs = isAbsolute(ref) ? ref : resolve(cwd, ref);
|
|
23
|
+
try {
|
|
24
|
+
if (existsSync(abs)) {
|
|
25
|
+
const st = statSync(abs);
|
|
26
|
+
if (st.isFile()) {
|
|
27
|
+
if (mediaTypeFor(abs)) {
|
|
28
|
+
// don't inline binary image bytes as text — paste it with Ctrl+V (or drag the file in) to attach visually
|
|
29
|
+
blocks.push(`Referenced \`${ref}\` is an image — paste it with Ctrl+V to attach it visually.`);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
let txt = readFileSync(abs, "utf8");
|
|
33
|
+
if (txt.length > MAX_FILE)
|
|
34
|
+
txt = txt.slice(0, MAX_FILE) + "\n…[truncated]";
|
|
35
|
+
blocks.push(`Referenced file \`${ref}\`:\n\`\`\`\n${txt}\n\`\`\``);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else if (st.isDirectory()) {
|
|
39
|
+
// `@dir` loads a listing of the directory's files (the agent can then read specific ones)
|
|
40
|
+
const files = walkFiles(abs, 300);
|
|
41
|
+
blocks.push(`Referenced directory \`${ref}\` (${files.length} files):\n\`\`\`\n${files.join("\n") || "(empty)"}\n\`\`\``);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
/* ignore unreadable mention */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return blocks.length ? `${input}\n\n${blocks.join("\n\n")}` : input;
|
|
50
|
+
}
|
|
51
|
+
// Short-lived per-cwd cache so Tab completion stays snappy without re-scanning every press.
|
|
52
|
+
const cache = new Map();
|
|
53
|
+
const CACHE_MS = 5000;
|
|
54
|
+
function projectEntries(cwd) {
|
|
55
|
+
const hit = cache.get(cwd);
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
if (hit && now - hit.at < CACHE_MS)
|
|
58
|
+
return hit.entries;
|
|
59
|
+
const files = listProjectFiles(cwd);
|
|
60
|
+
// files + their directory prefixes (so `@src/` drills into the subtree)
|
|
61
|
+
const entries = [...dirPrefixes(files), ...files];
|
|
62
|
+
cache.set(cwd, { at: now, entries });
|
|
63
|
+
return entries;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* File/dir candidates whose path matches `query`, for @ autocomplete.
|
|
67
|
+
* Recurses subdirectories (git-tracked + untracked, or a filesystem walk outside git),
|
|
68
|
+
* ranks path-prefix and basename matches first. Directories carry a trailing `/`.
|
|
69
|
+
*/
|
|
70
|
+
export function fileCandidates(cwd, query, limit = 25) {
|
|
71
|
+
const entries = projectEntries(cwd);
|
|
72
|
+
if (!query) {
|
|
73
|
+
// bare `@`: top-level entries, directories first
|
|
74
|
+
const top = entries.filter((e) => !e.replace(/\/$/, "").includes("/"));
|
|
75
|
+
top.sort((a, b) => (b.endsWith("/") ? 1 : 0) - (a.endsWith("/") ? 1 : 0) || a.localeCompare(b));
|
|
76
|
+
return top.slice(0, limit);
|
|
77
|
+
}
|
|
78
|
+
// drilling: `@src/` → the immediate children of src/ (directories first), like a file picker
|
|
79
|
+
if (query.endsWith("/")) {
|
|
80
|
+
const kids = entries.filter((e) => e.startsWith(query) && e !== query && !e.slice(query.length).replace(/\/$/, "").includes("/"));
|
|
81
|
+
if (kids.length) {
|
|
82
|
+
kids.sort((a, b) => (b.endsWith("/") ? 1 : 0) - (a.endsWith("/") ? 1 : 0) || a.localeCompare(b));
|
|
83
|
+
return kids.slice(0, limit);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// fuzzy subsequence ranking — `@scr` finds `src/`, `@idx` finds `src/index.ts`
|
|
87
|
+
return fuzzyRank(query, entries, (e) => e)
|
|
88
|
+
.slice(0, limit)
|
|
89
|
+
.map((r) => r.item);
|
|
90
|
+
}
|
package/dist/diff.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Minimal zero-dependency line diff (LCS) + colored unified-style renderer.
|
|
2
|
+
// Shown to the user after an edit so a coder sees exactly what changed.
|
|
3
|
+
import { stdout } from "node:process";
|
|
4
|
+
import { c } from "./ui.js";
|
|
5
|
+
/** Longest-common-subsequence line diff. O(n*m) — guarded by a size cap in renderDiff. */
|
|
6
|
+
function lcsDiff(a, b) {
|
|
7
|
+
const n = a.length;
|
|
8
|
+
const m = b.length;
|
|
9
|
+
const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
|
|
10
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
11
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
12
|
+
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const out = [];
|
|
16
|
+
let i = 0;
|
|
17
|
+
let j = 0;
|
|
18
|
+
while (i < n && j < m) {
|
|
19
|
+
if (a[i] === b[j]) {
|
|
20
|
+
out.push({ t: " ", s: a[i] });
|
|
21
|
+
i++;
|
|
22
|
+
j++;
|
|
23
|
+
}
|
|
24
|
+
else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
25
|
+
out.push({ t: "-", s: a[i] });
|
|
26
|
+
i++;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
out.push({ t: "+", s: b[j] });
|
|
30
|
+
j++;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
while (i < n)
|
|
34
|
+
out.push({ t: "-", s: a[i++] });
|
|
35
|
+
while (j < m)
|
|
36
|
+
out.push({ t: "+", s: b[j++] });
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
const CONTEXT = 2;
|
|
40
|
+
const MAX_LINES = 80;
|
|
41
|
+
const SIZE_CAP = 4000;
|
|
42
|
+
/** Colored unified-style diff for display, with context collapsing + a hard cap. "" if unchanged. */
|
|
43
|
+
export function renderDiff(path, oldText, newText) {
|
|
44
|
+
if (oldText === newText)
|
|
45
|
+
return "";
|
|
46
|
+
const a = oldText.length ? oldText.split("\n") : [];
|
|
47
|
+
const b = newText.length ? newText.split("\n") : [];
|
|
48
|
+
const adds = () => ops.filter((o) => o.t === "+").length;
|
|
49
|
+
const dels = () => ops.filter((o) => o.t === "-").length;
|
|
50
|
+
if (a.length > SIZE_CAP || b.length > SIZE_CAP) {
|
|
51
|
+
return c.dim(`◇ ${path} (${a.length}→${b.length} lines; too large to diff)\n`);
|
|
52
|
+
}
|
|
53
|
+
const ops = lcsDiff(a, b);
|
|
54
|
+
// mark which lines to show: every change ± CONTEXT lines of surrounding context
|
|
55
|
+
const show = new Array(ops.length).fill(false);
|
|
56
|
+
ops.forEach((o, k) => {
|
|
57
|
+
if (o.t !== " ")
|
|
58
|
+
for (let d = -CONTEXT; d <= CONTEXT; d++)
|
|
59
|
+
if (ops[k + d])
|
|
60
|
+
show[k + d] = true;
|
|
61
|
+
});
|
|
62
|
+
const lines = [c.dim(`◇ ${path} ${c.green("+" + adds())} ${c.red("-" + dels())}`)];
|
|
63
|
+
let skipping = false;
|
|
64
|
+
let shown = 0;
|
|
65
|
+
for (let k = 0; k < ops.length; k++) {
|
|
66
|
+
if (!show[k]) {
|
|
67
|
+
if (!skipping) {
|
|
68
|
+
lines.push(c.dim(" ⋯"));
|
|
69
|
+
skipping = true;
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
skipping = false;
|
|
74
|
+
if (shown >= MAX_LINES) {
|
|
75
|
+
lines.push(c.dim(" …(diff truncated)"));
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
const o = ops[k];
|
|
79
|
+
if (o.t === "+")
|
|
80
|
+
lines.push(c.green(` + ${o.s}`));
|
|
81
|
+
else if (o.t === "-")
|
|
82
|
+
lines.push(c.red(` - ${o.s}`));
|
|
83
|
+
else
|
|
84
|
+
lines.push(c.dim(` ${o.s}`));
|
|
85
|
+
shown++;
|
|
86
|
+
}
|
|
87
|
+
return lines.join("\n") + "\n";
|
|
88
|
+
}
|
|
89
|
+
/** Print a diff to the user — interactive terminal only, so pipes/tests stay clean. */
|
|
90
|
+
export function showDiff(path, oldText, newText) {
|
|
91
|
+
if (!stdout.isTTY)
|
|
92
|
+
return;
|
|
93
|
+
const d = renderDiff(path, oldText, newText);
|
|
94
|
+
if (d)
|
|
95
|
+
stdout.write(d);
|
|
96
|
+
}
|
|
97
|
+
/** Route a diff to the UI sink (TUI) when present, else print it to the terminal. */
|
|
98
|
+
export function emitDiff(path, oldText, newText, sink) {
|
|
99
|
+
if (sink)
|
|
100
|
+
sink.diff(renderDiff(path, oldText, newText));
|
|
101
|
+
else
|
|
102
|
+
showDiff(path, oldText, newText);
|
|
103
|
+
}
|
package/dist/fs-walk.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Shared filesystem walker — powers @file completion and the grep/glob tools.
|
|
2
|
+
// Walks a directory tree, skipping noise dirs, capped so huge trees stay responsive.
|
|
3
|
+
import { readdirSync, statSync } from "node:fs";
|
|
4
|
+
import { join, relative, sep } from "node:path";
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
import { fuzzyRank } from "./fuzzy.js";
|
|
7
|
+
export const IGNORE_DIRS = new Set([
|
|
8
|
+
".git", "node_modules", "dist", "build", "out", ".next", ".nuxt", ".cache",
|
|
9
|
+
"coverage", ".venv", "venv", "__pycache__", ".mypy_cache", ".pytest_cache",
|
|
10
|
+
"target", ".idea", ".vscode", ".hara", ".turbo", ".parcel-cache", "vendor",
|
|
11
|
+
]);
|
|
12
|
+
const toPosix = (p) => (sep === "/" ? p : p.split(sep).join("/"));
|
|
13
|
+
/** Relative POSIX file paths under `root`, skipping IGNORE_DIRS, capped at `cap` files. */
|
|
14
|
+
export function walkFiles(root, cap = 8000) {
|
|
15
|
+
const files = [];
|
|
16
|
+
const stack = [root];
|
|
17
|
+
while (stack.length && files.length < cap) {
|
|
18
|
+
const dir = stack.pop();
|
|
19
|
+
let entries;
|
|
20
|
+
try {
|
|
21
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
for (const e of entries) {
|
|
27
|
+
if (files.length >= cap)
|
|
28
|
+
break;
|
|
29
|
+
if (e.name.startsWith(".") && e.name !== ".env" && e.isDirectory()) {
|
|
30
|
+
if (IGNORE_DIRS.has(e.name))
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (e.isDirectory()) {
|
|
34
|
+
if (IGNORE_DIRS.has(e.name))
|
|
35
|
+
continue;
|
|
36
|
+
stack.push(join(dir, e.name));
|
|
37
|
+
}
|
|
38
|
+
else if (e.isFile()) {
|
|
39
|
+
files.push(toPosix(relative(root, join(dir, e.name))));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return files;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Non-ignored files for `root`. In a git repo: tracked + untracked (respects .gitignore).
|
|
47
|
+
* Otherwise: a filesystem walk. POSIX-relative paths.
|
|
48
|
+
*/
|
|
49
|
+
export function listProjectFiles(root, cap = 8000) {
|
|
50
|
+
try {
|
|
51
|
+
const out = execSync("git ls-files --cached --others --exclude-standard", {
|
|
52
|
+
cwd: root,
|
|
53
|
+
encoding: "utf8",
|
|
54
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
55
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
56
|
+
})
|
|
57
|
+
.split("\n")
|
|
58
|
+
.map((s) => s.trim())
|
|
59
|
+
.filter(Boolean);
|
|
60
|
+
if (out.length)
|
|
61
|
+
return out.slice(0, cap);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
/* not a git repo — fall through to fs walk */
|
|
65
|
+
}
|
|
66
|
+
return walkFiles(root, cap);
|
|
67
|
+
}
|
|
68
|
+
/** Directory prefixes implied by a set of file paths, e.g. "a/b/c.ts" → "a/", "a/b/". */
|
|
69
|
+
export function dirPrefixes(files) {
|
|
70
|
+
const dirs = new Set();
|
|
71
|
+
for (const f of files) {
|
|
72
|
+
const parts = f.split("/");
|
|
73
|
+
let acc = "";
|
|
74
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
75
|
+
acc += parts[i] + "/";
|
|
76
|
+
dirs.add(acc);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return [...dirs];
|
|
80
|
+
}
|
|
81
|
+
export function isProbablyBinary(buf) {
|
|
82
|
+
const n = Math.min(buf.length, 4096);
|
|
83
|
+
for (let i = 0; i < n; i++)
|
|
84
|
+
if (buf[i] === 0)
|
|
85
|
+
return true;
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
export function fileSize(p) {
|
|
89
|
+
try {
|
|
90
|
+
return statSync(p).size;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Up to `n` project files most similar to a (possibly mistyped) path — for did-you-mean. */
|
|
97
|
+
export function nearestPaths(cwd, p, n = 3) {
|
|
98
|
+
if (!p)
|
|
99
|
+
return [];
|
|
100
|
+
return fuzzyRank(p, listProjectFiles(cwd), (f) => f)
|
|
101
|
+
.slice(0, n)
|
|
102
|
+
.map((r) => r.item);
|
|
103
|
+
}
|
package/dist/fuzzy.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Tiny zero-dependency fuzzy matcher (subsequence scoring, codex/nucleo-flavored).
|
|
2
|
+
// Used for @path completion, file-path did-you-mean, and slash-command suggestions.
|
|
3
|
+
/**
|
|
4
|
+
* Score how well `query` fuzzy-matches `target` (case-insensitive subsequence).
|
|
5
|
+
* Higher is better; returns null if `query` is not a subsequence of `target`.
|
|
6
|
+
* Bonuses: first char, word boundary (/ _ - . space), camelCase, consecutive runs;
|
|
7
|
+
* gap penalty for spread-out matches; mild shorter-is-better tiebreak.
|
|
8
|
+
*/
|
|
9
|
+
export function fuzzyScore(query, target) {
|
|
10
|
+
if (!query)
|
|
11
|
+
return 0;
|
|
12
|
+
const q = query.toLowerCase();
|
|
13
|
+
let qi = 0;
|
|
14
|
+
let score = 0;
|
|
15
|
+
let prev = -2;
|
|
16
|
+
let consec = 0;
|
|
17
|
+
for (let ti = 0; ti < target.length && qi < q.length; ti++) {
|
|
18
|
+
if (target[ti].toLowerCase() === q[qi]) {
|
|
19
|
+
let s = 16;
|
|
20
|
+
if (ti === prev + 1) {
|
|
21
|
+
consec++;
|
|
22
|
+
s += 8 * consec; // reward consecutive runs strongly so clean prefixes win
|
|
23
|
+
}
|
|
24
|
+
else
|
|
25
|
+
consec = 0;
|
|
26
|
+
if (ti === 0)
|
|
27
|
+
s += 10;
|
|
28
|
+
else {
|
|
29
|
+
const p = target[ti - 1];
|
|
30
|
+
if ("/_-. ".includes(p))
|
|
31
|
+
s += 8; // boundary
|
|
32
|
+
else if (p === p.toLowerCase() && target[ti] !== target[ti].toLowerCase())
|
|
33
|
+
s += 6; // camelCase
|
|
34
|
+
}
|
|
35
|
+
if (prev >= 0)
|
|
36
|
+
s -= Math.min(ti - prev - 1, 4); // gap penalty
|
|
37
|
+
score += s;
|
|
38
|
+
prev = ti;
|
|
39
|
+
qi++;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (qi < q.length)
|
|
43
|
+
return null; // not all query chars consumed → no match
|
|
44
|
+
return score - target.length * 0.1;
|
|
45
|
+
}
|
|
46
|
+
/** Rank `items` by fuzzy match against `query`; drops non-matches; best first. */
|
|
47
|
+
export function fuzzyRank(query, items, key) {
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const item of items) {
|
|
50
|
+
const sc = fuzzyScore(query, key(item));
|
|
51
|
+
if (sc !== null)
|
|
52
|
+
out.push({ item, score: sc });
|
|
53
|
+
}
|
|
54
|
+
out.sort((a, b) => b.score - a.score || key(a.item).length - key(b.item).length || key(a.item).localeCompare(key(b.item)));
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
/** Up to `n` nearest strings to `query` (for did-you-mean suggestions). */
|
|
58
|
+
export function nearest(query, candidates, n = 3) {
|
|
59
|
+
return fuzzyRank(query, candidates, (s) => s)
|
|
60
|
+
.slice(0, n)
|
|
61
|
+
.map((r) => r.item);
|
|
62
|
+
}
|
package/dist/images.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Image attachments for the prompt — paste a screenshot (Ctrl+V) or drag/paste an image file path.
|
|
2
|
+
// Zero-dependency by design: shells out to OS tools (osascript/sips on macOS, wl-paste/xclip on
|
|
3
|
+
// Linux, PowerShell on Windows) rather than pulling a native clipboard module — same posture as
|
|
4
|
+
// sandbox.ts. Paths (not bytes) ride in the conversation; encoding to base64 happens at send time.
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { extname, join, resolve } from "node:path";
|
|
9
|
+
const MEDIA = {
|
|
10
|
+
".png": "image/png",
|
|
11
|
+
".jpg": "image/jpeg",
|
|
12
|
+
".jpeg": "image/jpeg",
|
|
13
|
+
".gif": "image/gif",
|
|
14
|
+
".webp": "image/webp",
|
|
15
|
+
};
|
|
16
|
+
export function mediaTypeFor(p) {
|
|
17
|
+
return MEDIA[extname(p).toLowerCase()] ?? null;
|
|
18
|
+
}
|
|
19
|
+
// Anthropic caps an image near 5 MB of base64; downsize a larger source, then hard-skip if still over.
|
|
20
|
+
const DOWNSIZE_OVER = 3_600_000; // raw bytes (base64 ≈ 1.37×)
|
|
21
|
+
const MAX_B64 = 5_000_000;
|
|
22
|
+
/**
|
|
23
|
+
* Treat a typed/pasted string as a path to an existing image. Terminals emit a bare (often quoted or
|
|
24
|
+
* backslash-escaped) path when you drag a file in; we also accept a `file://` URL. Returns the
|
|
25
|
+
* resolved attachment, or null when it isn't an existing image file.
|
|
26
|
+
*/
|
|
27
|
+
export function imagePathFromPaste(raw, cwd = process.cwd()) {
|
|
28
|
+
let s = raw.trim();
|
|
29
|
+
if (!s || /[\r\n]/.test(s))
|
|
30
|
+
return null;
|
|
31
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))
|
|
32
|
+
s = s.slice(1, -1);
|
|
33
|
+
if (s.startsWith("file://")) {
|
|
34
|
+
try {
|
|
35
|
+
s = decodeURIComponent(s.slice("file://".length));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
s = s.replace(/\\ /g, " "); // un-escape dragged-in spaces
|
|
42
|
+
const mediaType = mediaTypeFor(s);
|
|
43
|
+
if (!mediaType)
|
|
44
|
+
return null;
|
|
45
|
+
const abs = resolve(cwd, s);
|
|
46
|
+
try {
|
|
47
|
+
if (!existsSync(abs) || !statSync(abs).isFile())
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return { path: abs, mediaType };
|
|
54
|
+
}
|
|
55
|
+
let seq = 0;
|
|
56
|
+
function tmpPng() {
|
|
57
|
+
seq += 1;
|
|
58
|
+
return join(tmpdir(), `hara-clip-${process.pid}-${Date.now()}-${seq}.png`);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Pull an image off the OS clipboard into a temp PNG (Ctrl+V screenshot-paste). Synchronous — one
|
|
62
|
+
* short-lived helper process. Returns null when the clipboard holds no image or the tooling/platform
|
|
63
|
+
* isn't available. (Copied *files* arrive as a path instead → handled by imagePathFromPaste.)
|
|
64
|
+
*/
|
|
65
|
+
export function readClipboardImage() {
|
|
66
|
+
const out = tmpPng();
|
|
67
|
+
try {
|
|
68
|
+
if (process.platform === "darwin") {
|
|
69
|
+
const script = [
|
|
70
|
+
"try",
|
|
71
|
+
" set thePng to (the clipboard as «class PNGf»)",
|
|
72
|
+
"on error",
|
|
73
|
+
' return "NONE"',
|
|
74
|
+
"end try",
|
|
75
|
+
`set theFile to open for access POSIX file ${JSON.stringify(out)} with write permission`,
|
|
76
|
+
"write thePng to theFile",
|
|
77
|
+
"close access theFile",
|
|
78
|
+
'return "OK"',
|
|
79
|
+
].join("\n");
|
|
80
|
+
const r = spawnSync("osascript", ["-e", script], { encoding: "utf8" });
|
|
81
|
+
if (r.status !== 0 || !String(r.stdout).includes("OK"))
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
else if (process.platform === "linux") {
|
|
85
|
+
if (!dumpStdout("wl-paste", ["--type", "image/png"], out) &&
|
|
86
|
+
!dumpStdout("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"], out))
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
else if (process.platform === "win32") {
|
|
90
|
+
const ps = `Add-Type -AssemblyName System.Windows.Forms; $i=[System.Windows.Forms.Clipboard]::GetImage(); if($i){$i.Save(${JSON.stringify(out)},[System.Drawing.Imaging.ImageFormat]::Png)}else{exit 1}`;
|
|
91
|
+
if (spawnSync("powershell", ["-NoProfile", "-Command", ps], { encoding: "utf8" }).status !== 0)
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
if (!existsSync(out) || statSync(out).size === 0)
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
downsizeIfHuge(out);
|
|
109
|
+
return { path: out, mediaType: "image/png" };
|
|
110
|
+
}
|
|
111
|
+
function dumpStdout(cmd, args, outPath) {
|
|
112
|
+
try {
|
|
113
|
+
const r = spawnSync(cmd, args, { maxBuffer: 64 * 1024 * 1024 });
|
|
114
|
+
if (r.status !== 0 || !r.stdout || r.stdout.length === 0)
|
|
115
|
+
return false;
|
|
116
|
+
writeFileSync(outPath, r.stdout);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Shrink an oversized image in place (macOS `sips`, always present) to stay under the API cap. */
|
|
124
|
+
function downsizeIfHuge(path) {
|
|
125
|
+
try {
|
|
126
|
+
if (statSync(path).size <= DOWNSIZE_OVER)
|
|
127
|
+
return;
|
|
128
|
+
if (process.platform === "darwin")
|
|
129
|
+
spawnSync("sips", ["--resampleHeightWidthMax", "1568", path], { encoding: "utf8" });
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
/* best effort — send as-is and let the provider decide */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Read an image file → base64 for an API request. Null when missing or still too large to send. */
|
|
136
|
+
export function imageToBase64(path) {
|
|
137
|
+
try {
|
|
138
|
+
if (!existsSync(path))
|
|
139
|
+
return null;
|
|
140
|
+
const b64 = readFileSync(path).toString("base64");
|
|
141
|
+
return b64.length > MAX_B64 ? null : b64;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|