studiograph 1.3.48-next.76 → 1.3.48-next.78

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 (141) hide show
  1. package/dist/agent/prompts/entity-types-section.d.ts +8 -0
  2. package/dist/agent/prompts/entity-types-section.js +27 -2
  3. package/dist/agent/prompts/entity-types-section.js.map +1 -1
  4. package/dist/agent/tools/folder-tools.js +18 -7
  5. package/dist/agent/tools/folder-tools.js.map +1 -1
  6. package/dist/agent/tools/graph-tools.js +65 -9
  7. package/dist/agent/tools/graph-tools.js.map +1 -1
  8. package/dist/cli/commands/index.js +1 -1
  9. package/dist/cli/commands/index.js.map +1 -1
  10. package/dist/core/entity-types-catalog.js +2 -0
  11. package/dist/core/entity-types-catalog.js.map +1 -1
  12. package/dist/core/graph.d.ts +106 -2
  13. package/dist/core/graph.js +281 -10
  14. package/dist/core/graph.js.map +1 -1
  15. package/dist/core/validation.d.ts +85 -4
  16. package/dist/core/validation.js +34 -3
  17. package/dist/core/validation.js.map +1 -1
  18. package/dist/core/workspace-manager.js +3 -1
  19. package/dist/core/workspace-manager.js.map +1 -1
  20. package/dist/mcp/tools.js +6 -1
  21. package/dist/mcp/tools.js.map +1 -1
  22. package/dist/server/routes/asset-api.js +29 -7
  23. package/dist/server/routes/asset-api.js.map +1 -1
  24. package/dist/server/routes/graph-api.js +290 -38
  25. package/dist/server/routes/graph-api.js.map +1 -1
  26. package/dist/services/asset-upload-service.d.ts +53 -0
  27. package/dist/services/asset-upload-service.js +199 -0
  28. package/dist/services/asset-upload-service.js.map +1 -0
  29. package/dist/services/assets/base.d.ts +15 -0
  30. package/dist/services/assets/base.js +63 -0
  31. package/dist/services/assets/base.js.map +1 -1
  32. package/dist/services/assets/index.d.ts +17 -0
  33. package/dist/services/assets/index.js +17 -0
  34. package/dist/services/assets/index.js.map +1 -1
  35. package/dist/services/import-service.d.ts +33 -1
  36. package/dist/services/import-service.js +234 -48
  37. package/dist/services/import-service.js.map +1 -1
  38. package/dist/services/vector-service.d.ts +20 -2
  39. package/dist/services/vector-service.js +46 -5
  40. package/dist/services/vector-service.js.map +1 -1
  41. package/dist/web/_app/immutable/assets/0.C-U08O83.css +2 -0
  42. package/dist/web/_app/immutable/assets/2.D4UyD6ef.css +1 -0
  43. package/dist/web/_app/immutable/assets/{4.DMWmOCtr.css → 4.CMuW9ER9.css} +1 -1
  44. package/dist/web/_app/immutable/chunks/{BxqZoi16.js → B2dQ_HtA.js} +1 -1
  45. package/dist/web/_app/immutable/chunks/{CbChB7Sk.js → BVG-59G_.js} +1 -1
  46. package/dist/web/_app/immutable/chunks/{CcKbicMh.js → BYMboxS1.js} +1 -1
  47. package/dist/web/_app/immutable/chunks/BcnRmtrP2.js +1 -0
  48. package/dist/web/_app/immutable/chunks/Bi49oCkW.js +1 -0
  49. package/dist/web/_app/immutable/chunks/{9wFWX30W.js → BkG8IWXL.js} +1 -1
  50. package/dist/web/_app/immutable/chunks/BljXMCRN2.js +1 -0
  51. package/dist/web/_app/immutable/chunks/C1eadUOT.js +1 -0
  52. package/dist/web/_app/immutable/chunks/{DgmiIA_n.js → C7ZXf1Ei.js} +1 -1
  53. package/dist/web/_app/immutable/chunks/{BGQcC8WM.js → C9fPdp9e.js} +1 -1
  54. package/dist/web/_app/immutable/chunks/{CpjCnsRC2.js → CABEPnKi2.js} +1 -1
  55. package/dist/web/_app/immutable/chunks/{C4o1fi_w.js → CFFhRHgg.js} +1 -1
  56. package/dist/web/_app/immutable/chunks/{C3q48eWV.js → CMv-UMQD.js} +1 -1
  57. package/dist/web/_app/immutable/chunks/{14Ks5neZ.js → CO4LsWNt.js} +1 -1
  58. package/dist/web/_app/immutable/chunks/{DNod4IR72.js → CXGYcrGq2.js} +1 -1
  59. package/dist/web/_app/immutable/chunks/ClxmxcVF.js +1 -0
  60. package/dist/web/_app/immutable/chunks/Co7pw0iy.js +1 -0
  61. package/dist/web/_app/immutable/chunks/Cu_13EXg.js +8 -0
  62. package/dist/web/_app/immutable/chunks/{DxmEO50C.js → Cy1yuSCN.js} +1 -1
  63. package/dist/web/_app/immutable/chunks/{_VOdw24P.js → CzT0T6FJ.js} +1 -1
  64. package/dist/web/_app/immutable/chunks/{1TGSC9bG.js → D1AVtJaJ.js} +1 -1
  65. package/dist/web/_app/immutable/chunks/{CHdVOK6e.js → D6ObqpSX.js} +1 -1
  66. package/dist/web/_app/immutable/chunks/DBMIhZt72.js +1 -0
  67. package/dist/web/_app/immutable/chunks/{CUVp7kiS.js → DFE7ldAP.js} +1 -1
  68. package/dist/web/_app/immutable/chunks/{f1DrsN7w.js → DKDDh_Qn.js} +1 -1
  69. package/dist/web/_app/immutable/chunks/DNneJjJN.js +1 -0
  70. package/dist/web/_app/immutable/chunks/{r4OgPVQH.js → DOastlFY.js} +1 -1
  71. package/dist/web/_app/immutable/chunks/DPHJu-Bj.js +5 -0
  72. package/dist/web/_app/immutable/chunks/{D00taIEV.js → DQKT4stP.js} +1 -1
  73. package/dist/web/_app/immutable/chunks/DQe4a6BS.js +1 -0
  74. package/dist/web/_app/immutable/chunks/{Ce2P7b-X.js → DQpUZZbo.js} +1 -1
  75. package/dist/web/_app/immutable/chunks/DVxZLm0W.js +2 -0
  76. package/dist/web/_app/immutable/chunks/{Bzy2ehL_.js → DYbq0sax.js} +1 -1
  77. package/dist/web/_app/immutable/chunks/{hlnmlkiT.js → Ddr2JLfY.js} +1 -1
  78. package/dist/web/_app/immutable/chunks/DeQkDfnZ.js +1 -0
  79. package/dist/web/_app/immutable/chunks/Df-4W2cE2.js +2 -0
  80. package/dist/web/_app/immutable/chunks/{e9oCENPh.js → Dfu55H0O.js} +1 -1
  81. package/dist/web/_app/immutable/chunks/{DDQLyGcm.js → DhERF52D.js} +1 -1
  82. package/dist/web/_app/immutable/chunks/{C2UI_KQ1.js → Dt_-Caf8.js} +1 -1
  83. package/dist/web/_app/immutable/chunks/DxS997Bi.js +1 -0
  84. package/dist/web/_app/immutable/chunks/{ChPgixWE.js → P-vv4BQi2.js} +1 -1
  85. package/dist/web/_app/immutable/chunks/{Fgrpvl6l.js → UqWZFiDw.js} +1 -1
  86. package/dist/web/_app/immutable/chunks/{DPXpm86N.js → ZhCPwTv_.js} +1 -1
  87. package/dist/web/_app/immutable/chunks/anxNIaXZ.js +1 -0
  88. package/dist/web/_app/immutable/chunks/{BO35Fv1U.js → dTznEU-N.js} +4 -4
  89. package/dist/web/_app/immutable/chunks/{wY5DYt0W.js → iqOW8ttz.js} +1 -1
  90. package/dist/web/_app/immutable/chunks/{1h1Q-dyp.js → j7kuJOP8.js} +1 -1
  91. package/dist/web/_app/immutable/chunks/{IXuV70l3.js → mbVGz96e.js} +1 -1
  92. package/dist/web/_app/immutable/chunks/rmFsC7j32.js +1 -0
  93. package/dist/web/_app/immutable/chunks/{CLBsX7ax.js → sdhQg9Jq.js} +1 -1
  94. package/dist/web/_app/immutable/entry/{app.DbP3pP9I.js → app.BUM2MbTk.js} +2 -2
  95. package/dist/web/_app/immutable/entry/start.BObvxb93.js +1 -0
  96. package/dist/web/_app/immutable/nodes/{0.CirGvHiz.js → 0.BMXqKYhl.js} +2 -2
  97. package/dist/web/_app/immutable/nodes/{1.kTjENnhN.js → 1.QaGjZyIl.js} +1 -1
  98. package/dist/web/_app/immutable/nodes/10.F1nNX_z9.js +1 -0
  99. package/dist/web/_app/immutable/nodes/{11.DIQCUxzh.js → 11.BfZsU6Vh.js} +1 -1
  100. package/dist/web/_app/immutable/nodes/{12.DOlW7sto.js → 12.C5hRguPI.js} +1 -1
  101. package/dist/web/_app/immutable/nodes/{13.qikQg_xf.js → 13.BqOI1wyC.js} +1 -1
  102. package/dist/web/_app/immutable/nodes/2.CCWtRh8O.js +130 -0
  103. package/dist/web/_app/immutable/nodes/3.DDPCGvvJ.js +2 -0
  104. package/dist/web/_app/immutable/nodes/4.BhR6-Pss.js +11 -0
  105. package/dist/web/_app/immutable/nodes/5.DkAirE7a.js +1 -0
  106. package/dist/web/_app/immutable/nodes/{6.C1tDoWWj.js → 6.CF014BWC.js} +1 -1
  107. package/dist/web/_app/immutable/nodes/{7.ZR0ZhRen.js → 7.B6NRypBk.js} +1 -1
  108. package/dist/web/_app/immutable/nodes/{8.DUZm9iwH.js → 8.B4moM7KQ.js} +1 -1
  109. package/dist/web/_app/immutable/nodes/{9.BuV4JbTd.js → 9.DVcGpCuy.js} +1 -1
  110. package/dist/web/_app/version.json +1 -1
  111. package/dist/web/index.html +16 -16
  112. package/package.json +1 -1
  113. package/dist/web/_app/immutable/assets/0.DNW-Nd5m.css +0 -2
  114. package/dist/web/_app/immutable/assets/2.R677OFd1.css +0 -1
  115. package/dist/web/_app/immutable/chunks/-jdP5JqO.js +0 -5
  116. package/dist/web/_app/immutable/chunks/B-z4a3eR.js +0 -1
  117. package/dist/web/_app/immutable/chunks/B6o1oq9U2.js +0 -2
  118. package/dist/web/_app/immutable/chunks/BDCOho_G.js +0 -1
  119. package/dist/web/_app/immutable/chunks/Be0wk0_s2.js +0 -1
  120. package/dist/web/_app/immutable/chunks/C7o6G8Ak2.js +0 -1
  121. package/dist/web/_app/immutable/chunks/CHRfvdkq2.js +0 -1
  122. package/dist/web/_app/immutable/chunks/CSWuNPeq.js +0 -1
  123. package/dist/web/_app/immutable/chunks/CTFOXiYo.js +0 -1
  124. package/dist/web/_app/immutable/chunks/CXYTfe_P2.js +0 -1
  125. package/dist/web/_app/immutable/chunks/DBATbpMA.js +0 -2
  126. package/dist/web/_app/immutable/chunks/DDzRa0WU.js +0 -1
  127. package/dist/web/_app/immutable/chunks/DTnTQDdN.js +0 -1
  128. package/dist/web/_app/immutable/chunks/DYG9AA8k.js +0 -8
  129. package/dist/web/_app/immutable/chunks/DhikmMsg2.js +0 -1
  130. package/dist/web/_app/immutable/chunks/Pr10v8I7.js +0 -1
  131. package/dist/web/_app/immutable/chunks/q69OiNAp.js +0 -1
  132. package/dist/web/_app/immutable/entry/start.Cl9IsfI4.js +0 -1
  133. package/dist/web/_app/immutable/nodes/10.5xATla_3.js +0 -1
  134. package/dist/web/_app/immutable/nodes/2.CiklFX4J.js +0 -130
  135. package/dist/web/_app/immutable/nodes/3.Byts3_Iy.js +0 -2
  136. package/dist/web/_app/immutable/nodes/4.BWfkHbqv.js +0 -11
  137. package/dist/web/_app/immutable/nodes/5.tH1sz1qb.js +0 -1
  138. /package/dist/web/_app/immutable/chunks/{D5VdX2NC.js → CQwUl_3l.js} +0 -0
  139. /package/dist/web/_app/immutable/chunks/{EdMwt2az.js → CvZmtMIf.js} +0 -0
  140. /package/dist/web/_app/immutable/chunks/{CUyyAHze.js → Dn6yJ9ZG.js} +0 -0
  141. /package/dist/web/_app/immutable/chunks/{DXeZwJCC2.js → rsjIo1y12.js} +0 -0
