@owloops/browserbird 1.5.9 → 1.6.0
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/dist/index.mjs +1242 -46
- package/package.json +1 -1
- package/dist/db-BNF1vZIm.mjs +0 -1038
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { $ as touchSession, A as SYSTEM_CRON_PREFIX, B as listCronJobs, C as failStaleJobs, D as listJobs, E as hasPendingCronJob, F as deleteOldCronRuns, G as createSession, H as setCronJobEnabled, I as ensureSystemCronJob, J as getSession, K as deleteOldSessions, L as getCronJob, M as createCronJob, N as createCronRun, O as retryAllFailedJobs, P as deleteCronJob, Q as listSessions, R as getEnabledCronJobs, S as failJob, T as getJobStatus, U as updateCronJob, V as listFlights, W as updateCronJobStatus, X as getSessionMessages, Y as getSessionCount, Z as getSessionTokenStats, _ as clearJobs, a as getSetting, at as optimizeDatabase, b as deleteJob, c as getUserCount, d as getRecentLogs, et as updateSessionProviderId, f as insertLog, g as claimNextJob, h as logMessage, i as createUser, it as openDatabase, j as completeCronRun, k as retryJob, l as setSetting, m as getMessageStats, n as resolveDbPath, nt as closeDatabase, o as getUserByEmail, ot as resolveByUid, p as deleteOldMessages, q as findSession, r as resolveDbPathFromArgv, rt as getDb, s as getUserById, st as logger, tt as shortUid, u as deleteOldLogs, v as completeJob, w as getJobStats, x as deleteOldJobs, y as createJob, z as getFlightStats } from "./db-BNF1vZIm.mjs";
|
|
2
1
|
import { createRequire } from "node:module";
|
|
3
2
|
import { parseArgs, styleText } from "node:util";
|
|
4
|
-
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
5
4
|
import { dirname, extname, join, resolve } from "node:path";
|
|
5
|
+
import { DatabaseSync } from "node:sqlite";
|
|
6
6
|
import { createHmac, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
|
7
7
|
import { connect } from "node:net";
|
|
8
8
|
import { execFileSync, spawn } from "node:child_process";
|
|
@@ -21,6 +21,76 @@ const COMMANDS = {
|
|
|
21
21
|
DOCTOR: "doctor"
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/core/logger.ts
|
|
26
|
+
/**
|
|
27
|
+
* @fileoverview Structured logger. Respects NO_COLOR.
|
|
28
|
+
*
|
|
29
|
+
* Stream routing by mode:
|
|
30
|
+
* - CLI mode (default): all logs to stderr, keeping stdout clean for data output.
|
|
31
|
+
* - Daemon mode: errors/warnings to stderr, everything else to stdout.
|
|
32
|
+
* Cloud platforms (Railway, Fly, etc.) treat stderr lines as errors, so this
|
|
33
|
+
* prevents info lines from showing up red.
|
|
34
|
+
*
|
|
35
|
+
* Call `logger.setMode('daemon')` once at startup to switch.
|
|
36
|
+
*/
|
|
37
|
+
function shouldUseColor$1() {
|
|
38
|
+
if (process.env["NO_COLOR"] !== void 0) return false;
|
|
39
|
+
if (process.env["TERM"] === "dumb") return false;
|
|
40
|
+
if (process.argv.includes("--no-color")) return false;
|
|
41
|
+
return process.stdout.isTTY === true || process.stderr.isTTY === true;
|
|
42
|
+
}
|
|
43
|
+
const useColor$1 = shouldUseColor$1();
|
|
44
|
+
function style(format, text) {
|
|
45
|
+
if (!useColor$1) return text;
|
|
46
|
+
return styleText(format, text);
|
|
47
|
+
}
|
|
48
|
+
const LOG_LEVELS = {
|
|
49
|
+
ERROR: 0,
|
|
50
|
+
WARN: 1,
|
|
51
|
+
INFO: 2,
|
|
52
|
+
DEBUG: 3
|
|
53
|
+
};
|
|
54
|
+
let currentLevel = LOG_LEVELS.INFO;
|
|
55
|
+
let daemonMode = false;
|
|
56
|
+
function timestamp() {
|
|
57
|
+
return (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
|
|
58
|
+
}
|
|
59
|
+
function out(prefix, message) {
|
|
60
|
+
(daemonMode ? process.stdout : process.stderr).write(`${style("dim", timestamp())} ${prefix} ${message}\n`);
|
|
61
|
+
}
|
|
62
|
+
function err(prefix, message) {
|
|
63
|
+
process.stderr.write(`${style("dim", timestamp())} ${prefix} ${message}\n`);
|
|
64
|
+
}
|
|
65
|
+
const logger = {
|
|
66
|
+
error(message) {
|
|
67
|
+
if (currentLevel >= LOG_LEVELS.ERROR) err(style("red", "[error]"), message);
|
|
68
|
+
},
|
|
69
|
+
warn(message) {
|
|
70
|
+
if (currentLevel >= LOG_LEVELS.WARN) err(style("yellow", "[warn]"), message);
|
|
71
|
+
},
|
|
72
|
+
info(message) {
|
|
73
|
+
if (currentLevel >= LOG_LEVELS.INFO) out(style("blue", "[info]"), message);
|
|
74
|
+
},
|
|
75
|
+
debug(message) {
|
|
76
|
+
if (currentLevel >= LOG_LEVELS.DEBUG) out(style("dim", "[debug]"), message);
|
|
77
|
+
},
|
|
78
|
+
success(message) {
|
|
79
|
+
if (currentLevel >= LOG_LEVELS.INFO) out(style("green", "[ok]"), message);
|
|
80
|
+
},
|
|
81
|
+
setLevel(level) {
|
|
82
|
+
currentLevel = {
|
|
83
|
+
error: LOG_LEVELS.ERROR,
|
|
84
|
+
warn: LOG_LEVELS.WARN,
|
|
85
|
+
info: LOG_LEVELS.INFO,
|
|
86
|
+
debug: LOG_LEVELS.DEBUG
|
|
87
|
+
}[level] ?? LOG_LEVELS.INFO;
|
|
88
|
+
},
|
|
89
|
+
setMode(mode) {
|
|
90
|
+
daemonMode = mode === "daemon";
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
24
94
|
//#endregion
|
|
25
95
|
//#region src/core/utils.ts
|
|
26
96
|
/** @fileoverview Shared utilities: formatting, time ranges, and CLI table output. */
|
|
@@ -122,8 +192,8 @@ function unknownSubcommand(subcommand, command, validCommands) {
|
|
|
122
192
|
/** @fileoverview ASCII banner displayed on daemon startup and in help text. */
|
|
123
193
|
const pkg = createRequire(import.meta.url)("../package.json");
|
|
124
194
|
const buildInfo = [];
|
|
125
|
-
buildInfo.push(`commit: ${"
|
|
126
|
-
buildInfo.push(`built: 2026-03-
|
|
195
|
+
buildInfo.push(`commit: ${"8468e2519ab927c9f810bc3af7d127829a93c83e".substring(0, 7)}`);
|
|
196
|
+
buildInfo.push(`built: 2026-03-19T13:57:16+04:00`);
|
|
127
197
|
const buildString = buildInfo.length > 0 ? ` (${buildInfo.join(", ")})` : "";
|
|
128
198
|
const VERSION = `browserbird ${pkg.version}${buildString}`;
|
|
129
199
|
const BIRD = [
|
|
@@ -346,6 +416,329 @@ function loadDotEnv(envPath) {
|
|
|
346
416
|
}
|
|
347
417
|
}
|
|
348
418
|
|
|
419
|
+
//#endregion
|
|
420
|
+
//#region src/db/core.ts
|
|
421
|
+
/** @fileoverview SQLite database lifecycle, migrations, and query utilities. */
|
|
422
|
+
const DEFAULT_PER_PAGE = 15;
|
|
423
|
+
const MAX_PER_PAGE = 100;
|
|
424
|
+
/**
|
|
425
|
+
* Parses a sort string into an SQL ORDER BY clause.
|
|
426
|
+
* Each token is a column name optionally prefixed with `-` for DESC.
|
|
427
|
+
* Only columns present in `allowedColumns` are included.
|
|
428
|
+
*/
|
|
429
|
+
function parseSort(raw, allowedColumns, fallback) {
|
|
430
|
+
if (!raw) return fallback;
|
|
431
|
+
const parts = [];
|
|
432
|
+
for (const token of raw.split(",")) {
|
|
433
|
+
const trimmed = token.trim();
|
|
434
|
+
if (!trimmed) continue;
|
|
435
|
+
const desc = trimmed.startsWith("-");
|
|
436
|
+
const col = desc ? trimmed.slice(1) : trimmed;
|
|
437
|
+
if (allowedColumns.has(col)) parts.push(`${col} ${desc ? "DESC" : "ASC"}`);
|
|
438
|
+
}
|
|
439
|
+
return parts.length > 0 ? parts.join(", ") : fallback;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Builds a parenthesized OR clause for LIKE-based search across columns.
|
|
443
|
+
* Returns empty sql/params when the search term is empty.
|
|
444
|
+
*/
|
|
445
|
+
function buildSearchClause(term, columns) {
|
|
446
|
+
if (!term || columns.length === 0) return {
|
|
447
|
+
sql: "",
|
|
448
|
+
params: []
|
|
449
|
+
};
|
|
450
|
+
const like = `%${term}%`;
|
|
451
|
+
return {
|
|
452
|
+
sql: `(${columns.map((c) => `${c} LIKE ?`).join(" OR ")})`,
|
|
453
|
+
params: columns.map(() => like)
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
function paginate(table, page, perPage, whereOrOptions, params, orderBy) {
|
|
457
|
+
let where;
|
|
458
|
+
let allParams;
|
|
459
|
+
let resolvedOrderBy;
|
|
460
|
+
if (typeof whereOrOptions === "object" && whereOrOptions !== null) {
|
|
461
|
+
const opts = whereOrOptions;
|
|
462
|
+
const conditions = [];
|
|
463
|
+
allParams = [...opts.params ?? []];
|
|
464
|
+
if (opts.where) conditions.push(opts.where);
|
|
465
|
+
if (opts.search && opts.searchColumns && opts.searchColumns.length > 0) {
|
|
466
|
+
const sc = buildSearchClause(opts.search, opts.searchColumns);
|
|
467
|
+
if (sc.sql) {
|
|
468
|
+
conditions.push(sc.sql);
|
|
469
|
+
allParams.push(...sc.params);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
where = conditions.join(" AND ");
|
|
473
|
+
resolvedOrderBy = parseSort(opts.sort, opts.allowedSortColumns ?? /* @__PURE__ */ new Set(), opts.defaultSort ?? "created_at DESC");
|
|
474
|
+
} else {
|
|
475
|
+
where = whereOrOptions ?? "";
|
|
476
|
+
allParams = params ?? [];
|
|
477
|
+
resolvedOrderBy = orderBy ?? "created_at DESC";
|
|
478
|
+
}
|
|
479
|
+
const pp = Math.min(Math.max(perPage, 1), MAX_PER_PAGE);
|
|
480
|
+
const p = Math.max(page, 1);
|
|
481
|
+
const offset = (p - 1) * pp;
|
|
482
|
+
const countSql = `SELECT COUNT(*) as count FROM ${table}${where ? ` WHERE ${where}` : ""}`;
|
|
483
|
+
const totalItems = getDb().prepare(countSql).get(...allParams).count;
|
|
484
|
+
const totalPages = Math.max(Math.ceil(totalItems / pp), 1);
|
|
485
|
+
const dataSql = `SELECT * FROM ${table}${where ? ` WHERE ${where}` : ""} ORDER BY ${resolvedOrderBy} LIMIT ? OFFSET ?`;
|
|
486
|
+
return {
|
|
487
|
+
items: getDb().prepare(dataSql).all(...allParams, pp, offset),
|
|
488
|
+
page: p,
|
|
489
|
+
perPage: pp,
|
|
490
|
+
totalItems,
|
|
491
|
+
totalPages
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Versioned migration registry. Each entry runs once, in order.
|
|
496
|
+
* PRAGMA user_version tracks which migrations have been applied.
|
|
497
|
+
* All DDL uses IF NOT EXISTS for idempotency.
|
|
498
|
+
*/
|
|
499
|
+
const MIGRATIONS = [
|
|
500
|
+
{
|
|
501
|
+
name: "initial schema",
|
|
502
|
+
up(d) {
|
|
503
|
+
d.exec(`
|
|
504
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
505
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
506
|
+
email TEXT UNIQUE NOT NULL COLLATE NOCASE,
|
|
507
|
+
password_hash TEXT NOT NULL,
|
|
508
|
+
token_key TEXT NOT NULL,
|
|
509
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
513
|
+
key TEXT PRIMARY KEY,
|
|
514
|
+
value TEXT NOT NULL
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
518
|
+
uid TEXT PRIMARY KEY,
|
|
519
|
+
channel_id TEXT NOT NULL,
|
|
520
|
+
thread_id TEXT,
|
|
521
|
+
agent_id TEXT NOT NULL DEFAULT 'default',
|
|
522
|
+
provider_session_id TEXT NOT NULL,
|
|
523
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
524
|
+
last_active TEXT NOT NULL DEFAULT (datetime('now')),
|
|
525
|
+
message_count INTEGER NOT NULL DEFAULT 0,
|
|
526
|
+
UNIQUE(channel_id, thread_id)
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
CREATE TABLE IF NOT EXISTS cron_jobs (
|
|
530
|
+
uid TEXT PRIMARY KEY,
|
|
531
|
+
name TEXT NOT NULL,
|
|
532
|
+
agent_id TEXT NOT NULL DEFAULT 'default',
|
|
533
|
+
schedule TEXT NOT NULL,
|
|
534
|
+
prompt TEXT NOT NULL,
|
|
535
|
+
target_channel_id TEXT,
|
|
536
|
+
active_hours_start TEXT,
|
|
537
|
+
active_hours_end TEXT,
|
|
538
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
539
|
+
failure_count INTEGER NOT NULL DEFAULT 0,
|
|
540
|
+
last_run TEXT,
|
|
541
|
+
last_status TEXT,
|
|
542
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
CREATE TABLE IF NOT EXISTS cron_runs (
|
|
546
|
+
uid TEXT PRIMARY KEY,
|
|
547
|
+
job_uid TEXT NOT NULL REFERENCES cron_jobs(uid),
|
|
548
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
549
|
+
finished_at TEXT,
|
|
550
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
551
|
+
result TEXT,
|
|
552
|
+
error TEXT
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
556
|
+
id INTEGER PRIMARY KEY,
|
|
557
|
+
channel_id TEXT NOT NULL,
|
|
558
|
+
thread_id TEXT,
|
|
559
|
+
user_id TEXT NOT NULL,
|
|
560
|
+
direction TEXT NOT NULL CHECK(direction IN ('in', 'out')),
|
|
561
|
+
content TEXT,
|
|
562
|
+
tokens_in INTEGER,
|
|
563
|
+
tokens_out INTEGER,
|
|
564
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
568
|
+
id INTEGER PRIMARY KEY,
|
|
569
|
+
name TEXT NOT NULL,
|
|
570
|
+
payload TEXT,
|
|
571
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'running', 'completed', 'failed')),
|
|
572
|
+
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('high', 'normal', 'low')),
|
|
573
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
574
|
+
max_attempts INTEGER NOT NULL DEFAULT 1,
|
|
575
|
+
timeout INTEGER NOT NULL DEFAULT 1800,
|
|
576
|
+
cron_job_uid TEXT REFERENCES cron_jobs(uid),
|
|
577
|
+
run_at TEXT,
|
|
578
|
+
started_at TEXT,
|
|
579
|
+
completed_at TEXT,
|
|
580
|
+
result TEXT,
|
|
581
|
+
error TEXT,
|
|
582
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
CREATE TABLE IF NOT EXISTS logs (
|
|
586
|
+
id INTEGER PRIMARY KEY,
|
|
587
|
+
level TEXT NOT NULL DEFAULT 'info' CHECK(level IN ('debug', 'info', 'warn', 'error')),
|
|
588
|
+
source TEXT NOT NULL,
|
|
589
|
+
message TEXT NOT NULL,
|
|
590
|
+
channel_id TEXT,
|
|
591
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_channel_thread
|
|
595
|
+
ON sessions(channel_id, thread_id);
|
|
596
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_last_active
|
|
597
|
+
ON sessions(last_active);
|
|
598
|
+
CREATE INDEX IF NOT EXISTS idx_messages_channel_thread
|
|
599
|
+
ON messages(channel_id, thread_id);
|
|
600
|
+
CREATE INDEX IF NOT EXISTS idx_messages_created_at
|
|
601
|
+
ON messages(created_at DESC);
|
|
602
|
+
CREATE INDEX IF NOT EXISTS idx_cron_runs_job_uid
|
|
603
|
+
ON cron_runs(job_uid, started_at DESC);
|
|
604
|
+
CREATE INDEX IF NOT EXISTS idx_cron_jobs_enabled
|
|
605
|
+
ON cron_jobs(enabled);
|
|
606
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_poll
|
|
607
|
+
ON jobs(status, priority, run_at, created_at);
|
|
608
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_cron_job_uid
|
|
609
|
+
ON jobs(cron_job_uid);
|
|
610
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_stale
|
|
611
|
+
ON jobs(status, started_at);
|
|
612
|
+
CREATE INDEX IF NOT EXISTS idx_logs_created_at
|
|
613
|
+
ON logs(created_at DESC);
|
|
614
|
+
CREATE INDEX IF NOT EXISTS idx_logs_level_source
|
|
615
|
+
ON logs(level, source, created_at DESC);
|
|
616
|
+
`);
|
|
617
|
+
}
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
name: "browser lock",
|
|
621
|
+
up(d) {
|
|
622
|
+
d.exec(`
|
|
623
|
+
CREATE TABLE IF NOT EXISTS browser_lock (
|
|
624
|
+
id INTEGER PRIMARY KEY CHECK(id = 1),
|
|
625
|
+
holder TEXT NOT NULL,
|
|
626
|
+
acquired_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
627
|
+
);
|
|
628
|
+
`);
|
|
629
|
+
}
|
|
630
|
+
},
|
|
631
|
+
{
|
|
632
|
+
name: "feedback",
|
|
633
|
+
up(d) {
|
|
634
|
+
d.exec(`
|
|
635
|
+
CREATE TABLE IF NOT EXISTS feedback (
|
|
636
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
637
|
+
channel_id TEXT NOT NULL,
|
|
638
|
+
thread_id TEXT,
|
|
639
|
+
message_ts TEXT,
|
|
640
|
+
user_id TEXT NOT NULL,
|
|
641
|
+
value TEXT NOT NULL CHECK(value IN ('good', 'bad')),
|
|
642
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
643
|
+
UNIQUE(channel_id, message_ts, user_id)
|
|
644
|
+
);
|
|
645
|
+
CREATE INDEX IF NOT EXISTS idx_feedback_channel_thread
|
|
646
|
+
ON feedback(channel_id, thread_id);
|
|
647
|
+
`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
];
|
|
651
|
+
let db = null;
|
|
652
|
+
function getSchemaVersion(d) {
|
|
653
|
+
return d.prepare("PRAGMA user_version").get().user_version;
|
|
654
|
+
}
|
|
655
|
+
function setSchemaVersion(d, version) {
|
|
656
|
+
d.exec(`PRAGMA user_version = ${version}`);
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Runs pending migrations inside a transaction.
|
|
660
|
+
* Safe to call on every startup; already-applied migrations are skipped.
|
|
661
|
+
*/
|
|
662
|
+
function migrate(d) {
|
|
663
|
+
const current = getSchemaVersion(d);
|
|
664
|
+
const target = MIGRATIONS.length;
|
|
665
|
+
if (current >= target) return;
|
|
666
|
+
for (let i = current; i < target; i++) {
|
|
667
|
+
const migration = MIGRATIONS[i];
|
|
668
|
+
logger.info(`migration ${i + 1}/${target}: ${migration.name}`);
|
|
669
|
+
d.exec("BEGIN");
|
|
670
|
+
try {
|
|
671
|
+
migration.up(d);
|
|
672
|
+
setSchemaVersion(d, i + 1);
|
|
673
|
+
d.exec("COMMIT");
|
|
674
|
+
} catch (err) {
|
|
675
|
+
d.exec("ROLLBACK");
|
|
676
|
+
throw new Error(`migration "${migration.name}" failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Opens (or creates) the SQLite database at the given path.
|
|
682
|
+
* Configures WAL mode, runs pending migrations.
|
|
683
|
+
*/
|
|
684
|
+
function openDatabase(dbPath) {
|
|
685
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
686
|
+
db = new DatabaseSync(dbPath);
|
|
687
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
688
|
+
db.exec("PRAGMA synchronous = NORMAL");
|
|
689
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
690
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
691
|
+
migrate(db);
|
|
692
|
+
logger.debug(`database opened at ${dbPath}`);
|
|
693
|
+
}
|
|
694
|
+
function closeDatabase() {
|
|
695
|
+
if (db) {
|
|
696
|
+
db.close();
|
|
697
|
+
db = null;
|
|
698
|
+
logger.debug("database closed");
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
function getDb() {
|
|
702
|
+
if (!db) throw new Error("Database not initialized. Call openDatabase() first.");
|
|
703
|
+
return db;
|
|
704
|
+
}
|
|
705
|
+
/** Runs WAL checkpoint and query planner optimization. Safe to call periodically. */
|
|
706
|
+
function optimizeDatabase() {
|
|
707
|
+
const d = getDb();
|
|
708
|
+
d.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
709
|
+
d.exec("PRAGMA optimize");
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Resolves a row by UID or UID prefix, like git's short SHA resolution.
|
|
713
|
+
* Exact match first (fast path), then prefix scan via LIKE.
|
|
714
|
+
*/
|
|
715
|
+
function resolveByUid(table, uidPrefix) {
|
|
716
|
+
const input = uidPrefix.toLowerCase();
|
|
717
|
+
const d = getDb();
|
|
718
|
+
const exact = d.prepare(`SELECT * FROM ${table} WHERE uid = ?`).get(input);
|
|
719
|
+
if (exact) return { row: exact };
|
|
720
|
+
const rows = d.prepare(`SELECT * FROM ${table} WHERE uid LIKE ? LIMIT 2`).all(`${input}%`);
|
|
721
|
+
if (rows.length === 0) return void 0;
|
|
722
|
+
if (rows.length > 1) return {
|
|
723
|
+
ambiguous: true,
|
|
724
|
+
count: d.prepare(`SELECT COUNT(*) as count FROM ${table} WHERE uid LIKE ?`).get(`${input}%`).count
|
|
725
|
+
};
|
|
726
|
+
return { row: rows[0] };
|
|
727
|
+
}
|
|
728
|
+
/** Wraps a function in BEGIN/COMMIT with automatic ROLLBACK on error. */
|
|
729
|
+
function transaction(fn) {
|
|
730
|
+
const d = getDb();
|
|
731
|
+
d.exec("BEGIN IMMEDIATE");
|
|
732
|
+
try {
|
|
733
|
+
const result = fn();
|
|
734
|
+
d.exec("COMMIT");
|
|
735
|
+
return result;
|
|
736
|
+
} catch (err) {
|
|
737
|
+
d.exec("ROLLBACK");
|
|
738
|
+
throw err;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
349
742
|
//#endregion
|
|
350
743
|
//#region src/browser/lock.ts
|
|
351
744
|
/** @fileoverview SQLite-based browser lock for persistent browser mode. */
|
|
@@ -390,6 +783,586 @@ function clearBrowserLock() {
|
|
|
390
783
|
getDb().prepare("DELETE FROM browser_lock WHERE id = 1").run();
|
|
391
784
|
}
|
|
392
785
|
|
|
786
|
+
//#endregion
|
|
787
|
+
//#region src/core/uid.ts
|
|
788
|
+
/** @fileoverview Prefixed short ID generation (PocketBase/Motebase pattern). */
|
|
789
|
+
const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
790
|
+
const UID_PREFIX = {
|
|
791
|
+
bird: "br_",
|
|
792
|
+
flight: "fl_",
|
|
793
|
+
session: "ss_"
|
|
794
|
+
};
|
|
795
|
+
function generateUid(prefix) {
|
|
796
|
+
const bytes = randomBytes(15);
|
|
797
|
+
let id = prefix;
|
|
798
|
+
for (let i = 0; i < 15; i++) id += ALPHABET[bytes[i] % 36];
|
|
799
|
+
return id;
|
|
800
|
+
}
|
|
801
|
+
function shortUid(uid) {
|
|
802
|
+
const i = uid.indexOf("_");
|
|
803
|
+
if (i === -1) return uid.slice(0, 10);
|
|
804
|
+
return uid.slice(0, i + 1 + 7);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
//#endregion
|
|
808
|
+
//#region src/db/sessions.ts
|
|
809
|
+
function findSession(channelId, threadId) {
|
|
810
|
+
return getDb().prepare("SELECT * FROM sessions WHERE channel_id = ? AND thread_id IS ?").get(channelId, threadId);
|
|
811
|
+
}
|
|
812
|
+
function createSession(channelId, threadId, agentId, providerSessionId) {
|
|
813
|
+
const uid = generateUid(UID_PREFIX.session);
|
|
814
|
+
return getDb().prepare(`INSERT INTO sessions (uid, channel_id, thread_id, agent_id, provider_session_id)
|
|
815
|
+
VALUES (?, ?, ?, ?, ?)
|
|
816
|
+
RETURNING *`).get(uid, channelId, threadId, agentId, providerSessionId);
|
|
817
|
+
}
|
|
818
|
+
function touchSession(uid, messageCountDelta = 1) {
|
|
819
|
+
getDb().prepare(`UPDATE sessions SET last_active = datetime('now'), message_count = message_count + ? WHERE uid = ?`).run(messageCountDelta, uid);
|
|
820
|
+
}
|
|
821
|
+
const SESSION_SORT_COLUMNS = new Set([
|
|
822
|
+
"uid",
|
|
823
|
+
"channel_id",
|
|
824
|
+
"agent_id",
|
|
825
|
+
"message_count",
|
|
826
|
+
"last_active",
|
|
827
|
+
"created_at"
|
|
828
|
+
]);
|
|
829
|
+
const SESSION_SEARCH_COLUMNS = [
|
|
830
|
+
"channel_id",
|
|
831
|
+
"thread_id",
|
|
832
|
+
"agent_id"
|
|
833
|
+
];
|
|
834
|
+
function listSessions(page = 1, perPage = DEFAULT_PER_PAGE, sort, search) {
|
|
835
|
+
return paginate("sessions", page, perPage, {
|
|
836
|
+
defaultSort: "last_active DESC",
|
|
837
|
+
sort,
|
|
838
|
+
search,
|
|
839
|
+
allowedSortColumns: SESSION_SORT_COLUMNS,
|
|
840
|
+
searchColumns: SESSION_SEARCH_COLUMNS
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
function getSession(uid) {
|
|
844
|
+
return getDb().prepare("SELECT * FROM sessions WHERE uid = ?").get(uid);
|
|
845
|
+
}
|
|
846
|
+
const MESSAGE_SORT_COLUMNS = new Set([
|
|
847
|
+
"id",
|
|
848
|
+
"created_at",
|
|
849
|
+
"direction",
|
|
850
|
+
"user_id"
|
|
851
|
+
]);
|
|
852
|
+
const MESSAGE_SEARCH_COLUMNS = ["content", "user_id"];
|
|
853
|
+
function getSessionMessages(channelId, threadId, page = 1, perPage = DEFAULT_PER_PAGE, sort, search) {
|
|
854
|
+
const pp = Math.min(Math.max(perPage, 1), MAX_PER_PAGE);
|
|
855
|
+
const p = Math.max(page, 1);
|
|
856
|
+
const offset = (p - 1) * pp;
|
|
857
|
+
const conditions = ["channel_id = ? AND thread_id IS ?"];
|
|
858
|
+
const allParams = [channelId, threadId];
|
|
859
|
+
if (search) {
|
|
860
|
+
const sc = buildSearchClause(search, MESSAGE_SEARCH_COLUMNS);
|
|
861
|
+
if (sc.sql) {
|
|
862
|
+
conditions.push(sc.sql);
|
|
863
|
+
allParams.push(...sc.params);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
const where = conditions.join(" AND ");
|
|
867
|
+
const orderBy = parseSort(sort, MESSAGE_SORT_COLUMNS, "created_at ASC, id ASC");
|
|
868
|
+
const totalItems = getDb().prepare(`SELECT COUNT(*) as count FROM messages WHERE ${where}`).get(...allParams).count;
|
|
869
|
+
const totalPages = Math.max(Math.ceil(totalItems / pp), 1);
|
|
870
|
+
return {
|
|
871
|
+
items: getDb().prepare(`SELECT * FROM messages
|
|
872
|
+
WHERE ${where}
|
|
873
|
+
ORDER BY ${orderBy}
|
|
874
|
+
LIMIT ? OFFSET ?`).all(...allParams, pp, offset),
|
|
875
|
+
page: p,
|
|
876
|
+
perPage: pp,
|
|
877
|
+
totalItems,
|
|
878
|
+
totalPages
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
function getSessionTokenStats(channelId, threadId) {
|
|
882
|
+
return getDb().prepare(`SELECT COALESCE(SUM(tokens_in), 0) as totalTokensIn,
|
|
883
|
+
COALESCE(SUM(tokens_out), 0) as totalTokensOut
|
|
884
|
+
FROM messages
|
|
885
|
+
WHERE channel_id = ? AND thread_id IS ?`).get(channelId, threadId);
|
|
886
|
+
}
|
|
887
|
+
function getSessionCount() {
|
|
888
|
+
return getDb().prepare("SELECT COUNT(*) as count FROM sessions").get().count;
|
|
889
|
+
}
|
|
890
|
+
function deleteOldSessions(retentionDays) {
|
|
891
|
+
const result = getDb().prepare(`DELETE FROM sessions WHERE last_active < datetime('now', ? || ' days')`).run(`-${retentionDays}`);
|
|
892
|
+
return Number(result.changes);
|
|
893
|
+
}
|
|
894
|
+
function updateSessionProviderId(uid, providerSessionId) {
|
|
895
|
+
getDb().prepare("UPDATE sessions SET provider_session_id = ? WHERE uid = ?").run(providerSessionId, uid);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
//#endregion
|
|
899
|
+
//#region src/db/birds.ts
|
|
900
|
+
const SYSTEM_CRON_PREFIX = "__bb_";
|
|
901
|
+
const CRON_SORT_COLUMNS = new Set([
|
|
902
|
+
"uid",
|
|
903
|
+
"name",
|
|
904
|
+
"schedule",
|
|
905
|
+
"agent_id",
|
|
906
|
+
"enabled",
|
|
907
|
+
"last_run",
|
|
908
|
+
"created_at"
|
|
909
|
+
]);
|
|
910
|
+
const CRON_SEARCH_COLUMNS = [
|
|
911
|
+
"uid",
|
|
912
|
+
"name",
|
|
913
|
+
"prompt",
|
|
914
|
+
"schedule"
|
|
915
|
+
];
|
|
916
|
+
function listCronJobs(page = 1, perPage = DEFAULT_PER_PAGE, includeSystem = false, sort, search) {
|
|
917
|
+
return paginate("cron_jobs", page, perPage, {
|
|
918
|
+
where: includeSystem ? "" : `name NOT LIKE '${SYSTEM_CRON_PREFIX}%'`,
|
|
919
|
+
defaultSort: "created_at ASC",
|
|
920
|
+
sort,
|
|
921
|
+
search,
|
|
922
|
+
allowedSortColumns: CRON_SORT_COLUMNS,
|
|
923
|
+
searchColumns: CRON_SEARCH_COLUMNS
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
function getEnabledCronJobs() {
|
|
927
|
+
return getDb().prepare("SELECT * FROM cron_jobs WHERE enabled = 1 ORDER BY created_at").all();
|
|
928
|
+
}
|
|
929
|
+
function createCronJob(name, schedule, prompt, targetChannelId, agentId, activeHoursStart, activeHoursEnd) {
|
|
930
|
+
const uid = generateUid(UID_PREFIX.bird);
|
|
931
|
+
return getDb().prepare(`INSERT INTO cron_jobs (uid, name, schedule, prompt, target_channel_id, agent_id, active_hours_start, active_hours_end)
|
|
932
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
933
|
+
RETURNING *`).get(uid, name, schedule, prompt, targetChannelId ?? null, agentId ?? "default", activeHoursStart ?? null, activeHoursEnd ?? null);
|
|
934
|
+
}
|
|
935
|
+
function updateCronJobStatus(jobUid, status, failureCount) {
|
|
936
|
+
getDb().prepare(`UPDATE cron_jobs SET last_run = datetime('now'), last_status = ?, failure_count = ? WHERE uid = ?`).run(status, failureCount, jobUid);
|
|
937
|
+
}
|
|
938
|
+
function getCronJob(jobUid) {
|
|
939
|
+
return getDb().prepare("SELECT * FROM cron_jobs WHERE uid = ?").get(jobUid);
|
|
940
|
+
}
|
|
941
|
+
function setCronJobEnabled(jobUid, enabled) {
|
|
942
|
+
const result = getDb().prepare("UPDATE cron_jobs SET enabled = ? WHERE uid = ?").run(enabled ? 1 : 0, jobUid);
|
|
943
|
+
return Number(result.changes) > 0;
|
|
944
|
+
}
|
|
945
|
+
function updateCronJob(jobUid, fields) {
|
|
946
|
+
const sets = [];
|
|
947
|
+
const params = [];
|
|
948
|
+
if (fields.name !== void 0) {
|
|
949
|
+
sets.push("name = ?");
|
|
950
|
+
params.push(fields.name);
|
|
951
|
+
}
|
|
952
|
+
if (fields.schedule !== void 0) {
|
|
953
|
+
sets.push("schedule = ?");
|
|
954
|
+
params.push(fields.schedule);
|
|
955
|
+
}
|
|
956
|
+
if (fields.prompt !== void 0) {
|
|
957
|
+
sets.push("prompt = ?");
|
|
958
|
+
params.push(fields.prompt);
|
|
959
|
+
}
|
|
960
|
+
if (fields.targetChannelId !== void 0) {
|
|
961
|
+
sets.push("target_channel_id = ?");
|
|
962
|
+
params.push(fields.targetChannelId);
|
|
963
|
+
}
|
|
964
|
+
if (fields.agentId !== void 0) {
|
|
965
|
+
sets.push("agent_id = ?");
|
|
966
|
+
params.push(fields.agentId);
|
|
967
|
+
}
|
|
968
|
+
if (fields.activeHoursStart !== void 0) {
|
|
969
|
+
sets.push("active_hours_start = ?");
|
|
970
|
+
params.push(fields.activeHoursStart);
|
|
971
|
+
}
|
|
972
|
+
if (fields.activeHoursEnd !== void 0) {
|
|
973
|
+
sets.push("active_hours_end = ?");
|
|
974
|
+
params.push(fields.activeHoursEnd);
|
|
975
|
+
}
|
|
976
|
+
if (sets.length === 0) return getCronJob(jobUid);
|
|
977
|
+
params.push(jobUid);
|
|
978
|
+
return getDb().prepare(`UPDATE cron_jobs SET ${sets.join(", ")} WHERE uid = ? RETURNING *`).get(...params);
|
|
979
|
+
}
|
|
980
|
+
function deleteCronJob(jobUid) {
|
|
981
|
+
const d = getDb();
|
|
982
|
+
d.exec("BEGIN");
|
|
983
|
+
try {
|
|
984
|
+
d.prepare("DELETE FROM cron_runs WHERE job_uid = ?").run(jobUid);
|
|
985
|
+
d.prepare("UPDATE jobs SET cron_job_uid = NULL WHERE cron_job_uid = ?").run(jobUid);
|
|
986
|
+
const result = d.prepare("DELETE FROM cron_jobs WHERE uid = ?").run(jobUid);
|
|
987
|
+
d.exec("COMMIT");
|
|
988
|
+
return Number(result.changes) > 0;
|
|
989
|
+
} catch (err) {
|
|
990
|
+
d.exec("ROLLBACK");
|
|
991
|
+
throw err;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
const FLIGHT_SORT_COLUMNS = new Set([
|
|
995
|
+
"uid",
|
|
996
|
+
"started_at",
|
|
997
|
+
"finished_at",
|
|
998
|
+
"status",
|
|
999
|
+
"bird_name"
|
|
1000
|
+
]);
|
|
1001
|
+
const FLIGHT_SORT_MAP = { bird_name: "j.name" };
|
|
1002
|
+
const FLIGHT_SEARCH_COLUMNS = [
|
|
1003
|
+
"r.uid",
|
|
1004
|
+
"r.job_uid",
|
|
1005
|
+
"j.uid",
|
|
1006
|
+
"j.name",
|
|
1007
|
+
"r.error",
|
|
1008
|
+
"r.result"
|
|
1009
|
+
];
|
|
1010
|
+
function listFlights(page = 1, perPage = DEFAULT_PER_PAGE, filters = {}, sort, search) {
|
|
1011
|
+
const pp = Math.min(Math.max(perPage, 1), MAX_PER_PAGE);
|
|
1012
|
+
const p = Math.max(page, 1);
|
|
1013
|
+
const offset = (p - 1) * pp;
|
|
1014
|
+
const conditions = [];
|
|
1015
|
+
const params = [];
|
|
1016
|
+
if (!filters.system) conditions.push(`j.name NOT LIKE '${SYSTEM_CRON_PREFIX}%'`);
|
|
1017
|
+
if (filters.birdUid != null) {
|
|
1018
|
+
conditions.push("r.job_uid = ?");
|
|
1019
|
+
params.push(filters.birdUid);
|
|
1020
|
+
}
|
|
1021
|
+
if (filters.status) {
|
|
1022
|
+
conditions.push("r.status = ?");
|
|
1023
|
+
params.push(filters.status);
|
|
1024
|
+
}
|
|
1025
|
+
if (search) {
|
|
1026
|
+
const sc = buildSearchClause(search, FLIGHT_SEARCH_COLUMNS);
|
|
1027
|
+
if (sc.sql) {
|
|
1028
|
+
conditions.push(sc.sql);
|
|
1029
|
+
params.push(...sc.params);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1033
|
+
let orderBy = parseSort(sort, FLIGHT_SORT_COLUMNS, "r.started_at DESC");
|
|
1034
|
+
for (const [key, qualified] of Object.entries(FLIGHT_SORT_MAP)) orderBy = orderBy.replaceAll(key, qualified);
|
|
1035
|
+
orderBy = orderBy.replace(/(?<![a-z.])\b(uid|started_at|finished_at|status)\b/g, "r.$1");
|
|
1036
|
+
const totalItems = getDb().prepare(`SELECT COUNT(*) as count FROM cron_runs r JOIN cron_jobs j ON j.uid = r.job_uid ${where}`).get(...params).count;
|
|
1037
|
+
const totalPages = Math.max(Math.ceil(totalItems / pp), 1);
|
|
1038
|
+
return {
|
|
1039
|
+
items: getDb().prepare(`SELECT r.uid, r.job_uid, j.uid as bird_uid, j.name as bird_name,
|
|
1040
|
+
r.started_at, r.finished_at, r.status, r.result, r.error
|
|
1041
|
+
FROM cron_runs r
|
|
1042
|
+
JOIN cron_jobs j ON j.uid = r.job_uid
|
|
1043
|
+
${where}
|
|
1044
|
+
ORDER BY ${orderBy}
|
|
1045
|
+
LIMIT ? OFFSET ?`).all(...params, pp, offset),
|
|
1046
|
+
page: p,
|
|
1047
|
+
perPage: pp,
|
|
1048
|
+
totalItems,
|
|
1049
|
+
totalPages
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
function createCronRun(jobUid) {
|
|
1053
|
+
const uid = generateUid(UID_PREFIX.flight);
|
|
1054
|
+
return getDb().prepare("INSERT INTO cron_runs (uid, job_uid) VALUES (?, ?) RETURNING *").get(uid, jobUid);
|
|
1055
|
+
}
|
|
1056
|
+
function completeCronRun(runUid, status, result, error) {
|
|
1057
|
+
getDb().prepare(`UPDATE cron_runs SET finished_at = datetime('now'), status = ?, result = ?, error = ? WHERE uid = ?`).run(status, result ?? null, error ?? null, runUid);
|
|
1058
|
+
}
|
|
1059
|
+
function getFlightStats() {
|
|
1060
|
+
const rows = getDb().prepare(`SELECT r.status, COUNT(*) as count
|
|
1061
|
+
FROM cron_runs r
|
|
1062
|
+
JOIN cron_jobs j ON j.uid = r.job_uid
|
|
1063
|
+
WHERE j.name NOT LIKE '${SYSTEM_CRON_PREFIX}%'
|
|
1064
|
+
GROUP BY r.status`).all();
|
|
1065
|
+
const stats = {
|
|
1066
|
+
running: 0,
|
|
1067
|
+
completed: 0,
|
|
1068
|
+
failed: 0,
|
|
1069
|
+
total: 0
|
|
1070
|
+
};
|
|
1071
|
+
for (const row of rows) {
|
|
1072
|
+
if (row.status === "running") stats.running = row.count;
|
|
1073
|
+
else if (row.status === "success") stats.completed = row.count;
|
|
1074
|
+
else if (row.status === "error") stats.failed = row.count;
|
|
1075
|
+
stats.total += row.count;
|
|
1076
|
+
}
|
|
1077
|
+
return stats;
|
|
1078
|
+
}
|
|
1079
|
+
function deleteOldCronRuns(retentionDays) {
|
|
1080
|
+
const result = getDb().prepare(`DELETE FROM cron_runs WHERE started_at < datetime('now', ? || ' days')`).run(`-${retentionDays}`);
|
|
1081
|
+
return Number(result.changes);
|
|
1082
|
+
}
|
|
1083
|
+
function ensureSystemCronJob(name, schedule, prompt) {
|
|
1084
|
+
if (getDb().prepare("SELECT uid FROM cron_jobs WHERE name = ?").get(name)) return;
|
|
1085
|
+
const uid = generateUid(UID_PREFIX.bird);
|
|
1086
|
+
getDb().prepare(`INSERT INTO cron_jobs (uid, name, schedule, prompt, agent_id) VALUES (?, ?, ?, ?, 'system')`).run(uid, name, schedule, prompt);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
//#endregion
|
|
1090
|
+
//#region src/db/jobs.ts
|
|
1091
|
+
function createJob(options) {
|
|
1092
|
+
const payload = options.payload != null ? JSON.stringify(options.payload) : null;
|
|
1093
|
+
const priority = options.priority ?? "normal";
|
|
1094
|
+
const maxAttempts = options.maxAttempts ?? 1;
|
|
1095
|
+
const timeout = options.timeout ?? 1800;
|
|
1096
|
+
const cronJobUid = options.cronJobUid ?? null;
|
|
1097
|
+
if (options.delaySeconds) return getDb().prepare(`INSERT INTO jobs (name, payload, priority, max_attempts, timeout, cron_job_uid, run_at)
|
|
1098
|
+
VALUES (?, ?, ?, ?, ?, ?, datetime('now', '+' || ? || ' seconds'))
|
|
1099
|
+
RETURNING *`).get(options.name, payload, priority, maxAttempts, timeout, cronJobUid, options.delaySeconds);
|
|
1100
|
+
return getDb().prepare(`INSERT INTO jobs (name, payload, priority, max_attempts, timeout, cron_job_uid)
|
|
1101
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1102
|
+
RETURNING *`).get(options.name, payload, priority, maxAttempts, timeout, cronJobUid);
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Atomically claims the next pending job for processing.
|
|
1106
|
+
* Uses IMMEDIATE transaction to prevent race conditions.
|
|
1107
|
+
* Priority order: high > normal > low, then by creation time.
|
|
1108
|
+
*/
|
|
1109
|
+
function claimNextJob() {
|
|
1110
|
+
return transaction(() => {
|
|
1111
|
+
const row = getDb().prepare(`SELECT * FROM jobs
|
|
1112
|
+
WHERE status = 'pending' AND (run_at IS NULL OR run_at <= datetime('now'))
|
|
1113
|
+
ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 WHEN 'low' THEN 2 END, created_at ASC
|
|
1114
|
+
LIMIT 1`).get();
|
|
1115
|
+
if (!row) return void 0;
|
|
1116
|
+
getDb().prepare(`UPDATE jobs SET status = 'running', started_at = datetime('now'), attempts = attempts + 1
|
|
1117
|
+
WHERE id = ?`).run(row.id);
|
|
1118
|
+
return {
|
|
1119
|
+
...row,
|
|
1120
|
+
status: "running",
|
|
1121
|
+
attempts: row.attempts + 1
|
|
1122
|
+
};
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
function getJobStatus(jobId) {
|
|
1126
|
+
return getDb().prepare("SELECT status FROM jobs WHERE id = ?").get(jobId)?.status;
|
|
1127
|
+
}
|
|
1128
|
+
function completeJob(jobId, result) {
|
|
1129
|
+
getDb().prepare(`UPDATE jobs SET status = 'completed', completed_at = datetime('now'), result = ?
|
|
1130
|
+
WHERE id = ?`).run(result ?? null, jobId);
|
|
1131
|
+
}
|
|
1132
|
+
function failJob(jobId, error) {
|
|
1133
|
+
const job = getDb().prepare("SELECT attempts, max_attempts FROM jobs WHERE id = ?").get(jobId);
|
|
1134
|
+
if (!job) return;
|
|
1135
|
+
if (job.attempts < job.max_attempts) {
|
|
1136
|
+
const delaySeconds = job.attempts * job.attempts;
|
|
1137
|
+
getDb().prepare(`UPDATE jobs SET status = 'pending', error = ?,
|
|
1138
|
+
run_at = datetime('now', '+' || ? || ' seconds')
|
|
1139
|
+
WHERE id = ?`).run(error, delaySeconds, jobId);
|
|
1140
|
+
} else getDb().prepare(`UPDATE jobs SET status = 'failed', completed_at = datetime('now'), error = ?
|
|
1141
|
+
WHERE id = ?`).run(error, jobId);
|
|
1142
|
+
}
|
|
1143
|
+
/** Marks running jobs past their timeout as failed and cascades to linked cron_runs/cron_jobs. */
|
|
1144
|
+
function failStaleJobs() {
|
|
1145
|
+
const d = getDb();
|
|
1146
|
+
const staleRows = d.prepare(`SELECT id, cron_job_uid FROM jobs
|
|
1147
|
+
WHERE status = 'running'
|
|
1148
|
+
AND started_at < datetime('now', '-' || timeout || ' seconds')`).all();
|
|
1149
|
+
if (staleRows.length === 0) return 0;
|
|
1150
|
+
const updateJob = d.prepare(`UPDATE jobs SET status = 'failed', error = 'timeout', completed_at = datetime('now')
|
|
1151
|
+
WHERE id = ?`);
|
|
1152
|
+
const updateRun = d.prepare(`UPDATE cron_runs SET status = 'error', error = 'timeout', finished_at = datetime('now')
|
|
1153
|
+
WHERE job_uid = ? AND status = 'running'`);
|
|
1154
|
+
const updateBird = d.prepare(`UPDATE cron_jobs SET last_status = 'failed', failure_count = failure_count + 1
|
|
1155
|
+
WHERE uid = ?`);
|
|
1156
|
+
for (const row of staleRows) {
|
|
1157
|
+
updateJob.run(row.id);
|
|
1158
|
+
if (row.cron_job_uid != null) {
|
|
1159
|
+
updateRun.run(row.cron_job_uid);
|
|
1160
|
+
updateBird.run(row.cron_job_uid);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
return staleRows.length;
|
|
1164
|
+
}
|
|
1165
|
+
function deleteOldJobs(retentionDays) {
|
|
1166
|
+
const stmt = getDb().prepare(`DELETE FROM jobs WHERE status IN ('completed', 'failed')
|
|
1167
|
+
AND completed_at < datetime('now', ? || ' days')`);
|
|
1168
|
+
return Number(stmt.run(`-${retentionDays}`).changes);
|
|
1169
|
+
}
|
|
1170
|
+
const JOB_SORT_COLUMNS = new Set([
|
|
1171
|
+
"id",
|
|
1172
|
+
"name",
|
|
1173
|
+
"status",
|
|
1174
|
+
"priority",
|
|
1175
|
+
"created_at",
|
|
1176
|
+
"started_at"
|
|
1177
|
+
]);
|
|
1178
|
+
const JOB_SEARCH_COLUMNS = ["name", "error"];
|
|
1179
|
+
function listJobs(page = 1, perPage = DEFAULT_PER_PAGE, filters = {}, sort, search) {
|
|
1180
|
+
const conditions = [];
|
|
1181
|
+
const params = [];
|
|
1182
|
+
if (filters.status) {
|
|
1183
|
+
conditions.push("status = ?");
|
|
1184
|
+
params.push(filters.status);
|
|
1185
|
+
}
|
|
1186
|
+
if (filters.cronJobUid != null) {
|
|
1187
|
+
conditions.push("cron_job_uid = ?");
|
|
1188
|
+
params.push(filters.cronJobUid);
|
|
1189
|
+
}
|
|
1190
|
+
if (filters.name) {
|
|
1191
|
+
conditions.push("name LIKE ?");
|
|
1192
|
+
params.push(`%${filters.name}%`);
|
|
1193
|
+
}
|
|
1194
|
+
return paginate("jobs", page, perPage, {
|
|
1195
|
+
where: conditions.join(" AND "),
|
|
1196
|
+
params,
|
|
1197
|
+
defaultSort: "created_at DESC",
|
|
1198
|
+
sort,
|
|
1199
|
+
search,
|
|
1200
|
+
allowedSortColumns: JOB_SORT_COLUMNS,
|
|
1201
|
+
searchColumns: JOB_SEARCH_COLUMNS
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
function getJobStats() {
|
|
1205
|
+
const rows = getDb().prepare("SELECT status, COUNT(*) as count FROM jobs GROUP BY status").all();
|
|
1206
|
+
const stats = {
|
|
1207
|
+
pending: 0,
|
|
1208
|
+
running: 0,
|
|
1209
|
+
completed: 0,
|
|
1210
|
+
failed: 0,
|
|
1211
|
+
total: 0
|
|
1212
|
+
};
|
|
1213
|
+
for (const row of rows) {
|
|
1214
|
+
if (row.status in stats) stats[row.status] = row.count;
|
|
1215
|
+
stats.total += row.count;
|
|
1216
|
+
}
|
|
1217
|
+
return stats;
|
|
1218
|
+
}
|
|
1219
|
+
function retryJob(jobId) {
|
|
1220
|
+
const result = getDb().prepare(`UPDATE jobs SET status = 'pending', attempts = 0, error = NULL, result = NULL,
|
|
1221
|
+
run_at = NULL, started_at = NULL, completed_at = NULL
|
|
1222
|
+
WHERE id = ? AND status = 'failed'`).run(jobId);
|
|
1223
|
+
return Number(result.changes) > 0;
|
|
1224
|
+
}
|
|
1225
|
+
function retryAllFailedJobs() {
|
|
1226
|
+
const result = getDb().prepare(`UPDATE jobs SET status = 'pending', attempts = 0, error = NULL, result = NULL,
|
|
1227
|
+
run_at = NULL, started_at = NULL, completed_at = NULL
|
|
1228
|
+
WHERE status = 'failed'`).run();
|
|
1229
|
+
return Number(result.changes);
|
|
1230
|
+
}
|
|
1231
|
+
function deleteJob(jobId) {
|
|
1232
|
+
const result = getDb().prepare("DELETE FROM jobs WHERE id = ?").run(jobId);
|
|
1233
|
+
return Number(result.changes) > 0;
|
|
1234
|
+
}
|
|
1235
|
+
function clearJobs(status) {
|
|
1236
|
+
const result = getDb().prepare("DELETE FROM jobs WHERE status = ?").run(status);
|
|
1237
|
+
return Number(result.changes);
|
|
1238
|
+
}
|
|
1239
|
+
/** Returns true if the given cron job has a pending or running job in the queue. */
|
|
1240
|
+
function hasPendingCronJob(cronJobUid) {
|
|
1241
|
+
return getDb().prepare(`SELECT 1 FROM jobs WHERE cron_job_uid = ? AND status IN ('pending', 'running') LIMIT 1`).get(cronJobUid) != null;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
//#endregion
|
|
1245
|
+
//#region src/db/messages.ts
|
|
1246
|
+
/** @fileoverview Message logging: message audit trail and token tracking. */
|
|
1247
|
+
function logMessage(channelId, threadId, userId, direction, content, tokensIn, tokensOut) {
|
|
1248
|
+
getDb().prepare(`INSERT INTO messages (channel_id, thread_id, user_id, direction, content, tokens_in, tokens_out)
|
|
1249
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(channelId, threadId ?? null, userId, direction, content ?? null, tokensIn ?? null, tokensOut ?? null);
|
|
1250
|
+
}
|
|
1251
|
+
function getLastInboundMessage(channelId, threadId) {
|
|
1252
|
+
const row = getDb().prepare(`SELECT content, created_at FROM messages
|
|
1253
|
+
WHERE channel_id = ? AND thread_id IS ? AND direction = 'in' AND content IS NOT NULL
|
|
1254
|
+
ORDER BY created_at DESC LIMIT 1`).get(channelId, threadId);
|
|
1255
|
+
if (!row) return void 0;
|
|
1256
|
+
return {
|
|
1257
|
+
content: row.content,
|
|
1258
|
+
timestamp: row.created_at
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
function deleteOldMessages(retentionDays) {
|
|
1262
|
+
const result = getDb().prepare(`DELETE FROM messages WHERE created_at < datetime('now', ? || ' days')`).run(`-${retentionDays}`);
|
|
1263
|
+
return Number(result.changes);
|
|
1264
|
+
}
|
|
1265
|
+
function getMessageStats() {
|
|
1266
|
+
return getDb().prepare(`SELECT
|
|
1267
|
+
COUNT(*) as totalMessages,
|
|
1268
|
+
COALESCE(SUM(tokens_in), 0) as totalTokensIn,
|
|
1269
|
+
COALESCE(SUM(tokens_out), 0) as totalTokensOut
|
|
1270
|
+
FROM messages`).get();
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
//#endregion
|
|
1274
|
+
//#region src/db/logs.ts
|
|
1275
|
+
function insertLog(level, source, message, channelId) {
|
|
1276
|
+
getDb().prepare(`INSERT INTO logs (level, source, message, channel_id) VALUES (?, ?, ?, ?)`).run(level, source, message, channelId ?? null);
|
|
1277
|
+
}
|
|
1278
|
+
const LOG_SORT_COLUMNS = new Set([
|
|
1279
|
+
"id",
|
|
1280
|
+
"level",
|
|
1281
|
+
"source",
|
|
1282
|
+
"created_at"
|
|
1283
|
+
]);
|
|
1284
|
+
const LOG_SEARCH_COLUMNS = ["message", "source"];
|
|
1285
|
+
function getRecentLogs(page = 1, perPage = DEFAULT_PER_PAGE, level, source, sort, search) {
|
|
1286
|
+
const conditions = [];
|
|
1287
|
+
const params = [];
|
|
1288
|
+
if (level) {
|
|
1289
|
+
conditions.push("level = ?");
|
|
1290
|
+
params.push(level);
|
|
1291
|
+
}
|
|
1292
|
+
if (source) {
|
|
1293
|
+
conditions.push("source = ?");
|
|
1294
|
+
params.push(source);
|
|
1295
|
+
}
|
|
1296
|
+
return paginate("logs", page, perPage, {
|
|
1297
|
+
where: conditions.join(" AND "),
|
|
1298
|
+
params,
|
|
1299
|
+
defaultSort: "created_at DESC",
|
|
1300
|
+
sort,
|
|
1301
|
+
search,
|
|
1302
|
+
allowedSortColumns: LOG_SORT_COLUMNS,
|
|
1303
|
+
searchColumns: LOG_SEARCH_COLUMNS
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
function deleteOldLogs(retentionDays) {
|
|
1307
|
+
const stmt = getDb().prepare(`DELETE FROM logs WHERE created_at < datetime('now', ? || ' days')`);
|
|
1308
|
+
return Number(stmt.run(`-${retentionDays}`).changes);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
//#endregion
|
|
1312
|
+
//#region src/db/auth.ts
|
|
1313
|
+
/** @fileoverview User and settings persistence for the auth system. */
|
|
1314
|
+
function getUserCount() {
|
|
1315
|
+
return getDb().prepare("SELECT COUNT(*) as count FROM users").get().count;
|
|
1316
|
+
}
|
|
1317
|
+
function getUserByEmail(email) {
|
|
1318
|
+
return getDb().prepare("SELECT * FROM users WHERE email = ?").get(email);
|
|
1319
|
+
}
|
|
1320
|
+
function getUserById(id) {
|
|
1321
|
+
return getDb().prepare("SELECT * FROM users WHERE id = ?").get(id);
|
|
1322
|
+
}
|
|
1323
|
+
function createUser(email, passwordHash, tokenKey) {
|
|
1324
|
+
getDb().prepare("INSERT INTO users (email, password_hash, token_key) VALUES (?, ?, ?)").run(email, passwordHash, tokenKey);
|
|
1325
|
+
return getUserByEmail(email);
|
|
1326
|
+
}
|
|
1327
|
+
function getSetting(key) {
|
|
1328
|
+
return getDb().prepare("SELECT value FROM settings WHERE key = ?").get(key)?.value;
|
|
1329
|
+
}
|
|
1330
|
+
function setSetting(key, value) {
|
|
1331
|
+
getDb().prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(key, value);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
//#endregion
|
|
1335
|
+
//#region src/db/feedback.ts
|
|
1336
|
+
/** @fileoverview Response feedback persistence (thumbs up/down). */
|
|
1337
|
+
function insertFeedback(channelId, threadId, messageTs, userId, value) {
|
|
1338
|
+
getDb().prepare(`INSERT OR REPLACE INTO feedback (channel_id, thread_id, message_ts, user_id, value)
|
|
1339
|
+
VALUES (?, ?, ?, ?, ?)`).run(channelId, threadId ?? null, messageTs ?? null, userId, value);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
//#endregion
|
|
1343
|
+
//#region src/db/path.ts
|
|
1344
|
+
/** @fileoverview Database path resolution: CLI flag, env var, or default. */
|
|
1345
|
+
const DEFAULT_DB_PATH = resolve(".browserbird", "browserbird.db");
|
|
1346
|
+
/**
|
|
1347
|
+
* Resolves the database file path.
|
|
1348
|
+
* Priority: explicit value > BROWSERBIRD_DB env var > default.
|
|
1349
|
+
*/
|
|
1350
|
+
function resolveDbPath(explicit) {
|
|
1351
|
+
if (explicit) return resolve(explicit);
|
|
1352
|
+
const envValue = process.env["BROWSERBIRD_DB"];
|
|
1353
|
+
if (envValue) return resolve(envValue);
|
|
1354
|
+
return DEFAULT_DB_PATH;
|
|
1355
|
+
}
|
|
1356
|
+
/**
|
|
1357
|
+
* Extracts --db value from a raw argv array, then resolves.
|
|
1358
|
+
* Used by CLI command handlers that receive argv directly.
|
|
1359
|
+
*/
|
|
1360
|
+
function resolveDbPathFromArgv(argv) {
|
|
1361
|
+
const idx = argv.indexOf("--db");
|
|
1362
|
+
if (idx !== -1 && idx + 1 < argv.length) return resolveDbPath(argv[idx + 1]);
|
|
1363
|
+
return resolveDbPath();
|
|
1364
|
+
}
|
|
1365
|
+
|
|
393
1366
|
//#endregion
|
|
394
1367
|
//#region src/server/auth.ts
|
|
395
1368
|
/** @fileoverview Password hashing, token signing, and verification using node:crypto. */
|
|
@@ -2141,11 +3114,7 @@ function buildCommand(options) {
|
|
|
2141
3114
|
env
|
|
2142
3115
|
};
|
|
2143
3116
|
}
|
|
2144
|
-
/**
|
|
2145
|
-
* Parses a single line of stream-json output into zero or more StreamEvents.
|
|
2146
|
-
* Only extracts text, images, completion, and error events. Tool use/result
|
|
2147
|
-
* events are internal to the agent and not surfaced to the channel layer.
|
|
2148
|
-
*/
|
|
3117
|
+
/** Parses a single line of stream-json output into zero or more StreamEvents. */
|
|
2149
3118
|
function parseStreamLine(line) {
|
|
2150
3119
|
const trimmed = line.trim();
|
|
2151
3120
|
if (!trimmed || !trimmed.startsWith("{")) return [];
|
|
@@ -2166,7 +3135,7 @@ function parseStreamLine(line) {
|
|
|
2166
3135
|
}];
|
|
2167
3136
|
return [];
|
|
2168
3137
|
case "assistant": return parseAssistantContent(parsed);
|
|
2169
|
-
case "user": return
|
|
3138
|
+
case "user": return parseUserContent(parsed);
|
|
2170
3139
|
case "result": {
|
|
2171
3140
|
const usage = parsed["usage"];
|
|
2172
3141
|
return [{
|
|
@@ -2216,23 +3185,36 @@ function parseAssistantContent(parsed) {
|
|
|
2216
3185
|
type: "text_delta",
|
|
2217
3186
|
delta: b["text"]
|
|
2218
3187
|
});
|
|
2219
|
-
else if (b["type"] === "tool_use" && typeof b["name"] === "string")
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
3188
|
+
else if (b["type"] === "tool_use" && typeof b["name"] === "string") {
|
|
3189
|
+
const input = b["input"];
|
|
3190
|
+
events.push({
|
|
3191
|
+
type: "tool_use",
|
|
3192
|
+
toolName: b["name"],
|
|
3193
|
+
toolCallId: typeof b["id"] === "string" ? b["id"] : void 0,
|
|
3194
|
+
details: extractToolDetails(b["name"], input)
|
|
3195
|
+
});
|
|
3196
|
+
}
|
|
2223
3197
|
}
|
|
2224
3198
|
return events;
|
|
2225
3199
|
}
|
|
2226
|
-
function
|
|
3200
|
+
function parseUserContent(parsed) {
|
|
2227
3201
|
const msg = parsed["message"];
|
|
2228
3202
|
if (!msg || typeof msg !== "object") return [];
|
|
2229
3203
|
const content = msg["content"];
|
|
2230
3204
|
if (!Array.isArray(content)) return [];
|
|
3205
|
+
const events = [];
|
|
2231
3206
|
const images = [];
|
|
2232
3207
|
for (const block of content) {
|
|
2233
3208
|
if (!block || typeof block !== "object") continue;
|
|
2234
3209
|
const b = block;
|
|
2235
3210
|
if (b["type"] !== "tool_result") continue;
|
|
3211
|
+
const toolUseId = b["tool_use_id"];
|
|
3212
|
+
if (typeof toolUseId === "string") events.push({
|
|
3213
|
+
type: "tool_result",
|
|
3214
|
+
toolCallId: toolUseId,
|
|
3215
|
+
isError: b["is_error"] === true,
|
|
3216
|
+
output: extractToolOutput(b["content"])
|
|
3217
|
+
});
|
|
2236
3218
|
const inner = b["content"];
|
|
2237
3219
|
if (!Array.isArray(inner)) continue;
|
|
2238
3220
|
for (const item of inner) {
|
|
@@ -2247,11 +3229,45 @@ function extractImages(parsed) {
|
|
|
2247
3229
|
});
|
|
2248
3230
|
}
|
|
2249
3231
|
}
|
|
2250
|
-
if (images.length > 0)
|
|
3232
|
+
if (images.length > 0) events.push({
|
|
2251
3233
|
type: "tool_images",
|
|
2252
3234
|
images
|
|
2253
|
-
}
|
|
2254
|
-
return
|
|
3235
|
+
});
|
|
3236
|
+
return events;
|
|
3237
|
+
}
|
|
3238
|
+
const MAX_DETAIL_LENGTH = 120;
|
|
3239
|
+
function truncate$1(text, max) {
|
|
3240
|
+
return text.length > max ? text.slice(0, max - 3) + "..." : text;
|
|
3241
|
+
}
|
|
3242
|
+
function extractToolDetails(toolName, input) {
|
|
3243
|
+
if (!input) return void 0;
|
|
3244
|
+
if (toolName === "Bash") {
|
|
3245
|
+
const desc = input["description"];
|
|
3246
|
+
const cmd = input["command"];
|
|
3247
|
+
return truncate$1(desc || cmd || "", MAX_DETAIL_LENGTH) || void 0;
|
|
3248
|
+
}
|
|
3249
|
+
if (toolName === "Read" || toolName === "Edit" || toolName === "Write") return input["file_path"] || void 0;
|
|
3250
|
+
if (toolName === "Grep") {
|
|
3251
|
+
const pattern = input["pattern"];
|
|
3252
|
+
const path = input["path"];
|
|
3253
|
+
if (!pattern) return void 0;
|
|
3254
|
+
return path ? `${pattern} in ${path}` : pattern;
|
|
3255
|
+
}
|
|
3256
|
+
if (toolName === "Glob") return input["pattern"] || void 0;
|
|
3257
|
+
if (toolName === "WebSearch") return input["query"] || void 0;
|
|
3258
|
+
if (toolName === "WebFetch") return input["url"] || void 0;
|
|
3259
|
+
if (toolName.startsWith("mcp__playwright__")) {
|
|
3260
|
+
const url = input["url"];
|
|
3261
|
+
if (url) return url;
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
function extractToolOutput(content) {
|
|
3265
|
+
if (typeof content === "string") {
|
|
3266
|
+
const lines = content.split("\n").filter((l) => l.trim());
|
|
3267
|
+
if (lines.length === 0) return void 0;
|
|
3268
|
+
if (lines.length === 1) return truncate$1(lines[0], MAX_DETAIL_LENGTH);
|
|
3269
|
+
return `${lines.length} lines of output`;
|
|
3270
|
+
}
|
|
2255
3271
|
}
|
|
2256
3272
|
|
|
2257
3273
|
//#endregion
|
|
@@ -2456,10 +3472,11 @@ function header(text) {
|
|
|
2456
3472
|
text: plain(text)
|
|
2457
3473
|
};
|
|
2458
3474
|
}
|
|
2459
|
-
function section(text) {
|
|
3475
|
+
function section(text, opts) {
|
|
2460
3476
|
return {
|
|
2461
3477
|
type: "section",
|
|
2462
|
-
text: mrkdwn(text)
|
|
3478
|
+
text: mrkdwn(text),
|
|
3479
|
+
...opts?.expand ? { expand: true } : {}
|
|
2463
3480
|
};
|
|
2464
3481
|
}
|
|
2465
3482
|
function fields(...pairs) {
|
|
@@ -2477,6 +3494,23 @@ function context(text) {
|
|
|
2477
3494
|
elements: [mrkdwn(text)]
|
|
2478
3495
|
};
|
|
2479
3496
|
}
|
|
3497
|
+
function feedbackButtons() {
|
|
3498
|
+
return {
|
|
3499
|
+
type: "context_actions",
|
|
3500
|
+
elements: [{
|
|
3501
|
+
type: "feedback_buttons",
|
|
3502
|
+
action_id: "response_feedback",
|
|
3503
|
+
positive_button: {
|
|
3504
|
+
text: plain("Good"),
|
|
3505
|
+
value: "good"
|
|
3506
|
+
},
|
|
3507
|
+
negative_button: {
|
|
3508
|
+
text: plain("Bad"),
|
|
3509
|
+
value: "bad"
|
|
3510
|
+
}
|
|
3511
|
+
}]
|
|
3512
|
+
};
|
|
3513
|
+
}
|
|
2480
3514
|
const SUBTYPE_LABELS = {
|
|
2481
3515
|
error_max_turns: "Warning: Hit turn limit",
|
|
2482
3516
|
error_max_budget_usd: "Warning: Hit budget limit",
|
|
@@ -2498,7 +3532,12 @@ function completionFooterBlocks(completion, hasError, birdName, userId) {
|
|
|
2498
3532
|
parts.push(formatDuration(completion.durationMs));
|
|
2499
3533
|
parts.push(`${completion.numTurns} turn${completion.numTurns === 1 ? "" : "s"}`);
|
|
2500
3534
|
if (birdName) parts.push(birdName);
|
|
2501
|
-
return [
|
|
3535
|
+
return [
|
|
3536
|
+
divider(),
|
|
3537
|
+
feedbackButtons(),
|
|
3538
|
+
context(parts.join(" | ")),
|
|
3539
|
+
context("BrowserBird can hallucinate and may be inaccurate.")
|
|
3540
|
+
];
|
|
2502
3541
|
}
|
|
2503
3542
|
/**
|
|
2504
3543
|
* Standalone completion card for cron/bird results posted to a channel
|
|
@@ -2509,7 +3548,7 @@ function sessionCompleteBlocks(completion, summary, birdName, userId) {
|
|
|
2509
3548
|
const blocks = [header(completion.subtype === "success" ? "Session Complete" : "Session Ended"), fields(["Status", statusText], ["Duration", formatDuration(completion.durationMs)], ["Turns", String(completion.numTurns)])];
|
|
2510
3549
|
if (summary) {
|
|
2511
3550
|
const trimmed = summary.length > 2800 ? summary.slice(0, 2800) + "..." : summary;
|
|
2512
|
-
blocks.push(section(`*Summary:*\n${trimmed}
|
|
3551
|
+
blocks.push(section(`*Summary:*\n${trimmed}`, { expand: true }));
|
|
2513
3552
|
}
|
|
2514
3553
|
const contextParts = [];
|
|
2515
3554
|
if (birdName) contextParts.push(`Bird: *${birdName}*`);
|
|
@@ -2700,6 +3739,31 @@ function statusBlocks(opts) {
|
|
|
2700
3739
|
if (opts.runningBirds && opts.runningBirds.length > 0) result.push(section(`*In flight:* ${opts.runningBirds.join(", ")}`));
|
|
2701
3740
|
return result;
|
|
2702
3741
|
}
|
|
3742
|
+
function homeTabView(opts) {
|
|
3743
|
+
const blocks = [
|
|
3744
|
+
header(opts.agentName),
|
|
3745
|
+
section(opts.description),
|
|
3746
|
+
divider(),
|
|
3747
|
+
header("Scheduled Birds")
|
|
3748
|
+
];
|
|
3749
|
+
if (opts.birds.length === 0) blocks.push(section("No birds configured. Use `/bird create` to get started."));
|
|
3750
|
+
else {
|
|
3751
|
+
const lines = opts.birds.map((b) => {
|
|
3752
|
+
return `[${b.enabled ? "on" : "off"}] *${b.name}* \`${b.schedule}\``;
|
|
3753
|
+
});
|
|
3754
|
+
blocks.push(section(lines.join("\n")));
|
|
3755
|
+
}
|
|
3756
|
+
blocks.push(divider(), header("Quick Commands"), section([
|
|
3757
|
+
"`/bird list` -- show all birds",
|
|
3758
|
+
"`/bird create` -- create a new bird",
|
|
3759
|
+
"`/bird fly <name>` -- trigger a bird now",
|
|
3760
|
+
"`/bird status` -- check system status"
|
|
3761
|
+
].join("\n")), divider(), context(`${opts.activeSessions}/${opts.maxConcurrent} active sessions`));
|
|
3762
|
+
return {
|
|
3763
|
+
type: "home",
|
|
3764
|
+
blocks
|
|
3765
|
+
};
|
|
3766
|
+
}
|
|
2703
3767
|
function truncate(text, maxLength) {
|
|
2704
3768
|
if (text.length <= maxLength) return text;
|
|
2705
3769
|
return text.slice(0, maxLength) + "...";
|
|
@@ -2793,7 +3857,11 @@ function startScheduler(getConfig, signal, deps) {
|
|
|
2793
3857
|
return "completed (no output)";
|
|
2794
3858
|
}
|
|
2795
3859
|
if (payload.channelId && deps?.postToSlack) {
|
|
2796
|
-
await deps.postToSlack(payload.channelId, result);
|
|
3860
|
+
const ts = await deps.postToSlack(payload.channelId, result);
|
|
3861
|
+
if (ts && deps.setThreadTitle) {
|
|
3862
|
+
const title = `${agent.name}: ${payload.prompt.slice(0, 60)}`;
|
|
3863
|
+
deps.setThreadTitle(payload.channelId, ts, title).catch(() => {});
|
|
3864
|
+
}
|
|
2797
3865
|
if (completion) {
|
|
2798
3866
|
const blocks = sessionCompleteBlocks(completion, void 0, agent.name);
|
|
2799
3867
|
const fallback = `Bird ${agent.name} completed: ${completion.numTurns} turns`;
|
|
@@ -2950,6 +4018,23 @@ function toolStatusText(toolName) {
|
|
|
2950
4018
|
if (toolName === "Edit" || toolName === "Write") return "is writing code...";
|
|
2951
4019
|
return "is working...";
|
|
2952
4020
|
}
|
|
4021
|
+
function toolTaskTitle(toolName) {
|
|
4022
|
+
if (toolName.startsWith(BROWSER_TOOL_PREFIX)) return `Browser: ${toolName.slice(17)}`;
|
|
4023
|
+
if (toolName.startsWith("mcp__")) {
|
|
4024
|
+
const parts = toolName.slice(5).split("__");
|
|
4025
|
+
return `${parts[0] ?? "mcp"}: ${parts.slice(1).join("__") || "call"}`;
|
|
4026
|
+
}
|
|
4027
|
+
switch (toolName) {
|
|
4028
|
+
case "Bash": return "Running command";
|
|
4029
|
+
case "Read": return "Reading file";
|
|
4030
|
+
case "Grep": return "Searching code";
|
|
4031
|
+
case "Glob": return "Finding files";
|
|
4032
|
+
case "Edit": return "Editing file";
|
|
4033
|
+
case "Write": return "Writing file";
|
|
4034
|
+
case "Agent": return "Running sub-agent";
|
|
4035
|
+
default: return toolName;
|
|
4036
|
+
}
|
|
4037
|
+
}
|
|
2953
4038
|
function createHandler(client, getConfig, signal, getTeamId, getChannelNameToId) {
|
|
2954
4039
|
const locks = /* @__PURE__ */ new Map();
|
|
2955
4040
|
let activeSpawns = 0;
|
|
@@ -2984,6 +4069,8 @@ function createHandler(client, getConfig, signal, getTeamId, getChannelNameToId)
|
|
|
2984
4069
|
let hasError = false;
|
|
2985
4070
|
let timedOut = false;
|
|
2986
4071
|
let timedOutMs = 0;
|
|
4072
|
+
const activeTasks = /* @__PURE__ */ new Map();
|
|
4073
|
+
let toolCount = 0;
|
|
2987
4074
|
function isStreamExpired(err) {
|
|
2988
4075
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2989
4076
|
return msg.includes("not_in_streaming_state") || msg.includes("streaming");
|
|
@@ -3033,7 +4120,40 @@ function createHandler(client, getConfig, signal, getTeamId, getChannelNameToId)
|
|
|
3033
4120
|
meta.onToolUse?.(event.toolName);
|
|
3034
4121
|
lastStatus = toolStatusText(event.toolName);
|
|
3035
4122
|
client.setStatus?.(channelId, threadTs, lastStatus).catch(() => {});
|
|
4123
|
+
if (event.toolCallId !== void 0) {
|
|
4124
|
+
toolCount++;
|
|
4125
|
+
activeTasks.set(event.toolCallId, event.toolName);
|
|
4126
|
+
const title = toolTaskTitle(event.toolName);
|
|
4127
|
+
const chunks = [];
|
|
4128
|
+
if (toolCount === 1) chunks.push({
|
|
4129
|
+
type: "plan_update",
|
|
4130
|
+
title: "Working on it"
|
|
4131
|
+
});
|
|
4132
|
+
chunks.push({
|
|
4133
|
+
type: "task_update",
|
|
4134
|
+
id: event.toolCallId,
|
|
4135
|
+
title,
|
|
4136
|
+
status: "in_progress",
|
|
4137
|
+
...event.details ? { details: event.details } : {}
|
|
4138
|
+
});
|
|
4139
|
+
await safeAppend({ chunks });
|
|
4140
|
+
}
|
|
4141
|
+
break;
|
|
4142
|
+
case "tool_result": {
|
|
4143
|
+
const toolName = activeTasks.get(event.toolCallId);
|
|
4144
|
+
if (toolName) {
|
|
4145
|
+
activeTasks.delete(event.toolCallId);
|
|
4146
|
+
const title = toolTaskTitle(toolName);
|
|
4147
|
+
await safeAppend({ chunks: [{
|
|
4148
|
+
type: "task_update",
|
|
4149
|
+
id: event.toolCallId,
|
|
4150
|
+
title,
|
|
4151
|
+
status: event.isError ? "error" : "complete",
|
|
4152
|
+
...event.output ? { output: event.output } : {}
|
|
4153
|
+
}] });
|
|
4154
|
+
}
|
|
3036
4155
|
break;
|
|
4156
|
+
}
|
|
3037
4157
|
case "completion":
|
|
3038
4158
|
completion = event;
|
|
3039
4159
|
logger.info(`completion [${event.subtype}]: ${event.tokensIn}in/${event.tokensOut}out, $${event.costUsd.toFixed(4)}, ${event.numTurns} turns`);
|
|
@@ -3059,6 +4179,21 @@ function createHandler(client, getConfig, signal, getTeamId, getChannelNameToId)
|
|
|
3059
4179
|
}
|
|
3060
4180
|
clearInterval(statusTimer);
|
|
3061
4181
|
client.setStatus?.(channelId, threadTs, "").catch(() => {});
|
|
4182
|
+
if (activeTasks.size > 0) {
|
|
4183
|
+
const staleChunks = [];
|
|
4184
|
+
for (const [id, toolName] of activeTasks) staleChunks.push({
|
|
4185
|
+
type: "task_update",
|
|
4186
|
+
id,
|
|
4187
|
+
title: toolTaskTitle(toolName),
|
|
4188
|
+
status: "error"
|
|
4189
|
+
});
|
|
4190
|
+
await safeAppend({ chunks: staleChunks });
|
|
4191
|
+
activeTasks.clear();
|
|
4192
|
+
}
|
|
4193
|
+
if (toolCount > 0) await safeAppend({ chunks: [{
|
|
4194
|
+
type: "plan_update",
|
|
4195
|
+
title: hasError ? "Finished with errors" : `Completed (${toolCount} ${toolCount === 1 ? "step" : "steps"})`
|
|
4196
|
+
}] });
|
|
3062
4197
|
if (timedOut) {
|
|
3063
4198
|
await safeStop({});
|
|
3064
4199
|
const blocks = sessionTimeoutBlocks(timedOutMs, { sessionUid });
|
|
@@ -3428,7 +4563,7 @@ var SlackChannelClient = class {
|
|
|
3428
4563
|
thread_ts: opts.threadTs,
|
|
3429
4564
|
recipient_team_id: opts.teamId,
|
|
3430
4565
|
recipient_user_id: opts.userId,
|
|
3431
|
-
task_display_mode: "
|
|
4566
|
+
task_display_mode: "plan",
|
|
3432
4567
|
buffer_size: 128
|
|
3433
4568
|
});
|
|
3434
4569
|
}
|
|
@@ -3446,11 +4581,12 @@ var SlackChannelClient = class {
|
|
|
3446
4581
|
title
|
|
3447
4582
|
});
|
|
3448
4583
|
}
|
|
3449
|
-
async setSuggestedPrompts(channelId, threadTs, prompts) {
|
|
4584
|
+
async setSuggestedPrompts(channelId, threadTs, prompts, title) {
|
|
3450
4585
|
await this.web.assistant.threads.setSuggestedPrompts({
|
|
3451
4586
|
channel_id: channelId,
|
|
3452
4587
|
thread_ts: threadTs,
|
|
3453
|
-
prompts
|
|
4588
|
+
prompts,
|
|
4589
|
+
...title ? { title } : {}
|
|
3454
4590
|
});
|
|
3455
4591
|
}
|
|
3456
4592
|
};
|
|
@@ -3572,6 +4708,20 @@ function createSlackChannel(getConfig, signal) {
|
|
|
3572
4708
|
}
|
|
3573
4709
|
coalescer.push(channelId, threadTs, userId, text, messageTs);
|
|
3574
4710
|
});
|
|
4711
|
+
const DEFAULT_PROMPTS = [
|
|
4712
|
+
{
|
|
4713
|
+
title: "Browse a website",
|
|
4714
|
+
message: "Browse https://example.com and summarize it"
|
|
4715
|
+
},
|
|
4716
|
+
{
|
|
4717
|
+
title: "Run a command",
|
|
4718
|
+
message: "List files in the current directory"
|
|
4719
|
+
},
|
|
4720
|
+
{
|
|
4721
|
+
title: "Help me code",
|
|
4722
|
+
message: "Help me write a function that..."
|
|
4723
|
+
}
|
|
4724
|
+
];
|
|
3575
4725
|
socketClient.on("assistant_thread_started", async ({ ack, event }) => {
|
|
3576
4726
|
await ack();
|
|
3577
4727
|
if (!event) return;
|
|
@@ -3580,23 +4730,54 @@ function createSlackChannel(getConfig, signal) {
|
|
|
3580
4730
|
const channelId = threadInfo["channel_id"];
|
|
3581
4731
|
const threadTs = threadInfo["thread_ts"];
|
|
3582
4732
|
if (!channelId || !threadTs) return;
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
},
|
|
3588
|
-
{
|
|
3589
|
-
title: "Run a command",
|
|
3590
|
-
message: "List files in the current directory"
|
|
3591
|
-
},
|
|
3592
|
-
{
|
|
3593
|
-
title: "Help me code",
|
|
3594
|
-
message: "Help me write a function that..."
|
|
3595
|
-
}
|
|
3596
|
-
]).catch(() => {});
|
|
4733
|
+
const agent = matchAgent(channelId, getConfig().agents, channelNameToId);
|
|
4734
|
+
const prompts = agent?.suggestedPrompts ?? DEFAULT_PROMPTS;
|
|
4735
|
+
const promptTitle = `What can ${agent?.name ?? "BrowserBird"} help with?`;
|
|
4736
|
+
channelClient.setSuggestedPrompts?.(channelId, threadTs, prompts, promptTitle).catch(() => {});
|
|
3597
4737
|
});
|
|
3598
|
-
socketClient.on("assistant_thread_context_changed", async ({ ack }) => {
|
|
4738
|
+
socketClient.on("assistant_thread_context_changed", async ({ ack, event }) => {
|
|
3599
4739
|
await ack();
|
|
4740
|
+
if (!event) return;
|
|
4741
|
+
const threadInfo = event["assistant_thread"];
|
|
4742
|
+
if (!threadInfo) return;
|
|
4743
|
+
const channelId = threadInfo["channel_id"];
|
|
4744
|
+
const threadTs = threadInfo["thread_ts"];
|
|
4745
|
+
if (!channelId || !threadTs) return;
|
|
4746
|
+
const contextChannelId = threadInfo["context"]?.["channel_id"];
|
|
4747
|
+
const config = getConfig();
|
|
4748
|
+
const agent = matchAgent(contextChannelId ?? channelId, config.agents, channelNameToId);
|
|
4749
|
+
const prompts = agent?.suggestedPrompts ?? DEFAULT_PROMPTS;
|
|
4750
|
+
const promptTitle = `What can ${agent?.name ?? "BrowserBird"} help with?`;
|
|
4751
|
+
channelClient.setSuggestedPrompts?.(channelId, threadTs, prompts, promptTitle).catch(() => {});
|
|
4752
|
+
});
|
|
4753
|
+
socketClient.on("app_home_opened", async ({ ack, event }) => {
|
|
4754
|
+
await ack();
|
|
4755
|
+
if (!event) return;
|
|
4756
|
+
const userId = event["user"];
|
|
4757
|
+
if (!userId) return;
|
|
4758
|
+
if (event["tab"] !== "home") return;
|
|
4759
|
+
try {
|
|
4760
|
+
const config = getConfig();
|
|
4761
|
+
const agent = config.agents[0];
|
|
4762
|
+
const birds = listCronJobs(1, 20).items.map((b) => ({
|
|
4763
|
+
name: b.name,
|
|
4764
|
+
schedule: b.schedule,
|
|
4765
|
+
enabled: b.enabled === 1
|
|
4766
|
+
}));
|
|
4767
|
+
const view = homeTabView({
|
|
4768
|
+
agentName: agent?.name ?? "BrowserBird",
|
|
4769
|
+
description: agent?.systemPrompt ?? "A self-hosted AI assistant with a real browser and a scheduler.",
|
|
4770
|
+
birds,
|
|
4771
|
+
activeSessions: handler.activeCount(),
|
|
4772
|
+
maxConcurrent: config.sessions.maxConcurrent
|
|
4773
|
+
});
|
|
4774
|
+
await webClient.views.publish({
|
|
4775
|
+
user_id: userId,
|
|
4776
|
+
view
|
|
4777
|
+
});
|
|
4778
|
+
} catch (err) {
|
|
4779
|
+
logger.warn(`home tab error: ${err instanceof Error ? err.message : String(err)}`);
|
|
4780
|
+
}
|
|
3600
4781
|
});
|
|
3601
4782
|
let connected = false;
|
|
3602
4783
|
const statusProvider = {
|
|
@@ -3634,6 +4815,16 @@ function createSlackChannel(getConfig, signal) {
|
|
|
3634
4815
|
if (!sessionUid) continue;
|
|
3635
4816
|
await handleSessionRetry(sessionUid, user ?? "unknown", handler);
|
|
3636
4817
|
}
|
|
4818
|
+
if (actionId === "response_feedback") {
|
|
4819
|
+
const value = action["value"];
|
|
4820
|
+
if (value === "good" || value === "bad") {
|
|
4821
|
+
const message = body["message"];
|
|
4822
|
+
const threadTs = message?.["thread_ts"];
|
|
4823
|
+
const messageTs = message?.["ts"];
|
|
4824
|
+
insertFeedback(channel, threadTs, messageTs, user ?? "unknown", value);
|
|
4825
|
+
logger.info(`feedback: ${value} from ${user} in ${channel}`);
|
|
4826
|
+
}
|
|
4827
|
+
}
|
|
3637
4828
|
}
|
|
3638
4829
|
}
|
|
3639
4830
|
});
|
|
@@ -3711,11 +4902,14 @@ function createSlackChannel(getConfig, signal) {
|
|
|
3711
4902
|
return connected;
|
|
3712
4903
|
}
|
|
3713
4904
|
async function postMessage(channel, text, opts) {
|
|
3714
|
-
await webClient.chat.postMessage({
|
|
4905
|
+
return (await webClient.chat.postMessage({
|
|
3715
4906
|
channel,
|
|
3716
4907
|
text,
|
|
3717
4908
|
...opts?.blocks ? { blocks: opts.blocks } : {}
|
|
3718
|
-
});
|
|
4909
|
+
})).ts ?? "";
|
|
4910
|
+
}
|
|
4911
|
+
async function setTitle(channelId, threadTs, title) {
|
|
4912
|
+
await channelClient.setTitle(channelId, threadTs, title);
|
|
3719
4913
|
}
|
|
3720
4914
|
return {
|
|
3721
4915
|
start,
|
|
@@ -3723,6 +4917,7 @@ function createSlackChannel(getConfig, signal) {
|
|
|
3723
4917
|
isConnected,
|
|
3724
4918
|
activeCount: () => handler.activeCount(),
|
|
3725
4919
|
postMessage,
|
|
4920
|
+
setTitle,
|
|
3726
4921
|
resolveChannelNames
|
|
3727
4922
|
};
|
|
3728
4923
|
}
|
|
@@ -3738,7 +4933,6 @@ async function handleBirdCreateSubmission(view, webClient) {
|
|
|
3738
4933
|
logger.warn("bird_create submission missing required fields");
|
|
3739
4934
|
return;
|
|
3740
4935
|
}
|
|
3741
|
-
const { createCronJob, setCronJobEnabled } = await import("./db-BNF1vZIm.mjs").then((n) => n.t);
|
|
3742
4936
|
const bird = createCronJob(name, schedule, prompt, channelId || void 0, "default");
|
|
3743
4937
|
if (enabledValue !== "enabled") setCronJobEnabled(bird.uid, false);
|
|
3744
4938
|
await webClient.chat.postMessage({
|
|
@@ -3752,7 +4946,6 @@ async function handleBirdCreateSubmission(view, webClient) {
|
|
|
3752
4946
|
}
|
|
3753
4947
|
async function handleSessionRetry(sessionUid, userId, handler) {
|
|
3754
4948
|
try {
|
|
3755
|
-
const { getSession, getLastInboundMessage } = await import("./db-BNF1vZIm.mjs").then((n) => n.t);
|
|
3756
4949
|
const session = getSession(sessionUid);
|
|
3757
4950
|
if (!session) {
|
|
3758
4951
|
logger.warn(`retry: session ${sessionUid} not found`);
|
|
@@ -3851,7 +5044,10 @@ async function startDaemon(options) {
|
|
|
3851
5044
|
currentConfig = config;
|
|
3852
5045
|
if (!schedulerStarted && config.agents.length > 0) {
|
|
3853
5046
|
logger.info("starting scheduler...");
|
|
3854
|
-
startScheduler(getConfig, controller.signal, {
|
|
5047
|
+
startScheduler(getConfig, controller.signal, {
|
|
5048
|
+
postToSlack: (channel, text, opts) => slackHandle ? slackHandle.postMessage(channel, text, opts) : Promise.resolve(""),
|
|
5049
|
+
setThreadTitle: (channelId, threadTs, title) => slackHandle ? slackHandle.setTitle(channelId, threadTs, title) : Promise.resolve()
|
|
5050
|
+
});
|
|
3855
5051
|
schedulerStarted = true;
|
|
3856
5052
|
}
|
|
3857
5053
|
if (!healthStarted) {
|