@hachej/boring-agent 0.2.3 → 0.2.4

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
@@ -134,6 +134,8 @@ Choose the seam by how the text changes and who should see it:
134
134
 
135
135
  Failure is fail-open and Pi's: a throwing `context` is reported through `HarnessOptions.onReport` and the request goes out without the context (Pi's `hooks.each`, scheduler.js). An aborted turn rethrows. This is advisory text, not policy or authorization; do not use it to enforce a decision. The text is sent to the model provider and gateways or telemetry that log request bodies may see it; the model can quote it into a stored assistant message. Compaction summarizes the stored transcript only, so the context is not folded into summaries. After a crash the hook runs again at recovery, so the value is fresh per attempt, not replayed. Placement: before the round's question when that is the last message; after tool results or an assistant continuation, at the tail. Tests: `test/packages/agent-prompt-context.test.mjs`.
136
136
 
137
+ Screen context: a browser can send what the person is looking at with each submit. `createRemoteChat({ requestContext })` adds the returned plain object to the submit body as `context`. In `ChatTransportAccess`, `requestContext(value)` receives it after validation and before `conversation.submit`; a value that is not a plain JSON object of at most 4 KB is `400 invalid-request`, and a host without `requestContext` ignores the field. The host stores `context` by conversation id in `requestContext` and renders it in `createPromptContext({ context })`; the transport keeps nothing. Tests: `test/packages/agent-chat-transport-context.test.mjs`, `test/packages/ui-remote-chat-context.test.mjs`.
138
+
137
139
  ## Native questions
138
140
 
139
141
  `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.
@@ -5,6 +5,7 @@ export declare const CHAT_TRANSPORT: Readonly<{
5
5
  schema: "boring.chat-transport";
6
6
  version: 1;
7
7
  maxRequestBytes: 8388608;
8
+ maxContextBytes: 4096;
8
9
  maxHistoryPage: 200;
9
10
  maxPendingFrames: 100;
10
11
  heartbeatMs: 15000;
@@ -47,6 +48,13 @@ export interface ChatTransportAccess {
47
48
  * scopes the id to this conversation. Without it `?op=withdraw` is not supported. This handler never edits the inbox.
48
49
  */
49
50
  readonly abortSubmission?: (id: SubmissionId) => Promise<'aborted' | 'already_placed' | 'settled' | 'not_found'>;
51
+ /**
52
+ * Receive the screen context a browser sent with a submit (`context`, from `createRemoteChat({ requestContext })`), called after
53
+ * the request is validated and admitted and before `conversation.submit`. The value is a plain JSON object of at most
54
+ * `CHAT_TRANSPORT.maxContextBytes`; anything else is `400 invalid-request`. The host stores it by conversation id and renders
55
+ * it in `createPromptContext({ context })`; this handler keeps nothing. Without it the `context` field is ignored.
56
+ */
57
+ readonly requestContext?: (value: Readonly<Record<string, unknown>>) => void | Promise<void>;
50
58
  /** Refuse an effect call. Reading stays governed by `authenticate` and `project`. */
51
59
  readonly allow?: (operation: 'submit' | 'abort' | 'answer' | 'withdraw' | 'configure') => boolean | Promise<boolean>;
52
60
  }
@@ -1,6 +1,6 @@
1
1
  import { RequestGuardError, hasJsonContentType, readJsonBody } from '@hachej/boring-files/request-guard';
2
2
  /** Wire protocol shared with the browser client in `@hachej/boring-ui-kit/remote-chat`. */
3
- export const CHAT_TRANSPORT = Object.freeze({ schema: 'boring.chat-transport', version: 1, maxRequestBytes: 8_388_608, maxHistoryPage: 200, maxPendingFrames: 100, heartbeatMs: 15_000 });
3
+ export const CHAT_TRANSPORT = Object.freeze({ schema: 'boring.chat-transport', version: 1, maxRequestBytes: 8_388_608, maxContextBytes: 4096, maxHistoryPage: 200, maxPendingFrames: 100, heartbeatMs: 15_000 });
4
4
  /** `no-transform` keeps proxies from compressing or rewriting responses; the watch stream adds `x-accel-buffering: no` so nginx does not buffer it. */
5
5
  const headers = { 'cache-control': 'no-store, no-transform', 'x-content-type-options': 'nosniff' };
6
6
  const failure = (status, reason) => Response.json({ schema: CHAT_TRANSPORT.schema, version: CHAT_TRANSPORT.version, reason }, { status, headers });
@@ -12,6 +12,14 @@ function content(value) {
12
12
  return Array.isArray(value) && value.length > 0 && value.every(part => part !== null && typeof part === 'object'
13
13
  && ((part.type === 'text' && typeof part.text === 'string') || (part.type === 'image' && typeof part.data === 'string' && typeof part.mimeType === 'string')));
14
14
  }
15
+ /** The submit `context` field: absent, or a plain JSON object of at most `maxContextBytes` once serialized. */
16
+ function contextValue(value) {
17
+ if (value === undefined)
18
+ return true;
19
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
20
+ return false;
21
+ return encoder.encode(JSON.stringify(value)).length <= CHAT_TRANSPORT.maxContextBytes;
22
+ }
15
23
  async function body(request) {
16
24
  const value = await readJsonBody(request, CHAT_TRANSPORT.maxRequestBytes, request.signal);
17
25
  if (value === null || typeof value !== 'object' || Array.isArray(value))
@@ -207,7 +215,8 @@ export function createChatTransportHandler(options) {
207
215
  if (request.method === 'POST' && operation === 'submit') {
208
216
  const input = await body(request);
209
217
  if (typeof input['requestId'] !== 'string' || !input['requestId'] || !content(input['content'])
210
- || (input['whenBusy'] !== undefined && typeof input['whenBusy'] !== 'string'))
218
+ || (input['whenBusy'] !== undefined && typeof input['whenBusy'] !== 'string')
219
+ || (access.requestContext && !contextValue(input['context'])))
211
220
  return failure(400, 'invalid-request');
212
221
  let prepared = input['content'];
213
222
  if (access.prepareInput) {
@@ -218,6 +227,10 @@ export function createChatTransportHandler(options) {
218
227
  }
219
228
  if (!await allowed('submit') || access.revoked?.aborted)
220
229
  return failure(403, 'not-authorized');
230
+ if (access.requestContext && input['context'] !== undefined)
231
+ await access.requestContext(input['context']);
232
+ if (access.revoked?.aborted)
233
+ return failure(403, 'not-authorized');
221
234
  const submission = await conversation.submit({ type: 'input', requestId: input['requestId'], content: prepared,
222
235
  ...(input['whenBusy'] === undefined ? {} : { whenBusy: input['whenBusy'] }) }, context);
223
236
  return json({ submissionId: submission.id });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-agent",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -139,8 +139,8 @@
139
139
  "@earendil-works/pi-durable": "~1.1.0",
140
140
  "@earendil-works/pi-ai": "~1.1.0",
141
141
  "@earendil-works/chord": "~1.1.0",
142
- "@hachej/boring-files": "^0.2.3",
143
- "@hachej/boring-ui-kit": "^0.2.3"
142
+ "@hachej/boring-files": "^0.2.4",
143
+ "@hachej/boring-ui-kit": "^0.2.4"
144
144
  },
145
145
  "peerDependenciesMeta": {
146
146
  "@hachej/boring-files": {