@oar-sh/oar 0.9.14 → 0.9.16
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/CHANGELOG.md +179 -0
- package/README.md +9 -9
- package/bin/oar.js +525 -12
- package/package.json +3 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-session-process.mjs +4 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
- package/server/db-schema.mjs +52 -0
- package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
- package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
- package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
- package/server/migrations/0022-relay-schedules.mjs +142 -0
- package/server/migrations/0023-relay-asks.mjs +141 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +61 -3
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/inbox-ui.js +356 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +23 -2
- package/server/public/app/relay-event-subscriptions-ui.js +4 -2
- package/server/public/app/relay-event-view.mjs +33 -7
- package/server/public/app/relay-schedule-view.mjs +119 -0
- package/server/public/app/relay-schedules-ui.js +172 -0
- package/server/public/app/settings-modal.js +17 -3
- package/server/public/app/socket-handlers.js +27 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +159 -2
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +97 -11
- package/server/repositories/remote-relay-repository.mjs +20 -2
- package/server/repositories/session-repository.mjs +7 -1
- package/server/routes/ask-user-routes.mjs +57 -3
- package/server/routes/cache-routes.mjs +6 -0
- package/server/routes/conversation-group-routes.mjs +17 -2
- package/server/routes/messages-routes.mjs +3 -3
- package/server/routes/relay-ask-routes.mjs +387 -0
- package/server/routes/relay-event-routes.mjs +3 -1
- package/server/routes/relay-schedule-routes.mjs +57 -0
- package/server/routes/sessions-routes.mjs +613 -56
- package/server/server-runtime.mjs +80 -1
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/oar-cli-helpers.mjs +12 -4
- package/server/services/relay-ask-link-service.mjs +174 -0
- package/server/services/relay-ask-service.mjs +604 -0
- package/server/services/relay-event-service.mjs +589 -73
- package/server/services/relay-schedule-service.mjs +596 -0
- package/server/services/remote-relay-caller-context.mjs +89 -20
- package/server/services/remote-relay-dispatcher.mjs +956 -30
- package/server/services/windows-autostart-service.mjs +88 -16
- package/server/services/windows-boot-autostart-service.mjs +775 -126
- package/shared/relay-asks.mjs +145 -0
- package/shared/relay-events.mjs +268 -18
- package/shared/relay-schedules.mjs +128 -0
- package/shared/remote-relay-contract.mjs +553 -25
- package/shared/remote-relay-tool-core.mjs +16 -5
- package/.github/extensions/web-relay/README.md +0 -190
- package/server/README.md +0 -2334
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Questions that don't block: remote_relay's `ask` puts a question for the
|
|
2
|
+
// owner into a session (a card there and in the Inbox) and returns at once;
|
|
3
|
+
// the asking session keeps working and is told the answer later, as a relay
|
|
4
|
+
// event (question_answered).
|
|
5
|
+
//
|
|
6
|
+
// One ask is `{ id, kind: 'ask', session, question, options, recommendation,
|
|
7
|
+
// detail, urgency, status, answer, choice, createdBy, creator, ... }`. Its
|
|
8
|
+
// `status` is pending until the owner answers or dismisses it, or the agent
|
|
9
|
+
// that asked withdraws it.
|
|
10
|
+
//
|
|
11
|
+
// Every text of an ask is written by an agent, possibly on another relay:
|
|
12
|
+
// whoever shows it shows it as plain text.
|
|
13
|
+
//
|
|
14
|
+
// SDK-free and side-effect free: imported by the server, the remote_relay
|
|
15
|
+
// contract and the browser.
|
|
16
|
+
|
|
17
|
+
// The kind a question card carries in /api/relay-questions and the Inbox:
|
|
18
|
+
// `ask` (this feature), `approval` (a relay's Allow / Deny card on its own
|
|
19
|
+
// agent's call, context.source 'remote_relay'), `ask_user` (every other card a
|
|
20
|
+
// turn waits on).
|
|
21
|
+
export const RELAY_QUESTION_KINDS = Object.freeze(['ask', 'ask_user', 'approval']);
|
|
22
|
+
export const RELAY_ASK_KIND = 'ask';
|
|
23
|
+
|
|
24
|
+
export const RELAY_ASK_URGENCIES = Object.freeze(['normal', 'now']);
|
|
25
|
+
export const RELAY_ASK_STATUSES = Object.freeze(['pending', 'answered', 'dismissed', 'withdrawn', 'cancelled']);
|
|
26
|
+
// What the Inbox lists under its divider (for answeredKeepMs).
|
|
27
|
+
export const RELAY_ASK_CLOSED_STATUSES = Object.freeze(['answered', 'dismissed']);
|
|
28
|
+
|
|
29
|
+
// Socket event the relay sends on every change of an ask (`{ ask }`) and
|
|
30
|
+
// of the Inbox's count (`{ pending }`).
|
|
31
|
+
export const RELAY_ASK_SOCKET_EVENT = 'relay_ask_updated';
|
|
32
|
+
|
|
33
|
+
export const RELAY_ASK_LIMITS = Object.freeze({
|
|
34
|
+
questionMax: 500,
|
|
35
|
+
optionsMax: 8,
|
|
36
|
+
optionMax: 120,
|
|
37
|
+
recommendationMax: 300,
|
|
38
|
+
detailMax: 2000,
|
|
39
|
+
// Pending asks one conversation may have asked (for itself and others), and
|
|
40
|
+
// pending asks one session may have open.
|
|
41
|
+
pendingPerConversation: 20,
|
|
42
|
+
// How long an answered or dismissed ask stays in the Inbox, under its divider.
|
|
43
|
+
answeredKeepMs: 24 * 60 * 60_000,
|
|
44
|
+
// Closed asks are kept this long, then pruned.
|
|
45
|
+
closedKeepMs: 7 * 24 * 60 * 60_000,
|
|
46
|
+
// The asking relay looks for answers to its asks on a paired relay this often.
|
|
47
|
+
remotePollMs: 5_000,
|
|
48
|
+
remotePollFailingMs: 30_000,
|
|
49
|
+
listMax: 200,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Ask ids carry this prefix: the question routes tell them from cards by it.
|
|
53
|
+
export const RELAY_ASK_ID_PREFIX = 'ask_';
|
|
54
|
+
|
|
55
|
+
export function isRelayAskId(value) {
|
|
56
|
+
return /^ask_[a-z0-9]{6,40}$/.test(String(value ?? '').trim());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toText(value) {
|
|
60
|
+
return String(value ?? '').trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Control characters of agent text go; line breaks stay in the detail only.
|
|
64
|
+
const CONTROLS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u2028\u2029]+/g;
|
|
65
|
+
|
|
66
|
+
function oneLine(value) {
|
|
67
|
+
return String(value ?? '').replace(CONTROLS, ' ').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function multiLine(value) {
|
|
71
|
+
return String(value ?? '').replace(/\r\n?/g, '\n').replace(CONTROLS, ' ').replace(/[ \t]+\n/g, '\n').trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function invalid(error) {
|
|
75
|
+
return { ok: false, error };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* An ask as an agent writes it, checked: `{ ok, value: { question, options,
|
|
80
|
+
* recommendation, detail, urgency } }` or `{ ok:false, error }`. Too long is
|
|
81
|
+
* refused, never cut: the owner decides on the words the agent chose. The
|
|
82
|
+
* recommendation may name an option (matched case-insensitively, then given
|
|
83
|
+
* the option's spelling) or be free text.
|
|
84
|
+
*/
|
|
85
|
+
export function normalizeRelayAskInput(input = {}) {
|
|
86
|
+
const source = input && typeof input === 'object' ? input : {};
|
|
87
|
+
const question = oneLine(source.question);
|
|
88
|
+
if (!question) return invalid('ask needs question (the question for the owner, at most 500 characters)');
|
|
89
|
+
if (question.length > RELAY_ASK_LIMITS.questionMax) {
|
|
90
|
+
return invalid(`question is longer than ${RELAY_ASK_LIMITS.questionMax} characters; put the background in detail`);
|
|
91
|
+
}
|
|
92
|
+
let rawOptions = source.options;
|
|
93
|
+
if (rawOptions === undefined || rawOptions === null || rawOptions === '') rawOptions = [];
|
|
94
|
+
if (typeof rawOptions === 'string') rawOptions = rawOptions.split(/\r?\n/);
|
|
95
|
+
if (!Array.isArray(rawOptions)) return invalid('options must be a list of short labels');
|
|
96
|
+
const options = [];
|
|
97
|
+
for (const raw of rawOptions) {
|
|
98
|
+
if (typeof raw !== 'string' && typeof raw !== 'number') return invalid('options must be a list of short labels');
|
|
99
|
+
const label = oneLine(raw);
|
|
100
|
+
if (!label) continue;
|
|
101
|
+
if (label.length > RELAY_ASK_LIMITS.optionMax) return invalid(`an option is longer than ${RELAY_ASK_LIMITS.optionMax} characters: "${label.slice(0, 40)}…"`);
|
|
102
|
+
if (options.some((option) => option.toLowerCase() === label.toLowerCase())) continue;
|
|
103
|
+
options.push(label);
|
|
104
|
+
}
|
|
105
|
+
if (options.length > RELAY_ASK_LIMITS.optionsMax) return invalid(`options takes at most ${RELAY_ASK_LIMITS.optionsMax} labels`);
|
|
106
|
+
let recommendation = oneLine(source.recommendation);
|
|
107
|
+
if (recommendation.length > RELAY_ASK_LIMITS.recommendationMax) {
|
|
108
|
+
return invalid(`recommendation is longer than ${RELAY_ASK_LIMITS.recommendationMax} characters`);
|
|
109
|
+
}
|
|
110
|
+
const named = options.find((option) => option.toLowerCase() === recommendation.toLowerCase());
|
|
111
|
+
if (named) recommendation = named;
|
|
112
|
+
const detail = multiLine(source.detail);
|
|
113
|
+
if (detail.length > RELAY_ASK_LIMITS.detailMax) return invalid(`detail is longer than ${RELAY_ASK_LIMITS.detailMax} characters`);
|
|
114
|
+
const urgency = toText(source.urgency).toLowerCase() || 'normal';
|
|
115
|
+
if (!RELAY_ASK_URGENCIES.includes(urgency)) return invalid(`urgency must be one of: ${RELAY_ASK_URGENCIES.join(', ')}`);
|
|
116
|
+
return { ok: true, value: { question, options, recommendation, detail, urgency } };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The owner's answer to an ask: `{ ok, value: { answer, choice } }` or
|
|
121
|
+
* `{ ok:false, error }`. `choice` is the option picked (its own spelling),
|
|
122
|
+
* `answer` the text the asker reads: the typed text, else the choice.
|
|
123
|
+
*/
|
|
124
|
+
export function normalizeRelayAskAnswer(ask, { answer = '', choice = '' } = {}) {
|
|
125
|
+
const options = Array.isArray(ask?.options) ? ask.options : [];
|
|
126
|
+
const wanted = oneLine(choice);
|
|
127
|
+
let picked = '';
|
|
128
|
+
if (wanted) {
|
|
129
|
+
picked = options.find((option) => option.toLowerCase() === wanted.toLowerCase()) || '';
|
|
130
|
+
if (!picked) return invalid(`"${wanted.slice(0, 80)}" is not an option of this question`);
|
|
131
|
+
}
|
|
132
|
+
const text = multiLine(answer).slice(0, RELAY_ASK_LIMITS.detailMax);
|
|
133
|
+
if (!picked && !text) return invalid('An answer needs a choice or some text');
|
|
134
|
+
// A typed text that is an option counts as that choice.
|
|
135
|
+
if (!picked) picked = options.find((option) => option.toLowerCase() === oneLine(text).toLowerCase()) || '';
|
|
136
|
+
return { ok: true, value: { answer: text || picked, choice: picked } };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The kind of a formatted question card (a row of /api/relay-questions). */
|
|
140
|
+
export function relayQuestionKind(question) {
|
|
141
|
+
const kind = toText(question?.kind);
|
|
142
|
+
if (RELAY_QUESTION_KINDS.includes(kind)) return kind;
|
|
143
|
+
const context = question && typeof question.context === 'object' && question.context ? question.context : {};
|
|
144
|
+
return toText(context.source) === 'remote_relay' ? 'approval' : 'ask_user';
|
|
145
|
+
}
|
package/shared/relay-events.mjs
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// SDK-free and side-effect free: imported by the server, the remote_relay
|
|
12
12
|
// contract and the browser (which renders the event rows).
|
|
13
13
|
|
|
14
|
+
import { RELAY_SCHEDULE_MESSAGE_KIND } from './relay-schedules.mjs';
|
|
15
|
+
|
|
14
16
|
export const RELAY_EVENT_VERSION = 1;
|
|
15
17
|
|
|
16
18
|
// `messages.kind` of the message the relay writes into the watcher's
|
|
@@ -20,6 +22,10 @@ export const RELAY_EVENT_MESSAGE_KIND = 'relay-event';
|
|
|
20
22
|
|
|
21
23
|
export const RELAY_EVENT_TYPES = Object.freeze([
|
|
22
24
|
'question_asked',
|
|
25
|
+
// The owner answered or dismissed a question that does not block (remote_relay
|
|
26
|
+
// `ask`). The asking session is always told, without a subscription; a
|
|
27
|
+
// subscription that takes question_asked takes it too.
|
|
28
|
+
'question_answered',
|
|
23
29
|
'error',
|
|
24
30
|
'turn_end',
|
|
25
31
|
'task_started',
|
|
@@ -51,12 +57,16 @@ const ALL_EVENT_TYPES = Object.freeze([...RELAY_EVENT_TYPES, RELAY_EVENT_HEARTBE
|
|
|
51
57
|
// whole context; a failed one is still a task_failed.
|
|
52
58
|
export const RELAY_EVENT_SUBAGENT_FINISHED_TYPE = 'subagent_finished';
|
|
53
59
|
|
|
60
|
+
// Not named in `events`: question_asked takes it (relayEventTypesTake).
|
|
61
|
+
export const RELAY_EVENT_QUESTION_ANSWERED_TYPE = 'question_answered';
|
|
62
|
+
const NAMED_EVENT_TYPES = RELAY_EVENT_TYPES.filter((type) => type !== RELAY_EVENT_QUESTION_ANSWERED_TYPE);
|
|
63
|
+
|
|
54
64
|
// What `events` may name: the event types, the narrower subagent_finished
|
|
55
65
|
// and the relay's own relay_restarted.
|
|
56
66
|
export const RELAY_EVENT_SUBSCRIBABLE_TYPES = Object.freeze([
|
|
57
|
-
...
|
|
67
|
+
...NAMED_EVENT_TYPES.slice(0, NAMED_EVENT_TYPES.indexOf('task_finished') + 1),
|
|
58
68
|
RELAY_EVENT_SUBAGENT_FINISHED_TYPE,
|
|
59
|
-
...
|
|
69
|
+
...NAMED_EVENT_TYPES.slice(NAMED_EVENT_TYPES.indexOf('task_finished') + 1),
|
|
60
70
|
...RELAY_EVENT_RELAY_LEVEL_TYPES,
|
|
61
71
|
]);
|
|
62
72
|
|
|
@@ -74,9 +84,18 @@ export function isRelayLevelOnly(types) {
|
|
|
74
84
|
return list.length > 0 && list.every((type) => RELAY_EVENT_RELAY_LEVEL_TYPES.includes(type));
|
|
75
85
|
}
|
|
76
86
|
|
|
77
|
-
// The default before subagent_finished: every type but task_started.
|
|
78
|
-
// subscription made without events stored exactly this list.
|
|
79
|
-
|
|
87
|
+
// The default before subagent_finished: every type of then but task_started.
|
|
88
|
+
// A subscription made without events stored exactly this list. Written out:
|
|
89
|
+
// a type added since (question_answered) is not part of it.
|
|
90
|
+
export const RELAY_EVENT_LEGACY_DEFAULT_TYPES = Object.freeze([
|
|
91
|
+
'question_asked',
|
|
92
|
+
'error',
|
|
93
|
+
'turn_end',
|
|
94
|
+
'task_finished',
|
|
95
|
+
'task_failed',
|
|
96
|
+
'stalled',
|
|
97
|
+
'all_idle',
|
|
98
|
+
]);
|
|
80
99
|
|
|
81
100
|
/**
|
|
82
101
|
* The events a stored subscription takes. A list that is exactly the old
|
|
@@ -98,21 +117,25 @@ export function isLegacyDefaultRelayEventTypes(types) {
|
|
|
98
117
|
}
|
|
99
118
|
|
|
100
119
|
/**
|
|
101
|
-
* Whether a subscription's `events` take this event: its type named,
|
|
102
|
-
*
|
|
120
|
+
* Whether a subscription's `events` take this event: its type named, a
|
|
121
|
+
* question_answered under question_asked (the answer to a question it was
|
|
122
|
+
* told of), or a task_finished of anything but a shell command under
|
|
123
|
+
* subagent_finished.
|
|
103
124
|
*/
|
|
104
125
|
export function relayEventTypesTake(types, event) {
|
|
105
126
|
const list = Array.isArray(types) ? types : [];
|
|
106
127
|
const type = toText(event?.type);
|
|
107
128
|
if (list.includes(type)) return true;
|
|
129
|
+
if (type === RELAY_EVENT_QUESTION_ANSWERED_TYPE) return list.includes('question_asked');
|
|
108
130
|
return type === 'task_finished'
|
|
109
131
|
&& list.includes(RELAY_EVENT_SUBAGENT_FINISHED_TYPE)
|
|
110
132
|
&& toText(event?.detail?.kind) !== 'shell';
|
|
111
133
|
}
|
|
112
134
|
|
|
113
135
|
// Never held back: not by the 30 s coalescing, not by the digest past the
|
|
114
|
-
// hourly cap (they still count toward it).
|
|
115
|
-
|
|
136
|
+
// hourly cap (they still count toward it). An answer is a decision the
|
|
137
|
+
// asking session may be waiting for.
|
|
138
|
+
export const RELAY_EVENT_URGENT_TYPES = Object.freeze(['question_asked', 'question_answered', 'error']);
|
|
116
139
|
|
|
117
140
|
export const RELAY_EVENT_SCOPES = Object.freeze(['sessions', 'all']);
|
|
118
141
|
|
|
@@ -125,11 +148,17 @@ export const RELAY_EVENT_LIMITS = Object.freeze({
|
|
|
125
148
|
stalledMinutesMax: 24 * 60,
|
|
126
149
|
heartbeatMinutesMin: 1,
|
|
127
150
|
heartbeatMinutesMax: 24 * 60,
|
|
151
|
+
// Per subscription: a digest of its non-urgent events every N minutes,
|
|
152
|
+
// and the session ids an "all" subscription leaves out.
|
|
153
|
+
digestMinutesMin: 1,
|
|
154
|
+
digestMinutesMax: 24 * 60,
|
|
155
|
+
excludeMax: 50,
|
|
128
156
|
matchMaxChars: 200,
|
|
129
157
|
sessionsMax: 50,
|
|
130
158
|
activeSubscriptionsPerWatcher: 20,
|
|
131
159
|
previewChars: 300,
|
|
132
160
|
questionChars: 500,
|
|
161
|
+
answerChars: 2000,
|
|
133
162
|
optionsMax: 8,
|
|
134
163
|
linesPerMessage: 40,
|
|
135
164
|
// Across paired relays. The watching relay asks the watched one for new
|
|
@@ -183,6 +212,20 @@ export function isRelayEventDelivery(pending) {
|
|
|
183
212
|
return toText(pending?.messageKind) === RELAY_EVENT_MESSAGE_KIND;
|
|
184
213
|
}
|
|
185
214
|
|
|
215
|
+
// The relay's own automated messages that reach a worker as a turn: event
|
|
216
|
+
// messages and scheduled prompts (shared/relay-schedules.mjs). The dequeue
|
|
217
|
+
// payload names their kind, and the workers push them without the steer note.
|
|
218
|
+
export const RELAY_AUTOMATED_MESSAGE_KINDS = Object.freeze([RELAY_EVENT_MESSAGE_KIND, RELAY_SCHEDULE_MESSAGE_KIND]);
|
|
219
|
+
|
|
220
|
+
export function isRelayAutomatedMessageKind(kind) {
|
|
221
|
+
return RELAY_AUTOMATED_MESSAGE_KINDS.includes(toText(kind));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Whether a delivered queue message is one of the relay's automated messages (isRelayEventDelivery's wider sibling). */
|
|
225
|
+
export function isRelayAutomatedDelivery(pending) {
|
|
226
|
+
return isRelayAutomatedMessageKind(pending?.messageKind);
|
|
227
|
+
}
|
|
228
|
+
|
|
186
229
|
/** Any type an event message can carry, the heartbeat included. */
|
|
187
230
|
export function isRelayEventType(type) {
|
|
188
231
|
return ALL_EVENT_TYPES.includes(toText(type));
|
|
@@ -210,6 +253,149 @@ export function compileRelayEventMatch(pattern) {
|
|
|
210
253
|
}
|
|
211
254
|
}
|
|
212
255
|
|
|
256
|
+
// ─── Quiet hours ─────────────────────────────────────────────────────────────
|
|
257
|
+
//
|
|
258
|
+
// A subscription's `quiet_hours` is `{ from: "23:00", to: "06:00", tz }`: a
|
|
259
|
+
// window of local wall-clock time in an IANA time zone, which may cross
|
|
260
|
+
// midnight. Non-urgent events that fall in it are held until it ends.
|
|
261
|
+
|
|
262
|
+
const CLOCK_PATTERN = /^([01]?\d|2[0-3]):([0-5]\d)$/;
|
|
263
|
+
|
|
264
|
+
/** Minutes after midnight of "HH:MM" (or "H:MM"), or null. */
|
|
265
|
+
export function relayEventClockMinutes(value) {
|
|
266
|
+
const match = CLOCK_PATTERN.exec(toText(value));
|
|
267
|
+
return match ? Number(match[1]) * 60 + Number(match[2]) : null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function clockText(minutes) {
|
|
271
|
+
return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The canonical name of an IANA time zone this runtime knows, or ''. */
|
|
275
|
+
export function canonicalRelayEventTimeZone(value) {
|
|
276
|
+
const tz = toText(value);
|
|
277
|
+
if (!tz || tz.length > 64) return '';
|
|
278
|
+
try {
|
|
279
|
+
return new Intl.DateTimeFormat('en-US', { timeZone: tz }).resolvedOptions().timeZone || '';
|
|
280
|
+
} catch {
|
|
281
|
+
return '';
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* A `quiet_hours` value checked: `{ ok: true, value }` with `{ from, to, tz }`
|
|
287
|
+
* as "HH:MM" and the zone's canonical name, `{ ok: true, value: null }` when
|
|
288
|
+
* there is none, `{ ok: false, error }` otherwise. Takes the object or its
|
|
289
|
+
* JSON text.
|
|
290
|
+
*/
|
|
291
|
+
export function normalizeRelayEventQuietHours(input) {
|
|
292
|
+
if (input === undefined || input === null || input === false || (typeof input === 'string' && !input.trim())) return { ok: true, value: null };
|
|
293
|
+
let value = input;
|
|
294
|
+
if (typeof value === 'string') {
|
|
295
|
+
try {
|
|
296
|
+
value = JSON.parse(value);
|
|
297
|
+
} catch {
|
|
298
|
+
return { ok: false, error: 'quiet_hours must be an object: {"from":"23:00","to":"06:00","tz":"Europe/Berlin"}' };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
302
|
+
return { ok: false, error: 'quiet_hours must be an object: {"from":"23:00","to":"06:00","tz":"Europe/Berlin"}' };
|
|
303
|
+
}
|
|
304
|
+
// Some providers send every property of the schema, an unused one empty.
|
|
305
|
+
if (['from', 'to', 'tz'].every((key) => !toText(value[key]))) return { ok: true, value: null };
|
|
306
|
+
const from = relayEventClockMinutes(value.from);
|
|
307
|
+
const to = relayEventClockMinutes(value.to);
|
|
308
|
+
if (from === null || to === null) return { ok: false, error: 'quiet_hours from and to must be times of day as "HH:MM" (00:00 to 23:59)' };
|
|
309
|
+
if (from === to) return { ok: false, error: 'quiet_hours from and to must differ' };
|
|
310
|
+
if (!toText(value.tz)) return { ok: false, error: 'quiet_hours needs tz, an IANA time zone such as "Europe/Berlin"' };
|
|
311
|
+
const tz = canonicalRelayEventTimeZone(value.tz);
|
|
312
|
+
if (!tz) return { ok: false, error: `quiet_hours tz "${oneLine(value.tz, 64)}" is not an IANA time zone (for example "Europe/Berlin", "America/New_York", "UTC")` };
|
|
313
|
+
return { ok: true, value: { from: clockText(from), to: clockText(to), tz } };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** "23:00–06:00 Europe/Berlin" */
|
|
317
|
+
export function relayEventQuietHoursText(quiet) {
|
|
318
|
+
return `${oneLine(quiet?.from, 5)}–${oneLine(quiet?.to, 5)} ${oneLine(quiet?.tz, 64)}`.trim();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const DAY_MS = 24 * 60 * 60_000;
|
|
322
|
+
|
|
323
|
+
function minuteFloor(ms) {
|
|
324
|
+
return Math.floor(ms / 60_000) * 60_000;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const zoneFormatters = new Map();
|
|
328
|
+
|
|
329
|
+
function zoneFormatter(tz) {
|
|
330
|
+
let formatter = zoneFormatters.get(tz);
|
|
331
|
+
if (!formatter) {
|
|
332
|
+
formatter = new Intl.DateTimeFormat('en-US', {
|
|
333
|
+
timeZone: tz,
|
|
334
|
+
hourCycle: 'h23',
|
|
335
|
+
year: 'numeric',
|
|
336
|
+
month: 'numeric',
|
|
337
|
+
day: 'numeric',
|
|
338
|
+
hour: 'numeric',
|
|
339
|
+
minute: 'numeric',
|
|
340
|
+
});
|
|
341
|
+
zoneFormatters.set(tz, formatter);
|
|
342
|
+
}
|
|
343
|
+
return formatter;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The wall clock in `tz` at instant `ms`, as the UTC timestamp of the same digits. */
|
|
347
|
+
function wallClock(ms, tz) {
|
|
348
|
+
const parts = {};
|
|
349
|
+
for (const part of zoneFormatter(tz).formatToParts(new Date(ms))) parts[part.type] = Number(part.value);
|
|
350
|
+
return Date.UTC(parts.year, parts.month - 1, parts.day, (parts.hour || 0) % 24, parts.minute || 0);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function insideWindow(minute, from, to) {
|
|
354
|
+
return from < to ? minute >= from && minute < to : minute >= from || minute < to;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function wallMinute(wall) {
|
|
358
|
+
const date = new Date(wall);
|
|
359
|
+
return date.getUTCHours() * 60 + date.getUTCMinutes();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* When the quiet window that instant `ms` falls in ends (epoch ms), or null
|
|
364
|
+
* when `ms` is outside it (or the value is not a valid window). The clock
|
|
365
|
+
* changes: where the clocks go back and `to` comes twice, the window ends
|
|
366
|
+
* the first time and is not entered again in the repeated hour; where `to`
|
|
367
|
+
* falls in the hour the clocks skip, it ends the moment they jump. A known
|
|
368
|
+
* limit: a `from` inside the repeated hour starts the window on the first
|
|
369
|
+
* pass, and on the second pass the minutes before `from` read as outside.
|
|
370
|
+
*/
|
|
371
|
+
export function relayEventQuietWindowEnd(quiet, ms) {
|
|
372
|
+
const from = relayEventClockMinutes(quiet?.from);
|
|
373
|
+
const to = relayEventClockMinutes(quiet?.to);
|
|
374
|
+
const tz = canonicalRelayEventTimeZone(quiet?.tz);
|
|
375
|
+
if (from === null || to === null || from === to || !tz || !Number.isFinite(ms)) return null;
|
|
376
|
+
const wall = wallClock(ms, tz);
|
|
377
|
+
const minute = wallMinute(wall);
|
|
378
|
+
if (!insideWindow(minute, from, to)) return null;
|
|
379
|
+
const day = Date.UTC(new Date(wall).getUTCFullYear(), new Date(wall).getUTCMonth(), new Date(wall).getUTCDate());
|
|
380
|
+
// Past midnight's side of a window that crosses it ends today, else tomorrow.
|
|
381
|
+
const target = day + (from > to && minute >= from ? DAY_MS : 0) + to * 60_000;
|
|
382
|
+
// The instants whose local time reads `target`, under the offsets in force
|
|
383
|
+
// around it; the earliest is the end. Already past it, `ms` is in the
|
|
384
|
+
// repeated hour after the window ended: outside.
|
|
385
|
+
const offsets = new Set([-DAY_MS, 0, DAY_MS].map((shift) => wallClock(target + shift, tz) - minuteFloor(target + shift)));
|
|
386
|
+
const ends = [...offsets]
|
|
387
|
+
.map((offset) => target - offset)
|
|
388
|
+
.filter((instant) => wallClock(instant, tz) === target)
|
|
389
|
+
.sort((a, b) => a - b);
|
|
390
|
+
if (ends.length) return ends[0] > ms ? ends[0] : null;
|
|
391
|
+
// `to` does not exist that day (the clocks skip it): the first minute
|
|
392
|
+
// outside the window.
|
|
393
|
+
for (let instant = minuteFloor(ms) + 60_000; instant <= ms + 2 * DAY_MS; instant += 60_000) {
|
|
394
|
+
if (!insideWindow(wallMinute(wallClock(instant, tz)), from, to)) return instant;
|
|
395
|
+
}
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
|
|
213
399
|
/** The text a `match` is tried on, or null when the event has none. */
|
|
214
400
|
export function relayEventMatchText(event) {
|
|
215
401
|
const detail = event?.detail || {};
|
|
@@ -349,6 +535,24 @@ export function formatRelayEventLine(event) {
|
|
|
349
535
|
if (options.length) parts.push(`options: ${options.map((option) => `[${option}]`).join(' ')}`);
|
|
350
536
|
if (detail.approvalCard) parts.push('(an approval card: only the user can answer it)');
|
|
351
537
|
else if (oneLine(detail.questionId, 80)) parts.push(`(question_id ${oneLine(detail.questionId, 80)})`);
|
|
538
|
+
if (detail.kind === 'ask') parts.push('(asked with ask: it does not block that session)');
|
|
539
|
+
return parts.join(' ');
|
|
540
|
+
}
|
|
541
|
+
case 'question_answered': {
|
|
542
|
+
const id = oneLine(detail.questionId, 80);
|
|
543
|
+
const parts = [`${head}${id ? ` (question_id ${id})` : ''} ${quote(detail.prompt, RELAY_EVENT_LIMITS.questionChars)}`];
|
|
544
|
+
if (detail.dismissed === true) {
|
|
545
|
+
parts.push('→ the user dismissed it without an answer; decide yourself or ask again later');
|
|
546
|
+
} else {
|
|
547
|
+
const choice = oneLine(detail.choice, 120);
|
|
548
|
+
const answer = oneLine(detail.answer, RELAY_EVENT_LIMITS.answerChars);
|
|
549
|
+
parts.push(`→ the user answered ${quote(answer || choice, RELAY_EVENT_LIMITS.answerChars)}`);
|
|
550
|
+
if (choice && choice !== answer) parts.push(`(option [${choice}])`);
|
|
551
|
+
}
|
|
552
|
+
// The relay whose UI it came from: the one the ask is kept on, or a
|
|
553
|
+
// paired relay's Inbox.
|
|
554
|
+
const via = oneLine(detail.via, 60);
|
|
555
|
+
if (via) parts.push(`(via relay ${quote(via, 60)})`);
|
|
352
556
|
return parts.join(' ');
|
|
353
557
|
}
|
|
354
558
|
case 'error': {
|
|
@@ -377,7 +581,7 @@ export function formatRelayEventLine(event) {
|
|
|
377
581
|
}
|
|
378
582
|
}
|
|
379
583
|
|
|
380
|
-
const URGENT_ORDER = ['question_asked', 'error'];
|
|
584
|
+
const URGENT_ORDER = ['question_asked', 'question_answered', 'error'];
|
|
381
585
|
|
|
382
586
|
function replayOrder(a, b) {
|
|
383
587
|
const rank = (event) => {
|
|
@@ -393,6 +597,48 @@ function pushCapped(lines, events) {
|
|
|
393
597
|
if (events.length > shown.length) lines.push(`${RELAY_EVENT_LINE_PREFIX} … and ${events.length - shown.length} more`);
|
|
394
598
|
}
|
|
395
599
|
|
|
600
|
+
function distinct(values) {
|
|
601
|
+
return [...new Set(values.filter(Boolean))];
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* The lines in front of the events: one "Digest of" line for the hourly
|
|
606
|
+
* cap's digest (`digest`) and a subscription's digest together, and one for
|
|
607
|
+
* events quiet hours held. An event's `held` is `{ reason: 'quiet', from,
|
|
608
|
+
* to, tz }` or `{ reason: 'digest', minutes, subscriptionId }`, set when it
|
|
609
|
+
* is delivered. A subscription's digest of a single event reads as that
|
|
610
|
+
* event, without a line of its own.
|
|
611
|
+
*/
|
|
612
|
+
function headerLines(events, { digest = false, all = events } = {}) {
|
|
613
|
+
const lines = [];
|
|
614
|
+
// The cap's digest is of this relay's events and live ones; what the
|
|
615
|
+
// options held, replayed events included, is counted from all.
|
|
616
|
+
const capped = digest && events.length > 0;
|
|
617
|
+
const gathered = all.filter((event) => event?.held?.reason === 'digest');
|
|
618
|
+
const parts = [];
|
|
619
|
+
if (capped) {
|
|
620
|
+
parts.push(`the limit of ${RELAY_EVENT_LIMITS.wakesPerHour} wake-ups an hour is reached; until it frees up, events come every ${Math.round(RELAY_EVENT_LIMITS.digestIntervalMs / 60_000)} min`);
|
|
621
|
+
}
|
|
622
|
+
if (gathered.length > 1 || (capped && gathered.length)) {
|
|
623
|
+
parts.push(distinct(gathered.map((event) => {
|
|
624
|
+
const minutes = Number(event.held.minutes) || 0;
|
|
625
|
+
const id = oneLine(event.held.subscriptionId, 80);
|
|
626
|
+
return `${id ? `subscription ${id}` : 'a subscription'} every ${minutes} min`;
|
|
627
|
+
})).join(', '));
|
|
628
|
+
parts.push('questions and errors come at once');
|
|
629
|
+
}
|
|
630
|
+
if (parts.length) {
|
|
631
|
+
const count = capped ? events.length : gathered.length;
|
|
632
|
+
lines.push(`Digest of ${count} event${count === 1 ? '' : 's'} (${parts.join('; ')}):`);
|
|
633
|
+
}
|
|
634
|
+
const quiet = all.filter((event) => event?.held?.reason === 'quiet');
|
|
635
|
+
if (quiet.length) {
|
|
636
|
+
const windows = distinct(quiet.map((event) => relayEventQuietHoursText(event.held)));
|
|
637
|
+
lines.push(`Held during quiet hours (${windows.join(', ')}): ${quiet.length} event${quiet.length === 1 ? '' : 's'}, delivered now that the window has ended; questions and errors came at once.`);
|
|
638
|
+
}
|
|
639
|
+
return lines;
|
|
640
|
+
}
|
|
641
|
+
|
|
396
642
|
/**
|
|
397
643
|
* The whole message for one wake-up: the lead line, then one line per event
|
|
398
644
|
* (at most `linesPerMessage`, then a count of the rest). `digest` says the
|
|
@@ -417,10 +663,7 @@ export function buildRelayEventMessageText(events, { digest = false, ended = []
|
|
|
417
663
|
if (!replays.has(relayId)) replays.set(relayId, []);
|
|
418
664
|
replays.get(relayId).push(event);
|
|
419
665
|
}
|
|
420
|
-
const lines = [RELAY_EVENT_LEAD];
|
|
421
|
-
if (digest && plain.length) {
|
|
422
|
-
lines.push(`Digest of ${plain.length} event${plain.length === 1 ? '' : 's'} (the limit of ${RELAY_EVENT_LIMITS.wakesPerHour} wake-ups an hour is reached; until it frees up, events come every ${Math.round(RELAY_EVENT_LIMITS.digestIntervalMs / 60_000)} min):`);
|
|
423
|
-
}
|
|
666
|
+
const lines = [RELAY_EVENT_LEAD, ...headerLines(plain, { digest, all: list })];
|
|
424
667
|
pushCapped(lines, plain);
|
|
425
668
|
for (const group of replays.values()) {
|
|
426
669
|
const lost = group.filter((event) => event.type === RELAY_EVENT_LINK_LOST_TYPE);
|
|
@@ -431,7 +674,12 @@ export function buildRelayEventMessageText(events, { digest = false, ended = []
|
|
|
431
674
|
lines.push(`Replayed: ${kept.length} event${kept.length === 1 ? '' : 's'} relay ${quote(relayDisplayName(group[0]), 60)} kept while the link was down, questions and errors first:`);
|
|
432
675
|
pushCapped(lines, kept);
|
|
433
676
|
}
|
|
434
|
-
for (const event of restored)
|
|
677
|
+
for (const event of restored) {
|
|
678
|
+
// Kept events a subscription's digest or quiet hours hold come later.
|
|
679
|
+
const held = (Number(event?.detail?.kept) || 0) - kept.length;
|
|
680
|
+
lines.push(formatRelayEventLine(event)
|
|
681
|
+
+ (held > 0 ? `; ${held} of them ${held === 1 ? 'is' : 'are'} held by your subscription's digest or quiet hours and come${held === 1 ? 's' : ''} later` : ''));
|
|
682
|
+
}
|
|
435
683
|
}
|
|
436
684
|
const endedIds = (Array.isArray(ended) ? ended : []).map((id) => oneLine(id, 80)).filter(Boolean);
|
|
437
685
|
if (endedIds.length) {
|
|
@@ -443,6 +691,7 @@ export function buildRelayEventMessageText(events, { digest = false, ended = []
|
|
|
443
691
|
// An event line: the prefix, a paired relay's name, the session, the type.
|
|
444
692
|
export const RELAY_EVENT_LINE_PATTERN = /^\[relay event\] (?:[^"·\n]{1,60} · )?(?:session "(?:[^"]*)" \([^)]*\)(?: on relay "[^"]*")?: )?([a-z_]+)/;
|
|
445
693
|
const DIGEST_LINE_PATTERN = /^Digest of \d+ events?/;
|
|
694
|
+
const QUIET_LINE_PATTERN = /^Held during quiet hours/;
|
|
446
695
|
const REPLAYED_LINE_PATTERN = /^Replayed: \d+ events?/;
|
|
447
696
|
|
|
448
697
|
/**
|
|
@@ -454,9 +703,10 @@ const REPLAYED_LINE_PATTERN = /^Replayed: \d+ events?/;
|
|
|
454
703
|
export function parseRelayEventMessageText(text) {
|
|
455
704
|
const rows = String(text ?? '').split(/\r?\n/).map((line) => line.trimEnd()).filter((line) => line.trim());
|
|
456
705
|
const body = rows[0] === RELAY_EVENT_LEAD ? rows.slice(1) : rows;
|
|
457
|
-
const digest = DIGEST_LINE_PATTERN.test(
|
|
706
|
+
const digest = body.some((line) => DIGEST_LINE_PATTERN.test(line));
|
|
707
|
+
const quiet = body.some((line) => QUIET_LINE_PATTERN.test(line));
|
|
458
708
|
const replayed = body.some((line) => REPLAYED_LINE_PATTERN.test(line));
|
|
459
|
-
const lines = body.filter((line) => !DIGEST_LINE_PATTERN.test(line) && !REPLAYED_LINE_PATTERN.test(line));
|
|
709
|
+
const lines = body.filter((line) => !DIGEST_LINE_PATTERN.test(line) && !QUIET_LINE_PATTERN.test(line) && !REPLAYED_LINE_PATTERN.test(line));
|
|
460
710
|
const types = [];
|
|
461
711
|
let count = 0;
|
|
462
712
|
for (const line of lines) {
|
|
@@ -470,5 +720,5 @@ export function parseRelayEventMessageText(text) {
|
|
|
470
720
|
const more = /^\[relay event\] … and (\d+) more$/.exec(line);
|
|
471
721
|
if (more) count += Number(more[1]) || 0;
|
|
472
722
|
}
|
|
473
|
-
return { lines, types, count: count || lines.length, digest, replayed };
|
|
723
|
+
return { lines, types, count: count || lines.length, digest, quiet, replayed };
|
|
474
724
|
}
|