@hachej/boring-agent 0.4.26 → 0.4.28

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
@@ -327,6 +327,17 @@ The resource must be UTF-8 `application/json`, optionally with `charset=utf-8`.
327
327
 
328
328
  Run `node fixtures/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.
329
329
 
330
+ ## One-call runs
331
+
332
+ A run is one agent asked once, in its own native conversation, for a result: the host starts it by a key, the agent answers by calling a result tool (or in text), the host checks the answer, asks for a corrected one while the repair budget lasts, and hands the end to the operation that started the run. Every home needs the same parts, so they are here once. Pi still owns the turns, the transcript and recovery; these read the transcript and submit native follow-up inputs, and the host keeps its data and its budgets.
333
+
334
+ - `@hachej/boring-agent/run-result` (optional peer `typebox`): `resultConfig` (the card's `result: { schema?, validate?, repairs? }`), `resultParameters`, `resultExecutor` (the result tool's `execute`: checks the call against the schema and the card's validator operation, spends the budget), `settleResult` (wait for the answer, read it, repair it in the run's own conversation with `repair:<conversation>:<n>` inputs, check it once more with `phase: "delivery"`), `runMessage`, `runResult`, `resultState`, `evidenceOf`, `withEvidence`. The validator is an operation the host calls through `invoke(operation, input, key)` as the person whose run it is. `ResultBudget` keeps the stored document shape of the homes that ran this first.
335
+ - `@hachej/boring-agent/runs`: the run ends (`completed`, `invalid_output`, `cancelled`, `interrupted`, `revoked`, `error`), `RunView`, `sumUsage`, `OVER_BUDGET` and `isOverBudget`, and `deliverRunEnd`: bounded attempts under one `deliver:<key>` receipt key, a completed end checked again first and delivered as `invalid_output` when it no longer passes. A delivery that fails is reported, not dropped.
336
+ - `@hachej/boring-agent/run-ledger`: `createRunLedger(sql)` keeps `_host_agent_runs` in the person's own SQLite file; an end happens once (`UPDATE ... RETURNING`), so a cancel and the run's own end can race. `createRunner({ ledger, person, context, has, prepare, invoke, admit?, usage? })` gives `start(agent, input, key, deliverTo)` (the same key is the same run), `status`, `cancel` and `recover`: a run a stopped process left ends `interrupted`, and one that ended without being delivered is delivered. `admit` is the host's spend meter: a refused run is never started and ends `error: over_budget`. The cap, the spend ledger and the prices stay the host's.
337
+ - `@hachej/boring-agent/run-outbox`: `stageStarts(sql, ...)` in the app operation's own transaction, `drainStarts` after the commit. A row is removed only when the run is acknowledged, retried with backoff when it may pass, dropped when it never will.
338
+
339
+ Stores are a synchronous `SyncSql` function (`node:sqlite` `prepare(sql).all(...params)`, a Durable Object's `sql.exec`); nothing here opens a connection or a transaction. Not here, by design: the card parser and its front matter, the app operations a run calls, the schedule and alarm of a Durable Object home, and anything clinical or app specific.
340
+
330
341
  ## Structured output and native tool evidence
331
342
 
332
343
  `@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.
@@ -449,7 +460,7 @@ const delegates = [
449
460
  ];
