@vincemakes/kiso-tools-node 0.24.3 → 0.24.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.
package/dist/index.d.ts CHANGED
@@ -93,6 +93,22 @@ export interface WorkspaceToolsOptions {
93
93
  * the keys are an exposure surface for any command).
94
94
  */
95
95
  readonly shellEnv?: "inherit";
96
+ /**
97
+ * DC-54 — the bounds that keep a tool call finite. Every field is
98
+ * optional and defaults to the constant beside it; a host embedding
99
+ * kiso over an unusually large or unusually small tree can move them,
100
+ * and the gate can set them low enough to observe the stop.
101
+ */
102
+ readonly limits?: {
103
+ /** search_text: skip a file larger than this (default 1 MiB). */
104
+ readonly searchMaxFileBytes?: number;
105
+ /** search_text: stop the walk after this many files (default 20,000). */
106
+ readonly searchMaxFiles?: number;
107
+ /** search_text: stop the walk after this long (default 10s). */
108
+ readonly searchMaxMs?: number;
109
+ /** read_file: refuse a file larger than this (default 64 MiB). */
110
+ readonly readMaxFileBytes?: number;
111
+ };
96
112
  }
97
113
  export declare function readFileTool(opts: WorkspaceToolsOptions): Tool<{
98
114
  path: string;
package/dist/index.js CHANGED
@@ -17,9 +17,10 @@
17
17
  * states what was dropped (deterministic per file state), so the model
18
18
  * always has a path to the full content.
19
19
  */
20
- import { execFileSync, spawn } from "node:child_process";
20
+ import { execFile, execFileSync, spawn } from "node:child_process";
21
+ import { promisify } from "node:util";
21
22
  import { chmodSync, existsSync, linkSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, appendFileSync, mkdirSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
22
- import { readdir } from "node:fs/promises";
23
+ import { open, readdir } from "node:fs/promises";
23
24
  import { createHash } from "node:crypto";
24
25
  import { tmpdir } from "node:os";
25
26
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
@@ -79,6 +80,36 @@ const DEFAULT_READ_LINES = 200;
79
80
  const YIELD_EVERY = 64;
80
81
  const MAX_SEARCH_MATCHES = 50;
81
82
  const MAX_DIR_ENTRIES = 200;
83
+ /**
84
+ * DC-54 — the bounds. The defect they close: `search_text` read every
85
+ * file WHOLE and SYNCHRONOUSLY, so one 994 GB sparse disk image under a
86
+ * workspace rooted at `~` stopped the event loop and never restarted it.
87
+ *
88
+ * Each constant answers a different unbounded thing, and all four are
89
+ * needed — per-file bounds still leave 296,924 files to traverse under
90
+ * `~`, and a call that traverses forever is a freeze whatever it does
91
+ * per file. The tool must ALWAYS return.
92
+ */
93
+ /** Skip a file larger than this. Not a PREFIX read: a partial match is a
94
+ * result that has to be explained, and a source file over 1 MiB is
95
+ * almost never what the model was looking for. */
96
+ const SEARCH_MAX_FILE_BYTES = 1024 * 1024;
97
+ /** Stop the walk after this many files, whatever it has found. */
98
+ const SEARCH_MAX_FILES = 20_000;
99
+ /** Stop the walk after this long, whatever it has found. */
100
+ const SEARCH_MAX_MS = 10_000;
101
+ /** read_file refuses above this rather than freezing on it. The ceiling
102
+ * is a REFUSAL, not a truncation, because the `[rev:…]` token hashes
103
+ * the whole file: a revision issued over a prefix would never match the
104
+ * full-file hash write_file computes, and every later write would be
105
+ * refused as stale. No read, no revision, no lie. */
106
+ const READ_MAX_FILE_BYTES = 64 * 1024 * 1024;
107
+ /** How much of a file's head decides whether it is text. */
108
+ const BINARY_SNIFF_BYTES = 8 * 1024;
109
+ /** Bytes as the refusal should say them: "128.0 MiB". */
110
+ function mib(bytes) {
111
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
112
+ }
82
113
  function cap(text) {
83
114
  return text.length > OUTPUT_CAP ? `${text.slice(0, OUTPUT_CAP)}\n…[truncated]` : text;
84
115
  }
@@ -186,12 +217,41 @@ export function canonicalTargetPath(input) {
186
217
  * - non-regular files (sockets, devices, fifos): refused.
187
218
  * Returns a denial reason, or null when the file is safe to read.
188
219
  */
189
- function inodeReadPolicy(root, full) {
220
+ /** DC-52 — the guard's verdict, per (dev, ino), for this process. The
221
+ * scan is the expensive thing; the answer is a fact about an inode and
222
+ * does not change under us within a call. */
223
+ const inodeVerdict = new Map();
224
+ /**
225
+ * DC-52 — BOUNDED, ASYNCHRONOUS, AND SILENT.
226
+ *
227
+ * This was `execFileSync("find", [root, "-xdev", "-inum", …])` with no
228
+ * `stdio`, run once per multi-link file. Three faults in one line:
229
+ *
230
+ * 1. no `stdio` gives the child the PARENT'S stderr, which is the
231
+ * terminal — `find: …: Operation not permitted` went straight past
232
+ * the compositor's frame and over the composer;
233
+ * 2. the scan is unbounded, and SYNCHRONOUS: with the workspace root
234
+ * at `~` a single call is tens of seconds with the event loop
235
+ * frozen, so `esc` does nothing and the whole product looks hung;
236
+ * 3. it ran for `search_text` too, which returns a 160-character
237
+ * excerpt — a disk traversal to decide whether a line may be
238
+ * quoted is not a trade anyone would make.
239
+ *
240
+ * Now: `execFile` with a 2s budget, stderr discarded, verdict cached.
241
+ * A scan that does not finish inside the budget is fail-closed exactly
242
+ * as an unverifiable one always was — refused, never hung. Fault 3 is
243
+ * answered by `search_text` not calling this at all.
244
+ */
245
+ async function inodeReadPolicy(root, full) {
190
246
  const st = statSync(full);
191
247
  if (!st.isFile())
192
248
  return `not a regular file — refusing to read (${full})`;
193
249
  if (st.nlink <= 1)
194
250
  return null;
251
+ const key = `${st.dev}:${st.ino}`;
252
+ const cached = inodeVerdict.get(key);
253
+ if (cached !== undefined)
254
+ return cached;
195
255
  // round 4: the link count is verified STRUCTURALLY, never by counting
196
256
  // newline-split text. `find -print0` emits NUL-separated paths — a file
197
257
  // named "inside\nspoof" is ONE path, not two — and every match is then
@@ -205,7 +265,15 @@ function inodeReadPolicy(root, full) {
205
265
  let inside = 0;
206
266
  try {
207
267
  const rootReal = realpathSync(root);
208
- const out = execFileSync("find", [rootReal, "-xdev", "-inum", String(st.ino), "-print0"], { encoding: "utf8", maxBuffer: 1 << 20 });
268
+ const { stdout: out } = await promisify(execFile)("find", [rootReal, "-xdev", "-inum", String(st.ino), "-print0"],
269
+ // DC-52: the child never outlives the budget, and its stderr
270
+ // never reaches the terminal. The second is the ASYNC form's
271
+ // own doing and is the whole reason to prefer it here:
272
+ // `execFileSync` without an explicit `stdio` gives the child
273
+ // the PARENT'S stderr, which is how `find: … Operation not
274
+ // permitted` got over the composer. `execFile` pipes both
275
+ // streams into the callback; there is nowhere for it to go.
276
+ { encoding: "utf8", maxBuffer: 1 << 20, timeout: INODE_SCAN_MS });
209
277
  for (const path of out.split("\0")) {
210
278
  if (path === "")
211
279
  continue;
@@ -223,12 +291,15 @@ function inodeReadPolicy(root, full) {
223
291
  catch {
224
292
  inside = -1; // cannot verify — refuse (fail-closed)
225
293
  }
226
- if (inside < 0 || inside < st.nlink) {
227
- const verified = inside < 0 ? "unverifiable" : `${inside}/${st.nlink}`;
228
- return `file has hard links outside the workspace (${verified} inside) — refusing to read (${full})`;
229
- }
230
- return null;
294
+ const verdict = inside < 0 || inside < st.nlink
295
+ ? `file has hard links outside the workspace (${inside < 0 ? "unverifiable" : `${inside}/${st.nlink}`} inside) — refusing to read (${full})`
296
+ : null;
297
+ inodeVerdict.set(key, verdict);
298
+ return verdict;
231
299
  }
300
+ /** DC-52 — the guard's budget. A scan that outruns it is fail-closed,
301
+ * which is what an unverifiable scan has always been. */
302
+ const INODE_SCAN_MS = 2_000;
232
303
  /** The "… N more lines" note — the actionable continuation: the exact
233
304
  * line the next read must start at, so the model can always reach the
234
305
  * full content in ranges (the red line). */
@@ -260,13 +331,44 @@ export function readFileTool(opts) {
260
331
  promptSnippet: "read_file — whole files or offset/limit ranges, workspace-relative paths",
261
332
  promptGuidelines: ["read only the range you need — offset/limit beat whole-file reads"],
262
333
  execute: async ({ path, offset, limit }) => {
334
+ const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
263
335
  try {
264
336
  const full = resolveWithinRoot(opts.workspaceRoot, path);
265
- const denied = inodeReadPolicy(opts.workspaceRoot, full);
337
+ const denied = await inodeReadPolicy(opts.workspaceRoot, full);
266
338
  if (denied !== null)
267
339
  return escapeResult(denied);
340
+ // DC-54 — the ceiling. `read_file` had the same unbounded
341
+ // `readFileSync` that froze `search_text`, and the same 994 GB
342
+ // `Docker.raw` would have frozen it identically.
343
+ //
344
+ // It REFUSES rather than truncating, because the `[rev:…]`
345
+ // token this tool issues hashes the whole file: a revision
346
+ // computed over a prefix would never equal the full-file hash
347
+ // `write_file` computes, so every later write would be refused
348
+ // as stale. A bounded read here would buy a freeze-free read
349
+ // at the price of a write path that silently stops working.
350
+ const size = statSync(full).size;
351
+ if (size > maxReadBytes) {
352
+ return precondition(`read_file: ${path} is ${mib(size)} — too large to read (ceiling ${mib(maxReadBytes)}); use shell with sed/head to take a range`);
353
+ }
268
354
  // WR-1: hash the raw bytes BEFORE decoding — the revision is a
269
355
  // fact about the world, not about UTF-8 replacement semantics.
356
+ //
357
+ // DC-54, and this read stays SYNCHRONOUS on purpose. The
358
+ // ruling said to make it async; the first build did, and
359
+ // `tui2-r1-visibility` went red on an invariant worth more
360
+ // than the microseconds: the durable log of three concurrent
361
+ // `read_file` calls stopped being deterministic, because
362
+ // completion order became the libuv threadpool's to decide.
363
+ // That test compares a PTY session's log to a pipe session's
364
+ // to prove the rollup is display-side only, and it cannot do
365
+ // that job over a log that varies run to run.
366
+ //
367
+ // The freeze came from UNBOUNDED work, not from synchronous
368
+ // work. With the ceiling above, the worst case here is a
369
+ // 64 MiB read — about 100 ms, measured — against the 180+
370
+ // seconds this finding is named for. Determinism is worth
371
+ // more than that hitch.
270
372
  const bytes = readFileSync(full);
271
373
  const content = bytes.toString("utf8");
272
374
  // The lines the file DISPLAYS: a trailing newline's empty split
@@ -359,15 +461,26 @@ export function listDirTool(opts) {
359
461
  execute: async ({ path }) => {
360
462
  try {
361
463
  const dir = resolveWithinRoot(opts.workspaceRoot, path ?? ".");
362
- const entries = readdirSync(dir, { withFileTypes: true }).map((e) => {
464
+ // DC-54 TRUNCATED BEFORE IT BUILDS. It was a `.map` over
465
+ // EVERY entry with the 200-entry slice only after: a directory
466
+ // of 200,000 entries built 200,000 strings to show 200 of
467
+ // them. Milder than the `search_text` freeze and the same
468
+ // mistake — unbounded work before the bound.
469
+ //
470
+ // `readdirSync`, still: same reason as read_file above. The
471
+ // listing is one syscall whose cost is the directory's size,
472
+ // which no bound of ours can shrink, and making it async buys
473
+ // nothing while costing the durable log its determinism.
474
+ const dirents = readdirSync(dir, { withFileTypes: true });
475
+ const entries = dirents.slice(0, MAX_DIR_ENTRIES).map((e) => {
363
476
  const isDir = e.isDirectory();
364
477
  return `${isDir ? "dir " : "file"} ${e.name}${isDir ? "/" : ""}`;
365
478
  });
366
- let content = entries.length ? cap(entries.slice(0, MAX_DIR_ENTRIES).join("\n")) : "(empty directory)";
367
- if (entries.length > MAX_DIR_ENTRIES) {
479
+ let content = entries.length ? cap(entries.join("\n")) : "(empty directory)";
480
+ if (dirents.length > MAX_DIR_ENTRIES) {
368
481
  // R-C item 2: the N of M form — the cap names its
369
482
  // continuation (narrow to a subdirectory for more).
370
- content += `\n… ${MAX_DIR_ENTRIES} of ${entries.length} entries shown (narrow to a subdirectory for more)`;
483
+ content += `\n… ${MAX_DIR_ENTRIES} of ${dirents.length} entries shown (narrow to a subdirectory for more)`;
371
484
  }
372
485
  return { content, isError: false };
373
486
  }
@@ -442,6 +555,39 @@ export function searchTextTool(opts) {
442
555
  // depth cap and the node_modules/dotfile skip stay.
443
556
  const matches = [];
444
557
  let totalMatches = 0;
558
+ // DC-52 — what the search did NOT look at, so the note can say so.
559
+ let multiLink = 0;
560
+ let unreadableDirs = 0;
561
+ // DC-54 — the same discipline for the two new refusals, and for
562
+ // the call budget: a silent skip is a result the model cannot
563
+ // tell is incomplete.
564
+ let skippedFiles = 0;
565
+ let filesSeen = 0;
566
+ // An explicit flag, NOT `stoppedAt > 0`: a wall-clock budget can
567
+ // expire before the first file is scanned, and a zero-valued
568
+ // sentinel would then read as "never stopped" — the walk would
569
+ // still end, but silently, which is the one thing every note in
570
+ // this function exists to prevent.
571
+ let stopped = false;
572
+ let stoppedAt = 0;
573
+ const maxFileBytes = opts.limits?.searchMaxFileBytes ?? SEARCH_MAX_FILE_BYTES;
574
+ const maxFiles = opts.limits?.searchMaxFiles ?? SEARCH_MAX_FILES;
575
+ const deadline = Date.now() + (opts.limits?.searchMaxMs ?? SEARCH_MAX_MS);
576
+ /** DC-54 ④ — the CALL budget. Per-file bounds are not enough:
577
+ * under `~` the walk still reaches 296,924 files, and 10 seconds
578
+ * of traversal with nothing on screen is the same freeze from
579
+ * the outside. Whichever bound trips first stops the walk, and
580
+ * the note names the continuation. */
581
+ const outOfBudget = () => {
582
+ if (stopped)
583
+ return true;
584
+ if (filesSeen >= maxFiles || Date.now() > deadline) {
585
+ stopped = true;
586
+ stoppedAt = filesSeen;
587
+ return true;
588
+ }
589
+ return false;
590
+ };
445
591
  // R3 — the walk YIELDS. It was `readdirSync` + `readFileSync` all
446
592
  // the way down inside an `async` body, which is the shape that
447
593
  // blocks Node's event loop for the whole traversal: measured at
@@ -451,9 +597,15 @@ export function searchTextTool(opts) {
451
597
  //
452
598
  // The fix is not "make it faster" — a big tree is legitimately
453
599
  // slow. It is to stop OWNING the loop: `fs.promises.readdir`,
454
- // and a yield every YIELD_EVERY files so a long read stretch
455
- // cannot monopolise it either. Same traversal, same order, same
456
- // caps, same results; only the loop is shared now.
600
+ // and a yield every YIELD_EVERY files.
601
+ //
602
+ // DC-54 corrects what this comment used to claim next — that
603
+ // the yield also stopped "a long read stretch" monopolising the
604
+ // loop. It never did and could not: `breathe()` runs BETWEEN
605
+ // files, and nothing preempts a single synchronous read once
606
+ // entered. R3 bounded the traversal; the FILE stayed unbounded
607
+ // until DC-54 ①–③ above, and one 994 GB image was enough to
608
+ // stop the process dead with this yield fully in place.
457
609
  let sinceYield = 0;
458
610
  const breathe = async () => {
459
611
  sinceYield += 1;
@@ -468,17 +620,85 @@ export function searchTextTool(opts) {
468
620
  // Two copies would be two answers to "what does searching this
469
621
  // file mean".
470
622
  const scanFile = async (full) => {
623
+ if (outOfBudget())
624
+ return;
625
+ filesSeen += 1;
471
626
  await breathe();
472
627
  try {
473
- // round 8: same inode boundary as read_file a hard link
474
- // to an external inode is not searched. round 4 (adversarial):
475
- // the link count is verified against the WORKSPACE
476
- // root, not the search subroot a link that lives
477
- // inside the workspace but outside the search dir is
478
- // legal and must not be silently skipped.
479
- if (inodeReadPolicy(opts.workspaceRoot, full) !== null)
628
+ // DC-52 SEARCH DOES NOT RUN THE INODE GUARD.
629
+ //
630
+ // Round 8 gave search the same inode boundary read_file
631
+ // has, and the boundary is right; what was wrong is the
632
+ // price. The guard's verification is a `find` over the
633
+ // whole workspace root, once per multi-link file — with
634
+ // the root at `~` that is tens of seconds each, and it
635
+ // ran on the owner's machine for eight minutes without
636
+ // finishing. A search returns a 160-character excerpt of
637
+ // a line. No excerpt is worth a disk traversal.
638
+ //
639
+ // So a multi-link file is SKIPPED, and counted, and the
640
+ // count is said. That is fail-closed at zero cost: the
641
+ // external-link case the guard exists for is refused
642
+ // exactly as before, and the legal case is refused too,
643
+ // which is a loss of coverage rather than of safety.
644
+ // read_file keeps the guard (bounded, above), and that
645
+ // is where the file's contents can actually be had.
646
+ const st = statSync(full);
647
+ if (st.nlink > 1) {
648
+ multiLink += 1;
480
649
  return;
481
- const text = readFileSync(full, "utf8");
650
+ }
651
+ // DC-54 ① — SIZE, decided before anything is opened.
652
+ //
653
+ // This is the line that was missing when a workspace
654
+ // rooted at `~` met `Docker.raw`: 994 GB went into
655
+ // `readFileSync(full, "utf8")` and the process never came
656
+ // back. Measured on the owner's machine, without it: a
657
+ // 467 MB `.mov` read SUCCESSFULLY, 3,817 ms of dead loop
658
+ // and 2.06 GB of RSS, split into 1,823,112 "lines".
659
+ if (st.size > maxFileBytes) {
660
+ skippedFiles += 1;
661
+ return;
662
+ }
663
+ // DC-54 ② — BINARY, decided from the head alone.
664
+ //
665
+ // One open serves both the sniff and the read. The sniff
666
+ // passes an explicit position, which by contract leaves
667
+ // the handle's own position at 0, so `readFile()` below
668
+ // still sees the whole file.
669
+ const fh = await open(full, "r");
670
+ let text;
671
+ try {
672
+ const headLen = Math.min(BINARY_SNIFF_BYTES, st.size);
673
+ const head = Buffer.alloc(headLen);
674
+ if (headLen > 0) {
675
+ // An explicit `position` leaves the handle's own
676
+ // position at 0, so the whole-file read below still
677
+ // starts at byte 0. The gate proves it with a
678
+ // needle placed PAST the sniff window: were the
679
+ // position to advance, every line number in this
680
+ // file's matches would be silently wrong.
681
+ await fh.read(head, 0, headLen, 0);
682
+ if (head.includes(0)) {
683
+ skippedFiles += 1;
684
+ return;
685
+ }
686
+ }
687
+ // DC-54 ③ — the read is ASYNCHRONOUS. R3 made the walk
688
+ // yield and its comment claimed that stopped a long
689
+ // read owning the loop; it never could. `breathe()`
690
+ // runs BETWEEN files, and nothing preempts a
691
+ // `readFileSync` once entered. Bounded above by ①, so
692
+ // this is at most a 1 MiB read that shares the loop.
693
+ //
694
+ // A file that fits inside the sniff window is already
695
+ // entirely in `head`: reading it a second time would
696
+ // double the syscalls for the commonest small file.
697
+ text = (st.size <= headLen ? head : await fh.readFile()).toString("utf8");
698
+ }
699
+ finally {
700
+ await fh.close();
701
+ }
482
702
  for (const [i, line] of text.split("\n").entries()) {
483
703
  if (regex.test(line)) {
484
704
  totalMatches += 1;
@@ -493,9 +713,32 @@ export function searchTextTool(opts) {
493
713
  }
494
714
  };
495
715
  const walk = async (dir, depth) => {
496
- if (depth > 8)
716
+ if (depth > 8 || outOfBudget())
497
717
  return;
498
- for (const entry of await readdir(dir, { withFileTypes: true })) {
718
+ // DC-52 a directory the OS REFUSES is skipped, not fatal.
719
+ //
720
+ // An unreadable FILE has always been skipped (the catch in
721
+ // scanFile); an unreadable DIRECTORY threw out of the walk
722
+ // and failed the whole tool. On macOS that is not an edge
723
+ // case — `~/Library/Accounts` and its neighbours are TCC
724
+ // protected, so a search anywhere under `~` died on one of
725
+ // them. The asymmetry was the defect: same fact, same
726
+ // remedy.
727
+ let entries;
728
+ try {
729
+ entries = await readdir(dir, { withFileTypes: true });
730
+ }
731
+ catch (err) {
732
+ const code = err.code;
733
+ if (code === "EACCES" || code === "EPERM") {
734
+ unreadableDirs += 1;
735
+ return;
736
+ }
737
+ throw err;
738
+ }
739
+ for (const entry of entries) {
740
+ if (outOfBudget())
741
+ return;
499
742
  if (entry.name.startsWith(".") || entry.name === "node_modules")
500
743
  continue;
501
744
  const full = join(dir, entry.name);
@@ -515,6 +758,22 @@ export function searchTextTool(opts) {
515
758
  return { content: `search_text failed: ${err.message}`, isError: true, errorKind: "fatal" };
516
759
  }
517
760
  let content = matches.length ? cap(matches.join("\n")) : "(no matches)";
761
+ // DC-52: what was NOT searched is said. A silent skip is a
762
+ // result the model cannot tell is incomplete.
763
+ if (multiLink > 0)
764
+ content += `\n… ${multiLink} multi-link ${multiLink === 1 ? "file" : "files"} skipped (read_file verifies them individually)`;
765
+ if (unreadableDirs > 0)
766
+ content += `\n… ${unreadableDirs} unreadable ${unreadableDirs === 1 ? "directory" : "directories"} skipped`;
767
+ // DC-54 — one merged sentence, and only when it happened. A note
768
+ // that always fires says nothing.
769
+ if (skippedFiles > 0 || stopped) {
770
+ const parts = [];
771
+ if (skippedFiles > 0)
772
+ parts.push(`${skippedFiles} ${skippedFiles === 1 ? "file" : "files"} skipped (large or binary)`);
773
+ if (stopped)
774
+ parts.push(`stopped after ${stoppedAt} ${stoppedAt === 1 ? "file" : "files"} — narrow the path`);
775
+ content += `\n… ${parts.join(" · ")}`;
776
+ }
518
777
  if (totalMatches > matches.length) {
519
778
  // R-C item 2: the N of M form — the cap names its
520
779
  // continuation (narrow the pattern for more).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tools-node",
3
- "version": "0.24.3",
3
+ "version": "0.24.5",
4
4
  "description": "kiso coding tools for Node hosts — read file, list directory, search text, write/edit file, shell command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,7 +21,7 @@
21
21
  "test": "vitest run"
22
22
  },
23
23
  "dependencies": {
24
- "@vincemakes/kiso-core": "0.24.3"
24
+ "@vincemakes/kiso-core": "0.24.5"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.2",