@zhin.js/plugin-runtime 1.1.23 → 1.2.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
@@ -43,4 +43,4 @@ prepare shadow state -> validate -> handoff -> atomic commit
43
43
 
44
44
  验证:`pnpm --filter @zhin.js/plugin-runtime test && pnpm --filter @zhin.js/plugin-runtime build`。
45
45
 
46
- 详细契约见 [Kernel 与 Generation](../../../docs/architecture/target-implementation/kernel-and-generation.md)。
46
+ 详细契约见 [Generation 生命周期](../../../docs/concepts/generation-lifecycle.md)。
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Outbound template context — conversation + incoming message metadata
3
- * available as top-level variables inside `${expr}` template expressions.
3
+ * addressable by `${path}` placeholders in outbound text.
4
4
  *
5
- * Templates can reference e.g. `${sender.name}`, `${adapter}`,
6
- * `${content}`, `${messageId}`, `${timestamp}`.
5
+ * A placeholder is a plain property path over these fields, e.g.
6
+ * `${sender.name}`, `${adapter}`, `${content}`, `${segments[0].type}`.
7
+ * Nothing is evaluated: arithmetic, calls, unknown names and undefined
8
+ * values stay in the text exactly as written.
7
9
  */
8
10
  export interface TemplateContext {
9
11
  /** Adapter feature name (e.g. 'qq', 'sandbox'). */
@@ -37,8 +39,9 @@ export interface TemplateContext {
37
39
  readonly mentioned?: boolean;
38
40
  }
39
41
  /**
40
- * Thin Host Resource for Plugin Runtime outbound template compilation.
41
- * Evaluates `${expr}` template expressions in a sandboxed VM context
42
+ * Thin Host Resource for Plugin Runtime outbound template substitution.
43
+ * Replaces `${path}` placeholders by whitelist path lookup over the
44
+ * `TemplateContext`; it never evaluates expressions.
42
45
  * The Host keeps render authority inside the active Runtime operation.
43
46
  *
44
47
  * Implemented by the CLI Host; absent when not wired — consumers must
package/lib/inbox.d.ts CHANGED
@@ -2,10 +2,9 @@ import type { DatabaseHost } from './database-host.js';
2
2
  /**
3
3
  * 统一收件箱(console endpoint-detail 消息历史/请求/通知视图)表定义与写入辅助。
4
4
  *
5
- * 列结构对齐 packages/im/zhin/src/setup/register-inbox.ts(legacy 读路径 SSOT),
6
- * request/notice 额外补 consumed/consumed_at 列(对齐 legacy host-api
7
- * endpoint-persistence 的 console_bot_requests/notices consumed 标记方式),
8
- * 供 console-rpc-extended 的 request.consumed / notice.consumed 写路径使用。
5
+ * 本文件是这三张表列结构的 SSOT。request/notice 带 consumed/consumed_at 列,
6
+ * 供 packages/host/http/src/console-rpc-extended/inbox-rpc.ts 的
7
+ * request.consumed / notice.consumed 写路径使用。
9
8
  */
10
9
  export declare const INBOX_TABLE_MESSAGE = "unified_inbox_message";
11
10
  export declare const INBOX_TABLE_REQUEST = "unified_inbox_request";
package/lib/inbox.js CHANGED
@@ -2,10 +2,9 @@ import { createToken } from './token.js';
2
2
  /**
3
3
  * 统一收件箱(console endpoint-detail 消息历史/请求/通知视图)表定义与写入辅助。
4
4
  *
5
- * 列结构对齐 packages/im/zhin/src/setup/register-inbox.ts(legacy 读路径 SSOT),
6
- * request/notice 额外补 consumed/consumed_at 列(对齐 legacy host-api
7
- * endpoint-persistence 的 console_bot_requests/notices consumed 标记方式),
8
- * 供 console-rpc-extended 的 request.consumed / notice.consumed 写路径使用。
5
+ * 本文件是这三张表列结构的 SSOT。request/notice 带 consumed/consumed_at 列,
6
+ * 供 packages/host/http/src/console-rpc-extended/inbox-rpc.ts 的
7
+ * request.consumed / notice.consumed 写路径使用。
9
8
  */
10
9
  export const INBOX_TABLE_MESSAGE = 'unified_inbox_message';
11
10
  export const INBOX_TABLE_REQUEST = 'unified_inbox_request';
package/lib/index.d.ts CHANGED
@@ -51,3 +51,5 @@ export * from './relational-target-migration.js';
51
51
  export * from './environment-expansion.js';
52
52
  export * from './host-installer.js';
53
53
  export * from './host-credential-storage.js';
54
+ /** @public ADR 0050 layered feature configuration: resolution keys, resolver and dispatch gate. */
55
+ export * from './layered-config.js';
package/lib/index.js CHANGED
@@ -51,3 +51,5 @@ export * from './relational-target-migration.js';
51
51
  export * from './environment-expansion.js';
52
52
  export * from './host-installer.js';
53
53
  export * from './host-credential-storage.js';
54
+ /** @public ADR 0050 layered feature configuration: resolution keys, resolver and dispatch gate. */
55
+ export * from './layered-config.js';
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Layered feature configuration (ADR 0050 / 0055): global < adapter < endpoint < conversation.
3
+ *
4
+ * Pure resolution shared by the Runtime (`configFor` / `isEnabledFor`), IM dispatch
5
+ * gating and Agent tool disclosure. Schema-level checks live in `@zhin.js/runtime`.
6
+ */
7
+ import { type PrimaryConfig } from './host-installer.js';
8
+ import { type PluginId } from './identity.js';
9
+ import type { RuntimeConfigDocument } from './root-config-document.js';
10
+ import type { RuntimeSnapshot } from './snapshot.js';
11
+ export type FeatureConfigLevel = 'global' | 'adapter' | 'endpoint' | 'conversation';
12
+ export declare const FEATURE_CONFIG_LEVELS: readonly FeatureConfigLevel[];
13
+ /** Levels stored in the configuration file (`conversation` lives in the project database). */
14
+ export type FeatureFileLevel = Exclude<FeatureConfigLevel, 'conversation'>;
15
+ /** Mirrors `ConversationKind` of `@zhin.js/im-contract` (this package has no dependencies). */
16
+ export type FeatureConversationKind = 'group' | 'private' | 'channel';
17
+ export declare const FEATURE_CONVERSATION_KINDS: readonly FeatureConversationKind[];
18
+ /** Longest accepted platform conversation id (unescaped). */
19
+ export declare const FEATURE_CONVERSATION_ID_MAX_LENGTH = 256;
20
+ /** Reserved key in a Plugin's own config block that holds adapter / endpoint overrides. */
21
+ export declare const FEATURE_OVERRIDES_KEY = "overrides";
22
+ /** Reserved field inside one override entry: the level's enable switch. */
23
+ export declare const FEATURE_ENABLED_KEY = "enabled";
24
+ /** Adapter instance alias, the top-level key of an adapter Plugin block. */
25
+ export declare const FEATURE_ADAPTER_KEY_PATTERN: RegExp;
26
+ /** `<alias>/<endpointId>`; endpoint ids exclude the record (`~`) and capability (`\0`) delimiters. */
27
+ export declare const FEATURE_ENDPOINT_KEY_PATTERN: RegExp;
28
+ export type FeatureOverrideEntry = Readonly<Record<string, unknown>> & {
29
+ readonly enabled?: boolean;
30
+ };
31
+ export interface FeatureOverrides {
32
+ readonly adapters?: Readonly<Record<string, FeatureOverrideEntry>>;
33
+ readonly endpoints?: Readonly<Record<string, FeatureOverrideEntry>>;
34
+ }
35
+ /** Parsed resolution key: `''` (global), `'qq'` (adapter), `'qq/bot-1'` (endpoint) or `'qq/bot-1/group:123'` (conversation). */
36
+ export interface FeatureResolutionKey {
37
+ readonly adapter?: string;
38
+ readonly endpointId?: string;
39
+ /** Requires `adapter` and `endpointId`. `id` is the unescaped platform conversation id. */
40
+ readonly conversation?: FeatureConversationTarget;
41
+ }
42
+ export interface FeatureConversationTarget {
43
+ readonly kind: FeatureConversationKind;
44
+ readonly id: string;
45
+ }
46
+ /** One stored conversation-level override (ADR 0055 §3). */
47
+ export interface FeatureConversationOverride {
48
+ /** Only `false` is ever stored: enabling at a lower level removes the switch. */
49
+ readonly enabled?: false;
50
+ /** Field patch limited to the Plugin's `conversationFields`. */
51
+ readonly patch: Readonly<Record<string, unknown>>;
52
+ readonly revision: number;
53
+ readonly updatedAt: number;
54
+ readonly updatedBy?: string;
55
+ }
56
+ /** Read side of the instance's conversation override store, provided on the Root Scope. */
57
+ export interface FeatureConversationOverrideReader {
58
+ /** Bumped on every write; resolution caches compare it. */
59
+ readonly version: number;
60
+ /** `plugin` is the instance path (`rss`, `parent/child`); `key` a conversation resolution key. */
61
+ get(plugin: string, key: string): FeatureConversationOverride | undefined;
62
+ }
63
+ export declare const featureConversationOverridesToken: import("./token.js").Token<FeatureConversationOverrideReader>;
64
+ /**
65
+ * What `configFor` / `isEnabledFor` accept: a resolution key string, an explicit
66
+ * `{ adapter: <instance alias>, endpointId? }`, or a message-like value carrying
67
+ * `conversation.endpoint.id` (resolved through the generation's AdapterIndex).
68
+ */
69
+ export type FeatureTarget = string | Readonly<{
70
+ adapter: string;
71
+ endpointId?: string;
72
+ conversation?: FeatureConversationTarget;
73
+ }> | Readonly<{
74
+ conversation: Readonly<{
75
+ endpoint: Readonly<{
76
+ id: string;
77
+ }>;
78
+ kind?: string;
79
+ id?: string;
80
+ }>;
81
+ endpointId?: string;
82
+ }>
83
+ /**
84
+ * An outbound selector (`outbound.send({ adapter, endpointKey })`): resolved to the Endpoint's
85
+ * CapabilityId through the same `AdapterIndex.resolve` path as the send, so platform names,
86
+ * online names and omitted keys address the same account the send reaches.
87
+ */
88
+ | Readonly<{
89
+ endpoint: Readonly<{
90
+ adapter: string;
91
+ endpointKey: string;
92
+ }>;
93
+ conversation?: Readonly<{
94
+ kind?: string;
95
+ id?: string;
96
+ }>;
97
+ }>;
98
+ export interface FeatureEnabledResolution {
99
+ readonly enabled: boolean;
100
+ /** Highest level whose switch is off. */
101
+ readonly disabledAt?: FeatureConfigLevel;
102
+ /**
103
+ * Set when a level above the queried one disabled the feature. The queried level is then
104
+ * locked: it can neither enable nor change its own switch (ADR 0050 §4).
105
+ */
106
+ readonly lockedBy?: FeatureFileLevel;
107
+ }
108
+ export interface LayeredConfigResolution<T = Readonly<Record<string, unknown>>> extends FeatureEnabledResolution {
109
+ /** Normalized resolution key (`''`, `alias`, `alias/endpointId` or `alias/endpointId/kind:id`). */
110
+ readonly key: string;
111
+ /** Effective value: deep-merged and frozen. Fields cleared with `null` are absent. */
112
+ readonly value: Readonly<T>;
113
+ /** JSON Pointer of every effective leaf (and every cleared path) → the level that set it. */
114
+ readonly sources: Readonly<Record<string, FeatureConfigLevel>>;
115
+ }
116
+ export interface LayeredConfigInput {
117
+ readonly global: unknown;
118
+ readonly overrides?: unknown;
119
+ /** False when the instance alias is listed in `disable_plugins`. */
120
+ readonly globalEnabled?: boolean;
121
+ readonly key?: FeatureResolutionKey | string;
122
+ /** Conversation-level entry (`{ enabled?: false, ...patch }`); used only for conversation keys. */
123
+ readonly conversation?: FeatureOverrideEntry;
124
+ }
125
+ /**
126
+ * Escapes a platform conversation id for a resolution key: `%` → `%25`, `/` → `%2F`, nothing
127
+ * else. The key then has exactly two `/` separators and the id round-trips unchanged.
128
+ */
129
+ export declare function escapeFeatureConversationId(id: string): string;
130
+ /** Inverse of {@link escapeFeatureConversationId}; undefined for any other `%` sequence or a raw `/`. */
131
+ export declare function unescapeFeatureConversationId(escaped: string): string | undefined;
132
+ /** Whether `id` can be a conversation key id: 1–256 characters without control characters. */
133
+ export declare function isFeatureConversationId(id: unknown): id is string;
134
+ /** `<alias>/<endpointId>/<kind>:<escaped id>` (ADR 0055 §1). */
135
+ export declare function formatFeatureConversationKey(adapter: string, endpointId: string, conversation: FeatureConversationTarget): string;
136
+ /** Parses a conversation key; undefined for adapter / endpoint keys and malformed input. */
137
+ export declare function parseFeatureConversationKey(key: string): Required<FeatureResolutionKey> | undefined;
138
+ export declare function parseFeatureResolutionKey(key: string | FeatureResolutionKey | undefined): FeatureResolutionKey;
139
+ export declare function formatFeatureResolutionKey(key: FeatureResolutionKey | string | undefined): string;
140
+ /** The endpoint key above a conversation key (`qq/bot/group:1` → `qq/bot`); other keys unchanged. */
141
+ export declare function featureEndpointKeyOf(key: string): string;
142
+ /** Conversation entry as resolution input: `{ enabled?: false, ...patch }`. */
143
+ export declare function featureConversationEntry(override: FeatureConversationOverride | undefined): FeatureOverrideEntry | undefined;
144
+ /** The Root Scope's conversation override reader, when the instance provides one. */
145
+ export declare function readFeatureConversationOverrides(snapshot: Pick<RuntimeSnapshot, 'root' | 'resources'> | undefined): FeatureConversationOverrideReader | undefined;
146
+ /** Instance path (`rss`, `parent/child`) of a non-root Plugin id. */
147
+ export declare function featureInstancePath(owner: PluginId): string;
148
+ /** Reads the structural `overrides` value; anything malformed contributes nothing. */
149
+ export declare function readFeatureOverrides(value: unknown): FeatureOverrides;
150
+ /** Level a resolution key addresses. */
151
+ export declare function featureKeyLevel(key: FeatureResolutionKey | string | undefined): FeatureConfigLevel;
152
+ /**
153
+ * Top-down: an upper `false` wins and a lower `enabled: true` never re-enables. A level is
154
+ * locked (`lockedBy`) when any level above it is off; it may only disable while every upper
155
+ * level is on (ADR 0050 §4, ADR 0055 §4).
156
+ */
157
+ export declare function resolveFeatureEnabled(input: Omit<LayeredConfigInput, 'global'> & {
158
+ readonly level?: FeatureConfigLevel;
159
+ }): FeatureEnabledResolution;
160
+ export declare function escapeJsonPointerSegment(segment: string): string;
161
+ /** Bottom-up merge `global ← adapter ← endpoint ← conversation` with per-field source tracking. */
162
+ export declare function resolveLayeredConfig<T = Readonly<Record<string, unknown>>>(input: LayeredConfigInput): LayeredConfigResolution<T>;
163
+ /** Source of one field path, falling back to the nearest recorded ancestor (a whole replaced object). */
164
+ export declare function featureConfigSource(resolution: Pick<LayeredConfigResolution, 'sources'>, path: readonly string[]): FeatureConfigLevel | undefined;
165
+ /** The document address of one Plugin instance's own config block (`root/a/b` → `['a', 'b']`). */
166
+ export declare function pluginConfigBlockPath(owner: PluginId): readonly string[];
167
+ export declare function readPluginConfigBlock(document: RuntimeConfigDocument | undefined, owner: PluginId): Readonly<Record<string, unknown>> | undefined;
168
+ export declare function readPrimaryConfig(snapshot: Pick<RuntimeSnapshot, 'root' | 'resources'>): PrimaryConfig | undefined;
169
+ /**
170
+ * Maps a message endpoint (`conversation.endpoint.id`, a CapabilityId) to its resolution key
171
+ * `<adapter instance alias>/<endpointId>`; undefined when this generation does not know it.
172
+ * With `conversation` (kind + platform id) the key goes down to the conversation level; an id
173
+ * that cannot be keyed (too long, control characters) stays at the endpoint level.
174
+ */
175
+ export declare function resolveEndpointFeatureKey(snapshot: Pick<RuntimeSnapshot, 'projections' | 'tree'>, endpointCapabilityId: string, fallbackEndpointId?: string, conversation?: Readonly<{
176
+ kind?: unknown;
177
+ id?: unknown;
178
+ }>): string | undefined;
179
+ /** Normalizes any {@link FeatureTarget}; message targets need the generation's AdapterIndex. */
180
+ export declare function resolveFeatureTargetKey(snapshot: Pick<RuntimeSnapshot, 'projections' | 'tree'> | undefined, target: FeatureTarget): string | undefined;
181
+ /**
182
+ * Dispatch gate: whether `owner` is enabled for one resolution key in this generation.
183
+ * Plugins listed in `disable_plugins` never load, so only override levels are consulted here.
184
+ * Conversation keys also read the instance's conversation overrides; the cache is keyed by
185
+ * (configuration generation, store version, owner, key).
186
+ */
187
+ export declare function isFeatureOwnerEnabled(snapshot: Pick<RuntimeSnapshot, 'root' | 'resources'>, owner: PluginId, key: string | undefined): boolean;
188
+ /** Owners in `owners` that are switched off for `key`; empty for global keys. */
189
+ export declare function disabledFeatureOwners(snapshot: Pick<RuntimeSnapshot, 'root' | 'resources'>, owners: Iterable<PluginId>, key: string | undefined): ReadonlySet<PluginId>;
@@ -0,0 +1,437 @@
1
+ /**
2
+ * Layered feature configuration (ADR 0050 / 0055): global < adapter < endpoint < conversation.
3
+ *
4
+ * Pure resolution shared by the Runtime (`configFor` / `isEnabledFor`), IM dispatch
5
+ * gating and Agent tool disclosure. Schema-level checks live in `@zhin.js/runtime`.
6
+ */
7
+ import { primaryConfigToken } from './host-installer.js';
8
+ import { featureId } from './identity.js';
9
+ import { createToken } from './token.js';
10
+ export const FEATURE_CONFIG_LEVELS = Object.freeze(['global', 'adapter', 'endpoint', 'conversation']);
11
+ export const FEATURE_CONVERSATION_KINDS = Object.freeze(['group', 'private', 'channel']);
12
+ /** Longest accepted platform conversation id (unescaped). */
13
+ export const FEATURE_CONVERSATION_ID_MAX_LENGTH = 256;
14
+ /** Reserved key in a Plugin's own config block that holds adapter / endpoint overrides. */
15
+ export const FEATURE_OVERRIDES_KEY = 'overrides';
16
+ /** Reserved field inside one override entry: the level's enable switch. */
17
+ export const FEATURE_ENABLED_KEY = 'enabled';
18
+ /** Adapter instance alias, the top-level key of an adapter Plugin block. */
19
+ export const FEATURE_ADAPTER_KEY_PATTERN = /^[a-z0-9][a-z0-9-]*$/u;
20
+ /** `<alias>/<endpointId>`; endpoint ids exclude the record (`~`) and capability (`\0`) delimiters. */
21
+ export const FEATURE_ENDPOINT_KEY_PATTERN = /^[a-z0-9][a-z0-9-]*\/[^/~\0]+$/u;
22
+ export const featureConversationOverridesToken = createToken('zhin.feature-conversation-overrides', 'Conversation-level feature overrides (ADR 0055), read side');
23
+ function plainObject(value) {
24
+ if (!value || typeof value !== 'object' || Array.isArray(value))
25
+ return false;
26
+ const prototype = Object.getPrototypeOf(value);
27
+ return prototype === Object.prototype || prototype === null;
28
+ }
29
+ const ADAPTER_ALIAS = /^[a-z0-9][a-z0-9-]*$/u;
30
+ const ENDPOINT_ID = /^[^/~\0]{1,128}$/u;
31
+ // eslint-disable-next-line no-control-regex
32
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/u;
33
+ /**
34
+ * Escapes a platform conversation id for a resolution key: `%` → `%25`, `/` → `%2F`, nothing
35
+ * else. The key then has exactly two `/` separators and the id round-trips unchanged.
36
+ */
37
+ export function escapeFeatureConversationId(id) {
38
+ return id.replaceAll('%', '%25').replaceAll('/', '%2F');
39
+ }
40
+ /** Inverse of {@link escapeFeatureConversationId}; undefined for any other `%` sequence or a raw `/`. */
41
+ export function unescapeFeatureConversationId(escaped) {
42
+ if (/%(?!25|2[fF])/u.test(escaped) || escaped.includes('/'))
43
+ return undefined;
44
+ return escaped.replace(/%2[fF]|%25/gu, match => match === '%25' ? '%' : '/');
45
+ }
46
+ /** Whether `id` can be a conversation key id: 1–256 characters without control characters. */
47
+ export function isFeatureConversationId(id) {
48
+ return typeof id === 'string' && id.length > 0 && id.length <= FEATURE_CONVERSATION_ID_MAX_LENGTH && !CONTROL_CHARACTERS.test(id);
49
+ }
50
+ function assertConversationParts(adapter, endpointId, conversation) {
51
+ if (!ADAPTER_ALIAS.test(adapter))
52
+ throw new TypeError('A conversation resolution key requires an adapter instance alias');
53
+ if (!ENDPOINT_ID.test(endpointId))
54
+ throw new TypeError('A conversation resolution key requires a valid endpointId');
55
+ if (!FEATURE_CONVERSATION_KINDS.includes(conversation.kind))
56
+ throw new TypeError('Conversation kind must be group, private or channel');
57
+ if (!isFeatureConversationId(conversation.id))
58
+ throw new TypeError('Conversation id must be 1-256 characters without control characters');
59
+ }
60
+ /** `<alias>/<endpointId>/<kind>:<escaped id>` (ADR 0055 §1). */
61
+ export function formatFeatureConversationKey(adapter, endpointId, conversation) {
62
+ assertConversationParts(adapter, endpointId, conversation);
63
+ return `${adapter}/${endpointId}/${conversation.kind}:${escapeFeatureConversationId(conversation.id)}`;
64
+ }
65
+ /** Parses a conversation key; undefined for adapter / endpoint keys and malformed input. */
66
+ export function parseFeatureConversationKey(key) {
67
+ const first = key.indexOf('/');
68
+ const second = first < 0 ? -1 : key.indexOf('/', first + 1);
69
+ if (first <= 0 || second < 0)
70
+ return undefined;
71
+ const rest = key.slice(second + 1);
72
+ const colon = rest.indexOf(':');
73
+ if (colon <= 0)
74
+ return undefined;
75
+ const id = unescapeFeatureConversationId(rest.slice(colon + 1));
76
+ if (id === undefined)
77
+ return undefined;
78
+ const parsed = { adapter: key.slice(0, first), endpointId: key.slice(first + 1, second),
79
+ conversation: Object.freeze({ kind: rest.slice(0, colon), id }) };
80
+ try {
81
+ assertConversationParts(parsed.adapter, parsed.endpointId, parsed.conversation);
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ return formatFeatureConversationKey(parsed.adapter, parsed.endpointId, parsed.conversation) === key ? Object.freeze(parsed) : undefined;
87
+ }
88
+ export function parseFeatureResolutionKey(key) {
89
+ if (key === undefined || key === '')
90
+ return Object.freeze({});
91
+ if (typeof key !== 'string') {
92
+ if (key.endpointId !== undefined && key.adapter === undefined)
93
+ throw new TypeError('An endpoint resolution key requires its adapter alias');
94
+ if (key.conversation !== undefined) {
95
+ if (key.adapter === undefined || key.endpointId === undefined)
96
+ throw new TypeError('A conversation resolution key requires its adapter alias and endpointId');
97
+ assertConversationParts(key.adapter, key.endpointId, key.conversation);
98
+ }
99
+ return Object.freeze({ ...(key.adapter !== undefined ? { adapter: key.adapter } : {}), ...(key.endpointId !== undefined ? { endpointId: key.endpointId } : {}),
100
+ ...(key.conversation !== undefined ? { conversation: Object.freeze({ kind: key.conversation.kind, id: key.conversation.id }) } : {}) });
101
+ }
102
+ const slash = key.indexOf('/');
103
+ if (slash < 0)
104
+ return Object.freeze({ adapter: key });
105
+ if (key.indexOf('/', slash + 1) >= 0) {
106
+ const conversation = parseFeatureConversationKey(key);
107
+ if (!conversation)
108
+ throw new TypeError('Invalid conversation resolution key (expected <alias>/<endpointId>/<kind>:<escaped id>)');
109
+ return conversation;
110
+ }
111
+ return Object.freeze({ adapter: key.slice(0, slash), endpointId: key.slice(slash + 1) });
112
+ }
113
+ export function formatFeatureResolutionKey(key) {
114
+ const parsed = parseFeatureResolutionKey(key);
115
+ if (parsed.adapter === undefined)
116
+ return '';
117
+ if (parsed.endpointId === undefined)
118
+ return parsed.adapter;
119
+ return parsed.conversation === undefined ? `${parsed.adapter}/${parsed.endpointId}`
120
+ : formatFeatureConversationKey(parsed.adapter, parsed.endpointId, parsed.conversation);
121
+ }
122
+ /** The endpoint key above a conversation key (`qq/bot/group:1` → `qq/bot`); other keys unchanged. */
123
+ export function featureEndpointKeyOf(key) {
124
+ const parsed = parseFeatureResolutionKey(key);
125
+ return parsed.conversation ? `${parsed.adapter}/${parsed.endpointId}` : formatFeatureResolutionKey(parsed);
126
+ }
127
+ /** Conversation entry as resolution input: `{ enabled?: false, ...patch }`. */
128
+ export function featureConversationEntry(override) {
129
+ if (!override)
130
+ return undefined;
131
+ return Object.freeze({ ...override.patch, ...(override.enabled === false ? { [FEATURE_ENABLED_KEY]: false } : {}) });
132
+ }
133
+ /** The Root Scope's conversation override reader, when the instance provides one. */
134
+ export function readFeatureConversationOverrides(snapshot) {
135
+ const value = snapshot?.resources.get(snapshot.root)?.get(featureConversationOverridesToken.id);
136
+ return value && typeof value.get === 'function' ? value : undefined;
137
+ }
138
+ /** Instance path (`rss`, `parent/child`) of a non-root Plugin id. */
139
+ export function featureInstancePath(owner) {
140
+ return String(owner).split('/').slice(1).join('/');
141
+ }
142
+ /** Reads the structural `overrides` value; anything malformed contributes nothing. */
143
+ export function readFeatureOverrides(value) {
144
+ if (!plainObject(value))
145
+ return Object.freeze({});
146
+ const pick = (entries) => {
147
+ if (!plainObject(entries))
148
+ return undefined;
149
+ return Object.freeze(Object.fromEntries(Object.entries(entries)
150
+ .filter((entry) => plainObject(entry[1]))));
151
+ };
152
+ const adapters = pick(value.adapters);
153
+ const endpoints = pick(value.endpoints);
154
+ return Object.freeze({ ...(adapters ? { adapters } : {}), ...(endpoints ? { endpoints } : {}) });
155
+ }
156
+ function overrideEntries(overrides, key) {
157
+ const adapter = key.adapter !== undefined && Object.hasOwn(overrides.adapters ?? {}, key.adapter)
158
+ ? overrides.adapters[key.adapter] : undefined;
159
+ const endpointKey = key.adapter !== undefined && key.endpointId !== undefined ? `${key.adapter}/${key.endpointId}` : undefined;
160
+ const endpoint = endpointKey !== undefined && Object.hasOwn(overrides.endpoints ?? {}, endpointKey)
161
+ ? overrides.endpoints[endpointKey] : undefined;
162
+ return { ...(adapter ? { adapter } : {}), ...(endpoint ? { endpoint } : {}) };
163
+ }
164
+ /** Level a resolution key addresses. */
165
+ export function featureKeyLevel(key) {
166
+ const parsed = parseFeatureResolutionKey(key);
167
+ return parsed.conversation !== undefined ? 'conversation' : parsed.endpointId !== undefined ? 'endpoint' : parsed.adapter !== undefined ? 'adapter' : 'global';
168
+ }
169
+ /**
170
+ * Top-down: an upper `false` wins and a lower `enabled: true` never re-enables. A level is
171
+ * locked (`lockedBy`) when any level above it is off; it may only disable while every upper
172
+ * level is on (ADR 0050 §4, ADR 0055 §4).
173
+ */
174
+ export function resolveFeatureEnabled(input) {
175
+ const key = parseFeatureResolutionKey(input.key);
176
+ const { adapter, endpoint } = overrideEntries(readFeatureOverrides(input.overrides), key);
177
+ const conversation = key.conversation !== undefined && plainObject(input.conversation) ? input.conversation : undefined;
178
+ const disabledAt = input.globalEnabled === false ? 'global'
179
+ : adapter?.enabled === false ? 'adapter'
180
+ : endpoint?.enabled === false ? 'endpoint'
181
+ : conversation?.enabled === false ? 'conversation' : undefined;
182
+ const level = input.level ?? featureKeyLevel(key);
183
+ const rank = (value) => FEATURE_CONFIG_LEVELS.indexOf(value);
184
+ const lockedBy = disabledAt !== undefined && disabledAt !== 'conversation' && rank(disabledAt) < rank(level) ? disabledAt : undefined;
185
+ return Object.freeze({ enabled: disabledAt === undefined, ...(disabledAt ? { disabledAt } : {}), ...(lockedBy ? { lockedBy } : {}) });
186
+ }
187
+ export function escapeJsonPointerSegment(segment) {
188
+ return segment.replaceAll('~', '~0').replaceAll('/', '~1');
189
+ }
190
+ function clearSources(sources, path) {
191
+ for (const existing of Object.keys(sources)) {
192
+ if (existing === path || existing.startsWith(`${path}/`))
193
+ delete sources[existing];
194
+ }
195
+ }
196
+ function markLeaves(value, path, level, sources) {
197
+ if (plainObject(value) && Object.keys(value).length > 0) {
198
+ for (const [key, child] of Object.entries(value))
199
+ markLeaves(child, `${path}/${escapeJsonPointerSegment(key)}`, level, sources);
200
+ return;
201
+ }
202
+ sources[path] = level;
203
+ }
204
+ function mergeLayer(base, layer, level, prefix, sources, top) {
205
+ for (const [key, value] of Object.entries(layer)) {
206
+ if (value === undefined || (top && (key === FEATURE_ENABLED_KEY || key === FEATURE_OVERRIDES_KEY)))
207
+ continue;
208
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype')
209
+ continue;
210
+ const path = `${prefix}/${escapeJsonPointerSegment(key)}`;
211
+ if (value === null) {
212
+ // Explicit clear: the field stops inheriting and is absent from the effective value.
213
+ delete base[key];
214
+ clearSources(sources, path);
215
+ sources[path] = level;
216
+ continue;
217
+ }
218
+ if (plainObject(value)) {
219
+ const current = base[key];
220
+ if (!plainObject(current))
221
+ clearSources(sources, path);
222
+ base[key] = mergeLayer(plainObject(current) ? { ...current } : {}, value, level, path, sources, false);
223
+ if (Object.keys(value).length === 0 && !plainObject(current))
224
+ sources[path] = level;
225
+ continue;
226
+ }
227
+ // Arrays and scalars replace whole.
228
+ clearSources(sources, path);
229
+ base[key] = structuredClone(value);
230
+ sources[path] = level;
231
+ }
232
+ return base;
233
+ }
234
+ function deepFreeze(value) {
235
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
236
+ for (const child of Object.values(value))
237
+ deepFreeze(child);
238
+ Object.freeze(value);
239
+ }
240
+ return value;
241
+ }
242
+ /** Bottom-up merge `global ← adapter ← endpoint ← conversation` with per-field source tracking. */
243
+ export function resolveLayeredConfig(input) {
244
+ const key = parseFeatureResolutionKey(input.key);
245
+ const overrides = readFeatureOverrides(input.overrides);
246
+ const sources = {};
247
+ const global = plainObject(input.global) ? input.global : {};
248
+ const { [FEATURE_OVERRIDES_KEY]: _ignored, ...globalFields } = global;
249
+ for (const [field, value] of Object.entries(globalFields)) {
250
+ if (value !== undefined)
251
+ markLeaves(value, `/${escapeJsonPointerSegment(field)}`, 'global', sources);
252
+ }
253
+ let value = structuredClone(globalFields);
254
+ const { adapter, endpoint } = overrideEntries(overrides, key);
255
+ if (adapter)
256
+ value = mergeLayer(value, adapter, 'adapter', '', sources, true);
257
+ if (endpoint)
258
+ value = mergeLayer(value, endpoint, 'endpoint', '', sources, true);
259
+ const conversation = key.conversation !== undefined && plainObject(input.conversation) ? input.conversation : undefined;
260
+ if (conversation)
261
+ value = mergeLayer(value, conversation, 'conversation', '', sources, true);
262
+ const enabled = resolveFeatureEnabled({ overrides, globalEnabled: input.globalEnabled, key, ...(conversation ? { conversation } : {}) });
263
+ return deepFreeze({
264
+ ...enabled,
265
+ key: formatFeatureResolutionKey(key),
266
+ value: value,
267
+ sources: Object.fromEntries(Object.entries(sources).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)),
268
+ });
269
+ }
270
+ /** Source of one field path, falling back to the nearest recorded ancestor (a whole replaced object). */
271
+ export function featureConfigSource(resolution, path) {
272
+ for (let length = path.length; length > 0; length -= 1) {
273
+ const pointer = `/${path.slice(0, length).map(escapeJsonPointerSegment).join('/')}`;
274
+ const source = resolution.sources[pointer];
275
+ if (source)
276
+ return source;
277
+ }
278
+ return undefined;
279
+ }
280
+ /** The document address of one Plugin instance's own config block (`root/a/b` → `['a', 'b']`). */
281
+ export function pluginConfigBlockPath(owner) {
282
+ const parts = String(owner).split('/');
283
+ return Object.freeze(parts.slice(1));
284
+ }
285
+ export function readPluginConfigBlock(document, owner) {
286
+ const path = pluginConfigBlockPath(owner);
287
+ if (!document || path.length === 0)
288
+ return undefined;
289
+ let value = document;
290
+ for (const segment of path) {
291
+ if (!plainObject(value) || !Object.hasOwn(value, segment))
292
+ return undefined;
293
+ value = value[segment];
294
+ }
295
+ return plainObject(value) ? value : undefined;
296
+ }
297
+ export function readPrimaryConfig(snapshot) {
298
+ return snapshot.resources.get(snapshot.root)?.get(primaryConfigToken.id);
299
+ }
300
+ const adapterProjectionId = featureId('zhin.adapter');
301
+ function endpointIndex(snapshot) {
302
+ const value = snapshot.projections.get(adapterProjectionId);
303
+ return value && typeof value.owner === 'function' && typeof value.endpointEntry === 'function'
304
+ ? value : undefined;
305
+ }
306
+ /**
307
+ * Maps a message endpoint (`conversation.endpoint.id`, a CapabilityId) to its resolution key
308
+ * `<adapter instance alias>/<endpointId>`; undefined when this generation does not know it.
309
+ * With `conversation` (kind + platform id) the key goes down to the conversation level; an id
310
+ * that cannot be keyed (too long, control characters) stays at the endpoint level.
311
+ */
312
+ export function resolveEndpointFeatureKey(snapshot, endpointCapabilityId, fallbackEndpointId, conversation) {
313
+ const index = endpointIndex(snapshot);
314
+ if (!index)
315
+ return undefined;
316
+ let owner;
317
+ try {
318
+ owner = index.owner(endpointCapabilityId);
319
+ }
320
+ catch {
321
+ return undefined;
322
+ }
323
+ const alias = snapshot.tree.get(owner)?.instanceKey;
324
+ if (!alias)
325
+ return undefined;
326
+ let endpointId;
327
+ try {
328
+ endpointId = index.endpointEntry(endpointCapabilityId)?.desired?.endpointId;
329
+ }
330
+ catch {
331
+ endpointId = undefined;
332
+ }
333
+ const id = typeof endpointId === 'string' && endpointId ? endpointId
334
+ : typeof fallbackEndpointId === 'string' && fallbackEndpointId ? fallbackEndpointId : undefined;
335
+ if (id === undefined)
336
+ return alias;
337
+ if (conversation && FEATURE_CONVERSATION_KINDS.includes(conversation.kind) && isFeatureConversationId(conversation.id)) {
338
+ try {
339
+ return formatFeatureConversationKey(alias, id, { kind: conversation.kind, id: conversation.id });
340
+ }
341
+ catch { /* an endpoint id that cannot carry conversations stays at the endpoint level */ }
342
+ }
343
+ return `${alias}/${id}`;
344
+ }
345
+ /** Normalizes any {@link FeatureTarget}; message targets need the generation's AdapterIndex. */
346
+ export function resolveFeatureTargetKey(snapshot, target) {
347
+ if (typeof target === 'string')
348
+ return formatFeatureResolutionKey(target);
349
+ if (!target || typeof target !== 'object')
350
+ throw new TypeError('Feature target must be a resolution key, { adapter, endpointId } or a message');
351
+ const selector = target.endpoint;
352
+ if (selector && typeof selector === 'object' && !('id' in selector)) {
353
+ const { adapter, endpointKey } = selector;
354
+ if (typeof adapter !== 'string' || typeof endpointKey !== 'string' || !snapshot)
355
+ return undefined;
356
+ const index = endpointIndex(snapshot);
357
+ let id;
358
+ try {
359
+ id = index?.resolve?.(adapter, endpointKey);
360
+ }
361
+ catch {
362
+ id = undefined;
363
+ }
364
+ if (!id)
365
+ return undefined;
366
+ const conversation = target.conversation;
367
+ return resolveEndpointFeatureKey(snapshot, String(id), undefined, conversation);
368
+ }
369
+ const conversation = target.conversation;
370
+ if (conversation && typeof conversation === 'object' && 'endpoint' in conversation) {
371
+ const message = target;
372
+ const id = message.conversation?.endpoint?.id;
373
+ if (typeof id !== 'string' || !snapshot)
374
+ return undefined;
375
+ return resolveEndpointFeatureKey(snapshot, id, typeof message.endpointId === 'string' ? message.endpointId : undefined, message.conversation);
376
+ }
377
+ const explicit = target;
378
+ if (typeof explicit.adapter !== 'string' || !explicit.adapter)
379
+ throw new TypeError('Feature target adapter must be an instance alias');
380
+ return formatFeatureResolutionKey({ adapter: explicit.adapter, ...(typeof explicit.endpointId === 'string' ? { endpointId: explicit.endpointId } : {}),
381
+ ...(explicit.conversation !== undefined ? { conversation: explicit.conversation } : {}) });
382
+ }
383
+ const enabledCache = new WeakMap();
384
+ /** Bound for one generation's per-key cache; conversations are open-ended. */
385
+ const ENABLED_CACHE_LIMIT = 20_000;
386
+ /**
387
+ * Dispatch gate: whether `owner` is enabled for one resolution key in this generation.
388
+ * Plugins listed in `disable_plugins` never load, so only override levels are consulted here.
389
+ * Conversation keys also read the instance's conversation overrides; the cache is keyed by
390
+ * (configuration generation, store version, owner, key).
391
+ */
392
+ export function isFeatureOwnerEnabled(snapshot, owner, key) {
393
+ if (!key || owner === snapshot.root)
394
+ return true;
395
+ const primary = readPrimaryConfig(snapshot);
396
+ if (!primary)
397
+ return true;
398
+ let parsed;
399
+ try {
400
+ parsed = parseFeatureResolutionKey(key);
401
+ }
402
+ catch {
403
+ return true;
404
+ }
405
+ const reader = parsed.conversation ? readFeatureConversationOverrides(snapshot) : undefined;
406
+ const version = reader?.version ?? -1;
407
+ let cache = enabledCache.get(primary);
408
+ if (!cache || (reader && cache.version !== version)) {
409
+ // A store write invalidates the generation's cache; file-level entries are rebuilt cheaply.
410
+ cache = { version: reader ? version : cache?.version ?? -1, entries: new Map() };
411
+ enabledCache.set(primary, cache);
412
+ }
413
+ const cacheKey = `${String(owner)}\0${key}`;
414
+ const cached = cache.entries.get(cacheKey);
415
+ if (cached !== undefined)
416
+ return cached;
417
+ const overrides = readPluginConfigBlock(primary.document, owner)?.[FEATURE_OVERRIDES_KEY];
418
+ const conversation = reader ? featureConversationEntry(reader.get(featureInstancePath(owner), key)) : undefined;
419
+ // A nested Plugin inherits its ancestors' switches: a disabled parent hides its children too.
420
+ const parent = String(owner).slice(0, String(owner).lastIndexOf('/'));
421
+ const enabled = (parent === snapshot.root || !String(parent) || isFeatureOwnerEnabled(snapshot, parent, key))
422
+ && resolveFeatureEnabled({ overrides, key, ...(conversation ? { conversation } : {}) }).enabled;
423
+ if (cache.entries.size >= ENABLED_CACHE_LIMIT)
424
+ cache.entries.clear();
425
+ cache.entries.set(cacheKey, enabled);
426
+ return enabled;
427
+ }
428
+ /** Owners in `owners` that are switched off for `key`; empty for global keys. */
429
+ export function disabledFeatureOwners(snapshot, owners, key) {
430
+ const disabled = new Set();
431
+ if (!key)
432
+ return disabled;
433
+ for (const owner of owners)
434
+ if (!isFeatureOwnerEnabled(snapshot, owner, key))
435
+ disabled.add(owner);
436
+ return disabled;
437
+ }
@@ -60,7 +60,26 @@ export interface PluginConsoleRpcStatus<TConfig = Record<string, unknown>> exten
60
60
  /** Allow the demo-scope Console token to read this status. Default `false`. */
