proactive-gate 0.2.1 → 0.2.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
@@ -100,12 +100,37 @@ something returned false".
100
100
  | 5 | `snooze()` | `user.snoozedUntil` is in the future | global pause |
101
101
  | 6 | `mute()` | `candidate.type` is in `user.mutedTypes` | per-type mute |
102
102
  | 7 | `intensity()` | priority is below the user's intensity floor | low hears only high, normal hears normal and up, high hears everything |
103
- | 8 | `quietHours({ priorityFloor })` | inside the user's local quiet window | IANA time zone, window may cross midnight, bypassed at or above the floor |
103
+ | 8 | `quietHours({ priorityFloor })` | inside the user's local quiet window | IANA time zone, window may cross midnight, bypassed at or above the floor; one window every day or [a schedule per day](#quiet-hours-that-differ-by-day) |
104
104
  | 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 |
105
105
  | 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 |
106
106
  | 11 | `adaptiveTiming({ nextGoodMoment, surfacesFor })` | never | non-rejecting: moves `deliverAt` or narrows surfaces; a check marked `nonRejecting` cannot reject even if it tries |
107
107
  | 12 | `dailyBudget({ limit, bypassPriority })` | the user's local-day counter is at the limit | `evaluate` reads, `commit` increments atomically and can still refuse |
108
108
 
109
+ ### Quiet hours that differ by day
110
+
111
+ A working week is not Monday to Friday everywhere, and a holiday is not a weekday at all.
112
+ `quietHours` takes a schedule as well as a single window:
113
+
114
+ ```ts
115
+ quietHours: {
116
+ default: { start: "22:00", end: "08:00" },
117
+ days: { fri: { start: "00:00", end: "23:59" }, sat: { start: "00:00", end: "23:59" }, sun: null },
118
+ dates: { "2026-12-25": { start: "00:00", end: "23:59" } },
119
+ }
120
+ ```
121
+
122
+ A date beats a weekday beats the default, and `null` means the day has no quiet hours, which is
123
+ how a working day is carved out of a default. A window belongs to the day it opens on, so one
124
+ that crosses midnight silences the next morning and the reason names the day it came from.
125
+
126
+ Two things this deliberately does not do. There is no bundled holiday calendar: the dates you
127
+ observe are yours to supply, and a bundled one goes stale without anyone noticing. And one row
128
+ cannot express more than 24 hours, so a Friday evening to Saturday evening silence is two rows,
129
+ `fri: 18:00 to 00:00` and `sat: 00:00 to 20:00`.
130
+
131
+ Passing a single window is unchanged and remains the common case; a schedule whose every day
132
+ resolves to the same window behaves identically to that window.
133
+
109
134
  `weeklyBudget({ limit, bypassPriority })` is the same shape keyed on the user's local ISO
110
135
  week; `defaultChecks({ weeklyLimit })` places it just before the daily one. Budgets are
111
136
  consumed in check order at commit, so when a weekly check passes and the daily one then
@@ -472,6 +497,10 @@ preset) still refuses. Both are part of `npm run examples` and of the test suite
472
497
  pip install proactive-gate
473
498
  ```
474
499
 
500
+ To run an unreleased state, install from the repository instead: `pip install "proactive-gate @
501
+ git+https://github.com/Bubblegunn/proactive-gate#subdirectory=python"`. The published release was
502
+ uploaded from a local build with a token, so unlike the npm package it carries no build provenance.
503
+
475
504
  ```python
476
505
  from proactive_gate import Gate
477
506
  gate = Gate.from_policy(policy) # the same policy.json
package/README.tr.md CHANGED
@@ -309,6 +309,10 @@ kurmak gerekmez. Her biri kapının gerekçesiyle reddeder ve onayda bütçeyi t
309
309
  pip install proactive-gate
310
310
  ```
311
311
 
312
+ Yayınlanmamış bir durumu denemek için depodan kurulur: `pip install "proactive-gate @
313
+ git+https://github.com/Bubblegunn/proactive-gate#subdirectory=python"`. Yayınlanan sürüm yerel bir
314
+ derlemeden token ile yüklendi; npm paketinin aksine derleme kanıtı taşımıyor.
315
+
312
316
  `python/` sapan bir port değil, bir kardeştir: `spec/fixtures` altındaki her senaryoyu senkron
313
317
  `Gate` ve `AsyncGate` (Redis, `redis.asyncio` üzerinden) ile geçer; mypy strict, CI'da Python
314
318
  3.11 ve 3.13. Bkz. [`python/README.md`](python/README.md).
@@ -1,4 +1,4 @@
1
- import type { Check, CheckContext, Priority, Surface } from "./types.js";
1
+ import type { Check, CheckContext, Priority, QuietSchedule, QuietWindow, Surface, Weekday } from "./types.js";
2
2
  export declare const DAY_SECONDS: number;
3
3
  /** Local "HH:MM" and calendar day for an instant in an IANA zone, using Intl only. */
