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 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;
@@ -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;
@@ -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
+ }