@hachej/boring-agent 0.4.22 → 0.4.23

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 CHANGED
@@ -357,13 +357,35 @@ With self-evolution, workspace skills may be flat `.agent/skills/<name>.md` file
357
357
 
358
358
  `createSkillsExtension(name, skills, options?)` also takes a `SkillSource` instead of an array, so skills added, edited or removed after start reach the agent without rebuilding the extension. A source is `{ list(context), version(context) }`: `list` returns `{ skills, errors }`, `version` a cheap token that changes when the skills do. Two are built in, and both read through the workspace's Pi `FileSystem` (an `ExecutionEnv` is one; pass the workspace provider's instance, never `node:fs`):
359
359
 
360
- - `directorySkillSource(fs, 'skills')`: every `skills/<name>/SKILL.md`, its folder passed to `load_skill` as for self-evolution. A missing directory holds no skills; folders without `SKILL.md` and companion files are not read. Its version is the folder names with each `SKILL.md`'s `mtimeMs` and size (one `listDir` and one `fileInfo` per folder).
360
+ - `directorySkillSource(fs, 'skills')`: every `skills/<name>/SKILL.md`, its folder passed to `load_skill` as for self-evolution. A missing directory holds no skills; folders without `SKILL.md` are skipped, and companion files are read only with `companionFiles` (below). Its version is the folder names with each `SKILL.md`'s `mtimeMs` and size (one `listDir` and one `fileInfo` per folder).
361
361
  - `fileSkillSource(fs, 'notes/triage.md')`: one skill file, its folder the file's directory; a missing file holds no skill. Its version is the file's `mtimeMs` and size.
362
362
 
363
- The source is read on first use. After that the listing section and `load_skill` use the cached copy until it is older than `options.maxAgeMs` (default 5000), then ask `version()` and read `list()` again only when it changed. There is no watcher or timer: the check happens on use, and concurrent uses share one read. What a source returns is checked with `validSkill` and the duplicate-name rule (the first skill of a name wins). A skill that fails, or a file that cannot be read or parsed, is left out instead of taking the extension down: `options.onDiagnostics(errors)` receives every `SkillError` (`{ path, name?, message }`) after each read (an empty array once all are fine), and `load_skill` asked for the left-out name answers `Skill "<name>" could not be loaded (<path>): <reason>`. A source whose `list`/`version` throws is a host bug: the section keeps the text already shown (Pi's `renderSections`) and `load_skill` fails. A fixed array works as before: validated once, an invalid or duplicate skill throws. With no skills, the section is omitted.
363
+ The source is read on first use. After that the listing section and `load_skill` use the cached copy until it is older than `options.maxAgeMs` (default 5000), then ask `version()` and read `list()` again only when it changed. There is no watcher or timer: the check happens on use, and concurrent uses share one read. `load_skill` asked for a name the conversation's listing does not hold makes the same check at once, whatever `maxAgeMs` (see *refresh on miss* below). What a source returns is checked with `validSkill` and the duplicate-name rule (the first skill of a name wins). A skill that fails, or a file that cannot be read or parsed, is left out instead of taking the extension down: `options.onDiagnostics(errors)` receives every `SkillError` (`{ path, name?, message }`) after each read (an empty array once all are fine), and `load_skill` asked for the left-out name answers `Skill "<name>" could not be loaded (<path>): <reason>`. A source whose `list`/`version` throws is a host bug: the section keeps the text already shown (Pi's `renderSections`) and `load_skill` fails. A fixed array works as before: validated once, an invalid or duplicate skill throws. With no skills, the section is omitted.
364
364
 
365
365
  Pi renders every extension section before each model request (`PromptSection.render`), so a refreshed listing reaches the next request; Pi's `planSystemEntries` writes nothing when the text is unchanged and a minimal `pi.system` section patch when it changed. Prompt cache: on a model that accepts system messages mid-conversation the patch is appended and the cached prefix stays valid; on one that does not, Pi collapses the system messages into one leading prompt, so every listing change rewrites the start of the prompt and misses the provider's prompt cache once. Keep `maxAgeMs` coarse and edits to skill descriptions deliberate. Pi itself has no skills loader in the packages this one depends on (`pi-durable` and `pi-ai`), so this is a host concern built on its `FileSystem` and section seams.