@@ -203,11 +203,19 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
203
203
  const memberCount = authService.getFolderMembers(r.name).length;
204
204
  const callerRole = user ? authService.getFolderRole(user.id, r.name) : null;
205
205
  let entityTypes = [];
206
+ // `folders` carries the per-folder display_name override (when one
207
+ // exists in the folder's .studiograph-folder.json sidecar); the client
208
+ // falls back to toDisplayName(slug) when display_name is absent.
206
209
  let folders = [];
207
210
  try {
208
211
  const graph = workspaceManager.getGraph(r.name);
209
212
  entityTypes = graph.listEntityTypes();
210
- folders = graph.listFolders().map(f => f.path);
213
+ folders = graph.listFolders().map(f => {
214
+ const entry = { path: f.path };
215
+ if (f.display_name)
216
+ entry.display_name = f.display_name;
217
+ return entry;
218
+ });
211
219
  }
212
220
  catch { /* repo not mounted */ }
213
221
  return {
@@ -902,6 +910,48 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
902
910
  return reply.status(400).send({ error: err.message });
903
911
  }
904
912
  });
913
+ // POST /api/repos/:repo/entities/bulk-delete — delete many entities in
914
+ // one git transaction + one WS broadcast. Replaces N parallel
915
+ // DELETE /entities/:type/:id calls, which on a 500-entry bulk delete
916
+ // produced 500 git commits + 500 entity_change broadcasts and froze
917
+ // the UI for minutes.
918
+ fastify.post('/api/repos/:repo/entities/bulk-delete', async (req, reply) => {
919
+ const { repo } = req.params;
920
+ if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
921
+ return reply.status(404).send({ error: `Folder “${repo}” not found` });
922
+ }
923
+ const body = req.body;
924
+ if (!body?.entities || !Array.isArray(body.entities)) {
925
+ return reply.status(400).send({ error: 'Expected { entities: [{ entityType, entityId }] }' });
926
+ }
927
+ let graph;
928
+ try {
929
+ graph = workspaceManager.getGraph(repo);
930
+ }
931
+ catch (err) {
932
+ return reply.status(404).send({ error: err.message });
933
+ }
934
+ try {
935
+ const result = await graph.bulkDelete(body.entities.map(e => ({ entityType: e.entityType, entityId: e.entityId })), {
936
+ user: reqGitUser(req),
937
+ commitMessage: `Bulk delete ${body.entities.length} ${body.entities.length === 1 ? 'entry' : 'entries'} from web UI`,
938
+ });
939
+ if (result.deleted.length > 0) {
940
+ wsHub?.broadcast({
941
+ type: 'repo_sync', repo,
942
+ actor: req.user?.displayName ?? 'API',
943
+ source: 'api', timestamp: new Date().toISOString(),
944
+ });
945
+ }
946
+ return reply.send({
947
+ deleted: result.deleted.length,
948
+ errors: result.errors,
949
+ });
950
+ }
951
+ catch (err) {
952
+ return reply.status(400).send({ error: err.message });
953
+ }
954
+ });
905
955
  // POST /api/repos/:repo/entities — create a new entity
