@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,124 @@
|
|
|
1
|
+
// Plugins — a distribution unit that drops skills / roles / MCP servers onto disk; it owns nothing at
|
|
2
|
+
// runtime. The existing loaders pick the contents up (skillsDirs/loadRoles append the resolvers below;
|
|
3
|
+
// index.ts merges pluginMcpServers into the MCP set). Manifest is Claude-Code-compatible: we read
|
|
4
|
+
// .claude-plugin/plugin.json, .hara-plugin/plugin.json, or a bare plugin.json at the plugin root.
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync, cpSync } from "node:fs";
|
|
6
|
+
import { join, resolve, isAbsolute } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { readRawConfig } from "../config.js";
|
|
10
|
+
export function pluginsDir() {
|
|
11
|
+
return join(homedir(), ".hara", "plugins");
|
|
12
|
+
}
|
|
13
|
+
const MANIFEST_PATHS = [".claude-plugin/plugin.json", ".hara-plugin/plugin.json", "plugin.json"];
|
|
14
|
+
function readManifest(root) {
|
|
15
|
+
for (const rel of MANIFEST_PATHS) {
|
|
16
|
+
const p = join(root, rel);
|
|
17
|
+
if (!existsSync(p))
|
|
18
|
+
continue;
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
/** Every installed plugin under ~/.hara/plugins (regardless of enabled state). */
|
|
29
|
+
export function listInstalled() {
|
|
30
|
+
const dir = pluginsDir();
|
|
31
|
+
if (!existsSync(dir))
|
|
32
|
+
return [];
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const entry of readdirSync(dir)) {
|
|
35
|
+
const root = join(dir, entry);
|
|
36
|
+
const manifest = readManifest(root);
|
|
37
|
+
if (!manifest)
|
|
38
|
+
continue;
|
|
39
|
+
out.push({ name: manifest.name || entry, version: manifest.version || "0.0.0", root, manifest });
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** A plugin is active unless explicitly disabled in config (`plugins.enabled[name] === false`). */
|
|
44
|
+
export function enabledPlugins() {
|
|
45
|
+
const enabled = (readRawConfig().plugins?.enabled ?? {});
|
|
46
|
+
return listInstalled().filter((p) => enabled[p.name] !== false);
|
|
47
|
+
}
|
|
48
|
+
function resolveDirs(p, entries) {
|
|
49
|
+
return (entries ?? [])
|
|
50
|
+
.map((e) => (isAbsolute(e) ? e : resolve(p.root, e)))
|
|
51
|
+
.filter((d) => existsSync(d));
|
|
52
|
+
}
|
|
53
|
+
// --- Contribution resolvers (consumed by the existing loaders; lowest precedence) ---
|
|
54
|
+
/** Skill search dirs from enabled plugins (each holds <name>/SKILL.md subdirs). */
|
|
55
|
+
export function pluginSkillDirs() {
|
|
56
|
+
return enabledPlugins().flatMap((p) => resolveDirs(p, p.manifest.skills));
|
|
57
|
+
}
|
|
58
|
+
/** Role/subagent dirs from enabled plugins. */
|
|
59
|
+
export function pluginRoleDirs() {
|
|
60
|
+
return enabledPlugins().flatMap((p) => resolveDirs(p, p.manifest.agents));
|
|
61
|
+
}
|
|
62
|
+
/** MCP servers contributed by enabled plugins (merged under user config, which wins). */
|
|
63
|
+
export function pluginMcpServers() {
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const p of enabledPlugins())
|
|
66
|
+
Object.assign(out, p.manifest.mcpServers ?? {});
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
/** Install a plugin from `file:<path>`, `github:<owner/repo>`, or `git:<url>` into ~/.hara/plugins/<name>. */
|
|
70
|
+
export function installPlugin(source) {
|
|
71
|
+
mkdirSync(pluginsDir(), { recursive: true });
|
|
72
|
+
const tmpName = `_install-${process.pid}-${Date.now()}`;
|
|
73
|
+
const tmp = join(pluginsDir(), tmpName);
|
|
74
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
75
|
+
try {
|
|
76
|
+
if (source.startsWith("file:")) {
|
|
77
|
+
const src = resolve(source.slice("file:".length));
|
|
78
|
+
if (!existsSync(src))
|
|
79
|
+
throw new Error(`no such path: ${src}`);
|
|
80
|
+
cpSync(src, tmp, { recursive: true });
|
|
81
|
+
}
|
|
82
|
+
else if (source.startsWith("github:")) {
|
|
83
|
+
execFileSync("git", ["clone", "--depth", "1", `https://github.com/${source.slice("github:".length)}.git`, tmp], { stdio: "ignore" });
|
|
84
|
+
}
|
|
85
|
+
else if (source.startsWith("git:")) {
|
|
86
|
+
execFileSync("git", ["clone", "--depth", "1", source.slice("git:".length), tmp], { stdio: "ignore" });
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
throw new Error("source must be file:<path>, github:<owner/repo>, or git:<url>");
|
|
90
|
+
}
|
|
91
|
+
const manifest = readManifest(tmp);
|
|
92
|
+
if (!manifest || !manifest.name) {
|
|
93
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
94
|
+
throw new Error("no valid plugin.json (need at least a name) at the source root");
|
|
95
|
+
}
|
|
96
|
+
const dest = join(pluginsDir(), manifest.name);
|
|
97
|
+
rmSync(dest, { recursive: true, force: true });
|
|
98
|
+
// move tmp → dest (rename within the same dir)
|
|
99
|
+
cpSync(tmp, dest, { recursive: true });
|
|
100
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
101
|
+
return { name: manifest.name, version: manifest.version || "0.0.0", root: dest, manifest };
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
105
|
+
throw e;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export function uninstallPlugin(name) {
|
|
109
|
+
const dest = join(pluginsDir(), name);
|
|
110
|
+
if (!existsSync(dest))
|
|
111
|
+
return false;
|
|
112
|
+
rmSync(dest, { recursive: true, force: true });
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
/** Persist a plugin's enabled flag in ~/.hara/config.json (`plugins.enabled[name]`). */
|
|
116
|
+
export function setPluginEnabled(name, on) {
|
|
117
|
+
const p = join(homedir(), ".hara", "config.json");
|
|
118
|
+
const cfg = readRawConfig();
|
|
119
|
+
const plugins = (cfg.plugins && typeof cfg.plugins === "object" ? cfg.plugins : {});
|
|
120
|
+
plugins.enabled = { ...(plugins.enabled ?? {}), [name]: on };
|
|
121
|
+
cfg.plugins = plugins;
|
|
122
|
+
mkdirSync(join(homedir(), ".hara"), { recursive: true });
|
|
123
|
+
writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
124
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import { imageToBase64 } from "../images.js";
|
|
3
|
+
export function toAnthropic(history) {
|
|
4
|
+
const msgs = [];
|
|
5
|
+
for (const m of history) {
|
|
6
|
+
if (m.role === "user") {
|
|
7
|
+
if (m.images?.length) {
|
|
8
|
+
const blocks = [];
|
|
9
|
+
if (m.content)
|
|
10
|
+
blocks.push({ type: "text", text: m.content });
|
|
11
|
+
for (const img of m.images) {
|
|
12
|
+
const data = imageToBase64(img.path);
|
|
13
|
+
if (data)
|
|
14
|
+
blocks.push({ type: "image", source: { type: "base64", media_type: img.mediaType, data } });
|
|
15
|
+
}
|
|
16
|
+
msgs.push({ role: "user", content: blocks.length ? blocks : m.content });
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
msgs.push({ role: "user", content: m.content });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
else if (m.role === "assistant") {
|
|
23
|
+
const content = [];
|
|
24
|
+
if (m.text)
|
|
25
|
+
content.push({ type: "text", text: m.text });
|
|
26
|
+
for (const tu of m.toolUses)
|
|
27
|
+
content.push({ type: "tool_use", id: tu.id, name: tu.name, input: tu.input });
|
|
28
|
+
msgs.push({ role: "assistant", content: content.length ? content : [{ type: "text", text: "(no output)" }] });
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
msgs.push({
|
|
32
|
+
role: "user",
|
|
33
|
+
content: m.results.map((r) => ({
|
|
34
|
+
type: "tool_result",
|
|
35
|
+
tool_use_id: r.id,
|
|
36
|
+
content: r.content,
|
|
37
|
+
is_error: r.isError,
|
|
38
|
+
})),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return msgs;
|
|
43
|
+
}
|
|
44
|
+
export function createAnthropicProvider(opts) {
|
|
45
|
+
const client = new Anthropic({ apiKey: opts.apiKey, maxRetries: 4, ...(opts.baseURL ? { baseURL: opts.baseURL } : {}) });
|
|
46
|
+
return {
|
|
47
|
+
id: "anthropic",
|
|
48
|
+
model: opts.model,
|
|
49
|
+
async turn({ system, history, tools, onText, onReasoning, signal }) {
|
|
50
|
+
const stream = client.messages.stream({
|
|
51
|
+
model: opts.model,
|
|
52
|
+
max_tokens: 32000,
|
|
53
|
+
thinking: { type: "adaptive" },
|
|
54
|
+
system,
|
|
55
|
+
tools: tools,
|
|
56
|
+
messages: toAnthropic(history),
|
|
57
|
+
}, { signal });
|
|
58
|
+
stream.on("text", onText);
|
|
59
|
+
if (onReasoning)
|
|
60
|
+
stream.on("thinking", onReasoning); // thinking deltas (if emitted)
|
|
61
|
+
let msg;
|
|
62
|
+
try {
|
|
63
|
+
msg = await stream.finalMessage();
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
if (signal?.aborted)
|
|
67
|
+
return { text: "", toolUses: [], stop: "error", errorMsg: "interrupted" };
|
|
68
|
+
const errorMsg = e instanceof Anthropic.APIError ? `${e.status ?? ""} ${e.message}` : String(e);
|
|
69
|
+
return { text: "", toolUses: [], stop: "error", errorMsg };
|
|
70
|
+
}
|
|
71
|
+
const text = msg.content
|
|
72
|
+
.filter((b) => b.type === "text")
|
|
73
|
+
.map((b) => b.text)
|
|
74
|
+
.join("");
|
|
75
|
+
const toolUses = msg.content
|
|
76
|
+
.filter((b) => b.type === "tool_use")
|
|
77
|
+
.map((b) => ({ id: b.id, name: b.name, input: b.input }));
|
|
78
|
+
const stop = msg.stop_reason === "tool_use" ? "tool_use" : "end";
|
|
79
|
+
const usage = { input: msg.usage?.input_tokens ?? 0, output: msg.usage?.output_tokens ?? 0 };
|
|
80
|
+
return { text, toolUses, stop, usage };
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import { imageToBase64 } from "../images.js";
|
|
3
|
+
/** Build OpenAI chat-completions messages from neutral history. */
|
|
4
|
+
export function toOpenAI(system, history) {
|
|
5
|
+
const msgs = [{ role: "system", content: system }];
|
|
6
|
+
for (const m of history) {
|
|
7
|
+
if (m.role === "user") {
|
|
8
|
+
if (m.images?.length) {
|
|
9
|
+
// multimodal content parts: text + image_url data URLs (Qwen-VL / GLM-4V / OpenAI vision)
|
|
10
|
+
const parts = [];
|
|
11
|
+
if (m.content)
|
|
12
|
+
parts.push({ type: "text", text: m.content });
|
|
13
|
+
for (const img of m.images) {
|
|
14
|
+
const data = imageToBase64(img.path);
|
|
15
|
+
if (data)
|
|
16
|
+
parts.push({ type: "image_url", image_url: { url: `data:${img.mediaType};base64,${data}` } });
|
|
17
|
+
}
|
|
18
|
+
msgs.push({ role: "user", content: parts.length ? parts : m.content });
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
msgs.push({ role: "user", content: m.content });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
else if (m.role === "assistant") {
|
|
25
|
+
const tool_calls = m.toolUses.map((tu) => ({
|
|
26
|
+
id: tu.id,
|
|
27
|
+
type: "function",
|
|
28
|
+
function: { name: tu.name, arguments: JSON.stringify(tu.input ?? {}) },
|
|
29
|
+
}));
|
|
30
|
+
msgs.push({
|
|
31
|
+
role: "assistant",
|
|
32
|
+
content: m.text || null,
|
|
33
|
+
...(tool_calls.length ? { tool_calls } : {}),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
for (const r of m.results) {
|
|
38
|
+
msgs.push({
|
|
39
|
+
role: "tool",
|
|
40
|
+
tool_call_id: r.id,
|
|
41
|
+
content: r.isError ? `ERROR: ${r.content}` : r.content,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return msgs;
|
|
47
|
+
}
|
|
48
|
+
/** OpenAI-compatible provider (works with OpenAI, Qwen/DashScope, GLM, Kimi, …). */
|
|
49
|
+
export function createOpenAIProvider(opts) {
|
|
50
|
+
const client = new OpenAI({ apiKey: opts.apiKey, maxRetries: 4, ...(opts.baseURL ? { baseURL: opts.baseURL } : {}) });
|
|
51
|
+
return {
|
|
52
|
+
id: opts.label ?? "openai",
|
|
53
|
+
model: opts.model,
|
|
54
|
+
async turn({ system, history, tools, onText, onReasoning, signal }) {
|
|
55
|
+
const oaiTools = tools.map((t) => ({
|
|
56
|
+
type: "function",
|
|
57
|
+
function: { name: t.name, description: t.description, parameters: t.input_schema },
|
|
58
|
+
}));
|
|
59
|
+
const params = {
|
|
60
|
+
model: opts.model,
|
|
61
|
+
messages: toOpenAI(system, history),
|
|
62
|
+
max_tokens: 8192,
|
|
63
|
+
stream: true,
|
|
64
|
+
stream_options: { include_usage: true },
|
|
65
|
+
};
|
|
66
|
+
if (oaiTools.length)
|
|
67
|
+
params.tools = oaiTools;
|
|
68
|
+
// Stream: emit text deltas live; accumulate tool-call args by index; grab usage from the tail chunk.
|
|
69
|
+
let text = "";
|
|
70
|
+
const acc = new Map();
|
|
71
|
+
let finish;
|
|
72
|
+
let usage = { input: 0, output: 0 };
|
|
73
|
+
try {
|
|
74
|
+
const stream = await client.chat.completions.create(params, { signal });
|
|
75
|
+
for await (const chunk of stream) {
|
|
76
|
+
const choice = chunk.choices?.[0];
|
|
77
|
+
const delta = choice?.delta;
|
|
78
|
+
if (delta?.content) {
|
|
79
|
+
text += delta.content;
|
|
80
|
+
onText(delta.content);
|
|
81
|
+
}
|
|
82
|
+
const rc = delta?.reasoning_content ?? delta?.reasoning; // GLM-5 / DeepSeek
|
|
83
|
+
if (rc)
|
|
84
|
+
onReasoning?.(rc);
|
|
85
|
+
if (delta?.tool_calls) {
|
|
86
|
+
for (const tc of delta.tool_calls) {
|
|
87
|
+
const idx = tc.index ?? 0;
|
|
88
|
+
const cur = acc.get(idx) ?? { id: "", name: "", args: "" };
|
|
89
|
+
if (tc.id)
|
|
90
|
+
cur.id = tc.id;
|
|
91
|
+
if (tc.function?.name)
|
|
92
|
+
cur.name = tc.function.name;
|
|
93
|
+
if (tc.function?.arguments)
|
|
94
|
+
cur.args += tc.function.arguments;
|
|
95
|
+
acc.set(idx, cur);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (choice?.finish_reason)
|
|
99
|
+
finish = choice.finish_reason;
|
|
100
|
+
if (chunk.usage)
|
|
101
|
+
usage = { input: chunk.usage.prompt_tokens ?? 0, output: chunk.usage.completion_tokens ?? 0 };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
if (signal?.aborted)
|
|
106
|
+
return { text: "", toolUses: [], stop: "error", errorMsg: "interrupted" };
|
|
107
|
+
return { text: "", toolUses: [], stop: "error", errorMsg: `${e?.status ?? ""} ${e?.message ?? e}` };
|
|
108
|
+
}
|
|
109
|
+
const toolUses = [...acc.values()]
|
|
110
|
+
.filter((t) => t.id && t.name)
|
|
111
|
+
.map((t) => {
|
|
112
|
+
let input = {};
|
|
113
|
+
try {
|
|
114
|
+
input = JSON.parse(t.args || "{}");
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
input = {};
|
|
118
|
+
}
|
|
119
|
+
return { id: t.id, name: t.name, input };
|
|
120
|
+
});
|
|
121
|
+
const stop = finish === "tool_calls" || toolUses.length ? "tool_use" : "end";
|
|
122
|
+
return { text, toolUses, stop, usage };
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Qwen "Qwen Code" free-tier OAuth (device-code + PKCE), ported from OpenClaw's qwen-portal-auth.
|
|
2
|
+
// Token (access/refresh/resource_url) is stored in ~/.hara/qwen-oauth.json and auto-refreshed.
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
7
|
+
const BASE = "https://chat.qwen.ai";
|
|
8
|
+
const DEVICE_CODE_URL = `${BASE}/api/v1/oauth2/device/code`;
|
|
9
|
+
const TOKEN_URL = `${BASE}/api/v1/oauth2/token`;
|
|
10
|
+
const CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56";
|
|
11
|
+
const SCOPE = "openid profile email model.completion";
|
|
12
|
+
const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
13
|
+
const DEFAULT_BASE_URL = "https://portal.qwen.ai/v1";
|
|
14
|
+
function tokenPath() {
|
|
15
|
+
return join(homedir(), ".hara", "qwen-oauth.json");
|
|
16
|
+
}
|
|
17
|
+
export function loadQwenToken() {
|
|
18
|
+
const p = tokenPath();
|
|
19
|
+
if (!existsSync(p))
|
|
20
|
+
return null;
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function saveQwenToken(t) {
|
|
29
|
+
const p = tokenPath();
|
|
30
|
+
mkdirSync(join(homedir(), ".hara"), { recursive: true });
|
|
31
|
+
writeFileSync(p, JSON.stringify(t, null, 2) + "\n", "utf8");
|
|
32
|
+
}
|
|
33
|
+
const b64url = (b) => b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
34
|
+
function pkce() {
|
|
35
|
+
const verifier = b64url(randomBytes(32));
|
|
36
|
+
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
|
37
|
+
return { verifier, challenge };
|
|
38
|
+
}
|
|
39
|
+
/** resource_url → a clean https base ending in /v1 (default portal.qwen.ai). */
|
|
40
|
+
export function normalizeBaseUrl(v) {
|
|
41
|
+
const raw = (v && v.trim()) || DEFAULT_BASE_URL;
|
|
42
|
+
const withProto = raw.startsWith("http") ? raw : `https://${raw}`;
|
|
43
|
+
return withProto.endsWith("/v1") ? withProto : `${withProto.replace(/\/+$/, "")}/v1`;
|
|
44
|
+
}
|
|
45
|
+
/** Device-code login. Prints the verification URL via `log`, polls until approved. */
|
|
46
|
+
export async function qwenDeviceLogin(log) {
|
|
47
|
+
const { verifier, challenge } = pkce();
|
|
48
|
+
const dc = await fetch(DEVICE_CODE_URL, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
51
|
+
body: new URLSearchParams({
|
|
52
|
+
client_id: CLIENT_ID,
|
|
53
|
+
scope: SCOPE,
|
|
54
|
+
code_challenge: challenge,
|
|
55
|
+
code_challenge_method: "S256",
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
if (!dc.ok)
|
|
59
|
+
throw new Error(`device-code request failed: ${dc.status} ${await dc.text()}`);
|
|
60
|
+
const dev = (await dc.json());
|
|
61
|
+
if (!dev.device_code || !dev.verification_uri)
|
|
62
|
+
throw new Error("incomplete device authorization payload");
|
|
63
|
+
const url = dev.verification_uri_complete || dev.verification_uri;
|
|
64
|
+
log(`Open this URL in your browser and approve access:\n\n ${url}\n\n (if prompted, enter code: ${dev.user_code})\n\nWaiting for approval…`);
|
|
65
|
+
let wait = (dev.interval || 2) * 1000;
|
|
66
|
+
const deadline = Date.now() + (dev.expires_in || 300) * 1000;
|
|
67
|
+
while (Date.now() < deadline) {
|
|
68
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
69
|
+
const tr = await fetch(TOKEN_URL, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
72
|
+
body: new URLSearchParams({
|
|
73
|
+
grant_type: DEVICE_GRANT,
|
|
74
|
+
client_id: CLIENT_ID,
|
|
75
|
+
device_code: dev.device_code,
|
|
76
|
+
code_verifier: verifier,
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
if (tr.ok) {
|
|
80
|
+
const t = (await tr.json());
|
|
81
|
+
if (t.access_token && t.refresh_token) {
|
|
82
|
+
const tok = {
|
|
83
|
+
access: t.access_token,
|
|
84
|
+
refresh: t.refresh_token,
|
|
85
|
+
expires: Date.now() + (t.expires_in || 3600) * 1000,
|
|
86
|
+
resourceUrl: t.resource_url,
|
|
87
|
+
};
|
|
88
|
+
saveQwenToken(tok);
|
|
89
|
+
return tok;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
let err = {};
|
|
94
|
+
try {
|
|
95
|
+
err = await tr.json();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
/* ignore */
|
|
99
|
+
}
|
|
100
|
+
if (err.error === "authorization_pending")
|
|
101
|
+
continue;
|
|
102
|
+
if (err.error === "slow_down") {
|
|
103
|
+
wait = Math.min(wait * 1.5, 10000);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
throw new Error(`token poll failed: ${err.error_description || err.error || tr.status}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
throw new Error("Qwen OAuth timed out waiting for approval.");
|
|
110
|
+
}
|
|
111
|
+
async function refreshToken(tok) {
|
|
112
|
+
const r = await fetch(TOKEN_URL, {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
115
|
+
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: tok.refresh, client_id: CLIENT_ID }),
|
|
116
|
+
});
|
|
117
|
+
if (!r.ok)
|
|
118
|
+
throw new Error(`Qwen token refresh failed (${r.status}) — re-run \`hara login qwen\`.`);
|
|
119
|
+
const t = (await r.json());
|
|
120
|
+
if (!t.access_token)
|
|
121
|
+
throw new Error("refresh response missing access_token");
|
|
122
|
+
const next = {
|
|
123
|
+
access: t.access_token,
|
|
124
|
+
refresh: t.refresh_token || tok.refresh,
|
|
125
|
+
expires: Date.now() + (t.expires_in || 3600) * 1000,
|
|
126
|
+
resourceUrl: tok.resourceUrl,
|
|
127
|
+
};
|
|
128
|
+
saveQwenToken(next);
|
|
129
|
+
return next;
|
|
130
|
+
}
|
|
131
|
+
/** Valid access token + baseURL, refreshing if within 60s of expiry. null if not logged in. */
|
|
132
|
+
export async function getValidQwenAuth() {
|
|
133
|
+
let tok = loadQwenToken();
|
|
134
|
+
if (!tok)
|
|
135
|
+
return null;
|
|
136
|
+
if (Date.now() > tok.expires - 60_000)
|
|
137
|
+
tok = await refreshToken(tok);
|
|
138
|
+
return { accessToken: tok.access, baseURL: normalizeBaseUrl(tok.resourceUrl) };
|
|
139
|
+
}
|
package/dist/recall.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Code-asset recall — a personal, git-versionable library of snippets/playbooks the agent can
|
|
2
|
+
// reference. Lexical search over `~/.hara/code-assets/**/*.md` (override with HARA_ASSETS).
|
|
3
|
+
// Phase-C v0: lexical-first (no embeddings); reuses the shared filesystem walker.
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { readFileSync, mkdirSync, writeFileSync, existsSync } from "node:fs";
|
|
7
|
+
import { walkFiles } from "./fs-walk.js";
|
|
8
|
+
import { skillsDirs } from "./skills/skills.js";
|
|
9
|
+
export function assetsDir() {
|
|
10
|
+
return process.env.HARA_ASSETS || join(homedir(), ".hara", "code-assets");
|
|
11
|
+
}
|
|
12
|
+
/** Every lexical-search root for "assets": the skills (project + global + plugin) and the code-asset
|
|
13
|
+
* library — one corpus so `recall` and dedup-before-save see the same things. */
|
|
14
|
+
export function assetSearchRoots(cwd) {
|
|
15
|
+
return [...skillsDirs(cwd), assetsDir()];
|
|
16
|
+
}
|
|
17
|
+
export function titleOf(text, path) {
|
|
18
|
+
const fm = /^---\n([\s\S]*?)\n---/.exec(text);
|
|
19
|
+
if (fm) {
|
|
20
|
+
const t = /(?:^|\n)title:\s*(.+)/i.exec(fm[1]);
|
|
21
|
+
if (t)
|
|
22
|
+
return t[1].trim();
|
|
23
|
+
}
|
|
24
|
+
const h = /^#\s+(.+)$/m.exec(text);
|
|
25
|
+
return h ? h[1].trim() : (path.split("/").pop() ?? path);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Lexical search: rank .md files by how many query words appear in path+content.
|
|
29
|
+
* Default searches the code-asset library (relative paths). Pass `roots` to search other dirs
|
|
30
|
+
* (e.g. the memory store) — then paths come back absolute so callers can read them directly.
|
|
31
|
+
*/
|
|
32
|
+
export function searchAssets(query, limit = 5, roots) {
|
|
33
|
+
const dirs = roots ?? [assetsDir()];
|
|
34
|
+
const abs = roots !== undefined;
|
|
35
|
+
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
36
|
+
if (!words.length)
|
|
37
|
+
return [];
|
|
38
|
+
const hits = [];
|
|
39
|
+
for (const dir of dirs) {
|
|
40
|
+
if (!existsSync(dir))
|
|
41
|
+
continue;
|
|
42
|
+
for (const rel of walkFiles(dir).filter((f) => f.endsWith(".md"))) {
|
|
43
|
+
let text;
|
|
44
|
+
try {
|
|
45
|
+
text = readFileSync(join(dir, rel), "utf8");
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const hay = (rel + "\n" + text).toLowerCase();
|
|
51
|
+
const score = words.filter((w) => hay.includes(w)).length;
|
|
52
|
+
if (!score)
|
|
53
|
+
continue;
|
|
54
|
+
hits.push({ path: abs ? join(dir, rel) : rel, title: titleOf(text, rel), snippet: text.slice(0, 800), score });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
hits.sort((a, b) => b.score - a.score || a.path.length - b.path.length);
|
|
58
|
+
return hits.slice(0, limit);
|
|
59
|
+
}
|
|
60
|
+
/** Create the assets dir with an example snippet + README. Returns files written. */
|
|
61
|
+
export function scaffoldAssets() {
|
|
62
|
+
const dir = assetsDir();
|
|
63
|
+
mkdirSync(join(dir, "snippets"), { recursive: true });
|
|
64
|
+
const written = [];
|
|
65
|
+
const ex = join(dir, "snippets", "example.md");
|
|
66
|
+
if (!existsSync(ex)) {
|
|
67
|
+
writeFileSync(ex, "---\ntitle: Example snippet\ntags: [example]\nlang: ts\n---\n\n# Example snippet\n\nDescribe a reusable pattern, then the code:\n\n```ts\nexport const example = 1;\n```\n");
|
|
68
|
+
written.push("snippets/example.md");
|
|
69
|
+
}
|
|
70
|
+
const rd = join(dir, "README.md");
|
|
71
|
+
if (!existsSync(rd)) {
|
|
72
|
+
writeFileSync(rd, '# hara code-assets\n\nDrop `*.md` files here (snippets, playbooks). `hara recall "<query>"` searches them;\nin the REPL, `/recall <query>` pulls the best matches into your next message. A personal,\ngit-versionable library of code/patterns you want to reuse.\n');
|
|
73
|
+
written.push("README.md");
|
|
74
|
+
}
|
|
75
|
+
return written;
|
|
76
|
+
}
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// OS sandboxing for the bash tool. macOS = Seatbelt (sandbox-exec); other platforms run unsandboxed
|
|
2
|
+
// (the approval gate + cwd-scoped file tools still apply). Only the `bash` shell is sandboxed —
|
|
3
|
+
// hara's own file tools (write_file/edit_file) are in-process, explicit, and gated.
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { writeFileSync, mkdtempSync } from "node:fs";
|
|
6
|
+
import { tmpdir, platform } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
const sbQuote = (s) => '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
|
|
9
|
+
function seatbeltProfile(cwd, mode) {
|
|
10
|
+
const writable = [
|
|
11
|
+
'(literal "/dev/null")',
|
|
12
|
+
'(literal "/dev/stdout")',
|
|
13
|
+
'(literal "/dev/stderr")',
|
|
14
|
+
'(literal "/dev/dtracehelper")',
|
|
15
|
+
'(literal "/dev/tty")',
|
|
16
|
+
'(subpath "/private/tmp")',
|
|
17
|
+
'(subpath "/private/var/folders")',
|
|
18
|
+
];
|
|
19
|
+
if (mode === "workspace-write")
|
|
20
|
+
writable.push(`(subpath ${sbQuote(cwd)})`);
|
|
21
|
+
return `(version 1)\n(allow default)\n(deny file-write*)\n(allow file-write*\n ${writable.join("\n ")})\n`;
|
|
22
|
+
}
|
|
23
|
+
export function sandboxSupported() {
|
|
24
|
+
return platform() === "darwin";
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Run a shell command, sandboxed when mode != off and the platform supports it.
|
|
28
|
+
* Streams output via `opts.onData` while capturing it for the resolved value.
|
|
29
|
+
* Resolves on exit 0; rejects (with `.stdout`/`.stderr`/`.code`) on nonzero exit or timeout.
|
|
30
|
+
*/
|
|
31
|
+
export function runShell(command, cwd, mode, opts) {
|
|
32
|
+
let cmd;
|
|
33
|
+
let args;
|
|
34
|
+
if (mode !== "off" && platform() === "darwin") {
|
|
35
|
+
const dir = mkdtempSync(join(tmpdir(), "hara-sb-"));
|
|
36
|
+
const profileFile = join(dir, "policy.sb");
|
|
37
|
+
writeFileSync(profileFile, seatbeltProfile(cwd, mode));
|
|
38
|
+
cmd = "sandbox-exec";
|
|
39
|
+
args = ["-f", profileFile, "/bin/bash", "-lc", command];
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
cmd = "/bin/sh";
|
|
43
|
+
args = ["-c", command];
|
|
44
|
+
}
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const child = spawn(cmd, args, { cwd });
|
|
47
|
+
let stdout = "";
|
|
48
|
+
let stderr = "";
|
|
49
|
+
let timedOut = false;
|
|
50
|
+
const grow = (cur, add) => (cur.length < opts.maxBuffer ? cur + add : cur);
|
|
51
|
+
const timer = setTimeout(() => {
|
|
52
|
+
timedOut = true;
|
|
53
|
+
child.kill("SIGKILL");
|
|
54
|
+
}, opts.timeout);
|
|
55
|
+
child.stdout.on("data", (d) => {
|
|
56
|
+
const s = d.toString();
|
|
57
|
+
stdout = grow(stdout, s);
|
|
58
|
+
opts.onData?.(s);
|
|
59
|
+
});
|
|
60
|
+
child.stderr.on("data", (d) => {
|
|
61
|
+
const s = d.toString();
|
|
62
|
+
stderr = grow(stderr, s);
|
|
63
|
+
opts.onData?.(s);
|
|
64
|
+
});
|
|
65
|
+
child.on("error", (e) => {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
reject(Object.assign(e, { stdout, stderr }));
|
|
68
|
+
});
|
|
69
|
+
child.on("close", (code) => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
if (timedOut)
|
|
72
|
+
return reject(Object.assign(new Error(`timed out after ${opts.timeout}ms`), { stdout, stderr }));
|
|
73
|
+
if (code !== 0)
|
|
74
|
+
return reject(Object.assign(new Error(`exit code ${code}`), { stdout, stderr, code }));
|
|
75
|
+
resolve({ stdout, stderr });
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const DEFAULT_MODEL = {
|
|
2
|
+
ollama: "nomic-embed-text",
|
|
3
|
+
qwen: "text-embedding-v3",
|
|
4
|
+
openai: "text-embedding-3-small",
|
|
5
|
+
};
|
|
6
|
+
/** Build an Embedder from config, or null if embeddings are off/unconfigured (→ lexical fallback). */
|
|
7
|
+
export function getEmbedder(cfg) {
|
|
8
|
+
const provider = cfg.embedProvider;
|
|
9
|
+
if (!provider || provider === "off")
|
|
10
|
+
return null;
|
|
11
|
+
const model = cfg.embedModel || DEFAULT_MODEL[provider] || "embed";
|
|
12
|
+
if (provider === "ollama") {
|
|
13
|
+
const base = (cfg.embedBaseURL || "http://localhost:11434").replace(/\/$/, "");
|
|
14
|
+
return async (texts) => {
|
|
15
|
+
const out = [];
|
|
16
|
+
for (const input of texts) {
|
|
17
|
+
const r = await fetch(`${base}/api/embeddings`, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: { "content-type": "application/json" },
|
|
20
|
+
body: JSON.stringify({ model, prompt: input }),
|
|
21
|
+
});
|
|
22
|
+
if (!r.ok)
|
|
23
|
+
throw new Error(`ollama embeddings ${r.status}`);
|
|
24
|
+
out.push((await r.json()).embedding);
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
// qwen (DashScope compatible-mode) + any OpenAI-compatible endpoint: POST /embeddings { model, input[] }
|
|
30
|
+
const base = (cfg.embedBaseURL || (provider === "qwen" ? "https://dashscope.aliyuncs.com/compatible-mode/v1" : cfg.baseURL || "https://api.openai.com/v1")).replace(/\/$/, "");
|
|
31
|
+
const key = cfg.embedApiKey || cfg.apiKey || "";
|
|
32
|
+
return async (texts) => {
|
|
33
|
+
const r = await fetch(`${base}/embeddings`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
|
|
36
|
+
body: JSON.stringify({ model, input: texts }),
|
|
37
|
+
});
|
|
38
|
+
if (!r.ok)
|
|
39
|
+
throw new Error(`embeddings ${r.status}`);
|
|
40
|
+
return ((await r.json()).data || []).map((d) => d.embedding);
|
|
41
|
+
};
|
|
42
|
+
}
|