char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somadd skills extension for loading skillsa4e772a

main
7.0 KiB182 linesraw
1import type { Context } from "@earendil-works/chord";
2import { getOrThrow } from "@earendil-works/pi-durable/env";
3import type { Environment } from "@berry/api";
4import { parse } from "@std/yaml";
5import { userDirectory } from "./paths.ts";
6
7export const MAX_SKILL_BYTES = 64 * 1024;
8const MAX_SKILLS = 128;
9const MAX_COLLECTION_ENTRIES = 256;
10
11export type Skill = {
12  name: string;
13  description: string;
14  directory: string;
15  location: string;
16  scope: "project" | "user" | "host";
17};
18
19export type Catalog = {
20  projectRoot: string;
21  skills: Skill[];
22  diagnostics: string[];
23};
24
25export function parseSkill(content: string, directoryName: string) {
26  const frontmatter = content.match(/^---[ \t]*\r?\n([\s\S]*?)^---[ \t]*(?:\r?\n|$)/m);
27  if (!frontmatter || frontmatter.index !== 0) throw new Error("Missing YAML frontmatter");
28  const metadata = parse(frontmatter[1], { schema: "core" });
29  if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
30    throw new Error("Frontmatter must be a mapping");
31  }
32  const { name, description } = metadata as Record<string, unknown>;
33  if (typeof name !== "string" || name.length > 64 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
34    throw new Error("name must be at most 64 lowercase letters, numbers, and single hyphens");
35  }
36  if (typeof description !== "string" || !description.trim() || description.length > 1024) {
37    throw new Error("description must be a nonempty string of at most 1024 characters");
38  }
39  const warnings: string[] = [];
40  if (name !== directoryName) warnings.push(`name ${name} differs from directory ${directoryName}`);
41  return { name, description, warnings };
42}
43
44/** A bounded, complete UTF-8 read of a regular skill file. */
45export async function readSkill(env: Environment, location: string, context: Context) {
46  const reader = getOrThrow(await env.openBinaryReader(location, undefined, context));
47  try {
48    const { size } = getOrThrow(await reader.info(context));
49    if (size > MAX_SKILL_BYTES) throw new Error(`SKILL.md exceeds ${MAX_SKILL_BYTES / 1024} KiB`);
50    const bytes = getOrThrow(await reader.read(0, MAX_SKILL_BYTES + 1, context));
51    if (bytes.length > MAX_SKILL_BYTES) {
52      throw new Error(`SKILL.md exceeds ${MAX_SKILL_BYTES / 1024} KiB`);
53    }
54    return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
55  } finally {
56    await reader.close(context);
57  }
58}
59
60/** The cwd's ancestors through its nearest repository root, or just the cwd outside a repository. */
61export async function projectDirectories(env: Environment, context: Context): Promise<string[]> {
62  const cwd = getOrThrow(await env.canonicalPath(env.cwd, context));
63  const ancestors: string[] = [];
64  for (let dir = cwd; ancestors.length < 128;) {
65    ancestors.push(dir);
66    for (const marker of [".git", ".jj"]) {
67      const path = getOrThrow(await env.joinPath([dir, marker], context));
68      const found = await env.fileInfo(path, context);
69      if (found.ok) return ancestors;
70      if (found.error.code !== "not_found" && found.error.code !== "not_directory") {
71        throw found.error;
72      }
73    }
74    const parent = getOrThrow(await env.joinPath([dir, ".."], context));
75    if (parent === dir) break;
76    dir = parent;
77  }
78  return [cwd];
79}
80
81/** Discover only known collections, with project roots omitted unless explicitly allowed. */
82export async function discover(
83  env: Environment,
84  host: Environment,
85  context: Context,
86  trustedRoots?: readonly string[],
87): Promise<Catalog> {
88  const directories = await projectDirectories(env, context);
89  const projectRoot = directories.at(-1)!;
90  const sources: { env: Environment; path: string; scope: Skill["scope"] }[] = [];
91  if (trustedRoots === undefined || trustedRoots.includes(projectRoot)) {
92    for (const dir of directories) {
93      sources.push({
94        env,
95        path: getOrThrow(await env.joinPath([dir, ".agents", "skills"], context)),
96        scope: "project",
97      });
98    }
99  }
100  const config = await userDirectory(host, "XDG_CONFIG_HOME", [".config"], context);
101  sources.push(
102    {
103      env: host,
104      path: getOrThrow(await host.joinPath([config, "berry", "skills"], context)),
105      scope: "host",
106    },
107    {
108      env,
109      path: getOrThrow(await env.joinPath([env.home, ".agents", "skills"], context)),
110      scope: "user",
111    },
112  );
113
114  const skills = new Map<string, Skill>();
115  const diagnostics: string[] = [];
116  const seen = new Set<string>();
117  for (const source of sources) {
118    const env = source.env;
119    const opened = await env.openDirReader(source.path, context);
120    if (!opened.ok) {
121      if (opened.error.code === "aborted") throw opened.error;
122      if (opened.error.code !== "not_found") {
123        diagnostics.push(`${source.path}: ${opened.error.message}`);
124      }
125      continue;
126    }
127    const reader = opened.value;
128    let page;
129    try {
130      page = getOrThrow(await reader.next(MAX_COLLECTION_ENTRIES + 1, context));
131    } finally {
132      await reader.close(context);
133    }
134    if (!page.done || page.entries.length > MAX_COLLECTION_ENTRIES) {
135      diagnostics.push(`${source.path}: stopped after ${MAX_COLLECTION_ENTRIES} entries`);
136    }
137    const entries = page.entries.slice(0, MAX_COLLECTION_ENTRIES).sort((a, b) =>
138      a.name.localeCompare(b.name)
139    );
140    for (const entry of entries) {
141      if (entry.kind === "file" || entry.name.startsWith(".")) continue;
142      const location = getOrThrow(await env.joinPath([entry.path, "SKILL.md"], context));
143      const canonical = await env.canonicalPath(location, context);
144      if (!canonical.ok) {
145        if (canonical.error.code === "aborted") throw canonical.error;
146        if (canonical.error.code !== "not_found") {
147          diagnostics.push(`${location}: ${canonical.error.message}`);
148        }
149        continue;
150      }
151      const identity = `${env.id}\0${canonical.value}`;
152      if (seen.has(identity)) continue;
153      seen.add(identity);
154      try {
155        const content = await readSkill(env, canonical.value, context);
156        const { name, description, warnings } = parseSkill(content, entry.name);
157        for (const warning of warnings) diagnostics.push(`${location}: ${warning}`);
158        const previous = skills.get(name);
159        if (previous) {
160          diagnostics.push(`${location}: ${name} is shadowed by ${previous.location}`);
161          continue;
162        }
163        if (skills.size === MAX_SKILLS) {
164          diagnostics.push(`Stopped after ${MAX_SKILLS} skills`);
165          return { projectRoot, skills: [...skills.values()], diagnostics };
166        }
167        skills.set(name, {
168          name,
169          description,
170          directory: getOrThrow(await env.canonicalPath(entry.path, context)),
171          location: canonical.value,
172          scope: source.scope,
173        });
174      } catch (error) {
175        context.abortSignal?.throwIfAborted();
176        if (error instanceof Error && "code" in error && error.code === "aborted") throw error;
177        diagnostics.push(`${location}: ${error instanceof Error ? error.message : String(error)}`);
178      }
179    }
180  }
181  return { projectRoot, skills: [...skills.values()], diagnostics };
182}