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.
- package/README.md +305 -11
- package/README.tr.md +209 -3
- package/dist/src/adapters/ai-sdk.d.ts +35 -0
- package/dist/src/adapters/ai-sdk.js +17 -0
- package/dist/src/adapters/langchain.d.ts +32 -0
- package/dist/src/adapters/langchain.js +20 -0
- package/dist/src/adapters/mastra.d.ts +25 -0
- package/dist/src/adapters/mastra.js +16 -0
- package/dist/src/adapters/openai-agents.d.ts +31 -0
- package/dist/src/adapters/openai-agents.js +16 -0
- package/dist/src/checks.d.ts +118 -12
- package/dist/src/checks.js +196 -24
- package/dist/src/cli.d.ts +14 -1
- package/dist/src/cli.js +154 -24
- package/dist/src/conformance.d.ts +42 -0
- package/dist/src/conformance.js +83 -0
- package/dist/src/gate.d.ts +13 -5
- package/dist/src/gate.js +73 -28
- package/dist/src/index.d.ts +6 -3
- package/dist/src/index.js +3 -1
- package/dist/src/init.d.ts +16 -0
- package/dist/src/init.js +115 -0
- package/dist/src/policy.d.ts +8 -0
- package/dist/src/policy.js +74 -0
- package/dist/src/presets.d.ts +9 -0
- package/dist/src/presets.js +45 -0
- package/dist/src/stores.js +5 -8
- package/dist/src/types.d.ts +74 -2
- package/package.json +57 -9
- package/dist/test/gate.test.d.ts +0 -1
- package/dist/test/gate.test.js +0 -261
|
@@ -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
|
+
}
|
package/dist/src/checks.d.ts
CHANGED
|
@@ -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(
|
|
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
|
|
75
|
+
export interface BudgetOptions {
|
|
71
76
|
limit?: number;
|
|
72
77
|
bypassPriority?: Priority;
|
|
73
|
-
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
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?: {
|
package/dist/src/checks.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
200
|
-
const limit = options.limit ??
|
|
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:
|
|
216
|
+
id: spec.id,
|
|
203
217
|
limit,
|
|
204
|
-
async run(
|
|
205
|
-
if (
|
|
218
|
+
async run(ctx) {
|
|
219
|
+
if (bypass(ctx.priority))
|
|
206
220
|
return pass;
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
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}:${
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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: "
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
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 {};
|