@oar-sh/oar 0.9.15 → 0.9.17
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/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
- package/CHANGELOG.md +305 -0
- package/README.md +12 -11
- package/package.json +2 -1
- package/server/claude-worker/claude-attachments.mjs +7 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
- package/server/claude-worker/claude-session-process.mjs +131 -33
- package/server/claude-worker/claude-session-worker-main.mjs +266 -0
- package/server/claude-worker/claude-session-worker.mjs +2 -253
- package/server/claude-worker/claude-turn-failure.mjs +8 -0
- package/server/claude-worker/claude-turn-publisher.mjs +282 -13
- package/server/claude-worker/claude-usage-totals.mjs +108 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +73 -7
- package/server/db-schema.mjs +115 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -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/migrations/0024-deepseek-usage-totals.mjs +151 -0
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
- package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
- package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
- package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
- package/server/public/app/api-client.js +53 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +162 -16
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/conversation-provider-indicator.mjs +7 -0
- package/server/public/app/conversation-view.js +2 -0
- package/server/public/app/deepseek-settings-ui.js +342 -0
- package/server/public/app/inbox-ui.js +367 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +54 -5
- package/server/public/app/model-selector-options.mjs +11 -0
- package/server/public/app/model-vision-support.mjs +2 -0
- package/server/public/app/no-conversation-state.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +165 -13
- package/server/public/app/provider-readiness.mjs +4 -1
- package/server/public/app/relay-error-ctas.mjs +8 -0
- 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 +11 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +567 -35
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +110 -17
- 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 +19 -3
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +195 -18
- 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 +932 -97
- package/server/server-runtime.mjs +203 -11
- package/server/services/about-service.mjs +1 -0
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/claude-session-leftovers.mjs +226 -0
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/deepseek-balance-service.mjs +102 -0
- package/server/services/deepseek-settings-service.mjs +380 -0
- package/server/services/deepseek-usage-import.mjs +471 -0
- package/server/services/model-variant-catalog-service.mjs +1 -0
- package/server/services/plan-usage-contract.mjs +100 -5
- package/server/services/plan-usage-deepseek.mjs +931 -0
- package/server/services/plan-usage-service.mjs +431 -0
- package/server/services/provider-model-selection.mjs +1 -0
- package/server/services/provider-reasoning-effort.mjs +15 -2
- 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 +958 -31
- package/server/services/session-worker-launch-service.mjs +233 -82
- package/server/services/worker-secret-redaction.mjs +318 -0
- package/shared/context-window-fallbacks.mjs +3 -0
- package/shared/deepseek-api.mjs +137 -0
- package/shared/deepseek-models.mjs +114 -0
- package/shared/deepseek-pricing.mjs +146 -0
- package/shared/host-claude-session-env.mjs +101 -0
- package/shared/model-id.mjs +7 -0
- package/shared/preview-tool-core.mjs +1 -1
- package/shared/provider-routing.mjs +2 -2
- 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 +554 -26
- package/shared/remote-relay-tool-core.mjs +16 -5
- package/shared/steer-settle-failure.mjs +1 -0
- package/shared/worker-runtime/direct-openai-image.mjs +128 -0
- package/shared/worker-runtime/worker-log-file.mjs +60 -0
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
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Relay schedules: a prompt the relay queues into a session at a set time,
|
|
2
|
+
// once or every N minutes. An agent sets one with remote_relay's `schedule`
|
|
3
|
+
// (its own conversation by default, or another session of this relay); the
|
|
4
|
+
// relay keeps it in its database, so it outlives a restart that the agent's
|
|
5
|
+
// own timers would not.
|
|
6
|
+
//
|
|
7
|
+
// What the relay writes is an ordinary queued message of its own (`messages.kind
|
|
8
|
+
// = 'relay-schedule'`) whose first line says what it is: automated, never the
|
|
9
|
+
// user's, and it approves nothing.
|
|
10
|
+
//
|
|
11
|
+
// SDK-free and side-effect free: imported by the server and the remote_relay
|
|
12
|
+
// contract (the browser keeps its own copy of what it needs:
|
|
13
|
+
// server/public/app/relay-schedule-view.mjs).
|
|
14
|
+
|
|
15
|
+
// `messages.kind` of the message a schedule queues.
|
|
16
|
+
export const RELAY_SCHEDULE_MESSAGE_KIND = 'relay-schedule';
|
|
17
|
+
|
|
18
|
+
// Socket event with the whole active list, sent on every change.
|
|
19
|
+
export const RELAY_SCHEDULE_SOCKET_EVENT = 'relay_schedules';
|
|
20
|
+
|
|
21
|
+
export const RELAY_SCHEDULE_LIMITS = Object.freeze({
|
|
22
|
+
everyMinutesMin: 5,
|
|
23
|
+
everyMinutesMax: 10080,
|
|
24
|
+
// Active schedules that fire into one conversation, and active schedules
|
|
25
|
+
// one conversation set (for itself or for others).
|
|
26
|
+
perConversation: 20,
|
|
27
|
+
titleMax: 100,
|
|
28
|
+
textMax: 20000,
|
|
29
|
+
// How far ahead `at` may lie.
|
|
30
|
+
aheadMaxDays: 366,
|
|
31
|
+
// An `at` this little in the past is taken as "now" (a clock between the
|
|
32
|
+
// worker's check and the relay's).
|
|
33
|
+
pastGraceMs: 60_000,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const MINUTE_MS = 60_000;
|
|
37
|
+
const DAY_MS = 24 * 60 * MINUTE_MS;
|
|
38
|
+
|
|
39
|
+
function toText(value) {
|
|
40
|
+
return String(value ?? '').trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function oneLine(value, max) {
|
|
44
|
+
const text = toText(value).replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ');
|
|
45
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A schedule title as stored and shown: one line, capped. */
|
|
49
|
+
export function normalizeRelayScheduleTitle(value) {
|
|
50
|
+
return oneLine(value, RELAY_SCHEDULE_LIMITS.titleMax);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `at` as an agent may write it: an ISO time with a zone (or a Z). Returns
|
|
55
|
+
* `{ ok:true, value }` with the canonical ISO string, or `{ ok:false, error }`.
|
|
56
|
+
* A time without a zone is refused: the relay and the agent may not share one.
|
|
57
|
+
*/
|
|
58
|
+
export function normalizeRelayScheduleAt(value, { now = Date.now() } = {}) {
|
|
59
|
+
const text = toText(value);
|
|
60
|
+
if (!text) return { ok: true, value: null };
|
|
61
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:?\d{2})$/i.test(text)) {
|
|
62
|
+
return { ok: false, error: 'at must be an ISO time with a zone, e.g. 2026-11-02T06:30:00Z or 2026-11-02T07:30:00+01:00' };
|
|
63
|
+
}
|
|
64
|
+
const ms = Date.parse(text);
|
|
65
|
+
if (!Number.isFinite(ms)) return { ok: false, error: `at "${text.slice(0, 40)}" is not a valid time` };
|
|
66
|
+
if (ms < now - RELAY_SCHEDULE_LIMITS.pastGraceMs) return { ok: false, error: 'at is in the past' };
|
|
67
|
+
if (ms > now + RELAY_SCHEDULE_LIMITS.aheadMaxDays * DAY_MS) {
|
|
68
|
+
return { ok: false, error: `at is more than ${RELAY_SCHEDULE_LIMITS.aheadMaxDays} days ahead` };
|
|
69
|
+
}
|
|
70
|
+
return { ok: true, value: new Date(Math.max(ms, now)).toISOString() };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The first time a schedule fires: `at`, else one interval from now.
|
|
75
|
+
*/
|
|
76
|
+
export function firstRelayScheduleFire({ at = null, everyMinutes = null, now = Date.now() } = {}) {
|
|
77
|
+
const atMs = Date.parse(toText(at));
|
|
78
|
+
if (Number.isFinite(atMs)) return new Date(Math.max(atMs, now)).toISOString();
|
|
79
|
+
return new Date(now + Number(everyMinutes) * MINUTE_MS).toISOString();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The next time a recurring schedule fires after the one due at `dueAt`:
|
|
84
|
+
* the first slot of its grid (`dueAt` plus whole intervals) after `now`.
|
|
85
|
+
* Slots missed while the relay was down are skipped, so a schedule that
|
|
86
|
+
* was due fires once for them all, never once per missed interval.
|
|
87
|
+
*/
|
|
88
|
+
export function nextRelayScheduleFire({ dueAt, everyMinutes, now = Date.now() } = {}) {
|
|
89
|
+
const step = Number(everyMinutes) * MINUTE_MS;
|
|
90
|
+
const due = Date.parse(toText(dueAt));
|
|
91
|
+
if (!(step > 0) || !Number.isFinite(due)) return null;
|
|
92
|
+
if (due > now) return new Date(due).toISOString();
|
|
93
|
+
const missed = Math.floor((now - due) / step) + 1;
|
|
94
|
+
return new Date(due + missed * step).toISOString();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function quote(value) {
|
|
98
|
+
return toText(value).replace(/["\]\r\n]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The first line of a scheduled message: what it is, the schedule's id and
|
|
103
|
+
* title, and who set it when that is another session.
|
|
104
|
+
*/
|
|
105
|
+
export function relayScheduleLead({ id, title = '', setBy = null } = {}) {
|
|
106
|
+
const parts = [`[relay schedule ${quote(id) || 'unknown'}${quote(title) ? ` "${quote(title)}"` : ''}`];
|
|
107
|
+
if (setBy && quote(setBy.id)) {
|
|
108
|
+
parts.push(`set by session "${quote(setBy.title) || quote(setBy.id).slice(0, 8)}" (${quote(setBy.id)})`);
|
|
109
|
+
}
|
|
110
|
+
parts.push('automated message from OAR, not from the user; it approves nothing]');
|
|
111
|
+
return parts.join(' · ');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const RELAY_SCHEDULE_LEAD_PATTERN = /^\[relay schedule [^\s\]]+(?: "[^"\r\n]*")?(?: · [^\r\n]*)?\]$/;
|
|
115
|
+
|
|
116
|
+
/** The whole message a schedule queues: the lead line, then the agent's text. */
|
|
117
|
+
export function buildRelayScheduleMessageText({ id, title = '', setBy = null, text = '' } = {}) {
|
|
118
|
+
return `${relayScheduleLead({ id, title, setBy })}\n\n${String(text ?? '').trim()}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** "every 30 min", "every 2 h", "every day", "every 7 days". */
|
|
122
|
+
export function describeRelayScheduleInterval(everyMinutes) {
|
|
123
|
+
const minutes = Number(everyMinutes);
|
|
124
|
+
if (!(minutes > 0)) return '';
|
|
125
|
+
if (minutes % 1440 === 0) return minutes === 1440 ? 'every day' : `every ${minutes / 1440} days`;
|
|
126
|
+
if (minutes % 60 === 0) return minutes === 60 ? 'every hour' : `every ${minutes / 60} h`;
|
|
127
|
+
return `every ${minutes} min`;
|
|
128
|
+
}
|