@noetic-tools/types 0.2.0 → 1.0.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/README.md CHANGED
@@ -14,6 +14,10 @@ both of which re-export the parts relevant to their public surface. Application
14
14
  code normally imports these types from `@noetic-tools/core` rather than from
15
15
  this package directly.
16
16
 
17
+ The `MemoryLayer` contract is additionally exported at the
18
+ `@noetic-tools/types/contract` subpath for memory-layer authors who want the
19
+ contract without the rest of the vocabulary.
20
+
17
21
  ## License
18
22
 
19
23
  Apache-2.0
@@ -11,8 +11,16 @@ export declare class NoeticErrorImpl extends Error {
11
11
  /**
12
12
  * Type guard for `NoeticErrorImpl`.
13
13
  *
14
+ * Uses an `instanceof` fast path, then falls back to a structural check
15
+ * (`Error` carrying a `noeticError.kind: string`). The fallback keeps the
16
+ * guard reliable when multiple copies of `@noetic-tools/types` are loaded
17
+ * (e.g. mixed `src`/`dist` resolution or duplicated node_modules), where
18
+ * `instanceof` fails across realms. The inner `kind` is deliberately not
19
+ * validated against the closed `NoeticError` union so errors minted by a
20
+ * newer copy of the package still pass (forward compatibility).
21
+ *
14
22
  * @public
15
23
  * @param e - Value to check.
16
- * @returns `true` if `e` is a `NoeticErrorImpl`.
24
+ * @returns `true` if `e` is a `NoeticErrorImpl` (or structurally equivalent).
17
25
  */
18
26
  export declare function isNoeticError(e: unknown): e is NoeticErrorImpl;
@@ -14,12 +14,30 @@ export class NoeticErrorImpl extends Error {
14
14
  /**
15
15
  * Type guard for `NoeticErrorImpl`.
16
16
  *
17
+ * Uses an `instanceof` fast path, then falls back to a structural check
18
+ * (`Error` carrying a `noeticError.kind: string`). The fallback keeps the
19
+ * guard reliable when multiple copies of `@noetic-tools/types` are loaded
20
+ * (e.g. mixed `src`/`dist` resolution or duplicated node_modules), where
21
+ * `instanceof` fails across realms. The inner `kind` is deliberately not
22
+ * validated against the closed `NoeticError` union so errors minted by a
23
+ * newer copy of the package still pass (forward compatibility).
24
+ *
17
25
  * @public
18
26
  * @param e - Value to check.
19
- * @returns `true` if `e` is a `NoeticErrorImpl`.
27
+ * @returns `true` if `e` is a `NoeticErrorImpl` (or structurally equivalent).
20
28
  */
21
29
  export function isNoeticError(e) {
22
- return e instanceof NoeticErrorImpl;
30
+ if (e instanceof NoeticErrorImpl) {
31
+ return true;
32
+ }
33
+ if (!(e instanceof Error)) {
34
+ return false;
35
+ }
36
+ if (!('noeticError' in e)) {
37
+ return false;
38
+ }
39
+ const inner = e.noeticError;
40
+ return (typeof inner === 'object' && inner !== null && 'kind' in inner && typeof inner.kind === 'string');
23
41
  }
24
42
  function formatMessage(error) {
25
43
  switch (error.kind) {
@@ -43,6 +61,10 @@ function formatMessage(error) {
43
61
  return `Budget exceeded: ${error.field} limit ${error.limit}, actual ${error.actual}`;
44
62
  case 'steering_denied':
45
63
  return `Steering denied${error.guidance ? `: ${error.guidance}` : ''}`;
64
+ case 'item_schema_mismatch':
65
+ return error.itemType
66
+ ? `Item type '${error.itemType}' did not match any registered item extension schema.`
67
+ : `Item did not match any registered ${error.category} extension schema.`;
46
68
  case 'handle_evicted':
47
69
  return `Subprocess handle '${error.handleId}' evicted (step '${error.stepId}'): adapter returned null for ${error.gracePeriodMs}ms`;
48
70
  default: {
@@ -11,6 +11,11 @@
11
11
  * Use this at trust boundaries (loading persisted session files, accepting
12
12
  * externally-supplied transcripts) where we need to reject garbage JSON but
13
13
  * don't need to re-validate every provider field TypeScript already types.
14
+ *
15
+ * Extension schemas registered on the registry are **gates, not
16
+ * normalizers**: they validate item shape and the ORIGINAL value is returned
17
+ * on match. Zod transforms, defaults, and key-stripping in extension schemas
18
+ * are unsupported — their output is discarded. Declare pure shape validators.
14
19
  */
15
20
  import { z } from 'zod';
16
21
  import type { Item, ItemSchemaExtensions } from '../types/items.js';
@@ -18,7 +23,14 @@ import type { Item, ItemSchemaExtensions } from '../types/items.js';
18
23
  export declare const ItemSchema: z.ZodCustom<Item, Item>;
19
24
  type ItemSchemaCategory = keyof Required<ItemSchemaExtensions>;
20
25
  export declare function mergeExtensions(a: ItemSchemaExtensions | undefined, b: ItemSchemaExtensions | undefined): ItemSchemaExtensions;
21
- /** @public Runtime item validator with optional tool, memory-layer, and harness extension schemas. */
26
+ /**
27
+ * @public Runtime item validator with optional tool, memory-layer, and harness extension schemas.
28
+ *
29
+ * Extension schemas are pure shape validators (gate, not normalizer): on
30
+ * match the original value is returned unchanged, including fields the schema
31
+ * does not declare. Transforms and defaults in extension schemas are
32
+ * unsupported — their parsed output is never used.
33
+ */
22
34
  export declare class ItemSchemaRegistry {
23
35
  readonly extensions: ItemSchemaExtensions;
24
36
  readonly strictUnknownExtensions: boolean;
@@ -28,6 +40,13 @@ export declare class ItemSchemaRegistry {
28
40
  extend(extensions?: ItemSchemaExtensions): ItemSchemaRegistry;
29
41
  parse(value: unknown): Item;
30
42
  parseMany(values: Iterable<unknown>): Item[];
43
+ /**
44
+ * Validate `value` against the schemas registered for `category` only.
45
+ *
46
+ * When no schemas are registered for the category, falls back to
47
+ * `parse()`. Throws a `NoeticErrorImpl` with kind `item_schema_mismatch`
48
+ * when schemas exist for the category and none match.
49
+ */
31
50
  parseWithCategory(value: unknown, category: ItemSchemaCategory): Item;
32
51
  }
33
52
  /** @public Default item schema registry. */
@@ -11,8 +11,14 @@
11
11
  * Use this at trust boundaries (loading persisted session files, accepting
12
12
  * externally-supplied transcripts) where we need to reject garbage JSON but
13
13
  * don't need to re-validate every provider field TypeScript already types.
14
+ *
15
+ * Extension schemas registered on the registry are **gates, not
16
+ * normalizers**: they validate item shape and the ORIGINAL value is returned
17
+ * on match. Zod transforms, defaults, and key-stripping in extension schemas
18
+ * are unsupported — their output is discarded. Declare pure shape validators.
14
19
  */
15
20
  import { z } from 'zod';
21
+ import { NoeticErrorImpl } from '../errors/noetic-error.js';
16
22
  function isItemLike(value) {
17
23
  if (typeof value !== 'object' || value === null) {
18
24
  return false;
@@ -85,15 +91,19 @@ function isKnownBaseType(type) {
85
91
  type.startsWith('openrouter:'));
86
92
  }
87
93
  function schemaMismatch(category) {
88
- return new z.ZodError([
89
- {
90
- code: 'custom',
91
- message: `Item did not match any registered ${category} extension schema.`,
92
- path: [],
93
- },
94
- ]);
94
+ return new NoeticErrorImpl({
95
+ kind: 'item_schema_mismatch',
96
+ category,
97
+ });
95
98
  }
96
- /** @public Runtime item validator with optional tool, memory-layer, and harness extension schemas. */
99
+ /**
100
+ * @public Runtime item validator with optional tool, memory-layer, and harness extension schemas.
101
+ *
102
+ * Extension schemas are pure shape validators (gate, not normalizer): on
103
+ * match the original value is returned unchanged, including fields the schema
104
+ * does not declare. Transforms and defaults in extension schemas are
105
+ * unsupported — their parsed output is never used.
106
+ */
97
107
  export class ItemSchemaRegistry {
98
108
  extensions;
99
109
  strictUnknownExtensions;
@@ -116,25 +126,30 @@ export class ItemSchemaRegistry {
116
126
  for (const schema of schemas) {
117
127
  const parsed = schema.safeParse(value);
118
128
  if (parsed.success) {
119
- return ItemSchema.parse(parsed.data);
129
+ // Gate, not normalizer: return the original value, not the
130
+ // zod-stripped clone, so undeclared fields survive validation.
131
+ return base;
120
132
  }
121
133
  }
122
134
  if (this.strictUnknownExtensions && !isKnownBaseType(base.type)) {
123
- throw new z.ZodError([
124
- {
125
- code: 'custom',
126
- message: `Item type '${base.type}' did not match any registered item extension schema.`,
127
- path: [
128
- 'type',
129
- ],
130
- },
131
- ]);
135
+ throw new NoeticErrorImpl({
136
+ kind: 'item_schema_mismatch',
137
+ category: 'items',
138
+ itemType: base.type,
139
+ });
132
140
  }
133
141
  return base;
134
142
  }
135
143
  parseMany(values) {
136
144
  return Array.from(values, (value) => this.parse(value));
137
145
  }
146
+ /**
147
+ * Validate `value` against the schemas registered for `category` only.
148
+ *
149
+ * When no schemas are registered for the category, falls back to
150
+ * `parse()`. Throws a `NoeticErrorImpl` with kind `item_schema_mismatch`
151
+ * when schemas exist for the category and none match.
152
+ */
138
153
  parseWithCategory(value, category) {
139
154
  const schemas = schemasForCategory(this.extensions, category);
140
155
  if (schemas.length === 0) {
@@ -143,7 +158,8 @@ export class ItemSchemaRegistry {
143
158
  for (const schema of schemas) {
144
159
  const parsed = schema.safeParse(value);
145
160
  if (parsed.success) {
146
- return ItemSchema.parse(parsed.data);
161
+ // Gate, not normalizer: validate shape, return the original value.
162
+ return ItemSchema.parse(value);
147
163
  }
148
164
  }
149
165
  throw schemaMismatch(category);
@@ -23,6 +23,12 @@ export interface ExternalChannel<T> extends Channel<T> {
23
23
  }
24
24
  /** @public Handle for sending messages to a channel and checking its closed state. */
25
25
  export interface ChannelHandle<T> {
26
+ /**
27
+ * External-sender write: synchronous and never back-pressured. When a
28
+ * queue channel is at capacity the OLDEST item is dropped (with a warning)
29
+ * so external callers (HTTP handlers, CLI prompts) never block. Internal
30
+ * senders (`ctx.send`) get async back-pressure instead.
31
+ */
26
32
  send(value: T): void;
27
33
  readonly closed: boolean;
28
34
  readonly channel: Channel<T>;
@@ -135,7 +135,13 @@ export interface ContextHarness {
135
135
  projectHistory(layers: MemoryLayer[], items: ReadonlyArray<import('./items.js').Item>, ctx: Context): Promise<ReadonlyArray<import('./items.js').Item>>;
136
136
  storeLayers(layers: MemoryLayer[], response: LLMResponse, ctx: Context): Promise<void>;
137
137
  previewRequestItems(scope?: unknown): Promise<ReadonlyArray<import('./items.js').Item>>;
138
- send<T>(channel: Channel<T>, value: T, ctx: Context): void;
138
+ /**
139
+ * Internal-sender channel write. Resolves immediately unless the target
140
+ * queue channel is at capacity, in which case the send parks until a
141
+ * consumer frees a slot (back-pressure; default 30s timeout →
142
+ * `channel_timeout`, abort → `cancelled`).
143
+ */
144
+ send<T>(channel: Channel<T>, value: T, ctx: Context): Promise<void>;
139
145
  recv<T>(channel: Channel<T>, ctx: Context, opts?: {
140
146
  timeout?: number;
141
147
  }): Promise<T>;
@@ -193,7 +199,14 @@ export interface Context<TMemory = ContextMemory, TState = unknown> {
193
199
  recv<T>(channel: Channel<T>, opts?: {
194
200
  timeout?: number;
195
201
  }): Promise<T>;
196
- send<T>(channel: Channel<T>, value: T): void;
202
+ /**
203
+ * Send a value into a channel. Resolves immediately for value/topic
204
+ * channels and for queue channels below capacity. When a queue channel is
205
+ * at capacity the returned promise parks until a consumer dequeues an item
206
+ * (back-pressure): after the default 30s timeout it rejects with
207
+ * `channel_timeout`, and aborting the context rejects it with `cancelled`.
208
+ */
209
+ send<T>(channel: Channel<T>, value: T): Promise<void>;
197
210
  tryRecv<T>(channel: Channel<T>): T | null;
198
211
  checkpoint(): Promise<void>;
199
212
  complete<T>(value: T): void;
@@ -40,6 +40,18 @@ export type NoeticError = {
40
40
  } | {
41
41
  kind: 'cancelled';
42
42
  reason?: string;
43
+ } | {
44
+ /**
45
+ * Raised by `ItemSchemaRegistry` when an item fails extension-schema
46
+ * validation: either a category item (e.g. a tool result) matches none
47
+ * of the schemas registered for its category, or — under
48
+ * `strictUnknownExtensions` — an unknown item type matches no
49
+ * registered extension schema. `itemType` is set for the
50
+ * unknown-item-type case.
51
+ */
52
+ kind: 'item_schema_mismatch';
53
+ category: 'items' | 'developerMessages' | 'toolCalls' | 'toolResults';
54
+ itemType?: string;
43
55
  } | {
44
56
  kind: 'budget_exceeded';
45
57
  field: 'cost' | 'steps' | 'duration';
@@ -195,7 +195,13 @@ export interface AgentHarnessContract<TParams extends Record<string, unknown> =
195
195
  readonly rootCwdState: CwdState;
196
196
  /** Update the harness root cwd. Used by the TUI when the user issues a `! cd`. */
197
197
  setRootCwd(nextCwd: string): void;
198
- send<T>(channel: Channel<T>, value: T, ctx: Context): void;
198
+ /**
199
+ * Internal-sender channel write (back-pressured): resolves immediately
200
+ * unless the queue channel is at capacity, in which case it parks until a
201
+ * consumer frees a slot (30s default timeout → `channel_timeout`; context
202
+ * abort → `cancelled`).
203
+ */
204
+ send<T>(channel: Channel<T>, value: T, ctx: Context): Promise<void>;
199
205
  recv<T>(channel: Channel<T>, ctx: Context, opts?: {
200
206
  timeout?: number;
201
207
  }): Promise<T>;
@@ -2,7 +2,11 @@ import type { LLMResponse } from '../types/common.js';
2
2
  import type { Context } from '../types/context.js';
3
3
  import type { InputMessageItem, Item, MessageItem } from '../types/items.js';
4
4
  export declare function isAssistantMessage(item: unknown): item is MessageItem;
5
- export declare function isUserMessage(item: unknown): item is MessageItem;
5
+ /** @public Input message item narrowed to the `user` role. */
6
+ export type UserMessageItem = InputMessageItem & {
7
+ readonly role: 'user';
8
+ };
9
+ export declare function isUserMessage(item: unknown): item is UserMessageItem;
6
10
  export declare function isOutputText(part: {
7
11
  type: string;
8
12
  }): part is {
@@ -28,7 +28,7 @@ export function isTextPart(part) {
28
28
  /** Extracts assistant/output text from items for memory buffering (one string per message). */
29
29
  export function collectOutputText(items) {
30
30
  return items
31
- .filter((i) => i.type === 'message')
31
+ .filter(isAssistantMessage)
32
32
  .map((i) => i.content
33
33
  .filter(isOutputText)
34
34
  .map((c) => c.text)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noetic-tools/types",
3
- "version": "0.2.0",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
5
  "description": "Foundational type contracts and primitives shared across the Noetic framework (items, LLM config, adapters, steering, errors).",
6
6
  "license": "Apache-2.0",
@@ -60,7 +60,7 @@
60
60
  "scripts": {
61
61
  "build": "bunx tsc -p tsconfig.build.json && bun scripts/add-js-extensions.ts",
62
62
  "verify:esm": "bun scripts/verify-node-esm.ts",
63
- "prepublishOnly": "rm -rf dist && bun run build && bun run verify:esm",
63
+ "prepublishOnly": "rm -rf dist && bun run build && bun run verify:esm && bun scripts/strip-dev-conditions.ts",
64
64
  "test": "bun test",
65
65
  "test:coverage": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov",
66
66
  "test:ci": "bun test --coverage --coverage-reporter=lcov",