pi-central 0.6.0 → 0.7.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/README.md CHANGED
@@ -124,12 +124,14 @@ Stated plainly, because a dashboard that overstates itself is worse than one tha
124
124
  **Works:** multiple sandboxed sessions in different directories with verified isolation; live transcripts
125
125
  reconstructed from Pi's delta-only stream; the approval dock answerable for any session; a bounded tool
126
126
  trace with full traversal; a read-only file tree and image viewer confined to the session's directory;
127
+ images shown where they belong in the conversation, including the ones a tool read, and screenshots pasted or
128
+ dropped straight into the composer;
127
129
  history searchable by content, with a clear refusal when a session's directory is gone; a session roster
128
130
  that survives a restart; agent-scoped credentials with the host's own credentials denied, and commits
129
131
  signed as the agent when the store is configured to sign.
130
132
 
131
- **Not yet:** no file editing from the browser, no update checking, no export. Each is a tracked issue
132
- rather than a wish.
133
+ **Not yet:** no file editing from the browser, no update checking, no export. The image size caps are derived from the
134
+ transport's frame limit rather than tuned against real reading. Each is a tracked issue rather than a wish.
133
135
 
134
136
  ## Working on this project
135
137
 
package/bin/pi-central.js CHANGED
@@ -25,7 +25,7 @@ function isBlockingDialog(record) {
25
25
  }
26
26
 
27
27
  // packages/protocol/src/envelopes.ts
28
- var PROTOCOL_VERSION = 4;
28
+ var PROTOCOL_VERSION = 5;
29
29
 
30
30
  // packages/protocol/src/own-installation.ts
31
31
  var PIPELINE_MARKERS = ["packages/orchestrator", "packages/ui", "packages/protocol"];
@@ -1479,6 +1479,7 @@ var blockingDialogMethods = [...BLOCKING_DIALOG_METHODS];
1479
1479
  var fireAndForgetUiMethods = [...FIRE_AND_FORGET_UI_METHODS];
1480
1480
 
1481
1481
  // packages/orchestrator/src/supervisor.ts
1482
+ var EMPTY_PROMPT_WITH_IMAGES = "See the attached image.";
1482
1483
  var Supervisor = class extends EventEmitter2 {
1483
1484
  #config;
1484
1485
  #sessions = /* @__PURE__ */ new Map();
@@ -1848,22 +1849,32 @@ ${rpc.stderrTail}` : ""}`;
1848
1849
  this.#patch(id);
1849
1850
  }
1850
1851
  /**
1851
- * Send a prompt. The command shape is closed; the message is the only free value.
1852
+ * Send a prompt. The command shape is closed; the message and its images are the only free values.
1852
1853
  *
1853
1854
  * `behavior` decides what a message means mid-run, and it is the caller's choice rather than
1854
1855
  * this method's: Pi REJECTS a streaming `prompt` with no `streamingBehavior` (`"Agent is
1855
1856
  * already processing. Specify streamingBehavior ('steer' or 'followUp')"`), so defaulting it
1856
1857
  * silently would decide for the user something the composer is there to ask (issue #3). When
1857
1858
  * idle there is nothing to steer into, so the flag is dropped rather than sent.
1859
+ *
1860
+ * `images` arrive ALREADY VALIDATED (`prompt-images.ts`) and are mapped to Pi's shape here — the one
1861
+ * place a control becomes Pi's stdin, which is the right place for the spelling to change. Pi wants
1862
+ * `{ type: "image", data, mimeType }`; the wire carries `{ mediaType, data }` because `type` is the
1863
+ * discriminant of every record on that wire and reusing it inside a payload makes a log ambiguous.
1858
1864
  */
