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
@@ -20,6 +20,10 @@ export interface GraphConfig {
20
20
  schemaRegistry?: SchemaRegistry;
21
21
  /** When true, all git operations are skipped. Used for the private/ graph. */
22
22
  noGit?: boolean;
23
+ /** Workspace root path. Used to anchor cross-repo artefacts like the
24
+ * PDF text sidecar at `<workspaceRoot>/.studiograph/assets-text/...`.
25
+ * Optional — when undefined, sidecar cleanup is a no-op. */
26
+ workspaceRoot?: string;
23
27
  }
24
28
  export interface EntityFile {
25
29
  id: string;
@@ -35,6 +39,10 @@ export interface FolderEntry {
35
39
  path: string;
36
40
  /** Last segment of the path. */
37
41
  name: string;
42
+ /** Optional human-readable label. Set only when an explicit override exists
43
+ * in the folder's `.studiograph-folder.json` sidecar; clients fall back to
44
+ * toDisplayName(name) when undefined. */
45
+ display_name?: string;
38
46
  }
39
47
  export interface FolderDeleteResult {
40
48
  entriesDeleted: number;
@@ -89,9 +97,15 @@ export declare class BaseGraphManager {
89
97
  private csvService;
90
98
  private vectorService?;
91
99
  private schemaRegistry?;
100
+ private workspaceRoot?;
92
101
  constructor(config: GraphConfig);
93
102
  /** Expose git service for deferred commit operations (SessionManager). */
94
103
  getGitService(): GitService | undefined;
104
+ /** Workspace root passed in via GraphConfig. Used by the asset upload
105
+ * service to anchor PDF text sidecars at workspace level (so all repos
106
+ * share the same `assets-text/` tree, mirroring the shared
107
+ * `.studiograph/assets/` directory the local backend uses). */
108
+ getWorkspaceRoot(): string | undefined;
95
109
  /**
96
110
  * Ensure repository exists
97
111
  */
@@ -171,6 +185,62 @@ export declare class BaseGraphManager {
171
185
  /**
172
186
  * Delete an entity
173
187
  */
188
+ /**
189
+ * Delete multiple entities in a single git transaction. Each entity's
190
+ * file is removed from disk; folder markers (.gitkeep) are added where
191
+ * the deletion left a folder empty. Then everything commits as one
192
+ * atomic operation.
193
+ *
194
+ * Compared to looping `delete()` per entity, this is a real performance
195
+ * primitive: one fork of git, one index update, one commit, one ref
196
+ * advance — instead of N. Bulk delete of a few hundred entities goes
197
+ * from minutes to seconds.
198
+ *
199
+ * Errors per entity (e.g. "not found") don't abort the batch — they're
200
+ * collected in `errors` and the rest still commit. Returns both the
201
+ * successfully-deleted set (for vector cleanup + WS broadcast) and the
202
+ * errors so the caller can surface partial failures.
203
+ */
204
+ bulkDelete(specs: Array<{
205
+ entityType: EntityType;
206
+ entityId: string;
207
+ }>, options?: {
208
+ commitMessage?: string;
209
+ user?: GitUser;
210
+ }): Promise<{
211
+ deleted: Array<{
212
+ entityType: EntityType;
213
+ entityId: string;
214
+ path: string;
215
+ }>;
216
+ errors: Array<{
217
+ entityType: EntityType;
218
+ entityId: string;
219
+ error: string;
220
+ }>;
221
+ }>;
222
+ /**
223
+ * Commit a set of already-on-disk file changes as a single git
224
+ * operation. Used by callers (e.g. the import route) that perform many
225
+ * `skipCommit: true` writes and want one transaction at the end instead
226
+ * of per-file commits.
227
+ *
228
+ * `entries` describes what each file represents so the transaction can
229
+ * be staged correctly. No-op when nothing is staged or git isn't
230
+ * available.
231
+ */
232
+ commitPending(entries: Array<{
233
+ operation: 'create' | 'update' | 'delete';
234
+ entityType: EntityType;
235
+ entityId: string;
236
+ filePath: string;
237
+ }>, options: {
238
+ commitMessage: string;
239
+ user?: GitUser;
240
+ extraPaths?: string[];
241
+ }): Promise<{
242
+ committed: boolean;
243
+ }>;
174
244
  delete(entityType: EntityType, entityId: string, commitMessage?: string, user?: GitUser): Promise<void>;
175
245
  /**
176
246
  * Rename an entity (change its ID). Preserves all data, content, and source_ref.
@@ -285,18 +355,52 @@ export declare class BaseGraphManager {
285
355
  * The repo root ("") is intentionally omitted — it's implicit.
286
356
  */
287
357
  listFolders(): FolderEntry[];
358
+ /**
359
+ * Read the display_name override for a single folder, or null if no sidecar
360
+ * exists / is unreadable. Used by mutation paths that need the current
361
+ * override before deciding whether to rewrite it.
362
+ */
363
+ readFolderDisplayName(folderPath: string): string | null;
364
+ /**
365
+ * Write or remove the per-folder display_name sidecar. Pass `null` /
366
+ * empty-string to remove (returns to slug fallback). Returns the absolute
367
+ * path of the sidecar (for git staging) when one was written or removed,
368
+ * or null when no change was needed (idempotent).
369
+ *
370
+ * Caller is responsible for committing — the helper only touches the
371
+ * filesystem. Folder must already exist; this never creates one.
372
+ */
373
+ writeFolderDisplayName(folderPath: string, displayName: string | null): string | null;
374
+ /**
375
+ * Bulk-write display_name sidecars for many folders at once. Used by the
376
+ * import path to record original-casing overrides for every nested
377
+ * directory it slugified. First-writer-wins: existing sidecars are NOT
378
+ * overwritten, so a second import doesn't clobber a user's manual rename.
379
+ *
380
+ * Returns the list of sidecar paths actually written (for git staging).
381
+ * Skipped paths (folder missing, slug already matches display, sidecar
382
+ * already exists) are quietly omitted.
383
+ */
384
+ recordFolderDisplays(entries: Array<{
385
+ path: string;
386
+ display_name: string;
387
+ }>): string[];
288
388
  /**
289
389
  * Create a folder at the given path (mkdir -p semantics) and drop a
290
390
  * .gitkeep so git tracks it even when empty. Validates the path. No-op if
291
391
  * the folder already exists; .gitkeep is added if missing.
292
392
  */
293
- createFolder(folderPath: string, commitMessage?: string, user?: GitUser): Promise<void>;
393
+ createFolder(folderPath: string, commitMessage?: string, user?: GitUser, options?: {
394
+ displayName?: string;
395
+ }): Promise<void>;
294
396
  /**
295
397
  * Rename / move a folder within the repo. Moves the entire subtree
296
398
  * atomically via filesystem rename. Refuses if the target path is already
297
399
  * occupied.
298
400
  */
299
- renameFolder(fromPath: string, toPath: string, commitMessage?: string, user?: GitUser): Promise<void>;
401
+ renameFolder(fromPath: string, toPath: string, commitMessage?: string, user?: GitUser, options?: {
402
+ displayName?: string | null;
403
+ }): Promise<void>;
300
404
  /**
301
405
  * Move a folder (and its full subtree) into a different repo at `targetFolderPath`.
302
406
  *
@@ -5,7 +5,7 @@
5
5
  * Integrates Git, Markdown, and Asset services.
6
6
  */
7
7
  import { join, dirname, relative } from 'path';
8
- import { existsSync, readdirSync, rmSync, statSync, writeFileSync, mkdirSync } from 'fs';
8
+ import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, mkdirSync } from 'fs';
9
9
  import { GitService } from '../services/git.js';
10
10
  import { MarkdownService } from '../services/markdown.js';
11
11
  import { AssetService } from '../services/assets/index.js';
@@ -15,6 +15,34 @@ import { folderPathFromFile, normalizeFolderPath, validateFolderPath, InvalidFol
15
15
  import { z } from 'zod';
16
16
  /** Names skipped during recursive repo walks. */
17
17
  const WALK_IGNORE = new Set(['.git', '.studiograph', 'node_modules']);
18
+ /**
19
+ * Per-folder sidecar carrying optional display_name override. Lives inside
20
+ * the folder itself so it travels atomically with FS rename / move / cross-repo
21
+ * transfer — the same way an entity .md file's frontmatter travels with the
22
+ * file. Sparse: only written when display_name differs from the slug fallback
23
+ * (toDisplayName), so kebab-named folders never produce a sidecar.
24
+ */
25
+ const FOLDER_SIDECAR = '.studiograph-folder.json';
26
+ /**
27
+ * Read the display_name override from a folder's sidecar. Returns null when
28
+ * the sidecar is absent, unreadable, or malformed — drift is tolerated, the
29
+ * caller falls back to toDisplayName(slug) just like a folder with no
30
+ * override at all.
31
+ */
32
+ function readFolderSidecar(absDir) {
33
+ const sidecarPath = join(absDir, FOLDER_SIDECAR);
34
+ if (!existsSync(sidecarPath))
35
+ return null;
36
+ try {
37
+ const raw = readFileSync(sidecarPath, 'utf-8');
38
+ const parsed = JSON.parse(raw);
39
+ const display = parsed?.display_name;
40
+ return typeof display === 'string' && display.trim() ? display.trim() : null;
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
18
46
  export class BaseGraphManager {
19
47
  repoPath;
20
48
  repoName;
@@ -26,6 +54,7 @@ export class BaseGraphManager {
26
54
  csvService;
27
55
  vectorService;
28
56
  schemaRegistry;
57
+ workspaceRoot;
29
58
  constructor(config) {
30
59
  this.repoPath = config.repoPath;
31
60
  this.repoName = config.repoName;
@@ -33,6 +62,7 @@ export class BaseGraphManager {
33
62
  this.noGit = config.noGit ?? false;
34
63
  this.vectorService = config.vectorService;
35
64
  this.schemaRegistry = config.schemaRegistry;
65
+ this.workspaceRoot = config.workspaceRoot;
36
66
  // Initialize services
37
67
  if (!this.noGit) {
38
68
  this.gitService = new GitService(this.repoPath);
@@ -50,6 +80,13 @@ export class BaseGraphManager {
50
80
  getGitService() {
51
81
  return this.gitService;
52
82
  }
83
+ /** Workspace root passed in via GraphConfig. Used by the asset upload
84
+ * service to anchor PDF text sidecars at workspace level (so all repos
85
+ * share the same `assets-text/` tree, mirroring the shared
86
+ * `.studiograph/assets/` directory the local backend uses). */
87
+ getWorkspaceRoot() {
88
+ return this.workspaceRoot;
89
+ }
53
90
  /**
54
91
  * Ensure repository exists
55
92
  */
@@ -463,11 +500,116 @@ export class BaseGraphManager {
463
500
  /**
464
501
  * Delete an entity
465
502
  */
503
+ /**
504
+ * Delete multiple entities in a single git transaction. Each entity's
505
+ * file is removed from disk; folder markers (.gitkeep) are added where
506
+ * the deletion left a folder empty. Then everything commits as one
507
+ * atomic operation.
508
+ *
509
+ * Compared to looping `delete()` per entity, this is a real performance
510
+ * primitive: one fork of git, one index update, one commit, one ref
511
+ * advance — instead of N. Bulk delete of a few hundred entities goes
512
+ * from minutes to seconds.
513
+ *
514
+ * Errors per entity (e.g. "not found") don't abort the batch — they're
515
+ * collected in `errors` and the rest still commit. Returns both the
516
+ * successfully-deleted set (for vector cleanup + WS broadcast) and the
517
+ * errors so the caller can surface partial failures.
518
+ */
519
+ async bulkDelete(specs, options) {
520
+ const deleted = [];
521
+ const errors = [];
522
+ const transaction = this.gitService?.createTransaction();
523
+ for (const { entityType, entityId } of specs) {
524
+ try {
525
+ const existing = this.get(entityType, entityId);
526
+ rmSync(existing.path, { force: true });
527
+ // Folder marker if the deletion emptied the folder — same lifecycle
528
+ // semantics as single delete().
529
+ const addedMarker = this.ensureFolderMarker(dirname(existing.path));
530
+ if (transaction) {
531
+ transaction.add({ operation: 'delete', entityType, entityId, filePath: existing.path });
532
+ if (addedMarker) {
533
+ transaction.add({ operation: 'create', entityType, entityId, filePath: addedMarker });
534
+ }
535
+ }
536
+ deleted.push({ entityType, entityId, path: existing.path });
537
+ }
538
+ catch (err) {
539
+ errors.push({
540
+ entityType,
541
+ entityId,
542
+ error: err instanceof Error ? err.message : String(err),
543
+ });
544
+ }
545
+ }
546
+ this.invalidateIndex();
547
+ if (transaction && deleted.length > 0) {
548
+ const message = options?.commitMessage
549
+ ?? `Delete ${deleted.length} ${deleted.length === 1 ? 'entry' : 'entries'} from web UI`;
550
+ await transaction.commit(options?.user ?? this.gitUser, message);
551
+ }
552
+ // Vector index cleanup, fire-and-forget (matches single delete() behavior).
553
+ for (const { entityType, entityId } of deleted) {
554
+ this.vectorService?.delete(this.repoName, entityType, entityId).catch(err => console.warn(`[vector] delete failed for ${entityType}/${entityId}:`, err.message));
555
+ }
556
+ return { deleted, errors };
557
+ }
558
+ /**
559
+ * Commit a set of already-on-disk file changes as a single git
560
+ * operation. Used by callers (e.g. the import route) that perform many
561
+ * `skipCommit: true` writes and want one transaction at the end instead
562
+ * of per-file commits.
563
+ *
564
+ * `entries` describes what each file represents so the transaction can
565
+ * be staged correctly. No-op when nothing is staged or git isn't
566
+ * available.
567
+ */
568
+ async commitPending(entries, options) {
569
+ if (!this.gitService)
570
+ return { committed: false };
571
+ if (entries.length === 0 && !options.extraPaths?.length)
572
+ return { committed: false };
573
+ const transaction = this.gitService.createTransaction();
574
+ for (const e of entries)
575
+ transaction.add(e);
576
+ // Auxiliary paths (folder-display sidecars from the import flow) are
577
+ // staged alongside the entity files so they land in the same commit.
578
+ // The transaction only uses `filePath` for staging — the other fields
579
+ // are metadata, so we fill them with sentinel values.
580
+ if (options.extraPaths?.length) {
581
+ for (const filePath of options.extraPaths) {
582
+ transaction.add({
583
+ operation: 'create',
584
+ entityType: '_sidecar',
585
+ entityId: '_sidecar',
586
+ filePath,
587
+ });
588
+ }
589
+ }
590
+ await transaction.commit(options.user ?? this.gitUser, options.commitMessage);
591
+ return { committed: true };
592
+ }
466
593
  async delete(entityType, entityId, commitMessage, user) {
467
594
  // Verify entity exists
468
595
  const existing = this.get(entityType, entityId);
469
596
  // Delete the flat entity file
470
597
  rmSync(existing.path, { force: true });
598
+ // Asset entities own a binary blob (and optionally a PDF text sidecar).
599
+ // Both must be cleaned up — leaving them orphans the storage layer and
600
+ // poisons future entity_id reuse with a stale text dump. Fire-and-forget:
601
+ // the markdown is the system of record, so a stuck binary shouldn't
602
+ // block the delete commit. (Same approach as vector-index cleanup below.)
603
+ if (entityType === 'asset' && this.assetService) {
604
+ this.assetService.deleteAll('asset', entityId).catch(err => console.warn(`[asset] deleteAll failed for asset/${entityId}: ${err?.message ?? err}`));
605
+ if (this.workspaceRoot) {
606
+ const sidecarPath = join(this.workspaceRoot, '.studiograph', 'assets-text', 'asset', `${entityId}.txt`);
607
+ try {
608
+ rmSync(sidecarPath, { force: true });
609
+ }
610
+ catch { /* sidecar cleanup is best-effort */ }
611
+ }
612
+ }
471
613
  // Folder lifecycle: deleting an entry never deletes its containing folder.
472
614
  // If this was the last .md in the folder, write a .gitkeep so it stays
473
615
  // visible to listFolders() (and therefore to the sidebar). A user who
@@ -537,6 +679,18 @@ export class BaseGraphManager {
537
679
  * folder when changed to a note), they invoke move() separately.
538
680
  */
539
681
  async changeType(entityId, fromType, toType, commitMessage, user) {
682
+ // Asset entities are bound to a binary in the storage backend plus
683
+ // (for PDFs) a text sidecar. Changing TO asset would leave the entity
684
+ // without an asset_url / asset_kind / blob; changing FROM asset would
685
+ // orphan the binary and sidecar. Both directions must be a
686
+ // delete-and-recreate, not a type swap.
687
+ if (toType === 'asset' || fromType === 'asset') {
688
+ const direction = toType === 'asset' ? 'to' : 'from';
689
+ throw new Error(`Cannot change_type ${direction} 'asset' — assets are bound to a binary in storage. ` +
690
+ (toType === 'asset'
691
+ ? 'Delete this entry and re-upload the file via the asset upload route to convert it to an asset.'
692
+ : 'Delete this asset entry (which removes the binary) and create a fresh entry of the target type.'));
693
+ }
540
694
  const existing = this.get(fromType, entityId);
541
695
  // (type, id) uniqueness check — if an entry with the new type already
542
696
  // exists with this id, refuse rather than silently colliding in the index.
@@ -1064,6 +1218,7 @@ export class BaseGraphManager {
1064
1218
  if (!existsSync(this.repoPath))
1065
1219
  return [];
1066
1220
  const folders = new Set();
1221
+ const sidecarByPath = new Map();
1067
1222
  const stack = [this.repoPath];
1068
1223
  const repoRoot = this.repoPath.replace(/\/+$/, '');
1069
1224
  while (stack.length > 0) {
@@ -1076,9 +1231,12 @@ export class BaseGraphManager {
1076
1231
  continue;
1077
1232
  }
1078
1233
  let kept = false;
1234
+ let hasSidecar = false;
1079
1235
  for (const entry of entries) {
1080
1236
  if (entry === '.gitkeep')
1081
1237
  kept = true;
1238
+ if (entry === FOLDER_SIDECAR)
1239
+ hasSidecar = true;
1082
1240
  if (entry.startsWith('.'))
1083
1241
  continue;
1084
1242
  if (WALK_IGNORE.has(entry))
@@ -1096,23 +1254,113 @@ export class BaseGraphManager {
1096
1254
  }
1097
1255
  if (dir === repoRoot)
1098
1256
  continue;
1099
- // Include this directory if it has any .md files OR a .gitkeep marker.
1257
+ // Include this directory if it has any .md files OR a .gitkeep marker
1258
+ // OR a sidecar (which carries display metadata even for empty folders).
1100
1259
  const hasMarkdown = entries.some(e => e.endsWith('.md') && !e.startsWith('.'));
1101
- if (hasMarkdown || kept) {
1102
- folders.add(folderPathFromFile(join(dir, 'placeholder.md'), this.repoPath));
1260
+ if (hasMarkdown || kept || hasSidecar) {
1261
+ const folderPath = folderPathFromFile(join(dir, 'placeholder.md'), this.repoPath);
1262
+ folders.add(folderPath);
1263
+ if (hasSidecar) {
1264
+ const display = readFolderSidecar(dir);
1265
+ if (display !== null)
1266
+ sidecarByPath.set(folderPath, display);
1267
+ }
1103
1268
  }
1104
1269
  }
1105
1270
  return [...folders]
1106
1271
  .filter(p => p.length > 0)
1107
1272
  .sort()
1108
- .map(p => ({ path: p, name: p.slice(p.lastIndexOf('/') + 1) }));
1273
+ .map(p => {
1274
+ const entry = { path: p, name: p.slice(p.lastIndexOf('/') + 1) };
1275
+ const display = sidecarByPath.get(p);
1276
+ if (display)
1277
+ entry.display_name = display;
1278
+ return entry;
1279
+ });
1280
+ }
1281
+ /**
1282
+ * Read the display_name override for a single folder, or null if no sidecar
1283
+ * exists / is unreadable. Used by mutation paths that need the current
1284
+ * override before deciding whether to rewrite it.
1285
+ */
1286
+ readFolderDisplayName(folderPath) {
1287
+ const normalized = validateFolderPath(folderPath);
1288
+ if (!normalized)
1289
+ return null;
1290
+ return readFolderSidecar(join(this.repoPath, normalized));
1291
+ }
1292
+ /**
1293
+ * Write or remove the per-folder display_name sidecar. Pass `null` /
1294
+ * empty-string to remove (returns to slug fallback). Returns the absolute
1295
+ * path of the sidecar (for git staging) when one was written or removed,
1296
+ * or null when no change was needed (idempotent).
1297
+ *
1298
+ * Caller is responsible for committing — the helper only touches the
1299
+ * filesystem. Folder must already exist; this never creates one.
1300
+ */
1301
+ writeFolderDisplayName(folderPath, displayName) {
1302
+ const normalized = validateFolderPath(folderPath);
1303
+ if (!normalized) {
1304
+ throw new InvalidFolderPathError('Folder path cannot be empty');
1305
+ }
1306
+ const abs = join(this.repoPath, normalized);
1307
+ if (!existsSync(abs)) {
1308
+ throw new Error(`Folder not found: ${normalized}`);
1309
+ }
1310
+ const sidecarPath = join(abs, FOLDER_SIDECAR);
1311
+ const trimmed = displayName?.trim();
1312
+ if (!trimmed) {
1313
+ if (!existsSync(sidecarPath))
1314
+ return null;
1315
+ rmSync(sidecarPath, { force: true });
1316
+ this.invalidateIndex();
1317
+ return sidecarPath;
1318
+ }
1319
+ const existing = readFolderSidecar(abs);
1320
+ if (existing === trimmed)
1321
+ return null;
1322
+ writeFileSync(sidecarPath, JSON.stringify({ display_name: trimmed }, null, 2) + '\n', 'utf-8');
1323
+ this.invalidateIndex();
1324
+ return sidecarPath;
1325
+ }
1326
+ /**
1327
+ * Bulk-write display_name sidecars for many folders at once. Used by the
1328
+ * import path to record original-casing overrides for every nested
1329
+ * directory it slugified. First-writer-wins: existing sidecars are NOT
1330
+ * overwritten, so a second import doesn't clobber a user's manual rename.
1331
+ *
1332
+ * Returns the list of sidecar paths actually written (for git staging).
1333
+ * Skipped paths (folder missing, slug already matches display, sidecar
1334
+ * already exists) are quietly omitted.
1335
+ */
1336
+ recordFolderDisplays(entries) {
1337
+ const written = [];
1338
+ for (const entry of entries) {
1339
+ const normalized = validateFolderPath(entry.path);
1340
+ if (!normalized)
1341
+ continue;
1342
+ const abs = join(this.repoPath, normalized);
1343
+ if (!existsSync(abs))
1344
+ continue;
1345
+ const sidecarPath = join(abs, FOLDER_SIDECAR);
1346
+ if (existsSync(sidecarPath))
1347
+ continue; // first-writer-wins
1348
+ const display = entry.display_name.trim();
1349
+ if (!display)
1350
+ continue;
1351
+ writeFileSync(sidecarPath, JSON.stringify({ display_name: display }, null, 2) + '\n', 'utf-8');
1352
+ written.push(sidecarPath);
1353
+ }
1354
+ if (written.length > 0)
1355
+ this.invalidateIndex();
1356
+ return written;
1109
1357
  }
1110
1358
  /**
1111
1359
  * Create a folder at the given path (mkdir -p semantics) and drop a
1112
1360
  * .gitkeep so git tracks it even when empty. Validates the path. No-op if
1113
1361
  * the folder already exists; .gitkeep is added if missing.
1114
1362
  */
1115
- async createFolder(folderPath, commitMessage, user) {
1363
+ async createFolder(folderPath, commitMessage, user, options) {
1116
1364
  const normalized = validateFolderPath(folderPath);
1117
1365
  if (!normalized) {
1118
1366
  throw new InvalidFolderPathError('Folder path cannot be empty (use the repo root directly)');
@@ -1123,10 +1371,16 @@ export class BaseGraphManager {
1123
1371
  if (!existsSync(keep)) {
1124
1372
  writeFileSync(keep, '');
1125
1373
  }
1374
+ // Sidecar is written here (not after the commit) so it joins the same
1375
+ // commit as .gitkeep — folder creation is atomic from git's perspective.
1376
+ const sidecarPath = options?.displayName
1377
+ ? this.writeFolderDisplayName(normalized, options.displayName)
1378
+ : null;
1126
1379
  this.invalidateIndex();
1127
1380
  if (this.gitService) {
1128
1381
  const message = commitMessage || `Create folder: ${normalized}`;
1129
- this.gitService.commitFiles([keep], message, user ?? this.gitUser);
1382
+ const files = sidecarPath ? [keep, sidecarPath] : [keep];
1383
+ this.gitService.commitFiles(files, message, user ?? this.gitUser);
1130
1384
  }
1131
1385
  }
1132
1386
  /**
@@ -1134,15 +1388,24 @@ export class BaseGraphManager {
1134
1388
  * atomically via filesystem rename. Refuses if the target path is already
1135
1389
  * occupied.
1136
1390
  */
1137
- async renameFolder(fromPath, toPath, commitMessage, user) {
1391
+ async renameFolder(fromPath, toPath, commitMessage, user, options) {
1138
1392
  const fromNormalized = validateFolderPath(fromPath);
1139
1393
  const toNormalized = validateFolderPath(toPath);
1140
1394
  if (!fromNormalized)
1141
1395
  throw new InvalidFolderPathError('Source folder path required');
1142
1396
  if (!toNormalized)
1143
1397
  throw new InvalidFolderPathError('Target folder path required');
1144
- if (fromNormalized === toNormalized)
1398
+ // Display-only update: same path, just rewrite the sidecar.
1399
+ if (fromNormalized === toNormalized) {
1400
+ if (options?.displayName === undefined)
1401
+ return;
1402
+ const sidecarPath = this.writeFolderDisplayName(fromNormalized, options.displayName);
1403
+ if (sidecarPath && this.gitService) {
1404
+ const message = commitMessage || `Update folder display name: ${fromNormalized}`;
1405
+ this.gitService.commitFiles([sidecarPath], message, user ?? this.gitUser);
1406
+ }
1145
1407
  return;
1408
+ }
1146
1409
  const fromAbs = join(this.repoPath, fromNormalized);
1147
1410
  const toAbs = join(this.repoPath, toNormalized);
1148
1411
  if (!existsSync(fromAbs)) {
@@ -1154,10 +1417,18 @@ export class BaseGraphManager {
1154
1417
  mkdirSync(dirname(toAbs), { recursive: true });
1155
1418
  const { renameSync } = await import('fs');
1156
1419
  renameSync(fromAbs, toAbs);
1420
+ // The sidecar moved with the folder via FS rename. Update it now if the
1421
+ // caller passed a new display name (or `null` to clear). Idempotent — no
1422
+ // sidecar write happens when display matches the existing override.
1423
+ let sidecarPath = null;
1424
+ if (options?.displayName !== undefined) {
1425
+ sidecarPath = this.writeFolderDisplayName(toNormalized, options.displayName);
1426
+ }
1157
1427
  this.invalidateIndex();
1158
1428
  if (this.gitService) {
1159
1429
  const message = commitMessage || `Rename folder: ${fromNormalized} → ${toNormalized}`;
1160
- this.gitService.commitFiles([fromAbs, toAbs], message, user ?? this.gitUser);
1430
+ const files = sidecarPath ? [fromAbs, toAbs, sidecarPath] : [fromAbs, toAbs];
1431
+ this.gitService.commitFiles(files, message, user ?? this.gitUser);
1161
1432
  }
1162
1433
  await this.pruneEmptyAncestors(dirname(fromAbs));
1163
1434
  }