@noetic-tools/context 1.6.0 → 1.7.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.
@@ -27,6 +27,13 @@ export declare function pinKey(layer: ContextLayer): string;
27
27
  export declare function createContextCacheStore(): ContextCacheStore;
28
28
  /** Drop a lineage's epoch — used when a child execution ends. */
29
29
  export declare function dropLineage(store: ContextCacheStore, key: string): void;
30
+ /**
31
+ * Drop the pins of layers that left a lineage's layer set — a closing
32
+ * `withContext` boundary. A pin outlives its layer by design (dropping it
33
+ * shortens the cached prefix), so a scoped layer must be unpinned explicitly or
34
+ * its last render keeps replaying after its scope ends.
35
+ */
36
+ export declare function unpinLayers(store: ContextCacheStore, key: string, layers: ContextLayer[]): void;
30
37
  /** Read a layer's churn record, creating an empty one on first sight. */
31
38
  export declare function churnFor(store: ContextCacheStore, key: string): LayerChurn;
32
39
  /** Share of watched assemblies in which a layer's output changed, 0–1. */
@@ -66,6 +66,22 @@ export function dropLineage(store, key) {
66
66
  store.epochs.delete(key);
67
67
  store.pendingReanchor.delete(key);
68
68
  }
69
+ /**
70
+ * Drop the pins of layers that left a lineage's layer set — a closing
71
+ * `withContext` boundary. A pin outlives its layer by design (dropping it
72
+ * shortens the cached prefix), so a scoped layer must be unpinned explicitly or
73
+ * its last render keeps replaying after its scope ends.
74
+ */
75
+ export function unpinLayers(store, key, layers) {
76
+ const epoch = store.epochs.get(key);
77
+ if (!epoch) {
78
+ return;
79
+ }
80
+ for (const layer of layers) {
81
+ epoch.pins.delete(pinKey(layer));
82
+ epoch.autoBand.delete(pinKey(layer));
83
+ }
84
+ }
69
85
  /** Read a layer's churn record, creating an empty one on first sight. */
