proactive-gate 0.1.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/LICENSE +21 -0
- package/README.md +188 -0
- package/dist/src/checks.d.ts +81 -0
- package/dist/src/checks.js +230 -0
- package/dist/src/cli.d.ts +6 -0
- package/dist/src/cli.js +120 -0
- package/dist/src/gate.d.ts +20 -0
- package/dist/src/gate.js +120 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +5 -0
- package/dist/src/stores.d.ts +38 -0
- package/dist/src/stores.js +67 -0
- package/dist/src/types.d.ts +129 -0
- package/dist/src/types.js +1 -0
- package/dist/test/gate.test.d.ts +1 -0
- package/dist/test/gate.test.js +192 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Efe Genc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# proactive-gate
|
|
2
|
+
|
|
3
|
+
Decide whether a proactive AI agent may reach a user right now, and log why not.
|
|
4
|
+
|
|
5
|
+
A proactive assistant has two halves. The generating half decides what is worth
|
|
6
|
+
saying. The suppressing half decides whether to say it now, later, or never. Almost
|
|
7
|
+
everything written about proactive AI is about the first half. This package is the
|
|
8
|
+
second half: one gate, an ordered list of checks, and a reason for every rejection.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
npm install proactive-gate
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { createGate, defaultChecks, RedisStore } from "proactive-gate";
|
|
16
|
+
|
|
17
|
+
const gate = createGate({
|
|
18
|
+
store: new RedisStore(redis), // MemoryStore() for one instance
|
|
19
|
+
checks: defaultChecks({ dailyLimit: 3, quietHoursFloor: "high" }),
|
|
20
|
+
onDecision: (d) => log.info("gate", d), // every decision, allowed or not
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const decision = await gate.evaluate({ user, candidate });
|
|
24
|
+
if (decision.allowed && (await gate.commit(decision, { user, candidate }))) {
|
|
25
|
+
await send(decision.surfaces, candidate.payload);
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Zero dependencies. TypeScript. Node 20 or newer. Framework-agnostic: the gate sits
|
|
30
|
+
between "the model produced something" and "the user's phone buzzed", whichever
|
|
31
|
+
model or framework produced it.
|
|
32
|
+
|
|
33
|
+
## What a decision looks like
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
{
|
|
37
|
+
allowed: false,
|
|
38
|
+
userId: "ayse",
|
|
39
|
+
candidateId: "a1",
|
|
40
|
+
rejectedBy: "quietHours",
|
|
41
|
+
reason: "quiet hours 22:00 to 08:00 Europe/Istanbul; priority normal is below the floor (high)",
|
|
42
|
+
surfaces: [],
|
|
43
|
+
trace: [
|
|
44
|
+
{ id: "killSwitch", outcome: "pass", ms: 0.02 },
|
|
45
|
+
{ id: "consent", outcome: "pass", ms: 0.01 },
|
|
46
|
+
{ id: "enabled", outcome: "pass", ms: 0.01 },
|
|
47
|
+
{ id: "mode", outcome: "pass", ms: 0.01 },
|
|
48
|
+
{ id: "snooze", outcome: "pass", ms: 0.02 },
|
|
49
|
+
{ id: "mute", outcome: "pass", ms: 0.01 },
|
|
50
|
+
{ id: "intensity", outcome: "pass", ms: 0.02 },
|
|
51
|
+
{ id: "quietHours", outcome: "reject", reason: "quiet hours 22:00 to 08:00 …", ms: 0.09 }
|
|
52
|
+
],
|
|
53
|
+
evaluatedAt: 2026-09-04T03:00:00.000Z
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
With one gate and a logged reason, "why was the user not told about this" has an
|
|
58
|
+
answer. With checks scattered through a pipeline, the honest answer is "somewhere,
|
|
59
|
+
something returned false".
|
|
60
|
+
|
|
61
|
+
## The checks, in the order the default runs them
|
|
62
|
+
|
|
63
|
+
| # | check | rejects when | notes |
|
|
64
|
+
|---|---|---|---|
|
|
65
|
+
| 1 | `killSwitch(isOn)` | your flag is on | production hard-stop; silences every producer at once |
|
|
66
|
+
| 2 | `consent()` | `user.consent` is false | comes first, or you have evaluated preferences for someone who never agreed |
|
|
67
|
+
| 3 | `enabled()` | `user.proactiveEnabled === false` | per-profile switch |
|
|
68
|
+
| 4 | `mode({ allow })` | `user.mode` is not in the list | e.g. only `"normal"`, never `"focus"` |
|
|
69
|
+
| 5 | `snooze()` | `user.snoozedUntil` is in the future | global pause |
|
|
70
|
+
| 6 | `mute()` | `candidate.type` is in `user.mutedTypes` | per-type mute |
|
|
71
|
+
| 7 | `intensity()` | priority is below the user's intensity floor | low hears only high, normal hears normal and up, high hears everything |
|
|
72
|
+
| 8 | `quietHours({ priorityFloor })` | inside the user's local quiet window | IANA time zone, window may cross midnight, bypassed at or above the floor |
|
|
73
|
+
| 9 | `trustRamp({ days, minPriority })` | user is newer than `days` and priority is below the floor | the system is least calibrated exactly when the user is least forgiving |
|
|
74
|
+
| 10 | `dismissalCooldown({ dismissals, withinDays, silenceDays })` | the user dismissed that type `dismissals` times in the window | fed by `gate.record(user, candidate, "dismissed")`; every further dismissal restarts the silence |
|
|
75
|
+
| 11 | `adaptiveTiming({ nextGoodMoment, surfacesFor })` | never | non-rejecting: moves `deliverAt` or narrows surfaces; a check marked `nonRejecting` cannot reject even if it tries |
|
|
76
|
+
| 12 | `dailyBudget({ limit, bypassPriority })` | the user's local-day counter is at the limit | `evaluate` reads, `commit` increments atomically and can still refuse |
|
|
77
|
+
|
|
78
|
+
Order is a design decision and it should be visible. Consent has to come before
|
|
79
|
+
everything. Quiet hours have to come before the budget, or a rejected candidate
|
|
80
|
+
consumes a delivery it never made. Reorder freely; the trace will show what you did.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { createGate, checks } from "proactive-gate";
|
|
84
|
+
|
|
85
|
+
const gate = createGate({
|
|
86
|
+
checks: [
|
|
87
|
+
checks.consent(),
|
|
88
|
+
checks.quietHours({ priorityFloor: "high" }),
|
|
89
|
+
checks.dailyBudget({ limit: 3, bypassPriority: "critical" }),
|
|
90
|
+
myOwnCheck, // { id, run(ctx) => pass | reject | adjust | skip }
|
|
91
|
+
],
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## The budget is enforced at commit, not at evaluate
|
|
96
|
+
|
|
97
|
+
Two instances can both evaluate a candidate for the same user, both see four of
|
|
98
|
+
five used, and both decide to send. The only race-safe place to enforce a cap is
|
|
99
|
+
the atomic increment right before sending:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
const decision = await gate.evaluate(input); // reads the counter
|
|
103
|
+
if (decision.allowed && await gate.commit(decision, input)) { // INCR, returns false on the sixth
|
|
104
|
+
await send(...);
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`RedisStore` uses `INCR` and attaches the day's TTL on the first increment. The
|
|
109
|
+
counter is keyed on the user's local day, so a budget resets at the user's midnight,
|
|
110
|
+
not at UTC.
|
|
111
|
+
|
|
112
|
+
## Fail open, on purpose
|
|
113
|
+
|
|
114
|
+
When a store-backed check throws (Redis is down), the default lets the candidate
|
|
115
|
+
through and records `outcome: "skip", reason: "check threw (…); failing open"` in the
|
|
116
|
+
trace. A cache outage should not silence every user of a product whose whole point
|
|
117
|
+
is to speak up. If your product would rather stay silent, pass `onStoreError:
|
|
118
|
+
"closed"` and the same failure becomes a rejection that names the check.
|
|
119
|
+
|
|
120
|
+
## Replay a day before you ship a policy
|
|
121
|
+
|
|
122
|
+
The CLI takes a JSONL file of `{ user, candidate, now }` lines and reports what a
|
|
123
|
+
policy would have done. `--commit` consumes the budget in order, as production would.
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
npx proactive-gate replay examples/day.jsonl --commit
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
17 candidates · 7 allowed (41.2%) · 10 rejected
|
|
131
|
+
|
|
132
|
+
check rejected example
|
|
133
|
+
---------------------------------------------------------------
|
|
134
|
+
intensity 3 priority low is below the "normal" intensity floor (normal)
|
|
135
|
+
consent 3 user has not consented to proactive behaviour
|
|
136
|
+
mode 2 operating mode "focus" does not allow proactive messages
|
|
137
|
+
quietHours 1 quiet hours 22:00 to 08:00 Europe/Istanbul; priority normal is below the floor (critical)
|
|
138
|
+
dailyBudget 1 daily budget of 5 used (5)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`--policy examples/policy.js` loads your own gate; `--json` prints one full decision
|
|
142
|
+
per line for a notebook. Replay a week of real candidates against a proposed policy
|
|
143
|
+
and you know its allow rate and its silence reasons before a single user does.
|
|
144
|
+
|
|
145
|
+
## Learning from what happened
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
await gate.record(user, candidate, "dismissed"); // feeds dismissalCooldown
|
|
149
|
+
await gate.record(user, candidate, "acted"); // recorded for you to extend
|
|
150
|
+
await gate.inspect(user); // { budgetUsed, dismissals }
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Silence has to be measurable or it becomes an excuse. Log every decision through
|
|
154
|
+
`onDecision`; the allow rate, the top rejection reasons, and the dismissal rate of
|
|
155
|
+
what was allowed are the three numbers that tell you whether the gate is tuned.
|
|
156
|
+
|
|
157
|
+
## What this does not do
|
|
158
|
+
|
|
159
|
+
- It does not decide what is worth saying. That is the generating half, and it
|
|
160
|
+
belongs to your model and your product.
|
|
161
|
+
- It does not score value against attention. `adaptiveTiming` is a hook for your
|
|
162
|
+
own model of the user's next good moment; the package ships no such model.
|
|
163
|
+
- It does not coordinate across products. If three agents each respect a budget of
|
|
164
|
+
three, the user still gets nine. A cross-agent layer is a different problem.
|
|
165
|
+
- It does not replace consent law. `consent()` checks a boolean you set; how you
|
|
166
|
+
obtained it is on you.
|
|
167
|
+
|
|
168
|
+
## Where it comes from
|
|
169
|
+
|
|
170
|
+
This is the delivery gate from [LILA](https://efe-genc-portfolio.vercel.app/projects/lila/),
|
|
171
|
+
a proactive assistant I have been building alone since February 2026, extracted and
|
|
172
|
+
made framework-agnostic. The order of the twelve checks, the trust ramp, the
|
|
173
|
+
three-in-thirty cooldown and the fail-open budget are all decisions that were made
|
|
174
|
+
in production and defended in
|
|
175
|
+
[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/).
|
|
176
|
+
Tian Pan's
|
|
177
|
+
[notification budget](https://tianpan.co/blog/2026-05-13-background-agents-notification-budget-attention-economy)
|
|
178
|
+
essay argues the same case from the product side and suggests a daily cap of three
|
|
179
|
+
to five; `defaultChecks({ dailyLimit })` defaults to five.
|
|
180
|
+
|
|
181
|
+
## Development
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
npm ci
|
|
185
|
+
npm test # tsc build, then node:test over dist/test
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
MIT.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Check, CheckContext, Priority, Surface } from "./types.js";
|
|
2
|
+
export declare const DAY_SECONDS: number;
|
|
3
|
+
/** Local "HH:MM" and calendar day for an instant in an IANA zone, using Intl only. */
|
|
4
|
+
export declare function localClock(now: Date, timezone: string): {
|
|
5
|
+
minutes: number;
|
|
6
|
+
day: string;
|
|
7
|
+
};
|
|
8
|
+
/** True when `minutes` falls inside [start, end), where the window may cross midnight. */
|
|
9
|
+
export declare function inWindow(minutes: number, start: number, end: number): boolean;
|
|
10
|
+
/** A production hard-stop that silences every producer at once. */
|
|
11
|
+
export declare function killSwitch(isOn: () => boolean | Promise<boolean>): Check;
|
|
12
|
+
/** Consent comes before everything, or you have evaluated preferences for someone who never agreed. */
|
|
13
|
+
export declare function consent(): Check;
|
|
14
|
+
/** Proactive behaviour switched on for this profile. Defaults to on when undefined. */
|
|
15
|
+
export declare function enabled(): Check;
|
|
16
|
+
/** Only these operating modes may receive proactive messages. Undefined mode passes. */
|
|
17
|
+
export declare function mode(options: {
|
|
18
|
+
allow: string[];
|
|
19
|
+
}): Check;
|
|
20
|
+
/** A global pause until an instant. */
|
|
21
|
+
export declare function snooze(): Check;
|
|
22
|
+
/** Per-type mute. */
|
|
23
|
+
export declare function mute(): Check;
|
|
24
|
+
/**
|
|
25
|
+
* The user's intensity setting maps to a priority floor:
|
|
26
|
+
* low hears only high priority, normal hears normal and up, high hears everything.
|
|
27
|
+
*/
|
|
28
|
+
export declare function intensity(floors?: Record<"low" | "normal" | "high", Priority>): Check;
|
|
29
|
+
/** Timezone-aware quiet hours, bypassed only at or above the priority floor. */
|
|
30
|
+
export declare function quietHours(options?: {
|
|
31
|
+
priorityFloor?: Priority;
|
|
32
|
+
}): Check;
|
|
33
|
+
/**
|
|
34
|
+
* For the first `days` after sign-up the user hears from the system only at
|
|
35
|
+
* or above `minPriority`. A proactive assistant is least calibrated exactly
|
|
36
|
+
* when the user is least forgiving.
|
|
37
|
+
*/
|
|
38
|
+
export declare function trustRamp(options?: {
|
|
39
|
+
days?: number;
|
|
40
|
+
minPriority?: Priority;
|
|
41
|
+
}): Check;
|
|
42
|
+
/**
|
|
43
|
+
* When the user has dismissed `dismissals` candidates of a type within
|
|
44
|
+
* `withinDays`, that type stays silent for `silenceDays`. Fed by
|
|
45
|
+
* gate.record(userId, candidate, "dismissed").
|
|
46
|
+
*/
|
|
47
|
+
export declare function dismissalCooldown(options?: {
|
|
48
|
+
dismissals?: number;
|
|
49
|
+
withinDays?: number;
|
|
50
|
+
silenceDays?: number;
|
|
51
|
+
}): Check;
|
|
52
|
+
export declare const dismissalKey: (userId: string, type: string) => string;
|
|
53
|
+
/**
|
|
54
|
+
* Never rejects. Moves a delivery to the user's next good moment when the
|
|
55
|
+
* caller supplies one, and can narrow surfaces. The default keeps the
|
|
56
|
+
* candidate where it is; pass `nextGoodMoment` to plug in your own model.
|
|
57
|
+
*/
|
|
58
|
+
export declare function adaptiveTiming(options?: {
|
|
59
|
+
nextGoodMoment?: (ctx: CheckContext) => Promise<Date | null> | Date | null;
|
|
60
|
+
surfacesFor?: (ctx: CheckContext) => Surface[] | null;
|
|
61
|
+
}): Check;
|
|
62
|
+
/**
|
|
63
|
+
* At most `limit` deliveries per user per local day. The check reads the
|
|
64
|
+
* counter; gate.commit() increments it atomically and can still refuse when
|
|
65
|
+
* two instances race, which is the only race-safe place to enforce a cap.
|
|
66
|
+
*/
|
|
67
|
+
export interface BudgetCheck extends Check {
|
|
68
|
+
limit: number;
|
|
69
|
+
}
|
|
70
|
+
export declare function dailyBudget(options?: {
|
|
71
|
+
limit?: number;
|
|
72
|
+
bypassPriority?: Priority;
|
|
73
|
+
}): BudgetCheck;
|
|
74
|
+
export declare const budgetKey: (userId: string, now: Date, timezone?: string) => string;
|
|
75
|
+
/** The LILA order, as a starting point. Replace, reorder, or drop checks freely. */
|
|
76
|
+
export declare function defaultChecks(options?: {
|
|
77
|
+
killSwitch?: () => boolean | Promise<boolean>;
|
|
78
|
+
modes?: string[];
|
|
79
|
+
dailyLimit?: number;
|
|
80
|
+
quietHoursFloor?: Priority;
|
|
81
|
+
}): Check[];
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { PRIORITY_RANK } from "./types.js";
|
|
2
|
+
const pass = { kind: "pass" };
|
|
3
|
+
const reject = (reason) => ({ kind: "reject", reason });
|
|
4
|
+
const skip = (reason) => ({ kind: "skip", reason });
|
|
5
|
+
const atLeast = (priority, floor) => PRIORITY_RANK[priority] >= PRIORITY_RANK[floor];
|
|
6
|
+
const toDate = (value) => {
|
|
7
|
+
if (value === null || value === undefined)
|
|
8
|
+
return null;
|
|
9
|
+
const d = value instanceof Date ? value : new Date(value);
|
|
10
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
11
|
+
};
|
|
12
|
+
export const DAY_SECONDS = 24 * 60 * 60;
|
|
13
|
+
/** Local "HH:MM" and calendar day for an instant in an IANA zone, using Intl only. */
|
|
14
|
+
export function localClock(now, timezone) {
|
|
15
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
16
|
+
timeZone: timezone,
|
|
17
|
+
hourCycle: "h23",
|
|
18
|
+
year: "numeric",
|
|
19
|
+
month: "2-digit",
|
|
20
|
+
day: "2-digit",
|
|
21
|
+
hour: "2-digit",
|
|
22
|
+
minute: "2-digit",
|
|
23
|
+
}).formatToParts(now);
|
|
24
|
+
const get = (type) => parts.find((p) => p.type === type)?.value ?? "00";
|
|
25
|
+
const hour = Number(get("hour")) % 24;
|
|
26
|
+
return { minutes: hour * 60 + Number(get("minute")), day: `${get("year")}-${get("month")}-${get("day")}` };
|
|
27
|
+
}
|
|
28
|
+
const parseHHMM = (text) => {
|
|
29
|
+
const [h, m] = text.split(":").map(Number);
|
|
30
|
+
if (h === undefined || m === undefined || Number.isNaN(h) || Number.isNaN(m))
|
|
31
|
+
throw new Error(`bad time "${text}", expected HH:MM`);
|
|
32
|
+
return h * 60 + m;
|
|
33
|
+
};
|
|
34
|
+
/** True when `minutes` falls inside [start, end), where the window may cross midnight. */
|
|
35
|
+
export function inWindow(minutes, start, end) {
|
|
36
|
+
if (start === end)
|
|
37
|
+
return false;
|
|
38
|
+
return start < end ? minutes >= start && minutes < end : minutes >= start || minutes < end;
|
|
39
|
+
}
|
|
40
|
+
/* ------------------------------------------------------------------------ */
|
|
41
|
+
/* The checks, in the order LILA runs them. Compose your own order freely. */
|
|
42
|
+
/* ------------------------------------------------------------------------ */
|
|
43
|
+
/** A production hard-stop that silences every producer at once. */
|
|
44
|
+
export function killSwitch(isOn) {
|
|
45
|
+
return {
|
|
46
|
+
id: "killSwitch",
|
|
47
|
+
async run() {
|
|
48
|
+
return (await isOn()) ? reject("engine kill switch is on") : pass;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Consent comes before everything, or you have evaluated preferences for someone who never agreed. */
|
|
53
|
+
export function consent() {
|
|
54
|
+
return {
|
|
55
|
+
id: "consent",
|
|
56
|
+
run: ({ user }) => (user.consent ? pass : reject("user has not consented to proactive behaviour")),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Proactive behaviour switched on for this profile. Defaults to on when undefined. */
|
|
60
|
+
export function enabled() {
|
|
61
|
+
return {
|
|
62
|
+
id: "enabled",
|
|
63
|
+
run: ({ user }) => (user.proactiveEnabled === false ? reject("proactive behaviour is disabled on this profile") : pass),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Only these operating modes may receive proactive messages. Undefined mode passes. */
|
|
67
|
+
export function mode(options) {
|
|
68
|
+
return {
|
|
69
|
+
id: "mode",
|
|
70
|
+
run: ({ user }) => user.mode !== undefined && !options.allow.includes(user.mode)
|
|
71
|
+
? reject(`operating mode "${user.mode}" does not allow proactive messages`)
|
|
72
|
+
: pass,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** A global pause until an instant. */
|
|
76
|
+
export function snooze() {
|
|
77
|
+
return {
|
|
78
|
+
id: "snooze",
|
|
79
|
+
run: ({ user, now }) => {
|
|
80
|
+
const until = toDate(user.snoozedUntil);
|
|
81
|
+
return until && until > now ? reject(`snoozed until ${until.toISOString()}`) : pass;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** Per-type mute. */
|
|
86
|
+
export function mute() {
|
|
87
|
+
return {
|
|
88
|
+
id: "mute",
|
|
89
|
+
run: ({ user, candidate }) => user.mutedTypes?.includes(candidate.type) ? reject(`type "${candidate.type}" is muted by the user`) : pass,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The user's intensity setting maps to a priority floor:
|
|
94
|
+
* low hears only high priority, normal hears normal and up, high hears everything.
|
|
95
|
+
*/
|
|
96
|
+
export function intensity(floors = { low: "high", normal: "normal", high: "low" }) {
|
|
97
|
+
return {
|
|
98
|
+
id: "intensity",
|
|
99
|
+
run: ({ user, priority }) => {
|
|
100
|
+
const floor = floors[user.intensity ?? "normal"];
|
|
101
|
+
return atLeast(priority, floor) ? pass : reject(`priority ${priority} is below the "${user.intensity ?? "normal"}" intensity floor (${floor})`);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** Timezone-aware quiet hours, bypassed only at or above the priority floor. */
|
|
106
|
+
export function quietHours(options = {}) {
|
|
107
|
+
const floor = options.priorityFloor ?? "critical";
|
|
108
|
+
return {
|
|
109
|
+
id: "quietHours",
|
|
110
|
+
run: ({ user, now, priority }) => {
|
|
111
|
+
if (!user.quietHours)
|
|
112
|
+
return pass;
|
|
113
|
+
if (!user.timezone)
|
|
114
|
+
return skip("quiet hours set but no timezone on the user; cannot evaluate");
|
|
115
|
+
const { minutes } = localClock(now, user.timezone);
|
|
116
|
+
const start = parseHHMM(user.quietHours.start);
|
|
117
|
+
const end = parseHHMM(user.quietHours.end);
|
|
118
|
+
if (!inWindow(minutes, start, end))
|
|
119
|
+
return pass;
|
|
120
|
+
if (atLeast(priority, floor))
|
|
121
|
+
return pass;
|
|
122
|
+
return reject(`quiet hours ${user.quietHours.start} to ${user.quietHours.end} ${user.timezone}; priority ${priority} is below the floor (${floor})`);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* For the first `days` after sign-up the user hears from the system only at
|
|
128
|
+
* or above `minPriority`. A proactive assistant is least calibrated exactly
|
|
129
|
+
* when the user is least forgiving.
|
|
130
|
+
*/
|
|
131
|
+
export function trustRamp(options = {}) {
|
|
132
|
+
const days = options.days ?? 7;
|
|
133
|
+
const floor = options.minPriority ?? "high";
|
|
134
|
+
return {
|
|
135
|
+
id: "trustRamp",
|
|
136
|
+
run: ({ user, now, priority }) => {
|
|
137
|
+
const created = toDate(user.createdAt);
|
|
138
|
+
if (!created)
|
|
139
|
+
return skip("no createdAt on the user; ramp cannot be evaluated");
|
|
140
|
+
const age = (now.getTime() - created.getTime()) / (DAY_SECONDS * 1000);
|
|
141
|
+
if (age >= days)
|
|
142
|
+
return pass;
|
|
143
|
+
return atLeast(priority, floor) ? pass : reject(`trust ramp: day ${Math.floor(age) + 1} of ${days}, priority ${priority} is below ${floor}`);
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* When the user has dismissed `dismissals` candidates of a type within
|
|
149
|
+
* `withinDays`, that type stays silent for `silenceDays`. Fed by
|
|
150
|
+
* gate.record(userId, candidate, "dismissed").
|
|
151
|
+
*/
|
|
152
|
+
export function dismissalCooldown(options = {}) {
|
|
153
|
+
const n = options.dismissals ?? 3;
|
|
154
|
+
const withinDays = options.withinDays ?? 30;
|
|
155
|
+
const silenceDays = options.silenceDays ?? 7;
|
|
156
|
+
return {
|
|
157
|
+
id: "dismissalCooldown",
|
|
158
|
+
async run({ user, candidate, now, store }) {
|
|
159
|
+
const key = dismissalKey(user.id, candidate.type);
|
|
160
|
+
const raw = await store.get(key);
|
|
161
|
+
const stamps = raw ? JSON.parse(raw) : [];
|
|
162
|
+
const windowStart = now.getTime() - withinDays * DAY_SECONDS * 1000;
|
|
163
|
+
const recent = stamps.filter((t) => t >= windowStart).sort((a, b) => a - b);
|
|
164
|
+
if (recent.length < n)
|
|
165
|
+
return pass;
|
|
166
|
+
// Silence runs from the most recent dismissal; every further dismissal restarts it.
|
|
167
|
+
const latest = recent[recent.length - 1];
|
|
168
|
+
const silentUntil = latest + silenceDays * DAY_SECONDS * 1000;
|
|
169
|
+
if (now.getTime() >= silentUntil)
|
|
170
|
+
return pass;
|
|
171
|
+
return reject(`${recent.length} dismissals of "${candidate.type}" in ${withinDays} days; silent until ${new Date(silentUntil).toISOString()}`);
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
export const dismissalKey = (userId, type) => `cooldown:${userId}:${type}`;
|
|
176
|
+
/**
|
|
177
|
+
* Never rejects. Moves a delivery to the user's next good moment when the
|
|
178
|
+
* caller supplies one, and can narrow surfaces. The default keeps the
|
|
179
|
+
* candidate where it is; pass `nextGoodMoment` to plug in your own model.
|
|
180
|
+
*/
|
|
181
|
+
export function adaptiveTiming(options = {}) {
|
|
182
|
+
return {
|
|
183
|
+
id: "adaptiveTiming",
|
|
184
|
+
nonRejecting: true,
|
|
185
|
+
async run(ctx) {
|
|
186
|
+
const at = options.nextGoodMoment ? await options.nextGoodMoment(ctx) : null;
|
|
187
|
+
const surfaces = options.surfacesFor ? options.surfacesFor(ctx) : null;
|
|
188
|
+
if (!at && !surfaces)
|
|
189
|
+
return pass;
|
|
190
|
+
const parts = [];
|
|
191
|
+
if (at)
|
|
192
|
+
parts.push(`deliver at ${at.toISOString()}`);
|
|
193
|
+
if (surfaces)
|
|
194
|
+
parts.push(`surfaces ${surfaces.join(",")}`);
|
|
195
|
+
return { kind: "adjust", reason: parts.join("; "), ...(at ? { deliverAt: at } : {}), ...(surfaces ? { surfaces } : {}) };
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
export function dailyBudget(options = {}) {
|
|
200
|
+
const limit = options.limit ?? 5;
|
|
201
|
+
return {
|
|
202
|
+
id: "dailyBudget",
|
|
203
|
+
limit,
|
|
204
|
+
async run({ user, now, store, priority }) {
|
|
205
|
+
if (options.bypassPriority && atLeast(priority, options.bypassPriority))
|
|
206
|
+
return pass;
|
|
207
|
+
const key = budgetKey(user.id, now, user.timezone);
|
|
208
|
+
const used = Number((await store.get(key)) ?? 0);
|
|
209
|
+
return used < limit ? pass : reject(`daily budget of ${limit} used (${used})`);
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
export const budgetKey = (userId, now, timezone) => `budget:${userId}:${timezone ? localClock(now, timezone).day : now.toISOString().slice(0, 10)}`;
|
|
214
|
+
/** The LILA order, as a starting point. Replace, reorder, or drop checks freely. */
|
|
215
|
+
export function defaultChecks(options = {}) {
|
|
216
|
+
return [
|
|
217
|
+
killSwitch(options.killSwitch ?? (() => false)),
|
|
218
|
+
consent(),
|
|
219
|
+
enabled(),
|
|
220
|
+
mode({ allow: options.modes ?? ["normal"] }),
|
|
221
|
+
snooze(),
|
|
222
|
+
mute(),
|
|
223
|
+
intensity(),
|
|
224
|
+
quietHours({ priorityFloor: options.quietHoursFloor ?? "critical" }),
|
|
225
|
+
trustRamp(),
|
|
226
|
+
dismissalCooldown(),
|
|
227
|
+
adaptiveTiming(),
|
|
228
|
+
dailyBudget({ limit: options.dailyLimit ?? 5 }),
|
|
229
|
+
];
|
|
230
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import type { Gate } from "./gate.js";
|
|
3
|
+
import type { Decision } from "./types.js";
|
|
4
|
+
export declare function loadPolicy(path?: string): Promise<Gate>;
|
|
5
|
+
export declare function replay(lines: string[], gate: Gate, commit: boolean): Promise<Decision[]>;
|
|
6
|
+
export declare function summarize(decisions: Decision[]): string;
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* proactive-gate replay <events.jsonl> [--policy <module>] [--json]
|
|
4
|
+
*
|
|
5
|
+
* Replays candidate messages through a gate and prints why each one was or
|
|
6
|
+
* was not allowed. Each JSONL line is an EvaluateInput: { user, candidate, now? }.
|
|
7
|
+
* The policy module must export `gate` (a Gate) or default-export one; without
|
|
8
|
+
* --policy the default check order runs against an in-memory store.
|
|
9
|
+
*/
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
import { pathToFileURL } from "node:url";
|
|
12
|
+
import { resolve } from "node:path";
|
|
13
|
+
import { createGate } from "./gate.js";
|
|
14
|
+
import { defaultChecks } from "./checks.js";
|
|
15
|
+
const HELP = `usage: proactive-gate replay <events.jsonl> [--policy <module.js>] [--json] [--commit]
|
|
16
|
+
|
|
17
|
+
Replays candidates through a gate and reports what was allowed and why not.
|
|
18
|
+
|
|
19
|
+
--policy <module> ES module exporting \`gate\` (or default) built with createGate()
|
|
20
|
+
--json one Decision per line instead of the summary table
|
|
21
|
+
--commit also call gate.commit() for allowed decisions, so the daily
|
|
22
|
+
budget is consumed in order, as it would be in production
|
|
23
|
+
-h, --help this text
|
|
24
|
+
|
|
25
|
+
Each line of the file is {"user": {...}, "candidate": {...}, "now": "ISO date"}.`;
|
|
26
|
+
export async function loadPolicy(path) {
|
|
27
|
+
if (!path)
|
|
28
|
+
return createGate({ checks: defaultChecks() });
|
|
29
|
+
const mod = await import(pathToFileURL(resolve(path)).href);
|
|
30
|
+
const gate = mod.gate ?? mod.default;
|
|
31
|
+
if (!gate || typeof gate.evaluate !== "function")
|
|
32
|
+
throw new Error(`${path} must export a gate created with createGate()`);
|
|
33
|
+
return gate;
|
|
34
|
+
}
|
|
35
|
+
export async function replay(lines, gate, commit) {
|
|
36
|
+
const decisions = [];
|
|
37
|
+
for (const [i, line] of lines.entries()) {
|
|
38
|
+
if (!line.trim())
|
|
39
|
+
continue;
|
|
40
|
+
let input;
|
|
41
|
+
try {
|
|
42
|
+
const raw = JSON.parse(line);
|
|
43
|
+
input = { ...raw, ...(raw.now ? { now: new Date(raw.now) } : {}) };
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
throw new Error(`line ${i + 1}: ${error instanceof Error ? error.message : String(error)}`);
|
|
47
|
+
}
|
|
48
|
+
const decision = await gate.evaluate(input);
|
|
49
|
+
if (commit && decision.allowed) {
|
|
50
|
+
const ok = await gate.commit(decision, input);
|
|
51
|
+
if (!ok) {
|
|
52
|
+
decision.allowed = false;
|
|
53
|
+
decision.rejectedBy = "dailyBudget";
|
|
54
|
+
decision.reason = "daily budget exhausted at commit";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
decisions.push(decision);
|
|
58
|
+
}
|
|
59
|
+
return decisions;
|
|
60
|
+
}
|
|
61
|
+
export function summarize(decisions) {
|
|
62
|
+
const allowed = decisions.filter((d) => d.allowed).length;
|
|
63
|
+
const byCheck = new Map();
|
|
64
|
+
const sample = new Map();
|
|
65
|
+
for (const d of decisions) {
|
|
66
|
+
if (d.allowed || !d.rejectedBy)
|
|
67
|
+
continue;
|
|
68
|
+
byCheck.set(d.rejectedBy, (byCheck.get(d.rejectedBy) ?? 0) + 1);
|
|
69
|
+
if (!sample.has(d.rejectedBy) && d.reason)
|
|
70
|
+
sample.set(d.rejectedBy, d.reason);
|
|
71
|
+
}
|
|
72
|
+
const deferred = decisions.filter((d) => d.allowed && d.deliverAt).length;
|
|
73
|
+
const lines = [
|
|
74
|
+
`${decisions.length} candidates · ${allowed} allowed (${pct(allowed, decisions.length)}) · ${decisions.length - allowed} rejected${deferred ? ` · ${deferred} deferred to a later moment` : ""}`,
|
|
75
|
+
"",
|
|
76
|
+
];
|
|
77
|
+
if (byCheck.size) {
|
|
78
|
+
const w = Math.max(...[...byCheck.keys()].map((k) => k.length), 5);
|
|
79
|
+
lines.push(`${"check".padEnd(w)} ${"rejected".padStart(8)} example`);
|
|
80
|
+
lines.push("-".repeat(w + 2 + 8 + 2 + 40));
|
|
81
|
+
for (const [id, n] of [...byCheck.entries()].sort((a, b) => b[1] - a[1])) {
|
|
82
|
+
lines.push(`${id.padEnd(w)} ${String(n).padStart(8)} ${sample.get(id) ?? ""}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
lines.push("nothing was rejected");
|
|
87
|
+
}
|
|
88
|
+
return lines.join("\n");
|
|
89
|
+
}
|
|
90
|
+
const pct = (n, total) => (total ? `${((100 * n) / total).toFixed(1)}%` : "0%");
|
|
91
|
+
async function main(argv) {
|
|
92
|
+
if (argv.length === 0 || argv.includes("-h") || argv.includes("--help")) {
|
|
93
|
+
console.log(HELP);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const [command, file] = argv;
|
|
97
|
+
if (command !== "replay" || !file) {
|
|
98
|
+
console.error(HELP);
|
|
99
|
+
process.exit(2);
|
|
100
|
+
}
|
|
101
|
+
const policyIndex = argv.indexOf("--policy");
|
|
102
|
+
const policy = policyIndex >= 0 ? argv[policyIndex + 1] : undefined;
|
|
103
|
+
const gate = await loadPolicy(policy);
|
|
104
|
+
const text = await readFile(file, "utf8");
|
|
105
|
+
const decisions = await replay(text.split("\n"), gate, argv.includes("--commit"));
|
|
106
|
+
if (argv.includes("--json")) {
|
|
107
|
+
for (const d of decisions)
|
|
108
|
+
console.log(JSON.stringify(d));
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
console.log(summarize(decisions));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
|
|
115
|
+
if (entry === import.meta.url || entry.endsWith("/proactive-gate")) {
|
|
116
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
117
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
118
|
+
process.exit(1);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Candidate, Check, Decision, EvaluateInput, GateOptions, OutcomeEvent, UserState } from "./types.js";
|
|
2
|
+
export interface Gate {
|
|
3
|
+
/** Run every check in order. Never throws for a check failure; see the trace. */
|
|
4
|
+
evaluate(input: EvaluateInput): Promise<Decision>;
|
|
5
|
+
/**
|
|
6
|
+
* Call right before you actually send. Atomically consumes one unit of the
|
|
7
|
+
* user's daily budget when a dailyBudget check is configured, and returns
|
|
8
|
+
* false if the budget was exhausted by a concurrent delivery in the meantime.
|
|
9
|
+
*/
|
|
10
|
+
commit(decision: Decision, input: EvaluateInput): Promise<boolean>;
|
|
11
|
+
/** Tell the gate what happened after delivery, so cooldowns can learn. */
|
|
12
|
+
record(user: Pick<UserState, "id">, candidate: Pick<Candidate, "type">, event: OutcomeEvent, at?: Date): Promise<void>;
|
|
13
|
+
/** Snapshot of the current counters for a user, for debugging and UIs. */
|
|
14
|
+
inspect(user: UserState, now?: Date): Promise<{
|
|
15
|
+
budgetUsed: number;
|
|
16
|
+
dismissals: Record<string, number>;
|
|
17
|
+
}>;
|
|
18
|
+
readonly checks: readonly Check[];
|
|
19
|
+
}
|
|
20
|
+
export declare function createGate(options: GateOptions): Gate;
|
package/dist/src/gate.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { budgetKey, dismissalKey, DAY_SECONDS } from "./checks.js";
|
|
2
|
+
import { MemoryStore } from "./stores.js";
|
|
3
|
+
class PrefixedStore {
|
|
4
|
+
inner;
|
|
5
|
+
prefix;
|
|
6
|
+
constructor(inner, prefix) {
|
|
7
|
+
this.inner = inner;
|
|
8
|
+
this.prefix = prefix;
|
|
9
|
+
}
|
|
10
|
+
get(key) { return this.inner.get(this.prefix + key); }
|
|
11
|
+
set(key, value, ttl) { return this.inner.set(this.prefix + key, value, ttl); }
|
|
12
|
+
incr(key, ttl) { return this.inner.incr(this.prefix + key, ttl); }
|
|
13
|
+
del(key) { return this.inner.del(this.prefix + key); }
|
|
14
|
+
}
|
|
15
|
+
export function createGate(options) {
|
|
16
|
+
const store = new PrefixedStore(options.store ?? new MemoryStore(), options.keyPrefix ?? "pg:");
|
|
17
|
+
const onStoreError = options.onStoreError ?? "open";
|
|
18
|
+
const checks = [...options.checks];
|
|
19
|
+
const budgetCheck = checks.find((c) => c.id === "dailyBudget");
|
|
20
|
+
const evaluate = async (input) => {
|
|
21
|
+
const now = input.now ?? new Date();
|
|
22
|
+
const priority = input.candidate.priority ?? "normal";
|
|
23
|
+
const trace = [];
|
|
24
|
+
let surfaces = pickSurfaces(input.user, input.candidate);
|
|
25
|
+
let deliverAt;
|
|
26
|
+
const finish = (partial) => {
|
|
27
|
+
const decision = {
|
|
28
|
+
allowed: false,
|
|
29
|
+
userId: input.user.id,
|
|
30
|
+
candidateId: input.candidate.id,
|
|
31
|
+
surfaces: [],
|
|
32
|
+
trace,
|
|
33
|
+
evaluatedAt: now,
|
|
34
|
+
...partial,
|
|
35
|
+
};
|
|
36
|
+
options.onDecision?.(decision);
|
|
37
|
+
return decision;
|
|
38
|
+
};
|
|
39
|
+
for (const check of checks) {
|
|
40
|
+
const ctx = { user: input.user, candidate: input.candidate, now, priority, store, surfaces };
|
|
41
|
+
const started = performance.now();
|
|
42
|
+
let outcome;
|
|
43
|
+
try {
|
|
44
|
+
outcome = await check.run(ctx);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
48
|
+
if (onStoreError === "closed") {
|
|
49
|
+
trace.push({ id: check.id, outcome: "reject", reason: `check threw (${message}); failing closed`, ms: elapsed(started) });
|
|
50
|
+
return finish({ rejectedBy: check.id, reason: `check "${check.id}" failed and the gate fails closed: ${message}` });
|
|
51
|
+
}
|
|
52
|
+
trace.push({ id: check.id, outcome: "skip", reason: `check threw (${message}); failing open`, ms: elapsed(started) });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (check.nonRejecting && outcome.kind === "reject") {
|
|
56
|
+
// A non-rejecting check that tries to reject is a bug in the check, not a decision about the user.
|
|
57
|
+
trace.push({ id: check.id, outcome: "skip", reason: `non-rejecting check returned reject (${outcome.reason}); ignored`, ms: elapsed(started) });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
trace.push({ id: check.id, outcome: outcome.kind, ...(("reason" in outcome && outcome.reason) ? { reason: outcome.reason } : {}), ms: elapsed(started) });
|
|
61
|
+
if (outcome.kind === "reject") {
|
|
62
|
+
return finish({ rejectedBy: check.id, reason: outcome.reason });
|
|
63
|
+
}
|
|
64
|
+
if (outcome.kind === "adjust") {
|
|
65
|
+
if (outcome.deliverAt)
|
|
66
|
+
deliverAt = outcome.deliverAt;
|
|
67
|
+
if (outcome.surfaces)
|
|
68
|
+
surfaces = outcome.surfaces;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return finish({ allowed: true, surfaces, ...(deliverAt ? { deliverAt } : {}) });
|
|
72
|
+
};
|
|
73
|
+
const commit = async (decision, input) => {
|
|
74
|
+
if (!decision.allowed)
|
|
75
|
+
return false;
|
|
76
|
+
if (!budgetCheck)
|
|
77
|
+
return true;
|
|
78
|
+
const now = input.now ?? new Date();
|
|
79
|
+
const limit = readLimit(budgetCheck);
|
|
80
|
+
try {
|
|
81
|
+
const used = await store.incr(budgetKey(input.user.id, now, input.user.timezone), 2 * DAY_SECONDS);
|
|
82
|
+
return limit === undefined || used <= limit;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return onStoreError === "open";
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const record = async (user, candidate, event, at = new Date()) => {
|
|
89
|
+
if (event !== "dismissed")
|
|
90
|
+
return;
|
|
91
|
+
const key = dismissalKey(user.id, candidate.type);
|
|
92
|
+
const raw = await store.get(key);
|
|
93
|
+
const stamps = raw ? JSON.parse(raw) : [];
|
|
94
|
+
const keepFrom = at.getTime() - 90 * DAY_SECONDS * 1000;
|
|
95
|
+
const next = [...stamps.filter((t) => t >= keepFrom), at.getTime()];
|
|
96
|
+
await store.set(key, JSON.stringify(next), 90 * DAY_SECONDS);
|
|
97
|
+
};
|
|
98
|
+
const inspect = async (user, now = new Date()) => {
|
|
99
|
+
const budgetUsed = Number((await store.get(budgetKey(user.id, now, user.timezone))) ?? 0);
|
|
100
|
+
const dismissals = {};
|
|
101
|
+
for (const type of user.mutedTypes ?? []) {
|
|
102
|
+
const raw = await store.get(dismissalKey(user.id, type));
|
|
103
|
+
dismissals[type] = raw ? JSON.parse(raw).length : 0;
|
|
104
|
+
}
|
|
105
|
+
return { budgetUsed, dismissals };
|
|
106
|
+
};
|
|
107
|
+
return { evaluate, commit, record, inspect, checks };
|
|
108
|
+
}
|
|
109
|
+
function pickSurfaces(user, candidate) {
|
|
110
|
+
const wanted = candidate.surfaces ?? ["feed"];
|
|
111
|
+
if (!user.surfaces)
|
|
112
|
+
return wanted;
|
|
113
|
+
const allowed = new Set(user.surfaces);
|
|
114
|
+
return wanted.filter((s) => allowed.has(s));
|
|
115
|
+
}
|
|
116
|
+
const elapsed = (started) => Math.round((performance.now() - started) * 1000) / 1000;
|
|
117
|
+
/** dailyBudget() closes over its limit; expose it through a well-known property for commit(). */
|
|
118
|
+
function readLimit(check) {
|
|
119
|
+
return typeof check.limit === "number" ? check.limit : undefined;
|
|
120
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createGate } from "./gate.js";
|
|
2
|
+
export type { Gate } from "./gate.js";
|
|
3
|
+
export { MemoryStore, RedisStore } from "./stores.js";
|
|
4
|
+
export type { RedisLike } from "./stores.js";
|
|
5
|
+
export * as checks from "./checks.js";
|
|
6
|
+
export { defaultChecks, localClock, inWindow, budgetKey, dismissalKey } from "./checks.js";
|
|
7
|
+
export { PRIORITY_RANK } from "./types.js";
|
|
8
|
+
export type { Candidate, Check, CheckContext, CheckOutcome, Decision, EvaluateInput, GateOptions, OutcomeEvent, Priority, Store, Surface, TraceEntry, UserState, } from "./types.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createGate } from "./gate.js";
|
|
2
|
+
export { MemoryStore, RedisStore } from "./stores.js";
|
|
3
|
+
export * as checks from "./checks.js";
|
|
4
|
+
export { defaultChecks, localClock, inWindow, budgetKey, dismissalKey } from "./checks.js";
|
|
5
|
+
export { PRIORITY_RANK } from "./types.js";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Store } from "./types.js";
|
|
2
|
+
/** In-process store. Correct for one instance, wrong the moment you scale out. */
|
|
3
|
+
export declare class MemoryStore implements Store {
|
|
4
|
+
private readonly clock;
|
|
5
|
+
private readonly data;
|
|
6
|
+
constructor(clock?: () => number);
|
|
7
|
+
private live;
|
|
8
|
+
get(key: string): Promise<string | null>;
|
|
9
|
+
set(key: string, value: string, ttlSeconds?: number): Promise<void>;
|
|
10
|
+
incr(key: string, ttlSeconds?: number): Promise<number>;
|
|
11
|
+
del(key: string): Promise<void>;
|
|
12
|
+
/** Test helper. */
|
|
13
|
+
size(): number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The subset of a Redis client the gate needs. Both ioredis and node-redis
|
|
17
|
+
* satisfy it (node-redis names are upper-case; pass a small adapter).
|
|
18
|
+
*/
|
|
19
|
+
export interface RedisLike {
|
|
20
|
+
get(key: string): Promise<string | null>;
|
|
21
|
+
set(key: string, value: string, ...args: any[]): Promise<unknown>;
|
|
22
|
+
incr(key: string): Promise<number>;
|
|
23
|
+
expire(key: string, seconds: number): Promise<unknown>;
|
|
24
|
+
del(key: string): Promise<unknown>;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Redis-backed store. `incr` is atomic on the server, which is what makes the
|
|
28
|
+
* daily budget safe across many instances; the TTL is attached on the first
|
|
29
|
+
* increment so a day's counter disappears on its own.
|
|
30
|
+
*/
|
|
31
|
+
export declare class RedisStore implements Store {
|
|
32
|
+
private readonly client;
|
|
33
|
+
constructor(client: RedisLike);
|
|
34
|
+
get(key: string): Promise<string | null>;
|
|
35
|
+
set(key: string, value: string, ttlSeconds?: number): Promise<void>;
|
|
36
|
+
incr(key: string, ttlSeconds?: number): Promise<number>;
|
|
37
|
+
del(key: string): Promise<void>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** In-process store. Correct for one instance, wrong the moment you scale out. */
|
|
2
|
+
export class MemoryStore {
|
|
3
|
+
clock;
|
|
4
|
+
data = new Map();
|
|
5
|
+
constructor(clock = () => Date.now()) {
|
|
6
|
+
this.clock = clock;
|
|
7
|
+
}
|
|
8
|
+
live(key) {
|
|
9
|
+
const entry = this.data.get(key);
|
|
10
|
+
if (!entry)
|
|
11
|
+
return undefined;
|
|
12
|
+
if (entry.expiresAt !== null && entry.expiresAt <= this.clock()) {
|
|
13
|
+
this.data.delete(key);
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
return entry;
|
|
17
|
+
}
|
|
18
|
+
async get(key) {
|
|
19
|
+
return this.live(key)?.value ?? null;
|
|
20
|
+
}
|
|
21
|
+
async set(key, value, ttlSeconds) {
|
|
22
|
+
this.data.set(key, { value, expiresAt: ttlSeconds ? this.clock() + ttlSeconds * 1000 : null });
|
|
23
|
+
}
|
|
24
|
+
async incr(key, ttlSeconds) {
|
|
25
|
+
const current = this.live(key);
|
|
26
|
+
const next = (current ? Number(current.value) : 0) + 1;
|
|
27
|
+
const expiresAt = current ? current.expiresAt : ttlSeconds ? this.clock() + ttlSeconds * 1000 : null;
|
|
28
|
+
this.data.set(key, { value: String(next), expiresAt });
|
|
29
|
+
return next;
|
|
30
|
+
}
|
|
31
|
+
async del(key) {
|
|
32
|
+
this.data.delete(key);
|
|
33
|
+
}
|
|
34
|
+
/** Test helper. */
|
|
35
|
+
size() {
|
|
36
|
+
return this.data.size;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Redis-backed store. `incr` is atomic on the server, which is what makes the
|
|
41
|
+
* daily budget safe across many instances; the TTL is attached on the first
|
|
42
|
+
* increment so a day's counter disappears on its own.
|
|
43
|
+
*/
|
|
44
|
+
export class RedisStore {
|
|
45
|
+
client;
|
|
46
|
+
constructor(client) {
|
|
47
|
+
this.client = client;
|
|
48
|
+
}
|
|
49
|
+
async get(key) {
|
|
50
|
+
return this.client.get(key);
|
|
51
|
+
}
|
|
52
|
+
async set(key, value, ttlSeconds) {
|
|
53
|
+
if (ttlSeconds)
|
|
54
|
+
await this.client.set(key, value, "EX", ttlSeconds);
|
|
55
|
+
else
|
|
56
|
+
await this.client.set(key, value);
|
|
57
|
+
}
|
|
58
|
+
async incr(key, ttlSeconds) {
|
|
59
|
+
const next = await this.client.incr(key);
|
|
60
|
+
if (next === 1 && ttlSeconds)
|
|
61
|
+
await this.client.expire(key, ttlSeconds);
|
|
62
|
+
return next;
|
|
63
|
+
}
|
|
64
|
+
async del(key) {
|
|
65
|
+
await this.client.del(key);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** Priority of a candidate message. Higher priorities may bypass some checks. */
|
|
2
|
+
export type Priority = "low" | "normal" | "high" | "critical";
|
|
3
|
+
export declare const PRIORITY_RANK: Record<Priority, number>;
|
|
4
|
+
/** Where a delivery may land. Free-form so callers can add their own. */
|
|
5
|
+
export type Surface = "feed" | "push" | "chat" | "voice" | "email" | (string & {});
|
|
6
|
+
/** Everything the gate knows about the person it might interrupt. */
|
|
7
|
+
export interface UserState {
|
|
8
|
+
id: string;
|
|
9
|
+
/** Has the user agreed to proactive behaviour at all? */
|
|
10
|
+
consent: boolean;
|
|
11
|
+
/** Is proactive behaviour switched on for this profile right now? */
|
|
12
|
+
proactiveEnabled?: boolean;
|
|
13
|
+
/** Operating mode of the assistant for this user, e.g. "normal", "focus", "vacation". */
|
|
14
|
+
mode?: string;
|
|
15
|
+
/** Global pause until this instant. */
|
|
16
|
+
snoozedUntil?: Date | string | null;
|
|
17
|
+
/** Candidate types the user has muted. */
|
|
18
|
+
mutedTypes?: string[];
|
|
19
|
+
/** How much the user wants to hear from the assistant. */
|
|
20
|
+
intensity?: "low" | "normal" | "high";
|
|
21
|
+
/** IANA time zone, required for quiet hours. */
|
|
22
|
+
timezone?: string;
|
|
23
|
+
/** Quiet hours in local time, "HH:MM". May cross midnight. */
|
|
24
|
+
quietHours?: {
|
|
25
|
+
start: string;
|
|
26
|
+
end: string;
|
|
27
|
+
} | null;
|
|
28
|
+
/** When the user joined. Drives the trust ramp. */
|
|
29
|
+
createdAt?: Date | string;
|
|
30
|
+
/** Surfaces the user allows, in preference order. Defaults to the candidate's surfaces. */
|
|
31
|
+
surfaces?: Surface[];
|
|
32
|
+
}
|
|
33
|
+
/** The thing the agent wants to say. */
|
|
34
|
+
export interface Candidate {
|
|
35
|
+
id: string;
|
|
36
|
+
/** A stable category such as "reminder", "insight", "follow_up". Used by mute and cooldown. */
|
|
37
|
+
type: string;
|
|
38
|
+
priority?: Priority;
|
|
39
|
+
/** Surfaces this candidate can be delivered on, in preference order. */
|
|
40
|
+
surfaces?: Surface[];
|
|
41
|
+
/** Free-form payload; the gate never reads it. */
|
|
42
|
+
payload?: unknown;
|
|
43
|
+
}
|
|
44
|
+
export interface EvaluateInput {
|
|
45
|
+
user: UserState;
|
|
46
|
+
candidate: Candidate;
|
|
47
|
+
/** Injected clock for tests and replays. */
|
|
48
|
+
now?: Date;
|
|
49
|
+
}
|
|
50
|
+
/** What a single check may say. */
|
|
51
|
+
export type CheckOutcome = {
|
|
52
|
+
kind: "pass";
|
|
53
|
+
} | {
|
|
54
|
+
kind: "reject";
|
|
55
|
+
reason: string;
|
|
56
|
+
} | {
|
|
57
|
+
kind: "adjust";
|
|
58
|
+
reason: string;
|
|
59
|
+
deliverAt?: Date;
|
|
60
|
+
surfaces?: Surface[];
|
|
61
|
+
} | {
|
|
62
|
+
kind: "skip";
|
|
63
|
+
reason: string;
|
|
64
|
+
};
|
|
65
|
+
export interface CheckContext {
|
|
66
|
+
user: UserState;
|
|
67
|
+
candidate: Candidate;
|
|
68
|
+
now: Date;
|
|
69
|
+
priority: Priority;
|
|
70
|
+
store: Store;
|
|
71
|
+
/** Surfaces still on the table after earlier checks. */
|
|
72
|
+
surfaces: Surface[];
|
|
73
|
+
}
|
|
74
|
+
export interface Check {
|
|
75
|
+
id: string;
|
|
76
|
+
/** True when the check can never reject; it only adjusts timing or surfaces. */
|
|
77
|
+
nonRejecting?: boolean;
|
|
78
|
+
run(ctx: CheckContext): Promise<CheckOutcome> | CheckOutcome;
|
|
79
|
+
}
|
|
80
|
+
export interface TraceEntry {
|
|
81
|
+
id: string;
|
|
82
|
+
outcome: CheckOutcome["kind"];
|
|
83
|
+
reason?: string;
|
|
84
|
+
ms: number;
|
|
85
|
+
}
|
|
86
|
+
export interface Decision {
|
|
87
|
+
allowed: boolean;
|
|
88
|
+
userId: string;
|
|
89
|
+
candidateId: string;
|
|
90
|
+
/** Surfaces to route to when allowed. Empty when rejected. */
|
|
91
|
+
surfaces: Surface[];
|
|
92
|
+
/** Set when a non-rejecting check asked for a later delivery. */
|
|
93
|
+
deliverAt?: Date;
|
|
94
|
+
/** The check that rejected, when rejected. */
|
|
95
|
+
rejectedBy?: string;
|
|
96
|
+
/** Human-readable reason, when rejected. */
|
|
97
|
+
reason?: string;
|
|
98
|
+
/** Every check that ran, in order, with what it said. */
|
|
99
|
+
trace: TraceEntry[];
|
|
100
|
+
evaluatedAt: Date;
|
|
101
|
+
}
|
|
102
|
+
/** Outcome events the gate learns from. */
|
|
103
|
+
export type OutcomeEvent = "delivered" | "dismissed" | "acted" | "ignored";
|
|
104
|
+
/**
|
|
105
|
+
* Minimal key-value contract. MemoryStore ships with the package; wrap a Redis
|
|
106
|
+
* client with RedisStore. Every method may throw; the gate decides per check
|
|
107
|
+
* whether a store failure fails open or closed.
|
|
108
|
+
*/
|
|
109
|
+
export interface Store {
|
|
110
|
+
get(key: string): Promise<string | null>;
|
|
111
|
+
set(key: string, value: string, ttlSeconds?: number): Promise<void>;
|
|
112
|
+
/** Atomic increment. Returns the new value. */
|
|
113
|
+
incr(key: string, ttlSeconds?: number): Promise<number>;
|
|
114
|
+
del(key: string): Promise<void>;
|
|
115
|
+
}
|
|
116
|
+
export interface GateOptions {
|
|
117
|
+
checks: Check[];
|
|
118
|
+
store?: Store;
|
|
119
|
+
/**
|
|
120
|
+
* What to do when a store-backed check throws. "open" lets the candidate
|
|
121
|
+
* through and records the failure in the trace; "closed" rejects.
|
|
122
|
+
* Default "open": a Redis outage should not silence every user.
|
|
123
|
+
*/
|
|
124
|
+
onStoreError?: "open" | "closed";
|
|
125
|
+
/** Receives every decision. Wire this to your logger. */
|
|
126
|
+
onDecision?: (decision: Decision) => void;
|
|
127
|
+
/** Key prefix for everything the gate writes to the store. */
|
|
128
|
+
keyPrefix?: string;
|
|
129
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const PRIORITY_RANK = { low: 0, normal: 1, high: 2, critical: 3 };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createGate, MemoryStore, defaultChecks, checks, localClock, inWindow } from "../src/index.js";
|
|
4
|
+
import { replay, summarize } from "../src/cli.js";
|
|
5
|
+
const user = (overrides = {}) => ({
|
|
6
|
+
id: "u1",
|
|
7
|
+
consent: true,
|
|
8
|
+
proactiveEnabled: true,
|
|
9
|
+
mode: "normal",
|
|
10
|
+
intensity: "normal",
|
|
11
|
+
timezone: "Europe/Istanbul",
|
|
12
|
+
quietHours: { start: "22:00", end: "08:00" },
|
|
13
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
14
|
+
...overrides,
|
|
15
|
+
});
|
|
16
|
+
const candidate = (overrides = {}) => ({ id: "c1", type: "reminder", priority: "normal", surfaces: ["push", "feed"], ...overrides });
|
|
17
|
+
const noon = new Date("2026-09-04T09:00:00Z"); // 12:00 in Istanbul (UTC+3)
|
|
18
|
+
const night = new Date("2026-09-04T20:30:00Z"); // 23:30 in Istanbul
|
|
19
|
+
test("happy path: every check passes, surfaces are returned, trace lists all twelve", async () => {
|
|
20
|
+
const gate = createGate({ checks: defaultChecks() });
|
|
21
|
+
const d = await gate.evaluate({ user: user(), candidate: candidate(), now: noon });
|
|
22
|
+
assert.equal(d.allowed, true);
|
|
23
|
+
assert.deepEqual(d.surfaces, ["push", "feed"]);
|
|
24
|
+
assert.equal(d.trace.length, 12);
|
|
25
|
+
assert.deepEqual(d.trace.map((t) => t.id), ["killSwitch", "consent", "enabled", "mode", "snooze", "mute", "intensity", "quietHours", "trustRamp", "dismissalCooldown", "adaptiveTiming", "dailyBudget"]);
|
|
26
|
+
});
|
|
27
|
+
test("order is visible: consent rejects before quiet hours gets a chance", async () => {
|
|
28
|
+
const gate = createGate({ checks: defaultChecks() });
|
|
29
|
+
const d = await gate.evaluate({ user: user({ consent: false }), candidate: candidate(), now: night });
|
|
30
|
+
assert.equal(d.allowed, false);
|
|
31
|
+
assert.equal(d.rejectedBy, "consent");
|
|
32
|
+
assert.equal(d.trace.length, 2);
|
|
33
|
+
});
|
|
34
|
+
test("kill switch silences everything and says so", async () => {
|
|
35
|
+
const gate = createGate({ checks: defaultChecks({ killSwitch: () => true }) });
|
|
36
|
+
const d = await gate.evaluate({ user: user(), candidate: candidate({ priority: "critical" }), now: noon });
|
|
37
|
+
assert.equal(d.rejectedBy, "killSwitch");
|
|
38
|
+
assert.match(d.reason, /kill switch/);
|
|
39
|
+
});
|
|
40
|
+
test("quiet hours: rejects at 23:30 local, passes at noon, crosses midnight, bypassed at the floor", async () => {
|
|
41
|
+
const gate = createGate({ checks: [checks.quietHours({ priorityFloor: "high" })] });
|
|
42
|
+
const late = await gate.evaluate({ user: user(), candidate: candidate(), now: night });
|
|
43
|
+
assert.equal(late.rejectedBy, "quietHours");
|
|
44
|
+
assert.match(late.reason, /22:00 to 08:00 Europe\/Istanbul/);
|
|
45
|
+
const early = await gate.evaluate({ user: user(), candidate: candidate(), now: new Date("2026-09-05T03:30:00Z") }); // 06:30 local
|
|
46
|
+
assert.equal(early.allowed, false);
|
|
47
|
+
const day = await gate.evaluate({ user: user(), candidate: candidate(), now: noon });
|
|
48
|
+
assert.equal(day.allowed, true);
|
|
49
|
+
const urgent = await gate.evaluate({ user: user(), candidate: candidate({ priority: "high" }), now: night });
|
|
50
|
+
assert.equal(urgent.allowed, true);
|
|
51
|
+
const noTz = await gate.evaluate({ user: user({ timezone: undefined }), candidate: candidate(), now: night });
|
|
52
|
+
assert.equal(noTz.allowed, true);
|
|
53
|
+
assert.equal(noTz.trace[0]?.outcome, "skip");
|
|
54
|
+
});
|
|
55
|
+
test("localClock and inWindow handle zones and midnight", () => {
|
|
56
|
+
assert.equal(localClock(new Date("2026-09-04T20:30:00Z"), "Europe/Istanbul").minutes, 23 * 60 + 30);
|
|
57
|
+
assert.equal(localClock(new Date("2026-09-04T20:30:00Z"), "America/Los_Angeles").minutes, 13 * 60 + 30);
|
|
58
|
+
assert.equal(localClock(new Date("2026-09-04T23:30:00Z"), "Europe/Istanbul").day, "2026-09-05");
|
|
59
|
+
assert.equal(inWindow(23 * 60, 22 * 60, 8 * 60), true);
|
|
60
|
+
assert.equal(inWindow(7 * 60, 22 * 60, 8 * 60), true);
|
|
61
|
+
assert.equal(inWindow(12 * 60, 22 * 60, 8 * 60), false);
|
|
62
|
+
assert.equal(inWindow(12 * 60, 9 * 60, 17 * 60), true);
|
|
63
|
+
assert.equal(inWindow(12 * 60, 12 * 60, 12 * 60), false);
|
|
64
|
+
});
|
|
65
|
+
test("trust ramp: new users hear only high priority for seven days", async () => {
|
|
66
|
+
const gate = createGate({ checks: [checks.trustRamp()] });
|
|
67
|
+
const fresh = user({ createdAt: "2026-09-02T00:00:00Z" });
|
|
68
|
+
const normal = await gate.evaluate({ user: fresh, candidate: candidate(), now: noon });
|
|
69
|
+
assert.equal(normal.rejectedBy, "trustRamp");
|
|
70
|
+
assert.match(normal.reason, /day 3 of 7/);
|
|
71
|
+
const high = await gate.evaluate({ user: fresh, candidate: candidate({ priority: "high" }), now: noon });
|
|
72
|
+
assert.equal(high.allowed, true);
|
|
73
|
+
const old = await gate.evaluate({ user: user(), candidate: candidate(), now: noon });
|
|
74
|
+
assert.equal(old.allowed, true);
|
|
75
|
+
});
|
|
76
|
+
test("intensity maps to a priority floor", async () => {
|
|
77
|
+
const gate = createGate({ checks: [checks.intensity()] });
|
|
78
|
+
assert.equal((await gate.evaluate({ user: user({ intensity: "low" }), candidate: candidate(), now: noon })).rejectedBy, "intensity");
|
|
79
|
+
assert.equal((await gate.evaluate({ user: user({ intensity: "low" }), candidate: candidate({ priority: "high" }), now: noon })).allowed, true);
|
|
80
|
+
assert.equal((await gate.evaluate({ user: user({ intensity: "high" }), candidate: candidate({ priority: "low" }), now: noon })).allowed, true);
|
|
81
|
+
});
|
|
82
|
+
test("snooze, mute, mode and enabled each reject with a specific reason", async () => {
|
|
83
|
+
const gate = createGate({ checks: defaultChecks() });
|
|
84
|
+
const cases = [
|
|
85
|
+
[{ snoozedUntil: "2026-09-04T12:00:00Z" }, "snooze"],
|
|
86
|
+
[{ mutedTypes: ["reminder"] }, "mute"],
|
|
87
|
+
[{ mode: "focus" }, "mode"],
|
|
88
|
+
[{ proactiveEnabled: false }, "enabled"],
|
|
89
|
+
];
|
|
90
|
+
for (const [overrides, expected] of cases) {
|
|
91
|
+
const d = await gate.evaluate({ user: user(overrides), candidate: candidate(), now: noon });
|
|
92
|
+
assert.equal(d.rejectedBy, expected, `expected ${expected} to reject`);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
test("dismissal cooldown: three dismissals in thirty days buy a week of silence for that type only", async () => {
|
|
96
|
+
const store = new MemoryStore();
|
|
97
|
+
const gate = createGate({ store, checks: [checks.dismissalCooldown()] });
|
|
98
|
+
const u = user();
|
|
99
|
+
for (const day of [1, 2, 3])
|
|
100
|
+
await gate.record(u, { type: "reminder" }, "dismissed", new Date(`2026-09-0${day}T10:00:00Z`));
|
|
101
|
+
const silenced = await gate.evaluate({ user: u, candidate: candidate(), now: new Date("2026-09-05T10:00:00Z") });
|
|
102
|
+
assert.equal(silenced.rejectedBy, "dismissalCooldown");
|
|
103
|
+
assert.match(silenced.reason, /silent until 2026-09-10T10:00:00.000Z/);
|
|
104
|
+
const otherType = await gate.evaluate({ user: u, candidate: candidate({ type: "insight" }), now: new Date("2026-09-05T10:00:00Z") });
|
|
105
|
+
assert.equal(otherType.allowed, true);
|
|
106
|
+
const later = await gate.evaluate({ user: u, candidate: candidate(), now: new Date("2026-09-11T10:00:00Z") });
|
|
107
|
+
assert.equal(later.allowed, true);
|
|
108
|
+
await gate.record(u, { type: "reminder" }, "acted");
|
|
109
|
+
const acted = await gate.evaluate({ user: u, candidate: candidate(), now: new Date("2026-09-11T10:00:00Z") });
|
|
110
|
+
assert.equal(acted.allowed, true);
|
|
111
|
+
});
|
|
112
|
+
test("daily budget: evaluate reads, commit consumes atomically and refuses the sixth delivery", async () => {
|
|
113
|
+
const store = new MemoryStore();
|
|
114
|
+
const gate = createGate({ store, checks: [checks.dailyBudget({ limit: 5 })] });
|
|
115
|
+
const input = { user: user(), candidate: candidate(), now: noon };
|
|
116
|
+
for (let i = 0; i < 5; i++) {
|
|
117
|
+
const d = await gate.evaluate(input);
|
|
118
|
+
assert.equal(d.allowed, true, `delivery ${i + 1} should be allowed`);
|
|
119
|
+
assert.equal(await gate.commit(d, input), true);
|
|
120
|
+
}
|
|
121
|
+
const sixth = await gate.evaluate(input);
|
|
122
|
+
assert.equal(sixth.rejectedBy, "dailyBudget");
|
|
123
|
+
assert.match(sixth.reason, /5 used \(5\)/);
|
|
124
|
+
// Two instances that both evaluated before either committed: only one wins.
|
|
125
|
+
const store2 = new MemoryStore();
|
|
126
|
+
const gate2 = createGate({ store: store2, checks: [checks.dailyBudget({ limit: 1 })] });
|
|
127
|
+
const a = await gate2.evaluate(input);
|
|
128
|
+
const b = await gate2.evaluate(input);
|
|
129
|
+
assert.equal(a.allowed && b.allowed, true);
|
|
130
|
+
assert.equal(await gate2.commit(a, input), true);
|
|
131
|
+
assert.equal(await gate2.commit(b, input), false);
|
|
132
|
+
// The budget resets on the user's local day, not UTC.
|
|
133
|
+
const nextLocalDay = new Date("2026-09-04T21:30:00Z"); // 00:30 next day in Istanbul
|
|
134
|
+
assert.equal((await gate.evaluate({ ...input, now: nextLocalDay })).allowed, true);
|
|
135
|
+
assert.equal((await gate.inspect(user(), noon)).budgetUsed, 5);
|
|
136
|
+
});
|
|
137
|
+
test("adaptive timing never rejects; it defers and can narrow surfaces", async () => {
|
|
138
|
+
const later = new Date("2026-09-04T15:00:00Z");
|
|
139
|
+
const gate = createGate({
|
|
140
|
+
checks: [
|
|
141
|
+
checks.adaptiveTiming({ nextGoodMoment: () => later, surfacesFor: () => ["feed"] }),
|
|
142
|
+
{ id: "rogue", nonRejecting: true, run: () => ({ kind: "reject", reason: "should be ignored" }) },
|
|
143
|
+
],
|
|
144
|
+
});
|
|
145
|
+
const d = await gate.evaluate({ user: user(), candidate: candidate(), now: noon });
|
|
146
|
+
assert.equal(d.allowed, true);
|
|
147
|
+
assert.equal(d.deliverAt?.toISOString(), later.toISOString());
|
|
148
|
+
assert.deepEqual(d.surfaces, ["feed"]);
|
|
149
|
+
assert.equal(d.trace[1]?.outcome, "skip");
|
|
150
|
+
});
|
|
151
|
+
test("store failure fails open by default and closed on request, and the trace says which", async () => {
|
|
152
|
+
const broken = {
|
|
153
|
+
get: async () => { throw new Error("redis down"); },
|
|
154
|
+
set: async () => { throw new Error("redis down"); },
|
|
155
|
+
incr: async () => { throw new Error("redis down"); },
|
|
156
|
+
del: async () => { throw new Error("redis down"); },
|
|
157
|
+
};
|
|
158
|
+
const open = createGate({ store: broken, checks: [checks.dailyBudget({ limit: 1 })] });
|
|
159
|
+
const d1 = await open.evaluate({ user: user(), candidate: candidate(), now: noon });
|
|
160
|
+
assert.equal(d1.allowed, true);
|
|
161
|
+
assert.match(d1.trace[0]?.reason ?? "", /failing open/);
|
|
162
|
+
assert.equal(await open.commit(d1, { user: user(), candidate: candidate(), now: noon }), true);
|
|
163
|
+
const closed = createGate({ store: broken, onStoreError: "closed", checks: [checks.dailyBudget({ limit: 1 })] });
|
|
164
|
+
const d2 = await closed.evaluate({ user: user(), candidate: candidate(), now: noon });
|
|
165
|
+
assert.equal(d2.allowed, false);
|
|
166
|
+
assert.equal(d2.rejectedBy, "dailyBudget");
|
|
167
|
+
});
|
|
168
|
+
test("user surfaces filter candidate surfaces; onDecision sees every decision", async () => {
|
|
169
|
+
const seen = [];
|
|
170
|
+
const gate = createGate({ checks: defaultChecks(), onDecision: (d) => seen.push(`${d.candidateId}:${d.allowed}`) });
|
|
171
|
+
const d = await gate.evaluate({ user: user({ surfaces: ["feed"] }), candidate: candidate({ surfaces: ["push", "feed", "voice"] }), now: noon });
|
|
172
|
+
assert.deepEqual(d.surfaces, ["feed"]);
|
|
173
|
+
await gate.evaluate({ user: user({ consent: false }), candidate: candidate({ id: "c2" }), now: noon });
|
|
174
|
+
assert.deepEqual(seen, ["c1:true", "c2:false"]);
|
|
175
|
+
});
|
|
176
|
+
test("replay summarises a day of candidates and consumes the budget in order", async () => {
|
|
177
|
+
const gate = createGate({ checks: defaultChecks({ dailyLimit: 2 }) });
|
|
178
|
+
const lines = [
|
|
179
|
+
JSON.stringify({ user: user(), candidate: candidate({ id: "a" }), now: noon }),
|
|
180
|
+
JSON.stringify({ user: user(), candidate: candidate({ id: "b" }), now: noon }),
|
|
181
|
+
JSON.stringify({ user: user(), candidate: candidate({ id: "c" }), now: noon }),
|
|
182
|
+
JSON.stringify({ user: user(), candidate: candidate({ id: "d" }), now: night }),
|
|
183
|
+
JSON.stringify({ user: user({ consent: false }), candidate: candidate({ id: "e" }), now: noon }),
|
|
184
|
+
];
|
|
185
|
+
const decisions = await replay(lines, gate, true);
|
|
186
|
+
assert.deepEqual(decisions.map((d) => d.allowed), [true, true, false, false, false]);
|
|
187
|
+
const text = summarize(decisions);
|
|
188
|
+
assert.match(text, /5 candidates {2}· {2}2 allowed \(40\.0%\)/);
|
|
189
|
+
assert.match(text, /dailyBudget/);
|
|
190
|
+
assert.match(text, /quietHours/);
|
|
191
|
+
assert.match(text, /consent/);
|
|
192
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "proactive-gate",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/src/index.js",
|
|
7
|
+
"types": "./dist/src/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/src/index.d.ts",
|
|
11
|
+
"import": "./dist/src/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"proactive-gate": "dist/src/cli.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.json",
|
|
24
|
+
"test": "npm run build && node --test dist/test/gate.test.js",
|
|
25
|
+
"lint": "tsc -p tsconfig.json --noEmit",
|
|
26
|
+
"prepublishOnly": "npm test"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"ai",
|
|
33
|
+
"agents",
|
|
34
|
+
"proactive",
|
|
35
|
+
"notifications",
|
|
36
|
+
"rate-limit",
|
|
37
|
+
"quiet-hours",
|
|
38
|
+
"consent",
|
|
39
|
+
"budget",
|
|
40
|
+
"llm"
|
|
41
|
+
],
|
|
42
|
+
"author": "Efe Genc",
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/Bubblegunn/proactive-gate.git"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://github.com/Bubblegunn/proactive-gate#readme",
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^22.15.0",
|
|
51
|
+
"typescript": "^5.8.0"
|
|
52
|
+
}
|
|
53
|
+
}
|