Skip to content

插件作者隐式契约指南

本文档整理 Aalis 插件作者需要遵守、但 API 文档不会显式提醒的几条约定。 写完一个插件、确认它能运行之后,建议通读本指南,核对是否遗漏了其中任何一条。

重要前置阅读node-usage-policy —— 业务插件不能直接 import node:fs / node:child_process / node:os / node:http(s),必须通过 @aalis/api-storage / @aalis/api-process 等网关访问。biome 会拦截违例。

完整参考文档(给第三方作者与维护者的全景):

  • 概念层 concepts/ —— 服务模型、惰性访问、双源 manifest、存储文法、安全模型、消息管线(写插件前先通读这 6 篇)。
  • 服务契约层 services/ —— 26 个 *-api 契约逐篇讲解:如何编写 provider、如何消费、边界与常见错误。
  • 工具库 utils/ —— 4 个 util-* 纯函数库(bounded-map / json-repair / network-guard / text-normalize)。
  • 脚手架上手 guide/scaffolding.md —— npm create aalis@latest(建项目)与 create-aalis-plugin(建插件)从零到能跑。

本指南(下文)专讲那些 API 文档不会显式提醒、但容易出错的隐式约定。


1. 服务实例替换:你需要主动通知下游吗?

结论

场景你要不要做什么
插件 dispose 时不主动 dispose 自己 provided 的服务实例什么都不用做,PluginManager 会处理
插件 active 期间临时换一个服务实例(同名 provide 二次)必须手动 evict 下游消费者,否则它们仍持有旧引用
插件配置变更触发热重载调用 updateConfig()(现为 bounce(instanceId, { config }) 别名);PluginManager 负责 dispose 与 reapply。下游是否级联取决于 requiresBounceOnDepChange(默认否)

为什么

ctx.provide(name, instance, opts)ctx.dispose() 时自动从 ServiceContainer 中注销。下游 optional 依赖该服务的插件会收到 service:unregistered 事件,被 recompute({type:'service-down'}) 自动 bounce,重新 apply 时拿到新实例。

但如果你在 active 期间(不是 dispose)二次调用 ctx.provide(name, newInstance) 来"替换"实例:

typescript
// ❌ 反模式:下游持有的还是旧 instance,不会感知更新
ctx.provide('mysvc', newInstance);

正确的做法是:dispose 自己再让 PluginManager 重启你

typescript
// 触发 PluginManager 走完整 bounce 流程(bounce 在 PluginManagerService 接口上)
await ctx.getService<PluginManagerService>('plugins')!.bounce(myInstanceId);

或者直接通过 updateConfig 让 PluginManager 把整套 dispose+evict+reapply 统一完成。

什么时候真的需要在 apply 内部替换实例?

几乎从不需要。若你认为需要,通常是混淆了「配置驱动」与「运行时驱动」: 配置变了 → updateConfig;运行时事件让服务能力变了 → 改服务内部状态而非 重新 provide。


2. inject.required vs inject.optional:选哪个?

选 required选 optional
没这个服务我无法 apply,连注册命令都不能有更好,没有也能跑(功能降级)
服务消失时我必须停下服务消失时我可以保留主功能
服务实例被替换我应该重新 apply服务实例被替换我需要重新 apply(因为我可能注册过依赖于旧实例的回调)

注意 optional 依赖也会被 bounce:当依赖的服务被 unregister 后又重新 provide (典型场景:另一个插件配置变更触发自己 reload),你会被重新 apply 一次以拿到新 实例引用。这是为了避免「先前注册到旧 commands 实例的 /help 子命令在新实例 中消失」这类隐性 bug。

inject 只认服务名,不认能力

typescript
inject: {
  required: ['llm'],            // 裸字符串
  optional: [{ service: 'memory' }],  // 或 { service } 对象,等价
}

inject 的元素是 string | { service: string }——只有服务名,0.5.0 起 core DI 不再有 { service, capabilities: [...] } 这一维。多个插件 provide 同名服务时, getService(name) 的胜者按 preference > priority > 注册顺序 选出(见第 13 节的 preferService 与 WebUI Services 页),跟"能力"无关。