1859
- async prompt(id, message, behavior) {
1865
+ async prompt(id, message, behavior, images = []) {
1860
1866
  const rpc = this.#processes.get(id);
1861
1867
  const session = this.#sessions.get(id);
1862
1868
  if (!rpc || !session) throw new Error(`no such session: ${id}`);
1869
+ const text = message.trim().length === 0 && images.length > 0 ? EMPTY_PROMPT_WITH_IMAGES : message;
1870
+ const withImages = images.length > 0 ? { images: images.map((image) => ({ type: "image", data: image.data, mimeType: image.mediaType })) } : {};
1863
1871
  if (session.state === "streaming") {
1864
- await rpc.send({ type: "prompt", message, streamingBehavior: behavior ?? "followUp" }, 6e4);
1872
+ await rpc.send(
1873
+ { type: "prompt", message: text, streamingBehavior: behavior ?? "followUp", ...withImages },
1874
+ 6e4
1875
+ );
1865
1876
  } else {
1866
- await rpc.send({ type: "prompt", message }, 6e4);
1877
+ await rpc.send({ type: "prompt", message: text, ...withImages }, 6e4);
1867
1878
  }
1868
1879
  }
1869
1880
  /**
@@ -1916,7 +1927,20 @@ ${rpc.stderrTail}` : ""}`;
1916
1927
  provider: String(model["provider"] ?? ""),
1917
1928
  id: String(model["id"] ?? ""),
1918
1929
  ...typeof model["name"] === "string" ? { name: model["name"] } : {},
1919
- ...typeof model["reasoning"] === "boolean" ? { reasoning: model["reasoning"] } : {}
1930
+ ...typeof model["reasoning"] === "boolean" ? { reasoning: model["reasoning"] } : {},
1931
+ /*
1932
+ * `input` decides whether the composer may offer a paste.
1933
+ *
1934
+ * Filtered to the two values the wire declares rather than passed through: Pi's catalogue may grow a
1935
+ * modality this version does not know, and an unrecognised member would widen the UI's type for a capability
1936
+ * nothing here can use. Absent when Pi did not report it — which is "unknown", NOT "text only", and the
1937
+ * difference decides whether the affordance is hidden or merely unflagged.
1938
+ */
1939
+ ...Array.isArray(model["input"]) ? {
1940
+ input: model["input"].filter(
1941
+ (value) => value === "text" || value === "image"
1942
+ )
1943
+ } : {}
1920
1944
  })).filter((model) => model.provider !== "" && model.id !== "");
1921
1945
  return { ok: true, models };
