diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 3b871bd..4a6b619 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -4,6 +4,7 @@ import { Command } from 'commander'; import * as workgraph from '@versatly/workgraph-kernel'; import { startWorkgraphServer, waitForShutdown } from '@versatly/workgraph-control-api'; import { registerAutonomyCommands } from './cli/commands/autonomy.js'; +import { registerCapabilityCommands } from './cli/commands/capability.js'; import { registerConversationCommands } from './cli/commands/conversation.js'; import { registerDispatchCommands } from './cli/commands/dispatch.js'; import { registerMcpCommands } from './cli/commands/mcp.js'; @@ -2224,6 +2225,7 @@ registerConversationCommands(program, DEFAULT_ACTOR); registerSafetyCommands(program, DEFAULT_ACTOR); registerPortabilityCommands(program); registerFederationCommands(program, threadCmd, DEFAULT_ACTOR); +registerCapabilityCommands(program, DEFAULT_ACTOR); // ============================================================================ // onboarding diff --git a/packages/cli/src/cli/commands/capability.ts b/packages/cli/src/cli/commands/capability.ts new file mode 100644 index 0000000..7c352fe --- /dev/null +++ b/packages/cli/src/cli/commands/capability.ts @@ -0,0 +1,168 @@ +import { Command } from 'commander'; +import * as workgraph from '@versatly/workgraph-kernel'; +import { + addWorkspaceOption, + csv, + resolveWorkspacePath, + runCommand, +} from '../core.js'; + +export function registerCapabilityCommands(program: Command, defaultActor: string): void { + const capabilityCmd = program + .command('capability') + .description('Inspect agent capability registry and thread requirement matching'); + + addWorkspaceOption( + capabilityCmd + .command('list') + .description('List known capabilities and owning agents') + .option('--agent ', 'Filter to one agent') + .option('--json', 'Emit structured JSON output'), + ).action((opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + const registry = workgraph.capability.buildAgentCapabilityRegistry(workspacePath); + const agentFilter = normalizeToken(opts.agent); + const agents = agentFilter + ? registry.agents.filter((entry) => entry.agentName === agentFilter) + : registry.agents; + const capabilities = agentFilter + ? registry.capabilities.filter((entry) => entry.agents.includes(agentFilter)) + : registry.capabilities; + return { + generatedAt: registry.generatedAt, + agents, + capabilities, + agentCount: agents.length, + capabilityCount: capabilities.length, + }; + }, + (result) => { + if (result.agents.length === 0) return ['No agent capabilities found.']; + return [ + `Agents: ${result.agentCount}`, + `Capabilities: ${result.capabilityCount}`, + ...result.agents.map((entry) => + `${entry.agentName} caps=${entry.capabilities.length} skills=${entry.skills.length} adapters=${entry.adapters.length}`), + ]; + }, + ), + ); + + addWorkspaceOption( + capabilityCmd + .command('search ') + .description('Search capabilities by token or agent identifier') + .option('--agent ', 'Filter search results by one agent') + .option('--json', 'Emit structured JSON output'), + ).action((query, opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + const agentFilter = normalizeToken(opts.agent); + const results = workgraph.capability.searchCapabilityRegistry(workspacePath, query) + .filter((entry) => !agentFilter || entry.agents.includes(agentFilter)); + return { + query: String(query), + agent: agentFilter || undefined, + results, + count: results.length, + }; + }, + (result) => { + if (result.results.length === 0) return [`No capabilities matched "${result.query}".`]; + return [ + ...result.results.map((entry) => `${entry.capability} <- ${entry.agents.join(', ')}`), + `${result.count} capability match(es)`, + ]; + }, + ), + ); + + addWorkspaceOption( + capabilityCmd + .command('match ') + .description('Match one thread against an agent capability profile') + .option('-a, --agent ', 'Agent identity', defaultActor) + .option('--capabilities ', 'Comma-separated extra capabilities') + .option('--skills ', 'Comma-separated extra skills') + .option('--adapters ', 'Comma-separated extra adapters') + .option('--json', 'Emit structured JSON output'), + ).action((threadRef, opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + const normalizedAgent = normalizeToken(opts.agent ?? defaultActor); + if (!normalizedAgent) { + throw new Error('Agent name is required. Provide --agent.'); + } + const threadInstance = workgraph.capability.resolveThreadInstance(workspacePath, threadRef); + if (!threadInstance || threadInstance.type !== 'thread') { + throw new Error(`Thread not found: ${threadRef}`); + } + + const resolved = workgraph.capability.resolveAgentCapabilityProfile(workspacePath, normalizedAgent); + const mergedCapabilities = dedupeStrings([ + ...resolved.capabilities, + ...(csv(opts.capabilities) ?? []).map((item) => normalizeToken(item)), + ]); + const mergedSkills = dedupeStrings([ + ...resolved.skills, + ...(csv(opts.skills) ?? []).map((item) => normalizeToken(item)), + ]); + const mergedAdapters = dedupeStrings([ + ...resolved.adapters, + ...(csv(opts.adapters) ?? []).map((item) => normalizeToken(item)), + ]); + const profile = { + ...resolved, + capabilities: mergedCapabilities, + skills: mergedSkills, + adapters: mergedAdapters, + }; + const match = workgraph.capability.matchThreadToCapabilityProfile(threadInstance, profile); + + return { + thread: match.thread, + profile, + requirements: match.requirements, + missing: match.missing, + matched: match.matched, + }; + }, + (result) => { + const requirementSummary = [ + `capabilities=${result.requirements.capabilities.join(', ') || 'none'}`, + `skills=${result.requirements.skills.join(', ') || 'none'}`, + `adapters=${result.requirements.adapters.join(', ') || 'none'}`, + ].join(' '); + const missingSummary = [ + `capabilities=${result.missing.capabilities.join(', ') || 'none'}`, + `skills=${result.missing.skills.join(', ') || 'none'}`, + `adapters=${result.missing.adapters.join(', ') || 'none'}`, + ].join(' '); + return [ + `Thread: ${result.thread.path}`, + `Agent: ${result.profile.agentName}`, + `Matched: ${result.matched}`, + `Requirements: ${requirementSummary}`, + `Missing: ${missingSummary}`, + ]; + }, + ), + ); +} + +function normalizeToken(value: unknown): string { + return String(value ?? '') + .trim() + .toLowerCase(); +} + +function dedupeStrings(values: string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} diff --git a/packages/kernel/package.json b/packages/kernel/package.json index ed4a93a..47ec00b 100644 --- a/packages/kernel/package.json +++ b/packages/kernel/package.json @@ -11,5 +11,8 @@ "dependencies": { "gray-matter": "^4.0.3", "yaml": "^2.8.1" + }, + "devDependencies": { + "@versatly/workgraph-mcp-server": "workspace:*" } } diff --git a/packages/kernel/src/agent-self-assembly.ts b/packages/kernel/src/agent-self-assembly.ts index f266911..5c2e8f6 100644 --- a/packages/kernel/src/agent-self-assembly.ts +++ b/packages/kernel/src/agent-self-assembly.ts @@ -12,6 +12,7 @@ import * as auth from './auth.js'; import * as agent from './agent.js'; +import * as capability from './capability.js'; import * as conversation from './conversation.js'; import * as dispatch from './dispatch.js'; import * as orientation from './orientation.js'; @@ -20,12 +21,6 @@ import * as store from './store.js'; import * as thread from './thread.js'; import type { PrimitiveInstance, WorkgraphBrief, WorkgraphStatusSnapshot } from './types.js'; -const REQUIREMENT_TAG_PREFIXES = { - capability: 'requires:capability:', - skill: 'requires:skill:', - adapter: 'requires:adapter:', -} as const; - export interface AgentCapabilityAdvertisement { capabilities?: string[]; skills?: string[]; @@ -156,24 +151,7 @@ export function matchThreadToAgent( threadInstance: PrimitiveInstance, capabilityProfile: AgentCapabilityProfile, ): ThreadCapabilityMatch { - const requirements = readThreadCapabilityRequirements(threadInstance); - const missingCapabilities = requirements.capabilities - .filter((requiredCapability) => !capabilitySatisfied(capabilityProfile.capabilities, requiredCapability)); - const missingSkills = requirements.skills - .filter((requiredSkill) => !capabilityProfile.skills.includes(requiredSkill)); - const missingAdapters = requirements.adapters - .filter((requiredAdapter) => !capabilityProfile.adapters.includes(requiredAdapter)); - - return { - thread: threadInstance, - requirements, - missing: { - capabilities: missingCapabilities, - skills: missingSkills, - adapters: missingAdapters, - }, - matched: missingCapabilities.length === 0 && missingSkills.length === 0 && missingAdapters.length === 0, - }; + return capability.matchThreadToCapabilityProfile(threadInstance, capabilityProfile); } function maybeBootstrapRegister( @@ -383,53 +361,6 @@ function findPlanStepForExecution( }); } -function readThreadCapabilityRequirements(threadInstance: PrimitiveInstance): ThreadCapabilityRequirements { - const capabilityRequirements = dedupeStrings([ - ...asStringList(threadInstance.fields.required_capabilities), - ...asStringList(threadInstance.fields.requiredCapabilities), - ...extractTagRequirements(threadInstance.fields.tags, REQUIREMENT_TAG_PREFIXES.capability), - ]); - const skillRequirements = dedupeStrings([ - ...asStringList(threadInstance.fields.required_skills), - ...asStringList(threadInstance.fields.requiredSkills), - ...extractTagRequirements(threadInstance.fields.tags, REQUIREMENT_TAG_PREFIXES.skill), - ]); - const adapterRequirements = dedupeStrings([ - ...asStringList(threadInstance.fields.required_adapters), - ...asStringList(threadInstance.fields.requiredAdapters), - ...extractTagRequirements(threadInstance.fields.tags, REQUIREMENT_TAG_PREFIXES.adapter), - ]); - - return { - capabilities: capabilityRequirements, - skills: skillRequirements, - adapters: adapterRequirements, - }; -} - -function extractTagRequirements(value: unknown, prefix: string): string[] { - return asStringList(value) - .filter((tag) => tag.startsWith(prefix)) - .map((tag) => tag.slice(prefix.length)) - .filter(Boolean); -} - -function capabilitySatisfied(grantedCapabilities: string[], requiredCapability: string): boolean { - const normalizedRequired = normalizeToken(requiredCapability); - if (!normalizedRequired) return true; - for (const grantedCapability of grantedCapabilities) { - if (grantedCapability === '*') return true; - if (grantedCapability === normalizedRequired) return true; - if ( - grantedCapability.endsWith(':*') && - normalizedRequired.startsWith(`${grantedCapability.slice(0, -2)}:`) - ) { - return true; - } - } - return false; -} - function withCredentialContext(credentialToken: string | undefined, fn: () => T): T { const token = readOptionalString(credentialToken); if (!token) return fn(); diff --git a/packages/kernel/src/capability.test.ts b/packages/kernel/src/capability.test.ts new file mode 100644 index 0000000..0310f84 --- /dev/null +++ b/packages/kernel/src/capability.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import * as agent from './agent.js'; +import { + buildAgentCapabilityRegistry, + matchThreadToAgent, + matchThreadToCapabilityProfile, + readThreadCapabilityRequirements, + searchCapabilityRegistry, +} from './capability.js'; +import * as policy from './policy.js'; +import * as store from './store.js'; +import * as thread from './thread.js'; +import { initWorkspace } from './workspace.js'; + +let workspacePath: string; + +beforeEach(() => { + workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-capability-')); + initWorkspace(workspacePath, { createReadme: false }); +}); + +afterEach(() => { + fs.rmSync(workspacePath, { recursive: true, force: true }); +}); + +describe('capability registry and matching', () => { + it('builds a merged agent capability registry from policy and presence', () => { + policy.upsertParty( + workspacePath, + 'ops-agent', + { + roles: ['ops'], + capabilities: ['thread:claim', 'skill:incident-triage', 'adapter:shell-worker'], + }, + { + actor: 'system', + skipAuthorization: true, + }, + ); + agent.heartbeat(workspacePath, 'ops-agent', { + actor: 'system', + capabilities: ['domain:ops', 'skill:incident-triage'], + }); + + const registry = buildAgentCapabilityRegistry(workspacePath); + const opsAgent = registry.agents.find((entry) => entry.agentName === 'ops-agent'); + + expect(opsAgent).toBeDefined(); + expect(opsAgent?.capabilities).toEqual([ + 'adapter:shell-worker', + 'domain:ops', + 'skill:incident-triage', + 'thread:claim', + ]); + expect(opsAgent?.skills).toEqual(['incident-triage']); + expect(opsAgent?.adapters).toEqual(['shell-worker']); + expect(opsAgent?.sources).toEqual(['policy', 'presence']); + expect(registry.capabilities.find((entry) => entry.capability === 'domain:ops')?.agents).toEqual(['ops-agent']); + }); + + it('supports capability search by token or agent identifier', () => { + policy.upsertParty( + workspacePath, + 'router-agent', + { + roles: ['ops'], + capabilities: ['dispatch:run', 'thread:claim'], + }, + { + actor: 'system', + skipAuthorization: true, + }, + ); + + const dispatchMatches = searchCapabilityRegistry(workspacePath, 'dispatch'); + expect(dispatchMatches).toHaveLength(1); + expect(dispatchMatches[0].capability).toBe('dispatch:run'); + expect(dispatchMatches[0].agents).toContain('router-agent'); + + const agentMatches = searchCapabilityRegistry(workspacePath, 'router'); + expect(agentMatches.map((entry) => entry.capability)).toContain('dispatch:run'); + expect(agentMatches.map((entry) => entry.capability)).toContain('thread:claim'); + }); + + it('reads thread requirements and computes missing capability dimensions', () => { + const createdThread = thread.createThread( + workspacePath, + 'Ops triage', + 'Perform triage using shell tooling', + 'system', + ); + const updatedThread = store.update( + workspacePath, + createdThread.path, + { + required_capabilities: ['domain:ops'], + required_skills: ['incident-triage'], + required_adapters: ['shell-worker'], + tags: [ + 'requires:capability:dispatch:run', + 'requires:skill:postmortem-writing', + 'requires:adapter:cursor-cloud', + ], + }, + undefined, + 'system', + ); + + const requirements = readThreadCapabilityRequirements(updatedThread); + expect(requirements.capabilities).toEqual(['domain:ops', 'dispatch:run']); + expect(requirements.skills).toEqual(['incident-triage', 'postmortem-writing']); + expect(requirements.adapters).toEqual(['shell-worker', 'cursor-cloud']); + + const matched = matchThreadToCapabilityProfile(updatedThread, { + capabilities: [ + 'domain:*', + 'dispatch:run', + 'skill:incident-triage', + 'skill:postmortem-writing', + 'adapter:shell-worker', + 'adapter:cursor-cloud', + ], + }); + expect(matched.matched).toBe(true); + expect(matched.missing).toEqual({ + capabilities: [], + skills: [], + adapters: [], + }); + + const unmatched = matchThreadToCapabilityProfile(updatedThread, { + capabilities: ['domain:ops'], + skills: ['incident-triage'], + adapters: ['shell-worker'], + }); + expect(unmatched.matched).toBe(false); + expect(unmatched.missing.capabilities).toEqual(['dispatch:run']); + expect(unmatched.missing.skills).toEqual(['postmortem-writing']); + expect(unmatched.missing.adapters).toEqual(['cursor-cloud']); + }); + + it('matches a thread to an agent profile resolved from registry', () => { + policy.upsertParty( + workspacePath, + 'router-agent', + { + roles: ['ops'], + capabilities: ['domain:ops', 'dispatch:run', 'skill:incident-triage', 'adapter:shell-worker'], + }, + { + actor: 'system', + skipAuthorization: true, + }, + ); + agent.heartbeat(workspacePath, 'router-agent', { + actor: 'system', + capabilities: ['domain:ops', 'dispatch:run', 'skill:incident-triage', 'adapter:shell-worker'], + }); + + const createdThread = thread.createThread( + workspacePath, + 'Route incident', + 'Dispatch and triage', + 'system', + ); + store.update( + workspacePath, + createdThread.path, + { + required_capabilities: ['domain:ops', 'dispatch:run'], + required_skills: ['incident-triage'], + required_adapters: ['shell-worker'], + }, + undefined, + 'system', + ); + + const match = matchThreadToAgent(workspacePath, 'route-incident', 'router-agent'); + expect(match.matched).toBe(true); + expect(match.profile.agentName).toBe('router-agent'); + expect(match.profile.capabilities).toContain('dispatch:run'); + }); +}); diff --git a/packages/kernel/src/capability.ts b/packages/kernel/src/capability.ts new file mode 100644 index 0000000..c4c6bd4 --- /dev/null +++ b/packages/kernel/src/capability.ts @@ -0,0 +1,342 @@ +/** + * Agent capability registry and thread requirement matching. + */ + +import * as policy from './policy.js'; +import * as store from './store.js'; +import type { PrimitiveInstance } from './types.js'; + +export const REQUIREMENT_TAG_PREFIXES = { + capability: 'requires:capability:', + skill: 'requires:skill:', + adapter: 'requires:adapter:', +} as const; + +export type CapabilitySource = 'policy' | 'presence'; + +export interface AgentCapabilityProfile { + agentName: string; + capabilities: string[]; + skills: string[]; + adapters: string[]; + sources: CapabilitySource[]; +} + +export interface CapabilityRegistryEntry { + capability: string; + agents: string[]; +} + +export interface AgentCapabilityRegistry { + generatedAt: string; + agents: AgentCapabilityProfile[]; + capabilities: CapabilityRegistryEntry[]; +} + +export interface ThreadCapabilityRequirements { + capabilities: string[]; + skills: string[]; + adapters: string[]; +} + +export interface CapabilityMatchProfile { + capabilities?: string[]; + skills?: string[]; + adapters?: string[]; +} + +export interface ThreadCapabilityMatch { + thread: PrimitiveInstance; + requirements: ThreadCapabilityRequirements; + missing: ThreadCapabilityRequirements; + matched: boolean; +} + +export interface ThreadAgentCapabilityMatch extends ThreadCapabilityMatch { + profile: AgentCapabilityProfile; +} + +export function buildAgentCapabilityRegistry(workspacePath: string): AgentCapabilityRegistry { + const byAgent = new Map; + sources: Set; + }>(); + + const ensureAgent = (agentName: string) => { + const normalizedAgent = normalizeToken(agentName); + if (!normalizedAgent) return null; + const existing = byAgent.get(normalizedAgent); + if (existing) return existing; + const created = { + capabilities: new Set(), + sources: new Set(), + }; + byAgent.set(normalizedAgent, created); + return created; + }; + + const policyRegistry = policy.loadPolicyRegistry(workspacePath); + for (const party of Object.values(policyRegistry.parties)) { + const agent = ensureAgent(party.id); + if (!agent) continue; + for (const capability of asStringList(party.capabilities)) { + agent.capabilities.add(capability); + } + if (agent.capabilities.size > 0) { + agent.sources.add('policy'); + } + } + + const presenceEntries = store.list(workspacePath, 'presence'); + for (const presence of presenceEntries) { + const fallbackName = basenameWithoutExtension(presence.path); + const agent = ensureAgent(String(presence.fields.name ?? fallbackName)); + if (!agent) continue; + const capabilities = asStringList(presence.fields.capabilities); + for (const capability of capabilities) { + agent.capabilities.add(capability); + } + if (capabilities.length > 0) { + agent.sources.add('presence'); + } + } + + const agents: AgentCapabilityProfile[] = [...byAgent.entries()] + .map(([agentName, value]) => { + const capabilities = [...value.capabilities].sort((a, b) => a.localeCompare(b)); + const skills = extractScopedValues(capabilities, 'skill:'); + const adapters = extractScopedValues(capabilities, 'adapter:'); + return { + agentName, + capabilities, + skills, + adapters, + sources: [...value.sources].sort((a, b) => a.localeCompare(b)), + }; + }) + .sort((a, b) => a.agentName.localeCompare(b.agentName)); + + const capabilityMap = new Map>(); + for (const agent of agents) { + for (const capability of agent.capabilities) { + const existing = capabilityMap.get(capability); + if (existing) { + existing.add(agent.agentName); + } else { + capabilityMap.set(capability, new Set([agent.agentName])); + } + } + } + const capabilities: CapabilityRegistryEntry[] = [...capabilityMap.entries()] + .map(([capability, agentsWithCapability]) => ({ + capability, + agents: [...agentsWithCapability].sort((a, b) => a.localeCompare(b)), + })) + .sort((a, b) => a.capability.localeCompare(b.capability)); + + return { + generatedAt: new Date().toISOString(), + agents, + capabilities, + }; +} + +export function searchCapabilityRegistry( + workspacePath: string, + query: string, +): CapabilityRegistryEntry[] { + const registry = buildAgentCapabilityRegistry(workspacePath); + const normalizedQuery = normalizeToken(query); + if (!normalizedQuery) return registry.capabilities; + return registry.capabilities.filter((entry) => + entry.capability.includes(normalizedQuery) || + entry.agents.some((agentName) => agentName.includes(normalizedQuery)) + ); +} + +export function resolveAgentCapabilityProfile( + workspacePath: string, + agentName: string, +): AgentCapabilityProfile { + const normalizedAgent = normalizeToken(agentName); + if (!normalizedAgent) { + throw new Error('Agent name is required.'); + } + const registry = buildAgentCapabilityRegistry(workspacePath); + const existing = registry.agents.find((entry) => entry.agentName === normalizedAgent); + if (existing) return existing; + return { + agentName: normalizedAgent, + capabilities: [], + skills: [], + adapters: [], + sources: [], + }; +} + +export function resolveThreadInstance( + workspacePath: string, + threadRef: string, +): PrimitiveInstance | null { + const normalizedRef = normalizeThreadRef(threadRef); + if (!normalizedRef) return null; + const direct = store.read(workspacePath, normalizedRef); + if (direct?.type === 'thread') return direct; + const slug = basenameWithoutExtension(normalizedRef); + if (!slug) return null; + return store.list(workspacePath, 'thread') + .find((candidate) => basenameWithoutExtension(candidate.path) === slug) ?? null; +} + +export function matchThreadToAgent( + workspacePath: string, + threadRef: string, + agentName: string, +): ThreadAgentCapabilityMatch { + const threadInstance = resolveThreadInstance(workspacePath, threadRef); + if (!threadInstance) { + throw new Error(`Thread not found: ${threadRef}`); + } + const profile = resolveAgentCapabilityProfile(workspacePath, agentName); + return { + ...matchThreadToCapabilityProfile(threadInstance, profile), + profile, + }; +} + +export function readThreadCapabilityRequirements( + threadInstance: PrimitiveInstance, +): ThreadCapabilityRequirements { + const capabilityRequirements = dedupeStrings([ + ...asStringList(threadInstance.fields.required_capabilities), + ...asStringList(threadInstance.fields.requiredCapabilities), + ...extractTagRequirements(threadInstance.fields.tags, REQUIREMENT_TAG_PREFIXES.capability), + ]); + const skillRequirements = dedupeStrings([ + ...asStringList(threadInstance.fields.required_skills), + ...asStringList(threadInstance.fields.requiredSkills), + ...extractTagRequirements(threadInstance.fields.tags, REQUIREMENT_TAG_PREFIXES.skill), + ]); + const adapterRequirements = dedupeStrings([ + ...asStringList(threadInstance.fields.required_adapters), + ...asStringList(threadInstance.fields.requiredAdapters), + ...extractTagRequirements(threadInstance.fields.tags, REQUIREMENT_TAG_PREFIXES.adapter), + ]); + + return { + capabilities: capabilityRequirements, + skills: skillRequirements, + adapters: adapterRequirements, + }; +} + +export function matchThreadToCapabilityProfile( + threadInstance: PrimitiveInstance, + profile: CapabilityMatchProfile, +): ThreadCapabilityMatch { + const normalizedCapabilities = dedupeStrings(asStringList(profile.capabilities)); + const normalizedSkills = dedupeStrings([ + ...extractScopedValues(normalizedCapabilities, 'skill:'), + ...asStringList(profile.skills), + ]); + const normalizedAdapters = dedupeStrings([ + ...extractScopedValues(normalizedCapabilities, 'adapter:'), + ...asStringList(profile.adapters), + ]); + const requirements = readThreadCapabilityRequirements(threadInstance); + const missingCapabilities = requirements.capabilities + .filter((requiredCapability) => !capabilitySatisfied(normalizedCapabilities, requiredCapability)); + const missingSkills = requirements.skills + .filter((requiredSkill) => !normalizedSkills.includes(requiredSkill)); + const missingAdapters = requirements.adapters + .filter((requiredAdapter) => !normalizedAdapters.includes(requiredAdapter)); + + return { + thread: threadInstance, + requirements, + missing: { + capabilities: missingCapabilities, + skills: missingSkills, + adapters: missingAdapters, + }, + matched: missingCapabilities.length === 0 && missingSkills.length === 0 && missingAdapters.length === 0, + }; +} + +export function capabilitySatisfied(grantedCapabilities: string[], requiredCapability: string): boolean { + const normalizedRequired = normalizeToken(requiredCapability); + if (!normalizedRequired) return true; + for (const grantedCapability of asStringList(grantedCapabilities)) { + if (grantedCapability === '*') return true; + if (grantedCapability === normalizedRequired) return true; + if ( + grantedCapability.endsWith(':*') && + normalizedRequired.startsWith(`${grantedCapability.slice(0, -2)}:`) + ) { + return true; + } + } + return false; +} + +function normalizeThreadRef(value: unknown): string { + const raw = String(value ?? '').trim(); + if (!raw) return ''; + const unwrapped = raw.startsWith('[[') && raw.endsWith(']]') + ? raw.slice(2, -2) + : raw; + const primary = unwrapped.split('|')[0].trim().split('#')[0].trim(); + if (!primary) return ''; + if (primary.startsWith('threads/')) { + return primary.endsWith('.md') ? primary : `${primary}.md`; + } + if (primary.includes('/')) { + return primary.endsWith('.md') ? primary : `${primary}.md`; + } + return `threads/${primary}.md`; +} + +function extractTagRequirements(value: unknown, prefix: string): string[] { + return asStringList(value) + .filter((tag) => tag.startsWith(prefix)) + .map((tag) => tag.slice(prefix.length)) + .filter(Boolean); +} + +function extractScopedValues(tokens: string[], prefix: string): string[] { + return dedupeStrings(tokens + .filter((token) => token.startsWith(prefix)) + .map((token) => token.slice(prefix.length)) + .filter(Boolean)); +} + +function asStringList(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .map((entry) => normalizeToken(entry)) + .filter(Boolean); + } + if (typeof value === 'string') { + return value + .split(',') + .map((entry) => normalizeToken(entry)) + .filter(Boolean); + } + return []; +} + +function dedupeStrings(values: string[]): string[] { + return [...new Set(values.map((value) => normalizeToken(value)).filter(Boolean))]; +} + +function basenameWithoutExtension(value: string): string { + const normalized = String(value ?? '').replace(/\\/g, '/'); + const basename = normalized.slice(normalized.lastIndexOf('/') + 1); + return basename.replace(/\.md$/i, '').trim().toLowerCase(); +} + +function normalizeToken(value: unknown): string { + return String(value ?? '') + .trim() + .toLowerCase(); +} diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts index 286c9c1..a079d6c 100644 --- a/packages/kernel/src/index.ts +++ b/packages/kernel/src/index.ts @@ -4,6 +4,7 @@ export * as ledger from './ledger.js'; export * as auth from './auth.js'; export * as store from './store.js'; export * as thread from './thread.js'; +export * as capability from './capability.js'; export * as conversation from './conversation.js'; export * as workspace from './workspace.js'; export * as serverConfig from './server-config.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fc9f85..2091b33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,39 +11,6 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.27.1 version: 1.27.1(zod@4.3.6) - '@versatly/workgraph-adapter-claude-code': - specifier: workspace:* - version: link:packages/adapter-claude-code - '@versatly/workgraph-adapter-cursor-cloud': - specifier: workspace:* - version: link:packages/adapter-cursor-cloud - '@versatly/workgraph-cli': - specifier: workspace:* - version: link:packages/cli - '@versatly/workgraph-control-api': - specifier: workspace:* - version: link:packages/control-api - '@versatly/workgraph-kernel': - specifier: workspace:* - version: link:packages/kernel - '@versatly/workgraph-mcp-server': - specifier: workspace:* - version: link:packages/mcp-server - '@versatly/workgraph-obsidian-integration': - specifier: workspace:* - version: link:packages/obsidian-integration - '@versatly/workgraph-policy': - specifier: workspace:* - version: link:packages/policy - '@versatly/workgraph-runtime-adapter-core': - specifier: workspace:* - version: link:packages/runtime-adapter-core - '@versatly/workgraph-search-qmd-adapter': - specifier: workspace:* - version: link:packages/search-qmd-adapter - '@versatly/workgraph-skills': - specifier: workspace:* - version: link:packages/skills commander: specifier: ^12.0.0 version: 12.1.0 @@ -122,6 +89,10 @@ importers: yaml: specifier: ^2.8.1 version: 2.8.2 + devDependencies: + '@versatly/workgraph-mcp-server': + specifier: workspace:* + version: link:../mcp-server packages/mcp-server: dependencies: