PKU harness

内置插件

用 definePlugin 编写随 App 一起发布的插件。本页通过一个完整示例,依次介绍工具、数据源、页面、设置项、小组件、快捷指令、英文文字和测试。

内置插件(bundled plugin)是 pku-harness-plugins/plugins/<id> 下的一个 TypeScript 模块,只依赖 @pku-harness/sdk。它在手机上的 App 内部运行,只能通过「宿主服务」(host services)访问 App 的其他部分。宿主服务是 App 传给每个插件的一组对象,用于发起校园请求、发送通知、管理待办和定时任务等。和插件文档不同,内置插件可以添加原生界面。

内置插件只能随 App 版本发布,所以请先开一个 issue,说明插件做什么,以及为什么不能写成插件文档或远程插件。

大多数校园服务已经改用插件文档

TypeScript 用于 App 自身的功能:界面、看板、定时任务、待办、日程、课表、记忆、插件管理、模型服务商、身份验证器和付款码页面。插件语言暂时表达不了的接入也用 TypeScript。官方的校园接入 classrooms、campus-events、grades、canteen、shuttle 和 notices 都是随 App 内置的插件文档,其余的正在迁移。如果你的插件只是向校内或公开接口发请求、再提取数据,请改写 PLUGIN.md。如果它需要插件语言还没有的功能,请提议新增一项宿主能力(ability)。

本页从头编写一个完整的插件 focus。它记录专注时段(比如做一小时作业),并显示你今天一共专注了多少分钟。这个插件不调用任何外部服务,可以直接运行。如果要对接校园服务,就把其中的存储调用换成 ctx.services.campus.request(...)(见校园会话)。

你需要 bun,并了解 TypeScript 和一些 React Native。第 1 到第 6 步都在你电脑上的插件仓库里完成,最后一节会在模拟器或手机上的 App 里运行这个插件。

目录结构

插件放在 plugins/ 下自己的文件夹里。除此之外,你还要修改两个共用文件:内置插件列表 plugins/index.ts,以及 registry/registry.json。

package.json
index.ts
store.ts
store.test.ts
strings.ts
screen.tsx
widget.tsx
index.ts

1. package.json

plugins/focus/package.json
{
  "name": "@pku-harness/plugin-focus",
  "private": true,
  "version": "0.1.0",
  "description": "Focus sessions and today's total, on the device",
  "type": "module",
  "main": "src/index.ts",
  "license": "MIT",
  "pkuHarness": { "builtin": true },
  "peerDependencies": {
    "@pku-harness/sdk": "*",
    "zod": "^4.0.0",
    "react": "*",
    "react-native": "*"
  }
}

每个插件都是插件工作区里的一个包,工作区会把 @pku-harness/sdk 解析到 packages/sdk/src。所有包都是私有的,不会发布到 npm。依赖写成 peer dependencies,因为它们由 App 提供。添加其他运行时依赖之前,请先和我们商量,因为所有代码都会打进 App 的安装包,让它变大。

2. 先写纯逻辑

先写不依赖 React Native 的逻辑,放在普通模块里。导入了 react-native 的模块,不做 mock 的话 bun test 无法加载,所以把逻辑分开写,测试起来就很方便。

这里的 store.ts 把专注时段保存在插件的键值(KV)存储里,并计算今天的分钟数。minutesOn 是一个纯函数:它把时间 now 当作参数传入,而不是自己读取时钟,所以测试可以传入任意时间。

plugins/focus/src/store.ts
import type { KV } from '@pku-harness/sdk';

export type Session = { id: string; label: string; startedAt: number; endedAt: number | null };

const KEY = 'sessions';

export async function loadSessions(kv: KV): Promise<Session[]> {
  return (await kv.get<Session[]>(KEY)) ?? [];
}

export async function saveSessions(kv: KV, list: Session[]): Promise<void> {
  await kv.set(KEY, list.slice(-500));
}

/** `now` 所在本地日期的专注分钟数,进行中的专注算到 `now` 为止。纯函数。 */
export function minutesOn(list: Session[], now: number): number {
  const d = new Date(now);
  const start = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
  const end = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime();
  let ms = 0;
  for (const s of list) {
    const a = Math.max(s.startedAt, start);
    const b = Math.min(s.endedAt ?? now, end);
    if (b > a) ms += b - a;
  }
  return Math.round(ms / 60_000);
}

