@hachej/boring-agent 0.2.1 → 0.2.2

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
@@ -118,6 +118,20 @@ Deviations, honestly:
118
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
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
120
 
121
+ ## Per-request prompt context
122
+
123
+ `@hachej/boring-agent/prompt-context` (`createPromptContext({ context })`) is one ordinary native extension whose `GenerationHooks.beforeRequest` (registered with `hook(GenerationTask, ...)`) runs before every request attempt, retries and crash recovery included. The hook's text is used for that request only: it is recomputed each time and never appended to the transcript. The text is one user-role `<request-context>...</request-context>` message placed immediately before the round's question when the final message is the user's question, otherwise at the tail (after tool results or an assistant continuation); system messages and earlier messages are unchanged, so the stable prefix stays cacheable.
124
+
125
+ Choose the seam by how the text changes and who should see it:
126
+
127
+ - Stable per-agent text (instructions, a fixed section): `AgentSpec.instructions` or a `PromptSection`. Pi stores a system entry whenever a section's text changes, so use it only for text that changes rarely.
128
+ - Volatile or private text (the open document, the time, a permission snapshot): this hook. Nothing is stored and the person does not see it in the history.
129
+ - Text the person should see and keep: `prepareInput` on the submit route, which becomes the user message.
130
+
131
+ `context({ conversationId, messages, signal })` returns the text (`undefined` or `''` adds nothing). The host keeps request-scoped state in its own store keyed by `conversationId` and reads it there; Pi's hook context carries no caller values. Install several extensions to compose independent contexts (they apply in install order).
132
+
133
+ 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`.
134
+
121
135
  ## Native questions
122
136
 
123
137
  `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.
package/dist/artifacts.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { defineTool } from '@earendil-works/pi-durable';
2
2
  import { Type } from '@earendil-works/pi-ai';
3
+ import { workspacePath } from '@hachej/boring-files/file-types';
3
4
  import { asWorkspaceResolver, workspaceFor } from './workspaces.js';
4
5
  /*
5
6
  * Artifacts: substantial, self-contained content (a document, an HTML page, an SVG, code) that a chat shows as a card
@@ -70,12 +71,13 @@ const decode = (bytes) => { try {
70
71
  catch {
71
72
  return undefined;
72
73
  } };
73
- const PRESENTED = {
74
+ // Null-prototype tables: a name such as `constructor` is not an entry.
75
+ const PRESENTED = Object.assign(Object.create(null), {
74
76
  md: { type: 'markdown', mediaType: 'text/markdown' }, markdown: { type: 'markdown', mediaType: 'text/markdown' },
75
77
  html: { type: 'html', mediaType: 'text/html' }, htm: { type: 'html', mediaType: 'text/html' },
76
78
  svg: { type: 'svg', mediaType: 'image/svg+xml' }, tldraw: { type: 'canvas', mediaType: 'application/vnd.tldraw+json' },
77
- };
78
- const LANGUAGE_OF = { js: 'javascript', mjs: 'javascript', ts: 'typescript', py: 'python', json: 'json', css: 'css', sql: 'sql', yaml: 'yaml', yml: 'yaml', sh: 'shell', rs: 'rust', go: 'go', java: 'java', csv: 'csv' };
79
+ });
80
+ const LANGUAGE_OF = Object.assign(Object.create(null), { js: 'javascript', mjs: 'javascript', ts: 'typescript', py: 'python', json: 'json', css: 'css', sql: 'sql', yaml: 'yaml', yml: 'yaml', sh: 'shell', rs: 'rust', go: 'go', java: 'java', csv: 'csv' });
79
81
  /**
80
82
  * `present(path)`: show a workspace file to the person as a card and a side panel. The agent writes the file with its ordinary file
81
83
  * tools first. The card points at the file; the file's history (the provider's) is its list of versions. Nothing is written here.
@@ -95,7 +97,10 @@ export function createPresentTool(options = {}) {
95
97
  parameters: Type.Object({ path: Type.String({ minLength: 1, maxLength: 512, description: 'The file path, relative to the workspace.' }) }, { additionalProperties: false }),
96
98
  replay: 'safe',
97
99
  execute: async (args, api, context) => {
98
- const path = args.path.replace(/^\.\//, '');
100
+ // An absolute path is refused, not normalised: `/workspace/a.md` must never be read as `workspace/a.md` under the root.
101
+ if (args.path.startsWith('/'))
102
+ return reply({ kind: 'denied', reason: 'The path must be relative to the workspace' });
103
+ const path = workspacePath(args.path);
99
104
  const resolved = await workspaceFor(resolver, api, context);
100
105
  if ('refused' in resolved)
101
106
  return reply({ kind: 'denied', reason: resolved.refused });
@@ -1,14 +1,10 @@
1
1
  export type FileKind = 'text' | 'pdf' | 'office' | 'image' | 'archive' | 'binary';
2
- /** The lower-case extension of the last path segment, or ''. */
3
- export declare const extensionOf: (path: string) => string;
4
2
  /** The media type of a native image (png, jpeg, gif, webp) by extension, or undefined. */
