@exulu/backend 3.7.3 → 3.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -2252,11 +2252,6 @@ interface PythonSetupResult {
2252
2252
  /** Full output from setup script */
2253
2253
  output?: string;
2254
2254
  }
2255
- /**
2256
- * Check if Python environment is already set up
2257
- * Note: This only checks if the venv exists, not if packages are installed.
2258
- * Use validatePythonEnvironment() for a more thorough check.
2259
- */
2260
2255
  declare function isPythonEnvironmentSetup(packageRoot?: string): boolean;
2261
2256
  /**
2262
2257
  * Set up the Python environment by running the setup script
package/dist/index.d.ts CHANGED
@@ -2252,11 +2252,6 @@ interface PythonSetupResult {
2252
2252
  /** Full output from setup script */
2253
2253
  output?: string;
2254
2254
  }
2255
- /**
2256
- * Check if Python environment is already set up
2257
- * Note: This only checks if the venv exists, not if packages are installed.
2258
- * Use validatePythonEnvironment() for a more thorough check.
2259
- */
2260
2255
  declare function isPythonEnvironmentSetup(packageRoot?: string): boolean;
2261
2256
  /**
2262
2257
  * Set up the Python environment by running the setup script
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  isPythonEnvironmentSetup,
6
6
  setupPythonEnvironment,
7
7
  validatePythonEnvironment
8
- } from "./chunk-T6JVFT7L.js";
8
+ } from "./chunk-27K2CO47.js";
9
9
  import {
10
10
  COMPACTION_INSUFFICIENT,
11
11
  ContextCompactionRequiredError,
@@ -88,7 +88,7 @@ import {
88
88
  verifyCredentialNonce,
89
89
  waitForLiteLLMReady,
90
90
  withRetry
91
- } from "./chunk-BNTL6LYY.js";
91
+ } from "./chunk-AWMU6QXB.js";
92
92
  import {
93
93
  LiteLLMAdminError,
94
94
  findLiteLLMModel,
@@ -8285,10 +8285,12 @@ var generateSync = async ({
8285
8285
  }
8286
8286
  let project;
8287
8287
  let sessionItems;
8288
+ let sessionOwnerId;
8288
8289
  if (session) {
8289
8290
  const sessionData = await getSession({ sessionID: session });
8290
8291
  sessionItems = sessionData.session_items;
8291
8292
  project = sessionData.project;
8293
+ sessionOwnerId = sessionData.user ?? void 0;
8292
8294
  }
8293
8295
  const model = languageModel;
8294
8296
  console.log("[EXULU] Model created for generating sync.");
@@ -8392,7 +8394,8 @@ var generateSync = async ({
8392
8394
  agent,
8393
8395
  memoryItems,
8394
8396
  contextWindow,
8395
- disabledTools
8397
+ disabledTools,
8398
+ sessionOwnerId
8396
8399
  );
8397
8400
  const agenticEntry = currentTools?.find((t) => t.id === "agentic_context_search");
8398
8401
  const agenticToolKey = agenticEntry ? sanitizeToolName(agenticEntry.name) : void 0;
@@ -8627,10 +8630,12 @@ var generateStream = async ({
8627
8630
  let previousMessagesContent = previousMessages || [];
8628
8631
  let project;
8629
8632
  let sessionItems;
8633
+ let sessionOwnerId;
8630
8634
  if (session) {
8631
8635
  const sessionData = await getSession({ sessionID: session });
8632
8636
  project = sessionData.project;
8633
8637
  sessionItems = sessionData.session_items;
8638
+ sessionOwnerId = sessionData.user ?? void 0;
8634
8639
  console.log("[EXULU] loading previous messages from session: " + session);
8635
8640
  const previousMessages2 = await getAgentMessages({
8636
8641
  session,
@@ -8810,7 +8815,8 @@ When a tool execution is not approved by the user, do not retry it unless explic
8810
8815
  agent,
8811
8816
  memoryItems,
8812
8817
  contextWindow,
8813
- disabledTools
8818
+ disabledTools,
8819
+ sessionOwnerId
8814
8820
  );
8815
8821
  console.log("[EXULU] Converted tools", Object.keys(tools));
8816
8822
  const includesContextSearchTool = currentTools?.some(
@@ -0,0 +1,17 @@
1
+ import "dotenv/config";
2
+ import {
3
+ getPackageRoot,
4
+ getPythonSetupInstructions,
5
+ getPythonVenvPath,
6
+ isPythonEnvironmentSetup,
7
+ setupPythonEnvironment,
8
+ validatePythonEnvironment
9
+ } from "./chunk-27K2CO47.js";
10
+ export {
11
+ getPackageRoot,
12
+ getPythonSetupInstructions,
13
+ getPythonVenvPath,
14
+ isPythonEnvironmentSetup,
15
+ setupPythonEnvironment,
16
+ validatePythonEnvironment
17
+ };
@@ -0,0 +1,49 @@
1
+ import { isIgnoredArtifactPath, capArtifacts, needsDownload } from "./artifact-filter";
2
+
3
+ describe("isIgnoredArtifactPath — dependency and cache trees are not session artifacts", () => {
4
+ it("ignores virtualenvs, node_modules, caches and VCS metadata at any depth", () => {
5
+ for (const p of [
6
+ "venv/pyvenv.cfg",
7
+ "venv/lib/python3.11/site-packages/docx/api.py",
8
+ ".venv/bin/python",
9
+ "node_modules/lodash/index.js",
10
+ "scripts/__pycache__/create.cpython-311.pyc",
11
+ ".cache/pip/http/x",
12
+ ".git/objects/ab/cd",
13
+ "project/env/lib/python3.11/site-packages/x.py",
14
+ ]) {
15
+ expect(isIgnoredArtifactPath(p)).toBe(true);
16
+ }
17
+ });
18
+
19
+ it("keeps the files the agent actually produces", () => {
20
+ for (const p of ["Besprechungsprotokoll.docx", "create_minutes.py", "out/report.pdf", "Angebot/Angebot_107426.docx"]) {
21
+ expect(isIgnoredArtifactPath(p)).toBe(false);
22
+ }
23
+ });
24
+ });
25
+
26
+ describe("capArtifacts — a bash command that touches thousands of files must not flood the tool result", () => {
27
+ const many = Array.from({ length: 250 }, (_, i) => ({ relativePath: `out/file_${i}.txt`, url: `https://s3/${i}` }));
28
+
29
+ it("returns everything below the cap untouched", () => {
30
+ const { kept, omitted } = capArtifacts(many.slice(0, 10), 50);
31
+ expect(kept).toHaveLength(10);
32
+ expect(omitted).toBe(0);
33
+ });
34
+
35
+ it("keeps only the first N and reports how many were omitted", () => {
36
+ const { kept, omitted } = capArtifacts(many, 50);
37
+ expect(kept).toHaveLength(50);
38
+ expect(kept[0]!.relativePath).toBe("out/file_0.txt");
39
+ expect(omitted).toBe(200);
40
+ });
41
+ });
42
+
43
+ describe("needsDownload — resyncing S3 session files into a live sandbox", () => {
44
+ it("downloads when the local file is missing or differs in size, skips identical files", () => {
45
+ expect(needsDownload(undefined, 100)).toBe(true);
46
+ expect(needsDownload(99, 100)).toBe(true);
47
+ expect(needsDownload(100, 100)).toBe(false);
48
+ });
49
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The sandbox mirrors every file a shell command creates under the session directory to
3
+ * S3 and lists each one (with a presigned URL) in the tool result. A single
4
+ * `python -m venv venv && pip install python-docx` creates thousands of files, which
5
+ * turned one tool result into ~1.8M characters and killed the session's context window.
6
+ */
7
+
8
+ const IGNORED_SEGMENTS = new Set([
9
+ "venv",
10
+ ".venv",
11
+ "env",
12
+ "node_modules",
13
+ "__pycache__",
14
+ "site-packages",
15
+ "dist-packages",
16
+ ".cache",
17
+ ".git",
18
+ ".pytest_cache",
19
+ ".mypy_cache",
20
+ ".ipynb_checkpoints",
21
+ ]);
22
+
23
+ /** True when any path segment is a dependency, cache or VCS directory. */
24
+ export function isIgnoredArtifactPath(relativePath: string): boolean {
25
+ return relativePath.split(/[\\/]+/).some((segment) => IGNORED_SEGMENTS.has(segment));
26
+ }
27
+
28
+ export const DEFAULT_ARTIFACT_CAP = 50;
29
+
30
+ export function capArtifacts<T>(artifacts: T[], max: number = DEFAULT_ARTIFACT_CAP): { kept: T[]; omitted: number } {
31
+ if (artifacts.length <= max) return { kept: artifacts, omitted: 0 };
32
+ return { kept: artifacts.slice(0, max), omitted: artifacts.length - max };
33
+ }
34
+
35
+ /** Whether an S3 session file must be (re)downloaded into the live sandbox directory. */
36
+ export function needsDownload(localSize: number | undefined, remoteSize: number): boolean {
37
+ return localSize === undefined || localSize !== remoteSize;
38
+ }
@@ -8,6 +8,7 @@ import { join, dirname, resolve, relative, posix } from 'node:path'
8
8
  import { exec, spawn } from 'node:child_process'
