@witchpot/gmc 0.6.0 → 0.8.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/CHANGELOG.md +58 -0
- package/README.md +3 -0
- package/package.json +2 -2
- package/skill/gmc-analysis/CHANGELOG.md +172 -0
- package/skill/gmc-analysis/SKILL.md +372 -7
- package/skill/gmc-analysis.manifest.json +2 -2
- package/src/args.js +42 -4
- package/src/constants.js +20 -3
- package/src/help.js +208 -1
- package/src/index.js +1 -0
- package/src/requests.js +399 -2
- package/src/requests.test.js +870 -0
- package/src/workflows.js +15 -1
- package/src/workflows.test.js +156 -0
|
@@ -8,7 +8,7 @@ description: >-
|
|
|
8
8
|
or campaigns that GMC can answer — including turning results into
|
|
9
9
|
publish-ready branded chart cards.
|
|
10
10
|
metadata:
|
|
11
|
-
version: 0.
|
|
11
|
+
version: 0.15.0
|
|
12
12
|
---
|
|
13
13
|
|
|
14
14
|
# GMC Analysis
|
|
@@ -51,8 +51,32 @@ applies identically. Mechanics map as follows:
|
|
|
51
51
|
- Cohort evidence (`reviews patterns`, `cohort ...`) -> `cohort_evidence`,
|
|
52
52
|
`cohort_review_categories`, `search_review_claims`.
|
|
53
53
|
- Single-title drill-down (`games detail` / `games analysis`) ->
|
|
54
|
-
`game_profile`; name resolution -> `resolve`;
|
|
55
|
-
`showcase_fit
|
|
54
|
+
`game_profile`; name resolution -> `resolve`; showcase submission
|
|
55
|
+
candidates -> `showcase_fit`; per-title participation history ->
|
|
56
|
+
`showcase_history`; Steam store-page neighbours (`games more-like-this`)
|
|
57
|
+
-> `more_like_this`; review topics by review language (`games
|
|
58
|
+
language-topics`) -> `game_language_topics`; review-count history with
|
|
59
|
+
its lifecycle/announcement/discount context (`games review-history`) ->
|
|
60
|
+
`game_review_history`.
|
|
61
|
+
- Per-family collection status ("has GMC actually collected X for this
|
|
62
|
+
game?") has no CLI command yet and is MCP-only: `game_collection_status`
|
|
63
|
+
(see the dedicated section below).
|
|
64
|
+
- Publisher/developer questions ("what else has this studio shipped", how
|
|
65
|
+
often they release, how big their audience is) have no CLI command yet
|
|
66
|
+
and are MCP-only: `entity_resolve` turns a raw Steam publisher/developer
|
|
67
|
+
string into a stable `entityId`, then `entity_profile` returns that
|
|
68
|
+
entity's portfolio, composition, release cadence and audience footprint.
|
|
69
|
+
Always resolve first — a game record credits names, never ids — and pass
|
|
70
|
+
the id, since one entity commonly has several raw spellings and a
|
|
71
|
+
name-keyed lookup silently omits the titles credited to the siblings.
|
|
72
|
+
`resolved: null` means that exact string (case-sensitive, whitespace
|
|
73
|
+
included) is not in the master tables; it never means the entity has no
|
|
74
|
+
titles. Read `aggregatesAvailability` before anything else:
|
|
75
|
+
`not_collected` means the aggregates were not computed yet, so the block
|
|
76
|
+
is absent rather than zero, and it never means the studio has published
|
|
77
|
+
nothing — `pagination.total` still carries the live title count. A null
|
|
78
|
+
`totalFollowers` is not collected, never zero, and follower counts can
|
|
79
|
+
never be summed across platforms.
|
|
56
80
|
- The response envelope differs from the CLI: MCP tool responses carry
|
|
57
81
|
`meta.quota` = `{ used, limit, remaining, resets_at }` (credits) plus
|
|
58
82
|
`meta.credits_charged`, `meta.basis`, `meta.denominator`,
|
|
@@ -103,10 +127,13 @@ A Game List is a workspace-scoped, reusable cohort input — persist a filter
|
|
|
103
127
|
on every call.
|
|
104
128
|
|
|
105
129
|
- Manage lists via `gmc lists ...` (`list`, `create`, `show`, `games`, `add`,
|
|
106
|
-
`remove`, `delete`) or the MCP tools
|
|
107
|
-
`
|
|
108
|
-
`
|
|
109
|
-
|
|
130
|
+
`remove`, `delete`, `resolve`, `materialize`) or the MCP tools
|
|
131
|
+
`list_game_lists` / `get_game_list` / `create_game_list` /
|
|
132
|
+
`update_game_list` / `add_game_list_items` / `remove_game_list_item` /
|
|
133
|
+
`delete_game_list` / `resolve_game_list` / `materialize_game_list`. All are
|
|
134
|
+
scoped to the caller's own workspace. The first seven are 0-credit;
|
|
135
|
+
`resolve_game_list` and `materialize_game_list` cost 2 each (see
|
|
136
|
+
"Query-backed lists" below).
|
|
110
137
|
- Two kinds: `manual` (explicit appid membership) and `filter` (a stored
|
|
111
138
|
`GameFilter`, re-evaluated live against current data on every read). The
|
|
112
139
|
MCP `game_list_id` input accepts `filter`-kind lists only; the CLI
|
|
@@ -124,8 +151,56 @@ on every call.
|
|
|
124
151
|
calls — diff hashes across calls instead of re-diffing the filter
|
|
125
152
|
yourself.
|
|
126
153
|
|
|
154
|
+
### Query-backed lists (structured plus natural language)
|
|
155
|
+
|
|
156
|
+
A `filter` list's definition may also be the version 2 query envelope:
|
|
157
|
+
|
|
158
|
+
```json
|
|
159
|
+
{"version":2,"mode":"query",
|
|
160
|
+
"structured":{"tags":["cozy"],"reviewsMin":500},
|
|
161
|
+
"semanticQuery":"games about running a shop",
|
|
162
|
+
"semanticMode":"rank"}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Read it exactly this way, and say it this way to the user:
|
|
166
|
+
|
|
167
|
+
- **`structured` decides membership.** Under the default `semanticMode:"rank"`
|
|
168
|
+
the natural-language part only ORDERS results inside that universe; it never
|
|
169
|
+
adds a game the structured conditions exclude. Under `"narrow"` it also
|
|
170
|
+
narrows membership.
|
|
171
|
+
- **The total may not be a catalog count.** `meta.resolution.total_kind` says
|
|
172
|
+
which: `exact`, `approximate`, `at_least` (a floor), `lexical_only` (the total
|
|
173
|
+
counts the lexical population while the page is wider), or `clipped`. Anything
|
|
174
|
+
other than `exact` must be reported as approximate. Never restate it as an
|
|
175
|
+
exact number.
|
|
176
|
+
- **A `semanticQuery` list needs a resolution before its games can be read.**
|
|
177
|
+
`list_games` / `get_game_list` / `gmc lists games <id>` answer
|
|
178
|
+
`RESOLUTION_REQUIRED` until `resolve_game_list` (CLI: `gmc lists resolve <id>`)
|
|
179
|
+
has run. Resolve costs 2 credits every call, including when an existing
|
|
180
|
+
resolution is reused, and it never changes membership.
|
|
181
|
+
- **Do not pass `sort` or `as_of` when reading a `semanticQuery` list or a
|
|
182
|
+
frozen one.** Both are refused with `INVALID_INPUT`: such a list is served in
|
|
183
|
+
its resolved ranking order or its stored order, so a sort you supplied could
|
|
184
|
+
only be ignored. Omit them.
|
|
185
|
+
- **If a resolve comes back `CONFLICT`, someone edited the list while it ran.**
|
|
186
|
+
Re-read the list and resolve again; `details.reason` is
|
|
187
|
+
`definition_revision_mismatch`. `NOT_FOUND` or `SNAPSHOT_IS_FROZEN` from a
|
|
188
|
+
resolve means the list was deleted or frozen mid-flight.
|
|
189
|
+
- **`materialize_game_list` is not the same operation.** It FREEZES the current
|
|
190
|
+
members, so the list stops following its definition, and the only way back is
|
|
191
|
+
to discard the frozen rows. It costs 2 credits, requires `confirm: true` (CLI:
|
|
192
|
+
`--confirm`), and must never be called speculatively — if the user only wants
|
|
193
|
+
the list evaluated now, `resolve_game_list` is the non-destructive option.
|
|
194
|
+
|
|
127
195
|
Sharp edges:
|
|
128
196
|
|
|
197
|
+
- **A `semanticQuery` list cannot be aggregated.** `market_aggregate` and
|
|
198
|
+
`cohort_review_categories` (and `cohort_evidence`) reject it with
|
|
199
|
+
`SEMANTIC_LIST_UNSUPPORTED_FOR_TOOL`, in `rank` mode as well as `narrow`.
|
|
200
|
+
They could only aggregate the structured half, which is a different population
|
|
201
|
+
from the one the list contains — a wrong number, not a degraded one. Use
|
|
202
|
+
`list_games` with the `game_list_id` to read its games. A **structured-only**
|
|
203
|
+
v2 envelope has no such limit and behaves exactly like a flat filter.
|
|
129
204
|
- **Composite (union) lists** work only with `list_games`/`cohort_evidence`;
|
|
130
205
|
`market_aggregate`/`cohort_review_categories` reject them with
|
|
131
206
|
`COMPOSITE_LIST_UNSUPPORTED_FOR_TOOL` (those two are single aggregate
|
|
@@ -139,6 +214,296 @@ Sharp edges:
|
|
|
139
214
|
`CONFIRMATION_REQUIRED`, naming the list; only pass `confirm` set to that
|
|
140
215
|
exact name after the user has explicitly asked to delete it — never
|
|
141
216
|
guess or pre-fill it speculatively.
|
|
217
|
+
- `list_game_lists` reports `membership` per row and `get_game_list` adds
|
|
218
|
+
`definition_revision` and `last_resolved_at`. A `membership:"snapshot"` list
|
|
219
|
+
is frozen: it serves stored rows, ignores its definition, and answers
|
|
220
|
+
`SNAPSHOT_IS_FROZEN` if you try to resolve it.
|
|
221
|
+
|
|
222
|
+
## Showcase workflows (fit + history)
|
|
223
|
+
|
|
224
|
+
Two complementary surfaces; use them as a pair for "where should we
|
|
225
|
+
submit?" questions:
|
|
226
|
+
|
|
227
|
+
- **Forward-looking fit**: `gmc showcases fit --source steam <externalId>
|
|
228
|
+
--json` (MCP: `showcase_fit`) — tag-overlap candidates from the public
|
|
229
|
+
showcase directory, filtered to currently recommendable entries.
|
|
230
|
+
- **Participation history**: `gmc showcases history --source steam
|
|
231
|
+
<externalId> --json` (MCP: `showcase_history`) — which showcases/events a
|
|
232
|
+
title actually appeared in, with a `directory` link (series, next
|
|
233
|
+
edition, submission window) attached when the event name resolves to the
|
|
234
|
+
directory. The chain "similar games -> which showcases did they attend ->
|
|
235
|
+
next edition deadline" is: resolve the cohort, run history per title,
|
|
236
|
+
then read the next-edition field on the linked rows.
|
|
237
|
+
- Field casing differs by transport: the CLI keeps the API's camelCase
|
|
238
|
+
(`directory.nextEdition.submissionCloses`), while the MCP tool emits
|
|
239
|
+
snake_case (`directory.next_edition.submission_closes`). Look for the
|
|
240
|
+
casing that matches the surface you called before concluding a field is
|
|
241
|
+
absent.
|
|
242
|
+
|
|
243
|
+
Interpretation guardrails (these are honesty rules, not suggestions):
|
|
244
|
+
|
|
245
|
+
- Read the response's warnings/caveats BEFORE interpreting missing
|
|
246
|
+
`directory` fields. On plan-limited responses the appearance rows remain
|
|
247
|
+
but every directory link is redacted (the CLI summary then reports
|
|
248
|
+
`linkedToDirectoryCount: null`, and a plan-limited warning is attached) —
|
|
249
|
+
that state is **indeterminate**, not "unlinked". The same applies when a
|
|
250
|
+
directory-lookup warning is present.
|
|
251
|
+
- Absent such warnings, a row without `directory` means the event name is
|
|
252
|
+
**not linked** to the directory (unresolved alias, or a non-showcase
|
|
253
|
+
event such as an award show) — it NEVER means the appearance didn't
|
|
254
|
+
happen. Do not drop unlinked rows from the story; label them as
|
|
255
|
+
unlinked.
|
|
256
|
+
- Over MCP, `showcase_history` merges a second source (`steam_event`,
|
|
257
|
+
Steam-operated events like Next Fest). A `meta.warnings` entry saying the
|
|
258
|
+
steam_event source is unavailable means that source was NOT collected —
|
|
259
|
+
report "research-source only", never "zero Steam event participations".
|
|
260
|
+
The CLI command covers the research source only and says so in its
|
|
261
|
+
warnings.
|
|
262
|
+
- The next-edition data depends on the directory's edition coverage,
|
|
263
|
+
which is still sparse for submission windows — absence of the
|
|
264
|
+
next-edition field (or of its submission-close date) means "window
|
|
265
|
+
unknown", not "no upcoming edition". When present, check the
|
|
266
|
+
submission-close date against today before recommending a submission.
|
|
267
|
+
|
|
268
|
+
## Steam "More Like This" neighbours
|
|
269
|
+
|
|
270
|
+
`gmc games more-like-this --source steam <externalId> --json` (MCP:
|
|
271
|
+
`more_like_this`) returns the games Steam itself showed in a title's
|
|
272
|
+
store-page "More Like This" carousel, for one storefront country.
|
|
273
|
+
`--country` accepts `de`, `fr`, `gb`, `jp`, `kr`, `us` and defaults to
|
|
274
|
+
`us`; omit the flag unless the question is about a specific storefront.
|
|
275
|
+
|
|
276
|
+
Use it as a cohort seed — "who does Steam put next to this game?" — then
|
|
277
|
+
feed the candidate appids into `coverage_check` and the cohort workflows
|
|
278
|
+
above. It is an observation of Steam's storefront, not a computed
|
|
279
|
+
similarity model, so it answers "what does Steam associate?" and not "what
|
|
280
|
+
is objectively similar?".
|
|
281
|
+
|
|
282
|
+
Interpretation guardrails (honesty rules, not suggestions):
|
|
283
|
+
|
|
284
|
+
- `position` is the order Steam displayed the candidate. It is **not** a
|
|
285
|
+
similarity score and not a relevance ranking. Never sort by it as if it
|
|
286
|
+
measured closeness, and never say "the most similar game is X" because X
|
|
287
|
+
sat at position 1.
|
|
288
|
+
- An empty candidate list is ambiguous until you read `availability`.
|
|
289
|
+
`observed_empty` means the store page was collected and the carousel was
|
|
290
|
+
empty; `not_observed` means that game and country pair has not been
|
|
291
|
+
collected yet. Report the second as missing collection — presenting it as
|
|
292
|
+
"this game has no similar games" states a collection gap as a fact about
|
|
293
|
+
the market.
|
|
294
|
+
- `stale: true` means the observation is older than `staleAfterDays`. The
|
|
295
|
+
candidates are still returned in full; date the claim ("as observed in
|
|
296
|
+
<month>") instead of dropping it or presenting it as current.
|
|
297
|
+
- Candidates with `resolved: false` are part of the observed set but have
|
|
298
|
+
no catalog details (delisted, or not a game-type app). Keep them in the
|
|
299
|
+
count and label them unresolved; do not silently drop them.
|
|
300
|
+
|
|
301
|
+
## Review topics by review language
|
|
302
|
+
|
|
303
|
+
`gmc games language-topics --source steam <externalId> --json` (MCP:
|
|
304
|
+
`game_language_topics`) breaks one game's review topics down by the
|
|
305
|
+
language each review was written in — one row per topic cluster x
|
|
306
|
+
language. `--min-reviews <10-200>` moves the evidence floor (server
|
|
307
|
+
default 30); `--include-insufficient` also returns the rows below it,
|
|
308
|
+
capped by the server. Those two flags are the whole parameter surface,
|
|
309
|
+
matching the API exactly: there is no language filter, topic filter, or row
|
|
310
|
+
limit, so narrow by reading the rows rather than by inventing a flag.
|
|
311
|
+
|
|
312
|
+
Use it for "which topics land differently depending on the language players
|
|
313
|
+
review in?" — a localization and community-priority question, not a
|
|
314
|
+
market-sizing one.
|
|
315
|
+
|
|
316
|
+
Field paths differ by surface: the CLI and REST wrap the payload, so read
|
|
317
|
+
`data.evidenceState` / `data.snapshot` / `data.coverage`. The MCP tool
|
|
318
|
+
returns the same fields at the top level, so read `evidenceState` /
|
|
319
|
+
`snapshot` / `coverage`. The guardrails below name the CLI path; drop the
|
|
320
|
+
`data.` prefix over MCP.
|
|
321
|
+
|
|
322
|
+
Interpretation guardrails (honesty rules, not suggestions):
|
|
323
|
+
|
|
324
|
+
- **Read `data.evidenceState` first.** `ready` = at least one topic x
|
|
325
|
+
language row cleared the floor. `insufficient_evidence` = rows exist but
|
|
326
|
+
none cleared it — thin evidence, not absent sentiment. `not_analyzed` =
|
|
327
|
+
no rows exist — no data, not zero mentions. Neither of the last two is
|
|
328
|
+
ever a zero, and neither may be reported as "no complaints in that
|
|
329
|
+
language".
|
|
330
|
+
- **The population is the analyzed subset**, about 2,500 titles today,
|
|
331
|
+
selected for review analysis rather than sampled from the catalog. Say so
|
|
332
|
+
every time, and never extrapolate a share of Steam from it.
|
|
333
|
+
- **Review language is not geography.** It is the language the text was
|
|
334
|
+
written in — never a country, a region, a market, or a culture. "Reviews
|
|
335
|
+
written in German" is a claim you can make; "German players" is not.
|
|
336
|
+
- **Observed and estimated are separate objects.** `counts`/`rates` are
|
|
337
|
+
observed; `estimates` are sample-expanded and carry
|
|
338
|
+
`samplingWeightMethod`. Never combine them in one figure, and always say
|
|
339
|
+
which one a number came from.
|
|
340
|
+
- **Name the denominator.** `counts.sourceReviews`, `sampledReviews`,
|
|
341
|
+
`analyzedReviews`, and `distinctReviews` are four different bases;
|
|
342
|
+
`min_reviews` is applied to `distinctReviews`.
|
|
343
|
+
- **Date the claim.** `data.snapshot` carries `periodStart`/`periodEnd`,
|
|
344
|
+
`analysisVersion`, and `generatedAt`. The aggregate is refreshed by the
|
|
345
|
+
reviews pipeline, so report it as a snapshot rather than as current.
|
|
346
|
+
- **`data.coverage` survives the plan lock.** When
|
|
347
|
+
`reviews.topicLanguages.full` is locked you receive at most 5 rows with
|
|
348
|
+
`estimates` reduced to `samplingWeightMethod`, but `coverage` still
|
|
349
|
+
states how much evidence exists — present the rows as a preview and name
|
|
350
|
+
the lock key.
|
|
351
|
+
|
|
352
|
+
## Which review count you are looking at
|
|
353
|
+
|
|
354
|
+
Steam's `appreviews` endpoint returns two different totals depending on
|
|
355
|
+
`purchase_type`, and the gap is not a rounding difference. Measured
|
|
356
|
+
2026-07-28: appid 570 came back with 14,375 under the default and 2,746,987
|
|
357
|
+
with `purchase_type=all`.
|
|
358
|
+
|
|
359
|
+
- `steam` is the endpoint DEFAULT and counts only reviewers who activated the
|
|
360
|
+
game on Steam. **Every plain `reviews` field in every tool and CLI payload
|
|
361
|
+
is this family**, including list rows, the `reviews_desc` sort, the
|
|
362
|
+
`reviews_min`/`reviews_max` filters, `market_aggregate`'s `*_reviews`
|
|
363
|
+
metrics, and the entity portfolio aggregates.
|
|
364
|
+
- `all` counts every reviewer and is a strict superset. It is GMC's headline
|
|
365
|
+
current total and is served by `game_profile` as `detail.reviewCounts`.
|
|
366
|
+
|
|
367
|
+
Rules:
|
|
368
|
+
- **Name the family and the observation date** with any count you report.
|
|
369
|
+
"2.7M reviews across all purchase types, observed 2026-08-11" is a claim;
|
|
370
|
+
"2.7M reviews" is not.
|
|
371
|
+
- **`allAvailable: false` means not collected.** The wider count does not
|
|
372
|
+
exist for that title yet. It is never zero, and the steam number beside it
|
|
373
|
+
must not be presented as if it were the wide one.
|
|
374
|
+
- **Compare the families only through `steamAtAllObservation`.** The
|
|
375
|
+
top-level `steam` observation can be newer than `all`, so a share taken
|
|
376
|
+
across the two would mix dates. `steamShare` is already computed from the
|
|
377
|
+
same-row pair.
|
|
378
|
+
- **Derive a positive rate inside one family.** `snapshot.rating` is the
|
|
379
|
+
steam-derived percentage; pairing it with an all-family total is a
|
|
380
|
+
mixed-family figure.
|
|
381
|
+
- **Never splice the families into one series.** The all family only starts
|
|
382
|
+
at its capture epoch, so a joined line shows a jump that never happened.
|
|
383
|
+
`game_review_history` and `game_profile.series` both carry
|
|
384
|
+
`family: "steam"` for their whole length, which means their last point is
|
|
385
|
+
normally SMALLER than the all-family headline. They are not
|
|
386
|
+
interchangeable, and a difference between them is not a decline.
|
|
387
|
+
- **Neither family is sales.** A review count is not units, owners, copies,
|
|
388
|
+
or revenue.
|
|
389
|
+
|
|
390
|
+
## Review-count history and the events around it
|
|
391
|
+
|
|
392
|
+
`gmc games review-history --source steam <externalId> --json` (MCP:
|
|
393
|
+
`game_review_history`) returns one game's full daily total-review series
|
|
394
|
+
plus the collected market context beside it: `events` (release,
|
|
395
|
+
early-access, demo lifecycle points and clustered official announcements)
|
|
396
|
+
and `discountWindows` (observed major-discount states).
|
|
397
|
+
|
|
398
|
+
There are no flags. The endpoint takes no query parameters, so the whole
|
|
399
|
+
series comes back every time — window it yourself when the question has a
|
|
400
|
+
range, and say which window you chose. Do not look for a `--range` or
|
|
401
|
+
`--from`/`--to`; narrowing belongs in your reading, not in the request. The
|
|
402
|
+
CLI rejects those flags with a usage error rather than ignoring them, so
|
|
403
|
+
that failure is not transient — drop the flag and window the result.
|
|
404
|
+
|
|
405
|
+
Use it for "how did this title's reviews accumulate, and what was going on
|
|
406
|
+
around the jumps?" — a timeline question. It answers what was OBSERVED near
|
|
407
|
+
a movement, never what caused it.
|
|
408
|
+
|
|
409
|
+
Field paths differ by surface: the CLI and REST wrap the payload, so read
|
|
410
|
+
`data.series` / `data.events` / `data.discountWindows`. The MCP tool returns
|
|
411
|
+
the same fields at the top level. The guardrails below name the CLI path;
|
|
412
|
+
drop the `data.` prefix over MCP.
|
|
413
|
+
|
|
414
|
+
Interpretation guardrails (honesty rules, not suggestions):
|
|
415
|
+
|
|
416
|
+
- **Scale by date, never by array index.** Collection has real gaps, so two
|
|
417
|
+
adjacent entries are not two adjacent days. A chart or a rate computed on
|
|
418
|
+
index positions is wrong.
|
|
419
|
+
- **The series is cleaned, and it can lag.** It is published-run-only and
|
|
420
|
+
contamination-filtered: an observation more than 2% below the highest
|
|
421
|
+
earlier one for the same game is withheld as invalid. Differences taken
|
|
422
|
+
from it are safe to report, but its last point can be OLDER than the
|
|
423
|
+
game's headline review count until that day's run is published. Date the
|
|
424
|
+
claim. An empty series means no published daily observation has covered
|
|
425
|
+
the title yet — not a review count of zero.
|
|
426
|
+
- **Empty event arrays are collection states.** An empty `data.events` or
|
|
427
|
+
`data.discountWindows` means nothing was COLLECTED for that source. Never
|
|
428
|
+
report it as "no announcements", "no updates", or "never discounted".
|
|
429
|
+
- **The announcement list is capped.** Official Steam feeds only, most
|
|
430
|
+
recent 1000 items before clustering, one cluster per UTC day (`count` is
|
|
431
|
+
the cluster size). For a long-lived game the oldest announcements are
|
|
432
|
+
absent, so this is not its complete announcement history.
|
|
433
|
+
- **A discount window is a threshold observation, not a sale.** It is an
|
|
434
|
+
observed state at the >=20% collection threshold. `endDate` is the first
|
|
435
|
+
observation back below the threshold and is NOT part of the window, and
|
|
436
|
+
because observations have gaps you cannot infer the actual last discounted
|
|
437
|
+
day from it. `endDate: null` means no end has been observed yet;
|
|
438
|
+
`discountPercent` is the opening observation, not the deepest or final
|
|
439
|
+
depth.
|
|
440
|
+
- **Temporal comparison only.** "Reviews rose in the week after the update"
|
|
441
|
+
is a claim you can make; "the update caused the rise" is not.
|
|
442
|
+
|
|
443
|
+
## Per-family collection status
|
|
444
|
+
|
|
445
|
+
`game_collection_status` (MCP-only, no CLI command) returns per-signal-family
|
|
446
|
+
collection status for one appid: `families[]` always carries all six —
|
|
447
|
+
`metadata`, `reviews`, `research`, `followers`, `social`, `ccu` — each with a
|
|
448
|
+
`state` of `not_collected`, `enrolled`, `current`, `stale`, `unsupported`, or
|
|
449
|
+
`unknown`. Use it for "has GMC actually collected X for this game yet?"
|
|
450
|
+
questions before treating a thin or empty result from another tool as a real
|
|
451
|
+
finding about the title.
|
|
452
|
+
|
|
453
|
+
Interpretation guardrails (honesty rules, not suggestions):
|
|
454
|
+
|
|
455
|
+
- **`unknown` is not `not_collected`.** They differ in KIND, not degree.
|
|
456
|
+
`not_collected` is a positive finding — nothing has been collected for that
|
|
457
|
+
family. `unknown` means the status itself could not be determined (the read
|
|
458
|
+
model was unreachable, or a preceding catalogue read failed) and is
|
|
459
|
+
evidence of neither collection nor non-collection. Never restate an
|
|
460
|
+
`unknown` family as uncollected.
|
|
461
|
+
- **`available: false` and the `collection_status_unavailable` warning are
|
|
462
|
+
coupled.** When the whole status could not be determined, every family
|
|
463
|
+
reports `unknown` and the bare token `collection_status_unavailable`
|
|
464
|
+
appears in `warnings` if and only if `available` is `false` — branch on
|
|
465
|
+
either alone.
|
|
466
|
+
- **`requestable` is narrow.** It says only whether the product's
|
|
467
|
+
detailed-analysis request routes demand to that family TODAY — only
|
|
468
|
+
`research` is `true`. It is never a statement about any other family's own
|
|
469
|
+
collection schedule, and requesting analysis never starts collecting a
|
|
470
|
+
family whose `requestable` is `false`.
|
|
471
|
+
- **Read-only.** Calling this tool never enqueues, enrolls, or prioritizes
|
|
472
|
+
collection work, no matter how many times it is called — it is a status
|
|
473
|
+
read, not a collection request.
|
|
474
|
+
- An unknown appid returns `NOT_FOUND`, with the fixed weight-1 credit
|
|
475
|
+
already charged — resolve the appid with `resolve`/`list_games` first
|
|
476
|
+
rather than probing.
|
|
477
|
+
|
|
478
|
+
## AI disclosure state
|
|
479
|
+
|
|
480
|
+
Every title carries a tri-state AI-disclosure read from its Steam store page:
|
|
481
|
+
`disclosed`, `absent`, or `unconfirmed`. Filter a cohort with
|
|
482
|
+
`--ai-disclosure <state>` (CLI) or `filter.ai_disclosure` (MCP) on games
|
|
483
|
+
search/count and `market_aggregate`; group with
|
|
484
|
+
`--group-by ai_disclosure` (it cross-tabs with `release_month`); read one
|
|
485
|
+
title's state from `detail.aiDisclosure` (`state`, `observedAt`,
|
|
486
|
+
`rawCategoryValue`).
|
|
487
|
+
|
|
488
|
+
Interpretation guardrails (honesty rules, not suggestions):
|
|
489
|
+
|
|
490
|
+
- **`absent` is an observation, not a verdict.** It means a read of the store
|
|
491
|
+
page found no disclosure block. Steam requires disclosure only in defined
|
|
492
|
+
cases and disclosure text can be edited away, so `absent` must never be
|
|
493
|
+
reported as "this title uses no generative AI".
|
|
494
|
+
- **`unconfirmed` is not a finding.** It means not yet successfully observed,
|
|
495
|
+
and it includes titles nobody has checked. Report it as its own bucket with
|
|
496
|
+
its count; never fold it into `absent` and never drop it from a share, or
|
|
497
|
+
the remaining two states become a claim about the whole cohort.
|
|
498
|
+
- **The date matters.** `observedAt` is when the read happened, not the
|
|
499
|
+
release date, and the state is the latest observed one. A title can disclose
|
|
500
|
+
today and not have when it shipped.
|
|
501
|
+
- **`aiDisclosure: null` on a detail read** means only that the read model
|
|
502
|
+
could not be reached; the response also carries `ai_disclosure_unavailable`.
|
|
503
|
+
Never report it as "not disclosed".
|
|
504
|
+
- `cohort_review_categories` and `compare_as_of` refuse this filter outright
|
|
505
|
+
(the underlying rollups have no disclosure predicate). Size disclosure
|
|
506
|
+
cohorts with `market_aggregate` instead.
|
|
142
507
|
|
|
143
508
|
## Hard rules
|
|
144
509
|
|
package/src/args.js
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import { EXIT_USAGE } from "./constants.js";
|
|
2
2
|
import { writeJsonError } from "./output.js";
|
|
3
3
|
|
|
4
|
-
export const BOOLEAN_OPTIONS = new Set([
|
|
4
|
+
export const BOOLEAN_OPTIONS = new Set([
|
|
5
|
+
"json",
|
|
6
|
+
"help",
|
|
7
|
+
"version",
|
|
8
|
+
"no-open",
|
|
9
|
+
"global",
|
|
10
|
+
"include-total",
|
|
11
|
+
// gmc games language-topics (NAK-455): mirrors the REST
|
|
12
|
+
// `include_insufficient` flag, which takes no value.
|
|
13
|
+
"include-insufficient",
|
|
14
|
+
// gmc lists materialize (NAK-525): the freeze acknowledgement. A value-taking
|
|
15
|
+
// flag here would swallow the next token, so `--confirm --json` would parse as
|
|
16
|
+
// `confirm: "--json"` and the freeze would run unacknowledged.
|
|
17
|
+
"confirm"
|
|
18
|
+
]);
|
|
5
19
|
|
|
6
20
|
export function parseArgs(args) {
|
|
7
21
|
const command = [];
|
|
@@ -16,6 +30,19 @@ export function parseArgs(args) {
|
|
|
16
30
|
const key = separatorIndex >= 0 ? raw.slice(0, separatorIndex) : raw;
|
|
17
31
|
const inlineValue = separatorIndex >= 0 ? raw.slice(separatorIndex + 1) : undefined;
|
|
18
32
|
if (BOOLEAN_OPTIONS.has(key)) {
|
|
33
|
+
// Every option in BOOLEAN_OPTIONS is presence-only, so the `--flag=value`
|
|
34
|
+
// spelling has no meaning here. It used to be accepted and the value
|
|
35
|
+
// dropped on the floor, which made `--include-insufficient=false` (and
|
|
36
|
+
// `--json=false`) read as TRUE — the caller's stated intent silently
|
|
37
|
+
// inverted. Reject it instead of teaching these flags a second, parallel
|
|
38
|
+
// value grammar: the fix for `--flag=false` is to omit the flag.
|
|
39
|
+
if (inlineValue != null) {
|
|
40
|
+
writeJsonError({
|
|
41
|
+
code: "CLI_USAGE_ERROR",
|
|
42
|
+
message: `--${key} is a flag and takes no value. Pass --${key} on its own to enable it, or omit it to leave it off.`
|
|
43
|
+
});
|
|
44
|
+
process.exit(EXIT_USAGE);
|
|
45
|
+
}
|
|
19
46
|
options[key] = true;
|
|
20
47
|
continue;
|
|
21
48
|
}
|
|
@@ -58,6 +85,11 @@ function isCommandToken(command, token) {
|
|
|
58
85
|
"cohort",
|
|
59
86
|
"showcases",
|
|
60
87
|
"lists",
|
|
88
|
+
// NAK-706. A token missing from this list is NOT an error: it falls
|
|
89
|
+
// through to positional, so `gmc steam tag-trends` would parse as the
|
|
90
|
+
// unknown command `steam` with a stray positional rather than reaching
|
|
91
|
+
// the tag-trends branch.
|
|
92
|
+
"steam",
|
|
61
93
|
"auth",
|
|
62
94
|
"login",
|
|
63
95
|
"logout",
|
|
@@ -77,7 +109,7 @@ function isCommandToken(command, token) {
|
|
|
77
109
|
return ["install", "print", "status"].includes(token);
|
|
78
110
|
}
|
|
79
111
|
if (command[0] === "games") {
|
|
80
|
-
return ["search", "count", "aggregate", "resolve", "benchmark", "detail", "analysis", "compare", "topics", "success-report"].includes(token);
|
|
112
|
+
return ["search", "count", "aggregate", "resolve", "benchmark", "detail", "analysis", "compare", "topics", "language-topics", "review-history", "success-report", "more-like-this"].includes(token);
|
|
81
113
|
}
|
|
82
114
|
if (command[0] === "reviews") {
|
|
83
115
|
return token === "patterns";
|
|
@@ -95,10 +127,16 @@ function isCommandToken(command, token) {
|
|
|
95
127
|
return ["trends", "pricing", "brief", "compare"].includes(token);
|
|
96
128
|
}
|
|
97
129
|
if (command[0] === "showcases") {
|
|
98
|
-
return ["search", "fit", "patterns"].includes(token);
|
|
130
|
+
return ["search", "fit", "history", "patterns"].includes(token);
|
|
99
131
|
}
|
|
100
132
|
if (command[0] === "lists") {
|
|
101
|
-
|
|
133
|
+
// NAK-525 adds materialize/resolve. A token missing from this list is NOT
|
|
134
|
+
// an error: it falls through to positional, so `gmc lists materialize <id>`
|
|
135
|
+
// would silently run the bare `gmc lists` index and exit 0.
|
|
136
|
+
return ["create", "show", "games", "add", "remove", "delete", "materialize", "resolve"].includes(token);
|
|
137
|
+
}
|
|
138
|
+
if (command[0] === "steam") {
|
|
139
|
+
return ["tag-trends", "trend-stories"].includes(token);
|
|
102
140
|
}
|
|
103
141
|
return false;
|
|
104
142
|
}
|
package/src/constants.js
CHANGED
|
@@ -7,7 +7,7 @@ export const EXIT_CONFIG = 3;
|
|
|
7
7
|
export const EXIT_API = 4;
|
|
8
8
|
|
|
9
9
|
export const CLI_PACKAGE_NAME = "@witchpot/gmc";
|
|
10
|
-
export const CLI_VERSION = "0.
|
|
10
|
+
export const CLI_VERSION = "0.8.0";
|
|
11
11
|
|
|
12
12
|
export const DEFAULT_API_BASE_URL = "https://gamemarketcopilot.com";
|
|
13
13
|
export const DEFAULT_CONFIG_PATH = path.join(homedir(), ".config", "gmc", "config.json");
|
|
@@ -32,13 +32,17 @@ export const SUPPORTED_BETA_COMMANDS = [
|
|
|
32
32
|
"gmc games success-report --source steam <externalId> --json",
|
|
33
33
|
"gmc campaigns signals --source steam <externalId> --json",
|
|
34
34
|
"gmc games topics --source steam <externalId> --json",
|
|
35
|
+
"gmc games language-topics --source steam <externalId> --json",
|
|
36
|
+
"gmc games review-history --source steam <externalId> --json",
|
|
37
|
+
"gmc games more-like-this --source steam <externalId> --json",
|
|
35
38
|
"gmc games compare --source steam <externalId> <externalId...> --focus market --json",
|
|
36
39
|
"gmc games benchmark --source steam --tags <tag_slug[,tag_slug...]> --reviews-min <number> --json",
|
|
37
40
|
"gmc lists --json",
|
|
38
41
|
"gmc lists games <id> --json",
|
|
39
42
|
"gmc reviews patterns --source steam --tags <tag_slug[,tag_slug...]> --reviews-min <number> --json",
|
|
40
43
|
"gmc showcases search --query <text> --json",
|
|
41
|
-
"gmc showcases fit --source steam <externalId> --deadline-before <YYYY-MM-DD> --json"
|
|
44
|
+
"gmc showcases fit --source steam <externalId> --deadline-before <YYYY-MM-DD> --json",
|
|
45
|
+
"gmc showcases history --source steam <externalId> --json"
|
|
42
46
|
];
|
|
43
47
|
|
|
44
48
|
export const PROVISIONAL_BETA_COMMANDS = [
|
|
@@ -51,7 +55,12 @@ export const PROVISIONAL_BETA_COMMANDS = [
|
|
|
51
55
|
"gmc cohort compare --source steam --tags <tag_slug[,tag_slug...]> --json",
|
|
52
56
|
"gmc cohort brief --source steam --tags <tag_slug[,tag_slug...]> --json",
|
|
53
57
|
"gmc cohort brief --source steam --list <id> --json",
|
|
54
|
-
"gmc showcases patterns --source steam --tags <tag_slug[,tag_slug...]> --json"
|
|
58
|
+
"gmc showcases patterns --source steam --tags <tag_slug[,tag_slug...]> --json",
|
|
59
|
+
// NAK-706. Provisional rather than supported: the endpoint is pre-launch and
|
|
60
|
+
// default-off, so claiming supported-beta status would promise a stability
|
|
61
|
+
// the feature flag has not yet been asked to keep.
|
|
62
|
+
"gmc steam trend-stories --locale en --json",
|
|
63
|
+
"gmc steam tag-trends --view featured|board|group|table|tag --json"
|
|
55
64
|
];
|
|
56
65
|
|
|
57
66
|
export const COMPARE_FOCUSES = ["overview", "market", "reviews", "marketing", "audience", "showcases"];
|
|
@@ -65,5 +74,13 @@ export const DEFAULT_REVIEW_PATTERNS_LIMIT = "20";
|
|
|
65
74
|
export const DEFAULT_REVIEW_PATTERNS_MAX_TOPICS_PER_GAME = "10";
|
|
66
75
|
export const DEFAULT_REVIEW_PATTERNS_MIN_GAMES = "3";
|
|
67
76
|
export const DEFAULT_SHOWCASE_FIT_LIMIT = "25";
|
|
77
|
+
// Mirrors LANGUAGE_TOPIC_MIN_REVIEWS_FLOOR/CEILING on the server (NAK-452).
|
|
78
|
+
// Kept as local literals because the CLI package cannot import server code;
|
|
79
|
+
// the server remains the enforcing authority and returns 400 (param
|
|
80
|
+
// min_reviews) for anything it rejects. The DEFAULT (30) is deliberately NOT
|
|
81
|
+
// mirrored: the CLI omits the parameter entirely when the caller does, so the
|
|
82
|
+
// server's default can never drift from a copy kept here.
|
|
83
|
+
export const LANGUAGE_TOPIC_MIN_REVIEWS_FLOOR = 10;
|
|
84
|
+
export const LANGUAGE_TOPIC_MIN_REVIEWS_CEILING = 200;
|
|
68
85
|
export const MAX_SAMPLE_PAGES = 10;
|
|
69
86
|
export const MAX_SAMPLE_SIZE = 500;
|