450
461
  ```
451
462
 
452
- **Schedule.** `@hachej/boring-agent/schedule` exports `createScheduleExtension({ now?, onDeadline?, maxDelaySeconds? })`, one native extension with `schedule({ instruction, delaySeconds | at, operationId? })`, `list_schedules()` and `cancel_schedule({ id })`, plus the delivery task `ScheduledDelivery`. A schedule is a native Pi task owned by the conversation as background work: it sleeps on the Harness clock (`runtime.sleep`) and then submits the instruction to its conversation as a follow-up input (entry kind `boring.schedule.delivery`, request id = schedule id, so a replayed delivery never submits twice). Pi's scheduler runs it and a restart resumes it; there is no second scheduler. Pending schedules live in the conversation document `ConversationSchedules`, so a conversation lists and cancels only its own; a cancelled schedule's task wakes and ends without delivering. `schedule` is idempotent on `operationId` (default: the tool task, so a replayed call returns the schedule it made). Hosts schedule outside a tool with `addSchedule(tx, conversationId, { operationId, instruction, runAt })`, `cancelSchedule(tx, conversationId, id)` and `listSchedules(harness, conversationId, context)`. Pass the Harness's own clock as `now`. A host with one wake-up timer (a Durable Object alarm) computes the earliest deadline from `harness.inspect()` with `scheduledDeadline(task)` and moves the timer earlier from `onDeadline`; its alarm handler only reopens or resumes the Harness, and Pi delivers what is due.
463
+ **Schedule.** `@hachej/boring-agent/schedule` exports `createScheduleExtension({ now?, onDeadline?, maxDelaySeconds? })`, one native extension with `schedule({ instruction, delaySeconds | at, operationId? })`, `list_schedules()` and `cancel_schedule({ id })`, plus the delivery task `ScheduledDelivery`. A schedule is a native Pi task owned by the conversation as background work: it waits on the Harness clock (`runtime.setTimer`; Pi 1.4 removed `runtime.sleep`) and then submits the instruction to its conversation as a follow-up input (entry kind `boring.schedule.delivery`, request id = schedule id, so a replayed delivery never submits twice). Pi's scheduler runs it and a restart resumes it; there is no second scheduler. Pending schedules live in the conversation document `ConversationSchedules`, so a conversation lists and cancels only its own; a cancelled schedule's task wakes and ends without delivering. `schedule` is idempotent on `operationId` (default: the tool task, so a replayed call returns the schedule it made). Hosts schedule outside a tool with `addSchedule(tx, conversationId, { operationId, instruction, runAt })`, `cancelSchedule(tx, conversationId, id)` and `listSchedules(harness, conversationId, context)`. Pass the Harness's own clock as `now`. A host with one wake-up timer (a Durable Object alarm) computes the earliest deadline from `harness.inspect()` with `scheduledDeadline(task)` and moves the timer earlier from `onDeadline`; its alarm handler only reopens or resumes the Harness, and Pi delivers what is due.
453
464
 
454
465
  **Storage conformance.** Boring ships no Pi `Storage` implementation. `test/packages/pi-storage-conformance.test.mjs` runs Pi's own suite (`createStorageConformance`, what `registerStorageConformance` registers with Vitest) on every storage the repository configures: `openPiStorage` (shared/pi-storage.mjs) on the local-disk and network-file-system presets, Pi's `SqliteStorage` over `openBrowserSqlite`, and Pi's `openDurableObjectSqliteStorage` on a SQLite-backed Durable Object in workerd through miniflare (the version the pinned wrangler resolves).
455
466
 
@@ -510,3 +521,33 @@ const navigate = createPresentationTool({
510
521
  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.
511
522
 
512
523
  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.
524
+
525
+ ## Structured actions
526
+
527
+ `@hachej/boring-agent/actions` provides `withActionModels(models, { resolve })`, an optional adapter at the public `HarnessOptions.models` seam. A host decision admits one structured extraction; its single validated tool call is executed by Pi. The next native generation renders a template from the actual tool result, without contacting an LLM. Ambiguous requests return to the full model. No extra harness, tool dispatcher or durable journal is created.
528
+
529
+ ```ts
530
+ import { ACTION_PROVIDER, withActionModels } from '@hachej/boring-agent/actions';
531
+
532
+ const actionModels = withActionModels(admittedModels, {
533
+ resolve: id => id === 'notes' ? {
534
+ model: fullModel,
535
+ extraction: { model: extractionModel, instructions: 'Extract one new note.', tool: noteTool },
536
+ decide: async ({ message, history, signal }) => decideNote({ message, history, signal }),
537
+ reply: 'Saved {note.title}.',
538
+ uncertainReply: 'The outcome is uncertain. Check before retrying.',
539
+ } : undefined,
540
+ });
541
+ // Pass actionModels as the models option when the host opens its native Harness.
542
+ await conversation.configure({ model: { provider: ACTION_PROVIDER, modelId: 'notes' } }, context);
543
+ ```
544
+
545
+ `admittedModels` is the host's existing native Models, including its provider authentication and budget wrappers. `noteTool` is a native tool declaration, already installed and allowed in the conversation. The extraction schema may be stricter than that tool's schema; both are checked without coercion before returning a call. No commercial provider, API key, subscription or Jev client is embedded in the package. The host's `decide` adapts its chosen decision service; only literal `true` admits extraction. The callback gets native prior messages so the host can choose and bound the authorized decision context. Extraction receives only the current text input.
546
+
547
+ The default decision deadline is 400 ms and the input cap is 1,500 characters; `decisionTimeoutMs` and `maxInputCharacters` override them. Decision failure/timeout, non-text input, missing current tool, malformed/multiple extraction calls or extraction refusal return to the full model. The decision receives an abort signal; an uncooperative service may finish later, but its result cannot start extraction. Native compaction uses `completeSimple` with a tool-free summarization context, which delegates to the full model. Explicit deferred requests also use the full model; polling and cancellation retain the underlying provider. Missing action definitions or full models return the native unavailable-model result. Unselected models and native registry capabilities remain available.
548
+
549
+ Reply placeholders such as `{note.title}` accept actual strings, finite numbers and string arrays (joined by newline). Missing output, a failed tool or unreadable result produces `uncertainReply`, never a blind replay of a possible mutation. `rejectedWithoutEffect(result)` may opt into full-model handling only when the host can prove an atomic rejection; a throwing predicate leaves the outcome uncertain. Tools still own permission checks and durable idempotent receipts; the adapter cannot make external writes transactional. Install the adapter outside the metered Models so real extraction and fallback requests retain admission. Synthetic replies have zero usage; failed extraction usage needs host request metering because only the final fallback message enters Pi's transcript.
550
+
551
+ Hosts must explicitly configure existing conversations when adopting this virtual model; deployment alone does not change their persisted selection. Keep the action's tool and result contract compatible while native work is pending. Changing definitions during an outstanding action is not a version-pinning guarantee.
552
+
553
+ Evidence: `node --test test/packages/agent-actions.test.mjs` runs the public package through a native Harness, SQLite reopen, native tool gates and scripted models. `npm run test:definitions-consumer -- --actions` repeats these tests and checks strict installed declarations. Real-model speed and decision accuracy remain consumer evals, not claims of these scripted tests.
@@ -0,0 +1,38 @@
1
+ import type { Message, Models, Tool, ToolResultMessage } from '@earendil-works/pi-ai';
2
+ import type { ModelRef } from '@earendil-works/pi-durable';
3
+ /** Virtual model identity persisted by Pi; real requests use the host's Models. */
4
+ export declare const ACTION_PROVIDER = "boring-action";
5
+ export interface StructuredAction {
6
+ readonly model: ModelRef;
7
+ readonly extraction: {
8
+ readonly model: ModelRef;
9
+ readonly instructions: string;
10
+ readonly tool: Tool;
11
+ };
12
+ /** Host decision policy (Jev, another classifier, or deterministic rules). Literal true admits extraction. */
13
+ readonly decide: (input: {
14
+ readonly message: string;
15
+ readonly history: readonly Message[];
16
+ readonly signal: AbortSignal;
17
+ }) => Promise<boolean>;
18
+ readonly reply: string;
19
+ /** Host-localized text for an outcome that must not be retried automatically. */
20
+ readonly uncertainReply: string;
21
+ /** Only return true for a known atomic rejection with no effect. */
22
+ readonly rejectedWithoutEffect?: (result: ToolResultMessage) => boolean;
23
+ readonly decisionTimeoutMs?: number;
24
+ readonly maxInputCharacters?: number;
25
+ }
26
+ export interface ActionModelsOptions {
27
+ /** Resolve trusted configuration; this never registers tools or grants permissions. */
28
+ readonly resolve: (id: string) => StructuredAction | undefined;
29
+ readonly now?: () => number;
30
+ }
31
+ /** Substitute actual scalar results only. Missing values cannot become invented confirmations. */
32
+ export declare function actionReply(template: string, result: unknown): string;
33
+ /** Compose at HarnessOptions.models, outside the host's admitted/metered real Models.
34
+ * Pi still owns generation, tool execution, hooks, history and recovery. Tool-free requests
35
+ * (including native compaction) delegate to the full model. No tool is executed here.
36
+ */
37
+ export declare function withActionModels<T extends Models>(models: T, options: ActionModelsOptions): T;
38
+ //# sourceMappingURL=actions.d.ts.map
@@ -0,0 +1,158 @@
1
+ import { createAssistantMessageEventStream, getCurrentTools, normalizeContext } from '@earendil-works/pi-ai';
2
+ import { Check } from 'typebox/schema';
3
+ /** Virtual model identity persisted by Pi; real requests use the host's Models. */
4
+ export const ACTION_PROVIDER = 'boring-action';
5
+ const PREFIX = 'boring_action_';
6
+ const zeroUsage = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } });
7
+ const inputText = (content) => typeof content === 'string' ? content : content.filter(p => p.type === 'text').map(p => p.text).join('');
8
+ /** Substitute actual scalar results only. Missing values cannot become invented confirmations. */
9
+ export function actionReply(template, result) {
10
+ return template.replace(/\{([\w.]+)\}/g, (_match, path) => {
11
+ let value = result;
12
+ for (const key of path.split('.')) {
13
+ value = value !== null && typeof value === 'object' && Object.hasOwn(value, key) ? Reflect.get(value, key) : undefined;
14
+ }
15
+ if (Array.isArray(value) && value.every(v => typeof v === 'string'))
16
+ value = value.join('\n');
17
+ if (typeof value !== 'string' && !(typeof value === 'number' && Number.isFinite(value)))
18
+ throw new Error(`Missing action result: ${path}`);
19
+ return String(value);
20
+ });
21
+ }
22
+ async function admitted(action, message, history, parent) {
23
+ const controller = new AbortController();
24
+ const signal = parent ? AbortSignal.any([parent, controller.signal]) : controller.signal;
25
+ if (signal.aborted)
26
+ return false;
27
+ let timer;
28
+ let onAbort = () => { };
29
+ const cancelled = new Promise(resolve => {
30
+ onAbort = () => resolve(false);
31
+ signal.addEventListener('abort', onAbort, { once: true });
32
+ timer = setTimeout(() => controller.abort(), action.decisionTimeoutMs ?? 400);
33
+ });
34
+ try {
35
+ return await Promise.race([Promise.resolve().then(() => action.decide({ message, history, signal })).then(value => value === true, () => false), cancelled]);
36
+ }
37
+ finally {
38
+ clearTimeout(timer);
39
+ signal.removeEventListener('abort', onAbort);
40
+ controller.abort();
41
+ }
42
+ }
43
+ /** Compose at HarnessOptions.models, outside the host's admitted/metered real Models.
44
+ * Pi still owns generation, tool execution, hooks, history and recovery. Tool-free requests
45
+ * (including native compaction) delegate to the full model. No tool is executed here.
46
+ */
47
+ export function withActionModels(models, options) {
48
+ const now = options.now ?? Date.now;
49
+ const required = (ref) => {
50
+ const model = models.getModel(ref.provider, ref.modelId);
51
+ if (!model || model.provider === ACTION_PROVIDER)
52
+ throw new Error(`Unavailable action model: ${ref.provider}/${ref.modelId}`);
53
+ return model;
54
+ };
55
+ const resolve = (id) => {
56
+ const action = options.resolve(id);
57
+ if (!action)
58
+ throw new Error(`No action agent ${id}`);
59
+ return action;
60
+ };
61
+ const wrapped = {
62
+ getModel(provider, id) {
63
+ if (provider !== ACTION_PROVIDER)
64
+ return models.getModel(provider, id);
65
+ const action = options.resolve(id);
66
+ const model = action && models.getModel(action.model.provider, action.model.modelId);
67
+ return model && model.provider !== ACTION_PROVIDER ? { ...model, provider, id } : undefined;
68
+ },
69
+ streamSimple(model, context, requestOptions) {
70
+ if (model.provider !== ACTION_PROVIDER)
71
+ return models.streamSimple(model, context, requestOptions);
72
+ const action = resolve(model.id);
73
+ const original = required(action.model);
74
+ const out = createAssistantMessageEventStream();
75
+ const full = async () => { for await (const event of models.streamSimple(original, context, requestOptions))
76
+ out.push(event); };
77
+ const emit = (message) => {
78
+ if (message.stopReason === 'error' || message.stopReason === 'aborted')
79
+ out.push({ type: 'error', reason: message.stopReason, error: message });
80
+ else if (message.stopReason !== 'pending')
81
+ out.push({ type: 'done', reason: message.stopReason, message });
82
+ else
83
+ throw new Error('An action cannot emit a pending completion');
84
+ };
85
+ void (async () => {
86
+ const messages = context.messages.filter(message => message.role !== 'system');
87
+ const last = messages.at(-1), previous = messages.at(-2);
88
+ const calls = previous?.role === 'assistant' ? previous.content.filter(p => p.type === 'toolCall') : [];
89
+ const call = calls.length === 1 ? calls[0] : undefined;
90
+ if (last?.role === 'toolResult' && call?.id === last.toolCallId && call.name === action.extraction.tool.name && call.id.startsWith(PREFIX)) {
91
+ if (last.isError) {
92
+ let rejected = false;
93
+ try {
94
+ rejected = action.rejectedWithoutEffect?.(last) === true;
95
+ }
96
+ catch { /* Unknown outcomes remain uncertain. */ }
97
+ if (rejected)
98
+ return full();
99
+ }
100
+ let text = action.uncertainReply;
101
+ if (!last.isError) {
102
+ try {
103
+ text = actionReply(action.reply, JSON.parse(inputText(last.content)));
104
+ }
105
+ catch { /* A possible effect must not be repeated. */ }
106
+ }
107
+ emit({ role: 'assistant', content: [{ type: 'text', text }], api: original.api, provider: ACTION_PROVIDER, model: model.id, usage: zeroUsage(), stopReason: 'stop', timestamp: now() });
108
+ return;
109
+ }
110
+ const tool = getCurrentTools(normalizeContext(context).messages).find(tool => tool.name === action.extraction.tool.name);
111
+ if (!tool || last?.role !== 'user' || requestOptions?.signal?.aborted || requestOptions?.deferred)
112
+ return full();
113
+ const raw = inputText(last.content);
114
+ if (!raw.trim() || raw.length > (action.maxInputCharacters ?? 1500) || (Array.isArray(last.content) && last.content.some(p => p.type !== 'text')))
115
+ return full();
116
+ if (!await admitted(action, raw, messages.slice(0, -1), requestOptions?.signal) || requestOptions?.signal?.aborted)
117
+ return full();
118
+ let extracted;
119
+ try {
120
+ extracted = await models.streamSimple(required(action.extraction.model), {
121
+ systemPrompt: action.extraction.instructions, messages: [last], tools: [action.extraction.tool],
122
+ }, requestOptions).result();
123
+ const extractedCalls = extracted.content.filter(p => p.type === 'toolCall');
124
+ const extractedCall = extractedCalls.length === 1 ? extractedCalls[0] : undefined;
125
+ if (extracted.stopReason !== 'toolUse' || !extractedCall || extractedCall.name !== tool.name ||
126
+ !Check(action.extraction.tool.parameters, extractedCall.arguments) || !Check(tool.parameters, extractedCall.arguments))
127
+ return full();
128
+ // Native ToolTask owns the write, permission checks and host receipt; keep this ID in its transcript.
129
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
130
+ const id = PREFIX + Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
131
+ emit({ ...extracted, content: [{ ...extractedCall, id }] });
132
+ }
133
+ catch {
134
+ return full();
135
+ }
136
+ })().catch(error => emit({ role: 'assistant', content: [], api: original.api, provider: original.provider, model: original.id, usage: zeroUsage(), stopReason: 'error', errorMessage: String(error instanceof Error ? error.message : error), timestamp: now() })).finally(() => out.end());
137
+ return out;
138
+ },
139
+ stream(model, context, requestOptions) {
140
+ return models.stream(model.provider === ACTION_PROVIDER ? required(resolve(model.id).model) : model, context, requestOptions);
141
+ },
142
+ completeSimple: (model, context, requestOptions) => wrapped.streamSimple(model, context, requestOptions).result(),
143
+ complete: (model, context, requestOptions) => wrapped.stream(model, context, requestOptions).result(),
144
+ streamDeferred(model, handle, requestOptions) {
145
+ return models.streamDeferred(model.provider === ACTION_PROVIDER ? required(resolve(model.id).model) : model, handle, requestOptions);
146
+ },
147
+ fetchDeferred: (model, handle, requestOptions) => wrapped.streamDeferred(model, handle, requestOptions).result(),
148
+ cancelDeferred(model, handle, requestOptions) {
149
+ return models.cancelDeferred(model.provider === ACTION_PROVIDER ? required(resolve(model.id).model) : model, handle, requestOptions);
150
+ },
151
+ };
152
+ return new Proxy(models, { get(target, key) {
153
+ if (Object.hasOwn(wrapped, key))
154
+ return Reflect.get(wrapped, key);
155
+ const value = Reflect.get(target, key);
156
+ return typeof value === 'function' ? value.bind(target) : value;
157
+ } });
158
+ }
package/dist/ask-user.js CHANGED
@@ -6,7 +6,7 @@ export const ASK_USER_TOOL = 'ask_user';
6
6
  export const ASK_USER_MAX_ANSWER = 2000;
7
7
  /** One member per assistant entry and tool call, in the asking conversation. */
8
8
  const documents = defineDocFamily({
9
- kind: 'boring.ask-user.question', version: 1, scope: 'conversation', history: 'latest', fork: 'initial', family: true,
9
+ kind: 'boring.ask-user.question', checkpointWhen: () => false, version: 1, scope: 'conversation', history: 'latest', fork: 'initial', family: true,
10
10
  initial: () => ({ question: null }),
11
11
  });
12
12
  /** Bind an answer to the exact assistant entry that asked it. */
@@ -11,7 +11,7 @@ import { AmbiguousCall, UnknownCall, callBinding, callBindingId, ownBindingKey,
11
11
  export const BROWSER_TASK_MAX_ANSWER = 16_384;
12
12
  /** One member per assistant entry and tool call, in the calling conversation. */
13
13
  const documents = defineDocFamily({
14
- kind: 'boring.browser-task', version: 1, scope: 'conversation', history: 'latest', fork: 'initial', family: true,
14
+ kind: 'boring.browser-task', checkpointWhen: () => false, version: 1, scope: 'conversation', history: 'latest', fork: 'initial', family: true,
15
15
  initial: () => ({ task: null }),
16
16
  });
17
17
  /** The answer ID of a browser task call: the same `[assistantEntryId, callId]` binding as `askUserQuestionId`. */
@@ -3,7 +3,7 @@ import { RequestGuardError, hasJsonContentType, readJsonBody } from '@hachej/bor
3
3
  import { MENTION_FILE_PREFIX } from './mentions.js';
4
4
  /** The native document. `fork: 'initial'`: a fork made by `fork()` gets its own metadata in the creating commit; any other fork starts unmanaged. */
5
5
  export const conversationMetadata = defineDoc({
6
- kind: 'boring.conversation.metadata', version: 1, scope: 'conversation', history: 'latest', fork: 'initial',
6
+ kind: 'boring.conversation.metadata', checkpointWhen: () => false, version: 1, scope: 'conversation', history: 'latest', fork: 'initial',
7
7
  initial: () => ({ owner: '', title: null, createdAt: 0, updatedAt: 0, lastMessage: null, archived: false, deletedAt: null }),
8
8
  });
9
9
  const managed = (meta, owner) => meta !== undefined && meta.owner !== '' && meta.deletedAt === null && (owner === undefined || meta.owner === owner);
@@ -4,7 +4,7 @@ import { createEditTool, createReadTool, createWriteTool } from '@earendil-works
4
4
  import { asWorkspaceResolver, decorate, workspaceFor } from './workspaces.js';
5
5
  /** Conversation document: workspace and path (relative to the workspace root; see `baselineKey`) to the revision this conversation last read or wrote. */
6
6
  export const lastReadRevisions = defineDoc({
7
- kind: 'boring.files.last-read', version: 1, scope: 'conversation', history: 'latest', fork: 'initial',
7
+ kind: 'boring.files.last-read', checkpointWhen: () => false, version: 1, scope: 'conversation', history: 'latest', fork: 'initial',
8
8
  initial: () => ({ revisions: {} }),
9
9
  });
10
10
  /** The baseline key of `path` in `workspace` (its `id`, or its provider id): `<workspace>:<path>`. */
@@ -15,26 +15,26 @@ export { DEFAULTS as OPTCHAT_DEFAULTS, PLACEHOLDER as OPTCHAT_PLACEHOLDER, compa
15
15
  export { OptChatLog, OptChatTree, advanceFold, emptyFold, leavesOf, renderView, zoomText, dateText } from './optchat-core.js';
16
16
  /** Summaries of one conversation, in shards of 64 nodes per level (`<level>.<shard>`). A fork starts with the current ones; fingerprints reject any that no longer match. */
17
17
  export const OptChatNodes = defineDocFamily({
18
- kind: 'boring.memory.optchat.nodes', version: 1, family: true, scope: 'conversation', history: 'latest', fork: 'current', initial: () => ({ nodes: {} }),
18
+ kind: 'boring.memory.optchat.nodes', checkpointWhen: () => false, version: 1, family: true, scope: 'conversation', history: 'latest', fork: 'current', initial: () => ({ nodes: {} }),
19
19
  });
20
20
  /** Seed memories, stored once per conversation and inherited by forks. They precede the Pi transcript in the memory only. */
21
21
  export const OptChatNotes = defineDoc({
22
- kind: 'boring.memory.optchat.notes', version: 1, scope: 'conversation', history: 'latest', fork: 'current',
22
+ kind: 'boring.memory.optchat.notes', checkpointWhen: () => false, version: 1, scope: 'conversation', history: 'latest', fork: 'current',
23
23
  initial: () => ({ seeded: false, notes: [] }),
24
24
  });
25
25
  /** Memory-only tombstones. Values are leaf counts, never the forgotten content. Pi's transcript is unchanged. */
26
26
  export const OptChatEdits = defineDoc({
27
- kind: 'boring.memory.optchat.edits', version: 1, scope: 'conversation', history: 'latest', fork: 'current',
27
+ kind: 'boring.memory.optchat.edits', checkpointWhen: () => false, version: 1, scope: 'conversation', history: 'latest', fork: 'current',
28
28
  initial: () => ({ revision: 0, forgotten: {} }),
29
29
  });
30
30
  /** Only the emitted tiling and fingerprints are retained, never another copy of message or summary text. */
31
31
  const OptChatFold = defineDocFamily({
32
- kind: 'boring.memory.optchat.fold', version: 1, family: true, scope: 'conversation', history: 'latest', fork: 'current',
32
+ kind: 'boring.memory.optchat.fold', checkpointWhen: () => false, version: 1, family: true, scope: 'conversation', history: 'latest', fork: 'current',
33
33
  initial: () => ({ configuration: '', revision: -1, fingerprints: [], fold: null }),
34
34
  });
35
35
  /** Compactor bookkeeping of one conversation: calls, spend and the last failure of each node, including its retry streak. */
36
36
  export const OptChatState = defineDoc({
37
- kind: 'boring.memory.optchat.state', version: 1, scope: 'conversation', history: 'latest', fork: 'initial',
37
+ kind: 'boring.memory.optchat.state', checkpointWhen: () => false, version: 1, scope: 'conversation', history: 'latest', fork: 'initial',
38
38
  initial: () => ({ calls: 0, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }, failures: {} }),
39
39
  });
40
40
  const SHARD = 64;
@@ -3,7 +3,7 @@
3
3
  * native shapes produced them. New records take the current release; records written under an earlier reviewed release stay
4
4
  * readable, because the native conversation and tool shapes they refer to did not change between them.
5
5
  */
6
- export declare const NATIVE_VERSION = "pi-durable@1.2.0";
6
+ export declare const NATIVE_VERSION = "pi-durable@1.4.0";
7
7
  export type NativeVersion = 'pi-durable@1.0.1' | 'pi-durable@1.1.0' | typeof NATIVE_VERSION;
8
8
  export declare const isNativeVersion: (value: unknown) => value is NativeVersion;
9
9
  //# sourceMappingURL=native-version.d.ts.map
@@ -3,5 +3,5 @@
3
3
  * native shapes produced them. New records take the current release; records written under an earlier reviewed release stay
4
4
  * readable, because the native conversation and tool shapes they refer to did not change between them.
5
5
  */
6
- export const NATIVE_VERSION = 'pi-durable@1.2.0';
6
+ export const NATIVE_VERSION = 'pi-durable@1.4.0';
7
7
  export const isNativeVersion = (value) => value === NATIVE_VERSION || value === 'pi-durable@1.1.0' || value === 'pi-durable@1.0.1';
package/dist/questions.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ReadAfterWrite, defineDocFamily, defineExtension, defineTask } from '@earendil-works/pi-durable';
2
+ import { waitUntil } from './wait-until.js';
2
3
  function sameRef(left, right) {
3
4
  return left.runtimeId === right.runtimeId && left.conversationId === right.conversationId
4
5
  && left.taskId === right.taskId && left.questionId === right.questionId && left.scopeId === right.scopeId
@@ -11,7 +12,7 @@ export function createQuestions(options) {
11
12
  throw new TypeError('A stable runtime identity is required');
12
13
  const now = options.now ?? Date.now;
13
14
  const documents = defineDocFamily({
14
- kind: 'boring.questions.record', version: 1, scope: 'session', family: true,
15
+ kind: 'boring.questions.record', checkpointWhen: () => false, version: 1, scope: 'session', family: true,
15
16
  initial: () => ({ question: null }),
16
17
  });
17
18
  const key = (ref) => JSON.stringify([runtimeId, ref.scopeId, ref.questionId]);
@@ -40,11 +41,17 @@ export function createQuestions(options) {
40
41
  watch.start(async (value) => { if (value?.question?.state.kind !== 'pending')
41
42
  resolve(); });
42
43
  });
43
- await Promise.race([
44
- changed,
45
- runtime.sleep(Date.parse(watch.value.question.ref.expiresAt), context),
46
- watch.closed.then(() => { throw new Error('Question observation closed'); }),
47
- ]);
44
+ const expiry = waitUntil(runtime, Date.parse(watch.value.question.ref.expiresAt), context);
45
+ try {
46
+ await Promise.race([
47
+ changed,
48
+ expiry.done,
49
+ watch.closed.then(() => { throw new Error('Question observation closed'); }),
50
+ ]);
51
+ }
52
+ finally {
53
+ expiry.disarm();
54
+ }
48
55
  }
49
56
  await runtime.commit(async (tx) => {
50
57
  const doc = await tx.doc(documents, running.input.key, null);
package/dist/room.js CHANGED
@@ -118,8 +118,8 @@ export function wakeFor(entry, room) {
118
118
  return room.people.length === 1 && room.people[0] === author && room.agents.includes('agent:pm') ? [wake('agent:pm')] : [];
119
119
  }
120
120
  /** Conversation names to native ids, and each card's first entry (an action's target). */
121
- const RoomIndex = defineDoc({ kind: 'boring.room.index', version: 1, scope: 'session', initial: () => ({ conversations: {}, cards: {} }) });
122
- const RoomWakes = defineDoc({ kind: 'boring.room.wakes', version: 1, scope: 'session', initial: () => ({ open: [] }) });
121
+ const RoomIndex = defineDoc({ kind: 'boring.room.index', checkpointWhen: () => false, version: 1, scope: 'session', initial: () => ({ conversations: {}, cards: {} }) });
122
+ const RoomWakes = defineDoc({ kind: 'boring.room.wakes', checkpointWhen: () => false, version: 1, scope: 'session', initial: () => ({ open: [] }) });
123
123
  const REFUSED = Object.freeze({ refused: 'no-open-wake' });
124
124
  const copyWake = (wake) => ({ ...wake });
125
125
  /** The room over one Harness that never runs a model. Membership, roles and rates are the host's, before it calls. */
@@ -264,7 +264,7 @@ const framesOf = (entries) => entries.flatMap(entry => (entry.model ?? []).flatM
264
264
  return [];
265
265
  return message.content.flatMap((part) => part.type === 'text' && part.text ? [{ type: 'text', text: part.text }] : part.type === 'toolCall' ? [{ type: 'step', label: part.name, status: 'running' }] : []);
266
266
  }));
267
- const Turns = defineDoc({ kind: 'boring.room.turns', version: 1, scope: 'conversation', history: 'latest', fork: 'initial', initial: () => ({ owed: [] }) });
267
+ const Turns = defineDoc({ kind: 'boring.room.turns', checkpointWhen: () => false, version: 1, scope: 'conversation', history: 'latest', fork: 'initial', initial: () => ({ owed: [] }) });
268
268
  /** The agent's side of a room, over its own conversation in its own home. Delivery to the port is at least once. */
269
269
  export function createRoomTurn(options) {
270
270
  const { conversation, port } = options;
@@ -0,0 +1,98 @@
1
+ import type { Context } from '@earendil-works/chord';
2
+ import type { SyncSql } from './run-outbox.js';
3
+ import type { ResultCard, ResultConversation, ResultInvoke, ResultSubmission, ResultValidationRecord } from './run-result.js';
4
+ import type { RunEnd, RunUsage, RunView } from './runs.js';
5
+ /**
6
+ * The runs a home keeps in a person's own store, and the runner over them: start a run by its key (the same key is the same run),
7
+ * end it exactly once, deliver its end to the operation that started it, cancel it, and find again what a stopped process left.
8
+ * The store is the host's data (a table in its SQLite file); the model turns stay Pi's (the run is a native conversation).
9
+ */
10
+ export declare const RUNS_TABLE = "CREATE TABLE IF NOT EXISTS _host_agent_runs (key TEXT PRIMARY KEY, agent TEXT NOT NULL, status TEXT NOT NULL, output TEXT, error TEXT, deliver TEXT, validation TEXT, delivered INTEGER NOT NULL DEFAULT 0, person TEXT NOT NULL, usage TEXT, started_at TEXT)";
11
+ export interface RunRow {
12
+ readonly key: string;
13
+ readonly agent: string;
14
+ readonly status: string;
15
+ readonly output: string | null;
16
+ readonly error: string | null;
17
+ readonly deliver: string | null;
18
+ readonly validation: string | null;
19
+ readonly delivered: number;
20
+ readonly person: string;
21
+ readonly usage: string | null;
22
+ readonly started_at: string | null;
23
+ }
24
+ /** A refusal with an HTTP-like `status` (404: no such agent; 409: the key belongs to another agent). */
25
+ export declare class RunRefused extends Error {
26
+ readonly status: number;
27
+ constructor(status: number, message: string);
28
+ }
29
+ export interface EndOptions {
30
+ readonly output?: unknown;
31
+ readonly error?: string | null;
32
+ readonly validation?: ResultValidationRecord;
33
+ readonly usage?: RunUsage | null;
34
+ }
35
+ /** The ledger over a store's synchronous SQL (`SyncSql`: `UPDATE ... RETURNING` is what makes an end happen once). */
36
+ export declare function createRunLedger(sql: SyncSql): {
37
+ ensure: () => void;
38
+ get: (key: string) => RunRow | undefined;
39
+ /** Record a new running run. The caller has checked `get(key)` first. */
40
+ begin(run: {
41
+ key: string;
42
+ agent: string;
43
+ deliver: string | null;
44
+ person: unknown;
45
+ startedAt: string;
46
+ }): void;
47
+ /** End a run still running, once (a cancel and the run's own end race): false when something else ended it first. */
48
+ end(key: string, status: RunEnd, options?: EndOptions): boolean;
49
+ /** A completed run whose delivery-time check failed: its end becomes the failure (never after it was delivered). */
50
+ fail(key: string, status: RunEnd, error: string): void;
51
+ markDelivered(key: string): void;
52
+ /** Runs a stopped process left: still running (they end `interrupted`), or ended and not delivered. */
53
+ left(): RunRow[];
54
+ view(row: RunRow): RunView;
55
+ };
56
+ export type RunLedger = ReturnType<typeof createRunLedger>;
57
+ /** A started run's live handle: its conversation and first submission, and the card it answers. */
58
+ export interface RunExecution {
59
+ readonly conversation: ResultConversation & {
60
+ abort(context: Context): Promise<unknown>;
61
+ };
62
+ readonly submission: ResultSubmission;
63
+ readonly card: ResultCard;
64
+ }
65
+ export interface RunnerOptions {
66
+ readonly ledger: RunLedger;
67
+ /** The person whose store this is: written on each row so another process can deliver it as them. */
68
+ readonly person: unknown;
69
+ readonly context: Context;
70
+ /** Whether the home has this agent (a start of an unknown agent is refused 404 before any row is written). */
71
+ readonly has: (agent: string) => boolean | Promise<boolean>;
72
+ /** Open the run's conversation and submit its input: null when the agent is gone. */
73
+ readonly prepare: (agent: string, input: unknown) => Promise<RunExecution | null>;
74
+ /** Call an operation as the person: the result validators and the delivery (which may reach an `internal` operation). */
75
+ readonly invoke: ResultInvoke;
76
+ /** The run's model answers summed, from its conversation; null when none answered. */
77
+ readonly usage?: (conversation: RunExecution['conversation']) => Promise<RunUsage | null>;
78
+ /** The host's spend meter: throws `over_budget` when the app may not spend now. A refused run is never started and ends `error: over_budget`. */
79
+ readonly admit?: () => void;
80
+ readonly now?: () => string;
81
+ readonly deliverAttempts?: number;
82
+ readonly deliverBackoffMs?: number;
83
+ }
84
+ /**
85
+ * The runner of one person's store. A run lives in this process: one a restart cut short is found by `recover` and ends
86
+ * `interrupted`; one that ended and was not delivered is delivered then. Delivery is once (the host's receipt `deliver:<key>`).
87
+ */
88
+ export declare function createRunner(options: RunnerOptions): {
89
+ /** Starts the run of this key (a key already started is the same run, whatever else is asked): launched and returned at once. */
90
+ start(agent: string, input: unknown, key: string, deliverTo: string | null): Promise<RunView>;
91
+ /** The run of this key as recorded, or null. Never waits on a delivery in flight: safe to call from inside one. */
92
+ status(key: string): Promise<RunView | null>;
93
+ /** Cancels it (its delivery gets `cancelled`); a run that has ended stays as it is; null: no such run. */
94
+ cancel(key: string): Promise<RunView | null>;
95
+ recover: () => Promise<void>;
96
+ };
97
+ export type Runner = ReturnType<typeof createRunner>;
98
+ //# sourceMappingURL=run-ledger.d.ts.map