talon-agent 3.0.2 → 3.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.0.2",
3
+ "version": "3.0.3",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -75,6 +75,12 @@ const TELEPORT_TIMEOUT_HINT =
75
75
  "background it in-shell instead: `cmd > /tmp/out.log 2>&1 &`, then poll the log " +
76
76
  "with read, or bound the command itself: `logcat -d`, `timeout 30 …`, `head -n 200`.)";
77
77
  const MAX_READ_LINES = 2_000;
78
+ /**
79
+ * Refuse to slurp huge files into memory: `read` loads the whole file to
80
+ * slice lines, so an unbounded readFile of a multi-GB log balloons RSS.
81
+ * Past this, bash is the right tool (`sed -n`, `tail`, `head`).
82
+ */
83
+ const MAX_READ_FILE_BYTES = 32 * 1024 * 1024;
78
84
  /**
79
85
  * Image extensions the model can actually view, mapped to their MIME type.
80
86
  * Reading one returns an image content block instead of the file's raw bytes
@@ -169,7 +175,19 @@ async function bash(
169
175
  const active = await getTeleport(chatId);
170
176
 
171
177
  let dir = typeof cwd === "string" && cwd.trim() ? cwd.trim() : undefined;
172
- if (dir !== undefined && !active) dir = resolvePathParam(dir, undefined);
178
+ if (dir !== undefined && !active) {
179
+ dir = resolvePathParam(dir, undefined);
180
+ // Validate here: a bad cwd makes spawn fail with "spawn bash ENOENT",
181
+ // which reads as "bash is missing" and sends the model down the wrong
182
+ // path. Name the actual problem instead.
183
+ try {
184
+ const st = await fsStat(dir);
185
+ if (!st.isDirectory())
186
+ return { ok: false, text: `cwd is not a directory: ${dir}` };
187
+ } catch {
188
+ return { ok: false, text: `Working directory does not exist: ${dir}` };
189
+ }
190
+ }
173
191
 
174
192
  const result = await (async () => {
175
193
  if (background === true) {
@@ -509,6 +527,10 @@ async function read(
509
527
  bytes = res.data;
510
528
  } else {
511
529
  try {
530
+ // stat first — no point slurping a 40MB photo just to refuse it.
531
+ const st = await fsStat(p);
532
+ if (st.size > MAX_IMAGE_BYTES)
533
+ return oversizeImageResult(shown, where, st.size, p);
512
534
  bytes = await readFile(p);
513
535
  } catch (err) {
514
536
  return {
@@ -518,13 +540,7 @@ async function read(
518
540
  }
519
541
  }
520
542
  if (bytes.length > MAX_IMAGE_BYTES) {
521
- return {
522
- ok: false,
523
- text:
524
- `${shown} [${where}] is ${(bytes.length / 1_048_576).toFixed(1)}MB — over the ` +
525
- `${MAX_IMAGE_BYTES / 1_048_576}MB image limit. Downscale it first ` +
526
- `(e.g. \`convert '${p}' -resize 1568x /tmp/small.jpg\`) and read that.`,
527
- };
543
+ return oversizeImageResult(shown, where, bytes.length, p);
528
544
  }
529
545
  return {
530
546
  ok: true,
@@ -533,15 +549,26 @@ async function read(
533
549
  };
534
550
  }
535
551
 
536
- const start = num(offset) ?? 0;
537
- const max = Math.min(num(limit) ?? MAX_READ_LINES, MAX_READ_LINES);
552
+ // Clamp: a negative offset would silently flip slice() into
553
+ // count-from-the-end (with line numbers that lie), and a zero/negative
554
+ // limit would return an empty read that looks like an empty file.
555
+ const start = Math.max(0, Math.trunc(num(offset) ?? 0));
556
+ const max = Math.min(
557
+ Math.max(1, Math.trunc(num(limit) ?? MAX_READ_LINES)),
558
+ MAX_READ_LINES,
559
+ );
538
560
  let content: string;
539
561
  if (active) {
540
562
  const res = await getMeshService().readFileBytes(active.deviceId, p);
541
563
  if ("error" in res) return { ok: false, text: res.error };
564
+ if (res.data.length > MAX_READ_FILE_BYTES)
565
+ return oversizeReadResult(shown, where, res.data.length, p);
542
566
  content = res.data.toString("utf8");
543
567
  } else {
544
568
  try {
569
+ const st = await fsStat(p);
570
+ if (st.isFile() && st.size > MAX_READ_FILE_BYTES)
571
+ return oversizeReadResult(shown, where, st.size, p);
545
572
  content = await readFile(p, "utf8");
546
573
  } catch (err) {
547
574
  return {
@@ -550,7 +577,24 @@ async function read(
550
577
  };
551
578
  }
552
579
  }
580
+ // Binary files decoded as UTF-8 are mojibake the model can't use — same
581
+ // rationale as the image branch, but with no viewable representation to
582
+ // return. Point at the shell tools that can actually inspect the bytes.
583
+ if (content.includes("\0")) {
584
+ return {
585
+ ok: false,
586
+ text:
587
+ `${shown} [${where}] looks binary — refusing to render it as text. ` +
588
+ `Inspect it with bash instead: \`file '${p}'\`, \`xxd '${p}' | head\`, \`strings '${p}'\`.`,
589
+ };
590
+ }
553
591
  const lines = content.split("\n");
592
+ if (start >= lines.length) {
593
+ return {
594
+ ok: false,
595
+ text: `${shown} [${where}] has ${lines.length} lines — offset ${start} is past the end.`,
596
+ };
597
+ }
554
598
  const slice = lines.slice(start, start + max);
555
599
  const numbered = slice
556
600
  .map((line, i) => `${String(start + i + 1).padStart(6)}\t${line}`)
@@ -588,7 +632,10 @@ async function write(
588
632
  text: `Cannot write ${shown}: ${(err as Error).message}`,
589
633
  };
590
634
  }
591
- return { ok: true, text: `Wrote ${body.length} bytes to ${shown} [local].` };
635
+ return {
636
+ ok: true,
637
+ text: `Wrote ${Buffer.byteLength(body, "utf8")} bytes to ${shown} [local].`,
638
+ };
592
639
  }
593
640
 
594
641
  async function edit(
@@ -638,10 +685,14 @@ async function edit(
638
685
  text: `old_string appears ${count}× in ${shown}; pass replace_all or make it unique.`,
639
686
  };
640
687
  }
688
+ // Splice, not String.replace: replace() treats dollar-sign substitution
689
+ // patterns ($&, $', $BACKTICK, $$) in the replacement as directives, silently
690
+ // corrupting any new_string that contains them (shell snippets, regexes,
691
+ // Makefiles...). split/join and index-splice are both literal.
641
692
  const updated =
642
693
  replaceAll === true
643
694
  ? content.split(from).join(to)
644
- : content.replace(from, to);
695
+ : spliceOnce(content, from, to);
645
696
 
646
697
  if (active) {
647
698
  const res = await svc.writeFileToDevice(active.deviceId, p, updated);
@@ -705,6 +756,9 @@ async function glob(
705
756
  } else {
706
757
  files = res.stdout.trim().split("\n").filter(Boolean);
707
758
  }
759
+ // rg's parallel directory walk (and the JS fallback's) emit in
760
+ // nondeterministic order — sort so identical calls render identically.
761
+ files.sort();
708
762
  return {
709
763
  ok: true,
710
764
  text: files.length
@@ -840,6 +894,36 @@ async function searchJs(
840
894
  return lines;
841
895
  }
842
896
 
897
+ function oversizeReadResult(
898
+ shown: string,
899
+ where: string,
900
+ size: number,
901
+ p: string,
902
+ ): Result {
903
+ return {
904
+ ok: false,
905
+ text:
906
+ `${shown} [${where}] is ${(size / 1_048_576).toFixed(1)}MB — over the ` +
907
+ `${MAX_READ_FILE_BYTES / 1_048_576}MB read limit. Slice it with bash instead: ` +
908
+ `\`sed -n '1,200p' '${p}'\`, \`tail -n 200 '${p}'\`, or \`grep\` for what you need.`,
909
+ };
910
+ }
911
+
912
+ function oversizeImageResult(
913
+ shown: string,
914
+ where: string,
915
+ size: number,
916
+ p: string,
917
+ ): Result {
918
+ return {
919
+ ok: false,
920
+ text:
921
+ `${shown} [${where}] is ${(size / 1_048_576).toFixed(1)}MB — over the ` +
922
+ `${MAX_IMAGE_BYTES / 1_048_576}MB image limit. Downscale it first ` +
923
+ `(e.g. \`convert '${p}' -resize 1568x /tmp/small.jpg\`) and read that.`,
924
+ };
925
+ }
926
+
843
927
  /**
844
928
  * When an edit's old_string doesn't match verbatim, the cause is almost
845
929
  * always invisible: indentation, tabs vs spaces, or a trailing space. Point
@@ -862,6 +946,13 @@ function nearMissHint(content: string, from: string): string {
862
946
  );
863
947
  }
864
948
 
949
+ /** Replace the first occurrence of `from` with `to`, both taken literally. */
950
+ function spliceOnce(content: string, from: string, to: string): string {
951
+ const idx = content.indexOf(from);
952
+ if (idx === -1) return content;
953
+ return content.slice(0, idx) + to + content.slice(idx + from.length);
954
+ }
955
+
865
956
  // ── helpers ─────────────────────────────────────────────────────────────────
866
957
 
867
958
  function runLocal(