@hachej/boring-agent 0.1.110 → 0.2.0
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/LICENSE +1 -1
- package/README.md +262 -51
- package/dist/agents.d.ts +75 -0
- package/dist/agents.js +91 -0
- package/dist/approval.d.ts +71 -0
- package/dist/approval.js +126 -0
- package/dist/artifacts.d.ts +53 -0
- package/dist/artifacts.js +129 -0
- package/dist/ask-user.d.ts +40 -0
- package/dist/ask-user.js +134 -0
- package/dist/attachment.d.ts +5 -0
- package/dist/attachment.js +4 -0
- package/dist/browser-task.d.ts +32 -0
- package/dist/browser-task.js +119 -0
- package/dist/chat-transport.d.ts +80 -0
- package/dist/chat-transport.js +306 -0
- package/dist/contracts.d.ts +40 -0
- package/dist/contracts.js +1 -0
- package/dist/conversations.d.ts +136 -0
- package/dist/conversations.js +366 -0
- package/dist/definitions.d.ts +37 -0
- package/dist/definitions.js +99 -0
- package/dist/delivery-input.d.ts +35 -0
- package/dist/delivery-input.js +56 -0
- package/dist/delivery.d.ts +33 -0
- package/dist/delivery.js +154 -0
- package/dist/durable-wait.d.ts +23 -0
- package/dist/durable-wait.js +79 -0
- package/dist/file-convert.d.ts +46 -0
- package/dist/file-convert.js +159 -0
- package/dist/file-guard.d.ts +50 -0
- package/dist/file-guard.js +171 -0
- package/dist/file-types.d.ts +17 -0
- package/dist/file-types.js +95 -0
- package/dist/gateway-provider.d.ts +24 -0
- package/dist/gateway-provider.js +42 -0
- package/dist/git.d.ts +34 -0
- package/dist/git.js +79 -0
- package/dist/harness-pool.d.ts +38 -0
- package/dist/harness-pool.js +134 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1 -0
- package/dist/json-schema-tool.d.ts +25 -0
- package/dist/json-schema-tool.js +19 -0
- package/dist/memory/optchat-core.d.ts +118 -0
- package/dist/memory/optchat-core.js +317 -0
- package/dist/memory/optchat.d.ts +156 -0
- package/dist/memory/optchat.js +432 -0
- package/dist/mentions.d.ts +56 -0
- package/dist/mentions.js +140 -0
- package/dist/metering.d.ts +183 -0
- package/dist/metering.js +443 -0
- package/dist/model-gateway.d.ts +86 -0
- package/dist/model-gateway.js +241 -0
- package/dist/native-version.d.ts +9 -0
- package/dist/native-version.js +7 -0
- package/dist/native.d.ts +4 -0
- package/dist/native.js +1 -0
- package/dist/presentation.d.ts +17 -0
- package/dist/presentation.js +39 -0
- package/dist/projection-format.d.ts +68 -0
- package/dist/projection-format.js +118 -0
- package/dist/projection-window.d.ts +9 -0
- package/dist/projection-window.js +93 -0
- package/dist/projection.d.ts +22 -0
- package/dist/projection.js +153 -0
- package/dist/question-response.d.ts +20 -0
- package/dist/question-response.js +75 -0
- package/dist/questions.d.ts +74 -0
- package/dist/questions.js +199 -0
- package/dist/self-evolving.d.ts +55 -0
- package/dist/self-evolving.js +239 -0
- package/dist/skills.d.ts +15 -0
- package/dist/skills.js +51 -0
- package/dist/tool-evidence.d.ts +14 -0
- package/dist/tool-evidence.js +54 -0
- package/dist/validation.d.ts +38 -0
- package/dist/validation.js +68 -0
- package/dist/workspaces.d.ts +107 -0
- package/dist/workspaces.js +212 -0
- package/package.json +145 -150
- package/dist/DebugDrawer-JNA43SVR.js +0 -8
- package/dist/admin-DORVQfLn.d.ts +0 -340
- package/dist/chunk-2BGLZZ7T.js +0 -10
- package/dist/chunk-42D6KAQV.js +0 -2882
- package/dist/chunk-4XOW57KJ.js +0 -343
- package/dist/chunk-72KT6VWV.js +0 -1338
- package/dist/chunk-7XAOVFSN.js +0 -28
- package/dist/chunk-KJIEMSQP.js +0 -198
- package/dist/chunk-KN72XWP7.js +0 -394
- package/dist/chunk-N6THTH7P.js +0 -110
- package/dist/chunk-V3H5A3BR.js +0 -22501
- package/dist/chunk-YGRQ4Q5P.js +0 -20
- package/dist/chunk-YTUZFKB3.js +0 -78
- package/dist/core/index.d.ts +0 -28
- package/dist/core/index.js +0 -617
- package/dist/createHarness-DOLOHA62.js +0 -18
- package/dist/eval/index.d.ts +0 -247
- package/dist/eval/index.js +0 -541
- package/dist/file-search-BUgMSKZE.d.ts +0 -289
- package/dist/front/artifacts.d.ts +0 -34
- package/dist/front/artifacts.js +0 -21
- package/dist/front/index.d.ts +0 -1082
- package/dist/front/index.js +0 -12912
- package/dist/front/styles.css +0 -5380
- package/dist/harness-qHlMJhZo.d.ts +0 -276
- package/dist/middleware-CaAEXpi5.d.ts +0 -115
- package/dist/piChatEvent-DpE0lIb9.d.ts +0 -450
- package/dist/piChatSessionService-CBct-hmz.d.ts +0 -86
- package/dist/sandbox-BMhut3kh.d.ts +0 -317
- package/dist/server/agent-host/testing/compositionRouteProof.d.ts +0 -8
- package/dist/server/agent-host/testing/compositionRouteProof.js +0 -78
- package/dist/server/agent-host/testing/gatewayConformance.d.ts +0 -43
- package/dist/server/agent-host/testing/gatewayConformance.js +0 -605
- package/dist/server/index.d.ts +0 -2278
- package/dist/server/index.js +0 -274
- package/dist/server/pi-session-readability.d.ts +0 -12
- package/dist/server/pi-session-readability.js +0 -23
- package/dist/server/worker/index.d.ts +0 -68
- package/dist/server/worker/index.js +0 -377
- package/dist/share-entry-CxS6UJ_L.d.ts +0 -330
- package/dist/shared/index.d.ts +0 -2111
- package/dist/shared/index.js +0 -262
- package/dist/types-DcWoQMn8.d.ts +0 -1377
- package/docs/ACCESSIBILITY.md +0 -52
- package/docs/AGENT_GATEWAY_V0.md +0 -212
- package/docs/AGENT_PACKAGES.md +0 -90
- package/docs/API.md +0 -123
- package/docs/AUTHORING.md +0 -68
- package/docs/CSP.md +0 -41
- package/docs/ERROR_CODES.md +0 -229
- package/docs/KNOWN_LIMITATIONS.md +0 -100
- package/docs/MIGRATION.md +0 -50
- package/docs/PERFORMANCE.md +0 -68
- package/docs/PLUGINS.md +0 -138
- package/docs/README.md +0 -98
- package/docs/RISKS-MULTI-TAB.md +0 -46
- package/docs/SESSION_IDENTITY.md +0 -91
- package/docs/STYLING.md +0 -73
- package/docs/UI-SHADCN.md +0 -56
- package/docs/VERCEL_COSTS.md +0 -103
- package/docs/plans/archive/AGENT_EVAL_FRAMEWORK.md +0 -466
- package/docs/plans/archive/agent-package-spec.md +0 -1777
- package/docs/plans/archive/harness-followup-capabilities.md +0 -440
- package/docs/plans/archive/harness-tool-ui-capabilities.md +0 -144
- package/docs/plans/archive/pi-followup-history-projection.md +0 -229
- package/docs/plans/archive/pi-tools-migration.md +0 -1061
- package/docs/plans/archive/reviews/pi-followup-history-codex-review.md +0 -11
- package/docs/plans/archive/reviews/pi-followup-history-gpt-review.md +0 -64
- package/docs/plans/archive/reviews/pi-followup-history-opus-review.md +0 -43
- package/docs/plans/archive/reviews/pi-followup-history-xai-review.md +0 -43
- package/docs/plans/archive/vercel-base-snapshot-template-plan.md +0 -527
- package/docs/plans/archive/vercel-persistent-sandbox-adapter.md +0 -553
- package/docs/runtime-provisioning.md +0 -157
- package/docs/runtime.md +0 -296
- package/docs/tools.md +0 -102
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { TaskId } from '@earendil-works/pi-durable';
|
|
2
|
+
import type { ResourceAccess, ResourceExpectation } from '@hachej/boring-files';
|
|
3
|
+
type Identity = Pick<ResourceAccess, 'principalId' | 'scopeId' | 'initiatorId'> & {
|
|
4
|
+
authorizationRef: string | null;
|
|
5
|
+
};
|
|
6
|
+
export type DocumentDeliveryTarget = ResourceExpectation & {
|
|
7
|
+
readonly preconditions?: readonly ResourceExpectation[];
|
|
8
|
+
};
|
|
9
|
+
export type DeliveryInput = {
|
|
10
|
+
producer: TaskId<string>;
|
|
11
|
+
target: ResourceExpectation;
|
|
12
|
+
preconditions: readonly ResourceExpectation[];
|
|
13
|
+
identity: Identity;
|
|
14
|
+
namespace: string;
|
|
15
|
+
validationVersion: string;
|
|
16
|
+
};
|
|
17
|
+
export type DeliveryPhase = {
|
|
18
|
+
phase: 'wait';
|
|
19
|
+
} | {
|
|
20
|
+
phase: 'validate';
|
|
21
|
+
} | {
|
|
22
|
+
phase: 'publish';
|
|
23
|
+
text: string;
|
|
24
|
+
};
|
|
25
|
+
export declare function captureDeliveryTarget(value: DocumentDeliveryTarget): {
|
|
26
|
+
target: ResourceExpectation;
|
|
27
|
+
preconditions: ResourceExpectation[];
|
|
28
|
+
};
|
|
29
|
+
export declare function identity(access: ResourceAccess): Identity;
|
|
30
|
+
export declare function migrateDelivery(input: unknown, checkpoint: unknown, fromVersion: number): {
|
|
31
|
+
input: DeliveryInput;
|
|
32
|
+
checkpoint: DeliveryPhase;
|
|
33
|
+
};
|
|
34
|
+
export {};
|
|
35
|
+
//# sourceMappingURL=delivery-input.d.ts.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { locator, reference } from '@hachej/boring-files/publication';
|
|
2
|
+
function object(value) {
|
|
3
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
4
|
+
throw new TypeError('Invalid delivery binding');
|
|
5
|
+
return Object.fromEntries(Object.entries(value));
|
|
6
|
+
}
|
|
7
|
+
function expectation(value) {
|
|
8
|
+
const input = object(value);
|
|
9
|
+
if (input.kind === 'absent')
|
|
10
|
+
return { kind: 'absent', target: locator(input.target) };
|
|
11
|
+
if (input.kind === 'revision')
|
|
12
|
+
return { kind: 'revision', target: reference(input.target) };
|
|
13
|
+
throw new TypeError('Invalid delivery expectation');
|
|
14
|
+
}
|
|
15
|
+
export function captureDeliveryTarget(value) {
|
|
16
|
+
const input = object(value);
|
|
17
|
+
if (input.preconditions !== undefined && !Array.isArray(input.preconditions))
|
|
18
|
+
throw new TypeError('Invalid delivery preconditions');
|
|
19
|
+
return { target: expectation(input), preconditions: input.preconditions === undefined ? [] : Array.from(input.preconditions, expectation) };
|
|
20
|
+
}
|
|
21
|
+
export function identity(access) {
|
|
22
|
+
const result = { principalId: access.principalId, scopeId: access.scopeId, initiatorId: access.initiatorId, authorizationRef: access.authorizationRef ?? null };
|
|
23
|
+
if ([result.principalId, result.scopeId, result.initiatorId].some(value => typeof value !== 'string' || !value))
|
|
24
|
+
throw new TypeError('Delivery requires an authenticated identity');
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
function nonempty(value) {
|
|
28
|
+
if (typeof value !== 'string' || !value)
|
|
29
|
+
throw new TypeError('Invalid v1 delivery binding');
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function producerId(value) {
|
|
33
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
|
34
|
+
}
|
|
35
|
+
export function migrateDelivery(input, checkpoint, fromVersion) {
|
|
36
|
+
if (fromVersion !== 1)
|
|
37
|
+
throw new TypeError('Unsupported delivery version');
|
|
38
|
+
const old = object(input), phase = object(checkpoint), actor = object(old.identity);
|
|
39
|
+
if (!producerId(old.producer) || 'preconditions' in old)
|
|
40
|
+
throw new TypeError('Invalid v1 delivery input');
|
|
41
|
+
if (actor.authorizationRef !== null && typeof actor.authorizationRef !== 'string')
|
|
42
|
+
throw new TypeError('Invalid v1 delivery identity');
|
|
43
|
+
const migrated = {
|
|
44
|
+
producer: old.producer, target: expectation(old.target), preconditions: [],
|
|
45
|
+
identity: {
|
|
46
|
+
principalId: nonempty(actor.principalId), scopeId: nonempty(actor.scopeId), initiatorId: nonempty(actor.initiatorId),
|
|
47
|
+
authorizationRef: actor.authorizationRef,
|
|
48
|
+
},
|
|
49
|
+
namespace: nonempty(old.namespace), validationVersion: nonempty(old.validationVersion),
|
|
50
|
+
};
|
|
51
|
+
if (phase.phase === 'wait' || phase.phase === 'validate')
|
|
52
|
+
return { input: migrated, checkpoint: { phase: phase.phase } };
|
|
53
|
+
if (phase.phase === 'publish' && typeof phase.text === 'string')
|
|
54
|
+
return { input: migrated, checkpoint: { phase: 'publish', text: phase.text } };
|
|
55
|
+
throw new TypeError('Invalid v1 delivery checkpoint');
|
|
56
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { TaskId, TaskOptions, Tx } from '@earendil-works/pi-durable';
|
|
2
|
+
import type { Context } from '@earendil-works/chord';
|
|
3
|
+
import type { PublicationLookup, PublicationResult, ResourceAccess, ResourceExpectation, ResourcePublisher } from '@hachej/boring-files';
|
|
4
|
+
import type { DeliveryInput, DeliveryPhase, DocumentDeliveryTarget } from './delivery-input.js';
|
|
5
|
+
export type { DocumentDeliveryTarget } from './delivery-input.js';
|
|
6
|
+
export type DocumentDeliveryResult = PublicationResult | {
|
|
7
|
+
kind: 'invalid';
|
|
8
|
+
errors: readonly string[];
|
|
9
|
+
} | {
|
|
10
|
+
kind: 'producer-failed';
|
|
11
|
+
status: string;
|
|
12
|
+
};
|
|
13
|
+
export interface DocumentDeliveryOptions {
|
|
14
|
+
readonly operationNamespace: string;
|
|
15
|
+
readonly validationVersion: string;
|
|
16
|
+
readonly publisher: ResourcePublisher;
|
|
17
|
+
readonly lookup: PublicationLookup;
|
|
18
|
+
/** This callback checks real producer output. It does not generate or repair output. */
|
|
19
|
+
readonly validate: (text: string, producer: TaskId<string>, context: Context) => readonly string[];
|
|
20
|
+
readonly resolveAccess: (target: ResourceExpectation, producer: TaskId<string>, context: Context) => ResourceAccess | Promise<ResourceAccess>;
|
|
21
|
+
/** Safe replay requires host qualification of atomic receipts and durable duplicate suppression. */
|
|
22
|
+
readonly replay?: 'safe' | 'reconcile-only';
|
|
23
|
+
}
|
|
24
|
+
export declare function createDocumentDelivery(options: DocumentDeliveryOptions): {
|
|
25
|
+
task: import("@earendil-works/pi-durable").Task<DeliveryInput, DeliveryPhase, DocumentDeliveryResult, object>;
|
|
26
|
+
extension: import("@earendil-works/pi-durable").Extension<import("@earendil-works/pi-durable").ToolRegistration>;
|
|
27
|
+
admit: (tx: Tx, createProducer: (tx: Tx) => Promise<TaskId<string>>, target: DocumentDeliveryTarget, taskOptions: TaskOptions, context: Context, access?: ResourceAccess) => Promise<{
|
|
28
|
+
producer: TaskId<string>;
|
|
29
|
+
delivery: TaskId<DocumentDeliveryResult>;
|
|
30
|
+
operationId: string;
|
|
31
|
+
}>;
|
|
32
|
+
};
|
|
33
|
+
//# sourceMappingURL=delivery.d.ts.map
|
package/dist/delivery.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { defineExtension, defineTask } from '@earendil-works/pi-durable';
|
|
2
|
+
import { parsePublicationResult, publicationDigest } from '@hachej/boring-files/publication';
|
|
3
|
+
import { captureDeliveryTarget, identity, migrateDelivery } from './delivery-input.js';
|
|
4
|
+
export function createDocumentDelivery(options) {
|
|
5
|
+
const namespace = options.operationNamespace;
|
|
6
|
+
const validationVersion = options.validationVersion;
|
|
7
|
+
if (!namespace || !validationVersion)
|
|
8
|
+
throw new TypeError('Stable delivery namespace and validation version are required');
|
|
9
|
+
const operationId = (taskId) => JSON.stringify([namespace, taskId]);
|
|
10
|
+
const task = defineTask({
|
|
11
|
+
name: 'boring.documents.deliver', version: 2, initial: () => ({ phase: 'wait' }), migrate: migrateDelivery,
|
|
12
|
+
phases: {
|
|
13
|
+
wait: async (running, runtime, context) => {
|
|
14
|
+
await runtime.commit(() => ({ status: 'waiting', on: [running.input.producer], policy: 'allSettled', checkpoint: { phase: 'validate' } }), context);
|
|
15
|
+
},
|
|
16
|
+
validate: async (running, runtime, context) => {
|
|
17
|
+
const [outcome] = await runtime.outcomes([running.input.producer], context);
|
|
18
|
+
if (!outcome)
|
|
19
|
+
throw new Error('Native producer outcome is missing');
|
|
20
|
+
if (outcome.status !== 'completed') {
|
|
21
|
+
await runtime.commit(() => ({ status: 'terminal', outcome: { status: 'completed', result: { kind: 'producer-failed', status: outcome.status } } }), context);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const text = outcome.result;
|
|
25
|
+
const validation = typeof text !== 'string' ? ['Producer did not return text']
|
|
26
|
+
: new TextDecoder('utf-8', { ignoreBOM: true }).decode(new TextEncoder().encode(text)) !== text ? ['Producer text contains invalid Unicode']
|
|
27
|
+
: options.validate(text, running.input.producer, context);
|
|
28
|
+
const errors = Array.isArray(validation) && validation.every(item => typeof item === 'string')
|
|
29
|
+
? [...validation] : ['Validator must return an array of errors'];
|
|
30
|
+
await runtime.commit(() => errors.length
|
|
31
|
+
? { status: 'terminal', outcome: { status: 'completed', result: { kind: 'invalid', errors } } }
|
|
32
|
+
: { status: 'running', checkpoint: { phase: 'publish', text } }, context);
|
|
33
|
+
},
|
|
34
|
+
publish: async (running, runtime, context) => {
|
|
35
|
+
const input = running.input;
|
|
36
|
+
const id = JSON.stringify([input.namespace, runtime.taskId]);
|
|
37
|
+
const unknown = (reason) => ({ kind: 'unknown', operationId: id, reason });
|
|
38
|
+
const settle = async (result) => {
|
|
39
|
+
await runtime.commit(() => ({ status: 'terminal', outcome: { status: 'completed', result } }), context);
|
|
40
|
+
};
|
|
41
|
+
if (input.namespace !== namespace || input.validationVersion !== validationVersion) {
|
|
42
|
+
await settle(unknown('Delivery binding changed; reconcile using the original binding'));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const attempted = await runtime.memo('boring.delivery.attempted.v1', context);
|
|
46
|
+
let access;
|
|
47
|
+
let activeIdentity;
|
|
48
|
+
try {
|
|
49
|
+
access = { ...await options.resolveAccess(structuredClone(input.target), input.producer, context) };
|
|
50
|
+
activeIdentity = identity(access);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (!attempted)
|
|
54
|
+
throw error;
|
|
55
|
+
await settle(unknown('Access could not be resolved after a publication attempt; reconcile using the original identity'));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (activeIdentity.principalId !== input.identity.principalId || activeIdentity.scopeId !== input.identity.scopeId
|
|
59
|
+
|| activeIdentity.initiatorId !== input.identity.initiatorId || activeIdentity.authorizationRef !== input.identity.authorizationRef) {
|
|
60
|
+
await settle(unknown('Delivery identity changed; reconcile using the original authenticated identity'));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const request = deliveryRequest(input, id, running.state.checkpoint.text);
|
|
64
|
+
let result;
|
|
65
|
+
try {
|
|
66
|
+
if (attempted) {
|
|
67
|
+
const found = await options.lookup.lookup(id, { ...access, signal: runtime.signal });
|
|
68
|
+
if (found.kind !== 'not-found')
|
|
69
|
+
result = parsePublicationResult(found);
|
|
70
|
+
else if (options.replay !== 'safe')
|
|
71
|
+
result = unknown('No retained receipt; replay has not been qualified');
|
|
72
|
+
else
|
|
73
|
+
result = parsePublicationResult(await options.publisher.publish(structuredClone(request), { ...access, signal: runtime.signal }));
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
await runtime.memo('boring.delivery.attempted.v1', true, context);
|
|
77
|
+
result = parsePublicationResult(await options.publisher.publish(structuredClone(request), { ...access, signal: runtime.signal }));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
result = unknown('Delivery acknowledgement was lost');
|
|
82
|
+
}
|
|
83
|
+
result = await deliveryEvidence(input, request, result);
|
|
84
|
+
await settle(result);
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
abort: async (running, runtime, context) => {
|
|
88
|
+
const id = JSON.stringify([running.input.namespace, runtime.taskId]);
|
|
89
|
+
// An abort after a publication attempt must not hide a document that may be published.
|
|
90
|
+
let result;
|
|
91
|
+
if (await runtime.memo('boring.delivery.attempted.v1', context)) {
|
|
92
|
+
try {
|
|
93
|
+
const access = { ...await options.resolveAccess(structuredClone(running.input.target), running.input.producer, context) };
|
|
94
|
+
const input = running.input;
|
|
95
|
+
const activeIdentity = identity(access);
|
|
96
|
+
if (input.namespace !== namespace || input.validationVersion !== validationVersion
|
|
97
|
+
|| activeIdentity.principalId !== input.identity.principalId || activeIdentity.scopeId !== input.identity.scopeId
|
|
98
|
+
|| activeIdentity.initiatorId !== input.identity.initiatorId || activeIdentity.authorizationRef !== input.identity.authorizationRef) {
|
|
99
|
+
throw new Error('Delivery binding changed');
|
|
100
|
+
}
|
|
101
|
+
const found = await options.lookup.lookup(id, access);
|
|
102
|
+
result = found.kind !== 'not-found' && running.state.checkpoint.phase === 'publish'
|
|
103
|
+
? await deliveryEvidence(input, deliveryRequest(input, id, running.state.checkpoint.text), parsePublicationResult(found))
|
|
104
|
+
: { kind: 'unknown', operationId: id, reason: 'Aborted after a publication attempt; no matching receipt was retained' };
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
result = { kind: 'unknown', operationId: id, reason: 'Aborted after a publication attempt; the outcome could not be looked up' };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
await runtime.commit(() => result ? { status: 'terminal', outcome: { status: 'completed', result } } : { status: 'terminal', outcome: { status: 'aborted' } }, context);
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
/** Producer creation and its delivery obligation use one native transaction. */
|
|
114
|
+
async function admit(tx, createProducer, target, taskOptions, context, access) {
|
|
115
|
+
const captured = captureDeliveryTarget(target);
|
|
116
|
+
const admittedIdentity = access === undefined ? undefined : identity({ ...access });
|
|
117
|
+
const producer = await createProducer(tx);
|
|
118
|
+
const boundIdentity = admittedIdentity ?? identity({ ...await options.resolveAccess(structuredClone(captured.target), producer, context) });
|
|
119
|
+
const delivery = await tx.createTask(task, { producer, ...captured, identity: boundIdentity, namespace, validationVersion }, taskOptions);
|
|
120
|
+
return { producer, delivery, operationId: operationId(delivery) };
|
|
121
|
+
}
|
|
122
|
+
return { task, extension: defineExtension({ name: 'boring.documents.delivery', tasks: [task] }), admit };
|
|
123
|
+
}
|
|
124
|
+
function deliveryRequest(input, id, text) {
|
|
125
|
+
const bytes = new TextEncoder().encode(text);
|
|
126
|
+
const request = { operationId: id, atomicity: 'all-or-nothing', preconditions: input.preconditions, changes: [input.target.kind === 'absent'
|
|
127
|
+
? { kind: 'create', target: input.target.target, expected: { kind: 'absent' }, bytes, mediaType: 'text/markdown' }
|
|
128
|
+
: { kind: 'replace', target: input.target.target, bytes, mediaType: 'text/markdown' }] };
|
|
129
|
+
return request;
|
|
130
|
+
}
|
|
131
|
+
async function deliveryEvidence(input, request, result) {
|
|
132
|
+
const id = request.operationId;
|
|
133
|
+
const digest = await publicationDigest(request);
|
|
134
|
+
const unknown = (reason) => ({ kind: 'unknown', operationId: id, reason });
|
|
135
|
+
if (result.kind === 'committed') {
|
|
136
|
+
const receipt = result.receipt;
|
|
137
|
+
const change = receipt.changes[0];
|
|
138
|
+
const expected = request.changes[0];
|
|
139
|
+
const after = change?.after;
|
|
140
|
+
const targetMatches = after && after.resource.providerId === input.target.target.resource.providerId
|
|
141
|
+
&& after.resource.path === input.target.target.resource.path && JSON.stringify(after.view) === JSON.stringify(input.target.target.view);
|
|
142
|
+
const beforeMatches = input.target.kind === 'absent' ? change?.before === null
|
|
143
|
+
: change?.before?.revision === input.target.target.revision && change.before.resource.providerId === input.target.target.resource.providerId
|
|
144
|
+
&& change.before.resource.path === input.target.target.resource.path && JSON.stringify(change.before.view) === JSON.stringify(input.target.target.view);
|
|
145
|
+
if (receipt.operationId !== id || receipt.argumentDigest !== digest || receipt.principalId !== input.identity.principalId
|
|
146
|
+
|| receipt.scopeId !== input.identity.scopeId || receipt.initiatorId !== input.identity.initiatorId || receipt.changes.length !== 1
|
|
147
|
+
|| change?.kind !== expected.kind || !targetMatches || !beforeMatches)
|
|
148
|
+
result = unknown('Receipt does not match the admitted delivery');
|
|
149
|
+
}
|
|
150
|
+
else if (result.kind === 'partial' || (result.kind === 'unknown' && result.operationId !== id)) {
|
|
151
|
+
result = unknown('Provider returned incompatible delivery evidence');
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Conversation, ConversationDocFamilyToken, JsonObject, TaskId, ToolExecutionApi, Tx } from '@earendil-works/pi-durable';
|
|
2
|
+
import type { Context } from '@earendil-works/chord';
|
|
3
|
+
export declare class UnknownCall extends Error {
|
|
4
|
+
}
|
|
5
|
+
export declare class AmbiguousCall extends Error {
|
|
6
|
+
}
|
|
7
|
+
/** Bind an answer to the exact assistant entry that made the call. */
|
|
8
|
+
export declare const callBindingId: (assistantEntryId: number, callId: string) => string;
|
|
9
|
+
export interface CallBinding {
|
|
10
|
+
readonly key: string;
|
|
11
|
+
readonly callId: string;
|
|
12
|
+
readonly taskId: TaskId;
|
|
13
|
+
}
|
|
14
|
+
/** The native tool task an answer ID names: `callBindingId(entry, call)`, or a raw call ID used by exactly one task. */
|
|
15
|
+
export declare function callBinding(tx: Tx, conversation: Conversation, id: string): Promise<CallBinding>;
|
|
16
|
+
/** The binding key of the calling tool task (its assistant entry and call id). */
|
|
17
|
+
export declare function ownBindingKey(tx: Tx, api: Pick<ToolExecutionApi, 'taskId' | 'callId'>): Promise<string>;
|
|
18
|
+
/**
|
|
19
|
+
* Waits until `settled(value)` holds for the document `key` of `family`, without holding a model request. When the call is aborted,
|
|
20
|
+
* `cancel` runs in a background commit (`label` names the waiting thing in errors) (the answer path also refuses a call whose task has ended) and the abort is rethrown.
|
|
21
|
+
*/
|
|
22
|
+
export declare function waitForDocument<T extends JsonObject>(api: ToolExecutionApi, family: ConversationDocFamilyToken<T, null>, key: string, context: Context, settled: (value: Readonly<T> | null | undefined) => boolean, cancel: (tx: Tx) => Promise<void>, label: string): Promise<Readonly<T> | null | undefined>;
|
|
23
|
+
//# sourceMappingURL=durable-wait.d.ts.map
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The durable wait shared by `ask_user` (./ask-user.ts) and browser tasks (./browser-task.ts): a native tool call parks on a
|
|
2
|
+
// conversation document keyed by its assistant entry and call id, and an authenticated answer later settles that document. No model
|
|
3
|
+
// request is held while waiting; a restart re-attaches the replay-safe call to the same document; stopping the conversation
|
|
4
|
+
// cancels it. Internal module: not a package export.
|
|
5
|
+
import { ToolTask } from '@earendil-works/pi-durable';
|
|
6
|
+
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
|
|
7
|
+
export class UnknownCall extends Error {
|
|
8
|
+
}
|
|
9
|
+
export class AmbiguousCall extends Error {
|
|
10
|
+
}
|
|
11
|
+
/** Bind an answer to the exact assistant entry that made the call. */
|
|
12
|
+
export const callBindingId = (assistantEntryId, callId) => JSON.stringify([assistantEntryId, callId]);
|
|
13
|
+
/** The native tool task an answer ID names: `callBindingId(entry, call)`, or a raw call ID used by exactly one task. */
|
|
14
|
+
export async function callBinding(tx, conversation, id) {
|
|
15
|
+
let cursor;
|
|
16
|
+
let match;
|
|
17
|
+
do {
|
|
18
|
+
const page = await tx.scanTasks({ conversationId: conversation.id, kind: ToolTask.definition.name }, 100, cursor);
|
|
19
|
+
for (const task of page.items) {
|
|
20
|
+
const input = task.input;
|
|
21
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || typeof input['callId'] !== 'string' || typeof input['assistant'] !== 'number')
|
|
22
|
+
continue;
|
|
23
|
+
const key = callBindingId(input['assistant'], input['callId']);
|
|
24
|
+
if (key !== id && input['callId'] !== id)
|
|
25
|
+
continue;
|
|
26
|
+
if (match !== undefined)
|
|
27
|
+
throw new AmbiguousCall();
|
|
28
|
+
match = { key, callId: input['callId'], taskId: task.id };
|
|
29
|
+
}
|
|
30
|
+
cursor = page.next;
|
|
31
|
+
} while (cursor !== undefined);
|
|
32
|
+
if (match === undefined)
|
|
33
|
+
throw new UnknownCall();
|
|
34
|
+
return match;
|
|
35
|
+
}
|
|
36
|
+
/** The binding key of the calling tool task (its assistant entry and call id). */
|
|
37
|
+
export async function ownBindingKey(tx, api) {
|
|
38
|
+
const task = await tx.task(api.taskId);
|
|
39
|
+
const input = task?.input;
|
|
40
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || typeof input['assistant'] !== 'number')
|
|
41
|
+
throw new Error('Tool task has no assistant entry');
|
|
42
|
+
return callBindingId(input['assistant'], api.callId);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Waits until `settled(value)` holds for the document `key` of `family`, without holding a model request. When the call is aborted,
|
|
46
|
+
* `cancel` runs in a background commit (`label` names the waiting thing in errors) (the answer path also refuses a call whose task has ended) and the abort is rethrown.
|
|
47
|
+
*/
|
|
48
|
+
export async function waitForDocument(api, family, key, context, settled, cancel, label) {
|
|
49
|
+
const watch = await api.watchDoc(family, api.conversationId, key, context);
|
|
50
|
+
if (!watch)
|
|
51
|
+
throw new Error(`${label} record is missing`);
|
|
52
|
+
const signal = context.abortSignal;
|
|
53
|
+
let final = watch.value;
|
|
54
|
+
try {
|
|
55
|
+
if (!settled(final)) {
|
|
56
|
+
final = await new Promise((resolve, reject) => {
|
|
57
|
+
if (signal?.aborted)
|
|
58
|
+
return reject(signal.reason ?? new Error(`${label} cancelled`));
|
|
59
|
+
signal?.addEventListener('abort', () => reject(signal.reason ?? new Error(`${label} cancelled`)), { once: true });
|
|
60
|
+
void watch.closed.then(() => reject(new Error(`${label} observation closed`)));
|
|
61
|
+
watch.start(async (value) => { if (settled(value))
|
|
62
|
+
resolve(value); });
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (signal?.aborted) {
|
|
68
|
+
try {
|
|
69
|
+
await api.commit(cancel, BACKGROUND_CONTEXT);
|
|
70
|
+
}
|
|
71
|
+
catch { /* the answer path also refuses a call whose task has ended */ }
|
|
72
|
+
}
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
await watch.stop();
|
|
77
|
+
}
|
|
78
|
+
return final;
|
|
79
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ToolExecutionApi, ToolRegistration } from '@earendil-works/pi-durable';
|
|
2
|
+
import type { Context } from '@earendil-works/chord';
|
|
3
|
+
import type { ResourceAccess } from '@hachej/boring-files';
|
|
4
|
+
import type { WorkspaceBinding, WorkspaceResolver } from './workspaces.js';
|
|
5
|
+
/** The converter: bytes to text, or `{ error }`. A failure is not cached. */
|
|
6
|
+
export type FileConverter = (file: {
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly mediaType: string;
|
|
9
|
+
readonly bytes: Uint8Array;
|
|
10
|
+
}) => Promise<{
|
|
11
|
+
readonly text: string;
|
|
12
|
+
} | {
|
|
13
|
+
readonly error: string;
|
|
14
|
+
}>;
|
|
15
|
+
/** Converted text per `<path>@<revision>`. A `Map<string, string>` satisfies it. */
|
|
16
|
+
export interface ConvertedTextCache {
|
|
17
|
+
get(key: string): string | undefined | Promise<string | undefined>;
|
|
18
|
+
set(key: string, text: string): unknown;
|
|
19
|
+
}
|
|
20
|
+
export interface ConvertingReadOptions {
|
|
21
|
+
/** The workspace of each call, resolved like the file guard's (`@hachej/boring-agent/workspaces`); absent: the env's workspace. */
|
|
22
|
+
readonly workspace?: WorkspaceResolver | WorkspaceBinding | undefined;
|
|
23
|
+
/** The agent's principal for the provider. Default: the binding's `access`. */
|
|
24
|
+
readonly resolveAccess?: (api: ToolExecutionApi, context: Context) => ResourceAccess | Promise<ResourceAccess>;
|
|
25
|
+
/** Converts a file to text. Without it, images use Pi's native read and other binary files answer `unsupported`. */
|
|
26
|
+
readonly convert?: FileConverter;
|
|
27
|
+
/** Stores converted text per revision. Without it, each read converts again. */
|
|
28
|
+
readonly cache?: ConvertedTextCache;
|
|
29
|
+
/** Largest file converted, in bytes. Default 20 MiB. */
|
|
30
|
+
readonly maxBytes?: number;
|
|
31
|
+
/** The extension name (default `boring.files.read`). */
|
|
32
|
+
readonly name?: string;
|
|
33
|
+
}
|
|
34
|
+
/** Pi's `read`, extended to convert PDF, office and image files through the host's converter. An extension to select (after the extension that registers `read`, before the file guard). */
|
|
35
|
+
export declare function createConvertingRead(options?: ConvertingReadOptions): import("@earendil-works/pi-durable").Extension<ToolRegistration>;
|
|
36
|
+
/**
|
|
37
|
+
* Adapts a Workers AI binding (`env.AI`, whose `toMarkdown` converts a file to Markdown) to a `FileConverter`. Typed loosely, so the
|
|
38
|
+
* package needs no Cloudflare types.
|
|
39
|
+
*/
|
|
40
|
+
export declare function workersAiMarkdown(ai: {
|
|
41
|
+
toMarkdown(input: {
|
|
42
|
+
name: string;
|
|
43
|
+
blob: Blob;
|
|
44
|
+
}): Promise<unknown>;
|
|
45
|
+
}): FileConverter;
|
|
46
|
+
//# sourceMappingURL=file-convert.d.ts.map
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { defineExtension, wrapTool } from '@earendil-works/pi-durable';
|
|
2
|
+
import { createReadTool } from '@earendil-works/pi-durable/tools';
|
|
3
|
+
import { workspaceRelative } from './file-guard.js';
|
|
4
|
+
import { SNIFF_BYTES, fileKind, mediaTypeFor } from './file-types.js';
|
|
5
|
+
import { asWorkspaceResolver, workspaceFor } from './workspaces.js';
|
|
6
|
+
/*
|
|
7
|
+
* One `read`. This wraps Pi's native `read` (public `wrapTool`, the supported way to override a native tool) and adds no tool:
|
|
8
|
+
*
|
|
9
|
+
* - Text goes to Pi's native read untouched.
|
|
10
|
+
* - A PDF, an office document or an image goes to the host's `convert` hook (`workersAiMarkdown` adapts Workers AI). The converted
|
|
11
|
+
* text is cached per path and saved revision and paged exactly as Pi pages a text file: `offset` is the 1-based line to start at,
|
|
12
|
+
* `limit` a count of lines, a page ends at 2000 lines or 50 KB, and the info diagnostic names the `offset` to continue from.
|
|
13
|
+
* - An archive or other binary file (a NUL byte) answers `unsupported` and is never converted. Without `convert`, an image goes to
|
|
14
|
+
* Pi's native behaviour and a PDF or office file answers `unsupported`.
|
|
15
|
+
*
|
|
16
|
+
* What is a text file and what is not comes from the single table in file-types.ts: the extension and the leading bytes (Pi's read
|
|
17
|
+
* returns garbled text for a PDF, so the magic number decides, not only the name). The converted file is read through the workspace
|
|
18
|
+
* provider, which checks access; a path this wrapper cannot place inside the workspace is left to the native read, and the file guard
|
|
19
|
+
* (`createFileGuard`) refuses it. Install this extension before the guard, so the guard stays outermost. Nothing is written.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_LINES = 2000; // Pi's read page: DEFAULT_MAX_LINES
|
|
22
|
+
const MAX_BYTES = 50 * 1024; // Pi's read page: DEFAULT_MAX_BYTES
|
|
23
|
+
const DEFAULT_MAX_FILE = 20 * 1024 * 1024;
|
|
24
|
+
const failure = (text, code) => ({ content: [{ type: 'text', text }], isError: true, diagnostics: [{ severity: 'error', code, message: text }] });
|
|
25
|
+
const utf8Length = (text) => new TextEncoder().encode(text).length;
|
|
26
|
+
/**
|
|
27
|
+
* The converted text as pageable lines. A line longer than MAX_BYTES is split, on character boundaries, into segments of at most
|
|
28
|
+
* MAX_BYTES; each segment counts as one line for `offset` and `limit`, so every byte stays reachable with an honest offset (Pi's own
|
|
29
|
+
* read can only point at `sed` for the rest of such a line, and converted text has no file for `sed`). `split` maps the 0-based
|
|
30
|
+
* index of each segment to the 1-based number of the original line.
|
|
31
|
+
*/
|
|
32
|
+
function linesOf(text) {
|
|
33
|
+
const lines = [], split = new Map();
|
|
34
|
+
const encoder = new TextEncoder(), decoder = new TextDecoder('utf-8', { ignoreBOM: true }); // keep a U+FEFF that starts a segment
|
|
35
|
+
text.split('\n').forEach((line, index) => {
|
|
36
|
+
if (utf8Length(line) <= MAX_BYTES) {
|
|
37
|
+
lines.push(line);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const bytes = encoder.encode(line);
|
|
41
|
+
for (let start = 0; start < bytes.length;) {
|
|
42
|
+
let end = Math.min(bytes.length, start + MAX_BYTES);
|
|
43
|
+
while (end < bytes.length && end > start && (bytes[end] & 0xc0) === 0x80)
|
|
44
|
+
end--; // never cut inside a character
|
|
45
|
+
split.set(lines.length, index + 1);
|
|
46
|
+
lines.push(decoder.decode(bytes.subarray(start, end)));
|
|
47
|
+
start = end;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
return { lines, split };
|
|
51
|
+
}
|
|
52
|
+
/** The lines from the 0-based `start`, cut like Pi's `truncateHead`: whole lines up to MAX_LINES / MAX_BYTES (no line exceeds MAX_BYTES). */
|
|
53
|
+
function page(lines, start, count) {
|
|
54
|
+
const out = [];
|
|
55
|
+
let bytes = 0, byBytes = false;
|
|
56
|
+
for (let index = start; index < start + count && out.length < MAX_LINES; index++) {
|
|
57
|
+
const line = lines[index];
|
|
58
|
+
const size = utf8Length(line) + (out.length > 0 ? 1 : 0);
|
|
59
|
+
if (bytes + size > MAX_BYTES) {
|
|
60
|
+
byBytes = true;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
bytes += size;
|
|
64
|
+
out.push(line);
|
|
65
|
+
}
|
|
66
|
+
return { text: out.join('\n'), shown: out.length, byBytes };
|
|
67
|
+
}
|
|
68
|
+
/** Pi's read result for already converted text. */
|
|
69
|
+
function convertedPage(text, offset, limit) {
|
|
70
|
+
const { lines, split } = linesOf(text);
|
|
71
|
+
const total = lines.length;
|
|
72
|
+
const start = offset ? Math.max(0, Math.trunc(offset) - 1) : 0;
|
|
73
|
+
if (start >= total)
|
|
74
|
+
throw new Error(`Offset ${offset} is beyond end of file (${total} lines total)`);
|
|
75
|
+
const wanted = limit === undefined ? total - start : Math.max(1, Math.min(Math.trunc(limit), total - start));
|
|
76
|
+
const { text: shownText, shown, byBytes } = page(lines, start, wanted);
|
|
77
|
+
const end = start + shown;
|
|
78
|
+
const diagnostics = [];
|
|
79
|
+
const cut = [...new Set([...split].filter(([index]) => index >= start && index < end).map(([, original]) => original))];
|
|
80
|
+
if (cut.length)
|
|
81
|
+
diagnostics.push({ severity: 'warn', code: 'truncated', message: `The converted text has ${cut.length === 1 ? 'a line' : 'lines'} longer than ${MAX_BYTES / 1024}KB (original line ${cut.join(', ')}); each is split into segments of at most ${MAX_BYTES / 1024}KB and every segment counts as one line for offset and limit.` });
|
|
82
|
+
if (shown < wanted)
|
|
83
|
+
diagnostics.push({ severity: 'info', code: 'truncated', message: `Showing lines ${start + 1}-${end} of ${total}${byBytes ? ` (${MAX_BYTES / 1024}KB limit)` : ''}. Use offset=${end + 1} to continue.` });
|
|
84
|
+
else if (end < total)
|
|
85
|
+
diagnostics.push({ severity: 'info', message: `${total - end} more lines in file. Use offset=${end + 1} to continue.` });
|
|
86
|
+
return { content: shownText === '' ? [] : [{ type: 'text', text: shownText }], diagnostics };
|
|
87
|
+
}
|
|
88
|
+
/** Pi's `read`, extended to convert PDF, office and image files through the host's converter. An extension to select (after the extension that registers `read`, before the file guard). */
|
|
89
|
+
export function createConvertingRead(options = {}) {
|
|
90
|
+
const resolver = asWorkspaceResolver(options.workspace);
|
|
91
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_FILE;
|
|
92
|
+
function converting(tool) {
|
|
93
|
+
const native = tool.execute;
|
|
94
|
+
const execute = async (args, api, context) => {
|
|
95
|
+
const resolved = await workspaceFor(resolver, api, context);
|
|
96
|
+
if ('refused' in resolved)
|
|
97
|
+
return native(args, api, context);
|
|
98
|
+
const root = resolved.binding.root.replace(/\/+$/, '');
|
|
99
|
+
const located = await workspaceRelative(api, root, args.path, context);
|
|
100
|
+
if (located === undefined)
|
|
101
|
+
return native(args, api, context);
|
|
102
|
+
// The access check comes first, and the type is sniffed on the bytes the provider returned: nothing is read around it.
|
|
103
|
+
const { files } = resolved.binding;
|
|
104
|
+
const access = options.resolveAccess ? await options.resolveAccess(api, context) : resolved.binding.access;
|
|
105
|
+
if (access === undefined)
|
|
106
|
+
return failure('Refused: the host gave no access for this workspace.', 'denied');
|
|
107
|
+
const read = await files.read({ target: { resource: { providerId: files.providerId, path: located.path }, view: { kind: 'published' } }, revision: { kind: 'latest' } }, { ...access });
|
|
108
|
+
// Never delegate a missing path: Pi's read would try other spellings the provider and the containment check never saw.
|
|
109
|
+
if (read.kind === 'missing')
|
|
110
|
+
return failure(`Refused: ${args.path} does not exist in the workspace.`, 'denied');
|
|
111
|
+
if (read.kind !== 'available')
|
|
112
|
+
return failure(`Refused: ${args.path} cannot be read here (${read.reason}).`, 'denied');
|
|
113
|
+
const { snapshot } = read;
|
|
114
|
+
const head = snapshot.bytes.subarray(0, SNIFF_BYTES);
|
|
115
|
+
let kind = fileKind(located.path, head);
|
|
116
|
+
// The leading bytes are only a sniff: a NUL anywhere in the loaded snapshot makes a text file binary.
|
|
117
|
+
if (kind === 'text' && snapshot.bytes.includes(0))
|
|
118
|
+
kind = 'binary';
|
|
119
|
+
if (kind === 'text')
|
|
120
|
+
return native(args, api, context);
|
|
121
|
+
if (kind === 'archive' || kind === 'binary')
|
|
122
|
+
return failure(`${args.path} is ${kind === 'archive' ? 'an archive' : 'a binary file'}; it cannot be read as text here (unsupported).`, 'unsupported');
|
|
123
|
+
if (!options.convert) {
|
|
124
|
+
if (kind === 'image')
|
|
125
|
+
return native(args, api, context);
|
|
126
|
+
return failure(`${args.path} is a ${kind} file; this agent has no converter, so it cannot be read as text here (unsupported).`, 'unsupported');
|
|
127
|
+
}
|
|
128
|
+
if (snapshot.bytes.byteLength > maxBytes)
|
|
129
|
+
return failure(`${args.path} is larger than ${maxBytes} bytes and is not converted.`, 'too_large');
|
|
130
|
+
const key = `${located.path}@${snapshot.ref.revision}`;
|
|
131
|
+
let text = await options.cache?.get(key);
|
|
132
|
+
if (text === undefined) {
|
|
133
|
+
const result = await options.convert({ name: located.path.slice(located.path.lastIndexOf('/') + 1), mediaType: mediaTypeFor(located.path, kind, snapshot.mediaType, head), bytes: snapshot.bytes });
|
|
134
|
+
if ('error' in result)
|
|
135
|
+
return failure(`${args.path} could not be converted to text: ${result.error}`, 'unavailable');
|
|
136
|
+
text = result.text;
|
|
137
|
+
await options.cache?.set(key, text);
|
|
138
|
+
}
|
|
139
|
+
return convertedPage(text, args.offset, args.limit);
|
|
140
|
+
};
|
|
141
|
+
return { ...tool, execute };
|
|
142
|
+
}
|
|
143
|
+
return defineExtension({
|
|
144
|
+
name: options.name ?? 'boring.files.read',
|
|
145
|
+
wraps: [wrapTool(createReadTool(), converting)],
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Adapts a Workers AI binding (`env.AI`, whose `toMarkdown` converts a file to Markdown) to a `FileConverter`. Typed loosely, so the
|
|
150
|
+
* package needs no Cloudflare types.
|
|
151
|
+
*/
|
|
152
|
+
export function workersAiMarkdown(ai) {
|
|
153
|
+
return async ({ name, mediaType, bytes }) => {
|
|
154
|
+
const result = await ai.toMarkdown({ name, blob: new Blob([bytes], { type: mediaType }) });
|
|
155
|
+
if (result?.format === 'markdown' && typeof result.data === 'string')
|
|
156
|
+
return { text: result.data };
|
|
157
|
+
return { error: typeof result?.error === 'string' ? result.error : 'The file could not be converted to text.' };
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ToolExecutionApi, ToolRegistration } from '@earendil-works/pi-durable';
|
|
2
|
+
import type { ExecutionEnv } from '@earendil-works/pi-durable/env';
|
|
3
|
+
import type { Context } from '@earendil-works/chord';
|
|
4
|
+
import type { ResourceAccess } from '@hachej/boring-files';
|
|
5
|
+
import type { WorkspaceBinding, WorkspaceResolver } from './workspaces.js';
|
|
6
|
+
type Baselines = {
|
|
7
|
+
revisions: Record<string, string>;
|
|
8
|
+
};
|
|
9
|
+
/** Conversation document: workspace and path (relative to the workspace root; see `baselineKey`) to the revision this conversation last read or wrote. */
|
|
10
|
+
export declare const lastReadRevisions: import("@earendil-works/pi-durable").ConversationDocToken<Baselines>;
|
|
11
|
+
/** The baseline key of `path` in `workspace` (its `id`, or its provider id): `<workspace>:<path>`. */
|
|
12
|
+
export declare const baselineKey: (workspace: Pick<WorkspaceBinding, "id" | "files">, path: string) => string;
|
|
13
|
+
/** 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. */
|
|
14
|
+
export declare const lastReadRevision: (api: ToolExecutionApi, key: string, context: Context) => Promise<string | undefined>;
|
|
15
|
+
/** Record `revision` as the conversation's baseline under `key` (see `baselineKey`), durably. */
|
|
16
|
+
export declare const recordRevision: (api: ToolExecutionApi, key: string, revision: string, context: Context) => Promise<void>;
|
|
17
|
+
export interface FileGuardOptions {
|
|
18
|
+
/**
|
|
19
|
+
* The workspace of each call: a resolver shaped like `HarnessOptions.env` (`@hachej/boring-agent/workspaces`), or one binding
|
|
20
|
+
* `{ files, root }` for a host with a single workspace; absent: the workspace attached to the call's env (`withWorkspace`). `files` is the provider the viewers use (its queue serialises the calls,
|
|
21
|
+
* its reads name the revisions); `root` is the workspace root as the call's ExecutionEnv names it. Paths outside it are refused.
|
|
22
|
+
*/
|
|
23
|
+
readonly workspace?: WorkspaceResolver | WorkspaceBinding | undefined;
|
|
24
|
+
/** The agent's principal for the provider. Default: the binding's `access`. */
|
|
25
|
+
readonly resolveAccess?: (api: ToolExecutionApi, context: Context) => ResourceAccess | Promise<ResourceAccess>;
|
|
26
|
+
/**
|
|
27
|
+
* The extension name (default `boring.files.guard`). A registry keeps one extension per name, so two agents with different
|
|
28
|
+
* workspace resolvers in one registry need two names; otherwise the last installed guard wraps every agent's tools.
|
|
29
|
+
*/
|
|
30
|
+
readonly name?: string;
|
|
31
|
+
}
|
|
32
|
+
/** `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. */
|
|
33
|
+
export declare function workspaceRelative(api: ToolExecutionApi, root: string, path: string, context: Context): Promise<{
|
|
34
|
+
readonly path: string;
|
|
35
|
+
readonly absolute: string;
|
|
36
|
+
} | undefined>;
|
|
37
|
+
export declare const ONE_PATH: Set<string>;
|
|
38
|
+
/**
|
|
39
|
+
* `env` as the native `read`, `write` and `edit` must see it: every operation that opens, changes or lists a path first re-checks the
|
|
40
|
+
* path it is ABOUT to touch (links resolved, like `contained`) and refuses outside `root` with a typed `permission_denied`. The check
|
|
41
|
+
* is on the path Pi actually opens, so a fallback spelling Pi picks after the provider's check (NFD, curly apostrophe) is judged as
|
|
42
|
+
* itself. `exists` answers false for a path outside, so Pi's fallback loop moves on instead of revealing it. A Pi extension point
|
|
43
|
+
* (`ExecutionEnv` decorator), public API only. Residual: a symlink swapped between this check and the OS `open` is not closable from
|
|
44
|
+
* here; that boundary is the host's isolation (an isolated worker env), not this guard.
|
|
45
|
+
*/
|
|
46
|
+
export declare function confinedEnv(env: ExecutionEnv, root: string): ExecutionEnv;
|
|
47
|
+
/** The guard as a native extension. Select it after the extension(s) that register `read`, `write` and `edit`. */
|
|
48
|
+
export declare function createFileGuard(options?: FileGuardOptions): import("@earendil-works/pi-durable").Extension<ToolRegistration>;
|
|
49
|
+
export {};
|
|
50
|
+
//# sourceMappingURL=file-guard.d.ts.map
|