@viccydev/pi-fpa 0.2.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.
Files changed (29) hide show
  1. package/README.md +135 -0
  2. package/extensions/fpa-data/calc.ts +544 -0
  3. package/extensions/fpa-data/index.ts +478 -0
  4. package/extensions/fpa-data/registry.ts +303 -0
  5. package/extensions/fpa-data/sql.ts +412 -0
  6. package/extensions/fpa-data/supabase.ts +96 -0
  7. package/package.json +54 -0
  8. package/prompts/fpa-plan-cycle.md +44 -0
  9. package/prompts/fpa-review-cycle.md +33 -0
  10. package/skills/fpa-analyze-drivers/SKILL.md +30 -0
  11. package/skills/fpa-analyze-drivers/references/artifact-contract.md +34 -0
  12. package/skills/fpa-apply-core-rules/SKILL.md +34 -0
  13. package/skills/fpa-apply-core-rules/references/core-rules.md +96 -0
  14. package/skills/fpa-diagnose-actuals/SKILL.md +30 -0
  15. package/skills/fpa-diagnose-actuals/references/artifact-contract.md +38 -0
  16. package/skills/fpa-execute-approved-strategy/SKILL.md +40 -0
  17. package/skills/fpa-execute-approved-strategy/references/artifact-contract.md +29 -0
  18. package/skills/fpa-forecast-approved-strategy/SKILL.md +39 -0
  19. package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +36 -0
  20. package/skills/fpa-plan-cycle/SKILL.md +29 -0
  21. package/skills/fpa-plan-cycle/references/artifact-contract.md +41 -0
  22. package/skills/fpa-recommend-strategy/SKILL.md +29 -0
  23. package/skills/fpa-recommend-strategy/references/artifact-contract.md +30 -0
  24. package/skills/fpa-review-cycle/SKILL.md +32 -0
  25. package/skills/fpa-review-cycle/references/artifact-contract.md +30 -0
  26. package/skills/fpa-review-strategy/SKILL.md +28 -0
  27. package/skills/fpa-review-strategy/references/artifact-contract.md +25 -0
  28. package/skills/fpa-simulate-strategies/SKILL.md +32 -0
  29. package/skills/fpa-simulate-strategies/references/artifact-contract.md +35 -0
