@agentguard-run/spend 0.3.0 → 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 +5 -0
- package/README.es-419.md +11 -0
- package/README.md +11 -0
- package/README.pt-BR.md +11 -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/main.d.ts.map +1 -1
- package/dist/cli/main.js +6 -0
- package/dist/cli/main.js.map +1 -1
- 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/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -2
- package/dist/index.js.map +1 -1
- package/dist/telemetry.js +1 -1
- package/package.json +9 -2
- package/src/cli/coach.ts +249 -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/cli/coach.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentguard coach`: local LLM-driven policy setup.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as readline from 'readline';
|
|
6
|
+
import { AGENTGUARD_SPEND_VERSION } from '../index';
|
|
7
|
+
import { CoachConversation, formatCents } from '../coach/conversation';
|
|
8
|
+
import { forecastMonthEnd } from '../coach/forecast';
|
|
9
|
+
import { createCoachClient, resolveCoachApiKey, type CoachChatMessage, type CoachProvider } from '../coach/llm-client';
|
|
10
|
+
import { readDecisionSpend, reviewAnomalies } from '../coach/anomaly';
|
|
11
|
+
import { MASTER_SYSTEM_PROMPT } from '../coach/system-prompt';
|
|
12
|
+
import { createCoachSessionLogger, writeCoachOutputs } from '../coach/output';
|
|
13
|
+
import { banner, cyanBold, dim, green, yellow } from './colors';
|
|
14
|
+
|
|
15
|
+
interface CoachCliOptions {
|
|
16
|
+
provider: CoachProvider;
|
|
17
|
+
baseUrl?: string;
|
|
18
|
+
model?: string;
|
|
19
|
+
language?: 'ts' | 'py';
|
|
20
|
+
defaults: boolean;
|
|
21
|
+
yes: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const HELP = `agentguard coach: local LLM-driven policy setup
|
|
25
|
+
|
|
26
|
+
usage:
|
|
27
|
+
agentguard coach [--provider openrouter|openai|anthropic|compatible] [--base-url <url>] [--model <model>]
|
|
28
|
+
agentguard coach review [--scope <name>]
|
|
29
|
+
agentguard coach forecast [--scope <name>] [--cap-cents <cents>]
|
|
30
|
+
|
|
31
|
+
examples:
|
|
32
|
+
agentguard auth openrouter
|
|
33
|
+
agentguard coach
|
|
34
|
+
agentguard coach --base-url https://api.deepseek.com/v1 --model deepseek-chat
|
|
35
|
+
agentguard coach review
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
export async function runCoach(argv: string[]): Promise<number> {
|
|
39
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
40
|
+
console.log(HELP);
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
if (argv[0] === 'review') return runCoachReview(argv.slice(1));
|
|
44
|
+
if (argv[0] === 'forecast') return runCoachForecast(argv.slice(1));
|
|
45
|
+
|
|
46
|
+
const options = parseOptions(argv);
|
|
47
|
+
const apiKey = resolveCoachApiKey(options.provider);
|
|
48
|
+
if (options.provider !== 'mock' && !apiKey) {
|
|
49
|
+
console.log('');
|
|
50
|
+
console.log(' ' + banner(AGENTGUARD_SPEND_VERSION));
|
|
51
|
+
console.log('');
|
|
52
|
+
console.log(` ${yellow('agentguard coach needs a local provider key')}`);
|
|
53
|
+
console.log(' Run agentguard auth openrouter, set OPENROUTER_API_KEY, or pass --base-url with AGENTGUARD_COACH_API_KEY.');
|
|
54
|
+
console.log(' Prompts and policy details stay in your terminal and go only to your chosen provider.');
|
|
55
|
+
console.log('');
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let cancelled = false;
|
|
60
|
+
const logger = createCoachSessionLogger();
|
|
61
|
+
const onSigint = () => {
|
|
62
|
+
cancelled = true;
|
|
63
|
+
logger.append('cancelled', { reason: 'sigint' });
|
|
64
|
+
process.stdout.write('\ncoach cancelled. No policy files were written.\n');
|
|
65
|
+
};
|
|
66
|
+
process.once('SIGINT', onSigint);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const conversation = new CoachConversation(options.language ? { language: options.language } : undefined);
|
|
70
|
+
const history: CoachChatMessage[] = [{ role: 'system', content: MASTER_SYSTEM_PROMPT }];
|
|
71
|
+
const client = createCoachClient({ provider: options.provider, baseUrl: options.baseUrl, model: options.model, apiKey: apiKey ?? undefined });
|
|
72
|
+
|
|
73
|
+
console.log('');
|
|
74
|
+
console.log(' ' + banner(AGENTGUARD_SPEND_VERSION));
|
|
75
|
+
console.log('');
|
|
76
|
+
console.log(cyanBold('AgentGuard Coach'));
|
|
77
|
+
console.log(dim('Local LLM setup. No AgentGuard service receives prompts, completions, keys, or policies.'));
|
|
78
|
+
console.log('');
|
|
79
|
+
|
|
80
|
+
if (options.defaults || !process.stdin.isTTY) {
|
|
81
|
+
fillDefaults(conversation);
|
|
82
|
+
logger.append('answers_defaulted', { answers: conversation.snapshot() });
|
|
83
|
+
} else {
|
|
84
|
+
await runInteractiveQuestions(conversation, logger, history, client, () => cancelled);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (cancelled) return 130;
|
|
88
|
+
if (!conversation.isComplete()) {
|
|
89
|
+
console.log(yellow('coach did not collect enough answers to write policy files.'));
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const profile = conversation.profile(process.cwd());
|
|
94
|
+
if (options.language) profile.language = options.language;
|
|
95
|
+
logger.append('profile_built', { profile });
|
|
96
|
+
|
|
97
|
+
console.log('');
|
|
98
|
+
await streamCoachSummary(client, history, profileSummaryPrompt(profile), logger, () => cancelled);
|
|
99
|
+
if (cancelled) return 130;
|
|
100
|
+
|
|
101
|
+
const outputs = writeCoachOutputs(profile, { language: profile.language, overwrite: options.yes || options.defaults });
|
|
102
|
+
logger.append('files_written', { policyPath: outputs.policyPath, quickstartPath: outputs.quickstartPath, sessionLogPath: logger.path });
|
|
103
|
+
|
|
104
|
+
console.log('');
|
|
105
|
+
console.log(outputs.savingsTable);
|
|
106
|
+
console.log('');
|
|
107
|
+
console.log(`${green('created')} ${outputs.policyPath}`);
|
|
108
|
+
console.log(`${green('created')} ${outputs.quickstartPath}`);
|
|
109
|
+
console.log(`${green('session')} ${logger.path}`);
|
|
110
|
+
console.log('');
|
|
111
|
+
console.log('Next: agentguard demo --policy ~/.agentguard/policy.yaml');
|
|
112
|
+
console.log('Verify receipts: https://agentguard.run/verify');
|
|
113
|
+
console.log('');
|
|
114
|
+
return 0;
|
|
115
|
+
} finally {
|
|
116
|
+
process.removeListener('SIGINT', onSigint);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function runCoachReview(argv: string[]): Promise<number> {
|
|
121
|
+
const scope = valueAfter(argv, '--scope') ?? 'default';
|
|
122
|
+
const anomalies = reviewAnomalies(readDecisionSpend(scope));
|
|
123
|
+
console.log('');
|
|
124
|
+
console.log(' ' + banner(AGENTGUARD_SPEND_VERSION));
|
|
125
|
+
console.log('');
|
|
126
|
+
if (anomalies.length === 0) {
|
|
127
|
+
console.log('No local spend anomalies found for the last 24 hours.');
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
for (const item of anomalies) {
|
|
131
|
+
console.log(`${item.scope}/${item.agentId}: ${formatCents(item.last24hCents)} in last 24h. Baseline ${formatCents(item.baselineCents)}, sigma ${formatCents(item.sigmaCents)}.`);
|
|
132
|
+
console.log(`Suggestion: ${item.suggestion}`);
|
|
133
|
+
}
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function runCoachForecast(argv: string[]): Promise<number> {
|
|
138
|
+
const scope = valueAfter(argv, '--scope') ?? 'default';
|
|
139
|
+
const cap = valueAfter(argv, '--cap-cents');
|
|
140
|
+
const forecast = forecastMonthEnd(readDecisionSpend(scope), cap ? Number(cap) : null);
|
|
141
|
+
console.log('');
|
|
142
|
+
console.log(' ' + banner(AGENTGUARD_SPEND_VERSION));
|
|
143
|
+
console.log('');
|
|
144
|
+
console.log(`Days observed: ${forecast.daysObserved}`);
|
|
145
|
+
console.log(`Projected month-end: ${formatCents(forecast.monthEndCents)}`);
|
|
146
|
+
if (forecast.capCents !== null) console.log(`Cap: ${formatCents(forecast.capCents)}`);
|
|
147
|
+
console.log(forecast.message);
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function runInteractiveQuestions(
|
|
152
|
+
conversation: CoachConversation,
|
|
153
|
+
logger: ReturnType<typeof createCoachSessionLogger>,
|
|
154
|
+
history: CoachChatMessage[],
|
|
155
|
+
client: ReturnType<typeof createCoachClient>,
|
|
156
|
+
isCancelled: () => boolean,
|
|
157
|
+
): Promise<void> {
|
|
158
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
159
|
+
const ask = (prompt: string) => new Promise<string>((resolve) => rl.question(`${prompt}\n> `, resolve));
|
|
160
|
+
try {
|
|
161
|
+
while (!conversation.isComplete() && !isCancelled()) {
|
|
162
|
+
const question = conversation.currentQuestion();
|
|
163
|
+
if (!question) break;
|
|
164
|
+
const answer = (await ask(question.prompt)).trim();
|
|
165
|
+
if (answer.toLowerCase() === 'back') {
|
|
166
|
+
conversation.back();
|
|
167
|
+
logger.append('back', { to: conversation.currentQuestion()?.id });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (!answer) continue;
|
|
171
|
+
conversation.answer(answer);
|
|
172
|
+
logger.append('answer', { question: question.id, answer });
|
|
173
|
+
history.push({ role: 'user', content: `${question.prompt}\n${answer}` });
|
|
174
|
+
await streamCoachSummary(client, history, coachQuestionPrompt(question.id), logger, isCancelled);
|
|
175
|
+
conversation.next();
|
|
176
|
+
}
|
|
177
|
+
} finally {
|
|
178
|
+
rl.close();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function streamCoachSummary(
|
|
183
|
+
client: ReturnType<typeof createCoachClient>,
|
|
184
|
+
history: CoachChatMessage[],
|
|
185
|
+
prompt: string,
|
|
186
|
+
logger: ReturnType<typeof createCoachSessionLogger>,
|
|
187
|
+
isCancelled: () => boolean,
|
|
188
|
+
): Promise<void> {
|
|
189
|
+
history.push({ role: 'user', content: prompt });
|
|
190
|
+
let content = '';
|
|
191
|
+
process.stdout.write('\n');
|
|
192
|
+
try {
|
|
193
|
+
for await (const token of client.streamChat(history)) {
|
|
194
|
+
if (isCancelled()) break;
|
|
195
|
+
content += token;
|
|
196
|
+
process.stdout.write(token);
|
|
197
|
+
}
|
|
198
|
+
process.stdout.write('\n');
|
|
199
|
+
} catch (err) {
|
|
200
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
201
|
+
content = `Coach provider unavailable: ${message}`;
|
|
202
|
+
console.log(content);
|
|
203
|
+
}
|
|
204
|
+
history.push({ role: 'assistant', content });
|
|
205
|
+
logger.append('assistant', { content });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function fillDefaults(conversation: CoachConversation): void {
|
|
209
|
+
const answers = [
|
|
210
|
+
'A customer support agent for an ecommerce business',
|
|
211
|
+
'Team of 5, around 2000 support tickets and orders per month',
|
|
212
|
+
'triage refunds, draft support replies, assemble chargeback evidence',
|
|
213
|
+
'$199 monthly budget with low per-call costs',
|
|
214
|
+
'Confirmed',
|
|
215
|
+
];
|
|
216
|
+
for (const answer of answers) {
|
|
217
|
+
if (!conversation.currentQuestion()) break;
|
|
218
|
+
conversation.answer(answer);
|
|
219
|
+
conversation.next();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function coachQuestionPrompt(id: string): string {
|
|
224
|
+
return `Acknowledge the ${id} answer in 2 short sentences. Ask only the next unanswered setup question. Do not write files yet.`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function profileSummaryPrompt(profile: ReturnType<CoachConversation['profile']>): string {
|
|
228
|
+
return `Prepare a concise final setup summary for this profile before files are written: ${JSON.stringify(profile)}. Include projected savings math in words. Do not invent model names or pricing.`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function parseOptions(argv: string[]): CoachCliOptions {
|
|
232
|
+
const baseUrl = valueAfter(argv, '--base-url');
|
|
233
|
+
const providerFlag = valueAfter(argv, '--provider') as CoachProvider | undefined;
|
|
234
|
+
const provider = providerFlag ?? (baseUrl ? 'compatible' : 'openrouter');
|
|
235
|
+
const language = valueAfter(argv, '--language') as 'ts' | 'py' | undefined;
|
|
236
|
+
return {
|
|
237
|
+
provider,
|
|
238
|
+
baseUrl,
|
|
239
|
+
model: valueAfter(argv, '--model'),
|
|
240
|
+
language: language === 'py' ? 'py' : language === 'ts' ? 'ts' : undefined,
|
|
241
|
+
defaults: argv.includes('--defaults'),
|
|
242
|
+
yes: argv.includes('--yes') || argv.includes('-y'),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function valueAfter(argv: string[], flag: string): string | undefined {
|
|
247
|
+
const index = argv.indexOf(flag);
|
|
248
|
+
return index >= 0 ? argv[index + 1] : undefined;
|
|
249
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentGuard(TM) Spend: Coach conversation state.
|
|
3
|
+
*
|
|
4
|
+
* Patent notice: Protected by U.S. patent-pending technology
|
|
5
|
+
* (App. Nos. 63/983,615; 63/983,621; 63/983,843; 63/984,626;
|
|
6
|
+
* 64/071,781; 64/071,789).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import type { CapabilityTier, SpendCap, SpendPolicy } from '../types';
|
|
12
|
+
|
|
13
|
+
export type CoachQuestionId = 'building' | 'scale' | 'tasks' | 'budget' | 'confirm';
|
|
14
|
+
|
|
15
|
+
export interface CoachQuestion {
|
|
16
|
+
id: CoachQuestionId;
|
|
17
|
+
prompt: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CoachAnswers {
|
|
21
|
+
building?: string;
|
|
22
|
+
scale?: string;
|
|
23
|
+
tasks?: string;
|
|
24
|
+
budget?: string;
|
|
25
|
+
confirm?: string;
|
|
26
|
+
language?: 'ts' | 'py';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CoachBusinessProfile {
|
|
30
|
+
vertical: string;
|
|
31
|
+
tenantId: string;
|
|
32
|
+
teamSize: number;
|
|
33
|
+
monthlyVolume: number;
|
|
34
|
+
tasks: string[];
|
|
35
|
+
monthlyBudgetCents: number;
|
|
36
|
+
requiredCapability: CapabilityTier;
|
|
37
|
+
primaryModel: string;
|
|
38
|
+
fallbackModel: string;
|
|
39
|
+
perCallCapCents: number;
|
|
40
|
+
perDayCapCents: number;
|
|
41
|
+
perMonthCapCents: number;
|
|
42
|
+
scopeLabel: string;
|
|
43
|
+
language: 'ts' | 'py';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ProjectedSavingsRow {
|
|
47
|
+
label: string;
|
|
48
|
+
beforeCents: number;
|
|
49
|
+
afterCents: number;
|
|
50
|
+
savingsCents: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ProjectedSavings {
|
|
54
|
+
rows: ProjectedSavingsRow[];
|
|
55
|
+
monthlyBeforeCents: number;
|
|
56
|
+
monthlyAfterCents: number;
|
|
57
|
+
monthlySavingsCents: number;
|
|
58
|
+
savingsPercent: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const COACH_QUESTIONS: CoachQuestion[] = [
|
|
62
|
+
{ id: 'building', prompt: 'What are you building or running? Include your business type and the agent workflow.' },
|
|
63
|
+
{ id: 'scale', prompt: 'How big is the team and roughly how many AI calls, tickets, orders, encounters, matters, or jobs happen per month?' },
|
|
64
|
+
{ id: 'tasks', prompt: 'What are the top 3 AI tasks this agent will perform?' },
|
|
65
|
+
{ id: 'budget', prompt: 'What monthly AI budget or per-task ceiling should this stay under?' },
|
|
66
|
+
{ id: 'confirm', prompt: 'Confirm the setup or list any refinements before I write policy.yaml and quickstart code.' },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const VERTICAL_HINTS: Array<{ vertical: string; needles: string[]; scopeLabel: string; capability: CapabilityTier; primary: string; fallback: string; perCall: number }> = [
|
|
70
|
+
{ vertical: 'law-firm', needles: ['law', 'legal', 'attorney', 'paralegal', 'contract', 'matter', 'discovery'], scopeLabel: 'matter', capability: 'data_write', primary: 'anthropic/claude-sonnet-4-6', fallback: 'anthropic/claude-haiku-4-5', perCall: 300 },
|
|
71
|
+
{ vertical: 'healthcare', needles: ['health', 'medical', 'dental', 'clinic', 'patient', 'chart', 'hipaa', 'encounter'], scopeLabel: 'encounter', capability: 'data_write', primary: 'anthropic.claude-sonnet-4-v1:0', fallback: 'amazon.nova-lite-v1:0', perCall: 300 },
|
|
72
|
+
{ vertical: 'ecommerce', needles: ['shopify', 'commerce', 'store', 'order', 'refund', 'chargeback', 'fraud', 'returns'], scopeLabel: 'order', capability: 'payment_initiate', primary: 'openai/gpt-5-mini', fallback: 'openai/gpt-4o-mini', perCall: 25 },
|
|
73
|
+
{ vertical: 'accounting', needles: ['accounting', 'bookkeeping', 'tax', 'ledger', 'receipt', 'close', 'audit', 'sox'], scopeLabel: 'engagement', capability: 'data_write', primary: 'anthropic/claude-sonnet-4-6', fallback: 'anthropic/claude-haiku-4-5', perCall: 150 },
|
|
74
|
+
{ vertical: 'software', needles: ['software', 'code', 'developer', 'github', 'repo', 'pull request', 'pr'], scopeLabel: 'repo', capability: 'read_only', primary: 'google/gemini-3-flash-preview', fallback: 'openai/gpt-4o-mini', perCall: 10 },
|
|
75
|
+
{ vertical: 'real-estate', needles: ['real estate', 'broker', 'listing', 'mortgage', 'tenant', 'lease', 'property'], scopeLabel: 'transaction', capability: 'data_write', primary: 'openai/gpt-5-mini', fallback: 'openai/gpt-4o-mini', perCall: 50 },
|
|
76
|
+
{ vertical: 'marketing', needles: ['marketing', 'agency', 'content', 'campaign', 'brand'], scopeLabel: 'campaign', capability: 'data_write', primary: 'openai/gpt-5-mini', fallback: 'openai/gpt-4o-mini', perCall: 25 },
|
|
77
|
+
{ vertical: 'local-services', needles: ['salon', 'gym', 'restaurant', 'landscaping', 'construction', 'photography', 'fitness', 'pet'], scopeLabel: 'job', capability: 'data_write', primary: 'openai/gpt-5-mini', fallback: 'openai/gpt-4o-mini', perCall: 25 },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
export class CoachConversation {
|
|
81
|
+
private answers: CoachAnswers = {};
|
|
82
|
+
private index = 0;
|
|
83
|
+
|
|
84
|
+
constructor(initial?: CoachAnswers) {
|
|
85
|
+
if (initial) this.answers = { ...initial };
|
|
86
|
+
this.index = firstMissingIndex(this.answers);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
currentQuestion(): CoachQuestion | null {
|
|
90
|
+
return COACH_QUESTIONS[this.index] ?? null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
answer(value: string): void {
|
|
94
|
+
const question = this.currentQuestion();
|
|
95
|
+
if (!question) return;
|
|
96
|
+
this.answers[question.id] = value.trim();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
next(): CoachQuestion | null {
|
|
100
|
+
this.index = Math.min(this.index + 1, COACH_QUESTIONS.length);
|
|
101
|
+
return this.currentQuestion();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
back(): CoachQuestion | null {
|
|
105
|
+
this.index = Math.max(0, this.index - 1);
|
|
106
|
+
return this.currentQuestion();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
isComplete(): boolean {
|
|
110
|
+
return COACH_QUESTIONS.every((question) => Boolean(this.answers[question.id]?.trim()));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
snapshot(): CoachAnswers {
|
|
114
|
+
return { ...this.answers };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
profile(cwd = process.cwd()): CoachBusinessProfile {
|
|
118
|
+
return buildBusinessProfile(this.answers, cwd);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function buildBusinessProfile(answers: CoachAnswers, cwd = process.cwd()): CoachBusinessProfile {
|
|
123
|
+
const joined = `${answers.building ?? ''} ${answers.scale ?? ''} ${answers.tasks ?? ''}`.toLowerCase();
|
|
124
|
+
const hint = VERTICAL_HINTS.find((candidate) => candidate.needles.some((needle) => joined.includes(needle))) ?? VERTICAL_HINTS[0]!;
|
|
125
|
+
const scale = parseScale(answers.scale ?? '');
|
|
126
|
+
const budgetCents = parseBudgetCents(answers.budget ?? '', Math.max(4900, Math.ceil(scale.monthlyVolume * hint.perCall * 0.35)));
|
|
127
|
+
const tasks = parseTasks(answers.tasks ?? answers.building ?? hint.vertical);
|
|
128
|
+
const perMonthCapCents = Math.max(1000, budgetCents);
|
|
129
|
+
const perDayCapCents = Math.max(hint.perCall * 10, Math.ceil(perMonthCapCents / 20));
|
|
130
|
+
return {
|
|
131
|
+
vertical: hint.vertical,
|
|
132
|
+
tenantId: tenantIdFromBusiness(answers.building ?? hint.vertical),
|
|
133
|
+
teamSize: scale.teamSize,
|
|
134
|
+
monthlyVolume: scale.monthlyVolume,
|
|
135
|
+
tasks,
|
|
136
|
+
monthlyBudgetCents: perMonthCapCents,
|
|
137
|
+
requiredCapability: capabilityFor(joined, hint.capability),
|
|
138
|
+
primaryModel: hint.primary,
|
|
139
|
+
fallbackModel: hint.fallback,
|
|
140
|
+
perCallCapCents: hint.perCall,
|
|
141
|
+
perDayCapCents,
|
|
142
|
+
perMonthCapCents,
|
|
143
|
+
scopeLabel: hint.scopeLabel,
|
|
144
|
+
language: answers.language ?? detectProjectLanguage(cwd),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function buildPolicyFromProfile(profile: CoachBusinessProfile): SpendPolicy {
|
|
149
|
+
const caps: SpendCap[] = [
|
|
150
|
+
{
|
|
151
|
+
amountCents: profile.perCallCapCents,
|
|
152
|
+
window: 'per_call',
|
|
153
|
+
action: 'downgrade',
|
|
154
|
+
downgradeTo: profile.fallbackModel,
|
|
155
|
+
reason: 'Per-call budget reached, route to fallback model',
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
amountCents: profile.perDayCapCents,
|
|
159
|
+
window: 'per_day',
|
|
160
|
+
action: 'block',
|
|
161
|
+
reason: 'Daily budget reached',
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
amountCents: profile.perMonthCapCents,
|
|
165
|
+
window: 'per_month',
|
|
166
|
+
action: 'block',
|
|
167
|
+
reason: 'Monthly budget reached',
|
|
168
|
+
},
|
|
169
|
+
];
|
|
170
|
+
return {
|
|
171
|
+
id: `coach-${profile.vertical}-v1`,
|
|
172
|
+
name: `Coach generated ${profile.vertical} policy`,
|
|
173
|
+
scope: { tenantId: profile.tenantId },
|
|
174
|
+
caps,
|
|
175
|
+
mode: 'enforce',
|
|
176
|
+
requiredCapability: profile.requiredCapability,
|
|
177
|
+
version: 1,
|
|
178
|
+
effectiveFrom: new Date().toISOString(),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function projectedSavings(profile: CoachBusinessProfile): ProjectedSavings {
|
|
183
|
+
const heavyPerCall = Math.max(profile.perCallCapCents * 4, 100);
|
|
184
|
+
const routedPerCall = Math.max(1, Math.ceil(profile.perCallCapCents * 0.45));
|
|
185
|
+
const monthlyBeforeCents = profile.monthlyVolume * heavyPerCall;
|
|
186
|
+
const monthlyAfterCents = Math.min(profile.perMonthCapCents, profile.monthlyVolume * routedPerCall);
|
|
187
|
+
const monthlySavingsCents = Math.max(0, monthlyBeforeCents - monthlyAfterCents);
|
|
188
|
+
return {
|
|
189
|
+
rows: [
|
|
190
|
+
{ label: 'All traffic on premium model', beforeCents: monthlyBeforeCents, afterCents: 0, savingsCents: 0 },
|
|
191
|
+
{ label: 'Task routed with AgentGuard caps', beforeCents: 0, afterCents: monthlyAfterCents, savingsCents: monthlySavingsCents },
|
|
192
|
+
],
|
|
193
|
+
monthlyBeforeCents,
|
|
194
|
+
monthlyAfterCents,
|
|
195
|
+
monthlySavingsCents,
|
|
196
|
+
savingsPercent: monthlyBeforeCents > 0 ? Math.round((monthlySavingsCents / monthlyBeforeCents) * 100) : 0,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function formatCents(cents: number): string {
|
|
201
|
+
return `$${(cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function detectProjectLanguage(cwd: string): 'ts' | 'py' {
|
|
205
|
+
try {
|
|
206
|
+
if (fs.existsSync(path.join(cwd, 'pyproject.toml')) || fs.existsSync(path.join(cwd, 'requirements.txt'))) return 'py';
|
|
207
|
+
if (fs.existsSync(path.join(cwd, 'package.json'))) return 'ts';
|
|
208
|
+
} catch {
|
|
209
|
+
return 'ts';
|
|
210
|
+
}
|
|
211
|
+
return 'ts';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function firstMissingIndex(answers: CoachAnswers): number {
|
|
215
|
+
const index = COACH_QUESTIONS.findIndex((question) => !answers[question.id]?.trim());
|
|
216
|
+
return index === -1 ? COACH_QUESTIONS.length : index;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function parseScale(value: string): { teamSize: number; monthlyVolume: number } {
|
|
220
|
+
const numbers = (value.match(/\d[\d,]*/g) ?? []).map((part) => Number(part.replace(/,/g, ''))).filter(Number.isFinite);
|
|
221
|
+
const teamSize = Math.max(1, numbers[0] ?? 3);
|
|
222
|
+
const monthlyVolume = Math.max(25, numbers[1] ?? numbers[0] ?? 500);
|
|
223
|
+
return { teamSize, monthlyVolume };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parseBudgetCents(value: string, fallback: number): number {
|
|
227
|
+
const money = value.match(/\$?\s*(\d[\d,]*(?:\.\d{1,2})?)/);
|
|
228
|
+
if (!money) return fallback;
|
|
229
|
+
const amount = Number(money[1]!.replace(/,/g, ''));
|
|
230
|
+
if (!Number.isFinite(amount) || amount <= 0) return fallback;
|
|
231
|
+
return Math.round(amount * 100);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function parseTasks(value: string): string[] {
|
|
235
|
+
const parts = value.split(/,|\n|;| and /i).map((part) => part.trim()).filter(Boolean);
|
|
236
|
+
return parts.length > 0 ? parts.slice(0, 3) : ['review requests', 'route models', 'write audit receipts'];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function tenantIdFromBusiness(value: string): string {
|
|
240
|
+
const cleaned = value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
|
|
241
|
+
return cleaned || 'local-business';
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function capabilityFor(text: string, fallback: CapabilityTier): CapabilityTier {
|
|
245
|
+
if (/refund|payment|charge|dispute|money|invoice/.test(text)) return 'payment_initiate';
|
|
246
|
+
if (/write|update|ledger|chart|patient|health|phi|sox|legal|contract|tax|student|employment/.test(text)) return 'data_write';
|
|
247
|
+
return fallback;
|
|
248
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentGuard(TM) Spend: local Coach forecast 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 type { CoachSpendPoint } from './anomaly';
|
|
12
|
+
|
|
13
|
+
export interface CoachForecast {
|
|
14
|
+
daysObserved: number;
|
|
15
|
+
monthEndCents: number;
|
|
16
|
+
capCents: number | null;
|
|
17
|
+
overCap: boolean;
|
|
18
|
+
message: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function forecastMonthEnd(points: CoachSpendPoint[], capCents: number | null = null, now = new Date()): CoachForecast {
|
|
22
|
+
const daily = lastThirtyDaily(points, now);
|
|
23
|
+
if (daily.length === 0) {
|
|
24
|
+
return { daysObserved: 0, monthEndCents: 0, capCents, overCap: false, message: 'No local spend history found.' };
|
|
25
|
+
}
|
|
26
|
+
const slope = linearSlope(daily.map((row, index) => ({ x: index + 1, y: row.cents })));
|
|
27
|
+
const monthDays = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
28
|
+
const elapsed = now.getDate();
|
|
29
|
+
const observedTotal = daily.reduce((sum, row) => sum + row.cents, 0);
|
|
30
|
+
const avg = observedTotal / Math.max(1, daily.length);
|
|
31
|
+
const projectedRemaining = Math.max(0, monthDays - elapsed) * Math.max(0, avg + slope);
|
|
32
|
+
const monthEndCents = Math.round(observedTotal + projectedRemaining);
|
|
33
|
+
const overCap = capCents !== null && monthEndCents > capCents;
|
|
34
|
+
return {
|
|
35
|
+
daysObserved: daily.length,
|
|
36
|
+
monthEndCents,
|
|
37
|
+
capCents,
|
|
38
|
+
overCap,
|
|
39
|
+
message: overCap ? 'Projected spend is above cap. Consider a lower fallback model or a tighter per_day cap.' : 'Projected spend is within the current cap.',
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function lastThirtyDaily(points: CoachSpendPoint[], now: Date): Array<{ day: string; cents: number }> {
|
|
44
|
+
const cutoff = now.getTime() - 30 * 24 * 60 * 60 * 1000;
|
|
45
|
+
const buckets = new Map<string, number>();
|
|
46
|
+
for (const point of points) {
|
|
47
|
+
const t = Date.parse(point.ts);
|
|
48
|
+
if (!Number.isFinite(t) || t < cutoff) continue;
|
|
49
|
+
const day = new Date(t).toISOString().slice(0, 10);
|
|
50
|
+
buckets.set(day, (buckets.get(day) ?? 0) + point.cents);
|
|
51
|
+
}
|
|
52
|
+
return [...buckets.entries()].map(([day, cents]) => ({ day, cents })).sort((a, b) => a.day.localeCompare(b.day));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function linearSlope(points: Array<{ x: number; y: number }>): number {
|
|
56
|
+
if (points.length < 2) return 0;
|
|
57
|
+
const n = points.length;
|
|
58
|
+
const sx = points.reduce((sum, point) => sum + point.x, 0);
|
|
59
|
+
const sy = points.reduce((sum, point) => sum + point.y, 0);
|
|
60
|
+
const sxx = points.reduce((sum, point) => sum + point.x * point.x, 0);
|
|
61
|
+
const sxy = points.reduce((sum, point) => sum + point.x * point.y, 0);
|
|
62
|
+
const denom = n * sxx - sx * sx;
|
|
63
|
+
return denom === 0 ? 0 : (n * sxy - sx * sy) / denom;
|
|
64
|
+
}
|