PKU harness

plugin

定义插件时用到的全部类型:definePlugin、defineTool、definePreference、PluginDef、ToolDef、ToolContext、效果、调用方、能力需求、KV、轮次信息、数据源、设置项、模型、语音和 OCR 服务商、翻译和旧 id。

每个内置插件都导出一个用 definePlugin 定义的对象,里面写明插件的名称、需要的能力、提供的工具和界面等。这一页列出这个对象和工具的全部字段,以及工具运行时收到的上下文。

源码:packages/sdk/src/plugin.ts。

definePlugin

function definePlugin(def: PluginDef): PluginDef;
function defineTool<I extends z.ZodType, O>(def: ToolDef<I, O>): ToolDef;
function definePreference<T extends SettingValue>(def: PluginPreferenceDef<T>): PluginPreferenceDef<T>;

三个函数都原样返回参数,用途只是类型检查。defineTool 会根据 zod 的 input schema 推断出 execute 的入参类型;definePreference 让设置项的值类型和它的 input schema 保持一致。

PluginDef

PluginDef 是插件对象本身。下面先给出完整的类型,再逐个说明字段。

interface PluginDef {
  id: string;
  name: string;
  description: string;
  version: string;
  maxAgentSteps?: number;
  icon?: string;
  core?: boolean;
  requires?: Capability[];
  cloud?: CloudManifest;
  preferences?: Record<string, PluginPreferenceDef<any>>;
  remote?: { url: string; server: string };
  defaultModes?: Partial<Record<ToolEffect, 'auto' | 'ask' | 'off'>>;
  scopes: string[];
  tools: Record<string, ToolDef>;
  dataSources?: Record<string, DataSourceDef>;
  providers?: Record<string, ProviderDef>;
  speech?: Record<string, SpeechProviderDef>;
  ocr?: Record<string, OcrProviderDef>;
  instructions?: Dynamic<string>;
  context?: (input: ContextInput) => Promise<string | undefined>;
  contributes?: Contributions;
  i18n?: { en?: PluginTranslation };
  legacy?: PluginLegacy;
}

属性

类型

ToolDef

工具是插件提供给助手、用户和调度器调用的函数。tools 里的每一项都是一个 ToolDef。

interface ToolDef<I extends z.ZodType = z.ZodType, O = unknown> {
  title: string;
  description: Dynamic<string>;
  input: I;
  effect: ToolEffect;
  resolveEffect?: (input: z.infer<I>, services: HostServices) => Promise<ToolEffect | ToolAssessment>;
  untrusted?: boolean;
  userOnly?: boolean;
  onDemand?: boolean;
  grant?: boolean;
  alwaysAsk?: boolean;
  serial?: boolean;
  dayScoped?: boolean;
  execute: (input: z.infer<I>, ctx: ToolContext) => Promise<O>;
}

/** 宿主观察到的操作对象,和模型给出的工具参数分开显示。 */
type ToolAssessment = {
  effect: ToolEffect;
  target?: { label: string; context?: string };
};

属性

类型

ToolContext

宿主调用 execute 时会传入第二个参数 ctx。它说明这次是谁在调用(actor),并提供插件私有的键值存储 kv 和宿主服务 services。

interface ToolContext {
  /** 本次调用经过(可选的)原生分类后被批准的效果。 */
  effect?: ToolEffect;
  pluginId: string;
  actor: Actor;
  /** 助手在一轮对话中调用时才有值。 */
  turn?: TurnInfo;
  /** 本插件的私有存储。 */
  kv: KV;
  services: HostServices;
  /** 绑定到本插件的 PKU harness Cloud;在所有宿主版本都接入之前是可选的。 */
  cloud?: PluginCloud;
  /** 用户对 `grant` 工具提示的回答。用户直接调用时不设置。 */
  grant?: 'once' | 'always';
  /**
   * 本插件自己的某个模型服务商(`PluginDef.providers` 的键)的地址和 Key,不能取其他插件的。
   * 在所有宿主版本都接入之前是可选的。
   */
  provider?: (key: string) => Promise<{ baseURL: string; credential?: string }>;
  signal?: AbortSignal;
}

type Actor = 'user' | 'agent' | 'scheduler';

interface TurnInfo {
  sessionId?: string;
  /** 本轮中助手此前调用过不受信任的工具(或任何远程插件)。 */
  tainted: boolean;
  /** 用户在本轮自己发的消息中要求记住某件事。 */
  userAsked?: boolean;
  /** 本轮使用的模型能看图片(模型目录的 `inputModalities` 中有 'image')。 */
  acceptsImages?: boolean;
}

