feat(plugin-sdk-tamagotchi,airi-plugin-game-chess): tools api, gamelet api, init chess gamelet

This commit is contained in:
Neko Ayaka
2026-04-21 17:17:20 +08:00
parent 0294dad6ef
commit 3390122085
12 changed files with 670 additions and 3 deletions
+4
View File
@@ -31,6 +31,7 @@ words:
- baichuan
- baiducloud
- bailian
- bestmove
- bigserial
- bilibili
- Bitstream
@@ -110,6 +111,7 @@ words:
- flexsearch
- formkit
- frontmatter
- gamelet
- Genshin
- giteeai
- gltf
@@ -289,6 +291,7 @@ words:
- ssml
- staticlib
- stepfun
- stockfish
- sumimakito
- supergroup
- superjson
@@ -322,6 +325,7 @@ words:
- valibot
- vaul
- velin
- vieval
- vishot
- VITE
- vitepress
@@ -0,0 +1,49 @@
{
"name": "@proj-airi/plugin-sdk-tamagotchi",
"type": "module",
"version": "0.9.0",
"private": true,
"description": "Tamagotchi-specific DX helpers for Project AIRI plugins",
"author": {
"name": "Moeru AI Project AIRI Team",
"email": "airi@moeru.ai",
"url": "https://github.com/moeru-ai"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/moeru-ai/airi.git",
"directory": "packages/plugin-sdk-tamagotchi"
},
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"./gamelet": {
"types": "./dist/gamelet/index.d.mts",
"default": "./dist/gamelet/index.mjs"
},
"./tools": {
"types": "./dist/tools/index.d.mts",
"default": "./dist/tools/index.mjs"
}
},
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"files": [
"README.md",
"dist",
"package.json"
],
"scripts": {
"test": "vitest",
"typecheck": "tsc --noEmit",
"build": "tsdown"
},
"dependencies": {
"@proj-airi/plugin-sdk": "workspace:*",
"valibot": "catalog:",
"xsschema": "catalog:"
}
}
@@ -0,0 +1,184 @@
import type { ContextInit } from '@proj-airi/plugin-sdk'
import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host'
/**
* Describes a widget hint contributed by a gamelet to the tamagotchi host.
*
* Use when:
* - A gamelet should expose one or more mountable widget surfaces
*
* Expects:
* - `id` is stable within the gamelet
*
* Returns:
* - A serializable host hint for widget registration
*/
export interface GameletWidgetDefinition {
id: string
kind: string
}
/**
* Describes host-managed configuration defaults declared by a gamelet.
*
* Use when:
* - A gamelet wants the host to persist validated defaults
*
* Expects:
* - `defaults` is JSON-compatible
*
* Returns:
* - The configuration declaration stored in the gamelet module config
*/
export interface GameletConfigDefinition<TDefaults extends HostDataRecord = HostDataRecord> {
defaults?: TDefaults
}
/**
* Describes the friendly tamagotchi authoring shape for a gamelet.
*
* Use when:
* - A plugin wants to register one UI-driven gamelet without raw kit/module calls
*
* Expects:
* - `entrypoint` points at the plugin-provided UI asset entry
*
* Returns:
* - A declarative gamelet definition consumed by {@link defineGamelet}
*/
export interface GameletDefinition<TDefaults extends HostDataRecord = HostDataRecord> {
id: string
title: string
entrypoint: string
widgets?: GameletWidgetDefinition[]
config?: GameletConfigDefinition<TDefaults>
}
/**
* Represents one registered tamagotchi gamelet.
*
* Use when:
* - Tools or plugin bootstrap code need to check whether host registration succeeded
*
* Expects:
* - Returned values come from a previously completed {@link defineGamelet} call
*
* Returns:
* - A minimal handle that keeps host lifecycle concerns internal
*/
export interface DefinedGamelet {
id: string
isSupported: () => Promise<boolean>
}
/**
* Normalizes one author-facing gamelet widget into host-safe binding config data.
*
* Before:
* - `{ id: 'main-board', kind: 'primary' }`
*
* After:
* - `{ id: 'main-board', kind: 'primary' }`
*/
function createWidgetHintRecord(definition: GameletWidgetDefinition): HostDataRecord {
return {
id: definition.id,
kind: definition.kind,
}
}
/**
* Normalizes one gamelet definition into binding config stored in `kit.gamelet`.
*
* Before:
* - Friendly authoring fields that may include optional properties and typed helper objects
*
* After:
* - A plain `HostDataRecord` with only host-safe values and no `undefined` properties
*/
function buildModuleConfig<TDefaults extends HostDataRecord>(definition: GameletDefinition<TDefaults>): HostDataRecord {
return {
title: definition.title,
entrypoint: definition.entrypoint,
widgets: (definition.widgets ?? []).map(createWidgetHintRecord),
widget: {
mount: 'iframe',
iframe: {
assetPath: definition.entrypoint,
sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups',
},
windowSize: {
width: 980,
height: 840,
minWidth: 640,
minHeight: 640,
},
},
...(definition.config
? {
config: {
defaults: definition.config.defaults ?? {},
},
}
: {}),
}
}
/**
* Registers a tamagotchi gamelet through the low-level kit/binding APIs.
*
* Use when:
* - A plugin targets stage-tamagotchi and wants one-step gamelet registration
*
* Expects:
* - The host exposes the `kit.gamelet` kit through `ctx.apis.kits`
*
* Returns:
* - A handle that reports whether the host supports the gamelet kit
*/
export async function defineGamelet<TDefaults extends HostDataRecord = HostDataRecord>(
ctx: Pick<ContextInit, 'apis'>,
definition: GameletDefinition<TDefaults>,
): Promise<DefinedGamelet> {
const kits = await ctx.apis.kits.list()
const supported = kits.some(kit => kit.kitId === 'kit.gamelet')
if (!supported) {
return {
id: definition.id,
async isSupported() {
return false
},
}
}
const existingModules = await ctx.apis.bindings.list()
const existingModule = existingModules.find(module => module.moduleId === definition.id)
const config = buildModuleConfig(definition)
if (!existingModule) {
await ctx.apis.bindings.announce({
moduleId: definition.id,
kitId: 'kit.gamelet',
kitModuleType: 'gamelet',
config,
})
}
else {
await ctx.apis.bindings.update({
moduleId: definition.id,
config,
})
}
await ctx.apis.bindings.activate({
moduleId: definition.id,
})
return {
id: definition.id,
async isSupported() {
return true
},
}
}
@@ -0,0 +1,119 @@
import { object, optional, string } from 'valibot'
import { describe, expect, it, vi } from 'vitest'
import { defineGamelet, defineToolset } from './index'
describe('plugin-sdk-tamagotchi', () => {
/**
* @example
* expect(registerBinding).toHaveBeenCalledWith(expect.objectContaining({ kitId: 'kit.gamelet' }))
* expect(registerTool).toHaveBeenCalledWith(expect.objectContaining({ tool: expect.any(Object) }))
*/
it('should allow a plugin to define a gamelet and toolset without raw kit or module calls', async () => {
const registerBinding = vi.fn()
const registerTool = vi.fn()
const ctx = {
apis: {
tools: {
register: registerTool,
},
kits: {
list: async () => [
{
kitId: 'kit.gamelet',
version: '1.0.0',
runtimes: ['electron'],
capabilities: [],
},
],
getCapabilities: async () => [
{
key: 'kit.gamelet.runtime',
actions: ['announce', 'activate', 'update'],
},
],
},
bindings: {
list: async () => [],
announce: registerBinding,
update: registerBinding,
activate: registerBinding,
},
},
}
const gamelet = await defineGamelet(ctx as never, {
id: 'chess',
title: 'Chess',
entrypoint: './ui/index.html',
widgets: [
{
id: 'main-board',
kind: 'primary',
},
],
})
await defineToolset(ctx as never, {
tools: [
{
id: 'play_chess',
title: 'Play Chess',
description: 'Open chess.',
inputSchema: object({
opening: optional(string()),
}),
execute: async () => ({ ok: true }),
},
],
})
expect(gamelet).toBeDefined()
expect(registerBinding).toHaveBeenCalledWith({
moduleId: 'chess',
kitId: 'kit.gamelet',
kitModuleType: 'gamelet',
config: {
title: 'Chess',
entrypoint: './ui/index.html',
widgets: [
{
id: 'main-board',
kind: 'primary',
},
],
widget: {
mount: 'iframe',
iframe: {
assetPath: './ui/index.html',
sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups',
},
windowSize: {
width: 980,
height: 840,
minWidth: 640,
minHeight: 640,
},
},
},
})
expect(registerBinding).toHaveBeenCalledWith({
moduleId: 'chess',
})
expect(registerTool).toHaveBeenCalled()
expect(registerTool).toHaveBeenCalledWith(expect.objectContaining({
tool: expect.objectContaining({
id: 'play_chess',
parameters: expect.objectContaining({
type: 'object',
properties: expect.objectContaining({
opening: expect.objectContaining({
type: 'string',
}),
}),
}),
}),
}))
})
})
@@ -0,0 +1,2 @@
export * from './gamelet'
export * from './tools'
@@ -0,0 +1,211 @@
import type { ContextInit } from '@proj-airi/plugin-sdk'
import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host'
import type { JsonSchema, Schema as StandardSchemaV1 } from 'xsschema'
import { hostDataRecordSchema } from '@proj-airi/plugin-sdk/plugin-host'
import { parse } from 'valibot'
import { toJsonSchema } from 'xsschema'
/**
* Describes the host services available while checking or executing a plugin tool.
*
* Use when:
* - Tool logic needs to orchestrate gamelet surfaces
*
* Expects:
* - All methods are provided by the host runtime, not the plugin
*
* Returns:
* - A runtime capability surface for tool execution
*/
export interface ToolExecutionContext {
gamelets: {
open: (id: string, params?: Record<string, unknown>) => Promise<void>
configure: (id: string, patch: Record<string, unknown>) => Promise<void>
close: (id: string) => Promise<void>
isOpen: (id: string) => boolean
}
// TODO:
// Add character/runtime orchestration APIs after the gamelet/tool path is stable.
}
/**
* Describes renderer-side discovery hints for a plugin tool.
*
* Use when:
* - Tool pickers or activation matchers need keywords and regexp patterns
*
* Expects:
* - `patterns` are JavaScript `RegExp` instances and will be serialized by source
*
* Returns:
* - Optional metadata separate from xsai execution schema
*/
export interface PluginToolActivationDefinition {
keywords?: string[]
patterns?: RegExp[]
}
/**
* Describes one high-level plugin tool declaration.
*
* Use when:
* - A plugin wants one declaration to drive host registry and xsai schema generation
*
* Expects:
* - `inputSchema` is either an xsschema-compatible schema or a prebuilt JSON Schema object
*
* Returns:
* - A friendly authoring record consumed by {@link defineToolset}
*/
export interface PluginToolDefinition<TInputSchema = unknown> {
id: string
title: string
description: string
activation?: PluginToolActivationDefinition
inputSchema: TInputSchema
isAvailable?: (context: ToolExecutionContext) => Promise<boolean> | boolean
execute: (input: unknown, context: ToolExecutionContext) => Promise<unknown> | unknown
}
/**
* Declares a set of plugin tools in one call.
*
* Use when:
* - A plugin registers all of its tools during bootstrap
*
* Expects:
* - `ctx.apis.tools.register` is available from the host
*
* Returns:
* - Resolves once every tool has been registered with the host
*/
export interface DefineToolsetOptions<TInputSchema = unknown> {
tools: Array<PluginToolDefinition<TInputSchema>>
}
function createToolExecutionContext(): ToolExecutionContext {
return {
gamelets: {
async open() {},
async configure() {},
async close() {},
isOpen: () => false,
},
}
}
/**
* Checks whether one unknown value already looks like a JSON Schema root object.
*
* Use when:
* - Tool authoring code may pass either a prebuilt JSON Schema or a Standard Schema
*
* Expects:
* - JSON Schema roots are plain objects and commonly include `type`, `properties`, or `$schema`
*
* Returns:
* - `true` when the value should be cloned directly instead of converted with `toJsonSchema`
*/
function isJsonSchemaRecord(inputSchema: unknown): inputSchema is JsonSchema {
if (!inputSchema || typeof inputSchema !== 'object' || Array.isArray(inputSchema)) {
return false
}
return 'type' in inputSchema || 'properties' in inputSchema || '$schema' in inputSchema || '$ref' in inputSchema
}
/**
* Checks whether one unknown value implements the Standard Schema contract.
*
* Use when:
* - Tool authoring code passes a Valibot or other standard-schema-compatible validator
*
* Expects:
* - Standard schemas expose the `~standard` marker used by `xsschema`
*
* Returns:
* - `true` when the value can be converted by {@link toJsonSchema}
*/
function isStandardSchema(inputSchema: unknown): inputSchema is StandardSchemaV1 {
return Boolean(
inputSchema
&& typeof inputSchema === 'object'
&& '~standard' in inputSchema,
)
}
/**
* Validates that one plain object can cross the plugin-host boundary as `HostDataRecord`.
*
* Before:
* - A generic schema-shaped object with unknown property value types
*
* After:
* - The same object narrowed to `HostDataRecord` after runtime validation succeeds
*/
function toHostDataRecord(value: object): HostDataRecord {
parse(hostDataRecordSchema, value)
return value as HostDataRecord
}
/**
* Normalizes tool parameter schemas into the host-safe record shape expected by plugin-sdk.
*
* Before:
* - A Standard Schema instance or a JSON Schema-like authoring object
*
* After:
* - A validated `HostDataRecord` safe to store in the host tool registry
*/
async function serializeToolParameters(inputSchema: unknown): Promise<HostDataRecord> {
if (isStandardSchema(inputSchema)) {
return toHostDataRecord(await toJsonSchema(inputSchema))
}
if (isJsonSchemaRecord(inputSchema)) {
return toHostDataRecord(structuredClone(inputSchema))
}
throw new TypeError('Tool input schema must be a JSON Schema object or a Standard Schema instance.')
}
/**
* Registers one or more plugin tools with the tamagotchi host wrapper.
*
* Use when:
* - A plugin wants to declare xsai-compatible tools without low-level host records
*
* Expects:
* - The caller supplies stable tool ids and schemas
*
* Returns:
* - Resolves after all tool registrations complete
*/
export async function defineToolset(
ctx: Pick<ContextInit, 'apis'>,
options: DefineToolsetOptions,
): Promise<void> {
const executionContext = createToolExecutionContext()
for (const definition of options.tools) {
await ctx.apis.tools.register({
tool: {
id: definition.id,
title: definition.title,
description: definition.description,
activation: {
keywords: definition.activation?.keywords ?? [],
patterns: (definition.activation?.patterns ?? []).map(pattern => pattern.source),
},
parameters: await serializeToolParameters(definition.inputSchema),
},
availability: definition.isAvailable
? () => definition.isAvailable?.(executionContext)
: undefined,
execute: input => definition.execute(input, executionContext),
})
}
}
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": [
"ESNext",
"DOM"
],
"module": "ESNext",
"moduleResolution": "bundler",
"types": [
"node"
],
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": [
"src/**/*.ts"
]
}
@@ -0,0 +1,11 @@
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: [
'src/index.ts',
'src/gamelet/index.ts',
'src/tools/index.ts',
],
dts: true,
format: 'esm',
})
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
})
@@ -0,0 +1,37 @@
{
"name": "@proj-airi/airi-plugin-game-chess",
"type": "module",
"version": "0.1.0",
"private": true,
"description": "Chess plugin for AIRI gamelet/widget runtime",
"scripts": {
"test": "vitest run --root . --config vitest.config.ts",
"eval:run": "vieval run --root . --config ./vieval.config.ts",
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@moeru/std": "catalog:",
"@proj-airi/plugin-sdk-tamagotchi": "workspace:*",
"@proj-airi/ui": "workspace:^",
"animejs": "^4.3.6",
"chess.js": "catalog:",
"reka-ui": "catalog:",
"stockfish": "catalog:",
"vue": "catalog:"
},
"devDependencies": {
"@ax-llm/ax": "catalog:",
"@proj-airi/plugin-sdk": "workspace:*",
"@proj-airi/stage-ui": "workspace:^",
"@proj-airi/unocss-preset-chromatic": "^1.0.2",
"@unocss/reset": "^66.6.7",
"@vitejs/plugin-vue": "^6.0.5",
"@xsai-ext/providers": "catalog:",
"@xsai/generate-text": "catalog:",
"@xsai/shared-chat": "catalog:",
"@xsai/stream-text": "catalog:",
"pinia": "catalog:",
"unocss": "^66.6.7",
"vieval": "catalog:"
}
}
+22 -3
View File
@@ -2624,6 +2624,22 @@ importers:
xstate:
specifier: ^5.30.0
version: 5.30.0
devDependencies:
es-toolkit:
specifier: 'catalog:'
version: 1.43.0
packages/plugin-sdk-tamagotchi:
dependencies:
'@proj-airi/plugin-sdk':
specifier: workspace:*
version: link:../plugin-sdk
valibot:
specifier: 'catalog:'
version: 1.2.0(typescript@5.9.3)
xsschema:
specifier: 'catalog:'
version: 0.5.0-beta.2(@valibot/to-json-schema@1.0.0-rc.0(valibot@1.3.1(typescript@5.9.3)))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6)
packages/scenarios-stage-tamagotchi-browser:
dependencies:
@@ -3803,6 +3819,9 @@ importers:
'@moeru/std':
specifier: 'catalog:'
version: 0.1.0-beta.17
'@proj-airi/plugin-sdk-tamagotchi':
specifier: workspace:*
version: link:../../packages/plugin-sdk-tamagotchi
'@proj-airi/ui':
specifier: workspace:^
version: link:../../packages/ui
@@ -3825,6 +3844,9 @@ importers:
'@ax-llm/ax':
specifier: 'catalog:'
version: 19.0.45(zod@4.3.6)
'@proj-airi/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
'@proj-airi/stage-ui':
specifier: workspace:^
version: link:../../packages/stage-ui
@@ -3858,9 +3880,6 @@ importers:
vieval:
specifier: 'catalog:'
version: 0.0.1(@types/node@25.6.0)(chokidar@5.0.0)(dotenv@17.4.2)(esbuild@0.27.2)(giget@2.0.0)(jiti@2.6.1)(less@4.6.4)(magicast@0.5.2)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vue-tsc:
specifier: ^3.1.1
version: 3.2.6(typescript@5.9.3)
plugins/airi-plugin-homeassistant:
dependencies:
+1
View File
@@ -10,6 +10,7 @@ export default defineConfig({
'packages/cap-vite',
'packages/vishot-runner-browser',
'packages/plugin-sdk',
'packages/plugin-sdk-tamagotchi',
'packages/server-runtime',
'packages/server-sdk',
'packages/stage-shared',