export const running = (list: Session[]) => list.find((s) => s.endedAt == null) ?? null;

3. 插件本体

src/index.ts 把各部分组合起来。它定义了三个工具(start、stop 和 today)、一个供小组件使用的数据源,以及插件添加到 App 里的页面、设置项、小组件和快捷指令。

plugins/focus/src/index.ts
import { definePlugin, defineTool } from '@pku-harness/sdk';
import { z } from 'zod';

import { FocusScreen } from './screen';
import { loadSessions, minutesOn, running, saveSessions } from './store';
import { FocusWidget } from './widget';

/** 专注:开始、结束专注时段,查看今天的总时长。所有数据都留在设备上。 */
export const focusPlugin = definePlugin({
  id: 'focus',
  name: '专注',
  description: '记录专注时段,查看今天专注了多久。',
  version: '0.1.0',
  icon: 'clock',
  requires: [],
  scopes: [],
  i18n: {
    en: {
      name: 'Focus',
      description: 'Track focus sessions and see how long you focused today.',
      tools: { start: { title: 'Start focusing' }, stop: { title: 'Stop focusing' }, today: { title: 'Focus today' } },
      widgets: { today: { title: 'Focus today', description: 'Minutes focused today and the running session, if any.' } },
      screens: { main: { title: 'Focus' } },
      settings: { main: { title: 'Focus', subtitle: 'Focus sessions' } },
      commands: { focus: { description: 'Open focus sessions', aliases: ['pomodoro'] } },
      dataSources: { today: { title: 'Focus today' } },
    },
  },
  instructions: `专注记录:用户说"开始学习/专注""我学完了"时用 focus__start / focus__stop;问"今天学了多久"时用 focus__today。`,
  dataSources: {
    today: { title: '今日专注', tool: 'today', ttlMs: 60_000 },
  },
  contributes: {
    screens: { main: { title: '专注', component: FocusScreen } },
    settings: [{ screen: 'main', title: '专注', subtitle: '专注时段', icon: 'clock' }],
    widgets: {
      today: {
        title: '今日专注',
        description: '今天专注的分钟数,以及正在进行的专注。',
        sizes: ['sm', 'md', 'lg'],
        defaultSize: 'sm',
        component: FocusWidget,
      },
    },
    commands: {
      focus: { description: '打开专注', aliases: ['专注', '番茄钟'], run: (_input, { navigate }) => navigate('/p/focus/main') },
    },
  },
  tools: {
    start: defineTool({
      title: '开始专注',
      description: 'Start a focus session now. Fails if one is already running (stop it first).',
      input: z.object({ label: z.string().trim().max(40).optional().describe('what the user focuses on, e.g. "高数作业"') }),
      effect: 'write',
      execute: async ({ label }, ctx) => {
        const list = await loadSessions(ctx.kv);
        if (running(list)) throw new Error('已经有一个正在进行的专注,请先结束它');
        const session = { id: Date.now().toString(36), label: label ?? '', startedAt: Date.now(), endedAt: null };
        await saveSessions(ctx.kv, [...list, session]);
        return { started: true, label: session.label };
      },
    }),
    stop: defineTool({
      title: '结束专注',
      description: 'Stop the running focus session. Returns its length in minutes.',
      input: z.object({}),
      effect: 'write',
      execute: async (_input, ctx) => {
        const list = await loadSessions(ctx.kv);
        const current = running(list);
        if (!current) return { stopped: false, reason: 'nothing running' };
        const now = Date.now();
        await saveSessions(ctx.kv, list.map((s) => (s.id === current.id ? { ...s, endedAt: now } : s)));
        return { stopped: true, minutes: Math.round((now - current.startedAt) / 60_000) };
      },
    }),
    today: defineTool({
      title: '今日专注',
      description: 'Minutes focused today (device-local day), including the running session, and the running session if any.',
      input: z.object({}),
      effect: 'read',
      execute: async (_input, ctx) => {
        const list = await loadSessions(ctx.kv);
        const current = running(list);
        return { minutes: minutesOn(list, Date.now()), running: current ? { label: current.label, startedAt: current.startedAt } : null };
      },
    }),
  },
});

definePlugin 接收一个描述整个插件的对象,其中各部分的作用如下:

