@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +15 -2
  3. package/SECURITY.md +4 -0
  4. package/dist/agent/limits.js +51 -0
  5. package/dist/agent/loop.js +367 -112
  6. package/dist/agent/repeat-guard.js +49 -12
  7. package/dist/checkpoints.js +9 -0
  8. package/dist/cli.js +2 -1
  9. package/dist/config.js +10 -2
  10. package/dist/context/agents-md.js +21 -2
  11. package/dist/context/mentions.js +84 -1
  12. package/dist/context/workspace-scope.js +49 -0
  13. package/dist/cron/deliver.js +61 -38
  14. package/dist/cron/runner.js +423 -65
  15. package/dist/cron/schedule.js +23 -7
  16. package/dist/cron/store.js +709 -43
  17. package/dist/fs-walk.js +279 -7
  18. package/dist/fs-write.js +9 -0
  19. package/dist/gateway/feishu.js +351 -64
  20. package/dist/gateway/flows-pending.js +28 -14
  21. package/dist/gateway/flows.js +306 -31
  22. package/dist/gateway/outbound-files.js +20 -6
  23. package/dist/gateway/runtime-state.js +1485 -0
  24. package/dist/gateway/serve.js +550 -242
  25. package/dist/gateway/telegram.js +279 -29
  26. package/dist/gateway/tts.js +182 -38
  27. package/dist/hooks.js +22 -22
  28. package/dist/index.js +466 -158
  29. package/dist/memory/store.js +8 -5
  30. package/dist/org/planner.js +11 -6
  31. package/dist/process-identity.js +52 -0
  32. package/dist/providers/bounded-turn.js +42 -0
  33. package/dist/recall.js +69 -1
  34. package/dist/runtime.js +24 -0
  35. package/dist/sandbox.js +54 -4
  36. package/dist/search/embed.js +13 -2
  37. package/dist/search/hybrid.js +6 -3
  38. package/dist/search/semindex.js +87 -5
  39. package/dist/security/guardian.js +3 -15
  40. package/dist/security/permissions.js +11 -0
  41. package/dist/serve/server.js +70 -42
  42. package/dist/serve/sessions.js +4 -3
  43. package/dist/tools/agent.js +1 -1
  44. package/dist/tools/ask_user.js +5 -1
  45. package/dist/tools/builtin.js +5 -2
  46. package/dist/tools/codebase.js +67 -18
  47. package/dist/tools/computer.js +149 -68
  48. package/dist/tools/cron.js +72 -18
  49. package/dist/tools/edit.js +3 -2
  50. package/dist/tools/external_agent.js +66 -12
  51. package/dist/tools/memory.js +25 -7
  52. package/dist/tools/patch.js +11 -2
  53. package/dist/tools/registry.js +16 -1
  54. package/dist/tools/search.js +68 -9
  55. package/dist/tools/send.js +1 -1
  56. package/dist/tools/skill.js +1 -1
  57. package/dist/tools/web.js +43 -13
  58. package/dist/tui/App.js +93 -25
  59. package/dist/vision.js +5 -6
  60. package/package.json +3 -3
  61. package/runtime-bootstrap.cjs +22 -0
package/dist/fs-walk.js CHANGED
@@ -1,23 +1,193 @@
1
1
  // Shared filesystem walker — powers @file completion and the grep/glob tools.
2
2
  // Walks a directory tree, skipping noise dirs, capped so huge trees stay responsive.
3
3
  import { readdirSync, statSync } from "node:fs";
4
+ import { opendir } from "node:fs/promises";
4
5
  import { join, relative, sep } from "node:path";
5
- import { execSync } from "node:child_process";
6
+ import { execFile, execSync } from "node:child_process";
6
7
  import { fuzzyRank } from "./fuzzy.js";
7
8
  import { isSensitiveFilePath } from "./security/sensitive-files.js";
8
9
  import { toolSubprocessEnv } from "./security/subprocess-env.js";
10
+ import { recursiveRootContainsHome } from "./context/workspace-scope.js";
9
11
  export const IGNORE_DIRS = new Set([
10
12
  ".git", "node_modules", "dist", "build", "out", ".next", ".nuxt", ".cache",
11
13
  "coverage", ".venv", "venv", "__pycache__", ".mypy_cache", ".pytest_cache",
12
14
  "target", ".idea", ".vscode", ".hara", ".turbo", ".parcel-cache", "vendor",
13
15
  ]);
14
16
  const toPosix = (p) => (sep === "/" ? p : p.split(sep).join("/"));
