Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions pm-stack/aggregator/scripts/collect-fleet-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@
// and check-usage append records there; each collection drains it into
// the feed's snapshots field for the laptop store to ingest),
// SUPABASE_PROJECT_REF (default: the muse-dev project),
// CHARGES_EXPORT_PATH (the #1057 charges producer export —
// ChargeCandidate[] JSON written by a Muse-side producer holding
// the Plaid/Gmail connectors; when present and parseable, the hub
// charges section comes from the auto-matcher in src/charges-match.ts
// instead of the prose extractor), CHARGES_SURFACED_STATE_PATH (the
// persisted keys of unmatched SaaS charges already surfaced in the
// feed, so each one surfaces exactly once),
// AA_OPENCODE_CONFIG_PATH (default: the repo's .opencode/opencode.jsonc).
import { execFile } from "node:child_process";
import { access, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
Expand All @@ -73,6 +80,13 @@ import type {
import { extractQuotaPools } from "../src/quota-extract.ts";
import { computeChoices } from "../src/quota-guidance.ts";
import { extractCharges, extractPaygLines, extractSaasServices, extractSubscriptions } from "../src/saas-extract.ts";
import { matchCharges, type UnmatchedCharge } from "../src/charges-match.ts";
import {
parseChargeCandidatesExport,
parseSurfacedState,
planUnmatchedSurfacing,
serviceRefsFromSaas,
} from "../src/charges-collect.ts";
import type { ChargeRow, PaygLine, SaasService, SnapshotRecord } from "../src/model.ts";
import { recordsFromDns, recordsFromPaygLines, recordsFromSaasServices } from "../src/snapshot-records.ts";
import { isSnapshotRecord, snapshotKey } from "../src/snapshots.ts";
Expand Down Expand Up @@ -102,6 +116,9 @@ const dnsRegisterPath =
const launchRecordsPath = process.env.CLAUDE_LAUNCH_RECORDS_PATH ?? join(HIDDEN, "claude-launch-records.jsonl");
const quotaRegistryPath = process.env.QUOTA_REGISTRY_PATH ?? join(HIDDEN, "provider-quota-registry.md");
const snapshotOutboxPath = process.env.USAGE_SNAPSHOT_OUTBOX_PATH ?? join(HIDDEN, "usage-snapshot-outbox.jsonl");
const chargesExportPath = process.env.CHARGES_EXPORT_PATH ?? join(HOME, "atlas/charges-export/latest.json");
const chargesSurfacedStatePath =
process.env.CHARGES_SURFACED_STATE_PATH ?? join(HIDDEN, "charges-surfaced-state.json");
const opencodeConfigPath = process.env.AA_OPENCODE_CONFIG_PATH ?? join(PKG, "..", "..", ".opencode", "opencode.jsonc");
const serverUrl = process.env.SANDBOX_OPENCODE_URL ?? "http://127.0.0.1:4096";
const passwordFile =
Expand Down Expand Up @@ -380,6 +397,8 @@ async function main(): Promise<void> {
let saas: SaasService[] | undefined;
let payg: PaygLine[] | undefined;
let charges: ChargeRow[] | undefined;
let chargesUnmatched: UnmatchedCharge[] | undefined;
let pendingSurfacedKeys: string[] | undefined;
let subscriptions: FleetFeed["subscriptions"];
if (quotaViewText) {
let registryText = "";
Expand All @@ -391,6 +410,23 @@ async function main(): Promise<void> {
saas = extractSaasServices(quotaViewText);
payg = extractPaygLines(quotaViewText, registryText);
charges = extractCharges(quotaViewText, registryText);
// --- charges auto-match (#1057): a producer export, when present
// and parseable, replaces the prose evidence with matched rows ---
try {
const candidates = parseChargeCandidatesExport(await readFile(chargesExportPath, "utf8"));
if (candidates && saas) {
const matched = matchCharges(candidates, serviceRefsFromSaas(saas));
charges = matched.rows;
const stateText = await readFile(chargesSurfacedStatePath, "utf8").catch(() => "");
const plan = planUnmatchedSurfacing(matched.unmatchedSaas, parseSurfacedState(stateText));
chargesUnmatched = plan.fresh;
pendingSurfacedKeys = plan.nextKeys;
} else if (candidates === null) {
console.error("collector: charges export unparseable; keeping prose-extracted charges");
}
} catch {
// No export yet: prose extraction stays in charge (fail-soft).
}
subscriptions = extractSubscriptions(quotaViewText);
}

Expand Down Expand Up @@ -563,12 +599,25 @@ async function main(): Promise<void> {
...(saas ? { saas } : {}),
...(payg ? { payg } : {}),
...(charges ? { charges } : {}),
...(chargesUnmatched?.length ? { chargesUnmatched } : {}),
...(subscriptions ? { subscriptions } : {}),
...(dedupedSnapshots.length ? { snapshots: dedupedSnapshots } : {}),
...(projects ? { projects } : {}),
aa,
};
await writeFile(outPath, JSON.stringify(feed, null, 2));
if (pendingSurfacedKeys) {
await writeFile(chargesSurfacedStatePath, JSON.stringify({ surfacedKeys: pendingSurfacedKeys }, null, 2)).catch(
Comment on lines 608 to +610

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist surfaced keys only after successful feed delivery

If the laptop is unreachable during a collection, these keys are permanently marked as surfaced before the feed reaches its consumer. In scripts/push-fleet-feed.sh, the collector runs before the Python transfer section; if that transfer fails, the next timer run recollects the feed, excludes the already-recorded charges, and overwrites the only feed containing their alerts. Consequently, those unmatched charges never appear on the laptop. Retain pending alerts across collections or acknowledge their keys only after successful delivery.

Useful? React with 👍 / 👎.

(err) => console.error("collector: could not persist charges surfaced state (" + err + "); unmatched charges may re-surface"),
);
console.log(
"collector: charges auto-match - " +
(charges?.length ?? 0) +
" rows, " +
(chargesUnmatched?.length ?? 0) +
" newly surfaced unmatched SaaS",
);
}
const withReads = pools.filter((p) => p.windows.length > 0).length;
console.log(
`collector: wrote ${outPath} — ${pools.length} pools (${withReads} with reads), ${attention.length} sessions (sandbox + jules${jules ? `, ${jules.tracked} jules tracked` : ""})` +
Expand Down
100 changes: 100 additions & 0 deletions pm-stack/aggregator/scripts/gather-charges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env bun
// Charges producer (#1057): the Muse-side half of the charges
// auto-matcher. The aggregator's collector cannot reach the Plaid or
// Gmail connectors in-process, so an agent holding those connectors
// saves their raw reads to files and this script normalizes them into
// the charges export the collector ingests
// (~/atlas/charges-export/latest.json — the file-export convention,
// gather-quota.ts family 2).
//
// Inputs (both fail-soft; either may be absent):
// PLAID_TRANSACTIONS_PATH — a Plaid transactions-get response saved
// verbatim: { accounts?: [{account_id, name, mask}], transactions:
// [{date, name, merchant_name?, amount, pending?}] }. Plaid's
// amount sign: positive = money out. Pending rows are skipped.
// Balances are never read or written — charge evidence only.
// GMAIL_ALERTS_PATH — receipt/card alert extractions as an array of
// {at, merchant, amountUsd, method?} (the reading agent extracts
// these fields from the mail; this script does not parse mail).
//
// Output: { generatedAt, candidates: ChargeCandidate[] } written
// atomically to CHARGES_EXPORT_PATH (default
// ~/atlas/charges-export/latest.json). The collector's matcher
// dedupes across sources, so overlapping reads are safe; re-running
// for the same period republishes the same candidates.
//
// Usage: bun run scripts/gather-charges.ts
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import type { ChargeCandidate } from "../src/charges-match.ts";
import { parseChargeCandidatesExport } from "../src/charges-collect.ts";

const HOME = homedir();
const plaidPath = process.env.PLAID_TRANSACTIONS_PATH ?? join(HOME, "atlas/charges-export/plaid-transactions.json");
const gmailPath = process.env.GMAIL_ALERTS_PATH ?? join(HOME, "atlas/charges-export/gmail-alerts.json");
const outPath = process.env.CHARGES_EXPORT_PATH ?? join(HOME, "atlas/charges-export/latest.json");

interface PlaidAccount {
account_id?: string;
name?: string;
mask?: string;
}
interface PlaidTransaction {
date?: string;
name?: string;
merchant_name?: string;
amount?: number;
pending?: boolean;
account_id?: string;
}

function plaidCandidates(doc: unknown): ChargeCandidate[] {
const d = doc as { accounts?: PlaidAccount[]; transactions?: PlaidTransaction[] };
if (!Array.isArray(d?.transactions)) return [];
const accounts = new Map((d.accounts ?? []).map((a) => [a.account_id, a]));
const out: ChargeCandidate[] = [];
for (const tx of d.transactions) {
if (tx.pending) continue;
if (typeof tx.amount !== "number" || !(tx.amount > 0)) continue;
if (typeof tx.date !== "string") continue;
const merchant = (tx.merchant_name || tx.name || "").trim();
if (!merchant) continue;
const account = tx.account_id ? accounts.get(tx.account_id) : undefined;
const method =
account?.name && account?.mask
? account.name + " •" + account.mask
: account?.mask
? "•" + account.mask
: undefined;
out.push({ source: "plaid", at: tx.date, merchant, amountUsd: tx.amount, ...(method ? { method } : {}) });
}
return out;
}

async function readJson(path: string): Promise<unknown | null> {
try {
return JSON.parse(await readFile(path, "utf8"));
} catch {
return null;
}
}

const candidates: ChargeCandidate[] = [];
const plaidDoc = await readJson(plaidPath);
if (plaidDoc) candidates.push(...plaidCandidates(plaidDoc));
else console.error("gather-charges: no readable Plaid transactions at " + plaidPath);

const gmailDoc = await readJson(gmailPath);
if (gmailDoc) {
const parsed = parseChargeCandidatesExport(JSON.stringify(gmailDoc));
candidates.push(...(parsed ?? []).filter((c) => c.source === "gmail"));
Comment on lines +90 to +91

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Tag Gmail alert extractions before validating candidates

When GMAIL_ALERTS_PATH contains the documented {at, merchant, amountUsd, method?} rows, every alert is silently dropped: parseChargeCandidatesExport requires a source field equal to gmail or plaid, but the documented extraction format supplies neither. A valid Gmail-only receipt therefore produces no charge evidence or unmatched alert despite the producer reporting success. Add the Gmail source while normalizing these rows before candidate validation.

Useful? React with 👍 / 👎.

} else {
console.error("gather-charges: no readable Gmail alerts at " + gmailPath);
}

await mkdir(dirname(outPath), { recursive: true });
const tmp = outPath + ".tmp";
await writeFile(tmp, JSON.stringify({ generatedAt: new Date().toISOString(), candidates }, null, 2));
await rename(tmp, outPath);
Comment on lines +98 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the export when both input sources are unavailable

When both input files are missing, unreadable, or invalid JSON, the producer still exits successfully and overwrites the last-known-good export with candidates: []. The collector accepts that empty array and replaces its prose-derived charges with zero matched rows, so a source-read failure erases existing evidence and disables the otherwise-working prose fallback. Track whether any source was successfully read and leave the existing export untouched when neither was available, distinguishing that failure from a successful read containing no transactions.

Useful? React with 👍 / 👎.

console.log("gather-charges: wrote " + candidates.length + " candidates to " + outPath);
111 changes: 111 additions & 0 deletions pm-stack/aggregator/src/charges-collect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// charges-collect (#1057): the collector-side half of the charges
// auto-matcher. The pure matching core lives in src/charges-match.ts;
// this module owns everything stateful or parsing-shaped around it:
//
// - the charges export contract: a Muse-side producer (an agent
// with the Plaid and Gmail connectors, which the collector process
// cannot reach in-process) writes ChargeCandidate[] JSON to
// ~/atlas/charges-export/latest.json — the file-export convention
// gather-quota.ts family 2 established (#1301). The collector
// ingests that file; an absent or malformed export changes
// nothing (the prose extractor stays in charge).
// - building the matcher's service refs from the hub's own SaaS
// service list, so a charge matches the services the hub tracks.
// - the persisted "already surfaced" state for unmatched SaaS
// charges: an unmatched charge joins the feed's chargesUnmatched
// section exactly once; the collector records its key in a state
// file and never surfaces the same charge again. Re-running the
// collector for the same period is a no-op for surfacing.
//
// No balances or debt data ever enter here: candidates are charge
// evidence only (date, merchant, amount, payment-method label).

import type { ChargeCandidate, ChargeServiceRef, UnmatchedCharge } from "./charges-match.ts";
import type { SaasService } from "./model.ts";

/**
* Parse a charges export document into candidates. Returns null when
* the document is not JSON at all (the caller keeps its fallback);
* a JSON document with malformed entries yields the valid subset —
* one bad row from a producer must not sink the whole period.
*/
export function parseChargeCandidatesExport(text: string): ChargeCandidate[] | null {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return null;
}
const list = Array.isArray(parsed)
? parsed
: (parsed as { candidates?: unknown })?.candidates;
if (!Array.isArray(list)) return null;
const out: ChargeCandidate[] = [];
for (const entry of list) {
if (typeof entry !== "object" || entry === null) continue;
const e = entry as Record<string, unknown>;
if (e.source !== "plaid" && e.source !== "gmail") continue;
if (typeof e.at !== "string" || !Number.isFinite(Date.parse(e.at))) continue;
if (typeof e.merchant !== "string" || !e.merchant.trim()) continue;
if (typeof e.amountUsd !== "number" || !Number.isFinite(e.amountUsd)) continue;
out.push({
source: e.source,
at: e.at,
merchant: e.merchant,
amountUsd: e.amountUsd,
...(typeof e.method === "string" && e.method.trim() ? { method: e.method } : {}),
});
}
return out;
}

/** The matcher's service refs: the hub's tracked SaaS services by name. */
export function serviceRefsFromSaas(saas: SaasService[]): ChargeServiceRef[] {
return saas.map((s) => ({ name: s.name }));
}

/**
* Identity of one unmatched charge for surfacing purposes: the same
* charge re-observed next period (a new posting date) is a new
* charge and may surface again; the same posting re-collected is not.
*/
export function unmatchedChargeKey(u: UnmatchedCharge): string {
return `${u.normalized}|${u.amountUsd.toFixed(2)}|${u.at}`;
Comment on lines +72 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve unmatched-charge identity when Plaid evidence arrives

When a Gmail alert is collected before its corresponding Plaid posting, the same purchase surfaces twice if their dates differ. For example, a Gmail Cursor charge dated October 26 is recorded under that exact date; when the matching Plaid charge dated October 28 arrives, matchCharges correctly merges the evidence and prefers the Plaid date, but this key changes and the planner treats it as fresh. Reconcile persisted identities using the matcher's cross-source date window or another stable purchase identity so adding source-of-record evidence does not notify again.

Useful? React with 👍 / 👎.

}

export interface SurfacingPlan {
/** Unmatched charges not present in the persisted state. */
fresh: UnmatchedCharge[];
/** The full key set to persist (prior keys plus the fresh ones). */
nextKeys: string[];
}

/**
* Decide which unmatched charges surface this run. Pure: the
* collector loads the persisted keys, plans, publishes `fresh`, and
* writes `nextKeys` back — in that order, so a crash between publish
* and persist can re-surface (visible, harmless) but never lose a
* surfacing silently.
*/
export function planUnmatchedSurfacing(unmatched: UnmatchedCharge[], surfacedKeys: string[]): SurfacingPlan {
const seen = new Set(surfacedKeys);
const fresh: UnmatchedCharge[] = [];
for (const u of unmatched) {
const key = unmatchedChargeKey(u);
if (seen.has(key)) continue;
seen.add(key);
fresh.push(u);
}
return { fresh, nextKeys: [...seen] };
}

/** Parse the persisted surfaced-keys state file; anything odd = empty state. */
export function parseSurfacedState(text: string): string[] {
try {
const parsed: unknown = JSON.parse(text);
const keys = (parsed as { surfacedKeys?: unknown })?.surfacedKeys;
return Array.isArray(keys) ? keys.filter((k): k is string => typeof k === "string") : [];
} catch {
return [];
}
}
5 changes: 5 additions & 0 deletions pm-stack/aggregator/src/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
SessionRecord,
SnapshotRecord,
} from "./model.ts";
import type { UnmatchedCharge } from "./charges-match.ts";

export interface FleetFeed {
version: 1;
Expand All @@ -43,6 +44,10 @@ export interface FleetFeed {
payg?: PaygLine[];
/** Hub charges evidence (§2.4), from the same dated records. */
charges?: ChargeRow[];
/** Unmatched SaaS/AI-looking charges surfaced for classification
* (#1057): only charges the collector has never surfaced before
* (persisted state decides), so each appears exactly once. */
chargesUnmatched?: UnmatchedCharge[];
/** Hub subscriptions strip rows (Q5: renewal-only). */
subscriptions?: { name: string; plan: string; renewsAt: string | null }[];
/** Usage snapshot records (hub §4 writers): derived by the collector
Expand Down
2 changes: 1 addition & 1 deletion pm-stack/aggregator/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1182,7 +1182,7 @@ export interface ChargeRow {
* projected unbilled PAYG over $5 (unbilled accrual only, Q3 default).
*/
export interface AttentionFlag {
kind: "over-limit" | "payg-unbilled";
kind: "over-limit" | "payg-unbilled" | "unmatched-charge";
label: string;
detail: string;
href: string;
Expand Down
11 changes: 10 additions & 1 deletion pm-stack/aggregator/src/sources/live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -817,7 +817,16 @@ export function liveSource(cfg: Config, opts?: { standingAuto?: () => Promise<bo
chargesNote: partial.charges.length
? undefined
: "No charge evidence in the feed's records yet — rows appear as Plaid/Gmail receipt evidence is recorded (§2.4).",
attention: buildAttentionFlags(feed.quota, partial),
attention: [
...buildAttentionFlags(feed.quota, partial),
...(feed.chargesUnmatched ?? []).map((u) => ({
kind: "unmatched-charge" as const,
label: `Unmatched SaaS charge - ${u.merchant}`,
detail: `${u.amountUsd.toFixed(2)} on ${u.at} via ${u.source === "plaid" ? "Plaid" : "Gmail alert"} - classify it or add the service to the hub registry.`,
href: "#quota",
severity: u.amountUsd,
})),
],
domains,
history,
};
Expand Down
Loading
Loading