@witchpot/gmc 0.1.8 → 0.1.9

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/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to the Game Market Copilot CLI will be recorded here.
4
4
 
5
+ ## Unreleased
6
+
7
+ ## 0.1.9 - 2026-07-03
8
+
9
+ - Clarified packaged Agent Skill docs so Claude Code uses `gmc skill install`
10
+ and Codex/AGENTS.md-based agents use
11
+ `gmc skill print --format agents-md >> AGENTS.md`.
12
+
5
13
  ## 0.1.8 - 2026-06-12
6
14
 
7
15
  - Updated the bundled `gmc-analysis` skill to v0.2.0 with the learnings from
package/README.md CHANGED
@@ -6,17 +6,17 @@ showcases.
6
6
 
7
7
  ## Install
8
8
 
9
- Install the beta CLI:
9
+ Install the CLI:
10
10
 
11
11
  ```bash
12
- npm install -g @witchpot/gmc@beta
12
+ npm install -g @witchpot/gmc
13
13
  gmc --version
14
14
  ```
15
15
 
16
16
  Or run it without a global install:
17
17
 
18
18
  ```bash
19
- npx @witchpot/gmc@beta --version
19
+ npx @witchpot/gmc --version
20
20
  ```
21
21
 
22
22
  The package installs three equivalent binaries: `gmc`, `gmcp`, and
@@ -64,8 +64,10 @@ a non-secret prefix.
64
64
  ## Agent Skill
65
65
 
66
66
  The package bundles the `gmc-analysis` Agent Skill — analysis recipes, pivot
67
- heuristics, quota etiquette, and honesty rules for coding agents. Install it
68
- so Claude Code (and similar tools) load it automatically:
67
+ heuristics, quota etiquette, and honesty rules for coding agents. The core
68
+ analysis guidance is shared, but the registration step depends on the agent.
69
+
70
+ For Claude Code, install the packaged skill directory:
69
71
 
70
72
  ```bash
71
73
  gmc skill install # writes .claude/skills/gmc-analysis (project)
@@ -73,13 +75,16 @@ gmc skill install --global # or ~/.claude/skills/gmc-analysis
73
75
  gmc skill status --json # packaged vs installed versions
74
76
  ```
75
77
 
76
- For Codex and other AGENTS.md-based agents, append the self-contained
77
- edition (references inlined) to your project brief:
78
+ For Codex and other AGENTS.md-based agents, append the self-contained edition
79
+ (references inlined) to the repo brief the agent reads:
78
80
 
79
81
  ```bash
80
82
  gmc skill print --format agents-md >> AGENTS.md
81
83
  ```
82
84
 
85
+ If `AGENTS.md` already contains a generated `gmc-analysis` section, update that
86
+ section instead of appending a duplicate.
87
+
83
88
  Re-run install/print after `npm update` to pick up newer skill versions.
84
89
 
85
90
  ## First Queries
@@ -87,11 +92,11 @@ Re-run install/print after `npm update` to pick up newer skill versions.
87
92
  ```bash
88
93
  gmc schema --json
89
94
  gmc update --json
90
- gmc games resolve --source steam --query Timberborn --json
91
- gmc games detail --source steam 1062090 --json
92
- gmc campaigns signals --source steam 1062090 --json
93
- gmc games benchmark --source steam --tags city_builder,automation --reviews-min 1000 --preset summary --json
94
- gmc showcases fit --source steam 1062090 --json
95
+ gmc games search --source steam --release-year 2025 --reviews-min 1000 --fields title,appid,rating,reviews --json
96
+ gmc games aggregate --source steam --release-year 2025 --metric count,median_reviews --group-by release_year --reviews-min 1000 --json
97
+ gmc games benchmark --source steam --tags <tag_slug[,tag_slug...]> --reviews-min 1000 --preset summary --json
98
+ gmc reviews patterns --source steam --tags <tag_slug[,tag_slug...]> --sample-size 15 --preset summary --json
99
+ gmc showcases patterns --source steam --tags <tag_slug[,tag_slug...]> --json
95
100
  ```
96
101
 
97
102
  Preserve `meta.requestId`, `meta.entitlements`, `warnings`, and `pagination`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@witchpot/gmc",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "private": false,
5
5
  "description": "Command line tools for Game Market Copilot market research.",
6
6
  "license": "UNLICENSED",
@@ -45,6 +45,8 @@
45
45
  "smoke:beta": "node scripts/smoke-beta.mjs",
46
46
  "pack:check": "node scripts/smoke-pack.mjs --dry-run",
47
47
  "smoke:pack": "node scripts/smoke-pack.mjs",
48
+ "publish:latest:dry-run": "npm publish --access public --dry-run --cache /tmp/gmc-npm-cache",
49
+ "publish:latest": "npm publish --access public --cache /tmp/gmc-npm-cache",
48
50
  "publish:beta:dry-run": "npm publish --tag beta --access public --dry-run --cache /tmp/gmc-npm-cache",
49
51
  "publish:beta": "npm publish --tag beta --access public --cache /tmp/gmc-npm-cache"
50
52
  }
@@ -33,14 +33,40 @@ const smokeCommands = [
33
33
  validate: (payload) => requireSuccessEnvelope(payload, ["data.version", "data.cliCommands"])
34
34
  },
35
35
  {
36
- name: "resolve Timberborn",
37
- args: ["games", "resolve", "--source", "steam", "--query", "Timberborn", "--json"],
38
- validate: (payload) => requireComposedEnvelope(payload, ["data.candidates", "data.resolution"])
36
+ name: "search recent reviewed games",
37
+ args: [
38
+ "games",
39
+ "search",
40
+ "--source",
41
+ "steam",
42
+ "--release-year",
43
+ "2025",
44
+ "--reviews-min",
45
+ "1000",
46
+ "--limit",
47
+ "5",
48
+ "--json"
49
+ ],
50
+ validate: (payload) => requireSuccessEnvelope(payload, ["data"])
39
51
  },
40
52
  {
41
- name: "detail Timberborn",
42
- args: ["games", "detail", "--source", "steam", "1062090", "--json"],
43
- validate: (payload) => requireSuccessEnvelope(payload, ["data.title"])
53
+ name: "aggregate recent reviewed games",
54
+ args: [
55
+ "games",
56
+ "aggregate",
57
+ "--source",
58
+ "steam",
59
+ "--release-year",
60
+ "2025",
61
+ "--metric",
62
+ "count,median_reviews",
63
+ "--group-by",
64
+ "release_year",
65
+ "--reviews-min",
66
+ "1000",
67
+ "--json"
68
+ ],
69
+ validate: (payload) => requireSuccessEnvelope(payload, ["data"])
44
70
  },
45
71
  {
46
72
  name: "benchmark city builder automation",
@@ -60,9 +86,9 @@ const smokeCommands = [
60
86
  validate: (payload) => requireComposedEnvelope(payload, ["data.summary"])
61
87
  },
62
88
  {
63
- name: "showcase fit Timberborn",
64
- args: ["showcases", "fit", "--source", "steam", "1062090", "--json"],
65
- validate: (payload) => requireComposedEnvelope(payload, ["data.matches", "data.summary"])
89
+ name: "showcase patterns by tag",
90
+ args: ["showcases", "patterns", "--source", "steam", "--tags", "city_builder,automation", "--json"],
91
+ validate: (payload) => requireComposedEnvelope(payload, ["data.summary"])
66
92
  }
67
93
  ];
68
94
 
@@ -53,6 +53,11 @@ For cohorts beyond one page, and for the full-cohort map-reduce procedure
53
53
  (pagination, bounded concurrency, `--fields` projection, subagent chunking),
54
54
  follow the recipes reference.
55
55
 
