neoagent 2.5.2-beta.9 → 3.0.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.
Files changed (29) hide show
  1. package/README.md +5 -1
  2. package/docs/integrations.md +11 -7
  3. package/package.json +2 -2
  4. package/server/public/.last_build_id +1 -1
  5. package/server/public/flutter_bootstrap.js +1 -1
  6. package/server/public/main.dart.js +4 -4
  7. package/server/services/ai/deliverables/artifact_helpers.js +92 -9
  8. package/server/services/ai/deliverables/selector.js +17 -0
  9. package/server/services/ai/loop/blank_recovery.js +37 -0
  10. package/server/services/ai/loop/conversation_loop.js +345 -242
  11. package/server/services/ai/loop/error_recovery.js +38 -0
  12. package/server/services/ai/loop/messaging_delivery.js +52 -6
  13. package/server/services/ai/loop/progress_classification.js +178 -0
  14. package/server/services/ai/loop/progress_monitor.js +6 -5
  15. package/server/services/ai/loop/tool_dispatch.js +1 -0
  16. package/server/services/ai/loopPolicy.js +15 -6
  17. package/server/services/ai/messagingFallback.js +23 -1
  18. package/server/services/ai/preModelCompaction.js +31 -0
  19. package/server/services/ai/repetitionGuard.js +47 -2
  20. package/server/services/ai/systemPrompt.js +6 -0
  21. package/server/services/ai/taskAnalysis.js +10 -0
  22. package/server/services/ai/toolEvidence.js +15 -3
  23. package/server/services/ai/toolResult.js +29 -0
  24. package/server/services/ai/toolSelector.js +20 -3
  25. package/server/services/ai/tools.js +196 -26
  26. package/server/services/integrations/github/common.js +2 -2
  27. package/server/services/integrations/github/repos.js +123 -55
  28. package/server/services/runtime/docker-vm-manager.js +21 -1
  29. package/server/services/workspace/manager.js +56 -0
@@ -490,29 +490,6 @@ const githubToolDefinitions = [
490
490
  required: ['owner_repo'],
491
491
  },
492
492
  },