61
61
  readonly demo?: boolean;
62
62
  }
63
+ /**
64
+ * Level of a plugin-defined permission (ADR 0049 amendment 2026-11-11). Built-in roles grant by
65
+ * level: viewer → read; operator → read + operate; admin and owner → all.
66
+ */
67
+ export type PluginConsolePermissionLevel = 'read' | 'operate' | 'manage';
68
+ /** Metadata for a permission string used in this plugin's pages or Console RPC. */
69
+ export interface PluginConsolePermission {
70
+ /** `<plugin>:<action>`, e.g. `rss:read`, `ops:approve`. */
71
+ readonly id: string;
72
+ readonly level: PluginConsolePermissionLevel;
73
+ /** Short label for the Console's role editor. */
74
+ readonly description?: string;
75
+ }
63
76
  export interface PluginConsoleRpcDefinition<TConfig = Record<string, unknown>> {
77
+ /**
78
+ * Levels of the permissions this plugin uses (its pages' and methods' requirements). An
79
+ * undeclared permission takes its level from the suffix: `:read` / `:view` → read;
80
+ * `:approve` / `:manage` / `:admin` / `:write` / `:config` → manage; anything else → operate.
81
+ */
82
+ readonly permissions?: readonly PluginConsolePermission[];
64
83
  /** Read-only status for the plugin's Console page(s): `plugin.<pluginId>.status`. */
65
84
  readonly status?: PluginConsoleRpcStatus<TConfig>;
66
85
  /**
@@ -92,6 +111,8 @@ export interface NormalizedPluginConsoleRpcMethod {
92
111
  /** Value published under {@link pluginConsoleRpcToken}; create it with {@link definePluginConsoleRpc}. */
93
112
  export interface PluginConsoleRpc extends GenerationAdmissionSource {
94
113
  readonly methods: ReadonlyMap<string, NormalizedPluginConsoleRpcMethod>;
114
+ /** Declared permission levels; absent or empty when the plugin declared none. */
115
+ readonly permissions?: readonly PluginConsolePermission[];
95
116
  }
96
117
  /** Safe, user-facing failure. `message` is shown in the Console; never put raw errors in it. */
97
118
  export declare class PluginConsoleRpcError extends Error {
@@ -35,10 +35,12 @@ export function definePluginConsoleRpc(definition) {
35
35
  }
36
36
  if (methods.size === 0)
37
37
  throw new TypeError('Plugin Console RPC must declare status or at least one action');
38
+ const permissions = permissionDeclarations(definition.permissions);
38
39
  const gate = createGenerationAdmissionGate();
39
40
  const gates = Object.freeze([gate]);
40
41
  return Object.freeze({
41
42
  methods: methods,
43
+ ...(permissions.length ? { permissions } : {}),
42
44
  get [generationAdmissionSource]() { return gates; },
43
45
  });
44
46
  }
@@ -66,6 +68,37 @@ function normalizeMethod(name, method, demo, readOnly) {
66
68
  handler: method.handler,
67
69
  });
