@hachej/boring-agent 0.2.0 → 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 +15 -1
- package/dist/artifacts.js +9 -4
- package/dist/file-guard.js +7 -1
- package/dist/file-types.d.ts +0 -4
- package/dist/file-types.js +12 -37
- package/dist/mentions.js +2 -1
- package/dist/prompt-context.d.ts +25 -0
- package/dist/prompt-context.js +38 -0
- package/package.json +10 -6
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
## One file tool set: the guard
|
|
10
10
|
|
|
11
|
-
The agent edits files with Pi's own `read`, `write`, `edit` and `bash` over the workspace's `ExecutionEnv`; there are no document or artifact tools of Boring's. `createFileGuard({ workspace, resolveAccess?, name? })` from `@hachej/boring-agent/file-guard` returns a native extension (named `name`, default `boring.files.guard`: two agents with different workspaces in one registry need two names, or the last installed guard wraps both) that wraps those three tools with the public `wrapTool` (select it after the extension that registers them). Every wrapped call runs inside the workspace provider's mutation queue (`@hachej/boring-files/workspace`), so a viewer save cannot land between `edit`'s internal read and its write. `workspace` is the call's workspace resolved like Pi's environment, or one binding `{ files, root, access? }` (see [Workspace per call](#workspace-per-call)); `resolveAccess` defaults to the binding's `access`. A genuine `read` call records the file's revision as the conversation's last-read revision of that workspace and path (`baselineKey`) in a native conversation document (`lastReadRevisions`, kept across restarts; a fork starts empty); `edit`'s internal read records nothing. `write` and `edit` of a file that exists are refused unless the conversation has read it and its revision still equals the recorded one, and the refusal tells the model to read the file again. Creating a file is allowed only while it is absent. The guard fails closed: an unresolvable workspace, a refused provider read, a path outside the root, a link that leads outside it and a path that does not exist (so Pi's read never falls back to another spelling of it, such as an NFD name) (resolved through the env's `canonicalPath`, so also a new file under a link directory) are refused for `read` as well as `write` and `edit`; nothing falls through to Pi's native tool. After a successful `write` or `edit` the baseline is the revision just written. Pi's `edit` tries an exact match first and falls back to a fuzzy one (Unicode normalisation, trailing whitespace, smart quotes, dashes and special spaces); the guard does not change that. A `bash` write is not intercepted, but it changes the revision, so the next `write` or `edit` of that file is refused until it is read. The guard adds no strictness option and no file API of its own.
|
|
11
|
+
The agent edits files with Pi's own `read`, `write`, `edit` and `bash` over the workspace's `ExecutionEnv`; there are no document or artifact tools of Boring's. `createFileGuard({ workspace, resolveAccess?, name? })` from `@hachej/boring-agent/file-guard` returns a native extension (named `name`, default `boring.files.guard`: two agents with different workspaces in one registry need two names, or the last installed guard wraps both) that wraps those three tools with the public `wrapTool` (select it after the extension that registers them). Every wrapped call runs inside the workspace provider's mutation queue (`@hachej/boring-files/workspace`), so a viewer save cannot land between `edit`'s internal read and its write. `workspace` is the call's workspace resolved like Pi's environment, or one binding `{ files, root, access? }` (see [Workspace per call](#workspace-per-call)); `resolveAccess` defaults to the binding's `access`. A genuine `read` call records the file's revision as the conversation's last-read revision of that workspace and path (`baselineKey`) in a native conversation document (`lastReadRevisions`, kept across restarts; a fork starts empty); `edit`'s internal read records nothing. `write` and `edit` of a file that exists are refused unless the conversation has read it and its revision still equals the recorded one, and the refusal tells the model to read the file again. Creating a file is allowed only while it is absent. The guard fails closed: an unresolvable workspace, a refused provider read, a path outside the root, a link that leads outside it and a path that does not exist (so Pi's read never falls back to another spelling of it, such as an NFD name) (resolved through the env's `canonicalPath`, so also a new file under a link directory) are refused for `read` as well as `write` and `edit`; nothing falls through to Pi's native tool. After a successful `write` or `edit` the baseline is the revision just written, and the guard calls the provider's `record` (owner ruling 2: observed, never receipted): the file's history gains the written revision with source `agent` (and the bytes it replaced, read before the tool ran when that revision is not already retained), and the workspace's change feed an `agent` event with the conversation. Nothing enters the publication journal. Pi's `edit` tries an exact match first and falls back to a fuzzy one (Unicode normalisation, trailing whitespace, smart quotes, dashes and special spaces); the guard does not change that. A `bash` write is not intercepted, but it changes the revision, so the next `write` or `edit` of that file is refused until it is read; it reaches the change feed (no history) through the file system's watcher or the host's `poll()` at turn end. The guard adds no strictness option and no file API of its own.
|
|
12
12
|
|
|
13
13
|
## Workspace per call
|
|
14
14
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 });
|
package/dist/file-guard.js
CHANGED
|
@@ -149,11 +149,17 @@ export function createFileGuard(options = {}) {
|
|
|
149
149
|
if (known !== before.revision)
|
|
150
150
|
return refusal(`Refused: ${path} changed since you last read it (the person or another process saved it). Read it again with the read tool, then redo your change on top of what is there now.`);
|
|
151
151
|
}
|
|
152
|
+
// Ruling 2: an agent write is observed (history, change event), never receipted. History keeps the replaced bytes too, so
|
|
153
|
+
// they are read now, before the tool overwrites them, unless that revision is already retained.
|
|
154
|
+
const preimage = before.kind === 'file' && !files.history(path).includes(before.revision) ? await files.read({ target: { resource: { providerId: files.providerId, path }, view: { kind: 'published' } }, revision: { kind: 'exact', value: before.revision } }, access) : undefined;
|
|
152
155
|
const result = await run();
|
|
153
156
|
if (!result.isError) {
|
|
154
157
|
const after = await observe(path);
|
|
155
|
-
if (after.kind === 'file')
|
|
158
|
+
if (after.kind === 'file') {
|
|
156
159
|
await recordRevision(api, key, after.revision, context);
|
|
160
|
+
// Not through `files.queue`: this already runs inside it. A failed history write never turns the done write into an error.
|
|
161
|
+
await files.record({ path, before: preimage?.kind === 'available' ? { revision: preimage.snapshot.ref.revision, bytes: preimage.snapshot.bytes } : null, after: after.revision, source: 'agent', conversationId: String(api.conversationId) }, access).catch(() => undefined);
|
|
162
|
+
}
|
|
157
163
|
}
|
|
158
164
|
return result;
|
|
159
165
|
});
|
package/dist/file-types.d.ts
CHANGED
|
@@ -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.
|
package/dist/file-types.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
32
|
+
const sniffed = sniffMediaType(bytes);
|
|
33
|
+
if (sniffed === 'application/pdf')
|
|
37
34
|
return 'pdf';
|
|
38
|
-
if (startsWith(
|
|
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 :
|
|
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' ?
|
|
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 {
|
|
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.
|
|
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.
|
|
135
|
-
"@hachej/boring-ui-kit": "0.2.
|
|
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": {
|