@capekai/core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/package.json +105 -0
- package/src/adapters/ai-sdk.ts +84 -0
- package/src/compaction/contracts.ts +82 -0
- package/src/compaction/executor.ts +161 -0
- package/src/compaction/policy.ts +318 -0
- package/src/compaction/recovery.ts +139 -0
- package/src/compaction/task.ts +540 -0
- package/src/configuration/contracts.ts +58 -0
- package/src/configuration/defaults.ts +27 -0
- package/src/configuration/runtime.ts +42 -0
- package/src/configuration/single-model.ts +75 -0
- package/src/context/assembler.ts +112 -0
- package/src/context/index.ts +2 -0
- package/src/context/sources.ts +119 -0
- package/src/context/workspace.ts +63 -0
- package/src/core/agent.ts +401 -0
- package/src/core/build-tools.ts +139 -0
- package/src/core/chat-handler.ts +858 -0
- package/src/core/error-handling.ts +18 -0
- package/src/core/fork.ts +103 -0
- package/src/core/interrupt.ts +192 -0
- package/src/core/message-utils.ts +261 -0
- package/src/core/model-utils.ts +149 -0
- package/src/core/part-utils.ts +88 -0
- package/src/core/provider-utils.ts +67 -0
- package/src/core/revert.ts +46 -0
- package/src/core/step-handlers.ts +157 -0
- package/src/core/stream/finalization.ts +65 -0
- package/src/core/stream/stream-config.ts +82 -0
- package/src/core/stream-handlers.ts +242 -0
- package/src/core/structured-output.ts +68 -0
- package/src/core/tool-builders/agent-tools.ts +71 -0
- package/src/core/tool-builders/external-tools.ts +179 -0
- package/src/core/tool-builders/types.ts +16 -0
- package/src/core/tool-builders/workspace-tools.ts +293 -0
- package/src/core/tool-capabilities.ts +65 -0
- package/src/goals/evaluator.ts +171 -0
- package/src/goals/index.ts +3 -0
- package/src/goals/loop.ts +167 -0
- package/src/goals/service.ts +39 -0
- package/src/index.ts +10 -0
- package/src/internal/ask-authority.ts +29 -0
- package/src/internal/composition.ts +44 -0
- package/src/internal/configuration.ts +22 -0
- package/src/internal/execution.ts +108 -0
- package/src/internal/hosts.ts +64 -0
- package/src/internal/plugins.ts +71 -0
- package/src/internal/providers.ts +32 -0
- package/src/internal/sandbox.ts +19 -0
- package/src/internal/tools.ts +48 -0
- package/src/internal/workspace.ts +25 -0
- package/src/kernel/diagnostics.ts +249 -0
- package/src/kernel/errors.ts +120 -0
- package/src/kernel/events.ts +82 -0
- package/src/kernel/index.ts +72 -0
- package/src/kernel/kernel.ts +62 -0
- package/src/kernel/lifecycle.ts +72 -0
- package/src/kernel/plugin.ts +218 -0
- package/src/kernel/registry.ts +493 -0
- package/src/kernel/scope.ts +776 -0
- package/src/kernel/service-key.ts +19 -0
- package/src/kernel/types.ts +317 -0
- package/src/memory/index.ts +2 -0
- package/src/memory/memory-tool.ts +75 -0
- package/src/memory/registry.ts +172 -0
- package/src/permission/ask-user-api.ts +70 -0
- package/src/permission/contracts.ts +135 -0
- package/src/permission/permission-request-manager.ts +58 -0
- package/src/permission/policy.ts +277 -0
- package/src/permission/runtime.ts +612 -0
- package/src/plugins/compaction-policy.ts +46 -0
- package/src/plugins/compose.ts +171 -0
- package/src/plugins/context-sections.ts +246 -0
- package/src/plugins/default-agent-driver.ts +14 -0
- package/src/plugins/facade-plugins.ts +129 -0
- package/src/plugins/goal-domain.ts +82 -0
- package/src/plugins/legacy-system-message.ts +152 -0
- package/src/plugins/loaded-tools.ts +23 -0
- package/src/plugins/memory-domain.ts +264 -0
- package/src/plugins/orchestrator-session.ts +29 -0
- package/src/plugins/permission-policy.ts +49 -0
- package/src/plugins/retry-policy.ts +28 -0
- package/src/plugins/scheduler-domain.ts +192 -0
- package/src/plugins/service-keys.ts +294 -0
- package/src/plugins/session-search-domain.ts +238 -0
- package/src/plugins/skills-domain.ts +272 -0
- package/src/plugins/subagent-domain.ts +287 -0
- package/src/plugins/tool-catalog.ts +78 -0
- package/src/plugins/tool-output-policy.ts +52 -0
- package/src/plugins/value-plugins.ts +150 -0
- package/src/plugins/workflow-domain.ts +198 -0
- package/src/plugins/workspace-policy.ts +37 -0
- package/src/providers/registry.ts +63 -0
- package/src/providers/types.ts +44 -0
- package/src/retry/policy.ts +282 -0
- package/src/retry/stream-chat.ts +312 -0
- package/src/runtime/agent-runtime.ts +83 -0
- package/src/runtime/default-agent-driver.ts +23 -0
- package/src/runtime/domain-tool-source.ts +156 -0
- package/src/runtime/events.ts +61 -0
- package/src/runtime/host-dependencies.ts +71 -0
- package/src/runtime/host-guidance.ts +22 -0
- package/src/runtime/host-layout.ts +23 -0
- package/src/runtime/host.ts +129 -0
- package/src/runtime/standalone-host.ts +118 -0
- package/src/sandbox/controller.ts +204 -0
- package/src/sandbox/model.ts +305 -0
- package/src/sandbox/provider.ts +53 -0
- package/src/sandbox/types.ts +110 -0
- package/src/scheduler/host.ts +22 -0
- package/src/scheduler/scheduler-tool.ts +172 -0
- package/src/session-search/host.ts +56 -0
- package/src/session-search/index.ts +23 -0
- package/src/session-search/session-search-tool.ts +151 -0
- package/src/skills/index.ts +3 -0
- package/src/skills/registry.ts +63 -0
- package/src/skills/skill-manage-tool.ts +205 -0
- package/src/skills/skill-tool.ts +42 -0
- package/src/storage/contracts.ts +159 -0
- package/src/storage/memory.ts +321 -0
- package/src/storage/options.ts +75 -0
- package/src/storage/runtime.ts +115 -0
- package/src/storage/sqlite-tool-output-artifacts.ts +106 -0
- package/src/storage/sqlite.ts +321 -0
- package/src/storage/tool-output-artifacts.ts +75 -0
- package/src/storage.ts +31 -0
- package/src/subagent/child-session.ts +282 -0
- package/src/subagent/guidance.ts +8 -0
- package/src/subagent/policy.ts +198 -0
- package/src/subagent/task-tool.ts +584 -0
- package/src/tool-output/contracts.ts +111 -0
- package/src/tool-output/policy.ts +410 -0
- package/src/tool.ts +1 -0
- package/src/tools/executor.ts +258 -0
- package/src/tools/install-manifest.ts +40 -0
- package/src/tools/llm-api.ts +77 -0
- package/src/tools/registry.ts +206 -0
- package/src/tools/tool-artifact.ts +182 -0
- package/src/tools/tool-source.ts +53 -0
- package/src/utils/errors.ts +334 -0
- package/src/utils/strip-visualization.ts +50 -0
- package/src/workflow/decomposer.ts +139 -0
- package/src/workflow/execution.ts +523 -0
- package/src/workflow/orchestrator-session.ts +161 -0
- package/src/workflow/synthesizer.ts +130 -0
- package/src/workspace/contracts.ts +135 -0
- package/src/workspace/policy.ts +327 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Session, Workspace } from '@capekai/types';
|
|
2
|
+
|
|
3
|
+
export interface SearchMessageResult {
|
|
4
|
+
messageId: string;
|
|
5
|
+
sessionId: string;
|
|
6
|
+
workspaceId: string;
|
|
7
|
+
role: string;
|
|
8
|
+
content: string;
|
|
9
|
+
timestamp: number;
|
|
10
|
+
sessionTitle: string | null;
|
|
11
|
+
rank: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SessionSearchHost {
|
|
15
|
+
getWorkspace(id: string): Promise<Workspace | null>;
|
|
16
|
+
getSession(id: string): Promise<Session | null>;
|
|
17
|
+
listWorkspaceSessions(workspaceId: string): Promise<Session[]>;
|
|
18
|
+
listAgentSessions(agentId: string, limit: number): Promise<Session[]>;
|
|
19
|
+
countSessionMessages(sessionId: string): Promise<number>;
|
|
20
|
+
searchMessages(options: {
|
|
21
|
+
query: string;
|
|
22
|
+
workspaceId?: string;
|
|
23
|
+
agentId?: string;
|
|
24
|
+
sessionId?: string;
|
|
25
|
+
roleFilter: string[];
|
|
26
|
+
limit: number;
|
|
27
|
+
sort: 'relevance' | 'newest' | 'oldest';
|
|
28
|
+
}): Promise<SearchMessageResult[]>;
|
|
29
|
+
countMessagesBefore(sessionId: string, timestamp: number): Promise<number>;
|
|
30
|
+
countMessagesAfter(sessionId: string, timestamp: number): Promise<number>;
|
|
31
|
+
getLatestMessage(sessionId: string): Promise<{ id: string; timestamp: number } | null>;
|
|
32
|
+
getMessage(messageId: string, sessionId: string): Promise<{ id: string; timestamp: number } | null>;
|
|
33
|
+
listMessagesBefore(sessionId: string, timestamp: number, limit: number): Promise<Array<{ id: string; role: string; timestamp: number }>>;
|
|
34
|
+
listMessagesAfter(sessionId: string, timestamp: number, limit: number): Promise<Array<{ id: string; role: string; timestamp: number }>>;
|
|
35
|
+
getMessageSummary(messageId: string): Promise<{ role: string; timestamp: number; content: string; toolName: string } | null>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const emptyHost: SessionSearchHost = {
|
|
39
|
+
getWorkspace: async () => null,
|
|
40
|
+
getSession: async () => null,
|
|
41
|
+
listWorkspaceSessions: async () => [],
|
|
42
|
+
listAgentSessions: async () => [],
|
|
43
|
+
countSessionMessages: async () => 0,
|
|
44
|
+
searchMessages: async () => [],
|
|
45
|
+
countMessagesBefore: async () => 0,
|
|
46
|
+
countMessagesAfter: async () => 0,
|
|
47
|
+
getLatestMessage: async () => null,
|
|
48
|
+
getMessage: async () => null,
|
|
49
|
+
listMessagesBefore: async () => [],
|
|
50
|
+
listMessagesAfter: async () => [],
|
|
51
|
+
getMessageSummary: async () => null,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
let host = emptyHost;
|
|
55
|
+
export function configureSessionSearchHost(value?: SessionSearchHost): void { host = value ?? emptyHost; }
|
|
56
|
+
export function getSessionSearchHost(): SessionSearchHost { return host; }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export * from './host';
|
|
2
|
+
export * from './session-search-tool';
|
|
3
|
+
|
|
4
|
+
export const SESSION_SEARCH_GUIDANCE = `You can use session_search to recall prior conversation details from the current workspace or current session archive.
|
|
5
|
+
Use it when the user references past work, says "we did this before", asks what happened earlier, or when compaction may have removed exact details from active context.
|
|
6
|
+
|
|
7
|
+
Three modes:
|
|
8
|
+
1. List mode (action: "list"): Enumerate recent sessions in the workspace. Returns session IDs, titles, and message counts. Use this to discover what exists.
|
|
9
|
+
2. Search mode (provide "query"): Full-text search across messages.
|
|
10
|
+
3. Read mode (provide "sessionId"): Read the latest context from a session. Optionally provide "aroundMessageId" to anchor at a specific message.
|
|
11
|
+
|
|
12
|
+
Search scopes:
|
|
13
|
+
- scope="current_session": Search only this session archive.
|
|
14
|
+
- scope="workspace": Search all sessions in the current workspace (default).
|
|
15
|
+
- scope="agent": Search YOUR past sessions across ALL workspaces. Use this to recall work from other projects.
|
|
16
|
+
|
|
17
|
+
Typical workflow: list sessions, then read a session's latest context, then search for specific keywords if needed.
|
|
18
|
+
Prefer scope="current_session" when looking for details from earlier in this same conversation.
|
|
19
|
+
Prefer scope="workspace" when looking for related previous sessions in this workspace.
|
|
20
|
+
Use scope="agent" when you need to recall work from a different project.
|
|
21
|
+
Do not ask the user to repeat information until you have searched likely prior context.
|
|
22
|
+
Search results are snippets; use read mode with sessionId to get full surrounding context.
|
|
23
|
+
Default search focuses on user/assistant messages. Include tool results only when exact tool output, commands, errors, or logs are relevant.`;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { PermissionAsk } from '@capekai/tool'
|
|
2
|
+
import type { PermissionRiskLevel } from '@capekai/tool'
|
|
3
|
+
import type { Session } from '@capekai/types';
|
|
4
|
+
import { getSessionSearchHost, type SessionSearchHost } from './host';
|
|
5
|
+
|
|
6
|
+
export const sessionSearchToolDefinition = {
|
|
7
|
+
name: 'session_search',
|
|
8
|
+
description: `Search prior conversation messages, list recent sessions, or read session context from the current workspace.
|
|
9
|
+
Use it to recall past work, find earlier discussions, or retrieve details that may have been compacted away from active context.
|
|
10
|
+
Three modes:
|
|
11
|
+
1. List mode (provide "action": "list"): List recent sessions in the workspace with their IDs, titles, and message counts. Use this to discover what sessions exist before reading.
|
|
12
|
+
2. Search mode (provide "query"): Full-text search across messages in the workspace or current session.
|
|
13
|
+
3. Read-around mode (provide "sessionId", optionally "aroundMessageId"): Read messages surrounding a specific message. If "aroundMessageId" is omitted, reads the latest messages in that session.
|
|
14
|
+
|
|
15
|
+
Typical workflow: list sessions → read a session's latest context → search for specific keywords if needed.`,
|
|
16
|
+
inputSchema: {
|
|
17
|
+
type: 'object' as const,
|
|
18
|
+
properties: {
|
|
19
|
+
action: { type: 'string' as const, enum: ['list', 'search', 'read'], description: 'The action to perform. "list": enumerate recent sessions. "search": full-text search (default if query provided). "read": read session context. Defaults to "search" if query is provided, "read" if sessionId is provided.' },
|
|
20
|
+
query: { type: 'string' as const, description: 'Search query for full-text search. Triggers search mode.' },
|
|
21
|
+
scope: { type: 'string' as const, enum: ['current_session', 'workspace', 'agent'], description: 'Search scope. "current_session" searches only the current session archive. "workspace" searches all sessions in the workspace. "agent" searches YOUR past sessions across ALL workspaces. Defaults to "workspace".' },
|
|
22
|
+
sessionId: { type: 'string' as const, description: 'Session ID for read-around mode. Use "list" action first to discover session IDs. Must belong to the current workspace or (for agents) be an agent-owned session.' },
|
|
23
|
+
aroundMessageId: { type: 'string' as const, description: 'Anchor message ID for read-around mode. Returns surrounding messages. If omitted, reads the latest messages in the session.' },
|
|
24
|
+
limit: { type: 'number' as const, description: 'Max results for search mode, or max sessions for list mode. Default 5, max 20.' },
|
|
25
|
+
window: { type: 'number' as const, description: 'Number of messages to return around the anchor in read-around mode. Default 8, max 25.' },
|
|
26
|
+
roleFilter: { type: 'array' as const, items: { type: 'string' as const, enum: ['user', 'assistant', 'tool'] }, description: 'Roles to include in results. Defaults to ["user", "assistant"] unless workspace includes tool results.' },
|
|
27
|
+
sort: { type: 'string' as const, enum: ['relevance', 'newest', 'oldest'], description: 'Sort order for search results. Defaults to "relevance".' },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
timeout: 15000,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const MAX_CONTENT_LENGTH = 2000;
|
|
34
|
+
export interface SessionListEntry { id: string; title: string; messageCount: number; updatedAt: string }
|
|
35
|
+
export interface SessionSearchResult {
|
|
36
|
+
success: boolean;
|
|
37
|
+
mode: 'list' | 'search' | 'read';
|
|
38
|
+
title: string;
|
|
39
|
+
sessions?: SessionListEntry[];
|
|
40
|
+
query?: string;
|
|
41
|
+
scope?: string;
|
|
42
|
+
results?: Array<{ sessionId: string; sessionTitle: string | null; messageId: string; role: string; timestamp: number; snippet: string; rank: number; messagesBefore: number; messagesAfter: number }>;
|
|
43
|
+
sessionId?: string;
|
|
44
|
+
sessionTitle?: string | null;
|
|
45
|
+
anchorMessageId?: string;
|
|
46
|
+
anchorInferred?: boolean;
|
|
47
|
+
messagesBefore?: number;
|
|
48
|
+
messagesAfter?: number;
|
|
49
|
+
messages?: Array<{ id: string; role: string; timestamp: number; content: string }>;
|
|
50
|
+
error?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Unscoped execution path: reads the configured module-level host, exactly
|
|
54
|
+
* like the pre-C5 tool. */
|
|
55
|
+
export async function executeSessionSearchTool(input: Record<string, unknown>, workspaceId: string, currentSessionId: string, includeToolResults: boolean, risk: PermissionRiskLevel, askFn?: (ask: PermissionAsk) => Promise<unknown>, agentId?: string | null): Promise<SessionSearchResult> {
|
|
56
|
+
return runSessionSearch(getSessionSearchHost(), input, workspaceId, currentSessionId, includeToolResults, risk, askFn, agentId);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Composed execution path: the domain plugin captures the process-scoped
|
|
60
|
+
* host service at setup and passes it here, so composed execution never
|
|
61
|
+
* reads the mutable module-global host accessor. */
|
|
62
|
+
export async function executeSessionSearchToolWithHost(host: SessionSearchHost, input: Record<string, unknown>, workspaceId: string, currentSessionId: string, includeToolResults: boolean, risk: PermissionRiskLevel, askFn?: (ask: PermissionAsk) => Promise<unknown>, agentId?: string | null): Promise<SessionSearchResult> {
|
|
63
|
+
return runSessionSearch(host, input, workspaceId, currentSessionId, includeToolResults, risk, askFn, agentId);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function runSessionSearch(host: SessionSearchHost, input: Record<string, unknown>, workspaceId: string, currentSessionId: string, includeToolResults: boolean, risk: PermissionRiskLevel, askFn?: (ask: PermissionAsk) => Promise<unknown>, agentId?: string | null): Promise<SessionSearchResult> {
|
|
67
|
+
const workspace = await host.getWorkspace(workspaceId);
|
|
68
|
+
if (!workspace) return { success: false, mode: 'search', title: 'Workspace not found', error: 'Workspace not found' };
|
|
69
|
+
const query = input.query as string | undefined;
|
|
70
|
+
const scope = (input.scope as string) || 'workspace';
|
|
71
|
+
const sessionId = input.sessionId as string | undefined;
|
|
72
|
+
const action = (input.action as string) || (query ? 'search' : sessionId ? 'read' : 'search');
|
|
73
|
+
if (scope === 'agent' && !agentId) {
|
|
74
|
+
return { success: false, mode: action === 'read' ? 'read' : action === 'list' ? 'list' : 'search', title: 'Agent scope unavailable', error: 'Agent scope requires an agent session' };
|
|
75
|
+
}
|
|
76
|
+
if (action === 'list') return executeList(host, workspaceId, currentSessionId, input, scope, agentId);
|
|
77
|
+
if (risk !== 'none' && askFn) {
|
|
78
|
+
const approved = await askFn({ type: 'permission', question: query ? `Allow searching workspace sessions for "${query.slice(0, 100)}"?` : 'Allow reading session context?', description: `Tool: session_search\nWorkspace: ${workspace.name}${query ? `\nQuery: ${query.slice(0, 200)}` : ''}\nScope: ${scope}`, risk, resource: 'session', action: 'read' });
|
|
79
|
+
if (!approved) return { success: false, mode: 'search', title: 'Permission denied', error: 'USER_REJECTION' };
|
|
80
|
+
}
|
|
81
|
+
if (query) return executeSearch(host, query, scope, workspaceId, currentSessionId, includeToolResults, input, agentId);
|
|
82
|
+
if (sessionId) return executeReadAround(host, sessionId, input.aroundMessageId as string | undefined, workspaceId, input, agentId);
|
|
83
|
+
return { success: false, mode: 'search', title: 'Invalid arguments', error: 'Provide "action": "list" to enumerate sessions, "query" for search mode, or "sessionId" for read-around mode.' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function executeList(host: SessionSearchHost, workspaceId: string, currentSessionId: string, input: Record<string, unknown>, scope: string, agentId?: string | null): Promise<SessionSearchResult> {
|
|
87
|
+
const limit = Math.min(Math.max((input.limit as number) || 10, 1), 20);
|
|
88
|
+
let sessions: Session[];
|
|
89
|
+
let label: string;
|
|
90
|
+
if (scope === 'agent' && agentId) {
|
|
91
|
+
sessions = await host.listAgentSessions(agentId, limit);
|
|
92
|
+
label = 'agent sessions (cross-workspace)';
|
|
93
|
+
} else {
|
|
94
|
+
sessions = await host.listWorkspaceSessions(workspaceId);
|
|
95
|
+
label = scope === 'current_session' ? 'current session' : 'workspace';
|
|
96
|
+
}
|
|
97
|
+
const limited = sessions.slice(0, limit);
|
|
98
|
+
if (limited.length === 0) return { success: true, mode: 'list', title: 'No sessions found', sessions: [] };
|
|
99
|
+
const entries: SessionListEntry[] = [];
|
|
100
|
+
for (const session of limited) {
|
|
101
|
+
entries.push({ id: session.id, title: session.title || '(untitled)', messageCount: await host.countSessionMessages(session.id), updatedAt: session.updatedAt, ...(session.id === currentSessionId && { isCurrent: true }) } as SessionListEntry);
|
|
102
|
+
}
|
|
103
|
+
return { success: true, mode: 'list', title: `${sessions.length} session${sessions.length === 1 ? '' : 's'} (${label})`, sessions: entries };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function executeSearch(host: SessionSearchHost, query: string, scope: string, workspaceId: string, currentSessionId: string, includeTools: boolean, input: Record<string, unknown>, agentId?: string | null): Promise<SessionSearchResult> {
|
|
107
|
+
const limit = Math.min(Math.max((input.limit as number) || 5, 1), 20);
|
|
108
|
+
const sort = ((input.sort as string) || 'relevance') as 'relevance' | 'newest' | 'oldest';
|
|
109
|
+
let roles = (input.roleFilter as string[] | undefined) ?? (includeTools ? ['user', 'assistant', 'tool'] : ['user', 'assistant']);
|
|
110
|
+
roles = roles.filter((role) => ['user', 'assistant', 'tool'].includes(role));
|
|
111
|
+
if (roles.length === 0) roles = ['user', 'assistant'];
|
|
112
|
+
const results = await host.searchMessages({ query, workspaceId: scope === 'agent' ? undefined : workspaceId, agentId: scope === 'agent' ? agentId ?? undefined : undefined, sessionId: scope === 'current_session' ? currentSessionId : undefined, roleFilter: roles, limit, sort });
|
|
113
|
+
if (results.length === 0) return { success: true, mode: 'search', title: 'No prior context found', query, scope, results: [] };
|
|
114
|
+
const mappedResults: NonNullable<SessionSearchResult['results']> = [];
|
|
115
|
+
for (const result of results) {
|
|
116
|
+
mappedResults.push({ sessionId: result.sessionId, sessionTitle: result.sessionTitle, messageId: result.messageId, role: result.role, timestamp: result.timestamp, snippet: result.content, rank: result.rank, messagesBefore: await host.countMessagesBefore(result.sessionId, result.timestamp), messagesAfter: await host.countMessagesAfter(result.sessionId, result.timestamp) });
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
success: true, mode: 'search', title: `Searched ${scope === 'current_session' ? 'current session' : scope === 'agent' ? 'agent sessions (cross-workspace)' : 'workspace sessions'}`, query, scope,
|
|
120
|
+
results: mappedResults,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function executeReadAround(host: SessionSearchHost, sessionId: string, anchorId: string | undefined, workspaceId: string, input: Record<string, unknown>, agentId?: string | null): Promise<SessionSearchResult> {
|
|
125
|
+
const session = await host.getSession(sessionId);
|
|
126
|
+
if (!session) return { success: false, mode: 'read', title: 'Session not found', error: 'Session not found' };
|
|
127
|
+
if (session.workspaceId !== workspaceId && !(agentId && session.agentId === agentId)) return { success: false, mode: 'read', title: 'Access denied', error: 'Session does not belong to current workspace or agent' };
|
|
128
|
+
let inferred = false;
|
|
129
|
+
let anchor = anchorId ? await host.getMessage(anchorId, sessionId) : null;
|
|
130
|
+
if (!anchorId) {
|
|
131
|
+
anchor = await host.getLatestMessage(sessionId);
|
|
132
|
+
inferred = true;
|
|
133
|
+
if (!anchor) return { success: false, mode: 'read', title: 'Empty session', error: 'Session has no messages' };
|
|
134
|
+
}
|
|
135
|
+
if (!anchor) return { success: false, mode: 'read', title: 'Message not found', error: 'Anchor message not found in session' };
|
|
136
|
+
const window = Math.min(Math.max((input.window as number) || 8, 1), 25);
|
|
137
|
+
const half = Math.floor(window / 2);
|
|
138
|
+
const before = await host.listMessagesBefore(sessionId, anchor.timestamp, half);
|
|
139
|
+
const after = await host.listMessagesAfter(sessionId, anchor.timestamp, half);
|
|
140
|
+
const ids = [...before.reverse().map((message) => message.id), anchor.id, ...after.map((message) => message.id)];
|
|
141
|
+
const messages: NonNullable<SessionSearchResult['messages']> = [];
|
|
142
|
+
for (const id of ids) {
|
|
143
|
+
const summary = await host.getMessageSummary(id);
|
|
144
|
+
if (!summary) continue;
|
|
145
|
+
let text = summary.content;
|
|
146
|
+
if (summary.toolName) text = text ? `${text} [tool: ${summary.toolName}]` : `[tool: ${summary.toolName}]`;
|
|
147
|
+
if (text.length > MAX_CONTENT_LENGTH) text = `${text.slice(0, MAX_CONTENT_LENGTH)}...`;
|
|
148
|
+
messages.push({ id, role: summary.role, timestamp: summary.timestamp, content: text || '(no text content)' });
|
|
149
|
+
}
|
|
150
|
+
return { success: true, mode: 'read', title: inferred ? 'Read latest session context' : 'Read session context', sessionId, sessionTitle: session.title, anchorMessageId: anchor.id, ...(inferred && { anchorInferred: true }), messagesBefore: await host.countMessagesBefore(sessionId, anchor.timestamp), messagesAfter: await host.countMessagesAfter(sessionId, anchor.timestamp), messages };
|
|
151
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { readdir, readFile } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { getHostLayout } from '../runtime/host-layout';
|
|
5
|
+
import type { SkillInfo } from '@capekai/types';
|
|
6
|
+
|
|
7
|
+
function parseFrontmatter(raw: string): { frontmatter: Record<string, unknown>; content: string } {
|
|
8
|
+
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
9
|
+
if (!match) return { frontmatter: {}, content: raw };
|
|
10
|
+
const frontmatter: Record<string, unknown> = {};
|
|
11
|
+
for (const line of match[1].split('\n')) {
|
|
12
|
+
const colon = line.indexOf(':');
|
|
13
|
+
if (colon > 0) frontmatter[line.slice(0, colon).trim()] = line.slice(colon + 1).trim().replace(/^['"]|['"]$/g, '');
|
|
14
|
+
}
|
|
15
|
+
return { frontmatter, content: match[2] };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function scanSkillsDir(skillsDir: string): Promise<SkillInfo[]> {
|
|
19
|
+
if (!existsSync(skillsDir)) return [];
|
|
20
|
+
const skills: SkillInfo[] = [];
|
|
21
|
+
try {
|
|
22
|
+
for (const folder of await readdir(skillsDir, { withFileTypes: true })) {
|
|
23
|
+
if (!folder.isDirectory()) continue;
|
|
24
|
+
const location = join(skillsDir, folder.name, 'SKILL.md');
|
|
25
|
+
try {
|
|
26
|
+
if (!existsSync(location)) continue;
|
|
27
|
+
const { frontmatter, content } = parseFrontmatter(await readFile(location, 'utf-8'));
|
|
28
|
+
if (!frontmatter.name || !frontmatter.description) {
|
|
29
|
+
console.warn(`Invalid SKILL.md in ${folder.name}: missing name or description`);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
skills.push({ name: frontmatter.name as string, description: frontmatter.description as string, location, content: content.trim(), userInvocable: frontmatter['user-invocable'] !== false });
|
|
33
|
+
} catch (error: unknown) {
|
|
34
|
+
console.warn(`Failed to read SKILL.md in ${folder.name}:`, error);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} catch (error: unknown) {
|
|
38
|
+
console.error('Failed to scan skills directory:', error);
|
|
39
|
+
}
|
|
40
|
+
return skills;
|
|
41
|
+
}
|
|
42
|
+
export const scanSkillsFromDir = scanSkillsDir;
|
|
43
|
+
export async function scanSkills(workspacePath: string, agentSkillsDir?: string): Promise<SkillInfo[]> {
|
|
44
|
+
const skills = await scanSkillsDir(getHostLayout().workspaceSkillsDir(workspacePath));
|
|
45
|
+
if (agentSkillsDir) {
|
|
46
|
+
const names = new Set(skills.map((skill) => skill.name));
|
|
47
|
+
for (const skill of await scanSkillsDir(agentSkillsDir)) if (!names.has(skill.name)) skills.push(skill);
|
|
48
|
+
}
|
|
49
|
+
return skills;
|
|
50
|
+
}
|
|
51
|
+
export async function getSkill(name: string, workspacePath: string, agentSkillsDir?: string): Promise<SkillInfo | null> {
|
|
52
|
+
return (await scanSkills(workspacePath, agentSkillsDir)).find((skill) => skill.name === name) ?? null;
|
|
53
|
+
}
|
|
54
|
+
export const listSkills = scanSkills;
|
|
55
|
+
export async function getAvailableSkills(workspacePath: string, allowed: string[] | null | undefined, agentSkillsDir?: string): Promise<SkillInfo[]> {
|
|
56
|
+
const all = await scanSkills(workspacePath, agentSkillsDir);
|
|
57
|
+
if (allowed === undefined || allowed === null) return all;
|
|
58
|
+
if (allowed.length === 0) return [];
|
|
59
|
+
return all.filter((skill) => allowed.includes(skill.name));
|
|
60
|
+
}
|
|
61
|
+
export function formatSkillsList(skills: SkillInfo[]): string {
|
|
62
|
+
return skills.length === 0 ? 'No skills are currently available.' : skills.map((skill) => `- **${skill.name}**: ${skill.description}`).join('\n');
|
|
63
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { mkdir, readFile, readdir, rm, writeFile } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import type { PermissionAsk } from '@capekai/tool';
|
|
5
|
+
import { PermissionRiskLevel } from '@capekai/tool';
|
|
6
|
+
import { scanSkillsFromDir } from './registry';
|
|
7
|
+
|
|
8
|
+
type SkillManageAction = 'list' | 'create' | 'update' | 'patch' | 'delete';
|
|
9
|
+
export interface SkillManageResult {
|
|
10
|
+
success: boolean;
|
|
11
|
+
title?: string;
|
|
12
|
+
action?: SkillManageAction;
|
|
13
|
+
name?: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
path?: string;
|
|
16
|
+
summary?: string;
|
|
17
|
+
skills?: Array<{ name: string; description: string }>;
|
|
18
|
+
error?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sanitizeSkillName(name: string): string {
|
|
22
|
+
return name.toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
|
23
|
+
}
|
|
24
|
+
function validateSkillName(name: string): string | null {
|
|
25
|
+
if (!name || name.trim().length === 0) return 'Skill name cannot be empty.';
|
|
26
|
+
if (name.includes('/') || name.includes('\\')) return 'Skill name cannot contain path separators.';
|
|
27
|
+
if (name.startsWith('.')) return 'Skill name cannot start with a dot.';
|
|
28
|
+
if (name.includes('..')) return 'Skill name cannot contain "..".';
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const frontmatter = (name: string, description: string) => `---\nname: ${name}\ndescription: ${description}\n---\n`;
|
|
32
|
+
const skillDir = (root: string, name: string) => join(root, name);
|
|
33
|
+
const skillPath = (root: string, name: string) => join(skillDir(root, name), 'SKILL.md');
|
|
34
|
+
|
|
35
|
+
async function resolveSkillFolder(rawName: string, root: string): Promise<{ folderName: string; skillMdPath: string } | null> {
|
|
36
|
+
const safeName = sanitizeSkillName(rawName);
|
|
37
|
+
const direct = skillPath(root, safeName);
|
|
38
|
+
if (existsSync(direct)) return { folderName: safeName, skillMdPath: direct };
|
|
39
|
+
if (!existsSync(root)) return null;
|
|
40
|
+
try {
|
|
41
|
+
for (const folder of await readdir(root, { withFileTypes: true })) {
|
|
42
|
+
if (!folder.isDirectory()) continue;
|
|
43
|
+
const path = join(root, folder.name, 'SKILL.md');
|
|
44
|
+
if (!existsSync(path)) continue;
|
|
45
|
+
if (folder.name.toLowerCase() === safeName.toLowerCase()) return { folderName: folder.name, skillMdPath: path };
|
|
46
|
+
try {
|
|
47
|
+
const match = (await readFile(path, 'utf-8')).match(/^---\n([\s\S]*?)\n---\n?/);
|
|
48
|
+
const name = match?.[1].match(/^name:\s*(.+)$/m)?.[1].replace(/^['"]|['"]$/g, '').trim().toLowerCase();
|
|
49
|
+
if (name === safeName.toLowerCase()) return { folderName: folder.name, skillMdPath: path };
|
|
50
|
+
} catch {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
async function availableNames(root: string): Promise<string[]> {
|
|
60
|
+
return (await scanSkillsFromDir(root)).map((skill) => skill.name);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function buildSkillManageToolDescription(root: string): Promise<string> {
|
|
64
|
+
const skills = await scanSkillsFromDir(root);
|
|
65
|
+
const lines = [
|
|
66
|
+
'Create, update, patch, delete, or list workspace skills.', '',
|
|
67
|
+
'Workspace skills are reusable procedures and workflows stored as SKILL.md files in .agents/skills/.', '',
|
|
68
|
+
'Actions:',
|
|
69
|
+
'- list: List all existing skills with their names and descriptions. No other parameters needed.',
|
|
70
|
+
'- create: Create a new skill. Requires name, description, and content (markdown body).',
|
|
71
|
+
'- update: Replace a skill\'s full body and optionally update description. Requires name and content.',
|
|
72
|
+
'- patch: Targeted string replacement in a skill\'s SKILL.md. Requires name, oldString, newString.',
|
|
73
|
+
'- delete: Remove a skill entirely. Requires name only.', '',
|
|
74
|
+
'Skill bodies should be procedural: When to Use, Procedure steps, Pitfalls, Verification.', '',
|
|
75
|
+
];
|
|
76
|
+
if (skills.length > 0) {
|
|
77
|
+
lines.push('Existing skills in this workspace:');
|
|
78
|
+
for (const skill of skills) lines.push(`- ${skill.name}: ${skill.description}`);
|
|
79
|
+
lines.push('', 'For patch/update, use the exact name shown above. Skill names are matched case-insensitively.');
|
|
80
|
+
} else lines.push('No skills exist yet. Use create to make the first one.');
|
|
81
|
+
return lines.join('\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const skillManageToolDefinition = {
|
|
85
|
+
name: 'skill_manage',
|
|
86
|
+
description: `Create, update, patch, or delete workspace skills.
|
|
87
|
+
|
|
88
|
+
Workspace skills are reusable procedures and workflows stored as SKILL.md files in .agents/skills/.
|
|
89
|
+
|
|
90
|
+
Actions:
|
|
91
|
+
- list: List all existing skills with their names and descriptions. No other parameters needed.
|
|
92
|
+
- create: Create a new skill. Requires name, description, and content (markdown body).
|
|
93
|
+
- update: Replace a skill's full body and optionally update description. Requires name and content.
|
|
94
|
+
- patch: Targeted string replacement in a skill's SKILL.md. Requires name, oldString, newString.
|
|
95
|
+
- delete: Remove a skill entirely. Requires name only.
|
|
96
|
+
|
|
97
|
+
Skill bodies should be procedural: When to Use, Procedure steps, Pitfalls, Verification.`,
|
|
98
|
+
inputSchema: {
|
|
99
|
+
type: 'object' as const,
|
|
100
|
+
properties: {
|
|
101
|
+
action: { type: 'string' as const, enum: ['list', 'create', 'update', 'patch', 'delete'], description: 'The action to perform.' },
|
|
102
|
+
name: { type: 'string' as const, description: 'Skill name (will be normalized to a safe slug). Matched case-insensitively against existing skills.' },
|
|
103
|
+
description: { type: 'string' as const, description: 'Concise trigger/use description for the skill. Required for create. Optional for update/patch.' },
|
|
104
|
+
content: { type: 'string' as const, description: 'Markdown body for create/update actions. Not full frontmatter — just the body.' },
|
|
105
|
+
oldString: { type: 'string' as const, description: 'Exact text to find for patch action. Must match exactly once. Load the skill first to see the exact content.' },
|
|
106
|
+
newString: { type: 'string' as const, description: 'Replacement text for patch action.' },
|
|
107
|
+
},
|
|
108
|
+
required: ['action'],
|
|
109
|
+
},
|
|
110
|
+
timeout: 10000,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export async function executeSkillManageTool(input: Record<string, unknown>, root: string, risk: PermissionRiskLevel, askFn?: (ask: PermissionAsk) => Promise<unknown>): Promise<SkillManageResult> {
|
|
114
|
+
const action = input.action as SkillManageAction;
|
|
115
|
+
const rawName = input.name as string;
|
|
116
|
+
const description = input.description as string | undefined;
|
|
117
|
+
const content = input.content as string | undefined;
|
|
118
|
+
const oldString = input.oldString as string | undefined;
|
|
119
|
+
const newString = input.newString as string | undefined;
|
|
120
|
+
if (!['list', 'create', 'update', 'patch', 'delete'].includes(action)) return { success: false, error: 'Invalid action. Must be list, create, update, patch, or delete.' };
|
|
121
|
+
if (action === 'list') {
|
|
122
|
+
const skills = await scanSkillsFromDir(root);
|
|
123
|
+
if (skills.length === 0) return { success: true, title: 'No skills found', action: 'list', summary: 'No skills exist in this workspace yet.', skills: [] };
|
|
124
|
+
return { success: true, title: `${skills.length} skill${skills.length === 1 ? '' : 's'} found`, action: 'list', summary: skills.map((skill) => `${skill.name}: ${skill.description}`).join('\n'), skills: skills.map(({ name, description: value }) => ({ name, description: value })) };
|
|
125
|
+
}
|
|
126
|
+
if (typeof rawName !== 'string') return { success: false, error: 'Skill name cannot be empty.' };
|
|
127
|
+
if (description !== undefined && typeof description !== 'string') return { success: false, error: 'description must be a string.' };
|
|
128
|
+
if (content !== undefined && typeof content !== 'string') return { success: false, error: 'content must be a string.' };
|
|
129
|
+
if (oldString !== undefined && typeof oldString !== 'string') return { success: false, error: 'oldString must be a string.' };
|
|
130
|
+
if (newString !== undefined && typeof newString !== 'string') return { success: false, error: 'newString must be a string.' };
|
|
131
|
+
const nameError = validateSkillName(rawName);
|
|
132
|
+
if (nameError) return { success: false, error: nameError };
|
|
133
|
+
const safeName = sanitizeSkillName(rawName);
|
|
134
|
+
if (!safeName) return { success: false, error: 'Skill name is invalid after normalization.' };
|
|
135
|
+
if (risk !== 'none' && askFn) {
|
|
136
|
+
const approved = await askFn({ type: 'permission', question: `Allow skill ${action}: ${safeName}?`, description: `Action: ${action}\nSkill: ${safeName}${description ? `\nDescription: ${description.slice(0, 200)}` : ''}${content ? `\nContent: ${content.slice(0, 200)}` : ''}`, risk, resource: 'file', action: 'write', paths: [`${safeName}/SKILL.md`] });
|
|
137
|
+
if (!approved) return { success: false, error: 'USER_REJECTION' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (action === 'create') {
|
|
141
|
+
if (!description) return { success: false, error: 'description is required for create action.' };
|
|
142
|
+
if (!content) return { success: false, error: 'content is required for create action.' };
|
|
143
|
+
const path = skillPath(root, safeName);
|
|
144
|
+
if (existsSync(path)) return { success: false, error: `Skill "${safeName}" already exists. Use update or patch instead.` };
|
|
145
|
+
await mkdir(skillDir(root, safeName), { recursive: true });
|
|
146
|
+
await writeFile(path, frontmatter(safeName, description) + '\n' + content + '\n', 'utf-8');
|
|
147
|
+
return { success: true, title: `Skill created: ${safeName}`, action, name: safeName, description, path: `${safeName}/SKILL.md`, summary: 'Created workspace skill.' };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const resolved = await resolveSkillFolder(rawName, root);
|
|
151
|
+
if (!resolved) {
|
|
152
|
+
const names = await availableNames(root);
|
|
153
|
+
return { success: false, error: `Skill "${rawName}" does not exist.${names.length ? ` Available skills: ${names.join(', ')}` : action === 'delete' ? '' : ' No skills exist yet. Use create first.'}` };
|
|
154
|
+
}
|
|
155
|
+
const relativePath = `${resolved.folderName}/SKILL.md`;
|
|
156
|
+
if (action === 'delete') {
|
|
157
|
+
await rm(skillDir(root, resolved.folderName), { recursive: true, force: true });
|
|
158
|
+
return { success: true, title: `Skill deleted: ${resolved.folderName}`, action, name: resolved.folderName, path: relativePath, summary: 'Removed workspace skill directory.' };
|
|
159
|
+
}
|
|
160
|
+
let existing: string;
|
|
161
|
+
try { existing = await readFile(resolved.skillMdPath, 'utf-8'); } catch { return { success: false, error: 'Failed to read existing skill file.' }; }
|
|
162
|
+
|
|
163
|
+
if (action === 'update') {
|
|
164
|
+
if (!content) return { success: false, error: 'content is required for update action.' };
|
|
165
|
+
const existingDescription = existing.match(/^description:\s*(.+)$/m)?.[1].replace(/^['"]|['"]$/g, '') ?? '';
|
|
166
|
+
const existingName = existing.match(/^name:\s*(.+)$/m)?.[1].replace(/^['"]|['"]$/g, '').trim() ?? resolved.folderName;
|
|
167
|
+
const effectiveDescription = description ?? existingDescription;
|
|
168
|
+
await writeFile(resolved.skillMdPath, frontmatter(existingName, effectiveDescription) + '\n' + content + '\n', 'utf-8');
|
|
169
|
+
return { success: true, title: `Skill updated: ${existingName}`, action, name: existingName, description: effectiveDescription, path: relativePath, summary: 'Replaced skill body.' };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!oldString) return { success: false, error: 'oldString is required for patch action.' };
|
|
173
|
+
if (newString === undefined || newString === null) return { success: false, error: 'newString is required for patch action.' };
|
|
174
|
+
const matches = existing.split(oldString).length - 1;
|
|
175
|
+
if (matches === 0) return { success: false, error: 'oldString not found in skill file. Load the skill via the "skill" tool first to see the exact content, then copy the exact text to oldString.' };
|
|
176
|
+
if (matches > 1) return { success: false, error: `oldString matched ${matches} locations. Provide a more specific oldString.` };
|
|
177
|
+
let patched = existing.replace(oldString, newString);
|
|
178
|
+
if (description) patched = patched.replace(/^(description:\s*).*$/m, `$1${description}`);
|
|
179
|
+
await writeFile(resolved.skillMdPath, patched, 'utf-8');
|
|
180
|
+
const resultName = patched.match(/^name:\s*(.+)$/m)?.[1].replace(/^['"]|['"]$/g, '').trim() ?? resolved.folderName;
|
|
181
|
+
const resultDescription = description ?? patched.match(/^description:\s*(.+)$/m)?.[1].replace(/^['"]|['"]$/g, '');
|
|
182
|
+
return { success: true, title: `Skill patched: ${resultName}`, action, name: resultName, description: resultDescription, path: relativePath, summary: 'Replaced one matching block.' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export const SKILL_MANAGE_GUIDANCE = `You can create and update workspace skills using the skill_manage tool.
|
|
186
|
+
Workspace skills are reusable procedures/workflows stored under .agents/skills in the current workspace.
|
|
187
|
+
|
|
188
|
+
Use memory for compact durable facts.
|
|
189
|
+
Use skill_manage for repeatable multi-step procedures, debugging workflows, conventions, and verification steps that are too procedural for MEMORY.md.
|
|
190
|
+
|
|
191
|
+
When to create or update a skill:
|
|
192
|
+
- After completing a complex reusable workflow.
|
|
193
|
+
- After debugging through errors and discovering the working path.
|
|
194
|
+
- When the user corrects your approach in a way that should affect future similar tasks.
|
|
195
|
+
- When you discover workspace-specific procedures, pitfalls, commands, or verification steps.
|
|
196
|
+
|
|
197
|
+
When not to create a skill:
|
|
198
|
+
- For one-off facts or temporary context.
|
|
199
|
+
- For secrets, credentials, raw logs, or large code dumps.
|
|
200
|
+
- For obvious information already present in AGENTS.md or an existing skill.
|
|
201
|
+
|
|
202
|
+
Before creating a new skill, consider whether an existing skill should be patched instead.
|
|
203
|
+
Prefer patch over update for small changes.
|
|
204
|
+
Keep skill descriptions concise and trigger-focused because descriptions are used to decide when to load a skill.
|
|
205
|
+
Keep skill bodies procedural and verification-oriented.`;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Tool } from 'ai';
|
|
2
|
+
import type { ToolDefinition } from '@capekai/tool';
|
|
3
|
+
import { dirname } from 'path';
|
|
4
|
+
import { pathToFileURL } from 'url';
|
|
5
|
+
import { formatSkillsList, getAvailableSkills, getSkill } from './registry';
|
|
6
|
+
|
|
7
|
+
export async function buildSkillToolDefinition(workspacePath: string, allowed: string[] | null | undefined, _sessionId: string, agentSkillsDir?: string): Promise<ToolDefinition | null> {
|
|
8
|
+
const skills = await getAvailableSkills(workspacePath, allowed, agentSkillsDir);
|
|
9
|
+
if (skills.length === 0) return null;
|
|
10
|
+
const examples = skills.slice(0, 3).map((skill) => `'${skill.name}'`).join(', ');
|
|
11
|
+
const description = [
|
|
12
|
+
'Load a specialized skill that provides domain-specific instructions and workflows.', '',
|
|
13
|
+
'When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.', '',
|
|
14
|
+
'The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.', '',
|
|
15
|
+
'Tool output includes a `<skill_content name="...">` block with the loaded content.', '',
|
|
16
|
+
'The following skills provide specialized sets of instructions for particular tasks.',
|
|
17
|
+
'Invoke this tool to load a skill when a task matches one of the available skills listed below:', '', formatSkillsList(skills),
|
|
18
|
+
].join('\n');
|
|
19
|
+
return {
|
|
20
|
+
name: 'skill', description,
|
|
21
|
+
inputSchema: { type: 'object', properties: { name: { type: 'string', description: `The name of the skill from available_skills${examples ? ` (e.g., ${examples}, ...)` : ''}` } }, required: ['name'] },
|
|
22
|
+
outputSchema: { type: 'object', properties: { title: { type: 'string' }, output: { type: 'string' } } }, timeout: 5000,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function executeSkillTool(name: string, workspacePath: string, allowed: string[] | null | undefined, _sessionId: string, agentSkillsDir?: string): Promise<{ success: boolean; result?: unknown; error?: string }> {
|
|
27
|
+
const available = await getAvailableSkills(workspacePath, allowed, agentSkillsDir);
|
|
28
|
+
if (available.length === 0) return { success: false, error: 'No skills are available for this session.' };
|
|
29
|
+
const skill = await getSkill(name, workspacePath, agentSkillsDir);
|
|
30
|
+
if (!skill) return { success: false, error: `Skill "${name}" not found. Available skills: ${available.map((item) => item.name).join(', ') || 'none'}` };
|
|
31
|
+
if (!(allowed === undefined || allowed === null || allowed.includes(name))) return { success: false, error: `Skill "${name}" is not available for this session.` };
|
|
32
|
+
const directory = dirname(skill.location);
|
|
33
|
+
const output = [`<skill_content name="${skill.name}">`, `# Skill: ${skill.name}`, '', skill.content, '', `Base directory for this skill: ${pathToFileURL(directory).href}`, 'Relative paths in this skill (e.g., scripts/, references/) are relative to this base directory.', '</skill_content>'].join('\n');
|
|
34
|
+
return { success: true, result: { title: `Loaded skill: ${skill.name}`, output } };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function createSkillTool(workspacePath: string, allowed: string[] | null | undefined, sessionId: string, agentSkillsDir?: string): Promise<{ name: string; tool: Tool } | null> {
|
|
38
|
+
const definition = await buildSkillToolDefinition(workspacePath, allowed, sessionId, agentSkillsDir);
|
|
39
|
+
if (!definition) return null;
|
|
40
|
+
const { jsonSchema, tool } = await import('ai');
|
|
41
|
+
return { name: 'skill', tool: tool({ description: definition.description, inputSchema: jsonSchema(definition.inputSchema), execute: async (args: Record<string, unknown>) => executeSkillTool(args.name as string, workspacePath, allowed, sessionId, agentSkillsDir) }) };
|
|
42
|
+
}
|