auggy 0.3.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 +96 -0
- package/LICENSE +201 -0
- package/README.md +161 -0
- package/package.json +76 -0
- package/src/agent-card.ts +39 -0
- package/src/agent.ts +283 -0
- package/src/agentmail-client.ts +138 -0
- package/src/augments/bash/index.ts +463 -0
- package/src/augments/bash/skill/SKILL.md +156 -0
- package/src/augments/budgets/budget-store.ts +513 -0
- package/src/augments/budgets/index.ts +134 -0
- package/src/augments/budgets/preamble.ts +93 -0
- package/src/augments/budgets/types.ts +89 -0
- package/src/augments/file-memory/index.ts +71 -0
- package/src/augments/filesystem/index.ts +533 -0
- package/src/augments/filesystem/skill/SKILL.md +142 -0
- package/src/augments/filesystem/skill/references/mount-permissions.md +81 -0
- package/src/augments/layered-memory/extractor/buffer.ts +56 -0
- package/src/augments/layered-memory/extractor/frequency.ts +79 -0
- package/src/augments/layered-memory/extractor/inject-handler.ts +103 -0
- package/src/augments/layered-memory/extractor/parse.ts +75 -0
- package/src/augments/layered-memory/extractor/prompt.md +26 -0
- package/src/augments/layered-memory/index.ts +757 -0
- package/src/augments/layered-memory/skill/SKILL.md +153 -0
- package/src/augments/layered-memory/storage/migrations/README.md +16 -0
- package/src/augments/layered-memory/storage/migrations/supabase-add-fact-fields.sql +9 -0
- package/src/augments/layered-memory/storage/sqlite-store.ts +352 -0
- package/src/augments/layered-memory/storage/supabase-store.ts +263 -0
- package/src/augments/layered-memory/storage/types.ts +98 -0
- package/src/augments/link/index.ts +489 -0
- package/src/augments/link/translate.ts +261 -0
- package/src/augments/notify/adapters/agentmail.ts +70 -0
- package/src/augments/notify/adapters/telegram.ts +60 -0
- package/src/augments/notify/adapters/webhook.ts +55 -0
- package/src/augments/notify/index.ts +284 -0
- package/src/augments/notify/skill/SKILL.md +150 -0
- package/src/augments/org-context/index.ts +721 -0
- package/src/augments/org-context/skill/SKILL.md +96 -0
- package/src/augments/skills/index.ts +103 -0
- package/src/augments/supabase-memory/index.ts +151 -0
- package/src/augments/telegram-transport/index.ts +312 -0
- package/src/augments/telegram-transport/polling.ts +55 -0
- package/src/augments/telegram-transport/webhook.ts +56 -0
- package/src/augments/turn-control/index.ts +61 -0
- package/src/augments/turn-control/skill/SKILL.md +155 -0
- package/src/augments/visitor-auth/email-validation.ts +66 -0
- package/src/augments/visitor-auth/index.ts +779 -0
- package/src/augments/visitor-auth/rate-limiter.ts +90 -0
- package/src/augments/visitor-auth/skill/SKILL.md +55 -0
- package/src/augments/visitor-auth/storage/sqlite-store.ts +398 -0
- package/src/augments/visitor-auth/storage/types.ts +164 -0
- package/src/augments/visitor-auth/types.ts +123 -0
- package/src/augments/visitor-auth/verify-page.ts +179 -0
- package/src/augments/web-fetch/index.ts +331 -0
- package/src/augments/web-fetch/skill/SKILL.md +100 -0
- package/src/cli/agent-index.ts +289 -0
- package/src/cli/augment-catalog.ts +320 -0
- package/src/cli/augment-resolver.ts +597 -0
- package/src/cli/commands/add-skill.ts +194 -0
- package/src/cli/commands/add.ts +87 -0
- package/src/cli/commands/chat.ts +207 -0
- package/src/cli/commands/create.ts +462 -0
- package/src/cli/commands/dev.ts +139 -0
- package/src/cli/commands/eval.ts +180 -0
- package/src/cli/commands/ls.ts +66 -0
- package/src/cli/commands/remove.ts +95 -0
- package/src/cli/commands/restart.ts +40 -0
- package/src/cli/commands/start.ts +123 -0
- package/src/cli/commands/status.ts +104 -0
- package/src/cli/commands/stop.ts +84 -0
- package/src/cli/commands/visitors-revoke.ts +155 -0
- package/src/cli/commands/visitors.ts +101 -0
- package/src/cli/config-parser.ts +1034 -0
- package/src/cli/engine-resolver.ts +68 -0
- package/src/cli/index.ts +178 -0
- package/src/cli/model-picker.ts +89 -0
- package/src/cli/pid-registry.ts +146 -0
- package/src/cli/plist-generator.ts +117 -0
- package/src/cli/resolve-config.ts +56 -0
- package/src/cli/scaffold-skills.ts +158 -0
- package/src/cli/scaffold.ts +291 -0
- package/src/cli/skill-frontmatter.ts +51 -0
- package/src/cli/skill-validator.ts +151 -0
- package/src/cli/types.ts +228 -0
- package/src/cli/yaml-helpers.ts +66 -0
- package/src/engines/_shared/cost.ts +55 -0
- package/src/engines/_shared/schema-normalize.ts +75 -0
- package/src/engines/anthropic/pricing.ts +117 -0
- package/src/engines/anthropic.ts +483 -0
- package/src/engines/openai/pricing.ts +67 -0
- package/src/engines/openai.ts +446 -0
- package/src/engines/openrouter/pricing.ts +83 -0
- package/src/engines/openrouter.ts +185 -0
- package/src/helpers.ts +24 -0
- package/src/http.ts +387 -0
- package/src/index.ts +165 -0
- package/src/kernel/capability-table.ts +172 -0
- package/src/kernel/context-allocator.ts +161 -0
- package/src/kernel/history-manager.ts +198 -0
- package/src/kernel/lifecycle-manager.ts +106 -0
- package/src/kernel/output-validator.ts +35 -0
- package/src/kernel/preamble.ts +23 -0
- package/src/kernel/route-collector.ts +97 -0
- package/src/kernel/timeout.ts +21 -0
- package/src/kernel/tool-selector.ts +47 -0
- package/src/kernel/trace-emitter.ts +66 -0
- package/src/kernel/transport-queue.ts +147 -0
- package/src/kernel/turn-loop.ts +1148 -0
- package/src/memory/context-synthesis.ts +83 -0
- package/src/memory/memory-bus.ts +61 -0
- package/src/memory/registry.ts +80 -0
- package/src/memory/tools.ts +320 -0
- package/src/memory/types.ts +8 -0
- package/src/parts.ts +30 -0
- package/src/scaffold-templates/identity.md +31 -0
- package/src/telegram-client.ts +145 -0
- package/src/tokenizer.ts +14 -0
- package/src/transports/ag-ui-events.ts +253 -0
- package/src/transports/visitor-token.ts +82 -0
- package/src/transports/web-transport.ts +948 -0
- package/src/types.ts +1009 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import type { TurnGateTicket, CostResult, TrustLevel } from "../../types";
|
|
3
|
+
import type { BudgetCaps, BudgetStoreConfig } from "./types";
|
|
4
|
+
|
|
5
|
+
// ────────────────────────────────────────────────────────────
|
|
6
|
+
// Schema
|
|
7
|
+
// ────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
const SCHEMA_STATEMENTS = [
|
|
10
|
+
`CREATE TABLE IF NOT EXISTS turn_reservations (
|
|
11
|
+
turn_id TEXT PRIMARY KEY,
|
|
12
|
+
peer_id TEXT NOT NULL,
|
|
13
|
+
thread_id TEXT NOT NULL,
|
|
14
|
+
day TEXT NOT NULL,
|
|
15
|
+
trust_level TEXT NOT NULL,
|
|
16
|
+
public_substate TEXT,
|
|
17
|
+
reserved_at INTEGER NOT NULL,
|
|
18
|
+
committed_at INTEGER,
|
|
19
|
+
cost_usd REAL,
|
|
20
|
+
priced INTEGER NOT NULL DEFAULT 0,
|
|
21
|
+
decision TEXT NOT NULL,
|
|
22
|
+
reason TEXT
|
|
23
|
+
)`,
|
|
24
|
+
`CREATE INDEX IF NOT EXISTS idx_reservations_peer_day
|
|
25
|
+
ON turn_reservations(peer_id, day)`,
|
|
26
|
+
`CREATE INDEX IF NOT EXISTS idx_reservations_thread_day
|
|
27
|
+
ON turn_reservations(thread_id, day)`,
|
|
28
|
+
|
|
29
|
+
`CREATE TABLE IF NOT EXISTS daily_global (
|
|
30
|
+
day TEXT PRIMARY KEY,
|
|
31
|
+
total_cost_usd REAL NOT NULL DEFAULT 0,
|
|
32
|
+
unpriced_turns INTEGER NOT NULL DEFAULT 0,
|
|
33
|
+
updated_at INTEGER NOT NULL
|
|
34
|
+
)`,
|
|
35
|
+
|
|
36
|
+
`CREATE TABLE IF NOT EXISTS peer_daily_costs (
|
|
37
|
+
peer_id TEXT NOT NULL,
|
|
38
|
+
day TEXT NOT NULL,
|
|
39
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
40
|
+
unpriced_turns INTEGER NOT NULL DEFAULT 0,
|
|
41
|
+
updated_at INTEGER NOT NULL,
|
|
42
|
+
PRIMARY KEY (peer_id, day)
|
|
43
|
+
)`,
|
|
44
|
+
|
|
45
|
+
`CREATE TABLE IF NOT EXISTS anonymous_requests (
|
|
46
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
47
|
+
timestamp INTEGER NOT NULL,
|
|
48
|
+
source_hint TEXT
|
|
49
|
+
)`,
|
|
50
|
+
`CREATE INDEX IF NOT EXISTS idx_anon_requests_ts
|
|
51
|
+
ON anonymous_requests(timestamp)`,
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// ────────────────────────────────────────────────────────────
|
|
55
|
+
// Public interface
|
|
56
|
+
// ────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export interface BudgetStore {
|
|
59
|
+
/**
|
|
60
|
+
* Stage admission writes inside an open transaction. Reads cap state,
|
|
61
|
+
* decides allow/deny, INSERTs reservation row + optional anonymous request
|
|
62
|
+
* row (only when allow). Returns a ticket that owns the open txn.
|
|
63
|
+
*
|
|
64
|
+
* Caller MUST call exactly one of confirm() or rollback() on the ticket.
|
|
65
|
+
*/
|
|
66
|
+
prepare(input: {
|
|
67
|
+
turnId: string;
|
|
68
|
+
peerId: string;
|
|
69
|
+
threadId: string;
|
|
70
|
+
trustLevel: TrustLevel;
|
|
71
|
+
publicSubstate: "anonymous" | "recognized" | null;
|
|
72
|
+
caps: BudgetCaps | null;
|
|
73
|
+
anonymousGlobalLimit: number | undefined;
|
|
74
|
+
dailyBudgetUsd: number | undefined;
|
|
75
|
+
}): Promise<TurnGateTicket>;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Post-response cost commit. Updates daily_global and peer_daily_costs
|
|
79
|
+
* atomically. Idempotent on the reservation's committed_at IS NULL guard.
|
|
80
|
+
*/
|
|
81
|
+
commit(turnId: string, peerId: string, cost: CostResult): Promise<void>;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Read-only accessor for context() preamble. Returns current usage so the
|
|
85
|
+
* BATS preamble can compute a budgetRatio AND surface unpriced turns —
|
|
86
|
+
* the schema's unpriced_turns counter (already collected per peer/day)
|
|
87
|
+
* is exposed here so operators see when budget enforcement is degraded.
|
|
88
|
+
*/
|
|
89
|
+
getPeerUsage(
|
|
90
|
+
peerId: string,
|
|
91
|
+
threadId: string,
|
|
92
|
+
day?: string,
|
|
93
|
+
): Promise<{
|
|
94
|
+
thread: number;
|
|
95
|
+
day: number;
|
|
96
|
+
costUsd: number;
|
|
97
|
+
unpricedTurns: number;
|
|
98
|
+
}>;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Periodic cleanup. Marks reservations stuck in pending state (engine
|
|
102
|
+
* errored before commit) as 'allow:incomplete'. Default window: 1 hour.
|
|
103
|
+
*/
|
|
104
|
+
sweepIncompleteReservations(opts?: { olderThanMs?: number }): Promise<number>;
|
|
105
|
+
|
|
106
|
+
close(): Promise<void>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ────────────────────────────────────────────────────────────
|
|
110
|
+
// Helpers
|
|
111
|
+
// ────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
/** Format a Unix ms timestamp as YYYY-MM-DD UTC. */
|
|
114
|
+
function ymdUtc(ts: number): string {
|
|
115
|
+
return new Date(ts).toISOString().slice(0, 10);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ────────────────────────────────────────────────────────────
|
|
119
|
+
// Factory
|
|
120
|
+
// ────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export function createBudgetStore(config: BudgetStoreConfig): BudgetStore {
|
|
123
|
+
const cleanupWindowMs = config.cleanupWindowMs ?? 60 * 60_000; // 1 hour
|
|
124
|
+
|
|
125
|
+
const db = new Database(config.dbPath, { create: true });
|
|
126
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
127
|
+
db.run("PRAGMA foreign_keys = ON");
|
|
128
|
+
|
|
129
|
+
// Schema before prepared statements (statements reference schema objects).
|
|
130
|
+
for (const stmt of SCHEMA_STATEMENTS) {
|
|
131
|
+
db.run(stmt);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Prepared statements ──────────────────────────────────
|
|
135
|
+
|
|
136
|
+
// Reservation reads
|
|
137
|
+
const countActiveThreadStmt = db.prepare<{ n: number }, [string, string, string]>(
|
|
138
|
+
`SELECT COUNT(*) AS n FROM turn_reservations
|
|
139
|
+
WHERE peer_id = ? AND thread_id = ? AND day = ?
|
|
140
|
+
AND decision IN ('allow', 'allow:incomplete', 'allow:orphaned')`,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const countActiveDayStmt = db.prepare<{ n: number }, [string, string]>(
|
|
144
|
+
`SELECT COUNT(*) AS n FROM turn_reservations
|
|
145
|
+
WHERE peer_id = ? AND day = ?
|
|
146
|
+
AND decision IN ('allow', 'allow:incomplete', 'allow:orphaned')`,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const selectDailyTotalStmt = db.prepare<{ total_cost_usd: number }, [string]>(
|
|
150
|
+
`SELECT total_cost_usd FROM daily_global WHERE day = ?`,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const selectPeerCostStmt = db.prepare<{ cost_usd: number }, [string, string]>(
|
|
154
|
+
`SELECT cost_usd FROM peer_daily_costs WHERE peer_id = ? AND day = ?`,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const selectPeerUnpricedTurnsStmt = db.prepare<{ unpriced_turns: number }, [string, string]>(
|
|
158
|
+
`SELECT unpriced_turns FROM peer_daily_costs WHERE peer_id = ? AND day = ?`,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const countAnonRequestsSinceStmt = db.prepare<{ n: number }, [number]>(
|
|
162
|
+
`SELECT COUNT(*) AS n FROM anonymous_requests WHERE timestamp >= ?`,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Reservation write
|
|
166
|
+
const insertReservationStmt = db.prepare(
|
|
167
|
+
`INSERT INTO turn_reservations
|
|
168
|
+
(turn_id, peer_id, thread_id, day, trust_level, public_substate,
|
|
169
|
+
reserved_at, committed_at, cost_usd, priced, decision, reason)
|
|
170
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// Anonymous request write
|
|
174
|
+
const insertAnonRequestStmt = db.prepare(
|
|
175
|
+
`INSERT INTO anonymous_requests (timestamp, source_hint) VALUES (?, ?)`,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// Commit — priced path
|
|
179
|
+
const updateReservationPricedStmt = db.prepare(
|
|
180
|
+
`UPDATE turn_reservations
|
|
181
|
+
SET committed_at = ?, cost_usd = ?, priced = 1
|
|
182
|
+
WHERE turn_id = ? AND committed_at IS NULL`,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const upsertDailyGlobalPricedStmt = db.prepare(
|
|
186
|
+
`INSERT INTO daily_global (day, total_cost_usd, unpriced_turns, updated_at)
|
|
187
|
+
VALUES (?, ?, 0, ?)
|
|
188
|
+
ON CONFLICT(day) DO UPDATE SET
|
|
189
|
+
total_cost_usd = total_cost_usd + excluded.total_cost_usd,
|
|
190
|
+
updated_at = excluded.updated_at`,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const upsertPeerDailyCostPricedStmt = db.prepare(
|
|
194
|
+
`INSERT INTO peer_daily_costs (peer_id, day, cost_usd, unpriced_turns, updated_at)
|
|
195
|
+
VALUES (?, ?, ?, 0, ?)
|
|
196
|
+
ON CONFLICT(peer_id, day) DO UPDATE SET
|
|
197
|
+
cost_usd = cost_usd + excluded.cost_usd,
|
|
198
|
+
updated_at = excluded.updated_at`,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// Commit — unpriced path
|
|
202
|
+
const updateReservationUnpricedStmt = db.prepare(
|
|
203
|
+
`UPDATE turn_reservations
|
|
204
|
+
SET committed_at = ?, cost_usd = 0, priced = 0
|
|
205
|
+
WHERE turn_id = ? AND committed_at IS NULL`,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const upsertDailyGlobalUnpricedStmt = db.prepare(
|
|
209
|
+
`INSERT INTO daily_global (day, total_cost_usd, unpriced_turns, updated_at)
|
|
210
|
+
VALUES (?, 0, 1, ?)
|
|
211
|
+
ON CONFLICT(day) DO UPDATE SET
|
|
212
|
+
unpriced_turns = unpriced_turns + 1,
|
|
213
|
+
updated_at = excluded.updated_at`,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const upsertPeerDailyCostUnpricedStmt = db.prepare(
|
|
217
|
+
`INSERT INTO peer_daily_costs (peer_id, day, cost_usd, unpriced_turns, updated_at)
|
|
218
|
+
VALUES (?, ?, 0, 1, ?)
|
|
219
|
+
ON CONFLICT(peer_id, day) DO UPDATE SET
|
|
220
|
+
unpriced_turns = unpriced_turns + 1,
|
|
221
|
+
updated_at = excluded.updated_at`,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// Reservation day lookup (for Fix 4: commit books to reservation's day)
|
|
225
|
+
const selectReservationDayStmt = db.prepare<{ day: string }, [string]>(
|
|
226
|
+
`SELECT day FROM turn_reservations WHERE turn_id = ?`,
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// Sweep
|
|
230
|
+
const sweepStmt = db.prepare(
|
|
231
|
+
`UPDATE turn_reservations
|
|
232
|
+
SET decision = 'allow:incomplete'
|
|
233
|
+
WHERE committed_at IS NULL AND reserved_at < ?
|
|
234
|
+
AND decision = 'allow'`,
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
// ── Cap evaluation ───────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
function checkCaps(
|
|
240
|
+
input: {
|
|
241
|
+
turnId: string;
|
|
242
|
+
peerId: string;
|
|
243
|
+
threadId: string;
|
|
244
|
+
trustLevel: TrustLevel;
|
|
245
|
+
publicSubstate: "anonymous" | "recognized" | null;
|
|
246
|
+
caps: BudgetCaps | null;
|
|
247
|
+
anonymousGlobalLimit: number | undefined;
|
|
248
|
+
dailyBudgetUsd: number | undefined;
|
|
249
|
+
},
|
|
250
|
+
dayKey: string,
|
|
251
|
+
now: number,
|
|
252
|
+
): string | null {
|
|
253
|
+
// 1. Anonymous global rate (rolling 60-second window)
|
|
254
|
+
if (input.publicSubstate === "anonymous" && input.anonymousGlobalLimit !== undefined) {
|
|
255
|
+
const row = countAnonRequestsSinceStmt.get(now - 60_000);
|
|
256
|
+
const count = row?.n ?? 0;
|
|
257
|
+
if (count >= input.anonymousGlobalLimit) {
|
|
258
|
+
return "anonymous global rate limit exceeded";
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 2. Facility-wide daily USD ceiling
|
|
263
|
+
if (input.dailyBudgetUsd !== undefined) {
|
|
264
|
+
const row = selectDailyTotalStmt.get(dayKey);
|
|
265
|
+
const total = row?.total_cost_usd ?? 0;
|
|
266
|
+
if (total >= input.dailyBudgetUsd) {
|
|
267
|
+
return `dailyBudgetUsd reached ($${total.toFixed(2)})`;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 3. Per-peer caps (null = no per-tier caps)
|
|
272
|
+
if (input.caps === null) return null;
|
|
273
|
+
|
|
274
|
+
if (input.caps.maxUsdPerDay !== undefined) {
|
|
275
|
+
const row = selectPeerCostStmt.get(input.peerId, dayKey);
|
|
276
|
+
const peerCost = row?.cost_usd ?? 0;
|
|
277
|
+
if (peerCost >= input.caps.maxUsdPerDay) {
|
|
278
|
+
return `peer maxUsdPerDay reached ($${peerCost.toFixed(2)})`;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (input.caps.maxTurnsPerThread !== undefined) {
|
|
283
|
+
const row = countActiveThreadStmt.get(input.peerId, input.threadId, dayKey);
|
|
284
|
+
const count = row?.n ?? 0;
|
|
285
|
+
if (count >= input.caps.maxTurnsPerThread) {
|
|
286
|
+
return "per-thread turn cap reached";
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (input.caps.maxTurnsPerDay !== undefined) {
|
|
291
|
+
const row = countActiveDayStmt.get(input.peerId, dayKey);
|
|
292
|
+
const count = row?.n ?? 0;
|
|
293
|
+
if (count >= input.caps.maxTurnsPerDay) {
|
|
294
|
+
return "daily turn cap reached";
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Mutex for prepare ───────────────────────────────────
|
|
302
|
+
// Serializes BEGIN IMMEDIATE acquisitions across concurrent kernel turns.
|
|
303
|
+
// Each prepare awaits the prior one's full lifecycle (confirm or rollback)
|
|
304
|
+
// before starting its own transaction. Without this, concurrency > 1 on the
|
|
305
|
+
// shared Database handle hits SQLite "transaction already in progress" errors.
|
|
306
|
+
|
|
307
|
+
let prepareChain: Promise<void> = Promise.resolve();
|
|
308
|
+
|
|
309
|
+
// ── prepare ─────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
async function prepare(input: {
|
|
312
|
+
turnId: string;
|
|
313
|
+
peerId: string;
|
|
314
|
+
threadId: string;
|
|
315
|
+
trustLevel: TrustLevel;
|
|
316
|
+
publicSubstate: "anonymous" | "recognized" | null;
|
|
317
|
+
caps: BudgetCaps | null;
|
|
318
|
+
anonymousGlobalLimit: number | undefined;
|
|
319
|
+
dailyBudgetUsd: number | undefined;
|
|
320
|
+
}): Promise<TurnGateTicket> {
|
|
321
|
+
// Wait for any in-flight prepare/confirm/rollback to finish before starting
|
|
322
|
+
// our own. The chain advances when our ticket's confirm or rollback runs.
|
|
323
|
+
const priorChain = prepareChain;
|
|
324
|
+
let releaseChain!: () => void;
|
|
325
|
+
prepareChain = new Promise<void>((resolve) => {
|
|
326
|
+
releaseChain = resolve;
|
|
327
|
+
});
|
|
328
|
+
await priorChain;
|
|
329
|
+
|
|
330
|
+
const now = Date.now();
|
|
331
|
+
const dayKey = ymdUtc(now);
|
|
332
|
+
|
|
333
|
+
let done = false;
|
|
334
|
+
|
|
335
|
+
function rollbackIfActive(): void {
|
|
336
|
+
if (done) return;
|
|
337
|
+
done = true;
|
|
338
|
+
try {
|
|
339
|
+
db.run("ROLLBACK");
|
|
340
|
+
} catch {
|
|
341
|
+
// Swallow already-rolled-back errors (bun:sqlite throws if no
|
|
342
|
+
// active transaction).
|
|
343
|
+
} finally {
|
|
344
|
+
releaseChain();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
db.run("BEGIN IMMEDIATE");
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
// ── Retry path: if this turnId already has a reservation row,
|
|
352
|
+
// respect the existing decision rather than failing on PK conflict.
|
|
353
|
+
const existingRow = db
|
|
354
|
+
.prepare<{ decision: string; reason: string | null }, [string]>(
|
|
355
|
+
`SELECT decision, reason FROM turn_reservations WHERE turn_id = ?`,
|
|
356
|
+
)
|
|
357
|
+
.get(input.turnId);
|
|
358
|
+
|
|
359
|
+
if (existingRow !== null) {
|
|
360
|
+
// Row already committed on a prior prepare. Roll back (nothing new to commit).
|
|
361
|
+
rollbackIfActive();
|
|
362
|
+
const allowed =
|
|
363
|
+
existingRow.decision === "allow" ||
|
|
364
|
+
existingRow.decision === "allow:incomplete" ||
|
|
365
|
+
existingRow.decision === "allow:orphaned";
|
|
366
|
+
if (allowed) {
|
|
367
|
+
return {
|
|
368
|
+
decision: { allow: true },
|
|
369
|
+
confirm: async () => {
|
|
370
|
+
/* already committed */
|
|
371
|
+
},
|
|
372
|
+
rollback: async () => {
|
|
373
|
+
/* already committed */
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
decision: { allow: false, reason: existingRow.reason ?? "denied" },
|
|
379
|
+
confirm: async () => {
|
|
380
|
+
/* no-op */
|
|
381
|
+
},
|
|
382
|
+
rollback: async () => {
|
|
383
|
+
/* no-op */
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── Evaluate caps inside the open transaction.
|
|
389
|
+
const denyReason = checkCaps(input, dayKey, now);
|
|
390
|
+
|
|
391
|
+
if (denyReason !== null) {
|
|
392
|
+
// Deny: rollback the empty transaction (nothing was written).
|
|
393
|
+
return {
|
|
394
|
+
decision: { allow: false, reason: denyReason },
|
|
395
|
+
confirm: async () => rollbackIfActive(), // confirm on deny = rollback
|
|
396
|
+
rollback: async () => rollbackIfActive(),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ── Stage writes.
|
|
401
|
+
insertReservationStmt.run(
|
|
402
|
+
input.turnId,
|
|
403
|
+
input.peerId,
|
|
404
|
+
input.threadId,
|
|
405
|
+
dayKey,
|
|
406
|
+
input.trustLevel,
|
|
407
|
+
input.publicSubstate ?? null,
|
|
408
|
+
now,
|
|
409
|
+
null, // committed_at
|
|
410
|
+
null, // cost_usd
|
|
411
|
+
0, // priced
|
|
412
|
+
"allow",
|
|
413
|
+
null, // reason
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
if (input.publicSubstate === "anonymous" && input.anonymousGlobalLimit !== undefined) {
|
|
417
|
+
insertAnonRequestStmt.run(now, null);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
decision: { allow: true },
|
|
422
|
+
confirm: async () => {
|
|
423
|
+
if (done) return;
|
|
424
|
+
done = true;
|
|
425
|
+
try {
|
|
426
|
+
db.run("COMMIT");
|
|
427
|
+
} finally {
|
|
428
|
+
releaseChain();
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
rollback: async () => rollbackIfActive(),
|
|
432
|
+
};
|
|
433
|
+
} catch (err) {
|
|
434
|
+
rollbackIfActive();
|
|
435
|
+
throw err;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── commit ───────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
async function commit(turnId: string, peerId: string, cost: CostResult): Promise<void> {
|
|
442
|
+
const now = Date.now();
|
|
443
|
+
|
|
444
|
+
const tx = db.transaction(() => {
|
|
445
|
+
// Look up the reservation's stored day so cost is booked to the SAME
|
|
446
|
+
// day the admission decision was made against — not the day the engine
|
|
447
|
+
// call happened to finish in. A turn admitted just before UTC midnight
|
|
448
|
+
// and finished just after must not leak spend across day boundaries.
|
|
449
|
+
const reservationRow = selectReservationDayStmt.get(turnId);
|
|
450
|
+
if (!reservationRow) return; // reservation doesn't exist (never reserved or already swept)
|
|
451
|
+
const dayKey = reservationRow.day;
|
|
452
|
+
|
|
453
|
+
if (cost.priced) {
|
|
454
|
+
const result = updateReservationPricedStmt.run(now, cost.costUsd, turnId);
|
|
455
|
+
if (result.changes === 0) return; // already committed — idempotent
|
|
456
|
+
upsertDailyGlobalPricedStmt.run(dayKey, cost.costUsd, now);
|
|
457
|
+
upsertPeerDailyCostPricedStmt.run(peerId, dayKey, cost.costUsd, now);
|
|
458
|
+
} else {
|
|
459
|
+
const result = updateReservationUnpricedStmt.run(now, turnId);
|
|
460
|
+
if (result.changes === 0) return; // already committed — idempotent
|
|
461
|
+
upsertDailyGlobalUnpricedStmt.run(dayKey, now);
|
|
462
|
+
upsertPeerDailyCostUnpricedStmt.run(peerId, dayKey, now);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
tx();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── getPeerUsage ─────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
async function getPeerUsage(
|
|
472
|
+
peerId: string,
|
|
473
|
+
threadId: string,
|
|
474
|
+
day?: string,
|
|
475
|
+
): Promise<{ thread: number; day: number; costUsd: number; unpricedTurns: number }> {
|
|
476
|
+
const dayKey = day ?? ymdUtc(Date.now());
|
|
477
|
+
|
|
478
|
+
const threadRow = countActiveThreadStmt.get(peerId, threadId, dayKey);
|
|
479
|
+
const dayRow = countActiveDayStmt.get(peerId, dayKey);
|
|
480
|
+
const costRow = selectPeerCostStmt.get(peerId, dayKey);
|
|
481
|
+
const unpricedRow = selectPeerUnpricedTurnsStmt.get(peerId, dayKey);
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
thread: threadRow?.n ?? 0,
|
|
485
|
+
day: dayRow?.n ?? 0,
|
|
486
|
+
costUsd: costRow?.cost_usd ?? 0,
|
|
487
|
+
unpricedTurns: unpricedRow?.unpriced_turns ?? 0,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ── sweepIncompleteReservations ──────────────────────────
|
|
492
|
+
|
|
493
|
+
async function sweepIncompleteReservations(opts?: { olderThanMs?: number }): Promise<number> {
|
|
494
|
+
const windowMs = opts?.olderThanMs ?? cleanupWindowMs;
|
|
495
|
+
const cutoff = Date.now() - windowMs;
|
|
496
|
+
const result = sweepStmt.run(cutoff);
|
|
497
|
+
return result.changes;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ── close ────────────────────────────────────────────────
|
|
501
|
+
|
|
502
|
+
async function close(): Promise<void> {
|
|
503
|
+
db.close();
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
prepare,
|
|
508
|
+
commit,
|
|
509
|
+
getPeerUsage,
|
|
510
|
+
sweepIncompleteReservations,
|
|
511
|
+
close,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Augment,
|
|
3
|
+
PeerIdentity,
|
|
4
|
+
TurnGateProvider,
|
|
5
|
+
TurnGateTicket,
|
|
6
|
+
ContextBlock,
|
|
7
|
+
TurnState,
|
|
8
|
+
} from "../../types";
|
|
9
|
+
import { createBudgetStore, type BudgetStore } from "./budget-store";
|
|
10
|
+
import type { BudgetsConfig, BudgetCaps } from "./types";
|
|
11
|
+
import { buildBudgetPreamble } from "./preamble";
|
|
12
|
+
|
|
13
|
+
export interface BudgetsAugmentOptions extends BudgetsConfig {
|
|
14
|
+
/** Storage backend. Only "sqlite" is supported in v0. */
|
|
15
|
+
backend?: "sqlite";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the BudgetCaps for a peer based on their trust level and, for
|
|
20
|
+
* public-trust peers, their publicSubstate.
|
|
21
|
+
*
|
|
22
|
+
* creator → null (bypass — no store write)
|
|
23
|
+
* agent → config.caps?.agent ?? null
|
|
24
|
+
* public:anonymous → config.caps?.public?.anonymous ?? null
|
|
25
|
+
* public:recognized → config.caps?.public?.recognized ?? null
|
|
26
|
+
*
|
|
27
|
+
* A null peer (internal/scheduled trigger) also bypasses.
|
|
28
|
+
*/
|
|
29
|
+
function resolveCaps(peer: PeerIdentity | null, config: BudgetsConfig): BudgetCaps | null {
|
|
30
|
+
if (!peer) return null;
|
|
31
|
+
switch (peer.trustLevel) {
|
|
32
|
+
case "creator":
|
|
33
|
+
return null;
|
|
34
|
+
case "agent":
|
|
35
|
+
return config.caps?.agent ?? null;
|
|
36
|
+
case "public":
|
|
37
|
+
return peer.publicSubstate === "recognized"
|
|
38
|
+
? (config.caps?.public?.recognized ?? null)
|
|
39
|
+
: (config.caps?.public?.anonymous ?? null);
|
|
40
|
+
default:
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function budgets(opts: BudgetsAugmentOptions): Augment {
|
|
46
|
+
const store: BudgetStore = createBudgetStore({
|
|
47
|
+
dbPath: opts.dbPath,
|
|
48
|
+
cleanupWindowMs: opts.cleanupWindowMs,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Periodic sweep: mark reservations stuck in pending state (engine errored
|
|
52
|
+
// before commit) as 'allow:incomplete'. Fire at half the cleanup window so
|
|
53
|
+
// stale turns are caught within at most cleanupWindowMs of becoming stale.
|
|
54
|
+
const cleanupWindowMs = opts.cleanupWindowMs ?? 60 * 60_000; // default 1 hour
|
|
55
|
+
const sweepIntervalMs = Math.max(60_000, Math.floor(cleanupWindowMs / 2));
|
|
56
|
+
const sweepTimer = setInterval(() => {
|
|
57
|
+
store.sweepIncompleteReservations({ olderThanMs: cleanupWindowMs }).catch((err) => {
|
|
58
|
+
console.error("[budgets] sweep failed:", err);
|
|
59
|
+
});
|
|
60
|
+
}, sweepIntervalMs);
|
|
61
|
+
// Don't keep the process alive just for the sweeper.
|
|
62
|
+
if (typeof sweepTimer === "object" && sweepTimer !== null && "unref" in sweepTimer) {
|
|
63
|
+
(sweepTimer as { unref(): void }).unref();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const turnGate: TurnGateProvider = {
|
|
67
|
+
/**
|
|
68
|
+
* PREPARE — delegates to the store. The store opens a SQLite transaction,
|
|
69
|
+
* evaluates caps, stages writes, returns a ticket whose confirm/rollback
|
|
70
|
+
* close the transaction. The augment is a thin pass-through.
|
|
71
|
+
*
|
|
72
|
+
* Creator and null peer (internal trigger) bypass entirely. Return a no-op
|
|
73
|
+
* ticket — no transaction opened, no rows staged.
|
|
74
|
+
*/
|
|
75
|
+
async prepare({ turnId, peer, threadId }): Promise<TurnGateTicket> {
|
|
76
|
+
if (!peer || peer.trustLevel === "creator") {
|
|
77
|
+
return {
|
|
78
|
+
decision: { allow: true },
|
|
79
|
+
confirm: async () => {},
|
|
80
|
+
rollback: async () => {},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const caps = resolveCaps(peer, opts);
|
|
85
|
+
return store.prepare({
|
|
86
|
+
turnId,
|
|
87
|
+
peerId: peer.id,
|
|
88
|
+
threadId,
|
|
89
|
+
trustLevel: peer.trustLevel,
|
|
90
|
+
publicSubstate: peer.publicSubstate ?? null,
|
|
91
|
+
caps,
|
|
92
|
+
anonymousGlobalLimit: opts.anonymousGlobalLimit,
|
|
93
|
+
dailyBudgetUsd: opts.dailyBudgetUsd,
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* COMMIT — post-response cost recording. Skips creator and null-peer turns
|
|
99
|
+
* (their prepare returned a no-op ticket; nothing in the store to commit
|
|
100
|
+
* against).
|
|
101
|
+
*/
|
|
102
|
+
async commit({ turnId, peer, cost }): Promise<void> {
|
|
103
|
+
if (!peer || peer.trustLevel === "creator") return;
|
|
104
|
+
await store.commit(turnId, peer.id, cost);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
name: "budgets",
|
|
110
|
+
capabilities: ["context", "lifecycle"],
|
|
111
|
+
turnGate,
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* BATS-style budget context block. Reads peer usage from the store
|
|
115
|
+
* and emits a ContextBlock describing remaining capacity and behavioral
|
|
116
|
+
* guidance. Called after the turn-gate 2PC confirm, so `used` already
|
|
117
|
+
* counts the current turn as consumed — remaining values are post-this-turn.
|
|
118
|
+
*/
|
|
119
|
+
context: async (turn: TurnState): Promise<ContextBlock[]> => {
|
|
120
|
+
const peer = turn.peer;
|
|
121
|
+
if (!peer) return [];
|
|
122
|
+
const caps = resolveCaps(peer, opts);
|
|
123
|
+
if (caps === null) return [];
|
|
124
|
+
const used = await store.getPeerUsage(peer.id, turn.threadId);
|
|
125
|
+
const block = buildBudgetPreamble({ caps, used });
|
|
126
|
+
return block ? [block] : [];
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
onShutdown: async () => {
|
|
130
|
+
clearInterval(sweepTimer);
|
|
131
|
+
await store.close();
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|