366
366
 
367
+ **Control (all optional; with none set, the extension behaves as above).**
368
+
369
+ - `visible(conversationId, skills)`: which skills a conversation sees. It is called once per render and once per `load_skill` call (may be async) with the whole current list of valid skills, and returns the subset that conversation sees (matched by name). The id is Pi's own: the section gets it from `PromptInput.conversationId`, the tool from `ToolExecutionApi.conversationId`, so no host bookkeeping is needed. A hidden skill is left out of the listing, and `load_skill` answers for it exactly as for an unknown name (`Unknown skill "<name>". Available: <visible names>`). With a filter, a skill that could not be read also answers as unknown (it is never in the list); its reason still reaches `onDiagnostics`. Works for an array too.
370
+ - `companionFiles: true`: `load_skill`'s answer ends with the files beside the skill's `SKILL.md` (the Agent Skills layout: `references/`, `scripts/`, `assets/`; any depth, name order, at most 200), and `load_skill({ name, path })` returns one of them as text. The files are checked when the listing is read, not when the model asks: each one must be a regular file inside the skill's folder that reads as UTF-8 text. A symbolic link, an unreadable or binary file (a NUL byte or invalid UTF-8) and files past the 200th are left out and reported to `onDiagnostics` (`{ path, name: <skill>, message: 'Companion file left out: …' }`). Only a listed path is read, so a path cannot leave the folder (`../`, absolute paths and links are refused with `Skill "<name>" has no companion file "<path>"`). The list is kept with the listing: a companion added or removed later shows up on the next read of the source (a `SKILL.md` change or `refresh()`); a listed file's content is read on demand through the source's `fs` (the same workspace `FileSystem`; both built-in sources set `SkillSource.fs`), never cached. A skill file not named `SKILL.md` (a `fileSkillSource` on `notes/triage.md`) has none. An array, or a source without `fs`, throws.
371
+ - `intro`: the listing section's text before the `- name: description` lines, replacing the default (“Before starting a task that matches a skill, call load_skill …”, with the `/<skill-name>` rule). The skill lines still follow it.
372
+ - `onPublish(next, previous)`: called with both listings' skills before a newly read listing of a source replaces the current one (may be async). Return a reason, or throw, to refuse it: the current listing stays and `onDiagnostics` gets `[{ path: '', message: 'Listing refused by onPublish: <reason>' }]`. A refused listing is not offered again until the source's version changes or `refresh()` runs. The first read is offered too (`previous` empty).
373
+ - `createSkillsController(name, source, options?)` returns `{ extension, refresh }`, so `createSkillsExtension` keeps its return type (it is `createSkillsController(...).extension` for a source). `await refresh(context)` reads the source now, whatever `maxAgeMs`, and publishes it through `onPublish`; `refresh(context, name)` takes only that skill from the new read (added, replaced or removed) and keeps the others as they were. It resolves `true` when published, `false` when refused. Reads run one at a time, so a refresh never interleaves with a `maxAgeMs` check. Call it from the code that changed the skills (an editor save, a self-evolution keep); there is still no watcher.
374
+
375
+ **Refresh on miss.** On a source, `load_skill` for a name not in the conversation's listing checks the source once before answering, whatever `maxAgeMs`: `version()` now, and `list()` plus `onPublish` only when the version changed, serialized with the other reads. So a skill written a moment ago loads on the first try, and the listing shows it from the next request on. A model guessing names costs one `version()` per miss (a `fileInfo` per skill folder for the built-in sources), never a re-read of an unchanged source, and concurrent misses share one check in flight, so there is no time floor. A miss of a refused listing's skill stays a miss until the version changes or `refresh()` runs, as above.
376
+
377
+ ```ts
378
+ const skills = createSkillsController('workspace.skills', directorySkillSource(env, 'skills'), {
379
+ companionFiles: true,
380
+ visible: (conversationId, skills) => billingConversations.has(conversationId) ? skills : skills.filter(skill => !skill.name.startsWith('billing-')),
381
+ onDiagnostics: errors => errors.forEach(error => log.warn(`skill ${error.path}: ${error.message}`)),
382
+ intro: 'Clinic procedures. Load the matching one with load_skill before you answer:',
383
+ onPublish: next => next.some(skill => !skill.body.includes('Reviewed')) ? 'unreviewed skill' : undefined,
384
+ });
385
+ const agent = defineAgent({ id: 'clerk', model, extensions: [skills.extension] });
386
+ await skills.refresh(context, 'letters'); // after the host saved skills/letters/SKILL.md
387
+ ```
388
+
367
389
  ## Sub-agents and schedules
368
390
 
369
391
  These follow [Pi Durable on Cloudflare](https://earendil.com/posts/pi-durable-on-cloudflare/) and stay host-agnostic: a Node process and a Durable Object mount the same tools.
package/dist/agents.d.ts CHANGED
@@ -5,8 +5,8 @@ import type { SelfEvolutionReport } from './self-evolving.js';
5
5
  import type { DelegateOptions } from './delegate.js';
6
6
  import type { Skill } from './skills.js';
7
7
  export type { SelfEvolutionError, SelfEvolutionReport } from './self-evolving.js';
8
- export { createSkillsExtension, directorySkillSource, fileSkillSource, parseSkill } from './skills.js';
9
- export type { Skill, SkillError, SkillFileSystem, SkillListing, SkillSource, SkillsExtensionOptions } from './skills.js';
8
+ export { createSkillsController, createSkillsExtension, directorySkillSource, fileSkillSource, parseSkill } from './skills.js';
9
+ export type { Skill, SkillError, SkillFileSystem, SkillListing, SkillSource, SkillsController, SkillsExtensionOptions } from './skills.js';
10
10
  export { jsonSchemaTool } from './json-schema-tool.js';
11
11
  export type { JsonObjectSchema, JsonSchemaToolOptions } from './json-schema-tool.js';
12
12
  export { createDelegateTool, DELEGATE_DETAILS_KIND, DELEGATE_TOOL } from './delegate.js';
package/dist/agents.js CHANGED
@@ -3,7 +3,7 @@ import { AgentDoc, defineExtension } from '@earendil-works/pi-durable';
3
3
  import { createSelfEvolution } from './self-evolving.js';
4
4
  import { NAME, createSkillsExtension, parseSkill, validSkill } from './skills.js';
5
5
  import { createDelegateTool } from './delegate.js';
6
- export { createSkillsExtension, directorySkillSource, fileSkillSource, parseSkill } from './skills.js';
6
+ export { createSkillsController, createSkillsExtension, directorySkillSource, fileSkillSource, parseSkill } from './skills.js';
7
7
  export { jsonSchemaTool } from './json-schema-tool.js';
8
8
  export { createDelegateTool, DELEGATE_DETAILS_KIND, DELEGATE_TOOL } from './delegate.js';
9
9
  /** Turn an agent described as data into native extensions and a native agent configuration. */
package/dist/skills.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Extension } from '@earendil-works/pi-durable';
1
+ import type { ConversationId, Extension } from '@earendil-works/pi-durable';
2
2
  import type { FileSystem } from '@earendil-works/pi-durable/env';
3
3
  import type { Context } from '@earendil-works/chord';
4
4
  /** Names of skills and agents: a lowercase letter or digit, then lowercase letters, digits, `.`, `_` or `-` (at most 64). */
