proactive-gate 0.1.1 → 0.2.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
@@ -2,6 +2,15 @@
2
2
 
3
3
  English | [Türkçe](README.tr.md)
4
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
+
5
14
  Decide whether a proactive AI agent may reach a user right now, and log why not.
6
15
 
7
16
  A proactive assistant has two halves. The generating half decides what is worth
@@ -28,11 +37,24 @@ if (decision.allowed && (await gate.commit(decision, { user, candidate }))) {
28
37
  }
29
38
  ```
30
39
 
40
+ Or start from a policy file and the wiring for your framework, in one command:
41
+
42
+ ```
43
+ npx proactive-gate init --preset usTcpa --framework mastra
44
+ ```
45
+
46
+ That writes `proactive-gate.policy.json` with the ten checks in order, appends the
47
+ preset you named, and prints the preset's own source next to the few lines that plug
48
+ the gate into that framework. `npx proactive-gate init --list` shows the fourteen
49
+ platform and legal presets and the four frameworks.
50
+
31
51
  Zero dependencies. TypeScript. Node 20 or newer. Framework-agnostic: the gate sits
32
52
  between "the model produced something" and "the user's phone buzzed", whichever
33
53
  model or framework produced it. Examples: [`examples/vercel-ai-sdk.ts`](examples/vercel-ai-sdk.ts),
34
- [`examples/mastra.ts`](examples/mastra.ts), and a replayable policy in
35
- [`examples/policy.js`](examples/policy.js).
54
+ [`examples/mastra.ts`](examples/mastra.ts), [`examples/langgraph.ts`](examples/langgraph.ts), and a
55
+ replayable policy in [`examples/policy.json`](examples/policy.json). Docs and a browser playground:
56
+ [bubblegunn.github.io/proactive-gate](https://bubblegunn.github.io/proactive-gate/). API reference:
57
+ [`docs/api`](docs/api/README.md). Python: [`python/`](python/README.md).
36
58
 
37
59
  ## What a decision looks like
38
60
 
@@ -58,7 +80,10 @@ model or framework produced it. Examples: [`examples/vercel-ai-sdk.ts`](examples
58
80
  }
59
81
  ```
60
82
 
61
- <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>
83
+ <p align="center"><img src="docs/assets/trace.svg" width="900" alt="Two real decision traces side by side: candidate a1 rejected by quietHours after eight checks, candidate a5 allowed after thirteen, each check with its outcome, reason and cost"></p>
84
+
85
+ The figure is drawn from the replay's `--json` output by `node scripts/trace-svg.mjs`, every
86
+ line verbatim; the left decision is the one printed above.
62
87
 
63
88
  With one gate and a logged reason, "why was the user not told about this" has an
64
89
  answer. With checks scattered through a pipeline, the honest answer is "somewhere,
@@ -81,6 +106,12 @@ something returned false".
81
106
  | 11 | `adaptiveTiming({ nextGoodMoment, surfacesFor })` | never | non-rejecting: moves `deliverAt` or narrows surfaces; a check marked `nonRejecting` cannot reject even if it tries |
82
107
  | 12 | `dailyBudget({ limit, bypassPriority })` | the user's local-day counter is at the limit | `evaluate` reads, `commit` increments atomically and can still refuse |
83
108
 
109
+ `weeklyBudget({ limit, bypassPriority })` is the same shape keyed on the user's local ISO
110
+ week; `defaultChecks({ weeklyLimit })` places it just before the daily one. Budgets are
111
+ consumed in check order at commit, so when a weekly check passes and the daily one then
112
+ refuses, that weekly unit is spent without a delivery. It only happens when two commits
113
+ race after a shared evaluate.
114
+
84
115
  Order is a design decision and it should be visible. Consent has to come before
85
116
  everything. Quiet hours have to come before the budget, or a rejected candidate
86
117
  consumes a delivery it never made. Reorder freely; the trace will show what you did.
@@ -123,6 +154,102 @@ Mark a check `nonRejecting: true` when it may only move timing or narrow surface
123
154
  gate then ignores a reject from it and says so in the trace, so a bug in a timing model
124
155
  cannot silence a user.
125
156
 
