@vincemakes/kiso-runtime 0.52.0 → 0.54.0

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/session.js CHANGED
@@ -32,7 +32,8 @@ import { executionLedger } from "./ledger.js";
32
32
  import { overflowBelt } from "./overflow-belt.js";
33
33
  import { windowLearner } from "./window-learner.js";
34
34
  import { denialResult } from "@vincemakes/kiso-core";
35
- import { buildProfile, readProfile, writeProfile, writeSummary } from "./profile.js";
35
+ import { buildProfile, readForkOrigin, readProfile, writeProfile, writeSummary } from "./profile.js";
36
+ import { forkNote } from "./fork.js";
36
37
  import { summarizeEvents } from "./session-summary.js";
37
38
  import { lookupModelMetadata, resolveReasoning } from "./provider/metadata.js";
38
39
  import { DROP_PLACEHOLDER, estimateSummarySavings, KEEP_RECENT_ROUNDS, KEEP_TOKENS_DEFAULT, lastSummaryPoint, MAX_SUMMARY_FAILURES, policyTriggerFromWindow, serializeCovered, MANUAL_SUMMARY_BUDGET, SUMMARY_MAX_OUTPUT, resolveCheckpoint, snapshotCheckpointSections, summarizeConversation, SummaryBudgetExhausted, summaryBoundarySeq, } from "./summarize.js";
@@ -91,6 +92,43 @@ function summaryReasoning(model, baseUrl) {
91
92
  const r = resolveReasoning(model, { thinking: "disabled", effort: "default" }, baseUrl);
92
93
  return r.ok && r.wire.thinking !== undefined ? { reasoning: r.wire } : {};
93
94
  }
