@nanhara/hara 0.121.1 → 0.122.1
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 +120 -0
- package/README.md +57 -10
- package/SECURITY.md +48 -9
- package/dist/agent/loop.js +169 -31
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +24 -6
- package/dist/checkpoints.js +103 -17
- package/dist/cli.js +16 -0
- package/dist/config.js +173 -34
- package/dist/context/agents-md.js +44 -9
- package/dist/context/mentions.js +10 -4
- package/dist/context/subdir-hints.js +40 -7
- package/dist/cron/deliver.js +37 -3
- package/dist/cron/runner.js +372 -37
- package/dist/cron/store.js +11 -3
- package/dist/exec/jobs.js +88 -20
- package/dist/feedback.js +3 -2
- package/dist/fs-read.js +421 -12
- package/dist/fs-walk.js +8 -2
- package/dist/fs-write.js +433 -21
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -20
- package/dist/gateway/feishu.js +157 -58
- package/dist/gateway/flows-pending.js +727 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +81 -18
- package/dist/gateway/mattermost.js +44 -34
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/outbound-files.js +379 -0
- package/dist/gateway/serve.js +712 -169
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +31 -28
- package/dist/gateway/slack.js +28 -21
- package/dist/gateway/telegram.js +33 -21
- package/dist/gateway/tmux-routes.js +11 -3
- package/dist/gateway/wecom.js +38 -31
- package/dist/gateway/weixin.js +147 -59
- package/dist/hooks.js +41 -23
- package/dist/index.js +763 -273
- package/dist/mcp/client.js +164 -12
- package/dist/memory/store.js +68 -22
- package/dist/org/planner.js +36 -10
- package/dist/org/projects.js +347 -0
- package/dist/org/review-chain.js +360 -24
- package/dist/org/roles.js +42 -13
- package/dist/profile/profile.js +152 -27
- package/dist/recall.js +4 -2
- package/dist/runtime.js +37 -0
- package/dist/sandbox.js +142 -33
- package/dist/search/semindex.js +182 -53
- package/dist/search/zvec-store.js +121 -42
- package/dist/security/permissions.js +326 -19
- package/dist/security/private-state.js +299 -0
- package/dist/security/project-trust.js +6 -0
- package/dist/security/secrets.js +84 -9
- package/dist/security/sensitive-files.js +723 -0
- package/dist/security/subprocess-env.js +210 -0
- package/dist/serve/server.js +774 -318
- package/dist/serve/sessions.js +113 -33
- package/dist/session/store.js +298 -47
- package/dist/skills/skills.js +16 -7
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +77 -49
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/cron.js +6 -0
- package/dist/tools/edit.js +22 -9
- package/dist/tools/external_agent.js +110 -16
- package/dist/tools/memory.js +38 -8
- package/dist/tools/patch.js +253 -34
- package/dist/tools/search.js +543 -73
- package/dist/tools/send.js +11 -5
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +168 -54
- package/dist/undo.js +83 -7
- package/package.json +11 -10
- package/runtime-bootstrap.cjs +72 -0
package/dist/config.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join, dirname, resolve } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { ensurePrivateHaraState } from "./security/private-state.js";
|
|
5
|
+
import { readVerifiedRegularFileSnapshotSync } from "./fs-read.js";
|
|
6
|
+
import { projectRepositoryTrustedAtStartup } from "./security/project-trust.js";
|
|
4
7
|
const PROVIDER_DEFAULTS = {
|
|
5
8
|
anthropic: { model: "claude-opus-4-8", envKey: "ANTHROPIC_API_KEY" },
|
|
6
9
|
qwen: {
|
|
@@ -35,21 +38,90 @@ export const REASONING_EFFORTS = ["off", "low", "medium", "high", "max"];
|
|
|
35
38
|
export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
|
|
36
39
|
export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
|
|
37
40
|
const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
|
|
41
|
+
const MAX_PROJECT_CONFIG_BYTES = 256 * 1024;
|
|
42
|
+
const KNOWN_CONFIG_KEYS = new Set([
|
|
43
|
+
...CONFIG_KEYS,
|
|
44
|
+
"hooks", "mcpServers", "modelVision", "overlays", "profiles",
|
|
45
|
+
]);
|
|
46
|
+
/** Deliberately narrow: these keys change presentation/model preference, but cannot redirect credentials,
|
|
47
|
+
* execute code, grant tools more authority, or disable a safety layer. Everything else requires a launch-
|
|
48
|
+
* time trust decision so cloning/chdir into a repository never silently changes the process trust boundary. */
|
|
49
|
+
const SAFE_PROJECT_CONFIG_KEYS = new Set(["model", "theme", "vimMode", "autoCompact", "reasoningEffort"]);
|
|
50
|
+
const projectConfigWarnings = new Set();
|
|
51
|
+
function printableConfigKeys(keys) {
|
|
52
|
+
// A repository controls JSON property names too. Only schema names are safe diagnostics; an unknown key
|
|
53
|
+
// may itself contain a copied token and must never be treated as printable metadata.
|
|
54
|
+
const safe = [...new Set(keys.map((key) => (KNOWN_CONFIG_KEYS.has(key) ? key : "<unknown-key>")))].sort();
|
|
55
|
+
const shown = safe.slice(0, 32);
|
|
56
|
+
return `${shown.join(", ")}${safe.length > shown.length ? `, … (+${safe.length - shown.length})` : ""}`;
|
|
57
|
+
}
|
|
58
|
+
function warnProjectConfig(kind, message) {
|
|
59
|
+
if (projectConfigWarnings.has(kind))
|
|
60
|
+
return;
|
|
61
|
+
projectConfigWarnings.add(kind);
|
|
62
|
+
try {
|
|
63
|
+
process.stderr.write(`hara: ${message}\n`);
|
|
64
|
+
}
|
|
65
|
+
catch { /* best effort */ }
|
|
66
|
+
}
|
|
67
|
+
function validSafeProjectValue(key, value) {
|
|
68
|
+
if (key === "model")
|
|
69
|
+
return typeof value === "string" && value.trim().length > 0 && value.length <= 256 && !/[\u0000-\u001f\u007f]/.test(value);
|
|
70
|
+
if (key === "theme")
|
|
71
|
+
return value === "dark" || value === "light";
|
|
72
|
+
if (key === "reasoningEffort")
|
|
73
|
+
return REASONING_EFFORTS.includes(value);
|
|
74
|
+
if (key === "vimMode" || key === "autoCompact")
|
|
75
|
+
return typeof value === "boolean" || value === "true" || value === "false";
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
function filterProjectConfig(input) {
|
|
79
|
+
const blocked = Object.keys(input).filter((key) => !SAFE_PROJECT_CONFIG_KEYS.has(key));
|
|
80
|
+
if (projectRepositoryTrustedAtStartup()) {
|
|
81
|
+
if (blocked.length) {
|
|
82
|
+
const names = printableConfigKeys(blocked);
|
|
83
|
+
warnProjectConfig(`trusted:${names}`, `trusted project config enabled for privileged key(s): ${names}.`);
|
|
84
|
+
}
|
|
85
|
+
return input;
|
|
86
|
+
}
|
|
87
|
+
if (blocked.length) {
|
|
88
|
+
const names = printableConfigKeys(blocked);
|
|
89
|
+
warnProjectConfig(`ignored:${names}`, `ignored untrusted project config key(s): ${names}. Set HARA_TRUST_PROJECT_CONFIG=1 before starting hara only for a repository you trust.`);
|
|
90
|
+
}
|
|
91
|
+
const invalid = Object.entries(input)
|
|
92
|
+
.filter(([key, value]) => SAFE_PROJECT_CONFIG_KEYS.has(key) && !validSafeProjectValue(key, value))
|
|
93
|
+
.map(([key]) => key);
|
|
94
|
+
if (invalid.length) {
|
|
95
|
+
const names = printableConfigKeys(invalid);
|
|
96
|
+
warnProjectConfig(`invalid-safe:${names}`, `ignored invalid project config value(s) for key(s): ${names}.`);
|
|
97
|
+
}
|
|
98
|
+
return Object.fromEntries(Object.entries(input).filter(([key, value]) => (SAFE_PROJECT_CONFIG_KEYS.has(key) && validSafeProjectValue(key, value))));
|
|
99
|
+
}
|
|
38
100
|
export function configPath() {
|
|
39
101
|
return join(homedir(), ".hara", "config.json");
|
|
40
102
|
}
|
|
103
|
+
function configRecord(value) {
|
|
104
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
105
|
+
}
|
|
41
106
|
export function readRawConfig() {
|
|
107
|
+
ensurePrivateHaraState();
|
|
42
108
|
const p = configPath();
|
|
43
109
|
if (!existsSync(p))
|
|
44
110
|
return {};
|
|
45
111
|
try {
|
|
46
|
-
return JSON.parse(readFileSync(p, "utf8"));
|
|
112
|
+
return configRecord(JSON.parse(readFileSync(p, "utf8")));
|
|
47
113
|
}
|
|
48
114
|
catch {
|
|
49
115
|
return {};
|
|
50
116
|
}
|
|
51
117
|
}
|
|
52
|
-
const ROUTING_CONFIG_KEYS = new Set([
|
|
118
|
+
const ROUTING_CONFIG_KEYS = new Set([
|
|
119
|
+
"provider", "apiKey", "model", "baseURL",
|
|
120
|
+
"fallbackProvider", "fallbackApiKey", "fallbackModel", "fallbackBaseURL",
|
|
121
|
+
"visionApiKey", "visionModel", "visionBaseURL",
|
|
122
|
+
"embedProvider", "embedApiKey", "embedModel", "embedBaseURL",
|
|
123
|
+
"routeApiKey", "routeModel", "routeBaseURL",
|
|
124
|
+
]);
|
|
53
125
|
/** Empty routing values are not meaningful credentials/endpoints. Ignore them at each precedence layer so
|
|
54
126
|
* an empty project override (or launcher-exported empty env var) cannot hide a valid global config value. */
|
|
55
127
|
function withoutBlankRoutingValues(input) {
|
|
@@ -71,17 +143,78 @@ function nonBlankEnv(value) {
|
|
|
71
143
|
const trimmed = value?.trim();
|
|
72
144
|
return trimmed || undefined;
|
|
73
145
|
}
|
|
74
|
-
|
|
146
|
+
function projectConfigReadFailure(kind) {
|
|
147
|
+
warnProjectConfig(`unsafe-file:${kind}`, `ignored an unsafe project .hara/config.json (${kind}); no project values were loaded.`);
|
|
148
|
+
return {};
|
|
149
|
+
}
|
|
150
|
+
/** Nearest project override `.hara/config.json`, searching a canonical cwd up to the repo root. Project
|
|
151
|
+
* configuration is repository input, not private Hara state: its `.hara` parent and final entry must remain
|
|
152
|
+
* ordinary single-link filesystem objects while a bounded O_NOFOLLOW descriptor is read. */
|
|
75
153
|
function readProjectConfig(cwd) {
|
|
76
|
-
let dir
|
|
154
|
+
let dir;
|
|
155
|
+
try {
|
|
156
|
+
dir = realpathSync.native(resolve(cwd));
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
dir = resolve(cwd);
|
|
160
|
+
}
|
|
77
161
|
for (;;) {
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
162
|
+
const hara = join(dir, ".hara");
|
|
163
|
+
const p = join(hara, "config.json");
|
|
164
|
+
let haraInfo;
|
|
165
|
+
try {
|
|
166
|
+
haraInfo = lstatSync(hara);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (error?.code !== "ENOENT")
|
|
170
|
+
return projectConfigReadFailure("unreadable parent");
|
|
171
|
+
}
|
|
172
|
+
if (haraInfo) {
|
|
173
|
+
if (haraInfo.isSymbolicLink())
|
|
174
|
+
return projectConfigReadFailure("symlink parent");
|
|
175
|
+
if (haraInfo.isDirectory()) {
|
|
176
|
+
let fileInfo;
|
|
177
|
+
try {
|
|
178
|
+
const canonicalParent = realpathSync.native(hara);
|
|
179
|
+
if (canonicalParent !== hara)
|
|
180
|
+
return projectConfigReadFailure("non-canonical parent");
|
|
181
|
+
fileInfo = lstatSync(p);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (error?.code !== "ENOENT")
|
|
185
|
+
return projectConfigReadFailure("unreadable file");
|
|
186
|
+
}
|
|
187
|
+
if (fileInfo) {
|
|
188
|
+
if (fileInfo.isSymbolicLink())
|
|
189
|
+
return projectConfigReadFailure("symlink file");
|
|
190
|
+
if (!fileInfo.isFile())
|
|
191
|
+
return projectConfigReadFailure("non-regular file");
|
|
192
|
+
try {
|
|
193
|
+
const snapshot = readVerifiedRegularFileSnapshotSync(p, MAX_PROJECT_CONFIG_BYTES, {
|
|
194
|
+
action: "read project config",
|
|
195
|
+
protectSensitive: false,
|
|
196
|
+
rejectHardLinks: true,
|
|
197
|
+
});
|
|
198
|
+
const parentAfter = lstatSync(hara);
|
|
199
|
+
if (!parentAfter.isDirectory()
|
|
200
|
+
|| parentAfter.isSymbolicLink()
|
|
201
|
+
|| parentAfter.dev !== haraInfo.dev
|
|
202
|
+
|| parentAfter.ino !== haraInfo.ino
|
|
203
|
+
|| realpathSync.native(hara) !== hara)
|
|
204
|
+
return projectConfigReadFailure("changed parent");
|
|
205
|
+
return filterProjectConfig(configRecord(JSON.parse(snapshot.text)));
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (error?.code === "HARA_HARD_LINKED_FILE")
|
|
209
|
+
return projectConfigReadFailure("hard-linked file");
|
|
210
|
+
if (error?.code === "HARA_FILE_TOO_LARGE")
|
|
211
|
+
return projectConfigReadFailure("oversized file");
|
|
212
|
+
if (/changed while (?:opening|reading)|File changed/i.test(error?.message ?? "")) {
|
|
213
|
+
return projectConfigReadFailure("changed file");
|
|
214
|
+
}
|
|
215
|
+
return projectConfigReadFailure("invalid file");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
85
218
|
}
|
|
86
219
|
}
|
|
87
220
|
if (PROJECT_ROOT_MARKERS.some((m) => existsSync(join(dir, m))))
|
|
@@ -95,7 +228,12 @@ function readProjectConfig(cwd) {
|
|
|
95
228
|
}
|
|
96
229
|
/** Write the config 0600 (it can hold `apiKey`) + tighten an existing file. */
|
|
97
230
|
function persistConfig(p, cfg) {
|
|
98
|
-
|
|
231
|
+
ensurePrivateHaraState();
|
|
232
|
+
mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
|
|
233
|
+
try {
|
|
234
|
+
chmodSync(dirname(p), 0o700);
|
|
235
|
+
}
|
|
236
|
+
catch { /* best effort */ }
|
|
99
237
|
writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
100
238
|
try {
|
|
101
239
|
chmodSync(p, 0o600);
|
|
@@ -123,7 +261,7 @@ export function setModelVisionOverride(model, cap) {
|
|
|
123
261
|
persistConfig(p, cfg);
|
|
124
262
|
}
|
|
125
263
|
/**
|
|
126
|
-
* Effective config. Precedence (high→low): env vars > project `.hara/config.json` >
|
|
264
|
+
* Effective config. Precedence (high→low): env vars > allowed/trusted project `.hara/config.json` >
|
|
127
265
|
* named overlay (`overlays.<name>` in global config) > global `~/.hara/config.json`
|
|
128
266
|
* > provider defaults.
|
|
129
267
|
*
|
|
@@ -139,14 +277,15 @@ export function loadConfig(opts = {}) {
|
|
|
139
277
|
// Strip both the new (`overlays`) and legacy (`profiles`) overlay containers from the base merge.
|
|
140
278
|
// The legacy `profiles` key is kept readable for back-compat with users who already have it.
|
|
141
279
|
const { overlays, profiles, ...globalBase } = global;
|
|
142
|
-
const
|
|
143
|
-
const
|
|
280
|
+
const effectiveCwd = resolve(opts.cwd ?? process.cwd());
|
|
281
|
+
const project = readProjectConfig(effectiveCwd);
|
|
282
|
+
const overlayName = nonBlankEnv(process.env.HARA_OVERLAY) ?? nonBlankEnv(opts.overlay);
|
|
144
283
|
const overlayMap = overlays && typeof overlays === "object" ? overlays : profiles && typeof profiles === "object" ? profiles : null;
|
|
145
|
-
const overlay = overlayName && overlayMap
|
|
284
|
+
const overlay = configRecord(overlayName && overlayMap ? overlayMap[overlayName] : undefined);
|
|
146
285
|
const merged = {
|
|
147
286
|
...withoutBlankRoutingValues(globalBase),
|
|
148
|
-
...withoutBlankRoutingValues(project),
|
|
149
287
|
...withoutBlankRoutingValues(overlay),
|
|
288
|
+
...withoutBlankRoutingValues(project),
|
|
150
289
|
};
|
|
151
290
|
const provider = (nonBlankEnv(process.env.HARA_PROVIDER) ?? merged.provider ?? "anthropic");
|
|
152
291
|
const d = PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic;
|
|
@@ -160,21 +299,21 @@ export function loadConfig(opts = {}) {
|
|
|
160
299
|
const assetCapture = (process.env.HARA_ASSET_CAPTURE ?? merged.assetCapture ?? "ask");
|
|
161
300
|
const computerUse = (process.env.HARA_COMPUTER_USE ?? merged.computerUse ?? "off");
|
|
162
301
|
const computerApps = String(process.env.HARA_COMPUTER_APPS ?? merged.computerApps ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
163
|
-
const visionModel = process.env.HARA_VISION_MODEL ?? merged.visionModel;
|
|
164
|
-
const visionBaseURL = process.env.HARA_VISION_BASE_URL ?? merged.visionBaseURL;
|
|
165
|
-
const visionApiKey = process.env.HARA_VISION_API_KEY ?? merged.visionApiKey;
|
|
302
|
+
const visionModel = nonBlankEnv(process.env.HARA_VISION_MODEL) ?? merged.visionModel;
|
|
303
|
+
const visionBaseURL = nonBlankEnv(process.env.HARA_VISION_BASE_URL) ?? merged.visionBaseURL;
|
|
304
|
+
const visionApiKey = nonBlankEnv(process.env.HARA_VISION_API_KEY) ?? merged.visionApiKey;
|
|
166
305
|
const modelVision = merged.modelVision && typeof merged.modelVision === "object" ? merged.modelVision : {};
|
|
167
|
-
const embedProvider = (process.env.HARA_EMBED_PROVIDER ?? merged.embedProvider ?? "off");
|
|
168
|
-
const embedModel = process.env.HARA_EMBED_MODEL ?? merged.embedModel;
|
|
169
|
-
const embedBaseURL = process.env.HARA_EMBED_BASE_URL ?? merged.embedBaseURL;
|
|
170
|
-
const embedApiKey = process.env.HARA_EMBED_API_KEY ?? merged.embedApiKey;
|
|
171
|
-
const routeModel = process.env.HARA_ROUTE_MODEL ?? merged.routeModel;
|
|
172
|
-
const routeBaseURL = process.env.HARA_ROUTE_BASE_URL ?? merged.routeBaseURL;
|
|
173
|
-
const routeApiKey = process.env.HARA_ROUTE_API_KEY ?? merged.routeApiKey;
|
|
306
|
+
const embedProvider = (nonBlankEnv(process.env.HARA_EMBED_PROVIDER) ?? merged.embedProvider ?? "off");
|
|
307
|
+
const embedModel = nonBlankEnv(process.env.HARA_EMBED_MODEL) ?? merged.embedModel;
|
|
308
|
+
const embedBaseURL = nonBlankEnv(process.env.HARA_EMBED_BASE_URL) ?? merged.embedBaseURL;
|
|
309
|
+
const embedApiKey = nonBlankEnv(process.env.HARA_EMBED_API_KEY) ?? merged.embedApiKey;
|
|
310
|
+
const routeModel = nonBlankEnv(process.env.HARA_ROUTE_MODEL) ?? merged.routeModel;
|
|
311
|
+
const routeBaseURL = nonBlankEnv(process.env.HARA_ROUTE_BASE_URL) ?? merged.routeBaseURL;
|
|
312
|
+
const routeApiKey = nonBlankEnv(process.env.HARA_ROUTE_API_KEY) ?? merged.routeApiKey;
|
|
174
313
|
const mcpServers = {
|
|
175
314
|
...(globalBase.mcpServers ?? {}),
|
|
176
|
-
...(project.mcpServers ?? {}),
|
|
177
315
|
...(overlay.mcpServers ?? {}),
|
|
316
|
+
...(project.mcpServers ?? {}),
|
|
178
317
|
};
|
|
179
318
|
const hooks = (merged.hooks && typeof merged.hooks === "object" ? merged.hooks : {});
|
|
180
319
|
// Guardian: default ON; env HARA_GUARDIAN=0/off/false or config guardian:"off" disables it.
|
|
@@ -185,15 +324,15 @@ export function loadConfig(opts = {}) {
|
|
|
185
324
|
const autoCompact = !(process.env.HARA_AUTO_COMPACT === "0" || merged.autoCompact === false || merged.autoCompact === "false"); // default ON
|
|
186
325
|
const fileCheckpoints = !(process.env.HARA_CHECKPOINTS === "0" || merged.fileCheckpoints === false || merged.fileCheckpoints === "false"); // default ON
|
|
187
326
|
const updateCheck = !(process.env.HARA_UPDATE_CHECK === "0" || merged.updateCheck === false || merged.updateCheck === "false"); // default ON
|
|
188
|
-
const fallbackModel = process.env.HARA_FALLBACK_MODEL ?? merged.fallbackModel;
|
|
189
|
-
const fallbackProvider = (process.env.HARA_FALLBACK_PROVIDER ?? merged.fallbackProvider);
|
|
190
|
-
const fallbackBaseURL = process.env.HARA_FALLBACK_BASE_URL ?? merged.fallbackBaseURL;
|
|
191
|
-
const fallbackApiKey = process.env.HARA_FALLBACK_API_KEY ?? merged.fallbackApiKey;
|
|
327
|
+
const fallbackModel = nonBlankEnv(process.env.HARA_FALLBACK_MODEL) ?? merged.fallbackModel;
|
|
328
|
+
const fallbackProvider = (nonBlankEnv(process.env.HARA_FALLBACK_PROVIDER) ?? merged.fallbackProvider);
|
|
329
|
+
const fallbackBaseURL = nonBlankEnv(process.env.HARA_FALLBACK_BASE_URL) ?? merged.fallbackBaseURL;
|
|
330
|
+
const fallbackApiKey = nonBlankEnv(process.env.HARA_FALLBACK_API_KEY) ?? merged.fallbackApiKey;
|
|
192
331
|
const reasoningRaw = process.env.HARA_REASONING_EFFORT ?? merged.reasoningEffort;
|
|
193
332
|
const reasoningEffort = reasoningRaw && ["off", "low", "medium", "high", "max"].includes(reasoningRaw)
|
|
194
333
|
? reasoningRaw
|
|
195
334
|
: undefined;
|
|
196
|
-
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:
|
|
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 };
|
|
197
336
|
}
|
|
198
337
|
export function providerEnvKey(provider) {
|
|
199
338
|
return (PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic).envKey;
|
|
@@ -1,10 +1,36 @@
|
|
|
1
1
|
// Project-context loading (AGENTS.md) — the cross-tool standard read by Codex/Claude Code/OpenClaw.
|
|
2
2
|
// Walks up from cwd to the project root, concatenates AGENTS.md files, caps total size.
|
|
3
|
-
import {
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
4
|
import { join, dirname, resolve } from "node:path";
|
|
5
|
+
import { readModelContextBytePrefixSync } from "../fs-read.js";
|
|
5
6
|
const FILENAMES = ["AGENTS.override.md", "AGENTS.md"];
|
|
6
7
|
const ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
|
|
7
8
|
const MAX_BYTES = 32 * 1024;
|
|
9
|
+
const SEPARATOR = "\n\n--- project-doc ---\n\n";
|
|
10
|
+
const TRUNCATED = "\n…[truncated to project-context budget]";
|
|
11
|
+
function byteLength(value) {
|
|
12
|
+
return Buffer.byteLength(value, "utf8");
|
|
13
|
+
}
|
|
14
|
+
/** Retain only whole Unicode code points whose UTF-8 encoding fits the remaining byte budget. */
|
|
15
|
+
function utf8Prefix(value, maxBytes) {
|
|
16
|
+
if (maxBytes <= 0)
|
|
17
|
+
return "";
|
|
18
|
+
if (byteLength(value) <= maxBytes)
|
|
19
|
+
return value;
|
|
20
|
+
let used = 0;
|
|
21
|
+
let out = "";
|
|
22
|
+
for (const char of value) {
|
|
23
|
+
const bytes = byteLength(char);
|
|
24
|
+
if (used + bytes > maxBytes)
|
|
25
|
+
break;
|
|
26
|
+
out += char;
|
|
27
|
+
used += bytes;
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
function markBudgetTruncation(value) {
|
|
32
|
+
return utf8Prefix(value, MAX_BYTES - byteLength(TRUNCATED)) + TRUNCATED;
|
|
33
|
+
}
|
|
8
34
|
export function findProjectRoot(cwd) {
|
|
9
35
|
let dir = resolve(cwd);
|
|
10
36
|
for (;;) {
|
|
@@ -30,15 +56,28 @@ export function loadAgentsMd(cwd) {
|
|
|
30
56
|
break;
|
|
31
57
|
dir = parent;
|
|
32
58
|
}
|
|
33
|
-
|
|
59
|
+
let combined = "";
|
|
34
60
|
for (const d of chain) {
|
|
35
61
|
for (const name of FILENAMES) {
|
|
36
62
|
const p = join(d, name);
|
|
37
63
|
if (existsSync(p)) {
|
|
38
64
|
try {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
65
|
+
const separator = combined ? SEPARATOR : "";
|
|
66
|
+
const header = `<!-- ${name} @ ${d} -->\n`;
|
|
67
|
+
const fixedBytes = byteLength(separator + header);
|
|
68
|
+
const remaining = MAX_BYTES - byteLength(combined) - fixedBytes;
|
|
69
|
+
if (remaining <= byteLength(TRUNCATED))
|
|
70
|
+
return markBudgetTruncation(combined);
|
|
71
|
+
const read = readModelContextBytePrefixSync(p, remaining);
|
|
72
|
+
if (read.binary)
|
|
73
|
+
continue;
|
|
74
|
+
let txt = read.text.trim();
|
|
75
|
+
if (!txt)
|
|
76
|
+
continue;
|
|
77
|
+
if (read.truncated) {
|
|
78
|
+
txt = utf8Prefix(txt, Math.max(0, remaining - byteLength(TRUNCATED))) + TRUNCATED;
|
|
79
|
+
}
|
|
80
|
+
combined += separator + header + utf8Prefix(txt, remaining);
|
|
42
81
|
}
|
|
43
82
|
catch {
|
|
44
83
|
/* ignore unreadable */
|
|
@@ -46,10 +85,6 @@ export function loadAgentsMd(cwd) {
|
|
|
46
85
|
}
|
|
47
86
|
}
|
|
48
87
|
}
|
|
49
|
-
let combined = parts.join("\n\n--- project-doc ---\n\n");
|
|
50
|
-
if (Buffer.byteLength(combined, "utf8") > MAX_BYTES) {
|
|
51
|
-
combined = Buffer.from(combined, "utf8").subarray(0, MAX_BYTES).toString("utf8") + "\n…[truncated]";
|
|
52
|
-
}
|
|
53
88
|
return combined;
|
|
54
89
|
}
|
|
55
90
|
export function hasAgentsMd(cwd) {
|
package/dist/context/mentions.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// @file mentions — expand `@path` references in user input into appended file contents,
|
|
2
2
|
// and provide fuzzy file candidates for REPL tab-completion.
|
|
3
|
-
import { existsSync,
|
|
3
|
+
import { existsSync, lstatSync } from "node:fs";
|
|
4
4
|
import { isAbsolute, resolve } from "node:path";
|
|
5
5
|
import { listProjectFiles, dirPrefixes, walkFiles } from "../fs-walk.js";
|
|
6
6
|
import { fuzzyRank } from "../fuzzy.js";
|
|
7
7
|
import { mediaTypeFor } from "../images.js";
|
|
8
|
-
import {
|
|
8
|
+
import { readModelContextPrefixSync } from "../fs-read.js";
|
|
9
|
+
import { sensitiveFileError } from "../security/sensitive-files.js";
|
|
9
10
|
const MAX_FILE = 50_000;
|
|
10
11
|
// @ at start-of-string or after whitespace; capture a path with no spaces/@ (avoids emails like a@b.com)
|
|
11
12
|
const MENTION_RE = /(?:^|\s)@([^\s@]+)/g;
|
|
@@ -46,15 +47,20 @@ export function expandMentions(input, cwd) {
|
|
|
46
47
|
/** Render one mention as an inline block, or null if it isn't a readable file/dir. */
|
|
47
48
|
function expandRef(ref, cwd) {
|
|
48
49
|
const abs = isAbsolute(ref) ? ref : resolve(cwd, ref);
|
|
50
|
+
const denied = sensitiveFileError(abs, "attach");
|
|
51
|
+
if (denied)
|
|
52
|
+
return `\nProtected file \`${ref}\` was not inserted into model context. ${denied}\n`;
|
|
49
53
|
try {
|
|
50
54
|
if (!existsSync(abs))
|
|
51
55
|
return null;
|
|
52
|
-
const st =
|
|
56
|
+
const st = lstatSync(abs);
|
|
57
|
+
if (st.isSymbolicLink())
|
|
58
|
+
return `Referenced \`${ref}\` is a symbolic link — it was not inserted into model context.`;
|
|
53
59
|
if (st.isFile()) {
|
|
54
60
|
// don't inline binary image bytes as text — paste with Ctrl+V (or drag the file in) to attach visually
|
|
55
61
|
if (mediaTypeFor(abs))
|
|
56
62
|
return `Referenced \`${ref}\` is an image — paste it with Ctrl+V to attach it visually.`;
|
|
57
|
-
const prefix =
|
|
63
|
+
const prefix = readModelContextPrefixSync(abs, MAX_FILE);
|
|
58
64
|
if (prefix.binary)
|
|
59
65
|
return `Referenced \`${ref}\` appears to be binary — it was not inserted into the model context.`;
|
|
60
66
|
const txt = prefix.text + (prefix.truncated ? "\n…[truncated]" : "");
|
|
@@ -2,12 +2,36 @@
|
|
|
2
2
|
// AGENTS.md / CLAUDE.md (the local conventions for that package) by appending it to the tool result, so a
|
|
3
3
|
// monorepo's per-package rules reach the model exactly when work moves into that package. Startup already
|
|
4
4
|
// loads root→cwd (agents-md.ts); this covers the subdirs the agent navigates INTO. Each dir loaded once.
|
|
5
|
-
import {
|
|
5
|
+
import { existsSync, statSync } from "node:fs";
|
|
6
6
|
import { join, dirname, resolve, relative, isAbsolute } from "node:path";
|
|
7
7
|
import { findProjectRoot } from "./agents-md.js";
|
|
8
|
+
import { readModelContextBytePrefixSync } from "../fs-read.js";
|
|
8
9
|
const FILENAMES = ["AGENTS.override.md", "AGENTS.md", "CLAUDE.md"];
|
|
9
10
|
const MAX = 8 * 1024;
|
|
11
|
+
const TRUNCATED = "\n…[truncated to subdirectory-context budget]";
|
|
10
12
|
const loaded = new Set(); // dirs whose local doc we've already injected (per process / session)
|
|
13
|
+
function byteLength(value) {
|
|
14
|
+
return Buffer.byteLength(value, "utf8");
|
|
15
|
+
}
|
|
16
|
+
function utf8Prefix(value, maxBytes) {
|
|
17
|
+
if (maxBytes <= 0)
|
|
18
|
+
return "";
|
|
19
|
+
if (byteLength(value) <= maxBytes)
|
|
20
|
+
return value;
|
|
21
|
+
let used = 0;
|
|
22
|
+
let out = "";
|
|
23
|
+
for (const char of value) {
|
|
24
|
+
const bytes = byteLength(char);
|
|
25
|
+
if (used + bytes > maxBytes)
|
|
26
|
+
break;
|
|
27
|
+
out += char;
|
|
28
|
+
used += bytes;
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
function markBudgetTruncation(value) {
|
|
33
|
+
return utf8Prefix(value, MAX - byteLength(TRUNCATED)) + TRUNCATED;
|
|
34
|
+
}
|
|
11
35
|
function isDir(p) {
|
|
12
36
|
try {
|
|
13
37
|
return statSync(p).isDirectory();
|
|
@@ -31,7 +55,7 @@ function pathsFrom(input) {
|
|
|
31
55
|
export function subdirHint(input, cwd) {
|
|
32
56
|
const base = resolve(cwd);
|
|
33
57
|
const root = findProjectRoot(cwd);
|
|
34
|
-
|
|
58
|
+
let hint = "";
|
|
35
59
|
for (const raw of pathsFrom(input)) {
|
|
36
60
|
const absPath = isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw);
|
|
37
61
|
const startDir = isDir(absPath) ? absPath : dirname(absPath);
|
|
@@ -58,12 +82,21 @@ export function subdirHint(input, cwd) {
|
|
|
58
82
|
if (!existsSync(fp))
|
|
59
83
|
continue;
|
|
60
84
|
try {
|
|
61
|
-
|
|
85
|
+
const separator = "\n\n";
|
|
86
|
+
const header = `<!-- ${name} @ ${cd} — local conventions for this directory -->\n`;
|
|
87
|
+
const remaining = MAX - byteLength(hint) - byteLength(separator + header);
|
|
88
|
+
if (remaining <= byteLength(TRUNCATED))
|
|
89
|
+
return markBudgetTruncation(hint);
|
|
90
|
+
const read = readModelContextBytePrefixSync(fp, remaining);
|
|
91
|
+
if (read.binary)
|
|
92
|
+
break;
|
|
93
|
+
let txt = read.text.trim();
|
|
62
94
|
if (!txt)
|
|
63
95
|
break;
|
|
64
|
-
if (
|
|
65
|
-
txt = txt.
|
|
66
|
-
|
|
96
|
+
if (read.truncated) {
|
|
97
|
+
txt = utf8Prefix(txt, Math.max(0, remaining - byteLength(TRUNCATED))) + TRUNCATED;
|
|
98
|
+
}
|
|
99
|
+
hint += separator + header + utf8Prefix(txt, remaining);
|
|
67
100
|
}
|
|
68
101
|
catch {
|
|
69
102
|
/* ignore unreadable */
|
|
@@ -72,5 +105,5 @@ export function subdirHint(input, cwd) {
|
|
|
72
105
|
}
|
|
73
106
|
}
|
|
74
107
|
}
|
|
75
|
-
return
|
|
108
|
+
return hint;
|
|
76
109
|
}
|
package/dist/cron/deliver.js
CHANGED
|
@@ -1,15 +1,39 @@
|
|
|
1
|
+
// Cron result delivery — push a finished job's output to a chat channel (openclaw/hermes parity),
|
|
2
|
+
// WITHOUT needing the gateway process: adapters are constructed one-shot from the same env vars the
|
|
3
|
+
// gateway uses, send once, and are dropped. Spec format: "<target>:<id>" —
|
|
4
|
+
// telegram:<chatId> (HARA_TELEGRAM_TOKEN)
|
|
5
|
+
// feishu:<chatId> (HARA_FEISHU_APP_ID + HARA_FEISHU_APP_SECRET)
|
|
6
|
+
// webhook:<url> (plain POST {name,status,text} JSON — for anything else)
|
|
7
|
+
// weixin:<peerId> (sends over stored ~/.hara/weixin creds — explicit peer required; guessing the
|
|
8
|
+
// "owner" from a multi-DM context-token cache can deliver private results to the wrong person)
|
|
9
|
+
// Adapters are imported LAZILY so the (heavy) SDKs never load unless a job actually delivers.
|
|
1
10
|
/** Parse a `--deliver` spec; error string on anything unsupported (listing what IS supported). */
|
|
2
11
|
export function parseDeliver(spec) {
|
|
3
12
|
const i = spec.indexOf(":");
|
|
4
13
|
if (i <= 0)
|
|
5
|
-
return { error: `bad deliver spec "${spec}" — use telegram:<chatId>, feishu:<chatId>, or webhook:<url>` };
|
|
14
|
+
return { error: `bad deliver spec "${spec}" — use telegram:<chatId>, feishu:<chatId>, weixin:<peerId>, or webhook:<url>` };
|
|
6
15
|
const platform = spec.slice(0, i).toLowerCase();
|
|
7
16
|
const to = spec.slice(i + 1).trim();
|
|
8
17
|
if (!to)
|
|
9
18
|
return { error: `deliver spec "${spec}" is missing a target after ":"` };
|
|
10
|
-
if (platform === "
|
|
19
|
+
if (platform === "weixin" && to.toLowerCase() === "owner") {
|
|
20
|
+
return { error: "weixin:owner is ambiguous when several people have messaged the bot — use an explicit weixin:<peerId>" };
|
|
21
|
+
}
|
|
22
|
+
if (platform === "telegram" || platform === "feishu" || platform === "webhook" || platform === "weixin")
|
|
11
23
|
return { platform, to };
|
|
12
|
-
return { error: `unsupported deliver platform "${platform}" — supported: telegram, feishu, webhook
|
|
24
|
+
return { error: `unsupported deliver platform "${platform}" — supported: telegram, feishu, weixin, webhook` };
|
|
25
|
+
}
|
|
26
|
+
/** Flatten markdown for plain-text chat surfaces (Feishu/WeChat/Telegram text messages render syntax
|
|
27
|
+
* literally): drop code fences/inline backticks/bold/headers, turn [text](url) into "text (url)". */
|
|
28
|
+
export function plainChat(text) {
|
|
29
|
+
return text
|
|
30
|
+
.replace(/```[a-zA-Z0-9_-]*\n?/g, "")
|
|
31
|
+
.replace(/```/g, "")
|
|
32
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
33
|
+
.replace(/__([^_]+)__/g, "$1")
|
|
34
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
35
|
+
.replace(/^#{1,6}\s+/gm, "")
|
|
36
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)");
|
|
13
37
|
}
|
|
14
38
|
/** Send `text` to the target. Returns null on success, or an error string (never throws — cron
|
|
15
39
|
* delivery is best-effort and must not kill the tick). */
|
|
@@ -17,6 +41,8 @@ export async function deliverResult(spec, text) {
|
|
|
17
41
|
const t = parseDeliver(spec);
|
|
18
42
|
if ("error" in t)
|
|
19
43
|
return t.error;
|
|
44
|
+
if (t.platform !== "webhook")
|
|
45
|
+
text = plainChat(text); // chat surfaces are plain text; webhooks get raw payload
|
|
20
46
|
try {
|
|
21
47
|
if (t.platform === "webhook") {
|
|
22
48
|
const r = await fetch(t.to, {
|
|
@@ -35,6 +61,14 @@ export async function deliverResult(spec, text) {
|
|
|
35
61
|
await telegramAdapter(token).send(t.to, text);
|
|
36
62
|
return null;
|
|
37
63
|
}
|
|
64
|
+
if (t.platform === "weixin") {
|
|
65
|
+
const { loadWeixinCreds, weixinAdapter } = await import("../gateway/weixin.js");
|
|
66
|
+
const creds = loadWeixinCreds();
|
|
67
|
+
if (!creds)
|
|
68
|
+
return "hara weixin not logged in (~/.hara/weixin/creds.json missing)";
|
|
69
|
+
await weixinAdapter(creds).send(t.to, text);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
38
72
|
// feishu
|
|
39
73
|
const appId = process.env.HARA_FEISHU_APP_ID;
|
|
40
74
|
const appSecret = process.env.HARA_FEISHU_APP_SECRET;
|