@nanhara/hara 0.122.3 → 0.122.5
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 +117 -0
- package/README.md +15 -2
- package/SECURITY.md +4 -0
- 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 +28 -14
- 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/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/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/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/web.js +43 -13
- package/dist/tui/App.js +93 -25
- package/dist/vision.js +5 -6
- package/package.json +3 -3
- package/runtime-bootstrap.cjs +22 -0
|
@@ -18,34 +18,71 @@ function scopedSeen(scope) {
|
|
|
18
18
|
* so a space separator is unambiguous). */
|
|
19
19
|
export function keyOf(name, input) {
|
|
20
20
|
try {
|
|
21
|
-
|
|
21
|
+
const seen = new WeakSet();
|
|
22
|
+
const canonical = (value) => {
|
|
23
|
+
if (!value || typeof value !== "object")
|
|
24
|
+
return value;
|
|
25
|
+
if (seen.has(value))
|
|
26
|
+
throw new TypeError("circular tool input");
|
|
27
|
+
seen.add(value);
|
|
28
|
+
try {
|
|
29
|
+
if (Array.isArray(value))
|
|
30
|
+
return value.map(canonical);
|
|
31
|
+
const out = {};
|
|
32
|
+
for (const key of Object.keys(value).sort()) {
|
|
33
|
+
const next = value[key];
|
|
34
|
+
if (next !== undefined && typeof next !== "function" && typeof next !== "symbol")
|
|
35
|
+
out[key] = canonical(next);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
seen.delete(value);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
return name + " " + JSON.stringify(canonical(input ?? {}));
|
|
22
44
|
}
|
|
23
45
|
catch {
|
|
24
46
|
return name + " <unserializable>";
|
|
25
47
|
}
|
|
26
48
|
}
|
|
27
49
|
/** Does a tool RESULT string look like a failure? hara tools report failures as ordinary strings
|
|
28
|
-
* (bash -> "Command failed: ...", file tools -> "Error: ...",
|
|
29
|
-
* so the loop's isError flag alone misses them.
|
|
30
|
-
|
|
31
|
-
|
|
50
|
+
* (bash -> "Command failed: ...", file tools -> "Error: ...", safety gates -> "Blocked: ..."),
|
|
51
|
+
* so the loop's isError flag alone misses them. Tool-specific shapes are intentionally keyed by `name`:
|
|
52
|
+
* a read_file/web_fetch result can legitimately begin with prose such as "Search failed ..." and must not
|
|
53
|
+
* be mistaken for the web_search tool's own diagnostic. Pure — exported for tests. */
|
|
54
|
+
export function looksFailed(content, name) {
|
|
55
|
+
const text = content.trimStart();
|
|
56
|
+
if (/^(Command failed|Error:|Failed:|Blocked:|Skipped without running)/.test(text))
|
|
57
|
+
return true;
|
|
58
|
+
if (name === "web_search")
|
|
59
|
+
return /^Search failed across available providers\b/.test(text);
|
|
60
|
+
if (name === "external_agent") {
|
|
61
|
+
return /^(?:external_agent is disabled\b|Unknown backend\b|'[^'\r\n]+' CLI not found\b|\[[^\]\r\n]+\]\s+failed\b|\[[^\]\r\n]+\s+exit\s+(?!0\b)[^\]\r\n]+\])/.test(text);
|
|
62
|
+
}
|
|
63
|
+
if (name === "cronjob" || name === "cron")
|
|
64
|
+
return /^✗\s+\S+\s+failed\s*:/.test(text);
|
|
65
|
+
if (name === "computer") {
|
|
66
|
+
return /^(?:Refused:|Screen control is off\.|No apps allowlisted\b|Grounding needs a vision model\b|'[^'\r\n]+' needs a higher tier\b|(?:activate|find|click\/move) needs\b|⛔ Stopping screen control\b)/.test(text)
|
|
67
|
+
|| /^Screenshot saved\b[\s\S]*\bConfigure a vision model\b/.test(text);
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
32
70
|
}
|
|
33
71
|
/** Record a completed call; returns a warning to APPEND to the tool result when the same call has now
|
|
34
72
|
* failed >=2x in a row (empty string otherwise). Pure aside from the session-scoped map. */
|
|
35
73
|
export function recordCall(name, input, content, isError = false, scope) {
|
|
36
74
|
const k = keyOf(name, input);
|
|
37
|
-
const failed = isError || looksFailed(content);
|
|
75
|
+
const failed = isError || looksFailed(content, name);
|
|
38
76
|
const seen = scopedSeen(scope);
|
|
39
|
-
const s = seen.get(k) ?? { fails: 0 };
|
|
40
77
|
if (!failed) {
|
|
41
|
-
seen.
|
|
78
|
+
seen.clear(); // any success is progress; a later failure starts a fresh no-progress streak
|
|
42
79
|
return "";
|
|
43
80
|
}
|
|
81
|
+
const s = seen.get(k) ?? { fails: 0 };
|
|
44
82
|
s.fails++;
|
|
45
|
-
|
|
46
|
-
seen.
|
|
47
|
-
|
|
48
|
-
seen.delete(seen.keys().next().value);
|
|
83
|
+
// "In a row" is literal: a different failed call is a changed attempt and breaks the old streak.
|
|
84
|
+
seen.clear();
|
|
85
|
+
seen.set(k, s);
|
|
49
86
|
if (s.fails < 2)
|
|
50
87
|
return "";
|
|
51
88
|
return (`\n\n⟳ hara: this exact ${name} call has now FAILED ${s.fails}× with identical arguments — ` +
|
package/dist/checkpoints.js
CHANGED
|
@@ -10,6 +10,7 @@ import { dirname, join } from "node:path";
|
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { createHash } from "node:crypto";
|
|
12
12
|
import { findProjectRoot } from "./context/agents-md.js";
|
|
13
|
+
import { isHomeWorkspace } from "./context/workspace-scope.js";
|
|
13
14
|
import { toolSubprocessEnv } from "./security/subprocess-env.js";
|
|
14
15
|
import { sensitiveFileReason } from "./security/sensitive-files.js";
|
|
15
16
|
import { redactSensitiveText } from "./security/secrets.js";
|
|
@@ -104,6 +105,10 @@ function dropSensitiveIndexEntries(root, gitDir) {
|
|
|
104
105
|
}
|
|
105
106
|
/** Snapshot the current working tree into the shadow repo. Returns the short sha, or null on failure. */
|
|
106
107
|
export function checkpoint(cwd, label) {
|
|
108
|
+
// A shadow `git add -A` at ~/ would traverse the user's entire personal/control-data scope. Home is
|
|
109
|
+
// deliberately not an implicit project, including when reached through a symlink alias.
|
|
110
|
+
if (isHomeWorkspace(cwd))
|
|
111
|
+
return null;
|
|
107
112
|
const root = findProjectRoot(cwd);
|
|
108
113
|
const gitDir = shadowGitDir(root);
|
|
109
114
|
if (!ensureRepo(root, gitDir))
|
|
@@ -133,6 +138,8 @@ export function checkpoint(cwd, label) {
|
|
|
133
138
|
}
|
|
134
139
|
/** Recent checkpoints, newest first. */
|
|
135
140
|
export function listCheckpoints(cwd, n = 15) {
|
|
141
|
+
if (isHomeWorkspace(cwd))
|
|
142
|
+
return [];
|
|
136
143
|
const root = findProjectRoot(cwd);
|
|
137
144
|
const gitDir = shadowGitDir(root);
|
|
138
145
|
if (!ensureRepo(root, gitDir) || !existsSync(join(gitDir, "HEAD")))
|
|
@@ -156,6 +163,8 @@ export function listCheckpoints(cwd, n = 15) {
|
|
|
156
163
|
export function restoreCheckpoint(cwd, ref) {
|
|
157
164
|
if (!/^[0-9a-f]{4,64}$/i.test(ref))
|
|
158
165
|
return null; // checkpoint refs are hashes; reject option/ref injection
|
|
166
|
+
if (isHomeWorkspace(cwd))
|
|
167
|
+
return null;
|
|
159
168
|
const root = findProjectRoot(cwd);
|
|
160
169
|
const gitDir = shadowGitDir(root);
|
|
161
170
|
if (!ensureRepo(root, gitDir) || !existsSync(join(gitDir, "HEAD")))
|
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Keep the runtime gate dependency-free and ahead of the main CLI import. Older Node releases must see an
|
|
3
3
|
// actionable upgrade message even if a future dependency starts using syntax or APIs they cannot load.
|
|
4
|
-
import { unsupportedNodeMessage } from "./runtime.js";
|
|
4
|
+
import { applyPortableHomeEnv, unsupportedNodeMessage } from "./runtime.js";
|
|
5
5
|
const runtimeError = unsupportedNodeMessage();
|
|
6
6
|
if (runtimeError) {
|
|
7
7
|
process.stderr.write(`${runtimeError}\n`);
|
|
8
8
|
process.exitCode = 1;
|
|
9
9
|
}
|
|
10
10
|
else {
|
|
11
|
+
applyPortableHomeEnv();
|
|
11
12
|
void import("./index.js").catch((error) => {
|
|
12
13
|
const message = error instanceof Error ? error.message : String(error);
|
|
13
14
|
process.stderr.write(`hara: failed to start: ${message}\n`);
|
package/dist/config.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join, dirname, resolve } from "node:path";
|
|
3
3
|
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { agentMaxRounds, agentRunTimeoutMs } from "./agent/limits.js";
|
|
4
5
|
import { ensurePrivateHaraState } from "./security/private-state.js";
|
|
5
6
|
import { readVerifiedRegularFileSnapshotSync } from "./fs-read.js";
|
|
6
7
|
import { projectRepositoryTrustedAtStartup } from "./security/project-trust.js";
|
|
8
|
+
import { isHomeWorkspace } from "./context/workspace-scope.js";
|
|
7
9
|
const PROVIDER_DEFAULTS = {
|
|
8
10
|
anthropic: { model: "claude-opus-4-8", envKey: "ANTHROPIC_API_KEY" },
|
|
9
11
|
qwen: {
|
|
@@ -33,7 +35,7 @@ const PROVIDER_DEFAULTS = {
|
|
|
33
35
|
},
|
|
34
36
|
"hara-gateway": { model: "", envKey: "HARA_GATEWAY_TOKEN" }, // B-end: enrolled device → token in ~/.hara/org.json, routed by the gateway
|
|
35
37
|
};
|
|
36
|
-
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", "guardian", "notify", "vimMode", "autoCompact", "fileCheckpoints", "updateCheck", "fallbackModel", "fallbackProvider", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
|
|
38
|
+
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", "guardian", "notify", "runTimeoutMs", "maxAgentRounds", "vimMode", "autoCompact", "fileCheckpoints", "updateCheck", "fallbackModel", "fallbackProvider", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
|
|
37
39
|
export const REASONING_EFFORTS = ["off", "low", "medium", "high", "max"];
|
|
38
40
|
export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
|
|
39
41
|
export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
|
|
@@ -159,6 +161,10 @@ function readProjectConfig(cwd) {
|
|
|
159
161
|
dir = resolve(cwd);
|
|
160
162
|
}
|
|
161
163
|
for (;;) {
|
|
164
|
+
// ~/.hara/config.json is the global control-plane file already loaded by loadConfig(). Never read it
|
|
165
|
+
// a second time as repository input, and never climb through Home into a parent repository.
|
|
166
|
+
if (isHomeWorkspace(dir))
|
|
167
|
+
break;
|
|
162
168
|
const hara = join(dir, ".hara");
|
|
163
169
|
const p = join(hara, "config.json");
|
|
164
170
|
let haraInfo;
|
|
@@ -320,6 +326,8 @@ export function loadConfig(opts = {}) {
|
|
|
320
326
|
const guardianRaw = process.env.HARA_GUARDIAN ?? merged.guardian;
|
|
321
327
|
const guardian = guardianRaw === "0" || guardianRaw === "off" || guardianRaw === "false" ? "off" : "on";
|
|
322
328
|
const notify = (process.env.HARA_NOTIFY ?? merged.notify ?? "off");
|
|
329
|
+
const runTimeoutMs = agentRunTimeoutMs(process.env.HARA_RUN_TIMEOUT_MS ?? merged.runTimeoutMs);
|
|
330
|
+
const maxAgentRounds = agentMaxRounds(process.env.HARA_MAX_AGENT_ROUNDS ?? merged.maxAgentRounds);
|
|
323
331
|
const vimMode = process.env.HARA_VIM === "1" || merged.vimMode === true || merged.vimMode === "true";
|
|
324
332
|
const autoCompact = !(process.env.HARA_AUTO_COMPACT === "0" || merged.autoCompact === false || merged.autoCompact === "false"); // default ON
|
|
325
333
|
const fileCheckpoints = !(process.env.HARA_CHECKPOINTS === "0" || merged.fileCheckpoints === false || merged.fileCheckpoints === "false"); // default ON
|
|
@@ -332,7 +340,7 @@ export function loadConfig(opts = {}) {
|
|
|
332
340
|
const reasoningEffort = reasoningRaw && ["off", "low", "medium", "high", "max"].includes(reasoningRaw)
|
|
333
341
|
? reasoningRaw
|
|
334
342
|
: undefined;
|
|
335
|
-
return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, guardian, hooks, notify, vimMode, autoCompact, fileCheckpoints, updateCheck, fallbackModel, fallbackProvider, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: effectiveCwd };
|
|
343
|
+
return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, guardian, hooks, notify, runTimeoutMs, maxAgentRounds, vimMode, autoCompact, fileCheckpoints, updateCheck, fallbackModel, fallbackProvider, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: effectiveCwd };
|
|
336
344
|
}
|
|
337
345
|
export function providerEnvKey(provider) {
|
|
338
346
|
return (PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic).envKey;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { join, dirname, resolve } from "node:path";
|
|
5
5
|
import { readModelContextBytePrefixSync } from "../fs-read.js";
|
|
6
|
+
import { homeWorkspaceGuidance, isHomeWorkspace } from "./workspace-scope.js";
|
|
6
7
|
const FILENAMES = ["AGENTS.override.md", "AGENTS.md"];
|
|
7
8
|
const ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
|
|
8
9
|
const MAX_BYTES = 32 * 1024;
|
|
@@ -32,13 +33,23 @@ function markBudgetTruncation(value) {
|
|
|
32
33
|
return utf8Prefix(value, MAX_BYTES - byteLength(TRUNCATED)) + TRUNCATED;
|
|
33
34
|
}
|
|
34
35
|
export function findProjectRoot(cwd) {
|
|
35
|
-
|
|
36
|
+
const start = resolve(cwd);
|
|
37
|
+
// Home is a control/personal-data scope even when it happens to contain a package.json/.git marker.
|
|
38
|
+
// Resolve this before the marker check so `hara` launched at ~/ cannot inherit a parent repository
|
|
39
|
+
// through a symlinked/nested HOME used by managed development environments.
|
|
40
|
+
if (isHomeWorkspace(start))
|
|
41
|
+
return start;
|
|
42
|
+
let dir = start;
|
|
36
43
|
for (;;) {
|
|
44
|
+
// A marker accidentally placed at ~/ (for example a personal package.json) must not make every
|
|
45
|
+
// unmarked child directory inherit the entire home as its project root. Explicit child scope stays local.
|
|
46
|
+
if (dir !== start && isHomeWorkspace(dir))
|
|
47
|
+
return start;
|
|
37
48
|
if (ROOT_MARKERS.some((m) => existsSync(join(dir, m))))
|
|
38
49
|
return dir;
|
|
39
50
|
const parent = dirname(dir);
|
|
40
51
|
if (parent === dir)
|
|
41
|
-
return
|
|
52
|
+
return start; // no marker found → treat cwd as root
|
|
42
53
|
dir = parent;
|
|
43
54
|
}
|
|
44
55
|
}
|
|
@@ -87,6 +98,14 @@ export function loadAgentsMd(cwd) {
|
|
|
87
98
|
}
|
|
88
99
|
return combined;
|
|
89
100
|
}
|
|
101
|
+
/** Model context is project AGENTS.md plus a built-in scope note when cwd is the user's home. Keeping the
|
|
102
|
+
* built-in note separate from loadAgentsMd() preserves that function's file-loading semantics and lets the
|
|
103
|
+
* UI accurately say whether an AGENTS.md file was actually loaded. */
|
|
104
|
+
export function loadAgentContext(cwd) {
|
|
105
|
+
const guidance = homeWorkspaceGuidance(cwd);
|
|
106
|
+
const agents = loadAgentsMd(cwd);
|
|
107
|
+
return [guidance, agents].filter(Boolean).join("\n\n--- workspace-context ---\n\n");
|
|
108
|
+
}
|
|
90
109
|
export function hasAgentsMd(cwd) {
|
|
91
110
|
const root = findProjectRoot(cwd);
|
|
92
111
|
return FILENAMES.some((n) => existsSync(join(root, n)));
|
package/dist/context/mentions.js
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
// and provide fuzzy file candidates for REPL tab-completion.
|
|
3
3
|
import { existsSync, lstatSync } from "node:fs";
|
|
4
4
|
import { isAbsolute, resolve } from "node:path";
|
|
5
|
-
import { listProjectFiles, dirPrefixes, walkFiles } from "../fs-walk.js";
|
|
5
|
+
import { listProjectFiles, dirPrefixes, walkFiles, walkFilesAsync } from "../fs-walk.js";
|
|
6
6
|
import { fuzzyRank } from "../fuzzy.js";
|
|
7
7
|
import { mediaTypeFor } from "../images.js";
|
|
8
8
|
import { readModelContextPrefixSync } from "../fs-read.js";
|
|
9
9
|
import { sensitiveFileError } from "../security/sensitive-files.js";
|
|
10
|
+
import { recursiveRootContainsHome, recursiveHomeSearchError } from "./workspace-scope.js";
|
|
10
11
|
const MAX_FILE = 50_000;
|
|
11
12
|
// @ at start-of-string or after whitespace; capture a path with no spaces/@ (avoids emails like a@b.com)
|
|
12
13
|
const MENTION_RE = /(?:^|\s)@([^\s@]+)/g;
|
|
@@ -44,6 +45,86 @@ export function expandMentions(input, cwd) {
|
|
|
44
45
|
return `${prefix}${block}`;
|
|
45
46
|
});
|
|
46
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Interruptible production variant. In particular, `@dir` uses the streaming async walker so mention
|
|
50
|
+
* expansion cannot block the event loop before an agent run's own deadline starts.
|
|
51
|
+
*/
|
|
52
|
+
export async function expandMentionsAsync(input, cwd, options = {}) {
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
const mentionRe = new RegExp(MENTION_RE.source, MENTION_RE.flags);
|
|
55
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(0, Math.floor(options.timeoutMs)) : 2_000;
|
|
56
|
+
const startedAt = Date.now();
|
|
57
|
+
let output = "";
|
|
58
|
+
let copiedThrough = 0;
|
|
59
|
+
let match;
|
|
60
|
+
while ((match = mentionRe.exec(input)) !== null) {
|
|
61
|
+
if (options.signal?.aborted) {
|
|
62
|
+
throw options.signal.reason instanceof Error ? options.signal.reason : new Error("mention expansion cancelled");
|
|
63
|
+
}
|
|
64
|
+
output += input.slice(copiedThrough, match.index);
|
|
65
|
+
copiedThrough = match.index + match[0].length;
|
|
66
|
+
const whole = match[0];
|
|
67
|
+
const ref = match[1];
|
|
68
|
+
const prefix = whole.slice(0, whole.length - ref.length - 1);
|
|
69
|
+
if (seen.has(ref)) {
|
|
70
|
+
output += whole;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const remainingMs = timeoutMs - (Date.now() - startedAt);
|
|
74
|
+
if (remainingMs <= 0) {
|
|
75
|
+
output += `${prefix}\nReferenced \`${ref}\` was not inserted because the ${timeoutMs}ms attachment budget was exhausted.\n`;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const block = await expandRefAsync(ref, cwd, {
|
|
79
|
+
...options,
|
|
80
|
+
timeoutMs: remainingMs,
|
|
81
|
+
maxFiles: Math.min(options.maxFiles ?? 300, 300),
|
|
82
|
+
});
|
|
83
|
+
if (block === null)
|
|
84
|
+
output += whole;
|
|
85
|
+
else {
|
|
86
|
+
seen.add(ref);
|
|
87
|
+
output += `${prefix}${block}`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return output + input.slice(copiedThrough);
|
|
91
|
+
}
|
|
92
|
+
async function expandRefAsync(ref, cwd, options) {
|
|
93
|
+
const abs = isAbsolute(ref) ? ref : resolve(cwd, ref);
|
|
94
|
+
const denied = sensitiveFileError(abs, "attach");
|
|
95
|
+
if (denied)
|
|
96
|
+
return `\nProtected file \`${ref}\` was not inserted into model context. ${denied}\n`;
|
|
97
|
+
try {
|
|
98
|
+
const st = lstatSync(abs);
|
|
99
|
+
if (st.isSymbolicLink())
|
|
100
|
+
return `Referenced \`${ref}\` is a symbolic link — it was not inserted into model context.`;
|
|
101
|
+
if (st.isFile()) {
|
|
102
|
+
if (mediaTypeFor(abs))
|
|
103
|
+
return `Referenced \`${ref}\` is an image — paste it with Ctrl+V to attach it visually.`;
|
|
104
|
+
const prefix = readModelContextPrefixSync(abs, MAX_FILE);
|
|
105
|
+
if (prefix.binary)
|
|
106
|
+
return `Referenced \`${ref}\` appears to be binary — it was not inserted into the model context.`;
|
|
107
|
+
const txt = prefix.text + (prefix.truncated ? "\n…[truncated]" : "");
|
|
108
|
+
return `\nReferenced file \`${ref}\`:\n\`\`\`\n${txt}\n\`\`\`\n`;
|
|
109
|
+
}
|
|
110
|
+
if (st.isDirectory()) {
|
|
111
|
+
if (recursiveRootContainsHome(abs))
|
|
112
|
+
return recursiveHomeSearchError("directory attachment");
|
|
113
|
+
const inventory = await walkFilesAsync(abs, options);
|
|
114
|
+
const bounded = inventory.truncated
|
|
115
|
+
? `; listing stopped at its ${inventory.reason?.replace("_", " ") ?? "safety limit"}`
|
|
116
|
+
: "";
|
|
117
|
+
return `\nReferenced directory \`${ref}\` (${inventory.files.length} files${bounded}):\n\`\`\`\n${inventory.files.join("\n") || "(empty)"}\n\`\`\`\n`;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (options.signal?.aborted) {
|
|
122
|
+
throw options.signal.reason instanceof Error ? options.signal.reason : new Error("mention expansion cancelled");
|
|
123
|
+
}
|
|
124
|
+
/* ignore unreadable mention */
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
47
128
|
/** Render one mention as an inline block, or null if it isn't a readable file/dir. */
|
|
48
129
|
function expandRef(ref, cwd) {
|
|
49
130
|
const abs = isAbsolute(ref) ? ref : resolve(cwd, ref);
|
|
@@ -67,6 +148,8 @@ function expandRef(ref, cwd) {
|
|
|
67
148
|
return `\nReferenced file \`${ref}\`:\n\`\`\`\n${txt}\n\`\`\`\n`;
|
|
68
149
|
}
|
|
69
150
|
if (st.isDirectory()) {
|
|
151
|
+
if (recursiveRootContainsHome(abs))
|
|
152
|
+
return recursiveHomeSearchError("directory attachment");
|
|
70
153
|
// `@dir` loads a listing of the directory's files (the agent can then read specific ones)
|
|
71
154
|
const files = walkFiles(abs, 300);
|
|
72
155
|
return `\nReferenced directory \`${ref}\` (${files.length} files):\n\`\`\`\n${files.join("\n") || "(empty)"}\n\`\`\`\n`;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
/** Resolve existing paths through symlinks before comparing security/workspace scopes. Falling back to
|
|
5
|
+
* resolve() keeps messages deterministic for a path that disappears during startup; normal cwd/home paths
|
|
6
|
+
* exist and therefore take the realpath branch. */
|
|
7
|
+
export function canonicalWorkspacePath(path) {
|
|
8
|
+
const absolute = resolve(path);
|
|
9
|
+
try {
|
|
10
|
+
return realpathSync.native(absolute);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return absolute;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** The user's home directory is a control/personal-data scope, not an implicit project workspace. */
|
|
17
|
+
export function isHomeWorkspace(cwd, home = homedir()) {
|
|
18
|
+
return canonicalWorkspacePath(cwd) === canonicalWorkspacePath(home);
|
|
19
|
+
}
|
|
20
|
+
/** A recursive root is unsafe when it is Home itself OR an ancestor that would descend into Home. This
|
|
21
|
+
* closes `path: ".."`, filesystem-root, and symlink-alias bypasses while keeping an explicitly selected
|
|
22
|
+
* project child under Home usable. */
|
|
23
|
+
export function recursiveRootContainsHome(root, home = homedir()) {
|
|
24
|
+
const canonicalRoot = canonicalWorkspacePath(root);
|
|
25
|
+
const canonicalHome = canonicalWorkspacePath(home);
|
|
26
|
+
const rel = relative(canonicalRoot, canonicalHome);
|
|
27
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
28
|
+
}
|
|
29
|
+
export function homeWorkspaceActionError(action) {
|
|
30
|
+
return (`Refusing to ${action} from the home directory: it is not an implicit project workspace. ` +
|
|
31
|
+
"Run `cd /path/to/project` first.");
|
|
32
|
+
}
|
|
33
|
+
export function recursiveHomeSearchError(tool) {
|
|
34
|
+
return (`Error: ${tool} will not recursively scan the home directory. ` +
|
|
35
|
+
"Run Hara from a project (`cd /path/to/project`) or set `path` to a specific file/subdirectory. " +
|
|
36
|
+
"Non-recursive `ls` and explicit file reads remain available.");
|
|
37
|
+
}
|
|
38
|
+
/** Injected into model context when Hara was intentionally launched at ~/. Runtime checks enforce the same
|
|
39
|
+
* policy, but guidance avoids wasting turns on calls that are guaranteed to be rejected. */
|
|
40
|
+
export function homeWorkspaceGuidance(cwd) {
|
|
41
|
+
if (!isHomeWorkspace(cwd))
|
|
42
|
+
return "";
|
|
43
|
+
return ("# Home-directory workspace boundary\n" +
|
|
44
|
+
"The working directory resolves to the user's home directory, which is not an implicit project. " +
|
|
45
|
+
"Do not initialize a project, build a repository index, run shell/external executable tools, or recursively " +
|
|
46
|
+
"grep/glob/search the home root. " +
|
|
47
|
+
"Ask the user to run `cd /path/to/project` for project work. Non-recursive `ls`, explicitly named files, " +
|
|
48
|
+
"and explicitly named child directories remain available.");
|
|
49
|
+
}
|
package/dist/cron/deliver.js
CHANGED
|
@@ -7,21 +7,36 @@
|
|
|
7
7
|
// weixin:<peerId> (sends over stored ~/.hara/weixin creds — explicit peer required; guessing the
|
|
8
8
|
// "owner" from a multi-DM context-token cache can deliver private results to the wrong person)
|
|
9
9
|
// Adapters are imported LAZILY so the (heavy) SDKs never load unless a job actually delivers.
|
|
10
|
+
import { outboundTransferTimeoutMs, withOutboundDeadline } from "../gateway/telegram.js";
|
|
10
11
|
/** Parse a `--deliver` spec; error string on anything unsupported (listing what IS supported). */
|
|
11
12
|
export function parseDeliver(spec) {
|
|
12
13
|
const i = spec.indexOf(":");
|
|
13
14
|
if (i <= 0)
|
|
14
|
-
return { error:
|
|
15
|
+
return { error: "bad deliver spec — use telegram:<chatId>, feishu:<chatId>, weixin:<peerId>, or webhook:<url>" };
|
|
15
16
|
const platform = spec.slice(0, i).toLowerCase();
|
|
16
17
|
const to = spec.slice(i + 1).trim();
|
|
17
18
|
if (!to)
|
|
18
|
-
return { error:
|
|
19
|
+
return { error: "deliver spec is missing a target after ':'" };
|
|
19
20
|
if (platform === "weixin" && to.toLowerCase() === "owner") {
|
|
20
21
|
return { error: "weixin:owner is ambiguous when several people have messaged the bot — use an explicit weixin:<peerId>" };
|
|
21
22
|
}
|
|
22
23
|
if (platform === "telegram" || platform === "feishu" || platform === "webhook" || platform === "weixin")
|
|
23
24
|
return { platform, to };
|
|
24
|
-
return { error:
|
|
25
|
+
return { error: "unsupported deliver platform — supported: telegram, feishu, weixin, webhook" };
|
|
26
|
+
}
|
|
27
|
+
function safeDeliveryError(error) {
|
|
28
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
29
|
+
const boundedLifecycle = /^(?:Gateway delivery|Webhook delivery|Telegram (?:send|upload)|Feishu (?:send|upload|recall)) (?:cancelled|timed out after \d+ms)$/u.exec(message);
|
|
30
|
+
if (boundedLifecycle)
|
|
31
|
+
return boundedLifecycle[0];
|
|
32
|
+
const telegramStatus = /^(Telegram [A-Za-z]+ failed: HTTP \d+)/u.exec(message);
|
|
33
|
+
if (telegramStatus)
|
|
34
|
+
return telegramStatus[1];
|
|
35
|
+
const controlledTransport = /^(?:Telegram [A-Za-z]+ transport failed|Feishu (?:auth transport failed|transport failed|auth failed: HTTP \d+(?: · code=\d+)?|request failed: HTTP \d+(?: · code=\d+)?))$/u.exec(message);
|
|
36
|
+
if (controlledTransport)
|
|
37
|
+
return controlledTransport[0];
|
|
38
|
+
// Opaque adapters and native fetch errors may contain the full target URL (including query credentials).
|
|
39
|
+
return "transport request failed";
|
|
25
40
|
}
|
|
26
41
|
/** Flatten markdown for plain-text chat surfaces (Feishu/WeChat/Telegram text messages render syntax
|
|
27
42
|
* literally): drop code fences/inline backticks/bold/headers, turn [text](url) into "text (url)". */
|
|
@@ -36,49 +51,57 @@ export function plainChat(text) {
|
|
|
36
51
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)");
|
|
37
52
|
}
|
|
38
53
|
/** Send `text` to the target. Returns null on success, or an error string (never throws — cron
|
|
39
|
-
*
|
|
40
|
-
|
|
54
|
+
* delivery is best-effort and must not kill the tick). `signal` ties one-shot adapters to their caller;
|
|
55
|
+
* the outer hard deadline also bounds adapters/SDKs that fail to cooperate with cancellation. */
|
|
56
|
+
export async function deliverResult(spec, text, signal, idempotencyKey) {
|
|
41
57
|
const t = parseDeliver(spec);
|
|
42
58
|
if ("error" in t)
|
|
43
59
|
return t.error;
|
|
44
60
|
if (t.platform !== "webhook")
|
|
45
61
|
text = plainChat(text); // chat surfaces are plain text; webhooks get raw payload
|
|
46
62
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return
|
|
69
|
-
|
|
63
|
+
return await withOutboundDeadline("Gateway delivery", signal, outboundTransferTimeoutMs("text"), async (deliverySignal) => {
|
|
64
|
+
if (t.platform === "webhook") {
|
|
65
|
+
return withOutboundDeadline("Webhook delivery", deliverySignal, 15_000, async (webhookSignal) => {
|
|
66
|
+
const r = await fetch(t.to, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: {
|
|
69
|
+
"content-type": "application/json",
|
|
70
|
+
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify({ source: "hara-cron", text }),
|
|
73
|
+
signal: webhookSignal,
|
|
74
|
+
});
|
|
75
|
+
return r.ok ? null : `webhook ${r.status}`;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (t.platform === "telegram") {
|
|
79
|
+
const token = process.env.HARA_TELEGRAM_TOKEN;
|
|
80
|
+
if (!token)
|
|
81
|
+
return "HARA_TELEGRAM_TOKEN not set";
|
|
82
|
+
const { telegramAdapter } = await import("../gateway/telegram.js");
|
|
83
|
+
await telegramAdapter(token).send(t.to, text, deliverySignal, idempotencyKey);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (t.platform === "weixin") {
|
|
87
|
+
const { loadWeixinCreds, weixinAdapter } = await import("../gateway/weixin.js");
|
|
88
|
+
const creds = loadWeixinCreds();
|
|
89
|
+
if (!creds)
|
|
90
|
+
return "hara weixin not logged in (~/.hara/weixin/creds.json missing)";
|
|
91
|
+
await weixinAdapter(creds).send(t.to, text, deliverySignal);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
// feishu
|
|
95
|
+
const appId = process.env.HARA_FEISHU_APP_ID;
|
|
96
|
+
const appSecret = process.env.HARA_FEISHU_APP_SECRET;
|
|
97
|
+
if (!appId || !appSecret)
|
|
98
|
+
return "HARA_FEISHU_APP_ID / HARA_FEISHU_APP_SECRET not set";
|
|
99
|
+
const { feishuAdapter } = await import("../gateway/feishu.js");
|
|
100
|
+
await feishuAdapter(appId, appSecret).send(t.to, text, deliverySignal, idempotencyKey);
|
|
70
101
|
return null;
|
|
71
|
-
}
|
|
72
|
-
// feishu
|
|
73
|
-
const appId = process.env.HARA_FEISHU_APP_ID;
|
|
74
|
-
const appSecret = process.env.HARA_FEISHU_APP_SECRET;
|
|
75
|
-
if (!appId || !appSecret)
|
|
76
|
-
return "HARA_FEISHU_APP_ID / HARA_FEISHU_APP_SECRET not set";
|
|
77
|
-
const { feishuAdapter } = await import("../gateway/feishu.js");
|
|
78
|
-
await feishuAdapter(appId, appSecret).send(t.to, text);
|
|
79
|
-
return null;
|
|
102
|
+
});
|
|
80
103
|
}
|
|
81
104
|
catch (e) {
|
|
82
|
-
return `delivery failed: ${
|
|
105
|
+
return `delivery failed: ${safeDeliveryError(e)}`;
|
|
83
106
|
}
|
|
84
107
|
}
|