@nanhara/hara 0.122.2 → 0.122.4
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 +108 -0
- package/README.md +15 -2
- package/dist/agent/limits.js +51 -0
- package/dist/agent/loop.js +367 -112
- package/dist/agent/repeat-guard.js +49 -12
- package/dist/checkpoints.js +9 -0
- package/dist/cli.js +2 -1
- package/dist/config.js +10 -2
- package/dist/context/agents-md.js +21 -2
- package/dist/context/mentions.js +84 -1
- package/dist/context/workspace-scope.js +49 -0
- package/dist/cron/deliver.js +61 -38
- package/dist/cron/runner.js +423 -65
- package/dist/cron/schedule.js +23 -7
- package/dist/cron/store.js +709 -43
- package/dist/fs-walk.js +279 -7
- package/dist/fs-write.js +9 -0
- package/dist/gateway/feishu.js +351 -64
- package/dist/gateway/flows-pending.js +31 -17
- package/dist/gateway/flows.js +306 -31
- package/dist/gateway/outbound-files.js +20 -6
- package/dist/gateway/runtime-state.js +1485 -0
- package/dist/gateway/serve.js +550 -242
- package/dist/gateway/sessions.js +3 -3
- package/dist/gateway/telegram.js +279 -29
- package/dist/gateway/tts.js +182 -38
- package/dist/hooks.js +22 -22
- package/dist/index.js +466 -158
- package/dist/memory/store.js +8 -5
- package/dist/org/planner.js +11 -6
- package/dist/org/projects.js +3 -3
- package/dist/process-identity.js +52 -0
- package/dist/providers/bounded-turn.js +42 -0
- package/dist/recall.js +69 -1
- package/dist/runtime.js +24 -0
- package/dist/sandbox.js +54 -4
- package/dist/search/embed.js +13 -2
- package/dist/search/hybrid.js +6 -3
- package/dist/search/semindex.js +87 -5
- package/dist/security/guardian.js +3 -15
- package/dist/security/permissions.js +11 -0
- package/dist/serve/server.js +70 -42
- package/dist/serve/sessions.js +4 -3
- package/dist/sync-sleep.js +46 -0
- package/dist/tools/agent.js +1 -1
- package/dist/tools/ask_user.js +5 -1
- package/dist/tools/builtin.js +5 -2
- package/dist/tools/codebase.js +67 -18
- package/dist/tools/computer.js +149 -68
- package/dist/tools/cron.js +72 -18
- package/dist/tools/edit.js +3 -2
- package/dist/tools/external_agent.js +66 -12
- package/dist/tools/memory.js +25 -7
- package/dist/tools/patch.js +11 -2
- package/dist/tools/registry.js +16 -1
- package/dist/tools/search.js +68 -9
- package/dist/tools/send.js +1 -1
- package/dist/tools/skill.js +1 -1
- package/dist/tools/task.js +3 -3
- package/dist/tools/web.js +43 -13
- package/dist/tui/App.js +93 -25
- package/dist/vision.js +5 -6
- package/package.json +2 -2
- package/runtime-bootstrap.cjs +22 -0
package/dist/memory/store.js
CHANGED
|
@@ -57,15 +57,16 @@ async function inspectMemoryWrite(path, action) {
|
|
|
57
57
|
throw error;
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
-
async function commitMemoryText(path, text, action) {
|
|
60
|
+
async function commitMemoryText(path, text, action, signal) {
|
|
61
61
|
const { boundary, snapshot } = await inspectMemoryWrite(path, action);
|
|
62
62
|
await atomicWriteText(boundary.target, text, {
|
|
63
63
|
expected: snapshot?.text ?? null,
|
|
64
64
|
expectedIdentity: snapshot ?? undefined,
|
|
65
65
|
boundary,
|
|
66
|
+
signal,
|
|
66
67
|
});
|
|
67
68
|
}
|
|
68
|
-
export async function appendMemory(scope, target, content, cwd) {
|
|
69
|
+
export async function appendMemory(scope, target, content, cwd, signal) {
|
|
69
70
|
const f = targetFile(scope, target, cwd);
|
|
70
71
|
const { boundary, snapshot } = await inspectMemoryWrite(f, "append memory");
|
|
71
72
|
const text = (snapshot ? `${snapshot.text}\n` : "") + content.trim() + "\n";
|
|
@@ -73,15 +74,16 @@ export async function appendMemory(scope, target, content, cwd) {
|
|
|
73
74
|
expected: snapshot?.text ?? null,
|
|
74
75
|
expectedIdentity: snapshot ?? undefined,
|
|
75
76
|
boundary,
|
|
77
|
+
signal,
|
|
76
78
|
});
|
|
77
79
|
return f;
|
|
78
80
|
}
|
|
79
|
-
export async function replaceMemory(scope, target, content, cwd) {
|
|
81
|
+
export async function replaceMemory(scope, target, content, cwd, signal) {
|
|
80
82
|
const f = targetFile(scope, target, cwd);
|
|
81
|
-
await commitMemoryText(f, content.trim() + "\n", "replace memory");
|
|
83
|
+
await commitMemoryText(f, content.trim() + "\n", "replace memory", signal);
|
|
82
84
|
return f;
|
|
83
85
|
}
|
|
84
|
-
export async function forgetMemory(scope, target, match, cwd) {
|
|
86
|
+
export async function forgetMemory(scope, target, match, cwd, signal) {
|
|
85
87
|
const f = targetFile(scope, target, cwd);
|
|
86
88
|
if (!match)
|
|
87
89
|
return 0;
|
|
@@ -97,6 +99,7 @@ export async function forgetMemory(scope, target, match, cwd) {
|
|
|
97
99
|
expected: snapshot.text,
|
|
98
100
|
expectedIdentity: snapshot,
|
|
99
101
|
boundary,
|
|
102
|
+
signal,
|
|
100
103
|
});
|
|
101
104
|
return removed;
|
|
102
105
|
}
|
package/dist/org/planner.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// at .hara/org/plan.json. This is hara's differentiator: not one agent, an org that plans.
|
|
5
5
|
import { existsSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
+
import { boundedProviderTurn } from "../providers/bounded-turn.js";
|
|
7
8
|
import { runShell } from "../sandbox.js";
|
|
8
9
|
import { readModelContextFileSync, readVerifiedRegularFileSnapshot } from "../fs-read.js";
|
|
9
10
|
import { atomicWriteText, bindAtomicWritePath } from "../fs-write.js";
|
|
@@ -15,14 +16,16 @@ Return ONLY a JSON object, no prose:
|
|
|
15
16
|
{"atoms":[{"id":"a1","title":"imperative step","detail":"how/where","deps":[],"verify":"observable done-criteria","check":"shell command exiting 0 iff done (optional)","role":"<roleId or omit>"}]}
|
|
16
17
|
Rules: short ids (a1,a2,…); deps reference earlier ids only; typically 3-8 atoms; each atom small and verifiable. Prefer a concrete 'check' command (e.g. "npm test", "tsc --noEmit", "test -f src/x.ts") so a step is verified objectively; omit 'check' if none fits.`;
|
|
17
18
|
/** Ask the model to decompose `task` into an atomized, sequenced plan. */
|
|
18
|
-
export async function decompose(provider, task, roles) {
|
|
19
|
+
export async function decompose(provider, task, roles, opts = {}) {
|
|
19
20
|
const roleHint = roles.length ? `\nAvailable roles for the optional "role" field: ${roles.map((r) => r.id).join(", ")}.` : "";
|
|
20
|
-
const r = await provider
|
|
21
|
+
const r = await boundedProviderTurn(provider, {
|
|
21
22
|
system: PLAN_SYSTEM + roleHint,
|
|
22
23
|
history: [{ role: "user", content: `Task: ${task}\n\nReturn the JSON plan.` }],
|
|
23
24
|
tools: [],
|
|
24
25
|
onText: () => { },
|
|
25
|
-
});
|
|
26
|
+
}, { timeoutMs: opts.timeoutMs ?? 60_000, signal: opts.signal, label: "plan decomposition" });
|
|
27
|
+
if (r.stop === "error")
|
|
28
|
+
return { task, atoms: [], createdAt: new Date().toISOString() };
|
|
26
29
|
return { task, atoms: parsePlan(r.text), createdAt: new Date().toISOString() };
|
|
27
30
|
}
|
|
28
31
|
/** Extract + normalize atoms from the model's (possibly fenced/noisy) JSON reply. */
|
|
@@ -124,8 +127,8 @@ export function atomPrompt(atom, plan, done) {
|
|
|
124
127
|
`Use tools as needed. Finish with a one-line result.`);
|
|
125
128
|
}
|
|
126
129
|
/** Soft verification gate: ask the model whether the atom met its done-criteria. */
|
|
127
|
-
export async function verify(provider, atom, transcriptTail) {
|
|
128
|
-
const r = await provider
|
|
130
|
+
export async function verify(provider, atom, transcriptTail, opts = {}) {
|
|
131
|
+
const r = await boundedProviderTurn(provider, {
|
|
129
132
|
system: "You verify whether a coding step met its done-criteria. " +
|
|
130
133
|
"Reply EXACTLY 'DONE' if met, or 'NEEDSWORK: <short reason>' if not. No other text.",
|
|
131
134
|
history: [
|
|
@@ -136,7 +139,9 @@ export async function verify(provider, atom, transcriptTail) {
|
|
|
136
139
|
],
|
|
137
140
|
tools: [],
|
|
138
141
|
onText: () => { },
|
|
139
|
-
});
|
|
142
|
+
}, { timeoutMs: opts.timeoutMs ?? 30_000, signal: opts.signal, label: "plan verification" });
|
|
143
|
+
if (r.stop === "error")
|
|
144
|
+
return { ok: false, reason: r.errorMsg?.slice(0, 200) || "plan verification failed" };
|
|
140
145
|
const t = r.text.trim();
|
|
141
146
|
if (/^done\b/i.test(t))
|
|
142
147
|
return { ok: true, reason: "verified" };
|
package/dist/org/projects.js
CHANGED
|
@@ -5,8 +5,8 @@ import { isAbsolute, join, resolve } from "node:path";
|
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import { loadRoles, loadGlobalRoles } from "./roles.js";
|
|
8
|
+
import { sleepSync } from "../sync-sleep.js";
|
|
8
9
|
const PROJECT_NAME = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
|
|
9
|
-
const sleepCell = new Int32Array(new SharedArrayBuffer(4));
|
|
10
10
|
const LOCK_ATTEMPTS = 500;
|
|
11
11
|
const LOCK_WAIT_MS = 10;
|
|
12
12
|
function haraDir() {
|
|
@@ -101,7 +101,7 @@ function withProjectsLock(fn) {
|
|
|
101
101
|
continue;
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
-
|
|
104
|
+
sleepSync(LOCK_WAIT_MS);
|
|
105
105
|
continue;
|
|
106
106
|
}
|
|
107
107
|
const candidate = { pid: process.pid, token: randomUUID() };
|
|
@@ -132,7 +132,7 @@ function withProjectsLock(fn) {
|
|
|
132
132
|
rmSync(reclaim, { force: true });
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
|
-
|
|
135
|
+
sleepSync(LOCK_WAIT_MS);
|
|
136
136
|
}
|
|
137
137
|
if (!claim)
|
|
138
138
|
throw new Error("projects registry is busy; retry the operation");
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Cross-process birth identity used by file leases. Only a same-version, unequal identity proves PID reuse;
|
|
2
|
+
// missing probes and format upgrades are deliberately "unknown" so callers fail closed.
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
/** Stable for one OS process lifetime. Unknown platforms/probe failures return null. */
|
|
6
|
+
export function defaultProcessIdentity(pid) {
|
|
7
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
8
|
+
return null;
|
|
9
|
+
try {
|
|
10
|
+
if (process.platform === "linux") {
|
|
11
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
12
|
+
const close = stat.lastIndexOf(")");
|
|
13
|
+
if (close < 0)
|
|
14
|
+
return null;
|
|
15
|
+
// Fields after the comm closing parenthesis begin at field 3; starttime is field 22.
|
|
16
|
+
const startTicks = stat.slice(close + 1).trim().split(/\s+/)[19];
|
|
17
|
+
if (!/^\d+$/.test(startTicks ?? ""))
|
|
18
|
+
return null;
|
|
19
|
+
const bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim().toLowerCase();
|
|
20
|
+
if (!/^[a-f0-9-]{16,64}$/.test(bootId))
|
|
21
|
+
return null;
|
|
22
|
+
return `linux-v1:${bootId}:${startTicks}`;
|
|
23
|
+
}
|
|
24
|
+
if (process.platform === "darwin") {
|
|
25
|
+
// `lstart` is rendered in the child process' timezone. Fix both locale and TZ so the same live PID keeps
|
|
26
|
+
// one identity even if Hara's environment or the host timezone changes between lock acquisition/check.
|
|
27
|
+
const started = execFileSync("/bin/ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
28
|
+
encoding: "utf8",
|
|
29
|
+
env: { ...process.env, LC_ALL: "C", TZ: "UTC0" },
|
|
30
|
+
maxBuffer: 1_024,
|
|
31
|
+
timeout: 1_000,
|
|
32
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
33
|
+
}).trim().replace(/\s+/g, " ");
|
|
34
|
+
return started ? `darwin-v1:${started}` : null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
export function compareProcessIdentity(expected, current) {
|
|
43
|
+
if (!expected || !current)
|
|
44
|
+
return "unknown";
|
|
45
|
+
const expectedSeparator = expected.indexOf(":");
|
|
46
|
+
const currentSeparator = current.indexOf(":");
|
|
47
|
+
if (expectedSeparator <= 0 || currentSeparator <= 0)
|
|
48
|
+
return "unknown";
|
|
49
|
+
if (expected.slice(0, expectedSeparator) !== current.slice(0, currentSeparator))
|
|
50
|
+
return "unknown";
|
|
51
|
+
return expected === current ? "same" : "different";
|
|
52
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const errorResult = (message) => ({ text: "", toolUses: [], stop: "error", errorMsg: message });
|
|
2
|
+
/**
|
|
3
|
+
* Execute a one-shot provider call behind both cooperative cancellation and a hard Promise boundary.
|
|
4
|
+
*
|
|
5
|
+
* Passing only an AbortSignal is insufficient: third-party SDKs and custom providers may ignore it and
|
|
6
|
+
* leave their Promise pending forever. This helper aborts the request for cooperative providers while also
|
|
7
|
+
* resolving the caller independently. The abandoned provider Promise always retains rejection handling.
|
|
8
|
+
*/
|
|
9
|
+
export async function boundedProviderTurn(provider, args, options) {
|
|
10
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(1, Math.trunc(options.timeoutMs)) : 60_000;
|
|
11
|
+
const label = options.label?.trim() || "model call";
|
|
12
|
+
const parent = options.signal ?? args.signal;
|
|
13
|
+
if (parent?.aborted)
|
|
14
|
+
return errorResult(`${label} cancelled`);
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
let stopResolve;
|
|
17
|
+
const stopped = new Promise((resolve) => (stopResolve = resolve));
|
|
18
|
+
let stopSettled = false;
|
|
19
|
+
const stop = (message) => {
|
|
20
|
+
if (stopSettled)
|
|
21
|
+
return;
|
|
22
|
+
stopSettled = true;
|
|
23
|
+
controller.abort(new Error(message));
|
|
24
|
+
stopResolve(errorResult(message));
|
|
25
|
+
};
|
|
26
|
+
const onParentAbort = () => stop(`${label} cancelled`);
|
|
27
|
+
parent?.addEventListener("abort", onParentAbort, { once: true });
|
|
28
|
+
// Keep this timer referenced. In a short-lived headless process it may be the only active handle keeping
|
|
29
|
+
// the hard boundary alive while a deliberately non-cooperative test/provider returns a handle-less Promise.
|
|
30
|
+
const timer = setTimeout(() => stop(`${label} timed out after ${timeoutMs}ms`), timeoutMs);
|
|
31
|
+
const turn = Promise.resolve()
|
|
32
|
+
// Cancellation may happen synchronously after this async function returns but before its provider
|
|
33
|
+
// microtask starts. Re-check here so an already-cancelled auxiliary call never incurs a request/cost.
|
|
34
|
+
.then(() => controller.signal.aborted ? errorResult(`${label} cancelled`) : provider.turn({ ...args, signal: controller.signal }))
|
|
35
|
+
.catch((error) => errorResult(error instanceof Error ? error.message : String(error)));
|
|
36
|
+
const result = await Promise.race([turn, stopped]);
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
parent?.removeEventListener("abort", onParentAbort);
|
|
39
|
+
stopSettled = true;
|
|
40
|
+
// A parent cancellation remains authoritative if it raced a provider's late success.
|
|
41
|
+
return parent?.aborted ? errorResult(`${label} cancelled`) : result;
|
|
42
|
+
}
|
package/dist/recall.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
|
|
7
|
-
import { walkFiles } from "./fs-walk.js";
|
|
7
|
+
import { walkFiles, walkFilesAsync } from "./fs-walk.js";
|
|
8
8
|
import { skillsDirs } from "./skills/skills.js";
|
|
9
9
|
import { readModelContextFileSync } from "./fs-read.js";
|
|
10
10
|
const MAX_RECALL_SOURCE_BYTES = 256 * 1024;
|
|
@@ -75,6 +75,74 @@ export function searchAssets(query, limit = 5, roots) {
|
|
|
75
75
|
hits.sort((a, b) => b.score - a.score || b.boost - a.boost || a.path.length - b.path.length);
|
|
76
76
|
return hits.slice(0, limit).map(({ boost, ...r }) => r);
|
|
77
77
|
}
|
|
78
|
+
/** Interruptible lexical search for agent/CLI paths. All roots and file reads share one wall budget. */
|
|
79
|
+
export async function searchAssetsAsync(query, limit = 5, roots, options = {}) {
|
|
80
|
+
const dirs = roots ?? [assetsDir()];
|
|
81
|
+
const abs = roots !== undefined;
|
|
82
|
+
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
83
|
+
if (!words.length)
|
|
84
|
+
return [];
|
|
85
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(0, Math.floor(options.timeoutMs)) : 2_000;
|
|
86
|
+
const maxFiles = Number.isFinite(options.maxFiles) ? Math.max(0, Math.floor(options.maxFiles)) : 8_000;
|
|
87
|
+
const maxDirectories = Number.isFinite(options.maxDirectories) ? Math.max(0, Math.floor(options.maxDirectories)) : 20_000;
|
|
88
|
+
const maxEntries = Number.isFinite(options.maxEntries) ? Math.max(0, Math.floor(options.maxEntries)) : 100_000;
|
|
89
|
+
const startedAt = Date.now();
|
|
90
|
+
let filesLeft = maxFiles;
|
|
91
|
+
let directoriesLeft = maxDirectories;
|
|
92
|
+
let entriesLeft = maxEntries;
|
|
93
|
+
const hits = [];
|
|
94
|
+
for (const dir of dirs) {
|
|
95
|
+
if (options.signal?.aborted)
|
|
96
|
+
throw options.signal.reason instanceof Error ? options.signal.reason : new Error("asset search cancelled");
|
|
97
|
+
const remainingMs = timeoutMs - (Date.now() - startedAt);
|
|
98
|
+
if (remainingMs <= 0 || filesLeft <= 0 || directoriesLeft <= 0 || entriesLeft <= 0)
|
|
99
|
+
break;
|
|
100
|
+
if (!existsSync(dir))
|
|
101
|
+
continue;
|
|
102
|
+
const inventory = await walkFilesAsync(dir, {
|
|
103
|
+
maxFiles: filesLeft,
|
|
104
|
+
maxDirectories: directoriesLeft,
|
|
105
|
+
maxEntries: entriesLeft,
|
|
106
|
+
timeoutMs: remainingMs,
|
|
107
|
+
signal: options.signal,
|
|
108
|
+
yieldEvery: options.yieldEvery,
|
|
109
|
+
});
|
|
110
|
+
filesLeft = Math.max(0, filesLeft - inventory.files.length);
|
|
111
|
+
directoriesLeft = Math.max(0, directoriesLeft - inventory.directoriesVisited);
|
|
112
|
+
entriesLeft = Math.max(0, entriesLeft - inventory.entriesVisited);
|
|
113
|
+
let fileIndex = 0;
|
|
114
|
+
for (const rel of inventory.files) {
|
|
115
|
+
if (!rel.endsWith(".md"))
|
|
116
|
+
continue;
|
|
117
|
+
if (options.signal?.aborted)
|
|
118
|
+
throw options.signal.reason instanceof Error ? options.signal.reason : new Error("asset search cancelled");
|
|
119
|
+
if (Date.now() - startedAt >= timeoutMs)
|
|
120
|
+
break;
|
|
121
|
+
if (fileIndex++ > 0 && fileIndex % 32 === 0) {
|
|
122
|
+
await new Promise((resolveTurn) => setImmediate(resolveTurn));
|
|
123
|
+
if (options.signal?.aborted)
|
|
124
|
+
throw options.signal.reason instanceof Error ? options.signal.reason : new Error("asset search cancelled");
|
|
125
|
+
}
|
|
126
|
+
let text;
|
|
127
|
+
try {
|
|
128
|
+
text = readModelContextFileSync(join(dir, rel), MAX_RECALL_SOURCE_BYTES);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const hay = (rel + "\n" + text).toLowerCase();
|
|
134
|
+
const score = words.filter((word) => hay.includes(word)).length;
|
|
135
|
+
if (!score)
|
|
136
|
+
continue;
|
|
137
|
+
const title = titleOf(text, rel);
|
|
138
|
+
hits.push({ path: abs ? join(dir, rel) : rel, title, snippet: text.slice(0, 800), score, boost: metaBoost(text, title, words) });
|
|
139
|
+
}
|
|
140
|
+
if (Date.now() - startedAt >= timeoutMs || inventory.truncated)
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
hits.sort((a, b) => b.score - a.score || b.boost - a.boost || a.path.length - b.path.length);
|
|
144
|
+
return hits.slice(0, limit).map(({ boost, ...recalled }) => recalled);
|
|
145
|
+
}
|
|
78
146
|
/** Create the assets dir with an example snippet + README. Returns files written. */
|
|
79
147
|
export function scaffoldAssets() {
|
|
80
148
|
const dir = assetsDir();
|
package/dist/runtime.js
CHANGED
|
@@ -2,6 +2,30 @@ export const MIN_NODE_MAJOR = 22;
|
|
|
2
2
|
// Commander 15 (a direct runtime dependency) requires this exact floor. Keep package engines, startup,
|
|
3
3
|
// doctor, and docs aligned so an early Node 22 release gets an upgrade hint before dependencies load.
|
|
4
4
|
export const MIN_NODE_VERSION = "22.12.0";
|
|
5
|
+
/** Node's `os.homedir()` ignores HOME on Windows and prefers USERPROFILE. Git Bash, portable launchers,
|
|
6
|
+
* and hermetic automation conventionally override HOME, so mirror that explicit override before the
|
|
7
|
+
* rest of Hara is imported. This keeps every ~/.hara consumer on the same private root. */
|
|
8
|
+
export function applyPortableHomeEnv(env = process.env, runtimePlatform = process.platform) {
|
|
9
|
+
if (runtimePlatform !== "win32")
|
|
10
|
+
return false;
|
|
11
|
+
const home = normalizePortableWindowsHome(env.HOME ?? "");
|
|
12
|
+
if (!home || env.USERPROFILE === home)
|
|
13
|
+
return false;
|
|
14
|
+
env.USERPROFILE = home;
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
/** Convert the MSYS/Git-Bash forms seen by native Windows Node into native drive/UNC paths. */
|
|
18
|
+
export function normalizePortableWindowsHome(value) {
|
|
19
|
+
const home = value.trim();
|
|
20
|
+
const drive = /^\/([a-zA-Z])(?:\/(.*))?$/u.exec(home);
|
|
21
|
+
if (drive)
|
|
22
|
+
return `${drive[1].toUpperCase()}:\\${(drive[2] ?? "").replace(/\//g, "\\")}`;
|
|
23
|
+
if (/^\/\/[^/]/u.test(home))
|
|
24
|
+
return `\\\\${home.slice(2).replace(/\//g, "\\")}`;
|
|
25
|
+
if (/^[a-zA-Z]:[\\/]/u.test(home))
|
|
26
|
+
return home[0].toUpperCase() + home.slice(1).replace(/\//g, "\\");
|
|
27
|
+
return home;
|
|
28
|
+
}
|
|
5
29
|
function supportedNodeVersion(version) {
|
|
6
30
|
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
|
|
7
31
|
if (!match)
|
package/dist/sandbox.js
CHANGED
|
@@ -8,13 +8,38 @@
|
|
|
8
8
|
// emitted from runShell so every entry point — REPL, -p, org, cron — surfaces it). Only the `bash`
|
|
9
9
|
// shell is sandboxed; hara's own file tools are in-process, explicit, and gated by the approval flow.
|
|
10
10
|
import { spawn, spawnSync } from "node:child_process";
|
|
11
|
+
import { existsSync, statSync } from "node:fs";
|
|
11
12
|
import { platform } from "node:os";
|
|
13
|
+
import { win32 as winPath } from "node:path";
|
|
12
14
|
import { existingSensitiveSeatbeltMasks, sensitiveFilesAllowed, sensitiveShellCommandReason, } from "./security/sensitive-files.js";
|
|
13
15
|
import { terminateSubprocessTree, toolSubprocessEnv } from "./security/subprocess-env.js";
|
|
16
|
+
import { homeWorkspaceActionError, isHomeWorkspace } from "./context/workspace-scope.js";
|
|
14
17
|
// Windows shell resolution. hara (and the model) speak POSIX shell — the agent writes `ls`, `grep`,
|
|
15
18
|
// `cat`, pipes, `&&`. So on Windows we PREFER a real bash (Git Bash or WSL, which most Windows devs
|
|
16
19
|
// have) and only fall back to cmd.exe when none is found. Memoized: the PATH probe runs at most once.
|
|
17
20
|
let _winBash;
|
|
21
|
+
/** Conventional Git-for-Windows installations are not always added to PATH (the installer makes that
|
|
22
|
+
* an explicit choice). Keep candidate construction pure so non-Windows CI can cover it. */
|
|
23
|
+
export function windowsBashCandidates(env = process.env) {
|
|
24
|
+
const roots = [
|
|
25
|
+
env.ProgramFiles,
|
|
26
|
+
env["ProgramFiles(x86)"],
|
|
27
|
+
env.LocalAppData ? winPath.join(env.LocalAppData, "Programs") : undefined,
|
|
28
|
+
"C:\\Program Files",
|
|
29
|
+
"C:\\Program Files (x86)",
|
|
30
|
+
].filter((value) => !!value?.trim());
|
|
31
|
+
return [...new Set(roots.map((root) => winPath.join(root, "Git", "bin", "bash.exe")))];
|
|
32
|
+
}
|
|
33
|
+
export function firstInstalledWindowsBash(candidates, isFile) {
|
|
34
|
+
return candidates.find((path) => {
|
|
35
|
+
try {
|
|
36
|
+
return isFile(path);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}) ?? null;
|
|
42
|
+
}
|
|
18
43
|
function findWindowsBash() {
|
|
19
44
|
if (_winBash !== undefined)
|
|
20
45
|
return _winBash;
|
|
@@ -23,7 +48,7 @@ function findWindowsBash() {
|
|
|
23
48
|
// (a huge PATH, a dead network drive on PATH) hangs hara at startup with nothing able to interrupt it.
|
|
24
49
|
const onPath = spawnSync("where", ["bash"], { encoding: "utf8", timeout: 3000 });
|
|
25
50
|
const hit = onPath.status === 0 ? String(onPath.stdout).split(/\r?\n/).find((l) => l.trim()) : "";
|
|
26
|
-
_winBash = (hit && hit.trim()) ||
|
|
51
|
+
_winBash = (hit && hit.trim()) || firstInstalledWindowsBash(windowsBashCandidates(), (path) => (existsSync(path) && statSync(path).isFile()));
|
|
27
52
|
return _winBash;
|
|
28
53
|
}
|
|
29
54
|
/** Pure shell-argv resolution — split out so the platform branching is unit-testable without spawning.
|
|
@@ -99,6 +124,10 @@ let warnedUnsandboxed = false; // emit the "macOS-only" notice at most once per
|
|
|
99
124
|
/** Build the (sandboxed, when supported) argv for a shell command — shared by runShell + background jobs
|
|
100
125
|
* so the seatbelt write-confinement is identical for both. */
|
|
101
126
|
export function shellCommand(command, cwd, mode) {
|
|
127
|
+
// Shell syntax can recursively traverse arbitrary paths, so unlike explicit read/write tools it is not
|
|
128
|
+
// safe at the Home root. Check before protected-file mask discovery, which itself would have to walk ~/.
|
|
129
|
+
if (isHomeWorkspace(cwd))
|
|
130
|
+
throw new Error(homeWorkspaceActionError("run shell commands"));
|
|
102
131
|
const protectedReason = sensitiveShellCommandReason(command, cwd);
|
|
103
132
|
if (protectedReason) {
|
|
104
133
|
throw new Error(`shell command crosses Hara's protected secret boundary (${protectedReason}). ` +
|
|
@@ -135,6 +164,8 @@ export function maybeWarnUnsandboxed(mode) {
|
|
|
135
164
|
}
|
|
136
165
|
}
|
|
137
166
|
export function runShell(command, cwd, mode, opts) {
|
|
167
|
+
if (opts.signal?.aborted)
|
|
168
|
+
return Promise.reject(new Error("interrupted before command start"));
|
|
138
169
|
const { cmd, args } = shellCommand(command, cwd, mode);
|
|
139
170
|
return new Promise((resolve, reject) => {
|
|
140
171
|
// Non-interactive by contract: there is no terminal to answer a credential prompt, so a git
|
|
@@ -144,6 +175,7 @@ export function runShell(command, cwd, mode, opts) {
|
|
|
144
175
|
const env = toolSubprocessEnv(process.env, {
|
|
145
176
|
GIT_TERMINAL_PROMPT: process.env.GIT_TERMINAL_PROMPT ?? "0",
|
|
146
177
|
GCM_INTERACTIVE: process.env.GCM_INTERACTIVE ?? "never",
|
|
178
|
+
...(opts.env ?? {}),
|
|
147
179
|
});
|
|
148
180
|
// A dedicated POSIX process group lets timeout/output-cap termination reach grandchildren (shell →
|
|
149
181
|
// node/npm → worker). Windows uses taskkill /T in terminateSubprocessTree instead.
|
|
@@ -152,6 +184,7 @@ export function runShell(command, cwd, mode, opts) {
|
|
|
152
184
|
let stdout = "";
|
|
153
185
|
let stderr = "";
|
|
154
186
|
let timedOut = false;
|
|
187
|
+
let aborted = false;
|
|
155
188
|
let killedForSize = false;
|
|
156
189
|
let done = false;
|
|
157
190
|
let receivedBytes = 0;
|
|
@@ -194,6 +227,7 @@ export function runShell(command, cwd, mode, opts) {
|
|
|
194
227
|
return;
|
|
195
228
|
done = true;
|
|
196
229
|
clearTimeout(timer);
|
|
230
|
+
opts.signal?.removeEventListener("abort", abortRun);
|
|
197
231
|
cancelTermination?.();
|
|
198
232
|
if (error) {
|
|
199
233
|
reject(Object.assign(error, { stdout, stderr }));
|
|
@@ -205,6 +239,9 @@ export function runShell(command, cwd, mode, opts) {
|
|
|
205
239
|
else if (timedOut) {
|
|
206
240
|
reject(Object.assign(new Error(`timed out after ${opts.timeout}ms`), { stdout, stderr }));
|
|
207
241
|
}
|
|
242
|
+
else if (aborted) {
|
|
243
|
+
reject(Object.assign(new Error("interrupted by agent run deadline or cancellation"), { stdout, stderr }));
|
|
244
|
+
}
|
|
208
245
|
else if (code !== 0) {
|
|
209
246
|
reject(Object.assign(new Error(`exit code ${code}`), { stdout, stderr, code }));
|
|
210
247
|
}
|
|
@@ -216,6 +253,19 @@ export function runShell(command, cwd, mode, opts) {
|
|
|
216
253
|
timedOut = true;
|
|
217
254
|
stopTree();
|
|
218
255
|
}, opts.timeout);
|
|
256
|
+
const abortRun = () => {
|
|
257
|
+
if (done || timedOut || killedForSize || aborted)
|
|
258
|
+
return;
|
|
259
|
+
aborted = true;
|
|
260
|
+
stopTree();
|
|
261
|
+
};
|
|
262
|
+
opts.signal?.addEventListener("abort", abortRun, { once: true });
|
|
263
|
+
if (opts.signal?.aborted)
|
|
264
|
+
abortRun();
|
|
265
|
+
// A hook may exit successfully without reading stdin. Swallow the resulting benign EPIPE and let the
|
|
266
|
+
// child's authoritative close status settle the operation, matching spawnSync's historical behavior.
|
|
267
|
+
child.stdin.on("error", () => { });
|
|
268
|
+
child.stdin.end(opts.input);
|
|
219
269
|
// Kill a runaway command once its total output passes maxBuffer — don't let it stream GBs to the UI
|
|
220
270
|
// until the timeout just because we stopped retaining the bytes.
|
|
221
271
|
const checkOverflow = () => {
|
|
@@ -225,7 +275,7 @@ export function runShell(command, cwd, mode, opts) {
|
|
|
225
275
|
}
|
|
226
276
|
};
|
|
227
277
|
child.stdout.on("data", (d) => {
|
|
228
|
-
if (done || timedOut || killedForSize)
|
|
278
|
+
if (done || timedOut || killedForSize || aborted)
|
|
229
279
|
return;
|
|
230
280
|
const s = d.toString();
|
|
231
281
|
receivedBytes += d.length;
|
|
@@ -235,7 +285,7 @@ export function runShell(command, cwd, mode, opts) {
|
|
|
235
285
|
checkOverflow();
|
|
236
286
|
});
|
|
237
287
|
child.stderr.on("data", (d) => {
|
|
238
|
-
if (done || timedOut || killedForSize)
|
|
288
|
+
if (done || timedOut || killedForSize || aborted)
|
|
239
289
|
return;
|
|
240
290
|
const s = d.toString();
|
|
241
291
|
receivedBytes += d.length;
|
|
@@ -248,7 +298,7 @@ export function runShell(command, cwd, mode, opts) {
|
|
|
248
298
|
settle(null, e);
|
|
249
299
|
});
|
|
250
300
|
child.on("close", (code) => {
|
|
251
|
-
if ((timedOut || killedForSize) && !forceIssued) {
|
|
301
|
+
if ((timedOut || killedForSize || aborted) && !forceIssued) {
|
|
252
302
|
closeBeforeForce = true;
|
|
253
303
|
closeBeforeForceCode = code;
|
|
254
304
|
return;
|
package/dist/search/embed.js
CHANGED
|
@@ -3,6 +3,11 @@ const DEFAULT_MODEL = {
|
|
|
3
3
|
qwen: "text-embedding-v3",
|
|
4
4
|
openai: "text-embedding-3-small",
|
|
5
5
|
};
|
|
6
|
+
const EMBED_TIMEOUT_MS = 30_000;
|
|
7
|
+
const embedSignal = (parent) => {
|
|
8
|
+
const timeout = AbortSignal.timeout(EMBED_TIMEOUT_MS);
|
|
9
|
+
return parent ? AbortSignal.any([parent, timeout]) : timeout;
|
|
10
|
+
};
|
|
6
11
|
/** Build an Embedder from config, or null if embeddings are off/unconfigured (→ lexical fallback). */
|
|
7
12
|
export function getEmbedder(cfg) {
|
|
8
13
|
const provider = cfg.embedProvider;
|
|
@@ -11,13 +16,16 @@ export function getEmbedder(cfg) {
|
|
|
11
16
|
const model = cfg.embedModel || DEFAULT_MODEL[provider] || "embed";
|
|
12
17
|
if (provider === "ollama") {
|
|
13
18
|
const base = (cfg.embedBaseURL || "http://localhost:11434").replace(/\/$/, "");
|
|
14
|
-
return async (texts) => {
|
|
19
|
+
return async (texts, signal) => {
|
|
15
20
|
const out = [];
|
|
16
21
|
for (const input of texts) {
|
|
22
|
+
if (signal?.aborted)
|
|
23
|
+
throw new Error("embedding request interrupted");
|
|
17
24
|
const r = await fetch(`${base}/api/embeddings`, {
|
|
18
25
|
method: "POST",
|
|
19
26
|
headers: { "content-type": "application/json" },
|
|
20
27
|
body: JSON.stringify({ model, prompt: input }),
|
|
28
|
+
signal: embedSignal(signal),
|
|
21
29
|
});
|
|
22
30
|
if (!r.ok)
|
|
23
31
|
throw new Error(`ollama embeddings ${r.status}`);
|
|
@@ -29,11 +37,14 @@ export function getEmbedder(cfg) {
|
|
|
29
37
|
// qwen (DashScope compatible-mode) + any OpenAI-compatible endpoint: POST /embeddings { model, input[] }
|
|
30
38
|
const base = (cfg.embedBaseURL || (provider === "qwen" ? "https://dashscope.aliyuncs.com/compatible-mode/v1" : cfg.baseURL || "https://api.openai.com/v1")).replace(/\/$/, "");
|
|
31
39
|
const key = cfg.embedApiKey || cfg.apiKey || "";
|
|
32
|
-
return async (texts) => {
|
|
40
|
+
return async (texts, signal) => {
|
|
41
|
+
if (signal?.aborted)
|
|
42
|
+
throw new Error("embedding request interrupted");
|
|
33
43
|
const r = await fetch(`${base}/embeddings`, {
|
|
34
44
|
method: "POST",
|
|
35
45
|
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
|
|
36
46
|
body: JSON.stringify({ model, input: texts }),
|
|
47
|
+
signal: embedSignal(signal),
|
|
37
48
|
});
|
|
38
49
|
if (!r.ok)
|
|
39
50
|
throw new Error(`embeddings ${r.status}`);
|
package/dist/search/hybrid.js
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
// Hybrid search — lexical (always) blended with semantic (when an index + embedder are configured).
|
|
2
2
|
// One entry point for `recall` and `memory_search`: semantic hits lead (more relevant), lexical fills the
|
|
3
3
|
// rest, deduped by path. With no index/embedder it's exactly the lexical result — zero behaviour change.
|
|
4
|
-
import {
|
|
4
|
+
import { searchAssetsAsync, titleOf } from "../recall.js";
|
|
5
5
|
import { loadConfig } from "../config.js";
|
|
6
6
|
import { getEmbedder } from "./embed.js";
|
|
7
7
|
import { queryIndex, indexExists } from "./semindex.js";
|
|
8
8
|
export async function searchHybrid(query, cwd, opts) {
|
|
9
9
|
const limit = opts.limit ?? 5;
|
|
10
|
-
const lex =
|
|
10
|
+
const lex = await searchAssetsAsync(query, limit, opts.roots, { signal: opts.signal, timeoutMs: opts.timeoutMs });
|
|
11
11
|
const embed = getEmbedder(loadConfig());
|
|
12
12
|
if (!embed || !indexExists(opts.indexName, cwd))
|
|
13
13
|
return lex;
|
|
14
14
|
let sem;
|
|
15
15
|
try {
|
|
16
|
-
sem = await queryIndex(opts.indexName, query, embed, cwd, limit);
|
|
16
|
+
sem = await queryIndex(opts.indexName, query, embed, cwd, limit, opts.signal);
|
|
17
17
|
}
|
|
18
18
|
catch {
|
|
19
|
+
if (opts.signal?.aborted) {
|
|
20
|
+
throw opts.signal.reason instanceof Error ? opts.signal.reason : new Error("hybrid search cancelled");
|
|
21
|
+
}
|
|
19
22
|
return lex; // embedding endpoint down → degrade to lexical
|
|
20
23
|
}
|
|
21
24
|
const out = [];
|