@hachej/boring-agent 0.4.25 → 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 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
- `createSkillsExtension` 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.
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
- `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`):
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 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; `{ 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 `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.
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 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, 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. An array, or a source without `fs`, throws.
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
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
- - `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 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.
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 = createSkillsController('workspace.skills', directorySkillSource(env, '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.
@@ -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` parsed with `parseSkill`, 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.
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 { createSkillsController, createSkillsExtension, directorySkillSource, fileSkillSource, parseSkill } from './skills.js';
9
- export type { Skill, SkillError, SkillFileSystem, SkillListing, SkillSource, SkillsController, SkillsExtensionOptions } from './skills.js';
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
- readonly skills?: readonly Skill[];
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, createSkillsExtension, parseSkill, validSkill } from './skills.js';
4
+ import { NAME, isSkillArray, liveSkills, staticSkills, validSkill } from './skills.js';
5
5
  import { createDelegateTool } from './delegate.js';
6
- export { createSkillsController, createSkillsExtension, directorySkillSource, fileSkillSource, parseSkill } from './skills.js';
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
- const hostSkills = spec.skills?.length && !evolving ? [createSkillsExtension(`agent.${spec.id}.skills`, spec.skills)] : [];
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(spec.skills ?? []);
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: (spec.skills ?? []).map(validSkill), parseSkill, skillsExtension: createSkillsExtension, reserved,
67
+ name: `self-evolving:${workspace}`, skills: fixed.map(validSkill), reserved,
62
68
  });
63
69
  if (!spec.selfEvolving)
64
70
  return assemble([...own, ...hostSkills], undefined);
