codsh-bundle 0.5.0 → 0.6.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/lib/index.js +568 -39
- package/lib/types/clipboard-image.d.ts +44 -0
- package/lib/types/keys.d.ts +2 -0
- package/lib/types/prompt.d.ts +60 -0
- package/lib/types/ship.d.ts +8 -1
- package/lib/types/vision.d.ts +84 -0
- package/package.json +48 -46
package/lib/index.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, join, parse } from "node:path";
|
|
5
5
|
import z from "@deepseek-ai/schemastery";
|
|
6
6
|
import { installModelSelection } from "@deepseek-ai/dsh-agent";
|
|
7
|
+
import { admitEncodedImages, isImageAdmissionError } from "@deepseek-ai/dsh-attachment";
|
|
7
8
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
8
9
|
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
9
|
-
import { homedir } from "node:os";
|
|
10
|
+
import { homedir, tmpdir } from "node:os";
|
|
10
11
|
import stringWidth from "string-width";
|
|
11
12
|
import { readdirSync } from "node:fs";
|
|
12
13
|
import { createInterface } from "node:readline";
|
|
@@ -795,6 +796,7 @@ const CONTROLS = {
|
|
|
795
796
|
"": { kind: "expand-output" },
|
|
796
797
|
"": { kind: "toggle-todos" },
|
|
797
798
|
"": { kind: "kill-input" },
|
|
799
|
+
"": { kind: "paste-image" },
|
|
798
800
|
"": { kind: "kill-word" }
|
|
799
801
|
};
|
|
800
802
|
/** Decodes terminal bytes into keys, holding partial sequences between reads. */
|
|
@@ -2225,6 +2227,158 @@ var TerminalConsole = class {
|
|
|
2225
2227
|
}
|
|
2226
2228
|
};
|
|
2227
2229
|
|
|
2230
|
+
//#endregion
|
|
2231
|
+
//#region src/clipboard-image.ts
|
|
2232
|
+
/** The most a clipboard image may be; beyond this the read reports none. */
|
|
2233
|
+
const MAX_CLIPBOARD_IMAGE_BYTES = 64 * 1024 * 1024;
|
|
2234
|
+
/**
|
|
2235
|
+
* Run one command, capturing binary stdout.
|
|
2236
|
+
* @param command - the executable.
|
|
2237
|
+
* @param args - its arguments.
|
|
2238
|
+
* @returns stdout as bytes, or undefined on any failure or empty output.
|
|
2239
|
+
*/
|
|
2240
|
+
function run$1(command, args) {
|
|
2241
|
+
return new Promise((resolve) => {
|
|
2242
|
+
execFile(command, [...args], {
|
|
2243
|
+
encoding: "buffer",
|
|
2244
|
+
maxBuffer: MAX_CLIPBOARD_IMAGE_BYTES,
|
|
2245
|
+
timeout: 1e4
|
|
2246
|
+
}, (error, stdout) => {
|
|
2247
|
+
if (error !== null || stdout.length === 0) resolve(void 0);
|
|
2248
|
+
else resolve(stdout);
|
|
2249
|
+
});
|
|
2250
|
+
});
|
|
2251
|
+
}
|
|
2252
|
+
/**
|
|
2253
|
+
* What image type these bytes are, from their magic numbers.
|
|
2254
|
+
*
|
|
2255
|
+
* Sniffed rather than taken from the reader's word: the store verifies the
|
|
2256
|
+
* declared type against the decoded bytes and refuses a mismatch, so lying
|
|
2257
|
+
* here would only defer the failure to a worse moment.
|
|
2258
|
+
* @param data - the bytes.
|
|
2259
|
+
* @returns the media type, or undefined for anything that is not an image.
|
|
2260
|
+
*/
|
|
2261
|
+
function sniffImageType(data) {
|
|
2262
|
+
if (data.length < 12) return void 0;
|
|
2263
|
+
if (data[0] === 137 && data[1] === 80 && data[2] === 78 && data[3] === 71) return "image/png";
|
|
2264
|
+
if (data[0] === 255 && data[1] === 216 && data[2] === 255) return "image/jpeg";
|
|
2265
|
+
if (data[0] === 71 && data[1] === 73 && data[2] === 70 && data[3] === 56) return "image/gif";
|
|
2266
|
+
if (data.subarray(0, 4).toString("latin1") === "RIFF" && data.subarray(8, 12).toString("latin1") === "WEBP") return "image/webp";
|
|
2267
|
+
}
|
|
2268
|
+
/** The clipboard's image on macOS: AppleScript writes the PNG to a file. */
|
|
2269
|
+
async function readDarwin() {
|
|
2270
|
+
const dir = await mkdtemp(join(tmpdir(), "codsh-clip-"));
|
|
2271
|
+
const file = join(dir, "clipboard.png");
|
|
2272
|
+
try {
|
|
2273
|
+
const script = [
|
|
2274
|
+
"-e",
|
|
2275
|
+
"set png_data to (the clipboard as «class PNGf»)",
|
|
2276
|
+
"-e",
|
|
2277
|
+
`set fp to open for access POSIX file "${file}" with write permission`,
|
|
2278
|
+
"-e",
|
|
2279
|
+
"write png_data to fp",
|
|
2280
|
+
"-e",
|
|
2281
|
+
"close access fp"
|
|
2282
|
+
];
|
|
2283
|
+
if (!await new Promise((resolve) => {
|
|
2284
|
+
execFile("osascript", script, { timeout: 1e4 }, (error) => void resolve(error === null));
|
|
2285
|
+
})) return void 0;
|
|
2286
|
+
return await readFile(file).catch(() => void 0);
|
|
2287
|
+
} finally {
|
|
2288
|
+
await rm(dir, {
|
|
2289
|
+
recursive: true,
|
|
2290
|
+
force: true
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
/** The clipboard's image on Linux, Wayland first, X11 as the fallback. */
|
|
2295
|
+
async function readLinux(env) {
|
|
2296
|
+
if (env.WAYLAND_DISPLAY !== void 0) {
|
|
2297
|
+
const offered$1 = (await run$1("wl-paste", ["-l"]))?.toString("utf8").match(/image\/(?:png|jpeg|webp|gif)/u)?.[0];
|
|
2298
|
+
if (offered$1 === void 0) return void 0;
|
|
2299
|
+
return run$1("wl-paste", ["-t", offered$1]);
|
|
2300
|
+
}
|
|
2301
|
+
const offered = (await run$1("xclip", [
|
|
2302
|
+
"-selection",
|
|
2303
|
+
"clipboard",
|
|
2304
|
+
"-t",
|
|
2305
|
+
"TARGETS",
|
|
2306
|
+
"-o"
|
|
2307
|
+
]))?.toString("utf8").match(/image\/(?:png|jpeg|webp|gif)/u)?.[0];
|
|
2308
|
+
if (offered === void 0) return void 0;
|
|
2309
|
+
return run$1("xclip", [
|
|
2310
|
+
"-selection",
|
|
2311
|
+
"clipboard",
|
|
2312
|
+
"-t",
|
|
2313
|
+
offered,
|
|
2314
|
+
"-o"
|
|
2315
|
+
]);
|
|
2316
|
+
}
|
|
2317
|
+
/** The clipboard's image on Windows: PowerShell saves it as a PNG file. */
|
|
2318
|
+
async function readWin32() {
|
|
2319
|
+
const dir = await mkdtemp(join(tmpdir(), "codsh-clip-"));
|
|
2320
|
+
const file = join(dir, "clipboard.png");
|
|
2321
|
+
try {
|
|
2322
|
+
const script = `$img = Get-Clipboard -Format Image; if ($img) { $img.Save('${file.replaceAll("\\", "\\\\")}', [System.Drawing.Imaging.ImageFormat]::Png) }`;
|
|
2323
|
+
if (!await new Promise((resolve) => {
|
|
2324
|
+
execFile("powershell", [
|
|
2325
|
+
"-NoProfile",
|
|
2326
|
+
"-Command",
|
|
2327
|
+
script
|
|
2328
|
+
], { timeout: 1e4 }, (error) => void resolve(error === null));
|
|
2329
|
+
})) return void 0;
|
|
2330
|
+
return await readFile(file).catch(() => void 0);
|
|
2331
|
+
} finally {
|
|
2332
|
+
await rm(dir, {
|
|
2333
|
+
recursive: true,
|
|
2334
|
+
force: true
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
/**
|
|
2339
|
+
* Pixel dimensions from the image header, best effort.
|
|
2340
|
+
*
|
|
2341
|
+
* sharp decodes properly, but it is a native module and the flash line does
|
|
2342
|
+
* not justify failing a paste over it — an unreadable header simply reports
|
|
2343
|
+
* no dimensions.
|
|
2344
|
+
* @param data - the image bytes.
|
|
2345
|
+
* @returns width and height, or undefined.
|
|
2346
|
+
*/
|
|
2347
|
+
async function probeDimensions(data) {
|
|
2348
|
+
try {
|
|
2349
|
+
const { default: sharp } = await import("sharp");
|
|
2350
|
+
const meta = await sharp(data).metadata();
|
|
2351
|
+
if (typeof meta.width === "number" && typeof meta.height === "number") return {
|
|
2352
|
+
width: meta.width,
|
|
2353
|
+
height: meta.height
|
|
2354
|
+
};
|
|
2355
|
+
return;
|
|
2356
|
+
} catch {
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
/**
|
|
2361
|
+
* The image on the system clipboard, or undefined when it holds none.
|
|
2362
|
+
*
|
|
2363
|
+
* `CODSH_CLIPBOARD_IMAGE_CMD` overrides the platform reader with a shell
|
|
2364
|
+
* command whose stdout is the image bytes — the seam the tests use, exactly
|
|
2365
|
+
* as `CODSH_CLIPBOARD=osc52` keeps the write path off the real clipboard.
|
|
2366
|
+
* @param env - the environment, for the override and the display probes.
|
|
2367
|
+
* @returns the image with its sniffed type and dimensions, or undefined.
|
|
2368
|
+
*/
|
|
2369
|
+
async function readClipboardImage(env) {
|
|
2370
|
+
const override = env.CODSH_CLIPBOARD_IMAGE_CMD;
|
|
2371
|
+
const data = override !== void 0 && override !== "" ? await run$1(env.SHELL ?? "/bin/sh", ["-c", override]) : process.platform === "darwin" ? await readDarwin() : process.platform === "win32" ? await readWin32() : await readLinux(env);
|
|
2372
|
+
if (data === void 0) return void 0;
|
|
2373
|
+
const mediaType = sniffImageType(data);
|
|
2374
|
+
if (mediaType === void 0) return void 0;
|
|
2375
|
+
return {
|
|
2376
|
+
data,
|
|
2377
|
+
mediaType,
|
|
2378
|
+
...await probeDimensions(data)
|
|
2379
|
+
};
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2228
2382
|
//#endregion
|
|
2229
2383
|
//#region src/editor.ts
|
|
2230
2384
|
/** Longest run of history the editor keeps for one session. */
|
|
@@ -2451,9 +2605,11 @@ var Editor = class {
|
|
|
2451
2605
|
backspace() {
|
|
2452
2606
|
if (this.column > 0) {
|
|
2453
2607
|
const cells = points(this.line());
|
|
2454
|
-
cells.
|
|
2608
|
+
const token = /\[Image #\d+\]$/u.exec(cells.slice(0, this.column).join(""));
|
|
2609
|
+
const width = token === null ? 1 : points(token[0]).length;
|
|
2610
|
+
cells.splice(this.column - width, width);
|
|
2455
2611
|
this.setLine(cells.join(""));
|
|
2456
|
-
this.column -=
|
|
2612
|
+
this.column -= width;
|
|
2457
2613
|
} else if (this.row > 0) {
|
|
2458
2614
|
const previous = this.lines[this.row - 1] ?? "";
|
|
2459
2615
|
const current = this.line();
|
|
@@ -2991,6 +3147,15 @@ var Prompt = class {
|
|
|
2991
3147
|
* not be lost; the queue is what a line reader provides for free.
|
|
2992
3148
|
*/
|
|
2993
3149
|
queued = [];
|
|
3150
|
+
/** Images pasted into the box, by the number their `[Image #N]` token wears. */
|
|
3151
|
+
pendingImages = /* @__PURE__ */ new Map();
|
|
3152
|
+
/** Numbers are never reused within a session: a recalled token must not
|
|
3153
|
+
* silently pick up a different image. */
|
|
3154
|
+
imageCounter = 0;
|
|
3155
|
+
/** The images belonging to the line the last read handed out. */
|
|
3156
|
+
submittedImages = [];
|
|
3157
|
+
/** Whether a clipboard read is already in flight; a second Ctrl+V waits. */
|
|
3158
|
+
pastingImage = false;
|
|
2994
3159
|
/** The working indicator shown under the box. */
|
|
2995
3160
|
hint;
|
|
2996
3161
|
/** A short-lived notice that borrows the hint row, e.g. the copy toast. */
|
|
@@ -3150,7 +3315,10 @@ var Prompt = class {
|
|
|
3150
3315
|
read(signal) {
|
|
3151
3316
|
if (!this.console.readsKeys) return this.console.readLine(signal);
|
|
3152
3317
|
const typedAhead = this.queued.shift();
|
|
3153
|
-
if (typedAhead !== void 0)
|
|
3318
|
+
if (typedAhead !== void 0) {
|
|
3319
|
+
this.submittedImages = typedAhead.images;
|
|
3320
|
+
return Promise.resolve(typedAhead.text);
|
|
3321
|
+
}
|
|
3154
3322
|
if (this.console.finished) return Promise.resolve(void 0);
|
|
3155
3323
|
this.reading = true;
|
|
3156
3324
|
this.render();
|
|
@@ -3158,6 +3326,7 @@ var Prompt = class {
|
|
|
3158
3326
|
const settle = (text) => {
|
|
3159
3327
|
this.pending = void 0;
|
|
3160
3328
|
this.reading = false;
|
|
3329
|
+
this.submittedImages = text === void 0 ? [] : this.claimImages(text);
|
|
3161
3330
|
resolve(text);
|
|
3162
3331
|
};
|
|
3163
3332
|
const onAbort = () => {
|
|
@@ -3297,12 +3466,19 @@ var Prompt = class {
|
|
|
3297
3466
|
this.render();
|
|
3298
3467
|
return;
|
|
3299
3468
|
}
|
|
3469
|
+
if (key.kind === "paste-image") {
|
|
3470
|
+
this.pasteImage();
|
|
3471
|
+
return;
|
|
3472
|
+
}
|
|
3300
3473
|
const action = this.editor.handle(key);
|
|
3301
3474
|
switch (action.kind) {
|
|
3302
3475
|
case "submit": {
|
|
3303
3476
|
const waiting = this.pending;
|
|
3304
3477
|
if (waiting === void 0) {
|
|
3305
|
-
this.queued.push(
|
|
3478
|
+
this.queued.push({
|
|
3479
|
+
text: action.text,
|
|
3480
|
+
images: this.claimImages(action.text)
|
|
3481
|
+
});
|
|
3306
3482
|
break;
|
|
3307
3483
|
}
|
|
3308
3484
|
waiting.dispose();
|
|
@@ -3327,6 +3503,81 @@ var Prompt = class {
|
|
|
3327
3503
|
this.render();
|
|
3328
3504
|
}
|
|
3329
3505
|
/**
|
|
3506
|
+
* Read the clipboard and attach its image behind an `[Image #N]` token.
|
|
3507
|
+
*
|
|
3508
|
+
* The read shells out and takes real time, so it runs off the key handler;
|
|
3509
|
+
* a second Ctrl+V during it is dropped rather than raced. Numbers count up
|
|
3510
|
+
* for the whole session — a token in a recalled line must never quietly
|
|
3511
|
+
* name a different image than the one it was minted for.
|
|
3512
|
+
*/
|
|
3513
|
+
async pasteImage() {
|
|
3514
|
+
const read = this.handlers.readClipboardImage;
|
|
3515
|
+
if (read === void 0 || this.pastingImage) return;
|
|
3516
|
+
this.pastingImage = true;
|
|
3517
|
+
try {
|
|
3518
|
+
const found = await read();
|
|
3519
|
+
if (found === void 0) {
|
|
3520
|
+
this.setFlash(this.theme.dim(" no image in the clipboard"));
|
|
3521
|
+
return;
|
|
3522
|
+
}
|
|
3523
|
+
this.imageCounter += 1;
|
|
3524
|
+
const id = this.imageCounter;
|
|
3525
|
+
const pending = {
|
|
3526
|
+
id,
|
|
3527
|
+
image: {
|
|
3528
|
+
mediaType: found.mediaType,
|
|
3529
|
+
data: found.data.toString("base64"),
|
|
3530
|
+
name: `Pasted image #${id}`
|
|
3531
|
+
}
|
|
3532
|
+
};
|
|
3533
|
+
if (found.width !== void 0) pending.width = found.width;
|
|
3534
|
+
if (found.height !== void 0) pending.height = found.height;
|
|
3535
|
+
this.pendingImages.set(id, pending);
|
|
3536
|
+
this.editor.handle({
|
|
3537
|
+
kind: "paste",
|
|
3538
|
+
text: `[Image #${id}]`
|
|
3539
|
+
});
|
|
3540
|
+
const size = found.width !== void 0 && found.height !== void 0 ? ` (${found.width}×${found.height} ${found.mediaType.slice(6)})` : "";
|
|
3541
|
+
this.setFlash(this.theme.dim(` ✓ image #${id} attached${size}`));
|
|
3542
|
+
} finally {
|
|
3543
|
+
this.pastingImage = false;
|
|
3544
|
+
this.render();
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
/**
|
|
3548
|
+
* The images a submitted line actually references, in token order.
|
|
3549
|
+
*
|
|
3550
|
+
* The tokens are the source of truth: a token the person deleted drops its
|
|
3551
|
+
* image, a token duplicated by editing still names one attachment once.
|
|
3552
|
+
* Claimed images leave the pending pool, so a token recalled from history
|
|
3553
|
+
* later submits as plain text rather than resurrecting consumed bytes.
|
|
3554
|
+
* @param text - the submitted line.
|
|
3555
|
+
* @returns the referenced images, ready to ride the submission.
|
|
3556
|
+
*/
|
|
3557
|
+
claimImages(text) {
|
|
3558
|
+
const images = [];
|
|
3559
|
+
for (const match of text.matchAll(/\[Image #(\d+)\]/gu)) {
|
|
3560
|
+
const id = Number(match[1]);
|
|
3561
|
+
const pending = this.pendingImages.get(id);
|
|
3562
|
+
if (pending === void 0) continue;
|
|
3563
|
+
this.pendingImages.delete(id);
|
|
3564
|
+
images.push(pending);
|
|
3565
|
+
}
|
|
3566
|
+
return images;
|
|
3567
|
+
}
|
|
3568
|
+
/**
|
|
3569
|
+
* The images belonging to the line the last read returned.
|
|
3570
|
+
*
|
|
3571
|
+
* A drain, in the transcript's `take*` idiom: the caller reads the line,
|
|
3572
|
+
* then takes its images exactly once.
|
|
3573
|
+
* @returns the images in token order, empty for a plain line.
|
|
3574
|
+
*/
|
|
3575
|
+
takeAttachments() {
|
|
3576
|
+
const images = this.submittedImages;
|
|
3577
|
+
this.submittedImages = [];
|
|
3578
|
+
return images;
|
|
3579
|
+
}
|
|
3580
|
+
/**
|
|
3330
3581
|
* The todo readout's rows: the one in flight, or the whole list once opened.
|
|
3331
3582
|
* @param columns - display columns available.
|
|
3332
3583
|
* @returns the rows, empty when no list is live.
|
|
@@ -3365,7 +3616,7 @@ var Prompt = class {
|
|
|
3365
3616
|
rows.push(...box.rows);
|
|
3366
3617
|
}
|
|
3367
3618
|
if (this.queued.length > 0) {
|
|
3368
|
-
const preview = this.queued[0] ?? "";
|
|
3619
|
+
const preview = this.queued[0]?.text ?? "";
|
|
3369
3620
|
const more = this.queued.length > 1 ? ` (+${this.queued.length - 1} more)` : "";
|
|
3370
3621
|
rows.push(this.theme.dim(truncate(` ↳ queued: ${preview.split("\n")[0] ?? ""}${more}`, columns)));
|
|
3371
3622
|
}
|
|
@@ -3888,6 +4139,13 @@ var TerminalQuestions = class {
|
|
|
3888
4139
|
* from idea to shipped, verified code — a research-grounded interview, a
|
|
3889
4140
|
* confirmed spec (gate 1), an approved plan (gate 2), then autonomous landing
|
|
3890
4141
|
* until the spec's acceptance criteria pass.
|
|
4142
|
+
*
|
|
4143
|
+
* The spec FILE is the workflow's memory, not the conversation: the approved
|
|
4144
|
+
* plan is written into it, its Status line names the phase, its checkboxes
|
|
4145
|
+
* are the progress, and a bare /ship offers to resume whatever it finds
|
|
4146
|
+
* unfinished. Conversations get interrupted, compacted, and cleared; the file
|
|
4147
|
+
* survives all three, which is what makes the landing reliable rather than
|
|
4148
|
+
* merely well-intentioned.
|
|
3891
4149
|
*/
|
|
3892
4150
|
/** The `/ship` prompt body; `$ARGUMENTS` is the typed one-sentence requirement. */
|
|
3893
4151
|
const SHIP_PROMPT = `Run the /ship workflow: take the one-sentence requirement below from idea to shipped, verified code in this repository. The requirement, exactly as typed:
|
|
@@ -3896,17 +4154,17 @@ const SHIP_PROMPT = `Run the /ship workflow: take the one-sentence requirement b
|
|
|
3896
4154
|
$ARGUMENTS
|
|
3897
4155
|
</idea>
|
|
3898
4156
|
|
|
3899
|
-
If the idea between the <idea> tags is empty, that is not an error:
|
|
4157
|
+
If the idea between the <idea> tags is empty, that is not an error. First look for unfinished work: scan the repository's spec directory (docs/specs/, or the repo's own design-document convention) for a spec whose Status line is not \`shipped\` — a bare /ship most likely means "carry on", so offer through ask_user_question to resume that spec from the phase its Status names, with everything below applying from that phase onward. Only when there is nothing to resume, ask for the one-sentence requirement with ask_user_question and use the answer as the idea. Images accompanying the command — [Image #N] tokens, <pasted-image> context, attached image blocks — are part of the requirement: a mockup or a screenshot is requirements material, so read it and cite what it shows in the interview.
|
|
3900
4158
|
|
|
3901
4159
|
Phase 1 — grounded interview. Research before you ask: read the repository layout, the docs, and the code paths the idea touches, so every question is informed by what actually exists. Then interrogate the idea with ask_user_question, one focused question per call, never a batch. Cover, as far as they are genuinely open: who this is for and what success looks like, scope and explicit non-goals, constraints (compatibility, performance, security, dependencies), edge cases and failure behavior, and how the result should be verified. Prefer concrete options grounded in what you found over open-ended prompts. Do not ask what inspection can answer — where code lives or how current behavior works is yours to find out. Stop when answers stop changing the design; do not pad the interview to look thorough.
|
|
3902
4160
|
|
|
3903
|
-
Phase 2 — the spec (gate 1). Write the agreed design to a spec file inside the repository. Follow the repo's existing convention for design documents if one exists (a specs, rfcs, or ADR directory); otherwise create docs/specs/<kebab-case-slug>.md. The spec must stand alone for a reader without this conversation: the one-sentence requirement, background, each interview decision with its reason, scope and non-goals, constraints, edge cases, and a numbered list of acceptance criteria where every criterion
|
|
4161
|
+
Phase 2 — the spec (gate 1). Write the agreed design to a spec file inside the repository. Follow the repo's existing convention for design documents if one exists (a specs, rfcs, or ADR directory); otherwise create docs/specs/<kebab-case-slug>.md. The spec must stand alone for a reader without this conversation: the one-sentence requirement, background, each interview decision with its reason, scope and non-goals, constraints, edge cases, and a numbered list of acceptance criteria where every criterion names the exact command that proves it and the output that counts as passing — the final phase runs those commands verbatim, so a criterion without a command is not finished. Give the file a \`Status:\` line (interviewing, confirmed, planned, landing, shipped) and keep it current at every phase change: it is what lets an interrupted /ship resume instead of starting over. Present the spec file path and a compact summary through ask_user_question and get an explicit yes. If the answer amends or rejects it, update the file and ask again. Do not proceed on silence or a vague reply.
|
|
3904
4162
|
|
|
3905
|
-
Phase 3 — the plan (gate 2). Only after the spec is confirmed, produce an implementation plan: ordered milestones with the files each touches, the tests each milestone adds or changes, which acceptance criterion each milestone satisfies, and the commands that prove the whole thing (build, typecheck, test). Present the plan through ask_user_question and get an explicit yes; fold rejections back in and present again. Write no implementation code before this gate passes, and do not use todo_write before it either — it tracks landing, not the interview.
|
|
4163
|
+
Phase 3 — the plan (gate 2). Only after the spec is confirmed, produce an implementation plan: ordered milestones with the files each touches, the tests each milestone adds or changes, which acceptance criterion each milestone satisfies, and the commands that prove the whole thing (build, typecheck, test). Present the plan through ask_user_question and get an explicit yes; fold rejections back in and present again. Once approved, write the plan into the spec file as a \`## Plan\` section with one checkbox per milestone — an approved plan lives on disk, not in a conversation that can be compacted or lost. Then, still before any implementation code, establish the ground: check the working tree is clean (uncommitted unrelated changes are the user's to decide about — ask), and run the plan's proof commands once, recording the baseline in the spec. A baseline that is already red changes what "green" will mean, so surface it here rather than discovering it under your own diff. Write no implementation code before this gate passes, and do not use todo_write before it either — it tracks landing, not the interview.
|
|
3906
4164
|
|
|
3907
|
-
Phase 4 — landing. After gate 2, work autonomously; return to the user only for a genuine blocker that contradicts the spec, never for routine decisions. Choose the mechanism by the approved plan's size. If it has at most three milestones and you expect the whole change to fit comfortably in this session's context, implement in-session: track the milestones with todo_write, and for each one implement, run the tests,
|
|
4165
|
+
Phase 4 — landing. After gate 2, work autonomously; return to the user only for a genuine blocker that contradicts the spec, never for routine decisions. Either way the spec file — not this conversation — is the working memory: re-read it before starting each milestone, tick the milestone's checkbox and update Status as you go, and commit after each milestone turns green — small commits are the progress that survives a crash and the history a reviewer can walk. Choose the mechanism by the approved plan's size. If it has at most three milestones and you expect the whole change to fit comfortably in this session's context, implement in-session: track the milestones with todo_write, and for each one implement, run the tests, fix until green, then commit before moving on. If it is larger — four or more substantially independent milestones, or work you expect to exceed what one session can hold — the user running /ship is their explicit request for a fresh-agent Ralph loop: call the ralph tool once, with an objective that names the spec file path as the single source of truth, instructs each round to read the spec from disk (plan, checkboxes, baseline), pick the first unchecked milestone, implement and test it, then commit and tick its checkbox, and defines completion as every acceptance criterion in the spec passing. Bound the loop: budget about three rounds per milestone, and instruct it to stop and report rather than continue past two consecutive rounds that tick nothing.
|
|
3908
4166
|
|
|
3909
|
-
Phase 5 — done means verified. The workflow ends only when every acceptance criterion passes with you actually running
|
|
4167
|
+
Phase 5 — done means verified. The workflow ends only when every acceptance criterion passes with you actually running its named command and reading the real output. After a Ralph loop returns, run every proof command again yourself — the loop's word is a report, not a verification. Never report a result you did not run, and never weaken a criterion to make it pass; if one cannot be met, say so plainly and why. When a decision changes mid-flight, update the spec file first so the file on disk stays the truth. Set Status to shipped only after that final run, and close with an honest report listing each criterion, the command that proved it, and what it printed — plus anything left open.
|
|
3910
4168
|
|
|
3911
4169
|
If the session is in plan mode, the plan-mode rules win: nothing here authorizes writes while it is active. Tell the user this workflow needs to write the spec file and ask them to leave plan mode before continuing past the interview.`;
|
|
3912
4170
|
|
|
@@ -3994,6 +4252,166 @@ var Spinner = class {
|
|
|
3994
4252
|
}
|
|
3995
4253
|
};
|
|
3996
4254
|
|
|
4255
|
+
//#endregion
|
|
4256
|
+
//#region src/vision.ts
|
|
4257
|
+
/** How long one description may take before the paste falls back to file-only. */
|
|
4258
|
+
const VISION_TIMEOUT_MS = 3e4;
|
|
4259
|
+
/**
|
|
4260
|
+
* The one instruction the sidecar gets.
|
|
4261
|
+
*
|
|
4262
|
+
* It is the eyes for a model that has none, so completeness beats brevity and
|
|
4263
|
+
* verbatim beats summary: a truncated error message or a paraphrased line of
|
|
4264
|
+
* code is exactly the part the coding agent needed.
|
|
4265
|
+
*/
|
|
4266
|
+
const VISION_PROMPT = "You are the eyes for a text-only coding agent. Describe this image precisely and completely. Transcribe ALL visible text, code, commands, error messages, numbers and labels verbatim. When it shows a UI, terminal, diagram or chart, describe its structure and layout so the agent can reason about it. Do not speculate beyond what is visible.";
|
|
4267
|
+
/**
|
|
4268
|
+
* The sidecar from the environment, or undefined when none is configured.
|
|
4269
|
+
* @param env - the process environment.
|
|
4270
|
+
* @returns the config when both the base URL and the model are set.
|
|
4271
|
+
*/
|
|
4272
|
+
function visionConfigFromEnv(env) {
|
|
4273
|
+
const baseUrl = env.CODSH_VISION_BASE_URL;
|
|
4274
|
+
const model = env.CODSH_VISION_MODEL;
|
|
4275
|
+
if (baseUrl === void 0 || baseUrl === "" || model === void 0 || model === "") return void 0;
|
|
4276
|
+
const config = {
|
|
4277
|
+
baseUrl: baseUrl.replace(/\/$/u, ""),
|
|
4278
|
+
model
|
|
4279
|
+
};
|
|
4280
|
+
const key = env.CODSH_VISION_API_KEY;
|
|
4281
|
+
if (key !== void 0 && key !== "") config.apiKey = key;
|
|
4282
|
+
return config;
|
|
4283
|
+
}
|
|
4284
|
+
/**
|
|
4285
|
+
* Ask the sidecar what an image shows.
|
|
4286
|
+
* @param image - the encoded image.
|
|
4287
|
+
* @param config - which endpoint and model to ask.
|
|
4288
|
+
* @param signal - cancels the request, on top of the built-in timeout.
|
|
4289
|
+
* @returns the description.
|
|
4290
|
+
* @throws on timeout, a non-2xx answer, or an answer with no text.
|
|
4291
|
+
*/
|
|
4292
|
+
async function describeImage(image, config, signal) {
|
|
4293
|
+
const timeout = AbortSignal.timeout(VISION_TIMEOUT_MS);
|
|
4294
|
+
const response = await fetch(`${config.baseUrl}/chat/completions`, {
|
|
4295
|
+
method: "POST",
|
|
4296
|
+
headers: {
|
|
4297
|
+
"content-type": "application/json",
|
|
4298
|
+
...config.apiKey === void 0 ? {} : { authorization: `Bearer ${config.apiKey}` }
|
|
4299
|
+
},
|
|
4300
|
+
body: JSON.stringify({
|
|
4301
|
+
model: config.model,
|
|
4302
|
+
messages: [{
|
|
4303
|
+
role: "user",
|
|
4304
|
+
content: [{
|
|
4305
|
+
type: "image_url",
|
|
4306
|
+
image_url: { url: `data:${image.mediaType};base64,${image.data}` }
|
|
4307
|
+
}, {
|
|
4308
|
+
type: "text",
|
|
4309
|
+
text: VISION_PROMPT
|
|
4310
|
+
}]
|
|
4311
|
+
}]
|
|
4312
|
+
}),
|
|
4313
|
+
signal: signal === void 0 ? timeout : AbortSignal.any([signal, timeout])
|
|
4314
|
+
});
|
|
4315
|
+
if (!response.ok) throw new Error(`vision endpoint answered ${response.status}`);
|
|
4316
|
+
const text = (await response.json()).choices?.[0]?.message?.content?.trim();
|
|
4317
|
+
if (text === void 0 || text === "") throw new Error("vision endpoint answered without text");
|
|
4318
|
+
return text;
|
|
4319
|
+
}
|
|
4320
|
+
/**
|
|
4321
|
+
* The upstream store's default admission limits, for use when no store is
|
|
4322
|
+
* mounted: the sidecar payload is bounded by the same line either way.
|
|
4323
|
+
*/
|
|
4324
|
+
const DEFAULT_IMAGE_LIMITS = {
|
|
4325
|
+
maxImageBytes: 3.5 * 1024 * 1024,
|
|
4326
|
+
maxImagesPerMessage: 20,
|
|
4327
|
+
maxMessageImageBytes: 100 * 1024 * 1024,
|
|
4328
|
+
maxImagePixels: 4e7,
|
|
4329
|
+
maxImageDimension: 2e3,
|
|
4330
|
+
mediaTypes: [
|
|
4331
|
+
"image/png",
|
|
4332
|
+
"image/jpeg",
|
|
4333
|
+
"image/webp",
|
|
4334
|
+
"image/gif"
|
|
4335
|
+
]
|
|
4336
|
+
};
|
|
4337
|
+
/** File extension per media type, for the saved copy's name. */
|
|
4338
|
+
const EXTENSIONS = {
|
|
4339
|
+
"image/png": "png",
|
|
4340
|
+
"image/jpeg": "jpg",
|
|
4341
|
+
"image/webp": "webp",
|
|
4342
|
+
"image/gif": "gif"
|
|
4343
|
+
};
|
|
4344
|
+
/**
|
|
4345
|
+
* Save a pasted image where the agent's tools can reach it.
|
|
4346
|
+
*
|
|
4347
|
+
* The original bytes, never a downscaled copy — the person may want the asset
|
|
4348
|
+
* itself committed. Content-addressed under the dsh home so a repeated paste
|
|
4349
|
+
* dedupes, nothing lands in the workspace uninvited, and the path stays valid
|
|
4350
|
+
* for `--resume`.
|
|
4351
|
+
* @param image - the encoded image.
|
|
4352
|
+
* @returns the absolute path of the saved file.
|
|
4353
|
+
*/
|
|
4354
|
+
async function savePastedImage(image) {
|
|
4355
|
+
const data = Buffer.from(image.data, "base64");
|
|
4356
|
+
const digest = createHash("sha256").update(data).digest("hex").slice(0, 12);
|
|
4357
|
+
const dir = dshHomePath("attachments", "pasted");
|
|
4358
|
+
await mkdir(dir, { recursive: true });
|
|
4359
|
+
const path = join(dir, `${digest}.${EXTENSIONS[image.mediaType]}`);
|
|
4360
|
+
await writeFile(path, data);
|
|
4361
|
+
return path;
|
|
4362
|
+
}
|
|
4363
|
+
/**
|
|
4364
|
+
* The context block a pasted image contributes on a text-only route.
|
|
4365
|
+
*
|
|
4366
|
+
* The same XMLish convention the `!` passthrough uses: the model reads the
|
|
4367
|
+
* path (its tools can open the file), the dimensions, and — when the sidecar
|
|
4368
|
+
* ran — the description standing in for sight.
|
|
4369
|
+
* @param id - the `[Image #N]` number the person's text references.
|
|
4370
|
+
* @param image - the encoded image.
|
|
4371
|
+
* @param at - where the file was saved and what is known about it.
|
|
4372
|
+
* @returns the block text.
|
|
4373
|
+
*/
|
|
4374
|
+
function pastedImageBlock(id, image, at) {
|
|
4375
|
+
const size = at.width !== void 0 && at.height !== void 0 ? ` dimensions="${at.width}x${at.height}"` : "";
|
|
4376
|
+
const body = at.description === void 0 ? "" : `\n<description>\n${at.description}\n</description>`;
|
|
4377
|
+
return `<pasted-image id="${id}" media="${image.mediaType}"${size} path="${at.path}">${body}\n</pasted-image>`;
|
|
4378
|
+
}
|
|
4379
|
+
/**
|
|
4380
|
+
* Shrink an image until the attachment store will admit it.
|
|
4381
|
+
*
|
|
4382
|
+
* Retina screenshots exceed the deployed routes' 2000-pixel side limit as a
|
|
4383
|
+
* matter of course, and refusing them would make the feature useless on the
|
|
4384
|
+
* machines most likely to use it. The downscale re-encodes as PNG; a copy
|
|
4385
|
+
* still over the byte limit falls back to JPEG, which is what a photograph
|
|
4386
|
+
* that big actually is.
|
|
4387
|
+
* @param image - the encoded image.
|
|
4388
|
+
* @param limits - the store's admission limits.
|
|
4389
|
+
* @returns the image, downscaled only when it had to be.
|
|
4390
|
+
*/
|
|
4391
|
+
async function fitWithinLimits(image, limits) {
|
|
4392
|
+
const data = Buffer.from(image.data, "base64");
|
|
4393
|
+
const { default: sharp } = await import("sharp");
|
|
4394
|
+
const meta = await sharp(data).metadata();
|
|
4395
|
+
if (!(Math.max(meta.width ?? 0, meta.height ?? 0) > limits.maxImageDimension || data.length > limits.maxImageBytes || (meta.width ?? 0) * (meta.height ?? 0) > limits.maxImagePixels)) return image;
|
|
4396
|
+
const png = await sharp(data).resize({
|
|
4397
|
+
width: limits.maxImageDimension,
|
|
4398
|
+
height: limits.maxImageDimension,
|
|
4399
|
+
fit: "inside",
|
|
4400
|
+
withoutEnlargement: true
|
|
4401
|
+
}).png().toBuffer();
|
|
4402
|
+
if (png.length <= limits.maxImageBytes) return {
|
|
4403
|
+
...image,
|
|
4404
|
+
mediaType: "image/png",
|
|
4405
|
+
data: png.toString("base64")
|
|
4406
|
+
};
|
|
4407
|
+
const jpeg = await sharp(png).jpeg({ quality: 80 }).toBuffer();
|
|
4408
|
+
return {
|
|
4409
|
+
...image,
|
|
4410
|
+
mediaType: "image/jpeg",
|
|
4411
|
+
data: jpeg.toString("base64")
|
|
4412
|
+
};
|
|
4413
|
+
}
|
|
4414
|
+
|
|
3997
4415
|
//#endregion
|
|
3998
4416
|
//#region src/streaming.ts
|
|
3999
4417
|
/** Accumulates assistant text deltas into rendered lines. */
|
|
@@ -4088,6 +4506,32 @@ function visibleText(content) {
|
|
|
4088
4506
|
return content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
4089
4507
|
}
|
|
4090
4508
|
/**
|
|
4509
|
+
* One dim line per image a user message carried, in place of pixels.
|
|
4510
|
+
*
|
|
4511
|
+
* An image block's bytes cannot render here, and a `<pasted-image>` context
|
|
4512
|
+
* block is the pipeline talking to the model — pages of description would
|
|
4513
|
+
* bury the words the person typed. Either becomes a line saying what rode
|
|
4514
|
+
* along and what became of it.
|
|
4515
|
+
* @param content - the message's blocks.
|
|
4516
|
+
* @param theme - styling for the meta lines.
|
|
4517
|
+
* @returns the lines, empty for a text-only message.
|
|
4518
|
+
*/
|
|
4519
|
+
function imageMetaLines(content, theme) {
|
|
4520
|
+
const lines = [];
|
|
4521
|
+
for (const block of content) if (block.type === "image") {
|
|
4522
|
+
const { width, height, mediaType } = block.attachment;
|
|
4523
|
+
lines.push(theme.dim(` [image · ${width}×${height} ${mediaType.slice(6)} · sent to the model]`));
|
|
4524
|
+
} else if (block.type === "text" && block.text.startsWith("<pasted-image ")) {
|
|
4525
|
+
const id = /id="(\d+)"/u.exec(block.text)?.[1] ?? "?";
|
|
4526
|
+
const dims = /dimensions="(\d+)x(\d+)"/u.exec(block.text);
|
|
4527
|
+
const media = /media="image\/(\w+)"/u.exec(block.text)?.[1] ?? "image";
|
|
4528
|
+
const size = dims === null ? "" : ` · ${dims[1]}×${dims[2]}`;
|
|
4529
|
+
const fate = block.text.includes("<description>") ? "described" : "saved to file";
|
|
4530
|
+
lines.push(theme.dim(` [image #${id}${size} ${media} · ${fate}]`));
|
|
4531
|
+
}
|
|
4532
|
+
return lines;
|
|
4533
|
+
}
|
|
4534
|
+
/**
|
|
4091
4535
|
* The left rules the transcript draws down a block's edge, one per kind.
|
|
4092
4536
|
*
|
|
4093
4537
|
* A rule is how a segment shows where it starts and ends without a frame or a
|
|
@@ -4261,10 +4705,12 @@ var Transcript = class {
|
|
|
4261
4705
|
case "user/message": {
|
|
4262
4706
|
if (event.data.source.kind !== "user") return [];
|
|
4263
4707
|
this.rule = rules.user;
|
|
4264
|
-
const [first = "", ...rest] =
|
|
4708
|
+
const [first = "", ...rest] = event.data.content.filter((block) => block.type === "text").filter((block) => !block.text.startsWith("<pasted-image ")).map((block) => block.text).join("").split("\n");
|
|
4709
|
+
const meta = imageMetaLines(event.data.content, theme);
|
|
4265
4710
|
return [
|
|
4266
4711
|
`${theme.user("›")} ${first}`,
|
|
4267
4712
|
...rest.map((line) => ` ${line}`),
|
|
4713
|
+
...meta,
|
|
4268
4714
|
""
|
|
4269
4715
|
];
|
|
4270
4716
|
}
|
|
@@ -4656,20 +5102,16 @@ function replay(session, transcript, io, theme) {
|
|
|
4656
5102
|
if (summary !== void 0) io.console.foldRecent(lines.length, summary, FOLD_LABELS.answer);
|
|
4657
5103
|
}
|
|
4658
5104
|
}
|
|
4659
|
-
|
|
4660
|
-
* Run one conversation turn and wait for the agent to go idle.
|
|
4661
|
-
* @param agent - the live agent.
|
|
4662
|
-
* @param text - the person's message.
|
|
4663
|
-
* @param working - the indicator to run while the turn does.
|
|
4664
|
-
* @param source - the message source; a canned prompt is plugin-sourced so the
|
|
4665
|
-
* transcript echoes the command that ran it, not its whole body.
|
|
4666
|
-
*/
|
|
4667
|
-
async function turn(agent, text, working, source = { kind: "user" }) {
|
|
5105
|
+
async function turn(agent, text, working, source = { kind: "user" }, extra) {
|
|
4668
5106
|
agent.followup(createUserMessage({
|
|
4669
|
-
content: [
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
5107
|
+
content: [
|
|
5108
|
+
...extra?.leading ?? [],
|
|
5109
|
+
{
|
|
5110
|
+
type: "text",
|
|
5111
|
+
text
|
|
5112
|
+
},
|
|
5113
|
+
...extra?.trailing ?? []
|
|
5114
|
+
],
|
|
4673
5115
|
source
|
|
4674
5116
|
}));
|
|
4675
5117
|
working?.start();
|
|
@@ -4688,7 +5130,7 @@ async function turn(agent, text, working, source = { kind: "user" }) {
|
|
|
4688
5130
|
* @param theme - styling for the command's report.
|
|
4689
5131
|
* @param signal - cancels the command when the person interrupts.
|
|
4690
5132
|
*/
|
|
4691
|
-
async function runCommand(ctx, agent, line, io, theme, signal) {
|
|
5133
|
+
async function runCommand(ctx, agent, line, io, theme, signal, images = []) {
|
|
4692
5134
|
const commands = ctx.get("commands");
|
|
4693
5135
|
if (commands === void 0) {
|
|
4694
5136
|
io.console.write(theme.error(" commands are unavailable in this composition"));
|
|
@@ -4701,7 +5143,7 @@ async function runCommand(ctx, agent, line, io, theme, signal) {
|
|
|
4701
5143
|
io.console.write("");
|
|
4702
5144
|
return;
|
|
4703
5145
|
}
|
|
4704
|
-
const execution = await commands.execute(agent, line,
|
|
5146
|
+
const execution = await commands.execute(agent, line, images, signal);
|
|
4705
5147
|
if (execution === void 0) {
|
|
4706
5148
|
io.console.write(theme.error(` unknown command: ${line}`));
|
|
4707
5149
|
return;
|
|
@@ -4918,11 +5360,33 @@ async function run(ctx, config, io) {
|
|
|
4918
5360
|
}))).flat().map((entry) => ({
|
|
4919
5361
|
provider: entry.provider,
|
|
4920
5362
|
id: entry.id,
|
|
4921
|
-
name: entry.name
|
|
5363
|
+
name: entry.name,
|
|
5364
|
+
...entry.inputModalities === void 0 ? {} : { inputModalities: entry.inputModalities }
|
|
4922
5365
|
}));
|
|
4923
5366
|
};
|
|
4924
5367
|
refreshModelCatalog();
|
|
4925
5368
|
/**
|
|
5369
|
+
* Whether the current route explicitly accepts image input.
|
|
5370
|
+
*
|
|
5371
|
+
* Exact-model metadata, not the advisory catalog, owns this decision. The
|
|
5372
|
+
* catalog is fetched in the background and can be empty or stale while a
|
|
5373
|
+
* saved route is already usable. The same explicit-true test the adapter
|
|
5374
|
+
* applies before throwing UNSUPPORTED_CONTENT remains fail-closed: absent
|
|
5375
|
+
* modalities or a failed resolution get the text fallback, which degrades,
|
|
5376
|
+
* where the block path would crash the turn.
|
|
5377
|
+
*/
|
|
5378
|
+
const routeAcceptsImages = async () => {
|
|
5379
|
+
const current = selection.current;
|
|
5380
|
+
if (current === void 0) return false;
|
|
5381
|
+
const llm = ctx.get("llm");
|
|
5382
|
+
if (llm === void 0) return false;
|
|
5383
|
+
try {
|
|
5384
|
+
return (await llm.resolveModelInfo(current.provider, current.model)).inputModalities?.includes("image") === true;
|
|
5385
|
+
} catch {
|
|
5386
|
+
return false;
|
|
5387
|
+
}
|
|
5388
|
+
};
|
|
5389
|
+
/**
|
|
4926
5390
|
* Resolve a /model argument to a selection.
|
|
4927
5391
|
* @param typed - a bare model id, or an explicit `provider/model`.
|
|
4928
5392
|
* @returns the selection, or an error message naming what is available.
|
|
@@ -5036,7 +5500,8 @@ async function run(ctx, config, io) {
|
|
|
5036
5500
|
},
|
|
5037
5501
|
expandOutput: () => {
|
|
5038
5502
|
if (!io.console.toggleFolds()) prompt.write(theme.dim(" nothing to expand"));
|
|
5039
|
-
}
|
|
5503
|
+
},
|
|
5504
|
+
readClipboardImage: () => readClipboardImage(process.env)
|
|
5040
5505
|
}, "Ask anything · / for commands · @ for files · ⇧Tab plan mode");
|
|
5041
5506
|
let turnBaseTokens = 0;
|
|
5042
5507
|
const spinner = new Spinner({
|
|
@@ -5497,13 +5962,70 @@ async function run(ctx, config, io) {
|
|
|
5497
5962
|
* than only in the status line.
|
|
5498
5963
|
* @param text - the person's message.
|
|
5499
5964
|
*/
|
|
5500
|
-
|
|
5965
|
+
/**
|
|
5966
|
+
* Turn pasted images into what this turn's message can carry.
|
|
5967
|
+
*
|
|
5968
|
+
* Three exits, decided by the route. An image-capable model gets the images
|
|
5969
|
+
* as first-class blocks through the durable store — the runtime's own path.
|
|
5970
|
+
* A text-only model gets each image saved as a file plus, when the vision
|
|
5971
|
+
* sidecar is configured, a description standing in for sight; both ride the
|
|
5972
|
+
* same message so they persist for `--resume`. A failure never loses the
|
|
5973
|
+
* turn: it flashes, and the text still goes.
|
|
5974
|
+
* @param images - the submission's pasted images, in token order.
|
|
5975
|
+
* @returns blocks around the text, or undefined when there is nothing extra.
|
|
5976
|
+
*/
|
|
5977
|
+
const prepareImages = async (images) => {
|
|
5978
|
+
if (images.length === 0) return void 0;
|
|
5979
|
+
const store = ctx.get("attachments");
|
|
5980
|
+
const limits = store?.imageLimits ?? DEFAULT_IMAGE_LIMITS;
|
|
5981
|
+
if (await routeAcceptsImages() && store !== void 0) try {
|
|
5982
|
+
return {
|
|
5983
|
+
leading: (await admitEncodedImages(store, await Promise.all(images.map((pending) => fitWithinLimits(pending.image, limits))))).map((attachment) => ({
|
|
5984
|
+
type: "image",
|
|
5985
|
+
attachment
|
|
5986
|
+
})),
|
|
5987
|
+
trailing: []
|
|
5988
|
+
};
|
|
5989
|
+
} catch (error) {
|
|
5990
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
5991
|
+
prompt.setFlash(theme.error(truncate(` image dropped: ${reason}`, io.console.columns)));
|
|
5992
|
+
if (!isImageAdmissionError(error)) return void 0;
|
|
5993
|
+
}
|
|
5994
|
+
const vision = visionConfigFromEnv(process.env);
|
|
5995
|
+
const trailing = [];
|
|
5996
|
+
for (const pending of images) {
|
|
5997
|
+
const at = { path: await savePastedImage(pending.image) };
|
|
5998
|
+
if (pending.width !== void 0) at.width = pending.width;
|
|
5999
|
+
if (pending.height !== void 0) at.height = pending.height;
|
|
6000
|
+
if (vision !== void 0) {
|
|
6001
|
+
prompt.setHint(theme.dim(` ✻ describing image #${pending.id} with ${vision.model}…`));
|
|
6002
|
+
try {
|
|
6003
|
+
at.description = await describeImage(await fitWithinLimits(pending.image, limits), vision);
|
|
6004
|
+
} catch (error) {
|
|
6005
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
6006
|
+
prompt.setFlash(theme.error(truncate(` image #${pending.id}: description failed (${reason}) — attached as file only`, io.console.columns)));
|
|
6007
|
+
} finally {
|
|
6008
|
+
prompt.setHint(void 0);
|
|
6009
|
+
}
|
|
6010
|
+
}
|
|
6011
|
+
trailing.push({
|
|
6012
|
+
type: "text",
|
|
6013
|
+
text: pastedImageBlock(pending.id, pending.image, at)
|
|
6014
|
+
});
|
|
6015
|
+
}
|
|
6016
|
+
return {
|
|
6017
|
+
leading: [],
|
|
6018
|
+
trailing
|
|
6019
|
+
};
|
|
6020
|
+
};
|
|
6021
|
+
const answer = async (text, source, images = []) => {
|
|
6022
|
+
const extra = await prepareImages(images);
|
|
5501
6023
|
const before = totalTokens(facts(branch).usage) ?? 0;
|
|
5502
6024
|
turnBaseTokens = before;
|
|
5503
6025
|
const started = performance.now();
|
|
5504
6026
|
io.console.setTitle(`⚡ dsh code — ${basename(cwd)}`);
|
|
5505
6027
|
try {
|
|
5506
|
-
await turn(live.agent, text, spinner, source);
|
|
6028
|
+
await turn(live.agent, text, spinner, source, extra);
|
|
5507
6029
|
} finally {
|
|
5508
6030
|
io.console.setTitle(`dsh code — ${basename(cwd)}`);
|
|
5509
6031
|
}
|
|
@@ -5581,11 +6103,13 @@ async function run(ctx, config, io) {
|
|
|
5581
6103
|
}
|
|
5582
6104
|
const line = await prompt.read();
|
|
5583
6105
|
if (line === void 0) break;
|
|
6106
|
+
const images = prompt.takeAttachments();
|
|
5584
6107
|
io.console.collapseFolds();
|
|
5585
6108
|
const trimmed = line.trim();
|
|
5586
6109
|
if (trimmed === "") continue;
|
|
5587
6110
|
if (trimmed === "/exit" || trimmed === "/quit") break;
|
|
5588
6111
|
if (trimmed.startsWith("!")) {
|
|
6112
|
+
if (images.length > 0) prompt.setFlash(theme.dim(" images do not ride ! commands — send them with a prompt"));
|
|
5589
6113
|
const command = trimmed.slice(1).trim();
|
|
5590
6114
|
if (command !== "") await passthrough(command);
|
|
5591
6115
|
continue;
|
|
@@ -5597,14 +6121,14 @@ async function run(ctx, config, io) {
|
|
|
5597
6121
|
await answer(INIT_PROMPT, {
|
|
5598
6122
|
kind: "plugin",
|
|
5599
6123
|
plugin: "coding-cli"
|
|
5600
|
-
});
|
|
6124
|
+
}, images);
|
|
5601
6125
|
continue;
|
|
5602
6126
|
}
|
|
5603
6127
|
if (name$1 === "ship") {
|
|
5604
6128
|
await answer(expandTemplate(SHIP_PROMPT, rest.trim()), {
|
|
5605
6129
|
kind: "plugin",
|
|
5606
6130
|
plugin: "coding-cli"
|
|
5607
|
-
});
|
|
6131
|
+
}, images);
|
|
5608
6132
|
continue;
|
|
5609
6133
|
}
|
|
5610
6134
|
const canned = customByName.get(name$1);
|
|
@@ -5612,18 +6136,23 @@ async function run(ctx, config, io) {
|
|
|
5612
6136
|
await answer(expandTemplate(canned.template, rest.trim()), {
|
|
5613
6137
|
kind: "plugin",
|
|
5614
6138
|
plugin: "coding-cli"
|
|
5615
|
-
});
|
|
6139
|
+
}, images);
|
|
5616
6140
|
continue;
|
|
5617
6141
|
}
|
|
6142
|
+
let batch = [];
|
|
6143
|
+
if (images.length > 0) if (await routeAcceptsImages()) {
|
|
6144
|
+
const limits = ctx.get("attachments")?.imageLimits ?? DEFAULT_IMAGE_LIMITS;
|
|
6145
|
+
batch = await Promise.all(images.map((pending) => fitWithinLimits(pending.image, limits)));
|
|
6146
|
+
} else prompt.setFlash(theme.error(" this model does not accept images with commands — they were dropped"));
|
|
5618
6147
|
running = new AbortController();
|
|
5619
6148
|
try {
|
|
5620
|
-
await runCommand(ctx, live.agent, trimmed, io, theme, running.signal);
|
|
6149
|
+
await runCommand(ctx, live.agent, trimmed, io, theme, running.signal, batch);
|
|
5621
6150
|
} finally {
|
|
5622
6151
|
running = void 0;
|
|
5623
6152
|
}
|
|
5624
6153
|
continue;
|
|
5625
6154
|
}
|
|
5626
|
-
await answer(trimmed);
|
|
6155
|
+
await answer(trimmed, void 0, images);
|
|
5627
6156
|
}
|
|
5628
6157
|
await sessions.flush(live.agent.session);
|
|
5629
6158
|
try {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading an image off the system clipboard.
|
|
3
|
+
*
|
|
4
|
+
* An image never arrives through the terminal: bracketed paste is text by
|
|
5
|
+
* construction, and a screenshot sitting on the clipboard has no byte channel
|
|
6
|
+
* into stdin at all. So — exactly as Claude Code does — Ctrl+V asks the
|
|
7
|
+
* platform for the clipboard's image directly: `osascript` on macOS,
|
|
8
|
+
* `wl-paste`/`xclip` on Linux, PowerShell on Windows. A machine without the
|
|
9
|
+
* helper, or a clipboard holding text, reads as "no image" rather than an
|
|
10
|
+
* error, the same silent tolerance the clipboard WRITE path has.
|
|
11
|
+
* @module codsh-bundle/src/clipboard-image
|
|
12
|
+
*/
|
|
13
|
+
import type { ImageMediaType } from '@deepseek-ai/dsh-attachment/types';
|
|
14
|
+
/** An image read off the clipboard, plus what the flash line wants to say. */
|
|
15
|
+
export interface ClipboardImage {
|
|
16
|
+
/** The raw bytes. */
|
|
17
|
+
data: Buffer;
|
|
18
|
+
/** The type the bytes actually are, sniffed rather than trusted. */
|
|
19
|
+
mediaType: ImageMediaType;
|
|
20
|
+
/** Pixel width, when the header could be read. */
|
|
21
|
+
width?: number;
|
|
22
|
+
/** Pixel height, when the header could be read. */
|
|
23
|
+
height?: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* What image type these bytes are, from their magic numbers.
|
|
27
|
+
*
|
|
28
|
+
* Sniffed rather than taken from the reader's word: the store verifies the
|
|
29
|
+
* declared type against the decoded bytes and refuses a mismatch, so lying
|
|
30
|
+
* here would only defer the failure to a worse moment.
|
|
31
|
+
* @param data - the bytes.
|
|
32
|
+
* @returns the media type, or undefined for anything that is not an image.
|
|
33
|
+
*/
|
|
34
|
+
export declare function sniffImageType(data: Buffer): ImageMediaType | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* The image on the system clipboard, or undefined when it holds none.
|
|
37
|
+
*
|
|
38
|
+
* `CODSH_CLIPBOARD_IMAGE_CMD` overrides the platform reader with a shell
|
|
39
|
+
* command whose stdout is the image bytes — the seam the tests use, exactly
|
|
40
|
+
* as `CODSH_CLIPBOARD=osc52` keeps the write path off the real clipboard.
|
|
41
|
+
* @param env - the environment, for the override and the display probes.
|
|
42
|
+
* @returns the image with its sniffed type and dimensions, or undefined.
|
|
43
|
+
*/
|
|
44
|
+
export declare function readClipboardImage(env: Record<string, string | undefined>): Promise<ClipboardImage | undefined>;
|
package/lib/types/keys.d.ts
CHANGED
package/lib/types/prompt.d.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* pipe and draws nothing. Callers ask for the next submission either way.
|
|
9
9
|
* @module codsh-bundle/src/prompt
|
|
10
10
|
*/
|
|
11
|
+
import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types';
|
|
12
|
+
import type { ClipboardImage } from './clipboard-image.ts';
|
|
11
13
|
import type { TerminalConsole } from './console.ts';
|
|
12
14
|
import type { EditorSources } from './editor.ts';
|
|
13
15
|
import type { SelectOutcome, SelectSpec } from './selector.ts';
|
|
@@ -25,6 +27,27 @@ export interface PromptHandlers {
|
|
|
25
27
|
shiftTab?(): void;
|
|
26
28
|
/** Ctrl-O: show the last clipped tool output in full. */
|
|
27
29
|
expandOutput?(): void;
|
|
30
|
+
/**
|
|
31
|
+
* Ctrl-V: the system clipboard's image, or undefined for none.
|
|
32
|
+
*
|
|
33
|
+
* Injected rather than imported so the pure-module tests can hand the
|
|
34
|
+
* prompt a fixture instead of a machine's clipboard.
|
|
35
|
+
*/
|
|
36
|
+
readClipboardImage?(): Promise<ClipboardImage | undefined>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* One pasted image awaiting its submission.
|
|
40
|
+
*
|
|
41
|
+
* The wire form the runtime admits, plus the dimensions the paste already
|
|
42
|
+
* probed — the submission pipeline says them back without re-decoding.
|
|
43
|
+
*/
|
|
44
|
+
export interface PendingImage {
|
|
45
|
+
/** The number the `[Image #N]` token wears, for context that names it back. */
|
|
46
|
+
id: number;
|
|
47
|
+
/** Base64 bytes and their sniffed media type. */
|
|
48
|
+
image: EncodedImageAttachment;
|
|
49
|
+
width?: number;
|
|
50
|
+
height?: number;
|
|
28
51
|
}
|
|
29
52
|
/** Drives the input box and answers reads and selections. */
|
|
30
53
|
export declare class Prompt {
|
|
@@ -43,6 +66,15 @@ export declare class Prompt {
|
|
|
43
66
|
* not be lost; the queue is what a line reader provides for free.
|
|
44
67
|
*/
|
|
45
68
|
private readonly queued;
|
|
69
|
+
/** Images pasted into the box, by the number their `[Image #N]` token wears. */
|
|
70
|
+
private readonly pendingImages;
|
|
71
|
+
/** Numbers are never reused within a session: a recalled token must not
|
|
72
|
+
* silently pick up a different image. */
|
|
73
|
+
private imageCounter;
|
|
74
|
+
/** The images belonging to the line the last read handed out. */
|
|
75
|
+
private submittedImages;
|
|
76
|
+
/** Whether a clipboard read is already in flight; a second Ctrl+V waits. */
|
|
77
|
+
private pastingImage;
|
|
46
78
|
/** The working indicator shown under the box. */
|
|
47
79
|
private hint;
|
|
48
80
|
/** A short-lived notice that borrows the hint row, e.g. the copy toast. */
|
|
@@ -164,6 +196,34 @@ export declare class Prompt {
|
|
|
164
196
|
* @param key - the decoded keystroke.
|
|
165
197
|
*/
|
|
166
198
|
private onKey;
|
|
199
|
+
/**
|
|
200
|
+
* Read the clipboard and attach its image behind an `[Image #N]` token.
|
|
201
|
+
*
|
|
202
|
+
* The read shells out and takes real time, so it runs off the key handler;
|
|
203
|
+
* a second Ctrl+V during it is dropped rather than raced. Numbers count up
|
|
204
|
+
* for the whole session — a token in a recalled line must never quietly
|
|
205
|
+
* name a different image than the one it was minted for.
|
|
206
|
+
*/
|
|
207
|
+
private pasteImage;
|
|
208
|
+
/**
|
|
209
|
+
* The images a submitted line actually references, in token order.
|
|
210
|
+
*
|
|
211
|
+
* The tokens are the source of truth: a token the person deleted drops its
|
|
212
|
+
* image, a token duplicated by editing still names one attachment once.
|
|
213
|
+
* Claimed images leave the pending pool, so a token recalled from history
|
|
214
|
+
* later submits as plain text rather than resurrecting consumed bytes.
|
|
215
|
+
* @param text - the submitted line.
|
|
216
|
+
* @returns the referenced images, ready to ride the submission.
|
|
217
|
+
*/
|
|
218
|
+
private claimImages;
|
|
219
|
+
/**
|
|
220
|
+
* The images belonging to the line the last read returned.
|
|
221
|
+
*
|
|
222
|
+
* A drain, in the transcript's `take*` idiom: the caller reads the line,
|
|
223
|
+
* then takes its images exactly once.
|
|
224
|
+
* @returns the images in token order, empty for a plain line.
|
|
225
|
+
*/
|
|
226
|
+
takeAttachments(): PendingImage[];
|
|
167
227
|
/**
|
|
168
228
|
* The todo readout's rows: the one in flight, or the whole list once opened.
|
|
169
229
|
* @param columns - display columns available.
|
package/lib/types/ship.d.ts
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
* from idea to shipped, verified code — a research-grounded interview, a
|
|
4
4
|
* confirmed spec (gate 1), an approved plan (gate 2), then autonomous landing
|
|
5
5
|
* until the spec's acceptance criteria pass.
|
|
6
|
+
*
|
|
7
|
+
* The spec FILE is the workflow's memory, not the conversation: the approved
|
|
8
|
+
* plan is written into it, its Status line names the phase, its checkboxes
|
|
9
|
+
* are the progress, and a bare /ship offers to resume whatever it finds
|
|
10
|
+
* unfinished. Conversations get interrupted, compacted, and cleared; the file
|
|
11
|
+
* survives all three, which is what makes the landing reliable rather than
|
|
12
|
+
* merely well-intentioned.
|
|
6
13
|
*/
|
|
7
14
|
/** The `/ship` prompt body; `$ARGUMENTS` is the typed one-sentence requirement. */
|
|
8
|
-
export declare const SHIP_PROMPT = "Run the /ship workflow: take the one-sentence requirement below from idea to shipped, verified code in this repository. The requirement, exactly as typed:\n\n<idea>\n$ARGUMENTS\n</idea>\n\nIf the idea between the <idea> tags is empty, that is not an error:
|
|
15
|
+
export declare const SHIP_PROMPT = "Run the /ship workflow: take the one-sentence requirement below from idea to shipped, verified code in this repository. The requirement, exactly as typed:\n\n<idea>\n$ARGUMENTS\n</idea>\n\nIf the idea between the <idea> tags is empty, that is not an error. First look for unfinished work: scan the repository's spec directory (docs/specs/, or the repo's own design-document convention) for a spec whose Status line is not `shipped` \u2014 a bare /ship most likely means \"carry on\", so offer through ask_user_question to resume that spec from the phase its Status names, with everything below applying from that phase onward. Only when there is nothing to resume, ask for the one-sentence requirement with ask_user_question and use the answer as the idea. Images accompanying the command \u2014 [Image #N] tokens, <pasted-image> context, attached image blocks \u2014 are part of the requirement: a mockup or a screenshot is requirements material, so read it and cite what it shows in the interview.\n\nPhase 1 \u2014 grounded interview. Research before you ask: read the repository layout, the docs, and the code paths the idea touches, so every question is informed by what actually exists. Then interrogate the idea with ask_user_question, one focused question per call, never a batch. Cover, as far as they are genuinely open: who this is for and what success looks like, scope and explicit non-goals, constraints (compatibility, performance, security, dependencies), edge cases and failure behavior, and how the result should be verified. Prefer concrete options grounded in what you found over open-ended prompts. Do not ask what inspection can answer \u2014 where code lives or how current behavior works is yours to find out. Stop when answers stop changing the design; do not pad the interview to look thorough.\n\nPhase 2 \u2014 the spec (gate 1). Write the agreed design to a spec file inside the repository. Follow the repo's existing convention for design documents if one exists (a specs, rfcs, or ADR directory); otherwise create docs/specs/<kebab-case-slug>.md. The spec must stand alone for a reader without this conversation: the one-sentence requirement, background, each interview decision with its reason, scope and non-goals, constraints, edge cases, and a numbered list of acceptance criteria where every criterion names the exact command that proves it and the output that counts as passing \u2014 the final phase runs those commands verbatim, so a criterion without a command is not finished. Give the file a `Status:` line (interviewing, confirmed, planned, landing, shipped) and keep it current at every phase change: it is what lets an interrupted /ship resume instead of starting over. Present the spec file path and a compact summary through ask_user_question and get an explicit yes. If the answer amends or rejects it, update the file and ask again. Do not proceed on silence or a vague reply.\n\nPhase 3 \u2014 the plan (gate 2). Only after the spec is confirmed, produce an implementation plan: ordered milestones with the files each touches, the tests each milestone adds or changes, which acceptance criterion each milestone satisfies, and the commands that prove the whole thing (build, typecheck, test). Present the plan through ask_user_question and get an explicit yes; fold rejections back in and present again. Once approved, write the plan into the spec file as a `## Plan` section with one checkbox per milestone \u2014 an approved plan lives on disk, not in a conversation that can be compacted or lost. Then, still before any implementation code, establish the ground: check the working tree is clean (uncommitted unrelated changes are the user's to decide about \u2014 ask), and run the plan's proof commands once, recording the baseline in the spec. A baseline that is already red changes what \"green\" will mean, so surface it here rather than discovering it under your own diff. Write no implementation code before this gate passes, and do not use todo_write before it either \u2014 it tracks landing, not the interview.\n\nPhase 4 \u2014 landing. After gate 2, work autonomously; return to the user only for a genuine blocker that contradicts the spec, never for routine decisions. Either way the spec file \u2014 not this conversation \u2014 is the working memory: re-read it before starting each milestone, tick the milestone's checkbox and update Status as you go, and commit after each milestone turns green \u2014 small commits are the progress that survives a crash and the history a reviewer can walk. Choose the mechanism by the approved plan's size. If it has at most three milestones and you expect the whole change to fit comfortably in this session's context, implement in-session: track the milestones with todo_write, and for each one implement, run the tests, fix until green, then commit before moving on. If it is larger \u2014 four or more substantially independent milestones, or work you expect to exceed what one session can hold \u2014 the user running /ship is their explicit request for a fresh-agent Ralph loop: call the ralph tool once, with an objective that names the spec file path as the single source of truth, instructs each round to read the spec from disk (plan, checkboxes, baseline), pick the first unchecked milestone, implement and test it, then commit and tick its checkbox, and defines completion as every acceptance criterion in the spec passing. Bound the loop: budget about three rounds per milestone, and instruct it to stop and report rather than continue past two consecutive rounds that tick nothing.\n\nPhase 5 \u2014 done means verified. The workflow ends only when every acceptance criterion passes with you actually running its named command and reading the real output. After a Ralph loop returns, run every proof command again yourself \u2014 the loop's word is a report, not a verification. Never report a result you did not run, and never weaken a criterion to make it pass; if one cannot be met, say so plainly and why. When a decision changes mid-flight, update the spec file first so the file on disk stays the truth. Set Status to shipped only after that final run, and close with an honest report listing each criterion, the command that proved it, and what it printed \u2014 plus anything left open.\n\nIf the session is in plan mode, the plan-mode rules win: nothing here authorizes writes while it is active. Tell the user this workflow needs to write the spec file and ask them to leave plan mode before continuing past the interview.";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a pasted image becomes when the model cannot see.
|
|
3
|
+
*
|
|
4
|
+
* DeepSeek Vision routes receive first-class image blocks before this module
|
|
5
|
+
* is involved. For text-only routes such as Flash and Pro, codsh gives an
|
|
6
|
+
* image two honest lives: it is always saved to a stable file the agent's
|
|
7
|
+
* tools can touch — inspect, commit, embed. And when a vision sidecar is
|
|
8
|
+
* configured (`CODSH_VISION_*`: any OpenAI-compatible multimodal endpoint),
|
|
9
|
+
* the image is also described into text the model can actually read: everything in it
|
|
10
|
+
* transcribed, structure narrated. Both ride the same message the person
|
|
11
|
+
* sent, so they persist in durable history and survive `--resume`.
|
|
12
|
+
* @module codsh-bundle/src/vision
|
|
13
|
+
*/
|
|
14
|
+
import type { EncodedImageAttachment, ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment/types';
|
|
15
|
+
/** A vision sidecar: an OpenAI-compatible endpoint that can see. */
|
|
16
|
+
export interface VisionConfig {
|
|
17
|
+
/** The API base, e.g. `https://open.bigmodel.cn/api/paas/v4`. */
|
|
18
|
+
baseUrl: string;
|
|
19
|
+
/** Bearer token; absent for endpoints that need none (local ollama). */
|
|
20
|
+
apiKey?: string;
|
|
21
|
+
/** The multimodal model to ask. */
|
|
22
|
+
model: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The sidecar from the environment, or undefined when none is configured.
|
|
26
|
+
* @param env - the process environment.
|
|
27
|
+
* @returns the config when both the base URL and the model are set.
|
|
28
|
+
*/
|
|
29
|
+
export declare function visionConfigFromEnv(env: Record<string, string | undefined>): VisionConfig | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* Ask the sidecar what an image shows.
|
|
32
|
+
* @param image - the encoded image.
|
|
33
|
+
* @param config - which endpoint and model to ask.
|
|
34
|
+
* @param signal - cancels the request, on top of the built-in timeout.
|
|
35
|
+
* @returns the description.
|
|
36
|
+
* @throws on timeout, a non-2xx answer, or an answer with no text.
|
|
37
|
+
*/
|
|
38
|
+
export declare function describeImage(image: EncodedImageAttachment, config: VisionConfig, signal?: AbortSignal): Promise<string>;
|
|
39
|
+
/**
|
|
40
|
+
* The upstream store's default admission limits, for use when no store is
|
|
41
|
+
* mounted: the sidecar payload is bounded by the same line either way.
|
|
42
|
+
*/
|
|
43
|
+
export declare const DEFAULT_IMAGE_LIMITS: ImageAttachmentLimits;
|
|
44
|
+
/**
|
|
45
|
+
* Save a pasted image where the agent's tools can reach it.
|
|
46
|
+
*
|
|
47
|
+
* The original bytes, never a downscaled copy — the person may want the asset
|
|
48
|
+
* itself committed. Content-addressed under the dsh home so a repeated paste
|
|
49
|
+
* dedupes, nothing lands in the workspace uninvited, and the path stays valid
|
|
50
|
+
* for `--resume`.
|
|
51
|
+
* @param image - the encoded image.
|
|
52
|
+
* @returns the absolute path of the saved file.
|
|
53
|
+
*/
|
|
54
|
+
export declare function savePastedImage(image: EncodedImageAttachment): Promise<string>;
|
|
55
|
+
/**
|
|
56
|
+
* The context block a pasted image contributes on a text-only route.
|
|
57
|
+
*
|
|
58
|
+
* The same XMLish convention the `!` passthrough uses: the model reads the
|
|
59
|
+
* path (its tools can open the file), the dimensions, and — when the sidecar
|
|
60
|
+
* ran — the description standing in for sight.
|
|
61
|
+
* @param id - the `[Image #N]` number the person's text references.
|
|
62
|
+
* @param image - the encoded image.
|
|
63
|
+
* @param at - where the file was saved and what is known about it.
|
|
64
|
+
* @returns the block text.
|
|
65
|
+
*/
|
|
66
|
+
export declare function pastedImageBlock(id: number, image: EncodedImageAttachment, at: {
|
|
67
|
+
path: string;
|
|
68
|
+
width?: number;
|
|
69
|
+
height?: number;
|
|
70
|
+
description?: string;
|
|
71
|
+
}): string;
|
|
72
|
+
/**
|
|
73
|
+
* Shrink an image until the attachment store will admit it.
|
|
74
|
+
*
|
|
75
|
+
* Retina screenshots exceed the deployed routes' 2000-pixel side limit as a
|
|
76
|
+
* matter of course, and refusing them would make the feature useless on the
|
|
77
|
+
* machines most likely to use it. The downscale re-encodes as PNG; a copy
|
|
78
|
+
* still over the byte limit falls back to JPEG, which is what a photograph
|
|
79
|
+
* that big actually is.
|
|
80
|
+
* @param image - the encoded image.
|
|
81
|
+
* @param limits - the store's admission limits.
|
|
82
|
+
* @returns the image, downscaled only when it had to be.
|
|
83
|
+
*/
|
|
84
|
+
export declare function fitWithinLimits(image: EncodedImageAttachment, limits: ImageAttachmentLimits): Promise<EncodedImageAttachment>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codsh-bundle",
|
|
3
3
|
"description": "The codsh runtime: the interactive TTY surface and code-cli agent preset, installed into a dsh profile. Users install codsh-cli (the launcher) — this package is what it registers.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"main": "lib/index.js",
|
|
@@ -38,59 +38,61 @@
|
|
|
38
38
|
}
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@deepseek-ai/dsh-agent-instructions": "^0.1.
|
|
42
|
-
"@deepseek-ai/dsh-agent-presets": "^0.1.
|
|
43
|
-
"@deepseek-ai/dsh-
|
|
44
|
-
"@deepseek-ai/dsh-
|
|
45
|
-
"@deepseek-ai/dsh-
|
|
46
|
-
"@deepseek-ai/dsh-
|
|
47
|
-
"@deepseek-ai/dsh-compaction-
|
|
48
|
-
"@deepseek-ai/dsh-
|
|
49
|
-
"@deepseek-ai/dsh-lsp
|
|
50
|
-
"@deepseek-ai/dsh-
|
|
51
|
-
"@deepseek-ai/dsh-
|
|
52
|
-
"@deepseek-ai/dsh-
|
|
53
|
-
"@deepseek-ai/dsh-
|
|
54
|
-
"@deepseek-ai/dsh-terminal
|
|
55
|
-
"@deepseek-ai/dsh-
|
|
56
|
-
"@deepseek-ai/dsh-tool-
|
|
57
|
-
"@deepseek-ai/dsh-tool-
|
|
58
|
-
"@deepseek-ai/dsh-tool-fs
|
|
59
|
-
"@deepseek-ai/dsh-tool-
|
|
60
|
-
"@deepseek-ai/dsh-tool-
|
|
61
|
-
"@deepseek-ai/dsh-tool-
|
|
62
|
-
"@deepseek-ai/dsh-tool-
|
|
63
|
-
"@deepseek-ai/dsh-tool-
|
|
64
|
-
"@deepseek-ai/dsh-tool-
|
|
65
|
-
"@deepseek-ai/dsh-tool-
|
|
66
|
-
"@deepseek-ai/dsh-tool-subagent
|
|
67
|
-
"@deepseek-ai/dsh-tool-
|
|
68
|
-
"@deepseek-ai/dsh-tool-
|
|
69
|
-
"@deepseek-ai/dsh-tool-
|
|
70
|
-
"@deepseek-ai/dsh-tool-
|
|
71
|
-
"@deepseek-ai/dsh-workflow
|
|
41
|
+
"@deepseek-ai/dsh-agent-instructions": "^0.1.1-rc.2",
|
|
42
|
+
"@deepseek-ai/dsh-agent-presets": "^0.1.1-rc.2",
|
|
43
|
+
"@deepseek-ai/dsh-attachment": "^0.1.1-rc.2",
|
|
44
|
+
"@deepseek-ai/dsh-cmdline": "^0.1.1-rc.2",
|
|
45
|
+
"@deepseek-ai/dsh-code-runtime-worker-thread": "^0.1.1-rc.2",
|
|
46
|
+
"@deepseek-ai/dsh-command-compact": "^0.1.1-rc.2",
|
|
47
|
+
"@deepseek-ai/dsh-compaction-basic": "^0.1.1-rc.2",
|
|
48
|
+
"@deepseek-ai/dsh-compaction-tool-result-pruner": "^0.1.1-rc.2",
|
|
49
|
+
"@deepseek-ai/dsh-lsp": "^0.1.1-rc.2",
|
|
50
|
+
"@deepseek-ai/dsh-lsp-stdio": "^0.1.1-rc.2",
|
|
51
|
+
"@deepseek-ai/dsh-persona": "^0.1.1-rc.2",
|
|
52
|
+
"@deepseek-ai/dsh-plan-mode": "^0.1.1-rc.2",
|
|
53
|
+
"@deepseek-ai/dsh-skill-filesystem": "^0.1.1-rc.2",
|
|
54
|
+
"@deepseek-ai/dsh-terminal": "^0.1.1-rc.2",
|
|
55
|
+
"@deepseek-ai/dsh-terminal-bash": "^0.1.1-rc.2",
|
|
56
|
+
"@deepseek-ai/dsh-tool-ask-user": "^0.1.1-rc.2",
|
|
57
|
+
"@deepseek-ai/dsh-tool-bash": "^0.1.1-rc.2",
|
|
58
|
+
"@deepseek-ai/dsh-tool-fs": "^0.1.1-rc.2",
|
|
59
|
+
"@deepseek-ai/dsh-tool-fs-search": "^0.1.1-rc.2",
|
|
60
|
+
"@deepseek-ai/dsh-tool-goal": "^0.1.1-rc.2",
|
|
61
|
+
"@deepseek-ai/dsh-tool-jobs": "^0.1.1-rc.2",
|
|
62
|
+
"@deepseek-ai/dsh-tool-lsp": "^0.1.1-rc.2",
|
|
63
|
+
"@deepseek-ai/dsh-tool-pwsh": "^0.1.1-rc.2",
|
|
64
|
+
"@deepseek-ai/dsh-tool-ralph": "^0.1.1-rc.2",
|
|
65
|
+
"@deepseek-ai/dsh-tool-skill": "^0.1.1-rc.2",
|
|
66
|
+
"@deepseek-ai/dsh-tool-subagent": "^0.1.1-rc.2",
|
|
67
|
+
"@deepseek-ai/dsh-tool-subagent-control": "^0.1.1-rc.2",
|
|
68
|
+
"@deepseek-ai/dsh-tool-terminal": "^0.1.1-rc.2",
|
|
69
|
+
"@deepseek-ai/dsh-tool-todo": "^0.1.1-rc.2",
|
|
70
|
+
"@deepseek-ai/dsh-tool-web": "^0.1.1-rc.2",
|
|
71
|
+
"@deepseek-ai/dsh-tool-workflow": "^0.1.1-rc.2",
|
|
72
|
+
"@deepseek-ai/dsh-workflow-worker-thread": "^0.1.1-rc.2",
|
|
72
73
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
73
74
|
"commander": "^15.0.0",
|
|
74
75
|
"diff": "^9.0.0",
|
|
76
|
+
"sharp": "^0.35.3",
|
|
75
77
|
"string-width": "^8.2.2"
|
|
76
78
|
},
|
|
77
79
|
"peerDependencies": {
|
|
78
80
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
79
81
|
"@deepseek-ai/cordis-plugin-loader": "^1.0.2",
|
|
80
|
-
"@deepseek-ai/dsh-agent": "^0.1.
|
|
81
|
-
"@deepseek-ai/dsh-agent-default-model": "^0.1.
|
|
82
|
-
"@deepseek-ai/dsh-commands": "^0.1.
|
|
83
|
-
"@deepseek-ai/dsh-home-paths": "^0.1.
|
|
84
|
-
"@deepseek-ai/dsh-invariants": "^0.1.
|
|
85
|
-
"@deepseek-ai/dsh-llm": "^0.1.
|
|
86
|
-
"@deepseek-ai/dsh-permission-presets": "^0.1.
|
|
87
|
-
"@deepseek-ai/dsh-session": "^0.1.
|
|
88
|
-
"@deepseek-ai/dsh-session-projection": "^0.1.
|
|
89
|
-
"@deepseek-ai/dsh-session-query": "^0.1.
|
|
90
|
-
"@deepseek-ai/dsh-token-meter": "^0.1.
|
|
91
|
-
"@deepseek-ai/dsh-tools": "^0.1.
|
|
92
|
-
"@deepseek-ai/dsh-user-approval": "^0.1.
|
|
93
|
-
"@deepseek-ai/dsh-user-questions": "^0.1.
|
|
82
|
+
"@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
|
|
83
|
+
"@deepseek-ai/dsh-agent-default-model": "^0.1.1-rc.2",
|
|
84
|
+
"@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
|
|
85
|
+
"@deepseek-ai/dsh-home-paths": "^0.1.1-rc.2",
|
|
86
|
+
"@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
|
|
87
|
+
"@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
|
|
88
|
+
"@deepseek-ai/dsh-permission-presets": "^0.1.1-rc.2",
|
|
89
|
+
"@deepseek-ai/dsh-session": "^0.1.1-rc.2",
|
|
90
|
+
"@deepseek-ai/dsh-session-projection": "^0.1.1-rc.2",
|
|
91
|
+
"@deepseek-ai/dsh-session-query": "^0.1.1-rc.2",
|
|
92
|
+
"@deepseek-ai/dsh-token-meter": "^0.1.1-rc.2",
|
|
93
|
+
"@deepseek-ai/dsh-tools": "^0.1.1-rc.2",
|
|
94
|
+
"@deepseek-ai/dsh-user-approval": "^0.1.1-rc.2",
|
|
95
|
+
"@deepseek-ai/dsh-user-questions": "^0.1.1-rc.2"
|
|
94
96
|
},
|
|
95
97
|
"engines": {
|
|
96
98
|
"node": ">=22.19"
|