56
+ Search commands are optimized for row retrieval. Do not expect
57
+ `pagination.total` from `gmc games search` or CLI-composed evidence packs
58
+ unless you explicitly pass `--include-total`; for count-only questions use
59
+ `gmc games count` instead.
60
+
56
61
  ## Hard rules
57
62
 
58
63
  - **Missing data is not absent sentiment.** Report `cohort.usableGames`,
@@ -69,4 +69,6 @@ exemplar rows and caveat bucket assignments, especially during sale seasons.
69
69
  List endpoints cap at `limit=100`. For "how many" questions use
70
70
  `gmc games count`; for distributions use `gmc games aggregate` — both are
71
71
  single-request server aggregates. Reserve pagination loops for when you
72
- actually need the rows (recipe R2).
72
+ actually need the rows (recipe R2). `gmc games search` is optimized for rows
73
+ and may omit `pagination.total`; pass `--include-total` only when a row page
74
+ also needs an exact count.
@@ -25,3 +25,6 @@ indicative — the response metadata is the source of truth.
25
25
  midnight), and tell the user how much of the analysis completed.
26
26
  5. **Prefer server aggregates** (`count`, `aggregate`) over row pagination —
27
27
  they answer sizing questions in one request.
28
+ 6. **Keep totals explicit.** Search commands prioritize fast row pages and
29
+ exact totals are opt-in (`--include-total`). Use `games count` for
30
+ denominators so row collection stays cheap.
@@ -36,7 +36,9 @@ Cost: ~(pages + N) requests. Check quota headroom first (see quota.md).
36
36
  1. **Collect the cohort**: page `gmc games search <filters> --coverage
37
37
  full,partial --sort reviews_desc --limit 100 --offset <0,100,...>
38
38
  --fields appid,title,reviews --json` until `pagination.nextOffset` is
39
- null.
39
+ null. Run `gmc games count` first when you need the denominator; add
40
+ `--include-total` to search only when the exact total must travel with the
41
+ row page.
40
42
  2. **Fan out** `gmc games analysis --source steam <appid> --sections
41
43
  reviews --json` per game with bounded concurrency (<= 8 parallel
42
44
  processes). Write each response to a temp file; tolerate individual
package/src/args.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { EXIT_USAGE } from "./constants.js";
2
2
  import { writeJsonError } from "./output.js";
3
3
 
4
- export const BOOLEAN_OPTIONS = new Set(["json", "help", "version", "no-open", "global"]);
4
+ export const BOOLEAN_OPTIONS = new Set(["json", "help", "version", "no-open", "global", "include-total"]);
5
5
 
6
6
  export function parseArgs(args) {
7
7
  const command = [];
package/src/config.js CHANGED
@@ -187,15 +187,15 @@ export async function handleLocalCommand(command, options, positional, config) {
187
187
  status: "manual_update_required",
188
188
  updateCheck: {
189
189
  available: null,
190
- reason: "The CLI does not query the npm registry yet. Use the install command for the closed-beta package once it is published."
190
+ reason: "The CLI does not query the npm registry yet. Use npm install or npx to run the current public package."
191
191
  },
192
192
  commands: {
193
- npmGlobal: `npm install -g ${CLI_BETA_PACKAGE_NAME}@beta`,
194
- npmExec: `npx ${CLI_BETA_PACKAGE_NAME}@beta --version`
193
+ npmGlobal: `npm install -g ${CLI_PACKAGE_NAME}`,
194
+ npmExec: `npx ${CLI_PACKAGE_NAME} --version`
195
195
  },
196
196
  notes: [
197
- "This command is intentionally non-mutating in the closed-beta contract.",
198
- "A future gmc update --apply command can run the package-manager update after the npm distribution channel is finalized."
197
+ "This command is intentionally non-mutating.",
198
+ "A future gmc update --apply command can run the package-manager update after registry checks are implemented."
199
199
  ]
200
200
  }
201
201
  };
package/src/constants.js CHANGED
@@ -8,7 +8,7 @@ export const EXIT_API = 4;
8
8
 
9
9
  export const CLI_PACKAGE_NAME = "@witchpot/gmc";
10
10
  export const CLI_BETA_PACKAGE_NAME = "@witchpot/gmc";
11
- export const CLI_VERSION = "0.1.8";
11
+ export const CLI_VERSION = "0.1.9";
12
12
 
13
13
  export const DEFAULT_API_BASE_URL = "https://gamemarketcopilot.com";
14
14
  export const DEFAULT_CONFIG_PATH = path.join(homedir(), ".config", "gmc", "config.json");
package/src/help.js CHANGED
@@ -56,10 +56,10 @@ Binary aliases:
56
56
  alias shadows gmc (Oh My Zsh's git plugin defines gmc as
57
57
  'git merge --continue').
58
58
 
59
- Supported beta commands:
59
+ Supported commands:
60
60
  ${SUPPORTED_BETA_COMMANDS.map((command) => ` ${command}`).join("\n")}
61
61
 
62
- Provisional beta commands:
62
+ Provisional commands:
63
63
  ${PROVISIONAL_BETA_COMMANDS.map((command) => ` ${command}`).join("\n")}
64
64
 
65
65
  Environment:
@@ -102,6 +102,8 @@ Sorting:
102
102
  Pagination:
103
103
  --limit <number> 1-100, default 20.
104
104
  --offset <number> Offset for the next page.
105
+ --include-total Also fetch an exact total. Prefer games count
106
+ when the user only asks how many games match.
105
107
 
106
108
  Output:
107
109
  --fields <list> Optional comma-separated field list.
package/src/main.js CHANGED
@@ -12,6 +12,7 @@ import { printGamesSearchHelp, printHelp } from "./help.js";
12
12
  import { writeJsonError } from "./output.js";
13
13
  import { applyOutputProjection } from "./projection.js";
14
14
  import { buildRequest } from "./requests.js";
15
+ import { trackCliCommandExecuted } from "./telemetry.js";
15
16
  import { executeRequestPlan } from "./workflows.js";
16
17
 
17
18
  export async function main(argv = process.argv.slice(2)) {
@@ -78,6 +79,7 @@ export async function main(argv = process.argv.slice(2)) {
78
79
  process.exit(EXIT_CONFIG);
79
80
  }
80
81
 
82
+ const startedAt = Date.now();
81
83
  const response = await executeRequestPlan(baseUrl, request, { apiKey });
82
84
  const payload = matches(command, ["auth", "status"])
83
85
  ? enrichAuthStatusOutput(response, config)
@@ -89,6 +91,16 @@ export async function main(argv = process.argv.slice(2)) {
89
91
  });
90
92
 
91
93
  process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
94
+ await trackCliCommandExecuted({
95
+ baseUrl,
96
+ apiKey,
97
+ command,
98
+ options,
99
+ positional,
100
+ request,
101
+ response: payload,
102
+ startedAt
103
+ });
92
104
  }
93
105
 
