gamealgo-cli 0.4.2 → 0.4.3

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
@@ -49,6 +49,24 @@ gamealgo report advanced list
49
49
 
50
50
  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
51
 
52
+ 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:
53
+
54
+ ```bash
55
+ gamealgo report advanced run \
56
+ --query-id retention_drilldown \
57
+ --platform ios \
58
+ --from 2026-08-08 \
59
+ --to 2026-09-22 \
60
+ --observation-end 2026-09-22 \
61
+ --group-by network,campaign,country_code \
62
+ --day-offsets 0-30 \
63
+ --pivot-metric retention_rate \
64
+ --filter network=Meta \
65
+ --wait --out retention.json --json
66
+ ```
67
+
68
+ 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.
69
+
52
70
  Help and documentation are fetched from the configured Server on every invocation. The CLI intentionally does not keep a documentation cache.
53
71
 
54
72
  ## Agent skill
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gamealgo-cli",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
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