blob: ac0b246b6d7ef3a990a66b9e5d16e4dd758f2e6c [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Service, type Context } from './plugin-kernel.js';
import { PluginScopeRegistry } from './plugin-scope-registry.js';
import {
pluginIdentity,
registerPluginContribution,
type MakaContributionIdentity,
type MakaPluginRootId,
MakaPluginRuntimeError,
} from './plugin-runtime.js';
declare module './plugin-kernel.js' {
interface Context {
readonly skills: PluginSkillService;
}
}
export interface PluginSkillDefinition {
readonly name: string;
readonly description: string;
readonly instructions: string;
readonly declaredTools?: readonly string[];
readonly requiredTools?: readonly string[];
readonly modelInvocable?: boolean;
readonly userInvocable?: boolean;
}
export interface PluginSkillInspection extends MakaContributionIdentity {
readonly name: string;
}
interface RegisteredSkill extends MakaContributionIdentity {
readonly definition: PluginSkillDefinition;
readonly token: symbol;
retired: boolean;
}
/** Profile/Session-scoped Skill contributions owned by the registering Fiber. */
export class PluginSkillService extends Service {
private readonly registry = new PluginScopeRegistry<RegisteredSkill>();
private revision = 0;
constructor(ctx: Context) {
super(ctx, 'skills');
}
register(definition: PluginSkillDefinition): () => Promise<void> {
validateSkill(definition);
const identity = pluginIdentity(this.ctx);
if (identity.scopeId === 'desktop-ui')
throw new MakaPluginRuntimeError(
'activation_failed',
'desktop-ui plugins cannot register Host skills',
);
return registerPluginContribution(
this.ctx,
`skills.register(${JSON.stringify(definition.name)})`,
() => {
const rootId = identity.scopeId as MakaPluginRootId;
const existing = this.registry.get(rootId, definition.name);
if (existing && existing.entryId !== identity.entryId)
throw new MakaPluginRuntimeError(
'activation_failed',
`Plugin Skill ${JSON.stringify(definition.name)} is already registered by ${existing.entryId}`,
);
const dispose = this.registry.publish(rootId, definition.name, {
...identity,
definition: freezeSkill(definition),
token: Symbol(definition.name),
retired: false,
});
this.revision += 1;
return async () => {
await dispose();
this.revision += 1;
};
},
);
}
resolve(sessionId: string): readonly PluginSkillDefinition[] {
return Object.freeze(
[...this.registry.visible(assertScope(sessionId)).values()]
.sort(compareIdentity)
.map(({ definition }) => definition),
);
}
get(sessionId: string, name: string): PluginSkillDefinition | undefined {
return this.registry.visible(assertScope(sessionId)).get(name)?.definition;
}
snapshot(sessionId: string): {
readonly revision: number;
readonly skills: readonly PluginSkillDefinition[];
} {
return Object.freeze({ revision: this.revision, skills: this.resolve(sessionId) });
}
inspect(rootId?: MakaPluginRootId): readonly PluginSkillInspection[] {
return Object.freeze(
[...this.registry.entries(rootId)]
.sort(compareIdentity)
.map(({ definition, ...identity }) =>
Object.freeze({ ...identity, name: definition.name }),
),
);
}
}
function validateSkill(value: PluginSkillDefinition): void {
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(value.name))
throw new TypeError('Plugin Skill name must be lower-kebab-case');
if (!value.description.trim() || !value.instructions.trim())
throw new TypeError('Plugin Skill description and instructions are required');
if (value.instructions.length > 256 * 1024)
throw new TypeError('Plugin Skill instructions exceed 256 KiB');
}
function freezeSkill(value: PluginSkillDefinition): PluginSkillDefinition {
return Object.freeze({
...value,
description: value.description.trim(),
instructions: value.instructions,
declaredTools: Object.freeze([...(value.declaredTools ?? [])]),
requiredTools: Object.freeze([...(value.requiredTools ?? [])]),
});
}
function assertScope(value: string): string {
if (!value || /[\0\r\n]/u.test(value)) throw new TypeError('Session id is invalid');
return value;
}
function compareIdentity(left: MakaContributionIdentity, right: MakaContributionIdentity): number {
return left.entryId.localeCompare(right.entryId) || left.generation - right.generation;
}