mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 23:01:30 +00:00
Merge origin/main into kitty-protocol-support
This commit is contained in:
commit
b1be086169
32 changed files with 969 additions and 187 deletions
|
|
@ -4,15 +4,22 @@ export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js";
|
|||
export type {
|
||||
AgentEndEvent,
|
||||
AgentStartEvent,
|
||||
BashToolResultEvent,
|
||||
BranchEvent,
|
||||
BranchEventResult,
|
||||
CustomToolResultEvent,
|
||||
EditToolResultEvent,
|
||||
ExecResult,
|
||||
FindToolResultEvent,
|
||||
GrepToolResultEvent,
|
||||
HookAPI,
|
||||
HookError,
|
||||
HookEvent,
|
||||
HookEventContext,
|
||||
HookFactory,
|
||||
HookUIContext,
|
||||
LsToolResultEvent,
|
||||
ReadToolResultEvent,
|
||||
SessionEvent,
|
||||
ToolCallEvent,
|
||||
ToolCallEventResult,
|
||||
|
|
@ -20,4 +27,14 @@ export type {
|
|||
ToolResultEventResult,
|
||||
TurnEndEvent,
|
||||
TurnStartEvent,
|
||||
WriteToolResultEvent,
|
||||
} from "./types.js";
|
||||
export {
|
||||
isBashToolResult,
|
||||
isEditToolResult,
|
||||
isFindToolResult,
|
||||
isGrepToolResult,
|
||||
isLsToolResult,
|
||||
isReadToolResult,
|
||||
isWriteToolResult,
|
||||
} from "./types.js";
|
||||
|
|
|
|||
|
|
@ -44,26 +44,21 @@ export function wrapToolWithHooks<T>(tool: AgentTool<any, T>, hookRunner: HookRu
|
|||
|
||||
// Emit tool_result event - hooks can modify the result
|
||||
if (hookRunner.hasHandlers("tool_result")) {
|
||||
// Extract text from result for hooks
|
||||
const resultText = result.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
|
||||
const resultResult = (await hookRunner.emit({
|
||||
type: "tool_result",
|
||||
toolName: tool.name,
|
||||
toolCallId,
|
||||
input: params,
|
||||
result: resultText,
|
||||
content: result.content,
|
||||
details: result.details,
|
||||
isError: false,
|
||||
})) as ToolResultEventResult | undefined;
|
||||
|
||||
// Apply modifications if any
|
||||
if (resultResult?.result !== undefined) {
|
||||
if (resultResult) {
|
||||
return {
|
||||
...result,
|
||||
content: [{ type: "text", text: resultResult.result }],
|
||||
content: resultResult.content ?? result.content,
|
||||
details: (resultResult.details ?? result.details) as T,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,15 @@
|
|||
*/
|
||||
|
||||
import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core";
|
||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import type { ImageContent, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import type { SessionEntry } from "../session-manager.js";
|
||||
import type {
|
||||
BashToolDetails,
|
||||
FindToolDetails,
|
||||
GrepToolDetails,
|
||||
LsToolDetails,
|
||||
ReadToolDetails,
|
||||
} from "../tools/index.js";
|
||||
|
||||
// ============================================================================
|
||||
// Execution Context
|
||||
|
|
@ -140,23 +147,106 @@ export interface ToolCallEvent {
|
|||
}
|
||||
|
||||
/**
|
||||
* Event data for tool_result event.
|
||||
* Fired after a tool is executed. Hooks can modify the result.
|
||||
* Base interface for tool_result events.
|
||||
*/
|
||||
export interface ToolResultEvent {
|
||||
interface ToolResultEventBase {
|
||||
type: "tool_result";
|
||||
/** Tool name (e.g., "bash", "edit", "write") */
|
||||
toolName: string;
|
||||
/** Tool call ID */
|
||||
toolCallId: string;
|
||||
/** Tool input parameters */
|
||||
input: Record<string, unknown>;
|
||||
/** Tool result content (text) */
|
||||
result: string;
|
||||
/** Full content array (text and images) */
|
||||
content: (TextContent | ImageContent)[];
|
||||
/** Whether the tool execution was an error */
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
/** Tool result event for bash tool */
|
||||
export interface BashToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "bash";
|
||||
details: BashToolDetails | undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for read tool */
|
||||
export interface ReadToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "read";
|
||||
details: ReadToolDetails | undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for edit tool */
|
||||
export interface EditToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "edit";
|
||||
details: undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for write tool */
|
||||
export interface WriteToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "write";
|
||||
details: undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for grep tool */
|
||||
export interface GrepToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "grep";
|
||||
details: GrepToolDetails | undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for find tool */
|
||||
export interface FindToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "find";
|
||||
details: FindToolDetails | undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for ls tool */
|
||||
export interface LsToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "ls";
|
||||
details: LsToolDetails | undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for custom/unknown tools */
|
||||
export interface CustomToolResultEvent extends ToolResultEventBase {
|
||||
toolName: string;
|
||||
details: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event data for tool_result event.
|
||||
* Fired after a tool is executed. Hooks can modify the result.
|
||||
* Use toolName to discriminate and get typed details.
|
||||
*/
|
||||
export type ToolResultEvent =
|
||||
| BashToolResultEvent
|
||||
| ReadToolResultEvent
|
||||
| EditToolResultEvent
|
||||
| WriteToolResultEvent
|
||||
| GrepToolResultEvent
|
||||
| FindToolResultEvent
|
||||
| LsToolResultEvent
|
||||
| CustomToolResultEvent;
|
||||
|
||||
// Type guards for narrowing ToolResultEvent to specific tool types
|
||||
export function isBashToolResult(e: ToolResultEvent): e is BashToolResultEvent {
|
||||
return e.toolName === "bash";
|
||||
}
|
||||
export function isReadToolResult(e: ToolResultEvent): e is ReadToolResultEvent {
|
||||
return e.toolName === "read";
|
||||
}
|
||||
export function isEditToolResult(e: ToolResultEvent): e is EditToolResultEvent {
|
||||
return e.toolName === "edit";
|
||||
}
|
||||
export function isWriteToolResult(e: ToolResultEvent): e is WriteToolResultEvent {
|
||||
return e.toolName === "write";
|
||||
}
|
||||
export function isGrepToolResult(e: ToolResultEvent): e is GrepToolResultEvent {
|
||||
return e.toolName === "grep";
|
||||
}
|
||||
export function isFindToolResult(e: ToolResultEvent): e is FindToolResultEvent {
|
||||
return e.toolName === "find";
|
||||
}
|
||||
export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent {
|
||||
return e.toolName === "ls";
|
||||
}
|
||||
|
||||
/**
|
||||
* Event data for branch event.
|
||||
*/
|
||||
|
|
@ -201,8 +291,10 @@ export interface ToolCallEventResult {
|
|||
* Allows hooks to modify tool results.
|
||||
*/
|
||||
export interface ToolResultEventResult {
|
||||
/** Modified result text (if not set, original result is used) */
|
||||
result?: string;
|
||||
/** Replacement content array (text and images) */
|
||||
content?: (TextContent | ImageContent)[];
|
||||
/** Replacement details */
|
||||
details?: unknown;
|
||||
/** Override isError flag */
|
||||
isError?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,29 @@ import { homedir } from "os";
|
|||
import { basename, dirname, join, resolve } from "path";
|
||||
import { CONFIG_DIR_NAME } from "../config.js";
|
||||
|
||||
/**
|
||||
* Standard frontmatter fields per Agent Skills spec.
|
||||
* See: https://agentskills.io/specification#frontmatter-required
|
||||
*/
|
||||
const ALLOWED_FRONTMATTER_FIELDS = new Set([
|
||||
"name",
|
||||
"description",
|
||||
"license",
|
||||
"compatibility",
|
||||
"metadata",
|
||||
"allowed-tools",
|
||||
]);
|
||||
|
||||
/** Max name length per spec */
|
||||
const MAX_NAME_LENGTH = 64;
|
||||
|
||||
/** Max description length per spec */
|
||||
const MAX_DESCRIPTION_LENGTH = 1024;
|
||||
|
||||
export interface SkillFrontmatter {
|
||||
name?: string;
|
||||
description: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Skill {
|
||||
|
|
@ -16,6 +36,16 @@ export interface Skill {
|
|||
source: string;
|
||||
}
|
||||
|
||||
export interface SkillWarning {
|
||||
skillPath: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface LoadSkillsResult {
|
||||
skills: Skill[];
|
||||
warnings: SkillWarning[];
|
||||
}
|
||||
|
||||
type SkillFormat = "recursive" | "claude";
|
||||
|
||||
function stripQuotes(value: string): string {
|
||||
|
|
@ -25,28 +55,30 @@ function stripQuotes(value: string): string {
|
|||
return value;
|
||||
}
|
||||
|
||||
function parseFrontmatter(content: string): { frontmatter: SkillFrontmatter; body: string } {
|
||||
const frontmatter: SkillFrontmatter = { description: "" };
|
||||
function parseFrontmatter(content: string): { frontmatter: SkillFrontmatter; body: string; allKeys: string[] } {
|
||||
const frontmatter: SkillFrontmatter = {};
|
||||
const allKeys: string[] = [];
|
||||
|
||||
const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
|
||||
if (!normalizedContent.startsWith("---")) {
|
||||
return { frontmatter, body: normalizedContent };
|
||||
return { frontmatter, body: normalizedContent, allKeys };
|
||||
}
|
||||
|
||||
const endIndex = normalizedContent.indexOf("\n---", 3);
|
||||
if (endIndex === -1) {
|
||||
return { frontmatter, body: normalizedContent };
|
||||
return { frontmatter, body: normalizedContent, allKeys };
|
||||
}
|
||||
|
||||
const frontmatterBlock = normalizedContent.slice(4, endIndex);
|
||||
const body = normalizedContent.slice(endIndex + 4).trim();
|
||||
|
||||
for (const line of frontmatterBlock.split("\n")) {
|
||||
const match = line.match(/^(\w+):\s*(.*)$/);
|
||||
const match = line.match(/^(\w[\w-]*):\s*(.*)$/);
|
||||
if (match) {
|
||||
const key = match[1];
|
||||
const value = stripQuotes(match[2].trim());
|
||||
allKeys.push(key);
|
||||
if (key === "name") {
|
||||
frontmatter.name = value;
|
||||
} else if (key === "description") {
|
||||
|
|
@ -55,7 +87,65 @@ function parseFrontmatter(content: string): { frontmatter: SkillFrontmatter; bod
|
|||
}
|
||||
}
|
||||
|
||||
return { frontmatter, body };
|
||||
return { frontmatter, body, allKeys };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate skill name per Agent Skills spec.
|
||||
* Returns array of validation error messages (empty if valid).
|
||||
*/
|
||||
function validateName(name: string, parentDirName: string): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (name !== parentDirName) {
|
||||
errors.push(`name "${name}" does not match parent directory "${parentDirName}"`);
|
||||
}
|
||||
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
errors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`);
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(name)) {
|
||||
errors.push(`name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)`);
|
||||
}
|
||||
|
||||
if (name.startsWith("-") || name.endsWith("-")) {
|
||||
errors.push(`name must not start or end with a hyphen`);
|
||||
}
|
||||
|
||||
if (name.includes("--")) {
|
||||
errors.push(`name must not contain consecutive hyphens`);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate description per Agent Skills spec.
|
||||
*/
|
||||
function validateDescription(description: string | undefined): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!description || description.trim() === "") {
|
||||
errors.push(`description is required`);
|
||||
} else if (description.length > MAX_DESCRIPTION_LENGTH) {
|
||||
errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for unknown frontmatter fields.
|
||||
*/
|
||||
function validateFrontmatterFields(keys: string[]): string[] {
|
||||
const errors: string[] = [];
|
||||
for (const key of keys) {
|
||||
if (!ALLOWED_FRONTMATTER_FIELDS.has(key)) {
|
||||
errors.push(`unknown frontmatter field "${key}"`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export interface LoadSkillsFromDirOptions {
|
||||
|
|
@ -63,30 +153,23 @@ export interface LoadSkillsFromDirOptions {
|
|||
dir: string;
|
||||
/** Source identifier for these skills */
|
||||
source: string;
|
||||
/** Use colon-separated path names (e.g., db:migrate) instead of simple directory name */
|
||||
useColonPath?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills from a directory recursively.
|
||||
* Skills are directories containing a SKILL.md file with frontmatter including a description.
|
||||
*/
|
||||
export function loadSkillsFromDir(options: LoadSkillsFromDirOptions, subdir: string = ""): Skill[] {
|
||||
const { dir, source, useColonPath = false } = options;
|
||||
return loadSkillsFromDirInternal(dir, source, "recursive", useColonPath, subdir);
|
||||
export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult {
|
||||
const { dir, source } = options;
|
||||
return loadSkillsFromDirInternal(dir, source, "recursive");
|
||||
}
|
||||
|
||||
function loadSkillsFromDirInternal(
|
||||
dir: string,
|
||||
source: string,
|
||||
format: SkillFormat,
|
||||
useColonPath: boolean = false,
|
||||
subdir: string = "",
|
||||
): Skill[] {
|
||||
function loadSkillsFromDirInternal(dir: string, source: string, format: SkillFormat): LoadSkillsResult {
|
||||
const skills: Skill[] = [];
|
||||
const warnings: SkillWarning[] = [];
|
||||
|
||||
if (!existsSync(dir)) {
|
||||
return skills;
|
||||
return { skills, warnings };
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -106,30 +189,15 @@ function loadSkillsFromDirInternal(
|
|||
if (format === "recursive") {
|
||||
// Recursive format: scan directories, look for SKILL.md files
|
||||
if (entry.isDirectory()) {
|
||||
const newSubdir = subdir ? `${subdir}:${entry.name}` : entry.name;
|
||||
skills.push(...loadSkillsFromDirInternal(fullPath, source, format, useColonPath, newSubdir));
|
||||
const subResult = loadSkillsFromDirInternal(fullPath, source, format);
|
||||
skills.push(...subResult.skills);
|
||||
warnings.push(...subResult.warnings);
|
||||
} else if (entry.isFile() && entry.name === "SKILL.md") {
|
||||
try {
|
||||
const rawContent = readFileSync(fullPath, "utf-8");
|
||||
const { frontmatter } = parseFrontmatter(rawContent);
|
||||
|
||||
if (!frontmatter.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const skillDir = dirname(fullPath);
|
||||
// useColonPath: db:migrate (pi), otherwise just: migrate (codex)
|
||||
const nameFromPath = useColonPath ? subdir || basename(skillDir) : basename(skillDir);
|
||||
const name = frontmatter.name || nameFromPath;
|
||||
|
||||
skills.push({
|
||||
name,
|
||||
description: frontmatter.description,
|
||||
filePath: fullPath,
|
||||
baseDir: skillDir,
|
||||
source,
|
||||
});
|
||||
} catch {}
|
||||
const result = loadSkillFromFile(fullPath, source);
|
||||
if (result.skill) {
|
||||
skills.push(result.skill);
|
||||
}
|
||||
warnings.push(...result.warnings);
|
||||
}
|
||||
} else if (format === "claude") {
|
||||
// Claude format: only one level deep, each directory must contain SKILL.md
|
||||
|
|
@ -137,40 +205,77 @@ function loadSkillsFromDirInternal(
|
|||
continue;
|
||||
}
|
||||
|
||||
const skillDir = fullPath;
|
||||
const skillFile = join(skillDir, "SKILL.md");
|
||||
|
||||
const skillFile = join(fullPath, "SKILL.md");
|
||||
if (!existsSync(skillFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawContent = readFileSync(skillFile, "utf-8");
|
||||
const { frontmatter } = parseFrontmatter(rawContent);
|
||||
|
||||
if (!frontmatter.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = frontmatter.name || entry.name;
|
||||
|
||||
skills.push({
|
||||
name,
|
||||
description: frontmatter.description,
|
||||
filePath: skillFile,
|
||||
baseDir: skillDir,
|
||||
source,
|
||||
});
|
||||
} catch {}
|
||||
const result = loadSkillFromFile(skillFile, source);
|
||||
if (result.skill) {
|
||||
skills.push(result.skill);
|
||||
}
|
||||
warnings.push(...result.warnings);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return skills;
|
||||
return { skills, warnings };
|
||||
}
|
||||
|
||||
function loadSkillFromFile(filePath: string, source: string): { skill: Skill | null; warnings: SkillWarning[] } {
|
||||
const warnings: SkillWarning[] = [];
|
||||
|
||||
try {
|
||||
const rawContent = readFileSync(filePath, "utf-8");
|
||||
const { frontmatter, allKeys } = parseFrontmatter(rawContent);
|
||||
const skillDir = dirname(filePath);
|
||||
const parentDirName = basename(skillDir);
|
||||
|
||||
// Validate frontmatter fields
|
||||
const fieldErrors = validateFrontmatterFields(allKeys);
|
||||
for (const error of fieldErrors) {
|
||||
warnings.push({ skillPath: filePath, message: error });
|
||||
}
|
||||
|
||||
// Validate description
|
||||
const descErrors = validateDescription(frontmatter.description);
|
||||
for (const error of descErrors) {
|
||||
warnings.push({ skillPath: filePath, message: error });
|
||||
}
|
||||
|
||||
// Use name from frontmatter, or fall back to parent directory name
|
||||
const name = frontmatter.name || parentDirName;
|
||||
|
||||
// Validate name
|
||||
const nameErrors = validateName(name, parentDirName);
|
||||
for (const error of nameErrors) {
|
||||
warnings.push({ skillPath: filePath, message: error });
|
||||
}
|
||||
|
||||
// Still load the skill even with warnings (unless description is completely missing)
|
||||
if (!frontmatter.description || frontmatter.description.trim() === "") {
|
||||
return { skill: null, warnings };
|
||||
}
|
||||
|
||||
return {
|
||||
skill: {
|
||||
name,
|
||||
description: frontmatter.description,
|
||||
filePath,
|
||||
baseDir: skillDir,
|
||||
source,
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
} catch {
|
||||
return { skill: null, warnings };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format skills for inclusion in a system prompt.
|
||||
* Uses XML format per Agent Skills standard.
|
||||
* See: https://agentskills.io/integrate-skills
|
||||
*/
|
||||
export function formatSkillsForPrompt(skills: Skill[]): string {
|
||||
if (skills.length === 0) {
|
||||
|
|
@ -178,16 +283,18 @@ export function formatSkillsForPrompt(skills: Skill[]): string {
|
|||
}
|
||||
|
||||
const lines = [
|
||||
"\n\n<available_skills>",
|
||||
"The following skills provide specialized instructions for specific tasks.",
|
||||
"\n\nThe following skills provide specialized instructions for specific tasks.",
|
||||
"Use the read tool to load a skill's file when the task matches its description.",
|
||||
"Skills may contain {baseDir} placeholders - replace them with the skill's base directory path.\n",
|
||||
"",
|
||||
"<available_skills>",
|
||||
];
|
||||
|
||||
for (const skill of skills) {
|
||||
lines.push(`- ${skill.name}: ${skill.description}`);
|
||||
lines.push(` File: ${skill.filePath}`);
|
||||
lines.push(` Base directory: ${skill.baseDir}`);
|
||||
lines.push(" <skill>");
|
||||
lines.push(` <name>${escapeXml(skill.name)}</name>`);
|
||||
lines.push(` <description>${escapeXml(skill.description)}</description>`);
|
||||
lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
|
||||
lines.push(" </skill>");
|
||||
}
|
||||
|
||||
lines.push("</available_skills>");
|
||||
|
|
@ -195,36 +302,59 @@ export function formatSkillsForPrompt(skills: Skill[]): string {
|
|||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function loadSkills(): Skill[] {
|
||||
const skillMap = new Map<string, Skill>();
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Codex: recursive, simple directory name
|
||||
const codexUserDir = join(homedir(), ".codex", "skills");
|
||||
for (const skill of loadSkillsFromDirInternal(codexUserDir, "codex-user", "recursive", false)) {
|
||||
skillMap.set(skill.name, skill);
|
||||
/**
|
||||
* Load skills from all configured locations.
|
||||
* Returns skills and any validation warnings.
|
||||
*/
|
||||
export function loadSkills(): LoadSkillsResult {
|
||||
const skillMap = new Map<string, Skill>();
|
||||
const allWarnings: SkillWarning[] = [];
|
||||
const collisionWarnings: SkillWarning[] = [];
|
||||
|
||||
function addSkills(result: LoadSkillsResult) {
|
||||
allWarnings.push(...result.warnings);
|
||||
for (const skill of result.skills) {
|
||||
const existing = skillMap.get(skill.name);
|
||||
if (existing) {
|
||||
collisionWarnings.push({
|
||||
skillPath: skill.filePath,
|
||||
message: `name collision: "${skill.name}" already loaded from ${existing.filePath}, skipping this one`,
|
||||
});
|
||||
} else {
|
||||
skillMap.set(skill.name, skill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Codex: recursive
|
||||
const codexUserDir = join(homedir(), ".codex", "skills");
|
||||
addSkills(loadSkillsFromDirInternal(codexUserDir, "codex-user", "recursive"));
|
||||
|
||||
// Claude: single level only
|
||||
const claudeUserDir = join(homedir(), ".claude", "skills");
|
||||
for (const skill of loadSkillsFromDirInternal(claudeUserDir, "claude-user", "claude", false)) {
|
||||
skillMap.set(skill.name, skill);
|
||||
}
|
||||
addSkills(loadSkillsFromDirInternal(claudeUserDir, "claude-user", "claude"));
|
||||
|
||||
const claudeProjectDir = resolve(process.cwd(), ".claude", "skills");
|
||||
for (const skill of loadSkillsFromDirInternal(claudeProjectDir, "claude-project", "claude", false)) {
|
||||
skillMap.set(skill.name, skill);
|
||||
}
|
||||
addSkills(loadSkillsFromDirInternal(claudeProjectDir, "claude-project", "claude"));
|
||||
|
||||
// Pi: recursive, colon-separated path names
|
||||
// Pi: recursive
|
||||
const globalSkillsDir = join(homedir(), CONFIG_DIR_NAME, "agent", "skills");
|
||||
for (const skill of loadSkillsFromDirInternal(globalSkillsDir, "user", "recursive", true)) {
|
||||
skillMap.set(skill.name, skill);
|
||||
}
|
||||
addSkills(loadSkillsFromDirInternal(globalSkillsDir, "user", "recursive"));
|
||||
|
||||
const projectSkillsDir = resolve(process.cwd(), CONFIG_DIR_NAME, "skills");
|
||||
for (const skill of loadSkillsFromDirInternal(projectSkillsDir, "project", "recursive", true)) {
|
||||
skillMap.set(skill.name, skill);
|
||||
}
|
||||
addSkills(loadSkillsFromDirInternal(projectSkillsDir, "project", "recursive"));
|
||||
|
||||
return Array.from(skillMap.values());
|
||||
return {
|
||||
skills: Array.from(skillMap.values()),
|
||||
warnings: [...allWarnings, ...collisionWarnings],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ export function substituteArgs(content: string, args: string[]): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Recursively scan a directory for .md files and load them as slash commands
|
||||
* Recursively scan a directory for .md files (and symlinks to .md files) and load them as slash commands
|
||||
*/
|
||||
function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: string = ""): FileSlashCommand[] {
|
||||
const commands: FileSlashCommand[] = [];
|
||||
|
|
@ -118,7 +118,7 @@ function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: st
|
|||
// Recurse into subdirectory
|
||||
const newSubdir = subdir ? `${subdir}:${entry.name}` : entry.name;
|
||||
commands.push(...loadCommandsFromDir(fullPath, source, newSubdir));
|
||||
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
||||
} else if ((entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith(".md")) {
|
||||
try {
|
||||
const rawContent = readFileSync(fullPath, "utf-8");
|
||||
const { frontmatter, content } = parseFrontmatter(rawContent);
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
|||
// Append skills section (only if read tool is available)
|
||||
const customPromptHasRead = !selectedTools || selectedTools.includes("read");
|
||||
if (skillsEnabled && customPromptHasRead) {
|
||||
const skills = loadSkills();
|
||||
const { skills } = loadSkills();
|
||||
prompt += formatSkillsForPrompt(skills);
|
||||
}
|
||||
|
||||
|
|
@ -255,7 +255,7 @@ Documentation:
|
|||
|
||||
// Append skills section (only if read tool is available)
|
||||
if (skillsEnabled && hasRead) {
|
||||
const skills = loadSkills();
|
||||
const { skills } = loadSkills();
|
||||
prompt += formatSkillsForPrompt(skills);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const bashSchema = Type.Object({
|
|||
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
|
||||
});
|
||||
|
||||
interface BashToolDetails {
|
||||
export interface BashToolDetails {
|
||||
truncation?: TruncationResult;
|
||||
fullOutputPath?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const findSchema = Type.Object({
|
|||
|
||||
const DEFAULT_LIMIT = 1000;
|
||||
|
||||
interface FindToolDetails {
|
||||
export interface FindToolDetails {
|
||||
truncation?: TruncationResult;
|
||||
resultLimitReached?: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const grepSchema = Type.Object({
|
|||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
|
||||
interface GrepToolDetails {
|
||||
export interface GrepToolDetails {
|
||||
truncation?: TruncationResult;
|
||||
matchLimitReached?: number;
|
||||
linesTruncated?: boolean;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
export { bashTool } from "./bash.js";
|
||||
export { type BashToolDetails, bashTool } from "./bash.js";
|
||||
export { editTool } from "./edit.js";
|
||||
export { findTool } from "./find.js";
|
||||
export { grepTool } from "./grep.js";
|
||||
export { lsTool } from "./ls.js";
|
||||
export { readTool } from "./read.js";
|
||||
export { type FindToolDetails, findTool } from "./find.js";
|
||||
export { type GrepToolDetails, grepTool } from "./grep.js";
|
||||
export { type LsToolDetails, lsTool } from "./ls.js";
|
||||
export { type ReadToolDetails, readTool } from "./read.js";
|
||||
export type { TruncationResult } from "./truncate.js";
|
||||
export { writeTool } from "./write.js";
|
||||
|
||||
import { bashTool } from "./bash.js";
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const lsSchema = Type.Object({
|
|||
|
||||
const DEFAULT_LIMIT = 500;
|
||||
|
||||
interface LsToolDetails {
|
||||
export interface LsToolDetails {
|
||||
truncation?: TruncationResult;
|
||||
entryLimitReached?: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const readSchema = Type.Object({
|
|||
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
|
||||
});
|
||||
|
||||
interface ReadToolDetails {
|
||||
export interface ReadToolDetails {
|
||||
truncation?: TruncationResult;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,17 +35,23 @@ export type {
|
|||
ToolUIContext,
|
||||
} from "./core/custom-tools/index.js";
|
||||
export { discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index.js";
|
||||
// Hook system types
|
||||
export type {
|
||||
AgentEndEvent,
|
||||
AgentStartEvent,
|
||||
BashToolResultEvent,
|
||||
BranchEvent,
|
||||
BranchEventResult,
|
||||
CustomToolResultEvent,
|
||||
EditToolResultEvent,
|
||||
FindToolResultEvent,
|
||||
GrepToolResultEvent,
|
||||
HookAPI,
|
||||
HookEvent,
|
||||
HookEventContext,
|
||||
HookFactory,
|
||||
HookUIContext,
|
||||
LsToolResultEvent,
|
||||
ReadToolResultEvent,
|
||||
SessionEvent,
|
||||
ToolCallEvent,
|
||||
ToolCallEventResult,
|
||||
|
|
@ -53,6 +59,17 @@ export type {
|
|||
ToolResultEventResult,
|
||||
TurnEndEvent,
|
||||
TurnStartEvent,
|
||||
WriteToolResultEvent,
|
||||
} from "./core/hooks/index.js";
|
||||
// Hook system types and type guards
|
||||
export {
|
||||
isBashToolResult,
|
||||
isEditToolResult,
|
||||
isFindToolResult,
|
||||
isGrepToolResult,
|
||||
isLsToolResult,
|
||||
isReadToolResult,
|
||||
isWriteToolResult,
|
||||
} from "./core/hooks/index.js";
|
||||
export { messageTransformer } from "./core/messages.js";
|
||||
export {
|
||||
|
|
@ -81,13 +98,30 @@ export {
|
|||
export {
|
||||
formatSkillsForPrompt,
|
||||
type LoadSkillsFromDirOptions,
|
||||
type LoadSkillsResult,
|
||||
loadSkills,
|
||||
loadSkillsFromDir,
|
||||
type Skill,
|
||||
type SkillFrontmatter,
|
||||
type SkillWarning,
|
||||
} from "./core/skills.js";
|
||||
// Tools
|
||||
export { bashTool, codingTools, editTool, readTool, writeTool } from "./core/tools/index.js";
|
||||
export {
|
||||
type BashToolDetails,
|
||||
bashTool,
|
||||
codingTools,
|
||||
editTool,
|
||||
type FindToolDetails,
|
||||
findTool,
|
||||
type GrepToolDetails,
|
||||
grepTool,
|
||||
type LsToolDetails,
|
||||
lsTool,
|
||||
type ReadToolDetails,
|
||||
readTool,
|
||||
type TruncationResult,
|
||||
writeTool,
|
||||
} from "./core/tools/index.js";
|
||||
|
||||
// Main entry point
|
||||
export { main } from "./main.js";
|
||||
|
|
|
|||
|
|
@ -305,13 +305,20 @@ export class InteractiveMode {
|
|||
}
|
||||
|
||||
// Show loaded skills
|
||||
const skills = loadSkills();
|
||||
const { skills, warnings: skillWarnings } = loadSkills();
|
||||
if (skills.length > 0) {
|
||||
const skillList = skills.map((s) => theme.fg("dim", ` ${s.filePath}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded skills:\n") + skillList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
// Show skill warnings if any
|
||||
if (skillWarnings.length > 0) {
|
||||
const warningList = skillWarnings.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("warning", "Skill warnings:\n") + warningList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
// Show loaded custom tools
|
||||
if (this.customTools.size > 0) {
|
||||
const toolList = Array.from(this.customTools.values())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue