@hachej/boring-agent 0.4.24 → 0.4.26
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 +35 -13
- package/dist/agents.d.ts +9 -5
- package/dist/agents.js +11 -5
- package/dist/delegate.d.ts +2 -1
- package/dist/delegate.js +10 -4
- package/dist/mentions.d.ts +2 -2
- package/dist/mentions.js +2 -2
- package/dist/self-evolving.d.ts +0 -2
- package/dist/self-evolving.js +8 -41
- package/dist/skills.d.ts +74 -24
- package/dist/skills.js +210 -43
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -349,33 +349,34 @@ Each evidence entry must link to a completed native ToolTask, its original assis
|
|
|
349
349
|
|
|
350
350
|
`?op=configure` (POST `{ model?: { provider, modelId }, thinkingLevel? }`) changes the conversation's native agent for a model or effort picker. It is gated by `allow('configure')` and delegated to `ChatTransportAccess.configure(change)`; without that function the op answers `not-supported`. The host function applies `conversation.configure(...)` and owns the allow-list: it validates the change against its own models and levels and returns a refusal such as `{ kind: 'refused', reason }` instead of applying it (the handler checks only the request shape). `createRemoteChat().configure(change)` returns whatever the host answered. A change applies from the next model request.
|
|
351
351
|
|
|
352
|
-
`
|
|
352
|
+
The skills section (`staticSkills` and `liveSkills`) tells the model that a user message starting with `/<skill-name>` is an explicit request to load that skill first, which is what the chat's `/` menu inserts. `defineAgent(...).skills` lists each skill's name and description for such a menu when the skills are an array.
|
|
353
353
|
|
|
354
354
|
With self-evolution, workspace skills may be flat `.agent/skills/<name>.md` files or `.agent/skills/<name>/SKILL.md` folders. Only `SKILL.md` defines a skill inside a folder; other Markdown and deeper files are companions, not skills. `load_skill` returns the body followed by “This skill's folder is `.agent/skills/<name>`; paths in it are relative to that folder.” (For flat files the folder is `.agent/skills`.) A skill can say `see portions.md` or `see tables/2026.csv`; the agent reads that path under the returned folder with Pi's native `read`, through the same workspace provider. Scanning and loading do not read the companions. No special companion folder, new file tool or additional access is granted. Host-given `Skill` data stays `{ name, description, body }`: with no source folder, `load_skill` returns its body unchanged.
|
|
355
355
|
|
|
356
356
|
## Live skills
|
|
357
357
|
|
|
358
|
-
`
|
|
358
|
+
`staticSkills(skills, { name, intro?, visible? })` takes an array and returns `{ extension }`. `liveSkills(source, { name, maxAgeMs?, onDiagnostics?, companionFiles?, onPublish?, intro?, visible? })` takes a `SkillSource` instead, so skills added, edited or removed after start reach the agent without rebuilding the extension; `defineAgent({ skills })` takes either. 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` are skipped, and companion files are read only with `companionFiles` (below). Its version is
|
|
360
|
+
- `directorySkillSource(fs, 'skills')`: every `skills/<name>/SKILL.md`, its folder passed to `load_skill` as for self-evolution; `{ flat: true }` adds every `skills/<name>.md` file (self-evolution's `.agent/skills` layout). A missing directory holds no skills; folders without `SKILL.md` are skipped, and companion files are read only with `companionFiles` (below). Its version is each skill file's path with its `mtimeMs` and size (one `listDir` and one `fileInfo` per skill file).
|
|
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 `
|
|
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 `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: `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
367
|
**Control (all optional; with none set, the extension behaves as above).**
|
|
368
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
|
|
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,
|
|
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 `staticSkills` 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, and a listed file is checked again 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>"`). Before returning a listed file, `load_skill` looks at the skill's folder, every folder on the way and the file with `fileInfo` (which does not follow links) and checks the text again: a file or folder swapped for a symbolic link since the listing (the agent may write its own skill folder) answers `Companion file "<path>" of skill "<name>" could not be read: <path> is a symbolic link` and is never followed. 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. A source without `fs` throws.
|
|
371
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 `
|
|
373
|
-
- `
|
|
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 that listing's errors followed by `{ path: '', message: 'Listing refused by onPublish: <reason>' }` (the per-skill errors are kept; the entry with `path: ''` is the refusal). 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
|
+
- `liveSkills(source, options)` returns `{ extension, refresh }`; `staticSkills` returns `{ extension }` only. `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 until the source's `version()` changes again: later `maxAgeMs` checks and `load_skill` misses do not re-read an unchanged source, and the first read after a change offers the whole source (through `onPublish`) as usual. A source only lists everything at once, so the others' pending edits are part of that next read. 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
374
|
|
|
375
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
376
|
|
|
377
377
|
```ts
|
|
378
|
-
const skills =
|
|
378
|
+
const skills = liveSkills(directorySkillSource(env, 'skills'), {
|
|
379
|
+
name: 'workspace.skills',
|
|
379
380
|
companionFiles: true,
|
|
380
381
|
visible: (conversationId, skills) => billingConversations.has(conversationId) ? skills : skills.filter(skill => !skill.name.startsWith('billing-')),
|
|
381
382
|
onDiagnostics: errors => errors.forEach(error => log.warn(`skill ${error.path}: ${error.message}`)),
|
|
@@ -386,6 +387,27 @@ const agent = defineAgent({ id: 'clerk', model, extensions: [skills.extension] }
|
|
|
386
387
|
await skills.refresh(context, 'letters'); // after the host saved skills/letters/SKILL.md
|
|
387
388
|
```
|
|
388
389
|
|
|
390
|
+
### Versioned skill folders
|
|
391
|
+
|
|
392
|
+
`directorySkillSource(fs, root, { versioned: true })` reads skills published as whole versions, for example from a bucket mounted read-only into the agent runtime (the library only reads a folder through the workspace `FileSystem`; it does not know where the bytes live):
|
|
393
|
+
|
|
394
|
+
```
|
|
395
|
+
<root>/v12/<skill>/SKILL.md (+ companion files)
|
|
396
|
+
<root>/v13/<skill>/SKILL.md
|
|
397
|
+
<root>/current one line: v13
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
- **Publish**: write the new version folder completely, then overwrite `current` with its name. A version folder is never changed once written (the library reads each version once and keeps it).
|
|
401
|
+
- **Rollback**: write the old version's name back into `current`.
|
|
402
|
+
- **Which version a conversation sees**: a new conversation starts on the version `current` names (read again once older than `maxAgeMs`). A running conversation keeps the version it started on until the person sends `/reload-skill`, which moves that conversation only to the version `current` names now (reading that version's folder again). `{ flat: true }` applies inside each version folder.
|
|
403
|
+
- **A bad pointer**: `current` missing or unreadable, a value that is not one folder name (`../v1`), or a version with no folder is reported to `onDiagnostics` as `{ path: '<root>/current', message }`; new conversations stay on the last good version, and `/reload-skill` answers `Skills stay on version <v>: <message>`. Before any good version, the agent has no skills.
|
|
404
|
+
|
|
405
|
+
`/reload-skill` is the skills extension's native tool `reload_skill` (Pi extension point: a tool and the listing section of the extension, over the `SkillSource` on the workspace's `ExecutionEnv` `FileSystem`; no second loader). The listing tells the model that a message `/reload-skill` means calling it, as `/<skill-name>` means `load_skill`; the tool answers `Skills reloaded: version <v> (<names>).` A host command can do the same without the model: `refresh(context, undefined, conversationId)` moves one conversation, `refresh(context)` every conversation, and resolves `false` on a bad pointer. `refresh(context, name)` is refused (versions move whole), and so is `onPublish`. Limit: which version each conversation is on is kept in the process, so after a restart every conversation starts on the version `current` names then.
|
|
406
|
+
|
|
407
|
+
```ts
|
|
408
|
+
const agent = defineAgent({ id: 'clerk', model, skills: directorySkillSource(env, '/mnt/skills', { versioned: true }) });
|
|
409
|
+
```
|
|
410
|
+
|
|
389
411
|
## Sub-agents and schedules
|
|
390
412
|
|
|
391
413
|
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.
|
|
@@ -412,12 +434,12 @@ const delegates = [{ agent: researcher, onChild: async (parent, child, tx) => {
|
|
|
412
434
|
| `limits.maxStartsPerHour` | `number` | Children started for one parent conversation in the last 60 minutes (rolling), ended or not. |
|
|
413
435
|
| `limits.maxTaskChars` | `number` | Characters of `task`. |
|
|
414
436
|
| `limits.timeoutMs` | `number` | Wall-clock milliseconds from the call's first start (kept across a rerun). On expiry the tool aborts the child conversation (`ConversationHandle.abort`, the same cancel path as a stopped call) and the call fails: `details.status` `failed`, `error` `The <agent> agent timed out after <ms> ms.` |
|
|
415
|
-
| `meter` | `(parent, child) => DelegateMeter \| Promise<DelegateMeter>` | The host's handle for the child's spend: the tool calls `record(event)` once per child model response (`DelegateModelEvent`: `entryId`, `provider`, `model`, Pi's per-response `usage`) and `close(end)` once at the end (`done
|
|
437
|
+
| `meter` | `(parent, child) => DelegateMeter \| Promise<DelegateMeter>` | The host's handle for the child's spend: the tool calls `record(event)` once per child model response (`DelegateModelEvent`: `entryId`, `provider`, `model`, Pi's per-response `usage`) and `close(end)` once at the end (`done` once the child's answer is committed to the parent; `cancelled` only when the call or the child was aborted; `timeout`; `failed` for every other end, including a throw such as a failed commit). The kit keeps no log. |
|
|
416
438
|
| `childTools` | `string[]` | Names of the tools a child may have: its enabled tools are the intersection with what it would otherwise get, written as Pi's own `pi.agent` `tools` filter in the creating commit. |
|
|
417
439
|
|
|
418
|
-
A call over a limit is refused: it creates no child and returns an ordinary error result the model reads, for example `2 delegates already running for this conversation (limit 2).`, `1 delegate already running
|
|
440
|
+
A call over a limit is refused: it creates no child and returns an ordinary error result the model reads, for example `2 delegates already running for this conversation (limit 2).`, `1 delegate already running across the conversations sharing this limit (limit 1).`, `1 delegate already started for this conversation in the last hour (limit 1).` or `The task is 41 characters; the limit is 40.` It never throws and carries no `details`. Only a call that would create a child is checked: a rerun that finds its existing child (after a crash or eviction) is never refused, and a call counts once, by its tool task.
|
|
419
441
|
|
|
420
|
-
The counts live in this process and belong to the `limits` object: every delegate tool given the same object shares them, so one object passed to several `delegates` entries is one host-wide cap. Separate processes do not share them, and a restart starts them empty (reruns that reattach count their children again; the hourly window forgets earlier starts). `meter` is called after the commit that creates the child and again by a rerun in a new process that reattaches to it, since the earlier handle is gone; such a rerun sends the child's earlier responses again, so dedupe by `entryId`. A throw from `meter` fails the call; a throw from `record` or `close` is ignored.
|
|
442
|
+
The counts live in this process and belong to the `limits` object: every delegate tool given the same object shares them, so one object passed to several `delegates` entries is one host-wide cap, while two entries written with their own literals (`limits: { maxRunning: 2 }` each) get two separate budgets; the refusal text says “sharing this limit” for that reason. Separate processes do not share them, and a restart starts them empty (reruns that reattach count their children again; the hourly window forgets earlier starts). `meter` is called after the commit that creates the child and again by a rerun in a new process that reattaches to it, since the earlier handle is gone; such a rerun sends the child's earlier responses again, so dedupe by `entryId`. A throw from `meter` fails the call; a throw from `record` or `close` is ignored.
|
|
421
443
|
|
|
422
444
|
```ts
|
|
423
445
|
const limits = { maxRunningPerConversation: 2, maxRunning: 8, maxStartsPerHour: 20, maxTaskChars: 4000, timeoutMs: 300_000 };
|
|
@@ -439,7 +461,7 @@ const delegates = [
|
|
|
439
461
|
|
|
440
462
|
`defineAgent({ …, selfEvolving: true, workspace })` lets the agent keep its own standing instructions, skills and tools in the `.agent/` folder of its workspace and apply them with a `reload` tool; the plan and its reasons are in [SELF-EVOLUTION.md](../../docs/architecture/SELF-EVOLUTION.md). Off by default.
|
|
441
463
|
|
|
442
|
-
- One native extension per workspace, named `self-evolving:<workspace>`, so two workspaces in one host never replace each other's tools. Without a fixed `workspace` the agent works in a workspace per conversation: `inWorkspace(name)` returns the agent of workspace `name` (made once, then the same), whose configuration selects only `self-evolving:<name>`; the host creates each conversation with the agent of its owner's workspace and installs and reloads that workspace's extension when the workspace opens, so one harness and one registry never offer one workspace's agent-written tools to another's. It carries the skills (the host's, then `.agent/skills/*.md` and `.agent/skills/<name>/SKILL.md
|
|
464
|
+
- One native extension per workspace, named `self-evolving:<workspace>`, so two workspaces in one host never replace each other's tools. Without a fixed `workspace` the agent works in a workspace per conversation: `inWorkspace(name)` returns the agent of workspace `name` (made once, then the same), whose configuration selects only `self-evolving:<name>`; the host creates each conversation with the agent of its owner's workspace and installs and reloads that workspace's extension when the workspace opens, so one harness and one registry never offer one workspace's agent-written tools to another's. It carries the skills (the host's, then `.agent/skills/*.md` and `.agent/skills/<name>/SKILL.md`, read with `directorySkillSource(env, '.agent/skills', { flat: true })`, behind the usual `load_skill`), `reload`, the agent-written tools and the `agent-written` prompt section. It is selected last; the host's `instructions` become the first section (`host-instructions`) of `agent.<id>`, because Pi renders native `instructions` after every extension section.
|
|
443
465
|
- `.agent/AGENTS.md` is rendered by that section on every request through the request's environment, labelled as agent-written and capped at `MAX_AGENT_INSTRUCTIONS` (16,000) characters.
|
|
444
466
|
- `.agent/tools/<name>.json` is `{ name, description, parameters, run }`: `name` snake_case and not one of the agent's other tools, `parameters` a JSON Schema with `"type": "object"`, `run` one command line; a path under `.agent/` that `run` names must exist. Each becomes a native `ToolRegistration` (through `jsonSchemaTool`, so Pi refuses arguments that do not match `parameters` before `run` starts) whose `execute` runs `run` through the call's own `ExecutionEnv.exec` (the same provider instance as the files it was read from), with the arguments as one line of JSON on standard input (a quoted here-document); the output is the result and a non-zero exit is an error.
|
|
445
467
|
- `reload` (and `DefinedAgent.reload(env, context)`, which a host calls on open and for the person's `/reload`) reads `.agent/` through the given environment's `FileSystem`, rebuilds the extension and calls the native `registry.install()` in every registry `install` was given (same name: replaced in place). Reloads are serialised. The report (`SelfEvolutionReport`, its `text` is the tool result) names tools added, changed and removed, the agent's skills, the instructions and every load error (invalid JSON, schema, missing script, name conflict, unreadable file). A reload never imports or evaluates anything. Without `exec` the tools are reported unavailable and skipped; instructions and skills still load.
|
package/dist/agents.d.ts
CHANGED
|
@@ -3,10 +3,10 @@ import type { Context } from '@earendil-works/chord';
|
|
|
3
3
|
import type { ExecutionEnv } from '@earendil-works/pi-durable/env';
|
|
4
4
|
import type { SelfEvolutionReport } from './self-evolving.js';
|
|
5
5
|
import type { DelegateOptions } from './delegate.js';
|
|
6
|
-
import type { Skill } from './skills.js';
|
|
6
|
+
import type { Skill, SkillSource } from './skills.js';
|
|
7
7
|
export type { SelfEvolutionError, SelfEvolutionReport } from './self-evolving.js';
|
|
8
|
-
export {
|
|
9
|
-
export type { Skill, SkillError, SkillFileSystem, SkillListing, SkillSource,
|
|
8
|
+
export { directorySkillSource, fileSkillSource, liveSkills, parseSkill, staticSkills } from './skills.js';
|
|
9
|
+
export type { LiveSkills, LiveSkillsOptions, Skill, SkillError, SkillFileSystem, SkillListing, SkillSource, SkillsOptions, SkillVersions } 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';
|
|
@@ -22,7 +22,11 @@ export interface AgentSpec {
|
|
|
22
22
|
readonly tools?: readonly ToolRegistration[];
|
|
23
23
|
/** Additional native extensions this agent selects, for example Pi's `CodingTools`. */
|
|
24
24
|
readonly extensions?: readonly Extension[];
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The agent's skills: an array, or a `SkillSource` (`directorySkillSource`, `fileSkillSource` or a host's own) read on use with
|
|
27
|
+
* `liveSkills`. A source cannot be combined with `selfEvolving`.
|
|
28
|
+
*/
|
|
29
|
+
readonly skills?: readonly Skill[] | SkillSource;
|
|
26
30
|
/**
|
|
27
31
|
* Sub-agents this agent may hand a task to: each becomes a `delegate` tool (`createDelegateTool`) among this agent's tools, and
|
|
28
32
|
* `install` installs the child agent's extensions too. The child runs in a conversation owned by the tool call, with only its own tools.
|
|
@@ -47,7 +51,7 @@ export interface DefinedAgent {
|
|
|
47
51
|
readonly id: string;
|
|
48
52
|
/** Every native extension this agent selects, own extension first; with `selfEvolving` the per-workspace extension (as first defined) last. */
|
|
49
53
|
readonly extensions: readonly Extension[];
|
|
50
|
-
/** Name and description of this agent's skills, for hosts that offer them in a `/` menu. */
|
|
54
|
+
/** Name and description of this agent's skills (an array given to `skills`), for hosts that offer them in a `/` menu. A source's skills are read at run time and not listed. */
|
|
51
55
|
readonly skills: readonly Pick<Skill, 'name' | 'description'>[];
|
|
52
56
|
/** Native per-conversation agent configuration. */
|
|
53
57
|
readonly agent: AgentChange;
|
package/dist/agents.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { AgentDoc, defineExtension } from '@earendil-works/pi-durable';
|
|
3
3
|
import { createSelfEvolution } from './self-evolving.js';
|
|
4
|
-
import { NAME,
|
|
4
|
+
import { NAME, isSkillArray, liveSkills, staticSkills, validSkill } from './skills.js';
|
|
5
5
|
import { createDelegateTool } from './delegate.js';
|
|
6
|
-
export {
|
|
6
|
+
export { directorySkillSource, fileSkillSource, liveSkills, parseSkill, staticSkills } 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. */
|
|
@@ -23,7 +23,13 @@ export function defineAgent(spec) {
|
|
|
23
23
|
// The skills of a self-evolving agent live in its per-workspace extension, so agent-written skills join them on reload. Without a
|
|
24
24
|
// workspace (a self-evolving agent whose workspace is chosen per conversation, before `inWorkspace`) they stay the host's alone.
|
|
25
25
|
const evolving = spec.selfEvolving === true && spec.workspace !== undefined;
|
|
26
|
-
|
|
26
|
+
if (spec.selfEvolving && spec.skills !== undefined && !isSkillArray(spec.skills))
|
|
27
|
+
throw new TypeError(`Agent ${spec.id}: selfEvolving takes an array of skills, not a SkillSource`);
|
|
28
|
+
const fixed = spec.skills === undefined ? [] : isSkillArray(spec.skills) ? spec.skills : [];
|
|
29
|
+
const skillsName = `agent.${spec.id}.skills`;
|
|
30
|
+
const hostSkills = spec.skills === undefined || evolving ? [] : isSkillArray(spec.skills)
|
|
31
|
+
? (spec.skills.length ? [staticSkills(spec.skills, { name: skillsName }).extension] : [])
|
|
32
|
+
: [liveSkills(spec.skills, { name: skillsName }).extension];
|
|
27
33
|
const listed = (skills) => Object.freeze(skills.map(skill => Object.freeze({ name: skill.name, description: skill.description.trim() })));
|
|
28
34
|
const reserved = [...tools, ...(spec.extensions ?? []).flatMap(extension => extension.tools ?? [])].map(tool => tool.name);
|
|
29
35
|
/** The agent as one native configuration: its own extensions, the host's, and with `evolution` the per-workspace one last. */
|
|
@@ -38,7 +44,7 @@ export function defineAgent(spec) {
|
|
|
38
44
|
...(spec.thinkingLevel === undefined ? {} : { thinkingLevel: spec.thinkingLevel }),
|
|
39
45
|
...(spec.cwd === undefined ? {} : { cwd: spec.cwd }),
|
|
40
46
|
});
|
|
41
|
-
const fixedSkills = listed(
|
|
47
|
+
const fixedSkills = listed(fixed);
|
|
42
48
|
return Object.freeze({
|
|
43
49
|
id: spec.id, extensions, agent,
|
|
44
50
|
get skills() { return evolution ? listed(evolution.skills()) : fixedSkills; },
|
|
@@ -58,7 +64,7 @@ export function defineAgent(spec) {
|
|
|
58
64
|
});
|
|
59
65
|
}
|
|
60
66
|
const evolutionOf = (workspace) => createSelfEvolution({
|
|
61
|
-
name: `self-evolving:${workspace}`, skills:
|
|
67
|
+
name: `self-evolving:${workspace}`, skills: fixed.map(validSkill), reserved,
|
|
62
68
|
});
|
|
63
69
|
if (!spec.selfEvolving)
|
|
64
70
|
return assemble([...own, ...hostSkills], undefined);
|
package/dist/delegate.d.ts
CHANGED
|
@@ -117,7 +117,8 @@ export interface DelegateOptions {
|
|
|
117
117
|
* child's final text. The child inherits the parent's approval and sandbox extensions (appended after its own, in the parent's order, so a
|
|
118
118
|
* sandbox stays last), so a delegate cannot escape the parent's gate; `details` is a `DelegateDetails` (live while the child runs) for a UI that shows it.
|
|
119
119
|
* `onChild` lets the host write the parent's identity onto the child in the same commit that creates it.
|
|
120
|
-
* Host policy, all optional (Pi has no extension point for these): `limits` (running children per conversation and
|
|
120
|
+
* Host policy, all optional (Pi has no extension point for these): `limits` (running children per conversation and across
|
|
121
|
+
* the conversations sharing one `limits` object in this process,
|
|
121
122
|
* starts per hour, task size, a child deadline; a refusal is an error result, never a throw, and creates no child), `meter`
|
|
122
123
|
* (the host's flight-log handle, fed each child model response and closed when the call ends) and `childTools` (an allow list
|
|
123
124
|
* applied through Pi's own tool filter). With none of them set the tool behaves as before.
|
package/dist/delegate.js
CHANGED
|
@@ -13,7 +13,8 @@ export const DELEGATE_DETAILS_KIND = 'boring.delegate';
|
|
|
13
13
|
* child's final text. The child inherits the parent's approval and sandbox extensions (appended after its own, in the parent's order, so a
|
|
14
14
|
* sandbox stays last), so a delegate cannot escape the parent's gate; `details` is a `DelegateDetails` (live while the child runs) for a UI that shows it.
|
|
15
15
|
* `onChild` lets the host write the parent's identity onto the child in the same commit that creates it.
|
|
16
|
-
* Host policy, all optional (Pi has no extension point for these): `limits` (running children per conversation and
|
|
16
|
+
* Host policy, all optional (Pi has no extension point for these): `limits` (running children per conversation and across
|
|
17
|
+
* the conversations sharing one `limits` object in this process,
|
|
17
18
|
* starts per hour, task size, a child deadline; a refusal is an error result, never a throw, and creates no child), `meter`
|
|
18
19
|
* (the host's flight-log handle, fed each child model response and closed when the call ends) and `childTools` (an allow list
|
|
19
20
|
* applied through Pi's own tool filter). With none of them set the tool behaves as before.
|
|
@@ -60,7 +61,8 @@ export function createDelegateTool(options) {
|
|
|
60
61
|
if ('refused' in opened)
|
|
61
62
|
return { output: [{ type: 'text', text: opened.refused }], isError: true };
|
|
62
63
|
const childId = opened.id;
|
|
63
|
-
|
|
64
|
+
// `end` is set where the call's outcome is known; a throw ends `cancelled` only when the call itself was aborted.
|
|
65
|
+
let meter, end = 'failed', timer;
|
|
64
66
|
try {
|
|
65
67
|
meter = await options.meter?.(parent, childId);
|
|
66
68
|
const startedAt = await api.memo('delegate.startedAt', Date.now(), context);
|
|
@@ -96,12 +98,16 @@ export function createDelegateTool(options) {
|
|
|
96
98
|
end = reason === 'aborted' ? 'cancelled' : 'failed';
|
|
97
99
|
return { output: [{ type: 'text', text: error }], isError: true, details: reason === 'aborted' ? details('cancelled', counts) : details('failed', counts, error) };
|
|
98
100
|
}
|
|
99
|
-
end = 'done';
|
|
100
101
|
const answer = await api.commit(tx => tx.entry(AssistantEntry, settled.answer), context);
|
|
102
|
+
end = 'done';
|
|
101
103
|
const message = answer?.model?.find(part => part.role === 'assistant');
|
|
102
104
|
const text = message?.role === 'assistant' ? message.content.flatMap(part => part.type === 'text' ? [part.text] : []).join('\n') : '';
|
|
103
105
|
return { output: [{ type: 'text', text }], details: details('done', counts) };
|
|
104
106
|
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
end = context.abortSignal?.aborted ? 'cancelled' : 'failed';
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
105
111
|
finally {
|
|
106
112
|
clearTimeout(timer);
|
|
107
113
|
ledger?.running.delete(api.taskId);
|
|
@@ -216,7 +222,7 @@ function admit(limits, ledger, taskId, parent, task) {
|
|
|
216
222
|
if (limits.maxRunningPerConversation !== undefined && here >= limits.maxRunningPerConversation)
|
|
217
223
|
return `${delegates(here)} already running for this conversation (limit ${limits.maxRunningPerConversation}).`;
|
|
218
224
|
if (limits.maxRunning !== undefined && others.length >= limits.maxRunning)
|
|
219
|
-
return `${delegates(others.length)} already running
|
|
225
|
+
return `${delegates(others.length)} already running across the conversations sharing this limit (limit ${limits.maxRunning}).`;
|
|
220
226
|
const started = [...ledger.starts].filter(([id, start]) => id !== taskId && start.parent === parent).length;
|
|
221
227
|
if (limits.maxStartsPerHour !== undefined && started >= limits.maxStartsPerHour)
|
|
222
228
|
return `${delegates(started)} already started for this conversation in the last hour (limit ${limits.maxStartsPerHour}).`;
|
package/dist/mentions.d.ts
CHANGED
|
@@ -33,8 +33,8 @@ export declare const MENTION_LIMITS: Readonly<{
|
|
|
33
33
|
}>;
|
|
34
34
|
/** Every part this module adds starts with this prefix, so a viewer can tell it from what the person typed. */
|
|
35
35
|
export declare const MENTION_FILE_PREFIX = "<file path=\"";
|
|
36
|
-
/** The `@path` tokens of a message: at the start or after whitespace; a bare path drops trailing punctuation, a quoted `@"a b.md"` (escapes `\"`, `\\`) is taken as written. The syntax
|
|
37
|
-
* parser (`MENTION_TOKEN` and `pieces`
|
|
36
|
+
/** The `@path` tokens of a message: at the start or after whitespace; a bare path drops trailing punctuation, a quoted `@"a b.md"` (escapes `\"`, `\\`) is taken as written. The syntax the chat writes. The browser cannot import this, so the kit's `chat-text` holds the
|
|
37
|
+
* same parser (`mentionedPaths`, `MENTION_TOKEN` and `pieces` of `@hachej/boring-ui-kit/chat-text`); `test/packages/ui-chat-text.test.mjs` keeps the two equal. */
|
|
38
38
|
export declare function mentionedPaths(text: string): string[];
|
|
39
39
|
/** A workspace-relative path with no absolute prefix, no `..`, no backslash and no control characters. */
|
|
40
40
|
export declare function safeMentionPath(path: string): boolean;
|
package/dist/mentions.js
CHANGED
|
@@ -3,8 +3,8 @@ import { SNIFF_BYTES, fileKind, nativeImageType } from './file-types.js';
|
|
|
3
3
|
export const MENTION_LIMITS = Object.freeze({ fileBytes: 100_000, totalBytes: 300_000, files: 20 });
|
|
4
4
|
/** Every part this module adds starts with this prefix, so a viewer can tell it from what the person typed. */
|
|
5
5
|
export const MENTION_FILE_PREFIX = '<file path="';
|
|
6
|
-
/** The `@path` tokens of a message: at the start or after whitespace; a bare path drops trailing punctuation, a quoted `@"a b.md"` (escapes `\"`, `\\`) is taken as written. The syntax
|
|
7
|
-
* parser (`MENTION_TOKEN` and `pieces`
|
|
6
|
+
/** The `@path` tokens of a message: at the start or after whitespace; a bare path drops trailing punctuation, a quoted `@"a b.md"` (escapes `\"`, `\\`) is taken as written. The syntax the chat writes. The browser cannot import this, so the kit's `chat-text` holds the
|
|
7
|
+
* same parser (`mentionedPaths`, `MENTION_TOKEN` and `pieces` of `@hachej/boring-ui-kit/chat-text`); `test/packages/ui-chat-text.test.mjs` keeps the two equal. */
|
|
8
8
|
export function mentionedPaths(text) {
|
|
9
9
|
const found = new Set();
|
|
10
10
|
for (const match of text.matchAll(/(^|\s)@(?:"((?:[^"\\]|\\.)*)"|([^\s]+))/g)) {
|
package/dist/self-evolving.d.ts
CHANGED
|
@@ -36,8 +36,6 @@ export interface SelfEvolutionOptions {
|
|
|
36
36
|
readonly skills: readonly Skill[];
|
|
37
37
|
/** Tool names the agent already has from the host; an agent-written tool may not take one. */
|
|
38
38
|
readonly reserved: readonly string[];
|
|
39
|
-
readonly parseSkill: (markdown: string) => Skill;
|
|
40
|
-
readonly skillsExtension: (name: string, skills: readonly Skill[]) => Extension;
|
|
41
39
|
}
|
|
42
40
|
export interface SelfEvolution {
|
|
43
41
|
/** The extension as it is now (the host's skills, `reload` and the agent-written section before the first scan). */
|
package/dist/self-evolving.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineExtension, defineTool } from '@earendil-works/pi-durable';
|
|
2
2
|
import { Type } from '@earendil-works/pi-ai';
|
|
3
|
-
import {
|
|
3
|
+
import { directorySkillSource, skillFile, staticSkills } from './skills.js';
|
|
4
4
|
import { jsonSchemaTool } from './json-schema-tool.js';
|
|
5
5
|
/*
|
|
6
6
|
* Self-evolution (docs/architecture/SELF-EVOLUTION.md, laws SELF-1..4 in this package's README): an agent keeps its own standing
|
|
@@ -103,29 +103,8 @@ async function filesIn(env, directory, suffix, context) {
|
|
|
103
103
|
return listed.error.code === 'not_found' ? 'missing' : { error: listed.error.message };
|
|
104
104
|
return listed.value.filter(entry => entry.kind === 'file' && entry.name.endsWith(suffix)).map(entry => `${directory}/${entry.name}`).sort();
|
|
105
105
|
}
|
|
106
|
-
/** Flat Markdown skills and `SKILL.md` in immediate child folders; companion files are not definitions. */
|
|
107
|
-
async function skillFilesIn(env, context, errors) {
|
|
108
|
-
const listed = await env.listDir(SKILLS, context);
|
|
109
|
-
if (!listed.ok) {
|
|
110
|
-
if (listed.error.code !== 'not_found')
|
|
111
|
-
errors.push({ path: SKILLS, message: listed.error.message });
|
|
112
|
-
return [];
|
|
113
|
-
}
|
|
114
|
-
const paths = listed.value.filter(entry => entry.kind === 'file' && entry.name.endsWith('.md')).map(entry => `${SKILLS}/${entry.name}`);
|
|
115
|
-
for (const entry of listed.value.filter(entry => entry.kind === 'directory')) {
|
|
116
|
-
const folder = `${SKILLS}/${entry.name}`;
|
|
117
|
-
const contents = await env.listDir(folder, context);
|
|
118
|
-
if (!contents.ok) {
|
|
119
|
-
errors.push({ path: folder, message: contents.error.message });
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
if (contents.value.some(file => file.kind === 'file' && file.name === 'SKILL.md'))
|
|
123
|
-
paths.push(`${folder}/SKILL.md`);
|
|
124
|
-
}
|
|
125
|
-
return paths.sort();
|
|
126
|
-
}
|
|
127
106
|
export function createSelfEvolution(options) {
|
|
128
|
-
const { name
|
|
107
|
+
const { name } = options;
|
|
129
108
|
const hostSkills = [...options.skills];
|
|
130
109
|
const registries = new Set();
|
|
131
110
|
let agentSkills = [];
|
|
@@ -149,7 +128,7 @@ export function createSelfEvolution(options) {
|
|
|
149
128
|
const section = agentInstructionsSection();
|
|
150
129
|
function build(tools) {
|
|
151
130
|
const skills = [...hostSkills, ...agentSkills];
|
|
152
|
-
const offered = skills.length ?
|
|
131
|
+
const offered = skills.length ? staticSkills(skills, { name: `${name}.skills` }).extension : undefined;
|
|
153
132
|
return defineExtension({
|
|
154
133
|
name,
|
|
155
134
|
tools: [reloadTool, ...(offered?.tools ?? []), ...tools.map(toolOf)],
|
|
@@ -169,26 +148,14 @@ export function createSelfEvolution(options) {
|
|
|
169
148
|
else if (read.error.code !== 'not_found')
|
|
170
149
|
errors.push({ path: INSTRUCTIONS, message: read.error.message });
|
|
171
150
|
const skills = [];
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (!text.ok) {
|
|
176
|
-
errors.push({ path, message: text.error.message });
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
|
-
let skill;
|
|
180
|
-
try {
|
|
181
|
-
skill = parseSkill(text.value);
|
|
182
|
-
}
|
|
183
|
-
catch (error) {
|
|
184
|
-
errors.push({ path, message: reason(error) });
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
151
|
+
const listing = await directorySkillSource(env, SKILLS, { flat: true }).list(context);
|
|
152
|
+
errors.push(...listing.errors.map(({ path, message }) => ({ path, message })));
|
|
153
|
+
for (const skill of listing.skills) {
|
|
187
154
|
if ([...hostSkills, ...skills].some(other => other.name === skill.name)) {
|
|
188
|
-
errors.push({ path, message: `a skill named ${skill.name} already exists` });
|
|
155
|
+
errors.push({ path: skillFile(skill) ?? SKILLS, message: `a skill named ${skill.name} already exists` });
|
|
189
156
|
continue;
|
|
190
157
|
}
|
|
191
|
-
skills.push(
|
|
158
|
+
skills.push(skill);
|
|
192
159
|
}
|
|
193
160
|
const available = typeof env.exec === 'function';
|
|
194
161
|
const tools = [];
|
package/dist/skills.d.ts
CHANGED
|
@@ -33,18 +33,45 @@ export interface SkillSource {
|
|
|
33
33
|
readonly version: (context: Context) => Promise<string>;
|
|
34
34
|
/** The FileSystem the skills' folders live in; the built-in sources set it. `companionFiles` reads through it. */
|
|
35
35
|
readonly fs?: SkillFileSystem;
|
|
36
|
+
/**
|
|
37
|
+
* A versioned source only (`directorySkillSource(fs, root, { versioned: true })`): `liveSkills` then keeps each conversation on
|
|
38
|
+
* the version it started with until it is reloaded, instead of following `version()`.
|
|
39
|
+
*/
|
|
40
|
+
readonly versions?: SkillVersions;
|
|
41
|
+
}
|
|
42
|
+
/** The versions of a versioned source. A version's folder never changes once written, so it is read once. */
|
|
43
|
+
export interface SkillVersions {
|
|
44
|
+
/**
|
|
45
|
+
* Read the pointer now: the version new conversations start on. When it cannot be used, `error` says why and `version` is the
|
|
46
|
+
* last good one (absent when there was none).
|
|
47
|
+
*/
|
|
48
|
+
readonly current: (context: Context) => Promise<{
|
|
49
|
+
readonly version?: string;
|
|
50
|
+
readonly error?: SkillError;
|
|
51
|
+
}>;
|
|
52
|
+
/** The skills of one version. */
|
|
53
|
+
readonly list: (version: string, context: Context) => Promise<SkillListing>;
|
|
36
54
|
}
|
|
37
55
|
/** The part of Pi's `FileSystem` the built-in sources read through (an `ExecutionEnv` is one). */
|
|
38
56
|
export type SkillFileSystem = Pick<FileSystem, 'readTextFile' | 'listDir' | 'fileInfo'>;
|
|
39
|
-
/**
|
|
40
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Every `<directory>/<name>/SKILL.md`; with `flat`, also each `<directory>/<name>.md`, whose folder is the directory. A missing
|
|
59
|
+
* directory holds no skills. Other files are read only with `companionFiles`.
|
|
60
|
+
*
|
|
61
|
+
* With `versioned`, the skills are in `<directory>/<version>/` and the file `<directory>/current` names the version (one line,
|
|
62
|
+
* for example `v13`). A pointer that is missing, unreadable or names no folder is reported as an error at `<directory>/current`,
|
|
63
|
+
* and the source keeps the last good version.
|
|
64
|
+
*/
|
|
65
|
+
export declare function directorySkillSource(fs: SkillFileSystem, directory: string, options?: {
|
|
66
|
+
readonly flat?: boolean;
|
|
67
|
+
readonly versioned?: boolean;
|
|
68
|
+
}): SkillSource;
|
|
41
69
|
/** One skill file (`SKILL.md` front matter); a missing file holds no skill. Its folder is the file's directory. */
|
|
42
70
|
export declare function fileSkillSource(fs: SkillFileSystem, path: string): SkillSource;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
readonly onDiagnostics?: (errors: readonly SkillError[]) => void;
|
|
71
|
+
/** What both kinds of skills take: the native extension name, the listing text and which skills a conversation sees. */
|
|
72
|
+
export interface SkillsOptions {
|
|
73
|
+
/** The native extension name, one per registry, for example `agent.<id>.skills`. */
|
|
74
|
+
readonly name: string;
|
|
48
75
|
/**
|
|
49
76
|
* Which skills a conversation sees: called once per listing render and per `load_skill` call with Pi's conversation id and
|
|
50
77
|
* every valid skill, it returns the subset that conversation sees (matched by name; anything else returned is ignored).
|
|
@@ -52,39 +79,62 @@ export interface SkillsExtensionOptions {
|
|
|
52
79
|
* a skill that could not be read also answers as unknown (its reason still goes to `onDiagnostics`).
|
|
53
80
|
*/
|
|
54
81
|
readonly visible?: (conversationId: ConversationId, skills: readonly Skill[]) => readonly Skill[] | Promise<readonly Skill[]>;
|
|
82
|
+
/** The listing section's text before the `- name: description` lines. Default: call load_skill first, `/<skill-name>` is explicit. */
|
|
83
|
+
readonly intro?: string;
|
|
84
|
+
}
|
|
85
|
+
/** The options of `liveSkills`: a source is read on use, so it also has a cache age, diagnostics, companion files and a veto. */
|
|
86
|
+
export interface LiveSkillsOptions extends SkillsOptions {
|
|
87
|
+
/** How long a source's copy is used before `version()` is asked again, in milliseconds. Default 5000. */
|
|
88
|
+
readonly maxAgeMs?: number;
|
|
89
|
+
/**
|
|
90
|
+
* Called after each read of a source with every skill it left out (an empty array when none). When `onPublish` refuses the
|
|
91
|
+
* read, it gets the errors of the listing still in effect followed by `{ path: '', message: 'Listing refused by onPublish: <reason>' }`.
|
|
92
|
+
*/
|
|
93
|
+
readonly onDiagnostics?: (errors: readonly SkillError[]) => void;
|
|
55
94
|
/**
|
|
56
95
|
* List the files beside each `SKILL.md` (at any depth, at most 200) in `load_skill`'s answer, and let
|
|
57
96
|
* `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`.
|
|
59
|
-
*
|
|
97
|
+
* a file that cannot be read or one that is not UTF-8 text is left out and reported to `onDiagnostics`. `load_skill` checks a
|
|
98
|
+
* listed file again before returning it (the skill's folder, every folder on the way and the file itself, with `fileInfo`,
|
|
99
|
+
* which does not follow links), so a file or folder swapped for a link since the listing is refused. Read through the
|
|
100
|
+
* source's `fs`; a source without `fs` throws.
|
|
60
101
|
*/
|
|
61
102
|
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
103
|
/**
|
|
65
104
|
* 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>`.
|
|
105
|
+
* (or throw) to refuse it: the current listing stays, and `onDiagnostics` gets its errors plus `Listing refused by onPublish: <reason>`.
|
|
67
106
|
* A refused listing is offered again only once the source's version changes or `refresh()` is called.
|
|
68
107
|
*/
|
|
69
108
|
readonly onPublish?: (next: readonly Skill[], previous: readonly Skill[]) => string | void | Promise<string | void>;
|
|
70
109
|
}
|
|
71
|
-
/** A
|
|
72
|
-
export interface
|
|
110
|
+
/** A live source's extension, with a handle to re-read the source now. */
|
|
111
|
+
export interface LiveSkills {
|
|
73
112
|
readonly extension: Extension;
|
|
74
113
|
/**
|
|
75
114
|
* 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
|
|
77
|
-
* `
|
|
115
|
+
* is taken from the new read (added, replaced or removed); the others stay as they were until the source's `version()`
|
|
116
|
+
* changes again, when the next read offers the whole source as usual. Resolves `true` when published, `false` when
|
|
117
|
+
* `onPublish` refused it.
|
|
118
|
+
*
|
|
119
|
+
* A versioned source: read its pointer now and move conversation `conversationId` (every conversation when absent) to that
|
|
120
|
+
* version; `name` is refused. Resolves `false` when the pointer could not be used (the conversations keep their version).
|
|
78
121
|
*/
|
|
79
|
-
readonly refresh: (context: Context, name?: string) => Promise<boolean>;
|
|
122
|
+
readonly refresh: (context: Context, name?: string, conversationId?: ConversationId) => Promise<boolean>;
|
|
80
123
|
}
|
|
81
124
|
/**
|
|
82
|
-
* A prompt section listing the skills and a `load_skill` tool returning one body. Usable without `defineAgent`.
|
|
83
|
-
*
|
|
84
|
-
|
|
85
|
-
|
|
125
|
+
* A prompt section listing the skills and a `load_skill` tool returning one body. Usable without `defineAgent`. Each skill is
|
|
126
|
+
* validated now: an invalid or duplicate skill throws.
|
|
127
|
+
*/
|
|
128
|
+
export declare function staticSkills(skills: readonly Skill[], options: SkillsOptions): {
|
|
129
|
+
readonly extension: Extension;
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* `staticSkills` over a source, plus `refresh(context, name?)` to re-read it now instead of after `maxAgeMs`. The source is read on
|
|
133
|
+
* first use and again, once the copy is older than `maxAgeMs`, when its version changed. What it cannot use is left out and
|
|
134
|
+
* reported, never thrown.
|
|
86
135
|
*/
|
|
87
|
-
export declare function
|
|
88
|
-
|
|
89
|
-
|
|
136
|
+
export declare function liveSkills(source: SkillSource, options: LiveSkillsOptions): LiveSkills;
|
|
137
|
+
export declare const isSkillArray: (skills: readonly Skill[] | SkillSource) => skills is readonly Skill[];
|
|
138
|
+
/** The file a built-in source read this skill from, if any: self-evolution reports a skill it refuses at that path. */
|
|
139
|
+
export declare const skillFile: (skill: Skill) => string | undefined;
|
|
90
140
|
//# sourceMappingURL=skills.d.ts.map
|
package/dist/skills.js
CHANGED
|
@@ -50,36 +50,48 @@ async function readSkillFile(fs, path, name, context) {
|
|
|
50
50
|
return { path, name, message: reason(error) };
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
-
/**
|
|
54
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Every `<directory>/<name>/SKILL.md`; with `flat`, also each `<directory>/<name>.md`, whose folder is the directory. A missing
|
|
55
|
+
* directory holds no skills. Other files are read only with `companionFiles`.
|
|
56
|
+
*
|
|
57
|
+
* With `versioned`, the skills are in `<directory>/<version>/` and the file `<directory>/current` names the version (one line,
|
|
58
|
+
* for example `v13`). A pointer that is missing, unreadable or names no folder is reported as an error at `<directory>/current`,
|
|
59
|
+
* and the source keeps the last good version.
|
|
60
|
+
*/
|
|
61
|
+
export function directorySkillSource(fs, directory, options = {}) {
|
|
55
62
|
const root = directory.replace(/\/+$/, '');
|
|
56
|
-
|
|
63
|
+
if (options.versioned)
|
|
64
|
+
return versionedSource(fs, root, options.flat === true);
|
|
65
|
+
/** The skill files the directory holds, each with the name it is reported under. */
|
|
66
|
+
async function candidates(context) {
|
|
57
67
|
const listed = await fs.listDir(root, context);
|
|
58
68
|
if (!listed.ok)
|
|
59
|
-
return listed.error.code === 'not_found' ? {
|
|
60
|
-
|
|
69
|
+
return listed.error.code === 'not_found' ? { files: [] } : { files: [], error: { path: root, message: listed.error.message } };
|
|
70
|
+
const folders = listed.value.filter(entry => entry.kind === 'directory').map(entry => ({ path: `${root}/${entry.name}/SKILL.md`, name: entry.name }));
|
|
71
|
+
const flat = options.flat ? listed.value.filter(entry => entry.kind === 'file' && entry.name.endsWith('.md'))
|
|
72
|
+
.map(entry => ({ path: `${root}/${entry.name}`, name: entry.name.slice(0, -'.md'.length) })) : [];
|
|
73
|
+
return { files: [...folders, ...flat].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) };
|
|
61
74
|
}
|
|
62
|
-
const info = (name, context) => fs.fileInfo(`${root}/${name}/SKILL.md`, context);
|
|
63
75
|
return {
|
|
64
76
|
fs,
|
|
65
77
|
version: async (context) => {
|
|
66
|
-
const {
|
|
78
|
+
const { files, error } = await candidates(context);
|
|
67
79
|
if (error)
|
|
68
80
|
return `error:${error.message}`;
|
|
69
|
-
const stamps = await Promise.all(
|
|
70
|
-
const found = await
|
|
71
|
-
return `${
|
|
81
|
+
const stamps = await Promise.all(files.map(async (file) => {
|
|
82
|
+
const found = await fs.fileInfo(file.path, context);
|
|
83
|
+
return `${file.path}=${found.ok ? stamp(found.value) : found.error.code}`;
|
|
72
84
|
}));
|
|
73
85
|
return stamps.join('|');
|
|
74
86
|
},
|
|
75
87
|
list: async (context) => {
|
|
76
|
-
const {
|
|
88
|
+
const { files, error } = await candidates(context);
|
|
77
89
|
const skills = [], errors = error ? [error] : [];
|
|
78
|
-
for (const
|
|
79
|
-
const found = await
|
|
90
|
+
for (const file of files) {
|
|
91
|
+
const found = await fs.fileInfo(file.path, context);
|
|
80
92
|
if (!found.ok && found.error.code === 'not_found')
|
|
81
93
|
continue;
|
|
82
|
-
const read = await readSkillFile(fs,
|
|
94
|
+
const read = await readSkillFile(fs, file.path, file.name, context);
|
|
83
95
|
if ('body' in read)
|
|
84
96
|
skills.push(read);
|
|
85
97
|
else
|
|
@@ -89,6 +101,39 @@ export function directorySkillSource(fs, directory) {
|
|
|
89
101
|
},
|
|
90
102
|
};
|
|
91
103
|
}
|
|
104
|
+
/** A version is one folder name: no separator, no control character, not `.` or `..`. */
|
|
105
|
+
const VERSION = /^(?!\.\.?$)[^/\\\u0000-\u001f]{1,128}$/;
|
|
106
|
+
function versionedSource(fs, root, flat) {
|
|
107
|
+
const pointer = `${root}/current`;
|
|
108
|
+
let good;
|
|
109
|
+
const folder = (version) => directorySkillSource(fs, `${root}/${version}`, { flat });
|
|
110
|
+
const versions = {
|
|
111
|
+
current: async (context) => {
|
|
112
|
+
const kept = (message) => ({ ...(good === undefined ? {} : { version: good }), error: { path: pointer, message } });
|
|
113
|
+
const text = await fs.readTextFile(pointer, context);
|
|
114
|
+
if (!text.ok)
|
|
115
|
+
return kept(`Skill version pointer could not be read: ${text.error.message}`);
|
|
116
|
+
const version = text.value.trim();
|
|
117
|
+
if (!VERSION.test(version))
|
|
118
|
+
return kept(`Skill version pointer names no version folder: ${JSON.stringify(version.slice(0, 80))}`);
|
|
119
|
+
const info = await fs.fileInfo(`${root}/${version}`, context);
|
|
120
|
+
if (!info.ok || info.value.kind !== 'directory')
|
|
121
|
+
return kept(`Skill version ${version} has no folder ${root}/${version}${info.ok ? '' : `: ${info.error.message}`}`);
|
|
122
|
+
good = version;
|
|
123
|
+
return { version };
|
|
124
|
+
},
|
|
125
|
+
list: (version, context) => folder(version).list(context),
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
fs, versions,
|
|
129
|
+
version: async (context) => { const read = await versions.current(context); return `${read.version ?? ''}|${read.error?.message ?? ''}`; },
|
|
130
|
+
list: async (context) => {
|
|
131
|
+
const read = await versions.current(context);
|
|
132
|
+
const listing = read.version === undefined ? { skills: [], errors: [] } : await versions.list(read.version, context);
|
|
133
|
+
return read.error ? { skills: listing.skills, errors: [...listing.errors, read.error] } : listing;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
92
137
|
/** One skill file (`SKILL.md` front matter); a missing file holds no skill. Its folder is the file's directory. */
|
|
93
138
|
export function fileSkillSource(fs, path) {
|
|
94
139
|
const name = path.slice(path.lastIndexOf('/') + 1).replace(/\.md$/i, '');
|
|
@@ -144,7 +189,7 @@ function only(name, next, current) {
|
|
|
144
189
|
* The current copy of a source: read again once it is older than `maxAgeMs` and `version()` changed, or when `refresh` asks.
|
|
145
190
|
* Reads run one at a time; concurrent callers of `current` share one.
|
|
146
191
|
*/
|
|
147
|
-
function
|
|
192
|
+
function copyOf(source, options) {
|
|
148
193
|
const maxAgeMs = options.maxAgeMs ?? 5000, fs = options.companionFiles ? source.fs : undefined;
|
|
149
194
|
let state = { known: new Map(), errors: [] };
|
|
150
195
|
let version, checkedAt = -Infinity, pending, missing;
|
|
@@ -163,8 +208,9 @@ function liveSkills(source, options) {
|
|
|
163
208
|
}
|
|
164
209
|
async function publish(next) {
|
|
165
210
|
const refused = await refusal(next);
|
|
211
|
+
// The listing in effect stays, so its errors stay too: the refusal is added to them, never replaces them.
|
|
166
212
|
if (refused !== undefined) {
|
|
167
|
-
options.onDiagnostics?.([{ path: '', message: `Listing refused by onPublish: ${refused}` }]);
|
|
213
|
+
options.onDiagnostics?.([...state.errors, { path: '', message: `Listing refused by onPublish: ${refused}` }]);
|
|
168
214
|
return false;
|
|
169
215
|
}
|
|
170
216
|
state = next;
|
|
@@ -192,17 +238,117 @@ function liveSkills(source, options) {
|
|
|
192
238
|
miss: (context) => missing ??= serial(() => check(context)).finally(() => { missing = undefined; }),
|
|
193
239
|
refresh: (context, name) => serial(async () => {
|
|
194
240
|
const next = await source.version(context), listing = await read(context);
|
|
195
|
-
//
|
|
196
|
-
|
|
241
|
+
// The read is accounted for at this version, whether all of it or one skill was taken (or onPublish refused it): the
|
|
242
|
+
// others kept by a one-skill refresh are offered again only once the source changes, not at the next maxAgeMs check.
|
|
243
|
+
version = next;
|
|
197
244
|
checkedAt = Date.now();
|
|
198
245
|
return publish(name === undefined ? listing : only(name, listing, state));
|
|
199
246
|
}),
|
|
200
247
|
};
|
|
201
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* A versioned source: each conversation is pinned to the version it first read and keeps it until `reload` or `refresh` moves
|
|
251
|
+
* it. The pointer is read again once older than `maxAgeMs`; each version is read once (a reload reads its version again).
|
|
252
|
+
* Pins live in this process: after a restart a conversation starts on the version current then.
|
|
253
|
+
*/
|
|
254
|
+
function versionsOf(versions, sourceFs, options) {
|
|
255
|
+
const maxAgeMs = options.maxAgeMs ?? 5000, fs = options.companionFiles ? sourceFs : undefined;
|
|
256
|
+
const empty = { known: new Map(), errors: [] };
|
|
257
|
+
const read = new Map(), pins = new Map();
|
|
258
|
+
let latest, checkedAt = -Infinity, reported, pending;
|
|
259
|
+
function load(version, context) {
|
|
260
|
+
let found = read.get(version);
|
|
261
|
+
if (!found) {
|
|
262
|
+
const next = (async () => { const listing = loaded(await versions.list(version, context)); return fs ? withCompanions(fs, listing, context) : listing; })();
|
|
263
|
+
read.set(version, next);
|
|
264
|
+
next.catch(() => { if (read.get(version) === next)
|
|
265
|
+
read.delete(version); });
|
|
266
|
+
found = next;
|
|
267
|
+
}
|
|
268
|
+
return found;
|
|
269
|
+
}
|
|
270
|
+
/** Read the pointer now; `again` reads its version's folder again too. Reports diagnostics when what was read changed. */
|
|
271
|
+
async function check(context, again = false) {
|
|
272
|
+
const pointed = await versions.current(context);
|
|
273
|
+
if (again && pointed.version !== undefined)
|
|
274
|
+
read.delete(pointed.version);
|
|
275
|
+
latest = pointed.version;
|
|
276
|
+
checkedAt = Date.now();
|
|
277
|
+
const state = latest === undefined ? empty : await load(latest, context);
|
|
278
|
+
const errors = pointed.error ? [...state.errors, pointed.error] : state.errors;
|
|
279
|
+
const token = JSON.stringify([latest, errors]);
|
|
280
|
+
if (token !== reported) {
|
|
281
|
+
reported = token;
|
|
282
|
+
options.onDiagnostics?.(errors);
|
|
283
|
+
}
|
|
284
|
+
return { ...pointed, state };
|
|
285
|
+
}
|
|
286
|
+
async function newest(context) {
|
|
287
|
+
if (Date.now() - checkedAt >= maxAgeMs)
|
|
288
|
+
await (pending ??= check(context).finally(() => { pending = undefined; }));
|
|
289
|
+
return latest;
|
|
290
|
+
}
|
|
291
|
+
async function move(context, conversationId) {
|
|
292
|
+
const pointed = await check(context, true);
|
|
293
|
+
if (pointed.version !== undefined && pointed.error === undefined) {
|
|
294
|
+
if (conversationId !== undefined)
|
|
295
|
+
pins.set(conversationId, pointed.version);
|
|
296
|
+
else
|
|
297
|
+
for (const id of pins.keys())
|
|
298
|
+
pins.set(id, pointed.version);
|
|
299
|
+
}
|
|
300
|
+
return pointed;
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
current: async (context, conversationId) => {
|
|
304
|
+
let version = conversationId === undefined ? undefined : pins.get(conversationId);
|
|
305
|
+
if (version === undefined) {
|
|
306
|
+
version = await newest(context);
|
|
307
|
+
if (version !== undefined && conversationId !== undefined)
|
|
308
|
+
pins.set(conversationId, version);
|
|
309
|
+
}
|
|
310
|
+
return version === undefined ? empty : load(version, context);
|
|
311
|
+
},
|
|
312
|
+
reload: async (context, conversationId) => {
|
|
313
|
+
const pointed = await move(context, conversationId);
|
|
314
|
+
if (pointed.error) {
|
|
315
|
+
const kept = conversationId === undefined ? pointed.version : pins.get(conversationId) ?? pointed.version;
|
|
316
|
+
return `${kept === undefined ? 'No skill version is in use' : `Skills stay on version ${kept}`}: ${pointed.error.message}`;
|
|
317
|
+
}
|
|
318
|
+
const names = [...pointed.state.known.keys()];
|
|
319
|
+
return `Skills reloaded: version ${pointed.version} (${names.length ? names.join(', ') : 'no skills'}).`;
|
|
320
|
+
},
|
|
321
|
+
refresh: async (context, name, conversationId) => {
|
|
322
|
+
if (name !== undefined)
|
|
323
|
+
throw new TypeError('A versioned skill source moves whole versions: refresh takes no skill name');
|
|
324
|
+
return (await move(context, conversationId)).error === undefined;
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
202
328
|
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
329
|
const COMPANION_LIMIT = 200;
|
|
204
330
|
/** Each skill's companion files, checked when its listing was read. */
|
|
205
331
|
const companionLists = new WeakMap();
|
|
332
|
+
const notText = (text) => /[\u0000\uFFFD]/.test(text);
|
|
333
|
+
/**
|
|
334
|
+
* Why a listed companion may not be read now, or `undefined`: the skill's folder and every folder on the way must still be
|
|
335
|
+
* directories and the file a regular file (`fileInfo` does not follow links), so a link swapped in since the listing is refused.
|
|
336
|
+
*/
|
|
337
|
+
async function companionChanged(fs, folder, relative, context) {
|
|
338
|
+
const parts = relative.split('/'), base = folder === '.' ? '' : `${folder}/`;
|
|
339
|
+
const steps = [...(folder === '.' ? [] : [{ path: folder, kind: 'directory' }]),
|
|
340
|
+
...parts.map((_, index) => ({ path: base + parts.slice(0, index + 1).join('/'), kind: index === parts.length - 1 ? 'file' : 'directory' }))];
|
|
341
|
+
for (const step of steps) {
|
|
342
|
+
const info = await fs.fileInfo(step.path, context);
|
|
343
|
+
if (!info.ok)
|
|
344
|
+
return info.error.message;
|
|
345
|
+
if (info.value.kind === 'symlink')
|
|
346
|
+
return `${step.path} is a symbolic link`;
|
|
347
|
+
if (info.value.kind !== step.kind)
|
|
348
|
+
return `${step.path} is not a ${step.kind === 'file' ? 'regular file' : 'directory'}`;
|
|
349
|
+
}
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
206
352
|
/**
|
|
207
353
|
* Walk the files beside each skill's `SKILL.md` (relative to its folder, depth first in name order, at most
|
|
208
354
|
* `COMPANION_LIMIT`) and keep those that read as UTF-8 text. A symbolic link, an unreadable or non-text file, and files past
|
|
@@ -246,7 +392,7 @@ async function withCompanions(fs, listing, context) {
|
|
|
246
392
|
const text = await fs.readTextFile(at(path), context);
|
|
247
393
|
if (!text.ok)
|
|
248
394
|
leftOut(path, `Companion file left out: ${text.error.message}`);
|
|
249
|
-
else if (
|
|
395
|
+
else if (notText(text.value))
|
|
250
396
|
leftOut(path, 'Companion file left out: not UTF-8 text');
|
|
251
397
|
else
|
|
252
398
|
files.push(path);
|
|
@@ -257,7 +403,7 @@ async function withCompanions(fs, listing, context) {
|
|
|
257
403
|
return { known: listing.known, errors };
|
|
258
404
|
}
|
|
259
405
|
/** The listing section and `load_skill` over `live`, filtered per conversation by `visible`. */
|
|
260
|
-
function skillsExtension(
|
|
406
|
+
function skillsExtension(live, options, fs) {
|
|
261
407
|
const { visible } = options, intro = options.intro ?? INTRO_TEXT;
|
|
262
408
|
// `at` is read only with a filter, so direct callers of render/execute without Pi's input or api keep working.
|
|
263
409
|
async function seen(known, at) {
|
|
@@ -271,10 +417,11 @@ function skillsExtension(name, live, options, fs) {
|
|
|
271
417
|
? Type.Object({ name: Type.String(), path: Type.Optional(Type.String({ description: 'A companion file of the skill, relative to its folder.' })) }, { additionalProperties: false })
|
|
272
418
|
: Type.Object({ name: Type.String() }, { additionalProperties: false });
|
|
273
419
|
return defineExtension({
|
|
274
|
-
name,
|
|
420
|
+
name: options.name,
|
|
275
421
|
sections: [{ key: 'skills', render: async (input, context) => {
|
|
276
|
-
const skills = await seen((await live.current(context)).known, () => input.conversationId);
|
|
277
|
-
|
|
422
|
+
const skills = await seen((await live.current(context, input?.conversationId)).known, () => input.conversationId);
|
|
423
|
+
const lines = [...skills.length ? [intro] : [], ...live.reload ? [RELOAD_TEXT] : [], ...skills.map(skill => `- ${skill.name}: ${skill.description}`)];
|
|
424
|
+
return lines.length ? lines.join('\n') : undefined;
|
|
278
425
|
} }],
|
|
279
426
|
tools: [defineTool({
|
|
280
427
|
name: 'load_skill',
|
|
@@ -283,7 +430,7 @@ function skillsExtension(name, live, options, fs) {
|
|
|
283
430
|
replay: 'safe',
|
|
284
431
|
execute: async (args, api, context) => {
|
|
285
432
|
const text = (value) => ({ output: [{ type: 'text', text: value }] });
|
|
286
|
-
let { known, errors } = await live.current(context);
|
|
433
|
+
let { known, errors } = await live.current(context, api?.conversationId);
|
|
287
434
|
let shown = await seen(known, () => api.conversationId);
|
|
288
435
|
if (!shown.some(candidate => candidate.name === args.name) && live.miss) {
|
|
289
436
|
// Not in this conversation's listing: the skill may have been added since, so check the source once now.
|
|
@@ -298,31 +445,43 @@ function skillsExtension(name, live, options, fs) {
|
|
|
298
445
|
const folder = skillFolders.get(skill);
|
|
299
446
|
const companions = fs ? companionLists.get(skill) ?? [] : [];
|
|
300
447
|
if (fs && args.path !== undefined) {
|
|
301
|
-
// Only a listed file is read, so the path cannot leave the folder
|
|
448
|
+
// Only a listed file is read, so the path cannot leave the folder; it is checked again now, so a link swapped in
|
|
449
|
+
// since the listing (the agent may write its own skill folder) is refused rather than followed.
|
|
302
450
|
const path = args.path.replace(/^(\.\/)+/, '');
|
|
303
|
-
if (!companions.includes(path))
|
|
451
|
+
if (!companions.includes(path) || folder === undefined)
|
|
304
452
|
return text(`Skill "${skill.name}" has no companion file "${args.path}". Companion files: ${companions.join(', ') || 'none'}`);
|
|
453
|
+
const failed = (why) => text(`Companion file "${path}" of skill "${skill.name}" could not be read: ${why}`);
|
|
454
|
+
const changed = await companionChanged(fs, folder, path, context);
|
|
455
|
+
if (changed !== undefined)
|
|
456
|
+
return failed(changed);
|
|
305
457
|
const read = await fs.readTextFile(folder === '.' ? path : `${folder}/${path}`, context);
|
|
306
|
-
|
|
458
|
+
if (!read.ok)
|
|
459
|
+
return failed(read.error.message);
|
|
460
|
+
return notText(read.value) ? failed('not UTF-8 text') : text(read.value);
|
|
307
461
|
}
|
|
308
462
|
const location = folder === undefined ? '' : `\n\nThis skill's folder is \`${folder}\`; paths in it are relative to that folder.`;
|
|
309
463
|
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
464
|
return text(skill.body + location + listed);
|
|
311
465
|
},
|
|
312
|
-
})],
|
|
466
|
+
}), ...live.reload ? [reloadTool(live.reload)] : []],
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
const RELOAD_TEXT = 'A user message that is /reload-skill asks to move this conversation to the latest published skills: call reload_skill and tell the person its answer.';
|
|
470
|
+
/** The `reload_skill` tool of a versioned source: the person's `/reload-skill`, for the calling conversation only. */
|
|
471
|
+
function reloadTool(reload) {
|
|
472
|
+
return defineTool({
|
|
473
|
+
name: 'reload_skill',
|
|
474
|
+
description: 'Move this conversation to the latest published version of the skills. Call it only when the person sends /reload-skill.',
|
|
475
|
+
parameters: Type.Object({}, { additionalProperties: false }),
|
|
476
|
+
replay: 'safe',
|
|
477
|
+
execute: async (_args, api, context) => ({ output: [{ type: 'text', text: await reload(context, api?.conversationId) }] }),
|
|
313
478
|
});
|
|
314
479
|
}
|
|
315
480
|
/**
|
|
316
|
-
* A prompt section listing the skills and a `load_skill` tool returning one body. Usable without `defineAgent`.
|
|
317
|
-
*
|
|
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.
|
|
481
|
+
* A prompt section listing the skills and a `load_skill` tool returning one body. Usable without `defineAgent`. Each skill is
|
|
482
|
+
* validated now: an invalid or duplicate skill throws.
|
|
320
483
|
*/
|
|
321
|
-
export function
|
|
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');
|
|
484
|
+
export function staticSkills(skills, options) {
|
|
326
485
|
const known = new Map();
|
|
327
486
|
for (const skill of skills.map(validSkill)) {
|
|
328
487
|
if (known.has(skill.name))
|
|
@@ -330,13 +489,21 @@ export function createSkillsExtension(name, skills, options = {}) {
|
|
|
330
489
|
known.set(skill.name, skill);
|
|
331
490
|
}
|
|
332
491
|
const fixed = { known, errors: [] };
|
|
333
|
-
return skillsExtension(
|
|
492
|
+
return { extension: skillsExtension({ current: async () => fixed }, options, undefined) };
|
|
334
493
|
}
|
|
335
|
-
/**
|
|
336
|
-
|
|
494
|
+
/**
|
|
495
|
+
* `staticSkills` over a source, plus `refresh(context, name?)` to re-read it now instead of after `maxAgeMs`. The source is read on
|
|
496
|
+
* first use and again, once the copy is older than `maxAgeMs`, when its version changed. What it cannot use is left out and
|
|
497
|
+
* reported, never thrown.
|
|
498
|
+
*/
|
|
499
|
+
export function liveSkills(source, options) {
|
|
337
500
|
if (options.companionFiles && !source.fs)
|
|
338
501
|
throw new TypeError('Skills extension: companionFiles need a SkillSource with fs');
|
|
339
|
-
|
|
340
|
-
|
|
502
|
+
if (source.versions && options.onPublish)
|
|
503
|
+
throw new TypeError('Skills extension: onPublish does not apply to a versioned source');
|
|
504
|
+
const live = source.versions ? versionsOf(source.versions, source.fs, options) : copyOf(source, options);
|
|
505
|
+
return { extension: skillsExtension(live, options, options.companionFiles ? source.fs : undefined), refresh: live.refresh };
|
|
341
506
|
}
|
|
342
|
-
const isSkillArray = (skills) => Array.isArray(skills);
|
|
507
|
+
export const isSkillArray = (skills) => Array.isArray(skills);
|
|
508
|
+
/** The file a built-in source read this skill from, if any: self-evolution reports a skill it refuses at that path. */
|
|
509
|
+
export const skillFile = (skill) => skillFiles.get(skill);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hachej/boring-agent",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.26",
|
|
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.
|
|
164
|
-
"@hachej/boring-ui-kit": "^0.4.
|
|
163
|
+
"@hachej/boring-files": "^0.4.26",
|
|
164
|
+
"@hachej/boring-ui-kit": "^0.4.26",
|
|
165
165
|
"xlsx": "^0.20.3"
|
|
166
166
|
},
|
|
167
167
|
"peerDependenciesMeta": {
|