capacitor-mobile-claw 1.4.1 → 1.4.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.
@@ -0,0 +1,764 @@
1
+ import { existsSync, statSync, truncateSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import {
4
+ isDbReady,
5
+ run as dbRun,
6
+ queryOne as dbQueryOne,
7
+ flush as dbFlush,
8
+ getSchedulerConfig,
9
+ getHeartbeatConfig,
10
+ setHeartbeatConfig,
11
+ listCronSkills,
12
+ listCronJobs,
13
+ getDueJobs,
14
+ updateCronJob,
15
+ insertCronRun,
16
+ enqueueSystemEvent,
17
+ peekPendingEvents,
18
+ consumePendingEvents,
19
+ } from './worker-db.js';
20
+
21
+ const DEFAULT_HEARTBEAT_PROMPT =
22
+ 'Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.';
23
+
24
+ const HEARTBEAT_OK_TOKENS = ['HEARTBEAT_OK', 'heartbeat_ok', 'ok', 'OK', '✓', '👍'];
25
+ const ERROR_BACKOFF_MS = [30_000, 60_000, 300_000, 900_000, 3_600_000];
26
+
27
+ let deps = null;
28
+ let wakeInFlight = false;
29
+ let heartbeatConsecutiveErrors = 0;
30
+
31
+ export function initHeartbeat(initDeps) {
32
+ deps = {
33
+ ...initDeps,
34
+ };
35
+ }
36
+
37
+ export async function handleHeartbeatWake(source = 'manual', opts = {}) {
38
+ if (!deps || !deps.channel) return;
39
+ if (wakeInFlight) {
40
+ _emit('heartbeat.skipped', { reason: 'busy' });
41
+ return;
42
+ }
43
+
44
+ wakeInFlight = true;
45
+ try {
46
+ const scheduler = getSchedulerConfig();
47
+ const isManual = source === 'manual' || opts.force === true;
48
+
49
+ if (!isManual && !scheduler?.enabled) {
50
+ _emit('heartbeat.skipped', { reason: 'scheduler_disabled' });
51
+ _emitSchedulerStatus({ scheduler });
52
+ return;
53
+ }
54
+
55
+ if (!isManual && deps.isUserTurnActive?.()) {
56
+ _emit('heartbeat.skipped', { reason: 'user_active' });
57
+ _emitSchedulerStatus({ scheduler });
58
+ return;
59
+ }
60
+
61
+ _emit('heartbeat.started', { source });
62
+
63
+ const now = Date.now();
64
+ const heartbeatResult = await _runHeartbeatCycle({
65
+ source,
66
+ now,
67
+ force: isManual || opts.force === true,
68
+ forceSessionKey: opts.forceSessionKey,
69
+ });
70
+
71
+ await _runDueCronJobs({
72
+ source,
73
+ now,
74
+ forceJobId: opts.forceJobId,
75
+ });
76
+
77
+ const durationMs = Date.now() - now;
78
+ if (heartbeatResult.status === 'skipped') {
79
+ _emit('heartbeat.skipped', { reason: heartbeatResult.reason || 'not_due' });
80
+ } else {
81
+ _emit('heartbeat.completed', {
82
+ status: heartbeatResult.status,
83
+ reason: heartbeatResult.reason,
84
+ durationMs,
85
+ ...(heartbeatResult.responsePreview
86
+ ? { responsePreview: heartbeatResult.responsePreview }
87
+ : {}),
88
+ });
89
+ }
90
+
91
+ _emitSchedulerStatus({ scheduler: getSchedulerConfig() });
92
+ } catch (err) {
93
+ const durationMs = 0;
94
+ _emit('heartbeat.completed', {
95
+ status: 'error',
96
+ reason: err?.message || 'heartbeat_failed',
97
+ durationMs,
98
+ });
99
+ } finally {
100
+ wakeInFlight = false;
101
+ }
102
+ }
103
+
104
+ function _emit(type, payload = {}) {
105
+ deps.channel.send('message', { type, ...payload });
106
+ }
107
+
108
+ function _emitSchedulerStatus({ scheduler }) {
109
+ const heartbeat = getHeartbeatConfig();
110
+ const dueJobs = getDueJobs(Date.now());
111
+ _emit('scheduler.status', {
112
+ enabled: !!scheduler?.enabled,
113
+ mode: scheduler?.schedulingMode || 'balanced',
114
+ heartbeatNext: heartbeat?.nextRunAt,
115
+ nextDueAt: dueJobs[0]?.nextRunAt,
116
+ });
117
+ }
118
+
119
+ async function _runHeartbeatCycle(params) {
120
+ const now = params.now ?? Date.now();
121
+ const config = getHeartbeatConfig();
122
+ if (!params.force && !config?.enabled) {
123
+ return { status: 'skipped', reason: 'heartbeat_disabled' };
124
+ }
125
+
126
+ if (!params.force && config.nextRunAt && now < config.nextRunAt) {
127
+ return { status: 'skipped', reason: 'not_due' };
128
+ }
129
+
130
+ if (!isWithinActiveHours(
131
+ config.activeHours?.start,
132
+ config.activeHours?.end,
133
+ config.activeHours?.tz
134
+ )) {
135
+ const nextRunAt = now + Math.max(15_000, Number(config.everyMs) || 1_800_000);
136
+ setHeartbeatConfig({ nextRunAt });
137
+ return { status: 'skipped', reason: 'outside_active_hours' };
138
+ }
139
+
140
+ const heartbeatSkill = _resolveSkill(config.skillId);
141
+ const sessionKey =
142
+ params.forceSessionKey || deps.getCurrentSessionKey?.() || `main/${Date.now()}`;
143
+
144
+ const pendingEvents = peekPendingEvents(sessionKey);
145
+ const prompt = _buildHeartbeatPrompt({
146
+ basePrompt: config.prompt || DEFAULT_HEARTBEAT_PROMPT,
147
+ pendingEvents,
148
+ });
149
+
150
+ const transcriptState = captureTranscriptState('main', sessionKey, deps.OPENCLAW_ROOT);
151
+ const startedAt = Date.now();
152
+
153
+ try {
154
+ const runResult = await _runHeartbeatTurn({
155
+ prompt,
156
+ heartbeatSkill,
157
+ });
158
+
159
+ const text = runResult.text || '';
160
+ const trimmed = text.trim();
161
+ const hash = fnv1aHash(trimmed);
162
+ const isOk = isHeartbeatOk(trimmed);
163
+ const isDuplicate =
164
+ !!trimmed &&
165
+ !!config.lastHash &&
166
+ config.lastHash === hash &&
167
+ !!config.lastSentAt &&
168
+ startedAt - config.lastSentAt < 24 * 60 * 60 * 1000;
169
+
170
+ if (!trimmed || isOk || isDuplicate) {
171
+ if (runResult.agent && typeof runResult.preMessageCount === 'number') {
172
+ _pruneInMemoryMessages(runResult.agent, runResult.preMessageCount);
173
+ }
174
+ pruneHeartbeatTranscript(transcriptState, sessionKey);
175
+ if (pendingEvents.length > 0) {
176
+ consumePendingEvents(pendingEvents.map((e) => e.id));
177
+ }
178
+ setHeartbeatConfig({
179
+ nextRunAt: startedAt + (Number(config.everyMs) || 1_800_000),
180
+ });
181
+
182
+ heartbeatConsecutiveErrors = 0;
183
+ if (isDuplicate) {
184
+ return { status: 'deduped', reason: 'duplicate' };
185
+ }
186
+ if (isOk) {
187
+ return { status: 'suppressed', reason: 'heartbeat_ok' };
188
+ }
189
+ return { status: 'suppressed', reason: 'empty' };
190
+ }
191
+
192
+ heartbeatConsecutiveErrors = 0;
193
+ if (runResult.usedExistingAgent) {
194
+ deps.persistCurrentSession?.(startedAt, 'main', sessionKey);
195
+ } else if (runResult.agent) {
196
+ _persistEphemeralAgentSession(runResult.agent, sessionKey, startedAt);
197
+ }
198
+
199
+ _emit('cron.notification', {
200
+ title: 'Sentinel heartbeat',
201
+ body: trimmed,
202
+ source: 'heartbeat',
203
+ });
204
+
205
+ if (pendingEvents.length > 0) {
206
+ consumePendingEvents(pendingEvents.map((e) => e.id));
207
+ }
208
+
209
+ setHeartbeatConfig({
210
+ nextRunAt: startedAt + (Number(config.everyMs) || 1_800_000),
211
+ lastHash: hash,
212
+ lastSentAt: startedAt,
213
+ });
214
+
215
+ return {
216
+ status: 'ok',
217
+ responsePreview: trimmed.slice(0, 240),
218
+ };
219
+ } catch (err) {
220
+ heartbeatConsecutiveErrors += 1;
221
+ const normalNext = startedAt + (Number(config.everyMs) || 1_800_000);
222
+ const backoffNext = startedAt + errorBackoffMs(heartbeatConsecutiveErrors);
223
+ setHeartbeatConfig({ nextRunAt: Math.max(normalNext, backoffNext) });
224
+ return { status: 'error', reason: err?.message || 'heartbeat_error' };
225
+ }
226
+ }
227
+
228
+ async function _runDueCronJobs(params) {
229
+ const now = params.now ?? Date.now();
230
+ const skills = listCronSkills();
231
+ const skillById = new Map(skills.map((s) => [s.id, s]));
232
+ const allJobs = listCronJobs();
233
+ const dueJobs = params.forceJobId
234
+ ? allJobs.filter((job) => job.id === params.forceJobId)
235
+ : getDueJobs(now);
236
+
237
+ for (const job of dueJobs) {
238
+ const startedAt = Date.now();
239
+ const skill = skillById.get(job.skillId) || null;
240
+
241
+ if (!job.enabled) continue;
242
+
243
+ if (!isWithinActiveHours(job.activeHours?.start, job.activeHours?.end, job.activeHours?.tz)) {
244
+ const nextRunAt = _computeNextRunAt(job, now);
245
+ updateCronJob(job.id, {
246
+ lastRunAt: startedAt,
247
+ nextRunAt,
248
+ lastRunStatus: 'skipped',
249
+ });
250
+ insertCronRun({
251
+ jobId: job.id,
252
+ startedAt,
253
+ endedAt: Date.now(),
254
+ status: 'skipped',
255
+ wakeSource: params.source,
256
+ });
257
+ continue;
258
+ }
259
+
260
+ _emit('cron.job.started', { jobId: job.id, jobName: job.name });
261
+
262
+ try {
263
+ if (job.sessionTarget === 'main') {
264
+ const sessionKey = deps.getCurrentSessionKey?.() || `main/${Date.now()}`;
265
+ const contextKey = `cron:${job.id}:${startedAt}`;
266
+ enqueueSystemEvent(sessionKey, contextKey, job.prompt);
267
+
268
+ if (job.wakeMode === 'now' || params.forceJobId === job.id) {
269
+ await _runHeartbeatCycle({
270
+ source: `cron:${job.id}`,
271
+ now: Date.now(),
272
+ force: true,
273
+ forceSessionKey: sessionKey,
274
+ });
275
+ }
276
+
277
+ const nextRunAt = _computeNextRunAt(job, startedAt);
278
+ updateCronJob(job.id, {
279
+ lastRunAt: startedAt,
280
+ nextRunAt,
281
+ lastRunStatus: 'ok',
282
+ lastError: null,
283
+ lastDurationMs: Date.now() - startedAt,
284
+ consecutiveErrors: 0,
285
+ });
286
+
287
+ insertCronRun({
288
+ jobId: job.id,
289
+ startedAt,
290
+ endedAt: Date.now(),
291
+ status: 'ok',
292
+ responseText: 'Enqueued to main session heartbeat.',
293
+ delivered: false,
294
+ wakeSource: params.source,
295
+ });
296
+
297
+ _emit('cron.job.completed', {
298
+ jobId: job.id,
299
+ status: 'ok',
300
+ durationMs: Date.now() - startedAt,
301
+ responsePreview: 'Queued for heartbeat',
302
+ });
303
+ continue;
304
+ }
305
+
306
+ const resultText = await _runIsolatedCronJob({
307
+ job,
308
+ skill,
309
+ });
310
+ const trimmed = (resultText || '').trim();
311
+ const hash = fnv1aHash(trimmed);
312
+
313
+ const isDeduped =
314
+ !!trimmed &&
315
+ !!job.lastResponseHash &&
316
+ job.lastResponseHash === hash &&
317
+ !!job.lastResponseSentAt &&
318
+ startedAt - job.lastResponseSentAt < 24 * 60 * 60 * 1000;
319
+
320
+ const status = !trimmed
321
+ ? 'suppressed'
322
+ : isHeartbeatOk(trimmed)
323
+ ? 'suppressed'
324
+ : isDeduped
325
+ ? 'deduped'
326
+ : 'ok';
327
+
328
+ let delivered = false;
329
+ if (status === 'ok') {
330
+ delivered = await _deliverCronResult(job, trimmed);
331
+ }
332
+
333
+ const nextRunAt = _computeNextRunAt(job, startedAt);
334
+ const runEndedAt = Date.now();
335
+
336
+ updateCronJob(job.id, {
337
+ lastRunAt: startedAt,
338
+ nextRunAt,
339
+ lastRunStatus: status,
340
+ lastError: null,
341
+ lastDurationMs: runEndedAt - startedAt,
342
+ lastResponseHash: trimmed ? hash : job.lastResponseHash,
343
+ lastResponseSentAt: delivered ? runEndedAt : job.lastResponseSentAt,
344
+ consecutiveErrors: 0,
345
+ });
346
+
347
+ insertCronRun({
348
+ jobId: job.id,
349
+ startedAt,
350
+ endedAt: runEndedAt,
351
+ status,
352
+ durationMs: runEndedAt - startedAt,
353
+ responseText: trimmed || null,
354
+ wasHeartbeatOk: isHeartbeatOk(trimmed),
355
+ wasDeduped: isDeduped,
356
+ delivered,
357
+ wakeSource: params.source,
358
+ });
359
+
360
+ _emit('cron.job.completed', {
361
+ jobId: job.id,
362
+ status,
363
+ durationMs: runEndedAt - startedAt,
364
+ ...(trimmed ? { responsePreview: trimmed.slice(0, 200) } : {}),
365
+ });
366
+ } catch (err) {
367
+ const nextConsecutiveErrors = (job.consecutiveErrors || 0) + 1;
368
+ const normalNext = _computeNextRunAt(job, startedAt);
369
+ const backoffNext = startedAt + errorBackoffMs(nextConsecutiveErrors);
370
+ const nextRunAt = normalNext ? Math.max(normalNext, backoffNext) : backoffNext;
371
+
372
+ updateCronJob(job.id, {
373
+ lastRunAt: startedAt,
374
+ nextRunAt,
375
+ lastRunStatus: 'error',
376
+ lastError: err?.message || 'cron_error',
377
+ lastDurationMs: Date.now() - startedAt,
378
+ consecutiveErrors: nextConsecutiveErrors,
379
+ });
380
+
381
+ insertCronRun({
382
+ jobId: job.id,
383
+ startedAt,
384
+ endedAt: Date.now(),
385
+ status: 'error',
386
+ error: err?.message || 'cron_error',
387
+ wakeSource: params.source,
388
+ });
389
+
390
+ _emit('cron.job.error', {
391
+ jobId: job.id,
392
+ error: err?.message || 'cron_error',
393
+ consecutiveErrors: nextConsecutiveErrors,
394
+ });
395
+ }
396
+ }
397
+ }
398
+
399
+ async function _runHeartbeatTurn({ prompt, heartbeatSkill }) {
400
+ const existingAgent = deps.getCurrentAgent?.();
401
+ if (existingAgent) {
402
+ const preMessageCount = existingAgent.state.messages.length;
403
+ await existingAgent.prompt(prompt);
404
+ await existingAgent.waitForIdle();
405
+ return {
406
+ text: _extractLastAssistantText(existingAgent.state.messages),
407
+ agent: existingAgent,
408
+ preMessageCount,
409
+ usedExistingAgent: true,
410
+ };
411
+ }
412
+
413
+ const agent = await _createUnattendedAgent({
414
+ skill: heartbeatSkill,
415
+ });
416
+ await agent.prompt(prompt);
417
+ await agent.waitForIdle();
418
+ return {
419
+ text: _extractLastAssistantText(agent.state.messages),
420
+ agent,
421
+ preMessageCount: 0,
422
+ usedExistingAgent: false,
423
+ };
424
+ }
425
+
426
+ async function _runIsolatedCronJob({ job, skill }) {
427
+ const agent = await _createUnattendedAgent({ skill });
428
+ await agent.prompt(job.prompt);
429
+ await agent.waitForIdle();
430
+ return _extractLastAssistantText(agent.state.messages);
431
+ }
432
+
433
+ async function _createUnattendedAgent({ skill }) {
434
+ await deps.refreshOAuthTokenIfNeeded?.('main');
435
+ const authProfiles = deps.loadAuthProfiles?.('main');
436
+ const apiKey = deps.resolveApiKey?.(authProfiles);
437
+ if (!apiKey) {
438
+ throw new Error('No API provider configured');
439
+ }
440
+
441
+ const allowedTools = Array.isArray(skill?.allowedTools) ? skill.allowedTools : null;
442
+ const localTools = deps.buildAutoApproveTools(allowedTools);
443
+ const mcpRaw = await deps.discoverMcpTools();
444
+ const mcpTools = allowedTools
445
+ ? mcpRaw.filter((tool) => allowedTools.includes(tool.name))
446
+ : mcpRaw;
447
+ const tools = [...localTools, ...mcpTools];
448
+
449
+ const modelId = skill?.model || 'claude-sonnet-4-5';
450
+ const model = deps.getModel('anthropic', modelId);
451
+ const systemPrompt = skill?.systemPrompt || deps.loadSystemPrompt();
452
+
453
+ return new deps.Agent({
454
+ initialState: {
455
+ systemPrompt,
456
+ model,
457
+ tools,
458
+ thinkingLevel: 'off',
459
+ ...(skill?.maxTurns ? { maxTurns: skill.maxTurns } : {}),
460
+ },
461
+ convertToLlm: (messages) =>
462
+ messages.filter(
463
+ (m) => m.role === 'user' || m.role === 'assistant' || m.role === 'toolResult'
464
+ ),
465
+ getApiKey: () => apiKey,
466
+ });
467
+ }
468
+
469
+ function _persistEphemeralAgentSession(agent, sessionKey, startedAt) {
470
+ if (!isDbReady()) return;
471
+ const usage = _extractUsage(agent);
472
+ const messages = agent.state?.messages || [];
473
+
474
+ for (let i = 0; i < messages.length; i++) {
475
+ const m = messages[i];
476
+ dbRun(
477
+ `INSERT OR IGNORE INTO messages
478
+ (session_key, sequence, role, content, timestamp, model, tool_call_id, usage_input, usage_output)
479
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
480
+ [
481
+ sessionKey,
482
+ i,
483
+ m.role,
484
+ typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
485
+ m.timestamp || null,
486
+ m.model || null,
487
+ m.toolCallId || null,
488
+ m.usage?.input || null,
489
+ m.usage?.output || null,
490
+ ]
491
+ );
492
+ }
493
+
494
+ dbRun(
495
+ `INSERT OR REPLACE INTO sessions
496
+ (session_key, agent_id, created_at, updated_at, model, total_tokens, input_tokens, output_tokens)
497
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
498
+ [
499
+ sessionKey,
500
+ 'main',
501
+ startedAt,
502
+ Date.now(),
503
+ 'anthropic/claude-sonnet-4-5',
504
+ usage.totalTokens,
505
+ usage.inputTokens,
506
+ usage.outputTokens,
507
+ ]
508
+ );
509
+
510
+ dbFlush();
511
+ }
512
+
513
+ function _pruneInMemoryMessages(agent, keepCount) {
514
+ if (!agent || typeof keepCount !== 'number') return;
515
+ const current = Array.isArray(agent.state?.messages) ? agent.state.messages : [];
516
+ if (current.length <= keepCount) return;
517
+ const trimmed = current.slice(0, keepCount);
518
+ if (typeof agent.replaceMessages === 'function') {
519
+ agent.replaceMessages(trimmed);
520
+ return;
521
+ }
522
+ if (agent.state) {
523
+ agent.state.messages = trimmed;
524
+ }
525
+ }
526
+
527
+ function _extractUsage(agent) {
528
+ let inputTokens = 0;
529
+ let outputTokens = 0;
530
+ for (const msg of agent.state?.messages || []) {
531
+ if (msg.role === 'assistant' && msg.usage) {
532
+ inputTokens += msg.usage.input || 0;
533
+ outputTokens += msg.usage.output || 0;
534
+ }
535
+ }
536
+ return {
537
+ inputTokens,
538
+ outputTokens,
539
+ totalTokens: inputTokens + outputTokens,
540
+ };
541
+ }
542
+
543
+ function _extractLastAssistantText(messages) {
544
+ for (let i = messages.length - 1; i >= 0; i--) {
545
+ const msg = messages[i];
546
+ if (msg.role !== 'assistant') continue;
547
+ const content = msg.content;
548
+ if (typeof content === 'string') return content;
549
+ if (!Array.isArray(content)) continue;
550
+ const text = content
551
+ .filter((part) => part && part.type === 'text')
552
+ .map((part) => part.text || '')
553
+ .join('')
554
+ .trim();
555
+ if (text) return text;
556
+ }
557
+ return '';
558
+ }
559
+
560
+ function _buildHeartbeatPrompt({ basePrompt, pendingEvents }) {
561
+ if (!pendingEvents || pendingEvents.length === 0) return basePrompt;
562
+ const lines = pendingEvents.map((event) => {
563
+ const ts = new Date(event.createdAt).toISOString();
564
+ const key = event.contextKey ? ` (${event.contextKey})` : '';
565
+ return `- [${ts}]${key} ${event.text}`;
566
+ });
567
+ return `${basePrompt}\n\nSystem events:\n${lines.join('\n')}`;
568
+ }
569
+
570
+ function _resolveSkill(skillId) {
571
+ if (!skillId) return null;
572
+ const skills = listCronSkills();
573
+ return skills.find((s) => s.id === skillId) || null;
574
+ }
575
+
576
+ function _computeNextRunAt(job, nowMs) {
577
+ const schedule = job.schedule || {};
578
+ if (schedule.kind === 'every') {
579
+ const everyMs = Number(schedule.everyMs) || 0;
580
+ return everyMs > 0 ? nowMs + everyMs : null;
581
+ }
582
+ if (schedule.kind === 'at') {
583
+ const atMs = Number(schedule.atMs) || 0;
584
+ return atMs > nowMs ? atMs : null;
585
+ }
586
+ return null;
587
+ }
588
+
589
+ async function _deliverCronResult(job, text) {
590
+ if (!text || job.deliveryMode === 'none') return false;
591
+
592
+ if (job.deliveryMode === 'webhook' && job.deliveryWebhookUrl) {
593
+ try {
594
+ const response = await fetch(job.deliveryWebhookUrl, {
595
+ method: 'POST',
596
+ headers: { 'Content-Type': 'application/json' },
597
+ body: JSON.stringify({
598
+ jobId: job.id,
599
+ jobName: job.name,
600
+ text,
601
+ sentAt: Date.now(),
602
+ }),
603
+ });
604
+ if (response.ok) return true;
605
+ } catch {
606
+ // Fall back to local notification event below.
607
+ }
608
+ }
609
+
610
+ _emit('cron.notification', {
611
+ title: job.deliveryNotificationTitle || job.name,
612
+ body: text,
613
+ jobId: job.id,
614
+ source: 'cron',
615
+ });
616
+ return true;
617
+ }
618
+
619
+ export function captureTranscriptState(agentId, sessionKey, openclawRoot = deps?.OPENCLAW_ROOT) {
620
+ const jsonlPath = openclawRoot
621
+ ? join(openclawRoot, 'agents', agentId, 'sessions', `${sessionKey.replace('/', '_')}.jsonl`)
622
+ : null;
623
+
624
+ let jsonlSize = null;
625
+ if (jsonlPath && existsSync(jsonlPath)) {
626
+ try {
627
+ jsonlSize = statSync(jsonlPath).size;
628
+ } catch {
629
+ jsonlSize = null;
630
+ }
631
+ }
632
+
633
+ if (!isDbReady()) {
634
+ return {
635
+ sessionKey,
636
+ jsonlPath,
637
+ jsonlSize,
638
+ maxMessageSeq: -1,
639
+ sessionUpdatedAt: null,
640
+ };
641
+ }
642
+
643
+ const maxRow = dbQueryOne('SELECT MAX(sequence) as max_seq FROM messages WHERE session_key = ?', [
644
+ sessionKey,
645
+ ]);
646
+ const maxMessageSeq = Number.isFinite(maxRow?.max_seq) ? maxRow.max_seq : -1;
647
+
648
+ const sessionRow = dbQueryOne('SELECT updated_at FROM sessions WHERE session_key = ?', [sessionKey]);
649
+
650
+ return {
651
+ sessionKey,
652
+ jsonlPath,
653
+ jsonlSize,
654
+ maxMessageSeq,
655
+ sessionUpdatedAt:
656
+ typeof sessionRow?.updated_at === 'number' ? sessionRow.updated_at : null,
657
+ };
658
+ }
659
+
660
+ export function pruneHeartbeatTranscript(state, sessionKey = state?.sessionKey) {
661
+ if (!state || !sessionKey) return;
662
+
663
+ if (state.jsonlPath && typeof state.jsonlSize === 'number') {
664
+ try {
665
+ if (existsSync(state.jsonlPath)) {
666
+ const size = statSync(state.jsonlPath).size;
667
+ if (size > state.jsonlSize) {
668
+ truncateSync(state.jsonlPath, state.jsonlSize);
669
+ }
670
+ }
671
+ } catch {
672
+ // Non-fatal.
673
+ }
674
+ }
675
+
676
+ if (isDbReady()) {
677
+ dbRun('DELETE FROM messages WHERE session_key = ? AND sequence > ?', [
678
+ sessionKey,
679
+ typeof state.maxMessageSeq === 'number' ? state.maxMessageSeq : -1,
680
+ ]);
681
+
682
+ if (typeof state.sessionUpdatedAt === 'number') {
683
+ dbRun('UPDATE sessions SET updated_at = ? WHERE session_key = ?', [
684
+ state.sessionUpdatedAt,
685
+ sessionKey,
686
+ ]);
687
+ }
688
+ dbFlush();
689
+ }
690
+ }
691
+
692
+ export function isHeartbeatOk(text) {
693
+ const trimmed = (text || '').trim();
694
+ if (!trimmed) return true;
695
+ return HEARTBEAT_OK_TOKENS.some(
696
+ (token) => trimmed === token || trimmed.startsWith(`${token}\n`)
697
+ );
698
+ }
699
+
700
+ export function fnv1aHash(str) {
701
+ let hash = 0x811c9dc5;
702
+ for (let i = 0; i < str.length; i++) {
703
+ hash ^= str.charCodeAt(i);
704
+ hash = Math.imul(hash, 0x01000193);
705
+ }
706
+ return (hash >>> 0).toString(16).padStart(8, '0');
707
+ }
708
+
709
+ export function errorBackoffMs(consecutiveErrors) {
710
+ const idx = Math.min(consecutiveErrors - 1, ERROR_BACKOFF_MS.length - 1);
711
+ return ERROR_BACKOFF_MS[Math.max(0, idx)];
712
+ }
713
+
714
+ function _parseActiveHoursMinutes(raw, allow24 = false) {
715
+ if (typeof raw !== 'string') return null;
716
+ const match = raw.trim().match(/^(\d{2}):(\d{2})$/);
717
+ if (!match) return null;
718
+ const hour = Number(match[1]);
719
+ const minute = Number(match[2]);
720
+ if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
721
+ if (hour === 24) {
722
+ if (!allow24 || minute !== 0) return null;
723
+ return 24 * 60;
724
+ }
725
+ if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
726
+ return hour * 60 + minute;
727
+ }
728
+
729
+ function _resolveMinutesInTimeZone(nowMs, tz) {
730
+ try {
731
+ const parts = new Intl.DateTimeFormat('en-US', {
732
+ timeZone: tz || 'UTC',
733
+ hour: '2-digit',
734
+ minute: '2-digit',
735
+ hourCycle: 'h23',
736
+ }).formatToParts(new Date(nowMs));
737
+ const map = {};
738
+ for (const p of parts) {
739
+ if (p.type !== 'literal') map[p.type] = p.value;
740
+ }
741
+ const hour = Number(map.hour);
742
+ const minute = Number(map.minute);
743
+ if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
744
+ return hour * 60 + minute;
745
+ } catch {
746
+ return null;
747
+ }
748
+ }
749
+
750
+ export function isWithinActiveHours(start, end, tz, nowMs = Date.now()) {
751
+ if (!start || !end) return true;
752
+ const startMin = _parseActiveHoursMinutes(start, false);
753
+ const endMin = _parseActiveHoursMinutes(end, true);
754
+ if (startMin === null || endMin === null) return true;
755
+ if (startMin === endMin) return false;
756
+
757
+ const currentMin = _resolveMinutesInTimeZone(nowMs, tz || 'UTC');
758
+ if (currentMin === null) return true;
759
+
760
+ if (endMin > startMin) {
761
+ return currentMin >= startMin && currentMin < endMin;
762
+ }
763
+ return currentMin >= startMin || currentMin < endMin;
764
+ }