95
+ /** The follow-up's built-in ask (Session.followUp with no `ask`): the line the person may send next. No language is
96
+ * named: without the clause, Chinese conversations still gave Chinese lines (measured, owner 2026-11-03). */
97
+ export const FOLLOW_UP_ASK = "Write the one message the person is most likely to send you next, as they would type it, and nothing else. If the work is done and nothing naturally follows, answer with nothing.";
98
+ /** The built-in follow-up's answer as a line: its first non-empty line, without quotes around it; "" for none, and ""
99
+ * for a line over 200 characters (a cut message is not one the person would send, so it is dropped, not shortened). */
100
+ export function followUpLine(text) {
101
+ const first = text.split("\n").map((l) => l.trim()).find((l) => l !== "") ?? "";
102
+ const bare = first.replace(/^["'`\u201c\u2018\u300c]+|["'`\u201d\u2019\u300d]+$/g, "").trim();
103
+ return [...bare].length > 200 ? "" : bare;
104
+ }
105
+ /** The built-in follow-up's room when its thinking cannot be turned off: a one-line answer after the model's own thinking. */
106
+ const FOLLOW_UP_THINKING_ROOM = 600;
107
+ /** The session title's ask (Session.title). The language is named, unlike the follow-up's: the title sees one message
108
+ * and no history, and a short greeting under an English ask came back English (ds, a three-character Chinese
109
+ * greeting: 5 of 10 English titles without the clause, 0 of 10 with it; owner 2026-11-04). */
110
+ export const TITLE_PROMPT = "Write a short title (2 to 8 words) for a conversation that opens with the user's message, in the language of that message. Only the title: no quotes, no punctuation at the end.";
111
+ /** No thinking for a side answer, where the model table says how: thinking disabled, else effort `none`; null when the
112
+ * table knows neither (then nothing is sent: a raw field to an unknown model can be a 400). */
113
+ function quietReasoning(model, baseUrl) {
114
+ const off = resolveReasoning(model, { thinking: "disabled", effort: "default" }, baseUrl);
115
+ if (off.ok && off.wire.thinking !== undefined)
116
+ return off.wire;
117
+ const none = resolveReasoning(model, { thinking: "default", effort: "none" }, baseUrl);
118
+ return none.ok && none.wire.effort !== undefined ? none.wire : null;
119
+ }
120
+ /** A model's title as a name: its first non-empty line, without quotes around it or punctuation at its end, its spaces
121
+ * collapsed, at most 60 characters; "" when no letter or digit is left. */
122
+ export function cleanTitle(text) {
123
+ const line = text.split("\n").map((l) => l.trim()).find((l) => l !== "") ?? "";
124
+ // quotes and ending punctuation can wrap each other ("Title." / Title"."): strip both until neither is left
125
+ let bare = line;
126
+ for (let was = ""; was !== bare;)
127
+ (was = bare), (bare = bare.replace(/^["'`\u201c\u2018\u300c]+|["'`\u201d\u2019\u300d]+$/g, "").replace(/[.!?;:,\u3002\uff01\uff1f\uff1b\uff1a\uff0c]+$/u, "").trim());
128
+ bare = bare.replace(/\s+/g, " ");
129
+ const cut = [...bare].length > 60 ? `${[...bare].slice(0, 59).join("")}…` : bare;
130
+ return /[\p{L}\p{N}]/u.test(cut) ? cut : "";
131
+ }
94
132
  /** ADR-0057 §5 (3e D1): a steer detaches a foreground command only once it
95
133
  * is this old — a command about to end on its own is left to end. */
96
134
  export const AUTO_DETACH_MIN_AGE_MS = 2_000;
@@ -101,6 +139,8 @@ export class AgentSession {
101
139
  // NOT readonly since 0.1.23: /model replaces it between runs (the
102
140
  // constructor, setAdapter, and setModelBinding are the only writers).
103
141
  #adapter;
142
+ /** the last request a run sent, as the provider adapter got it (in memory only): what followUp sends again */
143
+ #lastRequest = null;
104
144
  #config;
105
145
  /** 0.51.0: the checkpoint every summary call is built from — resolved ONCE at construction from a snapshot of `contextPolicy.checkpointSections`; the raw host array is never read again. */
106
146
  #checkpoint;
@@ -778,6 +818,10 @@ export class AgentSession {
778
818
  * CLI's honest degradation.
779
819
  */
780
820
  async sideQuery(options) {
821
+ return this.#sideQuery(options);
822
+ }
823
+ /** sideQuery's body; `reasoning` (resolved, native) is the title's way to ask for no thinking, not a public option. */
824
+ async #sideQuery(options) {
781
825
  this.ensureHealthy();
782
826
  // a fresh id: this request is not the paused run's work, and giving
783
827
  // it the run's id would put on-demand rent inside a run's total.
@@ -810,6 +854,7 @@ export class AgentSession {
810
854
  messages: [{ role: "user", content: options.prompt }],
811
855
  systemPrompt: options.systemPrompt,
812
856
  ...(options.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {}),
857
+ ...(options.reasoning !== undefined ? { reasoning: options.reasoning } : {}),
813
858
  ...(options.signal !== undefined ? { signal: options.signal } : {}),
814
859
  })) {
815
860
  if (ev.type === "text_delta")
@@ -821,6 +866,27 @@ export class AgentSession {
821
866
  }
822
867
  return text;
823
868
  }
869
+ /**
870
+ * A title for the conversation `firstMessage` opens (owner 2026-11-03: the person's first line is not a title): one
871
+ * side query with its own short prompt and that message (its first 500 characters), no tools, no history, purpose
872
+ * "title" in the trace. It asks for no thinking where the route allows it (measured on co: 6-10 tokens out instead of
873
+ * 100 cut short): thinking disabled, else effort `none`, resolved through the model table as the manual summary is;
874
+ * for a model the table does not know it sends no reasoning field and allows more output. The answer is cleaned to
875
+ * one line (no quotes, no ending punctuation, at most 60 characters); "" when nothing is left. It writes nothing: the
876
+ * caller keeps the title where it keeps names, and only for a session the person has not named.
877
+ */
878
+ async title(firstMessage, options) {
879
+ const quiet = quietReasoning(this.#model, this.#baseUrl);
880
+ const text = await this.#sideQuery({
881
+ purpose: "title",
882
+ systemPrompt: TITLE_PROMPT,
883
+ prompt: firstMessage.slice(0, 500),
884
+ maxTokens: quiet === null ? 400 : 100,
885
+ ...(quiet !== null ? { reasoning: quiet } : {}),
886
+ ...(options?.signal !== undefined ? { signal: options.signal } : {}),
887
+ });
888
+ return cleanTitle(text);
889
+ }
824
890
  /** Run one user turn. Iterate to consume; `run.abort()` cancels. */
825
891
  /** REL-0152-D11: `input` is text, or the content blocks a turn with an
826
892
  * attachment carries. The durable event, the projection and both
@@ -837,7 +903,7 @@ export class AgentSession {
837
903
  if (this.#profilePending)
838
904
  this.#recordProfile(); // XP-1: legacy revision 1, before the first request
839
905
  const config = options?.maxTurns !== undefined ? { ...this.#effectiveConfig(), maxTurns: options.maxTurns } : this.#effectiveConfig();
840
- return new Run(this.#store, this.#learning(this.#adapter), config, this, input, options?.signal, false, options?.source, options?.via);
906
+ return new Run(this.#store, this.#remembering(this.#learning(this.#adapter)), config, this, this.#withForkNote(input, options?.source), options?.signal, false, options?.source, options?.via);
841
907
  }
842
908
  /**
843
909
  * Continue the interrupted run (Area 2): apply durable decisions,
@@ -847,7 +913,91 @@ export class AgentSession {
847
913
  */
848
914
  resume() {
849
915
  this.ensureHealthy();
850
- return new Run(this.#store, this.#learning(this.#adapter), this.#effectiveConfig(), this, undefined, undefined, true);
916
+ return new Run(this.#store, this.#remembering(this.#learning(this.#adapter)), this.#effectiveConfig(), this, undefined, undefined, true);
917
+ }
918
+ /** A fork's first input carries the fork note before the person's words, once (plan fork rev 2): after the copied
919
+ * history, so the request's prefix is the parent's and a caching route reads it from the cache. "First" is the log
920
+ * still ending where it was cut; a runtime notice (source "system") never carries it. */
921
+ #withForkNote(input, source) {
922
+ if (source === "system")
923
+ return input;
924
+ const origin = readForkOrigin(this.#store.root, this.id);
925
+ if (origin === null || this.log.lastSeq !== origin.afterSeq)
926
+ return input;
927
+ return [{ type: "text", text: forkNote(origin.title) }, ...(typeof input === "string" ? [{ type: "text", text: input }] : input)];
928
+ }
929
+ /** The adapter a run uses, noting each request it carries (the last one stands, without its signal) for followUp. */
930
+ #remembering(adapter) {
931
+ return {
932
+ stream: (options) => {
933
+ const { signal: _signal, ...request } = options;
934
+ this.#lastRequest = request;
935
+ return adapter.stream(options);
936
+ },
937
+ };
938
+ }
939
+ /**
940
+ * A follow-up of the last run: its last request sent again, byte for byte as the run sent it, then what came after it
941
+ * in the conversation (the run's last reply, as the projection has it, reasoning included) and `ask` as a user message,
942
+ * through the session's own adapter; the model's text comes back. The prefix is the run's own, so a route
943
+ * that caches reads nearly all of it from the cache: the web app asks it for the line the person may send next.
944
+ *
945
+ * Like sideQuery it writes nothing durable and lands one trace line with a fresh runId and purpose "follow-up". It
946
+ * sends nothing, and answers "", when no run has sent a request since this session was opened, or when the model has
947
+ * been switched since (that prefix would not be this model's). Tools stay in the request (they are part of the
948
+ * prefix); a tool call in the answer is ignored.
949
+ */
950
+ /** What the conversation holds after a request's messages: the projection's rest when it starts with them (the usual
951
+ * case), else only its last reply. */
952
+ #after(sent) {
953
+ const now = this.projected();
954
+ if (JSON.stringify(now.slice(0, sent.length)) === JSON.stringify(sent))
955
+ return now.slice(sent.length);
956
+ const last = now.at(-1);
957
+ return last?.role === "assistant" ? [last] : [];
958
+ }
959
+ async followUp(options) {
960
+ this.ensureHealthy();
961
+ const last = this.#lastRequest;
962
+ if (last === null || last.model !== this.#model)
963
+ return "";
964
+ const tracer = new RequestTracer({
965
+ root: this.#store.root,
966
+ sessionId: this.id,
967
+ runId: crypto.randomUUID(),
968
+ provider: this.#provider ?? "adapter",
969
+ model: this.#model,
970
+ ...(this.#baseUrl !== undefined ? { endpoint: this.#baseUrl } : {}),
971
+ adapterVersion: runtimeVersion(),
972
+ purpose: "follow-up",
973
+ log: [],
974
+ rentParts: { base: last.systemPrompt ?? "", appends: [] },
975
+ });
976
+ tracer.init();
977
+ const guarded = traceGuard(tracer, idleGuard(this.#adapter, this.#config.streamIdleMs ?? DEFAULT_STREAM_IDLE_MS));
978
+ // The built-in line asks for no thinking where the model table says how, as the title does: half of the measured
979
+ // follow-ups spent 80-200 output tokens thinking about one line, and some were cut off by the budget. Not on the
980
+ // anthropic route, where changing the thinking setting drops the cached prefix this request exists to reuse. A model
981
+ // that cannot be quieted keeps its own setting and gets room for its thinking. A host's own `ask` is sent as before.
982
+ const quiet = options.ask === undefined && this.#provider !== "anthropic" ? quietReasoning(this.#model, this.#baseUrl) : null;
983
+ const maxTokens = options.ask === undefined && quiet === null && options.maxTokens !== undefined ? Math.max(options.maxTokens, FOLLOW_UP_THINKING_ROOM) : options.maxTokens;
984
+ let text = "";
985
+ try {
986
+ for await (const ev of guarded.stream({
987
+ ...last,
988
+ ...(quiet !== null ? { reasoning: quiet } : {}),
989
+ messages: [...last.messages, ...this.#after(last.messages), { role: "user", content: options.ask ?? FOLLOW_UP_ASK }],
990
+ ...(maxTokens !== undefined ? { maxTokens } : {}),
991
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
992
+ })) {
993
+ if (ev.type === "text_delta")
994
+ text += ev.text;
995
+ }
996
+ }
997
+ finally {
998
+ tracer.finishRun();
999
+ }
1000
+ return options.ask === undefined ? followUpLine(text) : text;
851
1001
  }
852
1002
  /**
853
1003
  * /compact (ADR-0044): compress the older conversation with a model
package/dist/store.d.ts CHANGED
@@ -43,6 +43,7 @@
43
43
  * tolerated damage; everything else throws StoreCorruptionError.
44
44
  */
45
45
  import { type Event } from "@vincemakes/kiso-core";
46
+ import { type ForkOrigin } from "./profile.js";
46
47
  import { type LockAdapter } from "./lock-adapter.js";
47
48
  /** History that does not parse as a contiguous kiso trajectory. */
48
49
  export declare class StoreCorruptionError extends Error {
@@ -65,6 +66,8 @@ export interface SessionMeta {
65
66
  readonly runs: number;
66
67
  readonly createdAt: number;
67
68
  readonly updatedAt: number;
69
+ /** the session this one was forked from (SessionStore.fork), when it is a fork */
70
+ readonly forkedFrom?: string;
68
71
  }
69
72
  export declare function sessionTitle(records: readonly StoreRecord[]): string;
70
73
  export declare class SessionStore {
@@ -121,6 +124,14 @@ export declare class SessionStore {
121
124
  * needs only the ids must never pay that. An empty log is skipped, as
122
125
  * list() skips it.
123
126
  */
127
+ /** Fork `parent` after the run that ended at `afterSeq` (absent: its last finished run): a new session with the
128
+ * conversation up to there and nothing after (fork.ts). Throws ForkRefusedError, in words, when it cannot. */
129
+ fork(parent: string, options?: {
130
+ readonly afterSeq?: number;
131
+ }): {
132
+ readonly id: string;
133
+ readonly origin: ForkOrigin;
134
+ };
124
135
  ids(): string[];
125
136
  list(): SessionMeta[];
126
137
  /** Release a session's fd and OUR writer lock. Idempotent. */
package/dist/store.js CHANGED
@@ -45,8 +45,10 @@
45
45
  import { appendFileSync, closeSync, constants, existsSync, fsyncSync, fstatSync, statSync, ftruncateSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, writeSync, } from "node:fs";
46
46
  import { dirname, join } from "node:path";
47
47
  import { isKisoEvent } from "@vincemakes/kiso-core";
48
- import { readSessionName } from "./profile.js";
48
+ import { readForkOrigin, readSessionName } from "./profile.js";
49
+ import { forkSession } from "./fork.js";
49
50
  import { LockedError, nativeLockAdapter, } from "./lock-adapter.js";
51
+ import { isEnvironmentNote } from "./environment.js";
50
52
  /** History that does not parse as a contiguous kiso trajectory. */
51
53
  export class StoreCorruptionError extends Error {
52
54
  constructor(message) {
@@ -126,8 +128,10 @@ function contentText(content) {
126
128
  if (block === null || typeof block !== "object")
127
129
  continue;
128
130
  const b = block;
129
- if (b.type === "text" && typeof b.text === "string")
130
- parts.push(b.text);
131
+ if (b.type === "text" && typeof b.text === "string") {
132
+ if (!isEnvironmentNote(b.text))
133
+ parts.push(b.text); // the host's line about a folder, never the person's words
134
+ }
131
135
  else if (b.type === "image")
132
136
  parts.push("(image)");
133
137
  }
@@ -159,6 +163,8 @@ export class SessionStore {
159
163
  #fds = new Map();
160
164
  /** sessionId → the lock handle THIS instance holds (ADR-0050). */
161
165
  #lockHandles = new Map();
166
+ /** sessionId → what the last load read: the file it was (device, inode, size, mtime), the byte after its last committed line, and the records (load's tail read). */
167
+ #loaded = new Map();
162
168
  #lockAdapter;
163
169
  /** round 4 (adversarial): serialize concurrent acquireLock calls ON this instance —
164
170
  * two racing appends must not issue two acquisitions and fight each other. */
@@ -366,9 +372,92 @@ export class SessionStore {
366
372
  */
367
373
  load(sessionId) {
368
374
  const path = this.pathFor(sessionId);
369
- if (!existsSync(path))
375
+ if (!existsSync(path)) {
376
+ this.#loaded.delete(sessionId);
370
377
  return [];
371
- const raw = readFileSync(path, "utf8");
378
+ }
379
+ const fresh = this.#loadTail(sessionId, path);
380
+ if (fresh !== null)
381
+ return fresh;
382
+ const st = statSync(path);
383
+ const raw = readFileSync(path);
384
+ const records = this.#loadWhole(raw.toString("utf8"));
385
+ const end = raw.lastIndexOf(0x0a);
386
+ let lines = 0;
387
+ for (let i = 0; i <= end; i++)
388
+ if (raw[i] === 0x0a)
389
+ lines += 1;
390
+ this.#loaded.set(sessionId, { dev: st.dev, ino: st.ino, size: st.size, mtimeMs: st.mtimeMs, committed: end + 1, lines, records });
391
+ return records.slice();
392
+ }
393
+ /**
394
+ * The log read again only from where the last load stopped, when the file is the same file (device and inode) and has
395
+ * only grown past that point, which is how this store writes it: append-only, a torn tail truncated. Anything else (a
396
+ * shorter file, another inode, the same size with a new mtime, a last committed byte that is not a newline) is read whole
397
+ * by the strict path below, so a cached read never stands in for one that would have refused. Each caller gets its own
398
+ * array; the records themselves are shared and never mutated.
399
+ */
400
+ #loadTail(sessionId, path) {
401
+ const held = this.#loaded.get(sessionId);
402
+ if (held === undefined)
403
+ return null;
404
+ let st;
405
+ try {
406
+ st = statSync(path);
407
+ }
408
+ catch {
409
+ return null;
410
+ }
411
+ if (st.dev !== held.dev || st.ino !== held.ino || st.size < held.committed)
412
+ return null;
413
+ if (st.size === held.size && st.mtimeMs === held.mtimeMs)
414
+ return held.records.slice();
415
+ if (st.size === held.size)
416
+ return null;
417
+ const fd = openSync(path, "r");
418
+ let chunk;
419
+ try {
420
+ const from = held.committed - 1;
421
+ chunk = Buffer.alloc(st.size - from);
422
+ let got = 0;
423
+ while (got < chunk.length) {
424
+ const n = readSync(fd, chunk, got, chunk.length - got, from + got);
425
+ if (n === 0)
426
+ break;
427
+ got += n;
428
+ }
429
+ chunk = chunk.subarray(0, got);
430
+ }
431
+ finally {
432
+ closeSync(fd);
433
+ }
434
+ if (held.committed > 0 && chunk[0] !== 0x0a)
435
+ return null; // the byte before the new part must end a committed line
436
+ const body = held.committed > 0 ? chunk.subarray(1) : chunk;
437
+ const end = body.lastIndexOf(0x0a);
438
+ const records = held.records.slice();
439
+ let line = held.lines;
440
+ if (end >= 0) {
441
+ for (const text of body.subarray(0, end).toString("utf8").split("\n")) {
442
+ line += 1;
443
+ if (text === "")
444
+ continue;
445
+ let parsed;
446
+ try {
447
+ parsed = JSON.parse(text);
448
+ }
449
+ catch {
450
+ return null; // the strict path names it
451
+ }
452
+ if (!isRecord(parsed) || parsed.event.seq !== records.length)
453
+ return null;
454
+ records.push(parsed);
455
+ }
456
+ }
457
+ this.#loaded.set(sessionId, { dev: st.dev, ino: st.ino, size: st.size, mtimeMs: st.mtimeMs, committed: held.committed + (end >= 0 ? end + 1 : 0), lines: line, records });
458
+ return records.slice();
459
+ }
460
+ #loadWhole(raw) {
372
461
  const lines = raw.split("\n");
373
462
  const nonEmpty = [];
374
463
  for (let i = 0; i < lines.length; i++) {
@@ -415,6 +504,12 @@ export class SessionStore {
415
504
  * needs only the ids must never pay that. An empty log is skipped, as
416
505
  * list() skips it.
417
506
  */
507
+ /** Fork `parent` after the run that ended at `afterSeq` (absent: its last finished run): a new session with the
508
+ * conversation up to there and nothing after (fork.ts). Throws ForkRefusedError, in words, when it cannot. */
509
+ fork(parent, options) {
510
+ this.pathFor(parent); // the id is checked before any file is read
511
+ return forkSession(this.root, parent, options?.afterSeq);
512
+ }
418
513
  ids() {
419
514
  const out = [];
420
515
  for (const entry of readdirSync(this.root)) {
@@ -449,6 +544,7 @@ export class SessionStore {
449
544
  runs: new Set(records.map((r) => r.runId)).size,
450
545
  createdAt: records[0]?.ts ?? 0,
451
546
  updatedAt: records.at(-1)?.ts ?? 0,
547
+ ...((origin) => (origin === null ? {} : { forkedFrom: origin.session }))(readForkOrigin(this.root, id)),
452
548
  });
453
549
  }
454
550
  return metas.sort((a, b) => a.id.localeCompare(b.id));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-runtime",
3
- "version": "0.52.0",
3
+ "version": "0.54.0",
4
4
  "description": "kiso runtime \u2014 durable multi-turn agent sessions: AgentDefinition, AgentRuntime, AgentSession, Run, append-only JSONL store.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,12 +25,12 @@
25
25
  "test": "vitest run"
26
26
  },
27
27
  "dependencies": {
28
- "@vincemakes/kiso-core": "0.52.0"
28
+ "@vincemakes/kiso-core": "0.54.0"
29
29
  },
30
30
  "peerDependencies": {
31
- "@vincemakes/kiso-provider-anthropic": "0.52.0",
32
- "@vincemakes/kiso-provider-openai": "0.52.0",
33
- "@vincemakes/kiso-provider-openai-responses": "0.52.0"
31
+ "@vincemakes/kiso-provider-anthropic": "0.54.0",
32
+ "@vincemakes/kiso-provider-openai": "0.54.0",
33
+ "@vincemakes/kiso-provider-openai-responses": "0.54.0"
34
34
  },
35
35
  "peerDependenciesMeta": {
36
36
  "@vincemakes/kiso-provider-anthropic": {
@@ -44,7 +44,7 @@
44
44
  }
45
45
  },
46
46
  "devDependencies": {
47
- "@vincemakes/kiso-evals": "0.52.0",
47
+ "@vincemakes/kiso-evals": "0.54.0",
48
48
  "@types/node": "^26.1.2",
49
49
  "typescript": "^5.7.2",
50
50
  "vitest": "^3.0.0"