diff --git a/integrations/vscode/airi-plugin-vscode/README.md b/integrations/vscode/airi-plugin-vscode/README.md index 76f61e2ba..a8ab2a2c3 100644 --- a/integrations/vscode/airi-plugin-vscode/README.md +++ b/integrations/vscode/airi-plugin-vscode/README.md @@ -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 +} +``` diff --git a/integrations/vscode/airi-plugin-vscode/airi-plugin-vscode.json b/integrations/vscode/airi-plugin-vscode/airi-plugin-vscode.json new file mode 100644 index 000000000..a61e3281b --- /dev/null +++ b/integrations/vscode/airi-plugin-vscode/airi-plugin-vscode.json @@ -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" + } +} diff --git a/integrations/vscode/airi-plugin-vscode/package.json b/integrations/vscode/airi-plugin-vscode/package.json index 7be96437d..7ae76fb6b 100644 --- a/integrations/vscode/airi-plugin-vscode/package.json +++ b/integrations/vscode/airi-plugin-vscode/package.json @@ -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:" } } diff --git a/integrations/vscode/airi-plugin-vscode/src/index.ts b/integrations/vscode/airi-plugin-vscode/src/index.ts index f5288f564..7e9ee42d3 100644 --- a/integrations/vscode/airi-plugin-vscode/src/index.ts +++ b/integrations/vscode/airi-plugin-vscode/src/index.ts @@ -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 | undefined { + if (typeof value !== 'object' || value === null) + return undefined + + return value as Record +} + +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() +const config: VscodeAggregatorConfig = { ...defaultConfig } +let timer: ReturnType | 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 }>) { + 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 + 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(), + 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 { + 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}`], + }, + }) } diff --git a/integrations/vscode/vscode-airi/README.md b/integrations/vscode/vscode-airi/README.md index e7cb45b39..b97447eb7 100644 --- a/integrations/vscode/vscode-airi/README.md +++ b/integrations/vscode/vscode-airi/README.md @@ -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`. diff --git a/integrations/vscode/vscode-airi/src/airi.ts b/integrations/vscode/vscode-airi/src/airi.ts index d606eb500..06456aade 100644 --- a/integrations/vscode/vscode-airi/src/airi.ts +++ b/integrations/vscode/vscode-airi/src/airi.ts @@ -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 | null = null + private client: ServerClient | null = null + private configuredModel: VscodeModelConfig | undefined + + constructor(private readonly options: ClientOptions) {} async connect(): Promise { + if (this.client) + return true + try { - this.client = new ServerClient({ 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): Promise { + private async send(event: WebSocketEventOptionalSource): Promise { 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 { - 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 { + 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 { + async replaceContext( + text: string, + eventType: VscodeActivityEventType, + context: { + workspaceFolder?: string + filePath?: string + languageId?: string + cursor?: { line: number, character: number } + }, + ): Promise { 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 { diff --git a/integrations/vscode/vscode-airi/src/extension.ts b/integrations/vscode/vscode-airi/src/extension.ts index 26f4887b5..be3e4de44 100644 --- a/integrations/vscode/vscode-airi/src/extension.ts +++ b/integrations/vscode/vscode-airi/src/extension.ts @@ -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(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('enabled', true) const contextLines = config.get('contextLines', 5) const sendInterval = config.get('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') } diff --git a/integrations/vscode/vscode-airi/src/types.ts b/integrations/vscode/vscode-airi/src/types.ts index 1c4eb94b3..4adc8e55a 100644 --- a/integrations/vscode/vscode-airi/src/types.ts +++ b/integrations/vscode/vscode-airi/src/types.ts @@ -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 } diff --git a/packages/plugin-protocol/src/types/events.ts b/packages/plugin-protocol/src/types/events.ts index 9692017b9..40b5bd197 100644 --- a/packages/plugin-protocol/src/types/events.ts +++ b/packages/plugin-protocol/src/types/events.ts @@ -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 +} + +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('module: export const moduleAuthenticated = defineEventa('module:authenticated') export const moduleCompatibilityRequest = defineEventa('module:compatibility:request') export const moduleCompatibilityResult = defineEventa('module:compatibility:result') +export const pluginDiscovered = defineEventa('plugin:discovered') +export const pluginApprovalRequest = defineEventa('plugin:approval:request') +export const pluginApprovalResult = defineEventa('plugin:approval:result') +export const pluginEntrypointSelect = defineEventa('plugin:entrypoint:select') export const registryModulesSync = defineEventa('registry:modules:sync') export const registryModulesHealthUnhealthy = defineEventa('registry:modules:health:unhealthy') export const registryModulesHealthHealthy = defineEventa('registry:modules:health:healthy') @@ -917,6 +952,22 @@ export interface ProtocolEvents { * 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. diff --git a/packages/plugin-sdk/src/plugin-host/core.test.ts b/packages/plugin-sdk/src/plugin-host/core.test.ts index ed1c3daa5..823481663 100644 --- a/packages/plugin-sdk/src/plugin-host/core.test.ts +++ b/packages/plugin-sdk/src/plugin-host/core.test.ts @@ -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 () => { diff --git a/packages/plugin-sdk/src/plugin-host/core.ts b/packages/plugin-sdk/src/plugin-host/core.ts index 87e3a42bf..c536eba04 100644 --- a/packages/plugin-sdk/src/plugin-host/core.ts +++ b/packages/plugin-sdk/src/plugin-host/core.ts @@ -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 | undefined, selector: Record | 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() + private readonly resources = new Map() + private readonly resourceClaims = new Map() + private readonly resourceBindings = new Map() private readonly capabilityWaiters = new Map void>>() - private providersListResolver: () => Promise> | 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 }>) { - 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) { diff --git a/packages/plugin-sdk/src/plugin-host/testdata/test-consumer-llm-plugin.ts b/packages/plugin-sdk/src/plugin-host/testdata/test-consumer-llm-plugin.ts new file mode 100644 index 000000000..f72e75ac1 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/testdata/test-consumer-llm-plugin.ts @@ -0,0 +1,18 @@ +import type { ContextInit } from '../../plugin/shared' + +export async function setupModules({ apis }: ContextInit): Promise { + 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', + }, + }) +} diff --git a/packages/plugin-sdk/src/plugin-host/testdata/test-provider-openai-plugin.ts b/packages/plugin-sdk/src/plugin-host/testdata/test-provider-openai-plugin.ts new file mode 100644 index 000000000..f955c4a30 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/testdata/test-provider-openai-plugin.ts @@ -0,0 +1,21 @@ +import type { ContextInit } from '../../plugin/shared' + +export async function setupModules({ apis }: ContextInit): Promise { + 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', + }, + }) +} diff --git a/packages/plugin-sdk/src/plugin/apis/client/index.ts b/packages/plugin-sdk/src/plugin/apis/client/index.ts index 49867731f..51d87c101 100644 --- a/packages/plugin-sdk/src/plugin/apis/client/index.ts +++ b/packages/plugin-sdk/src/plugin/apis/client/index.ts @@ -1,10 +1,11 @@ import type { EventContext } from '@moeru/eventa' -import { createProviders } from './resources' +import { createProviders, createResources } from './resources' export function createApis(ctx: EventContext) { return { providers: createProviders(ctx), + resources: createResources(ctx), } } diff --git a/packages/plugin-sdk/src/plugin/apis/client/resources/core.ts b/packages/plugin-sdk/src/plugin/apis/client/resources/core.ts new file mode 100644 index 000000000..9e2258103 --- /dev/null +++ b/packages/plugin-sdk/src/plugin/apis/client/resources/core.ts @@ -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, key: string) { + const waitForCapability = defineInvoke(ctx, protocolCapabilityWait) + await waitForCapability({ key }) +} + +export function createResources(ctx: EventContext) { + return { + async register(input: ResourceRegistrationInput) { + await waitCapability(ctx, protocolResourceRegisterEventName) + const invoke = defineInvoke(ctx, protocolResourceRegister) + return await invoke(input) + }, + async list(filter?: ResourceListFilter): Promise { + 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 { + await waitCapability(ctx, protocolResourceSnapshotEventName) + const invoke = defineInvoke(ctx, protocolResourceSnapshot) + return await invoke() + }, + } +} diff --git a/packages/plugin-sdk/src/plugin/apis/client/resources/index.ts b/packages/plugin-sdk/src/plugin/apis/client/resources/index.ts index 22a649944..bba48de31 100644 --- a/packages/plugin-sdk/src/plugin/apis/client/resources/index.ts +++ b/packages/plugin-sdk/src/plugin/apis/client/resources/index.ts @@ -1 +1,2 @@ +export { createResources } from './core' export { createProviders } from './providers' diff --git a/packages/plugin-sdk/src/plugin/apis/client/resources/providers/index.ts b/packages/plugin-sdk/src/plugin/apis/client/resources/providers/index.ts index d8e041233..ed541bc0d 100644 --- a/packages/plugin-sdk/src/plugin/apis/client/resources/providers/index.ts +++ b/packages/plugin-sdk/src/plugin/apis/client/resources/providers/index.ts @@ -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) { 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 } + }) }, } } diff --git a/packages/plugin-sdk/src/plugin/apis/protocol/resources/core.ts b/packages/plugin-sdk/src/plugin/apis/protocol/resources/core.ts new file mode 100644 index 000000000..3880b1a93 --- /dev/null +++ b/packages/plugin-sdk/src/plugin/apis/protocol/resources/core.ts @@ -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 + metadata?: Record + phase: ResourcePhase + owner?: ResourceOwnerRef + updatedAt: number +} + +export interface ResourceClaim { + id: string + kind: string + owner?: ResourceOwnerRef + selector?: Record + constraints?: Record + 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 +} + +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 + metadata?: Record + phase?: ResourcePhase + owner?: ResourceOwnerRef +} + +export interface ResourceClaimCreateInput { + id?: string + kind: string + owner?: ResourceOwnerRef + selector?: Record + constraints?: Record +} + +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(protocolResourceRegisterEventName) +export const protocolResourceList = defineInvokeEventa(protocolResourceListEventName) +export const protocolResourceClaimCreate = defineInvokeEventa(protocolResourceClaimCreateEventName) +export const protocolResourceClaimRelease = defineInvokeEventa(protocolResourceClaimReleaseEventName) +export const protocolResourceClaimsList = defineInvokeEventa(protocolResourceClaimsListEventName) +export const protocolResourceBindingsList = defineInvokeEventa(protocolResourceBindingsListEventName) +export const protocolResourceSnapshot = defineInvokeEventa(protocolResourceSnapshotEventName) diff --git a/packages/plugin-sdk/src/plugin/apis/protocol/resources/index.ts b/packages/plugin-sdk/src/plugin/apis/protocol/resources/index.ts index 1c0607ac9..46d458ad7 100644 --- a/packages/plugin-sdk/src/plugin/apis/protocol/resources/index.ts +++ b/packages/plugin-sdk/src/plugin/apis/protocol/resources/index.ts @@ -1 +1 @@ -export { protocolProviders } from './providers' +export * from './core' diff --git a/packages/plugin-sdk/src/plugin/apis/protocol/resources/providers/index.ts b/packages/plugin-sdk/src/plugin/apis/protocol/resources/providers/index.ts deleted file mode 100644 index 6fa60eef4..000000000 --- a/packages/plugin-sdk/src/plugin/apis/protocol/resources/providers/index.ts +++ /dev/null @@ -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, -} diff --git a/packages/server-runtime/src/index.ts b/packages/server-runtime/src/index.ts index bc067a846..172ee71af 100644 --- a/packages/server-runtime/src/index.ts +++ b/packages/server-runtime/src/index.ts @@ -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>() 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 diff --git a/packages/server-runtime/src/types/conn.ts b/packages/server-runtime/src/types/conn.ts index e5702c1aa..9f706c14e 100644 --- a/packages/server-runtime/src/types/conn.ts +++ b/packages/server-runtime/src/types/conn.ts @@ -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 + } } diff --git a/packages/server-sdk/src/client.ts b/packages/server-sdk/src/client.ts index 1d02ca53c..7ac8121b3 100644 --- a/packages/server-sdk/src/client.ts +++ b/packages/server-sdk/src/client.ts @@ -18,19 +18,33 @@ import { } from '@proj-airi/server-shared/types' export interface ClientOptions { - url?: string + url?: string | string[] name: string possibleEvents?: Array> 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 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 { private connected = false private connecting = false @@ -54,30 +70,59 @@ export class Client { private connectAttempt?: Promise private connectTask?: Promise private heartbeatTimer?: ReturnType - private readonly identity: MetadataEventSource - private readonly opts: Required, 'token'>> & Pick, 'token'> + private readonly opts: Omit, 'token'>>, 'url'> & { url: string } & Pick, '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, Set<(data: WebSocketBaseEvent) => void | Promise> >() constructor(options: ClientOptions) { + 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 { readTimeout: 30_000, message: MessageHeartbeat.Ping, }, - ...options, + ...restOptions, identity, } @@ -94,13 +139,60 @@ export class Client { // 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 { // 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 { } 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 { 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 { 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 { 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 { 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 { ws.close() } + this.discovered = false + this.approved = false + this.entrypointSelected = false + this.announced = false + await this.connect() } } diff --git a/packages/stage-pages/src/pages/settings/connection/ConnectionSettings.vue b/packages/stage-pages/src/pages/settings/connection/ConnectionSettings.vue index 385311063..77507b904 100644 --- a/packages/stage-pages/src/pages/settings/connection/ConnectionSettings.vue +++ b/packages/stage-pages/src/pages/settings/connection/ConnectionSettings.vue @@ -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) diff --git a/packages/stage-ui/src/stores/configurator.ts b/packages/stage-ui/src/stores/configurator.ts index 8c7729232..04e1ba5eb 100644 --- a/packages/stage-ui/src/stores/configurator.ts +++ b/packages/stage-ui/src/stores/configurator.ts @@ -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) { send({ @@ -15,7 +16,17 @@ export const useConfiguratorByModsChannelServer = defineStore('configurator:adap }) } + function updateForIfAvailable(moduleName: string, config: Record) { + if (!channelStore.hasModule(moduleName)) { + return false + } + + updateFor(moduleName, config) + return true + } + return { updateFor, + updateForIfAvailable, } }) diff --git a/packages/stage-ui/src/stores/mods/api/channel-server.ts b/packages/stage-ui/src/stores/mods/api/channel-server.ts index 186677e43..7ac1be358 100644 --- a/packages/stage-ui/src/stores/mods/api/channel-server.ts +++ b/packages/stage-ui/src/stores/mods/api/channel-server.ts @@ -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(null) + const connectedProtocol = ref<'wss' | 'ws' | null>(null) const client = ref() const initializing = ref | null>(null) const pendingSend = ref>([]) const listenersInitialized = ref(false) const listenerDisposers = ref void>>([]) + const onlineModules = ref([]) - 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>('settings/connection/websocket-protocol-cache', {}) const basePossibleEvents: Array = [ '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 }) { 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((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(data: WebSocketEventOptionalSource) { @@ -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, diff --git a/packages/stage-ui/src/stores/modules/consciousness.ts b/packages/stage-ui/src/stores/modules/consciousness.ts index 11cc4fb6e..84b4f5157 100644 --- a/packages/stage-ui/src/stores/modules/consciousness.ts +++ b/packages/stage-ui/src/stores/modules/consciousness.ts @@ -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('settings/consciousness/active-provider', '') const activeModel = useLocalStorageManualReset('settings/consciousness/active-model', '') const activeCustomModelName = useLocalStorageManualReset('settings/consciousness/active-custom-model', '') + const modelSyncTargetModules = useLocalStorageManualReset('settings/consciousness/model-sync-target-modules', ['proj-airi:airi-plugin-vscode']) const expandedDescriptions = refManualReset>(() => ({})) const modelSearchQuery = refManualReset('') @@ -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 + + 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, } }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6d4f04c8..cc5c9a5b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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