@jini-ai/core 0.6.15-rc.2 → 0.6.16

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +3 -2
  3. package/dist/approval.d.ts +19 -4
  4. package/dist/approval.js +54 -14
  5. package/dist/batch-tool-inputs.d.ts +8 -0
  6. package/dist/batch-tool-inputs.js +63 -8
  7. package/dist/contribution-registry.d.ts +23 -0
  8. package/dist/fetch/cache.d.ts +1 -0
  9. package/dist/fetch/cache.js +15 -7
  10. package/dist/index.d.ts +4 -3
  11. package/dist/index.js +2 -2
  12. package/dist/optional-peer.d.ts +17 -0
  13. package/dist/optional-peer.js +40 -0
  14. package/dist/origin-validation.js +9 -8
  15. package/dist/primitives/index.d.ts +1 -0
  16. package/dist/primitives/index.js +1 -0
  17. package/dist/primitives/paths.js +2 -1
  18. package/dist/primitives/sse.js +4 -3
  19. package/dist/primitives/trim-chars.d.ts +25 -0
  20. package/dist/primitives/trim-chars.js +28 -0
  21. package/dist/redact.d.ts +0 -1
  22. package/dist/redact.js +29 -7
  23. package/dist/registration-kit.js +1 -3
  24. package/dist/settings/agent-tools.js +7 -5
  25. package/dist/settings/agent-value-write-tools.js +7 -3
  26. package/dist/settings/ensure-definitions.js +4 -2
  27. package/dist/settings/express/http-request.js +2 -1
  28. package/dist/settings/express/http.js +12 -4
  29. package/dist/settings/repo.memory.js +5 -2
  30. package/dist/settings/settings.js +5 -1
  31. package/dist/settings/tool-registrations.js +9 -3
  32. package/dist/settings/write-service.js +11 -9
  33. package/dist/target-scope.js +2 -1
  34. package/dist/text/index.d.ts +2 -0
  35. package/dist/text/index.js +2 -0
  36. package/dist/text/line-tail.d.ts +17 -0
  37. package/dist/text/line-tail.js +25 -0
  38. package/dist/text/sanitize-untrusted-text.js +33 -4
  39. package/dist/tool-exposure.js +20 -16
  40. package/dist/tool-input-normalization.d.ts +11 -3
  41. package/dist/tool-input-normalization.js +14 -8
  42. package/dist/tool-metadata-types.d.ts +5 -0
  43. package/dist/tool-registry.d.ts +9 -0
  44. package/package.json +8 -10
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # @jini-ai/core
2
2
 
3
+ ## 0.6.16
4
+
5
+ ### Patch Changes
6
+
7
+ - Remove the GitHub Copilot CLI agent: its def, `createCopilotStreamHandler`/`CopilotStreamEvent`,
8
+ account model discovery and the `@github/copilot-sdk` optional peer, the MCP installer's `copilot`
9
+ slug, and its icon. A saved `copilot` agent id no longer resolves (`getAgentDef` returns `null`).
10
+ CopilotKit/AG-UI is unrelated and unchanged.
11
+
12
+ Drop peers no runtime entry loads: `express` from core and http-kit (type-only imports; type
13
+ consumers keep `@types/express`), `vue` from ui-kit, `react-dom` from vibecoding.
14
+
15
+ ## 0.6.15
16
+
17
+ ### Minor Changes
18
+
19
+ - Export async WebCrypto `canonicalArgumentDigest` from `@jini-ai/core/records`, reusing
20
+ `canonicalJson` for identical plugin and host SHA-256 hex digests. Canonical JSON now
21
+ sorts keys recursively by Unicode code point and throws `CanonicalJsonError` for
22
+ non-JSON values and cycles; undefined object properties remain omitted.
23
+
24
+ MCP approval fingerprints reuse the core serializer. Existing ordinary JSON
25
+ fingerprints retain their bytes; non-JSON schemas now fail instead of colliding
26
+ with valid JSON, and astral object keys follow the shared code-point ordering.
27
+
28
+ - Export generic tool-contributor, derived-contributor and paired-registry contracts
29
+ from the existing contribution-registry owner without coupling core to host surfaces.
30
+
3
31
  ## 0.6.13
4
32
 
5
33
  ### Patch Changes
package/README.md CHANGED
@@ -15,8 +15,9 @@ set of tokens, and fails to typecheck if any pack's declared dependency is left
15
15
  npm install @jini-ai/core