@@ -31,23 +31,60 @@ export interface SkillListing {
31
31
  export interface SkillSource {
32
32
  readonly list: (context: Context) => Promise<SkillListing>;
33
33
  readonly version: (context: Context) => Promise<string>;
34
+ /** The FileSystem the skills' folders live in; the built-in sources set it. `companionFiles` reads through it. */
35
+ readonly fs?: SkillFileSystem;
34
36
  }
35
37
  /** The part of Pi's `FileSystem` the built-in sources read through (an `ExecutionEnv` is one). */
36
38
  export type SkillFileSystem = Pick<FileSystem, 'readTextFile' | 'listDir' | 'fileInfo'>;
37
- /** Every `<directory>/<name>/SKILL.md`; a missing directory holds no skills. Other files are companions and are never read. */
39
+ /** Every `<directory>/<name>/SKILL.md`; a missing directory holds no skills. Other files are read only with `companionFiles`. */
38
40
  export declare function directorySkillSource(fs: SkillFileSystem, directory: string): SkillSource;
39
41
  /** One skill file (`SKILL.md` front matter); a missing file holds no skill. Its folder is the file's directory. */
40
42
  export declare function fileSkillSource(fs: SkillFileSystem, path: string): SkillSource;
41
43
  export interface SkillsExtensionOptions {
42
44
  /** How long a source's copy is used before `version()` is asked again, in milliseconds. Default 5000. Unused for an array. */
43
45
  readonly maxAgeMs?: number;
44
- /** Called after each read of a source with every skill it left out (an empty array when none). */
46
+ /** Called after each read of a source with every skill it left out (an empty array when none), or with the `onPublish` refusal. */
45
47
  readonly onDiagnostics?: (errors: readonly SkillError[]) => void;
48
+ /**
49
+ * Which skills a conversation sees: called once per listing render and per `load_skill` call with Pi's conversation id and
50
+ * every valid skill, it returns the subset that conversation sees (matched by name; anything else returned is ignored).
51
+ * A hidden skill is left out of the listing and `load_skill` answers for it exactly as for an unknown name. With a filter,
52
+ * a skill that could not be read also answers as unknown (its reason still goes to `onDiagnostics`).
53
+ */
54
+ readonly visible?: (conversationId: ConversationId, skills: readonly Skill[]) => readonly Skill[] | Promise<readonly Skill[]>;
55
+ /**
56
+ * List the files beside each `SKILL.md` (at any depth, at most 200) in `load_skill`'s answer, and let
57
+ * `load_skill({ name, path })` return one of them as text. Each file is checked when the listing is read: a symbolic link,
58
+ * a file that cannot be read or one that is not UTF-8 text is left out and reported to `onDiagnostics`. Read through the
59
+ * source's `fs`; an array or a source without `fs` throws.
60
+ */
61
+ readonly companionFiles?: boolean;
62
+ /** The listing section's text before the `- name: description` lines. Default: call load_skill first, `/<skill-name>` is explicit. */
63
+ readonly intro?: string;
64
+ /**
65
+ * Called before a newly read listing of a source replaces the current one, with both listings' skills. Return a reason
66
+ * (or throw) to refuse it: the current listing stays, and `onDiagnostics` gets `Listing refused by onPublish: <reason>`.
67
+ * A refused listing is offered again only once the source's version changes or `refresh()` is called.
68
+ */
69
+ readonly onPublish?: (next: readonly Skill[], previous: readonly Skill[]) => string | void | Promise<string | void>;
70
+ }
71
+ /** A skills extension on a source, with a handle to re-read the source now. */
72
+ export interface SkillsController {
73
+ readonly extension: Extension;
74
+ /**
75
+ * Read the source now, whatever `maxAgeMs`, and publish what it holds (through `onPublish`). With `name`, only that skill
76
+ * is taken from the new read (added, replaced or removed); the others stay as they were. Resolves `true` when published,
77
+ * `false` when `onPublish` refused it.
78
+ */
79
+ readonly refresh: (context: Context, name?: string) => Promise<boolean>;
46
80
  }
47
81
  /**
48
82
  * A prompt section listing the skills and a `load_skill` tool returning one body. Usable without `defineAgent`.
49
83
  * A fixed array is validated now: an invalid or duplicate skill throws. A `SkillSource` is read on first use and again, once
50
84
  * the copy is older than `maxAgeMs`, when its version changed; what it cannot use is left out and reported, never thrown.
85
+ * `createSkillsController` returns the same extension on a source with a `refresh` handle.
51
86
  */
52
87
  export declare function createSkillsExtension(name: string, skills: readonly Skill[] | SkillSource, options?: SkillsExtensionOptions): Extension;
88
+ /** `createSkillsExtension` on a source, plus `refresh(context, name?)` to re-read it now instead of after `maxAgeMs`. */
89
+ export declare function createSkillsController(name: string, source: SkillSource, options?: SkillsExtensionOptions): SkillsController;
53
90
  //# sourceMappingURL=skills.d.ts.map
package/dist/skills.js CHANGED
@@ -50,7 +50,7 @@ async function readSkillFile(fs, path, name, context) {
50
50
  return { path, name, message: reason(error) };
51
51
  }
52
52
  }