真的依赖某个领域能力(如 LLM 的 tool-calling、storage 的 local-path)时, 不要试图让 core DI 帮你选——那是各域 *-api helper 的职责:用 resolveLLMModel(ctx, …) / resolveStorageEntryForRoot(ctx, root, caps)实例 / 句柄元数据过滤(见第 10 节)。


3. provides 的隐式约定

声明 provides: ['mysvc'] 后,PluginManager 会把你视为该服务的"权威提供者" 之一。这意味着:

  • 关机时拓扑序保证你晚于所有 injectmysvc 的下游 dispose
  • 下游插件 inject.required: ['mysvc'] 时,拓扑序保证你先于它们激活(提供者先起、消费者后起)

如果你 apply 内部调用了 ctx.provide('mysvc', ...) 但没在 module 顶层声明 provides: ['mysvc'],拓扑排序不会把你考虑进去。结果:

  • 关机时下游可能比你先 dispose(虽然有 reactive listener 兜底,但延迟一拍)
  • 激活时下游的依赖排序找不到你(仅靠 reactive 兜底)

dev 模式下 core 会在 apply 完成后扫描并 warn:「插件 X 注册了服务 [Y] 但未在 module.provides 中声明」,提示你补全 provides 列表。

除非有特殊理由,provides 应该和 ctx.provide() 完全一致


3.5 级联契约(opt-in):requiresBounceOnDepChange

默认行为

当某个 provider 插件被 bounce(配置更新 / 热重载 / 手动重启)时, inject 了它服务的下游插件默认不会被级联重启。下游应该通过 lazy ctx.getService() (在方法内调用时查询,而非 apply 时缓存) 透明拿到新的 provider 实例。

何时设 requiresBounceOnDepChange: true(罕用)

只有以下场景才需要让下游级联 bounce:

  • 你的 provider 改变了服务的核心能力 / 契约(如动态增删 capability)
  • 下游消费者必须重新 apply 才能感知变化(无法通过 lazy lookup 兼容)
  • 典型例:schema-changing provider、需要下游重新注册回调 / 子命令的 provider
typescript
export const module: PluginModule = {
  name: '@aalis/plugin-schema-provider',
  provides: ['schema'],
  requiresBounceOnDepChange: true,  // 下游 inject schema 的插件会被级联重新 apply
  apply(ctx) { ... },
};

下游推荐写法:lazy getter

typescript
// ✅ 推荐:存 ctx,方法内查询
class MyConsumer {
  constructor(private ctx: Context) {}
  async doWork() {
    const llm = this.ctx.getService<LLMModel>('llm');
    if (!llm) return;
    return llm.chat({...});
  }
}
typescript
// ❌ 反模式:apply 时缓存,provider 被 bounce 后拿到还是旧实例
class BadConsumer {
  constructor(private llm: LLMModel) {}  // 在 apply 内 = ctx.getService('llm')
}

参考实现:plugin-session-manager、plugin-memory-summary、plugin-message-archive 都采用该模式让 memory provider 的切换对它们透明。


4. reusable: true 的代价

typescript
export const reusable = true;

声明后允许同一 module 通过 name:suffix 注册多次(典型用例:多个 LLM provider 配多套 API key)。但你需要保证:

  • apply不直接注册全局命令(会重复注册),改为通过 commands 服务 路由,命令 handler 内根据 instanceId 区分实例
  • 如果你 provides 服务,所有实例提供的服务同名,下游通过 priority + preference 选胜者(域能力另由 *-api helper 按实例/句柄元数据过滤);你需要确保自己的服务实例之间互不串扰
  • displayName 内最好包含配置区分信息(displayName: \OpenAI / ${cfg.model}``) 让 WebUI 能区分

如果你的插件没有这种多实例需求不要声明 reusable —— 这会让重复注册 从直接抛错变成静默允许,掩盖配置 bug。

