@nanhara/hara 0.70.0 → 0.95.2
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 +395 -0
- package/README.md +7 -2
- package/dist/agent/compact.js +10 -0
- package/dist/agent/context-report.js +33 -0
- package/dist/agent/failover.js +53 -0
- package/dist/agent/loop.js +120 -13
- package/dist/agent/rewind.js +20 -0
- package/dist/agent/route.js +47 -0
- package/dist/checkpoints.js +91 -0
- package/dist/config.js +33 -9
- package/dist/context/subdir-hints.js +76 -0
- package/dist/cron/runner.js +7 -4
- package/dist/exec/jobs.js +82 -0
- package/dist/gateway/dingtalk.js +209 -0
- package/dist/gateway/discord.js +164 -0
- package/dist/gateway/feishu.js +144 -0
- package/dist/gateway/matrix.js +188 -0
- package/dist/gateway/mattermost.js +206 -0
- package/dist/gateway/serve.js +339 -0
- package/dist/gateway/sessions.js +89 -0
- package/dist/gateway/signal.js +220 -0
- package/dist/gateway/slack.js +190 -0
- package/dist/gateway/telegram.js +115 -0
- package/dist/gateway/tmux-routes.js +135 -0
- package/dist/gateway/tts.js +100 -0
- package/dist/gateway/wecom.js +383 -0
- package/dist/gateway/weixin.js +590 -0
- package/dist/index.js +1073 -120
- package/dist/org-fleet/enroll.js +28 -5
- package/dist/plugins/plugins.js +49 -1
- package/dist/profile/profile.js +436 -0
- package/dist/providers/anthropic.js +28 -1
- package/dist/providers/openai.js +16 -1
- package/dist/sandbox.js +15 -12
- package/dist/security/external-content.js +57 -0
- package/dist/security/permissions.js +211 -0
- package/dist/session/session-model.js +36 -0
- package/dist/statusbar.js +12 -3
- package/dist/tools/builtin.js +49 -2
- package/dist/tools/external_agent.js +118 -0
- package/dist/tools/send.js +35 -0
- package/dist/tools/skill.js +6 -2
- package/dist/tools/todo.js +65 -8
- package/dist/tools/web.js +4 -3
- package/dist/tui/App.js +241 -30
- package/dist/tui/run.js +36 -1
- package/package.json +4 -2
package/dist/agent/loop.js
CHANGED
|
@@ -6,6 +6,21 @@ import { makeRenderer } from "../md.js";
|
|
|
6
6
|
import { skillsDigest } from "../skills/skills.js";
|
|
7
7
|
import { runHooks } from "../hooks.js";
|
|
8
8
|
import { mapLimit, maxParallel } from "../concurrency.js";
|
|
9
|
+
import { decideCommand, loadPermissionRules } from "../security/permissions.js";
|
|
10
|
+
import { subdirHint } from "../context/subdir-hints.js";
|
|
11
|
+
import { classifyError, failoverAction, errorHint } from "./failover.js";
|
|
12
|
+
import { currentTodos } from "../tools/todo.js";
|
|
13
|
+
/** Spinner verb (terminal mode + reused by TUI tests): when the agent has an in_progress todo,
|
|
14
|
+
* surface its activeForm/text so the bottom-of-screen line reads concretely ("▶ updating tests… 3s")
|
|
15
|
+
* instead of "working 3s". Pure: takes a snapshot + elapsed seconds. */
|
|
16
|
+
export function spinnerVerb(list, elapsedSec) {
|
|
17
|
+
const active = list.find((t) => t.status === "in_progress");
|
|
18
|
+
if (active) {
|
|
19
|
+
const phrase = active.activeForm?.trim() || active.text;
|
|
20
|
+
return `${phrase}… ${elapsedSec}s`;
|
|
21
|
+
}
|
|
22
|
+
return `working ${elapsedSec}s`;
|
|
23
|
+
}
|
|
9
24
|
/** Whether a tool call needs user confirmation under the given approval mode. */
|
|
10
25
|
export function needsConfirm(kind, mode) {
|
|
11
26
|
if (kind === "read")
|
|
@@ -30,10 +45,24 @@ When a task matches one of the Skills listed below, call the \`skill\` tool to l
|
|
|
30
45
|
before acting; save a reusable how-to as a new skill with skill_create. If you discover a durable project
|
|
31
46
|
convention, you may propose an edit to AGENTS.md via edit_file (the user reviews the diff). After completing
|
|
32
47
|
a task, give a one-line summary.`;
|
|
48
|
+
/** When running inside `hara gateway`, tell the agent it's in a chat — so it delivers files via send_file
|
|
49
|
+
* (the only channel that reaches the peer) and never reaches for the desktop client / computer tool. */
|
|
50
|
+
function gatewayNote() {
|
|
51
|
+
const plat = process.env.HARA_GATEWAY;
|
|
52
|
+
if (!plat)
|
|
53
|
+
return "";
|
|
54
|
+
return (`\n\n# You are in a chat gateway (${plat})\n` +
|
|
55
|
+
`You are talking to the user through the ${plat} chat — not a terminal, and NOT the desktop ${plat} app. ` +
|
|
56
|
+
`To send a file or image to them, call the \`send_file\` tool with an absolute path; that is the ONLY channel ` +
|
|
57
|
+
`that reaches this chat. Do NOT use the \`computer\` tool, AppleScript, or any desktop/${plat}-client automation ` +
|
|
58
|
+
`to deliver files — that drives a different window and silently fails to reach the user. Never tell the user a ` +
|
|
59
|
+
`file was sent unless \`send_file\` returned success. Keep replies short and chat-friendly.`);
|
|
60
|
+
}
|
|
33
61
|
function composeSystem(cwd, projectContext, override, memory) {
|
|
34
62
|
const head = override ? `${override}\n\nWorking directory: ${cwd}` : HARA_SYSTEM(cwd);
|
|
35
63
|
const skills = skillsDigest(cwd);
|
|
36
64
|
return (head +
|
|
65
|
+
gatewayNote() +
|
|
37
66
|
(projectContext ? `\n\n# Project context (AGENTS.md)\n${projectContext}` : "") +
|
|
38
67
|
(memory ? `\n\n# Memory (durable — facts/decisions/prefs you've saved; use memory_search/get for more)\n${memory}` : "") +
|
|
39
68
|
(skills ? `\n\n# Skills (capabilities you can load — call the \`skill\` tool with the id for full instructions before using one)\n${skills}` : ""));
|
|
@@ -41,6 +70,16 @@ function composeSystem(cwd, projectContext, override, memory) {
|
|
|
41
70
|
/** Provider-agnostic agentic loop. Mutates `history` in place. */
|
|
42
71
|
export async function runAgent(history, opts) {
|
|
43
72
|
const { provider, ctx } = opts;
|
|
73
|
+
const permRules = loadPermissionRules(ctx.cwd); // command-level allow/ask/deny policy for the bash tool
|
|
74
|
+
let activeProvider = provider; // may switch to a fallback model on a recoverable error (app-failover)
|
|
75
|
+
let triedFallback = false;
|
|
76
|
+
// Stuck/loop guard — only in headless chat (`hara gateway`), where a wrong approach can grind forever with
|
|
77
|
+
// nobody to hit Esc (e.g. screenshots it can't read). Once per run, when the agent keeps repeating one
|
|
78
|
+
// non-read tool or acting blind, we inject a reflection nudge so it steps back instead of spinning.
|
|
79
|
+
const guard = !!process.env.HARA_GATEWAY;
|
|
80
|
+
const toolCounts = new Map();
|
|
81
|
+
let blindShots = 0;
|
|
82
|
+
let nudged = false;
|
|
44
83
|
for (;;) {
|
|
45
84
|
// Type-ahead steering: fold in anything the user submitted while the previous step ran, so it
|
|
46
85
|
// reaches the model on this next call (drained after the last tool round; empty on the 1st pass).
|
|
@@ -52,7 +91,17 @@ export async function runAgent(history, opts) {
|
|
|
52
91
|
const sink = ctx.ui; // TUI mode: route output to ink instead of stdout
|
|
53
92
|
const tty = stdout.isTTY && !opts.quiet && !sink;
|
|
54
93
|
const md = tty && process.env.HARA_MD !== "0" ? makeRenderer(out) : null;
|
|
55
|
-
|
|
94
|
+
// Reasoning rendering in plain-terminal mode: we put reasoning on its OWN dim lines (prefixed
|
|
95
|
+
// "│ ") instead of sharing a line with the spinner — that's what was eating DeepSeek's
|
|
96
|
+
// reasoning_content in non-TUI mode (each spinner tick `\r`-overwrote it). The TUI keeps its
|
|
97
|
+
// existing 5-line scroll window via the ink Block; this is the terminal equivalent.
|
|
98
|
+
let reasoningOpen = false;
|
|
99
|
+
const flushReasoningTail = () => {
|
|
100
|
+
if (reasoningOpen) {
|
|
101
|
+
out("\n");
|
|
102
|
+
reasoningOpen = false;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
56
105
|
// "working Ns" spinner until the first output arrives (cleared on text/reasoning or turn end)
|
|
57
106
|
let spin = null;
|
|
58
107
|
const stopSpin = () => {
|
|
@@ -66,9 +115,12 @@ export async function runAgent(history, opts) {
|
|
|
66
115
|
const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
|
|
67
116
|
const t0 = Date.now();
|
|
68
117
|
let fi = 0;
|
|
69
|
-
spin = setInterval(() =>
|
|
118
|
+
spin = setInterval(() => {
|
|
119
|
+
const verb = spinnerVerb(currentTodos(), Math.floor((Date.now() - t0) / 1000));
|
|
120
|
+
out(`\r\x1b[K${c.dim(`${frames[fi++ % frames.length]} ${verb}`)}`);
|
|
121
|
+
}, 100);
|
|
70
122
|
}
|
|
71
|
-
const r = await
|
|
123
|
+
const r = await activeProvider.turn({
|
|
72
124
|
system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory),
|
|
73
125
|
history,
|
|
74
126
|
tools: specs,
|
|
@@ -80,10 +132,7 @@ export async function runAgent(history, opts) {
|
|
|
80
132
|
return;
|
|
81
133
|
}
|
|
82
134
|
stopSpin();
|
|
83
|
-
|
|
84
|
-
out("\n");
|
|
85
|
-
sawReasoning = false;
|
|
86
|
-
}
|
|
135
|
+
flushReasoningTail();
|
|
87
136
|
if (md)
|
|
88
137
|
md.push(d);
|
|
89
138
|
else
|
|
@@ -97,14 +146,29 @@ export async function runAgent(history, opts) {
|
|
|
97
146
|
sink.reasoning(d);
|
|
98
147
|
return;
|
|
99
148
|
}
|
|
149
|
+
// Terminal mode: render reasoning on its own dim lines (prefix `│ ` per line). Each
|
|
150
|
+
// line is committed once and never overwritten — so a subsequent spinner tick can't
|
|
151
|
+
// clobber it (the old `out(c.dim(d))` bug). Multi-line deltas split cleanly; the
|
|
152
|
+
// current line resumes mid-output when the next delta arrives.
|
|
100
153
|
stopSpin();
|
|
101
|
-
|
|
102
|
-
|
|
154
|
+
const lines = d.split("\n");
|
|
155
|
+
for (let i = 0; i < lines.length; i++) {
|
|
156
|
+
if (!reasoningOpen) {
|
|
157
|
+
out(c.dim("│ "));
|
|
158
|
+
reasoningOpen = true;
|
|
159
|
+
}
|
|
160
|
+
out(c.dim(lines[i]));
|
|
161
|
+
if (i < lines.length - 1) {
|
|
162
|
+
out("\n");
|
|
163
|
+
reasoningOpen = false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
103
166
|
}
|
|
104
167
|
: undefined,
|
|
105
168
|
signal: opts.signal,
|
|
106
169
|
});
|
|
107
170
|
stopSpin();
|
|
171
|
+
flushReasoningTail();
|
|
108
172
|
md?.end();
|
|
109
173
|
if (!opts.quiet && !sink)
|
|
110
174
|
out("\n");
|
|
@@ -115,12 +179,26 @@ export async function runAgent(history, opts) {
|
|
|
115
179
|
}
|
|
116
180
|
history.push({ role: "assistant", text: r.text, toolUses: r.toolUses });
|
|
117
181
|
if (r.stop === "error") {
|
|
118
|
-
const
|
|
182
|
+
const kind = classifyError(r.errorMsg ?? "");
|
|
183
|
+
if (failoverAction(kind, { hasFallback: !!opts.fallback?.provider, triedFallback }) === "fallback") {
|
|
184
|
+
triedFallback = true;
|
|
185
|
+
history.pop(); // drop the errored (partial/empty) assistant turn before retrying
|
|
186
|
+
activeProvider = opts.fallback.provider;
|
|
187
|
+
if (!opts.quiet) {
|
|
188
|
+
const note = `✻ ${kind} → falling back to ${activeProvider.model}…`;
|
|
189
|
+
if (sink)
|
|
190
|
+
sink.notice(note);
|
|
191
|
+
else
|
|
192
|
+
out(c.dim(`${note}\n`));
|
|
193
|
+
}
|
|
194
|
+
continue; // retry once on the fallback model (guarded by triedFallback)
|
|
195
|
+
}
|
|
196
|
+
const msg = kind === "interrupted" ? "(interrupted)" : `[${activeProvider.id} error] ${r.errorMsg ?? "unknown"}${errorHint(kind)}`;
|
|
119
197
|
if (!opts.quiet) {
|
|
120
198
|
if (sink)
|
|
121
199
|
sink.notice(msg);
|
|
122
200
|
else
|
|
123
|
-
out(
|
|
201
|
+
out(kind === "interrupted" ? c.dim(`\n${msg}\n`) : c.red(`${msg}\n`));
|
|
124
202
|
}
|
|
125
203
|
return;
|
|
126
204
|
}
|
|
@@ -139,7 +217,14 @@ export async function runAgent(history, opts) {
|
|
|
139
217
|
.trim();
|
|
140
218
|
// Screen control is gated on EVERY action — a prior "don't ask again" must never satisfy it.
|
|
141
219
|
const alwaysGate = tool.kind === "computer";
|
|
142
|
-
|
|
220
|
+
// Command-level policy for shell commands: a deny rule blocks even in full-auto; an allow rule (or a
|
|
221
|
+
// read-only command) auto-runs even in suggest mode. Composes with, doesn't replace, the approval mode.
|
|
222
|
+
const cmdDecision = tool.kind === "exec" && typeof input.command === "string" ? decideCommand(input.command, permRules) : null;
|
|
223
|
+
if (cmdDecision === "deny") {
|
|
224
|
+
plans.push({ tu, tool, denied: "Denied by a permission rule (~/.hara/permissions.json). Loosen the rule or run it yourself." });
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (cmdDecision !== "allow" && needsConfirm(tool.kind, opts.approval) && (alwaysGate || !opts.autoApprove?.has(tu.name))) {
|
|
143
228
|
const reply = await opts.confirm(`${c.yellow("⚠")} ${c.bold(tu.name)} ${c.dim(preview)} — run?`);
|
|
144
229
|
if (reply === false) {
|
|
145
230
|
plans.push({ tu, tool, denied: "User denied this action." });
|
|
@@ -172,7 +257,8 @@ export async function runAgent(history, opts) {
|
|
|
172
257
|
return;
|
|
173
258
|
}
|
|
174
259
|
const res = await p.tool.run(p.tu.input, ctx);
|
|
175
|
-
|
|
260
|
+
// append any not-yet-seen subdirectory AGENTS.md/CLAUDE.md this call touched (monorepo-local conventions)
|
|
261
|
+
results[idx] = { id: p.tu.id, name: p.tu.name, content: res + subdirHint(p.tu.input, ctx.cwd) };
|
|
176
262
|
runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd); // observe-only
|
|
177
263
|
}
|
|
178
264
|
catch (e) {
|
|
@@ -202,5 +288,26 @@ export async function runAgent(history, opts) {
|
|
|
202
288
|
}
|
|
203
289
|
await flush();
|
|
204
290
|
history.push({ role: "tool", results });
|
|
291
|
+
if (guard && !nudged) {
|
|
292
|
+
for (const p of plans)
|
|
293
|
+
if (p.tool && p.tool.kind !== "read")
|
|
294
|
+
toolCounts.set(p.tu.name, (toolCounts.get(p.tu.name) ?? 0) + 1);
|
|
295
|
+
for (const res of results)
|
|
296
|
+
if (typeof res.content === "string" && /Configure a vision model/.test(res.content))
|
|
297
|
+
blindShots++;
|
|
298
|
+
const maxRepeat = Math.max(0, ...toolCounts.values());
|
|
299
|
+
const blind = blindShots >= 2;
|
|
300
|
+
if (blind || maxRepeat >= 5) {
|
|
301
|
+
nudged = true;
|
|
302
|
+
history.push({
|
|
303
|
+
role: "user",
|
|
304
|
+
content: blind
|
|
305
|
+
? "⚠ Self-check: your screenshots come back unreadable (no vision model) — you are acting blind, so this approach cannot work. Stop using the computer tool. Reach the user through a non-visual path instead (a CLI, an API, or the send_file tool). State the new plan in one line, then do it."
|
|
306
|
+
: "⚠ Self-check: you've repeated the same action several times without resolving the task. Stop and reconsider — is there a more direct tool or channel (e.g. send_file to deliver a file)? Don't keep retrying the same thing. State your revised plan in one line, then act.",
|
|
307
|
+
});
|
|
308
|
+
if (!opts.quiet && !ctx.ui)
|
|
309
|
+
out(c.dim(" ⟲ stuck-guard: nudging a rethink\n"));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
205
312
|
}
|
|
206
313
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Recent user turns, newest first (n=1 = most recent), each with a short preview — for `/rewind` to list. */
|
|
2
|
+
export function userTurnPreviews(history, max = 10) {
|
|
3
|
+
const idxs = [];
|
|
4
|
+
history.forEach((m, i) => m.role === "user" && idxs.push(i));
|
|
5
|
+
const out = [];
|
|
6
|
+
for (let k = idxs.length - 1, n = 1; k >= 0 && n <= max; k--, n++) {
|
|
7
|
+
const m = history[idxs[k]];
|
|
8
|
+
out.push({ n, preview: (m.role === "user" ? m.content : "").replace(/\s+/g, " ").slice(0, 70) });
|
|
9
|
+
}
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
/** Truncate history to just BEFORE the n-th-most-recent user turn (n=1 drops the last exchange), forking the
|
|
13
|
+
* conversation from that point. Returns the new history array, or null if n is out of range. */
|
|
14
|
+
export function rewindTo(history, n) {
|
|
15
|
+
const idxs = [];
|
|
16
|
+
history.forEach((m, i) => m.role === "user" && idxs.push(i));
|
|
17
|
+
if (!Number.isInteger(n) || n < 1 || n > idxs.length)
|
|
18
|
+
return null;
|
|
19
|
+
return history.slice(0, idxs[idxs.length - n]); // cut at the n-th-from-last user message
|
|
20
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Words that signal real coding/action work → keep the primary (strong) model. Broad on purpose: routing
|
|
2
|
+
// should fire only on clearly trivial, non-actionable turns (questions, lookups, chit-chat).
|
|
3
|
+
const COMPLEX = /\b(debug|refactor|implement|fix(es|ed|ing)?|build|test|deploy|migrat\w*|optimi[sz]e|architect|design|review|trace|profile|benchmark|docker|kubernetes|compile|exception|error|bug|patch|diff|commit|merge|rebase|rename|add|remove|delete|update|change|write|create|edit|run|install|generate|convert|parse|format|lint|setup|configure|wire|hook|render|fetch|query|schema|class|function|async|await|import|export)\b/i;
|
|
4
|
+
/** The text of the most recent genuine user message (tool results are role:"tool", so this is stable
|
|
5
|
+
* across a turn's tool rounds). */
|
|
6
|
+
export function lastUserText(history) {
|
|
7
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
8
|
+
const m = history[i];
|
|
9
|
+
if (m.role === "user")
|
|
10
|
+
return typeof m.content === "string" ? m.content : "";
|
|
11
|
+
}
|
|
12
|
+
return "";
|
|
13
|
+
}
|
|
14
|
+
/** True if a turn is trivial enough to hand to the cheap/general model: short, single-line, no code,
|
|
15
|
+
* no URL, and no coding/action keyword. Defaults to FALSE (stay on the strong model) when unsure. */
|
|
16
|
+
export function isTrivialTurn(text) {
|
|
17
|
+
const t = (text ?? "").trim();
|
|
18
|
+
if (!t)
|
|
19
|
+
return false;
|
|
20
|
+
if (t.length > 160)
|
|
21
|
+
return false; // long → probably substantive
|
|
22
|
+
if (t.split(/\s+/).length > 28)
|
|
23
|
+
return false;
|
|
24
|
+
if (t.includes("\n"))
|
|
25
|
+
return false; // multi-line / paste
|
|
26
|
+
if (t.includes("`"))
|
|
27
|
+
return false; // inline code / fences
|
|
28
|
+
if (/https?:\/\//i.test(t))
|
|
29
|
+
return false; // a URL to act on
|
|
30
|
+
if (/[{}();=]|=>|::|\/|\\/.test(t))
|
|
31
|
+
return false; // code-ish / a path
|
|
32
|
+
if (COMPLEX.test(t))
|
|
33
|
+
return false; // an action/coding verb
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
/** Wrap a primary + alternate provider so each turn routes to the alternate when the latest user message is
|
|
37
|
+
* trivial, else the primary. Decided per turn from history (stable across tool rounds). */
|
|
38
|
+
export function routingProvider(primary, alt) {
|
|
39
|
+
return {
|
|
40
|
+
id: primary.id,
|
|
41
|
+
model: primary.model, // reported model = primary; routing is transparent
|
|
42
|
+
turn(args) {
|
|
43
|
+
const useAlt = isTrivialTurn(lastUserText(args.history));
|
|
44
|
+
return (useAlt ? alt : primary).turn(args);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// File-state checkpoints via a SHADOW git repo — durable "undo the agent's file changes" beyond the
|
|
2
|
+
// edit-only in-memory undo (which misses bash-made changes). The shadow repo lives OUTSIDE the project
|
|
3
|
+
// (~/.hara/checkpoints/<hash>/git) with GIT_DIR there + GIT_WORK_TREE = the project root, so it captures the
|
|
4
|
+
// WHOLE tree, NEVER touches the user's real .git/index, and the model never sees it. Restore is
|
|
5
|
+
// snapshot-then-checkout: SAFE — it reverts changed/deleted files to the checkpoint and never deletes files
|
|
6
|
+
// created since (so a stray restore can't nuke new work; it's also itself undoable via the auto-snapshot).
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { findProjectRoot } from "./context/agents-md.js";
|
|
13
|
+
// Heavy/derived dirs the shadow repo must never snapshot (in addition to the project's own .gitignore).
|
|
14
|
+
const EXCLUDES = ["node_modules/", ".git/", "dist/", "build/", "out/", ".next/", "target/", ".venv/", "venv/", "__pycache__/", ".hara/", ".cache/", ".turbo/", "coverage/", "*.log", ".DS_Store"];
|
|
15
|
+
function shadowGitDir(root) {
|
|
16
|
+
return join(homedir(), ".hara", "checkpoints", createHash("sha256").update(root).digest("hex").slice(0, 16), "git");
|
|
17
|
+
}
|
|
18
|
+
function git(root, gitDir, args) {
|
|
19
|
+
return execFileSync("git", args, {
|
|
20
|
+
cwd: root,
|
|
21
|
+
env: { ...process.env, GIT_DIR: gitDir, GIT_WORK_TREE: root, GIT_AUTHOR_NAME: "hara", GIT_AUTHOR_EMAIL: "hara@local", GIT_COMMITTER_NAME: "hara", GIT_COMMITTER_EMAIL: "hara@local" },
|
|
22
|
+
encoding: "utf8",
|
|
23
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
24
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
25
|
+
}).toString();
|
|
26
|
+
}
|
|
27
|
+
function ensureRepo(root, gitDir) {
|
|
28
|
+
try {
|
|
29
|
+
if (!existsSync(join(gitDir, "HEAD"))) {
|
|
30
|
+
mkdirSync(gitDir, { recursive: true });
|
|
31
|
+
git(root, gitDir, ["init", "-q"]);
|
|
32
|
+
mkdirSync(join(gitDir, "info"), { recursive: true });
|
|
33
|
+
writeFileSync(join(gitDir, "info", "exclude"), EXCLUDES.join("\n") + "\n");
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Snapshot the current working tree into the shadow repo. Returns the short sha, or null on failure. */
|
|
42
|
+
export function checkpoint(cwd, label) {
|
|
43
|
+
const root = findProjectRoot(cwd);
|
|
44
|
+
const gitDir = shadowGitDir(root);
|
|
45
|
+
if (!ensureRepo(root, gitDir))
|
|
46
|
+
return null;
|
|
47
|
+
try {
|
|
48
|
+
git(root, gitDir, ["add", "-A"]);
|
|
49
|
+
git(root, gitDir, ["commit", "-q", "--allow-empty", "--no-gpg-sign", "-m", (label || "checkpoint").slice(0, 120)]);
|
|
50
|
+
return git(root, gitDir, ["rev-parse", "--short", "HEAD"]).trim();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Recent checkpoints, newest first. */
|
|
57
|
+
export function listCheckpoints(cwd, n = 15) {
|
|
58
|
+
const gitDir = shadowGitDir(findProjectRoot(cwd));
|
|
59
|
+
if (!existsSync(join(gitDir, "HEAD")))
|
|
60
|
+
return [];
|
|
61
|
+
try {
|
|
62
|
+
const out = git(findProjectRoot(cwd), gitDir, ["log", `-n${n}`, "--format=%h\x1f%ct\x1f%s"]).trim();
|
|
63
|
+
if (!out)
|
|
64
|
+
return [];
|
|
65
|
+
return out.split("\n").map((l) => {
|
|
66
|
+
const [sha, ct, ...rest] = l.split("\x1f");
|
|
67
|
+
return { sha, when: Number(ct) * 1000, label: rest.join("\x1f") };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Restore the working tree's changed/deleted files to a checkpoint (snapshots current first, so it's
|
|
75
|
+
* undoable). Files created AFTER the checkpoint are left in place — nothing is deleted. Returns the count of
|
|
76
|
+
* files restored, or null on failure. */
|
|
77
|
+
export function restoreCheckpoint(cwd, ref) {
|
|
78
|
+
const root = findProjectRoot(cwd);
|
|
79
|
+
const gitDir = shadowGitDir(root);
|
|
80
|
+
if (!existsSync(join(gitDir, "HEAD")))
|
|
81
|
+
return null;
|
|
82
|
+
try {
|
|
83
|
+
checkpoint(cwd, `before restore to ${ref}`); // make the restore itself undoable
|
|
84
|
+
const changed = git(root, gitDir, ["diff", "--name-only", ref, "--"]).trim(); // files differing now vs ref
|
|
85
|
+
git(root, gitDir, ["checkout", ref, "--", "."]);
|
|
86
|
+
return changed ? changed.split("\n").length : 0;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -12,7 +12,8 @@ const PROVIDER_DEFAULTS = {
|
|
|
12
12
|
openai: { model: "gpt-4o-mini", envKey: "OPENAI_API_KEY" },
|
|
13
13
|
"hara-gateway": { model: "", envKey: "HARA_GATEWAY_TOKEN" }, // B-end: enrolled device → token in ~/.hara/org.json, routed by the gateway
|
|
14
14
|
};
|
|
15
|
-
export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "notify", "vimMode"];
|
|
15
|
+
export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "routeModel", "routeBaseURL", "routeApiKey", "notify", "vimMode", "autoCompact", "fileCheckpoints", "fallbackModel", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
|
|
16
|
+
export const REASONING_EFFORTS = ["off", "low", "medium", "high"];
|
|
16
17
|
export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
|
|
17
18
|
export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
|
|
18
19
|
const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
|
|
@@ -82,16 +83,27 @@ export function setModelVisionOverride(model, cap) {
|
|
|
82
83
|
persistConfig(p, cfg);
|
|
83
84
|
}
|
|
84
85
|
/**
|
|
85
|
-
* Effective config. Precedence (high→low): env vars >
|
|
86
|
-
*
|
|
86
|
+
* Effective config. Precedence (high→low): env vars > project `.hara/config.json` >
|
|
87
|
+
* named overlay (`overlays.<name>` in global config) > global `~/.hara/config.json`
|
|
88
|
+
* > provider defaults.
|
|
89
|
+
*
|
|
90
|
+
* NOTE: `--profile` / `HARA_PROFILE` is the IDENTITY-profile selector (personal ↔ org A
|
|
91
|
+
* ↔ org B) — see src/profile/profile.ts. The legacy `profiles:{name:partial}` overlay
|
|
92
|
+
* mechanism (a tiny in-config preset / overlay) has been renamed to `overlays:{...}`
|
|
93
|
+
* to free the "profile" word for identity. We still read the legacy `profiles:{...}`
|
|
94
|
+
* key for one release for back-compat. Overlays are addressed by env var
|
|
95
|
+
* `HARA_OVERLAY=<name>` (or `opts.overlay`).
|
|
87
96
|
*/
|
|
88
97
|
export function loadConfig(opts = {}) {
|
|
89
98
|
const global = readRawConfig();
|
|
90
|
-
|
|
99
|
+
// Strip both the new (`overlays`) and legacy (`profiles`) overlay containers from the base merge.
|
|
100
|
+
// The legacy `profiles` key is kept readable for back-compat with users who already have it.
|
|
101
|
+
const { overlays, profiles, ...globalBase } = global;
|
|
91
102
|
const project = readProjectConfig(process.cwd());
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
const
|
|
103
|
+
const overlayName = process.env.HARA_OVERLAY ?? opts.overlay;
|
|
104
|
+
const overlayMap = overlays && typeof overlays === "object" ? overlays : profiles && typeof profiles === "object" ? profiles : null;
|
|
105
|
+
const overlay = overlayName && overlayMap && overlayMap[overlayName] ? overlayMap[overlayName] : {};
|
|
106
|
+
const merged = { ...globalBase, ...project, ...overlay };
|
|
95
107
|
const provider = (process.env.HARA_PROVIDER ?? merged.provider ?? "anthropic");
|
|
96
108
|
const d = PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic;
|
|
97
109
|
const model = process.env.HARA_MODEL ?? merged.model ?? d.model;
|
|
@@ -112,15 +124,27 @@ export function loadConfig(opts = {}) {
|
|
|
112
124
|
const embedModel = process.env.HARA_EMBED_MODEL ?? merged.embedModel;
|
|
113
125
|
const embedBaseURL = process.env.HARA_EMBED_BASE_URL ?? merged.embedBaseURL;
|
|
114
126
|
const embedApiKey = process.env.HARA_EMBED_API_KEY ?? merged.embedApiKey;
|
|
127
|
+
const routeModel = process.env.HARA_ROUTE_MODEL ?? merged.routeModel;
|
|
128
|
+
const routeBaseURL = process.env.HARA_ROUTE_BASE_URL ?? merged.routeBaseURL;
|
|
129
|
+
const routeApiKey = process.env.HARA_ROUTE_API_KEY ?? merged.routeApiKey;
|
|
115
130
|
const mcpServers = {
|
|
116
131
|
...(globalBase.mcpServers ?? {}),
|
|
117
132
|
...(project.mcpServers ?? {}),
|
|
118
|
-
...(
|
|
133
|
+
...(overlay.mcpServers ?? {}),
|
|
119
134
|
};
|
|
120
135
|
const hooks = (merged.hooks && typeof merged.hooks === "object" ? merged.hooks : {});
|
|
121
136
|
const notify = (process.env.HARA_NOTIFY ?? merged.notify ?? "off");
|
|
122
137
|
const vimMode = process.env.HARA_VIM === "1" || merged.vimMode === true || merged.vimMode === "true";
|
|
123
|
-
|
|
138
|
+
const autoCompact = !(process.env.HARA_AUTO_COMPACT === "0" || merged.autoCompact === false || merged.autoCompact === "false"); // default ON
|
|
139
|
+
const fileCheckpoints = !(process.env.HARA_CHECKPOINTS === "0" || merged.fileCheckpoints === false || merged.fileCheckpoints === "false"); // default ON
|
|
140
|
+
const fallbackModel = process.env.HARA_FALLBACK_MODEL ?? merged.fallbackModel;
|
|
141
|
+
const fallbackBaseURL = process.env.HARA_FALLBACK_BASE_URL ?? merged.fallbackBaseURL;
|
|
142
|
+
const fallbackApiKey = process.env.HARA_FALLBACK_API_KEY ?? merged.fallbackApiKey;
|
|
143
|
+
const reasoningRaw = process.env.HARA_REASONING_EFFORT ?? merged.reasoningEffort;
|
|
144
|
+
const reasoningEffort = reasoningRaw && ["off", "low", "medium", "high"].includes(reasoningRaw)
|
|
145
|
+
? reasoningRaw
|
|
146
|
+
: undefined;
|
|
147
|
+
return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, hooks, notify, vimMode, autoCompact, fileCheckpoints, fallbackModel, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: process.cwd() };
|
|
124
148
|
}
|
|
125
149
|
export function providerEnvKey(provider) {
|
|
126
150
|
return (PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic).envKey;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Lazy per-directory project docs — when a tool touches a directory we haven't seen yet, surface that dir's
|
|
2
|
+
// AGENTS.md / CLAUDE.md (the local conventions for that package) by appending it to the tool result, so a
|
|
3
|
+
// monorepo's per-package rules reach the model exactly when work moves into that package. Startup already
|
|
4
|
+
// loads root→cwd (agents-md.ts); this covers the subdirs the agent navigates INTO. Each dir loaded once.
|
|
5
|
+
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
6
|
+
import { join, dirname, resolve, relative, isAbsolute } from "node:path";
|
|
7
|
+
import { findProjectRoot } from "./agents-md.js";
|
|
8
|
+
const FILENAMES = ["AGENTS.override.md", "AGENTS.md", "CLAUDE.md"];
|
|
9
|
+
const MAX = 8 * 1024;
|
|
10
|
+
const loaded = new Set(); // dirs whose local doc we've already injected (per process / session)
|
|
11
|
+
function isDir(p) {
|
|
12
|
+
try {
|
|
13
|
+
return statSync(p).isDirectory();
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Candidate file/dir paths referenced by a tool call (its `path`, or path-like tokens in a shell command). */
|
|
20
|
+
function pathsFrom(input) {
|
|
21
|
+
const out = [];
|
|
22
|
+
if (typeof input?.path === "string")
|
|
23
|
+
out.push(input.path);
|
|
24
|
+
if (typeof input?.command === "string")
|
|
25
|
+
for (const m of input.command.matchAll(/[\w.@~+-]*\/[\w./@+-]+/g))
|
|
26
|
+
out.push(m[0]);
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
/** Project docs for any NEW directory (strictly under cwd) this tool call touches — appendable to the tool
|
|
30
|
+
* result. Returns "" when nothing new. Each directory is checked/loaded at most once per session. */
|
|
31
|
+
export function subdirHint(input, cwd) {
|
|
32
|
+
const base = resolve(cwd);
|
|
33
|
+
const root = findProjectRoot(cwd);
|
|
34
|
+
const parts = [];
|
|
35
|
+
for (const raw of pathsFrom(input)) {
|
|
36
|
+
const absPath = isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw);
|
|
37
|
+
const startDir = isDir(absPath) ? absPath : dirname(absPath);
|
|
38
|
+
const rel = relative(base, startDir);
|
|
39
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel))
|
|
40
|
+
continue; // not strictly under cwd → startup already covered it
|
|
41
|
+
// collect unseen dirs from startDir up to (but excluding) cwd
|
|
42
|
+
const chain = [];
|
|
43
|
+
let d = startDir;
|
|
44
|
+
while (d.startsWith(base) && d !== base) {
|
|
45
|
+
if (!loaded.has(d))
|
|
46
|
+
chain.unshift(d);
|
|
47
|
+
if (d === root)
|
|
48
|
+
break;
|
|
49
|
+
const parent = dirname(d);
|
|
50
|
+
if (parent === d)
|
|
51
|
+
break;
|
|
52
|
+
d = parent;
|
|
53
|
+
}
|
|
54
|
+
for (const cd of chain) {
|
|
55
|
+
loaded.add(cd); // mark checked even if no doc here, so we don't re-scan it
|
|
56
|
+
for (const name of FILENAMES) {
|
|
57
|
+
const fp = join(cd, name);
|
|
58
|
+
if (!existsSync(fp))
|
|
59
|
+
continue;
|
|
60
|
+
try {
|
|
61
|
+
let txt = readFileSync(fp, "utf8").trim();
|
|
62
|
+
if (!txt)
|
|
63
|
+
break;
|
|
64
|
+
if (Buffer.byteLength(txt, "utf8") > MAX)
|
|
65
|
+
txt = txt.slice(0, MAX) + "\n…[truncated]";
|
|
66
|
+
parts.push(`<!-- ${name} @ ${cd} — local conventions for this directory -->\n${txt}`);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* ignore unreadable */
|
|
70
|
+
}
|
|
71
|
+
break; // first filename match per dir
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return parts.length ? "\n\n" + parts.join("\n\n") : "";
|
|
76
|
+
}
|
package/dist/cron/runner.js
CHANGED
|
@@ -10,11 +10,14 @@ import { isDue } from "./schedule.js";
|
|
|
10
10
|
export function dueJobs(jobs, nowMs) {
|
|
11
11
|
return jobs.filter((j) => j.enabled && isDue(j, nowMs));
|
|
12
12
|
}
|
|
13
|
-
/** How to invoke hara again
|
|
14
|
-
*
|
|
13
|
+
/** How to invoke hara again. Under node, argv[1] is the entry to hand back to node — either `dist/index.js`
|
|
14
|
+
* OR the installed `hara` bin symlink (node runs both); as a compiled single-binary, execPath itself IS hara
|
|
15
|
+
* (argv[1] is a user arg), so re-invoke the binary directly. Used by the cron tick + the chat gateway.
|
|
16
|
+
* Discriminator is whether execPath is node — NOT argv[1]'s extension (the bin symlink has no `.js`). */
|
|
15
17
|
export function selfArgv() {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
+
const exec = process.execPath;
|
|
19
|
+
const underNode = /(^|[\\/])node(\.exe)?$/i.test(exec);
|
|
20
|
+
return underNode && process.argv[1] ? [exec, process.argv[1]] : [exec];
|
|
18
21
|
}
|
|
19
22
|
const lockPath = () => join(cronDir(), ".tick.lock");
|
|
20
23
|
// Generous: a live-PID owner is respected this long (so a genuinely long job isn't double-fired); past it
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Background shell jobs — run long-lived commands (dev servers, `tsc --watch`, long builds) WITHOUT blocking
|
|
2
|
+
// the agent, then tail/kill them. Pipe-based (no PTY yet; a true interactive PTY can come later behind an
|
|
3
|
+
// optional dep). Same sandbox write-confinement as runShell (shared `shellCommand`). Jobs are the agent's
|
|
4
|
+
// own child processes and are terminated when hara exits.
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { shellCommand } from "../sandbox.js";
|
|
7
|
+
const MAX_BUF = 64 * 1024; // retain only the tail of each job's combined output
|
|
8
|
+
const jobs = new Map();
|
|
9
|
+
let seq = 0;
|
|
10
|
+
let hooked = false;
|
|
11
|
+
function ensureExitCleanup() {
|
|
12
|
+
if (hooked)
|
|
13
|
+
return;
|
|
14
|
+
hooked = true;
|
|
15
|
+
process.on("exit", killAllJobs); // sync; runs on normal quit so background jobs don't orphan
|
|
16
|
+
}
|
|
17
|
+
/** Start a background shell job; returns its id immediately. Output is captured to a capped tail buffer. */
|
|
18
|
+
export function startJob(command, cwd, mode) {
|
|
19
|
+
ensureExitCleanup();
|
|
20
|
+
const { cmd, args } = shellCommand(command, cwd, mode);
|
|
21
|
+
const child = spawn(cmd, args, { cwd });
|
|
22
|
+
const job = { id: "j" + ++seq, command, child, status: "running", code: null, startedAt: Date.now(), buf: "" };
|
|
23
|
+
const onData = (d) => {
|
|
24
|
+
job.buf = (job.buf + d.toString()).slice(-MAX_BUF);
|
|
25
|
+
};
|
|
26
|
+
child.stdout?.on("data", onData);
|
|
27
|
+
child.stderr?.on("data", onData);
|
|
28
|
+
child.on("close", (code) => {
|
|
29
|
+
if (job.status === "running") {
|
|
30
|
+
job.status = "exited";
|
|
31
|
+
job.code = code;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
child.on("error", (e) => {
|
|
35
|
+
if (job.status === "running") {
|
|
36
|
+
job.status = "exited";
|
|
37
|
+
job.code = -1;
|
|
38
|
+
job.buf = (job.buf + `\n[spawn error] ${e.message}`).slice(-MAX_BUF);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
jobs.set(job.id, job);
|
|
42
|
+
return job.id;
|
|
43
|
+
}
|
|
44
|
+
export function listJobs() {
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
return [...jobs.values()].map((j) => ({ id: j.id, command: j.command, status: j.status, code: j.code, ageMs: now - j.startedAt }));
|
|
47
|
+
}
|
|
48
|
+
/** Last `lines` lines of a job's output buffer, or null if there is no such job. */
|
|
49
|
+
export function tailJob(id, lines = 40) {
|
|
50
|
+
const j = jobs.get(id);
|
|
51
|
+
if (!j)
|
|
52
|
+
return null;
|
|
53
|
+
return j.buf.split("\n").slice(-lines).join("\n");
|
|
54
|
+
}
|
|
55
|
+
/** Terminate a running job (SIGTERM). Returns true only if it was running. */
|
|
56
|
+
export function killJob(id) {
|
|
57
|
+
const j = jobs.get(id);
|
|
58
|
+
if (!j || j.status !== "running")
|
|
59
|
+
return false;
|
|
60
|
+
try {
|
|
61
|
+
j.child.kill("SIGTERM");
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
/* already gone */
|
|
65
|
+
}
|
|
66
|
+
j.status = "killed";
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
/** Terminate every running job — registered on process exit so dev servers don't outlive hara. */
|
|
70
|
+
export function killAllJobs() {
|
|
71
|
+
for (const j of jobs.values()) {
|
|
72
|
+
if (j.status === "running") {
|
|
73
|
+
try {
|
|
74
|
+
j.child.kill("SIGTERM");
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
/* ignore */
|
|
78
|
+
}
|
|
79
|
+
j.status = "killed";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|