@hachej/boring-agent 0.4.4 → 0.4.6

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
@@ -114,13 +114,13 @@ await conversation.configure({ extensions: { add: [memory.extension] } }, contex
114
114
  await conversation.configure({ extensions: { remove: [memory.extension] } }, context); // off again
115
115
  ```
116
116
 
117
- Pi applies an extension's hooks only in conversations that select it, so memory is opt-in per conversation and there is no separate mode. A host that selects extensions by default selects it everywhere; give conversations an explicit `extensions` list to keep it opt-in. `createOptChatMemory(options)` returns `{ extension, nap(id), stats(id), zoom(id, leafId, n), date(id, leafId), forgetMessage(id, messageIndex), forget(id), dispose() }`. Options: `harness`, `context` (long-lived, for background work), `summarizer` (required: `{ model }`, where `model` may be a function `(conversationId) => ModelRef`, sync or async, for example that conversation's own model, and `models` defaults to the Harness's; or `{ summarize(job, signal) }` returning one line), `nodeBytes` (512), `viewBytes` (128000), `compactBytes` (32000, the compactor's separate view), `notes` (seed strings, stored once per conversation before the transcript), `workOf(message)` (host classification of subagent or job reports), `jobs` (64, how many summary tasks of a conversation the host keeps live at once), `tries` (5), `cap` (30000), `retryMs` (10000 initially, doubling after each failure to at most 60000), `settleTimeoutMs` (60000), `agentName`, `name`, `onError`. `dispose()` only stops this process's waits: the summary tasks belong to Pi, which keeps and recovers them, so it is harmless to skip and safe to call before `harness.close`.
117
+ Pi applies an extension's hooks only in conversations that select it, so memory is opt-in per conversation and there is no separate mode. A host that selects extensions by default selects it everywhere; give conversations an explicit `extensions` list to keep it opt-in. `createOptChatMemory(options)` returns `{ extension, nap(id), stats(id), zoom(id, leafId, n), date(id, leafId), forgetMessage(id, messageIndex), forget(id), dispose() }`. Options: `harness`, `context` (long-lived, for background work), `summarizer` (required: `{ model }`, where `model` may be a function `(conversationId) => ModelRef`, sync or async, for example that conversation's own model, and `models` defaults to the Harness's; or `{ summarize(job, signal) }` returning one line), `nodeBytes` (512), `viewBytes` (128000), `compactBytes` (32000, the compactor's separate view), `foldHighWater` (1.25) and `foldLowWater` (0.75), fractions of both view budgets, `notes` (seed strings, stored once per conversation before the transcript), `workOf(message)` (host classification of subagent or job reports), `jobs` (64, how many summary tasks of a conversation the host keeps live at once), `tries` (5), `cap` (30000), `retryMs` (10000 initially, doubling after each failure to at most 60000), `settleTimeoutMs` (60000), `agentName`, `name`, `onError`. `dispose()` only stops this process's waits: the summary tasks belong to Pi, which keeps and recovers them, so it is harmless to skip and safe to call before `harness.close`.
118
118
 
119
119
  | Reference | Here |
120
120
  | --- | --- |
121
121
  | Append-only log of user, talk, tool and echo messages | The Pi transcript, never rewritten. Leaves are derived from the model context: a user message is `user`, assistant text `talk`, each tool call `tool`, each tool result `echo`; thinking is not logged. A leaf over `cap` characters keeps head and tail. Seeded `note` leaves precede them; messages marked by `workOf` become one `work` leaf |
122
122
  | Summary tree: node (l, i) covers messages [i·2^l, (i+1)·2^l), at most `nodeBytes` | The same, purely binary. A short message, or two children that fit together, are their own text and cost no call |
123
- | The view tiles the whole chat, one `id+n\|text` line per node, fit incrementally by merging the most due pair (age / 2^(level+2)), never splitting | The same fold (`advanceFold`), kept per conversation and advanced as the log grows, so the start of the view stays the same between requests. `renderView` produces the `<chat>` block |
123
+ | The view tiles the whole chat, one `id+n\|text` line per node, fit incrementally by merging the most due pair (age / 2^(level+2)), never splitting | The checkpoint fold (`advanceFold`): append between checkpoints; above 125% of the budget, merge deterministically down to 75%. Both fractions are configurable. The public four-argument core helper retains strict fitting. `renderView` produces the `<chat>` block |
124
124
  | Each turn calls the model with [system][view][new message] | `GenerationTask.beforeRequest` replaces the messages before the current turn with the view, as four-line text blocks at the start of the current turn's first message. The turn in progress starts at the latest user or work message. Work reports and sibling results whose tool calls fell outside the turn become ordinary user-role input, with their full text preserved. Every positional system message is kept in order. The explanation of the view is a prompt section, so the system prompt and tools never change between calls |
125
125
  | Compactor: context first, no ids, scale line, resubmit with the cut at the limit, `JOBS` in parallel, patient retry | The same protocol (`askModel`), run by native pi-durable tasks: each node `(level, index, fingerprint)` is one `boring.memory.optchat.node` task (shipped in the extension's `tasks`, input `{ key, level, index, of, upTo }`, one `summarize` phase) that calls the summarizer through `runtime.models` (or your `summarize`) and, in one commit, writes the node and the summarizer's usage into the extension's state. Pi schedules, runs and recovers them. See below |
126
126
  | A turn waits until every line of the view is a summary and never shows cut text | The request waits up to `settleTimeoutMs`; after that the missing lines show `(not summarized yet: zoom it)`, never a clipped message, and `zoom(id, 1)` returns the message whole |
@@ -134,14 +134,14 @@ What is native, what is host-side:
134
134
  - **Waiting.** A request waits with `waitForTask` on the live node tasks, up to `settleTimeoutMs`, then shows the placeholder. `nap` waits until all nodes are built, including retries. These waits are in-process; if the process dies the tasks are not lost, only the cascade to parents waits for the next trigger.
135
135
  - **Retry.** A failed node is a failed native task (`outcome.failed`). Its failure time and streak length live in `boring.memory.optchat.state` with call count and spend. The host's idle admission loop waits from `retryMs`, doubling to at most 60 seconds, then creates another native task without a new turn. `dispose()` aborts those waits, not Pi's tasks. After a process restart, `nap` or another trigger restarts admission. Requests retain their bounded wait and can show placeholders during an outage. `onError` reports only the first failure of a streak.
136
136
  - **Usage.** In the commit that stores a node (or records its failure) the task adds the summarizer's usage, all attempts of the cut-and-resubmit loop included, to the extension's own state document (`boring.memory.optchat.state`), reported by `stats().compactor.usage`. It is not written into Pi's `pi.usage`: Pi has no public way for a task to record model usage (`recordUsage` is internal), so per BORING-PI-4 that guarantee is unavailable here rather than worked around; a public usage-recording seam is an upstream request. Hosts that budget model spend add `stats().compactor.usage` to `harness.usage()`. Task and document kinds are all in `boring.memory.optchat.*`; this package writes no `pi.*` document.
137
- - **Still host-side.** The trigger and admission logic, the in-memory fold and log per process (a cache; fingerprints keep it honest), and the summarizer prompt. A custom `summarize` function has no model usage to record. A model call interrupted by a crash is rerun from the start of the task, and its spend is not recorded.
137
+ - **Still host-side.** The trigger and admission logic, the log cache and the summarizer prompt. Context refreshes reuse a prior leaf only after exact kind, capped-text and timestamp comparison, avoiding repeated byte counting and hashing without trusting native message identity. The emitted turn and compactor tilings, open flags and content fingerprints are retained in `boring.memory.optchat.fold` (no duplicated message or summary text), so cache eviction and restart restore the same prefix with unchanged options and history. A sent interior placeholder stays frozen until a checkpoint (the tail may settle); later summaries remain accessible through zoom. History changes, forgetting or changed fold options rebuild the view. A custom `summarize` function has no model usage to record. A model call interrupted by a crash is rerun from the start of the task, and its spend is not recorded.
138
138
 
139
139
  Deviations, honestly:
140
140
 
141
141
  - The log is Pi's model context, not an OptChat file, so ids are positions in that context. A native compaction, reset or edit that changes earlier messages invalidates the summaries it touches (fingerprints), and the view is refolded. `beforeCompact` declines while the extension is selected: the view replaces compaction, and a native summary would cut the transcript the view is built from. That is the only effect on Pi's lifecycle.
142
142
  - The current turn is verbatim (including its tool steps) instead of being summarized to one line each as soon as it is logged; it enters the view when the next user message arrives. A user message sent mid-run starts a new "current turn" for the next step.
143
143
  - A request is rewritten once per attempt from the full context. The summaries are a cache rebuilt from the log; the summarizer's calls are not Pi generations: they run as Pi tasks and their usage is in `stats().compactor.usage`, not `pi.usage` (see above).
144
- - Both views use four-line text blocks with a stable prefix. The compactor folds its own `compactBytes` view of preceding messages, independent of the chat's `viewBytes`. Admission builds preceding parents before dispatching later requests whose context would exceed this budget. Pi 1.2's `Models.complete` accepts retention preferences, but `TextContent` exposes no per-block cache marker. Blocks are unmarked; provider-specific cache-hit guarantees are unavailable. There is no separate `fsync` log or single-writer lock; Pi's storage owns durability. Summaries are read from documents on every request and stats call.
144
+ - Both views use four-line text blocks with a stable prefix. The compactor folds its own `compactBytes` view, independent of the chat's `viewBytes`. Earlier summary jobs take only lines wholly before their source from that same tiling, rather than unfolding an older checkpoint. Admission builds preceding parents before dispatching later requests whose context would exceed this budget. Pi 1.2's `Models.complete` accepts retention preferences, but `TextContent` exposes no per-block cache marker. Blocks are unmarked; provider-specific cache-hit guarantees are unavailable. The 600-message/100-turn prefix regression uses 128 KB for both views. Smaller compactor budgets spend a larger share on uncached source/scale text; a stable fold alone does not guarantee 95% cache reads for complete requests. Qualify the host's chosen budgets with its provider. There is no separate `fsync` log or single-writer lock; Pi's storage owns durability. Summaries are read from documents on every request and stats call.
145
145
  - Cost: the compactor makes about two calls per message, each carrying its small view as context. Choose a cheap summarizer model; the browser-agent example uses the chat model only because it has one.
146
146
  - The package runs in Node and in a browser (no Node-only imports). Fictional-data tests are in `test/packages/agent.test.mjs`; `examples/bot` and `examples/browser-agent` have journeys.
147
147
 
@@ -305,6 +305,8 @@ Each evidence entry must link to a completed native ToolTask, its original assis
305
305
 
306
306
  `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.
307
307
 
308
+ 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.
309
+
308
310
  ## Sub-agents and schedules
309
311
 
310
312
  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.
@@ -323,7 +325,7 @@ These follow [Pi Durable on Cloudflare](https://earendil.com/posts/pi-durable-on
323
325
 
324
326
  `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.
325
327
 
326
- - 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` 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.
328
+ - 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.
327
329
  - `.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.
328
330
  - `.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.
329
331
  - `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.
@@ -6,6 +6,9 @@ export declare const DEFAULTS: {
6
6
  readonly viewBytes: 128000;
7
7
  /** Independent view budget for summary requests. */
8
8
  readonly compactBytes: 32000;
9
+ /** Checkpoint thresholds, as fractions of each view budget. */
10
+ readonly foldHighWater: 1.25;
11
+ readonly foldLowWater: 0.75;
9
12
  /** Summarizer calls running at once. */
10
13
  readonly jobs: 64;
11
14
  /** Attempts per node to get a line under `nodeBytes`. */
@@ -49,7 +52,8 @@ export interface LeafOptions {
49
52
  /** Non-system message ordinals mapped to original leaf counts. Positional system updates must not shift tombstones. */
50
53
  readonly forgotten?: Readonly<Record<string, number>>;
51
54
  }
52
- export declare function leavesOf(messages: readonly Message[], cap?: number, options?: LeafOptions): {
55
+ /** Reuse prior leaves only after exact kind, capped-text and timestamp comparison; native contexts may contain fresh message objects. */
56
+ export declare function leavesOf(messages: readonly Message[], cap?: number, options?: LeafOptions, previous?: readonly Leaf[]): {
53
57
  leaves: Leaf[];
54
58
  before: number[];
55
59
  };
@@ -96,14 +100,16 @@ export interface ViewFold {
96
100
  T: number;
97
101
  size: number;
98
102
  parts: Part[];
103
+ /** Leaves already sent: interior placeholders stay frozen until a checkpoint. */
104
+ stableThrough?: number;
105
+ /** A checkpoint waiting for its required summaries; its due priorities keep the original leaf count. */
106
+ checkpoint?: boolean;
107
+ checkpointAt?: number;
99
108
  }
100
109
  export declare const emptyFold: () => ViewFold;
101
110
  export declare const partStart: (part: Pick<Part, "level" | "index">) => number;
102
- /**
103
- * Append leaves up to `to` (one part each, fitting after every append) and refit with what has been built since. The view
104
- * only ever appends at its end and coarsens, so its start stays the same from one request to the next (§5.2).
105
- */
106
- export declare function advanceFold(fold: ViewFold, tree: OptChatTree, to: number, viewBytes: number): ViewFold;
111
+ /** Append one leaf at a time; coarsen from the high mark to the low mark in a single checkpoint. */
112
+ export declare function advanceFold(fold: ViewFold, tree: OptChatTree, to: number, viewBytes: number, highWater?: number, lowWater?: number): ViewFold;
107
113
  /** The view's parts that still wait for a summary. */
108
114
  export declare const openParts: (fold: ViewFold) => Part[];
109
115
  /** One line per part, `id+n|text`, newlines as spaces, wrapped in `<chat>` (§5.1). */
@@ -5,6 +5,9 @@ export const DEFAULTS = {
5
5
  viewBytes: 128_000,
6
6
  /** Independent view budget for summary requests. */
7
7
  compactBytes: 32_000,
8
+ /** Checkpoint thresholds, as fractions of each view budget. */
9
+ foldHighWater: 1.25,
10
+ foldLowWater: 0.75,
8
11
  /** Summarizer calls running at once. */
9
12
  jobs: 64,
10
13
  /** Attempts per node to get a line under `nodeBytes`. */
@@ -72,13 +75,17 @@ export function capText(text, cap) {
72
75
  const head = Math.ceil(cap / 2), tail = Math.floor(cap / 2);
73
76
  return `${text.slice(0, head)}\n[… ${text.length - cap} characters cut …]\n${text.slice(text.length - tail)}`;
74
77
  }
75
- function leafOf(kind, text, at, cap) {
78
+ function leafOf(kind, text, at, cap, previous) {
76
79
  const capped = capText(text, cap);
80
+ if (previous?.kind === kind && previous.text === capped && previous.at === at)
81
+ return previous;
77
82
  const line = `${kind}: ${capped}`;
78
83
  return { kind, text: capped, line, bytes: byteLength(line), hash: hashText(line), ...(at === undefined ? {} : { at }) };
79
84
  }
80
- export function leavesOf(messages, cap = DEFAULTS.cap, options = {}) {
81
- const leaves = (options.notes ?? []).map(text => leafOf('note', text, undefined, cap)), before = [];
85
+ /** Reuse prior leaves only after exact kind, capped-text and timestamp comparison; native contexts may contain fresh message objects. */
86
+ export function leavesOf(messages, cap = DEFAULTS.cap, options = {}, previous = []) {
87
+ const leaves = (options.notes ?? []).map((text, index) => leafOf('note', text, undefined, cap, previous[index])), before = [];
88
+ const append = (kind, text, at) => leaves.push(leafOf(kind, text, at, cap, previous[leaves.length]));
82
89
  let messageIndex = 0;
83
90
  for (const message of messages) {
84
91
  before.push(leaves.length);
@@ -87,24 +94,24 @@ export function leavesOf(messages, cap = DEFAULTS.cap, options = {}) {
87
94
  const forgotten = options.forgotten?.[String(messageIndex++)];
88
95
  if (forgotten !== undefined) {
89
96
  for (let i = 0; i < forgotten; i++)
90
- leaves.push(leafOf('note', '(forgotten)', undefined, cap));
97
+ append('note', '(forgotten)', undefined);
91
98
  continue;
92
99
  }
93
100
  const at = typeof message.timestamp === 'number' ? message.timestamp : undefined;
94
101
  if (options.workOf?.(message))
95
- leaves.push(leafOf('work', textOf(message.content), at, cap));
102
+ append('work', textOf(message.content), at);
96
103
  else if (message.role === 'user')
97
- leaves.push(leafOf('user', textOf(message.content), at, cap));
104
+ append('user', textOf(message.content), at);
98
105
  else if (message.role === 'assistant') {
99
106
  const talk = message.content.filter(part => part.type === 'text').map(part => part.text).join('\n');
100
107
  if (talk.trim())
101
- leaves.push(leafOf('talk', talk, at, cap));
108
+ append('talk', talk, at);
102
109
  for (const part of message.content)
103
110
  if (part.type === 'toolCall')
104
- leaves.push(leafOf('tool', `${part.name} ${JSON.stringify(part.arguments)}`, at, cap));
111
+ append('tool', `${part.name} ${JSON.stringify(part.arguments)}`, at);
105
112
  }
106
113
  else if (message.role === 'toolResult')
107
- leaves.push(leafOf('echo', `${message.toolName}${message.isError ? ' (error)' : ''}: ${textOf(message.content)}`, at, cap));
114
+ append('echo', `${message.toolName}${message.isError ? ' (error)' : ''}: ${textOf(message.content)}`, at);
108
115
  }
109
116
  before.push(leaves.length);
110
117
  return { leaves, before };
@@ -193,7 +200,7 @@ const label = (part) => `${partStart(part)}+${span(part.level)}`;
193
200
  const lineBytes = (part, text) => byteLength(label(part)) + 1 + byteLength(flat(text)) + 1;
194
201
  function settleOpen(fold, tree) {
195
202
  for (const part of fold.parts) {
196
- if (!part.open)
203
+ if (!part.open || (!fold.checkpoint && partStart(part) < (fold.stableThrough ?? 0) && part !== fold.parts.at(-1)))
197
204
  continue;
198
205
  const text = tree.text(part.level, part.index);
199
206
  if (text === undefined)
@@ -204,37 +211,45 @@ function settleOpen(fold, tree) {
204
211
  part.open = false;
205
212
  }
206
213
  }
207
- /** Merge the most due adjacent pair until the view fits, never splitting; stop when no parent is built (§5.2). */
208
- function fit(fold, tree, viewBytes) {
209
- while (fold.size > viewBytes) {
214
+ /** Merge by due priority fixed at the checkpoint; never skip an unbuilt preferred parent. */
215
+ function fit(fold, tree, high, low) {
216
+ if (fold.size > high && !fold.checkpoint) {
217
+ fold.checkpoint = true;
218
+ fold.checkpointAt = fold.T;
219
+ }
220
+ if (!fold.checkpoint)
221
+ return;
222
+ settleOpen(fold, tree);
223
+ while (fold.size > low) {
210
224
  let best = -1, bestDue = -Infinity;
211
225
  for (let p = 0; p + 1 < fold.parts.length; p++) {
212
226
  const a = fold.parts[p], b = fold.parts[p + 1];
213
227
  if (a.level !== b.level || a.index % 2 !== 0 || b.index !== a.index + 1)
214
228
  continue;
215
- if (tree.text(a.level + 1, a.index / 2) === undefined)
216
- continue;
217
- const due = (fold.T - partStart(a)) / 2 ** (a.level + 2);
229
+ const due = ((fold.checkpointAt ?? fold.T) - partStart(a)) / 2 ** (a.level + 2);
218
230
  if (due > bestDue) {
219
231
  best = p;
220
232
  bestDue = due;
221
233
  }
222
234
  }
223
235
  if (best < 0)
224
- return;
236
+ break;
225
237
  const a = fold.parts[best], b = fold.parts[best + 1];
238
+ const text = tree.text(a.level + 1, a.index / 2);
239
+ if (text === undefined)
240
+ return;
226
241
  const merged = { level: a.level + 1, index: a.index / 2, bytes: 0, open: false };
227
- merged.bytes = lineBytes(merged, tree.text(merged.level, merged.index));
242
+ merged.bytes = lineBytes(merged, text);
228
243
  fold.size += merged.bytes - a.bytes - b.bytes;
229
244
  fold.parts.splice(best, 2, merged);
230
245
  }
246
+ fold.checkpoint = false;
247
+ delete fold.checkpointAt;
231
248
  }
232
- /**
233
- * Append leaves up to `to` (one part each, fitting after every append) and refit with what has been built since. The view
234
- * only ever appends at its end and coarsens, so its start stays the same from one request to the next (§5.2).
235
- */
236
- export function advanceFold(fold, tree, to, viewBytes) {
249
+ /** Append one leaf at a time; coarsen from the high mark to the low mark in a single checkpoint. */
250
+ export function advanceFold(fold, tree, to, viewBytes, highWater = 1, lowWater = 1) {
237
251
  settleOpen(fold, tree);
252
+ fit(fold, tree, viewBytes * highWater, viewBytes * lowWater);
238
253
  while (fold.T < to) {
239
254
  const index = fold.T++;
240
255
  const text = tree.text(0, index);
@@ -242,22 +257,21 @@ export function advanceFold(fold, tree, to, viewBytes) {
242
257
  part.bytes = lineBytes(part, text ?? PLACEHOLDER);
243
258
  fold.parts.push(part);
244
259
  fold.size += part.bytes;
245
- fit(fold, tree, viewBytes);
260
+ fit(fold, tree, viewBytes * highWater, viewBytes * lowWater);
246
261
  }
247
- fit(fold, tree, viewBytes);
248
262
  return fold;
249
263
  }
250
264
  /** The view's parts that still wait for a summary. */
251
265
  export const openParts = (fold) => fold.parts.filter(part => part.open);
252
266
  /** One line per part, `id+n|text`, newlines as spaces, wrapped in `<chat>` (§5.1). */
253
267
  export function renderView(fold, tree) {
254
- const lines = fold.parts.map(part => `${label(part)}|${flat(tree.text(part.level, part.index) ?? PLACEHOLDER)}`);
268
+ const lines = fold.parts.map(part => `${label(part)}|${flat(part.open ? PLACEHOLDER : tree.text(part.level, part.index) ?? PLACEHOLDER)}`);
255
269
  return `<chat>\n${lines.join('\n')}\n</chat>`;
256
270
  }
257
271
  /** The view's lines before `end` as bare text, one per line, with no ids: what a summarizer sees as context (§4.2). */
258
272
  export function bareLines(fold, tree, end) {
259
273
  return fold.parts.filter(part => partStart(part) + span(part.level) <= end)
260
- .map(part => flat(tree.text(part.level, part.index) ?? PLACEHOLDER)).join('\n');
274
+ .map(part => flat(part.open ? PLACEHOLDER : tree.text(part.level, part.index) ?? PLACEHOLDER)).join('\n');
261
275
  }
262
276
  /** zoom(id, n): line `id+n` opened into its two children, or message `id` whole when n is 1 (§7.1). */
263
277
  export function zoomText(tree, id, n) {
@@ -81,8 +81,11 @@ export interface OptChatOptions {
81
81
  readonly agentName?: string;
82
82
  readonly nodeBytes?: number;
83
83
  readonly viewBytes?: number;
84
- /** Budget of the compactor's separate, incrementally folded view. Default 32 000 UTF-8 bytes. */
84
+ /** Target budget of the compactor's separate checkpoint-folded view. Default 32 000 UTF-8 bytes. */
85
85
  readonly compactBytes?: number;
86
+ /** Checkpoint high/low marks as fractions of both view budgets. Defaults 1.25 / 0.75; set both to 1 for strict budgets. */
87
+ readonly foldHighWater?: number;
88
+ readonly foldLowWater?: number;
86
89
  /** Seed memories from the app brief or PM decisions. Captured at creation, stored once before this conversation's messages. */
87
90
  readonly notes?: readonly string[];
88
91
  /** Host classification of agent, subagent or job reports. A work message starts a turn like a user message. */
@@ -107,7 +110,7 @@ export interface OptChatStats {
107
110
  /** Stored summaries whose fingerprint matches the log. */
108
111
  readonly summaries: number;
109
112
  readonly view: {
110
- /** Bytes of the view's lines (what `budget` limits), without the `<chat>` wrapper. */
113
+ /** Bytes of the view's lines, without the `<chat>` wrapper; checkpoints start above budget × foldHighWater. */
111
114
  readonly bytes: number;
112
115
  readonly budget: number;
113
116
  /** Lines the next request would show, oldest first. `id` and `n` as in `id+n`. */
@@ -150,7 +153,7 @@ export interface OptChatStats {
150
153
  readonly logMessages: number;
151
154
  readonly viewBytes: number;
152
155
  readonly waitedMs: number;
153
- /** False if the wait timed out and the view shows placeholders. */
156
+ /** False while the view still shows placeholders, including frozen ones from an earlier timeout. */
154
157
  readonly settled: boolean;
155
158
  };
156
159
  }
@@ -167,7 +170,7 @@ export interface OptChatMemory {
167
170
  date(conversationId: ConversationId, id: number): Promise<string>;
168
171
  /** Forget a Pi context message by its zero-based position, including system positions. Atomically tombstones its leaves and deletes summaries that could depend on them, including through context. Does not delete Pi's session entry. */
169
172
  forgetMessage(conversationId: ConversationId, messageIndex: number): Promise<void>;
170
- /** Drop in-memory state of a conversation (stored summaries stay). */
173
+ /** Drop in-memory state of a conversation (stored summaries and the emitted tiling stay). */
171
174
  forget(conversationId: ConversationId): void;
172
175
  /** Stop this process's waits. Summary tasks belong to Pi, which keeps or recovers them; harmless to skip, and safe to call before closing the harness. */
173
176
  dispose(): Promise<void>;
@@ -27,6 +27,11 @@ export const OptChatEdits = defineDoc({
27
27
  kind: 'boring.memory.optchat.edits', version: 1, scope: 'conversation', history: 'latest', fork: 'current',
28
28
  initial: () => ({ revision: 0, forgotten: {} }),
29
29
  });
30
+ /** Only the emitted tiling and fingerprints are retained, never another copy of message or summary text. */
31
+ const OptChatFold = defineDocFamily({
32
+ kind: 'boring.memory.optchat.fold', version: 1, family: true, scope: 'conversation', history: 'latest', fork: 'current',
33
+ initial: () => ({ configuration: '', revision: -1, fingerprints: [], fold: null }),
34
+ });
30
35
  /** Compactor bookkeeping of one conversation: calls, spend and the last failure of each node, including its retry streak. */
31
36
  export const OptChatState = defineDoc({
32
37
  kind: 'boring.memory.optchat.state', version: 1, scope: 'conversation', history: 'latest', fork: 'initial',
@@ -59,6 +64,7 @@ class Session {
59
64
  }
60
65
  }
61
66
  const message = (text) => ({ role: 'user', content: text.map(part => ({ type: 'text', text: part })), timestamp: Date.now() });
67
+ const copyFold = (fold) => ({ ...fold, parts: fold.parts.map(part => ({ ...part })) });
62
68
  const LIVE = ['pending', 'running', 'waiting'];
63
69
  function redacted(item) {
64
70
  if (item.role === 'system')
@@ -104,6 +110,12 @@ export function createOptChatMemory(options) {
104
110
  const nodeBytes = options.nodeBytes ?? DEFAULTS.nodeBytes, viewBytes = options.viewBytes ?? DEFAULTS.viewBytes, jobs = options.jobs ?? DEFAULTS.jobs;
105
111
  const tries = options.tries ?? DEFAULTS.tries, cap = options.cap ?? DEFAULTS.cap, retryMs = options.retryMs ?? DEFAULTS.retryMs, settleMs = options.settleTimeoutMs ?? DEFAULTS.settleTimeoutMs;
106
112
  const compactBytes = options.compactBytes ?? DEFAULTS.compactBytes;
113
+ const highWater = options.foldHighWater ?? DEFAULTS.foldHighWater, lowWater = options.foldLowWater ?? DEFAULTS.foldLowWater;
114
+ if (!Number.isFinite(highWater) || !Number.isFinite(lowWater) || lowWater <= 0 || lowWater > highWater)
115
+ throw new Error('Invalid OptChat fold watermarks');
116
+ const configuration = JSON.stringify([nodeBytes, viewBytes, compactBytes, highWater, lowWater, cap]);
117
+ const foldView = (fold, tree, to, budget) => advanceFold(fold, tree, to, budget, highWater, lowWater);
118
+ const missingParts = (fold, tree) => openParts(fold).filter(part => tree.text(part.level, part.index) === undefined);
107
119
  const agentName = options.agentName ?? 'OptChat';
108
120
  const extensionName = options.name ?? 'boring.memory.optchat';
109
121
  const taskKind = `${extensionName}.node`;
@@ -142,11 +154,28 @@ export function createOptChatMemory(options) {
142
154
  throw new Error(`No conversation ${String(id)}`);
143
155
  return found;
144
156
  }
145
- /** The compactor has an independent fold. Copy before advancing: native tasks and requests can overlap. */
157
+ /** Earlier summary jobs take a prefix of the same tiling, never unfolding an already-sent checkpoint. */
146
158
  function compactView(s, to) {
147
- const cached = session(s.id).compactFold;
148
- const fold = cached.T > to ? emptyFold() : { T: cached.T, size: cached.size, parts: cached.parts.map(part => ({ ...part })) };
149
- return advanceFold(fold, s.tree, to, compactBytes);
159
+ return foldView(copyFold(s.compactFold), s.tree, to, compactBytes);
160
+ }
161
+ async function restoreFold(s, revision, to, kind = 'turn') {
162
+ const saved = await options.harness().snapshot(OptChatFold, s.id, kind, options.context);
163
+ if (saved?.fold && saved.configuration === configuration && saved.revision === revision && saved.fold.T <= to
164
+ && saved.fingerprints.every((fp, i) => { const part = saved.fold.parts[i]; return fp === s.log.fp(part.level, part.index); }))
165
+ s[kind === 'turn' ? 'fold' : 'compactFold'] = copyFold(saved.fold);
166
+ }
167
+ async function saveFold(s, revision, kind, fold, context) {
168
+ await (await conversationOf(s.id)).commit(async (tx) => {
169
+ if ((await tx.doc(OptChatEdits, s.id)).revision !== revision)
170
+ return;
171
+ const doc = await tx.doc(OptChatFold, s.id, kind, null);
172
+ if (kind === 'compact' && doc.configuration === configuration && doc.revision === revision && doc.fold && doc.fold.T > fold.T)
173
+ return;
174
+ doc.configuration = configuration;
175
+ doc.revision = revision;
176
+ doc.fold = copyFold(fold);
177
+ doc.fingerprints = fold.parts.map(part => s.log.fp(part.level, part.index));
178
+ }, context);
150
179
  }
151
180
  /** Bring a session up to date with the committed context and its stored summaries. */
152
181
  async function refresh(id) {
@@ -154,8 +183,10 @@ export function createOptChatMemory(options) {
154
183
  const view = await (await conversationOf(id)).context(options.context);
155
184
  const seed = await seedNotes(id);
156
185
  const edits = await memoryEdits(id);
157
- s.sync(leavesOf(view.messages, cap, logOptions(seed, edits)).leaves);
186
+ s.sync(leavesOf(view.messages, cap, logOptions(seed, edits), s.log.leaves).leaves);
158
187
  await loadStored(s, options.harness(), options.context);
188
+ await restoreFold(s, edits.revision, s.log.leaves.length);
189
+ await restoreFold(s, edits.revision, s.log.leaves.length, 'compact');
159
190
  return s;
160
191
  }
161
192
  async function memoryEdits(id) {
@@ -254,8 +285,9 @@ export function createOptChatMemory(options) {
254
285
  const load = async () => {
255
286
  const seed = (await runtime.snapshot(OptChatNotes, id, context))?.notes ?? [];
256
287
  const edits = await runtime.snapshot(OptChatEdits, id, context) ?? { revision: 0, forgotten: {} };
257
- s.sync(leavesOf((await runtime.context(id, context)).messages, cap, logOptions(seed, edits)).leaves);
288
+ s.sync(leavesOf((await runtime.context(id, context)).messages, cap, logOptions(seed, edits), s.log.leaves.length ? s.log.leaves : session(id).log.leaves).leaves);
258
289
  await loadStored(s, runtime, context);
290
+ await restoreFold(s, edits.revision, s.log.leaves.length, 'compact');
259
291
  return edits.revision;
260
292
  };
261
293
  const revision = await load();
@@ -263,13 +295,10 @@ export function createOptChatMemory(options) {
263
295
  if (!ready() || s.tree.text(level, index) !== undefined)
264
296
  return stale(); // the history changed, a child is missing, or the node needs no summary
265
297
  const to = Math.min(upTo, index * span(level));
266
- const shared = session(id);
267
- shared.sync(s.log.leaves);
268
298
  const fold = compactView(s, to);
269
- if (fold.size > compactBytes)
299
+ if (fold.size > compactBytes * highWater || fold.checkpoint)
270
300
  return stale(); // admission will build preceding parents before trying this node again
271
- if (fold.T >= shared.compactFold.T)
272
- shared.compactFold = fold;
301
+ await saveFold(s, revision, 'compact', fold, context);
273
302
  const source = level === 0 ? [s.log.leaves[index].line] : [s.tree.text(level - 1, 2 * index), s.tree.text(level - 1, 2 * index + 1)];
274
303
  const job = { conversationId: id, level, index, range: [index * span(level), (index + 1) * span(level) - 1], source, context: bareLines(fold, s.tree, to), nodeBytes };
275
304
  let summary;
@@ -314,8 +343,8 @@ export function createOptChatMemory(options) {
314
343
  /** Nodes that could be built now: sources exist and the whole preceding view is summarized (§4.1), oldest first. */
315
344
  function candidates(s) {
316
345
  const upTo = Math.min(s.upTo, s.log.leaves.length);
317
- advanceFold(s.fold, s.tree, upTo, viewBytes);
318
- const open = openParts(s.fold)[0];
346
+ foldView(s.fold, s.tree, upTo, viewBytes);
347
+ const open = missingParts(s.fold, s.tree)[0];
319
348
  const first = open ? partStart(open) : s.fold.T;
320
349
  const found = [];
321
350
  for (let level = 0; span(level) <= upTo; level++) {
@@ -327,7 +356,8 @@ export function createOptChatMemory(options) {
327
356
  continue;
328
357
  if (level > 0 && (s.tree.text(level - 1, 2 * index) === undefined || s.tree.text(level - 1, 2 * index + 1) === undefined))
329
358
  continue;
330
- if (compactView(s, index * span(level)).size > compactBytes)
359
+ const preceding = compactView(s, index * span(level));
360
+ if (preceding.size > compactBytes * highWater || preceding.checkpoint)
331
361
  break;
332
362
  found.push({ level, index });
333
363
  }
@@ -432,10 +462,12 @@ export function createOptChatMemory(options) {
432
462
  const deadline = Date.now() + settleMs, skip = new Set();
433
463
  for (;;) {
434
464
  await loadStored(s, options.harness(), options.context);
435
- advanceFold(s.fold, s.tree, covered, viewBytes);
436
- const open = openParts(s.fold).length;
437
- if (open === 0 && s.fold.size <= viewBytes)
438
- return true;
465
+ if (s.fold.stableThrough === undefined)
466
+ s.fold = emptyFold();
467
+ foldView(s.fold, s.tree, covered, viewBytes);
468
+ const open = missingParts(s.fold, s.tree).length;
469
+ if (open === 0 && s.fold.size <= viewBytes * highWater && !s.fold.checkpoint)
470
+ return openParts(s.fold).length === 0;
439
471
  const { live } = await admit(s, skip);
440
472
  if (open === 0 && live.size === 0)
441
473
  return true;
@@ -515,18 +547,17 @@ export function createOptChatMemory(options) {
515
547
  for (;;) {
516
548
  const seed = await seedNotes(api.conversationId);
517
549
  const edits = await memoryEdits(api.conversationId);
518
- const { leaves, before } = leavesOf(request.messages, cap, logOptions(seed, edits));
550
+ const cached = session(api.conversationId);
551
+ const { leaves, before } = leavesOf(request.messages, cap, logOptions(seed, edits), cached.log.leaves);
519
552
  const last = request.messages.findLastIndex(item => item.role === 'user' || (item.role !== 'system' && options.workOf?.(item)));
520
553
  const covered = last < 0 ? 0 : before[last];
521
554
  if (covered === 0 && Object.keys(edits.forgotten).length === 0 && !request.messages.some(item => options.workOf?.(item)))
522
555
  return undefined;
523
- const cached = session(api.conversationId);
524
556
  cached.sync(leaves);
525
557
  const s = new Session(api.conversationId, nodeBytes);
526
558
  s.sync(leaves);
527
559
  s.upTo = covered;
528
- if (cached.fold.T <= covered)
529
- s.fold = { T: cached.fold.T, size: cached.fold.size, parts: cached.fold.parts.map(part => ({ ...part })) };
560
+ await restoreFold(s, edits.revision, covered);
530
561
  const started = Date.now();
531
562
  const settled = await settle(s, covered, context);
532
563
  if ((await memoryEdits(api.conversationId)).revision !== edits.revision)
@@ -556,6 +587,10 @@ export function createOptChatMemory(options) {
556
587
  return [{ ...current, content: [...blocks, ...body] }];
557
588
  });
558
589
  cached.lastRequest = { at: new Date().toISOString(), covered, requestMessages: messages.length, logMessages: request.messages.length, viewBytes: byteLength(view), waitedMs: Date.now() - started, settled };
590
+ s.fold.stableThrough = s.fold.T;
591
+ await saveFold(s, edits.revision, 'turn', s.fold, context);
592
+ if ((await memoryEdits(api.conversationId)).revision !== edits.revision)
593
+ continue;
559
594
  cached.fold = s.fold;
560
595
  return { messages };
561
596
  }
@@ -575,8 +610,8 @@ export function createOptChatMemory(options) {
575
610
  nap: id => nap(id),
576
611
  async stats(id) {
577
612
  const s = await refresh(id);
578
- const fold = { T: s.fold.T, size: s.fold.size, parts: s.fold.parts.map(part => ({ ...part })) };
579
- advanceFold(fold, s.tree, s.log.leaves.length, viewBytes);
613
+ const fold = copyFold(s.fold);
614
+ foldView(fold, s.tree, s.log.leaves.length, viewBytes);
580
615
  let summaries = 0;
581
616
  for (const [key, node] of s.stored) {
582
617
  const [level, index] = key.split(':').map(Number);
@@ -591,7 +626,7 @@ export function createOptChatMemory(options) {
591
626
  return {
592
627
  leaves: s.log.leaves.length, summaries,
593
628
  view: { bytes: fold.size, budget: viewBytes, open: openParts(fold).length,
594
- parts: fold.parts.map(part => ({ id: partStart(part), n: span(part.level), level: part.level, text: s.tree.text(part.level, part.index) ?? '', open: part.open })) },
629
+ parts: fold.parts.map(part => ({ id: partStart(part), n: span(part.level), level: part.level, text: part.open ? '' : s.tree.text(part.level, part.index) ?? '', open: part.open })) },
595
630
  compactor: { busy, retrying, failed: failures.length, idle: busy === 0 && retrying === 0, ...(latest ? { error: latest.error } : {}), calls: state?.calls ?? 0, usage: { ...(state?.usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }) } },
596
631
  ...(s.lastRequest ? { lastRequest: s.lastRequest } : {}),
597
632
  };
@@ -1,5 +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
4
  import { jsonSchemaTool } from './json-schema-tool.js';
4
5
  /*
5
6
  * Self-evolution (docs/architecture/SELF-EVOLUTION.md, laws SELF-1..4 in this package's README): an agent keeps its own standing
@@ -102,6 +103,27 @@ async function filesIn(env, directory, suffix, context) {
102
103
  return listed.error.code === 'not_found' ? 'missing' : { error: listed.error.message };
103
104
  return listed.value.filter(entry => entry.kind === 'file' && entry.name.endsWith(suffix)).map(entry => `${directory}/${entry.name}`).sort();
104
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
+ }
105
127
  export function createSelfEvolution(options) {
106
128
  const { name, parseSkill, skillsExtension } = options;
107
129
  const hostSkills = [...options.skills];
@@ -113,7 +135,7 @@ export function createSelfEvolution(options) {
113
135
  name: 'reload',
114
136
  description: `Apply the changes you made to your own ${AGENT_FOLDER}/ folder in the workspace, then return what was added, changed and removed and every load error. `
115
137
  + `${INSTRUCTIONS}: standing instructions for yourself, shown after the host's instructions on every request. `
116
- + `${SKILLS}/<name>.md: skills (front matter with name and description between --- lines, then the instructions), loaded with load_skill. `
138
+ + `${SKILLS}/<name>.md or ${SKILLS}/<name>/SKILL.md: skills (front matter with name and description between --- lines, then the instructions), loaded with load_skill; paths in a skill are relative to its folder and read with read. `
117
139
  + `${TOOLS}/<name>.json: your own tools, {"name": "snake_case", "description": "...", "parameters": {a JSON Schema with "type": "object"}, "run": "a command run in the workspace, for example sh ${TOOLS}/<name>.sh"}; `
118
140
  + 'the arguments arrive as one line of JSON on standard input and the command output is the result. Restore or delete the files and reload to undo a change.',
119
141
  parameters: Type.Object({}, { additionalProperties: false }),
@@ -147,10 +169,8 @@ export function createSelfEvolution(options) {
147
169
  else if (read.error.code !== 'not_found')
148
170
  errors.push({ path: INSTRUCTIONS, message: read.error.message });
149
171
  const skills = [];
150
- const skillFiles = await filesIn(env, SKILLS, '.md', context);
151
- if (typeof skillFiles === 'object' && !Array.isArray(skillFiles))
152
- errors.push({ path: SKILLS, message: skillFiles.error });
153
- for (const path of Array.isArray(skillFiles) ? skillFiles : []) {
172
+ const skillFiles = await skillFilesIn(env, context, errors);
173
+ for (const path of skillFiles) {
154
174
  const text = await env.readTextFile(path, context);
155
175
  if (!text.ok) {
156
176
  errors.push({ path, message: text.error.message });
@@ -168,7 +188,7 @@ export function createSelfEvolution(options) {
168
188
  errors.push({ path, message: `a skill named ${skill.name} already exists` });
169
189
  continue;
170
190
  }
171
- skills.push(skill);
191
+ skills.push(skillInFolder(skill, path.slice(0, path.lastIndexOf('/'))));
172
192
  }
173
193
  const available = typeof env.exec === 'function';
174
194
  const tools = [];
package/dist/skills.d.ts CHANGED
@@ -7,6 +7,7 @@ export interface Skill {
7
7
  readonly description: string;
8
8
  readonly body: string;
9
9
  }
10
+ export declare function skillInFolder(skill: Skill, folder: string): Skill;
10
11
  /** Parse a `SKILL.md`-style document: `---` front matter with `name` and `description`, then the body. */
11
12
  export declare function parseSkill(markdown: string): Skill;
12
13
  export declare function validSkill(skill: Skill): Skill;
package/dist/skills.js CHANGED
@@ -2,6 +2,11 @@ import { defineExtension, defineTool } from '@earendil-works/pi-durable';
2
2
  import { Type } from '@earendil-works/pi-ai';
3
3
  /** Names of skills and agents: a lowercase letter or digit, then lowercase letters, digits, `.`, `_` or `-` (at most 64). */
4
4
  export const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
5
+ const skillFolders = new WeakMap();
6
+ export function skillInFolder(skill, folder) {
7
+ skillFolders.set(skill, folder);
8
+ return skill;
9
+ }
5
10
  /** Parse a `SKILL.md`-style document: `---` front matter with `name` and `description`, then the body. */
6
11
  export function parseSkill(markdown) {
7
12
  const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(markdown);
@@ -23,7 +28,9 @@ export function validSkill(skill) {
23
28
  throw new TypeError(`Invalid skill name: ${skill.name}`);
24
29
  if (!skill.description.trim() || !skill.body.trim())
25
30
  throw new TypeError(`Skill ${skill.name} needs a description and a body`);
26
- return Object.freeze({ name: skill.name, description: skill.description.trim(), body: skill.body });
31
+ const valid = Object.freeze({ name: skill.name, description: skill.description.trim(), body: skill.body });
32
+ const folder = skillFolders.get(skill);
33
+ return folder === undefined ? valid : skillInFolder(valid, folder);
27
34
  }
28
35
  /** A prompt section listing the skills and a `load_skill` tool returning one body. Usable without `defineAgent`. */
29
36
  export function createSkillsExtension(name, skills) {
@@ -44,7 +51,9 @@ export function createSkillsExtension(name, skills) {
44
51
  replay: 'safe',
45
52
  execute: async (args) => {
46
53
  const skill = known.get(args.name);
47
- return { output: [{ type: 'text', text: skill ? skill.body : `Unknown skill "${args.name}". Available: ${[...known.keys()].join(', ')}` }] };
54
+ const folder = skill && skillFolders.get(skill);
55
+ const location = folder === undefined ? '' : `\n\nThis skill's folder is \`${folder}\`; paths in it are relative to that folder.`;
56
+ return { output: [{ type: 'text', text: skill ? skill.body + location : `Unknown skill "${args.name}". Available: ${[...known.keys()].join(', ')}` }] };
48
57
  },
49
58
  })],
50
59
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-agent",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -154,8 +154,8 @@
154
154
  "@earendil-works/pi-durable": "~1.2.0",
155
155
  "@earendil-works/pi-ai": "~1.2.0",
156
156
  "@earendil-works/chord": "~1.2.0",
157
- "@hachej/boring-files": "^0.4.4",
158
- "@hachej/boring-ui-kit": "^0.4.4"
157
+ "@hachej/boring-files": "^0.4.6",
158
+ "@hachej/boring-ui-kit": "^0.4.6"
159
159
  },
160
160
  "peerDependenciesMeta": {
161
161
  "@hachej/boring-files": {