@@ -0,0 +1,478 @@
1
+ /**
2
+ * FP&A data extension: read-only Supabase mart access plus deterministic
3
+ * calculation tools.
4
+ *
5
+ * Design contract: the LLM never writes SQL and never performs arithmetic.
6
+ * It selects registered datasets/metrics/dimensions; SQL is generated and
7
+ * validated here, aggregation runs in Postgres, and every derived number
8
+ * (ratios, deltas, LTV/ROAS/retention, ad-hoc formulas) is computed in this
9
+ * extension with null-safe semantics.
10
+ */
11
+
12
+ import { StringEnum } from "@earendil-works/pi-ai";
13
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+ import { Type } from "typebox";
15
+
16
+ import {
17
+ buildCohortRows,
18
+ comparePeriods,
19
+ computeDerived,
20
+ round,
21
+ runCalc,
22
+ toNumber,
23
+ type CalcExpression,
24
+ type NumericLike,
25
+ } from "./calc.ts";
26
+ import { CATALOG_CAVEATS, DATASETS, DATASET_IDS } from "./registry.ts";
27
+ import {
28
+ buildCohortQuery,
29
+ buildCoverageSql,
30
+ buildCohortSizeCoverageSql,
31
+ buildQuery,
32
+ MAX_LIMIT,
33
+ type QuerySpec,
34
+ } from "./sql.ts";
35
+ import { runQuery, type SqlRow } from "./supabase.ts";
36
+
37
+ const MAX_TOOL_TEXT_CHARS = 100_000;
38
+ const MAX_DISPLAY_ROWS = 200;
39
+
40
+ const DatasetIdSchema = StringEnum(DATASET_IDS as [string, ...string[]], {
41
+ description: "Dataset id from fpa_data_catalog",
42
+ });
43
+
44
+ const ScalarSchema = Type.Union([Type.String(), Type.Number()]);
45
+ const FiltersSchema = Type.Record(
46
+ Type.String({ minLength: 1 }),
47
+ Type.Union([ScalarSchema, Type.Array(ScalarSchema, { minItems: 1 })]),
48
+ {
49
+ description:
50
+ "Dimension filters. Different fields are AND; array values are OR. Use date_from/date_to for the date range.",
51
+ },
52
+ );
53
+
54
+ interface Presented {
55
+ text: string;
56
+ displayedRows?: number;
57
+ truncated: boolean;
58
+ }
59
+
60
+ function present(result: Record<string, unknown>, rowsKey = "rows"): Presented {
61
+ let displayed = result;
62
+ let displayedRows: number | undefined;
63
+ let truncated = false;
64
+
65
+ const rows = result[rowsKey];
66
+ if (Array.isArray(rows) && rows.length > MAX_DISPLAY_ROWS) {
67
+ displayedRows = MAX_DISPLAY_ROWS;
68
+ truncated = true;
69
+ displayed = {
70
+ ...result,
71
+ [rowsKey]: rows.slice(0, MAX_DISPLAY_ROWS),
72
+ tool_output: {
73
+ truncated: true,
74
+ displayed_rows: MAX_DISPLAY_ROWS,
75
+ total_rows: rows.length,
76
+ hint: "Narrow filters or dimensions, or lower the limit.",
77
+ },
78
+ };
79
+ }
80
+
81
+ let text = JSON.stringify(displayed, null, 2);
82
+ while (text.length > MAX_TOOL_TEXT_CHARS) {
83
+ const currentRows = displayed[rowsKey];
84
+ if (!Array.isArray(currentRows) || currentRows.length <= 1) {
85
+ throw new Error(
86
+ `Result exceeds the ${MAX_TOOL_TEXT_CHARS}-character tool output limit. Narrow the query.`,
87
+ );
88
+ }
89
+ const next = Math.max(1, Math.floor(currentRows.length / 2));
90
+ displayedRows = next;
91
+ truncated = true;
92
+ displayed = {
93
+ ...displayed,
94
+ [rowsKey]: currentRows.slice(0, next),
95
+ tool_output: {
96
+ truncated: true,
97
+ displayed_rows: next,
98
+ total_rows: Array.isArray(rows) ? rows.length : undefined,
99
+ hint: "Narrow filters or dimensions, or lower the limit.",
100
+ },
101
+ };
102
+ text = JSON.stringify(displayed, null, 2);
103
+ }
104
+
105
+ return { text, displayedRows, truncated };
106
+ }
107
+
108
+ function toolResult(result: Record<string, unknown>, rowsKey = "rows") {
109
+ const presented = present(result, rowsKey);
110
+ return {
111
+ content: [{ type: "text" as const, text: presented.text }],
112
+ details: {
113
+ displayedRows: presented.displayedRows,
114
+ truncated: presented.truncated,
115
+ },
116
+ };
117
+ }
118
+
119
+ function shapeQueryRows(
120
+ rows: SqlRow[],
121
+ measures: string[],
122
+ derived: ReturnType<typeof buildQuery>["derived"],
123
+ ): SqlRow[] {
124
+ return rows.map((row) => {
125
+ const shaped: SqlRow = { ...row };
126
+ for (const name of measures) {
127
+ shaped[name] = round(toNumber(row[name] as NumericLike));
128
+ }
129
+ shaped.source_rows = toNumber(row.source_rows as NumericLike);
130
+ return { ...shaped, ...computeDerived(row, derived) };
131
+ });
132
+ }
133
+
134
+ async function runStructuredQuery(spec: QuerySpec, signal?: AbortSignal) {
135
+ const built = buildQuery(spec);
136
+ const rows = await runQuery(built.sql, { signal });
137
+ return { built, rows: shapeQueryRows(rows, built.measures, built.derived) };
138
+ }
139
+
140
+ export default function fpaDataExtension(pi: ExtensionAPI): void {
141
+ pi.registerTool({
142
+ name: "fpa_data_catalog",
143
+ label: "FP&A Data Catalog",
144
+ description:
145
+ "List the FP&A datasets, dimensions, metrics (with formulas and aggregation semantics), " +
146
+ "live date coverage per dataset, and known data caveats. Call this before the first " +
147
+ "fpa_query/fpa_cohort/fpa_compare of a session.",
148
+ promptSnippet: "List FP&A datasets, metrics, live coverage, and caveats",
149
+ promptGuidelines: [
150
+ "Call fpa_data_catalog before the first FP&A data query of a session and treat its coverage block as the authority on which periods are answerable.",
151
+ ],
152
+ parameters: Type.Object(
153
+ {
154
+ include_apps: Type.Optional(
155
+ Type.Boolean({ description: "Also list distinct app codes (up to 200)." }),
156
+ ),
157
+ },
158
+ { additionalProperties: false },
159
+ ),
160
+ executionMode: "parallel",
161
+ async execute(_toolCallId, params, signal) {
162
+ const [coverage, cohortSize, apps] = await Promise.all([
163
+ runQuery(buildCoverageSql(), { signal }),
164
+ runQuery(buildCohortSizeCoverageSql(), { signal }),
165
+ params.include_apps
166
+ ? runQuery(
167
+ "select app_code, string_agg(distinct platform, ',' order by platform) as platforms " +
168
+ "from appsflyer_ua_campaign_daily where app_code is not null " +
169
+ "group by 1 order by 1 limit 200",
170
+ { signal },
171
+ )
172
+ : Promise.resolve<SqlRow[]>([]),
173
+ ]);
174
+
175
+ return toolResult(
176
+ {
177
+ datasets: DATASETS.map((d) => ({
178
+ id: d.id,
179
+ description: d.description,
180
+ date_column: d.dateColumn,
181
+ dimensions: d.dimensions.map((x) => ({ name: x.name, description: x.description })),
182
+ metrics: [
183
+ ...d.measures.map((m) => ({ name: m.name, unit: m.unit, description: m.description })),
184
+ ...d.derived.map((m) => ({
185
+ name: m.name,
186
+ unit: m.unit,
187
+ description: `${m.description} (computed by the extension, never by the model)`,
188
+ })),
189
+ ],
190
+ notes: d.notes,
191
+ })),
192
+ coverage,
193
+ cohort_size_available: cohortSize[0] ?? null,
194
+ caveats: CATALOG_CAVEATS,
195
+ ...(params.include_apps ? { apps } : {}),
196
+ },
197
+ "coverage",
198
+ );
199
+ },
200
+ });
201
+
202
+ pi.registerTool({
203
+ name: "fpa_query",
204
+ label: "FP&A Query",
205
+ description:
206
+ "Aggregate FP&A metrics from the Supabase mart. Pick a dataset, registered metrics, optional " +
207
+ "dimensions/time grain/filters and a date range; SQL generation, aggregation, and ratio " +
208
+ "computation are handled deterministically (ratios recomputed from aggregated components, " +
209
+ "NULL on missing data or zero denominators). Results include date_min/date_max/source_rows coverage columns.",
210
+ promptSnippet: "Aggregate FP&A metrics (spend, installs, revenue, CPI, ROAS inputs) from Supabase",
211
+ promptGuidelines: [
212
+ "Use fpa_query for every FP&A number; never estimate or hand-compute metrics from raw rows.",
213
+ "Report the date_min/date_max coverage returned by fpa_query alongside any number whose requested range exceeds the covered range.",
214
+ ],
215
+ parameters: Type.Object(
216
+ {
217
+ dataset: DatasetIdSchema,
218
+ metrics: Type.Array(Type.String({ minLength: 1 }), {
219
+ minItems: 1,
220
+ description: "Registered measure or derived metric names for the dataset",
221
+ }),
222
+ dimensions: Type.Optional(
223
+ Type.Array(Type.String({ minLength: 1 }), { description: "Group-by dimensions" }),
224
+ ),
225
+ time_grain: Type.Optional(
226
+ StringEnum(["day", "week", "month"] as const, {
227
+ description: "Optional time bucketing on the dataset's date column",
228
+ }),
229
+ ),
230
+ date_from: Type.Optional(Type.String({ description: "Inclusive ISO date" })),
231
+ date_to: Type.Optional(Type.String({ description: "Inclusive ISO date" })),
232
+ filters: Type.Optional(FiltersSchema),
233
+ sort: Type.Optional(
234
+ Type.Object(
235
+ {
236
+ by: Type.String({ minLength: 1 }),
237
+ direction: Type.Optional(StringEnum(["asc", "desc"] as const)),
238
+ },
239
+ { additionalProperties: false },
240
+ ),
241
+ ),
242
+ limit: Type.Optional(
243
+ Type.Integer({ minimum: 1, maximum: MAX_LIMIT, description: `Row limit (default 200, max ${MAX_LIMIT})` }),
244
+ ),
245
+ },
246
+ { additionalProperties: false },
247
+ ),
248
+ executionMode: "parallel",
249
+ async execute(_toolCallId, params, signal) {
250
+ const { built, rows } = await runStructuredQuery(
251
+ {
252
+ dataset: params.dataset,
253
+ metrics: params.metrics,
254
+ dimensions: params.dimensions,
255
+ timeGrain: params.time_grain,
256
+ dateFrom: params.date_from,
257
+ dateTo: params.date_to,
258
+ filters: params.filters,
259
+ sort: params.sort,
260
+ limit: params.limit,
261
+ },
262
+ signal,
263
+ );
264
+ return toolResult({
265
+ dataset: built.dataset.id,
266
+ rows,
267
+ row_count: rows.length,
268
+ notes: built.notes,
269
+ sql: built.sql,
270
+ });
271
+ },
272
+ });
273
+
274
+ pi.registerTool({
275
+ name: "fpa_cohort",
276
+ label: "FP&A Cohort Analysis",
277
+ description:
278
+ "Install-cohort LTV, ROAS, and retention curves per horizon (D0/D3/D7/...). Revenue is " +
279
+ "accumulated per install cohort in SQL, spend is joined from the UA table, and all ratios are " +
280
+ "computed deterministically. Immature horizons and cohorts without a recorded size return NULL " +
281
+ "with an explicit reason, never a guessed value.",
282
+ promptSnippet: "Compute install-cohort LTV / ROAS / retention curves",
283
+ promptGuidelines: [
284
+ "Use fpa_cohort (not fpa_query) for any LTV, ROAS, retention, or payback question; treat its NULL-with-reason entries as unanswerable rather than approximating them.",
285
+ ],
286
+ parameters: Type.Object(
287
+ {
288
+ app_codes: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { minItems: 1 })),
289
+ platforms: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { minItems: 1 })),
290
+ install_from: Type.String({ description: "Inclusive ISO install-date start" }),
291
+ install_to: Type.String({ description: "Inclusive ISO install-date end" }),
292
+ horizons: Type.Optional(
293
+ Type.Array(Type.Integer({ minimum: 0, maximum: 365 }), {
294
+ minItems: 1,
295
+ description: "Days since install (default [0,3,7,14,30])",
296
+ }),
297
+ ),
298
+ bucket: Type.Optional(
299
+ StringEnum(["day", "week", "month", "total"] as const, {
300
+ description: "Install-date bucketing (default week)",
301
+ }),
302
+ ),
303
+ group_by: Type.Optional(
304
+ Type.Array(StringEnum(["app_code", "platform"] as const), {
305
+ description: "Grouping keys (default app_code + platform)",
306
+ }),
307
+ ),
308
+ },
309
+ { additionalProperties: false },
310
+ ),
311
+ executionMode: "parallel",
312
+ async execute(_toolCallId, params, signal) {
313
+ const built = buildCohortQuery({
314
+ appCodes: params.app_codes,
315
+ platforms: params.platforms,
316
+ installFrom: params.install_from,
317
+ installTo: params.install_to,
318
+ horizons: params.horizons,
319
+ bucket: params.bucket,
320
+ groupBy: params.group_by,
321
+ });
322
+ const [cohortRows, spendRows, asOfRows] = await Promise.all([
323
+ runQuery(built.sql, { signal }),
324
+ runQuery(built.spendSql, { signal }),
325
+ runQuery(built.asOfSql, { signal }),
326
+ ]);
327
+ const maxEventDate = (asOfRows[0]?.max_event_date as string | undefined) ?? null;
328
+ const minEventDate = (asOfRows[0]?.min_event_date as string | undefined) ?? null;
329
+ const rows = buildCohortRows(
330
+ cohortRows,
331
+ spendRows,
332
+ built.groupBy,
333
+ built.horizons,
334
+ maxEventDate,
335
+ minEventDate,
336
+ );
337
+ return toolResult({
338
+ bucket: built.bucket,
339
+ horizons: built.horizons,
340
+ data_as_of: asOfRows[0] ?? null,
341
+ rows,
342
+ row_count: rows.length,
343
+ notes: [
344
+ "Spend joined from appsflyer_ua_campaign_daily (breakdown_type=CAMPAIGN); ROAS is NULL where spend is absent.",
345
+ "LTV and retention use installed_user_count as the denominator and are NULL when the cohort size is unrecorded.",
346
+ "The source table is a rolling activity window: cohorts installed before its min event_date get NULL horizon metrics because their early revenue is not in the table.",
347
+ ],
348
+ });
349
+ },
350
+ });
351
+
352
+ pi.registerTool({
353
+ name: "fpa_calc",
354
+ label: "FP&A Calculator",
355
+ description:
356
+ "Deterministic calculator for FP&A arithmetic: evaluate named formulas (+, -, *, /, parentheses, " +
357
+ "abs/min/max/round) against named numeric inputs. NULL inputs and division by zero propagate as " +
358
+ "NULL. Later expressions can reference earlier results. Runs locally; no database access.",
359
+ promptSnippet: "Evaluate FP&A formulas deterministically (never do mental math)",
360
+ promptGuidelines: [
361
+ "Route every ad-hoc calculation (variance, growth, run-rate, budget splits) through fpa_calc instead of computing it in prose.",
362
+ ],
363
+ parameters: Type.Object(
364
+ {
365
+ values: Type.Record(Type.String({ minLength: 1 }), Type.Union([Type.Number(), Type.Null()]), {
366
+ description: "Named numeric inputs, e.g. from fpa_query results",
367
+ }),
368
+ expressions: Type.Array(
369
+ Type.Object(
370
+ {
371
+ name: Type.String({ minLength: 1, description: "Result name (valid identifier)" }),
372
+ formula: Type.String({ minLength: 1, description: "e.g. (actual - forecast) / abs(forecast)" }),
373
+ },
374
+ { additionalProperties: false },
375
+ ),
376
+ { minItems: 1 },
377
+ ),
378
+ precision: Type.Optional(
379
+ Type.Integer({ minimum: 0, maximum: 12, description: "Rounding precision (default 6)" }),
380
+ ),
381
+ },
382
+ { additionalProperties: false },
383
+ ),
384
+ executionMode: "parallel",
385
+ async execute(_toolCallId, params) {
386
+ const results = runCalc(
387
+ params.values,
388
+ params.expressions as CalcExpression[],
389
+ params.precision ?? undefined,
390
+ );
391
+ return toolResult({ results }, "results");
392
+ },
393
+ });
394
+
395
+ pi.registerTool({
396
+ name: "fpa_compare",
397
+ label: "FP&A Period Compare",
398
+ description:
399
+ "Compare FP&A metrics between two periods (or actual vs. plan windows) on the same dataset. " +
400
+ "Runs both aggregations in SQL and computes per-row and total deltas, percentage changes, and " +
401
+ "per-row contribution to the total delta deterministically.",
402
+ promptSnippet: "Compare FP&A metrics across two periods with computed deltas",
403
+ promptGuidelines: [
404
+ "Use fpa_compare for period-over-period or actual-vs-baseline variance; do not subtract numbers from separate fpa_query calls manually.",
405
+ ],
406
+ parameters: Type.Object(
407
+ {
408
+ dataset: DatasetIdSchema,
409
+ metrics: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
410
+ dimensions: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
411
+ filters: Type.Optional(FiltersSchema),
412
+ current: Type.Object(
413
+ { from: Type.String(), to: Type.String() },
414
+ { additionalProperties: false, description: "Current period (inclusive ISO dates)" },
415
+ ),
416
+ baseline: Type.Object(
417
+ { from: Type.String(), to: Type.String() },
418
+ { additionalProperties: false, description: "Baseline period (inclusive ISO dates)" },
419
+ ),
420
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_LIMIT })),
421
+ },
422
+ { additionalProperties: false },
423
+ ),
424
+ executionMode: "parallel",
425
+ async execute(_toolCallId, params, signal) {
426
+ const dims = params.dimensions ?? [];
427
+ const makeSpec = (from: string, to: string, withDims: boolean): QuerySpec => ({
428
+ dataset: params.dataset,
429
+ metrics: params.metrics,
430
+ dimensions: withDims ? dims : [],
431
+ dateFrom: from,
432
+ dateTo: to,
433
+ filters: params.filters,
434
+ limit: params.limit,
435
+ });
436
+
437
+ const [current, baseline, currentTotal, baselineTotal] = await Promise.all([
438
+ runStructuredQuery(makeSpec(params.current.from, params.current.to, true), signal),
439
+ runStructuredQuery(makeSpec(params.baseline.from, params.baseline.to, true), signal),
440
+ dims.length > 0
441
+ ? runStructuredQuery(makeSpec(params.current.from, params.current.to, false), signal)
442
+ : null,
443
+ dims.length > 0
444
+ ? runStructuredQuery(makeSpec(params.baseline.from, params.baseline.to, false), signal)
445
+ : null,
446
+ ]);
447
+
448
+ const baseMeasures = new Set(current.built.measures);
449
+ const rowComparisons = comparePeriods(
450
+ current.rows,
451
+ baseline.rows,
452
+ dims,
453
+ params.metrics,
454
+ baseMeasures,
455
+ );
456
+ const totals = comparePeriods(
457
+ (currentTotal ?? current).rows,
458
+ (baselineTotal ?? baseline).rows,
459
+ [],
460
+ params.metrics,
461
+ baseMeasures,
462
+ );
463
+
464
+ return toolResult({
465
+ dataset: params.dataset,
466
+ current_period: params.current,
467
+ baseline_period: params.baseline,
468
+ totals: totals[0] ?? null,
469
+ rows: rowComparisons,
470
+ row_count: rowComparisons.length,
471
+ notes: [
472
+ ...current.built.notes,
473
+ "delta_pct is relative to abs(baseline); contribution_pct is each row's share of the total delta (base measures only).",
474
+ ],
475
+ });
476
+ },
477
+ });
478
+ }