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 +98 -1
- package/README.tr.md +216 -0
- package/dist/src/checks.d.ts +6 -0
- package/dist/src/checks.js +27 -0
- package/dist/src/gate.js +13 -6
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +2 -2
- package/dist/src/stores.d.ts +11 -0
- package/dist/src/stores.js +51 -0
- package/dist/test/gate.test.js +70 -1
- package/package.json +8 -4
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.
|
package/dist/src/checks.d.ts
CHANGED
|
@@ -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[];
|
package/dist/src/checks.js
CHANGED
|
@@ -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
|
|
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 (!
|
|
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
|
|
82
|
-
|
|
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";
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/src/stores.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/src/stores.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/test/gate.test.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import {
|
|
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.
|
|
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
|
-
"@
|
|
51
|
-
"
|
|
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
|
}
|