906
956
  fastify.post('/api/repos/:repo/entities', async (req, reply) => {
907
957
  const { repo } = req.params;
@@ -982,9 +1032,11 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
982
1032
  }
983
1033
  });
984
1034
  // POST /api/repos/:repo/folders — create a folder (with .gitkeep). Auto-creates parents.
1035
+ // Optional display_name is written to the folder's .studiograph-folder.json
1036
+ // sidecar when set; otherwise the client falls back to toDisplayName(slug).
985
1037
  fastify.post('/api/repos/:repo/folders', async (req, reply) => {
986
1038
  const { repo } = req.params;
987
- const { path } = req.body ?? {};
1039
+ const { path, display_name } = req.body ?? {};
988
1040
  if (!path || typeof path !== 'string') {
989
1041
  return reply.status(400).send({ error: 'path is required' });
990
1042
  }
@@ -999,17 +1051,25 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
999
1051
  return reply.status(404).send({ error: err.message });
1000
1052
  }
1001
1053
  try {
1002
- await graph.createFolder(path, undefined, reqGitUser(req));
1054
+ await graph.createFolder(path, undefined, reqGitUser(req), {
1055
+ displayName: display_name,
1056
+ });
1003
1057
  return reply.status(201).send({ success: true, path });
1004
1058
  }
1005
1059
  catch (err) {
1006
1060
  return reply.status(400).send({ error: err.message });
1007
1061
  }
1008
1062
  });
