@witchpot/gmc 0.6.0 → 0.9.0

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/src/help.js CHANGED
@@ -29,8 +29,11 @@ Usage:
29
29
  gmc tags --q <term> --limit <number> --json
30
30
  gmc lists --json
31
31
  gmc lists create --name <name> --kind manual --json
32
- gmc lists games <id> --json
32
+ gmc lists create --name <name> --kind filter --filter '<json>' --json
33
+ gmc lists games <id> --limit <number> --cursor <cursor> --json
33
34
  gmc lists add <id> --app-ids <appid[,appid...]> --json
35
+ gmc lists resolve <id> --json
36
+ gmc lists materialize <id> --confirm --json
34
37
  gmc games search --source steam --query <text> --json
35
38
  gmc games count --source steam --release-year <year> --reviews-gt <number> --json
36
39
  gmc games aggregate --source steam --metric count,median_reviews --group-by release_year --json
@@ -44,6 +47,11 @@ Usage:
44
47
  gmc campaigns signals --source steam <externalId> --json
45
48
  gmc games compare --source steam <externalId> <externalId...> --focus market --json
46
49
  gmc games topics --source steam <externalId> --json
50
+ gmc games language-topics --source steam <externalId> --json
51
+ gmc games language-topics --source steam <externalId> --min-reviews <number> --include-insufficient --json
52
+ gmc games review-history --source steam <externalId> --json
53
+ gmc games more-like-this --source steam <externalId> --country <country> --json
54
+ gmc games ai-disclosure <appid> --json
47
55
  gmc reviews patterns --source steam --tags <tag_slug[,tag_slug...]> --reviews-min <number> --json
48
56
  gmc reviews patterns --source steam --tags <tag_slug[,tag_slug...]> --sample-size <number> --preset summary --json
49
57
  gmc marketing patterns --source steam --tags <tag_slug[,tag_slug...]> --reviews-min <number> --json
@@ -56,7 +64,12 @@ Usage:
56
64
  gmc cohort brief --source steam --list <id> --json
57
65
  gmc showcases search --query <text> --json
58
66
  gmc showcases fit --source steam <externalId> --deadline-before <YYYY-MM-DD> --json
67
+ gmc showcases history --source steam <externalId> --json
59
68
  gmc showcases patterns --source steam --tags <tag_slug[,tag_slug...]> --json
69
+ gmc steam trend-stories --locale en --json
70
+ gmc steam tag-trends --view featured --json
71
+ gmc steam tag-trends --view table --sort release_share_delta --order desc --json
72
+ gmc steam tag-trends --view tag --tag <tag_slug> --json
60
73
 
61
74
  Tag taxonomy:
62
75
  gmc tags --json List the full tag dictionary (slug, name).
@@ -71,11 +84,193 @@ Tag taxonomy:
71
84
  <term> first to resolve a genre or keyword to valid slugs before
72
85
  building a --tags filter.
73
86
 
