@exulu/backend 1.67.0 → 1.69.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.
@@ -119,9 +119,9 @@ async function postgresClient() {
119
119
  // Log pool events to help debug connection issues
120
120
  afterCreate: (conn, done) => {
121
121
  console.log("[EXULU] New database connection created");
122
- conn.query("SET statement_timeout = 1800000", (err) => {
122
+ conn.query("SET statement_timeout = 1800000; SET hnsw.ef_search = 20", (err) => {
123
123
  if (err) {
124
- console.error("[EXULU] Error setting statement_timeout:", err);
124
+ console.error("[EXULU] Error setting connection parameters:", err);
125
125
  }
126
126
  done(err, conn);
127
127
  });
@@ -362,9 +362,14 @@ var supervise = async (cfg) => {
362
362
  }
363
363
  };
364
364
  var _packageRoot;
365
+ var _clientMode = false;
365
366
  var setLiteLLMPackageRoot = (root) => {
366
367
  _packageRoot = root;
367
368
  };
369
+ var enableLiteLLMClientMode = () => {
370
+ if (internal.readyPromise) return;
371
+ _clientMode = true;
372
+ };
368
373
  var startLiteLLMSupervisor = async (options = {}) => {
369
374
  if (!isLiteLLMEnabled()) return;
370
375
  if (internal.readyPromise) {
@@ -413,6 +418,27 @@ var startLiteLLMSupervisor = async (options = {}) => {
413
418
  };
414
419
  var waitForLiteLLMReady = async () => {
415
420
  if (!isLiteLLMEnabled()) return;
421
+ if (_clientMode) {
422
+ if (internal.state === "ready") return;
423
+ const host = process.env.LITELLM_HOST ?? "127.0.0.1";
424
+ const port = process.env.LITELLM_PORT ?? "4000";
425
+ const url = `http://${host}:${port}/health/liveliness`;
426
+ let res;
427
+ try {
428
+ res = await fetch(url, { method: "GET" });
429
+ } catch (err) {
430
+ throw new Error(
431
+ `LiteLLM proxy not reachable at ${url} (is the Exulu server process running?): ${err.message}`
432
+ );
433
+ }
434
+ if (!res.ok) {
435
+ throw new Error(
436
+ `LiteLLM proxy health probe at ${url} returned ${res.status}.`
437
+ );
438
+ }
439
+ internal.state = "ready";
440
+ return;
441
+ }
416
442
  if (!internal.readyPromise) {
417
443
  return startLiteLLMSupervisor();
418
444
  }
@@ -499,6 +525,12 @@ function buildTags(input) {
499
525
  if (input.routine_name) {
500
526
  candidates.push("routine_name_" + input.routine_name);
501
527
  }
528
+ if (input.context_id) {
529
+ candidates.push("context_id_" + input.context_id);
530
+ }
531
+ if (input.context_name) {
532
+ candidates.push("context_name_" + input.context_name);
533
+ }
502
534
  console.log("[EXULU] Candidates", candidates);
503
535
  const out = [];
504
536
  for (const candidate of candidates) {
@@ -851,11 +883,20 @@ function durationToDays(duration) {
851
883
  }
852
884
  function windowStartYmd(reset_at, duration) {
853
885
  const days = durationToDays(duration);
886
+ const windowMs = days * DAY_MS;
887
+ const now = Date.now();
854
888
  const reset = reset_at ? new Date(reset_at) : null;
855
- const periodStart = reset && !Number.isNaN(reset.getTime()) ? new Date(reset.getTime() - days * DAY_MS) : null;
856
- const trailingStart = new Date(Date.now() - days * DAY_MS);
857
- const chosen = periodStart && periodStart > trailingStart ? periodStart : trailingStart;
858
- return ymd(chosen);
889
+ const resetMs = reset && !Number.isNaN(reset.getTime()) ? reset.getTime() : null;
890
+ const trailingStart = new Date(now - windowMs);
891
+ if (resetMs !== null) {
892
+ if (resetMs > now) {
893
+ const periodStart = new Date(resetMs - windowMs);
894
+ return ymd(periodStart > trailingStart ? periodStart : trailingStart);
895
+ } else {
896
+ return ymd(reset > trailingStart ? reset : trailingStart);
897
+ }
898
+ }
899
+ return ymd(trailingStart);
859
900
  }
860
901
  async function enrichSpendFromActivity(map) {
861
902
  const names = Object.keys(map);
@@ -866,7 +907,10 @@ async function enrichSpendFromActivity(map) {
866
907
  windows[name] = windowStartYmd(ti.budget_reset_at, ti.budget_duration);
867
908
  }
868
909
  try {
869
- const spendByTag = await getTagSpendByWindow(windows, ymd(/* @__PURE__ */ new Date()));
910
+ const spendByTag = await getTagSpendByWindow(
911
+ windows,
912
+ ymd(new Date(Date.now() + DAY_MS))
913
+ );
870
914
  for (const name of names) {
871
915
  const spend = spendByTag[name];
872
916
  if (typeof spend === "number" && Number.isFinite(spend)) {
@@ -1002,6 +1046,7 @@ async function getUserBudgetView(userId) {
1002
1046
  readCache.set(tag, { expiry: Date.now() + READ_TTL_MS, view: null });
1003
1047
  return null;
1004
1048
  }
1049
+ await provisionDefaultUserBudget(userId);
1005
1050
  const info = await tagInfo([tag]);
1006
1051
  const ti = info[tag];
1007
1052
  if (ti?.max_budget != null) {
@@ -1090,7 +1135,12 @@ var getLiteLLMProvider = ({
1090
1135
  // supports — including Vertex Gemini, which translates it into
1091
1136
  // responseSchema/responseMimeType — so enabling this matches the actual
1092
1137
  // proxy contract.
1093
- supportsStructuredOutputs: true
1138
+ supportsStructuredOutputs: true,
1139
+ // Request token usage on STREAMED responses. Without this the openai-compatible
1140
+ // provider omits `stream_options: { include_usage: true }`, so LiteLLM returns no
1141
+ // usage for streaming calls — which zeroes out the per-request token metrics and
1142
+ // the message-footer token count (both read the AI SDK `totalUsage`/finish-part usage).
1143
+ includeUsage: true
1094
1144
  });
1095
1145
  };
1096
1146
  async function resolveModel(input) {
@@ -1109,7 +1159,10 @@ async function resolveModel(input) {
1109
1159
  const litellm = getLiteLLMProvider({
1110
1160
  user,
1111
1161
  role: user?.role,
1112
- project,
1162
+ // Fall back to the caller's own project (set on API keys) when no
1163
+ // explicit request project is supplied, so API-triggered requests are
1164
+ // attributed to the key's project.
1165
+ project: project ?? user?.project,
1113
1166
  agent,
1114
1167
  team: user?.team,
1115
1168
  routine
@@ -1568,7 +1621,7 @@ var ExuluTool = class _ExuluTool {
1568
1621
  });
1569
1622
  providerapikey = resolved.apiKey;
1570
1623
  }
1571
- const { convertExuluToolsToAiSdkTools: convertExuluToolsToAiSdkTools2 } = await import("./convert-exulu-tools-to-ai-sdk-tools-4B7BQ5G2.js");
1624
+ const { convertExuluToolsToAiSdkTools: convertExuluToolsToAiSdkTools2 } = await import("./convert-exulu-tools-to-ai-sdk-tools-ET6UI7YG.js");
1572
1625
  const tools = await convertExuluToolsToAiSdkTools2(
1573
1626
  [this],
1574
1627
  [],
@@ -1577,7 +1630,6 @@ var ExuluTool = class _ExuluTool {
1577
1630
  agent.tools,
1578
1631
  providerapikey,
1579
1632
  void 0,
1580
- void 0,
1581
1633
  user,
1582
1634
  config,
1583
1635
  void 0,
@@ -1843,6 +1895,111 @@ var createSessionItemsRetrievalTool = async ({
1843
1895
  import { z as z9 } from "zod";
1844
1896
  import { createBashTool } from "bash-tool";
1845
1897
 
1898
+ // src/exulu/resolve-reranker.ts
1899
+ import "fs";
1900
+ var ResolveRerankerError = class extends Error {
1901
+ constructor(code, message) {
1902
+ super(message);
1903
+ this.code = code;
1904
+ this.name = "ResolveRerankerError";
1905
+ }
1906
+ };
1907
+ async function resolveReranker(input) {
1908
+ const { model, contextId, contextName, user, userId, roleId, project, agent, routine } = input;
1909
+ if (!isLiteLLMEnabled()) {
1910
+ throw new ResolveRerankerError(
1911
+ "LITELLM_NOT_CONFIGURED",
1912
+ "resolveReranker requires EXULU_USE_LITELLM=true \u2014 reranking is served exclusively through the LiteLLM proxy."
1913
+ );
1914
+ }
1915
+ try {
1916
+ await waitForLiteLLMReady();
1917
+ } catch (err) {
1918
+ throw new ResolveRerankerError(
1919
+ "LITELLM_NOT_READY",
1920
+ `LiteLLM is not ready: ${err.message}`
1921
+ );
1922
+ }
1923
+ const host = process.env.LITELLM_HOST ?? "127.0.0.1";
1924
+ const port = process.env.LITELLM_PORT ?? "4000";
1925
+ const masterKey = process.env.LITELLM_MASTER_KEY;
1926
+ if (!masterKey) {
1927
+ throw new ResolveRerankerError(
1928
+ "LITELLM_NOT_CONFIGURED",
1929
+ "LITELLM_MASTER_KEY is required when EXULU_USE_LITELLM=true"
1930
+ );
1931
+ }
1932
+ const resolvedUserId = user?.id ?? userId;
1933
+ if (resolvedUserId) await provisionDefaultUserBudget(resolvedUserId);
1934
+ const role = user?.role;
1935
+ const tags = buildTags({
1936
+ user_id: resolvedUserId,
1937
+ role_id: role?.id ?? roleId,
1938
+ project_id: (project ?? user?.project)?.id,
1939
+ agent_id: agent?.id,
1940
+ team_id: user?.team?.id,
1941
+ routine_id: routine?.id,
1942
+ context_id: contextId,
1943
+ user_name: !user ? void 0 : user.type === "api" ? user.firstname ?? user.email : user.email,
1944
+ role_name: role?.name,
1945
+ project_name: (project ?? user?.project)?.name,
1946
+ agent_name: agent?.name,
1947
+ team_name: user?.team?.name,
1948
+ routine_name: routine?.name,
1949
+ context_name: contextName
1950
+ });
1951
+ const endpoint = `http://${host}:${port}/v1/rerank`;
1952
+ const rerank = async (query, chunks, opts) => {
1953
+ try {
1954
+ if (chunks.length === 0) return [];
1955
+ const documents = chunks.map(
1956
+ (c) => `${c.item_name ?? ""}: ${c.chunk_content ?? ""}`
1957
+ );
1958
+ const res = await fetch(endpoint, {
1959
+ method: "POST",
1960
+ headers: {
1961
+ Authorization: `Bearer ${masterKey}`,
1962
+ "Content-Type": "application/json"
1963
+ },
1964
+ body: JSON.stringify({
1965
+ model,
1966
+ query,
1967
+ documents,
1968
+ // Default to scoring every document so callers can fully reorder; a
1969
+ // caller can pass a smaller topN as an optimization hint.
1970
+ top_n: opts?.topN ?? documents.length,
1971
+ // LiteLLM reads metadata.tags for tag-based spend tracking.
1972
+ metadata: { tags }
1973
+ })
1974
+ });
1975
+ if (!res.ok) {
1976
+ const text = await res.text().catch(() => "");
1977
+ throw new Error(
1978
+ `[EXULU] LiteLLM /v1/rerank returned ${res.status} for model "${model}": ${text}`
1979
+ );
1980
+ }
1981
+ const json = await res.json();
1982
+ if (!Array.isArray(json.results)) {
1983
+ throw new Error(
1984
+ `[EXULU] LiteLLM /v1/rerank returned no results for model "${model}".`
1985
+ );
1986
+ }
1987
+ const reranked = json.results.filter(
1988
+ (r) => typeof r.index === "number" && !!chunks[r.index]
1989
+ ).map((r) => ({
1990
+ ...chunks[r.index],
1991
+ rerank_score: r.relevance_score ?? 0
1992
+ }));
1993
+ reranked.sort((a, b) => b.rerank_score - a.rerank_score);
1994
+ return reranked;
1995
+ } catch (err) {
1996
+ console.error("[EXULU] Error reranking:", err);
1997
+ return [];
1998
+ }
1999
+ };
2000
+ return { model, rerank };
2001
+ }
2002
+
1846
2003
  // ee/entitlements.ts
1847
2004
  var ENTITLEMENTS = {
1848
2005
  "rbac": false,
@@ -1895,7 +2052,7 @@ async function withRetry(generateFn, maxRetries = 3) {
1895
2052
  if (attempt === maxRetries) {
1896
2053
  throw error;
1897
2054
  }
1898
- await new Promise((resolve3) => setTimeout(resolve3, Math.pow(2, attempt) * 1e3));
2055
+ await new Promise((resolve4) => setTimeout(resolve4, Math.pow(2, attempt) * 1e3));
1899
2056
  }
1900
2057
  }
1901
2058
  throw lastError;
@@ -2037,6 +2194,12 @@ var authentication = async ({
2037
2194
  user.team = team;
2038
2195
  }
2039
2196
  }
2197
+ if (user?.project) {
2198
+ const project = await db2.from("projects").select("*").where("id", user?.project).first();
2199
+ if (project) {
2200
+ user.project = project;
2201
+ }
2202
+ }
2040
2203
  if (!user) {
2041
2204
  return {
2042
2205
  error: true,
@@ -2102,6 +2265,18 @@ var authentication = async ({
2102
2265
  user.role = role;
2103
2266
  }
2104
2267
  }
2268
+ if (user?.team) {
2269
+ const team = await db2.from("teams").select("*").where("id", user?.team).first();
2270
+ if (team) {
2271
+ user.team = team;
2272
+ }
2273
+ }
2274
+ if (user?.project) {
2275
+ const project = await db2.from("projects").select("*").where("id", user?.project).first();
2276
+ if (project) {
2277
+ user.project = project;
2278
+ }
2279
+ }
2105
2280
  return {
2106
2281
  error: false,
2107
2282
  code: 200,
@@ -2270,7 +2445,7 @@ var uploadFile = async (file, fileName, config, options = {}, user, customBucket
2270
2445
  if (error.name === "SignatureDoesNotMatch" || error.name === "InvalidAccessKeyId" || error.name === "AccessDenied") {
2271
2446
  if (attempt < maxRetries) {
2272
2447
  const backoffMs = Math.pow(2, attempt) * 1e3;
2273
- await new Promise((resolve3) => setTimeout(resolve3, backoffMs));
2448
+ await new Promise((resolve4) => setTimeout(resolve4, backoffMs));
2274
2449
  s3Client = void 0;
2275
2450
  getS3Client(config);
2276
2451
  continue;
@@ -2991,9 +3166,871 @@ var ExuluStorage = class {
2991
3166
  // todo add upload and delete methods
2992
3167
  };
2993
3168
 
3169
+ // src/exulu/table-names.ts
3170
+ var getTableName = (id) => sanitizeName(id) + "_items";
3171
+ var getChunksTableName = (id) => sanitizeName(id) + "_chunks";
3172
+
2994
3173
  // src/exulu/context.ts
2995
3174
  import pgvector2 from "pgvector/knex";
2996
3175
 
3176
+ // ee/tokenizer.ts
3177
+ import { Tiktoken } from "tiktoken/lite";
3178
+ import { load } from "tiktoken/load";
3179
+ import registry2 from "tiktoken/registry.json" with { type: "json" };
3180
+ import models from "tiktoken/model_to_encoding.json" with { type: "json" };
3181
+ var ExuluTokenizer = class {
3182
+ constructor() {
3183
+ }
3184
+ encoder = null;
3185
+ async create(modelName) {
3186
+ if (this.encoder) {
3187
+ return this.encoder;
3188
+ }
3189
+ const time = performance.now();
3190
+ console.log("[EXULU] Loading tokenizer.", modelName);
3191
+ const model = await load(registry2[models[modelName]]);
3192
+ console.log("[EXULU] Loaded tokenizer.", modelName, performance.now() - time);
3193
+ const encoder = new Tiktoken(model.bpe_ranks, model.special_tokens, model.pat_str);
3194
+ console.log("[EXULU] Set encoder.");
3195
+ this.encoder = encoder;
3196
+ return encoder;
3197
+ }
3198
+ async decode(tokens) {
3199
+ if (!this.encoder) {
3200
+ throw new Error("Tokenizer not initialized");
3201
+ }
3202
+ const text = this.encoder.decode(tokens);
3203
+ return new TextDecoder().decode(text);
3204
+ }
3205
+ async decodeBatch(tokenSequences) {
3206
+ if (!this.encoder) {
3207
+ throw new Error("Tokenizer not initialized");
3208
+ }
3209
+ const promises = tokenSequences.map((tokens) => this.decode(tokens));
3210
+ return await Promise.all(promises);
3211
+ }
3212
+ encode(text) {
3213
+ if (!this.encoder) {
3214
+ throw new Error("Tokenizer not initialized");
3215
+ }
3216
+ const time = performance.now();
3217
+ console.log("[EXULU] Encoding text length: " + (text?.length || 0));
3218
+ const tokens = this.encoder.encode(text);
3219
+ console.log("[EXULU] Finished encoding text.", performance.now() - time);
3220
+ return tokens;
3221
+ }
3222
+ async countTokensBatch(texts) {
3223
+ if (!this.encoder) {
3224
+ throw new Error("Tokenizer not initialized");
3225
+ }
3226
+ const promises = texts.map((text) => Promise.resolve(this.countTokens(text)));
3227
+ return await Promise.all(promises);
3228
+ }
3229
+ countTokens(text) {
3230
+ if (!this.encoder) {
3231
+ throw new Error("Tokenizer not initialized");
3232
+ }
3233
+ const tokens = this.encoder.encode(text);
3234
+ const count = tokens.length;
3235
+ console.log("[EXULU] Token count.", count);
3236
+ return count;
3237
+ }
3238
+ async free() {
3239
+ console.log("[EXULU] Freeing tokenizer.");
3240
+ if (this.encoder) {
3241
+ this.encoder.free();
3242
+ }
3243
+ }
3244
+ };
3245
+
3246
+ // src/chunking/types/base.ts
3247
+ var Chunk = class _Chunk {
3248
+ /** The text of the chunk. */
3249
+ text;
3250
+ /** The starting index of the chunk in the original text. */
3251
+ startIndex;
3252
+ /** The ending index of the chunk in the original text. */
3253
+ endIndex;
3254
+ /** The number of tokens in the chunk. */
3255
+ tokenCount;
3256
+ /** Optional embedding for the chunk. */
3257
+ embedding;
3258
+ /**
3259
+ * Constructs a new Chunk object.
3260
+ *
3261
+ * @param {ChunkData} data - The data to construct the Chunk from.
3262
+ */
3263
+ constructor(data) {
3264
+ this.text = data.text;
3265
+ this.startIndex = data.startIndex;
3266
+ this.endIndex = data.endIndex;
3267
+ this.tokenCount = data.tokenCount;
3268
+ this.embedding = data.embedding;
3269
+ if (this.startIndex > this.endIndex) {
3270
+ throw new Error("Start index must be less than or equal to end index.");
3271
+ }
3272
+ if (this.tokenCount < 0) {
3273
+ throw new Error("Token count must be a non-negative integer.");
3274
+ }
3275
+ }
3276
+ /** Return a string representation of the Chunk.
3277
+ *
3278
+ * @returns {string} The text of the chunk.
3279
+ */
3280
+ toString() {
3281
+ return this.text;
3282
+ }
3283
+ /** Return a detailed string representation of the Chunk.
3284
+ *
3285
+ * @returns {string} The detailed string representation of the Chunk.
3286
+ */
3287
+ toRepresentation() {
3288
+ let repr = `Chunk(text='${this.text}', tokenCount=${this.tokenCount}, startIndex=${this.startIndex}, endIndex=${this.endIndex}`;
3289
+ repr += ")";
3290
+ return repr;
3291
+ }
3292
+ /** Return a slice of the chunk's text.
3293
+ *
3294
+ * @param {number} [start] - The starting index of the slice.
3295
+ * @param {number} [end] - The ending index of the slice.
3296
+ * @returns {string} The slice of the chunk's text.
3297
+ */
3298
+ slice(start, end) {
3299
+ return this.text.slice(start, end);
3300
+ }
3301
+ /** Return the Chunk as a dictionary-like object.
3302
+ *
3303
+ * @returns {ChunkData} The dictionary-like object.
3304
+ */
3305
+ toDict() {
3306
+ return {
3307
+ text: this.text,
3308
+ startIndex: this.startIndex,
3309
+ endIndex: this.endIndex,
3310
+ tokenCount: this.tokenCount,
3311
+ embedding: this.embedding
3312
+ };
3313
+ }
3314
+ /** Create a Chunk object from a dictionary-like object.
3315
+ *
3316
+ * @param {ChunkData} data - The dictionary-like object.
3317
+ * @returns {Chunk} The Chunk object.
3318
+ */
3319
+ static fromDict(data) {
3320
+ return new _Chunk({
3321
+ text: data.text,
3322
+ startIndex: data.startIndex,
3323
+ endIndex: data.endIndex,
3324
+ tokenCount: data.tokenCount,
3325
+ embedding: data.embedding
3326
+ });
3327
+ }
3328
+ /** Return a deep copy of the chunk.
3329
+ *
3330
+ * @returns {Chunk} The deep copy of the chunk.
3331
+ */
3332
+ copy() {
3333
+ return _Chunk.fromDict(this.toDict());
3334
+ }
3335
+ };
3336
+
3337
+ // src/chunking/types/sentence.ts
3338
+ var Sentence = class _Sentence {
3339
+ /** The text of the sentence */
3340
+ text;
3341
+ /** The starting index of the sentence in the original text */
3342
+ startIndex;
3343
+ /** The ending index of the sentence in the original text */
3344
+ endIndex;
3345
+ /** The number of tokens in the sentence */
3346
+ tokenCount;
3347
+ constructor(data) {
3348
+ this.text = data.text;
3349
+ this.startIndex = data.startIndex;
3350
+ this.endIndex = data.endIndex;
3351
+ this.tokenCount = data.tokenCount;
3352
+ }
3353
+ /** Return a string representation of the Sentence */
3354
+ toString() {
3355
+ return `Sentence(text=${this.text}, startIndex=${this.startIndex}, endIndex=${this.endIndex}, tokenCount=${this.tokenCount})`;
3356
+ }
3357
+ /** Return the Sentence as a dictionary-like object */
3358
+ toDict() {
3359
+ return {
3360
+ text: this.text,
3361
+ startIndex: this.startIndex,
3362
+ endIndex: this.endIndex,
3363
+ tokenCount: this.tokenCount
3364
+ };
3365
+ }
3366
+ /** Create a Sentence object from a dictionary-like object */
3367
+ static fromDict(data) {
3368
+ return new _Sentence(data);
3369
+ }
3370
+ };
3371
+ var SentenceChunk = class _SentenceChunk extends Chunk {
3372
+ /** List of sentences in the chunk */
3373
+ sentences;
3374
+ constructor(data) {
3375
+ super(data);
3376
+ this.sentences = data.sentences;
3377
+ this.embedding = data.embedding ?? void 0;
3378
+ }
3379
+ /**
3380
+ * Returns a detailed string representation of the SentenceChunk, including its text, start and end indices, token count, and a list of all contained sentences with their metadata.
3381
+ *
3382
+ * This method overrides the base {@link Chunk} toString method to provide a more informative output, which is especially useful for debugging and logging. Each sentence in the chunk is represented using its own toString method, and all sentences are included in the output.
3383
+ *
3384
+ * @returns {string} A string describing the SentenceChunk and all its sentences, e.g.,
3385
+ * SentenceChunk(text=..., startIndex=..., endIndex=..., tokenCount=..., sentences=[Sentence(...), ...])
3386
+ */
3387
+ toString() {
3388
+ const sentencesStr = this.sentences.map((s) => s.toString()).join(", ");
3389
+ return `SentenceChunk(text=${this.text}, startIndex=${this.startIndex}, endIndex=${this.endIndex}, tokenCount=${this.tokenCount}, sentences=[${sentencesStr}])`;
3390
+ }
3391
+ /**
3392
+ * Returns the SentenceChunk as a dictionary-like object.
3393
+ *
3394
+ * This method extends the base {@link Chunk} toDict method to include the sentences in the chunk.
3395
+ *
3396
+ * @returns {SentenceChunkData} A dictionary-like object containing the chunk's text, start and end indices, token count, and an array of sentence data.
3397
+ /** Return the SentenceChunk as a dictionary-like object */
3398
+ toDict() {
3399
+ const baseDict = super.toDict();
3400
+ return {
3401
+ ...baseDict,
3402
+ sentences: this.sentences.map((sentence) => sentence.toDict())
3403
+ };
3404
+ }
3405
+ /**
3406
+ * Creates a SentenceChunk object from a dictionary-like object.
3407
+ *
3408
+ * This method extends the base {@link Chunk} fromDict method to include the sentences in the chunk.
3409
+ *
3410
+ * @param {SentenceChunkData} data - A dictionary-like object containing the chunk's text, start and end indices, token count, and an array of sentence data.
3411
+ * @returns {SentenceChunk} A new SentenceChunk object created from the provided dictionary-like object.
3412
+ */
3413
+ static fromDict(data) {
3414
+ const sentences = data.sentences.map((sentence) => Sentence.fromDict(sentence));
3415
+ return new _SentenceChunk({
3416
+ text: data.text,
3417
+ startIndex: data.startIndex,
3418
+ endIndex: data.endIndex,
3419
+ tokenCount: data.tokenCount,
3420
+ sentences,
3421
+ embedding: data.embedding ?? void 0
3422
+ });
3423
+ }
3424
+ };
3425
+
3426
+ // src/chunking/base.ts
3427
+ var BaseChunker = class {
3428
+ tokenizer;
3429
+ _useConcurrency = true;
3430
+ // Determines if batch processing uses Promise.all
3431
+ constructor(tokenizer) {
3432
+ this.tokenizer = tokenizer;
3433
+ }
3434
+ /**
3435
+ * Returns a string representation of the chunker instance.
3436
+ *
3437
+ * @returns {string} The class name and constructor signature.
3438
+ */
3439
+ toString() {
3440
+ return `${this.constructor.name}()`;
3441
+ }
3442
+ async call(textOrTexts, showProgress = false) {
3443
+ if (typeof textOrTexts === "string") {
3444
+ return this.chunk(textOrTexts);
3445
+ } else if (Array.isArray(textOrTexts)) {
3446
+ return this.chunkBatch(textOrTexts, showProgress);
3447
+ } else {
3448
+ throw new Error("Input must be a string or an array of strings.");
3449
+ }
3450
+ }
3451
+ /**
3452
+ * Process a batch of texts sequentially (one after another).
3453
+ *
3454
+ * @protected
3455
+ * @param {string[]} texts - The texts to chunk.
3456
+ * @param {boolean} [showProgress=false] - Whether to display progress in the console.
3457
+ * @returns {Promise<Chunk[][]>} An array of chunked results for each input text.
3458
+ */
3459
+ async _sequential_batch_processing(texts, showProgress = false) {
3460
+ const results = [];
3461
+ const total = texts.length;
3462
+ for (let i = 0; i < total; i++) {
3463
+ if (showProgress && total > 1) {
3464
+ const progress = Math.round((i + 1) / total * 100);
3465
+ process.stdout.write(`Sequential processing: Document ${i + 1}/${total} (${progress}%)\r`);
3466
+ }
3467
+ results.push(await this.chunk(texts[i]));
3468
+ }
3469
+ if (showProgress && total > 1) {
3470
+ process.stdout.write("\n");
3471
+ }
3472
+ return results;
3473
+ }
3474
+ /**
3475
+ * Process a batch of texts concurrently using Promise.all.
3476
+ *
3477
+ * @protected
3478
+ * @param {string[]} texts - The texts to chunk.
3479
+ * @param {boolean} [showProgress=false] - Whether to display progress in the console.
3480
+ * @returns {Promise<Chunk[][]>} An array of chunked results for each input text.
3481
+ */
3482
+ async _concurrent_batch_processing(texts, showProgress = false) {
3483
+ const total = texts.length;
3484
+ let completedCount = 0;
3485
+ const updateProgress = () => {
3486
+ if (showProgress && total > 1) {
3487
+ completedCount++;
3488
+ const progress = Math.round(completedCount / total * 100);
3489
+ process.stdout.write(
3490
+ `Concurrent processing: Document ${completedCount}/${total} (${progress}%)\r`
3491
+ );
3492
+ }
3493
+ };
3494
+ const chunkPromises = texts.map(
3495
+ (text) => this.chunk(text).then((result) => {
3496
+ updateProgress();
3497
+ return result;
3498
+ })
3499
+ );
3500
+ const results = await Promise.all(chunkPromises);
3501
+ if (showProgress && total > 1 && completedCount > 0) {
3502
+ process.stdout.write("\n");
3503
+ }
3504
+ return results;
3505
+ }
3506
+ /**
3507
+ * Chunk a batch of texts, using either concurrent or sequential processing.
3508
+ *
3509
+ * If only one text is provided, processes it directly without batch overhead.
3510
+ *
3511
+ * @param {string[]} texts - The texts to chunk.
3512
+ * @param {boolean} [showProgress=true] - Whether to display progress in the console.
3513
+ * @returns {Promise<Chunk[][]>} An array of chunked results for each input text.
3514
+ */
3515
+ async chunkBatch(texts, showProgress = true) {
3516
+ if (texts.length === 0) {
3517
+ return [];
3518
+ }
3519
+ if (texts.length === 1) {
3520
+ return [await this.chunk(texts[0])];
3521
+ }
3522
+ if (this._useConcurrency) {
3523
+ return this._concurrent_batch_processing(texts, showProgress);
3524
+ } else {
3525
+ return this._sequential_batch_processing(texts, showProgress);
3526
+ }
3527
+ }
3528
+ };
3529
+
3530
+ // src/chunking/sentence.ts
3531
+ var SentenceChunker = class _SentenceChunker extends BaseChunker {
3532
+ chunkSize;
3533
+ chunkOverlap;
3534
+ minSentencesPerChunk;
3535
+ minCharactersPerSentence;
3536
+ approximate;
3537
+ delim;
3538
+ includeDelim;
3539
+ sep;
3540
+ /**
3541
+ * Private constructor. Use `SentenceChunker.create()` to instantiate.
3542
+ *
3543
+ * @param {Tokenizer} tokenizer - The tokenizer to use for token counting.
3544
+ * @param {number} chunkSize - Maximum number of tokens per chunk.
3545
+ * @param {number} chunkOverlap - Number of tokens to overlap between consecutive chunks.
3546
+ * @param {number} minSentencesPerChunk - Minimum number of sentences per chunk.
3547
+ * @param {number} minCharactersPerSentence - Minimum number of characters for a valid sentence.
3548
+ * @param {boolean} approximate - Whether to use approximate token counting.
3549
+ * @param {string[]} delim - List of sentence delimiters to use for splitting.
3550
+ * @param {('prev' | 'next' | null)} includeDelim - Whether to include the delimiter with the previous sentence ('prev'), next sentence ('next'), or exclude it (null).
3551
+ */
3552
+ constructor(tokenizer, chunkSize, chunkOverlap, minSentencesPerChunk, minCharactersPerSentence, approximate, delim, includeDelim) {
3553
+ super(tokenizer);
3554
+ if (chunkSize <= 0) {
3555
+ throw new Error("chunkSize must be greater than 0");
3556
+ }
3557
+ if (chunkOverlap < 0) {
3558
+ throw new Error("chunkOverlap must be non-negative");
3559
+ }
3560
+ if (chunkOverlap >= chunkSize) {
3561
+ throw new Error("chunkOverlap must be less than chunkSize");
3562
+ }
3563
+ if (minSentencesPerChunk <= 0) {
3564
+ throw new Error("minSentencesPerChunk must be greater than 0");
3565
+ }
3566
+ if (minCharactersPerSentence <= 0) {
3567
+ throw new Error("minCharactersPerSentence must be greater than 0");
3568
+ }
3569
+ if (!delim) {
3570
+ throw new Error("delim must be a list of strings or a string");
3571
+ }
3572
+ if (includeDelim !== "prev" && includeDelim !== "next" && includeDelim !== null) {
3573
+ throw new Error("includeDelim must be 'prev', 'next' or null");
3574
+ }
3575
+ if (approximate) {
3576
+ console.warn(
3577
+ "Approximate has been deprecated and will be removed from next version onwards!"
3578
+ );
3579
+ }
3580
+ this.chunkSize = chunkSize;
3581
+ this.chunkOverlap = chunkOverlap;
3582
+ this.minSentencesPerChunk = minSentencesPerChunk;
3583
+ this.minCharactersPerSentence = minCharactersPerSentence;
3584
+ this.approximate = approximate;
3585
+ this.delim = delim;
3586
+ this.includeDelim = includeDelim;
3587
+ this.sep = "\u2704";
3588
+ }
3589
+ /**
3590
+ * Creates and initializes a SentenceChunker instance that is directly callable.
3591
+ *
3592
+ * This method is a static factory function that returns a Promise resolving to a CallableSentenceChunker instance.
3593
+ * The returned instance is a callable function that can be used to chunk text strings or arrays of text strings.
3594
+ *
3595
+ * @param {SentenceChunkerOptions} [options] - Options for configuring the SentenceChunker.
3596
+ * @returns {Promise<CallableSentenceChunker>} A promise that resolves to a callable SentenceChunker instance.
3597
+ *
3598
+ * @example
3599
+ * const chunker = await SentenceChunker.create();
3600
+ * const chunks = await chunker("This is a sample text.");
3601
+ * const batchChunks = await chunker(["Text 1", "Text 2"]);
3602
+ *
3603
+ * @see SentenceChunkerOptions
3604
+ */
3605
+ static async create(options = {}) {
3606
+ const {
3607
+ tokenizer = "gpt-3.5-turbo",
3608
+ chunkSize = 512,
3609
+ chunkOverlap = 0,
3610
+ minSentencesPerChunk = 1,
3611
+ minCharactersPerSentence = 12,
3612
+ approximate = false,
3613
+ delim = [". ", "! ", "? ", "\n"],
3614
+ includeDelim = "prev"
3615
+ } = options;
3616
+ const tokenizerInstance = new ExuluTokenizer();
3617
+ await tokenizerInstance.create(tokenizer);
3618
+ const plainInstance = new _SentenceChunker(
3619
+ tokenizerInstance,
3620
+ chunkSize,
3621
+ chunkOverlap,
3622
+ minSentencesPerChunk,
3623
+ minCharactersPerSentence,
3624
+ approximate,
3625
+ delim,
3626
+ includeDelim
3627
+ );
3628
+ const callableFn = function(textOrTexts, showProgress) {
3629
+ if (typeof textOrTexts === "string") {
3630
+ return plainInstance.call(textOrTexts, showProgress);
3631
+ } else {
3632
+ return plainInstance.call(textOrTexts, showProgress);
3633
+ }
3634
+ };
3635
+ Object.setPrototypeOf(callableFn, _SentenceChunker.prototype);
3636
+ Object.assign(callableFn, plainInstance);
3637
+ return callableFn;
3638
+ }
3639
+ // NOTE: The replace + split method is not the best/most efficient way in general to be doing this. It works well in python because python implements .replace and .split in C while the re library is much slower in python.
3640
+ // NOTE: The new split -> join -> split is so weird, but it works. I don't quite like it however.
3641
+ // TODO: Implement a more efficient method for splitting text into sentences.
3642
+ /**
3643
+ * Fast sentence splitting while maintaining accuracy.
3644
+ *
3645
+ * @param {string} text - The text to split into sentences.
3646
+ * @returns {string[]} An array of sentences.
3647
+ */
3648
+ _splitText(text) {
3649
+ let t = text;
3650
+ for (const c of this.delim) {
3651
+ if (this.includeDelim === "prev") {
3652
+ t = t.split(c).join(c + this.sep);
3653
+ } else if (this.includeDelim === "next") {
3654
+ t = t.split(c).join(this.sep + c);
3655
+ } else {
3656
+ t = t.split(c).join(this.sep);
3657
+ }
3658
+ }
3659
+ const splits = t.split(this.sep);
3660
+ const sentences = [];
3661
+ let current = "";
3662
+ for (const s of splits) {
3663
+ if (!current) {
3664
+ current = s;
3665
+ } else {
3666
+ if (current.length >= this.minCharactersPerSentence) {
3667
+ sentences.push(current);
3668
+ current = s;
3669
+ } else {
3670
+ current += s;
3671
+ }
3672
+ }
3673
+ }
3674
+ if (current) {
3675
+ sentences.push(current);
3676
+ }
3677
+ return sentences;
3678
+ }
3679
+ /**
3680
+ * Split text into sentences and calculate token counts for each sentence.
3681
+ *
3682
+ * @param {string} text - The text to split into sentences.
3683
+ * @returns {Promise<Sentence[]>} An array of Sentence objects.
3684
+ */
3685
+ async _prepareSentences(text) {
3686
+ const sentenceTexts = this._splitText(text);
3687
+ if (!sentenceTexts.length) {
3688
+ return [];
3689
+ }
3690
+ const positions = [];
3691
+ let currentPos = 0;
3692
+ for (const sent of sentenceTexts) {
3693
+ positions.push(currentPos);
3694
+ currentPos += sent.length;
3695
+ }
3696
+ const tokenCounts = await this.tokenizer.countTokensBatch(sentenceTexts);
3697
+ return sentenceTexts.map(
3698
+ (sent, i) => new Sentence({
3699
+ text: sent,
3700
+ startIndex: positions[i],
3701
+ endIndex: positions[i] + sent.length,
3702
+ tokenCount: tokenCounts[i]
3703
+ })
3704
+ );
3705
+ }
3706
+ /**
3707
+ * Create a chunk from a list of sentences.
3708
+ *
3709
+ * @param {Sentence[]} sentences - The sentences to create a chunk from.
3710
+ * @returns {Promise<SentenceChunk>} A promise that resolves to a SentenceChunk object.
3711
+ */
3712
+ async _createChunk(sentences) {
3713
+ const chunkText = sentences.map((sentence) => sentence.text).join("");
3714
+ const tokenCount = this.tokenizer.countTokens(chunkText);
3715
+ return new SentenceChunk({
3716
+ text: chunkText,
3717
+ startIndex: sentences[0].startIndex,
3718
+ endIndex: sentences[sentences.length - 1].endIndex,
3719
+ tokenCount,
3720
+ sentences
3721
+ });
3722
+ }
3723
+ /**
3724
+ * Split text into overlapping chunks based on sentences while respecting token limits.
3725
+ *
3726
+ * @param {string} text - The text to split into chunks.
3727
+ * @returns {Promise<SentenceChunk[]>} A promise that resolves to an array of SentenceChunk objects.
3728
+ */
3729
+ async chunk(text) {
3730
+ if (!text.trim()) {
3731
+ return [];
3732
+ }
3733
+ const sentences = await this._prepareSentences(text);
3734
+ if (!sentences.length) {
3735
+ return [];
3736
+ }
3737
+ const tokenSums = [];
3738
+ let sum = 0;
3739
+ for (const sentence of sentences) {
3740
+ tokenSums.push(sum);
3741
+ sum += sentence.tokenCount;
3742
+ }
3743
+ tokenSums.push(sum);
3744
+ const chunks = [];
3745
+ let pos = 0;
3746
+ while (pos < sentences.length) {
3747
+ const targetTokens = tokenSums[pos] + this.chunkSize;
3748
+ let splitIdx = this._bisectLeft(tokenSums, targetTokens, pos) - 1;
3749
+ splitIdx = Math.min(splitIdx, sentences.length);
3750
+ splitIdx = Math.max(splitIdx, pos + 1);
3751
+ if (splitIdx - pos < this.minSentencesPerChunk) {
3752
+ if (pos + this.minSentencesPerChunk <= sentences.length) {
3753
+ splitIdx = pos + this.minSentencesPerChunk;
3754
+ } else {
3755
+ console.warn(
3756
+ `Minimum sentences per chunk as ${this.minSentencesPerChunk} could not be met for all chunks. Last chunk of the text will have only ${sentences.length - pos} sentences. Consider increasing the chunk_size or decreasing the min_sentences_per_chunk.`
3757
+ );
3758
+ splitIdx = sentences.length;
3759
+ }
3760
+ }
3761
+ const chunkSentences = sentences.slice(pos, splitIdx);
3762
+ chunks.push(await this._createChunk(chunkSentences));
3763
+ if (this.chunkOverlap > 0 && splitIdx < sentences.length) {
3764
+ let overlapTokens = 0;
3765
+ let overlapIdx = splitIdx - 1;
3766
+ while (overlapIdx > pos && overlapTokens < this.chunkOverlap) {
3767
+ const sent = sentences[overlapIdx];
3768
+ const nextTokens = overlapTokens + sent.tokenCount + 1;
3769
+ if (nextTokens > this.chunkOverlap) {
3770
+ break;
3771
+ }
3772
+ overlapTokens = nextTokens;
3773
+ overlapIdx--;
3774
+ }
3775
+ pos = overlapIdx + 1;
3776
+ } else {
3777
+ pos = splitIdx;
3778
+ }
3779
+ }
3780
+ await this.tokenizer.free();
3781
+ return chunks;
3782
+ }
3783
+ /**
3784
+ * Binary search to find the leftmost position where value should be inserted to maintain order.
3785
+ *
3786
+ * @param {number[]} arr - The array to search.
3787
+ * @param {number} value - The value to search for.
3788
+ * @param {number} [lo] - The starting index of the search.
3789
+ * @returns {number} The index of the leftmost position where value should be inserted.
3790
+ */
3791
+ _bisectLeft(arr, value, lo = 0) {
3792
+ let hi = arr.length;
3793
+ while (lo < hi) {
3794
+ const mid = lo + hi >>> 1;
3795
+ if (arr[mid] < value) {
3796
+ lo = mid + 1;
3797
+ } else {
3798
+ hi = mid;
3799
+ }
3800
+ }
3801
+ return lo;
3802
+ }
3803
+ /**
3804
+ * Return a string representation of the SentenceChunker.
3805
+ *
3806
+ * @returns {string} A string representation of the SentenceChunker.
3807
+ */
3808
+ toString() {
3809
+ return `SentenceChunker(tokenizer=${JSON.stringify(this.tokenizer)}, chunkSize=${this.chunkSize}, chunkOverlap=${this.chunkOverlap}, minSentencesPerChunk=${this.minSentencesPerChunk}, minCharactersPerSentence=${this.minCharactersPerSentence}, approximate=${this.approximate}, delim=${JSON.stringify(this.delim)}, includeDelim=${this.includeDelim})`;
3810
+ }
3811
+ };
3812
+
3813
+ // src/exulu/chunker.ts
3814
+ var defaultChunker = async (item, maxChunkSize) => {
3815
+ const body = typeof item.content === "string" && item.content || typeof item.description === "string" && item.description || "";
3816
+ const name = typeof item.name === "string" ? item.name : "";
3817
+ const text = [name, body].filter(Boolean).join("\n\n").trim();
3818
+ if (!text) {
3819
+ return { item, chunks: [] };
3820
+ }
3821
+ const chunker = await SentenceChunker.create({ chunkSize: maxChunkSize });
3822
+ const sentenceChunks = await chunker(text);
3823
+ const chunks = sentenceChunks.map((c, index) => ({ content: c.text.trim(), index })).filter((c) => c.content.length > 0).map((c, index) => ({ content: c.content, index }));
3824
+ return { item, chunks };
3825
+ };
3826
+
3827
+ // src/exulu/litellm/parse-embedding-models.ts
3828
+ import { existsSync as existsSync2, readFileSync } from "fs";
3829
+ import { resolve as resolve2 } from "path";
3830
+ var DEFAULT_MAX_CHUNK_SIZE = 1024;
3831
+ var DEFAULT_MAX_BATCH_SIZE = 100;
3832
+ var stripComment = (line) => {
3833
+ const idx = line.indexOf("#");
3834
+ return idx >= 0 ? line.slice(0, idx) : line;
3835
+ };
3836
+ var parseInt10 = (raw) => {
3837
+ const n = Number(raw.trim());
3838
+ return Number.isInteger(n) ? n : void 0;
3839
+ };
3840
+ var resolveLiteLLMConfigPath = () => process.env.LITELLM_CONFIG_PATH ?? resolve2(process.cwd(), "./config.litellm.yaml");
3841
+ var parseEmbeddingModels = (configPath) => {
3842
+ if (!existsSync2(configPath)) return [];
3843
+ const text = readFileSync(configPath, "utf8");
3844
+ const lines = text.split("\n");
3845
+ const entries = [];
3846
+ let current;
3847
+ for (const rawLine of lines) {
3848
+ const noComment = stripComment(rawLine);
3849
+ if (!noComment.trim()) continue;
3850
+ const indent = (rawLine.match(/^\s*/)?.[0] ?? "").length;
3851
+ const modelNameMatch = noComment.match(
3852
+ /^\s*-\s*model_name\s*:\s*["']?([^"'\s#]+)["']?\s*$/
3853
+ );
3854
+ if (modelNameMatch) {
3855
+ if (current) entries.push(current);
3856
+ current = { model_name: modelNameMatch[1], indent };
3857
+ continue;
3858
+ }
3859
+ if (!current) continue;
3860
+ if (indent <= current.indent && !/^\s*-\s/.test(rawLine)) {
3861
+ entries.push(current);
3862
+ current = void 0;
3863
+ continue;
3864
+ }
3865
+ const kvMatch = noComment.match(/^\s*(\w+)\s*:\s*(.+?)\s*$/);
3866
+ if (!kvMatch) continue;
3867
+ const key = kvMatch[1] ?? "";
3868
+ const rawValue = kvMatch[2] ?? "";
3869
+ switch (key) {
3870
+ case "dimensionality": {
3871
+ current.dimensionality = parseInt10(rawValue);
3872
+ break;
3873
+ }
3874
+ case "max_chunk_size": {
3875
+ current.max_chunk_size = parseInt10(rawValue);
3876
+ break;
3877
+ }
3878
+ case "max_batch_size": {
3879
+ current.max_batch_size = parseInt10(rawValue);
3880
+ break;
3881
+ }
3882
+ }
3883
+ }
3884
+ if (current) entries.push(current);
3885
+ return entries.filter((e) => typeof e.dimensionality === "number" && e.dimensionality > 0).map((e) => ({
3886
+ model_name: e.model_name,
3887
+ dimensionality: e.dimensionality,
3888
+ maxChunkSize: typeof e.max_chunk_size === "number" && e.max_chunk_size > 0 ? e.max_chunk_size : DEFAULT_MAX_CHUNK_SIZE,
3889
+ maxBatchSize: typeof e.max_batch_size === "number" && e.max_batch_size > 0 ? e.max_batch_size : DEFAULT_MAX_BATCH_SIZE
3890
+ }));
3891
+ };
3892
+ var getEmbeddingModelInfo = (modelName, configPath = resolveLiteLLMConfigPath()) => {
3893
+ const models2 = parseEmbeddingModels(configPath);
3894
+ const found = models2.find((m) => m.model_name === modelName);
3895
+ if (!found) {
3896
+ throw new Error(
3897
+ `[EXULU] Embedding model "${modelName}" was not found in ${configPath}, or its entry is missing a numeric \`model_info.dimensionality\`. Add it, e.g.:
3898
+ - model_name: ${modelName}
3899
+ litellm_params:
3900
+ model: <provider>/${modelName}
3901
+ model_info:
3902
+ dimensionality: 1024 # required (matches the model's output size)
3903
+ max_chunk_size: 1024 # optional
3904
+ max_batch_size: 100 # optional`
3905
+ );
3906
+ }
3907
+ return found;
3908
+ };
3909
+
3910
+ // src/exulu/resolve-embedder.ts
3911
+ var ResolveEmbedderError = class extends Error {
3912
+ constructor(code, message) {
3913
+ super(message);
3914
+ this.code = code;
3915
+ this.name = "ResolveEmbedderError";
3916
+ }
3917
+ };
3918
+ async function resolveEmbedder(input) {
3919
+ const { model, contextId, contextName, user, userId, roleId, project, agent, routine } = input;
3920
+ if (!isLiteLLMEnabled()) {
3921
+ throw new ResolveEmbedderError(
3922
+ "LITELLM_NOT_CONFIGURED",
3923
+ "resolveEmbedder requires EXULU_USE_LITELLM=true \u2014 embeddings are served exclusively through the LiteLLM proxy."
3924
+ );
3925
+ }
3926
+ try {
3927
+ await waitForLiteLLMReady();
3928
+ } catch (err) {
3929
+ throw new ResolveEmbedderError(
3930
+ "LITELLM_NOT_READY",
3931
+ `LiteLLM is not ready: ${err.message}`
3932
+ );
3933
+ }
3934
+ const host = process.env.LITELLM_HOST ?? "127.0.0.1";
3935
+ const port = process.env.LITELLM_PORT ?? "4000";
3936
+ const masterKey = process.env.LITELLM_MASTER_KEY;
3937
+ if (!masterKey) {
3938
+ throw new ResolveEmbedderError(
3939
+ "LITELLM_NOT_CONFIGURED",
3940
+ "LITELLM_MASTER_KEY is required when EXULU_USE_LITELLM=true"
3941
+ );
3942
+ }
3943
+ const resolvedUserId = user?.id ?? userId;
3944
+ if (resolvedUserId) await provisionDefaultUserBudget(resolvedUserId);
3945
+ const { dimensionality, maxChunkSize, maxBatchSize } = getEmbeddingModelInfo(model);
3946
+ const role = user?.role;
3947
+ const tags = buildTags({
3948
+ user_id: resolvedUserId,
3949
+ role_id: role?.id ?? roleId,
3950
+ project_id: (project ?? user?.project)?.id,
3951
+ agent_id: agent?.id,
3952
+ team_id: user?.team?.id,
3953
+ routine_id: routine?.id,
3954
+ context_id: contextId,
3955
+ user_name: !user ? void 0 : user.type === "api" ? user.firstname ?? user.email : user.email,
3956
+ role_name: role?.name,
3957
+ project_name: (project ?? user?.project)?.name,
3958
+ agent_name: agent?.name,
3959
+ team_name: user?.team?.name,
3960
+ routine_name: routine?.name,
3961
+ context_name: contextName
3962
+ });
3963
+ const endpoint = `http://${host}:${port}/v1/embeddings`;
3964
+ const embedBatch = async (batch) => {
3965
+ const res = await fetch(endpoint, {
3966
+ method: "POST",
3967
+ headers: {
3968
+ Authorization: `Bearer ${masterKey}`,
3969
+ "Content-Type": "application/json"
3970
+ },
3971
+ body: JSON.stringify({
3972
+ model,
3973
+ input: batch,
3974
+ encoding_format: "float",
3975
+ // Pin the output size to the configured column dimensions. LiteLLM's
3976
+ // `drop_params: true` silently drops this for providers that don't
3977
+ // support it; for those, a dimensionality mismatch surfaces as an
3978
+ // insert error (the correct fail-fast).
3979
+ dimensions: dimensionality,
3980
+ // LiteLLM reads metadata.tags for tag-based spend tracking — same
3981
+ // mechanism createTaggedFetch uses for chat completions.
3982
+ metadata: { tags }
3983
+ })
3984
+ });
3985
+ if (!res.ok) {
3986
+ const text = await res.text().catch(() => "");
3987
+ throw new Error(
3988
+ `[EXULU] LiteLLM /v1/embeddings returned ${res.status} for model "${model}": ${text}`
3989
+ );
3990
+ }
3991
+ const json = await res.json();
3992
+ const data = json.data ?? [];
3993
+ const ordered = [...data].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
3994
+ const vectors = ordered.map((d) => d.embedding ?? []);
3995
+ if (vectors.length !== batch.length) {
3996
+ throw new Error(
3997
+ `[EXULU] LiteLLM /v1/embeddings returned ${vectors.length} vectors for ${batch.length} inputs (model "${model}").`
3998
+ );
3999
+ }
4000
+ return vectors;
4001
+ };
4002
+ const embed = async (inputs) => {
4003
+ if (inputs.length === 0) return [];
4004
+ const out = [];
4005
+ for (let i = 0; i < inputs.length; i += maxBatchSize) {
4006
+ const batch = inputs.slice(i, i + maxBatchSize);
4007
+ const vectors = await embedBatch(batch);
4008
+ out.push(...vectors);
4009
+ }
4010
+ return out;
4011
+ };
4012
+ return {
4013
+ model,
4014
+ dimensions: dimensionality,
4015
+ maxChunkSize,
4016
+ maxBatchSize,
4017
+ embed
4018
+ };
4019
+ }
4020
+
4021
+ // types/enums/statistics.ts
4022
+ var STATISTICS_TYPE_ENUM = {
4023
+ CONTEXT_RETRIEVE: "CONTEXT_RETRIEVE",
4024
+ SOURCE_UPDATE: "SOURCE_UPDATE",
4025
+ EMBEDDER_UPSERT: "EMBEDDER_UPSERT",
4026
+ EMBEDDER_GENERATE: "EMBEDDER_GENERATE",
4027
+ EMBEDDER_DELETE: "EMBEDDER_DELETE",
4028
+ WORKFLOW_RUN: "WORKFLOW_RUN",
4029
+ CONTEXT_UPSERT: "CONTEXT_UPSERT",
4030
+ TOOL_CALL: "TOOL_CALL",
4031
+ AGENT_RUN: "AGENT_RUN"
4032
+ };
4033
+
2997
4034
  // src/utils/query-preprocessing.ts
2998
4035
  import { franc } from "franc";
2999
4036
  import natural from "natural";
@@ -3266,19 +4303,6 @@ var applyFilters = (query, filters, table, field_prefix) => {
3266
4303
  return query;
3267
4304
  };
3268
4305
 
3269
- // types/enums/statistics.ts
3270
- var STATISTICS_TYPE_ENUM = {
3271
- CONTEXT_RETRIEVE: "CONTEXT_RETRIEVE",
3272
- SOURCE_UPDATE: "SOURCE_UPDATE",
3273
- EMBEDDER_UPSERT: "EMBEDDER_UPSERT",
3274
- EMBEDDER_GENERATE: "EMBEDDER_GENERATE",
3275
- EMBEDDER_DELETE: "EMBEDDER_DELETE",
3276
- WORKFLOW_RUN: "WORKFLOW_RUN",
3277
- CONTEXT_UPSERT: "CONTEXT_UPSERT",
3278
- TOOL_CALL: "TOOL_CALL",
3279
- AGENT_RUN: "AGENT_RUN"
3280
- };
3281
-
3282
4306
  // types/models/vector-methods.ts
3283
4307
  var VectorMethodEnum = {
3284
4308
  "cosineDistance": "cosineDistance",
@@ -3939,6 +4963,11 @@ var agentsSchema = {
3939
4963
  {
3940
4964
  name: "animation_responding",
3941
4965
  type: "text"
4966
+ },
4967
+ {
4968
+ name: "sandbox_enabled",
4969
+ type: "boolean",
4970
+ default: false
3942
4971
  }
3943
4972
  ]
3944
4973
  };
@@ -4027,6 +5056,15 @@ var usersSchema = {
4027
5056
  name: "favourite_items",
4028
5057
  type: "json"
4029
5058
  },
5059
+ {
5060
+ // Knowledge: per-user "recently viewed" data items. Ordered JSON array
5061
+ // of global item ids ("<contextId>/<itemId>"), most-recent first, capped
5062
+ // client-side. Auto-added to existing DBs by the init-exulu-db column
5063
+ // sync; read via userById, written via usersUpdateOne (mirrors
5064
+ // favourite_items).
5065
+ name: "recently_viewed_items",
5066
+ type: "json"
5067
+ },
4030
5068
  {
4031
5069
  name: "firstname",
4032
5070
  type: "text"
@@ -4106,6 +5144,12 @@ var usersSchema = {
4106
5144
  {
4107
5145
  name: "team",
4108
5146
  type: "uuid"
5147
+ },
5148
+ {
5149
+ // Optional attribution target for API keys (type "api"): tags requests
5150
+ // triggered by the key with project_id_ for LiteLLM cost attribution.
5151
+ name: "project",
5152
+ type: "uuid"
4109
5153
  }
4110
5154
  ]
4111
5155
  };
@@ -4135,35 +5179,6 @@ var platformConfigurationsSchema = {
4135
5179
  }
4136
5180
  ]
4137
5181
  };
4138
- var embedderSettingsSchema = {
4139
- type: "embedder_settings",
4140
- name: {
4141
- plural: "embedder_settings",
4142
- singular: "embedder_setting"
4143
- },
4144
- RBAC: false,
4145
- fields: [
4146
- {
4147
- name: "context",
4148
- type: "text"
4149
- // id of the ExuluContext class
4150
- },
4151
- {
4152
- name: "embedder",
4153
- type: "text"
4154
- // id of the ExuluEmbedder class
4155
- },
4156
- {
4157
- name: "name",
4158
- type: "text"
4159
- },
4160
- {
4161
- name: "value",
4162
- type: "text"
4163
- // reference to an exulu variable
4164
- }
4165
- ]
4166
- };
4167
5182
  var entityTypeSettingsSchema = {
4168
5183
  type: "entity_type_settings",
4169
5184
  name: {
@@ -4191,6 +5206,13 @@ var entityTypeSettingsSchema = {
4191
5206
  name: "active",
4192
5207
  type: "boolean",
4193
5208
  default: true
5209
+ },
5210
+ {
5211
+ // "active" = a configured type (used in extraction); "suggested" = a type
5212
+ // the extractor proposed (active:false) awaiting promotion on the UI.
5213
+ name: "status",
5214
+ type: "text",
5215
+ default: "active"
4194
5216
  }
4195
5217
  ]
4196
5218
  };
@@ -4282,7 +5304,21 @@ var transcriptionJobsSchema = {
4282
5304
  { name: "target_rbac_users", type: "json" },
4283
5305
  { name: "target_rbac_roles", type: "json" },
4284
5306
  { name: "saved_item_id", type: "uuid", required: false },
4285
- { name: "error", type: "text" }
5307
+ { name: "error", type: "text" },
5308
+ // Recall.ai meeting-bot fields. source discriminates the pipeline: whisper
5309
+ // rows are driven by the polling loop, recall rows by webhooks.
5310
+ // Design doc: docs/superpowers/specs/2026-06-19-recall-meeting-recording-design.md
5311
+ { name: "source", type: "text", default: "whisper", index: true },
5312
+ { name: "meeting_url", type: "text" },
5313
+ { name: "recall_bot_id", type: "text", index: true },
5314
+ { name: "recall_recording_id", type: "text", index: true },
5315
+ { name: "recall_transcript_id", type: "text", index: true },
5316
+ { name: "bot_status", type: "text" },
5317
+ { name: "join_at", type: "date" },
5318
+ // Selected per-meeting post-processing: [{ prompt_id, agent_id }].
5319
+ { name: "post_processing_prompts", type: "json" },
5320
+ // Results: [{ prompt_id, agent_id, prompt_name, status, output, error, ran_at }].
5321
+ { name: "post_processing_outputs", type: "json" }
4286
5322
  ]
4287
5323
  };
4288
5324
  var imageGenerationsSchema = {
@@ -4337,6 +5373,26 @@ var oauthTokensSchema = {
4337
5373
  // null = non-expiring
4338
5374
  ]
4339
5375
  };
5376
+ var sharedArtifactsSchema = {
5377
+ type: "shared_artifacts",
5378
+ name: {
5379
+ plural: "shared_artifacts",
5380
+ singular: "shared_artifact"
5381
+ },
5382
+ // RBAC drives the "regular" auth_mode: rights_mode + the rbac table scope
5383
+ // who may view. public/password modes ignore rights_mode.
5384
+ RBAC: true,
5385
+ fields: [
5386
+ { name: "name", type: "text", index: true, unique: true, required: true },
5387
+ { name: "s3key", type: "text", required: true },
5388
+ { name: "auth_mode", type: "text", default: "regular" },
5389
+ { name: "password_hash", type: "text", required: false },
5390
+ // bcrypt; password mode only
5391
+ { name: "expires_at", type: "date", required: false },
5392
+ // null = no expiry
5393
+ { name: "content_type", type: "text", required: false }
5394
+ ]
5395
+ };
4340
5396
  var contextPresetsSchema = {
4341
5397
  type: "context_presets",
4342
5398
  name: {
@@ -4425,11 +5481,11 @@ var coreSchemas = {
4425
5481
  variablesSchema: () => addCoreFields(variablesSchema),
4426
5482
  platformConfigurationsSchema: () => addCoreFields(platformConfigurationsSchema),
4427
5483
  promptLibrarySchema: () => addCoreFields(promptLibrarySchema),
4428
- embedderSettingsSchema: () => addCoreFields(embedderSettingsSchema),
4429
5484
  entityTypeSettingsSchema: () => addCoreFields(entityTypeSettingsSchema),
4430
5485
  promptFavoritesSchema: () => addCoreFields(promptFavoritesSchema),
4431
5486
  contextPresetsSchema: () => addCoreFields(contextPresetsSchema),
4432
5487
  oauthTokensSchema: () => addCoreFields(oauthTokensSchema),
5488
+ sharedArtifactsSchema: () => addCoreFields(sharedArtifactsSchema),
4433
5489
  transcriptionJobsSchema: () => addCoreFields(transcriptionJobsSchema),
4434
5490
  imageGenerationsSchema: () => addCoreFields(imageGenerationsSchema)
4435
5491
  };
@@ -4560,11 +5616,89 @@ var entitiesEnabled = async (context) => {
4560
5616
  const types = await hydrateEntityTypes(context);
4561
5617
  return types.length > 0;
4562
5618
  };
5619
+ var upsertEntitySuggestions = async (context, suggestions) => {
5620
+ if (!suggestions.length) return;
5621
+ try {
5622
+ const { db: db2 } = await postgresClient();
5623
+ const existing = await db2.from("entity_type_settings").where({ context: context.id }).select("name");
5624
+ const existingNames = new Set(
5625
+ existing.map(
5626
+ (r) => String(r.name || "").toLowerCase().trim()
5627
+ )
5628
+ );
5629
+ const rows = suggestions.filter((s) => s.name && !existingNames.has(s.name.toLowerCase().trim())).map((s) => ({
5630
+ name: s.name,
5631
+ description: s.example ? `${s.description} (e.g. ${s.example})` : s.description,
5632
+ context: context.id,
5633
+ active: false,
5634
+ status: "suggested"
5635
+ }));
5636
+ if (rows.length) {
5637
+ await db2.from("entity_type_settings").insert(rows);
5638
+ }
5639
+ } catch (err) {
5640
+ console.warn(
5641
+ "[EXULU] Could not persist entity suggestions:",
5642
+ err.message
5643
+ );
5644
+ }
5645
+ };
5646
+ var entityModelKey = (contextId) => `entity_extraction_model:${contextId}`;
5647
+ var getEntityModelSetting = async (contextId) => {
5648
+ try {
5649
+ const { db: db2 } = await postgresClient();
5650
+ const row = await db2.from("platform_configurations").where({ config_key: entityModelKey(contextId) }).first();
5651
+ if (!row?.config_value) return null;
5652
+ const raw = row.config_value;
5653
+ let value = raw;
5654
+ if (typeof raw === "string") {
5655
+ try {
5656
+ value = JSON.parse(raw);
5657
+ } catch {
5658
+ value = raw;
5659
+ }
5660
+ }
5661
+ return typeof value === "string" && value.trim() ? value.trim() : null;
5662
+ } catch (err) {
5663
+ console.warn("[EXULU] Could not read entity model setting:", err.message);
5664
+ return null;
5665
+ }
5666
+ };
5667
+ var setEntityModelSetting = async (contextId, modelId) => {
5668
+ const { db: db2 } = await postgresClient();
5669
+ const key = entityModelKey(contextId);
5670
+ if (!modelId || !modelId.trim()) {
5671
+ await db2.from("platform_configurations").where({ config_key: key }).del();
5672
+ return;
5673
+ }
5674
+ const value = JSON.stringify(modelId.trim());
5675
+ await db2.from("platform_configurations").insert({
5676
+ config_key: key,
5677
+ config_value: value,
5678
+ description: `Entity extraction model for context ${contextId}`
5679
+ }).onConflict("config_key").merge({ config_value: value });
5680
+ };
5681
+ var resolveEntityModel = async (context) => {
5682
+ const databaseModel = await getEntityModelSetting(context.id);
5683
+ const codeModel = context.entities?.model ?? null;
5684
+ const envModel = process.env.EXULU_ENTITY_EXTRACTION_MODEL ?? null;
5685
+ if (databaseModel) {
5686
+ return { effectiveModel: databaseModel, source: "database", databaseModel, codeModel };
5687
+ }
5688
+ if (codeModel) {
5689
+ return { effectiveModel: codeModel, source: "code", databaseModel, codeModel };
5690
+ }
5691
+ if (envModel) {
5692
+ return { effectiveModel: envModel, source: "env", databaseModel, codeModel };
5693
+ }
5694
+ return { effectiveModel: null, source: null, databaseModel, codeModel };
5695
+ };
4563
5696
 
4564
5697
  // src/exulu/entities/extractor.ts
4565
5698
  import { generateText as generateText2, Output as Output2 } from "ai";
4566
5699
  import { z as z5 } from "zod";
4567
5700
  var CHUNK_BATCH_SIZE = 30;
5701
+ var MAX_SUGGESTIONS = 5;
4568
5702
  var mentionSchema = z5.object({
4569
5703
  entities: z5.array(
4570
5704
  z5.object({
@@ -4574,23 +5708,51 @@ var mentionSchema = z5.object({
4574
5708
  canonical: z5.string().describe("The canonical, language-normalized name used to merge variants."),
4575
5709
  confidence: z5.number().min(0).max(1).describe("Confidence 0..1 that this is a valid entity.")
4576
5710
  })
4577
- )
5711
+ ),
5712
+ suggestedTypes: z5.array(
5713
+ z5.object({
5714
+ name: z5.string().describe("A concise NEW entity type name, e.g. 'Error Code', 'Component'."),
5715
+ description: z5.string().describe("What this type captures, one sentence."),
5716
+ mentions: z5.array(
5717
+ z5.object({
5718
+ chunkIndex: z5.number().int().describe("Index of the chunk this mention was found in."),
5719
+ mention: z5.string().describe("The exact surface form as it appears in the text."),
5720
+ canonical: z5.string().describe("Canonical, language-normalized name."),
5721
+ confidence: z5.number().min(0).max(1).describe("Confidence 0..1.")
5722
+ })
5723
+ ).describe("Every mention of this new type found in the provided chunks.")
5724
+ })
5725
+ ).describe(
5726
+ "Entity TYPES that recur in the text but are NOT in the configured list and would be worth tracking. Empty array if none."
5727
+ ).optional()
4578
5728
  });
4579
5729
  var buildSystemPrompt = (types, canonicalLanguage) => {
4580
5730
  const typeList = types.map((t) => `- ${t.name}: ${t.description}`).join("\n");
5731
+ const typeNames = types.map((t) => `"${t.name}"`).join(", ");
4581
5732
  return [
4582
- "You are an entity extraction engine. Extract entities of ONLY the following types from the provided text chunks.",
5733
+ "You are an entity extraction engine. Extract entities of ONLY the types listed below from the provided text chunks.",
4583
5734
  "",
4584
- "Entity types:",
5735
+ 'Entity types (the "type" field of every entity you output MUST be exactly one of these names):',
4585
5736
  typeList,
4586
5737
  "",
5738
+ "For each entity, output an object with:",
5739
+ `- "type": EXACTLY one of these type names \u2014 ${typeNames}. NEVER put the description, a category value, or a generic label like "named entity"/"classification" in this field.`,
5740
+ '- "mention" and "canonical": the value (see how to choose them below).',
5741
+ '- "chunkIndex": the index of the chunk the entity was found in.',
5742
+ '- "confidence": your certainty the entity is valid (0..1).',
5743
+ "",
5744
+ "How to choose mention/canonical depends on each type's description:",
5745
+ `- If the type names concrete things mentioned in the text (a person, product, place, code, organization): output the exact surface form as it appears as "mention", and a "canonical" normalized to ${canonicalLanguage}. Output a SEPARATE entity object for every distinct value \u2014 a single chunk may contain many of these, including several of the SAME type (e.g. three different cities \u2192 three "City" entities) and several of different types. Never collapse them or limit yourself to one per chunk.`,
5746
+ `- If the type is a classification or property (the description defines a fixed set of categories, or asks you to judge a property of the content \u2014 e.g. "is this a fact or an instruction"): pick the single best-fitting category for the chunk and output that category as BOTH "mention" and "canonical", even if that exact word is not in the text. Output at most one entity of such a type per chunk.`,
5747
+ "",
4587
5748
  "Rules:",
4588
- `- For each entity, output a "mention" (the exact surface form as it appears, in its original language) and a "canonical" name normalized to ${canonicalLanguage}.`,
5749
+ "- A chunk can yield MULTIPLE entities \u2014 of the same named-entity type and of different types. Extract every distinct entity you find. The one-per-chunk limit applies ONLY to classification/property types.",
4589
5750
  `- The canonical name merges variants and translations: e.g. "M\xFCnchen" and "MUC" both canonicalize to the ${canonicalLanguage} form "Munich". "Acme Inc" and "ACME" both canonicalize to "Acme".`,
4590
5751
  "- DO NOT translate or alter identifiers, case numbers, SKUs, product codes, or proper product names \u2014 keep those verbatim as their own canonical.",
4591
- "- Only use the entity types listed above. Ignore anything that does not fit a listed type.",
4592
- "- Set chunkIndex to the index of the chunk the entity was found in.",
4593
- "- Return an empty array if no entities are present."
5752
+ "- Only put entities of the listed types in the `entities` array. Do NOT put anything else there.",
5753
+ "- Use an empty `entities` array when no listed type applies to the text.",
5754
+ "",
5755
+ `Separately, in "suggestedTypes", propose up to ${MAX_SUGGESTIONS} entity TYPES that recur in the text but are NOT in the list above and would be worth tracking (e.g. a kind of code, component, or product that keeps appearing). For each, give a concise name, a one-sentence description, and the full list of its "mentions" found in the chunks \u2014 extract those mentions exactly as you would for a configured named-entity type (each with its chunkIndex, the surface-form "mention", a normalized "canonical", and "confidence"). Leave the array empty if nothing stands out. Never put these in the "entities" array.`
4594
5756
  ].join("\n");
4595
5757
  };
4596
5758
  var buildUserPrompt = (chunks) => {
@@ -4602,13 +5764,17 @@ var extractEntitiesForItem = async ({
4602
5764
  chunks,
4603
5765
  types
4604
5766
  }) => {
4605
- if (!types.length || !chunks.length) return [];
4606
- const modelId = context.entities?.model || process.env.EXULU_ENTITY_EXTRACTION_MODEL;
5767
+ const empty = {
5768
+ mentions: [],
5769
+ suggestions: []
5770
+ };
5771
+ if (!types.length || !chunks.length) return empty;
5772
+ const { effectiveModel: modelId } = await resolveEntityModel(context);
4607
5773
  if (!modelId) {
4608
5774
  console.warn(
4609
- `[EXULU] Entity extraction skipped for context ${context.id}: no entities.model configured and EXULU_ENTITY_EXTRACTION_MODEL is unset.`
5775
+ `[EXULU] Entity extraction skipped for context ${context.id}: no model configured. Select one in the Entities tab, set context.entities.model in code, or set EXULU_ENTITY_EXTRACTION_MODEL.`
4610
5776
  );
4611
- return [];
5777
+ return empty;
4612
5778
  }
4613
5779
  const canonicalLanguage = context.entities?.canonicalLanguage || "english";
4614
5780
  const confidenceThreshold = context.entities?.confidenceThreshold ?? 0.5;
@@ -4625,15 +5791,15 @@ var extractEntitiesForItem = async ({
4625
5791
  `[EXULU] Entity extraction skipped for context ${context.id}: could not resolve model ${modelId}:`,
4626
5792
  err.message
4627
5793
  );
4628
- return [];
5794
+ return empty;
4629
5795
  }
4630
5796
  const system = buildSystemPrompt(types, canonicalLanguage);
4631
- const validTypeNames = new Set(types.map((t) => t.name.toLowerCase().trim()));
4632
5797
  const batches = [];
4633
5798
  for (let i = 0; i < chunks.length; i += CHUNK_BATCH_SIZE) {
4634
5799
  batches.push(chunks.slice(i, i + CHUNK_BATCH_SIZE));
4635
5800
  }
4636
5801
  const mentions = [];
5802
+ const suggestionsByName = /* @__PURE__ */ new Map();
4637
5803
  for (const batch of batches) {
4638
5804
  try {
4639
5805
  const { output } = await generateText2({
@@ -4647,16 +5813,47 @@ var extractEntitiesForItem = async ({
4647
5813
  for (const e of output.entities) {
4648
5814
  if (!e.mention || !e.canonical || !e.type) continue;
4649
5815
  if (e.confidence < confidenceThreshold) continue;
4650
- if (!validTypeNames.has(e.type.toLowerCase().trim())) continue;
4651
- const declared = types.find((t) => t.name.toLowerCase().trim() === e.type.toLowerCase().trim());
5816
+ const eType = e.type.toLowerCase().trim();
5817
+ const declared = types.find(
5818
+ (t) => t.name.toLowerCase().trim() === eType || (t.description || "").toLowerCase().trim() === eType
5819
+ );
5820
+ if (!declared) continue;
4652
5821
  mentions.push({
4653
5822
  chunkIndex: e.chunkIndex,
4654
- type: declared?.name || e.type,
5823
+ type: declared.name,
4655
5824
  mention: e.mention,
4656
5825
  canonical: e.canonical,
4657
5826
  confidence: e.confidence
4658
5827
  });
4659
5828
  }
5829
+ for (const s of output.suggestedTypes ?? []) {
5830
+ const name = (s?.name || "").trim();
5831
+ if (!name) continue;
5832
+ const key = name.toLowerCase();
5833
+ const isConfigured = types.some(
5834
+ (t) => t.name.toLowerCase().trim() === key || (t.description || "").toLowerCase().trim() === key
5835
+ );
5836
+ if (isConfigured) continue;
5837
+ const sMentions = (s.mentions ?? []).filter(
5838
+ (m) => m.mention && m.canonical && m.confidence >= confidenceThreshold
5839
+ );
5840
+ for (const m of sMentions) {
5841
+ mentions.push({
5842
+ chunkIndex: m.chunkIndex,
5843
+ type: name,
5844
+ mention: m.mention,
5845
+ canonical: m.canonical,
5846
+ confidence: m.confidence
5847
+ });
5848
+ }
5849
+ if (!suggestionsByName.has(key)) {
5850
+ suggestionsByName.set(key, {
5851
+ name,
5852
+ description: (s.description || "").trim(),
5853
+ example: sMentions[0]?.mention || void 0
5854
+ });
5855
+ }
5856
+ }
4660
5857
  } catch (err) {
4661
5858
  console.error(
4662
5859
  `[EXULU] Entity extraction batch failed for context ${context.id} (continuing):`,
@@ -4664,7 +5861,11 @@ var extractEntitiesForItem = async ({
4664
5861
  );
4665
5862
  }
4666
5863
  }
4667
- return mentions;
5864
+ const suggestions = [...suggestionsByName.values()];
5865
+ console.log(
5866
+ `[EXULU][entities] context ${context.id}: kept ${mentions.length} mention(s), ${suggestions.length} suggestion(s).`
5867
+ );
5868
+ return { mentions, suggestions };
4668
5869
  };
4669
5870
 
4670
5871
  // src/exulu/entities/normalize.ts
@@ -4746,6 +5947,27 @@ var getEntityIdsForItem = async (context, itemId) => {
4746
5947
  const rows = await db2(junctionTable).where({ item_id: itemId }).distinct("entity_id");
4747
5948
  return rows.map((r) => r.entity_id);
4748
5949
  };
5950
+ var getEntitiesForItem = async (context, itemId) => {
5951
+ if (!await chunkEntitiesTableExists(context)) return [];
5952
+ const { db: db2 } = await postgresClient();
5953
+ const junctionTable = getChunkEntitiesTableName(context.id);
5954
+ const entitiesTable = getEntitiesTableName(context.id);
5955
+ const rows = await db2(`${junctionTable} as j`).join(`${entitiesTable} as e`, "e.id", "j.entity_id").where("j.item_id", itemId).groupBy("e.id", "e.type", "e.display_name").select(
5956
+ "e.id as id",
5957
+ "e.type as type",
5958
+ "e.display_name as name",
5959
+ db2.raw("COUNT(*)::int as mentions")
5960
+ ).orderBy([
5961
+ { column: "e.type", order: "asc" },
5962
+ { column: "e.display_name", order: "asc" }
5963
+ ]);
5964
+ return rows.map((r) => ({
5965
+ id: r.id,
5966
+ type: r.type,
5967
+ name: r.name,
5968
+ mentions: Number(r.mentions) || 0
5969
+ }));
5970
+ };
4749
5971
  var ingestEntitiesForItem = async ({
4750
5972
  context,
4751
5973
  itemId,
@@ -4830,6 +6052,37 @@ var ingestEntitiesForItem = async ({
4830
6052
  });
4831
6053
  });
4832
6054
  };
6055
+ var detachEntitiesForItem = async (context, itemId) => {
6056
+ if (!await chunkEntitiesTableExists(context)) return 0;
6057
+ const { db: db2 } = await postgresClient();
6058
+ const entitiesTable = getEntitiesTableName(context.id);
6059
+ const junctionTable = getChunkEntitiesTableName(context.id);
6060
+ const itemsTable = getTableName(context.id);
6061
+ let detached = 0;
6062
+ await db2.transaction(async (trx) => {
6063
+ const affected = (await trx(junctionTable).where({ item_id: itemId }).distinct("entity_id")).map((r) => r.entity_id);
6064
+ detached = affected.length;
6065
+ if (!affected.length) return;
6066
+ await trx(junctionTable).where({ item_id: itemId }).delete();
6067
+ await trx(entitiesTable).whereIn("id", affected).update({ mention_count: 0, doc_count: 0 });
6068
+ const placeholders = affected.map(() => "?").join(",");
6069
+ await trx.raw(
6070
+ `UPDATE ${entitiesTable} e
6071
+ SET mention_count = sub.mc, doc_count = sub.dc
6072
+ FROM (
6073
+ SELECT entity_id, COUNT(*)::int AS mc, COUNT(DISTINCT item_id)::int AS dc
6074
+ FROM ${junctionTable}
6075
+ WHERE entity_id IN (${placeholders})
6076
+ GROUP BY entity_id
6077
+ ) sub
6078
+ WHERE e.id = sub.entity_id`,
6079
+ affected
6080
+ );
6081
+ await trx(entitiesTable).whereIn("id", affected).where({ mention_count: 0 }).delete();
6082
+ await trx(itemsTable).where({ id: itemId }).update({ entities_updated_at: (/* @__PURE__ */ new Date()).toISOString(), entity_types_signature: null });
6083
+ });
6084
+ return detached;
6085
+ };
4833
6086
  var resolveQueryEntities = async (context, mentions) => {
4834
6087
  if (!mentions.length) return [];
4835
6088
  const { db: db2 } = await postgresClient();
@@ -4961,22 +6214,31 @@ var extractAndIngestEntities = async ({
4961
6214
  }) => {
4962
6215
  try {
4963
6216
  const types = await hydrateEntityTypes(context);
4964
- if (!types.length || !context.embedder) return;
6217
+ if (!types.length || !context.embedder) return 0;
4965
6218
  await ensureEntityTables(context);
4966
6219
  const { db: db2 } = await postgresClient();
4967
6220
  const chunkRows = await db2(getChunksTableName(context.id)).where({ source: itemId }).select("chunk_index", "content").orderBy("chunk_index", "asc");
4968
6221
  const chunks = chunkRows.map((c) => ({ index: Number(c.chunk_index), content: c.content })).filter((c) => c.content);
4969
- const mentions = await extractEntitiesForItem({ context, chunks, types });
6222
+ const { mentions, suggestions } = await extractEntitiesForItem({
6223
+ context,
6224
+ chunks,
6225
+ types
6226
+ });
4970
6227
  const signature = computeTypesSignature(types);
4971
6228
  await ingestEntitiesForItem({ context, itemId, mentions, signature, previousEntityIds });
6229
+ if (suggestions.length) {
6230
+ await upsertEntitySuggestions(context, suggestions);
6231
+ }
4972
6232
  console.log(
4973
6233
  `[EXULU] Entity ingestion complete for item ${itemId} in context ${context.id}: ${mentions.length} mentions.`
4974
6234
  );
6235
+ return mentions.length;
4975
6236
  } catch (err) {
4976
6237
  console.error(
4977
6238
  `[EXULU] Entity ingestion failed for item ${itemId} in context ${context.id} (non-fatal):`,
4978
6239
  err.message
4979
6240
  );
6241
+ return 0;
4980
6242
  }
4981
6243
  };
4982
6244
 
@@ -4997,7 +6259,8 @@ var vectorSearch = async ({
4997
6259
  trigger,
4998
6260
  cutoffs,
4999
6261
  expand,
5000
- entityFilter
6262
+ entityFilter,
6263
+ queryEmbedding
5001
6264
  }) => {
5002
6265
  const table = convertContextToTableDefinition(context);
5003
6266
  console.log("[EXULU] Called vector search.", {
@@ -5079,29 +6342,53 @@ var vectorSearch = async ({
5079
6342
  let vector = [];
5080
6343
  let vectorStr = "";
5081
6344
  let vectorExpr = "";
6345
+ const _tBody = Date.now();
6346
+ let _preMs = 0, _statMs = 0, _resolveMs = 0, _embedMs = 0;
6347
+ let _embedSource = "none";
5082
6348
  if (query) {
6349
+ const _tp = Date.now();
5083
6350
  const { processed: stemmedQuery } = preprocessQuery(query, {
5084
6351
  enableStemming: true,
5085
6352
  detectLanguage: true
5086
6353
  });
6354
+ _preMs = Date.now() - _tp;
5087
6355
  console.log("[EXULU] Stemmed query:", stemmedQuery);
5088
6356
  if (stemmedQuery) {
5089
6357
  query = stemmedQuery;
5090
6358
  }
5091
- const result = await embedder.generateFromQuery(
5092
- context.id,
5093
- query,
5094
- {
6359
+ if (queryEmbedding && queryEmbedding.length) {
6360
+ vector = queryEmbedding;
6361
+ _embedSource = "reused";
6362
+ } else {
6363
+ const _ts = Date.now();
6364
+ await updateStatistic({
6365
+ name: "count",
5095
6366
  label: table.name.singular,
5096
- trigger
5097
- },
5098
- user?.id,
5099
- role
5100
- );
5101
- if (!result?.chunks?.[0]?.vector) {
5102
- throw new Error("No vector generated for query.");
6367
+ type: STATISTICS_TYPE_ENUM.EMBEDDER_GENERATE,
6368
+ trigger,
6369
+ count: 1,
6370
+ user: user?.id,
6371
+ role
6372
+ });
6373
+ _statMs = Date.now() - _ts;
6374
+ const _tr = Date.now();
6375
+ const resolved = await resolveEmbedder({
6376
+ model: embedder.model,
6377
+ contextId: context.id,
6378
+ contextName: context.name,
6379
+ user,
6380
+ roleId: role
6381
+ });
6382
+ _resolveMs = Date.now() - _tr;
6383
+ const _te = Date.now();
6384
+ const [queryVector] = await resolved.embed([query], { inputType: "query" });
6385
+ _embedMs = Date.now() - _te;
6386
+ if (!queryVector?.length) {
6387
+ throw new Error("No vector generated for query.");
6388
+ }
6389
+ vector = queryVector;
6390
+ _embedSource = "computed";
5103
6391
  }
5104
- vector = result.chunks[0].vector;
5105
6392
  vectorStr = `ARRAY[${vector.join(",")}]`;
5106
6393
  vectorExpr = `${vectorStr}::vector`;
5107
6394
  }
@@ -5128,6 +6415,7 @@ var vectorSearch = async ({
5128
6415
  const languages = configuration.languages?.length ? configuration.languages : ["english"];
5129
6416
  console.log("[EXULU] Vector search params:", { method, query, cutoffs, languages });
5130
6417
  let resultChunks = [];
6418
+ const _tSql = Date.now();
5131
6419
  switch (method) {
5132
6420
  case "tsvector":
5133
6421
  chunksQuery.limit(limit * 2);
@@ -5241,6 +6529,11 @@ var vectorSearch = async ({
5241
6529
  ]).orderByRaw("hybrid_score DESC").limit(Math.min(matchCount, 250));
5242
6530
  resultChunks = await hybridQuery;
5243
6531
  }
6532
+ if (process.env.EXULU_VS_TIMING) {
6533
+ console.log(
6534
+ `[EXULU-VS] ctx=${context.id} method=${method} embed=${_embedSource} pre=${_preMs}ms stat=${_statMs}ms resolve=${_resolveMs}ms embedApi=${_embedMs}ms sql=${Date.now() - _tSql}ms total=${Date.now() - _tBody}ms`
6535
+ );
6536
+ }
5244
6537
  console.log("[EXULU] Vector search chunk results:", resultChunks?.length);
5245
6538
  let results = resultChunks.map((chunk) => ({
5246
6539
  chunk_content: chunk.content,
@@ -5289,7 +6582,7 @@ var vectorSearch = async ({
5289
6582
  if (entitiesOn && rawQuery) {
5290
6583
  try {
5291
6584
  const types = await hydrateEntityTypes(context);
5292
- const queryMentions = await extractEntitiesForItem({
6585
+ const { mentions: queryMentions } = await extractEntitiesForItem({
5293
6586
  context,
5294
6587
  chunks: [{ index: 0, content: rawQuery }],
5295
6588
  types
@@ -5328,7 +6621,6 @@ var vectorSearch = async ({
5328
6621
  { length: expand.before },
5329
6622
  (_, i) => chunk.chunk_index - expand.before + i
5330
6623
  ).filter((index) => index >= 0);
5331
- console.log("[EXULU] Indices to fetch:", indicesToFetch);
5332
6624
  await Promise.all(
5333
6625
  indicesToFetch.map(async (index) => {
5334
6626
  if (expandedMap.has(`${chunk.item_id}-${index}`)) {
@@ -5373,7 +6665,6 @@ var vectorSearch = async ({
5373
6665
  { length: expand.after },
5374
6666
  (_, i) => chunk.chunk_index + i + 1
5375
6667
  );
5376
- console.log("[EXULU] Indices to fetch:", indicesToFetch);
5377
6668
  await Promise.all(
5378
6669
  indicesToFetch.map(async (index) => {
5379
6670
  if (expandedMap.has(`${chunk.item_id}-${index}`)) {
@@ -5449,7 +6740,7 @@ var vectorSearch = async ({
5449
6740
  context: {
5450
6741
  name: table.name.singular,
5451
6742
  id: table.id || "",
5452
- embedder: embedder.name
6743
+ embedder: embedder.model
5453
6744
  },
5454
6745
  chunks: results,
5455
6746
  entityInsights
@@ -5681,12 +6972,6 @@ var bullmqDecorator = async ({
5681
6972
  };
5682
6973
 
5683
6974
  // src/exulu/context.ts
5684
- var getTableName = (id) => {
5685
- return sanitizeName(id) + "_items";
5686
- };
5687
- var getChunksTableName = (id) => {
5688
- return sanitizeName(id) + "_chunks";
5689
- };
5690
6975
  var ExuluContext2 = class {
5691
6976
  // Must begin with a letter (a-z) or underscore (_). Subsequent characters in a name can be letters, digits (0-9), or
5692
6977
  // underscores and be a max length of 80 characters and at least 5 characters long.
@@ -5698,6 +6983,12 @@ var ExuluContext2 = class {
5698
6983
  processor;
5699
6984
  description;
5700
6985
  embedder;
6986
+ /**
6987
+ * Splits an item into embeddable chunks. Moved here from the removed
6988
+ * ExuluEmbedder. When omitted, the built-in `defaultChunker` (SentenceChunker)
6989
+ * is used so a context works from just an embedder model name.
6990
+ */
6991
+ chunker;
5701
6992
  queryRewriter;
5702
6993
  resultReranker;
5703
6994
  configuration;
@@ -5713,6 +7004,7 @@ var ExuluContext2 = class {
5713
7004
  name,
5714
7005
  description,
5715
7006
  embedder,
7007
+ chunker,
5716
7008
  processor,
5717
7009
  active,
5718
7010
  fields,
@@ -5744,6 +7036,7 @@ var ExuluContext2 = class {
5744
7036
  };
5745
7037
  this.description = description;
5746
7038
  this.embedder = embedder;
7039
+ this.chunker = chunker;
5747
7040
  this.active = active;
5748
7041
  this.queryRewriter = queryRewriter;
5749
7042
  this.resultReranker = resultReranker;
@@ -5904,20 +7197,40 @@ var ExuluContext2 = class {
5904
7197
  throw new Error("Item id is required for generating embeddings.");
5905
7198
  }
5906
7199
  const { db: db2 } = await postgresClient();
5907
- const { id: source, chunks } = await this.embedder.generateFromDocument(
5908
- this.id,
5909
- {
5910
- ...item,
5911
- id: item.id
5912
- },
5913
- config,
5914
- {
5915
- label: statistics?.label || this.name,
5916
- trigger: statistics?.trigger || "agent"
5917
- },
5918
- user,
5919
- role
7200
+ if (statistics) {
7201
+ await updateStatistic({
7202
+ name: "count",
7203
+ label: statistics.label,
7204
+ type: STATISTICS_TYPE_ENUM.EMBEDDER_GENERATE,
7205
+ trigger: statistics.trigger,
7206
+ count: 1,
7207
+ user,
7208
+ role
7209
+ });
7210
+ }
7211
+ const source = item.id;
7212
+ const resolved = await resolveEmbedder({
7213
+ model: this.embedder.model,
7214
+ contextId: this.id,
7215
+ contextName: this.name,
7216
+ userId: user,
7217
+ roleId: role
7218
+ });
7219
+ const chunkerFn = this.chunker ?? defaultChunker;
7220
+ const { chunks: produced } = await chunkerFn(
7221
+ { ...item, id: item.id },
7222
+ resolved.maxChunkSize,
7223
+ { storage: new ExuluStorage({ config }) }
5920
7224
  );
7225
+ console.log("[EXULU] Generating embeddings.");
7226
+ const contents = produced.map((c) => c.content);
7227
+ const vectors = contents.length ? await resolved.embed(contents, { inputType: "document" }) : [];
7228
+ const chunks = produced.map((c, i) => ({
7229
+ content: c.content,
7230
+ index: c.index,
7231
+ metadata: c.metadata ?? {},
7232
+ vector: vectors[i] ?? []
7233
+ }));
5921
7234
  const previousEntityIds = await captureEntitiesBeforeReembed(this, item.id);
5922
7235
  await db2.from(getChunksTableName(this.id)).where({ source }).delete();
5923
7236
  if (chunks?.length) {
@@ -6152,12 +7465,18 @@ var ExuluContext2 = class {
6152
7465
  };
6153
7466
  getItems = async ({
6154
7467
  filters,
6155
- fields
7468
+ fields,
7469
+ user,
7470
+ role
6156
7471
  }) => {
6157
7472
  const { db: db2 } = await postgresClient();
6158
- let query = db2.from(getTableName(this.id)).select(fields || "*");
6159
7473
  const tableDefinition = convertContextToTableDefinition(this);
7474
+ let query = db2.from(getTableName(this.id)).select(fields || "*");
6160
7475
  query = applyFilters(query, filters || [], tableDefinition);
7476
+ if (user) {
7477
+ const acUser = role && (!user.role || user.role.id !== role) ? { ...user, role: { ...user.role ?? {}, id: role } } : user;
7478
+ query = applyAccessControl(tableDefinition, query, acUser);
7479
+ }
6161
7480
  const items = await query;
6162
7481
  return items;
6163
7482
  };
@@ -6188,8 +7507,8 @@ var ExuluContext2 = class {
6188
7507
  console.log("[EXULU] embedder is in queue mode, scheduling job.");
6189
7508
  const job = await bullmqDecorator({
6190
7509
  timeoutInSeconds: queue.timeoutInSeconds || 180,
6191
- label: `${this.embedder.name}`,
6192
- embedder: this.embedder.id,
7510
+ label: `${this.embedder.model}`,
7511
+ embedder: this.embedder.model,
6193
7512
  context: this.id,
6194
7513
  backoff: queue.backoff || {
6195
7514
  type: "exponential",
@@ -6214,7 +7533,7 @@ var ExuluContext2 = class {
6214
7533
  config,
6215
7534
  user,
6216
7535
  {
6217
- label: this.embedder.name,
7536
+ label: this.embedder.model,
6218
7537
  trigger: trigger || "agent"
6219
7538
  },
6220
7539
  role,
@@ -6320,6 +7639,25 @@ var ExuluContext2 = class {
6320
7639
  }
6321
7640
  return { processed: batch.length, skipped };
6322
7641
  },
7642
+ /**
7643
+ * Extract + ingest entities for a SINGLE item — powers the item detail
7644
+ * page's "Extract entities" test action. Returns the number of mentions
7645
+ * found so the UI can report the result.
7646
+ */
7647
+ extractItem: async (itemId) => {
7648
+ if (!await entitiesEnabled(this)) {
7649
+ throw new Error(
7650
+ "Entity extraction is not configured for this context (no entity types, or no embedder)."
7651
+ );
7652
+ }
7653
+ const extracted = await extractAndIngestEntities({ context: this, itemId });
7654
+ return { extracted };
7655
+ },
7656
+ /** Detach all entities from a single item (drops links, prunes orphans). */
7657
+ detachItem: async (itemId) => {
7658
+ const detached = await detachEntitiesForItem(this, itemId);
7659
+ return { detached };
7660
+ },
6323
7661
  /** Remove all entities (and their mentions via cascade) of a given type. */
6324
7662
  purgeType: async (typeName) => {
6325
7663
  if (!await entitiesTableExists(this)) return { removed: 0 };
@@ -6374,18 +7712,19 @@ var ExuluContext2 = class {
6374
7712
  const { db: db2 } = await postgresClient();
6375
7713
  const tableName = getChunksTableName(this.id);
6376
7714
  console.log("[EXULU] Creating table: " + tableName);
7715
+ if (!this.embedder) {
7716
+ throw new Error(
7717
+ "Embedder must be set for context " + this.name + " to create chunks table."
7718
+ );
7719
+ }
7720
+ const { dimensionality } = getEmbeddingModelInfo(this.embedder.model);
6377
7721
  await db2.schema.createTable(tableName, (table) => {
6378
- if (!this.embedder) {
6379
- throw new Error(
6380
- "Embedder must be set for context " + this.name + " to create chunks table."
6381
- );
6382
- }
6383
7722
  table.uuid("id").primary().defaultTo(db2.fn.uuid());
6384
7723
  table.uuid("source").references("id").inTable(getTableName(this.id));
6385
7724
  table.text("content");
6386
7725
  table.jsonb("metadata");
6387
7726
  table.integer("chunk_index");
6388
- table.specificType("embedding", `vector(${this.embedder.vectorDimensions})`);
7727
+ table.specificType("embedding", `vector(${dimensionality})`);
6389
7728
  const languages = this.configuration.languages?.length ? this.configuration.languages : ["english"];
6390
7729
  const tsvectorExpression = languages.map((lang) => `to_tsvector('${lang}', coalesce(content, ''))`).join(" || ");
6391
7730
  table.specificType(
@@ -7043,16 +8382,16 @@ async function createDynamicTools(chunks, hadExcludedContent) {
7043
8382
  }
7044
8383
 
7045
8384
  // ee/agentic-retrieval/v3/session-tools-registry.ts
7046
- var registry2 = /* @__PURE__ */ new Map();
8385
+ var registry3 = /* @__PURE__ */ new Map();
7047
8386
  function registerSessionTools(sessionId, tools) {
7048
- const existing = registry2.get(sessionId) ?? /* @__PURE__ */ new Map();
8387
+ const existing = registry3.get(sessionId) ?? /* @__PURE__ */ new Map();
7049
8388
  for (const [name, toolDef] of Object.entries(tools)) {
7050
8389
  existing.set(name, toolDef);
7051
8390
  }
7052
- registry2.set(sessionId, existing);
8391
+ registry3.set(sessionId, existing);
7053
8392
  }
7054
8393
  function getSessionTools(sessionId) {
7055
- const toolMap = registry2.get(sessionId);
8394
+ const toolMap = registry3.get(sessionId);
7056
8395
  if (!toolMap || toolMap.size === 0) return {};
7057
8396
  return Object.fromEntries(toolMap.entries());
7058
8397
  }
@@ -7162,8 +8501,8 @@ ${customInstructions}` : ""
7162
8501
  (tc) => tc.toolName === "search_content" && tc.input?.includeContent === false || tc.toolName === "search_items_by_name"
7163
8502
  );
7164
8503
  if (reranker && stepChunks.length > 0) {
7165
- console.log(`[EXULU] v3 reranking ${stepChunks.length} chunks with ${reranker.name}`);
7166
- stepChunks = await reranker.run(query, stepChunks);
8504
+ console.log(`[EXULU] v3 reranking ${stepChunks.length} chunks with ${reranker.model}`);
8505
+ stepChunks = await reranker.rerank(query, stepChunks);
7167
8506
  }
7168
8507
  const newDynamic = await createDynamicTools(stepChunks, hadExcludedContent);
7169
8508
  Object.assign(dynamicTools, newDynamic);
@@ -7251,7 +8590,7 @@ ${customInstructions}` : ""
7251
8590
  }
7252
8591
 
7253
8592
  // ee/agentic-retrieval/v3/trajectory.ts
7254
- import * as fs from "fs/promises";
8593
+ import * as fs2 from "fs/promises";
7255
8594
  import * as path from "path";
7256
8595
  var trajectoryRegistry = {
7257
8596
  lastFile: void 0
@@ -7452,13 +8791,13 @@ var TrajectoryLogger = class {
7452
8791
  };
7453
8792
  if (!writeFiles) return void 0;
7454
8793
  try {
7455
- await fs.mkdir(this.logDir, { recursive: true });
8794
+ await fs2.mkdir(this.logDir, { recursive: true });
7456
8795
  const ts = Date.now();
7457
8796
  const jsonPath = path.join(this.logDir, `trajectory_${ts}.json`);
7458
8797
  const mdPath = path.join(this.logDir, `trajectory_${ts}.md`);
7459
8798
  await Promise.all([
7460
- fs.writeFile(jsonPath, JSON.stringify(this.data, null, 2), "utf-8"),
7461
- fs.writeFile(mdPath, this.toMarkdown(durationMs, success, error), "utf-8")
8799
+ fs2.writeFile(jsonPath, JSON.stringify(this.data, null, 2), "utf-8"),
8800
+ fs2.writeFile(mdPath, this.toMarkdown(durationMs, success, error), "utf-8")
7462
8801
  ]);
7463
8802
  console.log(`[EXULU] v3 trajectory saved: trajectory_${ts}.json + trajectory_${ts}.md`);
7464
8803
  trajectoryRegistry.lastFile = jsonPath;
@@ -7626,7 +8965,6 @@ SCOPE CONSTRAINT: Retrieval is scoped to preselected items/contexts. Per context
7626
8965
  function createAgenticRetrievalToolV3({
7627
8966
  contexts,
7628
8967
  instructions: adminInstructions,
7629
- rerankers,
7630
8968
  user,
7631
8969
  role,
7632
8970
  model,
@@ -7761,7 +9099,18 @@ function createAgenticRetrievalToolV3({
7761
9099
  requiresPreselectedContexts = toolVariablesConfig["require_preselected_contexts"] === true || toolVariablesConfig["require_preselected_contexts"] === "true";
7762
9100
  const rerankerId = toolVariablesConfig["reranker"];
7763
9101
  if (rerankerId && rerankerId !== "none") {
7764
- configuredReranker = rerankers.find((r) => r.id === rerankerId);
9102
+ try {
9103
+ configuredReranker = await resolveReranker({
9104
+ model: rerankerId,
9105
+ user,
9106
+ roleId: role
9107
+ });
9108
+ } catch (err) {
9109
+ console.warn(
9110
+ `[EXULU] v3 \u2014 could not resolve reranker "${rerankerId}", continuing without reranking:`,
9111
+ err
9112
+ );
9113
+ }
7765
9114
  }
7766
9115
  }
7767
9116
  console.log("[EXULU] Managed context enabled:", managedContextEnabled);
@@ -7844,9 +9193,21 @@ var createNewMemoryItemTool = (agent, context) => {
7844
9193
  case "longText":
7845
9194
  case "shortText":
7846
9195
  case "code":
7847
- case "enum":
7848
9196
  fields[field.name] = z10.string().describe("The " + field.name + " of the item to create");
7849
9197
  break;
9198
+ case "enum":
9199
+ if (field.enumValues && field.enumValues.length > 0) {
9200
+ const enumValues = field.enumValues;
9201
+ fields[field.name] = z10.preprocess(
9202
+ (v) => typeof v === "string" ? v.toUpperCase() : v,
9203
+ z10.enum(enumValues)
9204
+ ).describe(
9205
+ "The " + field.name + " of the item to create. Must be one of: " + field.enumValues.join(", ")
9206
+ );
9207
+ } else {
9208
+ fields[field.name] = z10.string().describe("The " + field.name + " of the item to create");
9209
+ }
9210
+ break;
7850
9211
  case "json":
7851
9212
  fields[field.name] = z10.string({}).describe(
7852
9213
  "The " + field.name + " of the item to create, it should be a valid JSON string."
@@ -7872,6 +9233,9 @@ var createNewMemoryItemTool = (agent, context) => {
7872
9233
  break;
7873
9234
  }
7874
9235
  }
9236
+ fields["visibility"] = z10.enum(["private", "public"]).optional().describe(
9237
+ "Whether this memory is private to the user or shared (public). Ask the user if unknown."
9238
+ );
7875
9239
  const toolName = "create_" + sanitizeName(context.name) + "_memory_item";
7876
9240
  return new ExuluTool({
7877
9241
  id: toolName,
@@ -7881,14 +9245,36 @@ var createNewMemoryItemTool = (agent, context) => {
7881
9245
  type: "function",
7882
9246
  inputSchema: z10.object(fields),
7883
9247
  config: [],
7884
- execute: async ({ name, description, surroundingContext, mode, information, exuluConfig, user }) => {
9248
+ execute: async (params) => {
9249
+ const { name, description, surroundingContext, information, visibility, exuluConfig, user } = params;
7885
9250
  let result = { result: "" };
9251
+ if (!visibility) {
9252
+ return {
9253
+ result: `Before saving this memory, ask the user whether it should be PRIVATE (visible only to them) or PUBLIC (shared with the team), then call \`${toolName}\` again with \`visibility\` set.`
9254
+ };
9255
+ }
7886
9256
  try {
9257
+ const extraFields = {};
9258
+ for (const field of context.fields ?? []) {
9259
+ if (field.type === "enum" && field.enumValues && field.enumValues.length > 0) {
9260
+ const raw = params[field.name];
9261
+ if (raw !== void 0 && raw !== null && raw !== "") {
9262
+ const rawStr = String(raw);
9263
+ const canonical = field.enumValues.find(
9264
+ (v) => v.toUpperCase() === rawStr.toUpperCase()
9265
+ );
9266
+ if (canonical !== void 0) {
9267
+ extraFields[field.name] = canonical;
9268
+ }
9269
+ }
9270
+ }
9271
+ }
7887
9272
  const newItem = {
7888
9273
  name,
7889
9274
  description: "Description: " + description + "\n\nSurrounding Context: " + surroundingContext,
7890
9275
  information: "Information: " + information,
7891
- rights_mode: "public"
9276
+ rights_mode: visibility === "private" ? "private" : "public",
9277
+ ...extraFields
7892
9278
  };
7893
9279
  const { item: createdItem, job: createdJob } = await context.createItem(
7894
9280
  newItem,
@@ -7926,14 +9312,14 @@ import {
7926
9312
  SandboxManager
7927
9313
  } from "@anthropic-ai/sandbox-runtime";
7928
9314
  import { mkdir as mkdir2, rm, writeFile as writeFile2, readFile as fsReadFile, readdir, stat } from "fs/promises";
7929
- import { existsSync as existsSync3 } from "fs";
7930
- import { join as join3, dirname, resolve as resolve2, relative, posix } from "path";
9315
+ import { existsSync as existsSync4 } from "fs";
9316
+ import { join as join3, dirname, resolve as resolve3, relative, posix } from "path";
7931
9317
  import { exec as exec2, spawn as spawn2 } from "child_process";
7932
9318
  import { promisify as promisify2 } from "util";
7933
9319
 
7934
9320
  // src/exulu/system-dependencies.ts
7935
9321
  import { exec } from "child_process";
7936
- import { existsSync as existsSync2 } from "fs";
9322
+ import { existsSync as existsSync3 } from "fs";
7937
9323
  import { join as join2 } from "path";
7938
9324
  import { promisify } from "util";
7939
9325
  var execAsync = promisify(exec);
@@ -7999,7 +9385,7 @@ async function probeDependency(dep) {
7999
9385
  case "npm-global": {
8000
9386
  const root = await getNpmGlobalRoot();
8001
9387
  if (!root) return false;
8002
- return existsSync2(join2(root, dep.check.packageName));
9388
+ return existsSync3(join2(root, dep.check.packageName));
8003
9389
  }
8004
9390
  }
8005
9391
  }
@@ -8078,18 +9464,18 @@ function probeSandboxSupport() {
8078
9464
  if (process.platform !== "linux") {
8079
9465
  return { canSandbox: false, reason: `Unsupported platform: ${process.platform}` };
8080
9466
  }
8081
- return await new Promise((resolve3) => {
9467
+ return await new Promise((resolve4) => {
8082
9468
  const child = spawn2("bwrap", ["--dev-bind", "/", "/", "--", "/bin/true"]);
8083
9469
  let stderr = "";
8084
9470
  child.stderr.on("data", (chunk) => {
8085
9471
  stderr += chunk.toString();
8086
9472
  });
8087
9473
  child.on("error", (err) => {
8088
- resolve3({ canSandbox: false, reason: `bwrap not executable: ${err.message}` });
9474
+ resolve4({ canSandbox: false, reason: `bwrap not executable: ${err.message}` });
8089
9475
  });
8090
9476
  child.on("exit", (code) => {
8091
- if (code === 0) resolve3({ canSandbox: true });
8092
- else resolve3({ canSandbox: false, reason: stderr.trim() || `bwrap exited ${code}` });
9477
+ if (code === 0) resolve4({ canSandbox: true });
9478
+ else resolve4({ canSandbox: false, reason: stderr.trim() || `bwrap exited ${code}` });
8093
9479
  });
8094
9480
  });
8095
9481
  })();
@@ -8135,7 +9521,7 @@ async function downloadSkill(skill, skillsDirectory, config) {
8135
9521
  }
8136
9522
  }
8137
9523
  function isArtifactPath(absPath, sessionDir) {
8138
- const resolved = resolve2(absPath);
9524
+ const resolved = resolve3(absPath);
8139
9525
  const rel = relative(sessionDir, resolved);
8140
9526
  if (!rel || rel.startsWith("..")) return false;
8141
9527
  const first = rel.split("/")[0];
@@ -8194,7 +9580,7 @@ async function restoreArtifactsFromS3(sessionDir, sessionId, userId, config) {
8194
9580
  async function downloadKeyIntoSandbox(opts) {
8195
9581
  const { sessionId, userId, fullS3Key, config } = opts;
8196
9582
  const sessionDir = join3("/tmp", "exulu-sessions", sessionId);
8197
- if (!existsSync3(sessionDir)) {
9583
+ if (!existsSync4(sessionDir)) {
8198
9584
  return { written: false };
8199
9585
  }
8200
9586
  const userPrefix = `user_${userId}/sessions/${sessionId}/`;
@@ -8228,7 +9614,7 @@ async function createSessionSandbox(sessionId, skills, config, userId) {
8228
9614
  return cached.handle;
8229
9615
  }
8230
9616
  const sessionDir = join3("/tmp", "exulu-sessions", sessionId);
8231
- const dirExisted = existsSync3(sessionDir);
9617
+ const dirExisted = existsSync4(sessionDir);
8232
9618
  await mkdir2(sessionDir, { recursive: true });
8233
9619
  const skillsDirectory = join3(sessionDir, "skills");
8234
9620
  const installedSkills = /* @__PURE__ */ new Map();
@@ -8355,7 +9741,7 @@ Probe error: ${probe.reason ?? "(no detail)"}`
8355
9741
  if (!persistenceEnabled || !isArtifactPath(absPath, sessionDir)) {
8356
9742
  return {};
8357
9743
  }
8358
- const rel = relative(sessionDir, resolve2(absPath));
9744
+ const rel = relative(sessionDir, resolve3(absPath));
8359
9745
  const s3Key = artifactS3Key(sessionId, rel);
8360
9746
  const out = {};
8361
9747
  try {
@@ -8647,7 +10033,7 @@ var hydrateVariables = async (tool6) => {
8647
10033
  await Promise.all(promises);
8648
10034
  return tool6;
8649
10035
  };
8650
- var convertExuluToolsToAiSdkTools = async (currentTools, currentSkills, approvedTools, allExuluTools, configs, providerapikey, contexts, rerankers, user, exuluConfig, sessionID, req, project, sessionItems, model, agent, memoryItems) => {
10036
+ var convertExuluToolsToAiSdkTools = async (currentTools, currentSkills, approvedTools, allExuluTools, configs, providerapikey, contexts, user, exuluConfig, sessionID, req, project, sessionItems, model, agent, memoryItems) => {
8651
10037
  if (!currentTools) return {};
8652
10038
  if (!allExuluTools) {
8653
10039
  allExuluTools = [];
@@ -8656,7 +10042,7 @@ var convertExuluToolsToAiSdkTools = async (currentTools, currentSkills, approved
8656
10042
  contexts = [];
8657
10043
  }
8658
10044
  let sharedSessionSandbox;
8659
- if (sessionID && exuluConfig) {
10045
+ if (sessionID && exuluConfig && agent?.sandbox_enabled === true) {
8660
10046
  try {
8661
10047
  sharedSessionSandbox = await createSessionSandbox(
8662
10048
  sessionID,
@@ -8715,7 +10101,6 @@ var convertExuluToolsToAiSdkTools = async (currentTools, currentSkills, approved
8715
10101
  const agenticSearchTool = createAgenticRetrievalToolV3({
8716
10102
  contexts: contexts.filter((context) => context.id !== agent?.memory),
8717
10103
  // dont include the agents memory in the agentic search tool!
8718
- rerankers: rerankers || [],
8719
10104
  user,
8720
10105
  role: user?.role?.id,
8721
10106
  model,
@@ -8911,6 +10296,7 @@ export {
8911
10296
  postgresClient,
8912
10297
  authentication,
8913
10298
  STATISTICS_TYPE_ENUM,
10299
+ resolveLiteLLMConfigPath,
8914
10300
  getPresignedUrl,
8915
10301
  uploadFile,
8916
10302
  listS3ObjectsByPrefix,
@@ -8923,17 +10309,17 @@ export {
8923
10309
  createUppyRoutes,
8924
10310
  ExuluStorage,
8925
10311
  sanitizeName,
8926
- applySorting,
8927
- applyAccessControl,
8928
- applyFilters,
8929
- checkLicense,
8930
- coreSchemas,
8931
- convertContextToTableDefinition,
8932
- updateStatistic,
8933
- checkRecordAccess,
10312
+ getTableName,
10313
+ getChunksTableName,
10314
+ ExuluTokenizer,
10315
+ Chunk,
10316
+ BaseChunker,
10317
+ SentenceChunker,
10318
+ defaultChunker,
8934
10319
  LITELLM_UI_PATH,
8935
10320
  isLiteLLMEnabled,
8936
10321
  setLiteLLMPackageRoot,
10322
+ enableLiteLLMClientMode,
8937
10323
  startLiteLLMSupervisor,
8938
10324
  waitForLiteLLMReady,
8939
10325
  buildTags,
@@ -8951,16 +10337,29 @@ export {
8951
10337
  getTagBudgetMap,
8952
10338
  provisionDefaultUserBudget,
8953
10339
  getUserBudgetView,
10340
+ resolveEmbedder,
10341
+ updateStatistic,
10342
+ applySorting,
10343
+ applyAccessControl,
10344
+ applyFilters,
10345
+ checkLicense,
10346
+ coreSchemas,
10347
+ convertContextToTableDefinition,
10348
+ setEntityModelSetting,
10349
+ resolveEntityModel,
10350
+ checkRecordAccess,
8954
10351
  ResolveModelError,
8955
10352
  resolveModel,
8956
10353
  exuluApp,
10354
+ getEntitiesTableName,
10355
+ getChunkEntitiesTableName,
10356
+ getEntitiesForItem,
8957
10357
  ensureEntityTables,
8958
10358
  vectorSearch,
8959
10359
  mapType,
8960
10360
  maybePruneJobResults,
8961
- getTableName,
8962
- getChunksTableName,
8963
10361
  ExuluContext2 as ExuluContext,
10362
+ resolveReranker,
8964
10363
  oauthRegistry,
8965
10364
  oauthTokenStore,
8966
10365
  OAUTH_CALLBACK_PATH,