5
3
  export declare const nativeImageType: (path: string) => string | undefined;
6
4
  /** How many leading bytes `fileKind` looks at. */
7
5
  export declare const SNIFF_BYTES = 16;
8
6
  /** The kind of `path`, from its extension and, when given, its leading bytes (the magic number wins). Unknown extensions are text unless a NUL byte says otherwise. */
9
7
  export declare function fileKind(path: string, head?: Uint8Array): FileKind;
10
- /** The media type a file's leading bytes name (a PDF or an image signature), or undefined. */
11
- export declare function sniffedMediaType(head: Uint8Array): string | undefined;
12
8
  /**
13
9
  * The media type a converter should be told. A signature in the leading bytes wins over everything (a PDF named `.txt` is
14
10
  * `application/pdf`, whatever the provider derived from the name); then the provider's type unless it is generic; then the table's.
@@ -1,6 +1,7 @@
1
1
  /*
2
- * The one table of file types the agent package routes by. `read` (file-convert.ts) decides whether Pi's native read or the host's
3
- * converter handles a file; the mention resolver (mentions.ts) decides what to put in a message. Both ask `fileKind`.
2
+ * The file kinds the agent package routes by. `read` (file-convert.ts) decides whether Pi's native read or the host's converter
3
+ * handles a file; the mention resolver (mentions.ts) decides what to put in a message. Both ask `fileKind`.
4
+ * Media types, extensions and magic numbers come from `@hachej/boring-files/file-types`; this file only says which kind each is.
4
5
  *
5
6
  * - `text`: Pi's native read, untouched.
6
7
  * - `pdf`, `office`, `image`: a host converter can turn them into text; Pi's read cannot (it returns garbage for a PDF).
@@ -10,33 +11,28 @@
10
11
  * A file is classified by its extension AND its leading bytes: a PDF or an image is recognised by its magic number even under a
11
12
  * wrong name.
12
13
  */
14
+ import { MEDIA_TYPES, extensionOf, sniffMediaType } from '@hachej/boring-files/file-types';
13
15
  const TEXT = ['md', 'markdown', 'txt', 'csv', 'tsv', 'json', 'jsonl', 'html', 'htm', 'svg', 'xml', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'css', 'py', 'sql', 'yaml', 'yml', 'sh', 'toml', 'ini', 'log', 'rst', 'tex'];
14
16
  const OFFICE = ['doc', 'docx', 'odt', 'rtf', 'xls', 'xlsx', 'ods', 'ppt', 'pptx', 'odp', 'epub'];
15
- /** Image extensions and their media types; `native` ones a model reads as an image part without conversion. */
16
- const IMAGES = {
17
- png: { mediaType: 'image/png', native: true }, jpg: { mediaType: 'image/jpeg', native: true }, jpeg: { mediaType: 'image/jpeg', native: true },
18
- gif: { mediaType: 'image/gif', native: true }, webp: { mediaType: 'image/webp', native: true },
19
- bmp: { mediaType: 'image/bmp', native: false }, tif: { mediaType: 'image/tiff', native: false }, tiff: { mediaType: 'image/tiff', native: false },
20
- };
17
+ /** Image extensions; `native` ones a model reads as an image part without conversion. Their media types are `MEDIA_TYPES`. */
18
+ const IMAGES = Object.assign(Object.create(null), { png: true, jpg: true, jpeg: true, gif: true, webp: true, bmp: false, tif: false, tiff: false });
21
19
  const BY_EXTENSION = new Map([
22
20
  ...TEXT.map(extension => [extension, 'text']),
23
21
  ...OFFICE.map(extension => [extension, 'office']),
24
22
  ...Object.keys(IMAGES).map(extension => [extension, 'image']),
25
23
  ['pdf', 'pdf'], ['zip', 'archive'],
26
24
  ]);
27
- /** The lower-case extension of the last path segment, or ''. */
28
- export const extensionOf = (path) => { const dot = path.lastIndexOf('.'); return dot > path.lastIndexOf('/') ? path.slice(dot + 1).toLowerCase() : ''; };
29
25
  /** The media type of a native image (png, jpeg, gif, webp) by extension, or undefined. */
30
- export const nativeImageType = (path) => { const image = IMAGES[extensionOf(path)]; return image?.native ? image.mediaType : undefined; };
26
+ export const nativeImageType = (path) => IMAGES[extensionOf(path)] === true ? MEDIA_TYPES[extensionOf(path)] : undefined;
31
27
  /** How many leading bytes `fileKind` looks at. */