87
+ Review topics by review language:
88
+ gmc games language-topics --source steam <externalId> --json
89
+ One game's review topics broken down by
90
+ the language each review was written in.
91
+ --min-reviews <number> Evidence floor applied to the distinct
92
+ review count behind a row, 10-200
93
+ (server default 30).
94
+ --include-insufficient Also return rows below that floor, capped
95
+ by the server, with coverage reporting
96
+ the cut.
97
+
98
+ Read data.evidenceState first. ready means at least one topic x language row
99
+ cleared the floor; insufficient_evidence means rows exist but none cleared
100
+ it; not_analyzed means no rows exist. The last two are thin evidence and no
101
+ data — never report either as zero mentions or absent sentiment.
102
+
103
+ Coverage is the analyzed subset only (about 2,500 titles today), selected
104
+ for review analysis rather than sampled from the catalog. It is not a
105
+ full-catalog population measurement, so do not extrapolate a share of Steam
106
+ from it.
107
+
108
+ Review language is the language a review was written in. It is not a
109
+ country, a region, a market, or a culture, and must not be reported as one.
110
+
111
+ data.rows[].counts and .rates are observed; data.rows[].estimates are
112
+ sample-expanded and carry samplingWeightMethod. Keep the two apart, and date
113
+ every claim with data.snapshot (periodStart/periodEnd/analysisVersion/
114
+ generatedAt).
115
+
116
+ Examples:
117
+ gmc games language-topics --source steam 730 --json
118
+ gmc games language-topics --source steam 730 --min-reviews 50 --json
119
+ gmc games language-topics --source steam 730 --include-insufficient --json
120
+
121
+ Review-count history:
122
+ gmc games review-history --source steam <externalId> --json
123
+ One game's full daily total-review
124
+ series, plus the collected lifecycle and
125
+ announcement events and the observed
126
+ major-discount windows beside it.
127
+
128
+ There are no parameters beyond the external id. The whole series is always
129
+ returned; window it yourself and state the window you chose. A --range,
130
+ --from, --to, --limit or --locale is rejected as a usage error rather than
131
+ ignored, so a narrowing you asked for can never be silently dropped.
132
+
133
+ data.series is published-run-only and contamination-filtered: an observation
134
+ more than 2% below the highest earlier one for the same game is withheld as
135
+ invalid. Differences taken from it are safe to report, but the newest point
136
+ can lag the game's headline review count until that day's run is published.
137
+ Collection can have real gaps, so scale by date and never by array index.
138
+
139
+ Empty data.events or data.discountWindows mean no events were COLLECTED for
140
+ those sources — never that no releases, announcements, or discounts
141
+ happened. Announcements come from official Steam feeds, capped at the most
142
+ recent 1000 items, and are clustered to one event per UTC day.
143
+
144
+ A discount window is an observed state at the >=20% collection threshold,
145
+ not an exact Steam sale period. endDate is EXCLUSIVE (the first observation
146
+ back below the threshold), a null endDate means no end observed yet, and
147
+ discountPercent is the opening observation.
148
+
149
+ Events support temporal comparison only. A review-count movement beside an
150
+ event is co-occurrence, never attribution.
151
+
152
+ Examples:
153
+ gmc games review-history --source steam 730 --json
154
+
155
+ AI-disclosure text and history:
156
+ gmc games ai-disclosure <appid> --json
157
+ One game's developer-authored AI-use
158
+ disclosure text, the store page it was
159
+ read from, and its observed history of
160
+ change. There are no parameters beyond
161
+ the appid.
162
+
163
+ data.state is disclosed, absent, or unconfirmed. absent means no
164
+ disclosure block was observed on the store page -- never that the title
165
+ uses no generative AI. unconfirmed means Game Market Copilot has not yet
166
+ successfully observed the store page either way, including a title never
167
+ attempted at all.
168
+
169
+ data.disclosureText is the developer's own wording, normalized only for
170
+ whitespace and never classified or summarized by Game Market Copilot. It
171
+ is null unless state is disclosed. data.sourceUrl is the store page the
172
+ observation was read from.
173
+
174
+ data.events lists only OBSERVED changes to the state, oldest first; a
175
+ title's first observation is a baseline and never appears as an event.
176
+
177
+ Example:
178
+ gmc games ai-disclosure 730 --json
179
+
180
+ Steam tag trends:
181
+ gmc steam tag-trends --view <view> --json
182
+ Tag supply trends over one frozen monthly
183
+ generation: how the share of new paid
184
+ Steam releases carrying each tag moved
185
+ between two three-month periods.
186
+ --view <view> featured | board | group | table | tag.
187
+ Defaults to featured.
188
+ --family <family> Taxonomy family filter (board/group/table).
189
+ --q <term> Substring over tag slug and name
190
+ (board/group/table).
191
+ --supply-direction <direction> up | down | unresolved | insufficient
192
+ (board/group/table).
193
+ --sort <key> release_share_delta | current_share |
194
+ known_members | median_reviews | slug
195
+ (table only).
196
+ --order <asc|desc> Table sort direction (table only).
197
+ --limit <number> group 1-50, table 1-100.
198
+ --cursor <cursor> Continuation token (group/table).
199
+ --tag <tag_slug> Required for --view tag.
200
+
201
+ Parameters are allowlisted per view and fail closed. Passing one the view
202
+ does not accept is a 400 naming it, not a filter that is quietly ignored, so
203
+ a narrowing you asked for can never be dropped while the answer looks right.
204
+
205
+ Read data.status and data.readiness before quoting anything. status "stale"
206
+ means no newer generation published: the payload keeps its ORIGINAL asOf and
207
+ the values that were truthful then, so date every claim by data.asOf rather
208
+ than by when you ran the command. readiness "limited" is a successful
209
+ partial-data answer, not an error.
210
+
211
+ Report release share as the [lower, upper] interval. The width is UNMEASURED
212
+ titles, not noise: unknownTagMembers are titles with no complete tag
213
+ observation and are never titles known to lack the tag. A
214
+ releaseShareDifferenceRange containing 0 means the data cannot separate the
215
+ two periods.
216
+
217
+ All four directions are measurements. "unresolved" means the difference
218
+ interval spans zero and "insufficient" means the tag is below the evidence
219
+ floor — neither is missing data and neither is "no change".
220
+
221
+ Every observed* field is computed over current.observedReviewMembers titles,
222
+ not over the whole cohort; quote that denominator. item.titles is a bounded
223
+ display sample, not the tag's membership (use current.knownMembers).
224
+
225
+ Before the first generation publishes, the endpoint answers 404
226
+ TAG_TRENDS_UNAVAILABLE. That is the documented pre-publication state, not an
227
+ outage and not worth retrying on a schedule.
228
+
229
+ Examples:
230
+ gmc steam tag-trends --view featured --json
231
+ gmc steam tag-trends --view board --family mechanic --q cozy --json
232
+ gmc steam tag-trends --view table --sort current_share --limit 50 --json
233
+ gmc steam tag-trends --view tag --tag cozy --json
234
+
74
235
  Binary aliases:
