@hachej/boring-agent 0.1.111 → 0.2.1

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.
Files changed (156) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +262 -51
  3. package/dist/agents.d.ts +75 -0
  4. package/dist/agents.js +91 -0
  5. package/dist/approval.d.ts +71 -0
  6. package/dist/approval.js +126 -0
  7. package/dist/artifacts.d.ts +53 -0
  8. package/dist/artifacts.js +129 -0
  9. package/dist/ask-user.d.ts +40 -0
  10. package/dist/ask-user.js +134 -0
  11. package/dist/attachment.d.ts +5 -0
  12. package/dist/attachment.js +4 -0
  13. package/dist/browser-task.d.ts +32 -0
  14. package/dist/browser-task.js +119 -0
  15. package/dist/chat-transport.d.ts +80 -0
  16. package/dist/chat-transport.js +306 -0
  17. package/dist/contracts.d.ts +40 -0
  18. package/dist/contracts.js +1 -0
  19. package/dist/conversations.d.ts +136 -0
  20. package/dist/conversations.js +366 -0
  21. package/dist/definitions.d.ts +37 -0
  22. package/dist/definitions.js +99 -0
  23. package/dist/delivery-input.d.ts +35 -0
  24. package/dist/delivery-input.js +56 -0
  25. package/dist/delivery.d.ts +33 -0
  26. package/dist/delivery.js +154 -0
  27. package/dist/durable-wait.d.ts +23 -0
  28. package/dist/durable-wait.js +79 -0
  29. package/dist/file-convert.d.ts +46 -0
  30. package/dist/file-convert.js +159 -0
  31. package/dist/file-guard.d.ts +50 -0
  32. package/dist/file-guard.js +177 -0
  33. package/dist/file-types.d.ts +17 -0
  34. package/dist/file-types.js +95 -0
  35. package/dist/gateway-provider.d.ts +24 -0
  36. package/dist/gateway-provider.js +42 -0
  37. package/dist/git.d.ts +34 -0
  38. package/dist/git.js +79 -0
  39. package/dist/harness-pool.d.ts +38 -0
  40. package/dist/harness-pool.js +134 -0
  41. package/dist/index.d.ts +4 -0
  42. package/dist/index.js +1 -0
  43. package/dist/json-schema-tool.d.ts +25 -0
  44. package/dist/json-schema-tool.js +19 -0
  45. package/dist/memory/optchat-core.d.ts +118 -0
  46. package/dist/memory/optchat-core.js +317 -0
  47. package/dist/memory/optchat.d.ts +156 -0
  48. package/dist/memory/optchat.js +432 -0
  49. package/dist/mentions.d.ts +56 -0
  50. package/dist/mentions.js +140 -0
  51. package/dist/metering.d.ts +183 -0
  52. package/dist/metering.js +443 -0
  53. package/dist/model-gateway.d.ts +86 -0
  54. package/dist/model-gateway.js +241 -0
  55. package/dist/native-version.d.ts +9 -0
  56. package/dist/native-version.js +7 -0
  57. package/dist/native.d.ts +4 -0
  58. package/dist/native.js +1 -0
  59. package/dist/presentation.d.ts +17 -0
  60. package/dist/presentation.js +39 -0
  61. package/dist/projection-format.d.ts +68 -0
  62. package/dist/projection-format.js +118 -0
  63. package/dist/projection-window.d.ts +9 -0
  64. package/dist/projection-window.js +93 -0
  65. package/dist/projection.d.ts +22 -0
  66. package/dist/projection.js +153 -0
  67. package/dist/question-response.d.ts +20 -0
  68. package/dist/question-response.js +75 -0
  69. package/dist/questions.d.ts +74 -0
  70. package/dist/questions.js +199 -0
  71. package/dist/self-evolving.d.ts +55 -0
  72. package/dist/self-evolving.js +239 -0
  73. package/dist/skills.d.ts +15 -0
  74. package/dist/skills.js +51 -0
  75. package/dist/tool-evidence.d.ts +14 -0
  76. package/dist/tool-evidence.js +54 -0
  77. package/dist/validation.d.ts +38 -0
  78. package/dist/validation.js +68 -0
  79. package/dist/workspaces.d.ts +107 -0
  80. package/dist/workspaces.js +212 -0
  81. package/package.json +145 -150
  82. package/dist/DebugDrawer-JNA43SVR.js +0 -8
  83. package/dist/admin-DORVQfLn.d.ts +0 -340
  84. package/dist/chunk-2BGLZZ7T.js +0 -10
  85. package/dist/chunk-42D6KAQV.js +0 -2882
  86. package/dist/chunk-4XOW57KJ.js +0 -343
  87. package/dist/chunk-72KT6VWV.js +0 -1338
  88. package/dist/chunk-7XAOVFSN.js +0 -28
  89. package/dist/chunk-KJIEMSQP.js +0 -198
  90. package/dist/chunk-KN72XWP7.js +0 -394
  91. package/dist/chunk-N6THTH7P.js +0 -110
  92. package/dist/chunk-V3H5A3BR.js +0 -22501
  93. package/dist/chunk-YGRQ4Q5P.js +0 -20
  94. package/dist/chunk-YTUZFKB3.js +0 -78
  95. package/dist/core/index.d.ts +0 -28
  96. package/dist/core/index.js +0 -617
  97. package/dist/createHarness-DOLOHA62.js +0 -18
  98. package/dist/eval/index.d.ts +0 -247
  99. package/dist/eval/index.js +0 -541
  100. package/dist/file-search-BUgMSKZE.d.ts +0 -289
  101. package/dist/front/artifacts.d.ts +0 -34
  102. package/dist/front/artifacts.js +0 -21
  103. package/dist/front/index.d.ts +0 -1082
  104. package/dist/front/index.js +0 -12912
  105. package/dist/front/styles.css +0 -5380
  106. package/dist/harness-qHlMJhZo.d.ts +0 -276
  107. package/dist/middleware-CaAEXpi5.d.ts +0 -115
  108. package/dist/piChatEvent-DpE0lIb9.d.ts +0 -450
  109. package/dist/piChatSessionService-CBct-hmz.d.ts +0 -86
  110. package/dist/sandbox-BMhut3kh.d.ts +0 -317
  111. package/dist/server/agent-host/testing/compositionRouteProof.d.ts +0 -8
  112. package/dist/server/agent-host/testing/compositionRouteProof.js +0 -78
  113. package/dist/server/agent-host/testing/gatewayConformance.d.ts +0 -43
  114. package/dist/server/agent-host/testing/gatewayConformance.js +0 -605
  115. package/dist/server/index.d.ts +0 -2278
  116. package/dist/server/index.js +0 -274
  117. package/dist/server/pi-session-readability.d.ts +0 -12
  118. package/dist/server/pi-session-readability.js +0 -23
  119. package/dist/server/worker/index.d.ts +0 -68
  120. package/dist/server/worker/index.js +0 -377
  121. package/dist/share-entry-CxS6UJ_L.d.ts +0 -330
  122. package/dist/shared/index.d.ts +0 -2111
  123. package/dist/shared/index.js +0 -262
  124. package/dist/types-DcWoQMn8.d.ts +0 -1377
  125. package/docs/ACCESSIBILITY.md +0 -52
  126. package/docs/AGENT_GATEWAY_V0.md +0 -212
  127. package/docs/AGENT_PACKAGES.md +0 -90
  128. package/docs/API.md +0 -123
  129. package/docs/AUTHORING.md +0 -68
  130. package/docs/CSP.md +0 -41
  131. package/docs/ERROR_CODES.md +0 -229
  132. package/docs/KNOWN_LIMITATIONS.md +0 -100
  133. package/docs/MIGRATION.md +0 -50
  134. package/docs/PERFORMANCE.md +0 -68
  135. package/docs/PLUGINS.md +0 -138
  136. package/docs/README.md +0 -98
  137. package/docs/RISKS-MULTI-TAB.md +0 -46
  138. package/docs/SESSION_IDENTITY.md +0 -91
  139. package/docs/STYLING.md +0 -73
  140. package/docs/UI-SHADCN.md +0 -56
  141. package/docs/VERCEL_COSTS.md +0 -103
  142. package/docs/plans/archive/AGENT_EVAL_FRAMEWORK.md +0 -466
  143. package/docs/plans/archive/agent-package-spec.md +0 -1777
  144. package/docs/plans/archive/harness-followup-capabilities.md +0 -440
  145. package/docs/plans/archive/harness-tool-ui-capabilities.md +0 -144
  146. package/docs/plans/archive/pi-followup-history-projection.md +0 -229
  147. package/docs/plans/archive/pi-tools-migration.md +0 -1061
  148. package/docs/plans/archive/reviews/pi-followup-history-codex-review.md +0 -11
  149. package/docs/plans/archive/reviews/pi-followup-history-gpt-review.md +0 -64
  150. package/docs/plans/archive/reviews/pi-followup-history-opus-review.md +0 -43
  151. package/docs/plans/archive/reviews/pi-followup-history-xai-review.md +0 -43
  152. package/docs/plans/archive/vercel-base-snapshot-template-plan.md +0 -527
  153. package/docs/plans/archive/vercel-persistent-sandbox-adapter.md +0 -553
  154. package/docs/runtime-provisioning.md +0 -157
  155. package/docs/runtime.md +0 -296
  156. package/docs/tools.md +0 -102
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 boringdata
3
+ Copyright (c) 2026 Julien Hurault
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,78 +1,289 @@
1
1
  # @hachej/boring-agent
