gamealgo-cli 0.4.2 → 0.4.4

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/README.md CHANGED
@@ -18,10 +18,13 @@ Run login from the game repository that should own the credentials:
18
18
  ```bash
19
19
  gamealgo login --host <admin-url> --admin-key <ga_admin_key>
20
20
  gamealgo whoami
21
+ gamealgo quota --json
21
22
  ```
22
23
 
23
24
  The login is stored in the current project's `.gamealgo/cli.json`, which is automatically ignored by Git. Authenticated commands require this project-scoped login and verify its `gameId` against the Server before continuing, preventing credentials from another game from being reused accidentally.
24
25
 
26
+ `gamealgo quota --json` returns the current effective plan, the per-user daily ordinary-event limit, UTC reset time, detail-query and retention windows, exempt revenue events, and today's actual quota-filtered count. Check it before adding or increasing telemetry.
27
+
25
28
  ## Current help and documentation
26
29
 
27
30
  ```bash
@@ -49,6 +52,24 @@ gamealgo report advanced list
49
52
 
50
53
  All Report Packs use `schemaVersion: 2`, with custom `groups[].views` or platform-owned `groups[].standard`. Advanced query runs are explicit, are never preloaded, and remain available as immutable reports for 30 days.
51
54
 
55
+ Managed marketing lifecycle queries can choose their runtime cohort shape without editing or publishing the Pack. For example, this returns one row per channel, Campaign, and country with D0–D30 retention as columns:
56
+
57
+ ```bash
58
+ gamealgo report advanced run \
59
+ --query-id retention_drilldown \
60
+ --platform ios \
61
+ --from 2026-08-08 \
62
+ --to 2026-09-22 \
63
+ --observation-end 2026-09-22 \
64
+ --group-by network,campaign,country_code \
65
+ --day-offsets 0-30 \
66
+ --pivot-metric retention_rate \
67
+ --filter network=Meta \
68
+ --wait --out retention.json --json
69
+ ```
70
+
71
+ Repeat `--filter field=value1,value2` for `cohort_dt`, `day_offset`, `network`, `campaign`, `campaign_id`, `country_code`, or `platform`. The CLI combines different fields with AND, merges repeated values for the same field, and automatically adds the internal `day_offset` grouping when `--group-by` is present. `--pivot-metric` accepts `retention_rate`, `active_users`, `daily_revenue`, `cumulative_revenue`, `ltv`, or `roas`; `--min-cohort-size` removes undersized groups.
72
+
52
73
  Help and documentation are fetched from the configured Server on every invocation. The CLI intentionally does not keep a documentation cache.
53
74
 
54
75
  ## Agent skill
package/dist/client.js CHANGED
@@ -201,6 +201,9 @@ export class GameAlgoAdminClient {
201
201
  async countEvents(body, options = {}) {
202
202
  return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/events/count`, body, options);
203
203
  }
204
+ async getGameQuota() {
205
+ return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/quota`);
206
+ }
204
207
  async listEventCatalog(days = 30, diagnostics = true) {
205
208
  const params = new URLSearchParams({ days: String(days), diagnostics: diagnostics ? "1" : "0" });
206
209
  return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/events/catalog?${params.toString()}`);
