talon-agent 3.0.2 → 3.0.4

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.4",
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",
@@ -52,6 +52,10 @@ async function probeSupportedModels(
52
52
  reject(new Error("aborted")),
53
53
  );
54
54
  });
55
+ // Unreachable — the promise above only ever rejects. The yield* both
56
+ // satisfies require-yield and documents that this stream produces
57
+ // nothing by design (streaming-input mode with no user message).
58
+ yield* [] as never[];
55
59
  };
56
60
 
57
61
  const q = query({
@@ -43,6 +43,10 @@ export async function warmSession(chatId: string): Promise<void> {
43
43
  reject(new Error("aborted")),
44
44
  );
45
45
  });
46
+ // Unreachable — the promise above only ever rejects. The yield* both
47
+ // satisfies require-yield and documents that this stream produces
48
+ // nothing by design (streaming-input mode with no user message).
49
+ yield* [] as never[];
46
50
  };
47
51
 
48
52
  const q = query({
@@ -133,7 +133,8 @@ export async function ensureChatMcpServer<TClient extends RemoteAgentClient>(
133
133
  // Rotate out other chat servers BEFORE registering this one. The
134
134
  // heartbeat sentinel server is exempt (it has no real chat to leak
135
135
  // into and stays connected for cron / trigger paths).
136
- for (const other of [...state.registeredMcpServers]) {
136
+ // Snapshot: rotation removes entries from the live set mid-iteration.
137
+ for (const other of Array.from(state.registeredMcpServers)) {
137
138
  if (
138
139
  !other.startsWith(`${TALON_MCP_SERVER_NAME}-`) ||
139
140
  other === serverName ||
@@ -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(
@@ -625,6 +672,16 @@ async function edit(
625
672
  }
626
673
  }
627
674
 
675
+ // Binary guard: decoding binary as UTF-8 is lossy (invalid sequences
676
+ // become U+FFFD), so a read→replace→write round-trip would corrupt every
677
+ // non-text byte — even when old_string matches a clean region.
678
+ if (content.includes("\0")) {
679
+ return {
680
+ ok: false,
681
+ text: `${shown} looks binary — refusing a text edit that would corrupt it. Use bash for byte-level changes.`,
682
+ };
683
+ }
684
+
628
685
  const count = from ? content.split(from).length - 1 : 0;
629
686
  if (count === 0) {
630
687
  return {
@@ -638,17 +695,22 @@ async function edit(
638
695
  text: `old_string appears ${count}× in ${shown}; pass replace_all or make it unique.`,
639
696
  };
640
697
  }
698
+ // Splice, not String.replace: replace() treats dollar-sign substitution
699
+ // patterns ($&, $', $BACKTICK, $$) in the replacement as directives, silently
700
+ // corrupting any new_string that contains them (shell snippets, regexes,
701
+ // Makefiles...). split/join and index-splice are both literal.
641
702
  const updated =
642
703
  replaceAll === true
643
704
  ? content.split(from).join(to)
644
- : content.replace(from, to);
705
+ : spliceOnce(content, from, to);
706
+ const firstLine = content.slice(0, content.indexOf(from)).split("\n").length;
645
707
 
646
708
  if (active) {
647
709
  const res = await svc.writeFileToDevice(active.deviceId, p, updated);
648
710
  return res.ok
649
711
  ? {
650
712
  ok: true,
651
- text: `Edited ${shown} [${active.deviceName}] (${count} replacement${count === 1 ? "" : "s"}).`,
713
+ text: `Edited ${shown} [${active.deviceName}] (${count} replacement${count === 1 ? "" : "s"}, first at line ${firstLine}).`,
652
714
  }
653
715
  : res;
654
716
  }
@@ -662,7 +724,7 @@ async function edit(
662
724
  }
663
725
  return {
664
726
  ok: true,
665
- text: `Edited ${shown} [local] (${count} replacement${count === 1 ? "" : "s"}).`,
727
+ text: `Edited ${shown} [local] (${count} replacement${count === 1 ? "" : "s"}, first at line ${firstLine}).`,
666
728
  };
667
729
  }
668
730
 
@@ -682,8 +744,8 @@ async function glob(
682
744
  // -name, path patterns via -path). `command -v` gates the choice so a
683
745
  // no-match rg exit (1) isn't misread as "rg missing, run find too".
684
746
  const findExpr = pat.includes("/")
685
- ? `-path ${shellQuote(`*${pat}`)}`
686
- : `-name ${shellQuote(pat)}`;
747
+ ? `-type f -path ${shellQuote(`*${pat}`)}`
748
+ : `-type f -name ${shellQuote(pat)}`;
687
749
  const cmd =
688
750
  `if command -v rg >/dev/null 2>&1; ` +
689
751
  `then rg --files -g ${shellQuote(pat)} ${shellQuote(root)}; ` +
@@ -705,6 +767,9 @@ async function glob(
705
767
  } else {
706
768
  files = res.stdout.trim().split("\n").filter(Boolean);
707
769
  }
770
+ // rg's parallel directory walk (and the JS fallback's) emit in
771
+ // nondeterministic order — sort so identical calls render identically.
772
+ files.sort();
708
773
  return {
709
774
  ok: true,
710
775
  text: files.length
@@ -840,6 +905,36 @@ async function searchJs(
840
905
  return lines;
841
906
  }
842
907
 
908
+ function oversizeReadResult(
909
+ shown: string,
910
+ where: string,
911
+ size: number,
912
+ p: string,
913
+ ): Result {
914
+ return {
915
+ ok: false,
916
+ text:
917
+ `${shown} [${where}] is ${(size / 1_048_576).toFixed(1)}MB — over the ` +
918
+ `${MAX_READ_FILE_BYTES / 1_048_576}MB read limit. Slice it with bash instead: ` +
919
+ `\`sed -n '1,200p' '${p}'\`, \`tail -n 200 '${p}'\`, or \`grep\` for what you need.`,
920
+ };
921
+ }
922
+
923
+ function oversizeImageResult(
924
+ shown: string,
925
+ where: string,
926
+ size: number,
927
+ p: string,
928
+ ): Result {
929
+ return {
930
+ ok: false,
931
+ text:
932
+ `${shown} [${where}] is ${(size / 1_048_576).toFixed(1)}MB — over the ` +
933
+ `${MAX_IMAGE_BYTES / 1_048_576}MB image limit. Downscale it first ` +
934
+ `(e.g. \`convert '${p}' -resize 1568x /tmp/small.jpg\`) and read that.`,
935
+ };
936
+ }
937
+
843
938
  /**
844
939
  * When an edit's old_string doesn't match verbatim, the cause is almost
845
940
  * always invisible: indentation, tabs vs spaces, or a trailing space. Point
@@ -862,6 +957,13 @@ function nearMissHint(content: string, from: string): string {
862
957
  );
863
958
  }
864
959
 
960
+ /** Replace the first occurrence of `from` with `to`, both taken literally. */
961
+ function spliceOnce(content: string, from: string, to: string): string {
962
+ const idx = content.indexOf(from);
963
+ if (idx === -1) return content;
964
+ return content.slice(0, idx) + to + content.slice(idx + from.length);
965
+ }
966
+
865
967
  // ── helpers ─────────────────────────────────────────────────────────────────
866
968
 
867
969
  function runLocal(
@@ -193,7 +193,9 @@ export class MeshService {
193
193
  // Stamp arrival against our own clock so waitForFreshLocation is immune
194
194
  // to device clock skew.
195
195
  this.receivedAt.set(loc.deviceId, Date.now());
196
- for (const notify of [...this.waiters]) notify();
196
+ // Snapshot: a notified waiter removes itself (and new waiters may be
197
+ // added) mid-iteration — iterate a copy, not the live set.
198
+ for (const notify of Array.from(this.waiters)) notify();
197
199
  return loc;
198
200
  }
199
201
 
@@ -372,11 +374,11 @@ export class MeshService {
372
374
 
373
375
  /** `ring_device`: make the device sound/vibrate so it can be found. */
374
376
  ringDevice(query?: unknown, message?: unknown): Promise<MeshToolResult> {
375
- return this.commandTool(query, "ring", {
376
- ...(typeof message === "string" && message.trim()
377
- ? { message: message.trim().slice(0, 200) }
378
- : {}),
379
- });
377
+ const note =
378
+ typeof message === "string" && message.trim()
379
+ ? message.trim().slice(0, 200)
380
+ : undefined;
381
+ return this.commandTool(query, "ring", note ? { message: note } : {});
380
382
  }
381
383
 
382
384
  /**
@@ -77,7 +77,7 @@ export function setPluginEnabled(
77
77
  if (!name) return { ok: false, error: "A plugin name is required." };
78
78
 
79
79
  if (isBuiltinPlugin(name)) {
80
- const section = { ...(builtinSection(config, name) ?? {}), enabled };
80
+ const section = { ...builtinSection(config, name), enabled };
81
81
  (config as unknown as Record<string, unknown>)[name] = section;
82
82
  return { ok: true, name, persist: { [name]: section } };
83
83
  }
@@ -48,7 +48,7 @@ export function pagerank(
48
48
  const idx = new Map(nodes.map((h, i) => [h, i]));
49
49
 
50
50
  // Weighted out-adjacency over coactivation edges (only among value nodes).
51
- const outWeight = new Array<number>(n).fill(0);
51
+ const outWeight = Array.from({ length: n }, () => 0);
52
52
  const edges: { from: number; to: number; w: number }[] = [];
53
53
  for (const from of nodes) {
54
54
  for (const e of dag.edgesFrom(from, "coactivation")) {
@@ -61,7 +61,7 @@ export function pagerank(
61
61
  }
62
62
 
63
63
  // Teleport / personalization vector.
64
- const teleport = new Array<number>(n).fill(1 / n);
64
+ const teleport = Array.from({ length: n }, () => 1 / n);
65
65
  if (opts.personalization && opts.personalization.size > 0) {
66
66
  let total = 0;
67
67
  for (const [, w] of opts.personalization) total += Math.max(0, w);
@@ -74,7 +74,7 @@ export function pagerank(
74
74
  }
75
75
  }
76
76
 
77
- let rank = new Array<number>(n).fill(1 / n);
77
+ let rank = Array.from({ length: n }, () => 1 / n);
78
78
  for (let it = 0; it < iterations; it++) {
79
79
  const next = teleport.map((t) => (1 - damping) * t);
80
80
 
@@ -66,7 +66,10 @@ const SYCOPHANCY = [
66
66
 
67
67
  // Emoji detection without a heavy dependency: pictographic + symbol ranges.
68
68
  const EMOJI_RE =
69
- /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}\u{FE00}-\u{FE0F}\u{1F000}-\u{1F0FF}]/gu;
69
+ // Variation selectors are combining marks — alternated separately so the
70
+ // character class holds only standalone pictographic ranges
71
+ // (no-misleading-character-class).
72
+ /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}\u{1F000}-\u{1F0FF}]|[\u{FE00}-\u{FE0F}]/gu;
70
73
 
71
74
  function countOccurrences(
72
75
  haystack: string,
@@ -63,7 +63,7 @@ export function cosineDistance(
63
63
  export function centroid(vectors: readonly (readonly number[])[]): number[] {
64
64
  if (vectors.length === 0) return [];
65
65
  const dim = vectors[0]!.length;
66
- const acc = new Array<number>(dim).fill(0);
66
+ const acc = Array.from({ length: dim }, () => 0);
67
67
  for (const v of vectors) {
68
68
  for (let i = 0; i < dim; i++) acc[i]! += v[i]!;
69
69
  }
@@ -110,7 +110,7 @@ export class HashingEmbedder implements Embedder {
110
110
  }
111
111
 
112
112
  private embedOne(text: string): number[] {
113
- const vec = new Array<number>(this.dim).fill(0);
113
+ const vec = Array.from({ length: this.dim }, () => 0);
114
114
  const lower = text.toLowerCase();
115
115
  const add = (feature: string): void => {
116
116
  const h = createHash("md5").update(feature).digest();
@@ -67,7 +67,7 @@ export class TalonEmbedder implements Embedder {
67
67
  }
68
68
  }
69
69
 
70
- const vec = new Array<number>(this.dim).fill(0);
70
+ const vec = Array.from({ length: this.dim }, () => 0);
71
71
  for (const [feature, count] of counts) {
72
72
  const h = createHash("md5").update(feature).digest();
73
73
  const idx = ((h[0]! << 16) | (h[1]! << 8) | h[2]!) % this.dim;
@@ -98,9 +98,9 @@ export function createGitHubPlugin(config: { token?: string }): TalonPlugin {
98
98
  },
99
99
 
100
100
  getEnvVars() {
101
- return {
102
- ...(token ? { GITHUB_PERSONAL_ACCESS_TOKEN: token } : {}),
103
- };
101
+ const vars: Record<string, string> = {};
102
+ if (token) vars.GITHUB_PERSONAL_ACCESS_TOKEN = token;
103
+ return vars;
104
104
  },
105
105
  };
106
106
  }