interface KV {
  get<T>(key: string): Promise<T | undefined>;
  set<T>(key: string, value: T): Promise<void>;
  delete(key: string): Promise<void>;
}

ctx.provider(key) 返回插件自己的某个模型服务商的地址和 Key,也就是用户在「设置 → AI 服务」中填写的内容。调用同一个服务、用同一个 Key 的工具可以用它,例如 DeepSeek 插件的 web_search 用的就是 DeepSeek 对话的 Key。用户还没保存 Key 时 credential 为空,这时抛出一个 Error,告诉用户去哪里填写。插件读不到其他插件的服务商。

效果与能力需求

效果(ToolEffect)说明一个工具会产生什么后果,宿主据此决定调用前是否要征求用户同意。能力需求(Capability)说明插件需要设备具备什么条件。

type ToolEffect = 'read' | 'write' | 'money' | 'public';

type Capability =
  | 'network'          // 普通的互联网访问
  | 'campus-network'   // 只能从北大校园网访问的主机
  | 'campus-session'   // 经宿主发出的已登录校园 HTTP 请求(不会暴露凭据)
  | 'notifications'
  | 'background'       // 定期在后台运行
  | 'shell'            // 手机上永远不可用;需要它的插件必须远程运行
  | 'filesystem'
  | 'cloud'            // 已配置 PKU harness Cloud 服务;见 PluginDef.cloud
  | 'phone-control'    // 操作手机上的其他 App(Copilot)
  | 'ongoing';         // 常驻通知 / 前台服务(仅 Android)

另见工具与效果和能力需求。

动态值

有些字段(如 instructions 和工具的 description)既可以写成固定的值,也可以写成函数。写成函数时,宿主在构建提示词时调用它并传入 HostServices,你可以据此按当前状态生成内容。resolveDynamic 对这两种写法统一求值。

type Dynamic<T> = T | ((services: HostServices) => T);
function resolveDynamic<T>(value: Dynamic<T>, services: HostServices): T;

DataSourceDef

数据源指向插件里一个适合给小组件和定时检查使用的读取工具,并给出调用参数 args 和缓存时长 ttlMs。

interface DataSourceDef {
  title: string;
  /** 本插件内的工具名。 */
  tool: string;
  args?: Record<string, unknown>;
  ttlMs: number;
}

请求上下文

PluginDef.context 函数在每轮对话前被调用,收到的参数是 ContextInput。

interface ContextInput {
  /** 用户最新的一条消息。 */
  query: string;
  kv: KV;
  services: HostServices;
}

/** 用来撤回先前展示过的事实的上下文行标记。 */
const CONTEXT_MARK = { deleted: '[已删除]', corrected: '[更正]', arrow: ' → ' } as const;

当一条事实被删除或更正时,宿主会追加 - [已删除] old 或 - [更正] old → new 这样的行。它只在历史里仍然显示 old 的对话中追加,而且只追加一次。因此,事实文本本身不能含有这个箭头。

PluginTranslation

插件清单中的名称、描述、标题等默认用中文写。i18n.en 用这个类型提供英文版本,没有写出的字段沿用中文。

type PluginTranslation = {
  name?: string;
  description?: string;
  /** 工具标题,以工具名为键。 */
  tools?: Record<string, { title?: string }>;
  widgets?: Record<string, { title?: string; description?: string }>;
  screens?: Record<string, { title?: string }>;
  /** 设置项,以它打开的页面为键。 */
  settings?: Record<string, { title?: string; subtitle?: string }>;
  /** 设置项(PluginDef.preferences)的标题和说明,以名称为键。 */
  preferences?: Record<string, { title?: string; description?: string; options?: Record<string, string> }>;
  /** `prompt`:提示词类命令的英文模板。 */
  commands?: Record<string, { description?: string; argHint?: string; aliases?: string[]; prompt?: string }>;
  providers?: Record<string, { name?: string; description?: string; credentialHint?: string; models?: Record<string, { name?: string; description?: string }> }>;
  speech?: Record<string, { name?: string; description?: string; credentialHint?: string; models?: Record<string, { name?: string; description?: string }> }>;
  dataSources?: Record<string, { title?: string }>;
  /** 用文字描述(而非主机名)的访问范围,以 `scopes` 中的原文为键。 */
  scopes?: Record<string, string>;
};