94
106
  export function runCliEntryPoint() {
package/src/requests.js CHANGED
@@ -88,7 +88,7 @@ export function buildRequest(command, options, positional) {
88
88
  const query = {
89
89
  sort: "reviews_desc",
90
90
  limit: sampling?.pageLimit ?? DEFAULT_BENCHMARK_LIMIT,
91
- ...buildGameSearchQuery(options)
91
+ ...buildGameSearchQuery(options, { includeTotal: false })
92
92
  };
93
93
  if (sampling && options.limit == null) query.limit = sampling.pageLimit;
94
94
  return {
@@ -96,6 +96,7 @@ export function buildRequest(command, options, positional) {
96
96
  source: "steam",
97
97
  query,
98
98
  sampling,
99
+ countRequest: buildOptionalGameCountRequest(options),
99
100
  searchRequest: {
100
101
  path: "/api/v1/steam/games",
101
102
  query
@@ -201,7 +202,7 @@ export function buildRequest(command, options, positional) {
201
202
  const query = {
202
203
  sort: "reviews_desc",
203
204
  limit: sampling?.pageLimit ?? DEFAULT_REVIEW_PATTERNS_LIMIT,
204
- ...buildGameSearchQuery(options)
205
+ ...buildGameSearchQuery(options, { includeTotal: false })
205
206
  };
206
207
  if (sampling && options.limit == null) query.limit = sampling.pageLimit;
207
208
 
@@ -210,6 +211,7 @@ export function buildRequest(command, options, positional) {
210
211
  source: "steam",
211
212
  query,
212
213
  sampling,
214
+ countRequest: buildOptionalGameCountRequest(options),
213
215
  minGames,
214
216
  maxTopicsPerGame,
215
217
  sentiment,
@@ -361,7 +363,7 @@ function buildCohortEvidenceRequest({ workflow, evidenceType, sections, options
361
363
  const query = {
362
364
  sort: "reviews_desc",
363
365
  limit: sampling?.pageLimit ?? DEFAULT_COHORT_WORKFLOW_LIMIT,
364
- ...buildGameSearchQuery(options)
366
+ ...buildGameSearchQuery(options, { includeTotal: false })
365
367
  };
366
368
  if (sampling && options.limit == null) query.limit = sampling.pageLimit;
367
369
  return {
@@ -372,6 +374,7 @@ function buildCohortEvidenceRequest({ workflow, evidenceType, sections, options
372
374
  sections,
373
375
  query,
374
376
  sampling,
377
+ countRequest: buildOptionalGameCountRequest(options),
375
378
  searchRequest: {
376
379
  path: "/api/v1/steam/games",
377
380
  query
@@ -410,7 +413,7 @@ function pickQuery(options, mapping) {
410
413
  return query;
411
414
  }
412
415
 
413
- function buildGameSearchQuery(options) {
416
+ function buildGameSearchQuery(options, { includeTotal = Boolean(options["include-total"]) } = {}) {
414
417
  const query = {
415
418
  ...buildGameCountQuery(options),
416
419
  ...pickQuery(options, {
@@ -418,12 +421,21 @@ function buildGameSearchQuery(options) {
418
421
  limit: "limit",
419
422
  offset: "offset",
420
423
  locale: "locale"
421
- })
424
+ }),
425
+ includeTotal
422
426
  };
423
427
 
424
428
  return query;
425
429
  }
426
430
 
431
+ function buildOptionalGameCountRequest(options) {
432
+ if (!options["include-total"]) return undefined;
433
+ return {
434
+ path: "/api/v1/steam/games/count",
435
+ query: buildGameCountQuery(options)
436
+ };
437
+ }
438
+
427
439
  function buildGameCountQuery(options) {
428
440
  const query = pickQuery(options, {
429
441
  query: "q",
@@ -15,11 +15,11 @@ test("games success-report builds the success-report request plan", () => {
15
15
  "success-report",
16
16
  "--source",
17
17
  "steam",
18
- "1062090",
18
+ "123456",
19
19
  "--json"
20
20
  ]);
21
21
 
22
- assert.equal(request.path, "/api/v1/steam/games/1062090/success-report");
22
+ assert.equal(request.path, "/api/v1/steam/games/123456/success-report");
23
23
  assert.deepEqual(request.query, {});
24
24
  });
25
25
 
@@ -29,13 +29,13 @@ test("games success-report forwards locale", () => {
29
29
  "success-report",
30
30
  "--source",
31
31
  "steam",
32
- "1062090",
32
+ "123456",
33
33
  "--locale",
34
34
  "ja",
35
35
  "--json"
36
36
  ]);
37
37
 
38
- assert.equal(request.path, "/api/v1/steam/games/1062090/success-report");
38
+ assert.equal(request.path, "/api/v1/steam/games/123456/success-report");
39
39
  assert.deepEqual(request.query, { locale: "ja" });
40
40
  });
41
41
 
@@ -45,11 +45,11 @@ test("games detail request plan stays unchanged", () => {
45
45
  "detail",
46
46
  "--source",
47
47
  "steam",
48
- "1062090",
48
+ "123456",
49
49
  "--json"
50
50
  ]);
51
51
 
52
- assert.equal(request.path, "/api/v1/steam/games/1062090");
52
+ assert.equal(request.path, "/api/v1/steam/games/123456");
53
53
  assert.deepEqual(request.query, {});
54
54
  });
55
55
 
@@ -69,6 +69,21 @@ test("games search forwards coverage as a filter", () => {
69
69
  assert.equal(request.path, "/api/v1/steam/games");
70
70
  assert.equal(request.query.coverage, "full,partial");
71
71
  assert.equal(request.query.reviewsMin, "1000");
72
+ assert.equal(request.query.includeTotal, false);
73
+ });
74
+
75
+ test("games search can opt into an exact total", () => {
76
+ const request = buildRequestFromArgv([
77
+ "games",
78
+ "search",
79
+ "--source",
80
+ "steam",
81
+ "--include-total",
82
+ "--json"
83
+ ]);
84
+
85
+ assert.equal(request.path, "/api/v1/steam/games");
86
+ assert.equal(request.query.includeTotal, true);
72
87
  });
73
88
 
74
89
  test("games count forwards coverage for cohort pre-estimation", () => {
@@ -101,4 +116,26 @@ test("reviews patterns forwards coverage into the cohort search request", () =>
101
116
 
102
117
  assert.equal(request.kind, "reviewPatterns");
103
118
  assert.equal(request.searchRequest.query.coverage, "full,partial");
119
+ assert.equal(request.searchRequest.query.includeTotal, false);
120
+ });
121
+
122
+ test("sampled workflows keep exact totals in a separate count request", () => {
123
+ const request = buildRequestFromArgv([
124
+ "games",
125
+ "benchmark",
126
+ "--source",
127
+ "steam",
128
+ "--release-year",
129
+ "2025",
130
+ "--include-total",
131
+ "--json"
132
+ ]);
133
+
134
+ assert.equal(request.kind, "benchmark");
135
+ assert.equal(request.searchRequest.query.includeTotal, false);
136
+ assert.equal(request.countRequest.path, "/api/v1/steam/games/count");
137
+ assert.deepEqual(request.countRequest.query, {
138
+ releaseFrom: "2025-01-01",
139
+ releaseTo: "2025-12-31"
140
+ });
104
141
  });
package/src/skill.js CHANGED
@@ -51,7 +51,8 @@ export function handleSkillInstall(options) {
51
51
  scope: options.dir ? "custom" : options.global ? "global" : "project",
52
52
  next: [
53
53
  "Claude Code picks up project skills from .claude/skills automatically.",
54
- "For Codex or other AGENTS.md-based agents run: gmc skill print --format agents-md >> AGENTS.md"
54
+ "For Codex or other AGENTS.md-based agents, append the self-contained instructions with: gmc skill print --format agents-md >> AGENTS.md",
55
+ "If AGENTS.md already contains a generated gmc-analysis section, update it instead of appending a duplicate."
55
56
  ]
56
57
  }
57
58
  };
@@ -0,0 +1,341 @@
1
+ import { CLI_VERSION } from "./constants.js";
2
+ import { buildRequestHeaders } from "./requests.js";
3
+
4
+ const TELEMETRY_TIMEOUT_MS = 800;
5
+ const TELEMETRY_ENDPOINT = "/api/v1/product-events";
6
+ const FORMAT_OPTION_KEYS = new Set(["json", "fields", "preset", "help", "version", "format"]);
7
+ const RAW_TEXT_KEYS = new Set(["q", "query", "title", "topic", "message", "prompt"]);
8
+ const SENSITIVE_OPTION_KEYS = new Set([
9
+ "api-key",
10
+ "authorization",
11
+ "bearer",
12
+ "credential",
13
+ "key",
14
+ "secret",
15
+ "token"
16
+ ]);
17
+ const SAFE_QUERY_VALUE_KEYS = new Set([
18
+ "bucket",
19
+ "compareBy",
20
+ "coverage",
21
+ "detail",
22
+ "format",
23
+ "focus",
24
+ "groupBy",
25
+ "includeTotal",
26
+ "limit",
27
+ "locale",
28
+ "maxTopicsPerGame",
29
+ "metric",
30
+ "minGames",
31
+ "offset",
32
+ "priceMax",
33
+ "priceMin",
34
+ "ratingMin",
35
+ "releaseFrom",
36
+ "releaseTo",
37
+ "reviewsGt",
38
+ "reviewsLt",
39
+ "reviewsMax",
40
+ "reviewsMin",
41
+ "samplePages",
42
+ "sampleSize",
43
+ "sections",
44
+ "sentiment",
45
+ "sort",
46
+ "source",
47
+ "status",
48
+ "tagMode",
49
+ "tags"
50
+ ]);
51
+
52
+ export async function trackCliCommandExecuted({
53
+ baseUrl,
54
+ apiKey,
55
+ command,
56
+ options,
57
+ positional,
58
+ request,
59
+ response,
60
+ startedAt
61
+ }) {
62
+ if (!apiKey || isCliTelemetryDisabled()) return;
63
+
64
+ const event = buildCliCommandTelemetryEvent({
65
+ command,
66
+ options,
67
+ positional,
68
+ request,
69
+ response,
70
+ startedAt
71
+ });
72
+
73
+ await sendCliTelemetryEvent(baseUrl, apiKey, event);
74
+ }
75
+
76
+ export function buildCliCommandTelemetryEvent({
77
+ command,
78
+ options = {},
79
+ positional = [],
80
+ request,
81
+ response,
82
+ startedAt,
83
+ now = Date.now()
84
+ }) {
85
+ const appid = extractSingleAppid(request);
86
+ const queryInputs = collectRequestQueries(request);
87
+ const safeQueryValues = mergeSafeQueryValues(queryInputs);
88
+ const properties = compactObject({
89
+ source: "gmc-cli",
90
+ cliVersion: CLI_VERSION,
91
+ command: command.join(" "),
92
+ commandGroup: command[0],
93
+ commandAction: command.slice(1).join(" ") || undefined,
94
+ workflow: request.workflow ?? request.kind ?? "direct",
95
+ endpointTemplate: getPrimaryEndpointTemplate(request),
96
+ success: true,
97
+ durationMs: boundedInteger(now - startedAt),
98
+ outputMode: options.json ? "json" : "default",
99
+ optionKeys: safeOptionKeys(options),
100
+ filterKeys: safeFilterKeys(queryInputs),
101
+ hasSearchText: hasRawTextInput(options, request, positional),
102
+ externalIdCount: countExternalIds(request),
103
+ tags: normalizeTokenList(safeQueryValues.tags),
104
+ sections: normalizeTokenList(safeQueryValues.sections),
105
+ coverage: normalizeTokenList(safeQueryValues.coverage),
106
+ focus: safeToken(request.focus ?? safeQueryValues.focus),
107
+ compareBy: safeToken(request.compareBy ?? safeQueryValues.compareBy),
108
+ evidenceType: safeToken(request.evidenceType),
109
+ metric: safeToken(safeQueryValues.metric),
110
+ groupBy: safeToken(safeQueryValues.groupBy),
111
+ bucket: safeToken(safeQueryValues.bucket),
112
+ sort: safeToken(safeQueryValues.sort),
113
+ status: safeToken(safeQueryValues.status),
114
+ format: safeToken(safeQueryValues.format),
115
+ tagMode: safeToken(safeQueryValues.tagMode),
116
+ limit: safeNumber(safeQueryValues.limit ?? request.limit),
117
+ offset: safeNumber(safeQueryValues.offset),
118
+ minGames: safeNumber(request.minGames),
119
+ maxTopicsPerGame: safeNumber(request.maxTopicsPerGame),
120
+ sampleMode: safeToken(request.sampling?.mode),
121
+ sampleRequestedSize: safeNumber(request.sampling?.requestedSize),
122
+ sampleRequestedPages: safeNumber(request.sampling?.requestedPages),
123
+ samplePageLimit: safeNumber(request.sampling?.pageLimit),
124
+ includeTotal: Boolean(safeQueryValues.includeTotal || request.countRequest),
125
+ releaseFrom: safeDate(safeQueryValues.releaseFrom),
126
+ releaseTo: safeDate(safeQueryValues.releaseTo),
127
+ reviewsMin: safeNumber(safeQueryValues.reviewsMin),
128
+ reviewsMax: safeNumber(safeQueryValues.reviewsMax),
129
+ reviewsGt: safeNumber(safeQueryValues.reviewsGt),
130
+ reviewsLt: safeNumber(safeQueryValues.reviewsLt),
131
+ ratingMin: safeNumber(safeQueryValues.ratingMin),
132
+ priceMin: safeNumber(safeQueryValues.priceMin),
133
+ priceMax: safeNumber(safeQueryValues.priceMax),
134
+ rowCount: topLevelRowCount(response),
135
+ warningCount: safeWarnings(response).length,
136
+ lockedCount: safeLockedSections(response).length,
137
+ resultKind: safeToken(response?.data?.kind),
138
+ apiRequestId: safeShortString(response?.meta?.requestId)
139
+ });
140
+
141
+ return compactObject({
142
+ eventType: "cli_command_executed",
143
+ appid,
144
+ properties
145
+ });
146
+ }
147
+
148
+ export async function sendCliTelemetryEvent(baseUrl, apiKey, event, {
149
+ fetchImpl = globalThis.fetch,
150
+ timeoutMs = TELEMETRY_TIMEOUT_MS
151
+ } = {}) {
152
+ if (typeof fetchImpl !== "function") return;
153
+
154
+ const controller = new AbortController();
155
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
156
+
157
+ try {
158
+ const url = new URL(TELEMETRY_ENDPOINT, baseUrl);
159
+ await fetchImpl(url, {
160
+ method: "POST",
161
+ headers: {
162
+ ...buildRequestHeaders(apiKey),
163
+ "content-type": "application/json"
164
+ },
165
+ body: JSON.stringify(event),
166
+ signal: controller.signal
167
+ });
168
+ } catch (error) {
169
+ // CLI telemetry is best-effort and must never change command behavior.
170
+ } finally {
171
+ clearTimeout(timeout);
172
+ }
173
+ }
174
+
175
+ export function isCliTelemetryDisabled(env = process.env) {
176
+ if (env.GMC_CLI_TELEMETRY != null) {
177
+ return /^(0|false|off|no|disabled)$/i.test(String(env.GMC_CLI_TELEMETRY));
178
+ }
179
+ if (env.GMC_DISABLE_TELEMETRY != null) {
180
+ return /^(1|true|on|yes|enabled)$/i.test(String(env.GMC_DISABLE_TELEMETRY));
181
+ }
182
+ return false;
183
+ }
184
+
185
+ function safeOptionKeys(options) {
186
+ return Object.keys(options)
187
+ .filter((key) => !FORMAT_OPTION_KEYS.has(key) && !SENSITIVE_OPTION_KEYS.has(key))
188
+ .sort()
189
+ .slice(0, 40);
190
+ }
191
+
192
+ function collectRequestQueries(request) {
193
+ return [
194
+ request.query,
195
+ request.searchRequest?.query,
196
+ request.countRequest?.query,
197
+ request.topicQuery,
198
+ request.detailQuery,
199
+ request.analysisQuery,
200
+ request.showcaseQuery
201
+ ].filter((query) => query && typeof query === "object");
202
+ }
203
+
204
+ function mergeSafeQueryValues(queries) {
205
+ const values = {};
206
+ for (const query of queries) {
207
+ for (const [key, value] of Object.entries(query)) {
208
+ if (SAFE_QUERY_VALUE_KEYS.has(key) && value != null && value !== "") values[key] = value;
209
+ }
210
+ }
211
+ return values;
212
+ }
213
+
214
+ function safeFilterKeys(queries) {
215
+ const keys = new Set();
216
+ for (const query of queries) {
217
+ for (const [key, value] of Object.entries(query)) {
218
+ if (value == null || value === "" || RAW_TEXT_KEYS.has(key)) continue;
219
+ keys.add(key);
220
+ }
221
+ }
222
+ return [...keys].sort().slice(0, 40);
223
+ }
224
+
225
+ function hasRawTextInput(options, request, positional) {
226
+ if (Object.keys(options).some((key) => RAW_TEXT_KEYS.has(key) && options[key] != null && options[key] !== "")) {
227
+ return true;
228
+ }
229
+ if (typeof request.query === "string" && request.query.trim()) return true;
230
+ if (typeof request.topic === "string" && request.topic.trim()) return true;
231
+ if (request.query && typeof request.query === "object" && typeof request.query.q === "string" && request.query.q.trim()) {
232
+ return true;
233
+ }
234
+ if ((request.kind === "resolve" || request.workflow) && positional.some((value) => String(value).trim())) {
235
+ return true;
236
+ }
237
+ return false;
238
+ }
239
+
240
+ function getPrimaryEndpointTemplate(request) {
241
+ const path = request.path ?? request.searchRequest?.path ?? request.countRequest?.path;
242
+ if (!path) return undefined;
243
+ return templateEndpoint(path);
244
+ }
245
+
246
+ function templateEndpoint(path) {
247
+ return String(path)
248
+ .replace(/\/api\/v1\/steam\/games\/[^/]+/g, "/api/v1/steam/games/{externalId}")
249
+ .replace(/\/\d+(?=\/|$)/g, "/{id}");
250
+ }
251
+
252
+ function extractSingleAppid(request) {
253
+ const candidates = [
254
+ request.externalId,
255
+ ...(Array.isArray(request.externalIds) ? request.externalIds : []),
256
+ extractAppidFromPath(request.path)
257
+ ].filter(Boolean);
258
+ const unique = [...new Set(candidates.map((value) => String(value)))];
259
+ if (unique.length !== 1 || !/^\d+$/.test(unique[0])) return undefined;
260
+ const appid = Number(unique[0]);
261
+ return Number.isSafeInteger(appid) && appid > 0 ? appid : undefined;
262
+ }
263
+
264
+ function extractAppidFromPath(path) {
265
+ if (!path) return undefined;
266
+ const match = String(path).match(/\/api\/v1\/steam\/games\/([^/]+)/);
267
+ if (!match) return undefined;
268
+ return decodeURIComponent(match[1]);
269
+ }
270
+
271
+ function countExternalIds(request) {
272
+ if (Array.isArray(request.externalIds)) return request.externalIds.length;
273
+ if (request.externalId) return 1;
274
+ return extractAppidFromPath(request.path) ? 1 : 0;
275
+ }
276
+
277
+ function normalizeTokenList(value) {
278
+ const values = Array.isArray(value) ? value : String(value ?? "").split(",");
279
+ const normalized = values
280
+ .map((item) => safeToken(item))
281
+ .filter(Boolean);
282
+ return normalized.length ? [...new Set(normalized)].slice(0, 20) : undefined;
283
+ }
284
+
285
+ function safeToken(value) {
286
+ if (value == null || value === "") return undefined;
287
+ const normalized = String(value)
288
+ .trim()
289
+ .replace(/[^a-zA-Z0-9_.:-]/g, "_")
290
+ .replace(/_+/g, "_")
291
+ .slice(0, 80);
292
+ return normalized || undefined;
293
+ }
294
+
295
+ function safeShortString(value) {
296
+ if (typeof value !== "string") return undefined;
297
+ const trimmed = value.trim();
298
+ return trimmed ? trimmed.slice(0, 120) : undefined;
299
+ }
300
+
301
+ function safeDate(value) {
302
+ if (typeof value !== "string") return undefined;
303
+ return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : undefined;
304
+ }
305
+
306
+ function safeNumber(value) {
307
+ if (value == null || value === "") return undefined;
308
+ const number = Number(value);
309
+ return Number.isFinite(number) ? number : undefined;
310
+ }
311
+
312
+ function boundedInteger(value) {
313
+ const number = Number(value);
314
+ if (!Number.isFinite(number)) return undefined;
315
+ return Math.max(0, Math.round(number));
316
+ }
317
+
318
+ function topLevelRowCount(response) {
319
+ if (Array.isArray(response?.data)) return response.data.length;
320
+ if (Array.isArray(response?.data?.games)) return response.data.games.length;
321
+ if (Array.isArray(response?.data?.cohort?.included)) return response.data.cohort.included.length;
322
+ return undefined;
323
+ }
324
+
325
+ function safeWarnings(response) {
326
+ if (Array.isArray(response?.warnings)) return response.warnings;
327
+ if (Array.isArray(response?.data?.warnings)) return response.data.warnings;
328
+ if (Array.isArray(response?.meta?.warnings)) return response.meta.warnings;
329
+ return [];
330
+ }
331
+
332
+ function safeLockedSections(response) {
333
+ const locked = response?.meta?.entitlements?.locked;
334
+ return Array.isArray(locked) ? locked : [];
335
+ }
336
+
337
+ function compactObject(value) {
338
+ return Object.fromEntries(
339
+ Object.entries(value).filter(([, entry]) => entry !== undefined)
340
+ );
341
+ }
@@ -0,0 +1,121 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import {
5
+ buildCliCommandTelemetryEvent,
6
+ isCliTelemetryDisabled,
7
+ sendCliTelemetryEvent
8
+ } from "./telemetry.js";
9
+
10
+ test("buildCliCommandTelemetryEvent captures safe CLI filters without raw search text", () => {
11
+ const event = buildCliCommandTelemetryEvent({
12
+ command: ["games", "search"],
13
+ options: {
14
+ source: "steam",
15
+ query: "cozy fishing games like Dredge",
16
+ tags: "cozy,fishing",
17
+ "reviews-min": "100",
18
+ "api-key": "gmc_should_not_appear",
19
+ json: true
20
+ },
21
+ positional: [],
22
+ request: {
23
+ path: "/api/v1/steam/games",
24
+ query: {
25
+ q: "cozy fishing games like Dredge",
26
+ tags: "cozy,fishing",
27
+ reviewsMin: "100",
28
+ source: "steam",
29
+ limit: "20"
30
+ }
31
+ },
32
+ response: {
33
+ data: [{ appid: 1 }, { appid: 2 }],
34
+ meta: {
35
+ requestId: "req_123"
36
+ }
37
+ },
38
+ startedAt: 1_000,
39
+ now: 1_250
40
+ });
41
+
42
+ const serialized = JSON.stringify(event);
43
+ assert.equal(event.eventType, "cli_command_executed");
44
+ assert.equal(event.properties.command, "games search");
45
+ assert.deepEqual(event.properties.tags, ["cozy", "fishing"]);
46
+ assert.equal(event.properties.reviewsMin, 100);
47
+ assert.equal(event.properties.hasSearchText, true);
48
+ assert.equal(event.properties.rowCount, 2);
49
+ assert.equal(event.properties.durationMs, 250);
50
+ assert.equal(event.properties.optionKeys.includes("api-key"), false);
51
+ assert.equal(serialized.includes("Dredge"), false);
52
+ assert.equal(serialized.includes("cozy fishing games"), false);
53
+ assert.equal(serialized.includes("gmc_should_not_appear"), false);
54
+ });
55
+
56
+ test("buildCliCommandTelemetryEvent templates app endpoints and attaches single appid", () => {
57
+ const event = buildCliCommandTelemetryEvent({
58
+ command: ["games", "analysis"],
59
+ options: {
60
+ source: "steam",
61
+ sections: "reviews,marketing",
62
+ json: true
63
+ },
64
+ positional: ["1384160"],
65
+ request: {
66
+ path: "/api/v1/steam/games/1384160/analysis",
67
+ query: {
68
+ sections: "reviews,marketing"
69
+ }
70
+ },
71
+ response: {
72
+ data: {
73
+ kind: "game_analysis"
74
+ },
75
+ meta: {
76
+ entitlements: {
77
+ locked: ["marketing.full"]
78
+ }
79
+ }
80
+ },
81
+ startedAt: 2_000,
82
+ now: 2_040
83
+ });
84
+
85
+ assert.equal(event.appid, 1384160);
86
+ assert.equal(event.properties.endpointTemplate, "/api/v1/steam/games/{externalId}/analysis");
87
+ assert.deepEqual(event.properties.sections, ["reviews", "marketing"]);
88
+ assert.equal(event.properties.lockedCount, 1);
89
+ assert.equal(event.properties.resultKind, "game_analysis");
90
+ });
91
+
92
+ test("sendCliTelemetryEvent posts product events with bearer auth and swallows failures", async () => {
93
+ const calls = [];
94
+ const fetchImpl = async (url, init) => {
95
+ calls.push({ url, init });
96
+ throw new Error("network unavailable");
97
+ };
98
+
99
+ await sendCliTelemetryEvent("https://example.test", "gmcc_secret", {
100
+ eventType: "cli_command_executed",
101
+ properties: {
102
+ source: "gmc-cli"
103
+ }
104
+ }, {
105
+ fetchImpl,
106
+ timeoutMs: 20
107
+ });
108
+
109
+ assert.equal(calls.length, 1);
110
+ assert.equal(String(calls[0].url), "https://example.test/api/v1/product-events");
111
+ assert.equal(calls[0].init.method, "POST");
112
+ assert.equal(calls[0].init.headers.authorization, "Bearer gmcc_secret");
113
+ });
114
+
115
+ test("isCliTelemetryDisabled supports opt-out environment values", () => {
116
+ assert.equal(isCliTelemetryDisabled({}), false);
117
+ assert.equal(isCliTelemetryDisabled({ GMC_CLI_TELEMETRY: "0" }), true);
118
+ assert.equal(isCliTelemetryDisabled({ GMC_CLI_TELEMETRY: "1" }), false);
119
+ assert.equal(isCliTelemetryDisabled({ GMC_DISABLE_TELEMETRY: "1" }), true);
120
+ assert.equal(isCliTelemetryDisabled({ GMC_CLI_TELEMETRY: "true" }), false);
121
+ });
package/src/workflows.js CHANGED
@@ -30,7 +30,10 @@ export async function executeRequestPlan(baseUrl, request, { apiKey }) {
30
30
  }
31
31
 
32
32
  async function executeCohortEvidenceRequest(baseUrl, request, { apiKey }) {
33
- const sampled = await executeSearchPages(baseUrl, request.searchRequest, request.sampling, { apiKey });
33
+ const [sampled, count] = await Promise.all([
34
+ executeSearchPages(baseUrl, request.searchRequest, request.sampling, { apiKey }),
35
+ fetchOptionalCount(baseUrl, request.countRequest, { apiKey })
36
+ ]);
34
37
  const search = mergeSearchPages(sampled.searches, request.sampling);
35
38
  const games = Array.isArray(search?.data) ? search.data : [];
36
39
  const analysisResults = request.analysisQuery
@@ -69,6 +72,7 @@ async function executeCohortEvidenceRequest(baseUrl, request, { apiKey }) {
69
72
 
70
73
  return composeCohortEvidencePayload(request, {
71
74
  search,
75
+ count,
72
76
  searches: sampled.searches,
73
77
  searchPageErrors: sampled.pageErrors,
74
78
  analysisResults
@@ -81,8 +85,11 @@ async function executeResolveRequest(baseUrl, request, { apiKey }) {
81
85
  }
82
86
 
83
87
  async function executeBenchmarkRequest(baseUrl, request, { apiKey }) {
84
- const sampled = await executeSearchPages(baseUrl, request.searchRequest, request.sampling, { apiKey });
85
- return composeBenchmarkPayload(request, sampled);
88
+ const [sampled, count] = await Promise.all([
89
+ executeSearchPages(baseUrl, request.searchRequest, request.sampling, { apiKey }),
90
+ fetchOptionalCount(baseUrl, request.countRequest, { apiKey })
91
+ ]);
92
+ return composeBenchmarkPayload(request, { ...sampled, count });
86
93
  }
87
94
 
88
95
  async function executeCompareRequest(baseUrl, request, { apiKey }) {
@@ -116,7 +123,10 @@ async function executeCompareRequest(baseUrl, request, { apiKey }) {
116
123
  }
117
124
 
118
125
  async function executeReviewPatternsRequest(baseUrl, request, { apiKey }) {
119
- const sampled = await executeSearchPages(baseUrl, request.searchRequest, request.sampling, { apiKey });
126
+ const [sampled, count] = await Promise.all([
127
+ executeSearchPages(baseUrl, request.searchRequest, request.sampling, { apiKey }),
128
+ fetchOptionalCount(baseUrl, request.countRequest, { apiKey })
129
+ ]);
120
130
  const search = mergeSearchPages(sampled.searches, request.sampling);
121
131
  const games = Array.isArray(search?.data) ? search.data : [];
122
132
  const topicResults = await Promise.all(
@@ -153,6 +163,7 @@ async function executeReviewPatternsRequest(baseUrl, request, { apiKey }) {
153
163
 
154
164
  return composeReviewPatternsPayload(request, {
155
165
  search,
166
+ count,
156
167
  searches: sampled.searches,
157
168
  searchPageErrors: sampled.pageErrors,
158
169
  topicResults
@@ -243,6 +254,11 @@ async function executeSearchPages(baseUrl, searchRequest, sampling, { apiKey })
243
254
  };
244
255
  }
245
256
 
257
+ async function fetchOptionalCount(baseUrl, countRequest, { apiKey }) {
258
+ if (!countRequest) return undefined;
259
+ return fetchJson(createRequestUrl(baseUrl, countRequest), { apiKey });
260
+ }
261
+
246
262
  function shouldFetchNextSamplePage(sampling, searches) {
247
263
  if (sampling.mode === "sample-pages") return searches.length < sampling.requestedPages;
248
264
  const fetchedRows = searches.reduce((sum, search) => sum + asArray(search?.data).length, 0);
@@ -383,6 +399,8 @@ export function composeBenchmarkPayload(request, search, options = {}) {
383
399
  : search;
384
400
  const games = Array.isArray(mergedSearch?.data) ? mergedSearch.data : [];
385
401
  const sample = buildSampleMetadata(request, searches, games, pageErrors);
402
+ const totalMatches = getTotalMatches(search.count, mergedSearch?.pagination);
403
+ const pagination = withPaginationTotal(mergedSearch?.pagination, totalMatches);
386
404
 
387
405
  return {
388
406
  data: {
@@ -391,19 +409,19 @@ export function composeBenchmarkPayload(request, search, options = {}) {
391
409
  workflow: "gamesBenchmark",
392
410
  inputs: {
393
411
  filters: request.query,
394
- ...(sample ? { sample: buildEvidencePackSample(sample, games.length) } : {
395
- sample: buildReturnedPageSample(mergedSearch?.pagination, games.length)
412
+ ...(sample ? { sample: buildEvidencePackSample(sample, games.length, totalMatches) } : {
413
+ sample: buildReturnedPageSample(pagination, games.length, totalMatches)
396
414
  })
397
415
  },
398
416
  query: request.query,
399
417
  summary: withSummaryBasis(
400
- buildBenchmarkSummary(games, mergedSearch?.pagination, request.source, { sampled: Boolean(sample) }),
418
+ buildBenchmarkSummary(games, pagination, request.source, { sampled: Boolean(sample) }),
401
419
  sample ? "bounded_sample" : "returned_page"
402
420
  ),
403
421
  ...(sample
404
422
  ? {
405
423
  sampledSummary: withSummaryBasis(
406
- buildBenchmarkSummary(games, mergedSearch?.pagination, request.source, { sampled: true }),
424
+ buildBenchmarkSummary(games, pagination, request.source, { sampled: true }),
407
425
  "bounded_sample"
408
426
  )
409
427
  }
@@ -424,11 +442,15 @@ export function composeBenchmarkPayload(request, search, options = {}) {
424
442
  requestId,
425
443
  composedBy: "gmc-cli",
426
444
  ...(sample ? { sample } : {}),
427
- upstreamRequests: buildSearchUpstreamRequests(searches, pageErrors)
445
+ upstreamRequests: [
446
+ ...buildSearchUpstreamRequests(searches, pageErrors),
447
+ ...buildCountUpstreamRequests(search.count)
448
+ ]
428
449
  },
429
- ...(mergedSearch?.pagination ? { pagination: mergedSearch.pagination } : {}),
450
+ ...(pagination ? { pagination } : {}),
430
451
  warnings: uniqueStrings([
431
452
  ...searches.flatMap((entry) => safeWarnings(entry)),
453
+ ...safeWarnings(search.count),
432
454
  ...pageErrors.map((entry) => `benchmark sample page failed at offset ${entry.offset}: ${entry.error?.code ?? "CLI_API_ERROR"}`),
433
455
  sample
434
456
  ? "sampledSummary is computed from bounded sampled pages; it is not a full market-size estimate"
@@ -457,6 +479,7 @@ export function composeReviewPatternsPayload(request, result, options = {}) {
457
479
  const includedGames = normalizedGames.filter((game) => !game.skippedReason);
458
480
  const usableGames = includedGames.filter((game) => game.topics.length > 0);
459
481
  const sample = buildSampleMetadata(request, searches, games, searchPageErrors);
482
+ const totalMatches = getTotalMatches(result.count, result.search?.pagination);
460
483
  const skipped = normalizedGames
461
484
  .filter((game) => game.skippedReason)
462
485
  .map((game) => ({
@@ -469,6 +492,7 @@ export function composeReviewPatternsPayload(request, result, options = {}) {
469
492
  }));
470
493
  const warnings = uniqueStrings([
471
494
  ...safeWarnings(result.search),
495
+ ...safeWarnings(result.count),
472
496
  ...searches.flatMap((entry) => safeWarnings(entry)),
473
497
  ...searchPageErrors.map((entry) => `review pattern sample page failed at offset ${entry.offset}: ${entry.error?.code ?? "CLI_API_ERROR"}`),
474
498
  ...topicResults.flatMap((entry) => safeWarnings(entry.topics)),
@@ -491,8 +515,8 @@ export function composeReviewPatternsPayload(request, result, options = {}) {
491
515
  ...(request.topic ? { topic: request.topic } : {}),
492
516
  ...(request.compareBy ? { compareBy: request.compareBy } : {}),
493
517
  ...(sample
494
- ? { sample: buildEvidencePackSample(sample, usableGames.length) }
495
- : { sample: buildReturnedPageSample(result.search?.pagination, usableGames.length) })
518
+ ? { sample: buildEvidencePackSample(sample, usableGames.length, totalMatches) }
519
+ : { sample: buildReturnedPageSample(result.search?.pagination, usableGames.length, totalMatches) })
496
520
  },
497
521
  cohort: {
498
522
  query: request.query,
@@ -505,7 +529,7 @@ export function composeReviewPatternsPayload(request, result, options = {}) {
505
529
  usableGames: usableGames.length,
506
530
  skippedGames: skipped.length,
507
531
  skippedReasons: tallySkippedReasons(skipped),
508
- totalMatches: result.search?.pagination?.total,
532
+ ...(typeof totalMatches === "number" ? { totalMatches } : {}),
509
533
  included: usableGames.map((game) => ({
510
534
  source: request.source,
511
535
  externalId: game.externalId,
@@ -521,7 +545,7 @@ export function composeReviewPatternsPayload(request, result, options = {}) {
521
545
  skipped
522
546
  },
523
547
  summary: withSummaryBasis(
524
- buildReviewPatternsSummary(request, usableGames, skipped, result.search?.pagination),
548
+ buildReviewPatternsSummary(request, usableGames, skipped, withPaginationTotal(result.search?.pagination, totalMatches)),
525
549
  sample ? "bounded_sample" : "returned_page"
526
550
  ),
527
551
  patterns: buildReviewPatterns(usableGames),
@@ -547,6 +571,7 @@ export function composeReviewPatternsPayload(request, result, options = {}) {
547
571
  ...(sample ? { sample } : {}),
548
572
  upstreamRequests: [
549
573
  ...buildSearchUpstreamRequests(searches, searchPageErrors),
574
+ ...buildCountUpstreamRequests(result.count),
550
575
  ...topicResults
551
576
  .filter((entry) => entry.topics || entry.error)
552
577
  .map((entry) => ({
@@ -559,7 +584,7 @@ export function composeReviewPatternsPayload(request, result, options = {}) {
559
584
  }))
560
585
  ]
561
586
  },
562
- ...(result.search?.pagination ? { pagination: result.search.pagination } : {}),
587
+ ...(result.search?.pagination ? { pagination: withPaginationTotal(result.search.pagination, totalMatches) } : {}),
563
588
  ...(warnings.length ? { warnings } : {})
564
589
  };
565
590
  }
@@ -670,8 +695,10 @@ export function composeCohortEvidencePayload(request, result, options = {}) {
670
695
  ...(entry.error ? { error: entry.error } : {})
671
696
  }));
672
697
  const sample = buildSampleMetadata(request, searches, games, searchPageErrors);
698
+ const totalMatches = getTotalMatches(result.count, result.search?.pagination);
673
699
  const warnings = uniqueStrings([
674
700
  ...safeWarnings(result.search),
701
+ ...safeWarnings(result.count),
675
702
  ...searches.flatMap((entry) => safeWarnings(entry)),
676
703
  ...searchPageErrors.map((entry) => `${request.workflow} sample page failed at offset ${entry.offset}: ${entry.error?.code ?? "CLI_API_ERROR"}`),
677
704
  ...analysisResults.flatMap((entry) => safeWarnings(entry.analysis)),
@@ -688,8 +715,8 @@ export function composeCohortEvidencePayload(request, result, options = {}) {
688
715
  inputs: {
689
716
  filters: request.query,
690
717
  sections: request.sections,
691
- ...(sample ? { sample: buildEvidencePackSample(sample, included.length) } : {
692
- sample: buildReturnedPageSample(result.search?.pagination, included.length)
718
+ ...(sample ? { sample: buildEvidencePackSample(sample, included.length, totalMatches) } : {
719
+ sample: buildReturnedPageSample(result.search?.pagination, included.length, totalMatches)
693
720
  })
694
721
  },
695
722
  cohort: {
@@ -698,7 +725,7 @@ export function composeCohortEvidencePayload(request, result, options = {}) {
698
725
  usableGames: included.length,
699
726
  skippedGames: skipped.length,
700
727
  skippedReasons: tallySkippedReasons(skipped),
701
- totalMatches: result.search?.pagination?.total,
728
+ ...(typeof totalMatches === "number" ? { totalMatches } : {}),
702
729
  included: included.map((entry) => ({
703
730
  source: request.source,
704
731
  externalId: entry.externalId,
@@ -716,12 +743,12 @@ export function composeCohortEvidencePayload(request, result, options = {}) {
716
743
  skipped
717
744
  },
718
745
  summary: withSummaryBasis(
719
- buildCohortEvidenceSummary(request, included, result.search?.pagination),
746
+ buildCohortEvidenceSummary(request, included, withPaginationTotal(result.search?.pagination, totalMatches)),
720
747
  sample ? "bounded_sample" : "returned_page"
721
748
  ),
722
749
  patterns: buildCohortEvidencePatterns(request, included),
723
750
  ...(request.evidenceType === "pricing" ? { segments: buildPricingSegments(included) } : {}),
724
- ...(request.evidenceType === "trends" || request.evidenceType === "brief" ? { trends: buildTrendSummary(included, result.search?.pagination, request.source) } : {}),
751
+ ...(request.evidenceType === "trends" || request.evidenceType === "brief" ? { trends: buildTrendSummary(included, withPaginationTotal(result.search?.pagination, totalMatches), request.source) } : {}),
725
752
  games: included.map((entry) => ({
726
753
  source: request.source,
727
754
  externalId: entry.externalId,
@@ -744,6 +771,7 @@ export function composeCohortEvidencePayload(request, result, options = {}) {
744
771
  ...(sample ? { sample } : {}),
745
772
  upstreamRequests: [
746
773
  ...buildSearchUpstreamRequests(searches, searchPageErrors),
774
+ ...buildCountUpstreamRequests(result.count),
747
775
  ...analysisResults
748
776
  .filter((entry) => entry.analysis || entry.error)
749
777
  .map((entry) => ({
@@ -756,7 +784,7 @@ export function composeCohortEvidencePayload(request, result, options = {}) {
756
784
  }))
757
785
  ]
758
786
  },
759
- ...(result.search?.pagination ? { pagination: result.search.pagination } : {}),
787
+ ...(result.search?.pagination ? { pagination: withPaginationTotal(result.search.pagination, totalMatches) } : {}),
760
788
  ...(warnings.length ? { warnings } : {})
761
789
  };
762
790
  }
@@ -1021,7 +1049,7 @@ function mergeSearchPages(searches, sampling) {
1021
1049
  ...(first.pagination ?? {}),
1022
1050
  limit: first.pagination?.limit,
1023
1051
  offset: first.pagination?.offset,
1024
- total: first.pagination?.total,
1052
+ ...(typeof first.pagination?.total === "number" ? { total: first.pagination.total } : {}),
1025
1053
  nextOffset: last.pagination?.nextOffset
1026
1054
  },
1027
1055
  warnings: uniqueStrings(searches.flatMap((search) => safeWarnings(search)))
@@ -1038,12 +1066,12 @@ function buildSampleMetadata(request, searches, games, pageErrors) {
1038
1066
  fetchedPages: searches.length,
1039
1067
  fetchedGames: games.length,
1040
1068
  skippedPages: pageErrors.length,
1041
- totalMatches: searches[0]?.pagination?.total,
1069
+ ...(typeof searches[0]?.pagination?.total === "number" ? { totalMatches: searches[0].pagination.total } : {}),
1042
1070
  quotaCaveat: "Sampled workflows issue one search request per fetched page, plus downstream per-game requests when the command needs detail evidence."
1043
1071
  };
1044
1072
  }
1045
1073
 
1046
- function buildEvidencePackSample(sample, returnedRows) {
1074
+ function buildEvidencePackSample(sample, returnedRows, totalMatches) {
1047
1075
  return {
1048
1076
  mode: normalizeEvidencePackSampleMode(sample.mode),
1049
1077
  requestedRows: sample.requestedSize,
@@ -1051,16 +1079,31 @@ function buildEvidencePackSample(sample, returnedRows) {
1051
1079
  returnedRows,
1052
1080
  fetchedPages: sample.fetchedPages,
1053
1081
  skippedPages: sample.skippedPages,
1054
- totalMatches: sample.totalMatches
1082
+ totalMatches: typeof totalMatches === "number" ? totalMatches : sample.totalMatches
1055
1083
  };
1056
1084
  }
1057
1085
 
1058
- function buildReturnedPageSample(pagination, returnedRows) {
1086
+ function buildReturnedPageSample(pagination, returnedRows, totalMatches) {
1059
1087
  return {
1060
1088
  mode: "returned_page",
1061
1089
  requestedRows: pagination?.limit,
1062
1090
  returnedRows,
1063
- totalMatches: pagination?.total
1091
+ totalMatches: typeof totalMatches === "number" ? totalMatches : pagination?.total
1092
+ };
1093
+ }
1094
+
1095
+ function getTotalMatches(count, pagination) {
1096
+ const countValue = numericValue(count?.data?.count);
1097
+ if (Number.isFinite(countValue)) return countValue;
1098
+ const paginationTotal = numericValue(pagination?.total);
1099
+ return Number.isFinite(paginationTotal) ? paginationTotal : undefined;
1100
+ }
1101
+
1102
+ function withPaginationTotal(pagination, totalMatches) {
1103
+ if (typeof totalMatches !== "number") return pagination;
1104
+ return {
1105
+ ...(pagination ?? {}),
1106
+ total: totalMatches
1064
1107
  };
1065
1108
  }
1066
1109
 
@@ -1096,6 +1139,17 @@ function buildSearchUpstreamRequests(searches, pageErrors = []) {
1096
1139
  ];
1097
1140
  }
1098
1141
 
1142
+ function buildCountUpstreamRequests(count) {
1143
+ if (!count) return [];
1144
+ return [{
1145
+ kind: "count",
1146
+ requestId: count?.meta?.requestId,
1147
+ source: count?.meta?.source,
1148
+ entitlements: count?.meta?.entitlements,
1149
+ count: count?.data?.count
1150
+ }];
1151
+ }
1152
+
1099
1153
  function buildReviewPatternGame(request, entry) {
1100
1154
  const game = entry.game ?? {};
1101
1155
  const externalId = asString(entry.externalId ?? game?.externalId ?? game?.appid);
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
 
4
- import { composeCohortEvidencePayload } from "./workflows.js";
4
+ import { composeBenchmarkPayload, composeCohortEvidencePayload } from "./workflows.js";
5
5
 
6
6
  test("cohort evidence summarizes skipped games by reason", () => {
7
7
  const request = {
@@ -78,3 +78,30 @@ test("cohort evidence reports an empty reason tally when nothing is skipped", ()
78
78
 
79
79
  assert.deepEqual(payload.data.cohort.skippedReasons, {});
80
80
  });
81
+
82
+ test("benchmark attaches exact total only when a count response is supplied", () => {
83
+ const request = {
84
+ source: "steam",
85
+ query: { sort: "reviews_desc", limit: 20, includeTotal: false }
86
+ };
87
+ const payload = composeBenchmarkPayload(request, {
88
+ searches: [{
89
+ data: [{ appid: 1, title: "Usable", reviews: 100 }],
90
+ pagination: { limit: 20, offset: 0, nextOffset: 20 },
91
+ meta: { requestId: "search_req", source: "steam-analysis" }
92
+ }],
93
+ pageErrors: [],
94
+ count: {
95
+ data: { count: 123 },
96
+ meta: { requestId: "count_req", source: "steam-analysis" }
97
+ }
98
+ }, {
99
+ generatedAt: "2026-06-12T00:00:00.000Z",
100
+ requestId: "req_test"
101
+ });
102
+
103
+ assert.equal(payload.data.summary.totalMatches, 123);
104
+ assert.equal(payload.data.inputs.sample.totalMatches, 123);
105
+ assert.equal(payload.pagination.total, 123);
106
+ assert.deepEqual(payload.meta.upstreamRequests.map((entry) => entry.kind), ["search", "count"]);
107
+ });