@hachej/boring-agent 0.4.25 → 0.4.27

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.
@@ -0,0 +1,195 @@
1
+ import { defineDoc } from '@earendil-works/pi-durable';
2
+ import { Check, Errors } from 'typebox/schema';
3
+ const MAX_HINT = 1000;
4
+ const OPERATION = /^[A-Za-z][\w.-]{0,80}$/;
5
+ /** A settled submission that was cut short (aborted, reset, superseded) is `interrupted`; any other non-answer is an `error`. */
6
+ const INTERRUPTED = new Set(['aborted', 'reset', 'stale']);
7
+ /**
8
+ * The per-conversation count of repair attempts that left no result call (a model that answered in text instead of the tool).
9
+ * Defined without `history` and `fork`, exactly as the homes that ran this protocol before persisted it: pi-durable refuses a token
10
+ * whose semantics differ from the stored record, so adding them here would break every existing run store.
11
+ */
12
+ export const ResultBudget = defineDoc({ kind: 'boring.result-budget', version: 1, scope: 'conversation', initial: () => ({ misses: 0 }) });
13
+ /** Parse `result:` of a card. `legacy` is the card's `tool.json`, whose input is the default schema. Throws a plain Error naming the agent. */
14
+ export function resultConfig(value, legacy, name) {
15
+ if (value !== undefined && (!value || typeof value !== 'object' || Array.isArray(value)))
16
+ throw new Error(`agents/${name}: result is { schema?, validate?, repairs? }`);
17
+ const result = (value ?? {});
18
+ if (result.validate !== undefined && (typeof result.validate !== 'string' || !OPERATION.test(result.validate)))
19
+ throw new Error(`agents/${name}: result.validate names an operation`);
20
+ const repairs = result.repairs === undefined ? 2 : result.repairs;
21
+ if (typeof repairs !== 'number' || !Number.isSafeInteger(repairs) || repairs < 0 || repairs > 10)
22
+ throw new Error(`agents/${name}: result.repairs is an integer from 0 to 10`);
23
+ const schema = result.schema === undefined ? legacy?.input : result.schema;
24
+ if (schema !== undefined && (!schema || typeof schema !== 'object' || Array.isArray(schema) || schema.type !== 'object'))
25
+ throw new Error(`agents/${name}: result.schema is an object JSON Schema`);
26
+ return { ...(schema ? { schema: schema } : {}), ...(typeof result.validate === 'string' ? { validate: result.validate } : {}), repairs };
27
+ }
28
+ export const callsOf = (messages) => messages.flatMap(m => m.role === 'assistant' && Array.isArray(m.content) ? m.content.filter(p => p.type === 'toolCall') : []);
29
+ /** The state of a run's result from its transcript. Only a host tool result can accept a call; a later rejected call cannot replace an accepted one. */
30
+ export function resultState(card, messages, misses = 0) {
31
+ if (!card.result)
32
+ return { accepted: null, attempts: 0, exhausted: false };
33
+ const all = callsOf(messages), calls = all.filter(c => c.name === card.output?.name);
34
+ const acceptedIds = new Set();
35
+ let accepted = null, error, end;
36
+ for (const message of messages) {
37
+ if (message.role !== 'toolResult' || message.toolName !== card.output?.name)
38
+ continue;
39
+ const checked = message.details?.resultValidation;
40
+ if (checked?.error) {
41
+ error = checked.error;
42
+ end = checked.end ?? 'error';
43
+ }
44
+ if (!message.isError && checked?.valid === true && message.toolCallId !== undefined)
45
+ acceptedIds.add(message.toolCallId);
46
+ }
47
+ for (const call of calls) {
48
+ if (acceptedIds.has(call.id) && all.filter(c => c.id === call.id).length === 1 && card.output && Check(card.output.input, call.arguments))
49
+ accepted = call.arguments;
50
+ }
51
+ const attempts = calls.length + misses;
52
+ return { accepted, ...(error !== undefined ? { error } : {}), ...(end !== undefined ? { end } : {}), attempts, exhausted: attempts > card.result.repairs + 1 || (attempts === card.result.repairs + 1 && !accepted) };
53
+ }
54
+ /** The message of a one-call run: the input as JSON, and how to answer. */
55
+ export function runMessage(card, input) {
56
+ const data = typeof input === 'string' ? input : `Input:\n${JSON.stringify(input ?? {}, null, 2)}`;
57
+ return card.output ? `${data}\n\nReturn the result by calling the tool ${card.output.name} exactly once. No text outside that call.` : data;
58
+ }
59
+ /** A run's answer: only the last host-accepted result call, else the last markdown reply. */
60
+ export function runResult(card, messages) {
61
+ if (card.output) {
62
+ const state = resultState(card, messages ?? []);
63
+ return state.accepted && !state.error && !state.exhausted ? { output: state.accepted } : null;
64
+ }
65
+ let text = '';
66
+ for (const message of messages ?? []) {
67
+ if (message?.role !== 'assistant')
68
+ continue;
69
+ const parts = typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : Array.isArray(message.content) ? message.content : [];
70
+ const said = parts.filter(p => p.type === 'text' && p.text).map(p => p.text).join('');
71
+ if (said)
72
+ text = said;
73
+ }
74
+ return text ? { text } : null;
75
+ }
76
+ /** The model sees the complete result schema as the tool's parameters (nested items, descriptions, required), like a production tool. */
77
+ export function resultParameters(card) {
78
+ return { ...card.output.input, type: 'object', properties: card.output.input.properties ?? {} };
79
+ }
80
+ export const resultDescription = (card) => card.output.description ?? '';
81
+ /** Read the host's whole transcript, not a compacted model context or model-provided evidence. */
82
+ export async function resultMessages(reader, conversationId) {
83
+ const entries = [];
84
+ let cursor;
85
+ do {
86
+ const page = await reader.scanEntries({ conversationId, order: 'ascending' }, 500, cursor);
87
+ entries.push(...page.items);
88
+ cursor = page.next;
89
+ } while (cursor);
90
+ return entries.flatMap(e => (e.model ?? []));
91
+ }
92
+ /** The tool results a run obtained (every call but the result tool's own), with the arguments that actually executed. */
93
+ export function evidenceOf(card, messages) {
94
+ const all = callsOf(messages), calls = all.filter(c => c.name !== card.output?.name);
95
+ return messages.filter(m => m.role === 'toolResult' && calls.some(c => c.id === m.toolCallId && c.name === m.toolName) && all.filter(c => c.id === m.toolCallId).length === 1).map(m => {
96
+ const call = calls.find(c => c.id === m.toolCallId);
97
+ return { name: call.name, arguments: m.details?.hostArguments ?? call.arguments, results: { content: m.content, isError: Boolean(m.isError) } };
98
+ });
99
+ }
100
+ /**
101
+ * Check a result: the schema first (no coercion), then the card's `result.validate` operation, called through `invoke` with the
102
+ * evidence. `phase` is `attempt` while the agent answers and `delivery` for the check made again just before the run is delivered;
103
+ * `key` must be fresh for a delivery check. Throws when the validator answers something else than `{ valid, hint? }`.
104
+ */
105
+ export async function validateResult(card, output, evidence, invoke, phase, key) {
106
+ if (card.output && !Check(card.output.input, output)) {
107
+ const [, errors] = Errors(card.output.input, output);
108
+ return { valid: false, hint: errors.map(e => `${e.instancePath || '/'}: ${e.message}`).join('; ').slice(0, MAX_HINT) };
109
+ }
110
+ if (!card.result?.validate)
111
+ return { valid: true };
112
+ const checked = await invoke(card.result.validate, { output, evidence, ...(phase === 'delivery' ? { phase } : {}) }, key);
113
+ if (!checked || typeof checked.valid !== 'boolean' || (checked.hint !== undefined && typeof checked.hint !== 'string'))
114
+ throw new Error(`result.validate ${card.result.validate}: expected { valid: boolean, hint?: string }`);
115
+ return { valid: checked.valid, hint: checked.hint?.slice(0, MAX_HINT) ?? 'Return a supported result.' };
116
+ }
117
+ /** `end` of a run from an error thrown while validating: a refusal by the host's authority is `revoked`, anything else `error`. */
118
+ export function validationError(error) {
119
+ const e = error;
120
+ return { error: String(e?.message ?? error).slice(0, 500), end: e?.status === 401 || e?.status === 403 ? 'revoked' : 'error' };
121
+ }
122
+ /** Record the arguments that actually executed (after guards and coercion), never ones invented in the final answer. */
123
+ export function withEvidence(tool) {
124
+ const execute = tool.execute;
125
+ return { ...tool, async execute(args, api, context) {
126
+ const result = await execute.call(tool, args, api, context);
127
+ return { ...result, details: { ...result.details, hostArguments: args } };
128
+ } };
129
+ }
130
+ /**
131
+ * Settle a one-call run in its own conversation: wait for the answer, read the result, check it, and while it is wrong and the card's
132
+ * repair budget lasts, ask for a corrected result with a native follow-up input (`repair:<conversation>:<n>`, so a replay finds the
133
+ * same submission). A result that passes is checked once more with phase `delivery` before it is returned. Not durable by itself:
134
+ * the caller records the end; a process that dies in the middle ends the run `interrupted`.
135
+ */
136
+ export async function settleResult(options) {
137
+ const { card, conversation, invoke, context, readResult = runResult } = options;
138
+ let submission = options.submission, previousAttempts = 0;
139
+ for (let attempt = 0;; attempt++) {
140
+ const settled = await submission.wait(context);
141
+ const messages = await conversation.commit(tx => resultMessages(tx, conversation.id), context);
142
+ const budget = await conversation.commit(async (tx) => ({ misses: (await tx.doc(ResultBudget, conversation.id)).misses }), context);
143
+ const state = resultState(card, messages, budget.misses);
144
+ if (state.error)
145
+ return { error: state.error, end: state.end ?? 'error' };
146
+ if (card.output && state.exhausted)
147
+ return { error: `${card.name} returned no result matching tool.json: repair budget exhausted`, end: 'invalid_output' };
148
+ if (settled.status !== 'done')
149
+ return { error: `${card.name} did not answer (${settled.reason ?? settled.status})`, end: INTERRUPTED.has(settled.reason ?? '') ? 'interrupted' : 'error' };
150
+ const result = readResult(card, messages);
151
+ // A card without `result:` keeps its own result reader: no host validation, no repairs.
152
+ if (!card.result)
153
+ return result ? { result } : {};
154
+ const checked = result
155
+ ? (card.output ? { valid: true } : await validateResult(card, result.text, evidenceOf(card, messages), invoke, 'attempt', `result:${conversation.id}:markdown:${attempt}`))
156
+ : { valid: false, hint: card.output ? `Return the result using ${card.output.name}.` : 'Return a non-empty answer.' };
157
+ if (checked.valid && result) {
158
+ const validation = { card, evidence: evidenceOf(card, messages) };
159
+ const fresh = await validateResult(card, 'output' in result ? result.output : result.text, validation.evidence, invoke, 'delivery', `result-delivery:${conversation.id}:${crypto.randomUUID()}`);
160
+ return fresh.valid ? { result, validation } : { error: fresh.hint ?? 'Return a supported result.', end: 'invalid_output' };
161
+ }
162
+ const hint = checked.hint ?? 'Return a supported result.';
163
+ const attempts = card.output ? state.attempts + (state.attempts === previousAttempts ? 1 : 0) : attempt + 1;
164
+ if (attempts >= card.result.repairs + 1)
165
+ return { error: hint, end: 'invalid_output' };
166
+ if (card.output && state.attempts === previousAttempts)
167
+ await conversation.commit(async (tx) => { (await tx.doc(ResultBudget, conversation.id)).misses++; }, context);
168
+ previousAttempts = attempts;
169
+ submission = await conversation.submit({ type: 'input', content: `Invalid result: ${hint}\nReturn a corrected result.`, requestId: `repair:${conversation.id}:${attempt + 1}` }, context);
170
+ }
171
+ }
172
+ /** The `execute` of the result tool: validates the call the model made, spends the repair budget, and ends the turn when the budget is gone. */
173
+ export function resultExecutor(card, invoke) {
174
+ return async (args, api, context) => {
175
+ const messages = await api.commit(tx => resultMessages(tx, api.conversationId), context);
176
+ const budget = await api.snapshot(ResultBudget, api.conversationId, context);
177
+ const state = resultState(card, messages, budget?.misses ?? 0);
178
+ const calls = callsOf(messages).filter(c => c.id === api.callId);
179
+ const output = calls[0]?.arguments ?? args;
180
+ let checked;
181
+ try {
182
+ checked = calls.length > 1 ? { valid: false, hint: 'Use a unique result call id.' }
183
+ : state.attempts > card.result.repairs + 1 ? { valid: false, hint: 'Result repair budget exhausted' }
184
+ : await validateResult(card, output, evidenceOf(card, messages), invoke, 'attempt', `result:${api.conversationId}:${api.taskId}`);
185
+ }
186
+ catch (error) {
187
+ return { output: [{ type: 'text', text: 'Result validation failed.' }], isError: true, details: { resultValidation: validationError(error) }, control: { terminate: true } };
188
+ }
189
+ return {
190
+ output: [{ type: 'text', text: checked.valid ? 'Saved.' : `Invalid result: ${checked.hint}` }],
191
+ isError: !checked.valid, details: { resultValidation: { valid: checked.valid } },
192
+ ...(!checked.valid && state.attempts >= card.result.repairs + 1 ? { control: { terminate: true } } : {}),
193
+ };
194
+ };
195
+ }
package/dist/runs.d.ts ADDED
@@ -0,0 +1,83 @@
1
+ import type { ResultFailure } from './run-result.js';
2
+ /**
3
+ * What a run is, in every home: the ways it ends, the view an operation reads, the usage it reports, and the delivery of its end
4
+ * to the operation that started it. The home keeps the run (a durable task on a Durable Object, a row in a person's SQLite file);
5
+ * these are the parts that must not differ between homes.
6
+ */
7
+ /** How a run ends. `revoked`: the host's authority was withdrawn (a delivery or validation refused with 401/403). */
8
+ export declare const RUN_ENDS: readonly ["completed", "invalid_output", "cancelled", "interrupted", "revoked", "error"];
9
+ export type RunEnd = typeof RUN_ENDS[number];
10
+ export type RunStatus = 'queued' | 'running' | RunEnd;
11
+ /** The end code of a run that the host's spend cap refused (`error: "over_budget"`), and of a request it refused. */
12
+ export declare const OVER_BUDGET = "over_budget";
13
+ /** What a run used, summed over its model answers: tokens, cost in USD as pi-ai prices it, and the models that answered. */
14
+ export interface RunUsage {
15
+ readonly input: number;
16
+ readonly output: number;
17
+ readonly cacheRead: number;
18
+ readonly cacheWrite: number;
19
+ readonly cost: number;
20
+ readonly models: readonly string[];
21
+ }
22
+ /** A run by its key, as the operation that started it reads it. `usage` is null for a run that never answered. */
23
+ export interface RunView {
24
+ readonly run: string;
25
+ readonly status: RunStatus;
26
+ readonly startedAt?: string;
27
+ readonly output?: unknown;
28
+ readonly usage: RunUsage | null;
29
+ readonly error?: string;
30
+ }
31
+ /** What the delivery operation is called with: the run's end. */
32
+ export interface RunPayload {
33
+ readonly run: string;
34
+ readonly status: RunEnd;
35
+ readonly output?: unknown;
36
+ readonly usage: RunUsage | null;
37
+ readonly error?: string;
38
+ }
39
+ /** Statuses a run can still be in, and ends the view carries an `error` text for. */
40
+ export declare const isEnd: (status: string) => status is RunEnd;
41
+ export declare const endsWithError: (status: string) => boolean;
42
+ /** True when `error` is the spend meter's refusal (`over_budget`, 429), by its `code` or its `extra.code`. */
43
+ export declare function isOverBudget(error: unknown): boolean;
44
+ /**
45
+ * A run's model answers summed from its transcript messages (pi-ai's `usage` on each assistant message); null when none answered.
46
+ * `modelRef` names the model of a message (default: its `model`), for hosts that report `provider:model`; return undefined to skip one.
47
+ */
48
+ export declare function sumUsage(messages: readonly unknown[], modelRef?: (message: {
49
+ provider?: string;
50
+ model?: string;
51
+ }) => string | undefined): RunUsage | null;
52
+ export interface DeliveryOptions {
53
+ /** The operation that receives the end (`deliver:` of the start). */
54
+ readonly operation: string;
55
+ /** The run's key: the delivery's receipt key is `deliver:<key>`, so the host's receipt makes the delivery once. */
56
+ readonly key: string;
57
+ readonly end: Omit<RunPayload, 'run'>;
58
+ /** Calls the operation as the person whose run it is. Resolve = delivered; throw = refused (the next attempt may pass). */
59
+ readonly invoke: (operation: string, input: unknown, key: string) => Promise<unknown>;
60
+ /** A completed run is checked again just before it is delivered (a `phase: "delivery"` check): its failure becomes the end delivered instead. */
61
+ readonly recheck?: () => Promise<ResultFailure | undefined>;
62
+ /** Told once when the re-check fails, before the delivery, so the home can record the failed end. */
63
+ readonly onFailure?: (failure: ResultFailure) => Promise<void> | void;
64
+ /** Attempts (default 3) and the first wait between two (default 20 ms, doubling). */
65
+ readonly attempts?: number;
66
+ readonly backoffMs?: number;
67
+ }
68
+ export interface DeliveryResult {
69
+ /** False when no attempt was accepted: the run's end stays undelivered and is delivered again later. */
70
+ readonly delivered: boolean;
71
+ /** The end as delivered or last tried: the re-check's failure replaces a completed end. */
72
+ readonly end: Omit<RunPayload, 'run'>;
73
+ readonly failure?: ResultFailure;
74
+ /** The last refusal, when not delivered. */
75
+ readonly error?: string;
76
+ }
77
+ /**
78
+ * Hand a run's end to the operation that started it, once however often it is tried: attempts are bounded, every one carries the same
79
+ * `deliver:<key>` receipt key, and a completed run is first checked again so a result that stopped being valid is delivered as
80
+ * `invalid_output` (or `revoked`), never as a success. A failed delivery is reported, not dropped: the home retries it later.
81
+ */
82
+ export declare function deliverRunEnd(options: DeliveryOptions): Promise<DeliveryResult>;
83
+ //# sourceMappingURL=runs.d.ts.map
package/dist/runs.js ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * What a run is, in every home: the ways it ends, the view an operation reads, the usage it reports, and the delivery of its end
3
+ * to the operation that started it. The home keeps the run (a durable task on a Durable Object, a row in a person's SQLite file);
4
+ * these are the parts that must not differ between homes.
5
+ */
6
+ /** How a run ends. `revoked`: the host's authority was withdrawn (a delivery or validation refused with 401/403). */
7
+ export const RUN_ENDS = Object.freeze(['completed', 'invalid_output', 'cancelled', 'interrupted', 'revoked', 'error']);
8
+ /** The end code of a run that the host's spend cap refused (`error: "over_budget"`), and of a request it refused. */
9
+ export const OVER_BUDGET = 'over_budget';
10
+ /** Statuses a run can still be in, and ends the view carries an `error` text for. */
11
+ export const isEnd = (status) => RUN_ENDS.includes(status);
12
+ const WITH_ERROR = new Set(['error', 'interrupted', 'invalid_output', 'revoked']);
13
+ export const endsWithError = (status) => WITH_ERROR.has(status);
14
+ /** True when `error` is the spend meter's refusal (`over_budget`, 429), by its `code` or its `extra.code`. */
15
+ export function isOverBudget(error) {
16
+ const e = error;
17
+ return (e?.code ?? e?.extra?.code) === OVER_BUDGET;
18
+ }
19
+ const count = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
20
+ /**
21
+ * A run's model answers summed from its transcript messages (pi-ai's `usage` on each assistant message); null when none answered.
22
+ * `modelRef` names the model of a message (default: its `model`), for hosts that report `provider:model`; return undefined to skip one.
23
+ */
24
+ export function sumUsage(messages, modelRef = (m) => m.model) {
25
+ let answered = false, input = 0, output = 0, cacheRead = 0, cacheWrite = 0, cost = 0;
26
+ const models = [];
27
+ for (const raw of messages) {
28
+ const message = raw;
29
+ if (message?.role !== 'assistant' || !message.usage)
30
+ continue;
31
+ const u = message.usage;
32
+ answered = true;
33
+ input += count(u.input);
34
+ output += count(u.output);
35
+ cacheRead += count(u.cacheRead);
36
+ cacheWrite += count(u.cacheWrite);
37
+ cost += count(u.cost?.total);
38
+ const ref = typeof message.model === 'string' ? modelRef(message) : undefined;
39
+ if (ref && !models.includes(ref))
40
+ models.push(ref);
41
+ }
42
+ return answered ? { input, output, cacheRead, cacheWrite, cost, models } : null;
43
+ }
44
+ /**
45
+ * Hand a run's end to the operation that started it, once however often it is tried: attempts are bounded, every one carries the same
46
+ * `deliver:<key>` receipt key, and a completed run is first checked again so a result that stopped being valid is delivered as
47
+ * `invalid_output` (or `revoked`), never as a success. A failed delivery is reported, not dropped: the home retries it later.
48
+ */
49
+ export async function deliverRunEnd(options) {
50
+ const { operation, key, invoke, recheck, onFailure, attempts = 3, backoffMs = 20 } = options;
51
+ let end = options.end, failure, last;
52
+ for (let attempt = 0; attempt < attempts; attempt++) {
53
+ if (attempt)
54
+ await new Promise(resolve => setTimeout(resolve, backoffMs * 2 ** (attempt - 1)));
55
+ if (end.status === 'completed' && recheck) {
56
+ let found;
57
+ try {
58
+ found = await recheck();
59
+ }
60
+ catch (error) {
61
+ // Only a refusal by authority changes the end; any other throw (an outage) leaves it completed, tried again.
62
+ const status = error?.status;
63
+ last = error?.message ?? error;
64
+ if (status !== 401 && status !== 403)
65
+ continue;
66
+ found = { error: String(last).slice(0, 500), end: 'revoked' };
67
+ }
68
+ if (found) {
69
+ failure = found;
70
+ end = { status: found.end, usage: end.usage, error: found.error };
71
+ await onFailure?.(found);
72
+ }
73
+ }
74
+ try {
75
+ await invoke(operation, { run: key, ...end }, `deliver:${key}`);
76
+ return { delivered: true, end, ...(failure ? { failure } : {}) };
77
+ }
78
+ catch (error) {
79
+ last = error?.message ?? error;
80
+ }
81
+ }
82
+ return { delivered: false, end, ...(failure ? { failure } : {}), error: `deliver ${operation}: ${String(last).slice(0, 200)}` };
83
+ }
@@ -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