设置项

设置项是属于某个插件的设置,例如 Copilot 允许使用的 App。你在 PluginDef.preferences 中声明它,宿主就会把它列入设置注册表,键名为 <plugin id>.<name>,和 App 自己的设置放在一起。用户在插件的页面里修改它,助手通过核心插件 settings 修改它,两者都经过 get 和 set。宿主不需要任何改动。详见架构 → 助手能操作一切。

源码:packages/sdk/src/settings.ts。

type SettingValue = string | number | boolean | string[];

interface PluginPreferenceDef<T extends SettingValue = SettingValue> {
  title: string;
  description?: string;
  kind: 'choice' | 'boolean' | 'text' | 'list';
  options?: { value: string; label: string }[];
  /** 校验新的值(来自助手或页面)。 */
  input: z.ZodType<T>;
  get(ctx: SettingContext): Promise<T>;
  set(ctx: SettingContext, value: T): Promise<void>;
  /** 显示这个设置项的页面路由(默认是插件的第一个页面)。 */
  screen?: string;
}

/** 设置项读写自己的值时能用的东西。 */
interface SettingContext {
  kv: KV;
  services: HostServices;
}

属性

类型

设置项不是机密。不要把 API Key 或密码声明为设置项:助手能读取和修改每一个设置项。宿主自己的机密是标记为 uiOnly 的条目,只有用户能设置。

Copilot 插件这样声明它的截图开关:

import { definePlugin, definePreference } from '@pku-harness/sdk';
import { z } from 'zod';

import { loadCopilotVision, saveCopilotVision } from './preferences';

export const copilotPlugin = definePlugin({
  id: 'copilot',
  // …
  i18n: {
    en: {
      preferences: {
        screenshots: { title: 'Let the AI see screenshots', description: 'For apps that hide their contents from Copilot (Taobao, WeChat).' },
      },
    },
  },
  preferences: {
    screenshots: definePreference({
      title: '允许 AI 查看截图',
      description: '用于不向 Copilot 显示内容的 App(淘宝、微信)。',
      kind: 'boolean',
      input: z.boolean(),
      get: async (c) => (await loadCopilotVision(c.kv)).screenshots,
      set: async (c, screenshots) => { await saveCopilotVision(c.kv, { screenshots }); },
    }),
  },
});

这样,助手就能用 settings__change 和键名 copilot.screenshots 修改它。

PluginLegacy

interface PluginLegacy {
  /** 现在由本插件承接的旧插件 id,例如 ['campus-card', 'netfee']。 */
  pluginIds?: string[];
  /** 旧的完整工具 id → 本插件中的工具名,例如 { 'campus-card.getBalance': 'cardBalance' }。 */
  tools?: Record<string, string>;
  /** 旧的 `<plugin>.<widget>` → 本插件 `contributes.widgets` 中的小组件键。 */
  widgets?: Record<string, string>;
}

合并或重命名插件之后,用它保留旧 id。宿主会把旧插件 id(设置、/p/<old>/… 页面、小组件)和旧工具 id 映射到新插件,一次性改写已保存的小组件和检查任务,并沿用原有设置,每种效果取最严格的授权方式。如果某个旧 id 对应的插件实际仍然安装着,宿主使用那个插件,不会用旧 id 映射遮盖它。

模型服务商

模型服务商为助手提供语言模型,例如 DeepSeek 或任何兼容 OpenAI 接口的服务。插件在 providers 中用 ProviderDef 描述一个服务商。

type ProviderAuth = 'apiKey' | 'account' | 'none';

interface ProviderDef {
  name: string;
  description: string;
  auth: ProviderAuth;
  defaultBaseURL: string;
  editableBaseURL?: boolean;
  models: ProviderModel[];
  defaultModel: string;
  /** API 支持的思考强度,从低到高,例如 DeepSeek 的 ['low', 'high', 'max']。用户在模型选择里选「自动」或其中一档。 */
  efforts?: readonly ('minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max')[];
  credentialHint?: string;
  listModels?: (config: ProviderConfig) => Promise<ProviderModel[]>;
  signIn?: () => Promise<string>;
  /** 用用户自己的凭据读取账户余额。 */
  balance?: (config: ProviderConfig) => Promise<ProviderAccount>;
  /** 返回一个 AI SDK 的 `LanguageModel`(类型写得比较宽松)。 */
  createModel: (config: ProviderConfig) => unknown;
}