4
4
  export declare function localClock(now: Date, timezone: string): {
@@ -7,6 +7,30 @@ export declare function localClock(now: Date, timezone: string): {
7
7
  };
8
8
  /** True when `minutes` falls inside [start, end), where the window may cross midnight. */
9
9
  export declare function inWindow(minutes: number, start: number, end: number): boolean;
10
+ /**
11
+ * The weekday of a local calendar date, and the date before it.
12
+ *
13
+ * Both are pure calendar arithmetic on the "YYYY-MM-DD" that `localClock` already
14
+ * resolved through Intl, never arithmetic on an instant. That is what keeps zones
15
+ * with a 45-minute offset (Kathmandu, Chatham, Eucla) and every daylight-saving
16
+ * transition out of this: the offset was applied before we got here.
17
+ */
18
+ export declare function weekdayOf(day: string): Weekday;
19
+ export declare function dayBefore(day: string): string;
20
+ /** The window in force on one local date: a date beats a weekday beats the default. */
21
+ export declare function windowFor(quiet: QuietWindow | QuietSchedule, day: string): QuietWindow | null;
22
+ /**
23
+ * Whether a local time is inside quiet hours, and which day's window says so.
24
+ *
25
+ * A window that crosses midnight belongs to the day it opens on, so a time can be
26
+ * quiet because of yesterday: Friday 18:00 to 00:00 silences Saturday 00:00 too.
27
+ * With one window every day this reduces exactly to `inWindow`, which is why the
28
+ * single-window form keeps behaving as it did.
29
+ */
30
+ export declare function quietAt(quiet: QuietWindow | QuietSchedule, day: string, minutes: number): {
31
+ window: QuietWindow;
32
+ day: string;
33
+ } | null;
10
34
  /** A production hard-stop that silences every producer at once. */
11
35
  export declare function killSwitch(isOn: () => boolean | Promise<boolean>): Check;
12
36
  /** Consent comes before everything, or you have evaluated preferences for someone who never agreed. */
@@ -39,6 +39,61 @@ export function inWindow(minutes, start, end) {
39
39
  return start < end ? minutes >= start && minutes < end : minutes >= start || minutes < end;
40
40
  }
41
41
  const localDay = (now, timezone) => (timezone ? localClock(now, timezone).day : now.toISOString().slice(0, 10));
42
+ const WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
43
+ /**
44
+ * The weekday of a local calendar date, and the date before it.
45
+ *
46
+ * Both are pure calendar arithmetic on the "YYYY-MM-DD" that `localClock` already
47
+ * resolved through Intl, never arithmetic on an instant. That is what keeps zones
48
+ * with a 45-minute offset (Kathmandu, Chatham, Eucla) and every daylight-saving
49
+ * transition out of this: the offset was applied before we got here.
50
+ */
51
+ export function weekdayOf(day) {
52
+ const [y, m, d] = day.split("-").map(Number);
53
+ return WEEKDAYS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()];
54
+ }
55
+ export function dayBefore(day) {
56
+ const [y, m, d] = day.split("-").map(Number);
57
+ return new Date(Date.UTC(y, m - 1, d - 1)).toISOString().slice(0, 10);
58
+ }
59
+ const isSchedule = (q) => !("start" in q);
60
+ /** The window in force on one local date: a date beats a weekday beats the default. */
61
+ export function windowFor(quiet, day) {
62
+ if (!isSchedule(quiet))
63
+ return quiet;
64
+ const byDate = quiet.dates?.[day];
65
+ if (byDate !== undefined)
66
+ return byDate;
67
+ const byDay = quiet.days?.[weekdayOf(day)];
68
+ if (byDay !== undefined)
69
+ return byDay;
70
+ return quiet.default ?? null;
71
+ }
72
+ /**
73
+ * Whether a local time is inside quiet hours, and which day's window says so.
74
+ *
75
+ * A window that crosses midnight belongs to the day it opens on, so a time can be
76
+ * quiet because of yesterday: Friday 18:00 to 00:00 silences Saturday 00:00 too.
77
+ * With one window every day this reduces exactly to `inWindow`, which is why the
78
+ * single-window form keeps behaving as it did.
79
+ */
80
+ export function quietAt(quiet, day, minutes) {
81
+ const today = windowFor(quiet, day);
82
+ if (today) {
83
+ const start = parseHHMM(today.start);
84
+ const end = parseHHMM(today.end);
85
+ if (start !== end && (start < end ? minutes >= start && minutes < end : minutes >= start))
86
+ return { window: today, day };
87
+ }
88
+ const yesterday = windowFor(quiet, dayBefore(day));
89
+ if (yesterday) {
90
+ const start = parseHHMM(yesterday.start);
91
+ const end = parseHHMM(yesterday.end);
92
+ if (start > end && minutes < end)
93
+ return { window: yesterday, day: dayBefore(day) };
94
+ }
95
+ return null;
96
+ }
42
97
  /* ------------------------------------------------------------------------ */
