@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,177 @@
1
+ import { defineDoc, defineExtension, wrapTool } from '@earendil-works/pi-durable';
2
+ import { FileError, err, getOrThrow, ok } from '@earendil-works/pi-durable/env';
3
+ import { createEditTool, createReadTool, createWriteTool } from '@earendil-works/pi-durable/tools';
4
+ import { asWorkspaceResolver, decorate, workspaceFor } from './workspaces.js';
5
+ /** Conversation document: workspace and path (relative to the workspace root; see `baselineKey`) to the revision this conversation last read or wrote. */
6
+ export const lastReadRevisions = defineDoc({
7
+ kind: 'boring.files.last-read', version: 1, scope: 'conversation', history: 'latest', fork: 'initial',
8
+ initial: () => ({ revisions: {} }),
9
+ });
10
+ /** The baseline key of `path` in `workspace` (its `id`, or its provider id): `<workspace>:<path>`. */
11
+ export const baselineKey = (workspace, path) => `${workspace.id ?? workspace.files.providerId}:${path}`;
12
+ /** The revision this conversation last read or wrote under `key` (see `baselineKey`), or undefined. Also what host tools over workspace files (such as the canvas tools) check. */
13
+ export const lastReadRevision = async (api, key, context) => (await api.snapshot(lastReadRevisions, api.conversationId, context))?.revisions[key];
14
+ /** Record `revision` as the conversation's baseline under `key` (see `baselineKey`), durably. */
15
+ export const recordRevision = (api, key, revision, context) => api.commit(async (tx) => {
16
+ const doc = await tx.doc(lastReadRevisions, api.conversationId);
17
+ doc.revisions[key] = revision;
18
+ }, context);
19
+ const refusal = (text) => ({ content: [{ type: 'text', text }], isError: true });
20
+ // The same normalisation as Pi's tool paths: a leading "@" and special spaces.
21
+ const normalised = (path) => { const spaced = path.replace(/[  -    ]/g, ' '); return spaced.startsWith('@') ? spaced.slice(1) : spaced; };
22
+ /** `path` as the call's env names it, and relative to `root`, or undefined when it is not lexically inside `root` (or the call has no env). Pi's path normalisation applies. */
23
+ export async function workspaceRelative(api, root, path, context) {
24
+ if (api.env === undefined)
25
+ return undefined;
26
+ const absolute = getOrThrow(await api.env.absolutePath(normalised(path), context));
27
+ return absolute.startsWith(`${root}/`) ? { path: absolute.slice(root.length + 1), absolute } : undefined;
28
+ }
29
+ /**
30
+ * Whether `absolute` really lies inside `root` once links are resolved, through the env's own `canonicalPath`. A path that does not
31
+ * exist yet is judged by its deepest existing ancestor, so a link directory cannot lead a new file out. Any other failure to resolve
32
+ * is a refusal: the guard fails closed.
33
+ */
34
+ async function contained(env, root, absolute, context) {
35
+ if (env === undefined)
36
+ return false;
37
+ const realRoot = await env.canonicalPath(root, context);
38
+ if (!realRoot.ok)
39
+ return false;
40
+ const base = realRoot.value.replace(/\/+$/, '');
41
+ let candidate = absolute, rest = '';
42
+ for (;;) {
43
+ const real = await env.canonicalPath(candidate, context);
44
+ if (real.ok) {
45
+ const resolved = `${real.value.replace(/\/+$/, '')}${rest}`;
46
+ return resolved === base || resolved.startsWith(`${base}/`);
47
+ }
48
+ if (real.error.code !== 'not_found')
49
+ return false;
50
+ // A dangling link resolves to nothing, but a write through it would create its target: refuse it, wherever it is.
51
+ const link = await env.fileInfo(candidate, context);
52
+ // Only `not_found` proves nothing is there; a lost or failed answer could hide a link, so it refuses too.
53
+ if (link.ok ? link.value.kind === 'symlink' : link.error.code !== 'not_found')
54
+ return false;
55
+ const cut = candidate.lastIndexOf('/');
56
+ if (cut <= 0)
57
+ return false;
58
+ rest = `${candidate.slice(cut)}${rest}`;
59
+ candidate = candidate.slice(0, cut);
60
+ }
61
+ }
62
+ export const ONE_PATH = new Set(['readTextFile', 'openTextLineReader', 'readTextLines', 'readBinaryFile', 'openBinaryReader', 'writeFile', 'appendFile', 'truncateFile', 'flushFile', 'fileInfo', 'listDir', 'openDirReader', 'createDir', 'remove', 'canonicalPath']);
63
+ /**
64
+ * `env` as the native `read`, `write` and `edit` must see it: every operation that opens, changes or lists a path first re-checks the
65
+ * path it is ABOUT to touch (links resolved, like `contained`) and refuses outside `root` with a typed `permission_denied`. The check
66
+ * is on the path Pi actually opens, so a fallback spelling Pi picks after the provider's check (NFD, curly apostrophe) is judged as
67
+ * itself. `exists` answers false for a path outside, so Pi's fallback loop moves on instead of revealing it. A Pi extension point
68
+ * (`ExecutionEnv` decorator), public API only. Residual: a symlink swapped between this check and the OS `open` is not closable from
69
+ * here; that boundary is the host's isolation (an isolated worker env), not this guard.
70
+ */
71
+ export function confinedEnv(env, root) {
72
+ const denied = (path) => err(new FileError('permission_denied', `Refused: ${path} is outside the workspace.`, path));
73
+ const inside = (path, context) => contained(env, root, path, context);
74
+ return decorate(env, new Proxy(env, {
75
+ get(target, property) {
76
+ const member = Reflect.get(target, property, target);
77
+ if (typeof member !== 'function')
78
+ return member;
79
+ const method = member.bind(target);
80
+ if (typeof property !== 'string')
81
+ return method;
82
+ if (ONE_PATH.has(property))
83
+ return async (path, ...rest) => await inside(path, rest[rest.length - 1]) ? method(path, ...rest) : denied(path);
84
+ if (property === 'renameFile')
85
+ return async (from, to, context) => await inside(from, context) && await inside(to, context) ? method(from, to, context) : denied(`${from} -> ${to}`);
86
+ if (property === 'exists')
87
+ return async (path, context) => await inside(path, context) ? method(path, context) : ok(false);
88
+ return method;
89
+ },
90
+ }));
91
+ }
92
+ /** The guard as a native extension. Select it after the extension(s) that register `read`, `write` and `edit`. */
93
+ export function createFileGuard(options = {}) {
94
+ const resolver = asWorkspaceResolver(options.workspace);
95
+ async function observeIn(files, path, access) {
96
+ const read = await files.read({ target: { resource: { providerId: files.providerId, path }, view: { kind: 'published' } }, revision: { kind: 'latest' } }, access);
97
+ if (read.kind === 'available')
98
+ return { kind: 'file', revision: read.snapshot.ref.revision };
99
+ if (read.kind === 'missing')
100
+ return { kind: 'missing' };
101
+ return { kind: 'refused', reason: read.reason };
102
+ }
103
+ function guarded(tool, mode) {
104
+ const execute = async (args, api, context) => {
105
+ const resolved = await workspaceFor(resolver, api, context);
106
+ if ('refused' in resolved)
107
+ return refusal(`Refused: ${resolved.refused}`);
108
+ const { binding } = resolved;
109
+ // The native tool gets the env decorated to re-check what it actually opens (see `confinedEnv`); the guard's own checks use the raw one.
110
+ const run = () => tool.execute(args, api.env === undefined ? api : Object.create(api, { env: { value: confinedEnv(api.env, binding.root.replace(/\/+$/, '')), enumerable: true } }), context);
111
+ const { files } = binding;
112
+ const root = binding.root.replace(/\/+$/, '');
113
+ const granted = options.resolveAccess ? await options.resolveAccess(api, context) : binding.access;
114
+ if (granted === undefined)
115
+ return refusal('Refused: the host gave no access for this workspace.');
116
+ const access = { ...granted };
117
+ const observe = (path) => observeIn(files, path, access);
118
+ const located = await workspaceRelative(api, root, args.path, context);
119
+ if (located === undefined || !await contained(api.env, root, located.absolute, context))
120
+ return refusal(`Refused: ${args.path} is outside the workspace.`);
121
+ const { path } = located;
122
+ const key = baselineKey(binding, path);
123
+ return files.queue.run(async () => {
124
+ const before = await observe(path);
125
+ if (mode === 'read') {
126
+ if (before.kind === 'refused')
127
+ return refusal(`Refused: ${path} cannot be read here (${before.reason}).`);
128
+ // Pi's read falls back to other spellings of a missing path (NFD, a curly apostrophe, a narrow space before AM/PM). The
129
+ // provider and the containment check only vouch for this spelling, so a missing one is refused here and never delegated.
130
+ if (before.kind === 'missing')
131
+ return refusal(`Refused: ${path} does not exist in the workspace.`);
132
+ const result = await run();
133
+ if (result.isError || before.kind !== 'file')
134
+ return result;
135
+ // Record only the bytes the model saw: a change during the call (a shell) leaves the baseline alone.
136
+ const after = await observe(path);
137
+ if (after.kind === 'file' && after.revision === before.revision)
138
+ await recordRevision(api, key, after.revision, context);
139
+ return result;
140
+ }
141
+ if (before.kind === 'refused')
142
+ return refusal(`Refused: ${path} cannot be changed here (${before.reason}).`);
143
+ if (mode === 'edit' && before.kind === 'missing')
144
+ return refusal(`Refused: ${path} does not exist in the workspace.`);
145
+ if (before.kind === 'file') {
146
+ const known = await lastReadRevision(api, key, context);
147
+ if (known === undefined)
148
+ return refusal(`Refused: ${path} already exists and you have not read it in this conversation. Read it with the read tool, then make your change.`);
149
+ if (known !== before.revision)
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
+ }
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;
155
+ const result = await run();
156
+ if (!result.isError) {
157
+ const after = await observe(path);
158
+ if (after.kind === 'file') {
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
+ }
163
+ }
164
+ return result;
165
+ });
166
+ };
167
+ return { ...tool, execute };
168
+ }
169
+ return defineExtension({
170
+ name: options.name ?? 'boring.files.guard',
171
+ wraps: [
172
+ wrapTool(createReadTool(), tool => guarded(tool, 'read')),
173
+ wrapTool(createWriteTool(), tool => guarded(tool, 'write')),
174
+ wrapTool(createEditTool(), tool => guarded(tool, 'edit')),
175
+ ],
176
+ });
177
+ }
@@ -0,0 +1,17 @@
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
+ /** The media type of a native image (png, jpeg, gif, webp) by extension, or undefined. */
5
+ export declare const nativeImageType: (path: string) => string | undefined;
6
+ /** How many leading bytes `fileKind` looks at. */
7
+ export declare const SNIFF_BYTES = 16;
8
+ /** 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
+ 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
+ /**
13
+ * The media type a converter should be told. A signature in the leading bytes wins over everything (a PDF named `.txt` is
14
+ * `application/pdf`, whatever the provider derived from the name); then the provider's type unless it is generic; then the table's.
15
+ */
16
+ export declare function mediaTypeFor(path: string, kind: FileKind, provided: string | undefined, head?: Uint8Array): string;
17
+ //# sourceMappingURL=file-types.d.ts.map
@@ -0,0 +1,95 @@
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`.
4
+ *
5
+ * - `text`: Pi's native read, untouched.
6
+ * - `pdf`, `office`, `image`: a host converter can turn them into text; Pi's read cannot (it returns garbage for a PDF).
7
+ * - `archive`: a container nothing here opens.
8
+ * - `binary`: any other file with a NUL byte.
9
+ *
10
+ * 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
+ * wrong name.
12
+ */
13
+ 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
+ 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
+ };
21
+ const BY_EXTENSION = new Map([
22
+ ...TEXT.map(extension => [extension, 'text']),
23
+ ...OFFICE.map(extension => [extension, 'office']),
24
+ ...Object.keys(IMAGES).map(extension => [extension, 'image']),
25
+ ['pdf', 'pdf'], ['zip', 'archive'],
26
+ ]);
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
+ /** 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; };
31
+ /** How many leading bytes `fileKind` looks at. */
32
+ export const SNIFF_BYTES = 16;
33
+ const startsWith = (bytes, signature, at = 0) => signature.every((byte, index) => bytes[at + index] === byte);
34
+ /** The kind a magic number names, or undefined. A ZIP signature is an office document only under an office extension. */
35
+ function kindByMagic(bytes, extension) {
36
+ if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d]))
37
+ 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')
40
+ return 'image';
41
+ if (startsWith(bytes, [0x50, 0x4b, 0x03, 0x04]))
42
+ return OFFICE.includes(extension) ? 'office' : 'archive';
43
+ if (startsWith(bytes, [0xd0, 0xcf, 0x11, 0xe0]))
44
+ return 'office';
45
+ return undefined;
46
+ }
47
+ /** 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. */
48
+ export function fileKind(path, head) {
49
+ const extension = extensionOf(path);
50
+ const named = BY_EXTENSION.get(extension);
51
+ if (head !== undefined) {
52
+ const magic = kindByMagic(head, extension);
53
+ if (magic !== undefined)
54
+ return magic;
55
+ if (named === undefined || named === 'text')
56
+ return head.includes(0) ? 'binary' : 'text';
57
+ }
58
+ return named ?? 'text';
59
+ }
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
+ /**
82
+ * The media type a converter should be told. A signature in the leading bytes wins over everything (a PDF named `.txt` is
83
+ * `application/pdf`, whatever the provider derived from the name); then the provider's type unless it is generic; then the table's.
84
+ */
85
+ export function mediaTypeFor(path, kind, provided, head) {
86
+ const sniffed = head === undefined ? undefined : sniffedMediaType(head);
87
+ if (sniffed !== undefined)
88
+ return sniffed;
89
+ const extension = extensionOf(path);
90
+ const table = kind === 'pdf' ? 'application/pdf' : kind === 'image' ? IMAGES[extension]?.mediaType : kind === 'office' ? OFFICE_TYPES[extension] : undefined;
91
+ const generic = !provided || provided === 'application/octet-stream' || provided.startsWith('text/') || provided === 'application/zip';
92
+ if (!generic)
93
+ return provided;
94
+ return table ?? provided ?? 'application/octet-stream';
95
+ }
@@ -0,0 +1,24 @@
1
+ import { type Provider } from '@earendil-works/pi-ai/models';
2
+ /** The provider id gateway models carry unless the host names another. */
3
+ export declare const GATEWAY_PROVIDER = "boring-gateway";
4
+ /** A model the gateway allows, by its gateway id (the host maps it to an upstream model). */
5
+ export interface GatewayModel {
6
+ readonly id: string;
7
+ readonly name?: string;
8
+ readonly contextWindow?: number;
9
+ /** Requested output ceiling; the gateway clamps it to the person's budget either way. */
10
+ readonly maxTokens?: number;
11
+ readonly input?: readonly ('text' | 'image')[];
12
+ }
13
+ export interface GatewayProviderOptions {
14
+ /** The gateway root: `/api/llm` (resolved against the page) or an absolute URL. Requests go to `<baseUrl>/v1/...` only. */
15
+ readonly baseUrl: string;
16
+ readonly models: readonly (string | GatewayModel)[];
17
+ /** The app session as request headers (for example `{ authorization: 'Bearer <session>' }`), read on every request. */
18
+ readonly getAuth: () => Readonly<Record<string, string>> | Promise<Readonly<Record<string, string>>>;
19
+ readonly id?: string;
20
+ readonly name?: string;
21
+ }
22
+ /** `models.setProvider(gatewayProvider({ baseUrl: '/api/llm', models: ['fernhill-preview'], getAuth }))`, then any Pi model call. */
23
+ export declare function gatewayProvider(options: GatewayProviderOptions): Provider<'openai-completions'>;
24
+ //# sourceMappingURL=gateway-provider.d.ts.map
@@ -0,0 +1,42 @@
1
+ // @hachej/boring-agent/gateway-provider: a native Pi provider whose models are served by a Boring model gateway
2
+ // (`@hachej/boring-agent/model-gateway` on the host, or a shared gateway service with the same API). Browser-safe: it knows only the
3
+ // gateway's base URL and the app session headers; it holds no model key and reads no environment. The wire is Pi's own
4
+ // `openai-completions` implementation pointed at `<baseUrl>/v1`, so streaming, tools and usage are native Pi behavior.
5
+ // Self-contained on purpose (ARCHITECTURE.json pathRules forbid its relative and node imports).
6
+ import { createProvider } from '@earendil-works/pi-ai/models';
7
+ import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy';
8
+ /** The provider id gateway models carry unless the host names another. */
9
+ export const GATEWAY_PROVIDER = 'boring-gateway';
10
+ function absolute(baseUrl) {
11
+ const page = globalThis.location?.href;
12
+ let url;
13
+ try {
14
+ url = page ? new URL(baseUrl, page) : new URL(baseUrl);
15
+ }
16
+ catch {
17
+ throw new TypeError('gatewayProvider needs an absolute baseUrl outside a page');
18
+ }
19
+ return `${url.origin}${url.pathname.replace(/\/+$/, '')}/v1`;
20
+ }
21
+ /** `models.setProvider(gatewayProvider({ baseUrl: '/api/llm', models: ['fernhill-preview'], getAuth }))`, then any Pi model call. */
22
+ export function gatewayProvider(options) {
23
+ if (typeof options.getAuth !== 'function')
24
+ throw new TypeError('gatewayProvider needs getAuth()');
25
+ const id = options.id ?? GATEWAY_PROVIDER;
26
+ const baseUrl = absolute(options.baseUrl);
27
+ const models = options.models.map(entry => {
28
+ const model = typeof entry === 'string' ? { id: entry } : entry;
29
+ return {
30
+ id: model.id, name: model.name ?? model.id, api: 'openai-completions', provider: id, baseUrl, reasoning: false,
31
+ input: [...(model.input ?? ['text'])], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
32
+ contextWindow: model.contextWindow ?? 128_000, maxTokens: model.maxTokens ?? 4096,
33
+ // The gateway speaks plain chat completions: no stored responses, system prompts as `system`, usage in the stream.
34
+ compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false, supportsUsageInStreaming: true, maxTokensField: 'max_completion_tokens' },
35
+ };
36
+ });
37
+ return createProvider({
38
+ id, name: options.name ?? 'Boring model gateway', baseUrl, models, api: openAICompletionsApi(),
39
+ // The app session is the only credential: no API key exists on this side.
40
+ auth: { apiKey: { name: 'App session', resolve: async () => ({ auth: { headers: { ...(await options.getAuth()) } }, source: 'app session' }) } },
41
+ });
42
+ }
package/dist/git.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { GitRepository } from '@hachej/boring-files/git';
2
+ import { Type } from '@earendil-works/pi-ai';
3
+ /**
4
+ * The host passes the same authorized working repository used by other callers, or nothing: then each call uses the repository of
5
+ * its own workspace, the binding attached to the env Pi handed the call (`withWorkspace` in `@hachej/boring-agent/workspaces`), so one
6
+ * harness serves a repository per person. Git mutations have no replay receipts.
7
+ */
8
+ export declare function createGitTool(repository?: GitRepository): import("@earendil-works/pi-durable").ToolRegistration<Type.TUnion<[Type.TObject<{
9
+ operation: Type.TUnion<[Type.TLiteral<"init">, Type.TLiteral<"status">, Type.TLiteral<"log">, Type.TLiteral<"branches">]>;
10
+ }>, Type.TObject<{
11
+ operation: Type.TUnion<[Type.TLiteral<"add">, Type.TLiteral<"remove">]>;
12
+ path: Type.TString;
13
+ }>, Type.TObject<{
14
+ operation: Type.TLiteral<"commit">;
15
+ message: Type.TString;
16
+ }>, Type.TObject<{
17
+ operation: Type.TUnion<[Type.TLiteral<"branch">, Type.TLiteral<"checkout">]>;
18
+ ref: Type.TString;
19
+ }>, Type.TObject<{
20
+ operation: Type.TLiteral<"diff">;
21
+ before: Type.TUnion<[Type.TObject<{
22
+ kind: Type.TLiteral<"tree">;
23
+ ref: Type.TString;
24
+ }>, Type.TObject<{
25
+ kind: Type.TUnion<[Type.TLiteral<"index">, Type.TLiteral<"worktree">]>;
26
+ }>]>;
27
+ after: Type.TUnion<[Type.TObject<{
28
+ kind: Type.TLiteral<"tree">;
29
+ ref: Type.TString;
30
+ }>, Type.TObject<{
31
+ kind: Type.TUnion<[Type.TLiteral<"index">, Type.TLiteral<"worktree">]>;
32
+ }>]>;
33
+ }>]>, import("@earendil-works/chord").JsonValue>;
34
+ //# sourceMappingURL=git.d.ts.map
package/dist/git.js ADDED
@@ -0,0 +1,79 @@
1
+ import { defineTool } from '@earendil-works/pi-durable';
2
+ import { Type } from '@earendil-works/pi-ai';
3
+ import { workspaceFor } from './workspaces.js';
4
+ /** Model-facing form of a diff side: decoded text, or a binary marker without the bytes. */
5
+ function readable(version) {
6
+ if (!version)
7
+ return null;
8
+ const { oid, mode, bytes } = version;
9
+ try {
10
+ return { oid, mode, text: new TextDecoder('utf-8', { fatal: true }).decode(bytes) };
11
+ }
12
+ catch {
13
+ return { oid, mode, binary: true, size: bytes.length };
14
+ }
15
+ }
16
+ /**
17
+ * The host passes the same authorized working repository used by other callers, or nothing: then each call uses the repository of
18
+ * its own workspace, the binding attached to the env Pi handed the call (`withWorkspace` in `@hachej/boring-agent/workspaces`), so one
19
+ * harness serves a repository per person. Git mutations have no replay receipts.
20
+ */
21
+ export function createGitTool(repository) {
22
+ const view = Type.Union([
23
+ Type.Object({ kind: Type.Literal('tree'), ref: Type.String({ minLength: 1 }) }, { additionalProperties: false }),
24
+ Type.Object({ kind: Type.Union([Type.Literal('index'), Type.Literal('worktree')]) }, { additionalProperties: false }),
25
+ ]);
26
+ return defineTool({
27
+ name: 'working_git',
28
+ description: 'Operate on the bound working repository. Removal affects only the index. Mutations may leave partial effects and are not publication.',
29
+ replay: 'unsafe',
30
+ parameters: Type.Union([
31
+ Type.Object({ operation: Type.Union([Type.Literal('init'), Type.Literal('status'), Type.Literal('log'), Type.Literal('branches')]) }, { additionalProperties: false }),
32
+ Type.Object({ operation: Type.Union([Type.Literal('add'), Type.Literal('remove')]), path: Type.String({ minLength: 1 }) }, { additionalProperties: false }),
33
+ Type.Object({ operation: Type.Literal('commit'), message: Type.String({ minLength: 1 }) }, { additionalProperties: false }),
34
+ Type.Object({ operation: Type.Union([Type.Literal('branch'), Type.Literal('checkout')]), ref: Type.String({ minLength: 1 }) }, { additionalProperties: false }),
35
+ Type.Object({ operation: Type.Literal('diff'), before: view, after: view }, { additionalProperties: false }),
36
+ ]),
37
+ execute: async (args, api, context) => {
38
+ const resolved = repository === undefined ? await workspaceFor(undefined, api, context) : undefined;
39
+ const selected = repository ?? (resolved && 'binding' in resolved ? resolved.binding.repository : undefined);
40
+ if (selected === undefined)
41
+ return { content: [{ type: 'text', text: 'This conversation has no working repository.' }], isError: true };
42
+ const signal = context.abortSignal;
43
+ let value;
44
+ switch (args.operation) {
45
+ case 'init':
46
+ value = await selected.init(signal);
47
+ break;
48
+ case 'status':
49
+ value = await selected.status(signal);
50
+ break;
51
+ case 'log':
52
+ value = await selected.log(signal);
53
+ break;
54
+ case 'branches':
55
+ value = await selected.branches(signal);
56
+ break;
57
+ case 'add':
58
+ value = await selected.add(args.path, signal);
59
+ break;
60
+ case 'remove':
61
+ value = await selected.remove(args.path, signal);
62
+ break;
63
+ case 'commit':
64
+ value = await selected.commit(args.message, signal);
65
+ break;
66
+ case 'branch':
67
+ value = await selected.branch(args.ref, signal);
68
+ break;
69
+ case 'checkout':
70
+ value = await selected.checkout(args.ref, signal);
71
+ break;
72
+ case 'diff':
73
+ value = (await selected.diff(args.before, args.after, signal)).map(change => ({ path: change.path, before: readable(change.before), after: readable(change.after) }));
74
+ break;
75
+ }
76
+ return { content: [{ type: 'text', text: JSON.stringify({ operation: args.operation, value: value ?? null }) }] };
77
+ },
78
+ });
79
+ }
@@ -0,0 +1,38 @@
1
+ import type { Harness } from '@earendil-works/pi-durable';
2
+ import type { Context } from '@earendil-works/chord';
3
+ /** What `create` returns: the owner's harness, anything else the host keeps with it, and how to close it (default `harness.close`). */
4
+ export interface PooledHarness {
5
+ readonly harness: Harness;
6
+ readonly close?: () => Promise<void>;
7
+ }
8
+ export interface HarnessPoolOptions<T extends PooledHarness = PooledHarness> {
9
+ /** Open the harness of `owner`. Called once per owner while it stays open; must not share storage with another owner. */
10
+ readonly create: (owner: string) => Promise<T>;
11
+ /** Long-lived context for `inspect` and `close`. */
12
+ readonly context: Context;
13
+ /** Close an owner's harness this long after its last use (default 10 minutes; `Infinity`: never). */
14
+ readonly idleMs?: number;
15
+ /** A close that failed. Must not throw. */
16
+ readonly onError?: (error: unknown, owner: string) => void;
17
+ }
18
+ export interface HarnessLease<T extends PooledHarness = PooledHarness> {
19
+ readonly owned: T;
20
+ /** Return the hold. It never closes the harness; the pool does that once idle. Idempotent. */
21
+ readonly release: () => void;
22
+ }
23
+ export interface HarnessPool<T extends PooledHarness = PooledHarness> {
24
+ /** Hold the harness of `owner`, opening it when needed. */
25
+ readonly acquire: (owner: string) => Promise<HarnessLease<T>>;
26
+ /** Run `operation` on the owner's harness, held for its duration. */
27
+ readonly use: <R>(owner: string, operation: (owned: T) => Promise<R>) => Promise<R>;
28
+ /** A transport operation: the harness stays held until the response body ends or is cancelled (a watch stream). */
29
+ readonly respond: (owner: string, operation: (owned: T) => Promise<Response>) => Promise<Response>;
30
+ /** The owners whose harness is open now. */
31
+ readonly owners: () => string[];
32
+ /** What `create` returned for every harness open now, without holding any (a health check reads them). */
33
+ readonly opened: () => Promise<T[]>;
34
+ /** Close every harness (host shutdown). */
35
+ readonly close: () => Promise<void>;
36
+ }
37
+ export declare function createHarnessPool<T extends PooledHarness = PooledHarness>(options: HarnessPoolOptions<T>): HarnessPool<T>;
38
+ //# sourceMappingURL=harness-pool.d.ts.map
@@ -0,0 +1,134 @@
1
+ export function createHarnessPool(options) {
2
+ const { context } = options;
3
+ const idleMs = options.idleMs ?? 10 * 60_000;
4
+ if (!(idleMs > 0))
5
+ throw new TypeError('idleMs must be positive');
6
+ const entries = new Map();
7
+ let closed = false;
8
+ function arm(owner, entry) {
9
+ if (entry.timer !== undefined)
10
+ clearTimeout(entry.timer);
11
+ if (!Number.isFinite(idleMs))
12
+ return;
13
+ entry.timer = setTimeout(() => { void sweep(owner, entry); }, Math.max(0, entry.lastUsed + idleMs - Date.now()));
14
+ entry.timer.unref?.();
15
+ }
16
+ async function sweep(owner, entry) {
17
+ entry.timer = undefined;
18
+ if (entries.get(owner) !== entry || entry.leases > 0)
19
+ return;
20
+ if (Date.now() - entry.lastUsed < idleMs)
21
+ return arm(owner, entry);
22
+ // Background work (a subagent, a queued follow-up) keeps the harness open: closing it would stop a task nobody is watching.
23
+ let busy = true;
24
+ try {
25
+ const inspection = await (await entry.opening).harness.inspect(context);
26
+ busy = inspection.tasks.length > 0 || inspection.submissions.length > 0;
27
+ }
28
+ catch { /* keep it */ }
29
+ if (entries.get(owner) !== entry || entry.leases > 0)
30
+ return;
31
+ if (busy) {
32
+ entry.lastUsed = Date.now();
33
+ return arm(owner, entry);
34
+ }
35
+ await dispose(owner, entry);
36
+ }
37
+ function dispose(owner, entry) {
38
+ if (entries.get(owner) === entry)
39
+ entries.delete(owner);
40
+ if (entry.timer !== undefined)
41
+ clearTimeout(entry.timer);
42
+ entry.closing ??= entry.opening.then(owned => owned.close ? owned.close() : owned.harness.close(context), () => undefined).catch(error => { options.onError?.(error, owner); });
43
+ return entry.closing;
44
+ }
45
+ async function acquire(owner) {
46
+ if (closed)
47
+ throw new Error('The harness pool is closed');
48
+ if (typeof owner !== 'string' || owner === '')
49
+ throw new TypeError('An owner key is required');
50
+ let entry = entries.get(owner);
51
+ if (entry === undefined) {
52
+ const opening = Promise.resolve().then(() => options.create(owner));
53
+ const created = { opening, leases: 0, lastUsed: Date.now() };
54
+ entries.set(owner, created);
55
+ opening.catch(() => { if (entries.get(owner) === created)
56
+ entries.delete(owner); });
57
+ entry = created;
58
+ }
59
+ const held = entry;
60
+ held.leases++;
61
+ held.lastUsed = Date.now();
62
+ let owned;
63
+ try {
64
+ owned = await held.opening;
65
+ }
66
+ catch (error) {
67
+ held.leases--;
68
+ throw error;
69
+ }
70
+ let released = false;
71
+ return { owned, release: () => {
72
+ if (released)
73
+ return;
74
+ released = true;
75
+ held.leases--;
76
+ held.lastUsed = Date.now();
77
+ if (held.leases === 0 && entries.get(owner) === held)
78
+ arm(owner, held);
79
+ } };
80
+ }
81
+ return {
82
+ acquire,
83
+ use: async (owner, operation) => {
84
+ const lease = await acquire(owner);
85
+ try {
86
+ return await operation(lease.owned);
87
+ }
88
+ finally {
89
+ lease.release();
90
+ }
91
+ },
92
+ respond: async (owner, operation) => {
93
+ const lease = await acquire(owner);
94
+ let response;
95
+ try {
96
+ response = await operation(lease.owned);
97
+ }
98
+ catch (error) {
99
+ lease.release();
100
+ throw error;
101
+ }
102
+ if (response.body === null) {
103
+ lease.release();
104
+ return response;
105
+ }
106
+ const reader = response.body.getReader();
107
+ const body = new ReadableStream({
108
+ async pull(controller) {
109
+ try {
110
+ const { done, value } = await reader.read();
111
+ if (done) {
112
+ lease.release();
113
+ controller.close();
114
+ }
115
+ else
116
+ controller.enqueue(value);
117
+ }
118
+ catch (error) {
119
+ lease.release();
120
+ controller.error(error);
121
+ }
122
+ },
123
+ async cancel(reason) { lease.release(); await reader.cancel(reason); },
124
+ });
125
+ return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
126
+ },
127
+ owners: () => [...entries.keys()],
128
+ opened: async () => (await Promise.all([...entries.values()].map(entry => entry.opening.catch(() => undefined)))).filter((owned) => owned !== undefined),
129
+ close: async () => {
130
+ closed = true;
131
+ await Promise.all([...entries].map(([owner, entry]) => dispose(owner, entry)));
132
+ },
133
+ };
134
+ }