16
16
  ```
17
17
 
18
- The root has no runtime dependencies and never loads settings or Express. `express` is an optional
19
- peer used only by `@jini-ai/core/settings/express`; install it in hosts mounting those routes.
18
+ The root has no runtime dependencies and never loads settings or Express. The
19
+ `@jini-ai/core/settings/express` adapter uses Express types and the host's injected app; it never
20
+ loads the Express runtime, so this package declares no Express peer.
20
21
 
21
22
  ## What you get
22
23
 
@@ -1,5 +1,7 @@
1
1
  import { type HumanConfirmer } from './registration-kit.js';
2
2
  import type { ToolExecutionContext, ToolExecutionOptions, ToolHandler, ToolRegistration } from './tool-registry.js';
3
+ /** Maximum human approval wait for one invocation, independent of execution time. */
4
+ export declare const APPROVAL_WAIT_TIMEOUT_MS = 120000;
3
5
  /** A host describes the action; core owns the decision lifecycle, never the card format. */
4
6
  export interface ApprovalPlan<TDescription> {
5
7
  readonly ask: boolean;
@@ -15,11 +17,13 @@ export type ApprovalAnswer = {
15
17
  reason: 'declined' | 'expired' | 'abandoned';
16
18
  };
17
19
  /** The decision reducer shared by parked tool calls and the browser's explicit reply transport.
18
- * An answer never revives an aborted operation. Transport failures propagate without an effect. */
20
+ * An answer never revives an aborted operation. Transport failures propagate without an effect.
21
+ * @param optional.onApprovalStateChange Reports only the human wait, released on every outcome.
22
+ * @complexity O(1) local time/space plus the injected human transport's wait. */
19
23
  export declare function resolveApproval({ signal, askHuman }: {
20
24
  signal: AbortSignal;
21
25
  askHuman: () => Promise<ApprovalAnswer>;
22
- }, _optional?: {}): Promise<ApprovalAnswer>;
26
+ }, { onApprovalStateChange }?: Pick<ToolExecutionOptions, 'onApprovalStateChange'>): Promise<ApprovalAnswer>;
23
27
  export interface RememberedApprovalPort {
24
28
  has(required: {
25
29
  ctx: ToolExecutionContext;
@@ -101,6 +105,12 @@ export declare function createApprovalProposalStore<TDescription>({ responseTool
101
105
  }, { createId }?: {
102
106
  createId?: () => string;
103
107
  }): {
108
+ request: ({ description, signal }: {
109
+ description: TDescription;
110
+ signal: AbortSignal;
111
+ }, { timeoutMs }?: {
112
+ timeoutMs?: number;
113
+ }) => Promise<ApprovalAnswer>;
104
114
  getSnapshot: (_required?: {}, _optional?: {}) => {
105
115
  pending: {
106
116
  approvalId: string;
@@ -115,7 +125,10 @@ export declare function createApprovalProposalStore<TDescription>({ responseTool
115
125
  description: TDescription;
116
126
  execute: () => Promise<unknown>;
117
127
  signal: AbortSignal;
118
- }, _optional?: {}): {
128
+ }, { humanOnly, decide }?: {
129
+ humanOnly?: boolean;
130
+ decide?: (answer: ApprovalAnswer) => void;
131
+ }): {
119
132
  status: "approval_required";
120
133
  approvalId: string;
121
134
  description: TDescription;
@@ -124,7 +137,9 @@ export declare function createApprovalProposalStore<TDescription>({ responseTool
124
137
  respond({ approvalId, approved }: {
125
138
  approvalId: string;
126
139
  approved: boolean;
127
- }, _optional?: {}): Promise<unknown>;
140
+ }, { source }?: {
141
+ source?: "human";
142
+ }): Promise<unknown>;
128
143
  };
129
144
  /** Apply a host classification through the same handler owner used by domain plans.
130
145
  * A host description's string `rememberKey` opts into reusable grants; descriptions remain opaque.
package/dist/approval.js CHANGED
@@ -1,20 +1,26 @@
1
1
  import { humanConfirmedHandler, requireInputRecord } from './registration-kit.js';
2
2
  import { ToolInputError } from './tool-registry.js';
3
3
  import { isNonArrayRecord } from './records.js';
4
+ /** Maximum human approval wait for one invocation, independent of execution time. */
5
+ export const APPROVAL_WAIT_TIMEOUT_MS = 120_000;
4
6
  /** The decision reducer shared by parked tool calls and the browser's explicit reply transport.
5
- * An answer never revives an aborted operation. Transport failures propagate without an effect. */
6
- export async function resolveApproval({ signal, askHuman }, _optional = {}) {
7
+ * An answer never revives an aborted operation. Transport failures propagate without an effect.
8
+ * @param optional.onApprovalStateChange Reports only the human wait, released on every outcome.
9
+ * @complexity O(1) local time/space plus the injected human transport's wait. */
10
+ export async function resolveApproval({ signal, askHuman }, { onApprovalStateChange } = {}) {
7
11
  if (signal.aborted)
8
12
  return { confirmed: false, reason: 'abandoned' };
9
13
  let abandon;
10
14
  const aborted = new Promise(resolve => { abandon = () => resolve({ confirmed: false, reason: 'abandoned' }); });
11
15
  signal.addEventListener('abort', abandon, { once: true });
12
16
  try {
17
+ onApprovalStateChange?.({ pending: true });
13
18
  const answer = await Promise.race([askHuman(), aborted]);
14
19
  return signal.aborted ? { confirmed: false, reason: 'abandoned' } : answer;
15
20
  }
16
21
  finally {
17
22
  signal.removeEventListener('abort', abandon);
23
+ onApprovalStateChange?.({ pending: false });
18
24
  }
19
25
  }
20
26
  /** The confirmer is always the run's own delegating principal, as a user. */
@@ -38,7 +44,7 @@ export function createApprovalHandler(steps, { remembered } = {}) {
38
44
  /** Ask the host transport; record the choice and any reusable grant only on an explicit yes. */
39
45
  const askFresh = async ({ prepared, plan }, options = {}) => {
40
46
  const { ctx } = prepared;
41
- const answer = await resolveApproval({ signal: ctx.signal, askHuman: () => steps.askHuman({ ctx, description: plan.description }, options) });
47
+ const answer = await resolveApproval({ signal: ctx.signal, askHuman: () => steps.askHuman({ ctx, description: plan.description }, options) }, options);
42
48
  if (ctx.signal.aborted)
43
49
  return abandoned(prepared);
44
50
  if (answer.confirmed !== true)
@@ -153,33 +159,45 @@ export function createApprovalProposalStore({ responseTool, messages }, { create
153
159
  const listeners = new Set();
154
160
  const publish = (next) => { snapshot = next; for (const listener of listeners)
155
161
  listener(); };
156
- const clear = () => {
162
+ const clear = (answer = { confirmed: false, reason: 'abandoned' }) => {
163
+ const decide = action?.decide;
157
164
  detach?.();
158
165
  detach = null;
159
166
  action = null;
160
167
  publish({ pending: null, error: null });
168
+ decide?.(answer);
161
169
  };
162
- return {
170
+ /** Resolve authority before consuming the single-use action; model input cannot set source. */
171
+ const actionForReply = ({ approvalId, source }) => {
172
+ if (!action || action.approvalId !== approvalId)
173
+ throw new Error(messages.missing);
174
+ if (action.humanOnly && source !== 'human')
175
+ throw new Error('This decision requires the app dialog');
176
+ return action;
177
+ };
178
+ const store = {
163
179
  getSnapshot: (_required = {}, _optional = {}) => snapshot,
164
180
  subscribe: ({ listener }, _optional = {}) => { listeners.add(listener); return () => { listeners.delete(listener); }; },
165
- propose({ description, execute, signal }, _optional = {}) {
181
+ propose({ description, execute, signal }, { humanOnly = false, decide } = {}) {
166
182
  if (signal.aborted)
167
183
  throw new Error(messages.closed);
168
184
  if (snapshot.pending)
169
185
  throw new Error(messages.pending);
170
186
  const frozenDescription = structuredClone(description);
171
187
  const approvalId = createId();
172
- action = { approvalId, execute, signal };
173
- signal.addEventListener('abort', clear, { once: true });
174
- detach = () => signal.removeEventListener('abort', clear);
188
+ action = { approvalId, execute, signal, humanOnly, ...(decide ? { decide } : {}) };
189
+ const abort = () => clear();
190
+ signal.addEventListener('abort', abort, { once: true });
191
+ detach = () => signal.removeEventListener('abort', abort);
175
192
  publish({ pending: { approvalId, description: structuredClone(frozenDescription) }, error: null });
176
193
  return { status: 'approval_required', approvalId, description: frozenDescription, responseTool };
177
194
  },
178
- async respond({ approvalId, approved }, _optional = {}) {
179
- if (!action || action.approvalId !== approvalId)
180
- throw new Error(messages.missing);
181
- const prepared = action;
182
- clear();
195
+ async respond({ approvalId, approved }, { source } = {}) {
196
+ const prepared = actionForReply({ approvalId, source });
197
+ clear(approved === true ? { confirmed: true } : { confirmed: false, reason: 'declined' });
198
+ // An awaiting caller owns execution after this decision; do not start a second lifecycle.
199
+ if (prepared.decide)
200
+ return { status: approved === true ? 'approved' : 'declined' };
183
201
  const answer = await resolveApproval({ signal: prepared.signal, askHuman: async () => approved === true ? { confirmed: true } : { confirmed: false, reason: 'declined' } });
184
202
  if (!answer.confirmed)
185
203
  return { status: 'declined' };
@@ -192,6 +210,28 @@ export function createApprovalProposalStore({ responseTool, messages }, { create
192
210
  }
193
211
  },
194
212
  };
213
+ /** Park a call on this same store without publishing any assistant reply capability. The app
214
+ * dialog alone may decide it. Expiry/abort consume the proposal before resolving the waiter. */
215
+ async function request({ description, signal }, { timeoutMs = APPROVAL_WAIT_TIMEOUT_MS } = {}) {
216
+ const lifetime = new AbortController();
217
+ const abort = () => lifetime.abort();
218
+ if (signal.aborted)
219
+ return { confirmed: false, reason: 'abandoned' };
220
+ signal.addEventListener('abort', abort, { once: true });
221
+ let expired = false;
222
+ const timer = setTimeout(() => { expired = true; lifetime.abort(); }, timeoutMs);
223
+ try {
224
+ return await resolveApproval({ signal: lifetime.signal, askHuman: () => new Promise(resolve => {
225
+ store.propose({ description, signal: lifetime.signal, execute: async () => undefined }, { humanOnly: true, decide: resolve });
226
+ }) }).then(answer => expired ? { confirmed: false, reason: 'expired' } : answer);
227
+ }
228
+ finally {
229
+ clearTimeout(timer);
230
+ signal.removeEventListener('abort', abort);
231
+ lifetime.abort();
232
+ }
233
+ }
234
+ return { ...store, request };
195
235
  }
196
236
  /** Apply a host classification through the same handler owner used by domain plans.
197
237
  * A host description's string `rememberKey` opts into reusable grants; descriptions remain opaque.
@@ -20,6 +20,14 @@ export declare const BATCH_GUIDANCE_MARKER = " Batch: ";
20
20
  export declare function withoutBatchGuidance({ description }: {
21
21
  description: string;
22
22
  }, _optional?: {}): string;
23
+ /** The top-level `required` fields {@link batchDescriptorFor} moved into its `items` description.
24
+ * A batch schema declares no top-level `required`, so a discovery signature built from it alone
25
+ * makes a single call look argument-free and the model sends `{}` — this recovers them.
26
+ * @returns The moved field names in order; [] for a non-batch schema or a tool that required none.
27
+ * @complexity O(n) in the items description length; pure. */
28
+ export declare function batchItemRequiredFields({ inputSchema }: {
29
+ inputSchema: unknown;
30
+ }, _optional?: {}): string[];
23
31
  /** Used when a {@link BatchableTool} names no `maxItems`. */
24
32
  export declare const DEFAULT_MAX_BATCH_ITEMS = 25;
25
33
  /** One item's outcome. `error` is the domain's own message without the appended input schema,
@@ -16,6 +16,22 @@ export function withoutBatchGuidance({ description }, _optional = {}) {
16
16
  return end === -1 ? `${BATCH_GUIDANCE_MARKER}to do several at once,${part}` : part.slice(end + BATCH_GUIDANCE_END.length);
17
17
  }).join("");
18
18
  }
19
+ const ITEM_NEEDS = " Each item needs ";
20
+ const ITEM_NEEDS_END = " (inside each item).";
21
+ /** The top-level `required` fields {@link batchDescriptorFor} moved into its `items` description.
22
+ * A batch schema declares no top-level `required`, so a discovery signature built from it alone
23
+ * makes a single call look argument-free and the model sends `{}` — this recovers them.
24
+ * @returns The moved field names in order; [] for a non-batch schema or a tool that required none.
25
+ * @complexity O(n) in the items description length; pure. */
26
+ export function batchItemRequiredFields({ inputSchema }, _optional = {}) {
27
+ const items = isPlainObject(inputSchema) && isPlainObject(inputSchema.properties) ? inputSchema.properties.items : undefined;
28
+ const text = isPlainObject(items) && typeof items.description === "string" ? items.description : "";
29
+ const start = text.indexOf(ITEM_NEEDS);
30
+ const end = text.indexOf(ITEM_NEEDS_END, start);
31
+ if (start === -1 || end === -1)
32
+ return [];
33
+ return text.slice(start + ITEM_NEEDS.length, end).split(", ");
34
+ }
19
35
  /** Used when a {@link BatchableTool} names no `maxItems`. */
20
36
  export const DEFAULT_MAX_BATCH_ITEMS = 25;
21
37
  /** Classify a batch result, including legacy envelopes, without dropping per-item evidence.
@@ -26,7 +42,13 @@ export function batchOutcomeFor({ output }, _options = {}) {
26
42
  if (!isPlainObject(output) || output.batch !== true || !Array.isArray(output.results) || output.results.length === 0)
27
43
  return undefined;
28
44
  const failed = output.results.filter(item => isPlainObject(item) && item.ok === false).length;
29
- return failed === 0 ? 'completed' : failed === output.results.length ? 'error' : 'partial_failure';
45
+ return outcomeFor({ failed, total: output.results.length });
46
+ }
47
+ /** No failure completes the batch, all failing is an error, anything between is partial. */
48
+ function outcomeFor({ failed, total }) {
49
+ if (failed === 0)
50
+ return 'completed';
51
+ return failed === total ? 'error' : 'partial_failure';
30
52
  }
31
53
  /** A JSON object: not null, not an array. */
32
54
  function isPlainObject(value) {
@@ -54,8 +76,9 @@ export function batchDescriptorFor({ descriptor, tool }, _optional = {}) {
54
76
  }
55
77
  const maxItems = tool.maxItems ?? DEFAULT_MAX_BATCH_ITEMS;
56
78
  const requiredFields = schema.required ?? [];
57
- const { required: _required, ...rest } = schema;
58
- const needs = requiredFields.length > 0 ? ` Each item needs ${requiredFields.join(", ")} (inside each item).` : "";
79
+ const rest = { ...schema };
80
+ delete rest.required;
81
+ const needs = requiredFields.length > 0 ? `${ITEM_NEEDS}${requiredFields.join(", ")}${ITEM_NEEDS_END}` : "";
59
82
  const items = {
60
83
  type: "array",
61
84
  minItems: 1,
@@ -104,6 +127,38 @@ async function normalizeBatchItem({ registration, defaults, shared, item }) {
104
127
  const normalized = await normalizeToolInput({ input, registration });
105
128
  return { input: normalized.input, notes: [...layers.flatMap(layer => layer.notes), ...normalized.notes] };
106
129
  }
130
+ /** Restore the single-call requirement the published batch schema intentionally omits, from the
131
+ * wrapped tool's own schema. Normalize first so repaired spellings and domain aliases satisfy it.
132
+ * @throws ToolInputError with every required name/type when any required value is missing.
133
+ * @complexity O(r) for r required fields, plus the existing normalization; no effects.
134
+ */
135
+ async function normalizeSingleBatchInput({ input, registration }) {
136
+ const normalized = await normalizeToolInput({ input, registration });
137
+ const value = normalized.input;
138
+ const schema = registration.descriptor.inputSchema;
139
+ const required = schema.required ?? [];
140
+ if (!isPlainObject(value) || required.every(name => value[name] !== undefined))
141
+ return normalized;
142
+ const missing = required.filter(name => value[name] === undefined);
143
+ const fields = required.map(name => `${name} (${schemaTypeLabel(schema.properties?.[name])})`);
144
+ throw new ToolInputError({ message: `${registration.descriptor.id}: missing required inputs: ${missing.join(', ')}. Required inputs: ${fields.join(', ')}.` });
145
+ }
146
+ /** A property's declared JSON type, `a | b` for a type list, `unknown` when undeclared. */
147
+ function schemaTypeLabel(property) {
148
+ const type = isPlainObject(property) ? property.type : undefined;
149
+ if (Array.isArray(type))
150
+ return type.join(' | ');
151
+ return typeof type === 'string' ? type : 'unknown';
152
+ }
153
+ /** A batch result is one JSON value, so an item's MCP `image` block would reach the model and the
154
+ * chat as base64 text (a 20-item import batch, 20 previews). Drop images from item envelopes; the
155
+ * item's text blocks still carry its result. Anything that is not an envelope is returned as is. */
156
+ function withoutItemImages(output) {
157
+ if (!isPlainObject(output) || !Array.isArray(output.content))
158
+ return output;
159
+ const content = output.content.filter((block) => !isPlainObject(block) || block.type !== 'image');
160
+ return content.length === output.content.length ? output : { ...output, content };
161
+ }
107
162
  /** Classify one returned output without dropping error payloads or normalization notes.
108
163
  * @complexity O(n) for a nested batch's n results, plus formatting its notes; no effects.
109
164
  */
@@ -119,7 +174,7 @@ function batchItemResult({ index, output, notes }, _options = {}) {
119
174
  */
120
175
  function summarizeBatchResults({ results }, _options = {}) {
121
176
  const succeeded = results.filter((result) => result.ok).length;
122
- const outcome = succeeded === results.length ? 'completed' : succeeded === 0 ? 'error' : 'partial_failure';
177
+ const outcome = outcomeFor({ failed: results.length - succeeded, total: results.length });
123
178
  return { batch: true, outcome, ...(succeeded === 0 ? { isError: true } : {}), total: results.length, succeeded, failed: results.length - succeeded, results };
124
179
  }
125
180
  /**
@@ -151,7 +206,7 @@ export function withBatchInput({ registration, tool }, { mode = 'items' } = {})
151
206
  // its policy and planner receive the same targets; never execute items to preflight them.
152
207
  normalizeInput: async ({ input }) => {
153
208
  if (!Object.hasOwn(input, 'items'))
154
- return normalizeToolInput({ input, registration });
209
+ return normalizeSingleBatchInput({ input, registration });
155
210
  const { items: rawItems, ...shared } = input;
156
211
  const items = readItems({ items: rawItems, toolId, maxItems });
157
212
  const normalized = [];
@@ -182,7 +237,7 @@ export function withBatchInput({ registration, tool }, { mode = 'items' } = {})
182
237
  if (!options?.invokeTool)
183
238
  throw new Error(`${toolId}: batch items require the gated ToolExecutor invocation port`);
184
239
  const output = await options.invokeTool({ toolId, input: normalized.input }, { registry: itemRegistry });
185
- results.push(batchItemResult({ index, output, notes: normalized.notes }));
240
+ results.push(batchItemResult({ index, output: withoutItemImages(output), notes: normalized.notes }));
186
241
  }
187
242
  catch (error) {
188
243
  results.push({ index, ok: false, error: withNormalizationNotes({ output: itemErrorMessage({ error, toolId }), notes }) });
@@ -192,9 +247,9 @@ export function withBatchInput({ registration, tool }, { mode = 'items' } = {})
192
247
  };
193
248
  return { ...registration, descriptor, handler,
194
249
  // Domain normalization belongs to each merged item, not the incomplete shared layer.
195
- normalizeInput: async ({ input }, options) => Object.hasOwn(input, 'items') || !registration.normalizeInput
250
+ normalizeInput: async ({ input }) => Object.hasOwn(input, 'items')
196
251
  ? { input, notes: [] }
197
- : registration.normalizeInput({ input }, options),
252
+ : normalizeSingleBatchInput({ input, registration }),
198
253
  };
199
254
  }
200
255
  /**
@@ -1,3 +1,26 @@
1
+ import type { DerivedRiskByToolId } from './registration-kit.js';
2
+ import type { ToolRegistration } from './tool-registry.js';
3
+ /** A domain owns its builder and risk classification; the host binds dependencies and surfaces. */
4
+ export interface ToolContributor<TDeps, TSurfaces> {
5
+ readonly domain: string;
6
+ readonly build: (deps: TDeps, surfaces: TSurfaces) => ToolRegistration[];
7
+ readonly risk: DerivedRiskByToolId;
8
+ }
9
+ /** Post-processing sees the domain registrations; ordering and collision checks belong to the host. */
10
+ export interface DerivedToolContributor<TDeps, TSurfaces> {
11
+ readonly domain: string;
12
+ readonly derive: (input: {
13
+ readonly registrations: readonly ToolRegistration[];
14
+ readonly routeDeps: TDeps;
15
+ readonly surfaces: TSurfaces;
16
+ }) => ToolRegistration[];
17
+ readonly risk: DerivedRiskByToolId;
18
+ }
19
+ /** The two ordered registries belong to one composition root, never shared across workspaces. */
20
+ export interface ToolContributions<TDeps, TSurfaces> {
21
+ readonly contributors: ContributionRegistry<ToolContributor<TDeps, TSurfaces>>;
22
+ readonly derivedContributors: ContributionRegistry<DerivedToolContributor<TDeps, TSurfaces>>;
23
+ }
1
24
  /** Replace-by-key registry for host-owned contributions; no singleton or execution surface. */
2
25
  export interface ContributionRegistry<TContribution> {
3
26
  register(required: {
@@ -110,6 +110,7 @@ export declare class FetchQueryCache {
110
110
  invalidate({ prefix }: {
111
111
  prefix: QueryKey;
112
112
  }): void;
113
+ private reloadInvalidated;
113
114
  onFocus(): void;
114
115
  /** Execute a read/write once connectivity returns; failures are not retried.
115
116
  * @param required.run Operation to invoke online.
@@ -16,7 +16,9 @@ const browserScheduler = {
16
16
  // Normalize unknown promise rejections once so consumers receive Error without repeating
17
17
  // the same narrowing boilerplate at every read and write call site.
18
18
  export function toQueryError(value) {
19
- return value instanceof Error ? value : new Error(typeof value === 'string' && value.trim() ? value : 'request failed');
19
+ if (value instanceof Error)
20
+ return value;
21
+ return new Error(typeof value === 'string' && value.trim() ? value : 'request failed');
20
22
  }
21
23
  /** One provider-owned cache. Requests are shared by structural primitive keys.
22
24
  * Failed reads retain earlier data; replacement writes supersede older requests.
@@ -162,12 +164,17 @@ export class FetchQueryCache {
162
164
  const pendingRead = entry.pendingRead;
163
165
  entry.pending = undefined;
164
166
  entry.pendingRead = undefined;
165
- if (entry.observers.size)
166
- this.refresh(entry);
167
- // An awaiting loader still needs an answer even if no observer is mounted.
168
- else if (pendingRead)
169
- void this.load({ entry, ...pendingRead });
167
+ this.reloadInvalidated(entry, pendingRead);
168
+ }
169
+ }
170
+ reloadInvalidated(entry, pendingRead) {
171
+ if (entry.observers.size) {
172
+ this.refresh(entry);
173
+ return;
170
174
  }
175
+ // An awaiting loader still needs an answer even if no observer is mounted.
176
+ if (pendingRead)
177
+ void this.load({ entry, ...pendingRead });
171
178
  }
172
179
  onFocus() {
173
180
  for (const entry of this.entries.values()) {
@@ -249,7 +256,8 @@ export class FetchQueryCache {
249
256
  }
250
257
  scheduleEviction(entry) {
251
258
  this.cancelEviction(entry);
252
- if (this.disposed || entry.listeners.size || entry.observers.size || entry.pending || !Number.isFinite(entry.retention))
259
+ const inUse = entry.listeners.size > 0 || entry.observers.size > 0 || Boolean(entry.pending);
260
+ if (this.disposed || inUse || !Number.isFinite(entry.retention))
253
261
  return;
254
262
  entry.eviction = this.scheduler.schedule({ callback: () => this.entries.delete(JSON.stringify(entry.key)), delayMs: entry.retention });
255
263
  }
package/dist/index.d.ts CHANGED
@@ -11,13 +11,14 @@ export * from './api-token-auth.js';
11
11
  export * from './origin-validation.js';
12
12
  export * from './principal.js';
13
13
  export * from './surface-exchanges.js';
14
- export type { AuthorizationDecision, RunRef, SurfaceEmission, SurfaceEmitter, ToolAuthorizationContext, ToolDescriptor, ToolExecutionContext, ToolExecutionOptions, ToolHandler, ToolPolicy, ToolRegistration, ToolRegistry, } from './tool-registry.js';
14
+ export type { AuthorizationDecision, RunRef, SurfaceEmission, SurfaceEmitter, ToolAuthorizationContext, ToolDescriptor, ToolExecutionContext, ToolExecutionOptions, ToolApprovalDecision, ToolHandler, ToolPolicy, ToolRegistration, ToolRegistry, } from './tool-registry.js';
15
15
  export { createToolRegistrationScope, toolRegistryRevision, createToolRegistry, isReadOnlyTool, toolDiscoveryMetadata, ToolInputError } from './tool-registry.js';
16
16
  export * from './tool-tokens.js';
17
17
  export * from './tool-exposure.js';
18
18
  export * from './gated-mutations.js';
19
19
  export * from './model-facing-tool-errors.js';
20
- export * from './contribution-registry.js';
20
+ export { createContributionRegistry } from './contribution-registry.js';
21
+ export type { ContributionRegistry, ToolContributor, DerivedToolContributor, ToolContributions } from './contribution-registry.js';
21
22
  export * from './provider-registry.js';
22
23
  export * from './object-store.js';
23
24
  export * from './naming.js';
@@ -29,6 +30,6 @@ export * from "./native-regexp.js";
29
30
  export { ForbiddenError as ToolPermissionDeniedError, requireToolPermission, adaptLegacyAuthorize, type LegacyToolAuthorizeFn, type AuthorizationPort as ToolPermissionAuthorizationPort, type RequireToolPermissionRequired, type RequireToolPermissionOptional, } from "./tool-permissions.js";
30
31
  export { evaluateFeatureFlag, type FeatureFlagDecision } from './feature-flags.js';
31
32
  export { normalizeToolInput, withNormalizationNotes, ToolInputNormalizationError, type NormalizedToolInput, type ToolInputNormalizer } from './tool-input-normalization.js';
32
- export { applyBatchInputs, BATCH_GUIDANCE_MARKER, withoutBatchGuidance, batchOutcomeFor, batchDescriptorFor, batchEligibilityFor, defaultBatchableTools, withBatchInput, DEFAULT_MAX_BATCH_ITEMS, type BatchableTool, type BatchEligibility, type BatchOptOut, type BatchResult, type BatchItemResult } from './batch-tool-inputs.js';
33
+ export { applyBatchInputs, BATCH_GUIDANCE_MARKER, withoutBatchGuidance, batchOutcomeFor, batchDescriptorFor, batchItemRequiredFields, batchEligibilityFor, defaultBatchableTools, withBatchInput, DEFAULT_MAX_BATCH_ITEMS, type BatchableTool, type BatchEligibility, type BatchOptOut, type BatchResult, type BatchItemResult } from './batch-tool-inputs.js';
33
34
  export type { MediaProviderCatalogPolicy, MediaProviderOption, ProviderPreset, ProviderPresetKind } from './provider-catalog.js';
34
35
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ export * from './tool-tokens.js';
14
14
  export * from './tool-exposure.js';
15
15
  export * from './gated-mutations.js';
16
16
  export * from './model-facing-tool-errors.js';
17
- export * from './contribution-registry.js';
17
+ export { createContributionRegistry } from './contribution-registry.js';
18
18
  export * from './provider-registry.js';
19
19
  export * from './object-store.js';
20
20
  export * from './naming.js';
@@ -25,5 +25,5 @@ export * from "./native-regexp.js";
25
25
  export { ForbiddenError as ToolPermissionDeniedError, requireToolPermission, adaptLegacyAuthorize, } from "./tool-permissions.js";
26
26
  export { evaluateFeatureFlag } from './feature-flags.js';
27
27
  export { normalizeToolInput, withNormalizationNotes, ToolInputNormalizationError } from './tool-input-normalization.js';
28
- export { applyBatchInputs, BATCH_GUIDANCE_MARKER, withoutBatchGuidance, batchOutcomeFor, batchDescriptorFor, batchEligibilityFor, defaultBatchableTools, withBatchInput, DEFAULT_MAX_BATCH_ITEMS } from './batch-tool-inputs.js';
28
+ export { applyBatchInputs, BATCH_GUIDANCE_MARKER, withoutBatchGuidance, batchOutcomeFor, batchDescriptorFor, batchItemRequiredFields, batchEligibilityFor, defaultBatchableTools, withBatchInput, DEFAULT_MAX_BATCH_ITEMS } from './batch-tool-inputs.js';
29
29
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Load a host-selected feature's optional library, synchronously or asynchronously.
3
+ * Keep the import/require literal in the caller so bundlers and closure guards can see it.
4
+ * @throws A feature-specific load error with the original resolution/ABI error as `cause`.
5
+ * @complexity O(1) wrapper work; loading cost belongs to the supplied loader.
6
+ */
7
+ export declare function loadOptionalPeer<T>({ feature, dependency, load }: {
8
+ feature: string;
9
+ dependency: string;
10
+ load: () => T;
11
+ }, { createError }?: {
12
+ createError?: (input: {
13
+ message: string;
14
+ cause: unknown;
15
+ }) => Error;
16
+ }): T;
17
+ //# sourceMappingURL=optional-peer.d.ts.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Load a host-selected feature's optional library, synchronously or asynchronously.
3
+ * Keep the import/require literal in the caller so bundlers and closure guards can see it.
4
+ * @throws A feature-specific load error with the original resolution/ABI error as `cause`.
5
+ * @complexity O(1) wrapper work; loading cost belongs to the supplied loader.
6
+ */
7
+ export function loadOptionalPeer({ feature, dependency, load }, { createError = ({ message, cause }) => new Error(message, { cause }) } = {}) {
8
+ const failed = (cause) => {
9
+ throw createError({ message: optionalPeerLoadMessage({ feature, dependency, cause }), cause });
10
+ };
11
+ try {
12
+ const library = load();
13
+ // Native import() returns a Promise. Synchronous factories must keep their return contract.
14
+ if (library instanceof Promise)
15
+ return library.catch(failed);
16
+ return library;
17
+ }
18
+ catch (cause) {
19
+ return failed(cause);
20
+ }
21
+ }
22
+ /** Classify loader failures without echoing paths or other untrusted driver diagnostics.
23
+ * Resolution of an internal dependency is not evidence the selected peer is absent.
24
+ * Pure; O(1) branches and O(message length) matching; no errors or side effects. */
25
+ function optionalPeerLoadMessage({ feature, dependency, cause }, _options = {}) {
26
+ const prefix = `${feature} could not load ${dependency}.`;
27
+ const { message: detail = '', code } = cause instanceof Error ? cause : {};
28
+ if (/NODE_MODULE_VERSION|compiled against a different Node\.js version/.test(detail)) {
29
+ return `${prefix} Its native binary was built for a different Node.js/Electron ABI. Rebuild ${dependency} for the host runtime.`;
30
+ }
31
+ if (code === 'ERR_DLOPEN_FAILED') {
32
+ return `${prefix} Its native binary is incompatible with this runtime or architecture. Rebuild ${dependency} for the host runtime.`;
33
+ }
34
+ const missing = code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND';
35
+ if (missing && (detail.includes(`Cannot find module '${dependency}'`) || detail.includes(`Cannot find package '${dependency}'`))) {
36
+ return `${prefix} It is not installed. Install ${dependency} to enable this feature.`;
37
+ }
38
+ return `${prefix} Check its installed dependencies and native binaries.`;
39
+ }
40
+ //# sourceMappingURL=optional-peer.js.map
@@ -134,7 +134,13 @@ function parseDottedQuad(host) {
134
134
  }
135
135
  /** RFC 1918 (10/8, 172.16/12, 192.168/16) or link-local (169.254/16), from the first two octets. */
136
136
  function isPrivateIpv4Prefix([a, b]) {
137
- return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254);
137
+ if (a === 10)
138
+ return true;
139
+ if (a === 172)
140
+ return b >= 16 && b <= 31;
141
+ if (a === 192)
142
+ return b === 168;
143
+ return a === 169 && b === 254;
138
144
  }
139
145
  /** Whether `hostname` is an RFC 1918 private IPv4 address (10/8, 172.16/12, 192.168/16) or link-local (169.254/16).
140
146
  * Implements the dev-browser LAN allow-list, not SSRF blocking. Use @jini-ai/platform/net
@@ -152,17 +158,12 @@ export function isIpLiteralHostname({ hostname }) {
152
158
  return true;
153
159
  return parseDottedQuad(host) !== undefined;
154
160
  }
161
+ const LOOPBACK_OR_UNSPECIFIED_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0', '::']);
155
162
  /** Whether `hostname` is loopback, unspecified, or a private-LAN address.
156
163
  * Implements the dev-browser LAN allow-list, not SSRF blocking (@jini-ai/platform/net). */
157
164
  export function isLoopbackOrPrivateLanHost({ hostname }) {
158
165
  const host = String(hostname || '').toLowerCase();
159
- return (host === 'localhost' ||
160
- host === '127.0.0.1' ||
161
- host === '::1' ||
162
- host === '[::1]' ||
163
- host === '0.0.0.0' ||
164
- host === '::' ||
165
- isPrivateIpv4({ hostname: host }));
166
+ return LOOPBACK_OR_UNSPECIFIED_HOSTS.has(host) || isPrivateIpv4({ hostname: host });
166
167
  }
167
168
  /** Whether a request's `Host` header names a locally-served or explicitly allow-listed host. */
168
169
  export function isAllowedBrowserHost({ hostHeader, ports, bindHost, extraAllowedOrigins }) {
@@ -8,4 +8,5 @@ export * from './logger.js';
8
8
  export * from './result.js';
9
9
  export * from './paths.js';
10
10
  export * from './tasks.js';
11
+ export { trimChars, trimCharsEnd, trimCharsStart } from './trim-chars.js';
11
12
  //# sourceMappingURL=index.d.ts.map
@@ -7,4 +7,5 @@ export * from './logger.js';
7
7
  export * from './result.js';
8
8
  export * from './paths.js';
9
9
  export * from './tasks.js';
10
+ export { trimChars, trimCharsEnd, trimCharsStart } from './trim-chars.js';
10
11
  //# sourceMappingURL=index.js.map
@@ -1,3 +1,4 @@
1
+ import { trimCharsEnd } from './trim-chars.js';
1
2
  /** Root of an already `/`-separated Windows path: a drive root (`C:/`) or a UNC share root. */
2
3
  function windowsRoot(path) {
3
4
  return /^[A-Za-z]:\//.exec(path)?.[0] ?? /^\/\/[^/]+\/[^/]+(?:\/|$)/.exec(path)?.[0];
@@ -22,7 +23,7 @@ function normalizeAbsolutePath(value, separator) {
22
23
  const prefix = (windows ? windowsRoot(path) : undefined) ?? (path.startsWith('/') ? '/' : undefined);
23
24
  if (!prefix)
24
25
  throw new RangeError('pathContains requires resolved absolute paths');
25
- return prefix.replace(/\/+$/, '') + '/' + resolveSegments(path.slice(prefix.length)).join('/');
26
+ return trimCharsEnd({ text: prefix, chars: '/' }) + '/' + resolveSegments(path.slice(prefix.length)).join('/');
26
27
  }
27
28
  /**
28
29
  * Check lexical containment using whole directory segments, including equality.