feat(plugin-sdk): impl

This commit is contained in:
Neko Ayaka 2026-03-17 16:41:21 +08:00
parent b97c7a8826
commit 40e47804bb
No known key found for this signature in database
28 changed files with 2193 additions and 200 deletions

View file

@ -1,3 +1,27 @@
# @proj-airi/airi-plugin-vscode
Host-side plugin that bridges VS Code context and exposes UI contributions in AIRI.
Host-side channel aggregator for VSCode integration.
## What it does
- Connects to `@proj-airi/server-runtime` as module `proj-airi:airi-plugin-vscode`.
- Receives `context:update` activity events from many `vscode-airi` extension instances.
- Aggregates usage across workspaces/instances and periodically emits a summarized `context:update` for Stage UI.
- Accepts `module:configure` (forwarded from `ui:configure`) to apply runtime settings, including preferred model.
- Broadcasts configured model down to all VSCode instances via targeted `module:configure`.
## Expected `ui:configure` payload
Use module name `proj-airi:airi-plugin-vscode` and send:
```json
{
"model": {
"provider": "openai",
"model": "gpt-4.1"
},
"emitIntervalMs": 5000,
"maxWorkspaces": 12,
"maxInstances": 32
}
```

View file

@ -0,0 +1,8 @@
{
"apiVersion": "v1",
"kind": "manifest.plugin.airi.moeru.ai",
"name": "airi-plugin-vscode",
"entrypoints": {
"electron": "../../../../../../../../neko/Git/moeru-ai/airi/integrations/vscode/airi-plugin-vscode/dist/index.mjs"
}
}

View file

@ -27,7 +27,13 @@
"build": "tsdown",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@proj-airi/plugin-protocol": "workspace:*",
"@proj-airi/server-sdk": "workspace:*",
"@xsai/generate-text": "catalog:",
"@xsai/utils-chat": "catalog:"
},
"devDependencies": {
"@proj-airi/plugin-sdk": "workspace:*"
"@guiiai/logg": "catalog:"
}
}

View file