2
2
 
3
- <div align="center">
3
+ /native re-exports exact Pi handles, configuration, task/view types and native ToolRegistration/ToolExecutionApi/ToolExecutionResult/hooks. Use upstream defineTool, registry installation and runtime values directly. There is no parallel Boring HostOperation/RuntimeSchema/tool engine.
4
4
 
5
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
- [![npm](https://img.shields.io/npm/v/@hachej/boring-agent.svg)](https://www.npmjs.com/package/@hachej/boring-agent)
5
+ /contracts contains only borrowed attachment and original runtime-question/delivery references. Passive attachment takes the native Harness, not a second possibly unrelated writable Registry. Host subtypes retain their native handle. Domain services keep their own signatures; no file package is required to configure an agent.
7
6
 
8
- </div>
7
+ `attachHarness({ harness })` preserves the exact host handle. It acquires no observers or resources, so detaching is passive and never closes the host. Use native observation APIs directly and release their subscriptions under their own owner.
9
8
 
10
- A pane-embeddable coding agent: an LLM agent loop, a tool catalog, and a chat UI
11
- behind one interface, with three swappable execution modes. The same agent,
12
- tools, and UI run in `direct` (host process), `local` (bwrap sandbox), or
13
- `vercel-sandbox` (Firecracker microVM) mode.
9
+ ## One file tool set: the guard
14
10
 
15
- ## Install
11
+ The agent edits files with Pi's own `read`, `write`, `edit` and `bash` over the workspace's `ExecutionEnv`; there are no document or artifact tools of Boring's. `createFileGuard({ workspace, resolveAccess?, name? })` from `@hachej/boring-agent/file-guard` returns a native extension (named `name`, default `boring.files.guard`: two agents with different workspaces in one registry need two names, or the last installed guard wraps both) that wraps those three tools with the public `wrapTool` (select it after the extension that registers them). Every wrapped call runs inside the workspace provider's mutation queue (`@hachej/boring-files/workspace`), so a viewer save cannot land between `edit`'s internal read and its write. `workspace` is the call's workspace resolved like Pi's environment, or one binding `{ files, root, access? }` (see [Workspace per call](#workspace-per-call)); `resolveAccess` defaults to the binding's `access`. A genuine `read` call records the file's revision as the conversation's last-read revision of that workspace and path (`baselineKey`) in a native conversation document (`lastReadRevisions`, kept across restarts; a fork starts empty); `edit`'s internal read records nothing. `write` and `edit` of a file that exists are refused unless the conversation has read it and its revision still equals the recorded one, and the refusal tells the model to read the file again. Creating a file is allowed only while it is absent. The guard fails closed: an unresolvable workspace, a refused provider read, a path outside the root, a link that leads outside it and a path that does not exist (so Pi's read never falls back to another spelling of it, such as an NFD name) (resolved through the env's `canonicalPath`, so also a new file under a link directory) are refused for `read` as well as `write` and `edit`; nothing falls through to Pi's native tool. After a successful `write` or `edit` the baseline is the revision just written, and the guard calls the provider's `record` (owner ruling 2: observed, never receipted): the file's history gains the written revision with source `agent` (and the bytes it replaced, read before the tool ran when that revision is not already retained), and the workspace's change feed an `agent` event with the conversation. Nothing enters the publication journal. Pi's `edit` tries an exact match first and falls back to a fuzzy one (Unicode normalisation, trailing whitespace, smart quotes, dashes and special spaces); the guard does not change that. A `bash` write is not intercepted, but it changes the revision, so the next `write` or `edit` of that file is refused until it is read; it reaches the change feed (no history) through the file system's watcher or the host's `poll()` at turn end. The guard adds no strictness option and no file API of its own.
16
12
 
17
- ```bash
18
- pnpm add @hachej/boring-agent
19
- # or: npm install @hachej/boring-agent
20
- ```
13
+ ## Workspace per call
14
+
15
+ `@hachej/boring-agent/workspaces` resolves the workspace of each call the way Pi resolves its environment, so one harness and one agent definition serve several people, each conversation in its own workspace. A `WorkspaceResolver` is `(target, context) => binding | undefined` with Pi's `EnvTarget`; a binding is `{ id?, files, root, env?, access? }` (`files` the workspace provider, `root` as the env names it, `env` the instance Pi uses, `access` the agent's principal). The guard, `present` and host tools (the studio's canvas tools) call `workspaceFor(resolver, api, context)`, which builds the target from the call (`callTarget`) and refuses a binding whose `env` is not the call's `api.env`. `constantWorkspace(binding)` is the single-workspace case; passing a binding where a resolver is expected wraps it. The first-class way needs no resolver at all: `withWorkspace(env, binding)` attaches a binding to an env (every `createWorkspaceCache` entry does it), and a tool given no `workspace` resolves `workspaceOfEnv(api.env)`, the workspace of the env the host's `HarnessOptions.env` returned for that call, so the host wires one function. A binding may also carry the workspace's git `repository` (`working_git` uses it).
16
+
17
+ `createWorkspaceCache({ key, open, atCwd?, idleMs?, busy?, onError? })` gives a host both sides from one entry per key: `env` for `HarnessOptions.env`, `workspace` for the tools, `acquire(key)` for viewer and HTTP routes after authentication (the lease's `release` never closes), `keys()` and `close()`. A workspace opens once per key, and closes after `idleMs` with no lease when `busy(key)` is not true. `rootConversation(session, id, context)` follows the owner edge of a task-owned conversation (a subagent's child) to the conversation whose workspace it uses. A `CredentialResolver` `(target, context, { tool, server? })` is the host's per-call secret lookup; tools call it at execute time and keep the value in memory only.
18
+
19
+ For a conversation with a different working directory, supply `atCwd(workspace, cwd, context)` to return an independent native environment over the same workspace. The cache retains that view per directory and attaches the original provider and access binding. The workspace owner closes any resources the callback acquires. Without this callback, a different directory is refused. Never mutate the shared environment's `cwd` or start a second remote workspace to create a directory view.
20
+
21
+ `createHarnessPool({ create, context, idleMs?, onError? })` from `@hachej/boring-agent/harness-pool` is the alternative: one harness per owner, opened by the host's `create(owner)` on first use (never shared, concurrent first uses open it once), held by `acquire`/`use`, by `respond` until a streaming response body ends, and closed once idle with no live task or submission. `owners()` and `opened()` read what is open without holding it. Choosing between them: [FILES-GIT-EXEC.md](../../docs/architecture/FILES-GIT-EXEC.md#workspace-per-call).
22
+
23
+ ## Conversations
24
+
25
+ `@hachej/boring-agent/conversations` is a conversation list over native Pi. `createConversations({ harness, context, now?, titleLength?, previewLength?, onError? })` keeps one native conversation document per conversation (`conversationMetadata`, kind `boring.conversation.metadata`, `fork: 'initial'`): the host's `owner` key, a `title` (set by `create`/`rename`, otherwise derived once from the first user message), `createdAt`, `updatedAt` (last activity), a bounded `lastMessage` preview of the last user or assistant text (mention `<file>` blocks excluded), `archived` and `deletedAt`. The metadata of a turn is written after its entries commit: the module observes `harness.subscribeCommits` (no polling, no transcript scan) and one queued writer applies the latest message of each conversation. Only conversations it created (or `adopt`ed) are managed; any other conversation, including a native fork made elsewhere, is never listed.
26
+
27
+ `create(owner, { title?, start? })` writes the metadata in the creating commit (`start(init)` creates the native conversation with the host's agent, for example `definition.createConversation(harness, context, { init })`). `list({ owner, archived?, query?, limit?, cursor? })` returns the owner's summaries newest activity first; `query` is a case-insensitive substring of the title or the last message. `rename`, `archive(owner, id, archived)`, `get`, `open` and `fork(owner, id, at)` (native `Conversation.fork`, the history up to and including entry `at`, titled "<title> (fork)") refuse another owner's or a deleted conversation by returning `undefined`. `delete` aborts the conversation's ordinary work and sets `deletedAt`: Pi has no conversation deletion, so the records stay in the session storage and their retention is the host's (prune or drop the storage). `adopt(id, owner)` manages an existing conversation once (title and preview from a bounded history read), for migrating a host's own index. Owner scoping and authorization stay with the host: `createConversationsHandler({ conversations, authenticate })` is a Fetch handler (`GET` lists with `?archived=1|all&q=&limit=&cursor=`; `POST ?op=create|rename|archive|delete|fork` with a JSON body) whose `authenticate(request)` returns `{ owner, start?, opened?, deleted?, allow? }` or null.
28
+
29
+ `conversationTitle(conversation, context, cache?)` is the title of a conversation that has none stored: its first user message, clipped to 80 characters. The first message never changes, so a non-null title is cached (`cache`: a `Map` keyed by the conversation id) and the history is read once; `null` (no user message yet) is not cached. A host's list poll passes its cache rather than paging the history again.
30
+
31
+ Listing reads every conversation record (`scanConversations`) and one metadata document each: Pi's storage has no query over document values or text index, so search is an in-memory substring match. A host with many conversations keeps its own index.
32
+
33
+ ## Mentions
34
+
35
+ `createMentionResolver({ read, mode?, limits? })` from `@hachej/boring-agent/mentions` turns the `@path` mentions of a submitted message into parts the host adds before submission. The default `mode: 'inline'` puts the text of each file (and images) in the message; documents (PDF, office, zip, epub) are never decoded as text. With `mode: 'reference'` each mention is one `<file path type size />` part and no content, so uploads do not fill the context: the agent reads the file with its tools (Pi `read`, which `createConvertingRead` from `@hachej/boring-agent/file-convert` extends to PDF, office and images) when it needs to.
36
+
37
+ ## Model gateway
38
+
39
+ `@hachej/boring-agent/model-gateway` (server only: it holds the provider key) is `createModelGateway({ upstream, allow, budget, authorize, log?, basePath? })`, a Fetch handler for `POST <basePath>/v1/chat/completions` (OpenAI-compatible, JSON or SSE passed through) and `GET <basePath>/v1/models`. `authorize(request)` returns the person's id from the app session (else 401); `allow` lists gateway model ids or maps them to upstream ids (else 403); `budget.admit` refuses (429, `Retry-After`) or admits with the output ceiling to send (`memoryBudget({ requestsPerMinute, maxTokensPerRequest })` is the in-process reference). The forwarded body is rebuilt from an allowlist of fields and no request header is forwarded; the upstream (`openAICompatibleUpstream`, `anthropicUpstream`, keyless `scriptedUpstream`) adds the key. Upstream failures are 502 with the gateway's own message, never the upstream body. `log` receives person, model, status, code, latency and token counts only. It imports no node or Pi module, so the same code can run as a shared service.
40
+
41
+ `@hachej/boring-agent/gateway-provider` is the browser half: `gatewayProvider({ baseUrl, models, getAuth })` returns a native Pi provider (Pi's `openai-completions` API at `<baseUrl>/v1`) whose only credential is `getAuth()`'s session headers, read on every request. Use it with `createModels().setProvider(...)` and any native model call.
42
+
43
+ ## Metering
44
+
45
+ `@hachej/boring-agent/metering` ports the v2 credits contract onto native Pi. The host owns the ledger, the price and the policy (INVARIANTS: budgets are host data); the module decides only when to call the ledger. Pi has no hook that can hold or refuse a model request (`beforeRequest` only replaces messages, `afterResponse` observes), so the fence is submission admission, and usage is read from the durable transcript, not from a second usage store (SPEC "Model admission, usage and settings").
46
+
47
+ - Scope (host-owned): `MeteringScope` is `{ userId, workspaceId?, attributes? }`, the host's mapping of its own authentication (attributes: at most 16 flat JSON-safe values such as a plan or tenant, passed through untouched). Every sink call carries the run scope: that plus `conversationId`, `requestId` and `runId`.
48
+ - `MeteringSink` (v2's `AgentMeteringSink`): `isEnabled?`, `reserveRun(input)` → `{ reservationId }` or `{ kind: 'refused', reason }` (a throw also refuses: fail closed), `recordUsage(input)` → `{ billedMicros }`, `settleRun`, `releaseRun`, and `openRuns()` for recovery. All idempotent on `runId` and `usageId`. The reservation also carries `model: { provider, id }`, the model the run will use (the conversation's selected model resolved through Pi's `Conversation.agent` at submit time, so a host can admit per model as v2 governance did), `kind` (`input`, or the input's native `whenBusy` choice `followUp` / `steer`), a `message` preview (text, whitespace collapsed, at most `MESSAGE_PREVIEW_LENGTH` = 280 characters) and the raw `content`. Usage keeps its own per-entry `model` (a run can switch models).
49
+ - `createMeter({ sink, context, price?, models?, markup?, onError? })`. `meter.conversation(conversation, scope)` is the native conversation with an input `submit` that reserves first; a refusal throws `MeteringRefused` and nothing is submitted (the chat transport answers it with 402 `submission-refused` and the ledger's message; `@hachej/boring-ui-kit/remote-chat` turns that into a blocked send showing the message). The run id is `run:<conversation>:<requestId>`, so a client retry reserves nothing new.
50
+ - Recording: on every conversation change the meter reads each open run's submission record and the entries from its user entry to the next placed input; every assistant entry (and tool result) carrying `usage` is recorded once under `entry:<conversation>:<entry>` (the native attempt identity), priced `price(usage, model) * markup` (default `catalogPrice(models)`: Pi's `calculateCost` with the model's rates, else the provider-reported cost, in micro-dollars).
51
+ - Termination, when the submission is `done` or `unanswered`, exactly one call: a usage write still failing charges the hold (`usage-write-failed`); a person's stop settles what was billed or frees the hold (`cancelled`); an aborted attempt with no billed usage (interrupted, usage unknown) or a non-zero report priced to nothing charges the hold (`fallback-charge`); billed usage settles; complete all-zero reports settle at zero; an answer without any usage report charges the hold; no model call at all frees it (`no-model-call`, `error-before-usage`, `not-started`).
52
+ - `meter.recover(harness)` after a restart: each run the ledger still holds open is finished from Pi's durable state (finished: settle with the replayed usage; interrupted with unknown usage: charge the hold once Pi's recovery ends the run; never submitted: release), and the rest are watched.
53
+ - Ledgers: `createMemoryLedger(options)` and `createSqliteLedger({ connection, ...options })` (a borrowed `SqliteConnection`) or `createSqliteLedger({ filename, sqlite?, ...options })` (a file the ledger opens with `openNodeConnection(filename, sqlite)` and closes on `close()`; `sqlite` is `SqliteSettings` from `@hachej/boring-files/sqlite`, local disk WAL by default; several processes on one file on a network file system use `{ journalMode: 'delete', lockingMode: 'normal' }`, one owner `sqliteSettings.networkFilesystem`). Every ledger write is one write transaction (`BEGIN IMMEDIATE`), so concurrent writers wait up to the busy timeout instead of failing, and a reservation's balance check and hold commit together (no double spend; four-process test in `test/packages/agent.test.mjs`). Tables `boring_metering_*` with each run's scope, model and kind, charges keyed by (run, step) with the usage's model; tables of the earlier account-keyed shape are refused, not migrated). Options: `holdMicros`, a number or a function of the reservation (model-aware admission: a costly model holds more, so a low balance refuses it while the default model is still admitted); `balanceKey?(scope)`, the balance a run draws on (default its `userId`; `scope => scope.workspaceId ?? scope.userId` pools a workspace); `refusal?(available, hold, input)`. Both add `balance(key)` and an idempotent `grant(key, amountMicros, grantId)`; a reservation is refused when the available balance (grants minus charges minus unspent open holds) is below its hold; a fallback charge tops the run's charges up to its hold.
54
+
55
+ Limits: a run is admitted against its hold, not stopped mid-run (no per-call fence in Pi), so one run can overdraw into recoverable debt that refuses the next one; usage of child conversations (subagents) and of input submitted without the metered view (channels) is not metered; a sink call that keeps failing is retried on the next change of that conversation or by `recover`. The browser-agent model gateway keeps its own per-request `ModelBudget.admit`; a host bills browser runs by calling its ledger from that `admit`.
56
+
57
+ ## Artifacts
58
+
59
+ `createPresentTool({ workspace, resolveAccess?, maxBytes? })` from `@hachej/boring-agent/artifacts` returns the native `present(path)` tool (its workspace resolved per call like the guard's): the agent writes a workspace file with its ordinary file tools and presents it, and the chat shows a card that points at that file (`target`, the revision shown, no `id` or `ordinal`). The file's versions are the history of the workspace provider (`@hachej/boring-files/workspace`, whose `keep` retains the presented revision); a card of an older revision pins it and a card of the newest follows the file as it changes. The tool writes nothing and refuses files that are missing, not UTF-8 text or larger than 256 KiB. How to use it is in the tool's own description, so a host does not repeat it in its prompt: substantial output goes to a file that is then presented, short answers (and anything asked for in the chat) stay in the chat, and a revision re-reads the file, changes it at the same path and presents it again. A host adds only what is its own, such as what its HTML preview allows.
60
+
61
+ `workspaceMentionReader(files, access, { maxReadBytes? })` is the reader for a host with a workspace provider: it reads the person's own published view through the provider (the access check the file viewers use) and has no containment check of its own. A host that has no provider has no reader here; it writes a `MentionReader` over whatever it does have.
62
+
63
+ The descriptor's `id` and `ordinal` are optional and `present` sets neither; `parseArtifact` validates a descriptor and has no Pi runtime dependency (the copied `pi-chat` registry item carries the same validator for the browser).
64
+
65
+ ## Agent state refresh and converted file text
66
+
67
+ `refreshAgentState({ conversations, agent, read, write, context })` from `@hachej/boring-agent/agents` brings existing conversations up to a changed deployment. A conversation keeps the agent it was created with, so when the agent's `instructions` or default `thinkingLevel` change, the helper calls `configure` on each conversation. The host stores a hash of `[instructions, thinkingLevel]` (`read()` returns the stored hash, `write(hash)` stores the new one); it configures nothing when the hash is unchanged and writes the new hash last, so a failed run is retried. It returns how many conversations were configured.
21
68
 
22
- Peer deps (optional): `react`, `react-dom`, `tailwindcss`.
69
+ `createConvertingRead({ workspace?, resolveAccess?, convert?, cache?, maxBytes?, name? })` from `@hachej/boring-agent/file-convert` returns an extension that wraps Pi's native `read` (`wrapTool`); there is one `read`, and no second tool. Text goes to Pi's read untouched. A PDF, an office document or an image goes to the host's `convert({ name, mediaType, bytes })`, once per saved revision (cached by path and revision), and the converted text is paged the way Pi pages a text file: `offset` is the 1-based line to start at, `limit` a number of lines, a page ends at 2000 lines or 50 KB, and the note names the `offset` to continue from. What is text and what is not comes from one table, `fileKind` in `@hachej/boring-agent/file-types`, which looks at the extension and the leading bytes (Pi's read returns garbled text for a PDF, so a PDF is recognised by `%PDF-` even under another name); the mention resolver uses the same table. An archive or other binary file answers `unsupported` and is never converted; without `convert`, an image uses Pi's native behaviour and a PDF or office file answers `unsupported`. A line of converted text above 50 KB is split into 50 KB segments, each counted as one line, with a warning, so nothing is cut. The type is sniffed on the bytes the provider returns after its access check, and the converter is told the media type the file's signature names (a PDF named `.txt` arrives as `application/pdf`). The file is read through the workspace provider, so its access policy applies; a path the wrapper cannot place inside the workspace is left to the native read. Install it before `createFileGuard`, so the guard stays outermost and refuses anything outside the workspace first. `workersAiMarkdown(env.AI)` adapts a Workers AI `toMarkdown` binding to `convert` without a Cloudflare types dependency.
23
70
 
24
- ## Usage
71
+ ## Approval gate
25
72
 
26
- **Server** — standalone Fastify app:
73
+ `requireApproval(tool, { summarize })` from `@hachej/boring-agent/approval` wraps a native tool so each call waits for the person's Approve or Deny before `execute`. It reuses the ask-user mechanism (`askPerson` from `@hachej/boring-agent/ask-user`): a conversation document keyed by the tool call ID, answered with `answerUserQuestion` through the chat transport's `answer` operation, and shown by the `pi-chat` approval card. The check runs inside the tool's own execution in host code, so the model cannot skip it. While the call waits, the one-line `summarize` text is also published as the call's running details under `APPROVAL_DETAILS` (it stays as the result's details unless the tool returns its own). That is also how `pi-chat` recognizes a gated call: no list of tool names is needed. The card shows the summary as the headline with the raw arguments collapsed. Deny (or a stopped conversation) returns an error result starting `DENIED_PREFIX` and `execute` never runs. The wrapper is replay-safe: a pending approval survives a restart, and a retained native memo makes execution at most once (after a crash during execution the model gets an "outcome unknown" error instead of a repeat of the change). Reads are simply not wrapped.
74
+
75
+ `createApprovalExtension({ name?, tools: { bash: true, write_note: { summarize, when } } })` is the same gate as an opt-in native extension: it brings no tools, only native `wraps` that decorate whichever tool of each name wins in a conversation, including Pi's stock `bash`, `write` and `edit`. Pi applies wraps only where the extension is selected, so the gate is per conversation: `conversation.configure({ extensions: { add: [approvals] } })` turns it on and `remove` turns it off from the next call. `when(args, call)` lets a rule ask only for some calls (for example any method but GET); `summarize(args, call)` writes the card's headline (default: the tool name and its arguments). `call` is `{ conversationId, callId, toolName }`, so a host with one conversation per site or project can name it in the headline. Approval extensions compose: a tool already gated is not gated again, so the first selected rule for a tool decides. `requireApproval(tool, { summarize, when? })` remains the building block for a tool you define yourself.
76
+
77
+ Code mode: gated tools are native tools and must not be exposed to a code-mode sandbox, whose nested calls cannot reach a person. Give `run_code` the read tools only and send changes through the gated native tool. `isApprovalGated(tool)` lets a host filter automatically.
78
+
79
+ ## OptChat memory
80
+
81
+ `@hachej/boring-agent/memory/optchat` is Victor Taelin's [OptChat](https://gist.github.com/VictorTaelin/91837951a5ce5b38f341ec1ba1df6449) (see also his [OptMem](https://github.com/VictorTaelin/OptMem)) as one native pi-durable extension: an endless conversation whose memory is the conversation itself, shown to the model as a compressed tree. Credit for the idea, the algorithm and the summarizer prompt is his; this package maps it onto Pi.
27
82
 
28
83
  ```ts
29
- import { createAgentApp } from "@hachej/boring-agent/server"
84
+ const memory = createOptChatMemory({ harness: () => harness, context, summarizer: { models, model: { provider, modelId } } });
85
+ registry.install(memory.extension); // like any extension
86
+ await conversation.configure({ extensions: { add: [memory.extension] } }, context); // on, for this conversation
87
+ await conversation.configure({ extensions: { remove: [memory.extension] } }, context); // off again
88
+ ```
89
+
90
+ 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, n), date(id), 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), `jobs` (8, how many summary tasks of a conversation the host keeps live at once), `tries` (5), `cap` (30000), `retryMs` (10000), `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`.
91
+
92
+ | Reference | Here |
93
+ | --- | --- |
94
+ | 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 |
95
+ | 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 |
96
+ | 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 |
97
+ | Each turn calls the model with [system][view][new message] | `GenerationTask.beforeRequest` replaces the messages before the current turn with the view, as the first block of the current user message. The turn in progress (the latest user message and the steps after it) stays verbatim; 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 |
98
+ | Compactor: context first, no ids, scale line, resubmit with the cut at the limit, `JOBS` in parallel, retry after 10 s | 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 |
99
+ | 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 |
100
+ | `zoom(id, n)` and `date(id)` | Native replay-safe tools of the same names |
101
+ | Summary files | A conversation document family (`boring.memory.optchat.nodes`, shards of 64 nodes per level, `fork: 'current'`), one fingerprint per node over the content it covers. A fork or an edit never shows a summary of different content: a stale node is unbuilt until rebuilt |
102
+
103
+ What is native, what is host-side:
104
+
105
+ - **Tasks.** The host creates node tasks with `conversation.commit(tx => tx.createTask(...))` (hooks are read-only) as `background: true`, conversation-owned tasks: they never keep the conversation busy, never block or get aborted with the user's run (`conversation.abort()` leaves them; `abort(context, { background: true })` aborts them), and show in `harness.taskGraph()` and `harness.inspect()`. A pending task after a restart is resumed by Pi once the extension is installed again (the extension carries the task definition; the options must be the same).
106
+ - **Triggers (host admission, not scheduling).** After an answer (`onYield`, once the run is idle) and when a request needs its view (`beforeRequest`). A trigger creates the tasks of the nodes whose sources exist and whose preceding view is summarized. One live task per node key, found by scanning native tasks in the same commit that creates them. `jobs` caps how many are live per conversation; Pi has no task concurrency setting, so this is the host's admission cap and Pi's scheduler decides when they run. A node's parent is created by the next trigger or by the waiting `nap` or request, once its children are built.
107
+ - **Waiting.** A request waits with `waitForTask` on the live node tasks, up to `settleTimeoutMs`, then shows the placeholder. `nap` waits the same way. These waits are in-process; if the process dies the tasks are not lost, only the cascade to parents waits for the next trigger.
108
+ - **Retry.** A failed node is a failed native task (`outcome.failed`). Its last failure time is kept in the conversation document `boring.memory.optchat.state` (with the summarizer call count and spend), and a trigger creates the task again once `retryMs` has passed. No timer retries: a node in its retry window is left alone, so a request that needs it shows the placeholder instead of waiting. `onError` reports the first failure of a streak.
109
+ - **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.
110
+ - **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.
111
+
112
+ Deviations, honestly:
113
+
114
+ - 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.
115
+ - 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.
116
+ - 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).
117
+ - No cache breakpoints inside the view (provider-specific stream options are the host's), no subagent or `note` kinds, no `fsync` log or single-writer lock (Pi's storage owns durability), and the compactor prompt names only the four kinds Pi logs. Summaries are read from the documents on every request and stats call, since tasks write them.
118
+ - Cost: the compactor makes about two calls per message, each carrying the view as context. Choose a cheap summarizer model; the browser-agent example uses the chat model only because it has one.
119
+ - 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.
120
+
121
+ ## Native questions
30
122
 
31
- const app = await createAgentApp({
32
- mode: "local", // "direct" | "local" | "vercel-sandbox"
33
- workspaceRoot: process.cwd(),
34
- })
35
- await app.listen({ port: 3001 })
123
+ `createQuestions` from `@hachej/boring-agent/questions` exposes a native task, session document family and extension. Install the extension explicitly. Call `admit(tx, question, ownership, context)` in a native commit to persist a single-choice question and its task together. `resolve` and `consume` take the caller's native transaction so host writes can share that atomic commit. Call them before host table writes: native task reads after a table write are unavailable and the operation returns conflict. Host authentication and current subject/policy checks run at both boundaries. A resolved answer is clarification, never publication permission.
124
+
125
+ The question task runs its durable wait phase while observing the native document. Parents can park with native task waiting on its ID. No browser or process-local waiter registry owns the answer. Native shutdown cancels observation; reopening checks the retained document. Session documents retain resolved/cancelled evidence after task completion. Expiry changes only pending state; late consumption refuses without deleting a prior answer. If supplying a custom clock, use the same clock for the Harness. Raw native document access is trusted host access; remote question resolution is still pending.
126
+
127
+ ## Validated document delivery
128
+
129
+ `createDocumentDelivery` from `@hachej/boring-agent/delivery` admits a producer callback and its delivery task in one native transaction. The callback creates ordinary native tasks and returns `TaskId<string>`. Delivery captures the document precondition and authenticated identity before production, waits on the native producer, validates its actual string output and persists that exact text before publication. Host validator versions and operation namespaces must remain stable across recovery.
130
+
131
+ This is string validation and conditional Markdown delivery. It does not implement model repairs, arbitrary host record delivery or prove tool provenance. Supply both `ResourcePublisher` and `PublicationLookup`. Recovery defaults to lookup only after durable publication intent; a missing receipt stays unknown. Explicit `replay: 'safe'` requires host qualification of durable duplicate suppression. Abort means the native task stopped; an interrupted external publication can still require lookup under the admitted operation ID.
132
+
133
+ The admission target can include `preconditions: readonly ResourceExpectation[]`. Use these for source-owned generation and human-edit revisions that must still match when output is published. Admission copies and validates the target and conditions before awaiting producer creation. The provider checks the guards in the same transaction as the document change; delivery includes them in its receipt digest. Independent subjects can use independent guards.
134
+
135
+ ```ts
136
+ await conversation.commit(tx => delivery.admit(tx, createProducer,
137
+ { kind: 'absent', target: report, preconditions: [generation, humanEdit] },
138
+ { ownership: { kind: 'conversation' } }, context), context);
36
139
  ```
37
140
 
38
- To embed routes into an existing Fastify instance, use `registerAgentRoutes`
39
- instead. The full IDE shell (file tree, panes, UI-bridge tools) lives in
40
- `@hachej/boring-workspace`, which mounts this agent.
141
+ The native delivery task is version 2. Existing version 1 records migrate with empty guards and unchanged producer, identity, target, namespace, validation version and checkpoint. Downgrading to a version 1 definition refuses guarded version 2 work. A committed receipt remains the result of that original delivery after a guard advances; a guard change before publication causes conflict. Advancing a provider guard and admitting native tasks are still two separate transactions. Hosts need recovery for that boundary; this API does not make them atomic.
142
+
143
+ `resolveAccess` may return a Promise. When recovery reads the original actor from native producer input, pass that already-authenticated access as the sixth admission argument: `admit(tx, createProducer, target, taskOptions, context, access)`. Admission captures its identity before awaiting producer creation. Publication still resolves current access and compares the original identity. Omitting the sixth argument preserves resolution during admission; such a resolver must not read the same Harness or call `tx.task` after producer writes. Native transactions reject table reads after their first write. Explicit access is trusted host input, not authentication or a grant. Resolved credential fields are copied before digest, memo and provider awaits.
144
+
145
+ Run `node examples/background-document.mjs` after building for fictional question, native producer and document delivery without a model, browser or shell. The example resolves a fixed fictional answer in trusted host code; it is not an authenticated remote human journey.
146
+
147
+ Import interfaces with `import type`. See [scaffold guide](../../docs/contracts/SCAFFOLD.md), [native examples](../../examples/native-compositions.ts) and [implementation evidence](../../docs/implementation/PARTIAL.md).
148
+
149
+ ## Authorized conversation text
150
+
151
+ `createConversationProjectionHandler` from `@hachej/boring-agent/projection` returns a Fetch `Request` to `Response` handler. The host authenticates the request and supplies the original native conversation, Context, fixed runtime/scope/principal binding, current authorization callbacks and a revocation signal. Requests use GET with `?version=2`. Other versions return 426 and other methods return 405. No command or asset route is installed.
41
152
 
42
- **Frontend** — the chat panel:
153
+ The response is newline-delimited JSON with `boring.conversation-text` version 2 frames. The first frame is a bounded snapshot; subsequent frames contain changed rows and the complete bounded row order, or a replacement snapshot when that is smaller. A delta names its preceding frame. Connection ID, frame and observation time describe this observation only; they are not native durable cursors. The stable row key is the original conversation ID, entry ID and message index. Rows absent from a delta's order are removed.
43
154
 
44
- ```tsx
45
- import { ChatPanel } from "@hachej/boring-agent"
46
- import "@hachej/boring-agent/front/styles.css"
155
+ Only host-permitted user/assistant text blocks are selected. Entry data, system instructions, thinking, tool arguments/results, assets, Context and errors are excluded. Live generation is also excluded; entry permission does not authorize the private live document. Entries retain their original conversation IDs across forks so entry policy can distinguish inherited content. This projects raw retained entry text, not edited model-context reconstruction.
47
156
 
48
- function App() {
49
- return <ChatPanel apiBaseUrl="http://localhost:3001" />
50
- }
157
+ `CONVERSATION_PROJECTION_LIMITS` fixes the window at 200 messages, each at most 32 KiB, with at most 192 KiB of messages and a 256 KiB frame. Byte limits measure UTF-8 JSON, including escaping and metadata; the frame limit includes its newline. Selection uses the pinned native entry-ID recency, then restores native presentation order, including its head marker. Oversized text is clipped at scalar boundaries and marked `clipped`. `window.truncated` reports omitted older permitted rows without counting denied entries. These limits bound adapter retention and wire output, not native watch allocation or the cost of scanning entries for permission.
158
+
159
+ Authorization must return literal `true` at opening, after watch acquisition and before each candidate delivery. The synchronous entry policy must also return literal `true`. Abort the host signal on revocation or identity change, including idle periods. Policy changes without a native update require revocation and a fresh connection. No further payload is enqueued after abort; delivered bytes cannot be recalled. The browser integration must clear restricted presentation on close/identity change while preserving separately owned drafts. That browser integration is not implemented here.
160
+
161
+ The adapter keeps the last bounded delivered window and no update queue. A slow reader observes the latest permitted view when it pulls. Unchanged visible rows produce no frame, including configuration-only updates. Disconnect stops only this connection's native watch. Reconnect authorizes a new watch and snapshot without submitting work. End of stream means observation ended, never confirmed task termination.
162
+
163
+ `createConversationTextReceiver(expectedIdentity)` validates one complete JSON frame per `read(line)` call and returns a detached snapshot. It rejects unknown fields, invalid bounds, identity mismatches, connection changes, missing bases, duplicate keys and sequence gaps. Any failure clears its cache and requires a fresh initial snapshot. Call `reset()` when opening a new connection or ending restricted presentation. The receiver does not frame network chunks, own a socket or reconstruct native execution state.
164
+
165
+ TLS, cookies, CORS, socket/proxy lifecycle, browser presentation and deployment authentication remain host qualifications. Public projection tests exercise actual native watches through in-process Fetch/Web Streams. `npm run test:projection-consumer` installs the agent archive without files/UI/execution packages, checks strict declarations, repeats the tests and builds the receiver for a browser. It requires the pinned registry archives in a writable npm cache.
166
+
167
+ Run `node examples/conversation-projection.mjs` after building for a fictional authenticated stream and idle revocation demonstration.
168
+
169
+ ## Authenticated question responses
170
+
171
+ `@hachej/boring-agent/question-response` exports `createQuestionResponseHandler`. It accepts a host `authenticateHuman(Request)` callback returning the original native conversation, question feature/reference, human Context and revocation signal. The host authenticates delegated human identity, applies cookie origin/CSRF rules where relevant and carries that identity into the original question's `authorize` policy. This is clarification resolution, not product approval, and must not be registered as agent approval authority.
172
+
173
+ POST `application/json` with `{ schema: 'boring.question-response', version: 1, target: originalReference, resolutionId, answer }`. The bounded streamed body must contain exactly these fields and the exact original target. The handler checks the selected conversation and resolves inside its native transaction. The original feature checks scope, responder, subject/policy, expiry and duplicate identity. No inbox database or additional decision state is created.
174
+
175
+ Responses expose the narrow native decision, with `no-store` headers. An observed abort before the transaction callback returns rolls back that attempt. External revocation has no atomic fence against durable commit. A commit error or observed late abort returns `resolution-not-confirmed`; the original resolution may already exist. Retry the same resolution identity under current authorization to recover its result. Closing the request never closes the borrowed Harness or cancels its question task.
176
+
177
+ `test/packages/agent-question-response.test.mjs` exercises real native transactions through Fetch requests, including duplicate answers, identity/policy refusal, streamed byte limits, unacknowledged source cancellation and lost response recovery. `npm run test:question-consumer` repeats these scenarios from the installed agent and files tarballs without UI/execution packages; the handler reads bodies through `@hachej/boring-files/request-guard`, so install `@hachej/boring-files` (an optional peer) with this subpath. Set `npm_config_cache` to a writable cache of the pinned dependencies. Socket/browser authentication, remote inbox presentation and human identity-provider integration remain unqualified.
178
+
179
+ The strict TypeScript consumer recipe additionally installs `@modelcontextprotocol/sdk@1.32.0`. Pinned Pi model declarations transitively load `@google/genai`, whose declarations reference that optional SDK. This dependency is needed for this `skipLibCheck: false` recipe even though the question runtime does not call MCP. The consumer does not establish strict checking with only the agent's declared peers installed.
180
+
181
+ `@hachej/boring-agent/git` exports `createGitTool(repository?)`. Register this ordinary native tool with the same host-created `@hachej/boring-files/git` repository used by Bash or direct callers, or without one: each call then uses the `repository` of its own workspace binding (the one attached to the call's env, see [Workspace per call](#workspace-per-call)), so one tool serves a repository per person. It exposes the local service operations and preserves native ToolTask execution. The tool is always unsafe to replay because working Git has no publication receipts. The repository's host-bound authorization applies on every call. No environment or shell is mandatory for a file-only repository binding.
182
+
183
+
184
+ ## Pinned JSON definitions
185
+
186
+ `loadAgentDefinition` from `@hachej/boring-agent/definitions` reads an authorized exact resource revision and returns a native `AgentChange` plus an `AgentDefinitionBinding`. Install `@hachej/boring-files` for this optional subpath. Importing the loader does not load the native engine or UI packages.
187
+
188
+ The JSON resource contains exactly these fields:
189
+
190
+ ```json
191
+ {"format":"boring.agent","version":1,"instructions":"Use the installed document tool.","tools":["save_note"]}
192
+ ```
193
+
194
+ The host supplies the reader, exact `ref`, authenticated `access`, host implementation version and a `resolveTool(name)` callback. The callback returns a currently installed and permitted native tool with its implementation version, or `undefined` to refuse it. An empty `tools` array selects no tools. The loader rejects unknown fields, duplicate or unavailable tools, mismatched resource identity, incompatible retained bindings and observed cancellation. It executes no file code and configures no conversation.
195
+
196
+ ```ts
197
+ const loaded = await loadAgentDefinition(options);
198
+ await conversation.configure(loaded.change, context);
51
199
  ```
52
200
 
53
- ## Configuration
201
+ The binding retains the selected resource/view/revision, scope, SHA-256 of the exact bytes, format/native versions and ordered host/tool implementation versions. Persist it in host-owned or native storage when the workflow requires reproducibility. On reload, pass it as `expectedBinding` and resolve current permissions again. The host chooses whether to migrate a changed definition or refuse that workflow.
202
+
203
+ Native configuration retains tool names. A later registry replacement or native wrapper can change execution even when the returned tool object and binding remain unchanged. Version tokens are host assertions about the effective implementation, including wrappers. They do not freeze code or grant future authority. Hosts requiring reproducibility must check versions and current authorization at admission and recovery.
204
+
205
+ The resource must be UTF-8 `application/json`, optionally with `charset=utf-8`. The default accepted size is 65,536 bytes; `maxBytes` accepts integers from 1 to 1,048,576. This limits accepted bytes after the reader returns, not provider allocation. The loader preserves borrowed tool/provider ownership and checks an optional abort signal after awaited operations; it cannot interrupt a noncooperative reader or resolver.
206
+
207
+ Run `node examples/agent-definitions.mjs` after building for a fictional definition, explicit native configuration and real document publication. `npm run test:definitions-consumer` installs files/agent archives with pinned registry dependencies, checks strict declarations and repeats exact-read, native publication, reopen and registry-replacement tests. Like the question consumer, this strict fixture explicitly installs the pinned optional model SDK declaration dependencies.
208
+
209
+ ## Structured output and native tool evidence
54
210
 
55
- Set an API key for the model provider (e.g. `ANTHROPIC_API_KEY`). Common env
56
- vars: `BORING_AGENT_MODE` (default `direct`), `BORING_AGENT_WORKSPACE_ROOT`
57
- (default cwd), `BORING_AGENT_SESSION_ROOT` (durable Pi session storage),
58
- `BORING_AGENT_PORT`, and the `BORING_AGENT_DEFAULT_MODEL*` /
59
- `BORING_AGENT_CUSTOM_MODEL*` / `BORING_AGENT_INFOMANIAK*` provider settings. See
60
- [docs/runtime.md](./docs/runtime.md) and [docs/API.md](./docs/API.md).
211
+ `@hachej/boring-agent/validation` exports `createOutputValidation`. Install its native extension explicitly. A producer returns `{ text, evidence }`, where `evidence` contains retained native tool-result entry IDs. Create the returned validation task with `{ producer }` through ordinary `tx.createTask`; it waits for the producer and returns `valid`, `invalid` or `producer-failed`. Only a `valid` result may feed application delivery. The host supplies a stable task name and definition version; incompatible stored versions require native migration or refusal.
61
212
 
62
- ## Documentation
213
+ The shared `check` method is also useful in `GenerationHooks.onYield` to request a bounded native continuation. Hook errors are observational. Always run the separate final validation task against the completed producer, even when the hook previously accepted an answer. Repair counts are application decisions, distinct from provider retries and compaction.
63
214
 
64
- See [docs/README.md](./docs/README.md) for the full doc index — architecture,
65
- the export surfaces (`/front`, `/server`, `/shared`, `/eval`), runtime modes and
66
- provisioning, theming, plugins, error codes, and risk/cost notes.
215
+ Validation borrows the host Harness through `harness: () => harness`. It reads native records in one read-only transaction, then calls the host's `authorize` and `validate` functions outside that transaction. Authorization must return literal `true` before and after validation. Inputs and the validated JSON value are detached before later awaits; validators cannot return undefined, non-finite numbers or non-JSON objects. Throws and missing evidence refuse validation.
67
216
 
68
- ## Contributions
217
+ Each evidence entry must link to a completed native ToolTask, its original assistant call and the original producer's ownership subtree. Task-owned conversations count; inherited fork history and sibling work do not. This bounded adapter accepts 1–64 distinct results and traverses at most 128 ownership edges per result. Every diagnostic currently refuses evidence, including informational truncation/spill notices. The host still checks tool semantics, current access and completeness; a silent truncation inside a custom tool cannot be inferred from native records.
69
218
 
70
- I do not accept outside contributions for my projects: I don't have the
71
- bandwidth to review them, and it's my name on the result. Issues and bug reports
72
- are welcome; PRs may be used to illustrate a fix, but I won't merge them
73
- directly — I'll have Claude or Codex review submissions via `gh` and
74
- independently decide whether and how to address them.
219
+ `NativeToolEvidence.call.arguments` contains the model's original call. Native argument preparation and tool hooks can change executed arguments. Tools requiring exact normalized-input provenance must retain those inputs with their actual return. This adapter proves the retained return and native call/task relationship, not exact executed arguments, external-source truth or nested code-mode evidence.
220
+
221
+ ## Asking the person
222
+
223
+ `@hachej/boring-agent/ask-user` exports `createAskUserTool()`, a replay-safe native tool named `ask_user` with arguments `{ question, options?: string[2..6], allowFreeText? }`. The call waits on a conversation document keyed by its assistant entry and tool call ID, so no model request is held, a pending question survives a restart and stopping the conversation cancels it. A trusted host resolves it with `answerUserQuestion(conversation, askUserQuestionId(assistantEntryId, callId), answer)` (pass the scoped ID through `ChatTransportAccess.answer` after authenticating the person): the answer must be an option unless free text is allowed, at most 2000 characters; one answer wins, repeating it is idempotent. The tool result is `{"kind":"answered","answer":"..."}`. The chat transport also serves `?op=answer` and `?op=withdraw` (a queued message, by submission ID), both gated by `allow`.
224
+
225
+ ## Browser tasks
226
+
227
+ `@hachej/boring-agent/browser-task` exports `createBrowserTaskTool({ name, description, parameters, checkAnswer, maxAnswerBytes? })`: the same durable wait as `ask_user` (shared internally), answered by the person's open page with a JSON object instead of a short text. The page finds the call in the conversation it watches (a call of that tool with no result yet) and answers through the chat transport's `?op=answer` with `browserTaskId(assistantEntryId, callId)`; the host wires `answerBrowserTask(conversation, tool, id, answer)` there after authenticating the person. The answer must be a JSON object within `maxAnswerBytes` (16 KiB) that `checkAnswer` accepts; one answer wins, repeating it is idempotent, a stopped conversation refuses it. The tool result is the answer's JSON. `browser_preview` in `@hachej/boring-feedback/agent` is one such task.
228
+
229
+ `?op=configure` (POST `{ model?: { provider, modelId }, thinkingLevel? }`) changes the conversation's native agent for a model or effort picker. It is gated by `allow('configure')` and delegated to `ChatTransportAccess.configure(change)`; without that function the op answers `not-supported`. The host function applies `conversation.configure(...)` and owns the allow-list: it validates the change against its own models and levels and returns a refusal such as `{ kind: 'refused', reason }` instead of applying it (the handler checks only the request shape). `createRemoteChat().configure(change)` returns whatever the host answered. A change applies from the next model request.
230
+
231
+ `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.
232
+
233
+ ## Tools from a JSON Schema
234
+
235
+ `jsonSchemaTool({ name, description, parameters, execute, ... })` from `@hachej/boring-agent/agents` makes a native tool registration from a raw JSON Schema (an MCP server's `inputSchema`, an agent-written `.agent/tools/*.json`) with typebox's `Type.Unsafe`, so no `as unknown as TSchema` cast is needed. It refuses a schema that is not `"type": "object"` and adds an empty `properties` when there is none. It validates nothing itself: the Harness already validates every call's arguments against `parameters` before `execute` (Pi's `validateToolArguments`, which compiles plain JSON Schema with typebox) and answers a mismatch with an `invalid_arguments` error result naming the fields, before an approval gate is asked. The studio's MCP recipe and self-evolution use it; their journeys send one call with a missing required argument each.
236
+
237
+ ## Self-evolution
238
+
239
+ `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.
240
+
241
+ - 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.
242
+ - `.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.
243
+ - `.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.
244
+ - `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.
245
+ - Choices where the plan was silent: the folder is relative to the environment's working directory; there is no separate trust setting, approval or sandbox (isolation is where `exec` runs; hosts that want approval use the existing gate); `registry.uninstall()` is not needed because replacing the one extension removes deleted tools; an agent-written skill or tool that collides with a host one is refused, not shadowing it.
246
+
247
+ | ID | Law | Structural check | Runtime proof |
248
+ | --- | --- | --- | --- |
249
+ | SELF-1 | Opt-in. Without `selfEvolving: true` nothing reads `.agent/`, and no `reload` tool or agent-written prompt section exists. | Prompt assembly test in `test/packages/agent.test.mjs`: without the option the same `.agent/AGENTS.md` is never shown and the native instructions stay last. | Studio journey `self-evolution`: a subagent (no option) over the same workspace has neither. |
250
+ | SELF-2 | Never imported into the host. Agent-written tools execute only through the conversation's `ExecutionEnv`; no `import()`/`eval`/`Function` of workspace content in library or host code. | `npm run check` (`scripts/check-pi-boundary.mjs`, mutants in `test/pi-boundary.test.mjs`) over `packages/` and `examples/`. | Journey: a tool printing its environment on the virtual variant sees none of the host's variables. |
251
+ | SELF-3 | The host's base prompt stays first and whole; agent-written instructions are appended, labelled and size-capped. | Prompt assembly order test in `test/packages/agent.test.mjs`. | Journey: the system context shows both, in order. |
252
+ | SELF-4 | Reload is native and honest: the native `registry.install` replaces the extension in place, running calls finish on the old version (native Pi), every load error is reported in the tool result. | `npm run check` (`scanNativeReload`, mutants in `test/pi-boundary.test.mjs`): only native `registry.install`/`uninstall`, no registry of its own. | Journey: write tool → reload → next turn uses it; broken JSON → error reported; restart → still there; rollback → gone; `/reload` gives the same report. |
253
+
254
+ Evidence is registered in [VERIFY.json](../../VERIFY.json) under `features`. Not proven at runtime: a call running during a reload finishing on its old registration (Pi's registry behaviour, relied on, not retested) and the report on an environment without `exec` (every studio variant has one).
255
+
256
+ ### Question notification recovery
257
+
258
+ External channel question notifications record success after `adapter.send` resolves. A restart retries an unacknowledged notification with the same assistant-entry-bound question ID. A crash after the external send but before the native acknowledgement can duplicate the notification; this is at-least-once delivery. The adapter may deduplicate that stable ID. A local success record does not prove that a person read the message.
259
+
260
+
261
+ ## Target-bound viewer commands
262
+
263
+ `@hachej/boring-agent/presentation` exports `createPresentationTool`. It returns an ordinary native `ToolRegistration`; install it through the host's native extension/registry. Select this subpath with the optional `@hachej/boring-ui-kit` peer installed. The adapter imports UI contracts only, with no controller or renderer runtime.
264
+
265
+ Bind one `PresentationCommand` and its captured `ViewerTarget`. The target and parsed command input must be structured-cloneable data. Authorization receives a separate input/target copy, so its asynchronous work cannot change the command that executes. The adapter snapshots it at construction, so later calls never switch to whichever viewer is currently focused. Commands retain responsibility for checking resource, buffer, mount, projection, expiry and read-only state at their actual effect. A replaced editor can therefore refuse an old tool as stale or unavailable.
266
+
267
+ The host supplies:
268
+
269
+ - Native `parameters` for model arguments. Pi validates them before execution.
270
+ - `prepareInput`, which maps those arguments to command input, including a host-controlled deadline. The command's parser validates the mapped input.
271
+ - `authorize`, which checks the input and captured target against the actual native invocation and current host policy on every call. Target metadata is not permission.
272
+ - `formatResult`, which explicitly chooses the native result content/details. Inspection can contain private buffer text or resource references; project only what this conversation may retain. Refused commands always produce `isError: true`.
273
+
274
+ ```ts
275
+ const navigate = createPresentationTool({
276
+ name: 'navigate_notes',
277
+ description: 'Navigate the bound notes viewer',
278
+ parameters: Type.Object({ index: Type.Integer({ minimum: 0 }) }),
279
+ command: mounted.revealHeading,
280
+ target: capturedTarget,
281
+ prepareInput: ({ index }) => ({ index, expiresAt: Date.now() + 5000 }),
282
+ authorize: (_input, _target, api, context) => canNavigate(api.conversationId, context),
283
+ formatResult: result => ({ content: [{ type: 'text', text: JSON.stringify({ kind: result.kind }) }] }),
284
+ });
285
+ ```
75
286
 
76
- ## License
287
+ Here `Type` is Pi AI's native schema builder, and `mounted`, `capturedTarget` and `canNavigate` belong to the host. The adapter passes native cancellation to the command and fixes replay to `unsafe`: an interrupted presentation call cannot safely be redirected or repeated. It acquires no controller, provider or Harness and owns none of their cleanup.
77
288
 
78
- MIT
289
+ This is direct in-process binding. A server cannot manipulate a remote browser through this adapter alone. The optional [canvas host recipe](../../docs/architecture/CANVAS.md#authenticated-remote-presentation-recipe) supplies authenticated selection/proposal delivery to a mounted canvas. Other viewer transports and deployment identity remain separate work. Presentation/proposal results are not publication receipts or human approval.
@@ -0,0 +1,75 @@
1
+ import type { AgentChange, Conversation, ConversationCreateOptions, Extension, Harness, Registry, ToolRegistration } from '@earendil-works/pi-durable';
2
+ import type { Context } from '@earendil-works/chord';
3
+ import type { ExecutionEnv } from '@earendil-works/pi-durable/env';
4
+ import type { SelfEvolutionReport } from './self-evolving.js';
5
+ import type { Skill } from './skills.js';
6
+ export type { SelfEvolutionError, SelfEvolutionReport } from './self-evolving.js';
7
+ export { createSkillsExtension, parseSkill } from './skills.js';
8
+ export type { Skill } from './skills.js';
9
+ export { jsonSchemaTool } from './json-schema-tool.js';
10
+ export type { JsonObjectSchema, JsonSchemaToolOptions } from './json-schema-tool.js';
11
+ /** One agent as plain data over native Pi pieces. It is a convenience, not another registry or lifecycle. */
12
+ export interface AgentSpec {
13
+ /** Stable name; it becomes the native extension name `agent.<id>`. */
14
+ readonly id: string;
15
+ readonly model: NonNullable<AgentChange['model']>;
16
+ readonly instructions?: string;
17
+ readonly thinkingLevel?: NonNullable<AgentChange['thinkingLevel']>;
18
+ /** Tools owned by this agent. */
19
+ readonly tools?: readonly ToolRegistration[];
20
+ /** Additional native extensions this agent selects, for example Pi's `CodingTools`. */
21
+ readonly extensions?: readonly Extension[];
22
+ readonly skills?: readonly Skill[];
23
+ /** Directory within the harness environment. */
24
+ readonly cwd?: string;
25
+ /**
26
+ * Let the agent keep its own instructions, skills and tools in `.agent/` of its workspace and apply them with `reload`
27
+ * (docs/architecture/SELF-EVOLUTION.md). Off by default: without it nothing reads `.agent/` (SELF-1).
28
+ */
29
+ readonly selfEvolving?: boolean;
30
+ /**
31
+ * The workspace instance this agent works in; names the per-workspace extension `self-evolving:<workspace>`. With `selfEvolving` and
32
+ * no `workspace`, the agent works in a workspace per conversation: `inWorkspace(name)` gives the agent of one workspace.
33
+ */
34
+ readonly workspace?: string;
35
+ }
36
+ export interface DefinedAgent {
37
+ readonly id: string;
38
+ /** Every native extension this agent selects, own extension first; with `selfEvolving` the per-workspace extension (as first defined) last. */
39
+ readonly extensions: readonly Extension[];
40
+ /** Name and description of this agent's skills, for hosts that offer them in a `/` menu. */
41
+ readonly skills: readonly Pick<Skill, 'name' | 'description'>[];
42
+ /** Native per-conversation agent configuration. */
43
+ readonly agent: AgentChange;
44
+ /** Install this agent's extensions in a host registry. Installing the same extension twice replaces it in place. */
45
+ readonly install: (registry: Registry) => void;
46
+ /**
47
+ * With `selfEvolving` only: rescan `.agent/` through `env` (the conversation's environment, or the workspace's on open) and replace
48
+ * the per-workspace extension in every registry `install` was given. The `reload` tool and a host's `/reload` command call this.
49
+ */
50
+ readonly reload?: (env: ExecutionEnv | undefined, context: Context) => Promise<SelfEvolutionReport>;
51
+ /** Create a native conversation configured as this agent. The host keeps the native handle. */
52
+ readonly createConversation: (harness: Harness, context: Context, options?: Partial<Pick<ConversationCreateOptions, 'ownership' | 'init'>>) => Promise<Conversation>;
53
+ /**
54
+ * With `selfEvolving` and no fixed `workspace` only: this agent in workspace `name` (made once per name, then returned again), which
55
+ * selects its own `self-evolving:<name>` extension last. One harness and one registry then serve a workspace per person: a
56
+ * conversation created with `inWorkspace('a')` is offered what was reloaded from workspace `a` only, never another workspace's tools.
57
+ */
58
+ readonly inWorkspace?: (name: string) => DefinedAgent;
59
+ }
60
+ /** Turn an agent described as data into native extensions and a native agent configuration. */
61
+ export declare function defineAgent(spec: AgentSpec): DefinedAgent;
62
+ /**
63
+ * Bring existing conversations up to the agent's current instructions and default thinking level. A conversation keeps the agent it
64
+ * was created with, so a changed deployment reaches it only through `configure`. The host keeps the hash of `[instructions,
65
+ * thinkingLevel]` (`read`/`write`); when it differs from the agent's, every conversation is configured and the new hash is written
66
+ * last, so a failed run is retried whole. Returns how many conversations were configured (0 when nothing changed).
67
+ */
68
+ export declare function refreshAgentState(options: {
69
+ readonly conversations: Iterable<Conversation> | AsyncIterable<Conversation>;
70
+ readonly agent: DefinedAgent;
71
+ readonly read: () => string | undefined | null | Promise<string | undefined | null>;
72
+ readonly write: (hash: string) => void | Promise<void>;
73
+ readonly context: Context;
74
+ }): Promise<number>;
75
+ //# sourceMappingURL=agents.d.ts.map
package/dist/agents.js ADDED
@@ -0,0 +1,91 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { defineExtension } from '@earendil-works/pi-durable';
3
+ import { createSelfEvolution } from './self-evolving.js';
4
+ import { NAME, createSkillsExtension, parseSkill, validSkill } from './skills.js';
5
+ export { createSkillsExtension, parseSkill } from './skills.js';
6
+ export { jsonSchemaTool } from './json-schema-tool.js';
7
+ /** Turn an agent described as data into native extensions and a native agent configuration. */
8
+ export function defineAgent(spec) {
9
+ if (!NAME.test(spec.id))
10
+ throw new TypeError(`Invalid agent id: ${spec.id}`);
11
+ if (spec.workspace !== undefined && (typeof spec.workspace !== 'string' || !spec.workspace.trim()))
12
+ throw new TypeError(`Agent ${spec.id}: workspace must be a non-empty name`);
13
+ // Self-evolving (SELF-3): Pi renders native `instructions` after every extension section, so the host's instructions become the first
14
+ // section of the agent's own (first) extension instead, and the agent-written section, in the last extension, comes after them.
15
+ const base = spec.selfEvolving && spec.instructions !== undefined ? spec.instructions : undefined;
16
+ const own = [];
17
+ if (spec.tools?.length || base !== undefined) {
18
+ own.push(defineExtension({ name: `agent.${spec.id}`, tools: [...spec.tools ?? []], sections: base === undefined ? [] : [{ key: 'host-instructions', render: () => base }] }));
19
+ }
20
+ // The skills of a self-evolving agent live in its per-workspace extension, so agent-written skills join them on reload. Without a
21
+ // workspace (a self-evolving agent whose workspace is chosen per conversation, before `inWorkspace`) they stay the host's alone.
22
+ const evolving = spec.selfEvolving === true && spec.workspace !== undefined;
23
+ const hostSkills = spec.skills?.length && !evolving ? [createSkillsExtension(`agent.${spec.id}.skills`, spec.skills)] : [];
24
+ const listed = (skills) => Object.freeze(skills.map(skill => Object.freeze({ name: skill.name, description: skill.description.trim() })));
25
+ const reserved = [...(spec.tools ?? []), ...(spec.extensions ?? []).flatMap(extension => extension.tools ?? [])].map(tool => tool.name);
26
+ /** The agent as one native configuration: its own extensions, the host's, and with `evolution` the per-workspace one last. */
27
+ function assemble(selected, evolution, inWorkspace) {
28
+ const initial = evolution?.current();
29
+ const extensions = Object.freeze([...selected, ...(spec.extensions ?? []), ...(initial ? [initial] : [])]);
30
+ if (new Set(extensions.map(extension => extension.name)).size !== extensions.length)
31
+ throw new TypeError(`Agent ${spec.id} selects one extension name twice`);
32
+ const agent = Object.freeze({
33
+ model: spec.model, extensions,
34
+ ...(spec.instructions === undefined || base !== undefined ? {} : { instructions: spec.instructions }),
35
+ ...(spec.thinkingLevel === undefined ? {} : { thinkingLevel: spec.thinkingLevel }),
36
+ ...(spec.cwd === undefined ? {} : { cwd: spec.cwd }),
37
+ });
38
+ const fixedSkills = listed(spec.skills ?? []);
39
+ return Object.freeze({
40
+ id: spec.id, extensions, agent,
41
+ get skills() { return evolution ? listed(evolution.skills()) : fixedSkills; },
42
+ install: (registry) => { for (const extension of extensions) {
43
+ if (extension === initial)
44
+ evolution.install(registry);
45
+ else
46
+ registry.install(extension);
47
+ } },
48
+ ...(evolution ? { reload: evolution.reload } : {}),
49
+ createConversation: (harness, context, options = {}) => harness.createConversation({ ownership: options.ownership ?? { kind: 'ownerless' }, agent, ...(options.init ? { init: options.init } : {}) }, context),
50
+ ...(inWorkspace ? { inWorkspace } : {}),
51
+ });
52
+ }
53
+ const evolutionOf = (workspace) => createSelfEvolution({
54
+ name: `self-evolving:${workspace}`, skills: (spec.skills ?? []).map(validSkill), parseSkill, skillsExtension: createSkillsExtension, reserved,
55
+ });
56
+ if (!spec.selfEvolving)
57
+ return assemble([...own, ...hostSkills], undefined);
58
+ if (spec.workspace !== undefined)
59
+ return assemble(own, evolutionOf(spec.workspace));
60
+ // A workspace per conversation: one evolution, and one agent configuration, per workspace name, made on first use.
61
+ const perWorkspace = new Map();
62
+ return assemble([...own, ...hostSkills], undefined, name => {
63
+ if (typeof name !== 'string' || !name.trim())
64
+ throw new TypeError(`Agent ${spec.id}: a workspace name is required`);
65
+ let found = perWorkspace.get(name);
66
+ if (!found) {
67
+ found = assemble(own, evolutionOf(name));
68
+ perWorkspace.set(name, found);
69
+ }
70
+ return found;
71
+ });
72
+ }
73
+ /**
74
+ * Bring existing conversations up to the agent's current instructions and default thinking level. A conversation keeps the agent it
75
+ * was created with, so a changed deployment reaches it only through `configure`. The host keeps the hash of `[instructions,
76
+ * thinkingLevel]` (`read`/`write`); when it differs from the agent's, every conversation is configured and the new hash is written
77
+ * last, so a failed run is retried whole. Returns how many conversations were configured (0 when nothing changed).
78
+ */
79
+ export async function refreshAgentState(options) {
80
+ const instructions = options.agent.agent.instructions ?? null, thinkingLevel = options.agent.agent.thinkingLevel ?? null;
81
+ const hash = createHash('sha256').update(JSON.stringify([instructions, thinkingLevel])).digest('hex');
82
+ if ((await options.read()) === hash)
83
+ return 0;
84
+ let configured = 0;
85
+ for await (const conversation of options.conversations) {
86
+ await conversation.configure({ instructions, thinkingLevel }, options.context);
87
+ configured++;
88
+ }
89
+ await options.write(hash);
90
+ return configured;
91
+ }