9
9
  import { promisify } from 'node:util'
10
10
  import { listS3ObjectsByPrefix, getS3ObjectBytes, uploadFile, getPresignedUrl, type S3FileObject } from '@SRC/uppy/index.ts'
11
+ import { isIgnoredArtifactPath, capArtifacts, needsDownload } from './artifact-filter'
11
12
  import { getNpmGlobalRoot } from '@SRC/exulu/system-dependencies.ts'
12
13
  import type { ExuluConfig } from '@SRC/exulu/app/index.ts'
13
14
  import { createBashTool, type Sandbox } from "bash-tool";
@@ -270,6 +271,7 @@ async function restoreArtifactsFromS3(
270
271
  sessionId: string,
271
272
  userId: number | string,
272
273
  config: ExuluConfig,
274
+ opts: { onlyMissing?: boolean } = {},
273
275
  ): Promise<void> {
274
276
  const userPrefix = `user_${userId}/sessions/${sessionId}/`
275
277
  let objects: S3FileObject[]
@@ -297,8 +299,18 @@ async function restoreArtifactsFromS3(
297
299
  const idx = obj.key.indexOf(userPrefix)
298
300
  const relativePath = idx >= 0 ? obj.key.slice(idx + userPrefix.length) : ''
299
301
  if (!relativePath) continue // directory marker or unexpected key shape
302
+ if (isIgnoredArtifactPath(relativePath)) continue // never pull dependency trees back
300
303
 
301
304
  const localPath = join(sessionDir, relativePath)
305
+ if (opts.onlyMissing) {
306
+ let localSize: number | undefined
307
+ try {
308
+ localSize = (await stat(localPath)).size
309
+ } catch {
310
+ localSize = undefined
311
+ }
312
+ if (!needsDownload(localSize, obj.size)) continue
313
+ }
302
314
  try {
303
315
  // Use binary-safe fetch — session artifacts now include PDFs, .docx
304
316
  // and other binary formats (from user uploads as well as agent
@@ -380,6 +392,20 @@ export async function downloadKeyIntoSandbox(opts: {
380
392
  * cache AND no session directory on disk), previously persisted artifacts for
381
393
  * the session are restored from S3 into the fresh session directory.
382
394
  */
395
+ /**
396
+ * Loaded lazily: python-setup resolves the package root via import.meta.url, which
397
+ * must not be evaluated when this module is merely imported (jest runs CJS).
398
+ */
399
+ async function resolvePythonVenvPath(): Promise<string | undefined> {
400
+ try {
401
+ const { getPythonVenvPath } = await import('@SRC/utils/python-setup.ts')
402
+ return getPythonVenvPath()
403
+ } catch (err) {
404
+ console.warn('[SKILLS] Could not resolve the Python venv for the session sandbox; skill scripts fall back to the system python.', err)
405
+ return undefined
406
+ }
407
+ }
408
+
383
409
  export async function createSessionSandbox(
384
410
  sessionId: string,
385
411
  skills: SkillRef[],
@@ -404,6 +430,16 @@ export async function createSessionSandbox(
404
430
  cached.installedSkills.set(skill.id, skill.current_version)
405
431
  }
406
432
 
433
+ // Files uploaded through the side panel (or by an email routine) after the
434
+ // sandbox was created live only in S3 until re-synced; without this the agent's
435
+ // `ls` never sees them although the system prompt promises it will.
436
+ if (userId && config.fileUploads) {
437
+ try {
438
+ await restoreArtifactsFromS3(cached.handle.sessionDir, sessionId, userId, config, { onlyMissing: true })
439
+ } catch (err) {
440
+ console.error(`[SKILLS] Failed to re-sync S3 session files for session ${sessionId}; continuing.`, err)
441
+ }
442
+ }
407
443
  return cached.handle
408
444
  }
409
445
 
@@ -439,8 +475,9 @@ export async function createSessionSandbox(
439
475
  // Restore artifacts from S3 only on a true cold start. If the dir already
440
476
  // existed, the local files are at least as new as S3 and may contain
441
477
  // unsaved-to-S3 writes from a prior process.
442
- if (userId && config.fileUploads && !dirExisted) {
443
- await restoreArtifactsFromS3(sessionDir, sessionId, userId, config)
478
+ if (userId && config.fileUploads) {
479
+ // Cold start restores everything; a surviving on-disk dir only fetches what changed.
480
+ await restoreArtifactsFromS3(sessionDir, sessionId, userId, config, { onlyMissing: dirExisted })
444
481
  }
445
482
 
446
483
  // Probe whether bwrap can actually create namespaces on this host. On a
@@ -492,6 +529,9 @@ export async function createSessionSandbox(
492
529
  // fail with a clear MODULE_NOT_FOUND, matching what the user would see
493
530
  // outside the sandbox).
494
531
  const npmGlobalRoot = await getNpmGlobalRoot()
532
+ // The @exulu/backend Python venv carries the skill runtime deps (python-docx …).
533
+ // Put its bin dir first on PATH so `python3` inside the sandbox resolves to it.
534
+ const pythonVenvPath = await resolvePythonVenvPath()
495
535
 
496
536
  // Per-session policy. Passed to every wrapWithSandbox() invocation made
497
537
  // from within this closure. customConfig wins over the singleton's
@@ -510,6 +550,7 @@ export async function createSessionSandbox(
510
550
  // the sandbox. Without this, `require('docx')` fails with
511
551
  // EPERM even when NODE_PATH points the resolver here.
512
552
  ...(npmGlobalRoot ? [npmGlobalRoot] : []),
553
+ ...(pythonVenvPath ? [pythonVenvPath] : []),
513
554
  ],
514
555
  allowWrite: [sessionDir],
515
556
  denyWrite: [],
@@ -563,6 +604,9 @@ export async function createSessionSandbox(
563
604
  ...configuredVariables,
564
605
  ...process.env,
565
606
  ...(npmGlobalRoot ? { NODE_PATH: npmGlobalRoot } : {}),
607
+ ...(pythonVenvPath
608
+ ? { PATH: `${join(pythonVenvPath, 'bin')}:${process.env.PATH ?? ''}`, VIRTUAL_ENV: pythonVenvPath }
609
+ : {}),
566
610
  }
567
611
 
568
612
  // Either wrap a command with bwrap/sandbox-exec or return it unchanged when
@@ -757,6 +801,7 @@ export async function createSessionSandbox(
757
801
  for (const entry of entries) {
758
802
  const full = join(dir, entry.name)
759
803
  if (full === skillsDir) continue
804
+ if (isIgnoredArtifactPath(relative(sessionDir, full))) continue
760
805
  if (entry.isDirectory()) {
761
806
  await walk(full)
762
807
  } else if (entry.isFile()) {
@@ -917,19 +962,26 @@ export async function createSessionSandbox(
917
962
  // marker block isn't truncated. Only files with a presigned URL
918
963
  // appear here; locally-only entries would just confuse the user.
919
964
  let stdout = result?.stdout ?? ''
920
- const withUrls = artifacts.filter((a) => a.url)
965
+ // Cap the listing: one command can touch thousands of files, and every
966
+ // entry carries a presigned URL. The full set is still mirrored to S3.
967
+ const { kept, omitted } = capArtifacts(artifacts)
968
+ const withUrls = kept.filter((a) => a.url)
921
969
  if (withUrls.length > 0) {
922
970
  const lines = ['', '[exulu-artifacts]']
923
971
  for (const a of withUrls) {
924
972
  lines.push(` ${a.relativePath}: ${a.url}`)
925
973
  }
974
+ if (omitted > 0) {
975
+ lines.push(` … ${omitted} more file(s) were created and mirrored but are not listed here.`)
976
+ }
926
977
  stdout = `${stdout}\n${lines.join('\n')}`
927
978
  }
928
979
 
929
980
  return {
930
981
  ...result,
931
982
  stdout,
932
- artifacts,
983
+ artifacts: kept,
984
+ ...(omitted > 0 ? { artifactsOmitted: omitted } : {}),
933
985
  }
934
986
  },
935
987
  })
@@ -52,3 +52,8 @@ prisma==0.15.0
52
52
  # NOT included in litellm[proxy]; without it, requests to Vertex models
53
53
  # fail with "No module named 'vertexai'".
54
54
  google-cloud-aiplatform>=1.38
55
+
56
+ # Skill runtime dependencies — the session sandbox puts this venv's bin dir on PATH so
57
+ # skill scripts (e.g. the built-in docx-manipulation skill) find their libraries without
58
+ # creating a venv per session (which mirrored thousands of files into the tool result).
59
+ python-docx>=1.1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@exulu/backend",
3
3
  "author": "Qventu Bv.",
4
- "version": "3.7.3",
4
+ "version": "3.7.4",
5
5
  "main": "./dist/index.js",
6
6
  "private": false,
7
7
  "publishConfig": {