32
28
  export const SNIFF_BYTES = 16;
33
29
  const startsWith = (bytes, signature, at = 0) => signature.every((byte, index) => bytes[at + index] === byte);
34
30
  /** The kind a magic number names, or undefined. A ZIP signature is an office document only under an office extension. */
35
31
  function kindByMagic(bytes, extension) {
36
- if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d]))
32
+ const sniffed = sniffMediaType(bytes);
33
+ if (sniffed === 'application/pdf')
37
34
  return 'pdf';
38
- if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47]) || startsWith(bytes, [0xff, 0xd8, 0xff]) || startsWith(bytes, [0x47, 0x49, 0x46, 0x38])
39
- || (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes, [0x57, 0x45, 0x42, 0x50], 8)) || startsWith(bytes, [0x42, 0x4d]) && extension === 'bmp')
35
+ if (sniffed?.startsWith('image/') || (startsWith(bytes, [0x42, 0x4d]) && extension === 'bmp'))
40
36
  return 'image';
41
37
  if (startsWith(bytes, [0x50, 0x4b, 0x03, 0x04]))
42
38
  return OFFICE.includes(extension) ? 'office' : 'archive';
@@ -57,37 +53,16 @@ export function fileKind(path, head) {
57
53
  }
58
54
  return named ?? 'text';
59
55
  }
60
- const OFFICE_TYPES = {
61
- doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
62
- xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
63
- ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
64
- odt: 'application/vnd.oasis.opendocument.text', ods: 'application/vnd.oasis.opendocument.spreadsheet', odp: 'application/vnd.oasis.opendocument.presentation',
65
- rtf: 'application/rtf', epub: 'application/epub+zip',
66
- };
67
- /** The media type a file's leading bytes name (a PDF or an image signature), or undefined. */
68
- export function sniffedMediaType(head) {
69
- if (startsWith(head, [0x25, 0x50, 0x44, 0x46, 0x2d]))
70
- return 'application/pdf';
71
- if (startsWith(head, [0x89, 0x50, 0x4e, 0x47]))
72
- return 'image/png';
73
- if (startsWith(head, [0xff, 0xd8, 0xff]))
74
- return 'image/jpeg';
75
- if (startsWith(head, [0x47, 0x49, 0x46, 0x38]))
76
- return 'image/gif';
77
- if (startsWith(head, [0x52, 0x49, 0x46, 0x46]) && startsWith(head, [0x57, 0x45, 0x42, 0x50], 8))
78
- return 'image/webp';
79
- return undefined;
80
- }
81
56
  /**
82
57
  * The media type a converter should be told. A signature in the leading bytes wins over everything (a PDF named `.txt` is
83
58
  * `application/pdf`, whatever the provider derived from the name); then the provider's type unless it is generic; then the table's.
84
59
  */