75
236
  gmcp and game-market-copilot run the same CLI. Use them when a shell
76
237
  alias shadows gmc (Oh My Zsh's git plugin defines gmc as
77
238
  'git merge --continue').
78
239
 
240
+ Query-backed lists (--kind filter):
241
+
242
+ --filter takes the games API's camelCase filter object, e.g.
243
+ --filter '{"tags":["cozy"],"reviewsMin":500}'
244
+
245
+ It also accepts the version 2 query envelope, which pairs structured
246
+ conditions with an optional natural-language one:
247
+
248
+ {"version":2,"mode":"query",
249
+ "structured":{"tags":["cozy"],"reviewsMin":500},
250
+ "semanticQuery":"games about running a shop",
251
+ "semanticMode":"rank"}
252
+
253
+ structured decides membership. semanticQuery only orders results inside
254
+ that universe under semanticMode "rank" (the default); under "narrow" it
255
+ also narrows membership, and the reported total is then approximate or a
256
+ floor rather than an exact count. Read total_kind in the response before
257
+ quoting any number as exact.
258
+
259
+ A list with a semanticQuery needs a resolution before its games can be
260
+ read: "gmc lists games <id>" answers 409 RESOLUTION_REQUIRED until you run
261
+ "gmc lists resolve <id>". Resolve costs 2 credits every time, including
262
+ when an existing resolution is reused, and it never changes membership.
263
+
264
+ "gmc lists materialize <id> --confirm" FREEZES the list's current members.
265
+ After it the list no longer follows its definition, and the only way back
266
+ is to discard the frozen rows. It costs 2 credits, requires --confirm, and
267
+ takes an optional --expected-revision <n> compare-and-swap so a definition
268
+ edit landing after your resolve fails the call instead of freezing the
269
+ wrong query. A definition using catalogAsOf cannot be frozen at all --
270
+ 400 SNAPSHOT_DEFINITION_UNSUPPORTED, nothing written, and retrying will
271
+ never succeed: a snapshot is a fixed set of games, while that predicate is
272
+ re-evaluated against a growing availability history. Read the list live.
273
+
79
274
  Supported commands:
80
275
  ${SUPPORTED_BETA_COMMANDS.map((command) => ` ${command}`).join("\n")}
