@agentguard-run/spend 0.2.2 → 0.4.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 +14 -1
- package/LICENSE +1 -1
- package/README.es-419.md +37 -100
- package/README.md +58 -121
- package/README.pt-BR.md +37 -100
- package/dist/bindings/anthropic.d.ts +11 -0
- package/dist/bindings/anthropic.d.ts.map +1 -0
- package/dist/bindings/anthropic.js +116 -0
- package/dist/bindings/anthropic.js.map +1 -0
- package/dist/bindings/bedrock.d.ts +11 -0
- package/dist/bindings/bedrock.d.ts.map +1 -0
- package/dist/bindings/bedrock.js +177 -0
- package/dist/bindings/bedrock.js.map +1 -0
- package/dist/cli/auth.d.ts +7 -0
- package/dist/cli/auth.d.ts.map +1 -0
- package/dist/cli/auth.js +189 -0
- package/dist/cli/auth.js.map +1 -0
- package/dist/cli/coach.d.ts +5 -0
- package/dist/cli/coach.d.ts.map +1 -0
- package/dist/cli/coach.js +257 -0
- package/dist/cli/coach.js.map +1 -0
- package/dist/cli/colors.d.ts +8 -3
- package/dist/cli/colors.d.ts.map +1 -1
- package/dist/cli/colors.js +93 -4
- package/dist/cli/colors.js.map +1 -1
- package/dist/cli/demo.d.ts.map +1 -1
- package/dist/cli/demo.js +23 -2
- package/dist/cli/demo.js.map +1 -1
- package/dist/cli/main.d.ts +0 -6
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +42 -16
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/models.d.ts +18 -0
- package/dist/cli/models.d.ts.map +1 -0
- package/dist/cli/models.js +277 -0
- package/dist/cli/models.js.map +1 -0
- package/dist/cli/tips.d.ts +21 -0
- package/dist/cli/tips.d.ts.map +1 -0
- package/dist/cli/tips.js +191 -0
- package/dist/cli/tips.js.map +1 -0
- package/dist/cli/wizard.d.ts +27 -0
- package/dist/cli/wizard.d.ts.map +1 -0
- package/dist/cli/wizard.js +182 -0
- package/dist/cli/wizard.js.map +1 -0
- package/dist/coach/anomaly.d.ts +26 -0
- package/dist/coach/anomaly.d.ts.map +1 -0
- package/dist/coach/anomaly.js +119 -0
- package/dist/coach/anomaly.js.map +1 -0
- package/dist/coach/conversation.d.ts +69 -0
- package/dist/coach/conversation.d.ts.map +1 -0
- package/dist/coach/conversation.js +228 -0
- package/dist/coach/conversation.js.map +1 -0
- package/dist/coach/forecast.d.ts +19 -0
- package/dist/coach/forecast.d.ts.map +1 -0
- package/dist/coach/forecast.js +57 -0
- package/dist/coach/forecast.js.map +1 -0
- package/dist/coach/llm-client.d.ts +41 -0
- package/dist/coach/llm-client.d.ts.map +1 -0
- package/dist/coach/llm-client.js +248 -0
- package/dist/coach/llm-client.js.map +1 -0
- package/dist/coach/output.d.ts +41 -0
- package/dist/coach/output.d.ts.map +1 -0
- package/dist/coach/output.js +173 -0
- package/dist/coach/output.js.map +1 -0
- package/dist/coach/system-prompt.d.ts +20 -0
- package/dist/coach/system-prompt.d.ts.map +1 -0
- package/dist/coach/system-prompt.js +177 -0
- package/dist/coach/system-prompt.js.map +1 -0
- package/dist/cost-table.d.ts +11 -36
- package/dist/cost-table.d.ts.map +1 -1
- package/dist/cost-table.js +114 -45
- package/dist/cost-table.js.map +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -2
- package/dist/index.js.map +1 -1
- package/dist/openrouter-catalog.d.ts +56 -0
- package/dist/openrouter-catalog.d.ts.map +1 -0
- package/dist/openrouter-catalog.js +183 -0
- package/dist/openrouter-catalog.js.map +1 -0
- package/dist/spend-guard.d.ts +38 -55
- package/dist/spend-guard.d.ts.map +1 -1
- package/dist/spend-guard.js +268 -83
- package/dist/spend-guard.js.map +1 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +52 -21
- package/dist/telemetry.js.map +1 -1
- package/dist/templates/index.d.ts +17 -0
- package/dist/templates/index.d.ts.map +1 -0
- package/dist/templates/index.js +100 -0
- package/dist/templates/index.js.map +1 -0
- package/dist/types.d.ts +18 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +39 -4
- package/src/bindings/anthropic.ts +142 -0
- package/src/bindings/bedrock.ts +200 -0
- package/src/cli/auth.ts +145 -0
- package/src/cli/coach.ts +249 -0
- package/src/cli/models.ts +236 -0
- package/src/cli/tips.ts +161 -0
- package/src/cli/wizard.ts +160 -0
- package/src/coach/anomaly.ts +98 -0
- package/src/coach/conversation.ts +248 -0
- package/src/coach/forecast.ts +64 -0
- package/src/coach/llm-client.ts +247 -0
- package/src/coach/output.ts +172 -0
- package/src/coach/system-prompt.ts +181 -0
- package/src/openrouter-catalog.ts +180 -0
- package/src/templates/agent-support.yaml +30 -0
- package/src/templates/chargeback-evidence.yaml +30 -0
- package/src/templates/code-scan.yaml +30 -0
- package/src/templates/index.ts +109 -0
- package/src/templates/payment-approval.yaml +30 -0
- package/src/templates/risk-review.yaml +30 -0
- package/tests/fixtures/openrouter-catalog.json +1 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as readline from 'readline';
|
|
4
|
+
import { AGENTGUARD_SPEND_VERSION } from '../index';
|
|
5
|
+
import { fetchCatalog, getCachedCatalog, syncPricingIntoCostTable, type OpenRouterModel } from '../openrouter-catalog';
|
|
6
|
+
import { getModelCost, knownModelCosts } from '../cost-table';
|
|
7
|
+
import { TASK_TEMPLATES, getTaskTemplate } from '../templates';
|
|
8
|
+
import { agentguardHome, banner, cyanBold, dim, green, statusBar, yellow } from './colors';
|
|
9
|
+
|
|
10
|
+
export interface ModelRow {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
inputCentsPerMtok: number | null;
|
|
14
|
+
outputCentsPerMtok: number | null;
|
|
15
|
+
capability: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function rowsFromCatalog(models: OpenRouterModel[]): ModelRow[] {
|
|
19
|
+
return models.map((model) => {
|
|
20
|
+
const input = Number(model.pricing?.prompt);
|
|
21
|
+
const output = Number(model.pricing?.completion);
|
|
22
|
+
return {
|
|
23
|
+
id: model.id,
|
|
24
|
+
name: model.name ?? model.id,
|
|
25
|
+
inputCentsPerMtok: Number.isFinite(input) ? input * 100 * 1_000_000 : null,
|
|
26
|
+
outputCentsPerMtok: Number.isFinite(output) ? output * 100 * 1_000_000 : null,
|
|
27
|
+
capability: capabilityForModel(model.id),
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function rowsFromCostTable(): ModelRow[] {
|
|
33
|
+
return Object.entries(knownModelCosts()).map(([id, cost]) => ({
|
|
34
|
+
id,
|
|
35
|
+
name: id,
|
|
36
|
+
inputCentsPerMtok: cost.inputCentsPerKtok * 1000,
|
|
37
|
+
outputCentsPerMtok: cost.outputCentsPerKtok * 1000,
|
|
38
|
+
capability: capabilityForModel(id),
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function filterRows(rows: ModelRow[], opts: { search?: string; capability?: string; task?: string }): ModelRow[] {
|
|
43
|
+
let out = rows;
|
|
44
|
+
if (opts.task) {
|
|
45
|
+
const template = getTaskTemplate(opts.task);
|
|
46
|
+
if (template) {
|
|
47
|
+
const allowed = new Set(template.allowedModels);
|
|
48
|
+
out = out.filter((row) => allowed.has(row.id) || row.capability === template.requiredCapability);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (opts.capability) out = out.filter((row) => row.capability === opts.capability);
|
|
52
|
+
if (opts.search) {
|
|
53
|
+
const needle = opts.search.toLowerCase();
|
|
54
|
+
out = out.filter((row) => row.id.toLowerCase().includes(needle) || row.name.toLowerCase().includes(needle));
|
|
55
|
+
}
|
|
56
|
+
return out.sort((a, b) => displayCost(a) - displayCost(b) || a.id.localeCompare(b.id));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function suggestedAssignments(task: string): Record<string, string[]> {
|
|
60
|
+
const template = getTaskTemplate(task);
|
|
61
|
+
if (!template) return {};
|
|
62
|
+
return {
|
|
63
|
+
primary: [template.primaryModel],
|
|
64
|
+
fallback: [template.fallbackModel],
|
|
65
|
+
allowed: template.allowedModels,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function runModels(argv: string[]): Promise<number> {
|
|
70
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
71
|
+
console.log('agentguard models [--sync-pricing] [--search <term>] [--capability <tier>] [--task <template>] [--json]');
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const sync = argv.includes('--sync-pricing');
|
|
76
|
+
const json = argv.includes('--json');
|
|
77
|
+
const search = argValue(argv, '--search');
|
|
78
|
+
const capability = argValue(argv, '--capability');
|
|
79
|
+
const task = argValue(argv, '--task');
|
|
80
|
+
|
|
81
|
+
if (sync) {
|
|
82
|
+
const result = await syncPricingIntoCostTable({ force: true });
|
|
83
|
+
if (json) {
|
|
84
|
+
console.log(JSON.stringify(result, null, 2));
|
|
85
|
+
} else {
|
|
86
|
+
console.log('');
|
|
87
|
+
console.log(' ' + banner(AGENTGUARD_SPEND_VERSION));
|
|
88
|
+
console.log('');
|
|
89
|
+
console.log(` ${green('applied')} ${result.count} OpenRouter pricing overrides`);
|
|
90
|
+
console.log(` ${dim('saved')} ${agentguardHome()}/cost-overrides.json`);
|
|
91
|
+
console.log('');
|
|
92
|
+
}
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const rows = await loadRows();
|
|
97
|
+
const filtered = filterRows(rows, { search, capability, task });
|
|
98
|
+
if (json) {
|
|
99
|
+
console.log(JSON.stringify({ models: filtered, assignments: task ? suggestedAssignments(task) : undefined }, null, 2));
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (process.stdin.isTTY && !search && !capability && !task) {
|
|
104
|
+
const selected = await interactivePick(filtered);
|
|
105
|
+
if (selected.length > 0) writeSelectedModels(selected);
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
printRows(filtered, task);
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function loadRows(): Promise<ModelRow[]> {
|
|
114
|
+
const cached = getCachedCatalog();
|
|
115
|
+
if (cached) return rowsFromCatalog(cached.data);
|
|
116
|
+
try {
|
|
117
|
+
const catalog = await fetchCatalog();
|
|
118
|
+
return rowsFromCatalog(catalog.data);
|
|
119
|
+
} catch {
|
|
120
|
+
return rowsFromCostTable();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function printRows(rows: ModelRow[], task?: string): void {
|
|
125
|
+
console.log('');
|
|
126
|
+
console.log(' ' + banner(AGENTGUARD_SPEND_VERSION));
|
|
127
|
+
console.log('');
|
|
128
|
+
if (task && TASK_TEMPLATES[task]) {
|
|
129
|
+
const assignment = suggestedAssignments(task);
|
|
130
|
+
console.log(' ' + cyanBold(`task: ${task}`));
|
|
131
|
+
console.log(` primary: ${assignment.primary?.join(', ') ?? 'n/a'}`);
|
|
132
|
+
console.log(` fallback: ${assignment.fallback?.join(', ') ?? 'n/a'}`);
|
|
133
|
+
console.log('');
|
|
134
|
+
}
|
|
135
|
+
for (const row of rows.slice(0, 80)) {
|
|
136
|
+
console.log(` ${green(row.id.padEnd(38))} ${formatCost(row).padEnd(22)} ${dim(row.capability)}`);
|
|
137
|
+
}
|
|
138
|
+
if (rows.length > 80) console.log(` ${yellow(String(rows.length - 80) + ' more')} refine with agentguard models --search <term>`);
|
|
139
|
+
console.log('');
|
|
140
|
+
console.log(' ' + statusBar());
|
|
141
|
+
console.log('');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function interactivePick(rows: ModelRow[]): Promise<string[]> {
|
|
145
|
+
const selected = new Set<string>();
|
|
146
|
+
let cursor = 0;
|
|
147
|
+
let filtered = rows.slice(0, 80);
|
|
148
|
+
let query = '';
|
|
149
|
+
readline.emitKeypressEvents(process.stdin);
|
|
150
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
151
|
+
|
|
152
|
+
const render = () => {
|
|
153
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
154
|
+
console.log(banner(AGENTGUARD_SPEND_VERSION));
|
|
155
|
+
console.log('');
|
|
156
|
+
console.log(`Search: ${query || dim('press / to filter')} Selected: ${selected.size}`);
|
|
157
|
+
console.log('');
|
|
158
|
+
filtered.slice(0, 18).forEach((row, index) => {
|
|
159
|
+
const marker = selected.has(row.id) ? '[x]' : '[ ]';
|
|
160
|
+
const pointer = index === cursor ? '>' : ' ';
|
|
161
|
+
console.log(`${pointer} ${marker} ${row.id.padEnd(40)} ${formatCost(row).padEnd(20)} ${row.capability}`);
|
|
162
|
+
});
|
|
163
|
+
console.log('');
|
|
164
|
+
console.log(dim('[/] search [space] select [enter] confirm [q] quit'));
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
render();
|
|
168
|
+
return new Promise((resolve) => {
|
|
169
|
+
const onKey = (_chunk: string, key: readline.Key) => {
|
|
170
|
+
if (key.name === 'q' || (key.ctrl && key.name === 'c')) finish([]);
|
|
171
|
+
else if (key.name === 'return') finish([...selected]);
|
|
172
|
+
else if (key.name === 'down') { cursor = Math.min(cursor + 1, Math.max(0, filtered.length - 1)); render(); }
|
|
173
|
+
else if (key.name === 'up') { cursor = Math.max(cursor - 1, 0); render(); }
|
|
174
|
+
else if (key.name === 'space') {
|
|
175
|
+
const row = filtered[cursor];
|
|
176
|
+
if (row) selected.has(row.id) ? selected.delete(row.id) : selected.add(row.id);
|
|
177
|
+
render();
|
|
178
|
+
} else if (key.name === 'backspace') {
|
|
179
|
+
query = query.slice(0, -1);
|
|
180
|
+
filtered = filterRows(rows, { search: query });
|
|
181
|
+
cursor = 0;
|
|
182
|
+
render();
|
|
183
|
+
} else if (key.sequence && key.sequence.length === 1 && key.sequence !== '/') {
|
|
184
|
+
query += key.sequence;
|
|
185
|
+
filtered = filterRows(rows, { search: query });
|
|
186
|
+
cursor = 0;
|
|
187
|
+
render();
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
const finish = (value: string[]) => {
|
|
191
|
+
process.stdin.off('keypress', onKey);
|
|
192
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
193
|
+
process.stdout.write('\n');
|
|
194
|
+
resolve(value);
|
|
195
|
+
};
|
|
196
|
+
process.stdin.on('keypress', onKey);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function writeSelectedModels(models: string[]): void {
|
|
201
|
+
const filePath = path.join(agentguardHome(), 'policy.yaml');
|
|
202
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
203
|
+
let existing = '';
|
|
204
|
+
try { existing = fs.readFileSync(filePath, 'utf8'); } catch { existing = defaultPolicy(); }
|
|
205
|
+
const block = ['allowedModels:', ...models.map((model) => ` - ${model}`)].join('\n') + '\n';
|
|
206
|
+
const next = existing.match(/^allowedModels:/m)
|
|
207
|
+
? existing.replace(/^allowedModels:\n(?: - .*\n)*/m, block)
|
|
208
|
+
: existing.trimEnd() + '\n' + block;
|
|
209
|
+
fs.writeFileSync(filePath, next);
|
|
210
|
+
console.log(`${green('saved')} ${filePath}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function defaultPolicy(): string {
|
|
214
|
+
return 'id: local-policy-v1\nname: Local AgentGuard policy\nversion: 1\neffectiveFrom: "' + new Date().toISOString() + '"\nmode: enforce\nscope:\n tenantId: local\ncaps:\n - amountCents: 100\n window: per_call\n action: allow\n';
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function formatCost(row: ModelRow): string {
|
|
218
|
+
if (row.inputCentsPerMtok === null || row.outputCentsPerMtok === null) return '$n/a per 1M';
|
|
219
|
+
return `$${(row.inputCentsPerMtok / 100).toFixed(2)}/$${(row.outputCentsPerMtok / 100).toFixed(2)} per 1M`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function displayCost(row: ModelRow): number {
|
|
223
|
+
return row.inputCentsPerMtok ?? Number.MAX_SAFE_INTEGER;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function capabilityForModel(model: string): string {
|
|
227
|
+
if (model.includes('gpt-5') || model.includes('opus') || model.includes('sonnet')) return 'payment_initiate';
|
|
228
|
+
if (model.includes('mini') || model.includes('haiku') || model.includes('flash')) return 'read_only';
|
|
229
|
+
if (getModelCost(model)) return 'data_write';
|
|
230
|
+
return 'read_only';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function argValue(argv: string[], flag: string): string | undefined {
|
|
234
|
+
const index = argv.indexOf(flag);
|
|
235
|
+
return index >= 0 ? argv[index + 1] : undefined;
|
|
236
|
+
}
|
package/src/cli/tips.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { AGENTGUARD_SPEND_VERSION } from '../index';
|
|
4
|
+
import { agentguardHome, banner, dim, green, statusBar } from './colors';
|
|
5
|
+
|
|
6
|
+
export interface AgentGuardTip {
|
|
7
|
+
id: string;
|
|
8
|
+
surface: string;
|
|
9
|
+
severity: 'info' | 'warning';
|
|
10
|
+
text: string;
|
|
11
|
+
learn_more_url: string;
|
|
12
|
+
dismissible: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface TipsState {
|
|
16
|
+
seen?: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const TIPS: AgentGuardTip[] = [
|
|
20
|
+
{
|
|
21
|
+
id: 'cheap-read-only',
|
|
22
|
+
surface: 'models',
|
|
23
|
+
severity: 'info',
|
|
24
|
+
text: "Use 'openai/gpt-4o-mini' for read-only tasks. It is far cheaper than gpt-5 with the same audit guarantees.",
|
|
25
|
+
learn_more_url: 'https://agentguard.run/docs/models',
|
|
26
|
+
dismissible: true,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'openrouter-cfo',
|
|
30
|
+
surface: 'auth',
|
|
31
|
+
severity: 'info',
|
|
32
|
+
text: 'One OpenRouter key gives access to hundreds of models across many providers. Your CFO sees one invoice while AgentGuard enforces who uses what.',
|
|
33
|
+
learn_more_url: 'https://agentguard.run/docs/openrouter',
|
|
34
|
+
dismissible: true,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'public-verify',
|
|
38
|
+
surface: 'verify',
|
|
39
|
+
severity: 'info',
|
|
40
|
+
text: 'Share https://agentguard.run/verify with your auditor. Paste any receipt and get browser-based cryptographic verification with no install.',
|
|
41
|
+
learn_more_url: 'https://agentguard.run/verify',
|
|
42
|
+
dismissible: true,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'weekly-pricing',
|
|
46
|
+
surface: 'models',
|
|
47
|
+
severity: 'info',
|
|
48
|
+
text: "Run 'agentguard models --sync-pricing' weekly to keep cost math current with OpenRouter live prices.",
|
|
49
|
+
learn_more_url: 'https://agentguard.run/docs/pricing',
|
|
50
|
+
dismissible: true,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'payment-attestation',
|
|
54
|
+
surface: 'wizard',
|
|
55
|
+
severity: 'warning',
|
|
56
|
+
text: "Capability tier 'payment_execute' should require a verified attestation. Do not accept caller-supplied claims for real money movement.",
|
|
57
|
+
learn_more_url: 'https://agentguard.run/docs/capabilities',
|
|
58
|
+
dismissible: true,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'shadow-rollout',
|
|
62
|
+
surface: 'policy',
|
|
63
|
+
severity: 'info',
|
|
64
|
+
text: "Use mode 'shadow' for the first day of a rollout. You get signed decisions before enforcement changes traffic.",
|
|
65
|
+
learn_more_url: 'https://agentguard.run/docs/policies',
|
|
66
|
+
dismissible: true,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'verify-chain',
|
|
70
|
+
surface: 'verify',
|
|
71
|
+
severity: 'info',
|
|
72
|
+
text: "Run 'agentguard verify <log.jsonl> <pubkey>' before sharing an audit packet. Whole-chain verification catches missing entries.",
|
|
73
|
+
learn_more_url: 'https://agentguard.run/docs/verify',
|
|
74
|
+
dismissible: true,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'local-overrides',
|
|
78
|
+
surface: 'models',
|
|
79
|
+
severity: 'info',
|
|
80
|
+
text: 'Negotiated rates belong in ~/.agentguard/cost-overrides.json. Keep pricing math local with the rest of the policy state.',
|
|
81
|
+
learn_more_url: 'https://agentguard.run/docs/pricing',
|
|
82
|
+
dismissible: true,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: 'bedrock-routing',
|
|
86
|
+
surface: 'bindings',
|
|
87
|
+
severity: 'info',
|
|
88
|
+
text: 'Use the native Bedrock binding when AWS procurement requires direct provider calls. AgentGuard still signs every local decision.',
|
|
89
|
+
learn_more_url: 'https://agentguard.run/docs/bedrock',
|
|
90
|
+
dismissible: true,
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
export function tipsStatePath(): string {
|
|
95
|
+
return path.join(agentguardHome(), 'tips-seen.json');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function readTipsState(filePath: string = tipsStatePath()): TipsState {
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as TipsState;
|
|
101
|
+
return Array.isArray(parsed.seen) ? parsed : { seen: [] };
|
|
102
|
+
} catch {
|
|
103
|
+
return { seen: [] };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function writeTipsState(state: TipsState, filePath: string = tipsStatePath()): void {
|
|
108
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
109
|
+
fs.writeFileSync(filePath, JSON.stringify({ seen: [...new Set(state.seen ?? [])] }, null, 2) + '\n', { mode: 0o600 });
|
|
110
|
+
try { fs.chmodSync(filePath, 0o600); } catch { return; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function unseenTips(state: TipsState = readTipsState()): AgentGuardTip[] {
|
|
114
|
+
const seen = new Set(state.seen ?? []);
|
|
115
|
+
return TIPS.filter((tip) => !seen.has(tip.id));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function markTipsSeen(ids: string[], filePath: string = tipsStatePath()): void {
|
|
119
|
+
const state = readTipsState(filePath);
|
|
120
|
+
writeTipsState({ seen: [...new Set([...(state.seen ?? []), ...ids])] }, filePath);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function maybeShowStartupTips(force = false): void {
|
|
124
|
+
const state = readTipsState();
|
|
125
|
+
const startupIds = ['startup-wizard', 'startup-verify'];
|
|
126
|
+
const seen = new Set(state.seen ?? []);
|
|
127
|
+
if (!force && startupIds.every((id) => seen.has(id))) return;
|
|
128
|
+
console.log('');
|
|
129
|
+
console.log(' ' + green("Tip: try 'agentguard wizard' to set up in 90 seconds."));
|
|
130
|
+
console.log(' ' + green('Tip: share https://agentguard.run/verify with your auditor.'));
|
|
131
|
+
markTipsSeen(startupIds);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function runTips(argv: string[]): Promise<number> {
|
|
135
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
136
|
+
console.log('agentguard tips [--reset] [--json]');
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
if (argv.includes('--reset')) {
|
|
140
|
+
writeTipsState({ seen: [] });
|
|
141
|
+
console.log('AgentGuard tips reset.');
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
if (argv.includes('--json')) {
|
|
145
|
+
console.log(JSON.stringify(TIPS, null, 2));
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
console.log('');
|
|
149
|
+
console.log(' ' + banner(AGENTGUARD_SPEND_VERSION));
|
|
150
|
+
console.log('');
|
|
151
|
+
for (const tip of TIPS) {
|
|
152
|
+
const label = tip.severity === 'warning' ? 'warning' : 'tip';
|
|
153
|
+
console.log(` ${green(label)} ${tip.text}`);
|
|
154
|
+
console.log(` ${dim(tip.learn_more_url)}`);
|
|
155
|
+
}
|
|
156
|
+
console.log('');
|
|
157
|
+
console.log(' ' + statusBar());
|
|
158
|
+
console.log('');
|
|
159
|
+
markTipsSeen(TIPS.filter((tip) => tip.dismissible).map((tip) => tip.id));
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as readline from 'readline';
|
|
4
|
+
import { AGENTGUARD_SPEND_VERSION } from '../index';
|
|
5
|
+
import { TASK_TEMPLATES, getTaskTemplate, listTaskTemplates, type TaskTemplate } from '../templates';
|
|
6
|
+
import { agentguardHome, banner, cyanBold, green, statusBar } from './colors';
|
|
7
|
+
|
|
8
|
+
const BUILD_TYPES = ['chatbot', 'agent', 'batch job', 'API endpoint'];
|
|
9
|
+
const CAPABILITY_TIERS = [
|
|
10
|
+
['read_only', 'Read records and produce findings'],
|
|
11
|
+
['data_write', 'Write allowed fields after policy checks'],
|
|
12
|
+
['payment_initiate', 'Create or recommend payment intents'],
|
|
13
|
+
['payment_execute', 'Execute money movement only with verified attestation'],
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
export interface WizardAnswers {
|
|
17
|
+
building: string;
|
|
18
|
+
template: string;
|
|
19
|
+
capability: string;
|
|
20
|
+
allowedModels: string[];
|
|
21
|
+
fallbackModel: string;
|
|
22
|
+
perCallCents: number;
|
|
23
|
+
perDayCents: number;
|
|
24
|
+
perMonthCents: number;
|
|
25
|
+
systemInstructions: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class WizardStateMachine {
|
|
29
|
+
step = 0;
|
|
30
|
+
readonly totalSteps = 7;
|
|
31
|
+
|
|
32
|
+
next(answer: string): boolean {
|
|
33
|
+
if (!this.validate(this.step, answer)) return false;
|
|
34
|
+
this.step = Math.min(this.step + 1, this.totalSteps - 1);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
back(): void {
|
|
39
|
+
this.step = Math.max(0, this.step - 1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
validate(step: number, answer: string): boolean {
|
|
43
|
+
if (step === 0) return BUILD_TYPES.includes(answer);
|
|
44
|
+
if (step === 1) return Boolean(TASK_TEMPLATES[answer]);
|
|
45
|
+
if (step === 2) return CAPABILITY_TIERS.some(([tier]) => tier === answer);
|
|
46
|
+
if (step === 3) return answer.split(',').map((item) => item.trim()).filter(Boolean).length > 0;
|
|
47
|
+
if (step === 4) return answer.trim().length > 0;
|
|
48
|
+
if (step === 5) return answer.split(',').every((item) => Number.isSafeInteger(Number(item.trim())) && Number(item.trim()) >= 0);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function runWizard(argv: string[]): Promise<number> {
|
|
54
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
55
|
+
console.log('agentguard wizard [--defaults] [--template <name>]');
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
const templateSlug = valueAfter(argv, '--template') ?? 'risk-review';
|
|
59
|
+
const useDefaults = argv.includes('--defaults') || !process.stdin.isTTY;
|
|
60
|
+
const answers = useDefaults ? defaultAnswers(templateSlug) : await promptAnswers(templateSlug);
|
|
61
|
+
const outputs = writeWizardOutputs(answers);
|
|
62
|
+
console.log('');
|
|
63
|
+
console.log(' ' + banner(AGENTGUARD_SPEND_VERSION));
|
|
64
|
+
console.log('');
|
|
65
|
+
console.log(` ${green('created')} ${outputs.policyPath}`);
|
|
66
|
+
console.log(` ${green('created')} ${outputs.quickstartTsPath}`);
|
|
67
|
+
console.log(` ${green('created')} ${outputs.quickstartPyPath}`);
|
|
68
|
+
console.log('');
|
|
69
|
+
console.log(cyanBold('Quickstart snippet'));
|
|
70
|
+
console.log('');
|
|
71
|
+
console.log(outputs.snippet);
|
|
72
|
+
console.log('');
|
|
73
|
+
console.log(' ' + statusBar());
|
|
74
|
+
console.log('');
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function defaultAnswers(templateSlug = 'risk-review'): WizardAnswers {
|
|
79
|
+
const template = getTaskTemplate(templateSlug) ?? getTaskTemplate('risk-review') as TaskTemplate;
|
|
80
|
+
const perCall = template.caps.find((cap) => cap.window === 'per_call')?.amountCents ?? 100;
|
|
81
|
+
const perDay = template.caps.find((cap) => cap.window === 'per_day')?.amountCents ?? 2500;
|
|
82
|
+
return {
|
|
83
|
+
building: 'agent',
|
|
84
|
+
template: template.slug,
|
|
85
|
+
capability: template.requiredCapability,
|
|
86
|
+
allowedModels: [...template.allowedModels],
|
|
87
|
+
fallbackModel: template.fallbackModel,
|
|
88
|
+
perCallCents: perCall,
|
|
89
|
+
perDayCents: perDay,
|
|
90
|
+
perMonthCents: perDay * 20,
|
|
91
|
+
systemInstructions: template.systemInstructions,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function writeWizardOutputs(answers: WizardAnswers, home = agentguardHome()): { policyPath: string; quickstartTsPath: string; quickstartPyPath: string; snippet: string } {
|
|
96
|
+
fs.mkdirSync(home, { recursive: true });
|
|
97
|
+
const policyPath = path.join(home, 'policy.yaml');
|
|
98
|
+
const quickstartTsPath = path.join(home, 'quickstart.ts');
|
|
99
|
+
const quickstartPyPath = path.join(home, 'quickstart.py');
|
|
100
|
+
const policy = policyYaml(answers);
|
|
101
|
+
const ts = quickstartTs(answers);
|
|
102
|
+
const py = quickstartPy(answers);
|
|
103
|
+
fs.writeFileSync(policyPath, policy);
|
|
104
|
+
fs.writeFileSync(quickstartTsPath, ts);
|
|
105
|
+
fs.writeFileSync(quickstartPyPath, py);
|
|
106
|
+
return { policyPath, quickstartTsPath, quickstartPyPath, snippet: tsSnippet(answers) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function promptAnswers(templateSlug: string): Promise<WizardAnswers> {
|
|
110
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
111
|
+
const ask = (question: string, fallback: string) => new Promise<string>((resolve) => {
|
|
112
|
+
rl.question(`${question} (${fallback}): `, (answer) => resolve(answer.trim() || fallback));
|
|
113
|
+
});
|
|
114
|
+
try {
|
|
115
|
+
const seed = defaultAnswers(templateSlug);
|
|
116
|
+
console.log('');
|
|
117
|
+
console.log(' ' + banner(AGENTGUARD_SPEND_VERSION));
|
|
118
|
+
console.log('');
|
|
119
|
+
const building = await ask(`What are you building? ${BUILD_TYPES.join(', ')}`, seed.building);
|
|
120
|
+
const template = await ask(`Pick a task template: ${listTaskTemplates().map((item) => item.slug).join(', ')}`, seed.template);
|
|
121
|
+
const capability = await ask(`Capability tier: ${CAPABILITY_TIERS.map(([tier, label]) => tier + ' (' + label + ')').join(', ')}`, seed.capability);
|
|
122
|
+
const templateDef = getTaskTemplate(template) ?? getTaskTemplate(seed.template) as TaskTemplate;
|
|
123
|
+
const allowedModels = (await ask('Allowed models, comma separated', templateDef.allowedModels.join(','))).split(',').map((item) => item.trim()).filter(Boolean);
|
|
124
|
+
const fallbackModel = await ask('Fallback on downgrade', templateDef.fallbackModel);
|
|
125
|
+
const caps = await ask('Caps in cents: per-call, per-day, per-month', `${seed.perCallCents},${seed.perDayCents},${seed.perMonthCents}`);
|
|
126
|
+
const [perCallCents, perDayCents, perMonthCents] = caps.split(',').map((item) => Number(item.trim()));
|
|
127
|
+
const systemInstructions = await ask('System instructions', templateDef.systemInstructions);
|
|
128
|
+
return { building, template, capability, allowedModels, fallbackModel, perCallCents, perDayCents, perMonthCents, systemInstructions };
|
|
129
|
+
} finally {
|
|
130
|
+
rl.close();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function policyYaml(answers: WizardAnswers): string {
|
|
135
|
+
const id = `agentguard-${answers.template}-v1`;
|
|
136
|
+
const allowed = answers.allowedModels.map((model) => ` - ${model}`).join('\n');
|
|
137
|
+
return `# AgentGuard Spend policy generated by agentguard wizard\nid: ${id}\nname: ${answers.template} policy\nversion: 1\neffectiveFrom: "${new Date().toISOString()}"\nmode: enforce\nrequiredCapability: ${answers.capability}\nscope:\n tenantId: my-tenant\nmodels:\n allowed:\n${allowed}\n fallback: ${answers.fallbackModel}\ncaps:\n # WHY: Per-call cap bounds one agent action.\n - amountCents: ${answers.perCallCents}\n window: per_call\n action: downgrade\n downgradeTo: ${answers.fallbackModel}\n reason: "Per-call budget reached, routing to fallback model"\n # WHY: Daily cap catches runaway loops and unexpected volume.\n - amountCents: ${answers.perDayCents}\n window: per_day\n action: block\n reason: "Daily budget reached"\n # WHY: Monthly cap keeps the finance view predictable.\n - amountCents: ${answers.perMonthCents}\n window: per_month\n action: block\n reason: "Monthly budget reached"\nsystemInstructions: |\n${indent(answers.systemInstructions, 2)}\n`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function quickstartTs(answers: WizardAnswers): string {
|
|
141
|
+
return `import OpenAI from 'openai';\nimport { withSpendGuard, type SpendPolicy } from '@agentguard-run/spend';\n\nconst policy: SpendPolicy = {\n id: 'agentguard-${answers.template}-v1',\n name: '${answers.template} policy',\n scope: { tenantId: 'my-tenant' },\n caps: [\n { amountCents: ${answers.perCallCents}, window: 'per_call', action: 'downgrade', downgradeTo: '${answers.fallbackModel}' },\n { amountCents: ${answers.perDayCents}, window: 'per_day', action: 'block' },\n { amountCents: ${answers.perMonthCents}, window: 'per_month', action: 'block' },\n ],\n mode: 'enforce',\n requiredCapability: '${answers.capability}',\n version: 1,\n effectiveFrom: new Date().toISOString(),\n};\n\nconst openrouter = new OpenAI({\n baseURL: 'https://openrouter.ai/api/v1',\n apiKey: process.env.OPENROUTER_API_KEY,\n});\n\nexport const guardedClient = withSpendGuard(openrouter, {\n policy,\n scope: { tenantId: 'my-tenant', agentId: '${answers.template}' },\n capabilityClaim: '${answers.capability}',\n});\n`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function quickstartPy(answers: WizardAnswers): string {
|
|
145
|
+
return `from openai import OpenAI\nfrom agentguard_spend import with_spend_guard\nfrom agentguard_spend.types import SpendPolicy, SpendCap\n\npolicy = SpendPolicy(\n id='agentguard-${answers.template}-v1', name='${answers.template} policy',\n scope={'tenantId': 'my-tenant'},\n caps=[\n SpendCap(amountCents=${answers.perCallCents}, window='per_call', action='downgrade', downgradeTo='${answers.fallbackModel}'),\n SpendCap(amountCents=${answers.perDayCents}, window='per_day', action='block'),\n SpendCap(amountCents=${answers.perMonthCents}, window='per_month', action='block'),\n ],\n mode='enforce', requiredCapability='${answers.capability}',\n version=1, effectiveFrom='2026-05-27T00:00:00Z',\n)\n\nopenrouter = OpenAI(base_url='https://openrouter.ai/api/v1')\nguarded_client = with_spend_guard(openrouter, policy=policy, scope={'tenantId': 'my-tenant', 'agentId': '${answers.template}'}, capability_claim='${answers.capability}')\n`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function tsSnippet(answers: WizardAnswers): string {
|
|
149
|
+
return `const response = await guardedClient.chat.completions.create({\n model: '${answers.allowedModels[0] ?? 'openai/gpt-4o-mini'}',\n messages: [{ role: 'user', content: 'Run the governed task.' }],\n});`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function indent(value: string, spaces: number): string {
|
|
153
|
+
const prefix = ' '.repeat(spaces);
|
|
154
|
+
return value.split('\n').map((line) => prefix + line).join('\n');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function valueAfter(argv: string[], flag: string): string | undefined {
|
|
158
|
+
const index = argv.indexOf(flag);
|
|
159
|
+
return index >= 0 ? argv[index + 1] : undefined;
|
|
160
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentGuard(TM) Spend: local Coach anomaly review skeleton.
|
|
3
|
+
*
|
|
4
|
+
* Reads local decision logs only. No network calls are made.
|
|
5
|
+
*
|
|
6
|
+
* Patent notice: Protected by U.S. patent-pending technology
|
|
7
|
+
* (App. Nos. 63/983,615; 63/983,621; 63/983,843; 63/984,626;
|
|
8
|
+
* 64/071,781; 64/071,789).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import { agentguardHome } from './output';
|
|
14
|
+
|
|
15
|
+
export interface CoachSpendPoint {
|
|
16
|
+
ts: string;
|
|
17
|
+
scope: string;
|
|
18
|
+
agentId: string;
|
|
19
|
+
cents: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CoachAnomaly {
|
|
23
|
+
scope: string;
|
|
24
|
+
agentId: string;
|
|
25
|
+
last24hCents: number;
|
|
26
|
+
baselineCents: number;
|
|
27
|
+
sigmaCents: number;
|
|
28
|
+
suggestion: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readDecisionSpend(scope = 'default', home = agentguardHome()): CoachSpendPoint[] {
|
|
32
|
+
const file = path.join(home, scope, 'decisions.ndjson');
|
|
33
|
+
if (!fs.existsSync(file)) return [];
|
|
34
|
+
const points: CoachSpendPoint[] = [];
|
|
35
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
36
|
+
if (!line.trim()) continue;
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(line);
|
|
39
|
+
const decision = parsed.decision ?? parsed;
|
|
40
|
+
const cents = Number(decision.actualCents ?? decision.projectedCents ?? 0);
|
|
41
|
+
const ts = String(decision.timestamp ?? parsed.timestamp ?? new Date(0).toISOString());
|
|
42
|
+
const scopeObj = decision.scope ?? {};
|
|
43
|
+
points.push({
|
|
44
|
+
ts,
|
|
45
|
+
scope: String(scopeObj.tenantId ?? decision.triggeredScopeKey ?? scope),
|
|
46
|
+
agentId: String(scopeObj.agentId ?? decision.agentId ?? 'unknown'),
|
|
47
|
+
cents: Number.isFinite(cents) ? cents : 0,
|
|
48
|
+
});
|
|
49
|
+
} catch {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return points;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function reviewAnomalies(points: CoachSpendPoint[], now = new Date()): CoachAnomaly[] {
|
|
57
|
+
const cutoff = now.getTime() - 24 * 60 * 60 * 1000;
|
|
58
|
+
const byAgent = new Map<string, CoachSpendPoint[]>();
|
|
59
|
+
for (const point of points) {
|
|
60
|
+
const key = `${point.scope}\t${point.agentId}`;
|
|
61
|
+
byAgent.set(key, [...(byAgent.get(key) ?? []), point]);
|
|
62
|
+
}
|
|
63
|
+
const anomalies: CoachAnomaly[] = [];
|
|
64
|
+
for (const [key, rows] of byAgent) {
|
|
65
|
+
const [scope, agentId] = key.split('\t');
|
|
66
|
+
const daily = bucketDaily(rows);
|
|
67
|
+
const historical = daily.filter((row) => row.day < dayKey(new Date(cutoff))).map((row) => row.cents);
|
|
68
|
+
const recent = rows.filter((row) => Date.parse(row.ts) >= cutoff).reduce((sum, row) => sum + row.cents, 0);
|
|
69
|
+
if (historical.length < 3 || recent <= 0) continue;
|
|
70
|
+
const mean = historical.reduce((sum, value) => sum + value, 0) / historical.length;
|
|
71
|
+
const variance = historical.reduce((sum, value) => sum + Math.pow(value - mean, 2), 0) / historical.length;
|
|
72
|
+
const sigma = Math.sqrt(variance);
|
|
73
|
+
if (recent > mean + 3 * sigma) {
|
|
74
|
+
anomalies.push({
|
|
75
|
+
scope: scope ?? 'unknown',
|
|
76
|
+
agentId: agentId ?? 'unknown',
|
|
77
|
+
last24hCents: recent,
|
|
78
|
+
baselineCents: Math.round(mean),
|
|
79
|
+
sigmaCents: Math.round(sigma),
|
|
80
|
+
suggestion: 'Lower the per_day cap or add a per_minute block cap for this agent.',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return anomalies.sort((a, b) => b.last24hCents - a.last24hCents);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function bucketDaily(points: CoachSpendPoint[]): Array<{ day: string; cents: number }> {
|
|
88
|
+
const buckets = new Map<string, number>();
|
|
89
|
+
for (const point of points) {
|
|
90
|
+
const day = dayKey(new Date(point.ts));
|
|
91
|
+
buckets.set(day, (buckets.get(day) ?? 0) + point.cents);
|
|
92
|
+
}
|
|
93
|
+
return [...buckets.entries()].map(([day, cents]) => ({ day, cents })).sort((a, b) => a.day.localeCompare(b.day));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function dayKey(date: Date): string {
|
|
97
|
+
return date.toISOString().slice(0, 10);
|
|
98
|
+
}
|