反模式:单实例 apply 内多次 ctx.provide(同一服务名)

typescript
// ❌ 不要这么写
export async function apply(ctx) {
  ctx.provide('llm', backend1);
  ctx.provide('llm', backend2); // 同名同 contextId 二次注册,下游路由命不中第二个
}

ServiceContainer 允许同一 contextId 下多次注册(容器层无校验),但下游 按 contextId 路由(如按 entryId 直查 LLMModel:resolveLLMModel(ctx, { provider, model })只会命中第一个。第二个 entry 既不会被路由到,也不会被 cap-filter 选中。 ctx.provide 会 warn 提醒你(详见 validateProvide)。

正确做法二选一:

方案 A:reusable: true + 配置后缀——适合「多套独立配置」(如多套 API key), 每份配置一个独立实例:

yaml
plugins:
  '@aalis/plugin-foo:chat': { ... }
  '@aalis/plugin-foo:vision': { ... }

方案 B:单实例 apply 内传 options.entryId 拆子粒度——适合「单插件实例、 但对外提供多个 entry」(如 per-model LLM、per-pool embedding):

typescript
export async function apply(ctx, cfg) {
  for (const model of cfg.models) {
    ctx.provide('llm', new LLMBackend(model), {
      entryId: `${ctx.id}/${model.id}`, // 显式拆子粒度,避开"二次注册命不中"的 warn
      label: `OpenAI / ${model.id}`,     // provide 选项:priority? / label? / entryId?
    });
  }
}

下游走 preference 机制选默认胜者(高优先级 / 偏好),或在请求参数中显式传 provider / model hint,由 *-api helper(如 resolveLLMModel(ctx, { provider, model })) 按 model-handle 元数据定位实例(参见 plugin-llm-openai / plugin-llm-deepseek 实现)—— 能力过滤在 helper 这层,不在 core DI


5. dispose hook:什么放进去、什么不放

应该放入

typescript
ctx.onDispose(() => {
  clearInterval(timer);
  childProcess.kill();
  websocket.close();
  fileHandle.close();
});

外部资源(OS handle、网络连接、子进程、定时器)必须手动清理。回调可以是 异步的:unload / bounce / 停机路径走 disposeAsync,会逐项等待你的 promise 完成(单项默认 5s 上限,超时放弃并 warn 点名)——await client.close() 这类 写法确实生效。

不要放入

typescript
ctx.onDispose(() => {
  offProvide();                                  // ctx.provide() 返回的退订闭包已自动处理
  offMiddleware();                               // ctx.middleware() 返回的 dispose 已自动处理
});

通过 ctx.on / ctx.middleware / ctx.provide / ctx.fork 注册 的所有东西都会被 DisposableChain 按 LIFO 顺序自动注销。手动再做一遍可能 double-free。

边界情形:在 dispose hook 内访问其它服务

PluginManager 只在 app.stop() 的整体关停里保证消费者先于提供者 dispose(拓扑反向, 异步清理按 disposeTimeoutMs 逐项设限)。单个插件被 unload / 禁用 / 热重载时它先被拆掉, 其 required 消费者随后才降级——所以 dispose hook 不能假定 ctx.getService('xxx') 一定还在: 拿不到就跳过,不要把只能在 dispose 时落盘的数据攒到最后(每次写点后就保存)。


6. 配置 schema:能力比形式重要

configSchema 是给 WebUI 自动生成表单的元数据。关键约定

  • secret: true 字段会在 WebUI 中被遮罩 + 写回时跳过空值(防止误清空)
  • required: true 仅作前端校验,core 不强制——你 apply 内还是要自己判空
  • default 就是运行时默认值——configSchema 是配置的唯一声明来源,宿主用 defaultsFrom(configSchema) 派生默认配置(不存在第二份手抄的默认值对象)
  • 嵌套对象用 SchemaGroup,数组用 SchemaArray,不要用裸 JSON 字符串字段

配置变更如何触发 reload

用户在 WebUI 点保存 → updateConfig(instanceId, newConfig)(现为 bounce(instanceId, { config }) 的别名):

  1. entry.config = newConfig + 写回 ConfigManager
  2. 如果当前 active:
    • evictDownstreamConsumers(entry) 仅针对声明了 requiresBounceOnDepChange: true 的 active 下游降级 pending(默认 false 不级联)
    • disposeAsync 你的 ctx → 你的 onDispose hook 执行(异步清理会被等待完成,落盘安全)
    • 你的 entry 状态 → pending
    • recompute({type:'plugin-state-changed'}) 把你和受影响的下游按拓扑序重激活
  3. 如果之前 error:直接 pending → recompute 重试

你 apply 内不需要做任何特殊处理。如果你的服务是无状态的(HTTP client、 工厂函数),下游在下一次 ctx.getService() lazy 查询时会自然拿到新实例。


7. 测试插件的最小写法

typescript
import { createApp } from '@aalis/core';
import myPlugin from './src/index.js';

it('should activate when its dependencies are present', async () => {
  const app = await createApp({ /* ... */ });
  await app.plugin(fakeDepProvider);  // 先注册依赖
  await app.plugin(myPlugin, { /* config */ });
  await new Promise(r => setTimeout(r, 10));  // 让 reactive listener 跑完
  expect(app.plugins.getPlugin('my-plugin')?.state).toBe('active');
});

关键点plugin()必须 await 一个微任务/setTimeout,因为 service:registered listener 走异步 recompute,同步立刻断言会读到瞬态。

测试 bounce / softReload 时同理 —— bounce 后立刻 assert 服务可用会读到 dispose 中间态,应等 plugins:changed 事件触发后再断言。


8. 何时 fork、何时新 App

隔离需求用法
一个独立"插件实例"(默认)app.plugin(mod, cfg) 自动 ctx.fork(id)
按会话/租户差异化配置或服务键控解析:按 key 查表(参考 session-manager 的 resolveConfig(sessionId) 模式),不需要上下文隔离
完全独立的事件总线 / 日志通道(少见,例如沙盒执行用户脚本)createApp({ events, services, hooks, ... }) 新建 App

曾经存在的 ctx.createScope(id)(服务/配置叠加隔离)已在 0.7.0 移除:全生态零消费者, 且其隔离边界(共享事件/钩子/文件系统)不足以承担"沙盒"语义。按 key 定制用键控解析, 真隔离用新 App。


9. 速查:apply 函数的"做什么 / 别做什么"

应该在 apply 里做

  • ctx.provide(...) 注册服务
  • ctx.on(event, ...) 监听事件
  • ctx.middleware(hook, ...) 注册中间件
  • ctx.whenService(name, svc => …) 跨插件消费服务的首选 —— 自动响应 provider 上下/下线
  • useToolService(ctx).register(...) / useCommandService(ctx).command(...) 通过 -api 包注册子能力
  • ctx.onDispose(...) 清理外部资源
  • 启动后台 worker / 连接外部服务

不应该在 apply 里做

  • await 永久阻塞(apply 必须返回,否则 PluginManager 卡住)
  • 直接修改全局 process 状态(process.env、信号 handler)
  • 跨插件 import 实现细节(应只 import @aalis/api-xxx
  • ctx.serviceContainer.register(...) 等绕过自动清理的低层 API(仅供桥接/诊断用)
  • 在 apply 内 throw —— 用 ctx.logger.error + 优雅降级;throw 会让你的 entry 进 error 态直到下次配置变更

10. 领域能力(domain capabilities)—— 写在实例 / 句柄上,不进 core 的 map

注意:0.5.0 起 core 不再有 ServiceCapabilityMap。core 的 declaration-merging 扩展点共四个:ServiceTypeMap(服务名→实例接口)、HookContextMapAalisEventsContributionPointMap(贡献点名→spec 类型) (外加配置层的 SchemaFieldTypes)。getService(name) / inject 只认服务名, 不再有 { capabilities: [...] } 这一维。

领域能力(LLM 的 tool-calling / vision、storage 的 read/write/local-path)不是 core DI 概念,而是落在服务实例 / model-handle 的元数据上,由各 -api 包自己导出能力 枚举 + 提供过滤 helper。-api 包把能力枚举当普通导出类型/常量声明出来即可,不需要、 也不能往 core 的某个 map 里 declare module

typescript
// packages/api-storage/src/index.ts
export interface StorageCapabilityRegistry {
  List: 'list';
  Read: 'read';
  Write: 'write';
  Delete: 'delete';
  LocalPath: 'local-path';
  Watch: 'watch';
}
export type StorageCapability = StorageCapabilityRegistry[keyof StorageCapabilityRegistry];
export const StorageCapabilities = { /* ...as const... */ } satisfies StorageCapabilityRegistry;

收益:

  • 能力枚举是普通导出,IDE 一样能自动补全 / 拦 typo,但不污染 core
  • 能力按需检测:storage 按 root 的真实权限位(readable/writable/deletable)+ 方法存在性(resolveLocalPath/watch)判定;LLM 按 model-handle 元数据判定
  • 运行时由各域的 *-api helper 做过滤(如 resolveStorageEntryForRoot(ctx, root, caps) / resolveLLMModel(ctx, { provider, model })),不是 core DI / 不是 getService(name, { caps })

不要在每个实现包里也声明自己的能力枚举——只 -api 包声明,实现包按需引用。

至于 core 自己的四个 declaration-merging 扩展点(ServiceTypeMap / AalisEvents / ContributionPointMap / HookContextMap),同样遵循"契约由 -api 包定义、实现包消费"的纪律。

AalisEvents 是封闭的:动态事件名怎么办?

AalisEvents / HookContextMap 没有 [key: string] 兜底(对扩展开放、对拼写 错误封闭):没声明过的事件名会在 ctx.on / ctx.emit 处直接编译报错。固定事件逐条 declare 即可;事件名需要运行时动态生成(按频道 / 任务 / 会话 ID 派生)时,官方 出路是在自己命名空间内合并一条模板字面量签名(TS 4.4+):

typescript
declare module '@aalis/core' {
  interface AalisEvents {
    'myplugin:ready': [];                                  // 固定事件:逐条声明
    [k: `myplugin:channel:${string}`]: [msg: ChannelMessage]; // 动态事件名族
  }
}

两条纪律:

  • 前缀必须是自己插件的命名空间。模板签名会吸收该前缀下的一切事件名, 与他人前缀重叠时会互相吞并类型。
  • 不要把模板签名当万能逃逸口(如 [k: `x:${string}`]宽到没有信息量)。 能枚举的事件就逐条声明——封闭性的价值正在于契约可枚举。

11. 消费跨插件服务的"心智阶梯"

顺序写法何时用
useXxxService(ctx)-api 包里有对应 helper(如 useToolService / useCommandService
ctx.whenService('xxx', svc => …)跨插件消费 + 需要在 provider 重启/替换时自动重接
inject.required: ['xxx'] + ctx.getService('xxx')!你已显式声明依赖、PluginManager 保证你被激活时 provider 一定在
ctx.getService('xxx') !== undefined + ctx.getService('xxx')探测性可选依赖(更推荐 inject.optional + bounce)

反模式

typescript
// ❌ 没声明 inject,又直接断言
export async function apply(ctx) {
  const llm = ctx.getService<LLMModel>('llm')!;  // provider 还没注册 → 运行时崩
  // ...
}

正确:

typescript
// ✅ 方式 A:声明依赖
export const inject = { required: ['llm'] };
export async function apply(ctx) {
  const llm = ctx.getService<LLMModel>('llm')!;  // 框架保证就绪
}

// ✅ 方式 B:whenService 异步等
export async function apply(ctx) {
  ctx.whenService('llm', llm => {
    // provider 上线时调用;返回的清理函数在 provider 下线/ctx dispose 时执行
    const off = llm.onEvent(handle);
    return () => off();
  });
}

whenService 比"自己 ctx.on('service:registered', …) 监听"轻量得多 —— core 已经处理好"已就绪立刻同步触发 / 反复上下线重接 / dispose 自动清理"。


12. -api 包:怎么让 ctx.getService('xxx') 拿到强类型

ServiceTypeMap 是 core 仅有的"服务名→实例接口"declaration-merging 扩展点。-api 包在自己的入口文件里把服务接口 declare 进去,业务插件只要 import '@aalis/api-xxx' (哪怕只是副作用导入)就能让 TS 在调用 ctx.getService('xxx') 时自动推断出对应接口类型:

typescript
// packages/api-llm/src/index.ts
export interface LLMModel {
  chat(req: ChatModelRequest): Promise<ChatResponse>;
  // ...
}

declare module '@aalis/core' {
  interface ServiceTypeMap {
    llm: LLMModel;
  }
}

能力枚举(LLMCapability / StorageCapability 等)是 -api 包的普通导出类型, 不进任何 core map(参见第 10 节)——ServiceTypeMap 只登记"服务名→实例接口"这一件事。

业务插件:

typescript
import '@aalis/api-llm';  // 仅副作用:把类型注册进 ServiceTypeMap

export async function apply(ctx) {
  const llm = ctx.getService('llm');
  //    ^? LLMModel | undefined  ←  无需手动 <LLMModel>
  await llm?.chat({ messages: [...] });
}

注意:没 import -api 包时,ctx.getService('llm') 会 fallback 到 unknown, 你只能 ctx.getService<LLMModel>('llm') 手动断言。所以消费方至少要把 -api 包作为 devDep / dep 引入并 import 一次。helper 形式(useToolService(ctx)) 已经把这个副作用包好了,是负担最小的写法。

实现包的 provides 服务也建议在 -api 包写类型,自己 import 使用 —— 保持 "接口契约 → -api 包 / 实现 → 实现包"的单向依赖。


13. 用户偏好放哪里?—— per-user 不进 ServiceContainer

ServiceContainer 有个 preferences: Map<serviceName, contextId> 用来"锁定某个 服务的胜者"。这个机制只用于管理员级 / App 级 default,不要拿来存 per-user 偏好。

为什么

  • ServiceContainer 是进程级单例。A 用户锁定 OpenAI、B 用户锁定 DeepSeek 在 WebUI 多用户场景下会互相覆盖
  • preferences 没有 user 维度,加进去就要把 tenancy 渗入 IoC,代价高昂
  • per-user 偏好语义本质上是请求维度的 hint,不是容器维度的 default

推荐方案

把"用户偏好的 LLM/embedding"等存在用户 profile 数据里:

typescript
interface UserProfile {
  id: string;
  preferences: {
    llm?: { ref?: ModelRef; requiredCapabilities?: LLMCapability[] };
    // ...
  };
}

每次请求时显式传入:

typescript
// agent / chat 路由内
const userPref = await getUserProfile(sessionUserId);
// 每个 model 是一个独立 entry:先把偏好落成 ModelRef,再解析出具体 entry
const entry = resolveLLMModel(ctx, req.llm ?? userPref.preferences.llm?.ref, ['chat']);
const res = await entry?.instance.chat({ messages });

优先级链清晰可追溯:req 显式 ref > user 偏好 ref > 服务偏好(ctx.preferService('llm', contextId))> 注册顺序 ——最后两级由 resolveLLMModelref=undefined 时回落到 all[0] 兑现(packages/api-llm/src/index.ts)。

多租户怎么办?

  • Layer A(不同公司):走部署,每租户一个独立进程 + 独立 AALIS_DATA_DIR
  • Layer B(沙盒/测试)createApp({ events, services, hooks }) 已经支持完全隔离
  • Layer C(同租户内多用户):上面的"per-user profile + 请求级 hint"方案

不要为了多租户改 ServiceContainer。让 IoC 保持"一进程 = 一产品实例"心智。


发布到插件市场

Aalis 市场走纯 npm 路线,无自建服务器、无静态索引——发现靠 npm registry 的 keyword 检索,分发靠 npm 包本身。要让你的插件出现在市场里:

  1. 打 keywordpackage.jsonkeywords 必须含 "aalis-plugin"(脚手架已自动产出)。 市场按 npm registry search keywords:aalis-plugin 发现插件。官方插件用 @aalis/ scope (市场标"官方");社区插件任意包名(标"社区")。
  2. 依赖正确归类(决定发布后能否被正确安装——脚手架已产出正确形态):
    • @aalis/corepeerDependencies: ">=0.2.0 <1.0.0"(宿主提供核心,不每插件 bundle 一份)
      • devDependencies: workspace:*(开发期编译)。宽松区间是刻意的:它接受任何 0.x 宿主 core,你的插件不必随 core 次版本升级而重发。但注意 core 在 1.0 之前并未承诺 0.x 内绝不 破坏公开面(0.7.0 删过 createScope 等、0.9.0 删过 hasService / getServiceEntries / ConfigSchema 全家)——用了新 API 就把下限抬到对应版本(如 >=0.9.0 <1.0.0)。稳定性承诺 自 1.0 起生效,以 docs/design/core-contract.md 为准。不要用 caret^0.2.0 只 匹配 0.2.x,会把插件锁死在某个 core 次版本,core 一升就显示不兼容)。

      注意:这条 0.x 兼容承诺只针对 @aalis/core 本身。你依赖的 @aalis/api-* 契约包(服务接口 / 类型 / 工具定义形状)不在该承诺内——0.x 期间仍可能改签名、增删 字段、重命名导出。消费它们的插件要关注 CHANGELOG.md、预期适配,别把当前 -api 契约当成冻结的稳定面。

    • import type / declaration merging 的 api 包 → devDependencies(编译期擦除, 运行时不装)。注意:若你写的是 -api 契约包且其导出类型引用别的包,那些要留 dependencies(类型会传递给消费方)。
    • 运行时用到值(useXxxService、helper、常量)的 api/util 包 → dependencies: workspace:>=<被依赖包当前版本> <1.0.0不要用 workspace:^workspace:*^ 发布成 ^0.x.y,caret 在 0.x 下只匹配 0.x.*跨 minor 就断——消费者会装到滞后的旧版;若同时依赖两个包而它们各锁不同 minor, npm 会装进两份同一 api 包,两份 declare module '@aalis/core' 撞成 TS2717,而 skipLibCheck: true 会把这个错误彻底吞掉,于是服务名静默解析成其中任意一份。* 发布成 精确版本,同样锁死。写成 >=x.y.z <1.0.0 范围时 pnpm 原样保留,跨 minor 自动拉新版。
    • 市场展示字段直接读 package.jsondescription/author/license/repository/version
  3. 声明 aalis.service 供装前披露:市场在 npm 上安装前只能读 package.json (拿不到代码里的 inject),所以在 package.json 加:
    json
    "aalis": { "service": { "required": ["llm"], "optional": ["memory"], "provides": ["my-service"] } }
    保持与代码 inject.required/optional + provides 一致。装后市场仍会按实际 inject + 工具/指令的 restricted 可见性聚合细化(双重披露)。
  4. breaking change 记 changelog1.0 之前 core 的公开面可能在次版本被删(已发生过: 0.7.0 / 0.9.0)。宽 peerDep 区间是为了让不用新 API 的插件不必随次版本频繁重发,不是兼容性承诺。 稳定性承诺自 1.0 起生效,条款见 docs/design/core-contract.md。core/契约包的不兼容变更 必须在 CHANGELOG.md 记录迁移说明。
  5. 发布pnpm publish:all(仓库根,递归拓扑序发 core→api→util→插件、跳 private、 转 workspace 协议)。单插件 npm publish。私有/未发布插件仍可走 monorepo 本地安装。

安全模型:市场是透明披露 + 用户知情同意,不是技术隔离。安装第三方插件 等于授予它声明的能力;真正的执行隔离(如 code_runner 沙箱)由容器化层负责。

相关文档