81
276
 
@@ -113,6 +308,39 @@ Filters:
113
308
  --coverage <list> Research coverage levels: basic, partial, full.
114
309
  Use --coverage full,partial to keep only games
115
310
  with collected analysis data.
311
+ --has-demo <true|false> Filter on whether the app has a linked demo in
312
+ app_demo_map. false means no demo linkage was
313
+ observed in collected appdetails, not confirmed
314
+ absence.
315
+ --event-name <slug> Restrict to the current participants of one
316
+ Steam event (a steam_events.event_name slug).
317
+ Membership is resolved at query time, so the
318
+ cohort follows collection updates. Unknown slugs
319
+ return INVALID_EVENT with suggestions; list
320
+ valid slugs at GET /api/v1/events.
321
+ --catalog-as-of <date> YYYY-MM-DD. Keep only titles Game Market Copilot
322
+ had NOT observed as delisted on that date. Not a
323
+ snapshot dial: --as-of picks which metrics are
324
+ read, this picks which titles exist, and the two
325
+ combine. Incomplete on purpose -- a title with no
326
+ availability observation by that date is KEPT, so
327
+ a real-data result carries the warning
328
+ catalog_as_of_observation_bounded (plus
329
+ catalog_as_of_predates_coverage when the date is
330
+ older than the observation window) and is not a
331
+ complete historical catalog. A static-fixture
332
+ result carries catalog_as_of_filter_unavailable
333
+ instead: there is no availability history behind
334
+ fixtures, so the filter was not applied at all.
335
+ --release-status <value> released, upcoming, or tba. An exact match on
336
+ top of the guard that already drops any title
337
+ still marked upcoming after its provisional
338
+ release date has passed, on every query.
339
+ --ai-disclosure <value> disclosed, absent, or unconfirmed. absent means
340
+ no AI-use disclosure was observed in collected
341
+ data -- not confirmed non-use. unconfirmed means
342
+ Game Market Copilot has not yet successfully
343
+ observed a disclosure state either way.
116
344
 
117
345
  Sorting:
118
346
  --sort <key> reviews_desc, reviews_asc, rating_desc,
@@ -134,11 +362,20 @@ Count and aggregation:
134
362
  Use gmc games count when you only need the number of matching games.
135
363
  Use gmc games aggregate for grouped metrics such as count by year,
136
364
  median reviews by year, or count by an explicit price/review/rating bucket.
365
+ Add --as-of to read a past published snapshot, and --compare-as-of to get a
366
+ per-app-paired comparison between two dates in data.comparison. Report the
367
+ comparison's exclusions, paired_beyond_lookback and insufficient_coverage
368
+ rather than inferring a trend from them. --compare-as-of does not support
369
+ --group-by tag, primary_market_tag, or ai_disclosure, nor the
370
+ --ai-disclosure filter. It DOES combine with --event-name and
371
+ --catalog-as-of: the comparison cohort applies the same Steam event
372
+ participation and catalog-availability predicates as the rows beside it.
137
373
 
138
374
  Examples:
139
375
  gmc games search --source steam --release-year 2025 --reviews-min 1000 --json
140
376
  gmc games count --source steam --release-year 2025 --reviews-gt 1000 --json
141
377
  gmc games aggregate --source steam --metric count,median_reviews --group-by release_year --reviews-gt 1000 --json
142
378
  gmc games aggregate --source steam --metric count --group-by price_bucket --bucket price:0,5,10,20,40 --json
