proactive-gate 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  English | [Türkçe](README.tr.md)
4
4
 
5
+ <p>
6
+ <img src="https://img.shields.io/npm/v/proactive-gate?style=flat-square&color=111111&label=npm" alt="npm">
7
+ <img src="https://img.shields.io/npm/dm/proactive-gate?style=flat-square&color=111111" alt="npm downloads">
8
+ <img src="https://img.shields.io/github/actions/workflow/status/Bubblegunn/proactive-gate/ci.yml?style=flat-square&color=111111&label=ci" alt="ci">
9
+ <img src="https://img.shields.io/bundlephobia/minzip/proactive-gate?style=flat-square&color=111111" alt="minzipped size">
10
+ <img src="https://img.shields.io/github/stars/Bubblegunn/proactive-gate?style=flat-square&color=111111" alt="stars">
11
+ <img src="https://img.shields.io/badge/license-MIT-111111?style=flat-square" alt="MIT">
12
+ </p>
13
+
5
14
  Decide whether a proactive AI agent may reach a user right now, and log why not.
6
15
 
7
16
  A proactive assistant has two halves. The generating half decides what is worth
@@ -31,8 +40,8 @@ if (decision.allowed && (await gate.commit(decision, { user, candidate }))) {
31
40
  Zero dependencies. TypeScript. Node 20 or newer. Framework-agnostic: the gate sits
32
41
  between "the model produced something" and "the user's phone buzzed", whichever
33
42
  model or framework produced it. Examples: [`examples/vercel-ai-sdk.ts`](examples/vercel-ai-sdk.ts),
34
- [`examples/mastra.ts`](examples/mastra.ts), and a replayable policy in
35
- [`examples/policy.js`](examples/policy.js).
43
+ [`examples/mastra.ts`](examples/mastra.ts), [`examples/langgraph.ts`](examples/langgraph.ts), and a
44
+ replayable policy in [`examples/policy.js`](examples/policy.js). API reference: [`docs/api`](docs/api/README.md).
36
45
 
37
46
  ## What a decision looks like
38
47
 
@@ -81,6 +90,12 @@ something returned false".
81
90
  | 11 | `adaptiveTiming({ nextGoodMoment, surfacesFor })` | never | non-rejecting: moves `deliverAt` or narrows surfaces; a check marked `nonRejecting` cannot reject even if it tries |
82
91
  | 12 | `dailyBudget({ limit, bypassPriority })` | the user's local-day counter is at the limit | `evaluate` reads, `commit` increments atomically and can still refuse |
83
92
 
93
+ `weeklyBudget({ limit, bypassPriority })` is the same shape keyed on the user's local ISO
94
+ week; `defaultChecks({ weeklyLimit })` places it just before the daily one. Budgets are
95
+ consumed in check order at commit, so when a weekly check passes and the daily one then
96
+ refuses, that weekly unit is spent without a delivery. It only happens when two commits
97
+ race after a shared evaluate.
98
+
84
99
  Order is a design decision and it should be visible. Consent has to come before
85
100
  everything. Quiet hours have to come before the budget, or a rejected candidate
86
101
  consumes a delivery it never made. Reorder freely; the trace will show what you did.
@@ -140,6 +155,13 @@ if (decision.allowed && await gate.commit(decision, input)) { // INCR, returns
140
155
  counter is keyed on the user's local day, so a budget resets at the user's midnight,
141
156
  not at UTC.
142
157
 
158
+ ## Stores
159
+
160
+ `MemoryStore` keeps values in process memory and is useful for a single instance. `RedisStore`
161
+ shares values across instances. `SqliteStore` persists values in a SQLite database without
162
+ adding a package dependency. `SqliteStore` requires Node.js 22.5 or newer; the SQLite module
163
+ is loaded only when the store is constructed so the package can still be used on Node.js 20. On Node 22 the module prints an ExperimentalWarning on first use; it is stable from Node 24.
164
+
143
165
  ## Fail open, on purpose
144
166
 
145
167
  When a store-backed check throws (Redis is down), the default lets the candidate
@@ -173,6 +195,50 @@ dailyBudget 1 daily budget of 5 used (5)
173
195
  per line for a notebook. Replay a week of real candidates against a proposed policy
174
196
  and you know its allow rate and its silence reasons before a single user does.
175
197
 
198
+ ## Compared with hand-rolled checks and feature flags
199
+
200
+ Most products start with a few `if` statements next to the send call and grow from there.
201
+ The difference is not the checks, which anyone can write, but four properties that are hard
202
+ to keep once the checks are scattered:
203
+
204
+ - The order is one list in one place, so "consent before everything" is a fact you can read
205
+ rather than a convention you hope each caller followed.
206
+ - Every rejection names the check and the reason, so "why was the user not told" has an
207
+ answer in the log instead of "something returned false somewhere".
208
+ - The budget is consumed by an atomic increment at send time, so two instances cannot both
209
+ send the sixth message; scattered checks read a counter and race.
210
+ - A policy can be replayed over a day of real candidates before it ships, and a non-rejecting
211
+ check cannot reject even if a bug makes it try.
212
+
213
+ A feature-flag system does a different job better: rolling a behaviour out to a percentage
214
+ of users, per-tenant overrides, and an audit trail of who flipped what. Use flags to decide
215
+ whether the gate runs at all, and the gate to decide whether this message reaches this
216
+ person now.
217
+
218
+ ## Integrations
219
+
220
+ | framework | example | where the gate sits |
221
+ |---|---|---|
222
+ | Vercel AI SDK | [`examples/vercel-ai-sdk.ts`](examples/vercel-ai-sdk.ts) | after `generateText`, before the push |
223
+ | Mastra | [`examples/mastra.ts`](examples/mastra.ts) | after `agent.generate`, before the send |
224
+ | LangGraph | [`examples/langgraph.ts`](examples/langgraph.ts) | inside the `notify` node, before the tool call |
225
+
226
+ The pattern is the same everywhere: the model decides whether there is something to say,
227
+ `gate.evaluate` decides whether it may be said now, and `gate.commit` runs right before the
228
+ message leaves.
229
+
230
+ ## Performance
231
+
232
+ `npm run bench` runs `gate.evaluate()` ten thousand times with the default twelve checks and
233
+ `MemoryStore`. On 5 September 2026:
234
+
235
+ ```
236
+ evaluate() x 10,000, twelve checks, MemoryStore: median 48.7 µs, p95 91.1 µs (v24.13.0, Apple M4 Max)
237
+ ```
238
+
239
+ With `RedisStore` the two store-backed checks add one round trip each; the gate itself is
240
+ not where the time goes.
241
+
176
242
  ## Learning from what happened
177
243
 
178
244
  ```ts
@@ -72,10 +72,16 @@ export declare function dailyBudget(options?: {
72
72
  bypassPriority?: Priority;
73
73
  }): BudgetCheck;
74
74
  export declare const budgetKey: (userId: string, now: Date, timezone?: string) => string;
75
+ export declare const weeklyBudgetKey: (userId: string, now: Date, timezone?: string) => string;
76
+ export declare function weeklyBudget(options?: {
77
+ limit?: number;
78
+ bypassPriority?: Priority;
79
+ }): BudgetCheck;
75
80
  /** The LILA order, as a starting point. Replace, reorder, or drop checks freely. */
76
81
  export declare function defaultChecks(options?: {
77
82
  killSwitch?: () => boolean | Promise<boolean>;
78
83
  modes?: string[];
79
84
  dailyLimit?: number;
85
+ weeklyLimit?: number;
80
86
  quietHoursFloor?: Priority;
81
87
  }): Check[];
@@ -211,6 +211,32 @@ export function dailyBudget(options = {}) {
211
211
  };
212
212
  }
213
213
  export const budgetKey = (userId, now, timezone) => `budget:${userId}:${timezone ? localClock(now, timezone).day : now.toISOString().slice(0, 10)}`;
214
+ const isoWeekKey = (day) => {
215
+ const date = new Date(`${day}T00:00:00Z`);
216
+ const weekday = date.getUTCDay() || 7;
217
+ date.setUTCDate(date.getUTCDate() + 4 - weekday);
218
+ const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
219
+ const week = Math.ceil((((date.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
220
+ return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
221
+ };
222
+ export const weeklyBudgetKey = (userId, now, timezone) => {
223
+ const day = timezone ? localClock(now, timezone).day : now.toISOString().slice(0, 10);
224
+ return `weeklyBudget:${userId}:${isoWeekKey(day)}`;
225
+ };
226
+ export function weeklyBudget(options = {}) {
227
+ const limit = options.limit ?? 20;
228
+ return {
229
+ id: "weeklyBudget",
230
+ limit,
231
+ async run({ user, now, store, priority }) {
232
+ if (options.bypassPriority && atLeast(priority, options.bypassPriority))
233
+ return pass;
234
+ const key = weeklyBudgetKey(user.id, now, user.timezone);
235
+ const used = Number((await store.get(key)) ?? 0);
236
+ return used < limit ? pass : reject(`weekly budget of ${limit} used (${used})`);
237
+ },
238
+ };
239
+ }
214
240
  /** The LILA order, as a starting point. Replace, reorder, or drop checks freely. */
215
241
  export function defaultChecks(options = {}) {
216
242
  return [
@@ -225,6 +251,7 @@ export function defaultChecks(options = {}) {
225
251
  trustRamp(),
226
252
  dismissalCooldown(),
227
253
  adaptiveTiming(),
254
+ ...(options.weeklyLimit === undefined ? [] : [weeklyBudget({ limit: options.weeklyLimit })]),
228
255
  dailyBudget({ limit: options.dailyLimit ?? 5 }),
229
256
  ];
230
257
  }
package/dist/src/gate.js CHANGED
@@ -1,4 +1,4 @@
1
- import { budgetKey, dismissalKey, DAY_SECONDS } from "./checks.js";
1
+ import { budgetKey, dismissalKey, weeklyBudgetKey, DAY_SECONDS } from "./checks.js";
2
2
  import { MemoryStore } from "./stores.js";
3
3
  class PrefixedStore {
4
4
  inner;
@@ -16,7 +16,7 @@ export function createGate(options) {
16
16
  const store = new PrefixedStore(options.store ?? new MemoryStore(), options.keyPrefix ?? "pg:");
17
17
  const onStoreError = options.onStoreError ?? "open";
18
18
  const checks = [...options.checks];
19
- const budgetCheck = checks.find((c) => c.id === "dailyBudget");
19
+ const budgetChecks = checks.filter((c) => c.id === "dailyBudget" || c.id === "weeklyBudget");
20
20
  const evaluate = async (input) => {
21
21
  const now = input.now ?? new Date();
22
22
  const priority = input.candidate.priority ?? "normal";
@@ -73,13 +73,20 @@ export function createGate(options) {
73
73
  const commit = async (decision, input) => {
74
74
  if (!decision.allowed)
75
75
  return false;
76
- if (!budgetCheck)
76
+ if (!budgetChecks.length)
77
77
  return true;
78
78
  const now = input.now ?? new Date();
79
- const limit = readLimit(budgetCheck);
80
79
  try {
81
- const used = await store.incr(budgetKey(input.user.id, now, input.user.timezone), 2 * DAY_SECONDS);
82
- return limit === undefined || used <= limit;
80
+ for (const check of budgetChecks) {
81
+ const key = check.id === "weeklyBudget"
82
+ ? weeklyBudgetKey(input.user.id, now, input.user.timezone)
83
+ : budgetKey(input.user.id, now, input.user.timezone);
84
+ const used = await store.incr(key, check.id === "weeklyBudget" ? 8 * DAY_SECONDS : 2 * DAY_SECONDS);
85
+ const limit = readLimit(check);
86
+ if (limit !== undefined && used > limit)
87
+ return false;
88
+ }
89
+ return true;
83
90
  }
84
91
  catch {
85
92
  return onStoreError === "open";
@@ -1,8 +1,8 @@
1
1
  export { createGate } from "./gate.js";
2
2
  export type { Gate } from "./gate.js";
3
- export { MemoryStore, RedisStore } from "./stores.js";
3
+ export { MemoryStore, RedisStore, SqliteStore } from "./stores.js";
4
4
  export type { RedisLike } from "./stores.js";
5
5
  export * as checks from "./checks.js";
6
- export { defaultChecks, localClock, inWindow, budgetKey, dismissalKey } from "./checks.js";
6
+ export { defaultChecks, localClock, inWindow, budgetKey, weeklyBudgetKey, dismissalKey } from "./checks.js";
7
7
  export { PRIORITY_RANK } from "./types.js";
8
8
  export type { Candidate, Check, CheckContext, CheckOutcome, Decision, EvaluateInput, GateOptions, OutcomeEvent, Priority, Store, Surface, TraceEntry, UserState, } from "./types.js";
package/dist/src/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { createGate } from "./gate.js";
2
- export { MemoryStore, RedisStore } from "./stores.js";
2
+ export { MemoryStore, RedisStore, SqliteStore } from "./stores.js";
3
3
  export * as checks from "./checks.js";
4
- export { defaultChecks, localClock, inWindow, budgetKey, dismissalKey } from "./checks.js";
4
+ export { defaultChecks, localClock, inWindow, budgetKey, weeklyBudgetKey, dismissalKey } from "./checks.js";
5
5
  export { PRIORITY_RANK } from "./types.js";
@@ -36,3 +36,14 @@ export declare class RedisStore implements Store {
36
36
  incr(key: string, ttlSeconds?: number): Promise<number>;
37
37
  del(key: string): Promise<void>;
38
38
  }
39
+ export declare class SqliteStore implements Store {
40
+ private readonly database;
41
+ private readonly clock;
42
+ constructor(path: string, clock?: () => number);
43
+ private live;
44
+ get(key: string): Promise<string | null>;
45
+ set(key: string, value: string, ttlSeconds?: number): Promise<void>;
46
+ incr(key: string, ttlSeconds?: number): Promise<number>;
47
+ del(key: string): Promise<void>;
48
+ close(): void;
49
+ }
@@ -1,3 +1,5 @@
1
+ import { createRequire } from "node:module";
2
+ const require = createRequire(import.meta.url);
1
3
  /** In-process store. Correct for one instance, wrong the moment you scale out. */
2
4
  export class MemoryStore {
3
5
  clock;
@@ -65,3 +67,52 @@ export class RedisStore {
65
67
  await this.client.del(key);
66
68
  }
67
69
  }
70
+ export class SqliteStore {
71
+ database;
72
+ clock;
73
+ constructor(path, clock = () => Date.now()) {
74
+ let DatabaseSync;
75
+ try {
76
+ ({ DatabaseSync } = require("node:sqlite"));
77
+ }
78
+ catch {
79
+ throw new Error("SqliteStore requires Node.js 22.5 or newer.");
80
+ }
81
+ this.database = new DatabaseSync(path);
82
+ this.clock = clock;
83
+ this.database.exec("CREATE TABLE IF NOT EXISTS proactive_gate_store (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL, expires_at INTEGER)");
84
+ }
85
+ live(key) {
86
+ const row = this.database.prepare("SELECT value, expires_at FROM proactive_gate_store WHERE key = ?").get(key);
87
+ if (!row)
88
+ return undefined;
89
+ if (row.expires_at !== null && row.expires_at <= this.clock()) {
90
+ this.database.prepare("DELETE FROM proactive_gate_store WHERE key = ?").run(key);
91
+ return undefined;
92
+ }
93
+ return { value: row.value, expiresAt: row.expires_at };
94
+ }
95
+ async get(key) {
96
+ return this.live(key)?.value ?? null;
97
+ }
98
+ async set(key, value, ttlSeconds) {
99
+ const expiresAt = ttlSeconds ? this.clock() + ttlSeconds * 1000 : null;
100
+ this.database
101
+ .prepare("INSERT INTO proactive_gate_store (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at")
102
+ .run(key, value, expiresAt);
103
+ }
104
+ async incr(key, ttlSeconds) {
105
+ const now = this.clock();
106
+ const expiresAt = ttlSeconds ? now + ttlSeconds * 1000 : null;
107
+ const row = this.database
108
+ .prepare("INSERT INTO proactive_gate_store (key, value, expires_at) VALUES (?, '1', ?) ON CONFLICT(key) DO UPDATE SET value = CASE WHEN proactive_gate_store.expires_at IS NOT NULL AND proactive_gate_store.expires_at <= ? THEN '1' ELSE CAST(CAST(proactive_gate_store.value AS INTEGER) + 1 AS TEXT) END, expires_at = CASE WHEN proactive_gate_store.expires_at IS NOT NULL AND proactive_gate_store.expires_at <= ? THEN excluded.expires_at ELSE proactive_gate_store.expires_at END RETURNING value")
109
+ .get(key, expiresAt, now, now);
110
+ return Number(row.value);
111
+ }
112
+ async del(key) {
113
+ this.database.prepare("DELETE FROM proactive_gate_store WHERE key = ?").run(key);
114
+ }
115
+ close() {
116
+ this.database.close();
117
+ }
118
+ }
@@ -1,6 +1,9 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { createGate, MemoryStore, defaultChecks, checks, localClock, inWindow } from "../src/index.js";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { createGate, MemoryStore, SqliteStore, defaultChecks, checks, localClock, inWindow } from "../src/index.js";
4
7
  import { replay, summarize } from "../src/cli.js";
5
8
  const user = (overrides = {}) => ({
6
9
  id: "u1",
@@ -134,6 +137,53 @@ test("daily budget: evaluate reads, commit consumes atomically and refuses the s
134
137
  assert.equal((await gate.evaluate({ ...input, now: nextLocalDay })).allowed, true);
135
138
  assert.equal((await gate.inspect(user(), noon)).budgetUsed, 5);
136
139
  });
140
+ test("weekly budget: resets on the user's local ISO week and commits atomically", async () => {
141
+ const store = new MemoryStore();
142
+ const gate = createGate({ store, checks: [checks.weeklyBudget({ limit: 2 })] });
143
+ const input = { user: user(), candidate: candidate(), now: new Date("2026-09-04T09:00:00Z") };
144
+ const first = await gate.evaluate(input);
145
+ const second = await gate.evaluate(input);
146
+ assert.equal(await gate.commit(first, input), true);
147
+ assert.equal(await gate.commit(second, input), true);
148
+ assert.equal((await gate.evaluate(input)).rejectedBy, "weeklyBudget");
149
+ const nextWeek = await gate.evaluate({ ...input, now: new Date("2026-09-07T09:00:00Z") });
150
+ assert.equal(nextWeek.allowed, true);
151
+ });
152
+ const sqliteAvailable = Number(process.versions.node.split(".")[0]) >= 22;
153
+ test("sqlite store supports get, set, increment, delete and expiration", { skip: !sqliteAvailable }, async () => {
154
+ let now = 1_000_000;
155
+ const store = new SqliteStore(":memory:", () => now);
156
+ assert.equal(await store.get("missing"), null);
157
+ await store.set("key", "value");
158
+ assert.equal(await store.get("key"), "value");
159
+ assert.equal(await store.incr("counter"), 1);
160
+ assert.equal(await store.incr("counter", 10), 2);
161
+ assert.equal(await store.get("counter"), "2");
162
+ await store.set("temporary", "value", 5);
163
+ assert.equal(await store.get("temporary"), "value");
164
+ now += 5000;
165
+ assert.equal(await store.get("temporary"), null);
166
+ await store.del("key");
167
+ assert.equal(await store.get("key"), null);
168
+ store.close();
169
+ });
170
+ test("sqlite store preserves values across database connections", { skip: !sqliteAvailable }, async () => {
171
+ const directory = mkdtempSync(join(tmpdir(), "proactive-gate-"));
172
+ const path = join(directory, "store.sqlite");
173
+ try {
174
+ const first = new SqliteStore(path);
175
+ await first.set("key", "value");
176
+ assert.equal(await first.incr("counter"), 1);
177
+ first.close();
178
+ const second = new SqliteStore(path);
179
+ assert.equal(await second.get("key"), "value");
180
+ assert.equal(await second.get("counter"), "1");
181
+ second.close();
182
+ }
183
+ finally {
184
+ rmSync(directory, { recursive: true, force: true });
185
+ }
186
+ });
137
187
  test("adaptive timing never rejects; it defers and can narrow surfaces", async () => {
138
188
  const later = new Date("2026-09-04T15:00:00Z");
139
189
  const gate = createGate({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proactive-gate",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Decide whether a proactive AI agent may reach a user right now, and log why not. Ordered checks: kill switch, consent, quiet hours, trust ramp, dismissal cooldown, daily budget.",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -23,7 +23,9 @@
23
23
  "build": "tsc -p tsconfig.json",
24
24
  "test": "npm run build && node --test dist/test/gate.test.js",
25
25
  "lint": "tsc -p tsconfig.json --noEmit",
26
- "prepublishOnly": "npm test"
26
+ "prepublishOnly": "npm test",
27
+ "examples": "npm run build && node dist/src/cli.js replay examples/day.jsonl --policy examples/policy.js --commit",
28
+ "bench": "npm run build && node bench/evaluate.mjs"
27
29
  },
28
30
  "engines": {
29
31
  "node": ">=20"
@@ -47,7 +49,9 @@
47
49
  },
48
50
  "homepage": "https://github.com/Bubblegunn/proactive-gate#readme",
49
51
  "devDependencies": {
50
- "@types/node": "^22.15.0",
51
- "typescript": "^5.8.0"
52
+ "@arethetypeswrong/cli": "^0.18.5",
53
+ "@types/node": "^26.4.1",
54
+ "publint": "^0.3.24",
55
+ "typescript": "^7.0.2"
52
56
  }
53
57
  }