@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
@@ -0,0 +1,93 @@
1
+ import { CONVERSATION_PROJECTION_LIMITS as limits, projectionBytes } from './projection-format.js';
2
+ function* textBlocks(message) {
3
+ if (typeof message.content === 'string')
4
+ yield { blockIndex: 0, text: message.content };
5
+ else
6
+ for (const [blockIndex, block] of message.content.entries())
7
+ if (block.type === 'text')
8
+ yield { blockIndex, text: block.text };
9
+ }
10
+ function scalarPrefix(text, length) {
11
+ const last = text.charCodeAt(length - 1), next = text.charCodeAt(length);
12
+ return text.slice(0, last >= 0xd800 && last <= 0xdbff && next >= 0xdc00 && next <= 0xdfff ? length - 1 : length);
13
+ }
14
+ function clippedText(text, bytes) {
15
+ let low = 0, high = Math.min(text.length, bytes);
16
+ const candidate = scalarPrefix(text, high);
17
+ if (projectionBytes(candidate) - 2 <= bytes)
18
+ return candidate;
19
+ while (low < high) {
20
+ const middle = Math.ceil((low + high) / 2);
21
+ if (projectionBytes(scalarPrefix(text, middle)) - 2 <= bytes)
22
+ low = middle;
23
+ else
24
+ high = middle - 1;
25
+ }
26
+ return scalarPrefix(text, low);
27
+ }
28
+ function projectRow(entry, message, messageIndex) {
29
+ if (message.role !== 'user' && message.role !== 'assistant')
30
+ return;
31
+ const content = [];
32
+ const base = { entryId: entry.id, conversationId: entry.conversationId,
33
+ ...(entry.byTaskId === undefined ? {} : { byTaskId: entry.byTaskId }), messageIndex, role: message.role };
34
+ let bytes = projectionBytes({ ...base, content, clipped: false }), clipped = false;
35
+ for (const block of textBlocks(message)) {
36
+ const overhead = projectionBytes({ blockIndex: block.blockIndex, text: '' }) + (content.length ? 1 : 0);
37
+ const available = limits.maxMessageBytes - bytes - overhead;
38
+ if (available < 0) {
39
+ clipped = true;
40
+ break;
41
+ }
42
+ const text = clippedText(block.text, available);
43
+ content.push({ blockIndex: block.blockIndex, text });
44
+ bytes += overhead + projectionBytes(text) - 2;
45
+ if (text.length !== block.text.length) {
46
+ clipped = true;
47
+ break;
48
+ }
49
+ }
50
+ return content.length ? { ...base, content, clipped } : undefined;
51
+ }
52
+ function* newestEntries(entries) {
53
+ let head = entries[0]?.head === undefined ? undefined : entries[0];
54
+ const first = head ? 1 : 0;
55
+ for (let position = entries.length - 1; position >= first; position--) {
56
+ const entry = entries[position];
57
+ if (!entry)
58
+ continue;
59
+ if (head && head.id > entry.id) {
60
+ yield { entry: head, position: 0 };
61
+ head = undefined;
62
+ }
63
+ yield { entry, position };
64
+ }
65
+ if (head)
66
+ yield { entry: head, position: 0 };
67
+ }
68
+ export function projectConversationWindow(entries, identity, allowEntry) {
69
+ const selected = [];
70
+ let bytes = 2, truncated = false;
71
+ outer: for (const { entry, position } of newestEntries(entries)) {
72
+ if (allowEntry(identity, entry) !== true)
73
+ continue;
74
+ const model = entry.model ?? [];
75
+ for (let j = model.length - 1; j >= 0; j--) {
76
+ const message = model[j];
77
+ if (!message)
78
+ continue;
79
+ const row = projectRow(entry, message, j);
80
+ if (!row)
81
+ continue;
82
+ const size = projectionBytes(row) + (selected.length ? 1 : 0);
83
+ if (selected.length === limits.maxMessages || bytes + size > limits.maxWindowBytes) {
84
+ truncated = true;
85
+ break outer;
86
+ }
87
+ selected.push({ row, position });
88
+ bytes += size;
89
+ }
90
+ }
91
+ selected.sort((a, b) => a.position - b.position || a.row.messageIndex - b.row.messageIndex);
92
+ return { messages: selected.map(item => item.row), window: { truncated } };
93
+ }
@@ -0,0 +1,22 @@
1
+ import type { Context } from '@earendil-works/chord';
2
+ import type { Conversation, EntryRecord } from '@earendil-works/pi-durable';
3
+ import type { ProjectionIdentity } from './projection-format.js';
4
+ export { CONVERSATION_PROJECTION_LIMITS, createConversationTextReceiver } from './projection-format.js';
5
+ export type { ProjectionIdentity, ConversationTextProjection, ConversationTextSnapshot, ConversationTextDelta, ConversationTextRow, ConversationTextKey, ConversationTextReceiver } from './projection-format.js';
6
+ export interface ConversationProjectionAccess {
7
+ readonly runtimeId: string;
8
+ readonly scopeId: string;
9
+ readonly principalId: string;
10
+ readonly conversation: Conversation;
11
+ readonly context: Context;
12
+ /** The host must abort on revocation or identity change, including while idle. */
13
+ readonly revoked: AbortSignal;
14
+ readonly authorize: (identity: ProjectionIdentity, phase: 'open' | 'initial' | 'delivery') => Promise<boolean>;
15
+ readonly allowEntry: (identity: ProjectionIdentity, entry: EntryRecord) => boolean;
16
+ }
17
+ export interface ConversationProjectionOptions {
18
+ /** Authenticate and resolve the native conversation in trusted host code. */
19
+ readonly authenticate: (request: Request) => Promise<ConversationProjectionAccess | null>;
20
+ }
21
+ export declare function createConversationProjectionHandler(options: ConversationProjectionOptions): (request: Request) => Promise<Response>;
22
+ //# sourceMappingURL=projection.d.ts.map
@@ -0,0 +1,153 @@
1
+ import { CONVERSATION_PROJECTION_LIMITS as limits, projectionEncoder, projectionIdentity, projectionKey } from './projection-format.js';
2
+ import { projectConversationWindow } from './projection-window.js';
3
+ import { NATIVE_VERSION } from './native-version.js';
4
+ export { CONVERSATION_PROJECTION_LIMITS, createConversationTextReceiver } from './projection-format.js';
5
+ function failure(status, reason) {
6
+ return Response.json({ schema: 'boring.conversation-text', version: 2, reason }, {
7
+ status, headers: { 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' },
8
+ });
9
+ }
10
+ // request-guard: exempt (GET only; this handler never reads a request body)
11
+ export function createConversationProjectionHandler(options) {
12
+ return async (request) => {
13
+ if (request.method !== 'GET')
14
+ return failure(405, 'method-not-supported');
15
+ if (new URL(request.url).searchParams.get('version') !== '2')
16
+ return failure(426, 'unsupported-version');
17
+ let access;
18
+ try {
19
+ access = await options.authenticate(request);
20
+ }
21
+ catch {
22
+ return failure(503, 'authentication-unavailable');
23
+ }
24
+ if (!access)
25
+ return failure(401, 'authentication-required');
26
+ const { conversation, context, revoked } = access;
27
+ const authorize = access.authorize.bind(access), allowEntry = access.allowEntry.bind(access);
28
+ const identity = Object.freeze({ runtimeId: access.runtimeId, scopeId: access.scopeId, principalId: access.principalId, conversationId: conversation.id });
29
+ if (!projectionIdentity(identity))
30
+ return failure(503, 'identity-unavailable');
31
+ const signal = AbortSignal.any([request.signal, revoked]);
32
+ try {
33
+ if (signal.aborted || await authorize(identity, 'open') !== true || signal.aborted)
34
+ return failure(403, 'not-authorized');
35
+ }
36
+ catch {
37
+ return failure(503, 'authorization-unavailable');
38
+ }
39
+ let watch;
40
+ try {
41
+ watch = await conversation.watch(context);
42
+ }
43
+ catch {
44
+ return failure(503, 'source-unavailable');
45
+ }
46
+ // A watch that already ended may reject on stop; teardown must never turn a refusal into a rejection.
47
+ const stopWatch = () => Promise.resolve().then(() => watch.stop()).then(() => undefined, () => undefined);
48
+ try {
49
+ if (signal.aborted || await authorize(identity, 'initial') !== true || signal.aborted) {
50
+ await stopWatch();
51
+ return failure(403, 'not-authorized');
52
+ }
53
+ }
54
+ catch {
55
+ await stopWatch();
56
+ return failure(503, 'authorization-unavailable');
57
+ }
58
+ const connectionId = globalThis.crypto.randomUUID();
59
+ let previous;
60
+ let revision = 0, delivered = -1, frame = 0;
61
+ let wake;
62
+ let stopped = false;
63
+ let controller;
64
+ let stopping;
65
+ function stop() {
66
+ if (!stopped) {
67
+ stopped = true;
68
+ signal.removeEventListener('abort', onAbort);
69
+ wake?.();
70
+ try {
71
+ controller.close();
72
+ }
73
+ catch { /* already closed or cancelled */ }
74
+ }
75
+ return stopping ??= stopWatch();
76
+ }
77
+ function onAbort() { void stop(); }
78
+ const body = new ReadableStream({
79
+ start(value) {
80
+ controller = value;
81
+ signal.addEventListener('abort', onAbort, { once: true });
82
+ if (signal.aborted) {
83
+ void stop();
84
+ return;
85
+ }
86
+ watch.start(async () => { revision++; wake?.(); });
87
+ void watch.closed.then(() => stop());
88
+ },
89
+ async pull() {
90
+ while (!stopped) {
91
+ while (!stopped && delivered === revision)
92
+ await new Promise(resolve => { wake = resolve; });
93
+ wake = undefined;
94
+ if (stopped)
95
+ return;
96
+ try {
97
+ if (await authorize(identity, 'delivery') !== true || signal.aborted || stopped) {
98
+ await stop();
99
+ return;
100
+ }
101
+ const visible = projectConversationWindow(watch.value.entries, identity, allowEntry);
102
+ delivered = revision;
103
+ if (signal.aborted || stopped) {
104
+ await stop();
105
+ return;
106
+ }
107
+ if (previous && JSON.stringify(previous.messages) === JSON.stringify(visible.messages)
108
+ && previous.window.truncated === visible.window.truncated)
109
+ continue;
110
+ const snapshot = {
111
+ schema: 'boring.conversation-text', version: 2, nativeVersion: NATIVE_VERSION, source: identity,
112
+ connection: { id: connectionId, frame, observedAt: new Date().toISOString() }, kind: 'snapshot', ...visible,
113
+ };
114
+ const snapshotBytes = projectionEncoder.encode(JSON.stringify(snapshot) + '\n');
115
+ let bytes = snapshotBytes;
116
+ if (previous) {
117
+ const before = new Map(previous.messages.map(message => [projectionKey(message), JSON.stringify(message)]));
118
+ const { messages, ...common } = snapshot;
119
+ const projection = { ...common, kind: 'delta', baseFrame: previous.connection.frame,
120
+ upsert: messages.filter(message => before.get(projectionKey(message)) !== JSON.stringify(message)),
121
+ order: messages.map(({ conversationId, entryId, messageIndex }) => ({ conversationId, entryId, messageIndex })),
122
+ };
123
+ const deltaBytes = projectionEncoder.encode(JSON.stringify(projection) + '\n');
124
+ if (deltaBytes.byteLength < snapshotBytes.byteLength)
125
+ bytes = deltaBytes;
126
+ }
127
+ if (bytes.byteLength > limits.maxFrameBytes || signal.aborted || stopped) {
128
+ await stop();
129
+ return;
130
+ }
131
+ previous = snapshot;
132
+ frame++;
133
+ controller.enqueue(bytes);
134
+ return;
135
+ }
136
+ catch {
137
+ await stop();
138
+ return;
139
+ }
140
+ }
141
+ },
142
+ async cancel() {
143
+ stopped = true;
144
+ signal.removeEventListener('abort', onAbort);
145
+ wake?.();
146
+ await (stopping ??= stopWatch());
147
+ },
148
+ }, { highWaterMark: 0 });
149
+ return new Response(body, { headers: {
150
+ 'content-type': 'application/x-ndjson; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff',
151
+ } });
152
+ };
153
+ }
@@ -0,0 +1,20 @@
1
+ import type { Context } from '@earendil-works/chord';
2
+ import type { Conversation } from '@earendil-works/pi-durable';
3
+ import type { RuntimeQuestionRef } from './contracts.js';
4
+ import type { createQuestions } from './questions.js';
5
+ export interface QuestionResponseAccess {
6
+ readonly conversation: Conversation;
7
+ readonly questions: Pick<ReturnType<typeof createQuestions>, 'resolve'>;
8
+ readonly ref: RuntimeQuestionRef;
9
+ /** Carries the authenticated human to the original question's commit policy. */
10
+ readonly context: Context;
11
+ readonly revoked: AbortSignal;
12
+ }
13
+ export interface QuestionResponseOptions {
14
+ /** Includes origin/CSRF checks for cookie authentication. Never install as an agent approval tool. */
15
+ readonly authenticateHuman: (request: Request) => Promise<QuestionResponseAccess | null>;
16
+ readonly maxBodyBytes?: number;
17
+ }
18
+ /** Resolves original native clarification state. No separate inbox or approval authority. */
19
+ export declare function createQuestionResponseHandler(options: QuestionResponseOptions): (request: Request) => Promise<Response>;
20
+ //# sourceMappingURL=question-response.d.ts.map
@@ -0,0 +1,75 @@
1
+ import { RequestGuardError, hasJsonContentType, readJsonBody } from '@hachej/boring-files/request-guard';
2
+ const targetFields = ['runtimeId', 'conversationId', 'taskId', 'questionId', 'scopeId', 'subjectDigest', 'policyVersion', 'expiresAt'];
3
+ function record(value) {
4
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
5
+ }
6
+ function reply(status, kind, decision) {
7
+ return Response.json({ schema: 'boring.question-response', version: 1, kind, ...(decision ? { decision } : {}) }, {
8
+ status, headers: { 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' },
9
+ });
10
+ }
11
+ /** Resolves original native clarification state. No separate inbox or approval authority. */
12
+ export function createQuestionResponseHandler(options) {
13
+ const maxBodyBytes = options.maxBodyBytes ?? 16_384;
14
+ if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes < 1)
15
+ throw new TypeError('A positive body byte limit is required');
16
+ return async (request) => {
17
+ if (request.method !== 'POST')
18
+ return reply(405, 'method-not-supported');
19
+ if (!hasJsonContentType(request.headers))
20
+ return reply(415, 'json-required');
21
+ let access;
22
+ try {
23
+ access = await options.authenticateHuman(request);
24
+ }
25
+ catch {
26
+ return reply(503, 'authentication-unavailable');
27
+ }
28
+ if (!access)
29
+ return reply(401, 'authentication-required');
30
+ const { conversation, questions, context, revoked } = access;
31
+ const ref = { ...access.ref };
32
+ if (ref.conversationId !== conversation.id)
33
+ return reply(403, 'binding-mismatch');
34
+ const signal = AbortSignal.any([request.signal, revoked]);
35
+ if (signal.aborted)
36
+ return reply(403, 'not-authorized');
37
+ let input;
38
+ try {
39
+ input = await readJsonBody(request, maxBodyBytes, signal);
40
+ }
41
+ catch (error) {
42
+ if (signal.aborted)
43
+ return reply(403, 'not-authorized');
44
+ return error instanceof RequestGuardError && error.status === 413 ? reply(413, 'body-too-large') : reply(400, 'invalid-request');
45
+ }
46
+ if (signal.aborted)
47
+ return reply(403, 'not-authorized');
48
+ if (!record(input) || Object.keys(input).sort().join() !== 'answer,resolutionId,schema,target,version'
49
+ || input['schema'] !== 'boring.question-response' || input['version'] !== 1
50
+ || typeof input['resolutionId'] !== 'string' || !input['resolutionId'] || typeof input['answer'] !== 'string')
51
+ return reply(400, 'invalid-request');
52
+ const target = input['target'];
53
+ if (!record(target) || Object.keys(target).length !== targetFields.length || targetFields.some(key => target[key] !== ref[key]))
54
+ return reply(409, 'target-mismatch');
55
+ const resolution = { resolutionId: input['resolutionId'], answer: input['answer'] };
56
+ const interrupted = new Error('Question response authorization changed');
57
+ let decision;
58
+ try {
59
+ decision = await conversation.commit(async (tx) => {
60
+ if (signal.aborted)
61
+ throw interrupted;
62
+ const result = await questions.resolve(tx, ref, resolution, context);
63
+ if (signal.aborted)
64
+ throw interrupted;
65
+ return result;
66
+ }, context);
67
+ }
68
+ catch {
69
+ return reply(503, 'resolution-not-confirmed');
70
+ }
71
+ if (signal.aborted)
72
+ return reply(503, 'resolution-not-confirmed');
73
+ return reply(decision.kind === 'resolved' ? 200 : decision.kind === 'denied' ? 403 : 409, 'decision', decision);
74
+ };
75
+ }
@@ -0,0 +1,74 @@
1
+ import type { TaskOptions, Tx } from '@earendil-works/pi-durable';
2
+ import type { Context } from '@earendil-works/chord';
3
+ import type { RuntimeQuestionRef } from './contracts.js';
4
+ type QuestionRef = {
5
+ [Key in keyof RuntimeQuestionRef]: RuntimeQuestionRef[Key];
6
+ };
7
+ export type ChoiceQuestion = Omit<QuestionRef, 'runtimeId' | 'taskId'> & {
8
+ readonly prompt: string;
9
+ readonly choices: readonly string[];
10
+ };
11
+ type Resolution = {
12
+ resolutionId: string;
13
+ answer: string;
14
+ responderId: string;
15
+ consumedBy: string | null;
16
+ };
17
+ export type QuestionState = {
18
+ kind: 'pending';
19
+ } | {
20
+ kind: 'resolved';
21
+ resolution: Resolution;
22
+ } | {
23
+ kind: 'expired' | 'cancelled';
24
+ };
25
+ type QuestionRecord = {
26
+ ref: QuestionRef;
27
+ prompt: string;
28
+ choices: string[];
29
+ state: QuestionState;
30
+ };
31
+ type QuestionDocument = {
32
+ question: QuestionRecord | null;
33
+ };
34
+ export type QuestionOutcome = {
35
+ kind: 'resolved';
36
+ answer: string;
37
+ resolutionId: string;
38
+ } | {
39
+ kind: 'expired' | 'cancelled' | 'unavailable';
40
+ };
41
+ export type QuestionDecision = QuestionOutcome | {
42
+ kind: 'denied' | 'conflict' | 'pending';
43
+ };
44
+ export interface QuestionOptions {
45
+ readonly runtimeId: string;
46
+ /** Synchronous host authentication/policy at the native commit boundary. IDs are not grants. */
47
+ readonly authorize: (ref: RuntimeQuestionRef, action: 'admit' | 'resolve' | 'consume', context: Context) => {
48
+ readonly principalId: string;
49
+ readonly scopeId: string;
50
+ } | undefined;
51
+ /** Recheck the bound subject digest and policy version, including at consumption. */
52
+ readonly isCurrent: (ref: RuntimeQuestionRef, context: Context) => boolean;
53
+ readonly now?: () => number;
54
+ }
55
+ /** Single-choice clarification. The native result is information, never approval or a publication grant. */
56
+ export declare function createQuestions(options: QuestionOptions): {
57
+ task: import("@earendil-works/pi-durable").Task<{
58
+ key: string;
59
+ runtimeId: string;
60
+ }, {
61
+ phase: "wait";
62
+ }, QuestionOutcome, object>;
63
+ documents: import("@earendil-works/pi-durable").SessionDocFamilyToken<QuestionDocument, null>;
64
+ extension: import("@earendil-works/pi-durable").Extension<import("@earendil-works/pi-durable").ToolRegistration>;
65
+ admit: (tx: Tx, input: ChoiceQuestion, ownership: TaskOptions["ownership"], context: Context) => Promise<QuestionRef>;
66
+ resolve: (tx: Tx, reference: RuntimeQuestionRef, input: {
67
+ readonly resolutionId: string;
68
+ readonly answer: string;
69
+ }, context: Context) => Promise<QuestionDecision>;
70
+ consume: (tx: Tx, reference: RuntimeQuestionRef, resolutionId: string, consumerId: string, context: Context) => Promise<QuestionDecision>;
71
+ documentKey: (ref: Pick<RuntimeQuestionRef, "scopeId" | "questionId">) => string;
72
+ };
73
+ export {};
74
+ //# sourceMappingURL=questions.d.ts.map
@@ -0,0 +1,199 @@
1
+ import { ReadAfterWrite, defineDocFamily, defineExtension, defineTask } from '@earendil-works/pi-durable';
2
+ function sameRef(left, right) {
3
+ return left.runtimeId === right.runtimeId && left.conversationId === right.conversationId
4
+ && left.taskId === right.taskId && left.questionId === right.questionId && left.scopeId === right.scopeId
5
+ && left.subjectDigest === right.subjectDigest && left.policyVersion === right.policyVersion && left.expiresAt === right.expiresAt;
6
+ }
7
+ /** Single-choice clarification. The native result is information, never approval or a publication grant. */
8
+ export function createQuestions(options) {
9
+ const runtimeId = options.runtimeId;
10
+ if (!runtimeId)
11
+ throw new TypeError('A stable runtime identity is required');
12
+ const now = options.now ?? Date.now;
13
+ const documents = defineDocFamily({
14
+ kind: 'boring.questions.record', version: 1, scope: 'session', family: true,
15
+ initial: () => ({ question: null }),
16
+ });
17
+ const key = (ref) => JSON.stringify([runtimeId, ref.scopeId, ref.questionId]);
18
+ const allowed = (ref, action, context) => {
19
+ if (ref.runtimeId !== runtimeId)
20
+ return undefined;
21
+ const actor = options.authorize({ ...ref }, action, context);
22
+ return typeof actor?.principalId === 'string' && actor.principalId.length > 0 && actor.scopeId === ref.scopeId ? actor : undefined;
23
+ };
24
+ const current = (ref, context) => options.isCurrent({ ...ref }, context) === true;
25
+ const expired = (ref) => now() >= Date.parse(ref.expiresAt);
26
+ const task = defineTask({
27
+ name: 'boring.questions.wait', version: 1, initial: () => ({ phase: 'wait' }),
28
+ phases: {
29
+ wait: async (running, runtime, context) => {
30
+ if (running.input.runtimeId !== runtimeId) {
31
+ await runtime.commit(() => ({ status: 'terminal', outcome: { status: 'completed', result: { kind: 'unavailable' } } }), context);
32
+ return;
33
+ }
34
+ const watch = await runtime.watchDoc(documents, running.input.key, context);
35
+ if (!watch?.value?.question || watch.value.question.ref.taskId !== runtime.taskId)
36
+ throw new Error('Native question record is missing or misbound');
37
+ try {
38
+ if (watch.value.question.state.kind === 'pending') {
39
+ const changed = new Promise(resolve => {
40
+ watch.start(async (value) => { if (value?.question?.state.kind !== 'pending')
41
+ resolve(); });
42
+ });
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
+ ]);
48
+ }
49
+ await runtime.commit(async (tx) => {
50
+ const doc = await tx.doc(documents, running.input.key, null);
51
+ const question = doc.question;
52
+ if (!question)
53
+ throw new Error('Question retired while waiting');
54
+ if (question.state.kind === 'pending')
55
+ question.state = { kind: 'expired' };
56
+ const result = question.state.kind === 'resolved'
57
+ ? { kind: 'resolved', answer: question.state.resolution.answer, resolutionId: question.state.resolution.resolutionId }
58
+ : { kind: question.state.kind };
59
+ return { status: 'terminal', outcome: { status: 'completed', result } };
60
+ }, context);
61
+ }
62
+ finally {
63
+ await watch.stop();
64
+ }
65
+ },
66
+ },
67
+ abort: async (running, runtime, context) => {
68
+ await runtime.commit(async (tx) => {
69
+ const doc = await tx.doc(documents, running.input.key, null);
70
+ if (doc.question?.state.kind === 'pending')
71
+ doc.question.state = { kind: 'cancelled' };
72
+ return { status: 'terminal', outcome: { status: 'aborted' } };
73
+ }, context);
74
+ },
75
+ });
76
+ async function admit(tx, input, ownership, context) {
77
+ const captured = structuredClone(input);
78
+ for (const value of [captured.questionId, captured.scopeId, captured.subjectDigest, captured.policyVersion, captured.prompt]) {
79
+ if (typeof value !== 'string' || !value)
80
+ throw new TypeError('Question identity, subject, policy and prompt are required');
81
+ }
82
+ if (!Number.isFinite(Date.parse(captured.expiresAt)) || Date.parse(captured.expiresAt) <= now())
83
+ throw new TypeError('Question expiry must be in the future');
84
+ if (!Array.isArray(captured.choices) || !captured.choices.length || captured.choices.some(choice => typeof choice !== 'string' || !choice)
85
+ || new Set(captured.choices).size !== captured.choices.length)
86
+ throw new TypeError('Distinct nonempty choices are required');
87
+ const doc = await tx.doc(documents, key(captured), null);
88
+ if (doc.question) {
89
+ const existing = doc.question;
90
+ if (!allowed(existing.ref, 'admit', context) || !current(existing.ref, context))
91
+ throw new Error('Question admission denied');
92
+ if (existing.ref.conversationId !== captured.conversationId || existing.ref.subjectDigest !== captured.subjectDigest
93
+ || existing.ref.policyVersion !== captured.policyVersion || existing.ref.expiresAt !== captured.expiresAt
94
+ || existing.prompt !== captured.prompt || JSON.stringify(existing.choices) !== JSON.stringify(captured.choices))
95
+ throw new Error('Question identity already binds different input');
96
+ return { ...existing.ref };
97
+ }
98
+ const taskId = await tx.createTask(task, { key: key(captured), runtimeId }, { conversationId: captured.conversationId, ownership });
99
+ const ref = { runtimeId, taskId, conversationId: captured.conversationId, questionId: captured.questionId,
100
+ scopeId: captured.scopeId, subjectDigest: captured.subjectDigest, policyVersion: captured.policyVersion, expiresAt: captured.expiresAt };
101
+ if (!allowed(ref, 'admit', context) || !current(ref, context))
102
+ throw new Error('Question admission denied');
103
+ doc.question = { ref, prompt: captured.prompt, choices: [...captured.choices], state: { kind: 'pending' } };
104
+ return structuredClone(ref);
105
+ }
106
+ /** A prior table write prevents the native task check; refuse rather than bypass cancellation. */
107
+ async function taskState(tx, ref) {
108
+ let record;
109
+ try {
110
+ record = await tx.task(ref.taskId);
111
+ }
112
+ catch (error) {
113
+ if (error instanceof ReadAfterWrite)
114
+ return 'unknown';
115
+ throw error;
116
+ }
117
+ const known = record?.kind === task.definition.name && record.conversationId === ref.conversationId
118
+ && record.input !== null && typeof record.input === 'object' && !Array.isArray(record.input)
119
+ && record.input['runtimeId'] === runtimeId && record.input['key'] === key(ref);
120
+ return !known || !record ? 'unknown' : record.abortRequested ? 'aborted' : 'known';
121
+ }
122
+ /** Resolve before native table writes in this Tx; an unreadable task returns conflict. */
123
+ async function resolve(tx, reference, input, context) {
124
+ const ref = structuredClone(reference);
125
+ const { resolutionId, answer } = input;
126
+ if (!allowed(ref, 'resolve', context))
127
+ return { kind: 'denied' };
128
+ const bound = await taskState(tx, ref);
129
+ if (bound === 'unknown')
130
+ return { kind: 'conflict' };
131
+ const doc = await tx.doc(documents, key(ref), null);
132
+ const question = doc.question;
133
+ if (!question)
134
+ throw new Error('Native question evidence is missing');
135
+ if (!sameRef(question.ref, ref))
136
+ return { kind: 'conflict' };
137
+ if (bound === 'aborted') {
138
+ if (question.state.kind === 'pending')
139
+ question.state = { kind: 'cancelled' };
140
+ return { kind: 'cancelled' };
141
+ }
142
+ const actor = allowed(ref, 'resolve', context);
143
+ if (!actor)
144
+ return { kind: 'denied' };
145
+ if (!current(ref, context))
146
+ return { kind: 'conflict' };
147
+ if (typeof resolutionId !== 'string' || !resolutionId || typeof answer !== 'string' || !question.choices.includes(answer))
148
+ return { kind: 'conflict' };
149
+ if (question.state.kind === 'resolved') {
150
+ const prior = question.state.resolution;
151
+ return prior.resolutionId === resolutionId && prior.answer === answer && prior.responderId === actor.principalId
152
+ ? { kind: 'resolved', answer, resolutionId } : { kind: 'conflict' };
153
+ }
154
+ if (question.state.kind !== 'pending')
155
+ return { kind: question.state.kind };
156
+ if (expired(ref)) {
157
+ question.state = { kind: 'expired' };
158
+ return { kind: 'expired' };
159
+ }
160
+ question.state = { kind: 'resolved', resolution: { resolutionId, answer, responderId: actor.principalId, consumedBy: null } };
161
+ return { kind: 'resolved', answer, resolutionId };
162
+ }
163
+ /** Consume before native host table writes in this Tx. Recheck external publication policy separately. */
164
+ async function consume(tx, reference, resolutionId, consumerId, context) {
165
+ const ref = structuredClone(reference);
166
+ if (!allowed(ref, 'consume', context))
167
+ return { kind: 'denied' };
168
+ const bound = await taskState(tx, ref);
169
+ if (bound === 'unknown')
170
+ return { kind: 'conflict' };
171
+ const doc = await tx.doc(documents, key(ref), null);
172
+ const question = doc.question;
173
+ if (!question)
174
+ throw new Error('Native question evidence is missing');
175
+ if (!allowed(ref, 'consume', context))
176
+ return { kind: 'denied' };
177
+ if (!sameRef(question.ref, ref) || !current(ref, context))
178
+ return { kind: 'conflict' };
179
+ if (bound === 'aborted') {
180
+ if (question.state.kind === 'pending')
181
+ question.state = { kind: 'cancelled' };
182
+ return { kind: 'cancelled' };
183
+ }
184
+ if (expired(ref)) {
185
+ if (question.state.kind === 'pending')
186
+ question.state = { kind: 'expired' };
187
+ return { kind: question.state.kind === 'cancelled' ? 'cancelled' : 'expired' };
188
+ }
189
+ if (question.state.kind !== 'resolved')
190
+ return { kind: question.state.kind };
191
+ const resolution = question.state.resolution;
192
+ if (typeof consumerId !== 'string' || !consumerId || resolution.resolutionId !== resolutionId || (resolution.consumedBy !== null && resolution.consumedBy !== consumerId))
193
+ return { kind: 'conflict' };
194
+ resolution.consumedBy = consumerId;
195
+ return { kind: 'resolved', answer: resolution.answer, resolutionId };
196
+ }
197
+ return { task, documents, extension: defineExtension({ name: 'boring.questions', tasks: [task] }), admit, resolve, consume,
198
+ documentKey: (ref) => key(ref) };
199
+ }