70
86
  export function churnFor(store, key) {
71
87
  const found = store.churn.get(key);
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Timed, traced calls into context layer hooks. Each call of a traced hook adds
3
+ * one `layer.hook` event to the span of the context it runs in (`ctx.trace`),
4
+ * shaped as a `LayerTraceSpan`. Events rather than child spans: an
5
+ * `ExecutionContext` can annotate its span but not open one.
6
+ */
7
+ import type { ExecutionContext, LayerTraceSpan } from '@noetic-tools/types';
8
+ type TracedHook = LayerTraceSpan['hook'];
9
+ interface HookCall<T> {
10
+ ctx: ExecutionContext;
11
+ layerId: string;
12
+ hook: TracedHook;
13
+ /** Milliseconds before the call fails as a timeout; `0` or less waits forever. */
14
+ timeout: number;
15
+ run: () => Promise<T>;
16
+ /** Reads a recall's budget and item count from the hook's result. */
17
+ measure?: (result: T) => Pick<LayerTraceSpan, 'budget' | 'itemCount'>;
18
+ }
19
+ /** Span event recording one layer hook call. Attributes follow `LayerTraceSpan`. */
20
+ export declare const LAYER_HOOK_EVENT = "layer.hook";
21
+ /**
22
+ * Runs a layer hook under its timeout and records a `layer.hook` event for it.
23
+ * Rethrows whatever the hook threw (or the timeout), so callers keep their own
24
+ * failure handling.
25
+ */
26
+ export declare function callHook<T>({ ctx, layerId, hook, timeout, run, measure, }: HookCall<T>): Promise<T>;
27
+ /** Records a hook the layer defines but the lifecycle did not call. */
28
+ export declare function traceSkipped(ctx: ExecutionContext, layerId: string, hook: TracedHook): void;
29
+ /** Runs a hook under a timeout without tracing it, for hooks outside `LayerTraceSpan`. */
30
+ export declare function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T>;
31
+ export {};
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Timed, traced calls into context layer hooks. Each call of a traced hook adds
3
+ * one `layer.hook` event to the span of the context it runs in (`ctx.trace`),
4
+ * shaped as a `LayerTraceSpan`. Events rather than child spans: an
5
+ * `ExecutionContext` can annotate its span but not open one.
6
+ */
7
+ //#endregion
8
+ //#region Public API
9
+ /** Span event recording one layer hook call. Attributes follow `LayerTraceSpan`. */
10
+ export const LAYER_HOOK_EVENT = 'layer.hook';
11
+ /**
12
+ * Runs a layer hook under its timeout and records a `layer.hook` event for it.
13
+ * Rethrows whatever the hook threw (or the timeout), so callers keep their own
14
+ * failure handling.
15
+ */
16
+ export async function callHook({ ctx, layerId, hook, timeout, run, measure, }) {
17
+ const started = performance.now();
18
+ try {
19
+ const result = await withTimeout(run(), timeout);
20
+ traceHook(ctx, {
21
+ layerId,
22
+ hook,
23
+ duration: performance.now() - started,
24
+ status: 'ok',
25
+ ...measure?.(result),
26
+ });
27
+ return result;
28
+ }
29
+ catch (e) {
30
+ traceHook(ctx, {
31
+ layerId,
32
+ hook,
33
+ duration: performance.now() - started,
34
+ status: e instanceof HookTimeout ? 'timeout' : 'error',
35
+ error: {
36
+ message: e instanceof Error ? e.message : String(e),
37
+ },
38
+ });
39
+ throw e;
40
+ }
41
+ }
42
+ /** Records a hook the layer defines but the lifecycle did not call. */
43
+ export function traceSkipped(ctx, layerId, hook) {
44
+ traceHook(ctx, {
45
+ layerId,
46
+ hook,
47
+ duration: 0,
48
+ status: 'skipped',
49
+ });
50
+ }
51
+ /** Runs a hook under a timeout without tracing it, for hooks outside `LayerTraceSpan`. */
52
+ export async function withTimeout(promise, ms) {
53
+ if (ms <= 0) {
54
+ return promise;
55
+ }
56
+ let timer;
57
+ return Promise.race([
58
+ promise.finally(() => clearTimeout(timer)),
59
+ new Promise((_, reject) => {
60
+ timer = setTimeout(() => reject(new HookTimeout(ms)), ms);
61
+ }),
62
+ ]);
63
+ }
64
+ //#endregion
65
+ //#region Helpers
66
+ class HookTimeout extends Error {
67
+ constructor(ms) {
68
+ super(`Timeout after ${ms}ms`);
69
+ }
70
+ }
71
+ /** Span event attributes hold only scalars, so nested fields flatten to dotted keys. */
72
+ function traceHook(ctx, span) {
73
+ const attributes = {
74
+ layerId: span.layerId,
75
+ hook: span.hook,
76
+ duration: span.duration,
77
+ status: span.status,
78
+ };
79
+ if (span.itemCount !== undefined) {
80
+ attributes.itemCount = span.itemCount;
81
+ }
82
+ if (span.budget) {
83
+ attributes['budget.allocated'] = span.budget.allocated;
84
+ attributes['budget.used'] = span.budget.used;
85
+ attributes['budget.yielded'] = span.budget.yielded;
86
+ }
87
+ if (span.error) {
88
+ attributes['error.message'] = span.error.message;
89
+ }
90
+ ctx.trace.addEvent(LAYER_HOOK_EVENT, attributes);
91
+ }
92
+ //#endregion
@@ -13,6 +13,13 @@ export interface LayerStateStore {
13
13
  */
14
14
  has?(executionId: string, layerId: string): boolean;
15
15
  cleanup(executionId: string): void;
16
+ /**
17
+ * Forget one layer's state, durable target, and disabled mark for an
18
+ * execution, leaving the execution's other layers alone. Optional so custom
19
+ * stores keep compiling; without it, a scoped dispose leaves the state in
20
+ * place for the next `init` to overwrite.
21
+ */
22
+ drop?(executionId: string, layerId: string): void;
16
23
  diagnostic: (layerId: string, hook: string, error: unknown) => void;
17
24
  /**
18
25
  * Register a durable write-through target for a layer's state under an
@@ -28,7 +35,8 @@ export interface LayerStateStore {
28
35
  flush?(executionId: string): Promise<void>;
29
36
  /**
30
37
  * Mark a layer disabled for an execution (its `init` failed with
31
- * `onInitError: 'disable'`). Disabled layers are skipped by every later
38
+ * `onInitError: 'disable'`, or its `onSpawn` returned `null` or failed for
39
+ * a child execution). Disabled layers are skipped by every later
32
40
  * hook. Optional so custom stores keep compiling; without explicit
33
41
  * tracking, the lifecycle falls back to the legacy sentinel (init-bearing
34
42
  * layer with no state).
@@ -113,6 +121,12 @@ interface DisposeLayersParams {
113
121
  layers: ContextLayer[];
114
122
  ctx: ExecutionContext;
115
123
  store: LayerStateStore;
124
+ /**
125
+ * The layers share their execution with layers that stay live (a
126
+ * `withContext` boundary closing). Drop only these layers' state instead of
127
+ * cleaning up the whole execution.
128
+ */
129
+ scoped?: boolean;
116
130
  }
117
131
  interface BeforeToolCallLayersParams {
118
132
  layers: ContextLayer[];
@@ -231,7 +245,7 @@ interface NotifyDecisionParams {
231
245
  * what an earlier one recorded.
232
246
  */
233
247
  export declare function notifyDecision({ layers, record, ctx, store, recallCache, }: NotifyDecisionParams): Promise<void>;
234
- export declare function disposeLayers({ layers, ctx, store }: DisposeLayersParams): Promise<void>;
248
+ export declare function disposeLayers({ layers, ctx, store, scoped, }: DisposeLayersParams): Promise<void>;
235
249
  export declare function spawnLayers({ layers, parentCtx, childCtx, store, itemSchemas, storage, }: SpawnLayersParams): Promise<{
236
250
  layerId: string;
237
251
  childState: unknown;
@@ -1,4 +1,5 @@
1
1
  import { createMessage, defaultItemSchemaRegistry, estimateTokens, frameworkCast, NoeticConfigError, NoeticErrorImpl, SteeringAction, } from '@noetic-tools/types';
2
+ import { callHook, traceSkipped, withTimeout } from './hook-call.js';
2
3
  import { createScopedStorage, resolveScopeKey } from './scope.js';
3
4
  /** Maximum allowed re-render depth to prevent infinite loops */
4
5
  const MAX_RERENDER_DEPTH = 3;
@@ -91,6 +92,11 @@ export function createLayerStateStore(diagnostic) {
91
92
  durables.delete(executionId);
92
93
  disabled.delete(executionId);
93
94
  },
95
+ drop(executionId, layerId) {
96
+ states.get(executionId)?.delete(layerId);
97
+ durables.get(executionId)?.delete(layerId);
98
+ disabled.get(executionId)?.delete(layerId);
99
+ },
94
100
  diagnostic: report,
95
101
  };
96
102
  }
@@ -136,18 +142,6 @@ function recallCacheKey(layer, ctx) {
136
142
  return `${resolveScopeKey(layer.scope, ctx)}:${layer.id}`;
137
143
  }
138
144
  //#endregion
139
- async function withTimeout(promise, ms) {
140
- if (ms <= 0) {
141
- return promise;
142
- }
143
- let timer;
144
- return Promise.race([
145
- promise.finally(() => clearTimeout(timer)),
146
- new Promise((_, reject) => {
147
- timer = setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms);
148
- }),
149
- ]);
150
- }
151
145
  export function mostRestrictive(decisions) {
152
146
  const guidances = [];
153
147
  let hasGuide = false;
@@ -202,18 +196,25 @@ export async function initLayers({ layers, ctx, storage, store, keep = false, })
202
196
  if (!layer.hooks.init) {
203
197
  continue;
204
198
  }
199
+ const init = layer.hooks.init;
205
200
  if (keep && store.has?.(ctx.executionId, layer.id)) {
201
+ traceSkipped(ctx, layer.id, 'init');
206
202
  continue;
207
203
  }
208
204
  const scopeKey = resolveScopeKey(layer.scope, ctx);
209
205
  const scopedStorage = createScopedStorage(storage, layer.id, scopeKey);
210
206
  try {
211
- const timeout = layer.timeouts?.init ?? 1e4;
212
- const result = await withTimeout(layer.hooks.init({
213
- storage: scopedStorage,
214
- scopeKey,
207
+ const result = await callHook({
215
208
  ctx,
216
- }), timeout);
209
+ layerId: layer.id,
210
+ hook: 'init',
211
+ timeout: layer.timeouts?.init ?? 1e4,
212
+ run: () => init({
213
+ storage: scopedStorage,
214
+ scopeKey,
215
+ ctx,
216
+ }),
217
+ });
217
218
  store.set(ctx.executionId, layer.id, result.state);
218
219
  }
219
220
  catch (e) {
@@ -272,6 +273,9 @@ export async function recallLayers({ layers, query, ctx, log, budgets, store, it
272
273
  layer,
273
274
  state: stored,
274
275
  })) {
276
+ if (!peek) {
277
+ traceSkipped(ctx, layer.id, 'recall');
278
+ }
275
279
  continue; // was disabled
276
280
  }
277
281
  // Skipping the write is not enough for a peek: a hook may mutate the
@@ -281,15 +285,27 @@ export async function recallLayers({ layers, query, ctx, log, budgets, store, it
281
285
  continue;
282
286
  }
283
287
  const budget = budgets.get(layer.id) ?? 0;
288
+ const recall = layer.hooks.recall;
289
+ const run = () => recall({
290
+ log,
291
+ query,
292
+ ctx,
293
+ state,
294
+ budget,
295
+ });
296
+ const timeout = layer.timeouts?.recall ?? 5e3;
284
297
  try {
285
- const timeout = layer.timeouts?.recall ?? 5e3;
286
- const result = await withTimeout(layer.hooks.recall({
287
- log,
288
- query,
289
- ctx,
290
- state,
291
- budget,
292
- }), timeout);
298
+ // A peek renders without leaving a trace, so it records no event either.
299
+ const result = peek
300
+ ? await withTimeout(run(), timeout)
301
+ : await callHook({
302
+ ctx,
303
+ layerId: layer.id,
304
+ hook: 'recall',
305
+ timeout,
306
+ run,
307
+ measure: (output) => measureRecall(output, budget),
308
+ });
293
309
  if (!result) {
294
310
  continue;
295
311
  }
@@ -324,6 +340,24 @@ export async function recallLayers({ layers, query, ctx, log, budgets, store, it
324
340
  return results;
325
341
  }
326
342
  const PEEK_UNSAFE = Symbol('peek-unsafe');
343
+ /** Budget and item count for a `layer.hook` recall event. */
344
+ function measureRecall(output, allocated) {
345
+ const measured = (used, itemCount) => ({
346
+ budget: {
347
+ allocated,
348
+ used,
349
+ yielded: Math.max(0, allocated - used),
350
+ },
351
+ itemCount,
352
+ });
353
+ if (!output) {
354
+ return measured(0, 0);
355
+ }
356
+ if (typeof output === 'string') {
357
+ return measured(estimateTokens(output), 1);
358
+ }
359
+ return measured(output.tokenCount, output.items.length);
360
+ }
327
361
  /** A deep copy, or `PEEK_UNSAFE` when the state cannot be copied (it holds functions). */
328
362
  function copyForPeek(state) {
329
363
  try {
@@ -415,6 +449,7 @@ export async function storeLayers({ layers, response, ctx, log, store, recallCac
415
449
  layer,
416
450
  state,
417
451
  })) {
452
+ traceSkipped(ctx, layer.id, 'store');
418
453
  continue;
419
454
  }
420
455
  snapshots.push({
@@ -424,15 +459,20 @@ export async function storeLayers({ layers, response, ctx, log, store, recallCac
424
459
  });
425
460
  }
426
461
  await Promise.allSettled(snapshots.map(async ({ layer, state, storeFn }) => {
427
- const timeout = layer.timeouts?.store ?? 3e4;
428
462
  try {
429
- const result = await withTimeout(storeFn({
430
- newItems: response.items,
431
- log,
432
- response,
463
+ const result = await callHook({
433
464
  ctx,
434
- state,
435
- }), timeout);
465
+ layerId: layer.id,
466
+ hook: 'store',
467
+ timeout: layer.timeouts?.store ?? 3e4,
468
+ run: () => storeFn({
469
+ newItems: response.items,
470
+ log,
471
+ response,
472
+ ctx,
473
+ state,
474
+ }),
475
+ });
436
476
  // `'state' in result` (not `!== undefined`) so a layer can explicitly
437
477
  // clear its state by returning `{ state: undefined }`. The store's
438
478
  // durable write-through mirrors the write (clearing deletes the
@@ -493,7 +533,7 @@ export async function notifyDecision({ layers, record, ctx, store, recallCache,
493
533
  }
494
534
  await store.flush?.(ctx.executionId);
495
535
  }
496
- export async function disposeLayers({ layers, ctx, store }) {
536
+ export async function disposeLayers({ layers, ctx, store, scoped = false, }) {
497
537
  // Sequential, REVERSE array order
498
538
  const reversed = [
499
539
  ...layers,
@@ -511,13 +551,20 @@ export async function disposeLayers({ layers, ctx, store }) {
511
551
  layer,
512
552
  state,
513
553
  })) {
554
+ traceSkipped(ctx, layer.id, 'dispose');
514
555
  continue;
515
556
  }
557
+ const dispose = layer.hooks.dispose;
516
558
  try {
517
- const timeout = layer.timeouts?.dispose ?? 5e3;
518
- await withTimeout(layer.hooks.dispose({
519
- state,
520
- }), timeout);
559
+ await callHook({
560
+ ctx,
561
+ layerId: layer.id,
562
+ hook: 'dispose',
563
+ timeout: layer.timeouts?.dispose ?? 5e3,
564
+ run: () => dispose({
565
+ state,
566
+ }),
567
+ });
521
568
  }
522
569
  catch (e) {
523
570
  store.diagnostic(layer.id, 'dispose', e);
@@ -525,7 +572,13 @@ export async function disposeLayers({ layers, ctx, store }) {
525
572
  }
526
573
  // Settle any in-flight durable mirror writes before dropping registrations.
527
574
  await store.flush?.(ctx.executionId);
528
- store.cleanup(ctx.executionId);
575
+ if (!scoped) {
576
+ store.cleanup(ctx.executionId);
577
+ return;
578
+ }
579
+ for (const layer of layers) {
580
+ store.drop?.(ctx.executionId, layer.id);
581
+ }
529
582
  }
530
583
  export async function spawnLayers({ layers, parentCtx, childCtx, store, itemSchemas = defaultItemSchemaRegistry, storage, }) {
531
584
  if (storage) {
@@ -554,25 +607,40 @@ export async function spawnLayers({ layers, parentCtx, childCtx, store, itemSche
554
607
  layer,
555
608
  state: parentState,
556
609
  })) {
610
+ traceSkipped(parentCtx, layer.id, 'onSpawn');
557
611
  continue;
558
612
  }
613
+ const onSpawn = layer.hooks.onSpawn;
559
614
  try {
560
- const timeout = layer.timeouts?.onSpawn ?? 1e4;
561
- const result = await withTimeout(layer.hooks.onSpawn({
562
- parentState,
563
- childCtx,
564
- }), timeout);
565
- if (result && result.childState !== null) {
615
+ const result = await callHook({
616
+ ctx: parentCtx,
617
+ layerId: layer.id,
618
+ hook: 'onSpawn',
619
+ timeout: layer.timeouts?.onSpawn ?? 1e4,
620
+ run: () => onSpawn({
621
+ parentState,
622
+ childCtx,
623
+ }),
624
+ });
625
+ // A `null` result opts the layer out of this child: every later hook
626
+ // skips it for the child's execution.
627
+ if (!result) {
628
+ store.disable?.(childCtx.executionId, layer.id);
629
+ continue;
630
+ }
631
+ // `childState: null` still injects items; the child just gets no state.
632
+ if (result.childState !== null) {
566
633
  store.set(childCtx.executionId, layer.id, result.childState);
567
- results.push({
568
- layerId: layer.id,
569
- childState: result.childState,
570
- items: layerItemSchemas(layer, itemSchemas).parseMany(result.items ?? []),
571
- });
572
634
  }
635
+ results.push({
636
+ layerId: layer.id,
637
+ childState: result.childState,
638
+ items: layerItemSchemas(layer, itemSchemas).parseMany(result.items ?? []),
639
+ });
573
640
  }
574
641
  catch (e) {
575
642
  store.diagnostic(layer.id, 'onSpawn', e);
643
+ store.disable?.(childCtx.executionId, layer.id);
576
644
  }
577
645
  }
578
646
  return results;
@@ -599,17 +667,24 @@ export async function returnLayers({ layers, stepId, parentCtx, childCtx, childL
599
667
  // initialized state can still be seeded from the child. Skip only when the
600
668
  // child produced nothing.
601
669
  if (childState === undefined) {
670
+ traceSkipped(childCtx, layer.id, 'onReturn');
602
671
  continue;
603
672
  }
673
+ const onReturn = layer.hooks.onReturn;
604
674
  try {
605
- const timeout = layer.timeouts?.onReturn ?? 1e4;
606
- const returnResult = await withTimeout(layer.hooks.onReturn({
607
- childState,
608
- childLog,
609
- parentState,
610
- result: currentResult,
611
- childCtx,
612
- }), timeout);
675
+ const returnResult = await callHook({
676
+ ctx: childCtx,
677
+ layerId: layer.id,
678
+ hook: 'onReturn',
679
+ timeout: layer.timeouts?.onReturn ?? 1e4,
680
+ run: () => onReturn({
681
+ childState,
682
+ childLog,
683
+ parentState,
684
+ result: currentResult,
685
+ childCtx,
686
+ }),
687
+ });
613
688
  if (returnResult?.parentState !== undefined) {
614
689
  store.set(parentCtx.executionId, layer.id, returnResult.parentState);
615
690
  }
@@ -648,16 +723,23 @@ export async function completeLayers({ layers, ctx, log, outcome, store, }) {
648
723
  layer,
649
724
  state,
650
725
  })) {
726
+ traceSkipped(ctx, layer.id, 'onComplete');
651
727
  continue;
652
728
  }
729
+ const onComplete = layer.hooks.onComplete;
653
730
  try {
654
- const timeout = layer.timeouts?.onComplete ?? 3e4;
655
- const result = await withTimeout(layer.hooks.onComplete({
656
- log,
731
+ const result = await callHook({
657
732
  ctx,
658
- state,
659
- outcome,
660
- }), timeout);
733
+ layerId: layer.id,
734
+ hook: 'onComplete',
735
+ timeout: layer.timeouts?.onComplete ?? 3e4,
736
+ run: () => onComplete({
737
+ log,
738
+ ctx,
739
+ state,
740
+ outcome,
741
+ }),
742
+ });
661
743
  if (result && 'state' in result) {
662
744
  store.set(ctx.executionId, layer.id, result.state);
663
745
  }
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export * from './context/budget.js';
9
9
  export * from './context/cache-anchoring.js';
10
10
  export * from './context/exec-context-factory.js';
11
11
  export * from './context/function-call-utils.js';
12
+ export { LAYER_HOOK_EVENT } from './context/hook-call.js';
12
13
  export * from './context/layer-api.js';
13
14
  export * from './context/layer-lifecycle.js';
14
15
  export * from './context/layer-provides.js';
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ export * from './context/budget.js';
11
11
  export * from './context/cache-anchoring.js';
12
12
  export * from './context/exec-context-factory.js';
13
13
  export * from './context/function-call-utils.js';
14
+ export { LAYER_HOOK_EVENT } from './context/hook-call.js';
14
15
  export * from './context/layer-api.js';
15
16
  export * from './context/layer-lifecycle.js';
16
17
  export * from './context/layer-provides.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noetic-tools/context",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "description": "Context layer system for Noetic agents: the ContextLayer contract, lifecycle, budget/projection machinery, and built-in layer implementations.",
6
6
  "license": "Apache-2.0",
@@ -44,7 +44,7 @@
44
44
  "zod": "^4.0.0"
45
45
  },
46
46
  "dependencies": {
47
- "@noetic-tools/types": "^3.7.0",
47
+ "@noetic-tools/types": "^3.8.0",
48
48
  "zod": "^4.0.0"
49
49
  },
50
50
  "scripts": {