@exulu/backend 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -9,14 +9,19 @@ import {
9
9
  import {
10
10
  COMPACTION_INSUFFICIENT,
11
11
  ContextCompactionRequiredError,
12
+ CredentialInvalidError,
12
13
  DEFAULT_CONTEXT_WINDOW,
13
14
  ExuluTool,
15
+ KB_EDITOR_TOOL_ID,
14
16
  LITELLM_UI_PATH,
15
17
  LiteLLMAdminError,
16
18
  OAUTH_CALLBACK_PATH,
17
19
  PreviewRenderError,
18
20
  ResolveModelError,
21
+ SCRUBBED_CREDENTIAL_TEXT,
22
+ SCRUBBED_OAUTH_TEXT,
19
23
  STATISTICS_TYPE_ENUM,
24
+ authRegistry,
20
25
  authentication,
21
26
  budgetTagFor,
22
27
  buildTags,
@@ -26,8 +31,10 @@ import {
26
31
  convertExuluToolsToAiSdkTools,
27
32
  copyS3Object,
28
33
  createAgenticRetrievalTool,
34
+ createKbEditorPickerTool,
29
35
  createTaggedFetch,
30
36
  createUppyRoutes,
37
+ credentialStore,
31
38
  decrypt,
32
39
  decryptOauthState,
33
40
  deleteS3Object,
@@ -40,12 +47,14 @@ import {
40
47
  exchangeCodeForTokens,
41
48
  exuluApp,
42
49
  getBudgetSettings,
50
+ getChunksTableName,
43
51
  getPdfPreviewBytes,
44
52
  getPresignedUrl,
45
53
  getS3ObjectBytes,
46
54
  getS3ObjectContent,
47
55
  getS3ObjectEtag,
48
56
  getS3SignedUploadUrl,
57
+ getTableName,
49
58
  getTagBudgetMap,
50
59
  getTagDailyActivity,
51
60
  getToken,
@@ -57,8 +66,6 @@ import {
57
66
  listS3ObjectsByPrefix,
58
67
  listTagsByPrefix,
59
68
  mapStreamErrorMessage,
60
- oauthRegistry,
61
- oauthTokenStore,
62
69
  parseResetAt,
63
70
  postgresClient,
64
71
  provisionDefaultUserBudget,
@@ -77,9 +84,10 @@ import {
77
84
  updateStatistic,
78
85
  uploadFile,
79
86
  upsertBudget,
87
+ verifyCredentialNonce,
80
88
  waitForLiteLLMReady,
81
89
  withRetry
82
- } from "./chunk-KFL7HIID.js";
90
+ } from "./chunk-ZDH5S2WF.js";
83
91
  import {
84
92
  findLiteLLMModel
85
93
  } from "./chunk-7CCMW3IW.js";
@@ -140,9 +148,9 @@ async function guardRedisStartup(label, run, source) {
140
148
  );
141
149
  }, WATCHDOG_INTERVAL_MS);
142
150
  watchdog.unref?.();
143
- let timer2;
151
+ let timer3;
144
152
  const timeout = new Promise((_resolve, reject) => {
145
- timer2 = setTimeout(() => {
153
+ timer3 = setTimeout(() => {
146
154
  reject(
147
155
  new Error(
148
156
  `[EXULU-REDIS] Redis unreachable at ${addr} after ${REDIS_STARTUP_TIMEOUT_MS / 1e3}s \u2014 aborting ${label} startup. Last error: ${lastError ? describeError(lastError) : "none surfaced"}. Check REDIS_HOST/REDIS_PORT and that a Redis server is reachable at ${addr}.`
@@ -159,7 +167,7 @@ async function guardRedisStartup(label, run, source) {
159
167
  return result;
160
168
  } finally {
161
169
  clearInterval(watchdog);
162
- if (timer2) clearTimeout(timer2);
170
+ if (timer3) clearTimeout(timer3);
163
171
  source?.off?.("error", onError);
164
172
  }
165
173
  }
@@ -209,7 +217,13 @@ var requestValidators = {
209
217
  const { db } = await postgresClient();
210
218
  let authtoken = null;
211
219
  if (!apikey) {
212
- authtoken = await getToken((req.headers["authorization"] || req.headers["x-api-key"]) ?? "");
220
+ try {
221
+ authtoken = await getToken(
222
+ (req.headers["authorization"] || req.headers["x-api-key"]) ?? ""
223
+ );
224
+ } catch {
225
+ authtoken = null;
226
+ }
213
227
  }
214
228
  return await authentication({
215
229
  authtoken,
@@ -523,10 +537,6 @@ var ExuluStorage = class {
523
537
  // todo add upload and delete methods
524
538
  };
525
539
 
526
- // src/exulu/table-names.ts
527
- var getTableName = (id) => sanitizeName(id) + "_items";
528
- var getChunksTableName = (id) => sanitizeName(id) + "_chunks";
529
-
530
540
  // src/exulu/context.ts
531
541
  import pgvector from "pgvector/knex";
532
542
 
@@ -1388,10 +1398,31 @@ function preprocessQuery(query, options = {}) {
1388
1398
  };
1389
1399
  }
1390
1400
 
1401
+ // src/graphql/resolvers/field-allow-list.ts
1402
+ var ALWAYS_ALLOWED = /* @__PURE__ */ new Set(["id", "createdAt", "updatedAt"]);
1403
+ function groupableFields(table) {
1404
+ const allowed = new Set(ALWAYS_ALLOWED);
1405
+ for (const field of table.fields) {
1406
+ if (field.hidden !== true) {
1407
+ allowed.add(field.name);
1408
+ }
1409
+ }
1410
+ return allowed;
1411
+ }
1412
+ function assertAllowedField(table, fieldName, label) {
1413
+ const allowed = groupableFields(table);
1414
+ if (!allowed.has(fieldName)) {
1415
+ throw new Error(`Cannot ${label} by "${fieldName}".`);
1416
+ }
1417
+ }
1418
+
1391
1419
  // src/graphql/resolvers/apply-sorting.ts
1392
- var applySorting = (query, sort, field_prefix) => {
1420
+ var applySorting = (query, sort, field_prefix, table) => {
1393
1421
  const prefix = field_prefix ? field_prefix + "." : "";
1394
1422
  if (sort) {
1423
+ if (table) {
1424
+ assertAllowedField(table, sort.field, "sort");
1425
+ }
1395
1426
  sort.field = prefix + sort.field;
1396
1427
  query = query.orderBy(sort.field, sort.direction.toLowerCase());
1397
1428
  }
@@ -2323,6 +2354,31 @@ var agentsSchema = {
2323
2354
  // (DEFAULT_MAX_STEPS in resolve-max-steps.ts). Auto-ALTERed on boot.
2324
2355
  name: "max_tool_steps",
2325
2356
  type: "number"
2357
+ },
2358
+ {
2359
+ name: "guest_access",
2360
+ type: "boolean",
2361
+ default: false
2362
+ },
2363
+ {
2364
+ name: "guest_auth_mode",
2365
+ type: "text",
2366
+ default: "regular"
2367
+ // 'public' | 'password' | 'regular' (= login)
2368
+ },
2369
+ {
2370
+ // bcrypt hash (hashSharePassword); NEVER exposed via GraphQL/REST —
2371
+ // see sanitizeRequestedFields + createExuluContextsTypeDefs filtering.
2372
+ name: "guest_password_hash",
2373
+ type: "text",
2374
+ required: false,
2375
+ hidden: true
2376
+ },
2377
+ {
2378
+ // S3 key of the custom login-page image shown on the public auth page.
2379
+ name: "guest_cover_image",
2380
+ type: "text",
2381
+ required: false
2326
2382
  }
2327
2383
  ]
2328
2384
  };
@@ -2439,7 +2495,8 @@ var usersSchema = {
2439
2495
  },
2440
2496
  {
2441
2497
  name: "temporary_token",
2442
- type: "text"
2498
+ type: "text",
2499
+ hidden: true
2443
2500
  },
2444
2501
  {
2445
2502
  name: "type",
@@ -2465,7 +2522,8 @@ var usersSchema = {
2465
2522
  },
2466
2523
  {
2467
2524
  name: "apikey",
2468
- type: "text"
2525
+ type: "text",
2526
+ hidden: true
2469
2527
  },
2470
2528
  {
2471
2529
  name: "scope_mode",
@@ -2482,11 +2540,13 @@ var usersSchema = {
2482
2540
  },
2483
2541
  {
2484
2542
  name: "password",
2485
- type: "text"
2543
+ type: "text",
2544
+ hidden: true
2486
2545
  },
2487
2546
  {
2488
2547
  name: "anthropic_token",
2489
- type: "text"
2548
+ type: "text",
2549
+ hidden: true
2490
2550
  },
2491
2551
  {
2492
2552
  name: "personal_system_prompt",
@@ -2706,29 +2766,20 @@ var imageGenerationsSchema = {
2706
2766
  { name: "error", type: "text", required: false }
2707
2767
  ]
2708
2768
  };
2709
- var oauthTokensSchema = {
2710
- type: "oauth_tokens",
2711
- name: {
2712
- plural: "oauth_tokens",
2713
- singular: "oauth_token"
2714
- },
2715
- // Rows are only ever read/written by the oauth token store for the owning
2716
- // (provider, user_id) pair — never exposed via GraphQL — so no RBAC fields.
2717
- RBAC: false,
2718
- fields: [
2719
- { name: "provider", type: "text", required: false, index: true },
2720
- { name: "tool_id", type: "text", required: true, index: true },
2721
- { name: "user_id", type: "number", required: true, index: true },
2722
- { name: "access_token", type: "longText", required: true },
2723
- // AES-encrypted
2724
- { name: "refresh_token", type: "longText", required: false },
2725
- // AES-encrypted
2726
- { name: "token_type", type: "text", required: false },
2727
- { name: "scopes", type: "text", required: false },
2728
- { name: "expires_at", type: "date", required: false }
2729
- // null = non-expiring
2730
- ]
2731
- };
2769
+ function userCredentialsSchema() {
2770
+ return `
2771
+ CREATE TABLE IF NOT EXISTS user_credentials (
2772
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
2773
+ provider text NOT NULL,
2774
+ user_id text NOT NULL,
2775
+ auth_type text NOT NULL CHECK (auth_type IN ('oauth', 'user_credentials')),
2776
+ data text NOT NULL,
2777
+ created_at timestamptz NOT NULL DEFAULT now(),
2778
+ updated_at timestamptz NOT NULL DEFAULT now(),
2779
+ UNIQUE (provider, user_id)
2780
+ );
2781
+ `;
2782
+ }
2732
2783
  var sharedArtifactsSchema = {
2733
2784
  type: "shared_artifacts",
2734
2785
  name: {
@@ -2742,7 +2793,7 @@ var sharedArtifactsSchema = {
2742
2793
  { name: "name", type: "text", index: true, unique: true, required: true },
2743
2794
  { name: "s3key", type: "text", required: true },
2744
2795
  { name: "auth_mode", type: "text", default: "regular" },
2745
- { name: "password_hash", type: "text", required: false },
2796
+ { name: "password_hash", type: "text", required: false, hidden: true },
2746
2797
  // bcrypt; password mode only
2747
2798
  { name: "expires_at", type: "date", required: false },
2748
2799
  // null = no expiry
@@ -2840,7 +2891,6 @@ var coreSchemas = {
2840
2891
  entityTypeSettingsSchema: () => addCoreFields(entityTypeSettingsSchema),
2841
2892
  promptFavoritesSchema: () => addCoreFields(promptFavoritesSchema),
2842
2893
  contextPresetsSchema: () => addCoreFields(contextPresetsSchema),
2843
- oauthTokensSchema: () => addCoreFields(oauthTokensSchema),
2844
2894
  sharedArtifactsSchema: () => addCoreFields(sharedArtifactsSchema),
2845
2895
  transcriptionJobsSchema: () => addCoreFields(transcriptionJobsSchema),
2846
2896
  imageGenerationsSchema: () => addCoreFields(imageGenerationsSchema)
@@ -3689,7 +3739,7 @@ var vectorSearch = async ({
3689
3739
  chunksQuery = applyFilters(chunksQuery, itemFilters, table, "items");
3690
3740
  chunksQuery = applyFilters(chunksQuery, chunkFilters, table, "chunks");
3691
3741
  chunksQuery = applyAccessControl(table, chunksQuery, user, "items");
3692
- chunksQuery = applySorting(chunksQuery, sort, "items");
3742
+ chunksQuery = applySorting(chunksQuery, sort, "items", table);
3693
3743
  if (filterEntityIds) {
3694
3744
  applyEntityFilter(chunksQuery, "chunks", context, filterEntityIds, entityFilter?.mode || "any");
3695
3745
  }
@@ -5274,6 +5324,8 @@ var GraphQLDate = new GraphQLScalarType({
5274
5324
  });
5275
5325
 
5276
5326
  // src/graphql/resolvers/utils.ts
5327
+ var NON_COLUMN_SELECTIONS = /* @__PURE__ */ new Set(["pageInfo", "items", "RBAC"]);
5328
+ var isSelectableColumn = (field) => !NON_COLUMN_SELECTIONS.has(field) && !field.startsWith("__");
5277
5329
  var getRequestedFields = (info) => {
5278
5330
  const selections = info.operation.selectionSet.selections[0].selectionSet.selections;
5279
5331
  const itemSelection = selections.find((s) => s.name.value === "item");
@@ -5286,7 +5338,7 @@ var getRequestedFields = (info) => {
5286
5338
  return acc;
5287
5339
  }, {})
5288
5340
  );
5289
- return fields.filter((field) => field !== "pageInfo" && field !== "items" && field !== "RBAC");
5341
+ return fields.filter(isSelectableColumn);
5290
5342
  }
5291
5343
  if (itemsSelection) {
5292
5344
  fields = Object.keys(
@@ -5295,7 +5347,7 @@ var getRequestedFields = (info) => {
5295
5347
  return acc;
5296
5348
  }, {})
5297
5349
  );
5298
- return fields.filter((field) => field !== "pageInfo" && field !== "items" && field !== "RBAC");
5350
+ return fields.filter(isSelectableColumn);
5299
5351
  }
5300
5352
  fields = Object.keys(
5301
5353
  selections.reduce((acc, field) => {
@@ -5303,7 +5355,7 @@ var getRequestedFields = (info) => {
5303
5355
  return acc;
5304
5356
  }, {})
5305
5357
  );
5306
- return fields.filter((field) => field !== "pageInfo" && field !== "items" && field !== "RBAC");
5358
+ return fields.filter(isSelectableColumn);
5307
5359
  };
5308
5360
  var contextItemsProcessorHandler = async (context, config, items, user, role) => {
5309
5361
  let jobs = [];
@@ -5370,7 +5422,7 @@ var exuluProviderFields = [
5370
5422
  "workflows"
5371
5423
  ];
5372
5424
 
5373
- // src/graphql/utilities/sanitize-and-hydrate-fields.ts
5425
+ // src/graphql/utilities/budget-field.ts
5374
5426
  var BUDGET_ENTITY_SINGULARS = /* @__PURE__ */ new Set([
5375
5427
  "user",
5376
5428
  "role",
@@ -5393,22 +5445,37 @@ var BUDGET_ENTITY_TYPE_BY_SINGULAR = {
5393
5445
  };
5394
5446
  var addBudgetField = async (requestedFields, result, tableSingular, user) => {
5395
5447
  if (!requestedFields.includes("budget")) return result;
5448
+ const entityType = BUDGET_ENTITY_TYPE_BY_SINGULAR[tableSingular];
5396
5449
  const scope = user?.role?.budget_management;
5397
- const canRead = !!user?.super_admin || scope === "read" || scope === "write";
5398
- if (!canRead || result?.id == null) {
5450
+ const canReadAll = !!user?.super_admin || scope === "read" || scope === "write";
5451
+ const memberView = !canReadAll && entityType === "project";
5452
+ if (!canReadAll && !memberView || result?.id == null || !entityType) {
5399
5453
  result.budget = null;
5400
5454
  return result;
5401
5455
  }
5402
- const entityType = BUDGET_ENTITY_TYPE_BY_SINGULAR[tableSingular];
5403
- if (!entityType) {
5456
+ const map = await getTagBudgetMap();
5457
+ const tag = budgetTagFor(entityType, result.id);
5458
+ const info = tag ? map[tag] ?? null : null;
5459
+ if (!memberView) {
5460
+ result.budget = info;
5461
+ return result;
5462
+ }
5463
+ if (!info) {
5404
5464
  result.budget = null;
5405
5465
  return result;
5406
5466
  }
5407
- const map = await getTagBudgetMap();
5408
- const tag = budgetTagFor(entityType, result.id);
5409
- result.budget = tag ? map[tag] ?? null : null;
5467
+ const settings = await getBudgetSettings();
5468
+ result.budget = {
5469
+ spend: info.spend,
5470
+ max_budget: info.max_budget,
5471
+ budget_duration: info.budget_duration,
5472
+ budget_reset_at: info.budget_reset_at,
5473
+ display: settings.user_budget_display
5474
+ };
5410
5475
  return result;
5411
5476
  };
5477
+
5478
+ // src/graphql/utilities/sanitize-and-hydrate-fields.ts
5412
5479
  var addProviderFields = async (args, requestedFields, providers, result, tools, user, contexts) => {
5413
5480
  let provider;
5414
5481
  let modelRow;
@@ -5658,6 +5725,14 @@ var finalizeRequestedFields = async ({
5658
5725
  if (!requestedFields.includes("provider")) {
5659
5726
  delete result.provider;
5660
5727
  }
5728
+ if (requestedFields.includes("guest_has_password")) {
5729
+ result.guest_has_password = !!result.guest_password_hash;
5730
+ }
5731
+ }
5732
+ for (const field of table.fields) {
5733
+ if (field.hidden === true) {
5734
+ delete result[field.name];
5735
+ }
5661
5736
  }
5662
5737
  if (BUDGET_ENTITY_SINGULARS.has(table.name.singular)) {
5663
5738
  result = await addBudgetField(requestedFields, result, table.name.singular, user);
@@ -5727,7 +5802,7 @@ var itemsPaginationRequest = async ({
5727
5802
  let dataQuery = db(tableName);
5728
5803
  dataQuery = applyFilters(dataQuery, filters, table);
5729
5804
  dataQuery = applyAccessControl(table, dataQuery, user);
5730
- dataQuery = applySorting(dataQuery, sort);
5805
+ dataQuery = applySorting(dataQuery, sort, void 0, table);
5731
5806
  if (page > 1) {
5732
5807
  dataQuery = dataQuery.offset((page - 1) * limit);
5733
5808
  }
@@ -5750,8 +5825,20 @@ var removeProviderFields = (requestedFields) => {
5750
5825
  return filtered;
5751
5826
  };
5752
5827
  var sanitizeRequestedFields = (table, requestedFields) => {
5828
+ const hiddenNames = new Set(
5829
+ table.fields.filter((f) => f.hidden === true).map((f) => f.name)
5830
+ );
5831
+ if (hiddenNames.size > 0) {
5832
+ requestedFields = requestedFields.filter((f) => !hiddenNames.has(f));
5833
+ }
5753
5834
  if (table.name.singular === "agent") {
5754
5835
  requestedFields = removeProviderFields(requestedFields);
5836
+ if (requestedFields.includes("guest_has_password")) {
5837
+ requestedFields = requestedFields.filter(
5838
+ (field) => field !== "guest_has_password"
5839
+ );
5840
+ requestedFields.push("guest_password_hash");
5841
+ }
5755
5842
  }
5756
5843
  if (["user", "role", "team", "project", "agent"].includes(table.name.singular)) {
5757
5844
  requestedFields = requestedFields.filter((field) => field !== "budget");
@@ -5849,7 +5936,7 @@ function createQueries(table, providers, tools, contexts) {
5849
5936
  let query = db.from(tableNamePlural).select(sanitizedFields);
5850
5937
  query = applyFilters(query, filters, table);
5851
5938
  query = applyAccessControl(table, query, context.user);
5852
- query = applySorting(query, sort);
5939
+ query = applySorting(query, sort, void 0, table);
5853
5940
  let result = await query.first();
5854
5941
  return finalizeRequestedFields({
5855
5942
  args,
@@ -5900,6 +5987,7 @@ function createQueries(table, providers, tools, contexts) {
5900
5987
  query = applyAccessControl(table, query, context.user);
5901
5988
  query = query.limit(limit);
5902
5989
  if (groupBy) {
5990
+ assertAllowedField(table, groupBy, "group");
5903
5991
  query = query.select(groupBy).groupBy(groupBy);
5904
5992
  if (tableNamePlural === "tracking") {
5905
5993
  query = query.sum("total as count");
@@ -6124,7 +6212,7 @@ var encryptSensitiveFields = (input) => {
6124
6212
  };
6125
6213
 
6126
6214
  // src/graphql/mutations/index.ts
6127
- import bcrypt2 from "bcryptjs";
6215
+ import bcrypt3 from "bcryptjs";
6128
6216
 
6129
6217
  // src/exulu/routines/run-state.ts
6130
6218
  import { v4 as uuidv42 } from "uuid";
@@ -6407,7 +6495,73 @@ var handleRBACUpdate = async (db, entityName, resourceId, rbacData, existingRbac
6407
6495
  }
6408
6496
  };
6409
6497
 
6498
+ // src/exulu/shared-artifacts.ts
6499
+ import bcrypt2 from "bcryptjs";
6500
+ var normalizeS3Key = (key, bucket) => {
6501
+ const segments = key.split("/").filter((s, i) => !(i === 0 && s === "")).map((s) => decodeURIComponent(s));
6502
+ if (segments[0] === bucket) segments.shift();
6503
+ return segments.join("/");
6504
+ };
6505
+ var isHtmlKey = (key) => /\.html?$/i.test(key);
6506
+ var deriveFilename = (key) => {
6507
+ const base = key.split("/").pop() ?? key;
6508
+ return base.split("_EXULU_").pop() ?? base;
6509
+ };
6510
+ var slugifyShareName = (input) => deriveFilename(input).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
6511
+ var isExpired = (expiresAt, now) => {
6512
+ if (!expiresAt) return false;
6513
+ return new Date(expiresAt).getTime() <= now.getTime();
6514
+ };
6515
+ var validateCreateInput = (input, now) => {
6516
+ if (!input.s3key) return { ok: false, message: "s3key is required." };
6517
+ if (!input.name) return { ok: false, message: "name is required." };
6518
+ const mode = input.auth_mode;
6519
+ if (mode !== "public" && mode !== "password" && mode !== "regular") {
6520
+ return { ok: false, message: "auth_mode must be public, password, or regular." };
6521
+ }
6522
+ if (mode === "password" && !input.password) {
6523
+ return { ok: false, message: "A password is required for password mode." };
6524
+ }
6525
+ if (input.expires_at && Number.isNaN(new Date(input.expires_at).getTime())) {
6526
+ return { ok: false, message: "expires_at is not a valid date." };
6527
+ }
6528
+ if (input.expires_at && isExpired(input.expires_at, now)) {
6529
+ return { ok: false, message: "expires_at must be in the future." };
6530
+ }
6531
+ return { ok: true };
6532
+ };
6533
+ var hashSharePassword = (password) => bcrypt2.hash(password, 10);
6534
+ var verifySharePassword = (password, hash) => bcrypt2.compare(password, hash);
6535
+ var contentHeadersFor = (key, contentType, filename) => {
6536
+ if (isHtmlKey(key)) return { contentType: "text/html; charset=utf-8" };
6537
+ return {
6538
+ contentType: contentType || "application/octet-stream",
6539
+ disposition: `attachment; filename="${filename.replace(/"/g, "")}"`
6540
+ };
6541
+ };
6542
+ var getSharedArtifactByName = (db, name) => db("shared_artifacts").where({ name }).first();
6543
+
6544
+ // src/graphql/utilities/agent-guest-fields.ts
6545
+ var VALID_GUEST_AUTH_MODES = /* @__PURE__ */ new Set(["public", "password", "regular"]);
6546
+ var applyAgentGuestFieldTransforms = async (input) => {
6547
+ if (input.guest_auth_mode !== void 0 && !VALID_GUEST_AUTH_MODES.has(input.guest_auth_mode)) {
6548
+ throw new Error(
6549
+ 'guest_auth_mode must be "public", "password", or "regular".'
6550
+ );
6551
+ }
6552
+ delete input.guest_password_hash;
6553
+ if (typeof input.guest_password === "string" && input.guest_password.length > 0) {
6554
+ input.guest_password_hash = await hashSharePassword(input.guest_password);
6555
+ }
6556
+ delete input.guest_password;
6557
+ if (input.guest_auth_mode !== void 0 && input.guest_auth_mode !== "password") {
6558
+ input.guest_password_hash = null;
6559
+ }
6560
+ return input;
6561
+ };
6562
+
6410
6563
  // src/graphql/mutations/index.ts
6564
+ var VALID_RIGHTS_MODES = ["private", "users", "roles", "teams", "public"];
6411
6565
  var postprocessDeletion = async ({
6412
6566
  table,
6413
6567
  requestedFields,
@@ -6601,7 +6755,9 @@ function createMutations(table, providers, contexts, tools, config) {
6601
6755
  entity: table.name.singular,
6602
6756
  target_resource_id: id,
6603
6757
  access_type: "Role",
6604
- role_id: user.role,
6758
+ // auth.ts hydrates user.role into the full roles row when it
6759
+ // exists; unhydrated it is still the uuid string.
6760
+ role_id: user.role?.id ?? user.role,
6605
6761
  rights: "write"
6606
6762
  }).first();
6607
6763
  if (rbacRecord) {
@@ -6614,7 +6770,8 @@ function createMutations(table, providers, contexts, tools, config) {
6614
6770
  entity: table.name.singular,
6615
6771
  target_resource_id: id,
6616
6772
  access_type: "Team",
6617
- team_id: user.team,
6773
+ // Same best-effort hydration as user.role above.
6774
+ team_id: user.team?.id ?? user.team,
6618
6775
  rights: "write"
6619
6776
  }).first();
6620
6777
  if (rbacRecord) {
@@ -6644,6 +6801,9 @@ function createMutations(table, providers, contexts, tools, config) {
6644
6801
  if (item.rights_mode) {
6645
6802
  item.rights_mode = "private";
6646
6803
  }
6804
+ if (tableNamePlural === "agents" && "guest_access" in item) {
6805
+ item.guest_access = false;
6806
+ }
6647
6807
  if (item.created_at) {
6648
6808
  item.created_at = /* @__PURE__ */ new Date();
6649
6809
  }
@@ -6712,9 +6872,12 @@ function createMutations(table, providers, contexts, tools, config) {
6712
6872
  }
6713
6873
  if (table.name.singular === "user" && input.password) {
6714
6874
  console.log("[EXULU] Hashing password", input.password);
6715
- input.password = await bcrypt2.hash(input.password, SALT_ROUNDS);
6875
+ input.password = await bcrypt3.hash(input.password, SALT_ROUNDS);
6716
6876
  console.log("[EXULU] Hashed password", input.password);
6717
6877
  }
6878
+ if (table.name.singular === "agent") {
6879
+ input = await applyAgentGuestFieldTransforms(input);
6880
+ }
6718
6881
  Object.keys(input).forEach((key) => {
6719
6882
  if (table.fields.find((field) => field.name === key)?.type === "json") {
6720
6883
  if (typeof input[key] === "object" || Array.isArray(input[key])) {
@@ -6728,10 +6891,15 @@ function createMutations(table, providers, contexts, tools, config) {
6728
6891
  input.id = db.fn.uuid();
6729
6892
  }
6730
6893
  }
6894
+ if (table.RBAC && input.rights_mode != null && !VALID_RIGHTS_MODES.includes(input.rights_mode)) {
6895
+ throw new Error(
6896
+ `Invalid rights_mode "${input.rights_mode}" \u2014 expected one of: ${VALID_RIGHTS_MODES.join(", ")}`
6897
+ );
6898
+ }
6731
6899
  const columns = await db(tableNamePlural).columnInfo();
6732
6900
  const insert = db(tableNamePlural).insert({
6733
6901
  ...input,
6734
- ...table.RBAC ? { rights_mode: "private" } : {}
6902
+ ...table.RBAC ? { rights_mode: input.rights_mode ?? "private" } : {}
6735
6903
  }).returning(Object.keys(columns));
6736
6904
  if (args.upsert) {
6737
6905
  insert.onConflict().merge();
@@ -6779,9 +6947,12 @@ function createMutations(table, providers, contexts, tools, config) {
6779
6947
  }
6780
6948
  if (table.name.singular === "user" && input.password) {
6781
6949
  console.log("[EXULU] Hashing password", input.password);
6782
- input.password = await bcrypt2.hash(input.password, SALT_ROUNDS);
6950
+ input.password = await bcrypt3.hash(input.password, SALT_ROUNDS);
6783
6951
  console.log("[EXULU] Hashed password", input.password);
6784
6952
  }
6953
+ if (table.name.singular === "agent") {
6954
+ input = await applyAgentGuestFieldTransforms(input);
6955
+ }
6785
6956
  Object.keys(input).forEach((key) => {
6786
6957
  if (table.fields.find((field) => field.name === key)?.type === "json") {
6787
6958
  if (typeof input[key] === "object" || Array.isArray(input[key])) {
@@ -6852,9 +7023,12 @@ function createMutations(table, providers, contexts, tools, config) {
6852
7023
  }
6853
7024
  if (table.name.singular === "user" && input.password) {
6854
7025
  console.log("[EXULU] Hashing password", input.password);
6855
- input.password = await bcrypt2.hash(input.password, SALT_ROUNDS);
7026
+ input.password = await bcrypt3.hash(input.password, SALT_ROUNDS);
6856
7027
  console.log("[EXULU] Hashed password", input.password);
6857
7028
  }
7029
+ if (table.name.singular === "agent") {
7030
+ input = await applyAgentGuestFieldTransforms(input);
7031
+ }
6858
7032
  Object.keys(input).forEach((key) => {
6859
7033
  if (table.fields.find((field) => field.name === key)?.type === "json") {
6860
7034
  if (typeof input[key] === "object" || Array.isArray(input[key])) {
@@ -7346,6 +7520,9 @@ var getEnabledTools = async (agent, allExuluTools, allContexts, disabledTools =
7346
7520
  model: void 0
7347
7521
  });
7348
7522
  }
7523
+ if (id === KB_EDITOR_TOOL_ID) {
7524
+ return null;
7525
+ }
7349
7526
  if (type === "agent") {
7350
7527
  if (id === agent.id) {
7351
7528
  return null;
@@ -7654,6 +7831,35 @@ var isRunSessionMetadata = (metadata) => {
7654
7831
  return typeof parsed === "object" && parsed !== null && !!parsed.job_result_id;
7655
7832
  };
7656
7833
 
7834
+ // src/exulu/auth/sanitize-ui-messages.ts
7835
+ var sanitizeAuthPayloadsInUiMessages = (messages) => messages.map((message) => {
7836
+ if (message.role !== "assistant" || !Array.isArray(message.parts)) {
7837
+ return message;
7838
+ }
7839
+ let changed = false;
7840
+ const parts = message.parts.map((part) => {
7841
+ const output = part?.output;
7842
+ if (output && typeof output === "object" && output.credentialRequest) {
7843
+ changed = true;
7844
+ return { ...part, output: { result: SCRUBBED_CREDENTIAL_TEXT } };
7845
+ }
7846
+ if (output && typeof output === "object" && output.oauth?.authorizationUrl) {
7847
+ changed = true;
7848
+ return { ...part, output: { result: SCRUBBED_OAUTH_TEXT } };
7849
+ }
7850
+ return part;
7851
+ });
7852
+ return changed ? { ...message, parts } : message;
7853
+ });
7854
+
7855
+ // src/exulu/auth/guardrail.ts
7856
+ var CREDENTIAL_GUARDRAIL = `Credential safety:
7857
+ Some tools collect credentials (API keys, passwords, tokens) through a secure form shown directly to the user in the chat UI. Credentials are never entered in the conversation itself.
7858
+ - Never ask the user to type credential values into the chat.
7859
+ - If the user pastes a credential value into the chat anyway, do not repeat it, do not store it, and do not pass it to any tool. Tell them to use the secure form instead (calling the tool again shows the form if it is no longer visible).
7860
+ - After the user confirms they submitted the form, call the tool again.`;
7861
+ var credentialGuardrailBlock = (currentTools) => currentTools?.some((t) => t.authentication?.authType === "user_credentials") ? CREDENTIAL_GUARDRAIL : null;
7862
+
7657
7863
  // src/exulu/provider.ts
7658
7864
  var ExuluProvider = class {
7659
7865
  // Must begin with a letter (a-z) or underscore (_). Subsequent characters in a name can be letters, digits (0-9), or
@@ -8023,6 +8229,10 @@ var ExuluProvider = class {
8023
8229
 
8024
8230
  When a tool execution is not approved by the user, do not retry it unless explicitly asked by the user. ' +
8025
8231
  'Inform the user that the action was not performed.`;
8232
+ const credentialGuardrail = credentialGuardrailBlock(currentTools);
8233
+ if (credentialGuardrail) {
8234
+ system += "\n\n" + credentialGuardrail;
8235
+ }
8026
8236
  if (prompt) {
8027
8237
  let result = { object: null, text: "" };
8028
8238
  let inputTokens = 0;
@@ -8100,8 +8310,12 @@ When a tool execution is not approved by the user, do not retry it unless explic
8100
8310
  model,
8101
8311
  // Should be a LanguageModelV1
8102
8312
  system,
8103
- messages: await convertToModelMessages(messages, {
8104
- ignoreIncompleteToolCalls: true
8313
+ // tools: applies each tool's toModelOutput to historical tool results;
8314
+ // sanitize: guarantees auth payloads never reach the model regardless
8315
+ // of part encoding (spec 2026-07-22 §1.2).
8316
+ messages: await convertToModelMessages(sanitizeAuthPayloadsInUiMessages(messages), {
8317
+ ignoreIncompleteToolCalls: true,
8318
+ tools
8105
8319
  }),
8106
8320
  maxRetries: 2,
8107
8321
  tools,
@@ -8421,6 +8635,10 @@ ${skillsList}
8421
8635
 
8422
8636
  When a tool execution is not approved by the user, do not retry it unless explicitly asked by the user. ' +
8423
8637
  'Inform the user that the action was not performed.`;
8638
+ const credentialGuardrail = credentialGuardrailBlock(currentTools);
8639
+ if (credentialGuardrail) {
8640
+ system += "\n\n" + credentialGuardrail;
8641
+ }
8424
8642
  console.log("[EXULU] Tools", currentTools?.map((x) => x.name));
8425
8643
  console.log("[EXULU] Skills", currentSkills?.map((x) => x.name));
8426
8644
  const tools = await convertExuluToolsToAiSdkTools(
@@ -8504,8 +8722,12 @@ When a tool execution is not approved by the user, do not retry it unless explic
8504
8722
  // TODO Make this configurable
8505
8723
  model,
8506
8724
  // Should be a LanguageModelV1
8507
- messages: await convertToModelMessages(messages, {
8508
- ignoreIncompleteToolCalls: true
8725
+ // tools: applies each tool's toModelOutput to historical tool results;
8726
+ // sanitize: guarantees auth payloads never reach the model regardless
8727
+ // of part encoding (spec 2026-07-22 §1.2).
8728
+ messages: await convertToModelMessages(sanitizeAuthPayloadsInUiMessages(messages), {
8729
+ ignoreIncompleteToolCalls: true,
8730
+ tools
8509
8731
  }),
8510
8732
  // PrepareStep could be used here to set the model
8511
8733
  // for the first step or change other parameters.
@@ -10822,10 +11044,25 @@ var RecallApiError = class extends Error {
10822
11044
  }
10823
11045
  };
10824
11046
  var jitterMs = () => 1e3 * Math.ceil(Math.random() * 5);
11047
+ var API_TIMEOUT_MS = 6e4;
11048
+ var DOWNLOAD_TIMEOUT_MS = 3e5;
10825
11049
  async function fetch_with_retry(args) {
10826
- const { url, options, max_attempts = 6 } = args;
11050
+ const { url, options, max_attempts = 6, timeout_ms, retry_on_reject } = args;
10827
11051
  for (let attempt = 1; attempt <= max_attempts; attempt++) {
10828
- const response = await fetch(url, options);
11052
+ let response;
11053
+ try {
11054
+ response = await fetch(
11055
+ url,
11056
+ timeout_ms ? { ...options, signal: AbortSignal.timeout(timeout_ms) } : options
11057
+ );
11058
+ } catch (err) {
11059
+ if (!retry_on_reject || attempt === max_attempts) throw err;
11060
+ console.log(
11061
+ `[EXULU-RECALL] fetch error from ${url} (${err.message}); retrying in ~5s (attempt ${attempt}/${max_attempts})`
11062
+ );
11063
+ await new Promise((resolve5) => setTimeout(resolve5, 5e3 + jitterMs()));
11064
+ continue;
11065
+ }
10829
11066
  let wait_for = null;
10830
11067
  switch (response.status) {
10831
11068
  case 429:
@@ -10866,7 +11103,9 @@ var request2 = async (path2, init = {}) => {
10866
11103
  ...init.body ? { "content-type": "application/json" } : {},
10867
11104
  ...init.headers ?? {}
10868
11105
  }
10869
- }
11106
+ },
11107
+ timeout_ms: API_TIMEOUT_MS,
11108
+ retry_on_reject: (init.method ?? "GET").toUpperCase() === "GET"
10870
11109
  });
10871
11110
  if (!response.ok) {
10872
11111
  const body = await response.text();
@@ -10938,7 +11177,9 @@ var recallClient = {
10938
11177
  downloadTranscript: async (downloadUrl) => {
10939
11178
  const response = await fetch_with_retry({
10940
11179
  url: downloadUrl,
10941
- options: { method: "GET" }
11180
+ options: { method: "GET" },
11181
+ timeout_ms: DOWNLOAD_TIMEOUT_MS,
11182
+ retry_on_reject: true
10942
11183
  });
10943
11184
  if (!response.ok) {
10944
11185
  const body = await response.text();
@@ -10991,6 +11232,12 @@ var durationFromSegments = (segments) => {
10991
11232
  // src/exulu/recall/service.ts
10992
11233
  var TABLE2 = "transcription_jobs";
10993
11234
  var DEFAULT_BOT_NAME = "Company Notetaker";
11235
+ var RECONCILE_STALE_MS = 10 * 60 * 1e3;
11236
+ var RECONCILE_PROBE_QUIET_MS = 60 * 60 * 1e3;
11237
+ var RECONCILE_GIVE_UP_MS = 24 * 60 * 60 * 1e3;
11238
+ var POST_PROCESSING_REDO_STALE_MS = 30 * 60 * 1e3;
11239
+ var POST_PROCESSING_PROMPT_TIMEOUT_MS = 10 * 60 * 1e3;
11240
+ var BOT_ENDED_STATUSES = ["done", "call_ended"];
10994
11241
  var log3 = (msg) => console.log(`[EXULU-RECALL] ${msg}`);
10995
11242
  var parseJson = (v) => {
10996
11243
  if (v == null) return null;
@@ -11067,7 +11314,9 @@ var recallService = {
11067
11314
  target_rights_mode: input.target_rights_mode ?? "private",
11068
11315
  target_rbac_users: input.target_rbac_users ? JSON.stringify(input.target_rbac_users) : null,
11069
11316
  target_rbac_roles: input.target_rbac_roles ? JSON.stringify(input.target_rbac_roles) : null,
11070
- post_processing_prompts: input.post_processing_prompts ? JSON.stringify(input.post_processing_prompts) : null,
11317
+ // Normalized to NULL when empty so the reconcile sweep's redo select
11318
+ // ("prompts configured but never ran") can never match a no-prompt row.
11319
+ post_processing_prompts: input.post_processing_prompts?.length ? JSON.stringify(input.post_processing_prompts) : null,
11071
11320
  rights_mode: "private",
11072
11321
  created_by: input.userId,
11073
11322
  createdAt: now,
@@ -11136,7 +11385,13 @@ var recallService = {
11136
11385
  return;
11137
11386
  }
11138
11387
  const { db } = await postgresClient();
11139
- const claimed = await db(TABLE2).where({ id: jobId }).whereNotIn("status", ["transcribing", "awaiting_review", "saved", "failed"]).update({
11388
+ const claimed = await db(TABLE2).where({ id: jobId }).whereNotIn("status", [
11389
+ "transcribing",
11390
+ "awaiting_review",
11391
+ "saved",
11392
+ "failed",
11393
+ "cancelled"
11394
+ ]).update({
11140
11395
  recall_recording_id: recordingId,
11141
11396
  status: "transcribing",
11142
11397
  updatedAt: /* @__PURE__ */ new Date()
@@ -11228,14 +11483,55 @@ var recallService = {
11228
11483
  log3(`post-processing for job ${jobId} skipped: no transcript.`);
11229
11484
  return [];
11230
11485
  }
11486
+ const claimed = await db(TABLE2).where({ id: jobId }).whereRaw(
11487
+ `(post_processing_outputs IS NULL OR (post_processing_outputs::text = '[]' AND "updatedAt" < ?))`,
11488
+ [new Date(Date.now() - POST_PROCESSING_REDO_STALE_MS)]
11489
+ ).update({ post_processing_outputs: "[]", updatedAt: /* @__PURE__ */ new Date() });
11490
+ if (!claimed) {
11491
+ log3(`post-processing for job ${jobId} already in flight; skipping.`);
11492
+ return [];
11493
+ }
11231
11494
  const outputs = [];
11232
11495
  for (const p of prompts) {
11233
11496
  outputs.push(await this._runOnePrompt(job, p.prompt_id, p.agent_id));
11497
+ await this._update(jobId, {});
11234
11498
  }
11235
- await this._update(jobId, {
11236
- post_processing_outputs: JSON.stringify(outputs)
11237
- });
11238
- return outputs;
11499
+ return this._mergeOutputs(jobId, outputs);
11500
+ },
11501
+ /**
11502
+ * Upsert entries into post_processing_outputs without clobbering concurrent
11503
+ * writers: optimistic-concurrency loop — read, merge by {prompt_id,
11504
+ * agent_id}, write only while the column still matches the snapshot.
11505
+ */
11506
+ async _mergeOutputs(jobId, entries) {
11507
+ const { db } = await postgresClient();
11508
+ for (let attempt = 1; attempt <= 5; attempt++) {
11509
+ const row = await db(TABLE2).where({ id: jobId }).first();
11510
+ if (!row) return entries;
11511
+ const raw = row.post_processing_outputs ?? null;
11512
+ const snapshot = raw == null ? null : typeof raw === "string" ? raw : JSON.stringify(raw);
11513
+ const current = parseJson(raw) ?? [];
11514
+ const merged = [
11515
+ ...current.filter(
11516
+ (o) => !entries.some(
11517
+ (e) => e.prompt_id === o.prompt_id && e.agent_id === o.agent_id
11518
+ )
11519
+ ),
11520
+ ...entries
11521
+ ];
11522
+ const updated = await db(TABLE2).where({ id: jobId }).whereRaw(
11523
+ "post_processing_outputs::jsonb IS NOT DISTINCT FROM ?::jsonb",
11524
+ [snapshot]
11525
+ ).update({
11526
+ post_processing_outputs: JSON.stringify(merged),
11527
+ updatedAt: /* @__PURE__ */ new Date()
11528
+ });
11529
+ if (updated) return merged;
11530
+ }
11531
+ log3(
11532
+ `post-processing outputs for job ${jobId} kept changing under the merge; leaving the concurrent writer's data in place.`
11533
+ );
11534
+ return entries;
11239
11535
  },
11240
11536
  /**
11241
11537
  * Manual single-prompt run (re-run from the review sheet). Upserts the matching
@@ -11246,13 +11542,14 @@ var recallService = {
11246
11542
  const dbRow = await db(TABLE2).where({ id: jobId }).first();
11247
11543
  if (!dbRow) throw new Error(`transcription_job ${jobId} not found`);
11248
11544
  const job = this._row(dbRow);
11545
+ const claimInFlight = dbRow.post_processing_outputs != null && (job.post_processing_outputs?.length ?? 0) === 0 && Date.now() - new Date(job.updatedAt).getTime() < POST_PROCESSING_REDO_STALE_MS;
11546
+ if (claimInFlight) {
11547
+ throw new Error(
11548
+ "POST_PROCESSING_IN_FLIGHT: the automatic post-processing run is still in progress; its results will appear shortly."
11549
+ );
11550
+ }
11249
11551
  const result = await this._runOnePrompt(job, promptId, agentId);
11250
- const existing = job.post_processing_outputs ?? [];
11251
- const next = existing.filter(
11252
- (o) => !(o.prompt_id === promptId && o.agent_id === agentId)
11253
- );
11254
- next.push(result);
11255
- await this._update(jobId, { post_processing_outputs: JSON.stringify(next) });
11552
+ await this._mergeOutputs(jobId, [result]);
11256
11553
  return result;
11257
11554
  },
11258
11555
  async _runOnePrompt(job, promptId, agentId) {
@@ -11286,7 +11583,8 @@ var recallService = {
11286
11583
  Meeting transcript:
11287
11584
 
11288
11585
  ${transcriptText}`,
11289
- maxRetries: 3
11586
+ maxRetries: 3,
11587
+ abortSignal: AbortSignal.timeout(POST_PROCESSING_PROMPT_TIMEOUT_MS)
11290
11588
  });
11291
11589
  return {
11292
11590
  prompt_id: promptId,
@@ -11310,6 +11608,206 @@ ${transcriptText}`,
11310
11608
  };
11311
11609
  }
11312
11610
  },
11611
+ /**
11612
+ * Reconciliation sweep: re-drive recall jobs whose webhook event was lost
11613
+ * (ACK-first delivery + crash/restart = no redelivery). Called periodically
11614
+ * from the reconcile loop. Returns the number of jobs it acted on.
11615
+ *
11616
+ * Idempotent by construction: every recovery path funnels into the same
11617
+ * guarded transitions the webhook handlers use (_onRecordingDone's atomic
11618
+ * claim, _onTranscriptDone's already-processed check, runPostProcessing's
11619
+ * skip-if-outputs-exist), so a webhook racing the sweep is harmless.
11620
+ */
11621
+ async reconcileOnce(limit = 10) {
11622
+ if (!recallEnabled()) return 0;
11623
+ const { db } = await postgresClient();
11624
+ const now = Date.now();
11625
+ const stuck = await db(TABLE2).where({ source: "recall" }).whereIn("status", ["queued", "transcribing"]).whereRaw(`("join_at" IS NULL OR "join_at" < ?)`, [
11626
+ new Date(now - RECONCILE_STALE_MS)
11627
+ ]).where("updatedAt", "<", new Date(now - RECONCILE_STALE_MS)).orderBy("updatedAt", "asc").limit(limit);
11628
+ const redo = await db(TABLE2).where({ source: "recall", status: "awaiting_review" }).whereNotNull("post_processing_prompts").whereRaw("post_processing_prompts::text <> '[]'").whereRaw("(raw_segments IS NOT NULL AND raw_segments::text <> '[]')").whereRaw(
11629
+ "(post_processing_outputs IS NULL OR post_processing_outputs::text = '[]')"
11630
+ ).where("updatedAt", "<", new Date(now - POST_PROCESSING_REDO_STALE_MS)).orderBy("updatedAt", "asc").limit(limit);
11631
+ let acted = 0;
11632
+ for (const dbRow of stuck) {
11633
+ const job = this._row(dbRow);
11634
+ try {
11635
+ if (await this._reconcileStuckJob(job)) acted++;
11636
+ } catch (err) {
11637
+ if (await this._reconcileError(job, err)) acted++;
11638
+ }
11639
+ }
11640
+ for (const dbRow of redo) {
11641
+ try {
11642
+ log3(`reconcile: re-running lost post-processing for job ${dbRow.id}`);
11643
+ const outputs = await this.runPostProcessing(dbRow.id);
11644
+ if (outputs.length > 0) acted++;
11645
+ } catch (err) {
11646
+ log3(
11647
+ `post-processing redo failed for job ${dbRow.id}: ${err.message}`
11648
+ );
11649
+ }
11650
+ }
11651
+ return acted;
11652
+ },
11653
+ async _reconcileStuckJob(job) {
11654
+ if (!job.recall_bot_id) {
11655
+ await this._fail(job.id, "bot was never launched (lost during creation)");
11656
+ return true;
11657
+ }
11658
+ if (job.status === "queued") return this._reconcileQueued(job);
11659
+ if (job.status === "transcribing") return this._reconcileTranscribing(job);
11660
+ return false;
11661
+ },
11662
+ /**
11663
+ * A recovery step threw. 404 means the Recall object is gone — terminal.
11664
+ * Otherwise apply the 24h give-up (API errors must not defer it forever),
11665
+ * and touch the row so it rotates to the back of the sweep window.
11666
+ */
11667
+ async _reconcileError(job, err) {
11668
+ const message = err.message ?? String(err);
11669
+ if (err.status === 404) {
11670
+ await this._fail(
11671
+ job.id,
11672
+ `Recall no longer knows this recording: ${message}`
11673
+ );
11674
+ return true;
11675
+ }
11676
+ if (this._pastGiveUp(job)) {
11677
+ await this._fail(
11678
+ job.id,
11679
+ `still stuck 24 hours after the meeting start (last error: ${message})`
11680
+ );
11681
+ return true;
11682
+ }
11683
+ log3(`reconcile failed for job ${job.id}: ${message}`);
11684
+ await this._update(job.id, {});
11685
+ return false;
11686
+ },
11687
+ /** A queued row: the recording.done event (or the whole bot lifecycle) was lost. */
11688
+ async _reconcileQueued(job) {
11689
+ const ended = !!job.bot_status && BOT_ENDED_STATUSES.includes(job.bot_status);
11690
+ const quietMs = Date.now() - new Date(job.updatedAt).getTime();
11691
+ if (!ended && quietMs < RECONCILE_PROBE_QUIET_MS) return false;
11692
+ const bot = await recallClient.retrieveBot(job.recall_bot_id);
11693
+ const code = bot?.status_changes?.at(-1)?.code ?? null;
11694
+ if (code === "fatal") {
11695
+ await this._fail(job.id, "bot fatal (recovered by reconciliation)");
11696
+ return true;
11697
+ }
11698
+ const recording = bot?.recordings?.[0] ?? null;
11699
+ if (recording?.id) {
11700
+ const readiness = await this._recordingReadiness(recording);
11701
+ if (readiness === "failed") {
11702
+ await this._fail(job.id, "recording failed (found by reconciliation)");
11703
+ return true;
11704
+ }
11705
+ if (readiness === "done") {
11706
+ log3(`reconcile: driving lost recording.done for job ${job.id}`);
11707
+ await this._onRecordingDone(job.id, recording.id);
11708
+ return true;
11709
+ }
11710
+ } else if (code === "done") {
11711
+ await this._fail(job.id, "bot finished without a recording");
11712
+ return true;
11713
+ }
11714
+ if (this._pastGiveUp(job)) {
11715
+ await this._fail(
11716
+ job.id,
11717
+ "no recording within 24 hours of the meeting start"
11718
+ );
11719
+ return true;
11720
+ }
11721
+ await this._update(
11722
+ job.id,
11723
+ code && code !== job.bot_status ? { bot_status: code } : {}
11724
+ );
11725
+ return false;
11726
+ },
11727
+ /** Whether a recording is safe to transcribe, checking the full recording
11728
+ * object when the bot payload carries no status. */
11729
+ async _recordingReadiness(recording) {
11730
+ const codeOf = (c) => c === "done" ? "done" : c === "failed" ? "failed" : c ? "processing" : null;
11731
+ const fromBot = codeOf(recording.status?.code);
11732
+ if (fromBot) return fromBot;
11733
+ if (recording.completed_at) return "done";
11734
+ const full = await recallClient.retrieveRecording(recording.id);
11735
+ const fromFull = codeOf(full?.status?.code);
11736
+ if (fromFull) return fromFull;
11737
+ return full?.completed_at || recordingDurationSeconds(full) != null ? "done" : "processing";
11738
+ },
11739
+ /**
11740
+ * A transcribing row: transcript.done was lost, or the original
11741
+ * recording.done handling crashed between the status claim and
11742
+ * createAsyncTranscript (in which case Recall was never asked to
11743
+ * transcribe and no transcript.* event will ever arrive).
11744
+ */
11745
+ async _reconcileTranscribing(job) {
11746
+ let recordingId = job.recall_recording_id;
11747
+ if (!recordingId && !job.recall_transcript_id) {
11748
+ const bot = await recallClient.retrieveBot(job.recall_bot_id);
11749
+ recordingId = bot?.recordings?.[0]?.id ?? null;
11750
+ }
11751
+ let transcriptId = job.recall_transcript_id;
11752
+ if (!transcriptId) {
11753
+ if (!recordingId) return this._transcribingNotReady(job);
11754
+ const rec = await recallClient.retrieveRecording(recordingId);
11755
+ const shortcut = rec?.media_shortcuts?.transcript;
11756
+ if (shortcut?.id) {
11757
+ transcriptId = shortcut.id;
11758
+ await this._update(job.id, {
11759
+ recall_recording_id: recordingId,
11760
+ recall_transcript_id: transcriptId
11761
+ });
11762
+ } else {
11763
+ const { db } = await postgresClient();
11764
+ const claimed = await db(TABLE2).where({ id: job.id, status: "transcribing" }).whereNull("recall_transcript_id").where("updatedAt", "<", new Date(Date.now() - RECONCILE_STALE_MS)).update({ recall_recording_id: recordingId, updatedAt: /* @__PURE__ */ new Date() });
11765
+ if (!claimed) return false;
11766
+ log3(
11767
+ `reconcile: re-requesting transcript for job ${job.id} (lost before createAsyncTranscript)`
11768
+ );
11769
+ const transcript2 = await recallClient.createAsyncTranscript(
11770
+ recordingId,
11771
+ job.language || "auto"
11772
+ );
11773
+ await this._update(job.id, {
11774
+ recall_transcript_id: transcript2.id ?? null
11775
+ });
11776
+ return true;
11777
+ }
11778
+ }
11779
+ const transcript = await recallClient.retrieveTranscript(transcriptId);
11780
+ const transcriptCode = transcript?.status?.code ?? null;
11781
+ if (transcriptCode === "error" || transcriptCode === "failed") {
11782
+ await this._fail(
11783
+ job.id,
11784
+ `transcript failed at Recall: ${transcript?.status?.sub_code || transcriptCode}`
11785
+ );
11786
+ return true;
11787
+ }
11788
+ if (transcript?.data?.download_url) {
11789
+ log3(`reconcile: driving lost transcript.done for job ${job.id}`);
11790
+ await this._onTranscriptDone(job.id, transcriptId, recordingId);
11791
+ return true;
11792
+ }
11793
+ return this._transcribingNotReady(job);
11794
+ },
11795
+ /** Transcript not ready yet: wait (touch) or give up after the hard cap. */
11796
+ async _transcribingNotReady(job) {
11797
+ if (this._pastGiveUp(job)) {
11798
+ await this._fail(
11799
+ job.id,
11800
+ "transcript was not ready within 24 hours of the meeting start"
11801
+ );
11802
+ return true;
11803
+ }
11804
+ await this._update(job.id, {});
11805
+ return false;
11806
+ },
11807
+ _pastGiveUp(job) {
11808
+ const startedAt = new Date(job.join_at ?? job.createdAt).getTime();
11809
+ return Number.isFinite(startedAt) && Date.now() - startedAt > RECONCILE_GIVE_UP_MS;
11810
+ },
11313
11811
  async _findJob(botId, recordingId, transcriptId) {
11314
11812
  const { db } = await postgresClient();
11315
11813
  let dbRow;
@@ -11539,7 +12037,8 @@ function createExuluContextsTypeDefs(table) {
11539
12037
  ${enumValues}
11540
12038
  }`;
11541
12039
  }).filter((enumDef) => enumDef !== null).join("\n");
11542
- let fields = table.fields.map((field) => {
12040
+ const graphqlFields = table.fields.filter((field) => field.hidden !== true);
12041
+ let fields = graphqlFields.map((field) => {
11543
12042
  let type;
11544
12043
  type = mapExuluFieldTypesToGraphqlTypes(field);
11545
12044
  const required = field.required ? "!" : "";
@@ -11560,6 +12059,7 @@ function createExuluContextsTypeDefs(table) {
11560
12059
  fields.push(" systemInstructions: String");
11561
12060
  fields.push(" workflows: AgentWorkflows");
11562
12061
  fields.push(" slug: String");
12062
+ fields.push(" guest_has_password: Boolean");
11563
12063
  }
11564
12064
  if (table.name.singular === "workflow_template") {
11565
12065
  fields.push(" variables: [String]");
@@ -11576,16 +12076,20 @@ function createExuluContextsTypeDefs(table) {
11576
12076
  }
11577
12077
  `;
11578
12078
  const rbacInputField = table.RBAC ? " RBAC: RBACInput" : "";
12079
+ const inputFields = table.fields.filter((f) => f.name !== "guest_password_hash");
12080
+ const inputExtra = table.name.singular === "agent" ? " guest_password: String" : "";
11579
12081
  const inputDef = `
11580
12082
  input ${table.name.singular}Input {
11581
- ${table.fields.map((f) => ` ${f.name}: ${mapExuluFieldTypesToGraphqlTypes(f)}`).join("\n")}
12083
+ ${inputFields.map((f) => ` ${f.name}: ${mapExuluFieldTypesToGraphqlTypes(f)}`).join("\n")}
12084
+ ${inputExtra}
11582
12085
  ${rbacInputField}
11583
12086
  }
11584
12087
  `;
11585
12088
  return enumDefs + typeDef + inputDef;
11586
12089
  }
11587
12090
  function createExuluContextsFilterTypeDefs(table) {
11588
- const fieldFilters = table.fields.map((field) => {
12091
+ const filterFields = table.fields.filter((field) => field.hidden !== true);
12092
+ const fieldFilters = filterFields.map((field) => {
11589
12093
  let type;
11590
12094
  if (field.type === "enum" && field.enumValues) {
11591
12095
  type = `${field.name}Enum`;
@@ -11598,7 +12102,7 @@ function createExuluContextsFilterTypeDefs(table) {
11598
12102
  let operatorTypes = "";
11599
12103
  let enumFilterOperators = [];
11600
12104
  const tableNameSingularUpperCaseFirst = table.name.singular.charAt(0).toUpperCase() + table.name.singular.slice(1);
11601
- enumFilterOperators = table.fields.filter((field) => field.type === "enum" && field.enumValues).map((field) => {
12105
+ enumFilterOperators = filterFields.filter((field) => field.type === "enum" && field.enumValues).map((field) => {
11602
12106
  const enumTypeName = `${field.name}Enum`;
11603
12107
  return `
11604
12108
  input FilterOperator${enumTypeName} {
@@ -13157,12 +13661,12 @@ type LiteLLMModel {
13157
13661
  nonArchived().andWhere((b) => b.whereNull("chunks_count").orWhere("chunks_count", "<=", 0)).count("* as c").first(),
13158
13662
  nonArchived().where("embeddings_updated_at", "<=", staleCutoff).count("* as c").first()
13159
13663
  ]);
13160
- const num = (v) => v == null ? 0 : Number(v);
13664
+ const num2 = (v) => v == null ? 0 : Number(v);
13161
13665
  return {
13162
- item_count: num(itemRow?.c),
13163
- chunk_total: num(chunkRow?.s),
13164
- stuck_count: num(stuckRow?.c),
13165
- stale_count: num(staleRow?.c)
13666
+ item_count: num2(itemRow?.c),
13667
+ chunk_total: num2(chunkRow?.s),
13668
+ stuck_count: num2(stuckRow?.c),
13669
+ stale_count: num2(staleRow?.c)
13166
13670
  };
13167
13671
  } catch (err) {
13168
13672
  console.error("[EXULU] computeContextAggregates failed for", contextId, err);
@@ -13368,6 +13872,7 @@ type LiteLLMModel {
13368
13872
  if (agenticRetrievalTool) {
13369
13873
  allTools.push(agenticRetrievalTool);
13370
13874
  }
13875
+ allTools.push(createKbEditorPickerTool());
13371
13876
  }
13372
13877
  if (search && search.trim()) {
13373
13878
  const searchTerm = search.toLowerCase().trim();
@@ -14479,6 +14984,125 @@ See docs/superpowers/specs/2026-05-31-in-chat-image-generation-design.md for the
14479
14984
 
14480
14985
  // src/exulu/routes.ts
14481
14986
  import { resolve as resolvePath } from "path";
14987
+
14988
+ // src/exulu/litellm/usage-view.ts
14989
+ var DAY_MS = 24 * 60 * 60 * 1e3;
14990
+ var DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
14991
+ var MAX_WINDOW_DAYS = 92;
14992
+ var DEFAULT_WINDOW_DAYS = 30;
14993
+ var parseDateParam = (raw) => {
14994
+ if (raw === void 0 || raw === null || raw === "") return void 0;
14995
+ if (typeof raw !== "string") return null;
14996
+ if (DATE_ONLY_RE.test(raw)) return raw;
14997
+ const dt = new Date(raw);
14998
+ if (Number.isNaN(dt.getTime())) return null;
14999
+ return dt.toISOString().slice(0, 10);
15000
+ };
15001
+ var ymdShift = (ymd, days) => new Date(Date.parse(ymd) + days * DAY_MS).toISOString().slice(0, 10);
15002
+ function resolveUsageWindow(startRaw, endRaw, now = /* @__PURE__ */ new Date()) {
15003
+ const start = parseDateParam(startRaw);
15004
+ const end = parseDateParam(endRaw);
15005
+ if (start === null || end === null) return null;
15006
+ const end_date = end ?? now.toISOString().slice(0, 10);
15007
+ let start_date = start ?? ymdShift(end_date, -(DEFAULT_WINDOW_DAYS - 1));
15008
+ if (start_date > end_date) return null;
15009
+ const days = Math.round((Date.parse(end_date) - Date.parse(start_date)) / DAY_MS) + 1;
15010
+ if (days > MAX_WINDOW_DAYS) {
15011
+ start_date = ymdShift(end_date, -(MAX_WINDOW_DAYS - 1));
15012
+ }
15013
+ return { start_date, end_date };
15014
+ }
15015
+ var num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
15016
+ var zeroMetrics = () => ({
15017
+ spend: 0,
15018
+ prompt_tokens: 0,
15019
+ completion_tokens: 0,
15020
+ total_tokens: 0,
15021
+ successful_requests: 0,
15022
+ failed_requests: 0,
15023
+ api_requests: 0
15024
+ });
15025
+ var readMetrics = (source) => {
15026
+ const m = source?.metrics ?? {};
15027
+ return {
15028
+ spend: num(m.spend ?? source?.spend),
15029
+ prompt_tokens: num(m.prompt_tokens ?? source?.prompt_tokens),
15030
+ completion_tokens: num(m.completion_tokens ?? source?.completion_tokens),
15031
+ total_tokens: num(m.total_tokens ?? source?.total_tokens),
15032
+ successful_requests: num(
15033
+ m.successful_requests ?? source?.successful_requests
15034
+ ),
15035
+ failed_requests: num(m.failed_requests ?? source?.failed_requests),
15036
+ api_requests: num(m.api_requests ?? source?.api_requests)
15037
+ };
15038
+ };
15039
+ var addMetrics = (into, add) => {
15040
+ into.spend += add.spend;
15041
+ into.prompt_tokens += add.prompt_tokens;
15042
+ into.completion_tokens += add.completion_tokens;
15043
+ into.total_tokens += add.total_tokens;
15044
+ into.successful_requests += add.successful_requests;
15045
+ into.failed_requests += add.failed_requests;
15046
+ into.api_requests += add.api_requests;
15047
+ };
15048
+ function projectMyUsage(raw) {
15049
+ const results = Array.isArray(raw?.results) ? raw.results : Array.isArray(raw) ? raw : [];
15050
+ const totals = zeroMetrics();
15051
+ const byDate = /* @__PURE__ */ new Map();
15052
+ const byModelMap = /* @__PURE__ */ new Map();
15053
+ for (const result of results) {
15054
+ const date = typeof result?.date === "string" ? result.date : null;
15055
+ if (!date) continue;
15056
+ const metrics = readMetrics(result);
15057
+ addMetrics(totals, metrics);
15058
+ const day = byDate.get(date) ?? zeroMetrics();
15059
+ addMetrics(day, metrics);
15060
+ byDate.set(date, day);
15061
+ const models2 = result?.breakdown && typeof result.breakdown.models === "object" ? result.breakdown.models : {};
15062
+ for (const [model, entry] of Object.entries(models2 ?? {})) {
15063
+ const acc = byModelMap.get(model) ?? zeroMetrics();
15064
+ addMetrics(acc, readMetrics(entry));
15065
+ byModelMap.set(model, acc);
15066
+ }
15067
+ }
15068
+ const daily = [...byDate.entries()].map(([date, m]) => ({ date, ...m })).sort((a, b) => a.date.localeCompare(b.date));
15069
+ const byModel = [...byModelMap.entries()].map(([model, m]) => ({
15070
+ model,
15071
+ spend: m.spend,
15072
+ prompt_tokens: m.prompt_tokens,
15073
+ completion_tokens: m.completion_tokens,
15074
+ total_tokens: m.total_tokens,
15075
+ successful_requests: m.successful_requests,
15076
+ failed_requests: m.failed_requests
15077
+ })).sort((a, b) => b.spend - a.spend);
15078
+ return { totals, daily, byModel };
15079
+ }
15080
+ async function getMyUsageView(userId, window) {
15081
+ const settings = await getBudgetSettings();
15082
+ if (!settings.show_user_budget_in_chat) return null;
15083
+ const tag = budgetTagFor("user", userId);
15084
+ if (!tag) return null;
15085
+ const daysInRange = Math.round(
15086
+ (Date.parse(window.end_date) - Date.parse(window.start_date)) / DAY_MS
15087
+ ) + 1;
15088
+ const raw = await getTagDailyActivity({
15089
+ startDate: window.start_date,
15090
+ endDate: window.end_date,
15091
+ tags: [tag],
15092
+ page: 1,
15093
+ pageSize: Math.min(daysInRange + 100, 1e4)
15094
+ });
15095
+ const { totals, daily, byModel } = projectMyUsage(raw);
15096
+ return {
15097
+ window,
15098
+ display: settings.user_budget_display,
15099
+ totals,
15100
+ daily,
15101
+ byModel
15102
+ };
15103
+ }
15104
+
15105
+ // src/exulu/routes.ts
14482
15106
  import multer from "multer";
14483
15107
  import Busboy from "busboy";
14484
15108
 
@@ -15188,7 +15812,7 @@ var getEnabledSkills = async (agent, disabledSkills = []) => {
15188
15812
  return enabledSkills;
15189
15813
  };
15190
15814
 
15191
- // src/exulu/oauth/callback-handler.ts
15815
+ // src/exulu/auth/callback-handler.ts
15192
15816
  var escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
15193
15817
  var renderResultPage = ({ success, message }) => `<!doctype html>
15194
15818
  <html lang="en">
@@ -15240,7 +15864,7 @@ var handleOauthCallback = async (req, res) => {
15240
15864
  "This authorization link is invalid or has expired. Return to your chat and run the tool again to get a fresh link."
15241
15865
  );
15242
15866
  }
15243
- const config = oauthRegistry.getByProvider(parsed.provider);
15867
+ const config = authRegistry.getByProvider(parsed.provider);
15244
15868
  if (!config) {
15245
15869
  return send(
15246
15870
  404,
@@ -15248,13 +15872,31 @@ var handleOauthCallback = async (req, res) => {
15248
15872
  `No OAuth configuration is registered for provider "${parsed.provider}".`
15249
15873
  );
15250
15874
  }
15875
+ if (config.authType !== "oauth") {
15876
+ return send(
15877
+ 500,
15878
+ false,
15879
+ `Provider "${parsed.provider}" is not configured for OAuth. This callback only handles OAuth flows.`
15880
+ );
15881
+ }
15251
15882
  try {
15252
15883
  const record = await exchangeCodeForTokens({
15253
15884
  config,
15254
15885
  code,
15255
15886
  codeVerifier: parsed.codeVerifier
15256
15887
  });
15257
- await oauthTokenStore.upsert(parsed.provider, parsed.userId, parsed.toolId, record);
15888
+ await credentialStore.upsert({
15889
+ provider: parsed.provider,
15890
+ userId: parsed.userId,
15891
+ authType: "oauth",
15892
+ data: {
15893
+ accessToken: record.accessToken,
15894
+ refreshToken: record.refreshToken ?? null,
15895
+ tokenType: record.tokenType ?? null,
15896
+ scopes: record.scopes ?? null,
15897
+ expiresAt: record.expiresAt ? record.expiresAt.toISOString() : null
15898
+ }
15899
+ });
15258
15900
  } catch (caught) {
15259
15901
  console.error("[EXULU] OAuth code exchange failed:", caught);
15260
15902
  return send(
@@ -15266,6 +15908,107 @@ var handleOauthCallback = async (req, res) => {
15266
15908
  return send(200, true, "You can close this tab and return to your chat.");
15267
15909
  };
15268
15910
 
15911
+ // src/exulu/auth/submit-handler.ts
15912
+ import { z as z4 } from "zod";
15913
+ var bodySchema = z4.object({
15914
+ nonce: z4.string().min(1),
15915
+ values: z4.record(z4.string(), z4.string())
15916
+ });
15917
+ async function handleCredentialSubmit(req, res) {
15918
+ const authResult = await requestValidators.authenticate(req);
15919
+ if (!authResult.user?.id) {
15920
+ res.status(401).json({ ok: false, error: "authentication required" });
15921
+ return;
15922
+ }
15923
+ const sessionUserId = authResult.user.id;
15924
+ let parsed;
15925
+ try {
15926
+ parsed = bodySchema.parse(req.body);
15927
+ } catch (caught) {
15928
+ res.status(400).json({ ok: false, error: "invalid body" });
15929
+ return;
15930
+ }
15931
+ const { nonce, values } = parsed;
15932
+ let nonceData;
15933
+ try {
15934
+ nonceData = verifyCredentialNonce(nonce);
15935
+ } catch (e) {
15936
+ res.status(401).json({
15937
+ ok: false,
15938
+ error: /expired/i.test(e.message) ? "nonce expired" : "nonce invalid"
15939
+ });
15940
+ return;
15941
+ }
15942
+ if (sessionUserId !== Number(nonceData.userId)) {
15943
+ res.status(403).json({ ok: false, error: "userId mismatch" });
15944
+ return;
15945
+ }
15946
+ const config = authRegistry.getByProvider(nonceData.provider);
15947
+ if (!config || config.authType !== "user_credentials") {
15948
+ res.status(400).json({
15949
+ ok: false,
15950
+ error: "provider is not a user_credentials provider"
15951
+ });
15952
+ return;
15953
+ }
15954
+ const expectedFields = new Set(config.fields.map((f) => f.name));
15955
+ const submittedFields = new Set(Object.keys(values));
15956
+ if (expectedFields.size !== submittedFields.size || [...expectedFields].some((f) => !submittedFields.has(f))) {
15957
+ res.status(400).json({
15958
+ ok: false,
15959
+ error: "field set mismatch"
15960
+ });
15961
+ return;
15962
+ }
15963
+ if (config.validate) {
15964
+ try {
15965
+ await config.validate(values);
15966
+ } catch (e) {
15967
+ res.status(400).json({
15968
+ ok: false,
15969
+ error: `validation failed: ${e.message}`
15970
+ });
15971
+ return;
15972
+ }
15973
+ }
15974
+ if (!Number.isInteger(sessionUserId) || sessionUserId <= 0) {
15975
+ res.status(401).json({ ok: false, error: "session invalid" });
15976
+ return;
15977
+ }
15978
+ await credentialStore.upsert({
15979
+ provider: nonceData.provider,
15980
+ userId: sessionUserId,
15981
+ authType: "user_credentials",
15982
+ data: values
15983
+ });
15984
+ res.status(200).json({ ok: true });
15985
+ }
15986
+
15987
+ // src/exulu/auth/manage-handlers.ts
15988
+ var handleCredentialList = async (req, res) => {
15989
+ const authResult = await requestValidators.authenticate(req);
15990
+ if (!authResult.user?.id) {
15991
+ res.status(authResult.code ?? 401).json({ ok: false, error: "authentication required" });
15992
+ return;
15993
+ }
15994
+ const credentials = await credentialStore.listByUser(authResult.user.id);
15995
+ res.status(200).json({ ok: true, credentials });
15996
+ };
15997
+ var handleCredentialDelete = async (req, res) => {
15998
+ const authResult = await requestValidators.authenticate(req);
15999
+ if (!authResult.user?.id) {
16000
+ res.status(authResult.code ?? 401).json({ ok: false, error: "authentication required" });
16001
+ return;
16002
+ }
16003
+ const provider = req.params.provider;
16004
+ if (!provider) {
16005
+ res.status(400).json({ ok: false, error: "provider is required" });
16006
+ return;
16007
+ }
16008
+ await credentialStore.delete(provider, authResult.user.id);
16009
+ res.status(200).json({ ok: true });
16010
+ };
16011
+
15269
16012
  // src/exulu/recall/verify.ts
15270
16013
  import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
15271
16014
  var TOLERANCE_SECONDS = 5 * 60;
@@ -15315,51 +16058,123 @@ var verifyRecallRequest = (headers, rawBody, secret = recallVerificationSecret()
15315
16058
  return passed ? { ok: true } : { ok: false, reason: "signature mismatch" };
15316
16059
  };
15317
16060
 
15318
- // src/exulu/shared-artifacts.ts
15319
- import bcrypt3 from "bcryptjs";
15320
- var normalizeS3Key = (key, bucket) => {
15321
- const segments = key.split("/").filter((s, i) => !(i === 0 && s === "")).map((s) => decodeURIComponent(s));
15322
- if (segments[0] === bucket) segments.shift();
15323
- return segments.join("/");
15324
- };
15325
- var isHtmlKey = (key) => /\.html?$/i.test(key);
15326
- var deriveFilename = (key) => {
15327
- const base = key.split("/").pop() ?? key;
15328
- return base.split("_EXULU_").pop() ?? base;
15329
- };
15330
- var slugifyShareName = (input) => deriveFilename(input).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
15331
- var isExpired = (expiresAt, now) => {
15332
- if (!expiresAt) return false;
15333
- return new Date(expiresAt).getTime() <= now.getTime();
15334
- };
15335
- var validateCreateInput = (input, now) => {
15336
- if (!input.s3key) return { ok: false, message: "s3key is required." };
15337
- if (!input.name) return { ok: false, message: "name is required." };
15338
- const mode = input.auth_mode;
15339
- if (mode !== "public" && mode !== "password" && mode !== "regular") {
15340
- return { ok: false, message: "auth_mode must be public, password, or regular." };
15341
- }
15342
- if (mode === "password" && !input.password) {
15343
- return { ok: false, message: "A password is required for password mode." };
15344
- }
15345
- if (input.expires_at && Number.isNaN(new Date(input.expires_at).getTime())) {
15346
- return { ok: false, message: "expires_at is not a valid date." };
15347
- }
15348
- if (input.expires_at && isExpired(input.expires_at, now)) {
15349
- return { ok: false, message: "expires_at must be in the future." };
16061
+ // src/exulu/public-agents.ts
16062
+ var publicAgentView = (row, slug) => ({
16063
+ id: row.id,
16064
+ name: row.name ?? "",
16065
+ description: row.description ?? "",
16066
+ image: row.image ?? null,
16067
+ welcomemessage: row.welcomemessage ?? "",
16068
+ slug,
16069
+ guest_auth_mode: row.guest_auth_mode || "regular",
16070
+ guest_has_cover: !!row.guest_cover_image
16071
+ });
16072
+ var evaluateGuestChatAccess = async (agent, userId, guestPassword) => {
16073
+ if (agent.guest_access) {
16074
+ if (userId != null) return { allowed: true, via: "guest" };
16075
+ const mode = agent.guest_auth_mode || "regular";
16076
+ if (mode === "public") return { allowed: true, via: "guest" };
16077
+ if (mode === "password") {
16078
+ if (agent.guest_password_hash && guestPassword) {
16079
+ const ok = await verifySharePassword(guestPassword, agent.guest_password_hash);
16080
+ if (ok) return { allowed: true, via: "guest" };
16081
+ return { allowed: false, status: 401, message: "Incorrect password." };
16082
+ }
16083
+ return { allowed: false, status: 401, message: "Password required." };
16084
+ }
16085
+ return { allowed: false, status: 401, message: "Authentication required." };
16086
+ }
16087
+ if (userId == null && agent.rights_mode === "public") {
16088
+ return { allowed: true, via: "rbac-public" };
16089
+ }
16090
+ return { allowed: false, status: 401, message: "Authentication required." };
16091
+ };
16092
+
16093
+ // src/exulu/guest-rate-limit.ts
16094
+ var MINUTE_MS = 6e4;
16095
+ var HOUR_MS2 = 36e5;
16096
+ var perMinuteLimit = () => parseInt(process.env.EXULU_GUEST_RATE_PER_MINUTE || "10", 10);
16097
+ var perHourLimit = () => parseInt(process.env.EXULU_GUEST_RATE_PER_HOUR || "60", 10);
16098
+ var maxMessageChars = () => parseInt(process.env.EXULU_GUEST_MAX_MESSAGE_CHARS || "8000", 10);
16099
+ var maxTotalChars = () => parseInt(process.env.EXULU_GUEST_MAX_TOTAL_CHARS || "32000", 10);
16100
+ var MAX_PART_COUNT = 100;
16101
+ var windows = /* @__PURE__ */ new Map();
16102
+ var guestRateLimitExceeded = (ip, now = Date.now()) => {
16103
+ const state = windows.get(ip) ?? {
16104
+ minuteStart: now,
16105
+ minuteCount: 0,
16106
+ hourStart: now,
16107
+ hourCount: 0,
16108
+ lastSeen: now
16109
+ };
16110
+ if (now - state.minuteStart >= MINUTE_MS) {
16111
+ state.minuteStart = now;
16112
+ state.minuteCount = 0;
16113
+ }
16114
+ if (now - state.hourStart >= HOUR_MS2) {
16115
+ state.hourStart = now;
16116
+ state.hourCount = 0;
16117
+ }
16118
+ state.minuteCount += 1;
16119
+ state.hourCount += 1;
16120
+ state.lastSeen = now;
16121
+ windows.set(ip, state);
16122
+ if (windows.size > 1e4) {
16123
+ for (const [key, value] of windows) {
16124
+ if (now - value.lastSeen >= HOUR_MS2) windows.delete(key);
16125
+ }
16126
+ if (windows.size > 1e4) {
16127
+ const sorted = [...windows.entries()].sort(
16128
+ (a, b) => a[1].lastSeen - b[1].lastSeen
16129
+ );
16130
+ for (const [key] of sorted) {
16131
+ if (windows.size <= 1e4) break;
16132
+ windows.delete(key);
16133
+ }
16134
+ }
15350
16135
  }
15351
- return { ok: true };
16136
+ return state.minuteCount > perMinuteLimit() || state.hourCount > perHourLimit();
15352
16137
  };
15353
- var hashSharePassword = (password) => bcrypt3.hash(password, 10);
15354
- var verifySharePassword = (password, hash) => bcrypt3.compare(password, hash);
15355
- var contentHeadersFor = (key, contentType, filename) => {
15356
- if (isHtmlKey(key)) return { contentType: "text/html; charset=utf-8" };
15357
- return {
15358
- contentType: contentType || "application/octet-stream",
15359
- disposition: `attachment; filename="${filename.replace(/"/g, "")}"`
16138
+ var partsTooLong = (parts) => Array.isArray(parts) && parts.some(
16139
+ (p) => typeof p?.text === "string" && p.text.length > maxMessageChars()
16140
+ );
16141
+ var collectTextParts = (b) => {
16142
+ const parts = [];
16143
+ const addFromParts = (ps) => {
16144
+ if (!Array.isArray(ps)) return;
16145
+ for (const p of ps) {
16146
+ if (typeof p?.text === "string") parts.push(p.text);
16147
+ }
15360
16148
  };
16149
+ if (b.message) addFromParts(b.message.parts);
16150
+ if (Array.isArray(b.messages)) {
16151
+ for (const m of b.messages) addFromParts(m?.parts);
16152
+ }
16153
+ return parts;
16154
+ };
16155
+ var guestMessageTooLong = (body) => {
16156
+ const b = body;
16157
+ if (!b) return false;
16158
+ if (b.message && partsTooLong(b.message.parts)) return true;
16159
+ if (Array.isArray(b.messages)) {
16160
+ if (b.messages.some((m) => partsTooLong(m?.parts))) return true;
16161
+ }
16162
+ const allParts = collectTextParts(b);
16163
+ if (allParts.length > MAX_PART_COUNT) return true;
16164
+ const totalChars = allParts.reduce((sum, t) => sum + t.length, 0);
16165
+ if (totalChars > maxTotalChars()) return true;
16166
+ return false;
16167
+ };
16168
+ var extractClientIp = (req) => {
16169
+ const forwarded = req.headers["x-forwarded-for"];
16170
+ if (process.env.EXULU_TRUST_PROXY === "true") {
16171
+ if (typeof forwarded === "string" && forwarded.length > 0) {
16172
+ const parts = forwarded.split(",");
16173
+ return parts[parts.length - 1].trim();
16174
+ }
16175
+ }
16176
+ return req.ip || req.socket?.remoteAddress || "unknown";
15361
16177
  };
15362
- var getSharedArtifactByName = (db, name) => db("shared_artifacts").where({ name }).first();
15363
16178
 
15364
16179
  // src/skills/skill-access.ts
15365
16180
  async function resolveSkillByName(db, name) {
@@ -15717,6 +16532,9 @@ var createExpressRoutes = async (app, providers, tools, contexts, config, evals,
15717
16532
  })
15718
16533
  );
15719
16534
  app.get(OAUTH_CALLBACK_PATH, handleOauthCallback);
16535
+ app.post("/credentials/submit", handleCredentialSubmit);
16536
+ app.get("/credentials", handleCredentialList);
16537
+ app.delete("/credentials/:provider", handleCredentialDelete);
15720
16538
  app.post("/test", async (req, res) => {
15721
16539
  const { item_name, context_id } = req.body;
15722
16540
  let itemFilters = [];
@@ -16060,17 +16878,33 @@ Mood: friendly and intelligent.
16060
16878
  }
16061
16879
  console.log("[EXULU] agent.rights_mode", agent.rights_mode);
16062
16880
  const authenticationResult = await requestValidators.authenticate(req);
16063
- if (!authenticationResult.user?.id && agent.rights_mode !== "public") {
16064
- res.status(authenticationResult.code || 500).json({ detail: `${authenticationResult.message}` });
16881
+ const user = authenticationResult.user;
16882
+ if (!user?.id) {
16883
+ const ip = extractClientIp(req);
16884
+ if (guestRateLimitExceeded(ip)) {
16885
+ res.status(429).json({ detail: "Too many requests. Try again later." });
16886
+ return;
16887
+ }
16888
+ if (guestMessageTooLong(req.body)) {
16889
+ res.status(413).json({ detail: "Message too long." });
16890
+ return;
16891
+ }
16892
+ }
16893
+ const guestGate = await evaluateGuestChatAccess(
16894
+ agent,
16895
+ user?.id,
16896
+ req.headers["x-guest-password"]
16897
+ );
16898
+ if (!user?.id && !guestGate.allowed) {
16899
+ res.status(guestGate.status).json({ detail: guestGate.message });
16065
16900
  return;
16066
16901
  }
16067
- const user = authenticationResult.user;
16068
16902
  const scopeCheck = checkApiKeyScope(user, instance);
16069
16903
  if (!scopeCheck.allowed) {
16070
16904
  res.status(scopeCheck.code).json({ detail: scopeCheck.reason });
16071
16905
  return;
16072
16906
  }
16073
- const hasAccessToAgent = await checkRecordAccess(agent, "read", user);
16907
+ const hasAccessToAgent = guestGate.allowed || await checkRecordAccess(agent, "read", user);
16074
16908
  if (!hasAccessToAgent) {
16075
16909
  res.status(401).json({
16076
16910
  message: "You don't have access to this agent."
@@ -17428,6 +18262,32 @@ ${style.markdown}` : params.prompt;
17428
18262
  }
17429
18263
  res.status(200).json({ budget: await getUserBudgetView(authResult.user.id) });
17430
18264
  });
18265
+ app.get("/me/usage", async (req, res) => {
18266
+ const authResult = await requestValidators.authenticate(req);
18267
+ if (!authResult.user?.id) {
18268
+ res.status(authResult.code ?? 401).json({ detail: authResult.message });
18269
+ return;
18270
+ }
18271
+ const window = resolveUsageWindow(req.query.start_date, req.query.end_date);
18272
+ if (!window) {
18273
+ res.status(400).json({
18274
+ detail: "start_date and end_date must be YYYY-MM-DD or ISO datetimes, with start_date <= end_date."
18275
+ });
18276
+ return;
18277
+ }
18278
+ try {
18279
+ res.status(200).json({ usage: await getMyUsageView(authResult.user.id, window) });
18280
+ } catch (err) {
18281
+ if (err instanceof LiteLLMAdminError) {
18282
+ res.status(502).json({ detail: err.message });
18283
+ return;
18284
+ }
18285
+ console.error("[EXULU] /me/usage failed", err);
18286
+ res.status(500).json({
18287
+ detail: err instanceof Error ? err.message : "Usage query failed."
18288
+ });
18289
+ }
18290
+ });
17431
18291
  app.put(
17432
18292
  "/admin/budgets/:entityType/bulk",
17433
18293
  async (req, res) => {
@@ -17541,10 +18401,10 @@ ${style.markdown}` : params.prompt;
17541
18401
  }
17542
18402
  return { user: authResult.user };
17543
18403
  };
17544
- const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
18404
+ const DATE_ONLY_RE2 = /^\d{4}-\d{2}-\d{2}$/;
17545
18405
  const normaliseDateParam = (raw) => {
17546
18406
  if (typeof raw !== "string" || raw.length === 0) return null;
17547
- if (DATE_ONLY_RE.test(raw)) return raw;
18407
+ if (DATE_ONLY_RE2.test(raw)) return raw;
17548
18408
  const dt = new Date(raw);
17549
18409
  if (Number.isNaN(dt.getTime())) return null;
17550
18410
  return dt.toISOString().slice(0, 10);
@@ -19141,6 +20001,93 @@ ${style.markdown}` : params.prompt;
19141
20001
  if (headers.disposition) res.setHeader("Content-Disposition", headers.disposition);
19142
20002
  res.send(bytes);
19143
20003
  });
20004
+ const resolvePublicAgentSlug = async (agentModel) => {
20005
+ if (isLiteLLMEnabled()) return "/agents/litellm/run";
20006
+ if (!agentModel) return "";
20007
+ const { db } = await postgresClient();
20008
+ const modelRow = await db.from("models").where({ id: agentModel }).first();
20009
+ const provider = modelRow?.provider ? providers.find((a) => a.id === modelRow.provider) : void 0;
20010
+ return provider?.slug || "";
20011
+ };
20012
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20013
+ const getGuestAgentById = async (id) => {
20014
+ if (!UUID_RE.test(id)) return void 0;
20015
+ const { db } = await postgresClient();
20016
+ return db.from("agents").where({ id, guest_access: true, active: true }).first();
20017
+ };
20018
+ app.get("/public-agents", async (_req, res) => {
20019
+ const { db } = await postgresClient();
20020
+ const rows = await db.from("agents").where({ guest_access: true, active: true }).select(
20021
+ "id",
20022
+ "name",
20023
+ "description",
20024
+ "image",
20025
+ "welcomemessage",
20026
+ "model",
20027
+ "guest_auth_mode",
20028
+ "guest_cover_image"
20029
+ );
20030
+ const views = await Promise.all(
20031
+ rows.map(
20032
+ async (row) => publicAgentView(row, await resolvePublicAgentSlug(row.model))
20033
+ )
20034
+ );
20035
+ res.json(views);
20036
+ });
20037
+ app.get("/public-agents/:id/meta", async (req, res) => {
20038
+ const row = await getGuestAgentById(req.params.id ?? "");
20039
+ if (!row) {
20040
+ res.status(404).json({ detail: "Not found." });
20041
+ return;
20042
+ }
20043
+ res.json(publicAgentView(row, await resolvePublicAgentSlug(row.model)));
20044
+ });
20045
+ app.get("/public-agents/:id/cover", async (req, res) => {
20046
+ const row = await getGuestAgentById(req.params.id ?? "");
20047
+ if (!row?.guest_cover_image) {
20048
+ res.status(404).json({ detail: "Not found." });
20049
+ return;
20050
+ }
20051
+ let bytes;
20052
+ try {
20053
+ bytes = await getS3ObjectBytes(row.guest_cover_image, config);
20054
+ } catch (e) {
20055
+ if (e?.name === "NoSuchKey" || e?.name === "NotFound" || e?.$metadata?.httpStatusCode === 404) {
20056
+ res.status(404).json({ detail: "Cover not found." });
20057
+ return;
20058
+ }
20059
+ console.error("[EXULU] public-agent cover read failed", e);
20060
+ res.status(500).json({ detail: "Failed to read cover." });
20061
+ return;
20062
+ }
20063
+ const ext = row.guest_cover_image.split(".").pop()?.toLowerCase();
20064
+ const contentType = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg";
20065
+ res.setHeader("Content-Type", contentType);
20066
+ res.setHeader("X-Content-Type-Options", "nosniff");
20067
+ res.setHeader("Cache-Control", "public, max-age=300");
20068
+ res.send(bytes);
20069
+ });
20070
+ app.post(
20071
+ "/public-agents/:id/verify-password",
20072
+ async (req, res) => {
20073
+ const ip = extractClientIp(req);
20074
+ if (guestRateLimitExceeded(ip)) {
20075
+ res.status(429).json({ detail: "Too many requests. Try again later." });
20076
+ return;
20077
+ }
20078
+ const row = await getGuestAgentById(req.params.id ?? "");
20079
+ if (!row || row.guest_auth_mode !== "password") {
20080
+ res.status(404).json({ detail: "Not found." });
20081
+ return;
20082
+ }
20083
+ const password = typeof req.body?.password === "string" ? req.body.password : "";
20084
+ if (!row.guest_password_hash || !await verifySharePassword(password, row.guest_password_hash)) {
20085
+ res.status(401).json({ detail: "Incorrect password." });
20086
+ return;
20087
+ }
20088
+ res.status(204).end();
20089
+ }
20090
+ );
19144
20091
  app.use(express2.static("public"));
19145
20092
  await registerOpenAIGatewayRoutes(app, providers, tools, contexts, config);
19146
20093
  return app;
@@ -19213,7 +20160,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
19213
20160
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
19214
20161
  import "express";
19215
20162
  import "@opentelemetry/api";
19216
- import { z as z4 } from "zod";
20163
+ import { z as z5 } from "zod";
19217
20164
  var SESSION_ID_HEADER = "mcp-session-id";
19218
20165
  var ExuluMCP = class {
19219
20166
  server = {};
@@ -19285,7 +20232,7 @@ var ExuluMCP = class {
19285
20232
  title: tool2.name + " agent",
19286
20233
  description: tool2.description,
19287
20234
  inputSchema: {
19288
- inputs: tool2.inputSchema || z4.object({})
20235
+ inputs: tool2.inputSchema || z5.object({})
19289
20236
  }
19290
20237
  },
19291
20238
  async ({ inputs }, args) => {
@@ -19337,7 +20284,7 @@ var ExuluMCP = class {
19337
20284
  title: "Get List of Prompt Templates",
19338
20285
  description: "Retrieves a list of prompt templates available for this agent. Returns the name, description, and ID of each template.",
19339
20286
  inputSchema: {
19340
- inputs: z4.object({})
20287
+ inputs: z5.object({})
19341
20288
  }
19342
20289
  },
19343
20290
  async ({ inputs }, args) => {
@@ -19383,8 +20330,8 @@ var ExuluMCP = class {
19383
20330
  title: "Get Prompt Template Details",
19384
20331
  description: "Retrieves the full details of a specific prompt template by ID, including the actual template content with variables.",
19385
20332
  inputSchema: {
19386
- inputs: z4.object({
19387
- id: z4.string().describe("The ID of the prompt template to retrieve")
20333
+ inputs: z5.object({
20334
+ id: z5.string().describe("The ID of the prompt template to retrieve")
19388
20335
  })
19389
20336
  }
19390
20337
  },
@@ -20485,7 +21432,7 @@ var ExuluEval = class {
20485
21432
  };
20486
21433
 
20487
21434
  // src/templates/evals/index.ts
20488
- import { z as z5 } from "zod";
21435
+ import { z as z6 } from "zod";
20489
21436
  import { generateText as generateText8, Output as Output2 } from "ai";
20490
21437
  var llmAsJudgeEval = () => {
20491
21438
  if (process.env.REDIS_HOST?.length && process.env.REDIS_PORT?.length) {
@@ -20538,8 +21485,8 @@ var llmAsJudgeEval = () => {
20538
21485
  prompt,
20539
21486
  maxRetries: 2,
20540
21487
  output: Output2.object({
20541
- schema: z5.object({
20542
- score: z5.number().min(0).max(100).describe("The score between 0 and 100.")
21488
+ schema: z6.object({
21489
+ score: z6.number().min(0).max(100).describe("The score between 0 and 100.")
20543
21490
  })
20544
21491
  })
20545
21492
  });
@@ -20767,12 +21714,12 @@ Usage:
20767
21714
  - If no todos exist yet, an empty list will be returned`;
20768
21715
 
20769
21716
  // src/templates/tools/todo/todo.ts
20770
- import z6 from "zod";
20771
- var TodoSchema = z6.object({
20772
- content: z6.string().describe("Brief description of the task"),
20773
- status: z6.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
20774
- priority: z6.string().describe("Priority level of the task: high, medium, low"),
20775
- id: z6.string().describe("Unique identifier for the todo item")
21717
+ import z7 from "zod";
21718
+ var TodoSchema = z7.object({
21719
+ content: z7.string().describe("Brief description of the task"),
21720
+ status: z7.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
21721
+ priority: z7.string().describe("Priority level of the task: high, medium, low"),
21722
+ id: z7.string().describe("Unique identifier for the todo item")
20776
21723
  });
20777
21724
  var TodoWriteTool = new ExuluTool({
20778
21725
  id: "todo_write",
@@ -20788,8 +21735,8 @@ var TodoWriteTool = new ExuluTool({
20788
21735
  default: todowrite_default
20789
21736
  }
20790
21737
  ],
20791
- inputSchema: z6.object({
20792
- todos: z6.array(TodoSchema).describe("The updated todo list")
21738
+ inputSchema: z7.object({
21739
+ todos: z7.array(TodoSchema).describe("The updated todo list")
20793
21740
  }),
20794
21741
  execute: async (inputs) => {
20795
21742
  const { sessionID, todos, user } = inputs;
@@ -20824,7 +21771,7 @@ var TodoReadTool = new ExuluTool({
20824
21771
  id: "todo_read",
20825
21772
  name: "Todo Read",
20826
21773
  description: "Use this tool to read your todo list",
20827
- inputSchema: z6.object({}),
21774
+ inputSchema: z7.object({}),
20828
21775
  type: "function",
20829
21776
  category: "todo",
20830
21777
  config: [
@@ -20866,7 +21813,7 @@ var todoTools = [TodoWriteTool, TodoReadTool];
20866
21813
  var questionread_default = 'Use this tool to read questions you\'ve asked and check if they\'ve been answered by the user. This tool helps you track the status of questions and retrieve the user\'s selected answers.\n\n## When to Use This Tool\n\nUse this tool proactively in these situations:\n- After asking a question to check if the user has responded\n- To retrieve the user\'s answer before proceeding with implementation\n- To review all questions and answers in the current session\n- When you need to reference a previous answer\n\n## How It Works\n\n- This tool takes no parameters (leave the input blank or empty)\n- Returns an array of all questions in the session\n- Each question includes:\n - `id`: Unique identifier for the question\n - `question`: The question text\n - `answerOptions`: Array of answer options with their IDs and text\n - `status`: Either "pending" (not answered) or "answered"\n - `selectedAnswerId`: The ID of the chosen answer (only present if answered)\n\n## Usage Pattern\n\nTypically you\'ll:\n1. Use Question Ask to pose a question\n2. Wait for the user to respond\n3. Use Question Read to check the answer\n4. Find the selected answer by matching the `selectedAnswerId` with an option in `answerOptions`\n5. Proceed with implementation based on the user\'s choice\n\n## Example Response\n\n```json\n[\n {\n "id": "question123",\n "question": "Which authentication method would you like to implement?",\n "answerOptions": [\n { "id": "ans1", "text": "JWT tokens" },\n { "id": "ans2", "text": "OAuth 2.0" },\n { "id": "ans3", "text": "Session-based auth" },\n { "id": "ans4", "text": "None of the above..." }\n ],\n "status": "answered",\n "selectedAnswerId": "ans1"\n }\n]\n```\n\nIn this example, the user selected "JWT tokens" (id: ans1).\n\n## Important Notes\n\n- If no questions exist in the session, an empty array will be returned\n- Questions remain in the session even after being answered for reference\n- Use the `selectedAnswerId` to find which answer option the user chose by matching it against the `id` field in `answerOptions`\n';
20867
21814
 
20868
21815
  // src/templates/tools/question/question.ts
20869
- import z8 from "zod";
21816
+ import z9 from "zod";
20870
21817
 
20871
21818
  // src/templates/tools/question/questionask.txt
20872
21819
  var questionask_default = `Use this tool to ask the user a question with multiple choice answer options during your session. This helps you gather user input, clarify requirements, and make informed decisions based on user preferences.
@@ -20953,18 +21900,18 @@ After asking a question, use the Question Read tool to check if the user has ans
20953
21900
  `;
20954
21901
 
20955
21902
  // src/templates/tools/question/question-ask.ts
20956
- import z7 from "zod";
21903
+ import z8 from "zod";
20957
21904
  import { randomUUID as randomUUID7 } from "crypto";
20958
- var AnswerOptionSchema = z7.object({
20959
- id: z7.string().describe("Unique identifier for the answer option"),
20960
- text: z7.string().describe("The text of the answer option")
21905
+ var AnswerOptionSchema = z8.object({
21906
+ id: z8.string().describe("Unique identifier for the answer option"),
21907
+ text: z8.string().describe("The text of the answer option")
20961
21908
  });
20962
- var _QuestionSchema = z7.object({
20963
- id: z7.string().describe("Unique identifier for the question"),
20964
- question: z7.string().describe("The question to ask the user"),
20965
- answerOptions: z7.array(AnswerOptionSchema).describe("Array of possible answer options"),
20966
- selectedAnswerId: z7.string().optional().describe("The ID of the answer option selected by the user"),
20967
- status: z7.enum(["pending", "answered"]).describe("Status of the question: pending or answered")
21909
+ var _QuestionSchema = z8.object({
21910
+ id: z8.string().describe("Unique identifier for the question"),
21911
+ question: z8.string().describe("The question to ask the user"),
21912
+ answerOptions: z8.array(AnswerOptionSchema).describe("Array of possible answer options"),
21913
+ selectedAnswerId: z8.string().optional().describe("The ID of the answer option selected by the user"),
21914
+ status: z8.enum(["pending", "answered"]).describe("Status of the question: pending or answered")
20968
21915
  });
20969
21916
  var QuestionAskTool = new ExuluTool({
20970
21917
  id: "question_ask",
@@ -20981,9 +21928,9 @@ var QuestionAskTool = new ExuluTool({
20981
21928
  default: questionask_default
20982
21929
  }
20983
21930
  ],
20984
- inputSchema: z7.object({
20985
- question: z7.string().describe("The question to ask the user"),
20986
- answerOptions: z7.array(z7.string()).describe("Array of possible answer options (strings)")
21931
+ inputSchema: z8.object({
21932
+ question: z8.string().describe("The question to ask the user"),
21933
+ answerOptions: z8.array(z8.string()).describe("Array of possible answer options (strings)")
20987
21934
  }),
20988
21935
  execute: async (inputs) => {
20989
21936
  const { sessionID, question, answerOptions, user } = inputs;
@@ -21056,7 +22003,7 @@ var QuestionReadTool = new ExuluTool({
21056
22003
  name: "Question Read",
21057
22004
  needsApproval: false,
21058
22005
  description: "Use this tool to read questions and their answers",
21059
- inputSchema: z8.object({}),
22006
+ inputSchema: z9.object({}),
21060
22007
  type: "function",
21061
22008
  category: "question",
21062
22009
  config: [
@@ -21086,15 +22033,15 @@ async function getQuestions(sessionID) {
21086
22033
  var questionTools = [QuestionAskTool, QuestionReadTool];
21087
22034
 
21088
22035
  // src/templates/tools/perplexity.ts
21089
- import z9 from "zod";
22036
+ import z10 from "zod";
21090
22037
  import Perplexity from "@perplexity-ai/perplexity_ai";
21091
22038
  var internetSearchTool = new ExuluTool({
21092
22039
  id: "internet_search",
21093
22040
  name: "Internet Search",
21094
22041
  description: "Search the internet for information.",
21095
- inputSchema: z9.object({
21096
- query: z9.string().describe("The query to the tool."),
21097
- search_recency_filter: z9.enum(["day", "week", "month", "year"]).optional().describe("The recency filter for the search, can be day, week, month or year.")
22042
+ inputSchema: z10.object({
22043
+ query: z10.string().describe("The query to the tool."),
22044
+ search_recency_filter: z10.enum(["day", "week", "month", "year"]).optional().describe("The recency filter for the search, can be day, week, month or year.")
21098
22045
  }),
21099
22046
  category: "internet_search",
21100
22047
  type: "web_search",
@@ -21187,7 +22134,7 @@ var perplexityTools = [internetSearchTool];
21187
22134
 
21188
22135
  // src/templates/tools/email.ts
21189
22136
  import * as nodemailer from "nodemailer";
21190
- import { z as z10 } from "zod";
22137
+ import { z as z11 } from "zod";
21191
22138
  var transporter = null;
21192
22139
  function getTransporter(config) {
21193
22140
  if (!transporter) {
@@ -21211,11 +22158,11 @@ var emailTool = new ExuluTool({
21211
22158
  id: "email",
21212
22159
  name: "Email",
21213
22160
  description: "Send an email.",
21214
- inputSchema: z10.object({
21215
- recipient: z10.string().describe("The recipient of the email."),
21216
- subject: z10.string().describe("The subject of the email."),
21217
- html: z10.string().describe("The HTML body of the email."),
21218
- text: z10.string().describe("The text body of the email.")
22161
+ inputSchema: z11.object({
22162
+ recipient: z11.string().describe("The recipient of the email."),
22163
+ subject: z11.string().describe("The subject of the email."),
22164
+ html: z11.string().describe("The HTML body of the email."),
22165
+ text: z11.string().describe("The text body of the email.")
21219
22166
  }),
21220
22167
  type: "function",
21221
22168
  config: [{
@@ -21281,7 +22228,7 @@ var emailTool = new ExuluTool({
21281
22228
  });
21282
22229
 
21283
22230
  // src/templates/tools/image-generation.ts
21284
- import { z as z11 } from "zod";
22231
+ import { z as z12 } from "zod";
21285
22232
  var _cachedImageModels;
21286
22233
  var setCachedImageModels = (models2) => {
21287
22234
  _cachedImageModels = models2;
@@ -21342,8 +22289,8 @@ var createImageGenerationWidgetTool = (models2) => {
21342
22289
  needsApproval: false,
21343
22290
  type: "function",
21344
22291
  config: [],
21345
- inputSchema: z11.object({
21346
- prompt: z11.string().describe(
22292
+ inputSchema: z12.object({
22293
+ prompt: z12.string().describe(
21347
22294
  "Initial image prompt. The user can edit it before generating."
21348
22295
  )
21349
22296
  }),
@@ -21454,6 +22401,42 @@ var startTranscriptionPollingLoop = () => {
21454
22401
  process.on("SIGTERM", stop);
21455
22402
  };
21456
22403
 
22404
+ // src/exulu/recall/reconcile-loop.ts
22405
+ var RECONCILE_INTERVAL_MS = 6e4;
22406
+ var timer2 = null;
22407
+ var stopped2 = false;
22408
+ var tick2 = async () => {
22409
+ if (stopped2) return;
22410
+ try {
22411
+ const acted = await recallService.reconcileOnce();
22412
+ if (acted > 0) {
22413
+ console.log(`[EXULU-RECALL] reconcile tick recovered ${acted} job(s)`);
22414
+ }
22415
+ } catch (err) {
22416
+ console.error(
22417
+ `[EXULU-RECALL] reconcile tick failed: ${err.message}`
22418
+ );
22419
+ } finally {
22420
+ if (!stopped2) {
22421
+ timer2 = setTimeout(tick2, RECONCILE_INTERVAL_MS);
22422
+ }
22423
+ }
22424
+ };
22425
+ var startRecallReconcileLoop = () => {
22426
+ if (timer2) return;
22427
+ stopped2 = false;
22428
+ timer2 = setTimeout(tick2, RECONCILE_INTERVAL_MS);
22429
+ const stop = () => {
22430
+ stopped2 = true;
22431
+ if (timer2) {
22432
+ clearTimeout(timer2);
22433
+ timer2 = null;
22434
+ }
22435
+ };
22436
+ process.on("SIGINT", stop);
22437
+ process.on("SIGTERM", stop);
22438
+ };
22439
+
21457
22440
  // src/exulu/app/index.ts
21458
22441
  var isDev = process.env.NODE_ENV !== "production";
21459
22442
  var lineLimitFormat = winston2.format((info) => {
@@ -21688,6 +22671,9 @@ var ExuluApp = class {
21688
22671
  );
21689
22672
  }
21690
22673
  logRecallStartup();
22674
+ if (recallEnabled()) {
22675
+ startRecallReconcileLoop();
22676
+ }
21691
22677
  return this._expressApp;
21692
22678
  }
21693
22679
  };
@@ -22728,7 +23714,6 @@ var {
22728
23714
  promptFavoritesSchema: promptFavoritesSchema3,
22729
23715
  transcriptionJobsSchema: transcriptionJobsSchema3,
22730
23716
  imageGenerationsSchema: imageGenerationsSchema2,
22731
- oauthTokensSchema: oauthTokensSchema2,
22732
23717
  sharedArtifactsSchema: sharedArtifactsSchema2
22733
23718
  } = coreSchemas.get();
22734
23719
  var addMissingFields = async (knex, tableName, fields, skipFields = []) => {
@@ -22751,6 +23736,18 @@ var addMissingFields = async (knex, tableName, fields, skipFields = []) => {
22751
23736
  console.log(`[EXULU] Field '${sanitizedName}' already exists in ${tableName} table.`);
22752
23737
  }
22753
23738
  };
23739
+ var migrateUserCredentialsDataColumn = async (knex) => {
23740
+ const dataType = await knex.raw(
23741
+ `SELECT data_type FROM information_schema.columns
23742
+ WHERE table_name = 'user_credentials' AND column_name = 'data'`
23743
+ );
23744
+ if (dataType.rows?.[0]?.data_type === "jsonb") {
23745
+ console.log("[EXULU] Migrating user_credentials.data jsonb -> text.");
23746
+ await knex.raw(
23747
+ "ALTER TABLE user_credentials ALTER COLUMN data TYPE text USING data::text"
23748
+ );
23749
+ }
23750
+ };
22754
23751
  var up = async function(knex) {
22755
23752
  console.log("[EXULU] Database up.");
22756
23753
  const schemas = [
@@ -22772,7 +23769,6 @@ var up = async function(knex) {
22772
23769
  promptFavoritesSchema3(),
22773
23770
  transcriptionJobsSchema3(),
22774
23771
  imageGenerationsSchema2(),
22775
- oauthTokensSchema2(),
22776
23772
  sharedArtifactsSchema2(),
22777
23773
  rbacSchema3(),
22778
23774
  agentsSchema3(),
@@ -22806,10 +23802,9 @@ var up = async function(knex) {
22806
23802
  console.log(`[EXULU] Creating ${schema.name.plural} table.`, schema.fields);
22807
23803
  await createTable(schema);
22808
23804
  }
22809
- await knex.raw("DROP INDEX IF EXISTS oauth_tokens_tool_id_user_id_unique");
22810
- await knex.raw(
22811
- "CREATE UNIQUE INDEX IF NOT EXISTS oauth_tokens_provider_user_id_unique ON oauth_tokens (provider, user_id)"
22812
- );
23805
+ await knex.raw("DROP TABLE IF EXISTS oauth_tokens CASCADE;");
23806
+ await knex.raw(userCredentialsSchema());
23807
+ await migrateUserCredentialsDataColumn(knex);
22813
23808
  if (await knex.schema.hasColumn("job_results", "workflow") && await knex.schema.hasColumn("job_results", "trigger_metadata")) {
22814
23809
  await knex.raw(
22815
23810
  "CREATE INDEX IF NOT EXISTS job_results_email_dedup_idx ON job_results (workflow, (trigger_metadata->>'message_id'))"
@@ -23003,6 +23998,11 @@ var execute = async ({ contexts }) => {
23003
23998
  evals: "read"
23004
23999
  }).returning("id");
23005
24000
  }
24001
+ const existingExternalRole = await db.from("roles").where({ name: "external" }).first();
24002
+ if (!existingExternalRole) {
24003
+ console.log("[EXULU] Creating external role.");
24004
+ await db.from("roles").insert({ name: "external" }).returning("id");
24005
+ }
23006
24006
  const existingUser = await db.from("users").where({ email: "admin@exulu.com" }).first();
23007
24007
  if (!existingUser) {
23008
24008
  const password = await encryptString("admin");
@@ -23836,7 +24836,7 @@ var MarkdownChunker = class {
23836
24836
  import * as fs3 from "fs";
23837
24837
  import * as path from "path";
23838
24838
  import { generateText as generateText9, Output as Output3 } from "ai";
23839
- import { z as z12 } from "zod";
24839
+ import { z as z13 } from "zod";
23840
24840
  import pLimit from "p-limit";
23841
24841
  import { randomUUID as randomUUID8 } from "crypto";
23842
24842
  import * as mammoth from "mammoth";
@@ -24295,15 +25295,15 @@ If the page contains a flow-chart, schematic, technical drawing or control board
24295
25295
  const result = await generateText9({
24296
25296
  model,
24297
25297
  output: Output3.object({
24298
- schema: z12.object({
24299
- needs_correction: z12.boolean(),
24300
- corrected_text: z12.string().nullable(),
24301
- current_page_table: z12.object({
24302
- headers: z12.array(z12.string()),
24303
- is_continuation: z12.boolean()
25298
+ schema: z13.object({
25299
+ needs_correction: z13.boolean(),
25300
+ corrected_text: z13.string().nullable(),
25301
+ current_page_table: z13.object({
25302
+ headers: z13.array(z13.string()),
25303
+ is_continuation: z13.boolean()
24304
25304
  }).nullable(),
24305
- confidence: z12.enum(["high", "medium", "low"]),
24306
- reasoning: z12.string()
25305
+ confidence: z13.enum(["high", "medium", "low"]),
25306
+ reasoning: z13.string()
24307
25307
  })
24308
25308
  }),
24309
25309
  messages: [
@@ -24923,6 +25923,7 @@ var ExuluPython = {
24923
25923
  instructions: getPythonSetupInstructions
24924
25924
  };
24925
25925
  export {
25926
+ CredentialInvalidError,
24926
25927
  JOB_STATUS_ENUM as EXULU_JOB_STATUS_ENUM,
24927
25928
  STATISTICS_TYPE_ENUM as EXULU_STATISTICS_TYPE_ENUM,
24928
25929
  ExuluApp,