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.
@@ -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
- /** Map a single parsed transcript line to zero or more ingest entries. */
41
- export function transcriptLineToEntries(line) {
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, source_uuid: srcUuid }];
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, source_uuid: srcUuid });
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, source_uuid: srcUuid });
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: typeof block.name === 'string' ? block.name : 'tool',
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);
@@ -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
- // Catalog mirrored from platform/packages/shared (TEMPLATES + KITS) -
11
- // the CLI ships as a standalone npm package and can't depend on the private
12
- // shared workspace. Keep these lists in sync when catalog entries change.
13
- //
14
- // Templates install a whole app (blank wiring or a working starter demo).
15
- // Kits are reusable building blocks added into an existing app's src/packages/.
16
- const TEMPLATES = ['web-simple', '3d-engine'];
17
- const STARTERS = ['web-fullstack', 'web-vision-cam', '2d-game', '3d-world', 'api'];
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
- function printCatalog() {
24
- console.log('');
25
- console.log(`${bold('Templates')} ${muted('- install a whole app into an empty project')}`);
26
- console.log(` ${TEMPLATES.join(', ')} ${muted('(blank wiring)')}`);
27
- console.log(` ${STARTERS.join(', ')} ${muted('(working demos)')}`);
28
- console.log('');
29
- console.log(`${bold('Kits')} ${muted('- add a reusable building block into an existing app')}`);
30
- for (const k of KITS)
31
- console.log(` ${k.key} ${muted('- ' + k.hint)}`);
32
- console.log('');
33
- console.log(`${bold('Local path')} ${muted('- install from a directory on disk (template or kit)')}`);
34
- console.log(` ${muted('gipity add ./path/to/template (or ~/path, /abs/path)')}`);
35
- console.log(` ${muted('gipity add ./path/to/kit (auto-detected via package.json gipity.install)')}`);
36
- console.log('');
37
- console.log(muted('Usage: gipity add <name|path> [--title <t>] [--description <d>] [--force]'));
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 to list the catalog.')
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
- .action((name, opts) => run('Add', async () => {
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
- printCatalog();
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 filename (default: generated.png)')
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: filename }));
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 ${filename}`));
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 filename (default: generated.mp4)')
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: filename }));
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 ${filename}`));
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 filename (default: speech.mp3)')
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: filename }));
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 ${filename}`));
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 res = await post('/tools/browser/eval', { url, expr, waitMs });
19
- const d = res.data;
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`, { url, waitMs });
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
- if (b.failedResources?.length > 0) {
75
- console.log(`\n ${clrError(`Failed resources (${b.failedResources.length}):`)}`);
76
- for (const r of b.failedResources) {
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 { existsSync, readdirSync, writeFileSync } from 'fs';
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
- function nextNumberedFilename(slug, suffix) {
48
- const prefix = suffix ? `ss-${slug}-${suffix}-` : `ss-${slug}-`;
49
- const escaped = prefix.replace(/[-.@]/g, '\\$&');
50
- const existing = readdirSync('.')
51
- .map((f) => {
52
- const m = f.match(new RegExp(`^${escaped}(\\d{3,})\\.png$`));
53
- return m ? parseInt(m[1], 10) : -1;
54
- })
55
- .filter((n) => n >= 0);
56
- const next = existing.length ? Math.max(...existing) + 1 : 1;
57
- let n = next;
58
- let candidate = `${prefix}${String(n).padStart(3, '0')}.png`;
59
- while (existsSync(candidate)) {
60
- n += 1;
61
- candidate = `${prefix}${String(n).padStart(3, '0')}.png`;
62
- }
63
- return candidate;
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 filename (single viewport only; default ss-<host>-NNN.png)')
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 (`ss-host-NNN.png`).
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 filename = opts.output || nextNumberedFilename(slug, suffix);
145
- writeFileSync(filename, pngs[i].buffer);
146
- savedFiles.push(filename);
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({