@@ -49,6 +49,12 @@ export async function handleDynamicConfig(client, args, global) {
49
49
  const key = optionalString(definition.key);
50
50
  if (!key)
51
51
  throw new Error("dynamic config definition.key is required");
52
+ if (definition.reportPack !== undefined) {
53
+ throw new Error("dynamic config definition.reportPack is no longer supported; inputs must reference the current Report Pack v2");
54
+ }
55
+ if (!Array.isArray(definition.inputs) || definition.inputs.length === 0) {
56
+ throw new Error("dynamic config definition.inputs must be a non-empty array");
57
+ }
52
58
  const body = { ...definition };
53
59
  delete body.key;
54
60
  const scriptPath = optionalString(flags.script);
@@ -0,0 +1,16 @@
1
+ import { parseFlags } from "../args.js";
2
+ import { printResult } from "../output.js";
3
+ import { writeTextFile } from "../resources.js";
4
+ export async function handleQuota(client, args, global) {
5
+ const flags = parseFlags(args);
6
+ if (args.length)
7
+ throw new Error("usage: gamealgo quota [--out quota.json]");
8
+ const value = await client.getGameQuota();
9
+ if (flags.out) {
10
+ const filePath = String(flags.out);
11
+ await writeTextFile(filePath, JSON.stringify(value, null, 2) + "\n");
12
+ await printResult({ ok: true, out: filePath }, global);
13
+ return;
14
+ }
15
+ await printResult(value, global);
16
+ }
@@ -165,6 +165,7 @@ export async function handleReport(client, args, global) {
165
165
  async function handleAdvancedReport(client, args, global) {
166
166
  const action = args.shift();
167
167
  if (action === "run") {
168
+ const marketingFilters = parseMarketingCohortFilterFlags(args);
168
169
  const flags = parseFlags(args);
169
170
  const queryId = requiredTextFlag(flags["query-id"], "--query-id");
170
171
  const startDate = requiredTextFlag(flags.from || flags.start || flags.startDate, "--from");
@@ -180,6 +181,7 @@ async function handleAdvancedReport(client, args, global) {
180
181
  const pack = packPath ? await readJsonFile(packPath) : undefined;
181
182
  if (pack !== undefined)
182
183
  assertWritableReportPackV2(pack, "report advanced run");
184
+ const marketingCohort = marketingCohortRunOptions(flags, marketingFilters);
183
185
  const wait = booleanFlag(flags.wait, "--wait");
184
186
  const timeoutMs = reportTimeoutMs(flags) ?? 15 * 60 * 1000;
185
187
  await announceTargetGame(client);
@@ -189,6 +191,7 @@ async function handleAdvancedReport(client, args, global) {
189
191
  endDate,
190
192
  platform,
191
193
  ...(pack !== undefined ? { pack } : {}),
194
+ ...(marketingCohort ? { marketingCohort } : {}),
192
195
  }, { timeoutMs: Math.min(timeoutMs, 30_000) });
193
196
  if (!wait) {
194
197
  await writeAdvancedOutput(created, flags, global);
@@ -229,6 +232,143 @@ async function handleAdvancedReport(client, args, global) {
229
232
  }
230
233
  throw new Error("usage: gamealgo report advanced <run|get|list>");
231
234
  }
235
+ const MARKETING_COHORT_DIMENSIONS = new Set([
236
+ "cohort_dt",
237
+ "day_offset",
238
+ "network",
239
+ "campaign",
240
+ "campaign_id",
241
+ "country_code",
242
+ "platform",
243
+ ]);
244
+ const MARKETING_COHORT_PIVOT_METRICS = new Set([
245
+ "retention_rate",
246
+ "active_users",
247
+ "daily_revenue",
248
+ "cumulative_revenue",
249
+ "ltv",
250
+ "roas",
251
+ ]);
252
+ function parseMarketingCohortFilterFlags(args) {
253
+ const grouped = new Map();
254
+ for (let index = 0; index < args.length;) {
255
+ if (args[index] !== "--filter") {
256
+ index += 1;
257
+ continue;
258
+ }
259
+ const expression = args[index + 1];
260
+ if (!expression || expression.startsWith("--"))
261
+ throw new Error("--filter requires field=value");
262
+ const separator = expression.indexOf("=");
263
+ if (separator <= 0)
264
+ throw new Error("--filter must use field=value or field=value1,value2");
265
+ const field = normalizeMarketingCohortDimension(expression.slice(0, separator).trim(), "--filter");
266
+ const values = expression.slice(separator + 1).split(",").map((value) => value.trim()).filter(Boolean);
267
+ if (!values.length)
268
+ throw new Error(`--filter ${field} requires at least one value`);
269
+ grouped.set(field, [...new Set([...(grouped.get(field) ?? []), ...values])]);
270
+ args.splice(index, 2);
271
+ }
272
+ return [...grouped].map(([field, values]) => ({ field, values }));
273
+ }
274
+ function marketingCohortRunOptions(flags, filters) {
275
+ const cohortFlagNames = ["observation-end", "group-by", "day-offsets", "min-cohort-size", "pivot-metric"];
276
+ if (!filters.length && !cohortFlagNames.some((flag) => flags[flag] !== undefined))
277
+ return undefined;
278
+ const observationEndDate = optionalTextFlag(flags["observation-end"], "--observation-end");
279
+ if (observationEndDate && !isIsoDate(observationEndDate)) {
280
+ throw new Error("--observation-end must use YYYY-MM-DD");
281
+ }
282
+ const groupByValue = optionalTextFlag(flags["group-by"], "--group-by");
283
+ const groupBy = groupByValue
284
+ ? [...new Set(groupByValue.split(",")
285
+ .map((field) => normalizeMarketingCohortDimension(field.trim(), "--group-by"))
286
+ .filter(Boolean))]
287
+ : undefined;
288
+ if (groupBy && !groupBy.includes("day_offset"))
289
+ groupBy.push("day_offset");
290
+ const dayOffsetsValue = optionalTextFlag(flags["day-offsets"], "--day-offsets");
291
+ const dayOffsets = dayOffsetsValue ? parseMarketingCohortDayOffsets(dayOffsetsValue) : undefined;
292
+ const minCohortSizeValue = optionalTextFlag(flags["min-cohort-size"], "--min-cohort-size");
293
+ const minCohortSize = minCohortSizeValue === undefined ? undefined : Number(minCohortSizeValue);
294
+ if (minCohortSize !== undefined && (!Number.isInteger(minCohortSize) || minCohortSize < 1 || minCohortSize > 1_000_000)) {
295
+ throw new Error("--min-cohort-size must be an integer between 1 and 1000000");
296
+ }
297
+ const pivotMetric = optionalTextFlag(flags["pivot-metric"], "--pivot-metric");
298
+ if (pivotMetric && !MARKETING_COHORT_PIVOT_METRICS.has(pivotMetric)) {
299
+ throw new Error(`--pivot-metric must be one of ${[...MARKETING_COHORT_PIVOT_METRICS].join(", ")}`);
300
+ }
301
+ if (pivotMetric && !groupBy) {
302
+ throw new Error("--group-by is required when --pivot-metric is used");
303
+ }
304
+ const filter = filters.length === 0
305
+ ? undefined
306
+ : filters.length === 1
307
+ ? marketingCohortFilter(filters[0])
308
+ : { op: "and", filters: filters.map(marketingCohortFilter) };
309
+ return {
310
+ ...(observationEndDate ? { observationEndDate } : {}),
311
+ ...(groupBy ? { groupBy } : {}),
312
+ ...(dayOffsets ? { dayOffsets } : {}),
313
+ ...(minCohortSize !== undefined ? { minCohortSize } : {}),
314
+ ...(pivotMetric ? { pivotMetric } : {}),
315
+ ...(filter ? { filter } : {}),
316
+ };
317
+ }
318
+ function marketingCohortFilter(input) {
319
+ const values = input.field === "day_offset"
320
+ ? input.values.map((value) => {
321
+ const day = Number(value);
322
+ if (!Number.isInteger(day) || day < 0 || day > 30) {
323
+ throw new Error("--filter day_offset values must be integers between 0 and 30");
324
+ }
325
+ return day;
326
+ })
327
+ : input.values;
328
+ return values.length === 1
329
+ ? { field: input.field, op: "eq", value: values[0] }
330
+ : { field: input.field, op: "in", values };
331
+ }
332
+ function normalizeMarketingCohortDimension(value, flag) {
333
+ const aliases = {
334
+ channel: "network",
335
+ country: "country_code",
336
+ "campaign-id": "campaign_id",
337
+ };
338
+ const normalized = aliases[value] ?? value;
339
+ if (!MARKETING_COHORT_DIMENSIONS.has(normalized)) {
340
+ throw new Error(`${flag} dimension must be one of ${[...MARKETING_COHORT_DIMENSIONS].join(", ")}`);
341
+ }
342
+ return normalized;
343
+ }
344
+ function parseMarketingCohortDayOffsets(value) {
345
+ const offsets = new Set();
346
+ for (const part of value.split(",").map((item) => item.trim()).filter(Boolean)) {
347
+ const match = part.match(/^D?(\d+)(?:\s*-\s*D?(\d+))?$/i);
348
+ if (!match)
349
+ throw new Error("--day-offsets must use 0-30 or a comma-separated list such as 0,1,7,14,30");
350
+ const start = Number(match[1]);
351
+ const end = match[2] === undefined ? start : Number(match[2]);
352
+ if (start > end || start < 0 || end > 30) {
353
+ throw new Error("--day-offsets values must be between 0 and 30");
354
+ }
355
+ for (let day = start; day <= end; day += 1)
356
+ offsets.add(day);
357
+ }
358
+ if (!offsets.size)
359
+ throw new Error("--day-offsets requires at least one value");
360
+ return [...offsets].sort((left, right) => left - right);
361
+ }
362
+ function optionalTextFlag(value, flag) {
363
+ if (value === undefined)
364
+ return undefined;
365
+ if (typeof value !== "string" || !value.trim())
366
+ throw new Error(`${flag} requires a value`);
367
+ return value.trim();
368
+ }
369
+ function isIsoDate(value) {
370
+ return /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(`${value}T00:00:00.000Z`));
371
+ }
232
372
  async function waitForAdvancedReport(client, runId, timeoutMs, startedAt) {
233
373
  while (true) {
234
374
  const response = await client.getAdvancedReportRun(runId, true);
package/dist/main.js CHANGED
@@ -7,6 +7,7 @@ import { handleIntegration } from "./commands/integration.js";
7
7
  import { handleGameKey } from "./commands/keys.js";
8
8
  import { handleMarketing } from "./commands/marketing.js";
9
9
  import { handleOptimize } from "./commands/optimize.js";
10
+ import { handleQuota } from "./commands/quota.js";
10
11
  import { handleReport } from "./commands/report.js";
11
12
  import { handleFileResource } from "./commands/resources.js";
12
13
  import { handleSkill } from "./commands/skill.js";
@@ -95,6 +96,10 @@ export async function main() {
95
96
  await handleEvents(client, args, global);
96
97
  return;
97
98
  }
99
+ if (command === "quota") {
100
+ await handleQuota(client, args, global);
101
+ return;
102
+ }
98
103
  if (command === "integration") {
99
104
  await handleIntegration(client, args, global);
100
105
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gamealgo-cli",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "GameAlgo command-line client for AI agents and game developers",
5
5
  "repository": {
6
6
  "type": "git",
@@ -59,6 +59,8 @@ Keep ratio and average components as additive Dataset columns and calculate the
59
59
 
60
60
  Use `gamealgo report result` to read an existing ordinary v2 materialization for an explicit date range and scope. It never starts a DW query. Use `gamealgo report advanced run --query-id <id> --platform <platform> --from <date> --to <date>` only when an explicit high-dimensional or non-preloaded query is required. `--pack <file>` supplies an immutable v2 Pack snapshot for that Run without publishing it. `advanced get` and `advanced list` only read the saved Run/history. Advanced results are whole-run bounded to 20,000 rows and 20 MiB and retained for 30 days; never replace this path with local production scans or a hidden query side channel.
61
61
 
62
+ For a managed `marketing/lifecycle` advanced Dataset, use runtime cohort flags instead of generating many near-duplicate Packs: `--observation-end <date>`, `--group-by <comma-separated dimensions>`, `--day-offsets <0-30|list>`, `--min-cohort-size <n>`, `--pivot-metric <metric>`, and repeatable `--filter field=value1,value2`. The CLI automatically adds `day_offset` to `--group-by`. Prefer pivoting one requested metric so the 20,000-row check applies after D0–D30 become columns. When the user wants one aggregate matrix for the selected acquisition range, omit `cohort_dt`; include it only when separate daily cohorts are explicitly required.
63
+
62
64
  For initial integration or business dashboard work, standard dashboards are only the baseline. During the initial integration, inspect the actual game loop and define a separate game-specific new-user flow dashboard with meaningful milestones and drop-off diagnostics. Add required milestone telemetry and event catalog fields in the same integration instead of waiting for production volume. Validate the pipeline with test data; label business conclusions as awaiting data when production samples are immature, but do not omit the dashboard design.
63
65
 
64
66
  ## Follow Mandatory Checkpoints
@@ -112,11 +114,16 @@ After approval, update the actual emission call sites and their normal-flow test
112
114
 
113
115
  ## Bound Custom Event Volume
114
116
 
115
- Before adding or changing a custom event, prefer a protocol-standard event or an existing custom event with the same meaning. Keep the event type stable and put variable values in payload fields; never generate event types from item IDs, levels, timestamps, user input, or other runtime values.
117
+ Before adding or changing any event, run `gamealgo quota --json` and `gamealgo events list --days 30 --json`. Treat the returned effective plan and `ordinaryEvents.dailyLimitPerUser` as a hard per-game, per-user, per-UTC-day budget shared by standard and custom ordinary events. Estimate the user's total ordinary event volume across all existing and proposed call sites, leave headroom for normal retries and future events, and reduce or aggregate high-frequency telemetry before implementation when the bound can reach the current limit.
118
+
119
+ Prefer a protocol-standard event or an existing custom event with the same meaning. Keep the event type stable and put variable values in payload fields; never generate event types from item IDs, levels, timestamps, user input, or other runtime values.
116
120
 
117
121
  Inspect every call site for render/update loops, timers, polling, transport retries, duplicate lifecycle callbacks, and re-entrant listeners. State the trigger boundary and estimate the maximum calls per user, session, context, and active user-day. Use the game's active-user estimate to describe the expected daily volume when it is available.
118
122
 
119
123
  - Standard SDK semantic events are governed by their protocol semantics. Do not disguise custom telemetry as a standard event to bypass safeguards.
124
+ - `purchase` and `ad_view` are exempt only when they represent their real protocol semantics and pass their strict validation. Every other accepted event consumes the ordinary daily budget, including standard events and repeated `eventId` submissions.
125
+ - `quota.today.filteredEvents` is the number of ordinary events actually rejected by quota enforcement today; `shadowWouldRejectEvents` is observation-only and must not be reported as data loss. If actual filtering is nonzero, identify and fix the highest-frequency emission paths before proposing more events.
126
+ - The quota resets at UTC midnight, not the player's local midnight. If quota information is unavailable, do not assume Free, Enterprise, or unlimited capacity; report the missing prerequisite and avoid publishing a higher-volume telemetry design.
120
127
  - The SDK limits custom events to 1,000 occurrences per `context × eventType`, 5,000 total occurrences per context, and 100 distinct custom event types per context.
121
128
  - A custom event with an unbounded trigger or an expected rate above 1,000 calls per context requires explicit developer review before implementation. Explain the expected volume, storage and processing impact, and the guard that makes the trigger bounded.
122
129
  - Never add client-side retries around `track`, rename an event after rejection, remove its custom-event prefix, declare it standard, raise a safety limit, or call a lower-level transport to bypass a quota.
@@ -126,14 +133,15 @@ Before delivery, report the estimated bound, normal-flow tests, the limiting loo
126
133
 
127
134
  ## Treat Dynamic Config As A Production Publisher
128
135
 
129
- A dynamic config pipeline is Server-side automation that turns aggregated Report Pack inputs into a live config file. It does not authorize SDK changes, detailed production-data downloads, or live publication by itself.
136
+ A dynamic config pipeline is Server-side automation that turns current Report Pack v2 ordinary Dataset snapshots into a live config file. It does not authorize SDK changes, detailed production-data downloads, or live publication by itself.
130
137
 
131
138
  - Define bounded aggregate inputs with Report Pack syntax. Keep filters, joins, aggregation, and data reduction in the managed query rather than passing event detail to JavaScript.
132
139
  - Keep transforms synchronous and deterministic. They may use only the supplied datasets, previous output, and scheduled metadata; never depend on network, filesystem, environment variables, wall-clock APIs, randomness, async functions, or Promise output.
133
140
  - The output file name and content type belong to the reviewed pipeline definition. The script may return only file content and JSON diagnostics.
134
141
  - Before publishing or materially updating an active pipeline, show the target game, input reports and windows, complete script diff, output file and content type, schedule, expected behavior, failure behavior, and user-facing or operational risks. Obtain explicit approval for that exact publication unless the user already gave it after seeing those details.
135
142
  - Prefer a disabled pipeline or a local script test while details are still under discussion. After an authorized publish, trigger one run, inspect the persisted run result and generated file version, and confirm that an unchanged second run is hash-deduplicated before treating the automation as verified.
136
- - Do not raise the 1 MiB script/input/output caps, the 5,000-row per-dataset cap, the 5-second execution cap, or the minimum 60-minute schedule to bypass a failing design. Reduce data in the Report Pack or simplify the transform.
143
+ - Do not embed a Report Pack, use Advanced Query/raw SQL, or trigger warehouse queries from Dynamic Config. Inputs reuse v2 View aggregation over already-materialized ordinary Datasets and pin the Dataset fingerprint at publish time.
144
+ - Do not raise the 1 MiB script/input/output caps, the 100-row per-input cap, the 500-row total cap, the 5-second execution cap, or the minimum daily schedule to bypass a failing design. Reduce the ordinary Dataset/View result or simplify the transform.
137
145
 
138
146
  Use `gamealgo dynamic-config list|get|publish|run|runs` for lifecycle operations. Dynamic config output continues through the ordinary SDK config-file delivery mechanism, so do not change client SDK code merely to consume a generated file that already uses the existing config API.
139
147