@exulu/backend 1.59.0 → 1.60.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.
@@ -262,7 +262,8 @@ var spawnLiteLLM = (cfg) => {
262
262
  log(
263
263
  `Spawning LiteLLM: ${cfg.litellmBin} --config ${cfg.configPath} --port ${cfg.port} --host ${cfg.host}`
264
264
  );
265
- const { DEBUG: _debug, ...envWithoutDebug } = process.env;
265
+ const { DEBUG: _debug, ...rest } = process.env;
266
+ const childEnv = { ...rest, DEBUG: "false" };
266
267
  const child = spawn(
267
268
  cfg.litellmBin,
268
269
  [
@@ -275,7 +276,7 @@ var spawnLiteLLM = (cfg) => {
275
276
  ],
276
277
  {
277
278
  stdio: ["ignore", "pipe", "pipe"],
278
- env: envWithoutDebug
279
+ env: childEnv
279
280
  }
280
281
  );
281
282
  child.stdout?.on("data", (chunk) => {
@@ -743,7 +744,7 @@ var ExuluTool = class {
743
744
  });
744
745
  providerapikey = resolved.apiKey;
745
746
  }
746
- const { convertExuluToolsToAiSdkTools: convertExuluToolsToAiSdkTools2 } = await import("./convert-exulu-tools-to-ai-sdk-tools-ZEECMX43.js");
747
+ const { convertExuluToolsToAiSdkTools: convertExuluToolsToAiSdkTools2 } = await import("./convert-exulu-tools-to-ai-sdk-tools-PLLM2CJL.js");
747
748
  const tools = await convertExuluToolsToAiSdkTools2(
748
749
  [this],
749
750
  [],
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  convertExuluToolsToAiSdkTools
3
- } from "./chunk-U36VJDZ7.js";
3
+ } from "./chunk-23YNGK3V.js";
4
4
  export {
5
5
  convertExuluToolsToAiSdkTools
6
6
  };
package/dist/index.cjs CHANGED
@@ -429,7 +429,8 @@ var init_supervisor = __esm({
429
429
  log(
430
430
  `Spawning LiteLLM: ${cfg.litellmBin} --config ${cfg.configPath} --port ${cfg.port} --host ${cfg.host}`
431
431
  );
432
- const { DEBUG: _debug, ...envWithoutDebug } = process.env;
432
+ const { DEBUG: _debug, ...rest } = process.env;
433
+ const childEnv = { ...rest, DEBUG: "false" };
433
434
  const child = (0, import_node_child_process.spawn)(
434
435
  cfg.litellmBin,
435
436
  [
@@ -442,7 +443,7 @@ var init_supervisor = __esm({
442
443
  ],
443
444
  {
444
445
  stdio: ["ignore", "pipe", "pipe"],
445
- env: envWithoutDebug
446
+ env: childEnv
446
447
  }
447
448
  );
448
449
  child.stdout?.on("data", (chunk) => {
@@ -18790,6 +18791,217 @@ var emailTool = new ExuluTool({
18790
18791
  }
18791
18792
  });
18792
18793
 
18794
+ // src/templates/tools/transcribe.ts
18795
+ init_cjs_shims();
18796
+ init_tool();
18797
+ init_supervisor();
18798
+ init_uppy();
18799
+ var import_node_crypto8 = require("crypto");
18800
+ var import_promises3 = require("fs/promises");
18801
+ var import_node_path5 = require("path");
18802
+ var import_zod20 = require("zod");
18803
+ var SANDBOX_ROOT = "/tmp/exulu-sessions";
18804
+ var parseSandboxPath = (input) => {
18805
+ const stripped = input.startsWith("file://") ? input.slice("file://".length) : input;
18806
+ if (!stripped.startsWith(`${SANDBOX_ROOT}/`)) return null;
18807
+ const tail = stripped.slice(SANDBOX_ROOT.length + 1);
18808
+ const slash = tail.indexOf("/");
18809
+ if (slash < 1) return null;
18810
+ const sessionId = tail.slice(0, slash);
18811
+ const relPath = tail.slice(slash + 1);
18812
+ if (!relPath) return null;
18813
+ return { sessionId, relPath };
18814
+ };
18815
+ var audioMimetypeFromExtension = (filename) => {
18816
+ const ext = filename.split(".").pop()?.toLowerCase();
18817
+ switch (ext) {
18818
+ case "mp3":
18819
+ return "audio/mpeg";
18820
+ case "m4a":
18821
+ case "mp4":
18822
+ return "audio/mp4";
18823
+ case "wav":
18824
+ return "audio/wav";
18825
+ case "ogg":
18826
+ case "oga":
18827
+ return "audio/ogg";
18828
+ case "flac":
18829
+ return "audio/flac";
18830
+ case "webm":
18831
+ return "audio/webm";
18832
+ case "aac":
18833
+ return "audio/aac";
18834
+ case "mpga":
18835
+ case "mpeg":
18836
+ return "audio/mpeg";
18837
+ default:
18838
+ throw new Error(
18839
+ `Unable to infer an audio mimetype from filename "${filename}". Supported extensions: mp3, m4a, mp4, wav, ogg, flac, webm, aac, mpga.`
18840
+ );
18841
+ }
18842
+ };
18843
+ var transcribeTool = new ExuluTool({
18844
+ id: "transcribe_audio",
18845
+ name: "Transcribe Audio",
18846
+ description: "Transcribe an audio file (mp3, wav, m4a, etc.) from a URL to text using the configured speech-to-text model. The transcript is stored as a .txt file on S3 and the URL is returned; use this for clips that may be too long to inline in the conversation.",
18847
+ inputSchema: import_zod20.z.object({
18848
+ audio_url: import_zod20.z.string().describe(
18849
+ "Location of the audio file to transcribe. Accepts a publicly fetchable URL (https URL or presigned S3 URL), or a sandbox path such as '/tmp/exulu-sessions/<sessionId>/<file>' or 'file:///tmp/exulu-sessions/<sessionId>/<file>' \u2014 sandbox paths are resolved to their persisted S3 copy."
18850
+ ),
18851
+ language: import_zod20.z.string().optional().describe(
18852
+ "ISO-639-1 language code of the audio, e.g. 'en' or 'de'. Omit to let the model auto-detect."
18853
+ )
18854
+ }),
18855
+ type: "function",
18856
+ config: [{
18857
+ name: "default_language",
18858
+ description: "ISO-639-1 language code of the audio, e.g. 'en' or 'de'. Omit to let the model auto-detect.",
18859
+ type: "string",
18860
+ default: void 0
18861
+ }],
18862
+ execute: async ({ audio_url, language, user, exuluConfig, sessionID }) => {
18863
+ if (!language && exuluConfig?.default_language) {
18864
+ language = exuluConfig?.default_language;
18865
+ } else {
18866
+ language = "en";
18867
+ }
18868
+ language = exuluConfig?.default_language;
18869
+ console.log("[EXULU] Exulu config", exuluConfig);
18870
+ if (!isLiteLLMEnabled()) {
18871
+ console.error("[EXULU] Speech-to-text is not enabled on this deployment (EXULU_USE_LITELLM is not 'true').");
18872
+ throw new Error(
18873
+ "Speech-to-text is not enabled on this deployment (EXULU_USE_LITELLM is not 'true')."
18874
+ );
18875
+ }
18876
+ if (!process.env.TRANSCRIPTION_MODEL) {
18877
+ console.error("[EXULU] TRANSCRIPTION_MODEL env var is not set.");
18878
+ throw new Error("TRANSCRIPTION_MODEL env var is not set.");
18879
+ }
18880
+ if (!exuluConfig?.fileUploads) {
18881
+ console.error("[EXULU] File uploads are not configured; the transcribe tool requires S3 to store transcripts.");
18882
+ throw new Error(
18883
+ "File uploads are not configured; the transcribe tool requires S3 to store transcripts."
18884
+ );
18885
+ }
18886
+ const sandboxPath = parseSandboxPath(audio_url);
18887
+ let buffer;
18888
+ let mimetype;
18889
+ let originalname;
18890
+ if (sandboxPath) {
18891
+ if (!user?.id) {
18892
+ throw new Error(
18893
+ "Sandbox audio paths require an authenticated user; got no user on the tool call."
18894
+ );
18895
+ }
18896
+ if (sessionID && sandboxPath.sessionId !== sessionID) {
18897
+ throw new Error(
18898
+ `Refusing to transcribe an audio file from a different session's sandbox (path session=${sandboxPath.sessionId}, current session=${sessionID}).`
18899
+ );
18900
+ }
18901
+ const rawKey = `user_${user.id}/sessions/${sandboxPath.sessionId}/${sandboxPath.relPath}`;
18902
+ console.log("[EXULU] Transcribing audio from sandbox path", {
18903
+ rawKey
18904
+ });
18905
+ const matches = await listS3ObjectsByPrefix(rawKey, exuluConfig);
18906
+ const found = matches.find((m) => m.key.endsWith(rawKey));
18907
+ if (!found) {
18908
+ console.error("[EXULU] Sandbox audio file not found in S3 storage at", {
18909
+ rawKey,
18910
+ matches
18911
+ });
18912
+ throw new Error(
18913
+ `Sandbox audio file not found in S3 storage at "${rawKey}". The file may not have been persisted yet \u2014 try again after the sandbox flushes it.`
18914
+ );
18915
+ }
18916
+ buffer = await getS3ObjectBytes(found.key, exuluConfig);
18917
+ originalname = decodeURIComponent(
18918
+ sandboxPath.relPath.split("/").pop() || "audio"
18919
+ );
18920
+ mimetype = audioMimetypeFromExtension(originalname);
18921
+ } else {
18922
+ console.log("[EXULU] Fetching audio from URL", {
18923
+ audio_url
18924
+ });
18925
+ const upstream = await fetch(audio_url);
18926
+ if (!upstream.ok) {
18927
+ console.error("[EXULU] Failed to fetch audio from", {
18928
+ audio_url,
18929
+ upstream
18930
+ });
18931
+ throw new Error(
18932
+ `Failed to fetch audio from ${audio_url}: ${upstream.status} ${upstream.statusText}`
18933
+ );
18934
+ }
18935
+ mimetype = upstream.headers.get("content-type") || "audio/mpeg";
18936
+ if (!mimetype.startsWith("audio/")) {
18937
+ throw new Error(
18938
+ `URL did not return an audio file (content-type: ${mimetype}).`
18939
+ );
18940
+ }
18941
+ buffer = Buffer.from(await upstream.arrayBuffer());
18942
+ originalname = "audio";
18943
+ try {
18944
+ const pathname = new URL(audio_url).pathname;
18945
+ const last = pathname.split("/").pop();
18946
+ if (last) originalname = decodeURIComponent(last);
18947
+ } catch {
18948
+ }
18949
+ }
18950
+ const { text } = await transcribeAudio({
18951
+ file: { buffer, originalname, mimetype },
18952
+ language
18953
+ });
18954
+ const transcriptBuffer = Buffer.from(text, "utf-8");
18955
+ const transcriptFilename = `${(0, import_node_crypto8.randomUUID)()}.txt`;
18956
+ const transcriptKey = sessionID ? `sessions/${sessionID}/transcripts/${transcriptFilename}` : `transcripts/${transcriptFilename}`;
18957
+ console.log("[EXULU] Uploading transcript to S3", {
18958
+ transcriptFilename,
18959
+ transcriptKey
18960
+ });
18961
+ const url = await uploadFile(
18962
+ transcriptBuffer,
18963
+ transcriptKey,
18964
+ exuluConfig,
18965
+ { contentType: "text/plain" },
18966
+ user?.id
18967
+ );
18968
+ console.log("[EXULU] Uploaded transcript to S3", {
18969
+ url
18970
+ });
18971
+ let sandboxLocalPath;
18972
+ if (sessionID) {
18973
+ sandboxLocalPath = (0, import_node_path5.join)(
18974
+ SANDBOX_ROOT,
18975
+ sessionID,
18976
+ "transcripts",
18977
+ transcriptFilename
18978
+ );
18979
+ console.log("[EXULU] Mirroring transcript into session sandbox", {
18980
+ sandboxLocalPath
18981
+ });
18982
+ try {
18983
+ await (0, import_promises3.mkdir)((0, import_node_path5.dirname)(sandboxLocalPath), { recursive: true });
18984
+ await (0, import_promises3.writeFile)(sandboxLocalPath, transcriptBuffer);
18985
+ } catch (err) {
18986
+ console.error(
18987
+ `[EXULU] Failed to write transcript to sandbox dir ${sandboxLocalPath}; S3 copy is unaffected.`,
18988
+ err
18989
+ );
18990
+ sandboxLocalPath = void 0;
18991
+ }
18992
+ }
18993
+ console.log("[EXULU] Transcribed audio successfully", {
18994
+ text,
18995
+ url,
18996
+ sandboxLocalPath,
18997
+ length: text.length
18998
+ });
18999
+ return {
19000
+ result: sandboxLocalPath ? `Transcript stored at: ${url} (also available in the session sandbox at ${sandboxLocalPath}, ${text.length} characters).` : `${url}`
19001
+ };
19002
+ }
19003
+ });
19004
+
18793
19005
  // src/validators/postgres-name.ts
18794
19006
  init_cjs_shims();
18795
19007
  var isValidPostgresName = (id) => {
@@ -19102,12 +19314,17 @@ var ExuluApp = class {
19102
19314
  ...providers ?? []
19103
19315
  ];
19104
19316
  this._config = config;
19317
+ const transcriptionTools = [];
19318
+ if (process.env.TRANSCRIPTION_MODEL && config?.fileUploads && config?.fileUploads?.s3region && config?.fileUploads?.s3key && config?.fileUploads?.s3secret && config?.fileUploads?.s3Bucket) {
19319
+ transcriptionTools.push(transcribeTool);
19320
+ }
19105
19321
  this._tools = [
19106
19322
  ...tools ?? [],
19107
19323
  ...todoTools,
19108
19324
  ...questionTools,
19109
19325
  ...perplexityTools,
19110
- emailTool
19326
+ emailTool,
19327
+ ...transcriptionTools
19111
19328
  // Because agents are stored in the database, we add those as tools
19112
19329
  // at request time, not during ExuluApp initialization. We add them
19113
19330
  // in the grahql tools resolver.
@@ -21274,7 +21491,7 @@ init_cjs_shims();
21274
21491
  // src/exulu/litellm/db-init.ts
21275
21492
  init_cjs_shims();
21276
21493
  var import_node_fs6 = require("fs");
21277
- var import_node_path5 = require("path");
21494
+ var import_node_path6 = require("path");
21278
21495
  var import_node_child_process5 = require("child_process");
21279
21496
  var import_pg = require("pg");
21280
21497
 
@@ -21344,7 +21561,7 @@ ${WARNING_BANNER}`);
21344
21561
  };
21345
21562
  var log2 = (line) => console.log(`[EXULU-LITELLM] ${line}`);
21346
21563
  var initLiteLLMDatabase = async (packageRoot) => {
21347
- const configPath = process.env.LITELLM_CONFIG_PATH ?? (0, import_node_path5.resolve)(packageRoot, "./config.litellm.yaml");
21564
+ const configPath = process.env.LITELLM_CONFIG_PATH ?? (0, import_node_path6.resolve)(packageRoot, "./config.litellm.yaml");
21348
21565
  const safety = checkLiteLLMDatabaseSafety(configPath);
21349
21566
  if (safety.ok && safety.reason === "no-litellm-db-mode") return;
21350
21567
  if (!safety.ok && safety.reason === "unparseable-url") {
@@ -21482,13 +21699,13 @@ var initLiteLLMDatabase = async (packageRoot) => {
21482
21699
  ]);
21483
21700
  return;
21484
21701
  }
21485
- const venvBin = (0, import_node_path5.resolve)(packageRoot, "ee/python/.venv/bin");
21486
- const prismaCli = (0, import_node_path5.resolve)(venvBin, "prisma");
21487
- const litellmProxyDir = (0, import_node_path5.resolve)(
21702
+ const venvBin = (0, import_node_path6.resolve)(packageRoot, "ee/python/.venv/bin");
21703
+ const prismaCli = (0, import_node_path6.resolve)(venvBin, "prisma");
21704
+ const litellmProxyDir = (0, import_node_path6.resolve)(
21488
21705
  packageRoot,
21489
21706
  "ee/python/.venv/lib/python3.12/site-packages/litellm/proxy"
21490
21707
  );
21491
- const schemaPath = (0, import_node_path5.resolve)(litellmProxyDir, "schema.prisma");
21708
+ const schemaPath = (0, import_node_path6.resolve)(litellmProxyDir, "schema.prisma");
21492
21709
  if (!(0, import_node_fs6.existsSync)(prismaCli)) {
21493
21710
  warn([
21494
21711
  `Prisma CLI not found at ${prismaCli}.`,
@@ -22078,7 +22295,7 @@ init_cjs_shims();
22078
22295
  var fs5 = __toESM(require("fs"), 1);
22079
22296
  var path2 = __toESM(require("path"), 1);
22080
22297
  var import_ai14 = require("ai");
22081
- var import_zod20 = require("zod");
22298
+ var import_zod21 = require("zod");
22082
22299
  var import_p_limit = __toESM(require("p-limit"), 1);
22083
22300
  var import_crypto = require("crypto");
22084
22301
  init_with_retry();
@@ -22433,15 +22650,15 @@ If the page contains a flow-chart, schematic, technical drawing or control board
22433
22650
  const result = await (0, import_ai14.generateText)({
22434
22651
  model,
22435
22652
  output: import_ai14.Output.object({
22436
- schema: import_zod20.z.object({
22437
- needs_correction: import_zod20.z.boolean(),
22438
- corrected_text: import_zod20.z.string().nullable(),
22439
- current_page_table: import_zod20.z.object({
22440
- headers: import_zod20.z.array(import_zod20.z.string()),
22441
- is_continuation: import_zod20.z.boolean()
22653
+ schema: import_zod21.z.object({
22654
+ needs_correction: import_zod21.z.boolean(),
22655
+ corrected_text: import_zod21.z.string().nullable(),
22656
+ current_page_table: import_zod21.z.object({
22657
+ headers: import_zod21.z.array(import_zod21.z.string()),
22658
+ is_continuation: import_zod21.z.boolean()
22442
22659
  }).nullable(),
22443
- confidence: import_zod20.z.enum(["high", "medium", "low"]),
22444
- reasoning: import_zod20.z.string()
22660
+ confidence: import_zod21.z.enum(["high", "medium", "low"]),
22661
+ reasoning: import_zod21.z.string()
22445
22662
  })
22446
22663
  }),
22447
22664
  messages: [
package/dist/index.js CHANGED
@@ -46,7 +46,7 @@ import {
46
46
  vectorSearch,
47
47
  waitForLiteLLMReady,
48
48
  withRetry
49
- } from "./chunk-U36VJDZ7.js";
49
+ } from "./chunk-23YNGK3V.js";
50
50
  import {
51
51
  findLiteLLMModel
52
52
  } from "./chunk-YS27XOXI.js";
@@ -11004,6 +11004,213 @@ var emailTool = new ExuluTool({
11004
11004
  }
11005
11005
  });
11006
11006
 
11007
+ // src/templates/tools/transcribe.ts
11008
+ import { randomUUID as randomUUID5 } from "crypto";
11009
+ import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
11010
+ import { dirname, join as join2 } from "path";
11011
+ import { z as z10 } from "zod";
11012
+ var SANDBOX_ROOT = "/tmp/exulu-sessions";
11013
+ var parseSandboxPath = (input) => {
11014
+ const stripped = input.startsWith("file://") ? input.slice("file://".length) : input;
11015
+ if (!stripped.startsWith(`${SANDBOX_ROOT}/`)) return null;
11016
+ const tail = stripped.slice(SANDBOX_ROOT.length + 1);
11017
+ const slash = tail.indexOf("/");
11018
+ if (slash < 1) return null;
11019
+ const sessionId = tail.slice(0, slash);
11020
+ const relPath = tail.slice(slash + 1);
11021
+ if (!relPath) return null;
11022
+ return { sessionId, relPath };
11023
+ };
11024
+ var audioMimetypeFromExtension = (filename) => {
11025
+ const ext = filename.split(".").pop()?.toLowerCase();
11026
+ switch (ext) {
11027
+ case "mp3":
11028
+ return "audio/mpeg";
11029
+ case "m4a":
11030
+ case "mp4":
11031
+ return "audio/mp4";
11032
+ case "wav":
11033
+ return "audio/wav";
11034
+ case "ogg":
11035
+ case "oga":
11036
+ return "audio/ogg";
11037
+ case "flac":
11038
+ return "audio/flac";
11039
+ case "webm":
11040
+ return "audio/webm";
11041
+ case "aac":
11042
+ return "audio/aac";
11043
+ case "mpga":
11044
+ case "mpeg":
11045
+ return "audio/mpeg";
11046
+ default:
11047
+ throw new Error(
11048
+ `Unable to infer an audio mimetype from filename "${filename}". Supported extensions: mp3, m4a, mp4, wav, ogg, flac, webm, aac, mpga.`
11049
+ );
11050
+ }
11051
+ };
11052
+ var transcribeTool = new ExuluTool({
11053
+ id: "transcribe_audio",
11054
+ name: "Transcribe Audio",
11055
+ description: "Transcribe an audio file (mp3, wav, m4a, etc.) from a URL to text using the configured speech-to-text model. The transcript is stored as a .txt file on S3 and the URL is returned; use this for clips that may be too long to inline in the conversation.",
11056
+ inputSchema: z10.object({
11057
+ audio_url: z10.string().describe(
11058
+ "Location of the audio file to transcribe. Accepts a publicly fetchable URL (https URL or presigned S3 URL), or a sandbox path such as '/tmp/exulu-sessions/<sessionId>/<file>' or 'file:///tmp/exulu-sessions/<sessionId>/<file>' \u2014 sandbox paths are resolved to their persisted S3 copy."
11059
+ ),
11060
+ language: z10.string().optional().describe(
11061
+ "ISO-639-1 language code of the audio, e.g. 'en' or 'de'. Omit to let the model auto-detect."
11062
+ )
11063
+ }),
11064
+ type: "function",
11065
+ config: [{
11066
+ name: "default_language",
11067
+ description: "ISO-639-1 language code of the audio, e.g. 'en' or 'de'. Omit to let the model auto-detect.",
11068
+ type: "string",
11069
+ default: void 0
11070
+ }],
11071
+ execute: async ({ audio_url, language, user, exuluConfig, sessionID }) => {
11072
+ if (!language && exuluConfig?.default_language) {
11073
+ language = exuluConfig?.default_language;
11074
+ } else {
11075
+ language = "en";
11076
+ }
11077
+ language = exuluConfig?.default_language;
11078
+ console.log("[EXULU] Exulu config", exuluConfig);
11079
+ if (!isLiteLLMEnabled()) {
11080
+ console.error("[EXULU] Speech-to-text is not enabled on this deployment (EXULU_USE_LITELLM is not 'true').");
11081
+ throw new Error(
11082
+ "Speech-to-text is not enabled on this deployment (EXULU_USE_LITELLM is not 'true')."
11083
+ );
11084
+ }
11085
+ if (!process.env.TRANSCRIPTION_MODEL) {
11086
+ console.error("[EXULU] TRANSCRIPTION_MODEL env var is not set.");
11087
+ throw new Error("TRANSCRIPTION_MODEL env var is not set.");
11088
+ }
11089
+ if (!exuluConfig?.fileUploads) {
11090
+ console.error("[EXULU] File uploads are not configured; the transcribe tool requires S3 to store transcripts.");
11091
+ throw new Error(
11092
+ "File uploads are not configured; the transcribe tool requires S3 to store transcripts."
11093
+ );
11094
+ }
11095
+ const sandboxPath = parseSandboxPath(audio_url);
11096
+ let buffer;
11097
+ let mimetype;
11098
+ let originalname;
11099
+ if (sandboxPath) {
11100
+ if (!user?.id) {
11101
+ throw new Error(
11102
+ "Sandbox audio paths require an authenticated user; got no user on the tool call."
11103
+ );
11104
+ }
11105
+ if (sessionID && sandboxPath.sessionId !== sessionID) {
11106
+ throw new Error(
11107
+ `Refusing to transcribe an audio file from a different session's sandbox (path session=${sandboxPath.sessionId}, current session=${sessionID}).`
11108
+ );
11109
+ }
11110
+ const rawKey = `user_${user.id}/sessions/${sandboxPath.sessionId}/${sandboxPath.relPath}`;
11111
+ console.log("[EXULU] Transcribing audio from sandbox path", {
11112
+ rawKey
11113
+ });
11114
+ const matches = await listS3ObjectsByPrefix(rawKey, exuluConfig);
11115
+ const found = matches.find((m) => m.key.endsWith(rawKey));
11116
+ if (!found) {
11117
+ console.error("[EXULU] Sandbox audio file not found in S3 storage at", {
11118
+ rawKey,
11119
+ matches
11120
+ });
11121
+ throw new Error(
11122
+ `Sandbox audio file not found in S3 storage at "${rawKey}". The file may not have been persisted yet \u2014 try again after the sandbox flushes it.`
11123
+ );
11124
+ }
11125
+ buffer = await getS3ObjectBytes(found.key, exuluConfig);
11126
+ originalname = decodeURIComponent(
11127
+ sandboxPath.relPath.split("/").pop() || "audio"
11128
+ );
11129
+ mimetype = audioMimetypeFromExtension(originalname);
11130
+ } else {
11131
+ console.log("[EXULU] Fetching audio from URL", {
11132
+ audio_url
11133
+ });
11134
+ const upstream = await fetch(audio_url);
11135
+ if (!upstream.ok) {
11136
+ console.error("[EXULU] Failed to fetch audio from", {
11137
+ audio_url,
11138
+ upstream
11139
+ });
11140
+ throw new Error(
11141
+ `Failed to fetch audio from ${audio_url}: ${upstream.status} ${upstream.statusText}`
11142
+ );
11143
+ }
11144
+ mimetype = upstream.headers.get("content-type") || "audio/mpeg";
11145
+ if (!mimetype.startsWith("audio/")) {
11146
+ throw new Error(
11147
+ `URL did not return an audio file (content-type: ${mimetype}).`
11148
+ );
11149
+ }
11150
+ buffer = Buffer.from(await upstream.arrayBuffer());
11151
+ originalname = "audio";
11152
+ try {
11153
+ const pathname = new URL(audio_url).pathname;
11154
+ const last = pathname.split("/").pop();
11155
+ if (last) originalname = decodeURIComponent(last);
11156
+ } catch {
11157
+ }
11158
+ }
11159
+ const { text } = await transcribeAudio({
11160
+ file: { buffer, originalname, mimetype },
11161
+ language
11162
+ });
11163
+ const transcriptBuffer = Buffer.from(text, "utf-8");
11164
+ const transcriptFilename = `${randomUUID5()}.txt`;
11165
+ const transcriptKey = sessionID ? `sessions/${sessionID}/transcripts/${transcriptFilename}` : `transcripts/${transcriptFilename}`;
11166
+ console.log("[EXULU] Uploading transcript to S3", {
11167
+ transcriptFilename,
11168
+ transcriptKey
11169
+ });
11170
+ const url = await uploadFile(
11171
+ transcriptBuffer,
11172
+ transcriptKey,
11173
+ exuluConfig,
11174
+ { contentType: "text/plain" },
11175
+ user?.id
11176
+ );
11177
+ console.log("[EXULU] Uploaded transcript to S3", {
11178
+ url
11179
+ });
11180
+ let sandboxLocalPath;
11181
+ if (sessionID) {
11182
+ sandboxLocalPath = join2(
11183
+ SANDBOX_ROOT,
11184
+ sessionID,
11185
+ "transcripts",
11186
+ transcriptFilename
11187
+ );
11188
+ console.log("[EXULU] Mirroring transcript into session sandbox", {
11189
+ sandboxLocalPath
11190
+ });
11191
+ try {
11192
+ await mkdir2(dirname(sandboxLocalPath), { recursive: true });
11193
+ await writeFile2(sandboxLocalPath, transcriptBuffer);
11194
+ } catch (err) {
11195
+ console.error(
11196
+ `[EXULU] Failed to write transcript to sandbox dir ${sandboxLocalPath}; S3 copy is unaffected.`,
11197
+ err
11198
+ );
11199
+ sandboxLocalPath = void 0;
11200
+ }
11201
+ }
11202
+ console.log("[EXULU] Transcribed audio successfully", {
11203
+ text,
11204
+ url,
11205
+ sandboxLocalPath,
11206
+ length: text.length
11207
+ });
11208
+ return {
11209
+ result: sandboxLocalPath ? `Transcript stored at: ${url} (also available in the session sandbox at ${sandboxLocalPath}, ${text.length} characters).` : `${url}`
11210
+ };
11211
+ }
11212
+ });
11213
+
11007
11214
  // src/validators/postgres-name.ts
11008
11215
  var isValidPostgresName = (id) => {
11009
11216
  const regex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
@@ -11015,17 +11222,17 @@ var isValidPostgresName = (id) => {
11015
11222
  // src/utils/python-setup.ts
11016
11223
  import { exec as exec2 } from "child_process";
11017
11224
  import { promisify as promisify2 } from "util";
11018
- import { resolve, join as join2, dirname } from "path";
11225
+ import { resolve, join as join3, dirname as dirname2 } from "path";
11019
11226
  import { existsSync as existsSync2, readFileSync } from "fs";
11020
11227
  import { fileURLToPath } from "url";
11021
11228
  var execAsync2 = promisify2(exec2);
11022
11229
  function getPackageRoot() {
11023
11230
  const currentFile = fileURLToPath(import.meta.url);
11024
- let currentDir = dirname(currentFile);
11231
+ let currentDir = dirname2(currentFile);
11025
11232
  let attempts = 0;
11026
11233
  const maxAttempts = 10;
11027
11234
  while (attempts < maxAttempts) {
11028
- const packageJsonPath = join2(currentDir, "package.json");
11235
+ const packageJsonPath = join3(currentDir, "package.json");
11029
11236
  if (existsSync2(packageJsonPath)) {
11030
11237
  try {
11031
11238
  const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
@@ -11042,7 +11249,7 @@ function getPackageRoot() {
11042
11249
  currentDir = parentDir;
11043
11250
  attempts++;
11044
11251
  }
11045
- const fallback = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
11252
+ const fallback = resolve(dirname2(fileURLToPath(import.meta.url)), "../..");
11046
11253
  return fallback;
11047
11254
  }
11048
11255
  function getSetupScriptPath(packageRoot) {
@@ -11054,7 +11261,7 @@ function getVenvPath(packageRoot) {
11054
11261
  function isPythonEnvironmentSetup(packageRoot) {
11055
11262
  const root = packageRoot ?? getPackageRoot();
11056
11263
  const venvPath = getVenvPath(root);
11057
- const pythonPath = join2(venvPath, "bin", "python");
11264
+ const pythonPath = join3(venvPath, "bin", "python");
11058
11265
  return existsSync2(venvPath) && existsSync2(pythonPath);
11059
11266
  }
11060
11267
  async function setupPythonEnvironment(options = {}) {
@@ -11157,7 +11364,7 @@ Note: In Docker containers, ensure you install all three components:
11157
11364
  async function validatePythonEnvironment(packageRoot, checkPackages = true) {
11158
11365
  const root = packageRoot ?? getPackageRoot();
11159
11366
  const venvPath = getVenvPath(root);
11160
- const pythonPath = join2(venvPath, "bin", "python");
11367
+ const pythonPath = join3(venvPath, "bin", "python");
11161
11368
  if (!existsSync2(venvPath)) {
11162
11369
  return {
11163
11370
  valid: false,
@@ -11307,12 +11514,17 @@ var ExuluApp = class {
11307
11514
  ...providers ?? []
11308
11515
  ];
11309
11516
  this._config = config;
11517
+ const transcriptionTools = [];
11518
+ if (process.env.TRANSCRIPTION_MODEL && config?.fileUploads && config?.fileUploads?.s3region && config?.fileUploads?.s3key && config?.fileUploads?.s3secret && config?.fileUploads?.s3Bucket) {
11519
+ transcriptionTools.push(transcribeTool);
11520
+ }
11310
11521
  this._tools = [
11311
11522
  ...tools ?? [],
11312
11523
  ...todoTools,
11313
11524
  ...questionTools,
11314
11525
  ...perplexityTools,
11315
- emailTool
11526
+ emailTool,
11527
+ ...transcriptionTools
11316
11528
  // Because agents are stored in the database, we add those as tools
11317
11529
  // at request time, not during ExuluApp initialization. We add them
11318
11530
  // in the grahql tools resolver.
@@ -14237,9 +14449,9 @@ var MarkdownChunker = class {
14237
14449
  import * as fs4 from "fs";
14238
14450
  import * as path from "path";
14239
14451
  import { generateText as generateText5, Output as Output2 } from "ai";
14240
- import { z as z10 } from "zod";
14452
+ import { z as z11 } from "zod";
14241
14453
  import pLimit from "p-limit";
14242
- import { randomUUID as randomUUID5 } from "crypto";
14454
+ import { randomUUID as randomUUID6 } from "crypto";
14243
14455
  import * as mammoth from "mammoth";
14244
14456
  import TurndownService from "turndown";
14245
14457
  import WordExtractor from "word-extractor";
@@ -14248,17 +14460,17 @@ import { parseOfficeAsync as parseOfficeAsync2 } from "officeparser";
14248
14460
  // src/utils/python-executor.ts
14249
14461
  import { exec as exec3 } from "child_process";
14250
14462
  import { promisify as promisify3 } from "util";
14251
- import { resolve as resolve3, join as join3, dirname as dirname2 } from "path";
14463
+ import { resolve as resolve3, join as join4, dirname as dirname3 } from "path";
14252
14464
  import { existsSync as existsSync5, readFileSync as readFileSync3 } from "fs";
14253
14465
  import { fileURLToPath as fileURLToPath2 } from "url";
14254
14466
  var execAsync3 = promisify3(exec3);
14255
14467
  function getPackageRoot2() {
14256
14468
  const currentFile = fileURLToPath2(import.meta.url);
14257
- let currentDir = dirname2(currentFile);
14469
+ let currentDir = dirname3(currentFile);
14258
14470
  let attempts = 0;
14259
14471
  const maxAttempts = 10;
14260
14472
  while (attempts < maxAttempts) {
14261
- const packageJsonPath = join3(currentDir, "package.json");
14473
+ const packageJsonPath = join4(currentDir, "package.json");
14262
14474
  if (existsSync5(packageJsonPath)) {
14263
14475
  try {
14264
14476
  const packageJson = JSON.parse(readFileSync3(packageJsonPath, "utf-8"));
@@ -14275,7 +14487,7 @@ function getPackageRoot2() {
14275
14487
  currentDir = parentDir;
14276
14488
  attempts++;
14277
14489
  }
14278
- return resolve3(dirname2(fileURLToPath2(import.meta.url)), "../..");
14490
+ return resolve3(dirname3(fileURLToPath2(import.meta.url)), "../..");
14279
14491
  }
14280
14492
  var PythonEnvironmentError = class extends Error {
14281
14493
  constructor(message) {
@@ -14300,7 +14512,7 @@ function getVenvPath2(packageRoot) {
14300
14512
  }
14301
14513
  function getPythonExecutable(packageRoot) {
14302
14514
  const venvPath = getVenvPath2(packageRoot);
14303
- return join3(venvPath, "bin", "python");
14515
+ return join4(venvPath, "bin", "python");
14304
14516
  }
14305
14517
  async function validatePythonEnvironmentForExecution(packageRoot) {
14306
14518
  const validation = await validatePythonEnvironment(packageRoot);
@@ -14589,15 +14801,15 @@ If the page contains a flow-chart, schematic, technical drawing or control board
14589
14801
  const result = await generateText5({
14590
14802
  model,
14591
14803
  output: Output2.object({
14592
- schema: z10.object({
14593
- needs_correction: z10.boolean(),
14594
- corrected_text: z10.string().nullable(),
14595
- current_page_table: z10.object({
14596
- headers: z10.array(z10.string()),
14597
- is_continuation: z10.boolean()
14804
+ schema: z11.object({
14805
+ needs_correction: z11.boolean(),
14806
+ corrected_text: z11.string().nullable(),
14807
+ current_page_table: z11.object({
14808
+ headers: z11.array(z11.string()),
14809
+ is_continuation: z11.boolean()
14598
14810
  }).nullable(),
14599
- confidence: z10.enum(["high", "medium", "low"]),
14600
- reasoning: z10.string()
14811
+ confidence: z11.enum(["high", "medium", "low"]),
14812
+ reasoning: z11.string()
14601
14813
  })
14602
14814
  }),
14603
14815
  messages: [
@@ -14997,7 +15209,7 @@ var loadFile = async (file, name, tempDir) => {
14997
15209
  if (!fileType) {
14998
15210
  throw new Error("[EXULU] File name does not include extension, extension is required for document processing.");
14999
15211
  }
15000
- const UUID = randomUUID5();
15212
+ const UUID = randomUUID6();
15001
15213
  let buffer;
15002
15214
  if (Buffer.isBuffer(file)) {
15003
15215
  filePath = path.join(tempDir, `${UUID}.${fileType}`);
@@ -15027,7 +15239,7 @@ async function documentProcessor({
15027
15239
  if (!license["advanced-document-processing"]) {
15028
15240
  throw new Error("Advanced document processing is an enterprise feature, please add a valid Exulu enterprise license key to use it.");
15029
15241
  }
15030
- const uuid = randomUUID5();
15242
+ const uuid = randomUUID6();
15031
15243
  const tempDir = path.join(process.cwd(), "temp", uuid);
15032
15244
  const localFilesAndFoldersToDelete = [tempDir];
15033
15245
  console.log(`[EXULU] Temporary directory for processing document ${name}: ${tempDir}`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@exulu/backend",
3
3
  "author": "Qventu Bv.",
4
- "version": "1.59.0",
4
+ "version": "1.60.0",
5
5
  "main": "./dist/index.js",
6
6
  "private": false,
7
7
  "publishConfig": {