1922
1946
  } catch (error) {
@@ -2124,6 +2148,90 @@ function readPackageJson(path) {
2124
2148
  }
2125
2149
  var VERIFY_INSTALL_HINT = "npm audit signatures";
2126
2150
 
2151
+ // packages/orchestrator/src/prompt-images.ts
2152
+ var PROMPT_IMAGE_TYPES = [
2153
+ "image/png",
2154
+ "image/jpeg",
2155
+ "image/gif",
2156
+ "image/webp"
2157
+ ];
2158
+ var MAX_PROMPT_IMAGE_BYTES = 3 * 1024 * 1024;
2159
+ function refuseImage(refusal, detail) {
2160
+ switch (refusal) {
2161
+ case "unsupported-type":
2162
+ return `an image of type ${detail?.type ?? "unknown"} cannot be sent \u2014 prompts accept PNG, JPEG, GIF and WebP`;
2163
+ case "too-large":
2164
+ return `that image is ${formatMegabytes(detail?.bytes ?? 0)} decoded, above the ${formatMegabytes(MAX_PROMPT_IMAGE_BYTES)} limit for a prompt \u2014 downscale it and paste again`;
2165
+ case "not-base64":
2166
+ return "that image's data is not valid base64 \u2014 it may have been truncated in transit";
2167
+ case "missing-type":
2168
+ return "an image arrived without a media type";
2169
+ case "missing-data":
2170
+ return "an image arrived with no data";
2171
+ case "not-an-object":
2172
+ return "an image arrived in an unrecognised shape";
2173
+ }
2174
+ }
2175
+ function formatMegabytes(bytes) {
2176
+ if (!Number.isFinite(bytes) || bytes <= 0) return "an unknown size";
2177
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
2178
+ }
2179
+ function base64Bytes(data) {
2180
+ if (data.length === 0) return 0;
2181
+ const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
2182
+ return Math.floor(data.length * 3 / 4) - padding;
2183
+ }
2184
+ function isBase64(data) {
2185
+ if (data.length === 0 || data.length % 4 !== 0) return false;
2186
+ return /^[A-Za-z0-9+/]+={0,2}$/.test(data);
2187
+ }
2188
+ function acceptImage(raw) {
2189
+ if (typeof raw !== "object" || raw === null) return { ok: false, reason: "not-an-object" };
2190
+ const candidate = raw;
2191
+ if (typeof candidate.mediaType !== "string" || candidate.mediaType.length === 0) {
2192
+ return { ok: false, reason: "missing-type" };
2193
+ }
2194
+ if (typeof candidate.data !== "string" || candidate.data.length === 0) {
2195
+ return { ok: false, reason: "missing-data" };
2196
+ }
2197
+ const mediaType = candidate.mediaType.toLowerCase();
2198
+ if (!PROMPT_IMAGE_TYPES.includes(mediaType)) return { ok: false, reason: "unsupported-type" };
2199
+ if (!isBase64(candidate.data)) return { ok: false, reason: "not-base64" };
2200
+ const bytes = base64Bytes(candidate.data);
2201
+ if (bytes > MAX_PROMPT_IMAGE_BYTES) return { ok: false, reason: "too-large" };
2202
+ return { ok: true, image: { mediaType, data: candidate.data } };
2203
+ }
2204
+ var MAX_PROMPT_IMAGES_ENCODED_BYTES = 6 * 1024 * 1024;
2205
+ function acceptImages(raw) {
2206
+ if (raw === void 0 || raw === null) return { ok: true, images: [] };
2207
+ if (!Array.isArray(raw)) return { ok: false, message: "images must be a list" };
2208
+ if (raw.length === 0) return { ok: true, images: [] };
2209
+ const images = [];
2210
+ for (const entry of raw) {
2211
+ const result = acceptImage(entry);
2212
+ if (!result.ok) {
2213
+ const type = typeof entry === "object" && entry !== null ? entry.mediaType : void 0;
2214
+ const bytes = typeof entry === "object" && entry !== null && typeof entry.data === "string" ? base64Bytes(entry.data) : void 0;
2215
+ return {
2216
+ ok: false,
2217
+ message: refuseImage(result.reason, {
2218
+ ...typeof type === "string" ? { type } : {},
2219
+ ...bytes !== void 0 ? { bytes } : {}
2220
+ })
2221
+ };
2222
+ }
2223
+ images.push(result.image);
2224
+ }
2225
+ const encoded = images.reduce((total, image) => total + image.data.length, 0);
2226
+ if (encoded > MAX_PROMPT_IMAGES_ENCODED_BYTES) {
2227
+ return {
2228
+ ok: false,
2229
+ message: `these images total ${(encoded / (1024 * 1024)).toFixed(1)} MB encoded, above the ${(MAX_PROMPT_IMAGES_ENCODED_BYTES / (1024 * 1024)).toFixed(1)} MB limit for one prompt \u2014 remove one or downscale them`
2230
+ };
2231
+ }
2232
+ return { ok: true, images };
2233
+ }
2234
+
2127
2235
  // packages/orchestrator/src/registry.ts
2128
2236
  import { createReadStream, readdirSync as readdirSync3, realpathSync as realpathSync2, statSync as statSync6 } from "node:fs";
2129
2237
  import { basename, dirname as dirname2, join as join10, resolve as resolve7, sep as sep4 } from "node:path";
@@ -2751,13 +2859,15 @@ var Transport = class {
2751
2859
  this.#broadcast({ kind: "session", session: result.session });
2752
2860
  return;
2753
2861
  }
2754
- case "prompt":
2755
- await this.#supervisor.prompt(
2756
- control.sessionId,
2757
- control.message,
2758
- ...control.behavior ? [control.behavior] : []
2759
- );
2862
+ case "prompt": {
2863
+ const accepted = acceptImages(control.images);
2864
+ if (!accepted.ok) {
2865
+ this.#broadcast({ kind: "error", message: `image refused: ${accepted.message}`, sessionId: control.sessionId });
2866
+ return;
2867
+ }
2868
+ await this.#supervisor.prompt(control.sessionId, control.message, control.behavior, accepted.images);
2760
2869
  return;
2870
+ }
2761
2871
  case "clearQueue": {
2762
2872
  await this.#supervisor.clearQueue(control.sessionId, control.andStop);
2763
2873
  return;