@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.
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.27",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -148,6 +148,26 @@
148
148
  "./room": {
149
149
  "types": "./dist/room.d.ts",
150
150
  "import": "./dist/room.js"
151
+ },
152
+ "./run-result": {
153
+ "types": "./dist/run-result.d.ts",
154
+ "import": "./dist/run-result.js"
155
+ },
156
+ "./runs": {
157
+ "types": "./dist/runs.d.ts",
158
+ "import": "./dist/runs.js"
159
+ },
160
+ "./run-ledger": {
161
+ "types": "./dist/run-ledger.d.ts",
162
+ "import": "./dist/run-ledger.js"
163
+ },
164
+ "./run-outbox": {
165
+ "types": "./dist/run-outbox.d.ts",
166
+ "import": "./dist/run-outbox.js"
167
+ },
168
+ "./actions": {
169
+ "types": "./dist/actions.d.ts",
170
+ "import": "./dist/actions.js"
151
171
  }
152
172
  },
153
173
  "dependencies": {
@@ -160,8 +180,9 @@
160
180
  "@earendil-works/pi-durable": "~1.2.0",
161
181
  "@earendil-works/pi-ai": "~1.2.0",
162
182
  "@earendil-works/chord": "~1.2.0",
163
- "@hachej/boring-files": "^0.4.25",
164
- "@hachej/boring-ui-kit": "^0.4.25",
183
+ "@hachej/boring-files": "^0.4.27",
184
+ "@hachej/boring-ui-kit": "^0.4.27",
185
+ "typebox": "1.3.27",
165
186
  "xlsx": "^0.20.3"
166
187
  },
167
188
  "peerDependenciesMeta": {