85
60
  export function mediaTypeFor(path, kind, provided, head) {
86
- const sniffed = head === undefined ? undefined : sniffedMediaType(head);
61
+ const sniffed = head === undefined ? undefined : sniffMediaType(head);
87
62
  if (sniffed !== undefined)
88
63
  return sniffed;
89
64
  const extension = extensionOf(path);
90
- const table = kind === 'pdf' ? 'application/pdf' : kind === 'image' ? IMAGES[extension]?.mediaType : kind === 'office' ? OFFICE_TYPES[extension] : undefined;
65
+ const table = kind === 'pdf' ? MEDIA_TYPES.pdf : kind === 'image' && IMAGES[extension] !== undefined ? MEDIA_TYPES[extension] : kind === 'office' && OFFICE.includes(extension) ? MEDIA_TYPES[extension] : undefined;
91
66
  const generic = !provided || provided === 'application/octet-stream' || provided.startsWith('text/') || provided === 'application/zip';
92
67
  if (!generic)
93
68
  return provided;
package/dist/mentions.js CHANGED
@@ -1,4 +1,5 @@
1
- import { SNIFF_BYTES, extensionOf, fileKind, nativeImageType } from './file-types.js';
1
+ import { extensionOf } from '@hachej/boring-files/file-types';
2
+ import { SNIFF_BYTES, fileKind, nativeImageType } from './file-types.js';
2
3
  export const MENTION_LIMITS = Object.freeze({ fileBytes: 100_000, totalBytes: 300_000, files: 20 });
3
4
  /** Every part this module adds starts with this prefix, so a viewer can tell it from what the person typed. */
4
5
  export const MENTION_FILE_PREFIX = '<file path="';
@@ -0,0 +1,25 @@
1
+ import type { ConversationId, Extension } from '@earendil-works/pi-durable';
2
+ import type { Message } from '@earendil-works/pi-ai';
3
+ export interface PromptContextInput {
4
+ readonly conversationId: ConversationId;
5
+ /** The request's messages as Pi would send them (read-only). */
6
+ readonly messages: readonly Message[];
7
+ /** The task's abort signal, when there is one. */
8
+ readonly signal: AbortSignal | undefined;
9
+ }
10
+ export interface PromptContextOptions {
11
+ /** Extension name (default `host.prompt-context`). Install one extension per independent context; they compose in install order. */
12
+ readonly name?: string;
13
+ /** Wrapper tag of the message (default `request-context`). */
14
+ readonly tag?: string;
15
+ /** Fresh text for this request attempt; `undefined` or `''` adds no message. */
16
+ readonly context: (input: PromptContextInput) => string | undefined | Promise<string | undefined>;
17
+ }
18
+ /**
19
+ * The request messages with one user message carrying `text` added. It goes right before the round's question (the last message
20
+ * that is not a context from an earlier extension) when that is a user message; otherwise (after tool results, or after an
21
+ * assistant continuation) at the tail. Pure: the input array is not changed.
22
+ */
23
+ export declare function withRequestContext(messages: readonly Message[], text: string, tag?: string): Message[];
24
+ export declare function createPromptContext(options: PromptContextOptions): Extension;
25
+ //# sourceMappingURL=prompt-context.d.ts.map
@@ -0,0 +1,38 @@
1
+ // Per-request prompt context as one ordinary native pi-durable extension. Extension point: `GenerationHooks.beforeRequest`,
2
+ // registered with `hook(GenerationTask, ...)`. Pi runs it before every request attempt (retries and crash recovery included)
3
+ // and uses its result for that request only, so the text is recomputed each time and never appended to the transcript.
4
+ // The host owns the data: `context` reads its own request-scoped state keyed by the conversation (Pi's hook context carries no
5
+ // caller values). Failure is Pi's: Pi's `hooks.each` reports a throw through `HarnessOptions.onReport`, continues the request
6
+ // without this hook's result, and rethrows only when the turn is aborted (scheduler.js, `hooks.each`). Advisory only: never use
7
+ // it for policy or authorization.
8
+ import { GenerationTask, defineExtension, hook } from '@earendil-works/pi-durable';
9
+ /** Messages made by this module, so a later extension skips them when it looks for the round's question (install order holds). */
10
+ const produced = new WeakSet();
11
+ /**
12
+ * The request messages with one user message carrying `text` added. It goes right before the round's question (the last message
13
+ * that is not a context from an earlier extension) when that is a user message; otherwise (after tool results, or after an
14
+ * assistant continuation) at the tail. Pure: the input array is not changed.
15
+ */
16
+ export function withRequestContext(messages, text, tag = 'request-context') {
17
+ const message = { role: 'user', content: [{ type: 'text', text: `<${tag}>\n${text}\n</${tag}>` }], timestamp: Date.now() };
18
+ produced.add(message);
19
+ let end = messages.length;
20
+ while (end > 0 && produced.has(messages[end - 1]))
21
+ end--;
22
+ const question = messages[end - 1];
23
+ const at = question?.role === 'user' ? end - 1 : messages.length;
24
+ return [...messages.slice(0, at), message, ...messages.slice(at)];
25
+ }
26
+ export function createPromptContext(options) {
27
+ return defineExtension({
28
+ name: options.name ?? 'host.prompt-context',
29
+ hooks: [hook(GenerationTask, {
30
+ beforeRequest: async (request, api, context) => {
31
+ const text = await options.context({ conversationId: api.conversationId, messages: request.messages, signal: context.abortSignal });
32
+ if (!text)
33
+ return undefined;
34
+ return { messages: withRequestContext(request.messages, text, options.tag) };
35
+ },
36
+ })],
37
+ });
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-agent",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -125,14 +125,18 @@
125
125
  "./metering": {
126
126
  "types": "./dist/metering.d.ts",
127
127
  "import": "./dist/metering.js"
128
+ },
129
+ "./prompt-context": {
130
+ "types": "./dist/prompt-context.d.ts",
131
+ "import": "./dist/prompt-context.js"
128
132
  }
129
133
  },
130
134
  "peerDependencies": {
131
- "@earendil-works/pi-durable": "1.1.0",
132
- "@earendil-works/pi-ai": "1.1.0",
133
- "@earendil-works/chord": "1.1.0",
134
- "@hachej/boring-files": "0.2.1",
135
- "@hachej/boring-ui-kit": "0.2.1"
135
+ "@earendil-works/pi-durable": "^1.1.0",
136
+ "@earendil-works/pi-ai": "^1.1.0",
137
+ "@earendil-works/chord": "^1.1.0",
138
+ "@hachej/boring-files": "^0.2.2",
139
+ "@hachej/boring-ui-kit": "^0.2.2"
136
140
  },
137
141
  "peerDependenciesMeta": {
138
142
  "@hachej/boring-files": {