@ -1,7 +1,650 @@
export function activate() {
// TODO: bind to vscode-airi bridge over control plane
import type { ContextUpdate, WebSocketBaseEvent } from '@proj-airi/server-sdk'
import { Format, LogLevel, useLogg } from '@guiiai/logg'
import {
moduleConfigurationCommit,
moduleConfigurationCommitStatus,
moduleConfigurationConfigured,
moduleConfigurationPlanRequest,
moduleConfigurationPlanResponse,
moduleConfigurationPlanStatus,
moduleConfigurationValidateRequest,
moduleConfigurationValidateResponse,
moduleConfigurationValidateStatus,
} from '@proj-airi/plugin-protocol/types'
import { Client, ContextUpdateStrategy } from '@proj-airi/server-sdk'
import { generateText } from '@xsai/generate-text'
import { message } from '@xsai/utils-chat'
const VSCODE_BRIDGE_PLUGIN_ID = 'proj-airi:vscode-airi'
const VSCODE_HOST_PLUGIN_ID = 'proj-airi:airi-plugin-vscode'
const VSCODE_ACTIVITY_KIND = 'vscode:activity'
const AGGREGATED_CONTEXT_ID = 'vscode:activity:aggregate'
const CONFIG_SCHEMA_ID = 'airi.config.airi-plugin-vscode'
const CONFIG_SCHEMA_VERSION = 1
export interface ConfiguredModel {
provider?: string
model?: string
baseURL?: string
apiKey?: string
}
export function deactivate() {
// TODO: clean up subscriptions
export interface VscodeAggregatorConfig {
emitIntervalMs: number
maxWorkspaces: number
maxInstances: number
model?: ConfiguredModel
channelUrl?: string
}
export interface VscodeActivityPayload {
kind: typeof VSCODE_ACTIVITY_KIND
eventType: 'heartbeat' | 'save' | 'switch-file'
instanceId: string
workspaceFolder?: string
workspaceFolders?: string[]
filePath?: string
languageId?: string
cursor?: {
line: number
character: number
}
timestamp: number
}
interface VscodeInstanceState {
instanceId: string
workspaceFolder?: string
workspaceFolders: string[]
lastFilePath?: string
lastLanguageId?: string
lastCursor?: {
line: number
character: number
}
lastEventType: VscodeActivityPayload['eventType']
firstSeenAt: number
lastSeenAt: number
updates: number
saveCount: number
switchFileCount: number
heartbeatCount: number
}
const defaultConfig: VscodeAggregatorConfig = {
emitIntervalMs: 5_000,
maxWorkspaces: 12,
maxInstances: 32,
}
const defaultChannelUrls = ['wss://localhost:6121/ws', 'ws://localhost:6121/ws']
const channelErrorLogThrottleMs = 15_000
const log = useLogg('airi-plugin-vscode').withLogLevel(LogLevel.Log).withFormat(Format.Pretty)
function createEventId() {
return `vscode-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
function asObject(value: unknown): Record<string, unknown> | undefined {
if (typeof value !== 'object' || value === null)
return undefined
return value as Record<string, unknown>
}
function toStringArray(value: unknown): string[] {
if (!Array.isArray(value))
return []
return value.filter((entry): entry is string => typeof entry === 'string')
}
function parseActivityPayload(event: WebSocketBaseEvent<'context:update', ContextUpdate>): VscodeActivityPayload | null {
const sourcePluginId = event.metadata?.source?.plugin?.id
if (sourcePluginId !== VSCODE_BRIDGE_PLUGIN_ID)
return null
const metadata = asObject(event.data.metadata)
if (!metadata)
return null
if (metadata.kind !== VSCODE_ACTIVITY_KIND)
return null
const eventType = metadata.eventType
if (eventType !== 'heartbeat' && eventType !== 'save' && eventType !== 'switch-file')
return null
const instanceId = metadata.instanceId
if (typeof instanceId !== 'string' || instanceId.length === 0)
return null
const cursor = asObject(metadata.cursor)
const parsedCursor = cursor && typeof cursor.line === 'number' && typeof cursor.character === 'number'
? { line: cursor.line, character: cursor.character }
: undefined
return {
kind: VSCODE_ACTIVITY_KIND,
eventType,
instanceId,
workspaceFolder: typeof metadata.workspaceFolder === 'string' ? metadata.workspaceFolder : undefined,
workspaceFolders: toStringArray(metadata.workspaceFolders),
filePath: typeof metadata.filePath === 'string' ? metadata.filePath : undefined,
languageId: typeof metadata.languageId === 'string' ? metadata.languageId : undefined,
cursor: parsedCursor,
timestamp: typeof metadata.timestamp === 'number' ? metadata.timestamp : Date.now(),
}
}
// ── Aggregator state ──────────────────────────────────────────────
const instances = new Map<string, VscodeInstanceState>()
const config: VscodeAggregatorConfig = { ...defaultConfig }
let timer: ReturnType<typeof setInterval> | null = null
let client: Client | null = null
let clientChannelKey = ''
let configured = false
let lastChannelErrorLogAt = 0
// ── Plugin-sdk lifecycle ──────────────────────────────────────────
export async function init({ channels }: { channels: { host: any }, apis: any }) {
const hostChannel = channels.host
// ── Validate handler ────────────────────────────────────────────
hostChannel.on(moduleConfigurationValidateRequest, (payload: any) => {
const identity = payload.identity
const full = asObject(payload.current?.full)
const modelConfig = asObject(full?.model)
const missing: string[] = []
if (!modelConfig?.baseURL)
missing.push('model.baseURL')
if (!modelConfig?.apiKey)
missing.push('model.apiKey')
if (!modelConfig?.model)
missing.push('model.model')
hostChannel.emit(moduleConfigurationValidateStatus, {
identity,
state: 'done',
})
hostChannel.emit(moduleConfigurationValidateResponse, {
identity,
validation: missing.length === 0
? { status: 'valid' as const }
: { status: 'partial' as const, missing },
current: payload.current,
})
})
// ── Plan handler ────────────────────────────────────────────────
hostChannel.on(moduleConfigurationPlanRequest, (payload: any) => {
const identity = payload.identity
const full = asObject(payload.current?.full)
const modelConfig = asObject(full?.model)
const missing: string[] = []
if (!modelConfig?.baseURL)
missing.push('model.baseURL')
if (!modelConfig?.apiKey)
missing.push('model.apiKey')
if (!modelConfig?.model)
missing.push('model.model')
hostChannel.emit(moduleConfigurationPlanStatus, {
identity,
state: 'done',
})
hostChannel.emit(moduleConfigurationPlanResponse, {
identity,
plan: {
schema: {
id: CONFIG_SCHEMA_ID,
version: CONFIG_SCHEMA_VERSION,
schema: {
type: 'object',
properties: {
channel: {
type: 'object',
properties: {
url: { type: 'string' },
},
},
model: {
type: 'object',
properties: {
provider: { type: 'string' },
model: { type: 'string' },
baseURL: { type: 'string' },
apiKey: { type: 'string' },
},
required: ['model', 'baseURL', 'apiKey'],
},
},
},
},
missing: missing.length > 0 ? missing : undefined,
nextSteps: missing.length > 0
? missing.map(field => `Provide ${field} via the consciousness configuration panel.`)
: undefined,
},
current: payload.current,
})
})
// ── Commit handler ──────────────────────────────────────────────
hostChannel.on(moduleConfigurationCommit, (payload: any) => {
const identity = payload.identity
const envelope = payload.config
applyEnvelopeConfig(envelope)
connectAndStart()
hostChannel.emit(moduleConfigurationCommitStatus, {
identity,
state: 'done',
})
hostChannel.emit(moduleConfigurationConfigured, {
identity,
config: envelope,
})
})
// ── Runtime re-configuration via WebSocket ──────────────────────
// ── Initial config from plugin-host lifecycle ───────────────────
hostChannel.on(moduleConfigurationConfigured, (payload: any) => {
applyEnvelopeConfig(payload.config)
connectAndStart()
})
}
export async function setupModules() {
if (configured) {
await emitSummaryContextUpdate()
}
}
// ── Connection & timer management ─────────────────────────────────
function connectAndStart() {
const activeClient = ensureClient()
if (!activeClient)
return
if (!configured) {
activeClient.onEvent('context:update', (event) => {
const payload = parseActivityPayload(event)
if (!payload)
return
ingest(payload)
})
activeClient.connect().catch((err) => {
log.withError(err).warn('failed to connect')
})
configured = true
}
startTimer()
broadcastModelConfig()
}
function startTimer() {
stopTimer()
timer = setInterval(() => {
void emitSummaryContextUpdate()
}, config.emitIntervalMs)
}
function stopTimer() {
if (!timer)
return
clearInterval(timer)
timer = null
}
// ── Runtime re-configuration (via WebSocket module:configure) ─────
function applyRuntimeConfig(event: WebSocketBaseEvent<'module:configure', { config?: Record<string, unknown> }>) {
const raw = asObject(event.data?.config)
if (!raw)
return
if (typeof raw.emitIntervalMs === 'number' && Number.isFinite(raw.emitIntervalMs) && raw.emitIntervalMs >= 1_000) {
config.emitIntervalMs = raw.emitIntervalMs
startTimer()
}
if (typeof raw.maxWorkspaces === 'number' && Number.isFinite(raw.maxWorkspaces) && raw.maxWorkspaces >= 1) {
config.maxWorkspaces = Math.floor(raw.maxWorkspaces)
}
if (typeof raw.maxInstances === 'number' && Number.isFinite(raw.maxInstances) && raw.maxInstances >= 1) {
config.maxInstances = Math.floor(raw.maxInstances)
}
const modelObj = asObject(raw.model)
if (modelObj) {
config.model = {
provider: typeof modelObj.provider === 'string' ? modelObj.provider : config.model?.provider,
model: typeof modelObj.model === 'string' ? modelObj.model : config.model?.model,
baseURL: typeof modelObj.baseURL === 'string' ? modelObj.baseURL : config.model?.baseURL,
apiKey: typeof modelObj.apiKey === 'string' ? modelObj.apiKey : config.model?.apiKey,
}
}
const channelObj = asObject(raw.channel)
if (channelObj) {
const previousChannelKey = clientChannelKey
config.channelUrl = normalizeChannelUrl(channelObj.url)
ensureClient()
if (previousChannelKey !== clientChannelKey) {
connectAndStart()
return
}
}
broadcastModelConfig()
void emitSummaryContextUpdate()
}
function normalizeChannelUrl(value: unknown): string | undefined {
if (typeof value !== 'string')
return undefined
const trimmed = value.trim()
if (!trimmed.startsWith('ws://') && !trimmed.startsWith('wss://'))
return undefined
return trimmed
}
function getChannelUrls() {
if (config.channelUrl)
return [config.channelUrl]
return [...defaultChannelUrls]
}
function createChannelClient(urls: string[]) {
const createdClient = new Client({
// NOTICE: `@proj-airi/server-sdk` currently publishes `url` as `string` in its exposed typings
// while this workspace implementation supports fallback URL arrays at runtime.
// Keep this cast until package exports are regenerated.
url: urls as unknown as string,
name: VSCODE_HOST_PLUGIN_ID,
identity: {
kind: 'plugin',
id: `${VSCODE_HOST_PLUGIN_ID}:${createEventId()}`,
plugin: {
id: VSCODE_HOST_PLUGIN_ID,
},
},
possibleEvents: ['context:update', 'module:configure', 'module:authenticated', 'registry:modules:sync'],
onError: (error) => {
const now = Date.now()
if (now - lastChannelErrorLogAt >= channelErrorLogThrottleMs) {
lastChannelErrorLogAt = now
log.withError(error).warn('channel error')
}
},
onClose: () => {
stopTimer()
},
autoConnect: false,
})
// When the server-runtime forwards `module:configure` (translated from `ui:configure`)
createdClient.onEvent('module:configure', (event) => {
applyRuntimeConfig(event)
})
return createdClient
}
function ensureClient() {
const urls = getChannelUrls()
const nextChannelKey = urls.join('|')
if (client && clientChannelKey === nextChannelKey)
return client
if (client) {
client.close()
configured = false
}
client = createChannelClient(urls)
clientChannelKey = nextChannelKey
return client
}
function applyEnvelopeConfig(envelope: unknown) {
const full = asObject(asObject(envelope)?.full)
const modelConfig = asObject(full?.model)
if (modelConfig) {
config.model = {
provider: typeof modelConfig.provider === 'string' ? modelConfig.provider : undefined,
model: typeof modelConfig.model === 'string' ? modelConfig.model : undefined,
baseURL: typeof modelConfig.baseURL === 'string' ? modelConfig.baseURL : undefined,
apiKey: typeof modelConfig.apiKey === 'string' ? modelConfig.apiKey : undefined,
}
}
const channelConfig = asObject(full?.channel)
if (channelConfig) {
config.channelUrl = normalizeChannelUrl(channelConfig.url)
}
}
// ── Ingestion ─────────────────────────────────────────────────────
function ingest(payload: VscodeActivityPayload) {
const existing = instances.get(payload.instanceId)
const state: VscodeInstanceState = existing ?? {
instanceId: payload.instanceId,
workspaceFolder: payload.workspaceFolder,
workspaceFolders: payload.workspaceFolders ?? [],
lastFilePath: payload.filePath,
lastLanguageId: payload.languageId,
lastCursor: payload.cursor,
lastEventType: payload.eventType,
firstSeenAt: payload.timestamp,
lastSeenAt: payload.timestamp,
updates: 0,
saveCount: 0,
switchFileCount: 0,
heartbeatCount: 0,
}
state.workspaceFolder = payload.workspaceFolder ?? state.workspaceFolder
state.workspaceFolders = payload.workspaceFolders?.length ? payload.workspaceFolders : state.workspaceFolders
state.lastFilePath = payload.filePath ?? state.lastFilePath
state.lastLanguageId = payload.languageId ?? state.lastLanguageId
state.lastCursor = payload.cursor ?? state.lastCursor
state.lastEventType = payload.eventType
state.lastSeenAt = payload.timestamp
state.updates += 1
if (payload.eventType === 'save')
state.saveCount += 1
else if (payload.eventType === 'switch-file')
state.switchFileCount += 1
else
state.heartbeatCount += 1
instances.set(payload.instanceId, state)
if (instances.size > config.maxInstances) {
const stale = [...instances.values()].sort((a, b) => a.lastSeenAt - b.lastSeenAt)
const overflow = instances.size - config.maxInstances
for (const candidate of stale.slice(0, overflow)) {
instances.delete(candidate.instanceId)
}
}
}
// ── LLM-based summary generation ─────────────────────────────────
function collectStructuredData(now: number) {
const byWorkspace = new Map<string, {
workspace: string
activeInstances: number
updates: number
saveCount: number
switchFileCount: number
heartbeatCount: number
lastSeenAt: number
languages: Map<string, number>
recentFiles: string[]
}>()
for (const instance of instances.values()) {
const workspace = instance.workspaceFolder ?? '(no-workspace)'
const entry = byWorkspace.get(workspace) ?? {
workspace,
activeInstances: 0,
updates: 0,
saveCount: 0,
switchFileCount: 0,
heartbeatCount: 0,
lastSeenAt: 0,
languages: new Map<string, number>(),
recentFiles: [],
}
entry.activeInstances += 1
entry.updates += instance.updates
entry.saveCount += instance.saveCount
entry.switchFileCount += instance.switchFileCount
entry.heartbeatCount += instance.heartbeatCount
entry.lastSeenAt = Math.max(entry.lastSeenAt, instance.lastSeenAt)
if (instance.lastLanguageId) {
entry.languages.set(instance.lastLanguageId, (entry.languages.get(instance.lastLanguageId) ?? 0) + 1)
}
if (instance.lastFilePath && !entry.recentFiles.includes(instance.lastFilePath)) {
entry.recentFiles.push(instance.lastFilePath)
}
byWorkspace.set(workspace, entry)
}
const sortedWorkspaces = [...byWorkspace.values()]
.sort((a, b) => b.lastSeenAt - a.lastSeenAt)
.slice(0, config.maxWorkspaces)
return {
totalInstances: instances.size,
workspaces: sortedWorkspaces.map(ws => ({
workspace: ws.workspace,
activeInstances: ws.activeInstances,
updates: ws.updates,
saveCount: ws.saveCount,
switchFileCount: ws.switchFileCount,
heartbeatCount: ws.heartbeatCount,
lastSeenAt: new Date(ws.lastSeenAt).toISOString(),
languages: [...ws.languages.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([name, count]) => ({ name, count })),
recentFiles: ws.recentFiles.slice(0, 5),
})),
generatedAt: new Date(now).toISOString(),
}
}
async function generateSummary(): Promise<string | null> {
const model = config.model
if (!model?.baseURL || !model?.apiKey || !model?.model) {
return null
}
const now = Date.now()
const data = collectStructuredData(now)
if (data.totalInstances === 0) {
return 'No active VS Code instances detected.'
}
try {
const result = await generateText({
baseURL: model.baseURL,
apiKey: model.apiKey,
model: model.model,
messages: message.messages(
message.system(
'You are a concise developer-activity summarizer. '
+ 'Given structured data about VS Code editor activity (workspaces, files, languages, save/switch counts, timestamps), '
+ 'produce a brief, natural-language summary (2-5 sentences) describing what the developer is currently working on. '
+ 'Focus on: which projects are active, what languages/files are being edited, and the intensity of activity. '
+ 'Do NOT mention technical details like instance IDs or raw counts — instead describe the activity qualitatively. '
+ 'Write in third person (e.g., "The developer is…").',
),
message.user(JSON.stringify(data)),
),
})
return result.text ?? null
}
catch (err) {
console.warn('[airi-plugin-vscode] LLM summary generation failed:', err)
return null
}
}
async function emitSummaryContextUpdate() {
if (!client)
return
const summary = await generateSummary()
if (summary == null)
return
client.send({
type: 'context:update',
data: {
id: createEventId(),
contextId: AGGREGATED_CONTEXT_ID,
lane: 'vscode-aggregate',
strategy: ContextUpdateStrategy.ReplaceSelf,
text: summary,
metadata: {
source: VSCODE_HOST_PLUGIN_ID,
generatedAt: Date.now(),
instances: instances.size,
model: config.model,
},
},
})
}
function broadcastModelConfig() {
if (!client)
return
client.send({
type: 'module:configure',
data: {
config: {
model: config.model
? { provider: config.model.provider, model: config.model.model }
: undefined,
},
},
route: {
destinations: [`plugin:${VSCODE_BRIDGE_PLUGIN_ID}`],
},
})
}

View file

@ -1,3 +1,11 @@
# AIRI VSCode Plugin
> Official VSCode extension for AIRI, streaming your current working at stuff back to AIRI.
Official VSCode bridge for AIRI.
## Channel design
- Extension module id: `proj-airi:vscode-airi`
- Host aggregator module id: `proj-airi:airi-plugin-vscode`
- This extension sends `context:update` activity events with route target `plugin:proj-airi:airi-plugin-vscode`.
- The host plugin aggregates cross-instance/workspace activity and emits one summarized `context:update` back to Stage UI.
- Model configuration can be pushed from UI into the host plugin (`ui:configure`), then forwarded to VSCode instances via `module:configure`.

View file

@ -1,38 +1,76 @@
import type { WebSocketEventOptionalSource } from '@proj-airi/server-sdk'
import type { Events } from './types'
import type { VscodeActivityEventType, VscodeModelConfig } from './types'
import { useLogger } from '@guiiai/logg'
import { ContextUpdateStrategy, Client as ServerClient } from '@proj-airi/server-sdk'
import { nanoid } from 'nanoid'
const VSCODE_BRIDGE_PLUGIN_ID = 'proj-airi:vscode-airi'
const VSCODE_HOST_PLUGIN_ID = 'proj-airi:airi-plugin-vscode'
export interface ClientOptions {
instanceId: string
workspaceFolders: string[]
}
export class Client {
private client: ServerClient<Events> | null = null
private client: ServerClient | null = null
private configuredModel: VscodeModelConfig | undefined
constructor(private readonly options: ClientOptions) {}
async connect(): Promise<boolean> {
if (this.client)
return true
try {
this.client = new ServerClient<Events>({ name: 'proj-airi:plugin-vscode' })
this.client = new ServerClient({
name: VSCODE_BRIDGE_PLUGIN_ID,
identity: {
kind: 'plugin',
id: `${VSCODE_BRIDGE_PLUGIN_ID}:${this.options.instanceId}`,
plugin: {
id: VSCODE_BRIDGE_PLUGIN_ID,
labels: {
source: 'vscode',
},
},
labels: {
instance: this.options.instanceId,
},
},
possibleEvents: ['context:update', 'module:configure', 'module:authenticated'],
})
this.client.onEvent('module:configure', (event) => {
const config = event.data?.config as { model?: VscodeModelConfig } | undefined
this.configuredModel = config?.model
})
await this.client.connect()
useLogger().log('AIRI connected to Server Channel')
useLogger().log('AIRI connected to VSCode host channel')
return true
}
catch (error) {
useLogger().errorWithError('Failed to connect to AIRI Server Channel:', error)
useLogger().errorWithError('Failed to connect to AIRI channel:', error)
this.client = null
return false
}
}
disconnect(): void {
if (this.client) {
this.client.close()
this.client = null
useLogger().log('AIRI disconnected')
}
if (!this.client)
return
this.client.close()
this.client = null
useLogger().log('AIRI disconnected')
}
private async send(event: WebSocketEventOptionalSource<Events>): Promise<void> {
private async send(event: WebSocketEventOptionalSource): Promise<void> {
if (!this.client) {
useLogger().warn('Cannot send event: not connected to AIRI Server Channel')
useLogger().warn('Cannot send event: VSCode channel is not connected')
return
}
@ -45,14 +83,54 @@ export class Client {
}
}
async replaceContext(context: string): Promise<void> {
const id = nanoid()
this.send({ type: 'context:update', data: { strategy: ContextUpdateStrategy.ReplaceSelf, text: context, id, contextId: id } })
private createActivityMetadata(
eventType: VscodeActivityEventType,
context: {
workspaceFolder?: string
filePath?: string
languageId?: string
cursor?: { line: number, character: number }
},
): Record<string, unknown> {
return {
kind: 'vscode:activity',
eventType,
instanceId: this.options.instanceId,
workspaceFolder: context.workspaceFolder,
workspaceFolders: this.options.workspaceFolders,
filePath: context.filePath,
languageId: context.languageId,
cursor: context.cursor,
timestamp: Date.now(),
model: this.configuredModel,
}
}
async appendContext(context: string): Promise<void> {
async replaceContext(
text: string,
eventType: VscodeActivityEventType,
context: {
workspaceFolder?: string
filePath?: string
languageId?: string
cursor?: { line: number, character: number }
},
): Promise<void> {
const id = nanoid()
this.send({ type: 'context:update', data: { strategy: ContextUpdateStrategy.AppendSelf, text: context, id, contextId: id } })
await this.send({
type: 'context:update',
data: {
id,
contextId: id,
strategy: ContextUpdateStrategy.ReplaceSelf,
text,
metadata: this.createActivityMetadata(eventType, context),
},
route: {
destinations: [`plugin:${VSCODE_HOST_PLUGIN_ID}`],
},
})
}
isConnected(): boolean {

View file

@ -1,9 +1,9 @@
import type { Lifecycle, ProvidedBy } from 'injeca'
import type { ProvidedBy } from 'injeca'
import type * as vscode from 'vscode'
import { initLogger, LoggerFormat, LoggerLevel, useLogger } from '@guiiai/logg'
import { noop } from 'es-toolkit'
import { injeca, lifecycle } from 'injeca'
import { injeca } from 'injeca'
import { commands, window, workspace } from 'vscode'
import { Client } from './airi'
@ -14,6 +14,21 @@ interface IntervalHandle {
setInterval: (fn: () => void) => NodeJS.Timeout
}
function createInstanceId() {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
async function getOrCreateInstanceId(context: vscode.ExtensionContext) {
const key = 'airi:instance-id'
const existing = context.globalState.get<string>(key)
if (existing)
return existing
const next = createInstanceId()
await context.globalState.update(key, next)
return next
}
/**
* Activate the plugin
*/
@ -22,15 +37,15 @@ export async function activate(context: vscode.ExtensionContext) {
useLogger().log('AIRI is activating...')
// Get the configuration
const config = workspace.getConfiguration('airi-vscode')
const isEnabled = config.get<boolean>('enabled', true)
const contextLines = config.get<number>('contextLines', 5)
const sendInterval = config.get<number>('sendInterval', 3000)
const instanceId = await getOrCreateInstanceId(context)
const workspaceFolders = workspace.workspaceFolders?.map(folder => folder.uri.fsPath) ?? []
// Initialize
const vscodeContext = injeca.provide('vscode:context', () => context)
const client = injeca.provide('proj-airi:client', () => new Client())
const client = injeca.provide('proj-airi:client', () => new Client({ instanceId, workspaceFolders }))
const contextCollector = injeca.provide('self:context-collector', () => new ContextCollector(contextLines))
const eventListeners = injeca.provide('self:event-listeners', () => [] as vscode.Disposable[])
const controlLoopInterval = injeca.provide('self:control-loop:interval:send', () => {
@ -50,7 +65,7 @@ export async function activate(context: vscode.ExtensionContext) {
})
const extension = injeca.provide('extension', {
dependsOn: { client, vscodeContext, contextCollector, eventListeners, lifecycle, controlLoopInterval },
dependsOn: { client, vscodeContext, contextCollector, eventListeners, controlLoopInterval },
build: ({ dependsOn }) => setup({ ...dependsOn, isEnabled, sendInterval }),
})
@ -67,58 +82,50 @@ async function setup(params: {
vscodeContext: vscode.ExtensionContext
contextCollector: ContextCollector
eventListeners: vscode.Disposable[]
lifecycle: Lifecycle
controlLoopInterval: IntervalHandle
isEnabled: boolean
sendInterval: number
}) {
// Connect to Airi Channel Server
if (params.isEnabled) {
const connected = await params.client.connect()
if (connected) {
window.showInformationMessage('AIRI Server Channel connected!')
window.showInformationMessage('AIRI VSCode channel connected')
}
else {
window.showWarningMessage('AIRI Server Channel connection failed!')
window.showWarningMessage('AIRI VSCode channel connection failed')
}
}
// Register commands
params.vscodeContext.subscriptions.push(
commands.registerCommand('airi-vscode.enable', async () => {
params.isEnabled = true
await params.client.connect()
await registerListeners({ ...params })
window.showInformationMessage('AIRI enabled!')
await registerListeners(params)
window.showInformationMessage('AIRI enabled')
}),
commands.registerCommand('airi-vscode.disable', () => {
params.isEnabled = false
unregisterListeners({ eventListeners: params.eventListeners, controlLoopInterval: params.controlLoopInterval })
params.client.disconnect()
window.showInformationMessage('AIRI disabled!')
window.showInformationMessage('AIRI disabled')
}),
commands.registerCommand('airi-vscode.status', () => {
const status = params.isEnabled && params.client ? 'Connected' : 'Disconnected'
window.showInformationMessage(`AIRI Server Channel status: ${status}.`)
const status = params.isEnabled && params.client.isConnected() ? 'Connected' : 'Disconnected'
window.showInformationMessage(`AIRI VSCode channel status: ${status}.`)
}),
)
// Register event listeners if enabled
if (params.isEnabled) {
await registerListeners({ ...params })
await registerListeners(params)
}
useLogger().log('AIRI activated successfully')
}
/**
* Register event listeners for file save and editor switch
*/
async function registerListeners(params: {
contextCollector: ContextCollector
lifecycle: Lifecycle
eventListeners: vscode.Disposable[]
client: Client
controlLoopInterval: IntervalHandle
@ -127,30 +134,10 @@ async function registerListeners(params: {
}) {
unregisterListeners({ eventListeners: params.eventListeners, controlLoopInterval: params.controlLoopInterval })
// File save event
params.eventListeners.push(
workspace.onDidSaveTextDocument(async (document) => {
const editor = window.activeTextEditor
if (editor && editor.document === document) {
const ctx = await params.contextCollector.collect(editor)
if (!ctx)
return
params.client.replaceContext(''
+ `User saved the file: ${ctx.file.fileName} (located at ${ctx.file.path}). Here is the context around the cursor after saving:\n`
+ '\n'
+ `${ctx.context.before.join('\n')}\n`
+ `${ctx.currentLine.text}\n`
+ `${ctx.context.after.join('\n')}`,
)
}
}),
)
// Switch file event
params.eventListeners.push(
window.onDidChangeActiveTextEditor(async (editor) => {
if (!editor) {
if (!editor || editor.document !== document) {
return
}
@ -159,41 +146,67 @@ async function registerListeners(params: {
return
}
params.client.replaceContext(''
await params.client.replaceContext(
''
+ `User saved the file: ${ctx.file.fileName} (located at ${ctx.file.path}). Here is the context around the cursor after saving:\n`
+ '\n'
+ `${ctx.context.before.join('\n')}\n`
+ `${ctx.currentLine.text}\n`
+ `${ctx.context.after.join('\n')}`,
'save',
{
workspaceFolder: ctx.file.workspaceFolder,
filePath: ctx.file.path,
languageId: ctx.file.languageId,
cursor: ctx.cursor,
},
)
}),
)
params.eventListeners.push(
window.onDidChangeActiveTextEditor(async (editor) => {
if (!editor)
return
const ctx = await params.contextCollector.collect(editor)
if (!ctx)
return
await params.client.replaceContext(
''
+ `User switched to file: ${ctx.file.fileName} (located at ${ctx.file.path}). Here is the context around the cursor after switching:\n`
+ '\n'
+ `${ctx.context.before.join('\n')}\n`
+ `${ctx.currentLine.text}\n`
+ `${ctx.context.after.join('\n')}`,
'switch-file',
{
workspaceFolder: ctx.file.workspaceFolder,
filePath: ctx.file.path,
languageId: ctx.file.languageId,
cursor: ctx.cursor,
},
)
}),
)
// Start periodic monitoring if interval is set
if (params.sendInterval > 0) {
startMonitoring({ ...params })
startMonitoring(params)
}
}
/**
* Unregister all event listeners
*/
function unregisterListeners(params: { eventListeners: vscode.Disposable[], controlLoopInterval: IntervalHandle }) {
params.eventListeners.forEach(listener => listener.dispose())
params.eventListeners = []
stopMonitoring({ controlLoopInterval: params.controlLoopInterval })
}
/**
* Start monitoring the coding context
*/
function startMonitoring(params: {
contextCollector: ContextCollector
lifecycle: Lifecycle
client: Client
controlLoopInterval: IntervalHandle
isEnabled: boolean
sendInterval: number
}) {
stopMonitoring({ controlLoopInterval: params.controlLoopInterval })
@ -209,21 +222,26 @@ function startMonitoring(params: {
if (!ctx)
return
params.client.replaceContext(''
await params.client.replaceContext(
''
+ `User opened file is: ${ctx.file.fileName} (located at ${ctx.file.path}), and current cursor is at line ${ctx.cursor.line + 1}, character ${ctx.cursor.character + 1}.\n`
+ '\n'
+ `Here is the context around the cursor:\n`
+ `\n`
+ 'Here is the context around the cursor:\n'
+ '\n'
+ `${ctx.context.before.join('\n')}\n`
+ `${ctx.currentLine.text}\n`
+ `${ctx.context.after.join('\n')}`,
'heartbeat',
{
workspaceFolder: ctx.file.workspaceFolder,
filePath: ctx.file.path,
languageId: ctx.file.languageId,
cursor: ctx.cursor,
},
)
})
}
/**
* Stop monitoring
*/
function stopMonitoring(params: { controlLoopInterval: IntervalHandle }) {
params.controlLoopInterval.clearInterval()
}
@ -238,5 +256,5 @@ export async function deactivate() {
unregisterListeners({ eventListeners, controlLoopInterval })
client?.disconnect()
useLogger().log('AIRI deactivated!')
useLogger().log('AIRI deactivated')
}

View file

@ -2,85 +2,55 @@
* Coding context information
*/
export interface CodingContext {
/**
* File information
*
* @example {
* "path": "/home/neko/Git/github.com/moeru-ai/airi/package.json",
* "languageId": "json",
* "fileName": "/home/neko/Git/github.com/moeru-ai/airi/package.json"
* }
*/
file: {
path: string
languageId: string
fileName: string
workspaceFolder?: string
}
/**
* Cursor position
*
* {
* "line": 5,
* "character": 35
* }
*/
cursor: {
line: number
character: number
}
/** Selected text */
selection?: {
text: string
start: { line: number, character: number }
end: { line: number, character: number }
}
/** Current line */
currentLine: {
lineNumber: number
text: string
}
/**
* Context (previous and next N lines)
*
* @example {
* "before": [
* "{",
* " \"name\": \"@proj-airi/root\",",
* " \"type\": \"module\",",
* " \"version\": \"0.8.1-beta.12\",",
* " \"private\": true,"
* ],
* "after": [
* " \"description\": \"LLM powered virtual character\",",
* " \"author\": {",
* " \"name\": \"Moeru AI Project AIRI Team\",",
* " \"email\": \"airi@moeru.ai\",",
* " \"url\": \"https://github.com/moeru-ai\""
* ]
* }
*/
context: {
before: string[]
after: string[]
}
/** Git information */
git?: {
branch: string
isDirty: boolean
}
/**
* Timestamp
*
* @example 1768584314898
*/
timestamp: number
}
/**
* Event types sent to Airi
*/
export interface Events {
type: 'coding:context' | 'coding:save' | 'coding:switch-file'
data: CodingContext
export type VscodeActivityEventType = 'heartbeat' | 'save' | 'switch-file'
export interface VscodeModelConfig {
provider?: string
model?: string
}
export interface VscodeActivityMessage {
kind: 'vscode:activity'
eventType: VscodeActivityEventType
instanceId: string
workspaceFolder?: string
workspaceFolders: string[]
filePath?: string
languageId?: string
cursor?: {
line: number
character: number
}
timestamp: number
model?: VscodeModelConfig
}

View file

@ -523,6 +523,37 @@ interface ModuleCompatibilityResultEvent {
reason?: string
}
interface PluginDiscoveredEvent {
name: string
identity: ModuleIdentity
entrypoints?: {
default?: string
electron?: string
web?: string
node?: string
server?: string
}
metadata?: Record<string, unknown>
}
interface PluginApprovalRequestEvent {
identity: ModuleIdentity
name: string
reason?: string
}
interface PluginApprovalResultEvent {
identity: ModuleIdentity
approved: boolean
reason?: string
}
interface PluginEntrypointSelectEvent {
identity: ModuleIdentity
runtime: 'electron' | 'web' | 'node' | 'server'
entrypoint: string
}
interface RegistryModulesSyncEvent {
modules: Array<{
name: string
@ -843,6 +874,10 @@ export const moduleAuthenticate = defineEventa<ModuleAuthenticateEvent>('module:
export const moduleAuthenticated = defineEventa<ModuleAuthenticatedEvent>('module:authenticated')
export const moduleCompatibilityRequest = defineEventa<ModuleCompatibilityRequestEvent>('module:compatibility:request')
export const moduleCompatibilityResult = defineEventa<ModuleCompatibilityResultEvent>('module:compatibility:result')
export const pluginDiscovered = defineEventa<PluginDiscoveredEvent>('plugin:discovered')
export const pluginApprovalRequest = defineEventa<PluginApprovalRequestEvent>('plugin:approval:request')
export const pluginApprovalResult = defineEventa<PluginApprovalResultEvent>('plugin:approval:result')
export const pluginEntrypointSelect = defineEventa<PluginEntrypointSelectEvent>('plugin:entrypoint:select')
export const registryModulesSync = defineEventa<RegistryModulesSyncEvent>('registry:modules:sync')
export const registryModulesHealthUnhealthy = defineEventa<RegistryModulesHealthUnhealthyEvent>('registry:modules:health:unhealthy')
export const registryModulesHealthHealthy = defineEventa<RegistryModulesHealthHealthyEvent>('registry:modules:health:healthy')
@ -917,6 +952,22 @@ export interface ProtocolEvents<C = undefined> {
* Host replies with accepted mode/result for protocol + API compatibility.
*/
'module:compatibility:result': ModuleCompatibilityResultEvent
/**
* Plugin announces itself as discovered and awaits explicit approval.
*/
'plugin:discovered': PluginDiscoveredEvent
/**
* Host/UI requests an approval decision for a discovered plugin.
*/
'plugin:approval:request': PluginApprovalRequestEvent
/**
* Approval decision for a discovered plugin.
*/
'plugin:approval:result': PluginApprovalResultEvent
/**
* Host selects runtime entrypoint for an approved plugin.
*/
'plugin:entrypoint:select': PluginEntrypointSelectEvent
/**
* Server-side registry sync for known online modules.
* Sent to newly authenticated peers to bootstrap module discovery.

View file

@ -1,12 +1,12 @@
import { join } from 'node:path'
import { createContext, defineEventa, defineInvoke, defineInvokeHandler } from '@moeru/eventa'
import { createContext, defineEventa, defineInvoke, defineInvokeEventa, defineInvokeHandler } from '@moeru/eventa'
import { moduleCompatibilityResult, moduleStatus, registryModulesSync } from '@proj-airi/plugin-protocol/types'
import { describe, expect, it, vi } from 'vitest'
import { FileSystemLoader, PluginHost } from '.'
import { createApis } from '../plugin/apis/client'
import { protocolCapabilityWait, protocolProviders } from '../plugin/apis/protocol'
import { protocolCapabilityWait, protocolResourceList, protocolResourceListEventName } from '../plugin/apis/protocol'
function reportPluginCapability(
host: PluginHost,
@ -145,7 +145,7 @@ describe('for FileSystemPluginHost', () => {
})
describe('for PluginHost', () => {
const providersCapability = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers'
const providersCapability = protocolResourceListEventName
const testManifest = {
apiVersion: 'v1' as const,
kind: 'manifest.plugin.airi.moeru.ai' as const,
@ -236,21 +236,21 @@ describe('for PluginHost', () => {
await expect(pluginDef.init?.({ channels: { host: ctx }, apis })).resolves.not.toThrow()
expect(onVitestCall).toHaveBeenCalledTimes(1)
defineInvokeHandler(ctx, protocolProviders.listProviders, async () => {
defineInvokeHandler(ctx, protocolResourceList, async () => {
return [
{ name: 'provider1' },
{ id: 'provider1', kind: 'ai.provider', phase: 'Ready' as const, updatedAt: Date.now(), metadata: { name: 'provider1' } },
]
})
defineInvokeHandler(ctx, protocolCapabilityWait, async () => {
return {
key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers',
key: protocolResourceListEventName,
state: 'ready',
updatedAt: Date.now(),
}
})
const onProviderListCall = vi.fn()
ctx.on(protocolProviders.listProviders.sendEvent, onProviderListCall)
ctx.on(protocolResourceList.sendEvent, onProviderListCall)
await expect(pluginDef.setupModules?.({ channels: { host: ctx }, apis })).resolves.not.toThrow()
expect(onProviderListCall).toHaveBeenCalledTimes(1)
})
@ -401,6 +401,166 @@ describe('for PluginHost', () => {
})
})
it('should create resource claim and bind it when matching resource is registered', () => {
const host = new PluginHost({
runtime: 'electron',
transport: { kind: 'in-memory' },
})
const claim = host.createResourceClaim({
id: 'claim-openai',
kind: 'ai.provider',
owner: {
pluginId: 'plugin-consumer',
moduleId: 'llm-agent',
},
selector: {
vendor: 'openai',
},
})
expect(claim.phase).toBe('Pending')
expect(claim.bindingId).toBeUndefined()
host.registerResource({
id: 'resource-openai',
kind: 'ai.provider',
labels: {
vendor: 'openai',
},
metadata: {
name: 'openai',
},
owner: {
pluginId: 'plugin-provider-openai',
},
})
const claims = host.listResourceClaims({ kind: 'ai.provider', phase: 'Bound' })
expect(claims).toHaveLength(1)
expect(claims[0]?.id).toBe('claim-openai')
expect(claims[0]?.bindingId).toBeTruthy()
const bindings = host.listResourceBindings({ claimId: 'claim-openai', phase: 'Bound' })
expect(bindings).toHaveLength(1)
expect(bindings[0]).toMatchObject({
claimId: 'claim-openai',
resourceId: 'resource-openai',
phase: 'Bound',
})
const snapshot = host.resourceSnapshot()
expect(snapshot.claims).toEqual(expect.arrayContaining([
expect.objectContaining({
id: 'claim-openai',
phase: 'Bound',
}),
]))
expect(snapshot.bindings).toEqual(expect.arrayContaining([
expect.objectContaining({
claimId: 'claim-openai',
}),
]))
})
it('should release claim and transition binding to released', () => {
const host = new PluginHost({
runtime: 'electron',
transport: { kind: 'in-memory' },
})
host.registerResource({
id: 'resource-openai',
kind: 'ai.provider',
labels: { vendor: 'openai' },
metadata: { name: 'openai' },
})
const claim = host.createResourceClaim({
id: 'claim-release',
kind: 'ai.provider',
selector: { vendor: 'openai' },
})
expect(claim.phase).toBe('Bound')
const released = host.releaseResourceClaim({
id: 'claim-release',
reason: 'plugin disabled',
})
expect(released.phase).toBe('Released')
const bindings = host.listResourceBindings({ claimId: 'claim-release' })
expect(bindings[0]?.phase).toBe('Released')
})
it('should load consumer and provider test plugins and bind claim after provider registration', async () => {
const host = new PluginHost({
runtime: 'electron',
transport: { kind: 'in-memory' },
})
const consumerSession = await host.start({
apiVersion: 'v1',
kind: 'manifest.plugin.airi.moeru.ai',
name: 'test-consumer-llm',
entrypoints: {
electron: join(import.meta.dirname, 'testdata', 'test-consumer-llm-plugin.ts'),
},
}, { cwd: '' })
expect(consumerSession.phase).toBe('ready')
expect(host.listResourceClaims({ ownerPluginId: 'test-consumer-llm' })).toEqual(expect.arrayContaining([
expect.objectContaining({
id: 'claim-consumer-openai',
phase: 'Pending',
}),
]))
const providerSession = await host.start({
apiVersion: 'v1',
kind: 'manifest.plugin.airi.moeru.ai',
name: 'test-provider-openai',
entrypoints: {
electron: join(import.meta.dirname, 'testdata', 'test-provider-openai-plugin.ts'),
},
}, { cwd: '' })
expect(providerSession.phase).toBe('ready')
const providersFromConsumer = await consumerSession.apis.providers.listProviders()
expect(providersFromConsumer).toEqual(expect.arrayContaining([
expect.objectContaining({
name: 'openai',
}),
]))
const consumerSnapshot = await consumerSession.apis.resources.snapshot()
const updatedClaim = consumerSnapshot.claims.find(claim => claim.id === 'claim-consumer-openai')
expect(updatedClaim).toBeDefined()
expect(updatedClaim?.phase).toBe('Bound')
expect(updatedClaim?.bindingId).toBeTruthy()
const binding = consumerSnapshot.bindings.find(item => item.claimId === 'claim-consumer-openai')
expect(binding).toMatchObject({
claimId: 'claim-consumer-openai',
resourceId: 'provider-openai-resource',
phase: 'Bound',
})
const fulfilledResource = consumerSnapshot.resources.find(resource => resource.id === 'provider-openai-resource')
expect(fulfilledResource).toMatchObject({
id: 'provider-openai-resource',
kind: 'ai.provider',
phase: 'Ready',
labels: {
vendor: 'openai',
category: 'llm',
},
metadata: {
name: 'openai',
features: ['generateText', 'streamText'],
},
})
})
it('should preserve previous cwd when reloading plugin', async () => {
const host = new PluginHost({
runtime: 'electron',
@ -571,14 +731,15 @@ describe('for PluginHost', () => {
name: 'test-plugin-session-two',
}, { cwd: '' })
defineInvokeHandler(sessionOne.channels.host, protocolProviders.listProviders, async () => [{ name: 'provider:one' }])
defineInvokeHandler(sessionTwo.channels.host, protocolProviders.listProviders, async () => [{ name: 'provider:two' }])
const testInvoke = defineInvokeEventa<{ source: string }, undefined>('vitest:invoke:isolation')
defineInvokeHandler(sessionOne.channels.host, testInvoke, async () => ({ source: 'session-one' }))
defineInvokeHandler(sessionTwo.channels.host, testInvoke, async () => ({ source: 'session-two' }))
const invokeOne = defineInvoke(sessionOne.channels.host, protocolProviders.listProviders)
const invokeTwo = defineInvoke(sessionTwo.channels.host, protocolProviders.listProviders)
const invokeOne = defineInvoke(sessionOne.channels.host, testInvoke)
const invokeTwo = defineInvoke(sessionTwo.channels.host, testInvoke)
await expect(invokeOne()).resolves.toEqual([{ name: 'provider:one' }])
await expect(invokeTwo()).resolves.toEqual([{ name: 'provider:two' }])
await expect(invokeOne(undefined)).resolves.toEqual({ source: 'session-one' })
await expect(invokeTwo(undefined)).resolves.toEqual({ source: 'session-two' })
})
it('should include active modules in registry sync when initializing another session', async () => {

View file

@ -10,6 +10,18 @@ import type { ActorRefFrom } from 'xstate'
import type { definePlugin } from '../plugin'
import type { createApis } from '../plugin/apis/client'
import type { CapabilityDescriptor } from '../plugin/apis/protocol'
import type {
ResourceBinding,
ResourceBindingListFilter,
ResourceClaim,
ResourceClaimCreateInput,
ResourceClaimListFilter,
ResourceClaimReleaseInput,
ResourceCondition,
ResourceInstance,
ResourceListFilter,
ResourceRegistrationInput,
} from '../plugin/apis/protocol/resources'
import type { Plugin } from '../plugin/shared'
import type { PluginTransport } from './transports'
@ -39,7 +51,22 @@ import { createActor, createMachine } from 'xstate'
import { createApis as createBoundApis } from '../plugin/apis/client'
import { protocolCapabilitySnapshot, protocolCapabilityWait } from '../plugin/apis/protocol'
import { protocolListProvidersEventName, protocolProviders } from '../plugin/apis/protocol/resources/providers'
import {
protocolResourceBindingsList,
protocolResourceBindingsListEventName,
protocolResourceClaimCreate,
protocolResourceClaimCreateEventName,
protocolResourceClaimRelease,
protocolResourceClaimReleaseEventName,
protocolResourceClaimsList,
protocolResourceClaimsListEventName,
protocolResourceList,
protocolResourceListEventName,
protocolResourceRegister,
protocolResourceRegisterEventName,
protocolResourceSnapshot,
protocolResourceSnapshotEventName,
} from '../plugin/apis/protocol/resources'
import { createPluginContext } from './runtimes/node'
/**
@ -402,6 +429,37 @@ function resolveNegotiatedVersion(preferredVersion: string, hostSupportedVersion
}
}
function nowIso() {
return new Date().toISOString()
}
function createCondition(
type: string,
status: ResourceCondition['status'],
reason: string,
message: string,
): ResourceCondition {
return {
type,
status,
reason,
message,
lastTransitionTime: nowIso(),
}
}
function labelsMatch(labels: Record<string, string> | undefined, selector: Record<string, string> | undefined) {
if (!selector || Object.keys(selector).length === 0) {
return true
}
if (!labels) {
return false
}
return Object.entries(selector).every(([key, value]) => labels[key] === value)
}
export type PluginRuntime = 'electron' | 'node' | 'web'
export type ModulePhase = ProtocolModulePhase
@ -516,9 +574,14 @@ export class PluginHost {
private readonly supportedProtocolVersions: string[]
private readonly supportedApiVersions: string[]
private readonly capabilities = new Map<string, CapabilityDescriptor>()
private readonly resources = new Map<string, ResourceInstance>()
private readonly resourceClaims = new Map<string, ResourceClaim>()
private readonly resourceBindings = new Map<string, ResourceBinding>()
private readonly capabilityWaiters = new Map<string, Set<(descriptor: CapabilityDescriptor) => void>>()
private providersListResolver: () => Promise<Array<{ name: string }>> | Array<{ name: string }> = () => []
private sessionCounter = 0
private resourceCounter = 0
private claimCounter = 0
private bindingCounter = 0
constructor(options: PluginHostOptions = {}) {
this.loader = new FileSystemLoader()
@ -528,7 +591,13 @@ export class PluginHost {
this.apiVersion = options.apiVersion ?? 'v1'
this.supportedProtocolVersions = resolveSupportedVersions(this.protocolVersion, options.supportedProtocolVersions)
this.supportedApiVersions = resolveSupportedVersions(this.apiVersion, options.supportedApiVersions)
this.markCapabilityReady(protocolListProvidersEventName, { source: 'plugin-host' })
this.markCapabilityReady(protocolResourceRegisterEventName, { source: 'plugin-host' })
this.markCapabilityReady(protocolResourceListEventName, { source: 'plugin-host' })
this.markCapabilityReady(protocolResourceClaimCreateEventName, { source: 'plugin-host' })
this.markCapabilityReady(protocolResourceClaimReleaseEventName, { source: 'plugin-host' })
this.markCapabilityReady(protocolResourceClaimsListEventName, { source: 'plugin-host' })
this.markCapabilityReady(protocolResourceBindingsListEventName, { source: 'plugin-host' })
this.markCapabilityReady(protocolResourceSnapshotEventName, { source: 'plugin-host' })
}
listSessions() {
@ -571,8 +640,26 @@ export class PluginHost {
defineInvokeHandler(hostChannel, protocolCapabilitySnapshot, async () => {
return this.listCapabilities()
})
defineInvokeHandler(hostChannel, protocolProviders.listProviders, async () => {
return await this.providersListResolver()
defineInvokeHandler(hostChannel, protocolResourceRegister, async (payload) => {
return this.registerResource(payload)
})
defineInvokeHandler(hostChannel, protocolResourceList, async (payload) => {
return this.listResources(payload)
})
defineInvokeHandler(hostChannel, protocolResourceClaimCreate, async (payload) => {
return this.createResourceClaim(payload)
})
defineInvokeHandler(hostChannel, protocolResourceClaimRelease, async (payload) => {
return this.releaseResourceClaim(payload)
})
defineInvokeHandler(hostChannel, protocolResourceClaimsList, async (payload) => {
return this.listResourceClaims(payload)
})
defineInvokeHandler(hostChannel, protocolResourceBindingsList, async (payload) => {
return this.listResourceBindings(payload)
})
defineInvokeHandler(hostChannel, protocolResourceSnapshot, async () => {
return this.resourceSnapshot()
})
const session: PluginHostSession = {
@ -864,9 +951,209 @@ export class PluginHost {
return session
}
setProvidersListResolver(resolver: () => Promise<Array<{ name: string }>> | Array<{ name: string }>) {
this.providersListResolver = resolver
this.markCapabilityReady(protocolListProvidersEventName, { source: 'plugin-host-override' })
registerResource(input: ResourceRegistrationInput): ResourceInstance {
const now = Date.now()
const id = input.id ?? `resource-${this.resourceCounter++}`
const resource: ResourceInstance = {
id,
kind: input.kind,
labels: input.labels,
metadata: input.metadata,
phase: input.phase ?? 'Ready',
owner: input.owner,
updatedAt: now,
}
this.resources.set(id, resource)
this.reconcilePendingClaims()
return resource
}
listResources(filter?: ResourceListFilter): ResourceInstance[] {
return [...this.resources.values()].filter((resource) => {
if (filter?.kind && resource.kind !== filter.kind) {
return false
}
if (filter?.phase && resource.phase !== filter.phase) {
return false
}
return labelsMatch(resource.labels, filter?.labels)
})
}
createResourceClaim(input: ResourceClaimCreateInput): ResourceClaim {
const now = Date.now()
const claimId = input.id ?? `resource-claim-${this.claimCounter++}`
const claim: ResourceClaim = {
id: claimId,
kind: input.kind,
owner: input.owner,
selector: input.selector,
constraints: input.constraints,
phase: 'Pending',
conditions: [
createCondition('ClaimCreated', 'True', 'ClaimAccepted', `Claim ${claimId} has been accepted by host scheduler.`),
],
updatedAt: now,
}
this.resourceClaims.set(claimId, claim)
return this.reconcileClaim(claimId)
}
releaseResourceClaim(input: ResourceClaimReleaseInput): ResourceClaim {
const claim = this.resourceClaims.get(input.id)
if (!claim) {
throw new Error(`Unable to release unknown resource claim: ${input.id}`)
}
claim.phase = 'Released'
claim.updatedAt = Date.now()
claim.conditions = [
...claim.conditions,
createCondition(
'Released',
'True',
'ClaimReleased',
input.reason ?? `Claim ${claim.id} has been released by request.`,
),
]
if (claim.bindingId) {
const binding = this.resourceBindings.get(claim.bindingId)
if (binding) {
binding.phase = 'Released'
binding.updatedAt = Date.now()
binding.conditions = [
...binding.conditions,
createCondition('Released', 'True', 'BindingReleased', `Binding ${binding.id} released because claim ${claim.id} was released.`),
]
}
}
return claim
}
listResourceClaims(filter?: ResourceClaimListFilter): ResourceClaim[] {
return [...this.resourceClaims.values()].filter((claim) => {
if (filter?.kind && claim.kind !== filter.kind) {
return false
}
if (filter?.phase && claim.phase !== filter.phase) {
return false
}
if (filter?.ownerPluginId && claim.owner?.pluginId !== filter.ownerPluginId) {
return false
}
return true
})
}
listResourceBindings(filter?: ResourceBindingListFilter): ResourceBinding[] {
return [...this.resourceBindings.values()].filter((binding) => {
if (filter?.claimId && binding.claimId !== filter.claimId) {
return false
}
if (filter?.resourceId && binding.resourceId !== filter.resourceId) {
return false
}
if (filter?.phase && binding.phase !== filter.phase) {
return false
}
return true
})
}
resourceSnapshot() {
return {
resources: this.listResources(),
claims: this.listResourceClaims(),
bindings: this.listResourceBindings(),
}
}
private reconcilePendingClaims() {
for (const claim of this.resourceClaims.values()) {
if (claim.phase === 'Pending' || claim.phase === 'Degraded') {
this.reconcileClaim(claim.id)
}
}
}
private reconcileClaim(claimId: string): ResourceClaim {
const claim = this.resourceClaims.get(claimId)
if (!claim) {
throw new Error(`Unable to reconcile unknown claim: ${claimId}`)
}
if (claim.phase === 'Released') {
return claim
}
const matched = [...this.resources.values()].filter((resource) => {
if (resource.kind !== claim.kind) {
return false
}
if (resource.phase !== 'Ready') {
return false
}
return labelsMatch(resource.labels, claim.selector)
})
if (matched.length === 0) {
claim.phase = 'Pending'
claim.updatedAt = Date.now()
claim.conditions = [
...claim.conditions,
createCondition('SelectorMatched', 'False', 'NoMatchingResources', `No ready resources found for kind ${claim.kind}.`),
]
return claim
}
const target = matched[0]!
let binding: ResourceBinding | undefined = claim.bindingId ? this.resourceBindings.get(claim.bindingId) : undefined
if (!binding) {
const bindingId = `resource-binding-${this.bindingCounter++}`
binding = {
id: bindingId,
claimId: claim.id,
resourceId: target.id,
phase: 'Bound',
conditions: [
createCondition('Bound', 'True', 'BindingAssigned', `Claim ${claim.id} has been bound to resource ${target.id}.`),
],
updatedAt: Date.now(),
}
this.resourceBindings.set(bindingId, binding)
claim.bindingId = bindingId
}
else {
binding.phase = 'Bound'
binding.resourceId = target.id
binding.updatedAt = Date.now()
binding.conditions = [
...binding.conditions,
createCondition('Bound', 'True', 'BindingReconciled', `Binding ${binding.id} confirmed against resource ${target.id}.`),
]
}
claim.phase = 'Bound'
claim.updatedAt = Date.now()
claim.conditions = [
...claim.conditions,
createCondition('Bound', 'True', 'DependenciesFulfilled', `Claim ${claim.id} dependencies fulfilled by resource ${target.id}.`),
]
return claim
}
announceCapability(key: string, metadata?: Record<string, unknown>) {

View file

@ -0,0 +1,18 @@
import type { ContextInit } from '../../plugin/shared'
export async function setupModules({ apis }: ContextInit): Promise<void> {
await apis.resources.createClaim({
id: 'claim-consumer-openai',
kind: 'ai.provider',
selector: {
vendor: 'openai',
},
constraints: {
task: 'text-generation',
},
owner: {
pluginId: 'test-consumer-llm',
moduleId: 'consumer-module',
},
})
}

View file

@ -0,0 +1,21 @@
import type { ContextInit } from '../../plugin/shared'
export async function setupModules({ apis }: ContextInit): Promise<void> {
await apis.resources.register({
id: 'provider-openai-resource',
kind: 'ai.provider',
labels: {
vendor: 'openai',
category: 'llm',
},
metadata: {
name: 'openai',
features: ['generateText', 'streamText'],
},
phase: 'Ready',
owner: {
pluginId: 'test-provider-openai',
moduleId: 'provider-module',
},
})
}

View file

@ -1,10 +1,11 @@
import type { EventContext } from '@moeru/eventa'
import { createProviders } from './resources'
import { createProviders, createResources } from './resources'
export function createApis(ctx: EventContext<any, any>) {
return {
providers: createProviders(ctx),
resources: createResources(ctx),
}
}

View file

@ -0,0 +1,77 @@
import type { EventContext } from '@moeru/eventa'
import type {
ResourceBindingListFilter,
ResourceClaimCreateInput,
ResourceClaimListFilter,
ResourceClaimReleaseInput,
ResourceInstance,
ResourceListFilter,
ResourceRegistrationInput,
ResourceSnapshot,
} from '../../protocol/resources'
import { defineInvoke } from '@moeru/eventa'
import { protocolCapabilityWait } from '../../protocol/capabilities'
import {
protocolResourceBindingsList,
protocolResourceBindingsListEventName,
protocolResourceClaimCreate,
protocolResourceClaimCreateEventName,
protocolResourceClaimRelease,
protocolResourceClaimReleaseEventName,
protocolResourceClaimsList,
protocolResourceClaimsListEventName,
protocolResourceList,
protocolResourceListEventName,
protocolResourceRegister,
protocolResourceRegisterEventName,
protocolResourceSnapshot,
protocolResourceSnapshotEventName,
} from '../../protocol/resources'
async function waitCapability(ctx: EventContext<any, any>, key: string) {
const waitForCapability = defineInvoke(ctx, protocolCapabilityWait)
await waitForCapability({ key })
}
export function createResources(ctx: EventContext<any, any>) {
return {
async register(input: ResourceRegistrationInput) {
await waitCapability(ctx, protocolResourceRegisterEventName)
const invoke = defineInvoke(ctx, protocolResourceRegister)
return await invoke(input)
},
async list(filter?: ResourceListFilter): Promise<ResourceInstance[]> {
await waitCapability(ctx, protocolResourceListEventName)
const invoke = defineInvoke(ctx, protocolResourceList)
return await invoke(filter)
},
async createClaim(input: ResourceClaimCreateInput) {
await waitCapability(ctx, protocolResourceClaimCreateEventName)
const invoke = defineInvoke(ctx, protocolResourceClaimCreate)
return await invoke(input)
},
async releaseClaim(input: ResourceClaimReleaseInput) {
await waitCapability(ctx, protocolResourceClaimReleaseEventName)
const invoke = defineInvoke(ctx, protocolResourceClaimRelease)
return await invoke(input)
},
async listClaims(filter?: ResourceClaimListFilter) {
await waitCapability(ctx, protocolResourceClaimsListEventName)
const invoke = defineInvoke(ctx, protocolResourceClaimsList)
return await invoke(filter)
},
async listBindings(filter?: ResourceBindingListFilter) {
await waitCapability(ctx, protocolResourceBindingsListEventName)
const invoke = defineInvoke(ctx, protocolResourceBindingsList)
return await invoke(filter)
},
async snapshot(): Promise<ResourceSnapshot> {
await waitCapability(ctx, protocolResourceSnapshotEventName)
const invoke = defineInvoke(ctx, protocolResourceSnapshot)
return await invoke()
},
}
}

View file

@ -1 +1,2 @@
export { createResources } from './core'
export { createProviders } from './providers'

View file

@ -3,18 +3,26 @@ import type { EventContext } from '@moeru/eventa'
import { defineInvoke } from '@moeru/eventa'
import { protocolCapabilityWait } from '../../../protocol/capabilities'
import { protocolListProviders, protocolListProvidersEventName } from '../../../protocol/resources/providers'
import { protocolResourceList, protocolResourceListEventName } from '../../../protocol/resources'
export function createProviders(ctx: EventContext<any, any>) {
return {
async listProviders() {
const waitForCapability = defineInvoke(ctx, protocolCapabilityWait)
await waitForCapability({
key: protocolListProvidersEventName,
key: protocolResourceListEventName,
})
const func = defineInvoke(ctx, protocolListProviders)
return await func()
const listResources = defineInvoke(ctx, protocolResourceList)
const resources = await listResources({ kind: 'ai.provider', phase: 'Ready' })
return resources.map((resource) => {
const name = typeof resource.metadata?.name === 'string'
? resource.metadata.name
: resource.id
return { name }
})
},
}
}

View file

@ -0,0 +1,112 @@
import { defineInvokeEventa } from '@moeru/eventa'
export type ResourcePhase = 'Ready' | 'Degraded' | 'Withdrawn'
export type ResourceClaimPhase = 'Pending' | 'Bound' | 'Degraded' | 'Released' | 'Failed'
export type ConditionStatus = 'True' | 'False' | 'Unknown'
export interface ResourceCondition {
type: string
status: ConditionStatus
reason: string
message: string
lastTransitionTime: string
}
export interface ResourceOwnerRef {
pluginId?: string
moduleId?: string
sessionId?: string
}
export interface ResourceInstance {
id: string
kind: string
labels?: Record<string, string>
metadata?: Record<string, unknown>
phase: ResourcePhase
owner?: ResourceOwnerRef
updatedAt: number
}
export interface ResourceClaim {
id: string
kind: string
owner?: ResourceOwnerRef
selector?: Record<string, string>
constraints?: Record<string, unknown>
phase: ResourceClaimPhase
bindingId?: string
conditions: ResourceCondition[]
updatedAt: number
}
export interface ResourceBinding {
id: string
claimId: string
resourceId: string
phase: ResourceClaimPhase
conditions: ResourceCondition[]
updatedAt: number
}
export interface ResourceListFilter {
kind?: string
phase?: ResourcePhase
labels?: Record<string, string>
}
export interface ResourceClaimListFilter {
kind?: string
phase?: ResourceClaimPhase
ownerPluginId?: string
}
export interface ResourceBindingListFilter {
claimId?: string
resourceId?: string
phase?: ResourceClaimPhase
}
export interface ResourceRegistrationInput {
id?: string
kind: string
labels?: Record<string, string>
metadata?: Record<string, unknown>
phase?: ResourcePhase
owner?: ResourceOwnerRef
}
export interface ResourceClaimCreateInput {
id?: string
kind: string
owner?: ResourceOwnerRef
selector?: Record<string, string>
constraints?: Record<string, unknown>
}
export interface ResourceClaimReleaseInput {
id: string
reason?: string
}
export interface ResourceSnapshot {
resources: ResourceInstance[]
claims: ResourceClaim[]
bindings: ResourceBinding[]
}
export const protocolResourceRegisterEventName = 'proj-airi:plugin-sdk:apis:protocol:resources:register'
export const protocolResourceListEventName = 'proj-airi:plugin-sdk:apis:protocol:resources:list'
export const protocolResourceClaimCreateEventName = 'proj-airi:plugin-sdk:apis:protocol:resources:claim:create'
export const protocolResourceClaimReleaseEventName = 'proj-airi:plugin-sdk:apis:protocol:resources:claim:release'
export const protocolResourceClaimsListEventName = 'proj-airi:plugin-sdk:apis:protocol:resources:claims:list'
export const protocolResourceBindingsListEventName = 'proj-airi:plugin-sdk:apis:protocol:resources:bindings:list'
export const protocolResourceSnapshotEventName = 'proj-airi:plugin-sdk:apis:protocol:resources:snapshot'
export const protocolResourceRegister = defineInvokeEventa<ResourceInstance, ResourceRegistrationInput>(protocolResourceRegisterEventName)
export const protocolResourceList = defineInvokeEventa<ResourceInstance[], ResourceListFilter | undefined>(protocolResourceListEventName)
export const protocolResourceClaimCreate = defineInvokeEventa<ResourceClaim, ResourceClaimCreateInput>(protocolResourceClaimCreateEventName)
export const protocolResourceClaimRelease = defineInvokeEventa<ResourceClaim, ResourceClaimReleaseInput>(protocolResourceClaimReleaseEventName)
export const protocolResourceClaimsList = defineInvokeEventa<ResourceClaim[], ResourceClaimListFilter | undefined>(protocolResourceClaimsListEventName)
export const protocolResourceBindingsList = defineInvokeEventa<ResourceBinding[], ResourceBindingListFilter | undefined>(protocolResourceBindingsListEventName)
export const protocolResourceSnapshot = defineInvokeEventa<ResourceSnapshot>(protocolResourceSnapshotEventName)

View file

@ -1 +1 @@
export { protocolProviders } from './providers'
export * from './core'

View file

@ -1,8 +0,0 @@
import { defineInvokeEventa } from '@moeru/eventa'
export const protocolListProvidersEventName = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers'
export const protocolListProviders = defineInvokeEventa<{ name: string }[]>(protocolListProvidersEventName)
export const protocolProviders = {
listProviders: protocolListProviders,
}

View file

@ -90,6 +90,10 @@ export interface AppOptions {
readTimeout?: number
message?: MessageHeartbeat | string
}
pluginApproval?: {
autoApprove?: boolean
runtime?: 'electron' | 'web' | 'node' | 'server'
}
}
export function normalizeLoggerConfig(options?: AppOptions) {
@ -123,6 +127,8 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
const peersByModule = new Map<string, Map<number | undefined, AuthenticatedPeer>>()
const heartbeatTtlMs = options?.heartbeat?.readTimeout ?? DEFAULT_HEARTBEAT_TTL_MS
const heartbeatMessage = options?.heartbeat?.message ?? MessageHeartbeat.Pong
const autoApproveDiscoveredPlugins = options?.pluginApproval?.autoApprove ?? true
const selectedPluginRuntime = options?.pluginApproval?.runtime ?? 'server'
const routingMiddleware = [
...(options?.routing?.policy ? [createPolicyMiddleware(options.routing.policy)] : []),
...(options?.routing?.middleware ?? []),
@ -248,14 +254,96 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
}
}
function resolveSelectedEntrypoint(
entrypoints: {
default?: string
electron?: string
web?: string
node?: string
server?: string
} | undefined,
runtime: 'electron' | 'web' | 'node' | 'server',
): string | undefined {
if (!entrypoints) {
return undefined
}
return entrypoints[runtime]
?? entrypoints.default
?? entrypoints.server
?? entrypoints.node
?? entrypoints.electron
?? entrypoints.web
}
function sendPluginApprovalRequest(pluginPeer: AuthenticatedPeer, parentId?: string) {
const payload = {
type: 'plugin:approval:request',
data: {
identity: pluginPeer.identity!,
name: pluginPeer.discoveredName ?? pluginPeer.name,
},
metadata: createServerEventMetadata(instanceId, parentId),
} as WebSocketEvent
const serialized = stringify(payload)
for (const peer of peers.values()) {
if (!peer.authenticated || peer.peer.id === pluginPeer.peer.id) {
continue
}
send(peer.peer, serialized)
}
send(pluginPeer.peer, payload)
}
function approvePluginPeer(pluginPeer: AuthenticatedPeer, approved: boolean, reason?: string, parentId?: string) {
pluginPeer.approved = approved
send(pluginPeer.peer, {
type: 'plugin:approval:result',
data: {
identity: pluginPeer.identity!,
approved,
reason,
},
metadata: createServerEventMetadata(instanceId, parentId),
})
if (!approved) {
return
}
const selectedEntrypoint = resolveSelectedEntrypoint(pluginPeer.discoveredEntrypoints, selectedPluginRuntime)
if (!selectedEntrypoint) {
pluginPeer.approved = false
send(pluginPeer.peer, RESPONSES.error(`No entrypoint can be selected for runtime '${selectedPluginRuntime}'.`, instanceId, parentId))
return
}
pluginPeer.selectedEntrypoint = {
runtime: selectedPluginRuntime,
entrypoint: selectedEntrypoint,
}
send(pluginPeer.peer, {
type: 'plugin:entrypoint:select',
data: {
identity: pluginPeer.identity!,
runtime: selectedPluginRuntime,
entrypoint: selectedEntrypoint,
},
metadata: createServerEventMetadata(instanceId, parentId),
})
}
app.get('/ws', defineWebSocketHandler({
open: (peer) => {
if (authToken) {
peers.set(peer.id, { peer, authenticated: false, name: '', lastHeartbeatAt: Date.now() })
peers.set(peer.id, { peer, authenticated: false, name: '', lastHeartbeatAt: Date.now(), discovered: false, approved: false })
}
else {
send(peer, RESPONSES.authenticated(instanceId))
peers.set(peer.id, { peer, authenticated: true, name: '', lastHeartbeatAt: Date.now() })
peers.set(peer.id, { peer, authenticated: true, name: '', lastHeartbeatAt: Date.now(), discovered: false, approved: false })
sendRegistrySync(peer)
}
@ -353,6 +441,82 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
return
}
case 'plugin:discovered': {
const p = peers.get(peer.id)
if (!p) {
return
}
if (authToken && !p.authenticated) {
send(peer, RESPONSES.error('must authenticate before plugin discovery', instanceId))
return
}
const { identity, name, entrypoints } = event.data as {
identity?: MetadataEventSource
name?: string
entrypoints?: {
default?: string
electron?: string
web?: string
node?: string
server?: string
}
}
if (!identity || identity.kind !== 'plugin' || !identity.plugin?.id) {
send(peer, RESPONSES.error('plugin identity must include kind=plugin and plugin id for event \'plugin:discovered\'', instanceId))
return
}
if (!name || typeof name !== 'string') {
send(peer, RESPONSES.error('the field \'name\' must be a non-empty string for event \'plugin:discovered\'', instanceId))
return
}
p.identity = identity
p.discovered = true
p.discoveredName = name
p.discoveredEntrypoints = entrypoints
sendPluginApprovalRequest(p, event.metadata?.event.id)
if (autoApproveDiscoveredPlugins) {
approvePluginPeer(p, true, 'auto-approved by runtime policy', event.metadata?.event.id)
}
return
}
case 'plugin:approval:result': {
const p = peers.get(peer.id)
if (!p?.authenticated) {
send(peer, RESPONSES.notAuthenticated(instanceId, event.metadata?.event.id))
return
}
const { identity, approved, reason } = event.data as {
identity?: MetadataEventSource
approved?: boolean
reason?: string
}
if (!identity?.id || typeof approved !== 'boolean') {
send(peer, RESPONSES.error('invalid plugin approval payload', instanceId, event.metadata?.event.id))
return
}
const target = [...peers.values()].find(candidate =>
candidate.identity?.id === identity.id
&& candidate.identity?.plugin?.id === identity.plugin?.id,
)
if (!target) {
send(peer, RESPONSES.error('plugin not found for approval result', instanceId, event.metadata?.event.id))
return
}
approvePluginPeer(target, approved, reason, event.metadata?.event.id)
return
}
case 'module:announce': {
const p = peers.get(peer.id)
if (!p) {
@ -385,6 +549,18 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
return
}
if (!p.discovered) {
send(peer, RESPONSES.error('plugin must be discovered before module announce', instanceId))
return
}
if (!p.approved) {
send(peer, RESPONSES.error('plugin is not approved for module announce', instanceId))
return
}
if (!p.selectedEntrypoint) {
send(peer, RESPONSES.error('plugin entrypoint is not selected', instanceId))
return
}
p.name = name
p.index = index
@ -457,6 +633,10 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
return
}
if (!p.discovered || !p.approved) {
send(peer, RESPONSES.error('plugin must be discovered and approved before sending routed events', instanceId, event.metadata?.event.id))
return
}
const payload = stringify(event)
const allowBypass = options?.routing?.allowBypass !== false

View file

@ -34,4 +34,21 @@ export interface AuthenticatedPeer extends NamedPeer {
lastHeartbeatAt?: number
healthy?: boolean
missedHeartbeats?: number
discovered?: boolean
approved?: boolean
discoveredName?: string
discoveredEntrypoints?: {
default?: string
electron?: string
web?: string
node?: string
server?: string
}
selectedEntrypoint?: {
runtime: 'electron' | 'web' | 'node' | 'server'
entrypoint: string
}
}

View file

@ -18,19 +18,33 @@ import {
} from '@proj-airi/server-shared/types'
export interface ClientOptions<C = undefined> {
url?: string
url?: string | string[]
name: string
possibleEvents?: Array<keyof WebSocketEvents<C>>
token?: string
identity?: MetadataEventSource
dependencies?: ModuleDependency[]
configSchema?: ModuleConfigSchema
entrypoints?: {
default?: string
electron?: string
web?: string
node?: string
server?: string
}
autoApprovePlugin?: boolean
heartbeat?: {
readTimeout?: number
message?: MessageHeartbeat | string
}
onPluginApprovalRequest?: (payload: {
identity: MetadataEventSource
name: string
reason?: string
}) => boolean | Promise<boolean>
onError?: (error: unknown) => void
onClose?: () => void
onConnected?: (url: string) => void
autoConnect?: boolean
autoReconnect?: boolean
maxReconnectAttempts?: number
@ -46,6 +60,8 @@ function createEventId() {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
const defaultClientUrls = ['wss://localhost:6121/ws', 'ws://localhost:6121/ws']
export class Client<C = undefined> {
private connected = false
private connecting = false
@ -54,30 +70,59 @@ export class Client<C = undefined> {
private connectAttempt?: Promise<void>
private connectTask?: Promise<void>
private heartbeatTimer?: ReturnType<typeof setInterval>
private readonly identity: MetadataEventSource
private readonly opts: Required<Omit<ClientOptions<C>, 'token'>> & Pick<ClientOptions<C>, 'token'>
private readonly opts: Omit<Required<Omit<ClientOptions<C>, 'token'>>, 'url'> & { url: string } & Pick<ClientOptions<C>, 'token'>
private readonly identity: MetadataEventSource
private readonly urls: string[]
private activeUrlIndex = 0
private discovered = false
private approved = false
private entrypointSelected = false
private announced = false
private readonly eventListeners = new Map<
keyof WebSocketEvents<C>,
Set<(data: WebSocketBaseEvent<any, any>) => void | Promise<void>>
>()
constructor(options: ClientOptions<C>) {
const { url, ...restOptions } = options
const normalizedUrls = (
Array.isArray(url)
? url
: url
? [url]
: [...defaultClientUrls])
.map(entry => entry.trim())
.filter(entry => entry.length > 0,
)
const urls = normalizedUrls.length > 0
? normalizedUrls
: [...defaultClientUrls]
const identity = options.identity ?? {
kind: 'plugin',
plugin: { id: options.name },
id: createInstanceId(),
}
this.urls = urls
this.opts = {
url: 'ws://localhost:6121/ws',
url: urls[0],
onAnyMessage: () => {},
onAnySend: () => {},
possibleEvents: [],
dependencies: [],
configSchema: undefined,
entrypoints: undefined,
autoApprovePlugin: true,
onPluginApprovalRequest: undefined,
onError: () => {},
onClose: () => {},
onConnected: () => {},
autoConnect: true,
autoReconnect: true,
maxReconnectAttempts: -1,
@ -85,7 +130,7 @@ export class Client<C = undefined> {
readTimeout: 30_000,
message: MessageHeartbeat.Ping,
},
...options,
...restOptions,
identity,
}
@ -94,13 +139,60 @@ export class Client<C = undefined> {
// Authentication listener is registered once only
this.onEvent('module:authenticated', async (event) => {
if (event.data.authenticated) {
this.tryAnnounce()
this.tryDiscover()
}
else {
await this.retryWithExponentialBackoff(() => this.tryAuthenticate())
}
})
this.onEvent('plugin:approval:request', async (event) => {
const expectedIdentity = this.identity
if (event.data.identity.id !== expectedIdentity.id || event.data.identity.plugin?.id !== expectedIdentity.plugin?.id) {
return
}
const approvedByHook = await this.opts.onPluginApprovalRequest?.({
identity: event.data.identity,
name: event.data.name,
reason: event.data.reason,
})
const approved = approvedByHook ?? this.opts.autoApprovePlugin
this.send({
type: 'plugin:approval:result',
data: {
identity: expectedIdentity,
approved,
reason: approved ? 'approved by client policy' : 'rejected by client policy',
},
})
})
this.onEvent('plugin:approval:result', async (event) => {
const expectedIdentity = this.identity
if (event.data.identity.id !== expectedIdentity.id || event.data.identity.plugin?.id !== expectedIdentity.plugin?.id) {
return
}
this.approved = event.data.approved
if (this.approved && this.entrypointSelected) {
this.tryAnnounce()
}
})
this.onEvent('plugin:entrypoint:select', async (event) => {
const expectedIdentity = this.identity
if (event.data.identity.id !== expectedIdentity.id || event.data.identity.plugin?.id !== expectedIdentity.plugin?.id) {
return
}
this.entrypointSelected = true
if (this.approved) {
this.tryAnnounce()
}
})
this.onEvent('error', async (event) => {
if (event.data.message === 'not authenticated') {
await this._reconnectDueToUnauthorized()
@ -124,6 +216,11 @@ export class Client<C = undefined> {
// Loop until attempts exceed maxReconnectAttempts, or unlimited if -1
while (true) {
if (this.shouldClose) {
console.warn('Aborting retry: client is closed')
return
}
if (maxReconnectAttempts !== -1 && attempts >= maxReconnectAttempts) {
console.error(`Maximum retry attempts (${maxReconnectAttempts}) reached`)
return
@ -135,6 +232,11 @@ export class Client<C = undefined> {
}
catch (err) {
this.opts.onError?.(err)
if (this.urls.length > 1) {
this.activeUrlIndex = (this.activeUrlIndex + 1) % this.urls.length
console.warn(`Attempt ${attempts + 1}: Failed to connect to ${this.urls[this.activeUrlIndex]}, trying next URL`)
}
const delay = Math.min(2 ** attempts * 1000, 30_000) // capped exponential backoff
await sleep(delay)
attempts++
@ -172,7 +274,7 @@ export class Client<C = undefined> {
fn()
}
const ws = new WebSocket(this.opts.url)
const ws = new WebSocket(this.urls[this.activeUrlIndex] ?? this.opts.url)
this.websocket = ws
const isCurrentSocket = () => this.websocket === ws
@ -212,6 +314,10 @@ export class Client<C = undefined> {
if (this.connected) {
this.connected = false
this.discovered = false
this.approved = false
this.entrypointSelected = false
this.announced = false
this.stopHeartbeat()
this.opts.onClose?.()
}
@ -226,13 +332,15 @@ export class Client<C = undefined> {
settle(() => {
this.connected = true
const connectedUrl = this.urls[this.activeUrlIndex] ?? this.opts.url
this.opts.onConnected?.(connectedUrl)
this.startHeartbeat()
if (this.opts.token)
this.tryAuthenticate()
else
this.tryAnnounce()
this.tryDiscover()
resolve()
})
@ -255,7 +363,28 @@ export class Client<C = undefined> {
return this.connectTask
}
private tryDiscover() {
if (this.discovered) {
return
}
this.discovered = true
this.send({
type: 'plugin:discovered',
data: {
name: this.opts.name,
identity: this.identity,
entrypoints: this.opts.entrypoints,
},
})
}
private tryAnnounce() {
if (this.announced || !this.approved || !this.entrypointSelected) {
return
}
this.announced = true
this.send({
type: 'module:announce',
data: {
@ -454,6 +583,11 @@ export class Client<C = undefined> {
ws.close()
}
this.discovered = false
this.approved = false
this.entrypointSelected = false
this.announced = false
await this.connect()
}
}

View file

@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const defaultWebSocketUrl = import.meta.env.VITE_AIRI_WS_URL || 'ws://localhost:6121/ws'
const defaultWebSocketUrl = import.meta.env.VITE_AIRI_WS_URL || 'localhost:6121/ws'
const websocketUrl = useLocalStorageManualReset('settings/connection/websocket-url', defaultWebSocketUrl)
</script>

View file

@ -3,7 +3,8 @@ import { defineStore } from 'pinia'
import { useModsServerChannelStore } from './mods/api/channel-server'
export const useConfiguratorByModsChannelServer = defineStore('configurator:adapter:proj-airi:server-sdk', () => {
const { send } = useModsServerChannelStore()
const channelStore = useModsServerChannelStore()
const { send } = channelStore
function updateFor(moduleName: string, config: Record<string, unknown>) {
send({
@ -15,7 +16,17 @@ export const useConfiguratorByModsChannelServer = defineStore('configurator:adap
})
}
function updateForIfAvailable(moduleName: string, config: Record<string, unknown>) {
if (!channelStore.hasModule(moduleName)) {
return false
}
updateFor(moduleName, config)
return true
}
return {
updateFor,
updateForIfAvailable,
}
})

View file

@ -10,15 +10,26 @@ import { ref, watch } from 'vue'
import { useWebSocketInspectorStore } from '../../devtools/websocket-inspector'
export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:server', () => {
interface OnlineModuleSummary {
name: string
index?: number
moduleId?: string
pluginId?: string
}
const connected = ref(false)
const connectedUrl = ref<string | null>(null)
const connectedProtocol = ref<'wss' | 'ws' | null>(null)
const client = ref<Client>()
const initializing = ref<Promise<void> | null>(null)
const pendingSend = ref<Array<WebSocketEvent>>([])
const listenersInitialized = ref(false)
const listenerDisposers = ref<Array<() => void>>([])
const onlineModules = ref<OnlineModuleSummary[]>([])
const defaultWebSocketUrl = import.meta.env.VITE_AIRI_WS_URL || 'ws://localhost:6121/ws'
const defaultWebSocketUrl = import.meta.env.VITE_AIRI_WS_URL || 'localhost:6121/ws'
const websocketUrl = useLocalStorage('settings/connection/websocket-url', defaultWebSocketUrl)
const websocketProtocolCache = useLocalStorage<Record<string, 'wss' | 'ws'>>('settings/connection/websocket-protocol-cache', {})
const basePossibleEvents: Array<keyof WebSocketEvents> = [
'context:update',
@ -26,6 +37,7 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se
'module:announce',
'module:configure',
'module:authenticated',
'registry:modules:sync',
'spark:notify',
'spark:emit',
'spark:command',
@ -37,6 +49,53 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se
'ui:configure',
]
function normalizeWsUrl(raw: string) {
const trimmed = raw.trim()
if (!trimmed)
return null
const withProtocol = /^wss?:\/\//i.test(trimmed)
? trimmed
: /^https?:\/\//i.test(trimmed)
? trimmed.replace(/^http/i, 'ws')
: `ws://${trimmed}`
try {
const parsed = new URL(withProtocol)
if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:')
return null
if (!parsed.pathname || parsed.pathname === '/')
parsed.pathname = '/ws'
return parsed
}
catch {
return null
}
}
function toEndpointKey(url: URL) {
return `${url.host}${url.pathname}${url.search}`
}
function resolveCandidateUrls(rawValue: string) {
const parsed = normalizeWsUrl(rawValue) ?? normalizeWsUrl(defaultWebSocketUrl)
if (!parsed) {
return ['wss://localhost:6121/ws', 'ws://localhost:6121/ws']
}
const endpointKey = toEndpointKey(parsed)
const explicitProtocol = /^wss?:\/\//i.test(rawValue.trim())
const cachedProtocol = websocketProtocolCache.value?.[endpointKey]
const currentProtocol = parsed.protocol === 'wss:' ? 'wss' : 'ws'
const primaryProtocol = cachedProtocol ?? (explicitProtocol ? currentProtocol : 'wss')
const secondaryProtocol = primaryProtocol === 'wss' ? 'ws' : 'wss'
const originAndPath = `${parsed.host}${parsed.pathname}${parsed.search}`
return [`${primaryProtocol}://${originAndPath}`, `${secondaryProtocol}://${originAndPath}`]
}
async function initialize(options?: { token?: string, possibleEvents?: Array<keyof WebSocketEvents> }) {
if (connected.value && client.value)
return Promise.resolve()
@ -51,30 +110,41 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se
initializing.value = new Promise<void>((resolve) => {
client.value = new Client({
name: isStageWeb() ? WebSocketEventSource.StageWeb : isStageTamagotchi() ? WebSocketEventSource.StageTamagotchi : WebSocketEventSource.StageWeb,
url: websocketUrl.value || defaultWebSocketUrl,
url: resolveCandidateUrls(websocketUrl.value || defaultWebSocketUrl),
token: options?.token,
possibleEvents,
onAnyMessage: (event) => {
onAnyMessage: (event: WebSocketEvent) => {
useWebSocketInspectorStore().add('incoming', event)
},
onAnySend: (event) => {
onAnySend: (event: WebSocketEvent) => {
useWebSocketInspectorStore().add('outgoing', event)
},
onError: (error) => {
onError: () => {
connected.value = false
initializing.value = null
clearListeners()
console.warn('WebSocket server connection error:', error)
},
onClose: () => {
connected.value = false
connectedUrl.value = null
connectedProtocol.value = null
initializing.value = null
clearListeners()
console.warn('WebSocket server connection closed')
},
})
onConnected: (url: string) => {
connectedUrl.value = url
connectedProtocol.value = url.startsWith('wss://') ? 'wss' : 'ws'
const parsed = normalizeWsUrl(url)
if (parsed) {
websocketProtocolCache.value = {
...websocketProtocolCache.value,
[toEndpointKey(parsed)]: connectedProtocol.value ?? 'ws',
}
}
},
} as any)
client.value.onEvent('module:authenticated', (event) => {
if (event.data.authenticated) {
@ -112,13 +182,51 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se
}
listenerDisposers.value = []
listenersInitialized.value = false
onlineModules.value = []
}
function initializeListeners() {
if (!client.value)
// No-op for now; keep placeholder for future shared listeners.
// eslint-disable-next-line no-useless-return
// No-op for now; keep placeholder for future shared listeners.
return
if (listenersInitialized.value) {
return
}
const onModuleAnnounce = (event: WebSocketBaseEvent<'module:announce', WebSocketEvents['module:announce']>) => {
const identity = event.data?.identity
const next: OnlineModuleSummary = {
name: event.data.name,
index: undefined,
moduleId: identity?.id,
pluginId: identity?.plugin?.id,
}
const nextModules = onlineModules.value.filter(module => module.name !== next.name)
nextModules.push(next)
onlineModules.value = nextModules
}
const onRegistryModulesSync = (event: WebSocketBaseEvent<'registry:modules:sync', WebSocketEvents['registry:modules:sync']>) => {
onlineModules.value = event.data.modules.map(module => ({
name: module.name,
index: module.index,
moduleId: module.identity.id,
pluginId: module.identity.plugin.id,
}))
}
client.value.onEvent('module:announce', onModuleAnnounce as any)
client.value.onEvent('registry:modules:sync', onRegistryModulesSync as any)
listenerDisposers.value.push(() => {
client.value?.offEvent('module:announce', onModuleAnnounce as any)
client.value?.offEvent('registry:modules:sync', onRegistryModulesSync as any)
})
listenersInitialized.value = true
}
function send<C = undefined>(data: WebSocketEventOptionalSource<C>) {
@ -182,7 +290,14 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se
client.value = undefined
}
connected.value = false
connectedUrl.value = null
connectedProtocol.value = null
initializing.value = null
onlineModules.value = []
}
function hasModule(moduleName: string) {
return onlineModules.value.some(module => module.name === moduleName || module.pluginId === moduleName)
}
watch(websocketUrl, (newUrl, oldUrl) => {
@ -197,7 +312,11 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se
return {
connected,
connectedUrl,
connectedProtocol,
ensureConnected,
onlineModules,
hasModule,
initialize,
send,

View file

@ -1,17 +1,20 @@
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
import { refManualReset } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { computed, watch } from 'vue'
import { useConfiguratorByModsChannelServer } from '../configurator'
import { useProvidersStore } from '../providers'
export const useConsciousnessStore = defineStore('consciousness', () => {
const providersStore = useProvidersStore()
const configuratorStore = useConfiguratorByModsChannelServer()
// State
const activeProvider = useLocalStorageManualReset<string>('settings/consciousness/active-provider', '')
const activeModel = useLocalStorageManualReset<string>('settings/consciousness/active-model', '')
const activeCustomModelName = useLocalStorageManualReset<string>('settings/consciousness/active-custom-model', '')
const modelSyncTargetModules = useLocalStorageManualReset<string[]>('settings/consciousness/model-sync-target-modules', ['proj-airi:airi-plugin-vscode'])
const expandedDescriptions = refManualReset<Record<string, boolean>>(() => ({}))
const modelSearchQuery = refManualReset<string>('')
@ -75,12 +78,43 @@ export const useConsciousnessStore = defineStore('consciousness', () => {
resetModelSelection()
}
let lastSyncedModelKey = ''
function syncModelConfigurationTargets() {
// TODO(@nekomeowww): migrate to provide a shared generateText(...) function through
// eventa invoke, so that credentials will not leak to plugin side without warning and confirmation.
const providerConfig = providersStore.getProviderConfig(activeProvider.value)
const payload = {
model: {
provider: activeProvider.value || undefined,
model: activeModel.value || undefined,
baseURL: (providerConfig?.baseUrl as string | undefined)?.trim() || undefined,
apiKey: (providerConfig?.apiKey as string | undefined)?.trim() || undefined,
},
} satisfies Record<string, unknown>
const nextKey = `${payload.model.provider ?? ''}::${payload.model.model ?? ''}`
if (nextKey === lastSyncedModelKey) {
return
}
lastSyncedModelKey = nextKey
for (const moduleName of modelSyncTargetModules.value) {
configuratorStore.updateForIfAvailable(moduleName, payload)
}
}
watch([activeProvider, activeModel], () => {
syncModelConfigurationTargets()
}, { immediate: true })
return {
// State
configured,
activeProvider,
activeModel,
customModelName: activeCustomModelName,
modelSyncTargetModules,
expandedDescriptions,
modelSearchQuery,
@ -96,5 +130,6 @@ export const useConsciousnessStore = defineStore('consciousness', () => {
loadModelsForProvider,
getModelsForProvider,
resetState,
syncModelConfigurationTargets,
}
})

25
pnpm-lock.yaml generated
View file

@ -1905,10 +1905,23 @@ importers:
version: 3.2.5(typescript@5.9.3)
integrations/vscode/airi-plugin-vscode:
devDependencies:
'@proj-airi/plugin-sdk':
dependencies:
'@proj-airi/plugin-protocol':
specifier: workspace:*
version: link:../../../packages/plugin-sdk
version: link:../../../packages/plugin-protocol
'@proj-airi/server-sdk':
specifier: workspace:*
version: link:../../../packages/server-sdk
'@xsai/generate-text':
specifier: 'catalog:'
version: 0.4.3
'@xsai/utils-chat':
specifier: 'catalog:'
version: 0.4.0-beta.13
devDependencies:
'@guiiai/logg':
specifier: 'catalog:'
version: 1.2.11
integrations/vscode/vscode-airi:
devDependencies:
@ -3237,7 +3250,7 @@ importers:
version: 14.1.0(vue@3.5.29(typescript@5.9.3))
'@wxt-dev/module-vue':
specifier: ^1.0.3
version: 1.0.3(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))(wxt@0.20.18(@types/node@24.10.14)(canvas@3.2.1)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
version: 1.0.3(vite@8.0.0-beta.15(@types/node@24.10.14)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))(wxt@0.20.18(@types/node@24.10.14)(canvas@3.2.1)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
nanoid:
specifier: ^5.1.6
version: 5.1.6
@ -23919,9 +23932,9 @@ snapshots:
'@types/filesystem': 0.0.36
'@types/har-format': 1.2.16
'@wxt-dev/module-vue@1.0.3(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))(wxt@0.20.18(@types/node@24.10.14)(canvas@3.2.1)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
'@wxt-dev/module-vue@1.0.3(vite@8.0.0-beta.15(@types/node@24.10.14)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))(wxt@0.20.18(@types/node@24.10.14)(canvas@3.2.1)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitejs/plugin-vue': 6.0.4(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))
'@vitejs/plugin-vue': 6.0.4(vite@8.0.0-beta.15(@types/node@24.10.14)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))
wxt: 0.20.18(@types/node@24.10.14)(canvas@3.2.1)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- vite