字段作用
id注册表 id,格式为 ^[a-z0-9][a-z0-9-]{1,40}$。工具 id 会是 focus.start,模型看到的名字是 focus__start。
name、description、工具的 title中文,展示给用户。英文写在 i18n.en 里。
instructions加在助手的系统提示词(system prompt)里,每次请求都会发送,所以要写得短。提到工具时用模型名。
dataSources适合给小组件和定时检查用的读取工具,带缓存时长。
contributes.screens挂载在 /p/focus/main(presentation: 'modal' 时为 /m/focus/<screen>)。
contributes.settings设置页里的一行,点开进入某个页面。
contributes.widgets一个原生的看板小组件(widget),助手会读取它的 description。
contributes.commands聊天输入框里的 /focus 快捷指令(这里是一个本地操作)。
tools插件的 API:每个工具一种效果(effect),用 zod 定义输入,返回数据。

4. 界面

页面和小组件通过 host 属性拿到 App 的组件,从不直接从 App 导入。这样插件就不依赖 App 的代码,App 改版后插件照样能用。页面、小组件和字符串表的写法如下:

plugins/focus/src/screen.tsx
import type { PluginScreenProps } from '@pku-harness/sdk';

import { strings } from './strings';

type Today = { minutes: number; running: { label: string; startedAt: number } | null };

/** 开始和结束专注。以用户身份调用插件自己的工具。 */
export function FocusScreen({ host, invoke }: PluginScreenProps) {
  const { Screen, Stat, Button, Column, Text } = host.components;
  const s = strings[host.useLocale()];
  const data = host.useToolData<Today>('focus.today', {}, { ttlMinutes: 1 });
  const active = data.value?.running ?? null;

  const toggle = async () => {
    host.haptics.tap();
    await invoke(active ? 'focus.stop' : 'focus.start', {});
    await data.refresh();
  };

  return (
    <Screen title={s.today}>
      <Column gap="lg">
        <Stat label={s.today} value={data.value?.minutes ?? null} suffix={host.useLocale() === 'en' ? ' min' : ' 分钟'} icon="clock" />
        <Text tone="muted">{active ? s.running(active.label) : s.idle}</Text>
        <Button title={active ? s.stop : s.start} variant={active ? 'secondary' : 'primary'} loading={data.fetching} onPress={() => void toggle()} />
      </Column>
    </Screen>
  );
}
plugins/focus/src/widget.tsx
import type { PluginWidgetProps } from '@pku-harness/sdk';
import { View } from 'react-native';

import { strings } from './strings';

type Today = { minutes: number; running: { label: string; startedAt: number } | null };

/** 今天的专注分钟数;由宿主负责刷新(小组件里不要轮询)。 */
export function FocusWidget({ host, size }: PluginWidgetProps) {
  const { Text, Skeleton } = host.components;
  const t = host.useTheme();
  const s = strings[host.useLocale()];
  const data = host.useToolData<Today>('focus.today', {}, { ttlMinutes: 1 });
  if (data.status === 'loading') return <Skeleton width="50%" height={28} />;
  if (!data.value) return <Text tone="muted">{data.error ?? ''}</Text>;
  return (
    <View style={{ gap: t.spacing.xs }}>
      {size === 'sm' ? (
        <Text variant="micro" tone="faint">
          {s.today}
        </Text>
      ) : null}
      <Text variant={size === 'lg' ? 'numberXL' : 'numberL'}>{s.minutes(data.value.minutes)}</Text>
      <Text variant="caption" tone={data.value.running ? 'accent' : 'muted'} numberOfLines={1}>
        {data.value.running ? s.running(data.value.running.label) : s.idle}
      </Text>
    </View>
  );
}
plugins/focus/src/strings.ts
import { defineStrings } from '@pku-harness/sdk';

type Strings = {
  today: string;
  minutes: (n: number) => string;
  start: string;
  stop: string;
  running: (label: string) => string;
  idle: string;
};

export const strings = defineStrings<Strings>({
  'zh-Hans': {
    today: '今日专注',
    minutes: (n) => `${n} 分钟`,
    start: '开始专注',
    stop: '结束',
    running: (label) => (label ? `正在专注:${label}` : '正在专注'),
    idle: '还没有开始',
  },
  en: {
    today: 'Focus today',
    minutes: (n) => (n === 1 ? '1 min' : `${n} min`),
    start: 'Start focusing',
    stop: 'Stop',
    running: (label) => (label ? `Focusing on ${label}` : 'Focusing'),
    idle: 'Not started yet',
  },
});