interface ProviderConfig {
  baseURL: string;
  model: string;
  /** 来自宿主安全存储的 API key 或账户 token。 */
  credential?: string;
  /** 宿主提供的支持流式响应的 fetch(例如 expo/fetch)。 */
  fetch: typeof fetch;
}

interface ProviderModel {
  id: string;
  name?: string;
  description?: string;
  /** 上下文窗口大小,单位为 token。未知时按 128k 处理。 */
  contextWindow?: number;
  /** 只有列出 'image' 时才允许添加图片附件。 */
  inputModalities?: ('text' | 'image')[];
  pricing?: ModelPricing;
}

interface ModelPricing {
  currency: 'CNY' | 'USD';
  /** 每 100 万 token 的价格。 */
  inputCacheHit: number;
  inputCacheMiss: number;
  output: number;
  /** 服务商时区内的高峰时段:星期几(0 = 周日)和 [startHour, endHour)。 */
  peak?: { multiplier: number; utcOffsetMinutes: number; weekdays: number[]; hours: [number, number][] };
  /** 价格的出处(URL 和核对日期)。 */
  source?: string;
}

interface ProviderBalance { currency: string; total: number; granted?: number; toppedUp?: number }
interface ProviderAccount { available: boolean; balances: ProviderBalance[] }

服务商插件就是设置了 providers 的普通插件,通常不提供工具。下面是一个最小的例子。实际使用中的服务商插件可以参考插件仓库里的 plugins/deepseek/src/index.ts 和 plugins/openai-compatible/src/index.ts。

import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { definePlugin } from '@pku-harness/sdk';

export const myProviderPlugin = definePlugin({
  id: 'my-provider',
  name: '我的模型',
  description: '接入 example.com 的模型。',
  version: '0.1.0',
  scopes: ['api.example.com'],
  tools: {},
  providers: {
    example: {
      name: 'Example',
      description: 'OpenAI-compatible models from example.com',
      auth: 'apiKey',
      defaultBaseURL: 'https://api.example.com/v1',
      models: [{ id: 'example-large', name: 'Example Large', contextWindow: 128_000 }],
      defaultModel: 'example-large',
      createModel: ({ baseURL, model, credential, fetch }) =>
        createOpenAICompatible({ name: 'example', baseURL, apiKey: credential, fetch })(model),
    },
  },
});

语音服务商

语音服务商把用户说的话转成文字,供对话框的语音输入使用。它和模型服务商类似:用户使用自己的 Key,录音从手机直接发送到 baseURL。插件在 speech 中用 SpeechProviderDef 描述每个服务,宿主中的 id 为 <plugin>.<key>。

实时服务实现 stream,用户说话时文字同步出现;只接受完整录音的服务实现 transcribe。使用 transcribe 时,宿主会在用户停顿处把录音切成小段陆续发送,所以用户说完时大部分已经转好。只有 transcribe 能处理作为附件的音频文件。

interface SpeechProviderDef {
  name: string;
  description: string;
  defaultBaseURL: string;
  editableBaseURL?: boolean;
  models: { id: string; name: string; description?: string }[];
  defaultModel: string;
  credentialHint?: string;
  /** 申请 Key 的网址,显示在输入框下方。 */
  keyURL?: string;
  /** 改用本插件中这个 id 的模型服务商的 Key,不需要单独的 Key。 */
  credentialFrom?: string;
  transcribe?: (config: SpeechConfig, audio: SpeechAudio, signal?: AbortSignal) => Promise<string>;
  stream?: (config: SpeechConfig, handlers: SpeechStreamHandlers) => Promise<SpeechStream>;
}

interface SpeechConfig {
  baseURL: string;
  model: string;
  /** 来自宿主安全存储的 API Key。 */
  credential?: string;
  /** 宿主提供的 fetch(在 FormData 中上传 `SpeechAudio.file`)。 */
  fetch: typeof fetch;
  /** 打开一个带请求头的 WebSocket(供在握手时鉴权的实时服务使用)。 */
  webSocket: (url: string, headers: Record<string, string>) => WebSocket;
}

/** 要转写的录音(除非 `mimeType` 另有说明,否则为 16 kHz 单声道 WAV)。 */
interface SpeechAudio {
  file: Blob;
  uri: string;
  name: string;
  mimeType: string;
  durationMs?: number;
}

