flyspace-cli 0.1.25 → 0.1.29
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/{chunk-B5NWSWAM.js → chunk-6YFDWUNF.js} +10 -5
- package/dist/cli.js +2063 -1946
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -89,6 +89,7 @@ import {
|
|
|
89
89
|
isLockedPlan,
|
|
90
90
|
isLogicalCwd,
|
|
91
91
|
isMemoryType,
|
|
92
|
+
isUnderLogicalRoot,
|
|
92
93
|
isUuid,
|
|
93
94
|
isValidSkillName,
|
|
94
95
|
lastRunsForWorkspace,
|
|
@@ -140,7 +141,6 @@ import {
|
|
|
140
141
|
resolveChannelPrompt,
|
|
141
142
|
resolveCwd,
|
|
142
143
|
resolveFeatureFlags,
|
|
143
|
-
resolveLogicalCwd,
|
|
144
144
|
resolveSelectedOutputStyle,
|
|
145
145
|
resolveUserId,
|
|
146
146
|
runAutomation,
|
|
@@ -184,7 +184,7 @@ import {
|
|
|
184
184
|
warnOnBrokenChannelCwds,
|
|
185
185
|
workspaceSkillsDir,
|
|
186
186
|
writeSkillFile
|
|
187
|
-
} from "./chunk-
|
|
187
|
+
} from "./chunk-6YFDWUNF.js";
|
|
188
188
|
import {
|
|
189
189
|
firstReplyBody,
|
|
190
190
|
getThread,
|
|
@@ -831,8 +831,8 @@ function flyspaceHome(entry = process.argv[1]) {
|
|
|
831
831
|
if (at <= 0) return void 0;
|
|
832
832
|
const npmPrefix = cliPath.slice(0, at);
|
|
833
833
|
if (basename(npmPrefix) !== "cli") return void 0;
|
|
834
|
-
const
|
|
835
|
-
return existsSync3(join4(
|
|
834
|
+
const home2 = dirname3(npmPrefix);
|
|
835
|
+
return existsSync3(join4(home2, "bin", "flyspace")) && existsSync3(join4(home2, "node", "bin", "node")) ? home2 : void 0;
|
|
836
836
|
}
|
|
837
837
|
function serviceLabel(profile) {
|
|
838
838
|
return profile === DEFAULT_PROFILE ? "ai.flyspace" : `ai.flyspace.${profile}`;
|
|
@@ -976,8 +976,8 @@ function detectClaudeBinDir() {
|
|
|
976
976
|
}
|
|
977
977
|
}
|
|
978
978
|
function detectNodeBinDir() {
|
|
979
|
-
const
|
|
980
|
-
return
|
|
979
|
+
const home2 = flyspaceHome();
|
|
980
|
+
return home2 ? join5(home2, "node", "bin") : dirname4(process.execPath);
|
|
981
981
|
}
|
|
982
982
|
function nodeShimDirs() {
|
|
983
983
|
return [join5(homedir2(), ".local", "share", "mise", "shims"), join5(homedir2(), ".asdf", "shims")].filter(
|
|
@@ -985,8 +985,8 @@ function nodeShimDirs() {
|
|
|
985
985
|
);
|
|
986
986
|
}
|
|
987
987
|
function detectCustieBin() {
|
|
988
|
-
const
|
|
989
|
-
if (
|
|
988
|
+
const home2 = flyspaceHome();
|
|
989
|
+
if (home2) return join5(home2, "bin", "flyspace");
|
|
990
990
|
const argv1 = process.argv[1];
|
|
991
991
|
if (argv1) {
|
|
992
992
|
return resolve2(argv1);
|
|
@@ -1219,6 +1219,7 @@ import {
|
|
|
1219
1219
|
readdirSync as readdirSync2,
|
|
1220
1220
|
readFileSync as readFileSync7,
|
|
1221
1221
|
readSync,
|
|
1222
|
+
realpathSync as realpathSync2,
|
|
1222
1223
|
statSync as statSync2,
|
|
1223
1224
|
symlinkSync
|
|
1224
1225
|
} from "fs";
|
|
@@ -1317,8 +1318,8 @@ function codexTrustedPaths() {
|
|
|
1317
1318
|
}
|
|
1318
1319
|
}
|
|
1319
1320
|
function isExcluded(path) {
|
|
1320
|
-
const
|
|
1321
|
-
if (path ===
|
|
1321
|
+
const home2 = homedir3();
|
|
1322
|
+
if (path === home2 || path === "/") return true;
|
|
1322
1323
|
if (path === "/tmp" || path.startsWith("/tmp/")) return true;
|
|
1323
1324
|
if (path === "/private/tmp" || path.startsWith("/private/tmp/")) return true;
|
|
1324
1325
|
const root = flyspaceRoot();
|
|
@@ -1330,10 +1331,10 @@ function isExcluded(path) {
|
|
|
1330
1331
|
}
|
|
1331
1332
|
}
|
|
1332
1333
|
function gitRepos() {
|
|
1333
|
-
const
|
|
1334
|
+
const home2 = homedir3();
|
|
1334
1335
|
const found = [];
|
|
1335
1336
|
for (const rootName of GIT_SCAN_ROOTS) {
|
|
1336
|
-
const root = join7(
|
|
1337
|
+
const root = join7(home2, rootName);
|
|
1337
1338
|
let entries2;
|
|
1338
1339
|
try {
|
|
1339
1340
|
entries2 = readdirSync2(root, { withFileTypes: true });
|
|
@@ -1362,7 +1363,23 @@ function detectRunners(history = { claude: false, codex: false }) {
|
|
|
1362
1363
|
}
|
|
1363
1364
|
];
|
|
1364
1365
|
}
|
|
1365
|
-
function
|
|
1366
|
+
function linkedTargets(slug) {
|
|
1367
|
+
const out = /* @__PURE__ */ new Map();
|
|
1368
|
+
if (!SLUG_RE.test(slug)) return out;
|
|
1369
|
+
const dir = join7(flyspaceRoot(), slug);
|
|
1370
|
+
try {
|
|
1371
|
+
for (const e of readdirSync2(dir, { withFileTypes: true })) {
|
|
1372
|
+
if (!e.isSymbolicLink()) continue;
|
|
1373
|
+
try {
|
|
1374
|
+
out.set(realpathSync2(join7(dir, e.name)), e.name);
|
|
1375
|
+
} catch {
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
} catch {
|
|
1379
|
+
}
|
|
1380
|
+
return out;
|
|
1381
|
+
}
|
|
1382
|
+
function scanOnboarding(slug) {
|
|
1366
1383
|
const byPath = /* @__PURE__ */ new Map();
|
|
1367
1384
|
const history = { claude: false, codex: false };
|
|
1368
1385
|
const add = (rawPath, source, mtime, sessions2, trusted = false) => {
|
|
@@ -1432,6 +1449,18 @@ function scanOnboarding() {
|
|
|
1432
1449
|
projects.sort(
|
|
1433
1450
|
(a, b) => Number(b.sources.length > 1) - Number(a.sources.length > 1) || Number(b.trusted) - Number(a.trusted) || b.lastUsed - a.lastUsed || b.sessions - a.sessions
|
|
1434
1451
|
);
|
|
1452
|
+
if (slug) {
|
|
1453
|
+
const linked = linkedTargets(slug);
|
|
1454
|
+
for (const p of projects) {
|
|
1455
|
+
let real = p.path;
|
|
1456
|
+
try {
|
|
1457
|
+
real = realpathSync2(p.path);
|
|
1458
|
+
} catch {
|
|
1459
|
+
}
|
|
1460
|
+
const name = linked.get(real);
|
|
1461
|
+
if (name) p.linkedAs = name;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1435
1464
|
return {
|
|
1436
1465
|
root: flyspaceRoot(),
|
|
1437
1466
|
runners: detectRunners(history),
|
|
@@ -1443,13 +1472,23 @@ var GENERAL_TEMPLATE_DIR = join7(paths.PACKAGE_ROOT, "templates", "general");
|
|
|
1443
1472
|
function makeLink(target, linkPath) {
|
|
1444
1473
|
symlinkSync(target, linkPath, process.platform === "win32" ? "junction" : "dir");
|
|
1445
1474
|
}
|
|
1475
|
+
var SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
1476
|
+
function assertLinkable(target) {
|
|
1477
|
+
const home2 = homedir3();
|
|
1478
|
+
const root = existsSync6(flyspaceRoot()) ? realpathSync2(flyspaceRoot()) : flyspaceRoot();
|
|
1479
|
+
const denied = [join7(home2, ".ssh"), join7(home2, "Library"), join7(home2, ".config"), join7(home2, ".gnupg")];
|
|
1480
|
+
if (target === home2 || target === "/" || target === root || target.startsWith(`${root}/`)) {
|
|
1481
|
+
throw new Error(`cannot link ${target}`);
|
|
1482
|
+
}
|
|
1483
|
+
if (denied.some((d) => target === d || target.startsWith(`${d}/`))) throw new Error(`cannot link ${target}`);
|
|
1484
|
+
}
|
|
1446
1485
|
function linkProjectFolders(slug, items) {
|
|
1447
|
-
if (
|
|
1486
|
+
if (!SLUG_RE.test(slug)) throw new Error(`invalid workspace slug: ${slug}`);
|
|
1448
1487
|
const dir = join7(flyspaceRoot(), slug);
|
|
1449
1488
|
mkdirSync5(dir, { recursive: true });
|
|
1450
1489
|
const results = [];
|
|
1451
1490
|
for (const item of items) {
|
|
1452
|
-
if (
|
|
1491
|
+
if (!SLUG_RE.test(item.name)) throw new Error(`invalid channel name: ${item.name}`);
|
|
1453
1492
|
const dest = join7(dir, item.name);
|
|
1454
1493
|
let exists = true;
|
|
1455
1494
|
try {
|
|
@@ -1460,10 +1499,21 @@ function linkProjectFolders(slug, items) {
|
|
|
1460
1499
|
if (exists && item.name === "general" && !item.path) {
|
|
1461
1500
|
cpSync(GENERAL_TEMPLATE_DIR, dest, { recursive: true, force: false });
|
|
1462
1501
|
}
|
|
1502
|
+
if (exists && item.path) {
|
|
1503
|
+
let current = null;
|
|
1504
|
+
try {
|
|
1505
|
+
current = realpathSync2(dest);
|
|
1506
|
+
} catch {
|
|
1507
|
+
}
|
|
1508
|
+
if (current !== realpathSync2(resolve3(item.path))) {
|
|
1509
|
+
throw new Error(`#${item.name} already points at another folder`);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1463
1512
|
if (!exists) {
|
|
1464
1513
|
if (item.path) {
|
|
1465
1514
|
const target = resolve3(item.path);
|
|
1466
1515
|
if (!statSync2(target).isDirectory()) throw new Error(`not a directory: ${target}`);
|
|
1516
|
+
assertLinkable(realpathSync2(target));
|
|
1467
1517
|
makeLink(target, dest);
|
|
1468
1518
|
} else {
|
|
1469
1519
|
mkdirSync5(dest, { recursive: true });
|
|
@@ -1785,7 +1835,7 @@ async function runAuthCmd(args) {
|
|
|
1785
1835
|
}
|
|
1786
1836
|
|
|
1787
1837
|
// ../api/src/version.ts
|
|
1788
|
-
var CLI_VERSION = true ? "0.1.
|
|
1838
|
+
var CLI_VERSION = true ? "0.1.29" : null;
|
|
1789
1839
|
function isOlderVersion(a, b) {
|
|
1790
1840
|
const parse = (v) => v.replace(/^v/, "").split("-")[0].split(".").map(Number);
|
|
1791
1841
|
const [pa, pb] = [parse(a), parse(b)];
|
|
@@ -1793,6 +1843,19 @@ function isOlderVersion(a, b) {
|
|
|
1793
1843
|
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] < pb[i];
|
|
1794
1844
|
return false;
|
|
1795
1845
|
}
|
|
1846
|
+
var LATEST_TTL_MS = 60 * 6e4;
|
|
1847
|
+
var latest = null;
|
|
1848
|
+
async function latestCliVersion() {
|
|
1849
|
+
if (latest && Date.now() - latest.at < LATEST_TTL_MS) return latest.version;
|
|
1850
|
+
let version = null;
|
|
1851
|
+
try {
|
|
1852
|
+
const res = await fetch("https://registry.npmjs.org/flyspace-cli/latest", { signal: AbortSignal.timeout(5e3) });
|
|
1853
|
+
if (res.ok) version = (await res.json()).version ?? null;
|
|
1854
|
+
} catch {
|
|
1855
|
+
}
|
|
1856
|
+
latest = { version, at: Date.now() };
|
|
1857
|
+
return version;
|
|
1858
|
+
}
|
|
1796
1859
|
|
|
1797
1860
|
// src/commands/setup.ts
|
|
1798
1861
|
var BOLD2 = "\x1B[1m";
|
|
@@ -1831,14 +1894,14 @@ async function servedWorkspaces() {
|
|
|
1831
1894
|
}
|
|
1832
1895
|
function checkLatestVersion() {
|
|
1833
1896
|
if (!CLI_VERSION) return true;
|
|
1834
|
-
let
|
|
1897
|
+
let latest2 = "";
|
|
1835
1898
|
try {
|
|
1836
|
-
|
|
1899
|
+
latest2 = execSync4(`npm view ${"flyspace-cli"} version`, { encoding: "utf-8", stdio: "pipe", timeout: 1e4 }).trim();
|
|
1837
1900
|
} catch {
|
|
1838
1901
|
return true;
|
|
1839
1902
|
}
|
|
1840
|
-
if (!
|
|
1841
|
-
bad(`flyspace ${CLI_VERSION} is out of date (latest ${
|
|
1903
|
+
if (!latest2 || !isOlderVersion(CLI_VERSION, latest2)) return true;
|
|
1904
|
+
bad(`flyspace ${CLI_VERSION} is out of date (latest ${latest2}) ${DIM2}run: flyspace upgrade${RESET2}`);
|
|
1842
1905
|
return false;
|
|
1843
1906
|
}
|
|
1844
1907
|
function loginArgs(args) {
|
|
@@ -2318,7 +2381,7 @@ async function runProfiles(args = []) {
|
|
|
2318
2381
|
|
|
2319
2382
|
// src/commands/upgrade.ts
|
|
2320
2383
|
import { execSync as execSync6 } from "child_process";
|
|
2321
|
-
import { realpathSync as
|
|
2384
|
+
import { realpathSync as realpathSync3 } from "fs";
|
|
2322
2385
|
import { join as join11 } from "path";
|
|
2323
2386
|
function restartInstalledProfiles() {
|
|
2324
2387
|
if (isHostServiceInstalled()) {
|
|
@@ -2342,7 +2405,7 @@ Restarting ${installed.length} instance(s)...`);
|
|
|
2342
2405
|
function npmInstallCommand() {
|
|
2343
2406
|
let prefix;
|
|
2344
2407
|
try {
|
|
2345
|
-
const entry =
|
|
2408
|
+
const entry = realpathSync3(process.argv[1] ?? "");
|
|
2346
2409
|
const at = entry.indexOf(`/lib/node_modules/${"flyspace-cli"}/`);
|
|
2347
2410
|
if (at > 0) prefix = entry.slice(0, at);
|
|
2348
2411
|
} catch {
|
|
@@ -2351,24 +2414,24 @@ function npmInstallCommand() {
|
|
|
2351
2414
|
return `npm install ${global} --ignore-scripts ${"flyspace-cli"}@latest && npm rebuild ${global} better-sqlite3`;
|
|
2352
2415
|
}
|
|
2353
2416
|
function npmEnv() {
|
|
2354
|
-
const
|
|
2355
|
-
return
|
|
2417
|
+
const home2 = flyspaceHome();
|
|
2418
|
+
return home2 ? { ...process.env, PATH: `${join11(home2, "node", "bin")}:${process.env.PATH ?? ""}` } : process.env;
|
|
2356
2419
|
}
|
|
2357
2420
|
async function runUpgrade() {
|
|
2358
|
-
const current = "0.1.
|
|
2421
|
+
const current = "0.1.29";
|
|
2359
2422
|
console.log(`Current version: ${current}`);
|
|
2360
2423
|
console.log("Checking for updates...\n");
|
|
2361
2424
|
try {
|
|
2362
|
-
const
|
|
2363
|
-
if (
|
|
2425
|
+
const latest2 = execSync6(`npm view ${"flyspace-cli"} version`, { encoding: "utf-8", env: npmEnv() }).trim();
|
|
2426
|
+
if (latest2 === current) {
|
|
2364
2427
|
console.log(`Already on the latest version (${current}).`);
|
|
2365
2428
|
return;
|
|
2366
2429
|
}
|
|
2367
|
-
console.log(`New version available: ${
|
|
2430
|
+
console.log(`New version available: ${latest2}`);
|
|
2368
2431
|
console.log("Upgrading...\n");
|
|
2369
2432
|
execSync6(npmInstallCommand(), { stdio: "inherit", env: npmEnv() });
|
|
2370
2433
|
console.log(`
|
|
2371
|
-
Upgraded from ${current} to ${
|
|
2434
|
+
Upgraded from ${current} to ${latest2}.`);
|
|
2372
2435
|
restartInstalledProfiles();
|
|
2373
2436
|
console.log("\nReload your shell to use the new version:");
|
|
2374
2437
|
console.log(" source ~/.zshrc # or source ~/.bashrc");
|
|
@@ -5038,7 +5101,7 @@ async function channelHistory(client2, nameOrId, args) {
|
|
|
5038
5101
|
const limit = parseInt(getArg2(args, "--limit") ?? "100", 10);
|
|
5039
5102
|
const isToday = args.includes("--today");
|
|
5040
5103
|
let oldest = getArg2(args, "--oldest");
|
|
5041
|
-
let
|
|
5104
|
+
let latest2 = getArg2(args, "--latest");
|
|
5042
5105
|
if (isToday) {
|
|
5043
5106
|
const now = /* @__PURE__ */ new Date();
|
|
5044
5107
|
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
@@ -5048,7 +5111,7 @@ async function channelHistory(client2, nameOrId, args) {
|
|
|
5048
5111
|
channel: channelId,
|
|
5049
5112
|
limit,
|
|
5050
5113
|
...oldest ? { oldest } : {},
|
|
5051
|
-
...
|
|
5114
|
+
...latest2 ? { latest: latest2 } : {}
|
|
5052
5115
|
});
|
|
5053
5116
|
const messages = (result.messages ?? []).reverse();
|
|
5054
5117
|
if (!messages.length) {
|
|
@@ -8501,7 +8564,7 @@ function createGuestRouter() {
|
|
|
8501
8564
|
}
|
|
8502
8565
|
|
|
8503
8566
|
// ../api/src/dashboard/auth-routes.ts
|
|
8504
|
-
import { Hono as
|
|
8567
|
+
import { Hono as Hono8 } from "hono";
|
|
8505
8568
|
|
|
8506
8569
|
// ../api/src/dashboard/supabase-server-client.ts
|
|
8507
8570
|
import { createServerClient, parseCookieHeader, serializeCookieHeader } from "@supabase/ssr";
|
|
@@ -8611,6 +8674,8 @@ function pruneExpired(now) {
|
|
|
8611
8674
|
if (entry.expires <= now) resolved.delete(key);
|
|
8612
8675
|
}
|
|
8613
8676
|
}
|
|
8677
|
+
var SUPPORT_NAME = "Flyspace Support";
|
|
8678
|
+
var SUPPORT_AVATAR_URL = "/logo/flyspace-mark.svg";
|
|
8614
8679
|
async function resolveAuthContext(c, opts = {}) {
|
|
8615
8680
|
const cookie = c.req?.header("Cookie") ?? "";
|
|
8616
8681
|
if (!cookie) return resolveAuthContextUncached(c);
|
|
@@ -8669,8 +8734,9 @@ async function resolveAuthContextUncached(c) {
|
|
|
8669
8734
|
handle: membership.handle,
|
|
8670
8735
|
role: membership.role,
|
|
8671
8736
|
workspaceId: workspace.id,
|
|
8672
|
-
|
|
8673
|
-
|
|
8737
|
+
// A support admin posts into a customer's workspace as Flyspace, not as
|
|
8738
|
+
// the staff member's personal profile.
|
|
8739
|
+
...membership.isSupport ? { name: SUPPORT_NAME, isSupport: true } : { name: metadata.name, avatarKey: metadata.avatarKey },
|
|
8674
8740
|
onboarded: Boolean(metadata.onboardedAt),
|
|
8675
8741
|
passwordSet: Boolean(metadata.passwordSetAt),
|
|
8676
8742
|
agentConnectedAt: workspace.agentConnectedAt ?? null,
|
|
@@ -8739,1781 +8805,2031 @@ async function verifyTurnstileToken(token) {
|
|
|
8739
8805
|
return data.action === void 0 || data.action === "login";
|
|
8740
8806
|
}
|
|
8741
8807
|
|
|
8742
|
-
// ../api/src/
|
|
8743
|
-
|
|
8744
|
-
|
|
8745
|
-
|
|
8746
|
-
|
|
8747
|
-
|
|
8748
|
-
|
|
8749
|
-
|
|
8750
|
-
|
|
8751
|
-
|
|
8752
|
-
const
|
|
8753
|
-
|
|
8754
|
-
|
|
8755
|
-
|
|
8756
|
-
|
|
8757
|
-
|
|
8758
|
-
|
|
8759
|
-
|
|
8760
|
-
|
|
8761
|
-
|
|
8762
|
-
|
|
8763
|
-
|
|
8808
|
+
// ../api/src/store/device-store.ts
|
|
8809
|
+
import { createHash as createHash7, createPublicKey as createPublicKey2, randomBytes as randomBytes4, randomInt } from "crypto";
|
|
8810
|
+
var DEVICE_CODE_TTL_MS = 10 * 6e4;
|
|
8811
|
+
var ACCESS_TOKEN_TTL_MS = 15 * 6e4;
|
|
8812
|
+
var CHALLENGE_TTL_MS = 2 * 6e4;
|
|
8813
|
+
var POLL_INTERVAL_S = 5;
|
|
8814
|
+
var sha256 = (s) => createHash7("sha256").update(s).digest("hex");
|
|
8815
|
+
function parseEd25519PublicKey(b64) {
|
|
8816
|
+
if (typeof b64 !== "string" || b64.length > 200) return null;
|
|
8817
|
+
try {
|
|
8818
|
+
const key = createPublicKey2({ key: Buffer.from(b64, "base64"), format: "der", type: "spki" });
|
|
8819
|
+
return key.asymmetricKeyType === "ed25519" ? key : null;
|
|
8820
|
+
} catch {
|
|
8821
|
+
return null;
|
|
8822
|
+
}
|
|
8823
|
+
}
|
|
8824
|
+
var USER_CODE_ALPHABET = "BCDFGHJKMNPQRSTVWXZ";
|
|
8825
|
+
function generateUserCode() {
|
|
8826
|
+
const pick = () => Array.from({ length: 4 }, () => USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)]).join("");
|
|
8827
|
+
return `${pick()}-${pick()}`;
|
|
8828
|
+
}
|
|
8829
|
+
function normalizeUserCode(input2) {
|
|
8830
|
+
const raw = input2.toUpperCase().replace(/[\s-]/g, "");
|
|
8831
|
+
if (raw.length !== 8 || [...raw].some((ch) => !USER_CODE_ALPHABET.includes(ch))) return null;
|
|
8832
|
+
return `${raw.slice(0, 4)}-${raw.slice(4)}`;
|
|
8833
|
+
}
|
|
8834
|
+
function toDevice(r) {
|
|
8835
|
+
return {
|
|
8836
|
+
id: String(r["id"]),
|
|
8837
|
+
userId: String(r["user_id"]),
|
|
8838
|
+
name: String(r["name"]),
|
|
8839
|
+
platform: r["platform"] ?? null,
|
|
8840
|
+
publicKey: String(r["public_key"]),
|
|
8841
|
+
createdAt: String(r["created_at"]),
|
|
8842
|
+
lastSeenAt: r["last_seen_at"] ?? null,
|
|
8843
|
+
revokedAt: r["revoked_at"] ?? null
|
|
8764
8844
|
};
|
|
8765
|
-
auth.post("/login", emailedLink(deliverSignInLink));
|
|
8766
|
-
auth.post("/forgot-password", emailedLink(deliverPasswordResetLink));
|
|
8767
|
-
auth.get("/session", async (c) => {
|
|
8768
|
-
const ctx = await resolveAuthContext(c, { fresh: c.req.query("fresh") === "1" });
|
|
8769
|
-
if (!ctx) return c.json({ error: "not signed in" }, 401);
|
|
8770
|
-
return c.json({
|
|
8771
|
-
email: ctx.email,
|
|
8772
|
-
handle: ctx.handle,
|
|
8773
|
-
role: ctx.role,
|
|
8774
|
-
name: ctx.name,
|
|
8775
|
-
avatarUrl: ctx.avatarKey ? await signAvatarUrl(ctx.avatarKey) : null,
|
|
8776
|
-
onboarded: ctx.onboarded,
|
|
8777
|
-
passwordSet: ctx.passwordSet,
|
|
8778
|
-
agentConnectedAt: ctx.agentConnectedAt,
|
|
8779
|
-
plan: ctx.plan ?? null,
|
|
8780
|
-
onboarding: { pending: onboardingPending(ctx) }
|
|
8781
|
-
});
|
|
8782
|
-
});
|
|
8783
|
-
auth.patch("/handle", async (c) => {
|
|
8784
|
-
const ctx = await resolveAuthContext(c);
|
|
8785
|
-
if (!ctx) return c.json({ error: "not signed in" }, 401);
|
|
8786
|
-
if (ctx.onboarded) return c.json({ error: "handle can no longer be changed" }, 403);
|
|
8787
|
-
const body = await c.req.json().catch(() => ({}));
|
|
8788
|
-
const handle = body.handle?.trim().replace(/^@/, "").toLowerCase() ?? "";
|
|
8789
|
-
if (!HANDLE_PATTERN.test(handle)) {
|
|
8790
|
-
return c.json(
|
|
8791
|
-
{ error: "handle must be 1-32 characters: a-z, 0-9, _ or -, starting with a letter or digit" },
|
|
8792
|
-
400
|
|
8793
|
-
);
|
|
8794
|
-
}
|
|
8795
|
-
try {
|
|
8796
|
-
await setMembershipHandle(ctx.workspaceId, ctx.userId, handle);
|
|
8797
|
-
} catch (err) {
|
|
8798
|
-
if (err instanceof HandleTakenError) return c.json({ error: err.message }, 409);
|
|
8799
|
-
throw err;
|
|
8800
|
-
}
|
|
8801
|
-
return c.json({ handle });
|
|
8802
|
-
});
|
|
8803
|
-
auth.post("/logout", async (c) => {
|
|
8804
|
-
const supabase = createRequestSupabaseClient(c);
|
|
8805
|
-
await supabase.auth.signOut();
|
|
8806
|
-
return c.json({ ok: true });
|
|
8807
|
-
});
|
|
8808
|
-
return auth;
|
|
8809
8845
|
}
|
|
8810
|
-
|
|
8811
|
-
|
|
8812
|
-
|
|
8813
|
-
|
|
8814
|
-
|
|
8815
|
-
|
|
8816
|
-
|
|
8817
|
-
|
|
8818
|
-
|
|
8819
|
-
|
|
8820
|
-
|
|
8821
|
-
|
|
8822
|
-
|
|
8846
|
+
function toPending(r) {
|
|
8847
|
+
return {
|
|
8848
|
+
id: String(r["id"]),
|
|
8849
|
+
userCode: String(r["user_code"]),
|
|
8850
|
+
publicKey: String(r["public_key"]),
|
|
8851
|
+
name: String(r["name"]),
|
|
8852
|
+
platform: r["platform"] ?? null,
|
|
8853
|
+
requesterIp: r["requester_ip"] ?? null,
|
|
8854
|
+
approvedBy: r["approved_by"] ?? null,
|
|
8855
|
+
workspaceIds: r["workspace_ids"] ?? [],
|
|
8856
|
+
deniedAt: r["denied_at"] ?? null,
|
|
8857
|
+
lastPolledAt: r["last_polled_at"] ?? null,
|
|
8858
|
+
expiresAt: String(r["expires_at"]),
|
|
8859
|
+
createdAt: String(r["created_at"])
|
|
8860
|
+
};
|
|
8823
8861
|
}
|
|
8824
|
-
|
|
8825
|
-
|
|
8826
|
-
if (c.req.method === "OPTIONS") return next();
|
|
8827
|
-
if (LOCK_EXEMPT.some((re) => re.test(c.req.path))) return next();
|
|
8828
|
-
const auth = c.get("auth");
|
|
8829
|
-
if (!auth) return next();
|
|
8830
|
-
if (isLockedPlan(await getWorkspacePlanStatus(auth.workspaceId))) {
|
|
8831
|
-
return c.json({ error: "trial ended: add a card to reactivate this workspace", code: "plan_locked" }, 402);
|
|
8832
|
-
}
|
|
8833
|
-
return next();
|
|
8862
|
+
function fail(what, error2) {
|
|
8863
|
+
throw new Error(`${what}: ${error2.message}`);
|
|
8834
8864
|
}
|
|
8835
|
-
function
|
|
8836
|
-
const
|
|
8837
|
-
|
|
8838
|
-
|
|
8839
|
-
|
|
8840
|
-
|
|
8841
|
-
|
|
8842
|
-
|
|
8843
|
-
|
|
8844
|
-
|
|
8845
|
-
|
|
8846
|
-
|
|
8847
|
-
|
|
8848
|
-
|
|
8849
|
-
} catch (err) {
|
|
8850
|
-
console.warn("[billing] subscription lookup failed:", err);
|
|
8851
|
-
}
|
|
8852
|
-
}
|
|
8853
|
-
return c.json({ planStatus, trialEnd, plan, locked: isLockedPlan(planStatus) });
|
|
8854
|
-
});
|
|
8855
|
-
billing.post("/checkout", async (c) => {
|
|
8856
|
-
const auth = c.get("auth");
|
|
8857
|
-
if (auth.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
8858
|
-
const { stripeCustomerId, stripeSubscriptionId } = await getWorkspaceBilling(auth.workspaceId);
|
|
8859
|
-
if (!stripeCustomerId) return c.json({ error: "no subscription for this workspace" }, 409);
|
|
8860
|
-
let plan = null;
|
|
8861
|
-
if (stripeSubscriptionId) {
|
|
8862
|
-
try {
|
|
8863
|
-
const sub = await getStripeClient().subscriptions.retrieve(stripeSubscriptionId);
|
|
8864
|
-
if (isPlan(sub.metadata["plan"])) plan = PLAN_INFO[sub.metadata["plan"]];
|
|
8865
|
-
} catch (err) {
|
|
8866
|
-
console.warn("[billing] subscription lookup failed:", err);
|
|
8867
|
-
}
|
|
8868
|
-
}
|
|
8869
|
-
const origin = c.req.header("origin") ?? `https://${trustedHost(c)}`;
|
|
8870
|
-
const session = await getStripeClient().checkout.sessions.create({
|
|
8871
|
-
mode: "setup",
|
|
8872
|
-
currency: "usd",
|
|
8873
|
-
customer: stripeCustomerId,
|
|
8874
|
-
success_url: `${origin}/?billing=done`,
|
|
8875
|
-
cancel_url: `${origin}/`,
|
|
8876
|
-
metadata: { workspace_id: auth.workspaceId },
|
|
8877
|
-
...plan && {
|
|
8878
|
-
custom_text: {
|
|
8879
|
-
submit: {
|
|
8880
|
-
message: `${plan.name} plan, US$${plan.priceUsd}/month. The first month is charged as soon as this card is saved, then monthly until cancelled.`
|
|
8881
|
-
}
|
|
8882
|
-
}
|
|
8883
|
-
}
|
|
8865
|
+
async function createDeviceCode(input2) {
|
|
8866
|
+
const db = getSupabaseClient();
|
|
8867
|
+
await db.from("device_codes").delete().lt("expires_at", (/* @__PURE__ */ new Date()).toISOString());
|
|
8868
|
+
const deviceCode = randomBytes4(32).toString("hex");
|
|
8869
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
8870
|
+
const userCode = generateUserCode();
|
|
8871
|
+
const { error: error2 } = await db.from("device_codes").insert({
|
|
8872
|
+
device_code_hash: sha256(deviceCode),
|
|
8873
|
+
user_code: userCode,
|
|
8874
|
+
public_key: input2.publicKey,
|
|
8875
|
+
name: input2.name,
|
|
8876
|
+
platform: input2.platform,
|
|
8877
|
+
requester_ip: input2.requesterIp,
|
|
8878
|
+
expires_at: new Date(Date.now() + DEVICE_CODE_TTL_MS).toISOString()
|
|
8884
8879
|
});
|
|
8885
|
-
if (!
|
|
8886
|
-
|
|
8887
|
-
}
|
|
8888
|
-
|
|
8880
|
+
if (!error2) return { deviceCode, userCode };
|
|
8881
|
+
if (error2.code !== "23505") fail("createDeviceCode", error2);
|
|
8882
|
+
}
|
|
8883
|
+
throw new Error("createDeviceCode: could not allocate a unique user code");
|
|
8889
8884
|
}
|
|
8890
|
-
|
|
8891
|
-
|
|
8892
|
-
|
|
8893
|
-
|
|
8894
|
-
// ../api/src/dashboard/admin-middleware.ts
|
|
8895
|
-
function platformAdminEmails() {
|
|
8896
|
-
const raw = process.env["PLATFORM_ADMIN_EMAILS"] ?? "";
|
|
8897
|
-
return new Set(
|
|
8898
|
-
raw.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean)
|
|
8899
|
-
);
|
|
8885
|
+
async function getDeviceCodeBySecret(deviceCode) {
|
|
8886
|
+
const { data, error: error2 } = await getSupabaseClient().from("device_codes").select("*").eq("device_code_hash", sha256(deviceCode)).maybeSingle();
|
|
8887
|
+
if (error2) fail("getDeviceCodeBySecret", error2);
|
|
8888
|
+
return data ? toPending(data) : null;
|
|
8900
8889
|
}
|
|
8901
|
-
|
|
8902
|
-
const
|
|
8903
|
-
|
|
8904
|
-
|
|
8905
|
-
|
|
8906
|
-
|
|
8907
|
-
const
|
|
8908
|
-
if (
|
|
8909
|
-
|
|
8910
|
-
|
|
8911
|
-
|
|
8912
|
-
|
|
8913
|
-
|
|
8914
|
-
|
|
8915
|
-
|
|
8916
|
-
|
|
8917
|
-
function
|
|
8918
|
-
const
|
|
8919
|
-
|
|
8920
|
-
|
|
8921
|
-
|
|
8922
|
-
|
|
8923
|
-
|
|
8924
|
-
|
|
8925
|
-
|
|
8926
|
-
|
|
8927
|
-
|
|
8928
|
-
|
|
8929
|
-
|
|
8930
|
-
|
|
8931
|
-
|
|
8932
|
-
|
|
8933
|
-
|
|
8934
|
-
|
|
8935
|
-
|
|
8936
|
-
|
|
8937
|
-
})
|
|
8890
|
+
async function getDeviceCodeByUserCode(userCode) {
|
|
8891
|
+
const { data, error: error2 } = await getSupabaseClient().from("device_codes").select("*").eq("user_code", userCode).maybeSingle();
|
|
8892
|
+
if (error2) fail("getDeviceCodeByUserCode", error2);
|
|
8893
|
+
return data ? toPending(data) : null;
|
|
8894
|
+
}
|
|
8895
|
+
async function markPolled(id) {
|
|
8896
|
+
const { error: error2 } = await getSupabaseClient().from("device_codes").update({ last_polled_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id);
|
|
8897
|
+
if (error2) fail("markPolled", error2);
|
|
8898
|
+
}
|
|
8899
|
+
async function decideDeviceCode(id, userId, decision) {
|
|
8900
|
+
const { data, error: error2 } = await getSupabaseClient().from("device_codes").update(
|
|
8901
|
+
decision.approve ? { approved_by: userId, workspace_ids: decision.workspaceIds } : { denied_at: (/* @__PURE__ */ new Date()).toISOString() }
|
|
8902
|
+
).eq("id", id).is("approved_by", null).is("denied_at", null).select("id");
|
|
8903
|
+
if (error2) fail("decideDeviceCode", error2);
|
|
8904
|
+
return (data ?? []).length > 0;
|
|
8905
|
+
}
|
|
8906
|
+
async function redeemDeviceCode(pending2) {
|
|
8907
|
+
const db = getSupabaseClient();
|
|
8908
|
+
const { data: claimed, error: claimError } = await db.from("device_codes").delete().eq("id", pending2.id).select("id");
|
|
8909
|
+
if (claimError) fail("redeemDeviceCode", claimError);
|
|
8910
|
+
if (!claimed?.length) return null;
|
|
8911
|
+
const existing = pending2.approvedBy ? await findDeviceByKey(pending2.publicKey, pending2.approvedBy) : null;
|
|
8912
|
+
const { data, error: error2 } = existing ? await db.from("devices").update({ name: pending2.name, platform: pending2.platform }).eq("id", existing.id).select("*").single() : await db.from("devices").insert({
|
|
8913
|
+
user_id: pending2.approvedBy,
|
|
8914
|
+
name: pending2.name,
|
|
8915
|
+
platform: pending2.platform,
|
|
8916
|
+
public_key: pending2.publicKey
|
|
8917
|
+
}).select("*").single();
|
|
8918
|
+
if (error2) fail("redeemDeviceCode", error2);
|
|
8919
|
+
const device = toDevice(data);
|
|
8920
|
+
if (pending2.workspaceIds.length) {
|
|
8921
|
+
const { error: bindError } = await db.from("device_workspaces").upsert(
|
|
8922
|
+
pending2.workspaceIds.map((workspaceId) => ({
|
|
8923
|
+
device_id: device.id,
|
|
8924
|
+
workspace_id: workspaceId,
|
|
8925
|
+
bound_by: pending2.approvedBy
|
|
8926
|
+
})),
|
|
8927
|
+
{ onConflict: "device_id,workspace_id", ignoreDuplicates: true }
|
|
8938
8928
|
);
|
|
8939
|
-
|
|
8940
|
-
}
|
|
8941
|
-
|
|
8942
|
-
const invites = await listSetupInvites();
|
|
8943
|
-
return c.json({ invites });
|
|
8944
|
-
});
|
|
8945
|
-
admin.post("/setup-invites", async (c) => {
|
|
8946
|
-
const body = await c.req.json().catch(() => ({}));
|
|
8947
|
-
const ownerEmail = body.ownerEmail?.trim().toLowerCase();
|
|
8948
|
-
if (!ownerEmail || !EMAIL_RE.test(ownerEmail)) {
|
|
8949
|
-
return c.json({ error: "ownerEmail must be a valid email address" }, 400);
|
|
8950
|
-
}
|
|
8951
|
-
const plan = body.plan === "cloud" ? "cloud" : body.plan === "local" ? "local" : void 0;
|
|
8952
|
-
if (!plan) return c.json({ error: 'plan must be "local" or "cloud"' }, 400);
|
|
8953
|
-
const ownerName = body.ownerName?.trim().slice(0, 80) || null;
|
|
8954
|
-
const message = body.message?.trim() || null;
|
|
8955
|
-
if (message && message.length > INVITE_MESSAGE_MAX) {
|
|
8956
|
-
return c.json({ error: `message must be ${INVITE_MESSAGE_MAX} characters or fewer` }, 400);
|
|
8957
|
-
}
|
|
8958
|
-
const operator = c.get("platformOperator");
|
|
8959
|
-
const token = await issueSetupInvite({
|
|
8960
|
-
email: ownerEmail,
|
|
8961
|
-
ownerName,
|
|
8962
|
-
plan,
|
|
8963
|
-
message,
|
|
8964
|
-
invitedBy: operator.userId
|
|
8965
|
-
});
|
|
8966
|
-
const inviteUrl = new URL(`${workspaceOrigin(c, "app")}/auth/new-workspace`);
|
|
8967
|
-
inviteUrl.searchParams.set("token", token);
|
|
8968
|
-
if (body.sendEmail === false) {
|
|
8969
|
-
return c.json({ email: ownerEmail, inviteLink: inviteUrl.toString() }, 201);
|
|
8970
|
-
}
|
|
8971
|
-
try {
|
|
8972
|
-
await deliverWorkspaceSetupInvite({
|
|
8973
|
-
email: ownerEmail,
|
|
8974
|
-
inviteLink: inviteUrl.toString(),
|
|
8975
|
-
message
|
|
8976
|
-
});
|
|
8977
|
-
} catch (err) {
|
|
8978
|
-
await deleteSetupInvite(token).catch(() => {
|
|
8979
|
-
});
|
|
8980
|
-
return c.json(
|
|
8981
|
-
{
|
|
8982
|
-
error: `the invite email failed: ${err instanceof Error ? err.message : "unknown error"}`
|
|
8983
|
-
},
|
|
8984
|
-
500
|
|
8985
|
-
);
|
|
8986
|
-
}
|
|
8987
|
-
return c.json({ email: ownerEmail, inviteLink: inviteUrl.toString() }, 201);
|
|
8988
|
-
});
|
|
8989
|
-
admin.delete("/setup-invites/:id", async (c) => {
|
|
8990
|
-
await revokeSetupInvite(c.req.param("id"));
|
|
8991
|
-
return c.json({ revoked: true });
|
|
8992
|
-
});
|
|
8993
|
-
admin.post("/setup-invites/:id/resend", async (c) => {
|
|
8994
|
-
const operator = c.get("platformOperator");
|
|
8995
|
-
let token;
|
|
8996
|
-
let invite;
|
|
8997
|
-
try {
|
|
8998
|
-
({ token, invite } = await reissueSetupInvite(c.req.param("id")));
|
|
8999
|
-
} catch (err) {
|
|
9000
|
-
return c.json({ error: err instanceof Error ? err.message : "resend failed" }, 404);
|
|
9001
|
-
}
|
|
9002
|
-
const inviteUrl = new URL(`${workspaceOrigin(c, "app")}/auth/new-workspace`);
|
|
9003
|
-
inviteUrl.searchParams.set("token", token);
|
|
9004
|
-
if (c.req.query("email") === "false") {
|
|
9005
|
-
return c.json({ sent: false, inviteLink: inviteUrl.toString() });
|
|
9006
|
-
}
|
|
9007
|
-
try {
|
|
9008
|
-
await deliverWorkspaceSetupInvite({
|
|
9009
|
-
email: invite.email,
|
|
9010
|
-
inviteLink: inviteUrl.toString(),
|
|
9011
|
-
message: invite.message
|
|
9012
|
-
});
|
|
9013
|
-
} catch (err) {
|
|
9014
|
-
return c.json(
|
|
9015
|
-
{
|
|
9016
|
-
error: `the invite email failed: ${err instanceof Error ? err.message : "unknown error"}`
|
|
9017
|
-
},
|
|
9018
|
-
500
|
|
9019
|
-
);
|
|
9020
|
-
}
|
|
9021
|
-
return c.json({ sent: true, inviteLink: inviteUrl.toString() });
|
|
9022
|
-
});
|
|
9023
|
-
return admin;
|
|
8929
|
+
if (bindError) fail("redeemDeviceCode", bindError);
|
|
8930
|
+
}
|
|
8931
|
+
return device;
|
|
9024
8932
|
}
|
|
9025
|
-
|
|
9026
|
-
|
|
9027
|
-
|
|
9028
|
-
|
|
9029
|
-
|
|
9030
|
-
// ../api/src/dashboard/data-api.ts
|
|
9031
|
-
import { existsSync as existsSync19 } from "fs";
|
|
9032
|
-
import sharp5 from "sharp";
|
|
9033
|
-
|
|
9034
|
-
// ../api/src/dashboard/automations-routes.ts
|
|
9035
|
-
import { Hono as Hono10 } from "hono";
|
|
9036
|
-
var TYPES2 = ["schedule", "trigger", "mention-trigger"];
|
|
9037
|
-
function isType(t) {
|
|
9038
|
-
return TYPES2.includes(t);
|
|
8933
|
+
async function getDevice(id) {
|
|
8934
|
+
const { data, error: error2 } = await getSupabaseClient().from("devices").select("*").eq("id", id).maybeSingle();
|
|
8935
|
+
if (error2) fail("getDevice", error2);
|
|
8936
|
+
return data ? toDevice(data) : null;
|
|
9039
8937
|
}
|
|
9040
|
-
function
|
|
9041
|
-
const {
|
|
9042
|
-
|
|
9043
|
-
|
|
9044
|
-
return rest;
|
|
8938
|
+
async function findDeviceByKey(publicKey, userId) {
|
|
8939
|
+
const { data, error: error2 } = await getSupabaseClient().from("devices").select("*").eq("public_key", publicKey).eq("user_id", userId).is("revoked_at", null).order("created_at", { ascending: false }).limit(1).maybeSingle();
|
|
8940
|
+
if (error2) fail("findDeviceByKey", error2);
|
|
8941
|
+
return data ? toDevice(data) : null;
|
|
9045
8942
|
}
|
|
9046
|
-
function
|
|
9047
|
-
|
|
9048
|
-
|
|
9049
|
-
|
|
8943
|
+
async function issueAccessToken(deviceId) {
|
|
8944
|
+
const token = `fsd_${randomBytes4(32).toString("hex")}`;
|
|
8945
|
+
const { error: error2 } = await getSupabaseClient().from("devices").update({
|
|
8946
|
+
access_token_hash: sha256(token),
|
|
8947
|
+
access_token_expires_at: new Date(Date.now() + ACCESS_TOKEN_TTL_MS).toISOString(),
|
|
8948
|
+
last_seen_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
8949
|
+
}).eq("id", deviceId);
|
|
8950
|
+
if (error2) fail("issueAccessToken", error2);
|
|
8951
|
+
return { token, expiresInS: ACCESS_TOKEN_TTL_MS / 1e3 };
|
|
9050
8952
|
}
|
|
9051
|
-
function
|
|
9052
|
-
|
|
9053
|
-
const
|
|
9054
|
-
|
|
9055
|
-
|
|
9056
|
-
if (!item) return c.json({ error: "not found" }, 404);
|
|
9057
|
-
return c.json(item);
|
|
9058
|
-
});
|
|
9059
|
-
app.post("/:type", async (c) => {
|
|
9060
|
-
const type = c.req.param("type");
|
|
9061
|
-
if (!isType(type)) return c.json({ error: "unknown type" }, 400);
|
|
9062
|
-
const body = sanitize(await c.req.json());
|
|
9063
|
-
const next = { ...body, created_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
9064
|
-
try {
|
|
9065
|
-
const m = manager();
|
|
9066
|
-
if (type === "schedule") await m.addSchedule(next);
|
|
9067
|
-
else if (type === "trigger") await m.addTrigger(next);
|
|
9068
|
-
else await m.addMentionTrigger(next);
|
|
9069
|
-
return c.json(next, 201);
|
|
9070
|
-
} catch (e) {
|
|
9071
|
-
const msg = e.message;
|
|
9072
|
-
const status = statusFor(msg);
|
|
9073
|
-
return c.json(status === 400 ? { errors: [msg] } : { error: msg }, status);
|
|
9074
|
-
}
|
|
9075
|
-
});
|
|
9076
|
-
app.put("/:type/:name", async (c) => {
|
|
9077
|
-
const type = c.req.param("type");
|
|
9078
|
-
if (!isType(type)) return c.json({ error: "unknown type" }, 400);
|
|
9079
|
-
const name = c.req.param("name");
|
|
9080
|
-
const body = sanitize(await c.req.json());
|
|
9081
|
-
try {
|
|
9082
|
-
const m = manager();
|
|
9083
|
-
const existing = m.get(name);
|
|
9084
|
-
const next = {
|
|
9085
|
-
...body,
|
|
9086
|
-
created_at: existing?.created_at
|
|
9087
|
-
};
|
|
9088
|
-
await m.update(type, name, next);
|
|
9089
|
-
return c.json(next, 200);
|
|
9090
|
-
} catch (e) {
|
|
9091
|
-
const msg = e.message;
|
|
9092
|
-
const status = statusFor(msg);
|
|
9093
|
-
return c.json(status === 400 ? { errors: [msg] } : { error: msg }, status);
|
|
9094
|
-
}
|
|
9095
|
-
});
|
|
9096
|
-
app.delete("/:type/:name", async (c) => {
|
|
9097
|
-
try {
|
|
9098
|
-
await manager().remove(c.req.param("name"));
|
|
9099
|
-
return c.json({ ok: true }, 200);
|
|
9100
|
-
} catch (e) {
|
|
9101
|
-
return c.json({ error: e.message }, 404);
|
|
9102
|
-
}
|
|
9103
|
-
});
|
|
9104
|
-
return app;
|
|
8953
|
+
async function deviceForAccessToken(token) {
|
|
8954
|
+
if (!token.startsWith("fsd_")) return null;
|
|
8955
|
+
const { data, error: error2 } = await getSupabaseClient().from("devices").select("*").eq("access_token_hash", sha256(token)).is("revoked_at", null).gt("access_token_expires_at", (/* @__PURE__ */ new Date()).toISOString()).maybeSingle();
|
|
8956
|
+
if (error2) fail("deviceForAccessToken", error2);
|
|
8957
|
+
return data ? toDevice(data) : null;
|
|
9105
8958
|
}
|
|
9106
|
-
|
|
9107
|
-
|
|
9108
|
-
|
|
9109
|
-
|
|
9110
|
-
|
|
9111
|
-
|
|
9112
|
-
|
|
9113
|
-
|
|
9114
|
-
|
|
9115
|
-
|
|
9116
|
-
|
|
9117
|
-
|
|
9118
|
-
const name = typeof body["name"] === "string" ? body["name"].trim() : "";
|
|
9119
|
-
if (!name) return c.json({ errors: ["name is required"] }, 400);
|
|
9120
|
-
const supabase = getSupabaseClient();
|
|
9121
|
-
const { data: last } = await supabase.from("categories").select("position").eq("profile", getProfile()).order("position", { ascending: false }).limit(1);
|
|
9122
|
-
const nextPosition = (last?.[0]?.position ?? -1) + 1;
|
|
9123
|
-
const { data, error: error2 } = await supabase.from("categories").insert({ name, position: nextPosition, profile: getProfile() }).select().single();
|
|
9124
|
-
if (error2) return c.json({ error: error2.message }, 500);
|
|
9125
|
-
return c.json(data, 201);
|
|
9126
|
-
});
|
|
9127
|
-
app.patch("/reorder", async (c) => {
|
|
9128
|
-
const body = await c.req.json().catch(() => ({}));
|
|
9129
|
-
const ids = Array.isArray(body["ids"]) ? body["ids"].filter((x) => typeof x === "string") : [];
|
|
9130
|
-
if (ids.length === 0) return c.json({ errors: ["ids is required"] }, 400);
|
|
9131
|
-
const supabase = getSupabaseClient();
|
|
9132
|
-
const results = await Promise.all(
|
|
9133
|
-
ids.map(
|
|
9134
|
-
(id, position) => supabase.from("categories").update({ position, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).eq("profile", getProfile())
|
|
9135
|
-
)
|
|
9136
|
-
);
|
|
9137
|
-
const failed2 = results.find((r) => r.error);
|
|
9138
|
-
if (failed2?.error) return c.json({ error: failed2.error.message }, 500);
|
|
9139
|
-
return c.json({ ok: true });
|
|
9140
|
-
});
|
|
9141
|
-
app.put("/:id", async (c) => {
|
|
9142
|
-
const id = c.req.param("id");
|
|
9143
|
-
const body = await c.req.json().catch(() => ({}));
|
|
9144
|
-
const name = typeof body["name"] === "string" ? body["name"].trim() : "";
|
|
9145
|
-
if (!name) return c.json({ errors: ["name is required"] }, 400);
|
|
9146
|
-
const { data, error: error2 } = await getSupabaseClient().from("categories").update({ name, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).eq("profile", getProfile()).select().single();
|
|
9147
|
-
if (error2) return c.json({ error: error2.message }, 404);
|
|
9148
|
-
return c.json(data);
|
|
9149
|
-
});
|
|
9150
|
-
app.delete("/:id", async (c) => {
|
|
9151
|
-
const id = c.req.param("id");
|
|
9152
|
-
const supabase = getSupabaseClient();
|
|
9153
|
-
const { error: unsetError } = await supabase.from("channels").update({ category_id: null, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("category_id", id);
|
|
9154
|
-
if (unsetError) return c.json({ error: unsetError.message }, 500);
|
|
9155
|
-
await refreshChannelRegistry();
|
|
9156
|
-
const { error: error2 } = await supabase.from("categories").delete().eq("id", id).eq("profile", getProfile());
|
|
9157
|
-
if (error2) return c.json({ error: error2.message }, 404);
|
|
9158
|
-
return c.json({ ok: true });
|
|
8959
|
+
async function revokeDevice2(id) {
|
|
8960
|
+
const { error: error2 } = await getSupabaseClient().from("devices").update({ revoked_at: (/* @__PURE__ */ new Date()).toISOString(), access_token_hash: null }).eq("id", id);
|
|
8961
|
+
if (error2) fail("revokeDevice", error2);
|
|
8962
|
+
}
|
|
8963
|
+
async function createChallenge(deviceId) {
|
|
8964
|
+
const db = getSupabaseClient();
|
|
8965
|
+
await db.from("device_challenges").delete().lt("expires_at", (/* @__PURE__ */ new Date()).toISOString());
|
|
8966
|
+
const nonce = randomBytes4(32).toString("base64url");
|
|
8967
|
+
const { error: error2 } = await db.from("device_challenges").insert({
|
|
8968
|
+
nonce,
|
|
8969
|
+
device_id: deviceId,
|
|
8970
|
+
expires_at: new Date(Date.now() + CHALLENGE_TTL_MS).toISOString()
|
|
9159
8971
|
});
|
|
9160
|
-
|
|
8972
|
+
if (error2) fail("createChallenge", error2);
|
|
8973
|
+
return nonce;
|
|
9161
8974
|
}
|
|
9162
|
-
|
|
9163
|
-
|
|
9164
|
-
|
|
9165
|
-
|
|
9166
|
-
// ../api/src/dashboard/fork-summary.ts
|
|
9167
|
-
var GROQ_API_URL3 = "https://api.groq.com/openai/v1/chat/completions";
|
|
9168
|
-
var SUMMARY_MODEL = "openai/gpt-oss-120b";
|
|
9169
|
-
var MAX_INPUT_CHARS2 = 24e3;
|
|
9170
|
-
var TITLE_MAX_CHARS = 80;
|
|
9171
|
-
var CONDENSE_SYSTEM_PROMPT = `You turn a chat conversation transcript into a new discussion topic where the work continues. Output EXACTLY two parts separated by one blank line: the first line is a concise topic title (max ${TITLE_MAX_CHARS} characters, no quotes, no Markdown), then a handoff summary of the conversation so far.
|
|
9172
|
-
|
|
9173
|
-
Rules:
|
|
9174
|
-
- Keep the conversation's own language. If it is Traditional Chinese (Taiwan), stay in Traditional Chinese with Taiwan wording and keep technical terms in English.
|
|
9175
|
-
- The summary opens a new thread where the work picks up: state the goal, what has been done or decided so far, and what remains open or should happen next.
|
|
9176
|
-
- Weight the END of the transcript more heavily: later messages reflect the current state. If an earlier plan or decision was later changed, superseded, or abandoned, summarize it in one clause at most and lead with what it became. Never present a discarded early approach with the same weight as the latest one.
|
|
9177
|
-
- Summarize the WORK, not the run mechanics: ignore status noise such as stopped/cancelled/terminated-run markers (e.g. "\u23F9 Stopped by user."), error or retry notices, and progress placeholders. A run being cut short is never part of the topic \u2014 capture the progress that was made instead.
|
|
9178
|
-
- Preserve essential code identifiers, file names, and URLs, but do not reproduce whole code blocks \u2014 describe what they do in one sentence instead.
|
|
9179
|
-
- No preamble like "Summary:" \u2014 start directly with the title line.`;
|
|
9180
|
-
var FOCUS_TITLE_SYSTEM_PROMPT = `You name a new discussion topic after one chat message that someone is branching off into a focused side-discussion.
|
|
9181
|
-
|
|
9182
|
-
Output ONE line only: the title. Nothing else \u2014 no summary, no explanation, no blank line.
|
|
9183
|
-
|
|
9184
|
-
Shape:
|
|
9185
|
-
- A short noun phrase naming the subject, like a filed ticket's title. Max ${TITLE_MAX_CHARS} characters, and shorter is better.
|
|
9186
|
-
- Not a sentence copied from the message, not a quote, not Markdown. No bold, no backticks, no quotes, no trailing punctuation.
|
|
9187
|
-
|
|
9188
|
-
Content:
|
|
9189
|
-
- Keep the message's own language. If it is Traditional Chinese (Taiwan), stay in Traditional Chinese with Taiwan wording and keep technical terms in English.
|
|
9190
|
-
- Name the topic after the FINAL message in the input. Any earlier message is only context for what that message replies to, never the subject of the title.
|
|
9191
|
-
- Name that message's central point. A message that diagnoses something is named after the finding, even when its last paragraph proposes a next step \u2014 do not title it after the proposed action.
|
|
9192
|
-
- Never reframe it into a different question, never assert a conclusion the message does not reach, and never turn an open investigation into a settled recommendation or a list of steps.
|
|
9193
|
-
|
|
9194
|
-
Example \u2014 a message that works out why an admin could not sign in, and closes by suggesting turning email confirmation off:
|
|
9195
|
-
admin \u5E33\u865F\u88AB email \u9A57\u8B49\u4FE1\u5361\u4F4F`;
|
|
9196
|
-
function clampTitle(title) {
|
|
9197
|
-
return title.length > TITLE_MAX_CHARS ? `${title.slice(0, TITLE_MAX_CHARS - 1)}\u2026` : title;
|
|
8975
|
+
async function consumeChallenge(deviceId, nonce) {
|
|
8976
|
+
const { data, error: error2 } = await getSupabaseClient().from("device_challenges").delete().eq("nonce", nonce).eq("device_id", deviceId).gt("expires_at", (/* @__PURE__ */ new Date()).toISOString()).select("nonce");
|
|
8977
|
+
if (error2) fail("consumeChallenge", error2);
|
|
8978
|
+
return (data ?? []).length > 0;
|
|
9198
8979
|
}
|
|
9199
|
-
function
|
|
9200
|
-
const
|
|
9201
|
-
|
|
8980
|
+
async function listAdminWorkspaces(userId) {
|
|
8981
|
+
const { data, error: error2 } = await getSupabaseClient().from("memberships").select("workspace_id, workspaces!inner(slug)").eq("user_id", userId).eq("role", "admin").eq("status", "active");
|
|
8982
|
+
if (error2) fail("listAdminWorkspaces", error2);
|
|
8983
|
+
return (data ?? []).map((r) => ({ id: r.workspace_id, slug: r.workspaces.slug })).sort((a, b) => a.slug.localeCompare(b.slug));
|
|
9202
8984
|
}
|
|
9203
|
-
function
|
|
9204
|
-
const
|
|
9205
|
-
|
|
9206
|
-
|
|
9207
|
-
);
|
|
8985
|
+
async function listDeviceWorkspaces(device) {
|
|
8986
|
+
const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("workspace_id").eq("device_id", device.id);
|
|
8987
|
+
if (error2) fail("listDeviceWorkspaces", error2);
|
|
8988
|
+
const bound = new Set((data ?? []).map((r) => r.workspace_id));
|
|
8989
|
+
return (await listAdminWorkspaces(device.userId)).filter((w) => bound.has(w.id));
|
|
9208
8990
|
}
|
|
9209
|
-
function
|
|
9210
|
-
|
|
8991
|
+
async function bindDeviceWorkspace(deviceId, workspaceId, boundBy) {
|
|
8992
|
+
const { error: error2 } = await getSupabaseClient().from("device_workspaces").upsert(
|
|
8993
|
+
{ device_id: deviceId, workspace_id: workspaceId, bound_by: boundBy },
|
|
8994
|
+
{ onConflict: "device_id,workspace_id", ignoreDuplicates: true }
|
|
8995
|
+
);
|
|
8996
|
+
if (error2) fail("bindDeviceWorkspace", error2);
|
|
9211
8997
|
}
|
|
9212
|
-
function
|
|
9213
|
-
const
|
|
9214
|
-
|
|
9215
|
-
|
|
9216
|
-
const title = text.slice(0, breakAt).trim();
|
|
9217
|
-
const body = text.slice(breakAt + 1).trim();
|
|
9218
|
-
if (!title || !body) return fallbackTopic(original);
|
|
9219
|
-
return {
|
|
9220
|
-
title: clampTitle(title),
|
|
9221
|
-
body
|
|
9222
|
-
};
|
|
8998
|
+
async function listWorkspaceDevices(workspaceId) {
|
|
8999
|
+
const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("devices!inner(*)").eq("workspace_id", workspaceId).is("devices.revoked_at", null);
|
|
9000
|
+
if (error2) fail("listWorkspaceDevices", error2);
|
|
9001
|
+
return (data ?? []).map((r) => toDevice(r.devices)).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
9223
9002
|
}
|
|
9224
|
-
|
|
9225
|
-
|
|
9226
|
-
|
|
9227
|
-
const
|
|
9228
|
-
|
|
9229
|
-
|
|
9230
|
-
|
|
9231
|
-
|
|
9232
|
-
|
|
9233
|
-
|
|
9234
|
-
|
|
9235
|
-
model: SUMMARY_MODEL,
|
|
9236
|
-
temperature: 0.2,
|
|
9237
|
-
// A title has to sort the message's central claim from the next-step
|
|
9238
|
-
// paragraph it often ends with; 'low' reliably grabs the latter.
|
|
9239
|
-
reasoning_effort: focus ? "medium" : "low",
|
|
9240
|
-
// A title is one short line, but focus mode reasons before answering
|
|
9241
|
-
// and the budget covers reasoning tokens too — too small a cap here
|
|
9242
|
-
// truncates the title mid-word rather than failing.
|
|
9243
|
-
max_tokens: focus ? 512 : 1024,
|
|
9244
|
-
messages: [
|
|
9245
|
-
{
|
|
9246
|
-
role: "system",
|
|
9247
|
-
content: focus ? FOCUS_TITLE_SYSTEM_PROMPT : CONDENSE_SYSTEM_PROMPT
|
|
9248
|
-
},
|
|
9249
|
-
{ role: "user", content: input2 }
|
|
9250
|
-
]
|
|
9251
|
-
})
|
|
9252
|
-
});
|
|
9253
|
-
if (!res.ok) throw new Error(`Groq request failed (${res.status})`);
|
|
9254
|
-
const data = await res.json();
|
|
9255
|
-
const raw = data.choices?.[0]?.message?.content?.trim();
|
|
9256
|
-
if (!raw) throw new Error("Groq returned an empty summary");
|
|
9257
|
-
if (focus) return { title: cleanTitleLine(raw) || deriveTitle(fallback), body: fallback };
|
|
9258
|
-
return parseTopic(raw, fallback);
|
|
9259
|
-
} catch (err) {
|
|
9260
|
-
console.error("Fork summary failed, using original text:", err);
|
|
9261
|
-
return fallbackTopic(fallback);
|
|
9003
|
+
var STALE_HOST_MS = 14 * 24 * 60 * 6e4;
|
|
9004
|
+
var isStale = (lastSeenAt) => !!lastSeenAt && Date.now() - Date.parse(lastSeenAt) > STALE_HOST_MS;
|
|
9005
|
+
async function listWorkspaceHosts(workspaceIds, exceptPublicKey) {
|
|
9006
|
+
const hosts2 = /* @__PURE__ */ new Map();
|
|
9007
|
+
if (!workspaceIds.length) return hosts2;
|
|
9008
|
+
const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("workspace_id, devices!inner(name, public_key, revoked_at, last_seen_at)").in("workspace_id", workspaceIds).is("devices.revoked_at", null);
|
|
9009
|
+
if (error2) fail("listWorkspaceHosts", error2);
|
|
9010
|
+
for (const r of data ?? []) {
|
|
9011
|
+
if (r.devices.public_key !== exceptPublicKey && !isStale(r.devices.last_seen_at)) {
|
|
9012
|
+
hosts2.set(r.workspace_id, r.devices.name);
|
|
9013
|
+
}
|
|
9262
9014
|
}
|
|
9015
|
+
return hosts2;
|
|
9263
9016
|
}
|
|
9264
|
-
|
|
9265
|
-
|
|
9266
|
-
|
|
9267
|
-
|
|
9268
|
-
|
|
9269
|
-
|
|
9270
|
-
|
|
9271
|
-
|
|
9272
|
-
|
|
9273
|
-
}
|
|
9274
|
-
function resolveSlackTokens(text) {
|
|
9275
|
-
return text.replace(/<@([A-Z0-9]+)(?:\|[^>]*)?>/g, (_m, id) => `@${getLocalCache().displayNameFor(id)}`).replace(/<#([A-Z0-9]+)\|([^>]+)>/g, (_m, _id, name) => `#${name}`).replace(/<#([A-Z0-9]+)>/g, (_m, id) => `#${getLocalCache().lookupChannelById(id)?.name ?? id}`).replace(/<!channel>/g, "@channel").replace(/<!here>/g, "@here").replace(/<!everyone>/g, "@everyone").replace(/<!subteam\^[A-Z0-9]+\|@?([^>]+)>/g, "@$1").replace(/<mailto:([^|>]+)\|([^>]+)>/g, (_m, addr, label2) => `[${label2}](mailto:${addr})`).replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, (_m, url, label2) => `[${label2}](${url})`).replace(/:[a-z0-9_+-]+:/gi, emojifyShortcode);
|
|
9017
|
+
async function releaseStaleHosts(workspaceIds, exceptPublicKey) {
|
|
9018
|
+
if (!workspaceIds.length) return;
|
|
9019
|
+
const db = getSupabaseClient();
|
|
9020
|
+
const { data, error: error2 } = await db.from("device_workspaces").select("device_id, workspace_id, devices!inner(public_key, last_seen_at)").in("workspace_id", workspaceIds);
|
|
9021
|
+
if (error2) fail("releaseStaleHosts", error2);
|
|
9022
|
+
for (const r of data ?? []) {
|
|
9023
|
+
if (r.devices.public_key === exceptPublicKey || !isStale(r.devices.last_seen_at)) continue;
|
|
9024
|
+
const { error: delError } = await db.from("device_workspaces").delete().eq("device_id", r.device_id).eq("workspace_id", r.workspace_id);
|
|
9025
|
+
if (delError) fail("releaseStaleHosts", delError);
|
|
9026
|
+
}
|
|
9276
9027
|
}
|
|
9277
|
-
function
|
|
9278
|
-
|
|
9028
|
+
async function userEmail(userId) {
|
|
9029
|
+
const { data, error: error2 } = await getSupabaseClient().auth.admin.getUserById(userId);
|
|
9030
|
+
if (error2) return null;
|
|
9031
|
+
return data.user?.email ?? null;
|
|
9279
9032
|
}
|
|
9280
9033
|
|
|
9281
|
-
// ../api/src/dashboard/
|
|
9282
|
-
|
|
9283
|
-
const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9284
|
-
const { data, error: error2 } = await supabase.rpc("relevant_threads", {
|
|
9285
|
-
handle,
|
|
9286
|
-
mention_pattern: `@${escaped}\\M`,
|
|
9287
|
-
channel_ids: channelIds,
|
|
9288
|
-
before: opts.before ?? null,
|
|
9289
|
-
page_limit: opts.limit
|
|
9290
|
-
});
|
|
9291
|
-
if (error2) throw new Error(`relevant_threads: ${error2.message}`);
|
|
9292
|
-
return data ?? [];
|
|
9293
|
-
}
|
|
9034
|
+
// ../api/src/dashboard/onboarding-routes.ts
|
|
9035
|
+
import { Hono as Hono7 } from "hono";
|
|
9294
9036
|
|
|
9295
|
-
// ../api/src/dashboard/
|
|
9296
|
-
import {
|
|
9037
|
+
// ../api/src/dashboard/agent-bridge-data.ts
|
|
9038
|
+
import { randomBytes as randomBytes5, randomUUID as randomUUID2, verify as verifyRaw } from "crypto";
|
|
9039
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
9297
9040
|
|
|
9298
|
-
// ../api/src/
|
|
9299
|
-
|
|
9300
|
-
|
|
9301
|
-
|
|
9302
|
-
|
|
9303
|
-
|
|
9304
|
-
if (error2) throw new Error(`getThreadTranslations(${lang}): ${error2.message}`);
|
|
9305
|
-
for (const row of data ?? []) {
|
|
9306
|
-
const replyId = row["reply_id"];
|
|
9307
|
-
found.set(replyId, {
|
|
9308
|
-
replyId,
|
|
9309
|
-
body: row["body"],
|
|
9310
|
-
passthrough: row["passthrough"]
|
|
9311
|
-
});
|
|
9312
|
-
}
|
|
9313
|
-
return found;
|
|
9041
|
+
// ../api/src/dashboard/host-identity.ts
|
|
9042
|
+
import { createPrivateKey as createPrivateKey2, createPublicKey as createPublicKey3, generateKeyPairSync as generateKeyPairSync2 } from "crypto";
|
|
9043
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync9, readFileSync as readFileSync16, writeFileSync as writeFileSync9 } from "fs";
|
|
9044
|
+
import { dirname as dirname5, join as join17 } from "path";
|
|
9045
|
+
function hostPrivateKeyPath() {
|
|
9046
|
+
return process.env["FLYSPACE_HOST_KEY_PATH"] || join17(paths.BASE_CONFIG_DIR, "host.key");
|
|
9314
9047
|
}
|
|
9315
|
-
|
|
9316
|
-
if (
|
|
9317
|
-
|
|
9318
|
-
|
|
9319
|
-
|
|
9320
|
-
|
|
9321
|
-
|
|
9322
|
-
body: r.body,
|
|
9323
|
-
passthrough: r.passthrough
|
|
9324
|
-
})),
|
|
9325
|
-
{ onConflict: "reply_id,lang,body_hash" }
|
|
9326
|
-
);
|
|
9327
|
-
if (error2) throw new Error(`putThreadTranslations(${lang}): ${error2.message}`);
|
|
9048
|
+
function loadHostPrivateKey(path = hostPrivateKeyPath()) {
|
|
9049
|
+
if (!existsSync17(path)) {
|
|
9050
|
+
throw new Error(
|
|
9051
|
+
`No Host identity at ${path}. Run \`pnpm run generate-host-key\` on this machine, then set FLYSPACE_HOST_PUBLIC_KEY on the Data process to the printed public key.`
|
|
9052
|
+
);
|
|
9053
|
+
}
|
|
9054
|
+
return createPrivateKey2(readFileSync16(path, "utf8"));
|
|
9328
9055
|
}
|
|
9329
9056
|
|
|
9330
|
-
// ../
|
|
9331
|
-
var
|
|
9332
|
-
var
|
|
9333
|
-
var
|
|
9334
|
-
var
|
|
9335
|
-
var
|
|
9336
|
-
var
|
|
9337
|
-
var
|
|
9338
|
-
|
|
9339
|
-
|
|
9340
|
-
|
|
9057
|
+
// ../core/src/bridge-protocol.ts
|
|
9058
|
+
var HOST_SOCKET_PATH = "/internal/host-socket";
|
|
9059
|
+
var DEFAULT_DATA_API_PORT = 4757;
|
|
9060
|
+
var CALL_TIMEOUT_MS = 15e3;
|
|
9061
|
+
var HEARTBEAT_INTERVAL_MS = 15e3;
|
|
9062
|
+
var HEARTBEAT_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * 2.5;
|
|
9063
|
+
var LOCAL_RELAY_PATH = "/internal/agent-call";
|
|
9064
|
+
var RELAYED_METHODS = [
|
|
9065
|
+
"onChatMessage",
|
|
9066
|
+
"isAgentThinking",
|
|
9067
|
+
"getAgentActivities",
|
|
9068
|
+
"cancelAgentRun",
|
|
9069
|
+
"regenerateAgentRun",
|
|
9070
|
+
"listAgentRuns"
|
|
9071
|
+
];
|
|
9072
|
+
function encodeWireMessage(msg) {
|
|
9073
|
+
return JSON.stringify(msg);
|
|
9341
9074
|
}
|
|
9342
|
-
function
|
|
9343
|
-
|
|
9344
|
-
|
|
9345
|
-
|
|
9075
|
+
function parseWireMessage(raw) {
|
|
9076
|
+
try {
|
|
9077
|
+
return JSON.parse(String(raw));
|
|
9078
|
+
} catch {
|
|
9079
|
+
return null;
|
|
9080
|
+
}
|
|
9346
9081
|
}
|
|
9347
|
-
function systemPrompt2(languageName2) {
|
|
9348
|
-
return `You translate messages from a group chat into ${languageName2}. You are a translator, not an editor, a summarizer, or a participant.
|
|
9349
9082
|
|
|
9350
|
-
|
|
9351
|
-
|
|
9352
|
-
|
|
9353
|
-
|
|
9354
|
-
|
|
9355
|
-
|
|
9356
|
-
|
|
9357
|
-
Rules:
|
|
9358
|
-
- "already_target" is true when the message is ALREADY written in ${languageName2}. In that case copy "body" through byte for byte, changing nothing at all. A message whose prose is in another language is NOT already_target just because it contains English technical terms, code, or product names.
|
|
9359
|
-
- Translate meaning, not words. These are people talking, not prose to be polished. Keep the speaker's register: a terse message stays terse, a casual one stays casual, a rude one stays rude. Do not make anyone sound more formal, more complete, or more articulate than they wrote.
|
|
9360
|
-
- A short reply ("\u597D\u554A", "same", "nope") is translated as the natural equivalent in ${languageName2}, using the context to see what it is answering. Never expand it into a full sentence the speaker did not write.
|
|
9361
|
-
- Keep the Markdown exactly as it is: same heading levels, list nesting, table shape, link and image syntax, blockquotes, and order. Do not add, merge, split, or reorder anything.
|
|
9362
|
-
- Do NOT translate: text inside fenced or inline code blocks, URLs, file paths, command names, or @mentions and /skill tokens. Leave them byte-for-byte identical.
|
|
9363
|
-
- Keep established technical terms in English where a fluent speaker of ${languageName2} would keep them in English rather than reaching for a translated coinage.
|
|
9364
|
-
- Use the punctuation conventions of ${languageName2}, including full-width punctuation for Chinese and Japanese. Use ordinary ASCII hyphens, never typographic or non-breaking hyphen variants.
|
|
9365
|
-
- Never add commentary, notes about your choices, apologies, or an explanation of anything you could not translate.`;
|
|
9366
|
-
}
|
|
9367
|
-
function normalizeHyphens2(text) {
|
|
9368
|
-
return text.replace(/‐|‑/g, "-");
|
|
9083
|
+
// ../api/src/dashboard/agent-bridge-data.ts
|
|
9084
|
+
var hosts = /* @__PURE__ */ new Map();
|
|
9085
|
+
var hostStatusListeners = /* @__PURE__ */ new Set();
|
|
9086
|
+
function notifyHostStatus() {
|
|
9087
|
+
const online = hosts.size > 0;
|
|
9088
|
+
for (const l of hostStatusListeners) l(online);
|
|
9369
9089
|
}
|
|
9370
|
-
function
|
|
9371
|
-
|
|
9090
|
+
function removeHost(ws) {
|
|
9091
|
+
if (hosts.delete(ws)) notifyHostStatus();
|
|
9372
9092
|
}
|
|
9373
|
-
function
|
|
9374
|
-
const
|
|
9375
|
-
|
|
9376
|
-
|
|
9377
|
-
|
|
9378
|
-
|
|
9379
|
-
if (current.length > 0 && size + len > BATCH_CHAR_BUDGET) {
|
|
9380
|
-
batches.push(current);
|
|
9381
|
-
current = [];
|
|
9382
|
-
size = 0;
|
|
9383
|
-
}
|
|
9384
|
-
current.push(m);
|
|
9385
|
-
size += len;
|
|
9093
|
+
function disconnectHostDevice(deviceId) {
|
|
9094
|
+
for (const [ws, scope] of hosts) {
|
|
9095
|
+
if (scope.deviceId !== deviceId) continue;
|
|
9096
|
+
console.log(`[host-socket] closing connection for revoked device ${deviceId}`);
|
|
9097
|
+
removeHost(ws);
|
|
9098
|
+
ws.close(1008, "device revoked");
|
|
9386
9099
|
}
|
|
9387
|
-
if (current.length > 0) batches.push(current);
|
|
9388
|
-
return batches;
|
|
9389
9100
|
}
|
|
9390
|
-
|
|
9391
|
-
|
|
9392
|
-
|
|
9393
|
-
|
|
9394
|
-
|
|
9395
|
-
|
|
9396
|
-
|
|
9397
|
-
|
|
9398
|
-
|
|
9399
|
-
|
|
9101
|
+
var LAST_SEEN_RESOLUTION_MS = 60 * 6e4;
|
|
9102
|
+
var lastCallError = /* @__PURE__ */ new Map();
|
|
9103
|
+
function getHostCallError(method, profile = getProfile()) {
|
|
9104
|
+
return lastCallError.get(`${profile}:${method}`) ?? null;
|
|
9105
|
+
}
|
|
9106
|
+
|
|
9107
|
+
// ../api/src/dashboard/unique-channel-name.ts
|
|
9108
|
+
function uniqueChannelName(raw, taken) {
|
|
9109
|
+
const base = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
|
|
9110
|
+
let name = base;
|
|
9111
|
+
for (let i = 2; taken.has(name); i += 1) name = `${base}-${i}`;
|
|
9112
|
+
taken.add(name);
|
|
9113
|
+
return name;
|
|
9114
|
+
}
|
|
9115
|
+
|
|
9116
|
+
// ../api/src/dashboard/workspace-greeting.ts
|
|
9117
|
+
var GROQ_API_URL3 = "https://api.groq.com/openai/v1/chat/completions";
|
|
9118
|
+
var GREETING_MODEL = "openai/gpt-oss-20b";
|
|
9119
|
+
var TIMEOUT_MS = 6e3;
|
|
9120
|
+
var MAX_LISTED_CHANNELS = 8;
|
|
9121
|
+
var GUIDES = [
|
|
9122
|
+
"Welcome to Flyspace",
|
|
9123
|
+
"Working with the agent",
|
|
9124
|
+
"Channels and folders",
|
|
9125
|
+
"Docs",
|
|
9126
|
+
"Sharing docs",
|
|
9127
|
+
"Inviting your team",
|
|
9128
|
+
"Automations"
|
|
9129
|
+
];
|
|
9130
|
+
var SYSTEM_PROMPT = `You write the first message a new Flyspace workspace owner sees, posted in their #general channel. Flyspace is a team chat where an AI agent works inside every channel.
|
|
9131
|
+
|
|
9132
|
+
- Plain Markdown, 60 to 110 words, in English. No headings, no emoji, no em or en dashes, no sign-off.
|
|
9133
|
+
- Greet the owner by name and name the workspace.
|
|
9134
|
+
- Say that #general is public: everyone invited to the workspace can see it.
|
|
9135
|
+
- If project channels are listed, mention them as #name and say each one is linked to that project folder. Never invent a channel.
|
|
9136
|
+
- Mention the trial or the missing Host only if the facts say so.
|
|
9137
|
+
- If guides are listed, say they are in the Docs pane of #general and suggest starting with the first one.
|
|
9138
|
+
- Use only the facts given. Do not add buttons or a list of next steps: those are appended for you.`;
|
|
9139
|
+
function greetingButtons(input2) {
|
|
9140
|
+
const buttons = [];
|
|
9141
|
+
if (!input2.hostConnected) buttons.push("Connect a machine");
|
|
9142
|
+
if (input2.channels[0]) buttons.push(`Summarise #${input2.channels[0]} for me`);
|
|
9143
|
+
if (input2.hostConnected) buttons.push("Give me a quick tour of the guides");
|
|
9144
|
+
if (input2.memberCount <= 1) buttons.push("Invite my team");
|
|
9145
|
+
buttons.push("Set up a daily digest");
|
|
9146
|
+
return buttons.slice(0, 3).map((b) => b.replace(/[|\]]/g, ""));
|
|
9147
|
+
}
|
|
9148
|
+
function fallbackGreeting(input2) {
|
|
9149
|
+
const channels = input2.channels.slice(0, MAX_LISTED_CHANNELS).map((c) => `#${c}`);
|
|
9150
|
+
return [
|
|
9151
|
+
`Welcome to ${input2.workspaceName}, ${input2.ownerName}.`,
|
|
9152
|
+
"This is #general. It is public, so everyone you invite to the workspace can see it.",
|
|
9153
|
+
channels.length ? `Your project channels are ready: ${channels.join(", ")}. Each one is linked to its project folder, so the agent there works on the real files.` : "",
|
|
9154
|
+
input2.hostConnected ? "A few short guides are waiting in the Docs pane of #general. Start with Welcome to Flyspace." : "",
|
|
9155
|
+
"Mention the agent in any channel to get started."
|
|
9156
|
+
].filter(Boolean).join("\n\n");
|
|
9157
|
+
}
|
|
9158
|
+
async function groqGreeting(input2, apiKey) {
|
|
9159
|
+
const facts = [
|
|
9160
|
+
`Owner: ${input2.ownerName}`,
|
|
9161
|
+
`Workspace: ${input2.workspaceName}`,
|
|
9162
|
+
`Plan: ${input2.trialing ? "14-day free trial, just started" : "not on a trial"}`,
|
|
9163
|
+
`Host: ${input2.hostConnected ? "a machine is connected, agents can run" : "no machine connected yet, agents cannot run until one is"}`,
|
|
9164
|
+
`Project channels: ${input2.channels.slice(0, MAX_LISTED_CHANNELS).join(", ") || "(none yet)"}`,
|
|
9165
|
+
`Members: ${input2.memberCount}`,
|
|
9166
|
+
`Guides in #general: ${input2.hostConnected ? GUIDES.join(", ") : "(none, no machine connected)"}`
|
|
9167
|
+
].join("\n");
|
|
9168
|
+
const res = await fetch(GROQ_API_URL3, {
|
|
9400
9169
|
method: "POST",
|
|
9170
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
9401
9171
|
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
9402
9172
|
body: JSON.stringify({
|
|
9403
|
-
model:
|
|
9404
|
-
temperature: 0,
|
|
9405
|
-
|
|
9406
|
-
|
|
9173
|
+
model: GREETING_MODEL,
|
|
9174
|
+
temperature: 0.6,
|
|
9175
|
+
// Reasoning model: the cap covers reasoning tokens too, so keep it roomy.
|
|
9176
|
+
reasoning_effort: "low",
|
|
9177
|
+
max_tokens: 900,
|
|
9407
9178
|
messages: [
|
|
9408
|
-
{ role: "system", content:
|
|
9409
|
-
{ role: "user", content:
|
|
9179
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
9180
|
+
{ role: "user", content: facts }
|
|
9410
9181
|
]
|
|
9411
9182
|
})
|
|
9412
9183
|
});
|
|
9413
|
-
if (!res.ok) throw new Error(`
|
|
9184
|
+
if (!res.ok) throw new Error(`Groq request failed (${res.status})`);
|
|
9414
9185
|
const data = await res.json();
|
|
9415
|
-
const
|
|
9416
|
-
|
|
9417
|
-
|
|
9418
|
-
|
|
9419
|
-
|
|
9420
|
-
|
|
9421
|
-
|
|
9422
|
-
|
|
9423
|
-
|
|
9424
|
-
|
|
9425
|
-
|
|
9426
|
-
|
|
9427
|
-
const byNumber = /* @__PURE__ */ new Map();
|
|
9428
|
-
for (const entry of parsed.messages ?? []) {
|
|
9429
|
-
if (typeof entry?.n === "number") byNumber.set(entry.n, entry);
|
|
9186
|
+
const text = data.choices?.[0]?.message?.content?.replace(/\[BUTTONS:[^\]]*\]/g, "").replace(/\s*[–—]\s*/g, ", ").trim();
|
|
9187
|
+
return text || null;
|
|
9188
|
+
}
|
|
9189
|
+
async function buildGreeting(input2) {
|
|
9190
|
+
let text = null;
|
|
9191
|
+
const apiKey = process.env["GROQ_API_KEY"];
|
|
9192
|
+
if (apiKey) {
|
|
9193
|
+
try {
|
|
9194
|
+
text = await groqGreeting(input2, apiKey);
|
|
9195
|
+
} catch (err) {
|
|
9196
|
+
console.error("Workspace greeting failed, using the fixed template:", err);
|
|
9197
|
+
}
|
|
9430
9198
|
}
|
|
9431
|
-
return
|
|
9432
|
-
|
|
9433
|
-
|
|
9434
|
-
if (!body) return { id: m.id, body: m.body, passthrough: true };
|
|
9435
|
-
return {
|
|
9436
|
-
id: m.id,
|
|
9437
|
-
body: normalizeHyphens2(body),
|
|
9438
|
-
passthrough: entry?.already_target === true
|
|
9439
|
-
};
|
|
9440
|
-
});
|
|
9199
|
+
return `${text ?? fallbackGreeting(input2)}
|
|
9200
|
+
|
|
9201
|
+
[BUTTONS: ${greetingButtons(input2).join(" | ")}]`;
|
|
9441
9202
|
}
|
|
9442
|
-
|
|
9443
|
-
|
|
9444
|
-
|
|
9445
|
-
|
|
9446
|
-
|
|
9447
|
-
|
|
9448
|
-
|
|
9449
|
-
|
|
9450
|
-
|
|
9451
|
-
|
|
9452
|
-
|
|
9453
|
-
|
|
9454
|
-
|
|
9455
|
-
const
|
|
9456
|
-
|
|
9457
|
-
const
|
|
9458
|
-
if (
|
|
9459
|
-
|
|
9460
|
-
|
|
9461
|
-
|
|
9462
|
-
|
|
9463
|
-
|
|
9464
|
-
|
|
9465
|
-
|
|
9466
|
-
|
|
9467
|
-
|
|
9468
|
-
|
|
9469
|
-
|
|
9470
|
-
|
|
9471
|
-
|
|
9472
|
-
|
|
9473
|
-
|
|
9474
|
-
|
|
9475
|
-
|
|
9476
|
-
|
|
9477
|
-
|
|
9203
|
+
|
|
9204
|
+
// ../api/src/dashboard/onboarding-routes.ts
|
|
9205
|
+
var MAX_FOLDERS = 20;
|
|
9206
|
+
var CHANNEL_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
9207
|
+
function isWorkspaceOwner(ctx) {
|
|
9208
|
+
return ctx?.role === "admin" && !ctx.isSupport;
|
|
9209
|
+
}
|
|
9210
|
+
function hostUnavailable(c, agent, method) {
|
|
9211
|
+
return c.json({ error: "host offline", reason: agent.getHostCallFailure?.(method) ?? "offline" }, 503);
|
|
9212
|
+
}
|
|
9213
|
+
function createOnboardingRouter(agent) {
|
|
9214
|
+
const app = new Hono7();
|
|
9215
|
+
app.get("/scan", async (c) => {
|
|
9216
|
+
const ctx = await resolveAuthContext(c);
|
|
9217
|
+
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
9218
|
+
const scan = await agent.scanOnboarding?.(getProfile());
|
|
9219
|
+
if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
|
|
9220
|
+
return c.json(scan);
|
|
9221
|
+
});
|
|
9222
|
+
app.post("/channels", async (c) => {
|
|
9223
|
+
const ctx = await resolveAuthContext(c);
|
|
9224
|
+
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
9225
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9226
|
+
const runner = body["agent"];
|
|
9227
|
+
const folders = Array.isArray(body["folders"]) ? body["folders"].filter((f) => typeof f === "string" && f.length > 0) : [];
|
|
9228
|
+
if (folders.length > MAX_FOLDERS) return c.json({ error: `at most ${MAX_FOLDERS} folders` }, 400);
|
|
9229
|
+
const scan = await agent.scanOnboarding?.();
|
|
9230
|
+
if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
|
|
9231
|
+
const offered = new Map(scan.projects.map((p) => [p.path, p]));
|
|
9232
|
+
const unknown = folders.find((f) => !offered.has(f));
|
|
9233
|
+
if (unknown) return c.json({ error: "folder was not offered by the scan" }, 400);
|
|
9234
|
+
const taken = new Set(getProfileChannelRegistry().map((ch) => ch.name));
|
|
9235
|
+
taken.add("general");
|
|
9236
|
+
const items = [{ name: "general" }];
|
|
9237
|
+
for (const path of folders) items.push({ name: uniqueChannelName(offered.get(path).name, taken), path });
|
|
9238
|
+
const linked = await agent.linkProjectFolders?.(getProfile(), items);
|
|
9239
|
+
if (!linked) return hostUnavailable(c, agent, "linkProjectFolders");
|
|
9240
|
+
if (runner === "claude" || runner === "codex") {
|
|
9241
|
+
await agent.saveLocalDashboardSettings?.({ providers: [{ runner }] });
|
|
9242
|
+
}
|
|
9243
|
+
const created = [];
|
|
9244
|
+
for (const l of linked) {
|
|
9245
|
+
created.push(
|
|
9246
|
+
l.name === "general" ? await ensureGeneralChannel() : await createChannel({ name: l.name, cwd: l.cwd })
|
|
9478
9247
|
);
|
|
9479
9248
|
}
|
|
9480
|
-
|
|
9481
|
-
|
|
9482
|
-
|
|
9483
|
-
|
|
9249
|
+
await markWorkspaceProjectsSetup(ctx.workspaceId);
|
|
9250
|
+
return c.json({ channels: created.map((ch) => ({ id: ch.id, name: ch.name })) }, 201);
|
|
9251
|
+
});
|
|
9252
|
+
app.post("/link-folder", async (c) => {
|
|
9253
|
+
const ctx = await resolveAuthContext(c);
|
|
9254
|
+
if (!isWorkspaceOwner(ctx)) return c.json({ error: "workspace owner access required" }, 403);
|
|
9255
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9256
|
+
const path = typeof body["path"] === "string" ? body["path"].trim() : "";
|
|
9257
|
+
const name = typeof body["name"] === "string" ? body["name"].trim() : "";
|
|
9258
|
+
if (!path || !CHANNEL_NAME_RE.test(name)) return c.json({ error: "path and a valid name are required" }, 400);
|
|
9259
|
+
if (getProfileChannelRegistry().some((ch) => ch.name === name)) {
|
|
9260
|
+
return c.json({ error: `#${name} already exists` }, 409);
|
|
9261
|
+
}
|
|
9262
|
+
if (body["manual"] === true) {
|
|
9263
|
+
const devices = await listWorkspaceDevices(ctx.workspaceId);
|
|
9264
|
+
if (!devices.some((d) => d.userId === ctx.userId)) {
|
|
9265
|
+
return c.json({ error: "a path can only be typed on your own machine" }, 403);
|
|
9266
|
+
}
|
|
9267
|
+
} else {
|
|
9268
|
+
const scan = await agent.scanOnboarding?.(getProfile());
|
|
9269
|
+
if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
|
|
9270
|
+
if (!scan.projects.some((p) => p.path === path)) {
|
|
9271
|
+
return c.json({ error: "folder was not offered by the scan" }, 400);
|
|
9272
|
+
}
|
|
9273
|
+
}
|
|
9274
|
+
const linked = await agent.linkProjectFolders?.(getProfile(), [{ name, path }]);
|
|
9275
|
+
if (!linked) {
|
|
9276
|
+
const hostError = getHostCallError("linkProjectFolders");
|
|
9277
|
+
if (hostError) return c.json({ error: hostError }, 409);
|
|
9278
|
+
return hostUnavailable(c, agent, "linkProjectFolders");
|
|
9279
|
+
}
|
|
9280
|
+
return c.json({ cwd: linked[0].cwd });
|
|
9281
|
+
});
|
|
9282
|
+
app.post("/reset", async (c) => {
|
|
9283
|
+
const ctx = await resolveAuthContext(c);
|
|
9284
|
+
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
9285
|
+
const { error: error2 } = await getSupabaseClient().from("threads").delete().eq("client_key", ctx.workspaceId);
|
|
9286
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
9287
|
+
await resetWorkspaceOnboarding(ctx.workspaceId);
|
|
9288
|
+
return c.json({ ok: true });
|
|
9289
|
+
});
|
|
9290
|
+
app.get("/greeting", async (c) => {
|
|
9291
|
+
const ctx = await resolveAuthContext(c);
|
|
9292
|
+
if (ctx?.role !== "admin") return c.json({ pending: false });
|
|
9293
|
+
const greetedAt = await getWorkspaceGreetedAt(ctx.workspaceId);
|
|
9294
|
+
const pending2 = !!ctx.projectsSetupAt && !greetedAt;
|
|
9295
|
+
if (!pending2) return c.json({ pending: pending2, channelId: null, threadId: null });
|
|
9296
|
+
const general = await ensureGeneralChannel();
|
|
9297
|
+
const threadId = await ensureGreetingThread(ctx.workspaceId, general.id, ctx.name?.trim() || ctx.handle);
|
|
9298
|
+
return c.json({ pending: pending2, channelId: general.id, threadId });
|
|
9299
|
+
});
|
|
9300
|
+
app.post("/greeting", async (c) => {
|
|
9301
|
+
const ctx = await resolveAuthContext(c);
|
|
9302
|
+
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
9303
|
+
const supabase = getSupabaseClient();
|
|
9304
|
+
const general = await ensureGeneralChannel();
|
|
9305
|
+
const ownerName = ctx.name?.trim() || ctx.handle;
|
|
9306
|
+
const threadId = await ensureGreetingThread(ctx.workspaceId, general.id, ownerName);
|
|
9307
|
+
if (!threadId) return c.json({ error: "could not create the welcome thread" }, 500);
|
|
9308
|
+
const done = { channelId: general.id, threadId };
|
|
9309
|
+
const replied = await supabase.from("thread_replies").select("id").eq("thread_id", threadId).limit(1).maybeSingle();
|
|
9310
|
+
if (replied.data) return c.json(done);
|
|
9311
|
+
if (await getWorkspaceGreetedAt(ctx.workspaceId))
|
|
9312
|
+
return c.json({ error: "already greeted" }, 409);
|
|
9313
|
+
const [branding, billing, members] = await Promise.all([
|
|
9314
|
+
getWorkspaceBranding(getProfile()).catch(() => null),
|
|
9315
|
+
getWorkspaceBilling(ctx.workspaceId).catch(() => null),
|
|
9316
|
+
listMemberships(ctx.workspaceId).catch(() => [])
|
|
9317
|
+
]);
|
|
9318
|
+
const body = await buildGreeting({
|
|
9319
|
+
ownerName,
|
|
9320
|
+
workspaceName: branding?.displayName || getProfile(),
|
|
9321
|
+
trialing: billing?.planStatus === "trialing",
|
|
9322
|
+
hostConnected: !!ctx.agentConnectedAt,
|
|
9323
|
+
channels: getProfileChannelRegistry().filter((ch) => !ch.isDefault && !ch.dmKey && !ch.agentName && !ch.archivedAt).map((ch) => ch.name),
|
|
9324
|
+
memberCount: members.filter((m) => !m.isSupport).length
|
|
9484
9325
|
});
|
|
9485
|
-
|
|
9486
|
-
|
|
9487
|
-
|
|
9488
|
-
}
|
|
9489
|
-
|
|
9490
|
-
|
|
9491
|
-
|
|
9492
|
-
return
|
|
9493
|
-
}
|
|
9494
|
-
function sanitizeModel(v) {
|
|
9495
|
-
if (typeof v !== "string") return null;
|
|
9496
|
-
const model = v.trim().toLowerCase();
|
|
9497
|
-
return isAllowedModel(model) ? model : null;
|
|
9326
|
+
const again = await supabase.from("thread_replies").select("id").eq("thread_id", threadId).limit(1).maybeSingle();
|
|
9327
|
+
if (again.data) return c.json(done);
|
|
9328
|
+
const reply = await supabase.from("thread_replies").insert({ thread_id: threadId, author: AGENT_AUTHOR, body });
|
|
9329
|
+
if (reply.error) return c.json({ error: reply.error.message }, 500);
|
|
9330
|
+
await markWorkspaceGreeted(ctx.workspaceId);
|
|
9331
|
+
return c.json(done, 201);
|
|
9332
|
+
});
|
|
9333
|
+
return app;
|
|
9498
9334
|
}
|
|
9499
|
-
async function
|
|
9500
|
-
const
|
|
9501
|
-
|
|
9335
|
+
async function ensureGreetingThread(workspaceId, channelId, ownerName) {
|
|
9336
|
+
const supabase = getSupabaseClient();
|
|
9337
|
+
const existing = () => supabase.from("threads").select("id").eq("client_key", workspaceId).maybeSingle();
|
|
9338
|
+
const found = await existing();
|
|
9339
|
+
if (found.data) return found.data.id;
|
|
9340
|
+
const { data, error: error2 } = await supabase.from("threads").insert({
|
|
9341
|
+
channel_id: channelId,
|
|
9342
|
+
title: `Welcome to your Flyspace, ${ownerName}!`,
|
|
9343
|
+
author: AGENT_AUTHOR,
|
|
9344
|
+
client_key: workspaceId
|
|
9345
|
+
}).select("id").single();
|
|
9346
|
+
if (data) return data.id;
|
|
9347
|
+
return error2?.code === "23505" ? (await existing()).data?.id ?? null : null;
|
|
9502
9348
|
}
|
|
9503
|
-
|
|
9504
|
-
|
|
9349
|
+
|
|
9350
|
+
// ../api/src/dashboard/auth-routes.ts
|
|
9351
|
+
function createAuthRouter() {
|
|
9352
|
+
const auth = new Hono8();
|
|
9353
|
+
const emailedLink = (deliver) => async (c) => {
|
|
9354
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9355
|
+
const email = body.email?.trim().toLowerCase();
|
|
9356
|
+
if (!email) return c.json({ error: "email is required" }, 400);
|
|
9357
|
+
if (!isDevHost(c) && !await verifyTurnstileToken(body.captchaToken)) {
|
|
9358
|
+
return c.json({ error: "captcha check failed \u2014 please retry" }, 400);
|
|
9359
|
+
}
|
|
9360
|
+
const workspace = await getWorkspaceBySlug(getProfile());
|
|
9361
|
+
if (!workspace) return c.json({ error: "unknown workspace" }, 404);
|
|
9362
|
+
const membership = await getMembershipByEmail(workspace.id, email);
|
|
9363
|
+
if (!membership) {
|
|
9364
|
+
return c.json({ error: "no access \u2014 ask an admin to invite you" }, 403);
|
|
9365
|
+
}
|
|
9366
|
+
try {
|
|
9367
|
+
await deliver(email, callbackUrl(c));
|
|
9368
|
+
} catch (err) {
|
|
9369
|
+
return c.json({ error: err instanceof Error ? err.message : "could not send" }, 500);
|
|
9370
|
+
}
|
|
9371
|
+
return c.json({ sent: true });
|
|
9372
|
+
};
|
|
9373
|
+
auth.post("/login", emailedLink(deliverSignInLink));
|
|
9374
|
+
auth.post("/forgot-password", emailedLink(deliverPasswordResetLink));
|
|
9375
|
+
auth.get("/session", async (c) => {
|
|
9376
|
+
const ctx = await resolveAuthContext(c, { fresh: c.req.query("fresh") === "1" });
|
|
9377
|
+
if (!ctx) return c.json({ error: "not signed in" }, 401);
|
|
9378
|
+
return c.json({
|
|
9379
|
+
email: ctx.email,
|
|
9380
|
+
handle: ctx.handle,
|
|
9381
|
+
role: ctx.role,
|
|
9382
|
+
name: ctx.name,
|
|
9383
|
+
avatarUrl: ctx.isSupport ? SUPPORT_AVATAR_URL : ctx.avatarKey ? await signAvatarUrl(ctx.avatarKey) : null,
|
|
9384
|
+
onboarded: ctx.onboarded,
|
|
9385
|
+
passwordSet: ctx.passwordSet,
|
|
9386
|
+
agentConnectedAt: ctx.agentConnectedAt,
|
|
9387
|
+
plan: ctx.plan ?? null,
|
|
9388
|
+
onboarding: { pending: onboardingPending(ctx) },
|
|
9389
|
+
owner: isWorkspaceOwner(ctx),
|
|
9390
|
+
// Only the Host's owner can run `flyspace upgrade` on it, so other
|
|
9391
|
+
// admins never see the upgrade banner. Support staff can tell them to.
|
|
9392
|
+
hostOwner: ctx.role === "admin" && (ctx.isSupport === true || (await listWorkspaceDevices(ctx.workspaceId)).some((d) => d.userId === ctx.userId))
|
|
9393
|
+
});
|
|
9394
|
+
});
|
|
9395
|
+
auth.patch("/handle", async (c) => {
|
|
9396
|
+
const ctx = await resolveAuthContext(c);
|
|
9397
|
+
if (!ctx) return c.json({ error: "not signed in" }, 401);
|
|
9398
|
+
if (ctx.onboarded) return c.json({ error: "handle can no longer be changed" }, 403);
|
|
9399
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9400
|
+
const handle = body.handle?.trim().replace(/^@/, "").toLowerCase() ?? "";
|
|
9401
|
+
if (!HANDLE_PATTERN.test(handle)) {
|
|
9402
|
+
return c.json(
|
|
9403
|
+
{ error: "handle must be 1-32 characters: a-z, 0-9, _ or -, starting with a letter or digit" },
|
|
9404
|
+
400
|
|
9405
|
+
);
|
|
9406
|
+
}
|
|
9407
|
+
try {
|
|
9408
|
+
await setMembershipHandle(ctx.workspaceId, ctx.userId, handle);
|
|
9409
|
+
} catch (err) {
|
|
9410
|
+
if (err instanceof HandleTakenError) return c.json({ error: err.message }, 409);
|
|
9411
|
+
throw err;
|
|
9412
|
+
}
|
|
9413
|
+
return c.json({ handle });
|
|
9414
|
+
});
|
|
9415
|
+
auth.post("/logout", async (c) => {
|
|
9416
|
+
const supabase = createRequestSupabaseClient(c);
|
|
9417
|
+
await supabase.auth.signOut();
|
|
9418
|
+
return c.json({ ok: true });
|
|
9419
|
+
});
|
|
9420
|
+
return auth;
|
|
9505
9421
|
}
|
|
9506
|
-
|
|
9507
|
-
|
|
9508
|
-
|
|
9509
|
-
|
|
9422
|
+
|
|
9423
|
+
// ../api/src/dashboard/auth-middleware.ts
|
|
9424
|
+
var requireWorkspaceMembership = async (c, next) => {
|
|
9425
|
+
const ctx = await resolveAuthContext(c);
|
|
9426
|
+
if (!ctx) return c.json({ error: "not signed in" }, 401);
|
|
9427
|
+
c.set("auth", ctx);
|
|
9428
|
+
return next();
|
|
9429
|
+
};
|
|
9430
|
+
|
|
9431
|
+
// ../api/src/dashboard/billing-routes.ts
|
|
9432
|
+
import { Hono as Hono9 } from "hono";
|
|
9433
|
+
function isPlan(value) {
|
|
9434
|
+
return value === "local" || value === "cloud";
|
|
9510
9435
|
}
|
|
9511
|
-
|
|
9512
|
-
|
|
9436
|
+
var LOCK_EXEMPT = [/\/billing(\/|$)/, /\/profiles?$/, /\/push\//];
|
|
9437
|
+
async function requireActivePlan(c, next) {
|
|
9438
|
+
if (c.req.method === "OPTIONS") return next();
|
|
9439
|
+
if (LOCK_EXEMPT.some((re) => re.test(c.req.path))) return next();
|
|
9440
|
+
const auth = c.get("auth");
|
|
9441
|
+
if (!auth) return next();
|
|
9442
|
+
if (isLockedPlan(await getWorkspacePlanStatus(auth.workspaceId))) {
|
|
9443
|
+
return c.json({ error: "trial ended: add a card to reactivate this workspace", code: "plan_locked" }, 402);
|
|
9444
|
+
}
|
|
9445
|
+
return next();
|
|
9513
9446
|
}
|
|
9514
|
-
function
|
|
9515
|
-
|
|
9516
|
-
|
|
9517
|
-
|
|
9518
|
-
|
|
9519
|
-
|
|
9520
|
-
|
|
9521
|
-
|
|
9522
|
-
|
|
9523
|
-
|
|
9524
|
-
|
|
9525
|
-
|
|
9526
|
-
|
|
9527
|
-
|
|
9528
|
-
|
|
9447
|
+
function createBillingRouter() {
|
|
9448
|
+
const billing = new Hono9();
|
|
9449
|
+
billing.get("/", async (c) => {
|
|
9450
|
+
const auth = c.get("auth");
|
|
9451
|
+
const { planStatus, stripeSubscriptionId } = await getWorkspaceBilling(auth.workspaceId);
|
|
9452
|
+
let trialEnd = null;
|
|
9453
|
+
let plan = null;
|
|
9454
|
+
if (stripeSubscriptionId) {
|
|
9455
|
+
try {
|
|
9456
|
+
const sub = await getStripeClient().subscriptions.retrieve(stripeSubscriptionId);
|
|
9457
|
+
if (planStatus === "trialing" && sub.trial_end) {
|
|
9458
|
+
trialEnd = new Date(sub.trial_end * 1e3).toISOString();
|
|
9459
|
+
}
|
|
9460
|
+
if (isPlan(sub.metadata["plan"])) plan = PLAN_INFO[sub.metadata["plan"]];
|
|
9461
|
+
} catch (err) {
|
|
9462
|
+
console.warn("[billing] subscription lookup failed:", err);
|
|
9463
|
+
}
|
|
9464
|
+
}
|
|
9465
|
+
return c.json({ planStatus, trialEnd, plan, locked: isLockedPlan(planStatus) });
|
|
9466
|
+
});
|
|
9467
|
+
billing.post("/checkout", async (c) => {
|
|
9468
|
+
const auth = c.get("auth");
|
|
9469
|
+
if (auth.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
9470
|
+
const { stripeCustomerId, stripeSubscriptionId } = await getWorkspaceBilling(auth.workspaceId);
|
|
9471
|
+
if (!stripeCustomerId) return c.json({ error: "no subscription for this workspace" }, 409);
|
|
9472
|
+
let plan = null;
|
|
9473
|
+
if (stripeSubscriptionId) {
|
|
9474
|
+
try {
|
|
9475
|
+
const sub = await getStripeClient().subscriptions.retrieve(stripeSubscriptionId);
|
|
9476
|
+
if (isPlan(sub.metadata["plan"])) plan = PLAN_INFO[sub.metadata["plan"]];
|
|
9477
|
+
} catch (err) {
|
|
9478
|
+
console.warn("[billing] subscription lookup failed:", err);
|
|
9479
|
+
}
|
|
9480
|
+
}
|
|
9481
|
+
const origin = c.req.header("origin") ?? `https://${trustedHost(c)}`;
|
|
9482
|
+
const session = await getStripeClient().checkout.sessions.create({
|
|
9483
|
+
mode: "setup",
|
|
9484
|
+
currency: "usd",
|
|
9485
|
+
customer: stripeCustomerId,
|
|
9486
|
+
success_url: `${origin}/?billing=done`,
|
|
9487
|
+
cancel_url: `${origin}/`,
|
|
9488
|
+
metadata: { workspace_id: auth.workspaceId },
|
|
9489
|
+
...plan && {
|
|
9490
|
+
custom_text: {
|
|
9491
|
+
submit: {
|
|
9492
|
+
message: `${plan.name} plan, US$${plan.priceUsd}/month. The first month is charged as soon as this card is saved, then monthly until cancelled.`
|
|
9493
|
+
}
|
|
9494
|
+
}
|
|
9495
|
+
}
|
|
9496
|
+
});
|
|
9497
|
+
if (!session.url) return c.json({ error: "Stripe returned no checkout URL" }, 502);
|
|
9498
|
+
return c.json({ url: session.url });
|
|
9499
|
+
});
|
|
9500
|
+
return billing;
|
|
9529
9501
|
}
|
|
9530
|
-
|
|
9531
|
-
|
|
9532
|
-
|
|
9533
|
-
|
|
9534
|
-
|
|
9535
|
-
|
|
9536
|
-
|
|
9537
|
-
var BOARD_PAGE_SIZE_MAX = 100;
|
|
9538
|
-
function ownChannelIds(c) {
|
|
9539
|
-
const viewer = c.get("auth");
|
|
9502
|
+
|
|
9503
|
+
// ../api/src/dashboard/admin-routes.ts
|
|
9504
|
+
import { Hono as Hono10 } from "hono";
|
|
9505
|
+
|
|
9506
|
+
// ../api/src/dashboard/admin-middleware.ts
|
|
9507
|
+
function platformAdminEmails() {
|
|
9508
|
+
const raw = process.env["PLATFORM_ADMIN_EMAILS"] ?? "";
|
|
9540
9509
|
return new Set(
|
|
9541
|
-
|
|
9510
|
+
raw.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean)
|
|
9542
9511
|
);
|
|
9543
9512
|
}
|
|
9544
|
-
async
|
|
9545
|
-
const
|
|
9546
|
-
const
|
|
9547
|
-
|
|
9548
|
-
|
|
9549
|
-
|
|
9550
|
-
|
|
9551
|
-
|
|
9552
|
-
|
|
9553
|
-
listMembershipsByEmail(viewer.email)
|
|
9554
|
-
]);
|
|
9555
|
-
workspaceIdBySlug = new Map(workspaces.map((w) => [w.slug, w.id]));
|
|
9556
|
-
roleByWorkspaceId = new Map(memberships.map((m) => [m.workspaceId, m.role]));
|
|
9557
|
-
}
|
|
9558
|
-
for (const ch of channels) {
|
|
9559
|
-
const workspaceId = workspaceIdBySlug.get(ch.profile);
|
|
9560
|
-
const role = workspaceId ? roleByWorkspaceId.get(workspaceId) : void 0;
|
|
9561
|
-
if (!canSeeChannel(ch, { email: viewer?.email, role })) continue;
|
|
9562
|
-
const meta = { workspace: ch.profile, channelName: ch.name, agentName: ch.agentName };
|
|
9563
|
-
map.set(ch.id, meta);
|
|
9564
|
-
if (ch.externalChannelId) map.set(ch.externalChannelId, meta);
|
|
9513
|
+
var requirePlatformOperator = async (c, next) => {
|
|
9514
|
+
const supabase = createRequestSupabaseClient(c);
|
|
9515
|
+
const {
|
|
9516
|
+
data: { user }
|
|
9517
|
+
} = await supabase.auth.getUser();
|
|
9518
|
+
if (!user?.email) return c.json({ error: "not signed in" }, 401);
|
|
9519
|
+
const email = user.email.trim().toLowerCase();
|
|
9520
|
+
if (!platformAdminEmails().has(email)) {
|
|
9521
|
+
return c.json({ error: "platform operator access required" }, 403);
|
|
9565
9522
|
}
|
|
9566
|
-
|
|
9567
|
-
|
|
9568
|
-
|
|
9569
|
-
|
|
9570
|
-
|
|
9571
|
-
|
|
9572
|
-
|
|
9573
|
-
|
|
9574
|
-
|
|
9575
|
-
|
|
9576
|
-
|
|
9577
|
-
|
|
9578
|
-
async
|
|
9579
|
-
|
|
9580
|
-
|
|
9581
|
-
|
|
9582
|
-
|
|
9583
|
-
|
|
9584
|
-
|
|
9585
|
-
)
|
|
9586
|
-
);
|
|
9587
|
-
const previewByThread = /* @__PURE__ */ new Map();
|
|
9588
|
-
for (const { data } of chunkResults) {
|
|
9589
|
-
for (const r of data ?? []) {
|
|
9590
|
-
const entry = previewByThread.get(r.thread_id);
|
|
9591
|
-
if (entry) {
|
|
9592
|
-
entry.count += 1;
|
|
9593
|
-
entry.lastAuthor = r.author;
|
|
9594
|
-
entry.participants.delete(r.author);
|
|
9595
|
-
entry.participants.set(r.author, r.external_user_id ?? null);
|
|
9596
|
-
} else
|
|
9597
|
-
previewByThread.set(r.thread_id, {
|
|
9598
|
-
count: 1,
|
|
9599
|
-
firstBody: r.body,
|
|
9600
|
-
lastAuthor: r.author,
|
|
9601
|
-
participants: /* @__PURE__ */ new Map()
|
|
9523
|
+
c.set("platformOperator", { userId: user.id, email });
|
|
9524
|
+
return next();
|
|
9525
|
+
};
|
|
9526
|
+
|
|
9527
|
+
// ../api/src/dashboard/admin-routes.ts
|
|
9528
|
+
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
9529
|
+
function createAdminRouter() {
|
|
9530
|
+
const admin = new Hono10();
|
|
9531
|
+
admin.use("*", requirePlatformOperator);
|
|
9532
|
+
admin.get("/workspaces", async (c) => {
|
|
9533
|
+
const summaries = await listWorkspaceSummaries();
|
|
9534
|
+
const workspaces = await Promise.all(
|
|
9535
|
+
summaries.map(async (w) => {
|
|
9536
|
+
const agentModel = cachedProfileConfig(w.slug)?.agentModel;
|
|
9537
|
+
const r2Bytes = await Promise.all(
|
|
9538
|
+
["chat", SCRATCH_PREFIX].map((prefix) => sumR2PrefixBytes(`${prefix}/${w.slug}/`))
|
|
9539
|
+
).then(([a, b]) => a + b).catch((err) => {
|
|
9540
|
+
console.error(`[admin] R2 usage for ${w.slug}:`, err.message);
|
|
9541
|
+
return null;
|
|
9602
9542
|
});
|
|
9603
|
-
|
|
9604
|
-
|
|
9605
|
-
|
|
9606
|
-
|
|
9607
|
-
|
|
9608
|
-
|
|
9609
|
-
|
|
9610
|
-
|
|
9611
|
-
|
|
9612
|
-
// the reader's own handle here means there is nothing new for them to
|
|
9613
|
-
// read, on this browser or any other (see web/src/lib/thread-unread).
|
|
9614
|
-
last_reply_author: entry && entry.count > 1 ? entry.lastAuthor : null,
|
|
9615
|
-
// Everyone who replied, most recent first, for the row's avatar stack.
|
|
9616
|
-
participants: [...entry?.participants ?? []].reverse().map(([author, external_user_id]) => ({ author, external_user_id }))
|
|
9617
|
-
};
|
|
9543
|
+
return {
|
|
9544
|
+
...w,
|
|
9545
|
+
agent: agentModel ? { runner: runnerForModel(agentModel), model: agentModel } : null,
|
|
9546
|
+
r2Bytes,
|
|
9547
|
+
flyVolumeBytes: null
|
|
9548
|
+
};
|
|
9549
|
+
})
|
|
9550
|
+
);
|
|
9551
|
+
return c.json({ workspaces });
|
|
9618
9552
|
});
|
|
9619
|
-
|
|
9620
|
-
|
|
9621
|
-
|
|
9622
|
-
(ch) => ch.id === channelId || ch.externalChannelId === channelId
|
|
9623
|
-
);
|
|
9624
|
-
if (!channel) return null;
|
|
9625
|
-
const supabase = getSupabaseClient();
|
|
9626
|
-
const { data: recent } = await supabase.from("threads").select("auto_title, title").eq("channel_id", channelId).order("created_at", { ascending: false }).limit(50);
|
|
9627
|
-
const existingCategories = extractCategories(
|
|
9628
|
-
(recent ?? []).flatMap((t) => [
|
|
9629
|
-
t.auto_title,
|
|
9630
|
-
t.title
|
|
9631
|
-
])
|
|
9632
|
-
);
|
|
9633
|
-
return buildAutoTitle({
|
|
9634
|
-
channelName: channel.name,
|
|
9635
|
-
channelPrompt: channel.prompt,
|
|
9636
|
-
workspacePrompt: loadWorkspacePrompt().prompt,
|
|
9637
|
-
titlePrompt: (await loadWorkspaceBranding()).autoTitlePrompt,
|
|
9638
|
-
existingCategories,
|
|
9639
|
-
body,
|
|
9640
|
-
scope
|
|
9553
|
+
admin.get("/invites", async (c) => {
|
|
9554
|
+
const invites = await listSetupInvites();
|
|
9555
|
+
return c.json({ invites });
|
|
9641
9556
|
});
|
|
9642
|
-
|
|
9643
|
-
|
|
9644
|
-
|
|
9645
|
-
|
|
9646
|
-
|
|
9647
|
-
const supabase = getSupabaseClient();
|
|
9648
|
-
await supabase.from("threads").update({ auto_title: autoTitle }).eq("id", threadId).is("title", null);
|
|
9649
|
-
}
|
|
9650
|
-
function createChatRouter(agent = {}) {
|
|
9651
|
-
const app = new Hono12();
|
|
9652
|
-
const {
|
|
9653
|
-
onChatMessage: onMessage,
|
|
9654
|
-
isAgentThinking,
|
|
9655
|
-
getAgentActivities,
|
|
9656
|
-
cancelAgentRun,
|
|
9657
|
-
regenerateAgentRun,
|
|
9658
|
-
listAgentRuns
|
|
9659
|
-
} = agent;
|
|
9660
|
-
app.post(
|
|
9661
|
-
"/threads/:id/agent-cancel",
|
|
9662
|
-
async (c) => c.json({ cancelled: await cancelAgentRun?.(c.req.param("id")) ?? false })
|
|
9663
|
-
);
|
|
9664
|
-
app.post(
|
|
9665
|
-
"/threads/:id/agent-regenerate",
|
|
9666
|
-
async (c) => c.json({ started: await regenerateAgentRun?.(c.req.param("id")) ?? false })
|
|
9667
|
-
);
|
|
9668
|
-
app.get(
|
|
9669
|
-
"/threads/:id/agent-status",
|
|
9670
|
-
async (c) => c.json({
|
|
9671
|
-
thinking: await isAgentThinking?.(c.req.param("id")) ?? false,
|
|
9672
|
-
activities: await getAgentActivities?.(c.req.param("id")) ?? []
|
|
9673
|
-
})
|
|
9674
|
-
);
|
|
9675
|
-
app.get("/agent-runs", async (c) => c.json({ threadIds: await listAgentRuns?.() ?? [] }));
|
|
9676
|
-
app.get("/channels/:channelId/threads", async (c) => {
|
|
9677
|
-
const channelId = c.req.param("channelId");
|
|
9678
|
-
if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
|
|
9679
|
-
const requestedLimit = Number(c.req.query("limit"));
|
|
9680
|
-
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
|
|
9681
|
-
const before = c.req.query("before");
|
|
9682
|
-
const sortColumn = c.req.query("sort") === "activity" ? "updated_at" : "created_at";
|
|
9683
|
-
const statusParam = c.req.query("status");
|
|
9684
|
-
const statusFilter = statusParam ? [
|
|
9685
|
-
...new Set(
|
|
9686
|
-
statusParam.split(",").map(boardColumnParam).filter((s) => s !== null)
|
|
9687
|
-
)
|
|
9688
|
-
] : [];
|
|
9689
|
-
const supabase = getSupabaseClient();
|
|
9690
|
-
let query = supabase.from("threads").select("*").eq("channel_id", channelId).is("archived_at", null).order(sortColumn, { ascending: false }).limit(limit);
|
|
9691
|
-
if (before) query = query.lt(sortColumn, before);
|
|
9692
|
-
if (statusFilter.length) {
|
|
9693
|
-
const withoutNone = statusFilter.filter((s) => s !== NO_STATUS_COLUMN);
|
|
9694
|
-
const includeNone = statusFilter.includes(NO_STATUS_COLUMN);
|
|
9695
|
-
if (includeNone && withoutNone.length) {
|
|
9696
|
-
query = query.or(`board_status.in.(${withoutNone.join(",")}),board_status.is.null`);
|
|
9697
|
-
} else if (includeNone) {
|
|
9698
|
-
query = query.is("board_status", null);
|
|
9699
|
-
} else {
|
|
9700
|
-
query = query.in("board_status", withoutNone);
|
|
9701
|
-
}
|
|
9557
|
+
admin.post("/setup-invites", async (c) => {
|
|
9558
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9559
|
+
const ownerEmail = body.ownerEmail?.trim().toLowerCase();
|
|
9560
|
+
if (!ownerEmail || !EMAIL_RE.test(ownerEmail)) {
|
|
9561
|
+
return c.json({ error: "ownerEmail must be a valid email address" }, 400);
|
|
9702
9562
|
}
|
|
9703
|
-
const
|
|
9704
|
-
if (
|
|
9705
|
-
|
|
9706
|
-
const
|
|
9707
|
-
|
|
9708
|
-
|
|
9709
|
-
|
|
9710
|
-
|
|
9711
|
-
const
|
|
9712
|
-
|
|
9713
|
-
|
|
9714
|
-
|
|
9563
|
+
const plan = body.plan === "cloud" ? "cloud" : body.plan === "local" ? "local" : void 0;
|
|
9564
|
+
if (!plan) return c.json({ error: 'plan must be "local" or "cloud"' }, 400);
|
|
9565
|
+
const ownerName = body.ownerName?.trim().slice(0, 80) || null;
|
|
9566
|
+
const message = body.message?.trim() || null;
|
|
9567
|
+
if (message && message.length > INVITE_MESSAGE_MAX) {
|
|
9568
|
+
return c.json({ error: `message must be ${INVITE_MESSAGE_MAX} characters or fewer` }, 400);
|
|
9569
|
+
}
|
|
9570
|
+
const operator = c.get("platformOperator");
|
|
9571
|
+
const token = await issueSetupInvite({
|
|
9572
|
+
email: ownerEmail,
|
|
9573
|
+
ownerName,
|
|
9574
|
+
plan,
|
|
9575
|
+
message,
|
|
9576
|
+
invitedBy: operator.userId
|
|
9577
|
+
});
|
|
9578
|
+
const inviteUrl = new URL(`${workspaceOrigin(c, "app")}/auth/new-workspace`);
|
|
9579
|
+
inviteUrl.searchParams.set("token", token);
|
|
9580
|
+
if (body.sendEmail === false) {
|
|
9581
|
+
return c.json({ email: ownerEmail, inviteLink: inviteUrl.toString() }, 201);
|
|
9582
|
+
}
|
|
9583
|
+
try {
|
|
9584
|
+
await deliverWorkspaceSetupInvite({
|
|
9585
|
+
email: ownerEmail,
|
|
9586
|
+
inviteLink: inviteUrl.toString(),
|
|
9587
|
+
message
|
|
9588
|
+
});
|
|
9589
|
+
} catch (err) {
|
|
9590
|
+
await deleteSetupInvite(token).catch(() => {
|
|
9591
|
+
});
|
|
9715
9592
|
return c.json(
|
|
9716
|
-
{
|
|
9717
|
-
|
|
9593
|
+
{
|
|
9594
|
+
error: `the invite email failed: ${err instanceof Error ? err.message : "unknown error"}`
|
|
9595
|
+
},
|
|
9596
|
+
500
|
|
9718
9597
|
);
|
|
9719
9598
|
}
|
|
9720
|
-
|
|
9721
|
-
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, BOARD_PAGE_SIZE_MAX) : BOARD_PAGE_SIZE;
|
|
9722
|
-
const before = c.req.query("before");
|
|
9723
|
-
const supabase = getSupabaseClient();
|
|
9724
|
-
let query = supabase.from("threads").select("*").eq("channel_id", channelId);
|
|
9725
|
-
query = column === NO_STATUS_COLUMN ? query.is("board_status", null) : query.eq("board_status", column);
|
|
9726
|
-
query = query.is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
|
|
9727
|
-
if (before) query = query.lt("updated_at", before);
|
|
9728
|
-
const { data: threads, error: error2 } = await query;
|
|
9729
|
-
if (error2) return c.json({ error: error2.message }, 500);
|
|
9730
|
-
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
9731
|
-
const withPreview = await withPreviews(supabase, threads);
|
|
9732
|
-
const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
|
|
9733
|
-
return c.json({ threads: withPreview, nextCursor });
|
|
9599
|
+
return c.json({ email: ownerEmail, inviteLink: inviteUrl.toString() }, 201);
|
|
9734
9600
|
});
|
|
9735
|
-
|
|
9736
|
-
|
|
9737
|
-
|
|
9601
|
+
admin.delete("/setup-invites/:id", async (c) => {
|
|
9602
|
+
await revokeSetupInvite(c.req.param("id"));
|
|
9603
|
+
return c.json({ revoked: true });
|
|
9604
|
+
});
|
|
9605
|
+
admin.post("/setup-invites/:id/resend", async (c) => {
|
|
9606
|
+
const operator = c.get("platformOperator");
|
|
9607
|
+
let token;
|
|
9608
|
+
let invite;
|
|
9609
|
+
try {
|
|
9610
|
+
({ token, invite } = await reissueSetupInvite(c.req.param("id")));
|
|
9611
|
+
} catch (err) {
|
|
9612
|
+
return c.json({ error: err instanceof Error ? err.message : "resend failed" }, 404);
|
|
9613
|
+
}
|
|
9614
|
+
const inviteUrl = new URL(`${workspaceOrigin(c, "app")}/auth/new-workspace`);
|
|
9615
|
+
inviteUrl.searchParams.set("token", token);
|
|
9616
|
+
if (c.req.query("email") === "false") {
|
|
9617
|
+
return c.json({ sent: false, inviteLink: inviteUrl.toString() });
|
|
9618
|
+
}
|
|
9619
|
+
try {
|
|
9620
|
+
await deliverWorkspaceSetupInvite({
|
|
9621
|
+
email: invite.email,
|
|
9622
|
+
inviteLink: inviteUrl.toString(),
|
|
9623
|
+
message: invite.message
|
|
9624
|
+
});
|
|
9625
|
+
} catch (err) {
|
|
9738
9626
|
return c.json(
|
|
9739
|
-
{
|
|
9740
|
-
|
|
9627
|
+
{
|
|
9628
|
+
error: `the invite email failed: ${err instanceof Error ? err.message : "unknown error"}`
|
|
9629
|
+
},
|
|
9630
|
+
500
|
|
9741
9631
|
);
|
|
9742
9632
|
}
|
|
9743
|
-
|
|
9744
|
-
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, BOARD_PAGE_SIZE_MAX) : BOARD_PAGE_SIZE;
|
|
9745
|
-
const before = c.req.query("before");
|
|
9746
|
-
const meta = c.req.query("scope") === "all" ? await globalChannelMeta(c) : null;
|
|
9747
|
-
const supabase = getSupabaseClient();
|
|
9748
|
-
let query = supabase.from("threads").select("*").in("channel_id", meta ? [...meta.keys()] : [...ownChannelIds(c)]);
|
|
9749
|
-
query = column === NO_STATUS_COLUMN ? query.is("board_status", null) : query.eq("board_status", column);
|
|
9750
|
-
query = query.is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
|
|
9751
|
-
if (before) query = query.lt("updated_at", before);
|
|
9752
|
-
const { data: threads, error: error2 } = await query;
|
|
9753
|
-
if (error2) return c.json({ error: error2.message }, 500);
|
|
9754
|
-
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
9755
|
-
const withPreview = await withPreviews(supabase, threads);
|
|
9756
|
-
const labelled = meta ? withPreview.map((t) => {
|
|
9757
|
-
const channelMeta = meta.get(t.channel_id ?? "");
|
|
9758
|
-
return {
|
|
9759
|
-
...t,
|
|
9760
|
-
workspace: channelMeta?.workspace ?? null,
|
|
9761
|
-
channel_name: channelMeta?.channelName ?? null,
|
|
9762
|
-
agent_name: channelMeta?.agentName ?? null
|
|
9763
|
-
};
|
|
9764
|
-
}) : withPreview;
|
|
9765
|
-
const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
|
|
9766
|
-
return c.json({ threads: labelled, nextCursor });
|
|
9633
|
+
return c.json({ sent: true, inviteLink: inviteUrl.toString() });
|
|
9767
9634
|
});
|
|
9768
|
-
|
|
9769
|
-
|
|
9770
|
-
|
|
9771
|
-
|
|
9772
|
-
|
|
9773
|
-
|
|
9774
|
-
|
|
9775
|
-
|
|
9776
|
-
|
|
9777
|
-
|
|
9778
|
-
|
|
9779
|
-
|
|
9780
|
-
|
|
9781
|
-
|
|
9782
|
-
|
|
9783
|
-
|
|
9784
|
-
|
|
9785
|
-
|
|
9786
|
-
|
|
9787
|
-
|
|
9788
|
-
|
|
9789
|
-
|
|
9790
|
-
|
|
9791
|
-
|
|
9792
|
-
|
|
9793
|
-
|
|
9794
|
-
|
|
9795
|
-
|
|
9796
|
-
|
|
9797
|
-
|
|
9798
|
-
|
|
9799
|
-
|
|
9800
|
-
|
|
9801
|
-
|
|
9802
|
-
|
|
9803
|
-
}
|
|
9804
|
-
}
|
|
9805
|
-
const withReplies = threads.map((t) => {
|
|
9806
|
-
const channelMeta = meta?.get(t.channel_id);
|
|
9807
|
-
return {
|
|
9808
|
-
...t,
|
|
9809
|
-
...meta ? {
|
|
9810
|
-
workspace: channelMeta?.workspace ?? null,
|
|
9811
|
-
channel_name: channelMeta?.channelName ?? null,
|
|
9812
|
-
agent_name: channelMeta?.agentName ?? null
|
|
9813
|
-
} : {},
|
|
9814
|
-
replies: (repliesByThread.get(t.id) ?? []).map((r) => ({
|
|
9815
|
-
...r,
|
|
9816
|
-
body: slackMrkdwnToMarkdown(r.body)
|
|
9817
|
-
}))
|
|
9818
|
-
};
|
|
9819
|
-
});
|
|
9820
|
-
const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
|
|
9821
|
-
return c.json({ threads: withReplies, nextCursor });
|
|
9635
|
+
return admin;
|
|
9636
|
+
}
|
|
9637
|
+
|
|
9638
|
+
// ../api/src/dashboard/agent-identities.ts
|
|
9639
|
+
var BOT_LABEL = "Workspace Agent";
|
|
9640
|
+
var BOT_DM_KEY = "claude";
|
|
9641
|
+
|
|
9642
|
+
// ../api/src/dashboard/data-api.ts
|
|
9643
|
+
import { existsSync as existsSync19 } from "fs";
|
|
9644
|
+
import sharp5 from "sharp";
|
|
9645
|
+
|
|
9646
|
+
// ../api/src/dashboard/automations-routes.ts
|
|
9647
|
+
import { Hono as Hono11 } from "hono";
|
|
9648
|
+
var TYPES2 = ["schedule", "trigger", "mention-trigger"];
|
|
9649
|
+
function isType(t) {
|
|
9650
|
+
return TYPES2.includes(t);
|
|
9651
|
+
}
|
|
9652
|
+
function sanitize(body) {
|
|
9653
|
+
const { created_by: _b, created_at: _a, ...rest } = body;
|
|
9654
|
+
void _b;
|
|
9655
|
+
void _a;
|
|
9656
|
+
return rest;
|
|
9657
|
+
}
|
|
9658
|
+
function statusFor(message) {
|
|
9659
|
+
if (/already exists/.test(message)) return 409;
|
|
9660
|
+
if (/not found/.test(message)) return 404;
|
|
9661
|
+
return 400;
|
|
9662
|
+
}
|
|
9663
|
+
function createAutomationWriteRouter() {
|
|
9664
|
+
const app = new Hono11();
|
|
9665
|
+
const manager = () => new AutomationManager();
|
|
9666
|
+
app.get("/:type/:name", (c) => {
|
|
9667
|
+
const item = manager().get(c.req.param("name"));
|
|
9668
|
+
if (!item) return c.json({ error: "not found" }, 404);
|
|
9669
|
+
return c.json(item);
|
|
9822
9670
|
});
|
|
9823
|
-
app.
|
|
9824
|
-
|
|
9825
|
-
|
|
9671
|
+
app.post("/:type", async (c) => {
|
|
9672
|
+
const type = c.req.param("type");
|
|
9673
|
+
if (!isType(type)) return c.json({ error: "unknown type" }, 400);
|
|
9674
|
+
const body = sanitize(await c.req.json());
|
|
9675
|
+
const next = { ...body, created_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
9676
|
+
try {
|
|
9677
|
+
const m = manager();
|
|
9678
|
+
if (type === "schedule") await m.addSchedule(next);
|
|
9679
|
+
else if (type === "trigger") await m.addTrigger(next);
|
|
9680
|
+
else await m.addMentionTrigger(next);
|
|
9681
|
+
return c.json(next, 201);
|
|
9682
|
+
} catch (e) {
|
|
9683
|
+
const msg = e.message;
|
|
9684
|
+
const status = statusFor(msg);
|
|
9685
|
+
return c.json(status === 400 ? { errors: [msg] } : { error: msg }, status);
|
|
9826
9686
|
}
|
|
9827
|
-
const requestedLimit = Number(c.req.query("limit"));
|
|
9828
|
-
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
|
|
9829
|
-
const before = c.req.query("before");
|
|
9830
|
-
const supabase = getSupabaseClient();
|
|
9831
|
-
let query = supabase.from("threads").select("*").in("channel_id", [...ownChannelIds(c)]).not("archived_at", "is", null).order("archived_at", { ascending: false }).limit(limit);
|
|
9832
|
-
if (before) query = query.lt("archived_at", before);
|
|
9833
|
-
const { data: threads, error: error2 } = await query;
|
|
9834
|
-
if (error2) return c.json({ error: error2.message }, 500);
|
|
9835
|
-
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
9836
|
-
const withPreview = await withPreviews(supabase, threads);
|
|
9837
|
-
const nextCursor = threads.length === limit ? threads[threads.length - 1].archived_at : null;
|
|
9838
|
-
return c.json({ threads: withPreview, nextCursor });
|
|
9839
|
-
});
|
|
9840
|
-
app.get("/threads/:id", async (c) => {
|
|
9841
|
-
const id = c.req.param("id");
|
|
9842
|
-
const requestedLimit = Number(c.req.query("limit"));
|
|
9843
|
-
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, REPLIES_PAGE_SIZE_MAX) : REPLIES_PAGE_SIZE;
|
|
9844
|
-
const before = c.req.query("before");
|
|
9845
|
-
let repliesQuery = getSupabaseClient().from("thread_replies").select("*, reactions:reply_reactions(emoji, author)").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: false }).limit(limit);
|
|
9846
|
-
if (before) repliesQuery = repliesQuery.lt("created_at", before);
|
|
9847
|
-
const [thread, replies, opening] = await Promise.all([
|
|
9848
|
-
getSupabaseClient().from("threads").select("*").eq("id", id).single(),
|
|
9849
|
-
repliesQuery,
|
|
9850
|
-
// The opening message, fetched separately because the replies page
|
|
9851
|
-
// above is newest-first: on a long thread the opener isn't in it. It
|
|
9852
|
-
// is what the header falls back to on a thread with no title of its
|
|
9853
|
-
// own (see threadCrumbLabel) — a title is never written on the
|
|
9854
|
-
// thread's behalf, so an untitled row must still be able to show and
|
|
9855
|
-
// rename a topic.
|
|
9856
|
-
getSupabaseClient().from("thread_replies").select("body").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: true }).limit(1)
|
|
9857
|
-
]);
|
|
9858
|
-
if (thread.error) return c.json({ error: thread.error.message }, 404);
|
|
9859
|
-
if (replies.error) return c.json({ error: replies.error.message }, 500);
|
|
9860
|
-
const channelId = thread.data?.channel_id;
|
|
9861
|
-
const meta = await globalChannelMeta(c);
|
|
9862
|
-
const channelMeta = channelId ? meta.get(channelId) : void 0;
|
|
9863
|
-
if (!channelId || !channelMeta) {
|
|
9864
|
-
return c.json({ error: "not found" }, 404);
|
|
9865
|
-
}
|
|
9866
|
-
const page2 = replies.data ?? [];
|
|
9867
|
-
const nextCursor = page2.length === limit ? page2[page2.length - 1].created_at : null;
|
|
9868
|
-
const formattedReplies = page2.reverse().map((r) => ({
|
|
9869
|
-
...r,
|
|
9870
|
-
body: slackMrkdwnToMarkdown(r.body)
|
|
9871
|
-
}));
|
|
9872
|
-
const openingBody = (opening.data ?? [])[0]?.body;
|
|
9873
|
-
return c.json({
|
|
9874
|
-
...thread.data,
|
|
9875
|
-
// Always stamped (not just on `?scope=all` feeds, see the `/threads`
|
|
9876
|
-
// route above) — the reply pane can be opened directly by id (global
|
|
9877
|
-
// board, permalink) without the active workspace's channel map ever
|
|
9878
|
-
// having loaded this thread's channel, so the header needs its own
|
|
9879
|
-
// workspace/channel label rather than depending on that cache.
|
|
9880
|
-
workspace: channelMeta.workspace,
|
|
9881
|
-
channel_name: channelMeta.channelName,
|
|
9882
|
-
agent_name: channelMeta.agentName,
|
|
9883
|
-
preview: openingBody ? slackMrkdwnToMarkdown(openingBody) : null,
|
|
9884
|
-
replies: formattedReplies,
|
|
9885
|
-
nextCursor
|
|
9886
|
-
});
|
|
9887
9687
|
});
|
|
9888
|
-
app.
|
|
9889
|
-
const
|
|
9890
|
-
|
|
9891
|
-
|
|
9892
|
-
const
|
|
9893
|
-
getSupabaseClient().from("threads").select("channel_id").eq("id", id).single(),
|
|
9894
|
-
getSupabaseClient().from("thread_replies").select("id, author, body").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: true }).limit(TRANSLATABLE_REPLIES_MAX)
|
|
9895
|
-
]);
|
|
9896
|
-
if (thread.error) return c.json({ error: "not found" }, 404);
|
|
9897
|
-
if (replies.error) return c.json({ error: replies.error.message }, 500);
|
|
9898
|
-
const channelId = thread.data?.channel_id;
|
|
9899
|
-
if (!channelId || !(await globalChannelMeta(c)).has(channelId)) {
|
|
9900
|
-
return c.json({ error: "not found" }, 404);
|
|
9901
|
-
}
|
|
9902
|
-
const messages = (replies.data ?? []).map(
|
|
9903
|
-
(r) => ({ id: r.id, author: r.author, body: slackMrkdwnToMarkdown(r.body) })
|
|
9904
|
-
);
|
|
9688
|
+
app.put("/:type/:name", async (c) => {
|
|
9689
|
+
const type = c.req.param("type");
|
|
9690
|
+
if (!isType(type)) return c.json({ error: "unknown type" }, 400);
|
|
9691
|
+
const name = c.req.param("name");
|
|
9692
|
+
const body = sanitize(await c.req.json());
|
|
9905
9693
|
try {
|
|
9906
|
-
|
|
9907
|
-
|
|
9908
|
-
const
|
|
9909
|
-
|
|
9910
|
-
|
|
9911
|
-
|
|
9912
|
-
|
|
9913
|
-
|
|
9914
|
-
|
|
9915
|
-
|
|
9916
|
-
|
|
9917
|
-
|
|
9918
|
-
return c.json(
|
|
9919
|
-
{ error: `board_status must be null or one of: ${BOARD_STATUSES.join(", ")}` },
|
|
9920
|
-
400
|
|
9921
|
-
);
|
|
9694
|
+
const m = manager();
|
|
9695
|
+
const existing = m.get(name);
|
|
9696
|
+
const next = {
|
|
9697
|
+
...body,
|
|
9698
|
+
created_at: existing?.created_at
|
|
9699
|
+
};
|
|
9700
|
+
await m.update(type, name, next);
|
|
9701
|
+
return c.json(next, 200);
|
|
9702
|
+
} catch (e) {
|
|
9703
|
+
const msg = e.message;
|
|
9704
|
+
const status = statusFor(msg);
|
|
9705
|
+
return c.json(status === 400 ? { errors: [msg] } : { error: msg }, status);
|
|
9922
9706
|
}
|
|
9923
|
-
if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
|
|
9924
|
-
const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ board_status: value }).eq("id", id).select().single();
|
|
9925
|
-
if (error2) return c.json({ error: error2.message }, 500);
|
|
9926
|
-
return c.json(thread);
|
|
9927
9707
|
});
|
|
9928
|
-
app.
|
|
9929
|
-
|
|
9930
|
-
|
|
9931
|
-
|
|
9932
|
-
|
|
9933
|
-
return c.json({
|
|
9708
|
+
app.delete("/:type/:name", async (c) => {
|
|
9709
|
+
try {
|
|
9710
|
+
await manager().remove(c.req.param("name"));
|
|
9711
|
+
return c.json({ ok: true }, 200);
|
|
9712
|
+
} catch (e) {
|
|
9713
|
+
return c.json({ error: e.message }, 404);
|
|
9934
9714
|
}
|
|
9935
|
-
const title = raw === null ? null : raw.trim();
|
|
9936
|
-
if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
|
|
9937
|
-
const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ title }).eq("id", id).select().single();
|
|
9938
|
-
if (error2) return c.json({ error: error2.message }, 500);
|
|
9939
|
-
return c.json(thread);
|
|
9940
9715
|
});
|
|
9941
|
-
app
|
|
9942
|
-
|
|
9943
|
-
|
|
9944
|
-
|
|
9945
|
-
|
|
9946
|
-
|
|
9716
|
+
return app;
|
|
9717
|
+
}
|
|
9718
|
+
|
|
9719
|
+
// ../api/src/dashboard/categories-routes.ts
|
|
9720
|
+
import { Hono as Hono12 } from "hono";
|
|
9721
|
+
function createCategoryWriteRouter() {
|
|
9722
|
+
const app = new Hono12();
|
|
9723
|
+
app.get("/", async (c) => {
|
|
9724
|
+
const { data, error: error2 } = await getSupabaseClient().from("categories").select("*").eq("profile", getProfile()).order("position", { ascending: true });
|
|
9947
9725
|
if (error2) return c.json({ error: error2.message }, 500);
|
|
9948
|
-
|
|
9949
|
-
const title = await labelThread(thread.channel_id, transcript, "thread");
|
|
9950
|
-
if (!title) return c.json({ error: "could not generate a topic" }, 502);
|
|
9951
|
-
return c.json({ title });
|
|
9726
|
+
return c.json({ categories: data ?? [] });
|
|
9952
9727
|
});
|
|
9953
|
-
app.
|
|
9954
|
-
const
|
|
9955
|
-
const
|
|
9956
|
-
|
|
9957
|
-
const body = input2?.body;
|
|
9958
|
-
if (!isNonEmptyString(author) || !isNonEmptyString(body)) {
|
|
9959
|
-
return c.json({ errors: ["author and body are required"] }, 400);
|
|
9960
|
-
}
|
|
9728
|
+
app.post("/", async (c) => {
|
|
9729
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9730
|
+
const name = typeof body["name"] === "string" ? body["name"].trim() : "";
|
|
9731
|
+
if (!name) return c.json({ errors: ["name is required"] }, 400);
|
|
9961
9732
|
const supabase = getSupabaseClient();
|
|
9962
|
-
const { data:
|
|
9963
|
-
|
|
9964
|
-
|
|
9965
|
-
if (existing.author !== author) {
|
|
9966
|
-
return c.json({ error: "only the author can edit a message" }, 403);
|
|
9967
|
-
}
|
|
9968
|
-
const { data: reply, error: error2 } = await supabase.from("thread_replies").update({ body, edited_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", replyId).select().single();
|
|
9733
|
+
const { data: last } = await supabase.from("categories").select("position").eq("profile", getProfile()).order("position", { ascending: false }).limit(1);
|
|
9734
|
+
const nextPosition = (last?.[0]?.position ?? -1) + 1;
|
|
9735
|
+
const { data, error: error2 } = await supabase.from("categories").insert({ name, position: nextPosition, profile: getProfile() }).select().single();
|
|
9969
9736
|
if (error2) return c.json({ error: error2.message }, 500);
|
|
9970
|
-
|
|
9971
|
-
const { data: latest } = await supabase.from("thread_replies").select("id").eq("thread_id", existing.thread_id).is("archived_at", null).order("created_at", { ascending: false }).limit(1).maybeSingle();
|
|
9972
|
-
if (latest?.id === replyId) {
|
|
9973
|
-
await Promise.resolve(regenerateAgentRun?.(existing.thread_id, replyId)).catch(() => {
|
|
9974
|
-
});
|
|
9975
|
-
}
|
|
9976
|
-
}
|
|
9977
|
-
return c.json(reply);
|
|
9737
|
+
return c.json(data, 201);
|
|
9978
9738
|
});
|
|
9979
|
-
app.
|
|
9980
|
-
const
|
|
9981
|
-
const
|
|
9982
|
-
|
|
9983
|
-
const emoji = input2?.emoji;
|
|
9984
|
-
if (!isNonEmptyString(author) || !isNonEmptyString(emoji)) {
|
|
9985
|
-
return c.json({ errors: ["author and emoji are required"] }, 400);
|
|
9986
|
-
}
|
|
9739
|
+
app.patch("/reorder", async (c) => {
|
|
9740
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9741
|
+
const ids = Array.isArray(body["ids"]) ? body["ids"].filter((x) => typeof x === "string") : [];
|
|
9742
|
+
if (ids.length === 0) return c.json({ errors: ["ids is required"] }, 400);
|
|
9987
9743
|
const supabase = getSupabaseClient();
|
|
9988
|
-
const
|
|
9989
|
-
|
|
9990
|
-
|
|
9991
|
-
|
|
9992
|
-
|
|
9993
|
-
|
|
9994
|
-
|
|
9995
|
-
|
|
9996
|
-
} else {
|
|
9997
|
-
const { error: error2 } = await supabase.from("reply_reactions").insert({ reply_id: replyId, emoji, author });
|
|
9998
|
-
if (error2 && error2.code !== "23505") return c.json({ error: error2.message }, 500);
|
|
9999
|
-
}
|
|
10000
|
-
const { data: reactions, error: reactionsError } = await supabase.from("reply_reactions").select("emoji, author").eq("reply_id", replyId);
|
|
10001
|
-
if (reactionsError) return c.json({ error: reactionsError.message }, 500);
|
|
10002
|
-
return c.json({ reactions: reactions ?? [] });
|
|
9744
|
+
const results = await Promise.all(
|
|
9745
|
+
ids.map(
|
|
9746
|
+
(id, position) => supabase.from("categories").update({ position, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).eq("profile", getProfile())
|
|
9747
|
+
)
|
|
9748
|
+
);
|
|
9749
|
+
const failed2 = results.find((r) => r.error);
|
|
9750
|
+
if (failed2?.error) return c.json({ error: failed2.error.message }, 500);
|
|
9751
|
+
return c.json({ ok: true });
|
|
10003
9752
|
});
|
|
10004
|
-
app.
|
|
10005
|
-
if (c.get("auth")?.role !== "admin") {
|
|
10006
|
-
return c.json({ error: "only an admin can archive a topic" }, 403);
|
|
10007
|
-
}
|
|
9753
|
+
app.put("/:id", async (c) => {
|
|
10008
9754
|
const id = c.req.param("id");
|
|
10009
|
-
const
|
|
10010
|
-
const
|
|
10011
|
-
if (
|
|
10012
|
-
|
|
10013
|
-
}
|
|
10014
|
-
|
|
10015
|
-
const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ archived_at: archived ? (/* @__PURE__ */ new Date()).toISOString() : null }).eq("id", id).select().single();
|
|
10016
|
-
if (error2) return c.json({ error: error2.message }, 500);
|
|
10017
|
-
return c.json(thread);
|
|
9755
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9756
|
+
const name = typeof body["name"] === "string" ? body["name"].trim() : "";
|
|
9757
|
+
if (!name) return c.json({ errors: ["name is required"] }, 400);
|
|
9758
|
+
const { data, error: error2 } = await getSupabaseClient().from("categories").update({ name, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).eq("profile", getProfile()).select().single();
|
|
9759
|
+
if (error2) return c.json({ error: error2.message }, 404);
|
|
9760
|
+
return c.json(data);
|
|
10018
9761
|
});
|
|
10019
|
-
app.
|
|
10020
|
-
const
|
|
10021
|
-
if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
|
|
10022
|
-
const input2 = await c.req.json();
|
|
10023
|
-
if (!isNonEmptyString(input2.author) || !isNonEmptyString(input2.body)) {
|
|
10024
|
-
return c.json({ errors: ["author and body are required"] }, 400);
|
|
10025
|
-
}
|
|
10026
|
-
const clientKey2 = isNonEmptyString(input2.clientKey) ? input2.clientKey : null;
|
|
10027
|
-
const model = requestedModel(c, input2.model);
|
|
10028
|
-
const effort = requestedEffort(c, input2.effort);
|
|
9762
|
+
app.delete("/:id", async (c) => {
|
|
9763
|
+
const id = c.req.param("id");
|
|
10029
9764
|
const supabase = getSupabaseClient();
|
|
10030
|
-
|
|
10031
|
-
|
|
10032
|
-
|
|
10033
|
-
|
|
10034
|
-
|
|
10035
|
-
|
|
10036
|
-
// opening message at display time (see threadCrumbLabel).
|
|
10037
|
-
title: isNonEmptyString(input2.title) ? input2.title.trim() : null,
|
|
10038
|
-
author: input2.author,
|
|
10039
|
-
client_key: clientKey2
|
|
10040
|
-
}).select().single();
|
|
10041
|
-
if (threadError) {
|
|
10042
|
-
if (!clientKey2 || threadError.code !== "23505") {
|
|
10043
|
-
return c.json({ error: threadError.message }, 500);
|
|
10044
|
-
}
|
|
10045
|
-
const { data: existing } = await supabase.from("threads").select("*").eq("client_key", clientKey2).single();
|
|
10046
|
-
if (!existing) return c.json({ error: threadError.message }, 500);
|
|
10047
|
-
thread = existing;
|
|
10048
|
-
replayed = true;
|
|
10049
|
-
} else {
|
|
10050
|
-
thread = inserted;
|
|
10051
|
-
}
|
|
10052
|
-
let { data: reply } = replayed && clientKey2 ? await supabase.from("thread_replies").select("*").eq("client_key", clientKey2).single() : { data: null };
|
|
10053
|
-
let replyCompleted = false;
|
|
10054
|
-
if (!reply) {
|
|
10055
|
-
const { data: insertedReply, error: replyError } = await supabase.from("thread_replies").insert({
|
|
10056
|
-
thread_id: thread.id,
|
|
10057
|
-
author: input2.author,
|
|
10058
|
-
body: input2.body,
|
|
10059
|
-
client_key: clientKey2,
|
|
10060
|
-
model,
|
|
10061
|
-
effort
|
|
10062
|
-
}).select().single();
|
|
10063
|
-
if (replyError) return c.json({ error: replyError.message }, 500);
|
|
10064
|
-
reply = insertedReply;
|
|
10065
|
-
replyCompleted = true;
|
|
10066
|
-
}
|
|
10067
|
-
if (!replayed || replyCompleted) {
|
|
10068
|
-
onMessage?.({
|
|
10069
|
-
threadId: thread.id,
|
|
10070
|
-
replyId: reply.id,
|
|
10071
|
-
author: input2.author,
|
|
10072
|
-
body: input2.body,
|
|
10073
|
-
// From the row, not `model`/`effort`: on the replay path the reply
|
|
10074
|
-
// predates this request, so its own stored override is the source of
|
|
10075
|
-
// truth.
|
|
10076
|
-
model: reply["model"] ?? void 0,
|
|
10077
|
-
effort: effortFromRow(reply["effort"])
|
|
10078
|
-
});
|
|
10079
|
-
}
|
|
10080
|
-
if (!replayed && thread["title"] == null) {
|
|
10081
|
-
void generateAutoTitle(thread.id, channelId, input2.body).catch(
|
|
10082
|
-
(err) => console.error("Auto title failed:", err)
|
|
10083
|
-
);
|
|
10084
|
-
}
|
|
10085
|
-
return c.json({ ...thread, replies: [reply] }, replayed ? 200 : 201);
|
|
10086
|
-
});
|
|
10087
|
-
app.post("/replies/:replyId/fork", async (c) => {
|
|
10088
|
-
const replyId = c.req.param("replyId");
|
|
10089
|
-
const input2 = await c.req.json().catch(() => null);
|
|
10090
|
-
const author = input2?.author;
|
|
10091
|
-
if (!isNonEmptyString(author)) {
|
|
10092
|
-
return c.json({ errors: ["author is required"] }, 400);
|
|
10093
|
-
}
|
|
10094
|
-
const mode = input2?.mode === "focus" ? "focus" : "condense";
|
|
10095
|
-
const supabase = getSupabaseClient();
|
|
10096
|
-
const { data: source, error: sourceError } = await supabase.from("thread_replies").select("*").eq("id", replyId).single();
|
|
10097
|
-
if (sourceError) return c.json({ error: sourceError.message }, 404);
|
|
10098
|
-
const { data: sourceThread, error: sourceThreadError } = await supabase.from("threads").select("*").eq("id", source.thread_id).single();
|
|
10099
|
-
if (sourceThreadError) return c.json({ error: sourceThreadError.message }, 404);
|
|
10100
|
-
if (!(await globalChannelMeta(c)).has(sourceThread.channel_id)) {
|
|
10101
|
-
return c.json({ error: "not found" }, 404);
|
|
10102
|
-
}
|
|
10103
|
-
const { data: priorReplies } = await supabase.from("thread_replies").select("author, body, created_at").eq("thread_id", source.thread_id).lte("created_at", source.created_at).is("archived_at", null).order("created_at", { ascending: true });
|
|
10104
|
-
const priorRows = priorReplies ?? [];
|
|
10105
|
-
const sourceText = slackMrkdwnToMarkdown(source.body);
|
|
10106
|
-
const FOCUS_CONTEXT_BELOW_CHARS = 280;
|
|
10107
|
-
const inputRows = mode === "focus" ? priorRows.slice(sourceText.length < FOCUS_CONTEXT_BELOW_CHARS ? -2 : -1) : priorRows;
|
|
10108
|
-
const transcript = inputRows.map((r) => `${r.author}: ${slackMrkdwnToMarkdown(r.body)}`).join("\n\n");
|
|
10109
|
-
const { title, body } = await buildForkTopic(transcript || sourceText, sourceText, mode);
|
|
10110
|
-
const backlink = `/chat/${encodeURIComponent(sourceThread.channel_id)}/${sourceThread.id}#m-${replyId.slice(0, 8)}`;
|
|
10111
|
-
const openingBody = `${body}
|
|
10112
|
-
|
|
10113
|
-
> \u2442 Forked from [a message by ${source.author}](${backlink})`;
|
|
10114
|
-
const { data: thread, error: threadError } = await supabase.from("threads").insert({ channel_id: sourceThread.channel_id, title, author }).select().single();
|
|
10115
|
-
if (threadError) return c.json({ error: threadError.message }, 500);
|
|
10116
|
-
const { data: reply, error: replyError } = await supabase.from("thread_replies").insert({ thread_id: thread.id, author, body: openingBody }).select().single();
|
|
10117
|
-
if (replyError) return c.json({ error: replyError.message }, 500);
|
|
10118
|
-
onMessage?.({
|
|
10119
|
-
threadId: thread.id,
|
|
10120
|
-
replyId: reply.id,
|
|
10121
|
-
author,
|
|
10122
|
-
body: openingBody
|
|
10123
|
-
});
|
|
10124
|
-
return c.json({ ...thread, replies: [reply] }, 201);
|
|
10125
|
-
});
|
|
10126
|
-
app.post("/threads/:id/replies", async (c) => {
|
|
10127
|
-
const threadId = c.req.param("id");
|
|
10128
|
-
const input2 = await c.req.json();
|
|
10129
|
-
if (!isNonEmptyString(input2.author) || !isNonEmptyString(input2.body)) {
|
|
10130
|
-
return c.json({ errors: ["author and body are required"] }, 400);
|
|
10131
|
-
}
|
|
10132
|
-
const clientKey2 = isNonEmptyString(input2.clientKey) ? input2.clientKey : null;
|
|
10133
|
-
const effort = requestedEffort(c, input2.effort);
|
|
10134
|
-
if (!await threadIsKnown(threadId, c)) return c.json({ error: "not found" }, 404);
|
|
10135
|
-
const model = requestedModel(c, input2.model) ?? await threadModel(threadId);
|
|
10136
|
-
const { data, error: error2 } = await getSupabaseClient().from("thread_replies").insert({
|
|
10137
|
-
thread_id: threadId,
|
|
10138
|
-
author: input2.author,
|
|
10139
|
-
body: input2.body,
|
|
10140
|
-
client_key: clientKey2,
|
|
10141
|
-
model,
|
|
10142
|
-
effort
|
|
10143
|
-
}).select().single();
|
|
10144
|
-
if (error2) {
|
|
10145
|
-
if (clientKey2 && error2.code === "23505") {
|
|
10146
|
-
const { data: existing } = await getSupabaseClient().from("thread_replies").select("*").eq("client_key", clientKey2).single();
|
|
10147
|
-
if (existing) return c.json(existing, 200);
|
|
10148
|
-
}
|
|
10149
|
-
return c.json({ error: error2.message }, 500);
|
|
10150
|
-
}
|
|
10151
|
-
onMessage?.({
|
|
10152
|
-
threadId,
|
|
10153
|
-
replyId: data.id,
|
|
10154
|
-
author: input2.author,
|
|
10155
|
-
body: input2.body,
|
|
10156
|
-
model: data.model ?? void 0,
|
|
10157
|
-
effort: effortFromRow(data.effort)
|
|
10158
|
-
});
|
|
10159
|
-
return c.json(data, 201);
|
|
9765
|
+
const { error: unsetError } = await supabase.from("channels").update({ category_id: null, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("category_id", id);
|
|
9766
|
+
if (unsetError) return c.json({ error: unsetError.message }, 500);
|
|
9767
|
+
await refreshChannelRegistry();
|
|
9768
|
+
const { error: error2 } = await supabase.from("categories").delete().eq("id", id).eq("profile", getProfile());
|
|
9769
|
+
if (error2) return c.json({ error: error2.message }, 404);
|
|
9770
|
+
return c.json({ ok: true });
|
|
10160
9771
|
});
|
|
10161
9772
|
return app;
|
|
10162
9773
|
}
|
|
10163
9774
|
|
|
10164
|
-
// ../api/src/dashboard/
|
|
9775
|
+
// ../api/src/dashboard/chat-routes.ts
|
|
10165
9776
|
import { Hono as Hono13 } from "hono";
|
|
10166
9777
|
|
|
10167
|
-
// ../api/src/dashboard/
|
|
10168
|
-
|
|
10169
|
-
var
|
|
10170
|
-
|
|
10171
|
-
|
|
10172
|
-
|
|
10173
|
-
brandingEnabled: true,
|
|
10174
|
-
translationEnabled: false,
|
|
10175
|
-
translationLangs: []
|
|
10176
|
-
};
|
|
10177
|
-
var HTML_FEATURES = {
|
|
10178
|
-
audioEnabled: false,
|
|
10179
|
-
askEnabled: false,
|
|
10180
|
-
socialEnabled: false,
|
|
10181
|
-
brandingEnabled: false,
|
|
10182
|
-
translationEnabled: false,
|
|
10183
|
-
translationLangs: []
|
|
10184
|
-
};
|
|
10185
|
-
function resolveFeatures(requested, existing) {
|
|
10186
|
-
const base = existing ?? DEFAULT_FEATURES;
|
|
10187
|
-
return {
|
|
10188
|
-
audioEnabled: requested?.audio ?? base.audioEnabled,
|
|
10189
|
-
askEnabled: requested?.ask ?? base.askEnabled,
|
|
10190
|
-
socialEnabled: requested?.social ?? base.socialEnabled,
|
|
10191
|
-
brandingEnabled: requested?.branding ?? base.brandingEnabled,
|
|
10192
|
-
translationEnabled: requested?.translation ?? base.translationEnabled,
|
|
10193
|
-
// Replaced wholesale, not merged: the allowlist IS the answer to "which
|
|
10194
|
-
// languages does this link offer", and a merge would make removing one
|
|
10195
|
-
// impossible.
|
|
10196
|
-
translationLangs: requested?.translationLangs ?? base.translationLangs
|
|
10197
|
-
};
|
|
10198
|
-
}
|
|
10199
|
-
async function deleteShareAssets(keys) {
|
|
10200
|
-
const client2 = getR2Client();
|
|
10201
|
-
const bucket = getR2Bucket();
|
|
10202
|
-
for (const key of keys) {
|
|
10203
|
-
try {
|
|
10204
|
-
await client2.send(new DeleteObjectCommand2({ Bucket: bucket, Key: key }));
|
|
10205
|
-
} catch {
|
|
10206
|
-
}
|
|
10207
|
-
}
|
|
10208
|
-
}
|
|
10209
|
-
async function updateShareFeatures(share, requested) {
|
|
10210
|
-
if (share.kind === "html") return share;
|
|
10211
|
-
if (share.kind === "excalidraw") {
|
|
10212
|
-
if (requested.branding === void 0) return share;
|
|
10213
|
-
return updateDocShareFeatures(share.id, { ...share, brandingEnabled: requested.branding });
|
|
10214
|
-
}
|
|
10215
|
-
const features = resolveFeatures(requested, share);
|
|
10216
|
-
const updated = await updateDocShareFeatures(share.id, features);
|
|
10217
|
-
if (!updated) return null;
|
|
10218
|
-
if (features.audioEnabled) void queueShareAudio(updated);
|
|
10219
|
-
else if (share.audioEnabled) await dropShareAudio(share);
|
|
10220
|
-
return updated;
|
|
10221
|
-
}
|
|
9778
|
+
// ../api/src/dashboard/fork-summary.ts
|
|
9779
|
+
var GROQ_API_URL4 = "https://api.groq.com/openai/v1/chat/completions";
|
|
9780
|
+
var SUMMARY_MODEL = "openai/gpt-oss-120b";
|
|
9781
|
+
var MAX_INPUT_CHARS2 = 24e3;
|
|
9782
|
+
var TITLE_MAX_CHARS = 80;
|
|
9783
|
+
var CONDENSE_SYSTEM_PROMPT = `You turn a chat conversation transcript into a new discussion topic where the work continues. Output EXACTLY two parts separated by one blank line: the first line is a concise topic title (max ${TITLE_MAX_CHARS} characters, no quotes, no Markdown), then a handoff summary of the conversation so far.
|
|
10222
9784
|
|
|
10223
|
-
|
|
10224
|
-
|
|
10225
|
-
|
|
9785
|
+
Rules:
|
|
9786
|
+
- Keep the conversation's own language. If it is Traditional Chinese (Taiwan), stay in Traditional Chinese with Taiwan wording and keep technical terms in English.
|
|
9787
|
+
- The summary opens a new thread where the work picks up: state the goal, what has been done or decided so far, and what remains open or should happen next.
|
|
9788
|
+
- Weight the END of the transcript more heavily: later messages reflect the current state. If an earlier plan or decision was later changed, superseded, or abandoned, summarize it in one clause at most and lead with what it became. Never present a discarded early approach with the same weight as the latest one.
|
|
9789
|
+
- Summarize the WORK, not the run mechanics: ignore status noise such as stopped/cancelled/terminated-run markers (e.g. "\u23F9 Stopped by user."), error or retry notices, and progress placeholders. A run being cut short is never part of the topic \u2014 capture the progress that was made instead.
|
|
9790
|
+
- Preserve essential code identifiers, file names, and URLs, but do not reproduce whole code blocks \u2014 describe what they do in one sentence instead.
|
|
9791
|
+
- No preamble like "Summary:" \u2014 start directly with the title line.`;
|
|
9792
|
+
var FOCUS_TITLE_SYSTEM_PROMPT = `You name a new discussion topic after one chat message that someone is branching off into a focused side-discussion.
|
|
10226
9793
|
|
|
10227
|
-
|
|
10228
|
-
|
|
10229
|
-
|
|
10230
|
-
|
|
10231
|
-
|
|
10232
|
-
|
|
9794
|
+
Output ONE line only: the title. Nothing else \u2014 no summary, no explanation, no blank line.
|
|
9795
|
+
|
|
9796
|
+
Shape:
|
|
9797
|
+
- A short noun phrase naming the subject, like a filed ticket's title. Max ${TITLE_MAX_CHARS} characters, and shorter is better.
|
|
9798
|
+
- Not a sentence copied from the message, not a quote, not Markdown. No bold, no backticks, no quotes, no trailing punctuation.
|
|
9799
|
+
|
|
9800
|
+
Content:
|
|
9801
|
+
- Keep the message's own language. If it is Traditional Chinese (Taiwan), stay in Traditional Chinese with Taiwan wording and keep technical terms in English.
|
|
9802
|
+
- Name the topic after the FINAL message in the input. Any earlier message is only context for what that message replies to, never the subject of the title.
|
|
9803
|
+
- Name that message's central point. A message that diagnoses something is named after the finding, even when its last paragraph proposes a next step \u2014 do not title it after the proposed action.
|
|
9804
|
+
- Never reframe it into a different question, never assert a conclusion the message does not reach, and never turn an open investigation into a settled recommendation or a list of steps.
|
|
9805
|
+
|
|
9806
|
+
Example \u2014 a message that works out why an admin could not sign in, and closes by suggesting turning email confirmation off:
|
|
9807
|
+
admin \u5E33\u865F\u88AB email \u9A57\u8B49\u4FE1\u5361\u4F4F`;
|
|
9808
|
+
function clampTitle(title) {
|
|
9809
|
+
return title.length > TITLE_MAX_CHARS ? `${title.slice(0, TITLE_MAX_CHARS - 1)}\u2026` : title;
|
|
10233
9810
|
}
|
|
10234
|
-
function
|
|
10235
|
-
|
|
10236
|
-
|
|
10237
|
-
`No Host identity at ${path}. Run \`pnpm run generate-host-key\` on this machine, then set FLYSPACE_HOST_PUBLIC_KEY on the Data process to the printed public key.`
|
|
10238
|
-
);
|
|
10239
|
-
}
|
|
10240
|
-
return createPrivateKey2(readFileSync16(path, "utf8"));
|
|
9811
|
+
function deriveTitle(body) {
|
|
9812
|
+
const line = body.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "Forked message";
|
|
9813
|
+
return clampTitle(line);
|
|
10241
9814
|
}
|
|
10242
|
-
|
|
10243
|
-
|
|
10244
|
-
|
|
10245
|
-
|
|
10246
|
-
|
|
10247
|
-
var CHALLENGE_TTL_MS = 2 * 6e4;
|
|
10248
|
-
var POLL_INTERVAL_S = 5;
|
|
10249
|
-
var sha256 = (s) => createHash8("sha256").update(s).digest("hex");
|
|
10250
|
-
function parseEd25519PublicKey(b64) {
|
|
10251
|
-
if (typeof b64 !== "string" || b64.length > 200) return null;
|
|
10252
|
-
try {
|
|
10253
|
-
const key = createPublicKey3({ key: Buffer.from(b64, "base64"), format: "der", type: "spki" });
|
|
10254
|
-
return key.asymmetricKeyType === "ed25519" ? key : null;
|
|
10255
|
-
} catch {
|
|
10256
|
-
return null;
|
|
10257
|
-
}
|
|
9815
|
+
function cleanTitleLine(raw) {
|
|
9816
|
+
const line = raw.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
|
|
9817
|
+
return clampTitle(
|
|
9818
|
+
line.replace(/^#{1,6}\s*/, "").replace(/^[*_`"'\u201c\u2018\u300c\u300e]+|[*_`"'\u201d\u2019\u300d\u300f]+$/g, "").trim()
|
|
9819
|
+
);
|
|
10258
9820
|
}
|
|
10259
|
-
|
|
10260
|
-
|
|
10261
|
-
const pick = () => Array.from({ length: 4 }, () => USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)]).join("");
|
|
10262
|
-
return `${pick()}-${pick()}`;
|
|
9821
|
+
function fallbackTopic(body) {
|
|
9822
|
+
return { title: deriveTitle(body), body };
|
|
10263
9823
|
}
|
|
10264
|
-
function
|
|
10265
|
-
const
|
|
10266
|
-
|
|
10267
|
-
|
|
10268
|
-
|
|
10269
|
-
|
|
10270
|
-
return
|
|
10271
|
-
id: String(r["id"]),
|
|
10272
|
-
userId: String(r["user_id"]),
|
|
10273
|
-
name: String(r["name"]),
|
|
10274
|
-
platform: r["platform"] ?? null,
|
|
10275
|
-
publicKey: String(r["public_key"]),
|
|
10276
|
-
createdAt: String(r["created_at"]),
|
|
10277
|
-
lastSeenAt: r["last_seen_at"] ?? null,
|
|
10278
|
-
revokedAt: r["revoked_at"] ?? null
|
|
10279
|
-
};
|
|
10280
|
-
}
|
|
10281
|
-
function toPending(r) {
|
|
9824
|
+
function parseTopic(raw, original) {
|
|
9825
|
+
const text = raw.trim();
|
|
9826
|
+
const breakAt = text.indexOf("\n");
|
|
9827
|
+
if (breakAt === -1) return { title: deriveTitle(text), body: text };
|
|
9828
|
+
const title = text.slice(0, breakAt).trim();
|
|
9829
|
+
const body = text.slice(breakAt + 1).trim();
|
|
9830
|
+
if (!title || !body) return fallbackTopic(original);
|
|
10282
9831
|
return {
|
|
10283
|
-
|
|
10284
|
-
|
|
10285
|
-
publicKey: String(r["public_key"]),
|
|
10286
|
-
name: String(r["name"]),
|
|
10287
|
-
platform: r["platform"] ?? null,
|
|
10288
|
-
requesterIp: r["requester_ip"] ?? null,
|
|
10289
|
-
approvedBy: r["approved_by"] ?? null,
|
|
10290
|
-
workspaceIds: r["workspace_ids"] ?? [],
|
|
10291
|
-
deniedAt: r["denied_at"] ?? null,
|
|
10292
|
-
lastPolledAt: r["last_polled_at"] ?? null,
|
|
10293
|
-
expiresAt: String(r["expires_at"]),
|
|
10294
|
-
createdAt: String(r["created_at"])
|
|
9832
|
+
title: clampTitle(title),
|
|
9833
|
+
body
|
|
10295
9834
|
};
|
|
10296
9835
|
}
|
|
10297
|
-
function
|
|
10298
|
-
|
|
10299
|
-
|
|
10300
|
-
|
|
10301
|
-
const
|
|
10302
|
-
|
|
10303
|
-
|
|
10304
|
-
|
|
10305
|
-
|
|
10306
|
-
|
|
10307
|
-
|
|
10308
|
-
|
|
10309
|
-
|
|
10310
|
-
|
|
10311
|
-
|
|
10312
|
-
|
|
10313
|
-
|
|
9836
|
+
async function buildForkTopic(transcript, fallbackText, mode = "condense") {
|
|
9837
|
+
const input2 = transcript.length > MAX_INPUT_CHARS2 ? transcript.slice(-MAX_INPUT_CHARS2) : transcript;
|
|
9838
|
+
const fallback = fallbackText ?? transcript;
|
|
9839
|
+
const focus = mode === "focus";
|
|
9840
|
+
const apiKey = process.env["GROQ_API_KEY"];
|
|
9841
|
+
if (!apiKey) return fallbackTopic(fallback);
|
|
9842
|
+
try {
|
|
9843
|
+
const res = await fetch(GROQ_API_URL4, {
|
|
9844
|
+
method: "POST",
|
|
9845
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
9846
|
+
body: JSON.stringify({
|
|
9847
|
+
model: SUMMARY_MODEL,
|
|
9848
|
+
temperature: 0.2,
|
|
9849
|
+
// A title has to sort the message's central claim from the next-step
|
|
9850
|
+
// paragraph it often ends with; 'low' reliably grabs the latter.
|
|
9851
|
+
reasoning_effort: focus ? "medium" : "low",
|
|
9852
|
+
// A title is one short line, but focus mode reasons before answering
|
|
9853
|
+
// and the budget covers reasoning tokens too — too small a cap here
|
|
9854
|
+
// truncates the title mid-word rather than failing.
|
|
9855
|
+
max_tokens: focus ? 512 : 1024,
|
|
9856
|
+
messages: [
|
|
9857
|
+
{
|
|
9858
|
+
role: "system",
|
|
9859
|
+
content: focus ? FOCUS_TITLE_SYSTEM_PROMPT : CONDENSE_SYSTEM_PROMPT
|
|
9860
|
+
},
|
|
9861
|
+
{ role: "user", content: input2 }
|
|
9862
|
+
]
|
|
9863
|
+
})
|
|
10314
9864
|
});
|
|
10315
|
-
if (!
|
|
10316
|
-
|
|
9865
|
+
if (!res.ok) throw new Error(`Groq request failed (${res.status})`);
|
|
9866
|
+
const data = await res.json();
|
|
9867
|
+
const raw = data.choices?.[0]?.message?.content?.trim();
|
|
9868
|
+
if (!raw) throw new Error("Groq returned an empty summary");
|
|
9869
|
+
if (focus) return { title: cleanTitleLine(raw) || deriveTitle(fallback), body: fallback };
|
|
9870
|
+
return parseTopic(raw, fallback);
|
|
9871
|
+
} catch (err) {
|
|
9872
|
+
console.error("Fork summary failed, using original text:", err);
|
|
9873
|
+
return fallbackTopic(fallback);
|
|
10317
9874
|
}
|
|
10318
|
-
throw new Error("createDeviceCode: could not allocate a unique user code");
|
|
10319
9875
|
}
|
|
10320
|
-
|
|
10321
|
-
|
|
10322
|
-
|
|
10323
|
-
|
|
9876
|
+
|
|
9877
|
+
// ../api/src/dashboard/slack-format.ts
|
|
9878
|
+
import { nameToEmoji } from "gemoji";
|
|
9879
|
+
var SLACK_EMOJI_ALIASES = {
|
|
9880
|
+
double_vertical_bar: "\u23F8\uFE0F"
|
|
9881
|
+
};
|
|
9882
|
+
function emojifyShortcode(code) {
|
|
9883
|
+
const name = code.slice(1, -1);
|
|
9884
|
+
return nameToEmoji[name] ?? SLACK_EMOJI_ALIASES[name] ?? code;
|
|
10324
9885
|
}
|
|
10325
|
-
|
|
10326
|
-
|
|
10327
|
-
if (error2) fail("getDeviceCodeByUserCode", error2);
|
|
10328
|
-
return data ? toPending(data) : null;
|
|
9886
|
+
function resolveSlackTokens(text) {
|
|
9887
|
+
return text.replace(/<@([A-Z0-9]+)(?:\|[^>]*)?>/g, (_m, id) => `@${getLocalCache().displayNameFor(id)}`).replace(/<#([A-Z0-9]+)\|([^>]+)>/g, (_m, _id, name) => `#${name}`).replace(/<#([A-Z0-9]+)>/g, (_m, id) => `#${getLocalCache().lookupChannelById(id)?.name ?? id}`).replace(/<!channel>/g, "@channel").replace(/<!here>/g, "@here").replace(/<!everyone>/g, "@everyone").replace(/<!subteam\^[A-Z0-9]+\|@?([^>]+)>/g, "@$1").replace(/<mailto:([^|>]+)\|([^>]+)>/g, (_m, addr, label2) => `[${label2}](mailto:${addr})`).replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, (_m, url, label2) => `[${label2}](${url})`).replace(/:[a-z0-9_+-]+:/gi, emojifyShortcode);
|
|
10329
9888
|
}
|
|
10330
|
-
|
|
10331
|
-
|
|
10332
|
-
if (error2) fail("markPolled", error2);
|
|
9889
|
+
function slackMrkdwnToMarkdown(text) {
|
|
9890
|
+
return resolveSlackTokens(text).replace(/(?<![\w*])\*(?!\*)(\S(?:[^*\n]*?\S)?)\*(?!\*)(?![\w*])/g, "**$1**").replace(/(?<![\w~])~(?!~)(\S(?:[^~\n]*?\S)?)~(?!~)(?![\w~])/g, "~~$1~~").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
10333
9891
|
}
|
|
10334
|
-
|
|
10335
|
-
|
|
10336
|
-
|
|
10337
|
-
|
|
10338
|
-
|
|
10339
|
-
|
|
9892
|
+
|
|
9893
|
+
// ../api/src/dashboard/thread-relevance.ts
|
|
9894
|
+
async function relevantThreadsPage(supabase, handle, channelIds, opts) {
|
|
9895
|
+
const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9896
|
+
const { data, error: error2 } = await supabase.rpc("relevant_threads", {
|
|
9897
|
+
handle,
|
|
9898
|
+
mention_pattern: `@${escaped}\\M`,
|
|
9899
|
+
channel_ids: channelIds,
|
|
9900
|
+
before: opts.before ?? null,
|
|
9901
|
+
page_limit: opts.limit
|
|
9902
|
+
});
|
|
9903
|
+
if (error2) throw new Error(`relevant_threads: ${error2.message}`);
|
|
9904
|
+
return data ?? [];
|
|
10340
9905
|
}
|
|
10341
|
-
|
|
10342
|
-
|
|
10343
|
-
|
|
10344
|
-
|
|
10345
|
-
|
|
10346
|
-
|
|
10347
|
-
const
|
|
10348
|
-
|
|
10349
|
-
|
|
10350
|
-
|
|
10351
|
-
|
|
10352
|
-
|
|
10353
|
-
|
|
10354
|
-
|
|
10355
|
-
|
|
10356
|
-
|
|
10357
|
-
|
|
10358
|
-
|
|
10359
|
-
workspace_id: workspaceId,
|
|
10360
|
-
bound_by: pending2.approvedBy
|
|
10361
|
-
})),
|
|
10362
|
-
{ onConflict: "device_id,workspace_id", ignoreDuplicates: true }
|
|
10363
|
-
);
|
|
10364
|
-
if (bindError) fail("redeemDeviceCode", bindError);
|
|
9906
|
+
|
|
9907
|
+
// ../api/src/dashboard/thread-translate.ts
|
|
9908
|
+
import { createHash as createHash8 } from "crypto";
|
|
9909
|
+
|
|
9910
|
+
// ../api/src/store/thread-translation-store.ts
|
|
9911
|
+
async function getThreadTranslations(lang, keys) {
|
|
9912
|
+
const found = /* @__PURE__ */ new Map();
|
|
9913
|
+
if (keys.length === 0) return found;
|
|
9914
|
+
const clauses = keys.map((k) => `and(reply_id.eq.${k.replyId},body_hash.eq.${k.bodyHash})`).join(",");
|
|
9915
|
+
const { data, error: error2 } = await getSupabaseClient().from("thread_reply_translations").select("reply_id, body, passthrough").eq("lang", lang).or(clauses);
|
|
9916
|
+
if (error2) throw new Error(`getThreadTranslations(${lang}): ${error2.message}`);
|
|
9917
|
+
for (const row of data ?? []) {
|
|
9918
|
+
const replyId = row["reply_id"];
|
|
9919
|
+
found.set(replyId, {
|
|
9920
|
+
replyId,
|
|
9921
|
+
body: row["body"],
|
|
9922
|
+
passthrough: row["passthrough"]
|
|
9923
|
+
});
|
|
10365
9924
|
}
|
|
10366
|
-
return
|
|
9925
|
+
return found;
|
|
10367
9926
|
}
|
|
10368
|
-
async function
|
|
10369
|
-
|
|
10370
|
-
|
|
10371
|
-
|
|
9927
|
+
async function putThreadTranslations(lang, rows) {
|
|
9928
|
+
if (rows.length === 0) return;
|
|
9929
|
+
const { error: error2 } = await getSupabaseClient().from("thread_reply_translations").upsert(
|
|
9930
|
+
rows.map((r) => ({
|
|
9931
|
+
reply_id: r.replyId,
|
|
9932
|
+
lang,
|
|
9933
|
+
body_hash: r.bodyHash,
|
|
9934
|
+
body: r.body,
|
|
9935
|
+
passthrough: r.passthrough
|
|
9936
|
+
})),
|
|
9937
|
+
{ onConflict: "reply_id,lang,body_hash" }
|
|
9938
|
+
);
|
|
9939
|
+
if (error2) throw new Error(`putThreadTranslations(${lang}): ${error2.message}`);
|
|
10372
9940
|
}
|
|
10373
|
-
|
|
10374
|
-
|
|
10375
|
-
|
|
10376
|
-
|
|
9941
|
+
|
|
9942
|
+
// ../api/src/dashboard/thread-translate.ts
|
|
9943
|
+
var GROQ_API_URL5 = "https://api.groq.com/openai/v1/chat/completions";
|
|
9944
|
+
var TRANSLATION_MODEL2 = "openai/gpt-oss-120b";
|
|
9945
|
+
var RECIPE_VERSION3 = 1;
|
|
9946
|
+
var BATCH_CHAR_BUDGET = 16e3;
|
|
9947
|
+
var CONTEXT_MESSAGES = 4;
|
|
9948
|
+
var MAX_MESSAGE_CHARS = 12e3;
|
|
9949
|
+
var MAX_OUTPUT_TOKENS2 = 32768;
|
|
9950
|
+
var BY_CODE2 = new Map(TRANSLATION_LANGUAGES.map((l) => [l.code, l]));
|
|
9951
|
+
function isTranslatableLanguage2(lang) {
|
|
9952
|
+
return BY_CODE2.has(lang);
|
|
10377
9953
|
}
|
|
10378
|
-
|
|
10379
|
-
|
|
10380
|
-
|
|
10381
|
-
|
|
10382
|
-
access_token_expires_at: new Date(Date.now() + ACCESS_TOKEN_TTL_MS).toISOString(),
|
|
10383
|
-
last_seen_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
10384
|
-
}).eq("id", deviceId);
|
|
10385
|
-
if (error2) fail("issueAccessToken", error2);
|
|
10386
|
-
return { token, expiresInS: ACCESS_TOKEN_TTL_MS / 1e3 };
|
|
9954
|
+
function translationHash2(author, body) {
|
|
9955
|
+
return createHash8("sha256").update(`${RECIPE_VERSION3}
|
|
9956
|
+
${author}
|
|
9957
|
+
${body}`).digest("hex");
|
|
10387
9958
|
}
|
|
10388
|
-
|
|
10389
|
-
|
|
10390
|
-
|
|
10391
|
-
|
|
10392
|
-
|
|
9959
|
+
function systemPrompt2(languageName2) {
|
|
9960
|
+
return `You translate messages from a group chat into ${languageName2}. You are a translator, not an editor, a summarizer, or a participant.
|
|
9961
|
+
|
|
9962
|
+
You receive a JSON object with two keys:
|
|
9963
|
+
- "context": earlier messages, for reference only. NEVER translate these and never mention them in your output.
|
|
9964
|
+
- "translate": the messages to translate, each with an "n" and a "body".
|
|
9965
|
+
|
|
9966
|
+
Reply with ONLY a JSON object of this exact shape, one entry per message in "translate", in the same order:
|
|
9967
|
+
{"messages":[{"n":1,"body":"...","already_target":false}]}
|
|
9968
|
+
|
|
9969
|
+
Rules:
|
|
9970
|
+
- "already_target" is true when the message is ALREADY written in ${languageName2}. In that case copy "body" through byte for byte, changing nothing at all. A message whose prose is in another language is NOT already_target just because it contains English technical terms, code, or product names.
|
|
9971
|
+
- Translate meaning, not words. These are people talking, not prose to be polished. Keep the speaker's register: a terse message stays terse, a casual one stays casual, a rude one stays rude. Do not make anyone sound more formal, more complete, or more articulate than they wrote.
|
|
9972
|
+
- A short reply ("\u597D\u554A", "same", "nope") is translated as the natural equivalent in ${languageName2}, using the context to see what it is answering. Never expand it into a full sentence the speaker did not write.
|
|
9973
|
+
- Keep the Markdown exactly as it is: same heading levels, list nesting, table shape, link and image syntax, blockquotes, and order. Do not add, merge, split, or reorder anything.
|
|
9974
|
+
- Do NOT translate: text inside fenced or inline code blocks, URLs, file paths, command names, or @mentions and /skill tokens. Leave them byte-for-byte identical.
|
|
9975
|
+
- Keep established technical terms in English where a fluent speaker of ${languageName2} would keep them in English rather than reaching for a translated coinage.
|
|
9976
|
+
- Use the punctuation conventions of ${languageName2}, including full-width punctuation for Chinese and Japanese. Use ordinary ASCII hyphens, never typographic or non-breaking hyphen variants.
|
|
9977
|
+
- Never add commentary, notes about your choices, apologies, or an explanation of anything you could not translate.`;
|
|
10393
9978
|
}
|
|
10394
|
-
|
|
10395
|
-
|
|
10396
|
-
if (error2) fail("revokeDevice", error2);
|
|
9979
|
+
function normalizeHyphens2(text) {
|
|
9980
|
+
return text.replace(/‐|‑/g, "-");
|
|
10397
9981
|
}
|
|
10398
|
-
|
|
10399
|
-
|
|
10400
|
-
|
|
10401
|
-
|
|
10402
|
-
const
|
|
10403
|
-
|
|
10404
|
-
|
|
10405
|
-
|
|
9982
|
+
function clip(body) {
|
|
9983
|
+
return body.length > MAX_MESSAGE_CHARS ? body.slice(0, MAX_MESSAGE_CHARS) : body;
|
|
9984
|
+
}
|
|
9985
|
+
function batchMessages(messages) {
|
|
9986
|
+
const batches = [];
|
|
9987
|
+
let current = [];
|
|
9988
|
+
let size = 0;
|
|
9989
|
+
for (const m of messages) {
|
|
9990
|
+
const len = Math.min(m.body.length, MAX_MESSAGE_CHARS);
|
|
9991
|
+
if (current.length > 0 && size + len > BATCH_CHAR_BUDGET) {
|
|
9992
|
+
batches.push(current);
|
|
9993
|
+
current = [];
|
|
9994
|
+
size = 0;
|
|
9995
|
+
}
|
|
9996
|
+
current.push(m);
|
|
9997
|
+
size += len;
|
|
9998
|
+
}
|
|
9999
|
+
if (current.length > 0) batches.push(current);
|
|
10000
|
+
return batches;
|
|
10001
|
+
}
|
|
10002
|
+
async function translateBatch(batch, context, lang) {
|
|
10003
|
+
const language = BY_CODE2.get(lang);
|
|
10004
|
+
if (!language) throw new Error(`unsupported translation language: ${lang}`);
|
|
10005
|
+
const apiKey = process.env["GROQ_API_KEY"];
|
|
10006
|
+
if (!apiKey) throw new Error("GROQ_API_KEY is not configured");
|
|
10007
|
+
const payload = {
|
|
10008
|
+
context: context.map((m) => ({ author: m.author, body: clip(m.body) })),
|
|
10009
|
+
translate: batch.map((m, i) => ({ n: i + 1, author: m.author, body: clip(m.body) }))
|
|
10010
|
+
};
|
|
10011
|
+
const res = await fetch(GROQ_API_URL5, {
|
|
10012
|
+
method: "POST",
|
|
10013
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
10014
|
+
body: JSON.stringify({
|
|
10015
|
+
model: TRANSLATION_MODEL2,
|
|
10016
|
+
temperature: 0,
|
|
10017
|
+
max_tokens: MAX_OUTPUT_TOKENS2,
|
|
10018
|
+
response_format: { type: "json_object" },
|
|
10019
|
+
messages: [
|
|
10020
|
+
{ role: "system", content: systemPrompt2(language.name) },
|
|
10021
|
+
{ role: "user", content: JSON.stringify(payload) }
|
|
10022
|
+
]
|
|
10023
|
+
})
|
|
10024
|
+
});
|
|
10025
|
+
if (!res.ok) throw new Error(`translation model returned ${res.status}`);
|
|
10026
|
+
const data = await res.json();
|
|
10027
|
+
const choice = data.choices?.[0];
|
|
10028
|
+
if (choice?.finish_reason === "length") {
|
|
10029
|
+
throw new Error("the translation ran past the model output limit");
|
|
10030
|
+
}
|
|
10031
|
+
const raw = choice?.message?.content?.trim() ?? "";
|
|
10032
|
+
if (!raw) throw new Error("the model returned an empty translation");
|
|
10033
|
+
let parsed;
|
|
10034
|
+
try {
|
|
10035
|
+
parsed = JSON.parse(raw);
|
|
10036
|
+
} catch {
|
|
10037
|
+
throw new Error("the model returned unparseable JSON");
|
|
10038
|
+
}
|
|
10039
|
+
const byNumber = /* @__PURE__ */ new Map();
|
|
10040
|
+
for (const entry of parsed.messages ?? []) {
|
|
10041
|
+
if (typeof entry?.n === "number") byNumber.set(entry.n, entry);
|
|
10042
|
+
}
|
|
10043
|
+
return batch.map((m, i) => {
|
|
10044
|
+
const entry = byNumber.get(i + 1);
|
|
10045
|
+
const body = typeof entry?.body === "string" ? entry.body.trim() : "";
|
|
10046
|
+
if (!body) return { id: m.id, body: m.body, passthrough: true };
|
|
10047
|
+
return {
|
|
10048
|
+
id: m.id,
|
|
10049
|
+
body: normalizeHyphens2(body),
|
|
10050
|
+
passthrough: entry?.already_target === true
|
|
10051
|
+
};
|
|
10052
|
+
});
|
|
10053
|
+
}
|
|
10054
|
+
var inFlight4 = /* @__PURE__ */ new Map();
|
|
10055
|
+
async function translateThread(threadId, lang, messages) {
|
|
10056
|
+
if (!BY_CODE2.has(lang)) throw new Error(`unsupported translation language: ${lang}`);
|
|
10057
|
+
if (messages.length === 0) return [];
|
|
10058
|
+
const shape = createHash8("sha256").update(messages.map((m) => translationHash2(m.author, m.body)).join("\n")).digest("hex");
|
|
10059
|
+
const key = `${threadId}:${lang}:${shape}`;
|
|
10060
|
+
const running = inFlight4.get(key);
|
|
10061
|
+
if (running) return running;
|
|
10062
|
+
const job = (async () => {
|
|
10063
|
+
const hashes = messages.map((m) => ({
|
|
10064
|
+
replyId: m.id,
|
|
10065
|
+
bodyHash: translationHash2(m.author, m.body)
|
|
10066
|
+
}));
|
|
10067
|
+
const cached = await getThreadTranslations(lang, hashes);
|
|
10068
|
+
const missing = messages.filter((m) => !cached.has(m.id));
|
|
10069
|
+
const fresh = /* @__PURE__ */ new Map();
|
|
10070
|
+
if (missing.length > 0) {
|
|
10071
|
+
const index = new Map(messages.map((m, i) => [m.id, i]));
|
|
10072
|
+
const batches = batchMessages(missing);
|
|
10073
|
+
const results = await Promise.all(
|
|
10074
|
+
batches.map((batch) => {
|
|
10075
|
+
const first = index.get(batch[0].id) ?? 0;
|
|
10076
|
+
const context = messages.slice(Math.max(0, first - CONTEXT_MESSAGES), first);
|
|
10077
|
+
return translateBatch(batch, context, lang);
|
|
10078
|
+
})
|
|
10079
|
+
);
|
|
10080
|
+
for (const row of results.flat()) fresh.set(row.id, row);
|
|
10081
|
+
const hashById = new Map(hashes.map((h) => [h.replyId, h.bodyHash]));
|
|
10082
|
+
await putThreadTranslations(
|
|
10083
|
+
lang,
|
|
10084
|
+
[...fresh.values()].map((r) => ({
|
|
10085
|
+
replyId: r.id,
|
|
10086
|
+
bodyHash: hashById.get(r.id),
|
|
10087
|
+
body: r.body,
|
|
10088
|
+
passthrough: r.passthrough
|
|
10089
|
+
}))
|
|
10090
|
+
);
|
|
10091
|
+
}
|
|
10092
|
+
return messages.map((m) => {
|
|
10093
|
+
const hit = cached.get(m.id);
|
|
10094
|
+
if (hit) return { id: m.id, body: hit.body, passthrough: hit.passthrough };
|
|
10095
|
+
return fresh.get(m.id) ?? { id: m.id, body: m.body, passthrough: true };
|
|
10096
|
+
});
|
|
10097
|
+
})().finally(() => inFlight4.delete(key));
|
|
10098
|
+
inFlight4.set(key, job);
|
|
10099
|
+
return job;
|
|
10100
|
+
}
|
|
10101
|
+
|
|
10102
|
+
// ../api/src/dashboard/chat-routes.ts
|
|
10103
|
+
function isNonEmptyString(v) {
|
|
10104
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
10105
|
+
}
|
|
10106
|
+
function sanitizeModel(v) {
|
|
10107
|
+
if (typeof v !== "string") return null;
|
|
10108
|
+
const model = v.trim().toLowerCase();
|
|
10109
|
+
return isAllowedModel(model) ? model : null;
|
|
10110
|
+
}
|
|
10111
|
+
async function threadModel(threadId) {
|
|
10112
|
+
const { data } = await getSupabaseClient().from("thread_replies").select("model").eq("thread_id", threadId).not("model", "is", null).order("created_at", { ascending: false }).limit(1).maybeSingle();
|
|
10113
|
+
return sanitizeModel(data?.model);
|
|
10114
|
+
}
|
|
10115
|
+
function requestedModel(c, model) {
|
|
10116
|
+
return c.get("auth")?.role === "admin" ? sanitizeModel(model) : null;
|
|
10117
|
+
}
|
|
10118
|
+
function sanitizeEffort(v) {
|
|
10119
|
+
if (typeof v !== "string") return null;
|
|
10120
|
+
const effort = v.trim().toLowerCase();
|
|
10121
|
+
return isEffort(effort) ? effort : null;
|
|
10122
|
+
}
|
|
10123
|
+
function requestedEffort(c, effort) {
|
|
10124
|
+
return c.get("auth")?.role === "admin" ? sanitizeEffort(effort) : null;
|
|
10125
|
+
}
|
|
10126
|
+
function effortFromRow(v) {
|
|
10127
|
+
return typeof v === "string" && isEffort(v) ? v : void 0;
|
|
10128
|
+
}
|
|
10129
|
+
var BOARD_STATUSES = [
|
|
10130
|
+
"backlog",
|
|
10131
|
+
"todo",
|
|
10132
|
+
"in_progress",
|
|
10133
|
+
"in_review",
|
|
10134
|
+
"done",
|
|
10135
|
+
"cancelled"
|
|
10136
|
+
];
|
|
10137
|
+
var NO_STATUS_COLUMN = "none";
|
|
10138
|
+
function boardColumnParam(status) {
|
|
10139
|
+
if (status === NO_STATUS_COLUMN) return NO_STATUS_COLUMN;
|
|
10140
|
+
return BOARD_STATUSES.includes(status) ? status : null;
|
|
10141
|
+
}
|
|
10142
|
+
var THREAD_CHUNK_SIZE = 25;
|
|
10143
|
+
var THREADS_PAGE_SIZE = 30;
|
|
10144
|
+
var THREADS_PAGE_SIZE_MAX = 100;
|
|
10145
|
+
var REPLIES_PAGE_SIZE = 50;
|
|
10146
|
+
var REPLIES_PAGE_SIZE_MAX = 200;
|
|
10147
|
+
var TRANSLATABLE_REPLIES_MAX = 400;
|
|
10148
|
+
var BOARD_PAGE_SIZE = 50;
|
|
10149
|
+
var BOARD_PAGE_SIZE_MAX = 100;
|
|
10150
|
+
function ownChannelIds(c) {
|
|
10151
|
+
const viewer = c.get("auth");
|
|
10152
|
+
return new Set(
|
|
10153
|
+
getProfileChannelRegistry().filter((ch) => canSeeChannel(ch, viewer)).flatMap((ch) => ch.externalChannelId ? [ch.id, ch.externalChannelId] : [ch.id])
|
|
10154
|
+
);
|
|
10155
|
+
}
|
|
10156
|
+
async function globalChannelMeta(c) {
|
|
10157
|
+
const viewer = c.get("auth");
|
|
10158
|
+
const map = /* @__PURE__ */ new Map();
|
|
10159
|
+
const channels = getChannelRegistry();
|
|
10160
|
+
let workspaceIdBySlug = /* @__PURE__ */ new Map();
|
|
10161
|
+
let roleByWorkspaceId = /* @__PURE__ */ new Map();
|
|
10162
|
+
if (viewer?.email && channels.length > 0) {
|
|
10163
|
+
const [workspaces, memberships] = await Promise.all([
|
|
10164
|
+
listWorkspaces(),
|
|
10165
|
+
listMembershipsByEmail(viewer.email)
|
|
10166
|
+
]);
|
|
10167
|
+
workspaceIdBySlug = new Map(workspaces.map((w) => [w.slug, w.id]));
|
|
10168
|
+
roleByWorkspaceId = new Map(memberships.map((m) => [m.workspaceId, m.role]));
|
|
10169
|
+
}
|
|
10170
|
+
for (const ch of channels) {
|
|
10171
|
+
const workspaceId = workspaceIdBySlug.get(ch.profile);
|
|
10172
|
+
const role = workspaceId ? roleByWorkspaceId.get(workspaceId) : void 0;
|
|
10173
|
+
if (!canSeeChannel(ch, { email: viewer?.email, role })) continue;
|
|
10174
|
+
const meta = { workspace: ch.profile, channelName: ch.name, agentName: ch.agentName };
|
|
10175
|
+
map.set(ch.id, meta);
|
|
10176
|
+
if (ch.externalChannelId) map.set(ch.externalChannelId, meta);
|
|
10177
|
+
}
|
|
10178
|
+
return map;
|
|
10179
|
+
}
|
|
10180
|
+
async function threadIsKnown(threadId, c) {
|
|
10181
|
+
const { data } = await getSupabaseClient().from("threads").select("channel_id").eq("id", threadId).single();
|
|
10182
|
+
const channelId = data?.channel_id;
|
|
10183
|
+
return !!channelId && (await globalChannelMeta(c)).has(channelId);
|
|
10184
|
+
}
|
|
10185
|
+
function chunk(items, size) {
|
|
10186
|
+
const out = [];
|
|
10187
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
10188
|
+
return out;
|
|
10189
|
+
}
|
|
10190
|
+
async function withPreviews(supabase, threads) {
|
|
10191
|
+
const chunkResults = await Promise.all(
|
|
10192
|
+
chunk(
|
|
10193
|
+
threads.map((t) => t.id),
|
|
10194
|
+
THREAD_CHUNK_SIZE
|
|
10195
|
+
).map(
|
|
10196
|
+
(ids) => supabase.from("thread_replies").select("thread_id, body, author, external_user_id").in("thread_id", ids).is("archived_at", null).order("created_at", { ascending: true })
|
|
10197
|
+
)
|
|
10198
|
+
);
|
|
10199
|
+
const previewByThread = /* @__PURE__ */ new Map();
|
|
10200
|
+
for (const { data } of chunkResults) {
|
|
10201
|
+
for (const r of data ?? []) {
|
|
10202
|
+
const entry = previewByThread.get(r.thread_id);
|
|
10203
|
+
if (entry) {
|
|
10204
|
+
entry.count += 1;
|
|
10205
|
+
entry.lastAuthor = r.author;
|
|
10206
|
+
entry.participants.delete(r.author);
|
|
10207
|
+
entry.participants.set(r.author, r.external_user_id ?? null);
|
|
10208
|
+
} else
|
|
10209
|
+
previewByThread.set(r.thread_id, {
|
|
10210
|
+
count: 1,
|
|
10211
|
+
firstBody: r.body,
|
|
10212
|
+
lastAuthor: r.author,
|
|
10213
|
+
participants: /* @__PURE__ */ new Map()
|
|
10214
|
+
});
|
|
10215
|
+
}
|
|
10216
|
+
}
|
|
10217
|
+
return threads.map((t) => {
|
|
10218
|
+
const entry = previewByThread.get(t.id);
|
|
10219
|
+
return {
|
|
10220
|
+
...t,
|
|
10221
|
+
reply_count: Math.max(0, (entry?.count ?? 0) - 1),
|
|
10222
|
+
preview: entry?.firstBody ? slackMrkdwnToMarkdown(entry.firstBody) : null,
|
|
10223
|
+
// Who spoke last, counting only replies AFTER the opening message —
|
|
10224
|
+
// the reader's own handle here means there is nothing new for them to
|
|
10225
|
+
// read, on this browser or any other (see web/src/lib/thread-unread).
|
|
10226
|
+
last_reply_author: entry && entry.count > 1 ? entry.lastAuthor : null,
|
|
10227
|
+
// Everyone who replied, most recent first, for the row's avatar stack.
|
|
10228
|
+
participants: [...entry?.participants ?? []].reverse().map(([author, external_user_id]) => ({ author, external_user_id }))
|
|
10229
|
+
};
|
|
10230
|
+
});
|
|
10231
|
+
}
|
|
10232
|
+
async function labelThread(channelId, body, scope) {
|
|
10233
|
+
const channel = getProfileChannelRegistry().find(
|
|
10234
|
+
(ch) => ch.id === channelId || ch.externalChannelId === channelId
|
|
10235
|
+
);
|
|
10236
|
+
if (!channel) return null;
|
|
10237
|
+
const supabase = getSupabaseClient();
|
|
10238
|
+
const { data: recent } = await supabase.from("threads").select("auto_title, title").eq("channel_id", channelId).order("created_at", { ascending: false }).limit(50);
|
|
10239
|
+
const existingCategories = extractCategories(
|
|
10240
|
+
(recent ?? []).flatMap((t) => [
|
|
10241
|
+
t.auto_title,
|
|
10242
|
+
t.title
|
|
10243
|
+
])
|
|
10244
|
+
);
|
|
10245
|
+
return buildAutoTitle({
|
|
10246
|
+
channelName: channel.name,
|
|
10247
|
+
channelPrompt: channel.prompt,
|
|
10248
|
+
workspacePrompt: loadWorkspacePrompt().prompt,
|
|
10249
|
+
titlePrompt: (await loadWorkspaceBranding()).autoTitlePrompt,
|
|
10250
|
+
existingCategories,
|
|
10251
|
+
body,
|
|
10252
|
+
scope
|
|
10253
|
+
});
|
|
10254
|
+
}
|
|
10255
|
+
async function generateAutoTitle(threadId, channelId, body) {
|
|
10256
|
+
if ((await loadWorkspaceBranding()).autoTitleEnabled === false) return;
|
|
10257
|
+
const autoTitle = await labelThread(channelId, body);
|
|
10258
|
+
if (!autoTitle) return;
|
|
10259
|
+
const supabase = getSupabaseClient();
|
|
10260
|
+
await supabase.from("threads").update({ auto_title: autoTitle }).eq("id", threadId).is("title", null);
|
|
10261
|
+
}
|
|
10262
|
+
function createChatRouter(agent = {}) {
|
|
10263
|
+
const app = new Hono13();
|
|
10264
|
+
const {
|
|
10265
|
+
onChatMessage: onMessage,
|
|
10266
|
+
isAgentThinking,
|
|
10267
|
+
getAgentActivities,
|
|
10268
|
+
cancelAgentRun,
|
|
10269
|
+
regenerateAgentRun,
|
|
10270
|
+
listAgentRuns
|
|
10271
|
+
} = agent;
|
|
10272
|
+
app.post(
|
|
10273
|
+
"/threads/:id/agent-cancel",
|
|
10274
|
+
async (c) => c.json({ cancelled: await cancelAgentRun?.(c.req.param("id")) ?? false })
|
|
10275
|
+
);
|
|
10276
|
+
app.post(
|
|
10277
|
+
"/threads/:id/agent-regenerate",
|
|
10278
|
+
async (c) => c.json({ started: await regenerateAgentRun?.(c.req.param("id")) ?? false })
|
|
10279
|
+
);
|
|
10280
|
+
app.get(
|
|
10281
|
+
"/threads/:id/agent-status",
|
|
10282
|
+
async (c) => c.json({
|
|
10283
|
+
thinking: await isAgentThinking?.(c.req.param("id")) ?? false,
|
|
10284
|
+
activities: await getAgentActivities?.(c.req.param("id")) ?? []
|
|
10285
|
+
})
|
|
10286
|
+
);
|
|
10287
|
+
app.get("/agent-runs", async (c) => c.json({ threadIds: await listAgentRuns?.() ?? [] }));
|
|
10288
|
+
app.get("/channels/:channelId/threads", async (c) => {
|
|
10289
|
+
const channelId = c.req.param("channelId");
|
|
10290
|
+
if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
|
|
10291
|
+
const requestedLimit = Number(c.req.query("limit"));
|
|
10292
|
+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
|
|
10293
|
+
const before = c.req.query("before");
|
|
10294
|
+
const sortColumn = c.req.query("sort") === "activity" ? "updated_at" : "created_at";
|
|
10295
|
+
const statusParam = c.req.query("status");
|
|
10296
|
+
const statusFilter = statusParam ? [
|
|
10297
|
+
...new Set(
|
|
10298
|
+
statusParam.split(",").map(boardColumnParam).filter((s) => s !== null)
|
|
10299
|
+
)
|
|
10300
|
+
] : [];
|
|
10301
|
+
const supabase = getSupabaseClient();
|
|
10302
|
+
let query = supabase.from("threads").select("*").eq("channel_id", channelId).is("archived_at", null).order(sortColumn, { ascending: false }).limit(limit);
|
|
10303
|
+
if (before) query = query.lt(sortColumn, before);
|
|
10304
|
+
if (statusFilter.length) {
|
|
10305
|
+
const withoutNone = statusFilter.filter((s) => s !== NO_STATUS_COLUMN);
|
|
10306
|
+
const includeNone = statusFilter.includes(NO_STATUS_COLUMN);
|
|
10307
|
+
if (includeNone && withoutNone.length) {
|
|
10308
|
+
query = query.or(`board_status.in.(${withoutNone.join(",")}),board_status.is.null`);
|
|
10309
|
+
} else if (includeNone) {
|
|
10310
|
+
query = query.is("board_status", null);
|
|
10311
|
+
} else {
|
|
10312
|
+
query = query.in("board_status", withoutNone);
|
|
10313
|
+
}
|
|
10314
|
+
}
|
|
10315
|
+
const { data: threads, error: error2 } = await query;
|
|
10316
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10317
|
+
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
10318
|
+
const withPreview = await withPreviews(supabase, threads);
|
|
10319
|
+
const nextCursor = threads.length === limit ? threads[threads.length - 1][sortColumn] : null;
|
|
10320
|
+
return c.json({ threads: withPreview, nextCursor });
|
|
10321
|
+
});
|
|
10322
|
+
app.get("/channels/:channelId/board/:status", async (c) => {
|
|
10323
|
+
const channelId = c.req.param("channelId");
|
|
10324
|
+
if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
|
|
10325
|
+
const column = boardColumnParam(c.req.param("status"));
|
|
10326
|
+
if (!column) {
|
|
10327
|
+
return c.json(
|
|
10328
|
+
{ error: `status must be one of: ${[...BOARD_STATUSES, NO_STATUS_COLUMN].join(", ")}` },
|
|
10329
|
+
400
|
|
10330
|
+
);
|
|
10331
|
+
}
|
|
10332
|
+
const requestedLimit = Number(c.req.query("limit"));
|
|
10333
|
+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, BOARD_PAGE_SIZE_MAX) : BOARD_PAGE_SIZE;
|
|
10334
|
+
const before = c.req.query("before");
|
|
10335
|
+
const supabase = getSupabaseClient();
|
|
10336
|
+
let query = supabase.from("threads").select("*").eq("channel_id", channelId);
|
|
10337
|
+
query = column === NO_STATUS_COLUMN ? query.is("board_status", null) : query.eq("board_status", column);
|
|
10338
|
+
query = query.is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
|
|
10339
|
+
if (before) query = query.lt("updated_at", before);
|
|
10340
|
+
const { data: threads, error: error2 } = await query;
|
|
10341
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10342
|
+
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
10343
|
+
const withPreview = await withPreviews(supabase, threads);
|
|
10344
|
+
const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
|
|
10345
|
+
return c.json({ threads: withPreview, nextCursor });
|
|
10346
|
+
});
|
|
10347
|
+
app.get("/board/:status", async (c) => {
|
|
10348
|
+
const column = boardColumnParam(c.req.param("status"));
|
|
10349
|
+
if (!column) {
|
|
10350
|
+
return c.json(
|
|
10351
|
+
{ error: `status must be one of: ${[...BOARD_STATUSES, NO_STATUS_COLUMN].join(", ")}` },
|
|
10352
|
+
400
|
|
10353
|
+
);
|
|
10354
|
+
}
|
|
10355
|
+
const requestedLimit = Number(c.req.query("limit"));
|
|
10356
|
+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, BOARD_PAGE_SIZE_MAX) : BOARD_PAGE_SIZE;
|
|
10357
|
+
const before = c.req.query("before");
|
|
10358
|
+
const meta = c.req.query("scope") === "all" ? await globalChannelMeta(c) : null;
|
|
10359
|
+
const supabase = getSupabaseClient();
|
|
10360
|
+
let query = supabase.from("threads").select("*").in("channel_id", meta ? [...meta.keys()] : [...ownChannelIds(c)]);
|
|
10361
|
+
query = column === NO_STATUS_COLUMN ? query.is("board_status", null) : query.eq("board_status", column);
|
|
10362
|
+
query = query.is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
|
|
10363
|
+
if (before) query = query.lt("updated_at", before);
|
|
10364
|
+
const { data: threads, error: error2 } = await query;
|
|
10365
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10366
|
+
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
10367
|
+
const withPreview = await withPreviews(supabase, threads);
|
|
10368
|
+
const labelled = meta ? withPreview.map((t) => {
|
|
10369
|
+
const channelMeta = meta.get(t.channel_id ?? "");
|
|
10370
|
+
return {
|
|
10371
|
+
...t,
|
|
10372
|
+
workspace: channelMeta?.workspace ?? null,
|
|
10373
|
+
channel_name: channelMeta?.channelName ?? null,
|
|
10374
|
+
agent_name: channelMeta?.agentName ?? null
|
|
10375
|
+
};
|
|
10376
|
+
}) : withPreview;
|
|
10377
|
+
const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
|
|
10378
|
+
return c.json({ threads: labelled, nextCursor });
|
|
10379
|
+
});
|
|
10380
|
+
app.get("/threads", async (c) => {
|
|
10381
|
+
const requestedLimit = Number(c.req.query("limit"));
|
|
10382
|
+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
|
|
10383
|
+
const before = c.req.query("before");
|
|
10384
|
+
const meta = c.req.query("scope") === "all" ? await globalChannelMeta(c) : null;
|
|
10385
|
+
const relevantTo = c.req.query("relevantTo");
|
|
10386
|
+
const supabase = getSupabaseClient();
|
|
10387
|
+
const channelIds = meta ? [...meta.keys()] : [...ownChannelIds(c)];
|
|
10388
|
+
let threads;
|
|
10389
|
+
if (isNonEmptyString(relevantTo)) {
|
|
10390
|
+
try {
|
|
10391
|
+
threads = await relevantThreadsPage(supabase, relevantTo, channelIds, { before, limit });
|
|
10392
|
+
} catch (err) {
|
|
10393
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
10394
|
+
}
|
|
10395
|
+
} else {
|
|
10396
|
+
let query = supabase.from("threads").select("*").in("channel_id", channelIds).is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
|
|
10397
|
+
if (before) query = query.lt("updated_at", before);
|
|
10398
|
+
const { data, error: error2 } = await query;
|
|
10399
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10400
|
+
threads = data;
|
|
10401
|
+
}
|
|
10402
|
+
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
10403
|
+
const threadIds = threads.map((t) => t.id);
|
|
10404
|
+
const chunkResults = await Promise.all(
|
|
10405
|
+
chunk(threadIds, THREAD_CHUNK_SIZE).map(
|
|
10406
|
+
(ids) => supabase.from("thread_replies").select("*").in("thread_id", ids).is("archived_at", null).order("created_at", { ascending: true })
|
|
10407
|
+
)
|
|
10408
|
+
);
|
|
10409
|
+
const repliesByThread = /* @__PURE__ */ new Map();
|
|
10410
|
+
for (const { data } of chunkResults) {
|
|
10411
|
+
for (const r of data ?? []) {
|
|
10412
|
+
const arr = repliesByThread.get(r.thread_id);
|
|
10413
|
+
if (arr) arr.push(r);
|
|
10414
|
+
else repliesByThread.set(r.thread_id, [r]);
|
|
10415
|
+
}
|
|
10416
|
+
}
|
|
10417
|
+
const withReplies = threads.map((t) => {
|
|
10418
|
+
const channelMeta = meta?.get(t.channel_id);
|
|
10419
|
+
return {
|
|
10420
|
+
...t,
|
|
10421
|
+
...meta ? {
|
|
10422
|
+
workspace: channelMeta?.workspace ?? null,
|
|
10423
|
+
channel_name: channelMeta?.channelName ?? null,
|
|
10424
|
+
agent_name: channelMeta?.agentName ?? null
|
|
10425
|
+
} : {},
|
|
10426
|
+
replies: (repliesByThread.get(t.id) ?? []).map((r) => ({
|
|
10427
|
+
...r,
|
|
10428
|
+
body: slackMrkdwnToMarkdown(r.body)
|
|
10429
|
+
}))
|
|
10430
|
+
};
|
|
10431
|
+
});
|
|
10432
|
+
const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
|
|
10433
|
+
return c.json({ threads: withReplies, nextCursor });
|
|
10434
|
+
});
|
|
10435
|
+
app.get("/threads/archived", async (c) => {
|
|
10436
|
+
if (c.get("auth")?.role !== "admin") {
|
|
10437
|
+
return c.json({ error: "only an admin can view archived topics" }, 403);
|
|
10438
|
+
}
|
|
10439
|
+
const requestedLimit = Number(c.req.query("limit"));
|
|
10440
|
+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
|
|
10441
|
+
const before = c.req.query("before");
|
|
10442
|
+
const supabase = getSupabaseClient();
|
|
10443
|
+
let query = supabase.from("threads").select("*").in("channel_id", [...ownChannelIds(c)]).not("archived_at", "is", null).order("archived_at", { ascending: false }).limit(limit);
|
|
10444
|
+
if (before) query = query.lt("archived_at", before);
|
|
10445
|
+
const { data: threads, error: error2 } = await query;
|
|
10446
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10447
|
+
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
10448
|
+
const withPreview = await withPreviews(supabase, threads);
|
|
10449
|
+
const nextCursor = threads.length === limit ? threads[threads.length - 1].archived_at : null;
|
|
10450
|
+
return c.json({ threads: withPreview, nextCursor });
|
|
10451
|
+
});
|
|
10452
|
+
app.get("/threads/:id", async (c) => {
|
|
10453
|
+
const id = c.req.param("id");
|
|
10454
|
+
const requestedLimit = Number(c.req.query("limit"));
|
|
10455
|
+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, REPLIES_PAGE_SIZE_MAX) : REPLIES_PAGE_SIZE;
|
|
10456
|
+
const before = c.req.query("before");
|
|
10457
|
+
let repliesQuery = getSupabaseClient().from("thread_replies").select("*, reactions:reply_reactions(emoji, author)").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: false }).limit(limit);
|
|
10458
|
+
if (before) repliesQuery = repliesQuery.lt("created_at", before);
|
|
10459
|
+
const [thread, replies, opening] = await Promise.all([
|
|
10460
|
+
getSupabaseClient().from("threads").select("*").eq("id", id).single(),
|
|
10461
|
+
repliesQuery,
|
|
10462
|
+
// The opening message, fetched separately because the replies page
|
|
10463
|
+
// above is newest-first: on a long thread the opener isn't in it. It
|
|
10464
|
+
// is what the header falls back to on a thread with no title of its
|
|
10465
|
+
// own (see threadCrumbLabel) — a title is never written on the
|
|
10466
|
+
// thread's behalf, so an untitled row must still be able to show and
|
|
10467
|
+
// rename a topic.
|
|
10468
|
+
getSupabaseClient().from("thread_replies").select("body").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: true }).limit(1)
|
|
10469
|
+
]);
|
|
10470
|
+
if (thread.error) return c.json({ error: thread.error.message }, 404);
|
|
10471
|
+
if (replies.error) return c.json({ error: replies.error.message }, 500);
|
|
10472
|
+
const channelId = thread.data?.channel_id;
|
|
10473
|
+
const meta = await globalChannelMeta(c);
|
|
10474
|
+
const channelMeta = channelId ? meta.get(channelId) : void 0;
|
|
10475
|
+
if (!channelId || !channelMeta) {
|
|
10476
|
+
return c.json({ error: "not found" }, 404);
|
|
10477
|
+
}
|
|
10478
|
+
const page2 = replies.data ?? [];
|
|
10479
|
+
const nextCursor = page2.length === limit ? page2[page2.length - 1].created_at : null;
|
|
10480
|
+
const formattedReplies = page2.reverse().map((r) => ({
|
|
10481
|
+
...r,
|
|
10482
|
+
body: slackMrkdwnToMarkdown(r.body)
|
|
10483
|
+
}));
|
|
10484
|
+
const openingBody = (opening.data ?? [])[0]?.body;
|
|
10485
|
+
return c.json({
|
|
10486
|
+
...thread.data,
|
|
10487
|
+
// Always stamped (not just on `?scope=all` feeds, see the `/threads`
|
|
10488
|
+
// route above) — the reply pane can be opened directly by id (global
|
|
10489
|
+
// board, permalink) without the active workspace's channel map ever
|
|
10490
|
+
// having loaded this thread's channel, so the header needs its own
|
|
10491
|
+
// workspace/channel label rather than depending on that cache.
|
|
10492
|
+
workspace: channelMeta.workspace,
|
|
10493
|
+
channel_name: channelMeta.channelName,
|
|
10494
|
+
agent_name: channelMeta.agentName,
|
|
10495
|
+
preview: openingBody ? slackMrkdwnToMarkdown(openingBody) : null,
|
|
10496
|
+
replies: formattedReplies,
|
|
10497
|
+
nextCursor
|
|
10498
|
+
});
|
|
10499
|
+
});
|
|
10500
|
+
app.post("/threads/:id/translation/:lang", async (c) => {
|
|
10501
|
+
const id = c.req.param("id");
|
|
10502
|
+
const lang = canonicalLanguage(c.req.param("lang"));
|
|
10503
|
+
if (!isTranslatableLanguage2(lang)) return c.json({ error: "unsupported language" }, 400);
|
|
10504
|
+
const [thread, replies] = await Promise.all([
|
|
10505
|
+
getSupabaseClient().from("threads").select("channel_id").eq("id", id).single(),
|
|
10506
|
+
getSupabaseClient().from("thread_replies").select("id, author, body").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: true }).limit(TRANSLATABLE_REPLIES_MAX)
|
|
10507
|
+
]);
|
|
10508
|
+
if (thread.error) return c.json({ error: "not found" }, 404);
|
|
10509
|
+
if (replies.error) return c.json({ error: replies.error.message }, 500);
|
|
10510
|
+
const channelId = thread.data?.channel_id;
|
|
10511
|
+
if (!channelId || !(await globalChannelMeta(c)).has(channelId)) {
|
|
10512
|
+
return c.json({ error: "not found" }, 404);
|
|
10513
|
+
}
|
|
10514
|
+
const messages = (replies.data ?? []).map(
|
|
10515
|
+
(r) => ({ id: r.id, author: r.author, body: slackMrkdwnToMarkdown(r.body) })
|
|
10516
|
+
);
|
|
10517
|
+
try {
|
|
10518
|
+
return c.json({ lang, messages: await translateThread(id, lang, messages) });
|
|
10519
|
+
} catch (err) {
|
|
10520
|
+
const message = err instanceof Error ? err.message : "translation failed";
|
|
10521
|
+
console.error(`Thread translation failed (${id} \u2192 ${lang}):`, err);
|
|
10522
|
+
return c.json({ error: message }, 502);
|
|
10523
|
+
}
|
|
10524
|
+
});
|
|
10525
|
+
app.patch("/threads/:id/board-status", async (c) => {
|
|
10526
|
+
const id = c.req.param("id");
|
|
10527
|
+
const input2 = await c.req.json().catch(() => null);
|
|
10528
|
+
const value = input2?.board_status ?? null;
|
|
10529
|
+
if (value !== null && !BOARD_STATUSES.includes(value)) {
|
|
10530
|
+
return c.json(
|
|
10531
|
+
{ error: `board_status must be null or one of: ${BOARD_STATUSES.join(", ")}` },
|
|
10532
|
+
400
|
|
10533
|
+
);
|
|
10534
|
+
}
|
|
10535
|
+
if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
|
|
10536
|
+
const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ board_status: value }).eq("id", id).select().single();
|
|
10537
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10538
|
+
return c.json(thread);
|
|
10539
|
+
});
|
|
10540
|
+
app.patch("/threads/:id", async (c) => {
|
|
10541
|
+
const id = c.req.param("id");
|
|
10542
|
+
const input2 = await c.req.json().catch(() => null);
|
|
10543
|
+
const raw = input2?.title;
|
|
10544
|
+
if (raw !== null && !isNonEmptyString(raw)) {
|
|
10545
|
+
return c.json({ errors: ["title must be a non-empty string, or null to clear it"] }, 400);
|
|
10546
|
+
}
|
|
10547
|
+
const title = raw === null ? null : raw.trim();
|
|
10548
|
+
if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
|
|
10549
|
+
const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ title }).eq("id", id).select().single();
|
|
10550
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10551
|
+
return c.json(thread);
|
|
10552
|
+
});
|
|
10553
|
+
app.post("/threads/:id/suggest-title", async (c) => {
|
|
10554
|
+
const id = c.req.param("id");
|
|
10555
|
+
if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
|
|
10556
|
+
const { data: thread } = await getSupabaseClient().from("threads").select("channel_id").eq("id", id).single();
|
|
10557
|
+
if (!thread) return c.json({ error: "not found" }, 404);
|
|
10558
|
+
const { data: rows, error: error2 } = await getSupabaseClient().from("thread_replies").select("author, body").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: true });
|
|
10559
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10560
|
+
const transcript = (rows ?? []).map((r) => `${r.author}: ${slackMrkdwnToMarkdown(r.body)}`).join("\n\n");
|
|
10561
|
+
const title = await labelThread(thread.channel_id, transcript, "thread");
|
|
10562
|
+
if (!title) return c.json({ error: "could not generate a topic" }, 502);
|
|
10563
|
+
return c.json({ title });
|
|
10564
|
+
});
|
|
10565
|
+
app.patch("/replies/:replyId", async (c) => {
|
|
10566
|
+
const replyId = c.req.param("replyId");
|
|
10567
|
+
const input2 = await c.req.json().catch(() => null);
|
|
10568
|
+
const author = input2?.author;
|
|
10569
|
+
const body = input2?.body;
|
|
10570
|
+
if (!isNonEmptyString(author) || !isNonEmptyString(body)) {
|
|
10571
|
+
return c.json({ errors: ["author and body are required"] }, 400);
|
|
10572
|
+
}
|
|
10573
|
+
const supabase = getSupabaseClient();
|
|
10574
|
+
const { data: existing, error: fetchError } = await supabase.from("thread_replies").select("*").eq("id", replyId).single();
|
|
10575
|
+
if (fetchError) return c.json({ error: fetchError.message }, 404);
|
|
10576
|
+
if (!await threadIsKnown(existing.thread_id, c)) return c.json({ error: "not found" }, 404);
|
|
10577
|
+
if (existing.author !== author) {
|
|
10578
|
+
return c.json({ error: "only the author can edit a message" }, 403);
|
|
10579
|
+
}
|
|
10580
|
+
const { data: reply, error: error2 } = await supabase.from("thread_replies").update({ body, edited_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", replyId).select().single();
|
|
10581
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10582
|
+
if (isHumanAuthor(author)) {
|
|
10583
|
+
const { data: latest2 } = await supabase.from("thread_replies").select("id").eq("thread_id", existing.thread_id).is("archived_at", null).order("created_at", { ascending: false }).limit(1).maybeSingle();
|
|
10584
|
+
if (latest2?.id === replyId) {
|
|
10585
|
+
await Promise.resolve(regenerateAgentRun?.(existing.thread_id, replyId)).catch(() => {
|
|
10586
|
+
});
|
|
10587
|
+
}
|
|
10588
|
+
}
|
|
10589
|
+
return c.json(reply);
|
|
10590
|
+
});
|
|
10591
|
+
app.post("/replies/:replyId/reactions", async (c) => {
|
|
10592
|
+
const replyId = c.req.param("replyId");
|
|
10593
|
+
const input2 = await c.req.json().catch(() => null);
|
|
10594
|
+
const author = input2?.author;
|
|
10595
|
+
const emoji = input2?.emoji;
|
|
10596
|
+
if (!isNonEmptyString(author) || !isNonEmptyString(emoji)) {
|
|
10597
|
+
return c.json({ errors: ["author and emoji are required"] }, 400);
|
|
10598
|
+
}
|
|
10599
|
+
const supabase = getSupabaseClient();
|
|
10600
|
+
const { data: reply, error: replyError } = await supabase.from("thread_replies").select("thread_id").eq("id", replyId).single();
|
|
10601
|
+
if (replyError) return c.json({ error: "not found" }, 404);
|
|
10602
|
+
if (!await threadIsKnown(reply.thread_id, c)) return c.json({ error: "not found" }, 404);
|
|
10603
|
+
const { data: existing, error: existingError } = await supabase.from("reply_reactions").select("id").eq("reply_id", replyId).eq("emoji", emoji).eq("author", author).maybeSingle();
|
|
10604
|
+
if (existingError) return c.json({ error: existingError.message }, 500);
|
|
10605
|
+
if (existing) {
|
|
10606
|
+
const { error: error2 } = await supabase.from("reply_reactions").delete().eq("id", existing.id);
|
|
10607
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10608
|
+
} else {
|
|
10609
|
+
const { error: error2 } = await supabase.from("reply_reactions").insert({ reply_id: replyId, emoji, author });
|
|
10610
|
+
if (error2 && error2.code !== "23505") return c.json({ error: error2.message }, 500);
|
|
10611
|
+
}
|
|
10612
|
+
const { data: reactions, error: reactionsError } = await supabase.from("reply_reactions").select("emoji, author").eq("reply_id", replyId);
|
|
10613
|
+
if (reactionsError) return c.json({ error: reactionsError.message }, 500);
|
|
10614
|
+
return c.json({ reactions: reactions ?? [] });
|
|
10615
|
+
});
|
|
10616
|
+
app.patch("/threads/:id/archive", async (c) => {
|
|
10617
|
+
if (c.get("auth")?.role !== "admin") {
|
|
10618
|
+
return c.json({ error: "only an admin can archive a topic" }, 403);
|
|
10619
|
+
}
|
|
10620
|
+
const id = c.req.param("id");
|
|
10621
|
+
const input2 = await c.req.json().catch(() => null);
|
|
10622
|
+
const archived = input2?.archived;
|
|
10623
|
+
if (typeof archived !== "boolean") {
|
|
10624
|
+
return c.json({ errors: ["archived (boolean) is required"] }, 400);
|
|
10625
|
+
}
|
|
10626
|
+
if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
|
|
10627
|
+
const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ archived_at: archived ? (/* @__PURE__ */ new Date()).toISOString() : null }).eq("id", id).select().single();
|
|
10628
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10629
|
+
return c.json(thread);
|
|
10630
|
+
});
|
|
10631
|
+
app.post("/channels/:channelId/threads", async (c) => {
|
|
10632
|
+
const channelId = c.req.param("channelId");
|
|
10633
|
+
if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
|
|
10634
|
+
const input2 = await c.req.json();
|
|
10635
|
+
if (!isNonEmptyString(input2.author) || !isNonEmptyString(input2.body)) {
|
|
10636
|
+
return c.json({ errors: ["author and body are required"] }, 400);
|
|
10637
|
+
}
|
|
10638
|
+
const clientKey2 = isNonEmptyString(input2.clientKey) ? input2.clientKey : null;
|
|
10639
|
+
const model = requestedModel(c, input2.model);
|
|
10640
|
+
const effort = requestedEffort(c, input2.effort);
|
|
10641
|
+
const supabase = getSupabaseClient();
|
|
10642
|
+
let thread;
|
|
10643
|
+
let replayed = false;
|
|
10644
|
+
const { data: inserted, error: threadError } = await supabase.from("threads").insert({
|
|
10645
|
+
channel_id: channelId,
|
|
10646
|
+
// A stored title means someone named the topic. A blank one is not a
|
|
10647
|
+
// name, so it lands as null and the topic is derived from the
|
|
10648
|
+
// opening message at display time (see threadCrumbLabel).
|
|
10649
|
+
title: isNonEmptyString(input2.title) ? input2.title.trim() : null,
|
|
10650
|
+
author: input2.author,
|
|
10651
|
+
client_key: clientKey2
|
|
10652
|
+
}).select().single();
|
|
10653
|
+
if (threadError) {
|
|
10654
|
+
if (!clientKey2 || threadError.code !== "23505") {
|
|
10655
|
+
return c.json({ error: threadError.message }, 500);
|
|
10656
|
+
}
|
|
10657
|
+
const { data: existing } = await supabase.from("threads").select("*").eq("client_key", clientKey2).single();
|
|
10658
|
+
if (!existing) return c.json({ error: threadError.message }, 500);
|
|
10659
|
+
thread = existing;
|
|
10660
|
+
replayed = true;
|
|
10661
|
+
} else {
|
|
10662
|
+
thread = inserted;
|
|
10663
|
+
}
|
|
10664
|
+
let { data: reply } = replayed && clientKey2 ? await supabase.from("thread_replies").select("*").eq("client_key", clientKey2).single() : { data: null };
|
|
10665
|
+
let replyCompleted = false;
|
|
10666
|
+
if (!reply) {
|
|
10667
|
+
const { data: insertedReply, error: replyError } = await supabase.from("thread_replies").insert({
|
|
10668
|
+
thread_id: thread.id,
|
|
10669
|
+
author: input2.author,
|
|
10670
|
+
body: input2.body,
|
|
10671
|
+
client_key: clientKey2,
|
|
10672
|
+
model,
|
|
10673
|
+
effort
|
|
10674
|
+
}).select().single();
|
|
10675
|
+
if (replyError) return c.json({ error: replyError.message }, 500);
|
|
10676
|
+
reply = insertedReply;
|
|
10677
|
+
replyCompleted = true;
|
|
10678
|
+
}
|
|
10679
|
+
if (!replayed || replyCompleted) {
|
|
10680
|
+
onMessage?.({
|
|
10681
|
+
threadId: thread.id,
|
|
10682
|
+
replyId: reply.id,
|
|
10683
|
+
author: input2.author,
|
|
10684
|
+
body: input2.body,
|
|
10685
|
+
// From the row, not `model`/`effort`: on the replay path the reply
|
|
10686
|
+
// predates this request, so its own stored override is the source of
|
|
10687
|
+
// truth.
|
|
10688
|
+
model: reply["model"] ?? void 0,
|
|
10689
|
+
effort: effortFromRow(reply["effort"])
|
|
10690
|
+
});
|
|
10691
|
+
}
|
|
10692
|
+
if (!replayed && thread["title"] == null) {
|
|
10693
|
+
void generateAutoTitle(thread.id, channelId, input2.body).catch(
|
|
10694
|
+
(err) => console.error("Auto title failed:", err)
|
|
10695
|
+
);
|
|
10696
|
+
}
|
|
10697
|
+
return c.json({ ...thread, replies: [reply] }, replayed ? 200 : 201);
|
|
10406
10698
|
});
|
|
10407
|
-
|
|
10408
|
-
|
|
10409
|
-
|
|
10410
|
-
|
|
10411
|
-
|
|
10412
|
-
|
|
10413
|
-
return (data ?? []).length > 0;
|
|
10414
|
-
}
|
|
10415
|
-
async function listAdminWorkspaces(userId) {
|
|
10416
|
-
const { data, error: error2 } = await getSupabaseClient().from("memberships").select("workspace_id, workspaces!inner(slug)").eq("user_id", userId).eq("role", "admin").eq("status", "active");
|
|
10417
|
-
if (error2) fail("listAdminWorkspaces", error2);
|
|
10418
|
-
return (data ?? []).map((r) => ({ id: r.workspace_id, slug: r.workspaces.slug })).sort((a, b) => a.slug.localeCompare(b.slug));
|
|
10419
|
-
}
|
|
10420
|
-
async function listDeviceWorkspaces(device) {
|
|
10421
|
-
const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("workspace_id").eq("device_id", device.id);
|
|
10422
|
-
if (error2) fail("listDeviceWorkspaces", error2);
|
|
10423
|
-
const bound = new Set((data ?? []).map((r) => r.workspace_id));
|
|
10424
|
-
return (await listAdminWorkspaces(device.userId)).filter((w) => bound.has(w.id));
|
|
10425
|
-
}
|
|
10426
|
-
async function bindDeviceWorkspace(deviceId, workspaceId, boundBy) {
|
|
10427
|
-
const { error: error2 } = await getSupabaseClient().from("device_workspaces").upsert(
|
|
10428
|
-
{ device_id: deviceId, workspace_id: workspaceId, bound_by: boundBy },
|
|
10429
|
-
{ onConflict: "device_id,workspace_id", ignoreDuplicates: true }
|
|
10430
|
-
);
|
|
10431
|
-
if (error2) fail("bindDeviceWorkspace", error2);
|
|
10432
|
-
}
|
|
10433
|
-
async function listWorkspaceDevices(workspaceId) {
|
|
10434
|
-
const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("devices!inner(*)").eq("workspace_id", workspaceId).is("devices.revoked_at", null);
|
|
10435
|
-
if (error2) fail("listWorkspaceDevices", error2);
|
|
10436
|
-
return (data ?? []).map((r) => toDevice(r.devices)).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
10437
|
-
}
|
|
10438
|
-
var STALE_HOST_MS = 14 * 24 * 60 * 6e4;
|
|
10439
|
-
var isStale = (lastSeenAt) => !!lastSeenAt && Date.now() - Date.parse(lastSeenAt) > STALE_HOST_MS;
|
|
10440
|
-
async function listWorkspaceHosts(workspaceIds, exceptPublicKey) {
|
|
10441
|
-
const hosts2 = /* @__PURE__ */ new Map();
|
|
10442
|
-
if (!workspaceIds.length) return hosts2;
|
|
10443
|
-
const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("workspace_id, devices!inner(name, public_key, revoked_at, last_seen_at)").in("workspace_id", workspaceIds).is("devices.revoked_at", null);
|
|
10444
|
-
if (error2) fail("listWorkspaceHosts", error2);
|
|
10445
|
-
for (const r of data ?? []) {
|
|
10446
|
-
if (r.devices.public_key !== exceptPublicKey && !isStale(r.devices.last_seen_at)) {
|
|
10447
|
-
hosts2.set(r.workspace_id, r.devices.name);
|
|
10699
|
+
app.post("/replies/:replyId/fork", async (c) => {
|
|
10700
|
+
const replyId = c.req.param("replyId");
|
|
10701
|
+
const input2 = await c.req.json().catch(() => null);
|
|
10702
|
+
const author = input2?.author;
|
|
10703
|
+
if (!isNonEmptyString(author)) {
|
|
10704
|
+
return c.json({ errors: ["author is required"] }, 400);
|
|
10448
10705
|
}
|
|
10449
|
-
|
|
10450
|
-
|
|
10451
|
-
}
|
|
10452
|
-
|
|
10453
|
-
|
|
10454
|
-
|
|
10455
|
-
|
|
10456
|
-
|
|
10457
|
-
|
|
10458
|
-
|
|
10459
|
-
const
|
|
10460
|
-
|
|
10461
|
-
|
|
10462
|
-
|
|
10463
|
-
|
|
10464
|
-
|
|
10465
|
-
|
|
10466
|
-
|
|
10467
|
-
}
|
|
10706
|
+
const mode = input2?.mode === "focus" ? "focus" : "condense";
|
|
10707
|
+
const supabase = getSupabaseClient();
|
|
10708
|
+
const { data: source, error: sourceError } = await supabase.from("thread_replies").select("*").eq("id", replyId).single();
|
|
10709
|
+
if (sourceError) return c.json({ error: sourceError.message }, 404);
|
|
10710
|
+
const { data: sourceThread, error: sourceThreadError } = await supabase.from("threads").select("*").eq("id", source.thread_id).single();
|
|
10711
|
+
if (sourceThreadError) return c.json({ error: sourceThreadError.message }, 404);
|
|
10712
|
+
if (!(await globalChannelMeta(c)).has(sourceThread.channel_id)) {
|
|
10713
|
+
return c.json({ error: "not found" }, 404);
|
|
10714
|
+
}
|
|
10715
|
+
const { data: priorReplies } = await supabase.from("thread_replies").select("author, body, created_at").eq("thread_id", source.thread_id).lte("created_at", source.created_at).is("archived_at", null).order("created_at", { ascending: true });
|
|
10716
|
+
const priorRows = priorReplies ?? [];
|
|
10717
|
+
const sourceText = slackMrkdwnToMarkdown(source.body);
|
|
10718
|
+
const FOCUS_CONTEXT_BELOW_CHARS = 280;
|
|
10719
|
+
const inputRows = mode === "focus" ? priorRows.slice(sourceText.length < FOCUS_CONTEXT_BELOW_CHARS ? -2 : -1) : priorRows;
|
|
10720
|
+
const transcript = inputRows.map((r) => `${r.author}: ${slackMrkdwnToMarkdown(r.body)}`).join("\n\n");
|
|
10721
|
+
const { title, body } = await buildForkTopic(transcript || sourceText, sourceText, mode);
|
|
10722
|
+
const backlink = `/chat/${encodeURIComponent(sourceThread.channel_id)}/${sourceThread.id}#m-${replyId.slice(0, 8)}`;
|
|
10723
|
+
const openingBody = `${body}
|
|
10468
10724
|
|
|
10469
|
-
|
|
10470
|
-
|
|
10471
|
-
|
|
10472
|
-
|
|
10473
|
-
|
|
10474
|
-
|
|
10475
|
-
|
|
10476
|
-
|
|
10477
|
-
|
|
10478
|
-
|
|
10479
|
-
|
|
10480
|
-
|
|
10481
|
-
|
|
10482
|
-
"
|
|
10483
|
-
|
|
10484
|
-
|
|
10485
|
-
|
|
10486
|
-
}
|
|
10487
|
-
|
|
10488
|
-
|
|
10489
|
-
|
|
10490
|
-
|
|
10491
|
-
|
|
10492
|
-
|
|
10725
|
+
> \u2442 Forked from [a message by ${source.author}](${backlink})`;
|
|
10726
|
+
const { data: thread, error: threadError } = await supabase.from("threads").insert({ channel_id: sourceThread.channel_id, title, author }).select().single();
|
|
10727
|
+
if (threadError) return c.json({ error: threadError.message }, 500);
|
|
10728
|
+
const { data: reply, error: replyError } = await supabase.from("thread_replies").insert({ thread_id: thread.id, author, body: openingBody }).select().single();
|
|
10729
|
+
if (replyError) return c.json({ error: replyError.message }, 500);
|
|
10730
|
+
onMessage?.({
|
|
10731
|
+
threadId: thread.id,
|
|
10732
|
+
replyId: reply.id,
|
|
10733
|
+
author,
|
|
10734
|
+
body: openingBody
|
|
10735
|
+
});
|
|
10736
|
+
return c.json({ ...thread, replies: [reply] }, 201);
|
|
10737
|
+
});
|
|
10738
|
+
app.post("/threads/:id/replies", async (c) => {
|
|
10739
|
+
const threadId = c.req.param("id");
|
|
10740
|
+
const input2 = await c.req.json();
|
|
10741
|
+
if (!isNonEmptyString(input2.author) || !isNonEmptyString(input2.body)) {
|
|
10742
|
+
return c.json({ errors: ["author and body are required"] }, 400);
|
|
10743
|
+
}
|
|
10744
|
+
const clientKey2 = isNonEmptyString(input2.clientKey) ? input2.clientKey : null;
|
|
10745
|
+
const effort = requestedEffort(c, input2.effort);
|
|
10746
|
+
if (!await threadIsKnown(threadId, c)) return c.json({ error: "not found" }, 404);
|
|
10747
|
+
const model = requestedModel(c, input2.model) ?? await threadModel(threadId);
|
|
10748
|
+
const { data, error: error2 } = await getSupabaseClient().from("thread_replies").insert({
|
|
10749
|
+
thread_id: threadId,
|
|
10750
|
+
author: input2.author,
|
|
10751
|
+
body: input2.body,
|
|
10752
|
+
client_key: clientKey2,
|
|
10753
|
+
model,
|
|
10754
|
+
effort
|
|
10755
|
+
}).select().single();
|
|
10756
|
+
if (error2) {
|
|
10757
|
+
if (clientKey2 && error2.code === "23505") {
|
|
10758
|
+
const { data: existing } = await getSupabaseClient().from("thread_replies").select("*").eq("client_key", clientKey2).single();
|
|
10759
|
+
if (existing) return c.json(existing, 200);
|
|
10760
|
+
}
|
|
10761
|
+
return c.json({ error: error2.message }, 500);
|
|
10762
|
+
}
|
|
10763
|
+
onMessage?.({
|
|
10764
|
+
threadId,
|
|
10765
|
+
replyId: data.id,
|
|
10766
|
+
author: input2.author,
|
|
10767
|
+
body: input2.body,
|
|
10768
|
+
model: data.model ?? void 0,
|
|
10769
|
+
effort: effortFromRow(data.effort)
|
|
10770
|
+
});
|
|
10771
|
+
return c.json(data, 201);
|
|
10772
|
+
});
|
|
10773
|
+
return app;
|
|
10493
10774
|
}
|
|
10494
10775
|
|
|
10495
|
-
// ../api/src/dashboard/
|
|
10496
|
-
|
|
10497
|
-
|
|
10498
|
-
|
|
10499
|
-
|
|
10500
|
-
|
|
10501
|
-
|
|
10502
|
-
|
|
10503
|
-
|
|
10776
|
+
// ../api/src/dashboard/doc-shares-routes.ts
|
|
10777
|
+
import { Hono as Hono14 } from "hono";
|
|
10778
|
+
|
|
10779
|
+
// ../api/src/dashboard/doc-share-features.ts
|
|
10780
|
+
import { DeleteObjectCommand as DeleteObjectCommand2 } from "@aws-sdk/client-s3";
|
|
10781
|
+
var DEFAULT_FEATURES = {
|
|
10782
|
+
audioEnabled: false,
|
|
10783
|
+
askEnabled: true,
|
|
10784
|
+
socialEnabled: false,
|
|
10785
|
+
brandingEnabled: true,
|
|
10786
|
+
translationEnabled: false,
|
|
10787
|
+
translationLangs: []
|
|
10788
|
+
};
|
|
10789
|
+
var HTML_FEATURES = {
|
|
10790
|
+
audioEnabled: false,
|
|
10791
|
+
askEnabled: false,
|
|
10792
|
+
socialEnabled: false,
|
|
10793
|
+
brandingEnabled: false,
|
|
10794
|
+
translationEnabled: false,
|
|
10795
|
+
translationLangs: []
|
|
10796
|
+
};
|
|
10797
|
+
function resolveFeatures(requested, existing) {
|
|
10798
|
+
const base = existing ?? DEFAULT_FEATURES;
|
|
10799
|
+
return {
|
|
10800
|
+
audioEnabled: requested?.audio ?? base.audioEnabled,
|
|
10801
|
+
askEnabled: requested?.ask ?? base.askEnabled,
|
|
10802
|
+
socialEnabled: requested?.social ?? base.socialEnabled,
|
|
10803
|
+
brandingEnabled: requested?.branding ?? base.brandingEnabled,
|
|
10804
|
+
translationEnabled: requested?.translation ?? base.translationEnabled,
|
|
10805
|
+
// Replaced wholesale, not merged: the allowlist IS the answer to "which
|
|
10806
|
+
// languages does this link offer", and a merge would make removing one
|
|
10807
|
+
// impossible.
|
|
10808
|
+
translationLangs: requested?.translationLangs ?? base.translationLangs
|
|
10809
|
+
};
|
|
10504
10810
|
}
|
|
10505
|
-
function
|
|
10506
|
-
|
|
10507
|
-
|
|
10508
|
-
|
|
10509
|
-
|
|
10510
|
-
|
|
10811
|
+
async function deleteShareAssets(keys) {
|
|
10812
|
+
const client2 = getR2Client();
|
|
10813
|
+
const bucket = getR2Bucket();
|
|
10814
|
+
for (const key of keys) {
|
|
10815
|
+
try {
|
|
10816
|
+
await client2.send(new DeleteObjectCommand2({ Bucket: bucket, Key: key }));
|
|
10817
|
+
} catch {
|
|
10818
|
+
}
|
|
10511
10819
|
}
|
|
10512
10820
|
}
|
|
10513
|
-
|
|
10514
|
-
|
|
10515
|
-
|
|
10516
|
-
|
|
10821
|
+
async function updateShareFeatures(share, requested) {
|
|
10822
|
+
if (share.kind === "html") return share;
|
|
10823
|
+
if (share.kind === "excalidraw") {
|
|
10824
|
+
if (requested.branding === void 0) return share;
|
|
10825
|
+
return updateDocShareFeatures(share.id, { ...share, brandingEnabled: requested.branding });
|
|
10826
|
+
}
|
|
10827
|
+
const features = resolveFeatures(requested, share);
|
|
10828
|
+
const updated = await updateDocShareFeatures(share.id, features);
|
|
10829
|
+
if (!updated) return null;
|
|
10830
|
+
if (features.audioEnabled) void queueShareAudio(updated);
|
|
10831
|
+
else if (share.audioEnabled) await dropShareAudio(share);
|
|
10832
|
+
return updated;
|
|
10517
10833
|
}
|
|
10518
10834
|
|
|
10519
10835
|
// ../api/src/dashboard/doc-share-publish.ts
|
|
@@ -10524,7 +10840,7 @@ import { CopyObjectCommand, PutObjectCommand as PutObjectCommand2 } from "@aws-s
|
|
|
10524
10840
|
var SHARE_ASSET_PLACEHOLDER = "/share/{{flyspace-share-id}}/assets/";
|
|
10525
10841
|
|
|
10526
10842
|
// ../api/src/dashboard/share-language.ts
|
|
10527
|
-
var
|
|
10843
|
+
var GROQ_API_URL6 = "https://api.groq.com/openai/v1/chat/completions";
|
|
10528
10844
|
var CLASSIFIER_MODEL = "openai/gpt-oss-20b";
|
|
10529
10845
|
var SAMPLE_CHARS = 4e3;
|
|
10530
10846
|
var NOISE = [
|
|
@@ -10563,7 +10879,7 @@ async function detectSourceLanguage(title, content) {
|
|
|
10563
10879
|
const sample = languageSample(title, content);
|
|
10564
10880
|
if (sample.length < MIN_SAMPLE_CHARS) return null;
|
|
10565
10881
|
try {
|
|
10566
|
-
const res = await fetch(
|
|
10882
|
+
const res = await fetch(GROQ_API_URL6, {
|
|
10567
10883
|
method: "POST",
|
|
10568
10884
|
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
10569
10885
|
body: JSON.stringify({
|
|
@@ -10793,7 +11109,7 @@ function resolveChannel2(c) {
|
|
|
10793
11109
|
}
|
|
10794
11110
|
var LIST_LIMIT = 500;
|
|
10795
11111
|
function createDocShareListRouter() {
|
|
10796
|
-
const app = new
|
|
11112
|
+
const app = new Hono14();
|
|
10797
11113
|
app.get("/shares", async (c) => {
|
|
10798
11114
|
const viewer = c.get("auth");
|
|
10799
11115
|
const readable = await filterReadableDocsChannels(
|
|
@@ -10837,7 +11153,7 @@ function createDocShareListRouter() {
|
|
|
10837
11153
|
return app;
|
|
10838
11154
|
}
|
|
10839
11155
|
function createDocShareWriteRouter(agent = {}) {
|
|
10840
|
-
const app = new
|
|
11156
|
+
const app = new Hono14();
|
|
10841
11157
|
app.use("/:id/docs/share", requireDocsAccess);
|
|
10842
11158
|
app.use("/:id/docs/share/*", requireDocsAccess);
|
|
10843
11159
|
app.use("/:id/docs/shares", requireDocsAccess);
|
|
@@ -10931,7 +11247,7 @@ function createDocShareWriteRouter(agent = {}) {
|
|
|
10931
11247
|
case "host-outdated":
|
|
10932
11248
|
return c.json(
|
|
10933
11249
|
{
|
|
10934
|
-
error: "The Host machine runs an older Flyspace version that cannot publish docs. Update it
|
|
11250
|
+
error: "The Host machine runs an older Flyspace version that cannot publish docs. Update it by running `flyspace upgrade` on that machine, then try again."
|
|
10935
11251
|
},
|
|
10936
11252
|
503
|
|
10937
11253
|
);
|
|
@@ -11010,7 +11326,7 @@ function createDocShareWriteRouter(agent = {}) {
|
|
|
11010
11326
|
}
|
|
11011
11327
|
|
|
11012
11328
|
// ../api/src/dashboard/search-routes.ts
|
|
11013
|
-
import { Hono as
|
|
11329
|
+
import { Hono as Hono15 } from "hono";
|
|
11014
11330
|
function matchScore(q, fields) {
|
|
11015
11331
|
const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
11016
11332
|
if (terms.length === 0) return 0;
|
|
@@ -11158,7 +11474,7 @@ function searchAutomations(q) {
|
|
|
11158
11474
|
}
|
|
11159
11475
|
var RESULT_LIMIT = 30;
|
|
11160
11476
|
function createSearchRouter() {
|
|
11161
|
-
const app = new
|
|
11477
|
+
const app = new Hono15();
|
|
11162
11478
|
app.get("/", async (c) => {
|
|
11163
11479
|
const q = c.req.query("q")?.trim();
|
|
11164
11480
|
if (!q) return c.json({ results: [] });
|
|
@@ -11174,14 +11490,14 @@ function createSearchRouter() {
|
|
|
11174
11490
|
}
|
|
11175
11491
|
|
|
11176
11492
|
// ../api/src/dashboard/feature-flags-routes.ts
|
|
11177
|
-
import { Hono as
|
|
11493
|
+
import { Hono as Hono16 } from "hono";
|
|
11178
11494
|
async function isFeatureEnabled(c, flagId) {
|
|
11179
11495
|
const auth = c.get("auth");
|
|
11180
11496
|
const enabled = await listEnabledFeatureFlags(auth.workspaceId);
|
|
11181
11497
|
return resolveFeatureFlags(enabled, isDevHost(c))[flagId];
|
|
11182
11498
|
}
|
|
11183
11499
|
function createFeatureFlagsRouter() {
|
|
11184
|
-
const app = new
|
|
11500
|
+
const app = new Hono16();
|
|
11185
11501
|
app.get("/", async (c) => {
|
|
11186
11502
|
const auth = c.get("auth");
|
|
11187
11503
|
const devHost = isDevHost(c);
|
|
@@ -11208,7 +11524,7 @@ function createFeatureFlagsRouter() {
|
|
|
11208
11524
|
|
|
11209
11525
|
// ../api/src/dashboard/uploads-routes.ts
|
|
11210
11526
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
11211
|
-
import { Hono as
|
|
11527
|
+
import { Hono as Hono17 } from "hono";
|
|
11212
11528
|
import { PutObjectCommand as PutObjectCommand3, GetObjectCommand as GetObjectCommand3 } from "@aws-sdk/client-s3";
|
|
11213
11529
|
import { getSignedUrl as getSignedUrl3 } from "@aws-sdk/s3-request-presigner";
|
|
11214
11530
|
var MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
@@ -11223,7 +11539,7 @@ function encodeKeyForUrl(key) {
|
|
|
11223
11539
|
return key.split("/").map(encodeURIComponent).join("/");
|
|
11224
11540
|
}
|
|
11225
11541
|
function createUploadsRouter() {
|
|
11226
|
-
const app = new
|
|
11542
|
+
const app = new Hono17();
|
|
11227
11543
|
app.post("/uploads", async (c) => {
|
|
11228
11544
|
const body = await c.req.parseBody().catch(() => null);
|
|
11229
11545
|
const file = body?.["file"];
|
|
@@ -11268,14 +11584,14 @@ function createUploadsRouter() {
|
|
|
11268
11584
|
}
|
|
11269
11585
|
|
|
11270
11586
|
// ../api/src/dashboard/transcribe-routes.ts
|
|
11271
|
-
import { Hono as
|
|
11587
|
+
import { Hono as Hono18 } from "hono";
|
|
11272
11588
|
var GROQ_TRANSCRIBE_URL = "https://api.groq.com/openai/v1/audio/transcriptions";
|
|
11273
11589
|
var MODEL = "whisper-large-v3-turbo";
|
|
11274
11590
|
var MAX_AUDIO_BYTES = 5 * 1024 * 1024;
|
|
11275
11591
|
var MAX_PROMPT_CHARS = 400;
|
|
11276
11592
|
var NO_SPEECH_THRESHOLD = 0.6;
|
|
11277
11593
|
function createTranscribeRouter() {
|
|
11278
|
-
const app = new
|
|
11594
|
+
const app = new Hono18();
|
|
11279
11595
|
app.post("/transcribe", async (c) => {
|
|
11280
11596
|
if (!await isFeatureEnabled(c, "dictation"))
|
|
11281
11597
|
return c.json({ error: "feature not enabled" }, 403);
|
|
@@ -11315,7 +11631,7 @@ function createTranscribeRouter() {
|
|
|
11315
11631
|
|
|
11316
11632
|
// ../api/src/dashboard/avatars-routes.ts
|
|
11317
11633
|
import { createHash as createHash9, randomUUID as randomUUID4 } from "crypto";
|
|
11318
|
-
import { Hono as
|
|
11634
|
+
import { Hono as Hono19 } from "hono";
|
|
11319
11635
|
import sharp4 from "sharp";
|
|
11320
11636
|
import { PutObjectCommand as PutObjectCommand4, DeleteObjectCommand as DeleteObjectCommand3 } from "@aws-sdk/client-s3";
|
|
11321
11637
|
var AVATAR_SIZE = 192;
|
|
@@ -11368,7 +11684,7 @@ function avatarKeyPrefix() {
|
|
|
11368
11684
|
return `${KEY_PREFIX2}/${getProfile()}/`;
|
|
11369
11685
|
}
|
|
11370
11686
|
function createAvatarsRouter() {
|
|
11371
|
-
const app = new
|
|
11687
|
+
const app = new Hono19();
|
|
11372
11688
|
app.get("/avatars/slack", async (c) => {
|
|
11373
11689
|
const ctx = await resolveAuthContext(c);
|
|
11374
11690
|
if (!ctx) return c.json({ error: "not signed in" }, 401);
|
|
@@ -11417,7 +11733,7 @@ function createAvatarsRouter() {
|
|
|
11417
11733
|
}
|
|
11418
11734
|
|
|
11419
11735
|
// ../api/src/dashboard/members-routes.ts
|
|
11420
|
-
import { Hono as
|
|
11736
|
+
import { Hono as Hono20 } from "hono";
|
|
11421
11737
|
function inviteLinkFor(c, token) {
|
|
11422
11738
|
const url = new URL("/auth/invite", callbackUrl(c));
|
|
11423
11739
|
url.searchParams.set("token", token);
|
|
@@ -11425,11 +11741,11 @@ function inviteLinkFor(c, token) {
|
|
|
11425
11741
|
}
|
|
11426
11742
|
var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
11427
11743
|
function createMembersRouter() {
|
|
11428
|
-
const members = new
|
|
11744
|
+
const members = new Hono20();
|
|
11429
11745
|
members.get("/", async (c) => {
|
|
11430
11746
|
const ctx = await resolveAuthContext(c);
|
|
11431
11747
|
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
11432
|
-
const list = await listMemberships(ctx.workspaceId);
|
|
11748
|
+
const list = (await listMemberships(ctx.workspaceId)).filter((m) => !m.isSupport);
|
|
11433
11749
|
const members2 = await Promise.all(
|
|
11434
11750
|
list.map(async (m) => {
|
|
11435
11751
|
const { data } = await getSupabaseClient().auth.admin.getUserById(m.userId);
|
|
@@ -11457,7 +11773,9 @@ function createMembersRouter() {
|
|
|
11457
11773
|
members.get("/directory", async (c) => {
|
|
11458
11774
|
const ctx = await resolveAuthContext(c);
|
|
11459
11775
|
if (!ctx) return c.json({ error: "unauthorized" }, 401);
|
|
11460
|
-
const list = (await listMemberships(ctx.workspaceId)).filter(
|
|
11776
|
+
const list = (await listMemberships(ctx.workspaceId)).filter(
|
|
11777
|
+
(m) => m.status === "active" && !m.isSupport
|
|
11778
|
+
);
|
|
11461
11779
|
const directory = await Promise.all(
|
|
11462
11780
|
list.map(async (m) => {
|
|
11463
11781
|
const { data } = await getSupabaseClient().auth.admin.getUserById(m.userId);
|
|
@@ -11650,7 +11968,7 @@ function createMembersRouter() {
|
|
|
11650
11968
|
}
|
|
11651
11969
|
|
|
11652
11970
|
// ../api/src/dashboard/notifications-routes.ts
|
|
11653
|
-
import { Hono as
|
|
11971
|
+
import { Hono as Hono21 } from "hono";
|
|
11654
11972
|
|
|
11655
11973
|
// ../api/src/store/notification-store.ts
|
|
11656
11974
|
function rowToNotification(row) {
|
|
@@ -11718,7 +12036,7 @@ async function markAllRead(channelIds, userId) {
|
|
|
11718
12036
|
|
|
11719
12037
|
// ../api/src/dashboard/notifications-routes.ts
|
|
11720
12038
|
function createNotificationsRouter() {
|
|
11721
|
-
const notifications = new
|
|
12039
|
+
const notifications = new Hono21();
|
|
11722
12040
|
notifications.get("/", async (c) => {
|
|
11723
12041
|
const auth = c.get("auth");
|
|
11724
12042
|
const channelIds = getProfileChannelRegistry().filter((ch) => canSeeChannel(ch, auth)).map((ch) => ch.id);
|
|
@@ -11745,7 +12063,7 @@ function createNotificationsRouter() {
|
|
|
11745
12063
|
return notifications;
|
|
11746
12064
|
}
|
|
11747
12065
|
function createThreadReadRouter() {
|
|
11748
|
-
const threads = new
|
|
12066
|
+
const threads = new Hono21();
|
|
11749
12067
|
threads.post("/threads/:threadId/read", async (c) => {
|
|
11750
12068
|
const { userId } = c.get("auth");
|
|
11751
12069
|
const threadId = c.req.param("threadId");
|
|
@@ -11757,7 +12075,7 @@ function createThreadReadRouter() {
|
|
|
11757
12075
|
}
|
|
11758
12076
|
|
|
11759
12077
|
// ../api/src/dashboard/push-routes.ts
|
|
11760
|
-
import { Hono as
|
|
12078
|
+
import { Hono as Hono22 } from "hono";
|
|
11761
12079
|
|
|
11762
12080
|
// ../api/src/push/send.ts
|
|
11763
12081
|
import webpush from "web-push";
|
|
@@ -11860,7 +12178,7 @@ async function pushToUser(userId, payload) {
|
|
|
11860
12178
|
|
|
11861
12179
|
// ../api/src/dashboard/push-routes.ts
|
|
11862
12180
|
function createPushRouter() {
|
|
11863
|
-
const push = new
|
|
12181
|
+
const push = new Hono22();
|
|
11864
12182
|
push.get("/key", (c) => c.json({ publicKey: getVapidPublicKey() }));
|
|
11865
12183
|
push.post("/subscribe", async (c) => {
|
|
11866
12184
|
const { userId } = c.get("auth");
|
|
@@ -11891,7 +12209,7 @@ function createPushRouter() {
|
|
|
11891
12209
|
}
|
|
11892
12210
|
|
|
11893
12211
|
// ../api/src/dashboard/mcp-oauth-routes.ts
|
|
11894
|
-
import { Hono as
|
|
12212
|
+
import { Hono as Hono23 } from "hono";
|
|
11895
12213
|
|
|
11896
12214
|
// ../api/src/mcp/crypto.ts
|
|
11897
12215
|
import { createCipheriv, createDecipheriv, randomBytes as randomBytes7, timingSafeEqual } from "crypto";
|
|
@@ -12406,7 +12724,7 @@ function backToDashboard(c, to, params) {
|
|
|
12406
12724
|
return c.redirect(`${target.pathname}${target.search}`);
|
|
12407
12725
|
}
|
|
12408
12726
|
function createMcpOAuthRouter() {
|
|
12409
|
-
const app = new
|
|
12727
|
+
const app = new Hono23();
|
|
12410
12728
|
app.get("/oauth/:provider/start", async (c) => {
|
|
12411
12729
|
const auth = c.get("auth");
|
|
12412
12730
|
if (auth.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
@@ -12533,7 +12851,7 @@ function createMcpOAuthRouter() {
|
|
|
12533
12851
|
}
|
|
12534
12852
|
|
|
12535
12853
|
// ../api/src/dashboard/mcp-routes.ts
|
|
12536
|
-
import { Hono as
|
|
12854
|
+
import { Hono as Hono24 } from "hono";
|
|
12537
12855
|
|
|
12538
12856
|
// ../api/src/mcp/transport.ts
|
|
12539
12857
|
var KINDS = ["inherit", "npx", "local", "http", "sse"];
|
|
@@ -12663,7 +12981,7 @@ function bindingsByConnection() {
|
|
|
12663
12981
|
return map;
|
|
12664
12982
|
}
|
|
12665
12983
|
function createMcpRouter() {
|
|
12666
|
-
const app = new
|
|
12984
|
+
const app = new Hono24();
|
|
12667
12985
|
app.get("/providers", async (c) => {
|
|
12668
12986
|
const denied = requireAdmin(c.get("auth"));
|
|
12669
12987
|
if (denied) return c.json({ error: denied }, 403);
|
|
@@ -12898,9 +13216,9 @@ async function threadUsageForWorkspace(workspace, threadTs) {
|
|
|
12898
13216
|
return { totals: totalsOf(rows), byModel: groupBy(rows, (r) => r.model ?? "unknown") };
|
|
12899
13217
|
}
|
|
12900
13218
|
async function sessionUsageForThreads(workspace, threadTsList) {
|
|
12901
|
-
const
|
|
13219
|
+
const latest2 = /* @__PURE__ */ new Map();
|
|
12902
13220
|
const totals = /* @__PURE__ */ new Map();
|
|
12903
|
-
if (threadTsList.length === 0) return { latest, totals };
|
|
13221
|
+
if (threadTsList.length === 0) return { latest: latest2, totals };
|
|
12904
13222
|
const rows = await fetchUsageRows(workspace, { threadTsIn: threadTsList });
|
|
12905
13223
|
const latestAt = /* @__PURE__ */ new Map();
|
|
12906
13224
|
for (const row of rows) {
|
|
@@ -12909,7 +13227,7 @@ async function sessionUsageForThreads(workspace, threadTsList) {
|
|
|
12909
13227
|
const seenAt = latestAt.get(row.thread_ts);
|
|
12910
13228
|
if (seenAt && seenAt >= row.ran_at) continue;
|
|
12911
13229
|
latestAt.set(row.thread_ts, row.ran_at);
|
|
12912
|
-
|
|
13230
|
+
latest2.set(row.thread_ts, {
|
|
12913
13231
|
model: row.model ?? void 0,
|
|
12914
13232
|
numTurns: row.num_turns ?? void 0,
|
|
12915
13233
|
inputTokens: row.input_tokens,
|
|
@@ -12918,7 +13236,7 @@ async function sessionUsageForThreads(workspace, threadTsList) {
|
|
|
12918
13236
|
label: row.label ?? void 0
|
|
12919
13237
|
});
|
|
12920
13238
|
}
|
|
12921
|
-
return { latest, totals };
|
|
13239
|
+
return { latest: latest2, totals };
|
|
12922
13240
|
}
|
|
12923
13241
|
|
|
12924
13242
|
// ../api/src/dashboard/usage-report.ts
|
|
@@ -13045,7 +13363,7 @@ async function buildThreadUsage(threadTs) {
|
|
|
13045
13363
|
}
|
|
13046
13364
|
|
|
13047
13365
|
// ../api/src/dashboard/channel-members-routes.ts
|
|
13048
|
-
import { Hono as
|
|
13366
|
+
import { Hono as Hono25 } from "hono";
|
|
13049
13367
|
async function isChannelAdmin(ctx, channelId) {
|
|
13050
13368
|
if (ctx.role === "admin") return true;
|
|
13051
13369
|
const membership = await getMembershipByUserId(ctx.workspaceId, ctx.userId);
|
|
@@ -13054,7 +13372,7 @@ async function isChannelAdmin(ctx, channelId) {
|
|
|
13054
13372
|
return members.some((m) => m.membershipId === membership.id && m.role === "channel_admin");
|
|
13055
13373
|
}
|
|
13056
13374
|
function createChannelMembersRouter() {
|
|
13057
|
-
const router = new
|
|
13375
|
+
const router = new Hono25();
|
|
13058
13376
|
router.get("/:id/members", async (c) => {
|
|
13059
13377
|
const ctx = await resolveAuthContext(c);
|
|
13060
13378
|
if (!ctx) return c.json({ error: "workspace access required" }, 403);
|
|
@@ -13124,7 +13442,7 @@ function createChannelMembersRouter() {
|
|
|
13124
13442
|
}
|
|
13125
13443
|
|
|
13126
13444
|
// ../api/src/dashboard/memories-routes.ts
|
|
13127
|
-
import { Hono as
|
|
13445
|
+
import { Hono as Hono26 } from "hono";
|
|
13128
13446
|
async function readPatch(c) {
|
|
13129
13447
|
const body = await c.req.json().catch(() => ({}));
|
|
13130
13448
|
const patch = {};
|
|
@@ -13152,7 +13470,7 @@ var requireAgentMemory = async (c, next) => {
|
|
|
13152
13470
|
await next();
|
|
13153
13471
|
};
|
|
13154
13472
|
function createMemoriesRouter() {
|
|
13155
|
-
const router = new
|
|
13473
|
+
const router = new Hono26();
|
|
13156
13474
|
for (const path of [
|
|
13157
13475
|
"/:id/memories",
|
|
13158
13476
|
"/:id/memories/*",
|
|
@@ -13225,7 +13543,7 @@ function createMemoriesRouter() {
|
|
|
13225
13543
|
return router;
|
|
13226
13544
|
}
|
|
13227
13545
|
function createScopedMemoriesRouter() {
|
|
13228
|
-
const router = new
|
|
13546
|
+
const router = new Hono26();
|
|
13229
13547
|
router.use("*", requireAgentMemory);
|
|
13230
13548
|
const workspace = (c) => ({
|
|
13231
13549
|
scope: "workspace",
|
|
@@ -13284,218 +13602,6 @@ function createScopedMemoriesRouter() {
|
|
|
13284
13602
|
return router;
|
|
13285
13603
|
}
|
|
13286
13604
|
|
|
13287
|
-
// ../api/src/dashboard/onboarding-routes.ts
|
|
13288
|
-
import { Hono as Hono26 } from "hono";
|
|
13289
|
-
|
|
13290
|
-
// ../api/src/dashboard/unique-channel-name.ts
|
|
13291
|
-
function uniqueChannelName(raw, taken) {
|
|
13292
|
-
const base = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
|
|
13293
|
-
let name = base;
|
|
13294
|
-
for (let i = 2; taken.has(name); i += 1) name = `${base}-${i}`;
|
|
13295
|
-
taken.add(name);
|
|
13296
|
-
return name;
|
|
13297
|
-
}
|
|
13298
|
-
|
|
13299
|
-
// ../api/src/dashboard/workspace-greeting.ts
|
|
13300
|
-
var GROQ_API_URL6 = "https://api.groq.com/openai/v1/chat/completions";
|
|
13301
|
-
var GREETING_MODEL = "openai/gpt-oss-20b";
|
|
13302
|
-
var TIMEOUT_MS = 6e3;
|
|
13303
|
-
var MAX_LISTED_CHANNELS = 8;
|
|
13304
|
-
var GUIDES = [
|
|
13305
|
-
"Welcome to Flyspace",
|
|
13306
|
-
"Working with the agent",
|
|
13307
|
-
"Channels and folders",
|
|
13308
|
-
"Docs",
|
|
13309
|
-
"Sharing docs",
|
|
13310
|
-
"Inviting your team",
|
|
13311
|
-
"Automations"
|
|
13312
|
-
];
|
|
13313
|
-
var SYSTEM_PROMPT = `You write the first message a new Flyspace workspace owner sees, posted in their #general channel. Flyspace is a team chat where an AI agent works inside every channel.
|
|
13314
|
-
|
|
13315
|
-
- Plain Markdown, 60 to 110 words, in English. No headings, no emoji, no em or en dashes, no sign-off.
|
|
13316
|
-
- Greet the owner by name and name the workspace.
|
|
13317
|
-
- Say that #general is public: everyone invited to the workspace can see it.
|
|
13318
|
-
- If project channels are listed, mention them as #name and say each one is linked to that project folder. Never invent a channel.
|
|
13319
|
-
- Mention the trial or the missing Host only if the facts say so.
|
|
13320
|
-
- If guides are listed, say they are in the Docs pane of #general and suggest starting with the first one.
|
|
13321
|
-
- Use only the facts given. Do not add buttons or a list of next steps: those are appended for you.`;
|
|
13322
|
-
function greetingButtons(input2) {
|
|
13323
|
-
const buttons = [];
|
|
13324
|
-
if (!input2.hostConnected) buttons.push("Connect a machine");
|
|
13325
|
-
if (input2.channels[0]) buttons.push(`Summarise #${input2.channels[0]} for me`);
|
|
13326
|
-
if (input2.hostConnected) buttons.push("Give me a quick tour of the guides");
|
|
13327
|
-
if (input2.memberCount <= 1) buttons.push("Invite my team");
|
|
13328
|
-
buttons.push("Set up a daily digest");
|
|
13329
|
-
return buttons.slice(0, 3).map((b) => b.replace(/[|\]]/g, ""));
|
|
13330
|
-
}
|
|
13331
|
-
function fallbackGreeting(input2) {
|
|
13332
|
-
const channels = input2.channels.slice(0, MAX_LISTED_CHANNELS).map((c) => `#${c}`);
|
|
13333
|
-
return [
|
|
13334
|
-
`Welcome to ${input2.workspaceName}, ${input2.ownerName}.`,
|
|
13335
|
-
"This is #general. It is public, so everyone you invite to the workspace can see it.",
|
|
13336
|
-
channels.length ? `Your project channels are ready: ${channels.join(", ")}. Each one is linked to its project folder, so the agent there works on the real files.` : "",
|
|
13337
|
-
input2.hostConnected ? "A few short guides are waiting in the Docs pane of #general. Start with Welcome to Flyspace." : "",
|
|
13338
|
-
"Mention the agent in any channel to get started."
|
|
13339
|
-
].filter(Boolean).join("\n\n");
|
|
13340
|
-
}
|
|
13341
|
-
async function groqGreeting(input2, apiKey) {
|
|
13342
|
-
const facts = [
|
|
13343
|
-
`Owner: ${input2.ownerName}`,
|
|
13344
|
-
`Workspace: ${input2.workspaceName}`,
|
|
13345
|
-
`Plan: ${input2.trialing ? "14-day free trial, just started" : "not on a trial"}`,
|
|
13346
|
-
`Host: ${input2.hostConnected ? "a machine is connected, agents can run" : "no machine connected yet, agents cannot run until one is"}`,
|
|
13347
|
-
`Project channels: ${input2.channels.slice(0, MAX_LISTED_CHANNELS).join(", ") || "(none yet)"}`,
|
|
13348
|
-
`Members: ${input2.memberCount}`,
|
|
13349
|
-
`Guides in #general: ${input2.hostConnected ? GUIDES.join(", ") : "(none, no machine connected)"}`
|
|
13350
|
-
].join("\n");
|
|
13351
|
-
const res = await fetch(GROQ_API_URL6, {
|
|
13352
|
-
method: "POST",
|
|
13353
|
-
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
13354
|
-
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
13355
|
-
body: JSON.stringify({
|
|
13356
|
-
model: GREETING_MODEL,
|
|
13357
|
-
temperature: 0.6,
|
|
13358
|
-
// Reasoning model: the cap covers reasoning tokens too, so keep it roomy.
|
|
13359
|
-
reasoning_effort: "low",
|
|
13360
|
-
max_tokens: 900,
|
|
13361
|
-
messages: [
|
|
13362
|
-
{ role: "system", content: SYSTEM_PROMPT },
|
|
13363
|
-
{ role: "user", content: facts }
|
|
13364
|
-
]
|
|
13365
|
-
})
|
|
13366
|
-
});
|
|
13367
|
-
if (!res.ok) throw new Error(`Groq request failed (${res.status})`);
|
|
13368
|
-
const data = await res.json();
|
|
13369
|
-
const text = data.choices?.[0]?.message?.content?.replace(/\[BUTTONS:[^\]]*\]/g, "").replace(/\s*[–—]\s*/g, ", ").trim();
|
|
13370
|
-
return text || null;
|
|
13371
|
-
}
|
|
13372
|
-
async function buildGreeting(input2) {
|
|
13373
|
-
let text = null;
|
|
13374
|
-
const apiKey = process.env["GROQ_API_KEY"];
|
|
13375
|
-
if (apiKey) {
|
|
13376
|
-
try {
|
|
13377
|
-
text = await groqGreeting(input2, apiKey);
|
|
13378
|
-
} catch (err) {
|
|
13379
|
-
console.error("Workspace greeting failed, using the fixed template:", err);
|
|
13380
|
-
}
|
|
13381
|
-
}
|
|
13382
|
-
return `${text ?? fallbackGreeting(input2)}
|
|
13383
|
-
|
|
13384
|
-
[BUTTONS: ${greetingButtons(input2).join(" | ")}]`;
|
|
13385
|
-
}
|
|
13386
|
-
|
|
13387
|
-
// ../api/src/dashboard/onboarding-routes.ts
|
|
13388
|
-
var MAX_FOLDERS = 20;
|
|
13389
|
-
function hostUnavailable(c, agent, method) {
|
|
13390
|
-
return c.json({ error: "host offline", reason: agent.getHostCallFailure?.(method) ?? "offline" }, 503);
|
|
13391
|
-
}
|
|
13392
|
-
function createOnboardingRouter(agent) {
|
|
13393
|
-
const app = new Hono26();
|
|
13394
|
-
app.get("/scan", async (c) => {
|
|
13395
|
-
const ctx = await resolveAuthContext(c);
|
|
13396
|
-
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
13397
|
-
const scan = await agent.scanOnboarding?.();
|
|
13398
|
-
if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
|
|
13399
|
-
return c.json(scan);
|
|
13400
|
-
});
|
|
13401
|
-
app.post("/channels", async (c) => {
|
|
13402
|
-
const ctx = await resolveAuthContext(c);
|
|
13403
|
-
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
13404
|
-
const body = await c.req.json().catch(() => ({}));
|
|
13405
|
-
const runner = body["agent"];
|
|
13406
|
-
const folders = Array.isArray(body["folders"]) ? body["folders"].filter((f) => typeof f === "string" && f.length > 0) : [];
|
|
13407
|
-
if (folders.length > MAX_FOLDERS) return c.json({ error: `at most ${MAX_FOLDERS} folders` }, 400);
|
|
13408
|
-
const scan = await agent.scanOnboarding?.();
|
|
13409
|
-
if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
|
|
13410
|
-
const offered = new Map(scan.projects.map((p) => [p.path, p]));
|
|
13411
|
-
const unknown = folders.find((f) => !offered.has(f));
|
|
13412
|
-
if (unknown) return c.json({ error: "folder was not offered by the scan" }, 400);
|
|
13413
|
-
const taken = new Set(getProfileChannelRegistry().map((ch) => ch.name));
|
|
13414
|
-
taken.add("general");
|
|
13415
|
-
const items = [{ name: "general" }];
|
|
13416
|
-
for (const path of folders) items.push({ name: uniqueChannelName(offered.get(path).name, taken), path });
|
|
13417
|
-
const linked = await agent.linkProjectFolders?.(getProfile(), items);
|
|
13418
|
-
if (!linked) return hostUnavailable(c, agent, "linkProjectFolders");
|
|
13419
|
-
if (runner === "claude" || runner === "codex") {
|
|
13420
|
-
await agent.saveLocalDashboardSettings?.({ providers: [{ runner }] });
|
|
13421
|
-
}
|
|
13422
|
-
const created = [];
|
|
13423
|
-
for (const l of linked) {
|
|
13424
|
-
created.push(
|
|
13425
|
-
l.name === "general" ? await ensureGeneralChannel() : await createChannel({ name: l.name, cwd: l.cwd })
|
|
13426
|
-
);
|
|
13427
|
-
}
|
|
13428
|
-
await markWorkspaceProjectsSetup(ctx.workspaceId);
|
|
13429
|
-
return c.json({ channels: created.map((ch) => ({ id: ch.id, name: ch.name })) }, 201);
|
|
13430
|
-
});
|
|
13431
|
-
app.post("/reset", async (c) => {
|
|
13432
|
-
const ctx = await resolveAuthContext(c);
|
|
13433
|
-
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
13434
|
-
const { error: error2 } = await getSupabaseClient().from("threads").delete().eq("client_key", ctx.workspaceId);
|
|
13435
|
-
if (error2) return c.json({ error: error2.message }, 500);
|
|
13436
|
-
await resetWorkspaceOnboarding(ctx.workspaceId);
|
|
13437
|
-
return c.json({ ok: true });
|
|
13438
|
-
});
|
|
13439
|
-
app.get("/greeting", async (c) => {
|
|
13440
|
-
const ctx = await resolveAuthContext(c);
|
|
13441
|
-
if (ctx?.role !== "admin") return c.json({ pending: false });
|
|
13442
|
-
const greetedAt = await getWorkspaceGreetedAt(ctx.workspaceId);
|
|
13443
|
-
const pending2 = !!ctx.projectsSetupAt && !greetedAt;
|
|
13444
|
-
if (!pending2) return c.json({ pending: pending2, channelId: null, threadId: null });
|
|
13445
|
-
const general = await ensureGeneralChannel();
|
|
13446
|
-
const threadId = await ensureGreetingThread(ctx.workspaceId, general.id, ctx.name?.trim() || ctx.handle);
|
|
13447
|
-
return c.json({ pending: pending2, channelId: general.id, threadId });
|
|
13448
|
-
});
|
|
13449
|
-
app.post("/greeting", async (c) => {
|
|
13450
|
-
const ctx = await resolveAuthContext(c);
|
|
13451
|
-
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
13452
|
-
const supabase = getSupabaseClient();
|
|
13453
|
-
const general = await ensureGeneralChannel();
|
|
13454
|
-
const ownerName = ctx.name?.trim() || ctx.handle;
|
|
13455
|
-
const threadId = await ensureGreetingThread(ctx.workspaceId, general.id, ownerName);
|
|
13456
|
-
if (!threadId) return c.json({ error: "could not create the welcome thread" }, 500);
|
|
13457
|
-
const done = { channelId: general.id, threadId };
|
|
13458
|
-
const replied = await supabase.from("thread_replies").select("id").eq("thread_id", threadId).limit(1).maybeSingle();
|
|
13459
|
-
if (replied.data) return c.json(done);
|
|
13460
|
-
if (await getWorkspaceGreetedAt(ctx.workspaceId))
|
|
13461
|
-
return c.json({ error: "already greeted" }, 409);
|
|
13462
|
-
const [branding, billing, members] = await Promise.all([
|
|
13463
|
-
getWorkspaceBranding(getProfile()).catch(() => null),
|
|
13464
|
-
getWorkspaceBilling(ctx.workspaceId).catch(() => null),
|
|
13465
|
-
listMemberships(ctx.workspaceId).catch(() => [])
|
|
13466
|
-
]);
|
|
13467
|
-
const body = await buildGreeting({
|
|
13468
|
-
ownerName,
|
|
13469
|
-
workspaceName: branding?.displayName || getProfile(),
|
|
13470
|
-
trialing: billing?.planStatus === "trialing",
|
|
13471
|
-
hostConnected: !!ctx.agentConnectedAt,
|
|
13472
|
-
channels: getProfileChannelRegistry().filter((ch) => !ch.isDefault && !ch.dmKey && !ch.agentName && !ch.archivedAt).map((ch) => ch.name),
|
|
13473
|
-
memberCount: members.length
|
|
13474
|
-
});
|
|
13475
|
-
const again = await supabase.from("thread_replies").select("id").eq("thread_id", threadId).limit(1).maybeSingle();
|
|
13476
|
-
if (again.data) return c.json(done);
|
|
13477
|
-
const reply = await supabase.from("thread_replies").insert({ thread_id: threadId, author: AGENT_AUTHOR, body });
|
|
13478
|
-
if (reply.error) return c.json({ error: reply.error.message }, 500);
|
|
13479
|
-
await markWorkspaceGreeted(ctx.workspaceId);
|
|
13480
|
-
return c.json(done, 201);
|
|
13481
|
-
});
|
|
13482
|
-
return app;
|
|
13483
|
-
}
|
|
13484
|
-
async function ensureGreetingThread(workspaceId, channelId, ownerName) {
|
|
13485
|
-
const supabase = getSupabaseClient();
|
|
13486
|
-
const existing = () => supabase.from("threads").select("id").eq("client_key", workspaceId).maybeSingle();
|
|
13487
|
-
const found = await existing();
|
|
13488
|
-
if (found.data) return found.data.id;
|
|
13489
|
-
const { data, error: error2 } = await supabase.from("threads").insert({
|
|
13490
|
-
channel_id: channelId,
|
|
13491
|
-
title: `Welcome to your Flyspace, ${ownerName}!`,
|
|
13492
|
-
author: AGENT_AUTHOR,
|
|
13493
|
-
client_key: workspaceId
|
|
13494
|
-
}).select("id").single();
|
|
13495
|
-
if (data) return data.id;
|
|
13496
|
-
return error2?.code === "23505" ? (await existing()).data?.id ?? null : null;
|
|
13497
|
-
}
|
|
13498
|
-
|
|
13499
13605
|
// ../api/src/dashboard/host-status-signal.ts
|
|
13500
13606
|
var HOST_STATUS_CHANNEL = "host-status";
|
|
13501
13607
|
var channelReady = null;
|
|
@@ -13605,7 +13711,10 @@ function registerDataRoutes(api, agent = {}) {
|
|
|
13605
13711
|
}
|
|
13606
13712
|
const auth = c.get("auth");
|
|
13607
13713
|
const paused = await getAgentPaused(auth.workspaceId);
|
|
13608
|
-
|
|
13714
|
+
const cliVersion = agent.getHostCliVersion?.(getProfile()) ?? null;
|
|
13715
|
+
const latest2 = cliVersion ? await latestCliVersion() : null;
|
|
13716
|
+
const outdated = !!(cliVersion && latest2 && isOlderVersion(cliVersion, latest2));
|
|
13717
|
+
return c.json({ online: !paused, cliVersion, ...outdated ? { latestCliVersion: latest2 } : {} });
|
|
13609
13718
|
});
|
|
13610
13719
|
api.get("/channels", async (c) => {
|
|
13611
13720
|
const auth = c.get("auth");
|
|
@@ -13842,17 +13951,17 @@ function registerDataRoutes(api, agent = {}) {
|
|
|
13842
13951
|
api.get("/sessions", async (c) => {
|
|
13843
13952
|
const raw = await agent.listSessions?.() ?? [];
|
|
13844
13953
|
const threadTsList = raw.map((s) => s.threadTs);
|
|
13845
|
-
let
|
|
13954
|
+
let latest2;
|
|
13846
13955
|
let totals;
|
|
13847
13956
|
try {
|
|
13848
|
-
({ latest, totals } = await sessionUsageForThreads(getProfile(), threadTsList));
|
|
13957
|
+
({ latest: latest2, totals } = await sessionUsageForThreads(getProfile(), threadTsList));
|
|
13849
13958
|
} catch {
|
|
13850
|
-
|
|
13959
|
+
latest2 = /* @__PURE__ */ new Map();
|
|
13851
13960
|
totals = /* @__PURE__ */ new Map();
|
|
13852
13961
|
const usageStore = existsSync19(paths.DB_FILE) ? getLocalCache().openUsageStore(paths.DB_FILE) : null;
|
|
13853
13962
|
if (usageStore) {
|
|
13854
13963
|
try {
|
|
13855
|
-
|
|
13964
|
+
latest2 = usageStore.latestByThread(threadTsList);
|
|
13856
13965
|
totals = usageStore.totalsByThread(threadTsList);
|
|
13857
13966
|
} finally {
|
|
13858
13967
|
usageStore.close();
|
|
@@ -13861,7 +13970,7 @@ function registerDataRoutes(api, agent = {}) {
|
|
|
13861
13970
|
}
|
|
13862
13971
|
const threadLinks = await resolveThreadLinks(raw);
|
|
13863
13972
|
const sessions2 = raw.map((s) => {
|
|
13864
|
-
const run4 =
|
|
13973
|
+
const run4 = latest2.get(s.threadTs);
|
|
13865
13974
|
const turns = run4?.numTurns ?? 1;
|
|
13866
13975
|
const total = run4 ? run4.inputTokens + run4.cacheReadTokens + run4.cacheCreationTokens : void 0;
|
|
13867
13976
|
const link = s.channelId.startsWith("chat:") ? threadLinks.chat.get(s.threadTs) : threadLinks.external.get(`${s.channelId}:${s.threadTs}`);
|
|
@@ -14494,7 +14603,8 @@ function coerceMemberEmails(raw) {
|
|
|
14494
14603
|
return raw.filter((v) => typeof v === "string").map((v) => v.trim().toLowerCase()).filter(Boolean);
|
|
14495
14604
|
}
|
|
14496
14605
|
function ensureCwdDir(cwd) {
|
|
14497
|
-
const expanded =
|
|
14606
|
+
const expanded = expandPath(cwd);
|
|
14607
|
+
if (isLogicalCwd(cwd) || isUnderLogicalRoot(expanded)) return;
|
|
14498
14608
|
if (!existsSync21(expanded)) mkdirSync10(expanded, { recursive: true });
|
|
14499
14609
|
}
|
|
14500
14610
|
function toResponse3(channel) {
|
|
@@ -14652,7 +14762,7 @@ import {
|
|
|
14652
14762
|
readSync as readSync2,
|
|
14653
14763
|
readdirSync as readdirSync5,
|
|
14654
14764
|
readFileSync as readFileSync20,
|
|
14655
|
-
realpathSync as
|
|
14765
|
+
realpathSync as realpathSync4,
|
|
14656
14766
|
renameSync,
|
|
14657
14767
|
rmSync as rmSync8,
|
|
14658
14768
|
statSync as statSync6,
|
|
@@ -15755,12 +15865,12 @@ var RETRY_DELAY_MS = 3e3;
|
|
|
15755
15865
|
var MISSED_MESSAGE_WINDOW_MS = 24 * 60 * 60 * 1e3;
|
|
15756
15866
|
var MISSED_MESSAGE_THREAD_LIMIT = 100;
|
|
15757
15867
|
var delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
15758
|
-
function stripPathPrefixes(label2, roots,
|
|
15868
|
+
function stripPathPrefixes(label2, roots, home2 = homedir9()) {
|
|
15759
15869
|
let out = label2;
|
|
15760
15870
|
for (const root of roots) {
|
|
15761
15871
|
if (root) out = out.replaceAll(`${root.replace(/\/+$/, "")}/`, "");
|
|
15762
15872
|
}
|
|
15763
|
-
if (
|
|
15873
|
+
if (home2) out = out.replaceAll(`${home2.replace(/\/+$/, "")}/`, "~/");
|
|
15764
15874
|
return out;
|
|
15765
15875
|
}
|
|
15766
15876
|
var ChatAgent = class {
|
|
@@ -17495,7 +17605,7 @@ function moveDocEntry(root, from, to) {
|
|
|
17495
17605
|
return { status: "into-self" };
|
|
17496
17606
|
}
|
|
17497
17607
|
try {
|
|
17498
|
-
if (
|
|
17608
|
+
if (realpathSync4(finalAbs) !== realpathSync4(absFrom)) return { status: "exists" };
|
|
17499
17609
|
} catch {
|
|
17500
17610
|
}
|
|
17501
17611
|
renameSync(absFrom, finalAbs);
|
|
@@ -18069,9 +18179,9 @@ function claudeAccount() {
|
|
|
18069
18179
|
}
|
|
18070
18180
|
}
|
|
18071
18181
|
function codexAccount() {
|
|
18072
|
-
const
|
|
18182
|
+
const home2 = process.env["CODEX_HOME"]?.trim() || join24(homedir10(), ".codex");
|
|
18073
18183
|
try {
|
|
18074
|
-
const auth = JSON.parse(readFileSync21(join24(
|
|
18184
|
+
const auth = JSON.parse(readFileSync21(join24(home2, "auth.json"), "utf8"));
|
|
18075
18185
|
const payload = auth.tokens?.id_token?.split(".")[1];
|
|
18076
18186
|
if (!payload) return void 0;
|
|
18077
18187
|
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
@@ -18762,7 +18872,7 @@ function localAgentCallbacks(chatAgent) {
|
|
|
18762
18872
|
docsRequest: (req) => handleDocsRequest(req),
|
|
18763
18873
|
hostRequest: (req) => handleHostRequest(req),
|
|
18764
18874
|
listSessions: () => listLocalSessions(),
|
|
18765
|
-
scanOnboarding: () => scanOnboarding(),
|
|
18875
|
+
scanOnboarding: (slug) => scanOnboarding(slug),
|
|
18766
18876
|
linkProjectFolders: (slug, items) => linkProjectFolders(slug, items),
|
|
18767
18877
|
isHostOnline: () => true
|
|
18768
18878
|
};
|
|
@@ -19494,6 +19604,13 @@ async function runReconcileCmd(args) {
|
|
|
19494
19604
|
}
|
|
19495
19605
|
|
|
19496
19606
|
// src/cli.ts
|
|
19607
|
+
import { join as join29 } from "path";
|
|
19608
|
+
var home = flyspaceHome();
|
|
19609
|
+
if (home) {
|
|
19610
|
+
const bin = join29(home, "bin");
|
|
19611
|
+
const path = process.env.PATH ?? "";
|
|
19612
|
+
if (!path.split(":").includes(bin)) process.env.PATH = path ? `${bin}:${path}` : bin;
|
|
19613
|
+
}
|
|
19497
19614
|
var XERO_USAGE = false ? " xero Connect to Xero and expose it as an MCP server\n" : "";
|
|
19498
19615
|
var USAGE9 = `
|
|
19499
19616
|
Usage: flyspace <command> [options]
|
|
@@ -19628,7 +19745,7 @@ async function main() {
|
|
|
19628
19745
|
break;
|
|
19629
19746
|
case "-v":
|
|
19630
19747
|
case "--version":
|
|
19631
|
-
printRaw("0.1.
|
|
19748
|
+
printRaw("0.1.29");
|
|
19632
19749
|
break;
|
|
19633
19750
|
case "-h":
|
|
19634
19751
|
case "--help":
|