@narumitw/pi-image-drop 0.18.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 +21 -0
- package/README.md +149 -0
- package/package.json +51 -0
- package/src/batch.ts +418 -0
- package/src/image-drop.ts +9 -0
- package/src/images.ts +408 -0
- package/src/pi-settings.ts +79 -0
- package/src/runtime.ts +417 -0
- package/src/server.ts +572 -0
- package/src/settings.ts +116 -0
- package/src/vendor.d.ts +30 -0
- package/src/web/app.js +403 -0
- package/src/web/index.html +74 -0
- package/src/web/state.js +61 -0
- package/src/web/styles.css +397 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 narumiruna
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# 🖼️ pi-image-drop — Browser Image Staging for Pi
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@narumitw/pi-image-drop) [](./LICENSE)
|
|
4
|
+
|
|
5
|
+
`@narumitw/pi-image-drop` adds one `/image-drop` command to the latest [Pi Coding Agent](https://pi.dev). It serves a private loopback page where you can paste, drop, choose, preview, reorder, retry, and remove local images. The ordered batch is attached to your next non-empty interactive Pi message.
|
|
6
|
+
|
|
7
|
+
The page never contains a prompt or Attach button: Pi remains the only place where messages are written and sent.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pi install npm:@narumitw/pi-image-drop
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Try the working tree without installing:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi -e ./extensions/pi-image-drop
|
|
19
|
+
# or
|
|
20
|
+
just try image-drop
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
This package targets the latest Pi release and uses its `agent_settled` lifecycle event. Older Pi releases are not supported.
|
|
24
|
+
|
|
25
|
+
## Workflow
|
|
26
|
+
|
|
27
|
+
1. Run `/image-drop` in an interactive Pi session.
|
|
28
|
+
2. Pi prints and displays a clickable one-time `http://127.0.0.1:<port>/...` link. The extension does **not** open a browser.
|
|
29
|
+
3. Open the link. Paste images anywhere, drop files, or select **Choose images**.
|
|
30
|
+
4. Review previews and processing details. Drag to reorder, use the keyboard-accessible arrow buttons, retry failures, delete individual items, or use confirmed **Clear all**.
|
|
31
|
+
5. Write and submit a non-empty message in Pi. The ready images are appended after any attachments already on that message, in browser order.
|
|
32
|
+
|
|
33
|
+
The `🖼️` widget above Pi's editor reports ready, uploading, error, and queued counts. Uploading or failed items block the whole batch and preserve the Pi editor text. Image-only messages are not supported.
|
|
34
|
+
|
|
35
|
+
Each `/image-drop` invocation rotates the unused one-time link. A browser refresh keeps the current in-memory batch. Opening the authenticated page in another tab gives the new tab the editing lease and makes the old tab stale.
|
|
36
|
+
|
|
37
|
+
## Supported images
|
|
38
|
+
|
|
39
|
+
| Input | Provider-ready output |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| PNG | PNG |
|
|
42
|
+
| JPEG | JPEG |
|
|
43
|
+
| WebP | WebP |
|
|
44
|
+
| GIF, including animation | GIF |
|
|
45
|
+
| BMP | PNG |
|
|
46
|
+
| TIFF | PNG |
|
|
47
|
+
| HEIC/HEIF | PNG |
|
|
48
|
+
| AVIF | PNG |
|
|
49
|
+
|
|
50
|
+
Detection uses file signatures, not filenames or browser MIME types. SVG, HTML, remote URLs, unknown formats, corrupt files, and images over the configured pixel limit are rejected.
|
|
51
|
+
|
|
52
|
+
The processor applies orientation and removes EXIF (including GPS), XMP, IPTC, comments, and other sensitive metadata. It retains an ICC color profile and animated GIF timing where the output format supports them. With Pi's `images.autoResize` enabled (the default), output is reduced to fit Pi's 2,000-pixel and approximately 4.5 MiB Base64 inline limits. With it disabled, output that exceeds either limit fails visibly instead of being resized.
|
|
53
|
+
|
|
54
|
+
## Configuration
|
|
55
|
+
|
|
56
|
+
Image Drop has one optional **global-only** JSON file:
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-image-drop.json
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Example:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"maxImages": 8,
|
|
67
|
+
"maxImageBytes": 10485760,
|
|
68
|
+
"maxBatchBytes": 41943040,
|
|
69
|
+
"maxImagePixels": 50000000
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
| Setting | Safe default | Hard ceiling |
|
|
74
|
+
| --- | ---: | ---: |
|
|
75
|
+
| `maxImages` | 8 | 32 |
|
|
76
|
+
| `maxImageBytes` | 10 MiB | 50 MiB |
|
|
77
|
+
| `maxBatchBytes` | 40 MiB | 200 MiB |
|
|
78
|
+
| `maxImagePixels` | 50 megapixels | 100 megapixels |
|
|
79
|
+
|
|
80
|
+
Values are positive integer counts/bytes/pixels. `maxImageBytes` cannot exceed `maxBatchBytes`. Unknown fields, malformed JSON, invalid values, symlinks, or values above a hard ceiling cause the **whole file** to be ignored with one warning and safe defaults to be used. Values above a safe default but within a hard ceiling produce a memory/provider-limit warning.
|
|
81
|
+
|
|
82
|
+
At upload and submission time, the extension also re-reads Pi's documented global and trusted-project `images.autoResize` and `images.blockImages` settings. `blockImages: true` or a text-only current model blocks processing/submission without discarding the draft.
|
|
83
|
+
|
|
84
|
+
## Security and privacy
|
|
85
|
+
|
|
86
|
+
- The HTTP listener binds only to a random `127.0.0.1` port.
|
|
87
|
+
- A rotating bootstrap token is exchanged once for an HttpOnly, `SameSite=Strict` session cookie, then removed from the URL.
|
|
88
|
+
- Exact Host, mutation Origin, session-cookie, and active-client checks are enforced. No permissive CORS headers are sent.
|
|
89
|
+
- Pages use a restrictive Content Security Policy plus no-store, no-referrer, MIME-sniffing, and frame-denial headers.
|
|
90
|
+
- Raw request bodies, decoded pixels, source bytes, previews, and provider-ready bytes are bounded and stay in the Pi process memory. The extension creates no image cache or temporary image files.
|
|
91
|
+
- Bytes are released after Pi records the matching user message, after Delete/Clear all, and on reload, session replacement/fork, or shutdown. Once Pi records a message, normal Pi/provider retention rules apply.
|
|
92
|
+
|
|
93
|
+
A loopback page is local to your operating-system network namespace. Do not expose the port to a LAN or public interface.
|
|
94
|
+
|
|
95
|
+
## Platforms, browsers, and remote environments
|
|
96
|
+
|
|
97
|
+
The supported local targets are current macOS, Windows, desktop Linux, and WSL with current stable Chrome, Edge, Firefox, or Safari where those browsers are available. Native `sharp` packages are installed for the current platform; HEVC-backed HEIC and BMP use bounded portable decoders because the patent-safe prebuilt `sharp`/libvips bundle omits them.
|
|
98
|
+
|
|
99
|
+
WSL normally forwards loopback to Windows automatically; always use the printed `127.0.0.1` URL rather than changing it to `localhost`. For SSH, a container, or a devcontainer, forward the exact printed port and preserve the Host value. If Pi prints port `45678`, for example:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
ssh -L 45678:127.0.0.1:45678 user@remote-host
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Then open the unchanged `http://127.0.0.1:45678/...` link locally. Image Drop does not provide a cloud relay or remote upload endpoint.
|
|
106
|
+
|
|
107
|
+
## Limitations
|
|
108
|
+
|
|
109
|
+
- Only `/image-drop` is registered; there is no `/image-drop clear` command.
|
|
110
|
+
- A non-empty interactive Pi message is required. RPC, extension-generated, slash-command, and image-only inputs do not consume the batch.
|
|
111
|
+
- All items must be ready. One uploading or failed item blocks submission until it is retried or deleted.
|
|
112
|
+
- Provider aggregate request limits vary. Raising the defaults to the hard ceilings does not guarantee that a provider accepts the final multi-image request.
|
|
113
|
+
- Batches are intentionally not persisted or shown as recent history.
|
|
114
|
+
|
|
115
|
+
## Package layout
|
|
116
|
+
|
|
117
|
+
```text
|
|
118
|
+
src/image-drop.ts Pi extension entrypoint
|
|
119
|
+
src/runtime.ts Pi lifecycle and message orchestration
|
|
120
|
+
src/batch.ts in-memory batch state machine
|
|
121
|
+
src/images.ts bounded image processing
|
|
122
|
+
src/server.ts authenticated loopback HTTP/SSE server
|
|
123
|
+
src/settings.ts extension settings
|
|
124
|
+
src/pi-settings.ts effective Pi image settings adapter
|
|
125
|
+
src/web/ framework-free browser page
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
From the repository root:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
npm --workspace @narumitw/pi-image-drop run check
|
|
134
|
+
npm test
|
|
135
|
+
just try image-drop
|
|
136
|
+
just pack image-drop
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The dry-run package must contain the manifest, license, README, TypeScript sources, and static web assets, but no tests, fixtures, image bytes, or `node_modules`.
|
|
140
|
+
|
|
141
|
+
## Publishing
|
|
142
|
+
|
|
143
|
+
The first publication is intentionally a maintainer action:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
npm publish --workspace @narumitw/pi-image-drop --access public
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`just npm-public` only changes visibility after a scoped package already exists. Do not publish from an implementation or verification run.
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@narumitw/pi-image-drop",
|
|
3
|
+
"version": "0.18.0",
|
|
4
|
+
"description": "Pi extension for staging browser images and attaching them to the next message.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"private": false,
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi",
|
|
12
|
+
"images",
|
|
13
|
+
"attachments",
|
|
14
|
+
"browser"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"pi": {
|
|
22
|
+
"extensions": [
|
|
23
|
+
"./src/image-drop.ts"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"check": "biome check . && npm run typecheck",
|
|
28
|
+
"format": "biome check --write .",
|
|
29
|
+
"typecheck": "tsc --noEmit"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"bmp-js": "^0.1.0",
|
|
33
|
+
"heic-decode": "^2.1.0",
|
|
34
|
+
"sharp": "^0.35.3"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@earendil-works/pi-ai": "*",
|
|
38
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@biomejs/biome": "2.5.3",
|
|
42
|
+
"@earendil-works/pi-ai": "0.80.3",
|
|
43
|
+
"@earendil-works/pi-coding-agent": "0.80.3",
|
|
44
|
+
"typescript": "6.0.3"
|
|
45
|
+
},
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "https://github.com/narumiruna/pi-extensions",
|
|
49
|
+
"directory": "extensions/pi-image-drop"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/batch.ts
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { ImageContent } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { ImageDropSettings } from "./settings.js";
|
|
4
|
+
|
|
5
|
+
export type ItemStatus = "uploading" | "processing" | "ready" | "error";
|
|
6
|
+
export type BatchPhase = "empty" | "editing" | "ready" | "blocked" | "reserved" | "closed";
|
|
7
|
+
|
|
8
|
+
export interface ItemReservation {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
size: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ProcessedImage {
|
|
15
|
+
bytes: Buffer;
|
|
16
|
+
mimeType: string;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
originalWidth: number;
|
|
20
|
+
originalHeight: number;
|
|
21
|
+
sourceFormat: string;
|
|
22
|
+
outputFormat: string;
|
|
23
|
+
resized: boolean;
|
|
24
|
+
hash: string;
|
|
25
|
+
notes: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface BatchItem extends ItemReservation {
|
|
29
|
+
status: ItemStatus;
|
|
30
|
+
source?: Buffer;
|
|
31
|
+
processed?: ProcessedImage;
|
|
32
|
+
processedAutoResize?: boolean;
|
|
33
|
+
error?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PublicBatchItem extends ItemReservation {
|
|
37
|
+
status: ItemStatus;
|
|
38
|
+
error?: string;
|
|
39
|
+
mimeType?: string;
|
|
40
|
+
width?: number;
|
|
41
|
+
height?: number;
|
|
42
|
+
originalWidth?: number;
|
|
43
|
+
originalHeight?: number;
|
|
44
|
+
sourceFormat?: string;
|
|
45
|
+
outputFormat?: string;
|
|
46
|
+
resized?: boolean;
|
|
47
|
+
notes: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PublicBatchState {
|
|
51
|
+
revision: number;
|
|
52
|
+
phase: BatchPhase;
|
|
53
|
+
items: PublicBatchItem[];
|
|
54
|
+
totalSourceBytes: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface MessageReservation {
|
|
58
|
+
id: string;
|
|
59
|
+
text: string;
|
|
60
|
+
streamingBehavior?: "steer" | "followUp";
|
|
61
|
+
images: ImageContent[];
|
|
62
|
+
digest: string;
|
|
63
|
+
preflightStarted: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type CompleteResult = { kind: "ready" } | { kind: "duplicate"; existingId: string };
|
|
67
|
+
|
|
68
|
+
export class BatchError extends Error {
|
|
69
|
+
constructor(
|
|
70
|
+
message: string,
|
|
71
|
+
readonly code: "closed" | "frozen" | "stale" | "limit" | "invalid" | "not-found" | "not-ready",
|
|
72
|
+
) {
|
|
73
|
+
super(message);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class BatchStore {
|
|
78
|
+
private items: BatchItem[] = [];
|
|
79
|
+
private revision = 0;
|
|
80
|
+
private reservation?: MessageReservation;
|
|
81
|
+
private closed = false;
|
|
82
|
+
|
|
83
|
+
constructor(private readonly settings: ImageDropSettings) {}
|
|
84
|
+
|
|
85
|
+
publicState(): PublicBatchState {
|
|
86
|
+
return {
|
|
87
|
+
revision: this.revision,
|
|
88
|
+
phase: this.phase(),
|
|
89
|
+
items: this.items.map((item) => ({
|
|
90
|
+
id: item.id,
|
|
91
|
+
name: item.name,
|
|
92
|
+
size: item.size,
|
|
93
|
+
status: item.status,
|
|
94
|
+
error: item.error,
|
|
95
|
+
mimeType: item.processed?.mimeType,
|
|
96
|
+
width: item.processed?.width,
|
|
97
|
+
height: item.processed?.height,
|
|
98
|
+
originalWidth: item.processed?.originalWidth,
|
|
99
|
+
originalHeight: item.processed?.originalHeight,
|
|
100
|
+
sourceFormat: item.processed?.sourceFormat,
|
|
101
|
+
outputFormat: item.processed?.outputFormat,
|
|
102
|
+
resized: item.processed?.resized,
|
|
103
|
+
notes: [...(item.processed?.notes ?? [])],
|
|
104
|
+
})),
|
|
105
|
+
totalSourceBytes: this.items.reduce((sum, item) => sum + item.size, 0),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
reserveItems(inputs: readonly ItemReservation[], expectedRevision = this.revision): number {
|
|
110
|
+
this.assertMutable(expectedRevision);
|
|
111
|
+
if (inputs.length === 0) throw new BatchError("No images supplied", "invalid");
|
|
112
|
+
const ids = new Set(this.items.map((item) => item.id));
|
|
113
|
+
let addedBytes = 0;
|
|
114
|
+
for (const input of inputs) {
|
|
115
|
+
if (!isSafeIdentifier(input.id) || ids.has(input.id)) {
|
|
116
|
+
throw new BatchError("Image id is invalid or duplicated", "invalid");
|
|
117
|
+
}
|
|
118
|
+
if (!input.name || input.name.length > 255 || hasControlCharacter(input.name)) {
|
|
119
|
+
throw new BatchError("Image name is invalid", "invalid");
|
|
120
|
+
}
|
|
121
|
+
if (!Number.isSafeInteger(input.size) || input.size <= 0) {
|
|
122
|
+
throw new BatchError("Image size is invalid", "invalid");
|
|
123
|
+
}
|
|
124
|
+
if (input.size > this.settings.maxImageBytes) {
|
|
125
|
+
throw new BatchError("Image exceeds the per-image limit", "limit");
|
|
126
|
+
}
|
|
127
|
+
ids.add(input.id);
|
|
128
|
+
addedBytes += input.size;
|
|
129
|
+
}
|
|
130
|
+
if (this.items.length + inputs.length > this.settings.maxImages) {
|
|
131
|
+
throw new BatchError("Batch exceeds the image-count limit", "limit");
|
|
132
|
+
}
|
|
133
|
+
const currentBytes = this.items.reduce((sum, item) => sum + item.size, 0);
|
|
134
|
+
if (currentBytes + addedBytes > this.settings.maxBatchBytes) {
|
|
135
|
+
throw new BatchError("Batch exceeds the byte limit", "limit");
|
|
136
|
+
}
|
|
137
|
+
this.items.push(...inputs.map((input) => ({ ...input, status: "uploading" as const })));
|
|
138
|
+
return this.bump();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
startProcessing(id: string, source: Buffer): number {
|
|
142
|
+
this.assertOpen();
|
|
143
|
+
if (this.reservation) throw new BatchError("Batch is frozen", "frozen");
|
|
144
|
+
const item = this.item(id);
|
|
145
|
+
if (source.byteLength !== item.size) {
|
|
146
|
+
throw new BatchError("Uploaded size does not match the reservation", "invalid");
|
|
147
|
+
}
|
|
148
|
+
item.source = Buffer.from(source);
|
|
149
|
+
item.processed = undefined;
|
|
150
|
+
item.processedAutoResize = undefined;
|
|
151
|
+
item.error = undefined;
|
|
152
|
+
item.status = "processing";
|
|
153
|
+
return this.bump();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
complete(id: string, processed: ProcessedImage, autoResize?: boolean): CompleteResult {
|
|
157
|
+
this.assertOpen();
|
|
158
|
+
if (this.reservation) throw new BatchError("Batch is frozen", "frozen");
|
|
159
|
+
const item = this.item(id);
|
|
160
|
+
if (item.status !== "processing") throw new BatchError("Image is not processing", "invalid");
|
|
161
|
+
const itemIndex = this.items.indexOf(item);
|
|
162
|
+
const duplicates = this.items
|
|
163
|
+
.map((candidate, index) => ({ candidate, index }))
|
|
164
|
+
.filter(
|
|
165
|
+
({ candidate }) => candidate.id !== id && candidate.processed?.hash === processed.hash,
|
|
166
|
+
);
|
|
167
|
+
const earlier = duplicates.find(({ index }) => index < itemIndex);
|
|
168
|
+
if (earlier) {
|
|
169
|
+
this.items = this.items.filter((candidate) => candidate.id !== id);
|
|
170
|
+
this.bump();
|
|
171
|
+
return { kind: "duplicate", existingId: earlier.candidate.id };
|
|
172
|
+
}
|
|
173
|
+
item.processed = cloneProcessed(processed);
|
|
174
|
+
item.processedAutoResize = autoResize;
|
|
175
|
+
item.status = "ready";
|
|
176
|
+
item.error = undefined;
|
|
177
|
+
if (duplicates.length > 0) {
|
|
178
|
+
const laterIds = new Set(duplicates.map(({ candidate }) => candidate.id));
|
|
179
|
+
this.items = this.items.filter((candidate) => !laterIds.has(candidate.id));
|
|
180
|
+
this.bump();
|
|
181
|
+
return { kind: "duplicate", existingId: item.id };
|
|
182
|
+
}
|
|
183
|
+
this.bump();
|
|
184
|
+
return { kind: "ready" };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
fail(id: string, error: string): number {
|
|
188
|
+
this.assertOpen();
|
|
189
|
+
if (this.reservation) throw new BatchError("Batch is frozen", "frozen");
|
|
190
|
+
const item = this.item(id);
|
|
191
|
+
item.status = "error";
|
|
192
|
+
item.error = sanitizeError(error);
|
|
193
|
+
item.processed = undefined;
|
|
194
|
+
item.processedAutoResize = undefined;
|
|
195
|
+
return this.bump();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
failUpload(id: string, error: string): number {
|
|
199
|
+
this.assertOpen();
|
|
200
|
+
if (this.reservation) throw new BatchError("Batch is frozen", "frozen");
|
|
201
|
+
const item = this.item(id);
|
|
202
|
+
if (item.status !== "uploading") {
|
|
203
|
+
throw new BatchError("Upload is no longer pending", "invalid");
|
|
204
|
+
}
|
|
205
|
+
item.status = "error";
|
|
206
|
+
item.error = sanitizeError(error);
|
|
207
|
+
return this.bump();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
beginAutoResizeReprocessing(autoResize: boolean): Array<{ id: string; source: Buffer }> {
|
|
211
|
+
this.assertOpen();
|
|
212
|
+
if (this.reservation) throw new BatchError("Batch is frozen", "frozen");
|
|
213
|
+
if (this.items.some((item) => item.status !== "ready")) {
|
|
214
|
+
throw new BatchError("Every image must be ready before reprocessing", "not-ready");
|
|
215
|
+
}
|
|
216
|
+
const candidates = this.items.filter(
|
|
217
|
+
(item) => item.processedAutoResize !== undefined && item.processedAutoResize !== autoResize,
|
|
218
|
+
);
|
|
219
|
+
if (candidates.length === 0) return [];
|
|
220
|
+
const jobs = candidates.map((item) => {
|
|
221
|
+
if (!item.source) throw new BatchError("Image source bytes are unavailable", "not-ready");
|
|
222
|
+
return { id: item.id, source: Buffer.from(item.source) };
|
|
223
|
+
});
|
|
224
|
+
for (const item of candidates) {
|
|
225
|
+
item.status = "processing";
|
|
226
|
+
item.processed = undefined;
|
|
227
|
+
item.processedAutoResize = undefined;
|
|
228
|
+
item.error = undefined;
|
|
229
|
+
}
|
|
230
|
+
this.bump();
|
|
231
|
+
return jobs;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
retrySource(id: string): Buffer {
|
|
235
|
+
this.assertOpen();
|
|
236
|
+
if (this.reservation) throw new BatchError("Batch is frozen", "frozen");
|
|
237
|
+
const item = this.item(id);
|
|
238
|
+
if (item.status !== "error" || !item.source) {
|
|
239
|
+
throw new BatchError("Image cannot be retried without uploaded source bytes", "not-ready");
|
|
240
|
+
}
|
|
241
|
+
item.status = "processing";
|
|
242
|
+
item.error = undefined;
|
|
243
|
+
item.processedAutoResize = undefined;
|
|
244
|
+
this.bump();
|
|
245
|
+
return Buffer.from(item.source);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
delete(id: string, expectedRevision = this.revision): number {
|
|
249
|
+
this.assertMutable(expectedRevision);
|
|
250
|
+
const before = this.items.length;
|
|
251
|
+
this.items = this.items.filter((item) => item.id !== id);
|
|
252
|
+
if (this.items.length === before) throw new BatchError("Image not found", "not-found");
|
|
253
|
+
return this.bump();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
reorder(ids: readonly string[], expectedRevision = this.revision): number {
|
|
257
|
+
this.assertMutable(expectedRevision);
|
|
258
|
+
if (
|
|
259
|
+
ids.length !== this.items.length ||
|
|
260
|
+
new Set(ids).size !== ids.length ||
|
|
261
|
+
ids.some((id) => !this.items.some((item) => item.id === id))
|
|
262
|
+
) {
|
|
263
|
+
throw new BatchError("Order must contain every image exactly once", "invalid");
|
|
264
|
+
}
|
|
265
|
+
const byId = new Map(this.items.map((item) => [item.id, item]));
|
|
266
|
+
this.items = ids.map((id) => byId.get(id) as BatchItem);
|
|
267
|
+
return this.bump();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
clear(expectedRevision = this.revision): number {
|
|
271
|
+
this.assertMutable(expectedRevision);
|
|
272
|
+
this.items = [];
|
|
273
|
+
return this.bump();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
reserveMessage(text: string, streamingBehavior?: "steer" | "followUp"): MessageReservation {
|
|
277
|
+
this.assertOpen();
|
|
278
|
+
if (this.reservation) throw new BatchError("Batch is already frozen", "frozen");
|
|
279
|
+
if (!text.trim()) throw new BatchError("Message text is required", "invalid");
|
|
280
|
+
if (this.items.length === 0 || this.items.some((item) => item.status !== "ready")) {
|
|
281
|
+
throw new BatchError("Every image must be ready", "not-ready");
|
|
282
|
+
}
|
|
283
|
+
const images = this.items.map((item) => ({
|
|
284
|
+
type: "image" as const,
|
|
285
|
+
data: item.processed?.bytes.toString("base64") ?? "",
|
|
286
|
+
mimeType: item.processed?.mimeType ?? "",
|
|
287
|
+
}));
|
|
288
|
+
const reservation: MessageReservation = {
|
|
289
|
+
id: cryptoId(this.revision, images),
|
|
290
|
+
text,
|
|
291
|
+
streamingBehavior,
|
|
292
|
+
images,
|
|
293
|
+
digest: digestImages(images),
|
|
294
|
+
preflightStarted: false,
|
|
295
|
+
};
|
|
296
|
+
this.reservation = reservation;
|
|
297
|
+
this.bump();
|
|
298
|
+
return cloneReservation(reservation);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
markPreflightStarted(): void {
|
|
302
|
+
if (this.reservation) this.reservation.preflightStarted = true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
currentReservation(): MessageReservation | undefined {
|
|
306
|
+
return this.reservation ? cloneReservation(this.reservation) : undefined;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
preview(id: string): { bytes: Buffer; mimeType: string } {
|
|
310
|
+
this.assertOpen();
|
|
311
|
+
const processed = this.item(id).processed;
|
|
312
|
+
if (!processed) throw new BatchError("Image preview is not ready", "not-ready");
|
|
313
|
+
return { bytes: Buffer.from(processed.bytes), mimeType: processed.mimeType };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
commitReservation(digest: string): boolean {
|
|
317
|
+
if (!this.reservation || this.reservation.digest !== digest) return false;
|
|
318
|
+
this.reservation = undefined;
|
|
319
|
+
this.items = [];
|
|
320
|
+
this.bump();
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
restoreReservation(): MessageReservation | undefined {
|
|
325
|
+
if (!this.reservation) return undefined;
|
|
326
|
+
const restored = cloneReservation(this.reservation);
|
|
327
|
+
this.reservation = undefined;
|
|
328
|
+
this.bump();
|
|
329
|
+
return restored;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
close(): void {
|
|
333
|
+
if (this.closed) return;
|
|
334
|
+
this.closed = true;
|
|
335
|
+
this.reservation = undefined;
|
|
336
|
+
this.items = [];
|
|
337
|
+
this.bump();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private phase(): BatchPhase {
|
|
341
|
+
if (this.closed) return "closed";
|
|
342
|
+
if (this.reservation) return "reserved";
|
|
343
|
+
if (this.items.length === 0) return "empty";
|
|
344
|
+
if (this.items.some((item) => item.status === "error")) return "blocked";
|
|
345
|
+
if (this.items.every((item) => item.status === "ready")) return "ready";
|
|
346
|
+
return "editing";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private assertOpen(): void {
|
|
350
|
+
if (this.closed) throw new BatchError("Batch is closed", "closed");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private assertMutable(expectedRevision: number): void {
|
|
354
|
+
this.assertOpen();
|
|
355
|
+
if (this.reservation) throw new BatchError("Batch is frozen", "frozen");
|
|
356
|
+
if (expectedRevision !== this.revision)
|
|
357
|
+
throw new BatchError("Batch revision is stale", "stale");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private item(id: string): BatchItem {
|
|
361
|
+
const item = this.items.find((candidate) => candidate.id === id);
|
|
362
|
+
if (!item) throw new BatchError("Image not found", "not-found");
|
|
363
|
+
return item;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private bump(): number {
|
|
367
|
+
this.revision += 1;
|
|
368
|
+
return this.revision;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function digestImages(images: readonly ImageContent[]): string {
|
|
373
|
+
const hash = createHash("sha256");
|
|
374
|
+
for (const image of images) {
|
|
375
|
+
hash.update(image.mimeType);
|
|
376
|
+
hash.update("\0");
|
|
377
|
+
hash.update(image.data);
|
|
378
|
+
hash.update("\0");
|
|
379
|
+
}
|
|
380
|
+
return hash.digest("hex");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function cloneProcessed(processed: ProcessedImage): ProcessedImage {
|
|
384
|
+
return { ...processed, bytes: Buffer.from(processed.bytes), notes: [...processed.notes] };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function cloneReservation(reservation: MessageReservation): MessageReservation {
|
|
388
|
+
return { ...reservation, images: reservation.images.map((image) => ({ ...image })) };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function sanitizeError(error: string): string {
|
|
392
|
+
const normalized = [...error]
|
|
393
|
+
.map((character) => (isControlCharacter(character) ? " " : character))
|
|
394
|
+
.join("")
|
|
395
|
+
.replace(/\s+/g, " ")
|
|
396
|
+
.trim();
|
|
397
|
+
return normalized.slice(0, 500) || "Image processing failed";
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function hasControlCharacter(value: string): boolean {
|
|
401
|
+
return [...value].some(isControlCharacter);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function isControlCharacter(character: string): boolean {
|
|
405
|
+
const code = character.charCodeAt(0);
|
|
406
|
+
return code <= 31 || code === 127;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function isSafeIdentifier(id: string): boolean {
|
|
410
|
+
return /^[A-Za-z0-9_-]{1,80}$/.test(id);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function cryptoId(revision: number, images: readonly ImageContent[]): string {
|
|
414
|
+
return createHash("sha256")
|
|
415
|
+
.update(`${revision}\0${Date.now()}\0${digestImages(images)}`)
|
|
416
|
+
.digest("hex")
|
|
417
|
+
.slice(0, 24);
|
|
418
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { ImageDropRuntime } from "./runtime.js";
|
|
3
|
+
|
|
4
|
+
export default function imageDrop(pi: ExtensionAPI) {
|
|
5
|
+
const runtime = new ImageDropRuntime(pi);
|
|
6
|
+
runtime.register();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export { ImageDropRuntime } from "./runtime.js";
|