gipity 1.0.356 → 1.0.365
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/capture/sources/claude-code.js +76 -11
- package/dist/commands/add.js +44 -27
- package/dist/commands/generate.js +16 -13
- package/dist/commands/page-eval.js +42 -3
- package/dist/commands/page-inspect.js +32 -4
- package/dist/commands/page-screenshot.js +34 -23
- package/dist/commands/page-test.js +86 -0
- package/dist/commands/page.js +5 -3
- package/dist/commands/service.js +56 -0
- package/dist/commands/skill.js +2 -1
- package/dist/commands/text.js +148 -0
- package/dist/commands/workflow.js +10 -14
- package/dist/config.js +8 -1
- package/dist/help-skills.js +1 -0
- package/dist/helpers/output.js +7 -1
- package/dist/helpers/text-analysis.js +200 -0
- package/dist/hooks/capture-runner.js +32 -8
- package/dist/index.js +31 -2
- package/dist/knowledge.js +13 -3
- package/dist/relay/daemon.js +11 -1
- package/dist/relay/stream-json.js +45 -8
- package/dist/setup.js +18 -4
- package/dist/sync.js +14 -2
- package/package.json +3 -3
|
@@ -24,6 +24,25 @@
|
|
|
24
24
|
* so retried POSTs are deduplicated by the partial unique index on
|
|
25
25
|
* messages(conversation_id, source_uuid).
|
|
26
26
|
*/
|
|
27
|
+
/** Pull token usage + model + stop_reason off an assistant `message` object.
|
|
28
|
+
* Same shape in the transcript JSONL and the stream-json assistant event, so
|
|
29
|
+
* both capture paths share this. Only includes keys that are actually present
|
|
30
|
+
* (so they spread cleanly onto an assistant entry without writing nulls).
|
|
31
|
+
* Cost is NOT here — it doesn't exist per-message; only the stream `result`
|
|
32
|
+
* footer reports a session total. */
|
|
33
|
+
export function usageFields(msg) {
|
|
34
|
+
const out = {};
|
|
35
|
+
const u = msg?.usage;
|
|
36
|
+
if (u && typeof u.input_tokens === 'number')
|
|
37
|
+
out.input_tokens = u.input_tokens;
|
|
38
|
+
if (u && typeof u.output_tokens === 'number')
|
|
39
|
+
out.output_tokens = u.output_tokens;
|
|
40
|
+
if (typeof msg?.model === 'string')
|
|
41
|
+
out.model = msg.model;
|
|
42
|
+
if (typeof msg?.stop_reason === 'string')
|
|
43
|
+
out.stop_reason = msg.stop_reason;
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
27
46
|
/** Extract joined `{type:'text', text}` blocks into a single string.
|
|
28
47
|
* The full blocks array is emitted separately so the client can render
|
|
29
48
|
* text + tool_use in the agent's original narrative order. */
|
|
@@ -37,8 +56,38 @@ function joinText(content) {
|
|
|
37
56
|
}
|
|
38
57
|
return parts.join('\n');
|
|
39
58
|
}
|
|
40
|
-
/**
|
|
41
|
-
|
|
59
|
+
/** Stamp a unique `source_uuid` on every entry parsed from one transcript line.
|
|
60
|
+
*
|
|
61
|
+
* One transcript line yields many entries (an assistant line emits its text
|
|
62
|
+
* entry PLUS one tool_use entry per tool call). The server dedupes ingest on
|
|
63
|
+
* the partial unique index messages(conversation_id, source_uuid) with
|
|
64
|
+
* ON CONFLICT DO NOTHING. If all siblings shared the bare `line.uuid`, the
|
|
65
|
+
* first entry (the assistant text) would claim the row and every subsequent
|
|
66
|
+
* tool_use would be silently dropped - leaving the later tool_result to land
|
|
67
|
+
* as a name-less stub. That bug made 100% of terminal-session tool calls lose
|
|
68
|
+
* their tool_name.
|
|
69
|
+
*
|
|
70
|
+
* The primary entry keeps the bare line uuid (so it still dedupes against rows
|
|
71
|
+
* written before this fix); each sibling gets a deterministic `#N` suffix.
|
|
72
|
+
* Determinism matters: a retried POST re-parses the same line in the same
|
|
73
|
+
* order, producing identical suffixes, so dedup still collapses retries. */
|
|
74
|
+
function stampEntries(entries, lineUuid, lineTs) {
|
|
75
|
+
return entries.map((e, i) => ({
|
|
76
|
+
...e,
|
|
77
|
+
source_uuid: i === 0 ? lineUuid : `${lineUuid}#${i}`,
|
|
78
|
+
...(lineTs ? { ts: lineTs } : {}),
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
/** Map a single parsed transcript line to zero or more ingest entries.
|
|
82
|
+
*
|
|
83
|
+
* `toolNames` (optional) is a per-transcript `tool_use_id → tool_name`
|
|
84
|
+
* map threaded across lines by `parseTranscript`: an assistant line
|
|
85
|
+
* records each tool call's name into it, and the later user line that
|
|
86
|
+
* carries the paired `tool_result` reads the name back out. This lets
|
|
87
|
+
* the server denormalize `tool_name` onto the tool row even when the
|
|
88
|
+
* result lands as a stub (the tool_use row missing/deduped). The
|
|
89
|
+
* `tool_result` block itself never carries the name. */
|
|
90
|
+
export function transcriptLineToEntries(line, toolNames) {
|
|
42
91
|
if (!line || typeof line !== 'object')
|
|
43
92
|
return [];
|
|
44
93
|
if (typeof line.type !== 'string')
|
|
@@ -54,13 +103,14 @@ export function transcriptLineToEntries(line) {
|
|
|
54
103
|
if (line.toolUseResult && !line.message)
|
|
55
104
|
return [];
|
|
56
105
|
const srcUuid = line.uuid;
|
|
106
|
+
const lineTs = typeof line.timestamp === 'string' ? line.timestamp : undefined;
|
|
57
107
|
const msg = line.message ?? {};
|
|
58
108
|
if (line.type === 'user') {
|
|
59
109
|
const content = msg.content;
|
|
60
110
|
if (typeof content === 'string') {
|
|
61
111
|
if (!content)
|
|
62
112
|
return [];
|
|
63
|
-
return [{ kind: 'prompt', prompt: content,
|
|
113
|
+
return stampEntries([{ kind: 'prompt', prompt: content }], srcUuid, lineTs);
|
|
64
114
|
}
|
|
65
115
|
if (Array.isArray(content)) {
|
|
66
116
|
const out = [];
|
|
@@ -69,18 +119,18 @@ export function transcriptLineToEntries(line) {
|
|
|
69
119
|
out.push({
|
|
70
120
|
kind: 'tool_result',
|
|
71
121
|
tool_use_id: b.tool_use_id,
|
|
122
|
+
tool_name: toolNames?.get(b.tool_use_id),
|
|
72
123
|
content: b.content ?? null,
|
|
73
124
|
is_error: Boolean(b.is_error),
|
|
74
|
-
source_uuid: srcUuid,
|
|
75
125
|
});
|
|
76
126
|
}
|
|
77
127
|
else if (b?.type === 'text' && typeof b.text === 'string' && b.text) {
|
|
78
128
|
// A user message with raw text blocks (rare - first-turn preamble
|
|
79
129
|
// is sometimes split this way). Treat like a prompt.
|
|
80
|
-
out.push({ kind: 'prompt', prompt: b.text
|
|
130
|
+
out.push({ kind: 'prompt', prompt: b.text });
|
|
81
131
|
}
|
|
82
132
|
}
|
|
83
|
-
return out;
|
|
133
|
+
return stampEntries(out, srcUuid, lineTs);
|
|
84
134
|
}
|
|
85
135
|
return [];
|
|
86
136
|
}
|
|
@@ -89,20 +139,21 @@ export function transcriptLineToEntries(line) {
|
|
|
89
139
|
const text = joinText(content);
|
|
90
140
|
const out = [];
|
|
91
141
|
if (text || content.length) {
|
|
92
|
-
out.push({ kind: 'assistant', text, blocks: content,
|
|
142
|
+
out.push({ kind: 'assistant', text, blocks: content, ...usageFields(msg) });
|
|
93
143
|
}
|
|
94
144
|
for (const block of content) {
|
|
95
145
|
if (block?.type === 'tool_use' && typeof block.id === 'string') {
|
|
146
|
+
const toolName = typeof block.name === 'string' ? block.name : 'tool';
|
|
147
|
+
toolNames?.set(block.id, toolName);
|
|
96
148
|
out.push({
|
|
97
149
|
kind: 'tool_use',
|
|
98
150
|
tool_use_id: block.id,
|
|
99
|
-
tool_name:
|
|
151
|
+
tool_name: toolName,
|
|
100
152
|
tool_input: block.input ?? null,
|
|
101
|
-
source_uuid: srcUuid,
|
|
102
153
|
});
|
|
103
154
|
}
|
|
104
155
|
}
|
|
105
|
-
return out;
|
|
156
|
+
return stampEntries(out, srcUuid, lineTs);
|
|
106
157
|
}
|
|
107
158
|
// Other envelope types (system notes, hook-emitted, …) - not captured.
|
|
108
159
|
return [];
|
|
@@ -120,6 +171,11 @@ export function parseTranscript(content, afterUuid) {
|
|
|
120
171
|
let seenWatermark = afterUuid === null;
|
|
121
172
|
let lastUuid = afterUuid;
|
|
122
173
|
const out = [];
|
|
174
|
+
// tool_use_id → tool_name, accumulated across lines so a later
|
|
175
|
+
// tool_result can be denormalized with its tool's name. Built from the
|
|
176
|
+
// whole file (including pre-watermark lines) so a result whose tool_use
|
|
177
|
+
// was forwarded in an earlier sweep still resolves its name.
|
|
178
|
+
const toolNames = new Map();
|
|
123
179
|
for (const raw of content.split('\n')) {
|
|
124
180
|
const line = raw.trim();
|
|
125
181
|
if (!line)
|
|
@@ -131,12 +187,21 @@ export function parseTranscript(content, afterUuid) {
|
|
|
131
187
|
catch {
|
|
132
188
|
continue;
|
|
133
189
|
}
|
|
190
|
+
// Record tool names from every assistant line we scan, even before the
|
|
191
|
+
// watermark - the map is read-only side state, it doesn't emit entries.
|
|
134
192
|
if (!seenWatermark) {
|
|
193
|
+
if (Array.isArray(parsed?.message?.content)) {
|
|
194
|
+
for (const block of parsed.message.content) {
|
|
195
|
+
if (block?.type === 'tool_use' && typeof block.id === 'string') {
|
|
196
|
+
toolNames.set(block.id, typeof block.name === 'string' ? block.name : 'tool');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
135
200
|
if (parsed?.uuid === afterUuid)
|
|
136
201
|
seenWatermark = true;
|
|
137
202
|
continue;
|
|
138
203
|
}
|
|
139
|
-
const entries = transcriptLineToEntries(parsed);
|
|
204
|
+
const entries = transcriptLineToEntries(parsed, toolNames);
|
|
140
205
|
if (entries.length) {
|
|
141
206
|
for (const e of entries)
|
|
142
207
|
out.push(e);
|
package/dist/commands/add.js
CHANGED
|
@@ -7,35 +7,38 @@ import { requireConfig } from '../config.js';
|
|
|
7
7
|
import { sync } from '../sync.js';
|
|
8
8
|
import { success, muted, bold } from '../colors.js';
|
|
9
9
|
import { run } from '../helpers/index.js';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
10
|
+
const STARTERS = [
|
|
11
|
+
{ key: 'web-fullstack', hint: 'backend API + database (weather-by-zip demo)' },
|
|
12
|
+
{ key: 'web-vision-cam', hint: 'fullscreen camera app with on-device vision (MediaPipe)' },
|
|
13
|
+
{ key: '2d-game', hint: '2D games with Phaser 3 - platformer, arcade, puzzle' },
|
|
14
|
+
{ key: '3d-world', hint: 'playable 3D multiplayer rocket-launcher demo' },
|
|
15
|
+
{ key: 'api', hint: 'pure API backend, no frontend' },
|
|
16
|
+
];
|
|
17
|
+
const BLANK = [
|
|
18
|
+
{ key: 'web-simple', hint: 'static frontend-only site - pages, dashboards, simple games' },
|
|
19
|
+
{ key: '3d-engine', hint: '3D multiplayer wiring - Three.js + Rapier + Gipity Realtime' },
|
|
20
|
+
];
|
|
18
21
|
const HIDDEN = [{ key: 'app-itsm', hint: 'IT service management / helpdesk / ticketing' }];
|
|
19
22
|
const KITS = [
|
|
20
23
|
{ key: 'realtime', hint: 'multiplayer / presence / shared state' },
|
|
21
24
|
{ key: 'web-vision-mediapipe', hint: 'browser camera vision - gesture, pose, object detection' },
|
|
25
|
+
{ key: 'i18n', hint: 'multi-language web apps - language picker, RTL, translations' },
|
|
22
26
|
];
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
console.log('');
|
|
27
|
+
// The catalog block, rendered once and reused by the full help output
|
|
28
|
+
// (`gipity add` / `gipity add --help`) and the bare listing (`gipity add
|
|
29
|
+
// --list`) so they can never drift. Three sections, one entry per line, keys
|
|
30
|
+
// column-aligned. No leading/trailing blank lines - callers add surrounding
|
|
31
|
+
// whitespace.
|
|
32
|
+
function catalogText() {
|
|
33
|
+
const width = Math.max(...[...STARTERS, ...BLANK, ...KITS].map(e => e.key.length));
|
|
34
|
+
const row = (e) => ` ${e.key.padEnd(width)} ${muted(e.hint)}`;
|
|
35
|
+
const section = (title, blurb, entries) => [`${bold(title)} ${muted('- ' + blurb)}`, ...entries.map(row)].join('\n');
|
|
36
|
+
return [
|
|
37
|
+
'Names to pass to `gipity add <name>`:',
|
|
38
|
+
section('Templates (working demos)', 'complete apps to run, then extend or replace', STARTERS),
|
|
39
|
+
section('Templates (blank wiring)', 'minimal framework setup - build your app on top', BLANK),
|
|
40
|
+
section('Kits', 'building blocks to add into an app you already scaffolded', KITS),
|
|
41
|
+
].join('\n\n');
|
|
39
42
|
}
|
|
40
43
|
// ─── Local-path payload mode ────────────────────────────────────────────────
|
|
41
44
|
//
|
|
@@ -136,14 +139,28 @@ function buildLocalPayload(rootDir) {
|
|
|
136
139
|
}
|
|
137
140
|
export const addCommand = new Command('add')
|
|
138
141
|
.description('Add a template (scaffold an app) or a kit (reusable building block) to the project. Pass ./path/to/dir to install a local template directly.')
|
|
139
|
-
.argument('[name]', 'Template/kit key, OR a local directory path (./, ~/, or /abs). Omit
|
|
142
|
+
.argument('[name]', 'Template/kit key, OR a local directory path (./, ~/, or /abs). Omit for help; use --list for just the catalog.')
|
|
140
143
|
.option('--title <title>', 'App title - templates only (defaults to project name)')
|
|
141
144
|
.option('--description <desc>', 'App description for meta tags - templates only')
|
|
142
145
|
.option('--force', 'Templates only: overwrite any colliding files')
|
|
146
|
+
.option('--list', 'List the template/kit catalog and exit')
|
|
143
147
|
.option('--json', 'Output as JSON')
|
|
144
|
-
.
|
|
148
|
+
.addHelpText('after', () => catalogText() + '\n\n'
|
|
149
|
+
+ muted('Local path gipity add ./dir (or ~/path, /abs) - template or kit, auto-detected'))
|
|
150
|
+
.action((name, opts, command) => run('Add', async () => {
|
|
151
|
+
// `--list` is a bare catalog dump; no project/config needed.
|
|
152
|
+
if (opts.list) {
|
|
153
|
+
if (opts.json) {
|
|
154
|
+
console.log(JSON.stringify({ templates: { starters: STARTERS, blank: BLANK }, kits: KITS }));
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
console.log('\n' + catalogText() + '\n');
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// No name = show the full help (usage + options + catalog), same as --help.
|
|
145
162
|
if (!name) {
|
|
146
|
-
|
|
163
|
+
command.outputHelp();
|
|
147
164
|
return;
|
|
148
165
|
}
|
|
149
166
|
const config = requireConfig();
|
|
@@ -2,15 +2,18 @@ import { Command } from 'commander';
|
|
|
2
2
|
import { post } from '../api.js';
|
|
3
3
|
import { resolveProjectContext } from '../config.js';
|
|
4
4
|
import { writeFileSync } from 'fs';
|
|
5
|
+
import { resolve as resolvePath } from 'path';
|
|
5
6
|
import { error as clrError, success, muted, info } from '../colors.js';
|
|
6
7
|
import { IMAGE_MODELS_DOC, IMAGE_GEMINI_ASPECT_RATIOS, IMAGE_GEMINI_SIZES, VIDEO_MODELS_DOC, TTS_PROVIDER_DESCRIPTIONS } from '../provider-docs.js';
|
|
7
|
-
/** Download a URL and save to a local file
|
|
8
|
+
/** Download a URL and save to a local file. Returns the absolute path written,
|
|
9
|
+
* so callers can report exactly where the file landed. */
|
|
8
10
|
async function downloadFile(url, filename) {
|
|
9
11
|
const res = await fetch(url);
|
|
10
12
|
if (!res.ok)
|
|
11
13
|
throw new Error(`Download failed: ${res.status}`);
|
|
12
14
|
const buffer = Buffer.from(await res.arrayBuffer());
|
|
13
15
|
writeFileSync(filename, buffer);
|
|
16
|
+
return resolvePath(filename);
|
|
14
17
|
}
|
|
15
18
|
// ── IMAGE ──────────────────────────────────────────────────────────────
|
|
16
19
|
const imageCommand = new Command('image')
|
|
@@ -34,7 +37,7 @@ Examples:
|
|
|
34
37
|
.option('--quality <quality>', 'Quality: low|medium|high|auto (gpt-image-1), standard|hd (dall-e-3)')
|
|
35
38
|
.option('--aspect-ratio <ratio>', 'Aspect ratio (Gemini only): 1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3, 4:5, 5:4, 21:9')
|
|
36
39
|
.option('--image-size <size>', 'Output resolution (Gemini only): 512, 1K, 2K, 4K')
|
|
37
|
-
.option('-o, --output <file>', 'Output
|
|
40
|
+
.option('-o, --output <file>', 'Output path (default ./generated.png). For an image your app ships, write it into the source tree so it deploys, e.g. -o src/assets/images/hero.png; the cwd default is fine for one-off generation.')
|
|
38
41
|
.option('--json', 'Output as JSON')
|
|
39
42
|
.action(async (prompt, opts) => {
|
|
40
43
|
try {
|
|
@@ -50,14 +53,14 @@ Examples:
|
|
|
50
53
|
});
|
|
51
54
|
const ext = result.content_type.includes('png') ? 'png' : 'jpg';
|
|
52
55
|
const filename = opts.output || `generated.${ext}`;
|
|
53
|
-
await downloadFile(result.url, filename);
|
|
56
|
+
const savedPath = await downloadFile(result.url, filename);
|
|
54
57
|
if (opts.json) {
|
|
55
|
-
console.log(JSON.stringify({ ...result, saved:
|
|
58
|
+
console.log(JSON.stringify({ ...result, saved: savedPath }));
|
|
56
59
|
}
|
|
57
60
|
else {
|
|
58
61
|
const sizeKb = Math.round(result.size_bytes / 1024);
|
|
59
62
|
console.log(`${muted(`Generated with ${result.provider}/${result.model} (${sizeKb}KB)`)}`);
|
|
60
|
-
console.log(success(`Saved to ${
|
|
63
|
+
console.log(success(`Saved to ${savedPath}`));
|
|
61
64
|
}
|
|
62
65
|
}
|
|
63
66
|
catch (err) {
|
|
@@ -88,7 +91,7 @@ Examples:
|
|
|
88
91
|
.option('--model <model>', 'Veo model: veo-3.1-generate-preview (quality), veo-3.1-fast-generate-preview (speed), veo-3.1-lite-generate-preview (budget)')
|
|
89
92
|
.option('--aspect <ratio>', 'Aspect ratio: 16:9 (landscape), 9:16 (portrait), 1:1 (square)')
|
|
90
93
|
.option('--resolution <res>', 'Video resolution: 720p, 1080p, 4k')
|
|
91
|
-
.option('-o, --output <file>', 'Output
|
|
94
|
+
.option('-o, --output <file>', 'Output path (default ./generated.mp4). For a clip your app ships, write it into the source tree so it deploys, e.g. -o src/assets/video/clip.mp4; the cwd default is fine for one-off generation.')
|
|
92
95
|
.option('--json', 'Output as JSON')
|
|
93
96
|
.action(async (prompt, opts) => {
|
|
94
97
|
try {
|
|
@@ -101,14 +104,14 @@ Examples:
|
|
|
101
104
|
resolution: opts.resolution,
|
|
102
105
|
});
|
|
103
106
|
const filename = opts.output || 'generated.mp4';
|
|
104
|
-
await downloadFile(result.url, filename);
|
|
107
|
+
const savedPath = await downloadFile(result.url, filename);
|
|
105
108
|
if (opts.json) {
|
|
106
|
-
console.log(JSON.stringify({ ...result, saved:
|
|
109
|
+
console.log(JSON.stringify({ ...result, saved: savedPath }));
|
|
107
110
|
}
|
|
108
111
|
else {
|
|
109
112
|
const sizeKb = Math.round(result.size_bytes / 1024);
|
|
110
113
|
console.log(`${muted(`Generated with ${result.provider}/${result.model} (${sizeKb}KB)`)}`);
|
|
111
|
-
console.log(success(`Saved to ${
|
|
114
|
+
console.log(success(`Saved to ${savedPath}`));
|
|
112
115
|
}
|
|
113
116
|
}
|
|
114
117
|
catch (err) {
|
|
@@ -137,7 +140,7 @@ Examples:
|
|
|
137
140
|
.option('--voice <voice>', 'Voice ID or name (provider-specific)')
|
|
138
141
|
.option('--language <code>', 'BCP-47 language code, e.g. ja-JP, es-ES (Gemini only, 60+ languages)')
|
|
139
142
|
.option('--speakers <json>', 'Multi-speaker config as JSON array (Gemini only, up to 2 speakers)')
|
|
140
|
-
.option('-o, --output <file>', 'Output
|
|
143
|
+
.option('-o, --output <file>', 'Output path (default ./speech.mp3). For audio your app ships, write it into the source tree so it deploys, e.g. -o src/assets/sounds/intro.mp3; the cwd default is fine for one-off generation.')
|
|
141
144
|
.option('--json', 'Output as JSON')
|
|
142
145
|
.action(async (text, opts) => {
|
|
143
146
|
try {
|
|
@@ -160,14 +163,14 @@ Examples:
|
|
|
160
163
|
speakers,
|
|
161
164
|
});
|
|
162
165
|
const filename = opts.output || 'speech.mp3';
|
|
163
|
-
await downloadFile(result.url, filename);
|
|
166
|
+
const savedPath = await downloadFile(result.url, filename);
|
|
164
167
|
if (opts.json) {
|
|
165
|
-
console.log(JSON.stringify({ ...result, saved:
|
|
168
|
+
console.log(JSON.stringify({ ...result, saved: savedPath }));
|
|
166
169
|
}
|
|
167
170
|
else {
|
|
168
171
|
const sizeKb = Math.round(result.size_bytes / 1024);
|
|
169
172
|
console.log(`${muted(`Generated with ${result.provider} (${sizeKb}KB)`)}`);
|
|
170
|
-
console.log(success(`Saved to ${
|
|
173
|
+
console.log(success(`Saved to ${savedPath}`));
|
|
171
174
|
}
|
|
172
175
|
}
|
|
173
176
|
catch (err) {
|
|
@@ -1,7 +1,38 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { post } from '../api.js';
|
|
2
|
+
import { post, get, ApiError } from '../api.js';
|
|
3
3
|
import { brand, bold, muted } from '../colors.js';
|
|
4
4
|
import { run } from '../helpers/index.js';
|
|
5
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
6
|
+
/** Poll the async eval job until it finishes. Eval runs server-side as a
|
|
7
|
+
* short-lived job (so a long --wait can't trip the gateway idle timeout);
|
|
8
|
+
* we submit, then poll the result out of the job store. */
|
|
9
|
+
async function pollEvalResult(evalJobId, waitMs) {
|
|
10
|
+
// Generous client budget: the server work is bounded by --wait plus browser
|
|
11
|
+
// open/settle overhead; give it that plus headroom before giving up.
|
|
12
|
+
const deadline = Date.now() + waitMs + 60_000;
|
|
13
|
+
let missCount = 0;
|
|
14
|
+
while (Date.now() < deadline) {
|
|
15
|
+
let rec;
|
|
16
|
+
try {
|
|
17
|
+
rec = (await get(`/tools/browser/eval/${evalJobId}`)).data;
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
// A 404 right after submit can happen if the record hasn't landed yet;
|
|
21
|
+
// tolerate a few, then treat a persistent 404 as the job being gone.
|
|
22
|
+
if (err instanceof ApiError && err.statusCode === 404 && missCount++ < 3) {
|
|
23
|
+
await sleep(500);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
if (rec.status === 'done')
|
|
29
|
+
return rec;
|
|
30
|
+
if (rec.status === 'error')
|
|
31
|
+
throw new ApiError(rec.httpStatus, rec.code, rec.reason);
|
|
32
|
+
await sleep(1000);
|
|
33
|
+
}
|
|
34
|
+
throw new ApiError(504, 'EVAL_TIMEOUT', 'Eval did not finish in time; narrow the expression or lower --wait');
|
|
35
|
+
}
|
|
5
36
|
// The long-tail escape hatch alongside `page inspect`'s fixed bundle: when the
|
|
6
37
|
// curated metrics don't cover what you need (computed styles, element rects,
|
|
7
38
|
// visibility, z-index stacks), eval an expression in page context and get the
|
|
@@ -11,12 +42,20 @@ export const pageEvalCommand = new Command('eval')
|
|
|
11
42
|
.argument('<url>', 'URL to load')
|
|
12
43
|
.argument('<expr>', 'JavaScript expression to evaluate in page context (result is JSON-serialized)')
|
|
13
44
|
.option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before evaluating (lets late async work settle)', '500')
|
|
45
|
+
.option('--wait-for <selector>', 'Wait until this CSS selector appears before evaluating (deterministic; replaces --wait)')
|
|
46
|
+
.option('--wait-timeout <ms>', 'Max ms to wait for --wait-for before giving up', '5000')
|
|
14
47
|
.option('--json', 'Output as JSON')
|
|
15
48
|
.action((url, expr, opts) => run('Page eval', async () => {
|
|
16
49
|
const parsedWait = parseInt(opts.wait, 10);
|
|
17
50
|
const waitMs = Number.isFinite(parsedWait) && parsedWait >= 0 ? parsedWait : 500;
|
|
18
|
-
const
|
|
19
|
-
const
|
|
51
|
+
const parsedTimeout = parseInt(opts.waitTimeout, 10);
|
|
52
|
+
const waitForTimeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 0 ? parsedTimeout : 5000;
|
|
53
|
+
const kickoff = await post('/tools/browser/eval', {
|
|
54
|
+
url, expr, waitMs,
|
|
55
|
+
waitForSelector: opts.waitFor || undefined,
|
|
56
|
+
waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
|
|
57
|
+
});
|
|
58
|
+
const d = await pollEvalResult(kickoff.data.evalJobId, waitMs);
|
|
20
59
|
if (opts.json) {
|
|
21
60
|
console.log(JSON.stringify(d));
|
|
22
61
|
return;
|
|
@@ -23,9 +23,12 @@ export const pageInspectCommand = new Command('inspect')
|
|
|
23
23
|
.description('Inspect a web page (console, failed resources, timing, layout overflow)')
|
|
24
24
|
.argument('<url>', 'URL to inspect')
|
|
25
25
|
.option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before capturing (lets late async/LCP work settle)', '500')
|
|
26
|
+
.option('--wait-for <selector>', 'Wait until this CSS selector appears before capturing (deterministic; replaces --wait)')
|
|
27
|
+
.option('--wait-timeout <ms>', 'Max ms to wait for --wait-for before giving up', '5000')
|
|
26
28
|
.option('--json', 'Output as JSON')
|
|
27
29
|
.option('--no-truncate', 'Show full URLs instead of truncating long ones with middle-ellipsis')
|
|
28
30
|
.option('--all', 'Include render-blocking, large resources, oversized images, overflow culprits, and LCP detail')
|
|
31
|
+
.option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps run headlessly (audio is a built-in tone, not real speech)')
|
|
29
32
|
// Hidden redirect: agents reach for `page inspect --screenshot`. We don't take
|
|
30
33
|
// an image here (`page screenshot` is the single path for that) — just point there.
|
|
31
34
|
.addOption(new Option('--screenshot [path]', 'Capture a screenshot').hideHelp())
|
|
@@ -38,9 +41,16 @@ export const pageInspectCommand = new Command('inspect')
|
|
|
38
41
|
return run('Page inspect', async () => {
|
|
39
42
|
const parsedWait = parseInt(opts.wait, 10);
|
|
40
43
|
const waitMs = Number.isFinite(parsedWait) && parsedWait >= 0 ? parsedWait : 500;
|
|
44
|
+
const parsedTimeout = parseInt(opts.waitTimeout, 10);
|
|
45
|
+
const waitForTimeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 0 ? parsedTimeout : 5000;
|
|
41
46
|
const truncate = opts.truncate !== false;
|
|
42
47
|
const showAll = opts.all === true;
|
|
43
|
-
const res = await post(`/tools/browser/inspect`, {
|
|
48
|
+
const res = await post(`/tools/browser/inspect`, {
|
|
49
|
+
url, waitMs,
|
|
50
|
+
waitForSelector: opts.waitFor || undefined,
|
|
51
|
+
waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
|
|
52
|
+
fakeMedia: opts.fakeMedia || undefined,
|
|
53
|
+
});
|
|
44
54
|
const b = res.data;
|
|
45
55
|
if (opts.json) {
|
|
46
56
|
console.log(JSON.stringify(b));
|
|
@@ -71,12 +81,30 @@ export const pageInspectCommand = new Command('inspect')
|
|
|
71
81
|
console.log(`\n ${bold('Console:')} ${muted('(clean)')}`);
|
|
72
82
|
}
|
|
73
83
|
// ── Failed Resources ──
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
84
|
+
// Browsers auto-request /favicon.ico at the site root for every page, so a
|
|
85
|
+
// 404 there isn't a resource the page actually links — it's noise on any
|
|
86
|
+
// app served under a subpath. Split that implicit request out of the failure
|
|
87
|
+
// list into a harmless note rather than flagging it as an error.
|
|
88
|
+
const isImplicitFavicon = (entry) => {
|
|
89
|
+
const urlPart = entry.replace(/\s*\([^)]*\)\s*$/, '');
|
|
90
|
+
try {
|
|
91
|
+
return new URL(urlPart).pathname === '/favicon.ico';
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const failed = (b.failedResources || []).filter((r) => !isImplicitFavicon(r));
|
|
98
|
+
const rootFaviconMissing = (b.failedResources || []).some(isImplicitFavicon);
|
|
99
|
+
if (failed.length > 0) {
|
|
100
|
+
console.log(`\n ${clrError(`Failed resources (${failed.length}):`)}`);
|
|
101
|
+
for (const r of failed) {
|
|
77
102
|
console.log(` ${clrError(r)}`);
|
|
78
103
|
}
|
|
79
104
|
}
|
|
105
|
+
if (rootFaviconMissing) {
|
|
106
|
+
console.log(`\n ${muted('No root /favicon.ico (browsers request this automatically; harmless for app pages served under a subpath)')}`);
|
|
107
|
+
}
|
|
80
108
|
// ── Layout (horizontal overflow) ──
|
|
81
109
|
if (b.overflow) {
|
|
82
110
|
if (b.overflow.overflowX) {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Command, Option } from 'commander';
|
|
2
|
-
import {
|
|
2
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join, resolve as resolvePath } from 'path';
|
|
3
4
|
import { postForTarEntries } from '../api.js';
|
|
5
|
+
import { getProjectRoot } from '../config.js';
|
|
4
6
|
import { brand, bold, muted, success } from '../colors.js';
|
|
5
7
|
import { formatSize } from '../utils.js';
|
|
6
8
|
import { run } from '../helpers/index.js';
|
|
@@ -44,23 +46,23 @@ function dimSuffix(vp) {
|
|
|
44
46
|
const dpr = vp.deviceScaleFactor ?? 1;
|
|
45
47
|
return dpr === 1 ? `${vp.width}x${vp.height}` : `${vp.width}x${vp.height}@${dpr}`;
|
|
46
48
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return
|
|
49
|
+
/** Default screenshot directory: `<project-root>/.gipity/screenshots`, falling
|
|
50
|
+
* back to `./.gipity/screenshots` in one-off mode (no linked project). `.gipity/`
|
|
51
|
+
* is sync-ignored, so these verification artifacts never sync to Gipity or
|
|
52
|
+
* deploy to the CDN - and they stay out of the project root. */
|
|
53
|
+
function defaultScreenshotDir() {
|
|
54
|
+
const root = getProjectRoot();
|
|
55
|
+
return join(root ?? '.', '.gipity', 'screenshots');
|
|
56
|
+
}
|
|
57
|
+
/** `yyyy-mm-dd_hh-mm-ss` per the repo timestamp convention - sorts chronologically,
|
|
58
|
+
* filesystem-safe. One stamp per invocation; viewport suffixes keep multi-shot
|
|
59
|
+
* runs distinct so they never collide on the shared timestamp. */
|
|
60
|
+
export function timestampSlug(d = new Date()) {
|
|
61
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
62
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`;
|
|
63
|
+
}
|
|
64
|
+
export function defaultFilename(slug, ts, suffix) {
|
|
65
|
+
return suffix ? `ss-${slug}-${suffix}-${ts}.png` : `ss-${slug}-${ts}.png`;
|
|
64
66
|
}
|
|
65
67
|
function parseViewportString(s) {
|
|
66
68
|
const m = s.trim().match(/^(\d+)x(\d+)(?:@(\d+(?:\.\d+)?))?$/i);
|
|
@@ -96,10 +98,11 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
96
98
|
.argument('<url>', 'URL to screenshot')
|
|
97
99
|
.option('--post-load-delay <ms>', 'Delay after DOMContentLoaded before capture, in ms', '1000')
|
|
98
100
|
.option('--full', 'Capture the full scrollable page (default: viewport only)')
|
|
99
|
-
.option('-o, --output <file>', 'Output
|
|
101
|
+
.option('-o, --output <file>', 'Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)')
|
|
100
102
|
.option('--device <names>', `Viewport preset(s): ${Object.keys(DEVICE_PRESETS).join(', ')} (comma-separated or repeat flag)`, appendOption, [])
|
|
101
103
|
.option('--viewport <dims>', 'Raw viewport(s): WxH or WxH@dpr (comma-separated or repeat flag)', appendOption, [])
|
|
102
104
|
.option('--no-reload-between', 'Skip reload between viewports (faster, lower fidelity - only safe for static pages)')
|
|
105
|
+
.option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps render headlessly (audio is a built-in tone, not real speech)')
|
|
103
106
|
.option('--json', 'Output JSON metadata instead of a friendly summary')
|
|
104
107
|
.addOption(new Option('--wait <ms>', 'Alias for --post-load-delay').hideHelp())
|
|
105
108
|
.action((url, opts) => run('Page screenshot', async () => {
|
|
@@ -118,7 +121,7 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
118
121
|
throw new Error('--output can only be used with a single viewport');
|
|
119
122
|
}
|
|
120
123
|
// Server defaults to 1280×720 when viewports is omitted - don't send it in
|
|
121
|
-
// the no-flag case so filename stays unsuffixed (
|
|
124
|
+
// the no-flag case so the filename stays unsuffixed (no viewport segment).
|
|
122
125
|
const userSpecifiedViewports = customViewports.length > 0;
|
|
123
126
|
const body = {
|
|
124
127
|
url,
|
|
@@ -126,6 +129,7 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
126
129
|
full: !!opts.full,
|
|
127
130
|
reloadBetween: opts.reloadBetween !== false,
|
|
128
131
|
...(userSpecifiedViewports ? { viewports: customViewports } : {}),
|
|
132
|
+
...(opts.fakeMedia ? { fakeMedia: true } : {}),
|
|
129
133
|
};
|
|
130
134
|
const entries = await postForTarEntries('/tools/browser/screenshot', body);
|
|
131
135
|
const metaEntry = entries.find((e) => e.name === 'meta.json');
|
|
@@ -137,13 +141,20 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
137
141
|
throw new Error(`Server returned ${pngs.length} PNGs but ${meta.screenshots.length} metadata entries`);
|
|
138
142
|
}
|
|
139
143
|
const slug = slugFromUrl(url);
|
|
144
|
+
const ts = timestampSlug();
|
|
145
|
+
const dir = defaultScreenshotDir();
|
|
146
|
+
if (!opts.output)
|
|
147
|
+
mkdirSync(dir, { recursive: true });
|
|
140
148
|
const savedFiles = [];
|
|
141
149
|
for (let i = 0; i < pngs.length; i++) {
|
|
142
150
|
const shot = meta.screenshots[i];
|
|
143
151
|
const suffix = userSpecifiedViewports ? dimSuffix(shot.viewport) : undefined;
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
152
|
+
const target = opts.output
|
|
153
|
+
? opts.output
|
|
154
|
+
: join(dir, defaultFilename(slug, ts, suffix));
|
|
155
|
+
writeFileSync(target, pngs[i].buffer);
|
|
156
|
+
// Absolute path so the agent knows exactly where the file landed.
|
|
157
|
+
savedFiles.push(resolvePath(target));
|
|
147
158
|
}
|
|
148
159
|
if (opts.json) {
|
|
149
160
|
console.log(JSON.stringify({
|