53
- /** Every `<directory>/<name>/SKILL.md`; a missing directory holds no skills. Other files are companions and are never read. */
53
+ /** Every `<directory>/<name>/SKILL.md`; a missing directory holds no skills. Other files are read only with `companionFiles`. */
54
54
  export function directorySkillSource(fs, directory) {
55
55
  const root = directory.replace(/\/+$/, '');
56
56
  async function folders(context) {
@@ -61,6 +61,7 @@ export function directorySkillSource(fs, directory) {
61
61
  }
62
62
  const info = (name, context) => fs.fileInfo(`${root}/${name}/SKILL.md`, context);
63
63
  return {
64
+ fs,
64
65
  version: async (context) => {
65
66
  const { names, error } = await folders(context);
66
67
  if (error)
@@ -92,6 +93,7 @@ export function directorySkillSource(fs, directory) {
92
93
  export function fileSkillSource(fs, path) {
93
94
  const name = path.slice(path.lastIndexOf('/') + 1).replace(/\.md$/i, '');
94
95
  return {
96
+ fs,
95
97
  version: async (context) => {
96
98
  const found = await fs.fileInfo(path, context);
97
99
  return found.ok ? stamp(found.value) : found.error.code;
@@ -122,75 +124,219 @@ function loaded(listing) {
122
124
  errors.push({ path, name: skill.name, message: `Duplicate skill: ${skill.name}` });
123
125
  continue;
124
126
  }
127
+ const file = skillFiles.get(raw);
128
+ if (file !== undefined)
129
+ skillFiles.set(skill, file);
125
130
  known.set(skill.name, skill);
126
131
  }
127
132
  return { known, errors };
128
133
  }
129
- /** The current copy of a source: read again once it is older than `maxAgeMs` and `version()` changed. Concurrent callers share one read. */
134
+ /** `next` with only `name` taken from it; the rest of `current` is kept. */
135
+ function only(name, next, current) {
136
+ const known = new Map(current.known), skill = next.known.get(name);
137
+ if (skill)
138
+ known.set(name, skill);
139
+ else
140
+ known.delete(name);
141
+ return { known, errors: [...current.errors.filter(error => error.name !== name), ...next.errors.filter(error => error.name === name)] };
142
+ }
143
+ /**
144
+ * The current copy of a source: read again once it is older than `maxAgeMs` and `version()` changed, or when `refresh` asks.
145
+ * Reads run one at a time; concurrent callers of `current` share one.
146
+ */
130
147
  function liveSkills(source, options) {
131
- const maxAgeMs = options.maxAgeMs ?? 5000;
148
+ const maxAgeMs = options.maxAgeMs ?? 5000, fs = options.companionFiles ? source.fs : undefined;
132
149
  let state = { known: new Map(), errors: [] };
133
- let version, checkedAt = -Infinity, pending;
134
- async function refresh(context) {
150
+ let version, checkedAt = -Infinity, pending, missing;
151
+ let queue = Promise.resolve();
152
+ const read = async (context) => { const next = loaded(await source.list(context)); return fs ? withCompanions(fs, next, context) : next; };
153
+ const serial = (run) => { const next = queue.then(run, run); queue = next.catch(() => { }); return next; };
154
+ async function refusal(next) {
155
+ if (!options.onPublish)
156
+ return undefined;
157
+ try {
158
+ return (await options.onPublish([...next.known.values()], [...state.known.values()])) ?? undefined;
159
+ }
160
+ catch (error) {
161
+ return reason(error);
162
+ }
163
+ }
164
+ async function publish(next) {
165
+ const refused = await refusal(next);
166
+ if (refused !== undefined) {
167
+ options.onDiagnostics?.([{ path: '', message: `Listing refused by onPublish: ${refused}` }]);
168
+ return false;
169
+ }
170
+ state = next;
171
+ options.onDiagnostics?.(state.errors);
172
+ return true;
173
+ }
174
+ async function check(context) {
135
175
  const next = await source.version(context);
136
176
  if (next !== version) {
137
- state = loaded(await source.list(context));
177
+ const listing = await read(context);
138
178
  version = next;
139
- options.onDiagnostics?.(state.errors);
179
+ await publish(listing);
140
180
  }
141
181
  checkedAt = Date.now();
142
182
  return state;
143
183
  }
144
- return context => {
145
- if (Date.now() - checkedAt < maxAgeMs)
146
- return Promise.resolve(state);
147
- return pending ??= refresh(context).finally(() => { pending = undefined; });
184
+ return {
185
+ current: (context) => {
186
+ if (Date.now() - checkedAt < maxAgeMs)
187
+ return Promise.resolve(state);
188
+ return pending ??= serial(() => check(context)).finally(() => { pending = undefined; });
189
+ },
190
+ // A load_skill miss: the maxAgeMs check now. One at a time, shared by concurrent misses; `list()` and `onPublish` run
191
+ // only when `version()` changed, so a model asking for names that do not exist costs one `version()` per miss.
192
+ miss: (context) => missing ??= serial(() => check(context)).finally(() => { missing = undefined; }),
193
+ refresh: (context, name) => serial(async () => {
194
+ const next = await source.version(context), listing = await read(context);
195
+ // One skill taken: the copy no longer matches any version, so the next check reads the whole source again.
196
+ version = name === undefined ? next : undefined;
197
+ checkedAt = Date.now();
198
+ return publish(name === undefined ? listing : only(name, listing, state));
199
+ }),
148
200
  };
149
201
  }
150
- const INTRO = 'Before starting a task that matches a skill, call load_skill with its name and follow the returned instructions. A user message that starts with /<skill-name> naming one of these skills is an explicit request: call load_skill for that skill first and follow it for the rest of the message.';
202
+ const INTRO_TEXT = 'Before starting a task that matches a skill, call load_skill with its name and follow the returned instructions. A user message that starts with /<skill-name> naming one of these skills is an explicit request: call load_skill for that skill first and follow it for the rest of the message.';
203
+ const COMPANION_LIMIT = 200;
204
+ /** Each skill's companion files, checked when its listing was read. */
205
+ const companionLists = new WeakMap();
151
206
  /**
152
- * A prompt section listing the skills and a `load_skill` tool returning one body. Usable without `defineAgent`.
153
- * A fixed array is validated now: an invalid or duplicate skill throws. A `SkillSource` is read on first use and again, once
154
- * the copy is older than `maxAgeMs`, when its version changed; what it cannot use is left out and reported, never thrown.
207
+ * Walk the files beside each skill's `SKILL.md` (relative to its folder, depth first in name order, at most
208
+ * `COMPANION_LIMIT`) and keep those that read as UTF-8 text. A symbolic link, an unreadable or non-text file, and files past
209
+ * the limit are left out and reported with the skill's name. A skill file not named `SKILL.md` has none.
155
210
  */
156
- export function createSkillsExtension(name, skills, options = {}) {
157
- let current;
158
- if (isSkillArray(skills)) {
159
- const known = new Map();
160
- for (const skill of skills.map(validSkill)) {
161
- if (known.has(skill.name))
162
- throw new TypeError(`Duplicate skill: ${skill.name}`);
163
- known.set(skill.name, skill);
164
- }
165
- const fixed = { known, errors: [] };
166
- current = async () => fixed;
211
+ async function withCompanions(fs, listing, context) {
212
+ const errors = [...listing.errors];
213
+ for (const skill of listing.known.values()) {
214
+ const folder = skillFolders.get(skill), file = skillFiles.get(skill), files = [];
215
+ companionLists.set(skill, files);
216
+ if (folder === undefined || (file !== undefined && !/(^|\/)SKILL\.md$/.test(file)))
217
+ continue;
218
+ const at = (relative) => relative === '' ? folder : folder === '.' ? relative : `${folder}/${relative}`;
219
+ const leftOut = (relative, message) => { errors.push({ path: at(relative), name: skill.name, message }); };
220
+ let over = false;
221
+ const walk = async (relative) => {
222
+ const listed = await fs.listDir(at(relative), context);
223
+ if (!listed.ok) {
224
+ leftOut(relative, `Companion files could not be listed: ${listed.error.message}`);
225
+ return;
226
+ }
227
+ for (const entry of [...listed.value].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
228
+ if (over)
229
+ return;
230
+ const path = relative === '' ? entry.name : `${relative}/${entry.name}`;
231
+ if (entry.kind === 'directory') {
232
+ await walk(path);
233
+ continue;
234
+ }
235
+ if (path === 'SKILL.md')
236
+ continue;
237
+ if (entry.kind === 'symlink') {
238
+ leftOut(path, 'Companion file left out: a symbolic link');
239
+ continue;
240
+ }
241
+ if (files.length >= COMPANION_LIMIT) {
242
+ over = true;
243
+ leftOut('', `Companion files left out: more than ${COMPANION_LIMIT}`);
244
+ return;
245
+ }
246
+ const text = await fs.readTextFile(at(path), context);
247
+ if (!text.ok)
248
+ leftOut(path, `Companion file left out: ${text.error.message}`);
249
+ else if (/[\u0000\uFFFD]/.test(text.value))
250
+ leftOut(path, 'Companion file left out: not UTF-8 text');
251
+ else
252
+ files.push(path);
253
+ }
254
+ };
255
+ await walk('');
167
256
  }
168
- else
169
- current = liveSkills(skills, options);
257
+ return { known: listing.known, errors };
258
+ }
259
+ /** The listing section and `load_skill` over `live`, filtered per conversation by `visible`. */
260
+ function skillsExtension(name, live, options, fs) {
261
+ const { visible } = options, intro = options.intro ?? INTRO_TEXT;
262
+ // `at` is read only with a filter, so direct callers of render/execute without Pi's input or api keep working.
263
+ async function seen(known, at) {
264
+ const all = [...known.values()];
265
+ if (!visible)
266
+ return all;
267
+ const names = new Set((await visible(at(), all)).map(skill => skill.name));
268
+ return all.filter(skill => names.has(skill.name));
269
+ }
270
+ const parameters = fs
271
+ ? Type.Object({ name: Type.String(), path: Type.Optional(Type.String({ description: 'A companion file of the skill, relative to its folder.' })) }, { additionalProperties: false })
272
+ : Type.Object({ name: Type.String() }, { additionalProperties: false });
170
273
  return defineExtension({
171
274
  name,
172
- sections: [{ key: 'skills', render: async (_input, context) => {
173
- const { known } = await current(context);
174
- return known.size ? `${INTRO}\n${[...known.values()].map(skill => `- ${skill.name}: ${skill.description}`).join('\n')}` : undefined;
275
+ sections: [{ key: 'skills', render: async (input, context) => {
276
+ const skills = await seen((await live.current(context)).known, () => input.conversationId);
277
+ return skills.length ? `${intro}\n${skills.map(skill => `- ${skill.name}: ${skill.description}`).join('\n')}` : undefined;
175
278
  } }],
176
279
  tools: [defineTool({
177
280
  name: 'load_skill',
178
- description: 'Load the full instructions of one listed skill.',
179
- parameters: Type.Object({ name: Type.String() }, { additionalProperties: false }),
281
+ description: fs ? 'Load the full instructions of one listed skill, or with `path` one of its companion files.' : 'Load the full instructions of one listed skill.',
282
+ parameters,
180
283
  replay: 'safe',
181
- execute: async (args, _api, context) => {
182
- const { known, errors } = await current(context);
183
- const skill = known.get(args.name);
284
+ execute: async (args, api, context) => {
285
+ const text = (value) => ({ output: [{ type: 'text', text: value }] });
286
+ let { known, errors } = await live.current(context);
287
+ let shown = await seen(known, () => api.conversationId);
288
+ if (!shown.some(candidate => candidate.name === args.name) && live.miss) {
289
+ // Not in this conversation's listing: the skill may have been added since, so check the source once now.
290
+ ({ known, errors } = await live.miss(context));
291
+ shown = await seen(known, () => api.conversationId);
292
+ }
293
+ const skill = shown.find(candidate => candidate.name === args.name);
184
294
  if (!skill) {
185
- const failed = errors.find(error => error.name === args.name);
186
- const text = failed ? `Skill "${args.name}" could not be loaded (${failed.path}): ${failed.message}` : `Unknown skill "${args.name}". Available: ${[...known.keys()].join(', ')}`;
187
- return { output: [{ type: 'text', text }] };
295
+ const failed = visible ? undefined : errors.find(error => error.name === args.name);
296
+ return text(failed ? `Skill "${args.name}" could not be loaded (${failed.path}): ${failed.message}` : `Unknown skill "${args.name}". Available: ${shown.map(entry => entry.name).join(', ')}`);
188
297
  }
189
298
  const folder = skillFolders.get(skill);
299
+ const companions = fs ? companionLists.get(skill) ?? [] : [];
300
+ if (fs && args.path !== undefined) {
301
+ // Only a listed file is read, so the path cannot leave the folder or follow a symbolic link.
302
+ const path = args.path.replace(/^(\.\/)+/, '');
303
+ if (!companions.includes(path))
304
+ return text(`Skill "${skill.name}" has no companion file "${args.path}". Companion files: ${companions.join(', ') || 'none'}`);
305
+ const read = await fs.readTextFile(folder === '.' ? path : `${folder}/${path}`, context);
306
+ return text(read.ok ? read.value : `Companion file "${path}" of skill "${skill.name}" could not be read: ${read.error.message}`);
307
+ }
190
308
  const location = folder === undefined ? '' : `\n\nThis skill's folder is \`${folder}\`; paths in it are relative to that folder.`;
191
- return { output: [{ type: 'text', text: skill.body + location }] };
309
+ const listed = companions.length ? `\n\nCompanion files of this skill (read one with load_skill and its path):\n${companions.map(path => `- ${path}`).join('\n')}` : '';
310
+ return text(skill.body + location + listed);
192
311
  },
193
312
  })],
194
313
  });
195
314
  }
315
+ /**
316
+ * A prompt section listing the skills and a `load_skill` tool returning one body. Usable without `defineAgent`.
317
+ * A fixed array is validated now: an invalid or duplicate skill throws. A `SkillSource` is read on first use and again, once
318
+ * the copy is older than `maxAgeMs`, when its version changed; what it cannot use is left out and reported, never thrown.
319
+ * `createSkillsController` returns the same extension on a source with a `refresh` handle.
320
+ */
321
+ export function createSkillsExtension(name, skills, options = {}) {
322
+ if (!isSkillArray(skills))
323
+ return createSkillsController(name, skills, options).extension;
324
+ if (options.companionFiles)
325
+ throw new TypeError('Skills extension: companionFiles need a SkillSource with fs, not an array');
326
+ const known = new Map();
327
+ for (const skill of skills.map(validSkill)) {
328
+ if (known.has(skill.name))
329
+ throw new TypeError(`Duplicate skill: ${skill.name}`);
330
+ known.set(skill.name, skill);
331
+ }
332
+ const fixed = { known, errors: [] };
333
+ return skillsExtension(name, { current: async () => fixed }, options, undefined);
334
+ }
335
+ /** `createSkillsExtension` on a source, plus `refresh(context, name?)` to re-read it now instead of after `maxAgeMs`. */
336
+ export function createSkillsController(name, source, options = {}) {
337
+ if (options.companionFiles && !source.fs)
338
+ throw new TypeError('Skills extension: companionFiles need a SkillSource with fs');
339
+ const live = liveSkills(source, options);
340
+ return { extension: skillsExtension(name, live, options, options.companionFiles ? source.fs : undefined), refresh: live.refresh };
341
+ }
196
342
  const isSkillArray = (skills) => Array.isArray(skills);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-agent",
3
- "version": "0.4.22",
3
+ "version": "0.4.23",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -160,8 +160,8 @@
160
160
  "@earendil-works/pi-durable": "~1.2.0",
161
161
  "@earendil-works/pi-ai": "~1.2.0",
162
162
  "@earendil-works/chord": "~1.2.0",
163
- "@hachej/boring-files": "^0.4.22",
164
- "@hachej/boring-ui-kit": "^0.4.22",
163
+ "@hachej/boring-files": "^0.4.23",
164
+ "@hachej/boring-ui-kit": "^0.4.23",
165
165
  "xlsx": "^0.20.3"
166
166
  },
167
167
  "peerDependenciesMeta": {