379
+ gmc games aggregate --source steam --metric count --group-by release_year --as-of 2026-08-01 --compare-as-of 2026-06-01 --json
143
380
  `);
144
381
  }
package/src/index.js CHANGED
@@ -37,5 +37,6 @@ export {
37
37
  composeReviewPatternsPayload,
38
38
  composeResolvePayload,
39
39
  composeShowcaseFitPayload,
40
+ composeShowcaseHistoryPayload,
40
41
  executeRequestPlan
41
42
  } from "./workflows.js";
package/src/main.js CHANGED
@@ -10,7 +10,7 @@ import { matches, parseArgs } from "./args.js";
10
10
  import { handleLocalCommand, loadConfig, parseBaseUrl } from "./config.js";
11
11
  import { printGamesSearchHelp, printHelp } from "./help.js";
12
12
  import { writeJsonError } from "./output.js";
13
- import { applyOutputProjection } from "./projection.js";
13
+ import { applyOutputProjection, assertPresetApplicable } from "./projection.js";
14
14
  import { buildRequest } from "./requests.js";
15
15
  import { trackCliCommandExecuted } from "./telemetry.js";
16
16
  import { executeRequestPlan } from "./workflows.js";
@@ -71,6 +71,12 @@ export async function main(argv = process.argv.slice(2)) {
71
71
  process.exit(EXIT_USAGE);
72
72
  }
73
73
 
74
+ // NAK-804 cross-model review: validated BEFORE the metered request below,
75
+ // not only afterward inside applyOutputProjection — an inapplicable
76
+ // --preset must never pay for the HTTP round trip (or its credit charge)
77
+ // only to be rejected once the response is already back.
78
+ assertPresetApplicable({ fields: options.fields, preset: options.preset, request });
79
+
74
80
  if (request.requiresApiKey && !apiKey) {
75
81
  writeJsonError({
76
82
  code: "CLI_MISSING_CONFIG",
@@ -0,0 +1,130 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { main } from "./main.js";
5
+ import { assertPresetApplicable } from "./projection.js";
6
+
7
+ /**
8
+ * Async counterpart to the `captureUsageFailure` helper in requests.test.js:
9
+ * `main()` is async, so `process.exit` must be mocked to throw INTO the
10
+ * awaited call rather than a synchronous one.
11
+ */
12
+ async function captureCliExit(run) {
13
+ const originalWrite = process.stdout.write;
14
+ const originalExit = process.exit;
15
+ const chunks = [];
16
+ let exitCode;
17
+ process.stdout.write = (chunk) => {
18
+ chunks.push(String(chunk));
19
+ return true;
20
+ };
21
+ process.exit = (code) => {
22
+ exitCode = code;
23
+ throw new Error("__cli_exit__");
24
+ };
25
+ try {
26
+ await run();
27
+ throw new Error("expected the CLI to exit");
28
+ } catch (error) {
29
+ if (error.message !== "__cli_exit__") throw error;
30
+ } finally {
31
+ process.stdout.write = originalWrite;
32
+ process.exit = originalExit;
33
+ }
34
+
35
+ return { output: JSON.parse(chunks.join("")), exitCode };
36
+ }
37
+
38
+ /**
39
+ * NAK-804 cross-model review (Codex Sol): `gmc games ai-disclosure 730
40
+ * --preset summary --json` used to build the request, send it, and only
41
+ * THEN discover in applyOutputProjection that ai-disclosure supports no
42
+ * preset — charging a credit for a call whose answer was always going to be
43
+ * a usage error. The counting fetch stub below is the direct evidence that
44
+ * the metered request never fires: if the ordering regresses, this test
45
+ * fails on `fetchCalls === 0` even though the exit code/message stay right.
46
+ */
47
+ test("(NAK-804) an unsupported --preset is rejected before the metered request executes", async () => {
48
+ const originalFetch = globalThis.fetch;
49
+ let fetchCalls = 0;
50
+ globalThis.fetch = async () => {
51
+ fetchCalls += 1;
52
+ throw new Error("fetch must not be called once --preset is rejected");
53
+ };
54
+
55
+ try {
56
+ const { output, exitCode } = await captureCliExit(() =>
57
+ main(["games", "ai-disclosure", "730", "--preset", "summary", "--json"])
58
+ );
59
+
60
+ assert.equal(fetchCalls, 0);
61
+ assert.equal(exitCode, 2);
62
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
63
+ assert.match(
64
+ output.error.message,
65
+ /--preset summary is supported for gmc reviews patterns, gmc games benchmark, and gmc cohort\/pattern workflows/
66
+ );
67
+ } finally {
68
+ globalThis.fetch = originalFetch;
69
+ }
70
+ });
71
+
72
+ /**
73
+ * The same early check is generic (it reuses resolvePresetFields exactly),
74
+ * not an ai-disclosure special case: an unknown preset NAME is rejected
75
+ * before the request too, for any command.
76
+ */
77
+ test("(NAK-804) an unknown --preset value is rejected before the metered request executes", async () => {
78
+ const originalFetch = globalThis.fetch;
79
+ let fetchCalls = 0;
80
+ globalThis.fetch = async () => {
81
+ fetchCalls += 1;
82
+ throw new Error("fetch must not be called once --preset is rejected");
83
+ };
84
+
85
+ try {
86
+ const { output, exitCode } = await captureCliExit(() =>
87
+ main(["games", "ai-disclosure", "730", "--preset", "not-a-real-preset", "--json"])
88
+ );
89
+
90
+ assert.equal(fetchCalls, 0);
91
+ assert.equal(exitCode, 2);
92
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
93
+ assert.match(output.error.message, /--preset must be one of: summary/);
94
+ } finally {
95
+ globalThis.fetch = originalFetch;
96
+ }
97
+ });
98
+
99
+ /**
100
+ * Negative control on the pure check itself (no CLI/network plumbing
101
+ * needed): a request `kind` that IS in the preset allowlist must not be
102
+ * rejected — the early check only intercepts INAPPLICABLE presets, not
103
+ * every --preset call.
104
+ */
105
+ test("(NAK-804) assertPresetApplicable does not reject a preset the request kind supports", () => {
106
+ assert.doesNotThrow(() =>
107
+ assertPresetApplicable({ preset: "summary", request: { kind: "reviewPatterns" } })
108
+ );
109
+ assert.doesNotThrow(() =>
110
+ assertPresetApplicable({ preset: "summary", request: { kind: "benchmark" } })
111
+ );
112
+ assert.doesNotThrow(() =>
113
+ assertPresetApplicable({ preset: "summary", request: { kind: "cohortEvidence" } })
114
+ );
115
+ });
116
+
117
+ /**
118
+ * Same precedence rule applyOutputProjection enforces: --fields silences an
119
+ * otherwise-inapplicable --preset entirely, so passing both together must
120
+ * not trip the early check either.
121
+ */
122
+ test("(NAK-804) assertPresetApplicable defers to --fields precedence, like applyOutputProjection", () => {
123
+ assert.doesNotThrow(() =>
124
+ assertPresetApplicable({
125
+ fields: "state",
126
+ preset: "summary",
127
+ request: { kind: undefined }
128
+ })
129
+ );
130
+ });
package/src/projection.js CHANGED
@@ -47,6 +47,27 @@ export function applyOutputProjection(payload, { fields, preset, request } = {})
47
47
  return applyFieldsProjection(payload, presetFields, `preset ${preset}`);
48
48
  }
49
49
 
50
+ /**
51
+ * NAK-804 cross-model review (Codex Sol): every metered command built a
52
+ * usage-error-only exit on an inapplicable --preset, but the check ran
53
+ * INSIDE applyOutputProjection, which main() calls only AFTER the HTTP
54
+ * request already executed (and, for a metered endpoint, already charged a
55
+ * credit). This is the same validation, called right after buildRequest()
56
+ * and before that request is ever sent, so the usage error lands before any
57
+ * network call or charge. General fix, not ai-disclosure-specific: it reuses
58
+ * resolvePresetFields exactly, so any command missing from its
59
+ * kind-allowlist gets the early rejection for free.
60
+ *
61
+ * Mirrors applyOutputProjection's own precedence: --fields silences --preset
62
+ * entirely (never even inspected) both here and there, so the two agree on
63
+ * every input rather than only on the paths that reach a real request.
64
+ */
65
+ export function assertPresetApplicable({ fields, preset, request } = {}) {
66
+ if (fields != null && fields !== "") return;
67
+ if (preset == null || preset === "") return;
68
+ resolvePresetFields(preset, request);
69
+ }
70
+
50
71
  function resolvePresetFields(preset, request) {
51
72
  if (preset !== "summary") {
52
73
  writeJsonError({