页面用 invoke 调用插件自己的工具。invoke 以用户身份(actor: 'user')运行工具:输入照常校验,权限照常生效,但不会弹出授权确认,因为用户刚刚亲手点了按钮。执行修改操作之后,要刷新页面上正在显示的实时查询,就像 toggle 里调用 data.refresh() 那样。

5. 测试

测试覆盖第 2 步的纯函数 minutesOn。由于它把 now 当作参数,每个测试都可以固定时间。

plugins/focus/src/store.test.ts
import { describe, expect, test } from 'bun:test';

import { minutesOn, type Session } from './store';

const at = (h: number, m = 0) => new Date(2026, 9, 21, h, m).getTime();

describe('minutesOn', () => {
  test('sums finished and running sessions of the day', () => {
    const list: Session[] = [
      { id: 'a', label: '高数', startedAt: at(8), endedAt: at(8, 50) },
      { id: 'b', label: '英语', startedAt: at(10), endedAt: null },
    ];
    expect(minutesOn(list, at(10, 30))).toBe(80);
  });

  test('cuts a session that started yesterday at midnight', () => {
    const list: Session[] = [{ id: 'c', label: '', startedAt: at(0) - 30 * 60_000, endedAt: at(0, 20) }];
    expect(minutesOn(list, at(12))).toBe(20);
  });
});

6. 注册插件

App 只加载 plugins/index.ts 里列出的插件,所以要把你的插件加进这个列表。列表的顺序也就是插件在设置页和系统提示词里的顺序:

plugins/index.ts
import { focusPlugin } from './focus/src';

export const builtinPlugins: PluginDef[] = [
  // …
  focusPlugin,
];

然后在 registry/registry.json 里加一条记录,插件市场展示的就是这份列表。图标不能和其他条目重复:

{
  "id": "focus",
  "name": "专注",
  "description": "记录专注时段,查看今天专注了多久。",
  "author": "your-github-name",
  "source": "https://github.com/pkuinfo/pku-harness-plugins/tree/main/plugins/focus",
  "kind": "builtin",
  "icon": "clock",
  "requires": [],
  "scopes": [],
  "tags": []
}

最后,运行和持续集成(CI)相同的检查:

bun test                          # 你的测试,以及多语言覆盖测试(plugins/i18n.test.ts)
bunx tsc --noEmit -p .            # 对 SDK 和插件做类型检查
bun scripts/validate-registry.ts  # 检查 schema、id 和图标是否唯一、内置插件是否存在

在 App 里运行

App 以 git 子模块 plugins/ 的形式引入 pku-harness-plugins,并从这里加载 @pku-harness/sdk 和内置插件。所以要在 App 里看到你的插件,就要在这个子模块里开发。

连同子模块克隆 App

git clone --recurse-submodules https://github.com/pkuinfo/pku-harness.git
cd pku-harness
bun install

在 plugins/ 里开发

创建 plugins/plugins/focus/,也就是子模块里的 plugins/ 目录。在 plugins/plugins/index.ts 里注册插件,然后运行 App:

bun run android           # 在模拟器或设备上运行开发版
# 或者,已经装好开发版时:
bunx expo start --dev-client

Metro 是 React Native 的 JavaScript 打包工具,你保存文件后它会自动重新加载 App。要推送分支,就把子模块的远程仓库指向你的 fork(git -C plugins remote add …)。pull request 提交到 pku-harness-plugins,之后再由 App 仓库更新子模块指向的提交。

试一试

有四种方式可以试用:打开「设置 → 专注」;在看板的小组件库里添加小组件;在对话里输入 /focus;或者对助手说「开始专注高数作业」。

如果你没有模型的 API key,可以用模拟模型。在设备上,有一种只在开发版里可用的深链接,可以直接运行某个工具:pkuharness://dev?tool=focus.today&input={}。

规则

  • 必须只从 @pku-harness/sdk、zod、react 和 react-native 导入,外加你自己的文件和 plugins/shared/(内置插件之间共享的代码,比如 campus-card.ts)。不能导入 App 的代码,也不能导入其他插件目录里的文件。
  • 必须遵守工具规则、多语言规则和能力需求规则。
  • 必须用 host.useToolData 加载小组件和页面的数据,不要轮询。
  • 应当让 instructions 和 context 尽量短,因为每次请求都要为它们消耗 token。

本页目录