interface SpeechStreamHandlers {
  /** 到目前为止的完整转写(已确认的文字加上尚未确定的结尾),替换上一次的结果。 */
  onText: (text: string) => void;
}

interface SpeechStream {
  /** 100 毫秒的 16 kHz 单声道 16 位小端 PCM,base64 编码。 */
  send: (pcmBase64: string) => void;
  /** 音频已结束:返回最终的转写结果。 */
  finish: () => Promise<string>;
  /** 放弃这次会话,不要结果。 */
  cancel: () => void;
}

packages/sdk/src/speech.ts 提供三个辅助函数:

  • openAITranscribe(config, audio, signal?) 发送 POST {baseURL}/audio/transcriptions(multipart 中带 file 和 model),返回清理后的 text。硅基流动、OpenAI 和大多数兼容服务都使用这种格式,所以插件可以直接把它作为 transcribe。
  • cleanTranscript(text) 去掉 <|zh|> 这类控制标记,并去除多余空白。
  • speechErrorMessage(status, body) 把错误响应整理成 "<status>: <message>"。

下面是精简后的硅基流动插件:

import { definePlugin, openAITranscribe } from '@pku-harness/sdk';

export const siliconflowPlugin = definePlugin({
  id: 'siliconflow',
  name: '硅基流动',
  description: '用硅基流动的语音识别把语音输入转成文字(你自己的 API Key)。',
  version: '0.1.0',
  requires: ['network'],
  icon: 'mic',
  scopes: ['api.siliconflow.cn'],
  tools: {},
  speech: {
    asr: {
      name: '硅基流动',
      description: 'SenseVoice 与 TeleSpeech,国内可直接访问;文字按句子出现',
      defaultBaseURL: 'https://api.siliconflow.cn/v1',
      models: [
        { id: 'FunAudioLLM/SenseVoiceSmall', name: 'SenseVoice', description: '普通话、英语、粤语、日语、韩语' },
        { id: 'TeleAI/TeleSpeechASR', name: 'TeleSpeech', description: '方言识别更好' },
      ],
      defaultModel: 'FunAudioLLM/SenseVoiceSmall',
      credentialHint: '在 cloud.siliconflow.cn 创建的 API Key',
      keyURL: 'https://cloud.siliconflow.cn/account/ak',
      transcribe: openAITranscribe,
    },
  },
});

实时服务的写法见插件仓库中的 plugins/aliyun-bailian/src/realtime.ts。

如果语音服务和模型用的是同一个 Key(例如 DeepSeek),把 credentialFrom 设为插件自己 providers 中的某个键。宿主就不再显示 Key 输入框,而是把这个服务商的 Key 放进 SpeechConfig.credential;Key 还没填写时,语音输入页面会提示用户先到 AI 服务设置中填写。示例见 plugins/deepseek/src/index.ts。

OCR 服务商

OCR 服务商负责识别手机自己读不出文字的文档,目前就是没有文字层的扫描版 PDF。插件在 ocr 中用 OcrProviderDef 描述每个服务。它总是使用插件自己某个模型服务商(credentialFrom)的地址和 Key,用户不需要额外设置。只有当用户附上这类文件、插件已开启、并且这个服务商已有 Key 时,宿主才会调用 recognize;否则文件仍像以前一样被拒绝。

interface OcrProviderDef {
  name: string;
  /** 使用哪个本插件模型服务商(`PluginDef.providers` 的键)的地址和 Key。 */
  credentialFrom: string;
  /** 按阅读顺序排列的识别结果,Markdown 格式,每页以 `## Page n` 标题开头。 */
  recognize: (
    config: { baseURL: string; credential: string; fetch: typeof fetch },
    doc: OcrDocument,
    signal?: AbortSignal,
  ) => Promise<string>;
}

/** 要识别的文档:用户附上的本地文件(`file` 可以放进 FormData 上传)。 */
interface OcrDocument {
  file: Blob;
  uri: string;
  name: string;
  mimeType: string;
}

返回的 Markdown 要在每页前加 ## Page n 标题,这样助手可以像读其他文档一样引用和翻页。recognize 抛出错误时,附件会失败并提示「<name> 未能识别这个扫描版 PDF:<message>」,所以错误消息要写得让用户能看懂。name 会原样显示给用户。完整示例见 plugins/deepseek/src/tools.ts 中 DeepSeek 插件的 deepseekOcr。

本页目录