157
+ ## A policy is data
158
+
159
+ The same checks as a JSON document, so a product team can change the rules without a
160
+ deploy and the same file runs in TypeScript, in Python, in the CLI and in the
161
+ [playground](https://bubblegunn.github.io/proactive-gate/playground/):
162
+
163
+ ```json
164
+ {
165
+ "specVersion": "1.0.0",
166
+ "checks": [
167
+ { "id": "consent" },
168
+ { "id": "snooze", "defer": true },
169
+ { "id": "quietHours", "priorityFloor": "high" },
170
+ { "preset": "usTcpa" },
171
+ { "id": "utilityFloor", "costFalseAlarm": 1, "costMissedHelp": 2, "shadow": true },
172
+ { "id": "dailyBudget", "limit": 3, "bypassPriority": "critical", "nearLimit": 0.67 }
173
+ ]
174
+ }
175
+ ```
176
+
177
+ ```ts
178
+ const gate = createGate({ policy: JSON.parse(await readFile("policy.json", "utf8")), store });
179
+ ```
180
+
181
+ Each entry names a check `id` or a `preset` plus that check's options. An unknown id throws
182
+ and names the known ones. `compilePolicy` is exported for callers that want the check list,
183
+ and the schema is at [`spec/schema/policy.schema.json`](spec/schema/policy.schema.json).
184
+ `examples/policy.js` stays as the escape hatch for checks that need functions.
185
+
186
+ ## Defer, shadow mode, near-limit notes and hooks
187
+
188
+ A check can `defer` instead of rejecting: the decision has `allowed: false`, `deferredBy` and
189
+ `retryAt`, and the caller knows when to try again. `snooze({ defer: true })` is the built-in
190
+ example.
191
+
192
+ A check with `shadow: true` runs and is traced with its real outcome, but cannot stop the
193
+ message; its id lands in `decision.shadowed`. Ship a new rule in shadow for a week, count how
194
+ often it would have fired, then turn it on.
195
+
196
+ Budgets report `nearLimit: { used, limit }` on the pass that reaches the threshold (80 percent
197
+ by default), listed under `decision.nearLimit`, so a dashboard can show who is about to go
198
+ quiet.
199
+
200
+ `hooks: { before, after, error, finally }` observe every check with its cost in milliseconds;
201
+ a hook that throws is routed to `error` and never changes the decision. `examples/otel.ts`
202
+ turns them into one span per check. Every decision has an `id`, and `commit` is idempotent on
203
+ it: a retry after a timeout does not consume a second unit.
204
+
205
+ ## Optional checks, fed by your own model
206
+
207
+ Both ship off. They read numbers the caller puts on the candidate.
208
+
209
+ - `utilityFloor({ costFalseAlarm, costMissedHelp })` acts only when `candidate.pAccept` clears
210
+ `tau = cFA / (cFA + pNeed * cFN)` (`pNeed` defaults to 1) and skips when there is no
211
+ `pAccept`. This is Horvitz's expected-utility rule with the PRISM threshold.
212
+ - `boundedDeferral({ lambda, interruptCost, staleness, boundSeconds })` never rejects. When
213
+ `candidate.busy` is true it moves `deliverAt` to `now + t*`, with
214
+ `t* = min(bound, lambda * interruptCost / (2 * staleness))`; the defaults give 116 seconds.
215
+
216
+ Neither check ships a model, a cost or a probability. `costFalseAlarm`, `costMissedHelp`,
217
+ `interruptCost` and `staleness` are yours to measure, and the package has no opinion about
218
+ what an interruption costs your users. The rules come from Eric Horvitz's work on
219
+ attention-sensitive alerting and bounded deferral; the field measurement people usually
220
+ reach for is [Iqbal and Horvitz, "Disruption and recovery of computing tasks", CHI
221
+ 2007](https://erichorvitz.com/CHI_2007_Iqbal_Horvitz.pdf), which logged real users and put
222
+ the return to a suspended task in the region of 11 to 16 minutes. The widely repeated "23
223
+ minutes 15 seconds" figure is not from a peer-reviewed paper and is not used here.
224
+
225
+ ## Presets: platform quotas and legal limits, with sources
226
+
227
+ ```ts
228
+ import { presets } from "proactive-gate/presets";
229
+ const gate = createGate({ checks: [checks.consent(), ...presets.kakaoBrandMessage()] });
230
+ ```
231
+
232
+ | preset | encodes |
233
+ |---|---|
234
+ | `lineMessagingApi({ plan })` | monthly push budget by LINE plan: 200, 5,000 or 30,000 |
235
+ | `wechatSubscriptionMessage` | one message per subscription opt-in |
236
+ | `wechatCustomerService` | within 48 h of the user's last message, at most 5 |
237
+ | `wechatTemplateMessage` | only after a user action, 3 templates a day |
238
+ | `wecomAppMessage` | 30 a minute and 1,000 an hour per member |
239
+ | `kakaoAlimtalk` | consent only; AlimTalk has no time-of-day rule |
240
+ | `kakaoBrandMessage` | advertising consent, 08:00 to 20:50 Asia/Seoul |
241
+ | `krNetworkAct50` | advertising consent, plus night consent for 21:00 to 08:00 local |
242
+ | `jpAntiSpamLaw` | opt-in |
243
+ | `cnMinorMode` | for minors: 06:00 to 22:00 Asia/Shanghai and one a day |
244
+ | `usTcpa` | 08:00 to 21:00 at the user's local time (47 CFR 64.1200) |
245
+ | `euEprivacy` | marketing consent with the soft opt-in for existing customers |
246
+ | `telegramBot` | 1 a second and 20 a minute per chat |
247
+ | `slackApp` | 1 a second per channel |
248
+
249
+ Each preset carries `sources` (the pages the numbers come from) and a `note` on what it leaves
250
+ out. Reviewable defaults, not legal advice: several official sources disagree with each other,
251
+ and the note says which value was chosen and why.
252
+
126
253
  ## The budget is enforced at commit, not at evaluate
127
254
 
128
255
  Two instances can both evaluate a candidate for the same user, both see four of
@@ -140,6 +267,13 @@ if (decision.allowed && await gate.commit(decision, input)) { // INCR, returns
140
267
  counter is keyed on the user's local day, so a budget resets at the user's midnight,
141
268
  not at UTC.
142
269
 
270
+ ## Stores
271
+
272
+ `MemoryStore` keeps values in process memory and is useful for a single instance. `RedisStore`
273
+ shares values across instances. `SqliteStore` persists values in a SQLite database without
274
+ adding a package dependency. `SqliteStore` requires Node.js 22.5 or newer; the SQLite module
275
+ 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.
276
+
143
277
  ## Fail open, on purpose
144
278
 
145
279
  When a store-backed check throws (Redis is down), the default lets the candidate
@@ -173,6 +307,143 @@ dailyBudget 1 daily budget of 5 used (5)
173
307
  per line for a notebook. Replay a week of real candidates against a proposed policy
174
308
  and you know its allow rate and its silence reasons before a single user does.
175
309
 
310
+ ## Compared with hand-rolled checks and feature flags
311
+
312
+ Most products start with a few `if` statements next to the send call and grow from there.
313
+ The difference is not the checks, which anyone can write, but four properties that are hard
314
+ to keep once the checks are scattered:
315
+
316
+ - The order is one list in one place, so "consent before everything" is a fact you can read
317
+ rather than a convention you hope each caller followed.
318
+ - Every rejection names the check and the reason, so "why was the user not told" has an
319
+ answer in the log instead of "something returned false somewhere".
320
+ - The budget is consumed by an atomic increment at send time, so two instances cannot both
321
+ send the sixth message; scattered checks read a counter and race.
322
+ - A policy can be replayed over a day of real candidates before it ships, and a non-rejecting
323
+ check cannot reject even if a bug makes it try.
324
+
325
+ Those are claims, so the repository runs them. `npm run bench:compare` replays a
326
+ committed day of 21 candidates for 7 users through `bench/naive.mjs`, an honest
327
+ hand-rolled policy of five `if` statements, and through a gate built from
328
+ `bench/fixtures/policy.json`:
329
+
330
+ ```
331
+ gate: 11 sent, 10 stopped
332
+ hand-rolled: 13 sent, 8 stopped
333
+
334
+ 6 disagreements, and none of them is a matter of taste:
335
+ a5 a critical alert: the gate lets priority bypass the cap, the cap in the if statements does not
336
+ b1 a two-day-old account: the gate holds normal messages back for a week, the if statements never knew
337
+ c1 the user pressed snooze: the gate defers to when it ends, the if statements have no snooze
338
+ e1 three dismissals of this type: the gate is silent for a week, the if statements do not track outcomes
339
+ f4 01:00 in Tokyo, a new local day: the gate resets the cap, the UTC-day key stays on yesterday for nine more hours
340
+ g4 18:00 in Los Angeles, still the same local day: the UTC-day key already rolled, so the cap pays out twice
341
+ ```
342
+
343
+ The hand-rolled policy is not a straw man. It checks consent, enabled, mute, quiet
344
+ hours and a daily cap, which is what actually gets written, and it takes the three
345
+ shortcuts that actually get taken: a fixed UTC offset per zone, the cap keyed by the
346
+ UTC calendar day, and the cap read then written. `test/naive.test.mjs` pins each one
347
+ against a real instant:
348
+
349
+ - The clocks change. At `2026-11-01T12:30:00Z` New York has left daylight time, so it is
350
+ 07:30 there and inside quiet hours; an offset captured in the summer computes 08:00 and
351
+ sends. That is twice a year, for every zone that observes it.
352
+ - The day boundary is local. The same UTC-day key silences the Tokyo user for the nine
353
+ hours between local midnight and 09:00, and hands the Los Angeles user a second full
354
+ budget at 17:00 while it is still their afternoon.
355
+ - Two deliveries are in flight. Read, compare, write lets both take the last slot, and the
356
+ counter still reads 2 afterwards, so nothing looks wrong. `commit()` takes the unit with
357
+ an atomic increment and returns `false` to the loser.
358
+
359
+ A feature-flag system does a different job better: rolling a behaviour out to a percentage
360
+ of users, per-tenant overrides, and an audit trail of who flipped what. Use flags to decide
361
+ whether the gate runs at all, and the gate to decide whether this message reaches this
362
+ person now.
363
+
364
+ ## Adapters
365
+
366
+ | subpath | framework | where the gate sits |
367
+ |---|---|---|
368
+ | `proactive-gate/ai-sdk` | Vercel AI SDK | answers a tool's `needsApproval` ([`examples/vercel-ai-sdk.ts`](examples/vercel-ai-sdk.ts); runnable offline: [`examples/ai-sdk/`](examples/ai-sdk/)) |
369
+ | `proactive-gate/mastra` | Mastra | an output processor before the send ([`examples/mastra.ts`](examples/mastra.ts); runnable offline: [`examples/mastra/`](examples/mastra/)) |
370
+ | `proactive-gate/langchain` | LangChain | middleware around the send tool |
371
+ | `proactive-gate/openai-agents` | OpenAI Agents | a guardrail |
372
+ | `npx proactive-gate hook` | Claude Code | a `PreToolUse` hook ([`examples/claude-code-hook.json`](examples/claude-code-hook.json)) |
373
+
374
+ The adapters are typed against the shape of a call, not against the framework package, so
375
+ nothing else has to be installed. Each denies with the gate's reason and commits the budget on
376
+ approval. The pattern is the same everywhere: the model decides whether there is something to
377
+ say, `gate.evaluate` decides whether it may be said now, and `gate.commit` runs right before
378
+ the message leaves. [`examples/langgraph.ts`](examples/langgraph.ts) shows the same thing
379
+ inside a LangGraph node.
380
+
381
+ Two of the examples run without the framework installed and without a network: `node
382
+ examples/mastra/run.mjs` makes the same `processOutputResult` call Mastra makes, over a day of
383
+ candidates with the clock taken from each line, and `node examples/ai-sdk/run.mjs` answers a
384
+ day of tool-approval requests, one of which is a critical alert that a legal window (the TCPA
385
+ preset) still refuses. Both are part of `npm run examples` and of the test suite.
386
+
387
+ ## Python
388
+
389
+ ```
390
+ pip install proactive-gate
391
+ ```
392
+
393
+ ```python
394
+ from proactive_gate import Gate
395
+ gate = Gate.from_policy(policy) # the same policy.json
396
+ decision = gate.evaluate(inp)
397
+ if decision.allowed and gate.commit(decision, inp): send(...)
398
+ ```
399
+
400
+ `python/` is a sibling, not a port that drifts: it passes every fixture under `spec/fixtures`
401
+ through a sync `Gate` and an `AsyncGate` (Redis over `redis.asyncio`), with mypy strict, on
402
+ Python 3.11 and 3.13 in CI. See [`python/README.md`](python/README.md).
403
+
404
+ ## Properties, not just examples
405
+
406
+ `test/properties.test.ts` generates gates, users and candidates from a seeded
407
+ 32-bit PRNG and asserts what has to hold for all of them, rather than for the
408
+ cases someone thought of:
409
+
410
+ - The trace is always a prefix of the declared check order. Nothing is skipped,
411
+ nothing is reordered, every check reports exactly once, and a stopped decision
412
+ ends on the check that stopped it with a reason attached.
413
+ - A check marked `nonRejecting` cannot stop a decision even when it returns a
414
+ rejection on purpose.
415
+ - However many deliveries race, `commit()` hands out exactly `min(racers, limit)`
416
+ units, and replaying one decision any number of times spends one.
417
+ - `MemoryStore` and `SqliteStore` answer the same random sequence of `get`, `set`,
418
+ `incr`, `del` and clock movement identically, TTLs included.
419
+
420
+ The generator is forty lines because the package has no dependencies; a property
421
+ library would shrink failures better. Each assertion prints its seed, so a failure
422
+ reproduces exactly. The race property was checked against a mutant: rewriting
423
+ `consume` as read-then-write, the shortcut in `bench/naive.mjs`, makes it fail.
424
+
425
+ ## The spec, and writing a second implementation
426
+
427
+ [`spec/SPEC.md`](spec/SPEC.md) states the behaviour as numbered requirements, and
428
+ [`spec/fixtures`](spec/fixtures) holds language-neutral cases: the DST edge in
429
+ America/New_York, Pacific/Apia, a wall-clock case in 2031, atomic commit, the ISO week,
430
+ deferral, shadow mode, the optional checks and four presets. The TypeScript tests and the
431
+ Python tests both run all of them; `npx proactive-gate replay --fixtures spec/fixtures` runs
432
+ them from the command line. A third implementation starts from the fixtures, not from this
433
+ source.
434
+
435
+ ## Performance
436
+
437
+ `npm run bench` runs `gate.evaluate()` ten thousand times with the default twelve checks and
438
+ `MemoryStore`. On 5 September 2026:
439
+
440
+ ```
441
+ evaluate() x 10,000, twelve checks, MemoryStore: median 48.7 µs, p95 91.1 µs (v24.13.0, Apple M4 Max)
442
+ ```
443
+
444
+ With `RedisStore` the two store-backed checks add one round trip each; the gate itself is
445
+ not where the time goes.
446
+
176
447
  ## Learning from what happened
177
448
 
178
449
  ```ts
@@ -209,11 +480,18 @@ Tian Pan's
209
480
  essay argues the same case from the product side and suggests a daily cap of three
210
481
  to five; `defaultChecks({ dailyLimit })` defaults to five.
211
482
 
483
+ The shape has older relatives. Matrix push rules are an ordered list where the first matching
484
+ rule decides. Android notification channels and iOS interruption levels give the user a
485
+ per-type switch and a priority floor that bypasses quiet time. Horvitz's work on mixed
486
+ initiative supplied the two optional checks. This package puts those ideas in one list with a
487
+ trace, and adds the part they leave out: the budget consumed at send time.
488
+
212
489
  ## Development
213
490
 
214
491
  ```
215
492
  npm ci
216
- npm test # tsc build, then node:test over dist/test
493
+ npm test # tsc build, spec-lint, then node:test over dist/test
494
+ cd python && pytest # the Python sibling against the same fixtures
217
495
  ```
218
496
 
219
497
  MIT.
package/README.tr.md CHANGED
@@ -33,7 +33,9 @@ Sıfır bağımlılık. TypeScript. Node 20 ya da üstü. Framework'ten bağıms
33
33
  şey üretti" ile "kullanıcının telefonu titredi" arasında durur; hangi model ya da framework
34
34
  üretmiş olursa olsun. Örnekler: [`examples/vercel-ai-sdk.ts`](examples/vercel-ai-sdk.ts),
35
35
  [`examples/mastra.ts`](examples/mastra.ts) ve yeniden oynatılabilir bir politika olarak
36
- [`examples/policy.js`](examples/policy.js).
36
+ [`examples/policy.json`](examples/policy.json). Dokümantasyon ve tarayıcıda oyun alanı:
37
+ [bubblegunn.github.io/proactive-gate](https://bubblegunn.github.io/proactive-gate/). Python:
38
+ [`python/`](python/README.md).
37
39
 
38
40
  ## Bir karar neye benzer
39
41
 
@@ -59,7 +61,10 @@ Sıfır bağımlılık. TypeScript. Node 20 ya da üstü. Framework'ten bağıms
59
61
  }
60
62
  ```
61
63
 
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>
64
+ <p align="center"><img src="docs/assets/trace.svg" width="900" alt="Yan yana iki gerçek karar izi: a1 adayı sekiz kontrolden sonra sessiz saatlerde reddedildi, a5 adayı on üç kontrolden sonra geçti; her kontrol sonucu, gerekçesi ve maliyetiyle"></p>
65
+
66
+ Şekil, replay komutunun `--json` çıktısından `node scripts/trace-svg.mjs` ile çizilir; her satır
67
+ olduğu gibi alınmıştır.
63
68
 
64
69
  Tek kapı ve kayıtlı bir gerekçe ile "kullanıcıya bu neden söylenmedi" sorusunun bir cevabı
65
70
  olur. Kontroller bir boru hattına dağılmışken dürüst cevap "bir yerde bir şey false döndü"
@@ -123,6 +128,125 @@ Bir kontrol yalnızca zamanlamayı taşıyabiliyor ya da yüzeyleri daraltabiliy
123
128
  `nonRejecting: true` işaretleyin; kapı ondan gelen bir reddi yok sayar ve bunu izde söyler,
124
129
  böylece bir zamanlama modelindeki hata bir kullanıcıyı susturamaz.
125
130
 
131
+ ## Politika bir veridir
132
+
133
+ Aynı kontroller bir JSON belgesi olarak da yazılabilir; ürün ekibi kuralları dağıtım yapmadan
134
+ değiştirir ve aynı dosya TypeScript'te, Python'da, CLI'da ve
135
+ [oyun alanında](https://bubblegunn.github.io/proactive-gate/playground/) çalışır:
136
+
137
+ ```json
138
+ {
139
+ "specVersion": "1.0.0",
140
+ "checks": [
141
+ { "id": "consent" },
142
+ { "id": "snooze", "defer": true },
143
+ { "id": "quietHours", "priorityFloor": "high" },
144
+ { "preset": "usTcpa" },
145
+ { "id": "utilityFloor", "costFalseAlarm": 1, "costMissedHelp": 2, "shadow": true },
146
+ { "id": "dailyBudget", "limit": 3, "bypassPriority": "critical", "nearLimit": 0.67 }
147
+ ]
148
+ }
149
+ ```
150
+
151
+ ```ts
152
+ const gate = createGate({ policy: JSON.parse(await readFile("policy.json", "utf8")), store });
153
+ ```
154
+
155
+ Her girdi bir kontrol `id`'si ya da bir `preset` ve o kontrolün seçeneklerini taşır. Bilinmeyen
156
+ bir id hata fırlatır ve bilinenleri sayar. Şema
157
+ [`spec/schema/policy.schema.json`](spec/schema/policy.schema.json) dosyasındadır;
158
+ `examples/policy.js`, fonksiyon gerektiren kontroller için kaçış yolu olarak durur.
159
+
160
+ ## Erteleme, gölge modu, sınıra yakınlık notları ve kancalar
161
+
162
+ Bir kontrol reddetmek yerine `defer` diyebilir: karar `allowed: false`, `deferredBy` ve
163
+ `retryAt` taşır, çağıran ne zaman tekrar deneyeceğini bilir. `snooze({ defer: true })` yerleşik
164
+ örnektir.
165
+
166
+ `shadow: true` işaretli bir kontrol çalışır ve izde gerçek sonucuyla görünür, ama mesajı
167
+ durduramaz; id'si `decision.shadowed` listesine düşer. Yeni bir kuralı bir hafta gölgede
168
+ çalıştırın, kaç kez ateşleyeceğini sayın, sonra açın.
169
+
170
+ Bütçeler eşiğe (varsayılan yüzde 80) ulaşan geçişte `nearLimit: { used, limit }` bildirir;
171
+ `decision.nearLimit` altında listelenir, böylece bir pano kimin susmak üzere olduğunu gösterir.
172
+
173
+ `hooks: { before, after, error, finally }` her kontrolü milisaniye maliyetiyle gözler; hata
174
+ fırlatan bir kanca `error` kancasına yönlendirilir ve kararı asla değiştirmez.
175
+ `examples/otel.ts` bunları kontrol başına bir span'e çevirir. Her kararın bir `id`'si vardır ve
176
+ `commit` bu id üzerinde tekrarlanabilir: zaman aşımından sonraki bir yeniden deneme ikinci
177
+ bir birim tüketmez.
178
+
179
+ ## İsteğe bağlı, kendi modelinizin beslediği kontroller
180
+
181
+ İkisi de kapalı gelir; adayın üzerine çağıranın koyduğu sayıları okurlar.
182
+
183
+ - `utilityFloor({ costFalseAlarm, costMissedHelp })` yalnızca `candidate.pAccept` değeri
184
+ `tau = cFA / (cFA + pNeed * cFN)` eşiğini geçtiğinde konuşur (`pNeed` varsayılanı 1);
185
+ `pAccept` yoksa atlar. Bu, Horvitz'in beklenen fayda kuralı ve PRISM eşiğidir.
186
+ - `boundedDeferral({ lambda, interruptCost, staleness, boundSeconds })` asla reddetmez.
187
+ `candidate.busy` doğruysa `deliverAt` değerini `now + t*` yapar;
188
+ `t* = min(bound, lambda * interruptCost / (2 * staleness))`, varsayılanlar 116 saniye verir.
189
+
190
+ ## Hazır paketler: platform kotaları ve yasal sınırlar, kaynaklarıyla
191
+
192
+ ```ts
193
+ import { presets } from "proactive-gate/presets";
194
+ const gate = createGate({ checks: [checks.consent(), ...presets.kakaoBrandMessage()] });
195
+ ```
196
+
197
+ | paket | ne kodlar |
198
+ |---|---|
199
+ | `lineMessagingApi({ plan })` | LINE planına göre aylık push bütçesi: 200, 5.000 ya da 30.000 |
200
+ | `wechatSubscriptionMessage` | abonelik onayı başına bir mesaj |
201
+ | `wechatCustomerService` | kullanıcının son mesajından sonraki 48 saat içinde en çok 5 |
202
+ | `wechatTemplateMessage` | yalnızca kullanıcı eyleminden sonra, günde 3 şablon |
203
+ | `wecomAppMessage` | üye başına dakikada 30 ve saatte 1.000 |
204
+ | `kakaoAlimtalk` | yalnızca rıza; AlimTalk'ta saat kuralı yok |
205
+ | `kakaoBrandMessage` | reklam rızası, 08:00 ile 20:50 Asia/Seoul |
206
+ | `krNetworkAct50` | reklam rızası, ayrıca 21:00 ile 08:00 yerel saat için gece rızası |
207
+ | `jpAntiSpamLaw` | opt-in |
208
+ | `cnMinorMode` | reşit olmayanlar için: 06:00 ile 22:00 Asia/Shanghai ve günde bir |
209
+ | `usTcpa` | kullanıcının yerel saatiyle 08:00 ile 21:00 (47 CFR 64.1200) |
210
+ | `euEprivacy` | pazarlama rızası, mevcut müşteriler için yumuşak opt-in |
211
+ | `telegramBot` | sohbet başına saniyede 1 ve dakikada 20 |
212
+ | `slackApp` | kanal başına saniyede 1 |
213
+
214
+ Her paket `sources` (sayıların geldiği sayfalar) ve neyi dışarıda bıraktığını söyleyen bir
215
+ `note` taşır. Gözden geçirilebilir varsayılanlar, hukuki tavsiye değil: birkaç resmi kaynak
216
+ birbiriyle çelişir ve not hangi değerin neden seçildiğini söyler.
217
+
218
+ ## Adaptörler
219
+
220
+ | alt yol | framework | kapı nerede durur |
221
+ |---|---|---|
222
+ | `proactive-gate/ai-sdk` | Vercel AI SDK | bir aracın `needsApproval` sorusunu yanıtlar (çevrimdışı çalışan örnek: [`examples/ai-sdk/`](examples/ai-sdk/)) |
223
+ | `proactive-gate/mastra` | Mastra | gönderimden önce bir çıktı işlemcisi (çevrimdışı çalışan örnek: [`examples/mastra/`](examples/mastra/)) |
224
+ | `proactive-gate/langchain` | LangChain | gönderim aracının çevresinde middleware |
225
+ | `proactive-gate/openai-agents` | OpenAI Agents | bir guardrail |
226
+ | `npx proactive-gate hook` | Claude Code | bir `PreToolUse` kancası ([`examples/claude-code-hook.json`](examples/claude-code-hook.json)) |
227
+
228
+ Adaptörler framework paketine değil, çağrının biçimine göre tiplenmiştir; başka bir şey
229
+ kurmak gerekmez. Her biri kapının gerekçesiyle reddeder ve onayda bütçeyi tüketir.
230
+
231
+ ## Python
232
+
233
+ ```
234
+ pip install proactive-gate
235
+ ```
236
+
237
+ `python/` sapan bir port değil, bir kardeştir: `spec/fixtures` altındaki her senaryoyu senkron
238
+ `Gate` ve `AsyncGate` (Redis, `redis.asyncio` üzerinden) ile geçer; mypy strict, CI'da Python
239
+ 3.11 ve 3.13. Bkz. [`python/README.md`](python/README.md).
240
+
241
+ ## Sözleşme ve ikinci bir uygulama yazmak
242
+
243
+ [`spec/SPEC.md`](spec/SPEC.md) davranışı numaralı gereksinimler olarak yazar;
244
+ [`spec/fixtures`](spec/fixtures) dile bağlı olmayan senaryoları tutar: America/New_York'taki
245
+ yaz saati kenarı, Pacific/Apia, 2031'de bir duvar saati senaryosu, atomik commit, ISO haftası,
246
+ erteleme, gölge modu, isteğe bağlı kontroller ve dört hazır paket. TypeScript ve Python
247
+ testleri hepsini çalıştırır; `npx proactive-gate replay --fixtures spec/fixtures` komut
248
+ satırından çalıştırır. Üçüncü bir uygulama bu kaynaktan değil, senaryolardan başlar.
249
+
126
250
  ## Bütçe evaluate'te değil, commit'te uygulanır
127
251
 
128
252
  İki örnek aynı kullanıcı için aynı adayı değerlendirebilir, ikisi de beşte dördün kullanıldığını
@@ -206,11 +330,18 @@ yazısında savunulmuş kararlardır. Tian Pan'ın
206
330
  yazısı aynı davayı ürün tarafından savunur ve günde üç ile beş arası bir tavan önerir;
207
331
  `defaultChecks({ dailyLimit })` varsayılanı beştir.
208
332
 
333
+ Biçimin daha eski akrabaları var. Matrix push kuralları, ilk eşleşen kuralın karar verdiği
334
+ sıralı bir listedir. Android bildirim kanalları ve iOS kesinti seviyeleri kullanıcıya tür
335
+ başına bir anahtar ve sessiz saati aşan bir öncelik tabanı verir. Horvitz'in karma girişim
336
+ çalışmaları iki isteğe bağlı kontrolü sağladı. Bu paket o fikirleri izli tek bir listeye
337
+ koyar ve onların dışarıda bıraktığı parçayı ekler: gönderim anında tüketilen bütçe.
338
+
209
339
  ## Geliştirme
210
340
 
211
341
  ```
212
342
  npm ci
213
- npm test # tsc build, then node:test over dist/test
343
+ npm test # tsc build, spec-lint, then node:test over dist/test
344
+ cd python && pytest # the Python sibling against the same fixtures
214
345
  ```
215
346
 
216
347
  MIT.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Vercel AI SDK tool approvals. Give the send tool `needsApproval: true` and
3
+ * answer each approval request through the gate:
4
+ *
5
+ * const approve = gateToolApproval({ gate, toInput: (call) => call.input.gate });
6
+ * const { approved, reason } = await approve(call);
7
+ * // then addToolApprovalResponse({ id: call.approvalId, approved, reason })
8
+ *
9
+ * Typed against the shape of a tool call, not against the `ai` package, so
10
+ * nothing needs to be installed to build proactive-gate.
11
+ */
12
+ import type { Gate } from "../gate.js";
13
+ import type { EvaluateInput } from "../types.js";
14
+ export interface ToolApprovalRequest {
15
+ toolName?: string;
16
+ toolCallId?: string;
17
+ approvalId?: string;
18
+ input?: unknown;
19
+ }
20
+ export interface ToolApprovalResult {
21
+ approved: boolean;
22
+ reason?: string;
23
+ }
24
+ export declare function gateToolApproval<T extends ToolApprovalRequest>(options: {
25
+ gate: Gate;
26
+ toInput: (call: T) => EvaluateInput;
27
+ /** Also consume the budget on approval. Default true. */
28
+ commit?: boolean;
29
+ }): (call: T) => Promise<ToolApprovalResult>;
30
+ export declare function describe(decision: {
31
+ rejectedBy?: string;
32
+ deferredBy?: string;
33
+ retryAt?: Date;
34
+ reason?: string;
35
+ }): string;
@@ -0,0 +1,17 @@
1
+ export function gateToolApproval(options) {
2
+ const commit = options.commit ?? true;
3
+ return async (call) => {
4
+ const input = options.toInput(call);
5
+ const decision = await options.gate.evaluate(input);
6
+ if (!decision.allowed)
7
+ return { approved: false, reason: describe(decision) };
8
+ if (commit && !(await options.gate.commit(decision, input)))
9
+ return { approved: false, reason: "a budget was exhausted at commit" };
10
+ return { approved: true, ...(decision.deliverAt ? { reason: `deliver at ${decision.deliverAt.toISOString()}` } : {}) };
11
+ };
12
+ }
13
+ export function describe(decision) {
14
+ if (decision.deferredBy)
15
+ return `deferred by ${decision.deferredBy} until ${decision.retryAt?.toISOString()}: ${decision.reason}`;
16
+ return `rejected by ${decision.rejectedBy}: ${decision.reason}`;
17
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * LangChain middleware that wraps tool calls. For the tools listed in `tools`
3
+ * the gate decides first; a rejection returns a tool message carrying the
4
+ * reason instead of running the tool.
5
+ *
6
+ * createAgent({ tools: [sendMessage], middleware: [gateMiddleware({ gate, tools: ["send_message"], toInput: (req) => req.toolCall.args.gate })] })
7
+ */
8
+ import type { Gate } from "../gate.js";
9
+ import type { EvaluateInput } from "../types.js";
10
+ export interface ToolCallRequest {
11
+ toolCall: {
12
+ name: string;
13
+ id?: string;
14
+ args: Record<string, unknown>;
15
+ };
16
+ }
17
+ export interface ToolMessageLike {
18
+ type: "tool";
19
+ content: string;
20
+ tool_call_id: string;
21
+ status: "success" | "error";
22
+ }
23
+ export interface ToolCallMiddleware<R extends ToolCallRequest = ToolCallRequest, T = unknown> {
24
+ name: string;
25
+ wrapToolCall(request: R, handler: (request: R) => Promise<T>): Promise<T | ToolMessageLike>;
26
+ }
27
+ export declare function gateMiddleware<R extends ToolCallRequest = ToolCallRequest, T = unknown>(options: {
28
+ gate: Gate;
29
+ tools: string[];
30
+ toInput: (request: R) => EvaluateInput;
31
+ commit?: boolean;
32
+ }): ToolCallMiddleware<R, T>;
@@ -0,0 +1,20 @@
1
+ import { describe } from "./ai-sdk.js";
2
+ export function gateMiddleware(options) {
3
+ const commit = options.commit ?? true;
4
+ const watched = new Set(options.tools);
5
+ return {
6
+ name: "proactive-gate",
7
+ async wrapToolCall(request, handler) {
8
+ if (!watched.has(request.toolCall.name))
9
+ return handler(request);
10
+ const input = options.toInput(request);
11
+ const decision = await options.gate.evaluate(input);
12
+ const refuse = (reason) => ({ type: "tool", content: `proactive-gate: ${reason}`, tool_call_id: request.toolCall.id ?? "", status: "error" });
13
+ if (!decision.allowed)
14
+ return refuse(describe(decision));
15
+ if (commit && !(await options.gate.commit(decision, input)))
16
+ return refuse("a budget was exhausted at commit");
17
+ return handler(request);
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A Mastra output processor. Put it in the agent's `outputProcessors`; when
3
+ * the gate rejects, the processor calls abort(reason) and the agent's result
4
+ * is stopped before it reaches the user.
5
+ *
6
+ * outputProcessors: [gateProcessor({ gate, toInput: ({ messages }) => ({ user, candidate: { id, type: "reply" } }) })]
7
+ */
8
+ import type { Gate } from "../gate.js";
9
+ import type { EvaluateInput } from "../types.js";
10
+ export interface ProcessorArgs<M = unknown> {
11
+ messages: M[];
12
+ abort: (reason?: string) => never;
13
+ }
14
+ export interface OutputProcessor<M = unknown> {
15
+ id: string;
16
+ processOutputResult(args: ProcessorArgs<M>): Promise<M[]>;
17
+ }
18
+ export declare function gateProcessor<M = unknown>(options: {
19
+ gate: Gate;
20
+ toInput: (args: {
21
+ messages: M[];
22
+ }) => EvaluateInput;
23
+ id?: string;
24
+ commit?: boolean;
25
+ }): OutputProcessor<M>;
@@ -0,0 +1,16 @@
1
+ import { describe } from "./ai-sdk.js";
2
+ export function gateProcessor(options) {
3
+ const commit = options.commit ?? true;
4
+ return {
5
+ id: options.id ?? "proactive-gate",
6
+ async processOutputResult({ messages, abort }) {
7
+ const input = options.toInput({ messages });
8
+ const decision = await options.gate.evaluate(input);
9
+ if (!decision.allowed)
10
+ return abort(`proactive-gate: ${describe(decision)}`);
11
+ if (commit && !(await options.gate.commit(decision, input)))
12
+ return abort("proactive-gate: a budget was exhausted at commit");
13
+ return messages;
14
+ },
15
+ };
16
+ }