proactive-gate 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # proactive-gate
2
2
 
3
+ English | [Türkçe](README.tr.md)
4
+
5
+ <p>
6
+ <img src="https://img.shields.io/npm/v/proactive-gate?style=flat-square&color=111111&label=npm" alt="npm">
7
+ <img src="https://img.shields.io/npm/dm/proactive-gate?style=flat-square&color=111111" alt="npm downloads">
8
+ <img src="https://img.shields.io/github/actions/workflow/status/Bubblegunn/proactive-gate/ci.yml?style=flat-square&color=111111&label=ci" alt="ci">
9
+ <img src="https://img.shields.io/bundlephobia/minzip/proactive-gate?style=flat-square&color=111111" alt="minzipped size">
10
+ <img src="https://img.shields.io/github/stars/Bubblegunn/proactive-gate?style=flat-square&color=111111" alt="stars">
11
+ <img src="https://img.shields.io/badge/license-MIT-111111?style=flat-square" alt="MIT">
12
+ </p>
13
+
3
14
  Decide whether a proactive AI agent may reach a user right now, and log why not.
4
15
 
5
16
  A proactive assistant has two halves. The generating half decides what is worth
@@ -28,7 +39,9 @@ if (decision.allowed && (await gate.commit(decision, { user, candidate }))) {
28
39
 
29
40
  Zero dependencies. TypeScript. Node 20 or newer. Framework-agnostic: the gate sits
30
41
  between "the model produced something" and "the user's phone buzzed", whichever
31
- model or framework produced it.
42
+ model or framework produced it. Examples: [`examples/vercel-ai-sdk.ts`](examples/vercel-ai-sdk.ts),
43
+ [`examples/mastra.ts`](examples/mastra.ts), [`examples/langgraph.ts`](examples/langgraph.ts), and a
44
+ replayable policy in [`examples/policy.js`](examples/policy.js). API reference: [`docs/api`](docs/api/README.md).
32
45
 
33
46
  ## What a decision looks like
34
47
 
@@ -54,6 +67,8 @@ model or framework produced it.
54
67
  }
55
68
  ```
56
69
 
70
+ <p align="center"><img src="assets/trace.png" width="900" alt="A real decision trace: eight checks ran, quiet hours rejected, each with its reason and cost"></p>
71
+
57
72
  With one gate and a logged reason, "why was the user not told about this" has an
58
73
  answer. With checks scattered through a pipeline, the honest answer is "somewhere,
59
74
  something returned false".
@@ -75,6 +90,12 @@ something returned false".
75
90
  | 11 | `adaptiveTiming({ nextGoodMoment, surfacesFor })` | never | non-rejecting: moves `deliverAt` or narrows surfaces; a check marked `nonRejecting` cannot reject even if it tries |
76
91
  | 12 | `dailyBudget({ limit, bypassPriority })` | the user's local-day counter is at the limit | `evaluate` reads, `commit` increments atomically and can still refuse |
77
92
 
93
+ `weeklyBudget({ limit, bypassPriority })` is the same shape keyed on the user's local ISO
94
+ week; `defaultChecks({ weeklyLimit })` places it just before the daily one. Budgets are
95
+ consumed in check order at commit, so when a weekly check passes and the daily one then
96
+ refuses, that weekly unit is spent without a delivery. It only happens when two commits
97
+ race after a shared evaluate.
98
+
78
99
  Order is a design decision and it should be visible. Consent has to come before
79
100
  everything. Quiet hours have to come before the budget, or a rejected candidate
80
101
  consumes a delivery it never made. Reorder freely; the trace will show what you did.
@@ -92,6 +113,31 @@ const gate = createGate({
92
113
  });
93
114
  ```
94
115
 
116
+ ### Writing your own check
117
+
118
+ A check is an object with an `id` and a `run` function. It receives the user, the
119
+ candidate, the clock, the resolved priority, the store and the surfaces still on the
120
+ table, and returns `pass`, `reject` with a reason, `adjust`, or `skip`. It appears in the
121
+ trace like every built-in one.
122
+
123
+ ```ts
124
+ const weekendFloor = {
125
+ id: "weekendFloor",
126
+ run: ({ now, priority }) => {
127
+ const day = now.getUTCDay();
128
+ if ((day === 0 || day === 6) && priority !== "high" && priority !== "critical") {
129
+ return { kind: "reject", reason: "weekend: only high priority" };
130
+ }
131
+ return { kind: "pass" };
132
+ },
133
+ };
134
+ const gate = createGate({ checks: [checks.consent(), weekendFloor, checks.dailyBudget({ limit: 5 })] });
135
+ ```
136
+
137
+ Mark a check `nonRejecting: true` when it may only move timing or narrow surfaces; the
138
+ gate then ignores a reject from it and says so in the trace, so a bug in a timing model
139
+ cannot silence a user.
140
+
95
141
  ## The budget is enforced at commit, not at evaluate
96
142
 
97
143
  Two instances can both evaluate a candidate for the same user, both see four of