68
70
  }
71
+ // Same shape as `CONSOLE_PLUGIN_PERMISSION_PATTERN` in @zhin.js/console-protocol (this package has no deps).
72
+ const permissionIdPattern = /^[a-z][a-z0-9_-]{0,63}:[a-z][a-z0-9_.-]{0,63}$/u;
73
+ const permissionLevels = new Set(['read', 'operate', 'manage']);
74
+ function permissionDeclarations(value) {
75
+ if (value === undefined)
76
+ return Object.freeze([]);
77
+ if (!Array.isArray(value))
78
+ throw new TypeError('Plugin Console RPC permissions must be an array');
79
+ const seen = new Set();
80
+ return Object.freeze(value.map((item) => {
81
+ if (!item || typeof item !== 'object' || Array.isArray(item))
82
+ throw new TypeError('Plugin Console RPC permission must be an object');
83
+ const { id, level, description, ...rest } = item;
84
+ if (Object.keys(rest).length)
85
+ throw new TypeError(`Plugin Console RPC permission has unknown keys: ${Object.keys(rest).sort().join(', ')}`);
86
+ if (typeof id !== 'string' || !permissionIdPattern.test(id))
87
+ throw new TypeError('Plugin Console RPC permission id must look like <plugin>:<action>');
88
+ if (seen.has(id))
89
+ throw new TypeError(`Duplicate plugin Console RPC permission: ${id}`);
90
+ seen.add(id);
91
+ if (typeof level !== 'string' || !permissionLevels.has(level))
92
+ throw new TypeError(`Plugin Console RPC permission ${id} level must be read, operate or manage`);
93
+ if (description !== undefined && (typeof description !== 'string' || !description.trim() || description.length > 256)) {
94
+ throw new TypeError(`Plugin Console RPC permission ${id} description must be a non-empty string (at most 256 characters)`);
95
+ }
96
+ return Object.freeze({
97
+ id, level: level,
98
+ ...(description === undefined ? {} : { description: description.trim() }),
99
+ });
100
+ }));
101
+ }
69
102
  function stringList(value, label) {
70
103
  if (value === undefined)
71
104
  return Object.freeze([]);
package/lib/plugin.d.ts CHANGED
@@ -6,6 +6,7 @@ import type { Dispose, DisposeStack } from './dispose.js';
6
6
  import type { GenerationHandoffRegistry } from './handoff.js';
7
7
  import type { FeatureId, PluginId } from './identity.js';
8
8
  import type { Scope, Token } from './token.js';
9
+ import type { FeatureTarget } from './layered-config.js';
9
10
  import type { PluginEventSubscriptions } from './plugin-events.js';
10
11
  import { type PluginConfigUpdateDeclaration } from './config-update-declaration.js';
11
12
  import { type PluginConsoleRpc, type PluginConsoleRpcDefinition } from './plugin-console-rpc.js';
@@ -42,6 +43,15 @@ export interface PluginSetupContext<TConfig = unknown> {
42
43
  readonly signal: AbortSignal;
43
44
  readonly plugin: PluginInstanceView;
44
45
  readonly config: ConfigView<TConfig>;
46
+ /**
47
+ * Effective configuration for one adapter / endpoint (ADR 0050): `global ← adapter ← endpoint`,
48
+ * merged, frozen and cached per configuration generation. `target` is a message, an explicit
49
+ * `{ adapter: <instance alias>, endpointId? }` or a resolution key such as `'qq/bot-1'`.
50
+ * Reads the latest committed configuration, so in-place override changes are visible without reload.
51
+ */
52
+ configFor(target: FeatureTarget): Readonly<TConfig>;
53
+ /** Effective enable state for one adapter / endpoint; an upper-level disable always wins. */
54
+ isEnabledFor(target: FeatureTarget): boolean;
45
55
  readonly resources: Scope;
46
56
  readonly lifecycle: DisposeStack;
47
57
  readonly handoff: GenerationHandoffRegistry;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/plugin-runtime",
3
- "version": "1.1.23",
3
+ "version": "1.2.0",
4
4
  "description": "Zero-dependency Plugin tree, Scope and atomic generation runtime for Zhin",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",