@nekro-nxt/contracts 0.0.0-stage → 0.1.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.
@@ -0,0 +1,473 @@
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * Host capability level this build implements. A Revision that declares `requires.sdk` above it is rejected at
5
+ * import with an "upgrade NekroNXT" message instead of an opaque schema error. Bump only when a new optional
6
+ * Manifest capability ships; never reuse a level for a different meaning.
7
+ */
8
+ export const EXTENSION_SDK_LEVEL = 6
9
+
10
+ export const ExtensionRequiresSchema = z.object({ sdk: z.number().int().min(1).max(1000) }).strict()
11
+ export type ExtensionRequires = z.output<typeof ExtensionRequiresSchema>
12
+
13
+ /** `api.example.com` or `*.example.com` (subdomains only); lower-case DNS labels, no scheme, port or path. */
14
+ export const ExtensionNetworkDomainSchema = z
15
+ .string()
16
+ .trim()
17
+ .toLowerCase()
18
+ .max(253)
19
+ .regex(/^(\*\.)?(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/u, {
20
+ message: '网络域名必须是 api.example.com 或 *.example.com 形式,不含协议、端口和路径。',
21
+ })
22
+
23
+ export const ExtensionNetworkCapabilitySchema = z.discriminatedUnion('mode', [
24
+ z
25
+ .object({
26
+ mode: z.literal('domains'),
27
+ domains: z.array(ExtensionNetworkDomainSchema).min(1).max(32),
28
+ })
29
+ .strict(),
30
+ z
31
+ .object({
32
+ mode: z.literal('config'),
33
+ /** Top-level config fields whose current value is a URL or host the user typed. */
34
+ fields: z.array(z.string().trim().min(1).max(64)).min(1).max(8),
35
+ })
36
+ .strict(),
37
+ z
38
+ .object({
39
+ mode: z.literal('unrestricted'),
40
+ /** Shown to the user next to the risk acknowledgement. */
41
+ purpose: z.string().trim().min(4).max(200),
42
+ })
43
+ .strict(),
44
+ ])
45
+ export type ExtensionNetworkCapability = z.output<typeof ExtensionNetworkCapabilitySchema>
46
+
47
+ export const EXTENSION_STORAGE_SCOPES = ['agent', 'channel', 'member', 'shared'] as const
48
+ export const ExtensionStorageScopeSchema = z.enum(EXTENSION_STORAGE_SCOPES)
49
+ export type ExtensionStorageScope = z.output<typeof ExtensionStorageScopeSchema>
50
+
51
+ export const EXTENSION_STORAGE_DEFAULT_QUOTA_BYTES = 8 * 1024 * 1024
52
+ export const EXTENSION_STORAGE_MAX_QUOTA_BYTES = 64 * 1024 * 1024
53
+
54
+ export const ExtensionStorageCapabilitySchema = z
55
+ .object({
56
+ scopes: z.array(ExtensionStorageScopeSchema).min(1).max(EXTENSION_STORAGE_SCOPES.length),
57
+ quotaBytes: z.number().int().min(1024).max(EXTENSION_STORAGE_MAX_QUOTA_BYTES).optional(),
58
+ })
59
+ .strict()
60
+ .refine((value) => new Set(value.scopes).size === value.scopes.length, '存储作用域不能重复。')
61
+ export type ExtensionStorageCapability = z.output<typeof ExtensionStorageCapabilitySchema>
62
+
63
+ export const EXTENSION_CONTEXT_STATIC_MAX_CHARS = 4000
64
+ export const EXTENSION_CONTEXT_DYNAMIC_MAX_CHARS = 2000
65
+
66
+ export const ExtensionContextContributionSchema = z
67
+ .object({
68
+ name: z
69
+ .string()
70
+ .trim()
71
+ .regex(/^[a-z0-9][a-z0-9-]{0,39}$/u, '上下文名称只能使用小写字母、数字和连字符。'),
72
+ kind: z.enum(['static', 'dynamic']),
73
+ maxChars: z.number().int().min(1).max(EXTENSION_CONTEXT_STATIC_MAX_CHARS),
74
+ })
75
+ .strict()
76
+ .refine(
77
+ (value) => value.kind === 'static' || value.maxChars <= EXTENSION_CONTEXT_DYNAMIC_MAX_CHARS,
78
+ `动态上下文最多 ${EXTENSION_CONTEXT_DYNAMIC_MAX_CHARS} 字。`,
79
+ )
80
+ export type ExtensionContextContribution = z.output<typeof ExtensionContextContributionSchema>
81
+
82
+ export const EXTENSION_LLM_MAX_CALLS_PER_TURN = 20
83
+ export const EXTENSION_LLM_MAX_OUTPUT_TOKENS = 8192
84
+
85
+ export const ExtensionLlmCapabilitySchema = z
86
+ .object({
87
+ maxCallsPerTurn: z.number().int().min(1).max(EXTENSION_LLM_MAX_CALLS_PER_TURN),
88
+ maxOutputTokens: z.number().int().min(64).max(EXTENSION_LLM_MAX_OUTPUT_TOKENS),
89
+ })
90
+ .strict()
91
+ export type ExtensionLlmCapability = z.output<typeof ExtensionLlmCapabilitySchema>
92
+
93
+ export const EXTENSION_INBOUND_HOOK_MAX_TIMEOUT_MS = 15_000
94
+
95
+ export const ExtensionInboundHookCapabilitySchema = z
96
+ .object({
97
+ /** `triggered`: only messages that would wake the agent anyway; `all`: every message in bound channels. */
98
+ reads: z.enum(['triggered', 'all']),
99
+ mayHide: z.boolean(),
100
+ mayForceTrigger: z.boolean(),
101
+ timeoutMs: z.number().int().min(100).max(EXTENSION_INBOUND_HOOK_MAX_TIMEOUT_MS),
102
+ })
103
+ .strict()
104
+ export type ExtensionInboundHookCapability = z.output<typeof ExtensionInboundHookCapabilitySchema>
105
+
106
+ export const EXTENSION_JOB_MAX_RUNTIME = 50
107
+
108
+ export const ExtensionDeclaredJobSchema = z
109
+ .object({
110
+ id: z
111
+ .string()
112
+ .trim()
113
+ .regex(/^[a-z0-9][a-z0-9-]{0,39}$/u, '定时任务 id 只能使用小写字母、数字和连字符。'),
114
+ label: z.string().trim().min(1).max(200),
115
+ cron: z.string().trim().min(9).max(120),
116
+ timezone: z.string().trim().min(1).max(64).optional(),
117
+ })
118
+ .strict()
119
+ export type ExtensionDeclaredJob = z.output<typeof ExtensionDeclaredJobSchema>
120
+
121
+ export const ExtensionJobsCapabilitySchema = z
122
+ .object({
123
+ /** Fixed schedules; each fires in every channel bound to the agent that enables the extension. */
124
+ declared: z
125
+ .array(ExtensionDeclaredJobSchema)
126
+ .max(8)
127
+ .refine((jobs) => new Set(jobs.map(({ id }) => id)).size === jobs.length, '定时任务 id 不能重复。')
128
+ .optional(),
129
+ /** Jobs the extension creates at runtime (`ctx.nxt.jobs.schedule`), at most this many active per agent. */
130
+ runtime: z
131
+ .object({ maxActive: z.number().int().min(1).max(EXTENSION_JOB_MAX_RUNTIME) })
132
+ .strict()
133
+ .optional(),
134
+ })
135
+ .strict()
136
+ export type ExtensionJobsCapability = z.output<typeof ExtensionJobsCapabilitySchema>
137
+
138
+ const AdapterKeySchema = z.string().trim().min(1).max(64)
139
+
140
+ export const ExtensionPlatformCapabilitySchema = z
141
+ .object({
142
+ /** Typed actions an Adapter declares, as `{ adapter, action }`. */
143
+ actions: z
144
+ .array(z.object({ adapter: AdapterKeySchema, action: z.string().regex(/^[a-z0-9_]{1,64}$/u) }).strict())
145
+ .max(32)
146
+ .default([]),
147
+ /** Adapters whose raw API passthrough the extension may call. */
148
+ raw: z.array(AdapterKeySchema).max(8).default([]),
149
+ })
150
+ .strict()
151
+ export type ExtensionPlatformCapability = z.output<typeof ExtensionPlatformCapabilitySchema>
152
+
153
+ /** A header or environment value: literal text, or the current value of a `meta.role: 'secret'` config field. */
154
+ export const ExtensionMcpValueSchema = z.union([
155
+ z.string().max(4096),
156
+ z.object({ secret: z.string().trim().min(1).max(64) }).strict(),
157
+ ])
158
+ export type ExtensionMcpValue = z.output<typeof ExtensionMcpValueSchema>
159
+
160
+ /** Becomes the model-facing tool prefix `mcp__<name>__`. */
161
+ export const ExtensionMcpServerNameSchema = z.string().regex(/^[A-Za-z0-9_-]{1,32}$/u)
162
+
163
+ const McpValueMapSchema = z
164
+ .record(z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,127}$/u), ExtensionMcpValueSchema)
165
+ .refine((value) => Object.keys(value).length <= 32, '最多 32 项。')
166
+ .default({})
167
+
168
+ export const ExtensionMcpServerSchema = z.discriminatedUnion('transport', [
169
+ z
170
+ .object({
171
+ transport: z.literal('streamable-http'),
172
+ name: ExtensionMcpServerNameSchema,
173
+ url: z
174
+ .string()
175
+ .url()
176
+ .refine((value) => /^https?:\/\//iu.test(value), 'MCP 地址必须是 http 或 https。'),
177
+ headers: McpValueMapSchema,
178
+ })
179
+ .strict(),
180
+ z
181
+ .object({
182
+ transport: z.literal('stdio'),
183
+ name: ExtensionMcpServerNameSchema,
184
+ command: z.string().trim().min(1).max(512),
185
+ args: z.array(z.string().max(4096)).max(64).default([]),
186
+ env: McpValueMapSchema,
187
+ })
188
+ .strict(),
189
+ ])
190
+ export type ExtensionMcpServer = z.output<typeof ExtensionMcpServerSchema>
191
+
192
+ /**
193
+ * MCP servers the Host connects for the agent while the extension is enabled; their tools appear as
194
+ * `mcp__<name>__<tool>`. `stdio` runs a program on the Host and is only created from the administrator's form.
195
+ */
196
+ export const ExtensionMcpCapabilitySchema = z
197
+ .object({
198
+ servers: z
199
+ .array(ExtensionMcpServerSchema)
200
+ .min(1)
201
+ .max(8)
202
+ .refine((value) => new Set(value.map(({ name }) => name)).size === value.length, 'MCP 服务名称不能重复。'),
203
+ })
204
+ .strict()
205
+ export type ExtensionMcpCapability = z.output<typeof ExtensionMcpCapabilitySchema>
206
+
207
+ /** One header or environment row of the administrator's form; secret rows become credential config fields. */
208
+ const McpFormValueSchema = z
209
+ .object({
210
+ name: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,127}$/u, '名称只能包含字母、数字、下划线和连字符。'),
211
+ value: z.string().max(4096),
212
+ secret: z.boolean().default(false),
213
+ })
214
+ .strict()
215
+
216
+ export const McpServerFormSchema = z.discriminatedUnion('transport', [
217
+ z
218
+ .object({
219
+ transport: z.literal('streamable-http'),
220
+ name: ExtensionMcpServerNameSchema,
221
+ url: z.string().url(),
222
+ headers: z.array(McpFormValueSchema).max(32).default([]),
223
+ })
224
+ .strict(),
225
+ z
226
+ .object({
227
+ transport: z.literal('stdio'),
228
+ name: ExtensionMcpServerNameSchema,
229
+ command: z.string().trim().min(1).max(512),
230
+ args: z.array(z.string().max(4096)).max(64).default([]),
231
+ env: z.array(McpFormValueSchema).max(32).default([]),
232
+ })
233
+ .strict(),
234
+ ])
235
+ export type McpServerForm = z.output<typeof McpServerFormSchema>
236
+
237
+ /** Secret config fields an MCP declaration reads. */
238
+ export const mcpSecretFields = (mcp: ExtensionMcpCapability | undefined): readonly string[] => [
239
+ ...new Set(
240
+ (mcp?.servers ?? []).flatMap((server) =>
241
+ Object.values(server.transport === 'stdio' ? server.env : server.headers).flatMap((value) =>
242
+ typeof value === 'string' ? [] : [value.secret],
243
+ ),
244
+ ),
245
+ ),
246
+ ]
247
+
248
+ /** What a server connects to; changing it needs a new approval, unlike its credentials. */
249
+ const mcpTarget = (server: ExtensionMcpServer): string =>
250
+ server.transport === 'stdio'
251
+ ? JSON.stringify(['stdio', server.name, server.command, server.args])
252
+ : JSON.stringify(['streamable-http', server.name, server.url])
253
+
254
+ /**
255
+ * Optional Host capabilities of an agent-scope Revision. Every field is additive to Manifest V6: an absent field
256
+ * means the Revision cannot use that capability, exactly as before the field existed.
257
+ */
258
+ export const ExtensionCapabilitiesSchema = z
259
+ .object({
260
+ network: ExtensionNetworkCapabilitySchema.optional(),
261
+ storage: ExtensionStorageCapabilitySchema.optional(),
262
+ assets: z
263
+ .object({ write: z.literal(true) })
264
+ .strict()
265
+ .optional(),
266
+ history: z
267
+ .object({ read: z.literal(true) })
268
+ .strict()
269
+ .optional(),
270
+ inboundHook: ExtensionInboundHookCapabilitySchema.optional(),
271
+ jobs: ExtensionJobsCapabilitySchema.optional(),
272
+ platform: ExtensionPlatformCapabilitySchema.optional(),
273
+ llm: ExtensionLlmCapabilitySchema.optional(),
274
+ mcp: ExtensionMcpCapabilitySchema.optional(),
275
+ context: z
276
+ .array(ExtensionContextContributionSchema)
277
+ .max(8)
278
+ .refine((value) => new Set(value.map(({ name }) => name)).size === value.length, '上下文名称不能重复。')
279
+ .optional(),
280
+ })
281
+ .strict()
282
+ export type ExtensionCapabilities = z.output<typeof ExtensionCapabilitiesSchema>
283
+
284
+ const NETWORK_MODE_RANK = { domains: 0, config: 1, unrestricted: 2 } as const
285
+
286
+ const networkExpands = (
287
+ previous: ExtensionNetworkCapability | undefined,
288
+ next: ExtensionNetworkCapability | undefined,
289
+ ): boolean => {
290
+ if (next === undefined) return false
291
+ if (previous === undefined) return true
292
+ if (NETWORK_MODE_RANK[next.mode] !== NETWORK_MODE_RANK[previous.mode]) {
293
+ return NETWORK_MODE_RANK[next.mode] > NETWORK_MODE_RANK[previous.mode]
294
+ }
295
+ if (next.mode === 'domains' && previous.mode === 'domains') {
296
+ return next.domains.some((domain) => !previous.domains.includes(domain))
297
+ }
298
+ if (next.mode === 'config' && previous.mode === 'config') {
299
+ return next.fields.some((field) => !previous.fields.includes(field))
300
+ }
301
+ return false
302
+ }
303
+
304
+ /**
305
+ * Whether `next` asks for something an approval of `previous` did not cover. Narrowing never needs approval;
306
+ * storage quota is bounded by the Host maximum and is not an expansion. Context contributions are capped text
307
+ * shown in the trace, so adding one is not an expansion either.
308
+ */
309
+ export const extensionCapabilitiesExpand = (
310
+ previous: ExtensionCapabilities | undefined,
311
+ next: ExtensionCapabilities | undefined,
312
+ ): boolean => {
313
+ if (next === undefined) return false
314
+ if (networkExpands(previous?.network, next.network)) return true
315
+ if (next.storage?.scopes.some((scope) => !(previous?.storage?.scopes ?? []).includes(scope))) return true
316
+ if (next.assets !== undefined && previous?.assets === undefined) return true
317
+ if (next.history !== undefined && previous?.history === undefined) return true
318
+ const platformKey = ({ adapter, action }: { readonly adapter: string; readonly action: string }) =>
319
+ `${adapter}:${action}`
320
+ const previousActions = new Set((previous?.platform?.actions ?? []).map(platformKey))
321
+ if ((next.platform?.actions ?? []).some((entry) => !previousActions.has(platformKey(entry)))) return true
322
+ if ((next.platform?.raw ?? []).some((adapter) => !(previous?.platform?.raw ?? []).includes(adapter))) return true
323
+ const hook = next.inboundHook
324
+ const previousHook = previous?.inboundHook
325
+ if (
326
+ hook !== undefined &&
327
+ (previousHook === undefined ||
328
+ (hook.reads === 'all' && previousHook.reads === 'triggered') ||
329
+ (hook.mayHide && !previousHook.mayHide) ||
330
+ (hook.mayForceTrigger && !previousHook.mayForceTrigger))
331
+ ) {
332
+ return true
333
+ }
334
+ if (
335
+ next.jobs?.runtime !== undefined &&
336
+ (previous?.jobs?.runtime === undefined || next.jobs.runtime.maxActive > previous.jobs.runtime.maxActive)
337
+ ) {
338
+ return true
339
+ }
340
+ const previousMcp = new Set((previous?.mcp?.servers ?? []).map(mcpTarget))
341
+ if ((next.mcp?.servers ?? []).some((server) => !previousMcp.has(mcpTarget(server)))) return true
342
+ if (
343
+ next.llm !== undefined &&
344
+ (previous?.llm === undefined ||
345
+ next.llm.maxCallsPerTurn > previous.llm.maxCallsPerTurn ||
346
+ next.llm.maxOutputTokens > previous.llm.maxOutputTokens)
347
+ ) {
348
+ return true
349
+ }
350
+ return false
351
+ }
352
+
353
+ /** Capability risk tiers shown on the approval page (§7 of the capability decision). */
354
+ export type ExtensionCapabilityRisk = 'normal' | 'sensitive' | 'high'
355
+
356
+ export interface ExtensionCapabilitySummaryItem {
357
+ readonly key: string
358
+ readonly risk: ExtensionCapabilityRisk
359
+ readonly label: string
360
+ readonly detail?: string
361
+ }
362
+
363
+ /** User-facing summary of declared capabilities, in a stable order, for approval and inspection pages. */
364
+ export const summarizeExtensionCapabilities = (
365
+ capabilities: ExtensionCapabilities | undefined,
366
+ ): readonly ExtensionCapabilitySummaryItem[] => {
367
+ if (capabilities === undefined) return []
368
+ const items: ExtensionCapabilitySummaryItem[] = []
369
+ const network = capabilities.network
370
+ if (network?.mode === 'domains') {
371
+ items.push({ key: 'network', risk: 'normal', label: '访问指定网站', detail: network.domains.join('、') })
372
+ } else if (network?.mode === 'config') {
373
+ items.push({ key: 'network', risk: 'normal', label: '访问你在配置中填写的地址', detail: network.fields.join('、') })
374
+ } else if (network?.mode === 'unrestricted') {
375
+ items.push({ key: 'network', risk: 'high', label: '访问任意公网地址', detail: network.purpose })
376
+ }
377
+ if (capabilities.storage !== undefined) {
378
+ const shared = capabilities.storage.scopes.includes('shared')
379
+ items.push({
380
+ key: 'storage',
381
+ risk: shared ? 'sensitive' : 'normal',
382
+ label: shared ? '保存数据,并在启用它的智能体之间共享' : '为这个智能体保存数据',
383
+ })
384
+ }
385
+ if (capabilities.assets !== undefined)
386
+ items.push({ key: 'assets', risk: 'normal', label: '在当前频道生成图片或文件' })
387
+ if (capabilities.history !== undefined)
388
+ items.push({ key: 'history', risk: 'normal', label: '读取当前频道的聊天记录' })
389
+ const hook = capabilities.inboundHook
390
+ if (hook !== undefined) {
391
+ const effects = [
392
+ hook.mayHide ? '对智能体隐藏消息' : '',
393
+ hook.mayForceTrigger ? '让智能体回应原本不会回应的消息' : '',
394
+ ]
395
+ .filter((effect) => effect !== '')
396
+ .join('、')
397
+ items.push({
398
+ key: 'inboundHook',
399
+ risk: hook.reads === 'all' ? 'high' : 'sensitive',
400
+ label: hook.reads === 'all' ? '查看频道里的全部消息' : '在智能体回应前查看要回应的消息',
401
+ ...(effects === '' ? {} : { detail: `可以${effects}` }),
402
+ })
403
+ }
404
+ const jobs = capabilities.jobs
405
+ if (jobs?.declared !== undefined && jobs.declared.length > 0) {
406
+ items.push({
407
+ key: 'jobs.declared',
408
+ risk: 'normal',
409
+ label: '按固定计划唤醒智能体',
410
+ detail: jobs.declared.map(({ label }) => label).join('、'),
411
+ })
412
+ }
413
+ if (jobs?.runtime !== undefined) {
414
+ items.push({
415
+ key: 'jobs.runtime',
416
+ risk: 'sensitive',
417
+ label: '创建定时任务唤醒智能体',
418
+ detail: `同时最多 ${jobs.runtime.maxActive} 个`,
419
+ })
420
+ }
421
+ const platform = capabilities.platform
422
+ if (platform !== undefined && platform.actions.length > 0) {
423
+ items.push({
424
+ key: 'platform.actions',
425
+ // Actions such as muting or removing members affect other people, so every typed action is confirmed.
426
+ risk: 'high',
427
+ label: '在平台上执行操作',
428
+ detail: platform.actions.map(({ adapter, action }) => `${adapter} · ${action}`).join('、'),
429
+ })
430
+ }
431
+ if (platform !== undefined && platform.raw.length > 0) {
432
+ items.push({
433
+ key: 'platform.raw',
434
+ risk: 'high',
435
+ label: '直接调用平台原始接口',
436
+ detail: platform.raw.join('、'),
437
+ })
438
+ }
439
+ if (capabilities.llm !== undefined) {
440
+ items.push({
441
+ key: 'llm',
442
+ risk: 'sensitive',
443
+ label: '使用这个智能体的模型',
444
+ detail: `每轮最多 ${capabilities.llm.maxCallsPerTurn} 次,计入智能体用量`,
445
+ })
446
+ }
447
+ for (const server of capabilities.mcp?.servers ?? []) {
448
+ items.push(
449
+ server.transport === 'stdio'
450
+ ? {
451
+ key: `mcp.${server.name}`,
452
+ risk: 'high',
453
+ label: `在本机运行 MCP 程序 ${server.name}`,
454
+ detail: [server.command, ...server.args].join(' '),
455
+ }
456
+ : {
457
+ key: `mcp.${server.name}`,
458
+ risk: 'sensitive',
459
+ label: `连接 MCP 服务 ${server.name}`,
460
+ detail: new URL(server.url).host,
461
+ },
462
+ )
463
+ }
464
+ if (capabilities.context !== undefined && capabilities.context.length > 0) {
465
+ items.push({
466
+ key: 'context',
467
+ risk: 'normal',
468
+ label: '向智能体提供补充说明',
469
+ detail: capabilities.context.map(({ name }) => name).join('、'),
470
+ })
471
+ }
472
+ return items
473
+ }