493
- {
494
- name: 'github_get_content',
495
- access: 'read',
496
- description: 'Get file or directory contents from a repository.',
497
- parameters: {
498
- type: 'object',
499
- properties: {
500
- owner_repo: {
501
- type: 'string',
502
- description: 'Repository in format "owner/repo".',
503
- },
504
- path: {
505
- type: 'string',
506
- description: 'File or directory path.',
507
- },
508
- ref: {
509
- type: 'string',
510
- description: 'Git ref (branch, tag, or SHA).',
511
- },
512
- },
513
- required: ['owner_repo', 'path'],
514
- },
515
- },
516
493
  {
517
494
  name: 'github_create_or_update_file',
518
495
  access: 'write',
@@ -534,7 +511,12 @@ const githubToolDefinitions = [
534
511
  },
535
512
  content: {
536
513
  type: 'string',
537
- description: 'Base64-encoded file content.',
514
+ description: 'File content as normal UTF-8 text by default. The tool handles the GitHub Contents API encoding.',
515
+ },
516
+ encoding: {
517
+ type: 'string',
518
+ enum: ['utf-8', 'base64'],
519
+ description: 'Input encoding for content. Default utf-8. Use base64 only for already-encoded binary content.',
538
520
  },
539
521
  sha: {
540
522
  type: 'string',
@@ -717,7 +699,7 @@ const githubToolDefinitions = [
717
699
  {
718
700
  name: 'github_api_request',
719
701
  access: 'dynamic_http_method',
720
- description: 'Make an authenticated GitHub API request for advanced operations not covered by dedicated tools.',
702
+ description: 'Make an authenticated GitHub API request for advanced operations not covered by dedicated tools. Path must be the FULL API path starting with /repos/{owner}/{repo}/... — e.g. /repos/NeoLabs-Systems/NeoAgent/git/trees/main?recursive=1. Alternatively, supply owner_repo and a relative path like /git/trees/main and the prefix is prepended automatically.',
721
703
  parameters: {
722
704
  type: 'object',
723
705
  properties: {
@@ -728,7 +710,19 @@ const githubToolDefinitions = [
728
710
  },
729
711
  path: {
730
712
  type: 'string',
731
- description: 'API path or full URL.',
713
+ description: 'Full API path (e.g. /repos/owner/repo/git/trees/main) or a relative path like /git/trees/main when owner_repo is also provided.',
714
+ },
715
+ owner_repo: {
716
+ type: 'string',
717
+ description: 'Repository in "owner/repo" format. When provided together with a relative path, the /repos/{owner}/{repo} prefix is automatically prepended.',
718
+ },
719
+ endpoint: {
720
+ type: 'string',
721
+ description: 'Alias for path.',
722
+ },
723
+ url: {
724
+ type: 'string',
725
+ description: 'Full GitHub API URL (https://api.github.com/...).',
732
726
  },
733
727
  query: {
734
728
  type: 'object',
@@ -738,8 +732,12 @@ const githubToolDefinitions = [
738
732
  type: 'object',
739
733
  description: 'Optional JSON request body.',
740
734
  },
735
+ payload: {
736
+ type: 'object',
737
+ description: 'Alias for body.',
738
+ },
741
739
  },
742
- required: ['method', 'path'],
740
+ required: ['method'],
743
741
  },
744
742
  },
745
743
  ];
@@ -749,6 +747,63 @@ function parseCommaSeparatedList(value) {
749
747
  return String(value).split(',').map((s) => s.trim()).filter(Boolean);
750
748
  }
751
749
 
750
+ function resolveApiRequestPath(args = {}) {
751
+ for (const key of ['path', 'endpoint', 'url']) {
752
+ const value = args?.[key];
753
+ if (typeof value === 'string' && value.trim()) return value.trim();
754
+ }
755
+ return '';
756
+ }
757
+
758
+ function hasOwnerRepoArg(args = {}) {
759
+ if (typeof args.owner_repo === 'string' && args.owner_repo.trim()) return true;
760
+ return Boolean(String(args.owner || '').trim() && String(args.repo || '').trim());
761
+ }
762
+
763
+ function parseOwnerRepoArg(args = {}) {
764
+ if (typeof args.owner_repo === 'string' && args.owner_repo.trim()) {
765
+ return parseOwnerRepo(args.owner_repo);
766
+ }
767
+ const owner = String(args.owner || '').trim();
768
+ const repo = String(args.repo || '').trim();
769
+ if (owner && repo) {
770
+ return parseOwnerRepo(`${owner}/${repo}`);
771
+ }
772
+ return parseOwnerRepo(args.owner_repo);
773
+ }
774
+
775
+ function encodeGithubFileContent(args = {}) {
776
+ const content = String(args.content ?? '');
777
+ const encoding = String(args.encoding || args.content_encoding || 'utf-8').toLowerCase();
778
+ if (encoding === 'base64') {
779
+ return content.replace(/\s/g, '');
780
+ }
781
+ return Buffer.from(content, 'utf8').toString('base64');
782
+ }
783
+
784
+ function resolveApiRequestBody(args = {}) {
785
+ if (args.body && typeof args.body === 'object') return args.body;
786
+ if (args.payload && typeof args.payload === 'object') return args.payload;
787
+ return null;
788
+ }
789
+
790
+ for (const definition of githubToolDefinitions) {
791
+ const parameters = definition.parameters;
792
+ const properties = parameters?.properties;
793
+ if (!properties?.owner_repo) continue;
794
+ properties.owner = {
795
+ type: 'string',
796
+ description: 'Repository owner alias. Use together with repo when owner_repo is not supplied.',
797
+ };
798
+ properties.repo = {
799
+ type: 'string',
800
+ description: 'Repository name alias. Use together with owner when owner_repo is not supplied.',
801
+ };
802
+ if (Array.isArray(parameters.required) && parameters.required.includes('owner_repo')) {
803
+ parameters.required = parameters.required.filter((item) => item !== 'owner_repo');
804
+ }
805
+ }
806
+
752
807
  async function executeGithubTool(toolName, args, auth) {
753
808
  switch (toolName) {
754
809
  case 'github_get_auth_user': {
@@ -768,14 +823,14 @@ async function executeGithubTool(toolName, args, auth) {
768
823
  }
769
824
 
770
825
  case 'github_get_repo': {
771
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
826
+ const { owner, repo } = parseOwnerRepoArg(args);
772
827
  return await githubApiRequest(auth, {
773
828
  path: `/repos/${owner}/${repo}`,
774
829
  });
775
830
  }
776
831
 
777
832
  case 'github_list_issues': {
778
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
833
+ const { owner, repo } = parseOwnerRepoArg(args);
779
834
  const query = { per_page: Math.min(Number(args.max_results) || 30, 100) };
780
835
  if (args.state) query.state = args.state;
781
836
  if (args.labels) query.labels = args.labels;
@@ -789,14 +844,14 @@ async function executeGithubTool(toolName, args, auth) {
789
844
  }
790
845
 
791
846
  case 'github_get_issue': {
792
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
847
+ const { owner, repo } = parseOwnerRepoArg(args);
793
848
  return await githubApiRequest(auth, {
794
849
  path: `/repos/${owner}/${repo}/issues/${Number(args.issue_number)}`,
795
850
  });
796
851
  }
797
852
 
798
853
  case 'github_create_issue': {
799
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
854
+ const { owner, repo } = parseOwnerRepoArg(args);
800
855
  return await githubApiRequest(auth, {
801
856
  method: 'POST',
802
857
  path: `/repos/${owner}/${repo}/issues`,
@@ -810,7 +865,7 @@ async function executeGithubTool(toolName, args, auth) {
810
865
  }
811
866
 
812
867
  case 'github_update_issue': {
813
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
868
+ const { owner, repo } = parseOwnerRepoArg(args);
814
869
  const body = {};
815
870
  if (args.title) body.title = String(args.title);
816
871
  if (args.body !== undefined) body.body = String(args.body);
@@ -825,7 +880,7 @@ async function executeGithubTool(toolName, args, auth) {
825
880
  }
826
881
 
827
882
  case 'github_add_issue_labels': {
828
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
883
+ const { owner, repo } = parseOwnerRepoArg(args);
829
884
  return await githubApiRequest(auth, {
830
885
  method: 'POST',
831
886
  path: `/repos/${owner}/${repo}/issues/${Number(args.issue_number)}/labels`,
@@ -836,7 +891,7 @@ async function executeGithubTool(toolName, args, auth) {
836
891
  }
837
892
 
838
893
  case 'github_add_issue_assignees': {
839
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
894
+ const { owner, repo } = parseOwnerRepoArg(args);
840
895
  return await githubApiRequest(auth, {
841
896
  method: 'POST',
842
897
  path: `/repos/${owner}/${repo}/issues/${Number(args.issue_number)}/assignees`,
@@ -847,7 +902,7 @@ async function executeGithubTool(toolName, args, auth) {
847
902
  }
848
903
 
849
904
  case 'github_list_prs': {
850
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
905
+ const { owner, repo } = parseOwnerRepoArg(args);
851
906
  const query = { per_page: Math.min(Number(args.max_results) || 30, 100) };
852
907
  if (args.state) query.state = args.state;
853
908
  if (args.sort) query.sort = args.sort;
@@ -859,14 +914,14 @@ async function executeGithubTool(toolName, args, auth) {
859
914
  }
860
915
 
861
916
  case 'github_get_pr': {
862
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
917
+ const { owner, repo } = parseOwnerRepoArg(args);
863
918
  return await githubApiRequest(auth, {
864
919
  path: `/repos/${owner}/${repo}/pulls/${Number(args.pr_number)}`,
865
920
  });
866
921
  }
867
922
 
868
923
  case 'github_create_pr': {
869
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
924
+ const { owner, repo } = parseOwnerRepoArg(args);
870
925
  return await githubApiRequest(auth, {
871
926
  method: 'POST',
872
927
  path: `/repos/${owner}/${repo}/pulls`,
@@ -882,7 +937,7 @@ async function executeGithubTool(toolName, args, auth) {
882
937
  }
883
938
 
884
939
  case 'github_update_pr': {
885
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
940
+ const { owner, repo } = parseOwnerRepoArg(args);
886
941
  const body = {};
887
942
  if (args.title) body.title = String(args.title);
888
943
  if (args.body !== undefined) body.body = String(args.body);
@@ -895,7 +950,7 @@ async function executeGithubTool(toolName, args, auth) {
895
950
  }
896
951
 
897
952
  case 'github_merge_pr': {
898
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
953
+ const { owner, repo } = parseOwnerRepoArg(args);
899
954
  return await githubApiRequest(auth, {
900
955
  method: 'PUT',
901
956
  path: `/repos/${owner}/${repo}/pulls/${Number(args.pr_number)}/merge`,
@@ -908,7 +963,7 @@ async function executeGithubTool(toolName, args, auth) {
908
963
  }
909
964
 
910
965
  case 'github_list_commits': {
911
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
966
+ const { owner, repo } = parseOwnerRepoArg(args);
912
967
  const query = { per_page: Math.min(Number(args.max_results) || 30, 100) };
913
968
  if (args.sha) query.sha = String(args.sha);
914
969
  if (args.path) query.path = String(args.path);
@@ -919,7 +974,7 @@ async function executeGithubTool(toolName, args, auth) {
919
974
  }
920
975
 
921
976
  case 'github_list_branches': {
922
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
977
+ const { owner, repo } = parseOwnerRepoArg(args);
923
978
  return await githubApiRequest(auth, {
924
979
  path: `/repos/${owner}/${repo}/branches`,
925
980
  query: buildPaginationParams({ per_page: args.max_results }),
@@ -927,7 +982,7 @@ async function executeGithubTool(toolName, args, auth) {
927
982
  }
928
983
 
929
984
  case 'github_get_branch': {
930
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
985
+ const { owner, repo } = parseOwnerRepoArg(args);
931
986
  const branch = encodeURIComponent(String(args.branch || ''));
932
987
  return await githubApiRequest(auth, {
933
988
  path: `/repos/${owner}/${repo}/branches/${branch}`,
@@ -935,7 +990,7 @@ async function executeGithubTool(toolName, args, auth) {
935
990
  }
936
991
 
937
992
  case 'github_list_collaborators': {
938
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
993
+ const { owner, repo } = parseOwnerRepoArg(args);
939
994
  const maxResults = parsePositiveInt(args.max_results, 30);
940
995
  const perPage = Math.min(100, maxResults);
941
996
  const query = {
@@ -958,23 +1013,28 @@ async function executeGithubTool(toolName, args, auth) {
958
1013
  }
959
1014
 
960
1015
  case 'github_get_content': {
961
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
1016
+ const { owner, repo } = parseOwnerRepoArg(args);
962
1017
  const query = {};
963
1018
  if (args.ref) query.ref = String(args.ref);
964
- return await githubApiRequest(auth, {
1019
+ const result = await githubApiRequest(auth, {
965
1020
  path: `/repos/${owner}/${repo}/contents/${String(args.path || '')}`,
966
1021
  query,
967
1022
  });
1023
+ if (result && result.encoding === 'base64' && typeof result.content === 'string') {
1024
+ result.content = Buffer.from(result.content.replace(/\n/g, ''), 'base64').toString('utf8');
1025
+ result.encoding = 'utf8';
1026
+ }
1027
+ return result;
968
1028
  }
969
1029
 
970
1030
  case 'github_create_or_update_file': {
971
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
1031
+ const { owner, repo } = parseOwnerRepoArg(args);
972
1032
  return await githubApiRequest(auth, {
973
1033
  method: 'PUT',
974
1034
  path: `/repos/${owner}/${repo}/contents/${String(args.path || '')}`,
975
1035
  body: {
976
1036
  message: String(args.message || ''),
977
- content: String(args.content || ''),
1037
+ content: encodeGithubFileContent(args),
978
1038
  sha: args.sha ? String(args.sha) : undefined,
979
1039
  branch: args.branch ? String(args.branch) : undefined,
980
1040
  },
@@ -982,7 +1042,7 @@ async function executeGithubTool(toolName, args, auth) {
982
1042
  }
983
1043
 
984
1044
  case 'github_delete_file': {
985
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
1045
+ const { owner, repo } = parseOwnerRepoArg(args);
986
1046
  return await githubApiRequest(auth, {
987
1047
  method: 'DELETE',
988
1048
  path: `/repos/${owner}/${repo}/contents/${String(args.path || '')}`,
@@ -995,7 +1055,7 @@ async function executeGithubTool(toolName, args, auth) {
995
1055
  }
996
1056
 
997
1057
  case 'github_list_workflow_runs': {
998
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
1058
+ const { owner, repo } = parseOwnerRepoArg(args);
999
1059
  let path = `/repos/${owner}/${repo}/actions/runs`;
1000
1060
  if (args.workflow_id) {
1001
1061
  path = `/repos/${owner}/${repo}/actions/workflows/${String(args.workflow_id)}/runs`;
@@ -1024,14 +1084,14 @@ async function executeGithubTool(toolName, args, auth) {
1024
1084
  }
1025
1085
 
1026
1086
  case 'github_get_workflow_run': {
1027
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
1087
+ const { owner, repo } = parseOwnerRepoArg(args);
1028
1088
  return await githubApiRequest(auth, {
1029
1089
  path: `/repos/${owner}/${repo}/actions/runs/${Number(args.run_id)}`,
1030
1090
  });
1031
1091
  }
1032
1092
 
1033
1093
  case 'github_trigger_workflow': {
1034
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
1094
+ const { owner, repo } = parseOwnerRepoArg(args);
1035
1095
  let inputs = {};
1036
1096
  if (args.inputs) {
1037
1097
  try {
@@ -1051,7 +1111,7 @@ async function executeGithubTool(toolName, args, auth) {
1051
1111
  }
1052
1112
 
1053
1113
  case 'github_list_workflows': {
1054
- const { owner, repo } = parseOwnerRepo(args.owner_repo);
1114
+ const { owner, repo } = parseOwnerRepoArg(args);
1055
1115
  return await githubApiRequest(auth, {
1056
1116
  path: `/repos/${owner}/${repo}/actions/workflows`,
1057
1117
  });
@@ -1085,7 +1145,10 @@ async function executeGithubTool(toolName, args, auth) {
1085
1145
 
1086
1146
  case 'github_api_request': {
1087
1147
  let baseUrl = 'https://api.github.com';
1088
- let path = String(args.path || '');
1148
+ let path = resolveApiRequestPath(args);
1149
+ if (!path) {
1150
+ throw new Error('github_api_request requires path, endpoint, or url.');
1151
+ }
1089
1152
  let query = args.query || null;
1090
1153
  if (path.startsWith('http')) {
1091
1154
  const url = new URL(path);
@@ -1103,12 +1166,17 @@ async function executeGithubTool(toolName, args, auth) {
1103
1166
  ...parsedQuery,
1104
1167
  ...(args.query && typeof args.query === 'object' ? args.query : {}),
1105
1168
  };
1169
+ } else if (hasOwnerRepoArg(args) && !path.startsWith('/repos/') && !path.startsWith('/user') && !path.startsWith('/orgs/') && !path.startsWith('/search/')) {
1170
+ // Convenience: prepend /repos/{owner}/{repo} for relative paths
1171
+ const { owner, repo } = parseOwnerRepoArg(args);
1172
+ const relativePath = path.startsWith('/') ? path : `/${path}`;
1173
+ path = `/repos/${owner}/${repo}${relativePath}`;
1106
1174
  }
1107
1175
  return await githubApiRequest(auth, {
1108
1176
  method: args.method || 'GET',
1109
1177
  path,
1110
1178
  query,
1111
- body: args.body || null,
1179
+ body: resolveApiRequestBody(args),
1112
1180
  baseUrl,
1113
1181
  });
1114
1182
  }
@@ -1121,4 +1189,4 @@ async function executeGithubTool(toolName, args, auth) {
1121
1189
  module.exports = {
1122
1190
  executeGithubTool,
1123
1191
  githubToolDefinitions,
1124
- };
1192
+ };
@@ -1,11 +1,25 @@
1
1
  'use strict';
2
2
 
3
3
  const { spawnSync } = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
4
6
  const http = require('http');
5
7
  const net = require('net');
8
+ const { AGENT_DATA_DIR } = require('../../../runtime/paths');
9
+ const { sanitizeWorkspaceKey } = require('../workspace/manager');
6
10
 
7
11
  const CONTAINER_IMAGE = 'mcr.microsoft.com/playwright:v1.44.0-focal';
8
12
  const CONTAINER_LABEL = 'neoagent.managed=1';
13
+ // The per-user host workspace is bind-mounted here so the shell (execute_command)
14
+ // and the workspace file tools (read_file/write_file/list_directory/search_files)
15
+ // operate on the SAME files. The guest agent defaults its shell cwd to this path.
16
+ const GUEST_WORKSPACE = '/workspace';
17
+
18
+ // Host path of a user's workspace — must match WorkspaceManager's layout exactly
19
+ // (AGENT_DATA_DIR/workspaces/<sanitized key>) so both sides see one directory.
20
+ function hostWorkspaceDir(key) {
21
+ return path.join(AGENT_DATA_DIR, 'workspaces', sanitizeWorkspaceKey(key));
22
+ }
9
23
 
10
24
  // ─── Guest agent ─────────────────────────────────────────────────────────────
11
25
  // Injected into every container. Pure Node.js — only built-in modules + playwright
@@ -104,7 +118,7 @@ const server = http.createServer(async (req, res) => {
104
118
  if (req.method === 'POST' && url === '/exec') {
105
119
  const timeoutMs = Math.min(Number(b.timeout) || 15 * 60 * 1000, 20 * 60 * 1000);
106
120
  const child = spawn('sh', ['-c', b.command || 'true'], {
107
- cwd: b.cwd || '/tmp',
121
+ cwd: b.cwd || '/workspace',
108
122
  env: { ...process.env, ...b.env },
109
123
  });
110
124
  const pid = child.pid;
@@ -325,6 +339,10 @@ class DockerVMManager {
325
339
  const port = await findAvailablePort();
326
340
  console.log(`[DockerVM:${this.profile}] Starting container for user ${key} on port ${port}`);
327
341
 
342
+ // Bind-mount the user's host workspace so shell and file tools share one place.
343
+ const workspaceDir = hostWorkspaceDir(key);
344
+ try { fs.mkdirSync(workspaceDir, { recursive: true }); } catch { /* best effort */ }
345
+
328
346
  const containerId = docker([
329
347
  'run', '-d',
330
348
  '--memory', `${this.memoryMb}m`,
@@ -334,6 +352,8 @@ class DockerVMManager {
334
352
  ...(this.guestToken ? ['-e', `NEOAGENT_VM_GUEST_TOKEN=${this.guestToken}`] : []),
335
353
  '--shm-size=2g',
336
354
  '--security-opt', 'no-new-privileges',
355
+ '-v', `${workspaceDir}:${GUEST_WORKSPACE}`,
356
+ '-w', GUEST_WORKSPACE,
337
357
  '--label', CONTAINER_LABEL,
338
358
  '--label', `neoagent.profile=${this.profile}`,
339
359
  '--label', `neoagent.user=${key}`,
@@ -294,6 +294,61 @@ class WorkspaceManager {
294
294
  }
295
295
  }
296
296
 
297
+ replaceFileRange(userId, options = {}) {
298
+ let filePath;
299
+ try {
300
+ filePath = this.resolvePath(userId, options.path || '', 'path');
301
+ if (!fs.existsSync(filePath)) {
302
+ return { success: false, error: `File not found: ${filePath}`, path: filePath };
303
+ }
304
+
305
+ const start = Number(options.start_line ?? options.startLine);
306
+ const end = Number(options.end_line ?? options.endLine ?? start);
307
+ if (!Number.isInteger(start) || start < 1) {
308
+ return { success: false, error: 'start_line must be a positive integer', path: filePath };
309
+ }
310
+ if (!Number.isInteger(end) || end < 1) {
311
+ return { success: false, error: 'end_line must be a positive integer', path: filePath };
312
+ }
313
+ if (start > end) {
314
+ return { success: false, error: 'start_line must be less than or equal to end_line', path: filePath };
315
+ }
316
+
317
+ const original = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
318
+ const hadFinalNewline = original.endsWith('\n');
319
+ const lines = original.split('\n');
320
+ if (hadFinalNewline) lines.pop();
321
+ const totalLines = Math.max(lines.length, 1);
322
+ if (start > totalLines || end > totalLines) {
323
+ return {
324
+ success: false,
325
+ error: `line range ${start}-${end} is outside file with ${totalLines} lines`,
326
+ path: filePath,
327
+ totalLines,
328
+ };
329
+ }
330
+
331
+ const replacementText = String(options.content ?? '').replace(/\r\n/g, '\n');
332
+ const replacementLines = replacementText === ''
333
+ ? []
334
+ : replacementText.replace(/\n$/, '').split('\n');
335
+ lines.splice(start - 1, end - start + 1, ...replacementLines);
336
+ const next = `${lines.join('\n')}${hadFinalNewline ? '\n' : ''}`;
337
+ fs.writeFileSync(filePath, next, 'utf8');
338
+ return {
339
+ success: true,
340
+ path: filePath,
341
+ startLine: start,
342
+ endLine: end,
343
+ replacedLines: end - start + 1,
344
+ insertedLines: replacementLines.length,
345
+ totalLines: lines.length,
346
+ };
347
+ } catch (err) {
348
+ return { success: false, path: filePath || null, error: err.message };
349
+ }
350
+ }
351
+
297
352
  listDirectory(userId, options = {}) {
298
353
  const dirPath = this.resolvePath(userId, options.path || '.', 'path');
299
354
  const depthValue = options.depth != null ? Number(options.depth) : (options.recursive ? 3 : 1);
@@ -414,4 +469,5 @@ class WorkspaceManager {
414
469
 
415
470
  module.exports = {
416
471
  WorkspaceManager,
472
+ sanitizeWorkspaceKey,
417
473
  };