proactive-gate 0.1.2 → 0.2.1

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.
@@ -0,0 +1,17 @@
1
+ export function gateToolApproval(options) {
2
+ const commit = options.commit ?? true;
3
+ return async (call) => {
4
+ const input = options.toInput(call);
5
+ const decision = await options.gate.evaluate(input);
6
+ if (!decision.allowed)
7
+ return { approved: false, reason: describe(decision) };
8
+ if (commit && !(await options.gate.commit(decision, input)))
9
+ return { approved: false, reason: "a budget was exhausted at commit" };
10
+ return { approved: true, ...(decision.deliverAt ? { reason: `deliver at ${decision.deliverAt.toISOString()}` } : {}) };
11
+ };
12
+ }
13
+ export function describe(decision) {
14
+ if (decision.deferredBy)
15
+ return `deferred by ${decision.deferredBy} until ${decision.retryAt?.toISOString()}: ${decision.reason}`;
16
+ return `rejected by ${decision.rejectedBy}: ${decision.reason}`;
17
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * LangChain middleware that wraps tool calls. For the tools listed in `tools`
3
+ * the gate decides first; a rejection returns a tool message carrying the
4
+ * reason instead of running the tool.
5
+ *
6
+ * createAgent({ tools: [sendMessage], middleware: [gateMiddleware({ gate, tools: ["send_message"], toInput: (req) => req.toolCall.args.gate })] })
7
+ */
8
+ import type { Gate } from "../gate.js";
9
+ import type { EvaluateInput } from "../types.js";
10
+ export interface ToolCallRequest {
11
+ toolCall: {
12
+ name: string;
13
+ id?: string;
14
+ args: Record<string, unknown>;
15
+ };
16
+ }
17
+ export interface ToolMessageLike {
18
+ type: "tool";
19
+ content: string;
20
+ tool_call_id: string;
21
+ status: "success" | "error";
22
+ }
23
+ export interface ToolCallMiddleware<R extends ToolCallRequest = ToolCallRequest, T = unknown> {
24
+ name: string;
25
+ wrapToolCall(request: R, handler: (request: R) => Promise<T>): Promise<T | ToolMessageLike>;
26
+ }
27
+ export declare function gateMiddleware<R extends ToolCallRequest = ToolCallRequest, T = unknown>(options: {
28
+ gate: Gate;
29
+ tools: string[];
30
+ toInput: (request: R) => EvaluateInput;
31
+ commit?: boolean;
32
+ }): ToolCallMiddleware<R, T>;
@@ -0,0 +1,20 @@
1
+ import { describe } from "./ai-sdk.js";
2
+ export function gateMiddleware(options) {
3
+ const commit = options.commit ?? true;
4
+ const watched = new Set(options.tools);
5
+ return {
6
+ name: "proactive-gate",
7
+ async wrapToolCall(request, handler) {
8
+ if (!watched.has(request.toolCall.name))
9
+ return handler(request);
10
+ const input = options.toInput(request);
11
+ const decision = await options.gate.evaluate(input);
12
+ const refuse = (reason) => ({ type: "tool", content: `proactive-gate: ${reason}`, tool_call_id: request.toolCall.id ?? "", status: "error" });
13
+ if (!decision.allowed)
14
+ return refuse(describe(decision));
15
+ if (commit && !(await options.gate.commit(decision, input)))
16
+ return refuse("a budget was exhausted at commit");
17
+ return handler(request);
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A Mastra output processor. Put it in the agent's `outputProcessors`; when
3
+ * the gate rejects, the processor calls abort(reason) and the agent's result
4
+ * is stopped before it reaches the user.
5
+ *
6
+ * outputProcessors: [gateProcessor({ gate, toInput: ({ messages }) => ({ user, candidate: { id, type: "reply" } }) })]
7
+ */
8
+ import type { Gate } from "../gate.js";
9
+ import type { EvaluateInput } from "../types.js";
10
+ export interface ProcessorArgs<M = unknown> {
11
+ messages: M[];
12
+ abort: (reason?: string) => never;
13
+ }
14
+ export interface OutputProcessor<M = unknown> {
15
+ id: string;
16
+ processOutputResult(args: ProcessorArgs<M>): Promise<M[]>;
17
+ }
18
+ export declare function gateProcessor<M = unknown>(options: {
19
+ gate: Gate;
20
+ toInput: (args: {
21
+ messages: M[];
22
+ }) => EvaluateInput;
23
+ id?: string;
24
+ commit?: boolean;
25
+ }): OutputProcessor<M>;
@@ -0,0 +1,16 @@
1
+ import { describe } from "./ai-sdk.js";
2
+ export function gateProcessor(options) {
3
+ const commit = options.commit ?? true;
4
+ return {
5
+ id: options.id ?? "proactive-gate",
6
+ async processOutputResult({ messages, abort }) {
7
+ const input = options.toInput({ messages });
8
+ const decision = await options.gate.evaluate(input);
9
+ if (!decision.allowed)
10
+ return abort(`proactive-gate: ${describe(decision)}`);
11
+ if (commit && !(await options.gate.commit(decision, input)))
12
+ return abort("proactive-gate: a budget was exhausted at commit");
13
+ return messages;
14
+ },
15
+ };
16
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * An OpenAI Agents SDK tool input guardrail. Attach it to the send tool with
3
+ * `defineToolInputGuardrail`-compatible shape; a rejection trips the wire with
4
+ * the reason in outputInfo.
5
+ *
6
+ * tool({ name: "send_message", inputGuardrails: [gateToolInputGuardrail({ gate, toInput: ({ input }) => input.gate })] })
7
+ */
8
+ import type { Gate } from "../gate.js";
9
+ import type { EvaluateInput } from "../types.js";
10
+ export interface GuardrailArgs<I = unknown> {
11
+ input: I;
12
+ context?: unknown;
13
+ }
14
+ export interface GuardrailResult {
15
+ tripwireTriggered: boolean;
16
+ outputInfo: {
17
+ reason?: string;
18
+ surfaces?: string[];
19
+ deliverAt?: string;
20
+ };
21
+ }
22
+ export interface ToolInputGuardrail<I = unknown> {
23
+ name: string;
24
+ execute(args: GuardrailArgs<I>): Promise<GuardrailResult>;
25
+ }
26
+ export declare function gateToolInputGuardrail<I = unknown>(options: {
27
+ gate: Gate;
28
+ toInput: (args: GuardrailArgs<I>) => EvaluateInput;
29
+ name?: string;
30
+ commit?: boolean;
31
+ }): ToolInputGuardrail<I>;
@@ -0,0 +1,16 @@
1
+ import { describe } from "./ai-sdk.js";
2
+ export function gateToolInputGuardrail(options) {
3
+ const commit = options.commit ?? true;
4
+ return {
5
+ name: options.name ?? "proactive-gate",
6
+ async execute(args) {
7
+ const input = options.toInput(args);
8
+ const decision = await options.gate.evaluate(input);
9
+ if (!decision.allowed)
10
+ return { tripwireTriggered: true, outputInfo: { reason: describe(decision) } };
11
+ if (commit && !(await options.gate.commit(decision, input)))
12
+ return { tripwireTriggered: true, outputInfo: { reason: "a budget was exhausted at commit" } };
13
+ return { tripwireTriggered: false, outputInfo: { surfaces: decision.surfaces, ...(decision.deliverAt ? { deliverAt: decision.deliverAt.toISOString() } : {}) } };
14
+ },
15
+ };
16
+ }
@@ -17,8 +17,10 @@ export declare function enabled(): Check;
17
17
  export declare function mode(options: {
18
18
  allow: string[];
19
19
  }): Check;
20
- /** A global pause until an instant. */
21
- export declare function snooze(): Check;
20
+ /** A global pause until an instant. With `defer: true` the decision carries the instant as `retryAt` instead of rejecting. */
21
+ export declare function snooze(options?: {
22
+ defer?: boolean;
23
+ }): Check;
22
24
  /** Per-type mute. */
23
25
  export declare function mute(): Check;
24
26
  /**
@@ -34,6 +36,9 @@ export declare function quietHours(options?: {
34
36
  * For the first `days` after sign-up the user hears from the system only at
35
37
  * or above `minPriority`. A proactive assistant is least calibrated exactly
36
38
  * when the user is least forgiving.
39
+ *
40
+ * Seven days is a judgement, not a finding. No study sets this number, and
41
+ * none of the literature the package cites speaks to it.
37
42
  */
38
43
  export declare function trustRamp(options?: {
39
44
  days?: number;
@@ -43,6 +48,10 @@ export declare function trustRamp(options?: {
43
48
  * When the user has dismissed `dismissals` candidates of a type within
44
49
  * `withinDays`, that type stays silent for `silenceDays`. Fed by
45
50
  * gate.record(userId, candidate, "dismissed").
51
+ *
52
+ * Three in thirty buying seven days is a judgement, not a finding. The shape
53
+ * is defensible, since a dismissal is the clearest signal a user gives; the
54
+ * three numbers are ours and no study sets them.
46
55
  */
47
56
  export declare function dismissalCooldown(options?: {
48
57
  dismissals?: number;
@@ -59,23 +68,120 @@ export declare function adaptiveTiming(options?: {
59
68
  nextGoodMoment?: (ctx: CheckContext) => Promise<Date | null> | Date | null;
60
69
  surfacesFor?: (ctx: CheckContext) => Surface[] | null;
61
70
  }): 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
71
  export interface BudgetCheck extends Check {
68
72
  limit: number;
73
+ consume(ctx: CheckContext): Promise<boolean>;
69
74
  }
70
- export declare function dailyBudget(options?: {
75
+ export interface BudgetOptions {
71
76
  limit?: number;
72
77
  bypassPriority?: Priority;
73
- }): BudgetCheck;
78
+ /** Fraction of the limit at which a pass carries a nearLimit note. Default 0.8. */
79
+ nearLimit?: number;
80
+ }
74
81
  export declare const budgetKey: (userId: string, now: Date, timezone?: string) => string;
75
82
  export declare const weeklyBudgetKey: (userId: string, now: Date, timezone?: string) => string;
76
- export declare function weeklyBudget(options?: {
77
- limit?: number;
78
- bypassPriority?: Priority;
83
+ export declare const monthlyBudgetKey: (userId: string, now: Date, timezone?: string) => string;
84
+ /**
85
+ * At most `limit` deliveries per user per local day.
86
+ *
87
+ * Five is a judgement, not a finding. The direction has support: Pielot and
88
+ * Rello, "Productive, Anxious, Lonely: 24 Hours Without Push Notifications",
89
+ * MobileHCI 2017 (https://arxiv.org/abs/1612.02314), cite an in-situ log study
90
+ * (Pielot, Church and de Oliveira, MobileHCI 2014) in which participants
91
+ * received a median of 63.5 notifications a day, so a handful is far below the
92
+ * ambient load. Nothing in that work says five.
93
+ */
94
+ export declare function dailyBudget(options?: BudgetOptions): BudgetCheck;
95
+ /**
96
+ * At most `limit` deliveries per user per local ISO week.
97
+ *
98
+ * The week is the ISO week, so the counter resets on Monday morning in the
99
+ * user's zone. For a Sunday-to-Thursday working week that reset lands
100
+ * mid-week. Documented rather than fixed; changing it would move every
101
+ * existing key.
102
+ */
103
+ export declare function weeklyBudget(options?: BudgetOptions): BudgetCheck;
104
+ /** At most `limit` deliveries per user per local calendar month. */
105
+ export declare function monthlyBudget(options?: BudgetOptions): BudgetCheck;
106
+ /**
107
+ * Expected-utility alerting: act only when the caller's estimate of acceptance
108
+ * clears tau = cFA / (cFA + pNeed * cFN). That threshold is the classical Bayes
109
+ * decision boundary between the cost of alerting when the user did not want it,
110
+ * (1 - p) * cFA, and the cost of staying silent when they did, p * cFN.
111
+ * The alerting application is Horvitz, Jacobs and Hovel, "Attention-Sensitive
112
+ * Alerting", UAI 1999 (https://arxiv.org/abs/1301.6707); the system in that
113
+ * paper is named Priorities.
114
+ * `candidate.pAccept` and `candidate.pNeed` come from the caller's own model.
115
+ */
116
+ export declare function utilityFloor(options: {
117
+ costFalseAlarm: number;
118
+ costMissedHelp: number;
119
+ }): Check;
120
+ /**
121
+ * Bounded deferral: when the user is busy, wait t* = min(bound,
122
+ * lambda * interruptCost / (2 * staleness)), the optimum of a quadratic
123
+ * staleness loss against the cost of interrupting a busy person, with the
124
+ * user becoming free at rate lambda. Never rejects; only moves deliverAt.
125
+ *
126
+ * The derivation is Achlioptas and Horvitz, "Principles of Bounded Deferral
127
+ * for Balancing Information Awareness with Interruption", Microsoft Research
128
+ * (http://erichorvitz.com/Bounded_Deferral.pdf): the expected cost is
129
+ * stationary where f'(t0) = lambda * c with f''(t0) > 0, so a quadratic
130
+ * staleness f(t) = s * t^2 gives t* = lambda * c / (2 * s).
131
+ *
132
+ * `lambda` defaults to 1/43 from the same paper's field study: 113 Microsoft
133
+ * employees (42 program managers, 25 developers, 19 testers, 10 administrators,
134
+ * 9 managers, 4 in sales and marketing, 4 research scientists), three
135
+ * sequential business days between 10am and 4pm, 4,803 busy situations, mean
136
+ * busy-session duration 43.12 s with a standard deviation of 51.79 s. That
137
+ * spread matters: the same paper's two-subject Interruption Workbench analysis
138
+ * puts the mean time to a lower-cost state after an alert at 11 s for one
139
+ * person and 101 s for the other. Measure your own users before trusting it.
140
+ *
141
+ * `staleness` and `boundSeconds` are scale choices, not findings. Only the
142
+ * ratio interruptCost / staleness affects t*, so the pair below is one way to
143
+ * express "a few minutes"; nothing in the literature fixes either number.
144
+ */
145
+ export declare function boundedDeferral(options?: {
146
+ lambda?: number;
147
+ interruptCost?: number;
148
+ staleness?: number;
149
+ boundSeconds?: number;
150
+ isBusy?: (ctx: CheckContext) => boolean;
151
+ }): Check;
152
+ /** Deliveries only inside [start, end) local time in a fixed zone or the user's. */
153
+ export declare function allowedWindow(options: {
154
+ start: string;
155
+ end: string;
156
+ timezone: string;
157
+ priorityFloor?: Priority;
158
+ id?: string;
159
+ }): Check;
160
+ /** Requires `user.consents[name]`, always or only inside a local-time window. */
161
+ export declare function requiresConsent(options: {
162
+ name: string;
163
+ when?: {
164
+ start: string;
165
+ end: string;
166
+ timezone: string;
167
+ };
168
+ id?: string;
169
+ }): Check;
170
+ /** Fixed-window rate limit keyed by user or by candidate.channel; consumed at commit. */
171
+ export declare function rateLimit(options: {
172
+ limit: number;
173
+ perSeconds: number;
174
+ keyBy?: "user" | "channel";
175
+ id?: string;
176
+ }): BudgetCheck;
177
+ /** The user wrote to the assistant within the last `withinHours`. */
178
+ export declare function recentInteraction(options: {
179
+ withinHours: number;
180
+ }): Check;
181
+ /** At most `limit` deliveries in the `withinHours` window that opened with the user's last inbound message. */
182
+ export declare function windowBudget(options: {
183
+ limit: number;
184
+ withinHours: number;
79
185
  }): BudgetCheck;
80
186
  /** The LILA order, as a starting point. Replace, reorder, or drop checks freely. */
81
187
  export declare function defaultChecks(options?: {
@@ -2,6 +2,7 @@ import { PRIORITY_RANK } from "./types.js";
2
2
  const pass = { kind: "pass" };
3
3
  const reject = (reason) => ({ kind: "reject", reason });
4
4
  const skip = (reason) => ({ kind: "skip", reason });
5
+ const defer = (reason, retryAt) => ({ kind: "defer", reason, retryAt });
5
6
  const atLeast = (priority, floor) => PRIORITY_RANK[priority] >= PRIORITY_RANK[floor];
6
7
  const toDate = (value) => {
7
8
  if (value === null || value === undefined)
@@ -37,6 +38,7 @@ export function inWindow(minutes, start, end) {
37
38
  return false;
38
39
  return start < end ? minutes >= start && minutes < end : minutes >= start || minutes < end;
39
40
  }
41
+ const localDay = (now, timezone) => (timezone ? localClock(now, timezone).day : now.toISOString().slice(0, 10));
40
42
  /* ------------------------------------------------------------------------ */
41
43
  /* The checks, in the order LILA runs them. Compose your own order freely. */
42
44
  /* ------------------------------------------------------------------------ */
@@ -72,13 +74,16 @@ export function mode(options) {
72
74
  : pass,
73
75
  };
74
76
  }
75
- /** A global pause until an instant. */
76
- export function snooze() {
77
+ /** A global pause until an instant. With `defer: true` the decision carries the instant as `retryAt` instead of rejecting. */
78
+ export function snooze(options = {}) {
77
79
  return {
78
80
  id: "snooze",
79
81
  run: ({ user, now }) => {
80
82
  const until = toDate(user.snoozedUntil);
81
- return until && until > now ? reject(`snoozed until ${until.toISOString()}`) : pass;
83
+ if (!until || until <= now)
84
+ return pass;
85
+ const reason = `snoozed until ${until.toISOString()}`;
86
+ return options.defer ? defer(reason, until) : reject(reason);
82
87
  },
83
88
  };
84
89
  }
@@ -127,6 +132,9 @@ export function quietHours(options = {}) {
127
132
  * For the first `days` after sign-up the user hears from the system only at
128
133
  * or above `minPriority`. A proactive assistant is least calibrated exactly
129
134
  * when the user is least forgiving.
135
+ *
136
+ * Seven days is a judgement, not a finding. No study sets this number, and
137
+ * none of the literature the package cites speaks to it.
130
138
  */
131
139
  export function trustRamp(options = {}) {
132
140
  const days = options.days ?? 7;
@@ -148,6 +156,10 @@ export function trustRamp(options = {}) {
148
156
  * When the user has dismissed `dismissals` candidates of a type within
149
157
  * `withinDays`, that type stays silent for `silenceDays`. Fed by
150
158
  * gate.record(userId, candidate, "dismissed").
159
+ *
160
+ * Three in thirty buying seven days is a judgement, not a finding. The shape
161
+ * is defensible, since a dismissal is the clearest signal a user gives; the
162
+ * three numbers are ours and no study sets them.
151
163
  */
152
164
  export function dismissalCooldown(options = {}) {
153
165
  const n = options.dismissals ?? 3;
@@ -196,21 +208,30 @@ export function adaptiveTiming(options = {}) {
196
208
  },
197
209
  };
198
210
  }
199
- export function dailyBudget(options = {}) {
200
- const limit = options.limit ?? 5;
211
+ function budget(spec, options) {
212
+ const limit = options.limit ?? spec.defaultLimit;
213
+ const nearAt = Math.max(1, Math.ceil(limit * (options.nearLimit ?? 0.8)));
214
+ const bypass = (priority) => options.bypassPriority !== undefined && atLeast(priority, options.bypassPriority);
201
215
  return {
202
- id: "dailyBudget",
216
+ id: spec.id,
203
217
  limit,
204
- async run({ user, now, store, priority }) {
205
- if (options.bypassPriority && atLeast(priority, options.bypassPriority))
218
+ async run(ctx) {
219
+ if (bypass(ctx.priority))
206
220
  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})`);
221
+ const used = Number((await ctx.store.get(spec.keyFor(ctx))) ?? 0);
222
+ if (used >= limit)
223
+ return reject(`${spec.label} of ${limit} used (${used})`);
224
+ return used >= nearAt ? { kind: "pass", reason: `${used} of ${limit} used`, nearLimit: { used, limit } } : pass;
225
+ },
226
+ async consume(ctx) {
227
+ if (bypass(ctx.priority))
228
+ return true;
229
+ const used = await ctx.store.incr(spec.keyFor(ctx), spec.ttlSeconds);
230
+ return used <= limit;
210
231
  },
211
232
  };
212
233
  }
213
- export const budgetKey = (userId, now, timezone) => `budget:${userId}:${timezone ? localClock(now, timezone).day : now.toISOString().slice(0, 10)}`;
234
+ export const budgetKey = (userId, now, timezone) => `budget:${userId}:${localDay(now, timezone)}`;
214
235
  const isoWeekKey = (day) => {
215
236
  const date = new Date(`${day}T00:00:00Z`);
216
237
  const weekday = date.getUTCDay() || 7;
@@ -219,24 +240,175 @@ const isoWeekKey = (day) => {
219
240
  const week = Math.ceil((((date.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
220
241
  return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
221
242
  };
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
- };
243
+ export const weeklyBudgetKey = (userId, now, timezone) => `weeklyBudget:${userId}:${isoWeekKey(localDay(now, timezone))}`;
244
+ export const monthlyBudgetKey = (userId, now, timezone) => `monthlyBudget:${userId}:${localDay(now, timezone).slice(0, 7)}`;
245
+ /**
246
+ * At most `limit` deliveries per user per local day.
247
+ *
248
+ * Five is a judgement, not a finding. The direction has support: Pielot and
249
+ * Rello, "Productive, Anxious, Lonely: 24 Hours Without Push Notifications",
250
+ * MobileHCI 2017 (https://arxiv.org/abs/1612.02314), cite an in-situ log study
251
+ * (Pielot, Church and de Oliveira, MobileHCI 2014) in which participants
252
+ * received a median of 63.5 notifications a day, so a handful is far below the
253
+ * ambient load. Nothing in that work says five.
254
+ */
255
+ export function dailyBudget(options = {}) {
256
+ return budget({ id: "dailyBudget", label: "daily budget", defaultLimit: 5, keyFor: ({ user, now }) => budgetKey(user.id, now, user.timezone), ttlSeconds: 2 * DAY_SECONDS }, options);
257
+ }
258
+ /**
259
+ * At most `limit` deliveries per user per local ISO week.
260
+ *
261
+ * The week is the ISO week, so the counter resets on Monday morning in the
262
+ * user's zone. For a Sunday-to-Thursday working week that reset lands
263
+ * mid-week. Documented rather than fixed; changing it would move every
264
+ * existing key.
265
+ */
226
266
  export function weeklyBudget(options = {}) {
227
- const limit = options.limit ?? 20;
267
+ return budget({ id: "weeklyBudget", label: "weekly budget", defaultLimit: 20, keyFor: ({ user, now }) => weeklyBudgetKey(user.id, now, user.timezone), ttlSeconds: 8 * DAY_SECONDS }, options);
268
+ }
269
+ /** At most `limit` deliveries per user per local calendar month. */
270
+ export function monthlyBudget(options = {}) {
271
+ return budget({ id: "monthlyBudget", label: "monthly budget", defaultLimit: 60, keyFor: ({ user, now }) => monthlyBudgetKey(user.id, now, user.timezone), ttlSeconds: 32 * DAY_SECONDS }, options);
272
+ }
273
+ /* ------------------------------------------------------------------------ */
274
+ /* Optional, caller-fed checks. Off by default; the package ships no model. */
275
+ /* ------------------------------------------------------------------------ */
276
+ /**
277
+ * Expected-utility alerting: act only when the caller's estimate of acceptance
278
+ * clears tau = cFA / (cFA + pNeed * cFN). That threshold is the classical Bayes
279
+ * decision boundary between the cost of alerting when the user did not want it,
280
+ * (1 - p) * cFA, and the cost of staying silent when they did, p * cFN.
281
+ * The alerting application is Horvitz, Jacobs and Hovel, "Attention-Sensitive
282
+ * Alerting", UAI 1999 (https://arxiv.org/abs/1301.6707); the system in that
283
+ * paper is named Priorities.
284
+ * `candidate.pAccept` and `candidate.pNeed` come from the caller's own model.
285
+ */
286
+ export function utilityFloor(options) {
287
+ const { costFalseAlarm: cFA, costMissedHelp: cFN } = options;
228
288
  return {
229
- id: "weeklyBudget",
230
- limit,
231
- async run({ user, now, store, priority }) {
232
- if (options.bypassPriority && atLeast(priority, options.bypassPriority))
289
+ id: "utilityFloor",
290
+ run: ({ candidate }) => {
291
+ if (typeof candidate.pAccept !== "number")
292
+ return skip("no pAccept on the candidate; utility floor cannot be evaluated");
293
+ const pNeed = typeof candidate.pNeed === "number" ? candidate.pNeed : 1;
294
+ const tau = cFA / (cFA + pNeed * cFN);
295
+ return candidate.pAccept >= tau ? pass : reject(`pAccept ${round3(candidate.pAccept)} < tau ${round3(tau)}`);
296
+ },
297
+ };
298
+ }
299
+ const round3 = (n) => Math.round(n * 1000) / 1000;
300
+ /**
301
+ * Bounded deferral: when the user is busy, wait t* = min(bound,
302
+ * lambda * interruptCost / (2 * staleness)), the optimum of a quadratic
303
+ * staleness loss against the cost of interrupting a busy person, with the
304
+ * user becoming free at rate lambda. Never rejects; only moves deliverAt.
305
+ *
306
+ * The derivation is Achlioptas and Horvitz, "Principles of Bounded Deferral
307
+ * for Balancing Information Awareness with Interruption", Microsoft Research
308
+ * (http://erichorvitz.com/Bounded_Deferral.pdf): the expected cost is
309
+ * stationary where f'(t0) = lambda * c with f''(t0) > 0, so a quadratic
310
+ * staleness f(t) = s * t^2 gives t* = lambda * c / (2 * s).
311
+ *
312
+ * `lambda` defaults to 1/43 from the same paper's field study: 113 Microsoft
313
+ * employees (42 program managers, 25 developers, 19 testers, 10 administrators,
314
+ * 9 managers, 4 in sales and marketing, 4 research scientists), three
315
+ * sequential business days between 10am and 4pm, 4,803 busy situations, mean
316
+ * busy-session duration 43.12 s with a standard deviation of 51.79 s. That
317
+ * spread matters: the same paper's two-subject Interruption Workbench analysis
318
+ * puts the mean time to a lower-cost state after an alert at 11 s for one
319
+ * person and 101 s for the other. Measure your own users before trusting it.
320
+ *
321
+ * `staleness` and `boundSeconds` are scale choices, not findings. Only the
322
+ * ratio interruptCost / staleness affects t*, so the pair below is one way to
323
+ * express "a few minutes"; nothing in the literature fixes either number.
324
+ */
325
+ export function boundedDeferral(options = {}) {
326
+ const lambda = options.lambda ?? 1 / 43;
327
+ const cost = options.interruptCost ?? 1;
328
+ const staleness = options.staleness ?? 0.0001;
329
+ const bound = options.boundSeconds ?? 240;
330
+ const tStar = Math.min(bound, (lambda * cost) / (2 * staleness));
331
+ return {
332
+ id: "boundedDeferral",
333
+ nonRejecting: true,
334
+ run: (ctx) => {
335
+ const busy = options.isBusy ? options.isBusy(ctx) : ctx.candidate.busy === true;
336
+ if (!busy)
233
337
  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})`);
338
+ const at = new Date(ctx.now.getTime() + Math.round(tStar * 1000));
339
+ return { kind: "adjust", reason: `user busy; deliver at ${at.toISOString()} (t* ${Math.round(tStar)} s)`, deliverAt: at };
340
+ },
341
+ };
342
+ }
343
+ /* ------------------------------------------------------------------------ */
344
+ /* Primitives the presets compose. */
345
+ /* ------------------------------------------------------------------------ */
346
+ const zoneOf = (ctx, timezone) => (timezone === "user" ? ctx.user.timezone : timezone);
347
+ /** Deliveries only inside [start, end) local time in a fixed zone or the user's. */
348
+ export function allowedWindow(options) {
349
+ const start = parseHHMM(options.start);
350
+ const end = parseHHMM(options.end);
351
+ return {
352
+ id: options.id ?? "allowedWindow",
353
+ run: (ctx) => {
354
+ const zone = zoneOf(ctx, options.timezone);
355
+ if (!zone)
356
+ return skip("no timezone on the user; window cannot be evaluated");
357
+ if (options.priorityFloor && atLeast(ctx.priority, options.priorityFloor))
358
+ return pass;
359
+ const { minutes } = localClock(ctx.now, zone);
360
+ return inWindow(minutes, start, end) ? pass : reject(`outside the allowed window ${options.start} to ${options.end} ${zone}`);
361
+ },
362
+ };
363
+ }
364
+ /** Requires `user.consents[name]`, always or only inside a local-time window. */
365
+ export function requiresConsent(options) {
366
+ const when = options.when ? { start: parseHHMM(options.when.start), end: parseHHMM(options.when.end), timezone: options.when.timezone } : null;
367
+ return {
368
+ id: options.id ?? `consent:${options.name}`,
369
+ run: (ctx) => {
370
+ if (when) {
371
+ const zone = zoneOf(ctx, when.timezone);
372
+ if (!zone)
373
+ return skip("no timezone on the user; consent window cannot be evaluated");
374
+ if (!inWindow(localClock(ctx.now, zone).minutes, when.start, when.end))
375
+ return pass;
376
+ }
377
+ return ctx.user.consents?.[options.name] ? pass : reject(`consent "${options.name}" is missing${when ? ` (required ${options.when.start} to ${options.when.end})` : ""}`);
237
378
  },
238
379
  };
239
380
  }
381
+ /** Fixed-window rate limit keyed by user or by candidate.channel; consumed at commit. */
382
+ export function rateLimit(options) {
383
+ const keyBy = options.keyBy ?? "user";
384
+ const keyFor = (ctx) => {
385
+ const scope = keyBy === "channel" ? ctx.candidate.channel ?? ctx.user.id : ctx.user.id;
386
+ return `rate:${keyBy}:${scope}:${options.perSeconds}:${Math.floor(ctx.now.getTime() / 1000 / options.perSeconds)}`;
387
+ };
388
+ const id = options.id ?? `rate:${options.limit}/${options.perSeconds}s`;
389
+ return budget({ id, label: `rate limit ${options.limit} per ${options.perSeconds} s`, defaultLimit: options.limit, keyFor, ttlSeconds: options.perSeconds * 2 }, { limit: options.limit, nearLimit: 1 });
390
+ }
391
+ /** The user wrote to the assistant within the last `withinHours`. */
392
+ export function recentInteraction(options) {
393
+ return {
394
+ id: "recentInteraction",
395
+ run: ({ user, now }) => {
396
+ const last = toDate(user.lastInboundAt);
397
+ if (!last)
398
+ return reject("no inbound message from the user on record");
399
+ const age = (now.getTime() - last.getTime()) / 3600000;
400
+ return age <= options.withinHours ? pass : reject(`last inbound message ${Math.floor(age)} h ago, window is ${options.withinHours} h`);
401
+ },
402
+ };
403
+ }
404
+ /** At most `limit` deliveries in the `withinHours` window that opened with the user's last inbound message. */
405
+ export function windowBudget(options) {
406
+ const keyFor = ({ user }) => {
407
+ const last = toDate(user.lastInboundAt);
408
+ return `windowBudget:${user.id}:${last ? Math.floor(last.getTime() / 1000) : "none"}`;
409
+ };
410
+ return budget({ id: "windowBudget", label: `window budget`, defaultLimit: options.limit, keyFor, ttlSeconds: options.withinHours * 3600 }, { limit: options.limit, nearLimit: 1 });
411
+ }
240
412
  /** The LILA order, as a starting point. Replace, reorder, or drop checks freely. */
241
413
  export function defaultChecks(options = {}) {
242
414
  return [
package/dist/src/cli.d.ts CHANGED
@@ -1,6 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import type { Gate } from "./gate.js";
3
- import type { Decision } from "./types.js";
3
+ import type { Decision, EvaluateInput } from "./types.js";
4
4
  export declare function loadPolicy(path?: string): Promise<Gate>;
5
5
  export declare function replay(lines: string[], gate: Gate, commit: boolean): Promise<Decision[]>;
6
6
  export declare function summarize(decisions: Decision[]): string;
7
+ interface PreToolUseEvent {
8
+ tool_name?: string;
9
+ tool_input?: {
10
+ gate?: {
11
+ user: EvaluateInput["user"];
12
+ candidate: EvaluateInput["candidate"];
13
+ now?: string;
14
+ };
15
+ };
16
+ }
17
+ /** Turns a PreToolUse event into the hook output Claude Code expects, or null when the tool does not match. */
18
+ export declare function hookDecision(event: PreToolUseEvent, gate: Gate, tool: string): Promise<string | null>;
19
+ export {};