@@ -109,6 +155,13 @@ if (decision.allowed && await gate.commit(decision, input)) { // INCR, returns
109
155
  counter is keyed on the user's local day, so a budget resets at the user's midnight,
110
156
  not at UTC.
111
157
 
158
+ ## Stores
159
+
160
+ `MemoryStore` keeps values in process memory and is useful for a single instance. `RedisStore`
161
+ shares values across instances. `SqliteStore` persists values in a SQLite database without
162
+ adding a package dependency. `SqliteStore` requires Node.js 22.5 or newer; the SQLite module
163
+ is loaded only when the store is constructed so the package can still be used on Node.js 20. On Node 22 the module prints an ExperimentalWarning on first use; it is stable from Node 24.
164
+
112
165
  ## Fail open, on purpose
113
166
 
114
167
  When a store-backed check throws (Redis is down), the default lets the candidate
@@ -142,6 +195,50 @@ dailyBudget 1 daily budget of 5 used (5)
142
195
  per line for a notebook. Replay a week of real candidates against a proposed policy
143
196
  and you know its allow rate and its silence reasons before a single user does.
144
197
 
198
+ ## Compared with hand-rolled checks and feature flags
199
+
200
+ Most products start with a few `if` statements next to the send call and grow from there.
201
+ The difference is not the checks, which anyone can write, but four properties that are hard
202
+ to keep once the checks are scattered:
203
+
204
+ - The order is one list in one place, so "consent before everything" is a fact you can read
205
+ rather than a convention you hope each caller followed.
206
+ - Every rejection names the check and the reason, so "why was the user not told" has an
207
+ answer in the log instead of "something returned false somewhere".
208
+ - The budget is consumed by an atomic increment at send time, so two instances cannot both
209
+ send the sixth message; scattered checks read a counter and race.
210
+ - A policy can be replayed over a day of real candidates before it ships, and a non-rejecting
211
+ check cannot reject even if a bug makes it try.
212
+
213
+ A feature-flag system does a different job better: rolling a behaviour out to a percentage
214
+ of users, per-tenant overrides, and an audit trail of who flipped what. Use flags to decide
215
+ whether the gate runs at all, and the gate to decide whether this message reaches this
216
+ person now.
217
+
218
+ ## Integrations
219
+
220
+ | framework | example | where the gate sits |
221
+ |---|---|---|
222
+ | Vercel AI SDK | [`examples/vercel-ai-sdk.ts`](examples/vercel-ai-sdk.ts) | after `generateText`, before the push |
223
+ | Mastra | [`examples/mastra.ts`](examples/mastra.ts) | after `agent.generate`, before the send |
224
+ | LangGraph | [`examples/langgraph.ts`](examples/langgraph.ts) | inside the `notify` node, before the tool call |
225
+
226
+ The pattern is the same everywhere: the model decides whether there is something to say,
227
+ `gate.evaluate` decides whether it may be said now, and `gate.commit` runs right before the
228
+ message leaves.
229
+
230
+ ## Performance
231
+
232
+ `npm run bench` runs `gate.evaluate()` ten thousand times with the default twelve checks and
233
+ `MemoryStore`. On 5 September 2026:
234
+
235
+ ```
236
+ evaluate() x 10,000, twelve checks, MemoryStore: median 48.7 µs, p95 91.1 µs (v24.13.0, Apple M4 Max)
237
+ ```
238
+
239
+ With `RedisStore` the two store-backed checks add one round trip each; the gate itself is
240
+ not where the time goes.
241
+
145
242
  ## Learning from what happened
146
243
 
147
244
  ```ts
package/README.tr.md ADDED
@@ -0,0 +1,216 @@
1
+ # proactive-gate
2
+
3
+ [English](README.md) | Türkçe
4
+
5
+ Proaktif bir yapay zekâ ajanının kullanıcıya şu an ulaşıp ulaşamayacağına karar verir ve
6
+ neden ulaşamadığını kaydeder.
7
+
8
+ Proaktif bir asistanın iki yarısı vardır. Üreten yarı neyin söylenmeye değer olduğuna karar
9
+ verir. Bastıran yarı ise onu şimdi mi, sonra mı, hiç mi söyleyeceğine karar verir. Proaktif
10
+ yapay zekâ üzerine yazılan hemen her şey ilk yarı hakkındadır. Bu paket ikinci yarıdır: tek
11
+ kapı, sıralı bir kontrol listesi ve her ret için bir gerekçe.
12
+
13
+ ```
14
+ npm install proactive-gate
15
+ ```
16
+
17
+ ```ts
18
+ import { createGate, defaultChecks, RedisStore } from "proactive-gate";
19
+
20
+ const gate = createGate({
21
+ store: new RedisStore(redis), // MemoryStore() for one instance
22
+ checks: defaultChecks({ dailyLimit: 3, quietHoursFloor: "high" }),
23
+ onDecision: (d) => log.info("gate", d), // every decision, allowed or not
24
+ });
25
+
26
+ const decision = await gate.evaluate({ user, candidate });
27
+ if (decision.allowed && (await gate.commit(decision, { user, candidate }))) {
28
+ await send(decision.surfaces, candidate.payload);
29
+ }
30
+ ```
31
+
32
+ Sıfır bağımlılık. TypeScript. Node 20 ya da üstü. Framework'ten bağımsız: kapı, "model bir
33
+ şey üretti" ile "kullanıcının telefonu titredi" arasında durur; hangi model ya da framework
34
+ üretmiş olursa olsun. Örnekler: [`examples/vercel-ai-sdk.ts`](examples/vercel-ai-sdk.ts),
35
+ [`examples/mastra.ts`](examples/mastra.ts) ve yeniden oynatılabilir bir politika olarak
36
+ [`examples/policy.js`](examples/policy.js).
37
+
38
+ ## Bir karar neye benzer
39
+
40
+ ```ts
41
+ {
42
+ allowed: false,
43
+ userId: "ayse",
44
+ candidateId: "a1",
45
+ rejectedBy: "quietHours",
46
+ reason: "quiet hours 22:00 to 08:00 Europe/Istanbul; priority normal is below the floor (high)",
47
+ surfaces: [],
48
+ trace: [
49
+ { id: "killSwitch", outcome: "pass", ms: 0.02 },
50
+ { id: "consent", outcome: "pass", ms: 0.01 },
51
+ { id: "enabled", outcome: "pass", ms: 0.01 },
52
+ { id: "mode", outcome: "pass", ms: 0.01 },
53
+ { id: "snooze", outcome: "pass", ms: 0.02 },
54
+ { id: "mute", outcome: "pass", ms: 0.01 },
55
+ { id: "intensity", outcome: "pass", ms: 0.02 },
56
+ { id: "quietHours", outcome: "reject", reason: "quiet hours 22:00 to 08:00 …", ms: 0.09 }
57
+ ],
58
+ evaluatedAt: 2026-09-04T03:00:00.000Z
59
+ }
60
+ ```
61
+
62
+ <p align="center"><img src="assets/trace.png" width="900" alt="Gerçek bir karar izi: sekiz kontrol çalıştı, sessiz saatler reddetti, her biri gerekçesi ve maliyetiyle"></p>
63
+
64
+ Tek kapı ve kayıtlı bir gerekçe ile "kullanıcıya bu neden söylenmedi" sorusunun bir cevabı
65
+ olur. Kontroller bir boru hattına dağılmışken dürüst cevap "bir yerde bir şey false döndü"
66
+ olurdu.
67
+
68
+ ## Kontroller, varsayılanın çalıştırdığı sırayla
69
+
70
+ | # | kontrol | ne zaman reddeder | not |
71
+ |---|---|---|---|
72
+ | 1 | `killSwitch(isOn)` | bayrağınız açıksa | üretim acil durdurma; her üreticiyi aynı anda susturur |
73
+ | 2 | `consent()` | `user.consent` false ise | her şeyden önce gelir, yoksa hiç rıza vermemiş biri için tercih değerlendirmiş olursunuz |
74
+ | 3 | `enabled()` | `user.proactiveEnabled === false` ise | profil başına anahtar |
75
+ | 4 | `mode({ allow })` | `user.mode` listede değilse | örneğin yalnızca `"normal"`, asla `"focus"` |
76
+ | 5 | `snooze()` | `user.snoozedUntil` gelecekteyse | genel duraklatma |
77
+ | 6 | `mute()` | `candidate.type`, `user.mutedTypes` içindeyse | tür bazlı susturma |
78
+ | 7 | `intensity()` | öncelik, kullanıcının yoğunluk tabanının altındaysa | low yalnızca high duyar, normal normal ve üstünü, high her şeyi |
79
+ | 8 | `quietHours({ priorityFloor })` | kullanıcının yerel sessiz penceresi içindeyse | IANA saat dilimi, pencere gece yarısını geçebilir, taban ve üstünde atlanır |
80
+ | 9 | `trustRamp({ days, minPriority })` | kullanıcı `days` günden yeniyse ve öncelik tabanın altındaysa | sistem, kullanıcı en az bağışlayıcıyken en az kalibredir |
81
+ | 10 | `dismissalCooldown({ dismissals, withinDays, silenceDays })` | kullanıcı o türü pencere içinde `dismissals` kez reddettiyse | `gate.record(user, candidate, "dismissed")` ile beslenir; her yeni ret sessizliği yeniden başlatır |
82
+ | 11 | `adaptiveTiming({ nextGoodMoment, surfacesFor })` | asla | reddetmez: `deliverAt` değerini taşır ya da yüzeyleri daraltır; `nonRejecting` işaretli bir kontrol istese de reddedemez |
83
+ | 12 | `dailyBudget({ limit, bypassPriority })` | kullanıcının yerel gün sayacı sınırdaysa | `evaluate` okur, `commit` atomik artırır ve yine de reddedebilir |
84
+
85
+ Sıra bir tasarım kararıdır ve görünür olmalıdır. Rıza her şeyden önce gelmelidir. Sessiz
86
+ saatler bütçeden önce gelmelidir, yoksa reddedilen bir aday hiç yapmadığı bir teslimi
87
+ tüketir. İstediğiniz gibi yeniden sıralayın; iz ne yaptığınızı gösterecektir.
88
+
89
+ ```ts
90
+ import { createGate, checks } from "proactive-gate";
91
+
92
+ const gate = createGate({
93
+ checks: [
94
+ checks.consent(),
95
+ checks.quietHours({ priorityFloor: "high" }),
96
+ checks.dailyBudget({ limit: 3, bypassPriority: "critical" }),
97
+ myOwnCheck, // { id, run(ctx) => pass | reject | adjust | skip }
98
+ ],
99
+ });
100
+ ```
101
+
102
+ ### Kendi kontrolünüzü yazmak
103
+
104
+ Bir kontrol, `id` ve `run` fonksiyonu olan bir nesnedir. Kullanıcıyı, adayı, saati, çözülmüş
105
+ önceliği, depoyu ve hâlâ masada olan yüzeyleri alır; `pass`, gerekçeli `reject`, `adjust` ya
106
+ da `skip` döndürür. İzde yerleşik kontroller gibi görünür.
107
+
108
+ ```ts
109
+ const weekendFloor = {
110
+ id: "weekendFloor",
111
+ run: ({ now, priority }) => {
112
+ const day = now.getUTCDay();
113
+ if ((day === 0 || day === 6) && priority !== "high" && priority !== "critical") {
114
+ return { kind: "reject", reason: "weekend: only high priority" };
115
+ }
116
+ return { kind: "pass" };
117
+ },
118
+ };
119
+ const gate = createGate({ checks: [checks.consent(), weekendFloor, checks.dailyBudget({ limit: 5 })] });
120
+ ```
121
+
122
+ Bir kontrol yalnızca zamanlamayı taşıyabiliyor ya da yüzeyleri daraltabiliyorsa
123
+ `nonRejecting: true` işaretleyin; kapı ondan gelen bir reddi yok sayar ve bunu izde söyler,
124
+ böylece bir zamanlama modelindeki hata bir kullanıcıyı susturamaz.
125
+
126
+ ## Bütçe evaluate'te değil, commit'te uygulanır
127
+
128
+ İki örnek aynı kullanıcı için aynı adayı değerlendirebilir, ikisi de beşte dördün kullanıldığını
129
+ görebilir ve ikisi de göndermeye karar verebilir. Bir sınırı yarış durumuna karşı güvenle
130
+ uygulayabileceğiniz tek yer, göndermeden hemen önceki atomik artırmadır:
131
+
132
+ ```ts
133
+ const decision = await gate.evaluate(input); // reads the counter
134
+ if (decision.allowed && await gate.commit(decision, input)) { // INCR, returns false on the sixth
135
+ await send(...);
136
+ }
137
+ ```
138
+
139
+ `RedisStore`, `INCR` kullanır ve günün TTL'sini ilk artırmada ekler. Sayaç kullanıcının yerel
140
+ gününe göre anahtarlanır, bu yüzden bütçe UTC'de değil kullanıcının gece yarısında sıfırlanır.
141
+
142
+ ## Bilerek açık başarısız olur
143
+
144
+ Depoya bağlı bir kontrol hata fırlattığında (Redis düştü), varsayılan adayı geçirir ve ize
145
+ `outcome: "skip", reason: "check threw (…); failing open"` yazar. Bir önbellek kesintisi, bütün
146
+ amacı konuşmak olan bir ürünün her kullanıcısını susturmamalıdır. Ürününüz sessiz kalmayı
147
+ tercih ediyorsa `onStoreError: "closed"` geçin; aynı hata, kontrolü adıyla anan bir ret olur.
148
+
149
+ ## Bir politikayı yayınlamadan önce bir günü yeniden oynatın
150
+
151
+ CLI, `{ user, candidate, now }` satırlarından oluşan bir JSONL dosyası alır ve bir politikanın
152
+ ne yapacağını raporlar. `--commit`, üretimde olduğu gibi bütçeyi sırayla tüketir.
153
+
154
+ ```
155
+ npx proactive-gate replay examples/day.jsonl --commit
156
+ ```
157
+
158
+ ```
159
+ 17 candidates · 7 allowed (41.2%) · 10 rejected
160
+
161
+ check rejected example
162
+ ---------------------------------------------------------------
163
+ intensity 3 priority low is below the "normal" intensity floor (normal)
164
+ consent 3 user has not consented to proactive behaviour
165
+ mode 2 operating mode "focus" does not allow proactive messages
166
+ quietHours 1 quiet hours 22:00 to 08:00 Europe/Istanbul; priority normal is below the floor (critical)
167
+ dailyBudget 1 daily budget of 5 used (5)
168
+ ```
169
+
170
+ `--policy examples/policy.js` kendi kapınızı yükler; `--json` bir not defteri için satır başına
171
+ tam bir karar basar. Bir haftalık gerçek adayı önerilen bir politikaya karşı oynatın; izin oranını
172
+ ve sessizlik nedenlerini tek bir kullanıcı öğrenmeden önce bilirsiniz.
173
+
174
+ ## Olandan öğrenmek
175
+
176
+ ```ts
177
+ await gate.record(user, candidate, "dismissed"); // feeds dismissalCooldown
178
+ await gate.record(user, candidate, "acted"); // recorded for you to extend
179
+ await gate.inspect(user); // { budgetUsed, dismissals }
180
+ ```
181
+
182
+ Sessizlik ölçülebilir olmalıdır, yoksa bahaneye dönüşür. Her kararı `onDecision` ile
183
+ kaydedin; izin oranı, en sık ret nedenleri ve izin verilenlerin reddedilme oranı, kapının
184
+ ayarlı olup olmadığını söyleyen üç sayıdır.
185
+
186
+ ## Bunu yapmaz
187
+
188
+ - Neyin söylenmeye değer olduğuna karar vermez. O üreten yarıdır ve modelinize ve ürününüze
189
+ aittir.
190
+ - Değeri dikkate karşı puanlamaz. `adaptiveTiming`, kullanıcının bir sonraki iyi anı için
191
+ kendi modelinize bir kancadır; paket böyle bir model içermez.
192
+ - Ürünler arasında koordinasyon yapmaz. Üç ajan her biri üçlük bir bütçeye uyarsa kullanıcı
193
+ yine dokuz alır. Ajanlar arası katman ayrı bir problemdir.
194
+ - Rıza hukukunun yerine geçmez. `consent()` sizin belirlediğiniz bir boolean'ı kontrol eder;
195
+ onu nasıl aldığınız size aittir.
196
+
197
+ ## Nereden geliyor
198
+
199
+ Bu, Şubat 2026'dan beri tek başıma geliştirdiğim proaktif asistan
200
+ [LILA](https://efe-genc-portfolio.vercel.app/projects/lila/)'nın teslim kapısıdır; çıkarılıp
201
+ framework'ten bağımsız hâle getirildi. On iki kontrolün sırası, güven rampası, otuzda üç
202
+ soğuması ve açık başarısız olan bütçe, üretimde verilmiş ve
203
+ [The hardest part of a proactive assistant is knowing when not to speak](https://efe-genc-portfolio.vercel.app/writing/knowing-when-not-to-speak/)
204
+ yazısında savunulmuş kararlardır. Tian Pan'ın
205
+ [bildirim bütçesi](https://tianpan.co/blog/2026-05-13-background-agents-notification-budget-attention-economy)
206
+ yazısı aynı davayı ürün tarafından savunur ve günde üç ile beş arası bir tavan önerir;
207
+ `defaultChecks({ dailyLimit })` varsayılanı beştir.
208
+
209
+ ## Geliştirme
210
+
211
+ ```
212
+ npm ci
213
+ npm test # tsc build, then node:test over dist/test
214
+ ```
215
+
216
+ MIT.
@@ -72,10 +72,16 @@ export declare function dailyBudget(options?: {
72
72
  bypassPriority?: Priority;
73
73
  }): BudgetCheck;
74
74
  export declare const budgetKey: (userId: string, now: Date, timezone?: string) => string;
75
+ export declare const weeklyBudgetKey: (userId: string, now: Date, timezone?: string) => string;
76
+ export declare function weeklyBudget(options?: {
77
+ limit?: number;
78
+ bypassPriority?: Priority;
79
+ }): BudgetCheck;
75
80
  /** The LILA order, as a starting point. Replace, reorder, or drop checks freely. */
76
81
  export declare function defaultChecks(options?: {
77
82
  killSwitch?: () => boolean | Promise<boolean>;
78
83
  modes?: string[];
79
84
  dailyLimit?: number;
85
+ weeklyLimit?: number;
80
86
  quietHoursFloor?: Priority;
81
87
  }): Check[];
@@ -211,6 +211,32 @@ export function dailyBudget(options = {}) {
211
211
  };
212
212
  }
213
213
  export const budgetKey = (userId, now, timezone) => `budget:${userId}:${timezone ? localClock(now, timezone).day : now.toISOString().slice(0, 10)}`;
214
+ const isoWeekKey = (day) => {
215
+ const date = new Date(`${day}T00:00:00Z`);
216
+ const weekday = date.getUTCDay() || 7;
217
+ date.setUTCDate(date.getUTCDate() + 4 - weekday);
218
+ const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
219
+ const week = Math.ceil((((date.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
220
+ return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
221
+ };
222
+ export const weeklyBudgetKey = (userId, now, timezone) => {
223
+ const day = timezone ? localClock(now, timezone).day : now.toISOString().slice(0, 10);
224
+ return `weeklyBudget:${userId}:${isoWeekKey(day)}`;
225
+ };
226
+ export function weeklyBudget(options = {}) {
227
+ const limit = options.limit ?? 20;
228
+ return {
229
+ id: "weeklyBudget",
230
+ limit,
231
+ async run({ user, now, store, priority }) {
232
+ if (options.bypassPriority && atLeast(priority, options.bypassPriority))
233
+ return pass;
234
+ const key = weeklyBudgetKey(user.id, now, user.timezone);
235
+ const used = Number((await store.get(key)) ?? 0);
236
+ return used < limit ? pass : reject(`weekly budget of ${limit} used (${used})`);
237
+ },
238
+ };
239
+ }
214
240
  /** The LILA order, as a starting point. Replace, reorder, or drop checks freely. */
215
241
  export function defaultChecks(options = {}) {
216
242
  return [
@@ -225,6 +251,7 @@ export function defaultChecks(options = {}) {
225
251
  trustRamp(),
226
252
  dismissalCooldown(),
227
253
  adaptiveTiming(),
254
+ ...(options.weeklyLimit === undefined ? [] : [weeklyBudget({ limit: options.weeklyLimit })]),
228
255
  dailyBudget({ limit: options.dailyLimit ?? 5 }),
229
256
  ];
230
257
  }
package/dist/src/gate.js CHANGED
@@ -1,4 +1,4 @@
1
- import { budgetKey, dismissalKey, DAY_SECONDS } from "./checks.js";
1
+ import { budgetKey, dismissalKey, weeklyBudgetKey, DAY_SECONDS } from "./checks.js";
2
2
  import { MemoryStore } from "./stores.js";
3
3
  class PrefixedStore {
4
4
  inner;
@@ -16,7 +16,7 @@ export function createGate(options) {
16
16
  const store = new PrefixedStore(options.store ?? new MemoryStore(), options.keyPrefix ?? "pg:");
17
17
  const onStoreError = options.onStoreError ?? "open";
18
18
  const checks = [...options.checks];
19
- const budgetCheck = checks.find((c) => c.id === "dailyBudget");
19
+ const budgetChecks = checks.filter((c) => c.id === "dailyBudget" || c.id === "weeklyBudget");
20
20
  const evaluate = async (input) => {
21
21
  const now = input.now ?? new Date();
22
22
  const priority = input.candidate.priority ?? "normal";
@@ -73,13 +73,20 @@ export function createGate(options) {
73
73
  const commit = async (decision, input) => {
74
74
  if (!decision.allowed)
75
75
  return false;
76
- if (!budgetCheck)
76
+ if (!budgetChecks.length)
77
77
  return true;
78
78
  const now = input.now ?? new Date();
79
- const limit = readLimit(budgetCheck);
80
79
  try {
81
- const used = await store.incr(budgetKey(input.user.id, now, input.user.timezone), 2 * DAY_SECONDS);
82
- return limit === undefined || used <= limit;
80
+ for (const check of budgetChecks) {
81
+ const key = check.id === "weeklyBudget"
82
+ ? weeklyBudgetKey(input.user.id, now, input.user.timezone)
83
+ : budgetKey(input.user.id, now, input.user.timezone);
84
+ const used = await store.incr(key, check.id === "weeklyBudget" ? 8 * DAY_SECONDS : 2 * DAY_SECONDS);
85
+ const limit = readLimit(check);
86
+ if (limit !== undefined && used > limit)
87
+ return false;
88
+ }
89
+ return true;
83
90
  }
84
91
  catch {
85
92
  return onStoreError === "open";
@@ -1,8 +1,8 @@
1
1
  export { createGate } from "./gate.js";
2
2
  export type { Gate } from "./gate.js";
3
- export { MemoryStore, RedisStore } from "./stores.js";
3
+ export { MemoryStore, RedisStore, SqliteStore } from "./stores.js";
4
4
  export type { RedisLike } from "./stores.js";
5
5
  export * as checks from "./checks.js";
6
- export { defaultChecks, localClock, inWindow, budgetKey, dismissalKey } from "./checks.js";
6
+ export { defaultChecks, localClock, inWindow, budgetKey, weeklyBudgetKey, dismissalKey } from "./checks.js";
7
7
  export { PRIORITY_RANK } from "./types.js";
8
8
  export type { Candidate, Check, CheckContext, CheckOutcome, Decision, EvaluateInput, GateOptions, OutcomeEvent, Priority, Store, Surface, TraceEntry, UserState, } from "./types.js";
package/dist/src/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { createGate } from "./gate.js";
2
- export { MemoryStore, RedisStore } from "./stores.js";
2
+ export { MemoryStore, RedisStore, SqliteStore } from "./stores.js";
3
3
  export * as checks from "./checks.js";
4
- export { defaultChecks, localClock, inWindow, budgetKey, dismissalKey } from "./checks.js";
4
+ export { defaultChecks, localClock, inWindow, budgetKey, weeklyBudgetKey, dismissalKey } from "./checks.js";
5
5
  export { PRIORITY_RANK } from "./types.js";
@@ -36,3 +36,14 @@ export declare class RedisStore implements Store {
36
36
  incr(key: string, ttlSeconds?: number): Promise<number>;
37
37
  del(key: string): Promise<void>;
38
38
  }
39
+ export declare class SqliteStore implements Store {
40
+ private readonly database;
41
+ private readonly clock;
42
+ constructor(path: string, clock?: () => number);
43
+ private live;
44
+ get(key: string): Promise<string | null>;
45
+ set(key: string, value: string, ttlSeconds?: number): Promise<void>;
46
+ incr(key: string, ttlSeconds?: number): Promise<number>;
47
+ del(key: string): Promise<void>;
48
+ close(): void;
49
+ }
@@ -1,3 +1,5 @@
1
+ import { createRequire } from "node:module";
2
+ const require = createRequire(import.meta.url);
1
3
  /** In-process store. Correct for one instance, wrong the moment you scale out. */
2
4
  export class MemoryStore {
3
5
  clock;
@@ -65,3 +67,52 @@ export class RedisStore {
65
67
  await this.client.del(key);
66
68
  }
67
69
  }
70
+ export class SqliteStore {
71
+ database;
72
+ clock;
73
+ constructor(path, clock = () => Date.now()) {
74
+ let DatabaseSync;
75
+ try {
76
+ ({ DatabaseSync } = require("node:sqlite"));
77
+ }
78
+ catch {
79
+ throw new Error("SqliteStore requires Node.js 22.5 or newer.");
80
+ }
81
+ this.database = new DatabaseSync(path);
82
+ this.clock = clock;
83
+ this.database.exec("CREATE TABLE IF NOT EXISTS proactive_gate_store (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL, expires_at INTEGER)");
84
+ }
85
+ live(key) {
86
+ const row = this.database.prepare("SELECT value, expires_at FROM proactive_gate_store WHERE key = ?").get(key);
87
+ if (!row)
88
+ return undefined;
89
+ if (row.expires_at !== null && row.expires_at <= this.clock()) {
90
+ this.database.prepare("DELETE FROM proactive_gate_store WHERE key = ?").run(key);
91
+ return undefined;
92
+ }
93
+ return { value: row.value, expiresAt: row.expires_at };
94
+ }
95
+ async get(key) {
96
+ return this.live(key)?.value ?? null;
97
+ }
98
+ async set(key, value, ttlSeconds) {
99
+ const expiresAt = ttlSeconds ? this.clock() + ttlSeconds * 1000 : null;
100
+ this.database
101
+ .prepare("INSERT INTO proactive_gate_store (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at")
102
+ .run(key, value, expiresAt);
103
+ }
104
+ async incr(key, ttlSeconds) {
105
+ const now = this.clock();
106
+ const expiresAt = ttlSeconds ? now + ttlSeconds * 1000 : null;
107
+ const row = this.database
108
+ .prepare("INSERT INTO proactive_gate_store (key, value, expires_at) VALUES (?, '1', ?) ON CONFLICT(key) DO UPDATE SET value = CASE WHEN proactive_gate_store.expires_at IS NOT NULL AND proactive_gate_store.expires_at <= ? THEN '1' ELSE CAST(CAST(proactive_gate_store.value AS INTEGER) + 1 AS TEXT) END, expires_at = CASE WHEN proactive_gate_store.expires_at IS NOT NULL AND proactive_gate_store.expires_at <= ? THEN excluded.expires_at ELSE proactive_gate_store.expires_at END RETURNING value")
109
+ .get(key, expiresAt, now, now);
110
+ return Number(row.value);
111
+ }
112
+ async del(key) {
113
+ this.database.prepare("DELETE FROM proactive_gate_store WHERE key = ?").run(key);
114
+ }
115
+ close() {
116
+ this.database.close();
117
+ }
118
+ }
@@ -1,6 +1,9 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { createGate, MemoryStore, defaultChecks, checks, localClock, inWindow } from "../src/index.js";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { createGate, MemoryStore, SqliteStore, defaultChecks, checks, localClock, inWindow } from "../src/index.js";
4
7
  import { replay, summarize } from "../src/cli.js";
5
8
  const user = (overrides = {}) => ({
6
9
  id: "u1",
@@ -134,6 +137,53 @@ test("daily budget: evaluate reads, commit consumes atomically and refuses the s
134
137
  assert.equal((await gate.evaluate({ ...input, now: nextLocalDay })).allowed, true);
135
138
  assert.equal((await gate.inspect(user(), noon)).budgetUsed, 5);
136
139
  });
140
+ test("weekly budget: resets on the user's local ISO week and commits atomically", async () => {
141
+ const store = new MemoryStore();
142
+ const gate = createGate({ store, checks: [checks.weeklyBudget({ limit: 2 })] });
143
+ const input = { user: user(), candidate: candidate(), now: new Date("2026-09-04T09:00:00Z") };
144
+ const first = await gate.evaluate(input);
145
+ const second = await gate.evaluate(input);
146
+ assert.equal(await gate.commit(first, input), true);
147
+ assert.equal(await gate.commit(second, input), true);
148
+ assert.equal((await gate.evaluate(input)).rejectedBy, "weeklyBudget");
149
+ const nextWeek = await gate.evaluate({ ...input, now: new Date("2026-09-07T09:00:00Z") });
150
+ assert.equal(nextWeek.allowed, true);
151
+ });
152
+ const sqliteAvailable = Number(process.versions.node.split(".")[0]) >= 22;
153
+ test("sqlite store supports get, set, increment, delete and expiration", { skip: !sqliteAvailable }, async () => {
154
+ let now = 1_000_000;
155
+ const store = new SqliteStore(":memory:", () => now);
156
+ assert.equal(await store.get("missing"), null);
157
+ await store.set("key", "value");
158
+ assert.equal(await store.get("key"), "value");
159
+ assert.equal(await store.incr("counter"), 1);
160
+ assert.equal(await store.incr("counter", 10), 2);
161
+ assert.equal(await store.get("counter"), "2");
162
+ await store.set("temporary", "value", 5);
163
+ assert.equal(await store.get("temporary"), "value");
164
+ now += 5000;
165
+ assert.equal(await store.get("temporary"), null);
166
+ await store.del("key");
167
+ assert.equal(await store.get("key"), null);
168
+ store.close();
169
+ });
170
+ test("sqlite store preserves values across database connections", { skip: !sqliteAvailable }, async () => {
171
+ const directory = mkdtempSync(join(tmpdir(), "proactive-gate-"));
172
+ const path = join(directory, "store.sqlite");
173
+ try {
174
+ const first = new SqliteStore(path);
175
+ await first.set("key", "value");
176
+ assert.equal(await first.incr("counter"), 1);
177
+ first.close();
178
+ const second = new SqliteStore(path);
179
+ assert.equal(await second.get("key"), "value");
180
+ assert.equal(await second.get("counter"), "1");
181
+ second.close();
182
+ }
183
+ finally {
184
+ rmSync(directory, { recursive: true, force: true });
185
+ }
186
+ });
137
187
  test("adaptive timing never rejects; it defers and can narrow surfaces", async () => {
138
188
  const later = new Date("2026-09-04T15:00:00Z");
139
189
  const gate = createGate({
@@ -190,3 +240,22 @@ test("replay summarises a day of candidates and consumes the budget in order", a
190
240
  assert.match(text, /quietHours/);
191
241
  assert.match(text, /consent/);
192
242
  });
243
+ test("a custom check is an ordinary object: it runs in order, reads the context, and shows in the trace", async () => {
244
+ const weekendsOnlyHigh = {
245
+ id: "weekendFloor",
246
+ run: ({ now, priority }) => {
247
+ const day = now.getUTCDay();
248
+ if ((day === 0 || day === 6) && priority !== "high" && priority !== "critical")
249
+ return { kind: "reject", reason: "weekend: only high priority" };
250
+ return { kind: "pass" };
251
+ },
252
+ };
253
+ const gate = createGate({ checks: [checks.consent(), weekendsOnlyHigh, checks.dailyBudget({ limit: 5 })] });
254
+ const saturday = new Date("2026-09-05T10:00:00Z");
255
+ const d = await gate.evaluate({ user: user(), candidate: candidate(), now: saturday });
256
+ assert.equal(d.rejectedBy, "weekendFloor");
257
+ assert.deepEqual(d.trace.map((t) => `${t.id}:${t.outcome}`), ["consent:pass", "weekendFloor:reject"]);
258
+ const monday = await gate.evaluate({ user: user(), candidate: candidate(), now: new Date("2026-09-07T10:00:00Z") });
259
+ assert.equal(monday.allowed, true);
260
+ assert.equal(monday.trace.length, 3);
261
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proactive-gate",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Decide whether a proactive AI agent may reach a user right now, and log why not. Ordered checks: kill switch, consent, quiet hours, trust ramp, dismissal cooldown, daily budget.",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -23,7 +23,9 @@
23
23
  "build": "tsc -p tsconfig.json",
24
24
  "test": "npm run build && node --test dist/test/gate.test.js",
25
25
  "lint": "tsc -p tsconfig.json --noEmit",
26
- "prepublishOnly": "npm test"
26
+ "prepublishOnly": "npm test",
27
+ "examples": "npm run build && node dist/src/cli.js replay examples/day.jsonl --policy examples/policy.js --commit",
28
+ "bench": "npm run build && node bench/evaluate.mjs"
27
29
  },
28
30
  "engines": {
29
31
  "node": ">=20"
@@ -47,7 +49,9 @@
47
49
  },
48
50
  "homepage": "https://github.com/Bubblegunn/proactive-gate#readme",
49
51
  "devDependencies": {
50
- "@types/node": "^22.15.0",
51
- "typescript": "^5.8.0"
52
+ "@arethetypeswrong/cli": "^0.18.5",
53
+ "@types/node": "^26.4.1",
54
+ "publint": "^0.3.24",
55
+ "typescript": "^7.0.2"
52
56
  }
53
57
  }