@jini-ai/core 0.4.2 → 0.4.4

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/CHANGELOG.md CHANGED
@@ -1,7 +1,13 @@
1
1
  # @jini-ai/core
2
2
 
3
+ ## 0.4.4 — 2026-10-12
4
+
5
+ - New `./records` subpath exports `isRecord` and `isNonArrayRecord` without loading the core barrel; the root and `registration-kit` keep re-exporting both.
6
+
3
7
  ## 0.4.2 — pending release
4
8
 
9
+ - Add source-backed `./plugin-directory` publisher identity and live metadata lookup. Warn and omit invalid advisory keywords; scope legacy `unknown/<name>` IDs to each plugin family, with optional family selection for ambiguous lookups.
10
+
5
11
  - Own generic settings at `./settings` and its optional Express adapter at `./settings/express`; keep both out of the root runtime closure. Move the existing permission helper into core, preserving the CMS command denial class through re-export.
6
12
 
7
13
  - Own the shared surface-exchange interfaces and reserved parameter constants, formerly declared in daemon. Add the `SurfaceAskThenReport` port so UI can consume the existing daemon lifecycle without depending on its runtime.
package/README.md CHANGED
@@ -94,6 +94,7 @@ Tool handlers receive required execution context first and optional `{ emitSurfa
94
94
 
95
95
  | subpath | what's behind it |
96
96
  |---|---|
97
+ | `./fetch` | Framework-neutral query contracts and `FetchQueryCache`; inject transport callbacks, `Clock`, scheduler and connectivity/focus ports. No React, DOM or TanStack dependency. |
97
98
  | `.` | The public surface above: tokens, packs, daemon composition, tool registry (descriptors only), principal, redact, auth primitives. |
98
99
  | `./composition` | **Not part of the public contract.** Exposes `authorizeToolInvocation` (resolves a registry's tool and runs its authorization gate — a security-sensitive value export, but one that only ever hands back a handler alongside an `'allow'` decision) plus type-only `AnyPack`/`RequiredTokenIds`/`MissingTokenIds` re-derivation helpers. Its sole intended consumers are `@jini-ai/daemon`'s `ToolExecutor` and `@jini-ai/server`'s `createLocalNodeDaemon`. Node's resolver will happily let any consumer import it — the boundary is enforced by `scripts/check-engine-boundaries.ts`, not by the exports map — so external adopters should treat it as off-limits regardless. |
99
100
 
@@ -0,0 +1,129 @@
1
+ import type { Clock } from '../primitives/time.js';
2
+ import type { QueryKey } from './types.js';
3
+ /**
4
+ * Ten seconds covers reading, closing and reopening a record or bouncing between screens
5
+ * without paying for another identical request. Five seconds was too short for that read
6
+ * cycle; thirty seconds exposed more other-operator staleness than the conservative initial
7
+ * shared default should allow. There is no default polling/focus refresh, so this bounds
8
+ * the extra exposure on a quick revisit. Write invalidation bypasses freshness; reads that
9
+ * must be fresh on every mount can choose staleTime: 0 without lowering every query default.
10
+ */
11
+ export declare const DEFAULT_STALE_TIME = 10000;
12
+ /** Minimum time an unobserved entry is kept; both adapters retain max(this, staleTime). */
13
+ export declare const IDLE_RETENTION: number;
14
+ export interface QueryCacheSchedulerPort {
15
+ schedule(required: {
16
+ callback: () => void;
17
+ delayMs: number;
18
+ }): unknown;
19
+ cancel(required: {
20
+ handle: unknown;
21
+ }): void;
22
+ }
23
+ /** Host connectivity and focus signals; the cache itself has no DOM dependency. */
24
+ export interface FetchQueryEnvironmentPort {
25
+ isOnline(): boolean;
26
+ subscribeOnline(required: {
27
+ listener: (online: boolean) => void;
28
+ }): () => void;
29
+ subscribeFocus(required: {
30
+ listener: () => void;
31
+ }): () => void;
32
+ }
33
+ export interface FetchQueryCacheOptions {
34
+ clock?: Clock;
35
+ scheduler?: QueryCacheSchedulerPort;
36
+ environment?: FetchQueryEnvironmentPort;
37
+ }
38
+ export interface QueryState {
39
+ data: unknown;
40
+ error: Error | null;
41
+ status: 'pending' | 'success' | 'error';
42
+ isFetching: boolean;
43
+ }
44
+ export interface QueryObserver {
45
+ fetch: () => Promise<unknown>;
46
+ staleTime: number;
47
+ focus: boolean;
48
+ }
49
+ export interface CacheEntry {
50
+ key: QueryKey;
51
+ state: QueryState;
52
+ updatedAt: number;
53
+ invalidated: boolean;
54
+ replacement: number;
55
+ pending: Promise<unknown> | undefined;
56
+ pendingRead: {
57
+ fetch: () => Promise<unknown>;
58
+ staleTime: number;
59
+ } | undefined;
60
+ refreshAfterPending: boolean;
61
+ listeners: Set<() => void>;
62
+ observers: Set<QueryObserver>;
63
+ retention: number;
64
+ eviction: unknown;
65
+ }
66
+ export declare function toQueryError(value: unknown): Error;
67
+ /** One provider-owned cache. Requests are shared by structural primitive keys.
68
+ * Failed reads retain earlier data; replacement writes supersede older requests.
69
+ * Idle entries live for at least five minutes or their declared freshness period. */
70
+ export declare class FetchQueryCache {
71
+ private readonly entries;
72
+ private disposed;
73
+ private readonly clock;
74
+ private readonly scheduler;
75
+ private readonly environment;
76
+ private online;
77
+ private readonly onlineWaiters;
78
+ private releaseEnvironment;
79
+ constructor(_required?: Record<string, never>, { clock, scheduler, environment }?: FetchQueryCacheOptions);
80
+ entry({ key }: {
81
+ key: QueryKey;
82
+ }, { staleTime }?: {
83
+ staleTime?: number;
84
+ }): CacheEntry;
85
+ fresh({ entry, staleTime }: {
86
+ entry: CacheEntry;
87
+ staleTime: number;
88
+ }): boolean;
89
+ subscribe({ entry, listener }: {
90
+ entry: CacheEntry;
91
+ listener: () => void;
92
+ }): () => void;
93
+ observe({ entry, observer }: {
94
+ entry: CacheEntry;
95
+ observer: QueryObserver;
96
+ }): () => void;
97
+ /** Cache hits respect freshness; explicit refreshes restart background reads.
98
+ * Initial reads still deduplicate because no earlier successful data exists to refresh. */
99
+ load<T>({ entry, fetch, staleTime }: {
100
+ entry: CacheEntry;
101
+ fetch: () => Promise<T>;
102
+ staleTime: number;
103
+ }, { force }?: {
104
+ force?: boolean;
105
+ }): Promise<T>;
106
+ replace({ entry, data }: {
107
+ entry: CacheEntry;
108
+ data: unknown;
109
+ }): void;
110
+ invalidate({ prefix }: {
111
+ prefix: QueryKey;
112
+ }): void;
113
+ onFocus(): void;
114
+ /** Execute a read/write once connectivity returns; failures are not retried.
115
+ * @param required.run Operation to invoke online.
116
+ * @returns The operation's original result or rejection. */
117
+ runOnline<T>({ run }: {
118
+ run: () => Promise<T>;
119
+ }): Promise<T>;
120
+ /** Stop timers on provider disposal; reactivate for React's effect replay. */
121
+ activate(): void;
122
+ dispose(): void;
123
+ private onOnline;
124
+ private refresh;
125
+ private publish;
126
+ private cancelEviction;
127
+ private scheduleEviction;
128
+ }
129
+ //# sourceMappingURL=cache.d.ts.map
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Ten seconds covers reading, closing and reopening a record or bouncing between screens
3
+ * without paying for another identical request. Five seconds was too short for that read
4
+ * cycle; thirty seconds exposed more other-operator staleness than the conservative initial
5
+ * shared default should allow. There is no default polling/focus refresh, so this bounds
6
+ * the extra exposure on a quick revisit. Write invalidation bypasses freshness; reads that
7
+ * must be fresh on every mount can choose staleTime: 0 without lowering every query default.
8
+ */
9
+ export const DEFAULT_STALE_TIME = 10_000;
10
+ /** Minimum time an unobserved entry is kept; both adapters retain max(this, staleTime). */
11
+ export const IDLE_RETENTION = 5 * 60_000;
12
+ const browserScheduler = {
13
+ schedule: ({ callback, delayMs }) => setTimeout(callback, delayMs),
14
+ cancel: ({ handle }) => clearTimeout(handle),
15
+ };
16
+ // Normalize unknown promise rejections once so consumers receive Error without repeating
17
+ // the same narrowing boilerplate at every read and write call site.
18
+ export function toQueryError(value) {
19
+ return value instanceof Error ? value : new Error(typeof value === 'string' && value.trim() ? value : 'request failed');
20
+ }
21
+ /** One provider-owned cache. Requests are shared by structural primitive keys.
22
+ * Failed reads retain earlier data; replacement writes supersede older requests.
23
+ * Idle entries live for at least five minutes or their declared freshness period. */
24
+ export class FetchQueryCache {
25
+ entries = new Map();
26
+ disposed = false;
27
+ clock;
28
+ scheduler;
29
+ environment;
30
+ online;
31
+ onlineWaiters = new Set();
32
+ releaseEnvironment;
33
+ constructor(_required = {}, { clock = { nowMs: () => Date.now() }, scheduler = browserScheduler, environment } = {}) {
34
+ this.clock = clock;
35
+ this.scheduler = scheduler;
36
+ this.environment = environment;
37
+ this.online = environment?.isOnline() ?? true;
38
+ }
39
+ entry({ key }, { staleTime = DEFAULT_STALE_TIME } = {}) {
40
+ const hash = JSON.stringify(key);
41
+ let entry = this.entries.get(hash);
42
+ if (!entry) {
43
+ entry = {
44
+ key: [...key], state: { data: undefined, error: null, status: 'pending', isFetching: false },
45
+ updatedAt: 0, invalidated: false, replacement: 0, pending: undefined, pendingRead: undefined,
46
+ refreshAfterPending: false, listeners: new Set(), observers: new Set(),
47
+ retention: IDLE_RETENTION, eviction: undefined,
48
+ };
49
+ this.entries.set(hash, entry);
50
+ }
51
+ // An imperative load has no standing observer: idle eviction must not drop a value
52
+ // earlier than its declared freshness and unexpectedly force another network request.
53
+ entry.retention = Math.max(entry.retention, staleTime);
54
+ return entry;
55
+ }
56
+ fresh({ entry, staleTime }) {
57
+ return entry.state.data !== undefined && !entry.invalidated && this.clock.nowMs() - entry.updatedAt < staleTime;
58
+ }
59
+ subscribe({ entry, listener }) {
60
+ this.cancelEviction(entry);
61
+ entry.listeners.add(listener);
62
+ return () => { entry.listeners.delete(listener); this.scheduleEviction(entry); };
63
+ }
64
+ observe({ entry, observer }) {
65
+ entry.observers.add(observer);
66
+ return () => { entry.observers.delete(observer); this.scheduleEviction(entry); };
67
+ }
68
+ /** Cache hits respect freshness; explicit refreshes restart background reads.
69
+ * Initial reads still deduplicate because no earlier successful data exists to refresh. */
70
+ load({ entry, fetch, staleTime }, { force = false } = {}) {
71
+ this.cancelEviction(entry);
72
+ if (entry.pending && (!force || entry.state.data === undefined))
73
+ return entry.pending;
74
+ if (!force && this.fresh({ entry: entry, staleTime: staleTime })) {
75
+ this.scheduleEviction(entry);
76
+ return Promise.resolve(entry.state.data);
77
+ }
78
+ // Shared per key across loader instances: a replacement while this request is out
79
+ // must win over its older answer, not just protect the instance that issued the write.
80
+ const replacement = entry.replacement;
81
+ const run = () => {
82
+ // A queued offline refresh may have been superseded before reconnection.
83
+ if (entry.pending !== pending)
84
+ return (entry.pending ?? Promise.resolve(entry.state.data));
85
+ this.publish(entry, { ...entry.state, isFetching: true });
86
+ return fetch();
87
+ };
88
+ const pending = Promise.resolve().then(() => {
89
+ if (this.online)
90
+ return run();
91
+ this.publish(entry, { ...entry.state, isFetching: false });
92
+ return this.runOnline({ run });
93
+ }).then(value => {
94
+ const data = replacement === entry.replacement ? value : entry.state.data;
95
+ // Undefined is never successful query data, including an initial or background read.
96
+ if (data === undefined)
97
+ throw new Error(`${JSON.stringify(entry.key)} data is undefined`);
98
+ return data;
99
+ }).then(data => {
100
+ // Only the current request may publish or clear invalidation. An older transport
101
+ // need not support abort: its callers follow the replacement request instead.
102
+ if (entry.pending !== pending)
103
+ return (entry.pending ?? entry.state.data);
104
+ entry.updatedAt = this.clock.nowMs();
105
+ entry.invalidated = false;
106
+ this.publish(entry, { data, error: null, status: 'success', isFetching: false });
107
+ return data;
108
+ }, error => {
109
+ if (entry.pending !== pending)
110
+ return (entry.pending ?? entry.state.data);
111
+ // A replacement write is newer than this read's failure as well as its data.
112
+ // Keep the write's timestamp and freshness; only settle the obsolete fetch flag.
113
+ if (replacement !== entry.replacement) {
114
+ this.publish(entry, { ...entry.state, isFetching: false });
115
+ return entry.state.data;
116
+ }
117
+ entry.invalidated = true;
118
+ this.publish(entry, { ...entry.state, error: toQueryError(error), status: 'error', isFetching: false });
119
+ throw error;
120
+ }).finally(() => {
121
+ if (entry.pending !== pending)
122
+ return;
123
+ entry.pending = undefined;
124
+ entry.pendingRead = undefined;
125
+ if (entry.refreshAfterPending) {
126
+ entry.refreshAfterPending = false;
127
+ this.refresh(entry);
128
+ }
129
+ this.scheduleEviction(entry);
130
+ });
131
+ entry.pending = pending;
132
+ entry.pendingRead = { fetch, staleTime };
133
+ // Mark handled even when a UI caller deliberately discards the promise.
134
+ void pending.catch(() => { });
135
+ // Like TanStack's background refetches, retain the last error until success clears it
136
+ // or another failure replaces it. Extend that rule to retries without cached data:
137
+ // starting a request is not evidence of recovery; isFetching reports its progress.
138
+ const state = entry.state.data === undefined ? { ...entry.state, status: 'pending' } : entry.state;
139
+ this.publish(entry, { ...state, isFetching: this.online });
140
+ return pending;
141
+ }
142
+ replace({ entry, data }) {
143
+ entry.replacement += 1;
144
+ entry.updatedAt = this.clock.nowMs();
145
+ entry.invalidated = false;
146
+ this.publish(entry, { data, error: null, status: 'success', isFetching: entry.state.isFetching });
147
+ this.scheduleEviction(entry);
148
+ }
149
+ invalidate({ prefix }) {
150
+ for (const entry of this.entries.values()) {
151
+ if (!prefix.every((part, index) => entry.key[index] === part))
152
+ continue;
153
+ entry.invalidated = true;
154
+ // Revoke loader-only requests too: an active parent may read this child after
155
+ // the write. Its next load must start after invalidation, never join old bytes.
156
+ const pendingRead = entry.pendingRead;
157
+ entry.pending = undefined;
158
+ entry.pendingRead = undefined;
159
+ if (entry.observers.size)
160
+ this.refresh(entry);
161
+ // An awaiting loader still needs an answer even if no observer is mounted.
162
+ else if (pendingRead)
163
+ void this.load({ entry, ...pendingRead });
164
+ }
165
+ }
166
+ onFocus() {
167
+ for (const entry of this.entries.values()) {
168
+ const observer = [...entry.observers].find(value => value.focus && !this.fresh({ entry: entry, staleTime: value.staleTime }));
169
+ // Environmental refreshes share a request already underway, unlike an explicit gesture.
170
+ if (observer && !entry.pending)
171
+ void this.load({ entry: entry, fetch: observer.fetch, staleTime: observer.staleTime });
172
+ }
173
+ }
174
+ /** Execute a read/write once connectivity returns; failures are not retried.
175
+ * @param required.run Operation to invoke online.
176
+ * @returns The operation's original result or rejection. */
177
+ runOnline({ run }) {
178
+ if (this.online)
179
+ return Promise.resolve().then(() => {
180
+ // Connectivity may change between queuing the operation and its invocation.
181
+ if (!this.online)
182
+ return this.runOnline({ run });
183
+ return run();
184
+ });
185
+ return new Promise(resolve => { this.onlineWaiters.add(resolve); }).then(() => this.runOnline({ run }));
186
+ }
187
+ /** Stop timers on provider disposal; reactivate for React's effect replay. */
188
+ activate() {
189
+ this.disposed = false;
190
+ if (!this.environment || this.releaseEnvironment)
191
+ return;
192
+ const releaseOnline = this.environment.subscribeOnline({ listener: online => this.onOnline({ online }) });
193
+ const releaseFocus = this.environment.subscribeFocus({ listener: () => this.onFocus() });
194
+ this.releaseEnvironment = () => { releaseOnline(); releaseFocus(); };
195
+ this.onOnline({ online: this.environment.isOnline() });
196
+ }
197
+ dispose() {
198
+ this.disposed = true;
199
+ this.releaseEnvironment?.();
200
+ this.releaseEnvironment = undefined;
201
+ for (const entry of this.entries.values())
202
+ this.cancelEviction(entry);
203
+ }
204
+ onOnline({ online }) {
205
+ const reconnect = online && !this.online;
206
+ this.online = online;
207
+ if (!online)
208
+ return;
209
+ for (const resume of this.onlineWaiters)
210
+ resume();
211
+ this.onlineWaiters.clear();
212
+ if (!reconnect)
213
+ return;
214
+ for (const entry of this.entries.values()) {
215
+ const observer = [...entry.observers].find(value => !this.fresh({ entry, staleTime: value.staleTime }));
216
+ // A paused request already owns its resumption; restarting it here would double-fetch.
217
+ if (observer && !entry.pending)
218
+ void this.load({ entry, fetch: observer.fetch, staleTime: observer.staleTime });
219
+ }
220
+ }
221
+ refresh(entry) {
222
+ const observer = entry.observers.values().next().value;
223
+ if (observer)
224
+ void this.load({ entry: entry, fetch: observer.fetch, staleTime: observer.staleTime }, { force: true });
225
+ }
226
+ publish(entry, state) {
227
+ entry.state = state;
228
+ for (const listener of entry.listeners)
229
+ listener();
230
+ }
231
+ cancelEviction(entry) {
232
+ if (entry.eviction !== undefined)
233
+ this.scheduler.cancel({ handle: entry.eviction });
234
+ entry.eviction = undefined;
235
+ }
236
+ scheduleEviction(entry) {
237
+ this.cancelEviction(entry);
238
+ if (this.disposed || entry.listeners.size || entry.observers.size || entry.pending || !Number.isFinite(entry.retention))
239
+ return;
240
+ entry.eviction = this.scheduler.schedule({ callback: () => this.entries.delete(JSON.stringify(entry.key)), delayMs: entry.retention });
241
+ }
242
+ }
243
+ //# sourceMappingURL=cache.js.map
@@ -0,0 +1,5 @@
1
+ /** Framework-neutral query contracts and cache; hosts supply transport callbacks and environment ports. */
2
+ export { DEFAULT_STALE_TIME, IDLE_RETENTION, FetchQueryCache, toQueryError } from './cache.js';
3
+ export type { QueryCacheSchedulerPort, FetchQueryEnvironmentPort, FetchQueryCacheOptions, QueryState, QueryObserver, CacheEntry } from './cache.js';
4
+ export type { QueryKey, QueryStatus, QueryResult, FetchQueryOptions, MutationStatus, MutationResult, FetchMutationOptions, CachedLoaderOptions, CachedLoader, FetchQueryAdapter } from './types.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,3 @@
1
+ /** Framework-neutral query contracts and cache; hosts supply transport callbacks and environment ports. */
2
+ export { DEFAULT_STALE_TIME, IDLE_RETENTION, FetchQueryCache, toQueryError } from './cache.js';
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Keep the contract independent of any query library so an adapter swap does not require
3
+ * rewriting callers. Add narrow required capabilities here rather than exporting library
4
+ * result/client types that would bypass this boundary.
5
+ */
6
+ /**
7
+ * Keys contain primitive array parts so parameterised resources compose without fragile
8
+ * string formatting or object-identity mismatches. Invalidation matches by PREFIX: a list
9
+ * key also refreshes its derived child keys. Exact-key equality would compile while quietly
10
+ * breaking that guarantee; avoid a shared prefix for resources that merely sound related,
11
+ * or writes will fan out unnecessary refetches.
12
+ * Stay primitive-only: the built-in cache matches each part with === while TanStack deep-matches,
13
+ * so an object part would invalidate under one adapter and silently not under the other.
14
+ */
15
+ export type QueryKey = readonly (string | number)[];
16
+ export type QueryStatus = "loading" | "success" | "error";
17
+ export interface QueryResult<T> {
18
+ data: T | undefined;
19
+ error: Error | null;
20
+ status: QueryStatus;
21
+ isFetching: boolean;
22
+ refetch: () => void;
23
+ }
24
+ export interface FetchQueryOptions<T> {
25
+ key: QueryKey;
26
+ fetch: () => Promise<T>;
27
+ /**
28
+ * Gate on user intent or prerequisites to avoid fetching an undefined id. Disabling does
29
+ * not erase cached failures: without any successful data, report loading/error:null so
30
+ * a gesture-gated read cannot show an old failure before the user asks for it. With
31
+ * earlier data, report success but retain a later background error so known-stale data
32
+ * need not be presented as an unqualified success.
33
+ */
34
+ enabled?: boolean;
35
+ staleTime?: number;
36
+ refetchOnWindowFocus?: boolean;
37
+ }
38
+ export type MutationStatus = "idle" | "pending" | "success" | "error";
39
+ export interface MutationResult<TInput, TOutput> {
40
+ /**
41
+ * Awaiting the write receives its result or rejection; discarding the same promise and
42
+ * reading state is safe because it already has a rejection handler. Returning a bare
43
+ * rejecting promise would make only the awaiting form safe. Node unhandledRejection
44
+ * can verify this contract even where a DOM unhandledrejection event does not fire.
45
+ */
46
+ mutate: (required: {
47
+ input: TInput;
48
+ }) => Promise<TOutput>;
49
+ status: MutationStatus;
50
+ error: Error | null;
51
+ reset: () => void;
52
+ }
53
+ export interface FetchMutationOptions<TInput, TOutput> {
54
+ run: (required: {
55
+ input: TInput;
56
+ }) => Promise<TOutput>;
57
+ invalidates?: readonly QueryKey[];
58
+ }
59
+ /**
60
+ * Keep key and fetch references stable: they are memo dependencies of the loader handle.
61
+ * Failed fetches reject and are not cached, so the next load retries instead of replaying
62
+ * a failure as data. Retain an idle value at least as long as its declared freshness: an
63
+ * imperative loader has no persistent observer to keep it alive.
64
+ */
65
+ export interface CachedLoaderOptions<T> {
66
+ key: QueryKey;
67
+ fetch: () => Promise<T>;
68
+ staleTime?: number;
69
+ }
70
+ /**
71
+ * A plain promise loader serves components that own their loading UI rather than consuming
72
+ * a hook result. It shares the provider cache, including one in-flight request per key.
73
+ */
74
+ export interface CachedLoader<T> {
75
+ peek: () => T | undefined;
76
+ load: (required?: Pick<CachedLoaderOptions<T>, "key" | "fetch">) => Promise<T>;
77
+ replace: (required: {
78
+ value: T;
79
+ }) => void;
80
+ }
81
+ /**
82
+ * Keep the provider-facing hooks, loader and invalidator behind one binding so replacing
83
+ * the cache implementation does not leave different consumers on different adapters.
84
+ */
85
+ export interface FetchQueryAdapter {
86
+ useFetchQuery: <T>(required: Pick<FetchQueryOptions<T>, "key" | "fetch">, optional?: Omit<FetchQueryOptions<T>, "key" | "fetch">) => QueryResult<T>;
87
+ useFetchMutation: <TInput, TOutput>(required: Pick<FetchMutationOptions<TInput, TOutput>, "run">, optional?: Omit<FetchMutationOptions<TInput, TOutput>, "run">) => MutationResult<TInput, TOutput>;
88
+ useInvalidate: () => (required: {
89
+ key: QueryKey;
90
+ }) => void;
91
+ useCachedLoader: <T>(required: Pick<CachedLoaderOptions<T>, "key" | "fetch">, optional?: Omit<CachedLoaderOptions<T>, "key" | "fetch">) => CachedLoader<T>;
92
+ }
93
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
package/dist/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export * from './origin-validation.js';
12
12
  export * from './principal.js';
13
13
  export * from './surface-exchanges.js';
14
14
  export type { AuthorizationDecision, RunRef, SurfaceEmission, SurfaceEmitter, ToolAuthorizationContext, ToolDescriptor, ToolExecutionContext, ToolExecutionOptions, ToolHandler, ToolPolicy, ToolRegistration, ToolRegistry, } from './tool-registry.js';
15
- export { createToolRegistry, isReadOnlyTool, ToolInputError } from './tool-registry.js';
15
+ export { createToolRegistrationScope, toolRegistryRevision, createToolRegistry, isReadOnlyTool, ToolInputError } from './tool-registry.js';
16
16
  export * from './tool-tokens.js';
17
17
  export * from './gated-mutations.js';
18
18
  export * from './model-facing-tool-errors.js';
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ export * from './api-token-auth.js';
9
9
  export * from './origin-validation.js';
10
10
  export * from './principal.js';
11
11
  export * from './surface-exchanges.js';
12
- export { createToolRegistry, isReadOnlyTool, ToolInputError } from './tool-registry.js';
12
+ export { createToolRegistrationScope, toolRegistryRevision, createToolRegistry, isReadOnlyTool, ToolInputError } from './tool-registry.js';
13
13
  export * from './tool-tokens.js';
14
14
  export * from './gated-mutations.js';
15
15
  export * from './model-facing-tool-errors.js';
@@ -0,0 +1,24 @@
1
+ /** @file Pure object-shape predicates shared by tool input readers and render-time decoders. */
2
+ /** Accept any non-null object, including arrays; never sanitizes or reads properties.
3
+ * @complexity O(1) time and space.
4
+ * @example isRecord({ value: [] }); // true
5
+ */
6
+ export declare function isRecord(required: {
7
+ value: unknown;
8
+ }, _optional?: Record<string, never>): required is {
9
+ value: Record<string, unknown>;
10
+ };
11
+ /**
12
+ * Accepts non-null, non-array objects, including class instances and null-prototype records.
13
+ * This is a shape check, not plain-object validation or sanitization. Keep `isRecord`'s
14
+ * broader contract: existing tool readers deliberately continue to accept arrays.
15
+ * @param required.value - The untrusted value whose object shape is checked.
16
+ * @complexity O(1) time and space.
17
+ * @example isNonArrayRecord({ value: [] }); // false
18
+ */
19
+ export declare function isNonArrayRecord(required: {
20
+ value: unknown;
21
+ }, _optional?: Record<string, never>): required is {
22
+ value: Record<string, unknown>;
23
+ };
24
+ //# sourceMappingURL=records.d.ts.map
@@ -0,0 +1,21 @@
1
+ /** @file Pure object-shape predicates shared by tool input readers and render-time decoders. */
2
+ /** Accept any non-null object, including arrays; never sanitizes or reads properties.
3
+ * @complexity O(1) time and space.
4
+ * @example isRecord({ value: [] }); // true
5
+ */
6
+ export function isRecord(required, _optional = {}) {
7
+ const { value } = required;
8
+ return typeof value === "object" && value !== null;
9
+ }
10
+ /**
11
+ * Accepts non-null, non-array objects, including class instances and null-prototype records.
12
+ * This is a shape check, not plain-object validation or sanitization. Keep `isRecord`'s
13
+ * broader contract: existing tool readers deliberately continue to accept arrays.
14
+ * @param required.value - The untrusted value whose object shape is checked.
15
+ * @complexity O(1) time and space.
16
+ * @example isNonArrayRecord({ value: [] }); // false
17
+ */
18
+ export function isNonArrayRecord(required, _optional = {}) {
19
+ return isRecord(required) && !Array.isArray(required.value);
20
+ }
21
+ //# sourceMappingURL=records.js.map
@@ -1,3 +1,4 @@
1
+ export { isRecord, isNonArrayRecord } from "./records.js";
1
2
  /**
2
3
  * @file The cross-domain half of the agent-tool wiring split: everything a domain's own
3
4
  * `tool-registrations.ts` needs that is NOT specific to that domain.
@@ -134,22 +135,6 @@ export declare function indexCatalogById<T extends {
134
135
  }>(required: {
135
136
  catalog: readonly T[];
136
137
  }, _optional?: Record<string, never>): ReadonlyMap<string, T>;
137
- export declare function isRecord(required: {
138
- value: unknown;
139
- }, _optional?: Record<string, never>): required is {
140
- value: Record<string, unknown>;
141
- };
142
- /**
143
- * Accepts non-null, non-array objects, including class instances and null-prototype records.
144
- * This is a shape check, not plain-object validation or sanitization. Keep `isRecord`'s
145
- * broader contract: existing tool readers deliberately continue to accept arrays.
146
- * @param required.value - The untrusted value whose object shape is checked.
147
- */
148
- export declare function isNonArrayRecord(required: {
149
- value: unknown;
150
- }, _optional?: Record<string, never>): required is {
151
- value: Record<string, unknown>;
152
- };
153
138
  /** Narrows `ctx.input` to a record, refusing anything else. The first line of most handlers. */
154
139
  export declare function requireInputRecord(required: {
155
140
  input: unknown;
@@ -1,3 +1,5 @@
1
+ import { isRecord } from "./records.js";
2
+ export { isRecord, isNonArrayRecord } from "./records.js";
1
3
  /**
2
4
  * @file The cross-domain half of the agent-tool wiring split: everything a domain's own
3
5
  * `tool-registrations.ts` needs that is NOT specific to that domain.
@@ -100,19 +102,6 @@ export function indexCatalogById(required, _optional = {}) {
100
102
  // ---------------------------------------------------------------------------
101
103
  // Input readers — the vocabulary every handler uses to read `ctx.input`.
102
104
  // ---------------------------------------------------------------------------
103
- export function isRecord(required, _optional = {}) {
104
- const { value } = required;
105
- return typeof value === "object" && value !== null;
106
- }
107
- /**
108
- * Accepts non-null, non-array objects, including class instances and null-prototype records.
109
- * This is a shape check, not plain-object validation or sanitization. Keep `isRecord`'s
110
- * broader contract: existing tool readers deliberately continue to accept arrays.
111
- * @param required.value - The untrusted value whose object shape is checked.
112
- */
113
- export function isNonArrayRecord(required, _optional = {}) {
114
- return isRecord(required) && !Array.isArray(required.value);
115
- }
116
105
  /** Narrows `ctx.input` to a record, refusing anything else. The first line of most handlers. */
117
106
  export function requireInputRecord(required, _optional = {}) {
118
107
  const { input } = required;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @file The one HTML/XML text escaper. A leaf module with NO imports, on purpose: the old copies
3
+ * lived inside `render.ts`, `form-render.ts` and `static-render.ts` and were duplicated rather than
4
+ * shared because importing them would have closed a runtime import cycle. A module that imports
5
+ * nothing can be imported from anywhere.
6
+ *
7
+ * `&` is replaced first: it is the escape character for every entity after it, so replacing any
8
+ * other character first would double-escape the `&` that replacement introduced. The apostrophe
9
+ * becomes the numeric `&#39;` in HTML, because `&apos;` is undefined in HTML4/XHTML1.
10
+ */
11
+ /** Escapes `& < > " '` for HTML text or a quoted attribute value. @complexity O(text length). */
12
+ export declare function escapeHtml({ text }: {
13
+ text: string;
14
+ }, _optional?: {}): string;
15
+ /** {@link escapeHtml} with the XML named entity `&apos;` for the apostrophe (sitemap and feed XML).
16
+ * @complexity O(text length). */
17
+ export declare function escapeXml({ text }: {
18
+ text: string;
19
+ }, _optional?: {}): string;
20
+ //# sourceMappingURL=escape.d.ts.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @file The one HTML/XML text escaper. A leaf module with NO imports, on purpose: the old copies
3
+ * lived inside `render.ts`, `form-render.ts` and `static-render.ts` and were duplicated rather than
4
+ * shared because importing them would have closed a runtime import cycle. A module that imports
5
+ * nothing can be imported from anywhere.
6
+ *
7
+ * `&` is replaced first: it is the escape character for every entity after it, so replacing any
8
+ * other character first would double-escape the `&` that replacement introduced. The apostrophe
9
+ * becomes the numeric `&#39;` in HTML, because `&apos;` is undefined in HTML4/XHTML1.
10
+ */
11
+ const HTML_ENTITIES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
12
+ const XML_ENTITIES = { ...HTML_ENTITIES, "'": "&apos;" };
13
+ const SPECIAL = /[&<>"']/g;
14
+ /** Escapes `& < > " '` for HTML text or a quoted attribute value. @complexity O(text length). */
15
+ export function escapeHtml({ text }, _optional = {}) {
16
+ return text.replace(SPECIAL, (char) => HTML_ENTITIES[char] ?? char);
17
+ }
18
+ /** {@link escapeHtml} with the XML named entity `&apos;` for the apostrophe (sitemap and feed XML).
19
+ * @complexity O(text length). */
20
+ export function escapeXml({ text }, _optional = {}) {
21
+ return text.replace(SPECIAL, (char) => XML_ENTITIES[char] ?? char);
22
+ }
23
+ //# sourceMappingURL=escape.js.map
@@ -1,3 +1,4 @@
1
1
  export { sanitizeUntrustedText, stripControlSequences } from './sanitize-untrusted-text.js';
2
2
  export type { SanitizeTextOptions } from './sanitize-untrusted-text.js';
3
+ export { escapeHtml, escapeXml } from "./escape.js";
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1,2 +1,3 @@
1
1
  export { sanitizeUntrustedText, stripControlSequences } from './sanitize-untrusted-text.js';
2
+ export { escapeHtml, escapeXml } from "./escape.js";
2
3
  //# sourceMappingURL=index.js.map
@@ -4,7 +4,7 @@
4
4
  * `{descriptor, handler, policy}` triples; the registry's *public* surface
5
5
  * (this file's `ToolRegistry` interface, and everything re-exported from
6
6
  * this package's root `index.ts`) exposes only `ToolDescriptor`s —
7
- * `register`/`has`/`list`. It never exposes a way to retrieve a `handler`
7
+ * `register`/`has`/`list`. Revocable host scopes add replacement without retrieval. It never exposes a way to retrieve a `handler`
8
8
  * or `policy` back out.
9
9
  *
10
10
  * That's the load-bearing invariant this task is named after: "handlers
@@ -199,6 +199,21 @@ export interface ToolRegistry {
199
199
  /** Descriptors only, in registration order. Never the underlying handlers/policies. */
200
200
  list(_required: Record<string, never>): readonly ToolDescriptor[];
201
201
  }
202
+ /** A host can invalidate its disposable discovery snapshot without exposing handlers. */
203
+ export declare function toolRegistryRevision(required: {
204
+ registry: object;
205
+ }, _optional?: Record<string, never>): number | undefined;
206
+ /** Revocable registration scope on the SAME registry the executor owns. Collision validation
207
+ * completes before mutation, so a failed replacement cannot lose the working generation.
208
+ * Only this closure may remove its own registrations; native and other scopes remain protected.
209
+ * Already-authorized closures are revoked too: confirmation can await a human across disable. */
210
+ export declare function createToolRegistrationScope(required: {
211
+ registry: ToolRegistry;
212
+ }, _optional?: Record<string, never>): {
213
+ replace({ registrations: next }: {
214
+ registrations: readonly ToolRegistration[];
215
+ }, _optional?: Record<string, never>): void;
216
+ };
202
217
  /**
203
218
  * Creates an empty, append-only `ToolRegistry`.
204
219
  *
@@ -33,6 +33,48 @@ export class ToolInputError extends Error {
33
33
  }
34
34
  }
35
35
  const registrationsByRegistry = new WeakMap();
36
+ // Static register() remains append-only; scoped host contributions below explicitly own removal.
37
+ const registryRevisions = new WeakMap();
38
+ /** A host can invalidate its disposable discovery snapshot without exposing handlers. */
39
+ export function toolRegistryRevision(required, _optional = {}) {
40
+ return registryRevisions.get(required.registry);
41
+ }
42
+ /** Revocable registration scope on the SAME registry the executor owns. Collision validation
43
+ * completes before mutation, so a failed replacement cannot lose the working generation.
44
+ * Only this closure may remove its own registrations; native and other scopes remain protected.
45
+ * Already-authorized closures are revoked too: confirmation can await a human across disable. */
46
+ export function createToolRegistrationScope(required, _optional = {}) {
47
+ const registrations = registrationsByRegistry.get(required.registry);
48
+ if (!registrations)
49
+ throw new Error('registration scopes require a core ToolRegistry');
50
+ let owned = new Set();
51
+ let generation = 0;
52
+ return {
53
+ replace({ registrations: next }, _optional = {}) {
54
+ const ids = new Set();
55
+ for (const registration of next) {
56
+ const id = registration.descriptor.id;
57
+ if (ids.has(id) || (registrations.has(id) && !owned.has(id)))
58
+ throw new Error(`ToolRegistry: tool "${id}" is already registered`);
59
+ ids.add(id);
60
+ }
61
+ generation++;
62
+ const current = generation;
63
+ for (const id of owned)
64
+ registrations.delete(id);
65
+ for (const registration of next)
66
+ registrations.set(registration.descriptor.id, { ...registration,
67
+ handler: (input, options) => {
68
+ if (generation !== current)
69
+ throw new Error('tool registration was revoked');
70
+ return registration.handler(input, options);
71
+ },
72
+ });
73
+ owned = ids;
74
+ registryRevisions.set(required.registry, (registryRevisions.get(required.registry) ?? 0) + 1);
75
+ },
76
+ };
77
+ }
36
78
  /**
37
79
  * Creates an empty, append-only `ToolRegistry`.
38
80
  *
@@ -51,6 +93,7 @@ export function createToolRegistry(_required) {
51
93
  throw new Error(`ToolRegistry: tool "${registration.descriptor.id}" is already registered`);
52
94
  }
53
95
  registrations.set(registration.descriptor.id, registration);
96
+ registryRevisions.set(registry, (registryRevisions.get(registry) ?? 0) + 1);
54
97
  },
55
98
  has({ toolId }) {
56
99
  return registrations.has(toolId);
@@ -60,6 +103,7 @@ export function createToolRegistry(_required) {
60
103
  },
61
104
  };
62
105
  registrationsByRegistry.set(registry, registrations);
106
+ registryRevisions.set(registry, 0);
63
107
  return registry;
64
108
  }
65
109
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/core",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "The Jini kernel: typed DI tokens, pack composition and lifecycle, the tool registry, redaction, and origin/API-token auth primitives. Zero dependencies.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -19,24 +19,32 @@
19
19
  "./model-facing-tool-errors": "universal",
20
20
  "./contribution-registry": "universal",
21
21
  "./naming": "universal",
22
+ "./records": "universal",
22
23
  "./primitives": "universal",
24
+ "./fetch": "universal",
23
25
  "./text": "universal",
24
26
  "./settings": "universal",
25
- "./settings/express": "node"
27
+ "./settings/express": "node",
28
+ "./plugin-directory": "universal"
26
29
  },
27
30
  "isolation": {
28
31
  "entries": {
29
32
  "./primitives": "core",
33
+ "./fetch": "fetch",
30
34
  "./settings": "settings",
31
35
  "./settings/express": "settings"
32
36
  },
33
37
  "domains": {
38
+ "fetch": [
39
+ "src/fetch"
40
+ ],
34
41
  "settings": [
35
42
  "src/settings"
36
43
  ]
37
44
  },
38
45
  "shared": [
39
- "src/tool-permissions.ts"
46
+ "src/tool-permissions.ts",
47
+ "src/records.ts"
40
48
  ],
41
49
  "forbidden": {
42
50
  "universal": [
@@ -95,11 +103,21 @@
95
103
  "import": "./dist/naming.js",
96
104
  "default": "./dist/naming.js"
97
105
  },
106
+ "./records": {
107
+ "types": "./dist/records.d.ts",
108
+ "import": "./dist/records.js",
109
+ "default": "./dist/records.js"
110
+ },
98
111
  "./primitives": {
99
112
  "types": "./dist/primitives/index.d.ts",
100
113
  "import": "./dist/primitives/index.js",
101
114
  "default": "./dist/primitives/index.js"
102
115
  },
116
+ "./fetch": {
117
+ "types": "./dist/fetch/index.d.ts",
118
+ "import": "./dist/fetch/index.js",
119
+ "default": "./dist/fetch/index.js"
120
+ },
103
121
  "./text": {
104
122
  "types": "./dist/text/index.d.ts",
105
123
  "import": "./dist/text/index.js",
@@ -114,6 +132,11 @@
114
132
  "types": "./dist/settings/express/index.d.ts",
115
133
  "import": "./dist/settings/express/index.js",
116
134
  "default": "./dist/settings/express/index.js"
135
+ },
136
+ "./plugin-directory": {
137
+ "types": "./plugin-directory.d.ts",
138
+ "import": "./plugin-directory.js",
139
+ "default": "./plugin-directory.js"
117
140
  }
118
141
  },
119
142
  "files": [
@@ -127,7 +150,9 @@
127
150
  "!dist/**/*.test.d.ts.map",
128
151
  "!dist/**/__tests__/**",
129
152
  "!dist/**/*.typecheck.*",
130
- "!dist/**/*.map"
153
+ "!dist/**/*.map",
154
+ "plugin-directory.js",
155
+ "plugin-directory.d.ts"
131
156
  ],
132
157
  "publishConfig": {
133
158
  "access": "public",
@@ -0,0 +1,32 @@
1
+ /** Identical identity metadata for portable agent packages and host site plugins. */
2
+ export interface PluginIdentityExtension {
3
+ readonly id?: string;
4
+ readonly provides?: readonly string[];
5
+ readonly [key: string]: unknown;
6
+ }
7
+ export interface PluginIdentity {
8
+ readonly id: string;
9
+ readonly keywords: readonly string[];
10
+ readonly provides: readonly string[];
11
+ readonly warnings: readonly string[];
12
+ }
13
+ export interface PluginProvider {
14
+ readonly id: string;
15
+ readonly version: string | null;
16
+ readonly enabled: boolean;
17
+ readonly family: string;
18
+ /** Stable storage/activation key; publisher identity never becomes a filesystem path. */
19
+ readonly localId: string;
20
+ readonly keywords: readonly string[];
21
+ readonly provides: readonly string[];
22
+ }
23
+ export interface PluginLookupPort {
24
+ getPlugin(required: { readonly id: string; readonly family?: string }, optional?: Record<string, never>): Promise<PluginProvider | null>;
25
+ findProviders(required: { readonly capability: string }, optional?: { readonly enabledOnly?: boolean }): Promise<readonly PluginProvider[]>;
26
+ }
27
+ export type PluginIdentityCandidate = Pick<PluginProvider, 'id' | 'family' | 'localId'>;
28
+ export function assertPluginIdentityAvailable(required: { readonly candidate: PluginIdentityCandidate; readonly installed: readonly PluginProvider[] }, optional?: Record<string, never>): void;
29
+ export function createPluginLookupFacade(required: { readonly lookup: PluginLookupPort }, optional?: Record<string, never>): PluginLookupPort;
30
+ export function validatePluginIdentity(required: { readonly manifest: unknown; readonly namespace: string }, optional?: Record<string, never>): { readonly errors: readonly string[] };
31
+ export function readPluginIdentity(required: { readonly manifest: Readonly<Record<string, unknown>>; readonly namespace: string; readonly name?: string }, optional?: Record<string, never>): PluginIdentity;
32
+ export function createPluginLookup(required: { readonly listPlugins: (required: Record<string, never>, optional?: Record<string, never>) => Promise<readonly PluginProvider[]> }, optional?: { readonly warn?: (message: string) => void }): PluginLookupPort;
@@ -0,0 +1,140 @@
1
+ /** Shared publisher identity and live plugin lookup. Source-backed, like persistent-state:
2
+ * hosts can adopt the additive entry without replacing a running server's compiled packages. */
3
+ const SEGMENT = '[a-z0-9]+(?:[.-][a-z0-9]+)*';
4
+ const ID_PATTERN = new RegExp(`^${SEGMENT}/${SEGMENT}$`);
5
+ // Legacy site ids used a looser grammar than portable Agent Plugin names. Preserve those keys
6
+ // in unknown/<name>; only explicitly declared publisher ids use the new strict grammar.
7
+ const LEGACY_NAME_PATTERN = /^[a-z0-9.-]+$/;
8
+ const CAPABILITY_PATTERN = new RegExp(`^${SEGMENT}(?::${SEGMENT})?$`);
9
+ const object = value => typeof value === 'object' && value !== null && !Array.isArray(value);
10
+
11
+ /** Collects identity errors without mutating the portable manifest. Absent metadata is legacy.
12
+ * @param {{manifest: unknown, namespace: string}} required
13
+ * @param {Record<string, never>} [optional]
14
+ * @returns {{errors: string[]}}
15
+ * @complexity O(k + p) in keyword and provides entries; no I/O.
16
+ */
17
+ export function validatePluginIdentity({ manifest, namespace }, optional = {}) {
18
+ const errors = [];
19
+ if (!object(manifest)) return { errors: ['Plugin manifest must be an object.'] };
20
+ if (manifest.extensions !== undefined && !object(manifest.extensions)) return { errors: [...errors, 'Plugin extensions must be an object.'] };
21
+ const extension = manifest.extensions?.[namespace];
22
+ if (extension === undefined) return { errors };
23
+ if (!object(extension)) return { errors: [...errors, 'Plugin identity extension must be an object.'] };
24
+ if (extension.id !== undefined && (typeof extension.id !== 'string' || extension.id.length > 129 || !ID_PATTERN.test(extension.id))) {
25
+ errors.push('Plugin id must have the form publisher/name (lowercase letters, digits, dots and hyphens).');
26
+ } else if (typeof extension.id === 'string' && extension.id.startsWith('unknown/')) {
27
+ // Only the host may infer a legacy identity; a publisher cannot claim its upgrade exemption.
28
+ errors.push("Plugin publisher 'unknown' is reserved for legacy identities.");
29
+ }
30
+ if (extension.provides !== undefined && (!Array.isArray(extension.provides) || !extension.provides.every(p => typeof p === 'string' && p.length <= 129 && CAPABILITY_PATTERN.test(p)))) {
31
+ errors.push('Plugin provides must be an array of capability names.');
32
+ }
33
+ return { errors };
34
+ }
35
+
36
+ /** Resolves one identity; warns on missing ids and refuses malformed declarations.
37
+ * @param {{manifest: Record<string, unknown>, namespace: string, name?: string}} required
38
+ * @param {Record<string, never>} [optional]
39
+ * @returns {{id: string, keywords: string[], provides: string[], warnings: string[]}}
40
+ * @throws {TypeError} Malformed identity metadata or missing legacy name.
41
+ * @example readPluginIdentity({ manifest: { name: 'example' }, namespace: 'host' })
42
+ * @complexity O(k + p), copying arrays so a caller cannot mutate manifest metadata.
43
+ */
44
+ export function readPluginIdentity({ manifest, namespace, name }, optional = {}) {
45
+ const { errors } = validatePluginIdentity({ manifest, namespace });
46
+ if (errors.length) throw new TypeError(errors.join(' '));
47
+ const extension = manifest.extensions?.[namespace];
48
+ const localName = name ?? manifest.name;
49
+ if (extension?.id === undefined && (typeof localName !== 'string' || !LEGACY_NAME_PATTERN.test(localName))) {
50
+ throw new TypeError('A valid plugin name is required for a legacy identity.');
51
+ }
52
+ const id = extension?.id ?? `unknown/${localName}`;
53
+ // Keywords are advisory portable metadata: older manifests accepted mixed or non-array values.
54
+ // The portable parser's coercion (non-string entries dropped, non-arrays ignored) lives here
55
+ // so validation and lookup normalize advisory metadata through the same owner.
56
+ const keywords = Array.isArray(manifest.keywords) ? manifest.keywords.filter(k => typeof k === 'string' && k.trim().length > 0) : [];
57
+ const warnings = extension?.id === undefined ? [`Plugin '${localName}' has no publisher-qualified id; using '${id}'.`] : [];
58
+ if (manifest.keywords !== undefined && (!Array.isArray(manifest.keywords) || keywords.length !== manifest.keywords.length)) {
59
+ warnings.push('Plugin keywords must be an array of nonempty strings; invalid keywords were ignored.');
60
+ }
61
+ return { id, keywords, provides: [...(extension?.provides ?? [])], warnings };
62
+ }
63
+
64
+ /** Rejects id reuse and publisher changes at an existing storage slot. Legacy metadata may
65
+ * acquire its first publisher id during an upgrade; a declared publisher can never be replaced.
66
+ * @param {{candidate: {id: string, family: string, localId: string}, installed: import('./plugin-directory.js').PluginProvider[]}} required
67
+ * @param {Record<string, never>} [optional]
68
+ * @returns {void}
69
+ * @throws {Error} Conflicting publisher identity.
70
+ * @complexity O(n) for n installed providers.
71
+ */
72
+ export function assertPluginIdentityAvailable({ candidate, installed }, optional = {}) {
73
+ for (const plugin of installed) {
74
+ const sameSlot = plugin.family === candidate.family && plugin.localId === candidate.localId;
75
+ if (sameSlot) {
76
+ if (plugin.id !== candidate.id && plugin.id !== `unknown/${candidate.localId}`) {
77
+ throw new Error(`Plugin '${candidate.localId}' belongs to a different publisher identity.`);
78
+ }
79
+ } else if (plugin.id === candidate.id && !(plugin.family !== candidate.family && candidate.id === `unknown/${candidate.localId}` && plugin.id === `unknown/${plugin.localId}`)) {
80
+ throw new Error(`Duplicate plugin id '${candidate.id}'.`);
81
+ }
82
+ }
83
+ }
84
+
85
+ /** Restricts an SDK handle at runtime, not just through a TypeScript interface. A host may carry
86
+ * lifecycle mutation ports, but a plugin's discovery surface exposes only these metadata reads.
87
+ * @param {{lookup: import('./plugin-directory.js').PluginLookupPort}} required
88
+ * @param {Record<string, never>} [optional]
89
+ * @returns {import('./plugin-directory.js').PluginLookupPort}
90
+ * @complexity O(1); delegates live reads without copying host methods.
91
+ */
92
+ export function createPluginLookupFacade({ lookup }, optional = {}) {
93
+ return {
94
+ getPlugin: (required, options = {}) => lookup.getPlugin(required, options),
95
+ findProviders: (required, options = {}) => lookup.findProviders(required, options),
96
+ };
97
+ }
98
+
99
+ /** Live, metadata-only directory over injected workspace-scoped sources. No plugin imports.
100
+ * Later duplicate providers are omitted with a warning, preserving the source's incumbent order.
101
+ * Admission checks the unfiltered installs separately, including disabled/cross-family collisions.
102
+ * Legacy fallback ids are family-scoped; an unscoped ambiguous get returns null.
103
+ * @param {{listPlugins: (required: {}, optional?: {}) => Promise<import('./plugin-directory.js').PluginProvider[]>}} required
104
+ * @param {{warn?: (message: string) => void}} [optional]
105
+ * @returns {import('./plugin-directory.js').PluginLookupPort}
106
+ * @example createPluginLookup({ listPlugins: async () => [] })
107
+ * @complexity O(n log n) per lookup, n installed plugins. Fresh reads preserve revocation.
108
+ */
109
+ export function createPluginLookup({ listPlugins }, optional = {}) {
110
+ const warn = optional.warn ?? console.warn;
111
+ async function snapshot() {
112
+ const plugins = await listPlugins({}, {});
113
+ const seen = new Set();
114
+ const unique = [];
115
+ for (const plugin of plugins) {
116
+ const key = plugin.id === `unknown/${plugin.localId}` ? `${plugin.family}:${plugin.id}` : plugin.id;
117
+ if (seen.has(key)) {
118
+ warn(`Duplicate plugin id '${plugin.id}'; later provider omitted from metadata lookup.`);
119
+ continue;
120
+ }
121
+ seen.add(key);
122
+ unique.push(plugin);
123
+ }
124
+ // Project metadata explicitly: host paths, services and additional source properties never
125
+ // leave this boundary, and a consumer cannot mutate the source's discovery arrays.
126
+ return unique.map(plugin => ({
127
+ id: plugin.id, version: plugin.version, enabled: plugin.enabled, family: plugin.family,
128
+ localId: plugin.localId, keywords: [...plugin.keywords], provides: [...plugin.provides],
129
+ })).sort((a, b) => a.id.localeCompare(b.id));
130
+ }
131
+ return {
132
+ async getPlugin({ id, family }, options = {}) {
133
+ const matches = (await snapshot()).filter(plugin => plugin.id === id && (family === undefined || plugin.family === family));
134
+ return matches.length === 1 ? matches[0] : null;
135
+ },
136
+ async findProviders({ capability }, { enabledOnly = false } = {}) {
137
+ return (await snapshot()).filter(plugin => (!enabledOnly || plugin.enabled) && plugin.provides.includes(capability));
138
+ },
139
+ };
140
+ }