gipity 1.0.428 → 1.0.429
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/dist/commands/deploy.js +19 -1
- package/dist/commands/generate.js +61 -4
- package/dist/commands/page-eval.js +387 -50
- package/dist/commands/page-inspect.js +8 -2
- package/dist/commands/page-screenshot.js +209 -43
- package/dist/hooks/capture-runner.js +9 -1
- package/dist/index.js +1057 -488
- package/dist/page-fixtures.js +92 -3
- package/dist/provider-docs.js +10 -10
- package/dist/relay/daemon.js +16 -2
- package/dist/relay/device-http.js +22 -0
- package/dist/relay/media-upload.js +184 -0
- package/dist/setup.js +4 -1
- package/package.json +2 -2
package/dist/page-fixtures.js
CHANGED
|
@@ -8,12 +8,63 @@
|
|
|
8
8
|
// Uses the same presigned init -> PUT -> complete flow the app file-upload
|
|
9
9
|
// service exposes, with `public: true`. Cleanup goes through the matching
|
|
10
10
|
// DELETE /api/:appGuid/uploads/:guid, which removes the public object too.
|
|
11
|
-
import { readFileSync, statSync } from 'node:fs';
|
|
12
|
-
import { basename } from 'node:path';
|
|
11
|
+
import { readFileSync, statSync, existsSync, readdirSync } from 'node:fs';
|
|
12
|
+
import { basename, resolve, relative, join } from 'node:path';
|
|
13
13
|
import { post, del } from './api.js';
|
|
14
|
+
import { getProjectRoot } from './config.js';
|
|
14
15
|
import { guessMime } from './upload.js';
|
|
16
|
+
/** Dirs a hand-made asset is never in, and that are expensive to walk. */
|
|
17
|
+
const FIND_SKIP = new Set(['node_modules', '.git', '.gipity', 'dist', 'build', '.next', 'coverage']);
|
|
18
|
+
/** Find files named `name` anywhere under `root` (breadth-first, bounded). */
|
|
19
|
+
function findByBasename(root, name, limit = 3) {
|
|
20
|
+
const hits = [];
|
|
21
|
+
const queue = [root];
|
|
22
|
+
let visited = 0;
|
|
23
|
+
while (queue.length && hits.length < limit && visited < 500) {
|
|
24
|
+
const dir = queue.shift();
|
|
25
|
+
visited++;
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
for (const e of entries) {
|
|
34
|
+
if (e.isDirectory()) {
|
|
35
|
+
if (!FIND_SKIP.has(e.name))
|
|
36
|
+
queue.push(join(dir, e.name));
|
|
37
|
+
}
|
|
38
|
+
else if (e.name === name && hits.length < limit) {
|
|
39
|
+
hits.push(join(dir, e.name));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return hits;
|
|
44
|
+
}
|
|
45
|
+
/** Every `page` flag that takes a local file resolves it against the CURRENT
|
|
46
|
+
* DIRECTORY, and a bare ENOENT from deep inside the upload path ("stat
|
|
47
|
+
* './tmp/fist.jpg'") tells the caller nothing about where we looked or where
|
|
48
|
+
* the file actually is. That costs an ls/find round-trip every time a file was
|
|
49
|
+
* written somewhere other than where the caller assumed - which is exactly what
|
|
50
|
+
* happens after a `cd` that failed, or a `gipity generate` run from a different
|
|
51
|
+
* directory. So: name the absolute path we tried, the directory it was resolved
|
|
52
|
+
* against, and - when a file with that basename exists elsewhere in the project
|
|
53
|
+
* - the path that would have worked. Recovery becomes copy-paste, not a search. */
|
|
54
|
+
export function assertLocalAsset(flag, localPath) {
|
|
55
|
+
const abs = resolve(localPath);
|
|
56
|
+
if (existsSync(abs))
|
|
57
|
+
return;
|
|
58
|
+
const root = getProjectRoot() ?? process.cwd();
|
|
59
|
+
const elsewhere = findByBasename(root, basename(localPath)).filter(p => p !== abs);
|
|
60
|
+
const found = elsewhere.length
|
|
61
|
+
? `\nThat file DOES exist here — pass this path instead:\n${elsewhere.map(p => ` ${flag} ${relative(process.cwd(), p) || p}`).join('\n')}`
|
|
62
|
+
: `\nNothing named "${basename(localPath)}" under ${root} either. Generate a frame with \`gipity generate image "<description>" -o ${localPath}\`, or check the path.`;
|
|
63
|
+
throw new Error(`${flag} ${localPath}: no such file — looked for ${abs} (relative paths resolve against the current directory, ${process.cwd()}).${found}`);
|
|
64
|
+
}
|
|
15
65
|
/** Upload a local file to the app's public file store and return its URL. */
|
|
16
|
-
export async function uploadPublicFixture(projectGuid, localPath) {
|
|
66
|
+
export async function uploadPublicFixture(projectGuid, localPath, flag = '--fixture') {
|
|
67
|
+
assertLocalAsset(flag, localPath);
|
|
17
68
|
const name = basename(localPath);
|
|
18
69
|
const size = statSync(localPath).size;
|
|
19
70
|
const contentType = guessMime(localPath);
|
|
@@ -38,4 +89,42 @@ export async function uploadPublicFixture(projectGuid, localPath) {
|
|
|
38
89
|
export async function deleteFixture(projectGuid, guid) {
|
|
39
90
|
await del(`/api/${projectGuid}/uploads/${guid}`);
|
|
40
91
|
}
|
|
92
|
+
// ── camera feed ─────────────────────────────────────────────────────────────
|
|
93
|
+
// A headless browser has no webcam, so a vision app (MediaPipe, YOLOX, any
|
|
94
|
+
// getUserMedia consumer) can't be exercised at all: getUserMedia rejects, the
|
|
95
|
+
// app shows its no-camera state, and the code path the user actually cares
|
|
96
|
+
// about never runs. `--fake-media` alone only fixes the rejection — Chrome's
|
|
97
|
+
// built-in synthetic device is a rolling test pattern, so a gesture/pose/object
|
|
98
|
+
// model still sees nothing recognizable and every agent ends up stubbing the
|
|
99
|
+
// model's own output to test around it.
|
|
100
|
+
//
|
|
101
|
+
// --camera closes that: host a real image/video and let the browser play it as
|
|
102
|
+
// the webcam's frames, so the app's REAL pipeline (frames → model → app logic)
|
|
103
|
+
// runs headlessly on input you control. Hosting reuses the fixture path above;
|
|
104
|
+
// the browser side (fetch the file, feed it to Chrome's fake capture device)
|
|
105
|
+
// is the server's job — the CLI just names the file and hands over its URL.
|
|
106
|
+
/** Container formats Chrome's fake video-capture device can be fed from, plus
|
|
107
|
+
* the still-image types the server transcodes into a looping single-frame
|
|
108
|
+
* feed. Anything else is rejected here (fast, local) rather than after an
|
|
109
|
+
* upload + a browser launch. */
|
|
110
|
+
const CAMERA_EXTS = ['.png', '.jpg', '.jpeg', '.webp', '.mp4', '.webm', '.y4m', '.mjpeg'];
|
|
111
|
+
/** Validate a --camera path before uploading it. Throws with the accepted list
|
|
112
|
+
* and the in-platform way to produce a frame, so a wrong file type costs one
|
|
113
|
+
* local error instead of an upload plus an opaque browser failure. */
|
|
114
|
+
export function assertCameraFile(localPath) {
|
|
115
|
+
assertLocalAsset('--camera', localPath);
|
|
116
|
+
const ext = localPath.slice(localPath.lastIndexOf('.')).toLowerCase();
|
|
117
|
+
if (CAMERA_EXTS.includes(ext))
|
|
118
|
+
return;
|
|
119
|
+
throw new Error(`--camera ${basename(localPath)}: unsupported file type "${ext || '(none)'}" — the camera feed must be an image or video (${CAMERA_EXTS.join(', ')}).\n` +
|
|
120
|
+
`A still image is played as a looping single-frame feed, which is what a gesture/pose/object model needs.\n` +
|
|
121
|
+
`No frame to hand? Generate one: gipity generate image "a hand making a closed fist, palm to camera, plain background"`);
|
|
122
|
+
}
|
|
123
|
+
/** Host a local image/video as the browser's webcam feed. Same public-upload
|
|
124
|
+
* path as a fixture (so it is fetchable from the browser container and is
|
|
125
|
+
* deleted the same way), validated first. */
|
|
126
|
+
export async function uploadCameraFeed(projectGuid, localPath) {
|
|
127
|
+
assertCameraFile(localPath);
|
|
128
|
+
return uploadPublicFixture(projectGuid, localPath, '--camera');
|
|
129
|
+
}
|
|
41
130
|
//# sourceMappingURL=page-fixtures.js.map
|
package/dist/provider-docs.js
CHANGED
|
@@ -42,17 +42,17 @@ export const GEMINI_TTS_VOICES_DOC = `Zephyr, Puck, Charon, Kore, Fenrir, Leda,
|
|
|
42
42
|
export const GEMINI_TTS_VOICES_SHORT = `Kore, Puck, Zephyr, Charon, Fenrir, Leda, Orus, Aoede, and 22 more`;
|
|
43
43
|
export const IMAGE_GEMINI_ASPECT_RATIOS = `1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3, 4:5, 5:4, 21:9`;
|
|
44
44
|
export const IMAGE_GEMINI_SIZES = `512, 1K, 2K, 4K`;
|
|
45
|
-
export const IMAGE_MODELS_DOC = `openai: gpt-image-2
|
|
46
|
-
export const IMAGE_PROVIDERS_BULLET = `- **OpenAI**: \`gpt-image-2
|
|
47
|
-
- **BFL/Flux**: \`flux-2-pro, flux-2-flex, flux-2-max, flux-
|
|
48
|
-
- **Gemini/Nano Banana**: \`gemini-2.5-flash-image, gemini-3.1-flash-image, gemini-3-pro-image\``;
|
|
45
|
+
export const IMAGE_MODELS_DOC = `openai: gpt-image-2. bfl: flux-2-pro, flux-2-flex, flux-2-max, flux-2-klein-9b, flux-2-klein-4b. gemini: gemini-3.1-flash-lite-image, gemini-2.5-flash-image, gemini-3.1-flash-image, gemini-3-pro-image`;
|
|
46
|
+
export const IMAGE_PROVIDERS_BULLET = `- **OpenAI**: \`gpt-image-2\`
|
|
47
|
+
- **BFL/Flux**: \`flux-2-pro, flux-2-flex, flux-2-max, flux-2-klein-9b, flux-2-klein-4b\`
|
|
48
|
+
- **Gemini/Nano Banana**: \`gemini-3.1-flash-lite-image, gemini-2.5-flash-image, gemini-3.1-flash-image, gemini-3-pro-image\``;
|
|
49
49
|
export const IMAGE_PROVIDERS_LIST = `openai, bfl, gemini`;
|
|
50
50
|
export const IMAGE_PROVIDER_DESCRIPTIONS = {
|
|
51
|
-
'openai': `OpenAI (gpt-image-2
|
|
52
|
-
'bfl': `BFL/Flux (flux-2-pro, flux-2-flex, flux-2-max, flux-
|
|
53
|
-
'gemini': `Gemini/Nano Banana (gemini-2.5-flash-image, gemini-3.1-flash-image, gemini-3-pro-image)`,
|
|
51
|
+
'openai': `OpenAI (gpt-image-2)`,
|
|
52
|
+
'bfl': `BFL/Flux (flux-2-pro, flux-2-flex, flux-2-max, flux-2-klein-9b, flux-2-klein-4b)`,
|
|
53
|
+
'gemini': `Gemini/Nano Banana (gemini-3.1-flash-lite-image, gemini-2.5-flash-image, gemini-3.1-flash-image, gemini-3-pro-image)`,
|
|
54
54
|
};
|
|
55
|
-
export const LLM_DEFAULT_MODELS_DOC = `OpenAI: gpt-4
|
|
55
|
+
export const LLM_DEFAULT_MODELS_DOC = `OpenAI: gpt-5.4-nano (cheapest), gpt-5.4-mini (cheap reasoning). Anthropic: claude-haiku-4-5 (cheapest). Gemini: gemini-2.5-flash-lite (cheapest, 1M context)`;
|
|
56
56
|
export const LLM_PROVIDERS_LIST = `anthropic, openai, gemini`;
|
|
57
57
|
export const OPENAI_TTS_VOICES_DOC = `alloy, ash, ballad, coral, echo, fable, nova, onyx, sage, shimmer, verse`;
|
|
58
58
|
export const TRANSCRIBE_PROVIDERS_DOC = `elevenlabs (default, Scribe v2), openai (GPT-4o Transcribe), gemini (Gemini 2.5 Flash - cheapest, multilingual)`;
|
|
@@ -63,7 +63,7 @@ export const TTS_PROVIDER_DESCRIPTIONS = {
|
|
|
63
63
|
'gemini': `Gemini (30 voices: Kore, Puck, Zephyr, Charon, Fenrir, Leda, Orus, Aoede, and 22 more). Multi-speaker (up to 2) and 60+ languages`,
|
|
64
64
|
};
|
|
65
65
|
export const VIDEO_ASPECT_RATIOS = `16:9 (landscape), 9:16 (portrait), 1:1 (square)`;
|
|
66
|
-
export const VIDEO_MODELS_DOC = `veo-3.1-generate-preview (best quality, ~$0.40/sec), veo-3.1-fast-generate-preview (faster, ~$0.
|
|
67
|
-
export const VIDEO_MODELS_LIST = `veo-3.1-generate-preview, veo-3.1-fast-generate-preview, veo-3.1-lite-generate-preview`;
|
|
66
|
+
export const VIDEO_MODELS_DOC = `veo-3.1-generate-preview (best quality, up to 4K, ~$0.40/sec), veo-3.1-fast-generate-preview (faster, ~$0.10/sec), veo-3.1-lite-generate-preview (budget, ~$0.05/sec), gemini-omni-flash-preview (3-10s @ 720p, conversational editing, ~$0.10/sec)`;
|
|
67
|
+
export const VIDEO_MODELS_LIST = `veo-3.1-generate-preview, veo-3.1-fast-generate-preview, veo-3.1-lite-generate-preview, gemini-omni-flash-preview`;
|
|
68
68
|
export const VIDEO_RESOLUTIONS = `720p, 1080p, 4k`;
|
|
69
69
|
//# sourceMappingURL=provider-docs.js.map
|
package/dist/relay/daemon.js
CHANGED
|
@@ -12,6 +12,7 @@ import { post } from '../api.js';
|
|
|
12
12
|
import * as state from './state.js';
|
|
13
13
|
import { createLineSplitter, parseEvent, mapEventToEntries, } from './stream-json.js';
|
|
14
14
|
import { IngestQueue } from './ingest-queue.js';
|
|
15
|
+
import { ImageBlockRewriter } from './media-upload.js';
|
|
15
16
|
import { DeltaAccumulator, DeltaBatcher } from './stream-delta.js';
|
|
16
17
|
import { randomUUID } from 'crypto';
|
|
17
18
|
import { deviceFetch, bridgeAbort as bridgeAbortImpl } from './device-http.js';
|
|
@@ -875,7 +876,13 @@ async function handleDispatch(claimed) {
|
|
|
875
876
|
// retry instead of the old fire-and-forget loss on network errors.
|
|
876
877
|
// Daemon-authored entries get a random source_uuid so retried batches
|
|
877
878
|
// dedup server-side.
|
|
878
|
-
|
|
879
|
+
// The rewriter uploads any base64 image blocks (Read-of-screenshot
|
|
880
|
+
// results) to VFS and swaps in image_ref blocks before the batch posts —
|
|
881
|
+
// transcripts carry URLs, never image bytes. Retries re-enter it, which
|
|
882
|
+
// is safe: rewritten entries have nothing left to upload, and the server
|
|
883
|
+
// stores by content hash so a replayed upload dedups.
|
|
884
|
+
const rewriter = new ImageBlockRewriter(d.conversation_guid, (msg, meta) => log('warn', msg, { id: d.short_guid, ...meta }));
|
|
885
|
+
const queue = new IngestQueue(async (entries) => postIngest(d.conversation_guid, await rewriter.rewrite(entries)), { onWarn: (msg, meta) => log('warn', msg, { id: d.short_guid, ...meta }) });
|
|
879
886
|
const pushSystem = (content) => {
|
|
880
887
|
queue.push({ kind: 'system', content, ts: new Date().toISOString(), source_uuid: randomUUID() });
|
|
881
888
|
};
|
|
@@ -886,6 +893,9 @@ async function handleDispatch(claimed) {
|
|
|
886
893
|
let bootstrapped;
|
|
887
894
|
try {
|
|
888
895
|
({ cwd, bootstrapped } = await resolveCwdForProject(d));
|
|
896
|
+
// Lets the rewriter map an absolute Read path (…/screenshots/x.png)
|
|
897
|
+
// to its project-relative VFS path for content-hash dedup.
|
|
898
|
+
rewriter.setCwd(cwd);
|
|
889
899
|
log('debug', 'resolved project cwd', { id: d.short_guid, project: d.project_slug, cwd, bootstrapped });
|
|
890
900
|
}
|
|
891
901
|
catch (err) {
|
|
@@ -1583,7 +1593,11 @@ export async function spawnGipityClaude(args, cwd, d, queue, meta) {
|
|
|
1583
1593
|
// no longer drops stream content permanently (source_uuid dedup makes
|
|
1584
1594
|
// the retries safe). Falls back to a local queue when the caller
|
|
1585
1595
|
// didn't pass one (tests, direct invocation).
|
|
1586
|
-
const q = queue ??
|
|
1596
|
+
const q = queue ?? (() => {
|
|
1597
|
+
const rw = new ImageBlockRewriter(d.conversation_guid, (msg, meta) => log('warn', msg, { id: d.short_guid, ...meta }));
|
|
1598
|
+
rw.setCwd(cwd);
|
|
1599
|
+
return new IngestQueue(async (entries) => postIngest(d.conversation_guid, await rw.rewrite(entries)), { onWarn: (msg, meta) => log('warn', msg, { id: d.short_guid, ...meta }) });
|
|
1600
|
+
})();
|
|
1587
1601
|
const ownQueue = !queue;
|
|
1588
1602
|
// Progress heartbeat state. Measured at the daemon boundary - this is
|
|
1589
1603
|
// what we actually observe, not anything the child self-reports. These
|
|
@@ -31,6 +31,28 @@ export function bridgeAbort(outer, inner) {
|
|
|
31
31
|
outer.addEventListener('abort', onAbort, { once: true });
|
|
32
32
|
return () => outer.removeEventListener('abort', onAbort);
|
|
33
33
|
}
|
|
34
|
+
/** Device-auth binary POST — raw bytes body (no JSON/base64 wrapping).
|
|
35
|
+
* Used by the transcript-media upload, where the whole point is that the
|
|
36
|
+
* image travels as bytes instead of base64. */
|
|
37
|
+
export async function deviceFetchBinary(method, path, body, contentType, timeoutMs) {
|
|
38
|
+
const controller = timeoutMs ? new AbortController() : null;
|
|
39
|
+
const timer = controller ? setTimeout(() => controller.abort('timeout'), timeoutMs) : null;
|
|
40
|
+
try {
|
|
41
|
+
return await fetch(`${apiBase()}${path}`, {
|
|
42
|
+
method,
|
|
43
|
+
headers: {
|
|
44
|
+
'Authorization': `Bearer ${deviceToken()}`,
|
|
45
|
+
'Content-Type': contentType,
|
|
46
|
+
},
|
|
47
|
+
body: new Uint8Array(body),
|
|
48
|
+
signal: controller?.signal,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
if (timer)
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
34
56
|
/** Device-auth HTTP helper. Sends `Authorization: Bearer <device-token>`
|
|
35
57
|
* + `Content-Type: application/json` to the configured API base. */
|
|
36
58
|
export async function deviceFetch(method, path, body, timeoutMs, signal) {
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript image rewrite — the relay's "no inline base64" chokepoint.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code's `Read` of an image file (screenshots, generated art, …)
|
|
5
|
+
* returns the bytes as a `{type:'image', source:{type:'base64', data}}`
|
|
6
|
+
* block inside the tool_result. Shipping that through ingest bloats the
|
|
7
|
+
* transcript DB and, worse, blows the server's 200 KB per-entry cap so
|
|
8
|
+
* real screenshots silently never reach the web client at all.
|
|
9
|
+
*
|
|
10
|
+
* `ImageBlockRewriter` sits in front of the ingest POST: it uploads each
|
|
11
|
+
* base64 image block's bytes to `POST /remote-sessions/:convGuid/media`
|
|
12
|
+
* (raw binary body) and swaps the block for a small `image_ref` pointing
|
|
13
|
+
* at the stored VFS file. The server dedups by content hash — a `Read` of
|
|
14
|
+
* an already-synced screenshot references the existing node.
|
|
15
|
+
*
|
|
16
|
+
* Failure handling: an upload that fails leaves small images inline
|
|
17
|
+
* (status-quo rendering still works) but replaces oversize ones with a
|
|
18
|
+
* text stub so one bad image can't poison the whole ingest batch. The
|
|
19
|
+
* IngestQueue retries a failed batch through this rewriter again, and the
|
|
20
|
+
* server's content-addressed storage makes replayed uploads idempotent.
|
|
21
|
+
*/
|
|
22
|
+
import path from 'path';
|
|
23
|
+
import { deviceFetchBinary } from './device-http.js';
|
|
24
|
+
/** Below this the upload round-trip outweighs the base64: tiny images
|
|
25
|
+
* (icons, favicons) stay inline. */
|
|
26
|
+
const MIN_UPLOAD_BYTES = 4 * 1024;
|
|
27
|
+
/** Server-side cap (TRANSCRIPT_MEDIA_MAX_BYTES). Larger images can't be
|
|
28
|
+
* stored; they get stubbed rather than shipped. */
|
|
29
|
+
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
30
|
+
/** Base64 payloads above this would break the server's 200 KB tool_result
|
|
31
|
+
* cap if left inline — when the upload fails, stub instead of poisoning
|
|
32
|
+
* the batch. Leaves headroom for the rest of the tool_result JSON. */
|
|
33
|
+
const INLINE_SAFE_B64_CHARS = 120_000;
|
|
34
|
+
/** How many tool_use → file_path pairs to remember for captioning/dedup. */
|
|
35
|
+
const PATH_MAP_CAP = 500;
|
|
36
|
+
/** Default uploader: raw-binary POST to the transcript-media endpoint. */
|
|
37
|
+
export const uploadViaDevice = async (convGuid, buf, opts) => {
|
|
38
|
+
const qs = new URLSearchParams({ filename: opts.filename, media_type: opts.mediaType });
|
|
39
|
+
if (opts.suggestedPath)
|
|
40
|
+
qs.set('suggested_path', opts.suggestedPath);
|
|
41
|
+
const res = await deviceFetchBinary('POST', `/remote-sessions/${encodeURIComponent(convGuid)}/media?${qs.toString()}`, buf, opts.mediaType, 30_000);
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
const body = await res.text().catch(() => '');
|
|
44
|
+
throw new Error(`HTTP ${res.status} ${body.slice(0, 120)}`);
|
|
45
|
+
}
|
|
46
|
+
const json = await res.json();
|
|
47
|
+
return json.data;
|
|
48
|
+
};
|
|
49
|
+
function extForMime(mime) {
|
|
50
|
+
switch (mime) {
|
|
51
|
+
case 'image/jpeg': return '.jpg';
|
|
52
|
+
case 'image/gif': return '.gif';
|
|
53
|
+
case 'image/webp': return '.webp';
|
|
54
|
+
case 'image/svg+xml': return '.svg';
|
|
55
|
+
default: return '.png';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function isBase64ImageBlock(b) {
|
|
59
|
+
return b?.type === 'image'
|
|
60
|
+
&& b.source?.type === 'base64'
|
|
61
|
+
&& typeof b.source.data === 'string'
|
|
62
|
+
&& b.source.data.length > 0;
|
|
63
|
+
}
|
|
64
|
+
export class ImageBlockRewriter {
|
|
65
|
+
convGuid;
|
|
66
|
+
onWarn;
|
|
67
|
+
upload;
|
|
68
|
+
/** tool_use_id → the file_path the tool was asked to read (for naming +
|
|
69
|
+
* server-side dedup against the synced node at that path). */
|
|
70
|
+
pathsByToolUseId = new Map();
|
|
71
|
+
/** Project root on this machine, once the dispatch resolves it — lets an
|
|
72
|
+
* absolute Read path map to its project-relative VFS path. */
|
|
73
|
+
cwd = null;
|
|
74
|
+
constructor(convGuid, onWarn, upload = uploadViaDevice) {
|
|
75
|
+
this.convGuid = convGuid;
|
|
76
|
+
this.onWarn = onWarn;
|
|
77
|
+
this.upload = upload;
|
|
78
|
+
}
|
|
79
|
+
setCwd(cwd) {
|
|
80
|
+
this.cwd = cwd;
|
|
81
|
+
}
|
|
82
|
+
/** Rewrite every base64 image block in the batch to an image_ref. Never
|
|
83
|
+
* throws — per-image failures degrade per the policy above. */
|
|
84
|
+
async rewrite(entries) {
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
if (entry.kind === 'tool_use') {
|
|
88
|
+
this.recordToolPath(entry);
|
|
89
|
+
out.push(entry);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (entry.kind !== 'tool_result' || !Array.isArray(entry.content) || typeof entry.tool_use_id !== 'string') {
|
|
93
|
+
out.push(entry);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const blocks = [];
|
|
97
|
+
for (const block of entry.content) {
|
|
98
|
+
blocks.push(isBase64ImageBlock(block)
|
|
99
|
+
? await this.rewriteBlock(block, entry.tool_use_id)
|
|
100
|
+
: block);
|
|
101
|
+
}
|
|
102
|
+
out.push({ ...entry, content: blocks });
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
recordToolPath(entry) {
|
|
107
|
+
if (typeof entry.tool_use_id !== 'string')
|
|
108
|
+
return;
|
|
109
|
+
const input = entry.tool_input;
|
|
110
|
+
const p = input && typeof input === 'object'
|
|
111
|
+
? (input.file_path ?? input.path ?? input.notebook_path)
|
|
112
|
+
: undefined;
|
|
113
|
+
if (typeof p !== 'string' || !p)
|
|
114
|
+
return;
|
|
115
|
+
if (this.pathsByToolUseId.size >= PATH_MAP_CAP) {
|
|
116
|
+
const oldest = this.pathsByToolUseId.keys().next().value;
|
|
117
|
+
if (oldest !== undefined)
|
|
118
|
+
this.pathsByToolUseId.delete(oldest);
|
|
119
|
+
}
|
|
120
|
+
this.pathsByToolUseId.set(entry.tool_use_id, p);
|
|
121
|
+
}
|
|
122
|
+
async rewriteBlock(block, toolUseId) {
|
|
123
|
+
const b64 = block.source.data;
|
|
124
|
+
let buf;
|
|
125
|
+
try {
|
|
126
|
+
buf = Buffer.from(b64, 'base64');
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return block; // not decodable — leave as-is
|
|
130
|
+
}
|
|
131
|
+
if (buf.length < MIN_UPLOAD_BYTES)
|
|
132
|
+
return block;
|
|
133
|
+
const mediaType = block.source.media_type || 'image/png';
|
|
134
|
+
const sourcePath = this.pathsByToolUseId.get(toolUseId);
|
|
135
|
+
const filename = sourcePath ? path.basename(sourcePath) : `image${extForMime(mediaType)}`;
|
|
136
|
+
const suggested = this.projectRelative(sourcePath);
|
|
137
|
+
if (buf.length > MAX_UPLOAD_BYTES) {
|
|
138
|
+
this.onWarn?.('transcript image exceeds upload cap - stubbed', { bytes: buf.length, filename });
|
|
139
|
+
return this.stub(filename, buf.length, 'too large to store');
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const ref = await this.upload(this.convGuid, buf, {
|
|
143
|
+
filename, mediaType, ...(suggested ? { suggestedPath: suggested } : {}),
|
|
144
|
+
});
|
|
145
|
+
return {
|
|
146
|
+
type: 'image_ref',
|
|
147
|
+
url: ref.url,
|
|
148
|
+
...(ref.thumb_url ? { thumb_url: ref.thumb_url } : {}),
|
|
149
|
+
media_type: mediaType,
|
|
150
|
+
path: ref.path,
|
|
151
|
+
...(ref.width != null ? { width: ref.width } : {}),
|
|
152
|
+
...(ref.height != null ? { height: ref.height } : {}),
|
|
153
|
+
bytes: ref.bytes,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
this.onWarn?.('transcript image upload failed', { filename, err: err?.message });
|
|
158
|
+
// Small enough to survive the ingest cap inline → keep the original
|
|
159
|
+
// (renders exactly as before this feature). Oversize → stub, because
|
|
160
|
+
// shipping it would 400 the entire batch and lose sibling entries.
|
|
161
|
+
if (b64.length <= INLINE_SAFE_B64_CHARS)
|
|
162
|
+
return block;
|
|
163
|
+
return this.stub(filename, buf.length, `upload failed: ${err?.message || 'unknown error'}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
stub(filename, bytes, reason) {
|
|
167
|
+
return {
|
|
168
|
+
type: 'text',
|
|
169
|
+
text: `[image ${filename} (${Math.round(bytes / 1024)} KB) omitted: ${reason}]`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/** Map an absolute local path to its project-relative VFS path, when it
|
|
173
|
+
* falls inside the dispatch's project root. */
|
|
174
|
+
projectRelative(p) {
|
|
175
|
+
if (!p || !this.cwd)
|
|
176
|
+
return undefined;
|
|
177
|
+
const abs = path.resolve(p);
|
|
178
|
+
const root = path.resolve(this.cwd);
|
|
179
|
+
if (!abs.startsWith(root + path.sep))
|
|
180
|
+
return undefined;
|
|
181
|
+
return abs.slice(root.length + 1).split(path.sep).join('/');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
//# sourceMappingURL=media-upload.js.map
|
package/dist/setup.js
CHANGED
|
@@ -50,7 +50,10 @@ export const AIDER_CONF_FILE = '.aider.conf.yml';
|
|
|
50
50
|
* scratch (the `_vsd_tmp/`/`_convert_tmp/` dirs that bloated past deploys)
|
|
51
51
|
* can't leak in either. Reference material to KEEP (diagrams, decks, ADRs) goes
|
|
52
52
|
* in `docs/` instead - synced and versioned, but outside `src/` so it's never
|
|
53
|
-
* deployed.
|
|
53
|
+
* deployed. Build screenshots follow the same keep-but-don't-deploy pattern in
|
|
54
|
+
* `screenshots/` (page-screenshot.ts writes there; the server excludes both
|
|
55
|
+
* dirs from root deploys in s3-deploy.ts). Gitignore-glob form, matched by the
|
|
56
|
+
* `ignore` package in config.ts. */
|
|
54
57
|
export const SCRATCH_IGNORE = ['tmp/', '.tmp/', '*_tmp/', '.gipityscratch/'];
|
|
55
58
|
export const DEFAULT_SYNC_IGNORE = [
|
|
56
59
|
'node_modules', '.git', '.gipity.json', '.gipity/', '.claude/', '.gitignore', AIDER_CONF_FILE,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gipity",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.429",
|
|
4
4
|
"description": "The full-stack platform tuned for AI agents. Database, storage, auth, functions, deploy, and drop-in kits - all agent-tuned. Pair with Claude Code or use standalone.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"gipity": "dist/updater/shim.js",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"prepack": "npm run build",
|
|
15
15
|
"dev": "tsc --watch",
|
|
16
16
|
"test": "npm run test:smoke",
|
|
17
|
-
"test:smoke": "
|
|
17
|
+
"test:smoke": "npm run build && node --test dist/__tests__/utils.test.js dist/__tests__/platform.test.js dist/__tests__/colors.test.js dist/__tests__/config.test.js dist/__tests__/sync.test.js dist/__tests__/sync-apply.test.js dist/__tests__/sync-unretrievable.test.js dist/__tests__/sync-clean-check.test.js dist/__tests__/sync-lock.test.js dist/__tests__/auth-lock.test.js dist/__tests__/api-401-retry.test.js dist/__tests__/push-cas.test.js dist/__tests__/upload.test.js dist/__tests__/progress.test.js dist/__tests__/updater.test.js dist/__tests__/cli-smoke.test.js dist/__tests__/claude-noninteractive.test.js dist/__tests__/claude-trust.test.js dist/__tests__/relay-state.test.js dist/__tests__/relay-daemon.test.js dist/__tests__/session-pool.test.js dist/__tests__/relay-diagnostics.test.js dist/__tests__/relay-installers.test.js dist/__tests__/relay-bridge-abort.test.js dist/__tests__/relay-redact.test.js dist/__tests__/relay-machine-id.test.js dist/__tests__/stream-json.test.js dist/__tests__/ingest-queue.test.js dist/__tests__/media-upload.test.js dist/__tests__/stream-delta.test.js dist/__tests__/phase-tracker.test.js dist/__tests__/relay-ingest-contract.test.js dist/__tests__/prompts.test.js dist/__tests__/capture-transcript.test.js dist/__tests__/capture-lock.test.js dist/__tests__/capture-resolve.test.js dist/__tests__/flag-aliases.test.js dist/__tests__/client-context.test.js dist/__tests__/adopt-cwd.test.js dist/__tests__/cli-cmd-agent.test.js dist/__tests__/cli-cmd-approval.test.js dist/__tests__/cli-cmd-audit.test.js dist/__tests__/cli-cmd-bug.test.js dist/__tests__/cli-cmd-bug-queue.test.js dist/__tests__/cli-cmd-chat.test.js dist/__tests__/cli-cmd-credits.test.js dist/__tests__/cli-cmd-db.test.js dist/__tests__/cli-cmd-deploy.test.js dist/__tests__/cli-cmd-domain.test.js dist/__tests__/cli-cmd-email.test.js dist/__tests__/cli-cmd-file.test.js dist/__tests__/cli-cmd-storage.test.js dist/__tests__/cli-cmd-fn.test.js dist/__tests__/cli-cmd-service.test.js dist/__tests__/cli-cmd-job.test.js dist/__tests__/cli-cmd-generate.test.js dist/__tests__/cli-cmd-gmail.test.js dist/__tests__/cli-cmd-info.test.js dist/__tests__/cli-cmd-init.test.js dist/__tests__/cli-cmd-location.test.js dist/__tests__/cli-cmd-text.test.js dist/__tests__/cli-cmd-login.test.js dist/__tests__/cli-cmd-logout.test.js dist/__tests__/cli-cmd-token.test.js dist/__tests__/cli-cmd-logs.test.js dist/__tests__/cli-cmd-memory.test.js dist/__tests__/cli-cmd-page.test.js dist/__tests__/cli-cmd-plan.test.js dist/__tests__/cli-cmd-project.test.js dist/__tests__/cli-cmd-rbac.test.js dist/__tests__/cli-cmd-realtime.test.js dist/__tests__/cli-cmd-records.test.js dist/__tests__/cli-cmd-relay.test.js dist/__tests__/cli-cmd-setup.test.js dist/__tests__/cli-cmd-doctor.test.js dist/__tests__/claude-setup.test.js dist/__tests__/cli-cmd-sandbox.test.js dist/__tests__/cli-cmd-add.test.js dist/__tests__/cli-cmd-remove.test.js dist/__tests__/cli-cmd-skill.test.js dist/__tests__/cli-cmd-test.test.js dist/__tests__/cli-cmd-workflow.test.js dist/__tests__/setup-skills-block.test.js dist/__tests__/setup-hooks.test.js dist/__tests__/trace.test.js",
|
|
18
18
|
"test:smoke:quick": "node scripts/smoke-quick.mjs",
|
|
19
19
|
"test:e2e": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-live.test.js dist/__tests__/cli-e2e-sync-live.test.js dist/__tests__/cli-e2e-sync-stress-live.test.js dist/__tests__/cli-e2e-rollback-live.test.js dist/__tests__/cli-e2e-services-media-live.test.js dist/__tests__/cli-e2e-workflow-live.test.js dist/__tests__/cli-e2e-sandbox-live.test.js dist/__tests__/cli-e2e-page-fetch-live.test.js dist/__tests__/cli-e2e-page-test-live.test.js",
|
|
20
20
|
"test:e2e:sync": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-sync-live.test.js",
|