proactive-gate 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,10 +4,8 @@ English | [Türkçe](README.tr.md)
4
4
 
5
5
  <p>
6
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
7
  <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
8
  <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
9
  <img src="https://img.shields.io/badge/license-MIT-111111?style=flat-square" alt="MIT">
12
10
  <a href="https://doi.org/10.5281/zenodo.22393512"><img src="https://img.shields.io/badge/DOI-10.5281%2Fzenodo.22393512-111111?style=flat-square" alt="DOI"></a>
13
11
  </p>
@@ -49,6 +47,13 @@ preset you named, and prints the preset's own source next to the few lines that
49
47
  the gate into that framework. `npx proactive-gate init --list` shows the fourteen
50
48
  platform and legal presets and the four frameworks.
51
49
 
50
+ If you would rather see the argument than read it: `npm run bench:compare` replays a
51
+ committed day through this gate and through five hand-written `if` statements, and prints
52
+ the six places they disagree: a critical alert the cap should have let through, a two-day-old
53
+ account, a snooze, a run of dismissals, and a local day boundary that silences one user and
54
+ pays another twice. The output, and why none of the six is a matter of taste, is in
55
+ [Compared with hand-rolled checks](#compared-with-hand-rolled-checks-and-feature-flags).
56
+
52
57
  Zero dependencies. TypeScript. Node 20 or newer. Framework-agnostic: the gate sits
53
58
  between "the model produced something" and "the user's phone buzzed", whichever
54
59
  model or framework produced it. Examples: [`examples/vercel-ai-sdk.ts`](examples/vercel-ai-sdk.ts),
@@ -247,6 +252,29 @@ a hook that throws is routed to `error` and never changes the decision. `example
247
252
  turns them into one span per check. Every decision has an `id`, and `commit` is idempotent on
248
253
  it: a retry after a timeout does not consume a second unit.
249
254
 
255
+ ## A rejection reason a product manager can read
256
+
257
+ `decision.reason` is written for the engineer holding the trace. `explain(decision)` renders
258
+ the same decision as sentences for the person who decides whether the assistant is too chatty:
259
+
260
+ ```ts
261
+ import { explain } from "proactive-gate";
262
+
263
+ explain(decision).summary;
264
+ // "Held until 08:00 because the user's quiet hours run 22:00 to 08:00 Europe/Istanbul and
265
+ // normal priority is below the critical floor needed to override them."
266
+ ```
267
+
268
+ It answers "why did this go out at 22:30" too: an allowed decision explains itself, and
269
+ `explanation.checks` carries one sentence per check that ran, in order. The renderer is a
270
+ pure function of the decision; it invents nothing, and a check whose reason matches nothing
271
+ known is quoted verbatim rather than guessed. `language` is a parameter: English ships as
272
+ `en`, and any other language is a `Partial<Sentences>` in `catalogs`, merged over English so
273
+ a partial translation still renders. The Python sibling ships the same sentences, and CI
274
+ compares both renderings of every fixture decision on each push. It was contributed by
275
+ [@LouisDeconinck](https://github.com/LouisDeconinck) in
276
+ [#28](https://github.com/Bubblegunn/proactive-gate/pull/28), in TypeScript and Python together.
277
+
250
278
  ## Optional checks, fed by your own model
251
279
 
252
280
  Both ship off. They read numbers the caller puts on the candidate.
@@ -433,8 +461,12 @@ Set `windowSeconds` from your own transport's retry horizon.
433
461
  `MemoryStore` keeps values in process memory and is useful for a single instance. `RedisStore`
434
462
  shares values across instances. `SqliteStore` persists values in a SQLite database without
435
463
  adding a package dependency. It was contributed by
436
- [@aaqib-hafeez-khan-in](https://github.com/aaqib-hafeez-khan-in) in [#3](https://github.com/Bubblegunn/proactive-gate/pull/3). `SqliteStore` requires Node.js 22.5 or newer; the SQLite module
464
+ [@Aaqibhafeezkhan](https://github.com/Aaqibhafeezkhan) in [#3](https://github.com/Bubblegunn/proactive-gate/pull/3). `SqliteStore` requires Node.js 22.5 or newer; the SQLite module
437
465
  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.
466
+ Expired rows are removed when a read touches them, and every `set` or `incr` first removes the
467
+ rows that have already expired, so a key nobody reads again still disappears: a year of daily
468
+ budget keys leaves the live rows in the table rather than one dead row per day. The write-time sweep and its partial index were contributed by
469
+ [@LouisDeconinck](https://github.com/LouisDeconinck) in [#27](https://github.com/Bubblegunn/proactive-gate/pull/27).
438
470
 
439
471
  **Writing your own store?** `proactive-gate/store-contract` exports the same suite these three are
440
472
  held to, so you can prove yours behaves rather than hope:
@@ -448,7 +480,7 @@ It checks `get`, `set` and `del`, `incr` from an absent key, concurrent `incr` a
448
480
  expiry boundary, and that a TTL given to `set` and one given to `incr` agree, then replays a
449
481
  seeded random operation sequence against `MemoryStore`. A store whose backend owns the clock
450
482
  passes `expiry: "skip"` and those cases are reported as skipped rather than quietly dropped. It
451
- was contributed by [@aaqib-hafeez-khan-in](https://github.com/aaqib-hafeez-khan-in) in
483
+ was contributed by [@Aaqibhafeezkhan](https://github.com/Aaqibhafeezkhan) in
452
484
  [#24](https://github.com/Bubblegunn/proactive-gate/pull/24), and lives on its own subpath so
453
485
  importing the package never pulls `node:test` into your bundle. See
454
486
  [docs/store-contract.md](docs/store-contract.md).
@@ -563,6 +595,15 @@ candidates with the clock taken from each line, and `node examples/ai-sdk/run.mj
563
595
  day of tool-approval requests, one of which is a critical alert that a legal window (the TCPA
564
596
  preset) still refuses. Both are part of `npm run examples` and of the test suite.
565
597
 
598
+ The other four, the `.ts` files, are illustrations rather than fixtures: they import
599
+ `@langchain/langgraph`, `@mastra/core` and the AI SDK, none of which is a dependency here, so
600
+ they are neither compiled nor executed and this README does not claim they are. What *is*
601
+ checked is the half we control: `test/example-imports.test.mjs` asserts that every symbol they
602
+ import from `proactive-gate` still exists, as a value or as a type, so renaming an export
603
+ cannot leave a published snippet quietly telling readers to import something that is gone. It
604
+ cannot tell you a framework changed its own API, and installing four agent frameworks to
605
+ type-check four snippets is a worse trade than saying which files are executed.
606
+
566
607
  ## Python
567
608
 
568
609
  ```
@@ -737,13 +778,15 @@ trace, and adds the part they leave out: the budget consumed at send time.
737
778
  ## Thanks
738
779
 
739
780
  Two people sent pull requests on the day this was published, neither of whom I had spoken to
740
- before. [@aaqib-hafeez-khan-in](https://github.com/aaqib-hafeez-khan-in) wrote `SqliteStore`
781
+ before. [@Aaqibhafeezkhan](https://github.com/Aaqibhafeezkhan) wrote `SqliteStore`
741
782
  ([#3](https://github.com/Bubblegunn/proactive-gate/pull/3)) and
742
783
  [@edwardsong08](https://github.com/edwardsong08) wrote the weekly budget
743
784
  ([#9](https://github.com/Bubblegunn/proactive-gate/pull/9)). Both shipped in 0.1.2 and are in
744
- every release since, including the one you install today. @aaqib-hafeez-khan-in came back for a
785
+ every release since, including the one you install today. @Aaqibhafeezkhan came back for a
745
786
  second one and wrote the store contract suite in [#24](https://github.com/Bubblegunn/proactive-gate/pull/24).
746
787
 
788
+ A third person arrived from the other direction. [@LouisDeconinck](https://github.com/LouisDeconinck) took [#26](https://github.com/Bubblegunn/proactive-gate/issues/26), an issue this project filed against itself to admit that expired rows were never cleaned up, and twelve minutes later sent the fix in both languages with the test that would have caught the original bug ([#27](https://github.com/Bubblegunn/proactive-gate/pull/27), in 0.3.1). He then took [#23](https://github.com/Bubblegunn/proactive-gate/issues/23), the issue asking for a rejection reason a product manager could read, and wrote its sixty-seven sentence templates in both languages ([#28](https://github.com/Bubblegunn/proactive-gate/pull/28), in 0.4.0). That is the work in this library that is hardest to review and easiest to get wrong, because it lives in the wording rather than in the code.
789
+
747
790
  ## Cite this
748
791
 
749
792
  Every release is archived on Zenodo with a DOI, so a paper or a report can point at the
package/README.tr.md CHANGED
@@ -37,6 +37,14 @@ Sıfır bağımlılık. TypeScript. Node 20 ya da üstü. Framework'ten bağıms
37
37
  [bubblegunn.github.io/proactive-gate](https://bubblegunn.github.io/proactive-gate/). Python:
38
38
  [`python/`](python/README.md).
39
39
 
40
+ Okumak yerine görmeyi tercih ederseniz: `npm run bench:compare`, kayıtlı bir günü hem bu kapıdan
41
+ hem de elle yazılmış beş `if` ifadesinden geçirir ve ayrıştıkları altı yeri yazdırır: kotanın
42
+ geçirmesi gereken kritik bir uyarı, iki günlük bir hesap, bir erteleme, arka arkaya gelen
43
+ reddedişler, ve bir kullanıcıyı susturup bir diğerine iki kat mesaj veren yerel gün sınırı. Altısının da
44
+ neden zevk meselesi olmadığı, bu dosyanın kısaltılmış olması nedeniyle yalnızca İngilizce
45
+ README'de anlatılıyor: [Compared with hand-rolled
46
+ checks](README.md#compared-with-hand-rolled-checks-and-feature-flags).
47
+
40
48
  ## Bir karar neye benzer
41
49
 
42
50
  ```ts
@@ -201,6 +209,31 @@ fırlatan bir kanca `error` kancasına yönlendirilir ve kararı asla değiştir
201
209
  `commit` bu id üzerinde tekrarlanabilir: zaman aşımından sonraki bir yeniden deneme ikinci
202
210
  bir birim tüketmez.
203
211
 
212
+ ## Bir ürün yöneticisinin okuyabileceği ret gerekçesi
213
+
214
+ `decision.reason` izi elinde tutan mühendis için yazılmıştır. `explain(decision)` aynı kararı,
215
+ asistanın fazla konuşkan olup olmadığına karar veren kişi için cümlelere çevirir; aynı izden,
216
+ hiçbir şey eklemeden:
217
+
218
+ ```ts
219
+ import { explain } from "proactive-gate";
220
+
221
+ explain(decision).summary;
222
+ // "Held until 08:00 because the user's quiet hours run 22:00 to 08:00 Europe/Istanbul and
223
+ // normal priority is below the critical floor needed to override them."
224
+ ```
225
+
226
+ "Bu mesaj 22:30'da neden gitti" sorusunun da bir yanıtı olur: izin verilen karar da kendini
227
+ anlatır, `explanation.checks` ise çalışan her kontrol için sırayla bir cümle taşır. Renderer
228
+ kararın saf bir fonksiyonudur: saat okumaz, adaya da kullanıcıya da bakmaz, dolayısıyla
229
+ gate'in vermediği bir kararı anlatamaz. Hiçbir şablona uymayan bir gerekçe tahmin edilmez,
230
+ olduğu gibi alıntılanır ve söyleyen kontrolün adıyla verilir. `language` bir parametredir:
231
+ İngilizce `en` olarak gelir, başka bir dil `catalogs` içinde İngilizcenin üzerine birleşen bir
232
+ `Partial<Sentences>`'tır, böylece yarım bir çeviri de görüntülenir. Python kardeşi aynı
233
+ cümleleri yollar, CI her push'ta iki uygulamanın her fixture kararını karşılaştırır. Bu bölümü
234
+ [@LouisDeconinck](https://github.com/LouisDeconinck)
235
+ [#28](https://github.com/Bubblegunn/proactive-gate/pull/28) ile iki dilde birden yazdı.
236
+
204
237
  ## İsteğe bağlı, kendi modelinizin beslediği kontroller
205
238
 
206
239
  İkisi de kapalı gelir; adayın üzerine çağıranın koyduğu sayıları okurlar.
@@ -330,6 +363,16 @@ yazmadığı bir sayıyı kodlardı; o yüzden yok.
330
363
  Adaptörler framework paketine değil, çağrının biçimine göre tiplenmiştir; başka bir şey
331
364
  kurmak gerekmez. Her biri kapının gerekçesiyle reddeder ve onayda bütçeyi tüketir.
332
365
 
366
+ Örneklerden ikisi framework kurulu olmadan ve ağ olmadan çalışır: `node examples/mastra/run.mjs`
367
+ ve `node examples/ai-sdk/run.mjs`. İkisi de `npm run examples` ve test paketinin parçası.
368
+ Diğer dört `.ts` dosyası ise fikstür değil, örnekleme: `@langchain/langgraph`, `@mastra/core` ve
369
+ AI SDK'yı içe aktarıyorlar, hiçbiri buranın bağımlılığı değil, dolayısıyla ne derleniyor ne
370
+ çalıştırılıyorlar ve bu README onların çalıştığını iddia etmiyor. Denetlenen şey, bizim
371
+ denetleyebildiğimiz yarısı: `test/example-imports.test.mjs`, bu dosyaların `proactive-gate`'ten içe
372
+ aktardığı her sembolün değer ya da tip olarak hâlâ var olduğunu doğrular, böylece bir export'un adı
373
+ değiştiğinde yayımlanmış bir örnek okuyucuya artık var olmayan bir şeyi içe aktarmasını söylemeye
374
+ sessizce devam edemez.
375
+
333
376
  ## Python
334
377
 
335
378
  ```
@@ -337,8 +380,10 @@ pip install proactive-gate
337
380
  ```
338
381
 
339
382
  Yayınlanmamış bir durumu denemek için depodan kurulur: `pip install "proactive-gate @
340
- git+https://github.com/Bubblegunn/proactive-gate#subdirectory=python"`. Yayınlanan sürüm yerel bir
341
- derlemeden token ile yüklendi; npm paketinin aksine derleme kanıtı taşımıyor.
383
+ git+https://github.com/Bubblegunn/proactive-gate#subdirectory=python"`. Python paketi npm paketiyle
384
+ aynı workflow tarafından yayımlanıyor, dolayısıyla her dosyayı hangi deponun ve hangi workflow'un
385
+ ürettiğini adlandıran PyPI yayın attestation'ları taşıyor. 0.2.2 öncesi sürümler yerel bir
386
+ derlemeden token ile yüklendi ve hiçbir kanıt taşımıyor.
342
387
 
343
388
  `python/` sapan bir port değil, bir kardeştir: `spec/fixtures` altındaki her senaryoyu senkron
344
389
  `Gate` ve `AsyncGate` (Redis, `redis.asyncio` üzerinden) ile geçer; mypy strict, CI'da Python
@@ -0,0 +1,130 @@
1
+ /**
2
+ * explain(): a decision rendered as sentences a non-engineer can read.
3
+ *
4
+ * The renderer is a pure function of the decision. Every fact in a sentence
5
+ * comes from a trace entry's own reason or from a decision field, so it can
6
+ * only describe a decision the gate actually made; a check that never ran has
7
+ * no sentence. The machine reasons are the contract both implementations pin
8
+ * word for word, which is what makes them safe to read back here. When a
9
+ * reason does not match what a check emits, the sentence quotes it verbatim
10
+ * rather than guessing.
11
+ *
12
+ * `decision.reason` is unchanged: the plain sentence and the machine reason
13
+ * sit side by side and neither replaces the other.
14
+ */
15
+ import type { Decision, TraceEntry } from "./types.js";
16
+ /** Fields a parser pulled out of a machine reason; a template reads them. */
17
+ export type SentenceFacts = Record<string, string | undefined>;
18
+ export type SentenceTemplate = (facts: SentenceFacts) => string;
19
+ /**
20
+ * Every sentence the renderer can emit, as one flat template table. A new
21
+ * language is a `Partial<Sentences>` merged over English, so a partial
22
+ * translation still renders. Keys are `check.outcome` plus the summary,
23
+ * note, gate and fallback entries.
24
+ */
25
+ export interface Sentences {
26
+ /** A catalog may define keys beyond this table; explain() only reads the named ones. */
27
+ [key: string]: SentenceTemplate | undefined;
28
+ /** Whole-decision line for a reject or defer. `until` is set when the trace names the instant the hold lifts. */
29
+ "summary.held": SentenceTemplate;
30
+ /** Whole-decision line for an allowed decision. `notes` is pre-joined, leading semicolon included. */
31
+ "summary.allowed": SentenceTemplate;
32
+ /** Allowed with an empty trace. */
33
+ "summary.allowedEmpty": SentenceTemplate;
34
+ /** Not allowed but no stopping entry found; the decision was built by hand. */
35
+ "summary.heldUnknown": SentenceTemplate;
36
+ "note.deliverAt": SentenceTemplate;
37
+ "note.nearLimit": SentenceTemplate;
38
+ "note.shadowed": SentenceTemplate;
39
+ /** Wraps a shadowed stop: `body` is the clause the check would have produced. */
40
+ "entry.shadow": SentenceTemplate;
41
+ "killSwitch.pass": SentenceTemplate;
42
+ "killSwitch.reject": SentenceTemplate;
43
+ "consent.pass": SentenceTemplate;
44
+ "consent.reject": SentenceTemplate;
45
+ "enabled.pass": SentenceTemplate;
46
+ "enabled.reject": SentenceTemplate;
47
+ "mode.pass": SentenceTemplate;
48
+ "mode.reject": SentenceTemplate;
49
+ "snooze.pass": SentenceTemplate;
50
+ "snooze.reject": SentenceTemplate;
51
+ "mute.pass": SentenceTemplate;
52
+ "mute.reject": SentenceTemplate;
53
+ "intensity.pass": SentenceTemplate;
54
+ "intensity.reject": SentenceTemplate;
55
+ "quietHours.pass": SentenceTemplate;
56
+ "quietHours.reject": SentenceTemplate;
57
+ "quietHours.skip": SentenceTemplate;
58
+ "trustRamp.pass": SentenceTemplate;
59
+ "trustRamp.reject": SentenceTemplate;
60
+ "trustRamp.skip": SentenceTemplate;
61
+ "dismissalCooldown.pass": SentenceTemplate;
62
+ "dismissalCooldown.reject": SentenceTemplate;
63
+ "adaptiveTiming.pass": SentenceTemplate;
64
+ "adaptiveTiming.adjust": SentenceTemplate;
65
+ "dedupe.pass": SentenceTemplate;
66
+ "dedupe.reject": SentenceTemplate;
67
+ "dedupe.skip": SentenceTemplate;
68
+ "dailyBudget.pass": SentenceTemplate;
69
+ "dailyBudget.reject": SentenceTemplate;
70
+ "weeklyBudget.pass": SentenceTemplate;
71
+ "weeklyBudget.reject": SentenceTemplate;
72
+ "monthlyBudget.pass": SentenceTemplate;
73
+ "monthlyBudget.reject": SentenceTemplate;
74
+ "windowBudget.pass": SentenceTemplate;
75
+ "windowBudget.reject": SentenceTemplate;
76
+ "rateLimit.pass": SentenceTemplate;
77
+ "rateLimit.reject": SentenceTemplate;
78
+ "budget.pass": SentenceTemplate;
79
+ "budget.reject": SentenceTemplate;
80
+ "utilityFloor.pass": SentenceTemplate;
81
+ "utilityFloor.reject": SentenceTemplate;
82
+ "utilityFloor.skip": SentenceTemplate;
83
+ "boundedDeferral.pass": SentenceTemplate;
84
+ "boundedDeferral.adjust": SentenceTemplate;
85
+ "allowedWindow.pass": SentenceTemplate;
86
+ "allowedWindow.reject": SentenceTemplate;
87
+ "allowedWindow.skip": SentenceTemplate;
88
+ "requiresConsent.pass": SentenceTemplate;
89
+ "requiresConsent.reject": SentenceTemplate;
90
+ "requiresConsent.skip": SentenceTemplate;
91
+ "recentInteraction.pass": SentenceTemplate;
92
+ "recentInteraction.reject": SentenceTemplate;
93
+ /** The gate's own trace reasons, not any check's: store failures and ignored non-rejecting stops. */
94
+ "gate.failOpen": SentenceTemplate;
95
+ "gate.failClosed": SentenceTemplate;
96
+ "gate.nonRejecting": SentenceTemplate;
97
+ /** Unknown check ids and reasons that match nothing: quote the trace, invent nothing. */
98
+ "fallback.stop": SentenceTemplate;
99
+ "fallback.pass": SentenceTemplate;
100
+ "fallback.skip": SentenceTemplate;
101
+ "fallback.adjust": SentenceTemplate;
102
+ }
103
+ /** One trace entry rendered: which check, what it said, and the sentence for it. */
104
+ export interface CheckSentence {
105
+ id: string;
106
+ outcome: TraceEntry["outcome"];
107
+ shadow?: boolean;
108
+ sentence: string;
109
+ }
110
+ export interface Explanation {
111
+ /** The decision as one sentence: "held until 08:00 because ...", "allowed because ...". */
112
+ summary: string;
113
+ /** One sentence per check that ran, in the order it ran. */
114
+ checks: CheckSentence[];
115
+ }
116
+ export interface ExplainOptions {
117
+ /**
118
+ * Language of the sentences. Only "en" ships with the package; any other
119
+ * code needs a matching entry in `catalogs`, which is merged over English
120
+ * so a partial translation still renders.
121
+ */
122
+ language?: string;
123
+ catalogs?: Record<string, Partial<Sentences>>;
124
+ }
125
+ export declare const en: Sentences;
126
+ /**
127
+ * Render a decision as sentences. Deterministic: the same decision always
128
+ * produces the same explanation, and nothing outside the decision is read.
129
+ */
130
+ export declare function explain(decision: Decision, options?: ExplainOptions): Explanation;
@@ -0,0 +1,336 @@
1
+ /* English ---------------------------------------------------------------- */
2
+ const plural = (n, unit) => `${n} ${unit}${n === 1 ? "" : "s"}`;
3
+ /** "1d" to "within the last day", "24h" to "within the last 24 hours": the dedupe window, said long. */
4
+ const spanWords = (label) => {
5
+ const m = /^(\d+)([dhms])$/.exec(label);
6
+ if (!m)
7
+ return `within the last ${label}`;
8
+ const unit = { d: "day", h: "hour", m: "minute", s: "second" }[m[2]];
9
+ const n = Number(m[1]);
10
+ return n === 1 ? `within the last ${unit}` : `within the last ${plural(n, unit)}`;
11
+ };
12
+ /** "86400" to "day", "7200" to "2 hours": a rate-limit period in seconds, said long. */
13
+ const secondsWords = (text) => {
14
+ const n = Number(text);
15
+ if (!Number.isFinite(n))
16
+ return `${text} seconds`;
17
+ // "per hour", not "per 1 hour": the same singular spanWords already says as "the last day".
18
+ const period = (count, unit) => (count === 1 ? unit : plural(count, unit));
19
+ if (n % 86400 === 0)
20
+ return period(n / 86400, "day");
21
+ if (n % 3600 === 0)
22
+ return period(n / 3600, "hour");
23
+ if (n % 60 === 0)
24
+ return period(n / 60, "minute");
25
+ return period(n, "second");
26
+ };
27
+ /** The spend note is true only when the pass really read the counter: a bypassed budget returns a bare pass, a near-limit pass cannot be a bypass. */
28
+ const budgetPass = (label) => (f) => f.used !== undefined
29
+ ? `the ${label} had room, but only just: ${f.used} of ${f.limit} already used; the unit is spent when the message actually goes out`
30
+ : `the ${label} did not stop it`;
31
+ const budgetReject = (label) => (f) => `the ${label} of ${f.limit} was already spent (${f.used} used)`;
32
+ export const en = {
33
+ "summary.held": (f) => `held${f.until ? ` until ${f.until}` : ""} because ${f.clause}`,
34
+ "summary.allowed": (f) => `allowed at ${f.at} because no check stopped it${f.notes ?? ""}`,
35
+ "summary.allowedEmpty": () => "allowed; no checks ran",
36
+ "summary.heldUnknown": () => "held; the trace does not name the check that stopped it",
37
+ "note.deliverAt": (f) => `delivery waits until ${f.at}`,
38
+ "note.nearLimit": (f) => `${f.check} was close to its limit (${f.used} of ${f.limit} used)`,
39
+ "note.shadowed": (f) => `${f.check} would have stopped it but ran in shadow mode`,
40
+ "entry.shadow": (f) => `it would have stopped the message (${f.body}), but the check ran in shadow mode so evaluation continued`,
41
+ "killSwitch.pass": () => "the kill switch was off",
42
+ "killSwitch.reject": () => "the kill switch is on, which stops every message",
43
+ "consent.pass": () => "the user has agreed to proactive messages",
44
+ "consent.reject": () => "the user has not agreed to proactive messages",
45
+ "enabled.pass": () => "proactive messages are switched on for this profile",
46
+ "enabled.reject": () => "proactive messages are switched off on this user's profile",
47
+ "mode.pass": () => "the user's operating mode did not block it",
48
+ "mode.reject": (f) => `the user's operating mode is "${f.mode}", which does not allow proactive messages`,
49
+ "snooze.pass": () => "no snooze was in effect",
50
+ "snooze.reject": (f) => `the user has snoozed the assistant until ${f.until}`,
51
+ "mute.pass": () => "this type of message is not muted",
52
+ "mute.reject": (f) => `the user has muted "${f.type}" messages`,
53
+ "intensity.pass": () => "the message's priority satisfied the user's intensity setting",
54
+ "intensity.reject": (f) => `the message was ${f.priority} priority, and the user's "${f.level}" intensity setting allows only ${f.floor} and above`,
55
+ "quietHours.pass": () => "quiet hours did not block it",
56
+ "quietHours.reject": (f) => `the user's quiet hours run ${f.start} to ${f.end} ${f.tz}${f.from ? `, a window belonging to ${f.from},` : ""} and ${f.priority} priority is below the ${f.floor} floor needed to override them`,
57
+ "quietHours.skip": () => "the user has quiet hours but no time zone, so the check could not run",
58
+ "trustRamp.pass": () => "the new-user trust period did not block it",
59
+ "trustRamp.reject": (f) => `the user is on day ${f.day} of a ${f.days}-day trust period for new users, which allows only ${f.floor} priority and above, and the message was ${f.priority}`,
60
+ "trustRamp.skip": () => "the user's sign-up date is not on record, so the new-user trust period could not be checked",
61
+ "dismissalCooldown.pass": () => "the user has not dismissed this type enough to silence it",
62
+ "dismissalCooldown.reject": (f) => `the user has dismissed "${f.type}" messages ${f.count} times in ${f.within} days, so this type stays silent until ${f.until}`,
63
+ "adaptiveTiming.pass": () => "the timing check left the message as it was",
64
+ "adaptiveTiming.adjust": (f) => f.at && f.surfaces
65
+ ? `delivery was moved to ${f.at} and narrowed to ${f.surfaces.split(",").join(", ")}`
66
+ : f.at
67
+ ? `delivery was moved to ${f.at}`
68
+ : `delivery was narrowed to ${(f.surfaces ?? "").split(",").join(", ")}`,
69
+ "dedupe.pass": () => "this event had not already produced a message; the event is claimed when the message actually goes out",
70
+ "dedupe.reject": (f) => `the same event already produced a message ${spanWords(f.window ?? "")}`,
71
+ "dedupe.skip": () => "the candidate carried no event key, so duplicate detection could not run",
72
+ "dailyBudget.pass": budgetPass("daily budget"),
73
+ "dailyBudget.reject": budgetReject("user's daily budget"),
74
+ "weeklyBudget.pass": budgetPass("weekly budget"),
75
+ "weeklyBudget.reject": budgetReject("user's weekly budget"),
76
+ "monthlyBudget.pass": budgetPass("monthly budget"),
77
+ "monthlyBudget.reject": budgetReject("user's monthly budget"),
78
+ "windowBudget.pass": budgetPass("window budget"),
79
+ "windowBudget.reject": (f) => `the budget for the window opened by the user's last message was already spent (${f.used} of ${f.limit} used)`,
80
+ "rateLimit.pass": () => "the rate limit did not stop it",
81
+ "rateLimit.reject": (f) => `the rate limit of ${f.limit} messages per ${secondsWords(f.per ?? "")} was already reached (${f.used} used)`,
82
+ "budget.pass": (f) => `the budget had room, but only just: ${f.used} of ${f.limit} already used; the unit is spent when the message actually goes out`,
83
+ "budget.reject": (f) => `the ${f.label} was already used up (${f.used} of ${f.limit} used)`,
84
+ "utilityFloor.pass": () => "the estimated acceptance chance cleared the utility floor",
85
+ "utilityFloor.reject": (f) => `the estimated chance the user would accept this message was ${f.pAccept}, below the utility floor of ${f.tau}`,
86
+ "utilityFloor.skip": () => "the candidate carried no acceptance estimate, so the utility floor could not run",
87
+ "boundedDeferral.pass": () => "the user did not look busy",
88
+ "boundedDeferral.adjust": (f) => `the user looked busy, so delivery was deferred ${f.tStar} seconds to ${f.at}`,
89
+ "allowedWindow.pass": (f) => (f.name ? `the "${f.name}" allowed window did not block it` : "the allowed window did not block it"),
90
+ "allowedWindow.reject": (f) => `messages may only go out between ${f.start} and ${f.end} (${f.zone}), and the local time was outside that window`,
91
+ "allowedWindow.skip": () => "the user has no time zone, so the allowed window could not be checked",
92
+ "requiresConsent.pass": (f) => (f.name ? `the "${f.name}" consent the check needs was in place` : "the consent the check needs was in place"),
93
+ "requiresConsent.reject": (f) => `the user has not given the required "${f.name}" consent${f.start ? `, which applies between ${f.start} and ${f.end}` : ""}`,
94
+ "requiresConsent.skip": () => "the user has no time zone, so the hours this consent applies could not be checked",
95
+ "recentInteraction.pass": () => "the user had written to the assistant recently enough",
96
+ "recentInteraction.reject": (f) => f.age ? `the user's last message to the assistant was ${f.age} h ago, outside the ${f.within} h window` : "the user has never written to the assistant, and this rule allows messages only after they do",
97
+ "gate.failOpen": (f) => `the "${f.id}" check failed with "${f.error}", and the gate is set to let messages through when a check fails`,
98
+ "gate.failClosed": (f) => `the "${f.id}" check failed with "${f.error}", and the gate is set to stop messages when a check fails`,
99
+ "gate.nonRejecting": (f) => `the "${f.id}" check tried to ${f.kind} ("${f.reason}") but is marked non-rejecting, so the gate ignored it`,
100
+ "fallback.stop": (f) => `the "${f.id}" check stopped it: ${f.reason}`,
101
+ "fallback.pass": (f) => `the "${f.id}" check let it through${f.reason ? ` (${f.reason})` : ""}`,
102
+ "fallback.skip": (f) => `the "${f.id}" check did not weigh in: ${f.reason}`,
103
+ "fallback.adjust": (f) => `the "${f.id}" check adjusted it: ${f.reason}`,
104
+ };
105
+ const match = (pattern, keys) => (reason) => {
106
+ const m = pattern.exec(reason);
107
+ if (!m)
108
+ return null;
109
+ const facts = {};
110
+ keys.forEach((key, i) => {
111
+ const value = m[i + 1];
112
+ if (value !== undefined)
113
+ facts[key] = value;
114
+ });
115
+ return facts;
116
+ };
117
+ const fixed = (text) => (reason) => (reason === text ? {} : null);
118
+ const first = (...parsers) => (reason) => {
119
+ for (const parse of parsers) {
120
+ const facts = parse(reason);
121
+ if (facts)
122
+ return facts;
123
+ }
124
+ return null;
125
+ };
126
+ const BUDGET_STOP = match(/^.* of (\d+) used \((\d+)\)$/, ["limit", "used"]);
127
+ const BUDGET_NEAR = match(/^(\d+) of (\d+) used$/, ["used", "limit"]);
128
+ const parseQuietHours = (reason) => {
129
+ const m = /^quiet hours (\S+) to (\S+)(?: \((\w+) (\S+)\))? (\S+); priority (\w+) is below the floor \((\w+)\)$/.exec(reason);
130
+ if (!m)
131
+ return null;
132
+ // holdUntil is the fact the summary headlines ("held until 08:00"); checks
133
+ // whose clause already says the instant (snooze, cooldown) leave it unset.
134
+ const facts = { start: m[1], end: m[2], tz: m[5], priority: m[6], floor: m[7], holdUntil: m[2] };
135
+ if (m[3] !== undefined)
136
+ facts.from = `${m[3]} ${m[4]}`;
137
+ return facts;
138
+ };
139
+ const PARSERS = {
140
+ killSwitch: { stop: fixed("engine kill switch is on") },
141
+ consent: { stop: fixed("user has not consented to proactive behaviour") },
142
+ enabled: { stop: fixed("proactive behaviour is disabled on this profile") },
143
+ mode: { stop: match(/^operating mode "(.+)" does not allow proactive messages$/, ["mode"]) },
144
+ snooze: { stop: match(/^snoozed until (.+)$/, ["until"]) },
145
+ mute: { stop: match(/^type "(.+)" is muted by the user$/, ["type"]) },
146
+ intensity: { stop: match(/^priority (\w+) is below the "(\w+)" intensity floor \((\w+)\)$/, ["priority", "level", "floor"]) },
147
+ quietHours: { stop: parseQuietHours, skip: fixed("quiet hours set but no timezone on the user; cannot evaluate") },
148
+ trustRamp: { stop: match(/^trust ramp: day (\d+) of (\S+), priority (\w+) is below (\w+)$/, ["day", "days", "priority", "floor"]), skip: fixed("no createdAt on the user; ramp cannot be evaluated") },
149
+ dismissalCooldown: { stop: match(/^(\d+) dismissals of "(.+)" in (\S+) days; silent until (.+)$/, ["count", "type", "within", "until"]) },
150
+ adaptiveTiming: { adjust: first(match(/^deliver at (\S+); surfaces (.+)$/, ["at", "surfaces"]), match(/^deliver at (\S+)$/, ["at"]), match(/^surfaces (.+)$/, ["surfaces"])) },
151
+ dedupe: { stop: match(/^already delivered within the last (.+)$/, ["window"]), skip: fixed("no dedupeKey on the candidate; deduplication cannot be evaluated") },
152
+ dailyBudget: { stop: BUDGET_STOP, passReason: BUDGET_NEAR },
153
+ weeklyBudget: { stop: BUDGET_STOP, passReason: BUDGET_NEAR },
154
+ monthlyBudget: { stop: BUDGET_STOP, passReason: BUDGET_NEAR },
155
+ windowBudget: { stop: BUDGET_STOP, passReason: BUDGET_NEAR },
156
+ rateLimit: { stop: match(/^rate limit (\d+) per (\d+) s of \d+ used \((\d+)\)$/, ["limit", "per", "used"]), passReason: BUDGET_NEAR },
157
+ utilityFloor: { stop: match(/^pAccept (\S+) < tau (\S+)$/, ["pAccept", "tau"]), skip: fixed("no pAccept on the candidate; utility floor cannot be evaluated") },
158
+ boundedDeferral: { adjust: match(/^user busy; deliver at (\S+) \(t\* (\d+) s\)$/, ["at", "tStar"]) },
159
+ allowedWindow: {
160
+ stop: match(/^outside the allowed window (\S+) to (\S+) (.+)$/, ["start", "end", "zone"]),
161
+ idFacts: (id) => (id.startsWith("window:") ? { name: id.slice("window:".length) } : {}),
162
+ skip: fixed("no timezone on the user; window cannot be evaluated"),
163
+ },
164
+ requiresConsent: {
165
+ stop: match(/^consent "(.+)" is missing(?: \(required (\S+) to (\S+)\))?$/, ["name", "start", "end"]),
166
+ idFacts: (id) => (id.startsWith("consent:") ? { name: id.slice("consent:".length) } : {}),
167
+ skip: fixed("no timezone on the user; consent window cannot be evaluated"),
168
+ },
169
+ recentInteraction: {
170
+ stop: first(fixed("no inbound message from the user on record"), match(/^last inbound message (\d+) h ago, window is (\S+) h$/, ["age", "within"])),
171
+ },
172
+ };
173
+ /** The ids the package itself emits: the fixed check ids, plus consent:<name>, rate:<limit>/<period>s and window:<name>. */
174
+ const keyForId = (id) => {
175
+ if (id in PARSERS)
176
+ return id;
177
+ if (id.startsWith("consent:"))
178
+ return "requiresConsent";
179
+ if (id.startsWith("rate:"))
180
+ return "rateLimit";
181
+ if (id.startsWith("window:"))
182
+ return "allowedWindow";
183
+ return undefined;
184
+ };
185
+ /** The gate's own trace entries: a store failure, or a non-rejecting check that tried to stop evaluation anyway. */
186
+ const GATE_ENTRIES = [
187
+ { key: "gate.failOpen", parse: match(/^check threw \((.*)\); failing open$/, ["error"]) },
188
+ { key: "gate.failClosed", parse: match(/^check threw \((.*)\); failing closed$/, ["error"]) },
189
+ { key: "gate.nonRejecting", parse: match(/^non-rejecting check returned (\w+) \((.*)\); ignored$/, ["kind", "reason"]) },
190
+ ];
191
+ /**
192
+ * Custom ids (a preset's "window:kakao", a caller's own wrapper) still emit the
193
+ * check's reason, so an unmatched id falls back to recognizing the reason's
194
+ * shape. Order is distinctiveness: each pattern names the check it sounds like.
195
+ */
196
+ const STOP_SCAN = ["quietHours", "allowedWindow", "requiresConsent", "rateLimit", "dismissalCooldown", "snooze", "mute", "mode", "intensity", "trustRamp", "dedupe", "utilityFloor", "recentInteraction", "killSwitch", "consent", "enabled"];
197
+ const SKIP_SCAN = ["quietHours", "trustRamp", "dedupe", "utilityFloor", "allowedWindow", "requiresConsent"];
198
+ const ADJUST_SCAN = ["adaptiveTiming", "boundedDeferral"];
199
+ const BUDGET_LABELS = { "daily budget": "dailyBudget", "weekly budget": "weeklyBudget", "monthly budget": "monthlyBudget", "window budget": "windowBudget" };
200
+ /** A "label of N used (M)" reason from a budget whose id we do not know. */
201
+ const scanBudget = (reason) => {
202
+ const m = /^(.+) of (\d+) used \((\d+)\)$/.exec(reason);
203
+ if (!m)
204
+ return null;
205
+ const known = BUDGET_LABELS[m[1]];
206
+ const facts = { label: m[1], limit: m[2], used: m[3] };
207
+ return { key: known ?? "budget", facts };
208
+ };
209
+ const cap = (text) => (text ? text[0].toUpperCase() + text.slice(1) : text);
210
+ const sentenceOf = (fragment) => `${cap(fragment)}.`;
211
+ const t = (s, key, facts) => s[key]?.(facts) ?? en[key]?.(facts) ?? facts.reason ?? "";
212
+ /** The stopping entry's clause and, when the trace names it, the instant the hold lifts. */
213
+ function stopFacts(entry) {
214
+ const reason = entry.reason ?? "";
215
+ for (const g of GATE_ENTRIES) {
216
+ const facts = g.parse(reason);
217
+ if (facts)
218
+ return { template: g.key, facts: { id: entry.id, ...facts } };
219
+ }
220
+ const key = keyForId(entry.id);
221
+ if (key && PARSERS[key]?.stop) {
222
+ const facts = PARSERS[key].stop(reason);
223
+ if (facts)
224
+ return { template: `${key}.reject`, facts };
225
+ }
226
+ for (const k of STOP_SCAN) {
227
+ const facts = PARSERS[k]?.stop?.(reason);
228
+ if (facts)
229
+ return { template: `${k}.reject`, facts };
230
+ }
231
+ const budget = scanBudget(reason);
232
+ if (budget)
233
+ return { template: `${budget.key}.reject`, facts: budget.facts };
234
+ return { template: "fallback.stop", facts: { id: entry.id, reason } };
235
+ }
236
+ function entryBody(entry, s) {
237
+ const reason = entry.reason ?? "";
238
+ const key = keyForId(entry.id);
239
+ switch (entry.outcome) {
240
+ case "reject":
241
+ case "defer": {
242
+ const stop = stopFacts(entry);
243
+ return t(s, stop.template, stop.facts);
244
+ }
245
+ case "pass": {
246
+ if (reason) {
247
+ const facts = key ? PARSERS[key]?.passReason?.(reason) : BUDGET_NEAR(reason);
248
+ if (facts)
249
+ return t(s, `${key ?? "budget"}.pass`, facts);
250
+ return t(s, "fallback.pass", { id: entry.id, reason });
251
+ }
252
+ const facts = key ? (PARSERS[key]?.idFacts?.(entry.id) ?? {}) : {};
253
+ return t(s, key ? `${key}.pass` : "fallback.pass", { id: entry.id, ...facts });
254
+ }
255
+ case "skip": {
256
+ for (const g of GATE_ENTRIES) {
257
+ const facts = g.parse(reason);
258
+ if (facts)
259
+ return t(s, g.key, { id: entry.id, ...facts });
260
+ }
261
+ if (key && PARSERS[key]?.skip) {
262
+ const facts = PARSERS[key].skip(reason);
263
+ if (facts)
264
+ return t(s, `${key}.skip`, facts);
265
+ }
266
+ for (const k of SKIP_SCAN) {
267
+ const facts = PARSERS[k]?.skip?.(reason);
268
+ if (facts)
269
+ return t(s, `${k}.skip`, facts);
270
+ }
271
+ return t(s, "fallback.skip", { id: entry.id, reason });
272
+ }
273
+ case "adjust": {
274
+ if (key && PARSERS[key]?.adjust) {
275
+ const facts = PARSERS[key].adjust(reason);
276
+ if (facts)
277
+ return t(s, `${key}.adjust`, facts);
278
+ }
279
+ for (const k of ADJUST_SCAN) {
280
+ const facts = PARSERS[k]?.adjust?.(reason);
281
+ if (facts)
282
+ return t(s, `${k}.adjust`, facts);
283
+ }
284
+ return t(s, "fallback.adjust", { id: entry.id, reason });
285
+ }
286
+ }
287
+ }
288
+ function explainEntry(entry, s) {
289
+ const body = entryBody(entry, s);
290
+ // Shadow is only a would-have-stopped when the outcome could have stopped.
291
+ const shadowedStop = entry.shadow === true && (entry.outcome === "reject" || entry.outcome === "defer");
292
+ return {
293
+ id: entry.id,
294
+ outcome: entry.outcome,
295
+ ...(entry.shadow ? { shadow: true } : {}),
296
+ sentence: sentenceOf(shadowedStop ? t(s, "entry.shadow", { body }) : body),
297
+ };
298
+ }
299
+ function summarize(decision, s) {
300
+ if (decision.allowed) {
301
+ if (!decision.trace.length)
302
+ return sentenceOf(t(s, "summary.allowedEmpty", {}));
303
+ const notes = [];
304
+ if (decision.deliverAt)
305
+ notes.push(t(s, "note.deliverAt", { at: decision.deliverAt.toISOString() }));
306
+ for (const n of decision.nearLimit)
307
+ notes.push(t(s, "note.nearLimit", { check: n.check, used: String(n.used), limit: String(n.limit) }));
308
+ for (const id of decision.shadowed)
309
+ notes.push(t(s, "note.shadowed", { check: id }));
310
+ return sentenceOf(t(s, "summary.allowed", { at: decision.evaluatedAt.toISOString(), notes: notes.length ? `; ${notes.join("; ")}` : "" }));
311
+ }
312
+ const stopId = decision.rejectedBy ?? decision.deferredBy;
313
+ const entry = stopId ? [...decision.trace].reverse().find((e) => e.id === stopId && (e.outcome === "reject" || e.outcome === "defer")) : undefined;
314
+ if (!entry)
315
+ return sentenceOf(t(s, "summary.heldUnknown", {}));
316
+ const stop = stopFacts(entry);
317
+ const clause = t(s, stop.template, stop.facts);
318
+ // For a deferral the hold ends at retryAt; when the clause already names the
319
+ // instant (snooze's reason is "snoozed until X"), naming it twice reads worse.
320
+ const clauseSaysWhen = stop.facts.until !== undefined;
321
+ const until = stop.facts.holdUntil ?? (!clauseSaysWhen && decision.deferredBy ? decision.retryAt?.toISOString() : undefined);
322
+ return sentenceOf(t(s, "summary.held", { clause, ...(until ? { until } : {}) }));
323
+ }
324
+ /**
325
+ * Render a decision as sentences. Deterministic: the same decision always
326
+ * produces the same explanation, and nothing outside the decision is read.
327
+ */
328
+ export function explain(decision, options = {}) {
329
+ const language = options.language ?? "en";
330
+ const overlay = options.catalogs?.[language];
331
+ if (language !== "en" && !overlay) {
332
+ throw new Error(`no sentence catalog for language "${language}"; pass one with the catalogs option`);
333
+ }
334
+ const s = { ...en, ...overlay };
335
+ return { summary: summarize(decision, s), checks: decision.trace.map((entry) => explainEntry(entry, s)) };
336
+ }
@@ -1,5 +1,7 @@
1
1
  export { createGate } from "./gate.js";
2
2
  export type { Gate, PolicyGateOptions } from "./gate.js";
3
+ export { explain, en } from "./explain.js";
4
+ export type { CheckSentence, Explanation, ExplainOptions, SentenceFacts, Sentences, SentenceTemplate } from "./explain.js";
3
5
  export { compilePolicy, KNOWN_CHECKS } from "./policy.js";
4
6
  export { presets } from "./presets.js";
5
7
  export type { Preset } from "./presets.js";
package/dist/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { createGate } from "./gate.js";
2
+ export { explain, en } from "./explain.js";
2
3
  export { compilePolicy, KNOWN_CHECKS } from "./policy.js";
3
4
  export { presets } from "./presets.js";
4
5
  export { MemoryStore, RedisStore, SqliteStore } from "./stores.js";
@@ -40,10 +40,17 @@ export declare class SqliteStore implements Store {
40
40
  private readonly database;
41
41
  private readonly clock;
42
42
  constructor(path: string, clock?: () => number);
43
+ /**
44
+ * A read prunes only the key it touches; a write first clears every row that
45
+ * has already expired, so a key nobody reads again does not live forever.
46
+ */
47
+ private sweep;
43
48
  private live;
44
49
  get(key: string): Promise<string | null>;
45
50
  set(key: string, value: string, ttlSeconds?: number): Promise<void>;
46
51
  incr(key: string, ttlSeconds?: number): Promise<number>;
47
52
  del(key: string): Promise<void>;
53
+ /** Test helper. */
54
+ size(): number;
48
55
  close(): void;
49
56
  }
@@ -78,6 +78,15 @@ export class SqliteStore {
78
78
  this.database = new DatabaseSync(path);
79
79
  this.clock = clock;
80
80
  this.database.exec("CREATE TABLE IF NOT EXISTS proactive_gate_store (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL, expires_at INTEGER)");
81
+ // The partial index keeps a sweep proportional to the dead rows instead of a table scan.
82
+ this.database.exec("CREATE INDEX IF NOT EXISTS proactive_gate_store_expires_at ON proactive_gate_store (expires_at) WHERE expires_at IS NOT NULL");
83
+ }
84
+ /**
85
+ * A read prunes only the key it touches; a write first clears every row that
86
+ * has already expired, so a key nobody reads again does not live forever.
87
+ */
88
+ sweep(now) {
89
+ this.database.prepare("DELETE FROM proactive_gate_store WHERE expires_at IS NOT NULL AND expires_at <= ?").run(now);
81
90
  }
82
91
  live(key) {
83
92
  const row = this.database.prepare("SELECT value, expires_at FROM proactive_gate_store WHERE key = ?").get(key);
@@ -93,7 +102,9 @@ export class SqliteStore {
93
102
  return this.live(key)?.value ?? null;
94
103
  }
95
104
  async set(key, value, ttlSeconds) {
96
- const expiresAt = ttlSeconds ? this.clock() + ttlSeconds * 1000 : null;
105
+ const now = this.clock();
106
+ const expiresAt = ttlSeconds ? now + ttlSeconds * 1000 : null;
107
+ this.sweep(now);
97
108
  this.database
98
109
  .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")
99
110
  .run(key, value, expiresAt);
@@ -101,6 +112,7 @@ export class SqliteStore {
101
112
  async incr(key, ttlSeconds) {
102
113
  const now = this.clock();
103
114
  const expiresAt = ttlSeconds ? now + ttlSeconds * 1000 : null;
115
+ this.sweep(now);
104
116
  const row = this.database
105
117
  .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")
106
118
  .get(key, expiresAt, now, now);
@@ -109,6 +121,11 @@ export class SqliteStore {
109
121
  async del(key) {
110
122
  this.database.prepare("DELETE FROM proactive_gate_store WHERE key = ?").run(key);
111
123
  }
124
+ /** Test helper. */
125
+ size() {
126
+ const row = this.database.prepare("SELECT COUNT(*) AS n FROM proactive_gate_store").get();
127
+ return row.n;
128
+ }
112
129
  close() {
113
130
  this.database.close();
114
131
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proactive-gate",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Decide whether a proactive AI agent may reach a user right now, and log why not. Ordered checks as code or JSON, a conformance spec, presets for platform and legal limits, adapters for AI SDK, Mastra, LangChain and OpenAI Agents, and a Python sibling.",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -48,7 +48,7 @@
48
48
  "sideEffects": false,
49
49
  "scripts": {
50
50
  "build": "tsc -p tsconfig.json",
51
- "test": "npm run build && node test/spec-lint.mjs && node --test dist/test/gate.test.js dist/test/conformance.test.js dist/test/presets.test.js dist/test/adapters.test.js dist/test/init.test.js dist/test/properties.test.js dist/test/dedupe.test.js test/release.test.mjs test/examples.test.mjs test/naive.test.mjs test/suite.test.mjs",
51
+ "test": "npm run build && node test/spec-lint.mjs && node --test dist/test/gate.test.js dist/test/conformance.test.js dist/test/presets.test.js dist/test/adapters.test.js dist/test/init.test.js dist/test/properties.test.js dist/test/dedupe.test.js dist/test/explain.test.js test/release.test.mjs test/examples.test.mjs test/example-imports.test.mjs test/naive.test.mjs test/suite.test.mjs",
52
52
  "lint": "tsc -p tsconfig.json --noEmit",
53
53
  "spec-lint": "node test/spec-lint.mjs",
54
54
  "conformance": "npm run build && node dist/src/cli.js replay --fixtures spec/fixtures",
@@ -59,7 +59,9 @@
59
59
  "release-gate": "npm run build && node scripts/release-gate.mjs",
60
60
  "trace-svg": "npm run build && node scripts/trace-svg.mjs",
61
61
  "bench:compare": "npm run build && node bench/compare.mjs",
62
- "conformance-table": "node scripts/conformance-table.mjs"
62
+ "conformance-table": "node scripts/conformance-table.mjs",
63
+ "explain-parity": "node scripts/explain-parity.mjs",
64
+ "og": "npm run build && node scripts/og-image.mjs"
63
65
  },
64
66
  "engines": {
65
67
  "node": ">=20"