15
- /** Relative POSIX file paths under `root`, skipping IGNORE_DIRS, capped at `cap` files. */
16
- export function walkFiles(root, cap = 8000) {
17
+ const DEFAULT_MAX_FILES = 8_000;
18
+ const DEFAULT_MAX_DIRECTORIES = 20_000;
19
+ const DEFAULT_MAX_ENTRIES = 100_000;
20
+ const DEFAULT_ASYNC_TIMEOUT_MS = 2_000;
21
+ // Compatibility-only synchronous callers (readline completion and legacy public APIs) cannot be made
22
+ // interruptible. Keep them on a much tighter wall budget; agent/tool paths use the async APIs below.
23
+ const DEFAULT_SYNC_TIMEOUT_MS = 250;
24
+ const DEFAULT_YIELD_EVERY = 128;
25
+ function finiteLimit(value, fallback) {
26
+ return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : fallback;
27
+ }
28
+ function normalizeOptions(options, defaultTimeoutMs) {
29
+ return {
30
+ maxFiles: finiteLimit(options?.maxFiles, DEFAULT_MAX_FILES),
31
+ maxDirectories: finiteLimit(options?.maxDirectories, DEFAULT_MAX_DIRECTORIES),
32
+ maxEntries: finiteLimit(options?.maxEntries, DEFAULT_MAX_ENTRIES),
33
+ timeoutMs: finiteLimit(options?.timeoutMs, defaultTimeoutMs),
34
+ signal: options?.signal,
35
+ yieldEvery: Math.max(1, finiteLimit(options?.yieldEvery, DEFAULT_YIELD_EVERY)),
36
+ };
37
+ }
38
+ function abortError(signal) {
39
+ const reason = signal?.reason;
40
+ if (reason instanceof Error)
41
+ return reason;
42
+ const error = new Error(typeof reason === "string" && reason ? reason : "file scan aborted");
43
+ error.name = "AbortError";
44
+ return error;
45
+ }
46
+ function throwIfAborted(signal) {
47
+ if (signal?.aborted)
48
+ throw abortError(signal);
49
+ }
50
+ function result(files, directoriesVisited, entriesVisited, reason) {
51
+ return { files, truncated: reason !== undefined, reason, directoriesVisited, entriesVisited };
52
+ }
53
+ function yieldToEventLoop() {
54
+ return new Promise((resolve) => setImmediate(resolve));
55
+ }
56
+ /**
57
+ * Interruptible filesystem walk for agent/tool paths. It has independent file, directory, Dirent and
58
+ * total wall-clock limits. Dirents are streamed with `opendir`, and explicit yields ensure an agent
59
+ * deadline's timer can run even when a large tree is entirely cached in memory.
60
+ */
61
+ export async function walkFilesAsync(root, options = {}) {
62
+ const cfg = normalizeOptions(options, DEFAULT_ASYNC_TIMEOUT_MS);
63
+ const startedAt = Date.now();
64
+ throwIfAborted(cfg.signal);
65
+ if (recursiveRootContainsHome(root))
66
+ return result([], 0, 0);
67
+ if (cfg.maxFiles === 0)
68
+ return result([], 0, 0, "file_limit");
69
+ if (cfg.maxDirectories === 0)
70
+ return result([], 0, 0, "directory_limit");
71
+ if (cfg.maxEntries === 0)
72
+ return result([], 0, 0, "entry_limit");
17
73
  const files = [];
18
74
  const stack = [root];
19
- while (stack.length && files.length < cap) {
75
+ let directoriesVisited = 0;
76
+ let entriesVisited = 0;
77
+ let sinceYield = 0;
78
+ const timedOut = () => Date.now() - startedAt >= cfg.timeoutMs;
79
+ while (stack.length) {
80
+ throwIfAborted(cfg.signal);
81
+ if (timedOut())
82
+ return result(files, directoriesVisited, entriesVisited, "time_limit");
83
+ if (directoriesVisited >= cfg.maxDirectories) {
84
+ return result(files, directoriesVisited, entriesVisited, "directory_limit");
85
+ }
20
86
  const dir = stack.pop();
87
+ directoriesVisited++;
88
+ if (recursiveRootContainsHome(dir))
89
+ continue;
90
+ let directory;
91
+ try {
92
+ // Stream Dirents so maxEntries is an allocation boundary too; readdir() would first materialize a
93
+ // million-entry directory and only then let us enforce the counter.
94
+ directory = await opendir(dir);
95
+ }
96
+ catch {
97
+ throwIfAborted(cfg.signal);
98
+ continue;
99
+ }
100
+ try {
101
+ throwIfAborted(cfg.signal);
102
+ if (timedOut())
103
+ return result(files, directoriesVisited, entriesVisited, "time_limit");
104
+ for await (const entry of directory) {
105
+ throwIfAborted(cfg.signal);
106
+ if (timedOut())
107
+ return result(files, directoriesVisited, entriesVisited, "time_limit");
108
+ if (entriesVisited >= cfg.maxEntries) {
109
+ return result(files, directoriesVisited, entriesVisited, "entry_limit");
110
+ }
111
+ entriesVisited++;
112
+ sinceYield++;
113
+ // Yield before filters/continues so a forest of ignored or sensitive entries remains cancellable.
114
+ if (sinceYield >= cfg.yieldEvery) {
115
+ sinceYield = 0;
116
+ await yieldToEventLoop();
117
+ throwIfAborted(cfg.signal);
118
+ if (timedOut())
119
+ return result(files, directoriesVisited, entriesVisited, "time_limit");
120
+ }
121
+ if (entry.name.startsWith(".") && entry.name !== ".env" && entry.isDirectory() && IGNORE_DIRS.has(entry.name)) {
122
+ continue;
123
+ }
124
+ if (entry.isDirectory()) {
125
+ if (IGNORE_DIRS.has(entry.name))
126
+ continue;
127
+ stack.push(join(dir, entry.name));
128
+ }
129
+ else if (entry.isFile()) {
130
+ const absolute = join(dir, entry.name);
131
+ if (isSensitiveFilePath(absolute))
132
+ continue;
133
+ files.push(toPosix(relative(root, absolute)));
134
+ if (files.length >= cfg.maxFiles) {
135
+ return result(files, directoriesVisited, entriesVisited, "file_limit");
136
+ }
137
+ }
138
+ }
139
+ }
140
+ catch {
141
+ throwIfAborted(cfg.signal);
142
+ // Directory vanished or became unreadable while streaming it; keep the best-effort inventory.
143
+ }
144
+ finally {
145
+ // Node normally closes a fully consumed async Dir iterator itself. Early budget/cancellation exits
146
+ // also pass here; ignore ERR_DIR_CLOSED when the iterator already performed the close.
147
+ try {
148
+ await directory.close();
149
+ }
150
+ catch {
151
+ /* already closed */
152
+ }
153
+ }
154
+ // Empty-directory forests do not increment the Dirent counter while they are being popped. Yield on
155
+ // directory progress too, otherwise that exact shape can starve AbortSignal timers.
156
+ sinceYield++;
157
+ if (sinceYield >= cfg.yieldEvery) {
158
+ sinceYield = 0;
159
+ await yieldToEventLoop();
160
+ throwIfAborted(cfg.signal);
161
+ if (timedOut())
162
+ return result(files, directoriesVisited, entriesVisited, "time_limit");
163
+ }
164
+ }
165
+ return result(files, directoriesVisited, entriesVisited);
166
+ }
167
+ /**
168
+ * Synchronous compatibility API. New agent/tool code must use `walkFilesAsync` so cancellation can run.
169
+ * This legacy path is nevertheless bounded by directory count, Dirent count and a short wall budget.
170
+ */
171
+ export function walkFiles(root, cap = DEFAULT_MAX_FILES, options = {}) {
172
+ // Defense in depth: callers that need an explicitly selected child directory pass that child as root.
173
+ // No generic inventory helper may silently turn the user's entire home into a project corpus.
174
+ if (recursiveRootContainsHome(root))
175
+ return [];
176
+ const cfg = normalizeOptions({ ...options, maxFiles: cap }, DEFAULT_SYNC_TIMEOUT_MS);
177
+ const startedAt = Date.now();
178
+ const files = [];
179
+ const stack = [root];
180
+ let directoriesVisited = 0;
181
+ let entriesVisited = 0;
182
+ while (stack.length && files.length < cfg.maxFiles) {
183
+ if (Date.now() - startedAt >= cfg.timeoutMs)
184
+ break;
185
+ if (directoriesVisited >= cfg.maxDirectories || entriesVisited >= cfg.maxEntries)
186
+ break;
187
+ const dir = stack.pop();
188
+ directoriesVisited++;
189
+ if (recursiveRootContainsHome(dir))
190
+ continue;
21
191
  let entries;
22
192
  try {
23
193
  entries = readdirSync(dir, { withFileTypes: true });
@@ -26,8 +196,11 @@ export function walkFiles(root, cap = 8000) {
26
196
  continue;
27
197
  }
28
198
  for (const e of entries) {
29
- if (files.length >= cap)
199
+ if (files.length >= cfg.maxFiles || entriesVisited >= cfg.maxEntries)
30
200
  break;
201
+ if (Date.now() - startedAt >= cfg.timeoutMs)
202
+ break;
203
+ entriesVisited++;
31
204
  if (e.name.startsWith(".") && e.name !== ".env" && e.isDirectory()) {
32
205
  if (IGNORE_DIRS.has(e.name))
33
206
  continue;
@@ -52,6 +225,9 @@ export function walkFiles(root, cap = 8000) {
52
225
  * Otherwise: a filesystem walk. POSIX-relative paths.
53
226
  */
54
227
  export function listProjectFiles(root, cap = 8000) {
228
+ if (recursiveRootContainsHome(root))
229
+ return [];
230
+ const startedAt = Date.now();
55
231
  try {
56
232
  const out = execSync("git ls-files --cached --others --exclude-standard", {
57
233
  cwd: root,
@@ -61,7 +237,7 @@ export function listProjectFiles(root, cap = 8000) {
61
237
  stdio: ["ignore", "pipe", "ignore"],
62
238
  // Bound it: a hung git (credential-helper GUI, a slow/network filesystem, a giant repo) must not
63
239
  // freeze the "which files exist" probe — on timeout it throws → we fall through to the fs walk.
64
- timeout: 5000,
240
+ timeout: DEFAULT_SYNC_TIMEOUT_MS,
65
241
  })
66
242
  .split("\n")
67
243
  .map((s) => s.trim())
@@ -72,7 +248,94 @@ export function listProjectFiles(root, cap = 8000) {
72
248
  catch {
73
249
  /* not a git repo — fall through to fs walk */
74
250
  }
75
- return walkFiles(root, cap);
251
+ return walkFiles(root, cap, { timeoutMs: Math.max(0, DEFAULT_SYNC_TIMEOUT_MS - (Date.now() - startedAt)) });
252
+ }
253
+ function gitProjectFiles(root, timeoutMs, signal) {
254
+ return new Promise((resolveOutput, rejectOutput) => {
255
+ try {
256
+ execFile("git", ["ls-files", "--cached", "--others", "--exclude-standard"], {
257
+ cwd: root,
258
+ env: toolSubprocessEnv(),
259
+ encoding: "utf8",
260
+ maxBuffer: 8 * 1024 * 1024,
261
+ timeout: Math.max(1, timeoutMs),
262
+ signal,
263
+ windowsHide: true,
264
+ }, (error, stdout) => {
265
+ if (error)
266
+ rejectOutput(error);
267
+ else
268
+ resolveOutput(stdout);
269
+ });
270
+ }
271
+ catch (error) {
272
+ rejectOutput(error);
273
+ }
274
+ });
275
+ }
276
+ /**
277
+ * Interruptible project inventory. The git probe and filesystem fallback share one total wall budget;
278
+ * an unavailable or slow git process can never grant the fallback a fresh timeout.
279
+ */
280
+ export async function listProjectFilesAsync(root, options = {}) {
281
+ const cfg = normalizeOptions(options, DEFAULT_ASYNC_TIMEOUT_MS);
282
+ const startedAt = Date.now();
283
+ throwIfAborted(cfg.signal);
284
+ if (recursiveRootContainsHome(root))
285
+ return result([], 0, 0);
286
+ if (cfg.maxFiles === 0)
287
+ return result([], 0, 0, "file_limit");
288
+ if (cfg.maxEntries === 0)
289
+ return result([], 0, 0, "entry_limit");
290
+ const remaining = () => cfg.timeoutMs - (Date.now() - startedAt);
291
+ try {
292
+ if (remaining() <= 0)
293
+ return result([], 0, 0, "time_limit");
294
+ const stdout = await gitProjectFiles(root, remaining(), cfg.signal);
295
+ throwIfAborted(cfg.signal);
296
+ if (remaining() <= 0)
297
+ return result([], 0, 0, "time_limit");
298
+ const files = [];
299
+ let entriesVisited = 0;
300
+ for (const rawPath of stdout.split("\n")) {
301
+ throwIfAborted(cfg.signal);
302
+ if (remaining() <= 0)
303
+ return result(files, 0, entriesVisited, "time_limit");
304
+ const projectPath = rawPath.trim();
305
+ if (!projectPath)
306
+ continue;
307
+ if (entriesVisited >= cfg.maxEntries)
308
+ return result(files, 0, entriesVisited, "entry_limit");
309
+ entriesVisited++;
310
+ if (!isSensitiveFilePath(join(root, projectPath)))
311
+ files.push(toPosix(projectPath));
312
+ if (files.length >= cfg.maxFiles)
313
+ return result(files, 0, entriesVisited, "file_limit");
314
+ if (entriesVisited % cfg.yieldEvery === 0) {
315
+ await yieldToEventLoop();
316
+ throwIfAborted(cfg.signal);
317
+ }
318
+ }
319
+ // A successful empty result is authoritative (empty repo, everything ignored, or only protected
320
+ // paths). Falling back would violate .gitignore/protected-file semantics.
321
+ return result(files, 0, entriesVisited);
322
+ }
323
+ catch {
324
+ throwIfAborted(cfg.signal);
325
+ // Not a git repo, git unavailable, or its bounded probe expired: fall through with only the budget
326
+ // that remains. A timeout is intentionally not reset for the filesystem traversal.
327
+ }
328
+ const timeoutMs = remaining();
329
+ if (timeoutMs <= 0)
330
+ return result([], 0, 0, "time_limit");
331
+ return walkFilesAsync(root, {
332
+ maxFiles: cfg.maxFiles,
333
+ maxDirectories: cfg.maxDirectories,
334
+ maxEntries: cfg.maxEntries,
335
+ timeoutMs,
336
+ signal: cfg.signal,
337
+ yieldEvery: cfg.yieldEvery,
338
+ });
76
339
  }
77
340
  /** Directory prefixes implied by a set of file paths, e.g. "a/b/c.ts" → "a/", "a/b/". */
78
341
  export function dirPrefixes(files) {
@@ -110,3 +373,12 @@ export function nearestPaths(cwd, p, n = 3) {
110
373
  .slice(0, n)
111
374
  .map((r) => r.item);
112
375
  }
376
+ /** Interruptible did-you-mean helper for agent tools. */
377
+ export async function nearestPathsAsync(cwd, p, n = 3, options = {}) {
378
+ if (!p)
379
+ return [];
380
+ const inventory = await listProjectFilesAsync(cwd, options);
381
+ return fuzzyRank(p, inventory.files, (f) => f)
382
+ .slice(0, n)
383
+ .map((ranked) => ranked.item);
384
+ }
package/dist/fs-write.js CHANGED
@@ -312,6 +312,11 @@ async function syncDirectory(path) {
312
312
  }
313
313
  /** Atomically replace/create a UTF-8 file, optionally refusing to overwrite a newer disk version. */
314
314
  export async function atomicWriteText(path, content, options = {}) {
315
+ const throwIfCancelled = () => {
316
+ if (options.signal?.aborted)
317
+ throw new Error(`write cancelled before commit: ${path}`);
318
+ };
319
+ throwIfCancelled();
315
320
  if (options.boundary) {
316
321
  if (resolve(path) !== resolve(options.boundary.target))
317
322
  throw new FileChangedError(path);
@@ -393,6 +398,7 @@ export async function atomicWriteText(path, content, options = {}) {
393
398
  try {
394
399
  // Keep the final parent-identity check and namespace mutation in one JS turn. Node has no portable
395
400
  // openat/linkat API; synchronous link is the narrowest available commit boundary.
401
+ throwIfCancelled();
396
402
  verifyCommitParent();
397
403
  linkSync(temp, target);
398
404
  }
@@ -409,6 +415,7 @@ export async function atomicWriteText(path, content, options = {}) {
409
415
  // path leaves a verify→commit race; a concurrent replacement can otherwise be silently destroyed.
410
416
  const claimed = join(dir, `.hara-claim-${process.pid}-${randomUUID()}.tmp`);
411
417
  try {
418
+ throwIfCancelled();
412
419
  verifyCommitParent();
413
420
  renameSync(target, claimed);
414
421
  }
@@ -459,6 +466,7 @@ export async function atomicWriteText(path, content, options = {}) {
459
466
  if (!claimedIdentity)
460
467
  throw new Error(`Failed to identify claimed file for ${path}`);
461
468
  try {
469
+ throwIfCancelled();
462
470
  verifyCommitParent();
463
471
  linkSync(temp, target); // atomic create-if-absent: never overwrites an entry created after claim.
464
472
  }
@@ -486,6 +494,7 @@ export async function atomicWriteText(path, content, options = {}) {
486
494
  }
487
495
  }
488
496
  else {
497
+ throwIfCancelled();
489
498
  verifyCommitParent();
490
499
  renameSync(temp, target);
491
500
  staged = false;