@frockbot/plugin-image 0.0.0 → 0.1.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.
package/src/bytes.ts ADDED
@@ -0,0 +1,125 @@
1
+ // What the bytes an image model returned actually are.
2
+ //
3
+ // The tool records `mimeType`, `width` and `height` in its durable
4
+ // `tool/result`, and those must describe the stored object rather than the
5
+ // request: Workers AI's `flux-1-schnell` accepts no size at all, so echoing
6
+ // the requested width back would put a number in the durable log that no file
7
+ // on disk agrees with. Reading the container header is the only honest source.
8
+ //
9
+ // Pure, total, and the Package's whole image-format surface. A byte string
10
+ // this cannot identify is not written: an unidentifiable blob under a durable
11
+ // root is data nothing can render and nothing can attribute a format to.
12
+
13
+ /** A decoded image container. */
14
+ export interface ImageDimensionsV1 {
15
+ mimeType: string;
16
+ width: number;
17
+ height: number;
18
+ }
19
+
20
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] as const;
21
+
22
+ function readUint32BE(bytes: Uint8Array, offset: number): number {
23
+ return (
24
+ ((bytes[offset] ?? 0) << 24) +
25
+ ((bytes[offset + 1] ?? 0) << 16) +
26
+ ((bytes[offset + 2] ?? 0) << 8) +
27
+ (bytes[offset + 3] ?? 0)
28
+ );
29
+ }
30
+
31
+ function readUint16BE(bytes: Uint8Array, offset: number): number {
32
+ return ((bytes[offset] ?? 0) << 8) + (bytes[offset + 1] ?? 0);
33
+ }
34
+
35
+ function decodePng(bytes: Uint8Array): ImageDimensionsV1 | undefined {
36
+ if (bytes.byteLength < 24) return undefined;
37
+ if (PNG_SIGNATURE.some((byte, index) => bytes[index] !== byte)) {
38
+ return undefined;
39
+ }
40
+ // The first chunk of a PNG is always IHDR, and its payload starts at 16.
41
+ if (
42
+ bytes[12] !== 0x49 ||
43
+ bytes[13] !== 0x48 ||
44
+ bytes[14] !== 0x44 ||
45
+ bytes[15] !== 0x52
46
+ ) {
47
+ return undefined;
48
+ }
49
+ const width = readUint32BE(bytes, 16);
50
+ const height = readUint32BE(bytes, 20);
51
+ if (width < 1 || height < 1) return undefined;
52
+ return { mimeType: "image/png", width, height };
53
+ }
54
+
55
+ /** SOF markers carrying a frame header. SOF4/8/12 are not frame starts. */
56
+ function isStartOfFrame(marker: number): boolean {
57
+ return (
58
+ marker >= 0xc0 &&
59
+ marker <= 0xcf &&
60
+ marker !== 0xc4 &&
61
+ marker !== 0xc8 &&
62
+ marker !== 0xcc
63
+ );
64
+ }
65
+
66
+ function decodeJpeg(bytes: Uint8Array): ImageDimensionsV1 | undefined {
67
+ if (bytes.byteLength < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) {
68
+ return undefined;
69
+ }
70
+ let offset = 2;
71
+ while (offset + 3 < bytes.byteLength) {
72
+ if (bytes[offset] !== 0xff) {
73
+ offset += 1;
74
+ continue;
75
+ }
76
+ const marker = bytes[offset + 1] ?? 0;
77
+ // Padding fill bytes, and the standalone markers that carry no length.
78
+ if (marker === 0xff) {
79
+ offset += 1;
80
+ continue;
81
+ }
82
+ if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) {
83
+ offset += 2;
84
+ continue;
85
+ }
86
+ const length = readUint16BE(bytes, offset + 2);
87
+ if (length < 2) return undefined;
88
+ if (isStartOfFrame(marker)) {
89
+ if (offset + 9 >= bytes.byteLength) return undefined;
90
+ const height = readUint16BE(bytes, offset + 5);
91
+ const width = readUint16BE(bytes, offset + 7);
92
+ if (width < 1 || height < 1) return undefined;
93
+ return { mimeType: "image/jpeg", width, height };
94
+ }
95
+ offset += 2 + length;
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ /**
101
+ * The container these bytes are, or `undefined` when they are neither a PNG
102
+ * nor a JPEG. Those two are what every Workers AI text-to-image model returns
103
+ * today; anything else is refused rather than guessed at.
104
+ */
105
+ export function decodeImageDimensionsV1(
106
+ bytes: Uint8Array,
107
+ ): ImageDimensionsV1 | undefined {
108
+ return decodePng(bytes) ?? decodeJpeg(bytes);
109
+ }
110
+
111
+ /** The sha-256 content address of some bytes, hex encoded. */
112
+ export async function sha256HexV1(bytes: Uint8Array): Promise<string> {
113
+ const digest = await crypto.subtle.digest(
114
+ "SHA-256",
115
+ bytes.slice().buffer as ArrayBuffer,
116
+ );
117
+ return [...new Uint8Array(digest)]
118
+ .map((byte) => byte.toString(16).padStart(2, "0"))
119
+ .join("");
120
+ }
121
+
122
+ /** The sha-256 of a string, for the prompt hash the intent event records. */
123
+ export function sha256HexOfTextV1(text: string): Promise<string> {
124
+ return sha256HexV1(new TextEncoder().encode(text));
125
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./agent.js";
2
+ export * from "./bytes.js";
3
+ export { default as imageManifest } from "./manifest.js";
4
+ export * from "./model.js";
5
+ export * from "./root.js";
@@ -0,0 +1,3 @@
1
+ import manifest from "../frockbot.json" with { type: "json" };
2
+
3
+ export default manifest;
package/src/model.ts ADDED
@@ -0,0 +1,63 @@
1
+ // The narrow image-model interface this Package consumes, and the models it
2
+ // knows how to ask for.
3
+ //
4
+ // "The kernel declares the narrow interfaces it consumes ... and owns no
5
+ // implementation of them." The same discipline applies one level down: this
6
+ // Package declares what it needs from an image model and implements none of
7
+ // it. The host — `apps/cloudflare` through `plugin-shell` — adapts Workers AI's
8
+ // `AI` binding to this shape, so no Cloudflare type and no provider response
9
+ // envelope reaches the tool.
10
+ //
11
+ // `run` answers raw image bytes, never base64 and never a provider envelope.
12
+ // Workers AI's text-to-image models disagree about which they return
13
+ // (`flux-1-schnell` answers `{ image: "<base64>" }`, the Stable Diffusion
14
+ // models answer a binary stream), and normalizing that is exactly the
15
+ // adapter's job.
16
+
17
+ /** The image model seam. One method, one direction, no provider vocabulary. */
18
+ export interface ImageModelV1 {
19
+ run(model: string, input: ImageModelInputV1): Promise<ArrayBuffer>;
20
+ }
21
+
22
+ /**
23
+ * What the tool asks for. `width` and `height` are a *request*: not every
24
+ * Workers AI text-to-image model accepts them (`flux-1-schnell` does not), so
25
+ * the tool reports the dimensions it decodes from the returned bytes rather
26
+ * than echoing these back as if they were honoured.
27
+ */
28
+ export interface ImageModelInputV1 {
29
+ prompt: string;
30
+ width: number;
31
+ height: number;
32
+ }
33
+
34
+ /**
35
+ * The default model. Cloudflare's own catalog calls FLUX.1 [schnell] the
36
+ * fastest text-to-image model on Workers AI, and it is the one the parity
37
+ * slice was specified against (`docs/plans/` slice O, §2). Overridable through
38
+ * the `image.model` Package setting.
39
+ */
40
+ export const DEFAULT_IMAGE_MODEL_V1 = "@cf/black-forest-labs/flux-1-schnell";
41
+
42
+ /**
43
+ * The models the `image.model` setting accepts, mirroring the manifest's
44
+ * `enum`. A model outside this list is refused rather than passed through: the
45
+ * host binding would happily run an image-to-image or a text model and answer
46
+ * something this tool cannot store.
47
+ */
48
+ export const IMAGE_MODELS_V1: readonly string[] = [
49
+ "@cf/black-forest-labs/flux-1-schnell",
50
+ "@cf/black-forest-labs/flux-2-klein-4b",
51
+ "@cf/stabilityai/stable-diffusion-xl-base-1.0",
52
+ "@cf/bytedance/stable-diffusion-xl-lightning",
53
+ ];
54
+
55
+ /** The setting's value, or the default; an unknown model is refused. */
56
+ export function resolveImageModelV1(configured?: string): string {
57
+ const named = configured?.trim();
58
+ if (!named) return DEFAULT_IMAGE_MODEL_V1;
59
+ if (!IMAGE_MODELS_V1.includes(named)) {
60
+ throw new Error(`image model "${named}" is not one this Package offers`);
61
+ }
62
+ return named;
63
+ }
package/src/root.ts ADDED
@@ -0,0 +1,84 @@
1
+ // Where a generated image lives, and why there.
2
+ //
3
+ // The durable Workspace, under a root this Package's manifest declares:
4
+ // `{kind: "package-declared", userId, packageId: "image", rootId: "generated"}`
5
+ // (`kernel-contracts/src/workspace.ts`). Object storage backs it, every write
6
+ // records its writer and produces a generation, and the durable-root sync
7
+ // (ADR 0013) presents it on the Computer as a real file the Bot can open with
8
+ // ordinary file tools — none of which is true of a data URL in the event log,
9
+ // the Durable Object's own storage, or a Memory root (single-writer,
10
+ // Markdown-only, and never written through the kernel file surface).
11
+ //
12
+ // `package-declared` roots are User-scoped, so one User's Bots share the root
13
+ // and each Bot's images sit under its own directory. That is the constitution's
14
+ // own rule for a Computer: "Bots of one User may read each other's Workspace
15
+ // files"; separation between them is organizational.
16
+ //
17
+ // The object is named by the *effect*, not by the prompt or a random id. That
18
+ // is the reconciliation fence: an interrupted Turn asks the Workspace whether
19
+ // the object for its effect exists, and the answer settles the effect without
20
+ // running — and therefore without billing — the model a second time.
21
+ import {
22
+ normalizeWorkspaceRelativePathV1,
23
+ type WorkspacePathV1,
24
+ type WorkspaceRootV1,
25
+ } from "@frockbot/kernel-contracts";
26
+
27
+ /** The Package id the declared root belongs to. Matches `frockbot.json`. */
28
+ export const IMAGE_PACKAGE_ID_V1 = "image";
29
+ /** The declared root generated images are written under. */
30
+ export const IMAGE_GENERATED_ROOT_ID_V1 = "generated";
31
+
32
+ /** The Bot and User a generated image is attributed to. */
33
+ export interface ImageOwnerV1 {
34
+ userId: string;
35
+ botId: string;
36
+ }
37
+
38
+ /** The Package-declared Workspace root this Package writes. */
39
+ export function generatedImageRootV1(userId: string): WorkspaceRootV1 {
40
+ return {
41
+ kind: "package-declared",
42
+ userId,
43
+ packageId: IMAGE_PACKAGE_ID_V1,
44
+ rootId: IMAGE_GENERATED_ROOT_ID_V1,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * One path segment made safe. An `effectId` is minted by the Agent loop as
50
+ * `tool:<turn>:<step>:<index>`; a Bot id is arbitrary durable text. Neither is
51
+ * a filename, so both are folded to a conservative alphabet rather than
52
+ * trusted — the Workspace would accept a colon, but a Computer's filesystem is
53
+ * where these land and there is no reason to find out which ones it dislikes.
54
+ */
55
+ export function imagePathSegmentV1(value: string): string {
56
+ const folded = value
57
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
58
+ .replace(/^-+|-+$/g, "");
59
+ if (!folded) throw new Error("image path segment is empty");
60
+ return folded.slice(0, 128);
61
+ }
62
+
63
+ /**
64
+ * The object one effect writes. Effect-keyed, so reconciliation reads exactly
65
+ * the object the interrupted attempt would have written.
66
+ */
67
+ export function generatedImagePathV1(
68
+ owner: ImageOwnerV1,
69
+ effectId: string,
70
+ extension: string,
71
+ ): WorkspacePathV1 {
72
+ return {
73
+ root: generatedImageRootV1(owner.userId),
74
+ path: normalizeWorkspaceRelativePathV1(
75
+ `${imagePathSegmentV1(owner.botId)}/${imagePathSegmentV1(effectId)}.${imagePathSegmentV1(extension)}`,
76
+ "generated image path",
77
+ ),
78
+ };
79
+ }
80
+
81
+ /** The file extension for a container this Package writes. */
82
+ export function imageExtensionV1(mimeType: string): string {
83
+ return mimeType === "image/jpeg" ? "jpg" : "png";
84
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,145 @@
1
+ // Test doubles for this Package, and nothing production imports.
2
+ //
3
+ // The Workspace fake is local rather than borrowed from `plugin-skills`: that
4
+ // one seeds text, and everything here is bytes. It records every call so a
5
+ // test can prove reconciliation read the store and never the model.
6
+ import {
7
+ workspaceRootKeyV1,
8
+ type WorkspaceDeleteRequestV1,
9
+ type WorkspaceEntryV1,
10
+ type WorkspaceFilesV1,
11
+ type WorkspaceGenerationV1,
12
+ type WorkspaceListOutcomeV1,
13
+ type WorkspaceListRequestV1,
14
+ type WorkspacePathV1,
15
+ type WorkspaceReadOutcomeV1,
16
+ type WorkspaceStatOutcomeV1,
17
+ type WorkspaceWriteOutcomeV1,
18
+ type WorkspaceWriteRequestV1,
19
+ } from "@frockbot/kernel-contracts";
20
+ import { sha256HexV1 } from "./bytes.js";
21
+ import type { ImageModelInputV1, ImageModelV1 } from "./model.js";
22
+
23
+ interface StoredFile {
24
+ entry: WorkspaceEntryV1;
25
+ bytes: Uint8Array;
26
+ }
27
+
28
+ /** An in-memory `WorkspaceFilesV1` over bytes. Records every call it served. */
29
+ export class FakeImageWorkspace implements WorkspaceFilesV1 {
30
+ readonly calls: string[] = [];
31
+ #files = new Map<string, StoredFile>();
32
+ #sequence = 0;
33
+ /** Set to make the next `write` answer this outcome instead of storing. */
34
+ nextWriteOutcome?: WorkspaceWriteOutcomeV1;
35
+
36
+ #key(path: WorkspacePathV1): string {
37
+ return `${workspaceRootKeyV1(path.root)}::${path.path}`;
38
+ }
39
+
40
+ #nextGenerationId(): string {
41
+ this.#sequence += 1;
42
+ return `gen-${String(this.#sequence).padStart(4, "0")}`;
43
+ }
44
+
45
+ read(path: WorkspacePathV1): Promise<WorkspaceReadOutcomeV1> {
46
+ this.calls.push(`read:${path.path}`);
47
+ const stored = this.#files.get(this.#key(path));
48
+ if (!stored) {
49
+ return Promise.resolve({ status: "not-found", reason: "no such file" });
50
+ }
51
+ return Promise.resolve({
52
+ status: "ok",
53
+ file: { ...stored.entry, bytes: stored.bytes },
54
+ });
55
+ }
56
+
57
+ stat(path: WorkspacePathV1): Promise<WorkspaceStatOutcomeV1> {
58
+ this.calls.push(`stat:${path.path}`);
59
+ const stored = this.#files.get(this.#key(path));
60
+ if (!stored) {
61
+ return Promise.resolve({ status: "not-found", reason: "no such file" });
62
+ }
63
+ return Promise.resolve({ status: "ok", entry: stored.entry });
64
+ }
65
+
66
+ list(request: WorkspaceListRequestV1): Promise<WorkspaceListOutcomeV1> {
67
+ this.calls.push(`list:${workspaceRootKeyV1(request.root)}`);
68
+ const key = workspaceRootKeyV1(request.root);
69
+ const entries = [...this.#files.values()]
70
+ .filter((stored) => workspaceRootKeyV1(stored.entry.path.root) === key)
71
+ .map((stored) => stored.entry);
72
+ return Promise.resolve({ status: "ok", entries });
73
+ }
74
+
75
+ async write(
76
+ request: WorkspaceWriteRequestV1,
77
+ ): Promise<WorkspaceWriteOutcomeV1> {
78
+ this.calls.push(`write:${request.path.path}`);
79
+ const scripted = this.nextWriteOutcome;
80
+ if (scripted) {
81
+ this.nextWriteOutcome = undefined;
82
+ return scripted;
83
+ }
84
+ const key = this.#key(request.path);
85
+ const existing = this.#files.get(key);
86
+ const seen = existing?.entry.generation.generationId ?? null;
87
+ if (seen !== request.expectedGenerationId) {
88
+ return { status: "conflict", reason: "unexpected generation" };
89
+ }
90
+ const generation: WorkspaceGenerationV1 = {
91
+ schemaVersion: 1,
92
+ generationId: this.#nextGenerationId(),
93
+ contentHash: await sha256HexV1(request.bytes),
94
+ size: request.bytes.byteLength,
95
+ writer: request.writer,
96
+ writtenAt: new Date(0).toISOString(),
97
+ };
98
+ this.#files.set(key, {
99
+ entry: { path: request.path, generation },
100
+ bytes: request.bytes,
101
+ });
102
+ return { status: "ok", generation };
103
+ }
104
+
105
+ delete(request: WorkspaceDeleteRequestV1): Promise<WorkspaceWriteOutcomeV1> {
106
+ this.calls.push(`delete:${request.path.path}`);
107
+ this.#files.delete(this.#key(request.path));
108
+ return Promise.resolve({
109
+ status: "refused",
110
+ reason: "the fake does not tombstone",
111
+ });
112
+ }
113
+ }
114
+
115
+ /** The smallest valid PNG header this Package can decode, at a given size. */
116
+ export function fakePngBytesV1(width: number, height: number): Uint8Array {
117
+ const bytes = new Uint8Array(64);
118
+ bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0);
119
+ // IHDR length (13) and type.
120
+ bytes.set([0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52], 8);
121
+ const view = new DataView(bytes.buffer);
122
+ view.setUint32(16, width);
123
+ view.setUint32(20, height);
124
+ bytes[24] = 8;
125
+ bytes[25] = 6;
126
+ return bytes;
127
+ }
128
+
129
+ /** An image model that answers fixed bytes and counts every call. */
130
+ export class FakeImageModel implements ImageModelV1 {
131
+ readonly calls: Array<{ model: string; input: ImageModelInputV1 }> = [];
132
+ #bytes: Uint8Array;
133
+ /** Set to make the next `run` reject with this message. */
134
+ failure?: string;
135
+
136
+ constructor(bytes: Uint8Array = fakePngBytesV1(1024, 1024)) {
137
+ this.#bytes = bytes;
138
+ }
139
+
140
+ run(model: string, input: ImageModelInputV1): Promise<ArrayBuffer> {
141
+ this.calls.push({ model, input });
142
+ if (this.failure) return Promise.reject(new Error(this.failure));
143
+ return Promise.resolve(this.#bytes.slice().buffer as ArrayBuffer);
144
+ }
145
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "allowImportingTsExtensions": true,
7
+ "resolveJsonModule": true,
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true,
11
+ "lib": ["ES2023", "DOM"],
12
+ "types": ["bun"]
13
+ },
14
+ "include": ["src/**/*.ts"]
15
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # @frockbot/plugin-image
2
-
3
- Placeholder reserving this name. See https://github.com/timoconnellaus/frockbot.