1009
- // PATCH /api/repos/:repo/folders — rename / move a folder within the repo.
1063
+ // PATCH /api/repos/:repo/folders — rename / move a folder within the repo,
1064
+ // optionally updating its display_name in the same commit. Three shapes:
1065
+ // { from, to } — slug-only rename / move
1066
+ // { from, to, display_name } — slug change + display update
1067
+ // { from, to: from, display_name } — display-only update (no FS move)
1068
+ // `display_name: ""` (or null) clears the override and reverts to the slug
1069
+ // fallback.
1010
1070
  fastify.patch('/api/repos/:repo/folders', async (req, reply) => {
1011
1071
  const { repo } = req.params;
1012
- const { from, to } = req.body ?? {};
1072
+ const { from, to, display_name } = req.body ?? {};
1013
1073
  if (!from || !to) {
1014
1074
  return reply.status(400).send({ error: 'from and to are required' });
1015
1075
  }
@@ -1026,7 +1086,9 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
1026
1086
  try {
1027
1087
  if (sessionManager)
1028
1088
  await sessionManager.flush(repo);
1029
- await graph.renameFolder(from, to, undefined, reqGitUser(req));
1089
+ await graph.renameFolder(from, to, undefined, reqGitUser(req), {
1090
+ displayName: display_name === undefined ? undefined : display_name,
1091
+ });
1030
1092
  return reply.send({ success: true, from, to });
1031
1093
  }
1032
1094
  catch (err) {
@@ -1091,7 +1153,18 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
1091
1153
  const committed = await sessionManager.flush(repo);
1092
1154
  return reply.send({ committed });
1093
1155
  });
1094
- // POST /api/repos/:repo/import — import files via agent processing
1156
+ // POST /api/repos/:repo/import — import files into a folder.
1157
+ //
1158
+ // Routing by extension:
1159
+ // .md / .txt / .csv → direct write via importFile() — no LLM
1160
+ // round-trip, preserves the source faithfully. Markdown frontmatter
1161
+ // (entity_type, entity_id) is honored when present; otherwise
1162
+ // sensible defaults.
1163
+ // .pdf + opaque binaries → asset wrapper via uploadAsset() —
1164
+ // creates an `asset` entity, stores the binary in R2 or the local
1165
+ // volume, writes a PDF text sidecar for vector indexing.
1166
+ // .docx → text extraction + agent capture
1167
+ // pipeline (until the mammoth → markdown workstream lands).
1095
1168
  fastify.post('/api/repos/:repo/import', async (req, reply) => {
1096
1169
  const { repo } = req.params;
1097
1170
  if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
@@ -1101,15 +1174,102 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
1101
1174
  if (!body?.files || !Array.isArray(body.files)) {
1102
1175
  return reply.status(400).send({ error: 'Expected { files: [{ filename, content }] }' });
1103
1176
  }
1177
+ // Reject paths with traversal segments; importFile slugifies the rest.
1178
+ // Empty / undefined = repo root (existing behavior).
1179
+ const targetFolderPath = (body.targetFolderPath ?? '').trim();
1180
+ if (targetFolderPath.split('/').some(seg => seg === '..' || seg === '.')) {
1181
+ return reply.status(400).send({ error: 'Invalid targetFolderPath: contains traversal segments' });
1182
+ }
1104
1183
  const captureRepo = body.agentDecides ? undefined : repo;
1184
+ const gitUser = reqGitUser(req);
1105
1185
  try {
1106
- const { extractText, isSupportedFile } = await import('../../services/import-service.js');
1186
+ const { extractText, isSupportedFile, importFile } = await import('../../services/import-service.js');
1187
+ const { isAssetUploadExtension } = await import('../../services/assets/base.js');
1188
+ const { uploadAsset } = await import('../../services/asset-upload-service.js');
1189
+ const { extname } = await import('path');
1107
1190
  const results = [];
1191
+ // Direct-write extensions never need an LLM. Verbatim preservation —
1192
+ // markdown stays markdown, CSV stays CSV.
1193
+ const DIRECT_WRITE_EXTS = new Set(['.md', '.txt', '.csv']);
1108
1194
  const CONCURRENCY = 3;
1109
- async function processFile(file) {
1110
- if (!file.filename || !isSupportedFile(file.filename)) {
1111
- return { filename: file.filename, status: 'error', error: `Unsupported file: ${file.filename}` };
1195
+ async function processDirect(file) {
1196
+ let graph;
1197
+ try {
1198
+ graph = workspaceManager.getGraph(repo);
1199
+ }
1200
+ catch (err) {
1201
+ return { filename: file.filename, status: 'error', error: err.message ?? 'Folder not found' };
1202
+ }
1203
+ const result = await importFile(graph, file.filename, file.content, {
1204
+ user: gitUser,
1205
+ // Caller-chosen destination subfolder; importFile combines it
1206
+ // with each file's own relative directory.
1207
+ folderPath: targetFolderPath || undefined,
1208
+ // Defer commits — we batch them into one transaction at the
1209
+ // end of the request so a 500-file import is one git commit
1210
+ // instead of 500. Per-file commits dominated wall time
1211
+ // (~50-200ms each) before this; batching collapses that to
1212
+ // one commit for the whole batch.
1213
+ skipCommit: true,
1214
+ });
1215
+ if (result.status === 'created' && result.path) {
1216
+ return {
1217
+ filename: file.filename,
1218
+ status: 'created',
1219
+ entities: [{ entityId: result.entityId, entityType: result.entityType }],
1220
+ entry: {
1221
+ operation: 'create',
1222
+ entityType: result.entityType,
1223
+ entityId: result.entityId,
1224
+ filePath: result.path,
1225
+ },
1226
+ sidecarPaths: result.sidecarPaths,
1227
+ };
1228
+ }
1229
+ return { filename: file.filename, status: result.status, error: result.error };
1230
+ }
1231
+ async function processAsset(file) {
1232
+ let graph;
1233
+ try {
1234
+ graph = workspaceManager.getGraph(repo);
1235
+ }
1236
+ catch (err) {
1237
+ return { filename: file.filename, status: 'error', error: err.message ?? 'Folder not found' };
1112
1238
  }
1239
+ try {
1240
+ const buffer = Buffer.from(file.content, 'base64');
1241
+ const result = await uploadAsset({
1242
+ graph,
1243
+ filename: file.filename,
1244
+ buffer,
1245
+ folderPath: targetFolderPath || undefined,
1246
+ user: gitUser,
1247
+ workspacePath: graph.getWorkspaceRoot(),
1248
+ // Defer commits — the dispatcher's batched commitPending
1249
+ // call at the bottom of the route covers every entry in
1250
+ // the same transaction.
1251
+ skipCommit: true,
1252
+ });
1253
+ return {
1254
+ filename: file.filename,
1255
+ status: 'created',
1256
+ entities: [{ entityId: result.entityId, entityType: result.entityType }],
1257
+ entry: {
1258
+ operation: 'create',
1259
+ entityType: 'asset',
1260
+ entityId: result.entityId,
1261
+ filePath: result.filePath,
1262
+ },
1263
+ // PDF text sidecars: include in extraPaths so they land in
1264
+ // the same commit as the entity files.
1265
+ sidecarPaths: result.sidecarPath ? [result.sidecarPath] : undefined,
1266
+ };
1267
+ }
1268
+ catch (err) {
1269
+ return { filename: file.filename, status: 'error', error: err.message ?? 'Asset upload failed' };
1270
+ }
1271
+ }
1272
+ async function processViaAgent(file) {
1113
1273
  try {
1114
1274
  const text = await extractText(file.filename, file.content);
1115
1275
  if (!text) {
@@ -1138,14 +1298,68 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
1138
1298
  return { filename: file.filename, status: 'error', error: err.message ?? 'Processing failed' };
1139
1299
  }
1140
1300
  }
1301
+ async function processFile(file) {
1302
+ if (!file.filename) {
1303
+ return { filename: file.filename, status: 'error', error: 'Missing filename' };
1304
+ }
1305
+ const ext = extname(file.filename).toLowerCase();
1306
+ // Direct-write text formats first — never collide with asset
1307
+ // detection. Then asset extensions (images, PDF, design files,
1308
+ // archives, …). Then the agent-extraction fallback for .docx
1309
+ // until the mammoth workstream lands. Anything else: reject.
1310
+ if (DIRECT_WRITE_EXTS.has(ext))
1311
+ return processDirect(file);
1312
+ if (isAssetUploadExtension(file.filename))
1313
+ return processAsset(file);
1314
+ if (isSupportedFile(file.filename))
1315
+ return processViaAgent(file);
1316
+ return { filename: file.filename, status: 'error', error: `Unsupported file: ${file.filename}` };
1317
+ }
1141
1318
  // Process files in batches of CONCURRENCY
1142
1319
  for (let i = 0; i < body.files.length; i += CONCURRENCY) {
1143
1320
  const batch = body.files.slice(i, i + CONCURRENCY);
1144
1321
  const batchResults = await Promise.all(batch.map(processFile));
1145
1322
  results.push(...batchResults);
1146
1323
  }
1324
+ // Direct-write path deferred git commits — flush them all in one
1325
+ // transaction now. Collapses N per-file commits into one batch
1326
+ // commit (~50ms vs N×50ms). Agent-path files committed inline via
1327
+ // /api/capture so they're not in this set.
1328
+ const pendingEntries = results
1329
+ .map(r => r.entry)
1330
+ .filter((e) => Boolean(e));
1331
+ // Collect every sidecar path produced by the import — these stage in
1332
+ // the same commit as the entity files, so the folder display name
1333
+ // and the entries it labels land atomically. De-dup because two
1334
+ // imported files inside the same Schema_OS/widgets/ would otherwise
1335
+ // produce duplicate sidecar entries.
1336
+ const sidecarPaths = Array.from(new Set(results.flatMap(r => r.sidecarPaths ?? [])));
1337
+ if (pendingEntries.length > 0) {
1338
+ try {
1339
+ const graph = workspaceManager.getGraph(repo);
1340
+ await graph.commitPending(pendingEntries, {
1341
+ commitMessage: `Import ${pendingEntries.length} ${pendingEntries.length === 1 ? 'entry' : 'entries'} from web UI`,
1342
+ user: gitUser,
1343
+ extraPaths: sidecarPaths.length > 0 ? sidecarPaths : undefined,
1344
+ });
1345
+ }
1346
+ catch (err) {
1347
+ console.warn(`[import] batch commit failed for ${repo}: ${err instanceof Error ? err.message : err}`);
1348
+ }
1349
+ }
1147
1350
  const created = results.filter(r => r.status === 'created').length;
1148
1351
  const entityCount = results.reduce((sum, r) => sum + (r.entities?.length ?? 0), 0);
1352
+ // Single repo_sync broadcast for the whole import. The realtime
1353
+ // store's repo_sync handler refreshes the repo's entity list once
1354
+ // — vs. one entity_change per file, which would do N re-renders
1355
+ // on every connected client and freeze the UI for large imports.
1356
+ if (created > 0) {
1357
+ wsHub?.broadcast({
1358
+ type: 'repo_sync', repo,
1359
+ actor: req.user?.displayName ?? 'API',
1360
+ source: 'api', timestamp: new Date().toISOString(),
1361
+ });
1362
+ }
1149
1363
  return {
1150
1364
  success: true,
1151
1365
  imported: created,
@@ -1204,41 +1418,79 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
1204
1418
  });
1205
1419
  }
1206
1420
  });
1207
- // POST /api/repos/:repo/import-imagesimport images as reference entities
1208
- fastify.post('/api/repos/:repo/import-images', { bodyLimit: 50 * 1024 * 1024 }, async (req, reply) => {
1421
+ // POST /api/repos/:repo/assetsgeneric asset upload.
1422
+ //
1423
+ // Multipart `file` field. Creates a wrapper `asset` entity whose
1424
+ // frontmatter points at the stored binary (R2 or local volume) via
1425
+ // `asset_url`. PDFs additionally get a text sidecar at
1426
+ // `<workspace>/.studiograph/assets-text/asset/<id>.txt` so the vector
1427
+ // indexer can RAG them without bloating the markdown body. See
1428
+ // docs/asset-architecture.md.
1429
+ fastify.post('/api/repos/:repo/assets', async (req, reply) => {
1209
1430
  const { repo } = req.params;
1210
1431
  if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
1211
- return reply.status(403).send({ error: 'Access denied' });
1432
+ return reply.status(404).send({ error: `Folder "${repo}" not found` });
1212
1433
  }
1213
- const body = req.body;
1214
- if (!body?.files || !Array.isArray(body.files)) {
1215
- return reply.status(400).send({ error: 'Expected { files: [{ filename, content }] }' });
1434
+ let graph;
1435
+ try {
1436
+ graph = workspaceManager.getGraph(repo);
1216
1437
  }
1438
+ catch (err) {
1439
+ return reply.status(404).send({ error: err.message });
1440
+ }
1441
+ // Per-route fileSize override — the global multipart limit (10MB) is
1442
+ // tuned for inline image embeds; full asset uploads accept up to the
1443
+ // 100MB cap enforced inside AssetService.upload().
1444
+ let file;
1217
1445
  try {
1218
- const { importImages } = await import('../../services/image-import-service.js');
1219
- const graph = workspaceManager.getGraph(repo);
1220
- const user = reqGitUser(req);
1221
- const result = await importImages(graph, body.files, user);
1222
- // Single git commit for all created entities
1223
- if (result.created > 0) {
1224
- const git = graph.getGitService();
1225
- if (git) {
1226
- const commitUser = user ?? { id: 'import', name: 'Import', email: 'import@studiograph' };
1227
- git.commitAll(`Import ${result.created} image${result.created !== 1 ? 's' : ''} as references`, commitUser);
1228
- }
1229
- }
1230
- return {
1231
- success: true,
1232
- created: result.created,
1233
- skipped: result.skipped,
1234
- errors: result.errors,
1235
- };
1446
+ file = await req.file({ limits: { fileSize: 100 * 1024 * 1024 } });
1236
1447
  }
1237
1448
  catch (err) {
1238
- return reply.status(400).send({
1239
- success: false,
1240
- error: err instanceof Error ? err.message : 'Unknown error',
1449
+ return reply.status(400).send({ error: err?.message ?? 'Invalid multipart upload' });
1450
+ }
1451
+ if (!file)
1452
+ return reply.status(400).send({ error: 'No file uploaded' });
1453
+ const buffer = await file.toBuffer();
1454
+ const folderPath = (req.query.folder ?? '').trim();
1455
+ if (folderPath.split('/').some(seg => seg === '..' || seg === '.')) {
1456
+ return reply.status(400).send({ error: 'Invalid folder: contains traversal segments' });
1457
+ }
1458
+ try {
1459
+ const { uploadAsset } = await import('../../services/asset-upload-service.js');
1460
+ const result = await uploadAsset({
1461
+ graph,
1462
+ filename: file.filename,
1463
+ buffer,
1464
+ folderPath: folderPath || undefined,
1465
+ name: req.query.name?.trim() || undefined,
1466
+ user: reqGitUser(req),
1467
+ workspacePath: graph.getWorkspaceRoot(),
1241
1468
  });
1469
+ wsHub?.broadcast({
1470
+ type: 'repo_sync',
1471
+ repo,
1472
+ actor: req.user?.displayName ?? 'API',
1473
+ source: 'api',
1474
+ timestamp: new Date().toISOString(),
1475
+ });
1476
+ return reply.send({
1477
+ success: true,
1478
+ entity: {
1479
+ entityId: result.entityId,
1480
+ entityType: result.entityType,
1481
+ asset_url: result.asset_url,
1482
+ asset_kind: result.asset_kind,
1483
+ asset_size: result.asset_size,
1484
+ pages: result.pages,
1485
+ },
1486
+ });
1487
+ }
1488
+ catch (err) {
1489
+ const msg = err instanceof Error ? err.message : String(err);
1490
+ const status = msg.includes('not configured') ? 501
1491
+ : msg.includes('Unsupported asset extension') ? 415
1492
+ : 400;
1493
+ return reply.status(status).send({ success: false, error: msg });
1242
1494
  }
1243
1495
  });
1244
1496
  }