@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.
Files changed (80) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +57 -10
  3. package/SECURITY.md +48 -9
  4. package/dist/agent/loop.js +169 -31
  5. package/dist/agent/reminders.js +22 -7
  6. package/dist/agent/repeat-guard.js +26 -7
  7. package/dist/agent/structured.js +231 -0
  8. package/dist/agent/touched.js +24 -6
  9. package/dist/checkpoints.js +103 -17
  10. package/dist/cli.js +16 -0
  11. package/dist/config.js +173 -34
  12. package/dist/context/agents-md.js +44 -9
  13. package/dist/context/mentions.js +10 -4
  14. package/dist/context/subdir-hints.js +40 -7
  15. package/dist/cron/deliver.js +37 -3
  16. package/dist/cron/runner.js +372 -37
  17. package/dist/cron/store.js +11 -3
  18. package/dist/exec/jobs.js +88 -20
  19. package/dist/feedback.js +3 -2
  20. package/dist/fs-read.js +421 -12
  21. package/dist/fs-walk.js +8 -2
  22. package/dist/fs-write.js +433 -21
  23. package/dist/gateway/dingtalk.js +4 -1
  24. package/dist/gateway/discord.js +53 -20
  25. package/dist/gateway/feishu.js +157 -58
  26. package/dist/gateway/flows-pending.js +727 -0
  27. package/dist/gateway/flows.js +391 -16
  28. package/dist/gateway/matrix.js +81 -18
  29. package/dist/gateway/mattermost.js +44 -34
  30. package/dist/gateway/media.js +659 -0
  31. package/dist/gateway/outbound-files.js +379 -0
  32. package/dist/gateway/serve.js +712 -169
  33. package/dist/gateway/sessions.js +475 -78
  34. package/dist/gateway/signal.js +31 -28
  35. package/dist/gateway/slack.js +28 -21
  36. package/dist/gateway/telegram.js +33 -21
  37. package/dist/gateway/tmux-routes.js +11 -3
  38. package/dist/gateway/wecom.js +38 -31
  39. package/dist/gateway/weixin.js +147 -59
  40. package/dist/hooks.js +41 -23
  41. package/dist/index.js +763 -273
  42. package/dist/mcp/client.js +164 -12
  43. package/dist/memory/store.js +68 -22
  44. package/dist/org/planner.js +36 -10
  45. package/dist/org/projects.js +347 -0
  46. package/dist/org/review-chain.js +360 -24
  47. package/dist/org/roles.js +42 -13
  48. package/dist/profile/profile.js +152 -27
  49. package/dist/recall.js +4 -2
  50. package/dist/runtime.js +37 -0
  51. package/dist/sandbox.js +142 -33
  52. package/dist/search/semindex.js +182 -53
  53. package/dist/search/zvec-store.js +121 -42
  54. package/dist/security/permissions.js +326 -19
  55. package/dist/security/private-state.js +299 -0
  56. package/dist/security/project-trust.js +6 -0
  57. package/dist/security/secrets.js +84 -9
  58. package/dist/security/sensitive-files.js +723 -0
  59. package/dist/security/subprocess-env.js +210 -0
  60. package/dist/serve/server.js +774 -318
  61. package/dist/serve/sessions.js +113 -33
  62. package/dist/session/store.js +298 -47
  63. package/dist/skills/skills.js +16 -7
  64. package/dist/tools/all.js +1 -0
  65. package/dist/tools/builtin.js +77 -49
  66. package/dist/tools/codebase.js +3 -1
  67. package/dist/tools/computer.js +98 -92
  68. package/dist/tools/cron.js +6 -0
  69. package/dist/tools/edit.js +22 -9
  70. package/dist/tools/external_agent.js +110 -16
  71. package/dist/tools/memory.js +38 -8
  72. package/dist/tools/patch.js +253 -34
  73. package/dist/tools/search.js +543 -73
  74. package/dist/tools/send.js +11 -5
  75. package/dist/tools/task.js +453 -0
  76. package/dist/tools/todo.js +67 -16
  77. package/dist/tools/web.js +168 -54
  78. package/dist/undo.js +83 -7
  79. package/package.json +11 -10
  80. 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 { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "node:fs";
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(["provider", "apiKey", "model", "baseURL"]);
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
- /** Nearest project override `.hara/config.json`, searching cwd up to the repo root. */
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 = resolve(cwd);
154
+ let dir;
155
+ try {
156
+ dir = realpathSync.native(resolve(cwd));
157
+ }
158
+ catch {
159
+ dir = resolve(cwd);
160
+ }
77
161
  for (;;) {
78
- const p = join(dir, ".hara", "config.json");
79
- if (existsSync(p)) {
80
- try {
81
- return JSON.parse(readFileSync(p, "utf8"));
82
- }
83
- catch {
84
- return {};
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
- mkdirSync(dirname(p), { recursive: true });
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 project = readProjectConfig(process.cwd());
143
- const overlayName = process.env.HARA_OVERLAY ?? opts.overlay;
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 && overlayMap[overlayName] ? overlayMap[overlayName] : {};
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: process.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 { readFileSync, existsSync } from "node:fs";
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
- const parts = [];
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 txt = readFileSync(p, "utf8").trim();
40
- if (txt)
41
- parts.push(`<!-- ${name} @ ${d} -->\n${txt}`);
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) {
@@ -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, statSync } from "node:fs";
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 { readTextPrefixSync } from "../fs-read.js";
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 = statSync(abs);
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 = readTextPrefixSync(abs, MAX_FILE);
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 { readFileSync, existsSync, statSync } from "node:fs";
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
- const parts = [];
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
- let txt = readFileSync(fp, "utf8").trim();
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 (Buffer.byteLength(txt, "utf8") > MAX)
65
- txt = txt.slice(0, MAX) + "\n…[truncated]";
66
- parts.push(`<!-- ${name} @ ${cd} — local conventions for this directory -->\n${txt}`);
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 parts.length ? "\n\n" + parts.join("\n\n") : "";
108
+ return hint;
76
109
  }
@@ -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 === "telegram" || platform === "feishu" || platform === "webhook")
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 (WeChat needs the live gateway)` };
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;