43
98
  /* The checks, in the order LILA runs them. Compose your own order freely. */
44
99
  /* ------------------------------------------------------------------------ */
@@ -117,14 +172,16 @@ export function quietHours(options = {}) {
117
172
  return pass;
118
173
  if (!user.timezone)
119
174
  return skip("quiet hours set but no timezone on the user; cannot evaluate");
120
- const { minutes } = localClock(now, user.timezone);
121
- const start = parseHHMM(user.quietHours.start);
122
- const end = parseHHMM(user.quietHours.end);
123
- if (!inWindow(minutes, start, end))
175
+ const { minutes, day } = localClock(now, user.timezone);
176
+ const hit = quietAt(user.quietHours, day, minutes);
177
+ if (!hit)
124
178
  return pass;
125
179
  if (atLeast(priority, floor))
126
180
  return pass;
127
- return reject(`quiet hours ${user.quietHours.start} to ${user.quietHours.end} ${user.timezone}; priority ${priority} is below the floor (${floor})`);
181
+ // Name the day the window came from: when it crossed midnight the reason is
182
+ // yesterday's setting, and a reader looking at today's would not find it.
183
+ const whose = hit.day === day ? "" : ` (${weekdayOf(hit.day)} ${hit.day})`;
184
+ return reject(`quiet hours ${hit.window.start} to ${hit.window.end}${whose} ${user.timezone}; priority ${priority} is below the floor (${floor})`);
128
185
  },
129
186
  };
130
187
  }
@@ -3,6 +3,27 @@ export type Priority = "low" | "normal" | "high" | "critical";
3
3
  export declare const PRIORITY_RANK: Record<Priority, number>;
4
4
  /** Where a delivery may land. Free-form so callers can add their own. */
5
5
  export type Surface = "feed" | "push" | "chat" | "voice" | "email" | (string & {});
6
+ /** A quiet window in local time, "HH:MM" to "HH:MM". `start` after `end` crosses midnight. */
7
+ export type QuietWindow = {
8
+ start: string;
9
+ end: string;
10
+ };
11
+ /** Weekday keys for a quiet-hours schedule, Sunday first to match `Date#getUTCDay`. */
12
+ export type Weekday = "sun" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat";
13
+ /**
14
+ * Quiet hours that differ by day. A working week is not Monday to Friday everywhere,
15
+ * and a holiday is not a weekday at all, so the window is resolved per day: a calendar
16
+ * date first, then the weekday, then the default. `null` at any level means the day has
17
+ * no quiet hours.
18
+ *
19
+ * There is no bundled holiday calendar and there will not be one: the dates a caller
20
+ * observes are the caller's to supply, and a bundled calendar goes stale silently.
21
+ */
22
+ export type QuietSchedule = {
23
+ default?: QuietWindow | null;
24
+ days?: Partial<Record<Weekday, QuietWindow | null>>;
25
+ dates?: Record<string, QuietWindow | null>;
26
+ };
6
27
  /** Everything the gate knows about the person it might interrupt. */
7
28
  export interface UserState {
8
29
  id: string;
@@ -20,11 +41,25 @@ export interface UserState {
20
41
  intensity?: "low" | "normal" | "high";
21
42
  /** IANA time zone, required for quiet hours. */
22
43
  timezone?: string;
23
- /** Quiet hours in local time, "HH:MM". May cross midnight. */
24
- quietHours?: {
25
- start: string;
26
- end: string;
27
- } | null;
44
+ /**
45
+ * Quiet hours in local time, "HH:MM". May cross midnight.
46
+ *
47
+ * One window applies every day. A schedule gives a window per weekday, and per
48
+ * calendar date for the days a weekday cannot express, such as a public holiday:
49
+ *
50
+ * ```ts
51
+ * quietHours: {
52
+ * default: { start: "22:00", end: "08:00" },
53
+ * days: { fri: { start: "18:00", end: "00:00" }, sat: { start: "00:00", end: "20:00" } },
54
+ * dates: { "2026-12-25": { start: "00:00", end: "23:59" } },
55
+ * }
56
+ * ```
57
+ *
58
+ * `null` for a weekday or a date means no quiet hours that day, which is how you
59
+ * carve a working day out of a default. A date beats a weekday, a weekday beats
60
+ * the default. Dates are the user's local calendar dates, "YYYY-MM-DD".
61
+ */
62
+ quietHours?: QuietWindow | QuietSchedule | null;
28
63
  /** When the user joined. Drives the trust ramp. */
29
64
  createdAt?: Date | string;
30
65
  /** Surfaces the user allows, in preference order. Defaults to the candidate's surfaces. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proactive-gate",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Decide whether a proactive AI agent may reach a user right now, and log why not. Ordered checks as code or JSON, a conformance spec, presets for platform and legal limits, adapters for AI SDK, Mastra, LangChain and OpenAI Agents, and a Python sibling.",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",