@@ -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 pi-chat produces. The registry item `pi-chat` cannot import this, so it copies the
37
- * parser (`MENTION_TOKEN` and `pieces` in `registry/pi-chat/config.ts`); `test/contracts/pi-chat-source.test.mjs` keeps the two equal. */
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 pi-chat produces. The registry item `pi-chat` cannot import this, so it copies the
7
- * parser (`MENTION_TOKEN` and `pieces` in `registry/pi-chat/config.ts`); `test/contracts/pi-chat-source.test.mjs` keeps the two equal. */
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)) {
@@ -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). */
@@ -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 { skillInFolder } from './skills.js';
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, parseSkill, skillsExtension } = options;
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 ? skillsExtension(`${name}.skills`, skills) : undefined;
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 skillFiles = await skillFilesIn(env, context, errors);
173
- for (const path of skillFiles) {
174
- const text = await env.readTextFile(path, context);
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(skillInFolder(skill, path.slice(0, path.lastIndexOf('/'))));
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,21 +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
- /** Every `<directory>/<name>/SKILL.md`; a missing directory holds no skills. Other files are read only with `companionFiles`. */
40
- export declare function directorySkillSource(fs: SkillFileSystem, directory: string): SkillSource;
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
- export interface SkillsExtensionOptions {
44
- /** How long a source's copy is used before `version()` is asked again, in milliseconds. Default 5000. Unused for an array. */
45
- readonly maxAgeMs?: number;
46
- /**
47
- * Called after each read of a source with every skill it left out (an empty array when none). When `onPublish` refuses the
48
- * read, it gets the errors of the listing still in effect followed by `{ path: '', message: 'Listing refused by onPublish: <reason>' }`.
49
- */
50
- 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;
51
75
  /**
52
76
  * Which skills a conversation sees: called once per listing render and per `load_skill` call with Pi's conversation id and
53
77
  * every valid skill, it returns the subset that conversation sees (matched by name; anything else returned is ignored).
@@ -55,17 +79,27 @@ export interface SkillsExtensionOptions {
55
79
  * a skill that could not be read also answers as unknown (its reason still goes to `onDiagnostics`).
56
80
  */
57
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;
58
94
  /**
59
95
  * List the files beside each `SKILL.md` (at any depth, at most 200) in `load_skill`'s answer, and let
60
96
  * `load_skill({ name, path })` return one of them as text. Each file is checked when the listing is read: a symbolic link,
61
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
62
98
  * listed file again before returning it (the skill's folder, every folder on the way and the file itself, with `fileInfo`,
63
99
  * which does not follow links), so a file or folder swapped for a link since the listing is refused. Read through the
64
- * source's `fs`; an array or a source without `fs` throws.
100
+ * source's `fs`; a source without `fs` throws.
65
101
  */
66
102
  readonly companionFiles?: boolean;
67
- /** The listing section's text before the `- name: description` lines. Default: call load_skill first, `/<skill-name>` is explicit. */
68
- readonly intro?: string;
69
103
  /**
70
104
  * Called before a newly read listing of a source replaces the current one, with both listings' skills. Return a reason
71
105
  * (or throw) to refuse it: the current listing stays, and `onDiagnostics` gets its errors plus `Listing refused by onPublish: <reason>`.
@@ -73,24 +107,34 @@ export interface SkillsExtensionOptions {
73
107
  */
74
108
  readonly onPublish?: (next: readonly Skill[], previous: readonly Skill[]) => string | void | Promise<string | void>;
75
109
  }
76
- /** A skills extension on a source, with a handle to re-read the source now. */
77
- export interface SkillsController {
110
+ /** A live source's extension, with a handle to re-read the source now. */
111
+ export interface LiveSkills {
78
112
  readonly extension: Extension;
79
113
  /**
80
114
  * Read the source now, whatever `maxAgeMs`, and publish what it holds (through `onPublish`). With `name`, only that skill
81
115
  * is taken from the new read (added, replaced or removed); the others stay as they were until the source's `version()`
82
116
  * changes again, when the next read offers the whole source as usual. Resolves `true` when published, `false` when
83
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).
84
121
  */
85
- readonly refresh: (context: Context, name?: string) => Promise<boolean>;
122
+ readonly refresh: (context: Context, name?: string, conversationId?: ConversationId) => Promise<boolean>;
86
123
  }
87
124
  /**
88
- * A prompt section listing the skills and a `load_skill` tool returning one body. Usable without `defineAgent`.
89
- * A fixed array is validated now: an invalid or duplicate skill throws. A `SkillSource` is read on first use and again, once
90
- * the copy is older than `maxAgeMs`, when its version changed; what it cannot use is left out and reported, never thrown.
91
- * `createSkillsController` returns the same extension on a source with a `refresh` handle.
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.
92
135
  */
93
- export declare function createSkillsExtension(name: string, skills: readonly Skill[] | SkillSource, options?: SkillsExtensionOptions): Extension;
94
- /** `createSkillsExtension` on a source, plus `refresh(context, name?)` to re-read it now instead of after `maxAgeMs`. */
95
- export declare function createSkillsController(name: string, source: SkillSource, options?: SkillsExtensionOptions): SkillsController;
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;
96
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
- /** Every `<directory>/<name>/SKILL.md`; a missing directory holds no skills. Other files are read only with `companionFiles`. */
54
- export function directorySkillSource(fs, directory) {
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
- async function folders(context) {
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' ? { names: [] } : { names: [], error: { path: root, message: listed.error.message } };
60
- return { names: listed.value.filter(entry => entry.kind === 'directory').map(entry => entry.name).sort() };
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 { names, error } = await folders(context);
78
+ const { files, error } = await candidates(context);
67
79
  if (error)
68
80
  return `error:${error.message}`;
69
- const stamps = await Promise.all(names.map(async (name) => {
70
- const found = await info(name, context);
71
- return `${name}=${found.ok ? stamp(found.value) : found.error.code}`;
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 { names, error } = await folders(context);
88
+ const { files, error } = await candidates(context);
77
89
  const skills = [], errors = error ? [error] : [];
78
- for (const name of names) {
79
- const found = await info(name, context);
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, `${root}/${name}/SKILL.md`, name, context);
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 liveSkills(source, options) {
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;
@@ -201,6 +246,85 @@ function liveSkills(source, options) {
201
246
  }),
202
247
  };
203
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
+ }
204
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.';
205
329
  const COMPANION_LIMIT = 200;
206
330
  /** Each skill's companion files, checked when its listing was read. */
@@ -279,7 +403,7 @@ async function withCompanions(fs, listing, context) {
279
403
  return { known: listing.known, errors };
280
404
  }
281
405
  /** The listing section and `load_skill` over `live`, filtered per conversation by `visible`. */
282
- function skillsExtension(name, live, options, fs) {
406
+ function skillsExtension(live, options, fs) {
283
407
  const { visible } = options, intro = options.intro ?? INTRO_TEXT;
284
408
  // `at` is read only with a filter, so direct callers of render/execute without Pi's input or api keep working.
285
409
  async function seen(known, at) {
@@ -293,10 +417,11 @@ function skillsExtension(name, live, options, fs) {
293
417
  ? Type.Object({ name: Type.String(), path: Type.Optional(Type.String({ description: 'A companion file of the skill, relative to its folder.' })) }, { additionalProperties: false })
294
418
  : Type.Object({ name: Type.String() }, { additionalProperties: false });
295
419
  return defineExtension({
296
- name,
420
+ name: options.name,
297
421
  sections: [{ key: 'skills', render: async (input, context) => {
298
- const skills = await seen((await live.current(context)).known, () => input.conversationId);
299
- return skills.length ? `${intro}\n${skills.map(skill => `- ${skill.name}: ${skill.description}`).join('\n')}` : undefined;
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;
300
425
  } }],
301
426
  tools: [defineTool({
302
427
  name: 'load_skill',
@@ -305,7 +430,7 @@ function skillsExtension(name, live, options, fs) {
305
430
  replay: 'safe',
306
431
  execute: async (args, api, context) => {
307
432
  const text = (value) => ({ output: [{ type: 'text', text: value }] });
308
- let { known, errors } = await live.current(context);
433
+ let { known, errors } = await live.current(context, api?.conversationId);
309
434
  let shown = await seen(known, () => api.conversationId);
310
435
  if (!shown.some(candidate => candidate.name === args.name) && live.miss) {
311
436
  // Not in this conversation's listing: the skill may have been added since, so check the source once now.
@@ -338,20 +463,25 @@ function skillsExtension(name, live, options, fs) {
338
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')}` : '';
339
464
  return text(skill.body + location + listed);
340
465
  },
341
- })],
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) }] }),
342
478
  });
343
479
  }
344
480
  /**
345
- * A prompt section listing the skills and a `load_skill` tool returning one body. Usable without `defineAgent`.
346
- * A fixed array is validated now: an invalid or duplicate skill throws. A `SkillSource` is read on first use and again, once
347
- * the copy is older than `maxAgeMs`, when its version changed; what it cannot use is left out and reported, never thrown.
348
- * `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.
349
483
  */
350
- export function createSkillsExtension(name, skills, options = {}) {
351
- if (!isSkillArray(skills))
352
- return createSkillsController(name, skills, options).extension;
353
- if (options.companionFiles)
354
- throw new TypeError('Skills extension: companionFiles need a SkillSource with fs, not an array');
484
+ export function staticSkills(skills, options) {
355
485
  const known = new Map();
356
486
  for (const skill of skills.map(validSkill)) {
357
487
  if (known.has(skill.name))
@@ -359,13 +489,21 @@ export function createSkillsExtension(name, skills, options = {}) {
359
489
  known.set(skill.name, skill);
360
490
  }
361
491
  const fixed = { known, errors: [] };
362
- return skillsExtension(name, { current: async () => fixed }, options, undefined);
492
+ return { extension: skillsExtension({ current: async () => fixed }, options, undefined) };
363
493
  }
364
- /** `createSkillsExtension` on a source, plus `refresh(context, name?)` to re-read it now instead of after `maxAgeMs`. */
365
- export function createSkillsController(name, source, options = {}) {
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) {
366
500
  if (options.companionFiles && !source.fs)
367
501
  throw new TypeError('Skills extension: companionFiles need a SkillSource with fs');
368
- const live = liveSkills(source, options);
369
- return { extension: skillsExtension(name, live, options, options.companionFiles ? source.fs : undefined), refresh: live.refresh };
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 };
370
506
  }
371
- 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.25",
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.25",
164
- "@hachej/boring-ui-kit": "^0.4.25",
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": {