@exulu/backend 4.1.0 → 4.2.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.
@@ -98,20 +98,24 @@ async function main() {
98
98
  console.log(`${colors.blue}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}`);
99
99
  console.log('');
100
100
 
101
- // Check if already exists (and not forcing)
102
- if (pythonEnvironmentExists() && !force) {
103
- console.log(`${colors.green}✓${colors.reset} Python environment already set up`);
104
- console.log('');
105
- console.log('To rebuild the environment, run:');
106
- console.log(` ${colors.green}npx @exulu/backend setup-python --force${colors.reset}`);
107
- console.log('');
108
- console.log(`${colors.blue}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}`);
109
- console.log('');
110
- return;
111
- }
112
-
113
- if (force && pythonEnvironmentExists()) {
114
- console.log(`${colors.yellow}⚠${colors.reset} Rebuilding Python environment (--force flag detected)`);
101
+ // We deliberately do NOT skip running setup.sh just because the venv
102
+ // directory and a `python` binary already exist (pythonEnvironmentExists()
103
+ // only checks that much, not that requirements.txt is fully installed).
104
+ // ee/python/setup.sh is itself idempotent — it only skips *recreating* the
105
+ // venv, never skips `pip install -r requirements.txt` so always invoking
106
+ // it is cheap when nothing is missing (a few seconds) and is the only way
107
+ // to guarantee a venv created before a new requirement was added (e.g.
108
+ // python-docx, 2026-09-11) actually gets it installed. This used to return
109
+ // early here whenever the venv folder was merely present, which silently
110
+ // left newly-added dependencies uninstalled across any deploy that reused
111
+ // an older venv — see the docx-manipulation skill incident on dx-algi,
112
+ // 2026-09-21.
113
+ if (pythonEnvironmentExists()) {
114
+ console.log(
115
+ force
116
+ ? `${colors.yellow}⚠${colors.reset} Rebuilding Python environment (--force flag detected)`
117
+ : `${colors.blue}ℹ${colors.reset} Python environment directory already exists — verifying and installing any missing dependencies...`,
118
+ );
115
119
  console.log('');
116
120
  }
117
121
 
@@ -1716,7 +1716,7 @@ var ExuluTool = class _ExuluTool {
1716
1716
  if (!agent) {
1717
1717
  throw new Error("Agent not found.");
1718
1718
  }
1719
- const { convertExuluToolsToAiSdkTools: convertExuluToolsToAiSdkTools2 } = await import("./convert-exulu-tools-to-ai-sdk-tools-6RU4IZMI.js");
1719
+ const { convertExuluToolsToAiSdkTools: convertExuluToolsToAiSdkTools2 } = await import("./convert-exulu-tools-to-ai-sdk-tools-AHRL4TN4.js");
1720
1720
  const tools = await convertExuluToolsToAiSdkTools2(
1721
1721
  [this],
1722
1722
  [],
@@ -6308,7 +6308,7 @@ async function downloadKeyIntoSandbox(opts) {
6308
6308
  }
6309
6309
  async function resolvePythonVenvPath() {
6310
6310
  try {
6311
- const { getPythonVenvPath } = await import("./python-setup-DRJ3QX5F.js");
6311
+ const { getPythonVenvPath } = await import("./python-setup-UYSNAW5V.js");
6312
6312
  return getPythonVenvPath();
6313
6313
  } catch (err) {
6314
6314
  console.warn("[SKILLS] Could not resolve the Python venv for the session sandbox; skill scripts fall back to the system python.", err);
@@ -6961,11 +6961,50 @@ import { parseOfficeAsync } from "officeparser";
6961
6961
  // src/templates/tools/document-render-helpers.ts
6962
6962
  import { execFile } from "child_process";
6963
6963
  import { promisify as promisify3 } from "util";
6964
+ import { existsSync as existsSync4 } from "fs";
6964
6965
  import { mkdtemp, readdir as readdir2, readFile, rm as rm2, writeFile as writeFile2 } from "fs/promises";
6965
6966
  import { tmpdir } from "os";
6966
6967
  import { join as join3 } from "path";
6967
6968
  var execFileAsync = promisify3(execFile);
6968
6969
  var MAX_STDOUT_BYTES = 64 * 1024 * 1024;
6970
+ var LEGACY_OFFICE_CONVERSION_TARGETS = {
6971
+ ".doc": "docx",
6972
+ ".xls": "xlsx",
6973
+ ".ppt": "pptx",
6974
+ ".rtf": "docx"
6975
+ };
6976
+ function isLegacyOfficeFormat(ext) {
6977
+ return ext in LEGACY_OFFICE_CONVERSION_TARGETS;
6978
+ }
6979
+ async function convertLegacyOfficeToModern(bytes, ext) {
6980
+ const target = LEGACY_OFFICE_CONVERSION_TARGETS[ext];
6981
+ if (!target) {
6982
+ throw new Error(`No modern-format conversion target registered for "${ext}"`);
6983
+ }
6984
+ const dir = await mkdtemp(join3(tmpdir(), "exulu-officeconv-"));
6985
+ try {
6986
+ const inputPath = join3(dir, `input${ext}`);
6987
+ await writeFile2(inputPath, bytes);
6988
+ try {
6989
+ await execFileAsync(
6990
+ "soffice",
6991
+ ["--headless", "--convert-to", target, inputPath, "--outdir", dir],
6992
+ { timeout: 6e4, maxBuffer: MAX_STDOUT_BYTES }
6993
+ );
6994
+ } catch (err) {
6995
+ throw new Error(
6996
+ `LibreOffice could not convert the legacy "${ext}" file to ${target}: ` + (err instanceof Error ? err.message : String(err))
6997
+ );
6998
+ }
6999
+ const outputPath = join3(dir, `input.${target}`);
7000
+ if (!existsSync4(outputPath)) {
7001
+ throw new Error(`LibreOffice produced no output converting "${ext}" to ${target}`);
7002
+ }
7003
+ return await readFile(outputPath);
7004
+ } finally {
7005
+ await rm2(dir, { recursive: true, force: true });
7006
+ }
7007
+ }
6969
7008
  async function pdfToText(pdf) {
6970
7009
  const dir = await mkdtemp(join3(tmpdir(), "exulu-parse-"));
6971
7010
  try {
@@ -7032,6 +7071,18 @@ var OFFICE_EXTENSIONS = /* @__PURE__ */ new Set([
7032
7071
  ".rtf"
7033
7072
  ]);
7034
7073
  var pagesPattern = /^(\d+)(?:-(\d+))?$/;
7074
+ var BINARY_DOCUMENT_EXTENSION_PATTERN = new RegExp(
7075
+ `([^\\s"'\`]+\\.(?:pdf|${[...OFFICE_EXTENSIONS].map((ext) => ext.slice(1)).join("|")}))\\b`,
7076
+ "i"
7077
+ );
7078
+ function binaryDocumentBashHint(command, stdout, stderr) {
7079
+ const match = command.match(BINARY_DOCUMENT_EXTENSION_PATTERN);
7080
+ if (!match) return void 0;
7081
+ const silent = !stdout.trim() && !stderr.trim();
7082
+ if (!silent && !looksLikeGarbledTextLayer(stdout)) return void 0;
7083
+ const [, file] = match;
7084
+ return `Note: "${file}" is a binary document \u2014 grep/cat/text tools cannot read the text inside it, so a silent or garbled result does not mean the content isn't there. Use parse_document to extract its text first, then search or read within that extracted text instead of the original file.`;
7085
+ }
7035
7086
  var createParseDocumentTool = ({
7036
7087
  sessionID,
7037
7088
  user,
@@ -7101,7 +7152,8 @@ var createParseDocumentTool = ({
7101
7152
  fullText = pageTexts.map((text, i) => ({ page: i + 1, text })).filter(({ page }) => page >= range[0] && page <= range[1]).map(({ page, text }) => `--- page ${page} ---
7102
7153
  ${text.trim()}`).join("\n");
7103
7154
  } else {
7104
- const extracted = await parseOfficeAsync(bytes, {
7155
+ const officeBytes = isLegacyOfficeFormat(ext) ? await convertLegacyOfficeToModern(bytes, ext) : bytes;
7156
+ const extracted = await parseOfficeAsync(officeBytes, {
7105
7157
  outputErrorToConsole: false,
7106
7158
  newlineDelimiter: "\n"
7107
7159
  });
@@ -7156,7 +7208,7 @@ import { extname as extname3 } from "path";
7156
7208
 
7157
7209
  // src/sessions/pdf-preview-cache.ts
7158
7210
  import { exec as exec3 } from "child_process";
7159
- import { existsSync as existsSync4 } from "fs";
7211
+ import { existsSync as existsSync5 } from "fs";
7160
7212
  import { mkdir as mkdir2, readFile as readFile2, rename, rm as rm3, writeFile as writeFile3 } from "fs/promises";
7161
7213
  import { extname as extname2, join as join4 } from "path";
7162
7214
  import { promisify as promisify4 } from "util";
@@ -7181,7 +7233,7 @@ async function getPdfPreviewBytes(opts) {
7181
7233
  throw new PreviewRenderError(`Invalid ETag for ${sourceKey}`);
7182
7234
  }
7183
7235
  const cachedPath = join4(CACHE_ROOT, `${safeEtag}.pdf`);
7184
- if (existsSync4(cachedPath)) {
7236
+ if (existsSync5(cachedPath)) {
7185
7237
  return readFile2(cachedPath);
7186
7238
  }
7187
7239
  const existing = inFlight.get(safeEtag);
@@ -7206,7 +7258,7 @@ async function getPdfPreviewBytes(opts) {
7206
7258
  `LibreOffice conversion failed for ${sourceKey} (etag ${etag}): ${err?.stderr ?? err?.message ?? "unknown error"}`
7207
7259
  );
7208
7260
  }
7209
- if (!existsSync4(outputPath)) {
7261
+ if (!existsSync5(outputPath)) {
7210
7262
  throw new PreviewRenderError(
7211
7263
  `LibreOffice produced no output for ${sourceKey} (etag ${etag})`
7212
7264
  );
@@ -8230,13 +8282,17 @@ var convertExuluToolsToAiSdkTools = async (currentTools, currentSkills, approved
8230
8282
  const origExecute = sharedSessionSandbox.tools.bash.execute;
8231
8283
  if (!origExecute) throw new Error("bash execute is undefined");
8232
8284
  const result = await origExecute(args, opts);
8285
+ const rawStdout = typeof result?.stdout === "string" ? result.stdout : "";
8286
+ const rawStderr = typeof result?.stderr === "string" ? result.stderr : "";
8287
+ const hint = binaryDocumentBashHint(String(args?.command ?? ""), rawStdout, rawStderr);
8288
+ const stderrWithHint = hint ? [rawStderr, hint].filter(Boolean).join("\n\n") : rawStderr;
8233
8289
  return {
8234
8290
  ...result,
8235
8291
  ...typeof result?.stdout === "string" && {
8236
- stdout: truncateToolOutput(result.stdout, budget.contextWindow, "bash", 0.1, toolOutputCharLimit)
8292
+ stdout: truncateToolOutput(rawStdout, budget.contextWindow, "bash", 0.1, toolOutputCharLimit)
8237
8293
  },
8238
- ...typeof result?.stderr === "string" && {
8239
- stderr: truncateToolOutput(result.stderr, budget.contextWindow, "bash stderr", 0.4, toolOutputCharLimit)
8294
+ ...(typeof result?.stderr === "string" || hint) && {
8295
+ stderr: truncateToolOutput(stderrWithHint, budget.contextWindow, "bash stderr", 0.4, toolOutputCharLimit)
8240
8296
  }
8241
8297
  };
8242
8298
  }
@@ -57,15 +57,11 @@ async function setupPythonEnvironment(options = {}) {
57
57
  timeout = 6e5
58
58
  // 10 minutes
59
59
  } = options;
60
- if (!force && isPythonEnvironmentSetup(packageRoot)) {
61
- if (verbose) {
62
- console.log("\u2713 Python environment already set up");
63
- }
64
- return {
65
- success: true,
66
- message: "Python environment already exists",
67
- alreadyExists: true
68
- };
60
+ const alreadyExisted = isPythonEnvironmentSetup(packageRoot);
61
+ if (verbose) {
62
+ console.log(
63
+ alreadyExisted ? "Python environment already exists \u2014 verifying and installing any missing dependencies..." : "Setting up Python environment..."
64
+ );
69
65
  }
70
66
  const setupScriptPath = getSetupScriptPath(packageRoot);
71
67
  if (!existsSync(setupScriptPath)) {
@@ -76,9 +72,6 @@ async function setupPythonEnvironment(options = {}) {
76
72
  };
77
73
  }
78
74
  try {
79
- if (verbose) {
80
- console.log("Setting up Python environment...");
81
- }
82
75
  const { stdout, stderr } = await execAsync(`bash "${setupScriptPath}"`, {
83
76
  cwd: packageRoot,
84
77
  timeout,
@@ -98,8 +91,8 @@ async function setupPythonEnvironment(options = {}) {
98
91
  }
99
92
  return {
100
93
  success: true,
101
- message: "Python environment set up successfully",
102
- alreadyExists: false,
94
+ message: alreadyExisted ? "Python environment verified (dependencies were already installed or have been updated)" : "Python environment set up successfully",
95
+ alreadyExists: alreadyExisted,
103
96
  pythonVersion,
104
97
  output
105
98
  };
@@ -108,7 +101,7 @@ async function setupPythonEnvironment(options = {}) {
108
101
  return {
109
102
  success: false,
110
103
  message: `Setup failed: ${error.message}`,
111
- alreadyExists: false,
104
+ alreadyExists: alreadyExisted,
112
105
  output: errorOutput
113
106
  };
114
107
  }
@@ -171,7 +164,7 @@ async function validatePythonEnvironment(packageRoot, checkPackages = true) {
171
164
  };
172
165
  }
173
166
  if (checkPackages) {
174
- const criticalPackages = ["pypdf", "transformers"];
167
+ const criticalPackages = ["pypdf", "transformers", "docx"];
175
168
  const missingPackages = [];
176
169
  for (const pkg of criticalPackages) {
177
170
  try {
@@ -2,7 +2,7 @@
2
2
  import "dotenv/config";
3
3
  import {
4
4
  getPackageRoot
5
- } from "../chunk-QMN6MVHQ.js";
5
+ } from "../chunk-JIZCKSJO.js";
6
6
 
7
7
  // src/cli/start-whisper.ts
8
8
  import "dotenv/config";
@@ -2,7 +2,7 @@ import "dotenv/config";
2
2
  import {
3
3
  convertExuluToolsToAiSdkTools,
4
4
  hydrateVariables
5
- } from "./chunk-RBEWHG7I.js";
5
+ } from "./chunk-D4PCOOL5.js";
6
6
  import "./chunk-4PDWNVNT.js";
7
7
  export {
8
8
  convertExuluToolsToAiSdkTools,
package/dist/index.cjs CHANGED
@@ -3950,15 +3950,11 @@ async function setupPythonEnvironment(options = {}) {
3950
3950
  timeout = 6e5
3951
3951
  // 10 minutes
3952
3952
  } = options;
3953
- if (!force && isPythonEnvironmentSetup(packageRoot)) {
3954
- if (verbose) {
3955
- console.log("\u2713 Python environment already set up");
3956
- }
3957
- return {
3958
- success: true,
3959
- message: "Python environment already exists",
3960
- alreadyExists: true
3961
- };
3953
+ const alreadyExisted = isPythonEnvironmentSetup(packageRoot);
3954
+ if (verbose) {
3955
+ console.log(
3956
+ alreadyExisted ? "Python environment already exists \u2014 verifying and installing any missing dependencies..." : "Setting up Python environment..."
3957
+ );
3962
3958
  }
3963
3959
  const setupScriptPath = getSetupScriptPath(packageRoot);
3964
3960
  if (!(0, import_fs.existsSync)(setupScriptPath)) {
@@ -3969,9 +3965,6 @@ async function setupPythonEnvironment(options = {}) {
3969
3965
  };
3970
3966
  }
3971
3967
  try {
3972
- if (verbose) {
3973
- console.log("Setting up Python environment...");
3974
- }
3975
3968
  const { stdout, stderr } = await execAsync2(`bash "${setupScriptPath}"`, {
3976
3969
  cwd: packageRoot,
3977
3970
  timeout,
@@ -3991,8 +3984,8 @@ async function setupPythonEnvironment(options = {}) {
3991
3984
  }
3992
3985
  return {
3993
3986
  success: true,
3994
- message: "Python environment set up successfully",
3995
- alreadyExists: false,
3987
+ message: alreadyExisted ? "Python environment verified (dependencies were already installed or have been updated)" : "Python environment set up successfully",
3988
+ alreadyExists: alreadyExisted,
3996
3989
  pythonVersion,
3997
3990
  output
3998
3991
  };
@@ -4001,7 +3994,7 @@ async function setupPythonEnvironment(options = {}) {
4001
3994
  return {
4002
3995
  success: false,
4003
3996
  message: `Setup failed: ${error.message}`,
4004
- alreadyExists: false,
3997
+ alreadyExists: alreadyExisted,
4005
3998
  output: errorOutput
4006
3999
  };
4007
4000
  }
@@ -4064,7 +4057,7 @@ async function validatePythonEnvironment(packageRoot, checkPackages = true) {
4064
4057
  };
4065
4058
  }
4066
4059
  if (checkPackages) {
4067
- const criticalPackages = ["pypdf", "transformers"];
4060
+ const criticalPackages = ["pypdf", "transformers", "docx"];
4068
4061
  const missingPackages = [];
4069
4062
  for (const pkg of criticalPackages) {
4070
4063
  try {
@@ -5013,6 +5006,38 @@ var init_session_file_read_tool = __esm({
5013
5006
  });
5014
5007
 
5015
5008
  // src/templates/tools/document-render-helpers.ts
5009
+ function isLegacyOfficeFormat(ext) {
5010
+ return ext in LEGACY_OFFICE_CONVERSION_TARGETS;
5011
+ }
5012
+ async function convertLegacyOfficeToModern(bytes, ext) {
5013
+ const target = LEGACY_OFFICE_CONVERSION_TARGETS[ext];
5014
+ if (!target) {
5015
+ throw new Error(`No modern-format conversion target registered for "${ext}"`);
5016
+ }
5017
+ const dir = await (0, import_promises2.mkdtemp)((0, import_node_path5.join)((0, import_node_os.tmpdir)(), "exulu-officeconv-"));
5018
+ try {
5019
+ const inputPath = (0, import_node_path5.join)(dir, `input${ext}`);
5020
+ await (0, import_promises2.writeFile)(inputPath, bytes);
5021
+ try {
5022
+ await execFileAsync(
5023
+ "soffice",
5024
+ ["--headless", "--convert-to", target, inputPath, "--outdir", dir],
5025
+ { timeout: 6e4, maxBuffer: MAX_STDOUT_BYTES }
5026
+ );
5027
+ } catch (err) {
5028
+ throw new Error(
5029
+ `LibreOffice could not convert the legacy "${ext}" file to ${target}: ` + (err instanceof Error ? err.message : String(err))
5030
+ );
5031
+ }
5032
+ const outputPath = (0, import_node_path5.join)(dir, `input.${target}`);
5033
+ if (!(0, import_node_fs6.existsSync)(outputPath)) {
5034
+ throw new Error(`LibreOffice produced no output converting "${ext}" to ${target}`);
5035
+ }
5036
+ return await (0, import_promises2.readFile)(outputPath);
5037
+ } finally {
5038
+ await (0, import_promises2.rm)(dir, { recursive: true, force: true });
5039
+ }
5040
+ }
5016
5041
  async function pdfToText(pdf) {
5017
5042
  const dir = await (0, import_promises2.mkdtemp)((0, import_node_path5.join)((0, import_node_os.tmpdir)(), "exulu-parse-"));
5018
5043
  try {
@@ -5049,18 +5074,25 @@ async function renderPdfPageToPng(pdf, page, scaleTo) {
5049
5074
  await (0, import_promises2.rm)(dir, { recursive: true, force: true });
5050
5075
  }
5051
5076
  }
5052
- var import_node_child_process4, import_node_util3, import_promises2, import_node_os, import_node_path5, execFileAsync, MAX_STDOUT_BYTES;
5077
+ var import_node_child_process4, import_node_util3, import_node_fs6, import_promises2, import_node_os, import_node_path5, execFileAsync, MAX_STDOUT_BYTES, LEGACY_OFFICE_CONVERSION_TARGETS;
5053
5078
  var init_document_render_helpers = __esm({
5054
5079
  "src/templates/tools/document-render-helpers.ts"() {
5055
5080
  "use strict";
5056
5081
  init_cjs_shims();
5057
5082
  import_node_child_process4 = require("child_process");
5058
5083
  import_node_util3 = require("util");
5084
+ import_node_fs6 = require("fs");
5059
5085
  import_promises2 = require("fs/promises");
5060
5086
  import_node_os = require("os");
5061
5087
  import_node_path5 = require("path");
5062
5088
  execFileAsync = (0, import_node_util3.promisify)(import_node_child_process4.execFile);
5063
5089
  MAX_STDOUT_BYTES = 64 * 1024 * 1024;
5090
+ LEGACY_OFFICE_CONVERSION_TARGETS = {
5091
+ ".doc": "docx",
5092
+ ".xls": "xlsx",
5093
+ ".ppt": "pptx",
5094
+ ".rtf": "docx"
5095
+ };
5064
5096
  }
5065
5097
  });
5066
5098
 
@@ -5077,7 +5109,15 @@ function looksLikeGarbledTextLayer(text) {
5077
5109
  if (visible < 40) return false;
5078
5110
  return control / visible > 0.01;
5079
5111
  }
5080
- var import_zod8, import_node_path6, import_officeparser, DEFAULT_LIMIT2, MAX_CONTENT_CHARS2, MIN_CHARS_PER_PAGE, OFFICE_EXTENSIONS, pagesPattern, createParseDocumentTool;
5112
+ function binaryDocumentBashHint(command, stdout, stderr) {
5113
+ const match = command.match(BINARY_DOCUMENT_EXTENSION_PATTERN);
5114
+ if (!match) return void 0;
5115
+ const silent = !stdout.trim() && !stderr.trim();
5116
+ if (!silent && !looksLikeGarbledTextLayer(stdout)) return void 0;
5117
+ const [, file] = match;
5118
+ return `Note: "${file}" is a binary document \u2014 grep/cat/text tools cannot read the text inside it, so a silent or garbled result does not mean the content isn't there. Use parse_document to extract its text first, then search or read within that extracted text instead of the original file.`;
5119
+ }
5120
+ var import_zod8, import_node_path6, import_officeparser, DEFAULT_LIMIT2, MAX_CONTENT_CHARS2, MIN_CHARS_PER_PAGE, OFFICE_EXTENSIONS, pagesPattern, BINARY_DOCUMENT_EXTENSION_PATTERN, createParseDocumentTool;
5081
5121
  var init_parse_document_tool = __esm({
5082
5122
  "src/templates/tools/parse-document-tool.ts"() {
5083
5123
  "use strict";
@@ -5105,6 +5145,10 @@ var init_parse_document_tool = __esm({
5105
5145
  ".rtf"
5106
5146
  ]);
5107
5147
  pagesPattern = /^(\d+)(?:-(\d+))?$/;
5148
+ BINARY_DOCUMENT_EXTENSION_PATTERN = new RegExp(
5149
+ `([^\\s"'\`]+\\.(?:pdf|${[...OFFICE_EXTENSIONS].map((ext) => ext.slice(1)).join("|")}))\\b`,
5150
+ "i"
5151
+ );
5108
5152
  createParseDocumentTool = ({
5109
5153
  sessionID,
5110
5154
  user,
@@ -5174,7 +5218,8 @@ var init_parse_document_tool = __esm({
5174
5218
  fullText = pageTexts.map((text, i) => ({ page: i + 1, text })).filter(({ page }) => page >= range[0] && page <= range[1]).map(({ page, text }) => `--- page ${page} ---
5175
5219
  ${text.trim()}`).join("\n");
5176
5220
  } else {
5177
- const extracted = await (0, import_officeparser.parseOfficeAsync)(bytes, {
5221
+ const officeBytes = isLegacyOfficeFormat(ext) ? await convertLegacyOfficeToModern(bytes, ext) : bytes;
5222
+ const extracted = await (0, import_officeparser.parseOfficeAsync)(officeBytes, {
5178
5223
  outputErrorToConsole: false,
5179
5224
  newlineDelimiter: "\n"
5180
5225
  });
@@ -5236,7 +5281,7 @@ async function getPdfPreviewBytes(opts) {
5236
5281
  throw new PreviewRenderError(`Invalid ETag for ${sourceKey}`);
5237
5282
  }
5238
5283
  const cachedPath = (0, import_node_path7.join)(CACHE_ROOT, `${safeEtag}.pdf`);
5239
- if ((0, import_node_fs6.existsSync)(cachedPath)) {
5284
+ if ((0, import_node_fs7.existsSync)(cachedPath)) {
5240
5285
  return (0, import_promises3.readFile)(cachedPath);
5241
5286
  }
5242
5287
  const existing = inFlight.get(safeEtag);
@@ -5261,7 +5306,7 @@ async function getPdfPreviewBytes(opts) {
5261
5306
  `LibreOffice conversion failed for ${sourceKey} (etag ${etag}): ${err?.stderr ?? err?.message ?? "unknown error"}`
5262
5307
  );
5263
5308
  }
5264
- if (!(0, import_node_fs6.existsSync)(outputPath)) {
5309
+ if (!(0, import_node_fs7.existsSync)(outputPath)) {
5265
5310
  throw new PreviewRenderError(
5266
5311
  `LibreOffice produced no output for ${sourceKey} (etag ${etag})`
5267
5312
  );
@@ -5278,13 +5323,13 @@ async function getPdfPreviewBytes(opts) {
5278
5323
  inFlight.set(safeEtag, promise);
5279
5324
  return promise;
5280
5325
  }
5281
- var import_node_child_process5, import_node_fs6, import_promises3, import_node_path7, import_node_util4, execAsync4, CACHE_ROOT, CACHE_IN, CACHE_OUT, inFlight, PreviewRenderError;
5326
+ var import_node_child_process5, import_node_fs7, import_promises3, import_node_path7, import_node_util4, execAsync4, CACHE_ROOT, CACHE_IN, CACHE_OUT, inFlight, PreviewRenderError;
5282
5327
  var init_pdf_preview_cache = __esm({
5283
5328
  "src/sessions/pdf-preview-cache.ts"() {
5284
5329
  "use strict";
5285
5330
  init_cjs_shims();
5286
5331
  import_node_child_process5 = require("child_process");
5287
- import_node_fs6 = require("fs");
5332
+ import_node_fs7 = require("fs");
5288
5333
  import_promises3 = require("fs/promises");
5289
5334
  import_node_path7 = require("path");
5290
5335
  import_node_util4 = require("util");
@@ -6540,13 +6585,17 @@ var init_convert_exulu_tools_to_ai_sdk_tools = __esm({
6540
6585
  const origExecute = sharedSessionSandbox.tools.bash.execute;
6541
6586
  if (!origExecute) throw new Error("bash execute is undefined");
6542
6587
  const result = await origExecute(args, opts);
6588
+ const rawStdout = typeof result?.stdout === "string" ? result.stdout : "";
6589
+ const rawStderr = typeof result?.stderr === "string" ? result.stderr : "";
6590
+ const hint = binaryDocumentBashHint(String(args?.command ?? ""), rawStdout, rawStderr);
6591
+ const stderrWithHint = hint ? [rawStderr, hint].filter(Boolean).join("\n\n") : rawStderr;
6543
6592
  return {
6544
6593
  ...result,
6545
6594
  ...typeof result?.stdout === "string" && {
6546
- stdout: truncateToolOutput(result.stdout, budget.contextWindow, "bash", 0.1, toolOutputCharLimit)
6595
+ stdout: truncateToolOutput(rawStdout, budget.contextWindow, "bash", 0.1, toolOutputCharLimit)
6547
6596
  },
6548
- ...typeof result?.stderr === "string" && {
6549
- stderr: truncateToolOutput(result.stderr, budget.contextWindow, "bash stderr", 0.4, toolOutputCharLimit)
6597
+ ...(typeof result?.stderr === "string" || hint) && {
6598
+ stderr: truncateToolOutput(stderrWithHint, budget.contextWindow, "bash stderr", 0.4, toolOutputCharLimit)
6550
6599
  }
6551
6600
  };
6552
6601
  }
@@ -12365,7 +12414,12 @@ var transcriptionJobsSchema = {
12365
12414
  // Selected per-meeting post-processing: [{ prompt_id, agent_id }].
12366
12415
  { name: "post_processing_prompts", type: "json" },
12367
12416
  // Results: [{ prompt_id, agent_id, prompt_name, status, output, error, ran_at }].
12368
- { name: "post_processing_outputs", type: "json" }
12417
+ { name: "post_processing_outputs", type: "json" },
12418
+ // Permanent local copy of the meeting's mixed MP4, downloaded from Recall
12419
+ // when RECALL_STORE_VIDEO_LOCALLY=true. Null when that flag is off (the
12420
+ // video then stays reachable only via ExuluRecall.getRecordingVideoUrl,
12421
+ // for as long as Recall itself retains it) or for Whisper upload jobs.
12422
+ { name: "video", type: "file" }
12369
12423
  ]
12370
12424
  };
12371
12425
  var imageGenerationsSchema = {
@@ -20847,6 +20901,9 @@ var buildTranscriptItemInput = ({
20847
20901
  post_processing: row.post_processing_outputs ?? void 0,
20848
20902
  // Handle for the meeting video; null for Whisper uploads.
20849
20903
  recall_recording_id: row.recall_recording_id ?? void 0,
20904
+ // Permanent local copy of the video, only present when
20905
+ // RECALL_STORE_VIDEO_LOCALLY was on at recording time.
20906
+ video_s3key: row.video_s3key ?? void 0,
20850
20907
  rights_mode: rightsMode,
20851
20908
  created_by: row.created_by
20852
20909
  });
@@ -21182,6 +21239,12 @@ var recordingMonthlyLimitSeconds = () => {
21182
21239
  const minutes = Number(raw);
21183
21240
  return Number.isFinite(minutes) && minutes > 0 ? minutes * 60 : null;
21184
21241
  };
21242
+ var RECALL_RECORDING_RETENTION_DEFAULT_HOURS = 2160;
21243
+ var recallRecordingRetentionHours = () => {
21244
+ const raw = Number(process.env.RECALL_RECORDING_RETENTION_HOURS);
21245
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : RECALL_RECORDING_RETENTION_DEFAULT_HOURS;
21246
+ };
21247
+ var recallStoreVideoLocally = () => process.env.RECALL_STORE_VIDEO_LOCALLY === "true";
21185
21248
  var recallWebhookUrl = () => {
21186
21249
  const base = recallPublicBaseUrl();
21187
21250
  return base ? `${base}/recall/webhooks` : null;
@@ -21296,7 +21359,6 @@ var recordingDurationSeconds = (rec) => {
21296
21359
  }
21297
21360
  return null;
21298
21361
  };
21299
- var RECORDING_RETENTION_HOURS = 2160;
21300
21362
  var buildCreateBotPayload = (input) => ({
21301
21363
  meeting_url: input.meeting_url,
21302
21364
  join_at: input.join_at,
@@ -21306,7 +21368,7 @@ var buildCreateBotPayload = (input) => ({
21306
21368
  video_mixed_layout: "speaker_view",
21307
21369
  participant_events: {},
21308
21370
  meeting_metadata: {},
21309
- retention: { type: "timed", hours: RECORDING_RETENTION_HOURS }
21371
+ retention: { type: "timed", hours: recallRecordingRetentionHours() }
21310
21372
  },
21311
21373
  ...input.notifyChat ? {
21312
21374
  chat: {
@@ -21368,6 +21430,31 @@ var recallClient = {
21368
21430
  }
21369
21431
  };
21370
21432
 
21433
+ // src/exulu/recall/video-storage.ts
21434
+ init_cjs_shims();
21435
+ init_uppy();
21436
+ async function downloadAndStoreRecordingVideo(recording, jobId, config) {
21437
+ const video = recording.media_shortcuts?.video_mixed;
21438
+ const url = video?.data?.download_url;
21439
+ if (!url) return null;
21440
+ if (video.status?.code && video.status.code !== "done") return null;
21441
+ const res = await fetch(url);
21442
+ if (!res.ok) {
21443
+ throw new Error(`video download failed for job ${jobId}: HTTP ${res.status}`);
21444
+ }
21445
+ const bytes = Buffer.from(await res.arrayBuffer());
21446
+ return uploadFile(
21447
+ bytes,
21448
+ `recall-videos/${jobId}.mp4`,
21449
+ config,
21450
+ { contentType: "video/mp4" },
21451
+ void 0,
21452
+ void 0,
21453
+ true
21454
+ // global: this is a system-stored artifact, not scoped to an uploading user.
21455
+ );
21456
+ }
21457
+
21371
21458
  // src/exulu/recall/transcript-map.ts
21372
21459
  init_cjs_shims();
21373
21460
  var speakerLabel = (participant) => {
@@ -21605,12 +21692,24 @@ var recallService = {
21605
21692
  const raw = await recallClient.downloadTranscript(url);
21606
21693
  const segments = mapRecallTranscript(raw);
21607
21694
  let duration = durationFromSegments(segments);
21695
+ let videoS3Key = null;
21608
21696
  const recId = recordingId ?? job.recall_recording_id;
21609
21697
  if (recId) {
21610
21698
  try {
21611
21699
  const rec = await recallClient.retrieveRecording(recId);
21612
21700
  const recDuration = recordingDurationSeconds(rec);
21613
21701
  if (recDuration != null) duration = recDuration;
21702
+ if (recallStoreVideoLocally()) {
21703
+ try {
21704
+ videoS3Key = await downloadAndStoreRecordingVideo(
21705
+ rec,
21706
+ jobId,
21707
+ exuluApp.get().config
21708
+ );
21709
+ } catch (err) {
21710
+ log4(`could not store video locally for job ${jobId}: ${err.message}`);
21711
+ }
21712
+ }
21614
21713
  } catch (err) {
21615
21714
  log4(`could not fetch recording duration for job ${jobId}: ${err.message}`);
21616
21715
  }
@@ -21619,6 +21718,7 @@ var recallService = {
21619
21718
  recall_transcript_id: transcriptId ?? job.recall_transcript_id ?? null,
21620
21719
  raw_segments: JSON.stringify(segments),
21621
21720
  duration_seconds: duration,
21721
+ ...videoS3Key ? { video_s3key: videoS3Key } : {},
21622
21722
  status: "awaiting_review"
21623
21723
  });
21624
21724
  } catch (err) {
@@ -22948,6 +23048,9 @@ type PageInfo {
22948
23048
  typeDefs += `
22949
23049
  meetingRecordingUsage: MeetingRecordingUsage
22950
23050
  `;
23051
+ typeDefs += `
23052
+ recordingVideoUrl(job_id: ID!): String
23053
+ `;
22951
23054
  typeDefs += `
22952
23055
  workflowTriggers(workflow: ID!): [WorkflowTrigger!]!
22953
23056
  `;
@@ -23894,6 +23997,13 @@ type LiteLLMModel {
23894
23997
  if (!context.user) throw new Error("Authentication required");
23895
23998
  return recallService.getUsage();
23896
23999
  };
24000
+ resolvers.Query["recordingVideoUrl"] = async (_, args, context) => {
24001
+ await assertOwnsTranscriptionJob(args.job_id, context);
24002
+ const { db: db2 } = context;
24003
+ const row = await db2.from("transcription_jobs").select(["recall_recording_id"]).where({ id: args.job_id }).first();
24004
+ if (!row?.recall_recording_id) return null;
24005
+ return recallService.getRecordingVideoUrl(row.recall_recording_id);
24006
+ };
23897
24007
  resolvers.Query["evals"] = async (_, args, context, info) => {
23898
24008
  const requestedFields = getRequestedFields(info);
23899
24009
  return {
@@ -25254,7 +25364,7 @@ async function editImage(args) {
25254
25364
 
25255
25365
  // src/exulu/litellm/parse-image-models.ts
25256
25366
  init_cjs_shims();
25257
- var import_node_fs7 = require("fs");
25367
+ var import_node_fs8 = require("fs");
25258
25368
  var stripComment3 = (line) => {
25259
25369
  const idx = line.indexOf("#");
25260
25370
  return idx >= 0 ? line.slice(0, idx) : line;
@@ -25277,8 +25387,8 @@ var parseInt103 = (raw) => {
25277
25387
  return Number.isInteger(n) ? n : void 0;
25278
25388
  };
25279
25389
  var parseImageGenerationModels = (configPath) => {
25280
- if (!(0, import_node_fs7.existsSync)(configPath)) return [];
25281
- const text = (0, import_node_fs7.readFileSync)(configPath, "utf8");
25390
+ if (!(0, import_node_fs8.existsSync)(configPath)) return [];
25391
+ const text = (0, import_node_fs8.readFileSync)(configPath, "utf8");
25282
25392
  const lines = text.split("\n");
25283
25393
  const entries = [];
25284
25394
  let current;
@@ -31094,7 +31204,10 @@ var transcriptionsContext = new ExuluContext2({
31094
31204
  // Link back to the Recall recording so the mixed video stays reachable
31095
31205
  // (resolve a fresh URL via ExuluRecall.getRecordingVideoUrl — it expires
31096
31206
  // after six hours). Null for Whisper uploads.
31097
- { name: "recall_recording_id", type: "text" }
31207
+ { name: "recall_recording_id", type: "text" },
31208
+ // Permanent local copy of the mixed video, present only when
31209
+ // RECALL_STORE_VIDEO_LOCALLY=true was set at recording time.
31210
+ { name: "video", type: "file" }
31098
31211
  ],
31099
31212
  sources: [],
31100
31213
  active: true,
@@ -31527,6 +31640,11 @@ var ExuluApp = class {
31527
31640
  get audit() {
31528
31641
  return this._audit ?? getAuditLogger(this._config ?? {});
31529
31642
  }
31643
+ /** The deployment's ExuluConfig, for background/webhook code paths that
31644
+ * have no request to read it off (e.g. the Recall webhook handler). */
31645
+ get config() {
31646
+ return this._config ?? {};
31647
+ }
31530
31648
  embeddings = {
31531
31649
  generate: {
31532
31650
  one: async ({ context: contextId, item: itemId }) => {
@@ -32782,17 +32900,17 @@ init_cjs_shims();
32782
32900
 
32783
32901
  // src/exulu/litellm/db-init.ts
32784
32902
  init_cjs_shims();
32785
- var import_node_fs9 = require("fs");
32903
+ var import_node_fs10 = require("fs");
32786
32904
  var import_node_path11 = require("path");
32787
32905
  var import_node_child_process6 = require("child_process");
32788
32906
  var import_pg = require("pg");
32789
32907
 
32790
32908
  // src/exulu/litellm/db-setup-check.ts
32791
32909
  init_cjs_shims();
32792
- var import_node_fs8 = require("fs");
32910
+ var import_node_fs9 = require("fs");
32793
32911
  var readLiteLLMDatabaseUrl = (configPath) => {
32794
- if (!(0, import_node_fs8.existsSync)(configPath)) return void 0;
32795
- const text = (0, import_node_fs8.readFileSync)(configPath, "utf8");
32912
+ if (!(0, import_node_fs9.existsSync)(configPath)) return void 0;
32913
+ const text = (0, import_node_fs9.readFileSync)(configPath, "utf8");
32796
32914
  const match = text.match(
32797
32915
  /^\s*database_url:\s*["']?([^"'\n#]+?)["']?\s*(#.*)?$/m
32798
32916
  );
@@ -32994,7 +33112,7 @@ var initLiteLLMDatabase = async (packageRoot) => {
32994
33112
  const venvBin = (0, import_node_path11.resolve)(packageRoot, "ee/python/.venv/bin");
32995
33113
  const prismaCli = (0, import_node_path11.resolve)(venvBin, "prisma");
32996
33114
  const venvLibDir = (0, import_node_path11.resolve)(packageRoot, "ee/python/.venv/lib");
32997
- const pythonVersionDir = (0, import_node_fs9.existsSync)(venvLibDir) ? (0, import_node_fs9.readdirSync)(venvLibDir).find((entry) => /^python3\.\d+$/.test(entry)) : void 0;
33115
+ const pythonVersionDir = (0, import_node_fs10.existsSync)(venvLibDir) ? (0, import_node_fs10.readdirSync)(venvLibDir).find((entry) => /^python3\.\d+$/.test(entry)) : void 0;
32998
33116
  if (!pythonVersionDir) {
32999
33117
  warn2([
33000
33118
  `Could not find a python3.* directory under ${venvLibDir}.`,
@@ -33009,7 +33127,7 @@ var initLiteLLMDatabase = async (packageRoot) => {
33009
33127
  "site-packages/litellm/proxy"
33010
33128
  );
33011
33129
  const schemaPath = (0, import_node_path11.resolve)(litellmProxyDir, "schema.prisma");
33012
- if (!(0, import_node_fs9.existsSync)(prismaCli)) {
33130
+ if (!(0, import_node_fs10.existsSync)(prismaCli)) {
33013
33131
  warn2([
33014
33132
  `Prisma CLI not found at ${prismaCli}.`,
33015
33133
  `Run \`npm run python:setup\` to create the venv and install prisma.`,
@@ -33017,7 +33135,7 @@ var initLiteLLMDatabase = async (packageRoot) => {
33017
33135
  ]);
33018
33136
  return;
33019
33137
  }
33020
- if (!(0, import_node_fs9.existsSync)(schemaPath)) {
33138
+ if (!(0, import_node_fs10.existsSync)(schemaPath)) {
33021
33139
  warn2([
33022
33140
  `LiteLLM Prisma schema not found at ${schemaPath}.`,
33023
33141
  `Re-run \`npm run python:setup\`. Skipping LiteLLM database setup.`
package/dist/index.d.cts CHANGED
@@ -1167,6 +1167,9 @@ declare class ExuluApp {
1167
1167
  context(id: string): ExuluContext | undefined;
1168
1168
  get contexts(): ExuluContext[];
1169
1169
  get audit(): AuditLogger;
1170
+ /** The deployment's ExuluConfig, for background/webhook code paths that
1171
+ * have no request to read it off (e.g. the Recall webhook handler). */
1172
+ get config(): ExuluConfig;
1170
1173
  embeddings: {
1171
1174
  generate: {
1172
1175
  one: ({ context: contextId, item: itemId }: {
package/dist/index.d.ts CHANGED
@@ -1167,6 +1167,9 @@ declare class ExuluApp {
1167
1167
  context(id: string): ExuluContext | undefined;
1168
1168
  get contexts(): ExuluContext[];
1169
1169
  get audit(): AuditLogger;
1170
+ /** The deployment's ExuluConfig, for background/webhook code paths that
1171
+ * have no request to read it off (e.g. the Recall webhook handler). */
1172
+ get config(): ExuluConfig;
1170
1173
  embeddings: {
1171
1174
  generate: {
1172
1175
  one: ({ context: contextId, item: itemId }: {
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  isPythonEnvironmentSetup,
6
6
  setupPythonEnvironment,
7
7
  validatePythonEnvironment
8
- } from "./chunk-QMN6MVHQ.js";
8
+ } from "./chunk-JIZCKSJO.js";
9
9
  import {
10
10
  COMPACTION_INSUFFICIENT,
11
11
  ContextCompactionRequiredError,
@@ -90,7 +90,7 @@ import {
90
90
  verifyCredentialNonce,
91
91
  waitForLiteLLMReady,
92
92
  withRetry
93
- } from "./chunk-RBEWHG7I.js";
93
+ } from "./chunk-D4PCOOL5.js";
94
94
  import {
95
95
  LiteLLMAdminError,
96
96
  findLiteLLMModel,
@@ -2880,7 +2880,12 @@ var transcriptionJobsSchema = {
2880
2880
  // Selected per-meeting post-processing: [{ prompt_id, agent_id }].
2881
2881
  { name: "post_processing_prompts", type: "json" },
2882
2882
  // Results: [{ prompt_id, agent_id, prompt_name, status, output, error, ran_at }].
2883
- { name: "post_processing_outputs", type: "json" }
2883
+ { name: "post_processing_outputs", type: "json" },
2884
+ // Permanent local copy of the meeting's mixed MP4, downloaded from Recall
2885
+ // when RECALL_STORE_VIDEO_LOCALLY=true. Null when that flag is off (the
2886
+ // video then stays reachable only via ExuluRecall.getRecordingVideoUrl,
2887
+ // for as long as Recall itself retains it) or for Whisper upload jobs.
2888
+ { name: "video", type: "file" }
2884
2889
  ]
2885
2890
  };
2886
2891
  var imageGenerationsSchema = {
@@ -11202,6 +11207,9 @@ var buildTranscriptItemInput = ({
11202
11207
  post_processing: row.post_processing_outputs ?? void 0,
11203
11208
  // Handle for the meeting video; null for Whisper uploads.
11204
11209
  recall_recording_id: row.recall_recording_id ?? void 0,
11210
+ // Permanent local copy of the video, only present when
11211
+ // RECALL_STORE_VIDEO_LOCALLY was on at recording time.
11212
+ video_s3key: row.video_s3key ?? void 0,
11205
11213
  rights_mode: rightsMode,
11206
11214
  created_by: row.created_by
11207
11215
  });
@@ -11529,6 +11537,12 @@ var recordingMonthlyLimitSeconds = () => {
11529
11537
  const minutes = Number(raw);
11530
11538
  return Number.isFinite(minutes) && minutes > 0 ? minutes * 60 : null;
11531
11539
  };
11540
+ var RECALL_RECORDING_RETENTION_DEFAULT_HOURS = 2160;
11541
+ var recallRecordingRetentionHours = () => {
11542
+ const raw = Number(process.env.RECALL_RECORDING_RETENTION_HOURS);
11543
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : RECALL_RECORDING_RETENTION_DEFAULT_HOURS;
11544
+ };
11545
+ var recallStoreVideoLocally = () => process.env.RECALL_STORE_VIDEO_LOCALLY === "true";
11532
11546
  var recallWebhookUrl = () => {
11533
11547
  const base = recallPublicBaseUrl();
11534
11548
  return base ? `${base}/recall/webhooks` : null;
@@ -11643,7 +11657,6 @@ var recordingDurationSeconds = (rec) => {
11643
11657
  }
11644
11658
  return null;
11645
11659
  };
11646
- var RECORDING_RETENTION_HOURS = 2160;
11647
11660
  var buildCreateBotPayload = (input) => ({
11648
11661
  meeting_url: input.meeting_url,
11649
11662
  join_at: input.join_at,
@@ -11653,7 +11666,7 @@ var buildCreateBotPayload = (input) => ({
11653
11666
  video_mixed_layout: "speaker_view",
11654
11667
  participant_events: {},
11655
11668
  meeting_metadata: {},
11656
- retention: { type: "timed", hours: RECORDING_RETENTION_HOURS }
11669
+ retention: { type: "timed", hours: recallRecordingRetentionHours() }
11657
11670
  },
11658
11671
  ...input.notifyChat ? {
11659
11672
  chat: {
@@ -11715,6 +11728,29 @@ var recallClient = {
11715
11728
  }
11716
11729
  };
11717
11730
 
11731
+ // src/exulu/recall/video-storage.ts
11732
+ async function downloadAndStoreRecordingVideo(recording, jobId, config) {
11733
+ const video = recording.media_shortcuts?.video_mixed;
11734
+ const url = video?.data?.download_url;
11735
+ if (!url) return null;
11736
+ if (video.status?.code && video.status.code !== "done") return null;
11737
+ const res = await fetch(url);
11738
+ if (!res.ok) {
11739
+ throw new Error(`video download failed for job ${jobId}: HTTP ${res.status}`);
11740
+ }
11741
+ const bytes = Buffer.from(await res.arrayBuffer());
11742
+ return uploadFile(
11743
+ bytes,
11744
+ `recall-videos/${jobId}.mp4`,
11745
+ config,
11746
+ { contentType: "video/mp4" },
11747
+ void 0,
11748
+ void 0,
11749
+ true
11750
+ // global: this is a system-stored artifact, not scoped to an uploading user.
11751
+ );
11752
+ }
11753
+
11718
11754
  // src/exulu/recall/transcript-map.ts
11719
11755
  var speakerLabel = (participant) => {
11720
11756
  if (!participant) return "unknown";
@@ -11951,12 +11987,24 @@ var recallService = {
11951
11987
  const raw = await recallClient.downloadTranscript(url);
11952
11988
  const segments = mapRecallTranscript(raw);
11953
11989
  let duration = durationFromSegments(segments);
11990
+ let videoS3Key = null;
11954
11991
  const recId = recordingId ?? job.recall_recording_id;
11955
11992
  if (recId) {
11956
11993
  try {
11957
11994
  const rec = await recallClient.retrieveRecording(recId);
11958
11995
  const recDuration = recordingDurationSeconds(rec);
11959
11996
  if (recDuration != null) duration = recDuration;
11997
+ if (recallStoreVideoLocally()) {
11998
+ try {
11999
+ videoS3Key = await downloadAndStoreRecordingVideo(
12000
+ rec,
12001
+ jobId,
12002
+ exuluApp.get().config
12003
+ );
12004
+ } catch (err) {
12005
+ log3(`could not store video locally for job ${jobId}: ${err.message}`);
12006
+ }
12007
+ }
11960
12008
  } catch (err) {
11961
12009
  log3(`could not fetch recording duration for job ${jobId}: ${err.message}`);
11962
12010
  }
@@ -11965,6 +12013,7 @@ var recallService = {
11965
12013
  recall_transcript_id: transcriptId ?? job.recall_transcript_id ?? null,
11966
12014
  raw_segments: JSON.stringify(segments),
11967
12015
  duration_seconds: duration,
12016
+ ...videoS3Key ? { video_s3key: videoS3Key } : {},
11968
12017
  status: "awaiting_review"
11969
12018
  });
11970
12019
  } catch (err) {
@@ -13286,6 +13335,9 @@ type PageInfo {
13286
13335
  typeDefs += `
13287
13336
  meetingRecordingUsage: MeetingRecordingUsage
13288
13337
  `;
13338
+ typeDefs += `
13339
+ recordingVideoUrl(job_id: ID!): String
13340
+ `;
13289
13341
  typeDefs += `
13290
13342
  workflowTriggers(workflow: ID!): [WorkflowTrigger!]!
13291
13343
  `;
@@ -14232,6 +14284,13 @@ type LiteLLMModel {
14232
14284
  if (!context.user) throw new Error("Authentication required");
14233
14285
  return recallService.getUsage();
14234
14286
  };
14287
+ resolvers.Query["recordingVideoUrl"] = async (_, args, context) => {
14288
+ await assertOwnsTranscriptionJob(args.job_id, context);
14289
+ const { db } = context;
14290
+ const row = await db.from("transcription_jobs").select(["recall_recording_id"]).where({ id: args.job_id }).first();
14291
+ if (!row?.recall_recording_id) return null;
14292
+ return recallService.getRecordingVideoUrl(row.recall_recording_id);
14293
+ };
14235
14294
  resolvers.Query["evals"] = async (_, args, context, info) => {
14236
14295
  const requestedFields = getRequestedFields(info);
14237
14296
  return {
@@ -21314,7 +21373,10 @@ var transcriptionsContext = new ExuluContext2({
21314
21373
  // Link back to the Recall recording so the mixed video stays reachable
21315
21374
  // (resolve a fresh URL via ExuluRecall.getRecordingVideoUrl — it expires
21316
21375
  // after six hours). Null for Whisper uploads.
21317
- { name: "recall_recording_id", type: "text" }
21376
+ { name: "recall_recording_id", type: "text" },
21377
+ // Permanent local copy of the mixed video, present only when
21378
+ // RECALL_STORE_VIDEO_LOCALLY=true was set at recording time.
21379
+ { name: "video", type: "file" }
21318
21380
  ],
21319
21381
  sources: [],
21320
21382
  active: true,
@@ -21741,6 +21803,11 @@ var ExuluApp = class {
21741
21803
  get audit() {
21742
21804
  return this._audit ?? getAuditLogger(this._config ?? {});
21743
21805
  }
21806
+ /** The deployment's ExuluConfig, for background/webhook code paths that
21807
+ * have no request to read it off (e.g. the Recall webhook handler). */
21808
+ get config() {
21809
+ return this._config ?? {};
21810
+ }
21744
21811
  embeddings = {
21745
21812
  generate: {
21746
21813
  one: async ({ context: contextId, item: itemId }) => {
@@ -6,7 +6,7 @@ import {
6
6
  isPythonEnvironmentSetup,
7
7
  setupPythonEnvironment,
8
8
  validatePythonEnvironment
9
- } from "./chunk-QMN6MVHQ.js";
9
+ } from "./chunk-JIZCKSJO.js";
10
10
  export {
11
11
  getPackageRoot,
12
12
  getPythonSetupInstructions,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@exulu/backend",
3
3
  "author": "Qventu B.V.",
4
- "version": "4.1.0",
4
+ "version": "4.2.0",
5
5
  "main": "./dist/index.js",
6
6
  "private": false,
7
7
  "publishConfig": {
@@ -156,15 +156,21 @@ async function main() {
156
156
  return;
157
157
  }
158
158
 
159
- // Check if already exists
159
+ // We deliberately do NOT skip running setup.sh just because the venv
160
+ // directory and a `python` binary already exist (pythonEnvironmentExists()
161
+ // only checks that much, not that requirements.txt is fully installed).
162
+ // ee/python/setup.sh is itself idempotent — it only skips *recreating* the
163
+ // venv, never skips `pip install -r requirements.txt` — so always invoking
164
+ // it is cheap when nothing is missing (a few seconds) and is the only way
165
+ // to guarantee a venv created before a new requirement was added (e.g.
166
+ // python-docx, 2026-09-11) actually gets it installed. This used to return
167
+ // early here whenever the venv folder was merely present, which silently
168
+ // left newly-added dependencies uninstalled across any install that reused
169
+ // an older venv — see the docx-manipulation skill incident on dx-algi,
170
+ // 2026-09-21.
160
171
  if (pythonEnvironmentExists()) {
161
- console.log(`${colors.green}✓${colors.reset} Python environment already set up`);
172
+ console.log(`${colors.blue}ℹ${colors.reset} Python environment directory already exists — verifying and installing any missing dependencies...`);
162
173
  console.log('');
163
- console.log('To rebuild, run:');
164
- console.log(` ${colors.green}import { setupPythonEnvironment } from '@exulu/backend';${colors.reset}`);
165
- console.log(` ${colors.green}await setupPythonEnvironment({ force: true });${colors.reset}`);
166
- console.log('');
167
- return;
168
174
  }
169
175
 
170
176
  // Run setup