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.
Files changed (121) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/README.md +161 -0
  4. package/package.json +76 -0
  5. package/src/agent-card.ts +39 -0
  6. package/src/agent.ts +283 -0
  7. package/src/agentmail-client.ts +138 -0
  8. package/src/augments/bash/index.ts +463 -0
  9. package/src/augments/bash/skill/SKILL.md +156 -0
  10. package/src/augments/budgets/budget-store.ts +513 -0
  11. package/src/augments/budgets/index.ts +134 -0
  12. package/src/augments/budgets/preamble.ts +93 -0
  13. package/src/augments/budgets/types.ts +89 -0
  14. package/src/augments/file-memory/index.ts +71 -0
  15. package/src/augments/filesystem/index.ts +533 -0
  16. package/src/augments/filesystem/skill/SKILL.md +142 -0
  17. package/src/augments/filesystem/skill/references/mount-permissions.md +81 -0
  18. package/src/augments/layered-memory/extractor/buffer.ts +56 -0
  19. package/src/augments/layered-memory/extractor/frequency.ts +79 -0
  20. package/src/augments/layered-memory/extractor/inject-handler.ts +103 -0
  21. package/src/augments/layered-memory/extractor/parse.ts +75 -0
  22. package/src/augments/layered-memory/extractor/prompt.md +26 -0
  23. package/src/augments/layered-memory/index.ts +757 -0
  24. package/src/augments/layered-memory/skill/SKILL.md +153 -0
  25. package/src/augments/layered-memory/storage/migrations/README.md +16 -0
  26. package/src/augments/layered-memory/storage/migrations/supabase-add-fact-fields.sql +9 -0
  27. package/src/augments/layered-memory/storage/sqlite-store.ts +352 -0
  28. package/src/augments/layered-memory/storage/supabase-store.ts +263 -0
  29. package/src/augments/layered-memory/storage/types.ts +98 -0
  30. package/src/augments/link/index.ts +489 -0
  31. package/src/augments/link/translate.ts +261 -0
  32. package/src/augments/notify/adapters/agentmail.ts +70 -0
  33. package/src/augments/notify/adapters/telegram.ts +60 -0
  34. package/src/augments/notify/adapters/webhook.ts +55 -0
  35. package/src/augments/notify/index.ts +284 -0
  36. package/src/augments/notify/skill/SKILL.md +150 -0
  37. package/src/augments/org-context/index.ts +721 -0
  38. package/src/augments/org-context/skill/SKILL.md +96 -0
  39. package/src/augments/skills/index.ts +103 -0
  40. package/src/augments/supabase-memory/index.ts +151 -0
  41. package/src/augments/telegram-transport/index.ts +312 -0
  42. package/src/augments/telegram-transport/polling.ts +55 -0
  43. package/src/augments/telegram-transport/webhook.ts +56 -0
  44. package/src/augments/turn-control/index.ts +61 -0
  45. package/src/augments/turn-control/skill/SKILL.md +155 -0
  46. package/src/augments/visitor-auth/email-validation.ts +66 -0
  47. package/src/augments/visitor-auth/index.ts +779 -0
  48. package/src/augments/visitor-auth/rate-limiter.ts +90 -0
  49. package/src/augments/visitor-auth/skill/SKILL.md +55 -0
  50. package/src/augments/visitor-auth/storage/sqlite-store.ts +398 -0
  51. package/src/augments/visitor-auth/storage/types.ts +164 -0
  52. package/src/augments/visitor-auth/types.ts +123 -0
  53. package/src/augments/visitor-auth/verify-page.ts +179 -0
  54. package/src/augments/web-fetch/index.ts +331 -0
  55. package/src/augments/web-fetch/skill/SKILL.md +100 -0
  56. package/src/cli/agent-index.ts +289 -0
  57. package/src/cli/augment-catalog.ts +320 -0
  58. package/src/cli/augment-resolver.ts +597 -0
  59. package/src/cli/commands/add-skill.ts +194 -0
  60. package/src/cli/commands/add.ts +87 -0
  61. package/src/cli/commands/chat.ts +207 -0
  62. package/src/cli/commands/create.ts +462 -0
  63. package/src/cli/commands/dev.ts +139 -0
  64. package/src/cli/commands/eval.ts +180 -0
  65. package/src/cli/commands/ls.ts +66 -0
  66. package/src/cli/commands/remove.ts +95 -0
  67. package/src/cli/commands/restart.ts +40 -0
  68. package/src/cli/commands/start.ts +123 -0
  69. package/src/cli/commands/status.ts +104 -0
  70. package/src/cli/commands/stop.ts +84 -0
  71. package/src/cli/commands/visitors-revoke.ts +155 -0
  72. package/src/cli/commands/visitors.ts +101 -0
  73. package/src/cli/config-parser.ts +1034 -0
  74. package/src/cli/engine-resolver.ts +68 -0
  75. package/src/cli/index.ts +178 -0
  76. package/src/cli/model-picker.ts +89 -0
  77. package/src/cli/pid-registry.ts +146 -0
  78. package/src/cli/plist-generator.ts +117 -0
  79. package/src/cli/resolve-config.ts +56 -0
  80. package/src/cli/scaffold-skills.ts +158 -0
  81. package/src/cli/scaffold.ts +291 -0
  82. package/src/cli/skill-frontmatter.ts +51 -0
  83. package/src/cli/skill-validator.ts +151 -0
  84. package/src/cli/types.ts +228 -0
  85. package/src/cli/yaml-helpers.ts +66 -0
  86. package/src/engines/_shared/cost.ts +55 -0
  87. package/src/engines/_shared/schema-normalize.ts +75 -0
  88. package/src/engines/anthropic/pricing.ts +117 -0
  89. package/src/engines/anthropic.ts +483 -0
  90. package/src/engines/openai/pricing.ts +67 -0
  91. package/src/engines/openai.ts +446 -0
  92. package/src/engines/openrouter/pricing.ts +83 -0
  93. package/src/engines/openrouter.ts +185 -0
  94. package/src/helpers.ts +24 -0
  95. package/src/http.ts +387 -0
  96. package/src/index.ts +165 -0
  97. package/src/kernel/capability-table.ts +172 -0
  98. package/src/kernel/context-allocator.ts +161 -0
  99. package/src/kernel/history-manager.ts +198 -0
  100. package/src/kernel/lifecycle-manager.ts +106 -0
  101. package/src/kernel/output-validator.ts +35 -0
  102. package/src/kernel/preamble.ts +23 -0
  103. package/src/kernel/route-collector.ts +97 -0
  104. package/src/kernel/timeout.ts +21 -0
  105. package/src/kernel/tool-selector.ts +47 -0
  106. package/src/kernel/trace-emitter.ts +66 -0
  107. package/src/kernel/transport-queue.ts +147 -0
  108. package/src/kernel/turn-loop.ts +1148 -0
  109. package/src/memory/context-synthesis.ts +83 -0
  110. package/src/memory/memory-bus.ts +61 -0
  111. package/src/memory/registry.ts +80 -0
  112. package/src/memory/tools.ts +320 -0
  113. package/src/memory/types.ts +8 -0
  114. package/src/parts.ts +30 -0
  115. package/src/scaffold-templates/identity.md +31 -0
  116. package/src/telegram-client.ts +145 -0
  117. package/src/tokenizer.ts +14 -0
  118. package/src/transports/ag-ui-events.ts +253 -0
  119. package/src/transports/visitor-token.ts +82 -0
  120. package/src/transports/web-transport.ts +948 -0
  121. 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
+ }